Skip to content

feat(validation): cross-check a mapping document against the documents it references - #595

Merged
jbonofre merged 6 commits into
apache:mainfrom
kayemkim:validation/mapping-cross-checks
Oct 29, 2026
Merged

jbonofre merged 6 commits into
apache:mainfrom
kayemkim:validation/mapping-cross-checks

Conversation

@kayemkim

Copy link
Copy Markdown
Contributor

Summary

A mapping document names its ontology and semantic model through ontology_ref and semantic_model_ref, but validate.py --schema ontology/mapping.json checked only the mapping's own shape. On current main, a copy of examples/flights.mapping.yaml with semantic_model_ref.name changed to Flights model, the first concept changed to Runwy and RUNWAY.designator changed to RUNWAY.designater still reports Validation PASSED.

The validator now follows each relative iri from the mapping file's own directory, as ontology.md specifies, and checks that the document found carries the name the reference says, that every concept the mapping names is declared in that ontology or is a built-in concept, and that every dataset.field an expression names is a declared field of that semantic model. The copy above now fails with a [Reference] error for each edit, two for RUNWAY.designater since the example writes it twice, and the unmodified example passes with nothing reported.

A reference the validator does not follow is left to a catalog and reported as a warning, not an error: no iri, an iri with a scheme, or a relative one on a document validated without a location to resolve against. The iri is read as a URI reference, so a percent-encoded path, a query or a fragment resolve as expected. A path that does not read as a YAML document is an error. Field references are the two-part identifiers in the expression text outside string literals, quoted identifiers and comments, compared exactly as the core reference checks do. A dataset that declares no fields list is not checked, since the core spec does not require one.

This is the validate.py follow-up from the dev@ thread "Decouple Ontologies, Semantic Models, and Their Mappings Through External References", which Shao handed over on 2026-10-15. Concepts reuse BUILT_IN_CONCEPTS from #489. The key coverage warning raised on that thread is not included, for the reason Shao gave there.

Verification

Related Issues

dev@ thread: https://lists.apache.org/thread/9op4mr1qfb6o0c229shdbnzp44jp4wxd

Checklist

Tests

  • All existing tests pass (pytest / CI green)
  • New functionality is covered by tests

Compliance

  • ASF license headers are present on all new source files (no new files)
  • No third-party dependencies are added without PMC/IPMC approval

@kayemkim

Copy link
Copy Markdown
Contributor Author

Pushed a second commit with the check JB suggested on the list: every relationship a referent or link mapping names has to resolve on its concept, directly or through extends, the way validate_ontology already resolves identify_by. A nested referent mapping resolves on the concept that plays the last role of the enclosing relationship, and a relationship with no role at all cannot carry one (a relationship with more than one role still resolves on its last). A link mapping's child maps the last role of its own relationship unless its object mapping names a concept, and the level of a link mapping has to match the arity of the relationship it names. The Concept.name form from the spec examples names the concept being mapped or one it extends.

flights.mapping.yaml still passes. With code misspelled under Runway's airport referent and geometry misspelled on a link child it reports both, the first against Airport. 233 tests, 6 of them new, and the validation workflow passes locally on current main.

@jbonofre
jbonofre self-requested a review October 17, 2026 09:55
Comment thread validation/validate.py Outdated
r"|/\*.*?\*/", # block comment
re.DOTALL,
)
_FIELD_REFERENCE = re.compile(r"(?<![\w.])([A-Za-z_]\w*)\.([A-Za-z_]\w*)(?![\w.]|\s*\()")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This treats any name.name outside a quote or comment as a dataset.field reference, so ordinary SQL fails validation even when every field exists:

  • CAST(orders.order_id AS pg_catalog.int4) and orders.order_id::pg_catalog.int4 report "declares no dataset 'pg_catalog'"
  • transform(orders.ids, x -> x.y) reports "declares no dataset 'x'"
  • orders.payload:customer.id (a Snowflake path) reports "declares no dataset 'customer'"
  • 'it\'s a.b' || orders.order_id (a backslash escape) reports "declares no dataset 'a'"

sqlglot is already a dependency of this script, so could this use the parser instead of a regex?
sqlglot.parse_one(expression).find_all(exp.Column) returns only orders.order_id / orders.ids for the cast and lambda cases. It gives each column's table and name along with their quoting, so quoted identifiers can be resolved too (they are skipped today).

Mapping expressions declare no dialect, so an expression that doesn't parse with the default dialect (the Snowflake path and backslash cases don't) could skip this check with a ValidationWarning rather than guess. The same goes for when sqlglot is not installed, as validate_sql already does.

Comment thread validation/validate.py Outdated
expression = node.get("expression")
if fields_by_dataset is not None and isinstance(expression, str):
for dataset, field in field_references(expression):
if dataset not in fields_by_dataset:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

core-spec/expression_language.md says regular (unquoted) identifiers are case-insensitive, and that matching is done on normalized identifiers: unquoted ones upper-cased, quoted ones taken as written. Both the dataset and the field comparison here are exact, so ORDERS.ORDER_ID against dataset orders / field order_id is reported as an error and the file fails validation, even though it names the same field.

Could both sides be normalized before comparing? That means treating the model's name values as regular identifiers, and upper-casing the expression's parts unless they are quoted. Following the spec's comparison table, "ORDERS".order_id would then match and "orders".order_id would not.

Comment thread validation/tests/test_ontology.py Outdated
"[Reference] concept_mappings -> 0 -> object_mappings -> 1: expression names "
"'orders.order_nr' but dataset 'orders' declares no field 'order_nr'",
"[Reference] concept_mappings -> 0 -> object_mappings -> 2: expression names "
"'ORDERS.order_id' but the semantic model declares no dataset 'ORDERS'",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This pins down the case-sentsitive behavior. Per expression_language.md, ORDERS.order_id names dataset orders, so I would expect no error for it.

Could it move to the expressions that pass, maybe with a case like ORDERS.ORDER_NR that is still reported as a missing field?

Comment thread validation/validate.py Outdated
if ontology is not None:
if isinstance(ontology.get("ontology"), list):
components = [c for c in ontology["ontology"] if isinstance(c, dict)]
concepts = {c.get("concept") for c in components} | BUILT_IN_CONCEPTS

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The referenced ontology is not schema-checked before it is used, so values of any type end up in this set, and also in the fields_by_dataset keys and sets below. A referenced ontology with concept: [Order], or a modal with a dataset name: [orders] or a field name: {a: 1}, raises TypeError: unhashable type. validate_file then reports an internal error with a traceback instead of a diagnostic about the referenced file.

Rather than adding isinstance guard here, could each referenced document go through validate_schema right after resolve_document_reference loads it? That would be ontology/ontology.json and core-spec/ossie-schema.json, using the existing local registry. If a document fails, its half of the checks would be skipped, with an error that names the referenced file. Helpers like as_list already assume "shapes that already failed the schema", and that is not true for these documents.

For example, roles given as a mapping is read as zero roles today and produces a misleading arity error.

Comment thread validation/validate.py Outdated
f"[Reference] Warning: {label} '{name}' has no iri, so only a catalog can "
f"resolve it: {skipped}"
)]
parts = urlsplit(iri)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

urlsplit raises ValueError for a malformed bracketed host (http://[oops/o.yaml or //[::1/o.yaml gives "Invalid IPv6 URL"), and path.read_text below raises ValueError for an embedded NUL.

Neither is caught, so a typo in an iri shows up as an internal validator error instead of a [Reference] diagnostic. Something like:

     try:
         parts = urlsplit(iri)
     except ValueError as e:
         return None, [f"[Reference] {label} iri '{iri}' is not a valid URI reference: {e}"]

plus a separate except ValueError as e: next to the OSError and around read_text. It can't share that clause, because ValueError has no strerror.

@kayemkim

Copy link
Copy Markdown
Contributor Author

Pushed a third commit that takes all five.

  • field_references parses the expression with sqlglot and takes the two-part columns of the tree, so CAST and :: types, lambda parameters and schema-qualified functions are no longer datasets, columns inside a subquery stay in that query's scope, and quoted identifiers are resolved. An expression that does not parse as a SQL expression, which is what the Snowflake path and the backslash escape do, skips the check with a warning, and so does every expression when sqlglot is missing.
  • Both sides are normalized the way expression_language.md describes: regular identifiers upper-cased, quoted ones as written, the model's names as regular identifiers. ORDERS.Order_Id and "ORDERS"."ORDER_ID" resolve, "orders".order_id and orders."order_id" do not.
  • The case tests moved to the passing side, with ORDERS.ORDER_NR still reported.
  • Each referenced document goes through validate_schema against its own schema right after it is loaded. One that fails is dropped with one error naming the reference and the first schema error, and the checks no longer guard against shapes the schema rules out, so roles given as a mapping is a schema error rather than an arity one. A referenced document at another version fails the same way, since both schemas pin it.
  • urlsplit and a path with an embedded NUL report as [Reference] errors. The NUL I could provoke is %00 in the iri, which open rejects. A NUL inside the file decodes fine and the YAML reader rejects it, which was already caught.

One consequence worth a look. Two Flight link mappings in examples/flights.mapping.yaml filter with FLIGHT.id WHERE ( FLIGHT.diverted == TRUE ), which is not a SQL expression as ontology.md defines one, so the example now passes with two warnings where the regex used to read FLIGHT.id and FLIGHT.diverted out of it. If that form is meant to be legal in object mappings the spec should say so, otherwise the example wants a different expression. I can take either as a follow-up.

267 tests, 34 of them new, and the validation workflow passes locally on current main.

@kayemkim
kayemkim requested a review from jbonofre October 19, 2026 11:58
Comment thread validation/validate.py Outdated
parts = column.parts
if len(parts) != 2 or not all(isinstance(p, sqlglot.exp.Identifier) for p in parts):
continue
if column.find_ancestor(sqlglot.exp.Select) is not None:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

column.find_ancestor(exp.Select) skips every column under any Select. The docstring says a correlated reference to a dataset is still included, but this drops it.
For EXISTS(SELECT 1 FROM t WHERE t.a = orders.nonexistent), the typo in orders.nonexistent is never reported.

Could we skip only columns whose table part resolves to something bound inside that subquery (its FROM or JOIN aliases), and still check the rest?

Comment thread validation/validate.py Outdated
Comment on lines +1120 to +1121
if not errors and isinstance(data, dict) and "concept_mappings" in data:
errors.extend(validate_mapping(data, base))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These gates use not errors, but ValidationWarning is a str appended to the same list. If an earlier check only warns, such as sqlglot being unavailable, errors is non-empty and the ontology and mapping checks, including the new validate_mapping call, are silently skipped.

Could we gate on real errors only, for example any(not isinstance(e, ValidationWarning) for e in errors)?

A test where a warning precedes a genuine mapping error would cover it.

@kayemkim

Copy link
Copy Markdown
Contributor Author

Pushed one more commit for the two.

  • field_references now skips a column only when its table part is bound by the queries around it: a table or alias of their FROM and JOIN clauses, a derived table, table function, VALUES or PIVOT alias, or a CTE name, with an inner query seeing the bindings of the outer ones. A correlated reference stays a reference, so EXISTS(SELECT 1 FROM t WHERE t.a = orders.nonexistent) reports orders.nonexistent. The bindings follow the same identifier normalization as the references. Cases added for alias, schema-qualified table, JOIN, comma join, derived table, CTE, UNNEST, VALUES, PIVOT, HAVING, two levels of nesting, a name bound only in a nested query or the other branch of a UNION, and a quoted alias. Bindings are collected in one pass over the tree, so a subquery with a few thousand predicates stays well under a second.
  • The three gates in validate_document count errors only, through a small clean() closure, so a warning no longer stops the ontology and mapping checks. On the test: nothing that runs before the mapping checks warns on a mapping document today (validate_sql only warns on a semantic model, and the two schemas do not share a document), so the test wraps validate_schema to emit one warning for the mapping document and checks that the concept error still follows it.

287 tests; the validation workflow passes locally on current main.

…s it references

A mapping document names an ontology and a semantic model by iri, but
validate.py checked only its own shape, so a mapping whose references
name the wrong document, whose concepts are not in the ontology, or whose
expressions name fields the model does not declare still passed.

Read each iri as a URI reference and follow its path from the mapping
file's own directory, as ontology.md says. Check that the document found
is named as the reference says, that every concept the mapping names is
declared there or built in, and that every dataset.field an expression
names is a declared field of the model. A dataset that declares no fields
list is not checked, since the core spec does not require one. A
reference with no iri, with a scheme, or with a relative path and no
location to resolve against is left to a catalog and reported as a
warning, so documents that only a catalog can resolve still pass.

Generated-by: Claude Code
…ncept

A referent mapping names a relationship of the concept it maps, and a
link mapping names one of the concept mapping's concept, but the
cross-checks read only the concept and expression of each node, so a
relationship the ontology does not declare passed.

Resolve each relationship on its concept or on a concept it extends,
the way validate_ontology resolves identify_by. Nested referent
mappings resolve on the concept that plays the last role of the
enclosing relationship, and a relationship with no such role cannot
carry them. A link mapping's children map the objects in the last
position of their relationship's links unless their object mapping
names a concept, and a link mapping's level has to match the arity of
the relationship it names, as ontology.md describes. A name written as
Concept.name names the concept being mapped or one it extends, as in
the spec examples.

Generated-by: Claude Code
…ents a mapping references

The dataset.field check read mapping expressions with a regular
expression, which took any two-part name for a reference. A type name
in CAST or ::, a lambda parameter and a schema-qualified function all
came back as datasets the model does not declare. The expression is now
parsed with sqlglot and the references are the two-part columns of the
tree, so those shapes are left alone, a column inside a subquery is left
to that query's scope, and quoted identifiers are resolved too. Mapping
expressions declare no dialect, so one that does not parse as a SQL
expression, or that sqlglot reads as a statement, skips the check with
a warning, and so does every expression when sqlglot is not installed,
as validate_sql already does.

Identifiers are compared the way expression_language.md compares them:
a regular identifier upper-cased, a quoted one as written, and the
model's names as regular identifiers. ORDERS.order_id names dataset
orders, and "orders".order_id does not. Datasets whose names differ
only in case are one dataset to a regular identifier, so their fields
are pooled.

The referenced ontology and semantic model are validated against their
own schemas right after they are loaded. A document that fails is
dropped with one error that names the reference and the first schema
error, instead of surfacing later as a TypeError from a set of
unhashable names or as a misleading arity error for roles given as a
mapping. The checks that read the documents assume the shapes the
schemas guarantee and no longer guard against the others. A copy of the
script without the schemas next to it skips that half with a warning.

An iri that urlsplit rejects, and a path with an embedded NUL, are
reported as reference errors rather than internal validator errors.

The two Flight link mappings in examples/flights.mapping.yaml filter
with "FLIGHT.id WHERE ( ... == TRUE )", which is not a SQL expression,
so the example now validates with two warnings.

Generated-by: Claude Code
…er checks on errors only

field_references dropped every two-part column under a SELECT, so a
correlated reference such as orders.nonexistent in
EXISTS(SELECT 1 FROM t WHERE t.a = orders.nonexistent) was never
checked. Now only a column whose table part names something the
enclosing queries bind, a table or alias of their FROM and JOIN clauses
or a name of their WITH clause, is left to that query. The bindings
follow identifier normalization, so a quoted alias binds as written.

validate_document gated the semantic, ontology and mapping checks on
`not errors`, and a ValidationWarning sits in the same list, so one
warning from an earlier check skipped everything after it. The gates
now count errors only.

Generated-by: Claude Code
@kayemkim
kayemkim force-pushed the validation/mapping-cross-checks branch from 2297c70 to bd855d3 Compare October 26, 2026 05:02
@kayemkim

Copy link
Copy Markdown
Contributor Author

Rebased on main now that #617 is in. The two conflicts were import lines; nothing else in the four commits changed.

Comment thread validation/validate.py Outdated
Comment on lines +1085 to +1091
for dataset in model["datasets"]:
name, fields = datasets.get(dataset["name"].upper(), (dataset["name"], set()))
if fields is not None and "fields" in dataset:
fields = fields | {f["name"].upper() for f in dataset["fields"]}
else:
fields = None
datasets[dataset["name"].upper()] = (name, fields)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Declared dataset and field names are always upper case, so a name that is not a regular identifier can never be referenced. The schema accepts any non-empty string, so a dataset order items with a field Order Id is valid but:

  • "order items"."Order Id" normalizes to ('order items', 'Order Id') and misses the ORDER ITEMS / ORDER ID keys, so it fails.
  • Only "ORDERS ITEMS"."ORDER ID" passes, which by expression_language.md names a different object.

Could declared names be normalized like references? A regular identifier is upper case, and any other name is matched exactly, as if quoted. sqlglot.exp.to_identifier already makes that call, and this branch only runs when sqlglot is available:

Suggested change
for dataset in model["datasets"]:
name, fields = datasets.get(dataset["name"].upper(), (dataset["name"], set()))
if fields is not None and "fields" in dataset:
fields = fields | {f["name"].upper() for f in dataset["fields"]}
else:
fields = None
datasets[dataset["name"].upper()] = (name, fields)
for dataset in model["datasets"]:
key = declared_name(dataset["name"])
name, fields = datasets.get(key, (dataset["name"], set()))
if fields is not None and "fields" in dataset:
fields = fields | {declared_name(f["name"]) for f in dataset["fields"]}
else:
fields = None
datasets[key] = (name, fields)

with a helper next to normalized_identifier:

def declared_name(name: str) -> str:
    """The normalized form of a name declared in a document: a regular
    identifier is upper-cased, any other name is kept as written."""
    return normalized_identifier(sqlglot.exp.to_identifier(name))

orders.order_id and Orders.ORDER_ID still match, and "order items"."Order Id" now does too.

Could we add a test with a dataset name that contains a space?

Comment thread validation/validate.py Outdated
like a keyword, such as from.x, otherwise would be. Needs sqlglot.
"""
try:
tree = sqlglot.parse_one(expression)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The statement check misses some statement types. With the default parse, sqlglot reads use.x, cache.x, analyze.x and truncate.x as Use / Cache / Analyze / TruncateTable, and orders.order_id;DROP TABLE t as a Block.
None of these are in the tuple, so they return []: no error and no "does not parse" warning, and use.no_such_field passes. The docstring says a keyword named dataset read as a statement should return None.

Instead of growing the list, parsing into exp.Condition makes sqlglot read the input as an expression:

Suggested change
tree = sqlglot.parse_one(expression)
tree = sqlglot.parse_one(expression, into=sqlglot.exp.Condition)

Could we add use.no_such_field and orders.order_id; DROP TABLE t to the tests?

Comment thread validation/validate.py
Comment on lines +927 to +928
try:
text = path.read_text(encoding="utf-8")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The iri is followed to whatever is on disk, and only the error from a failed read_text is checked:

  • iri: /dev/zero reads until the process runs out of memory.
  • iri: /dev/stdin, or a FIFO, blocks forever.

CI runs validate.py examples/flights.mapping.yaml, so a change to an example could hang the job. Checking for a regular file before opening it fixes both. Doing the stat inside the existing try keeps the current message for a missing file:

Suggested change
try:
text = path.read_text(encoding="utf-8")
try:
if not stat.S_ISREG(path.stat().st_mode):
return None, [f"[Reference] {label} iri '{iri}' does not name a regular file"]
text = path.read_text(encoding="utf-8")

This needs import stat. The %00 case in test_ontology.py will need its expected text updated, because stat() reports embedded null character in path.

One question: should the iri be allowed to leave the repository? ../../../home/u/notes.yaml gets read, and its name: is echoed in the mismatch error. ../ within the repo is legitimate, so I would not block on it, but I would like to known whether it's intended.

…tions, open only regular files

Declared dataset and field names were always upper-cased, so a name
that is not a regular identifier, such as a dataset "order items",
could never be referenced. A declared name is now normalized the way a
reference to it is: upper-cased when the parser reads it unquoted as
the second part of a reference, kept as written otherwise. The parser
decides rather than an ASCII pattern, so names such as été and ß, which
a reference can write unquoted, stay regular, and so does a keyword
such as from.

field_references parsed with the default entry point and rejected a
fixed list of statement types, which missed Use, Cache, Analyze,
TruncateTable and a semicolon-separated Block, so use.no_such_field
passed unchecked. The expression is now parsed as a condition, so a
dataset named like a keyword reads as a column and a statement does not
parse; expressions separated by semicolons are still rejected.

resolve_document_reference followed the iri to whatever was on disk, so
an iri naming /dev/zero read until memory ran out and one naming a pipe
blocked. A path that is not a regular file is now an error and is not
opened. The message for a path with an embedded NUL is fixed text, since
its wording from stat() differs between Python versions.

Generated-by: Claude Code
@kayemkim

Copy link
Copy Markdown
Contributor Author

Pushed a fifth commit for the three.

  • Declared names go through a declared_name helper next to normalized_identifier, so "order items"."Order Id" resolves and "ORDER ITEMS"."ORDER ID" does not, with a test for that pair. One change from the suggestion: to_identifier decides with an ASCII pattern, so été, ß or $x would be kept as written while ds.été parses unquoted and is upper-cased, and that reference would stop matching. declared_name asks the parser instead: a name that reads unquoted as the second part of x.<name> is upper-cased, anything else is kept as written. That also keeps a field named from or case regular. A keyword that cannot open a reference, such as a dataset named alter, still has to be written "ALTER", as before.
  • field_references parses into exp.Condition, and the statement list is gone. use.no_such_field now reads as a column and is reported, and so do cache, analyze and truncate. orders.order_id; DROP TABLE t no longer parses, but orders.order_id; orders.customer_id still comes back as a Block, so that one is rejected explicitly. Both are in the tests.
  • The iri has to name a regular file before it is opened, with tests for a directory and /dev/null. The %00 case keeps its old text, since stat() words it differently on 3.11 and on 3.12 and later.

On the iri leaving the repository: that is not intended, it just follows the path it is given. I'd leave this PR as it is and take up a limit separately if we want one.

377 tests; the validation workflow passes locally on current main, and the branch merges cleanly with #647 as well.

@kayemkim
kayemkim requested a review from jbonofre October 29, 2026 04:14
Comment thread validation/validate.py
Comment on lines +968 to +969
except RecursionError:
return None, [f"[Reference] {label} iri '{iri}' is too deeply nested to parse"]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A value that PyYAML resolves to a tag but cannot build gets past both branches, because SafeLoader's constructors don't wrap the error in YAMLError:

  • 2024-02-30 and !!int abc raise ValueError
  • !!timestamp abc raises AttributeError
  • !!bool abc raises KeyError

With description: 2024-02-30 in the referenced ontology, the error escapes validation_mapping, and validate_file reports Error: ValueError while validating <mapping> with a traceback, as a validator bug. It never names the ontology file, and the rest of the cross-checks are lost.

Suggested change
except RecursionError:
return None, [f"[Reference] {label} iri '{iri}' is too deeply nested to parse"]
except RecursionError:
return None, [f"[Reference] {label} iri '{iri}' is too deeply nested to parse"]
except (ValueError, AttributeError, KeyError) as e:
# SafeLoader raises these, not YAMLError, for a scalar it cannot build:
# 2024-02-30 or !!int abc, !!timestamp abc, !!bool abc.
return None, [f"[Reference] {label} iri '{iri}' holds a value YAML cannot build: {e}"]

Could we add a test with 2024-02-30 in the referenced document?

validate_file has the same gap for the document itself, but that one is already on main, so it can be a separate one.

Comment thread validation/validate.py Outdated
Comment on lines +1116 to +1119
if fields is not None and "fields" in dataset:
fields = fields | {declared_name(f["name"]) for f in dataset["fields"]}
else:
fields = None

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A dataset with no fields list turns the check off for every reference to it, so customers.anything passes, and so does any typo. The test says a dataset without fields "cannot be checked", but I think it can: ontology.md (Object mappings) defines mapping expressions as "SQL expressions over the fields of one or more datasets", and spec.md (Name Resolution) says a dataset that declares no fields exposes nothing to metrics and relationships.

The core checks already read a missing list as no fields.

Since datasets are pooled by normalized name, a fields less ORDERS also turns the check off for orders.

Readiing a missing list as empty fixes both:

Suggested change
if fields is not None and "fields" in dataset:
fields = fields | {declared_name(f["name"]) for f in dataset["fields"]}
else:
fields = None
fields = fields | {declared_name(f["name"]) for f in dataset.get("fields", [])}

…and empty expressions

SafeLoader raises ValueError, AttributeError, KeyError or IndexError
rather than YAMLError for a scalar it resolves to a tag but cannot
build, such as 2024-02-30, !!int abc or !!int '', so a referenced
document holding one escaped as a validator error. It is now reported
against the reference. An iri with a lone surrogate was reported as an
embedded null byte and is now reported as not a valid file name.

A dataset that declares no fields list turned the field check off for
its name, also for a dataset of the same name that declares fields. It
now exposes no fields, as spec.md (Name Resolution) says and as the
core checks read it.

An empty, whitespace-only or comment-only mapping expression was
skipped with a warning and passed. It is now an error, as the core
checks report an empty expression, whether or not the semantic model
could be read.

Generated-by: Claude Code
@kayemkim

Copy link
Copy Markdown
Contributor Author

Pushed a sixth commit for the two.

  • A referenced document holding a value YAML cannot build is now reported against its reference, with your except clause and a test for each value. I widened KeyError to LookupError, since !!int '' and !!float '' raise IndexError from the same constructors. An iri with a lone surrogate was reported as an embedded null byte, because UnicodeEncodeError is a ValueError; it now says it is not a valid file name.
  • A dataset without a fields list now exposes no fields, so customers.anything is reported and a fields-less ORDERS no longer turns the check off for orders. The test that pinned the old behaviour now asserts the error.

Going over the same paths I found one more: an empty or comment-only mapping expression was a warning and the document passed. It is now an error, worded like the core check.

I'll open a separate PR for the validate_file gap on the document itself. Printing a diagnostic that holds a lone surrogate fails there too, on main as well, so it can go in the same one.

390 tests; the validation workflow passes locally on current main, and the branch still merges cleanly with #647.

@kayemkim
kayemkim requested a review from jbonofre October 29, 2026 10:58

@jbonofre jbonofre left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

I checked that lambda parameters and UNNEST aliases (x -> x.id, AS t(value)) are not reported as dataset references, so those common expression forms validate correctly.

@jbonofre
jbonofre merged commit b46b2c9 into apache:main Oct 29, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants