mirror of
https://github.com/simonw/sqlite-utils.git
synced 2026-09-07 17:14:09 +02:00
Make tables in attached/temp schemas first-class (issue #432)
Table/View gain an explicit alias attribute for their attached or temp schema. db["alias.table"] resolves it when alias names a real attached database or the temp schema (schema_names), leaving plain and literally-dotted main-schema names unaffected. exists(), columns, schema, indexes, foreign_keys, triggers, and the read/write path (rows_where, get, insert/insert_all, upsert, update, delete, delete_where, add_column, drop, analyze) are all schema-qualified correctly instead of silently checking/writing the main schema. Methods that would need deeper cross-schema rework (create, transform, create_index, enable_fts and friends, add_foreign_key, extract, duplicate, spatial columns) raise a clear NotImplementedError for aliased tables instead of silently operating on a same-named main-schema table.
This commit is contained in:
parent
050f11f31f
commit
fe5260e47e
3 changed files with 263 additions and 49 deletions
|
|
@ -184,6 +184,21 @@ You can attach an additional database using the ``.attach()`` method, providing
|
|||
|
||||
You can reference tables in the attached database using the alias value you passed to ``db.attach(alias, filepath)`` as a prefix, for example the ``second.table_in_second`` reference in the SQL query above.
|
||||
|
||||
The same ``alias.table`` syntax works with ``db[...]`` to get back a :class:`.Table` object for a table in the attached database, with ``.alias`` set to the attached database's alias:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
table = db["second.table_in_second"]
|
||||
table.alias # "second"
|
||||
table.exists() # True, if it exists in second.db
|
||||
list(table.rows_where("id > ?", [10]))
|
||||
table.insert({"id": 20, "name": "example"})
|
||||
table.delete_where("id = ?", [20])
|
||||
|
||||
Core operations - ``exists()``, ``columns``, ``schema``, ``rows_where()``, ``get()``, ``insert()``/``insert_all()``, ``update()``, ``delete()``/``delete_where()``, ``add_column()`` and ``drop()`` - all work against attached tables this way, as does the ``temp`` schema for tables created with ``CREATE TEMPORARY TABLE``, for example ``db["temp.scratch"]``.
|
||||
|
||||
Schema-mutating methods that are not yet supported for attached or temp tables - such as ``.create()``, ``.transform()``, ``.create_index()`` and ``.enable_fts()`` - raise ``NotImplementedError`` rather than silently operating on a same-named table in the main schema.
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils --attach <cli_query_attach>`
|
||||
|
||||
|
|
|
|||
|
|
@ -720,11 +720,30 @@ class Database:
|
|||
If neither exists yet, a table is assumed - it will be created the first
|
||||
time data is inserted into it.
|
||||
|
||||
:param table_name: The name of the table or view
|
||||
A name of the form ``"alias.table"``, where ``alias`` is the ``temp``
|
||||
schema or a database attached with :meth:`.attach`, returns the table
|
||||
or view from that schema instead of the main one - see :ref:`python_api_attach`.
|
||||
|
||||
:param table_name: The name of the table or view, optionally prefixed with ``alias.``
|
||||
"""
|
||||
if table_name in self.view_names():
|
||||
return self.view(table_name)
|
||||
return self.table(table_name)
|
||||
alias, table_name = self._resolve_alias(table_name)
|
||||
if table_name in self.view_names(alias=alias):
|
||||
return self.view(table_name, alias=alias)
|
||||
return self.table(table_name, alias=alias)
|
||||
|
||||
def _resolve_alias(self, table_name: str) -> tuple[str | None, str]:
|
||||
"""
|
||||
Split ``"alias.table"`` into ``(alias, table)`` when ``alias`` names the
|
||||
``temp`` schema or a database attached with :meth:`.attach`. Otherwise
|
||||
returns ``(None, table_name)`` unchanged, so a plain name - or one that
|
||||
happens to contain a "." but does not match a known schema - is treated
|
||||
as a literal main-schema table name exactly as before.
|
||||
"""
|
||||
if "." in table_name:
|
||||
alias, _, rest = table_name.partition(".")
|
||||
if alias in self.schema_names:
|
||||
return alias, rest
|
||||
return None, table_name
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Database {self.conn}>"
|
||||
|
|
@ -942,32 +961,36 @@ class Database:
|
|||
return cursor
|
||||
return self.conn.executescript(sql)
|
||||
|
||||
def table(self, table_name: str, **kwargs: Any) -> "Table":
|
||||
def table(self, table_name: str, alias: str | None = None, **kwargs: Any) -> "Table":
|
||||
"""
|
||||
Return a table object, optionally configured with default options.
|
||||
|
||||
See :ref:`reference_db_table` for option details.
|
||||
|
||||
:param table_name: Name of the table
|
||||
:param alias: Look for the table in this attached or ``temp`` schema
|
||||
instead of the main schema, see :ref:`python_api_attach`
|
||||
"""
|
||||
if table_name in self.view_names():
|
||||
if table_name in self.view_names(alias=alias):
|
||||
raise NoTable(f"Table {table_name} is actually a view")
|
||||
kwargs.setdefault("strict", self.strict)
|
||||
return Table(self, table_name, **kwargs)
|
||||
return Table(self, table_name, alias=alias, **kwargs)
|
||||
|
||||
def view(self, view_name: str) -> "View":
|
||||
def view(self, view_name: str, alias: str | None = None) -> "View":
|
||||
"""
|
||||
Return a view object.
|
||||
|
||||
:param view_name: Name of the view
|
||||
:param alias: Look for the view in this attached or ``temp`` schema
|
||||
instead of the main schema, see :ref:`python_api_attach`
|
||||
"""
|
||||
if view_name not in self.view_names():
|
||||
if view_name in self.table_names():
|
||||
if view_name not in self.view_names(alias=alias):
|
||||
if view_name in self.table_names(alias=alias):
|
||||
raise NoView(
|
||||
f"View {view_name} does not exist - {view_name} is a table"
|
||||
)
|
||||
raise NoView(f"View {view_name} does not exist")
|
||||
return View(self, view_name)
|
||||
return View(self, view_name, alias=alias)
|
||||
|
||||
def quote(self, value: str) -> str:
|
||||
"""
|
||||
|
|
@ -1027,30 +1050,56 @@ class Database:
|
|||
|
||||
return self.quote(value)
|
||||
|
||||
def table_names(self, fts4: bool = False, fts5: bool = False) -> list[str]:
|
||||
def table_names(
|
||||
self, fts4: bool = False, fts5: bool = False, alias: str | None = None
|
||||
) -> list[str]:
|
||||
"""
|
||||
List of string table names in this database.
|
||||
|
||||
:param fts4: Only return tables that are part of FTS4 indexes
|
||||
:param fts5: Only return tables that are part of FTS5 indexes
|
||||
:param alias: Look in this attached or temp schema instead of the main schema
|
||||
"""
|
||||
where = ["type = 'table'"]
|
||||
if fts4:
|
||||
where.append("sql like '%USING FTS4%'")
|
||||
if fts5:
|
||||
where.append("sql like '%USING FTS5%'")
|
||||
sql = "select name from sqlite_master where {}".format(" AND ".join(where))
|
||||
sql = "select name from {} where {}".format(
|
||||
self._sqlite_master(alias), " AND ".join(where)
|
||||
)
|
||||
return [r[0] for r in self.execute(sql).fetchall()]
|
||||
|
||||
def view_names(self) -> list[str]:
|
||||
"List of string view names in this database."
|
||||
def view_names(self, alias: str | None = None) -> list[str]:
|
||||
"""
|
||||
List of string view names in this database.
|
||||
|
||||
:param alias: Look in this attached or temp schema instead of the main schema
|
||||
"""
|
||||
return [
|
||||
r[0]
|
||||
for r in self.execute(
|
||||
"select name from sqlite_master where type = 'view'"
|
||||
f"select name from {self._sqlite_master(alias)} where type = 'view'"
|
||||
).fetchall()
|
||||
]
|
||||
|
||||
def _sqlite_master(self, alias: str | None = None) -> str:
|
||||
"``sqlite_master``, optionally qualified with an attached/temp schema alias."
|
||||
return f"{quote_identifier(alias)}.sqlite_master" if alias else "sqlite_master"
|
||||
|
||||
@property
|
||||
def schema_names(self) -> list[str]:
|
||||
"""
|
||||
Names of the ``temp`` schema and any databases attached with :meth:`.attach`,
|
||||
excluding ``main`` - the schema aliases that can be used to look up a
|
||||
:class:`.Table` or :class:`.View` in a schema other than the main one.
|
||||
"""
|
||||
return [
|
||||
row[1]
|
||||
for row in self.execute("PRAGMA database_list").fetchall()
|
||||
if row[1] != "main"
|
||||
]
|
||||
|
||||
@property
|
||||
def tables(self) -> list["Table"]:
|
||||
"List of Table objects in this database."
|
||||
|
|
@ -1571,6 +1620,14 @@ class Database:
|
|||
:param transform: If table already exists transform it to fit the specified schema
|
||||
:param strict: Apply STRICT mode to table
|
||||
"""
|
||||
if self._resolve_alias(name)[0]:
|
||||
# name looks like "alias.table" for a real attached/temp schema -
|
||||
# refuse rather than silently creating a same-named literal
|
||||
# table in the main schema instead, see Table.create()
|
||||
raise NotImplementedError(
|
||||
f"create_table() is not currently supported for a table in "
|
||||
f"an attached or temp schema ({name!r})"
|
||||
)
|
||||
# Transform table to match the new definition if table already exists:
|
||||
if self[name].exists():
|
||||
if ignore:
|
||||
|
|
@ -1931,14 +1988,34 @@ class Database:
|
|||
class Queryable:
|
||||
db: "Database"
|
||||
name: str
|
||||
alias: str | None
|
||||
|
||||
def exists(self) -> bool:
|
||||
"Does this table or view exist yet?"
|
||||
return False
|
||||
|
||||
def __init__(self, db: "Database", name: str) -> None:
|
||||
def __init__(self, db: "Database", name: str, alias: str | None = None) -> None:
|
||||
self.db = db
|
||||
self.name = name
|
||||
#: The attached or ``temp`` schema this table/view lives in, or ``None`` for the main schema
|
||||
self.alias = alias
|
||||
|
||||
@property
|
||||
def quoted_name(self) -> str:
|
||||
"This table or view's name, quoted, and schema-qualified if it has an alias."
|
||||
name = quote_identifier(self.name)
|
||||
return f"{quote_identifier(self.alias)}.{name}" if self.alias else name
|
||||
|
||||
@property
|
||||
def schema_prefix(self) -> str:
|
||||
"""
|
||||
``""``, or the quoted alias followed by a ``.`` - for prefixing a PRAGMA
|
||||
name (``PRAGMA {schema_prefix}table_info(...)``) or ``sqlite_master``
|
||||
(``select ... from {schema_prefix}sqlite_master``), the two places
|
||||
SQLite puts the schema name somewhere other than in front of the table
|
||||
name itself.
|
||||
"""
|
||||
return f"{quote_identifier(self.alias)}." if self.alias else ""
|
||||
|
||||
def count_where(
|
||||
self,
|
||||
|
|
@ -1952,7 +2029,7 @@ class Queryable:
|
|||
:param where_args: Parameters to use with that fragment - an iterable for ``id > ?``
|
||||
parameters, or a dictionary for ``id > :id``
|
||||
"""
|
||||
sql = f"select count(*) from {quote_identifier(self.name)}"
|
||||
sql = f"select count(*) from {self.quoted_name}"
|
||||
if where is not None:
|
||||
sql += " where " + where
|
||||
return self.db.execute(sql, where_args or []).fetchone()[0]
|
||||
|
|
@ -1995,7 +2072,7 @@ class Queryable:
|
|||
"""
|
||||
if not self.exists():
|
||||
return
|
||||
sql = f"select {select} from {quote_identifier(self.name)}"
|
||||
sql = f"select {select} from {self.quoted_name}"
|
||||
if where is not None:
|
||||
sql += " where " + where
|
||||
if order_by is not None:
|
||||
|
|
@ -2063,7 +2140,7 @@ class Queryable:
|
|||
if not self.exists():
|
||||
return []
|
||||
rows = self.db.execute(
|
||||
f"PRAGMA table_info({quote_identifier(self.name)})"
|
||||
f"PRAGMA {self.schema_prefix}table_info({quote_identifier(self.name)})"
|
||||
).fetchall()
|
||||
return [Column(*row) for row in rows]
|
||||
|
||||
|
|
@ -2076,7 +2153,8 @@ class Queryable:
|
|||
def schema(self) -> str:
|
||||
"SQL schema for this table or view."
|
||||
return self.db.execute(
|
||||
"select sql from sqlite_master where name = ?", (self.name,)
|
||||
f"select sql from {self.schema_prefix}sqlite_master where name = ?",
|
||||
(self.name,),
|
||||
).fetchone()[0]
|
||||
|
||||
|
||||
|
|
@ -2105,6 +2183,7 @@ class Table(Queryable):
|
|||
:param conversions: Dictionary of column names and conversion functions
|
||||
:param columns: Dictionary of column names to column types
|
||||
:param strict: If True, apply STRICT mode to table
|
||||
:param alias: Attached or ``temp`` schema this table lives in, see :ref:`python_api_attach`
|
||||
"""
|
||||
|
||||
#: The ``rowid`` of the last inserted, updated or selected row.
|
||||
|
|
@ -2131,8 +2210,9 @@ class Table(Queryable):
|
|||
conversions: dict | None = None,
|
||||
columns: dict[str, Any] | None = None,
|
||||
strict: bool = False,
|
||||
alias: str | None = None,
|
||||
):
|
||||
super().__init__(db, name)
|
||||
super().__init__(db, name, alias=alias)
|
||||
self._defaults = {
|
||||
"pk": pk,
|
||||
"foreign_keys": foreign_keys,
|
||||
|
|
@ -2171,7 +2251,16 @@ class Table(Queryable):
|
|||
return self.count_where()
|
||||
|
||||
def exists(self) -> bool:
|
||||
return self.name in self.db.table_names()
|
||||
return self.name in self.db.table_names(alias=self.alias)
|
||||
|
||||
def _check_no_alias(self, feature: str) -> None:
|
||||
"Raise a clear error instead of silently touching the wrong schema."
|
||||
if self.alias:
|
||||
raise NotImplementedError(
|
||||
f"{feature} is not currently supported for a table in an "
|
||||
f"attached or temp schema (table {self.name!r} has alias "
|
||||
f"{self.alias!r})"
|
||||
)
|
||||
|
||||
@property
|
||||
def pks(self) -> list[str]:
|
||||
|
|
@ -2237,7 +2326,7 @@ class Table(Queryable):
|
|||
# with "seq" giving the column order within a compound foreign key.
|
||||
by_id: dict[int, list] = {}
|
||||
for row in self.db.execute(
|
||||
f"PRAGMA foreign_key_list({quote_identifier(self.name)})"
|
||||
f"PRAGMA {self.schema_prefix}foreign_key_list({quote_identifier(self.name)})"
|
||||
).fetchall():
|
||||
if row is not None:
|
||||
id, seq, table_name, from_, to_, on_update, on_delete, _match = row
|
||||
|
|
@ -2283,14 +2372,14 @@ class Table(Queryable):
|
|||
@property
|
||||
def indexes(self) -> list[Index]:
|
||||
"List of indexes defined on this table."
|
||||
sql = f'PRAGMA index_list("{self.name}")'
|
||||
sql = f'PRAGMA {self.schema_prefix}index_list("{self.name}")'
|
||||
indexes = []
|
||||
for row in self.db.execute_returning_dicts(sql):
|
||||
index_name = row["name"]
|
||||
index_name_quoted = (
|
||||
f'"{index_name}"' if not index_name.startswith('"') else index_name
|
||||
)
|
||||
column_sql = f"PRAGMA index_info({index_name_quoted})"
|
||||
column_sql = f"PRAGMA {self.schema_prefix}index_info({index_name_quoted})"
|
||||
columns = []
|
||||
for seqno, cid, name in self.db.execute(column_sql).fetchall():
|
||||
columns.append(name)
|
||||
|
|
@ -2305,14 +2394,14 @@ class Table(Queryable):
|
|||
@property
|
||||
def xindexes(self) -> list[XIndex]:
|
||||
"List of indexes defined on this table using the more detailed ``XIndex`` format."
|
||||
sql = f'PRAGMA index_list("{self.name}")'
|
||||
sql = f'PRAGMA {self.schema_prefix}index_list("{self.name}")'
|
||||
indexes = []
|
||||
for row in self.db.execute_returning_dicts(sql):
|
||||
index_name = row["name"]
|
||||
index_name_quoted = (
|
||||
f'"{index_name}"' if not index_name.startswith('"') else index_name
|
||||
)
|
||||
column_sql = f"PRAGMA index_xinfo({index_name_quoted})"
|
||||
column_sql = f"PRAGMA {self.schema_prefix}index_xinfo({index_name_quoted})"
|
||||
index_columns = []
|
||||
for info in self.db.execute(column_sql).fetchall():
|
||||
index_columns.append(XIndexColumn(*info))
|
||||
|
|
@ -2325,8 +2414,8 @@ class Table(Queryable):
|
|||
return [
|
||||
Trigger(*r)
|
||||
for r in self.db.execute(
|
||||
"select name, tbl_name, sql from sqlite_master where type = 'trigger'"
|
||||
" and tbl_name = ?",
|
||||
f"select name, tbl_name, sql from {self.schema_prefix}sqlite_master"
|
||||
" where type = 'trigger' and tbl_name = ?",
|
||||
(self.name,),
|
||||
).fetchall()
|
||||
]
|
||||
|
|
@ -2389,6 +2478,7 @@ class Table(Queryable):
|
|||
:param transform: If table already exists transform it to fit the specified schema
|
||||
:param strict: Apply STRICT mode to table
|
||||
"""
|
||||
self._check_no_alias("create()")
|
||||
# Resolve defaults from _defaults (issue #655)
|
||||
pk = self.value_or_default("pk", pk)
|
||||
foreign_keys = self.value_or_default("foreign_keys", foreign_keys)
|
||||
|
|
@ -2448,6 +2538,7 @@ class Table(Queryable):
|
|||
|
||||
:param new_name: Name of the new table
|
||||
"""
|
||||
self._check_no_alias("duplicate()")
|
||||
if not self.exists():
|
||||
raise NoTable(f"Table {self.name} does not exist")
|
||||
with self.db.atomic():
|
||||
|
|
@ -2500,6 +2591,7 @@ class Table(Queryable):
|
|||
:param strict: Set to ``True`` to make the table strict or ``False`` to make it
|
||||
non-strict. Defaults to ``None``, which preserves the existing strict mode.
|
||||
"""
|
||||
self._check_no_alias("transform()")
|
||||
if not self.exists():
|
||||
raise ValueError("Cannot transform a table that doesn't exist yet")
|
||||
sqls = self.transform_sql(
|
||||
|
|
@ -2624,6 +2716,7 @@ class Table(Queryable):
|
|||
:param strict: Set to ``True`` to make the table strict or ``False`` to make it
|
||||
non-strict. Defaults to ``None``, which preserves the existing strict mode.
|
||||
"""
|
||||
self._check_no_alias("transform_sql()")
|
||||
if strict is True and not self.db.supports_strict:
|
||||
raise TransformError("SQLite does not support STRICT tables")
|
||||
types = types or {}
|
||||
|
|
@ -2878,6 +2971,7 @@ class Table(Queryable):
|
|||
:param fk_column: Name of the foreign key column to populate in the original table
|
||||
:param rename: Dictionary of columns that should be renamed when populating the new table
|
||||
"""
|
||||
self._check_no_alias("extract()")
|
||||
rename = rename or {}
|
||||
if isinstance(columns, str):
|
||||
columns = [columns]
|
||||
|
|
@ -3007,6 +3101,7 @@ class Table(Queryable):
|
|||
|
||||
See :ref:`python_api_create_index`.
|
||||
"""
|
||||
self._check_no_alias("create_index()")
|
||||
if index_name is None:
|
||||
index_name = "idx_{}_{}".format(
|
||||
self.name.replace(" ", "_"), "_".join(columns)
|
||||
|
|
@ -3065,6 +3160,7 @@ class Table(Queryable):
|
|||
:param index_name: Name of the index to drop
|
||||
:param ignore: Set to ``True`` to ignore the error if the index does not exist
|
||||
"""
|
||||
self._check_no_alias("drop_index()")
|
||||
if index_name not in {index.name for index in self.indexes}:
|
||||
if ignore:
|
||||
return self
|
||||
|
|
@ -3119,7 +3215,7 @@ class Table(Queryable):
|
|||
f"NOT NULL DEFAULT {self.db.quote_default_value(not_null_default)}"
|
||||
)
|
||||
sql = "ALTER TABLE {} ADD COLUMN {} {col_type}{not_null_default};".format(
|
||||
quote_identifier(self.name),
|
||||
self.quoted_name,
|
||||
quote_identifier(col_name),
|
||||
col_type=fk_col_type or COLUMN_TYPE_MAPPING[col_type],
|
||||
not_null_default=(" " + not_null_sql) if not_null_sql else "",
|
||||
|
|
@ -3136,7 +3232,7 @@ class Table(Queryable):
|
|||
:param ignore: Set to ``True`` to ignore the error if the table does not exist
|
||||
"""
|
||||
try:
|
||||
self.db.execute(f"DROP TABLE {quote_identifier(self.name)}")
|
||||
self.db.execute(f"DROP TABLE {self.quoted_name}")
|
||||
except sqlite3.OperationalError:
|
||||
if not ignore:
|
||||
raise
|
||||
|
|
@ -3202,6 +3298,7 @@ class Table(Queryable):
|
|||
or ``"SET NULL"``.
|
||||
:param on_update: ``ON UPDATE`` action for the foreign key.
|
||||
"""
|
||||
self._check_no_alias("add_foreign_key()")
|
||||
columns = (column,) if isinstance(column, str) else tuple(column)
|
||||
columns = tuple(resolve_casing(c, self.columns_dict) for c in columns)
|
||||
# Ensure columns exist
|
||||
|
|
@ -3290,6 +3387,7 @@ class Table(Queryable):
|
|||
|
||||
See :ref:`python_api_cached_table_counts` for details.
|
||||
"""
|
||||
self._check_no_alias("enable_counts()")
|
||||
sql = (
|
||||
textwrap.dedent("""
|
||||
{create_counts_table}
|
||||
|
|
@ -3365,6 +3463,7 @@ class Table(Queryable):
|
|||
:param tokenize: Custom SQLite tokenizer to use, for example ``"porter"`` to enable Porter stemming.
|
||||
:param replace: Should any existing FTS index for this table be replaced by the new one?
|
||||
"""
|
||||
self._check_no_alias("enable_fts()")
|
||||
create_fts_sql = (
|
||||
textwrap.dedent("""
|
||||
CREATE VIRTUAL TABLE {table_fts} USING {fts_version} (
|
||||
|
|
@ -3443,6 +3542,7 @@ class Table(Queryable):
|
|||
|
||||
:param columns: Columns to populate the data for
|
||||
"""
|
||||
self._check_no_alias("populate_fts()")
|
||||
columns_quoted = ", ".join(quote_identifier(c) for c in columns)
|
||||
sql = (
|
||||
textwrap.dedent("""
|
||||
|
|
@ -3461,6 +3561,7 @@ class Table(Queryable):
|
|||
|
||||
def disable_fts(self) -> "Table":
|
||||
"Remove any full-text search index and related triggers configured for this table."
|
||||
self._check_no_alias("disable_fts()")
|
||||
fts_table = self.detect_fts()
|
||||
if fts_table:
|
||||
self.db[fts_table].drop()
|
||||
|
|
@ -3482,6 +3583,7 @@ class Table(Queryable):
|
|||
|
||||
def rebuild_fts(self) -> "Table":
|
||||
"Run the ``rebuild`` operation against the associated full-text search index table."
|
||||
self._check_no_alias("rebuild_fts()")
|
||||
fts_table = self.detect_fts()
|
||||
if fts_table is None:
|
||||
# Assume this is itself an FTS table
|
||||
|
|
@ -3496,6 +3598,7 @@ class Table(Queryable):
|
|||
|
||||
def detect_fts(self) -> str | None:
|
||||
"Detect if table has a corresponding FTS virtual table and return it"
|
||||
self._check_no_alias("detect_fts()")
|
||||
sql = textwrap.dedent("""
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE rootpage = 0
|
||||
|
|
@ -3521,6 +3624,7 @@ class Table(Queryable):
|
|||
|
||||
def optimize(self) -> "Table":
|
||||
"Run the ``optimize`` operation against the associated full-text search index table."
|
||||
self._check_no_alias("optimize()")
|
||||
fts_table = self.detect_fts()
|
||||
if fts_table is not None:
|
||||
with self.db.atomic():
|
||||
|
|
@ -3671,7 +3775,7 @@ class Table(Queryable):
|
|||
self.get(pk_values)
|
||||
wheres = [f"{quote_identifier(pk_name)} = ?" for pk_name in self.pks]
|
||||
sql = "delete from {} where {wheres}".format(
|
||||
quote_identifier(self.name), wheres=" and ".join(wheres)
|
||||
self.quoted_name, wheres=" and ".join(wheres)
|
||||
)
|
||||
with self.db.atomic():
|
||||
self.db.execute(sql, pk_values)
|
||||
|
|
@ -3695,7 +3799,7 @@ class Table(Queryable):
|
|||
"""
|
||||
if not self.exists():
|
||||
return self
|
||||
sql = f"delete from {quote_identifier(self.name)}"
|
||||
sql = f"delete from {self.quoted_name}"
|
||||
if where is not None:
|
||||
sql += " where " + where
|
||||
with self.db.atomic():
|
||||
|
|
@ -3743,7 +3847,7 @@ class Table(Queryable):
|
|||
wheres = [f"{quote_identifier(pk_name)} = ?" for pk_name in pks]
|
||||
args.extend(pk_values)
|
||||
sql = "update {} set {sets} where {wheres}".format(
|
||||
quote_identifier(self.name),
|
||||
self.quoted_name,
|
||||
sets=", ".join(sets),
|
||||
wheres=" and ".join(wheres),
|
||||
)
|
||||
|
|
@ -3826,7 +3930,7 @@ class Table(Queryable):
|
|||
fn_name = f"lambda_{abs(hash(fn))}"
|
||||
self.db.register_function(convert_value, name=fn_name)
|
||||
sql = "update {} set {sets}{where};".format(
|
||||
quote_identifier(self.name),
|
||||
self.quoted_name,
|
||||
sets=", ".join(
|
||||
[
|
||||
f"{quote_identifier(output or column)} = {fn_name}({quote_identifier(column)})"
|
||||
|
|
@ -3924,10 +4028,7 @@ class Table(Queryable):
|
|||
or_clause = " OR REPLACE"
|
||||
elif ignore:
|
||||
or_clause = " OR IGNORE"
|
||||
sql = (
|
||||
f"INSERT{or_clause} INTO {quote_identifier(self.name)} "
|
||||
"DEFAULT VALUES"
|
||||
)
|
||||
sql = f"INSERT{or_clause} INTO {self.quoted_name} DEFAULT VALUES"
|
||||
return [(sql, []) for _ in chunk]
|
||||
|
||||
if hash_id_columns and hash_id is None:
|
||||
|
|
@ -3988,7 +4089,7 @@ class Table(Queryable):
|
|||
# replace=True mean INSERT OR REPLACE INTO
|
||||
if replace:
|
||||
sql = (
|
||||
f"INSERT OR REPLACE INTO {quote_identifier(self.name)} "
|
||||
f"INSERT OR REPLACE INTO {self.quoted_name} "
|
||||
f"({columns_sql}) VALUES {row_placeholders_sql}"
|
||||
)
|
||||
return [(sql, flat_params)]
|
||||
|
|
@ -3999,7 +4100,7 @@ class Table(Queryable):
|
|||
if ignore:
|
||||
or_ignore = " OR IGNORE"
|
||||
sql = (
|
||||
f"INSERT{or_ignore} INTO {quote_identifier(self.name)} "
|
||||
f"INSERT{or_ignore} INTO {self.quoted_name} "
|
||||
f"({columns_sql}) VALUES {row_placeholders_sql}"
|
||||
)
|
||||
return [(sql, flat_params)]
|
||||
|
|
@ -4049,7 +4150,7 @@ class Table(Queryable):
|
|||
do_clause = "DO NOTHING"
|
||||
|
||||
sql = (
|
||||
f"INSERT INTO {quote_identifier(self.name)} ({columns_sql}) "
|
||||
f"INSERT INTO {self.quoted_name} ({columns_sql}) "
|
||||
f"VALUES {row_placeholders_sql} "
|
||||
f"ON CONFLICT({conflict_sql}) {do_clause}"
|
||||
)
|
||||
|
|
@ -4069,7 +4170,7 @@ class Table(Queryable):
|
|||
placeholders.extend(not_null)
|
||||
sql = (
|
||||
"INSERT OR IGNORE INTO {table}({cols}) VALUES({placeholders});".format(
|
||||
table=quote_identifier(self.name),
|
||||
table=self.quoted_name,
|
||||
cols=", ".join([quote_identifier(p) for p in placeholders]),
|
||||
placeholders=", ".join(["?" for p in placeholders]),
|
||||
)
|
||||
|
|
@ -4081,7 +4182,7 @@ class Table(Queryable):
|
|||
set_cols = [col for col in all_columns if col not in pks]
|
||||
if set_cols:
|
||||
sql2 = "UPDATE {} SET {pairs} WHERE {wheres}".format(
|
||||
quote_identifier(self.name),
|
||||
self.quoted_name,
|
||||
pairs=", ".join(
|
||||
"{} = {}".format(
|
||||
quote_identifier(col), conversions.get(col, "?")
|
||||
|
|
@ -4410,7 +4511,7 @@ class Table(Queryable):
|
|||
self.last_pk = None
|
||||
if truncate and self.exists():
|
||||
with self.db.atomic():
|
||||
self.db.execute(f"DELETE FROM {quote_identifier(self.name)};")
|
||||
self.db.execute(f"DELETE FROM {self.quoted_name};")
|
||||
result = None
|
||||
for chunk in chunks(itertools.chain([first_record], records_iter), batch_size):
|
||||
chunk = list(chunk)
|
||||
|
|
@ -4543,7 +4644,7 @@ class Table(Queryable):
|
|||
f"{quote_identifier(c)} = ?" for c in key_cols
|
||||
)
|
||||
existing = self.db.execute(
|
||||
f"select rowid from {quote_identifier(self.name)} where {where} limit 1",
|
||||
f"select rowid from {self.quoted_name} where {where} limit 1",
|
||||
key_values,
|
||||
).fetchone()
|
||||
if existing is not None:
|
||||
|
|
@ -4879,7 +4980,7 @@ class Table(Queryable):
|
|||
|
||||
def analyze(self) -> None:
|
||||
"Run ANALYZE against this table"
|
||||
self.db.analyze(self.name)
|
||||
self.db.execute(f"ANALYZE {self.quoted_name}")
|
||||
|
||||
def analyze_column(
|
||||
self,
|
||||
|
|
@ -5011,6 +5112,7 @@ class Table(Queryable):
|
|||
:param coord_dimension: Dimensions to use, defaults to ``"XY"`` - set to ``"XYZ"`` to work in three dimensions
|
||||
:param not_null: Should the column be ``NOT NULL``
|
||||
"""
|
||||
self._check_no_alias("add_geometry_column()")
|
||||
cursor = self.db.execute(
|
||||
"SELECT AddGeometryColumn(?, ?, ?, ?, ?, ?);",
|
||||
[
|
||||
|
|
@ -5050,6 +5152,7 @@ class Table(Queryable):
|
|||
|
||||
:param column_name: Geometry column to create the spatial index against
|
||||
"""
|
||||
self._check_no_alias("create_spatial_index()")
|
||||
if f"idx_{self.name}_{column_name}" in self.db.table_names():
|
||||
return False
|
||||
|
||||
|
|
@ -5077,7 +5180,7 @@ class View(Queryable):
|
|||
"""
|
||||
|
||||
try:
|
||||
self.db.execute(f"DROP VIEW {quote_identifier(self.name)}")
|
||||
self.db.execute(f"DROP VIEW {self.quoted_name}")
|
||||
except sqlite3.OperationalError:
|
||||
if not ignore:
|
||||
raise
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import pytest
|
||||
|
||||
from sqlite_utils import Database
|
||||
|
||||
|
||||
|
|
@ -14,3 +16,97 @@ def test_attach(tmpdir):
|
|||
assert db.execute(
|
||||
"select * from foo union all select * from bar.bar"
|
||||
).fetchall() == [(1, "foo"), (1, "bar")]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_with_attached(tmpdir):
|
||||
foo_path = str(tmpdir / "foo.db")
|
||||
bar_path = str(tmpdir / "bar.db")
|
||||
db2 = Database(bar_path)
|
||||
with db2.conn:
|
||||
db2["bar"].insert({"id": 1, "text": "bar"}, pk="id")
|
||||
db = Database(foo_path)
|
||||
db.attach("bar", bar_path)
|
||||
return db
|
||||
|
||||
|
||||
def test_attached_table_exists_and_is_queryable(db_with_attached):
|
||||
# Regression test for issue #432: a Table for an attached alias should
|
||||
# be first-class, not silently invisible to exists()/rows_where()/etc.
|
||||
db = db_with_attached
|
||||
table = db["bar.bar"]
|
||||
assert table.alias == "bar"
|
||||
assert table.name == "bar"
|
||||
assert table.exists() is True
|
||||
assert list(table.rows_where()) == [{"id": 1, "text": "bar"}]
|
||||
assert table.get(1) == {"id": 1, "text": "bar"}
|
||||
assert [c.name for c in table.columns] == ["id", "text"]
|
||||
|
||||
|
||||
def test_attached_table_insert_update_delete(db_with_attached):
|
||||
db = db_with_attached
|
||||
table = db["bar.bar"]
|
||||
table.insert({"id": 2, "text": "baz"})
|
||||
assert table.count == 2
|
||||
table.update(2, {"text": "baz2"})
|
||||
assert table.get(2)["text"] == "baz2"
|
||||
table.delete_where("id = ?", [2])
|
||||
assert table.count == 1
|
||||
table.delete(1)
|
||||
assert table.count == 0
|
||||
|
||||
|
||||
def test_attached_table_does_not_exist_yet(db_with_attached):
|
||||
db = db_with_attached
|
||||
table = db["bar.does_not_exist"]
|
||||
assert table.exists() is False
|
||||
# These should quietly no-op rather than raise or touch the main schema
|
||||
assert list(table.rows_where()) == []
|
||||
table.delete_where()
|
||||
assert "does_not_exist" not in db.table_names()
|
||||
|
||||
|
||||
def test_attached_table_create_raises_clear_error(db_with_attached):
|
||||
# Auto-creating tables in an attached schema isn't supported yet - this
|
||||
# should raise a clear error rather than silently creating the table in
|
||||
# the main schema instead
|
||||
db = db_with_attached
|
||||
table = db["bar.does_not_exist"]
|
||||
with pytest.raises(NotImplementedError):
|
||||
table.create({"id": int})
|
||||
with pytest.raises(NotImplementedError):
|
||||
table.insert({"id": 1})
|
||||
assert "does_not_exist" not in db.table_names()
|
||||
|
||||
|
||||
def test_attached_table_unsupported_operations_raise(db_with_attached):
|
||||
db = db_with_attached
|
||||
table = db["bar.bar"]
|
||||
with pytest.raises(NotImplementedError):
|
||||
table.transform(rename={"text": "value"})
|
||||
with pytest.raises(NotImplementedError):
|
||||
table.create_index(["text"])
|
||||
with pytest.raises(NotImplementedError):
|
||||
table.duplicate("bar2")
|
||||
|
||||
|
||||
def test_temp_table_is_first_class(fresh_db):
|
||||
db = fresh_db
|
||||
db.execute("create temp table scratch (id integer, value text)")
|
||||
db.execute("insert into scratch values (1, 'x')")
|
||||
table = db["temp.scratch"]
|
||||
assert table.alias == "temp"
|
||||
assert table.exists() is True
|
||||
assert list(table.rows_where()) == [{"id": 1, "value": "x"}]
|
||||
table.insert({"id": 2, "value": "y"})
|
||||
assert table.count == 2
|
||||
|
||||
|
||||
def test_dotted_name_without_matching_alias_is_literal(fresh_db):
|
||||
# A "." in a table name that doesn't match a real attached/temp schema
|
||||
# should be treated as a literal main-schema table name, unchanged from
|
||||
# existing behaviour
|
||||
db = fresh_db
|
||||
db["weird.name"].insert({"id": 1}, pk="id")
|
||||
assert db["weird.name"].alias is None
|
||||
assert "weird.name" in db.table_names()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue