„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
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.