mirror of
https://github.com/simonw/sqlite-utils.git
synced 2026-09-29 05:14:28 +02:00
Preserve AUTOINCREMENT through transforms
This commit is contained in:
parent
fcfccea813
commit
2b52b5ed6f
5 changed files with 139 additions and 1 deletions
|
|
@ -9,6 +9,7 @@
|
||||||
Unreleased
|
Unreleased
|
||||||
----------
|
----------
|
||||||
|
|
||||||
|
- ``table.transform()`` now preserves ``AUTOINCREMENT`` primary keys and their sequence high-water marks. Previously a transform removed ``AUTOINCREMENT`` and could reuse deleted row IDs. (:issue:`602`)
|
||||||
- New ``table.checks``, ``table.column_checks`` and ``table.table_checks`` introspection properties expose column-level and table-level ``CHECK`` constraints. (:issue:`834`)
|
- New ``table.checks``, ``table.column_checks`` and ``table.table_checks`` introspection properties expose column-level and table-level ``CHECK`` constraints. (:issue:`834`)
|
||||||
- New ``sqlite_utils.ANY`` marker type for creating and introspecting SQLite ``ANY`` columns. The Python API and CLI can create, add and transform these columns, and ``table.transform()`` and ``table.extract()`` now preserve ``ANY`` columns and their values in ``STRICT`` tables. (:issue:`790`)
|
- New ``sqlite_utils.ANY`` marker type for creating and introspecting SQLite ``ANY`` columns. The Python API and CLI can create, add and transform these columns, and ``table.transform()`` and ``table.extract()`` now preserve ``ANY`` columns and their values in ``STRICT`` tables. (:issue:`790`)
|
||||||
- ``table.transform()`` now preserves ``CHECK`` constraints, including comments within their expressions. Renaming a column rewrites identifier references in checks without changing string literals or function names. Dropping a column drops a check owned by that column, and raises ``TransformError`` if a remaining check depends on it. (:issue:`762`)
|
- ``table.transform()`` now preserves ``CHECK`` constraints, including comments within their expressions. Renaming a column rewrites identifier references in checks without changing string literals or function names. Dropping a column drops a check owned by that column, and raises ``TransformError`` if a remaining check depends on it. (:issue:`762`)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""Helpers for parsing CHECK constraints from SQLite CREATE TABLE SQL.
|
"""Helpers for parsing constraints from SQLite CREATE TABLE SQL.
|
||||||
|
|
||||||
SQLite does not expose CHECK constraints through a pragma, so preserving them
|
SQLite does not expose CHECK constraints through a pragma, so preserving them
|
||||||
across a table rebuild requires reading ``sqlite_schema.sql``. This module is
|
across a table rebuild requires reading ``sqlite_schema.sql``. This module is
|
||||||
|
|
@ -564,6 +564,34 @@ def parse_checks(create_sql: str) -> list[Check]:
|
||||||
return checks
|
return checks
|
||||||
|
|
||||||
|
|
||||||
|
def parse_autoincrement(create_sql: str) -> str | None:
|
||||||
|
"""Return the AUTOINCREMENT column from a valid CREATE TABLE statement."""
|
||||||
|
body_info = _table_body(create_sql)
|
||||||
|
if body_info is None:
|
||||||
|
return None
|
||||||
|
body, _ = body_info
|
||||||
|
for item, _, _ in _split_spans(body, _lex(body)):
|
||||||
|
item_tokens = _meaningful(_lex(item))
|
||||||
|
if not item_tokens:
|
||||||
|
continue
|
||||||
|
head = item_tokens[0]
|
||||||
|
if (
|
||||||
|
head.kind == "word" and head.text.upper() in _TABLE_CONSTRAINT_KEYWORDS
|
||||||
|
) or head.is_keyword("CONSTRAINT"):
|
||||||
|
continue
|
||||||
|
column = _unquote(head.text)
|
||||||
|
index = 1
|
||||||
|
while index < len(item_tokens):
|
||||||
|
token = item_tokens[index]
|
||||||
|
if token.text == "(":
|
||||||
|
index = _matching_paren(item_tokens, index) + 1
|
||||||
|
continue
|
||||||
|
if token.is_keyword("AUTOINCREMENT"):
|
||||||
|
return column
|
||||||
|
index += 1
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def parse_column_comments(create_sql: str) -> dict[str, ColumnComments]:
|
def parse_column_comments(create_sql: str) -> dict[str, ColumnComments]:
|
||||||
"""Return comments immediately before and after each column definition."""
|
"""Return comments immediately before and after each column definition."""
|
||||||
body_info = _table_body(create_sql)
|
body_info = _table_body(create_sql)
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ from .create_table_parser import (
|
||||||
ColumnComments,
|
ColumnComments,
|
||||||
ParseError,
|
ParseError,
|
||||||
check_references_identifier,
|
check_references_identifier,
|
||||||
|
parse_autoincrement,
|
||||||
parse_checks,
|
parse_checks,
|
||||||
parse_column_comments,
|
parse_column_comments,
|
||||||
rewrite_check_expression,
|
rewrite_check_expression,
|
||||||
|
|
@ -1422,6 +1423,7 @@ class Database:
|
||||||
strict: bool = False,
|
strict: bool = False,
|
||||||
_checks: Iterable[Check] | None = None,
|
_checks: Iterable[Check] | None = None,
|
||||||
_column_comments: Mapping[str, ColumnComments] | None = None,
|
_column_comments: Mapping[str, ColumnComments] | None = None,
|
||||||
|
_autoincrement: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Returns the SQL ``CREATE TABLE`` statement for creating the specified table.
|
Returns the SQL ``CREATE TABLE`` statement for creating the specified table.
|
||||||
|
|
@ -1525,10 +1527,22 @@ class Database:
|
||||||
column_items.insert(0, (pk, int))
|
column_items.insert(0, (pk, int))
|
||||||
elif pk:
|
elif pk:
|
||||||
pk = [resolve_casing(p, [c[0] for c in column_items]) for p in pk]
|
pk = [resolve_casing(p, [c[0] for c in column_items]) for p in pk]
|
||||||
|
if _autoincrement is not None:
|
||||||
|
_autoincrement = resolve_casing(
|
||||||
|
_autoincrement, [c[0] for c in column_items]
|
||||||
|
)
|
||||||
|
if _autoincrement != single_pk:
|
||||||
|
raise ValueError("AUTOINCREMENT requires a single-column primary key")
|
||||||
for column_name, column_type in column_items:
|
for column_name, column_type in column_items:
|
||||||
column_extras = []
|
column_extras = []
|
||||||
if column_name == single_pk:
|
if column_name == single_pk:
|
||||||
column_extras.append("PRIMARY KEY")
|
column_extras.append("PRIMARY KEY")
|
||||||
|
if column_name == _autoincrement:
|
||||||
|
if COLUMN_TYPE_MAPPING[column_type] != "INTEGER":
|
||||||
|
raise ValueError(
|
||||||
|
"AUTOINCREMENT requires an INTEGER PRIMARY KEY column"
|
||||||
|
)
|
||||||
|
column_extras.append("AUTOINCREMENT")
|
||||||
if column_name in not_null:
|
if column_name in not_null:
|
||||||
column_extras.append("NOT NULL")
|
column_extras.append("NOT NULL")
|
||||||
if column_name in defaults and defaults[column_name] is not None:
|
if column_name in defaults and defaults[column_name] is not None:
|
||||||
|
|
@ -2748,6 +2762,7 @@ class Table(Queryable):
|
||||||
try:
|
try:
|
||||||
existing_checks = self.checks
|
existing_checks = self.checks
|
||||||
existing_column_comments = parse_column_comments(self.schema)
|
existing_column_comments = parse_column_comments(self.schema)
|
||||||
|
existing_autoincrement = parse_autoincrement(self.schema)
|
||||||
except ParseError as ex:
|
except ParseError as ex:
|
||||||
raise TransformError(
|
raise TransformError(
|
||||||
f"Could not parse table schema for table {self.name!r}: {ex}"
|
f"Could not parse table schema for table {self.name!r}: {ex}"
|
||||||
|
|
@ -2870,6 +2885,11 @@ class Table(Queryable):
|
||||||
new_column_pairs.append((new_name, type_))
|
new_column_pairs.append((new_name, type_))
|
||||||
copy_from_to[name] = new_name
|
copy_from_to[name] = new_name
|
||||||
|
|
||||||
|
if existing_autoincrement:
|
||||||
|
existing_autoincrement = resolve_casing(
|
||||||
|
existing_autoincrement, existing_columns
|
||||||
|
)
|
||||||
|
|
||||||
if pk is DEFAULT:
|
if pk is DEFAULT:
|
||||||
pks_renamed = tuple(
|
pks_renamed = tuple(
|
||||||
rename.get(pk_name) or pk_name
|
rename.get(pk_name) or pk_name
|
||||||
|
|
@ -2880,6 +2900,28 @@ class Table(Queryable):
|
||||||
else:
|
else:
|
||||||
pk = pks_renamed
|
pk = pks_renamed
|
||||||
|
|
||||||
|
create_table_autoincrement = None
|
||||||
|
if existing_autoincrement and existing_autoincrement not in drop:
|
||||||
|
renamed_autoincrement = (
|
||||||
|
rename.get(existing_autoincrement) or existing_autoincrement
|
||||||
|
)
|
||||||
|
single_pk = pk[0] if isinstance(pk, (list, tuple)) and len(pk) == 1 else pk
|
||||||
|
new_column_types = dict(new_column_pairs)
|
||||||
|
if (
|
||||||
|
single_pk == renamed_autoincrement
|
||||||
|
and COLUMN_TYPE_MAPPING.get(new_column_types.get(renamed_autoincrement))
|
||||||
|
== "INTEGER"
|
||||||
|
):
|
||||||
|
create_table_autoincrement = renamed_autoincrement
|
||||||
|
|
||||||
|
autoincrement_sequence = None
|
||||||
|
if create_table_autoincrement:
|
||||||
|
sequence_row = self.db.execute(
|
||||||
|
"SELECT seq FROM sqlite_sequence WHERE name = ?", [self.name]
|
||||||
|
).fetchone()
|
||||||
|
if sequence_row is not None:
|
||||||
|
autoincrement_sequence = sequence_row[0]
|
||||||
|
|
||||||
# not_null may be a set or dict, need to convert to a set
|
# not_null may be a set or dict, need to convert to a set
|
||||||
create_table_not_null = {
|
create_table_not_null = {
|
||||||
rename.get(c.name) or c.name
|
rename.get(c.name) or c.name
|
||||||
|
|
@ -2931,6 +2973,7 @@ class Table(Queryable):
|
||||||
strict=self.strict if strict is None else strict,
|
strict=self.strict if strict is None else strict,
|
||||||
_checks=create_table_checks,
|
_checks=create_table_checks,
|
||||||
_column_comments=create_table_column_comments,
|
_column_comments=create_table_column_comments,
|
||||||
|
_autoincrement=create_table_autoincrement,
|
||||||
).strip()
|
).strip()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -3053,6 +3096,23 @@ class Table(Queryable):
|
||||||
"ON" if legacy_alter_table_was_on else "OFF"
|
"ON" if legacy_alter_table_was_on else "OFF"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
if autoincrement_sequence is not None:
|
||||||
|
table_name_literal = self.db.quote(self.name)
|
||||||
|
sqls.extend(
|
||||||
|
(
|
||||||
|
"UPDATE sqlite_sequence SET seq = MAX(seq, {sequence}) "
|
||||||
|
"WHERE name = {table_name};".format(
|
||||||
|
sequence=autoincrement_sequence,
|
||||||
|
table_name=table_name_literal,
|
||||||
|
),
|
||||||
|
"INSERT INTO sqlite_sequence (name, seq) "
|
||||||
|
"SELECT {table_name}, {sequence} WHERE NOT EXISTS "
|
||||||
|
"(SELECT 1 FROM sqlite_sequence WHERE name = {table_name});".format(
|
||||||
|
sequence=autoincrement_sequence,
|
||||||
|
table_name=table_name_literal,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
# Re-add existing indexes
|
# Re-add existing indexes
|
||||||
sqls.extend(index_create_sqls)
|
sqls.extend(index_create_sqls)
|
||||||
return sqls
|
return sqls
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ from sqlite_utils.create_table_parser import (
|
||||||
Check,
|
Check,
|
||||||
ColumnComments,
|
ColumnComments,
|
||||||
ParseError,
|
ParseError,
|
||||||
|
parse_autoincrement,
|
||||||
parse_checks,
|
parse_checks,
|
||||||
parse_column_comments,
|
parse_column_comments,
|
||||||
)
|
)
|
||||||
|
|
@ -117,6 +118,36 @@ def test_virtual_table_has_no_checks():
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"sql,expected",
|
||||||
|
[
|
||||||
|
(
|
||||||
|
"CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT, value TEXT)",
|
||||||
|
"id",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'CREATE TABLE t("quoted id" INTEGER PRIMARY KEY AUTOINCREMENT)',
|
||||||
|
"quoted id",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'CREATE TABLE t("autoincrement" INTEGER PRIMARY KEY, value TEXT)',
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"CREATE TABLE t(id INTEGER PRIMARY KEY /* AUTOINCREMENT */, value TEXT)",
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"CREATE TABLE t(id INTEGER PRIMARY KEY, value TEXT CHECK(value != 'AUTOINCREMENT'))",
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_parse_autoincrement(sql, expected):
|
||||||
|
sqlite3.connect(":memory:").execute(sql)
|
||||||
|
assert parse_autoincrement(sql) == expected
|
||||||
|
|
||||||
|
|
||||||
comment_or_space = st.sampled_from(
|
comment_or_space = st.sampled_from(
|
||||||
[
|
[
|
||||||
" ",
|
" ",
|
||||||
|
|
|
||||||
|
|
@ -1053,6 +1053,24 @@ def test_transform_with_unique_constraint_implicit_index(fresh_db):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_transform_preserves_autoincrement_and_sequence(fresh_db):
|
||||||
|
fresh_db.execute(
|
||||||
|
"CREATE TABLE entries (id INTEGER PRIMARY KEY AUTOINCREMENT, value TEXT)"
|
||||||
|
)
|
||||||
|
entries = fresh_db.table("entries")
|
||||||
|
entries.insert_all(({"value": "one"}, {"value": "two"}))
|
||||||
|
entries.delete(2)
|
||||||
|
|
||||||
|
entries.transform(rename={"value": "label"})
|
||||||
|
|
||||||
|
assert "PRIMARY KEY AUTOINCREMENT" in entries.schema
|
||||||
|
entries.insert({"label": "three"})
|
||||||
|
assert list(entries.rows) == [
|
||||||
|
{"id": 1, "label": "one"},
|
||||||
|
{"id": 3, "label": "three"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_transform_preserves_view(fresh_db):
|
def test_transform_preserves_view(fresh_db):
|
||||||
# https://github.com/simonw/sqlite-utils/issues/831
|
# https://github.com/simonw/sqlite-utils/issues/831
|
||||||
dogs = fresh_db.table("dogs")
|
dogs = fresh_db.table("dogs")
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue