Repository navigation
feat(validation): cross-check a mapping document against the documents it references - #595
Conversation
|
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 flights.mapping.yaml still passes. With |
| r"|/\*.*?\*/", # block comment | ||
| re.DOTALL, | ||
| ) | ||
| _FIELD_REFERENCE = re.compile(r"(?<![\w.])([A-Za-z_]\w*)\.([A-Za-z_]\w*)(?![\w.]|\s*\()") |
There was a problem hiding this comment.
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)andorders.order_id::pg_catalog.int4report "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.
| 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: |
There was a problem hiding this comment.
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.
| "[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'", |
There was a problem hiding this comment.
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?
| 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 |
There was a problem hiding this comment.
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.
| f"[Reference] Warning: {label} '{name}' has no iri, so only a catalog can " | ||
| f"resolve it: {skipped}" | ||
| )] | ||
| parts = urlsplit(iri) |
There was a problem hiding this comment.
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.
|
Pushed a third commit that takes all five.
One consequence worth a look. Two Flight link mappings in 267 tests, 34 of them new, and the validation workflow passes locally on current main. |
| 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: |
There was a problem hiding this comment.
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?
| if not errors and isinstance(data, dict) and "concept_mappings" in data: | ||
| errors.extend(validate_mapping(data, base)) |
There was a problem hiding this comment.
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.
|
Pushed one more commit for the two.
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
2297c70 to
bd855d3
Compare
|
Rebased on main now that #617 is in. The two conflicts were import lines; nothing else in the four commits changed. |
| 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) |
There was a problem hiding this comment.
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 theORDER ITEMS/ORDER IDkeys, so it fails.- Only
"ORDERS ITEMS"."ORDER ID"passes, which byexpression_language.mdnames 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:
| 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?
| like a keyword, such as from.x, otherwise would be. Needs sqlglot. | ||
| """ | ||
| try: | ||
| tree = sqlglot.parse_one(expression) |
There was a problem hiding this comment.
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:
| 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?
| try: | ||
| text = path.read_text(encoding="utf-8") |
There was a problem hiding this comment.
The iri is followed to whatever is on disk, and only the error from a failed read_text is checked:
iri: /dev/zeroreads 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:
| 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
|
Pushed a fifth commit for the three.
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. |
| except RecursionError: | ||
| return None, [f"[Reference] {label} iri '{iri}' is too deeply nested to parse"] |
There was a problem hiding this comment.
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-30and!!int abcraiseValueError!!timestamp abcraisesAttributeError!!bool abcraisesKeyError
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.
| 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.
| if fields is not None and "fields" in dataset: | ||
| fields = fields | {declared_name(f["name"]) for f in dataset["fields"]} | ||
| else: | ||
| fields = None |
There was a problem hiding this comment.
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:
| 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
|
Pushed a sixth commit for the two.
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 390 tests; the validation workflow passes locally on current main, and the branch still merges cleanly with #647. |
jbonofre
left a comment
There was a problem hiding this comment.
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.
Summary
A mapping document names its ontology and semantic model through
ontology_refandsemantic_model_ref, butvalidate.py --schema ontology/mapping.jsonchecked only the mapping's own shape. On current main, a copy ofexamples/flights.mapping.yamlwithsemantic_model_ref.namechanged toFlights model, the first concept changed toRunwyandRUNWAY.designatorchanged toRUNWAY.designaterstill reportsValidation PASSED.The validator now follows each relative
irifrom the mapping file's own directory, as ontology.md specifies, and checks that the document found carries thenamethe reference says, that every concept the mapping names is declared in that ontology or is a built-in concept, and that everydataset.fieldan expression names is a declared field of that semantic model. The copy above now fails with a[Reference]error for each edit, two forRUNWAY.designatersince 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, aniriwith a scheme, or a relative one on a document validated without a location to resolve against. Theiriis 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 nofieldslist 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_CONCEPTSfrom #489. The key coverage warning raised on that thread is not included, for the reason Shao gave there.Verification
validation/tests/: 227 passed, 26 of them new, also in random order. The validation workflow run locally against current main (9531429).examples/flights.mapping.yamlpasses with no warnings. The three-edit copy above reports four errors, as described. The other examples are unaffected.validate.pyor its tests: no conflicts, and no test changes outcome except the one feat(core-spec): support dataset-scoped metrics #591 already has on its own.Related Issues
dev@ thread: https://lists.apache.org/thread/9op4mr1qfb6o0c229shdbnzp44jp4wxd
Checklist
Tests
pytest/ CI green)Compliance