From 5aa61c2f7f5bd23d7883b669317114d0fa7c0896 Mon Sep 17 00:00:00 2001 From: Vincent Gao Date: Wed, 1 Jul 2026 01:03:46 +0200 Subject: [PATCH] Fix transform() corrupting TRUE/FALSE/NULL column defaults quote_default_value() passed keyword-literal defaults (TRUE, FALSE, NULL) through self.quote(), wrapping them in quotes. So transform() rebuilt a column declared "INTEGER DEFAULT TRUE" as "INTEGER DEFAULT 'TRUE'", and a later default insert stored the text 'TRUE' instead of the integer 1 (likewise 'FALSE' for 0 and 'NULL' for null) - silent value corruption. Return these keyword literals unquoted, as already done for the CURRENT_TIME/DATE/TIMESTAMP literals. --- sqlite_utils/db.py | 5 +++++ tests/test_default_value.py | 5 +++++ tests/test_transform.py | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 44 insertions(+) diff --git a/sqlite_utils/db.py b/sqlite_utils/db.py index ae99322b5..ff2957de8 100644 --- a/sqlite_utils/db.py +++ b/sqlite_utils/db.py @@ -710,6 +710,11 @@ def quote_default_value(self, value: str) -> str: if str(value).upper() in ("CURRENT_TIME", "CURRENT_DATE", "CURRENT_TIMESTAMP"): return value + if str(value).upper() in ("TRUE", "FALSE", "NULL"): + # Keyword literals must stay unquoted; quoting them would turn the + # default into a string ('TRUE' instead of 1, 'NULL' instead of null). + return value + if str(value).endswith(")"): # Expr return "({})".format(value) diff --git a/tests/test_default_value.py b/tests/test_default_value.py index c5e4b17b5..3724d9996 100644 --- a/tests/test_default_value.py +++ b/tests/test_default_value.py @@ -21,6 +21,11 @@ # Strings ("TEXT DEFAULT 'CURRENT_TIMESTAMP'", "'CURRENT_TIMESTAMP'", "'CURRENT_TIMESTAMP'"), ('TEXT DEFAULT "CURRENT_TIMESTAMP"', '"CURRENT_TIMESTAMP"', '"CURRENT_TIMESTAMP"'), + # Boolean and null keyword literals must stay unquoted + ("INTEGER DEFAULT TRUE", "TRUE", "TRUE"), + ("INTEGER DEFAULT FALSE", "FALSE", "FALSE"), + ("INTEGER DEFAULT true", "true", "true"), + ("TEXT DEFAULT NULL", "NULL", "NULL"), ] diff --git a/tests/test_transform.py b/tests/test_transform.py index 5eb501db5..71518bed9 100644 --- a/tests/test_transform.py +++ b/tests/test_transform.py @@ -224,6 +224,40 @@ def test_transform_rename_pk(fresh_db): ) +def test_transform_preserves_keyword_literal_defaults(fresh_db): + # transform() used to requote keyword-literal defaults (DEFAULT TRUE became + # DEFAULT 'TRUE'), so a default insert stored the text 'TRUE' instead of the + # integer 1 -- silent value corruption on every rebuilt table. + fresh_db.execute( + "CREATE TABLE t (" + " id INTEGER PRIMARY KEY," + " is_active INTEGER DEFAULT TRUE," + " flag INTEGER DEFAULT FALSE," + " note TEXT DEFAULT NULL" + ")" + ) + table = fresh_db["t"] + table.insert({"id": 1}) + before = fresh_db.execute("SELECT is_active, flag, note FROM t").fetchone() + assert before == (1, 0, None) + + # Rebuild the table via an unrelated change. + table.transform(rename={"note": "note2"}) + + # The keyword literals stay unquoted in the schema ... + assert "DEFAULT TRUE" in table.schema + assert "DEFAULT FALSE" in table.schema + assert "DEFAULT NULL" in table.schema + assert "'TRUE'" not in table.schema + + # ... and a fresh default insert still yields 1 / 0 / NULL, not strings. + table.insert({"id": 2}) + after = fresh_db.execute( + "SELECT is_active, flag, note2 FROM t WHERE id = 2" + ).fetchone() + assert after == (1, 0, None) + + def test_transform_not_null(fresh_db): dogs = fresh_db["dogs"] dogs.insert({"id": 1, "name": "Cleo", "age": "5"}, pk="id")