Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>com.github.Fayupable.snowflake-id-java</groupId>
<artifactId>snowflake-jpa</artifactId>
<version>v1.1.1</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
```

### 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:
Expand Down
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

<groupId>com.fayupable</groupId>
<artifactId>snowflake-id-java</artifactId>
<version>1.1.0</version>
<version>1.1.1</version>
<packaging>pom</packaging>

<properties>
Expand Down
4 changes: 2 additions & 2 deletions snowflake-benchmark/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

<groupId>com.fayupable</groupId>
<artifactId>snowflake-benchmark</artifactId>
<version>1.0.0</version>
<version>1.1.1</version>

<properties>
<maven.compiler.source>21</maven.compiler.source>
Expand All @@ -19,7 +19,7 @@
<dependency>
<groupId>com.fayupable</groupId>
<artifactId>snowflake-core</artifactId>
<version>1.0.0</version>
<version>1.1.1</version>
</dependency>
<dependency>
<groupId>org.openjdk.jmh</groupId>
Expand Down
2 changes: 1 addition & 1 deletion snowflake-core/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<parent>
<groupId>com.fayupable</groupId>
<artifactId>snowflake-id-java</artifactId>
<version>1.1.0</version>
<version>1.1.1</version>
</parent>

<artifactId>snowflake-core</artifactId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 20 additions & 1 deletion snowflake-jpa-spring/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<parent>
<groupId>com.fayupable</groupId>
<artifactId>snowflake-id-java</artifactId>
<version>1.1.0</version>
<version>1.1.1</version>
</parent>

<artifactId>snowflake-jpa-spring</artifactId>
Expand Down Expand Up @@ -54,4 +54,23 @@
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>com.fayupable</groupId>
<artifactId>snowflake-jpa</artifactId>
<version>${project.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>

</project>
10 changes: 9 additions & 1 deletion snowflake-jpa/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<parent>
<groupId>com.fayupable</groupId>
<artifactId>snowflake-id-java</artifactId>
<version>1.1.0</version>
<version>1.1.1</version>
</parent>

<artifactId>snowflake-jpa</artifactId>
Expand Down Expand Up @@ -43,6 +43,14 @@
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration>
<proc>none</proc>
</configuration>
</plugin>
</plugins>
</build>

Expand Down
Original file line number Diff line number Diff line change
@@ -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<? extends TypeElement> 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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
com.fayupable.snowflake.jpa.processor.SnowflakeGeneratedIdProcessor
Original file line number Diff line number Diff line change
@@ -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<JavaFileObject> 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<JavaFileObject> 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<JavaFileObject> compile(String className, String source) {
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
DiagnosticCollector<JavaFileObject> 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<String> 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;
}
}
Loading