Skip to content

[Coral-Schema] Restore ancestor nullability when a nested field is projected out - #618

Merged
aastha25 merged 2 commits into
linkedin:masterfrom
yyy1000:iceberg-first-avro/flattened-nullability
Sep 1, 2026
Merged

aastha25 merged 2 commits into
linkedin:masterfrom
yyy1000:iceberg-first-avro/flattened-nullability

Conversation

@yyy1000

@yyy1000 yyy1000 commented Aug 28, 2026 •

Copy link
Copy Markdown
Contributor

Summary

When a view projects a leaf out of a nullable struct — SELECT job.region AS job_region — the generated Avro schema declares the flattened column non-nullable if the leaf is required inside its parent. The view still returns null on every row where the parent struct is absent, so the published schema is stricter than the data: an Avro writer fails on those rows, and consumers built against the previously nullable column break.

This is a pre-existing bug in shared view code, introduced with visitFieldAccess in #83 / #110 (2021). It is not specific to any one schema source — see "Impact on the Hive path" below.

Root cause

Two nullability facts are in play, and both matter:

Fact Meaning
job is nullable the struct may be absent
job.region is required if a struct exists, region is populated

Flattening collapses two levels into one column, so that column must carry the union of both:

nullable(a.b.c) = nullable(a) OR nullable(a.b) OR nullable(a.b.c)

Only the last term was evaluated. getFieldFromTopSchema calls extractIfOption(topSchema) on entry and again at the end of every loop iteration, discarding each ancestor's ["null", …] branch; handleFieldAccess then copies the leaf's schema up verbatim. By the time the leaf is located, the only record that the parent could be absent is gone.

Note the leaf's required is not pushed onto the parent — the parent is erased and the leaf takes its place. Selecting a struct wholesale (SELECT job) was always correct: the envelope survives and carries the absent case.

This has been observed on real views whose base tables carry a nullable struct with required leaves, where the leaf is never null while its parent is present — so the leaf's required is accurate, and it is precisely the flattened column that has to widen.

Impact on the Hive path

The buggy code is shared, and views resolving through dali.row.schema are shielded only by an accident of input: ToNullableSchemaVisitor force-converts that schema to all-nullable, so no required leaf ever reaches the traversal.

That shield does not apply to tables whose partner is avro.schema.literal, since MergeHiveSchemaWithAvro.primitive keeps a leaf non-nullable when the partner says so. The new tests deliberately run through that path and fail without the fix.

The fix

  • getFieldFromTopSchema returns the resolved field plus a nullableAncestor flag, set wherever an extractIfOption strips a null branch — before the loop and on every iteration, so intermediate structs, array elements and map values all count.
  • handleFieldAccess applies makeNullable when any ancestor was optional, then reorderOptionIfRequired so a leaf carrying a default stays valid Avro (["string","null"], since Avro requires the default to match the first union branch).
  • SchemaUtilities gains an appendField overload taking an explicit schema; the existing 3-arg form delegates to it, so no existing caller changes behaviour.

Updated golden file

testProjectStructInnerField-expected.avsc has encoded this bug since #83 (May 2021), asserting Struct_Col.Bool_Field → "boolean" against a nullable Struct_Col. The view's WHERE Struct_Col IS NOT NULL does not justify it: LogicalFilter is documented in RelToAvroSchemaConverter as a nullability pass-through, so the output is identical with or without the predicate. Updated to the correct expectation.

Tests

Five new tests in ViewToAvroSchemaConverterTests, all running through the Hive path with avro.schema.literal:

Test Covers
testFlattenedFieldFromNullableStructIsNullable main case, plus a control that a required leaf under a non-nullable struct stays required
testFlattenedFieldFromNullableGrandparentIsNullable nullable grandparent, non-nullable parent
testFlattenedFieldFromNullableArrayAndMapIsNullable array-element and map-value traversal
testFlattenedFieldFromNullableStructKeepsDefaultValid required leaf with a default; asserts branch order and a parser round-trip
testSelectingNullableStructWholesaleKeepsRequiredLeaf SELECT job unchanged — no over-nullification

With the main-code change reverted, 4 of the 5 fail. Full ./gradlew test green; spotless clean.

The change has additionally been validated against a large corpus of existing views, where it corrected exactly the affected flattened columns and left every other schema unchanged.

getFieldFromTopSchema strips the option off every ancestor it walks --
extractIfOption on entry and again at the end of each loop iteration -- and
handleFieldAccess then copies the leaf's schema up verbatim. So
`SELECT job.region AS job_region` over `job: ["null", {region: "string"}]`
emitted `job_region: "string"`. The ["null", ...] carrying "job may be absent"
was discarded before the leaf was even located, and nothing in the output
replaced it. Note the leaf's `required` is not pushed onto the parent; the
parent is erased and the leaf takes its place.

Flattening collapses two independent facts into one column -- the parent may be
absent, and the leaf is populated whenever the parent exists -- so the column
must carry nullable(a) OR nullable(a.b) OR nullable(a.b.c). Only the last term
was evaluated. Selecting the struct wholesale was always fine: the envelope
survives and carries the absent case.

The result is a schema stricter than the data: an Avro writer fails on every row
where an ancestor struct is null, and consumers built against the previous
nullable column break. This was observed on real views whose base tables have a
nullable struct with required leaves, where the leaf is never null while its
parent is present -- so the leaf's `required` is accurate and it is precisely
the flattened column that has to widen.

The code is shared by both schema paths and dates to linkedin#110 (Jul 2021). Views
resolving through `dali.row.schema` are shielded, because ToNullableSchemaVisitor
force-converts that to all-nullable and no required leaf ever reaches the
traversal -- correct by accident, not by design. Tables whose partner is
avro.schema.literal are already exposed, since MergeHiveSchemaWithAvro.primitive
keeps a leaf non-nullable when the partner says so. The new tests run through
that path.

getFieldFromTopSchema now returns the field plus a nullableAncestor flag, and
handleFieldAccess applies makeNullable then reorderOptionIfRequired so a leaf
carrying a default stays valid Avro (["string","null"], since Avro requires the
default to match the first branch). SchemaUtilities gains an appendField
overload taking an explicit schema; the 3-arg form delegates to it unchanged.

testProjectStructInnerField-expected.avsc had encoded the bug since linkedin#83 (May
2021), asserting Struct_Col.Bool_Field -> "boolean" against a nullable
Struct_Col. Its `WHERE Struct_Col IS NOT NULL` does not justify that:
LogicalFilter is documented in this file as a nullability pass-through, so the
output is identical without the predicate. Updated.

Five new tests, covering the main case, a non-nullable-parent control, a
nullable grandparent, array/map traversal, a defaulted leaf, and wholesale
struct selection. Four of the five fail with the main change reverted. Full
./gradlew test green, spotless clean.
@yyy1000
yyy1000 force-pushed the iceberg-first-avro/flattened-nullability branch from 237379a to 2febbf4 Compare August 28, 2026 06:46
@aastha25

Copy link
Copy Markdown
Contributor

Excellent find, this latent bug has existed since schema inference was first introduced here. Nice work on the fix too: it lives in the shared traversal code, so it applies uniformly to both the existing schema-inference path and the new one, with no branch-specific special-casing needed.

Just one comment on testing coverage -
Current tests only prove 1-hop ( Job.Region ) and 2-hop/grandparent ( Wrapper.Mid.Leaf ) propagation. Could you add a 3+ level case, e.g.  A(required) → B(nullable) → C(required) → leaf(required) , asserting the flattened leaf column comes out nullable? That's the only way to confirm  nullableAncestor ||= ...  inside the loop actually accumulates across iterations, rather than just happening to work for 2 hops.

Note that testSelectDeepNestStructFieldFromDeepNestComplex  looks like it already covers this (3 levels deep) but doesn't: its base table ( basedeepnestedcomplex ) has no  avro.schema.literal , so it falls back to  TypeInfoToAvroSchemaConverter(mkFieldsOptional=true) , which force-nullifies every field — leaves included — regardless of ancestor nullability. That test would still pass even if  nullableAncestor  tracking were deleted entirely, so it isn't real evidence of 3+-level correctness.

Review asked for a case deeper than the existing tests to confirm the
nullableAncestor flag accumulates rather than merely happening to work at two
hops. Adds Deep(required) -> Mid(nullable) -> Nested(required) -> Leaf(required),
asserting the flattened column is nullable, plus an all-required control at the
same depth so the assertion cannot be satisfied by nullifying anything reached
through more than one hop.

The gap it closes is narrower than "3+ levels", and worth stating precisely.
Both existing multi-hop tests have their nullable ancestor at the *top* of the
chain, so nullableAncestor is seeded true before the loop runs and the in-loop
update only has to preserve it. Here the top-level column is required and the
nullable ancestor sits in the middle, so the flag has to be raised inside the
loop and then survive a further required hop.

Verified by mutation rather than assumed. Dropping the in-loop update entirely,
so only the top-level column's nullability counts -- the realistic form of this
bug -- fails this test alone and leaves every other test green. (For the record,
the weaker mutation of ||= to a plain assignment is already caught by the
existing grandparent and array/map tests, so this test earns its place on the
mid-chain case, not on depth as such.)

Nested is spelled that way because INNER is reserved in Hive QL; the test
harness swallows DDL failures, so the view would silently not exist.

149 coral-schema tests green, spotless clean.
@aastha25
aastha25 merged commit 1a2d2bc into linkedin:master Sep 1, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants