Conversation
Three ways a column containing nulls produced a wrong answer, all from
nulls not being accounted for.
1. The "single distinct value" shortcut. count(distinct col) skips
nulls in SQL, so a column holding nulls plus one other value reports
num_distinct == 1 and took the shortcut, which reports that one value
with a count of total_rows:
insert [{'c': None}, {'c': None}, {'c': 'a'}, {'c': 'a'}]
analyze_column('c').most_common # was [(None, 4)]
select c, count(*) group by c # is [(None, 2), ('a', 2)]
The shortcut now requires that the column has no nulls, so those
columns fall through to the group by like any other.
2. The tie-break sort compared the values directly, so a null and a
string with the same count raised TypeError:
analyze_column('c', common_limit=5)
# TypeError: '<' not supported between instances of 'NoneType' and 'str'
sort_key() keeps nulls out of the comparison and sorts them last,
which keeps the existing ordering of non-null ties - the test for
'Terryterryterry' ahead of 'Kumar' at equal counts still passes.
3. truncate() turned a null into the string 'None', so a null was
indistinguishable from a column really holding 'None'. That is the
path analyze-tables takes, since it passes value_truncate=80.
num_distinct itself is unchanged: it still follows SQL and reports 0 for
an all-null column, which test_analyze_table_column_all_nulls pins. But
num_distinct can therefore be lower than len(most_common) when a column
holds nulls alongside all-distinct values - {'c': None, 'a', 'b'} gives
num_distinct 2 and three most_common entries. Whether null should count
as a distinct value is a question about what the number means, so I have
left it alone rather than change an output the tests already pin.
Tests: 6 new cases in tests/test_analyze.py, all failing on 6bc1d33.
Suite 1506 passed, 16 skipped. mypy, pyright, flake8, ty, black and
cog --check clean.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follow-up to #887, which has the longer write-up.
analyze_column()mis-handles columns that contain nulls, in three separate ways. All three are insqlite_utils/db.py.1. The "single distinct value" shortcut reports a count that is too high
count(distinct col)skips nulls in SQL. So a column holding nulls plus one other value reportsnum_distinct == 1and takes the shortcut that skips the group by:One value, counted four times, with the two nulls not mentioned at all. The shortcut is only valid when every row holds that one value, so it now also requires the column to have no nulls.
2. A null tied on count with a string raises
TypeErrorBoth sorts break ties on the value itself, and
Nonecannot be ordered against a string:sort_key()keeps nulls out of the comparison and sorts them last. This preserves the existing ordering of non-null ties —test_analyze_tables.pyasserts"Terryterryterry"comes before"Kumar"at equal counts, and that still passes.3. A null renders as the string
"None"truncate()didstr(value)on anything that was not a float or int, so a null became"None"— indistinguishable from a column that really holds"None". This is the pathanalyze-tablestakes, since it passesvalue_truncate=80:With
--savethat string is also persisted into_analyze_tables_.most_common.What I did not change, and why
What I did not change, and why
num_distinctitself still follows SQL, so an all-null column reports0—test_analyze_table_column_all_nullspins that output and I did not want to change a tested value as a side effect of a crash fix.That leaves one consequence I can see but did not fix:
num_distinctcan be lower thanlen(most_common)when a column holds nulls alongside otherwise all-distinct values.The fix for that is one line — add
1tonum_distinctwhennum_null— but it changes whatDistinct values:means for every column containing nulls, and whether a null should count as a distinct value is a question about the number's meaning rather than a bug. Happy to make that change too if you would rather it did.Testing
Six new cases in
tests/test_analyze.py, all failing on6bc1d33:most_common_includes_nulls— the shortcut case, with and withoutvalue_truncatemost_common_ties_with_nullsandleast_common_ties_with_nulls— the two sortsdoes_not_render_null_as_the_string_none— thetruncate()casenum_distinct_ignores_nulls— pins the current SQL semantics, passes both before and after, so the decision above is documented either waySuite: 1506 passed, 16 skipped.
mypy sqlite_utils tests,pyright sqlite_utils tests,flake8,ty check sqlite_utils,black . --checkandcog --check --diff README.md docs/*.rstall clean.Testing
Six new cases in
tests/test_analyze.py, all failing on6bc1d33:most_common_includes_nulls— the shortcut case, with and withoutvalue_truncatemost_common_ties_with_nullsandleast_common_ties_with_nulls— the two sortsdoes_not_render_null_as_the_string_none— thetruncate()casenum_distinct_ignores_nulls— pins the current SQL semantics, passes both before and after, so the decision above is documented either waySuite: 1506 passed, 16 skipped.
mypy sqlite_utils tests,pyright sqlite_utils tests,flake8,ty check sqlite_utils,black . --checkandcog --check --diff README.md docs/*.rstall clean.📚 Documentation preview 📚: https://sqlite-utils--888.org.readthedocs.build/en/888/