diff --git a/sadu-mapper/build.gradle.kts b/sadu-mapper/build.gradle.kts index e5c3a763..d6e0dd27 100644 --- a/sadu-mapper/build.gradle.kts +++ b/sadu-mapper/build.gradle.kts @@ -2,4 +2,13 @@ description = "SADU module to map values received from a database to java object dependencies { api(project(":sadu-core")) + + testImplementation("org.postgresql", "postgresql", "42.7.3") + testImplementation(testlibs.bundles.junit) + testImplementation(project(":sadu-queries")) + testImplementation(project(":sadu-datasource")) + testImplementation(project(":sadu-postgresql")) + + testImplementation(testlibs.bundles.database.postgres) + testImplementation(testlibs.slf4j.noop) } diff --git a/sadu-mapper/src/main/java/de/chojo/sadu/mapper/RowMapperRegistry.java b/sadu-mapper/src/main/java/de/chojo/sadu/mapper/RowMapperRegistry.java index 23518c13..4425548e 100644 --- a/sadu-mapper/src/main/java/de/chojo/sadu/mapper/RowMapperRegistry.java +++ b/sadu-mapper/src/main/java/de/chojo/sadu/mapper/RowMapperRegistry.java @@ -6,14 +6,24 @@ package de.chojo.sadu.mapper; +import de.chojo.sadu.mapper.annotation.MappingProvider; +import de.chojo.sadu.mapper.exceptions.InvalidMappingException; import de.chojo.sadu.mapper.exceptions.MappingAlreadyRegisteredException; import de.chojo.sadu.mapper.exceptions.MappingException; import de.chojo.sadu.mapper.rowmapper.RowMapper; +import de.chojo.sadu.mapper.rowmapper.RowMapping; +import de.chojo.sadu.mapper.wrapper.Row; +import org.slf4j.Logger; +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; import java.sql.ResultSet; import java.sql.ResultSetMetaData; import java.sql.SQLException; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.Comparator; import java.util.HashMap; @@ -21,11 +31,14 @@ import java.util.Map; import java.util.Optional; +import static org.slf4j.LoggerFactory.getLogger; + /** * Class to register {@link RowMapper} to map rows to objects. */ public class RowMapperRegistry { private final Map, List>> mapper = new HashMap<>(); + private static final Logger log = getLogger(RowMapperRegistry.class); public RowMapperRegistry() { } @@ -176,6 +189,67 @@ public RowMapper findOrWildcard(Class clazz, ResultSetMetaData meta, M if (mapper.isPresent()) { return mapper.get(); } + + // Autodetect mapper if available + if (register(clazz)) { + return findOrWildcard(clazz, meta, config); + } + throw MappingException.create(clazz, meta); } + + public boolean register(Class clazz) { + boolean method = discoverMethodMapping(clazz); + boolean constructor = discoverConstructorMapping(clazz); + if (method || constructor) return true; + throw new InvalidMappingException("No mapping was detected for class %s".formatted(clazz.getName())); + } + + @SuppressWarnings("unchecked") + private boolean discoverMethodMapping(Class clazz) { + List methods = Arrays.stream(clazz.getDeclaredMethods()) + .filter(method -> method.isAnnotationPresent(MappingProvider.class) && Modifier.isStatic(method.getModifiers())) + .toList(); + + for (Method method : methods) { + MappingProvider provider = method.getAnnotation(MappingProvider.class); + RowMapping mapper; + try { + mapper = (RowMapping) method.invoke(null); + } catch (IllegalAccessException | InvocationTargetException | ClassCastException e) { + throw new InvalidMappingException("Could not retrieve mapping. Method has to return %s<%s> and take no arguments".formatted(RowMapping.class.getName(), clazz.getName()), e); + } + register(RowMapper.forClass(clazz).mapper(mapper).addColumns(provider.value()).build()); + log.info("Registered method auto mapping for {} with rows {}", clazz.getName(), provider.value()); + + } + return !methods.isEmpty(); + } + + @SuppressWarnings("unchecked") + private boolean discoverConstructorMapping(Class clazz) { + List> constructors = Arrays.stream(clazz.getDeclaredConstructors()) + .filter(constr -> constr.isAnnotationPresent(MappingProvider.class)) + .toList(); + + for (Constructor constructor : constructors) { + MappingProvider provider = constructor.getAnnotation(MappingProvider.class); + if (constructor.getParameterCount() != 1) { + throw new InvalidMappingException("Signature of a constructor with MappingProvider should be Constructor(Row)"); + } + if (constructor.getParameterTypes()[0] != Row.class) { + throw new InvalidMappingException("Signature of a constructor with MappingProvider should be Constructor(Row), but was Constructor(%s)".formatted(constructor.getParameterTypes()[0].getName())); + } + + register(RowMapper.forClass(clazz).mapper(row -> { + try { + return (V) constructor.newInstance(row); + } catch (InstantiationException | IllegalAccessException | InvocationTargetException e) { + throw new InvalidMappingException("Failed to create instance from RowMapping via constructor"); + } + }).addColumns(provider.value()).build()); + log.info("Registered constructor auto mapping for {} with rows {}", clazz.getName(), provider.value()); + } + return !constructors.isEmpty(); + } } diff --git a/sadu-mapper/src/main/java/de/chojo/sadu/mapper/annotation/MappingProvider.java b/sadu-mapper/src/main/java/de/chojo/sadu/mapper/annotation/MappingProvider.java new file mode 100644 index 00000000..789d3250 --- /dev/null +++ b/sadu-mapper/src/main/java/de/chojo/sadu/mapper/annotation/MappingProvider.java @@ -0,0 +1,18 @@ +/* + * SPDX-License-Identifier: LGPL-3.0-or-later + * + * Copyright (C) RainbowDashLabs and Contributor + */ + +package de.chojo.sadu.mapper.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.METHOD, ElementType.CONSTRUCTOR}) +public @interface MappingProvider { + String[] value(); +} diff --git a/sadu-mapper/src/main/java/de/chojo/sadu/mapper/exceptions/InvalidMappingException.java b/sadu-mapper/src/main/java/de/chojo/sadu/mapper/exceptions/InvalidMappingException.java new file mode 100644 index 00000000..386d194c --- /dev/null +++ b/sadu-mapper/src/main/java/de/chojo/sadu/mapper/exceptions/InvalidMappingException.java @@ -0,0 +1,18 @@ +/* + * SPDX-License-Identifier: LGPL-3.0-or-later + * + * Copyright (C) RainbowDashLabs and Contributor + */ + +package de.chojo.sadu.mapper.exceptions; + +public class InvalidMappingException extends RuntimeException { + + public InvalidMappingException(String message, Throwable cause) { + super(message, cause); + } + + public InvalidMappingException(String message) { + super(message); + } +} diff --git a/sadu-mapper/src/test/java/de/chojo/sadu/mapper/PostgresDatabase.java b/sadu-mapper/src/test/java/de/chojo/sadu/mapper/PostgresDatabase.java new file mode 100644 index 00000000..06c19043 --- /dev/null +++ b/sadu-mapper/src/test/java/de/chojo/sadu/mapper/PostgresDatabase.java @@ -0,0 +1,23 @@ +/* + * SPDX-License-Identifier: LGPL-3.0-or-later + * + * Copyright (C) RainbowDashLabs and Contributor + */ + +package de.chojo.sadu.mapper; + +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; + +public class PostgresDatabase { + public static GenericContainer createContainer(String user, String pw) { + GenericContainer self = new GenericContainer<>(DockerImageName.parse("postgres:latest")) + .withExposedPorts(5432) + .withEnv("POSTGRES_USER", user) + .withEnv("POSTGRES_PASSWORD", pw) + .waitingFor(Wait.forLogMessage(".*database system is ready to accept connections.*", 2)); + self.start(); + return self; + } +} diff --git a/sadu-mapper/src/test/java/de/chojo/sadu/mapper/annotation/MappingProviderTest.java b/sadu-mapper/src/test/java/de/chojo/sadu/mapper/annotation/MappingProviderTest.java new file mode 100644 index 00000000..da40f58f --- /dev/null +++ b/sadu-mapper/src/test/java/de/chojo/sadu/mapper/annotation/MappingProviderTest.java @@ -0,0 +1,121 @@ +/* + * SPDX-License-Identifier: LGPL-3.0-or-later + * + * Copyright (C) RainbowDashLabs and Contributor + */ + +package de.chojo.sadu.mapper.annotation; + +import de.chojo.sadu.datasource.DataSourceCreator; +import de.chojo.sadu.mapper.RowMapperRegistry; +import de.chojo.sadu.mapper.exceptions.InvalidMappingException; +import de.chojo.sadu.mapper.rowmapper.RowMapping; +import de.chojo.sadu.mapper.wrapper.Row; +import de.chojo.sadu.postgresql.databases.PostgreSql; +import de.chojo.sadu.postgresql.mapper.PostgresqlMapper; +import de.chojo.sadu.queries.api.configuration.QueryConfiguration; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.GenericContainer; + +import javax.sql.DataSource; +import java.io.IOException; +import java.sql.SQLException; + +import static de.chojo.sadu.mapper.PostgresDatabase.createContainer; +import static de.chojo.sadu.queries.api.query.Query.query; + +public class MappingProviderTest { + + + private static GenericContainer pg; + + @BeforeAll + static void beforeAll() throws IOException { + pg = createContainer("postgres", "postgres"); + DataSource dc = DataSourceCreator.create(PostgreSql.get()) + .configure(c -> c.host(pg.getHost()).port(pg.getFirstMappedPort())).create() + .usingPassword("postgres") + .usingUsername("postgres") + .build(); + QueryConfiguration.setDefault(QueryConfiguration.builder(dc) + .setRowMapperRegistry(new RowMapperRegistry().register(PostgresqlMapper.getDefaultMapper())) + .build()); + } + + @Test + public void testMethodMapperRegistration() { + MethodMappedClass methodMappedClass = query("Select 'test' as test") + .single() + .mapAs(MethodMappedClass.class) + .first() + .get(); + Assertions.assertEquals(methodMappedClass.test, "test"); + } + + @Test + public void testConstructorMapperRegistration() { + ConstructorMappedClass methodMappedClass = query("Select 'test' as test") + .single() + .mapAs(ConstructorMappedClass.class) + .first() + .get(); + Assertions.assertEquals(methodMappedClass.test, "test"); + } + + @Test + public void testInvalidRegistration() { + Assertions.assertThrows(InvalidMappingException.class, () -> { + query("Select 'test' as test") + .single() + .mapAs(InvalidClass.class) + .first(); + }); + } + + @AfterAll + static void afterAll() throws IOException { + pg.close(); + } + + public static class MethodMappedClass { + + String test; + + public MethodMappedClass(String test) { + this.test = test; + } + + @MappingProvider({"test"}) + public static RowMapping map() { + return row -> new MethodMappedClass(row.getString(1)); + } + } + + public static class ConstructorMappedClass { + + String test; + + public ConstructorMappedClass(String test) { + this.test = test; + } + + @MappingProvider({"test"}) + public ConstructorMappedClass(Row row) throws SQLException { + this.test = row.getString("test"); + } + + } + + public static class InvalidClass { + + String test; + + @MappingProvider({"test"}) + public InvalidClass(String test) { + this.test = test; + } + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 684ea888..63b88152 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -49,7 +49,7 @@ dependencyResolutionManagement { version("junit", "5.11.0") library("junit-jupiter", "org.junit.jupiter", "junit-jupiter").versionRef("junit") library("junit-params", "org.junit.jupiter", "junit-jupiter-params").versionRef("junit") - bundle("junit", listOf("junit-jupiter", "junit-params")) + bundle("junit", listOf("junit-jupiter", "junit-params", "slf4j-simple")) version("testcontainers", "1.20.1") library("testcontainers-postgres", "org.testcontainers", "postgresql").versionRef("testcontainers") @@ -60,6 +60,7 @@ dependencyResolutionManagement { version("slf4j", "2.0.16") library("slf4j-noop", "org.slf4j", "slf4j-nop").versionRef("slf4j") + library("slf4j-simple", "org.slf4j", "slf4j-simple").versionRef("slf4j") library("driver-postgres", "org.postgresql:postgresql:42.7.3") library("driver-mariadb", "org.mariadb.jdbc:mariadb-java-client:3.4.1")