diff --git a/CHANGELOG.md b/CHANGELOG.md index a9892bb..2da4d54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- `SnowflakeId.fromLong()` used a signed right shift (`>>`) instead of an unsigned one (`>>>`), inconsistent with + the internal decoding logic in `Moment`. Ordinary generated ids were never affected, since their sign bit is + always 0, but a caller passing a negative `long` into this public method would get corrupted `nodeId` and + `timestamp` values. Fixed to use `>>>` consistently. + +### Added +- Compile-time type validation for `@SnowflakeGeneratedId` in `snowflake-jpa`: an annotation processor now fails + the build if the annotation is applied to a field or getter whose type is not `long` or `Long`, instead of only + failing at runtime the first time Hibernate tries to persist the entity. Consumers using Maven must add + `snowflake-jpa` to their `maven-compiler-plugin`'s `annotationProcessorPaths` for the check to run reliably, + since automatic classpath discovery of annotation processors is not guaranteed across all Maven Compiler Plugin + versions; see the README for the exact configuration. + +## [1.1.0] - 2026-07-24 + ### Added - `SnowflakeMetrics` output port in `snowflake-core`, with a `NoOpSnowflakeMetrics` default so the generator never depends on a metrics backend directly. diff --git a/README.md b/README.md index 4536d26..ef4c72e 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,28 @@ private Long id; A plain `@GeneratedValue(strategy = GenerationType.AUTO)` on its own does not produce Snowflake IDs. `GenerationType` is a fixed JPA enum that has no Snowflake option, so the generator must always be referenced explicitly, either through `@SnowflakeGeneratedId` or through the `generator` name plus `@GenericGenerator` pairing shown above. +### Compile-time type checking + +`@SnowflakeGeneratedId` can only be applied to a field or getter of type `long` or `Long`. Applying it to any other type (`String`, `Integer`, `UUID`, and so on) fails the build at compile time instead of failing at runtime the first time Hibernate tries to persist the entity. + +This check is implemented as an annotation processor bundled in `snowflake-jpa`. Automatic discovery of annotation processors from the compile classpath is not guaranteed across all Maven Compiler Plugin versions, so for the check to run reliably, add `snowflake-jpa` to `annotationProcessorPaths` explicitly: + +```xml + + org.apache.maven.plugins + maven-compiler-plugin + + + + com.github.Fayupable.snowflake-id-java + snowflake-jpa + v1.1.1 + + + + +``` + ### Hibernate without Spring Depend on `snowflake-jpa` directly. The `@SnowflakeGeneratedId` annotation works the same way, but since there is no Spring context to wire the generator automatically, register it once at application startup, before the first entity is persisted: diff --git a/pom.xml b/pom.xml index 85c2375..0b2cfb8 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.fayupable snowflake-id-java - 1.1.0 + 1.1.1 pom diff --git a/snowflake-benchmark/pom.xml b/snowflake-benchmark/pom.xml index 2d71710..81d39ea 100644 --- a/snowflake-benchmark/pom.xml +++ b/snowflake-benchmark/pom.xml @@ -6,7 +6,7 @@ com.fayupable snowflake-benchmark - 1.0.0 + 1.1.1 21 @@ -19,7 +19,7 @@ com.fayupable snowflake-core - 1.0.0 + 1.1.1 org.openjdk.jmh diff --git a/snowflake-core/pom.xml b/snowflake-core/pom.xml index 0fbf9cb..a14c713 100644 --- a/snowflake-core/pom.xml +++ b/snowflake-core/pom.xml @@ -7,7 +7,7 @@ com.fayupable snowflake-id-java - 1.1.0 + 1.1.1 snowflake-core diff --git a/snowflake-core/src/main/java/com/fayupable/snowflake/SnowflakeId.java b/snowflake-core/src/main/java/com/fayupable/snowflake/SnowflakeId.java index c88a25d..5fa44a1 100644 --- a/snowflake-core/src/main/java/com/fayupable/snowflake/SnowflakeId.java +++ b/snowflake-core/src/main/java/com/fayupable/snowflake/SnowflakeId.java @@ -10,8 +10,8 @@ public record SnowflakeId(long value, long timestamp, long nodeId, long sequence public static SnowflakeId fromLong(long id, SnowflakeConfig config) { long sequence = id & config.maxSequence(); - long nodeId = (id >> config.nodeShift()) & ((1L << config.nodeBits()) - 1); - long timestamp = (id >> config.timestampShift()) + config.epoch(); + long nodeId = (id >>> config.nodeShift()) & ((1L << config.nodeBits()) - 1); + long timestamp = (id >>> config.timestampShift()) + config.epoch(); return new SnowflakeId(id, timestamp, nodeId, sequence); } } \ No newline at end of file diff --git a/snowflake-core/src/test/java/com/fayupable/snowflake/SnowflakeIdGeneratorTest.java b/snowflake-core/src/test/java/com/fayupable/snowflake/SnowflakeIdGeneratorTest.java index d027435..6439371 100644 --- a/snowflake-core/src/test/java/com/fayupable/snowflake/SnowflakeIdGeneratorTest.java +++ b/snowflake-core/src/test/java/com/fayupable/snowflake/SnowflakeIdGeneratorTest.java @@ -166,6 +166,16 @@ void decomposesGeneratedId() { assertEquals(NODE_ID, parsed.nodeId()); assertEquals(0L, parsed.sequence()); } + + @Test + @DisplayName("uses unsigned shifts so a crafted negative id does not corrupt decoded fields") + void decomposesNegativeIdWithUnsignedShift() { + SnowflakeConfig config = SnowflakeConfig.defaultConfig(EPOCH, NODE_ID); + + SnowflakeId parsed = SnowflakeId.fromLong(Long.MIN_VALUE, config); + + assertTrue(parsed.timestamp() > EPOCH); + } } @Nested diff --git a/snowflake-jpa-spring/pom.xml b/snowflake-jpa-spring/pom.xml index 2d3e6d0..f40c32d 100644 --- a/snowflake-jpa-spring/pom.xml +++ b/snowflake-jpa-spring/pom.xml @@ -7,7 +7,7 @@ com.fayupable snowflake-id-java - 1.1.0 + 1.1.1 snowflake-jpa-spring @@ -54,4 +54,23 @@ + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + + + com.fayupable + snowflake-jpa + ${project.version} + + + + + + + diff --git a/snowflake-jpa/pom.xml b/snowflake-jpa/pom.xml index 53f0893..cdc8a39 100644 --- a/snowflake-jpa/pom.xml +++ b/snowflake-jpa/pom.xml @@ -7,7 +7,7 @@ com.fayupable snowflake-id-java - 1.1.0 + 1.1.1 snowflake-jpa @@ -43,6 +43,14 @@ maven-surefire-plugin 3.2.5 + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + none + + diff --git a/snowflake-jpa/src/main/java/com/fayupable/snowflake/jpa/processor/SnowflakeGeneratedIdProcessor.java b/snowflake-jpa/src/main/java/com/fayupable/snowflake/jpa/processor/SnowflakeGeneratedIdProcessor.java new file mode 100644 index 0000000..4822396 --- /dev/null +++ b/snowflake-jpa/src/main/java/com/fayupable/snowflake/jpa/processor/SnowflakeGeneratedIdProcessor.java @@ -0,0 +1,62 @@ +package com.fayupable.snowflake.jpa.processor; + +import com.fayupable.snowflake.jpa.SnowflakeGeneratedId; + +import javax.annotation.processing.AbstractProcessor; +import javax.annotation.processing.RoundEnvironment; +import javax.annotation.processing.SupportedAnnotationTypes; +import javax.annotation.processing.SupportedSourceVersion; +import javax.lang.model.SourceVersion; +import javax.lang.model.element.Element; +import javax.lang.model.element.ElementKind; +import javax.lang.model.element.ExecutableElement; +import javax.lang.model.element.TypeElement; +import javax.lang.model.type.DeclaredType; +import javax.lang.model.type.TypeKind; +import javax.lang.model.type.TypeMirror; +import javax.tools.Diagnostic; +import java.util.Set; + +/** + * Fails compilation when {@link SnowflakeGeneratedId} is applied to a field or + * getter whose type is not {@code long} or {@code Long}. Without this check, + * a mismatched type only fails at runtime, the first time Hibernate tries to + * assign the generated value to the entity. + */ +@SupportedAnnotationTypes("com.fayupable.snowflake.jpa.SnowflakeGeneratedId") +@SupportedSourceVersion(SourceVersion.RELEASE_21) +public class SnowflakeGeneratedIdProcessor extends AbstractProcessor { + + @Override + public boolean process(Set annotations, RoundEnvironment roundEnv) { + for (Element element : roundEnv.getElementsAnnotatedWith(SnowflakeGeneratedId.class)) { + TypeMirror annotatedType = resolveAnnotatedType(element); + if (!isLongType(annotatedType)) { + processingEnv.getMessager().printMessage( + Diagnostic.Kind.ERROR, + "@SnowflakeGeneratedId can only be applied to a field or getter of type " + + "long or Long, but found type " + annotatedType, + element); + } + } + return true; + } + + private TypeMirror resolveAnnotatedType(Element element) { + if (element.getKind() == ElementKind.METHOD) { + return ((ExecutableElement) element).getReturnType(); + } + return element.asType(); + } + + private boolean isLongType(TypeMirror type) { + if (type.getKind() == TypeKind.LONG) { + return true; + } + if (type.getKind() == TypeKind.DECLARED) { + String qualifiedName = ((DeclaredType) type).asElement().toString(); + return "java.lang.Long".equals(qualifiedName); + } + return false; + } +} diff --git a/snowflake-jpa/src/main/resources/META-INF/services/javax.annotation.processing.Processor b/snowflake-jpa/src/main/resources/META-INF/services/javax.annotation.processing.Processor new file mode 100644 index 0000000..7edc8ba --- /dev/null +++ b/snowflake-jpa/src/main/resources/META-INF/services/javax.annotation.processing.Processor @@ -0,0 +1 @@ +com.fayupable.snowflake.jpa.processor.SnowflakeGeneratedIdProcessor diff --git a/snowflake-jpa/src/test/java/com/fayupable/snowflake/jpa/processor/SnowflakeGeneratedIdProcessorTest.java b/snowflake-jpa/src/test/java/com/fayupable/snowflake/jpa/processor/SnowflakeGeneratedIdProcessorTest.java new file mode 100644 index 0000000..fc919f0 --- /dev/null +++ b/snowflake-jpa/src/test/java/com/fayupable/snowflake/jpa/processor/SnowflakeGeneratedIdProcessorTest.java @@ -0,0 +1,110 @@ +package com.fayupable.snowflake.jpa.processor; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import javax.tools.Diagnostic; +import javax.tools.DiagnosticCollector; +import javax.tools.JavaCompiler; +import javax.tools.JavaFileObject; +import javax.tools.SimpleJavaFileObject; +import javax.tools.StandardJavaFileManager; +import javax.tools.StandardLocation; +import javax.tools.ToolProvider; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Compiles small in-memory sources with the real javac compiler and this + * processor attached, to prove the compile-time type check actually fires + * rather than trusting the processor logic by inspection alone. + */ +class SnowflakeGeneratedIdProcessorTest { + + @Test + @DisplayName("fails compilation when applied to a non-Long field") + void rejectsNonLongField() { + String source = """ + package com.example.generated; + + import com.fayupable.snowflake.jpa.SnowflakeGeneratedId; + + class BadEntity { + @SnowflakeGeneratedId + private String id; + } + """; + + DiagnosticCollector diagnostics = compile("BadEntity", source); + + boolean hasExpectedError = diagnostics.getDiagnostics().stream() + .anyMatch(d -> d.getKind() == Diagnostic.Kind.ERROR + && d.getMessage(null).contains("@SnowflakeGeneratedId")); + + assertTrue(hasExpectedError, "expected a compile error mentioning @SnowflakeGeneratedId"); + } + + @Test + @DisplayName("compiles cleanly when applied to a Long field") + void acceptsLongField() { + String source = """ + package com.example.generated; + + import com.fayupable.snowflake.jpa.SnowflakeGeneratedId; + + class GoodEntity { + @SnowflakeGeneratedId + private Long id; + } + """; + + DiagnosticCollector diagnostics = compile("GoodEntity", source); + + boolean hasError = diagnostics.getDiagnostics().stream() + .anyMatch(d -> d.getKind() == Diagnostic.Kind.ERROR); + + assertFalse(hasError, "did not expect any compile errors for a Long field"); + } + + private DiagnosticCollector compile(String className, String source) { + JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); + DiagnosticCollector diagnostics = new DiagnosticCollector<>(); + StandardJavaFileManager fileManager = + compiler.getStandardFileManager(diagnostics, null, null); + + try { + Path classOutputDir = Files.createTempDirectory("snowflake-processor-test"); + classOutputDir.toFile().deleteOnExit(); + fileManager.setLocation(StandardLocation.CLASS_OUTPUT, List.of(classOutputDir.toFile())); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + + JavaFileObject sourceObject = new SimpleJavaFileObject( + URI.create("string:///com/example/generated/" + className + ".java"), + JavaFileObject.Kind.SOURCE) { + @Override + public CharSequence getCharContent(boolean ignoreEncodingErrors) { + return source; + } + }; + + List options = Arrays.asList( + "-classpath", System.getProperty("java.class.path")); + + JavaCompiler.CompilationTask task = compiler.getTask( + null, fileManager, diagnostics, options, null, List.of(sourceObject)); + task.setProcessors(List.of(new SnowflakeGeneratedIdProcessor())); + task.call(); + + return diagnostics; + } +}