mirror of
https://github.com/simonw/sqlite-utils.git
synced 2026-10-28 22:27:02 +01:00
fix: transform() corrupts expression defaults into string literals
PRAGMA reports expression defaults without their parentheses (DEFAULT
(1+2) comes back as '1+2'), and transform() fed that fragment straight
back into CREATE TABLE, where it was re-quoted as a string literal.
Every table rebuilt by transform() then silently stored the text '1+2'
instead of the value 3 (and blob/concat defaults like (X'ff') or
('x'||'y') were corrupted too). Re-emit PRAGMA-derived defaults by
re-wrapping non-literal fragments in parentheses and pass blob literals
through unquoted.
This commit is contained in:
parent
6bc1d33d58
commit
f7c690d90b
2 changed files with 72 additions and 2 deletions
|
|
@ -1074,6 +1074,10 @@ class Database:
|
||||||
):
|
):
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
if str(value).upper().startswith("X'") and str(value).endswith("'"):
|
||||||
|
# Blob literal, e.g. X'FF'
|
||||||
|
return value
|
||||||
|
|
||||||
if str(value).upper() in ("CURRENT_TIME", "CURRENT_DATE", "CURRENT_TIMESTAMP"):
|
if str(value).upper() in ("CURRENT_TIME", "CURRENT_DATE", "CURRENT_TIMESTAMP"):
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
@ -1083,11 +1087,46 @@ class Database:
|
||||||
return value
|
return value
|
||||||
|
|
||||||
if str(value).endswith(")"):
|
if str(value).endswith(")"):
|
||||||
# Expr
|
# Expr - already parenthesized values pass through unchanged
|
||||||
|
if str(value).startswith("("):
|
||||||
|
return value
|
||||||
return f"({value})"
|
return f"({value})"
|
||||||
|
|
||||||
return self.quote(value)
|
return self.quote(value)
|
||||||
|
|
||||||
|
def transform_default_fragment(self, value: str) -> str:
|
||||||
|
"""Return a re-emittable DEFAULT fragment for a PRAGMA-returned value.
|
||||||
|
|
||||||
|
PRAGMA strips the parentheses from an expression default (``DEFAULT
|
||||||
|
(1+2)`` is reported as ``1+2``), so expressions must be wrapped again
|
||||||
|
or SQLite would treat them as string literals. Literals and keywords
|
||||||
|
are passed through unchanged.
|
||||||
|
"""
|
||||||
|
s = str(value)
|
||||||
|
if s.startswith("'") and s.endswith("'"):
|
||||||
|
# A single quoted string literal
|
||||||
|
if "''" not in s[1:-1] and "'" not in s[1:-1]:
|
||||||
|
return s
|
||||||
|
if s.upper().startswith("X'") and s.endswith("'"):
|
||||||
|
# Blob literal, e.g. X'FF'
|
||||||
|
return s
|
||||||
|
if s.upper() in (
|
||||||
|
"TRUE",
|
||||||
|
"FALSE",
|
||||||
|
"NULL",
|
||||||
|
"CURRENT_TIME",
|
||||||
|
"CURRENT_DATE",
|
||||||
|
"CURRENT_TIMESTAMP",
|
||||||
|
):
|
||||||
|
return s
|
||||||
|
try:
|
||||||
|
float(s)
|
||||||
|
return s
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
# Anything else was a parenthesized expression, e.g. "1+2" or "'a'||'b'"
|
||||||
|
return f"({s})"
|
||||||
|
|
||||||
def table_names(self, fts4: bool = False, fts5: bool = False) -> list[str]:
|
def table_names(self, fts4: bool = False, fts5: bool = False) -> list[str]:
|
||||||
"""
|
"""
|
||||||
List of string table names in this database.
|
List of string table names in this database.
|
||||||
|
|
@ -3061,7 +3100,9 @@ class Table(Queryable):
|
||||||
)
|
)
|
||||||
# defaults=
|
# defaults=
|
||||||
create_table_defaults = {
|
create_table_defaults = {
|
||||||
(rename.get(c.name) or c.name): c.default_value
|
(rename.get(c.name) or c.name): self.db.transform_default_fragment(
|
||||||
|
c.default_value
|
||||||
|
)
|
||||||
for c in self.columns
|
for c in self.columns
|
||||||
if c.default_value is not None and c.name not in drop
|
if c.default_value is not None and c.name not in drop
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -288,6 +288,35 @@ def test_transform_preserves_keyword_literal_defaults(fresh_db):
|
||||||
assert after == (1, 0, None)
|
assert after == (1, 0, None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_transform_preserves_expression_defaults(fresh_db):
|
||||||
|
# PRAGMA strips the parentheses from expression defaults, so transform()
|
||||||
|
# used to re-emit them as string literals: DEFAULT (1+2) became DEFAULT
|
||||||
|
# '1+2' and every new row silently got the text '1+2' instead of 3.
|
||||||
|
fresh_db.execute(
|
||||||
|
"CREATE TABLE t ("
|
||||||
|
" a TEXT,"
|
||||||
|
" b INTEGER DEFAULT (1+2),"
|
||||||
|
" c BLOB DEFAULT (X'ff'),"
|
||||||
|
" d TEXT DEFAULT ('x' || 'y')"
|
||||||
|
")"
|
||||||
|
)
|
||||||
|
table = fresh_db.table("t")
|
||||||
|
table.insert({"a": "first"})
|
||||||
|
|
||||||
|
# Rebuild via an unrelated change.
|
||||||
|
table.transform(rename={"a": "aa"})
|
||||||
|
|
||||||
|
schema = table.schema
|
||||||
|
assert "DEFAULT (1+2)" in schema
|
||||||
|
assert "DEFAULT X'ff'" in schema
|
||||||
|
assert "DEFAULT ('x' || 'y')" in schema
|
||||||
|
|
||||||
|
table.insert({"aa": "second"})
|
||||||
|
assert fresh_db.execute(
|
||||||
|
"SELECT b, c, d FROM t WHERE aa = 'second'"
|
||||||
|
).fetchone() == (3, b"\xff", "xy")
|
||||||
|
|
||||||
|
|
||||||
def test_transform_not_null(fresh_db):
|
def test_transform_not_null(fresh_db):
|
||||||
dogs = fresh_db.table("dogs")
|
dogs = fresh_db.table("dogs")
|
||||||
dogs.insert({"id": 1, "name": "Cleo", "age": "5"}, pk="id")
|
dogs.insert({"id": 1, "name": "Cleo", "age": "5"}, pk="id")
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue