Automatisch generierte Enum Converter

Wandlung ist notwendig wie die Erneuerung der Blätter im Frühling

Vincent van Gogh

In diesem Beitrag finden zwei meiner Steckenpferde hier im Blog zusammen. Es sind die Annotation Processors und die Enums. Beide gemeinsam können ein schon lange bestehendes Problem der Enums auf recht elegante Weise lösen.

Sollen Enums persistiert werden, dann gibt es dafür drei verschiedene Ansätze. Man kann sie über ihre Ordinalzahl oder ihren Namen speichern oder mit Hilfe eines selbstgeschriebenen

AttributeConverter
AttributeConverter in eine passende Darstellung für die Datenbank umwandeln.

Jede dieser Ansätze hat seine Nachteile. Wird die Ordinalzahl gespeichert, dann darf die Reihenfolge der Enum-Konstanten nicht verändert werden. Wird der Name gespeichert, dann darf keine Enum-Konstante umbenannt werden. Und der Nachteil des selbstgeschriebenen

AttributeConverter
AttributeConverter, ist natürlich das Schreiben des
AttributeConverter
AttributeConverter und seine Pflege. Verschiedene Möglichkeiten um diese Nachteile aufzufangen wurden bereits im Beitrag Enums für die Ewigkeit behandelt.

Der hier im Beitrag vorgestellte Ansatz, ist das automatische Generieren eines

AttributeConverter
AttributeConverter mit Hilfe eines Annotation Processors. In den Beiträgen Unterschiede finden mit dem Java Annotation Prozessor und Hamcrest Matcher Generator wurden schon Annotation Processors vorgestellt, die Klassen generierten um Java Beans zu vergleichen oder Hamrest Matcher für JUnit Tests bereitstellten.

Am folgenden JUnit Tests für den hier zu implementierenden

