db.table() only returns tables, added db.view(), refs #657

This commit is contained in:
Simon Willison 2025-05-08 22:53:07 -07:00
commit 290ad27d40
5 changed files with 100 additions and 74 deletions

View file

@ -251,21 +251,36 @@ In this example ``next()`` is used to retrieve the first result in the iterator
Accessing tables Accessing tables
================ ================
Tables are accessed using the indexing operator, like so: Tables are accessed using the ``db.table()`` method, like so:
.. code-block:: python
table = db["my_table"]
If the table does not yet exist, it will be created the first time you attempt to insert or upsert data into it.
You can also access tables using the ``.table()`` method like so:
.. code-block:: python .. code-block:: python
table = db.table("my_table") table = db.table("my_table")
Using this factory function allows you to set :ref:`python_api_table_configuration`. Using this factory function allows you to set :ref:`python_api_table_configuration`. Additional keyword arguments to ``db.table()`` will be used if a further method call causes the table to be created.
The ``db.table()`` method will always return a :ref:`reference_db_table` instance, or raise a ``sqlite_utils.db.NoTable`` exception if the table name is actually a SQL view.
You can also access tables or views using dictionary-style syntax, like this:
.. code-block:: python
table = db["my_table"]
If a table accessed using either of these methods does not yet exist, it will be created the first time you attempt to insert or upsert data into it.
.. _python_api_view:
Accessing views
===============
SQL views can be accessed using the ``db.view()`` method, like so:
.. code-block:: python
view = db.view("my_view")
This will return a :ref:`reference_db_view` instance, or raise a ``sqlite_utils.db.NoView`` exception if the view does not exist.
.. _python_api_tables: .. _python_api_tables:

View file

@ -186,6 +186,8 @@ def tables(
if schema: if schema:
headers.append("schema") headers.append("schema")
method = db.view if views else db.table
def _iter(): def _iter():
if views: if views:
items = db.view_names() items = db.view_names()
@ -194,15 +196,15 @@ def tables(
for name in items: for name in items:
row = [name] row = [name]
if counts: if counts:
row.append(db[name].count) row.append(method(name).count)
if columns: if columns:
cols = [c.name for c in db[name].columns] cols = [c.name for c in method(name).columns]
if csv: if csv:
row.append("\n".join(cols)) row.append("\n".join(cols))
else: else:
row.append(cols) row.append(cols)
if schema: if schema:
row.append(db[name].schema) row.append(method(name).schema)
yield row yield row
if table or fmt: if table or fmt:
@ -1693,7 +1695,7 @@ def create_view(path, view, select, ignore, replace, load_extension):
if ignore: if ignore:
return return
elif replace: elif replace:
db[view].drop() db.view(view).drop()
else: else:
raise click.ClickException( raise click.ClickException(
'View "{}" already exists. Use --replace to delete and replace it.'.format( 'View "{}" already exists. Use --replace to delete and replace it.'.format(

View file

@ -247,6 +247,10 @@ class NoTable(Exception):
"Specified table does not exist" "Specified table does not exist"
class NoView(Exception):
"Specified view does not exist"
class BadPrimaryKey(Exception): class BadPrimaryKey(Exception):
"Table does not have a single obvious primary key" "Table does not have a single obvious primary key"
@ -419,6 +423,8 @@ class Database:
:param table_name: The name of the table :param table_name: The name of the table
""" """
if table_name in self.view_names():
return self.view(table_name)
return self.table(table_name) return self.table(table_name)
def __repr__(self) -> str: def __repr__(self) -> str:
@ -541,7 +547,7 @@ class Database:
self._tracer(sql, None) self._tracer(sql, None)
return self.conn.executescript(sql) return self.conn.executescript(sql)
def table(self, table_name: str, **kwargs) -> Union["Table", "View"]: def table(self, table_name: str, **kwargs) -> "Table":
""" """
Return a table object, optionally configured with default options. Return a table object, optionally configured with default options.
@ -550,10 +556,19 @@ class Database:
:param table_name: Name of the table :param table_name: Name of the table
""" """
if table_name in self.view_names(): if table_name in self.view_names():
return View(self, table_name, **kwargs) raise NoTable("Table {} is actually a view".format(table_name))
else: kwargs.setdefault("strict", self.strict)
kwargs.setdefault("strict", self.strict) return Table(self, table_name, **kwargs)
return Table(self, table_name, **kwargs)
def view(self, view_name: str) -> "View":
"""
Return a view object.
:param view_name: Name of the view
"""
if view_name not in self.view_names():
raise NoView("View {} does not exist".format(view_name))
return View(self, view_name)
def quote(self, value: str) -> str: def quote(self, value: str) -> str:
""" """
@ -637,12 +652,12 @@ class Database:
@property @property
def tables(self) -> List["Table"]: def tables(self) -> List["Table"]:
"List of Table objects in this database." "List of Table objects in this database."
return cast(List["Table"], [self[name] for name in self.table_names()]) return [self.table(name) for name in self.table_names()]
@property @property
def views(self) -> List["View"]: def views(self) -> List["View"]:
"List of View objects in this database." "List of View objects in this database."
return cast(List["View"], [self[name] for name in self.view_names()]) return [self.view(name) for name in self.view_names()]
@property @property
def triggers(self) -> List[Trigger]: def triggers(self) -> List[Trigger]:
@ -808,7 +823,7 @@ class Database:
or a tuple of (column, other_table, other_column), or a tuple of or a tuple of (column, other_table, other_column), or a tuple of
(table, column, other_table, other_column) (table, column, other_table, other_column)
""" """
table = cast(Table, self[name]) table = self.table(name)
if all(isinstance(fk, ForeignKey) for fk in foreign_keys): if all(isinstance(fk, ForeignKey) for fk in foreign_keys):
return cast(List[ForeignKey], foreign_keys) return cast(List[ForeignKey], foreign_keys)
if all(isinstance(fk, str) for fk in foreign_keys): if all(isinstance(fk, str) for fk in foreign_keys):
@ -1039,11 +1054,11 @@ class Database:
# Transform table to match the new definition if table already exists: # Transform table to match the new definition if table already exists:
if self[name].exists(): if self[name].exists():
if ignore: if ignore:
return cast(Table, self[name]) return self.table(name)
elif replace: elif replace:
self[name].drop() self[name].drop()
if transform and self[name].exists(): if transform and self[name].exists():
table = cast(Table, self[name]) table = self.table(name)
should_transform = False should_transform = False
# First add missing columns and figure out columns to drop # First add missing columns and figure out columns to drop
existing_columns = table.columns_dict existing_columns = table.columns_dict
@ -1109,7 +1124,7 @@ class Database:
strict=strict, strict=strict,
) )
self.execute(sql) self.execute(sql)
created_table = self.table( return self.table(
name, name,
pk=pk, pk=pk,
foreign_keys=foreign_keys, foreign_keys=foreign_keys,
@ -1119,7 +1134,6 @@ class Database:
hash_id=hash_id, hash_id=hash_id,
hash_id_columns=hash_id_columns, hash_id_columns=hash_id_columns,
) )
return cast(Table, created_table)
def rename_table(self, name: str, new_name: str): def rename_table(self, name: str, new_name: str):
""" """
@ -1196,12 +1210,9 @@ class Database:
# Verify that all tables and columns exist # Verify that all tables and columns exist
for table, column, other_table, other_column in foreign_keys: for table, column, other_table, other_column in foreign_keys:
if not self[table].exists(): if not self.table(table).exists():
raise AlterError("No such table: {}".format(table)) raise AlterError("No such table: {}".format(table))
table_obj = self[table] table_obj = self.table(table)
if not isinstance(table_obj, Table):
raise AlterError("Must be a table, not a view: {}".format(table))
table_obj = cast(Table, table_obj)
if column not in table_obj.columns_dict: if column not in table_obj.columns_dict:
raise AlterError("No such column: {} in {}".format(column, table)) raise AlterError("No such column: {} in {}".format(column, table))
if not self[other_table].exists(): if not self[other_table].exists():
@ -1231,7 +1242,7 @@ class Database:
by_table.setdefault(fk[0], []).append(fk) by_table.setdefault(fk[0], []).append(fk)
for table, fks in by_table.items(): for table, fks in by_table.items():
cast(Table, self[table]).transform(add_foreign_keys=fks) self.table(table).transform(add_foreign_keys=fks)
self.vacuum() self.vacuum()
@ -3655,7 +3666,7 @@ class Table(Queryable):
already exists. already exists.
""" """
if isinstance(other_table, str): if isinstance(other_table, str):
other_table = cast(Table, self.db.table(other_table, pk=pk)) other_table = self.db.table(other_table, pk=pk)
our_id = self.last_pk our_id = self.last_pk
if lookup is not None: if lookup is not None:
assert record_or_iterable is None, "Provide lookup= or record, not both" assert record_or_iterable is None, "Provide lookup= or record, not both"

View file

@ -129,7 +129,7 @@ def test_uses_counts_after_enable_counts(counts_db_path):
db = Database(counts_db_path) db = Database(counts_db_path)
logged = [] logged = []
with db.tracer(lambda sql, parameters: logged.append((sql, parameters))): with db.tracer(lambda sql, parameters: logged.append((sql, parameters))):
assert db["foo"].count == 1 assert db.table("foo").count == 1
assert logged == [ assert logged == [
("select name from sqlite_master where type = 'view'", None), ("select name from sqlite_master where type = 'view'", None),
("select count(*) from [foo]", []), ("select count(*) from [foo]", []),
@ -138,7 +138,7 @@ def test_uses_counts_after_enable_counts(counts_db_path):
assert not db.use_counts_table assert not db.use_counts_table
db.enable_counts() db.enable_counts()
assert db.use_counts_table assert db.use_counts_table
assert db["foo"].count == 1 assert db.table("foo").count == 1
assert logged == [ assert logged == [
( (
"CREATE TABLE IF NOT EXISTS [_counts](\n [table] TEXT PRIMARY KEY,\n count INTEGER DEFAULT 0\n);", "CREATE TABLE IF NOT EXISTS [_counts](\n [table] TEXT PRIMARY KEY,\n count INTEGER DEFAULT 0\n);",

View file

@ -6,20 +6,21 @@ def test_tracer():
db = Database( db = Database(
memory=True, tracer=lambda sql, params: collected.append((sql, params)) memory=True, tracer=lambda sql, params: collected.append((sql, params))
) )
db["dogs"].insert({"name": "Cleopaws"}) dogs = db.table("dogs")
db["dogs"].enable_fts(["name"]) dogs.insert({"name": "Cleopaws"})
db["dogs"].search("Cleopaws") dogs.enable_fts(["name"])
dogs.search("Cleopaws")
assert collected == [ assert collected == [
("PRAGMA recursive_triggers=on;", None), ("PRAGMA recursive_triggers=on;", None),
("select name from sqlite_master where type = 'view'", None), ("select name from sqlite_master where type = 'view'", None),
("select name from sqlite_master where type = 'table'", None), ("select name from sqlite_master where type = 'table'", None),
("select name from sqlite_master where type = 'view'", None), ("select name from sqlite_master where type = 'view'", None),
("select name from sqlite_master where type = 'view'", None),
("select name from sqlite_master where type = 'table'", None), ("select name from sqlite_master where type = 'table'", None),
("select name from sqlite_master where type = 'view'", None), ("select name from sqlite_master where type = 'view'", None),
("CREATE TABLE [dogs] (\n [name] TEXT\n);\n ", None), ("CREATE TABLE [dogs] (\n [name] TEXT\n);\n ", None),
("select name from sqlite_master where type = 'view'", None), ("select name from sqlite_master where type = 'view'", None),
("INSERT INTO [dogs] ([name]) VALUES (?)", ["Cleopaws"]), ("INSERT INTO [dogs] ([name]) VALUES (?)", ["Cleopaws"]),
("select name from sqlite_master where type = 'view'", None),
( (
"CREATE VIRTUAL TABLE [dogs_fts] USING FTS5 (\n [name],\n content=[dogs]\n)", "CREATE VIRTUAL TABLE [dogs_fts] USING FTS5 (\n [name],\n content=[dogs]\n)",
None, None,
@ -28,7 +29,6 @@ def test_tracer():
"INSERT INTO [dogs_fts] (rowid, [name])\n SELECT rowid, [name] FROM [dogs];", "INSERT INTO [dogs_fts] (rowid, [name])\n SELECT rowid, [name] FROM [dogs];",
None, None,
), ),
("select name from sqlite_master where type = 'view'", None),
] ]
@ -40,60 +40,58 @@ def test_with_tracer():
db = Database(memory=True) db = Database(memory=True)
db["dogs"].insert({"name": "Cleopaws"}) dogs = db.table("dogs")
db["dogs"].enable_fts(["name"])
dogs.insert({"name": "Cleopaws"})
dogs.enable_fts(["name"])
assert len(collected) == 0 assert len(collected) == 0
with db.tracer(tracer): with db.tracer(tracer):
list(db["dogs"].search("Cleopaws")) list(dogs.search("Cleopaws"))
assert len(collected) == 5 assert len(collected) == 5
assert collected == [ assert collected == [
("select name from sqlite_master where type = 'view'", None),
( (
( "SELECT name FROM sqlite_master\n"
"SELECT name FROM sqlite_master\n" " WHERE rootpage = 0\n"
" WHERE rootpage = 0\n" " AND (\n"
" AND (\n" " sql LIKE :like\n"
" sql LIKE :like\n" " OR sql LIKE :like2\n"
" OR sql LIKE :like2\n" " OR (\n"
" OR (\n" " tbl_name = :table\n"
" tbl_name = :table\n" " AND sql LIKE '%VIRTUAL TABLE%USING FTS%'\n"
" AND sql LIKE '%VIRTUAL TABLE%USING FTS%'\n" " )\n"
" )\n" " )",
" )", {
{ "like": "%VIRTUAL TABLE%USING FTS%content=[dogs]%",
"like": "%VIRTUAL TABLE%USING FTS%content=[dogs]%", "like2": '%VIRTUAL TABLE%USING FTS%content="dogs"%',
"like2": '%VIRTUAL TABLE%USING FTS%content="dogs"%', "table": "dogs",
"table": "dogs", },
},
)
), ),
("select name from sqlite_master where type = 'view'", None), ("select name from sqlite_master where type = 'view'", None),
("select name from sqlite_master where type = 'view'", None),
("select sql from sqlite_master where name = ?", ("dogs_fts",)), ("select sql from sqlite_master where name = ?", ("dogs_fts",)),
( (
( "with original as (\n"
"with original as (\n" " select\n"
" select\n" " rowid,\n"
" rowid,\n" " *\n"
" *\n" " from [dogs]\n"
" from [dogs]\n" ")\n"
")\n" "select\n"
"select\n" " [original].*\n"
" [original].*\n" "from\n"
"from\n" " [original]\n"
" [original]\n" " join [dogs_fts] on [original].rowid = [dogs_fts].rowid\n"
" join [dogs_fts] on [original].rowid = [dogs_fts].rowid\n" "where\n"
"where\n" " [dogs_fts] match :query\n"
" [dogs_fts] match :query\n" "order by\n"
"order by\n" " [dogs_fts].rank",
" [dogs_fts].rank"
),
{"query": "Cleopaws"}, {"query": "Cleopaws"},
), ),
] ]
# Outside the with block collected should not be appended to # Outside the with block collected should not be appended to
db["dogs"].insert({"name": "Cleopaws"}) dogs.insert({"name": "Cleopaws"})
assert len(collected) == 5 assert len(collected) == 5