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 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, ist natürlich das Schreiben des 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 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 kann seine Arbeitsweise aufgezeigt werden.

  @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 aus der Source Code für die Enum TestEnum einen AttributeConverter mit dem abgeleiteten Name TestEnumConverter. Was angenehm in der Testmethode auffällt, ist die gute Lesbarkeit der Source Code Texte durch die Verwendung von Textblöcken.

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

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

@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 vorgesehen, in der alle erlaubten Mappings eingetragen werden. Für die Konvertierung wird dann einfach in die Map geschaut und der dort vorhandene Wert oder null zurückgegeben.

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

@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 Methode wird zuerst die aktuelle WithEnumConverter Annotation ermittelt und die Werte ihrer representation und autoApply Attribute bestimmt. Das autoApply Attribute bestimmt den Wert des entsprechenden Attribute der Converter Annotation am generierten AttributeConverter.

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

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

/**
* @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 wird für die Enum Konstante als Schlüssel und als Wert der erste Eintrag aus der Werteliste verwendet. Bei der 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 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 Gedanke zu „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.

    Antworten

Schreibe einen Kommentar