EnumConverterProcessor
EnumConverterProcessor kann seine Arbeitsweise aufgezeigt werden.

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
@Test
void withRepresentationAndMultipleConverterValue() throws IOException {
Compilation compilation = Compiler.javac().withProcessors(new EnumConverterProcessor())
.compile(JavaFileObjects.forSourceString("TestEnum",
"""
package de.schegge;
import de.schegge.enumconverter.WithEnumConverter;
import de.schegge.enumconverter.ConverterValue;
@WithEnumConverter(representation=String.class)
enum TestEnum {
@ConverterValue({"a", "legacyA"})
A,
@ConverterValue("b")
B,
@ConverterValue(ignored=true)
C
}"""));
JavaFileObject javaFileObject = compilation.generatedSourceFiles().get(0);
assertEquals("/SOURCE_OUTPUT/TestEnumConverter.java", javaFileObject.getName());
String charContent = javaFileObject.getCharContent(true).toString();
assertEquals(
"""
/**
* @author Enum Converter Generator by Jens Kaiser
* @version 1.0.0
*/
package de.schegge;
import javax.persistence.AttributeConverter;
import javax.persistence.Converter;
import java.util.Map;
import java.util.HashMap;
@Converter
public final class TestEnumConverter implements AttributeConverter<TestEnum, String> {
private final Map<TestEnum, String> toDatabaseColumn = new HashMap<>();
private final Map<String, TestEnum> toEntityAttribute = new HashMap<>();
public TestEnumConverter() {
toDatabaseColumn.put(TestEnum.A, "a");
toDatabaseColumn.put(TestEnum.B, "B");
toEntityAttribute.put("a", TestEnum.A);
toEntityAttribute.put("legacyA", TestEnum.A);
toEntityAttribute.put("B", TestEnum.B);
}
@Override
public String convertToDatabaseColumn(TestEnum value) {
return value == null ? null : toDatabaseColumn.get(value);
}
@Override
public TestEnum convertToEntityAttribute(String value) {
return value == null ? null : toEntityAttribute.get(value);
}
}""", charContent);
assertSame(Status.SUCCESS, compilation.status());
}
@Test void withRepresentationAndMultipleConverterValue() throws IOException { Compilation compilation = Compiler.javac().withProcessors(new EnumConverterProcessor()) .compile(JavaFileObjects.forSourceString("TestEnum", """ package de.schegge; import de.schegge.enumconverter.WithEnumConverter; import de.schegge.enumconverter.ConverterValue; @WithEnumConverter(representation=String.class) enum TestEnum { @ConverterValue({"a", "legacyA"}) A, @ConverterValue("b") B, @ConverterValue(ignored=true) C }""")); JavaFileObject javaFileObject = compilation.generatedSourceFiles().get(0); assertEquals("/SOURCE_OUTPUT/TestEnumConverter.java", javaFileObject.getName()); String charContent = javaFileObject.getCharContent(true).toString(); assertEquals( """ /** * @author Enum Converter Generator by Jens Kaiser * @version 1.0.0 */ package de.schegge; import javax.persistence.AttributeConverter; import javax.persistence.Converter; import java.util.Map; import java.util.HashMap; @Converter public final class TestEnumConverter implements AttributeConverter<TestEnum, String> { private final Map<TestEnum, String> toDatabaseColumn = new HashMap<>(); private final Map<String, TestEnum> toEntityAttribute = new HashMap<>(); public TestEnumConverter() { toDatabaseColumn.put(TestEnum.A, "a"); toDatabaseColumn.put(TestEnum.B, "B"); toEntityAttribute.put("a", TestEnum.A); toEntityAttribute.put("legacyA", TestEnum.A); toEntityAttribute.put("B", TestEnum.B); } @Override public String convertToDatabaseColumn(TestEnum value) { return value == null ? null : toDatabaseColumn.get(value); } @Override public TestEnum convertToEntityAttribute(String value) { return value == null ? null : toEntityAttribute.get(value); } }""", charContent); assertSame(Status.SUCCESS, compilation.status()); }
  @Test
  void withRepresentationAndMultipleConverterValue() throws IOException {
    Compilation compilation = Compiler.javac().withProcessors(new EnumConverterProcessor())
            .compile(JavaFileObjects.forSourceString("TestEnum",
                """
                    package de.schegge;
                    import de.schegge.enumconverter.WithEnumConverter;
                    import de.schegge.enumconverter.ConverterValue;
                                        
                    @WithEnumConverter(representation=String.class)
                    enum TestEnum {
                      @ConverterValue({"a", "legacyA"})
                      A,
                      @ConverterValue("b")
                      B,
                      @ConverterValue(ignored=true)
                      C
                    }"""));
    JavaFileObject javaFileObject = compilation.generatedSourceFiles().get(0);
    assertEquals("/SOURCE_OUTPUT/TestEnumConverter.java", javaFileObject.getName());
    String charContent = javaFileObject.getCharContent(true).toString();
    assertEquals(
        """
            /**
            * @author Enum Converter Generator by Jens Kaiser
            * @version 1.0.0
            */
            package de.schegge;

            import javax.persistence.AttributeConverter;
            import javax.persistence.Converter;
            import java.util.Map;
            import java.util.HashMap;

            @Converter
            public final class TestEnumConverter implements AttributeConverter<TestEnum, String> {

              private final Map<TestEnum, String> toDatabaseColumn = new HashMap<>();
              private final Map<String, TestEnum> toEntityAttribute = new HashMap<>();

              public TestEnumConverter() {
                toDatabaseColumn.put(TestEnum.A, "a");
                toDatabaseColumn.put(TestEnum.B, "B");

                toEntityAttribute.put("a", TestEnum.A);
                toEntityAttribute.put("legacyA", TestEnum.A);
                toEntityAttribute.put("B", TestEnum.B);
              }

              @Override
              public String convertToDatabaseColumn(TestEnum value) {
                return value == null ? null : toDatabaseColumn.get(value);
              }

              @Override
              public TestEnum convertToEntityAttribute(String value) {
                return value == null ? null : toEntityAttribute.get(value);
              }
            }""", charContent);
    assertSame(Status.SUCCESS, compilation.status());
  }

