Skip to content

Annotation order from customAnnotationsMapping depends on HashMap internal layout (not user-controllable) #47

Description

@Besi97

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:

  1. 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.

  2. 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:126sanitizeMap rebuilds customAnnotationsMapping as new HashMap<>(size); capacity changes with entry count and re-lays-out buckets.
  • AnnotationsMapper.java:153getTypeAnnotationsForKey iterates customAnnotationsMapping.entrySet() with no sorting; result order = bucket order.
  • MappingConfig.java:88customAnnotationsMapping 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions