Repository navigation
[Coral-Schema] Restore ancestor nullability when a nested field is projected out - #618
Conversation
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.
237379a to
2febbf4
Compare
|
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 - Note that |
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.
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 isrequiredinside 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
visitFieldAccessin #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:
jobis nullablejob.regionis requiredFlattening collapses two levels into one column, so that column must carry the union of both:
Only the last term was evaluated.
getFieldFromTopSchemacallsextractIfOption(topSchema)on entry and again at the end of every loop iteration, discarding each ancestor's["null", …]branch;handleFieldAccessthen 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
requiredis 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
requiredis 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.schemaare shielded only by an accident of input:ToNullableSchemaVisitorforce-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, sinceMergeHiveSchemaWithAvro.primitivekeeps a leaf non-nullable when the partner says so. The new tests deliberately run through that path and fail without the fix.The fix
getFieldFromTopSchemareturns the resolved field plus anullableAncestorflag, set wherever anextractIfOptionstrips a null branch — before the loop and on every iteration, so intermediate structs, array elements and map values all count.handleFieldAccessappliesmakeNullablewhen any ancestor was optional, thenreorderOptionIfRequiredso a leaf carrying a default stays valid Avro (["string","null"], since Avro requires the default to match the first union branch).SchemaUtilitiesgains anappendFieldoverload taking an explicit schema; the existing 3-arg form delegates to it, so no existing caller changes behaviour.Updated golden file
testProjectStructInnerField-expected.avschas encoded this bug since #83 (May 2021), assertingStruct_Col.Bool_Field→"boolean"against a nullableStruct_Col. The view'sWHERE Struct_Col IS NOT NULLdoes not justify it:LogicalFilteris documented inRelToAvroSchemaConverteras 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 withavro.schema.literal:testFlattenedFieldFromNullableStructIsNullabletestFlattenedFieldFromNullableGrandparentIsNullabletestFlattenedFieldFromNullableArrayAndMapIsNullabletestFlattenedFieldFromNullableStructKeepsDefaultValidtestSelectingNullableStructWholesaleKeepsRequiredLeafSELECT jobunchanged — no over-nullificationWith the main-code change reverted, 4 of the 5 fail. Full
./gradlew testgreen; 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.