sqlite-utils/tests/test_introspect.py
Simon Willison 8e015d024c Compound primary keys now resolve in PRIMARY KEY declaration order
PRAGMA table_info sets is_pk to the 1-based position of each column
within the PRIMARY KEY, which can differ from table column order.
table.pks previously returned table column order, so an implicit
compound FOREIGN KEY ... REFERENCES other was introspected with its
referenced columns inverted, and transform() baked that inverted order
into the rewritten schema - failing with IntegrityError on valid data,
or silently reversing the constraint with foreign keys off.

table.pks, compound foreign key guessing (create, add_foreign_key) and
transform() now all use declaration order, and transform() no longer
reorders a compound PRIMARY KEY (b, a) into table column order.

Refs https://github.com/simonw/sqlite-utils/issues/769#issuecomment-4900034150

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 21:34:02 -07:00

338 lines
11 KiB
Python

from sqlite_utils.db import Index, View, Database, XIndex, XIndexColumn
import pytest
def _check_supports_strict():
"""Check if SQLite supports strict tables without leaking the database."""
db = Database(memory=True)
result = db.supports_strict
db.close()
return result
def test_table_names(existing_db):
assert ["foo"] == existing_db.table_names()
def test_view_names(fresh_db):
fresh_db.create_view("foo_view", "select 1")
assert ["foo_view"] == fresh_db.view_names()
def test_table_names_fts4(existing_db):
existing_db["woo"].insert({"title": "Hello"}).enable_fts(
["title"], fts_version="FTS4"
)
existing_db["woo2"].insert({"title": "Hello"}).enable_fts(
["title"], fts_version="FTS5"
)
assert ["woo_fts"] == existing_db.table_names(fts4=True)
assert ["woo2_fts"] == existing_db.table_names(fts5=True)
def test_detect_fts(existing_db):
existing_db["woo"].insert({"title": "Hello"}).enable_fts(
["title"], fts_version="FTS4"
)
existing_db["woo2"].insert({"title": "Hello"}).enable_fts(
["title"], fts_version="FTS5"
)
assert "woo_fts" == existing_db["woo"].detect_fts()
assert "woo_fts" == existing_db["woo_fts"].detect_fts()
assert "woo2_fts" == existing_db["woo2"].detect_fts()
assert "woo2_fts" == existing_db["woo2_fts"].detect_fts()
assert existing_db["foo"].detect_fts() is None
@pytest.mark.parametrize("reverse_order", (True, False))
def test_detect_fts_similar_tables(fresh_db, reverse_order):
# https://github.com/simonw/sqlite-utils/issues/434
table1, table2 = ("demo", "demo2")
if reverse_order:
table1, table2 = table2, table1
fresh_db[table1].insert({"title": "Hello"}).enable_fts(
["title"], fts_version="FTS4"
)
fresh_db[table2].insert({"title": "Hello"}).enable_fts(
["title"], fts_version="FTS4"
)
assert fresh_db[table1].detect_fts() == "{}_fts".format(table1)
assert fresh_db[table2].detect_fts() == "{}_fts".format(table2)
def test_tables(existing_db):
assert len(existing_db.tables) == 1
assert existing_db.tables[0].name == "foo"
def test_views(fresh_db):
fresh_db.create_view("foo_view", "select 1")
assert len(fresh_db.views) == 1
view = fresh_db.views[0]
assert isinstance(view, View)
assert view.name == "foo_view"
assert repr(view) == "<View foo_view (1)>"
assert view.columns_dict == {"1": str}
def test_count(existing_db):
assert existing_db["foo"].count == 3
assert existing_db["foo"].count_where() == 3
assert existing_db["foo"].execute_count() == 3
def test_count_where(existing_db):
assert existing_db["foo"].count_where("text != ?", ["two"]) == 2
assert existing_db["foo"].count_where("text != :t", {"t": "two"}) == 2
def test_columns(existing_db):
table = existing_db["foo"]
assert [{"name": "text", "type": "TEXT"}] == [
{"name": col.name, "type": col.type} for col in table.columns
]
def test_table_schema(existing_db):
assert existing_db["foo"].schema == "CREATE TABLE foo (text TEXT)"
def test_database_schema(existing_db):
assert existing_db.schema == "CREATE TABLE foo (text TEXT);"
def test_table_repr(fresh_db):
table = fresh_db["dogs"].insert({"name": "Cleo", "age": 4})
assert "<Table dogs (name, age)>" == repr(table)
assert "<Table cats (does not exist yet)>" == repr(fresh_db["cats"])
def test_indexes(fresh_db):
fresh_db.executescript("""
create table Gosh (c1 text, c2 text, c3 text);
create index Gosh_c1 on Gosh(c1);
create index Gosh_c2c3 on Gosh(c2, c3);
""")
assert [
Index(
seq=0,
name="Gosh_c2c3",
unique=0,
origin="c",
partial=0,
columns=["c2", "c3"],
),
Index(seq=1, name="Gosh_c1", unique=0, origin="c", partial=0, columns=["c1"]),
] == fresh_db["Gosh"].indexes
def test_xindexes(fresh_db):
fresh_db.executescript("""
create table Gosh (c1 text, c2 text, c3 text);
create index Gosh_c1 on Gosh(c1);
create index Gosh_c2c3 on Gosh(c2, c3 desc);
""")
assert fresh_db["Gosh"].xindexes == [
XIndex(
name="Gosh_c2c3",
columns=[
XIndexColumn(seqno=0, cid=1, name="c2", desc=0, coll="BINARY", key=1),
XIndexColumn(seqno=1, cid=2, name="c3", desc=1, coll="BINARY", key=1),
XIndexColumn(seqno=2, cid=-1, name=None, desc=0, coll="BINARY", key=0),
],
),
XIndex(
name="Gosh_c1",
columns=[
XIndexColumn(seqno=0, cid=0, name="c1", desc=0, coll="BINARY", key=1),
XIndexColumn(seqno=1, cid=-1, name=None, desc=0, coll="BINARY", key=0),
],
),
]
@pytest.mark.parametrize(
"column,expected_table_guess",
(
("author", "authors"),
("author_id", "authors"),
("authors", "authors"),
("genre", "genre"),
("genre_id", "genre"),
),
)
def test_guess_foreign_table(fresh_db, column, expected_table_guess):
fresh_db.create_table("authors", {"name": str})
fresh_db.create_table("genre", {"name": str})
assert expected_table_guess == fresh_db["books"].guess_foreign_table(column)
@pytest.mark.parametrize(
"pk,expected", ((None, ["rowid"]), ("id", ["id"]), (["id", "id2"], ["id", "id2"]))
)
def test_pks(fresh_db, pk, expected):
fresh_db["foo"].insert_all([{"id": 1, "id2": 2}], pk=pk)
assert expected == fresh_db["foo"].pks
def test_triggers_and_triggers_dict(fresh_db):
assert [] == fresh_db.triggers
authors = fresh_db["authors"]
authors.insert_all(
[
{"name": "Frank Herbert", "famous_works": "Dune"},
{"name": "Neal Stephenson", "famous_works": "Cryptonomicon"},
]
)
fresh_db["other"].insert({"foo": "bar"})
assert authors.triggers == []
assert authors.triggers_dict == {}
assert fresh_db["other"].triggers == []
assert fresh_db.triggers_dict == {}
authors.enable_fts(
["name", "famous_works"], fts_version="FTS4", create_triggers=True
)
expected_triggers = {
("authors_ai", "authors"),
("authors_ad", "authors"),
("authors_au", "authors"),
}
assert expected_triggers == {(t.name, t.table) for t in fresh_db.triggers}
assert expected_triggers == {
(t.name, t.table) for t in fresh_db["authors"].triggers
}
expected_triggers = {
"authors_ai": (
'CREATE TRIGGER "authors_ai" AFTER INSERT ON "authors" BEGIN\n'
' INSERT INTO "authors_fts" (rowid, "name", "famous_works") VALUES (new.rowid, new."name", new."famous_works");\n'
"END"
),
"authors_ad": (
'CREATE TRIGGER "authors_ad" AFTER DELETE ON "authors" BEGIN\n'
' INSERT INTO "authors_fts" ("authors_fts", rowid, "name", "famous_works") VALUES(\'delete\', old.rowid, old."name", old."famous_works");\n'
"END"
),
"authors_au": (
'CREATE TRIGGER "authors_au" AFTER UPDATE ON "authors" BEGIN\n'
' INSERT INTO "authors_fts" ("authors_fts", rowid, "name", "famous_works") VALUES(\'delete\', old.rowid, old."name", old."famous_works");\n'
' INSERT INTO "authors_fts" (rowid, "name", "famous_works") VALUES (new.rowid, new."name", new."famous_works");\nEND'
),
}
assert authors.triggers_dict == expected_triggers
assert fresh_db["other"].triggers == []
assert fresh_db["other"].triggers_dict == {}
assert fresh_db.triggers_dict == expected_triggers
def test_has_counts_triggers(fresh_db):
authors = fresh_db["authors"]
authors.insert({"name": "Frank Herbert"})
assert not authors.has_counts_triggers
authors.enable_counts()
assert authors.has_counts_triggers
@pytest.mark.parametrize(
"sql,expected_name,expected_using",
[
(
"""
CREATE VIRTUAL TABLE foo USING FTS5(name)
""",
"foo",
"FTS5",
),
(
"""
CREATE VIRTUAL TABLE "foo" USING FTS4(name)
""",
"foo",
"FTS4",
),
(
"""
CREATE VIRTUAL TABLE IF NOT EXISTS `foo` USING FTS4(name)
""",
"foo",
"FTS4",
),
(
"""
CREATE VIRTUAL TABLE IF NOT EXISTS `foo` USING fts5(name)
""",
"foo",
"FTS5",
),
(
"""
CREATE TABLE IF NOT EXISTS `foo` (id integer primary key)
""",
"foo",
None,
),
],
)
def test_virtual_table_using(fresh_db, sql, expected_name, expected_using):
fresh_db.execute(sql)
assert fresh_db[expected_name].virtual_table_using == expected_using
def test_use_rowid(fresh_db):
fresh_db["rowid_table"].insert({"name": "Cleo"})
fresh_db["regular_table"].insert({"id": 1, "name": "Cleo"}, pk="id")
assert fresh_db["rowid_table"].use_rowid
assert not fresh_db["regular_table"].use_rowid
@pytest.mark.skipif(
not _check_supports_strict(),
reason="Needs SQLite version that supports strict",
)
@pytest.mark.parametrize(
"create_table,expected_strict",
(
("create table t (id integer) strict", True),
("create table t (id integer) STRICT", True),
("create table t (id integer primary key) StriCt, WITHOUT ROWID", True),
("create table t (id integer primary key) WITHOUT ROWID", False),
("create table t (id integer)", False),
),
)
def test_table_strict(fresh_db, create_table, expected_strict):
fresh_db.execute(create_table)
table = fresh_db["t"]
assert table.strict == expected_strict
@pytest.mark.parametrize(
"value",
(
1,
1.3,
"foo",
True,
b"binary",
),
)
def test_table_default_values(fresh_db, value):
fresh_db["default_values"].insert(
{"nodefault": 1, "value": value}, defaults={"value": value}
)
default_values = fresh_db["default_values"].default_values
assert default_values == {"value": value}
def test_pks_use_primary_key_declaration_order(fresh_db):
# PRIMARY KEY (a, b) declared against columns stored in order (b, a) -
# pks must follow the declaration order, which is what SQLite uses to
# resolve implicit foreign key references and compound pk lookups
fresh_db.execute("create table t (b text, a text, primary key (a, b))")
assert fresh_db["t"].pks == ["a", "b"]
def test_transform_preserves_compound_pk_declaration_order(fresh_db):
fresh_db.execute("create table t (a text, b text, c text, primary key (b, a))")
fresh_db["t"].transform(drop={"c"})
assert fresh_db["t"].pks == ["b", "a"]
assert 'PRIMARY KEY ("b", "a")' in fresh_db["t"].schema