From 919522ea9e34e634e36d8dae442c43aef193b440 Mon Sep 17 00:00:00 2001 From: Jan Loufek Date: Fri, 21 Nov 2025 12:18:22 +0100 Subject: [PATCH] add typed-ids-spring-convert module with converter factories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a new module providing Spring Framework converter factories for typed ID classes: - NumberToObjectBigIntIdConverterFactory: converts Number to ObjectBigIntId subtypes - StringToObjectUuidConverterFactory: converts String to ObjectUuid subtypes Both converters use MethodHandle-based reflection to invoke private constructors and integrate seamlessly with Spring's ConversionService. Includes comprehensive unit tests covering successful conversions, null handling, and error cases. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../typed-ids-spring-convert/build.gradle.kts | 19 ++++ ...umberToObjectBigIntIdConverterFactory.java | 70 ++++++++++++ .../StringToObjectUuidConverterFactory.java | 72 ++++++++++++ ...rToObjectBigIntIdConverterFactoryTest.java | 90 +++++++++++++++ ...tringToObjectUuidConverterFactoryTest.java | 105 ++++++++++++++++++ 5 files changed, 356 insertions(+) create mode 100644 modules/typed-ids-spring-convert/build.gradle.kts create mode 100644 modules/typed-ids-spring-convert/src/main/java/org/framefork/typedIds/spring/convert/NumberToObjectBigIntIdConverterFactory.java create mode 100644 modules/typed-ids-spring-convert/src/main/java/org/framefork/typedIds/spring/convert/StringToObjectUuidConverterFactory.java create mode 100644 modules/typed-ids-spring-convert/src/test/java/org/framefork/typedIds/spring/convert/NumberToObjectBigIntIdConverterFactoryTest.java create mode 100644 modules/typed-ids-spring-convert/src/test/java/org/framefork/typedIds/spring/convert/StringToObjectUuidConverterFactoryTest.java diff --git a/modules/typed-ids-spring-convert/build.gradle.kts b/modules/typed-ids-spring-convert/build.gradle.kts new file mode 100644 index 0000000..447a7bb --- /dev/null +++ b/modules/typed-ids-spring-convert/build.gradle.kts @@ -0,0 +1,19 @@ +plugins { + id("framefork.java-public") +} + +dependencies { + api(project(":typed-ids")) + + compileOnly("org.springframework:spring-core:6.0.0") + compileOnly(libs.jetbrains.annotations) + + compileOnly(libs.autoService.annotations) + annotationProcessor(libs.autoService.processor) + + testImplementation(project(":typed-ids-testing")) + testImplementation("org.springframework:spring-core:6.0.0") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +project.description = "TypeIds Spring Framework converters" \ No newline at end of file diff --git a/modules/typed-ids-spring-convert/src/main/java/org/framefork/typedIds/spring/convert/NumberToObjectBigIntIdConverterFactory.java b/modules/typed-ids-spring-convert/src/main/java/org/framefork/typedIds/spring/convert/NumberToObjectBigIntIdConverterFactory.java new file mode 100644 index 0000000..3db0cd5 --- /dev/null +++ b/modules/typed-ids-spring-convert/src/main/java/org/framefork/typedIds/spring/convert/NumberToObjectBigIntIdConverterFactory.java @@ -0,0 +1,70 @@ +package org.framefork.typedIds.spring.convert; + +import org.framefork.typedIds.bigint.ObjectBigIntId; +import org.framefork.typedIds.bigint.ObjectBigIntIdTypeUtils; +import org.framefork.typedIds.common.ReflectionHacks; +import org.jetbrains.annotations.NotNull; +import org.jspecify.annotations.Nullable; +import org.springframework.core.convert.converter.Converter; +import org.springframework.core.convert.converter.ConverterFactory; + +import java.lang.invoke.MethodHandle; + +/** + * Generic converter factory that converts {@link Number} to any {@link ObjectBigIntId} subtype. + * + *

This factory eliminates the need for individual converter classes for each Id type. + * It works by using {@link MethodHandle} to invoke the private constructor that all + * {@link ObjectBigIntId} subtypes have. + * + *

To use this converter, register it with Spring's conversion service: + *

{@code
+ * @Configuration
+ * public class ConversionServiceConfiguration {
+ *     @Bean
+ *     public ConversionService conversionService() {
+ *         DefaultConversionService service = new DefaultConversionService();
+ *         service.addConverterFactory(new NumberToObjectBigIntIdConverterFactory());
+ *         return service;
+ *     }
+ * }
+ * }
+ */ +public class NumberToObjectBigIntIdConverterFactory implements ConverterFactory> { + + @Override + public > @NotNull Converter getConverter(final @NotNull Class targetType) { + return new NumberToObjectBigIntIdConverter<>(targetType); + } + + private static final class NumberToObjectBigIntIdConverter> + implements Converter { + + private final Class targetType; + + private NumberToObjectBigIntIdConverter(final Class targetType) { + this.targetType = targetType; + } + + @Override + public @Nullable T convert(final @Nullable Number source) { + if (source == null) { + return null; + } + + try { + final var constructor = ReflectionHacks.getConstructor(targetType, long.class); + @SuppressWarnings("unchecked") + var result = (T) ObjectBigIntIdTypeUtils.wrapBigIntToIdentifier(source.longValue(), constructor); + return result; + } catch (final IllegalArgumentException e) { + throw new IllegalArgumentException( + "Cannot convert Number to " + targetType.getName() + + ". Ensure it extends ObjectBigIntId and has a private constructor taking a long parameter.", + e + ); + } + } + } + +} \ No newline at end of file diff --git a/modules/typed-ids-spring-convert/src/main/java/org/framefork/typedIds/spring/convert/StringToObjectUuidConverterFactory.java b/modules/typed-ids-spring-convert/src/main/java/org/framefork/typedIds/spring/convert/StringToObjectUuidConverterFactory.java new file mode 100644 index 0000000..1077a09 --- /dev/null +++ b/modules/typed-ids-spring-convert/src/main/java/org/framefork/typedIds/spring/convert/StringToObjectUuidConverterFactory.java @@ -0,0 +1,72 @@ +package org.framefork.typedIds.spring.convert; + +import org.framefork.typedIds.common.ReflectionHacks; +import org.framefork.typedIds.uuid.ObjectUuid; +import org.framefork.typedIds.uuid.ObjectUuidTypeUtils; +import org.jetbrains.annotations.NotNull; +import org.jspecify.annotations.Nullable; +import org.springframework.core.convert.converter.Converter; +import org.springframework.core.convert.converter.ConverterFactory; + +import java.lang.invoke.MethodHandle; +import java.util.UUID; + +/** + * Generic converter factory that converts {@link String} to any {@link ObjectUuid} subtype. + * + *

This factory eliminates the need for individual converter classes for each Id type. + * It works by using {@link MethodHandle} to invoke the private constructor that all + * {@link ObjectUuid} subtypes have. + * + *

To use this converter, register it with Spring's conversion service: + *

{@code
+ * @Configuration
+ * public class ConversionServiceConfiguration {
+ *     @Bean
+ *     public ConversionService conversionService() {
+ *         DefaultConversionService service = new DefaultConversionService();
+ *         service.addConverterFactory(new StringToObjectUuidConverterFactory());
+ *         return service;
+ *     }
+ * }
+ * }
+ */ +public class StringToObjectUuidConverterFactory implements ConverterFactory> { + + @Override + public > @NotNull Converter getConverter(final @NotNull Class targetType) { + return new StringToObjectUuidConverter<>(targetType); + } + + private static final class StringToObjectUuidConverter> + implements Converter { + + private final Class targetType; + + private StringToObjectUuidConverter(final Class targetType) { + this.targetType = targetType; + } + + @Override + public @Nullable T convert(final @Nullable String source) { + if (source == null || source.isEmpty()) { + return null; + } + + try { + final UUID uuid = UUID.fromString(source); + final var constructor = ReflectionHacks.getConstructor(targetType, UUID.class); + @SuppressWarnings("unchecked") + var result = (T) ObjectUuidTypeUtils.wrapUuidToIdentifier(uuid, constructor); + return result; + } catch (final IllegalArgumentException e) { + throw new IllegalArgumentException( + "Cannot convert String to " + targetType.getName() + + ". Ensure it extends ObjectUuid and has a private constructor taking a UUID parameter.", + e + ); + } + } + } + +} \ No newline at end of file diff --git a/modules/typed-ids-spring-convert/src/test/java/org/framefork/typedIds/spring/convert/NumberToObjectBigIntIdConverterFactoryTest.java b/modules/typed-ids-spring-convert/src/test/java/org/framefork/typedIds/spring/convert/NumberToObjectBigIntIdConverterFactoryTest.java new file mode 100644 index 0000000..7e6d0cf --- /dev/null +++ b/modules/typed-ids-spring-convert/src/test/java/org/framefork/typedIds/spring/convert/NumberToObjectBigIntIdConverterFactoryTest.java @@ -0,0 +1,90 @@ +package org.framefork.typedIds.spring.convert; + +import org.framefork.typedIds.bigint.ObjectBigIntId; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.core.convert.converter.Converter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class NumberToObjectBigIntIdConverterFactoryTest { + + private NumberToObjectBigIntIdConverterFactory factory; + + @BeforeEach + void setUp() { + factory = new NumberToObjectBigIntIdConverterFactory(); + } + + @Test + void shouldConvertLongToObjectBigIntId() { + // Given + Converter converter = factory.getConverter(TestBigIntId.class); + long value = 12345L; + + // When + TestBigIntId result = converter.convert(value); + + // Then + assertThat(result).isNotNull(); + assertThat(result.toLong()).isEqualTo(value); + } + + @Test + void shouldConvertIntegerToObjectBigIntId() { + // Given + Converter converter = factory.getConverter(TestBigIntId.class); + int value = 42; + + // When + TestBigIntId result = converter.convert(value); + + // Then + assertThat(result).isNotNull(); + assertThat(result.toLong()).isEqualTo(value); + } + + @Test + void shouldReturnNullForNullInput() { + // Given + Converter converter = factory.getConverter(TestBigIntId.class); + + // When + TestBigIntId result = converter.convert(null); + + // Then + assertThat(result).isNull(); + } + + @Test + void shouldThrowExceptionForInvalidType() { + // Given + Converter converter = factory.getConverter(InvalidBigIntId.class); + + // When / Then + assertThatThrownBy(() -> converter.convert(123L)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Cannot convert Number to"); + } + + // Test ID class + public static final class TestBigIntId extends ObjectBigIntId { + private TestBigIntId(long inner) { + super(inner); + } + + public static TestBigIntId from(long value) { + return ObjectBigIntId.fromLong(TestBigIntId::new, value); + } + } + + // Invalid test class (no proper constructor) + public static final class InvalidBigIntId extends ObjectBigIntId { + // This class intentionally has no long constructor to test error handling + @SuppressWarnings({"UnusedMethod", "UnusedVariable"}) + private InvalidBigIntId(String invalid) { + super(0); + } + } +} \ No newline at end of file diff --git a/modules/typed-ids-spring-convert/src/test/java/org/framefork/typedIds/spring/convert/StringToObjectUuidConverterFactoryTest.java b/modules/typed-ids-spring-convert/src/test/java/org/framefork/typedIds/spring/convert/StringToObjectUuidConverterFactoryTest.java new file mode 100644 index 0000000..78fc535 --- /dev/null +++ b/modules/typed-ids-spring-convert/src/test/java/org/framefork/typedIds/spring/convert/StringToObjectUuidConverterFactoryTest.java @@ -0,0 +1,105 @@ +package org.framefork.typedIds.spring.convert; + +import org.framefork.typedIds.uuid.ObjectUuid; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.core.convert.converter.Converter; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class StringToObjectUuidConverterFactoryTest { + + private StringToObjectUuidConverterFactory factory; + + @BeforeEach + void setUp() { + factory = new StringToObjectUuidConverterFactory(); + } + + @Test + void shouldConvertStringToObjectUuid() { + // Given + Converter converter = factory.getConverter(TestUuid.class); + UUID uuid = UUID.fromString("550e8400-e29b-41d4-a716-446655440000"); + String uuidString = uuid.toString(); + + // When + TestUuid result = converter.convert(uuidString); + + // Then + assertThat(result).isNotNull(); + assertThat(result.toNativeUuid()).isEqualTo(uuid); + } + + @Test + void shouldReturnNullForNullInput() { + // Given + Converter converter = factory.getConverter(TestUuid.class); + + // When + TestUuid result = converter.convert(null); + + // Then + assertThat(result).isNull(); + } + + @Test + void shouldReturnNullForEmptyString() { + // Given + Converter converter = factory.getConverter(TestUuid.class); + + // When + TestUuid result = converter.convert(""); + + // Then + assertThat(result).isNull(); + } + + @Test + void shouldThrowExceptionForInvalidUuidString() { + // Given + Converter converter = factory.getConverter(TestUuid.class); + + // When / Then + assertThatThrownBy(() -> converter.convert("not-a-uuid")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void shouldThrowExceptionForInvalidType() { + // Given + Converter converter = factory.getConverter(InvalidUuid.class); + + // When / Then + assertThatThrownBy(() -> converter.convert("550e8400-e29b-41d4-a716-446655440000")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Cannot convert String to"); + } + + // Test UUID class - using UUID v4 for testing + public static final class TestUuid extends ObjectUuid { + private TestUuid(UUID inner) { + super(inner); + } + + public static TestUuid from(UUID value) { + return ObjectUuid.fromUuid(TestUuid::new, value); + } + + public static TestUuid from(String value) { + return ObjectUuid.fromString(TestUuid::new, value); + } + } + + // Invalid test class (no proper constructor) + public static final class InvalidUuid extends ObjectUuid { + // This class intentionally has no UUID constructor to test error handling + @SuppressWarnings({"UnusedMethod", "UnusedVariable"}) + private InvalidUuid(String invalid) { + super(UUID.fromString("550e8400-e29b-41d4-a716-446655440000")); + } + } +} \ No newline at end of file