Wer ein Attribut in seiner Entity speichern möchte, das ein Set von Enum Werten enthält, kann dies in JPA sehr einfach realisieren. Manchmal fragen einen die Kollegen dann aber doch, ob es nicht andere Wege gibt, ohne eine zusätzliche Tabelle zu verwenden.
Mit Hilfe einer eigenen JPA AttributeConverter
Implementierung kann man auch dieses Problem schnell lösen.
Als Beispiel für den selbstgeschriebenen AttributConverter
sollen Attribute vom Typ Set<Wert>
konvertiert werden. Wert ist dabei eine recht einfallslose Enum.
public enum Wert { A, B, C, D, E, F, G }
Die Aufgabe des eigenen AttributConverter
ist es, ein BitSet
mit den Positionen der Enum Konstanten im Enum zu setzen. Enthält ein Set
die Konstante Wert.A
, dann wird das erste Bit gesetzt. Enthält ein Set
die Konstante Wert.E
, dann wird das fünfte Bit gesetzt. Das BitSet
wird dann in einen numerischen Type, wie Byte
, Short
oder Integer
konvertiert.
public abstract class EnumSetConverter<E extends Enum<E>, N extends Number> implements AttributeConverter<Set<E>, N> { private final E[] values; public EnumSetConverter(E[] values) { this.values = values; } private final Collector<E, ?, BitSet> COLLECTOR = Collector.of(BitSet::new, (a, b) -> a.set(b.ordinal()), (a, b) -> { a.or(b); return a; }); @Override public N convertToDatabaseColumn(Set<E> attribute) { if (attribute == null) { return null; } return map(attribute.stream().collect(COLLECTOR)); } @Override public Set<E> convertToEntityAttribute(N dbData) { if (dbData == null) { return null; } return map(dbData).stream().filter(i -> i < values.length).mapToObj(b -> values[b]).collect(toSet()); } protected abstract N map(BitSet bitSet); protected abstract BitSet map(N value); }
Der abstrakte EnumSetConverter
konvertiert zwischen einem Set
eines beliebigen Enum Typs und einer numerische Darstellung.
@Override public Set<E> convertToEntityAttribute(N dbData) { if (dbData == null) { return null; } return map(dbData).stream().mapToObj(b -> values[b]).collect(toSet()); }
Innerhalb der convertToEntityAttribute
Methode wird der numerische Wert in ein BitSet
gewandelt und dann die Positionen der gesetzten Bits auf die Enum Konstanten mit korrespondierenden Ordinalwerten gemappt. Alle Enum Konstanten werden am Ende in einer Set
Instanz gesammelt und diese zurückgegeben.
@Override public N convertToDatabaseColumn(Set<E> attribute) { if (attribute == null) { return null; } return map(attribute.stream().collect(COLLECTOR)); }
Die convertToDatabaseColumn
Methode verwendet einen eigenen Stream Collector
um aus dem Set
der Enum Konstanten ein BitSet
zu generieren. Das erzeugte Bitmap wird dann in die numerische Darstellung umgewandelt und diese zurückgegeben.
private final Collector<E, ?, BitSet> COLLECTOR = Collector.of(BitSet::new, (a, b) -> a.set(b.ordinal()), (a, b) -> { a.or(b); return a; });
Der eigene Collector
wird mit der Convenience-Methode Collector.of
erzeugt. Diese Methode wurde auch schon in den Beiträgen Einsammeln und portionieren mit Stream Collector und Aufzählungen und andere String-Konkatenationen vorgestellt. Der Akkumulator dieses Collector
ist ein BitSet
und die Methode für das Hinzufügen eines Elementes ist das Setzen der Bits an der Position des Ordinalwertes der Enum Konstante. Zwei Akkumulatoren können zusammengefügt werden, in dem die entsprechenden
Instanzen mit oder Verknüpft werden.BitSet
Da das Beispiel Enum Wert
nur sieben Elemente besitzt, reicht für dieses Enum ein Converter, der ein Byte
verarbeitet. Für größere Enums gibt es aber entsprechende Short
und Integer
basierte Konverter. Größer als Integer
muss der numerische Typ nicht sein, da ein Enum nicht mehr Konstanten enthalten kann.
public abstract class ToByteConverter<E extends Enum<E>> extends EnumSetConverter<E, Byte> { private int[] pow; public ToByteConverter(E[] values) { super(values); if (values.length > 7) { throw new IllegalArgumentException("cannot map to byte"); } pow = IntStream.range(0, values.length).map(i -> (int) Math.pow(2, i)).toArray(); } @Override protected Byte map(BitSet bitSet) { return (byte) bitSet.stream().map(i -> pow[i]).sum(); } @Override protected BitSet map(Byte value) { BigInteger bigInteger = new BigInteger(String.valueOf(value)); BitSet bitSet = new BitSet(); if (value == 0) { return bitSet; } for (int i = bigInteger.getLowestSetBit(), n = bigInteger.bitLength(); i <= n; i++) { if (bigInteger.testBit(i)) { bitSet.set(i); } } return bitSet; } }
Der ToByteConverter
erbt von EnumSetConverter
und implementiert die map
Methoden um zwischen BitSet
und Byte
Werten zu konvertieren. Außerdem prüft der Konstruktor, ob dieser Enum Typ nicht zu groß ist für eine Byte
Darstellung.
Zum Schluss fehlt nur noch die tatsächliche Converter
Implementierung für den Enum Type Wert
.
@Converter public class WertSetToByteConverter extends ToByteConverter<Wert> { public WertSetToByteConverter() { super(Wert.values()); } }
Die Klasse WertSetToByteConverter
erweitert ToByteConverter
für den Enum Wert
und ist annotiert mit @Convert
.
Zur Kontrolle der ordnungsgemäßen Verarbeitung dient ein JUnit Test, der den Converter
instanziiert und diverse Konvertierungen prüft.
@Test void testWertSetToByteConverter() { WertSetToByteConverter converter = new WertSetToByteConverter(); assertEquals((byte) 0b0001001, converter.convertToDatabaseColumn(EnumSet.of(Wert.A, Wert.D))); assertEquals((byte) 0b1110110, converter.convertToDatabaseColumn(EnumSet.complementOf(EnumSet.of(Wert.A, Wert.D)))); assertEquals((byte) 0b1111111, converter.convertToDatabaseColumn(EnumSet.allOf(Wert.class))); assertEquals(EnumSet.allOf(Wert.class), converter.convertToEntityAttribute((byte) 0b1111111)); assertEquals(Set.of(), converter.convertToEntityAttribute((byte) 0)); assertEquals(EnumSet.of(Wert.A, Wert.B), converter.convertToEntityAttribute((byte) 0b0000011)); }
Im Tests sind die byte
Konstanten in Binärdarstellung angegeben, damit man besser erkennt, welche Bits gesetzt sein sollen und welche nicht.
Wer seine Enum-Sets in Zukunft lieber als numerische Werte in seiner Entity speichern will, kann mit diesem Converter
seinen Wunsch erfüllen.