feat(salesforce): shared-engine support for contract checks on an org (R-642) - #2829
feat(salesforce): shared-engine support for contract checks on an org (R-642)#2829Niels-b wants to merge 13 commits into
Conversation
|
Dependency note: the Salesforce side is sodadata/soda-extensions#565, which imports 🤖 Generated with Claude Code |
| f"'{self.data_source_impl.type_name}' cannot sample rows. No checks were evaluated. " | ||
| f"Remove the sampling configuration for this dataset to run them against all rows." | ||
| ) | ||
| else: |
There was a problem hiding this comment.
Suggest extracting this to a helper function to avoid nesting even more
There was a problem hiding this comment.
Both arms extracted: _execute_queries() and _log_sampling_refusal(). The branch is now two lines and reads as the decision it is. The nesting was this PR's doing — the loop used to sit directly under the elif — so it seemed right to take the suggestion rather than defend it.
| @@ -281,6 +281,23 @@ def get_required_metric_impls(self) -> list[MetricImpl]: | |||
| return [self.row_count_metric_impl, self.multi_column_distinct_count_metric_impl] | |||
|
|
|||
| def evaluate(self, measurement_values: MeasurementValues) -> CheckResult: | |||
There was a problem hiding this comment.
This validates at check evaluation time, but hasn't the query already happened at this point?
There was a problem hiding this comment.
Correct, and it was the most useful comment on the PR. The execute loop completes before the evaluate loop, so the row-hash query was being sent and refused before the guard ran.
Moved to parse_check via a new CheckImpl.unsupported_reason() hook, so no metric is built and nothing is sent. It also collapses the two refusal mechanisms you flag below into one, with the condition living on the check rather than the data source.
| ] = self.data_source_impl.sql_dialect.build_column_metadatas_from_query_result(query_result) | ||
| except Exception as e: | ||
| logger.error(f"Error building column metadata from query result: {e}") | ||
| if not metadata_columns: |
There was a problem hiding this comment.
What about a table without columns? I guess most data sources may not support that, but apparently postgres does
There was a problem hiding this comment.
Moot now — the zero-columns error is removed entirely (see m1n0's thread on the same block). Worth saying the question was a real one: Postgres does allow CREATE TABLE t(), and the guard would have called that ERROR.
| return False | ||
|
|
||
| @property | ||
| def supported_check_types(self) -> Optional[frozenset[str]]: |
There was a problem hiding this comment.
Might be out of scope for this PR, but I started poking at the flow & number of properties we're creating here. supported_check_types, supports_row_hashing, supports_row_sampling all fall into this category, and there's probably more.
The design feels clunky with a data source having to define what checks it supports. Logic about what capabilities a check requires should live in the check IMO. On top of this property, we have duplicate check doing exactly that conditionally. It asserts supports_row_hashing, but only if there are multiple columns involved.
Claude came up with an approach of having both sides define capabilities:
class CheckImpl: def required_capabilities(self) -> frozenset[Capability]: ... # multi-column duplicate returns {ROW_HASHING}; single-column returns empty class DataSourceImpl: def capabilities(self) -> frozenset[Capability]: ...The engine does one thing, in parse_check, still before setup_metrics:
missing = check_impl.required_capabilities() - data_source_impl.capabilities() if missing: # refuse: "duplicate check on (a, b) requires row hashing, # which <source> does not support"
This seems like a better design to me than what we have today, but likely involves a bigger change
There was a problem hiding this comment.
Agreed on the direction, and parse_check is the right seam.
This PR takes the first step: all refusals now resolve there, and the multi-column condition moved onto the check class — your "logic about what a check requires should live in the check" point. CheckImpl.unsupported_reason() is your required_capabilities() with a string instead of an enum. The rest is follow-up work.
One thing worth recording from doing it: hashing and sampling unify cleanly as capabilities, but supported_check_types does not. "SOQL cannot do group_by" is a check type nobody has verified against the source, not a capability a check can name a requirement for — forcing it into the same enum means inventing one capability per check type. That is the design question the follow-up has to answer, and it is why this is not a one-hour change.
There was a problem hiding this comment.
As was discussed this morning; we'll should do that properly at a later time.
m1n0
left a comment
There was a problem hiding this comment.
Approving — guards are correctly scoped and every hook #565 overrides lands on the class core actually consults. Fix the description before merging though:
- "A contract against a non-existent dataset becomes loud" isn't what changes. That's already ERROR/exit 3 on main:
row_countalways runs an aggregation and a missing table throws there. Ran a schema-only contract on a missing table on both trees — ERROR both. The real flip is a queryable dataset whose metadata query resolves zero columns (prefix/casing mismatch — DuckDB with a 2-part DQN does it): UNKNOWN/exit 0 → ERROR/exit 3. Right call, but say that, and add acustomer-facing-release-noteblock for it — there's none. - The gate isn't contract-only. Data standards go through
CheckCollectionImpl._parse_checkstoo, andgroup_byregisters into the sharedCheckImpl.check_parsers, so it's gated as well. Only recon's ownparse_checkescapes. - Your comment about #565 importing
SODA_FAILED_ROWS_CTE_NAMEis stale since the revert.
| # and the CLI exiting 0. Reporting it here is what keeps a metadata failure visible for a | ||
| # data source whose accessor absorbs its own exceptions and returns an empty list | ||
| # (Databricks warns and does exactly that). It also makes a contract against a | ||
| # non-existent dataset loud on every source, where it was previously a clean scan. |
There was a problem hiding this comment.
Not the case — this path was already ERROR on main via the row_count aggregation. The new case is zero columns on a queryable dataset.
There was a problem hiding this comment.
You were right, and the change is removed rather than reworded. Outcomes now match main on every source, including zero resolved columns — so there is no behaviour change to release-note.
Removing it did expose one thing worth knowing: routing the schema check through get_columns_metadata() is still needed for a source whose metadata is not SQL, but Databricks overrides that accessor to absorb exceptions for the bulk-metadata sweep. So the routing alone would have quietened a metadata failure that used to be loud on that one source. get_schema_check_columns_metadata() separates the two audiences — the sweep keeps its empty list, the schema check sees the failure — and Databricks overrides it. Pinned by a mutation-checked unit test.
| # data source whose accessor absorbs its own exceptions and returns an empty list | ||
| # (Databricks warns and does exactly that). It also makes a contract against a | ||
| # non-existent dataset loud on every source, where it was previously a clean scan. | ||
| logger.error(msg=f"No columns metadata resolved for '{self.dataset_name}'") |
There was a problem hiding this comment.
Add the prefixes here — the failure mode is a schema/prefix mis-split and this line doesn't say where it looked. Also this now logs three ERROR lines together with the Actual columns are None pair from evaluate().
There was a problem hiding this comment.
Prefixes added, on the surviving message — the line you commented on goes away with the zero-columns error.
The pile-up goes with it on that path: without the error, execute() returns a measurement carrying an empty list, so get_value yields [] rather than None and the evaluate() pair never fires. That path drops from three ERROR lines to zero. The pair still fires on the exception path, which is pre-existing on main, so I left it alone.
| unsupported on such a source until someone has verified it. | ||
|
|
||
| Enforced where contract checks are parsed, which is every check the contract language exposes. | ||
| Reconciliation parses its own checks and is not gated here; a recon diff type listed in the set |
There was a problem hiding this comment.
Also gates data standards and group_by (shared check_parsers registry). Only recon escapes via its own parse_check.
There was a problem hiding this comment.
Corrected — and it reaches further than either of us said. MonitoringImpl and MonitoringBackfillImpl in soda-metric-monitoring are CheckCollectionImpl subclasses with no parse_check of their own, so metric monitoring is gated too.
The docstring now states the rule once — every check type in the check_parsers registry, for every CheckCollectionImpl subtype — rather than listing kinds and getting the list wrong. A test pinning the data-standards half is in sodadata/soda-extensions#565, since soda-data-standard is packaged there.
SchemaQuery rebuilt the information_schema SQL itself, duplicating the three steps DataSourceImpl.get_columns_metadata() already performs and bypassing any override of it. A source whose metadata does not come from SQL — Salesforce reads the Describe API — could therefore never be reached by the schema check. Routing through the accessor also moves the SQL build from __init__ to execute(), so a builder that raises (sparkdf rejects more than two prefixes) now leaves one check NOT_EVALUATED instead of aborting the whole verification. Zero resolved columns is reported as an error. evaluate() only logs for a *missing* measurement; an empty one falls through silently, leaving the scan UNKNOWN and the CLI exiting 0. That matters for a source whose accessor absorbs its own exceptions and returns an empty list, and it makes a contract against a non-existent dataset loud on every source. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A multi-column duplicate count is derived as row_count - distinct_count, and the distinct count comes from a row hash. A source whose query language cannot express one leaves that metric unmeasured, which convert_db_value coerces to 0 — making duplicate_count equal row_count and reporting every row as a duplicate. Measured on a Salesforce fixture with zero duplicates: FAILED at 100%. Guarded on supports_row_hashing, matching the guard reconciliation's duplicate_diff already applies. The flag defaults True, so no SQL source is affected; the accompanying test patches it and runs on every data source. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sampling was requested but silently unavailable on a source whose query language has no row sample. Two coordinated guards, keyed on the dialect's own supports_row_sampling() which defaults True: At construction the sampler is not attached. This is the load-bearing half: without it .SAMPLE() reaches the dialect's _build_sample_sql, whose default raises NotImplementedError inside CheckCollectionImpl.__init__ — before the execute loop exists to catch anything — and the whole verification aborts. At execution the queries are refused and the reason reported, which turns the refusal into NOT_EVALUATED for every check on the dataset rather than a raised exception. Sampling is configured per dataset, so honouring some checks unsampled would be worse than honouring none. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The count query's CTE alias was a bare literal in soda-core and a copy of the same literal in the Salesforce connector, which recognises this shape to reduce the count to a server-side aggregate. A rename on either side would have rerouted those queries silently, so the alias moves to a shared constant next to SODA_FILTERED_CTE_NAME and the connector keys on it. The over-threshold warning gains one sentence. Its existing advice — rewrite the query so it can be CTE-wrapped — is unachievable on a source with no CTEs, so it now also names the shape that can be counted server-side. The original sentence is kept: it is still the right advice everywhere CTEs exist. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The module had no min or max case at all, and sum only in its filtered variant — so three of the four documented aggregate functions were asserted nowhere, for any data source. Each renders as its own function in a translating dialect, so one regressing would have been invisible. Adds min, max, sum and a filtered max, on the fixture's existing rows. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ck advice" This reverts 917207f. Its only consumer was the Salesforce connector's server-side reduction of a user's `failed_rows: query:`, and that reduction has been removed: a user's own query now runs as written. With nothing keying on the alias there is no drift to guard against, so the constant is churn. The appended advice went with it — it described the shape the reduction recognised, which no longer means anything. soda-core's shared footprint for this workstream drops from five files to three. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
_build_v4_diagnostics_check_type_json_dict dispatches partly on the check TYPE
and partly on the result CLASS. The type-matched branches call .get() on
diagnostic_metric_values; the class-matched ones (schema, freshness) fall
through to the else-arm, which required either None or a SodaCloudJsonable and
raised "Unrecognized check result type" on a plain dict.
So a check result with no diagnostics had no representation that worked for
both sets: None crashed the first, {} crashed the second. Treating falsy as
"nothing to report" gives one — a check that was never evaluated carries no
diagnostics whatever its type.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A source that speaks something other than SQL can only answer part of the contract language. Until now the unanswerable part failed deep inside execution, surfacing as whatever the source said — a raw MALFORMED_QUERY, for instance, which reads like an engine defect rather than a boundary. DataSourceImpl.supported_check_types defaults to None, no restriction, so every existing source keeps running every registered check type. A source that declares a set gets checks outside it refused at parse time, before any metric or query is built: some check types run a user-written query, so reaching setup would send it to a source that cannot run it. The refusal reports NOT_EVALUATED rather than EXCLUDED — EXCLUDED means the user deselected the check, which is a different statement from "you asked for this and are not getting it" — and logs an error, so the contract lands on ERROR (exit 3) rather than UNKNOWN (exit 0). Unlike a NOT_EVALUATED from an unmeasured metric, which may succeed next run, this one is permanent: the check will never evaluate on this source until someone edits the contract. Exiting 0 would leave the contract quietly asserting less than it claims. Sources declare the whole supported set rather than the gaps, so a check type added to soda-core is unsupported until someone has verified it there. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both guards were defended by assertions that held for the wrong reason. test_schema_check asserted result.has_errors, which a verification already satisfies from unrelated errors of its own — silencing the guard entirely left the test green. test_duplicate_dataset_check asserted only the outcome and the absent number, so downgrading the reason to debug would have produced a silent NOT_EVALUATED with a clean exit, which is what the guard exists to prevent. Both now assert the message itself via caplog. No set_level: that would raise the level for every test in the run, and the assertions pass without it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Six review points from mivds and m1n0, plus one internal review pass. Schema check keeps main's behaviour. The zero-columns error is removed: it was the one intentional cross-source behaviour change, and restoring the previous outcome is preferred over closing the silent-scan hole here. Routing through get_columns_metadata() is still needed so a source whose metadata is not SQL can override it — but that alone would have made Databricks quieter, because its override absorbs exceptions for the bulk-metadata sweep. get_schema_check_columns_metadata() separates the two audiences: the sweep keeps its empty list, the schema check sees the failure. Databricks overrides it; every other source inherits the default and is untouched. Sampling capability is now a plain question about the data source rather than a compound one. data_source_supports_sampling answers only "can it sample", leaving the "was sampling requested" half at the two call sites that already ask it, and removing a double negative. True when there is no data source: nothing is queried, so nothing is refused — which was the unexplained branch mivds asked about. The two arms of that branch are extracted so the decision reads in place. Duplicate hashing is refused at parse time. The guard ran in evaluate(), after the execute loop had already sent a query the source cannot answer. It now resolves through CheckImpl.unsupported_reason(), the same field the declared check-type gate sets — one refusal mechanism instead of two, and the condition lives on the check rather than the data source. The message says "dataset-level" rather than "multi-column", because the hash is emitted even for `columns: [one]` (_build_combined_hash_sql keeps STRING_HASH for a single column), and it names the per-column form as an alternative along with its NULL-semantics caveat. The supported_check_types docstring stated the gate's reach as a partial list. It is enforced in the shared CheckImpl.parse_check, so it covers every CheckCollectionImpl subtype — contracts, data standards, metric monitoring — and every parser in the registry. Only reconciliation escapes. The columns-metadata failure message now names the prefixes it searched: a prefix mis-split is the likely cause and the message did not say where it looked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
4f57502 to
472bd73
Compare
m1n0
left a comment
There was a problem hiding this comment.
Re-ran the probe on the new head vs main: all four rows match, including zero resolved columns — so there's no behaviour change left to release-note. unsupported_reason() in parse_check is the right seam and the Databricks split keeps metadata failures loud. Approving.
Leftovers, none blocking: the CTE dependency comment on this PR is still up and still wrong; test_sampling_unsupported.py still calls caplog.set_level; the construction-time comment in base.py (~595) still describes the old sampling_is_unsupported framing.
Two leftovers from splitting `sampling_is_unsupported` into a pure capability. The construction-time comment still read as though one negative guard existed and pointed at "the execute-time guard below", which is now the extracted `_log_sampling_refusal`. Reworded to the positive framing the condition actually uses: the CTE is sampled only when sampling is both requested and supported, and the capability term is the load-bearing half. test_sampling_unsupported.py still called `caplog.set_level(logging.ERROR)`, which contradicted the claim that these assertions hold without it — raising the level for the whole run was the thing to avoid. Removed, with the import that existed only for it. Verified rather than assumed: the test passes on postgres and duckdb without it, and downgrading the refusal to `logger.debug` still fails it, so the assertion is not vacuous. No stale `sampling_is_unsupported` references remain anywhere in the repo. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|
Thanks for the leftovers; both have been addressed 👍 |



Shared-engine changes needed to run contract checks against a Salesforce org (R-642). The Salesforce
side is sodadata/soda-extensions#565.
Five production files, each pairing its change with tests that run on every data source, not
just Salesforce.
What changes for other data sources
Four of these alter shared behaviour, so that is the part worth reviewing hardest:
get_schema_check_columns_metadata()__init__toexecute(), so a builder that raises (sparkdf rejects >2 prefixes) now leaves one check NOT_EVALUATED instead of aborting the verification. Outcomes are otherwise identical to main, including for zero resolved columns. Databricks overrides the new seam so its accessor's swallow keeps serving the bulk sweep without silencing the schema check.duplicaterefuses when the source cannot hashTrue, so no SQL source reaches the guard.supports_row_sampling()True, and onlySalesforceSqlDialectoverrides it across all 20 installed dialects.supported_check_typesgates check parsingNone— no restriction — so every existing source keeps running every registered check type. Only a source that declares a set is affected.raise SodaException("Unrecognized check result type"), and now returnsNone. A populated non-SodaCloudJsonabledict still raises exactly as before.Why the correctness guards exist
Dataset-level
duplicatereported every row as a duplicate. The count is derived asrow_count - distinct_count, the distinct count comes from a row hash, and a source that cannotexpress one leaves it unmeasured — which
convert_db_valuecoerces to0. Measured against aSalesforce fixture with zero duplicates: FAILED at 100%.
reconciliation'sduplicate_diffalreadyguards on this flag; the direct check did not.
The refusal resolves in
parse_check, so no metric is built and the query the source cannot answer isnever sent. It shares one mechanism with the declared-check-type gate rather than adding a second.
Note the name: a dataset-level
duplicatehashes even forcolumns: [one](
_build_combined_hash_sqlkeepsSTRING_HASHfor a single column), so the message says"dataset-level" rather than "multi-column" and points at the per-column form, which needs no hash —
with the caveat that it excludes missing values where this one counts NULL as a value.
Sampling was requested but unavailable. Two coordinated guards. The construction-time one is
load-bearing: without it
.SAMPLE()reaches the dialect's_build_sample_sql, whose default raisesNotImplementedErrorinsideCheckCollectionImpl.__init__— before the execute loop exists to catchanything — and the whole verification aborts. The execute-time one turns the refusal into
NOT_EVALUATED with a reason instead of a raised exception.
data_source_supports_samplinganswers onlythe capability question; each call site pairs it with the "was sampling requested" half it already asks.
supported_check_typesA source that speaks something other than SQL can only answer part of the contract language. Until now
the unanswerable part failed deep inside execution, surfacing as whatever the source said — a raw
MALFORMED_QUERY, which reads like an engine defect rather than a boundary.DataSourceImpl.supported_check_typesisOptional[frozenset[str]], defaultNone. A source thatdeclares a set gets checks outside it refused in
CheckImpl.parse_check, before any metric or queryis built — that ordering is the point, because some check types run a user-written query and reaching
setup would send it to a source that cannot run it.
The gate is enforced in the shared
CheckImpl.parse_check, so it covers everyCheckCollectionImplsubtype — contracts, data standards, metric monitoring — and every parser in the
check_parsersregistry,
group_byincluded. Only reconciliation escapes, via its ownparse_check.Three deliberate choices, each measured rather than assumed:
statement from "you asked for this and are not getting it".
UNKNOWN, not PASSED:
_get_contract_verification_statusreaches PASSED only viaall(outcome == PASSED), so one NOT_EVALUATED already drops it. The reason to go further ispermanence — unlike a NOT_EVALUATED from an unmeasured metric, which may succeed next run, this check
will never evaluate on this source until someone edits the contract, and exit 0 would leave the
contract quietly asserting less than it claims.
unsupported on such a source until someone has verified it there.
The Cloud-payload fix is what makes the refusal publishable at all.
_build_v4_diagnostics_check_type_json_dictdispatches partly on the check type and partly on theresult class: type-matched branches call
.get()on the diagnostics, while the class-matched ones(schema, freshness) fall through to an else-arm that accepted only
Noneor aSodaCloudJsonable. So aresult with no diagnostics had no representation that worked for both —
Nonecrashed the first set withan
AttributeError,{}crashed the second withUnrecognized check result type. Both failure modeswere hit for real while building this.
Test additions
test_aggregate_check.pyhad nominormaxcase at all, andsumonly in its filtered variant— three of the four documented aggregate functions were asserted nowhere, for any source. Added, plus a
filtered
max.New
test_sampling_unsupported.pycovers both sampling guards by patching the capability flag, so itruns everywhere. Mutation-checked: removing the construction-time guard fails 2 of its 3 tests.
New
test_supported_check_types.pypatches the capability onto whatever source the suite runs against,so the behaviour is asserted on every source rather than only the one that declares a set. The publish
test is the one that catches both crash modes above — mutation-checked at 0 → 4 failures.
New
soda-databricks/tests/unit/test_databricks_columns_metadata.pypins the seam that keeps Databricksloud: its accessor still absorbs failures for bulk callers, and still lets them surface for the schema
check. Mutation-checked — deleting the override fails it.
test_duplicate_dataset_checkwas defended by an assertion that held for the wrong reason: itasserted only the outcome and the absent number, so downgrading the reason to
debugwould have produceda silent NOT_EVALUATED with a clean exit, which is exactly what the guard exists to prevent. It now
asserts the message via
caplog, and that the message names the per-column alternative.Verification
soda-tests/tests/integrationsoda-tests/tests/integrationsoda-tests/tests/integrationsoda-tests/tests/unit+featuresoda-databricks/tests/unitEvery count was compared against the same suite on a clean tree, not against a remembered baseline.
The integration deltas are exactly the one test removed with the zero-columns error.
Reviewer notes
has been removed, so outcomes match main on every source. Routing the schema check through its own
accessor is what remains, and Databricks overrides it so the routing does not quieten a metadata
failure that used to be loud.
supported_check_typesgates everyCheckCollectionImplsubtype, not contracts alone. A test insodadata/soda-extensions#565 pins that data standards are covered — it lives there because
soda-data-standardis packaged there.filter:on aquery:-form check is silently ignored on every data source.Measured and documented, but fixing it changes a long-standing shared flow, so it was left out of this
PR deliberately.
🤖 Generated with Claude Code