Skip to content

AVRO-4354: [java] Fix generated equals() and hashCode() semantics - #4045

Open
sharanggupta wants to merge 2 commits into
apache:mainfrom
sharanggupta:AVRO-4354-generated-equals-hashcode
Open

sharanggupta wants to merge 2 commits into
apache:mainfrom
sharanggupta:AVRO-4354-generated-equals-hashcode

Conversation

@sharanggupta

@sharanggupta sharanggupta commented Oct 14, 2026 •

Copy link
Copy Markdown

What is the purpose of the change

Fixes AVRO-4354: since 1.12.1 (AVRO-3527) the generated equals() compares array, map and union fields with java.util.Objects.equals(), and the generated hashCode() hashes CharSequence fields with their own hashCode(). A decoded record holds GenericData.Array and Utf8 values while a built record holds java.util.List and String values, so:

  • equals() throws AvroRuntimeException: Unknown datum type java.util.UUID (or LocalDate, Instant, ...) for a decoded array whose elements contain a union with a logical type, because GenericData.Array.equals() compares through the global GenericData, which has no logical type conversions. It is also asymmetric: List.equals(Array) compares element by element while Array.equals(List) goes through the model, so for string elements one direction sees String vs Utf8 and returns false (AVRO-4198).
  • hashCode() of a record holding a String differs from the same record after a round trip, which holds a Utf8, although equals() treats them as equal, so HashSet/HashMap lookups fail.

The fix keeps the fast paths of AVRO-3527 for primitive, CharSequence, record, enum, fixed, bytes and converted logical-type fields, and changes the generated code where the runtime type of the value is not under the control of the generated class:

  • array, map and non-nullable union fields are compared with MODEL$.compare(this.x, other.x, fieldSchema, true) and hashed with MODEL$.hashCode(this.x, fieldSchema): the SpecificData of the generated class knows the logical type conversions and applies the Avro equality rules (String/Utf8, List/GenericData.Array, map keys) symmetrically, as SpecificRecordBase.equals()/hashCode() did before 1.12.1 for the whole record. A null field (possible through the constructors and setters, not the builder) is equal only to null and is checked before delegating, so equals() never throws for it;
  • CharSequence fields are hashed with the new Utf8.hashCodeFor(CharSequence), which computes the hash code of the UTF-8 encoding of the sequence without encoding it, so that it matches Utf8.hashCode() for the same content and Utf8.compareSequences(), used by the generated equals(). The result is the hash that SpecificData.hashCode() computes for a string (used by GenericData.HashCodeCalculator now too, which previously allocated a Utf8 per String).
  • GenericData.compare(Object, Object, Schema, boolean) (and the SpecificData/ReflectData overrides) is now public so that generated code can call it; the three-argument compare() was already public.
  • GenericData.compare() compares the values of a map with the value schema, through a new compareMaps(m1, m2, valueSchema), instead of Object.equals(), so that map values that are arrays or maps get the same conversions and String/Utf8 equality as array elements. Record values are therefore compared field-wise by the model, as record elements of arrays already were. The two-argument compareMaps() keeps its behaviour.
  • GenericData.hashCode(Object, Schema) gains a MAP case: it used to fall back to Map.hashCode(), whose result depends on whether the keys and values are Strings or Utf8s, while compareMaps() treats them as equal. Map entries are now hashed order-independently with the UTF-8 hash of the key and the schema hash of the value.

Behaviour and compatibility changes

  • Hash codes. Compared with 1.12.1/1.12.2 (Utf8.hashCode() itself changed in 1.12.1, AVRO-4334): records generated by this compiler that hold Strings get the hash of the decoded record, which they must to honour equals(). The MAP case of GenericData.hashCode() also changes the hash of GenericRecords and of classes generated by older compilers that inherit SpecificRecordBase.hashCode(). Examples: a map<string> holding Strings, a map<enum> holding Java enums (identity hash before, ordinal-based now), and a map whose values are arrays longer than nine elements. Utf8-keyed maps with Utf8, numeric or record values keep their hash. GenericData.HashCodeCalculator only visits the first nine fields or elements, so the generated hashCode() equals SpecificData.hashCode() only for records and arrays within that bound, as on main.
  • New public API. The four-argument compare() of GenericData, SpecificData and ReflectData becomes public. That is binary compatible, but not source compatible for a subclass that overrides it as protected. Also new: Utf8.hashCodeFor(CharSequence), GenericData.compareMaps(Map, Map, Schema), SpecificCompiler#requiresModelComparison and SpecificCompiler#hashCodeFor(Field, String). An alternative that widens nothing is discussed in the review: protected helpers on SpecificRecordBase/SpecificExceptionBase called by the template.
  • Runtime coupling. As with Utf8.compareSequences() in 1.12.1, code generated by this compiler needs this runtime: on an older runtime its hashCode() fails with NoSuchMethodError (Utf8.hashCodeFor) and its equals() with IllegalAccessError (protected compare). Code generated by older compilers runs on this runtime. This is worth a release note, and a reason to be careful with a backport to branch-1.12.
  • Cost. The generated hashCode() of a CharSequence field holding a String now scans the string on each call (about 250-300 ns for 200 characters in the measurements from the review, instead of the cached String.hashCode()), while records decoded from Avro hold Utf8s, whose hash is cached.
  • Decimals. For arrays, maps and unions of decimal, equality goes through compare() (BigDecimal.compareTo, scale-insensitive) while the hash uses BigDecimal.hashCode() (scale-sensitive), as in 1.12.0. This only affects values with a scale different from the schema's, which can't be encoded.

Not addressed here, as they look like separate decisions, and worth tracking so AVRO-4354 isn't considered complete without them:

  • GenericData.Array.equals() called directly with logical type elements, for example order.getDates().equals(expected) (AVRO-4036).
  • A compiler/plugin option to disable the generation of equals()/hashCode() (item 4 of the ticket).
  • A backport to branch-1.12, see the runtime coupling above.

This also fixes AVRO-4198 for generated records.

Verifying this change

This change added tests and can be verified as follows:

  • TestGeneratedEqualsAndHashCode in the compiler module, on a new test schema generated_equals.avsc compiled at build time like the other SchemaTask schemas: array of records with a nullable uuid, array of nullable date, array of strings, nullable array of strings, map of strings, map of arrays of a nullable uuid, map of arrays of strings, map of maps of strings, a non-nullable union of string and int, null array/map fields against empty ones, CharSequence fields with a HashSet lookup, and generated hashCode() equal to SpecificData.hashCode() for a small record. The map tests and the null test were added after review: they fail on the first commit, with the same Unknown datum type exception, the two asymmetries and a NullPointerException. On main, arrayOfRecordsWithNullableLogicalType and arrayOfNullableLogicalType error with AvroRuntimeException: Unknown datum type java.util.UUID / java.time.LocalDate, arrayOfStrings, nullableArrayOfStrings and mapOfStrings fail on after.equals(before), and the two hash tests fail with different hash codes (4066 vs 5058 for EqualsNamed("a", "b")).
  • TestUtf8.hashCodeForMatchesUtf8HashCode and ...ForRandomStrings: Utf8.hashCodeFor() equals new Utf8(s).hashCode() for ASCII, 2-, 3- and 4-byte characters, unpaired surrogates and 1000 random strings.
  • TestGenericData.testMapHashCodeIsConsistentWithEquals: a String-keyed and a Utf8-keyed map with the same content compare equal and hash the same, and the Utf8 map keeps its previous hash code.
  • The expected generated classes of TestSpecificCompilerTool in tools/src/test/compiler/output* are regenerated. Only the equals()/hashCode() lines change.
  • Locally (JDK 21, build cache disabled): avro, compiler, tools, ipc, idl, maven-plugin and integration-test/codegen-test pass with install, and spotless:check is clean.

Documentation

  • Does this pull request introduce a new feature? no
  • If yes, how is the feature documented? not applicable (Utf8.hashCodeFor() and the template helpers have JavaDoc)

Since AVRO-3527 the generated equals() compares array, map and union
fields with java.util.Objects.equals(), and the generated hashCode()
hashes CharSequence fields with their own hashCode(). Decoded records
hold GenericData.Array and Utf8 values while built records hold
java.util.List and String values, so:

- equals() throws "Unknown datum type" for a decoded array whose
  elements contain a union with a logical type, because
  GenericData.Array.equals() uses the global GenericData, which has no
  logical type conversions, and List.equals(Array) is not symmetric
  with Array.equals(List) for string elements;
