From 3ab8406b1d845bf66119dde5c78b222ed9ad5ffb Mon Sep 17 00:00:00 2001 From: Johnson K C Date: Mon, 8 Jun 2026 14:59:25 -0400 Subject: [PATCH] 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 ( 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) --- sqlite_utils/db.py | 19 ++++++++++++++----- tests/test_extract.py | 23 +++++++++++++++++++++-- 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/sqlite_utils/db.py b/sqlite_utils/db.py index b562491..35f242e 100644 --- a/sqlite_utils/db.py +++ b/sqlite_utils/db.py @@ -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 ()`` 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 diff --git a/tests/test_extract.py b/tests/test_extract.py index a0622ba..130dee2 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -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}, + ]