From 2da78584981b3ac5ea6ca7cf8ec45a1e3b2fcda3 Mon Sep 17 00:00:00 2001 From: Edward Harman Date: Wed, 21 Aug 2024 10:11:04 -0400 Subject: [PATCH] Lots of refactoring. Replaced JStachio templates with JavaPoet - better suited for Java code. Got a few Micronaut test cases working. --- buildSrc/build.gradle.kts | 1 + .../buildsupport/EmbeddedPostgresPlugin.java | 1 + .../buildsupport/ProcessorConfigTask.java | 26 +- .../main/kotlin/java-convention.gradle.kts | 11 + catalog.settings.gradle.kts | 9 +- processor/build.gradle.kts | 5 +- .../kiwiproc/processor/ClassNameMixin.java | 16 - .../kiwiproc/processor/ContainerType.java | 14 +- .../kiwiproc/processor/CoreTypes.java | 242 +++++++++++++ .../kiwiproc/processor/DAOClassInfo.java | 14 +- .../kiwiproc/processor/DAODataSourceInfo.java | 7 + .../kiwiproc/processor/DAOGenerator.java | 54 +-- .../kiwiproc/processor/DAOMethodInfo.java | 50 ++- .../kiwiproc/processor/DAOParameterInfo.java | 32 +- .../kiwiproc/processor/DAOResultColumn.java | 7 + .../kiwiproc/processor/DAOResultInfo.java | 4 - .../kiwiproc/processor/KiwiProcessor.java | 166 +++++---- .../ethelred/kiwiproc/processor/KiwiType.java | 14 +- .../kiwiproc/processor/KiwiTypeVisitor.java | 57 ++++ .../processor/MethodParameterInfo.java | 32 +- .../kiwiproc/processor/QueryMethodKind.java | 32 +- .../kiwiproc/processor/RecordType.java | 8 + .../kiwiproc/processor/ReturnType.java | 23 -- .../kiwiproc/processor/ReturnTypeVisitor.java | 46 --- .../kiwiproc/processor/Signature.java | 29 +- .../kiwiproc/processor/SimpleType.java | 26 ++ .../kiwiproc/processor/SqlTypeMapping.java | 97 ++++-- .../kiwiproc/processor/TypeMapping.java | 26 +- .../kiwiproc/processor/TypeUtils.java | 106 +++--- .../kiwiproc/processor/TypeValidator.java | 321 +++++++++++------- .../kiwiproc/processor/UnsupportedType.java | 13 + .../org/ethelred/kiwiproc/processor/Util.java | 21 ++ .../processor/ValidContainerType.java | 17 +- .../ethelred/kiwiproc/processor/VoidType.java | 14 + .../processor/generator/ElementSupplier.java | 8 + .../processor/generator/ImplGenerator.java | 179 ++++++++++ .../generator/KiwiTypeConverter.java | 35 ++ .../processor/generator/PoetDAOGenerator.java | 64 ++++ .../generator/ProviderGenerator.java | 58 ++++ .../processor/generator/RuntimeTypes.java | 16 + .../TransactionManagerGenerator.java | 52 +++ .../processor/generator/package-info.java | 4 + .../kiwiproc/processor/package-info.java | 6 +- .../processor/templates/GeneratedMixin.java | 14 - .../processor/templates/ImplTemplate.java | 110 ------ .../processor/templates/MapperTemplate.java | 30 -- .../processor/templates/ProviderTemplate.java | 50 --- .../templates/RowRecordTemplate.java | 26 -- .../processor/templates/package-info.java | 8 - .../kiwiproc/processor/CoreTypesTest.java | 79 +++++ .../kiwiproc/processor/ProcessorTest.java | 101 +++--- .../processor/SqlTypeMappingTest.java | 40 +++ .../kiwiproc/processor/TestMapper.java | 17 + .../kiwiproc/processor/TypeValidatorTest.java | 199 +++++++++++ .../kiwiproc/meta/ColumnMetaData.java | 49 ++- .../kiwiproc/meta/DatabaseWrapper.java | 24 +- .../ethelred/kiwiproc/meta/ParsedQuery.java | 7 +- .../ethelred/kiwiproc/meta/QueryMetaData.java | 7 +- .../ethelred/kiwiproc/meta/package-info.java | 2 +- .../kiwiproc/meta/DatabaseWrapperTest.java | 57 ++-- .../kiwiproc/meta/ParsedSqlQueryTest.java | 9 +- .../java/org/ethelred/kiwiproc/api/Batch.java | 2 +- .../org/ethelred/kiwiproc/api/DAOContext.java | 3 +- .../ethelred/kiwiproc/api/DAOProvider.java | 5 +- .../kiwiproc/api/TransactionManager.java | 35 +- .../ethelred/kiwiproc/impl/AbstractBatch.java | 31 +- .../kiwiproc/impl/AbstractDAOProvider.java | 19 +- .../impl/AbstractTransactionManager.java | 95 ++++++ .../ethelred/kiwiproc/impl/BaseMapper.java | 4 - .../org/ethelred/kiwiproc/annotation/DAO.java | 3 +- .../kiwiproc/annotation/SqlBatch.java | 7 +- .../kiwiproc/annotation/SqlQuery.java | 7 +- .../kiwiproc/annotation/SqlUpdate.java | 7 +- .../processorconfig/DataSourceConfig.java | 9 +- .../DependencyInjectionStyle.java | 10 - .../processorconfig/ProcessorConfig.java | 7 +- test-micronaut/build.gradle.kts | 3 + .../org/ethelred/kiwiproc/test/Owner.java | 4 + .../ethelred/kiwiproc/test/PetClinicDAO.java | 36 +- .../org/ethelred/kiwiproc/test/PetType.java | 3 +- .../ethelred/kiwiproc/test/PetClinicTest.java | 58 ++++ 81 files changed, 2150 insertions(+), 990 deletions(-) delete mode 100644 processor/src/main/java/org/ethelred/kiwiproc/processor/ClassNameMixin.java create mode 100644 processor/src/main/java/org/ethelred/kiwiproc/processor/CoreTypes.java create mode 100644 processor/src/main/java/org/ethelred/kiwiproc/processor/DAODataSourceInfo.java create mode 100644 processor/src/main/java/org/ethelred/kiwiproc/processor/DAOResultColumn.java delete mode 100644 processor/src/main/java/org/ethelred/kiwiproc/processor/DAOResultInfo.java create mode 100644 processor/src/main/java/org/ethelred/kiwiproc/processor/KiwiTypeVisitor.java create mode 100644 processor/src/main/java/org/ethelred/kiwiproc/processor/RecordType.java delete mode 100644 processor/src/main/java/org/ethelred/kiwiproc/processor/ReturnType.java delete mode 100644 processor/src/main/java/org/ethelred/kiwiproc/processor/ReturnTypeVisitor.java create mode 100644 processor/src/main/java/org/ethelred/kiwiproc/processor/SimpleType.java create mode 100644 processor/src/main/java/org/ethelred/kiwiproc/processor/UnsupportedType.java create mode 100644 processor/src/main/java/org/ethelred/kiwiproc/processor/Util.java create mode 100644 processor/src/main/java/org/ethelred/kiwiproc/processor/VoidType.java create mode 100644 processor/src/main/java/org/ethelred/kiwiproc/processor/generator/ElementSupplier.java create mode 100644 processor/src/main/java/org/ethelred/kiwiproc/processor/generator/ImplGenerator.java create mode 100644 processor/src/main/java/org/ethelred/kiwiproc/processor/generator/KiwiTypeConverter.java create mode 100644 processor/src/main/java/org/ethelred/kiwiproc/processor/generator/PoetDAOGenerator.java create mode 100644 processor/src/main/java/org/ethelred/kiwiproc/processor/generator/ProviderGenerator.java create mode 100644 processor/src/main/java/org/ethelred/kiwiproc/processor/generator/RuntimeTypes.java create mode 100644 processor/src/main/java/org/ethelred/kiwiproc/processor/generator/TransactionManagerGenerator.java create mode 100644 processor/src/main/java/org/ethelred/kiwiproc/processor/generator/package-info.java delete mode 100644 processor/src/main/java/org/ethelred/kiwiproc/processor/templates/GeneratedMixin.java delete mode 100644 processor/src/main/java/org/ethelred/kiwiproc/processor/templates/ImplTemplate.java delete mode 100644 processor/src/main/java/org/ethelred/kiwiproc/processor/templates/MapperTemplate.java delete mode 100644 processor/src/main/java/org/ethelred/kiwiproc/processor/templates/ProviderTemplate.java delete mode 100644 processor/src/main/java/org/ethelred/kiwiproc/processor/templates/RowRecordTemplate.java delete mode 100644 processor/src/main/java/org/ethelred/kiwiproc/processor/templates/package-info.java create mode 100644 processor/src/test/java/org/ethelred/kiwiproc/processor/CoreTypesTest.java create mode 100644 processor/src/test/java/org/ethelred/kiwiproc/processor/SqlTypeMappingTest.java create mode 100644 processor/src/test/java/org/ethelred/kiwiproc/processor/TestMapper.java create mode 100644 processor/src/test/java/org/ethelred/kiwiproc/processor/TypeValidatorTest.java create mode 100644 runtime/src/main/java/org/ethelred/kiwiproc/impl/AbstractTransactionManager.java delete mode 100644 runtime/src/main/java/org/ethelred/kiwiproc/impl/BaseMapper.java create mode 100644 test-micronaut/src/main/java/org/ethelred/kiwiproc/test/Owner.java create mode 100644 test-micronaut/src/test/java/org/ethelred/kiwiproc/test/PetClinicTest.java diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 5137e0d..e885812 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -15,5 +15,6 @@ java { dependencies { implementation(libs.embeddedpostgres) + implementation("com.diffplug.spotless:spotless-plugin-gradle:7.0.0.BETA1") runtimeOnly(libs.liquibase.core) } \ No newline at end of file diff --git a/buildSrc/src/main/java/org/ethelred/buildsupport/EmbeddedPostgresPlugin.java b/buildSrc/src/main/java/org/ethelred/buildsupport/EmbeddedPostgresPlugin.java index 571d8a8..24fe150 100644 --- a/buildSrc/src/main/java/org/ethelred/buildsupport/EmbeddedPostgresPlugin.java +++ b/buildSrc/src/main/java/org/ethelred/buildsupport/EmbeddedPostgresPlugin.java @@ -14,6 +14,7 @@ public void apply(Project project) { var processorConfigTask = project.getTasks().register("processorConfig", ProcessorConfigTask.class, task -> { task.getConfigFile().set(project.getLayout().getBuildDirectory().file("config.json")); + task.getApplicationConfigFile().convention(project.getLayout().getBuildDirectory().file("resources/test/application-test.yml")); task.getDependencyInjectionStyle().convention("JAKARTA"); task.getLiquibaseChangelog().convention(project.getLayout().getProjectDirectory().file("src/main/resources/changelog.xml")); }); diff --git a/buildSrc/src/main/java/org/ethelred/buildsupport/ProcessorConfigTask.java b/buildSrc/src/main/java/org/ethelred/buildsupport/ProcessorConfigTask.java index 0a8f070..415d4d1 100644 --- a/buildSrc/src/main/java/org/ethelred/buildsupport/ProcessorConfigTask.java +++ b/buildSrc/src/main/java/org/ethelred/buildsupport/ProcessorConfigTask.java @@ -4,15 +4,13 @@ import org.gradle.api.file.RegularFileProperty; import org.gradle.api.provider.Property; import org.gradle.api.services.ServiceReference; -import org.gradle.api.tasks.Input; -import org.gradle.api.tasks.InputFile; -import org.gradle.api.tasks.OutputFile; -import org.gradle.api.tasks.TaskAction; +import org.gradle.api.tasks.*; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; +@UntrackedTask(because = "A new Postgres instance is started for each Gradle run, and the database port and name are chosen at random.") public abstract class ProcessorConfigTask extends DefaultTask { @ServiceReference("embeddedPostgres") abstract Property getService(); @@ -20,6 +18,9 @@ public abstract class ProcessorConfigTask extends DefaultTask { @OutputFile abstract RegularFileProperty getConfigFile(); + @OutputFile + abstract RegularFileProperty getApplicationConfigFile(); + @InputFile abstract RegularFileProperty getLiquibaseChangelog(); @@ -32,6 +33,7 @@ public void run() { var connectionInfo = getService().get().getPreparedDatabase(liquibaseFile); // the ":shared" json support is not available in buildSrc var config = + //language=JSON """ { "dataSources": { @@ -56,5 +58,21 @@ public void run() { } catch (IOException e) { throw new RuntimeException(e); } + var applicationConfig = + //language=yaml + """ + datasources: + default: + url: "jdbc:postgresql://localhost:%d/%s?user=%s" + """.formatted(connectionInfo.getPort(), connectionInfo.getDbName(), connectionInfo.getUser()); + if (getApplicationConfigFile().isPresent()) { + outputPath = getApplicationConfigFile().get().getAsFile().toPath(); + try { + Files.writeString(outputPath, applicationConfig, StandardCharsets.UTF_8); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + } } diff --git a/buildSrc/src/main/kotlin/java-convention.gradle.kts b/buildSrc/src/main/kotlin/java-convention.gradle.kts index 93dd531..acebdad 100644 --- a/buildSrc/src/main/kotlin/java-convention.gradle.kts +++ b/buildSrc/src/main/kotlin/java-convention.gradle.kts @@ -1,5 +1,6 @@ plugins { `java-library` + id("com.diffplug.spotless") } repositories { @@ -25,3 +26,13 @@ dependencies { testImplementation("com.google.truth.extensions:truth-java8-extension:1.1.5") testRuntimeOnly("org.junit.platform:junit-platform-launcher") } + +spotless { + java { + cleanthat() + importOrder() + removeUnusedImports() + palantirJavaFormat() + formatAnnotations() + } +} \ No newline at end of file diff --git a/catalog.settings.gradle.kts b/catalog.settings.gradle.kts index 12e3d1c..b833e3c 100644 --- a/catalog.settings.gradle.kts +++ b/catalog.settings.gradle.kts @@ -3,10 +3,10 @@ dependencyResolutionManagement { versionCatalogs { create("libs") { version("avaje-json", "1.7") - version("jstachio", "1.3.2") version("junit", "5.10.0") version("recordbuilder", "37") version("mapstruct", "1.5.5.Final") + version("ethelred-util", "2.2") library("avaje-json", "io.avaje", "avaje-jsonb").versionRef("avaje-json") library("avaje-json-processor", "io.avaje", "avaje-jsonb-generator").versionRef("avaje-json") @@ -21,9 +21,6 @@ dependencyResolutionManagement { library("utilitary", "com.karuslabs:utilitary:2.0.1") library("postgresql", "org.postgresql:postgresql:42.5.1") - library("jstachio-processor", "io.jstach", "jstachio-apt").versionRef("jstachio") - library("jstachio-compile", "io.jstach", "jstachio-annotation").versionRef("jstachio") - library("junit-api", "org.junit.jupiter", "junit-jupiter-api").versionRef("junit") library("junit-params", "org.junit.jupiter", "junit-jupiter-params").versionRef("junit") @@ -38,10 +35,14 @@ dependencyResolutionManagement { library("jspecify", "org.jspecify:jspecify:0.3.0") + library("javapoet", "com.palantir.javapoet:javapoet:0.2.0") library("guava", "com.google.guava:guava:32.1.3-jre") library("compile-testing", "com.google.testing.compile:compile-testing:0.21.0") library("compile-testing-extension", "io.github.kiskae:compile-testing-extension:1.0.2") + library("ethelred-util", "org.ethelred.util", "common").versionRef("ethelred-util") + library("yaml", "org.yaml:snakeyaml:2.2") + bundle("compile-testing", listOf("guava", "compile-testing", "compile-testing-extension")) } } diff --git a/processor/build.gradle.kts b/processor/build.gradle.kts index b63cb10..79a082f 100644 --- a/processor/build.gradle.kts +++ b/processor/build.gradle.kts @@ -6,7 +6,6 @@ dependencies { annotationProcessor(libs.metainfservices) annotationProcessor(libs.avaje.prisms) annotationProcessor(libs.recordbuilder.processor) - annotationProcessor(libs.jstachio.processor) implementation(project(":shared")) implementation(project(":querymeta")) implementation(libs.utilitary) @@ -15,8 +14,9 @@ dependencies { implementation(libs.metainfservices) implementation(libs.postgresql) implementation(libs.recordbuilder.core) - implementation(libs.jstachio.compile) implementation(libs.mapstruct.processor) + implementation(libs.javapoet) + implementation(libs.ethelred.util) testAnnotationProcessor(libs.mapstruct.processor) testImplementation(project(":runtime")) testImplementation(libs.jakarta.inject) @@ -24,5 +24,4 @@ dependencies { testImplementation(libs.embeddedpostgres) testImplementation(libs.liquibase.core) testImplementation(libs.mapstruct.compile) - } \ No newline at end of file diff --git a/processor/src/main/java/org/ethelred/kiwiproc/processor/ClassNameMixin.java b/processor/src/main/java/org/ethelred/kiwiproc/processor/ClassNameMixin.java deleted file mode 100644 index 3ecbfd5..0000000 --- a/processor/src/main/java/org/ethelred/kiwiproc/processor/ClassNameMixin.java +++ /dev/null @@ -1,16 +0,0 @@ -package org.ethelred.kiwiproc.processor; - -import io.jstach.jstache.JStacheLambda; - -public interface ClassNameMixin { - - @JStacheLambda - @JStacheLambda.Raw - default String className(@JStacheLambda.Raw String body, DAOClassInfo classInfo) { - return className(body, classInfo.daoName()); - } - - default String className(String body, String daoName) { - return "$" + daoName + "$" +body; - } -} diff --git a/processor/src/main/java/org/ethelred/kiwiproc/processor/ContainerType.java b/processor/src/main/java/org/ethelred/kiwiproc/processor/ContainerType.java index d4bcc4d..ecfe7fb 100644 --- a/processor/src/main/java/org/ethelred/kiwiproc/processor/ContainerType.java +++ b/processor/src/main/java/org/ethelred/kiwiproc/processor/ContainerType.java @@ -1,2 +1,14 @@ -package org.ethelred.kiwiproc.processor;public record ContainerType() { +package org.ethelred.kiwiproc.processor; + +public record ContainerType(ValidContainerType type, KiwiType containedType) implements KiwiType { + + @Override + public String packageName() { + return type.javaType().getPackageName(); + } + + @Override + public String className() { + return type.javaType().getSimpleName(); + } } diff --git a/processor/src/main/java/org/ethelred/kiwiproc/processor/CoreTypes.java b/processor/src/main/java/org/ethelred/kiwiproc/processor/CoreTypes.java new file mode 100644 index 0000000..7c52dd5 --- /dev/null +++ b/processor/src/main/java/org/ethelred/kiwiproc/processor/CoreTypes.java @@ -0,0 +1,242 @@ +package org.ethelred.kiwiproc.processor; + +import static org.ethelred.util.collect.BiMap.entry; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.OffsetDateTime; +import java.time.OffsetTime; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.ethelred.util.collect.BiMap; +import org.jspecify.annotations.Nullable; + +public class CoreTypes { + public static final SimpleType STRING_TYPE = SimpleType.ofClass(String.class); + public static final Set> BASIC_TYPES = Set.of( + String.class, + BigInteger.class, + BigDecimal.class, + LocalDate.class, + LocalTime.class, + OffsetTime.class, + LocalDateTime.class, + OffsetDateTime.class); + + public record Conversion(boolean isValid, @Nullable String warning, String conversionFormat) { + public boolean hasWarning() { + return warning != null; + } + } + + public static final BiMap, Class> primitiveToBoxed = BiMap.ofEntries( + entry(boolean.class, Boolean.class), + entry(byte.class, Byte.class), + entry(char.class, Character.class), + entry(short.class, Short.class), + entry(int.class, Integer.class), + entry(long.class, Long.class), + entry(float.class, Float.class), + entry(double.class, Double.class)); + + /* + The key type can be assigned to any of the value types without casting. + Primitive type mappings that are NOT in this map require a cast and a "lossy converson" warning. + */ + private static final Map, Set>> assignableFrom = Map.of( + byte.class, Set.of(short.class, int.class, long.class, float.class, double.class), + char.class, Set.of(int.class, long.class, float.class, double.class), + short.class, Set.of(int.class, long.class, float.class, double.class), + int.class, Set.of(long.class, float.class, double.class), + long.class, Set.of(float.class, double.class), + float.class, Set.of(double.class)); + + // boxing a primitive type is also assignable + // unboxing is invalid in Kiwiproc, since it would convert a nullable to non-null + private static boolean isAssignable(Class source, Class target) { + return assignableFrom.getOrDefault(source, Set.of()).contains(target); + } + + private final Conversion invalid = new Conversion(false, null, "invalid"); + Map, SimpleType> coreTypes; + Map coreMappings; + + public CoreTypes() { + coreTypes = defineTypes(); + coreMappings = defineMappings(); + // System.out.println( + // coreMappings.entrySet().stream().map(Object::toString).collect(Collectors.joining("\n"))); + } + + private Map defineMappings() { + List> entries = new ArrayList<>(200); + + addPrimitiveMappings(entries); + addPrimitiveParseMappings(entries); + addBigNumberMappings(entries); + addDateTimeMappings(entries); + + return entries.stream() + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue, (a, b) -> a, LinkedHashMap::new)); + } + + private void addPrimitiveParseMappings(Collection> entries) { + primitiveToBoxed.keysA().forEach(target -> { + String warning = "possible NumberFormatException parsing String to %s".formatted(target.getName()); + Class boxed = primitiveToBoxed.getByA(target).orElseThrow(); + // String -> primitive + TypeMapping t = new TypeMapping(STRING_TYPE, coreTypes.get(target)); + Conversion c = new Conversion( + true, + warning, + "%s.parse%s(%%s)".formatted(boxed.getSimpleName(), Util.capitalizeFirst(target.getSimpleName()))); + entries.add(Map.entry(t, c)); + // String -> boxed + t = new TypeMapping(STRING_TYPE, coreTypes.get(boxed)); + c = new Conversion(true, warning, "%s.valueOf(%%s)".formatted(boxed.getSimpleName())); + entries.add(Map.entry(t, c)); + }); + } + + private void addDateTimeMappings(Collection> entries) {} + + private void addBigNumberMappings(Collection> entries) { + List.of(BigInteger.class, BigDecimal.class).forEach(big -> { + // primitive -> Big + Stream.of(byte.class, short.class, int.class, long.class, float.class, double.class) + .forEach(source -> { + entries.add(mappingEntry(source, big, null, "%s.valueOf(%%s)".formatted(big.getSimpleName()))); + entries.add(mappingEntry( + primitiveToBoxed.getByA(source).orElseThrow(), + big, + null, + "%s.valueOf(%%s)".formatted(big.getSimpleName()))); + }); + + // String -> Big + String warning = "possible NumberFormatException parsing String to %s".formatted(big.getSimpleName()); + entries.add(mappingEntry(String.class, big, warning, "new %s(%%s)".formatted(big.getSimpleName()))); + + // Big -> primitive + Stream.of(byte.class, short.class, int.class, long.class, float.class, double.class) + .forEach(target -> { + String w = "possible lossy conversion from %s to %s".formatted(big.getName(), target.getName()); + entries.add(mappingEntry(big, target, w, "%%s.%sValue()".formatted(target.getName()))); + entries.add(mappingEntry( + big, + primitiveToBoxed.getByA(target).orElseThrow(), + w, + "%%s.%sValue()".formatted(target.getName()))); + }); + }); + } + + private void addPrimitiveMappings(Collection> entries) { + // boxing - is assignment + primitiveToBoxed.mapByA().forEach((source, target) -> entries.add(mappingEntry(source, target, null, "%s"))); + + // primitive safe assignments + assignableFrom.forEach((source, targets) -> { + targets.forEach(target -> { + // primitive + entries.add(mappingEntry(source, target, null, "%s")); + // also boxing + entries.add(mappingEntry(source, primitiveToBoxed.getByA(target).orElseThrow(), null, "%s")); + // also boxing both + entries.add(mappingEntry( + primitiveToBoxed.getByA(source).orElseThrow(), + primitiveToBoxed.getByA(target).orElseThrow(), + null, + "%s")); + }); + }); + + // primitive lossy assignments + primitiveToBoxed.keysA().forEach(source -> { + primitiveToBoxed.keysA().forEach(target -> { + if (!source.equals(target) && !isAssignable(source, target)) { + String warning = + "possible lossy conversion from %s to %s".formatted(source.getName(), target.getName()); + String conversionFormat = "(%s) %%s".formatted(target.getName()); + entries.add(mappingEntry(source, target, warning, conversionFormat)); + // also boxing + entries.add(mappingEntry( + source, primitiveToBoxed.getByA(target).orElseThrow(), warning, conversionFormat)); + } + }); + }); + } + + private Map.Entry mappingEntry( + Class source, Class target, @Nullable String warning, String conversionFormat) { + var fromType = Objects.requireNonNull(coreTypes.get(source)); + var toType = Objects.requireNonNull(coreTypes.get(target)); + var mapping = new TypeMapping(fromType, toType); + var lookup = new Conversion(true, warning, conversionFormat); + return Map.entry(mapping, lookup); + } + + private Map, SimpleType> defineTypes() { + Map, SimpleType> builder = new LinkedHashMap<>(32); + primitiveToBoxed.keysA().forEach(c -> builder.put(c, SimpleType.ofClass(c))); + primitiveToBoxed.keysB().forEach(c -> builder.put(c, SimpleType.ofClass(c, true))); + BASIC_TYPES.forEach(c -> builder.put(c, SimpleType.ofClass(c))); + return Map.copyOf(builder); + } + + public KiwiType type(Class aClass) { + if (coreTypes.containsKey(aClass)) { + return coreTypes.get(aClass); + } + return KiwiType.unsupported(); + } + + public Conversion lookup(TypeMapping mapper) { + return lookup(mapper.source(), mapper.target()); + } + + public Conversion lookup(SimpleType source, SimpleType target) { + if (source.equals(target) || source.withIsNullable(true).equals(target)) { + return new Conversion(true, null, "%s"); + } + // special case String + Conversion stringConversion = null; + if (STRING_TYPE.equals(target) || STRING_TYPE.withIsNullable(true).equals(target)) { + stringConversion = new Conversion(true, null, "String.valueOf(%s)"); + } + var result = firstNonNull( + stringConversion, + coreMappings.get(new TypeMapping(source, target)), + coreMappings.get(new TypeMapping(source, target.withIsNullable(false))), + coreMappings.get(new TypeMapping(source.withIsNullable(false), target.withIsNullable(false))), + invalid); + if (result.isValid() && source.isNullable()) { + result = new Conversion(true, result.warning(), nullWrap(result.conversionFormat())); + } + return result; + } + + private Conversion firstNonNull(@Nullable Conversion... conversions) { + for (var c : conversions) { + if (c != null) { + return c; + } + } + throw new NullPointerException(); + } + + private String nullWrap(String conversionFormat) { + conversionFormat = conversionFormat.replace("%s", "% methods -) { + TypeElement element, DAOPrism annotation, String packageName, String daoName, List methods) { public String dataSourceName() { return annotation().dataSourceName(); } + + public String className(String suffix) { + return "$" + daoName + "$" + suffix; + } } diff --git a/processor/src/main/java/org/ethelred/kiwiproc/processor/DAODataSourceInfo.java b/processor/src/main/java/org/ethelred/kiwiproc/processor/DAODataSourceInfo.java new file mode 100644 index 0000000..d4f22d6 --- /dev/null +++ b/processor/src/main/java/org/ethelred/kiwiproc/processor/DAODataSourceInfo.java @@ -0,0 +1,7 @@ +package org.ethelred.kiwiproc.processor; + +public record DAODataSourceInfo(String dataSourceName, String packageName) { + public String className(String suffix) { + return "$" + Util.toTitleCase(dataSourceName) + "$" + suffix; + } +} diff --git a/processor/src/main/java/org/ethelred/kiwiproc/processor/DAOGenerator.java b/processor/src/main/java/org/ethelred/kiwiproc/processor/DAOGenerator.java index f47c35b..14dd21d 100644 --- a/processor/src/main/java/org/ethelred/kiwiproc/processor/DAOGenerator.java +++ b/processor/src/main/java/org/ethelred/kiwiproc/processor/DAOGenerator.java @@ -1,55 +1,9 @@ package org.ethelred.kiwiproc.processor; -import org.ethelred.kiwiproc.processor.templates.*; -import org.ethelred.kiwiproc.processorconfig.DependencyInjectionStyle; +public interface DAOGenerator { + void generateProvider(DAOClassInfo classInfo); -import javax.annotation.processing.Filer; -import java.io.IOException; -import java.io.UncheckedIOException; -import java.io.Writer; -import java.util.Set; + void generateImpl(DAOClassInfo classInfo); -public class DAOGenerator implements ClassNameMixin { - private final Filer filer; - private final DependencyInjectionStyle dependencyInjectionStyle; - - public DAOGenerator(Filer filer, DependencyInjectionStyle dependencyInjectionStyle) { - - this.filer = filer; - this.dependencyInjectionStyle = dependencyInjectionStyle; - } - - @FunctionalInterface - interface Renderer { - void render(Writer w, DAOClassInfo classInfo) throws IOException; - } - - private void generate(DAOClassInfo classInfo, String className, Renderer renderer) { - try { - var fqcn = classInfo.packageName() + "." + className; - var file = filer.createSourceFile(fqcn, classInfo.element()); - System.err.println("Generating " + file.getName()); - try (var w = file.openWriter()) { - renderer.render(w, classInfo); - } - } catch (IOException e) { - throw new UncheckedIOException(e); - } - } - - public void generateProvider(DAOClassInfo classInfo) { - generate(classInfo, className("Provider", classInfo), (w, ci) -> ProviderTemplateRenderer.of().execute(new ProviderTemplate(dependencyInjectionStyle, ci), w)); - } - - public void generateMapper(DAOClassInfo classInfo, Set mappings) { - generate(classInfo, className("Mapper", classInfo), (w, ci) -> MapperTemplateRenderer.of().execute(new MapperTemplate(ci, mappings), w)); - } - - public void generateImpl(DAOClassInfo classInfo) { - generate(classInfo, className("Impl", classInfo), (w, ci) -> ImplTemplateRenderer.of().execute(new ImplTemplate(ci), w)); - } - - public void generateRowRecord(DAOClassInfo classInfo, Signature rowRecord) { - generate(classInfo, rowRecord.name(), (w, ci) -> RowRecordTemplateRenderer.of().execute(new RowRecordTemplate(ci, rowRecord), w)); - } + void generateTransactionManager(DAODataSourceInfo dataSourceInfo); } diff --git a/processor/src/main/java/org/ethelred/kiwiproc/processor/DAOMethodInfo.java b/processor/src/main/java/org/ethelred/kiwiproc/processor/DAOMethodInfo.java index e428028..e965e1d 100644 --- a/processor/src/main/java/org/ethelred/kiwiproc/processor/DAOMethodInfo.java +++ b/processor/src/main/java/org/ethelred/kiwiproc/processor/DAOMethodInfo.java @@ -1,55 +1,43 @@ package org.ethelred.kiwiproc.processor; import io.soabase.recordbuilder.core.RecordBuilderFull; +import java.util.List; +import javax.lang.model.element.ExecutableElement; import org.ethelred.kiwiproc.meta.ParsedQuery; import org.jspecify.annotations.Nullable; -import java.util.List; -import java.util.Optional; -import java.util.stream.Stream; - @RecordBuilderFull public record DAOMethodInfo( + ExecutableElement methodElement, Signature signature, QueryMethodKind kind, ParsedQuery parsedSql, List parameterMapping, - @Nullable Signature rowRecord, - @Nullable DAOResultInfo singleColumn) implements ClassNameMixin { - - public Stream mappers() { - return Stream.concat( - parameterMapping.stream().map(DAOParameterInfo::mapper), - returnTypeMapping().stream() - ).filter(m -> !m.isIdentity()); - } + List multipleColumns, + @Nullable DAOResultColumn singleColumn) { - public Optional internalComponentType() { - if (rowRecord != null) { - return Optional.of(rowRecord.name()); + public KiwiType resultComponentType() { + var kiwiType = signature.returnType(); + if (kiwiType instanceof ContainerType containerType) { + return containerType.containedType(); } - if (singleColumn != null) { - return Optional.of(singleColumn.javaType()); - } - return Optional.empty(); - } - - public String resultComponentType() { - return signature.returnType().baseType(); + return kiwiType; } - public Optional returnTypeMapping() { - return internalComponentType() - .map(ict -> new TypeMapping(ict, signature.returnType().baseType())) - .filter(m -> !m.isIdentity()); + public boolean singleResult() { + var kiwiType = signature.returnType(); + if (kiwiType instanceof ContainerType containerType) { + return !containerType.type().isMultiValued(); + } + return true; } public String fromList() { - var container = signature.returnType().containerType(); - if (container != null) { + if (signature.returnType() instanceof ContainerType containerType) { + var container = containerType.type(); var template = container.fromListTemplate(); if (template.contains("%s")) { // hacky - return template.formatted(signature.returnType().baseType()); + return template.formatted(containerType.containedType()); } else { return template; } diff --git a/processor/src/main/java/org/ethelred/kiwiproc/processor/DAOParameterInfo.java b/processor/src/main/java/org/ethelred/kiwiproc/processor/DAOParameterInfo.java index eba2347..9a9a1d4 100644 --- a/processor/src/main/java/org/ethelred/kiwiproc/processor/DAOParameterInfo.java +++ b/processor/src/main/java/org/ethelred/kiwiproc/processor/DAOParameterInfo.java @@ -1,14 +1,22 @@ package org.ethelred.kiwiproc.processor; -import org.ethelred.kiwiproc.meta.ColumnMetaData; -import org.jspecify.annotations.Nullable; - import java.util.ArrayList; import java.util.List; import java.util.Map; +import javax.lang.model.element.VariableElement; +import org.ethelred.kiwiproc.meta.ColumnMetaData; +import org.jspecify.annotations.Nullable; -public record DAOParameterInfo(int index, String javaAccessor, String setter, TypeMapping mapper, @Nullable String arrayComponent) { - public static List from(TypeUtils typeUtils, Map parameterMapping) { +public record DAOParameterInfo( + int index, + String javaAccessor, + String setter, + int sqlType, + TypeMapping mapper, + VariableElement element, + @Nullable String arrayComponent) { + public static List from( + TypeUtils typeUtils, Map parameterMapping) { List result = new ArrayList<>(parameterMapping.size()); parameterMapping.forEach(((columnMetaData, methodParameterInfo) -> { @@ -16,14 +24,20 @@ public static List from(TypeUtils typeUtils, Map databases = new HashMap<>(); - private @Nullable DAOGenerator generator; + private @Nullable PoetDAOGenerator poet; + private final Set generatedTransactionManagers = new HashSet<>(); // automated adapter discovery doesn't work in annotation processor Jsonb jsonb = Jsonb.builder() @@ -63,8 +60,7 @@ public synchronized void init(ProcessingEnvironment processingEnv) { var configPath = processingEnv.getOptions().get(CONFIGURATION_OPTION); config = loadConfig(configPath); typeUtils = new TypeUtils(elements, types, logger); - typeValidator = new TypeValidator(typeUtils, logger); - generator = new DAOGenerator(processingEnv.getFiler(), config.dependencyInjectionStyle()); + poet = new PoetDAOGenerator(logger, processingEnv.getFiler(), config.dependencyInjectionStyle()); } private ProcessorConfig loadConfig(@Nullable String configPath) { @@ -83,7 +79,10 @@ private ProcessorConfig loadConfig(@Nullable String configPath) { return config; } } catch (Exception e) { - logger.error(null, "Exception reading config file '%s'. %s%n%s".formatted(path, e.getMessage(), stackTrace(e))); + logger.error( + null, + "Exception reading config file '%s'. %s%n%s" + .formatted(path, e.getMessage(), stackTrace(e))); } } } @@ -98,9 +97,7 @@ private String stackTrace(Exception e) { private DatabaseWrapper getDatabase(String name) { return databases.computeIfAbsent( - name, - n -> new DatabaseWrapper(n, config.dataSources().getOrDefault(n, null)) - ); + name, n -> new DatabaseWrapper(n, config.dataSources().getOrDefault(n, null))); } @Override @@ -119,46 +116,72 @@ public boolean process(Set annotations, RoundEnvironment return true; } - @Nullable - private DAOMethodInfo processMethod(String daoName, QueryMethodKind kind, DatabaseWrapper databaseWrapper, ExecutableElement methodElement) throws SQLException { - var parsedSql = ParsedQuery.parse( - kind.getSql(methodElement) - ); + @Nullable private DAOMethodInfo processMethod( + String daoName, QueryMethodKind kind, DatabaseWrapper databaseWrapper, ExecutableElement methodElement) + throws SQLException { + var parsedSql = ParsedQuery.parse(kind.getSql(methodElement)); var queryMetaData = databaseWrapper.getQueryMetaData(parsedSql.parsedSql()); var parameterInfo = MethodParameterInfo.fromElements(typeUtils, methodElement.getParameters()); - Map parameterMapping = mapParameters(methodElement, parsedSql.parameterNames(), queryMetaData.parameters(), parameterInfo); - if (!typeValidator.validateParameters(parameterMapping)) { + Map parameterMapping = + mapParameters(methodElement, parsedSql.parameterNames(), queryMetaData.parameters(), parameterInfo); + var typeValidator = new TypeValidator(logger, methodElement); + if (!typeValidator.validateParameters(parameterMapping, kind)) { return null; } List templateParameterMapping = DAOParameterInfo.from(typeUtils, parameterMapping); - var returnType = methodElement.getReturnType(); - if (!typeValidator.validateReturn(queryMetaData.resultColumns(), returnType) || !kind.validateReturn(typeUtils, returnType)) { + var returnType = typeUtils.kiwiType(methodElement.getReturnType()); + if (!typeValidator.validateReturn(queryMetaData.resultColumns(), returnType, kind)) { logger.error(methodElement, "Invalid return type"); return null; } - Signature rowRecord = null; - DAOResultInfo singleColumnResult = null; + List multipleColumnResults = new ArrayList<>(); + DAOResultColumn singleColumnResult = null; + var returnComponentType = + returnType instanceof ContainerType containerType ? containerType.containedType() : returnType; if (kind == QUERY && queryMetaData.resultColumns().size() > 1) { - var rowBuilder = SignatureBuilder.builder().name(className(methodElement.getSimpleName() + "$Row", daoName)); - queryMetaData.resultColumns().forEach(col -> { - var name = col.name(); - rowBuilder.addParams(new DAOResultInfo(name, col.javaType(), SqlTypeMapping.get(col.sqlType()))); - }); - rowRecord = rowBuilder.build(); + if (returnComponentType instanceof RecordType recordType) { + recordType.components().forEach((component) -> { + var colOpt = queryMetaData.resultColumns().stream() + .filter(c -> component.name().equals(c.name())) + .findFirst(); + colOpt.ifPresentOrElse( + col -> multipleColumnResults.add( + new DAOResultColumn(component.name(), SqlTypeMapping.get(col), component.type())), + () -> logger.error( + methodElement, + "No matching column found for record component \"%s\"" + .formatted(component.name()))); + }); + } else { + logger.error(methodElement, "A query with multiple columns must be mapped to a Record type"); + } } else if (queryMetaData.resultColumns().size() == 1) { var col = queryMetaData.resultColumns().get(0); - singleColumnResult = new DAOResultInfo(col.name(), col.javaType(), SqlTypeMapping.get(col.sqlType())); + singleColumnResult = new DAOResultColumn(col.name(), SqlTypeMapping.get(col), returnComponentType); } - return new DAOMethodInfo(Signature.fromMethod(typeUtils, methodElement), kind, parsedSql, templateParameterMapping, rowRecord, singleColumnResult); + return new DAOMethodInfo( + methodElement, + Signature.fromMethod(typeUtils, methodElement), + kind, + parsedSql, + templateParameterMapping, + multipleColumnResults, + singleColumnResult); } - private Map mapParameters(ExecutableElement methodElement, List parameterNames, List queryParameters, Map methodParameters) { + private Map mapParameters( + ExecutableElement methodElement, + List parameterNames, + List queryParameters, + Map methodParameters) { Map r = new HashMap<>(); - for (var queryParameter: queryParameters) { + for (var queryParameter : queryParameters) { var name = parameterNames.get(queryParameter.index() - 1); var methodParameter = methodParameters.get(name); if (methodParameter == null) { - logger.error(methodElement, "No method parameter found for query parameter '%s'".formatted(queryParameter.name())); + logger.error( + methodElement, + "No method parameter found for query parameter '%s'".formatted(queryParameter.name())); } else { r.put(queryParameter, methodParameter); } @@ -167,20 +190,33 @@ private Map mapParameters(ExecutableElement } private void processInterface(TypeElement interfaceElement) throws SQLException { + Objects.requireNonNull(typeUtils, "processInterface called before init?"); + Objects.requireNonNull(poet, "processInterface called before init?"); var daoAnn = DAOPrism.getInstanceOn(interfaceElement); var dataSourceName = daoAnn.dataSourceName(); var databaseWrapper = getDatabase(dataSourceName); if (!databaseWrapper.isValid()) { - logger.error(interfaceElement, "Could not get valid datasource for name '%s'. %s".formatted(dataSourceName, databaseWrapper.getError().getMessage())); + logger.error( + interfaceElement, + "Could not get valid datasource for methodName '%s'. %s" + .formatted( + dataSourceName, databaseWrapper.getError().getMessage())); return; } var classBuilder = DAOClassInfoBuilder.builder(); String daoName = interfaceElement.getSimpleName().toString(); - classBuilder.element(interfaceElement).annotation(daoAnn).daoName(daoName).packageName(typeUtils.packageName(interfaceElement)); - for (var methodElement: ElementFilter.methodsIn(Set.copyOf(interfaceElement.getEnclosedElements()))) { + String packageName = typeUtils.packageName(interfaceElement); + classBuilder + .element(interfaceElement) + .annotation(daoAnn) + .daoName(daoName) + .packageName(packageName); + for (var methodElement : ElementFilter.methodsIn(Set.copyOf(interfaceElement.getEnclosedElements()))) { var kinds = QueryMethodKind.forMethod(methodElement); if (kinds.isEmpty()) { - logger.error(methodElement, "Must have a '@SqlQuery', '@SqlUpdate' or '@SqlBatch' annotation or a 'default' implementation."); + logger.error( + methodElement, + "Must have a '@SqlQuery', '@SqlUpdate' or '@SqlBatch' annotation or a 'default' implementation."); } else if (kinds.size() > 1) { logger.error(methodElement, "May only have one Sql annotation, or be default."); } @@ -193,18 +229,16 @@ private void processInterface(TypeElement interfaceElement) throws SQLException if (methodInfo != null) { classBuilder.addMethods(methodInfo); } - } if (classBuilder.methods().isEmpty()) { logger.error(interfaceElement, "No valid Sql or default methods found."); return; } var classInfo = classBuilder.build(); - var mappings = classInfo.methods().stream() - .flatMap(DAOMethodInfo::mappers).collect(Collectors.toSet()); - generator.generateProvider(classInfo); - generator.generateMapper(classInfo, mappings); - generator.generateImpl(classInfo); - classInfo.methods().stream().map(DAOMethodInfo::rowRecord).filter(Objects::nonNull).forEach(s -> generator.generateRowRecord(classInfo, s)); + poet.generateImpl(classInfo); + poet.generateProvider(classInfo); + if (generatedTransactionManagers.add(dataSourceName)) { + poet.generateTransactionManager(new DAODataSourceInfo(dataSourceName, packageName)); + } } } diff --git a/processor/src/main/java/org/ethelred/kiwiproc/processor/KiwiType.java b/processor/src/main/java/org/ethelred/kiwiproc/processor/KiwiType.java index c309bf3..c28c96d 100644 --- a/processor/src/main/java/org/ethelred/kiwiproc/processor/KiwiType.java +++ b/processor/src/main/java/org/ethelred/kiwiproc/processor/KiwiType.java @@ -1,2 +1,14 @@ -package org.ethelred.kiwiproc.processor;public interface KiwiType { +package org.ethelred.kiwiproc.processor; + +/** + * Simplified view of type for parameters and return. + */ +public sealed interface KiwiType permits ContainerType, RecordType, SimpleType, UnsupportedType, VoidType { + static KiwiType unsupported() { + return new UnsupportedType(); + } + + String packageName(); + + String className(); } diff --git a/processor/src/main/java/org/ethelred/kiwiproc/processor/KiwiTypeVisitor.java b/processor/src/main/java/org/ethelred/kiwiproc/processor/KiwiTypeVisitor.java new file mode 100644 index 0000000..cb8a480 --- /dev/null +++ b/processor/src/main/java/org/ethelred/kiwiproc/processor/KiwiTypeVisitor.java @@ -0,0 +1,57 @@ +package org.ethelred.kiwiproc.processor; + +import java.util.ArrayList; +import java.util.List; +import javax.lang.model.type.*; +import javax.lang.model.util.SimpleTypeVisitor14; + +public class KiwiTypeVisitor extends SimpleTypeVisitor14 { + private final TypeUtils utils; + + public KiwiTypeVisitor(TypeUtils utils) { + this.utils = utils; + } + + @Override + public KiwiType visitPrimitive(PrimitiveType t, Void ignore) { + return new SimpleType("", t.toString(), false); + } + + @Override + public KiwiType visitArray(ArrayType t, Void ignore) { + return new ContainerType(ValidContainerType.ARRAY, visit(t.getComponentType())); + } + + @Override + public KiwiType visitDeclared(DeclaredType t, Void ignore) { + if (utils.isBoxed(t)) { + return new SimpleType(utils.packageName(t), utils.className(t), true); + } + if (CoreTypes.BASIC_TYPES.stream().anyMatch(bt -> utils.isSameType(t, utils.type(bt)))) { + return new SimpleType(utils.packageName(t), utils.className(t), utils.isNullable(t)); + } + for (var vct : ValidContainerType.values()) { + if (utils.isSameType(utils.erasure(t), utils.erasure(vct.javaType()))) { + var typeArguments = t.getTypeArguments(); + return new ContainerType(vct, visit(typeArguments.get(0))); + } + } + if (utils.isRecord(t)) { + var componentTypes = new ArrayList(); + for (var component : utils.recordComponents(t)) { + componentTypes.add(new RecordType.RecordTypeComponent( + component.getSimpleName().toString(), visit(component.asType()))); + } + return new RecordType(utils.packageName(t), utils.className(t), List.copyOf(componentTypes)); + } + return KiwiType.unsupported(); + } + + @Override + public KiwiType visitNoType(NoType t, Void unused) { + if (t.getKind().equals(TypeKind.VOID)) { + return new VoidType(); + } + return KiwiType.unsupported(); + } +} diff --git a/processor/src/main/java/org/ethelred/kiwiproc/processor/MethodParameterInfo.java b/processor/src/main/java/org/ethelred/kiwiproc/processor/MethodParameterInfo.java index e6037fa..abda6fe 100644 --- a/processor/src/main/java/org/ethelred/kiwiproc/processor/MethodParameterInfo.java +++ b/processor/src/main/java/org/ethelred/kiwiproc/processor/MethodParameterInfo.java @@ -1,36 +1,29 @@ package org.ethelred.kiwiproc.processor; import io.soabase.recordbuilder.core.RecordBuilderFull; -import org.jspecify.annotations.Nullable; - -import javax.lang.model.element.VariableElement; -import javax.lang.model.type.TypeMirror; import java.util.*; import java.util.stream.Collectors; +import javax.lang.model.element.VariableElement; +import org.jspecify.annotations.Nullable; @RecordBuilderFull public record MethodParameterInfo( VariableElement variableElement, String name, - TypeMirror type, + KiwiType type, boolean isRecordComponent, - boolean isNullable, - @Nullable String recordParameterName -) { + @Nullable String recordParameterName) { - static Map fromElements(TypeUtils types, List variableElements) { + static Map fromElements( + TypeUtils types, List variableElements) { return variableElements.stream() .flatMap(variableElement -> fromElement(types, variableElement).stream()) - .collect(Collectors.toMap( - MethodParameterInfo::name, - x -> x, - MethodParameterInfo::merge - )); + .collect(Collectors.toMap(MethodParameterInfo::name, x -> x, MethodParameterInfo::merge)); } private static MethodParameterInfo merge(MethodParameterInfo a, MethodParameterInfo b) { if (a.isRecordComponent == b.isRecordComponent) { - throw new IllegalStateException("Can't have 2 parameters with same name"); + throw new IllegalStateException("Can't have 2 parameters with same methodName"); } if (a.isRecordComponent) { return b; @@ -49,9 +42,10 @@ static Set fromElement(TypeUtils types, VariableElement var private static Set fromSingle(TypeUtils types, VariableElement variableElement) { var info = MethodParameterInfoBuilder.builder() .name(variableElement.getSimpleName().toString()) - .type(variableElement.asType()) + .type(types.kiwiType(variableElement.asType())) .isRecordComponent(false) .recordParameterName(null) + .variableElement(variableElement) .build(); return Set.of(info); } @@ -62,11 +56,11 @@ private static Set fromRecord(TypeUtils types, VariableElem var parameterInfos = components.stream() .map(component -> MethodParameterInfoBuilder.builder() .name(component.getSimpleName().toString()) - .type(component.asType()) + .type(types.kiwiType(component.asType())) .isRecordComponent(true) .recordParameterName(variableElement.getSimpleName().toString()) - .build() - ) + .variableElement(variableElement) + .build()) .collect(Collectors.toCollection(HashSet::new)); parameterInfos.addAll(fromSingle(types, variableElement)); return parameterInfos; diff --git a/processor/src/main/java/org/ethelred/kiwiproc/processor/QueryMethodKind.java b/processor/src/main/java/org/ethelred/kiwiproc/processor/QueryMethodKind.java index 4c3bb3b..8e5b320 100644 --- a/processor/src/main/java/org/ethelred/kiwiproc/processor/QueryMethodKind.java +++ b/processor/src/main/java/org/ethelred/kiwiproc/processor/QueryMethodKind.java @@ -1,13 +1,11 @@ package org.ethelred.kiwiproc.processor; -import javax.lang.model.element.ExecutableElement; -import javax.lang.model.type.TypeKind; -import javax.lang.model.type.TypeMirror; -import java.util.Objects; import java.util.Set; import java.util.function.Predicate; import java.util.stream.Collectors; import java.util.stream.Stream; +import javax.lang.model.element.ExecutableElement; +import javax.lang.model.type.TypeMirror; public enum QueryMethodKind { DEFAULT(ExecutableElement::isDefault), @@ -27,11 +25,12 @@ public String getSql(ExecutableElement element) { @Override public boolean validateReturn(TypeUtils typeUtils, TypeMirror returnType) { - var result = typeUtils.returnType(returnType); - var kind = result.baseTypeKind(); - return kind == TypeKind.VOID - || (!result.isMultiValued() && (kind == TypeKind.INT || kind == TypeKind.LONG || kind == TypeKind.BOOLEAN)); - + // var result = typeUtils.returnType(returnType); + // var kind = result.baseTypeKind(); + // return kind == TypeKind.VOID + // || (!result.isMultiValued() && (kind == TypeKind.INT || kind == TypeKind.LONG || + // kind == TypeKind.BOOLEAN)); + return true; } }, BATCH(SqlBatchPrism::isPresent) { @@ -44,11 +43,12 @@ public String getSql(ExecutableElement element) { @Override public boolean validateReturn(TypeUtils typeUtils, TypeMirror returnType) { - var result = typeUtils.returnType(returnType); - var kind = result.baseTypeKind(); - return kind == TypeKind.VOID - || (result.isMultiValued() && (kind == TypeKind.INT || kind == TypeKind.LONG || kind == TypeKind.BOOLEAN)); - + // var result = typeUtils.returnType(returnType); + // var kind = result.baseTypeKind(); + // return kind == TypeKind.VOID + // || (result.isMultiValued() && (kind == TypeKind.INT || kind == TypeKind.LONG || + // kind == TypeKind.BOOLEAN)); + return true; } }; @@ -59,9 +59,7 @@ public boolean validateReturn(TypeUtils typeUtils, TypeMirror returnType) { } static Set forMethod(ExecutableElement methodElement) { - return Stream.of(values()) - .filter(p -> p.isKind.test(methodElement)) - .collect(Collectors.toSet()); + return Stream.of(values()).filter(p -> p.isKind.test(methodElement)).collect(Collectors.toSet()); } public String getSql(ExecutableElement element) { diff --git a/processor/src/main/java/org/ethelred/kiwiproc/processor/RecordType.java b/processor/src/main/java/org/ethelred/kiwiproc/processor/RecordType.java new file mode 100644 index 0000000..52f463b --- /dev/null +++ b/processor/src/main/java/org/ethelred/kiwiproc/processor/RecordType.java @@ -0,0 +1,8 @@ +package org.ethelred.kiwiproc.processor; + +import java.util.List; + +public record RecordType(String packageName, String className, List components) + implements KiwiType { + public record RecordTypeComponent(String name, KiwiType type) {} +} diff --git a/processor/src/main/java/org/ethelred/kiwiproc/processor/ReturnType.java b/processor/src/main/java/org/ethelred/kiwiproc/processor/ReturnType.java deleted file mode 100644 index deea4b1..0000000 --- a/processor/src/main/java/org/ethelred/kiwiproc/processor/ReturnType.java +++ /dev/null @@ -1,23 +0,0 @@ -package org.ethelred.kiwiproc.processor; - -import org.jspecify.annotations.Nullable; - -import javax.lang.model.type.TypeKind; - -public record ReturnType( - String baseType, - TypeKind baseTypeKind, - @Nullable ContainerType containerType - -) { - public boolean isMultiValued() { - return containerType != null && containerType.isMultiValued(); - } - - public String declaration() { - if (containerType != null) { - return "%s<%s>".formatted(containerType.javaType().getName(), baseType); - } - return baseType; - } -} diff --git a/processor/src/main/java/org/ethelred/kiwiproc/processor/ReturnTypeVisitor.java b/processor/src/main/java/org/ethelred/kiwiproc/processor/ReturnTypeVisitor.java deleted file mode 100644 index f57baf5..0000000 --- a/processor/src/main/java/org/ethelred/kiwiproc/processor/ReturnTypeVisitor.java +++ /dev/null @@ -1,46 +0,0 @@ -package org.ethelred.kiwiproc.processor; - -import javax.lang.model.type.*; -import javax.lang.model.util.SimpleTypeVisitor14; -import java.util.Optional; - -class ReturnTypeVisitor extends SimpleTypeVisitor14, Void> { - private final TypeUtils typeUtils; - - public ReturnTypeVisitor(TypeUtils typeUtils) { - super(Optional.empty()); - this.typeUtils = typeUtils; - } - - @Override - public Optional visitPrimitive(PrimitiveType t, Void unused) { - return Optional.of(new ReturnType(t.toString(), t.getKind(), null)); - } - - @Override - public Optional visitArray(ArrayType t, Void unused) { - var componentType = visit(t.getComponentType()); - return componentType.map(s -> new ReturnType(s.baseType(), s.baseTypeKind(), ContainerType.ARRAY)); - } - - @Override - public Optional visitDeclared(DeclaredType t, Void unused) { - try { - var unboxed = typeUtils.unboxedType(t); - return visitPrimitive(unboxed, null); - } catch (IllegalArgumentException e) { - // ignore - } - var containerType = typeUtils.containerType(t); - if (containerType != null) { - var componentType = visit(t.getTypeArguments().get(0)); - return componentType.map(s -> new ReturnType(s.baseType(), s.baseTypeKind(), containerType)); - } - return Optional.of(new ReturnType(typeUtils.toString(t), t.getKind(), null)); - } - - @Override - public Optional visitNoType(NoType t, Void unused) { - return Optional.of(new ReturnType("void", TypeKind.VOID, null)); - } -} diff --git a/processor/src/main/java/org/ethelred/kiwiproc/processor/Signature.java b/processor/src/main/java/org/ethelred/kiwiproc/processor/Signature.java index 77d23d4..50aaa39 100644 --- a/processor/src/main/java/org/ethelred/kiwiproc/processor/Signature.java +++ b/processor/src/main/java/org/ethelred/kiwiproc/processor/Signature.java @@ -1,31 +1,24 @@ package org.ethelred.kiwiproc.processor; import io.soabase.recordbuilder.core.RecordBuilderFull; - -import javax.lang.model.element.ExecutableElement; -import java.sql.JDBCType; import java.util.ArrayList; import java.util.List; +import javax.lang.model.element.ExecutableElement; @RecordBuilderFull -public record Signature(ReturnType returnType, String name, List params) { +public record Signature(KiwiType returnType, String methodName, List params) { static Signature fromMethod(TypeUtils typeUtils, ExecutableElement element) { - List params = new ArrayList<>(); - for (var parameterElement: element.getParameters()) { - params.add( - new DAOResultInfo(parameterElement.getSimpleName().toString(), - parameterElement.asType().toString(), - SqlTypeMapping.get(JDBCType.INTEGER)) // TODO - ); + List params = new ArrayList<>(); + for (var parameterElement : element.getParameters()) { + // params.add( + // new DAOResultColumn( + // parameterElement.getSimpleName().toString(), + // SqlTypeMapping.get(JDBCType.INTEGER)) // TODO + // ); } return new Signature( - typeUtils.returnType(element.getReturnType()), + typeUtils.kiwiType(element.getReturnType()), element.getSimpleName().toString(), - params - ); - } - - public String returnTypeDeclaration() { - return returnType.declaration(); + params); } } diff --git a/processor/src/main/java/org/ethelred/kiwiproc/processor/SimpleType.java b/processor/src/main/java/org/ethelred/kiwiproc/processor/SimpleType.java new file mode 100644 index 0000000..d5d5ed3 --- /dev/null +++ b/processor/src/main/java/org/ethelred/kiwiproc/processor/SimpleType.java @@ -0,0 +1,26 @@ +package org.ethelred.kiwiproc.processor; + +public record SimpleType(String packageName, String className, boolean isNullable) implements KiwiType { + public static SimpleType ofClass(Class aClass, boolean isNullable) { + var packageName = aClass.getPackageName(); + + if (aClass.isPrimitive()) { + packageName = ""; + } + + return new SimpleType(packageName, aClass.getSimpleName(), isNullable); + } + + public static SimpleType ofClass(Class aClass) { + return ofClass(aClass, false); + } + + @Override + public String toString() { + return className + (isNullable ? "/nullable" : "/non-null"); + } + + public SimpleType withIsNullable(boolean newValue) { + return new SimpleType(packageName, className, newValue); + } +} diff --git a/processor/src/main/java/org/ethelred/kiwiproc/processor/SqlTypeMapping.java b/processor/src/main/java/org/ethelred/kiwiproc/processor/SqlTypeMapping.java index 2ee9715..e61fb33 100644 --- a/processor/src/main/java/org/ethelred/kiwiproc/processor/SqlTypeMapping.java +++ b/processor/src/main/java/org/ethelred/kiwiproc/processor/SqlTypeMapping.java @@ -1,39 +1,88 @@ package org.ethelred.kiwiproc.processor; +import io.soabase.recordbuilder.core.RecordBuilderFull; import java.math.BigDecimal; import java.sql.JDBCType; +import java.time.*; import java.util.List; import java.util.Map; import java.util.stream.Collectors; +import org.ethelred.kiwiproc.meta.ColumnMetaData; + +@RecordBuilderFull +public record SqlTypeMapping( + JDBCType jdbcType, + Class baseType, + // empty accessorSuffix indicates to use getObject/setObject + String accessorSuffix, + boolean specialCase, + boolean isNullable) + implements SqlTypeMappingBuilder.With { + public SqlTypeMapping(JDBCType jdbcType, Class baseType, String accessorSuffix) { + this(jdbcType, baseType, accessorSuffix, false, false); + } -public record SqlTypeMapping(JDBCType jdbcType, Class baseType, String accessorSuffix, boolean specialCase) { private static final List types = List.of( - new SqlTypeMapping(JDBCType.BIT, boolean.class, "Boolean", false), - new SqlTypeMapping(JDBCType.BIGINT, long.class, "Long", false), - new SqlTypeMapping(JDBCType.TINYINT, byte.class, "Byte", false), - new SqlTypeMapping(JDBCType.SMALLINT, short.class, "Short", false), - new SqlTypeMapping(JDBCType.INTEGER, int.class, "Int", false), - new SqlTypeMapping(JDBCType.FLOAT, double.class, "Double", false), // postgres treats "FLOAT" and "DOUBLE" the same - new SqlTypeMapping(JDBCType.REAL, float.class, "Float", false), - new SqlTypeMapping(JDBCType.DOUBLE, double.class, "Double", false), - new SqlTypeMapping(JDBCType.NUMERIC, BigDecimal.class, "BigDecimal", false), - new SqlTypeMapping(JDBCType.DECIMAL, BigDecimal.class, "BigDecimal", false), - new SqlTypeMapping(JDBCType.CHAR, String.class, "String", false), - new SqlTypeMapping(JDBCType.VARCHAR, String.class, "String", false), - new SqlTypeMapping(JDBCType.LONGVARCHAR, String.class, "String", false), - new SqlTypeMapping(JDBCType.ARRAY, java.sql.Array.class, "Array", true) + new SqlTypeMapping(JDBCType.BIT, boolean.class, "Boolean"), + new SqlTypeMapping(JDBCType.BOOLEAN, boolean.class, "Boolean"), + new SqlTypeMapping(JDBCType.BIGINT, long.class, "Long"), + new SqlTypeMapping(JDBCType.TINYINT, byte.class, "Byte"), + new SqlTypeMapping(JDBCType.SMALLINT, short.class, "Short"), + new SqlTypeMapping(JDBCType.INTEGER, int.class, "Int"), + new SqlTypeMapping(JDBCType.FLOAT, double.class, "Double"), // postgres treats "FLOAT" and "DOUBLE" the same + new SqlTypeMapping(JDBCType.REAL, float.class, "Float"), + new SqlTypeMapping(JDBCType.DOUBLE, double.class, "Double"), + new SqlTypeMapping(JDBCType.NUMERIC, BigDecimal.class, "BigDecimal"), + new SqlTypeMapping(JDBCType.DECIMAL, BigDecimal.class, "BigDecimal"), + new SqlTypeMapping(JDBCType.CHAR, String.class, "String"), + new SqlTypeMapping(JDBCType.VARCHAR, String.class, "String"), + new SqlTypeMapping(JDBCType.LONGVARCHAR, String.class, "String"), + new SqlTypeMapping(JDBCType.NCHAR, String.class, "String"), + new SqlTypeMapping(JDBCType.NVARCHAR, String.class, "String"), + new SqlTypeMapping(JDBCType.LONGNVARCHAR, String.class, "String"), + new SqlTypeMapping(JDBCType.ARRAY, java.sql.Array.class, "Array", true, false), + // dates and times + // Use java.time types - recommended for Postgres https://tada.github.io/pljava/use/datetime.html + new SqlTypeMapping(JDBCType.DATE, LocalDate.class, ""), + new SqlTypeMapping(JDBCType.TIME, LocalTime.class, ""), + new SqlTypeMapping(JDBCType.TIME_WITH_TIMEZONE, OffsetTime.class, ""), + new SqlTypeMapping(JDBCType.TIMESTAMP, LocalDateTime.class, ""), + new SqlTypeMapping(JDBCType.TIMESTAMP_WITH_TIMEZONE, OffsetDateTime.class, ""), + new SqlTypeMapping(JDBCType.NULL, void.class, "", true, true) + // TODO fill out types as necessary - ); - private static final Map lookup = types.stream().collect(Collectors.toMap( - SqlTypeMapping::jdbcType, - t -> t - )); + ); + private static final Map lookup = + types.stream().collect(Collectors.toMap(SqlTypeMapping::jdbcType, t -> t)); - public static SqlTypeMapping get(JDBCType jdbcType) { - var r = lookup.get(jdbcType); + public static SqlTypeMapping get(ColumnMetaData columnMetaData) { + var r = lookup.get(columnMetaData.sqlType()); if (r == null) { - throw new IllegalArgumentException("Unsupported type " + jdbcType); + throw new IllegalArgumentException("Unsupported JDBCType type " + columnMetaData.sqlType()); + } + return r.withIsNullable(columnMetaData.nullable()); + } + + public KiwiType kiwiType() { + // TODO fix array type handling + // if (jdbcType == JDBCType.ARRAY && columnMetaData.componentType() != null) { + // var componentSql = get(columnMetaData.componentType()); + // var containedClass = componentSql.baseType; + // return ContainerTypeBuilder.builder() + // .type(ValidContainerType.ARRAY) + // .containedType(SimpleTypeBuilder.builder() + // .className(containedClass.getName()) + // .isNullable(columnMetaData.nullable()) + // .build()) + // .build(); + // } + var resolvedType = baseType; + if (isNullable) { + var maybeBoxed = CoreTypes.primitiveToBoxed.getByA(baseType); + if (maybeBoxed.isPresent()) { + resolvedType = maybeBoxed.get(); + } } - return r; + return SimpleType.ofClass(resolvedType, isNullable); } } diff --git a/processor/src/main/java/org/ethelred/kiwiproc/processor/TypeMapping.java b/processor/src/main/java/org/ethelred/kiwiproc/processor/TypeMapping.java index e754552..34c553e 100644 --- a/processor/src/main/java/org/ethelred/kiwiproc/processor/TypeMapping.java +++ b/processor/src/main/java/org/ethelred/kiwiproc/processor/TypeMapping.java @@ -1,27 +1,11 @@ package org.ethelred.kiwiproc.processor; -import java.util.Objects; +public record TypeMapping(SimpleType source, SimpleType target) { -public record TypeMapping(String source, String target) { - public static final TypeMapping VOID = new TypeMapping("void", "void"); - - public boolean isIdentity() { - return Objects.equals(source, target); - } - - public String methodName() { - var builder = new StringBuilder("to"); - boolean up = true; - for(var c: target.toCharArray()) { - if (!Character.isJavaIdentifierPart(c)) { - up = true; - } else if (up) { - builder.append(Character.toUpperCase(c)); - up = false; - } else { - builder.append(c); - } + public static TypeMapping of(KiwiType source, KiwiType target) { + if (source instanceof SimpleType simpleSource && target instanceof SimpleType simpleTarget) { + return new TypeMapping(simpleSource, simpleTarget); } - return builder.toString(); + throw new IllegalArgumentException("TypeMapping requires simple types (%s) -> (%s)".formatted(source, target)); } } diff --git a/processor/src/main/java/org/ethelred/kiwiproc/processor/TypeUtils.java b/processor/src/main/java/org/ethelred/kiwiproc/processor/TypeUtils.java index a810ff1..ab750f2 100644 --- a/processor/src/main/java/org/ethelred/kiwiproc/processor/TypeUtils.java +++ b/processor/src/main/java/org/ethelred/kiwiproc/processor/TypeUtils.java @@ -2,21 +2,14 @@ import com.karuslabs.utilitary.Logger; import com.karuslabs.utilitary.type.TypeMirrors; -import org.ethelred.kiwiproc.meta.ColumnMetaData; -import org.jspecify.annotations.Nullable; - +import java.util.List; +import java.util.Objects; import javax.lang.model.element.*; import javax.lang.model.type.*; import javax.lang.model.util.Elements; import javax.lang.model.util.SimpleTypeVisitor14; import javax.lang.model.util.Types; -import java.math.BigDecimal; -import java.sql.JDBCType; -import java.sql.Time; -import java.sql.Timestamp; -import java.util.List; -import java.util.Objects; -import java.util.Optional; +import org.jspecify.annotations.Nullable; public class TypeUtils extends TypeMirrors { private final Logger logger; @@ -50,54 +43,8 @@ public String toString(TypeMirror type) { return new ToStringVisitor().visit(type); } - Optional sqlType(ColumnMetaData target) { - TypeMirror targetType = null; - var baseClass = baseSqlType(target.sqlType()); - if (baseClass == null) { -if (target.sqlType() == JDBCType.ARRAY) { - baseClass = arraySqlType(target); - targetType = type(baseClass); -} - } else if (baseClass.isPrimitive() && target.nullable()) { - targetType = box(type(baseClass)); - } else { - targetType = type(baseClass); - } - - return Optional.ofNullable(targetType); - } - - private Class arraySqlType(ColumnMetaData target) { - return null; - } - - @Nullable - Class baseSqlType(JDBCType jdbcType) { - return switch (jdbcType) { - case BIT, BOOLEAN -> boolean.class; - case BINARY, LONGVARBINARY, VARBINARY -> byte[].class; - case CHAR, LONGNVARCHAR, LONGVARCHAR, NCHAR, NVARCHAR, VARCHAR -> String.class; - case DATE -> java.sql.Date.class; - case DECIMAL -> BigDecimal.class; - case DOUBLE -> double.class; - case FLOAT, REAL -> float.class; - case INTEGER -> int.class; - case BIGINT -> long.class; - case SMALLINT -> short.class; - case TINYINT -> byte.class; - case TIME, TIME_WITH_TIMEZONE -> Time.class; - case TIMESTAMP, TIMESTAMP_WITH_TIMEZONE -> Timestamp.class; - case ARRAY, OTHER -> null; // null means additional checks are required - default -> throw new IllegalArgumentException("Unsupported type " + jdbcType); - }; - } - - public ReturnType returnType(TypeMirror returnType) { - return new ReturnTypeVisitor(this).visit(returnType).orElseThrow(); - } - - public @Nullable ContainerType containerType(DeclaredType t) { - for (var ct: ContainerType.values()) { + public @Nullable ValidContainerType containerType(DeclaredType t) { + for (var ct : ValidContainerType.values()) { if (is(t, ct.javaType())) { return ct; } @@ -108,7 +55,7 @@ public ReturnType returnType(TypeMirror returnType) { @Override public boolean isSameType(TypeMirror t1, TypeMirror t2) { var result = super.isSameType(t1, t2); - logger.note(null, "isSameType(%s, %s) = %s".formatted(t1, t2, result)); + // logger.note(null, "isSameType(%s, %s) = %s".formatted(t1, t2, result)); return result; } @@ -116,7 +63,44 @@ public List recordComponents(DeclaredType t) { return Objects.requireNonNull(asTypeElement(t)).getRecordComponents(); } - class ToStringVisitor extends SimpleTypeVisitor14 { + public boolean isBoxed(DeclaredType t) { + try { + unboxedType(t); + return true; + } catch (IllegalArgumentException e) { + return false; + } + } + + public boolean isNullable(DeclaredType t) { + var annotations = t.getAnnotationMirrors(); + for (var annotationMirror : annotations) { + if (annotationMirror.getAnnotationType().toString().endsWith("Nullable")) { + return true; + } + } + return false; + } + + public KiwiType kiwiType(TypeMirror type) { + return type.accept(new KiwiTypeVisitor(this), null); + } + + public String fqcn(DeclaredType t) { + var te = (TypeElement) t.asElement(); + return te.getQualifiedName().toString(); + } + + public String packageName(DeclaredType t) { + return packageName((TypeElement) t.asElement()); + } + + public String className(DeclaredType t) { + var te = (TypeElement) t.asElement(); + return te.getSimpleName().toString(); + } + + class ToStringVisitor extends SimpleTypeVisitor14 { @Override protected String defaultAction(TypeMirror e, Void unused) { throw new UnsupportedOperationException(String.valueOf(e)); @@ -138,7 +122,7 @@ public String visitDeclared(DeclaredType t, Void unused) { if ("java.lang".equals(packageName(te))) { return te.getSimpleName().toString(); } - return String.valueOf(t);//TODO + return String.valueOf(t); // TODO } @Override diff --git a/processor/src/main/java/org/ethelred/kiwiproc/processor/TypeValidator.java b/processor/src/main/java/org/ethelred/kiwiproc/processor/TypeValidator.java index 7de0105..fe49e93 100644 --- a/processor/src/main/java/org/ethelred/kiwiproc/processor/TypeValidator.java +++ b/processor/src/main/java/org/ethelred/kiwiproc/processor/TypeValidator.java @@ -1,176 +1,233 @@ package org.ethelred.kiwiproc.processor; import com.karuslabs.utilitary.Logger; +import java.util.*; +import javax.lang.model.element.Element; import org.ethelred.kiwiproc.meta.ColumnMetaData; -import javax.lang.model.element.Name; -import javax.lang.model.element.TypeElement; -import javax.lang.model.type.*; - -import javax.lang.model.util.SimpleTypeVisitor14; -import java.math.BigDecimal; -import java.math.BigInteger; -import java.time.LocalDate; -import java.util.*; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.stream.Stream; +public record TypeValidator(Logger logger, Element element, CoreTypes coreTypes) { -public class TypeValidator { - public static final Set> BASIC_TYPES = Set.of(String.class, BigInteger.class, BigDecimal.class, LocalDate.class, Date.class); - private final TypeUtils typeUtils; - private final Logger logger; + private static final KiwiType UPDATE_RETURN_TYPE = SimpleType.ofClass(int.class); + private static final KiwiType BATCH_RETURN_TYPE = new ContainerType(ValidContainerType.ARRAY, UPDATE_RETURN_TYPE); - public TypeValidator(TypeUtils typeUtils, Logger logger) { - this.typeUtils = typeUtils; - this.logger = logger; - } - - void info(String format, Object... args) { - logger.note(null, String.format(format, args)); + public TypeValidator { + element = Objects.requireNonNull(element); } - interface Info { - void info(String format, Object... args); + public TypeValidator(Logger logger, Element methodElement) { + this(logger, methodElement, new CoreTypes()); } - public boolean validateParameters(Map parameterMapping) { - var valid = new AtomicBoolean(true); - parameterMapping.forEach(((columnMetaData, methodParameterInfo) -> { - TypeMirror type = methodParameterInfo.type(); - if (!type.accept(new ParameterTypeValidator(), columnMetaData)) { - valid.set(false); - logger.error(methodParameterInfo.variableElement(), "Unsupported type for parameter '%s': %s".formatted(methodParameterInfo.name(), type)); + public boolean validateParameters(Map parameterMapping, QueryMethodKind kind) { + boolean result = true; + for (var entry : parameterMapping.entrySet()) { + var columnMetaData = entry.getKey(); + var methodParameterInfo = entry.getValue(); + var parameterType = methodParameterInfo.type(); + if (kind == QueryMethodKind.BATCH && parameterType instanceof ContainerType containerType) { + // unwrap container because it will be iterated for the batch + parameterType = containerType.containedType(); } - })); + var element = methodParameterInfo.variableElement(); + KiwiType columnType = SqlTypeMapping.get(columnMetaData).kiwiType(); + if (!withElement(element).validateSingleParameter(parameterType, columnType)) { + result = false; + } + } + if (kind == QueryMethodKind.BATCH + && parameterMapping.values().stream() + .map(MethodParameterInfo::type) + .anyMatch(t -> t instanceof ContainerType)) { + result = false; + logger.error(element, "SqlBatch method must have at least one iterable parameter"); + } - return valid.get(); + return result; } - public boolean validateReturn(List columnMetaData, TypeMirror returnType) { - return new ReturnTypeValidator().visit(returnType, new ReturnTypeContext(TypeLevel.Container, columnMetaData, this::info)); + private TypeValidator withElement(Element element) { + return new TypeValidator(logger, element, coreTypes); } - enum TypeLevel { Container, Component, Value} + private boolean validateSingleParameter(KiwiType parameterType, KiwiType columnType) { + if (!validateGeneral(parameterType)) { + logger.error(element, "Unsupported type %s for parameter %s".formatted(parameterType, simpleName())); + return false; + } + if (!validateCompatible(parameterType, columnType)) { + logger.error( + element, + "Parameter type %s is not compatible with SQL type %s for parameter %s" + .formatted(parameterType, columnType, simpleName())); + return false; + } + return true; + } - record ReturnTypeContext(TypeLevel level, List columnMetaData, Info info) { + private CharSequence simpleName() { + return element == null ? "unknown element" : element.getSimpleName(); + } - public boolean single() { - return columnMetaData.size() == 1; + /** + * Check whether we know how to convert "from" to "to". + * This operation is not necessarily commutative. + * @param from + * @param to + * @return + */ + private boolean validateCompatible(KiwiType from, KiwiType to) { + if (from.equals(to)) { + // shortcut + return true; } - - public ReturnTypeContext withLevel(TypeLevel level) { - return new ReturnTypeContext(level, columnMetaData, info); + if (from instanceof ContainerType fromContainer && to instanceof ContainerType toContainer) { + // we can convert any container into a different one, since they are all effectively equivalent to Iterable. + return validateCompatible(fromContainer.containedType(), toContainer.containedType()); } - - public boolean container() { - return level == TypeLevel.Container; + if (to instanceof ContainerType toContainer) { + // we can convert a single value to a container by wrapping + return validateCompatible(from, ((ContainerType) to).containedType()); } - - public ReturnTypeContext asComponent() { - return withLevel(TypeLevel.Component); + if (from instanceof ContainerType containerType + && containerType.type() == ValidContainerType.OPTIONAL + && to instanceof SimpleType simpleType) { + // an Optional can be converted to a nullable simple type + // TODO how to interact with Record? + return simpleType.isNullable() && validateCompatible(containerType.containedType(), simpleType); } - - public boolean component() { - return level == TypeLevel.Component; + if (from instanceof RecordType fromRecord && to instanceof RecordType toRecord) { + // Component names must match, and types must be compatible. Order is not relevant in this context. + var toComponents = toRecord.components(); + return fromRecord.components().stream().allMatch(e -> { + var toComponentType = toComponents.stream() + .filter(toComponent -> e.name().equals(toComponent.name())) + .findFirst() + .orElse(null); + return toComponentType != null && validateCompatible(e.type(), toComponentType.type()); + }); } - - public ReturnTypeContext asValue() { - return withLevel(TypeLevel.Value); + if (from instanceof SimpleType fromType + && !fromType.isNullable() + && to instanceof SimpleType toType + && toType.isNullable()) { + // non-null can be converted to nullable + return validateCompatible(fromType.withIsNullable(true), toType); } - - public boolean hasColumn(Name simpleName) { - info.info("hasColumn(%s) %s", simpleName, columnMetaData.stream().map(ColumnMetaData::name).toList()); - return columnMetaData.stream().anyMatch(cmd -> cmd.name().equalsIgnoreCase(simpleName.toString())); + if (from instanceof SimpleType fromType && to instanceof SimpleType toType) { + return validateSimpleCompatible(fromType, toType); } + return false; } - class ReturnTypeValidator extends SimpleTypeVisitor14 { - public ReturnTypeValidator() { - super(false); + private boolean validateSimpleCompatible(SimpleType fromType, SimpleType toType) { + if (fromType.equals(toType)) { + // shortcut + return true; } - - - - @Override - public Boolean visitPrimitive(PrimitiveType t, ReturnTypeContext returnTypeContext) { - info("Return type validate %s, level %s", t, returnTypeContext.level); - return returnTypeContext.single() || returnTypeContext.level == TypeLevel.Value; + if (fromType.isNullable() != toType.isNullable()) { + // nullability must match. (See validateCompatible for non-null -> null) + return false; } - - @Override - public Boolean visitArray(ArrayType t, ReturnTypeContext returnTypeContext) { - info("Return type validate %s, level %s", t, returnTypeContext.level); - return returnTypeContext.container() && visit(t.getComponentType(), returnTypeContext.asComponent()); + if (toType.equals(CoreTypes.STRING_TYPE.withIsNullable(toType.isNullable()))) { + // anything can be converted to String + return true; } + var typeLookup = coreTypes.lookup(fromType, toType); + if (typeLookup.hasWarning()) { + logger.warn(element, typeLookup.warning()); + } + return typeLookup.isValid(); + // TODO user defined mappings + } - @Override - public Boolean visitDeclared(DeclaredType t, ReturnTypeContext returnTypeContext) { - info("Return type validate %s, level %s", t, returnTypeContext.level); - try { - var isBoxed = visitPrimitive(typeUtils.unboxedType(t), returnTypeContext); // succeeds if type is a boxed primitive - info("Boxed %s", t); - return isBoxed; - } catch (IllegalArgumentException ignored) { - // not a boxed type - } - if (BASIC_TYPES.stream().anyMatch(bt -> typeUtils.isSameType(t, typeUtils.type(bt)))) { - info("Basic type %s", t); - return returnTypeContext.single() || returnTypeContext.level == TypeLevel.Value; - } - if (Stream.of(ContainerType.values()).map(ContainerType::javaType).anyMatch(bt -> typeUtils.isSameType(typeUtils.erasure(t), typeUtils.erasure(typeUtils.type(bt))))) { - var typeArguments = t.getTypeArguments(); - info("Container %s args %s level %s", typeUtils.erasure(t), typeArguments, returnTypeContext.level); - return returnTypeContext.container() && typeArguments.size() == 1 && visit(typeArguments.get(0), returnTypeContext.asComponent()); - } - if (typeUtils.isRecord(t) && !returnTypeContext.single() && (returnTypeContext.container() || returnTypeContext.component())) { - info("Record %s", t); - var asValue = returnTypeContext.asValue(); - return typeUtils.recordComponents(t).stream().allMatch(c -> asValue.hasColumn(c.getSimpleName()) && visit(c.asType(), asValue)); - } - info("Unsupported return type %s", t); - return false; + /** + * Common check for the expected hierarchy. + * @param type + * @return + */ + private boolean validateGeneral(KiwiType type) { + // switch record pattern not available in Java 17 :-( + if (type instanceof SimpleType || type instanceof VoidType) { + return true; + } + if (type instanceof ContainerType ct) { + var contained = ct.containedType(); + return ((contained instanceof RecordType) || (contained instanceof SimpleType)) + && validateGeneral(contained); + } + if (type instanceof RecordType rt) { + var componentTypes = rt.components(); + return componentTypes.stream().allMatch(t -> t.type() instanceof SimpleType); } + return false; + } + private boolean reportError(String message) { + logger.error(element, message); + return false; + } + private void info(String message) { + logger.note(element, message); } - class ParameterTypeValidator extends SimpleTypeVisitor14 { - protected ParameterTypeValidator() { - super(false); - } + private void debug(String message) { + info("DEBUG: " + message); + } - @Override - public Boolean visitPrimitive(PrimitiveType t, ColumnMetaData columnMetaData) { + public boolean validateReturn(List columnMetaData, KiwiType returnType, QueryMethodKind kind) { + if (returnType instanceof VoidType && kind != QueryMethodKind.QUERY) { return true; } - - @Override - public Boolean visitArray(ArrayType t, ColumnMetaData columnMetaData) { - return visit(t.getComponentType(), columnMetaData); + if (kind == QueryMethodKind.UPDATE) { + return validateCompatible(UPDATE_RETURN_TYPE, returnType) + || reportError("SqlUpdate return type must be compatible with int"); } - - @Override - public Boolean visitDeclared(DeclaredType t, ColumnMetaData columnMetaData) { - try { - typeUtils.unboxedType(t); // succeeds if type is a boxed primitive - return true; - } catch (IllegalArgumentException ignored) { - - } - if (BASIC_TYPES.stream() - .anyMatch(bt -> typeUtils.isSameType(t, typeUtils.type(bt)))) { - return true; - } - if (typeUtils.isSubtype(t, typeUtils.type(Iterable.class))) { - var typeArguments = t.getTypeArguments(); - return typeArguments.size() == 1 && visit(typeArguments.get(0), columnMetaData); - } - if (typeUtils.isRecord(t) && t.asElement() instanceof TypeElement typeElement) { - var rc = typeElement.getRecordComponents(); - return !rc.isEmpty() - && rc.stream().allMatch(recordComponentElement -> visit(recordComponentElement.asType(), columnMetaData)); - } - return false; + if (kind == QueryMethodKind.BATCH) { + return validateCompatible(BATCH_RETURN_TYPE, returnType) + || reportError("SqlBatch return type must be compatible with int[]"); + } + // below clauses apply to kind QUERY + if (returnType instanceof ContainerType containerType) { + // any container is acceptable + debug("Return type Container %s.%s".formatted(containerType.packageName(), containerType.className())); + return validateReturn(columnMetaData, containerType.containedType(), kind); } + if (columnMetaData.size() == 1 && returnType instanceof SimpleType simpleType) { + debug("Return type simple %s.%s".formatted(simpleType.packageName(), simpleType.className())); + // a single column result maps to a simple type + var first = columnMetaData.get(0); + KiwiType columnType = SqlTypeMapping.get(first).kiwiType(); + return validateCompatible(columnType, simpleType); + } + if (returnType instanceof RecordType recordType) { + debug("Return type record %s.%s".formatted(recordType.packageName(), recordType.className())); + var components = recordType.components(); + return columnMetaData.stream().allMatch(cmd -> { + var componentType = components.stream() + .filter(toComponent -> cmd.name().equals(toComponent.name())) + .findFirst() + .orElse(null); + KiwiType columnType = SqlTypeMapping.get(cmd).kiwiType(); + return (componentType != null + && componentType.type() instanceof SimpleType simpleType + && validateCompatible(columnType, simpleType)) + || reportError("Missing or incompatible component type %s for column %s type %s" + .formatted(componentType, cmd.name(), columnType)); + }) + && components.stream().allMatch(cmp -> { + var cmd = columnMetaData.stream() + .filter(col -> col.name().equals(cmp.name())) + .findFirst() + .orElse(null); + var colType = + cmd == null ? null : SqlTypeMapping.get(cmd).kiwiType(); + return (cmd != null && validateCompatible(colType, cmp.type())) + || reportError("Missing or incompatible column type %s for component %s type %s" + .formatted(colType, cmp.name(), cmp.type())); + }); + } + + return false; } } diff --git a/processor/src/main/java/org/ethelred/kiwiproc/processor/UnsupportedType.java b/processor/src/main/java/org/ethelred/kiwiproc/processor/UnsupportedType.java new file mode 100644 index 0000000..425345c --- /dev/null +++ b/processor/src/main/java/org/ethelred/kiwiproc/processor/UnsupportedType.java @@ -0,0 +1,13 @@ +package org.ethelred.kiwiproc.processor; + +public record UnsupportedType() implements KiwiType { + @Override + public String packageName() { + throw new UnsupportedOperationException("UnsupportedType.packageName"); + } + + @Override + public String className() { + throw new UnsupportedOperationException("UnsupportedType.className"); + } +} diff --git a/processor/src/main/java/org/ethelred/kiwiproc/processor/Util.java b/processor/src/main/java/org/ethelred/kiwiproc/processor/Util.java new file mode 100644 index 0000000..d5be38d --- /dev/null +++ b/processor/src/main/java/org/ethelred/kiwiproc/processor/Util.java @@ -0,0 +1,21 @@ +package org.ethelred.kiwiproc.processor; + +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +class Util { + private Util() {} + + static Pattern delimiter = Pattern.compile("[\\W_]+|(?=\\p{Upper})"); + + static String toTitleCase(String input) { + return delimiter.splitAsStream(input).map(Util::capitalizeFirst).collect(Collectors.joining()); + } + + static String capitalizeFirst(String input) { + if (input.length() < 2) { + return input.toUpperCase(); + } + return input.substring(0, 1).toUpperCase() + input.substring(1).toLowerCase(); + } +} diff --git a/processor/src/main/java/org/ethelred/kiwiproc/processor/ValidContainerType.java b/processor/src/main/java/org/ethelred/kiwiproc/processor/ValidContainerType.java index 7bbf5c3..6c9fc79 100644 --- a/processor/src/main/java/org/ethelred/kiwiproc/processor/ValidContainerType.java +++ b/processor/src/main/java/org/ethelred/kiwiproc/processor/ValidContainerType.java @@ -6,7 +6,7 @@ import java.util.Optional; import java.util.Set; -public enum ContainerType { +public enum ValidContainerType { ARRAY(Array.class, """ l.toArray(new %s[l.size()]) """), @@ -14,12 +14,11 @@ public enum ContainerType { COLLECTION(Collection.class), LIST(List.class), SET(Set.class, """ - new LinkedHashSet<>(l) + new java.util.LinkedHashSet<>(l) """), OPTIONAL(Optional.class, """ l.isEmpty() ? Optional.empty() : Optional.of(l.get(0)) - """) - ; + """); private final Class javaType; @@ -29,19 +28,25 @@ public String fromListTemplate() { private final String fromListTemplate; - ContainerType(Class javaType, String fromListTemplate) { + ValidContainerType(Class javaType, String fromListTemplate) { this.javaType = javaType; this.fromListTemplate = fromListTemplate; } - ContainerType(Class javaType) { + ValidContainerType(Class javaType) { this(javaType, "List.copyOf(l)"); } public boolean isMultiValued() { return this != OPTIONAL; } + public Class javaType() { return javaType; } + + @Override + public String toString() { + return javaType().getName(); + } } diff --git a/processor/src/main/java/org/ethelred/kiwiproc/processor/VoidType.java b/processor/src/main/java/org/ethelred/kiwiproc/processor/VoidType.java new file mode 100644 index 0000000..91b1a8e --- /dev/null +++ b/processor/src/main/java/org/ethelred/kiwiproc/processor/VoidType.java @@ -0,0 +1,14 @@ +package org.ethelred.kiwiproc.processor; + +public record VoidType() implements KiwiType { + + @Override + public String packageName() { + return ""; + } + + @Override + public String className() { + return "void"; + } +} diff --git a/processor/src/main/java/org/ethelred/kiwiproc/processor/generator/ElementSupplier.java b/processor/src/main/java/org/ethelred/kiwiproc/processor/generator/ElementSupplier.java new file mode 100644 index 0000000..a220e79 --- /dev/null +++ b/processor/src/main/java/org/ethelred/kiwiproc/processor/generator/ElementSupplier.java @@ -0,0 +1,8 @@ +package org.ethelred.kiwiproc.processor.generator; + +import javax.lang.model.element.Element; +import org.jspecify.annotations.Nullable; + +public interface ElementSupplier { + @Nullable Element getElement(); +} diff --git a/processor/src/main/java/org/ethelred/kiwiproc/processor/generator/ImplGenerator.java b/processor/src/main/java/org/ethelred/kiwiproc/processor/generator/ImplGenerator.java new file mode 100644 index 0000000..bbcf50f --- /dev/null +++ b/processor/src/main/java/org/ethelred/kiwiproc/processor/generator/ImplGenerator.java @@ -0,0 +1,179 @@ +package org.ethelred.kiwiproc.processor.generator; + +import static org.ethelred.kiwiproc.processor.generator.RuntimeTypes.*; + +import com.karuslabs.utilitary.Logger; +import com.palantir.javapoet.*; +import java.sql.SQLException; +import java.util.*; +import javax.lang.model.element.Modifier; +import org.ethelred.kiwiproc.processor.*; + +public class ImplGenerator { + + private final Logger logger; + private final KiwiTypeConverter kiwiTypeConverter; + private final CoreTypes coreTypes; + + public ImplGenerator(Logger logger, KiwiTypeConverter kiwiTypeConverter, CoreTypes coreTypes) { + this.logger = logger; + + this.kiwiTypeConverter = kiwiTypeConverter; + this.coreTypes = coreTypes; + } + + public JavaFile generate(DAOClassInfo classInfo) { + var daoName = ClassName.get(classInfo.packageName(), classInfo.daoName()); + var superClass = ParameterizedTypeName.get(ABSTRACT_DAO, daoName); + var constructorSpec = MethodSpec.constructorBuilder() + .addModifiers(Modifier.PUBLIC) + .addParameter(ParameterSpec.builder(DAO_CONTEXT, "daoContext").build()) + .addStatement("super(daoContext)") + .build(); + var typeSpecBuilder = TypeSpec.classBuilder(ClassName.get(classInfo.packageName(), classInfo.className("Impl"))) + .addOriginatingElement(classInfo.element()) + .addAnnotation(AnnotationSpec.builder(ClassName.bestGuess("javax.annotation.processing.Generated")) + .addMember("value", "$S", "org.ethelred.kiwiproc.processor.KiwiProcessor") + .build()) + .addModifiers(Modifier.PUBLIC, Modifier.FINAL) + .superclass(superClass) + .addSuperinterface(daoName) + .addMethod(constructorSpec); + for (var methodThing : classInfo.methods()) { + + typeSpecBuilder.addMethod(buildMethod(methodThing)); + } + return JavaFile.builder(classInfo.packageName(), typeSpecBuilder.build()) + .build(); + } + + private MethodSpec buildMethod(DAOMethodInfo methodInfo) { + var methodSpecBuilder = MethodSpec.overriding(methodInfo.methodElement()); + methodSpecBuilder.addStatement("var connection = context.getConnection()"); + methodSpecBuilder.beginControlFlow( + "try (var statement = connection.prepareStatement($S))", + methodInfo.parsedSql().parsedSql()); + methodSpecBuilder.addCode( + switch (methodInfo.kind()) { + case QUERY -> queryMethodBody(methodInfo); + case UPDATE -> updateMethodBody(methodInfo); + case BATCH -> batchMethodBody(methodInfo); + case DEFAULT -> throw new IllegalArgumentException(); + }); + methodSpecBuilder + .nextControlFlow("catch ($T e)", SQLException.class) // end try + .addStatement("throw new $T(e)", UNCHECKED_SQL_EXCEPTION) + .endControlFlow(); // end catch + + return methodSpecBuilder.build(); + } + + private CodeBlock updateMethodBody(DAOMethodInfo methodInfo) { + return CodeBlock.of("//TODO"); + } + + private CodeBlock batchMethodBody(DAOMethodInfo methodInfo) { + return CodeBlock.of("//TODO"); + } + + private CodeBlock queryMethodBody(DAOMethodInfo methodInfo) { + var builder = CodeBlock.builder(); + Set parameterNames = new HashSet<>(); + methodInfo.parameterMapping().forEach(parameterInfo -> { + var name = "param" + parameterInfo.index(); + var conversion = lookupConversion(parameterInfo::element, parameterInfo.mapper()); + builder.addStatement( + "var $L = $L", name, conversion.conversionFormat().formatted(parameterInfo.javaAccessor())); + if (parameterInfo.mapper().source().isNullable()) { + builder.beginControlFlow("if ($L == null)", name) + .addStatement("statement.setNull($L, $L)", parameterInfo.index(), parameterInfo.sqlType()) + .nextControlFlow("else"); + } + builder.addStatement("statement.$L($L, $L)", parameterInfo.setter(), parameterInfo.index(), name); + if (parameterInfo.mapper().source().isNullable()) { + builder.endControlFlow(); + } + parameterNames.add(parameterInfo.javaAccessor()); + }); + builder.addStatement("var rs = statement.executeQuery()") + .addStatement( + "List<$T> l = new $T<>()", + kiwiTypeConverter.fromKiwiType(methodInfo.resultComponentType()), + ArrayList.class) + .beginControlFlow("$L (rs.next())", methodInfo.singleResult() ? "if" : "while"); + var singleColumn = methodInfo.singleColumn(); + var multipleColumns = methodInfo.multipleColumns(); + if (singleColumn != null) { + TypeMapping mapping = singleColumn.asTypeMapping(); + var conversion = lookupConversion(methodInfo::methodElement, mapping); + builder.addStatement( + "var rawValue = rs.get$L($S)", + singleColumn.sqlTypeMapping().accessorSuffix(), + singleColumn.name()) + .addStatement( + "var value = $L", conversion.conversionFormat().formatted("rawValue")); + } else if (!multipleColumns.isEmpty()) { + Map patchedNames = new HashMap<>(); + multipleColumns.forEach(daoResultColumn -> { + var conversion = lookupConversion(methodInfo::methodElement, daoResultColumn.asTypeMapping()); + String rawName = daoResultColumn.name() + "Raw"; + builder.addStatement( + "$T $L = rs.get$L($S)", + ClassName.get( + daoResultColumn.targetType().packageName(), + daoResultColumn.targetType().className()), + rawName, + daoResultColumn.sqlTypeMapping().accessorSuffix(), + daoResultColumn.name()); + if (daoResultColumn.sqlTypeMapping().isNullable()) { + builder.beginControlFlow("if (rs.wasNull())") + .addStatement("$L = null", rawName) + .endControlFlow(); + } + var varName = patchName(parameterNames, patchedNames, daoResultColumn.name()); + builder.addStatement( + "var $L = $L", varName, conversion.conversionFormat().formatted(rawName)); + }); + var params = multipleColumns.stream() + .map(p -> CodeBlock.of("$L", patchedNames.get(p.name()))) + .collect(CodeBlock.joining(",\n")); + params = CodeBlock.builder().indent().add(params).unindent().build(); + builder.add( + """ + var value = new $T( + $L + ); + """, + kiwiTypeConverter.fromKiwiType(methodInfo.resultComponentType()), + params); + } else { + throw new IllegalStateException("Expected singleColumn or multipleColumns"); + } + builder.addStatement("l.add(value)") + .endControlFlow() // end while + .addStatement("return $L", methodInfo.fromList()); + return builder.build(); + } + + private String patchName(Set parameterNames, Map patchedNames, String name) { + return patchedNames.computeIfAbsent(name, k -> { + var newName = k; + while (parameterNames.contains(newName)) { + newName = "_" + newName; + } + return newName; + }); + } + + CoreTypes.Conversion lookupConversion(ElementSupplier elementSupplier, TypeMapping t) { + var element = elementSupplier.getElement(); + var conversion = coreTypes.lookup(t); + if (!conversion.isValid()) { + logger.error(element, "No valid conversion for %s".formatted(t)); + } + if (conversion.hasWarning()) { + logger.warn(element, conversion.warning()); + } + return conversion; + } +} diff --git a/processor/src/main/java/org/ethelred/kiwiproc/processor/generator/KiwiTypeConverter.java b/processor/src/main/java/org/ethelred/kiwiproc/processor/generator/KiwiTypeConverter.java new file mode 100644 index 0000000..a274d05 --- /dev/null +++ b/processor/src/main/java/org/ethelred/kiwiproc/processor/generator/KiwiTypeConverter.java @@ -0,0 +1,35 @@ +package org.ethelred.kiwiproc.processor.generator; + +import com.palantir.javapoet.ClassName; +import com.palantir.javapoet.ParameterizedTypeName; +import com.palantir.javapoet.TypeName; +import java.util.Map; +import org.ethelred.kiwiproc.processor.ContainerType; +import org.ethelred.kiwiproc.processor.KiwiType; +import org.ethelred.kiwiproc.processorconfig.DependencyInjectionStyle; + +public class KiwiTypeConverter { + public TypeName fromKiwiType(KiwiType kiwiType) { + if (kiwiType instanceof ContainerType containerType) { + return ParameterizedTypeName.get( + ClassName.get(containerType.type().javaType()), fromKiwiType(containerType.containedType())); + } else { + return ClassName.get(kiwiType.packageName(), kiwiType.className()); + } + } + + DependencyInjectionTypes getDependencyInjectionType(DependencyInjectionStyle dependencyInjectionStyle) { + var result = dependencyInjectionTypesMap.get(dependencyInjectionStyle); + if (result == null) { + throw new IllegalArgumentException("Missing annotation types for " + dependencyInjectionStyle); + } + return result; + } + + record DependencyInjectionTypes(ClassName singleton, ClassName named) {} + + private final Map dependencyInjectionTypesMap = Map.of( + DependencyInjectionStyle.JAKARTA, + new DependencyInjectionTypes( + ClassName.get("jakarta.inject", "Singleton"), ClassName.get("jakarta.inject", "Named"))); +} diff --git a/processor/src/main/java/org/ethelred/kiwiproc/processor/generator/PoetDAOGenerator.java b/processor/src/main/java/org/ethelred/kiwiproc/processor/generator/PoetDAOGenerator.java new file mode 100644 index 0000000..1c782f1 --- /dev/null +++ b/processor/src/main/java/org/ethelred/kiwiproc/processor/generator/PoetDAOGenerator.java @@ -0,0 +1,64 @@ +package org.ethelred.kiwiproc.processor.generator; + +import com.karuslabs.utilitary.Logger; +import com.palantir.javapoet.JavaFile; +import java.io.IOException; +import javax.annotation.processing.Filer; +import org.ethelred.kiwiproc.processor.*; +import org.ethelred.kiwiproc.processorconfig.DependencyInjectionStyle; + +public class PoetDAOGenerator implements DAOGenerator { + private final Logger logger; + private final Filer filer; + private final DependencyInjectionStyle dependencyInjectionStyle; + private final KiwiTypeConverter kiwiTypeConverter; + private final ImplGenerator implGenerator; + private final ProviderGenerator providerGenerator; + private final CoreTypes coreTypes; + private final TransactionManagerGenerator transactionManagerGenerator; + + public PoetDAOGenerator(Logger logger, Filer filer, DependencyInjectionStyle dependencyInjectionStyle) { + this.logger = logger; + this.filer = filer; + this.dependencyInjectionStyle = dependencyInjectionStyle; + this.kiwiTypeConverter = new KiwiTypeConverter(); + this.coreTypes = new CoreTypes(); + this.implGenerator = new ImplGenerator(logger, kiwiTypeConverter, coreTypes); + this.providerGenerator = new ProviderGenerator(dependencyInjectionStyle, kiwiTypeConverter); + this.transactionManagerGenerator = new TransactionManagerGenerator(dependencyInjectionStyle, kiwiTypeConverter); + } + + @Override + public void generateProvider(DAOClassInfo classInfo) { + var javaFile = providerGenerator.generate(classInfo); + writeJavaFile(classInfo::element, javaFile); + } + + @Override + public void generateImpl(DAOClassInfo classInfo) { + var javaFile = implGenerator.generate(classInfo); + writeJavaFile(classInfo::element, javaFile); + } + + @Override + public void generateTransactionManager(DAODataSourceInfo dataSourceInfo) { + var javaFile = transactionManagerGenerator.generate(dataSourceInfo); + writeJavaFile(() -> null, javaFile); + } + + private void writeJavaFile(ElementSupplier elementSupplier, JavaFile javaFile) { + javaFile = javaFile.toBuilder() + .skipJavaLangImports(true) + .indent(" ") + .addFileComment("GENERATED CODE - DO NOT EDIT") + .build(); + try { + logger.note( + elementSupplier.getElement(), + "Generating " + javaFile.typeSpec().name()); + javaFile.writeTo(filer); + } catch (IOException e) { + throw new RuntimeException(e); + } + } +} diff --git a/processor/src/main/java/org/ethelred/kiwiproc/processor/generator/ProviderGenerator.java b/processor/src/main/java/org/ethelred/kiwiproc/processor/generator/ProviderGenerator.java new file mode 100644 index 0000000..23a99c8 --- /dev/null +++ b/processor/src/main/java/org/ethelred/kiwiproc/processor/generator/ProviderGenerator.java @@ -0,0 +1,58 @@ +package org.ethelred.kiwiproc.processor.generator; + +import static org.ethelred.kiwiproc.processor.generator.RuntimeTypes.ABSTRACT_PROVIDER; +import static org.ethelred.kiwiproc.processor.generator.RuntimeTypes.DAO_CONTEXT; + +import com.palantir.javapoet.*; +import javax.lang.model.element.Modifier; +import javax.sql.DataSource; +import org.ethelred.kiwiproc.processor.DAOClassInfo; +import org.ethelred.kiwiproc.processorconfig.DependencyInjectionStyle; + +public class ProviderGenerator { + private final DependencyInjectionStyle dependencyInjectionStyle; + private final KiwiTypeConverter kiwiTypeConverter; + + public ProviderGenerator(DependencyInjectionStyle dependencyInjectionStyle, KiwiTypeConverter kiwiTypeConverter) { + this.dependencyInjectionStyle = dependencyInjectionStyle; + this.kiwiTypeConverter = kiwiTypeConverter; + } + + public JavaFile generate(DAOClassInfo classInfo) { + var daoName = ClassName.get(classInfo.packageName(), classInfo.daoName()); + var superClass = ParameterizedTypeName.get(ABSTRACT_PROVIDER, daoName); + var constructorSpec = MethodSpec.constructorBuilder() + .addModifiers(Modifier.PUBLIC) + .addParameter(ParameterSpec.builder(DataSource.class, "dataSource") + .addAnnotation(AnnotationSpec.builder(kiwiTypeConverter + .getDependencyInjectionType(dependencyInjectionStyle) + .named()) + .addMember("value", "$S", classInfo.dataSourceName()) + .build()) + .build()) + .addStatement("super(dataSource)") + .build(); + var typeSpecBuilder = TypeSpec.classBuilder( + ClassName.get(classInfo.packageName(), classInfo.className("Provider"))) + .addOriginatingElement(classInfo.element()) + .addAnnotation(AnnotationSpec.builder(ClassName.bestGuess("javax.annotation.processing.Generated")) + .addMember("value", "$S", "org.ethelred.kiwiproc.processor.KiwiProcessor") + .build()) + .addAnnotation(kiwiTypeConverter + .getDependencyInjectionType(dependencyInjectionStyle) + .singleton()) + .addModifiers(Modifier.PUBLIC, Modifier.FINAL) + .superclass(superClass) + .addMethod(constructorSpec); + var withContextMethod = MethodSpec.methodBuilder("withContext") + .addModifiers(Modifier.PROTECTED) + .addAnnotation(Override.class) + .addParameter(ParameterSpec.builder(DAO_CONTEXT, "daoContext").build()) + .returns(daoName) + .addStatement("return new $L(daoContext)", classInfo.className("Impl")) + .build(); + typeSpecBuilder.addMethod(withContextMethod); + return JavaFile.builder(classInfo.packageName(), typeSpecBuilder.build()) + .build(); + } +} diff --git a/processor/src/main/java/org/ethelred/kiwiproc/processor/generator/RuntimeTypes.java b/processor/src/main/java/org/ethelred/kiwiproc/processor/generator/RuntimeTypes.java new file mode 100644 index 0000000..9b4baaf --- /dev/null +++ b/processor/src/main/java/org/ethelred/kiwiproc/processor/generator/RuntimeTypes.java @@ -0,0 +1,16 @@ +package org.ethelred.kiwiproc.processor.generator; + +import com.palantir.javapoet.ClassName; + +public final class RuntimeTypes { + public static final ClassName ABSTRACT_DAO = ClassName.get("org.ethelred.kiwiproc.impl", "AbstractDAO"); + public static final ClassName ABSTRACT_PROVIDER = + ClassName.get("org.ethelred.kiwiproc.impl", "AbstractDAOProvider"); + public static final ClassName ABSTRACT_TRANSACTION_MANAGER = + ClassName.get("org.ethelred.kiwiproc.impl", "AbstractTransactionManager"); + public static final ClassName DAO_CONTEXT = ClassName.get("org.ethelred.kiwiproc.api", "DAOContext"); + public static final ClassName UNCHECKED_SQL_EXCEPTION = + ClassName.get("org.ethelred.kiwiproc.exception", "UncheckedSQLException"); + + private RuntimeTypes() {} +} diff --git a/processor/src/main/java/org/ethelred/kiwiproc/processor/generator/TransactionManagerGenerator.java b/processor/src/main/java/org/ethelred/kiwiproc/processor/generator/TransactionManagerGenerator.java new file mode 100644 index 0000000..178066c --- /dev/null +++ b/processor/src/main/java/org/ethelred/kiwiproc/processor/generator/TransactionManagerGenerator.java @@ -0,0 +1,52 @@ +package org.ethelred.kiwiproc.processor.generator; + +import static org.ethelred.kiwiproc.processor.generator.RuntimeTypes.*; + +import com.palantir.javapoet.*; +import javax.lang.model.element.Modifier; +import javax.sql.DataSource; +import org.ethelred.kiwiproc.processor.DAODataSourceInfo; +import org.ethelred.kiwiproc.processorconfig.DependencyInjectionStyle; + +public class TransactionManagerGenerator { + private final DependencyInjectionStyle dependencyInjectionStyle; + private final KiwiTypeConverter kiwiTypeConverter; + + public TransactionManagerGenerator( + DependencyInjectionStyle dependencyInjectionStyle, KiwiTypeConverter kiwiTypeConverter) { + this.dependencyInjectionStyle = dependencyInjectionStyle; + this.kiwiTypeConverter = kiwiTypeConverter; + } + + public JavaFile generate(DAODataSourceInfo dataSourceInfo) { + var constructorSpec = MethodSpec.constructorBuilder() + .addModifiers(Modifier.PUBLIC) + .addParameter(ParameterSpec.builder(DataSource.class, "dataSource") + .addAnnotation(AnnotationSpec.builder(kiwiTypeConverter + .getDependencyInjectionType(dependencyInjectionStyle) + .named()) + .addMember("value", "$S", dataSourceInfo.dataSourceName()) + .build()) + .build()) + .addStatement("super(dataSource)") + .build(); + var typeSpecBuilder = TypeSpec.classBuilder( + ClassName.get(dataSourceInfo.packageName(), dataSourceInfo.className("TransactionManager"))) + .addAnnotation(AnnotationSpec.builder(ClassName.bestGuess("javax.annotation.processing.Generated")) + .addMember("value", "$S", "org.ethelred.kiwiproc.processor.KiwiProcessor") + .build()) + .addAnnotation(kiwiTypeConverter + .getDependencyInjectionType(dependencyInjectionStyle) + .singleton()) + .addAnnotation(AnnotationSpec.builder(kiwiTypeConverter + .getDependencyInjectionType(dependencyInjectionStyle) + .named()) + .addMember("value", "$S", dataSourceInfo.dataSourceName()) + .build()) + .addModifiers(Modifier.PUBLIC, Modifier.FINAL) + .superclass(ABSTRACT_TRANSACTION_MANAGER) + .addMethod(constructorSpec); + return JavaFile.builder(dataSourceInfo.packageName(), typeSpecBuilder.build()) + .build(); + } +} diff --git a/processor/src/main/java/org/ethelred/kiwiproc/processor/generator/package-info.java b/processor/src/main/java/org/ethelred/kiwiproc/processor/generator/package-info.java new file mode 100644 index 0000000..fff128e --- /dev/null +++ b/processor/src/main/java/org/ethelred/kiwiproc/processor/generator/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package org.ethelred.kiwiproc.processor.generator; + +import org.jspecify.annotations.NullMarked; diff --git a/processor/src/main/java/org/ethelred/kiwiproc/processor/package-info.java b/processor/src/main/java/org/ethelred/kiwiproc/processor/package-info.java index 915786c..20b0871 100644 --- a/processor/src/main/java/org/ethelred/kiwiproc/processor/package-info.java +++ b/processor/src/main/java/org/ethelred/kiwiproc/processor/package-info.java @@ -1,8 +1,8 @@ @GeneratePrism(DAO.class) @GeneratePrism(SqlQuery.class) - @GeneratePrism(SqlUpdate.class) - @GeneratePrism(SqlBatch.class) - @NullMarked +@GeneratePrism(SqlUpdate.class) +@GeneratePrism(SqlBatch.class) +@NullMarked package org.ethelred.kiwiproc.processor; import io.avaje.prism.GeneratePrism; diff --git a/processor/src/main/java/org/ethelred/kiwiproc/processor/templates/GeneratedMixin.java b/processor/src/main/java/org/ethelred/kiwiproc/processor/templates/GeneratedMixin.java deleted file mode 100644 index 5f4f4ce..0000000 --- a/processor/src/main/java/org/ethelred/kiwiproc/processor/templates/GeneratedMixin.java +++ /dev/null @@ -1,14 +0,0 @@ -package org.ethelred.kiwiproc.processor.templates; - -import org.ethelred.kiwiproc.processor.KiwiProcessor; - -public interface GeneratedMixin { - default String generated() { - return """ - @Generated("%s")""".formatted(KiwiProcessor.class.getName()); - } - - default String generatedImport() { - return "import javax.annotation.processing.Generated;"; - } -} diff --git a/processor/src/main/java/org/ethelred/kiwiproc/processor/templates/ImplTemplate.java b/processor/src/main/java/org/ethelred/kiwiproc/processor/templates/ImplTemplate.java deleted file mode 100644 index 378fb4b..0000000 --- a/processor/src/main/java/org/ethelred/kiwiproc/processor/templates/ImplTemplate.java +++ /dev/null @@ -1,110 +0,0 @@ -package org.ethelred.kiwiproc.processor.templates; - -import io.jstach.jstache.JStache; -import io.jstach.jstache.JStacheLambda; -import io.jstach.jstache.JStachePartial; -import io.jstach.jstache.JStachePartials; -import org.ethelred.kiwiproc.processor.ClassNameMixin; -import org.ethelred.kiwiproc.processor.DAOClassInfo; -import org.ethelred.kiwiproc.processor.DAOParameterInfo; - -@JStache(template = - // language=mustache - """ - {{#classInfo}} - package {{packageName}}; - - import org.ethelred.kiwiproc.impl.AbstractDAO; - import org.ethelred.kiwiproc.exception.UncheckedSQLException; - import org.ethelred.kiwiproc.api.DAOContext; - import org.mapstruct.factory.Mappers; - import java.sql.SQLException; - import java.util.List; - import java.util.ArrayList; - {{generatedImport}} - - {{generated}} - public class {{#className}}Impl{{/className}} extends AbstractDAO<{{daoName}}> implements {{daoName}} { - private final {{#className}}Mapper{{/className}} mapper; - public {{#className}}Impl{{/className}}(DAOContext context) { - super(context); - mapper = Mappers.getMapper({{#className}}Mapper{{/className}}.class); - } - - {{#methods}} - @Override - {{#signature}} - public {{returnTypeDeclaration}} {{name}}( - {{#params}}{{^@first}}, {{/@first}}{{javaType}} {{name}}{{/params}} - ){{/signature}} { - try { - var connection = context.getConnection(); - try (var statement = connection.prepareStatement("{{parsedSql.parsedSql}}")) { - {{#kind.BATCH}} - {{> setBatchParameters}} - {{/kind.BATCH}} - {{^kind.BATCH}} - {{> setParameters}} - {{/kind.BATCH}} - - {{#kind.QUERY}} - var resultSet = statement.executeQuery(); - List<{{resultComponentType}}> l = new ArrayList<>(); - while (resultSet.next()) { - {{#rowRecord}} - var rawValue = new {{internalComponentType}}( - {{#params}} - {{^@first}}, {{/@first}} - resultSet.get{{sqlTypeMapping.accessorSuffix}}("{{name}}") - {{/params}} - ); - {{/rowRecord}} - {{#singleColumn}} - var rawValue = resultSet.get{{sqlTypeMapping.accessorSuffix}}("{{name}}"); - {{/singleColumn}} - {{#returnTypeMapping}} - var value = mapper.{{methodName}}(rawValue); - {{/returnTypeMapping}} - {{^returnTypeMapping}} - var value = rawValue; - {{/returnTypeMapping}} - l.add(value); - } - - return {{fromList}}; - {{/kind.QUERY}} - } - } catch (SQLException e) { - throw new UncheckedSQLException(e); - } - } - {{/methods}} - } - {{/classInfo}} - """) -@JStachePartials({ - @JStachePartial(name = "setParameters", template = - // language=mustache - """ - // Test {{parameterMapping}} - {{#parameterMapping}} - statement.{{setter}}({{index}}, {{#typeMapping}}{{/typeMapping}}); - {{/parameterMapping}} - """), - @JStachePartial(name = "setBatchParameters", - template = - // language=mustache - """ - TODO - """) -}) -public record ImplTemplate(DAOClassInfo classInfo) implements ClassNameMixin, GeneratedMixin { - @JStacheLambda - @JStacheLambda.Raw - public String typeMapping(DAOParameterInfo parameterInfo) { - if (parameterInfo.mapper().isIdentity()) { - return parameterInfo.javaAccessor(); - } - return "%s(%s)".formatted(parameterInfo.mapper().methodName(), parameterInfo.javaAccessor()); - } -} diff --git a/processor/src/main/java/org/ethelred/kiwiproc/processor/templates/MapperTemplate.java b/processor/src/main/java/org/ethelred/kiwiproc/processor/templates/MapperTemplate.java deleted file mode 100644 index bfa3e3e..0000000 --- a/processor/src/main/java/org/ethelred/kiwiproc/processor/templates/MapperTemplate.java +++ /dev/null @@ -1,30 +0,0 @@ -package org.ethelred.kiwiproc.processor.templates; - -import io.jstach.jstache.JStache; -import org.ethelred.kiwiproc.processor.ClassNameMixin; -import org.ethelred.kiwiproc.processor.DAOClassInfo; -import org.ethelred.kiwiproc.processor.TypeMapping; - -import java.util.Set; - -@JStache(template = - // language=mustache - """ - {{#classInfo}} - package {{packageName}}; - - import org.ethelred.kiwiproc.impl.BaseMapper; - import org.mapstruct.Mapper; - {{generatedImport}} - - {{generated}} - @Mapper - public abstract class {{#className}}Mapper{{/className}} extends BaseMapper { - {{#mappings}} - public abstract {{target}} {{methodName}}({{source}} value); - {{/mappings}} - } - {{/classInfo}} - """) -public record MapperTemplate(DAOClassInfo classInfo, Set mappings) implements ClassNameMixin, GeneratedMixin { -} diff --git a/processor/src/main/java/org/ethelred/kiwiproc/processor/templates/ProviderTemplate.java b/processor/src/main/java/org/ethelred/kiwiproc/processor/templates/ProviderTemplate.java deleted file mode 100644 index 2330e8a..0000000 --- a/processor/src/main/java/org/ethelred/kiwiproc/processor/templates/ProviderTemplate.java +++ /dev/null @@ -1,50 +0,0 @@ -package org.ethelred.kiwiproc.processor.templates; - -import io.jstach.jstache.JStache; -import io.jstach.jstache.JStacheLambda; -import org.ethelred.kiwiproc.processor.ClassNameMixin; -import org.ethelred.kiwiproc.processor.DAOClassInfo; -import org.ethelred.kiwiproc.processorconfig.DependencyInjectionStyle; - -import java.util.List; - -@JStache( - template = - // language=mustache - """ - {{#classInfo}} - package {{packageName}}; - - import org.ethelred.kiwiproc.impl.AbstractDAOProvider; - import org.ethelred.kiwiproc.api.DAOContext; - import javax.sql.DataSource; - import java.sql.SQLException; - {{#diImports}} - import {{.}}; - {{/diImports}} - {{generatedImport}} - - {{generated}} - @Singleton - public class {{#className}}Provider{{/className}} extends AbstractDAOProvider<{{daoName}}> { - public {{#className}}Provider{{/className}}(@Named("{{dataSourceName}}") DataSource dataSource) { - super(dataSource); - } - - public String getDataSourceName() { - return "{{dataSourceName}}"; - } - - public {{daoName}} withContext(DAOContext context) throws SQLException { - return new {{#className}}Impl{{/className}}(context); - } - } - {{/classInfo}} - """) -public record ProviderTemplate(DependencyInjectionStyle dependencyInjectionStyle, DAOClassInfo classInfo) implements ClassNameMixin, GeneratedMixin { - - @JStacheLambda - List diImports() { - return dependencyInjectionStyle.getImports(); - } -} diff --git a/processor/src/main/java/org/ethelred/kiwiproc/processor/templates/RowRecordTemplate.java b/processor/src/main/java/org/ethelred/kiwiproc/processor/templates/RowRecordTemplate.java deleted file mode 100644 index 8201e79..0000000 --- a/processor/src/main/java/org/ethelred/kiwiproc/processor/templates/RowRecordTemplate.java +++ /dev/null @@ -1,26 +0,0 @@ -package org.ethelred.kiwiproc.processor.templates; - -import io.jstach.jstache.JStache; -import org.ethelred.kiwiproc.processor.DAOClassInfo; -import org.ethelred.kiwiproc.processor.Signature; - -@JStache(template = - // language=mustache - """ - {{#classInfo}} - package {{packageName}}; - - - {{generatedImport}} - - {{generated}} - {{#rowRecord}} - public record {{name}}( - {{#params}}{{^@first}}, {{/@first}}{{javaType}} {{name}}{{/params}} - ) {} - {{/rowRecord}} - {{/classInfo}} - """) -public record RowRecordTemplate(DAOClassInfo classInfo, Signature rowRecord) implements GeneratedMixin { - -} diff --git a/processor/src/main/java/org/ethelred/kiwiproc/processor/templates/package-info.java b/processor/src/main/java/org/ethelred/kiwiproc/processor/templates/package-info.java deleted file mode 100644 index d15df14..0000000 --- a/processor/src/main/java/org/ethelred/kiwiproc/processor/templates/package-info.java +++ /dev/null @@ -1,8 +0,0 @@ - -@JStacheConfig(type = JStacheType.STACHE) - @NullMarked -package org.ethelred.kiwiproc.processor.templates; - - import io.jstach.jstache.JStacheConfig; - import io.jstach.jstache.JStacheType; - import org.jspecify.annotations.NullMarked; \ No newline at end of file diff --git a/processor/src/test/java/org/ethelred/kiwiproc/processor/CoreTypesTest.java b/processor/src/test/java/org/ethelred/kiwiproc/processor/CoreTypesTest.java new file mode 100644 index 0000000..329a7d8 --- /dev/null +++ b/processor/src/test/java/org/ethelred/kiwiproc/processor/CoreTypesTest.java @@ -0,0 +1,79 @@ +package org.ethelred.kiwiproc.processor; + +import static com.google.common.truth.Truth.assertThat; +import static com.google.common.truth.Truth.assertWithMessage; +import static org.ethelred.kiwiproc.processor.SimpleType.ofClass; +import static org.junit.jupiter.params.provider.Arguments.arguments; + +import java.io.OutputStream; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +/** + * Don't exhaustively test every type and mapping, just some examples. + */ +public class CoreTypesTest { + CoreTypes coreTypes = new CoreTypes(); + + @Test + void typeFromClass() { + KiwiType type; + type = coreTypes.type(LocalDate.class); + assertThat(type).isInstanceOf(SimpleType.class); + assertThat(((SimpleType) type).className()).isEqualTo("LocalDate"); + assertThat(((SimpleType) type).packageName()).isEqualTo("java.time"); + assertThat(((SimpleType) type).isNullable()).isFalse(); + + type = coreTypes.type(short.class); + assertThat(type).isInstanceOf(SimpleType.class); + assertThat(((SimpleType) type).className()).isEqualTo("short"); + assertThat(((SimpleType) type).packageName()).isEqualTo(""); + assertThat(((SimpleType) type).isNullable()).isFalse(); + + type = coreTypes.type(Short.class); + assertThat(type).isInstanceOf(SimpleType.class); + assertThat(((SimpleType) type).className()).isEqualTo("Short"); + assertThat(((SimpleType) type).packageName()).isEqualTo("java.lang"); + assertThat(((SimpleType) type).isNullable()).isTrue(); + + type = coreTypes.type(OutputStream.class); + assertThat(type).isInstanceOf(UnsupportedType.class); + } + + @ParameterizedTest + @MethodSource + void testConversions( + SimpleType source, SimpleType target, boolean isValid, boolean isWarning, String conversionFormatContains) { + var conversion = coreTypes.lookup(source, target); + // System.out.println(conversion); + assertWithMessage("is valid").that(conversion.isValid()).isEqualTo(isValid); + assertWithMessage("is warning").that(conversion.hasWarning()).isEqualTo(isWarning); + if (conversion.isValid()) { + var formatted = conversion.conversionFormat().formatted("value"); + assertThat(formatted).isEqualTo(conversionFormatContains); + } + } + + public static Stream testConversions() { + return Stream.of( + arguments(ofClass(int.class), ofClass(int.class), true, false, "value"), + arguments(ofClass(int.class), ofClass(Integer.class, true), true, false, "value"), + arguments(ofClass(short.class), ofClass(Integer.class, true), true, false, "value"), + arguments( + ofClass(Short.class, true), + ofClass(Integer.class, true), + true, + false, + "value == null ? null : value"), + arguments(ofClass(double.class), ofClass(short.class), true, true, "(short) value"), + arguments(ofClass(double.class), ofClass(Short.class, true), true, true, "(short) value"), + arguments(ofClass(double.class), ofClass(BigDecimal.class), true, false, "BigDecimal.valueOf(value)"), + arguments(ofClass(String.class), ofClass(int.class), true, true, "Integer.parseInt(value)"), + arguments(ofClass(String.class), ofClass(Integer.class, true), true, true, "Integer.valueOf(value)")); + } +} diff --git a/processor/src/test/java/org/ethelred/kiwiproc/processor/ProcessorTest.java b/processor/src/test/java/org/ethelred/kiwiproc/processor/ProcessorTest.java index 158a22d..96c3f76 100644 --- a/processor/src/test/java/org/ethelred/kiwiproc/processor/ProcessorTest.java +++ b/processor/src/test/java/org/ethelred/kiwiproc/processor/ProcessorTest.java @@ -1,6 +1,7 @@ package org.ethelred.kiwiproc.processor; -import com.google.testing.compile.CompilationSubject; +import static com.google.testing.compile.CompilationSubject.assertThat; + import com.google.testing.compile.Compiler; import com.google.testing.compile.JavaFileObjects; import io.avaje.jsonb.JsonType; @@ -8,6 +9,10 @@ import io.zonky.test.db.postgres.embedded.LiquibasePreparer; import io.zonky.test.db.postgres.junit5.EmbeddedPostgresExtension; import io.zonky.test.db.postgres.junit5.PreparedDbExtension; +import java.io.IOException; +import java.nio.file.Files; +import java.util.Map; +import java.util.stream.Stream; import net.serverpeon.testing.compile.CompilationExtension; import org.ethelred.kiwiproc.processorconfig.DataSourceConfig; import org.ethelred.kiwiproc.processorconfig.DependencyInjectionStyle; @@ -16,30 +21,26 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.RegisterExtension; -import java.io.IOException; -import java.nio.file.Files; -import java.util.Map; -import java.util.stream.Stream; - -import static com.google.testing.compile.CompilationSubject.assertThat; - @ExtendWith(CompilationExtension.class) public class ProcessorTest { @RegisterExtension - public static PreparedDbExtension pg = EmbeddedPostgresExtension - .preparedDatabase(LiquibasePreparer.forClasspathLocation("changelog.xml")); + public static PreparedDbExtension pg = + EmbeddedPostgresExtension.preparedDatabase(LiquibasePreparer.forClasspathLocation("changelog.xml")); - public static JsonType processorConfigType = Jsonb.builder().build().type(ProcessorConfig.class); + public static JsonType processorConfigType = + Jsonb.builder().build().type(ProcessorConfig.class); @Test void whenNoConfigurationFileFailCompilation() { var compilation = Compiler.javac() .withProcessors(new KiwiProcessor()) - .compile(JavaFileObjects.forSourceString("com.example.MyDAO", - // language=java - """ + .compile( + JavaFileObjects.forSourceString( + "com.example.MyDAO", + // language=java + """ package com.example; - + public interface MyDAO { } """)); @@ -51,11 +52,13 @@ void whenConfigurationFileIsMissingFailCompilation() { var compilation = Compiler.javac() .withProcessors(new KiwiProcessor()) .withOptions("-Aorg.ethelred.kiwiproc.configuration=bogus.json") - .compile(JavaFileObjects.forSourceString("com.example.MyDAO", - // language=java - """ + .compile( + JavaFileObjects.forSourceString( + "com.example.MyDAO", + // language=java + """ package com.example; - + public interface MyDAO { } """)); @@ -72,13 +75,14 @@ void whenConfigurationFileIsInvalidFailCompilation() throws IOException { .withProcessors(new KiwiProcessor()) .withOptions("-Aorg.ethelred.kiwiproc.configuration=%s".formatted(config)) .compile( - JavaFileObjects.forSourceString("com.example.MyDAO", - // language=java - """ + JavaFileObjects.forSourceString( + "com.example.MyDAO", + // language=java + """ package com.example; - + import org.ethelred.kiwiproc.annotation.DAO; - + @DAO public interface MyDAO { } @@ -88,8 +92,15 @@ public interface MyDAO { Compiler configuredCompiler() throws IOException { var ci = pg.getConnectionInfo(); - var dataSourceConfig = new DataSourceConfig("default", "jdbc:postgresql://localhost:%d/%s?user=%s".formatted(ci.getPort(), ci.getDbName(), ci.getUser()), ci.getDbName(), ci.getUser(), "postgres", "org.postgresql.Driver"); - var processorConfig = new ProcessorConfig(Map.of("default", dataSourceConfig), DependencyInjectionStyle.JAKARTA); + var dataSourceConfig = new DataSourceConfig( + "default", + "jdbc:postgresql://localhost:%d/%s?user=%s".formatted(ci.getPort(), ci.getDbName(), ci.getUser()), + ci.getDbName(), + ci.getUser(), + "postgres", + "org.postgresql.Driver"); + var processorConfig = + new ProcessorConfig(Map.of("default", dataSourceConfig), DependencyInjectionStyle.JAKARTA); var configFile = Files.createTempFile("config", ".json"); Files.writeString(configFile, processorConfigType.toJson(processorConfig)); @@ -102,13 +113,14 @@ Compiler configuredCompiler() throws IOException { void whenDAOInterfaceHasNoQueryMethodsFailCompilation() throws IOException { var compilation = configuredCompiler() .compile( - JavaFileObjects.forSourceString("com.example.MyDAO", + JavaFileObjects.forSourceString( + "com.example.MyDAO", // language=java """ package com.example; - + import org.ethelred.kiwiproc.annotation.DAO; - + @DAO public interface MyDAO { } @@ -120,31 +132,32 @@ public interface MyDAO { void whenDAOInterfaceHasAQueryMethodAnImplementationIsGenerated() throws IOException { var compilation = configuredCompiler() .compile( - JavaFileObjects.forSourceString("com.example.Restaurant", + JavaFileObjects.forSourceString( + "com.example.Restaurant", // language=java """ package com.example; - - public record Restaurant(int id, String name, int tables, String chain){} + import org.jspecify.annotations.Nullable; + public record Restaurant(int id, String name, Integer tables, @Nullable String chain){} """), - JavaFileObjects.forSourceString("com.example.MyDAO", + JavaFileObjects.forSourceString( + "com.example.MyDAO", // language=java """ package com.example; - + import java.util.List; import org.ethelred.kiwiproc.annotation.DAO; import org.ethelred.kiwiproc.annotation.SqlQuery; - + @DAO public interface MyDAO { @SqlQuery(sql = "SELECT * FROM restaurant WHERE name like :search || '%'") List findRestaurantsByName(String search); } """)); - Stream.of("$MyDAO$Impl", "$MyDAO$Mapper", "$MyDAO$Provider", "$MyDAO$findRestaurantsByName$Row").forEach(className -> - assertThat(compilation).generatedSourceFile("com.example." + className) - ); + Stream.of("$MyDAO$Impl", "$MyDAO$Provider") + .forEach(className -> assertThat(compilation).generatedSourceFile("com.example." + className)); compilation.generatedSourceFiles().forEach(f -> { System.err.println(f.getName()); try { @@ -159,25 +172,27 @@ public interface MyDAO { void anArbitraryClassIsNotASupportedReturnType() throws IOException { var compilation = configuredCompiler() .compile( - JavaFileObjects.forSourceString("com.example.Restaurant", + JavaFileObjects.forSourceString( + "com.example.Restaurant", // language=java """ package com.example; - + public class Restaurant { public Restaurant(int id, String name, int tables, String chain) { } } """), - JavaFileObjects.forSourceString("com.example.MyDAO", + JavaFileObjects.forSourceString( + "com.example.MyDAO", // language=java """ package com.example; - + import java.util.List; import org.ethelred.kiwiproc.annotation.DAO; import org.ethelred.kiwiproc.annotation.SqlQuery; - + @DAO public interface MyDAO { @SqlQuery(sql = "SELECT * FROM restaurant WHERE name like :search || '%'") diff --git a/processor/src/test/java/org/ethelred/kiwiproc/processor/SqlTypeMappingTest.java b/processor/src/test/java/org/ethelred/kiwiproc/processor/SqlTypeMappingTest.java new file mode 100644 index 0000000..e652467 --- /dev/null +++ b/processor/src/test/java/org/ethelred/kiwiproc/processor/SqlTypeMappingTest.java @@ -0,0 +1,40 @@ +package org.ethelred.kiwiproc.processor; + +import static com.google.common.truth.Truth.assertThat; + +import java.sql.JDBCType; +import java.util.Set; +import org.ethelred.kiwiproc.meta.ColumnMetaData; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +public class SqlTypeMappingTest { + + Set unsupportedTypes = Set.of( + JDBCType.BINARY, + JDBCType.VARBINARY, + JDBCType.LONGVARBINARY, + JDBCType.OTHER, + JDBCType.JAVA_OBJECT, + JDBCType.DISTINCT, + JDBCType.STRUCT, + JDBCType.BLOB, + JDBCType.CLOB, + JDBCType.NCLOB, + JDBCType.REF, + JDBCType.DATALINK, + JDBCType.ROWID, // TODO? + JDBCType.SQLXML, + JDBCType.REF_CURSOR); + + @ParameterizedTest + @EnumSource(JDBCType.class) + public void sqlMappingIsPresentForJDBCType(JDBCType jdbcType) { + if (unsupportedTypes.contains(jdbcType)) { + return; + } + var columnMetaData = new ColumnMetaData(1, "columnName", false, jdbcType, null); + var mapping = SqlTypeMapping.get(columnMetaData); + assertThat(mapping).isNotNull(); + } +} diff --git a/processor/src/test/java/org/ethelred/kiwiproc/processor/TestMapper.java b/processor/src/test/java/org/ethelred/kiwiproc/processor/TestMapper.java new file mode 100644 index 0000000..8092a90 --- /dev/null +++ b/processor/src/test/java/org/ethelred/kiwiproc/processor/TestMapper.java @@ -0,0 +1,17 @@ +package org.ethelred.kiwiproc.processor; + +import java.time.LocalDate; +import org.mapstruct.Mapper; + +@Mapper +public interface TestMapper { + record PrimitiveIntRecord(int value) {} + + record StringRecord(String value) {} + + record LocalDateRecord(LocalDate value) {} + + StringRecord toString(PrimitiveIntRecord x); + + LocalDateRecord toLocalDate(StringRecord x); +} diff --git a/processor/src/test/java/org/ethelred/kiwiproc/processor/TypeValidatorTest.java b/processor/src/test/java/org/ethelred/kiwiproc/processor/TypeValidatorTest.java new file mode 100644 index 0000000..c7aabfd --- /dev/null +++ b/processor/src/test/java/org/ethelred/kiwiproc/processor/TypeValidatorTest.java @@ -0,0 +1,199 @@ +package org.ethelred.kiwiproc.processor; + +import static com.google.common.truth.Truth.assertThat; +import static org.ethelred.kiwiproc.processor.SimpleType.ofClass; +import static org.junit.jupiter.params.provider.Arguments.arguments; + +import com.karuslabs.utilitary.Logger; +import java.lang.annotation.Annotation; +import java.sql.JDBCType; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Stream; +import javax.annotation.processing.Messager; +import javax.lang.model.element.*; +import javax.lang.model.type.TypeMirror; +import javax.tools.Diagnostic; +import org.ethelred.kiwiproc.meta.ColumnMetaData; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +public class TypeValidatorTest { + TypeValidator validator = new TypeValidator(mockLogger(), mockMethodElement(), new CoreTypes()); + Set messages = new HashSet<>(); + static int colCount = 1; + + @AfterEach + void reset() { + colCount = 1; + } + + @ParameterizedTest + @MethodSource + void testQueryReturn( + List columnMetaData, + KiwiType returnType, + boolean expectedResult, + @Nullable String message) { + var result = validator.validateReturn(columnMetaData, returnType, QueryMethodKind.QUERY); + assertThat(result).isEqualTo(expectedResult); + if (message == null) { + assertThat(messages).isEmpty(); + } else { + assertThat(messages).contains(message); + } + } + + public static Stream testQueryReturn() { + return Stream.of( + testCase(ofClass(int.class), true, null, col(false, JDBCType.INTEGER)), + testCase( + new ContainerType(ValidContainerType.LIST, ofClass(int.class)), + true, + null, + col(false, JDBCType.INTEGER)), + testCase( + new ContainerType( + ValidContainerType.LIST, recordType("TestRecord", "test1", ofClass(int.class))), + true, + null, + col(false, JDBCType.INTEGER)), + testCase( + new ContainerType( + ValidContainerType.LIST, + recordType("TestRecord", "test1", ofClass(int.class), "test2", ofClass(String.class))), + false, + "Missing or incompatible column type null for component test2 type String/non-null", + col(false, JDBCType.INTEGER)), + testCase( + new ContainerType( + ValidContainerType.LIST, recordType("TestRecord", "test1", ofClass(int.class))), + false, + "Missing or incompatible component type null for column test2 type String/non-null", + col(false, JDBCType.INTEGER), + col(false, JDBCType.VARCHAR))); + } + + private static KiwiType recordType(String className, String componentName, KiwiType componentType) { + return new RecordType( + "test", className, List.of(new RecordType.RecordTypeComponent(componentName, componentType))); + } + + private static KiwiType recordType( + String className, + String componentName, + KiwiType componentType, + String componentName2, + KiwiType componentType2) { + return new RecordType( + "test", + className, + List.of( + new RecordType.RecordTypeComponent(componentName, componentType), + new RecordType.RecordTypeComponent(componentName2, componentType2))); + } + + static Arguments testCase( + KiwiType returnType, boolean expectedResult, @Nullable String message, ColumnMetaData... columns) { + var result = arguments(List.of(columns), returnType, expectedResult, message); + colCount = 1; + return result; + } + + static ColumnMetaData col(boolean nullable, JDBCType type, @Nullable JDBCType componentType) { + return new ColumnMetaData(colCount, "test" + colCount++, nullable, type, componentType); + } + + static ColumnMetaData col(boolean nullable, JDBCType type) { + return col(nullable, type, null); + } + + private Element mockMethodElement() { + return new Element() { + @Override + public TypeMirror asType() { + return null; + } + + @Override + public ElementKind getKind() { + return null; + } + + @Override + public Set getModifiers() { + return Set.of(); + } + + @Override + public Name getSimpleName() { + return null; + } + + @Override + public Element getEnclosingElement() { + return null; + } + + @Override + public List getEnclosedElements() { + return List.of(); + } + + @Override + public List getAnnotationMirrors() { + return List.of(); + } + + @Override + public A getAnnotation(Class annotationType) { + return null; + } + + @Override + public A[] getAnnotationsByType(Class annotationType) { + return null; + } + + @Override + public R accept(ElementVisitor v, P p) { + return null; + } + }; + } + + private Logger mockLogger() { + return new Logger(mockMessager()); + } + + private Messager mockMessager() { + return new Messager() { + @Override + public void printMessage(Diagnostic.Kind kind, CharSequence msg) { + if (kind != Diagnostic.Kind.NOTE) { + messages.add(msg.toString()); + } + } + + @Override + public void printMessage(Diagnostic.Kind kind, CharSequence msg, Element e) { + printMessage(kind, msg); + } + + @Override + public void printMessage(Diagnostic.Kind kind, CharSequence msg, Element e, AnnotationMirror a) { + printMessage(kind, msg); + } + + @Override + public void printMessage( + Diagnostic.Kind kind, CharSequence msg, Element e, AnnotationMirror a, AnnotationValue v) { + printMessage(kind, msg); + } + }; + } +} diff --git a/querymeta/src/main/java/org/ethelred/kiwiproc/meta/ColumnMetaData.java b/querymeta/src/main/java/org/ethelred/kiwiproc/meta/ColumnMetaData.java index 5fcfa0a..ade8d13 100644 --- a/querymeta/src/main/java/org/ethelred/kiwiproc/meta/ColumnMetaData.java +++ b/querymeta/src/main/java/org/ethelred/kiwiproc/meta/ColumnMetaData.java @@ -1,34 +1,30 @@ package org.ethelred.kiwiproc.meta; +import java.sql.*; import org.jspecify.annotations.Nullable; import org.postgresql.core.BaseConnection; -import java.sql.*; - public record ColumnMetaData( - int index, - String name, - boolean nullable, - JDBCType sqlType, - String dbType, - String javaType, - @Nullable JDBCType componentType + int index, String name, boolean nullable, JDBCType sqlType, @Nullable JDBCType componentType // TODO precision/scale? -) { - public static ColumnMetaData from(Connection connection, int index, ResultSetMetaData resultSetMetaData) throws SQLException { + ) { + public static ColumnMetaData from(Connection connection, int index, ResultSetMetaData resultSetMetaData) + throws SQLException { return new ColumnMetaData( index, resultSetMetaData.getColumnName(index), - resultSetMetaData.isNullable(index) != ResultSetMetaData.columnNoNulls, // for results, treat 'unknown' as 'nullable' since caller may need to handle null case + resultSetMetaData.isNullable(index) + != ResultSetMetaData + .columnNoNulls, // for results, treat 'unknown' as 'nullable' since caller may need to + // handle null case JDBCType.valueOf(resultSetMetaData.getColumnType(index)), - resultSetMetaData.getColumnTypeName(index), - resultSetMetaData.getColumnClassName(index), - componentType(connection, resultSetMetaData.getColumnType(index), resultSetMetaData.getColumnTypeName(index)) - ); + componentType( + connection, + resultSetMetaData.getColumnType(index), + resultSetMetaData.getColumnTypeName(index))); } - @Nullable - private static JDBCType componentType(Connection connection, int columnType, String columnTypeName) { + @Nullable private static JDBCType componentType(Connection connection, int columnType, String columnTypeName) { if (columnType != Types.ARRAY) { return null; } @@ -44,16 +40,19 @@ private static JDBCType componentType(Connection connection, int columnType, Str return null; } - public static ColumnMetaData from(Connection connection, int index, ParameterMetaData parameterMetaData) throws SQLException { + public static ColumnMetaData from(Connection connection, int index, ParameterMetaData parameterMetaData) + throws SQLException { return new ColumnMetaData( index, "parameter", // does not have a name in metadata, will be associated by index outside this scope - parameterMetaData.isNullable(index) == ParameterMetaData.parameterNullable, // for parameters, treat 'unknown' as 'not null' since DB might not accept a null + parameterMetaData.isNullable(index) + == ParameterMetaData + .parameterNullable, // for parameters, treat 'unknown' as 'not null' since DB might not + // accept a null JDBCType.valueOf(parameterMetaData.getParameterType(index)), - parameterMetaData.getParameterTypeName(index), - parameterMetaData.getParameterClassName(index), - componentType(connection,parameterMetaData.getParameterType(index), parameterMetaData.getParameterTypeName(index)) - ); + componentType( + connection, + parameterMetaData.getParameterType(index), + parameterMetaData.getParameterTypeName(index))); } - } diff --git a/querymeta/src/main/java/org/ethelred/kiwiproc/meta/DatabaseWrapper.java b/querymeta/src/main/java/org/ethelred/kiwiproc/meta/DatabaseWrapper.java index 537e57a..a572087 100644 --- a/querymeta/src/main/java/org/ethelred/kiwiproc/meta/DatabaseWrapper.java +++ b/querymeta/src/main/java/org/ethelred/kiwiproc/meta/DatabaseWrapper.java @@ -1,15 +1,15 @@ package org.ethelred.kiwiproc.meta; +import java.sql.Connection; +import java.sql.SQLException; +import javax.sql.DataSource; import org.ethelred.kiwiproc.processorconfig.DataSourceConfig; import org.jspecify.annotations.Nullable; import org.postgresql.ds.PGSimpleDataSource; -import javax.sql.DataSource; -import java.sql.Connection; -import java.sql.SQLException; - public class DatabaseWrapper { @Nullable private Boolean valid; + private DatabaseWrapperException error; private DataSource dataSource; @@ -17,7 +17,7 @@ public DatabaseWrapper(String name, @Nullable DataSourceConfig dataSourceConfig) if (dataSourceConfig == null) { valid = false; error = new DatabaseWrapperException("No config found for data source name %s".formatted(name)); - } else if(invalidDriver(dataSourceConfig.driverClassName())) { + } else if (invalidDriver(dataSourceConfig.driverClassName())) { valid = false; error = new DatabaseWrapperException("Sorry, I only support Postgres at the moment."); } else { @@ -31,7 +31,9 @@ public DatabaseWrapper(String name, @Nullable DataSourceConfig dataSourceConfig) } private boolean invalidDriver(@Nullable String driverClassName) { - return driverClassName != null && !driverClassName.isBlank() && !"org.postgresql.Driver".equals(driverClassName); + return driverClassName != null + && !driverClassName.isBlank() + && !"org.postgresql.Driver".equals(driverClassName); } public boolean isValid() { @@ -41,8 +43,7 @@ public boolean isValid() { return valid; } - public DatabaseWrapperException getError() - { + public DatabaseWrapperException getError() { return error; } @@ -53,7 +54,8 @@ public DatabaseWrapperException getError() @SuppressWarnings("SqlSourceToSinkFlow") public QueryMetaData getQueryMetaData(String sql) throws SQLException { System.err.printf("getQueryMetaData(%s)%n", sql); - try (var connection = getConnection(); var statement = connection.prepareStatement(sql)) { + try (var connection = getConnection(); + var statement = connection.prepareStatement(sql)) { var builder = QueryMetaDataBuilder.builder(); var rsmd = statement.getMetaData(); for (var index = 1; index <= rsmd.getColumnCount(); index++) { @@ -68,7 +70,9 @@ public QueryMetaData getQueryMetaData(String sql) throws SQLException { } private void testConnection() { - try (var connection = getConnection(); var st = connection.prepareStatement("SELECT 1 = 1"); var rs = st.executeQuery()) { + try (var connection = getConnection(); + var st = connection.prepareStatement("SELECT 1 = 1"); + var rs = st.executeQuery()) { valid = rs.next(); } catch (SQLException e) { valid = false; diff --git a/querymeta/src/main/java/org/ethelred/kiwiproc/meta/ParsedQuery.java b/querymeta/src/main/java/org/ethelred/kiwiproc/meta/ParsedQuery.java index 1aa9ca1..5c84d27 100644 --- a/querymeta/src/main/java/org/ethelred/kiwiproc/meta/ParsedQuery.java +++ b/querymeta/src/main/java/org/ethelred/kiwiproc/meta/ParsedQuery.java @@ -3,10 +3,11 @@ import java.util.*; import java.util.regex.Pattern; -public record ParsedQuery(String rawSql, String parsedSql, List parameterNames){ - private static final Pattern PARAMETER_REGEX = Pattern.compile("([^:\\\\]*)((? parameterNames) { + private static final Pattern PARAMETER_REGEX = + Pattern.compile("([^:\\\\]*)((?(); var matcher = PARAMETER_REGEX.matcher(rawSql); diff --git a/querymeta/src/main/java/org/ethelred/kiwiproc/meta/QueryMetaData.java b/querymeta/src/main/java/org/ethelred/kiwiproc/meta/QueryMetaData.java index f954d9d..bfba9af 100644 --- a/querymeta/src/main/java/org/ethelred/kiwiproc/meta/QueryMetaData.java +++ b/querymeta/src/main/java/org/ethelred/kiwiproc/meta/QueryMetaData.java @@ -1,12 +1,7 @@ package org.ethelred.kiwiproc.meta; import io.soabase.recordbuilder.core.RecordBuilderFull; - import java.util.List; @RecordBuilderFull -public record QueryMetaData( - List resultColumns, - List parameters -) { -} +public record QueryMetaData(List resultColumns, List parameters) {} diff --git a/querymeta/src/main/java/org/ethelred/kiwiproc/meta/package-info.java b/querymeta/src/main/java/org/ethelred/kiwiproc/meta/package-info.java index 9d3e241..3963598 100644 --- a/querymeta/src/main/java/org/ethelred/kiwiproc/meta/package-info.java +++ b/querymeta/src/main/java/org/ethelred/kiwiproc/meta/package-info.java @@ -1,4 +1,4 @@ @NullMarked package org.ethelred.kiwiproc.meta; -import org.jspecify.annotations.NullMarked; \ No newline at end of file +import org.jspecify.annotations.NullMarked; diff --git a/querymeta/src/test/java/org/ethelred/kiwiproc/meta/DatabaseWrapperTest.java b/querymeta/src/test/java/org/ethelred/kiwiproc/meta/DatabaseWrapperTest.java index 8abd27e..3602417 100644 --- a/querymeta/src/test/java/org/ethelred/kiwiproc/meta/DatabaseWrapperTest.java +++ b/querymeta/src/test/java/org/ethelred/kiwiproc/meta/DatabaseWrapperTest.java @@ -1,28 +1,33 @@ package org.ethelred.kiwiproc.meta; -import org.ethelred.kiwiproc.processorconfig.DataSourceConfig; +import static com.google.common.truth.Truth.assertThat; + import io.zonky.test.db.postgres.embedded.LiquibasePreparer; import io.zonky.test.db.postgres.junit5.EmbeddedPostgresExtension; import io.zonky.test.db.postgres.junit5.PreparedDbExtension; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.RegisterExtension; - import java.sql.JDBCType; import java.sql.SQLException; import java.util.ArrayList; - -import static com.google.common.truth.Truth.assertThat; +import org.ethelred.kiwiproc.processorconfig.DataSourceConfig; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; public class DatabaseWrapperTest { @RegisterExtension - public static PreparedDbExtension pg = EmbeddedPostgresExtension - .preparedDatabase(LiquibasePreparer.forClasspathLocation("changelog.xml")); + public static PreparedDbExtension pg = + EmbeddedPostgresExtension.preparedDatabase(LiquibasePreparer.forClasspathLocation("changelog.xml")); @Test public void validConnection() throws SQLException { var ci = pg.getConnectionInfo(); - var config = new DataSourceConfig("test", "jdbc:postgresql://localhost:%d/%s?user=%s".formatted(ci.getPort(), ci.getDbName(), ci.getUser()), ci.getDbName(), ci.getUser(), "postgres", "org.postgresql.Driver"); + var config = new DataSourceConfig( + "test", + "jdbc:postgresql://localhost:%d/%s?user=%s".formatted(ci.getPort(), ci.getDbName(), ci.getUser()), + ci.getDbName(), + ci.getUser(), + "postgres", + "org.postgresql.Driver"); var wrapper = new DatabaseWrapper("test", config); wrapper.isValid(); // triggers connection test assertThat(wrapper.getError()).isNull(); @@ -43,33 +48,47 @@ public void simpleQueryMetaData() throws SQLException { var queryMetaData = getQueryMetaData("SELECT * FROM test_table"); assertThat(queryMetaData.parameters()).isEmpty(); assertThat(queryMetaData.resultColumns()).hasSize(4); - assertThat(queryMetaData.resultColumns().get(0)).isEqualTo(new ColumnMetaData(1, "test_id", false, JDBCType.INTEGER, "int4", "java.lang.Integer", null)); - assertThat(queryMetaData.resultColumns().get(1)).isEqualTo(new ColumnMetaData(2, "notes", true, JDBCType.VARCHAR, "text", "java.lang.String", null)); - assertThat(queryMetaData.resultColumns().get(2)).isEqualTo(new ColumnMetaData(3, "something", true, JDBCType.OTHER, "jsonb", "java.lang.String", null)); - assertThat(queryMetaData.resultColumns().get(3)).isEqualTo(new ColumnMetaData(4, "large", true, JDBCType.BIGINT, "int8", "java.lang.Long", null)); + assertThat(queryMetaData.resultColumns().get(0)) + .isEqualTo(new ColumnMetaData(1, "test_id", false, JDBCType.INTEGER, null)); + assertThat(queryMetaData.resultColumns().get(1)) + .isEqualTo(new ColumnMetaData(2, "notes", true, JDBCType.VARCHAR, null)); + assertThat(queryMetaData.resultColumns().get(2)) + .isEqualTo(new ColumnMetaData(3, "something", true, JDBCType.OTHER, null)); + assertThat(queryMetaData.resultColumns().get(3)) + .isEqualTo(new ColumnMetaData(4, "large", true, JDBCType.BIGINT, null)); } @Test public void simpleQueryWithParameterMetaData() throws SQLException { var queryMetaData = getQueryMetaData("SELECT * FROM test_table where test_id = ?"); assertThat(queryMetaData.parameters()).hasSize(1); - assertThat(queryMetaData.parameters().get(0)).isEqualTo(new ColumnMetaData(1, "parameter", false, JDBCType.INTEGER, "int4", "java.lang.Integer", null)); + assertThat(queryMetaData.parameters().get(0)) + .isEqualTo(new ColumnMetaData(1, "parameter", false, JDBCType.INTEGER, null)); assertThat(queryMetaData.resultColumns()).hasSize(4); - assertThat(queryMetaData.resultColumns().get(0)).isEqualTo(new ColumnMetaData(1, "test_id", false, JDBCType.INTEGER, "int4", "java.lang.Integer", null)); + assertThat(queryMetaData.resultColumns().get(0)) + .isEqualTo(new ColumnMetaData(1, "test_id", false, JDBCType.INTEGER, null)); } @Test public void simpleQueryArrayParameterMetaData() throws SQLException { var queryMetaData = getQueryMetaData("SELECT * FROM test_table where test_id = ANY(?)"); assertThat(queryMetaData.parameters()).hasSize(1); - assertThat(queryMetaData.parameters().get(0)).isEqualTo(new ColumnMetaData(1, "parameter", false, JDBCType.ARRAY, "_int4", "java.sql.Array", JDBCType.INTEGER)); + assertThat(queryMetaData.parameters().get(0)) + .isEqualTo(new ColumnMetaData(1, "parameter", false, JDBCType.ARRAY, JDBCType.INTEGER)); assertThat(queryMetaData.resultColumns()).hasSize(4); - assertThat(queryMetaData.resultColumns().get(0)).isEqualTo(new ColumnMetaData(1, "test_id", false, JDBCType.INTEGER, "int4", "java.lang.Integer", null)); + assertThat(queryMetaData.resultColumns().get(0)) + .isEqualTo(new ColumnMetaData(1, "test_id", false, JDBCType.INTEGER, null)); } private QueryMetaData getQueryMetaData(String sql) throws SQLException { var ci = pg.getConnectionInfo(); - var config = new DataSourceConfig("test", "jdbc:postgresql://localhost:%d/%s?user=%s".formatted(ci.getPort(), ci.getDbName(), ci.getUser()), ci.getDbName(), ci.getUser(), "postgres", "org.postgresql.Driver"); + var config = new DataSourceConfig( + "test", + "jdbc:postgresql://localhost:%d/%s?user=%s".formatted(ci.getPort(), ci.getDbName(), ci.getUser()), + ci.getDbName(), + ci.getUser(), + "postgres", + "org.postgresql.Driver"); var wrapper = new DatabaseWrapper("test", config); return wrapper.getQueryMetaData(sql); diff --git a/querymeta/src/test/java/org/ethelred/kiwiproc/meta/ParsedSqlQueryTest.java b/querymeta/src/test/java/org/ethelred/kiwiproc/meta/ParsedSqlQueryTest.java index ac66dcf..58b56ca 100644 --- a/querymeta/src/test/java/org/ethelred/kiwiproc/meta/ParsedSqlQueryTest.java +++ b/querymeta/src/test/java/org/ethelred/kiwiproc/meta/ParsedSqlQueryTest.java @@ -1,9 +1,8 @@ package org.ethelred.kiwiproc.meta; +import com.google.common.truth.Truth; import java.util.List; import java.util.stream.Stream; - -import com.google.common.truth.Truth; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; @@ -13,8 +12,10 @@ public static Stream sampleQueries() { return Stream.of( sampleQuery("SELECT a FROM test", "SELECT a FROM test", List.of()), sampleQuery("SELECT a FROM test WHERE b = :btest", "SELECT a FROM test WHERE b = ?", List.of("btest")), - sampleQuery("INSERT INTO flavour (name, calories) VALUES (:name, :calories)","INSERT INTO flavour (name, calories) VALUES (?, ?)", List.of("name", "calories")) - ); + sampleQuery( + "INSERT INTO flavour (name, calories) VALUES (:name, :calories)", + "INSERT INTO flavour (name, calories) VALUES (?, ?)", + List.of("name", "calories"))); } private static Arguments sampleQuery(String rawSql, String expectedParsedSql, List expectedParameterNames) { diff --git a/runtime/src/main/java/org/ethelred/kiwiproc/api/Batch.java b/runtime/src/main/java/org/ethelred/kiwiproc/api/Batch.java index 02843cc..28504f2 100644 --- a/runtime/src/main/java/org/ethelred/kiwiproc/api/Batch.java +++ b/runtime/src/main/java/org/ethelred/kiwiproc/api/Batch.java @@ -8,5 +8,5 @@ public interface Batch> extends AutoCloseable { BatchId addBatch(DAORunnable consumer) throws SQLException; - record BatchId(String id){} + record BatchId(String id) {} } diff --git a/runtime/src/main/java/org/ethelred/kiwiproc/api/DAOContext.java b/runtime/src/main/java/org/ethelred/kiwiproc/api/DAOContext.java index 820062f..c4d6fcb 100644 --- a/runtime/src/main/java/org/ethelred/kiwiproc/api/DAOContext.java +++ b/runtime/src/main/java/org/ethelred/kiwiproc/api/DAOContext.java @@ -1,8 +1,7 @@ package org.ethelred.kiwiproc.api; import java.sql.Connection; -import java.sql.SQLException; public interface DAOContext { - Connection getConnection() throws SQLException; + Connection getConnection(); } diff --git a/runtime/src/main/java/org/ethelred/kiwiproc/api/DAOProvider.java b/runtime/src/main/java/org/ethelred/kiwiproc/api/DAOProvider.java index a81db39..10ba574 100644 --- a/runtime/src/main/java/org/ethelred/kiwiproc/api/DAOProvider.java +++ b/runtime/src/main/java/org/ethelred/kiwiproc/api/DAOProvider.java @@ -3,9 +3,8 @@ import java.sql.SQLException; public interface DAOProvider { - String getDataSourceName(); + R call(DAOCallable callback) throws SQLException; - void run(DAORunnable callback) throws SQLException; - T withContext(DAOContext context) throws SQLException; + void run(DAORunnable callback) throws SQLException; } diff --git a/runtime/src/main/java/org/ethelred/kiwiproc/api/TransactionManager.java b/runtime/src/main/java/org/ethelred/kiwiproc/api/TransactionManager.java index f3aceba..787e70e 100644 --- a/runtime/src/main/java/org/ethelred/kiwiproc/api/TransactionManager.java +++ b/runtime/src/main/java/org/ethelred/kiwiproc/api/TransactionManager.java @@ -1,23 +1,42 @@ package org.ethelred.kiwiproc.api; +import java.sql.SQLException; + public interface TransactionManager { - R inTransaction(DAOProvider providerA, DAOProvider providerB, TransactionCallable2 callback); - void inTransaction(DAOProvider providerA, DAOProvider providerB, TransactionRunnable2 callback); - interface TransactionCallable2 { + R inTransaction( + DAOProvider providerA, DAOProvider providerB, TransactionCallable2 callback) + throws SQLException; + + void inTransaction(DAOProvider providerA, DAOProvider providerB, TransactionRunnable2 callback) + throws SQLException; + + interface TransactionCallable2 { R call(A daoA, B daoB); } - interface TransactionRunnable2 { + interface TransactionRunnable2 { void run(A daoA, B daoB); } - R inTransaction(DAOProvider providerA, DAOProvider providerB, DAOProvider providerC, TransactionCallable3 callback); - void inTransaction(DAOProvider providerA, DAOProvider providerB, DAOProvider providerC, TransactionRunnable3 callback); - interface TransactionCallable3 { + R inTransaction( + DAOProvider providerA, + DAOProvider providerB, + DAOProvider providerC, + TransactionCallable3 callback) + throws SQLException; + + void inTransaction( + DAOProvider providerA, + DAOProvider providerB, + DAOProvider providerC, + TransactionRunnable3 callback) + throws SQLException; + + interface TransactionCallable3 { R call(A daoA, B daoB, C daoC); } - interface TransactionRunnable3 { + interface TransactionRunnable3 { void run(A daoA, B daoB, C daoC); } } diff --git a/runtime/src/main/java/org/ethelred/kiwiproc/impl/AbstractBatch.java b/runtime/src/main/java/org/ethelred/kiwiproc/impl/AbstractBatch.java index 540c937..370156a 100644 --- a/runtime/src/main/java/org/ethelred/kiwiproc/impl/AbstractBatch.java +++ b/runtime/src/main/java/org/ethelred/kiwiproc/impl/AbstractBatch.java @@ -1,10 +1,5 @@ package org.ethelred.kiwiproc.impl; -import org.ethelred.kiwiproc.api.Batch; -import org.ethelred.kiwiproc.api.Batchable; -import org.ethelred.kiwiproc.api.DAORunnable; -import org.ethelred.kiwiproc.exception.UncheckedSQLException; - import java.sql.PreparedStatement; import java.sql.SQLException; import java.util.Map; @@ -12,21 +7,24 @@ import java.util.function.Consumer; import java.util.function.Supplier; import java.util.stream.Collectors; +import org.ethelred.kiwiproc.api.Batch; +import org.ethelred.kiwiproc.api.Batchable; +import org.ethelred.kiwiproc.api.DAORunnable; +import org.ethelred.kiwiproc.exception.UncheckedSQLException; -public abstract class AbstractBatch> implements Batch -{ +public abstract class AbstractBatch> implements Batch { private final Map batches = new ConcurrentHashMap<>(); - protected AbstractBatch(){} + protected AbstractBatch() {} protected abstract DeferredContext newBatchContext(); + @Override public BatchId addBatch(DAORunnable consumer) throws SQLException { var context = newBatchContext(); var toAdd = context.run(consumer); - toAdd.setter().accept( - batches.computeIfAbsent(toAdd.batchId(), b -> toAdd.prepare().get()) - ); + toAdd.setter().accept(batches.computeIfAbsent(toAdd.batchId(), b -> toAdd.prepare() + .get())); return toAdd.batchId(); } @@ -46,8 +44,7 @@ private void closeOne(PreparedStatement preparedStatement) { @Override public Map execute() throws SQLException { try { - return batches.entrySet() - .stream() + return batches.entrySet().stream() .map(this::executeOne) .collect(Collectors.toUnmodifiableMap(Map.Entry::getKey, Map.Entry::getValue)); } catch (UncheckedSQLException e) { @@ -59,10 +56,7 @@ private Map.Entry executeOne(Map.Entry { DeferredAddBatch run(DAORunnable daoRunnable) throws SQLException; } - protected record DeferredAddBatch(BatchId batchId, Supplier prepare, Consumer setter){} + protected record DeferredAddBatch( + BatchId batchId, Supplier prepare, Consumer setter) {} } diff --git a/runtime/src/main/java/org/ethelred/kiwiproc/impl/AbstractDAOProvider.java b/runtime/src/main/java/org/ethelred/kiwiproc/impl/AbstractDAOProvider.java index 8d487c0..9bdca74 100644 --- a/runtime/src/main/java/org/ethelred/kiwiproc/impl/AbstractDAOProvider.java +++ b/runtime/src/main/java/org/ethelred/kiwiproc/impl/AbstractDAOProvider.java @@ -1,15 +1,15 @@ package org.ethelred.kiwiproc.impl; +import java.sql.SQLException; +import javax.sql.DataSource; import org.ethelred.kiwiproc.api.DAOCallable; +import org.ethelred.kiwiproc.api.DAOContext; import org.ethelred.kiwiproc.api.DAOProvider; import org.ethelred.kiwiproc.api.DAORunnable; import org.ethelred.kiwiproc.exception.UncheckedSQLException; -import javax.sql.DataSource; -import java.sql.SQLException; - public abstract class AbstractDAOProvider implements DAOProvider { - private final DataSource dataSource; + /* package */ final DataSource dataSource; protected AbstractDAOProvider(DataSource dataSource) { this.dataSource = dataSource; @@ -18,7 +18,11 @@ protected AbstractDAOProvider(DataSource dataSource) { @Override public R call(DAOCallable callback) throws SQLException { try (var connection = dataSource.getConnection()) { - return callback.call(withContext(() -> connection)); + connection.setAutoCommit(false); + var result = callback.call(withContext(() -> connection)); + connection.commit(); + connection.setAutoCommit(true); + return result; } catch (UncheckedSQLException e) { throw e.getCause(); } @@ -27,9 +31,14 @@ public R call(DAOCallable callback) throws SQLException { @Override public void run(DAORunnable callback) throws SQLException { try (var connection = dataSource.getConnection()) { + connection.setAutoCommit(false); callback.run(withContext(() -> connection)); + connection.commit(); + connection.setAutoCommit(true); } catch (UncheckedSQLException e) { throw e.getCause(); } } + + protected abstract T withContext(DAOContext context) throws SQLException; } diff --git a/runtime/src/main/java/org/ethelred/kiwiproc/impl/AbstractTransactionManager.java b/runtime/src/main/java/org/ethelred/kiwiproc/impl/AbstractTransactionManager.java new file mode 100644 index 0000000..af1aff4 --- /dev/null +++ b/runtime/src/main/java/org/ethelred/kiwiproc/impl/AbstractTransactionManager.java @@ -0,0 +1,95 @@ +package org.ethelred.kiwiproc.impl; + +import java.sql.Connection; +import java.sql.SQLException; +import javax.sql.DataSource; +import org.ethelred.kiwiproc.api.DAOProvider; +import org.ethelred.kiwiproc.api.TransactionManager; +import org.ethelred.kiwiproc.exception.UncheckedSQLException; + +/** + * Implements {@link TransactionManager}. Abstract because we need to generate an implementation with the correct dependency injection annotations. + */ +public abstract class AbstractTransactionManager implements TransactionManager { + private final DataSource dataSource; + + protected AbstractTransactionManager(DataSource dataSource) { + this.dataSource = dataSource; + } + + @Override + public R inTransaction( + DAOProvider providerA, DAOProvider providerB, TransactionCallable2 callback) + throws SQLException { + try (Connection connection = dataSource.getConnection()) { + connection.setAutoCommit(false); + A daoA = ((AbstractDAOProvider) providerA).withContext(() -> connection); + B daoB = ((AbstractDAOProvider) providerB).withContext(() -> connection); + var result = callback.call(daoA, daoB); + connection.commit(); + connection.setAutoCommit(true); + return result; + } catch (UncheckedSQLException e) { + throw e.getCause(); + } + } + + @Override + public void inTransaction( + DAOProvider providerA, DAOProvider providerB, TransactionRunnable2 callback) + throws SQLException { + + try (Connection connection = dataSource.getConnection()) { + connection.setAutoCommit(false); + A daoA = ((AbstractDAOProvider) providerA).withContext(() -> connection); + B daoB = ((AbstractDAOProvider) providerB).withContext(() -> connection); + callback.run(daoA, daoB); + connection.commit(); + connection.setAutoCommit(true); + } catch (UncheckedSQLException e) { + throw e.getCause(); + } + } + + @Override + public R inTransaction( + DAOProvider providerA, + DAOProvider providerB, + DAOProvider providerC, + TransactionCallable3 callback) + throws SQLException { + + try (Connection connection = dataSource.getConnection()) { + connection.setAutoCommit(false); + A daoA = ((AbstractDAOProvider) providerA).withContext(() -> connection); + B daoB = ((AbstractDAOProvider) providerB).withContext(() -> connection); + C daoC = ((AbstractDAOProvider) providerC).withContext(() -> connection); + var result = callback.call(daoA, daoB, daoC); + connection.commit(); + connection.setAutoCommit(true); + return result; + } catch (UncheckedSQLException e) { + throw e.getCause(); + } + } + + @Override + public void inTransaction( + DAOProvider providerA, + DAOProvider providerB, + DAOProvider providerC, + TransactionRunnable3 callback) + throws SQLException { + try (Connection connection = dataSource.getConnection()) { + connection.setAutoCommit(false); + A daoA = ((AbstractDAOProvider) providerA).withContext(() -> connection); + B daoB = ((AbstractDAOProvider) providerB).withContext(() -> connection); + C daoC = ((AbstractDAOProvider) providerC).withContext(() -> connection); + callback.run(daoA, daoB, daoC); + connection.commit(); + connection.setAutoCommit(true); + } catch (UncheckedSQLException e) { + throw e.getCause(); + } + } +} diff --git a/runtime/src/main/java/org/ethelred/kiwiproc/impl/BaseMapper.java b/runtime/src/main/java/org/ethelred/kiwiproc/impl/BaseMapper.java deleted file mode 100644 index 6aabd22..0000000 --- a/runtime/src/main/java/org/ethelred/kiwiproc/impl/BaseMapper.java +++ /dev/null @@ -1,4 +0,0 @@ -package org.ethelred.kiwiproc.impl; - -public abstract class BaseMapper { -} diff --git a/shared/src/main/java/org/ethelred/kiwiproc/annotation/DAO.java b/shared/src/main/java/org/ethelred/kiwiproc/annotation/DAO.java index 5c9b64b..3bd0f6d 100644 --- a/shared/src/main/java/org/ethelred/kiwiproc/annotation/DAO.java +++ b/shared/src/main/java/org/ethelred/kiwiproc/annotation/DAO.java @@ -11,7 +11,6 @@ */ @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) -public @interface DAO -{ +public @interface DAO { String dataSourceName() default "default"; } diff --git a/shared/src/main/java/org/ethelred/kiwiproc/annotation/SqlBatch.java b/shared/src/main/java/org/ethelred/kiwiproc/annotation/SqlBatch.java index 6017969..319ec90 100644 --- a/shared/src/main/java/org/ethelred/kiwiproc/annotation/SqlBatch.java +++ b/shared/src/main/java/org/ethelred/kiwiproc/annotation/SqlBatch.java @@ -1,21 +1,20 @@ package org.ethelred.kiwiproc.annotation; -import org.intellij.lang.annotations.Language; - import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +import org.intellij.lang.annotations.Language; @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) -public @interface SqlBatch -{ +public @interface SqlBatch { /** * Alias for "sql" */ @Language("SQL") String value() default ""; + @Language("SQL") String sql() default ""; diff --git a/shared/src/main/java/org/ethelred/kiwiproc/annotation/SqlQuery.java b/shared/src/main/java/org/ethelred/kiwiproc/annotation/SqlQuery.java index 583c3c3..0aef81b 100644 --- a/shared/src/main/java/org/ethelred/kiwiproc/annotation/SqlQuery.java +++ b/shared/src/main/java/org/ethelred/kiwiproc/annotation/SqlQuery.java @@ -1,11 +1,10 @@ package org.ethelred.kiwiproc.annotation; -import org.intellij.lang.annotations.Language; - import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +import org.intellij.lang.annotations.Language; /** *

@@ -23,13 +22,13 @@ */ @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) -public @interface SqlQuery -{ +public @interface SqlQuery { /** * Alias for "sql" */ @Language("SQL") String value() default ""; + @Language("SQL") String sql() default ""; diff --git a/shared/src/main/java/org/ethelred/kiwiproc/annotation/SqlUpdate.java b/shared/src/main/java/org/ethelred/kiwiproc/annotation/SqlUpdate.java index ffdfd92..ec291b9 100644 --- a/shared/src/main/java/org/ethelred/kiwiproc/annotation/SqlUpdate.java +++ b/shared/src/main/java/org/ethelred/kiwiproc/annotation/SqlUpdate.java @@ -1,21 +1,20 @@ package org.ethelred.kiwiproc.annotation; -import org.intellij.lang.annotations.Language; - import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +import org.intellij.lang.annotations.Language; @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) -public @interface SqlUpdate -{ +public @interface SqlUpdate { /** * Alias for "sql" */ @Language("SQL") String value() default ""; + @Language("SQL") String sql() default ""; } diff --git a/shared/src/main/java/org/ethelred/kiwiproc/processorconfig/DataSourceConfig.java b/shared/src/main/java/org/ethelred/kiwiproc/processorconfig/DataSourceConfig.java index fc8ab20..1713883 100644 --- a/shared/src/main/java/org/ethelred/kiwiproc/processorconfig/DataSourceConfig.java +++ b/shared/src/main/java/org/ethelred/kiwiproc/processorconfig/DataSourceConfig.java @@ -4,5 +4,10 @@ import org.jspecify.annotations.Nullable; @Json -public record DataSourceConfig(String named, String url, String database, String username, @Nullable String password, @Nullable String driverClassName) { -} +public record DataSourceConfig( + String named, + String url, + String database, + String username, + @Nullable String password, + @Nullable String driverClassName) {} diff --git a/shared/src/main/java/org/ethelred/kiwiproc/processorconfig/DependencyInjectionStyle.java b/shared/src/main/java/org/ethelred/kiwiproc/processorconfig/DependencyInjectionStyle.java index 3edd0c5..4001930 100644 --- a/shared/src/main/java/org/ethelred/kiwiproc/processorconfig/DependencyInjectionStyle.java +++ b/shared/src/main/java/org/ethelred/kiwiproc/processorconfig/DependencyInjectionStyle.java @@ -1,15 +1,5 @@ package org.ethelred.kiwiproc.processorconfig; -import java.util.List; - public enum DependencyInjectionStyle { JAKARTA; - - public List getImports() { - //TODO - return List.of( - "jakarta.inject.Singleton", - "jakarta.inject.Named" - ); - } } diff --git a/shared/src/main/java/org/ethelred/kiwiproc/processorconfig/ProcessorConfig.java b/shared/src/main/java/org/ethelred/kiwiproc/processorconfig/ProcessorConfig.java index 03c5471..e4489a3 100644 --- a/shared/src/main/java/org/ethelred/kiwiproc/processorconfig/ProcessorConfig.java +++ b/shared/src/main/java/org/ethelred/kiwiproc/processorconfig/ProcessorConfig.java @@ -1,15 +1,16 @@ package org.ethelred.kiwiproc.processorconfig; import io.avaje.jsonb.Json; - import java.util.Map; import java.util.Objects; @Json -public record ProcessorConfig(Map dataSources, DependencyInjectionStyle dependencyInjectionStyle) { +public record ProcessorConfig( + Map dataSources, DependencyInjectionStyle dependencyInjectionStyle) { public ProcessorConfig { dataSources = Objects.requireNonNullElse(dataSources, Map.of()); - dependencyInjectionStyle = Objects.requireNonNullElse(dependencyInjectionStyle, DependencyInjectionStyle.JAKARTA); + dependencyInjectionStyle = + Objects.requireNonNullElse(dependencyInjectionStyle, DependencyInjectionStyle.JAKARTA); } public static final ProcessorConfig EMPTY = new ProcessorConfig(Map.of(), DependencyInjectionStyle.JAKARTA); diff --git a/test-micronaut/build.gradle.kts b/test-micronaut/build.gradle.kts index d08adb0..1cc7de2 100644 --- a/test-micronaut/build.gradle.kts +++ b/test-micronaut/build.gradle.kts @@ -12,4 +12,7 @@ dependencies { annotationProcessor(project(":processor")) implementation(project(":runtime")) testImplementation("io.micronaut.test:micronaut-test-junit5") + testRuntimeOnly("io.micronaut.sql:micronaut-jdbc-hikari") + testRuntimeOnly(libs.postgresql) + testRuntimeOnly(libs.yaml) } \ No newline at end of file diff --git a/test-micronaut/src/main/java/org/ethelred/kiwiproc/test/Owner.java b/test-micronaut/src/main/java/org/ethelred/kiwiproc/test/Owner.java new file mode 100644 index 0000000..3ebe149 --- /dev/null +++ b/test-micronaut/src/main/java/org/ethelred/kiwiproc/test/Owner.java @@ -0,0 +1,4 @@ +package org.ethelred.kiwiproc.test; + +public record Owner(int id, String first_name, String last_name) { // TODO should be able to convert names to camel case +} diff --git a/test-micronaut/src/main/java/org/ethelred/kiwiproc/test/PetClinicDAO.java b/test-micronaut/src/main/java/org/ethelred/kiwiproc/test/PetClinicDAO.java index f1e8b6f..6f8c01c 100644 --- a/test-micronaut/src/main/java/org/ethelred/kiwiproc/test/PetClinicDAO.java +++ b/test-micronaut/src/main/java/org/ethelred/kiwiproc/test/PetClinicDAO.java @@ -1,13 +1,45 @@ package org.ethelred.kiwiproc.test; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; import org.ethelred.kiwiproc.annotation.DAO; import org.ethelred.kiwiproc.annotation.SqlQuery; - -import java.util.List; +import org.jspecify.annotations.Nullable; @DAO public interface PetClinicDAO { @SqlQuery(""" SELECT id, name FROM types""") List findPetTypes(); + + @SqlQuery(""" + SELECT name FROM pets""") + Set<@Nullable String> findPetNames(); // TODO shouldn't need to declare nullable in a collection + + @SqlQuery(""" + SELECT id, name FROM types WHERE id = :id""") + Optional getPetType(int id); + + record PetTypeWithCount( + int id, @Nullable String name, Long count) {} // TODO should be able to convert count to Integer + + @SqlQuery( + """ + SELECT t.id, t.name, count(*) FROM types t JOIN pets p ON t.id = p.type_id GROUP BY 1,2""") + List getPetTypesWithCountList(); + + default Map getPetTypesWithCount() { + return getPetTypesWithCountList().stream() + .map(ptc -> Map.entry(new PetType(ptc.id(), ptc.name()), ptc.count())) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + } + + /* TODO not yet supported + @SqlQuery(""" + SELECT id, first_name, last_name FROM owners WHERE id = ANY(:ids)""") + List findOwnersByIds(List ids); + */ } diff --git a/test-micronaut/src/main/java/org/ethelred/kiwiproc/test/PetType.java b/test-micronaut/src/main/java/org/ethelred/kiwiproc/test/PetType.java index 61a6148..88376f5 100644 --- a/test-micronaut/src/main/java/org/ethelred/kiwiproc/test/PetType.java +++ b/test-micronaut/src/main/java/org/ethelred/kiwiproc/test/PetType.java @@ -2,5 +2,4 @@ import org.jspecify.annotations.Nullable; -public record PetType(@Nullable Integer id, String name) { -} +public record PetType(int id, @Nullable String name) {} diff --git a/test-micronaut/src/test/java/org/ethelred/kiwiproc/test/PetClinicTest.java b/test-micronaut/src/test/java/org/ethelred/kiwiproc/test/PetClinicTest.java new file mode 100644 index 0000000..77c28e4 --- /dev/null +++ b/test-micronaut/src/test/java/org/ethelred/kiwiproc/test/PetClinicTest.java @@ -0,0 +1,58 @@ +package org.ethelred.kiwiproc.test; + +import static com.google.common.truth.Truth.assertThat; +import static com.google.common.truth.Truth8.assertThat; + +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import jakarta.inject.Inject; +import java.sql.SQLException; +import java.util.stream.Collectors; +import org.ethelred.kiwiproc.api.DAOProvider; +import org.junit.jupiter.api.Test; + +@MicronautTest(environments = "test") +public class PetClinicTest { + @Inject + DAOProvider daoProvider; + + @Test + void happySelectTypes() throws SQLException { + var types = daoProvider.call(PetClinicDAO::findPetTypes); + assertThat(types).isNotNull(); + assertThat(types).isNotEmpty(); + var typeNames = types.stream().map(PetType::name).collect(Collectors.toSet()); + assertThat(typeNames).contains("cat"); + } + + @Test + void happySelectOneType() throws SQLException { + var type = daoProvider.call(dao -> dao.getPetType(3)); + assertThat(type).isPresent(); + assertThat(type.get().name()).isEqualTo("lizard"); + } + + @Test + void happySelectPetNames() throws SQLException { + var names = daoProvider.call(PetClinicDAO::findPetNames); + assertThat(names).isNotEmpty(); + assertThat(names).contains("Jewel"); + } + + @Test + void happyTestDefaultMethod() throws SQLException { + var countsByType = daoProvider.call(PetClinicDAO::getPetTypesWithCount); + assertThat(countsByType).hasSize(6); + assertThat(countsByType).containsEntry(new PetType(2, "dog"), 4L); + } + + /* TODO not yet supported + @Test + void happyFindByArrayValues() throws SQLException { + var owners = daoProvider.call(dao -> dao.findOwnersByIds(List.of(2, 6, 99))); + assertThat(owners).hasSize(2); + var firstNames = owners.stream().map(Owner::first_name).toList(); + assertThat(firstNames).containsExactly("bob", "joe"); + } + + */ +}