- hashCode() differs for a String and a Utf8 with the same content,
  although equals() treats them as equal.

Array, map and non-nullable union fields are now compared and hashed
through the SpecificData model of the generated class, which knows the
conversions and the Avro equality rules, and CharSequence fields are
hashed with the new Utf8.hashCodeFor(), the hash code of the UTF-8
encoding of the sequence, so that the generated hashCode() agrees with
equals() and with SpecificData.hashCode(). The four-argument
GenericData.compare() is public, so that generated code can call it.
@github-actions github-actions Bot added Java Pull Requests for Java binding build labels Oct 14, 2026
@juliojgd

Copy link
Copy Markdown

Thanks @sharanggupta for tackling this. Delegating arrays/maps/unions to the model is the right direction, Utf8.hashCodeFor looks solid, and the description is upfront about what is out of scope. I built the PR head (9b4b14a) and its base (28cb08c) locally and went through it in some depth. TL;DR first, details and repros below (line numbers refer to the PR head).

TL;DR

  • The three scenarios in the JIRA are fixed (plus many sibling shapes), the existing suites are green, and the new tests are discriminating (they fail on main, pass on the PR).
  • Needs fixing:
    1. equals() now throws NullPointerException when an array/map field is null (behaviour change vs main/1.12.1+).
    2. The very same Unknown datum type exception (and String/Utf8 asymmetries) is still reachable through map values.
  • Needs an explicit decision / documentation:
    3. Widened visibility of compare(...) and other new public API, plus generated code that no longer runs on an older runtime.
    4. GenericData.hashCode changes for maps (this affects GenericRecord and classes generated by older compilers), and the description is not accurate about hash stability in a few cases.
  • Non-blocking: (5) cost of Utf8.hashCodeFor(String); (6) BigDecimal scale vs hash; (7) test coverage and follow-ups (AVRO-4036, opt-out, backport).

What I verified

  • JDK 21 / Maven 3.9. The CI workflows have not run on this PR yet (only triage), so these are local results.
  • Your 7 new tests in TestGeneratedEqualsAndHashCode fail on main (5 failures + 2 errors: Unknown datum type java.util.UUID / java.time.LocalDate, 4066 vs 5058) and pass on the PR.
  • mvn verify is green for avro (3463 tests), idl (39), compiler (99), maven-plugin (15), ipc (238) and integration-test/codegen-test (18). spotless:check is clean. I could not run the "Run all tests under Java 11" invoker IT, and I did not build mapred/protobuf/thrift/tools/trevni/grpc; nothing under lang/java overrides the 4-arg compare (see 3a).
  • Utf8.hashCodeFor matches new Utf8(s).hashCode() (and Arrays.hashCode(s.getBytes(UTF_8))) on 3M random strings, including unpaired surrogates and 1-4 byte characters. No mismatches.
  • I also ran a matrix of ~48 single-field shapes x stringType CharSequence/String against 1.12.0, 1.12.2, main and the PR. For each shape I checked built.equals(decoded), decoded.equals(built), decoded.equals(decoded2), hash equality and HashSet.contains.
Matrix summary (built = builder/Java types, decoded = after encode/decode)
Field type (single field of the record) main PR
JIRA 1: array<record{["null", uuid]}> decoded.equals(built) throws OK
JIRA 2: array<["null", date]> throws OK
JIRA 3: string hash, String vs Utf8 hashes differ (128 vs 159) OK
["null","string","int"] holding a String not equal, hashes differ OK
map<string>, array<string>, array<map<string>>, map<record>, map<enum>, map<["null",date]>, nested arrays not equal and/or hashes differ OK
map<array<["null", uuid]>>, map<array<record{["null", uuid]}>> throws / not equal throws (same exception)
map<array<string>> not equal both ways asymmetric
map<map<string>> not equal both ways not equal both ways
array<string> / map<string> field null vs empty false NPE

1. Needs fixing: equals() can now throw NullPointerException

The generated code calls MODEL$.compare(this.f, other.f, ...) with no null check (record.vm:651-652). GenericData.compare dereferences its arguments in the ARRAY case (GenericData.java:1307, ((Collection) o1).iterator()) and in compareMaps (m1.isEmpty()). So comparing a record whose array/map field is null with one where it is not now throws, where Objects.equals used to return false.

The builder rejects nulls for non-nullable fields, but the public no-arg constructor, the all-args constructor and the setters do not, so null array/map fields are easy to hit (mapping frameworks, test fixtures, new X() plus a few setters).

Repro (using your EqualsOrder)
EqualsOrder unset = new EqualsOrder();                    // array/map fields are null
EqualsOrder defaults = EqualsOrder.newBuilder().build();  // array/map fields are empty

unset.equals(defaults);             // main: false      PR: NullPointerException ("a1" is null)
List.of(defaults).contains(unset);  // main: false      PR: NullPointerException
unset.hashCode();                   // fine on both (MODEL$.hashCode(null, ...) returns 0)

1.12.0 (SpecificRecordBase#equals) threw the same NPE, so this restores the 1.12.0 behaviour. It is still a change for everyone on 1.12.1+, and a nasty one: assertEquals / List.contains blow up instead of failing / returning false.

Suggestion: guard against null in the generated code before delegating, e.g. this.f == null ? other.f != null : (other.f == null || MODEL$.compare(...) != 0), or centralize it in a null-safe helper (see 3b). And add a test (null vs empty/default in both directions, plus null vs null):

@Test
void nullCollectionFieldsDoNotThrow() {
  EqualsOrder unset = new EqualsOrder();
  EqualsOrder defaults = EqualsOrder.newBuilder().build();

  assertNotEquals(unset, defaults);
  assertNotEquals(defaults, unset);
  assertEquals(unset, new EqualsOrder());
  assertEquals(unset.hashCode(), new EqualsOrder().hashCode());
}

2. Needs fixing: the same exception (and asymmetries) is still reachable through map values

GenericData.compare handles MAP by delegating to compareMaps (GenericData.java:1318-1320), which compares the values with Object.equals (value.equals(value2), GenericData.java:1271), special-casing only Utf8 vs String for scalar values. When a map value is an array or a map, none of the machinery this PR relies on (schema, model conversions, String/Utf8 equivalence) applies:

  • map<array<["null", uuid]>> and map<array<record{["null", uuid]}>>: decoded.equals(built) and decoded.equals(decoded2) throw the same AvroRuntimeException: Unknown datum type java.util.UUID (on main, decoded.equals(decoded2) throws as well). Stack trace on the PR head:

    org.apache.avro.AvroRuntimeException: Unknown datum type java.util.UUID: de5714a7-0000-0000-0000-000000000001
      at org.apache.avro.generic.GenericData.getSchemaName(GenericData.java:979)
      at org.apache.avro.generic.GenericData.resolveUnion(GenericData.java:938)
      at org.apache.avro.generic.GenericData.compare(GenericData.java:1323)
      at org.apache.avro.generic.GenericData.compare(GenericData.java:1313)
      at org.apache.avro.generic.GenericData$AbstractArray.equals(GenericData.java:361)   <- GenericData.get(): no conversions
      at org.apache.avro.generic.GenericData.compareMaps(GenericData.java:1271)           <- value.equals(value2)
      at org.apache.avro.generic.GenericData.compare(GenericData.java:1320)
      at org.apache.avro.specific.SpecificData.compare(SpecificData.java:594)
      at ...EqualsOrder.equals(...)
    
  • map<array<string>>: asymmetric. built.equals(decoded) is false (List<String>.equals(GenericData.Array<Utf8>)), decoded.equals(built) is true.

  • map<map<string>>: false in both directions (Map<String,String> vs Map<Utf8,Utf8>).

This is not a new regression (1.12.0 behaves the same and main is worse), but it is the same root cause as the JIRA, and the new MAP case in HashCodeCalculator is schema-driven while the equality side is not. Since the PR already touches compare and the MAP hash, I'd fix it here: thread s.getValueType() from the MAP case into compareMaps (keeping the 2-arg protected method for subclasses) and compare non-null values with compare(v1, v2, valueSchema, true), keeping the existing String/Utf8 key lookup. One side effect to be aware of: record values would then be compared field-wise by the model rather than through the record's own equals, as record elements of arrays already are. If you prefer to leave it out of this PR, please open a follow-up JIRA and make clear that AVRO-4354 does not remove the exception for map values.

Suggested additions to generated_equals.avsc (fields of EqualsOrder) and tests (all three fail on the PR head today):

{ "name": "idsByKey", "type": { "type": "map", "values": { "type": "array", "items": ["null", { "type": "string", "logicalType": "uuid" }] } }, "default": {} },
{ "name": "tagsByKey", "type": { "type": "map", "values": { "type": "array", "items": "string" } }, "default": {} },
{ "name": "nestedAttributes", "type": { "type": "map", "values": { "type": "map", "values": "string" } }, "default": {} }
@Test
void mapOfArraysOfNullableLogicalType() throws IOException {
  EqualsOrder before = EqualsOrder.newBuilder().setIdsByKey(Map.of("k", Arrays.asList(ID, null))).build();
  EqualsOrder after = roundTrip(before);

  assertEqualBothWays(before, after);
  assertEqualBothWays(after, roundTrip(before));
}
// same shape for tagsByKey (Map.of("k", List.of("a", "b"))) and nestedAttributes (Map.of("k", Map.of("x", "y")))

3. Needs an explicit decision / documentation: new public API and runtime coupling

3a. Widened visibility. compare(Object, Object, Schema, boolean) goes from protected to public in GenericData (:1289), SpecificData (:588) and ReflectData (:983). That is source-incompatible for any third-party subclass that overrides the protected hook:

public class MyData extends GenericData {
  @Override
  protected int compare(Object o1, Object o2, Schema s, boolean equals) {
    return super.compare(o1, o2, s, equals);
  }
}
// compiles against main; against the PR: "attempting to assign weaker access privileges; was public"

It stays binary compatible. Nothing under lang/java overrides it (I grepped all modules) and the build has no japicmp/revapi, so CI would not flag it. The PR also adds public Utf8.hashCodeFor, SpecificCompiler#requiresModelComparison and SpecificCompiler#hashCodeFor(Field, String), and the now-public compare has no contract in its Javadoc (null handling, the equals flag, exceptions).

3b. An alternative that widens nothing. SpecificRecordBase#equals already calls the protected 4-arg SpecificData#compare, which is legal because both live in org.apache.avro.specific (SpecificRecordBase.java:79); SpecificExceptionBase, the base of generated error types, is in the same package. By default generated records/errors extend those bases (SpecificCompiler#getSchemaParentClass), so the template could call small protected helpers there (e.g. fieldEquals(int pos, Object a, Object b) / fieldHashCode(int pos, Object v), names illustrative) that use getSpecificData() and the schema of field pos. That leaves GenericData / SpecificData / ReflectData untouched, limits the new API surface to the generated-code base classes, shortens the generated code (no SCHEMA$.getFields().get(n).schema() repeated per field), and is the natural place for the null handling from item 1.

3c. Generated code needs the new runtime. Code generated by the PR's compiler fails at runtime on any runtime that lacks the PR, including a 1.12.2 runtime:

  • record with a CharSequence field, hashCode(): NoSuchMethodError: 'int org.apache.avro.util.Utf8.hashCodeFor(java.lang.CharSequence)'
  • record with an array/map/union field, equals(): IllegalAccessError: class ...Holder tried to access protected method 'int org.apache.avro.specific.SpecificData.compare(...)'

For comparison, code generated by 1.12.2/main runs on a 1.12.0 runtime for array/map fields; only CharSequence fields need >= 1.12.1 (Utf8.compareSequences, which the description mentions). Old generated code on the PR runtime works fine. This matters wherever the Avro runtime is dictated by the platform (e.g. Hadoop/Spark/Flink/Kafka classpaths) and the plugin version is chosen independently. I'd call it out in the release notes, and take it into account if this is backported to branch-1.12: a 1.12.3 plugin would generate classes that do not run on a 1.12.2 runtime within the same minor line (plus the API changes above in a patch release).

4. Needs an explicit decision / documentation: runtime hash codes change beyond the generated code

The new MAP case in HashCodeCalculator (GenericData.java:1176) changes GenericData.hashCode, hence GenericData.Record#hashCode and SpecificRecordBase#hashCode of classes generated by older compilers (they inherit it). Measured with classes generated by 1.12.0 (bytecode untouched, only the runtime jar switched from main to the PR):

Record generated by 1.12.0 hashCode() on main runtime on PR runtime
{map<string>} with stringType=String 89 93
{map<enum>} holding a Java enum value 1270144544 (Enum.hashCode() is an identity hash, varies per JVM) 170 (ordinal-based)

And for a GenericRecord {map<array<string>>} with Utf8 keys/values and a 12-element array: 1850569164 -> 2061980516 (Map.hashCode() used to delegate to List.hashCode(); the bounded HashCodeCalculator now only visits the first nine elements). The cases listed in the description (Utf8-keyed maps with Utf8, numeric or record values; arrays of up to nine elements) are indeed unchanged, and decoded records keep their 1.12.1/1.12.2 hash, which is good.

The new values are arguably more correct (consistent with equality, deterministic for enums), but this is a behaviour change for every user of GenericData.hashCode, not just generated code, in an area where silent hash changes have already been reported (AVRO-4334). I'd either split it into its own PR/JIRA with its own release note, or list it explicitly in the description and release notes.

The description also says that "records generated by this compiler, records generated before 1.12.1 and GenericRecords with the same content keep the same hash code". That is not accurate in a few cases:

  • HashCodeCalculator only visits the first nine fields/elements (counter = 10, GenericData.java:1146-1205), whereas the generated hashCode() hashes every field. For a record with 12 string fields the generated hashCode() is 1135094951 and SpecificData.get().hashCode(record, schema) is 327880286 (already the case on main/1.12.1+, but hashCodeOfGeneratedRecordMatchesSpecificData only covers a two-field record, which suggests an equivalence that does not hold in general).
  • "Before 1.12.1": Utf8.hashCode() itself changed in 1.12.1 (AVRO-4334; e.g. "a": 97 -> 128), so the reference point for "unchanged" is 1.12.1+, not earlier.
  • The map cases above.

Please reword and, if you keep that test, cover a record with more than nine fields.

5. Non-blocking: cost of Utf8.hashCodeFor(String)

For a CharSequence field holding a String, the generated hashCode() now re-scans the string on every call, where it used to hit the cached String.hashCode(). Rough numbers (plain timing loop, not JMH; JDK 21, same machine; main vs PR):

main PR
record with one string field holding a 200-char String: hashCode() ~7-8 ns ~240-310 ns
same record decoded (Utf8, hash cached): hashCode() ~7 ns ~7 ns
map<string> with 100 String entries: hashCode() ~0.22 µs ~1.9-2.3 µs
array<string> of 1000: equals() built / decoded 4.7-4.9 µs / 9.3-11 µs 6.8-7.4 µs / 13-15 µs
array<string> of 1000: hashCode() built / decoded ~0.9-1.0 µs / ~1.1-1.2 µs ~0.16 µs / ~0.05 µs (only nine elements are hashed)

This is probably acceptable, but it deserves a sentence in the description: code that uses records built from Strings as hash keys will notice it (records decoded from Avro hold Utf8, whose hash is cached, and are unchanged). If you want to mitigate it: for ASCII-only content the UTF-8 hash equals 31^n + s.hashCode() (n = length), so a fast path on top of the cached String hash is possible, although it still needs a scan to prove the content is ASCII.

6. Non-blocking: BigDecimal scale vs hash

For arrays/maps/unions of decimal, equality now goes through compare(), which uses BigDecimal.compareTo (scale-insensitive), while the hash uses BigDecimal.hashCode() (scale-sensitive). So List.of(new BigDecimal("1.5")) and List.of(new BigDecimal("1.50")) in an array<decimal(9,2)> field are equals but have different hash codes (on main they were simply not equal). It only affects values that were never encoded (the conversion rejects a scale different from the schema's on write) and 1.12.0 behaved the same, so low priority; but since the JIRA is precisely about the equals/hashCode contract, a note or a test would be nice.

7. Tests and follow-ups

Coverage that would have caught the points above:

  • the three map shapes (item 2) and null vs empty (item 1);
  • a non-nullable union field (e.g. ["null","string","int"]): requiresModelComparison has a dedicated !nullable branch that no field in generated_equals.avsc exercises (it works, I checked, but it should be covered);
  • stringType=String (the CharSequence branch of hashCodeFor is not used there, and arrays/maps hold plain Strings on both sides);
  • a record with more than nine fields (item 4).

Out of scope here per the description, but worth tracking so that AVRO-4354 isn't considered fully done:

  • GenericData.AbstractArray#equals still uses GenericData.get() (GenericData.java:361, AVRO-4036). record.equals now works, but comparing the extracted list directly still throws: order.getDates().equals(expected) -> Unknown datum type java.time.LocalDate (expected.equals(order.getDates()) is true). That is typical in assertions like assertThat(order.getDates()).isEqualTo(expected).
  • An opt-out for the generated equals/hashCode (item 4 of the JIRA). Given the behaviour changes above I'd like that escape hatch to be tracked.
  • A backport to branch-1.12 (the JIRA lists 1.12.1/1.12.2 as affected and has no fix version), see 3c.
  • AVRO-4198 is fixed for generated records by this PR, so it's worth referencing/closing.

Thanks again, happy to help review follow-ups.

…code

The generated equals() delegated array, map and non-nullable union
fields to SpecificData.compare(), which dereferences arrays and maps, so
a record whose array or map field is null (possible through the
constructors and setters) threw a NullPointerException where
Objects.equals() returned false. Check for null before delegating.

GenericData.compare() compared map values with Object.equals(), so a
map whose values are arrays or maps had the same problems as array
fields: "Unknown datum type" for logical types in unions, and String
vs Utf8 asymmetries. Compare the values with the value schema through a
new compareMaps(Map, Map, Schema); the two-argument compareMaps() keeps
its behaviour.

Also regenerate the expected classes of TestSpecificCompilerTool, whose
equals() and hashCode() follow the new template.
@sharanggupta

Copy link
Copy Markdown
Author

Thank you for this review, it's far more thorough than I could have hoped for. You were right on both items that need fixing, and you also found something you couldn't have seen without building tools: the golden files of TestSpecificCompilerTool didn't match the new template, so CI would have failed. All of that is in the new commit 81ef4c0.

Fixed

  1. NullPointerException for null array/map fields. The generated equals() now checks for null before delegating: this.f == null ? other.f != null : other.f == null || MODEL$.compare(...) != 0. nullCollectionFieldsDoNotThrow is your test: null vs defaults in both directions, null vs null, and the hash.
  2. Map values. compare() passes the value schema to a new compareMaps(m1, m2, valueSchema), which compares non-null values with compare(v1, v2, valueSchema, true). The String/Utf8 key lookup is unchanged. The two-argument protected compareMaps() keeps its behaviour for subclasses by delegating with a null schema. generated_equals.avsc has your three fields, and mapOfArraysOfNullableLogicalType, mapOfArraysOfStrings and mapOfMapsOfStrings cover them: they fail on the previous head (the Unknown datum type exception and the two asymmetries) and pass now. As you noted, record map values are now compared field-wise by the model, as record elements of arrays already are.
  3. Non-nullable union. A choice: ["null", "string", "int"] field exercises the !nullable branch of requiresModelComparison (unionOfStringAndInt).
  4. Golden files. The generated Player, NoSettersTest and OptionalGetters... classes in tools/src/test/compiler/output* are regenerated. The diff is only the equals()/hashCode() lines.

Description corrected

I rewrote the hash-stability paragraph: the reference point is 1.12.1+, the nine-element/field bound of HashCodeCalculator, and the MAP case of GenericData.hashCode changing the hash of GenericRecords and of classes generated by older compilers, with your examples. I also listed the new public API, the runtime coupling of generated code (3c), the cost of Utf8.hashCodeFor(String) and the BigDecimal scale note, and turned the out-of-scope items into explicit follow-ups (AVRO-4036, the opt-out, a backport, AVRO-4198).

Open for the maintainers

  • 3a/3b, public compare() vs protected helpers. I haven't changed this yet, because it shapes the API. Your 3b looks better to me: protected fieldEquals/fieldHashCode helpers on SpecificRecordBase and SpecificExceptionBase, used by the template. It leaves GenericData/SpecificData/ReflectData untouched and shortens the generated code. One question is what the template should do for a record whose configured parent class isn't one of those bases. I'm happy to switch if a committer prefers it.
  • 4, the MAP hash change. I can split it into its own JIRA and PR if that makes the release notes cleaner. It's needed here only for the generated hashCode() of map fields to agree with equals().

Locally (JDK 21, build cache disabled): avro, compiler, tools, ipc, idl, maven-plugin and integration-test/codegen-test pass with install, and spotless:check is clean.

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

Labels

build Java Pull Requests for Java binding

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants