Summary
Annotation order emitted from customAnnotationsMapping is decided by HashMap's internal bucket layout, not by anything the user controls. The same logical config can produce different generated bytes, and unrelated config edits silently reorder annotations on types they don't match.
This was found while investigating #42 (per-run non-determinism). The per-run claim turned out to be a non-issue on JDK 8+ (String.hashCode() is fixed), but this ordering fragility is real and deterministic — which is worse in some ways, because it breaks byte-for-byte reproducibility across configs without any warning.
Problem
Two symptoms, both from iterating customAnnotationsMapping.entrySet() without sorting:
-
Same key set, different insertion order → different output. When two or more regex patterns hash to the same HashMap bucket, their relative order becomes insertion order. Reordering the entries in build.gradle (e.g., two developers formatting the map differently, or alphabetic-sorting the config) changes the generated annotations.
-
Unrelated entries reorder output. The effective iteration order also depends on total map size: GraphQLCodegen.sanitizeMap() rebuilds the map as new HashMap<>(size), and the initial capacity (tableSizeFor) determines the bucket layout. Adding an annotation mapping for an unrelated type (that never matches the type in question) can flip the annotation order on types it has nothing to do with.
The output is semantically equivalent but byte-for-byte different → spurious diffs in generated code, merge conflicts, unreviewable PR churn.
Reproduction
Environment: 6.1.1-SNAPSHOT, JDK 21. Schema schema.graphqls:
type Query {
products: [ProductTO]
productById(id: ID!): ProductTO
}
type ProductTO {
id: ID!
name: String!
price: Float!
}
Driver (mirrors the issue's own example):
MappingConfig config = new MappingConfig();
config.setCustomAnnotationsMapping(map); // see variants below
config.setModelPackageName("example.generated.model");
config.setGenerateApis(false);
config.setAddGeneratedAnnotationDate(false);
new JavaGraphQLCodegen(List.of("schema.graphqls"), outputDir.toFile(), config,
new GeneratedInformation(config)).generate();
Inspect the class-level annotations of the generated ProductTO.java.
Symptom A — same 5 keys, all 120 insertion orders
Keys (all match ProductTO): .*TO$ → @Auditable, .*oductTO → @Versioned, Prod.* → @Entity, P.* → @P, .*.* → @X.
The three keys Prod.*, P.*, .*.* collide in the same bucket (smeared hash & 15 == 13), so their relative order comes from insertion order. Over all 120 permutations of the 5-key map:
6 distinct outputs (20 permutations each), e.g.:
@Auditable @Versioned @Auditable @X @Entity @P @X
@Auditable @Versioned @Auditable @X @Entity @X @P
@Auditable @Versioned @Auditable @X @P @Entity @X
...
Same semantic config → 6 different generated files.
Symptom B — unrelated entries flip the order
Same 4 patterns (.*TO$, Product.*, Prod.*, .*oductTO), with n filler entries that never match ProductTO:
| Total map entries |
HashMap capacity (via sanitizeMap) |
Annotation order on ProductTO |
| 4–6 |
8 |
@Auditable @Cached @Versioned @Entity |
| 7–12 |
16 |
@Auditable @Versioned @Cached @Entity |
| 13+ |
32 |
@Versioned @Cached @Entity @Auditable |
Adding an annotation mapping for an unrelated type reorders ProductTO's annotations.
Root cause
GraphQLCodegen.java:126 — sanitizeMap rebuilds customAnnotationsMapping as new HashMap<>(size); capacity changes with entry count and re-lays-out buckets.
AnnotationsMapper.java:153 — getTypeAnnotationsForKey iterates customAnnotationsMapping.entrySet() with no sorting; result order = bucket order.
MappingConfig.java:88 — customAnnotationsMapping defaults to HashMap.
Suggested fix
Sort during iteration or use a sorted map (any of the options from #42):
customAnnotationsMapping.entrySet().stream()
.sorted(Map.Entry.comparingByKey())
.forEach(entry -> { ... });
This makes the order deterministic, independent of map size and insertion order.
Summary
Annotation order emitted from
customAnnotationsMappingis decided byHashMap's internal bucket layout, not by anything the user controls. The same logical config can produce different generated bytes, and unrelated config edits silently reorder annotations on types they don't match.This was found while investigating #42 (per-run non-determinism). The per-run claim turned out to be a non-issue on JDK 8+ (
String.hashCode()is fixed), but this ordering fragility is real and deterministic — which is worse in some ways, because it breaks byte-for-byte reproducibility across configs without any warning.Problem
Two symptoms, both from iterating
customAnnotationsMapping.entrySet()without sorting:Same key set, different insertion order → different output. When two or more regex patterns hash to the same
HashMapbucket, their relative order becomes insertion order. Reordering the entries inbuild.gradle(e.g., two developers formatting the map differently, or alphabetic-sorting the config) changes the generated annotations.Unrelated entries reorder output. The effective iteration order also depends on total map size:
GraphQLCodegen.sanitizeMap()rebuilds the map asnew HashMap<>(size), and the initial capacity (tableSizeFor) determines the bucket layout. Adding an annotation mapping for an unrelated type (that never matches the type in question) can flip the annotation order on types it has nothing to do with.The output is semantically equivalent but byte-for-byte different → spurious diffs in generated code, merge conflicts, unreviewable PR churn.
Reproduction
Environment:
6.1.1-SNAPSHOT, JDK 21. Schemaschema.graphqls:Driver (mirrors the issue's own example):
Inspect the class-level annotations of the generated
ProductTO.java.Symptom A — same 5 keys, all 120 insertion orders
Keys (all match
ProductTO):.*TO$→@Auditable,.*oductTO→@Versioned,Prod.*→@Entity,P.*→@P,.*.*→@X.The three keys
Prod.*,P.*,.*.*collide in the same bucket (smeared hash & 15 == 13), so their relative order comes from insertion order. Over all 120 permutations of the 5-key map:Same semantic config → 6 different generated files.
Symptom B — unrelated entries flip the order
Same 4 patterns (
.*TO$,Product.*,Prod.*,.*oductTO), withnfiller entries that never matchProductTO:sanitizeMap)ProductTO@Auditable @Cached @Versioned @Entity@Auditable @Versioned @Cached @Entity@Versioned @Cached @Entity @AuditableAdding an annotation mapping for an unrelated type reorders
ProductTO's annotations.Root cause
GraphQLCodegen.java:126—sanitizeMaprebuildscustomAnnotationsMappingasnew HashMap<>(size); capacity changes with entry count and re-lays-out buckets.AnnotationsMapper.java:153—getTypeAnnotationsForKeyiteratescustomAnnotationsMapping.entrySet()with no sorting;resultorder = bucket order.MappingConfig.java:88—customAnnotationsMappingdefaults toHashMap.Suggested fix
Sort during iteration or use a sorted map (any of the options from #42):
This makes the order deterministic, independent of map size and insertion order.