Der Java Compiler generierte mit Hilfe des

EnumConverterProcessor
EnumConverterProcessor aus der Source Code für die Enum
TestEnum
TestEnum einen
AttributeConverter
AttributeConverter mit dem abgeleiteten Name
TestEnumConverter
TestEnumConverter. Was angenehm in der Testmethode auffällt, ist die gute Lesbarkeit der Source Code Texte durch die Verwendung von Textblöcken.

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
package de.schegge;
import de.schegge.enumconverter.WithEnumConverter;
import de.schegge.enumconverter.ConverterValue;
@WithEnumConverter(representation=String.class)
enum TestEnum {
@ConverterValue({"a", "legacyA"})
A,
B,
@ConverterValue(ignored=true)
C
}
package de.schegge; import de.schegge.enumconverter.WithEnumConverter; import de.schegge.enumconverter.ConverterValue; @WithEnumConverter(representation=String.class) enum TestEnum { @ConverterValue({"a", "legacyA"}) A, B, @ConverterValue(ignored=true) C }
package de.schegge;

import de.schegge.enumconverter.WithEnumConverter;
import de.schegge.enumconverter.ConverterValue;
                                        
@WithEnumConverter(representation=String.class)
enum TestEnum {
  @ConverterValue({"a", "legacyA"})
  A,
  B,
  @ConverterValue(ignored=true)
  C
}

Die Enum benötigt zur automatischen Generierung des

TestEnumConverter
TestEnumConverter nur die
@WithEnumConverter
@WithEnumConverter Annotation. In diesem Fall soll der Converter die Enum Konstanten in Strings umwandeln. Dabei wird die
TestEnum.A
TestEnum.A in
"a"
"a" umgewandelt,
TestEnum.B
TestEnum.B in
"B"
"B" und die
TestEnum.C
TestEnum.C wird ignoriert, d.h. in
null
null umgewandelt. Umgekehrt werden die Werte
"a"
"a" und
"legacyA"
"legacyA" aus der Datenbank in die Konstante
TestEnum.A
TestEnum.A , der Wert
"B"
"B" in die Konstante
TestEnum.B
TestEnum.B und alle anderen Werte aus der Datenbank in
null
null umgewandelt. Wird keine spezielle Annotation
@ConverterValue
@ConverterValue verwendet, dann wird der Name der Konstante genutzt.

Bei der @WithEnumConverter Annotation kann auch die Klasse

Integer
Integer als
representation
representation Attribut verwenden werden. Da diese Klasse der Default ist, kann das Attribut auch weggelassen werden. Bei der Klasse
Integer
Integer, wird statt dem Namen der Enum Konstante standardmäßig ihr Ordinalwert verwendet. Über die
@ConverterValue
@ConverterValue Annotation kann der Ordinalwert aber auch überschrieben werden.

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
@Converter
public final class TestEnumConverter implements AttributeConverter<TestEnum, String> {
private final Map<TestEnum, String> toDatabaseColumn = new HashMap<>();
private final Map<String, TestEnum> toEntityAttribute = new HashMap<>();
public TestEnumConverter() {
toDatabaseColumn.put(TestEnum.A, "a");
toDatabaseColumn.put(TestEnum.B, "B");
toEntityAttribute.put("a", TestEnum.A);
toEntityAttribute.put("legacyA", TestEnum.A);
toEntityAttribute.put("B", TestEnum.B);
}
@Override
public String convertToDatabaseColumn(TestEnum value) {
return value == null ? null : toDatabaseColumn.get(value);
}
@Override
public TestEnum convertToEntityAttribute(String value) {
return value == null ? null : toEntityAttribute.get(value);
}
}
@Converter public final class TestEnumConverter implements AttributeConverter<TestEnum, String> { private final Map<TestEnum, String> toDatabaseColumn = new HashMap<>(); private final Map<String, TestEnum> toEntityAttribute = new HashMap<>(); public TestEnumConverter() { toDatabaseColumn.put(TestEnum.A, "a"); toDatabaseColumn.put(TestEnum.B, "B"); toEntityAttribute.put("a", TestEnum.A); toEntityAttribute.put("legacyA", TestEnum.A); toEntityAttribute.put("B", TestEnum.B); } @Override public String convertToDatabaseColumn(TestEnum value) { return value == null ? null : toDatabaseColumn.get(value); } @Override public TestEnum convertToEntityAttribute(String value) { return value == null ? null : toEntityAttribute.get(value); } }
@Converter
public final class TestEnumConverter implements AttributeConverter<TestEnum, String> {

