Skip to content

Add ODCS type: library quality metric support - #1485

Open
Christopher-Lawford wants to merge 2 commits into
databrickslabs:mainfrom
Christopher-Lawford:worktree-odcs-library-metrics-wayfinder
Open

Add ODCS type: library quality metric support#1485
Christopher-Lawford wants to merge 2 commits into
databrickslabs:mainfrom
Christopher-Lawford:worktree-odcs-library-metrics-wayfinder

Conversation

@Christopher-Lawford

@Christopher-Lawford Christopher-Lawford commented Aug 25, 2026

Copy link
Copy Markdown

Summary

  • Adds native mapping from ODCS type: library quality entries to DQX checks for the five supported metrics: rowCount, nullValues, missingValues, invalidValues, and duplicateValues.
  • Each metric maps its eight shared ODCS threshold fields (mustBe, mustNotBe, mustBeGreaterOrEqualTo, mustBeLessOrEqualTo, mustBeGreaterThan, mustBeLessThan, mustBeBetween, mustNotBeBetween) onto exact-fit DQX aggregate checks where possible, with a dataset-level sql_query fallback for strict inequalities and the (both-bounds-exclusive, per ODCS) mustBeBetween/mustNotBeBetween forms.
  • Malformed or unrecognized type: library entries (missing/unknown metric, no recognized threshold field, malformed arguments, unrecognized unit, misplaced property/schema-level entries) are warned-and-skipped per entry rather than failing the whole contract; this processing is unconditional, with no opt-out flag.
  • Adds end-to-end integration coverage generating and applying rules from a contract exercising all five metrics.
  • Adds a new "Metric Rule Generation" guide section (and updates the "Metadata Fields" table) documenting the feature for end users.

Test plan

  • tests/unit/test_datacontract_generator.py::TestDataContractGeneratorLibraryRules — unit coverage per metric/threshold-field combination
  • tests/unit/test_checks_semantic_validator.py — updated coverage for the semantic validator changes
  • tests/integration/test_datacontract_integration.py — end-to-end generation + apply_checks_by_metadata against a real DataFrame for all five metrics
  • make test / make lint run clean on this branch (please confirm in CI)

🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

All commits in PR should be signed ('git commit -S ...'). See https://docs.github.com/en/authentication/managing-commit-signature-verification/signing-commits

@CLAassistant

CLAassistant commented Aug 25, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@Christopher-Lawford
Christopher-Lawford force-pushed the worktree-odcs-library-metrics-wayfinder branch from 828ba12 to 04aaf51 Compare August 26, 2026 09:14
@Christopher-Lawford
Christopher-Lawford marked this pull request as ready for review August 26, 2026 09:16
@Christopher-Lawford
Christopher-Lawford requested a review from a team as a code owner August 26, 2026 09:16
@Christopher-Lawford
Christopher-Lawford requested review from pratikk-databricks and removed request for a team August 26, 2026 09:16
@mwojtyczka mwojtyczka added the under-review This PR is currently being reviewed by one of DQX maintainers. label Sep 1, 2026

- **`mustBe: 0`** is special-cased for `nullValues`, `missingValues`, `invalidValues`, and `duplicateValues`: it maps onto a cheap **row-level** check (`is_not_null`, `is_not_in_list`, `is_in_list`/`regex_match`, or `is_unique`) that pinpoints the offending rows, rather than a dataset-level count.
- **`mustBe`, `mustNotBe`, `mustBeGreaterOrEqualTo`, `mustBeLessOrEqualTo`** (including `mustBe` with a non-zero value) map onto exact-fit **dataset-level aggregate checks** — `is_aggr_equal`, `is_aggr_not_equal`, `is_aggr_not_less_than`, `is_aggr_not_greater_than` — over the metric's count or percentage.
- **`mustBeGreaterThan`, `mustBeLessThan`, `mustBeBetween`, `mustNotBeBetween`** have no strict/exclusive-bound equivalent among DQX's aggregate checks, so they fall back to a dataset-level [`sql_query`](/docs/reference/quality_checks#using-sql-query) check with `condition_column: "condition"` (`true` means a violation). For `mustBeBetween`/`mustNotBeBetween`, **both bounds are exclusive**, per the ODCS specification — a value exactly equal to either bound does not count as being "between" them.

@mwojtyczka mwojtyczka Sep 1, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Out of scope for this PR (should be a follow up): I would implement the missing functions and replace the sql query:

  • mustBeGreaterThan (enforce metric > X): new function is_aggr_greater_than(limit=X)
  • mustBeLessThan (enforce metric < X): new function is_aggr_less_than(limit=X)
  • mustBeBetween (enforce lo < metric < hi): new function is_aggr_in_range(lo, hi)
  • mustNotBeBetween (enforce metric ≤ lo OR metric ≥ hi): new function is_aggr_not_in_range(lo, hi)

Can you please create a follow up issue for this?

@mwojtyczka mwojtyczka left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Automated code review — ODCS type: library quality metrics

Verified against head d2b6471. One serious correctness bug plus a serialization/validation cluster and a few lower-severity consistency issues. Details are in the inline comments; summary:

Severity Finding
🔴 High duplicateValues (any non-zero threshold) nests COUNT(*) OVER (...) inside a SUM/AVG aggregate → Spark AnalysisException at apply time. Only mustBe: 0 is execution-tested.
🟠 Med nullValues percent and missingValues forbidden embed live F.when/F.lit Column objects in the generated rule dicts → not YAML/JSON-serializable (save_checks fails). Other percent paths already use SQL strings to avoid this.
🟠 Med Because of the above, ChecksSemanticValidator silently skips conflict detection for those rules (unhashable Column in the key → TypeError swallowed).
🟡 Low RLIKE string literals don't escape backslashes → regex patterns mangled vs the row-level regex_match path.
🟡 Low Numeric validValues stringified into NOT IN ('..') → string vs numeric comparison mismatch with is_in_list.
🟡 Low mustBe == 0 matches boolean False, misses string "0".
🟡 Low Multiple rowCount entries on one schema collide on rule name.

Cleared: the sql_query fallback for mustBeGreaterThan/mustBeLessThan/mustBeBetween/mustNotBeBetween is correct — DQX has no strict-inequality or between aggregate check (only is_aggr_not_greater_than/not_less_than/equal/not_equal). SQL-injection via threshold interpolation is not viable — those fields are typed float | int.

partition_by = ", ".join(quoted)
value = "1" if unit == "rows" else "100.0"
return (
f"CASE WHEN {not_null_clause} AND COUNT(*) OVER (PARTITION BY {partition_by}) > 1 "

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Window function nested inside an aggregate — Spark rejects this at apply time.

_duplicate_indicator_sql builds CASE WHEN … COUNT(*) OVER (PARTITION BY …) > 1 …, and _duplicate_values_aggregate_check (just below) wraps it in is_aggr_equal/etc. with aggr_type sum/avg. That produces SUM(CASE WHEN … COUNT(*) OVER (PARTITION BY …) …), which Spark forbids: AnalysisException: It is not allowed to use window functions inside aggregate functions.

Every duplicateValues threshold except mustBe: 0 (which routes to is_unique) hits this path, and it is only asserted as a generated string in unit tests — never executed. Recommend (a) an integration test that actually applies a non-zero duplicateValues rule, and (b) a rewrite that derives the duplicate count without nesting a window inside an aggregate (e.g. aggregate over a pre-grouped count).

row_filter is used for the percentage fallback either, so the denominator stays the full
row count rather than shrinking to just the null rows.
"""
indicator = F.when(F.expr(f"{quoted_column} IS NULL"), F.lit(100.0)).otherwise(F.lit(0.0))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-serializable Column embedded in a metadata rule dict.

This F.when(...) Column is passed straight into _nullvalues_percent_aggregate_check as the check's column argument, so the generated rule dict carries a live PySpark Column. It cannot round-trip through YAML/JSON, so DQEngine.save_checks() (or any serialization of the generated rules) fails.

The missingValues/invalidValues percent paths deliberately use an AVG(CASE WHEN …) SQL string for exactly this reason (see the class comment near line 2508). This path — and the missingValues forbidden list (line ~2607) — were missed. Use the same SQL-string indicator here so the rule stays serializable.

"function": "is_not_in_list",
"arguments": {
"column": property_name,
"forbidden": [F.lit(value) for value in non_null_sentinels],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same serialization issue as the nullValues percent path.

forbidden holds F.lit(...) Column objects, so the generated is_not_in_list rule dict is not YAML/JSON-serializable. Pass plain scalar literals instead (the F.lit/resolver conversion happens at apply time), so the rule can round-trip through save_checks().

column = arguments.get("column")
if column is None:
column = arguments.get("columns")
if column is None or (isinstance(column, (str, list)) and not column):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Conflict detection is silently skipped for Column-valued arguments.

When a generated check carries a Column as its column argument (the nullValues-percent path in contract_rules_generator.py), _make_hashable returns the Column unchanged (it is neither list/tuple/dict), so the _conflict_key tuple contains an unhashable Column. In _conflict_issue, conflict_key not in seen then raises TypeError, which detect_conflicts catches and skips — so two genuinely conflicting generated rules on the same column are never flagged.

(Duplicate detection via _full_key is unaffected — it stringifies through json.dumps(default=str).) Fixing the root cause — keeping generated column args as SQL strings rather than Column objects — resolves this as well.

return " OR ".join(clauses)

@staticmethod
def _invalid_values_sql_literal(value: Any) -> str: # value: any contract-supplied scalar (str, number, bool)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

RLIKE patterns with backslashes get mangled.

_invalid_values_sql_literal only doubles single quotes; it does not escape backslashes. With Spark's default spark.sql.parser.escapedStringLiterals=false, a backslash in the literal acts as an escape character, so a regex like \d+ delivered via col RLIKE '\d+' differs from the raw pattern the row-level regex_match check (the mustBe: 0 path) uses. The aggregate/percent invalid-count then disagrees with the row-level rule. Escape backslashes too, or build the RLIKE via a bound parameter rather than string interpolation.

clauses = []
if valid_values is not None:
escaped_values = ", ".join(self._invalid_values_sql_literal(value) for value in valid_values)
clauses.append(f"{quoted_column} NOT IN ({escaped_values})")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Numeric validValues are stringified into the NOT IN list.

_invalid_values_sql_literal wraps every value in single quotes, so a numeric allow-list becomes col NOT IN ('1','2','3') (string comparison), while the row-level is_in_list path (mustBe: 0) passes the numbers through unchanged. On numeric columns the two paths can classify rows differently, so the aggregate count/percentage disagrees with the row-level rule. Preserve numeric literals unquoted.

threshold requires a resolved unit (rows/percent) to build the duplicate-count indicator;
returns None (after logging) when no threshold field is set, or when unit is missing/unrecognized.
"""
if quality_rule.mustBe == 0:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

mustBe == 0 special-cases boolean False and misses numeric strings.

mustBe is Any-typed. False == 0 is True, so mustBe: false is routed into the is_unique fast path; conversely mustBe: "0" (string) fails == 0 and silently drops into the aggregate path with a string limit. Consider validating/normalizing the threshold type before this comparison. (Low severity.)

return [
{
"check": check_dict,
"name": f"{schema_name}_rowCount",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Colliding rule names for multiple rowCount entries.

The name is always f"{schema_name}_rowCount", so two type: library rowCount entries on one schema (e.g. a lower and an upper bound) both get named <schema>_rowCount. Downstream tooling keyed on rule name (dedup, reporting, user_metadata joins) can't distinguish them. Include the threshold field or an index in the name. (Low severity.)

@vb-dbrks
vb-dbrks self-requested a review September 1, 2026 08:57

@mwojtyczka mwojtyczka left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Going in the right direction. Left some comments

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

Labels

needs-changes Changes required after review under-review This PR is currently being reviewed by one of DQX maintainers.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEATURE]: Generate DQX checks from ODCS "library" quality metrics (rowCount, nullValues, missingValues, invalidValues, duplicateValues)

3 participants