Enum Sets in JPA Entities speichern

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 BitSet Instanzen mit oder Verknüpft werden.

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.