Skip to content

Commit 4223070

Browse files
matdmillersimonw
andauthored
Recreate indexes when calling transform when possible (#634)
* Recreate indexes when calling transform when possible and raise an error when they cannot be retained automatically * Docs for sqlite_utils.db.TransformError Co-authored-by: Simon Willison <[email protected]>
1 parent cbddfb2 commit 4223070

File tree

3 files changed

+153
-1
lines changed

3 files changed

+153
-1
lines changed

docs/python-api.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1402,6 +1402,8 @@ To keep the original table around instead of dropping it, pass the ``keep_table=
14021402
14031403
table.transform(types={"age": int}, keep_table="original_table")
14041404
1405+
This method raises a ``sqlite_utils.db.TransformError`` exception if the table cannot be transformed, usually because there are existing constraints or indexes that are incompatible with modifications to the columns.
1406+
14051407
.. _python_api_transform_alter_column_types:
14061408

14071409
Altering column types

sqlite_utils/db.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,10 @@
156156
Trigger = namedtuple("Trigger", ("name", "table", "sql"))
157157

158158

159+
class TransformError(Exception):
160+
pass
161+
162+
159163
ForeignKeyIndicator = Union[
160164
str,
161165
ForeignKey,
@@ -1972,6 +1976,30 @@ def transform_sql(
19721976
sqls.append(
19731977
"ALTER TABLE [{}] RENAME TO [{}];".format(new_table_name, self.name)
19741978
)
1979+
# Re-add existing indexes
1980+
for index in self.indexes:
1981+
if index.origin != "pk":
1982+
index_sql = self.db.execute(
1983+
"""SELECT sql FROM sqlite_master WHERE type = 'index' AND name = :index_name;""",
1984+
{"index_name": index.name},
1985+
).fetchall()[0][0]
1986+
if index_sql is None:
1987+
raise TransformError(
1988+
f"Index '{index.name}' on table '{self.name}' does not have a "
1989+
"CREATE INDEX statement. You must manually drop this index prior to running this "
1990+
"transformation and manually recreate the new index after running this transformation."
1991+
)
1992+
if keep_table:
1993+
sqls.append(f"DROP INDEX IF EXISTS [{index.name}];")
1994+
for col in index.columns:
1995+
if col in rename.keys() or col in drop:
1996+
raise TransformError(
1997+
f"Index '{index.name}' column '{col}' is not in updated table '{self.name}'. "
1998+
f"You must manually drop this index prior to running this transformation "
1999+
f"and manually recreate the new index after running this transformation. "
2000+
f"The original index sql statement is: `{index_sql}`. No changes have been applied to this table."
2001+
)
2002+
sqls.append(index_sql)
19752003
return sqls
19762004

19772005
def extract(

tests/test_transform.py

Lines changed: 123 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from sqlite_utils.db import ForeignKey
1+
from sqlite_utils.db import ForeignKey, TransformError
22
from sqlite_utils.utils import OperationalError
33
import pytest
44

@@ -539,3 +539,125 @@ def test_transform_strict(fresh_db, strict):
539539
assert dogs.strict == strict or not fresh_db.supports_strict
540540
dogs.transform(not_null={"name"})
541541
assert dogs.strict == strict or not fresh_db.supports_strict
542+
543+
544+
@pytest.mark.parametrize(
545+
"indexes, transform_params",
546+
[
547+
([["name"]], {"types": {"age": str}}),
548+
([["name"], ["age", "breed"]], {"types": {"age": str}}),
549+
([], {"types": {"age": str}}),
550+
([["name"]], {"types": {"age": str}, "keep_table": "old_dogs"}),
551+
],
552+
)
553+
def test_transform_indexes(fresh_db, indexes, transform_params):
554+
# https://github.com/simonw/sqlite-utils/issues/633
555+
# New table should have same indexes as old table after transformation
556+
dogs = fresh_db["dogs"]
557+
dogs.insert({"id": 1, "name": "Cleo", "age": 5, "breed": "Labrador"}, pk="id")
558+
559+
for index in indexes:
560+
dogs.create_index(index)
561+
562+
indexes_before_transform = dogs.indexes
563+
564+
dogs.transform(**transform_params)
565+
566+
assert sorted(
567+
[
568+
{k: v for k, v in idx._asdict().items() if k != "seq"}
569+
for idx in dogs.indexes
570+
],
571+
key=lambda x: x["name"],
572+
) == sorted(
573+
[
574+
{k: v for k, v in idx._asdict().items() if k != "seq"}
575+
for idx in indexes_before_transform
576+
],
577+
key=lambda x: x["name"],
578+
), f"Indexes before transform: {indexes_before_transform}\nIndexes after transform: {dogs.indexes}"
579+
if "keep_table" in transform_params:
580+
assert all(
581+
index.origin == "pk"
582+
for index in fresh_db[transform_params["keep_table"]].indexes
583+
)
584+
585+
586+
def test_transform_retains_indexes_with_foreign_keys(fresh_db):
587+
dogs = fresh_db["dogs"]
588+
owners = fresh_db["owners"]
589+
590+
dogs.insert({"id": 1, "name": "Cleo", "owner_id": 1}, pk="id")
591+
owners.insert({"id": 1, "name": "Alice"}, pk="id")
592+
593+
dogs.create_index(["name"])
594+
595+
indexes_before_transform = dogs.indexes
596+
597+
fresh_db.add_foreign_keys([("dogs", "owner_id", "owners", "id")]) # calls transform
598+
599+
assert sorted(
600+
[
601+
{k: v for k, v in idx._asdict().items() if k != "seq"}
602+
for idx in dogs.indexes
603+
],
604+
key=lambda x: x["name"],
605+
) == sorted(
606+
[
607+
{k: v for k, v in idx._asdict().items() if k != "seq"}
608+
for idx in indexes_before_transform
609+
],
610+
key=lambda x: x["name"],
611+
), f"Indexes before transform: {indexes_before_transform}\nIndexes after transform: {dogs.indexes}"
612+
613+
614+
@pytest.mark.parametrize(
615+
"transform_params",
616+
[
617+
{"rename": {"age": "dog_age"}},
618+
{"drop": ["age"]},
619+
],
620+
)
621+
def test_transform_with_indexes_errors(fresh_db, transform_params):
622+
# Should error with a compound (name, age) index if age is renamed or dropped
623+
dogs = fresh_db["dogs"]
624+
dogs.insert({"id": 1, "name": "Cleo", "age": 5}, pk="id")
625+
626+
dogs.create_index(["name", "age"])
627+
628+
with pytest.raises(TransformError) as excinfo:
629+
dogs.transform(**transform_params)
630+
631+
assert (
632+
"Index 'idx_dogs_name_age' column 'age' is not in updated table 'dogs'. "
633+
"You must manually drop this index prior to running this transformation"
634+
in str(excinfo.value)
635+
)
636+
637+
638+
def test_transform_with_unique_constraint_implicit_index(fresh_db):
639+
dogs = fresh_db["dogs"]
640+
# Create a table with a UNIQUE constraint on 'name', which creates an implicit index
641+
fresh_db.execute(
642+
"""
643+
CREATE TABLE dogs (
644+
id INTEGER PRIMARY KEY,
645+
name TEXT UNIQUE,
646+
age INTEGER
647+
);
648+
"""
649+
)
650+
dogs.insert({"id": 1, "name": "Cleo", "age": 5})
651+
652+
# Attempt to transform the table without modifying 'name'
653+
with pytest.raises(TransformError) as excinfo:
654+
dogs.transform(types={"age": str})
655+
656+
assert (
657+
"Index 'sqlite_autoindex_dogs_1' on table 'dogs' does not have a CREATE INDEX statement."
658+
in str(excinfo.value)
659+
)
660+
assert (
661+
"You must manually drop this index prior to running this transformation and manually recreate the new index after running this transformation."
662+
in str(excinfo.value)
663+
)

0 commit comments

Comments
 (0)