fix: scope DUPLICATE_COUNT under --filter to the keys the window contains - #1619
gkhnelbstn wants to merge 2 commits into
Conversation
A uniqueness check answers "does this key repeat anywhere in the table". --filter narrowing it to "anywhere in today's rows" answers a different, weaker question, and it did so silently: a duplicate whose other half loaded on an earlier day passed every day, filtered or not. Hit this on real data -- field_unique passed for 45 days windowed; the same table read unfiltered reported 16 duplicate primary keys across 3376 rows. DUPLICATE_COUNT now reads unfiltered_t, the table handle kept before _apply_row_filter narrows t, both for the count itself and for its --include-failed-samples rows -- the two disagreeing would be worse than neither existing. Its row_count denominator (for a percent threshold) is the unfiltered count too, for the same reason. A --filter predicate that fails to compile no longer errors DUPLICATE_COUNT specs either: they never read the filtered table, so they do not need it to compile. Every other check (row_count, missing_count, invalid_count, freshness, custom SQL) is untouched -- unfiltered_t and t are the same object when there is no --filter, so this costs nothing in the common case, and unfiltered_row_count() short-circuits to model_row_count() rather than issuing a second COUNT query. tests/test_row_filter_duplicate_count.py, against a dedicated fixture (id repeats once per two-value "batch" column): the duplicate is caught unfiltered, still caught when a filter narrows to the batch that contains only one of the two occurrences (the exact false-negative shape), a sibling row_count check still honours the filter, and a filter predicate that would not even compile does not block the duplicate check. Verified locally against local/duckdb (Docker was not available for the Postgres/SQL Server/etc. suites): the new test file passes, the existing 13 in test_test_row_filter.py are unaffected, and a broader run across ~2200 non-container tests shows no failure this touches -- the ones present (duckdb/variables/metadata-only) reproduce identically on main, unrelated to this change (Windows-path escaping in a regex/YAML string). Fixes datacontract#1593.
|
Hi @gkhnelbstn. thank you for creating the issue and the PR! Right now you check for duplicates in the whole table. But since a filter is set, it would be enough if we do the check (a) within the selected rows and (b) between selected and unselected rows - i.e., not within the unselected rows. In other words, I'd propose to save costs by using a semi-join between the filtered rows and the whole table, conceptually like this: SELECT key, COUNT(*) FROM my_table
WHERE key IN (SELECT DISTINCT key FROM my_table WHERE <filter>)
GROUP BY key HAVING COUNT(*) > 1 |
|
Thanks @jschoedl, that is a better shape and it's done in ceb6f6e. Under One deliberate difference from the SQL you sketched: the join is If you'd rather NULL keys not count as duplicates at all, that's a reasonable call, but it would change the unfiltered check too, so I've left it out of this PR. Two things went back to how they were before this PR, since the check now depends on the filter:
I've rewritten the PR description to match. |
Reading the whole table caught a duplicate whose other half arrived outside the window, but it also re-reported every duplicate the window never touched, on every run, at the cost of grouping the entire table. Under --filter the check now reads the rows of the whole table whose key occurs among the filtered rows -- a semi-join, as suggested in review. That finds a key repeated within the window and one repeated across its edge, and skips keys the window does not contain. The join is IS NOT DISTINCT FROM (ibis identical_to), not equality. GROUP BY puts NULL keys in one group, so the unfiltered check counts two NULL ids as a duplicate; a plain `key IN (...)` never matches NULL and would pass the same rows filtered. The null-safe join keeps the two answers the same. Two consequences, both returning to how the check behaved before this PR: - A percent threshold is again of the filtered row count. The count is now of the window's keys, so the whole-table denominator no longer fits it. - A --filter that does not compile errors DUPLICATE_COUNT like every other check that reads rows. The check needs the window's keys to know which rows to read. Failed samples read the same scoped rows as the count. Tests cover a duplicate within the window, across its edge, wholly outside it (passes), and a NULL key. Replacing identical_to with == fails the NULL test; reading the whole table fails the wholly-outside test.
f1f1fb3 to
ceb6f6e
Compare
The bug
--filternarrows every check to the rows it selects. For uniqueness that answers "are today's rows unique among themselves", which misses a duplicate whose other half arrived outside the window.We hit this on real data: a windowed
field_uniquepassed every day for 45 days. The same contract read unfiltered against the same table reportedduplicate_count(order_id) was 8-- 8 duplicate primary keys on a column declaredprimaryKey: trueandunique: true, invisible the whole time.Filed as #1593.
The fix
Under
--filter,DUPLICATE_COUNTreads every row of the table -- selected or not -- whose key occurs among the selected rows. A semi-join, as suggested in review:That catches a key repeated within the window and one repeated across its edge, and skips keys the window does not contain, whose duplicates belong to another window. Without a filter nothing changes.
The change is in
_run_modeland a new_duplicate_scope()indatacontract/engines/ibis/ibis_check_execute.py, built with ibis'ssemi_join. On duckdb it compiles to:The join is null-safe on purpose.
GROUP BYputs NULL keys in one group, so the unfiltered check counts two NULL ids as a duplicate. A plainkey IN (...)never matches NULL, so the same rows would pass when filtered.IS NOT DISTINCT FROM(ibisidentical_to) keeps the two answers the same. Whether NULL keys should count as duplicates is a separate question; this only keeps filtered and unfiltered from disagreeing about it.Two consequences, both keeping the check's behaviour from before this PR:
--filterthat does not compile errorsDUPLICATE_COUNTlike every other check that reads rows. The check needs the window's keys to know which rows to read.--include-failed-samplesreads the same scoped rows as the count, so the samples and the number agree.Testing
tests/test_row_filter_duplicate_count.py, againsttests/fixtures/row-filter-duplicate/, whose batches hold: a key repeated across batches 1 and 2, a key repeated within batch 3, a batch-4 key that never repeats, and two NULL ids in batch 5.batch = 1batch = 3batch = 4batch = 5row_countstill narrowsbatch = 1no_such_column = 1Two of these pin the semantics rather than only the happy path. Replacing
identical_towith==fails the NULL case; reading the whole table instead of the semi-join fails the wholly-outside case. Both mutations were run.Locally against duckdb: the 7 tests pass, the 13 in
test_test_row_filter.pypass, the 59 matchingunique,duplicateorsamplesacross the suite pass, andruff check/ruff format --checkare clean.