  private final Map<TestEnum, String> toDatabaseColumn = new HashMap<>();
  private final Map<String, TestEnum> toEntityAttribute = new HashMap<>();

  public TestEnumConverter() {
    toDatabaseColumn.put(TestEnum.A, "a");
    toDatabaseColumn.put(TestEnum.B, "B");

    toEntityAttribute.put("a", TestEnum.A);
    toEntityAttribute.put("legacyA", TestEnum.A);
    toEntityAttribute.put("B", TestEnum.B);
  }

  @Override
  public String convertToDatabaseColumn(TestEnum value) {
    return value == null ? null : toDatabaseColumn.get(value);
  }

  @Override
  public TestEnum convertToEntityAttribute(String value) {
    return value == null ? null : toEntityAttribute.get(value);
  }
}

Der generierte Converter ist recht einfach aufgebaut. Für die beiden Richtungen der Konvertierung ist je eine

Map
Map vorgesehen, in der alle erlaubten Mappings eingetragen werden. Für die Konvertierung wird dann einfach in die
Map
Map geschaut und der dort vorhandene Wert oder
null
null zurückgegeben.

Die Implementierung des

EnumConverterProcessor
EnumConverterProcessor ist im folgenden skizziert. In der
process
process Methode werden im Stream alle Klassen bearbeitet, die mit
WithEnumConverter
WithEnumConverter annotiert sind. Die Methode
validateEnumType
validateEnumType prüft , ob es sich bei der annotierten Klasse tatsächlich um einen Enum Typen handelt. Wenn nicht, wird eine
IllegalArgumentException
IllegalArgumentException geworfen und die Bearbeitung mit einem Fehler beendet. Am Ende der Stream Verarbeitung werden in der
processEnumForConverter
processEnumForConverter Methode die
AttributeConverter
AttributeConverter produziert.

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
@SupportedAnnotationTypes({"de.schegge.enumconverter.WithEnumConverter"})
@SupportedSourceVersion(SourceVersion.RELEASE_17)
@AutoService(Processor.class)
public class EnumConverterProcessor extends AbstractProcessor {
@Override
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
try {
return annotations.stream().map(roundEnv::getElementsAnnotatedWith).flatMap(Set::stream)
.map(TypeElement.class::cast).map(this::validateEnumType).allMatch(this::processEnumForConverter);
} catch (IllegalArgumentException e) {
printMessage(Kind.ERROR, e.getMessage());
return false;
}
}
private boolean processEnumForConverter(TypeElement enumType) {
AnnotationMirror enumTypeMirror = getConverterAnnotation(enumType).orElseThrow(IllegalArgumentException::new);
String representationTypeName = getRepresentationType(enumTypeMirror);
boolean autoApply = getBooleanValue("autoApply", enumTypeMirror);
List<ValueHolder> list = createValueHolders(enumType, representationTypeName);
return writer.createEnumConverterClassFile(enumType, representationTypeName, list, autoApply);
}
}
@SupportedAnnotationTypes({"de.schegge.enumconverter.WithEnumConverter"}) @SupportedSourceVersion(SourceVersion.RELEASE_17) @AutoService(Processor.class) public class EnumConverterProcessor extends AbstractProcessor { @Override public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) { try { return annotations.stream().map(roundEnv::getElementsAnnotatedWith).flatMap(Set::stream) .map(TypeElement.class::cast).map(this::validateEnumType).allMatch(this::processEnumForConverter); } catch (IllegalArgumentException e) { printMessage(Kind.ERROR, e.getMessage()); return false; } } private boolean processEnumForConverter(TypeElement enumType) { AnnotationMirror enumTypeMirror = getConverterAnnotation(enumType).orElseThrow(IllegalArgumentException::new); String representationTypeName = getRepresentationType(enumTypeMirror); boolean autoApply = getBooleanValue("autoApply", enumTypeMirror); List<ValueHolder> list = createValueHolders(enumType, representationTypeName); return writer.createEnumConverterClassFile(enumType, representationTypeName, list, autoApply); } }
@SupportedAnnotationTypes({"de.schegge.enumconverter.WithEnumConverter"})
@SupportedSourceVersion(SourceVersion.RELEASE_17)
@AutoService(Processor.class)
public class EnumConverterProcessor extends AbstractProcessor {

  @Override
  public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
    try {
      return annotations.stream().map(roundEnv::getElementsAnnotatedWith).flatMap(Set::stream)
          .map(TypeElement.class::cast).map(this::validateEnumType).allMatch(this::processEnumForConverter);
    } catch (IllegalArgumentException e) {
      printMessage(Kind.ERROR, e.getMessage());
      return false;
    }
  }

  private boolean processEnumForConverter(TypeElement enumType) {
    AnnotationMirror enumTypeMirror = getConverterAnnotation(enumType).orElseThrow(IllegalArgumentException::new);
    String representationTypeName = getRepresentationType(enumTypeMirror);
    boolean autoApply = getBooleanValue("autoApply", enumTypeMirror);
    List<ValueHolder> list = createValueHolders(enumType, representationTypeName);
    return writer.createEnumConverterClassFile(enumType, representationTypeName, list, autoApply);
  }
}

In der

processEnumForConverter
processEnumForConverter Methode wird zuerst die aktuelle
WithEnumConverter
WithEnumConverter Annotation ermittelt und die Werte ihrer
representation
representation und
autoApply
autoApply Attribute bestimmt. Das
autoApply
autoApply Attribute bestimmt den Wert des entsprechenden Attribute der
Converter
Converter Annotation am generierten
AttributeConverter
AttributeConverter.

Danach wird die Liste der

ValueHolder
ValueHolder bestimmt. Das sind Paare aus dem Namen bzw. Ordinalwert der Enum Konstanten und der Liste von zugehörigen Datenbankwerten. In der
createValueHolders
createValueHolders Methode wird außerdem geprüft, ob es Doubletten bei den Datenbankwerten gibt.

Am Ende wird mit der

createEnumConverterClassFile
createEnumConverterClassFile die Sourcecode Datei geschrieben. Dazu wird wie in den anderen Beiträgen zu Annotation Processors ein Freemarker Template verwenden.

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
/**
* @author ${author}
* @version ${version}
*/
package ${package};
<#list imports as import>
import ${import};
</#list>
import javax.persistence.AttributeConverter;
import javax.persistence.Converter;
import java.util.Map;
import java.util.HashMap;
@Converter${autoApply?string('(autoApply=true)','')}
public final class ${enumConverterTypeName} implements AttributeConverter<${enumTypeName}, ${databaseTypeName}> {
private final Map<${enumTypeName}, ${databaseTypeName}> toDatabaseColumn = new HashMap<>();
private final Map<${databaseTypeName}, ${enumTypeName}> toEntityAttribute = new HashMap<>();
public ${enumConverterTypeName}() {
<#list elements as element>
toDatabaseColumn.put(${enumTypeName}.${element.key!""}, ${element.value[0]!""});
</#list>
<#list elements as element>
<#list element.value as value>
toEntityAttribute.put(${value!""}, ${enumTypeName}.${element.key!""});
</#list>
</#list>
}
@Override
public ${databaseTypeName} convertToDatabaseColumn(${enumTypeName} value) {
return value == null ? null : toDatabaseColumn.get(value);
}
@Override
public ${enumTypeName} convertToEntityAttribute(${databaseTypeName} value) {
return value == null ? null : toEntityAttribute.get(value);
}
}
/** * @author ${author} * @version ${version} */ package ${package}; <#list imports as import> import ${import}; </#list> import javax.persistence.AttributeConverter; import javax.persistence.Converter; import java.util.Map; import java.util.HashMap; @Converter${autoApply?string('(autoApply=true)','')} public final class ${enumConverterTypeName} implements AttributeConverter<${enumTypeName}, ${databaseTypeName}> { private final Map<${enumTypeName}, ${databaseTypeName}> toDatabaseColumn = new HashMap<>(); private final Map<${databaseTypeName}, ${enumTypeName}> toEntityAttribute = new HashMap<>(); public ${enumConverterTypeName}() { <#list elements as element> toDatabaseColumn.put(${enumTypeName}.${element.key!""}, ${element.value[0]!""}); </#list> <#list elements as element> <#list element.value as value> toEntityAttribute.put(${value!""}, ${enumTypeName}.${element.key!""}); </#list> </#list> } @Override public ${databaseTypeName} convertToDatabaseColumn(${enumTypeName} value) { return value == null ? null : toDatabaseColumn.get(value); } @Override public ${enumTypeName} convertToEntityAttribute(${databaseTypeName} value) { return value == null ? null : toEntityAttribute.get(value); } }
/**
* @author ${author}
* @version ${version}
*/
package ${package};

<#list imports as import>
  import ${import};
</#list>
import javax.persistence.AttributeConverter;
import javax.persistence.Converter;
import java.util.Map;
import java.util.HashMap;

@Converter${autoApply?string('(autoApply=true)','')}
public final class ${enumConverterTypeName} implements AttributeConverter<${enumTypeName}, ${databaseTypeName}> {

  private final Map<${enumTypeName}, ${databaseTypeName}> toDatabaseColumn = new HashMap<>();
  private final Map<${databaseTypeName}, ${enumTypeName}> toEntityAttribute = new HashMap<>();

  public ${enumConverterTypeName}() {
<#list elements as element>
    toDatabaseColumn.put(${enumTypeName}.${element.key!""}, ${element.value[0]!""});
</#list>

<#list elements as element>
  <#list element.value as value>
    toEntityAttribute.put(${value!""}, ${enumTypeName}.${element.key!""});
  </#list>
</#list>
  }

  @Override
  public ${databaseTypeName} convertToDatabaseColumn(${enumTypeName} value) {
    return value == null ? null : toDatabaseColumn.get(value);
  }

  @Override
  public ${enumTypeName} convertToEntityAttribute(${databaseTypeName} value) {
    return value == null ? null : toEntityAttribute.get(value);
  }
}

Die beiden Maps werden innerhalb des Template unterschiedlich befüllt. Bei der

toDatabaseColumn
toDatabaseColumn wird für die Enum Konstante als Schlüssel und als Wert der erste Eintrag aus der Werteliste verwendet. Bei der
toEntityAttribute
toEntityAttribute wird für jeden Eintrag aus der Liste als Schlüssel die Enum Konstante als Wert verwendet.

Damit ist die erste Version des

EnumConverterProcessor
EnumConverterProcessor auch schon implementiert. Auch hier gilt natürlich die Warnung vor den unbedarften Kollegen, die durch Änderungen an den Annotationen das ganze Gebilde zum Einsturz bringen. Auch hier kann die konsistente Datenbankabbildung nur durch entsprechende Unit Tests gewährleistet werden

1 thought on “Automatisch generierte Enum Converter”

  1. Ich fürchte, dass damit das Hauptziel noch nicht erreicht ist. Ich würde gerne in der JPA-QL eine Abfrage der Art:

    SELECT tb
    FROM BeispielTabelle tb
    WHERE
    :paramEnum IN tb.integerBitSet

    durchführen. Leider nur versagt der IN Operator seinen Dienst, da er mindestens
    in Hibernate den Abgleich auf ein x-Array durchführen will, in der DB aber das gewünschte Integer wiederfindet.

    Reply

Leave a Reply to chrisian groove Cancel reply