extract(): keep all-NULL rows unlinked even with a pre-existing NULL lookup row

Follow-up to the #186 fix: skipping the all-NULL combination in the lookup
INSERT is not enough on its own. When extract() reuses a lookup table that
already contains an all-NULL row (e.g. one written by an older sqlite-utils
version or created manually), the IS-based foreign-key UPDATE would still
match that row and link all-NULL source rows to it.

The UPDATE now carries a trailing `WHERE NOT (<col> IS NULL AND ...)` so
all-NULL source rows are never assigned a foreign key and keep the NULL that
the freshly-added column starts with, regardless of the lookup table's
existing contents.

Adds test_extract_all_null_stays_null_with_preexisting_null_lookup_row.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Johnson K C 2026-06-08 14:59:25 -04:00
commit 3ab8406b1d
2 changed files with 35 additions and 7 deletions

View file

@ -2289,12 +2289,15 @@ class Table(Queryable):
# Now add the new fk_column
self.add_column(magic_lookup_column, int)
# And populate it
# And populate it. The trailing ``WHERE NOT (<all columns NULL>)`` leaves
# all-NULL source rows with a NULL foreign key even when the lookup table
# already contains an all-NULL row (e.g. a reused or legacy lookup table)
# — the IS-based join would otherwise match that row and link to it (#186).
self.db.execute(
"UPDATE {} SET {} = (SELECT id FROM {} WHERE {where})".format(
quote_identifier(self.name),
quote_identifier(magic_lookup_column),
quote_identifier(table),
"UPDATE {table_name} SET {fk} = (SELECT id FROM {lookup} WHERE {where}) WHERE NOT ({all_null})".format(
table_name=quote_identifier(self.name),
fk=quote_identifier(magic_lookup_column),
lookup=quote_identifier(table),
where=" AND ".join(
"{}.{} IS {}.{}".format(
quote_identifier(self.name),
@ -2304,6 +2307,12 @@ class Table(Queryable):
)
for column in columns
),
all_null=" AND ".join(
"{}.{} IS NULL".format(
quote_identifier(self.name), quote_identifier(column)
)
for column in columns
),
)
)
# Figure out the right column order

View file

@ -236,6 +236,25 @@ def test_extract_multi_column_keeps_partial_null_but_not_all_null(fresh_db):
assert rows[0]["ab_id"] == rows[1]["ab_id"] is not None # partial NULL shared
assert rows[3]["ab_id"] not in (None, rows[0]["ab_id"])
# The lookup table must not contain an all-NULL row.
assert not any(
row["a"] is None and row["b"] is None for row in fresh_db["ab"].rows
assert not any(row["a"] is None and row["b"] is None for row in fresh_db["ab"].rows)
def test_extract_all_null_stays_null_with_preexisting_null_lookup_row(fresh_db):
# Reusing a lookup table that already contains an all-NULL row (e.g. created
# by an older sqlite-utils version) must still leave all-NULL source rows with
# a NULL foreign key — the IS-based join must not link them to that row (#186).
fresh_db["type"].insert_all(
[{"id": 1, "type": None}, {"id": 2, "type": "dog"}], pk="id"
)
fresh_db["creatures"].insert_all(
[
{"id": 1, "name": "Simon", "type": None},
{"id": 2, "name": "Cleo", "type": "dog"},
],
pk="id",
)
fresh_db["creatures"].extract("type")
assert list(fresh_db["creatures"].rows) == [
{"id": 1, "name": "Simon", "type_id": None},
{"id": 2, "name": "Cleo", "type_id": 2},
]