2021-08-01 21:47:39 -07:00
|
|
|
|
from .utils import (
|
2022-01-08 13:16:34 -08:00
|
|
|
|
chunks,
|
2026-07-05 21:20:39 -07:00
|
|
|
|
dedupe_keys,
|
2022-03-01 16:00:51 -08:00
|
|
|
|
hash_record,
|
2021-08-01 21:47:39 -07:00
|
|
|
|
sqlite3,
|
|
|
|
|
|
OperationalError,
|
|
|
|
|
|
suggest_column_types,
|
|
|
|
|
|
types_for_column_types,
|
|
|
|
|
|
column_affinity,
|
|
|
|
|
|
progressbar,
|
2022-02-04 00:55:09 -05:00
|
|
|
|
find_spatialite,
|
2021-08-01 21:47:39 -07:00
|
|
|
|
)
|
2022-08-27 15:41:10 -07:00
|
|
|
|
import binascii
|
2021-06-22 18:22:08 -07:00
|
|
|
|
from collections import namedtuple
|
2026-07-05 13:59:25 -07:00
|
|
|
|
from dataclasses import dataclass, field
|
2020-10-27 11:24:21 -05:00
|
|
|
|
from collections.abc import Mapping
|
2020-09-07 14:56:59 -07:00
|
|
|
|
import contextlib
|
2019-01-24 18:59:21 -08:00
|
|
|
|
import datetime
|
2020-05-10 18:50:03 -07:00
|
|
|
|
import decimal
|
2026-05-17 16:52:48 -07:00
|
|
|
|
import importlib
|
2020-09-21 17:31:43 -07:00
|
|
|
|
import inspect
|
2019-01-27 22:12:18 -08:00
|
|
|
|
import itertools
|
2018-07-28 15:20:29 -07:00
|
|
|
|
import json
|
2020-04-12 20:46:51 -07:00
|
|
|
|
import os
|
2019-01-27 15:53:41 -08:00
|
|
|
|
import pathlib
|
2020-11-04 19:53:32 -08:00
|
|
|
|
import re
|
2021-11-29 14:19:30 -08:00
|
|
|
|
import secrets
|
2026-05-17 16:52:48 -07:00
|
|
|
|
from sqlite_fts4 import rank_bm25
|
2020-09-07 11:12:45 -07:00
|
|
|
|
import textwrap
|
2021-08-10 16:09:28 -07:00
|
|
|
|
from typing import (
|
|
|
|
|
|
cast,
|
|
|
|
|
|
Any,
|
|
|
|
|
|
Callable,
|
|
|
|
|
|
Dict,
|
|
|
|
|
|
Generator,
|
|
|
|
|
|
Iterable,
|
2025-11-23 12:17:23 -08:00
|
|
|
|
Sequence,
|
More type annotations (#697)
* Add comprehensive type annotations
- mypy.ini: expanded configuration with module-specific settings
- hookspecs.py: type annotations for hook functions
- plugins.py: typed get_plugins() return value
- recipes.py: full type annotations for parsedate, parsedatetime, jsonsplit
- utils.py: extensive type annotations including Row type alias,
TypeTracker, ValueTracker, and all utility functions
- db.py: type annotations for Database methods (__exit__,
ensure_autocommit_off, tracer, register_function, etc.) and
Queryable class methods
- tests/test_docs.py: updated to match new signature display format
* Fix type errors caught by ty check
- Add type: ignore comments for external library type stub limitations
(csv.reader, click.progressbar, IOBase.name, Callable.__name__)
- Change Iterable to Sequence for SQL where_args parameters
- Use db.table() instead of db[name] for proper Table return type
- Fix rebuild_fts return type from None to Table
- Update test_tracer to expect fewer queries (optimization side effect)
* Fix mypy type errors
- Add type: ignore comments for runtime-valid patterns mypy can't verify
- Fix new_column_types annotation to Dict[str, Set[type]]
- Add type: ignore for Default sentinel values passed to create_table
* mypy skip tests directory
* Fix CI: exclude typing imports from recipe docs, skip mypy on tests
- Add Callable and Optional to exclusion list in _generate_convert_help()
- Regenerate docs/cli-reference.rst with cog
- Add [mypy-tests.*] ignore_errors = True to skip test type errors
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 22:11:47 -08:00
|
|
|
|
Set,
|
|
|
|
|
|
Type,
|
2021-08-10 16:09:28 -07:00
|
|
|
|
Union,
|
|
|
|
|
|
Optional,
|
|
|
|
|
|
List,
|
|
|
|
|
|
Tuple,
|
|
|
|
|
|
)
|
2020-07-29 18:10:25 -07:00
|
|
|
|
import uuid
|
2026-06-21 18:14:45 -05:00
|
|
|
|
from sqlite_utils.plugins import ensure_plugins_loaded, pm
|
2018-07-28 06:43:18 -07:00
|
|
|
|
|
2023-06-25 16:25:51 -07:00
|
|
|
|
try:
|
2026-05-17 16:52:48 -07:00
|
|
|
|
iterdump = importlib.import_module("sqlite_dump").iterdump
|
2023-06-25 16:25:51 -07:00
|
|
|
|
except ImportError:
|
|
|
|
|
|
iterdump = None
|
|
|
|
|
|
|
|
|
|
|
|
|
2019-07-28 14:41:57 +03:00
|
|
|
|
SQLITE_MAX_VARS = 999
|
|
|
|
|
|
|
2026-07-07 08:24:24 -07:00
|
|
|
|
# Names that refer to a rowid table's implicit integer primary key. These are
|
|
|
|
|
|
# valid primary key targets even though they are not listed among a table's
|
|
|
|
|
|
# columns. See https://www.sqlite.org/lang_createtable.html#rowid
|
|
|
|
|
|
ROWID_ALIASES = frozenset({"rowid", "_rowid_", "oid"})
|
|
|
|
|
|
|
2021-08-18 19:43:11 +01:00
|
|
|
|
_quote_fts_re = re.compile(r'\s+|(".*?")')
|
|
|
|
|
|
|
2020-11-04 19:53:32 -08:00
|
|
|
|
_virtual_table_using_re = re.compile(
|
|
|
|
|
|
r"""
|
|
|
|
|
|
^ # Start of string
|
|
|
|
|
|
\s*CREATE\s+VIRTUAL\s+TABLE\s+ # CREATE VIRTUAL TABLE
|
|
|
|
|
|
(
|
|
|
|
|
|
'(?P<squoted_table>[^']*(?:''[^']*)*)' | # single quoted name
|
|
|
|
|
|
"(?P<dquoted_table>[^"]*(?:""[^"]*)*)" | # double quoted name
|
|
|
|
|
|
`(?P<backtick_table>[^`]+)` | # `backtick` quoted name
|
|
|
|
|
|
\[(?P<squarequoted_table>[^\]]+)\] | # [...] quoted name
|
|
|
|
|
|
(?P<identifier> # SQLite non-quoted identifier
|
|
|
|
|
|
[A-Za-z_\u0080-\uffff] # \u0080-\uffff = "any character larger than u007f"
|
|
|
|
|
|
[A-Za-z_\u0080-\uffff0-9\$]* # zero-or-more alphanemuric or $
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
\s+(IF\s+NOT\s+EXISTS\s+)? # IF NOT EXISTS (optional)
|
2021-08-10 16:09:28 -07:00
|
|
|
|
USING\s+(?P<using>\w+) # for example USING FTS5
|
2020-11-04 19:53:32 -08:00
|
|
|
|
""",
|
|
|
|
|
|
re.VERBOSE | re.IGNORECASE,
|
|
|
|
|
|
)
|
2020-03-31 00:40:48 -04:00
|
|
|
|
|
2025-11-23 20:43:26 -08:00
|
|
|
|
|
|
|
|
|
|
def quote_identifier(identifier: str) -> str:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Quote an identifier (table name, column name, etc.) using double quotes.
|
|
|
|
|
|
|
|
|
|
|
|
Double quotes inside the identifier are escaped by doubling them.
|
|
|
|
|
|
"""
|
|
|
|
|
|
return '"{}"'.format(identifier.replace('"', '""'))
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-05 22:11:22 -07:00
|
|
|
|
_IDENTIFIER_CASEFOLD = str.maketrans(
|
|
|
|
|
|
"ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def fold_identifier_case(identifier: str) -> str:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Lowercase an identifier using the same rules SQLite uses - only ASCII
|
|
|
|
|
|
characters are folded, other characters are left unchanged.
|
|
|
|
|
|
"""
|
|
|
|
|
|
return identifier.translate(_IDENTIFIER_CASEFOLD)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def resolve_casing(name: str, candidates: Iterable[str]) -> str:
|
|
|
|
|
|
"""
|
|
|
|
|
|
SQLite treats identifiers as case-insensitive. Return the entry in
|
|
|
|
|
|
``candidates`` that matches ``name`` case-insensitively, preferring an
|
|
|
|
|
|
exact match. If nothing matches, return ``name`` unchanged.
|
|
|
|
|
|
"""
|
|
|
|
|
|
if name in candidates:
|
|
|
|
|
|
return name
|
|
|
|
|
|
folded = fold_identifier_case(name)
|
|
|
|
|
|
for candidate in candidates:
|
|
|
|
|
|
if fold_identifier_case(candidate) == folded:
|
|
|
|
|
|
return candidate
|
|
|
|
|
|
return name
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-17 16:52:48 -07:00
|
|
|
|
pd: Any = None
|
2020-03-31 00:40:48 -04:00
|
|
|
|
try:
|
2026-05-17 16:52:48 -07:00
|
|
|
|
pd = importlib.import_module("pandas")
|
2020-03-31 00:40:48 -04:00
|
|
|
|
except ImportError:
|
2026-05-17 16:52:48 -07:00
|
|
|
|
pd = None
|
2020-03-31 00:40:48 -04:00
|
|
|
|
|
2026-05-17 16:52:48 -07:00
|
|
|
|
np: Any = None
|
2019-02-23 20:02:19 -08:00
|
|
|
|
try:
|
2026-05-17 16:52:48 -07:00
|
|
|
|
np = importlib.import_module("numpy")
|
2019-02-23 20:02:19 -08:00
|
|
|
|
except ImportError:
|
2026-05-17 16:52:48 -07:00
|
|
|
|
np = None
|
2019-02-23 20:02:19 -08:00
|
|
|
|
|
2018-07-28 06:43:18 -07:00
|
|
|
|
Column = namedtuple(
|
|
|
|
|
|
"Column", ("cid", "name", "type", "notnull", "default_value", "is_pk")
|
|
|
|
|
|
)
|
2021-08-10 16:09:28 -07:00
|
|
|
|
Column.__doc__ = """
|
|
|
|
|
|
Describes a SQLite column returned by the :attr:`.Table.columns` property.
|
|
|
|
|
|
|
|
|
|
|
|
``cid``
|
|
|
|
|
|
Column index
|
|
|
|
|
|
|
|
|
|
|
|
``name``
|
|
|
|
|
|
Column name
|
|
|
|
|
|
|
|
|
|
|
|
``type``
|
|
|
|
|
|
Column type
|
|
|
|
|
|
|
|
|
|
|
|
``notnull``
|
2021-08-13 22:10:47 -07:00
|
|
|
|
Does the column have a ``not null`` constraint
|
2021-08-10 16:09:28 -07:00
|
|
|
|
|
|
|
|
|
|
``default_value``
|
|
|
|
|
|
Default value for this column
|
|
|
|
|
|
|
|
|
|
|
|
``is_pk``
|
|
|
|
|
|
Is this column part of the primary key
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
2020-12-12 23:20:11 -08:00
|
|
|
|
ColumnDetails = namedtuple(
|
|
|
|
|
|
"ColumnDetails",
|
|
|
|
|
|
(
|
|
|
|
|
|
"table",
|
|
|
|
|
|
"column",
|
|
|
|
|
|
"total_rows",
|
|
|
|
|
|
"num_null",
|
|
|
|
|
|
"num_blank",
|
|
|
|
|
|
"num_distinct",
|
|
|
|
|
|
"most_common",
|
|
|
|
|
|
"least_common",
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
2021-08-10 16:09:28 -07:00
|
|
|
|
ColumnDetails.__doc__ = """
|
|
|
|
|
|
Summary information about a column, see :ref:`python_api_analyze_column`.
|
|
|
|
|
|
|
|
|
|
|
|
``table``
|
|
|
|
|
|
The name of the table
|
|
|
|
|
|
|
|
|
|
|
|
``column``
|
|
|
|
|
|
The name of the column
|
|
|
|
|
|
|
|
|
|
|
|
``total_rows``
|
|
|
|
|
|
The total number of rows in the table
|
|
|
|
|
|
|
|
|
|
|
|
``num_null``
|
|
|
|
|
|
The number of rows for which this column is null
|
|
|
|
|
|
|
|
|
|
|
|
``num_blank``
|
|
|
|
|
|
The number of rows for which this column is blank (the empty string)
|
|
|
|
|
|
|
|
|
|
|
|
``num_distinct``
|
|
|
|
|
|
The number of distinct values in this column
|
|
|
|
|
|
|
|
|
|
|
|
``most_common``
|
2021-08-13 22:10:47 -07:00
|
|
|
|
The ``N`` most common values as a list of ``(value, count)`` tuples, or ``None`` if the table consists entirely of distinct values
|
2021-08-10 16:09:28 -07:00
|
|
|
|
|
|
|
|
|
|
``least_common``
|
2021-08-13 22:10:47 -07:00
|
|
|
|
The ``N`` least common values as a list of ``(value, count)`` tuples, or ``None`` if the table is entirely distinct
|
2021-08-10 16:09:28 -07:00
|
|
|
|
or if the number of distinct values is less than N (since they will already have been returned in ``most_common``)
|
|
|
|
|
|
"""
|
2026-07-05 13:59:25 -07:00
|
|
|
|
|
|
|
|
|
|
|
2026-07-06 21:35:45 -07:00
|
|
|
|
@dataclass(order=True, frozen=True)
|
2026-07-05 13:59:25 -07:00
|
|
|
|
class ForeignKey:
|
|
|
|
|
|
"""
|
|
|
|
|
|
A foreign key defined on a table.
|
|
|
|
|
|
|
|
|
|
|
|
For single-column foreign keys ``column`` and ``other_column`` hold the
|
2026-07-05 15:10:21 -07:00
|
|
|
|
column names, and ``columns``/``other_columns`` are one-item tuples.
|
2026-07-05 13:59:25 -07:00
|
|
|
|
|
|
|
|
|
|
For compound (multi-column) foreign keys ``column`` and ``other_column``
|
|
|
|
|
|
are ``None`` - use ``columns`` and ``other_columns`` instead, and check
|
|
|
|
|
|
``is_compound``.
|
|
|
|
|
|
|
2026-07-05 14:45:08 -07:00
|
|
|
|
``on_delete`` and ``on_update`` hold the foreign key actions, e.g.
|
|
|
|
|
|
``"CASCADE"`` - ``"NO ACTION"`` if not set.
|
|
|
|
|
|
|
2026-07-06 21:35:45 -07:00
|
|
|
|
Instances are immutable and hashable, so they can be collected into
|
|
|
|
|
|
sets and used as dictionary keys. Equality covers every compared field,
|
|
|
|
|
|
including ``on_delete`` and ``on_update`` - two foreign keys differing
|
|
|
|
|
|
only in their actions are different constraints.
|
|
|
|
|
|
|
2026-07-05 13:59:25 -07:00
|
|
|
|
Prior to sqlite-utils 4.0 this was a ``namedtuple`` and could be unpacked
|
|
|
|
|
|
or indexed as ``(table, column, other_table, other_column)``. It is now a
|
|
|
|
|
|
dataclass - access its fields by name instead.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
table: str
|
2026-07-05 14:26:56 -07:00
|
|
|
|
# column/other_column are None for compound keys, which would break
|
|
|
|
|
|
# ordering against str values - comparison uses columns/other_columns
|
|
|
|
|
|
column: Optional[str] = field(compare=False)
|
2026-07-05 13:59:25 -07:00
|
|
|
|
other_table: str
|
2026-07-05 14:26:56 -07:00
|
|
|
|
other_column: Optional[str] = field(compare=False)
|
2026-07-05 15:10:21 -07:00
|
|
|
|
columns: Tuple[str, ...] = ()
|
|
|
|
|
|
other_columns: Tuple[str, ...] = ()
|
2026-07-05 13:59:25 -07:00
|
|
|
|
is_compound: bool = False
|
2026-07-05 14:40:57 -07:00
|
|
|
|
on_delete: str = "NO ACTION"
|
|
|
|
|
|
on_update: str = "NO ACTION"
|
2026-07-05 13:59:25 -07:00
|
|
|
|
|
|
|
|
|
|
def __post_init__(self):
|
2026-07-05 15:03:23 -07:00
|
|
|
|
# Populate columns/other_columns for single-column foreign keys,
|
2026-07-06 21:35:45 -07:00
|
|
|
|
# normalizing any lists to tuples. object.__setattr__ because the
|
|
|
|
|
|
# dataclass is frozen
|
2026-07-05 15:03:23 -07:00
|
|
|
|
if self.columns:
|
2026-07-06 21:35:45 -07:00
|
|
|
|
object.__setattr__(self, "columns", tuple(self.columns))
|
2026-07-05 15:03:23 -07:00
|
|
|
|
else:
|
2026-07-06 21:35:45 -07:00
|
|
|
|
object.__setattr__(
|
|
|
|
|
|
self, "columns", (self.column,) if self.column is not None else ()
|
|
|
|
|
|
)
|
2026-07-05 15:03:23 -07:00
|
|
|
|
if self.other_columns:
|
2026-07-06 21:35:45 -07:00
|
|
|
|
object.__setattr__(self, "other_columns", tuple(self.other_columns))
|
2026-07-05 15:03:23 -07:00
|
|
|
|
else:
|
2026-07-06 21:35:45 -07:00
|
|
|
|
object.__setattr__(
|
|
|
|
|
|
self,
|
|
|
|
|
|
"other_columns",
|
|
|
|
|
|
(self.other_column,) if self.other_column is not None else (),
|
2026-07-05 13:59:25 -07:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-05 14:40:57 -07:00
|
|
|
|
def _fk_actions_sql(fk: ForeignKey) -> str:
|
|
|
|
|
|
"ON UPDATE/ON DELETE clauses for a foreign key, or an empty string."
|
|
|
|
|
|
actions = ""
|
|
|
|
|
|
if fk.on_update and fk.on_update != "NO ACTION":
|
|
|
|
|
|
actions += " ON UPDATE {}".format(fk.on_update)
|
|
|
|
|
|
if fk.on_delete and fk.on_delete != "NO ACTION":
|
|
|
|
|
|
actions += " ON DELETE {}".format(fk.on_delete)
|
|
|
|
|
|
return actions
|
|
|
|
|
|
|
|
|
|
|
|
|
2018-07-31 18:31:29 -07:00
|
|
|
|
Index = namedtuple("Index", ("seq", "name", "unique", "origin", "partial", "columns"))
|
2021-06-02 20:51:27 -07:00
|
|
|
|
XIndex = namedtuple("XIndex", ("name", "columns"))
|
|
|
|
|
|
XIndexColumn = namedtuple(
|
|
|
|
|
|
"XIndexColumn", ("seqno", "cid", "name", "desc", "coll", "key")
|
|
|
|
|
|
)
|
2019-09-02 17:09:41 -07:00
|
|
|
|
Trigger = namedtuple("Trigger", ("name", "table", "sql"))
|
|
|
|
|
|
|
|
|
|
|
|
|
2024-11-23 14:17:15 -06:00
|
|
|
|
class TransformError(Exception):
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-05 15:10:21 -07:00
|
|
|
|
# A single column name, or a tuple of columns for a compound foreign key
|
|
|
|
|
|
ForeignKeyColumns = Union[str, Tuple[str, ...], List[str]]
|
2026-07-05 15:03:23 -07:00
|
|
|
|
|
|
|
|
|
|
# (table, column(s), other_table, other_column(s))
|
|
|
|
|
|
ForeignKeyTuple = Tuple[str, ForeignKeyColumns, str, ForeignKeyColumns]
|
|
|
|
|
|
|
2023-08-17 17:48:08 -07:00
|
|
|
|
ForeignKeyIndicator = Union[
|
|
|
|
|
|
str,
|
|
|
|
|
|
ForeignKey,
|
2026-07-05 15:03:23 -07:00
|
|
|
|
Tuple[ForeignKeyColumns, str],
|
|
|
|
|
|
Tuple[ForeignKeyColumns, str, ForeignKeyColumns],
|
|
|
|
|
|
ForeignKeyTuple,
|
2021-08-18 15:25:18 -07:00
|
|
|
|
]
|
|
|
|
|
|
|
2023-08-17 17:48:08 -07:00
|
|
|
|
ForeignKeysType = Union[Iterable[ForeignKeyIndicator], List[ForeignKeyIndicator]]
|
|
|
|
|
|
|
2021-08-18 15:25:18 -07:00
|
|
|
|
|
2021-08-10 16:09:28 -07:00
|
|
|
|
class Default:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
DEFAULT = Default()
|
2018-07-28 06:43:18 -07:00
|
|
|
|
|
2026-05-17 16:52:48 -07:00
|
|
|
|
Tracer = Callable[[str, Optional[Union[Sequence[Any], Dict[str, Any]]]], None]
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-21 10:43:19 -07:00
|
|
|
|
def _iter_complete_sql_statements(sql: str) -> Generator[str, None, None]:
|
|
|
|
|
|
statement = []
|
|
|
|
|
|
for char in sql:
|
|
|
|
|
|
statement.append(char)
|
|
|
|
|
|
statement_sql = "".join(statement).strip()
|
|
|
|
|
|
if statement_sql and sqlite3.complete_statement(statement_sql):
|
|
|
|
|
|
yield statement_sql
|
|
|
|
|
|
statement = []
|
|
|
|
|
|
statement_sql = "".join(statement).strip()
|
|
|
|
|
|
if statement_sql:
|
|
|
|
|
|
yield statement_sql
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-17 16:52:48 -07:00
|
|
|
|
COLUMN_TYPE_MAPPING: Dict[Any, str] = {
|
2025-11-23 21:37:59 -08:00
|
|
|
|
float: "REAL",
|
2019-02-24 11:40:26 -08:00
|
|
|
|
int: "INTEGER",
|
|
|
|
|
|
bool: "INTEGER",
|
|
|
|
|
|
str: "TEXT",
|
2021-11-15 02:27:40 +02:00
|
|
|
|
dict: "TEXT",
|
2021-11-14 16:36:00 -08:00
|
|
|
|
tuple: "TEXT",
|
|
|
|
|
|
list: "TEXT",
|
2019-02-24 11:40:26 -08:00
|
|
|
|
bytes.__class__: "BLOB",
|
|
|
|
|
|
bytes: "BLOB",
|
2020-07-29 18:10:25 -07:00
|
|
|
|
memoryview: "BLOB",
|
2019-02-24 11:40:26 -08:00
|
|
|
|
datetime.datetime: "TEXT",
|
|
|
|
|
|
datetime.date: "TEXT",
|
|
|
|
|
|
datetime.time: "TEXT",
|
2023-11-04 01:49:50 +01:00
|
|
|
|
datetime.timedelta: "TEXT",
|
2025-11-23 21:37:59 -08:00
|
|
|
|
decimal.Decimal: "REAL",
|
2019-02-24 11:40:26 -08:00
|
|
|
|
None.__class__: "TEXT",
|
2020-07-29 18:10:25 -07:00
|
|
|
|
uuid.UUID: "TEXT",
|
2019-02-24 11:49:24 -08:00
|
|
|
|
# SQLite explicit types
|
|
|
|
|
|
"TEXT": "TEXT",
|
|
|
|
|
|
"INTEGER": "INTEGER",
|
|
|
|
|
|
"FLOAT": "FLOAT",
|
2025-11-23 21:37:59 -08:00
|
|
|
|
"REAL": "REAL",
|
2019-02-24 11:49:24 -08:00
|
|
|
|
"BLOB": "BLOB",
|
|
|
|
|
|
"text": "TEXT",
|
2023-12-06 10:49:21 -08:00
|
|
|
|
"str": "TEXT",
|
2019-02-24 11:49:24 -08:00
|
|
|
|
"integer": "INTEGER",
|
2023-12-06 10:49:21 -08:00
|
|
|
|
"int": "INTEGER",
|
2025-11-23 21:37:59 -08:00
|
|
|
|
"float": "REAL",
|
|
|
|
|
|
"real": "REAL",
|
2019-02-24 11:49:24 -08:00
|
|
|
|
"blob": "BLOB",
|
2023-12-06 10:49:21 -08:00
|
|
|
|
"bytes": "BLOB",
|
2019-02-24 11:40:26 -08:00
|
|
|
|
}
|
|
|
|
|
|
# If numpy is available, add more types
|
|
|
|
|
|
if np:
|
2024-07-18 11:32:48 -07:00
|
|
|
|
try:
|
|
|
|
|
|
COLUMN_TYPE_MAPPING.update(
|
|
|
|
|
|
{
|
|
|
|
|
|
np.int8: "INTEGER",
|
|
|
|
|
|
np.int16: "INTEGER",
|
|
|
|
|
|
np.int32: "INTEGER",
|
|
|
|
|
|
np.int64: "INTEGER",
|
|
|
|
|
|
np.uint8: "INTEGER",
|
|
|
|
|
|
np.uint16: "INTEGER",
|
|
|
|
|
|
np.uint32: "INTEGER",
|
|
|
|
|
|
np.uint64: "INTEGER",
|
2025-11-23 21:37:59 -08:00
|
|
|
|
np.float16: "REAL",
|
|
|
|
|
|
np.float32: "REAL",
|
|
|
|
|
|
np.float64: "REAL",
|
2024-07-18 11:32:48 -07:00
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
except AttributeError:
|
|
|
|
|
|
# https://github.com/simonw/sqlite-utils/issues/632
|
|
|
|
|
|
pass
|
2019-02-24 11:40:26 -08:00
|
|
|
|
|
2020-03-31 00:40:48 -04:00
|
|
|
|
# If pandas is available, add more types
|
|
|
|
|
|
if pd:
|
2021-08-18 14:48:05 -07:00
|
|
|
|
COLUMN_TYPE_MAPPING.update({pd.Timestamp: "TEXT"}) # type: ignore
|
2020-03-31 00:40:48 -04:00
|
|
|
|
|
2018-07-28 06:43:18 -07:00
|
|
|
|
|
2019-02-24 13:10:51 -08:00
|
|
|
|
class AlterError(Exception):
|
2021-08-10 16:09:28 -07:00
|
|
|
|
"Error altering table"
|
2019-02-24 13:10:51 -08:00
|
|
|
|
|
|
|
|
|
|
|
2019-06-12 21:51:09 -07:00
|
|
|
|
class NoObviousTable(Exception):
|
2021-08-10 16:09:28 -07:00
|
|
|
|
"Could not tell which table this operation refers to"
|
2019-06-12 21:51:09 -07:00
|
|
|
|
|
|
|
|
|
|
|
2022-07-15 14:45:14 -07:00
|
|
|
|
class NoTable(Exception):
|
|
|
|
|
|
"Specified table does not exist"
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-05-08 23:19:36 -07:00
|
|
|
|
class NoView(Exception):
|
|
|
|
|
|
"Specified view does not exist"
|
|
|
|
|
|
|
|
|
|
|
|
|
2019-06-12 21:51:09 -07:00
|
|
|
|
class BadPrimaryKey(Exception):
|
2021-08-10 16:09:28 -07:00
|
|
|
|
"Table does not have a single obvious primary key"
|
2019-06-12 21:51:09 -07:00
|
|
|
|
|
|
|
|
|
|
|
2019-07-14 21:28:51 -07:00
|
|
|
|
class NotFoundError(Exception):
|
2021-08-10 16:09:28 -07:00
|
|
|
|
"Record not found"
|
2019-07-14 21:28:51 -07:00
|
|
|
|
|
|
|
|
|
|
|
2020-01-05 09:20:11 -08:00
|
|
|
|
class PrimaryKeyRequired(Exception):
|
2021-08-10 16:09:28 -07:00
|
|
|
|
"Primary key needs to be specified"
|
2020-01-05 09:20:11 -08:00
|
|
|
|
|
|
|
|
|
|
|
2020-09-22 15:20:18 -07:00
|
|
|
|
class InvalidColumns(Exception):
|
2021-08-10 16:09:28 -07:00
|
|
|
|
"Specified columns do not exist"
|
2020-09-22 15:20:18 -07:00
|
|
|
|
|
|
|
|
|
|
|
2026-07-04 22:32:36 +00:00
|
|
|
|
class TransactionError(Exception):
|
|
|
|
|
|
"Operation cannot be performed while a transaction is open"
|
|
|
|
|
|
|
|
|
|
|
|
|
2021-05-28 22:01:38 -07:00
|
|
|
|
class DescIndex(str):
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
2021-08-01 21:47:39 -07:00
|
|
|
|
class BadMultiValues(Exception):
|
|
|
|
|
|
"With multi=True code must return a Python dictionary"
|
|
|
|
|
|
|
More type annotations (#697)
* Add comprehensive type annotations
- mypy.ini: expanded configuration with module-specific settings
- hookspecs.py: type annotations for hook functions
- plugins.py: typed get_plugins() return value
- recipes.py: full type annotations for parsedate, parsedatetime, jsonsplit
- utils.py: extensive type annotations including Row type alias,
TypeTracker, ValueTracker, and all utility functions
- db.py: type annotations for Database methods (__exit__,
ensure_autocommit_off, tracer, register_function, etc.) and
Queryable class methods
- tests/test_docs.py: updated to match new signature display format
* Fix type errors caught by ty check
- Add type: ignore comments for external library type stub limitations
(csv.reader, click.progressbar, IOBase.name, Callable.__name__)
- Change Iterable to Sequence for SQL where_args parameters
- Use db.table() instead of db[name] for proper Table return type
- Fix rebuild_fts return type from None to Table
- Update test_tracer to expect fewer queries (optimization side effect)
* Fix mypy type errors
- Add type: ignore comments for runtime-valid patterns mypy can't verify
- Fix new_column_types annotation to Dict[str, Set[type]]
- Add type: ignore for Default sentinel values passed to create_table
* mypy skip tests directory
* Fix CI: exclude typing imports from recipe docs, skip mypy on tests
- Add Callable and Optional to exclusion list in _generate_convert_help()
- Regenerate docs/cli-reference.rst with cog
- Add [mypy-tests.*] ignore_errors = True to skip test type errors
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 22:11:47 -08:00
|
|
|
|
def __init__(self, values: object) -> None:
|
2021-08-01 21:47:39 -07:00
|
|
|
|
self.values = values
|
|
|
|
|
|
|
|
|
|
|
|
|
2021-01-03 12:19:34 -08:00
|
|
|
|
_COUNTS_TABLE_CREATE_SQL = """
|
2025-11-23 20:43:26 -08:00
|
|
|
|
CREATE TABLE IF NOT EXISTS "{}"(
|
|
|
|
|
|
"table" TEXT PRIMARY KEY,
|
2021-01-03 12:19:34 -08:00
|
|
|
|
count INTEGER DEFAULT 0
|
|
|
|
|
|
);
|
|
|
|
|
|
""".strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-05 11:49:59 -07:00
|
|
|
|
_TRANSACTION_CONTROL_KEYWORDS = {
|
2026-07-04 23:15:01 +00:00
|
|
|
|
"BEGIN",
|
|
|
|
|
|
"COMMIT",
|
|
|
|
|
|
"END",
|
|
|
|
|
|
"ROLLBACK",
|
|
|
|
|
|
"SAVEPOINT",
|
|
|
|
|
|
"RELEASE",
|
2026-07-05 11:49:59 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
# Statements that never return rows and cannot run inside (or would break
|
|
|
|
|
|
# out of) the savepoint guard used by query()
|
|
|
|
|
|
_QUERY_REJECTED_KEYWORDS = _TRANSACTION_CONTROL_KEYWORDS | {
|
|
|
|
|
|
"VACUUM",
|
|
|
|
|
|
"ATTACH",
|
|
|
|
|
|
"DETACH",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _first_keyword(sql: str) -> str:
|
|
|
|
|
|
"""
|
2026-07-06 21:22:29 -07:00
|
|
|
|
Return the first keyword of a SQL statement, uppercased, skipping
|
|
|
|
|
|
everything the sqlite3 driver tolerates before the first real token:
|
|
|
|
|
|
whitespace, ``--`` or ``/* ... */`` comments, empty statements
|
|
|
|
|
|
(bare ``;``) and a UTF-8 byte order mark. Returns an empty string if
|
|
|
|
|
|
there is no leading keyword.
|
2026-07-05 11:49:59 -07:00
|
|
|
|
"""
|
|
|
|
|
|
i, n = 0, len(sql)
|
|
|
|
|
|
while i < n:
|
2026-07-06 21:22:29 -07:00
|
|
|
|
if sql[i].isspace() or sql[i] in (";", "\ufeff"):
|
2026-07-05 11:49:59 -07:00
|
|
|
|
i += 1
|
|
|
|
|
|
elif sql.startswith("--", i):
|
|
|
|
|
|
newline = sql.find("\n", i)
|
|
|
|
|
|
if newline == -1:
|
|
|
|
|
|
return ""
|
|
|
|
|
|
i = newline + 1
|
|
|
|
|
|
elif sql.startswith("/*", i):
|
|
|
|
|
|
end = sql.find("*/", i + 2)
|
|
|
|
|
|
if end == -1:
|
|
|
|
|
|
return ""
|
|
|
|
|
|
i = end + 2
|
|
|
|
|
|
else:
|
|
|
|
|
|
break
|
|
|
|
|
|
j = i
|
|
|
|
|
|
while j < n and (sql[j].isalpha() or sql[j] == "_"):
|
|
|
|
|
|
j += 1
|
|
|
|
|
|
return sql[i:j].upper()
|
2026-07-04 23:15:01 +00:00
|
|
|
|
|
|
|
|
|
|
|
2018-07-28 06:43:18 -07:00
|
|
|
|
class Database:
|
2021-08-10 16:09:28 -07:00
|
|
|
|
"""
|
|
|
|
|
|
Wrapper for a SQLite database connection that adds a variety of useful utility methods.
|
|
|
|
|
|
|
2021-08-11 04:56:54 -07:00
|
|
|
|
To create an instance::
|
|
|
|
|
|
|
|
|
|
|
|
# create data.db file, or open existing:
|
|
|
|
|
|
db = Database("data.db")
|
|
|
|
|
|
# Create an in-memory database:
|
|
|
|
|
|
dB = Database(memory=True)
|
|
|
|
|
|
|
2022-03-11 09:38:34 -08:00
|
|
|
|
:param filename_or_conn: String path to a file, or a ``pathlib.Path`` object, or a
|
2021-08-10 16:09:28 -07:00
|
|
|
|
``sqlite3`` connection
|
2022-03-11 09:38:34 -08:00
|
|
|
|
:param memory: set to ``True`` to create an in-memory database
|
|
|
|
|
|
:param memory_name: creates a named in-memory database that can be shared across multiple connections
|
|
|
|
|
|
:param recreate: set to ``True`` to delete and recreate a file database (**dangerous**)
|
|
|
|
|
|
:param recursive_triggers: defaults to ``True``, which sets ``PRAGMA recursive_triggers=on;`` -
|
2021-08-10 16:09:28 -07:00
|
|
|
|
set to ``False`` to avoid setting this pragma
|
2022-03-11 09:38:34 -08:00
|
|
|
|
:param tracer: set a tracer function (``print`` works for this) which will be called with
|
2021-08-10 16:09:28 -07:00
|
|
|
|
``sql, parameters`` every time a SQL query is executed
|
2022-03-11 09:38:34 -08:00
|
|
|
|
:param use_counts_table: set to ``True`` to use a cached counts table, if available. See
|
|
|
|
|
|
:ref:`python_api_cached_table_counts`
|
2025-05-08 20:37:49 -07:00
|
|
|
|
:param use_old_upsert: set to ``True`` to force the older upsert implementation. See
|
|
|
|
|
|
:ref:`python_api_old_upsert`
|
2023-12-07 21:05:27 -08:00
|
|
|
|
:param strict: Apply STRICT mode to all created tables (unless overridden)
|
2021-08-10 16:09:28 -07:00
|
|
|
|
"""
|
|
|
|
|
|
|
2021-01-02 14:03:52 -08:00
|
|
|
|
_counts_table_name = "_counts"
|
2021-01-03 12:19:34 -08:00
|
|
|
|
use_counts_table = False
|
2025-12-16 19:34:45 -08:00
|
|
|
|
conn: sqlite3.Connection
|
2021-01-02 14:03:52 -08:00
|
|
|
|
|
2020-09-07 13:45:06 -07:00
|
|
|
|
def __init__(
|
|
|
|
|
|
self,
|
2022-11-15 22:25:26 -08:00
|
|
|
|
filename_or_conn: Optional[Union[str, pathlib.Path, sqlite3.Connection]] = None,
|
2021-08-10 16:09:28 -07:00
|
|
|
|
memory: bool = False,
|
2022-11-15 22:25:26 -08:00
|
|
|
|
memory_name: Optional[str] = None,
|
2021-08-10 16:09:28 -07:00
|
|
|
|
recreate: bool = False,
|
|
|
|
|
|
recursive_triggers: bool = True,
|
2026-05-17 16:52:48 -07:00
|
|
|
|
tracer: Optional[Tracer] = None,
|
2021-08-10 16:09:28 -07:00
|
|
|
|
use_counts_table: bool = False,
|
2023-07-22 16:06:11 -07:00
|
|
|
|
execute_plugins: bool = True,
|
2025-05-08 20:37:49 -07:00
|
|
|
|
use_old_upsert: bool = False,
|
2023-12-07 21:05:27 -08:00
|
|
|
|
strict: bool = False,
|
2020-09-07 13:45:06 -07:00
|
|
|
|
):
|
2024-11-08 11:16:57 -08:00
|
|
|
|
self.memory_name = None
|
|
|
|
|
|
self.memory = False
|
2025-05-08 20:37:49 -07:00
|
|
|
|
self.use_old_upsert = use_old_upsert
|
2026-07-04 19:19:24 +00:00
|
|
|
|
if not (
|
|
|
|
|
|
(filename_or_conn is not None and (not memory and not memory_name))
|
|
|
|
|
|
or (filename_or_conn is None and (memory or memory_name))
|
|
|
|
|
|
):
|
|
|
|
|
|
raise ValueError("Either specify a filename_or_conn or pass memory=True")
|
2022-02-15 17:21:25 -08:00
|
|
|
|
if memory_name:
|
|
|
|
|
|
uri = "file:{}?mode=memory&cache=shared".format(memory_name)
|
|
|
|
|
|
self.conn = sqlite3.connect(
|
|
|
|
|
|
uri,
|
|
|
|
|
|
uri=True,
|
|
|
|
|
|
check_same_thread=False,
|
|
|
|
|
|
)
|
2024-11-08 11:16:57 -08:00
|
|
|
|
self.memory = True
|
|
|
|
|
|
self.memory_name = memory_name
|
2022-02-15 17:21:25 -08:00
|
|
|
|
elif memory or filename_or_conn == ":memory:":
|
2019-07-22 17:12:54 -07:00
|
|
|
|
self.conn = sqlite3.connect(":memory:")
|
2024-11-08 11:16:57 -08:00
|
|
|
|
self.memory = True
|
2020-04-12 20:46:51 -07:00
|
|
|
|
elif isinstance(filename_or_conn, (str, pathlib.Path)):
|
|
|
|
|
|
if recreate and os.path.exists(filename_or_conn):
|
2022-10-25 13:21:37 -07:00
|
|
|
|
try:
|
|
|
|
|
|
os.remove(filename_or_conn)
|
|
|
|
|
|
except OSError:
|
|
|
|
|
|
# Avoid mypy and __repr__ errors, see:
|
|
|
|
|
|
# https://github.com/simonw/sqlite-utils/issues/503
|
|
|
|
|
|
self.conn = sqlite3.connect(":memory:")
|
|
|
|
|
|
raise
|
2019-01-27 15:53:41 -08:00
|
|
|
|
self.conn = sqlite3.connect(str(filename_or_conn))
|
2018-07-28 06:43:18 -07:00
|
|
|
|
else:
|
2026-07-04 19:19:24 +00:00
|
|
|
|
if recreate:
|
|
|
|
|
|
raise ValueError("recreate cannot be used with connections, only paths")
|
2026-05-17 16:52:48 -07:00
|
|
|
|
self.conn = cast(sqlite3.Connection, filename_or_conn)
|
2026-07-04 22:38:31 +00:00
|
|
|
|
# Python 3.12+ autocommit=True/False connections make commit()
|
|
|
|
|
|
# and rollback() behave differently, silently breaking the
|
|
|
|
|
|
# transaction handling used by every write method
|
|
|
|
|
|
autocommit = getattr(self.conn, "autocommit", None)
|
|
|
|
|
|
if autocommit is not None and autocommit != getattr(
|
|
|
|
|
|
sqlite3, "LEGACY_TRANSACTION_CONTROL", -1
|
|
|
|
|
|
):
|
|
|
|
|
|
raise TransactionError(
|
|
|
|
|
|
"sqlite-utils requires a connection that uses the default "
|
|
|
|
|
|
"transaction handling - connections created with "
|
|
|
|
|
|
"autocommit=True or autocommit=False are not supported"
|
|
|
|
|
|
)
|
2026-05-17 16:52:48 -07:00
|
|
|
|
self._tracer: Optional[Tracer] = tracer
|
2020-09-07 13:45:06 -07:00
|
|
|
|
if recursive_triggers:
|
2020-09-07 14:56:59 -07:00
|
|
|
|
self.execute("PRAGMA recursive_triggers=on;")
|
2021-08-10 16:09:28 -07:00
|
|
|
|
self._registered_functions: set = set()
|
2021-01-03 12:19:34 -08:00
|
|
|
|
self.use_counts_table = use_counts_table
|
2023-07-22 16:06:11 -07:00
|
|
|
|
if execute_plugins:
|
2026-06-21 18:14:45 -05:00
|
|
|
|
ensure_plugins_loaded()
|
2023-07-22 16:06:11 -07:00
|
|
|
|
pm.hook.prepare_connection(conn=self.conn)
|
2023-12-07 21:05:27 -08:00
|
|
|
|
self.strict = strict
|
2023-07-22 15:59:08 -07:00
|
|
|
|
|
2025-12-11 16:56:12 -08:00
|
|
|
|
def __enter__(self) -> "Database":
|
|
|
|
|
|
return self
|
|
|
|
|
|
|
More type annotations (#697)
* Add comprehensive type annotations
- mypy.ini: expanded configuration with module-specific settings
- hookspecs.py: type annotations for hook functions
- plugins.py: typed get_plugins() return value
- recipes.py: full type annotations for parsedate, parsedatetime, jsonsplit
- utils.py: extensive type annotations including Row type alias,
TypeTracker, ValueTracker, and all utility functions
- db.py: type annotations for Database methods (__exit__,
ensure_autocommit_off, tracer, register_function, etc.) and
Queryable class methods
- tests/test_docs.py: updated to match new signature display format
* Fix type errors caught by ty check
- Add type: ignore comments for external library type stub limitations
(csv.reader, click.progressbar, IOBase.name, Callable.__name__)
- Change Iterable to Sequence for SQL where_args parameters
- Use db.table() instead of db[name] for proper Table return type
- Fix rebuild_fts return type from None to Table
- Update test_tracer to expect fewer queries (optimization side effect)
* Fix mypy type errors
- Add type: ignore comments for runtime-valid patterns mypy can't verify
- Fix new_column_types annotation to Dict[str, Set[type]]
- Add type: ignore for Default sentinel values passed to create_table
* mypy skip tests directory
* Fix CI: exclude typing imports from recipe docs, skip mypy on tests
- Add Callable and Optional to exclusion list in _generate_convert_help()
- Regenerate docs/cli-reference.rst with cog
- Add [mypy-tests.*] ignore_errors = True to skip test type errors
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 22:11:47 -08:00
|
|
|
|
def __exit__(
|
|
|
|
|
|
self,
|
|
|
|
|
|
exc_type: Optional[Type[BaseException]],
|
|
|
|
|
|
exc_val: Optional[BaseException],
|
|
|
|
|
|
exc_tb: Optional[object],
|
|
|
|
|
|
) -> None:
|
2025-12-11 16:56:12 -08:00
|
|
|
|
self.close()
|
|
|
|
|
|
|
2025-10-01 21:37:05 +01:00
|
|
|
|
def close(self) -> None:
|
2022-10-25 13:57:43 -07:00
|
|
|
|
"Close the SQLite connection, and the underlying database file"
|
|
|
|
|
|
self.conn.close()
|
|
|
|
|
|
|
2026-06-21 10:43:19 -07:00
|
|
|
|
@contextlib.contextmanager
|
|
|
|
|
|
def atomic(self) -> Generator["Database", None, None]:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Context manager for wrapping multiple database operations in a transaction.
|
|
|
|
|
|
|
|
|
|
|
|
Nested blocks use SQLite savepoints.
|
|
|
|
|
|
"""
|
|
|
|
|
|
if self.conn.in_transaction:
|
|
|
|
|
|
savepoint = "sqlite_utils_{}".format(secrets.token_hex(16))
|
|
|
|
|
|
self.conn.execute("SAVEPOINT {};".format(savepoint))
|
|
|
|
|
|
try:
|
|
|
|
|
|
yield self
|
|
|
|
|
|
except BaseException:
|
2026-07-06 22:04:00 -07:00
|
|
|
|
# An error such as a RAISE(ROLLBACK) trigger can destroy
|
|
|
|
|
|
# the whole transaction, savepoints included - cleaning up
|
|
|
|
|
|
# anyway would mask the original exception with
|
|
|
|
|
|
# "no such savepoint"
|
|
|
|
|
|
if self.conn.in_transaction:
|
|
|
|
|
|
self.conn.execute("ROLLBACK TO SAVEPOINT {};".format(savepoint))
|
|
|
|
|
|
self.conn.execute("RELEASE SAVEPOINT {};".format(savepoint))
|
2026-06-21 10:43:19 -07:00
|
|
|
|
raise
|
|
|
|
|
|
else:
|
|
|
|
|
|
self.conn.execute("RELEASE SAVEPOINT {};".format(savepoint))
|
|
|
|
|
|
else:
|
|
|
|
|
|
self.conn.execute("BEGIN")
|
|
|
|
|
|
try:
|
|
|
|
|
|
yield self
|
|
|
|
|
|
except BaseException:
|
2026-07-06 22:04:00 -07:00
|
|
|
|
# rollback() is a no-op if the error already destroyed the
|
|
|
|
|
|
# transaction, so the original exception propagates
|
|
|
|
|
|
self.rollback()
|
2026-06-21 10:43:19 -07:00
|
|
|
|
raise
|
|
|
|
|
|
else:
|
|
|
|
|
|
try:
|
2026-07-04 23:15:01 +00:00
|
|
|
|
self.conn.execute("COMMIT")
|
2026-06-21 10:43:19 -07:00
|
|
|
|
except BaseException:
|
2026-07-06 22:04:00 -07:00
|
|
|
|
self.rollback()
|
2026-06-21 10:43:19 -07:00
|
|
|
|
raise
|
|
|
|
|
|
|
2026-07-04 22:31:22 +00:00
|
|
|
|
def begin(self) -> None:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Start a transaction with ``BEGIN``, taking manual control of transaction
|
|
|
|
|
|
handling. End it by calling :meth:`commit` or :meth:`rollback`.
|
|
|
|
|
|
|
|
|
|
|
|
Raises ``sqlite3.OperationalError`` if a transaction is already open.
|
|
|
|
|
|
|
|
|
|
|
|
Most code should use the :meth:`atomic` context manager instead, which
|
|
|
|
|
|
commits and rolls back automatically. See :ref:`python_api_transactions`.
|
|
|
|
|
|
"""
|
|
|
|
|
|
self.execute("BEGIN")
|
|
|
|
|
|
|
|
|
|
|
|
def commit(self) -> None:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Commit the current transaction. Does nothing if no transaction is open.
|
|
|
|
|
|
"""
|
2026-07-04 23:15:01 +00:00
|
|
|
|
if self.conn.in_transaction:
|
|
|
|
|
|
self.conn.execute("COMMIT")
|
2026-07-04 22:31:22 +00:00
|
|
|
|
|
|
|
|
|
|
def rollback(self) -> None:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Roll back the current transaction, discarding its changes. Does nothing
|
|
|
|
|
|
if no transaction is open.
|
|
|
|
|
|
"""
|
2026-07-04 23:15:01 +00:00
|
|
|
|
if self.conn.in_transaction:
|
|
|
|
|
|
self.conn.execute("ROLLBACK")
|
2026-07-04 22:31:22 +00:00
|
|
|
|
|
2023-06-25 16:25:51 -07:00
|
|
|
|
@contextlib.contextmanager
|
2026-07-05 21:54:30 -07:00
|
|
|
|
def ensure_autocommit_on(self) -> Generator[None, None, None]:
|
2023-06-25 16:25:51 -07:00
|
|
|
|
"""
|
2026-07-05 21:54:30 -07:00
|
|
|
|
Ensure the connection is in driver-level autocommit mode for the
|
|
|
|
|
|
duration of a block of code.
|
|
|
|
|
|
|
|
|
|
|
|
This temporarily sets ``isolation_level = None`` on the underlying
|
|
|
|
|
|
``sqlite3`` connection, so the driver does not open implicit
|
|
|
|
|
|
transactions. This is useful for statements such as
|
|
|
|
|
|
``PRAGMA journal_mode=wal`` which cannot run inside a transaction.
|
2023-06-25 16:25:51 -07:00
|
|
|
|
|
|
|
|
|
|
Example usage::
|
|
|
|
|
|
|
2026-07-05 21:54:30 -07:00
|
|
|
|
with db.ensure_autocommit_on():
|
2023-06-25 16:25:51 -07:00
|
|
|
|
# do stuff here
|
|
|
|
|
|
|
2026-07-05 21:54:30 -07:00
|
|
|
|
The previous ``isolation_level`` is restored at the end of the block.
|
2026-07-06 21:51:31 -07:00
|
|
|
|
|
|
|
|
|
|
:raises TransactionError: if a transaction is open - assigning
|
|
|
|
|
|
``isolation_level`` would commit it as a side effect, silently
|
|
|
|
|
|
breaking the caller's ability to roll back
|
2023-06-25 16:25:51 -07:00
|
|
|
|
"""
|
2026-07-06 21:51:31 -07:00
|
|
|
|
if self.conn.in_transaction:
|
|
|
|
|
|
raise TransactionError(
|
|
|
|
|
|
"ensure_autocommit_on() cannot be used inside a transaction - "
|
|
|
|
|
|
"changing isolation_level would commit the open transaction"
|
|
|
|
|
|
)
|
2023-06-25 16:25:51 -07:00
|
|
|
|
old_isolation_level = self.conn.isolation_level
|
|
|
|
|
|
try:
|
|
|
|
|
|
self.conn.isolation_level = None
|
|
|
|
|
|
yield
|
|
|
|
|
|
finally:
|
|
|
|
|
|
self.conn.isolation_level = old_isolation_level
|
|
|
|
|
|
|
2020-09-07 14:56:59 -07:00
|
|
|
|
@contextlib.contextmanager
|
More type annotations (#697)
* Add comprehensive type annotations
- mypy.ini: expanded configuration with module-specific settings
- hookspecs.py: type annotations for hook functions
- plugins.py: typed get_plugins() return value
- recipes.py: full type annotations for parsedate, parsedatetime, jsonsplit
- utils.py: extensive type annotations including Row type alias,
TypeTracker, ValueTracker, and all utility functions
- db.py: type annotations for Database methods (__exit__,
ensure_autocommit_off, tracer, register_function, etc.) and
Queryable class methods
- tests/test_docs.py: updated to match new signature display format
* Fix type errors caught by ty check
- Add type: ignore comments for external library type stub limitations
(csv.reader, click.progressbar, IOBase.name, Callable.__name__)
- Change Iterable to Sequence for SQL where_args parameters
- Use db.table() instead of db[name] for proper Table return type
- Fix rebuild_fts return type from None to Table
- Update test_tracer to expect fewer queries (optimization side effect)
* Fix mypy type errors
- Add type: ignore comments for runtime-valid patterns mypy can't verify
- Fix new_column_types annotation to Dict[str, Set[type]]
- Add type: ignore for Default sentinel values passed to create_table
* mypy skip tests directory
* Fix CI: exclude typing imports from recipe docs, skip mypy on tests
- Add Callable and Optional to exclusion list in _generate_convert_help()
- Regenerate docs/cli-reference.rst with cog
- Add [mypy-tests.*] ignore_errors = True to skip test type errors
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 22:11:47 -08:00
|
|
|
|
def tracer(
|
2026-05-17 16:52:48 -07:00
|
|
|
|
self, tracer: Optional[Tracer] = None
|
More type annotations (#697)
* Add comprehensive type annotations
- mypy.ini: expanded configuration with module-specific settings
- hookspecs.py: type annotations for hook functions
- plugins.py: typed get_plugins() return value
- recipes.py: full type annotations for parsedate, parsedatetime, jsonsplit
- utils.py: extensive type annotations including Row type alias,
TypeTracker, ValueTracker, and all utility functions
- db.py: type annotations for Database methods (__exit__,
ensure_autocommit_off, tracer, register_function, etc.) and
Queryable class methods
- tests/test_docs.py: updated to match new signature display format
* Fix type errors caught by ty check
- Add type: ignore comments for external library type stub limitations
(csv.reader, click.progressbar, IOBase.name, Callable.__name__)
- Change Iterable to Sequence for SQL where_args parameters
- Use db.table() instead of db[name] for proper Table return type
- Fix rebuild_fts return type from None to Table
- Update test_tracer to expect fewer queries (optimization side effect)
* Fix mypy type errors
- Add type: ignore comments for runtime-valid patterns mypy can't verify
- Fix new_column_types annotation to Dict[str, Set[type]]
- Add type: ignore for Default sentinel values passed to create_table
* mypy skip tests directory
* Fix CI: exclude typing imports from recipe docs, skip mypy on tests
- Add Callable and Optional to exclusion list in _generate_convert_help()
- Regenerate docs/cli-reference.rst with cog
- Add [mypy-tests.*] ignore_errors = True to skip test type errors
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 22:11:47 -08:00
|
|
|
|
) -> Generator["Database", None, None]:
|
2021-08-10 16:09:28 -07:00
|
|
|
|
"""
|
|
|
|
|
|
Context manager to temporarily set a tracer function - all executed SQL queries will
|
|
|
|
|
|
be passed to this.
|
|
|
|
|
|
|
|
|
|
|
|
The tracer function should accept two arguments: ``sql`` and ``parameters``
|
|
|
|
|
|
|
|
|
|
|
|
Example usage::
|
|
|
|
|
|
|
|
|
|
|
|
with db.tracer(print):
|
|
|
|
|
|
db["creatures"].insert({"name": "Cleo"})
|
|
|
|
|
|
|
|
|
|
|
|
See :ref:`python_api_tracing`.
|
2022-03-11 09:38:34 -08:00
|
|
|
|
|
|
|
|
|
|
:param tracer: Callable accepting ``sql`` and ``parameters`` arguments
|
2021-08-10 16:09:28 -07:00
|
|
|
|
"""
|
2020-09-07 14:56:59 -07:00
|
|
|
|
prev_tracer = self._tracer
|
2026-05-17 16:52:48 -07:00
|
|
|
|
self._tracer = tracer or cast(Tracer, print)
|
2020-09-07 14:56:59 -07:00
|
|
|
|
try:
|
|
|
|
|
|
yield self
|
|
|
|
|
|
finally:
|
|
|
|
|
|
self._tracer = prev_tracer
|
2018-07-28 06:43:18 -07:00
|
|
|
|
|
2021-08-10 16:09:28 -07:00
|
|
|
|
def __getitem__(self, table_name: str) -> Union["Table", "View"]:
|
|
|
|
|
|
"""
|
2026-07-04 19:13:15 +00:00
|
|
|
|
``db[name]`` returns a :class:`.Table` object for the table with the specified name,
|
|
|
|
|
|
or a :class:`.View` object if the name matches an existing SQL view.
|
|
|
|
|
|
If neither exists yet, a table is assumed - it will be created the first
|
|
|
|
|
|
time data is inserted into it.
|
2022-03-11 09:38:34 -08:00
|
|
|
|
|
2026-07-04 19:13:15 +00:00
|
|
|
|
:param table_name: The name of the table or view
|
2021-08-10 16:09:28 -07:00
|
|
|
|
"""
|
2025-05-08 23:19:36 -07:00
|
|
|
|
if table_name in self.view_names():
|
|
|
|
|
|
return self.view(table_name)
|
2019-07-22 16:30:54 -07:00
|
|
|
|
return self.table(table_name)
|
2018-07-28 06:43:18 -07:00
|
|
|
|
|
2021-08-18 14:55:37 -07:00
|
|
|
|
def __repr__(self) -> str:
|
2018-07-31 17:35:36 -07:00
|
|
|
|
return "<Database {}>".format(self.conn)
|
|
|
|
|
|
|
2021-08-10 16:09:28 -07:00
|
|
|
|
def register_function(
|
2022-07-27 17:13:49 -07:00
|
|
|
|
self,
|
2022-11-15 22:25:26 -08:00
|
|
|
|
fn: Optional[Callable] = None,
|
2022-07-27 17:13:49 -07:00
|
|
|
|
deterministic: bool = False,
|
|
|
|
|
|
replace: bool = False,
|
|
|
|
|
|
name: Optional[str] = None,
|
More type annotations (#697)
* Add comprehensive type annotations
- mypy.ini: expanded configuration with module-specific settings
- hookspecs.py: type annotations for hook functions
- plugins.py: typed get_plugins() return value
- recipes.py: full type annotations for parsedate, parsedatetime, jsonsplit
- utils.py: extensive type annotations including Row type alias,
TypeTracker, ValueTracker, and all utility functions
- db.py: type annotations for Database methods (__exit__,
ensure_autocommit_off, tracer, register_function, etc.) and
Queryable class methods
- tests/test_docs.py: updated to match new signature display format
* Fix type errors caught by ty check
- Add type: ignore comments for external library type stub limitations
(csv.reader, click.progressbar, IOBase.name, Callable.__name__)
- Change Iterable to Sequence for SQL where_args parameters
- Use db.table() instead of db[name] for proper Table return type
- Fix rebuild_fts return type from None to Table
- Update test_tracer to expect fewer queries (optimization side effect)
* Fix mypy type errors
- Add type: ignore comments for runtime-valid patterns mypy can't verify
- Fix new_column_types annotation to Dict[str, Set[type]]
- Add type: ignore for Default sentinel values passed to create_table
* mypy skip tests directory
* Fix CI: exclude typing imports from recipe docs, skip mypy on tests
- Add Callable and Optional to exclusion list in _generate_convert_help()
- Regenerate docs/cli-reference.rst with cog
- Add [mypy-tests.*] ignore_errors = True to skip test type errors
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 22:11:47 -08:00
|
|
|
|
) -> Optional[Callable[[Callable], Callable]]:
|
2021-08-10 16:09:28 -07:00
|
|
|
|
"""
|
|
|
|
|
|
``fn`` will be made available as a function within SQL, with the same name and number
|
|
|
|
|
|
of arguments. Can be used as a decorator::
|
|
|
|
|
|
|
2022-07-27 17:07:23 -07:00
|
|
|
|
@db.register_function
|
2021-08-10 16:09:28 -07:00
|
|
|
|
def upper(value):
|
|
|
|
|
|
return str(value).upper()
|
|
|
|
|
|
|
|
|
|
|
|
The decorator can take arguments::
|
|
|
|
|
|
|
2022-07-27 17:07:23 -07:00
|
|
|
|
@db.register_function(deterministic=True, replace=True)
|
2021-08-10 16:09:28 -07:00
|
|
|
|
def upper(value):
|
|
|
|
|
|
return str(value).upper()
|
|
|
|
|
|
|
|
|
|
|
|
See :ref:`python_api_register_function`.
|
2022-03-11 09:38:34 -08:00
|
|
|
|
|
|
|
|
|
|
:param fn: Function to register
|
|
|
|
|
|
:param deterministic: set ``True`` for functions that always returns the same output for a given input
|
|
|
|
|
|
:param replace: set ``True`` to replace an existing function with the same name - otherwise throw an error
|
2022-07-27 17:13:49 -07:00
|
|
|
|
:param name: name of the SQLite function - if not specified, the Python function name will be used
|
2021-08-10 16:09:28 -07:00
|
|
|
|
"""
|
|
|
|
|
|
|
More type annotations (#697)
* Add comprehensive type annotations
- mypy.ini: expanded configuration with module-specific settings
- hookspecs.py: type annotations for hook functions
- plugins.py: typed get_plugins() return value
- recipes.py: full type annotations for parsedate, parsedatetime, jsonsplit
- utils.py: extensive type annotations including Row type alias,
TypeTracker, ValueTracker, and all utility functions
- db.py: type annotations for Database methods (__exit__,
ensure_autocommit_off, tracer, register_function, etc.) and
Queryable class methods
- tests/test_docs.py: updated to match new signature display format
* Fix type errors caught by ty check
- Add type: ignore comments for external library type stub limitations
(csv.reader, click.progressbar, IOBase.name, Callable.__name__)
- Change Iterable to Sequence for SQL where_args parameters
- Use db.table() instead of db[name] for proper Table return type
- Fix rebuild_fts return type from None to Table
- Update test_tracer to expect fewer queries (optimization side effect)
* Fix mypy type errors
- Add type: ignore comments for runtime-valid patterns mypy can't verify
- Fix new_column_types annotation to Dict[str, Set[type]]
- Add type: ignore for Default sentinel values passed to create_table
* mypy skip tests directory
* Fix CI: exclude typing imports from recipe docs, skip mypy on tests
- Add Callable and Optional to exclusion list in _generate_convert_help()
- Regenerate docs/cli-reference.rst with cog
- Add [mypy-tests.*] ignore_errors = True to skip test type errors
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 22:11:47 -08:00
|
|
|
|
def register(fn: Callable) -> Callable:
|
|
|
|
|
|
fn_name = name or fn.__name__ # type: ignore
|
2020-10-28 14:24:03 -07:00
|
|
|
|
arity = len(inspect.signature(fn).parameters)
|
2022-07-27 17:13:49 -07:00
|
|
|
|
if not replace and (fn_name, arity) in self._registered_functions:
|
2023-05-08 14:58:28 -07:00
|
|
|
|
return fn
|
More type annotations (#697)
* Add comprehensive type annotations
- mypy.ini: expanded configuration with module-specific settings
- hookspecs.py: type annotations for hook functions
- plugins.py: typed get_plugins() return value
- recipes.py: full type annotations for parsedate, parsedatetime, jsonsplit
- utils.py: extensive type annotations including Row type alias,
TypeTracker, ValueTracker, and all utility functions
- db.py: type annotations for Database methods (__exit__,
ensure_autocommit_off, tracer, register_function, etc.) and
Queryable class methods
- tests/test_docs.py: updated to match new signature display format
* Fix type errors caught by ty check
- Add type: ignore comments for external library type stub limitations
(csv.reader, click.progressbar, IOBase.name, Callable.__name__)
- Change Iterable to Sequence for SQL where_args parameters
- Use db.table() instead of db[name] for proper Table return type
- Fix rebuild_fts return type from None to Table
- Update test_tracer to expect fewer queries (optimization side effect)
* Fix mypy type errors
- Add type: ignore comments for runtime-valid patterns mypy can't verify
- Fix new_column_types annotation to Dict[str, Set[type]]
- Add type: ignore for Default sentinel values passed to create_table
* mypy skip tests directory
* Fix CI: exclude typing imports from recipe docs, skip mypy on tests
- Add Callable and Optional to exclusion list in _generate_convert_help()
- Regenerate docs/cli-reference.rst with cog
- Add [mypy-tests.*] ignore_errors = True to skip test type errors
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 22:11:47 -08:00
|
|
|
|
kwargs: Dict[str, bool] = {}
|
2022-04-13 15:31:37 -07:00
|
|
|
|
registered = False
|
|
|
|
|
|
if deterministic:
|
|
|
|
|
|
# Try this, but fall back if sqlite3.NotSupportedError
|
|
|
|
|
|
try:
|
|
|
|
|
|
self.conn.create_function(
|
2022-07-27 17:13:49 -07:00
|
|
|
|
fn_name, arity, fn, **dict(kwargs, deterministic=True)
|
2022-04-13 15:31:37 -07:00
|
|
|
|
)
|
|
|
|
|
|
registered = True
|
2024-01-30 18:37:38 -08:00
|
|
|
|
except sqlite3.NotSupportedError:
|
2022-04-13 15:31:37 -07:00
|
|
|
|
pass
|
|
|
|
|
|
if not registered:
|
2022-07-27 17:13:49 -07:00
|
|
|
|
self.conn.create_function(fn_name, arity, fn, **kwargs)
|
|
|
|
|
|
self._registered_functions.add((fn_name, arity))
|
2020-10-28 14:24:03 -07:00
|
|
|
|
return fn
|
|
|
|
|
|
|
|
|
|
|
|
if fn is None:
|
|
|
|
|
|
return register
|
|
|
|
|
|
else:
|
|
|
|
|
|
register(fn)
|
More type annotations (#697)
* Add comprehensive type annotations
- mypy.ini: expanded configuration with module-specific settings
- hookspecs.py: type annotations for hook functions
- plugins.py: typed get_plugins() return value
- recipes.py: full type annotations for parsedate, parsedatetime, jsonsplit
- utils.py: extensive type annotations including Row type alias,
TypeTracker, ValueTracker, and all utility functions
- db.py: type annotations for Database methods (__exit__,
ensure_autocommit_off, tracer, register_function, etc.) and
Queryable class methods
- tests/test_docs.py: updated to match new signature display format
* Fix type errors caught by ty check
- Add type: ignore comments for external library type stub limitations
(csv.reader, click.progressbar, IOBase.name, Callable.__name__)
- Change Iterable to Sequence for SQL where_args parameters
- Use db.table() instead of db[name] for proper Table return type
- Fix rebuild_fts return type from None to Table
- Update test_tracer to expect fewer queries (optimization side effect)
* Fix mypy type errors
- Add type: ignore comments for runtime-valid patterns mypy can't verify
- Fix new_column_types annotation to Dict[str, Set[type]]
- Add type: ignore for Default sentinel values passed to create_table
* mypy skip tests directory
* Fix CI: exclude typing imports from recipe docs, skip mypy on tests
- Add Callable and Optional to exclusion list in _generate_convert_help()
- Regenerate docs/cli-reference.rst with cog
- Add [mypy-tests.*] ignore_errors = True to skip test type errors
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 22:11:47 -08:00
|
|
|
|
return None
|
2020-09-21 17:31:43 -07:00
|
|
|
|
|
More type annotations (#697)
* Add comprehensive type annotations
- mypy.ini: expanded configuration with module-specific settings
- hookspecs.py: type annotations for hook functions
- plugins.py: typed get_plugins() return value
- recipes.py: full type annotations for parsedate, parsedatetime, jsonsplit
- utils.py: extensive type annotations including Row type alias,
TypeTracker, ValueTracker, and all utility functions
- db.py: type annotations for Database methods (__exit__,
ensure_autocommit_off, tracer, register_function, etc.) and
Queryable class methods
- tests/test_docs.py: updated to match new signature display format
* Fix type errors caught by ty check
- Add type: ignore comments for external library type stub limitations
(csv.reader, click.progressbar, IOBase.name, Callable.__name__)
- Change Iterable to Sequence for SQL where_args parameters
- Use db.table() instead of db[name] for proper Table return type
- Fix rebuild_fts return type from None to Table
- Update test_tracer to expect fewer queries (optimization side effect)
* Fix mypy type errors
- Add type: ignore comments for runtime-valid patterns mypy can't verify
- Fix new_column_types annotation to Dict[str, Set[type]]
- Add type: ignore for Default sentinel values passed to create_table
* mypy skip tests directory
* Fix CI: exclude typing imports from recipe docs, skip mypy on tests
- Add Callable and Optional to exclusion list in _generate_convert_help()
- Regenerate docs/cli-reference.rst with cog
- Add [mypy-tests.*] ignore_errors = True to skip test type errors
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 22:11:47 -08:00
|
|
|
|
def register_fts4_bm25(self) -> None:
|
2021-08-10 16:09:28 -07:00
|
|
|
|
"Register the ``rank_bm25(match_info)`` function used for calculating relevance with SQLite FTS4."
|
2023-05-08 23:53:58 +02:00
|
|
|
|
self.register_function(rank_bm25, deterministic=True, replace=True)
|
2020-11-06 10:23:16 -08:00
|
|
|
|
|
More type annotations (#697)
* Add comprehensive type annotations
- mypy.ini: expanded configuration with module-specific settings
- hookspecs.py: type annotations for hook functions
- plugins.py: typed get_plugins() return value
- recipes.py: full type annotations for parsedate, parsedatetime, jsonsplit
- utils.py: extensive type annotations including Row type alias,
TypeTracker, ValueTracker, and all utility functions
- db.py: type annotations for Database methods (__exit__,
ensure_autocommit_off, tracer, register_function, etc.) and
Queryable class methods
- tests/test_docs.py: updated to match new signature display format
* Fix type errors caught by ty check
- Add type: ignore comments for external library type stub limitations
(csv.reader, click.progressbar, IOBase.name, Callable.__name__)
- Change Iterable to Sequence for SQL where_args parameters
- Use db.table() instead of db[name] for proper Table return type
- Fix rebuild_fts return type from None to Table
- Update test_tracer to expect fewer queries (optimization side effect)
* Fix mypy type errors
- Add type: ignore comments for runtime-valid patterns mypy can't verify
- Fix new_column_types annotation to Dict[str, Set[type]]
- Add type: ignore for Default sentinel values passed to create_table
* mypy skip tests directory
* Fix CI: exclude typing imports from recipe docs, skip mypy on tests
- Add Callable and Optional to exclusion list in _generate_convert_help()
- Regenerate docs/cli-reference.rst with cog
- Add [mypy-tests.*] ignore_errors = True to skip test type errors
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 22:11:47 -08:00
|
|
|
|
def attach(self, alias: str, filepath: Union[str, pathlib.Path]) -> None:
|
2021-08-10 16:09:28 -07:00
|
|
|
|
"""
|
|
|
|
|
|
Attach another SQLite database file to this connection with the specified alias, equivalent to::
|
|
|
|
|
|
|
|
|
|
|
|
ATTACH DATABASE 'filepath.db' AS alias
|
2022-03-11 09:38:34 -08:00
|
|
|
|
|
|
|
|
|
|
:param alias: Alias name to use
|
|
|
|
|
|
:param filepath: Path to SQLite database file on disk
|
2021-08-10 16:09:28 -07:00
|
|
|
|
"""
|
2021-02-18 20:56:32 -08:00
|
|
|
|
attach_sql = """
|
2025-11-23 20:43:26 -08:00
|
|
|
|
ATTACH DATABASE '{}' AS {};
|
2021-02-18 20:56:32 -08:00
|
|
|
|
""".format(
|
2025-11-23 20:43:26 -08:00
|
|
|
|
str(pathlib.Path(filepath).resolve()), quote_identifier(alias)
|
2021-02-18 20:56:32 -08:00
|
|
|
|
).strip()
|
|
|
|
|
|
self.execute(attach_sql)
|
|
|
|
|
|
|
2021-08-10 16:09:28 -07:00
|
|
|
|
def query(
|
2025-12-16 19:34:45 -08:00
|
|
|
|
self, sql: str, params: Optional[Union[Sequence, Dict[str, Any]]] = None
|
2021-08-10 16:09:28 -07:00
|
|
|
|
) -> Generator[dict, None, None]:
|
2022-03-11 09:38:34 -08:00
|
|
|
|
"""
|
|
|
|
|
|
Execute ``sql`` and return an iterable of dictionaries representing each row.
|
|
|
|
|
|
|
2026-07-04 18:24:52 +00:00
|
|
|
|
The SQL is executed as soon as this method is called - the resulting rows
|
2026-07-04 17:40:48 -07:00
|
|
|
|
are then fetched lazily as the returned iterable is iterated over. A
|
|
|
|
|
|
row-returning write such as ``INSERT ... RETURNING`` takes effect
|
|
|
|
|
|
immediately, even if the results are never iterated.
|
2026-07-04 18:24:52 +00:00
|
|
|
|
|
2022-03-11 09:38:34 -08:00
|
|
|
|
:param sql: SQL query to execute
|
|
|
|
|
|
:param params: Parameters to use in that query - an iterable for ``where id = ?``
|
|
|
|
|
|
parameters, or a dictionary for ``where id = :id``
|
2026-07-04 18:24:52 +00:00
|
|
|
|
:raises ValueError: if the SQL statement does not return rows - use
|
2026-07-04 17:40:48 -07:00
|
|
|
|
:meth:`execute` for those statements instead. The rejected statement
|
2026-07-06 21:30:47 -07:00
|
|
|
|
is rolled back, so it has no effect on the database. One exception:
|
|
|
|
|
|
a row-less ``PRAGMA`` statement takes effect despite the
|
|
|
|
|
|
``ValueError``, because PRAGMAs run outside the savepoint guard -
|
|
|
|
|
|
some of them refuse to run inside a transaction
|
2022-03-11 09:38:34 -08:00
|
|
|
|
"""
|
2026-07-04 17:40:48 -07:00
|
|
|
|
message = (
|
|
|
|
|
|
"query() can only be used with SQL that returns rows - "
|
|
|
|
|
|
"use execute() for other statements"
|
|
|
|
|
|
)
|
2026-07-05 11:49:59 -07:00
|
|
|
|
keyword = _first_keyword(sql)
|
|
|
|
|
|
if keyword in _QUERY_REJECTED_KEYWORDS:
|
2026-07-04 17:40:48 -07:00
|
|
|
|
# None of these return rows - reject them without executing anything
|
|
|
|
|
|
raise ValueError(message)
|
|
|
|
|
|
if self._tracer:
|
|
|
|
|
|
self._tracer(sql, params)
|
|
|
|
|
|
args: tuple = (params,) if params is not None else ()
|
2026-07-05 11:49:59 -07:00
|
|
|
|
if keyword == "PRAGMA":
|
2026-07-04 17:40:48 -07:00
|
|
|
|
# Some PRAGMA statements refuse to run inside a transaction, so
|
2026-07-05 12:16:11 -07:00
|
|
|
|
# execute these without the savepoint guard used below. Some
|
|
|
|
|
|
# adapters open an implicit transaction before comment-prefixed
|
|
|
|
|
|
# PRAGMAs, so temporarily use driver autocommit when it is safe.
|
|
|
|
|
|
if self.conn.in_transaction:
|
|
|
|
|
|
cursor = self.conn.execute(sql, *args)
|
|
|
|
|
|
else:
|
2026-07-05 21:54:30 -07:00
|
|
|
|
with self.ensure_autocommit_on():
|
2026-07-05 12:16:11 -07:00
|
|
|
|
cursor = self.conn.execute(sql, *args)
|
2026-07-04 17:40:48 -07:00
|
|
|
|
if cursor.description is None:
|
|
|
|
|
|
raise ValueError(message)
|
2026-07-05 21:20:39 -07:00
|
|
|
|
keys = dedupe_keys(d[0] for d in cursor.description)
|
2026-07-04 17:40:48 -07:00
|
|
|
|
return (dict(zip(keys, row)) for row in cursor)
|
|
|
|
|
|
# Execute inside a savepoint, so a statement that turns out not to
|
|
|
|
|
|
# return rows can be rolled back before the ValueError is raised
|
|
|
|
|
|
self.conn.execute('SAVEPOINT "sqlite_utils_query"')
|
|
|
|
|
|
released = False
|
|
|
|
|
|
try:
|
|
|
|
|
|
cursor = self.conn.execute(sql, *args)
|
|
|
|
|
|
if cursor.description is None:
|
|
|
|
|
|
raise ValueError(message)
|
2026-07-05 21:20:39 -07:00
|
|
|
|
keys = dedupe_keys(d[0] for d in cursor.description)
|
2026-07-04 17:40:48 -07:00
|
|
|
|
try:
|
|
|
|
|
|
self.conn.execute('RELEASE "sqlite_utils_query"')
|
|
|
|
|
|
released = True
|
|
|
|
|
|
except sqlite3.OperationalError:
|
|
|
|
|
|
# The savepoint cannot be released while a write statement is
|
|
|
|
|
|
# still executing - this is INSERT ... RETURNING or similar,
|
|
|
|
|
|
# with unfetched rows. Fetch them so the write completes, then
|
|
|
|
|
|
# release again - committing the write immediately, unless an
|
|
|
|
|
|
# outer transaction is open
|
|
|
|
|
|
fetched = cursor.fetchall()
|
|
|
|
|
|
self.conn.execute('RELEASE "sqlite_utils_query"')
|
|
|
|
|
|
released = True
|
|
|
|
|
|
return (dict(zip(keys, row)) for row in fetched)
|
|
|
|
|
|
return (dict(zip(keys, row)) for row in cursor)
|
|
|
|
|
|
finally:
|
2026-07-06 22:04:00 -07:00
|
|
|
|
if not released and self.conn.in_transaction:
|
|
|
|
|
|
# An error occurred - undo anything the statement changed.
|
|
|
|
|
|
# If the error itself destroyed the transaction (such as a
|
|
|
|
|
|
# RAISE(ROLLBACK) trigger) the savepoint is already gone
|
|
|
|
|
|
# and there is nothing left to undo
|
2026-07-04 17:40:48 -07:00
|
|
|
|
self.conn.execute('ROLLBACK TO "sqlite_utils_query"')
|
|
|
|
|
|
self.conn.execute('RELEASE "sqlite_utils_query"')
|
2021-08-10 16:09:28 -07:00
|
|
|
|
|
|
|
|
|
|
def execute(
|
2025-12-16 19:34:45 -08:00
|
|
|
|
self, sql: str, parameters: Optional[Union[Sequence, Dict[str, Any]]] = None
|
2021-08-10 16:09:28 -07:00
|
|
|
|
) -> sqlite3.Cursor:
|
2022-03-11 09:38:34 -08:00
|
|
|
|
"""
|
|
|
|
|
|
Execute SQL query and return a ``sqlite3.Cursor``.
|
|
|
|
|
|
|
2026-07-04 23:15:01 +00:00
|
|
|
|
A write statement - ``INSERT``, ``UPDATE``, ``CREATE TABLE`` and so on -
|
|
|
|
|
|
is committed automatically, unless a transaction is already open, in
|
|
|
|
|
|
which case it becomes part of that transaction. See
|
|
|
|
|
|
:ref:`python_api_transactions`.
|
|
|
|
|
|
|
2022-03-11 09:38:34 -08:00
|
|
|
|
:param sql: SQL query to execute
|
|
|
|
|
|
:param parameters: Parameters to use in that query - an iterable for ``where id = ?``
|
|
|
|
|
|
parameters, or a dictionary for ``where id = :id``
|
|
|
|
|
|
"""
|
2020-09-07 14:56:59 -07:00
|
|
|
|
if self._tracer:
|
|
|
|
|
|
self._tracer(sql, parameters)
|
2026-07-04 23:15:01 +00:00
|
|
|
|
was_in_transaction = self.conn.in_transaction
|
2026-07-06 21:19:20 -07:00
|
|
|
|
try:
|
|
|
|
|
|
if parameters is not None:
|
|
|
|
|
|
cursor = self.conn.execute(sql, parameters)
|
|
|
|
|
|
else:
|
|
|
|
|
|
cursor = self.conn.execute(sql)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
if not was_in_transaction and self.conn.in_transaction:
|
|
|
|
|
|
# The failed statement opened an implicit transaction that
|
|
|
|
|
|
# nothing would ever commit - roll it back, otherwise it
|
|
|
|
|
|
# would capture every subsequent write
|
|
|
|
|
|
self.conn.execute("ROLLBACK")
|
|
|
|
|
|
raise
|
2026-07-04 23:15:01 +00:00
|
|
|
|
if (
|
|
|
|
|
|
not was_in_transaction
|
|
|
|
|
|
and self.conn.in_transaction
|
|
|
|
|
|
and cursor.description is None
|
2026-07-05 11:49:59 -07:00
|
|
|
|
and _first_keyword(sql) not in _TRANSACTION_CONTROL_KEYWORDS
|
2026-07-04 23:15:01 +00:00
|
|
|
|
):
|
|
|
|
|
|
# The statement opened an implicit transaction - commit it, so
|
|
|
|
|
|
# that execute() behaves consistently with the rest of the
|
|
|
|
|
|
# library and identically across connection modes
|
|
|
|
|
|
self.conn.execute("COMMIT")
|
|
|
|
|
|
return cursor
|
2020-09-07 14:56:59 -07:00
|
|
|
|
|
2021-08-10 16:09:28 -07:00
|
|
|
|
def executescript(self, sql: str) -> sqlite3.Cursor:
|
2022-03-11 09:38:34 -08:00
|
|
|
|
"""
|
|
|
|
|
|
Execute multiple SQL statements separated by ; and return the ``sqlite3.Cursor``.
|
|
|
|
|
|
|
|
|
|
|
|
:param sql: SQL to execute
|
|
|
|
|
|
"""
|
2020-09-07 14:56:59 -07:00
|
|
|
|
if self._tracer:
|
|
|
|
|
|
self._tracer(sql, None)
|
2026-06-21 10:43:19 -07:00
|
|
|
|
return self._executescript(sql)
|
|
|
|
|
|
|
|
|
|
|
|
def _executescript(self, sql: str) -> sqlite3.Cursor:
|
|
|
|
|
|
if self.conn.in_transaction:
|
|
|
|
|
|
cursor = self.conn.cursor()
|
|
|
|
|
|
# avoid sqlite3.executescript()'s implicit commit:
|
|
|
|
|
|
for statement in _iter_complete_sql_statements(sql):
|
|
|
|
|
|
cursor.execute(statement)
|
|
|
|
|
|
return cursor
|
2020-09-07 14:56:59 -07:00
|
|
|
|
return self.conn.executescript(sql)
|
|
|
|
|
|
|
More type annotations (#697)
* Add comprehensive type annotations
- mypy.ini: expanded configuration with module-specific settings
- hookspecs.py: type annotations for hook functions
- plugins.py: typed get_plugins() return value
- recipes.py: full type annotations for parsedate, parsedatetime, jsonsplit
- utils.py: extensive type annotations including Row type alias,
TypeTracker, ValueTracker, and all utility functions
- db.py: type annotations for Database methods (__exit__,
ensure_autocommit_off, tracer, register_function, etc.) and
Queryable class methods
- tests/test_docs.py: updated to match new signature display format
* Fix type errors caught by ty check
- Add type: ignore comments for external library type stub limitations
(csv.reader, click.progressbar, IOBase.name, Callable.__name__)
- Change Iterable to Sequence for SQL where_args parameters
- Use db.table() instead of db[name] for proper Table return type
- Fix rebuild_fts return type from None to Table
- Update test_tracer to expect fewer queries (optimization side effect)
* Fix mypy type errors
- Add type: ignore comments for runtime-valid patterns mypy can't verify
- Fix new_column_types annotation to Dict[str, Set[type]]
- Add type: ignore for Default sentinel values passed to create_table
* mypy skip tests directory
* Fix CI: exclude typing imports from recipe docs, skip mypy on tests
- Add Callable and Optional to exclusion list in _generate_convert_help()
- Regenerate docs/cli-reference.rst with cog
- Add [mypy-tests.*] ignore_errors = True to skip test type errors
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 22:11:47 -08:00
|
|
|
|
def table(self, table_name: str, **kwargs: Any) -> "Table":
|
2022-03-11 09:38:34 -08:00
|
|
|
|
"""
|
|
|
|
|
|
Return a table object, optionally configured with default options.
|
|
|
|
|
|
|
2022-07-27 17:28:46 -07:00
|
|
|
|
See :ref:`reference_db_table` for option details.
|
|
|
|
|
|
|
2022-03-11 09:38:34 -08:00
|
|
|
|
:param table_name: Name of the table
|
|
|
|
|
|
"""
|
2023-12-07 21:05:27 -08:00
|
|
|
|
if table_name in self.view_names():
|
2025-05-08 23:19:36 -07:00
|
|
|
|
raise NoTable("Table {} is actually a view".format(table_name))
|
|
|
|
|
|
kwargs.setdefault("strict", self.strict)
|
|
|
|
|
|
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():
|
2026-07-04 19:13:15 +00:00
|
|
|
|
if view_name in self.table_names():
|
|
|
|
|
|
raise NoView(
|
|
|
|
|
|
"View {name} does not exist - {name} is a table".format(
|
|
|
|
|
|
name=view_name
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
2025-05-08 23:19:36 -07:00
|
|
|
|
raise NoView("View {} does not exist".format(view_name))
|
|
|
|
|
|
return View(self, view_name)
|
2019-07-22 16:30:54 -07:00
|
|
|
|
|
2021-08-10 16:09:28 -07:00
|
|
|
|
def quote(self, value: str) -> str:
|
2022-03-11 09:38:34 -08:00
|
|
|
|
"""
|
2023-11-03 17:25:02 -07:00
|
|
|
|
Apply SQLite string quoting to a value, including wrapping it in single quotes.
|
2022-03-11 09:38:34 -08:00
|
|
|
|
|
|
|
|
|
|
:param value: String to quote
|
|
|
|
|
|
"""
|
2019-06-12 23:10:07 -07:00
|
|
|
|
# Normally we would use .execute(sql, [params]) for escaping, but
|
|
|
|
|
|
# occasionally that isn't available - most notable when we need
|
|
|
|
|
|
# to include a "... DEFAULT 'value'" in a column definition.
|
2020-09-07 14:56:59 -07:00
|
|
|
|
return self.execute(
|
2019-06-12 23:10:07 -07:00
|
|
|
|
# Use SQLite itself to correctly escape this string:
|
|
|
|
|
|
"SELECT quote(:value)",
|
|
|
|
|
|
{"value": value},
|
|
|
|
|
|
).fetchone()[0]
|
|
|
|
|
|
|
2021-08-18 19:43:11 +01:00
|
|
|
|
def quote_fts(self, query: str) -> str:
|
2022-03-11 09:38:34 -08:00
|
|
|
|
"""
|
|
|
|
|
|
Escape special characters in a SQLite full-text search query.
|
|
|
|
|
|
|
|
|
|
|
|
This works by surrounding each token within the query with double
|
|
|
|
|
|
quotes, in order to avoid words like ``NOT`` and ``OR`` having
|
|
|
|
|
|
special meaning as defined by the FTS query syntax here:
|
|
|
|
|
|
|
|
|
|
|
|
https://www.sqlite.org/fts5.html#full_text_query_syntax
|
|
|
|
|
|
|
|
|
|
|
|
If the query has unbalanced ``"`` characters, adds one at end.
|
|
|
|
|
|
|
|
|
|
|
|
:param query: String to escape
|
|
|
|
|
|
"""
|
2021-08-18 19:43:11 +01:00
|
|
|
|
if query.count('"') % 2:
|
|
|
|
|
|
query += '"'
|
|
|
|
|
|
bits = _quote_fts_re.split(query)
|
|
|
|
|
|
bits = [b for b in bits if b and b != '""']
|
|
|
|
|
|
return " ".join(
|
|
|
|
|
|
'"{}"'.format(bit) if not bit.startswith('"') else bit for bit in bits
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2023-05-09 06:13:36 +09:00
|
|
|
|
def quote_default_value(self, value: str) -> str:
|
|
|
|
|
|
if any(
|
|
|
|
|
|
[
|
|
|
|
|
|
str(value).startswith("'") and str(value).endswith("'"),
|
|
|
|
|
|
str(value).startswith('"') and str(value).endswith('"'),
|
|
|
|
|
|
]
|
|
|
|
|
|
):
|
|
|
|
|
|
return value
|
|
|
|
|
|
|
|
|
|
|
|
if str(value).upper() in ("CURRENT_TIME", "CURRENT_DATE", "CURRENT_TIMESTAMP"):
|
|
|
|
|
|
return value
|
|
|
|
|
|
|
2026-07-06 07:33:55 +02:00
|
|
|
|
if isinstance(value, str) and value.upper() in ("TRUE", "FALSE", "NULL"):
|
|
|
|
|
|
# Keyword literals must stay unquoted; quoting them would turn the
|
|
|
|
|
|
# default into a string ('TRUE' instead of 1, 'NULL' instead of null).
|
|
|
|
|
|
return value
|
|
|
|
|
|
|
2023-05-09 06:13:36 +09:00
|
|
|
|
if str(value).endswith(")"):
|
|
|
|
|
|
# Expr
|
|
|
|
|
|
return "({})".format(value)
|
|
|
|
|
|
|
|
|
|
|
|
return self.quote(value)
|
|
|
|
|
|
|
2021-08-10 16:09:28 -07:00
|
|
|
|
def table_names(self, fts4: bool = False, fts5: bool = False) -> List[str]:
|
2022-03-11 09:38:34 -08:00
|
|
|
|
"""
|
|
|
|
|
|
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
|
|
|
|
|
|
"""
|
2019-01-24 19:57:04 -08:00
|
|
|
|
where = ["type = 'table'"]
|
|
|
|
|
|
if fts4:
|
2020-09-08 16:16:03 -07:00
|
|
|
|
where.append("sql like '%USING FTS4%'")
|
2019-01-24 19:57:04 -08:00
|
|
|
|
if fts5:
|
2020-09-08 16:16:03 -07:00
|
|
|
|
where.append("sql like '%USING FTS5%'")
|
2019-01-24 19:57:04 -08:00
|
|
|
|
sql = "select name from sqlite_master where {}".format(" AND ".join(where))
|
2020-09-07 14:56:59 -07:00
|
|
|
|
return [r[0] for r in self.execute(sql).fetchall()]
|
2018-07-28 06:43:18 -07:00
|
|
|
|
|
2021-08-10 16:09:28 -07:00
|
|
|
|
def view_names(self) -> List[str]:
|
2022-03-11 09:38:34 -08:00
|
|
|
|
"List of string view names in this database."
|
2019-08-23 15:19:41 +03:00
|
|
|
|
return [
|
|
|
|
|
|
r[0]
|
2020-09-07 14:56:59 -07:00
|
|
|
|
for r in self.execute(
|
2019-08-23 15:19:41 +03:00
|
|
|
|
"select name from sqlite_master where type = 'view'"
|
|
|
|
|
|
).fetchall()
|
|
|
|
|
|
]
|
|
|
|
|
|
|
2018-07-31 17:35:36 -07:00
|
|
|
|
@property
|
2021-08-10 16:09:28 -07:00
|
|
|
|
def tables(self) -> List["Table"]:
|
2022-03-11 09:38:34 -08:00
|
|
|
|
"List of Table objects in this database."
|
2025-05-08 23:19:36 -07:00
|
|
|
|
return [self.table(name) for name in self.table_names()]
|
2018-07-31 17:35:36 -07:00
|
|
|
|
|
2019-08-23 15:19:41 +03:00
|
|
|
|
@property
|
2021-08-10 16:09:28 -07:00
|
|
|
|
def views(self) -> List["View"]:
|
2022-03-11 09:38:34 -08:00
|
|
|
|
"List of View objects in this database."
|
2025-05-08 23:19:36 -07:00
|
|
|
|
return [self.view(name) for name in self.view_names()]
|
2019-08-23 15:19:41 +03:00
|
|
|
|
|
2019-09-02 17:09:41 -07:00
|
|
|
|
@property
|
2021-08-10 16:09:28 -07:00
|
|
|
|
def triggers(self) -> List[Trigger]:
|
2022-03-11 09:38:34 -08:00
|
|
|
|
"List of ``(name, table_name, sql)`` tuples representing triggers in this database."
|
2019-09-02 17:09:41 -07:00
|
|
|
|
return [
|
|
|
|
|
|
Trigger(*r)
|
2020-09-07 14:56:59 -07:00
|
|
|
|
for r in self.execute(
|
2019-09-02 17:09:41 -07:00
|
|
|
|
"select name, tbl_name, sql from sqlite_master where type = 'trigger'"
|
|
|
|
|
|
).fetchall()
|
|
|
|
|
|
]
|
|
|
|
|
|
|
2021-01-02 20:19:55 -08:00
|
|
|
|
@property
|
2021-08-10 16:09:28 -07:00
|
|
|
|
def triggers_dict(self) -> Dict[str, str]:
|
|
|
|
|
|
"A ``{trigger_name: sql}`` dictionary of triggers in this database."
|
2021-01-02 20:19:55 -08:00
|
|
|
|
return {trigger.name: trigger.sql for trigger in self.triggers}
|
|
|
|
|
|
|
2021-06-11 13:51:49 -07:00
|
|
|
|
@property
|
2021-08-10 16:09:28 -07:00
|
|
|
|
def schema(self) -> str:
|
2022-03-11 09:38:34 -08:00
|
|
|
|
"SQL schema for this database."
|
2021-06-11 13:51:49 -07:00
|
|
|
|
sqls = []
|
|
|
|
|
|
for row in self.execute(
|
|
|
|
|
|
"select sql from sqlite_master where sql is not null"
|
|
|
|
|
|
).fetchall():
|
|
|
|
|
|
sql = row[0]
|
|
|
|
|
|
if not sql.strip().endswith(";"):
|
|
|
|
|
|
sql += ";"
|
|
|
|
|
|
sqls.append(sql)
|
|
|
|
|
|
return "\n".join(sqls)
|
|
|
|
|
|
|
2021-11-29 14:19:30 -08:00
|
|
|
|
@property
|
2022-01-24 20:12:32 -08:00
|
|
|
|
def supports_strict(self) -> bool:
|
|
|
|
|
|
"Does this database support STRICT mode?"
|
2025-05-08 20:37:49 -07:00
|
|
|
|
if not hasattr(self, "_supports_strict"):
|
|
|
|
|
|
try:
|
|
|
|
|
|
table_name = "t{}".format(secrets.token_hex(16))
|
2026-06-21 10:43:19 -07:00
|
|
|
|
with self.atomic():
|
2025-05-08 20:37:49 -07:00
|
|
|
|
self.conn.execute(
|
|
|
|
|
|
"create table {} (name text) strict".format(table_name)
|
|
|
|
|
|
)
|
|
|
|
|
|
self.conn.execute("drop table {}".format(table_name))
|
|
|
|
|
|
self._supports_strict = True
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
self._supports_strict = False
|
|
|
|
|
|
return self._supports_strict
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def supports_on_conflict(self) -> bool:
|
|
|
|
|
|
# SQLite's upsert is implemented as INSERT INTO ... ON CONFLICT DO ...
|
|
|
|
|
|
if not hasattr(self, "_supports_on_conflict"):
|
2021-11-29 14:19:30 -08:00
|
|
|
|
table_name = "t{}".format(secrets.token_hex(16))
|
2025-05-08 20:37:49 -07:00
|
|
|
|
try:
|
2026-06-21 10:43:19 -07:00
|
|
|
|
with self.atomic():
|
2025-05-08 20:37:49 -07:00
|
|
|
|
self.conn.execute(
|
|
|
|
|
|
"create table {} (id integer primary key, name text)".format(
|
|
|
|
|
|
table_name
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
self.conn.execute(
|
|
|
|
|
|
"insert into {} (id, name) values (1, 'one')".format(table_name)
|
|
|
|
|
|
)
|
|
|
|
|
|
self.conn.execute(
|
|
|
|
|
|
(
|
|
|
|
|
|
"insert into {} (id, name) values (1, 'two') "
|
|
|
|
|
|
"on conflict do update set name = 'two'"
|
|
|
|
|
|
).format(table_name)
|
|
|
|
|
|
)
|
|
|
|
|
|
self._supports_on_conflict = True
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
self._supports_on_conflict = False
|
|
|
|
|
|
finally:
|
|
|
|
|
|
self.conn.execute("drop table if exists {}".format(table_name))
|
|
|
|
|
|
return self._supports_on_conflict
|
2021-11-29 14:19:30 -08:00
|
|
|
|
|
2022-03-01 16:24:27 -08:00
|
|
|
|
@property
|
|
|
|
|
|
def sqlite_version(self) -> Tuple[int, ...]:
|
2022-03-11 09:38:34 -08:00
|
|
|
|
"Version of SQLite, as a tuple of integers for example ``(3, 36, 0)``."
|
2022-03-01 16:24:27 -08:00
|
|
|
|
row = self.execute("select sqlite_version()").fetchall()[0]
|
|
|
|
|
|
return tuple(map(int, row[0].split(".")))
|
|
|
|
|
|
|
2020-08-10 11:59:21 -07:00
|
|
|
|
@property
|
2021-08-10 16:09:28 -07:00
|
|
|
|
def journal_mode(self) -> str:
|
2022-03-11 09:38:34 -08:00
|
|
|
|
"""
|
|
|
|
|
|
Current ``journal_mode`` of this database.
|
|
|
|
|
|
|
|
|
|
|
|
https://www.sqlite.org/pragma.html#pragma_journal_mode
|
|
|
|
|
|
"""
|
2020-09-07 14:56:59 -07:00
|
|
|
|
return self.execute("PRAGMA journal_mode;").fetchone()[0]
|
2020-08-10 11:59:21 -07:00
|
|
|
|
|
More type annotations (#697)
* Add comprehensive type annotations
- mypy.ini: expanded configuration with module-specific settings
- hookspecs.py: type annotations for hook functions
- plugins.py: typed get_plugins() return value
- recipes.py: full type annotations for parsedate, parsedatetime, jsonsplit
- utils.py: extensive type annotations including Row type alias,
TypeTracker, ValueTracker, and all utility functions
- db.py: type annotations for Database methods (__exit__,
ensure_autocommit_off, tracer, register_function, etc.) and
Queryable class methods
- tests/test_docs.py: updated to match new signature display format
* Fix type errors caught by ty check
- Add type: ignore comments for external library type stub limitations
(csv.reader, click.progressbar, IOBase.name, Callable.__name__)
- Change Iterable to Sequence for SQL where_args parameters
- Use db.table() instead of db[name] for proper Table return type
- Fix rebuild_fts return type from None to Table
- Update test_tracer to expect fewer queries (optimization side effect)
* Fix mypy type errors
- Add type: ignore comments for runtime-valid patterns mypy can't verify
- Fix new_column_types annotation to Dict[str, Set[type]]
- Add type: ignore for Default sentinel values passed to create_table
* mypy skip tests directory
* Fix CI: exclude typing imports from recipe docs, skip mypy on tests
- Add Callable and Optional to exclusion list in _generate_convert_help()
- Regenerate docs/cli-reference.rst with cog
- Add [mypy-tests.*] ignore_errors = True to skip test type errors
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 22:11:47 -08:00
|
|
|
|
def enable_wal(self) -> None:
|
2022-03-11 09:38:34 -08:00
|
|
|
|
"""
|
|
|
|
|
|
Sets ``journal_mode`` to ``'wal'`` to enable Write-Ahead Log mode.
|
2026-07-04 18:46:09 +00:00
|
|
|
|
|
2026-07-04 22:32:36 +00:00
|
|
|
|
:raises TransactionError: if called while a transaction is open - the
|
2026-07-04 18:46:09 +00:00
|
|
|
|
journal mode can only be changed outside of a transaction
|
2022-03-11 09:38:34 -08:00
|
|
|
|
"""
|
2020-08-10 11:59:21 -07:00
|
|
|
|
if self.journal_mode != "wal":
|
2026-07-04 18:46:09 +00:00
|
|
|
|
self._ensure_no_open_transaction("enable_wal()")
|
2026-07-05 21:54:30 -07:00
|
|
|
|
with self.ensure_autocommit_on():
|
2023-06-25 16:25:51 -07:00
|
|
|
|
self.execute("PRAGMA journal_mode=wal;")
|
2020-08-10 11:59:21 -07:00
|
|
|
|
|
More type annotations (#697)
* Add comprehensive type annotations
- mypy.ini: expanded configuration with module-specific settings
- hookspecs.py: type annotations for hook functions
- plugins.py: typed get_plugins() return value
- recipes.py: full type annotations for parsedate, parsedatetime, jsonsplit
- utils.py: extensive type annotations including Row type alias,
TypeTracker, ValueTracker, and all utility functions
- db.py: type annotations for Database methods (__exit__,
ensure_autocommit_off, tracer, register_function, etc.) and
Queryable class methods
- tests/test_docs.py: updated to match new signature display format
* Fix type errors caught by ty check
- Add type: ignore comments for external library type stub limitations
(csv.reader, click.progressbar, IOBase.name, Callable.__name__)
- Change Iterable to Sequence for SQL where_args parameters
- Use db.table() instead of db[name] for proper Table return type
- Fix rebuild_fts return type from None to Table
- Update test_tracer to expect fewer queries (optimization side effect)
* Fix mypy type errors
- Add type: ignore comments for runtime-valid patterns mypy can't verify
- Fix new_column_types annotation to Dict[str, Set[type]]
- Add type: ignore for Default sentinel values passed to create_table
* mypy skip tests directory
* Fix CI: exclude typing imports from recipe docs, skip mypy on tests
- Add Callable and Optional to exclusion list in _generate_convert_help()
- Regenerate docs/cli-reference.rst with cog
- Add [mypy-tests.*] ignore_errors = True to skip test type errors
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 22:11:47 -08:00
|
|
|
|
def disable_wal(self) -> None:
|
2026-07-04 18:46:09 +00:00
|
|
|
|
"""
|
|
|
|
|
|
Sets ``journal_mode`` back to ``'delete'`` to disable Write-Ahead Log mode.
|
|
|
|
|
|
|
2026-07-04 22:32:36 +00:00
|
|
|
|
:raises TransactionError: if called while a transaction is open - the
|
2026-07-04 18:46:09 +00:00
|
|
|
|
journal mode can only be changed outside of a transaction
|
|
|
|
|
|
"""
|
2020-08-10 11:59:21 -07:00
|
|
|
|
if self.journal_mode != "delete":
|
2026-07-04 18:46:09 +00:00
|
|
|
|
self._ensure_no_open_transaction("disable_wal()")
|
2026-07-05 21:54:30 -07:00
|
|
|
|
with self.ensure_autocommit_on():
|
2023-06-25 16:25:51 -07:00
|
|
|
|
self.execute("PRAGMA journal_mode=delete;")
|
2020-08-10 11:59:21 -07:00
|
|
|
|
|
2026-07-04 18:46:09 +00:00
|
|
|
|
def _ensure_no_open_transaction(self, operation: str) -> None:
|
|
|
|
|
|
# Changing journal mode assigns conn.isolation_level, which commits
|
|
|
|
|
|
# any open transaction as a side effect - breaking the rollback
|
|
|
|
|
|
# guarantee of atomic() and of user-managed transactions
|
|
|
|
|
|
if self.conn.in_transaction:
|
2026-07-04 22:32:36 +00:00
|
|
|
|
raise TransactionError(
|
2026-07-04 18:46:09 +00:00
|
|
|
|
"{} cannot be used while a transaction is open".format(operation)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
More type annotations (#697)
* Add comprehensive type annotations
- mypy.ini: expanded configuration with module-specific settings
- hookspecs.py: type annotations for hook functions
- plugins.py: typed get_plugins() return value
- recipes.py: full type annotations for parsedate, parsedatetime, jsonsplit
- utils.py: extensive type annotations including Row type alias,
TypeTracker, ValueTracker, and all utility functions
- db.py: type annotations for Database methods (__exit__,
ensure_autocommit_off, tracer, register_function, etc.) and
Queryable class methods
- tests/test_docs.py: updated to match new signature display format
* Fix type errors caught by ty check
- Add type: ignore comments for external library type stub limitations
(csv.reader, click.progressbar, IOBase.name, Callable.__name__)
- Change Iterable to Sequence for SQL where_args parameters
- Use db.table() instead of db[name] for proper Table return type
- Fix rebuild_fts return type from None to Table
- Update test_tracer to expect fewer queries (optimization side effect)
* Fix mypy type errors
- Add type: ignore comments for runtime-valid patterns mypy can't verify
- Fix new_column_types annotation to Dict[str, Set[type]]
- Add type: ignore for Default sentinel values passed to create_table
* mypy skip tests directory
* Fix CI: exclude typing imports from recipe docs, skip mypy on tests
- Add Callable and Optional to exclusion list in _generate_convert_help()
- Regenerate docs/cli-reference.rst with cog
- Add [mypy-tests.*] ignore_errors = True to skip test type errors
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 22:11:47 -08:00
|
|
|
|
def _ensure_counts_table(self) -> None:
|
2026-06-21 10:43:19 -07:00
|
|
|
|
with self.atomic():
|
2021-01-03 12:19:34 -08:00
|
|
|
|
self.execute(_COUNTS_TABLE_CREATE_SQL.format(self._counts_table_name))
|
2021-01-02 14:03:52 -08:00
|
|
|
|
|
More type annotations (#697)
* Add comprehensive type annotations
- mypy.ini: expanded configuration with module-specific settings
- hookspecs.py: type annotations for hook functions
- plugins.py: typed get_plugins() return value
- recipes.py: full type annotations for parsedate, parsedatetime, jsonsplit
- utils.py: extensive type annotations including Row type alias,
TypeTracker, ValueTracker, and all utility functions
- db.py: type annotations for Database methods (__exit__,
ensure_autocommit_off, tracer, register_function, etc.) and
Queryable class methods
- tests/test_docs.py: updated to match new signature display format
* Fix type errors caught by ty check
- Add type: ignore comments for external library type stub limitations
(csv.reader, click.progressbar, IOBase.name, Callable.__name__)
- Change Iterable to Sequence for SQL where_args parameters
- Use db.table() instead of db[name] for proper Table return type
- Fix rebuild_fts return type from None to Table
- Update test_tracer to expect fewer queries (optimization side effect)
* Fix mypy type errors
- Add type: ignore comments for runtime-valid patterns mypy can't verify
- Fix new_column_types annotation to Dict[str, Set[type]]
- Add type: ignore for Default sentinel values passed to create_table
* mypy skip tests directory
* Fix CI: exclude typing imports from recipe docs, skip mypy on tests
- Add Callable and Optional to exclusion list in _generate_convert_help()
- Regenerate docs/cli-reference.rst with cog
- Add [mypy-tests.*] ignore_errors = True to skip test type errors
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 22:11:47 -08:00
|
|
|
|
def enable_counts(self) -> None:
|
2021-08-10 16:09:28 -07:00
|
|
|
|
"""
|
|
|
|
|
|
Enable trigger-based count caching for every table in the database, see
|
|
|
|
|
|
:ref:`python_api_cached_table_counts`.
|
|
|
|
|
|
"""
|
2021-01-02 14:03:52 -08:00
|
|
|
|
self._ensure_counts_table()
|
|
|
|
|
|
for table in self.tables:
|
|
|
|
|
|
if (
|
|
|
|
|
|
table.virtual_table_using is None
|
|
|
|
|
|
and table.name != self._counts_table_name
|
|
|
|
|
|
):
|
|
|
|
|
|
table.enable_counts()
|
2021-01-03 12:19:34 -08:00
|
|
|
|
self.use_counts_table = True
|
|
|
|
|
|
|
2021-08-10 16:09:28 -07:00
|
|
|
|
def cached_counts(self, tables: Optional[Iterable[str]] = None) -> Dict[str, int]:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Return ``{table_name: count}`` dictionary of cached counts for specified tables, or
|
|
|
|
|
|
all tables if ``tables`` not provided.
|
2022-03-11 09:38:34 -08:00
|
|
|
|
|
|
|
|
|
|
:param tables: Subset list of tables to return counts for.
|
2021-08-10 16:09:28 -07:00
|
|
|
|
"""
|
2025-11-23 20:43:26 -08:00
|
|
|
|
sql = 'select "table", count from {}'.format(self._counts_table_name)
|
2025-12-16 19:34:45 -08:00
|
|
|
|
tables_list = list(tables) if tables else None
|
|
|
|
|
|
if tables_list:
|
|
|
|
|
|
sql += ' where "table" in ({})'.format(", ".join("?" for _ in tables_list))
|
2021-01-03 12:19:34 -08:00
|
|
|
|
try:
|
2025-12-16 19:34:45 -08:00
|
|
|
|
return {r[0]: r[1] for r in self.execute(sql, tables_list).fetchall()}
|
2021-01-03 12:19:34 -08:00
|
|
|
|
except OperationalError:
|
|
|
|
|
|
return {}
|
2021-01-02 14:03:52 -08:00
|
|
|
|
|
More type annotations (#697)
* Add comprehensive type annotations
- mypy.ini: expanded configuration with module-specific settings
- hookspecs.py: type annotations for hook functions
- plugins.py: typed get_plugins() return value
- recipes.py: full type annotations for parsedate, parsedatetime, jsonsplit
- utils.py: extensive type annotations including Row type alias,
TypeTracker, ValueTracker, and all utility functions
- db.py: type annotations for Database methods (__exit__,
ensure_autocommit_off, tracer, register_function, etc.) and
Queryable class methods
- tests/test_docs.py: updated to match new signature display format
* Fix type errors caught by ty check
- Add type: ignore comments for external library type stub limitations
(csv.reader, click.progressbar, IOBase.name, Callable.__name__)
- Change Iterable to Sequence for SQL where_args parameters
- Use db.table() instead of db[name] for proper Table return type
- Fix rebuild_fts return type from None to Table
- Update test_tracer to expect fewer queries (optimization side effect)
* Fix mypy type errors
- Add type: ignore comments for runtime-valid patterns mypy can't verify
- Fix new_column_types annotation to Dict[str, Set[type]]
- Add type: ignore for Default sentinel values passed to create_table
* mypy skip tests directory
* Fix CI: exclude typing imports from recipe docs, skip mypy on tests
- Add Callable and Optional to exclusion list in _generate_convert_help()
- Regenerate docs/cli-reference.rst with cog
- Add [mypy-tests.*] ignore_errors = True to skip test type errors
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 22:11:47 -08:00
|
|
|
|
def reset_counts(self) -> None:
|
2021-08-10 16:09:28 -07:00
|
|
|
|
"Re-calculate cached counts for tables."
|
2021-01-03 12:59:31 -08:00
|
|
|
|
tables = [table for table in self.tables if table.has_counts_triggers]
|
2026-06-21 10:43:19 -07:00
|
|
|
|
with self.atomic():
|
2021-01-03 12:59:31 -08:00
|
|
|
|
self._ensure_counts_table()
|
2025-12-16 19:34:45 -08:00
|
|
|
|
counts_table = self.table(self._counts_table_name)
|
2021-01-03 12:59:31 -08:00
|
|
|
|
counts_table.delete_where()
|
|
|
|
|
|
counts_table.insert_all(
|
|
|
|
|
|
{"table": table.name, "count": table.execute_count()}
|
|
|
|
|
|
for table in tables
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2021-06-24 16:06:12 -07:00
|
|
|
|
def execute_returning_dicts(
|
2025-12-16 19:34:45 -08:00
|
|
|
|
self, sql: str, params: Optional[Union[Sequence, Dict[str, Any]]] = None
|
2021-06-24 16:06:12 -07:00
|
|
|
|
) -> List[dict]:
|
2021-06-21 21:03:59 -07:00
|
|
|
|
return list(self.query(sql, params))
|
2018-08-02 08:17:29 -07:00
|
|
|
|
|
2021-08-18 15:25:18 -07:00
|
|
|
|
def resolve_foreign_keys(
|
|
|
|
|
|
self, name: str, foreign_keys: ForeignKeysType
|
|
|
|
|
|
) -> List[ForeignKey]:
|
2023-08-17 17:48:08 -07:00
|
|
|
|
"""
|
|
|
|
|
|
Given a list of differing foreign_keys definitions, return a list of
|
|
|
|
|
|
fully resolved ForeignKey() named tuples.
|
|
|
|
|
|
|
|
|
|
|
|
:param name: Name of table that foreign keys are being defined for
|
|
|
|
|
|
:param foreign_keys: List of foreign keys, each of which can be a
|
Create tables with compound foreign keys, refs #594
create_table() and friends now accept compound foreign keys, specified
as lists of column names in the existing tuple forms:
foreign_keys=[
(["campus_name", "dept_code"], "departments"),
(["campus_name", "dept_code"], "departments", ["campus_name", "dept_code"]),
]
The two-item form guesses the compound primary key of the other table.
Compound keys are rendered as table-level FOREIGN KEY constraints,
while single-column keys keep their inline REFERENCES clauses.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 14:30:41 -07:00
|
|
|
|
string, a ForeignKey() object, a tuple of (column, other_table),
|
2023-08-17 17:48:08 -07:00
|
|
|
|
or a tuple of (column, other_table, other_column), or a tuple of
|
Create tables with compound foreign keys, refs #594
create_table() and friends now accept compound foreign keys, specified
as lists of column names in the existing tuple forms:
foreign_keys=[
(["campus_name", "dept_code"], "departments"),
(["campus_name", "dept_code"], "departments", ["campus_name", "dept_code"]),
]
The two-item form guesses the compound primary key of the other table.
Compound keys are rendered as table-level FOREIGN KEY constraints,
while single-column keys keep their inline REFERENCES clauses.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 14:30:41 -07:00
|
|
|
|
(table, column, other_table, other_column). For compound foreign
|
2026-07-05 15:10:21 -07:00
|
|
|
|
keys the column elements can be tuples of column names, e.g.
|
|
|
|
|
|
(("campus_name", "dept_code"), "departments") or
|
|
|
|
|
|
(("campus_name", "dept_code"), "departments", ("campus_name", "dept_code"))
|
2023-08-17 17:48:08 -07:00
|
|
|
|
"""
|
2025-05-08 23:19:36 -07:00
|
|
|
|
table = self.table(name)
|
2019-06-12 22:32:26 -07:00
|
|
|
|
fks = []
|
2026-07-06 21:37:56 -07:00
|
|
|
|
for fk in foreign_keys:
|
|
|
|
|
|
if isinstance(fk, ForeignKey):
|
|
|
|
|
|
fks.append(fk)
|
|
|
|
|
|
continue
|
|
|
|
|
|
if isinstance(fk, str):
|
|
|
|
|
|
# A bare column name - guess the other table and column
|
|
|
|
|
|
other_table = table.guess_foreign_table(fk)
|
|
|
|
|
|
other_column = table.guess_foreign_column(other_table)
|
|
|
|
|
|
fks.append(ForeignKey(name, fk, other_table, other_column))
|
|
|
|
|
|
continue
|
|
|
|
|
|
if not isinstance(fk, (tuple, list)):
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
"foreign_keys= should be a list of tuples, "
|
|
|
|
|
|
"ForeignKey objects or column name strings"
|
|
|
|
|
|
)
|
|
|
|
|
|
tuple_or_list = cast(Sequence[Any], fk)
|
2023-08-17 17:48:08 -07:00
|
|
|
|
if len(tuple_or_list) == 4:
|
2026-07-04 19:19:24 +00:00
|
|
|
|
if tuple_or_list[0] != name:
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
"First item in {} should have been {}".format(
|
|
|
|
|
|
tuple_or_list, name
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
Create tables with compound foreign keys, refs #594
create_table() and friends now accept compound foreign keys, specified
as lists of column names in the existing tuple forms:
foreign_keys=[
(["campus_name", "dept_code"], "departments"),
(["campus_name", "dept_code"], "departments", ["campus_name", "dept_code"]),
]
The two-item form guesses the compound primary key of the other table.
Compound keys are rendered as table-level FOREIGN KEY constraints,
while single-column keys keep their inline REFERENCES clauses.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 14:30:41 -07:00
|
|
|
|
tuple_or_list = tuple_or_list[1:]
|
|
|
|
|
|
if len(tuple_or_list) not in (2, 3):
|
2026-07-04 19:19:24 +00:00
|
|
|
|
raise ValueError(
|
|
|
|
|
|
"foreign_keys= should be a list of tuple pairs or triples"
|
|
|
|
|
|
)
|
Create tables with compound foreign keys, refs #594
create_table() and friends now accept compound foreign keys, specified
as lists of column names in the existing tuple forms:
foreign_keys=[
(["campus_name", "dept_code"], "departments"),
(["campus_name", "dept_code"], "departments", ["campus_name", "dept_code"]),
]
The two-item form guesses the compound primary key of the other table.
Compound keys are rendered as table-level FOREIGN KEY constraints,
while single-column keys keep their inline REFERENCES clauses.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 14:30:41 -07:00
|
|
|
|
column_or_columns = tuple_or_list[0]
|
|
|
|
|
|
other_table = tuple_or_list[1]
|
|
|
|
|
|
if isinstance(column_or_columns, (list, tuple)):
|
|
|
|
|
|
# Compound foreign key
|
2026-07-05 15:10:21 -07:00
|
|
|
|
columns = tuple(column_or_columns)
|
Create tables with compound foreign keys, refs #594
create_table() and friends now accept compound foreign keys, specified
as lists of column names in the existing tuple forms:
foreign_keys=[
(["campus_name", "dept_code"], "departments"),
(["campus_name", "dept_code"], "departments", ["campus_name", "dept_code"]),
]
The two-item form guesses the compound primary key of the other table.
Compound keys are rendered as table-level FOREIGN KEY constraints,
while single-column keys keep their inline REFERENCES clauses.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 14:30:41 -07:00
|
|
|
|
if len(tuple_or_list) == 3:
|
2026-07-05 15:10:21 -07:00
|
|
|
|
if not isinstance(tuple_or_list[2], (list, tuple)):
|
Create tables with compound foreign keys, refs #594
create_table() and friends now accept compound foreign keys, specified
as lists of column names in the existing tuple forms:
foreign_keys=[
(["campus_name", "dept_code"], "departments"),
(["campus_name", "dept_code"], "departments", ["campus_name", "dept_code"]),
]
The two-item form guesses the compound primary key of the other table.
Compound keys are rendered as table-level FOREIGN KEY constraints,
while single-column keys keep their inline REFERENCES clauses.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 14:30:41 -07:00
|
|
|
|
raise ValueError(
|
2026-07-05 15:10:21 -07:00
|
|
|
|
"Compound foreign key {} should reference a tuple "
|
Create tables with compound foreign keys, refs #594
create_table() and friends now accept compound foreign keys, specified
as lists of column names in the existing tuple forms:
foreign_keys=[
(["campus_name", "dept_code"], "departments"),
(["campus_name", "dept_code"], "departments", ["campus_name", "dept_code"]),
]
The two-item form guesses the compound primary key of the other table.
Compound keys are rendered as table-level FOREIGN KEY constraints,
while single-column keys keep their inline REFERENCES clauses.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 14:30:41 -07:00
|
|
|
|
"of other columns".format(tuple(tuple_or_list))
|
|
|
|
|
|
)
|
2026-07-05 15:10:21 -07:00
|
|
|
|
other_columns = tuple(tuple_or_list[2])
|
2023-08-17 17:48:08 -07:00
|
|
|
|
else:
|
Create tables with compound foreign keys, refs #594
create_table() and friends now accept compound foreign keys, specified
as lists of column names in the existing tuple forms:
foreign_keys=[
(["campus_name", "dept_code"], "departments"),
(["campus_name", "dept_code"], "departments", ["campus_name", "dept_code"]),
]
The two-item form guesses the compound primary key of the other table.
Compound keys are rendered as table-level FOREIGN KEY constraints,
while single-column keys keep their inline REFERENCES clauses.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 14:30:41 -07:00
|
|
|
|
# Guess the compound primary key of the other table
|
2026-07-05 15:10:21 -07:00
|
|
|
|
other_columns = tuple(self.table(other_table).pks)
|
Create tables with compound foreign keys, refs #594
create_table() and friends now accept compound foreign keys, specified
as lists of column names in the existing tuple forms:
foreign_keys=[
(["campus_name", "dept_code"], "departments"),
(["campus_name", "dept_code"], "departments", ["campus_name", "dept_code"]),
]
The two-item form guesses the compound primary key of the other table.
Compound keys are rendered as table-level FOREIGN KEY constraints,
while single-column keys keep their inline REFERENCES clauses.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 14:30:41 -07:00
|
|
|
|
if len(columns) != len(other_columns):
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
"Compound foreign key {} should have the same number "
|
|
|
|
|
|
"of columns on both sides".format(tuple(tuple_or_list))
|
|
|
|
|
|
)
|
|
|
|
|
|
if len(columns) == 1:
|
|
|
|
|
|
# Single-column key passed as a one-item list
|
|
|
|
|
|
fks.append(
|
|
|
|
|
|
ForeignKey(name, columns[0], other_table, other_columns[0])
|
2019-06-12 22:32:26 -07:00
|
|
|
|
)
|
Create tables with compound foreign keys, refs #594
create_table() and friends now accept compound foreign keys, specified
as lists of column names in the existing tuple forms:
foreign_keys=[
(["campus_name", "dept_code"], "departments"),
(["campus_name", "dept_code"], "departments", ["campus_name", "dept_code"]),
]
The two-item form guesses the compound primary key of the other table.
Compound keys are rendered as table-level FOREIGN KEY constraints,
while single-column keys keep their inline REFERENCES clauses.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 14:30:41 -07:00
|
|
|
|
else:
|
|
|
|
|
|
fks.append(
|
|
|
|
|
|
ForeignKey(
|
|
|
|
|
|
name,
|
|
|
|
|
|
None,
|
|
|
|
|
|
other_table,
|
|
|
|
|
|
None,
|
|
|
|
|
|
columns=columns,
|
|
|
|
|
|
other_columns=other_columns,
|
|
|
|
|
|
is_compound=True,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
elif len(tuple_or_list) == 3:
|
|
|
|
|
|
fks.append(
|
|
|
|
|
|
ForeignKey(name, column_or_columns, other_table, tuple_or_list[2])
|
2019-06-12 22:32:26 -07:00
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
# Guess the primary key
|
|
|
|
|
|
fks.append(
|
|
|
|
|
|
ForeignKey(
|
|
|
|
|
|
name,
|
Create tables with compound foreign keys, refs #594
create_table() and friends now accept compound foreign keys, specified
as lists of column names in the existing tuple forms:
foreign_keys=[
(["campus_name", "dept_code"], "departments"),
(["campus_name", "dept_code"], "departments", ["campus_name", "dept_code"]),
]
The two-item form guesses the compound primary key of the other table.
Compound keys are rendered as table-level FOREIGN KEY constraints,
while single-column keys keep their inline REFERENCES clauses.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 14:30:41 -07:00
|
|
|
|
column_or_columns,
|
|
|
|
|
|
other_table,
|
|
|
|
|
|
table.guess_foreign_column(other_table),
|
2019-06-12 22:32:26 -07:00
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
return fks
|
|
|
|
|
|
|
2026-07-05 22:11:22 -07:00
|
|
|
|
def _resolve_foreign_key_casing(
|
|
|
|
|
|
self, fk: ForeignKey, columns: Iterable[str]
|
|
|
|
|
|
) -> ForeignKey:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Return ``fk`` with its column references resolved to match the casing
|
|
|
|
|
|
of the actual columns. ``columns`` provides the column names of
|
|
|
|
|
|
``fk.table``, which may be a table that is still being created.
|
|
|
|
|
|
"""
|
|
|
|
|
|
resolved_columns = tuple(resolve_casing(c, columns) for c in fk.columns)
|
|
|
|
|
|
if fk.other_table == fk.table:
|
|
|
|
|
|
other_candidates: Iterable[str] = columns
|
|
|
|
|
|
else:
|
|
|
|
|
|
other_candidates = self[fk.other_table].columns_dict
|
|
|
|
|
|
resolved_other_columns = tuple(
|
|
|
|
|
|
resolve_casing(c, other_candidates) for c in fk.other_columns
|
|
|
|
|
|
)
|
|
|
|
|
|
if (
|
|
|
|
|
|
resolved_columns == fk.columns
|
|
|
|
|
|
and resolved_other_columns == fk.other_columns
|
|
|
|
|
|
):
|
|
|
|
|
|
return fk
|
|
|
|
|
|
if fk.is_compound:
|
|
|
|
|
|
return ForeignKey(
|
|
|
|
|
|
fk.table,
|
|
|
|
|
|
None,
|
|
|
|
|
|
fk.other_table,
|
|
|
|
|
|
None,
|
|
|
|
|
|
columns=resolved_columns,
|
|
|
|
|
|
other_columns=resolved_other_columns,
|
|
|
|
|
|
is_compound=True,
|
|
|
|
|
|
on_delete=fk.on_delete,
|
|
|
|
|
|
on_update=fk.on_update,
|
|
|
|
|
|
)
|
|
|
|
|
|
return ForeignKey(
|
|
|
|
|
|
fk.table,
|
|
|
|
|
|
resolved_columns[0],
|
|
|
|
|
|
fk.other_table,
|
|
|
|
|
|
resolved_other_columns[0],
|
|
|
|
|
|
on_delete=fk.on_delete,
|
|
|
|
|
|
on_update=fk.on_update,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2020-09-21 21:20:01 -07:00
|
|
|
|
def create_table_sql(
|
2019-06-12 23:10:07 -07:00
|
|
|
|
self,
|
2021-08-10 16:09:28 -07:00
|
|
|
|
name: str,
|
|
|
|
|
|
columns: Dict[str, Any],
|
|
|
|
|
|
pk: Optional[Any] = None,
|
2021-08-18 15:25:18 -07:00
|
|
|
|
foreign_keys: Optional[ForeignKeysType] = None,
|
|
|
|
|
|
column_order: Optional[List[str]] = None,
|
2022-03-01 16:00:51 -08:00
|
|
|
|
not_null: Optional[Iterable[str]] = None,
|
2021-08-18 15:25:18 -07:00
|
|
|
|
defaults: Optional[Dict[str, Any]] = None,
|
2021-11-14 18:19:28 -08:00
|
|
|
|
hash_id: Optional[str] = None,
|
2022-03-01 16:00:51 -08:00
|
|
|
|
hash_id_columns: Optional[Iterable[str]] = None,
|
2021-08-18 15:25:18 -07:00
|
|
|
|
extracts: Optional[Union[Dict[str, str], List[str]]] = None,
|
2022-02-05 17:28:53 -08:00
|
|
|
|
if_not_exists: bool = False,
|
2023-12-07 21:05:27 -08:00
|
|
|
|
strict: bool = False,
|
2021-08-10 16:09:28 -07:00
|
|
|
|
) -> str:
|
2022-03-11 09:38:34 -08:00
|
|
|
|
"""
|
|
|
|
|
|
Returns the SQL ``CREATE TABLE`` statement for creating the specified table.
|
|
|
|
|
|
|
|
|
|
|
|
:param name: Name of table
|
|
|
|
|
|
:param columns: Dictionary mapping column names to their types, for example ``{"name": str, "age": int}``
|
|
|
|
|
|
:param pk: String name of column to use as a primary key, or a tuple of strings for a compound primary key covering multiple columns
|
|
|
|
|
|
:param foreign_keys: List of foreign key definitions for this table
|
|
|
|
|
|
:param column_order: List specifying which columns should come first
|
|
|
|
|
|
:param not_null: List of columns that should be created as ``NOT NULL``
|
|
|
|
|
|
:param defaults: Dictionary specifying default values for columns
|
|
|
|
|
|
:param hash_id: Name of column to be used as a primary key containing a hash of the other columns
|
|
|
|
|
|
:param hash_id_columns: List of columns to be used when calculating the hash ID for a row
|
|
|
|
|
|
:param extracts: List or dictionary of columns to be extracted during inserts, see :ref:`python_api_extracts`
|
|
|
|
|
|
:param if_not_exists: Use ``CREATE TABLE IF NOT EXISTS``
|
2023-12-07 21:05:27 -08:00
|
|
|
|
:param strict: Apply STRICT mode to table
|
2022-03-11 09:38:34 -08:00
|
|
|
|
"""
|
2022-03-01 16:00:51 -08:00
|
|
|
|
if hash_id_columns and (hash_id is None):
|
|
|
|
|
|
hash_id = "id"
|
2026-07-05 22:11:22 -07:00
|
|
|
|
resolved_fks: List[ForeignKey] = [
|
|
|
|
|
|
self._resolve_foreign_key_casing(fk, columns)
|
|
|
|
|
|
for fk in self.resolve_foreign_keys(name, foreign_keys or [])
|
|
|
|
|
|
]
|
Create tables with compound foreign keys, refs #594
create_table() and friends now accept compound foreign keys, specified
as lists of column names in the existing tuple forms:
foreign_keys=[
(["campus_name", "dept_code"], "departments"),
(["campus_name", "dept_code"], "departments", ["campus_name", "dept_code"]),
]
The two-item form guesses the compound primary key of the other table.
Compound keys are rendered as table-level FOREIGN KEY constraints,
while single-column keys keep their inline REFERENCES clauses.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 14:30:41 -07:00
|
|
|
|
# Compound foreign keys are rendered as table-level constraints;
|
|
|
|
|
|
# single-column ones as inline REFERENCES on their column
|
|
|
|
|
|
foreign_keys_by_column = {
|
2026-07-05 22:11:22 -07:00
|
|
|
|
fk.column: fk for fk in resolved_fks if not fk.is_compound
|
Create tables with compound foreign keys, refs #594
create_table() and friends now accept compound foreign keys, specified
as lists of column names in the existing tuple forms:
foreign_keys=[
(["campus_name", "dept_code"], "departments"),
(["campus_name", "dept_code"], "departments", ["campus_name", "dept_code"]),
]
The two-item form guesses the compound primary key of the other table.
Compound keys are rendered as table-level FOREIGN KEY constraints,
while single-column keys keep their inline REFERENCES clauses.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 14:30:41 -07:00
|
|
|
|
}
|
2019-07-23 10:00:42 -07:00
|
|
|
|
# any extracts will be treated as integer columns with a foreign key
|
|
|
|
|
|
extracts = resolve_extracts(extracts)
|
|
|
|
|
|
for extract_column, extract_table in extracts.items():
|
2020-10-16 12:14:22 -07:00
|
|
|
|
if isinstance(extract_column, tuple):
|
|
|
|
|
|
assert False
|
2019-07-23 10:00:42 -07:00
|
|
|
|
# Ensure other table exists
|
2020-02-08 15:56:03 -08:00
|
|
|
|
if not self[extract_table].exists():
|
2019-07-23 10:00:42 -07:00
|
|
|
|
self.create_table(extract_table, {"id": int, "value": str}, pk="id")
|
|
|
|
|
|
columns[extract_column] = int
|
|
|
|
|
|
foreign_keys_by_column[extract_column] = ForeignKey(
|
|
|
|
|
|
name, extract_column, extract_table, "id"
|
|
|
|
|
|
)
|
2020-09-08 15:24:39 -07:00
|
|
|
|
# Soundness check not_null, and defaults if provided
|
2026-07-05 22:11:22 -07:00
|
|
|
|
not_null = {resolve_casing(n, columns) for n in not_null or set()}
|
|
|
|
|
|
defaults = {resolve_casing(n, columns): v for n, v in (defaults or {}).items()}
|
|
|
|
|
|
if column_order is not None:
|
|
|
|
|
|
column_order = [resolve_casing(c, columns) for c in column_order]
|
2026-07-04 19:19:24 +00:00
|
|
|
|
if not columns:
|
|
|
|
|
|
raise ValueError("Tables must have at least one column")
|
|
|
|
|
|
if not all(n in columns for n in not_null):
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
"not_null set {} includes items not in columns {}".format(
|
|
|
|
|
|
repr(not_null), repr(set(columns.keys()))
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
if not all(n in columns for n in defaults):
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
"defaults set {} includes items not in columns {}".format(
|
|
|
|
|
|
repr(set(defaults)), repr(set(columns.keys()))
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
2018-08-08 16:06:49 -07:00
|
|
|
|
column_items = list(columns.items())
|
|
|
|
|
|
if column_order is not None:
|
2021-08-18 15:25:18 -07:00
|
|
|
|
|
|
|
|
|
|
def sort_key(p):
|
|
|
|
|
|
return column_order.index(p[0]) if p[0] in column_order else 999
|
|
|
|
|
|
|
|
|
|
|
|
column_items.sort(key=sort_key)
|
2019-02-23 20:36:40 -08:00
|
|
|
|
if hash_id:
|
|
|
|
|
|
column_items.insert(0, (hash_id, str))
|
|
|
|
|
|
pk = hash_id
|
2020-09-08 15:24:39 -07:00
|
|
|
|
# Soundness check foreign_keys point to existing tables
|
2026-07-05 22:11:22 -07:00
|
|
|
|
for fk in resolved_fks:
|
Create tables with compound foreign keys, refs #594
create_table() and friends now accept compound foreign keys, specified
as lists of column names in the existing tuple forms:
foreign_keys=[
(["campus_name", "dept_code"], "departments"),
(["campus_name", "dept_code"], "departments", ["campus_name", "dept_code"]),
]
The two-item form guesses the compound primary key of the other table.
Compound keys are rendered as table-level FOREIGN KEY constraints,
while single-column keys keep their inline REFERENCES clauses.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 14:30:41 -07:00
|
|
|
|
for other_column in fk.other_columns:
|
|
|
|
|
|
if fk.other_table == name and columns.get(other_column):
|
|
|
|
|
|
continue
|
|
|
|
|
|
if other_column != "rowid" and not any(
|
|
|
|
|
|
c for c in self[fk.other_table].columns if c.name == other_column
|
|
|
|
|
|
):
|
|
|
|
|
|
raise AlterError(
|
|
|
|
|
|
"No such column: {}.{}".format(fk.other_table, other_column)
|
|
|
|
|
|
)
|
2019-07-14 21:28:51 -07:00
|
|
|
|
|
2019-06-12 23:10:07 -07:00
|
|
|
|
column_defs = []
|
2019-07-14 21:28:51 -07:00
|
|
|
|
# ensure pk is a tuple
|
|
|
|
|
|
single_pk = None
|
2026-07-05 22:11:22 -07:00
|
|
|
|
if isinstance(pk, (list, tuple)) and len(pk) == 1 and isinstance(pk[0], str):
|
2020-10-14 14:59:38 -07:00
|
|
|
|
pk = pk[0]
|
2019-07-14 21:28:51 -07:00
|
|
|
|
if isinstance(pk, str):
|
2026-07-05 22:11:22 -07:00
|
|
|
|
single_pk = pk = resolve_casing(pk, [c[0] for c in column_items])
|
2019-07-23 06:06:59 -07:00
|
|
|
|
if pk not in [c[0] for c in column_items]:
|
|
|
|
|
|
column_items.insert(0, (pk, int))
|
2026-07-05 22:11:22 -07:00
|
|
|
|
elif pk:
|
|
|
|
|
|
pk = [resolve_casing(p, [c[0] for c in column_items]) for p in pk]
|
2019-06-12 23:10:07 -07:00
|
|
|
|
for column_name, column_type in column_items:
|
|
|
|
|
|
column_extras = []
|
2019-07-14 21:28:51 -07:00
|
|
|
|
if column_name == single_pk:
|
2019-06-12 23:10:07 -07:00
|
|
|
|
column_extras.append("PRIMARY KEY")
|
|
|
|
|
|
if column_name in not_null:
|
|
|
|
|
|
column_extras.append("NOT NULL")
|
2020-09-21 21:20:01 -07:00
|
|
|
|
if column_name in defaults and defaults[column_name] is not None:
|
2019-06-12 23:10:07 -07:00
|
|
|
|
column_extras.append(
|
2023-05-09 06:13:36 +09:00
|
|
|
|
"DEFAULT {}".format(self.quote_default_value(defaults[column_name]))
|
2019-06-12 23:10:07 -07:00
|
|
|
|
)
|
|
|
|
|
|
if column_name in foreign_keys_by_column:
|
2026-07-05 14:40:57 -07:00
|
|
|
|
fk = foreign_keys_by_column[column_name]
|
2019-06-12 23:10:07 -07:00
|
|
|
|
column_extras.append(
|
2026-07-05 14:40:57 -07:00
|
|
|
|
"REFERENCES {}({}){}".format(
|
|
|
|
|
|
quote_identifier(fk.other_table),
|
|
|
|
|
|
quote_identifier(cast(str, fk.other_column)),
|
|
|
|
|
|
_fk_actions_sql(fk),
|
2018-07-28 15:06:59 -07:00
|
|
|
|
)
|
2019-06-12 23:10:07 -07:00
|
|
|
|
)
|
2024-11-23 14:27:21 -08:00
|
|
|
|
column_type_str = COLUMN_TYPE_MAPPING[column_type]
|
|
|
|
|
|
# Special case for strict tables to map FLOAT to REAL
|
|
|
|
|
|
# Refs https://github.com/simonw/sqlite-utils/issues/644
|
|
|
|
|
|
if strict and column_type_str == "FLOAT":
|
|
|
|
|
|
column_type_str = "REAL"
|
2019-06-12 23:10:07 -07:00
|
|
|
|
column_defs.append(
|
2025-11-23 20:43:26 -08:00
|
|
|
|
" {} {column_type}{column_extras}".format(
|
|
|
|
|
|
quote_identifier(column_name),
|
2024-11-23 14:27:21 -08:00
|
|
|
|
column_type=column_type_str,
|
2024-01-30 18:18:52 -08:00
|
|
|
|
column_extras=(
|
|
|
|
|
|
(" " + " ".join(column_extras)) if column_extras else ""
|
|
|
|
|
|
),
|
2019-06-12 23:10:07 -07:00
|
|
|
|
)
|
2018-07-28 15:06:59 -07:00
|
|
|
|
)
|
2019-07-14 21:28:51 -07:00
|
|
|
|
extra_pk = ""
|
|
|
|
|
|
if single_pk is None and pk and len(pk) > 1:
|
|
|
|
|
|
extra_pk = ",\n PRIMARY KEY ({pks})".format(
|
2025-11-23 20:43:26 -08:00
|
|
|
|
pks=", ".join([quote_identifier(p) for p in pk])
|
2019-07-14 21:28:51 -07:00
|
|
|
|
)
|
Create tables with compound foreign keys, refs #594
create_table() and friends now accept compound foreign keys, specified
as lists of column names in the existing tuple forms:
foreign_keys=[
(["campus_name", "dept_code"], "departments"),
(["campus_name", "dept_code"], "departments", ["campus_name", "dept_code"]),
]
The two-item form guesses the compound primary key of the other table.
Compound keys are rendered as table-level FOREIGN KEY constraints,
while single-column keys keep their inline REFERENCES clauses.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 14:30:41 -07:00
|
|
|
|
# Compound foreign keys become table-level FOREIGN KEY constraints
|
|
|
|
|
|
column_names = [c[0] for c in column_items]
|
2026-07-05 22:11:22 -07:00
|
|
|
|
for fk in resolved_fks:
|
Create tables with compound foreign keys, refs #594
create_table() and friends now accept compound foreign keys, specified
as lists of column names in the existing tuple forms:
foreign_keys=[
(["campus_name", "dept_code"], "departments"),
(["campus_name", "dept_code"], "departments", ["campus_name", "dept_code"]),
]
The two-item form guesses the compound primary key of the other table.
Compound keys are rendered as table-level FOREIGN KEY constraints,
while single-column keys keep their inline REFERENCES clauses.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 14:30:41 -07:00
|
|
|
|
if not fk.is_compound:
|
|
|
|
|
|
continue
|
|
|
|
|
|
missing = [c for c in fk.columns if c not in column_names]
|
|
|
|
|
|
if missing:
|
|
|
|
|
|
raise AlterError(
|
|
|
|
|
|
"No such column: {}".format(", ".join(sorted(missing)))
|
|
|
|
|
|
)
|
|
|
|
|
|
column_defs.append(
|
2026-07-05 14:40:57 -07:00
|
|
|
|
" FOREIGN KEY ({columns}) REFERENCES {other_table}({other_columns}){actions}".format(
|
Create tables with compound foreign keys, refs #594
create_table() and friends now accept compound foreign keys, specified
as lists of column names in the existing tuple forms:
foreign_keys=[
(["campus_name", "dept_code"], "departments"),
(["campus_name", "dept_code"], "departments", ["campus_name", "dept_code"]),
]
The two-item form guesses the compound primary key of the other table.
Compound keys are rendered as table-level FOREIGN KEY constraints,
while single-column keys keep their inline REFERENCES clauses.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 14:30:41 -07:00
|
|
|
|
columns=", ".join(quote_identifier(c) for c in fk.columns),
|
|
|
|
|
|
other_table=quote_identifier(fk.other_table),
|
|
|
|
|
|
other_columns=", ".join(
|
|
|
|
|
|
quote_identifier(c) for c in fk.other_columns
|
|
|
|
|
|
),
|
2026-07-05 14:40:57 -07:00
|
|
|
|
actions=_fk_actions_sql(fk),
|
Create tables with compound foreign keys, refs #594
create_table() and friends now accept compound foreign keys, specified
as lists of column names in the existing tuple forms:
foreign_keys=[
(["campus_name", "dept_code"], "departments"),
(["campus_name", "dept_code"], "departments", ["campus_name", "dept_code"]),
]
The two-item form guesses the compound primary key of the other table.
Compound keys are rendered as table-level FOREIGN KEY constraints,
while single-column keys keep their inline REFERENCES clauses.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 14:30:41 -07:00
|
|
|
|
)
|
|
|
|
|
|
)
|
2019-06-12 23:10:07 -07:00
|
|
|
|
columns_sql = ",\n".join(column_defs)
|
2025-11-23 20:43:26 -08:00
|
|
|
|
sql = """CREATE TABLE {if_not_exists}{table} (
|
2019-07-14 21:28:51 -07:00
|
|
|
|
{columns_sql}{extra_pk}
|
2023-12-07 21:05:27 -08:00
|
|
|
|
){strict};
|
2018-07-28 06:43:18 -07:00
|
|
|
|
""".format(
|
2022-02-05 17:28:53 -08:00
|
|
|
|
if_not_exists="IF NOT EXISTS " if if_not_exists else "",
|
2025-11-23 20:43:26 -08:00
|
|
|
|
table=quote_identifier(name),
|
2022-02-05 17:28:53 -08:00
|
|
|
|
columns_sql=columns_sql,
|
|
|
|
|
|
extra_pk=extra_pk,
|
2023-12-07 21:05:27 -08:00
|
|
|
|
strict=" STRICT" if strict and self.supports_strict else "",
|
2018-07-28 06:43:18 -07:00
|
|
|
|
)
|
2020-09-21 21:20:01 -07:00
|
|
|
|
return sql
|
|
|
|
|
|
|
|
|
|
|
|
def create_table(
|
|
|
|
|
|
self,
|
2021-08-10 16:09:28 -07:00
|
|
|
|
name: str,
|
|
|
|
|
|
columns: Dict[str, Any],
|
|
|
|
|
|
pk: Optional[Any] = None,
|
2021-08-18 15:25:18 -07:00
|
|
|
|
foreign_keys: Optional[ForeignKeysType] = None,
|
|
|
|
|
|
column_order: Optional[List[str]] = None,
|
2022-11-15 22:25:26 -08:00
|
|
|
|
not_null: Optional[Iterable[str]] = None,
|
2021-08-18 15:25:18 -07:00
|
|
|
|
defaults: Optional[Dict[str, Any]] = None,
|
2021-11-14 18:19:28 -08:00
|
|
|
|
hash_id: Optional[str] = None,
|
2022-03-01 16:00:51 -08:00
|
|
|
|
hash_id_columns: Optional[Iterable[str]] = None,
|
2021-08-18 15:25:18 -07:00
|
|
|
|
extracts: Optional[Union[Dict[str, str], List[str]]] = None,
|
2022-02-05 17:28:53 -08:00
|
|
|
|
if_not_exists: bool = False,
|
2023-07-22 12:15:40 -07:00
|
|
|
|
replace: bool = False,
|
|
|
|
|
|
ignore: bool = False,
|
2022-08-27 16:17:55 -07:00
|
|
|
|
transform: bool = False,
|
2023-12-07 21:05:27 -08:00
|
|
|
|
strict: bool = False,
|
2021-08-10 16:09:28 -07:00
|
|
|
|
) -> "Table":
|
|
|
|
|
|
"""
|
|
|
|
|
|
Create a table with the specified name and the specified ``{column_name: type}`` columns.
|
|
|
|
|
|
|
|
|
|
|
|
See :ref:`python_api_explicit_create`.
|
2022-03-11 09:38:34 -08:00
|
|
|
|
|
|
|
|
|
|
:param name: Name of table
|
|
|
|
|
|
:param columns: Dictionary mapping column names to their types, for example ``{"name": str, "age": int}``
|
|
|
|
|
|
:param pk: String name of column to use as a primary key, or a tuple of strings for a compound primary key covering multiple columns
|
|
|
|
|
|
:param foreign_keys: List of foreign key definitions for this table
|
|
|
|
|
|
:param column_order: List specifying which columns should come first
|
|
|
|
|
|
:param not_null: List of columns that should be created as ``NOT NULL``
|
|
|
|
|
|
:param defaults: Dictionary specifying default values for columns
|
|
|
|
|
|
:param hash_id: Name of column to be used as a primary key containing a hash of the other columns
|
|
|
|
|
|
:param hash_id_columns: List of columns to be used when calculating the hash ID for a row
|
|
|
|
|
|
:param extracts: List or dictionary of columns to be extracted during inserts, see :ref:`python_api_extracts`
|
|
|
|
|
|
:param if_not_exists: Use ``CREATE TABLE IF NOT EXISTS``
|
2023-07-22 12:15:40 -07:00
|
|
|
|
:param replace: Drop and replace table if it already exists
|
|
|
|
|
|
:param ignore: Silently do nothing if table already exists
|
2022-08-27 16:17:55 -07:00
|
|
|
|
:param transform: If table already exists transform it to fit the specified schema
|
2023-12-07 21:05:27 -08:00
|
|
|
|
:param strict: Apply STRICT mode to table
|
2022-08-27 16:17:55 -07:00
|
|
|
|
"""
|
|
|
|
|
|
# Transform table to match the new definition if table already exists:
|
2023-07-22 12:15:40 -07:00
|
|
|
|
if self[name].exists():
|
|
|
|
|
|
if ignore:
|
2025-05-08 23:19:36 -07:00
|
|
|
|
return self.table(name)
|
2023-07-22 12:15:40 -07:00
|
|
|
|
elif replace:
|
|
|
|
|
|
self[name].drop()
|
2022-08-27 16:17:55 -07:00
|
|
|
|
if transform and self[name].exists():
|
2025-05-08 23:19:36 -07:00
|
|
|
|
table = self.table(name)
|
2022-08-27 16:17:55 -07:00
|
|
|
|
should_transform = False
|
|
|
|
|
|
# First add missing columns and figure out columns to drop
|
|
|
|
|
|
existing_columns = table.columns_dict
|
2026-07-05 22:11:22 -07:00
|
|
|
|
# Match existing columns case-insensitively, the way SQLite does
|
|
|
|
|
|
columns = {
|
|
|
|
|
|
resolve_casing(col_name, existing_columns): col_type
|
|
|
|
|
|
for col_name, col_type in columns.items()
|
|
|
|
|
|
}
|
2022-08-27 16:17:55 -07:00
|
|
|
|
missing_columns = dict(
|
|
|
|
|
|
(col_name, col_type)
|
|
|
|
|
|
for col_name, col_type in columns.items()
|
|
|
|
|
|
if col_name not in existing_columns
|
|
|
|
|
|
)
|
|
|
|
|
|
columns_to_drop = [
|
|
|
|
|
|
column for column in existing_columns if column not in columns
|
|
|
|
|
|
]
|
|
|
|
|
|
if missing_columns:
|
|
|
|
|
|
for col_name, col_type in missing_columns.items():
|
|
|
|
|
|
table.add_column(col_name, col_type)
|
|
|
|
|
|
if missing_columns or columns_to_drop or columns != existing_columns:
|
|
|
|
|
|
should_transform = True
|
|
|
|
|
|
# Do we need to change the column order?
|
|
|
|
|
|
if (
|
|
|
|
|
|
column_order
|
|
|
|
|
|
and list(existing_columns)[: len(column_order)] != column_order
|
|
|
|
|
|
):
|
|
|
|
|
|
should_transform = True
|
|
|
|
|
|
# Has the primary key changed?
|
|
|
|
|
|
current_pks = table.pks
|
|
|
|
|
|
desired_pk = None
|
|
|
|
|
|
if isinstance(pk, str):
|
2026-07-05 22:11:22 -07:00
|
|
|
|
desired_pk = [resolve_casing(pk, existing_columns)]
|
2022-08-27 16:17:55 -07:00
|
|
|
|
elif pk:
|
2026-07-05 22:11:22 -07:00
|
|
|
|
desired_pk = [resolve_casing(p, existing_columns) for p in pk]
|
2022-08-27 16:17:55 -07:00
|
|
|
|
if desired_pk and current_pks != desired_pk:
|
|
|
|
|
|
should_transform = True
|
|
|
|
|
|
# Any not-null changes?
|
|
|
|
|
|
current_not_null = {c.name for c in table.columns if c.notnull}
|
2026-07-05 22:11:22 -07:00
|
|
|
|
desired_not_null = (
|
|
|
|
|
|
{resolve_casing(n, existing_columns) for n in not_null}
|
|
|
|
|
|
if not_null
|
|
|
|
|
|
else set()
|
|
|
|
|
|
)
|
2022-08-27 16:17:55 -07:00
|
|
|
|
if current_not_null != desired_not_null:
|
|
|
|
|
|
should_transform = True
|
|
|
|
|
|
# How about defaults?
|
2026-07-05 22:11:22 -07:00
|
|
|
|
if (
|
|
|
|
|
|
defaults
|
|
|
|
|
|
and {
|
|
|
|
|
|
resolve_casing(c, existing_columns): v for c, v in defaults.items()
|
|
|
|
|
|
}
|
|
|
|
|
|
!= table.default_values
|
|
|
|
|
|
):
|
2022-08-27 16:17:55 -07:00
|
|
|
|
should_transform = True
|
|
|
|
|
|
# Only run .transform() if there is something to do
|
|
|
|
|
|
if should_transform:
|
|
|
|
|
|
table.transform(
|
|
|
|
|
|
types=columns,
|
|
|
|
|
|
drop=columns_to_drop,
|
|
|
|
|
|
column_order=column_order,
|
|
|
|
|
|
not_null=not_null,
|
|
|
|
|
|
defaults=defaults,
|
|
|
|
|
|
pk=pk,
|
|
|
|
|
|
)
|
|
|
|
|
|
return table
|
2020-09-21 21:20:01 -07:00
|
|
|
|
sql = self.create_table_sql(
|
|
|
|
|
|
name=name,
|
|
|
|
|
|
columns=columns,
|
|
|
|
|
|
pk=pk,
|
|
|
|
|
|
foreign_keys=foreign_keys,
|
|
|
|
|
|
column_order=column_order,
|
|
|
|
|
|
not_null=not_null,
|
|
|
|
|
|
defaults=defaults,
|
|
|
|
|
|
hash_id=hash_id,
|
2022-03-01 16:00:51 -08:00
|
|
|
|
hash_id_columns=hash_id_columns,
|
2020-09-21 21:20:01 -07:00
|
|
|
|
extracts=extracts,
|
2022-02-05 17:28:53 -08:00
|
|
|
|
if_not_exists=if_not_exists,
|
2023-12-07 21:05:27 -08:00
|
|
|
|
strict=strict,
|
2020-09-21 21:20:01 -07:00
|
|
|
|
)
|
2020-09-07 14:56:59 -07:00
|
|
|
|
self.execute(sql)
|
2025-05-08 23:19:36 -07:00
|
|
|
|
return self.table(
|
2019-07-23 09:47:19 +02:00
|
|
|
|
name,
|
|
|
|
|
|
pk=pk,
|
|
|
|
|
|
foreign_keys=foreign_keys,
|
|
|
|
|
|
column_order=column_order,
|
|
|
|
|
|
not_null=not_null,
|
|
|
|
|
|
defaults=defaults,
|
|
|
|
|
|
hash_id=hash_id,
|
2022-03-01 16:00:51 -08:00
|
|
|
|
hash_id_columns=hash_id_columns,
|
2019-07-23 09:47:19 +02:00
|
|
|
|
)
|
2018-07-28 06:43:18 -07:00
|
|
|
|
|
More type annotations (#697)
* Add comprehensive type annotations
- mypy.ini: expanded configuration with module-specific settings
- hookspecs.py: type annotations for hook functions
- plugins.py: typed get_plugins() return value
- recipes.py: full type annotations for parsedate, parsedatetime, jsonsplit
- utils.py: extensive type annotations including Row type alias,
TypeTracker, ValueTracker, and all utility functions
- db.py: type annotations for Database methods (__exit__,
ensure_autocommit_off, tracer, register_function, etc.) and
Queryable class methods
- tests/test_docs.py: updated to match new signature display format
* Fix type errors caught by ty check
- Add type: ignore comments for external library type stub limitations
(csv.reader, click.progressbar, IOBase.name, Callable.__name__)
- Change Iterable to Sequence for SQL where_args parameters
- Use db.table() instead of db[name] for proper Table return type
- Fix rebuild_fts return type from None to Table
- Update test_tracer to expect fewer queries (optimization side effect)
* Fix mypy type errors
- Add type: ignore comments for runtime-valid patterns mypy can't verify
- Fix new_column_types annotation to Dict[str, Set[type]]
- Add type: ignore for Default sentinel values passed to create_table
* mypy skip tests directory
* Fix CI: exclude typing imports from recipe docs, skip mypy on tests
- Add Callable and Optional to exclusion list in _generate_convert_help()
- Regenerate docs/cli-reference.rst with cog
- Add [mypy-tests.*] ignore_errors = True to skip test type errors
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 22:11:47 -08:00
|
|
|
|
def rename_table(self, name: str, new_name: str) -> None:
|
2023-07-22 12:38:31 -07:00
|
|
|
|
"""
|
|
|
|
|
|
Rename a table.
|
|
|
|
|
|
|
|
|
|
|
|
:param name: Current table name
|
|
|
|
|
|
:param new_name: Name to rename it to
|
|
|
|
|
|
"""
|
|
|
|
|
|
self.execute(
|
2025-11-23 20:43:26 -08:00
|
|
|
|
"ALTER TABLE {} RENAME TO {}".format(
|
|
|
|
|
|
quote_identifier(name), quote_identifier(new_name)
|
2023-07-22 12:38:31 -07:00
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2021-08-10 16:09:28 -07:00
|
|
|
|
def create_view(
|
|
|
|
|
|
self, name: str, sql: str, ignore: bool = False, replace: bool = False
|
More type annotations (#697)
* Add comprehensive type annotations
- mypy.ini: expanded configuration with module-specific settings
- hookspecs.py: type annotations for hook functions
- plugins.py: typed get_plugins() return value
- recipes.py: full type annotations for parsedate, parsedatetime, jsonsplit
- utils.py: extensive type annotations including Row type alias,
TypeTracker, ValueTracker, and all utility functions
- db.py: type annotations for Database methods (__exit__,
ensure_autocommit_off, tracer, register_function, etc.) and
Queryable class methods
- tests/test_docs.py: updated to match new signature display format
* Fix type errors caught by ty check
- Add type: ignore comments for external library type stub limitations
(csv.reader, click.progressbar, IOBase.name, Callable.__name__)
- Change Iterable to Sequence for SQL where_args parameters
- Use db.table() instead of db[name] for proper Table return type
- Fix rebuild_fts return type from None to Table
- Update test_tracer to expect fewer queries (optimization side effect)
* Fix mypy type errors
- Add type: ignore comments for runtime-valid patterns mypy can't verify
- Fix new_column_types annotation to Dict[str, Set[type]]
- Add type: ignore for Default sentinel values passed to create_table
* mypy skip tests directory
* Fix CI: exclude typing imports from recipe docs, skip mypy on tests
- Add Callable and Optional to exclusion list in _generate_convert_help()
- Regenerate docs/cli-reference.rst with cog
- Add [mypy-tests.*] ignore_errors = True to skip test type errors
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 22:11:47 -08:00
|
|
|
|
) -> "Database":
|
2021-08-10 16:09:28 -07:00
|
|
|
|
"""
|
|
|
|
|
|
Create a new SQL view with the specified name - ``sql`` should start with ``SELECT ...``.
|
|
|
|
|
|
|
2022-03-11 09:38:34 -08:00
|
|
|
|
:param name: Name of the view
|
|
|
|
|
|
:param sql: SQL ``SELECT`` query to use for this view.
|
|
|
|
|
|
:param ignore: Set to ``True`` to do nothing if a view with this name already exists
|
|
|
|
|
|
:param replace: Set to ``True`` to replace the view if one with this name already exists
|
2021-08-10 16:09:28 -07:00
|
|
|
|
"""
|
2026-07-04 19:19:24 +00:00
|
|
|
|
if ignore and replace:
|
|
|
|
|
|
raise ValueError("Use one or the other of ignore/replace, not both")
|
2025-11-23 20:43:26 -08:00
|
|
|
|
create_sql = "CREATE VIEW {name} AS {sql}".format(
|
|
|
|
|
|
name=quote_identifier(name), sql=sql
|
|
|
|
|
|
)
|
2020-05-02 09:02:04 -07:00
|
|
|
|
if ignore or replace:
|
|
|
|
|
|
# Does view exist already?
|
|
|
|
|
|
if name in self.view_names():
|
|
|
|
|
|
if ignore:
|
|
|
|
|
|
return self
|
|
|
|
|
|
elif replace:
|
|
|
|
|
|
# If SQL is the same, do nothing
|
|
|
|
|
|
if create_sql == self[name].schema:
|
|
|
|
|
|
return self
|
|
|
|
|
|
self[name].drop()
|
2020-09-07 14:56:59 -07:00
|
|
|
|
self.execute(create_sql)
|
2020-05-02 09:02:04 -07:00
|
|
|
|
return self
|
2018-08-02 08:24:16 -07:00
|
|
|
|
|
2021-08-10 16:09:28 -07:00
|
|
|
|
def m2m_table_candidates(self, table: str, other_table: str) -> List[str]:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Given two table names returns the name of tables that could define a
|
|
|
|
|
|
many-to-many relationship between those two tables, based on having
|
|
|
|
|
|
foreign keys to both of the provided tables.
|
2022-03-11 09:38:34 -08:00
|
|
|
|
|
|
|
|
|
|
:param table: Table name
|
|
|
|
|
|
:param other_table: Other table name
|
2021-08-10 16:09:28 -07:00
|
|
|
|
"""
|
2019-08-03 21:07:06 +03:00
|
|
|
|
candidates = []
|
|
|
|
|
|
tables = {table, other_table}
|
2021-08-10 16:09:28 -07:00
|
|
|
|
for table_obj in self.tables:
|
2019-08-03 21:07:06 +03:00
|
|
|
|
# Does it have foreign keys to both table and other_table?
|
2021-08-10 16:09:28 -07:00
|
|
|
|
has_fks_to = {fk.other_table for fk in table_obj.foreign_keys}
|
2019-08-03 21:07:06 +03:00
|
|
|
|
if has_fks_to.issuperset(tables):
|
2021-08-10 16:09:28 -07:00
|
|
|
|
candidates.append(table_obj.name)
|
2019-08-03 21:07:06 +03:00
|
|
|
|
return candidates
|
|
|
|
|
|
|
More type annotations (#697)
* Add comprehensive type annotations
- mypy.ini: expanded configuration with module-specific settings
- hookspecs.py: type annotations for hook functions
- plugins.py: typed get_plugins() return value
- recipes.py: full type annotations for parsedate, parsedatetime, jsonsplit
- utils.py: extensive type annotations including Row type alias,
TypeTracker, ValueTracker, and all utility functions
- db.py: type annotations for Database methods (__exit__,
ensure_autocommit_off, tracer, register_function, etc.) and
Queryable class methods
- tests/test_docs.py: updated to match new signature display format
* Fix type errors caught by ty check
- Add type: ignore comments for external library type stub limitations
(csv.reader, click.progressbar, IOBase.name, Callable.__name__)
- Change Iterable to Sequence for SQL where_args parameters
- Use db.table() instead of db[name] for proper Table return type
- Fix rebuild_fts return type from None to Table
- Update test_tracer to expect fewer queries (optimization side effect)
* Fix mypy type errors
- Add type: ignore comments for runtime-valid patterns mypy can't verify
- Fix new_column_types annotation to Dict[str, Set[type]]
- Add type: ignore for Default sentinel values passed to create_table
* mypy skip tests directory
* Fix CI: exclude typing imports from recipe docs, skip mypy on tests
- Add Callable and Optional to exclusion list in _generate_convert_help()
- Regenerate docs/cli-reference.rst with cog
- Add [mypy-tests.*] ignore_errors = True to skip test type errors
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 22:11:47 -08:00
|
|
|
|
def add_foreign_keys(
|
2026-07-05 15:03:23 -07:00
|
|
|
|
self, foreign_keys: Iterable[Union[ForeignKey, ForeignKeyTuple]]
|
More type annotations (#697)
* Add comprehensive type annotations
- mypy.ini: expanded configuration with module-specific settings
- hookspecs.py: type annotations for hook functions
- plugins.py: typed get_plugins() return value
- recipes.py: full type annotations for parsedate, parsedatetime, jsonsplit
- utils.py: extensive type annotations including Row type alias,
TypeTracker, ValueTracker, and all utility functions
- db.py: type annotations for Database methods (__exit__,
ensure_autocommit_off, tracer, register_function, etc.) and
Queryable class methods
- tests/test_docs.py: updated to match new signature display format
* Fix type errors caught by ty check
- Add type: ignore comments for external library type stub limitations
(csv.reader, click.progressbar, IOBase.name, Callable.__name__)
- Change Iterable to Sequence for SQL where_args parameters
- Use db.table() instead of db[name] for proper Table return type
- Fix rebuild_fts return type from None to Table
- Update test_tracer to expect fewer queries (optimization side effect)
* Fix mypy type errors
- Add type: ignore comments for runtime-valid patterns mypy can't verify
- Fix new_column_types annotation to Dict[str, Set[type]]
- Add type: ignore for Default sentinel values passed to create_table
* mypy skip tests directory
* Fix CI: exclude typing imports from recipe docs, skip mypy on tests
- Add Callable and Optional to exclusion list in _generate_convert_help()
- Regenerate docs/cli-reference.rst with cog
- Add [mypy-tests.*] ignore_errors = True to skip test type errors
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 22:11:47 -08:00
|
|
|
|
) -> None:
|
2021-08-10 16:09:28 -07:00
|
|
|
|
"""
|
|
|
|
|
|
See :ref:`python_api_add_foreign_keys`.
|
|
|
|
|
|
|
2022-03-11 09:38:34 -08:00
|
|
|
|
:param foreign_keys: A list of ``(table, column, other_table, other_column)``
|
2026-07-05 14:37:32 -07:00
|
|
|
|
tuples - for compound foreign keys, ``column`` and ``other_column`` can
|
2026-07-05 15:10:21 -07:00
|
|
|
|
be tuples of column names
|
2021-08-10 16:09:28 -07:00
|
|
|
|
"""
|
2019-06-28 23:27:38 -07:00
|
|
|
|
# foreign_keys is a list of explicit 4-tuples
|
2026-07-04 19:19:24 +00:00
|
|
|
|
if not all(
|
2026-07-05 13:59:25 -07:00
|
|
|
|
isinstance(fk, ForeignKey)
|
|
|
|
|
|
or (isinstance(fk, (list, tuple)) and len(fk) == 4)
|
|
|
|
|
|
for fk in foreign_keys
|
2026-07-04 19:19:24 +00:00
|
|
|
|
):
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
"foreign_keys must be a list of 4-tuples, "
|
|
|
|
|
|
"(table, column, other_table, other_column)"
|
|
|
|
|
|
)
|
2019-06-28 23:27:38 -07:00
|
|
|
|
|
2026-07-05 15:42:41 -07:00
|
|
|
|
foreign_keys_to_create: List[ForeignKey] = []
|
2019-06-28 23:27:38 -07:00
|
|
|
|
|
|
|
|
|
|
# Verify that all tables and columns exist
|
2026-07-05 13:59:25 -07:00
|
|
|
|
for fk in foreign_keys:
|
|
|
|
|
|
if isinstance(fk, ForeignKey):
|
2026-07-05 15:42:41 -07:00
|
|
|
|
fk_object = fk
|
2026-07-05 13:59:25 -07:00
|
|
|
|
else:
|
2026-07-05 14:37:32 -07:00
|
|
|
|
table, column_or_columns, other_table, other_column_or_columns = fk
|
2026-07-05 15:10:21 -07:00
|
|
|
|
# Compound foreign keys use tuples of columns
|
2026-07-05 14:37:32 -07:00
|
|
|
|
columns = (
|
2026-07-05 15:10:21 -07:00
|
|
|
|
(column_or_columns,)
|
2026-07-05 14:37:32 -07:00
|
|
|
|
if isinstance(column_or_columns, str)
|
2026-07-05 15:10:21 -07:00
|
|
|
|
else tuple(column_or_columns)
|
2026-07-05 14:37:32 -07:00
|
|
|
|
)
|
|
|
|
|
|
other_columns = (
|
2026-07-05 15:10:21 -07:00
|
|
|
|
(other_column_or_columns,)
|
2026-07-05 14:37:32 -07:00
|
|
|
|
if isinstance(other_column_or_columns, str)
|
2026-07-05 15:10:21 -07:00
|
|
|
|
else tuple(other_column_or_columns)
|
2026-07-05 14:37:32 -07:00
|
|
|
|
)
|
2026-07-06 21:53:48 -07:00
|
|
|
|
if len(columns) != len(other_columns):
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
"Compound foreign key must have the same number of "
|
|
|
|
|
|
"columns on both sides"
|
|
|
|
|
|
)
|
2026-07-05 15:42:41 -07:00
|
|
|
|
if len(columns) == 1:
|
|
|
|
|
|
fk_object = ForeignKey(
|
|
|
|
|
|
table, columns[0], other_table, other_columns[0]
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
fk_object = ForeignKey(
|
|
|
|
|
|
table,
|
|
|
|
|
|
None,
|
|
|
|
|
|
other_table,
|
|
|
|
|
|
None,
|
|
|
|
|
|
columns=columns,
|
|
|
|
|
|
other_columns=other_columns,
|
|
|
|
|
|
is_compound=True,
|
|
|
|
|
|
)
|
|
|
|
|
|
table = fk_object.table
|
|
|
|
|
|
other_table = fk_object.other_table
|
2025-05-08 23:19:36 -07:00
|
|
|
|
if not self.table(table).exists():
|
2019-06-28 23:27:38 -07:00
|
|
|
|
raise AlterError("No such table: {}".format(table))
|
2025-05-08 23:19:36 -07:00
|
|
|
|
table_obj = self.table(table)
|
2026-07-05 22:11:22 -07:00
|
|
|
|
fk_object = self._resolve_foreign_key_casing(
|
|
|
|
|
|
fk_object, table_obj.columns_dict
|
|
|
|
|
|
)
|
|
|
|
|
|
columns = fk_object.columns
|
|
|
|
|
|
other_columns = fk_object.other_columns
|
2026-07-05 14:37:32 -07:00
|
|
|
|
for column in columns:
|
|
|
|
|
|
if column not in table_obj.columns_dict:
|
|
|
|
|
|
raise AlterError("No such column: {} in {}".format(column, table))
|
2020-02-08 15:56:03 -08:00
|
|
|
|
if not self[other_table].exists():
|
2019-06-28 23:27:38 -07:00
|
|
|
|
raise AlterError("No such other_table: {}".format(other_table))
|
2026-07-05 14:37:32 -07:00
|
|
|
|
for other_column in other_columns:
|
|
|
|
|
|
if (
|
|
|
|
|
|
other_column != "rowid"
|
|
|
|
|
|
and other_column not in self[other_table].columns_dict
|
|
|
|
|
|
):
|
|
|
|
|
|
raise AlterError(
|
|
|
|
|
|
"No such other_column: {} in {}".format(
|
|
|
|
|
|
other_column, other_table
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
2026-07-06 21:53:48 -07:00
|
|
|
|
# Silently skip foreign keys that exist already - but only if
|
|
|
|
|
|
# they match exactly, including ON DELETE/ON UPDATE actions
|
2026-07-05 22:11:22 -07:00
|
|
|
|
columns_folded = tuple(fold_identifier_case(c) for c in columns)
|
|
|
|
|
|
other_columns_folded = tuple(fold_identifier_case(c) for c in other_columns)
|
2026-07-06 21:53:48 -07:00
|
|
|
|
existing = [
|
2019-06-28 23:27:38 -07:00
|
|
|
|
fk
|
2021-08-10 16:09:28 -07:00
|
|
|
|
for fk in table_obj.foreign_keys
|
2026-07-05 22:11:22 -07:00
|
|
|
|
if tuple(fold_identifier_case(c) for c in fk.columns) == columns_folded
|
|
|
|
|
|
and fold_identifier_case(fk.other_table)
|
|
|
|
|
|
== fold_identifier_case(other_table)
|
|
|
|
|
|
and tuple(fold_identifier_case(c) for c in fk.other_columns)
|
|
|
|
|
|
== other_columns_folded
|
2026-07-06 21:53:48 -07:00
|
|
|
|
]
|
|
|
|
|
|
if not existing:
|
2026-07-05 15:42:41 -07:00
|
|
|
|
foreign_keys_to_create.append(fk_object)
|
2026-07-06 21:53:48 -07:00
|
|
|
|
elif any(
|
|
|
|
|
|
fk.on_delete != fk_object.on_delete
|
|
|
|
|
|
or fk.on_update != fk_object.on_update
|
|
|
|
|
|
for fk in existing
|
|
|
|
|
|
):
|
|
|
|
|
|
raise AlterError(
|
|
|
|
|
|
"Foreign key already exists for {} => {}.{} but with "
|
|
|
|
|
|
"different ON DELETE/ON UPDATE actions - use "
|
|
|
|
|
|
"table.transform() to change them".format(
|
|
|
|
|
|
", ".join(columns), other_table, ", ".join(other_columns)
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
2019-06-28 23:27:38 -07:00
|
|
|
|
|
2023-08-17 17:48:08 -07:00
|
|
|
|
# Group them by table
|
2026-07-05 15:42:41 -07:00
|
|
|
|
by_table: Dict[str, List[ForeignKey]] = {}
|
|
|
|
|
|
for fk_object in foreign_keys_to_create:
|
|
|
|
|
|
by_table.setdefault(fk_object.table, []).append(fk_object)
|
2023-08-17 17:48:08 -07:00
|
|
|
|
|
|
|
|
|
|
for table, fks in by_table.items():
|
2025-05-08 23:19:36 -07:00
|
|
|
|
self.table(table).transform(add_foreign_keys=fks)
|
2019-06-28 23:27:38 -07:00
|
|
|
|
|
2026-06-21 10:43:19 -07:00
|
|
|
|
if not self.conn.in_transaction:
|
|
|
|
|
|
self.vacuum()
|
2019-06-28 23:27:38 -07:00
|
|
|
|
|
More type annotations (#697)
* Add comprehensive type annotations
- mypy.ini: expanded configuration with module-specific settings
- hookspecs.py: type annotations for hook functions
- plugins.py: typed get_plugins() return value
- recipes.py: full type annotations for parsedate, parsedatetime, jsonsplit
- utils.py: extensive type annotations including Row type alias,
TypeTracker, ValueTracker, and all utility functions
- db.py: type annotations for Database methods (__exit__,
ensure_autocommit_off, tracer, register_function, etc.) and
Queryable class methods
- tests/test_docs.py: updated to match new signature display format
* Fix type errors caught by ty check
- Add type: ignore comments for external library type stub limitations
(csv.reader, click.progressbar, IOBase.name, Callable.__name__)
- Change Iterable to Sequence for SQL where_args parameters
- Use db.table() instead of db[name] for proper Table return type
- Fix rebuild_fts return type from None to Table
- Update test_tracer to expect fewer queries (optimization side effect)
* Fix mypy type errors
- Add type: ignore comments for runtime-valid patterns mypy can't verify
- Fix new_column_types annotation to Dict[str, Set[type]]
- Add type: ignore for Default sentinel values passed to create_table
* mypy skip tests directory
* Fix CI: exclude typing imports from recipe docs, skip mypy on tests
- Add Callable and Optional to exclusion list in _generate_convert_help()
- Regenerate docs/cli-reference.rst with cog
- Add [mypy-tests.*] ignore_errors = True to skip test type errors
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 22:11:47 -08:00
|
|
|
|
def index_foreign_keys(self) -> None:
|
2021-08-10 16:09:28 -07:00
|
|
|
|
"Create indexes for every foreign key column on every table in the database."
|
2019-06-30 16:50:54 -07:00
|
|
|
|
for table_name in self.table_names():
|
2025-12-16 19:34:45 -08:00
|
|
|
|
table = self.table(table_name)
|
2026-07-05 14:37:32 -07:00
|
|
|
|
existing_indexes = {tuple(i.columns) for i in table.indexes}
|
2019-06-30 16:50:54 -07:00
|
|
|
|
for fk in table.foreign_keys:
|
2026-07-05 14:37:32 -07:00
|
|
|
|
# A compound foreign key gets a single composite index
|
2026-07-05 15:10:21 -07:00
|
|
|
|
if fk.columns not in existing_indexes:
|
2026-07-05 14:37:32 -07:00
|
|
|
|
table.create_index(fk.columns, find_unique_name=True)
|
2026-07-05 15:10:21 -07:00
|
|
|
|
existing_indexes.add(fk.columns)
|
2019-06-30 16:50:54 -07:00
|
|
|
|
|
More type annotations (#697)
* Add comprehensive type annotations
- mypy.ini: expanded configuration with module-specific settings
- hookspecs.py: type annotations for hook functions
- plugins.py: typed get_plugins() return value
- recipes.py: full type annotations for parsedate, parsedatetime, jsonsplit
- utils.py: extensive type annotations including Row type alias,
TypeTracker, ValueTracker, and all utility functions
- db.py: type annotations for Database methods (__exit__,
ensure_autocommit_off, tracer, register_function, etc.) and
Queryable class methods
- tests/test_docs.py: updated to match new signature display format
* Fix type errors caught by ty check
- Add type: ignore comments for external library type stub limitations
(csv.reader, click.progressbar, IOBase.name, Callable.__name__)
- Change Iterable to Sequence for SQL where_args parameters
- Use db.table() instead of db[name] for proper Table return type
- Fix rebuild_fts return type from None to Table
- Update test_tracer to expect fewer queries (optimization side effect)
* Fix mypy type errors
- Add type: ignore comments for runtime-valid patterns mypy can't verify
- Fix new_column_types annotation to Dict[str, Set[type]]
- Add type: ignore for Default sentinel values passed to create_table
* mypy skip tests directory
* Fix CI: exclude typing imports from recipe docs, skip mypy on tests
- Add Callable and Optional to exclusion list in _generate_convert_help()
- Regenerate docs/cli-reference.rst with cog
- Add [mypy-tests.*] ignore_errors = True to skip test type errors
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 22:11:47 -08:00
|
|
|
|
def vacuum(self) -> None:
|
2021-08-10 16:09:28 -07:00
|
|
|
|
"Run a SQLite ``VACUUM`` against the database."
|
2020-09-07 14:56:59 -07:00
|
|
|
|
self.execute("VACUUM;")
|
2019-01-24 19:39:04 -08:00
|
|
|
|
|
More type annotations (#697)
* Add comprehensive type annotations
- mypy.ini: expanded configuration with module-specific settings
- hookspecs.py: type annotations for hook functions
- plugins.py: typed get_plugins() return value
- recipes.py: full type annotations for parsedate, parsedatetime, jsonsplit
- utils.py: extensive type annotations including Row type alias,
TypeTracker, ValueTracker, and all utility functions
- db.py: type annotations for Database methods (__exit__,
ensure_autocommit_off, tracer, register_function, etc.) and
Queryable class methods
- tests/test_docs.py: updated to match new signature display format
* Fix type errors caught by ty check
- Add type: ignore comments for external library type stub limitations
(csv.reader, click.progressbar, IOBase.name, Callable.__name__)
- Change Iterable to Sequence for SQL where_args parameters
- Use db.table() instead of db[name] for proper Table return type
- Fix rebuild_fts return type from None to Table
- Update test_tracer to expect fewer queries (optimization side effect)
* Fix mypy type errors
- Add type: ignore comments for runtime-valid patterns mypy can't verify
- Fix new_column_types annotation to Dict[str, Set[type]]
- Add type: ignore for Default sentinel values passed to create_table
* mypy skip tests directory
* Fix CI: exclude typing imports from recipe docs, skip mypy on tests
- Add Callable and Optional to exclusion list in _generate_convert_help()
- Regenerate docs/cli-reference.rst with cog
- Add [mypy-tests.*] ignore_errors = True to skip test type errors
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 22:11:47 -08:00
|
|
|
|
def analyze(self, name: Optional[str] = None) -> None:
|
2022-03-11 09:38:34 -08:00
|
|
|
|
"""
|
|
|
|
|
|
Run ``ANALYZE`` against the entire database or a named table or index.
|
|
|
|
|
|
|
|
|
|
|
|
:param name: Run ``ANALYZE`` against this specific named table or index
|
|
|
|
|
|
"""
|
2022-01-10 11:48:38 -08:00
|
|
|
|
sql = "ANALYZE"
|
|
|
|
|
|
if name is not None:
|
2025-11-23 20:43:26 -08:00
|
|
|
|
sql += " {}".format(quote_identifier(name))
|
2022-01-10 11:48:38 -08:00
|
|
|
|
self.execute(sql)
|
|
|
|
|
|
|
2023-06-25 16:25:51 -07:00
|
|
|
|
def iterdump(self) -> Generator[str, None, None]:
|
|
|
|
|
|
"A sequence of strings representing a SQL dump of the database"
|
|
|
|
|
|
if iterdump:
|
|
|
|
|
|
yield from iterdump(self.conn)
|
|
|
|
|
|
else:
|
|
|
|
|
|
try:
|
|
|
|
|
|
yield from self.conn.iterdump()
|
|
|
|
|
|
except AttributeError:
|
|
|
|
|
|
raise AttributeError(
|
|
|
|
|
|
"conn.iterdump() not found - try pip install sqlite-dump"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2022-11-15 22:25:26 -08:00
|
|
|
|
def init_spatialite(self, path: Optional[str] = None) -> bool:
|
2022-02-04 00:55:09 -05:00
|
|
|
|
"""
|
|
|
|
|
|
The ``init_spatialite`` method will load and initialize the SpatiaLite extension.
|
|
|
|
|
|
The ``path`` argument should be an absolute path to the compiled extension, which
|
|
|
|
|
|
can be found using ``find_spatialite``.
|
|
|
|
|
|
|
2022-03-11 09:38:34 -08:00
|
|
|
|
Returns ``True`` if SpatiaLite was successfully initialized.
|
2022-02-04 00:55:09 -05:00
|
|
|
|
|
|
|
|
|
|
.. code-block:: python
|
|
|
|
|
|
|
|
|
|
|
|
from sqlite_utils.db import Database
|
|
|
|
|
|
from sqlite_utils.utils import find_spatialite
|
|
|
|
|
|
|
|
|
|
|
|
db = Database("mydb.db")
|
|
|
|
|
|
db.init_spatialite(find_spatialite())
|
|
|
|
|
|
|
|
|
|
|
|
If you've installed SpatiaLite somewhere unexpected (for testing an alternate version, for example)
|
|
|
|
|
|
you can pass in an absolute path:
|
|
|
|
|
|
|
|
|
|
|
|
.. code-block:: python
|
|
|
|
|
|
|
|
|
|
|
|
from sqlite_utils.db import Database
|
|
|
|
|
|
from sqlite_utils.utils import find_spatialite
|
|
|
|
|
|
|
|
|
|
|
|
db = Database("mydb.db")
|
|
|
|
|
|
db.init_spatialite("./local/mod_spatialite.dylib")
|
|
|
|
|
|
|
2022-03-11 09:38:34 -08:00
|
|
|
|
:param path: Path to SpatiaLite module on disk
|
2022-02-04 00:55:09 -05:00
|
|
|
|
"""
|
|
|
|
|
|
if path is None:
|
|
|
|
|
|
path = find_spatialite()
|
2025-12-16 19:34:45 -08:00
|
|
|
|
if path is None:
|
|
|
|
|
|
raise OSError("Could not find SpatiaLite extension")
|
2022-02-04 00:55:09 -05:00
|
|
|
|
|
|
|
|
|
|
self.conn.enable_load_extension(True)
|
|
|
|
|
|
self.conn.load_extension(path)
|
|
|
|
|
|
# Initialize SpatiaLite if not yet initialized
|
|
|
|
|
|
if "spatial_ref_sys" in self.table_names():
|
|
|
|
|
|
return False
|
|
|
|
|
|
cursor = self.execute("select InitSpatialMetadata(1)")
|
|
|
|
|
|
result = cursor.fetchone()
|
|
|
|
|
|
return result and bool(result[0])
|
|
|
|
|
|
|
2018-07-28 06:43:18 -07:00
|
|
|
|
|
2019-08-23 15:19:41 +03:00
|
|
|
|
class Queryable:
|
More type annotations (#697)
* Add comprehensive type annotations
- mypy.ini: expanded configuration with module-specific settings
- hookspecs.py: type annotations for hook functions
- plugins.py: typed get_plugins() return value
- recipes.py: full type annotations for parsedate, parsedatetime, jsonsplit
- utils.py: extensive type annotations including Row type alias,
TypeTracker, ValueTracker, and all utility functions
- db.py: type annotations for Database methods (__exit__,
ensure_autocommit_off, tracer, register_function, etc.) and
Queryable class methods
- tests/test_docs.py: updated to match new signature display format
* Fix type errors caught by ty check
- Add type: ignore comments for external library type stub limitations
(csv.reader, click.progressbar, IOBase.name, Callable.__name__)
- Change Iterable to Sequence for SQL where_args parameters
- Use db.table() instead of db[name] for proper Table return type
- Fix rebuild_fts return type from None to Table
- Update test_tracer to expect fewer queries (optimization side effect)
* Fix mypy type errors
- Add type: ignore comments for runtime-valid patterns mypy can't verify
- Fix new_column_types annotation to Dict[str, Set[type]]
- Add type: ignore for Default sentinel values passed to create_table
* mypy skip tests directory
* Fix CI: exclude typing imports from recipe docs, skip mypy on tests
- Add Callable and Optional to exclusion list in _generate_convert_help()
- Regenerate docs/cli-reference.rst with cog
- Add [mypy-tests.*] ignore_errors = True to skip test type errors
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 22:11:47 -08:00
|
|
|
|
db: "Database"
|
|
|
|
|
|
name: str
|
|
|
|
|
|
|
2021-08-10 16:09:28 -07:00
|
|
|
|
def exists(self) -> bool:
|
|
|
|
|
|
"Does this table or view exist yet?"
|
2020-02-08 15:56:03 -08:00
|
|
|
|
return False
|
2019-08-23 15:19:41 +03:00
|
|
|
|
|
More type annotations (#697)
* Add comprehensive type annotations
- mypy.ini: expanded configuration with module-specific settings
- hookspecs.py: type annotations for hook functions
- plugins.py: typed get_plugins() return value
- recipes.py: full type annotations for parsedate, parsedatetime, jsonsplit
- utils.py: extensive type annotations including Row type alias,
TypeTracker, ValueTracker, and all utility functions
- db.py: type annotations for Database methods (__exit__,
ensure_autocommit_off, tracer, register_function, etc.) and
Queryable class methods
- tests/test_docs.py: updated to match new signature display format
* Fix type errors caught by ty check
- Add type: ignore comments for external library type stub limitations
(csv.reader, click.progressbar, IOBase.name, Callable.__name__)
- Change Iterable to Sequence for SQL where_args parameters
- Use db.table() instead of db[name] for proper Table return type
- Fix rebuild_fts return type from None to Table
- Update test_tracer to expect fewer queries (optimization side effect)
* Fix mypy type errors
- Add type: ignore comments for runtime-valid patterns mypy can't verify
- Fix new_column_types annotation to Dict[str, Set[type]]
- Add type: ignore for Default sentinel values passed to create_table
* mypy skip tests directory
* Fix CI: exclude typing imports from recipe docs, skip mypy on tests
- Add Callable and Optional to exclusion list in _generate_convert_help()
- Regenerate docs/cli-reference.rst with cog
- Add [mypy-tests.*] ignore_errors = True to skip test type errors
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 22:11:47 -08:00
|
|
|
|
def __init__(self, db: "Database", name: str) -> None:
|
2019-08-23 15:19:41 +03:00
|
|
|
|
self.db = db
|
|
|
|
|
|
self.name = name
|
|
|
|
|
|
|
2021-08-01 22:05:03 -07:00
|
|
|
|
def count_where(
|
|
|
|
|
|
self,
|
2022-11-15 22:25:26 -08:00
|
|
|
|
where: Optional[str] = None,
|
More type annotations (#697)
* Add comprehensive type annotations
- mypy.ini: expanded configuration with module-specific settings
- hookspecs.py: type annotations for hook functions
- plugins.py: typed get_plugins() return value
- recipes.py: full type annotations for parsedate, parsedatetime, jsonsplit
- utils.py: extensive type annotations including Row type alias,
TypeTracker, ValueTracker, and all utility functions
- db.py: type annotations for Database methods (__exit__,
ensure_autocommit_off, tracer, register_function, etc.) and
Queryable class methods
- tests/test_docs.py: updated to match new signature display format
* Fix type errors caught by ty check
- Add type: ignore comments for external library type stub limitations
(csv.reader, click.progressbar, IOBase.name, Callable.__name__)
- Change Iterable to Sequence for SQL where_args parameters
- Use db.table() instead of db[name] for proper Table return type
- Fix rebuild_fts return type from None to Table
- Update test_tracer to expect fewer queries (optimization side effect)
* Fix mypy type errors
- Add type: ignore comments for runtime-valid patterns mypy can't verify
- Fix new_column_types annotation to Dict[str, Set[type]]
- Add type: ignore for Default sentinel values passed to create_table
* mypy skip tests directory
* Fix CI: exclude typing imports from recipe docs, skip mypy on tests
- Add Callable and Optional to exclusion list in _generate_convert_help()
- Regenerate docs/cli-reference.rst with cog
- Add [mypy-tests.*] ignore_errors = True to skip test type errors
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 22:11:47 -08:00
|
|
|
|
where_args: Optional[Union[Sequence, Dict[str, Any]]] = None,
|
2021-08-10 16:09:28 -07:00
|
|
|
|
) -> int:
|
2022-03-11 09:38:34 -08:00
|
|
|
|
"""
|
|
|
|
|
|
Executes ``SELECT count(*) FROM table WHERE ...`` and returns a count.
|
|
|
|
|
|
|
|
|
|
|
|
:param where: SQL where fragment to use, for example ``id > ?``
|
|
|
|
|
|
:param where_args: Parameters to use with that fragment - an iterable for ``id > ?``
|
|
|
|
|
|
parameters, or a dictionary for ``id > :id``
|
|
|
|
|
|
"""
|
2025-11-23 20:43:26 -08:00
|
|
|
|
sql = "select count(*) from {}".format(quote_identifier(self.name))
|
2021-08-01 22:05:03 -07:00
|
|
|
|
if where is not None:
|
|
|
|
|
|
sql += " where " + where
|
|
|
|
|
|
return self.db.execute(sql, where_args or []).fetchone()[0]
|
|
|
|
|
|
|
More type annotations (#697)
* Add comprehensive type annotations
- mypy.ini: expanded configuration with module-specific settings
- hookspecs.py: type annotations for hook functions
- plugins.py: typed get_plugins() return value
- recipes.py: full type annotations for parsedate, parsedatetime, jsonsplit
- utils.py: extensive type annotations including Row type alias,
TypeTracker, ValueTracker, and all utility functions
- db.py: type annotations for Database methods (__exit__,
ensure_autocommit_off, tracer, register_function, etc.) and
Queryable class methods
- tests/test_docs.py: updated to match new signature display format
* Fix type errors caught by ty check
- Add type: ignore comments for external library type stub limitations
(csv.reader, click.progressbar, IOBase.name, Callable.__name__)
- Change Iterable to Sequence for SQL where_args parameters
- Use db.table() instead of db[name] for proper Table return type
- Fix rebuild_fts return type from None to Table
- Update test_tracer to expect fewer queries (optimization side effect)
* Fix mypy type errors
- Add type: ignore comments for runtime-valid patterns mypy can't verify
- Fix new_column_types annotation to Dict[str, Set[type]]
- Add type: ignore for Default sentinel values passed to create_table
* mypy skip tests directory
* Fix CI: exclude typing imports from recipe docs, skip mypy on tests
- Add Callable and Optional to exclusion list in _generate_convert_help()
- Regenerate docs/cli-reference.rst with cog
- Add [mypy-tests.*] ignore_errors = True to skip test type errors
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 22:11:47 -08:00
|
|
|
|
def execute_count(self) -> int:
|
2021-08-01 22:05:03 -07:00
|
|
|
|
# Backwards compatibility, see https://github.com/simonw/sqlite-utils/issues/305#issuecomment-890713185
|
|
|
|
|
|
return self.count_where()
|
2019-08-23 15:19:41 +03:00
|
|
|
|
|
2021-01-03 12:19:34 -08:00
|
|
|
|
@property
|
2021-08-10 16:09:28 -07:00
|
|
|
|
def count(self) -> int:
|
|
|
|
|
|
"A count of the rows in this table or view."
|
2021-08-01 22:05:03 -07:00
|
|
|
|
return self.count_where()
|
2021-01-03 12:19:34 -08:00
|
|
|
|
|
2019-08-23 15:19:41 +03:00
|
|
|
|
@property
|
More type annotations (#697)
* Add comprehensive type annotations
- mypy.ini: expanded configuration with module-specific settings
- hookspecs.py: type annotations for hook functions
- plugins.py: typed get_plugins() return value
- recipes.py: full type annotations for parsedate, parsedatetime, jsonsplit
- utils.py: extensive type annotations including Row type alias,
TypeTracker, ValueTracker, and all utility functions
- db.py: type annotations for Database methods (__exit__,
ensure_autocommit_off, tracer, register_function, etc.) and
Queryable class methods
- tests/test_docs.py: updated to match new signature display format
* Fix type errors caught by ty check
- Add type: ignore comments for external library type stub limitations
(csv.reader, click.progressbar, IOBase.name, Callable.__name__)
- Change Iterable to Sequence for SQL where_args parameters
- Use db.table() instead of db[name] for proper Table return type
- Fix rebuild_fts return type from None to Table
- Update test_tracer to expect fewer queries (optimization side effect)
* Fix mypy type errors
- Add type: ignore comments for runtime-valid patterns mypy can't verify
- Fix new_column_types annotation to Dict[str, Set[type]]
- Add type: ignore for Default sentinel values passed to create_table
* mypy skip tests directory
* Fix CI: exclude typing imports from recipe docs, skip mypy on tests
- Add Callable and Optional to exclusion list in _generate_convert_help()
- Regenerate docs/cli-reference.rst with cog
- Add [mypy-tests.*] ignore_errors = True to skip test type errors
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 22:11:47 -08:00
|
|
|
|
def rows(self) -> Generator[Dict[str, Any], None, None]:
|
2021-08-10 16:09:28 -07:00
|
|
|
|
"Iterate over every dictionaries for each row in this table or view."
|
2019-08-23 15:19:41 +03:00
|
|
|
|
return self.rows_where()
|
|
|
|
|
|
|
2021-02-14 12:02:41 -08:00
|
|
|
|
def rows_where(
|
|
|
|
|
|
self,
|
2022-11-15 22:25:26 -08:00
|
|
|
|
where: Optional[str] = None,
|
More type annotations (#697)
* Add comprehensive type annotations
- mypy.ini: expanded configuration with module-specific settings
- hookspecs.py: type annotations for hook functions
- plugins.py: typed get_plugins() return value
- recipes.py: full type annotations for parsedate, parsedatetime, jsonsplit
- utils.py: extensive type annotations including Row type alias,
TypeTracker, ValueTracker, and all utility functions
- db.py: type annotations for Database methods (__exit__,
ensure_autocommit_off, tracer, register_function, etc.) and
Queryable class methods
- tests/test_docs.py: updated to match new signature display format
* Fix type errors caught by ty check
- Add type: ignore comments for external library type stub limitations
(csv.reader, click.progressbar, IOBase.name, Callable.__name__)
- Change Iterable to Sequence for SQL where_args parameters
- Use db.table() instead of db[name] for proper Table return type
- Fix rebuild_fts return type from None to Table
- Update test_tracer to expect fewer queries (optimization side effect)
* Fix mypy type errors
- Add type: ignore comments for runtime-valid patterns mypy can't verify
- Fix new_column_types annotation to Dict[str, Set[type]]
- Add type: ignore for Default sentinel values passed to create_table
* mypy skip tests directory
* Fix CI: exclude typing imports from recipe docs, skip mypy on tests
- Add Callable and Optional to exclusion list in _generate_convert_help()
- Regenerate docs/cli-reference.rst with cog
- Add [mypy-tests.*] ignore_errors = True to skip test type errors
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 22:11:47 -08:00
|
|
|
|
where_args: Optional[Union[Sequence, Dict[str, Any]]] = None,
|
2022-11-15 22:25:26 -08:00
|
|
|
|
order_by: Optional[str] = None,
|
2021-08-10 16:09:28 -07:00
|
|
|
|
select: str = "*",
|
2022-11-15 22:25:26 -08:00
|
|
|
|
limit: Optional[int] = None,
|
|
|
|
|
|
offset: Optional[int] = None,
|
More type annotations (#697)
* Add comprehensive type annotations
- mypy.ini: expanded configuration with module-specific settings
- hookspecs.py: type annotations for hook functions
- plugins.py: typed get_plugins() return value
- recipes.py: full type annotations for parsedate, parsedatetime, jsonsplit
- utils.py: extensive type annotations including Row type alias,
TypeTracker, ValueTracker, and all utility functions
- db.py: type annotations for Database methods (__exit__,
ensure_autocommit_off, tracer, register_function, etc.) and
Queryable class methods
- tests/test_docs.py: updated to match new signature display format
* Fix type errors caught by ty check
- Add type: ignore comments for external library type stub limitations
(csv.reader, click.progressbar, IOBase.name, Callable.__name__)
- Change Iterable to Sequence for SQL where_args parameters
- Use db.table() instead of db[name] for proper Table return type
- Fix rebuild_fts return type from None to Table
- Update test_tracer to expect fewer queries (optimization side effect)
* Fix mypy type errors
- Add type: ignore comments for runtime-valid patterns mypy can't verify
- Fix new_column_types annotation to Dict[str, Set[type]]
- Add type: ignore for Default sentinel values passed to create_table
* mypy skip tests directory
* Fix CI: exclude typing imports from recipe docs, skip mypy on tests
- Add Callable and Optional to exclusion list in _generate_convert_help()
- Regenerate docs/cli-reference.rst with cog
- Add [mypy-tests.*] ignore_errors = True to skip test type errors
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 22:11:47 -08:00
|
|
|
|
) -> Generator[Dict[str, Any], None, None]:
|
2021-08-10 16:09:28 -07:00
|
|
|
|
"""
|
|
|
|
|
|
Iterate over every row in this table or view that matches the specified where clause.
|
|
|
|
|
|
|
|
|
|
|
|
Returns each row as a dictionary. See :ref:`python_api_rows` for more details.
|
2022-03-11 09:38:34 -08:00
|
|
|
|
|
|
|
|
|
|
:param where: SQL where fragment to use, for example ``id > ?``
|
|
|
|
|
|
:param where_args: Parameters to use with that fragment - an iterable for ``id > ?``
|
|
|
|
|
|
parameters, or a dictionary for ``id > :id``
|
|
|
|
|
|
:param order_by: Column or fragment of SQL to order by
|
|
|
|
|
|
:param select: Comma-separated list of columns to select - defaults to ``*``
|
|
|
|
|
|
:param limit: Integer number of rows to limit to
|
|
|
|
|
|
:param offset: Integer for SQL offset
|
2021-08-10 16:09:28 -07:00
|
|
|
|
"""
|
2020-02-08 15:56:03 -08:00
|
|
|
|
if not self.exists():
|
2021-08-10 16:09:28 -07:00
|
|
|
|
return
|
2025-11-23 20:43:26 -08:00
|
|
|
|
sql = "select {} from {}".format(select, quote_identifier(self.name))
|
2019-08-23 15:19:41 +03:00
|
|
|
|
if where is not None:
|
|
|
|
|
|
sql += " where " + where
|
2020-04-15 20:12:55 -07:00
|
|
|
|
if order_by is not None:
|
|
|
|
|
|
sql += " order by " + order_by
|
2021-02-14 12:02:41 -08:00
|
|
|
|
if limit is not None:
|
|
|
|
|
|
sql += " limit {}".format(limit)
|
|
|
|
|
|
if offset is not None:
|
|
|
|
|
|
sql += " offset {}".format(offset)
|
2020-09-07 14:56:59 -07:00
|
|
|
|
cursor = self.db.execute(sql, where_args or [])
|
2026-07-05 21:20:39 -07:00
|
|
|
|
columns = dedupe_keys(c[0] for c in cursor.description)
|
2019-08-23 15:19:41 +03:00
|
|
|
|
for row in cursor:
|
|
|
|
|
|
yield dict(zip(columns, row))
|
|
|
|
|
|
|
2021-02-25 08:28:17 -08:00
|
|
|
|
def pks_and_rows_where(
|
|
|
|
|
|
self,
|
2022-11-15 22:25:26 -08:00
|
|
|
|
where: Optional[str] = None,
|
More type annotations (#697)
* Add comprehensive type annotations
- mypy.ini: expanded configuration with module-specific settings
- hookspecs.py: type annotations for hook functions
- plugins.py: typed get_plugins() return value
- recipes.py: full type annotations for parsedate, parsedatetime, jsonsplit
- utils.py: extensive type annotations including Row type alias,
TypeTracker, ValueTracker, and all utility functions
- db.py: type annotations for Database methods (__exit__,
ensure_autocommit_off, tracer, register_function, etc.) and
Queryable class methods
- tests/test_docs.py: updated to match new signature display format
* Fix type errors caught by ty check
- Add type: ignore comments for external library type stub limitations
(csv.reader, click.progressbar, IOBase.name, Callable.__name__)
- Change Iterable to Sequence for SQL where_args parameters
- Use db.table() instead of db[name] for proper Table return type
- Fix rebuild_fts return type from None to Table
- Update test_tracer to expect fewer queries (optimization side effect)
* Fix mypy type errors
- Add type: ignore comments for runtime-valid patterns mypy can't verify
- Fix new_column_types annotation to Dict[str, Set[type]]
- Add type: ignore for Default sentinel values passed to create_table
* mypy skip tests directory
* Fix CI: exclude typing imports from recipe docs, skip mypy on tests
- Add Callable and Optional to exclusion list in _generate_convert_help()
- Regenerate docs/cli-reference.rst with cog
- Add [mypy-tests.*] ignore_errors = True to skip test type errors
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 22:11:47 -08:00
|
|
|
|
where_args: Optional[Union[Sequence, Dict[str, Any]]] = None,
|
2022-11-15 22:25:26 -08:00
|
|
|
|
order_by: Optional[str] = None,
|
|
|
|
|
|
limit: Optional[int] = None,
|
|
|
|
|
|
offset: Optional[int] = None,
|
More type annotations (#697)
* Add comprehensive type annotations
- mypy.ini: expanded configuration with module-specific settings
- hookspecs.py: type annotations for hook functions
- plugins.py: typed get_plugins() return value
- recipes.py: full type annotations for parsedate, parsedatetime, jsonsplit
- utils.py: extensive type annotations including Row type alias,
TypeTracker, ValueTracker, and all utility functions
- db.py: type annotations for Database methods (__exit__,
ensure_autocommit_off, tracer, register_function, etc.) and
Queryable class methods
- tests/test_docs.py: updated to match new signature display format
* Fix type errors caught by ty check
- Add type: ignore comments for external library type stub limitations
(csv.reader, click.progressbar, IOBase.name, Callable.__name__)
- Change Iterable to Sequence for SQL where_args parameters
- Use db.table() instead of db[name] for proper Table return type
- Fix rebuild_fts return type from None to Table
- Update test_tracer to expect fewer queries (optimization side effect)
* Fix mypy type errors
- Add type: ignore comments for runtime-valid patterns mypy can't verify
- Fix new_column_types annotation to Dict[str, Set[type]]
- Add type: ignore for Default sentinel values passed to create_table
* mypy skip tests directory
* Fix CI: exclude typing imports from recipe docs, skip mypy on tests
- Add Callable and Optional to exclusion list in _generate_convert_help()
- Regenerate docs/cli-reference.rst with cog
- Add [mypy-tests.*] ignore_errors = True to skip test type errors
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 22:11:47 -08:00
|
|
|
|
) -> Generator[Tuple[Any, Dict[str, Any]], None, None]:
|
2022-03-11 09:38:34 -08:00
|
|
|
|
"""
|
|
|
|
|
|
Like ``.rows_where()`` but returns ``(pk, row)`` pairs - ``pk`` can be a single value or tuple.
|
|
|
|
|
|
|
|
|
|
|
|
:param where: SQL where fragment to use, for example ``id > ?``
|
|
|
|
|
|
:param where_args: Parameters to use with that fragment - an iterable for ``id > ?``
|
|
|
|
|
|
parameters, or a dictionary for ``id > :id``
|
|
|
|
|
|
:param order_by: Column or fragment of SQL to order by
|
|
|
|
|
|
:param select: Comma-separated list of columns to select - defaults to ``*``
|
|
|
|
|
|
:param limit: Integer number of rows to limit to
|
|
|
|
|
|
:param offset: Integer for SQL offset
|
|
|
|
|
|
"""
|
2026-07-06 21:42:02 -07:00
|
|
|
|
# This method is defined on Queryable so it serves views too, which
|
|
|
|
|
|
# have no pks property - sort pk columns into declaration order here
|
|
|
|
|
|
pk_columns = sorted(
|
|
|
|
|
|
(column for column in self.columns if column.is_pk),
|
|
|
|
|
|
key=lambda column: column.is_pk,
|
|
|
|
|
|
)
|
|
|
|
|
|
pks = [column.name for column in pk_columns]
|
|
|
|
|
|
select_parts = [quote_identifier(column.name) for column in self.columns]
|
|
|
|
|
|
if not pks:
|
|
|
|
|
|
# rowid is left unquoted: it is not a real column, and SQLite
|
|
|
|
|
|
# turns a double-quoted identifier that does not resolve into a
|
|
|
|
|
|
# string literal - on a view that would silently select the
|
|
|
|
|
|
# string 'rowid' instead of raising an error
|
|
|
|
|
|
select_parts.insert(0, "rowid")
|
|
|
|
|
|
pks = ["rowid"]
|
|
|
|
|
|
select = ",".join(select_parts)
|
2021-02-25 08:28:17 -08:00
|
|
|
|
for row in self.rows_where(
|
|
|
|
|
|
select=select,
|
|
|
|
|
|
where=where,
|
|
|
|
|
|
where_args=where_args,
|
|
|
|
|
|
order_by=order_by,
|
|
|
|
|
|
limit=limit,
|
|
|
|
|
|
offset=offset,
|
|
|
|
|
|
):
|
|
|
|
|
|
row_pk = tuple(row[pk] for pk in pks)
|
|
|
|
|
|
if len(row_pk) == 1:
|
|
|
|
|
|
row_pk = row_pk[0]
|
|
|
|
|
|
yield row_pk, row
|
|
|
|
|
|
|
2019-08-23 15:19:41 +03:00
|
|
|
|
@property
|
2021-08-10 16:09:28 -07:00
|
|
|
|
def columns(self) -> List["Column"]:
|
|
|
|
|
|
"List of :ref:`Columns <reference_db_other_column>` representing the columns in this table or view."
|
2020-02-08 15:56:03 -08:00
|
|
|
|
if not self.exists():
|
2019-08-23 15:19:41 +03:00
|
|
|
|
return []
|
2025-11-23 20:43:26 -08:00
|
|
|
|
rows = self.db.execute(
|
|
|
|
|
|
"PRAGMA table_info({})".format(quote_identifier(self.name))
|
|
|
|
|
|
).fetchall()
|
2019-08-23 15:19:41 +03:00
|
|
|
|
return [Column(*row) for row in rows]
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
2021-08-10 16:09:28 -07:00
|
|
|
|
def columns_dict(self) -> Dict[str, Any]:
|
|
|
|
|
|
"``{column_name: python-type}`` dictionary representing columns in this table or view."
|
2020-03-14 13:04:06 -07:00
|
|
|
|
return {column.name: column_affinity(column.type) for column in self.columns}
|
2019-08-23 15:19:41 +03:00
|
|
|
|
|
|
|
|
|
|
@property
|
2021-08-10 16:09:28 -07:00
|
|
|
|
def schema(self) -> str:
|
|
|
|
|
|
"SQL schema for this table or view."
|
2020-09-07 14:56:59 -07:00
|
|
|
|
return self.db.execute(
|
2019-08-23 15:19:41 +03:00
|
|
|
|
"select sql from sqlite_master where name = ?", (self.name,)
|
|
|
|
|
|
).fetchone()[0]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Table(Queryable):
|
2022-07-27 17:28:46 -07:00
|
|
|
|
"""
|
|
|
|
|
|
Tables should usually be initialized using the ``db.table(table_name)`` or
|
|
|
|
|
|
``db[table_name]`` methods.
|
|
|
|
|
|
|
|
|
|
|
|
The following optional parameters can be passed to ``db.table(table_name, ...)``:
|
|
|
|
|
|
|
|
|
|
|
|
:param db: Provided by ``db.table(table_name)``
|
|
|
|
|
|
:param name: Provided by ``db.table(table_name)``
|
|
|
|
|
|
:param pk: Name of the primary key column, or tuple of columns
|
|
|
|
|
|
:param foreign_keys: List of foreign key definitions
|
|
|
|
|
|
:param column_order: List of column names in the order they should be in the table
|
|
|
|
|
|
:param not_null: List of columns that cannot be null
|
|
|
|
|
|
:param defaults: Dictionary of column names and default values
|
|
|
|
|
|
:param batch_size: Integer number of rows to insert at a time
|
2026-07-04 19:13:15 +00:00
|
|
|
|
:param hash_id: Name of a column to create and use as a primary key, where the
|
|
|
|
|
|
value of that primary key is derived from a hash of the row values
|
2022-07-27 17:28:46 -07:00
|
|
|
|
:param hash_id_columns: List of columns to use for the hash_id
|
|
|
|
|
|
:param alter: If True, automatically alter the table if it doesn't match the schema
|
|
|
|
|
|
:param ignore: If True, ignore rows that already exist when inserting
|
|
|
|
|
|
:param replace: If True, replace rows that already exist when inserting
|
|
|
|
|
|
:param extracts: Dictionary or list of column names to extract into a separate table on inserts
|
|
|
|
|
|
:param conversions: Dictionary of column names and conversion functions
|
|
|
|
|
|
:param columns: Dictionary of column names to column types
|
2023-12-07 21:05:27 -08:00
|
|
|
|
:param strict: If True, apply STRICT mode to table
|
2022-07-27 17:28:46 -07:00
|
|
|
|
"""
|
2022-07-28 08:13:47 -07:00
|
|
|
|
|
2021-08-13 04:32:40 -07:00
|
|
|
|
#: The ``rowid`` of the last inserted, updated or selected row.
|
2021-08-10 16:09:28 -07:00
|
|
|
|
last_rowid: Optional[int] = None
|
2021-08-13 04:32:40 -07:00
|
|
|
|
#: The primary key of the last inserted, updated or selected row.
|
2021-08-10 16:09:28 -07:00
|
|
|
|
last_pk: Optional[Any] = None
|
2020-04-12 20:22:32 -07:00
|
|
|
|
|
2019-07-22 16:30:54 -07:00
|
|
|
|
def __init__(
|
|
|
|
|
|
self,
|
2021-08-10 16:09:28 -07:00
|
|
|
|
db: Database,
|
|
|
|
|
|
name: str,
|
2021-08-18 15:25:18 -07:00
|
|
|
|
pk: Optional[Any] = None,
|
|
|
|
|
|
foreign_keys: Optional[ForeignKeysType] = None,
|
|
|
|
|
|
column_order: Optional[List[str]] = None,
|
2022-11-15 22:25:26 -08:00
|
|
|
|
not_null: Optional[Iterable[str]] = None,
|
2021-08-18 15:25:18 -07:00
|
|
|
|
defaults: Optional[Dict[str, Any]] = None,
|
|
|
|
|
|
batch_size: int = 100,
|
2021-11-14 18:19:28 -08:00
|
|
|
|
hash_id: Optional[str] = None,
|
2022-03-01 16:00:51 -08:00
|
|
|
|
hash_id_columns: Optional[Iterable[str]] = None,
|
2021-08-18 15:25:18 -07:00
|
|
|
|
alter: bool = False,
|
|
|
|
|
|
ignore: bool = False,
|
|
|
|
|
|
replace: bool = False,
|
|
|
|
|
|
extracts: Optional[Union[Dict[str, str], List[str]]] = None,
|
|
|
|
|
|
conversions: Optional[dict] = None,
|
2022-03-01 16:00:51 -08:00
|
|
|
|
columns: Optional[Dict[str, Any]] = None,
|
2023-12-07 21:05:27 -08:00
|
|
|
|
strict: bool = False,
|
2019-07-22 16:30:54 -07:00
|
|
|
|
):
|
2019-08-23 15:19:41 +03:00
|
|
|
|
super().__init__(db, name)
|
2019-07-22 16:30:54 -07:00
|
|
|
|
self._defaults = dict(
|
|
|
|
|
|
pk=pk,
|
|
|
|
|
|
foreign_keys=foreign_keys,
|
|
|
|
|
|
column_order=column_order,
|
|
|
|
|
|
not_null=not_null,
|
|
|
|
|
|
defaults=defaults,
|
|
|
|
|
|
batch_size=batch_size,
|
|
|
|
|
|
hash_id=hash_id,
|
2022-03-01 16:00:51 -08:00
|
|
|
|
hash_id_columns=hash_id_columns,
|
2019-07-22 16:30:54 -07:00
|
|
|
|
alter=alter,
|
|
|
|
|
|
ignore=ignore,
|
2019-12-27 09:15:31 +00:00
|
|
|
|
replace=replace,
|
2019-07-23 10:00:42 -07:00
|
|
|
|
extracts=extracts,
|
2020-01-30 16:24:30 -08:00
|
|
|
|
conversions=conversions or {},
|
2020-04-17 16:53:25 -07:00
|
|
|
|
columns=columns,
|
2023-12-07 21:05:27 -08:00
|
|
|
|
strict=strict,
|
2019-07-22 16:30:54 -07:00
|
|
|
|
)
|
2018-07-31 17:35:36 -07:00
|
|
|
|
|
2021-08-10 16:09:28 -07:00
|
|
|
|
def __repr__(self) -> str:
|
2018-07-31 17:35:36 -07:00
|
|
|
|
return "<Table {}{}>".format(
|
2019-07-22 17:05:51 -07:00
|
|
|
|
self.name,
|
2024-01-30 18:18:52 -08:00
|
|
|
|
(
|
|
|
|
|
|
" (does not exist yet)"
|
|
|
|
|
|
if not self.exists()
|
|
|
|
|
|
else " ({})".format(", ".join(c.name for c in self.columns))
|
|
|
|
|
|
),
|
2018-07-31 17:35:36 -07:00
|
|
|
|
)
|
2018-07-28 06:43:18 -07:00
|
|
|
|
|
2021-01-03 12:19:34 -08:00
|
|
|
|
@property
|
2021-08-10 16:09:28 -07:00
|
|
|
|
def count(self) -> int:
|
|
|
|
|
|
"Count of the rows in this table - optionally from the table count cache, if configured."
|
2021-01-03 12:19:34 -08:00
|
|
|
|
if self.db.use_counts_table:
|
|
|
|
|
|
counts = self.db.cached_counts([self.name])
|
|
|
|
|
|
if counts:
|
|
|
|
|
|
return next(iter(counts.values()))
|
2021-08-01 22:05:03 -07:00
|
|
|
|
return self.count_where()
|
2021-01-03 12:19:34 -08:00
|
|
|
|
|
2021-08-18 14:55:37 -07:00
|
|
|
|
def exists(self) -> bool:
|
2020-02-08 15:56:03 -08:00
|
|
|
|
return self.name in self.db.table_names()
|
|
|
|
|
|
|
2019-01-24 21:06:41 -08:00
|
|
|
|
@property
|
2021-08-10 16:09:28 -07:00
|
|
|
|
def pks(self) -> List[str]:
|
2026-07-06 21:34:02 -07:00
|
|
|
|
"""
|
|
|
|
|
|
Primary key columns for this table, 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 the order of the
|
|
|
|
|
|
columns in the table. SQLite uses the declaration order to resolve
|
|
|
|
|
|
implicit foreign key references, so this order matters.
|
|
|
|
|
|
"""
|
|
|
|
|
|
pk_columns = sorted(
|
|
|
|
|
|
(column for column in self.columns if column.is_pk),
|
|
|
|
|
|
key=lambda column: column.is_pk,
|
|
|
|
|
|
)
|
|
|
|
|
|
names = [column.name for column in pk_columns]
|
2019-07-28 15:44:33 +03:00
|
|
|
|
if not names:
|
|
|
|
|
|
names = ["rowid"]
|
|
|
|
|
|
return names
|
2019-01-24 21:06:41 -08:00
|
|
|
|
|
2021-06-19 08:12:29 -07:00
|
|
|
|
@property
|
2021-08-10 16:09:28 -07:00
|
|
|
|
def use_rowid(self) -> bool:
|
|
|
|
|
|
"Does this table use ``rowid`` for its primary key (no other primary keys are specified)?"
|
2021-06-19 08:12:29 -07:00
|
|
|
|
return not any(column for column in self.columns if column.is_pk)
|
|
|
|
|
|
|
2021-08-10 16:09:28 -07:00
|
|
|
|
def get(self, pk_values: Union[list, tuple, str, int]) -> dict:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Return row (as dictionary) for the specified primary key.
|
|
|
|
|
|
|
2022-03-11 09:38:34 -08:00
|
|
|
|
Raises ``sqlite_utils.db.NotFoundError`` if a matching row cannot be found.
|
2021-08-10 16:09:28 -07:00
|
|
|
|
|
2022-03-11 09:38:34 -08:00
|
|
|
|
:param pk_values: A single value, or a tuple of values for tables that have a compound primary key
|
2021-08-10 16:09:28 -07:00
|
|
|
|
"""
|
2019-07-14 21:28:51 -07:00
|
|
|
|
if not isinstance(pk_values, (list, tuple)):
|
|
|
|
|
|
pk_values = [pk_values]
|
|
|
|
|
|
pks = self.pks
|
2019-07-28 15:44:33 +03:00
|
|
|
|
last_pk = pk_values[0] if len(pks) == 1 else pk_values
|
|
|
|
|
|
if len(pks) != len(pk_values):
|
2019-07-14 21:28:51 -07:00
|
|
|
|
raise NotFoundError(
|
|
|
|
|
|
"Need {} primary key value{}".format(
|
2019-07-28 15:44:33 +03:00
|
|
|
|
len(pks), "" if len(pks) == 1 else "s"
|
2019-07-14 21:28:51 -07:00
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2025-11-23 20:43:26 -08:00
|
|
|
|
wheres = ["{} = ?".format(quote_identifier(pk_name)) for pk_name in pks]
|
2019-07-14 21:28:51 -07:00
|
|
|
|
rows = self.rows_where(" and ".join(wheres), pk_values)
|
|
|
|
|
|
try:
|
|
|
|
|
|
row = list(rows)[0]
|
|
|
|
|
|
self.last_pk = last_pk
|
|
|
|
|
|
return row
|
|
|
|
|
|
except IndexError:
|
|
|
|
|
|
raise NotFoundError
|
|
|
|
|
|
|
2018-07-28 15:41:18 -07:00
|
|
|
|
@property
|
2021-08-10 16:09:28 -07:00
|
|
|
|
def foreign_keys(self) -> List["ForeignKey"]:
|
2026-07-05 13:59:25 -07:00
|
|
|
|
"""
|
|
|
|
|
|
List of foreign keys defined on this table.
|
|
|
|
|
|
|
|
|
|
|
|
Compound (multi-column) foreign keys are returned as a single
|
|
|
|
|
|
``ForeignKey`` with ``is_compound=True`` and populated
|
|
|
|
|
|
``columns``/``other_columns`` lists.
|
|
|
|
|
|
"""
|
|
|
|
|
|
# PRAGMA foreign_key_list returns one row per column, grouped by "id"
|
|
|
|
|
|
# with "seq" giving the column order within a compound foreign key.
|
|
|
|
|
|
by_id: Dict[int, list] = {}
|
2020-09-07 14:56:59 -07:00
|
|
|
|
for row in self.db.execute(
|
2025-11-23 20:43:26 -08:00
|
|
|
|
"PRAGMA foreign_key_list({})".format(quote_identifier(self.name))
|
2018-07-28 15:41:18 -07:00
|
|
|
|
).fetchall():
|
|
|
|
|
|
if row is not None:
|
|
|
|
|
|
id, seq, table_name, from_, to_, on_update, on_delete, match = row
|
2026-07-05 14:40:57 -07:00
|
|
|
|
by_id.setdefault(id, []).append(
|
|
|
|
|
|
(seq, table_name, from_, to_, on_update, on_delete)
|
|
|
|
|
|
)
|
2026-07-05 13:59:25 -07:00
|
|
|
|
fks = []
|
|
|
|
|
|
for id in sorted(by_id):
|
|
|
|
|
|
rows = sorted(by_id[id]) # order columns by seq
|
|
|
|
|
|
other_table = rows[0][1]
|
2026-07-05 15:10:21 -07:00
|
|
|
|
columns = tuple(row[2] for row in rows)
|
|
|
|
|
|
other_columns = tuple(row[3] for row in rows)
|
2026-07-05 14:42:30 -07:00
|
|
|
|
if all(c is None for c in other_columns):
|
|
|
|
|
|
# "REFERENCES other_table" with no columns - the pragma
|
|
|
|
|
|
# returns None, meaning the other table's primary key
|
2026-07-05 15:10:21 -07:00
|
|
|
|
other_table_pks = tuple(self.db.table(other_table).pks)
|
2026-07-05 14:42:30 -07:00
|
|
|
|
if len(other_table_pks) == len(columns):
|
|
|
|
|
|
other_columns = other_table_pks
|
2026-07-05 13:59:25 -07:00
|
|
|
|
is_compound = len(rows) > 1
|
|
|
|
|
|
fks.append(
|
|
|
|
|
|
ForeignKey(
|
|
|
|
|
|
table=self.name,
|
|
|
|
|
|
column=None if is_compound else columns[0],
|
|
|
|
|
|
other_table=other_table,
|
|
|
|
|
|
other_column=None if is_compound else other_columns[0],
|
|
|
|
|
|
columns=columns,
|
|
|
|
|
|
other_columns=other_columns,
|
|
|
|
|
|
is_compound=is_compound,
|
2026-07-05 14:40:57 -07:00
|
|
|
|
on_update=rows[0][4],
|
|
|
|
|
|
on_delete=rows[0][5],
|
2018-07-28 15:41:18 -07:00
|
|
|
|
)
|
2026-07-05 13:59:25 -07:00
|
|
|
|
)
|
2018-07-28 15:41:18 -07:00
|
|
|
|
return fks
|
|
|
|
|
|
|
2020-11-04 19:53:32 -08:00
|
|
|
|
@property
|
2021-08-10 16:09:28 -07:00
|
|
|
|
def virtual_table_using(self) -> Optional[str]:
|
|
|
|
|
|
"Type of virtual table, or ``None`` if this is not a virtual table."
|
2020-11-04 19:53:32 -08:00
|
|
|
|
match = _virtual_table_using_re.match(self.schema)
|
|
|
|
|
|
if match is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
return match.groupdict()["using"].upper()
|
|
|
|
|
|
|
2018-07-31 18:31:29 -07:00
|
|
|
|
@property
|
2021-08-10 16:09:28 -07:00
|
|
|
|
def indexes(self) -> List[Index]:
|
|
|
|
|
|
"List of indexes defined on this table."
|
2018-08-01 08:29:53 -07:00
|
|
|
|
sql = 'PRAGMA index_list("{}")'.format(self.name)
|
2018-07-31 18:31:29 -07:00
|
|
|
|
indexes = []
|
2018-08-02 08:17:29 -07:00
|
|
|
|
for row in self.db.execute_returning_dicts(sql):
|
|
|
|
|
|
index_name = row["name"]
|
|
|
|
|
|
index_name_quoted = (
|
|
|
|
|
|
'"{}"'.format(index_name)
|
|
|
|
|
|
if not index_name.startswith('"')
|
|
|
|
|
|
else index_name
|
|
|
|
|
|
)
|
|
|
|
|
|
column_sql = "PRAGMA index_info({})".format(index_name_quoted)
|
2018-08-01 08:29:53 -07:00
|
|
|
|
columns = []
|
2020-09-07 14:56:59 -07:00
|
|
|
|
for seqno, cid, name in self.db.execute(column_sql).fetchall():
|
2018-08-01 08:29:53 -07:00
|
|
|
|
columns.append(name)
|
2018-08-02 08:17:29 -07:00
|
|
|
|
row["columns"] = columns
|
2019-05-24 17:41:04 -07:00
|
|
|
|
# These columns may be missing on older SQLite versions:
|
2018-08-02 08:17:29 -07:00
|
|
|
|
for key, default in {"origin": "c", "partial": 0}.items():
|
|
|
|
|
|
if key not in row:
|
|
|
|
|
|
row[key] = default
|
|
|
|
|
|
indexes.append(Index(**row))
|
2018-07-31 18:31:29 -07:00
|
|
|
|
return indexes
|
|
|
|
|
|
|
2021-06-02 20:51:27 -07:00
|
|
|
|
@property
|
2021-08-10 16:09:28 -07:00
|
|
|
|
def xindexes(self) -> List[XIndex]:
|
|
|
|
|
|
"List of indexes defined on this table using the more detailed ``XIndex`` format."
|
2021-06-02 20:51:27 -07:00
|
|
|
|
sql = 'PRAGMA index_list("{}")'.format(self.name)
|
|
|
|
|
|
indexes = []
|
|
|
|
|
|
for row in self.db.execute_returning_dicts(sql):
|
|
|
|
|
|
index_name = row["name"]
|
|
|
|
|
|
index_name_quoted = (
|
|
|
|
|
|
'"{}"'.format(index_name)
|
|
|
|
|
|
if not index_name.startswith('"')
|
|
|
|
|
|
else index_name
|
|
|
|
|
|
)
|
|
|
|
|
|
column_sql = "PRAGMA index_xinfo({})".format(index_name_quoted)
|
|
|
|
|
|
index_columns = []
|
|
|
|
|
|
for info in self.db.execute(column_sql).fetchall():
|
|
|
|
|
|
index_columns.append(XIndexColumn(*info))
|
|
|
|
|
|
indexes.append(XIndex(index_name, index_columns))
|
|
|
|
|
|
return indexes
|
|
|
|
|
|
|
2019-09-02 17:09:41 -07:00
|
|
|
|
@property
|
2021-08-10 16:09:28 -07:00
|
|
|
|
def triggers(self) -> List[Trigger]:
|
|
|
|
|
|
"List of triggers defined on this table."
|
2019-09-02 17:09:41 -07:00
|
|
|
|
return [
|
|
|
|
|
|
Trigger(*r)
|
2020-09-07 14:56:59 -07:00
|
|
|
|
for r in self.db.execute(
|
2019-09-02 17:09:41 -07:00
|
|
|
|
"select name, tbl_name, sql from sqlite_master where type = 'trigger'"
|
|
|
|
|
|
" and tbl_name = ?",
|
|
|
|
|
|
(self.name,),
|
|
|
|
|
|
).fetchall()
|
|
|
|
|
|
]
|
|
|
|
|
|
|
2021-01-01 18:10:04 -08:00
|
|
|
|
@property
|
2021-08-10 16:09:28 -07:00
|
|
|
|
def triggers_dict(self) -> Dict[str, str]:
|
|
|
|
|
|
"``{trigger_name: sql}`` dictionary of triggers defined on this table."
|
2021-01-01 18:10:04 -08:00
|
|
|
|
return {trigger.name: trigger.sql for trigger in self.triggers}
|
|
|
|
|
|
|
2022-08-27 15:41:10 -07:00
|
|
|
|
@property
|
|
|
|
|
|
def default_values(self) -> Dict[str, Any]:
|
|
|
|
|
|
"``{column_name: default_value}`` dictionary of default values for columns in this table."
|
|
|
|
|
|
return {
|
|
|
|
|
|
column.name: _decode_default_value(column.default_value)
|
|
|
|
|
|
for column in self.columns
|
|
|
|
|
|
if column.default_value is not None
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2021-11-29 14:19:30 -08:00
|
|
|
|
@property
|
|
|
|
|
|
def strict(self) -> bool:
|
|
|
|
|
|
"Is this a STRICT table?"
|
|
|
|
|
|
table_suffix = self.schema.split(")")[-1].strip().upper()
|
|
|
|
|
|
table_options = [bit.strip() for bit in table_suffix.split(",")]
|
|
|
|
|
|
return "STRICT" in table_options
|
|
|
|
|
|
|
2019-02-23 20:36:40 -08:00
|
|
|
|
def create(
|
2019-06-12 23:10:07 -07:00
|
|
|
|
self,
|
2021-08-18 15:25:18 -07:00
|
|
|
|
columns: Dict[str, Any],
|
2025-11-23 14:51:12 -08:00
|
|
|
|
pk: Optional[Any] = DEFAULT,
|
2025-11-23 15:53:19 -08:00
|
|
|
|
foreign_keys: Union[Optional[ForeignKeysType], Default] = DEFAULT,
|
|
|
|
|
|
column_order: Union[Optional[List[str]], Default] = DEFAULT,
|
|
|
|
|
|
not_null: Union[Optional[Iterable[str]], Default] = DEFAULT,
|
|
|
|
|
|
defaults: Union[Optional[Dict[str, Any]], Default] = DEFAULT,
|
|
|
|
|
|
hash_id: Union[Optional[str], Default] = DEFAULT,
|
|
|
|
|
|
hash_id_columns: Union[Optional[Iterable[str]], Default] = DEFAULT,
|
|
|
|
|
|
extracts: Union[Optional[Union[Dict[str, str], List[str]]], Default] = DEFAULT,
|
2022-02-05 17:28:53 -08:00
|
|
|
|
if_not_exists: bool = False,
|
2023-07-22 12:15:40 -07:00
|
|
|
|
replace: bool = False,
|
|
|
|
|
|
ignore: bool = False,
|
2022-08-27 16:17:55 -07:00
|
|
|
|
transform: bool = False,
|
2025-11-23 15:53:19 -08:00
|
|
|
|
strict: Union[bool, Default] = DEFAULT,
|
2021-08-10 16:09:28 -07:00
|
|
|
|
) -> "Table":
|
|
|
|
|
|
"""
|
|
|
|
|
|
Create a table with the specified columns.
|
|
|
|
|
|
|
|
|
|
|
|
See :ref:`python_api_explicit_create` for full details.
|
2022-03-11 09:38:34 -08:00
|
|
|
|
|
|
|
|
|
|
:param columns: Dictionary mapping column names to their types, for example ``{"name": str, "age": int}``
|
|
|
|
|
|
:param pk: String name of column to use as a primary key, or a tuple of strings for a compound primary key covering multiple columns
|
|
|
|
|
|
:param foreign_keys: List of foreign key definitions for this table
|
|
|
|
|
|
:param column_order: List specifying which columns should come first
|
|
|
|
|
|
:param not_null: List of columns that should be created as ``NOT NULL``
|
|
|
|
|
|
:param defaults: Dictionary specifying default values for columns
|
|
|
|
|
|
:param hash_id: Name of column to be used as a primary key containing a hash of the other columns
|
|
|
|
|
|
:param hash_id_columns: List of columns to be used when calculating the hash ID for a row
|
|
|
|
|
|
:param extracts: List or dictionary of columns to be extracted during inserts, see :ref:`python_api_extracts`
|
|
|
|
|
|
:param if_not_exists: Use ``CREATE TABLE IF NOT EXISTS``
|
2023-07-22 12:15:40 -07:00
|
|
|
|
:param replace: Drop and replace table if it already exists
|
|
|
|
|
|
:param ignore: Silently do nothing if table already exists
|
|
|
|
|
|
:param transform: If table already exists transform it to fit the specified schema
|
2023-12-07 21:05:27 -08:00
|
|
|
|
:param strict: Apply STRICT mode to table
|
2021-08-10 16:09:28 -07:00
|
|
|
|
"""
|
2025-11-23 14:51:12 -08:00
|
|
|
|
# Resolve defaults from _defaults (issue #655)
|
|
|
|
|
|
pk = self.value_or_default("pk", pk)
|
|
|
|
|
|
foreign_keys = self.value_or_default("foreign_keys", foreign_keys)
|
|
|
|
|
|
column_order = self.value_or_default("column_order", column_order)
|
|
|
|
|
|
not_null = self.value_or_default("not_null", not_null)
|
|
|
|
|
|
defaults = self.value_or_default("defaults", defaults)
|
|
|
|
|
|
hash_id = self.value_or_default("hash_id", hash_id)
|
|
|
|
|
|
hash_id_columns = self.value_or_default("hash_id_columns", hash_id_columns)
|
|
|
|
|
|
extracts = self.value_or_default("extracts", extracts)
|
|
|
|
|
|
strict = self.value_or_default("strict", strict)
|
|
|
|
|
|
|
|
|
|
|
|
# Store configuration in _defaults for subsequent operations (issue #655)
|
|
|
|
|
|
# Don't store pk if hash_id is set, since pk is derived from hash_id in that case
|
|
|
|
|
|
if pk is not None and hash_id is None:
|
|
|
|
|
|
self._defaults["pk"] = pk
|
|
|
|
|
|
if foreign_keys is not None:
|
|
|
|
|
|
self._defaults["foreign_keys"] = foreign_keys
|
|
|
|
|
|
if column_order is not None:
|
|
|
|
|
|
self._defaults["column_order"] = column_order
|
|
|
|
|
|
if not_null is not None:
|
|
|
|
|
|
self._defaults["not_null"] = not_null
|
|
|
|
|
|
if defaults is not None:
|
|
|
|
|
|
self._defaults["defaults"] = defaults
|
|
|
|
|
|
if hash_id is not None:
|
|
|
|
|
|
self._defaults["hash_id"] = hash_id
|
|
|
|
|
|
if hash_id_columns is not None:
|
|
|
|
|
|
self._defaults["hash_id_columns"] = hash_id_columns
|
|
|
|
|
|
if extracts is not None:
|
|
|
|
|
|
self._defaults["extracts"] = extracts
|
|
|
|
|
|
if strict:
|
|
|
|
|
|
self._defaults["strict"] = strict
|
|
|
|
|
|
|
2018-07-28 15:06:59 -07:00
|
|
|
|
columns = {name: value for (name, value) in columns.items()}
|
2026-06-21 10:43:19 -07:00
|
|
|
|
with self.db.atomic():
|
2019-01-27 22:26:45 -08:00
|
|
|
|
self.db.create_table(
|
|
|
|
|
|
self.name,
|
|
|
|
|
|
columns,
|
|
|
|
|
|
pk=pk,
|
More type annotations (#697)
* Add comprehensive type annotations
- mypy.ini: expanded configuration with module-specific settings
- hookspecs.py: type annotations for hook functions
- plugins.py: typed get_plugins() return value
- recipes.py: full type annotations for parsedate, parsedatetime, jsonsplit
- utils.py: extensive type annotations including Row type alias,
TypeTracker, ValueTracker, and all utility functions
- db.py: type annotations for Database methods (__exit__,
ensure_autocommit_off, tracer, register_function, etc.) and
Queryable class methods
- tests/test_docs.py: updated to match new signature display format
* Fix type errors caught by ty check
- Add type: ignore comments for external library type stub limitations
(csv.reader, click.progressbar, IOBase.name, Callable.__name__)
- Change Iterable to Sequence for SQL where_args parameters
- Use db.table() instead of db[name] for proper Table return type
- Fix rebuild_fts return type from None to Table
- Update test_tracer to expect fewer queries (optimization side effect)
* Fix mypy type errors
- Add type: ignore comments for runtime-valid patterns mypy can't verify
- Fix new_column_types annotation to Dict[str, Set[type]]
- Add type: ignore for Default sentinel values passed to create_table
* mypy skip tests directory
* Fix CI: exclude typing imports from recipe docs, skip mypy on tests
- Add Callable and Optional to exclusion list in _generate_convert_help()
- Regenerate docs/cli-reference.rst with cog
- Add [mypy-tests.*] ignore_errors = True to skip test type errors
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 22:11:47 -08:00
|
|
|
|
foreign_keys=foreign_keys, # type: ignore[arg-type]
|
|
|
|
|
|
column_order=column_order, # type: ignore[arg-type]
|
|
|
|
|
|
not_null=not_null, # type: ignore[arg-type]
|
|
|
|
|
|
defaults=defaults, # type: ignore[arg-type]
|
|
|
|
|
|
hash_id=hash_id, # type: ignore[arg-type]
|
|
|
|
|
|
hash_id_columns=hash_id_columns, # type: ignore[arg-type]
|
|
|
|
|
|
extracts=extracts, # type: ignore[arg-type]
|
2022-02-05 17:28:53 -08:00
|
|
|
|
if_not_exists=if_not_exists,
|
2023-07-22 12:15:40 -07:00
|
|
|
|
replace=replace,
|
|
|
|
|
|
ignore=ignore,
|
2022-08-27 16:17:55 -07:00
|
|
|
|
transform=transform,
|
More type annotations (#697)
* Add comprehensive type annotations
- mypy.ini: expanded configuration with module-specific settings
- hookspecs.py: type annotations for hook functions
- plugins.py: typed get_plugins() return value
- recipes.py: full type annotations for parsedate, parsedatetime, jsonsplit
- utils.py: extensive type annotations including Row type alias,
TypeTracker, ValueTracker, and all utility functions
- db.py: type annotations for Database methods (__exit__,
ensure_autocommit_off, tracer, register_function, etc.) and
Queryable class methods
- tests/test_docs.py: updated to match new signature display format
* Fix type errors caught by ty check
- Add type: ignore comments for external library type stub limitations
(csv.reader, click.progressbar, IOBase.name, Callable.__name__)
- Change Iterable to Sequence for SQL where_args parameters
- Use db.table() instead of db[name] for proper Table return type
- Fix rebuild_fts return type from None to Table
- Update test_tracer to expect fewer queries (optimization side effect)
* Fix mypy type errors
- Add type: ignore comments for runtime-valid patterns mypy can't verify
- Fix new_column_types annotation to Dict[str, Set[type]]
- Add type: ignore for Default sentinel values passed to create_table
* mypy skip tests directory
* Fix CI: exclude typing imports from recipe docs, skip mypy on tests
- Add Callable and Optional to exclusion list in _generate_convert_help()
- Regenerate docs/cli-reference.rst with cog
- Add [mypy-tests.*] ignore_errors = True to skip test type errors
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 22:11:47 -08:00
|
|
|
|
strict=strict, # type: ignore[arg-type]
|
2019-01-27 22:26:45 -08:00
|
|
|
|
)
|
2018-08-05 18:42:43 -07:00
|
|
|
|
return self
|
2018-07-28 06:43:18 -07:00
|
|
|
|
|
2022-07-15 14:29:52 -07:00
|
|
|
|
def duplicate(self, new_name: str) -> "Table":
|
2022-07-16 05:21:36 +08:00
|
|
|
|
"""
|
2022-07-15 14:29:52 -07:00
|
|
|
|
Create a duplicate of this table, copying across the schema and all row data.
|
2022-07-16 05:21:36 +08:00
|
|
|
|
|
2022-07-15 14:29:52 -07:00
|
|
|
|
:param new_name: Name of the new table
|
2022-07-16 05:21:36 +08:00
|
|
|
|
"""
|
2022-07-15 14:45:14 -07:00
|
|
|
|
if not self.exists():
|
|
|
|
|
|
raise NoTable(f"Table {self.name} does not exist")
|
2026-06-21 10:43:19 -07:00
|
|
|
|
with self.db.atomic():
|
2025-11-23 20:43:26 -08:00
|
|
|
|
sql = "CREATE TABLE {} AS SELECT * FROM {};".format(
|
|
|
|
|
|
quote_identifier(new_name),
|
|
|
|
|
|
quote_identifier(self.name),
|
2022-07-16 05:21:36 +08:00
|
|
|
|
)
|
|
|
|
|
|
self.db.execute(sql)
|
More type annotations (#697)
* Add comprehensive type annotations
- mypy.ini: expanded configuration with module-specific settings
- hookspecs.py: type annotations for hook functions
- plugins.py: typed get_plugins() return value
- recipes.py: full type annotations for parsedate, parsedatetime, jsonsplit
- utils.py: extensive type annotations including Row type alias,
TypeTracker, ValueTracker, and all utility functions
- db.py: type annotations for Database methods (__exit__,
ensure_autocommit_off, tracer, register_function, etc.) and
Queryable class methods
- tests/test_docs.py: updated to match new signature display format
* Fix type errors caught by ty check
- Add type: ignore comments for external library type stub limitations
(csv.reader, click.progressbar, IOBase.name, Callable.__name__)
- Change Iterable to Sequence for SQL where_args parameters
- Use db.table() instead of db[name] for proper Table return type
- Fix rebuild_fts return type from None to Table
- Update test_tracer to expect fewer queries (optimization side effect)
* Fix mypy type errors
- Add type: ignore comments for runtime-valid patterns mypy can't verify
- Fix new_column_types annotation to Dict[str, Set[type]]
- Add type: ignore for Default sentinel values passed to create_table
* mypy skip tests directory
* Fix CI: exclude typing imports from recipe docs, skip mypy on tests
- Add Callable and Optional to exclusion list in _generate_convert_help()
- Regenerate docs/cli-reference.rst with cog
- Add [mypy-tests.*] ignore_errors = True to skip test type errors
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 22:11:47 -08:00
|
|
|
|
return self.db.table(new_name)
|
2022-07-16 05:21:36 +08:00
|
|
|
|
|
2020-09-21 21:20:01 -07:00
|
|
|
|
def transform(
|
|
|
|
|
|
self,
|
2020-09-21 23:39:10 -07:00
|
|
|
|
*,
|
2022-03-11 09:38:34 -08:00
|
|
|
|
types: Optional[dict] = None,
|
|
|
|
|
|
rename: Optional[dict] = None,
|
|
|
|
|
|
drop: Optional[Iterable] = None,
|
2022-03-11 09:54:17 -08:00
|
|
|
|
pk: Optional[Any] = DEFAULT,
|
2022-08-27 16:17:55 -07:00
|
|
|
|
not_null: Optional[Iterable[str]] = None,
|
2022-03-11 09:38:34 -08:00
|
|
|
|
defaults: Optional[Dict[str, Any]] = None,
|
2023-08-17 17:48:08 -07:00
|
|
|
|
drop_foreign_keys: Optional[Iterable[str]] = None,
|
|
|
|
|
|
add_foreign_keys: Optional[ForeignKeysType] = None,
|
|
|
|
|
|
foreign_keys: Optional[ForeignKeysType] = None,
|
2022-03-11 09:38:34 -08:00
|
|
|
|
column_order: Optional[List[str]] = None,
|
2023-07-22 15:32:09 -07:00
|
|
|
|
keep_table: Optional[str] = None,
|
2026-07-11 16:43:37 -07:00
|
|
|
|
strict: Optional[bool] = None,
|
2021-08-10 16:09:28 -07:00
|
|
|
|
) -> "Table":
|
|
|
|
|
|
"""
|
|
|
|
|
|
Apply an advanced alter table, including operations that are not supported by
|
|
|
|
|
|
``ALTER TABLE`` in SQLite itself.
|
|
|
|
|
|
|
|
|
|
|
|
See :ref:`python_api_transform` for full details.
|
2022-03-11 09:38:34 -08:00
|
|
|
|
|
2026-07-12 08:43:51 -07:00
|
|
|
|
Raises :py:class:`sqlite_utils.db.TransactionError` if called while a
|
|
|
|
|
|
transaction is open with ``PRAGMA foreign_keys`` enabled and the table
|
|
|
|
|
|
is referenced by foreign keys with destructive ``ON DELETE`` actions -
|
|
|
|
|
|
see :ref:`python_api_transform_foreign_keys_transactions`.
|
|
|
|
|
|
|
2022-03-11 09:38:34 -08:00
|
|
|
|
:param types: Columns that should have their type changed, for example ``{"weight": float}``
|
|
|
|
|
|
:param rename: Columns to rename, for example ``{"headline": "title"}``
|
|
|
|
|
|
:param drop: Columns to drop
|
|
|
|
|
|
:param pk: New primary key for the table
|
|
|
|
|
|
:param not_null: Columns to set as ``NOT NULL``
|
|
|
|
|
|
:param defaults: Default values for columns
|
2026-07-05 14:33:15 -07:00
|
|
|
|
:param drop_foreign_keys: Foreign key constraints to remove - a column name
|
|
|
|
|
|
drops any foreign key that column participates in, a tuple of column names
|
|
|
|
|
|
drops the compound foreign key with exactly those columns
|
2023-08-17 17:48:08 -07:00
|
|
|
|
:param add_foreign_keys: List of foreign keys to add to the table
|
|
|
|
|
|
:param foreign_keys: List of foreign keys to set for the table, replacing any existing foreign keys
|
2022-03-11 09:38:34 -08:00
|
|
|
|
:param column_order: List of strings specifying a full or partial column order
|
2023-07-22 15:32:09 -07:00
|
|
|
|
to use when creating the table
|
|
|
|
|
|
:param keep_table: If specified, the existing table will be renamed to this and will not be
|
|
|
|
|
|
dropped
|
2026-07-11 16:43:37 -07:00
|
|
|
|
: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.
|
2021-08-10 16:09:28 -07:00
|
|
|
|
"""
|
2026-07-04 19:19:24 +00:00
|
|
|
|
if not self.exists():
|
|
|
|
|
|
raise ValueError("Cannot transform a table that doesn't exist yet")
|
2020-09-21 21:20:01 -07:00
|
|
|
|
sqls = self.transform_sql(
|
2020-09-21 23:39:10 -07:00
|
|
|
|
types=types,
|
2020-09-21 21:20:01 -07:00
|
|
|
|
rename=rename,
|
2020-09-22 00:46:32 -07:00
|
|
|
|
drop=drop,
|
2020-09-21 21:20:01 -07:00
|
|
|
|
pk=pk,
|
|
|
|
|
|
not_null=not_null,
|
|
|
|
|
|
defaults=defaults,
|
|
|
|
|
|
drop_foreign_keys=drop_foreign_keys,
|
2023-08-17 17:48:08 -07:00
|
|
|
|
add_foreign_keys=add_foreign_keys,
|
|
|
|
|
|
foreign_keys=foreign_keys,
|
2020-09-24 08:43:55 -07:00
|
|
|
|
column_order=column_order,
|
2023-07-22 15:32:09 -07:00
|
|
|
|
keep_table=keep_table,
|
2026-07-11 16:43:37 -07:00
|
|
|
|
strict=strict,
|
2020-09-21 21:20:01 -07:00
|
|
|
|
)
|
2026-06-21 10:43:19 -07:00
|
|
|
|
pragma_foreign_keys_was_on = bool(
|
|
|
|
|
|
self.db.execute("PRAGMA foreign_keys").fetchone()[0]
|
|
|
|
|
|
)
|
|
|
|
|
|
already_in_transaction = self.db.conn.in_transaction
|
|
|
|
|
|
should_disable_foreign_keys = (
|
|
|
|
|
|
pragma_foreign_keys_was_on and not already_in_transaction
|
|
|
|
|
|
)
|
|
|
|
|
|
should_defer_foreign_keys = (
|
|
|
|
|
|
pragma_foreign_keys_was_on and already_in_transaction
|
|
|
|
|
|
)
|
2026-07-12 08:43:51 -07:00
|
|
|
|
if should_defer_foreign_keys:
|
|
|
|
|
|
# PRAGMA foreign_keys is a no-op inside a transaction, and
|
|
|
|
|
|
# defer_foreign_keys only defers violation checks, not ON DELETE
|
|
|
|
|
|
# actions - so dropping the old table would still fire destructive
|
|
|
|
|
|
# actions on any tables that reference it. Refuse rather than
|
|
|
|
|
|
# silently modify or delete those rows.
|
|
|
|
|
|
destructive_fks = [
|
|
|
|
|
|
(table.name, fk)
|
|
|
|
|
|
for table in self.db.tables
|
|
|
|
|
|
for fk in table.foreign_keys
|
|
|
|
|
|
if fk.other_table == self.name
|
|
|
|
|
|
and fk.on_delete in ("CASCADE", "SET NULL", "SET DEFAULT")
|
|
|
|
|
|
]
|
|
|
|
|
|
if destructive_fks:
|
|
|
|
|
|
raise TransactionError(
|
|
|
|
|
|
"Cannot transform table {table} while a transaction is open: "
|
|
|
|
|
|
"PRAGMA foreign_keys cannot be changed inside a transaction, "
|
|
|
|
|
|
"and the table is referenced by foreign keys with ON DELETE "
|
|
|
|
|
|
"actions that would fire when the old table is dropped: "
|
|
|
|
|
|
"{fks}. Call transform() outside of the transaction, or "
|
|
|
|
|
|
'execute "PRAGMA foreign_keys = off" before opening it.'.format(
|
|
|
|
|
|
table=self.name,
|
|
|
|
|
|
fks=", ".join(
|
|
|
|
|
|
"{}.{} (ON DELETE {})".format(
|
|
|
|
|
|
table_name, ", ".join(fk.columns), fk.on_delete
|
|
|
|
|
|
)
|
|
|
|
|
|
for table_name, fk in destructive_fks
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
2026-06-21 10:43:19 -07:00
|
|
|
|
defer_foreign_keys_was_on = False
|
2020-09-21 21:20:01 -07:00
|
|
|
|
try:
|
2026-06-21 10:43:19 -07:00
|
|
|
|
if should_disable_foreign_keys:
|
2020-09-22 17:12:56 -07:00
|
|
|
|
self.db.execute("PRAGMA foreign_keys=0;")
|
2026-06-21 10:43:19 -07:00
|
|
|
|
elif should_defer_foreign_keys:
|
|
|
|
|
|
defer_foreign_keys_was_on = bool(
|
|
|
|
|
|
self.db.execute("PRAGMA defer_foreign_keys").fetchone()[0]
|
|
|
|
|
|
)
|
|
|
|
|
|
if not defer_foreign_keys_was_on:
|
|
|
|
|
|
self.db.execute("PRAGMA defer_foreign_keys=ON;")
|
|
|
|
|
|
with self.db.atomic():
|
2020-09-21 21:20:01 -07:00
|
|
|
|
for sql in sqls:
|
|
|
|
|
|
self.db.execute(sql)
|
2020-09-22 17:12:56 -07:00
|
|
|
|
# Run the foreign_key_check before we commit
|
|
|
|
|
|
if pragma_foreign_keys_was_on:
|
2026-06-21 10:43:19 -07:00
|
|
|
|
foreign_key_violations = self.db.execute(
|
|
|
|
|
|
"PRAGMA foreign_key_check;"
|
|
|
|
|
|
).fetchall()
|
|
|
|
|
|
if foreign_key_violations:
|
|
|
|
|
|
raise sqlite3.IntegrityError("FOREIGN KEY constraint failed")
|
2020-09-21 21:20:01 -07:00
|
|
|
|
finally:
|
2026-06-21 10:43:19 -07:00
|
|
|
|
if should_defer_foreign_keys and not defer_foreign_keys_was_on:
|
|
|
|
|
|
self.db.execute("PRAGMA defer_foreign_keys=OFF;")
|
|
|
|
|
|
if should_disable_foreign_keys:
|
2020-09-22 17:12:56 -07:00
|
|
|
|
self.db.execute("PRAGMA foreign_keys=1;")
|
2026-07-11 16:43:37 -07:00
|
|
|
|
if strict is not None:
|
|
|
|
|
|
self._defaults["strict"] = strict
|
2020-09-21 21:20:01 -07:00
|
|
|
|
return self
|
|
|
|
|
|
|
|
|
|
|
|
def transform_sql(
|
|
|
|
|
|
self,
|
2020-09-21 23:39:10 -07:00
|
|
|
|
*,
|
2023-07-22 15:32:09 -07:00
|
|
|
|
types: Optional[dict] = None,
|
|
|
|
|
|
rename: Optional[dict] = None,
|
|
|
|
|
|
drop: Optional[Iterable] = None,
|
|
|
|
|
|
pk: Optional[Any] = DEFAULT,
|
|
|
|
|
|
not_null: Optional[Iterable[str]] = None,
|
|
|
|
|
|
defaults: Optional[Dict[str, Any]] = None,
|
|
|
|
|
|
drop_foreign_keys: Optional[Iterable] = None,
|
2023-08-17 17:48:08 -07:00
|
|
|
|
add_foreign_keys: Optional[ForeignKeysType] = None,
|
|
|
|
|
|
foreign_keys: Optional[ForeignKeysType] = None,
|
2023-07-22 15:32:09 -07:00
|
|
|
|
column_order: Optional[List[str]] = None,
|
|
|
|
|
|
tmp_suffix: Optional[str] = None,
|
|
|
|
|
|
keep_table: Optional[str] = None,
|
2026-07-11 16:43:37 -07:00
|
|
|
|
strict: Optional[bool] = None,
|
2021-08-10 16:09:28 -07:00
|
|
|
|
) -> List[str]:
|
2022-03-11 09:38:34 -08:00
|
|
|
|
"""
|
|
|
|
|
|
Return a list of SQL statements that should be executed in order to apply this transformation.
|
|
|
|
|
|
|
|
|
|
|
|
:param types: Columns that should have their type changed, for example ``{"weight": float}``
|
|
|
|
|
|
:param rename: Columns to rename, for example ``{"headline": "title"}``
|
|
|
|
|
|
:param drop: Columns to drop
|
|
|
|
|
|
:param pk: New primary key for the table
|
|
|
|
|
|
:param not_null: Columns to set as ``NOT NULL``
|
|
|
|
|
|
:param defaults: Default values for columns
|
2026-07-05 14:33:15 -07:00
|
|
|
|
:param drop_foreign_keys: Foreign key constraints to remove - a column name
|
|
|
|
|
|
drops any foreign key that column participates in, a tuple of column names
|
|
|
|
|
|
drops the compound foreign key with exactly those columns
|
2023-08-17 17:48:08 -07:00
|
|
|
|
:param add_foreign_keys: List of foreign keys to add to the table
|
|
|
|
|
|
:param foreign_keys: List of foreign keys to set for the table, replacing any existing foreign keys
|
2022-03-11 09:38:34 -08:00
|
|
|
|
:param column_order: List of strings specifying a full or partial column order
|
2023-07-22 15:32:09 -07:00
|
|
|
|
to use when creating the table
|
|
|
|
|
|
:param tmp_suffix: Suffix to use for the temporary table name
|
|
|
|
|
|
:param keep_table: If specified, the existing table will be renamed to this and will not be
|
|
|
|
|
|
dropped
|
2026-07-11 16:43:37 -07:00
|
|
|
|
: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.
|
2022-03-11 09:38:34 -08:00
|
|
|
|
"""
|
2026-07-11 16:43:37 -07:00
|
|
|
|
if strict is True and not self.db.supports_strict:
|
|
|
|
|
|
raise TransformError("SQLite does not support STRICT tables")
|
2020-09-21 23:39:10 -07:00
|
|
|
|
types = types or {}
|
2020-09-21 21:20:01 -07:00
|
|
|
|
rename = rename or {}
|
|
|
|
|
|
drop = drop or set()
|
2023-08-17 17:48:08 -07:00
|
|
|
|
|
2026-07-05 22:11:22 -07:00
|
|
|
|
# Resolve column references against the existing schema, matching
|
|
|
|
|
|
# case-insensitively the way SQLite does
|
|
|
|
|
|
existing_columns = self.columns_dict
|
|
|
|
|
|
types = {resolve_casing(c, existing_columns): t for c, t in types.items()}
|
|
|
|
|
|
rename = {resolve_casing(c, existing_columns): v for c, v in rename.items()}
|
|
|
|
|
|
drop = {resolve_casing(c, existing_columns) for c in drop}
|
|
|
|
|
|
if pk is not DEFAULT and pk is not None:
|
|
|
|
|
|
if isinstance(pk, str):
|
|
|
|
|
|
pk = resolve_casing(pk, existing_columns)
|
|
|
|
|
|
else:
|
|
|
|
|
|
pk = [resolve_casing(p, existing_columns) for p in pk]
|
|
|
|
|
|
if isinstance(not_null, dict):
|
|
|
|
|
|
not_null = {
|
|
|
|
|
|
resolve_casing(c, existing_columns): v
|
|
|
|
|
|
for c, v in cast(Dict[str, Any], not_null).items()
|
|
|
|
|
|
}
|
|
|
|
|
|
elif isinstance(not_null, set):
|
|
|
|
|
|
not_null = {resolve_casing(c, existing_columns) for c in not_null}
|
|
|
|
|
|
if defaults is not None:
|
|
|
|
|
|
defaults = {
|
|
|
|
|
|
resolve_casing(c, existing_columns): v for c, v in defaults.items()
|
|
|
|
|
|
}
|
|
|
|
|
|
if column_order is not None:
|
|
|
|
|
|
column_order = [resolve_casing(c, existing_columns) for c in column_order]
|
|
|
|
|
|
|
2023-08-17 17:48:08 -07:00
|
|
|
|
create_table_foreign_keys: List[ForeignKeyIndicator] = []
|
|
|
|
|
|
|
|
|
|
|
|
if foreign_keys is not None:
|
|
|
|
|
|
if add_foreign_keys is not None:
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
"Cannot specify both foreign_keys and add_foreign_keys"
|
|
|
|
|
|
)
|
|
|
|
|
|
if drop_foreign_keys is not None:
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
"Cannot specify both foreign_keys and drop_foreign_keys"
|
|
|
|
|
|
)
|
|
|
|
|
|
create_table_foreign_keys.extend(foreign_keys)
|
|
|
|
|
|
else:
|
|
|
|
|
|
# Construct foreign_keys from current, plus add_foreign_keys, minus drop_foreign_keys
|
2026-07-05 22:11:22 -07:00
|
|
|
|
# The casing of columns in a foreign key definition can differ
|
|
|
|
|
|
# from the casing of the columns themselves, so these comparisons
|
|
|
|
|
|
# are all case-folded
|
|
|
|
|
|
dropped_columns_folded = {fold_identifier_case(c) for c in drop}
|
|
|
|
|
|
renamed_columns_folded = {
|
|
|
|
|
|
fold_identifier_case(k): v for k, v in rename.items()
|
|
|
|
|
|
}
|
2026-07-05 14:33:15 -07:00
|
|
|
|
|
|
|
|
|
|
def fk_should_be_dropped(fk: ForeignKey) -> bool:
|
2026-07-05 22:11:22 -07:00
|
|
|
|
fk_columns_folded = tuple(fold_identifier_case(c) for c in fk.columns)
|
2026-07-05 14:33:15 -07:00
|
|
|
|
if drop_foreign_keys is not None:
|
|
|
|
|
|
for spec in drop_foreign_keys:
|
|
|
|
|
|
if isinstance(spec, str):
|
|
|
|
|
|
# A column name matches any foreign key it participates in
|
2026-07-05 22:11:22 -07:00
|
|
|
|
if fold_identifier_case(spec) in fk_columns_folded:
|
2026-07-05 14:33:15 -07:00
|
|
|
|
return True
|
2026-07-05 22:11:22 -07:00
|
|
|
|
elif (
|
|
|
|
|
|
tuple(fold_identifier_case(s) for s in spec)
|
|
|
|
|
|
== fk_columns_folded
|
|
|
|
|
|
):
|
2026-07-05 14:33:15 -07:00
|
|
|
|
# A tuple/list must match a compound key's columns exactly
|
|
|
|
|
|
return True
|
|
|
|
|
|
# Dropping any of a foreign key's columns drops the whole key
|
2026-07-05 22:11:22 -07:00
|
|
|
|
return any(
|
|
|
|
|
|
column in dropped_columns_folded for column in fk_columns_folded
|
|
|
|
|
|
)
|
2026-07-05 14:33:15 -07:00
|
|
|
|
|
|
|
|
|
|
def fk_with_renamed_columns(fk: ForeignKey) -> ForeignKey:
|
2026-07-05 15:36:46 -07:00
|
|
|
|
columns = tuple(
|
2026-07-05 22:11:22 -07:00
|
|
|
|
renamed_columns_folded.get(fold_identifier_case(column)) or column
|
|
|
|
|
|
for column in fk.columns
|
2026-07-05 15:36:46 -07:00
|
|
|
|
)
|
2026-07-05 14:33:15 -07:00
|
|
|
|
if fk.is_compound:
|
|
|
|
|
|
return ForeignKey(
|
|
|
|
|
|
self.name,
|
|
|
|
|
|
None,
|
|
|
|
|
|
fk.other_table,
|
|
|
|
|
|
None,
|
|
|
|
|
|
columns=columns,
|
|
|
|
|
|
other_columns=fk.other_columns,
|
|
|
|
|
|
is_compound=True,
|
2026-07-05 14:40:57 -07:00
|
|
|
|
on_delete=fk.on_delete,
|
|
|
|
|
|
on_update=fk.on_update,
|
2026-07-05 14:33:15 -07:00
|
|
|
|
)
|
|
|
|
|
|
return ForeignKey(
|
2026-07-05 14:40:57 -07:00
|
|
|
|
self.name,
|
|
|
|
|
|
columns[0],
|
|
|
|
|
|
fk.other_table,
|
|
|
|
|
|
fk.other_columns[0],
|
|
|
|
|
|
on_delete=fk.on_delete,
|
|
|
|
|
|
on_update=fk.on_update,
|
2026-07-05 14:33:15 -07:00
|
|
|
|
)
|
|
|
|
|
|
|
2023-08-17 17:48:08 -07:00
|
|
|
|
create_table_foreign_keys = []
|
2026-07-05 14:33:15 -07:00
|
|
|
|
# Copy over old foreign keys, unless we are dropping them
|
2026-07-05 13:59:25 -07:00
|
|
|
|
for fk in self.foreign_keys:
|
2026-07-05 14:33:15 -07:00
|
|
|
|
if not fk_should_be_dropped(fk):
|
|
|
|
|
|
create_table_foreign_keys.append(fk_with_renamed_columns(fk))
|
2023-08-17 17:48:08 -07:00
|
|
|
|
# Add new foreign keys
|
|
|
|
|
|
if add_foreign_keys is not None:
|
|
|
|
|
|
for fk in self.db.resolve_foreign_keys(self.name, add_foreign_keys):
|
2026-07-05 14:33:15 -07:00
|
|
|
|
create_table_foreign_keys.append(fk_with_renamed_columns(fk))
|
2023-08-17 17:48:08 -07:00
|
|
|
|
|
2020-09-21 21:20:01 -07:00
|
|
|
|
new_table_name = "{}_new_{}".format(
|
|
|
|
|
|
self.name, tmp_suffix or os.urandom(6).hex()
|
|
|
|
|
|
)
|
|
|
|
|
|
current_column_pairs = list(self.columns_dict.items())
|
|
|
|
|
|
new_column_pairs = []
|
|
|
|
|
|
copy_from_to = {column: column for column, _ in current_column_pairs}
|
|
|
|
|
|
for name, type_ in current_column_pairs:
|
2020-09-21 23:39:10 -07:00
|
|
|
|
type_ = types.get(name) or type_
|
2020-09-21 21:20:01 -07:00
|
|
|
|
if name in drop:
|
|
|
|
|
|
del [copy_from_to[name]]
|
|
|
|
|
|
continue
|
|
|
|
|
|
new_name = rename.get(name) or name
|
|
|
|
|
|
new_column_pairs.append((new_name, type_))
|
|
|
|
|
|
copy_from_to[name] = new_name
|
|
|
|
|
|
|
|
|
|
|
|
if pk is DEFAULT:
|
2021-06-19 08:28:26 -07:00
|
|
|
|
pks_renamed = tuple(
|
2026-07-06 21:34:02 -07:00
|
|
|
|
rename.get(pk_name) or pk_name
|
|
|
|
|
|
for pk_name in (self.pks if not self.use_rowid else [])
|
2021-06-19 08:28:26 -07:00
|
|
|
|
)
|
2020-09-21 21:20:01 -07:00
|
|
|
|
if len(pks_renamed) == 1:
|
|
|
|
|
|
pk = pks_renamed[0]
|
|
|
|
|
|
else:
|
|
|
|
|
|
pk = pks_renamed
|
|
|
|
|
|
|
|
|
|
|
|
# not_null may be a set or dict, need to convert to a set
|
2020-09-22 00:46:32 -07:00
|
|
|
|
create_table_not_null = {
|
|
|
|
|
|
rename.get(c.name) or c.name
|
|
|
|
|
|
for c in self.columns
|
|
|
|
|
|
if c.notnull
|
|
|
|
|
|
if c.name not in drop
|
|
|
|
|
|
}
|
2020-09-21 21:20:01 -07:00
|
|
|
|
if isinstance(not_null, dict):
|
|
|
|
|
|
# Remove any columns with a value of False
|
|
|
|
|
|
for key, value in not_null.items():
|
|
|
|
|
|
# Column may have been renamed
|
|
|
|
|
|
key = rename.get(key) or key
|
|
|
|
|
|
if value is False and key in create_table_not_null:
|
|
|
|
|
|
create_table_not_null.remove(key)
|
|
|
|
|
|
else:
|
|
|
|
|
|
create_table_not_null.add(key)
|
|
|
|
|
|
elif isinstance(not_null, set):
|
2020-09-22 00:46:32 -07:00
|
|
|
|
create_table_not_null.update((rename.get(k) or k) for k in not_null)
|
2022-08-27 16:17:55 -07:00
|
|
|
|
elif not not_null:
|
2020-09-22 00:46:32 -07:00
|
|
|
|
pass
|
|
|
|
|
|
else:
|
2026-07-04 19:19:24 +00:00
|
|
|
|
raise ValueError(
|
|
|
|
|
|
"not_null must be a dict or a set or None, it was {}".format(
|
|
|
|
|
|
repr(not_null)
|
|
|
|
|
|
)
|
2022-08-27 16:17:55 -07:00
|
|
|
|
)
|
2020-09-21 21:20:01 -07:00
|
|
|
|
# defaults=
|
|
|
|
|
|
create_table_defaults = {
|
|
|
|
|
|
(rename.get(c.name) or c.name): c.default_value
|
|
|
|
|
|
for c in self.columns
|
2020-09-22 00:46:32 -07:00
|
|
|
|
if c.default_value is not None and c.name not in drop
|
2020-09-21 21:20:01 -07:00
|
|
|
|
}
|
|
|
|
|
|
if defaults is not None:
|
|
|
|
|
|
create_table_defaults.update(
|
|
|
|
|
|
{rename.get(c) or c: v for c, v in defaults.items()}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2020-09-24 08:43:55 -07:00
|
|
|
|
if column_order is not None:
|
|
|
|
|
|
column_order = [rename.get(col) or col for col in column_order]
|
|
|
|
|
|
|
2022-03-11 13:44:07 -08:00
|
|
|
|
sqls = []
|
2020-09-21 21:20:01 -07:00
|
|
|
|
sqls.append(
|
|
|
|
|
|
self.db.create_table_sql(
|
|
|
|
|
|
new_table_name,
|
|
|
|
|
|
dict(new_column_pairs),
|
|
|
|
|
|
pk=pk,
|
|
|
|
|
|
not_null=create_table_not_null,
|
|
|
|
|
|
defaults=create_table_defaults,
|
|
|
|
|
|
foreign_keys=create_table_foreign_keys,
|
2020-09-24 08:43:55 -07:00
|
|
|
|
column_order=column_order,
|
2026-07-11 16:43:37 -07:00
|
|
|
|
strict=self.strict if strict is None else strict,
|
2020-09-21 21:20:01 -07:00
|
|
|
|
).strip()
|
|
|
|
|
|
)
|
2020-09-22 00:46:32 -07:00
|
|
|
|
|
2020-09-21 21:20:01 -07:00
|
|
|
|
# Copy across data, respecting any renamed columns
|
|
|
|
|
|
new_cols = []
|
|
|
|
|
|
old_cols = []
|
|
|
|
|
|
for from_, to_ in copy_from_to.items():
|
|
|
|
|
|
old_cols.append(from_)
|
|
|
|
|
|
new_cols.append(to_)
|
2023-09-08 17:45:30 -07:00
|
|
|
|
# Ensure rowid is copied too
|
|
|
|
|
|
if "rowid" not in new_cols:
|
|
|
|
|
|
new_cols.insert(0, "rowid")
|
|
|
|
|
|
old_cols.insert(0, "rowid")
|
2025-11-23 20:43:26 -08:00
|
|
|
|
copy_sql = "INSERT INTO {} ({new_cols})\n SELECT {old_cols} FROM {};".format(
|
|
|
|
|
|
quote_identifier(new_table_name),
|
|
|
|
|
|
quote_identifier(self.name),
|
|
|
|
|
|
old_cols=", ".join(quote_identifier(col) for col in old_cols),
|
|
|
|
|
|
new_cols=", ".join(quote_identifier(col) for col in new_cols),
|
2020-09-21 21:20:01 -07:00
|
|
|
|
)
|
|
|
|
|
|
sqls.append(copy_sql)
|
2023-07-22 15:32:09 -07:00
|
|
|
|
# Drop (or keep) the old table
|
|
|
|
|
|
if keep_table:
|
|
|
|
|
|
sqls.append(
|
2025-11-23 20:43:26 -08:00
|
|
|
|
"ALTER TABLE {} RENAME TO {};".format(
|
|
|
|
|
|
quote_identifier(self.name), quote_identifier(keep_table)
|
|
|
|
|
|
)
|
2023-07-22 15:32:09 -07:00
|
|
|
|
)
|
|
|
|
|
|
else:
|
2025-11-23 20:43:26 -08:00
|
|
|
|
sqls.append("DROP TABLE {};".format(quote_identifier(self.name)))
|
2020-09-21 21:20:01 -07:00
|
|
|
|
# Rename the new one
|
2020-09-22 00:49:27 -07:00
|
|
|
|
sqls.append(
|
2025-11-23 20:43:26 -08:00
|
|
|
|
"ALTER TABLE {} RENAME TO {};".format(
|
|
|
|
|
|
quote_identifier(new_table_name), quote_identifier(self.name)
|
|
|
|
|
|
)
|
2020-09-22 00:49:27 -07:00
|
|
|
|
)
|
2024-11-23 14:17:15 -06:00
|
|
|
|
# Re-add existing indexes
|
|
|
|
|
|
for index in self.indexes:
|
|
|
|
|
|
if index.origin != "pk":
|
|
|
|
|
|
index_sql = self.db.execute(
|
|
|
|
|
|
"""SELECT sql FROM sqlite_master WHERE type = 'index' AND name = :index_name;""",
|
|
|
|
|
|
{"index_name": index.name},
|
|
|
|
|
|
).fetchall()[0][0]
|
|
|
|
|
|
if index_sql is None:
|
|
|
|
|
|
raise TransformError(
|
|
|
|
|
|
f"Index '{index.name}' on table '{self.name}' does not have a "
|
|
|
|
|
|
"CREATE INDEX statement. You must manually drop this index prior to running this "
|
|
|
|
|
|
"transformation and manually recreate the new index after running this transformation."
|
|
|
|
|
|
)
|
|
|
|
|
|
if keep_table:
|
2025-11-23 20:43:26 -08:00
|
|
|
|
sqls.append(f"DROP INDEX IF EXISTS {quote_identifier(index.name)};")
|
2024-11-23 14:17:15 -06:00
|
|
|
|
for col in index.columns:
|
|
|
|
|
|
if col in rename.keys() or col in drop:
|
|
|
|
|
|
raise TransformError(
|
|
|
|
|
|
f"Index '{index.name}' column '{col}' is not in updated table '{self.name}'. "
|
|
|
|
|
|
f"You must manually drop this index prior to running this transformation "
|
|
|
|
|
|
f"and manually recreate the new index after running this transformation. "
|
|
|
|
|
|
f"The original index sql statement is: `{index_sql}`. No changes have been applied to this table."
|
|
|
|
|
|
)
|
|
|
|
|
|
sqls.append(index_sql)
|
2020-09-21 21:20:01 -07:00
|
|
|
|
return sqls
|
|
|
|
|
|
|
2021-08-10 16:09:28 -07:00
|
|
|
|
def extract(
|
|
|
|
|
|
self,
|
|
|
|
|
|
columns: Union[str, Iterable[str]],
|
|
|
|
|
|
table: Optional[str] = None,
|
|
|
|
|
|
fk_column: Optional[str] = None,
|
|
|
|
|
|
rename: Optional[Dict[str, str]] = None,
|
|
|
|
|
|
) -> "Table":
|
|
|
|
|
|
"""
|
|
|
|
|
|
Extract specified columns into a separate table.
|
|
|
|
|
|
|
|
|
|
|
|
See :ref:`python_api_extract` for details.
|
2022-03-11 09:38:34 -08:00
|
|
|
|
|
|
|
|
|
|
:param columns: Single column or list of columns that should be extracted
|
|
|
|
|
|
:param table: Name of table in which the new records should be created
|
|
|
|
|
|
: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
|
2021-08-10 16:09:28 -07:00
|
|
|
|
"""
|
2020-09-22 15:57:02 -07:00
|
|
|
|
rename = rename or {}
|
2020-09-22 15:20:18 -07:00
|
|
|
|
if isinstance(columns, str):
|
|
|
|
|
|
columns = [columns]
|
2026-07-05 22:11:22 -07:00
|
|
|
|
columns = [resolve_casing(c, self.columns_dict) for c in columns]
|
|
|
|
|
|
rename = {resolve_casing(k, self.columns_dict): v for k, v in rename.items()}
|
2020-09-22 15:20:18 -07:00
|
|
|
|
if not set(columns).issubset(self.columns_dict.keys()):
|
|
|
|
|
|
raise InvalidColumns(
|
|
|
|
|
|
"Invalid columns {} for table with columns {}".format(
|
|
|
|
|
|
columns, list(self.columns_dict.keys())
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
2026-06-21 10:43:19 -07:00
|
|
|
|
with self.db.atomic():
|
|
|
|
|
|
table = table or "_".join(columns)
|
|
|
|
|
|
lookup_table = self.db.table(table)
|
|
|
|
|
|
fk_column = fk_column or "{}_id".format(table)
|
|
|
|
|
|
magic_lookup_column = "{}_{}".format(fk_column, os.urandom(6).hex())
|
|
|
|
|
|
|
|
|
|
|
|
# Populate the lookup table with all of the extracted unique values
|
|
|
|
|
|
lookup_columns_definition = {
|
|
|
|
|
|
(rename.get(col) or col): typ
|
|
|
|
|
|
for col, typ in self.columns_dict.items()
|
|
|
|
|
|
if col in columns
|
|
|
|
|
|
}
|
|
|
|
|
|
if lookup_table.exists():
|
|
|
|
|
|
if not set(lookup_columns_definition.items()).issubset(
|
|
|
|
|
|
lookup_table.columns_dict.items()
|
|
|
|
|
|
):
|
|
|
|
|
|
raise InvalidColumns(
|
|
|
|
|
|
"Lookup table {} already exists but does not have columns {}".format(
|
|
|
|
|
|
table, lookup_columns_definition
|
|
|
|
|
|
)
|
2020-09-24 08:43:55 -07:00
|
|
|
|
)
|
2026-06-21 10:43:19 -07:00
|
|
|
|
else:
|
|
|
|
|
|
lookup_table.create(
|
|
|
|
|
|
{
|
|
|
|
|
|
**{
|
|
|
|
|
|
"id": int,
|
|
|
|
|
|
},
|
|
|
|
|
|
**lookup_columns_definition,
|
2020-09-24 08:43:55 -07:00
|
|
|
|
},
|
2026-06-21 10:43:19 -07:00
|
|
|
|
pk="id",
|
|
|
|
|
|
)
|
|
|
|
|
|
lookup_columns = [(rename.get(col) or col) for col in columns]
|
|
|
|
|
|
lookup_table.create_index(lookup_columns, unique=True, if_not_exists=True)
|
2026-07-06 20:39:29 -07:00
|
|
|
|
# Rows where every extracted column is null are left alone - they
|
|
|
|
|
|
# get a null foreign key and no lookup table record, see #186
|
|
|
|
|
|
all_columns_are_null = " AND ".join(
|
|
|
|
|
|
"{} IS NULL".format(quote_identifier(c)) for c in columns
|
|
|
|
|
|
)
|
2026-07-06 21:55:21 -07:00
|
|
|
|
# INSERT OR IGNORE dedupes against the unique index, but unique
|
|
|
|
|
|
# indexes treat NULLs as distinct - the NOT EXISTS guard uses IS
|
|
|
|
|
|
# comparison so NULL-containing rows match existing lookup rows
|
|
|
|
|
|
# instead of being inserted again
|
|
|
|
|
|
already_in_lookup = " AND ".join(
|
|
|
|
|
|
"{lookup}.{lookup_col} IS {source}.{source_col}".format(
|
|
|
|
|
|
lookup=quote_identifier(table),
|
|
|
|
|
|
lookup_col=quote_identifier(rename.get(column) or column),
|
|
|
|
|
|
source=quote_identifier(self.name),
|
|
|
|
|
|
source_col=quote_identifier(column),
|
|
|
|
|
|
)
|
|
|
|
|
|
for column in columns
|
|
|
|
|
|
)
|
2026-06-21 10:43:19 -07:00
|
|
|
|
self.db.execute(
|
2026-07-06 21:55:21 -07:00
|
|
|
|
"INSERT OR IGNORE INTO {} ({lookup_columns}) SELECT DISTINCT {table_cols} FROM {} "
|
|
|
|
|
|
"WHERE NOT ({all_null}) AND NOT EXISTS (SELECT 1 FROM {lookup} WHERE {already_in_lookup})".format(
|
2026-06-21 10:43:19 -07:00
|
|
|
|
quote_identifier(table),
|
|
|
|
|
|
quote_identifier(self.name),
|
|
|
|
|
|
lookup_columns=", ".join(
|
|
|
|
|
|
quote_identifier(c) for c in lookup_columns
|
|
|
|
|
|
),
|
|
|
|
|
|
table_cols=", ".join(quote_identifier(c) for c in columns),
|
2026-07-06 20:39:29 -07:00
|
|
|
|
all_null=all_columns_are_null,
|
2026-07-06 21:55:21 -07:00
|
|
|
|
lookup=quote_identifier(table),
|
|
|
|
|
|
already_in_lookup=already_in_lookup,
|
2026-06-21 10:43:19 -07:00
|
|
|
|
)
|
2020-09-23 13:12:09 -07:00
|
|
|
|
)
|
2020-09-24 08:43:55 -07:00
|
|
|
|
|
2026-06-21 10:43:19 -07:00
|
|
|
|
# Now add the new fk_column
|
|
|
|
|
|
self.add_column(magic_lookup_column, int)
|
2020-09-24 08:43:55 -07:00
|
|
|
|
|
2026-06-21 10:43:19 -07:00
|
|
|
|
# And populate it
|
|
|
|
|
|
self.db.execute(
|
2026-07-06 20:39:29 -07:00
|
|
|
|
"UPDATE {} SET {} = (SELECT id FROM {} WHERE {where}) WHERE NOT ({all_null})".format(
|
2026-06-21 10:43:19 -07:00
|
|
|
|
quote_identifier(self.name),
|
|
|
|
|
|
quote_identifier(magic_lookup_column),
|
|
|
|
|
|
quote_identifier(table),
|
|
|
|
|
|
where=" AND ".join(
|
|
|
|
|
|
"{}.{} IS {}.{}".format(
|
|
|
|
|
|
quote_identifier(self.name),
|
|
|
|
|
|
quote_identifier(column),
|
|
|
|
|
|
quote_identifier(table),
|
|
|
|
|
|
quote_identifier(rename.get(column) or column),
|
|
|
|
|
|
)
|
|
|
|
|
|
for column in columns
|
|
|
|
|
|
),
|
2026-07-06 20:39:29 -07:00
|
|
|
|
all_null=all_columns_are_null,
|
2026-06-21 10:43:19 -07:00
|
|
|
|
)
|
2020-09-24 08:43:55 -07:00
|
|
|
|
)
|
2026-06-21 10:43:19 -07:00
|
|
|
|
# Figure out the right column order
|
|
|
|
|
|
column_order = []
|
|
|
|
|
|
for c in self.columns:
|
|
|
|
|
|
if c.name in columns and magic_lookup_column not in column_order:
|
|
|
|
|
|
column_order.append(magic_lookup_column)
|
|
|
|
|
|
elif c.name == magic_lookup_column:
|
|
|
|
|
|
continue
|
|
|
|
|
|
else:
|
|
|
|
|
|
column_order.append(c.name)
|
2020-09-24 08:43:55 -07:00
|
|
|
|
|
2026-06-21 10:43:19 -07:00
|
|
|
|
# Drop the unnecessary columns and rename lookup column
|
|
|
|
|
|
self.transform(
|
|
|
|
|
|
drop=set(columns),
|
|
|
|
|
|
rename={magic_lookup_column: fk_column},
|
|
|
|
|
|
column_order=column_order,
|
|
|
|
|
|
)
|
2020-09-24 08:43:55 -07:00
|
|
|
|
|
2026-06-21 10:43:19 -07:00
|
|
|
|
# And add the foreign key constraint
|
|
|
|
|
|
self.add_foreign_key(fk_column, table, "id")
|
2020-09-24 08:43:55 -07:00
|
|
|
|
return self
|
2020-09-22 15:20:18 -07:00
|
|
|
|
|
2021-08-10 16:09:28 -07:00
|
|
|
|
def create_index(
|
|
|
|
|
|
self,
|
|
|
|
|
|
columns: Iterable[Union[str, DescIndex]],
|
|
|
|
|
|
index_name: Optional[str] = None,
|
|
|
|
|
|
unique: bool = False,
|
|
|
|
|
|
if_not_exists: bool = False,
|
2021-11-14 14:49:19 -08:00
|
|
|
|
find_unique_name: bool = False,
|
2022-01-10 12:00:24 -08:00
|
|
|
|
analyze: bool = False,
|
2021-08-10 16:09:28 -07:00
|
|
|
|
):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Create an index on this table.
|
|
|
|
|
|
|
2022-03-11 09:38:34 -08:00
|
|
|
|
:param columns: A single columns or list of columns to index. These can be strings or,
|
2021-08-10 16:09:28 -07:00
|
|
|
|
to create an index using the column in descending order, ``db.DescIndex(column_name)`` objects.
|
2022-03-11 09:38:34 -08:00
|
|
|
|
:param index_name: The name to use for the new index. Defaults to the column names joined on ``_``.
|
|
|
|
|
|
:param unique: Should the index be marked as unique, forcing unique values?
|
|
|
|
|
|
:param if_not_exists: Only create the index if one with that name does not already exist.
|
|
|
|
|
|
:param find_unique_name: If ``index_name`` is not provided and the automatically derived name
|
2021-11-14 14:49:19 -08:00
|
|
|
|
already exists, keep incrementing a suffix number to find an available name.
|
2022-03-11 09:38:34 -08:00
|
|
|
|
:param analyze: Run ``ANALYZE`` against this index after creating it.
|
2021-08-10 16:09:28 -07:00
|
|
|
|
|
|
|
|
|
|
See :ref:`python_api_create_index`.
|
|
|
|
|
|
"""
|
2018-08-01 08:20:44 -07:00
|
|
|
|
if index_name is None:
|
|
|
|
|
|
index_name = "idx_{}_{}".format(
|
|
|
|
|
|
self.name.replace(" ", "_"), "_".join(columns)
|
|
|
|
|
|
)
|
2021-05-28 22:01:38 -07:00
|
|
|
|
columns_sql = []
|
|
|
|
|
|
for column in columns:
|
|
|
|
|
|
if isinstance(column, DescIndex):
|
2025-11-23 20:43:26 -08:00
|
|
|
|
columns_sql.append("{} desc".format(quote_identifier(column)))
|
2021-05-28 22:01:38 -07:00
|
|
|
|
else:
|
2025-11-23 20:43:26 -08:00
|
|
|
|
columns_sql.append(quote_identifier(column))
|
2021-11-14 14:49:19 -08:00
|
|
|
|
|
|
|
|
|
|
suffix = None
|
2022-01-10 12:00:24 -08:00
|
|
|
|
created_index_name = None
|
2021-11-14 14:49:19 -08:00
|
|
|
|
while True:
|
2022-01-10 12:00:24 -08:00
|
|
|
|
created_index_name = (
|
|
|
|
|
|
"{}_{}".format(index_name, suffix) if suffix else index_name
|
|
|
|
|
|
)
|
2021-11-14 14:49:19 -08:00
|
|
|
|
sql = (
|
2026-05-17 16:52:48 -07:00
|
|
|
|
textwrap.dedent("""
|
2025-11-23 20:43:26 -08:00
|
|
|
|
CREATE {unique}INDEX {if_not_exists}{index_name}
|
|
|
|
|
|
ON {table_name} ({columns});
|
2026-05-17 16:52:48 -07:00
|
|
|
|
""")
|
2021-11-14 14:49:19 -08:00
|
|
|
|
.strip()
|
|
|
|
|
|
.format(
|
2025-11-23 20:43:26 -08:00
|
|
|
|
index_name=quote_identifier(created_index_name),
|
|
|
|
|
|
table_name=quote_identifier(self.name),
|
2021-11-14 14:49:19 -08:00
|
|
|
|
columns=", ".join(columns_sql),
|
|
|
|
|
|
unique="UNIQUE " if unique else "",
|
|
|
|
|
|
if_not_exists="IF NOT EXISTS " if if_not_exists else "",
|
|
|
|
|
|
)
|
2020-09-07 12:45:54 -07:00
|
|
|
|
)
|
2021-11-14 14:49:19 -08:00
|
|
|
|
try:
|
|
|
|
|
|
self.db.execute(sql)
|
|
|
|
|
|
break
|
|
|
|
|
|
except OperationalError as e:
|
|
|
|
|
|
# find_unique_name=True - try again if 'index ... already exists'
|
|
|
|
|
|
arg = e.args[0]
|
|
|
|
|
|
if (
|
|
|
|
|
|
find_unique_name
|
|
|
|
|
|
and arg.startswith("index ")
|
|
|
|
|
|
and arg.endswith(" already exists")
|
|
|
|
|
|
):
|
|
|
|
|
|
if suffix is None:
|
|
|
|
|
|
suffix = 2
|
|
|
|
|
|
else:
|
|
|
|
|
|
suffix += 1
|
|
|
|
|
|
continue
|
|
|
|
|
|
else:
|
|
|
|
|
|
raise e
|
2022-01-10 12:00:24 -08:00
|
|
|
|
if analyze:
|
|
|
|
|
|
self.db.analyze(created_index_name)
|
2018-08-05 18:42:43 -07:00
|
|
|
|
return self
|
2018-08-01 08:20:44 -07:00
|
|
|
|
|
2026-07-07 21:00:43 -07:00
|
|
|
|
def drop_index(self, index_name: str, ignore: bool = False):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Drop an index on this table.
|
|
|
|
|
|
|
|
|
|
|
|
:param index_name: Name of the index to drop
|
|
|
|
|
|
:param ignore: Set to ``True`` to ignore the error if the index does not exist
|
|
|
|
|
|
"""
|
|
|
|
|
|
if index_name not in {index.name for index in self.indexes}:
|
|
|
|
|
|
if ignore:
|
|
|
|
|
|
return self
|
|
|
|
|
|
raise OperationalError(
|
|
|
|
|
|
"No index named {} on table {}".format(index_name, self.name)
|
|
|
|
|
|
)
|
|
|
|
|
|
self.db.execute("DROP INDEX {}".format(quote_identifier(index_name)))
|
|
|
|
|
|
return self
|
|
|
|
|
|
|
2019-06-12 18:35:02 -07:00
|
|
|
|
def add_column(
|
2022-03-11 09:38:34 -08:00
|
|
|
|
self,
|
|
|
|
|
|
col_name: str,
|
|
|
|
|
|
col_type: Optional[Any] = None,
|
|
|
|
|
|
fk: Optional[str] = None,
|
|
|
|
|
|
fk_col: Optional[str] = None,
|
|
|
|
|
|
not_null_default: Optional[Any] = None,
|
2019-06-12 18:35:02 -07:00
|
|
|
|
):
|
2022-03-11 09:38:34 -08:00
|
|
|
|
"""
|
|
|
|
|
|
Add a column to this table. See :ref:`python_api_add_column`.
|
|
|
|
|
|
|
|
|
|
|
|
:param col_name: Name of the new column
|
|
|
|
|
|
:param col_type: Column type - a Python type such as ``str`` or a SQLite type string such as ``"BLOB"``
|
|
|
|
|
|
:param fk: Name of a table that this column should be a foreign key reference to
|
|
|
|
|
|
:param fk_col: Column in the foreign key table that this should reference
|
|
|
|
|
|
:param not_null_default: Set this column to ``not null`` and give it this default value
|
|
|
|
|
|
"""
|
2019-05-28 21:54:43 -07:00
|
|
|
|
fk_col_type = None
|
|
|
|
|
|
if fk is not None:
|
|
|
|
|
|
# fk must be a valid table
|
2021-06-22 18:22:08 -07:00
|
|
|
|
if fk not in self.db.table_names():
|
2019-05-28 21:54:43 -07:00
|
|
|
|
raise AlterError("table '{}' does not exist".format(fk))
|
|
|
|
|
|
# if fk_col specified, must be a valid column
|
|
|
|
|
|
if fk_col is not None:
|
2026-07-05 22:11:22 -07:00
|
|
|
|
fk_col = resolve_casing(fk_col, self.db[fk].columns_dict)
|
2019-05-28 21:54:43 -07:00
|
|
|
|
if fk_col not in self.db[fk].columns_dict:
|
|
|
|
|
|
raise AlterError("table '{}' has no column {}".format(fk, fk_col))
|
|
|
|
|
|
else:
|
|
|
|
|
|
# automatically set fk_col to first primary_key of fk table
|
2026-07-06 21:34:02 -07:00
|
|
|
|
pks = sorted(
|
|
|
|
|
|
(c for c in self.db[fk].columns if c.is_pk),
|
|
|
|
|
|
key=lambda c: c.is_pk,
|
|
|
|
|
|
)
|
2019-05-28 21:54:43 -07:00
|
|
|
|
if pks:
|
|
|
|
|
|
fk_col = pks[0].name
|
|
|
|
|
|
fk_col_type = pks[0].type
|
|
|
|
|
|
else:
|
|
|
|
|
|
fk_col = "rowid"
|
|
|
|
|
|
fk_col_type = "INTEGER"
|
2019-02-24 14:24:00 -08:00
|
|
|
|
if col_type is None:
|
|
|
|
|
|
col_type = str
|
2019-06-12 18:35:02 -07:00
|
|
|
|
not_null_sql = None
|
|
|
|
|
|
if not_null_default is not None:
|
2023-05-09 06:13:36 +09:00
|
|
|
|
not_null_sql = "NOT NULL DEFAULT {}".format(
|
|
|
|
|
|
self.db.quote_default_value(not_null_default)
|
|
|
|
|
|
)
|
2025-11-23 20:43:26 -08:00
|
|
|
|
sql = "ALTER TABLE {} ADD COLUMN {} {col_type}{not_null_default};".format(
|
|
|
|
|
|
quote_identifier(self.name),
|
|
|
|
|
|
quote_identifier(col_name),
|
2019-05-28 21:54:43 -07:00
|
|
|
|
col_type=fk_col_type or COLUMN_TYPE_MAPPING[col_type],
|
2019-06-12 18:35:02 -07:00
|
|
|
|
not_null_default=(" " + not_null_sql) if not_null_sql else "",
|
2019-02-24 11:40:26 -08:00
|
|
|
|
)
|
2020-09-07 14:56:59 -07:00
|
|
|
|
self.db.execute(sql)
|
2019-05-28 21:54:43 -07:00
|
|
|
|
if fk is not None:
|
|
|
|
|
|
self.add_foreign_key(col_name, fk, fk_col)
|
2019-02-24 11:40:26 -08:00
|
|
|
|
return self
|
|
|
|
|
|
|
More type annotations (#697)
* Add comprehensive type annotations
- mypy.ini: expanded configuration with module-specific settings
- hookspecs.py: type annotations for hook functions
- plugins.py: typed get_plugins() return value
- recipes.py: full type annotations for parsedate, parsedatetime, jsonsplit
- utils.py: extensive type annotations including Row type alias,
TypeTracker, ValueTracker, and all utility functions
- db.py: type annotations for Database methods (__exit__,
ensure_autocommit_off, tracer, register_function, etc.) and
Queryable class methods
- tests/test_docs.py: updated to match new signature display format
* Fix type errors caught by ty check
- Add type: ignore comments for external library type stub limitations
(csv.reader, click.progressbar, IOBase.name, Callable.__name__)
- Change Iterable to Sequence for SQL where_args parameters
- Use db.table() instead of db[name] for proper Table return type
- Fix rebuild_fts return type from None to Table
- Update test_tracer to expect fewer queries (optimization side effect)
* Fix mypy type errors
- Add type: ignore comments for runtime-valid patterns mypy can't verify
- Fix new_column_types annotation to Dict[str, Set[type]]
- Add type: ignore for Default sentinel values passed to create_table
* mypy skip tests directory
* Fix CI: exclude typing imports from recipe docs, skip mypy on tests
- Add Callable and Optional to exclusion list in _generate_convert_help()
- Regenerate docs/cli-reference.rst with cog
- Add [mypy-tests.*] ignore_errors = True to skip test type errors
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 22:11:47 -08:00
|
|
|
|
def drop(self, ignore: bool = False) -> None:
|
2022-03-11 09:38:34 -08:00
|
|
|
|
"""
|
|
|
|
|
|
Drop this table.
|
|
|
|
|
|
|
|
|
|
|
|
:param ignore: Set to ``True`` to ignore the error if the table does not exist
|
|
|
|
|
|
"""
|
2021-02-25 09:05:08 -08:00
|
|
|
|
try:
|
2025-11-23 20:43:26 -08:00
|
|
|
|
self.db.execute("DROP TABLE {}".format(quote_identifier(self.name)))
|
2021-02-25 09:05:08 -08:00
|
|
|
|
except sqlite3.OperationalError:
|
|
|
|
|
|
if not ignore:
|
|
|
|
|
|
raise
|
2018-07-28 06:43:18 -07:00
|
|
|
|
|
2021-08-10 16:09:28 -07:00
|
|
|
|
def guess_foreign_table(self, column: str) -> str:
|
|
|
|
|
|
"""
|
|
|
|
|
|
For a given column, suggest another table that might be referenced by this
|
|
|
|
|
|
column should it be used as a foreign key.
|
|
|
|
|
|
|
|
|
|
|
|
For example, a column called ``tag_id`` or ``tag`` or ``tags`` might suggest
|
|
|
|
|
|
a ``tag`` table, if one exists.
|
|
|
|
|
|
|
|
|
|
|
|
If no candidates can be found, raises a ``NoObviousTable`` exception.
|
2022-03-11 09:38:34 -08:00
|
|
|
|
|
|
|
|
|
|
:param column: Name of column
|
2021-08-10 16:09:28 -07:00
|
|
|
|
"""
|
2019-06-12 21:51:09 -07:00
|
|
|
|
column = column.lower()
|
|
|
|
|
|
possibilities = [column]
|
|
|
|
|
|
if column.endswith("_id"):
|
|
|
|
|
|
column_without_id = column[:-3]
|
|
|
|
|
|
possibilities.append(column_without_id)
|
|
|
|
|
|
if not column_without_id.endswith("s"):
|
|
|
|
|
|
possibilities.append(column_without_id + "s")
|
|
|
|
|
|
elif not column.endswith("s"):
|
|
|
|
|
|
possibilities.append(column + "s")
|
|
|
|
|
|
existing_tables = {t.lower(): t for t in self.db.table_names()}
|
|
|
|
|
|
for table in possibilities:
|
|
|
|
|
|
if table in existing_tables:
|
|
|
|
|
|
return existing_tables[table]
|
|
|
|
|
|
# If we get here there's no obvious candidate - raise an error
|
|
|
|
|
|
raise NoObviousTable(
|
|
|
|
|
|
"No obvious foreign key table for column '{}' - tried {}".format(
|
|
|
|
|
|
column, repr(possibilities)
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
More type annotations (#697)
* Add comprehensive type annotations
- mypy.ini: expanded configuration with module-specific settings
- hookspecs.py: type annotations for hook functions
- plugins.py: typed get_plugins() return value
- recipes.py: full type annotations for parsedate, parsedatetime, jsonsplit
- utils.py: extensive type annotations including Row type alias,
TypeTracker, ValueTracker, and all utility functions
- db.py: type annotations for Database methods (__exit__,
ensure_autocommit_off, tracer, register_function, etc.) and
Queryable class methods
- tests/test_docs.py: updated to match new signature display format
* Fix type errors caught by ty check
- Add type: ignore comments for external library type stub limitations
(csv.reader, click.progressbar, IOBase.name, Callable.__name__)
- Change Iterable to Sequence for SQL where_args parameters
- Use db.table() instead of db[name] for proper Table return type
- Fix rebuild_fts return type from None to Table
- Update test_tracer to expect fewer queries (optimization side effect)
* Fix mypy type errors
- Add type: ignore comments for runtime-valid patterns mypy can't verify
- Fix new_column_types annotation to Dict[str, Set[type]]
- Add type: ignore for Default sentinel values passed to create_table
* mypy skip tests directory
* Fix CI: exclude typing imports from recipe docs, skip mypy on tests
- Add Callable and Optional to exclusion list in _generate_convert_help()
- Regenerate docs/cli-reference.rst with cog
- Add [mypy-tests.*] ignore_errors = True to skip test type errors
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 22:11:47 -08:00
|
|
|
|
def guess_foreign_column(self, other_table: str) -> str:
|
2019-06-12 22:32:26 -07:00
|
|
|
|
pks = [c for c in self.db[other_table].columns if c.is_pk]
|
|
|
|
|
|
if len(pks) != 1:
|
|
|
|
|
|
raise BadPrimaryKey(
|
|
|
|
|
|
"Could not detect single primary key for table '{}'".format(other_table)
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
return pks[0].name
|
|
|
|
|
|
|
2020-09-20 15:17:25 -07:00
|
|
|
|
def add_foreign_key(
|
2021-08-10 16:09:28 -07:00
|
|
|
|
self,
|
2026-07-05 15:03:23 -07:00
|
|
|
|
column: ForeignKeyColumns,
|
2021-08-10 16:09:28 -07:00
|
|
|
|
other_table: Optional[str] = None,
|
2026-07-05 15:03:23 -07:00
|
|
|
|
other_column: Optional[ForeignKeyColumns] = None,
|
2021-08-10 16:09:28 -07:00
|
|
|
|
ignore: bool = False,
|
2026-07-05 15:49:20 -07:00
|
|
|
|
on_delete: str = "NO ACTION",
|
|
|
|
|
|
on_update: str = "NO ACTION",
|
2020-09-20 15:17:25 -07:00
|
|
|
|
):
|
2021-08-10 16:09:28 -07:00
|
|
|
|
"""
|
|
|
|
|
|
Alter the schema to mark the specified column as a foreign key to another table.
|
|
|
|
|
|
|
2026-07-05 15:10:21 -07:00
|
|
|
|
:param column: The column to mark as a foreign key - use a tuple of columns
|
2026-07-05 14:37:32 -07:00
|
|
|
|
for a compound foreign key.
|
2022-03-11 09:38:34 -08:00
|
|
|
|
:param other_table: The table it refers to - if omitted, will be guessed based on the column name.
|
|
|
|
|
|
:param other_column: The column on the other table it - if omitted, will be guessed.
|
2026-07-05 15:10:21 -07:00
|
|
|
|
Use a tuple of columns for a compound foreign key.
|
2022-03-11 09:38:34 -08:00
|
|
|
|
:param ignore: Set this to ``True`` to ignore an existing foreign key - otherwise a ``AlterError`` will be raised.
|
2026-07-05 15:49:20 -07:00
|
|
|
|
:param on_delete: ``ON DELETE`` action for the foreign key, e.g. ``"CASCADE"``
|
|
|
|
|
|
or ``"SET NULL"``.
|
|
|
|
|
|
:param on_update: ``ON UPDATE`` action for the foreign key.
|
2021-08-10 16:09:28 -07:00
|
|
|
|
"""
|
2026-07-05 15:10:21 -07:00
|
|
|
|
columns = (column,) if isinstance(column, str) else tuple(column)
|
2026-07-05 22:11:22 -07:00
|
|
|
|
columns = tuple(resolve_casing(c, self.columns_dict) for c in columns)
|
2026-07-05 14:37:32 -07:00
|
|
|
|
# Ensure columns exist
|
|
|
|
|
|
for col in columns:
|
|
|
|
|
|
if col not in self.columns_dict:
|
|
|
|
|
|
raise AlterError("No such column: {}".format(col))
|
2019-06-12 21:51:09 -07:00
|
|
|
|
# If other_table is not specified, attempt to guess it from the column
|
|
|
|
|
|
if other_table is None:
|
2026-07-05 14:37:32 -07:00
|
|
|
|
if len(columns) > 1:
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
"other_table must be specified for a compound foreign key"
|
|
|
|
|
|
)
|
|
|
|
|
|
other_table = self.guess_foreign_table(columns[0])
|
2019-06-12 21:51:09 -07:00
|
|
|
|
# If other_column is not specified, detect the primary key on other_table
|
|
|
|
|
|
if other_column is None:
|
2026-07-05 14:37:32 -07:00
|
|
|
|
if len(columns) > 1:
|
2026-07-05 15:10:21 -07:00
|
|
|
|
other_columns = tuple(self.db.table(other_table).pks)
|
2026-07-05 14:37:32 -07:00
|
|
|
|
else:
|
2026-07-05 15:10:21 -07:00
|
|
|
|
other_columns = (self.guess_foreign_column(other_table),)
|
2026-07-05 14:37:32 -07:00
|
|
|
|
elif isinstance(other_column, str):
|
2026-07-05 15:10:21 -07:00
|
|
|
|
other_columns = (other_column,)
|
2026-07-05 14:37:32 -07:00
|
|
|
|
else:
|
2026-07-05 15:10:21 -07:00
|
|
|
|
other_columns = tuple(other_column)
|
2026-07-05 22:11:22 -07:00
|
|
|
|
other_columns = tuple(
|
|
|
|
|
|
resolve_casing(c, self.db[other_table].columns_dict) for c in other_columns
|
|
|
|
|
|
)
|
2026-07-05 14:37:32 -07:00
|
|
|
|
if len(columns) != len(other_columns):
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
"Compound foreign key must have the same number of columns "
|
|
|
|
|
|
"on both sides"
|
|
|
|
|
|
)
|
2019-06-12 21:51:09 -07:00
|
|
|
|
|
2026-07-05 14:37:32 -07:00
|
|
|
|
# Soundness check that the other columns exist
|
|
|
|
|
|
for other_col in other_columns:
|
|
|
|
|
|
if (
|
|
|
|
|
|
not [c for c in self.db[other_table].columns if c.name == other_col]
|
|
|
|
|
|
and other_col != "rowid"
|
|
|
|
|
|
):
|
|
|
|
|
|
raise AlterError("No such column: {}.{}".format(other_table, other_col))
|
2019-02-24 13:10:51 -08:00
|
|
|
|
# Check we do not already have an existing foreign key
|
|
|
|
|
|
if any(
|
|
|
|
|
|
fk
|
|
|
|
|
|
for fk in self.foreign_keys
|
2026-07-05 22:11:22 -07:00
|
|
|
|
if tuple(fold_identifier_case(c) for c in fk.columns)
|
|
|
|
|
|
== tuple(fold_identifier_case(c) for c in columns)
|
|
|
|
|
|
and fold_identifier_case(fk.other_table)
|
|
|
|
|
|
== fold_identifier_case(other_table)
|
|
|
|
|
|
and tuple(fold_identifier_case(c) for c in fk.other_columns)
|
|
|
|
|
|
== tuple(fold_identifier_case(c) for c in other_columns)
|
2019-02-24 13:10:51 -08:00
|
|
|
|
):
|
2020-09-20 15:17:25 -07:00
|
|
|
|
if ignore:
|
|
|
|
|
|
return self
|
|
|
|
|
|
else:
|
|
|
|
|
|
raise AlterError(
|
|
|
|
|
|
"Foreign key already exists for {} => {}.{}".format(
|
2026-07-05 14:37:32 -07:00
|
|
|
|
", ".join(columns), other_table, ", ".join(other_columns)
|
2020-09-20 15:17:25 -07:00
|
|
|
|
)
|
2019-02-24 13:10:51 -08:00
|
|
|
|
)
|
2026-07-05 14:37:32 -07:00
|
|
|
|
if len(columns) == 1:
|
2026-07-05 15:49:20 -07:00
|
|
|
|
fk_object = ForeignKey(
|
|
|
|
|
|
self.name,
|
|
|
|
|
|
columns[0],
|
|
|
|
|
|
other_table,
|
|
|
|
|
|
other_columns[0],
|
|
|
|
|
|
on_delete=on_delete,
|
|
|
|
|
|
on_update=on_update,
|
2026-07-05 14:37:32 -07:00
|
|
|
|
)
|
|
|
|
|
|
else:
|
2026-07-05 15:49:20 -07:00
|
|
|
|
fk_object = ForeignKey(
|
|
|
|
|
|
self.name,
|
|
|
|
|
|
None,
|
|
|
|
|
|
other_table,
|
|
|
|
|
|
None,
|
|
|
|
|
|
columns=columns,
|
|
|
|
|
|
other_columns=other_columns,
|
|
|
|
|
|
is_compound=True,
|
|
|
|
|
|
on_delete=on_delete,
|
|
|
|
|
|
on_update=on_update,
|
|
|
|
|
|
)
|
|
|
|
|
|
self.db.add_foreign_keys([fk_object])
|
2020-09-20 15:17:25 -07:00
|
|
|
|
return self
|
2018-07-28 06:43:18 -07:00
|
|
|
|
|
More type annotations (#697)
* Add comprehensive type annotations
- mypy.ini: expanded configuration with module-specific settings
- hookspecs.py: type annotations for hook functions
- plugins.py: typed get_plugins() return value
- recipes.py: full type annotations for parsedate, parsedatetime, jsonsplit
- utils.py: extensive type annotations including Row type alias,
TypeTracker, ValueTracker, and all utility functions
- db.py: type annotations for Database methods (__exit__,
ensure_autocommit_off, tracer, register_function, etc.) and
Queryable class methods
- tests/test_docs.py: updated to match new signature display format
* Fix type errors caught by ty check
- Add type: ignore comments for external library type stub limitations
(csv.reader, click.progressbar, IOBase.name, Callable.__name__)
- Change Iterable to Sequence for SQL where_args parameters
- Use db.table() instead of db[name] for proper Table return type
- Fix rebuild_fts return type from None to Table
- Update test_tracer to expect fewer queries (optimization side effect)
* Fix mypy type errors
- Add type: ignore comments for runtime-valid patterns mypy can't verify
- Fix new_column_types annotation to Dict[str, Set[type]]
- Add type: ignore for Default sentinel values passed to create_table
* mypy skip tests directory
* Fix CI: exclude typing imports from recipe docs, skip mypy on tests
- Add Callable and Optional to exclusion list in _generate_convert_help()
- Regenerate docs/cli-reference.rst with cog
- Add [mypy-tests.*] ignore_errors = True to skip test type errors
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 22:11:47 -08:00
|
|
|
|
def enable_counts(self) -> None:
|
2021-08-10 16:09:28 -07:00
|
|
|
|
"""
|
|
|
|
|
|
Set up triggers to update a cache of the count of rows in this table.
|
|
|
|
|
|
|
|
|
|
|
|
See :ref:`python_api_cached_table_counts` for details.
|
|
|
|
|
|
"""
|
2021-01-02 13:40:10 -08:00
|
|
|
|
sql = (
|
2026-05-17 16:52:48 -07:00
|
|
|
|
textwrap.dedent("""
|
2021-01-02 14:03:52 -08:00
|
|
|
|
{create_counts_table}
|
2025-11-23 20:43:26 -08:00
|
|
|
|
CREATE TRIGGER IF NOT EXISTS {trigger_insert} AFTER INSERT ON {table}
|
2021-01-02 13:40:10 -08:00
|
|
|
|
BEGIN
|
2025-11-23 20:43:26 -08:00
|
|
|
|
INSERT OR REPLACE INTO {counts_table}
|
2021-01-02 13:40:10 -08:00
|
|
|
|
VALUES (
|
|
|
|
|
|
{table_quoted},
|
|
|
|
|
|
COALESCE(
|
2025-11-23 20:43:26 -08:00
|
|
|
|
(SELECT count FROM {counts_table} WHERE "table" = {table_quoted}),
|
2021-01-02 13:40:10 -08:00
|
|
|
|
0
|
|
|
|
|
|
) + 1
|
|
|
|
|
|
);
|
|
|
|
|
|
END;
|
2025-11-23 20:43:26 -08:00
|
|
|
|
CREATE TRIGGER IF NOT EXISTS {trigger_delete} AFTER DELETE ON {table}
|
2021-01-02 13:40:10 -08:00
|
|
|
|
BEGIN
|
2025-11-23 20:43:26 -08:00
|
|
|
|
INSERT OR REPLACE INTO {counts_table}
|
2021-01-02 13:40:10 -08:00
|
|
|
|
VALUES (
|
|
|
|
|
|
{table_quoted},
|
|
|
|
|
|
COALESCE(
|
2025-11-23 20:43:26 -08:00
|
|
|
|
(SELECT count FROM {counts_table} WHERE "table" = {table_quoted}),
|
2021-01-02 13:40:10 -08:00
|
|
|
|
0
|
|
|
|
|
|
) - 1
|
|
|
|
|
|
);
|
|
|
|
|
|
END;
|
2025-11-23 20:43:26 -08:00
|
|
|
|
INSERT OR REPLACE INTO _counts VALUES ({table_quoted}, (select count(*) from {table}));
|
2026-05-17 16:52:48 -07:00
|
|
|
|
""")
|
2021-01-02 13:40:10 -08:00
|
|
|
|
.strip()
|
|
|
|
|
|
.format(
|
2021-01-03 12:19:34 -08:00
|
|
|
|
create_counts_table=_COUNTS_TABLE_CREATE_SQL.format(
|
|
|
|
|
|
self.db._counts_table_name
|
|
|
|
|
|
),
|
2025-11-23 20:43:26 -08:00
|
|
|
|
counts_table=quote_identifier(self.db._counts_table_name),
|
|
|
|
|
|
table=quote_identifier(self.name),
|
2021-01-02 20:15:04 -08:00
|
|
|
|
table_quoted=self.db.quote(self.name),
|
2025-11-23 20:43:26 -08:00
|
|
|
|
trigger_insert=quote_identifier(
|
|
|
|
|
|
self.name + self.db._counts_table_name + "_insert"
|
|
|
|
|
|
),
|
|
|
|
|
|
trigger_delete=quote_identifier(
|
|
|
|
|
|
self.name + self.db._counts_table_name + "_delete"
|
|
|
|
|
|
),
|
2021-01-02 13:40:10 -08:00
|
|
|
|
)
|
|
|
|
|
|
)
|
2026-06-21 10:43:19 -07:00
|
|
|
|
with self.db.atomic():
|
|
|
|
|
|
self.db._executescript(sql)
|
2021-01-03 12:19:34 -08:00
|
|
|
|
self.db.use_counts_table = True
|
2021-01-02 13:40:10 -08:00
|
|
|
|
|
2021-01-03 12:41:24 -08:00
|
|
|
|
@property
|
2021-08-10 16:09:28 -07:00
|
|
|
|
def has_counts_triggers(self) -> bool:
|
|
|
|
|
|
"Does this table have triggers setup to update cached counts?"
|
2021-01-03 12:41:24 -08:00
|
|
|
|
trigger_names = {
|
|
|
|
|
|
"{table}{counts_table}_{suffix}".format(
|
|
|
|
|
|
counts_table=self.db._counts_table_name, table=self.name, suffix=suffix
|
|
|
|
|
|
)
|
|
|
|
|
|
for suffix in ["insert", "delete"]
|
|
|
|
|
|
}
|
|
|
|
|
|
return trigger_names.issubset(self.triggers_dict.keys())
|
|
|
|
|
|
|
2020-08-01 13:40:36 -07:00
|
|
|
|
def enable_fts(
|
2020-09-20 15:05:46 -07:00
|
|
|
|
self,
|
2021-08-10 16:09:28 -07:00
|
|
|
|
columns: Iterable[str],
|
|
|
|
|
|
fts_version: str = "FTS5",
|
|
|
|
|
|
create_triggers: bool = False,
|
|
|
|
|
|
tokenize: Optional[str] = None,
|
|
|
|
|
|
replace: bool = False,
|
2020-08-01 13:40:36 -07:00
|
|
|
|
):
|
2021-08-10 16:09:28 -07:00
|
|
|
|
"""
|
|
|
|
|
|
Enable SQLite full-text search against the specified columns.
|
|
|
|
|
|
|
|
|
|
|
|
See :ref:`python_api_fts` for more details.
|
2022-03-11 09:38:34 -08:00
|
|
|
|
|
|
|
|
|
|
:param columns: List of column names to include in the search index.
|
|
|
|
|
|
:param fts_version: FTS version to use - defaults to ``FTS5`` but you may want ``FTS4`` for older SQLite versions.
|
|
|
|
|
|
:param create_triggers: Should triggers be created to keep the search index up-to-date? Defaults to ``False``.
|
|
|
|
|
|
: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?
|
2021-08-10 16:09:28 -07:00
|
|
|
|
"""
|
2020-09-20 15:05:46 -07:00
|
|
|
|
create_fts_sql = (
|
2026-05-17 16:52:48 -07:00
|
|
|
|
textwrap.dedent("""
|
2025-11-23 20:43:26 -08:00
|
|
|
|
CREATE VIRTUAL TABLE {table_fts} USING {fts_version} (
|
2020-08-01 13:40:36 -07:00
|
|
|
|
{columns},{tokenize}
|
2025-11-23 20:43:26 -08:00
|
|
|
|
content={table}
|
2020-09-20 15:05:46 -07:00
|
|
|
|
)
|
2026-05-17 16:52:48 -07:00
|
|
|
|
""")
|
2020-09-07 12:45:54 -07:00
|
|
|
|
.strip()
|
|
|
|
|
|
.format(
|
2025-11-23 20:43:26 -08:00
|
|
|
|
table=quote_identifier(self.name),
|
|
|
|
|
|
table_fts=quote_identifier(self.name + "_fts"),
|
|
|
|
|
|
columns=", ".join(quote_identifier(c) for c in columns),
|
2020-09-07 12:45:54 -07:00
|
|
|
|
fts_version=fts_version,
|
|
|
|
|
|
tokenize="\n tokenize='{}',".format(tokenize) if tokenize else "",
|
|
|
|
|
|
)
|
2018-07-31 09:19:05 -07:00
|
|
|
|
)
|
2020-09-20 15:05:46 -07:00
|
|
|
|
should_recreate = False
|
|
|
|
|
|
if replace and self.db["{}_fts".format(self.name)].exists():
|
|
|
|
|
|
# Does the table need to be recreated?
|
|
|
|
|
|
fts_schema = self.db["{}_fts".format(self.name)].schema
|
|
|
|
|
|
if fts_schema != create_fts_sql:
|
|
|
|
|
|
should_recreate = True
|
|
|
|
|
|
expected_triggers = {self.name + suffix for suffix in ("_ai", "_ad", "_au")}
|
|
|
|
|
|
existing_triggers = {t.name for t in self.triggers}
|
|
|
|
|
|
has_triggers = existing_triggers.issuperset(expected_triggers)
|
|
|
|
|
|
if has_triggers != create_triggers:
|
|
|
|
|
|
should_recreate = True
|
|
|
|
|
|
if not should_recreate:
|
|
|
|
|
|
# Table with correct configuration already exists
|
|
|
|
|
|
return self
|
|
|
|
|
|
|
|
|
|
|
|
if should_recreate:
|
|
|
|
|
|
self.disable_fts()
|
|
|
|
|
|
|
|
|
|
|
|
self.db.executescript(create_fts_sql)
|
2018-07-31 09:19:05 -07:00
|
|
|
|
self.populate_fts(columns)
|
2019-09-02 16:42:28 -07:00
|
|
|
|
|
|
|
|
|
|
if create_triggers:
|
2025-11-23 20:43:26 -08:00
|
|
|
|
old_cols = ", ".join("old.{}".format(quote_identifier(c)) for c in columns)
|
|
|
|
|
|
new_cols = ", ".join("new.{}".format(quote_identifier(c)) for c in columns)
|
|
|
|
|
|
columns_quoted = ", ".join(quote_identifier(c) for c in columns)
|
|
|
|
|
|
table = quote_identifier(self.name)
|
|
|
|
|
|
table_fts = quote_identifier(self.name + "_fts")
|
2020-09-07 12:45:54 -07:00
|
|
|
|
triggers = (
|
2026-05-17 16:52:48 -07:00
|
|
|
|
textwrap.dedent("""
|
2025-11-23 20:43:26 -08:00
|
|
|
|
CREATE TRIGGER {table_ai} AFTER INSERT ON {table} BEGIN
|
|
|
|
|
|
INSERT INTO {table_fts} (rowid, {columns}) VALUES (new.rowid, {new_cols});
|
2019-09-02 16:42:28 -07:00
|
|
|
|
END;
|
2025-11-23 20:43:26 -08:00
|
|
|
|
CREATE TRIGGER {table_ad} AFTER DELETE ON {table} BEGIN
|
|
|
|
|
|
INSERT INTO {table_fts} ({table_fts}, rowid, {columns}) VALUES('delete', old.rowid, {old_cols});
|
2019-09-02 16:42:28 -07:00
|
|
|
|
END;
|
2025-11-23 20:43:26 -08:00
|
|
|
|
CREATE TRIGGER {table_au} AFTER UPDATE ON {table} BEGIN
|
|
|
|
|
|
INSERT INTO {table_fts} ({table_fts}, rowid, {columns}) VALUES('delete', old.rowid, {old_cols});
|
|
|
|
|
|
INSERT INTO {table_fts} (rowid, {columns}) VALUES (new.rowid, {new_cols});
|
2019-09-02 16:42:28 -07:00
|
|
|
|
END;
|
2026-05-17 16:52:48 -07:00
|
|
|
|
""")
|
2020-09-07 12:45:54 -07:00
|
|
|
|
.strip()
|
|
|
|
|
|
.format(
|
2025-11-23 20:43:26 -08:00
|
|
|
|
table=table,
|
|
|
|
|
|
table_fts=table_fts,
|
|
|
|
|
|
table_ai=quote_identifier(self.name + "_ai"),
|
|
|
|
|
|
table_ad=quote_identifier(self.name + "_ad"),
|
|
|
|
|
|
table_au=quote_identifier(self.name + "_au"),
|
|
|
|
|
|
columns=columns_quoted,
|
2020-09-07 12:45:54 -07:00
|
|
|
|
old_cols=old_cols,
|
|
|
|
|
|
new_cols=new_cols,
|
|
|
|
|
|
)
|
2019-09-02 16:42:28 -07:00
|
|
|
|
)
|
2020-09-07 14:56:59 -07:00
|
|
|
|
self.db.executescript(triggers)
|
2018-08-05 18:42:43 -07:00
|
|
|
|
return self
|
2018-07-31 09:19:05 -07:00
|
|
|
|
|
2021-08-10 16:09:28 -07:00
|
|
|
|
def populate_fts(self, columns: Iterable[str]) -> "Table":
|
|
|
|
|
|
"""
|
|
|
|
|
|
Update the associated SQLite full-text search index with the latest data from the
|
|
|
|
|
|
table for the specified columns.
|
2022-03-11 09:38:34 -08:00
|
|
|
|
|
|
|
|
|
|
:param columns: Columns to populate the data for
|
2021-08-10 16:09:28 -07:00
|
|
|
|
"""
|
2025-11-23 20:43:26 -08:00
|
|
|
|
columns_quoted = ", ".join(quote_identifier(c) for c in columns)
|
2020-09-07 14:56:59 -07:00
|
|
|
|
sql = (
|
2026-05-17 16:52:48 -07:00
|
|
|
|
textwrap.dedent("""
|
2025-11-23 20:43:26 -08:00
|
|
|
|
INSERT INTO {table_fts} (rowid, {columns})
|
|
|
|
|
|
SELECT rowid, {columns} FROM {table};
|
2026-05-17 16:52:48 -07:00
|
|
|
|
""")
|
2020-09-07 14:56:59 -07:00
|
|
|
|
.strip()
|
|
|
|
|
|
.format(
|
2025-11-23 20:43:26 -08:00
|
|
|
|
table=quote_identifier(self.name),
|
|
|
|
|
|
table_fts=quote_identifier(self.name + "_fts"),
|
|
|
|
|
|
columns=columns_quoted,
|
2020-09-07 14:56:59 -07:00
|
|
|
|
)
|
2018-07-31 09:19:05 -07:00
|
|
|
|
)
|
2020-09-07 14:56:59 -07:00
|
|
|
|
self.db.executescript(sql)
|
2018-08-05 18:42:43 -07:00
|
|
|
|
return self
|
2018-07-31 09:19:05 -07:00
|
|
|
|
|
2021-08-10 16:09:28 -07:00
|
|
|
|
def disable_fts(self) -> "Table":
|
|
|
|
|
|
"Remove any full-text search index and related triggers configured for this table."
|
2020-02-26 20:40:35 -08:00
|
|
|
|
fts_table = self.detect_fts()
|
|
|
|
|
|
if fts_table:
|
|
|
|
|
|
self.db[fts_table].drop()
|
|
|
|
|
|
# Now delete the triggers that related to that table
|
2026-05-17 16:52:48 -07:00
|
|
|
|
sql = textwrap.dedent("""
|
2020-02-26 20:40:35 -08:00
|
|
|
|
SELECT name FROM sqlite_master
|
|
|
|
|
|
WHERE type = 'trigger'
|
2025-11-23 20:43:26 -08:00
|
|
|
|
AND (sql LIKE '% INSERT INTO [{}]%' OR sql LIKE '% INSERT INTO "{}"%')
|
2026-05-17 16:52:48 -07:00
|
|
|
|
""").strip().format(fts_table, fts_table)
|
2020-02-26 20:40:35 -08:00
|
|
|
|
trigger_names = []
|
2020-09-07 14:56:59 -07:00
|
|
|
|
for row in self.db.execute(sql).fetchall():
|
2020-02-26 20:40:35 -08:00
|
|
|
|
trigger_names.append(row[0])
|
2026-06-21 10:43:19 -07:00
|
|
|
|
with self.db.atomic():
|
2020-02-26 20:40:35 -08:00
|
|
|
|
for trigger_name in trigger_names:
|
2025-11-23 20:43:26 -08:00
|
|
|
|
self.db.execute(
|
|
|
|
|
|
"DROP TRIGGER IF EXISTS {}".format(quote_identifier(trigger_name))
|
|
|
|
|
|
)
|
2020-09-24 07:51:36 -07:00
|
|
|
|
return self
|
2020-02-26 20:40:35 -08:00
|
|
|
|
|
More type annotations (#697)
* Add comprehensive type annotations
- mypy.ini: expanded configuration with module-specific settings
- hookspecs.py: type annotations for hook functions
- plugins.py: typed get_plugins() return value
- recipes.py: full type annotations for parsedate, parsedatetime, jsonsplit
- utils.py: extensive type annotations including Row type alias,
TypeTracker, ValueTracker, and all utility functions
- db.py: type annotations for Database methods (__exit__,
ensure_autocommit_off, tracer, register_function, etc.) and
Queryable class methods
- tests/test_docs.py: updated to match new signature display format
* Fix type errors caught by ty check
- Add type: ignore comments for external library type stub limitations
(csv.reader, click.progressbar, IOBase.name, Callable.__name__)
- Change Iterable to Sequence for SQL where_args parameters
- Use db.table() instead of db[name] for proper Table return type
- Fix rebuild_fts return type from None to Table
- Update test_tracer to expect fewer queries (optimization side effect)
* Fix mypy type errors
- Add type: ignore comments for runtime-valid patterns mypy can't verify
- Fix new_column_types annotation to Dict[str, Set[type]]
- Add type: ignore for Default sentinel values passed to create_table
* mypy skip tests directory
* Fix CI: exclude typing imports from recipe docs, skip mypy on tests
- Add Callable and Optional to exclusion list in _generate_convert_help()
- Regenerate docs/cli-reference.rst with cog
- Add [mypy-tests.*] ignore_errors = True to skip test type errors
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 22:11:47 -08:00
|
|
|
|
def rebuild_fts(self) -> "Table":
|
2021-08-10 16:09:28 -07:00
|
|
|
|
"Run the ``rebuild`` operation against the associated full-text search index table."
|
2020-09-08 15:09:25 -07:00
|
|
|
|
fts_table = self.detect_fts()
|
|
|
|
|
|
if fts_table is None:
|
|
|
|
|
|
# Assume this is itself an FTS table
|
|
|
|
|
|
fts_table = self.name
|
2026-07-04 18:35:45 +00:00
|
|
|
|
with self.db.atomic():
|
|
|
|
|
|
self.db.execute(
|
|
|
|
|
|
"INSERT INTO {table}({table}) VALUES('rebuild');".format(
|
|
|
|
|
|
table=quote_identifier(fts_table)
|
|
|
|
|
|
)
|
2020-09-08 15:09:25 -07:00
|
|
|
|
)
|
2020-09-24 07:51:36 -07:00
|
|
|
|
return self
|
2020-09-08 15:09:25 -07:00
|
|
|
|
|
2021-08-10 16:09:28 -07:00
|
|
|
|
def detect_fts(self) -> Optional[str]:
|
2019-01-24 20:35:51 -08:00
|
|
|
|
"Detect if table has a corresponding FTS virtual table and return it"
|
2026-05-17 16:52:48 -07:00
|
|
|
|
sql = textwrap.dedent("""
|
2019-01-24 20:35:51 -08:00
|
|
|
|
SELECT name FROM sqlite_master
|
|
|
|
|
|
WHERE rootpage = 0
|
|
|
|
|
|
AND (
|
2022-06-14 16:24:06 -07:00
|
|
|
|
sql LIKE :like
|
|
|
|
|
|
OR sql LIKE :like2
|
2019-01-24 20:35:51 -08:00
|
|
|
|
OR (
|
2022-06-14 16:24:06 -07:00
|
|
|
|
tbl_name = :table
|
2019-01-24 20:35:51 -08:00
|
|
|
|
AND sql LIKE '%VIRTUAL TABLE%USING FTS%'
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
2026-05-17 16:52:48 -07:00
|
|
|
|
""").strip()
|
2022-06-14 16:24:06 -07:00
|
|
|
|
args = {
|
2026-06-21 15:54:19 -07:00
|
|
|
|
"like": "%VIRTUAL TABLE%USING FTS%content=[{}]%".format(self.name),
|
2022-06-14 16:24:06 -07:00
|
|
|
|
"like2": '%VIRTUAL TABLE%USING FTS%content="{}"%'.format(self.name),
|
|
|
|
|
|
"table": self.name,
|
|
|
|
|
|
}
|
|
|
|
|
|
rows = self.db.execute(sql, args).fetchall()
|
2019-01-24 20:35:51 -08:00
|
|
|
|
if len(rows) == 0:
|
|
|
|
|
|
return None
|
|
|
|
|
|
else:
|
|
|
|
|
|
return rows[0][0]
|
|
|
|
|
|
|
2021-08-10 16:09:28 -07:00
|
|
|
|
def optimize(self) -> "Table":
|
|
|
|
|
|
"Run the ``optimize`` operation against the associated full-text search index table."
|
2019-01-24 20:35:51 -08:00
|
|
|
|
fts_table = self.detect_fts()
|
|
|
|
|
|
if fts_table is not None:
|
2026-07-04 18:35:45 +00:00
|
|
|
|
with self.db.atomic():
|
|
|
|
|
|
self.db.execute("""
|
|
|
|
|
|
INSERT INTO {table} ({table}) VALUES ("optimize");
|
|
|
|
|
|
""".strip().format(table=quote_identifier(fts_table)))
|
2019-01-24 20:35:51 -08:00
|
|
|
|
return self
|
|
|
|
|
|
|
2021-08-18 12:55:53 -07:00
|
|
|
|
def search_sql(
|
|
|
|
|
|
self,
|
|
|
|
|
|
columns: Optional[Iterable[str]] = None,
|
|
|
|
|
|
order_by: Optional[str] = None,
|
|
|
|
|
|
limit: Optional[int] = None,
|
|
|
|
|
|
offset: Optional[int] = None,
|
2022-06-14 14:54:35 -07:00
|
|
|
|
where: Optional[str] = None,
|
2022-08-30 22:40:35 -05:00
|
|
|
|
include_rank: bool = False,
|
2021-08-18 12:55:53 -07:00
|
|
|
|
) -> str:
|
2022-03-11 09:38:34 -08:00
|
|
|
|
""" "
|
|
|
|
|
|
Return SQL string that can be used to execute searches against this table.
|
|
|
|
|
|
|
|
|
|
|
|
:param columns: Columns to search against
|
|
|
|
|
|
:param order_by: Column or SQL expression to sort by
|
|
|
|
|
|
:param limit: SQL limit
|
|
|
|
|
|
:param offset: SQL offset
|
2022-06-14 14:54:35 -07:00
|
|
|
|
:param where: Extra SQL fragment for the WHERE clause
|
2022-08-30 22:40:35 -05:00
|
|
|
|
:param include_rank: Select the search rank column in the final query
|
2022-03-11 09:38:34 -08:00
|
|
|
|
"""
|
2020-11-03 14:46:18 -08:00
|
|
|
|
# Pick names for table and rank column that don't clash
|
|
|
|
|
|
original = "original_" if self.name == "original" else "original"
|
2025-11-23 20:43:26 -08:00
|
|
|
|
original_quoted = quote_identifier(original)
|
2020-11-03 14:46:18 -08:00
|
|
|
|
columns_sql = "*"
|
2025-11-23 20:43:26 -08:00
|
|
|
|
columns_with_prefix_sql = "{}.*".format(original_quoted)
|
2020-11-03 14:46:18 -08:00
|
|
|
|
if columns:
|
2025-11-23 20:43:26 -08:00
|
|
|
|
columns_sql = ",\n ".join(quote_identifier(c) for c in columns)
|
2020-11-08 09:04:33 -08:00
|
|
|
|
columns_with_prefix_sql = ",\n ".join(
|
2025-11-23 20:43:26 -08:00
|
|
|
|
"{}.{}".format(original_quoted, quote_identifier(c)) for c in columns
|
2020-11-08 08:53:53 -08:00
|
|
|
|
)
|
2020-11-03 14:46:18 -08:00
|
|
|
|
fts_table = self.detect_fts()
|
2026-07-04 19:19:24 +00:00
|
|
|
|
if not fts_table:
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
"Full-text search is not configured for table '{}'".format(self.name)
|
|
|
|
|
|
)
|
2025-11-23 20:43:26 -08:00
|
|
|
|
fts_table_quoted = quote_identifier(fts_table)
|
More type annotations (#697)
* Add comprehensive type annotations
- mypy.ini: expanded configuration with module-specific settings
- hookspecs.py: type annotations for hook functions
- plugins.py: typed get_plugins() return value
- recipes.py: full type annotations for parsedate, parsedatetime, jsonsplit
- utils.py: extensive type annotations including Row type alias,
TypeTracker, ValueTracker, and all utility functions
- db.py: type annotations for Database methods (__exit__,
ensure_autocommit_off, tracer, register_function, etc.) and
Queryable class methods
- tests/test_docs.py: updated to match new signature display format
* Fix type errors caught by ty check
- Add type: ignore comments for external library type stub limitations
(csv.reader, click.progressbar, IOBase.name, Callable.__name__)
- Change Iterable to Sequence for SQL where_args parameters
- Use db.table() instead of db[name] for proper Table return type
- Fix rebuild_fts return type from None to Table
- Update test_tracer to expect fewer queries (optimization side effect)
* Fix mypy type errors
- Add type: ignore comments for runtime-valid patterns mypy can't verify
- Fix new_column_types annotation to Dict[str, Set[type]]
- Add type: ignore for Default sentinel values passed to create_table
* mypy skip tests directory
* Fix CI: exclude typing imports from recipe docs, skip mypy on tests
- Add Callable and Optional to exclusion list in _generate_convert_help()
- Regenerate docs/cli-reference.rst with cog
- Add [mypy-tests.*] ignore_errors = True to skip test type errors
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 22:11:47 -08:00
|
|
|
|
virtual_table_using = self.db.table(fts_table).virtual_table_using
|
2026-05-17 16:52:48 -07:00
|
|
|
|
sql = textwrap.dedent("""
|
2020-11-06 10:23:16 -08:00
|
|
|
|
with {original} as (
|
|
|
|
|
|
select
|
|
|
|
|
|
rowid,
|
|
|
|
|
|
{columns}
|
2025-11-23 20:43:26 -08:00
|
|
|
|
from {dbtable}{where_clause}
|
2020-11-06 10:23:16 -08:00
|
|
|
|
)
|
|
|
|
|
|
select
|
2020-11-08 08:53:53 -08:00
|
|
|
|
{columns_with_prefix}
|
2020-11-06 10:23:16 -08:00
|
|
|
|
from
|
2025-11-23 20:43:26 -08:00
|
|
|
|
{original}
|
|
|
|
|
|
join {fts_table} on {original}.rowid = {fts_table}.rowid
|
2020-11-06 10:23:16 -08:00
|
|
|
|
where
|
2025-11-23 20:43:26 -08:00
|
|
|
|
{fts_table} match :query
|
2020-11-06 10:23:16 -08:00
|
|
|
|
order by
|
2020-11-06 16:43:33 -08:00
|
|
|
|
{order_by}
|
2021-02-14 12:02:41 -08:00
|
|
|
|
{limit_offset}
|
2026-05-17 16:52:48 -07:00
|
|
|
|
""").strip()
|
2020-11-06 10:23:16 -08:00
|
|
|
|
if virtual_table_using == "FTS5":
|
2025-11-23 20:43:26 -08:00
|
|
|
|
rank_implementation = "{}.rank".format(fts_table_quoted)
|
2020-11-05 10:01:58 -08:00
|
|
|
|
else:
|
2020-11-06 10:23:16 -08:00
|
|
|
|
self.db.register_fts4_bm25()
|
2025-11-23 20:43:26 -08:00
|
|
|
|
rank_implementation = "rank_bm25(matchinfo({}, 'pcnalx'))".format(
|
|
|
|
|
|
fts_table_quoted
|
2020-11-03 14:46:18 -08:00
|
|
|
|
)
|
2022-08-30 22:40:35 -05:00
|
|
|
|
if include_rank:
|
|
|
|
|
|
columns_with_prefix_sql += ",\n " + rank_implementation + " rank"
|
2021-02-14 12:02:41 -08:00
|
|
|
|
limit_offset = ""
|
|
|
|
|
|
if limit is not None:
|
|
|
|
|
|
limit_offset += " limit {}".format(limit)
|
|
|
|
|
|
if offset is not None:
|
|
|
|
|
|
limit_offset += " offset {}".format(offset)
|
2020-11-05 10:01:58 -08:00
|
|
|
|
return sql.format(
|
2025-11-23 20:43:26 -08:00
|
|
|
|
dbtable=quote_identifier(self.name),
|
2022-06-14 14:54:35 -07:00
|
|
|
|
where_clause="\n where {}".format(where) if where else "",
|
2025-11-23 20:43:26 -08:00
|
|
|
|
original=original_quoted,
|
2020-11-05 10:01:58 -08:00
|
|
|
|
columns=columns_sql,
|
2020-11-08 08:53:53 -08:00
|
|
|
|
columns_with_prefix=columns_with_prefix_sql,
|
2025-11-23 20:43:26 -08:00
|
|
|
|
fts_table=fts_table_quoted,
|
2020-11-08 08:53:53 -08:00
|
|
|
|
order_by=order_by or rank_implementation,
|
2021-02-14 12:02:41 -08:00
|
|
|
|
limit_offset=limit_offset.strip(),
|
2020-11-03 14:46:18 -08:00
|
|
|
|
).strip()
|
|
|
|
|
|
|
2021-08-10 16:09:28 -07:00
|
|
|
|
def search(
|
|
|
|
|
|
self,
|
|
|
|
|
|
q: str,
|
|
|
|
|
|
order_by: Optional[str] = None,
|
2021-08-18 12:55:53 -07:00
|
|
|
|
columns: Optional[Iterable[str]] = None,
|
2021-08-10 16:09:28 -07:00
|
|
|
|
limit: Optional[int] = None,
|
|
|
|
|
|
offset: Optional[int] = None,
|
2022-11-15 22:25:26 -08:00
|
|
|
|
where: Optional[str] = None,
|
2022-06-14 14:54:35 -07:00
|
|
|
|
where_args: Optional[Union[Iterable, dict]] = None,
|
2024-11-24 04:34:27 +08:00
|
|
|
|
include_rank: bool = False,
|
2021-08-18 12:55:53 -07:00
|
|
|
|
quote: bool = False,
|
2021-08-10 16:09:28 -07:00
|
|
|
|
) -> Generator[dict, None, None]:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Execute a search against this table using SQLite full-text search, returning a sequence of
|
|
|
|
|
|
dictionaries for each row.
|
|
|
|
|
|
|
2022-03-11 09:38:34 -08:00
|
|
|
|
:param q: Terms to search for
|
|
|
|
|
|
:param order_by: Defaults to order by rank, or specify a column here.
|
|
|
|
|
|
:param columns: List of columns to return, defaults to all columns.
|
|
|
|
|
|
:param limit: Optional integer limit for returned rows.
|
|
|
|
|
|
:param offset: Optional integer SQL offset.
|
2022-06-14 14:54:35 -07:00
|
|
|
|
:param where: Extra SQL fragment for the WHERE clause
|
|
|
|
|
|
:param where_args: Arguments to use for :param placeholders in the extra WHERE clause
|
2024-11-24 04:34:27 +08:00
|
|
|
|
:param include_rank: Select the search rank column in the final query
|
2022-03-11 09:38:34 -08:00
|
|
|
|
:param quote: Apply quoting to disable any special characters in the search query
|
2021-08-10 16:09:28 -07:00
|
|
|
|
|
|
|
|
|
|
See :ref:`python_api_fts_search`.
|
|
|
|
|
|
"""
|
2022-06-14 14:54:35 -07:00
|
|
|
|
args = {"query": self.db.quote_fts(q) if quote else q}
|
|
|
|
|
|
if where_args and "query" in where_args:
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
"'query' is a reserved key and cannot be passed to where_args for .search()"
|
|
|
|
|
|
)
|
|
|
|
|
|
if where_args:
|
|
|
|
|
|
args.update(where_args)
|
|
|
|
|
|
|
2020-11-06 16:43:33 -08:00
|
|
|
|
cursor = self.db.execute(
|
|
|
|
|
|
self.search_sql(
|
|
|
|
|
|
order_by=order_by,
|
|
|
|
|
|
columns=columns,
|
|
|
|
|
|
limit=limit,
|
2021-02-14 12:02:41 -08:00
|
|
|
|
offset=offset,
|
2022-06-14 14:54:35 -07:00
|
|
|
|
where=where,
|
2024-11-24 04:34:27 +08:00
|
|
|
|
include_rank=include_rank,
|
2020-11-06 16:43:33 -08:00
|
|
|
|
),
|
2022-06-14 14:54:35 -07:00
|
|
|
|
args,
|
2020-11-06 16:43:33 -08:00
|
|
|
|
)
|
2026-07-05 21:20:39 -07:00
|
|
|
|
columns = dedupe_keys(c[0] for c in cursor.description)
|
2020-11-06 10:23:16 -08:00
|
|
|
|
for row in cursor:
|
|
|
|
|
|
yield dict(zip(columns, row))
|
2018-07-31 09:19:05 -07:00
|
|
|
|
|
More type annotations (#697)
* Add comprehensive type annotations
- mypy.ini: expanded configuration with module-specific settings
- hookspecs.py: type annotations for hook functions
- plugins.py: typed get_plugins() return value
- recipes.py: full type annotations for parsedate, parsedatetime, jsonsplit
- utils.py: extensive type annotations including Row type alias,
TypeTracker, ValueTracker, and all utility functions
- db.py: type annotations for Database methods (__exit__,
ensure_autocommit_off, tracer, register_function, etc.) and
Queryable class methods
- tests/test_docs.py: updated to match new signature display format
* Fix type errors caught by ty check
- Add type: ignore comments for external library type stub limitations
(csv.reader, click.progressbar, IOBase.name, Callable.__name__)
- Change Iterable to Sequence for SQL where_args parameters
- Use db.table() instead of db[name] for proper Table return type
- Fix rebuild_fts return type from None to Table
- Update test_tracer to expect fewer queries (optimization side effect)
* Fix mypy type errors
- Add type: ignore comments for runtime-valid patterns mypy can't verify
- Fix new_column_types annotation to Dict[str, Set[type]]
- Add type: ignore for Default sentinel values passed to create_table
* mypy skip tests directory
* Fix CI: exclude typing imports from recipe docs, skip mypy on tests
- Add Callable and Optional to exclusion list in _generate_convert_help()
- Regenerate docs/cli-reference.rst with cog
- Add [mypy-tests.*] ignore_errors = True to skip test type errors
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 22:11:47 -08:00
|
|
|
|
def value_or_default(self, key: str, value: Any) -> Any:
|
2019-07-22 16:30:54 -07:00
|
|
|
|
return self._defaults[key] if value is DEFAULT else value
|
|
|
|
|
|
|
2021-08-10 16:09:28 -07:00
|
|
|
|
def delete(self, pk_values: Union[list, tuple, str, int, float]) -> "Table":
|
2022-03-11 09:38:34 -08:00
|
|
|
|
"""
|
|
|
|
|
|
Delete row matching the specified primary key.
|
|
|
|
|
|
|
|
|
|
|
|
:param pk_values: A single value, or a tuple of values for tables that have a compound primary key
|
|
|
|
|
|
"""
|
2019-11-04 08:07:44 -08:00
|
|
|
|
if not isinstance(pk_values, (list, tuple)):
|
|
|
|
|
|
pk_values = [pk_values]
|
|
|
|
|
|
self.get(pk_values)
|
2025-11-23 20:43:26 -08:00
|
|
|
|
wheres = ["{} = ?".format(quote_identifier(pk_name)) for pk_name in self.pks]
|
|
|
|
|
|
sql = "delete from {} where {wheres}".format(
|
|
|
|
|
|
quote_identifier(self.name), wheres=" and ".join(wheres)
|
2019-11-04 08:07:44 -08:00
|
|
|
|
)
|
2026-06-21 10:43:19 -07:00
|
|
|
|
with self.db.atomic():
|
2020-09-07 14:56:59 -07:00
|
|
|
|
self.db.execute(sql, pk_values)
|
2020-09-24 07:51:36 -07:00
|
|
|
|
return self
|
2019-11-04 08:07:44 -08:00
|
|
|
|
|
2021-08-10 16:09:28 -07:00
|
|
|
|
def delete_where(
|
2022-01-10 17:08:05 -08:00
|
|
|
|
self,
|
2022-11-15 22:25:26 -08:00
|
|
|
|
where: Optional[str] = None,
|
More type annotations (#697)
* Add comprehensive type annotations
- mypy.ini: expanded configuration with module-specific settings
- hookspecs.py: type annotations for hook functions
- plugins.py: typed get_plugins() return value
- recipes.py: full type annotations for parsedate, parsedatetime, jsonsplit
- utils.py: extensive type annotations including Row type alias,
TypeTracker, ValueTracker, and all utility functions
- db.py: type annotations for Database methods (__exit__,
ensure_autocommit_off, tracer, register_function, etc.) and
Queryable class methods
- tests/test_docs.py: updated to match new signature display format
* Fix type errors caught by ty check
- Add type: ignore comments for external library type stub limitations
(csv.reader, click.progressbar, IOBase.name, Callable.__name__)
- Change Iterable to Sequence for SQL where_args parameters
- Use db.table() instead of db[name] for proper Table return type
- Fix rebuild_fts return type from None to Table
- Update test_tracer to expect fewer queries (optimization side effect)
* Fix mypy type errors
- Add type: ignore comments for runtime-valid patterns mypy can't verify
- Fix new_column_types annotation to Dict[str, Set[type]]
- Add type: ignore for Default sentinel values passed to create_table
* mypy skip tests directory
* Fix CI: exclude typing imports from recipe docs, skip mypy on tests
- Add Callable and Optional to exclusion list in _generate_convert_help()
- Regenerate docs/cli-reference.rst with cog
- Add [mypy-tests.*] ignore_errors = True to skip test type errors
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 22:11:47 -08:00
|
|
|
|
where_args: Optional[Union[Sequence, Dict[str, Any]]] = None,
|
2022-01-10 17:08:05 -08:00
|
|
|
|
analyze: bool = False,
|
2021-08-10 16:09:28 -07:00
|
|
|
|
) -> "Table":
|
2022-01-10 17:08:05 -08:00
|
|
|
|
"""
|
|
|
|
|
|
Delete rows matching the specified where clause, or delete all rows in the table.
|
|
|
|
|
|
|
|
|
|
|
|
See :ref:`python_api_delete_where`.
|
2022-03-11 09:38:34 -08:00
|
|
|
|
|
|
|
|
|
|
:param where: SQL where fragment to use, for example ``id > ?``
|
|
|
|
|
|
:param where_args: Parameters to use with that fragment - an iterable for ``id > ?``
|
|
|
|
|
|
parameters, or a dictionary for ``id > :id``
|
|
|
|
|
|
:param analyze: Set to ``True`` to run ``ANALYZE`` after the rows have been deleted.
|
2022-01-10 17:08:05 -08:00
|
|
|
|
"""
|
2020-02-08 15:56:03 -08:00
|
|
|
|
if not self.exists():
|
2021-08-10 16:09:28 -07:00
|
|
|
|
return self
|
2025-11-23 20:43:26 -08:00
|
|
|
|
sql = "delete from {}".format(quote_identifier(self.name))
|
2019-11-04 08:18:06 -08:00
|
|
|
|
if where is not None:
|
|
|
|
|
|
sql += " where " + where
|
2026-07-04 17:57:35 +00:00
|
|
|
|
with self.db.atomic():
|
|
|
|
|
|
self.db.execute(sql, where_args or [])
|
2022-01-10 17:08:05 -08:00
|
|
|
|
if analyze:
|
|
|
|
|
|
self.analyze()
|
2020-09-24 07:51:36 -07:00
|
|
|
|
return self
|
2019-11-04 08:18:06 -08:00
|
|
|
|
|
2021-08-10 16:09:28 -07:00
|
|
|
|
def update(
|
|
|
|
|
|
self,
|
|
|
|
|
|
pk_values: Union[list, tuple, str, int, float],
|
|
|
|
|
|
updates: Optional[dict] = None,
|
|
|
|
|
|
alter: bool = False,
|
|
|
|
|
|
conversions: Optional[dict] = None,
|
|
|
|
|
|
) -> "Table":
|
|
|
|
|
|
"""
|
|
|
|
|
|
Execute a SQL ``UPDATE`` against the specified row.
|
|
|
|
|
|
|
2022-03-11 09:38:34 -08:00
|
|
|
|
See :ref:`python_api_update`.
|
|
|
|
|
|
|
|
|
|
|
|
:param pk_values: The primary key of an individual record - can be a tuple if the
|
2021-08-10 16:09:28 -07:00
|
|
|
|
table has a compound primary key.
|
2022-03-11 09:38:34 -08:00
|
|
|
|
:param updates: A dictionary mapping columns to their updated values.
|
|
|
|
|
|
:param alter: Set to ``True`` to add any missing columns.
|
|
|
|
|
|
:param conversions: Optional dictionary of SQL functions to apply during the update, for example
|
2021-08-10 16:09:28 -07:00
|
|
|
|
``{"mycolumn": "upper(?)"}``.
|
|
|
|
|
|
"""
|
2019-07-14 10:03:18 -07:00
|
|
|
|
updates = updates or {}
|
2020-01-30 16:24:30 -08:00
|
|
|
|
conversions = conversions or {}
|
2019-07-14 10:03:18 -07:00
|
|
|
|
if not isinstance(pk_values, (list, tuple)):
|
|
|
|
|
|
pk_values = [pk_values]
|
2020-09-08 15:24:39 -07:00
|
|
|
|
# Soundness check that the record exists (raises error if not):
|
2020-09-24 08:43:55 -07:00
|
|
|
|
self.get(pk_values)
|
2019-07-28 17:59:52 +03:00
|
|
|
|
if not updates:
|
|
|
|
|
|
return self
|
2019-07-14 10:03:18 -07:00
|
|
|
|
args = []
|
|
|
|
|
|
sets = []
|
|
|
|
|
|
wheres = []
|
2020-09-24 08:43:55 -07:00
|
|
|
|
pks = self.pks
|
2019-07-14 10:03:18 -07:00
|
|
|
|
for key, value in updates.items():
|
2025-11-23 20:43:26 -08:00
|
|
|
|
sets.append(
|
|
|
|
|
|
"{} = {}".format(quote_identifier(key), conversions.get(key, "?"))
|
|
|
|
|
|
)
|
2020-12-08 18:49:42 +01:00
|
|
|
|
args.append(jsonify_if_needed(value))
|
2025-11-23 20:43:26 -08:00
|
|
|
|
wheres = ["{} = ?".format(quote_identifier(pk_name)) for pk_name in pks]
|
2019-07-14 10:03:18 -07:00
|
|
|
|
args.extend(pk_values)
|
2025-11-23 20:43:26 -08:00
|
|
|
|
sql = "update {} set {sets} where {wheres}".format(
|
|
|
|
|
|
quote_identifier(self.name),
|
|
|
|
|
|
sets=", ".join(sets),
|
|
|
|
|
|
wheres=" and ".join(wheres),
|
2019-07-14 10:03:18 -07:00
|
|
|
|
)
|
2026-06-21 10:43:19 -07:00
|
|
|
|
with self.db.atomic():
|
2019-07-28 17:51:49 +03:00
|
|
|
|
try:
|
2020-09-07 14:56:59 -07:00
|
|
|
|
rowcount = self.db.execute(sql, args).rowcount
|
2019-07-28 17:51:49 +03:00
|
|
|
|
except OperationalError as e:
|
|
|
|
|
|
if alter and (" column" in e.args[0]):
|
|
|
|
|
|
# Attempt to add any missing columns, then try again
|
|
|
|
|
|
self.add_missing_columns([updates])
|
2020-09-07 14:56:59 -07:00
|
|
|
|
rowcount = self.db.execute(sql, args).rowcount
|
2019-07-28 17:51:49 +03:00
|
|
|
|
else:
|
|
|
|
|
|
raise
|
|
|
|
|
|
|
2019-07-14 10:03:18 -07:00
|
|
|
|
# TODO: Test this works (rolls back) - use better exception:
|
|
|
|
|
|
assert rowcount == 1
|
2020-09-23 13:12:09 -07:00
|
|
|
|
self.last_pk = pk_values[0] if len(pks) == 1 else pk_values
|
2019-07-14 10:03:18 -07:00
|
|
|
|
return self
|
|
|
|
|
|
|
2021-08-01 21:47:39 -07:00
|
|
|
|
def convert(
|
|
|
|
|
|
self,
|
2021-08-10 16:09:28 -07:00
|
|
|
|
columns: Union[str, List[str]],
|
|
|
|
|
|
fn: Callable,
|
|
|
|
|
|
output: Optional[str] = None,
|
|
|
|
|
|
output_type: Optional[Any] = None,
|
|
|
|
|
|
drop: bool = False,
|
|
|
|
|
|
multi: bool = False,
|
|
|
|
|
|
where: Optional[str] = None,
|
More type annotations (#697)
* Add comprehensive type annotations
- mypy.ini: expanded configuration with module-specific settings
- hookspecs.py: type annotations for hook functions
- plugins.py: typed get_plugins() return value
- recipes.py: full type annotations for parsedate, parsedatetime, jsonsplit
- utils.py: extensive type annotations including Row type alias,
TypeTracker, ValueTracker, and all utility functions
- db.py: type annotations for Database methods (__exit__,
ensure_autocommit_off, tracer, register_function, etc.) and
Queryable class methods
- tests/test_docs.py: updated to match new signature display format
* Fix type errors caught by ty check
- Add type: ignore comments for external library type stub limitations
(csv.reader, click.progressbar, IOBase.name, Callable.__name__)
- Change Iterable to Sequence for SQL where_args parameters
- Use db.table() instead of db[name] for proper Table return type
- Fix rebuild_fts return type from None to Table
- Update test_tracer to expect fewer queries (optimization side effect)
* Fix mypy type errors
- Add type: ignore comments for runtime-valid patterns mypy can't verify
- Fix new_column_types annotation to Dict[str, Set[type]]
- Add type: ignore for Default sentinel values passed to create_table
* mypy skip tests directory
* Fix CI: exclude typing imports from recipe docs, skip mypy on tests
- Add Callable and Optional to exclusion list in _generate_convert_help()
- Regenerate docs/cli-reference.rst with cog
- Add [mypy-tests.*] ignore_errors = True to skip test type errors
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 22:11:47 -08:00
|
|
|
|
where_args: Optional[Union[Sequence, Dict[str, Any]]] = None,
|
2021-08-10 16:09:28 -07:00
|
|
|
|
show_progress: bool = False,
|
More type annotations (#697)
* Add comprehensive type annotations
- mypy.ini: expanded configuration with module-specific settings
- hookspecs.py: type annotations for hook functions
- plugins.py: typed get_plugins() return value
- recipes.py: full type annotations for parsedate, parsedatetime, jsonsplit
- utils.py: extensive type annotations including Row type alias,
TypeTracker, ValueTracker, and all utility functions
- db.py: type annotations for Database methods (__exit__,
ensure_autocommit_off, tracer, register_function, etc.) and
Queryable class methods
- tests/test_docs.py: updated to match new signature display format
* Fix type errors caught by ty check
- Add type: ignore comments for external library type stub limitations
(csv.reader, click.progressbar, IOBase.name, Callable.__name__)
- Change Iterable to Sequence for SQL where_args parameters
- Use db.table() instead of db[name] for proper Table return type
- Fix rebuild_fts return type from None to Table
- Update test_tracer to expect fewer queries (optimization side effect)
* Fix mypy type errors
- Add type: ignore comments for runtime-valid patterns mypy can't verify
- Fix new_column_types annotation to Dict[str, Set[type]]
- Add type: ignore for Default sentinel values passed to create_table
* mypy skip tests directory
* Fix CI: exclude typing imports from recipe docs, skip mypy on tests
- Add Callable and Optional to exclusion list in _generate_convert_help()
- Regenerate docs/cli-reference.rst with cog
- Add [mypy-tests.*] ignore_errors = True to skip test type errors
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 22:11:47 -08:00
|
|
|
|
) -> "Table":
|
2021-08-10 16:09:28 -07:00
|
|
|
|
"""
|
|
|
|
|
|
Apply conversion function ``fn`` to every value in the specified columns.
|
|
|
|
|
|
|
2022-03-11 09:38:34 -08:00
|
|
|
|
:param columns: A single column or list of string column names to convert.
|
|
|
|
|
|
:param fn: A callable that takes a single argument, ``value``, and returns it converted.
|
|
|
|
|
|
:param output: Optional string column name to write the results to (defaults to the input column).
|
|
|
|
|
|
:param output_type: If the output column needs to be created, this is the type that will be used
|
2021-08-10 16:09:28 -07:00
|
|
|
|
for the new column.
|
2022-03-11 09:38:34 -08:00
|
|
|
|
:param drop: Should the original column be dropped once the conversion is complete?
|
|
|
|
|
|
:param multi: If ``True`` the return value of ``fn(value)`` will be expected to be a
|
2021-08-10 16:09:28 -07:00
|
|
|
|
dictionary, and new columns will be created for each key of that dictionary.
|
2022-03-11 09:38:34 -08:00
|
|
|
|
:param where: SQL fragment to use as a ``WHERE`` clause to limit the rows to which the conversion
|
2021-08-10 16:09:28 -07:00
|
|
|
|
is applied, for example ``age > ?`` or ``age > :age``.
|
2022-03-11 09:38:34 -08:00
|
|
|
|
:param where_args: List of arguments (if using ``?``) or a dictionary (if using ``:age``).
|
|
|
|
|
|
:param show_progress: Should a progress bar be displayed?
|
2021-08-10 16:09:28 -07:00
|
|
|
|
|
|
|
|
|
|
See :ref:`python_api_convert`.
|
|
|
|
|
|
"""
|
2021-08-01 21:47:39 -07:00
|
|
|
|
if isinstance(columns, str):
|
|
|
|
|
|
columns = [columns]
|
2026-07-05 22:11:22 -07:00
|
|
|
|
columns = [resolve_casing(c, self.columns_dict) for c in columns]
|
2021-08-01 21:47:39 -07:00
|
|
|
|
|
|
|
|
|
|
if multi:
|
|
|
|
|
|
return self._convert_multi(
|
2021-08-01 22:05:03 -07:00
|
|
|
|
columns[0],
|
|
|
|
|
|
fn,
|
|
|
|
|
|
drop=drop,
|
|
|
|
|
|
where=where,
|
|
|
|
|
|
where_args=where_args,
|
|
|
|
|
|
show_progress=show_progress,
|
2021-08-01 21:47:39 -07:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if output is not None:
|
2026-07-04 19:19:24 +00:00
|
|
|
|
if len(columns) != 1:
|
|
|
|
|
|
raise ValueError("output= can only be used with a single column")
|
2026-07-05 22:11:22 -07:00
|
|
|
|
output = resolve_casing(output, self.columns_dict)
|
2021-08-01 21:47:39 -07:00
|
|
|
|
if output not in self.columns_dict:
|
|
|
|
|
|
self.add_column(output, output_type or "text")
|
|
|
|
|
|
|
2021-08-02 11:33:56 -07:00
|
|
|
|
todo_count = self.count_where(where, where_args) * len(columns)
|
2021-08-01 21:47:39 -07:00
|
|
|
|
with progressbar(length=todo_count, silent=not show_progress) as bar:
|
|
|
|
|
|
|
|
|
|
|
|
def convert_value(v):
|
|
|
|
|
|
bar.update(1)
|
2022-10-25 14:23:24 -07:00
|
|
|
|
return jsonify_if_needed(fn(v))
|
2021-08-01 21:47:39 -07:00
|
|
|
|
|
2025-12-16 19:34:45 -08:00
|
|
|
|
fn_name = getattr(fn, "__name__", "fn")
|
2023-05-08 14:54:24 -07:00
|
|
|
|
if fn_name == "<lambda>":
|
2023-05-08 15:12:39 -07:00
|
|
|
|
fn_name = f"lambda_{abs(hash(fn))}"
|
2023-05-08 23:53:58 +02:00
|
|
|
|
self.db.register_function(convert_value, name=fn_name)
|
2025-11-23 20:43:26 -08:00
|
|
|
|
sql = "update {} set {sets}{where};".format(
|
|
|
|
|
|
quote_identifier(self.name),
|
2021-08-01 21:47:39 -07:00
|
|
|
|
sets=", ".join(
|
|
|
|
|
|
[
|
2025-11-23 20:43:26 -08:00
|
|
|
|
"{} = {}({})".format(
|
|
|
|
|
|
quote_identifier(output or column),
|
|
|
|
|
|
fn_name,
|
|
|
|
|
|
quote_identifier(column),
|
2021-08-01 21:47:39 -07:00
|
|
|
|
)
|
|
|
|
|
|
for column in columns
|
|
|
|
|
|
]
|
|
|
|
|
|
),
|
2021-08-02 11:33:56 -07:00
|
|
|
|
where=" where {}".format(where) if where is not None else "",
|
2021-08-01 21:47:39 -07:00
|
|
|
|
)
|
2026-06-21 10:43:19 -07:00
|
|
|
|
with self.db.atomic():
|
2021-08-02 11:33:56 -07:00
|
|
|
|
self.db.execute(sql, where_args or [])
|
2021-08-01 21:47:39 -07:00
|
|
|
|
if drop:
|
|
|
|
|
|
self.transform(drop=columns)
|
|
|
|
|
|
return self
|
|
|
|
|
|
|
2021-08-01 22:05:03 -07:00
|
|
|
|
def _convert_multi(
|
|
|
|
|
|
self, column, fn, drop, show_progress, where=None, where_args=None
|
|
|
|
|
|
):
|
2021-08-01 21:47:39 -07:00
|
|
|
|
# First we execute the function
|
|
|
|
|
|
pk_to_values = {}
|
More type annotations (#697)
* Add comprehensive type annotations
- mypy.ini: expanded configuration with module-specific settings
- hookspecs.py: type annotations for hook functions
- plugins.py: typed get_plugins() return value
- recipes.py: full type annotations for parsedate, parsedatetime, jsonsplit
- utils.py: extensive type annotations including Row type alias,
TypeTracker, ValueTracker, and all utility functions
- db.py: type annotations for Database methods (__exit__,
ensure_autocommit_off, tracer, register_function, etc.) and
Queryable class methods
- tests/test_docs.py: updated to match new signature display format
* Fix type errors caught by ty check
- Add type: ignore comments for external library type stub limitations
(csv.reader, click.progressbar, IOBase.name, Callable.__name__)
- Change Iterable to Sequence for SQL where_args parameters
- Use db.table() instead of db[name] for proper Table return type
- Fix rebuild_fts return type from None to Table
- Update test_tracer to expect fewer queries (optimization side effect)
* Fix mypy type errors
- Add type: ignore comments for runtime-valid patterns mypy can't verify
- Fix new_column_types annotation to Dict[str, Set[type]]
- Add type: ignore for Default sentinel values passed to create_table
* mypy skip tests directory
* Fix CI: exclude typing imports from recipe docs, skip mypy on tests
- Add Callable and Optional to exclusion list in _generate_convert_help()
- Regenerate docs/cli-reference.rst with cog
- Add [mypy-tests.*] ignore_errors = True to skip test type errors
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 22:11:47 -08:00
|
|
|
|
new_column_types: Dict[str, Set[type]] = {}
|
2026-07-06 21:34:02 -07:00
|
|
|
|
pks = self.pks
|
2021-08-01 21:47:39 -07:00
|
|
|
|
|
|
|
|
|
|
with progressbar(
|
|
|
|
|
|
length=self.count, silent=not show_progress, label="1: Evaluating"
|
|
|
|
|
|
) as bar:
|
|
|
|
|
|
for row in self.rows_where(
|
|
|
|
|
|
select=", ".join(
|
2025-11-23 20:43:26 -08:00
|
|
|
|
quote_identifier(column_name) for column_name in (pks + [column])
|
2021-08-02 11:33:56 -07:00
|
|
|
|
),
|
|
|
|
|
|
where=where,
|
|
|
|
|
|
where_args=where_args,
|
2021-08-01 21:47:39 -07:00
|
|
|
|
):
|
|
|
|
|
|
row_pk = tuple(row[pk] for pk in pks)
|
|
|
|
|
|
if len(row_pk) == 1:
|
|
|
|
|
|
row_pk = row_pk[0]
|
|
|
|
|
|
values = fn(row[column])
|
|
|
|
|
|
if values is not None and not isinstance(values, dict):
|
|
|
|
|
|
raise BadMultiValues(values)
|
|
|
|
|
|
if values:
|
|
|
|
|
|
for key, value in values.items():
|
|
|
|
|
|
new_column_types.setdefault(key, set()).add(type(value))
|
|
|
|
|
|
pk_to_values[row_pk] = values
|
|
|
|
|
|
bar.update(1)
|
|
|
|
|
|
|
|
|
|
|
|
# Add any new columns
|
|
|
|
|
|
columns_to_create = types_for_column_types(new_column_types)
|
|
|
|
|
|
for column_name, column_type in columns_to_create.items():
|
|
|
|
|
|
if column_name not in self.columns_dict:
|
|
|
|
|
|
self.add_column(column_name, column_type)
|
|
|
|
|
|
|
|
|
|
|
|
# Run the updates
|
|
|
|
|
|
with progressbar(
|
|
|
|
|
|
length=self.count, silent=not show_progress, label="2: Updating"
|
|
|
|
|
|
) as bar:
|
2026-06-21 10:43:19 -07:00
|
|
|
|
with self.db.atomic():
|
2021-08-01 21:47:39 -07:00
|
|
|
|
for pk, updates in pk_to_values.items():
|
|
|
|
|
|
self.update(pk, updates)
|
|
|
|
|
|
bar.update(1)
|
|
|
|
|
|
if drop:
|
|
|
|
|
|
self.transform(drop=(column,))
|
|
|
|
|
|
|
2020-09-08 16:20:36 -07:00
|
|
|
|
def build_insert_queries_and_params(
|
|
|
|
|
|
self,
|
|
|
|
|
|
extracts,
|
|
|
|
|
|
chunk,
|
|
|
|
|
|
all_columns,
|
|
|
|
|
|
hash_id,
|
2022-03-01 16:00:51 -08:00
|
|
|
|
hash_id_columns,
|
2020-09-08 16:20:36 -07:00
|
|
|
|
upsert,
|
|
|
|
|
|
pk,
|
2023-05-08 12:24:10 -07:00
|
|
|
|
not_null,
|
2020-09-08 16:20:36 -07:00
|
|
|
|
conversions,
|
|
|
|
|
|
num_records_processed,
|
|
|
|
|
|
replace,
|
|
|
|
|
|
ignore,
|
2025-11-23 12:17:23 -08:00
|
|
|
|
list_mode=False,
|
2020-09-08 16:20:36 -07:00
|
|
|
|
):
|
2025-05-08 20:37:49 -07:00
|
|
|
|
"""
|
|
|
|
|
|
Given a list ``chunk`` of records that should be written to *this* table,
|
|
|
|
|
|
return a list of ``(sql, parameters)`` 2-tuples which, when executed in
|
|
|
|
|
|
order, perform the desired INSERT / UPSERT / REPLACE operation.
|
|
|
|
|
|
"""
|
2026-06-22 12:00:05 -07:00
|
|
|
|
# Dict-mode insert({}) has no explicit columns; SQLite spells that as
|
|
|
|
|
|
# DEFAULT VALUES. List mode with no columns is a different input shape.
|
|
|
|
|
|
if not list_mode and not all_columns:
|
2026-07-04 18:43:37 +00:00
|
|
|
|
if upsert:
|
|
|
|
|
|
raise PrimaryKeyRequired(
|
|
|
|
|
|
"upsert() requires a value for the primary key - "
|
|
|
|
|
|
"an empty record cannot be upserted"
|
|
|
|
|
|
)
|
2026-06-22 12:00:05 -07:00
|
|
|
|
or_clause = ""
|
|
|
|
|
|
if replace:
|
|
|
|
|
|
or_clause = " OR REPLACE"
|
|
|
|
|
|
elif ignore:
|
|
|
|
|
|
or_clause = " OR IGNORE"
|
|
|
|
|
|
sql = (
|
|
|
|
|
|
f"INSERT{or_clause} INTO {quote_identifier(self.name)} "
|
|
|
|
|
|
"DEFAULT VALUES"
|
|
|
|
|
|
)
|
|
|
|
|
|
return [(sql, []) for _ in chunk]
|
|
|
|
|
|
|
2022-03-01 16:00:51 -08:00
|
|
|
|
if hash_id_columns and hash_id is None:
|
|
|
|
|
|
hash_id = "id"
|
2025-05-08 20:37:49 -07:00
|
|
|
|
|
2020-09-08 16:20:36 -07:00
|
|
|
|
extracts = resolve_extracts(extracts)
|
2025-05-08 20:37:49 -07:00
|
|
|
|
|
|
|
|
|
|
# Build a row-list ready for executemany-style flattening
|
|
|
|
|
|
values = []
|
|
|
|
|
|
|
2025-11-23 12:17:23 -08:00
|
|
|
|
if list_mode:
|
|
|
|
|
|
# In list mode, records are already lists of values
|
|
|
|
|
|
num_columns = len(all_columns)
|
|
|
|
|
|
has_extracts = bool(extracts)
|
|
|
|
|
|
for record in chunk:
|
|
|
|
|
|
# Pad short records with None, truncate long ones
|
|
|
|
|
|
record_len = len(record)
|
|
|
|
|
|
if record_len < num_columns:
|
|
|
|
|
|
record_values = [jsonify_if_needed(v) for v in record] + [None] * (
|
|
|
|
|
|
num_columns - record_len
|
2022-03-01 16:00:51 -08:00
|
|
|
|
)
|
2025-11-23 12:17:23 -08:00
|
|
|
|
else:
|
|
|
|
|
|
record_values = [jsonify_if_needed(v) for v in record[:num_columns]]
|
|
|
|
|
|
# Only process extracts if there are any
|
|
|
|
|
|
if has_extracts:
|
|
|
|
|
|
for i, key in enumerate(all_columns):
|
2026-07-06 20:39:29 -07:00
|
|
|
|
if key in extracts and record_values[i] is not None:
|
More type annotations (#697)
* Add comprehensive type annotations
- mypy.ini: expanded configuration with module-specific settings
- hookspecs.py: type annotations for hook functions
- plugins.py: typed get_plugins() return value
- recipes.py: full type annotations for parsedate, parsedatetime, jsonsplit
- utils.py: extensive type annotations including Row type alias,
TypeTracker, ValueTracker, and all utility functions
- db.py: type annotations for Database methods (__exit__,
ensure_autocommit_off, tracer, register_function, etc.) and
Queryable class methods
- tests/test_docs.py: updated to match new signature display format
* Fix type errors caught by ty check
- Add type: ignore comments for external library type stub limitations
(csv.reader, click.progressbar, IOBase.name, Callable.__name__)
- Change Iterable to Sequence for SQL where_args parameters
- Use db.table() instead of db[name] for proper Table return type
- Fix rebuild_fts return type from None to Table
- Update test_tracer to expect fewer queries (optimization side effect)
* Fix mypy type errors
- Add type: ignore comments for runtime-valid patterns mypy can't verify
- Fix new_column_types annotation to Dict[str, Set[type]]
- Add type: ignore for Default sentinel values passed to create_table
* mypy skip tests directory
* Fix CI: exclude typing imports from recipe docs, skip mypy on tests
- Add Callable and Optional to exclusion list in _generate_convert_help()
- Regenerate docs/cli-reference.rst with cog
- Add [mypy-tests.*] ignore_errors = True to skip test type errors
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 22:11:47 -08:00
|
|
|
|
record_values[i] = self.db.table(extracts[key]).lookup(
|
2025-11-23 12:17:23 -08:00
|
|
|
|
{"value": record_values[i]}
|
|
|
|
|
|
)
|
|
|
|
|
|
values.append(record_values)
|
|
|
|
|
|
else:
|
|
|
|
|
|
# Dict mode: original logic
|
|
|
|
|
|
for record in chunk:
|
|
|
|
|
|
record_values = []
|
|
|
|
|
|
for key in all_columns:
|
|
|
|
|
|
value = jsonify_if_needed(
|
|
|
|
|
|
record.get(
|
|
|
|
|
|
key,
|
|
|
|
|
|
(
|
|
|
|
|
|
None
|
|
|
|
|
|
if key != hash_id
|
|
|
|
|
|
else hash_record(record, hash_id_columns)
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
2026-07-06 20:39:29 -07:00
|
|
|
|
if key in extracts and value is not None:
|
2025-11-23 12:17:23 -08:00
|
|
|
|
extract_table = extracts[key]
|
More type annotations (#697)
* Add comprehensive type annotations
- mypy.ini: expanded configuration with module-specific settings
- hookspecs.py: type annotations for hook functions
- plugins.py: typed get_plugins() return value
- recipes.py: full type annotations for parsedate, parsedatetime, jsonsplit
- utils.py: extensive type annotations including Row type alias,
TypeTracker, ValueTracker, and all utility functions
- db.py: type annotations for Database methods (__exit__,
ensure_autocommit_off, tracer, register_function, etc.) and
Queryable class methods
- tests/test_docs.py: updated to match new signature display format
* Fix type errors caught by ty check
- Add type: ignore comments for external library type stub limitations
(csv.reader, click.progressbar, IOBase.name, Callable.__name__)
- Change Iterable to Sequence for SQL where_args parameters
- Use db.table() instead of db[name] for proper Table return type
- Fix rebuild_fts return type from None to Table
- Update test_tracer to expect fewer queries (optimization side effect)
* Fix mypy type errors
- Add type: ignore comments for runtime-valid patterns mypy can't verify
- Fix new_column_types annotation to Dict[str, Set[type]]
- Add type: ignore for Default sentinel values passed to create_table
* mypy skip tests directory
* Fix CI: exclude typing imports from recipe docs, skip mypy on tests
- Add Callable and Optional to exclusion list in _generate_convert_help()
- Regenerate docs/cli-reference.rst with cog
- Add [mypy-tests.*] ignore_errors = True to skip test type errors
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 22:11:47 -08:00
|
|
|
|
value = self.db.table(extract_table).lookup({"value": value})
|
2025-11-23 12:17:23 -08:00
|
|
|
|
record_values.append(value)
|
|
|
|
|
|
values.append(record_values)
|
2020-09-08 16:20:36 -07:00
|
|
|
|
|
2025-11-23 20:43:26 -08:00
|
|
|
|
columns_sql = ", ".join(quote_identifier(c) for c in all_columns)
|
2025-05-08 20:37:49 -07:00
|
|
|
|
placeholder_expr = ", ".join(conversions.get(c, "?") for c in all_columns)
|
|
|
|
|
|
row_placeholders_sql = ", ".join(f"({placeholder_expr})" for _ in values)
|
|
|
|
|
|
flat_params = list(itertools.chain.from_iterable(values))
|
|
|
|
|
|
|
|
|
|
|
|
# replace=True mean INSERT OR REPLACE INTO
|
|
|
|
|
|
if replace:
|
|
|
|
|
|
sql = (
|
2025-11-23 20:43:26 -08:00
|
|
|
|
f"INSERT OR REPLACE INTO {quote_identifier(self.name)} "
|
2025-05-08 20:37:49 -07:00
|
|
|
|
f"({columns_sql}) VALUES {row_placeholders_sql}"
|
|
|
|
|
|
)
|
|
|
|
|
|
return [(sql, flat_params)]
|
|
|
|
|
|
|
|
|
|
|
|
# If not an upsert it's an INSERT, maybe with OR IGNORE
|
|
|
|
|
|
if not upsert:
|
|
|
|
|
|
or_ignore = ""
|
|
|
|
|
|
if ignore:
|
|
|
|
|
|
or_ignore = " OR IGNORE"
|
|
|
|
|
|
sql = (
|
2025-11-23 20:43:26 -08:00
|
|
|
|
f"INSERT{or_ignore} INTO {quote_identifier(self.name)} "
|
2025-05-08 20:37:49 -07:00
|
|
|
|
f"({columns_sql}) VALUES {row_placeholders_sql}"
|
|
|
|
|
|
)
|
|
|
|
|
|
return [(sql, flat_params)]
|
|
|
|
|
|
|
|
|
|
|
|
# Everything from here on is for upsert=True
|
|
|
|
|
|
pk_cols = [pk] if isinstance(pk, str) else list(pk)
|
2026-07-05 22:11:22 -07:00
|
|
|
|
# The records may use different casing for the pk columns than pk=
|
|
|
|
|
|
pk_cols = [resolve_casing(c, all_columns) for c in pk_cols]
|
2026-07-04 18:43:37 +00:00
|
|
|
|
# Every record must provide a value for every primary key column - a
|
|
|
|
|
|
# NULL primary key never matches ON CONFLICT, so the record would be
|
|
|
|
|
|
# inserted as a brand new row instead of upserted
|
|
|
|
|
|
missing_pk_cols = [c for c in pk_cols if c not in all_columns]
|
|
|
|
|
|
if missing_pk_cols:
|
|
|
|
|
|
raise PrimaryKeyRequired(
|
|
|
|
|
|
"upsert() requires a value for the primary key column{}: {}".format(
|
|
|
|
|
|
"s" if len(missing_pk_cols) > 1 else "",
|
|
|
|
|
|
", ".join(missing_pk_cols),
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
pk_indexes = [all_columns.index(c) for c in pk_cols]
|
|
|
|
|
|
for record_values in values:
|
|
|
|
|
|
if any(record_values[i] is None for i in pk_indexes):
|
|
|
|
|
|
raise PrimaryKeyRequired(
|
|
|
|
|
|
"upsert() requires a value for the primary key column{}: {}".format(
|
|
|
|
|
|
"s" if len(pk_cols) > 1 else "",
|
|
|
|
|
|
", ".join(pk_cols),
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
2025-05-08 20:37:49 -07:00
|
|
|
|
non_pk_cols = [c for c in all_columns if c not in pk_cols]
|
2025-11-23 20:43:26 -08:00
|
|
|
|
conflict_sql = ", ".join(quote_identifier(c) for c in pk_cols)
|
2025-05-08 20:37:49 -07:00
|
|
|
|
|
|
|
|
|
|
if self.db.supports_on_conflict and not self.db.use_old_upsert:
|
|
|
|
|
|
if non_pk_cols:
|
|
|
|
|
|
# DO UPDATE
|
|
|
|
|
|
assignments = []
|
|
|
|
|
|
for c in non_pk_cols:
|
2025-11-23 20:43:26 -08:00
|
|
|
|
c_quoted = quote_identifier(c)
|
2025-05-08 20:37:49 -07:00
|
|
|
|
if c in conversions:
|
|
|
|
|
|
assignments.append(
|
2025-11-23 20:43:26 -08:00
|
|
|
|
f"{c_quoted} = {conversions[c].replace('?', f'excluded.{c_quoted}')}"
|
2021-06-12 19:57:21 -07:00
|
|
|
|
)
|
2025-05-08 20:37:49 -07:00
|
|
|
|
else:
|
2025-11-23 20:43:26 -08:00
|
|
|
|
assignments.append(f"{c_quoted} = excluded.{c_quoted}")
|
2025-05-08 20:37:49 -07:00
|
|
|
|
do_clause = "DO UPDATE SET " + ", ".join(assignments)
|
|
|
|
|
|
else:
|
|
|
|
|
|
# All columns are in the PK – nothing to update.
|
|
|
|
|
|
do_clause = "DO NOTHING"
|
|
|
|
|
|
|
|
|
|
|
|
sql = (
|
2025-11-23 20:43:26 -08:00
|
|
|
|
f"INSERT INTO {quote_identifier(self.name)} ({columns_sql}) "
|
2025-05-08 20:37:49 -07:00
|
|
|
|
f"VALUES {row_placeholders_sql} "
|
|
|
|
|
|
f"ON CONFLICT({conflict_sql}) {do_clause}"
|
|
|
|
|
|
)
|
|
|
|
|
|
return [(sql, flat_params)]
|
2020-09-08 16:20:36 -07:00
|
|
|
|
|
2025-05-08 20:37:49 -07:00
|
|
|
|
# At this point we need compatibility UPSERT for SQLite < 3.24.0
|
|
|
|
|
|
# (INSERT OR IGNORE + second UPDATE stage)
|
|
|
|
|
|
queries_and_params = []
|
2026-07-05 22:11:22 -07:00
|
|
|
|
pks = pk_cols
|
2025-05-08 20:37:49 -07:00
|
|
|
|
self.last_pk = None
|
|
|
|
|
|
for record_values in values:
|
|
|
|
|
|
record = dict(zip(all_columns, record_values))
|
|
|
|
|
|
placeholders = list(pks)
|
|
|
|
|
|
# Need to populate not-null columns too, or INSERT OR IGNORE ignores
|
|
|
|
|
|
# them since it ignores the resulting integrity errors
|
|
|
|
|
|
if not_null:
|
|
|
|
|
|
placeholders.extend(not_null)
|
2025-11-23 20:43:26 -08:00
|
|
|
|
sql = (
|
|
|
|
|
|
"INSERT OR IGNORE INTO {table}({cols}) VALUES({placeholders});".format(
|
|
|
|
|
|
table=quote_identifier(self.name),
|
|
|
|
|
|
cols=", ".join([quote_identifier(p) for p in placeholders]),
|
|
|
|
|
|
placeholders=", ".join(["?" for p in placeholders]),
|
|
|
|
|
|
)
|
2020-09-08 16:20:36 -07:00
|
|
|
|
)
|
2025-05-08 20:37:49 -07:00
|
|
|
|
queries_and_params.append(
|
|
|
|
|
|
(sql, [record[col] for col in pks] + ["" for _ in (not_null or [])])
|
|
|
|
|
|
)
|
2025-11-23 20:43:26 -08:00
|
|
|
|
# UPDATE "book" SET "name" = 'Programming' WHERE "id" = 1001;
|
2025-05-08 20:37:49 -07:00
|
|
|
|
set_cols = [col for col in all_columns if col not in pks]
|
|
|
|
|
|
if set_cols:
|
2025-11-23 20:43:26 -08:00
|
|
|
|
sql2 = "UPDATE {} SET {pairs} WHERE {wheres}".format(
|
|
|
|
|
|
quote_identifier(self.name),
|
2025-05-08 20:37:49 -07:00
|
|
|
|
pairs=", ".join(
|
2025-11-23 20:43:26 -08:00
|
|
|
|
"{} = {}".format(
|
|
|
|
|
|
quote_identifier(col), conversions.get(col, "?")
|
|
|
|
|
|
)
|
2025-05-08 20:37:49 -07:00
|
|
|
|
for col in set_cols
|
|
|
|
|
|
),
|
2025-11-23 20:43:26 -08:00
|
|
|
|
wheres=" AND ".join(
|
|
|
|
|
|
"{} = ?".format(quote_identifier(pk)) for pk in pks
|
|
|
|
|
|
),
|
2025-05-08 20:37:49 -07:00
|
|
|
|
)
|
|
|
|
|
|
queries_and_params.append(
|
|
|
|
|
|
(
|
|
|
|
|
|
sql2,
|
|
|
|
|
|
[record[col] for col in set_cols] + [record[pk] for pk in pks],
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
# We can populate .last_pk right here
|
|
|
|
|
|
if num_records_processed == 1:
|
2025-12-16 19:34:45 -08:00
|
|
|
|
pk_values = tuple(record[pk] for pk in pks)
|
|
|
|
|
|
if len(pk_values) == 1:
|
|
|
|
|
|
self.last_pk = pk_values[0]
|
|
|
|
|
|
else:
|
|
|
|
|
|
self.last_pk = pk_values
|
2020-09-08 16:20:36 -07:00
|
|
|
|
return queries_and_params
|
|
|
|
|
|
|
|
|
|
|
|
def insert_chunk(
|
|
|
|
|
|
self,
|
|
|
|
|
|
alter,
|
|
|
|
|
|
extracts,
|
|
|
|
|
|
chunk,
|
|
|
|
|
|
all_columns,
|
|
|
|
|
|
hash_id,
|
2022-03-01 16:00:51 -08:00
|
|
|
|
hash_id_columns,
|
2020-09-08 16:20:36 -07:00
|
|
|
|
upsert,
|
|
|
|
|
|
pk,
|
2023-05-08 12:24:10 -07:00
|
|
|
|
not_null,
|
2020-09-08 16:20:36 -07:00
|
|
|
|
conversions,
|
|
|
|
|
|
num_records_processed,
|
|
|
|
|
|
replace,
|
|
|
|
|
|
ignore,
|
2025-11-23 12:17:23 -08:00
|
|
|
|
list_mode=False,
|
2025-05-08 20:37:49 -07:00
|
|
|
|
) -> Optional[sqlite3.Cursor]:
|
2020-09-08 16:20:36 -07:00
|
|
|
|
queries_and_params = self.build_insert_queries_and_params(
|
|
|
|
|
|
extracts,
|
|
|
|
|
|
chunk,
|
|
|
|
|
|
all_columns,
|
|
|
|
|
|
hash_id,
|
2022-03-01 16:00:51 -08:00
|
|
|
|
hash_id_columns,
|
2020-09-08 16:20:36 -07:00
|
|
|
|
upsert,
|
|
|
|
|
|
pk,
|
2023-05-08 12:24:10 -07:00
|
|
|
|
not_null,
|
2020-09-08 16:20:36 -07:00
|
|
|
|
conversions,
|
|
|
|
|
|
num_records_processed,
|
|
|
|
|
|
replace,
|
|
|
|
|
|
ignore,
|
2025-11-23 12:17:23 -08:00
|
|
|
|
list_mode,
|
2020-09-08 16:20:36 -07:00
|
|
|
|
)
|
2025-05-08 20:37:49 -07:00
|
|
|
|
result = None
|
2026-06-21 10:43:19 -07:00
|
|
|
|
with self.db.atomic():
|
2020-09-08 16:20:36 -07:00
|
|
|
|
for query, params in queries_and_params:
|
|
|
|
|
|
try:
|
|
|
|
|
|
result = self.db.execute(query, params)
|
|
|
|
|
|
except OperationalError as e:
|
|
|
|
|
|
if alter and (" column" in e.args[0]):
|
|
|
|
|
|
# Attempt to add any missing columns, then try again
|
|
|
|
|
|
self.add_missing_columns(chunk)
|
|
|
|
|
|
result = self.db.execute(query, params)
|
|
|
|
|
|
elif e.args[0] == "too many SQL variables":
|
|
|
|
|
|
first_half = chunk[: len(chunk) // 2]
|
|
|
|
|
|
second_half = chunk[len(chunk) // 2 :]
|
|
|
|
|
|
|
|
|
|
|
|
self.insert_chunk(
|
|
|
|
|
|
alter,
|
|
|
|
|
|
extracts,
|
|
|
|
|
|
first_half,
|
|
|
|
|
|
all_columns,
|
|
|
|
|
|
hash_id,
|
2022-03-01 16:00:51 -08:00
|
|
|
|
hash_id_columns,
|
2020-09-08 16:20:36 -07:00
|
|
|
|
upsert,
|
|
|
|
|
|
pk,
|
2023-05-08 12:24:10 -07:00
|
|
|
|
not_null,
|
2020-09-08 16:20:36 -07:00
|
|
|
|
conversions,
|
|
|
|
|
|
num_records_processed,
|
|
|
|
|
|
replace,
|
|
|
|
|
|
ignore,
|
2025-11-23 12:17:23 -08:00
|
|
|
|
list_mode,
|
2020-09-08 16:20:36 -07:00
|
|
|
|
)
|
|
|
|
|
|
|
2025-05-08 20:37:49 -07:00
|
|
|
|
result = self.insert_chunk(
|
2020-09-08 16:20:36 -07:00
|
|
|
|
alter,
|
|
|
|
|
|
extracts,
|
|
|
|
|
|
second_half,
|
|
|
|
|
|
all_columns,
|
|
|
|
|
|
hash_id,
|
2022-03-01 16:00:51 -08:00
|
|
|
|
hash_id_columns,
|
2020-09-08 16:20:36 -07:00
|
|
|
|
upsert,
|
|
|
|
|
|
pk,
|
2023-05-08 12:24:10 -07:00
|
|
|
|
not_null,
|
2020-09-08 16:20:36 -07:00
|
|
|
|
conversions,
|
|
|
|
|
|
num_records_processed,
|
|
|
|
|
|
replace,
|
|
|
|
|
|
ignore,
|
2025-11-23 12:17:23 -08:00
|
|
|
|
list_mode,
|
2020-09-08 16:20:36 -07:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
else:
|
|
|
|
|
|
raise
|
2025-05-08 20:37:49 -07:00
|
|
|
|
return result
|
2020-09-08 16:20:36 -07:00
|
|
|
|
|
2018-08-08 16:06:49 -07:00
|
|
|
|
def insert(
|
2019-02-23 20:36:40 -08:00
|
|
|
|
self,
|
2021-08-10 16:09:28 -07:00
|
|
|
|
record: Dict[str, Any],
|
2019-07-22 16:30:54 -07:00
|
|
|
|
pk=DEFAULT,
|
|
|
|
|
|
foreign_keys=DEFAULT,
|
2021-08-10 16:09:28 -07:00
|
|
|
|
column_order: Optional[Union[List[str], Default]] = DEFAULT,
|
2022-08-27 16:17:55 -07:00
|
|
|
|
not_null: Optional[Union[Iterable[str], Default]] = DEFAULT,
|
2021-08-10 16:09:28 -07:00
|
|
|
|
defaults: Optional[Union[Dict[str, Any], Default]] = DEFAULT,
|
|
|
|
|
|
hash_id: Optional[Union[str, Default]] = DEFAULT,
|
2022-03-01 16:05:11 -08:00
|
|
|
|
hash_id_columns: Optional[Union[Iterable[str], Default]] = DEFAULT,
|
2021-08-10 16:09:28 -07:00
|
|
|
|
alter: Optional[Union[bool, Default]] = DEFAULT,
|
|
|
|
|
|
ignore: Optional[Union[bool, Default]] = DEFAULT,
|
|
|
|
|
|
replace: Optional[Union[bool, Default]] = DEFAULT,
|
|
|
|
|
|
extracts: Optional[Union[Dict[str, str], List[str], Default]] = DEFAULT,
|
|
|
|
|
|
conversions: Optional[Union[Dict[str, str], Default]] = DEFAULT,
|
|
|
|
|
|
columns: Optional[Union[Dict[str, Any], Default]] = DEFAULT,
|
2023-12-07 21:05:27 -08:00
|
|
|
|
strict: Optional[Union[bool, Default]] = DEFAULT,
|
2021-08-10 16:09:28 -07:00
|
|
|
|
) -> "Table":
|
|
|
|
|
|
"""
|
|
|
|
|
|
Insert a single record into the table. The table will be created with a schema that matches
|
|
|
|
|
|
the inserted record if it does not already exist, see :ref:`python_api_creating_tables`.
|
|
|
|
|
|
|
|
|
|
|
|
- ``record`` - required: a dictionary representing the record to be inserted.
|
|
|
|
|
|
|
|
|
|
|
|
The other parameters are optional, and mostly influence how the new table will be created if
|
|
|
|
|
|
that table does not exist yet.
|
|
|
|
|
|
|
|
|
|
|
|
Each of them defaults to ``DEFAULT``, which indicates that the default setting for the current
|
|
|
|
|
|
``Table`` object (specified in the table constructor) should be used.
|
|
|
|
|
|
|
2022-03-11 09:38:34 -08:00
|
|
|
|
:param record: Dictionary record to be inserted
|
|
|
|
|
|
:param pk: If creating the table, which column should be the primary key.
|
|
|
|
|
|
:param foreign_keys: See :ref:`python_api_foreign_keys`.
|
|
|
|
|
|
:param column_order: List of strings specifying a full or partial column order
|
2021-08-10 16:09:28 -07:00
|
|
|
|
to use when creating the table.
|
2022-03-11 09:38:34 -08:00
|
|
|
|
:param not_null: Set of strings specifying columns that should be ``NOT NULL``.
|
|
|
|
|
|
:param defaults: Dictionary specifying default values for specific columns.
|
|
|
|
|
|
:param hash_id: Name of a column to create and use as a primary key, where the
|
2025-05-07 17:49:50 -07:00
|
|
|
|
value of that primary key will be derived as a SHA1 hash of the other column values
|
2021-08-10 16:09:28 -07:00
|
|
|
|
in the record. ``hash_id="id"`` is a common column name used for this.
|
2022-03-11 09:38:34 -08:00
|
|
|
|
:param alter: Boolean, should any missing columns be added automatically?
|
|
|
|
|
|
:param ignore: Boolean, if a record already exists with this primary key, ignore this insert.
|
|
|
|
|
|
:param replace: Boolean, if a record already exists with this primary key, replace it with this new record.
|
|
|
|
|
|
:param extracts: A list of columns to extract to other tables, or a dictionary that maps
|
2021-08-10 16:09:28 -07:00
|
|
|
|
``{column_name: other_table_name}``. See :ref:`python_api_extracts`.
|
2022-03-11 09:38:34 -08:00
|
|
|
|
:param conversions: Dictionary specifying SQL conversion functions to be applied to the data while it
|
2021-08-10 16:09:28 -07:00
|
|
|
|
is being inserted, for example ``{"name": "upper(?)"}``. See :ref:`python_api_conversions`.
|
2022-03-11 09:38:34 -08:00
|
|
|
|
:param columns: Dictionary over-riding the detected types used for the columns, for example
|
2021-08-10 16:09:28 -07:00
|
|
|
|
``{"age": int, "weight": float}``.
|
2023-12-07 21:05:27 -08:00
|
|
|
|
:param strict: Boolean, apply STRICT mode if creating the table.
|
2021-08-10 16:09:28 -07:00
|
|
|
|
"""
|
2018-07-28 06:43:18 -07:00
|
|
|
|
return self.insert_all(
|
2018-08-08 16:06:49 -07:00
|
|
|
|
[record],
|
|
|
|
|
|
pk=pk,
|
|
|
|
|
|
foreign_keys=foreign_keys,
|
|
|
|
|
|
column_order=column_order,
|
2019-06-12 23:10:07 -07:00
|
|
|
|
not_null=not_null,
|
|
|
|
|
|
defaults=defaults,
|
2019-02-23 20:36:40 -08:00
|
|
|
|
hash_id=hash_id,
|
2022-03-01 16:00:51 -08:00
|
|
|
|
hash_id_columns=hash_id_columns,
|
2019-05-24 17:41:04 -07:00
|
|
|
|
alter=alter,
|
2019-05-28 21:15:57 -07:00
|
|
|
|
ignore=ignore,
|
2019-12-27 09:15:31 +00:00
|
|
|
|
replace=replace,
|
2019-07-23 10:00:42 -07:00
|
|
|
|
extracts=extracts,
|
2020-01-30 16:24:30 -08:00
|
|
|
|
conversions=conversions,
|
2020-04-17 16:53:25 -07:00
|
|
|
|
columns=columns,
|
2023-12-07 21:05:27 -08:00
|
|
|
|
strict=strict,
|
2018-07-28 06:43:18 -07:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def insert_all(
|
2018-08-08 16:06:49 -07:00
|
|
|
|
self,
|
2025-11-23 12:17:23 -08:00
|
|
|
|
records: Union[
|
|
|
|
|
|
Iterable[Dict[str, Any]],
|
|
|
|
|
|
Iterable[Sequence[Any]],
|
|
|
|
|
|
],
|
2019-07-22 16:30:54 -07:00
|
|
|
|
pk=DEFAULT,
|
|
|
|
|
|
foreign_keys=DEFAULT,
|
|
|
|
|
|
column_order=DEFAULT,
|
|
|
|
|
|
not_null=DEFAULT,
|
|
|
|
|
|
defaults=DEFAULT,
|
|
|
|
|
|
batch_size=DEFAULT,
|
|
|
|
|
|
hash_id=DEFAULT,
|
2022-03-01 16:00:51 -08:00
|
|
|
|
hash_id_columns=DEFAULT,
|
2019-07-22 16:30:54 -07:00
|
|
|
|
alter=DEFAULT,
|
|
|
|
|
|
ignore=DEFAULT,
|
2019-12-27 09:15:31 +00:00
|
|
|
|
replace=DEFAULT,
|
2020-07-06 14:18:23 -07:00
|
|
|
|
truncate=False,
|
2019-07-23 10:00:42 -07:00
|
|
|
|
extracts=DEFAULT,
|
2020-01-30 16:24:30 -08:00
|
|
|
|
conversions=DEFAULT,
|
2020-04-17 16:53:25 -07:00
|
|
|
|
columns=DEFAULT,
|
2019-12-29 21:03:43 -08:00
|
|
|
|
upsert=False,
|
2022-01-10 17:00:34 -08:00
|
|
|
|
analyze=False,
|
2023-12-07 21:05:27 -08:00
|
|
|
|
strict=DEFAULT,
|
2021-08-10 16:09:28 -07:00
|
|
|
|
) -> "Table":
|
2018-07-28 06:43:18 -07:00
|
|
|
|
"""
|
2021-08-10 16:09:28 -07:00
|
|
|
|
Like ``.insert()`` but takes a list of records and ensures that the table
|
|
|
|
|
|
that it creates (if table does not exist) has columns for ALL of that data.
|
2022-01-10 17:00:34 -08:00
|
|
|
|
|
|
|
|
|
|
Use ``analyze=True`` to run ``ANALYZE`` after the insert has completed.
|
2018-07-28 06:43:18 -07:00
|
|
|
|
"""
|
2019-07-22 16:30:54 -07:00
|
|
|
|
pk = self.value_or_default("pk", pk)
|
|
|
|
|
|
foreign_keys = self.value_or_default("foreign_keys", foreign_keys)
|
|
|
|
|
|
column_order = self.value_or_default("column_order", column_order)
|
|
|
|
|
|
not_null = self.value_or_default("not_null", not_null)
|
|
|
|
|
|
defaults = self.value_or_default("defaults", defaults)
|
|
|
|
|
|
batch_size = self.value_or_default("batch_size", batch_size)
|
|
|
|
|
|
hash_id = self.value_or_default("hash_id", hash_id)
|
2022-03-01 16:00:51 -08:00
|
|
|
|
hash_id_columns = self.value_or_default("hash_id_columns", hash_id_columns)
|
2019-07-22 16:30:54 -07:00
|
|
|
|
alter = self.value_or_default("alter", alter)
|
|
|
|
|
|
ignore = self.value_or_default("ignore", ignore)
|
2019-12-27 09:15:31 +00:00
|
|
|
|
replace = self.value_or_default("replace", replace)
|
2019-07-23 10:00:42 -07:00
|
|
|
|
extracts = self.value_or_default("extracts", extracts)
|
2021-11-18 23:26:50 -08:00
|
|
|
|
conversions = self.value_or_default("conversions", conversions) or {}
|
2020-04-17 16:53:25 -07:00
|
|
|
|
columns = self.value_or_default("columns", columns)
|
2023-12-07 21:05:27 -08:00
|
|
|
|
strict = self.value_or_default("strict", strict)
|
2019-07-22 16:30:54 -07:00
|
|
|
|
|
2022-03-01 16:00:51 -08:00
|
|
|
|
if hash_id_columns and hash_id is None:
|
|
|
|
|
|
hash_id = "id"
|
|
|
|
|
|
|
2026-06-24 10:43:53 -07:00
|
|
|
|
if upsert and not pk and not hash_id and self.exists():
|
|
|
|
|
|
existing_pks = [column.name for column in self.columns if column.is_pk]
|
|
|
|
|
|
if existing_pks:
|
|
|
|
|
|
pk = existing_pks[0] if len(existing_pks) == 1 else tuple(existing_pks)
|
|
|
|
|
|
|
2020-02-06 23:17:06 -08:00
|
|
|
|
if upsert and (not pk and not hash_id):
|
|
|
|
|
|
raise PrimaryKeyRequired("upsert() requires a pk")
|
2025-05-08 20:37:49 -07:00
|
|
|
|
|
2026-07-04 19:19:24 +00:00
|
|
|
|
if hash_id and pk:
|
|
|
|
|
|
raise ValueError("Use either pk= or hash_id=")
|
2022-03-01 16:00:51 -08:00
|
|
|
|
if hash_id_columns and (hash_id is None):
|
|
|
|
|
|
hash_id = "id"
|
2020-02-06 23:17:06 -08:00
|
|
|
|
if hash_id:
|
|
|
|
|
|
pk = hash_id
|
|
|
|
|
|
|
2026-07-06 21:47:59 -07:00
|
|
|
|
# pk columns missing from an existing table are an error - unless
|
|
|
|
|
|
# alter=True, where a pk column supplied by the records will be
|
|
|
|
|
|
# added, so validation waits until the record keys are known
|
|
|
|
|
|
deferred_invalid_pk_check = None
|
2026-07-06 20:13:52 -07:00
|
|
|
|
if pk and not hash_id and self.exists():
|
|
|
|
|
|
pk_cols = [pk] if isinstance(pk, str) else list(pk)
|
|
|
|
|
|
existing_columns = self.columns_dict
|
2026-07-07 08:24:24 -07:00
|
|
|
|
# rowid and its aliases are valid primary keys for a rowid table
|
|
|
|
|
|
# even though they are not listed among the table's columns
|
|
|
|
|
|
rowid_aliases = ROWID_ALIASES if self.use_rowid else frozenset()
|
2026-07-06 20:13:52 -07:00
|
|
|
|
missing_pk_cols = [
|
|
|
|
|
|
col
|
|
|
|
|
|
for col in pk_cols
|
2026-07-07 08:24:24 -07:00
|
|
|
|
if col.lower() not in rowid_aliases
|
|
|
|
|
|
and resolve_casing(col, existing_columns) not in existing_columns
|
2026-07-06 20:13:52 -07:00
|
|
|
|
]
|
|
|
|
|
|
if missing_pk_cols:
|
2026-07-06 21:47:59 -07:00
|
|
|
|
invalid_pk_error = InvalidColumns(
|
2026-07-06 20:13:52 -07:00
|
|
|
|
"Invalid primary key column{} {} for table {} with columns {}".format(
|
|
|
|
|
|
"s" if len(missing_pk_cols) > 1 else "",
|
|
|
|
|
|
missing_pk_cols,
|
|
|
|
|
|
self.name,
|
|
|
|
|
|
list(existing_columns),
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
2026-07-06 21:47:59 -07:00
|
|
|
|
if not alter:
|
|
|
|
|
|
raise invalid_pk_error
|
|
|
|
|
|
deferred_invalid_pk_check = (missing_pk_cols, invalid_pk_error)
|
2026-07-06 20:13:52 -07:00
|
|
|
|
|
2026-07-04 19:19:24 +00:00
|
|
|
|
if ignore and replace:
|
|
|
|
|
|
raise ValueError("Use either ignore=True or replace=True, not both")
|
2021-08-10 16:09:28 -07:00
|
|
|
|
all_columns = []
|
2019-01-27 22:12:18 -08:00
|
|
|
|
first = True
|
2020-04-12 20:22:32 -07:00
|
|
|
|
num_records_processed = 0
|
2025-11-23 12:17:23 -08:00
|
|
|
|
|
|
|
|
|
|
# Detect if we're using list-based iteration or dict-based iteration
|
|
|
|
|
|
list_mode = False
|
|
|
|
|
|
column_names: List[str] = []
|
|
|
|
|
|
|
|
|
|
|
|
# Fix up any records with square braces in the column names (only for dict mode)
|
|
|
|
|
|
# We'll handle this differently for list mode
|
|
|
|
|
|
records_iter = iter(records)
|
|
|
|
|
|
|
|
|
|
|
|
# Peek at first record to determine mode:
|
2019-11-06 20:32:37 -08:00
|
|
|
|
try:
|
2025-11-23 12:17:23 -08:00
|
|
|
|
first_record = next(records_iter)
|
2019-11-06 20:32:37 -08:00
|
|
|
|
except StopIteration:
|
|
|
|
|
|
return self # It was an empty list
|
2025-11-23 12:17:23 -08:00
|
|
|
|
|
|
|
|
|
|
# Check if this is list mode or dict mode
|
|
|
|
|
|
if isinstance(first_record, (list, tuple)):
|
|
|
|
|
|
# List/tuple mode: first record should be column names
|
|
|
|
|
|
list_mode = True
|
|
|
|
|
|
if not all(isinstance(col, str) for col in first_record):
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
"When using list-based iteration, the first yielded value must be a list of column name strings"
|
|
|
|
|
|
)
|
2026-05-17 16:52:48 -07:00
|
|
|
|
column_names = cast(List[str], list(first_record))
|
2025-11-23 12:17:23 -08:00
|
|
|
|
all_columns = column_names
|
|
|
|
|
|
num_columns = len(column_names)
|
|
|
|
|
|
# Get the actual first data record
|
|
|
|
|
|
try:
|
|
|
|
|
|
first_record = next(records_iter)
|
|
|
|
|
|
except StopIteration:
|
|
|
|
|
|
return self # Only headers, no data
|
|
|
|
|
|
if not isinstance(first_record, (list, tuple)):
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
"After column names list, all subsequent records must also be lists"
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
# Dict mode: traditional behavior
|
|
|
|
|
|
records_iter = itertools.chain([first_record], records_iter)
|
|
|
|
|
|
try:
|
|
|
|
|
|
first_record = next(records_iter)
|
|
|
|
|
|
except StopIteration:
|
|
|
|
|
|
return self
|
|
|
|
|
|
first_record = cast(Dict[str, Any], first_record)
|
|
|
|
|
|
num_columns = len(first_record.keys())
|
|
|
|
|
|
|
2026-07-04 19:19:24 +00:00
|
|
|
|
if num_columns > SQLITE_MAX_VARS:
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
"Rows can have a maximum of {} columns".format(SQLITE_MAX_VARS)
|
|
|
|
|
|
)
|
2026-06-22 12:00:05 -07:00
|
|
|
|
batch_size = (
|
|
|
|
|
|
1
|
|
|
|
|
|
if num_columns == 0
|
|
|
|
|
|
else max(1, min(batch_size, SQLITE_MAX_VARS // num_columns))
|
|
|
|
|
|
)
|
2020-04-12 20:22:32 -07:00
|
|
|
|
self.last_rowid = None
|
|
|
|
|
|
self.last_pk = None
|
2020-07-06 14:18:23 -07:00
|
|
|
|
if truncate and self.exists():
|
2026-06-21 10:43:19 -07:00
|
|
|
|
with self.db.atomic():
|
|
|
|
|
|
self.db.execute("DELETE FROM {};".format(quote_identifier(self.name)))
|
2025-05-08 20:37:49 -07:00
|
|
|
|
result = None
|
2025-11-23 12:17:23 -08:00
|
|
|
|
for chunk in chunks(itertools.chain([first_record], records_iter), batch_size):
|
2019-01-27 22:12:18 -08:00
|
|
|
|
chunk = list(chunk)
|
2020-04-12 20:22:32 -07:00
|
|
|
|
num_records_processed += len(chunk)
|
2019-01-27 22:12:18 -08:00
|
|
|
|
if first:
|
2020-02-08 15:56:03 -08:00
|
|
|
|
if not self.exists():
|
2019-01-27 22:12:18 -08:00
|
|
|
|
# Use the first batch to derive the table names
|
2025-11-23 12:17:23 -08:00
|
|
|
|
if list_mode:
|
|
|
|
|
|
# Convert list records to dicts for type detection
|
|
|
|
|
|
chunk_as_dicts = [dict(zip(column_names, row)) for row in chunk]
|
|
|
|
|
|
column_types = suggest_column_types(chunk_as_dicts)
|
|
|
|
|
|
else:
|
2026-05-17 16:52:48 -07:00
|
|
|
|
dict_chunk = cast(List[Dict[str, Any]], chunk)
|
|
|
|
|
|
column_types = suggest_column_types(dict_chunk)
|
2025-05-08 20:37:49 -07:00
|
|
|
|
if extracts:
|
|
|
|
|
|
for col in extracts:
|
|
|
|
|
|
if col in column_types:
|
|
|
|
|
|
column_types[col] = (
|
|
|
|
|
|
int # This will be an integer foreign key
|
|
|
|
|
|
)
|
2020-04-17 16:53:25 -07:00
|
|
|
|
column_types.update(columns or {})
|
2019-01-27 22:12:18 -08:00
|
|
|
|
self.create(
|
2020-04-17 16:53:25 -07:00
|
|
|
|
column_types,
|
2019-01-27 22:12:18 -08:00
|
|
|
|
pk,
|
|
|
|
|
|
foreign_keys,
|
|
|
|
|
|
column_order=column_order,
|
2019-06-12 23:10:07 -07:00
|
|
|
|
not_null=not_null,
|
|
|
|
|
|
defaults=defaults,
|
2019-02-23 20:36:40 -08:00
|
|
|
|
hash_id=hash_id,
|
2022-03-01 16:00:51 -08:00
|
|
|
|
hash_id_columns=hash_id_columns,
|
2019-07-23 10:00:42 -07:00
|
|
|
|
extracts=extracts,
|
2023-12-07 21:05:27 -08:00
|
|
|
|
strict=strict,
|
2019-01-27 22:12:18 -08:00
|
|
|
|
)
|
2025-11-23 12:17:23 -08:00
|
|
|
|
if list_mode:
|
|
|
|
|
|
# In list mode, columns are already known
|
|
|
|
|
|
all_columns = list(column_names)
|
|
|
|
|
|
if hash_id:
|
|
|
|
|
|
all_columns.insert(0, hash_id)
|
|
|
|
|
|
else:
|
More type annotations (#697)
* Add comprehensive type annotations
- mypy.ini: expanded configuration with module-specific settings
- hookspecs.py: type annotations for hook functions
- plugins.py: typed get_plugins() return value
- recipes.py: full type annotations for parsedate, parsedatetime, jsonsplit
- utils.py: extensive type annotations including Row type alias,
TypeTracker, ValueTracker, and all utility functions
- db.py: type annotations for Database methods (__exit__,
ensure_autocommit_off, tracer, register_function, etc.) and
Queryable class methods
- tests/test_docs.py: updated to match new signature display format
* Fix type errors caught by ty check
- Add type: ignore comments for external library type stub limitations
(csv.reader, click.progressbar, IOBase.name, Callable.__name__)
- Change Iterable to Sequence for SQL where_args parameters
- Use db.table() instead of db[name] for proper Table return type
- Fix rebuild_fts return type from None to Table
- Update test_tracer to expect fewer queries (optimization side effect)
* Fix mypy type errors
- Add type: ignore comments for runtime-valid patterns mypy can't verify
- Fix new_column_types annotation to Dict[str, Set[type]]
- Add type: ignore for Default sentinel values passed to create_table
* mypy skip tests directory
* Fix CI: exclude typing imports from recipe docs, skip mypy on tests
- Add Callable and Optional to exclusion list in _generate_convert_help()
- Regenerate docs/cli-reference.rst with cog
- Add [mypy-tests.*] ignore_errors = True to skip test type errors
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 22:11:47 -08:00
|
|
|
|
all_columns_set: Set[str] = set()
|
2026-05-17 16:52:48 -07:00
|
|
|
|
for record in cast(List[Dict[str, Any]], chunk):
|
|
|
|
|
|
all_columns_set.update(record.keys())
|
2025-11-23 12:17:23 -08:00
|
|
|
|
all_columns = list(sorted(all_columns_set))
|
|
|
|
|
|
if hash_id:
|
|
|
|
|
|
all_columns.insert(0, hash_id)
|
2026-07-06 21:47:59 -07:00
|
|
|
|
if deferred_invalid_pk_check is not None:
|
|
|
|
|
|
# alter=True - pk columns the table lacks are valid if
|
|
|
|
|
|
# the records supply them, otherwise raise the error
|
|
|
|
|
|
missing_pk_cols, invalid_pk_error = deferred_invalid_pk_check
|
|
|
|
|
|
record_columns = {column: True for column in all_columns}
|
|
|
|
|
|
if any(
|
|
|
|
|
|
resolve_casing(col, record_columns) not in record_columns
|
|
|
|
|
|
for col in missing_pk_cols
|
|
|
|
|
|
):
|
|
|
|
|
|
raise invalid_pk_error
|
2020-08-28 15:30:13 -07:00
|
|
|
|
else:
|
2025-11-23 12:17:23 -08:00
|
|
|
|
if not list_mode:
|
2026-05-17 16:52:48 -07:00
|
|
|
|
for record in cast(List[Dict[str, Any]], chunk):
|
2025-11-23 12:17:23 -08:00
|
|
|
|
all_columns += [
|
|
|
|
|
|
column for column in record if column not in all_columns
|
|
|
|
|
|
]
|
2020-08-28 15:30:13 -07:00
|
|
|
|
|
2019-01-27 22:12:18 -08:00
|
|
|
|
first = False
|
2020-04-12 20:22:32 -07:00
|
|
|
|
|
2025-05-08 20:37:49 -07:00
|
|
|
|
result = self.insert_chunk(
|
2020-09-08 16:20:36 -07:00
|
|
|
|
alter,
|
|
|
|
|
|
extracts,
|
|
|
|
|
|
chunk,
|
|
|
|
|
|
all_columns,
|
|
|
|
|
|
hash_id,
|
2022-03-01 16:00:51 -08:00
|
|
|
|
hash_id_columns,
|
2020-09-08 16:20:36 -07:00
|
|
|
|
upsert,
|
|
|
|
|
|
pk,
|
2023-05-08 12:24:10 -07:00
|
|
|
|
not_null,
|
2020-09-08 16:20:36 -07:00
|
|
|
|
conversions,
|
|
|
|
|
|
num_records_processed,
|
|
|
|
|
|
replace,
|
|
|
|
|
|
ignore,
|
2025-11-23 12:17:23 -08:00
|
|
|
|
list_mode,
|
2020-09-08 16:20:36 -07:00
|
|
|
|
)
|
2019-12-27 09:30:29 +00:00
|
|
|
|
|
2025-05-08 20:37:49 -07:00
|
|
|
|
# If we only handled a single row populate self.last_pk
|
|
|
|
|
|
if num_records_processed == 1:
|
|
|
|
|
|
# For an insert we need to use result.lastrowid
|
|
|
|
|
|
if not upsert and result is not None:
|
2026-07-06 20:22:08 -07:00
|
|
|
|
ignored_insert = ignore and result.rowcount == 0
|
|
|
|
|
|
if ignored_insert:
|
2026-07-07 08:24:24 -07:00
|
|
|
|
# The row was not inserted because it conflicts with an
|
|
|
|
|
|
# existing row. Point last_pk / last_rowid at that existing
|
|
|
|
|
|
# row when we can identify it from the record's primary key
|
|
|
|
|
|
# values, rather than leaving them stale or unset.
|
2026-07-06 20:22:08 -07:00
|
|
|
|
if list_mode:
|
2026-07-07 08:24:24 -07:00
|
|
|
|
first_record_dict = dict(
|
|
|
|
|
|
zip(column_names, cast(Sequence[Any], first_record))
|
|
|
|
|
|
)
|
2025-05-08 20:37:49 -07:00
|
|
|
|
else:
|
2026-07-06 20:22:08 -07:00
|
|
|
|
first_record_dict = cast(Dict[str, Any], first_record)
|
2026-07-07 08:24:24 -07:00
|
|
|
|
if hash_id:
|
|
|
|
|
|
self.last_pk = hash_record(first_record_dict, hash_id_columns)
|
|
|
|
|
|
elif isinstance(pk, str):
|
|
|
|
|
|
self.last_pk = first_record_dict[
|
|
|
|
|
|
resolve_casing(pk, first_record_dict)
|
|
|
|
|
|
]
|
|
|
|
|
|
elif pk:
|
|
|
|
|
|
self.last_pk = tuple(
|
|
|
|
|
|
first_record_dict[resolve_casing(p, first_record_dict)]
|
|
|
|
|
|
for p in pk
|
|
|
|
|
|
)
|
|
|
|
|
|
# Locate the existing conflicting row using its primary key
|
|
|
|
|
|
# columns so we can report its rowid (and pk if not already
|
|
|
|
|
|
# known). Falls back to leaving them unset if the conflict
|
|
|
|
|
|
# cannot be resolved to a pk lookup (e.g. a UNIQUE column).
|
|
|
|
|
|
key_cols: Optional[List[str]] = None
|
|
|
|
|
|
if isinstance(pk, str):
|
|
|
|
|
|
key_cols = [pk]
|
|
|
|
|
|
elif pk:
|
|
|
|
|
|
key_cols = list(pk)
|
|
|
|
|
|
elif not hash_id and not self.use_rowid:
|
|
|
|
|
|
key_cols = self.pks
|
|
|
|
|
|
if key_cols:
|
|
|
|
|
|
try:
|
|
|
|
|
|
key_values = [
|
|
|
|
|
|
first_record_dict[resolve_casing(c, first_record_dict)]
|
|
|
|
|
|
for c in key_cols
|
2026-07-06 20:22:08 -07:00
|
|
|
|
]
|
2026-07-07 08:24:24 -07:00
|
|
|
|
except KeyError:
|
|
|
|
|
|
key_values = None
|
|
|
|
|
|
if key_values is not None:
|
|
|
|
|
|
where = " and ".join(
|
|
|
|
|
|
"{} = ?".format(quote_identifier(c)) for c in key_cols
|
2026-07-06 20:22:08 -07:00
|
|
|
|
)
|
2026-07-07 08:24:24 -07:00
|
|
|
|
existing = self.db.execute(
|
|
|
|
|
|
"select rowid from {} where {} limit 1".format(
|
|
|
|
|
|
quote_identifier(self.name), where
|
|
|
|
|
|
),
|
|
|
|
|
|
key_values,
|
|
|
|
|
|
).fetchone()
|
|
|
|
|
|
if existing is not None:
|
|
|
|
|
|
self.last_rowid = existing[0]
|
|
|
|
|
|
# On a primary key conflict the record's pk
|
|
|
|
|
|
# values identify the existing row
|
|
|
|
|
|
if self.last_pk is None:
|
|
|
|
|
|
self.last_pk = (
|
|
|
|
|
|
key_values[0]
|
|
|
|
|
|
if len(key_cols) == 1
|
|
|
|
|
|
else tuple(key_values)
|
|
|
|
|
|
)
|
2025-05-08 20:37:49 -07:00
|
|
|
|
else:
|
2026-07-06 20:22:08 -07:00
|
|
|
|
self.last_rowid = result.lastrowid
|
2026-07-07 08:24:24 -07:00
|
|
|
|
# A rowid-alias pk resolves directly to the rowid, so there
|
|
|
|
|
|
# is no separate pk column to look up
|
|
|
|
|
|
rowid_pk = isinstance(pk, str) and pk.lower() in ROWID_ALIASES
|
|
|
|
|
|
if (hash_id or (pk and not rowid_pk)) and self.last_rowid:
|
2026-07-06 20:22:08 -07:00
|
|
|
|
# Set self.last_pk to the pk(s) for that rowid
|
|
|
|
|
|
row = list(self.rows_where("rowid = ?", [self.last_rowid]))[0]
|
|
|
|
|
|
if hash_id:
|
|
|
|
|
|
self.last_pk = row[hash_id]
|
|
|
|
|
|
elif isinstance(pk, str):
|
|
|
|
|
|
self.last_pk = row[resolve_casing(pk, row)]
|
|
|
|
|
|
else:
|
|
|
|
|
|
self.last_pk = tuple(
|
|
|
|
|
|
row[resolve_casing(p, row)] for p in pk
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
self.last_pk = self.last_rowid
|
2025-05-08 20:37:49 -07:00
|
|
|
|
else:
|
|
|
|
|
|
# For an upsert use first_record from earlier
|
2025-11-23 12:17:23 -08:00
|
|
|
|
if list_mode:
|
|
|
|
|
|
# In list mode, look up pk value by column index
|
|
|
|
|
|
first_record_list = cast(Sequence[Any], first_record)
|
|
|
|
|
|
if hash_id:
|
|
|
|
|
|
# hash_id not supported in list mode for last_pk
|
|
|
|
|
|
pass
|
|
|
|
|
|
elif isinstance(pk, str):
|
2026-07-05 22:11:22 -07:00
|
|
|
|
pk_index = column_names.index(resolve_casing(pk, column_names))
|
2025-11-23 12:17:23 -08:00
|
|
|
|
self.last_pk = first_record_list[pk_index]
|
|
|
|
|
|
else:
|
|
|
|
|
|
self.last_pk = tuple(
|
2026-07-05 22:11:22 -07:00
|
|
|
|
first_record_list[
|
|
|
|
|
|
column_names.index(resolve_casing(p, column_names))
|
|
|
|
|
|
]
|
|
|
|
|
|
for p in pk
|
2025-11-23 12:17:23 -08:00
|
|
|
|
)
|
2025-05-08 20:37:49 -07:00
|
|
|
|
else:
|
2025-11-23 12:17:23 -08:00
|
|
|
|
first_record_dict = cast(Dict[str, Any], first_record)
|
|
|
|
|
|
if hash_id:
|
|
|
|
|
|
self.last_pk = hash_record(first_record_dict, hash_id_columns)
|
|
|
|
|
|
else:
|
|
|
|
|
|
self.last_pk = (
|
2026-07-05 22:11:22 -07:00
|
|
|
|
first_record_dict[resolve_casing(pk, first_record_dict)]
|
2025-11-23 12:17:23 -08:00
|
|
|
|
if isinstance(pk, str)
|
2026-07-05 22:11:22 -07:00
|
|
|
|
else tuple(
|
|
|
|
|
|
first_record_dict[resolve_casing(p, first_record_dict)]
|
|
|
|
|
|
for p in pk
|
|
|
|
|
|
)
|
2025-11-23 12:17:23 -08:00
|
|
|
|
)
|
2025-05-08 20:37:49 -07:00
|
|
|
|
|
2022-01-10 17:00:34 -08:00
|
|
|
|
if analyze:
|
|
|
|
|
|
self.analyze()
|
|
|
|
|
|
|
2018-08-05 18:42:43 -07:00
|
|
|
|
return self
|
2018-07-28 06:43:18 -07:00
|
|
|
|
|
2019-02-23 20:36:40 -08:00
|
|
|
|
def upsert(
|
2019-05-24 17:41:04 -07:00
|
|
|
|
self,
|
|
|
|
|
|
record,
|
2019-07-22 16:30:54 -07:00
|
|
|
|
pk=DEFAULT,
|
|
|
|
|
|
foreign_keys=DEFAULT,
|
|
|
|
|
|
column_order=DEFAULT,
|
|
|
|
|
|
not_null=DEFAULT,
|
|
|
|
|
|
defaults=DEFAULT,
|
|
|
|
|
|
hash_id=DEFAULT,
|
2022-03-01 16:00:51 -08:00
|
|
|
|
hash_id_columns=DEFAULT,
|
2019-07-22 16:30:54 -07:00
|
|
|
|
alter=DEFAULT,
|
2019-07-23 10:00:42 -07:00
|
|
|
|
extracts=DEFAULT,
|
2020-01-30 16:24:30 -08:00
|
|
|
|
conversions=DEFAULT,
|
2020-04-17 16:53:25 -07:00
|
|
|
|
columns=DEFAULT,
|
2023-12-07 21:05:27 -08:00
|
|
|
|
strict=DEFAULT,
|
2021-08-10 16:09:28 -07:00
|
|
|
|
) -> "Table":
|
|
|
|
|
|
"""
|
|
|
|
|
|
Like ``.insert()`` but performs an ``UPSERT``, where records are inserted if they do
|
|
|
|
|
|
not exist and updated if they DO exist, based on matching against their primary key.
|
|
|
|
|
|
|
|
|
|
|
|
See :ref:`python_api_upsert`.
|
|
|
|
|
|
"""
|
2019-12-27 09:30:29 +00:00
|
|
|
|
return self.upsert_all(
|
|
|
|
|
|
[record],
|
|
|
|
|
|
pk=pk,
|
|
|
|
|
|
foreign_keys=foreign_keys,
|
|
|
|
|
|
column_order=column_order,
|
|
|
|
|
|
not_null=not_null,
|
|
|
|
|
|
defaults=defaults,
|
|
|
|
|
|
hash_id=hash_id,
|
2022-03-01 16:00:51 -08:00
|
|
|
|
hash_id_columns=hash_id_columns,
|
2019-12-27 09:30:29 +00:00
|
|
|
|
alter=alter,
|
|
|
|
|
|
extracts=extracts,
|
2020-01-30 16:24:30 -08:00
|
|
|
|
conversions=conversions,
|
2020-04-17 16:53:25 -07:00
|
|
|
|
columns=columns,
|
2023-12-07 21:05:27 -08:00
|
|
|
|
strict=strict,
|
2019-12-27 09:30:29 +00:00
|
|
|
|
)
|
2018-07-28 06:43:18 -07:00
|
|
|
|
|
2019-02-06 21:50:25 -08:00
|
|
|
|
def upsert_all(
|
2019-02-23 20:36:40 -08:00
|
|
|
|
self,
|
2025-11-23 12:17:23 -08:00
|
|
|
|
records: Union[
|
|
|
|
|
|
Iterable[Dict[str, Any]],
|
|
|
|
|
|
Iterable[Sequence[Any]],
|
|
|
|
|
|
],
|
2019-07-22 16:30:54 -07:00
|
|
|
|
pk=DEFAULT,
|
|
|
|
|
|
foreign_keys=DEFAULT,
|
|
|
|
|
|
column_order=DEFAULT,
|
|
|
|
|
|
not_null=DEFAULT,
|
|
|
|
|
|
defaults=DEFAULT,
|
|
|
|
|
|
batch_size=DEFAULT,
|
|
|
|
|
|
hash_id=DEFAULT,
|
2022-03-01 16:00:51 -08:00
|
|
|
|
hash_id_columns=DEFAULT,
|
2019-07-22 16:30:54 -07:00
|
|
|
|
alter=DEFAULT,
|
2019-07-23 10:00:42 -07:00
|
|
|
|
extracts=DEFAULT,
|
2020-01-30 16:24:30 -08:00
|
|
|
|
conversions=DEFAULT,
|
2020-04-17 16:53:25 -07:00
|
|
|
|
columns=DEFAULT,
|
2022-01-10 17:00:34 -08:00
|
|
|
|
analyze=False,
|
2023-12-07 21:05:27 -08:00
|
|
|
|
strict=DEFAULT,
|
2021-08-10 16:09:28 -07:00
|
|
|
|
) -> "Table":
|
|
|
|
|
|
"""
|
|
|
|
|
|
Like ``.upsert()`` but can be applied to a list of records.
|
|
|
|
|
|
"""
|
2019-12-29 21:03:43 -08:00
|
|
|
|
return self.insert_all(
|
|
|
|
|
|
records,
|
|
|
|
|
|
pk=pk,
|
|
|
|
|
|
foreign_keys=foreign_keys,
|
|
|
|
|
|
column_order=column_order,
|
|
|
|
|
|
not_null=not_null,
|
|
|
|
|
|
defaults=defaults,
|
|
|
|
|
|
batch_size=batch_size,
|
|
|
|
|
|
hash_id=hash_id,
|
2022-03-01 16:00:51 -08:00
|
|
|
|
hash_id_columns=hash_id_columns,
|
2019-12-29 21:03:43 -08:00
|
|
|
|
alter=alter,
|
|
|
|
|
|
extracts=extracts,
|
2020-01-30 16:24:30 -08:00
|
|
|
|
conversions=conversions,
|
2020-04-17 16:53:25 -07:00
|
|
|
|
columns=columns,
|
2019-12-29 21:03:43 -08:00
|
|
|
|
upsert=True,
|
2022-01-10 17:00:34 -08:00
|
|
|
|
analyze=analyze,
|
2023-12-07 21:05:27 -08:00
|
|
|
|
strict=strict,
|
2019-12-29 21:03:43 -08:00
|
|
|
|
)
|
2018-07-28 06:43:18 -07:00
|
|
|
|
|
2021-08-10 16:09:28 -07:00
|
|
|
|
def add_missing_columns(self, records: Iterable[Dict[str, Any]]) -> "Table":
|
2020-02-01 13:38:26 -08:00
|
|
|
|
needed_columns = suggest_column_types(records)
|
2021-01-12 15:17:27 -08:00
|
|
|
|
current_columns = {c.lower() for c in self.columns_dict}
|
2019-05-24 17:41:04 -07:00
|
|
|
|
for col_name, col_type in needed_columns.items():
|
2021-01-12 15:17:27 -08:00
|
|
|
|
if col_name.lower() not in current_columns:
|
2019-05-24 17:41:04 -07:00
|
|
|
|
self.add_column(col_name, col_type)
|
2020-09-24 07:51:36 -07:00
|
|
|
|
return self
|
2019-05-24 17:41:04 -07:00
|
|
|
|
|
2021-11-14 18:01:56 -08:00
|
|
|
|
def lookup(
|
|
|
|
|
|
self,
|
|
|
|
|
|
lookup_values: Dict[str, Any],
|
|
|
|
|
|
extra_values: Optional[Dict[str, Any]] = None,
|
2021-11-18 23:26:50 -08:00
|
|
|
|
pk: Optional[str] = "id",
|
|
|
|
|
|
foreign_keys: Optional[ForeignKeysType] = None,
|
|
|
|
|
|
column_order: Optional[List[str]] = None,
|
2022-08-27 16:17:55 -07:00
|
|
|
|
not_null: Optional[Iterable[str]] = None,
|
2021-11-18 23:26:50 -08:00
|
|
|
|
defaults: Optional[Dict[str, Any]] = None,
|
|
|
|
|
|
extracts: Optional[Union[Dict[str, str], List[str]]] = None,
|
|
|
|
|
|
conversions: Optional[Dict[str, str]] = None,
|
|
|
|
|
|
columns: Optional[Dict[str, Any]] = None,
|
2023-12-07 21:05:27 -08:00
|
|
|
|
strict: Optional[bool] = False,
|
2021-11-14 18:01:56 -08:00
|
|
|
|
):
|
2021-08-10 16:09:28 -07:00
|
|
|
|
"""
|
|
|
|
|
|
Create or populate a lookup table with the specified values.
|
|
|
|
|
|
|
|
|
|
|
|
``db["Species"].lookup({"name": "Palm"})`` will create a table called ``Species``
|
|
|
|
|
|
(if one does not already exist) with two columns: ``id`` and ``name``. It will
|
|
|
|
|
|
set up a unique constraint on the ``name`` column to guarantee it will not
|
|
|
|
|
|
contain duplicate rows.
|
|
|
|
|
|
|
2021-11-11 12:50:22 -08:00
|
|
|
|
It will then insert a new row with the ``name`` set to ``Palm`` and return the
|
2021-08-10 16:09:28 -07:00
|
|
|
|
new integer primary key value.
|
|
|
|
|
|
|
2021-11-14 18:01:56 -08:00
|
|
|
|
An optional second argument can be provided with more ``name: value`` pairs to
|
|
|
|
|
|
be included only if the record is being created for the first time. These will
|
|
|
|
|
|
be ignored on subsequent lookup calls for records that already exist.
|
|
|
|
|
|
|
2022-03-11 09:38:34 -08:00
|
|
|
|
All other keyword arguments are passed through to ``.insert()``.
|
|
|
|
|
|
|
2021-08-10 16:09:28 -07:00
|
|
|
|
See :ref:`python_api_lookup_tables` for more details.
|
2021-11-18 23:26:50 -08:00
|
|
|
|
|
2022-03-11 09:38:34 -08:00
|
|
|
|
:param lookup_values: Dictionary specifying column names and values to use for the lookup
|
|
|
|
|
|
:param extra_values: Additional column values to be used only if creating a new record
|
2023-12-07 21:05:27 -08:00
|
|
|
|
:param strict: Boolean, apply STRICT mode if creating the table.
|
2021-08-10 16:09:28 -07:00
|
|
|
|
"""
|
2026-07-04 19:19:24 +00:00
|
|
|
|
if not isinstance(lookup_values, dict):
|
|
|
|
|
|
raise ValueError("lookup_values must be a dictionary")
|
|
|
|
|
|
if pk is None:
|
|
|
|
|
|
raise ValueError("pk cannot be None")
|
|
|
|
|
|
if extra_values is not None and not isinstance(extra_values, dict):
|
|
|
|
|
|
raise ValueError("extra_values must be a dictionary")
|
2021-11-14 18:01:56 -08:00
|
|
|
|
combined_values = dict(lookup_values)
|
|
|
|
|
|
if extra_values is not None:
|
|
|
|
|
|
combined_values.update(extra_values)
|
2020-02-08 15:56:03 -08:00
|
|
|
|
if self.exists():
|
2021-11-14 18:01:56 -08:00
|
|
|
|
self.add_missing_columns([combined_values])
|
2026-07-05 22:11:22 -07:00
|
|
|
|
unique_column_sets = [
|
|
|
|
|
|
{fold_identifier_case(c) for c in i.columns} for i in self.indexes
|
|
|
|
|
|
]
|
|
|
|
|
|
if {
|
|
|
|
|
|
fold_identifier_case(c) for c in lookup_values
|
|
|
|
|
|
} not in unique_column_sets:
|
2021-11-14 18:01:56 -08:00
|
|
|
|
self.create_index(lookup_values.keys(), unique=True)
|
2026-07-06 20:39:29 -07:00
|
|
|
|
# IS rather than = so that null values are matched correctly
|
2025-11-23 20:43:26 -08:00
|
|
|
|
wheres = [
|
2026-07-06 20:39:29 -07:00
|
|
|
|
"{} IS ?".format(quote_identifier(column)) for column in lookup_values
|
2025-11-23 20:43:26 -08:00
|
|
|
|
]
|
2019-07-23 06:06:59 -07:00
|
|
|
|
rows = list(
|
|
|
|
|
|
self.rows_where(
|
2021-11-14 18:01:56 -08:00
|
|
|
|
" and ".join(wheres), [value for _, value in lookup_values.items()]
|
2019-07-23 06:06:59 -07:00
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
try:
|
2026-07-05 22:11:22 -07:00
|
|
|
|
return rows[0][resolve_casing(pk, rows[0])]
|
2019-07-23 06:06:59 -07:00
|
|
|
|
except IndexError:
|
2021-11-18 23:26:50 -08:00
|
|
|
|
return self.insert(
|
|
|
|
|
|
combined_values,
|
|
|
|
|
|
pk=pk,
|
|
|
|
|
|
foreign_keys=foreign_keys,
|
|
|
|
|
|
column_order=column_order,
|
|
|
|
|
|
not_null=not_null,
|
|
|
|
|
|
defaults=defaults,
|
|
|
|
|
|
extracts=extracts,
|
|
|
|
|
|
conversions=conversions,
|
|
|
|
|
|
columns=columns,
|
2023-12-07 21:05:27 -08:00
|
|
|
|
strict=strict,
|
2021-11-18 23:26:50 -08:00
|
|
|
|
).last_pk
|
2019-07-23 06:06:59 -07:00
|
|
|
|
else:
|
2021-11-18 23:26:50 -08:00
|
|
|
|
pk = self.insert(
|
|
|
|
|
|
combined_values,
|
|
|
|
|
|
pk=pk,
|
|
|
|
|
|
foreign_keys=foreign_keys,
|
|
|
|
|
|
column_order=column_order,
|
|
|
|
|
|
not_null=not_null,
|
|
|
|
|
|
defaults=defaults,
|
|
|
|
|
|
extracts=extracts,
|
|
|
|
|
|
conversions=conversions,
|
|
|
|
|
|
columns=columns,
|
2023-12-07 21:05:27 -08:00
|
|
|
|
strict=strict,
|
2021-11-18 23:26:50 -08:00
|
|
|
|
).last_pk
|
2021-11-14 18:01:56 -08:00
|
|
|
|
self.create_index(lookup_values.keys(), unique=True)
|
2019-07-23 06:06:59 -07:00
|
|
|
|
return pk
|
|
|
|
|
|
|
2019-08-03 20:51:22 +03:00
|
|
|
|
def m2m(
|
2020-10-27 09:26:01 -07:00
|
|
|
|
self,
|
2021-08-10 16:09:28 -07:00
|
|
|
|
other_table: Union[str, "Table"],
|
|
|
|
|
|
record_or_iterable: Optional[
|
|
|
|
|
|
Union[Iterable[Dict[str, Any]], Dict[str, Any]]
|
|
|
|
|
|
] = None,
|
|
|
|
|
|
pk: Optional[Union[Any, Default]] = DEFAULT,
|
|
|
|
|
|
lookup: Optional[Dict[str, Any]] = None,
|
|
|
|
|
|
m2m_table: Optional[str] = None,
|
|
|
|
|
|
alter: bool = False,
|
2019-08-03 20:51:22 +03:00
|
|
|
|
):
|
2021-08-10 16:09:28 -07:00
|
|
|
|
"""
|
|
|
|
|
|
After inserting a record in a table, create one or more records in some other
|
|
|
|
|
|
table and then create many-to-many records linking the original record and the
|
|
|
|
|
|
newly created records together.
|
|
|
|
|
|
|
|
|
|
|
|
For example::
|
|
|
|
|
|
|
|
|
|
|
|
db["dogs"].insert({"id": 1, "name": "Cleo"}, pk="id").m2m(
|
|
|
|
|
|
"humans", {"id": 1, "name": "Natalie"}, pk="id"
|
|
|
|
|
|
)
|
2021-08-18 11:55:19 -07:00
|
|
|
|
|
2021-08-10 16:09:28 -07:00
|
|
|
|
See :ref:`python_api_m2m` for details.
|
|
|
|
|
|
|
2022-03-11 09:38:34 -08:00
|
|
|
|
:param other_table: The name of the table to insert the new records into.
|
|
|
|
|
|
:param record_or_iterable: A single dictionary record to insert, or a list of records.
|
|
|
|
|
|
:param pk: The primary key to use if creating ``other_table``.
|
|
|
|
|
|
:param lookup: Same dictionary as for ``.lookup()``, to create a many-to-many lookup table.
|
|
|
|
|
|
:param m2m_table: The string name to use for the many-to-many table, defaults to creating
|
2021-08-10 16:09:28 -07:00
|
|
|
|
this automatically based on the names of the two tables.
|
2022-03-11 09:38:34 -08:00
|
|
|
|
:param alter: Set to ``True`` to add any missing columns on ``other_table`` if that table
|
2021-08-10 16:09:28 -07:00
|
|
|
|
already exists.
|
|
|
|
|
|
"""
|
2019-08-04 05:09:17 +03:00
|
|
|
|
if isinstance(other_table, str):
|
More type annotations (#697)
* Add comprehensive type annotations
- mypy.ini: expanded configuration with module-specific settings
- hookspecs.py: type annotations for hook functions
- plugins.py: typed get_plugins() return value
- recipes.py: full type annotations for parsedate, parsedatetime, jsonsplit
- utils.py: extensive type annotations including Row type alias,
TypeTracker, ValueTracker, and all utility functions
- db.py: type annotations for Database methods (__exit__,
ensure_autocommit_off, tracer, register_function, etc.) and
Queryable class methods
- tests/test_docs.py: updated to match new signature display format
* Fix type errors caught by ty check
- Add type: ignore comments for external library type stub limitations
(csv.reader, click.progressbar, IOBase.name, Callable.__name__)
- Change Iterable to Sequence for SQL where_args parameters
- Use db.table() instead of db[name] for proper Table return type
- Fix rebuild_fts return type from None to Table
- Update test_tracer to expect fewer queries (optimization side effect)
* Fix mypy type errors
- Add type: ignore comments for runtime-valid patterns mypy can't verify
- Fix new_column_types annotation to Dict[str, Set[type]]
- Add type: ignore for Default sentinel values passed to create_table
* mypy skip tests directory
* Fix CI: exclude typing imports from recipe docs, skip mypy on tests
- Add Callable and Optional to exclusion list in _generate_convert_help()
- Regenerate docs/cli-reference.rst with cog
- Add [mypy-tests.*] ignore_errors = True to skip test type errors
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 22:11:47 -08:00
|
|
|
|
other_table = self.db.table(other_table, pk=pk)
|
2019-07-31 08:31:27 +03:00
|
|
|
|
our_id = self.last_pk
|
2019-08-03 17:28:03 +03:00
|
|
|
|
if lookup is not None:
|
2026-07-04 19:19:24 +00:00
|
|
|
|
if record_or_iterable is not None:
|
|
|
|
|
|
raise ValueError("Provide lookup= or record, not both")
|
|
|
|
|
|
elif record_or_iterable is None:
|
|
|
|
|
|
raise ValueError("Provide lookup= or record, not both")
|
2019-08-04 05:09:17 +03:00
|
|
|
|
tables = list(sorted([self.name, other_table.name]))
|
2019-07-31 08:31:27 +03:00
|
|
|
|
columns = ["{}_id".format(t) for t in tables]
|
2019-08-03 21:15:16 +03:00
|
|
|
|
if m2m_table is not None:
|
|
|
|
|
|
m2m_table_name = m2m_table
|
|
|
|
|
|
else:
|
|
|
|
|
|
# Detect if there is a single, unambiguous option
|
2019-08-04 05:09:17 +03:00
|
|
|
|
candidates = self.db.m2m_table_candidates(self.name, other_table.name)
|
2019-08-03 21:15:16 +03:00
|
|
|
|
if len(candidates) == 1:
|
|
|
|
|
|
m2m_table_name = candidates[0]
|
|
|
|
|
|
elif len(candidates) > 1:
|
|
|
|
|
|
raise NoObviousTable(
|
|
|
|
|
|
"No single obvious m2m table for {}, {} - use m2m_table= parameter".format(
|
2019-08-04 05:09:17 +03:00
|
|
|
|
self.name, other_table.name
|
2019-08-03 21:15:16 +03:00
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
# If not, create a new table
|
|
|
|
|
|
m2m_table_name = m2m_table or "{}_{}".format(*tables)
|
2021-08-10 16:09:28 -07:00
|
|
|
|
m2m_table_obj = self.db.table(m2m_table_name, pk=columns, foreign_keys=columns)
|
2019-08-03 17:28:03 +03:00
|
|
|
|
if lookup is None:
|
2020-10-27 11:24:21 -05:00
|
|
|
|
# if records is only one record, put the record in a list
|
2021-08-10 16:09:28 -07:00
|
|
|
|
if isinstance(record_or_iterable, Mapping):
|
|
|
|
|
|
records = [record_or_iterable]
|
|
|
|
|
|
else:
|
|
|
|
|
|
records = cast(List, record_or_iterable)
|
2019-08-03 17:28:03 +03:00
|
|
|
|
# Ensure each record exists in other table
|
|
|
|
|
|
for record in records:
|
2021-01-17 20:26:02 -08:00
|
|
|
|
id = other_table.insert(
|
2021-08-10 16:09:28 -07:00
|
|
|
|
cast(dict, record), pk=pk, replace=True, alter=alter
|
2021-01-17 20:26:02 -08:00
|
|
|
|
).last_pk
|
2021-08-10 16:09:28 -07:00
|
|
|
|
m2m_table_obj.insert(
|
2019-08-04 05:09:17 +03:00
|
|
|
|
{
|
|
|
|
|
|
"{}_id".format(other_table.name): id,
|
|
|
|
|
|
"{}_id".format(self.name): our_id,
|
2019-12-27 09:30:29 +00:00
|
|
|
|
},
|
|
|
|
|
|
replace=True,
|
2019-08-03 17:28:03 +03:00
|
|
|
|
)
|
|
|
|
|
|
else:
|
2019-08-04 05:09:17 +03:00
|
|
|
|
id = other_table.lookup(lookup)
|
2021-08-10 16:09:28 -07:00
|
|
|
|
m2m_table_obj.insert(
|
2019-08-04 05:09:17 +03:00
|
|
|
|
{
|
|
|
|
|
|
"{}_id".format(other_table.name): id,
|
|
|
|
|
|
"{}_id".format(self.name): our_id,
|
2019-12-27 09:30:29 +00:00
|
|
|
|
},
|
|
|
|
|
|
replace=True,
|
2019-07-31 08:31:27 +03:00
|
|
|
|
)
|
|
|
|
|
|
return self
|
|
|
|
|
|
|
More type annotations (#697)
* Add comprehensive type annotations
- mypy.ini: expanded configuration with module-specific settings
- hookspecs.py: type annotations for hook functions
- plugins.py: typed get_plugins() return value
- recipes.py: full type annotations for parsedate, parsedatetime, jsonsplit
- utils.py: extensive type annotations including Row type alias,
TypeTracker, ValueTracker, and all utility functions
- db.py: type annotations for Database methods (__exit__,
ensure_autocommit_off, tracer, register_function, etc.) and
Queryable class methods
- tests/test_docs.py: updated to match new signature display format
* Fix type errors caught by ty check
- Add type: ignore comments for external library type stub limitations
(csv.reader, click.progressbar, IOBase.name, Callable.__name__)
- Change Iterable to Sequence for SQL where_args parameters
- Use db.table() instead of db[name] for proper Table return type
- Fix rebuild_fts return type from None to Table
- Update test_tracer to expect fewer queries (optimization side effect)
* Fix mypy type errors
- Add type: ignore comments for runtime-valid patterns mypy can't verify
- Fix new_column_types annotation to Dict[str, Set[type]]
- Add type: ignore for Default sentinel values passed to create_table
* mypy skip tests directory
* Fix CI: exclude typing imports from recipe docs, skip mypy on tests
- Add Callable and Optional to exclusion list in _generate_convert_help()
- Regenerate docs/cli-reference.rst with cog
- Add [mypy-tests.*] ignore_errors = True to skip test type errors
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 22:11:47 -08:00
|
|
|
|
def analyze(self) -> None:
|
2022-01-10 11:48:38 -08:00
|
|
|
|
"Run ANALYZE against this table"
|
|
|
|
|
|
self.db.analyze(self.name)
|
|
|
|
|
|
|
2020-12-12 23:20:11 -08:00
|
|
|
|
def analyze_column(
|
2023-05-21 09:19:30 -07:00
|
|
|
|
self,
|
|
|
|
|
|
column: str,
|
|
|
|
|
|
common_limit: int = 10,
|
|
|
|
|
|
value_truncate=None,
|
|
|
|
|
|
total_rows=None,
|
|
|
|
|
|
most_common: bool = True,
|
|
|
|
|
|
least_common: bool = True,
|
2021-08-10 16:09:28 -07:00
|
|
|
|
) -> "ColumnDetails":
|
|
|
|
|
|
"""
|
|
|
|
|
|
Return statistics about the specified column.
|
|
|
|
|
|
|
|
|
|
|
|
See :ref:`python_api_analyze_column`.
|
2022-03-11 09:38:34 -08:00
|
|
|
|
|
|
|
|
|
|
:param column: Column to analyze
|
|
|
|
|
|
:param common_limit: Show this many column values
|
|
|
|
|
|
:param value_truncate: Truncate display of common values to this many characters
|
|
|
|
|
|
:param total_rows: Optimization - pass the total number of rows in the table to save running a fresh ``count(*)`` query
|
2023-05-21 09:19:30 -07:00
|
|
|
|
:param most_common: If ``True``, calculate the most common values
|
|
|
|
|
|
:param least_common: If ``True``, calculate the least common values
|
2021-08-10 16:09:28 -07:00
|
|
|
|
"""
|
2020-12-12 23:20:11 -08:00
|
|
|
|
db = self.db
|
|
|
|
|
|
table = self.name
|
|
|
|
|
|
if total_rows is None:
|
|
|
|
|
|
total_rows = db[table].count
|
|
|
|
|
|
|
|
|
|
|
|
def truncate(value):
|
|
|
|
|
|
if value_truncate is None or isinstance(value, (float, int)):
|
|
|
|
|
|
return value
|
|
|
|
|
|
value = str(value)
|
|
|
|
|
|
if len(value) > value_truncate:
|
|
|
|
|
|
value = value[:value_truncate] + "..."
|
|
|
|
|
|
return value
|
|
|
|
|
|
|
2025-11-23 20:43:26 -08:00
|
|
|
|
table_quoted = quote_identifier(table)
|
|
|
|
|
|
column_quoted = quote_identifier(column)
|
2020-12-12 23:20:11 -08:00
|
|
|
|
num_null = db.execute(
|
2025-11-23 20:43:26 -08:00
|
|
|
|
"select count(*) from {} where {} is null".format(
|
|
|
|
|
|
table_quoted, column_quoted
|
|
|
|
|
|
)
|
2020-12-12 23:20:11 -08:00
|
|
|
|
).fetchone()[0]
|
|
|
|
|
|
num_blank = db.execute(
|
2025-11-23 20:43:26 -08:00
|
|
|
|
"select count(*) from {} where {} = ''".format(table_quoted, column_quoted)
|
2020-12-12 23:20:11 -08:00
|
|
|
|
).fetchone()[0]
|
|
|
|
|
|
num_distinct = db.execute(
|
2025-11-23 20:43:26 -08:00
|
|
|
|
"select count(distinct {}) from {}".format(column_quoted, table_quoted)
|
2020-12-12 23:20:11 -08:00
|
|
|
|
).fetchone()[0]
|
2023-05-21 09:19:30 -07:00
|
|
|
|
most_common_results = None
|
|
|
|
|
|
least_common_results = None
|
2020-12-12 23:20:11 -08:00
|
|
|
|
if num_distinct == 1:
|
|
|
|
|
|
value = db.execute(
|
2025-11-23 20:43:26 -08:00
|
|
|
|
"select {} from {} limit 1".format(column_quoted, table_quoted)
|
2020-12-12 23:20:11 -08:00
|
|
|
|
).fetchone()[0]
|
2023-05-21 09:19:30 -07:00
|
|
|
|
most_common_results = [(truncate(value), total_rows)]
|
2020-12-12 23:20:11 -08:00
|
|
|
|
elif num_distinct != total_rows:
|
2023-05-21 09:19:30 -07:00
|
|
|
|
if most_common:
|
2023-05-21 10:19:16 -07:00
|
|
|
|
# Optimization - if all rows are null, don't run this query
|
|
|
|
|
|
if num_null == total_rows:
|
|
|
|
|
|
most_common_results = [(None, total_rows)]
|
|
|
|
|
|
else:
|
|
|
|
|
|
most_common_results = [
|
|
|
|
|
|
(truncate(r[0]), r[1])
|
|
|
|
|
|
for r in db.execute(
|
2025-11-23 20:43:26 -08:00
|
|
|
|
"select {}, count(*) from {} group by {} order by count(*) desc, {} limit {}".format(
|
|
|
|
|
|
column_quoted,
|
|
|
|
|
|
table_quoted,
|
|
|
|
|
|
column_quoted,
|
|
|
|
|
|
column_quoted,
|
|
|
|
|
|
common_limit,
|
2023-05-21 10:19:16 -07:00
|
|
|
|
)
|
|
|
|
|
|
).fetchall()
|
|
|
|
|
|
]
|
|
|
|
|
|
most_common_results.sort(key=lambda p: (p[1], p[0]), reverse=True)
|
2023-05-21 09:19:30 -07:00
|
|
|
|
if least_common:
|
|
|
|
|
|
if num_distinct <= common_limit:
|
2023-05-21 10:19:16 -07:00
|
|
|
|
# No need to run the query if it will just return the results in reverse order
|
2023-05-21 09:19:30 -07:00
|
|
|
|
least_common_results = None
|
|
|
|
|
|
else:
|
|
|
|
|
|
least_common_results = [
|
|
|
|
|
|
(truncate(r[0]), r[1])
|
|
|
|
|
|
for r in db.execute(
|
2025-11-23 20:43:26 -08:00
|
|
|
|
"select {}, count(*) from {} group by {} order by count(*), {} desc limit {}".format(
|
|
|
|
|
|
column_quoted,
|
|
|
|
|
|
table_quoted,
|
|
|
|
|
|
column_quoted,
|
|
|
|
|
|
column_quoted,
|
|
|
|
|
|
common_limit,
|
2023-05-21 09:19:30 -07:00
|
|
|
|
)
|
|
|
|
|
|
).fetchall()
|
|
|
|
|
|
]
|
|
|
|
|
|
least_common_results.sort(key=lambda p: (p[1], p[0]))
|
2020-12-12 23:20:11 -08:00
|
|
|
|
return ColumnDetails(
|
|
|
|
|
|
self.name,
|
|
|
|
|
|
column,
|
|
|
|
|
|
total_rows,
|
|
|
|
|
|
num_null,
|
|
|
|
|
|
num_blank,
|
|
|
|
|
|
num_distinct,
|
2023-05-21 09:19:30 -07:00
|
|
|
|
most_common_results,
|
|
|
|
|
|
least_common_results,
|
2020-12-12 23:20:11 -08:00
|
|
|
|
)
|
|
|
|
|
|
|
2022-02-04 00:55:09 -05:00
|
|
|
|
def add_geometry_column(
|
|
|
|
|
|
self,
|
|
|
|
|
|
column_name: str,
|
|
|
|
|
|
geometry_type: str,
|
|
|
|
|
|
srid: int = 4326,
|
|
|
|
|
|
coord_dimension: str = "XY",
|
|
|
|
|
|
not_null: bool = False,
|
|
|
|
|
|
) -> bool:
|
|
|
|
|
|
"""
|
|
|
|
|
|
In SpatiaLite, a geometry column can only be added to an existing table.
|
|
|
|
|
|
To do so, use ``table.add_geometry_column``, passing in a geometry type.
|
|
|
|
|
|
|
|
|
|
|
|
By default, this will add a nullable column using
|
|
|
|
|
|
`SRID 4326 <https://spatialreference.org/ref/epsg/wgs-84/>`__. This can
|
|
|
|
|
|
be customized using the ``column_name``, ``srid`` and ``not_null`` arguments.
|
|
|
|
|
|
|
2022-03-11 09:38:34 -08:00
|
|
|
|
Returns ``True`` if the column was successfully added, ``False`` if not.
|
2022-02-04 00:55:09 -05:00
|
|
|
|
|
|
|
|
|
|
.. code-block:: python
|
|
|
|
|
|
|
|
|
|
|
|
from sqlite_utils.db import Database
|
|
|
|
|
|
from sqlite_utils.utils import find_spatialite
|
|
|
|
|
|
|
|
|
|
|
|
db = Database("mydb.db")
|
|
|
|
|
|
db.init_spatialite(find_spatialite())
|
|
|
|
|
|
|
|
|
|
|
|
# the table must exist before adding a geometry column
|
|
|
|
|
|
table = db["locations"].create({"name": str})
|
|
|
|
|
|
table.add_geometry_column("geometry", "POINT")
|
|
|
|
|
|
|
2022-03-11 09:38:34 -08:00
|
|
|
|
:param column_name: Name of column to add
|
|
|
|
|
|
:param geometry_type: Type of geometry column, for example ``"GEOMETRY"`` or ``"POINT" or ``"POLYGON"``
|
|
|
|
|
|
:param srid: Integer SRID, defaults to 4326 for WGS84
|
|
|
|
|
|
: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``
|
2022-02-04 00:55:09 -05:00
|
|
|
|
"""
|
|
|
|
|
|
cursor = self.db.execute(
|
|
|
|
|
|
"SELECT AddGeometryColumn(?, ?, ?, ?, ?, ?);",
|
|
|
|
|
|
[
|
|
|
|
|
|
self.name,
|
|
|
|
|
|
column_name,
|
|
|
|
|
|
srid,
|
|
|
|
|
|
geometry_type,
|
|
|
|
|
|
coord_dimension,
|
|
|
|
|
|
int(not_null),
|
|
|
|
|
|
],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
result = cursor.fetchone()
|
|
|
|
|
|
return result and bool(result[0])
|
|
|
|
|
|
|
|
|
|
|
|
def create_spatial_index(self, column_name) -> bool:
|
|
|
|
|
|
"""
|
|
|
|
|
|
A spatial index allows for significantly faster bounding box queries.
|
|
|
|
|
|
To create one, use ``create_spatial_index`` with the name of an existing geometry column.
|
|
|
|
|
|
|
|
|
|
|
|
Returns ``True`` if the index was successfully created, ``False`` if not. Calling this
|
|
|
|
|
|
function if an index already exists is a no-op.
|
|
|
|
|
|
|
|
|
|
|
|
.. code-block:: python
|
|
|
|
|
|
|
|
|
|
|
|
# assuming SpatiaLite is loaded, create the table, add the column
|
|
|
|
|
|
table = db["locations"].create({"name": str})
|
|
|
|
|
|
table.add_geometry_column("geometry", "POINT")
|
|
|
|
|
|
|
|
|
|
|
|
# now we can index it
|
|
|
|
|
|
table.create_spatial_index("geometry")
|
|
|
|
|
|
|
|
|
|
|
|
# the spatial index is a virtual table, which we can inspect
|
|
|
|
|
|
print(db["idx_locations_geometry"].schema)
|
|
|
|
|
|
# outputs:
|
|
|
|
|
|
# CREATE VIRTUAL TABLE "idx_locations_geometry" USING rtree(pkid, xmin, xmax, ymin, ymax)
|
|
|
|
|
|
|
2022-03-11 09:38:34 -08:00
|
|
|
|
:param column_name: Geometry column to create the spatial index against
|
2022-02-04 00:55:09 -05:00
|
|
|
|
"""
|
|
|
|
|
|
if f"idx_{self.name}_{column_name}" in self.db.table_names():
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
cursor = self.db.execute(
|
|
|
|
|
|
"select CreateSpatialIndex(?, ?)", [self.name, column_name]
|
|
|
|
|
|
)
|
|
|
|
|
|
result = cursor.fetchone()
|
|
|
|
|
|
return result and bool(result[0])
|
|
|
|
|
|
|
2018-07-28 06:43:18 -07:00
|
|
|
|
|
2019-08-23 15:19:41 +03:00
|
|
|
|
class View(Queryable):
|
More type annotations (#697)
* Add comprehensive type annotations
- mypy.ini: expanded configuration with module-specific settings
- hookspecs.py: type annotations for hook functions
- plugins.py: typed get_plugins() return value
- recipes.py: full type annotations for parsedate, parsedatetime, jsonsplit
- utils.py: extensive type annotations including Row type alias,
TypeTracker, ValueTracker, and all utility functions
- db.py: type annotations for Database methods (__exit__,
ensure_autocommit_off, tracer, register_function, etc.) and
Queryable class methods
- tests/test_docs.py: updated to match new signature display format
* Fix type errors caught by ty check
- Add type: ignore comments for external library type stub limitations
(csv.reader, click.progressbar, IOBase.name, Callable.__name__)
- Change Iterable to Sequence for SQL where_args parameters
- Use db.table() instead of db[name] for proper Table return type
- Fix rebuild_fts return type from None to Table
- Update test_tracer to expect fewer queries (optimization side effect)
* Fix mypy type errors
- Add type: ignore comments for runtime-valid patterns mypy can't verify
- Fix new_column_types annotation to Dict[str, Set[type]]
- Add type: ignore for Default sentinel values passed to create_table
* mypy skip tests directory
* Fix CI: exclude typing imports from recipe docs, skip mypy on tests
- Add Callable and Optional to exclusion list in _generate_convert_help()
- Regenerate docs/cli-reference.rst with cog
- Add [mypy-tests.*] ignore_errors = True to skip test type errors
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 22:11:47 -08:00
|
|
|
|
def exists(self) -> bool:
|
2020-02-08 15:56:03 -08:00
|
|
|
|
return True
|
2019-08-23 15:19:41 +03:00
|
|
|
|
|
2022-10-25 13:14:41 -07:00
|
|
|
|
def __repr__(self) -> str:
|
2019-08-23 15:19:41 +03:00
|
|
|
|
return "<View {} ({})>".format(
|
|
|
|
|
|
self.name, ", ".join(c.name for c in self.columns)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
More type annotations (#697)
* Add comprehensive type annotations
- mypy.ini: expanded configuration with module-specific settings
- hookspecs.py: type annotations for hook functions
- plugins.py: typed get_plugins() return value
- recipes.py: full type annotations for parsedate, parsedatetime, jsonsplit
- utils.py: extensive type annotations including Row type alias,
TypeTracker, ValueTracker, and all utility functions
- db.py: type annotations for Database methods (__exit__,
ensure_autocommit_off, tracer, register_function, etc.) and
Queryable class methods
- tests/test_docs.py: updated to match new signature display format
* Fix type errors caught by ty check
- Add type: ignore comments for external library type stub limitations
(csv.reader, click.progressbar, IOBase.name, Callable.__name__)
- Change Iterable to Sequence for SQL where_args parameters
- Use db.table() instead of db[name] for proper Table return type
- Fix rebuild_fts return type from None to Table
- Update test_tracer to expect fewer queries (optimization side effect)
* Fix mypy type errors
- Add type: ignore comments for runtime-valid patterns mypy can't verify
- Fix new_column_types annotation to Dict[str, Set[type]]
- Add type: ignore for Default sentinel values passed to create_table
* mypy skip tests directory
* Fix CI: exclude typing imports from recipe docs, skip mypy on tests
- Add Callable and Optional to exclusion list in _generate_convert_help()
- Regenerate docs/cli-reference.rst with cog
- Add [mypy-tests.*] ignore_errors = True to skip test type errors
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 22:11:47 -08:00
|
|
|
|
def drop(self, ignore: bool = False) -> None:
|
2022-03-11 09:38:34 -08:00
|
|
|
|
"""
|
|
|
|
|
|
Drop this view.
|
|
|
|
|
|
|
|
|
|
|
|
:param ignore: Set to ``True`` to ignore the error if the view does not exist
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
2021-02-25 09:05:08 -08:00
|
|
|
|
try:
|
2025-11-23 20:43:26 -08:00
|
|
|
|
self.db.execute("DROP VIEW {}".format(quote_identifier(self.name)))
|
2021-02-25 09:05:08 -08:00
|
|
|
|
except sqlite3.OperationalError:
|
|
|
|
|
|
if not ignore:
|
|
|
|
|
|
raise
|
2019-08-23 15:19:41 +03:00
|
|
|
|
|
|
|
|
|
|
|
More type annotations (#697)
* Add comprehensive type annotations
- mypy.ini: expanded configuration with module-specific settings
- hookspecs.py: type annotations for hook functions
- plugins.py: typed get_plugins() return value
- recipes.py: full type annotations for parsedate, parsedatetime, jsonsplit
- utils.py: extensive type annotations including Row type alias,
TypeTracker, ValueTracker, and all utility functions
- db.py: type annotations for Database methods (__exit__,
ensure_autocommit_off, tracer, register_function, etc.) and
Queryable class methods
- tests/test_docs.py: updated to match new signature display format
* Fix type errors caught by ty check
- Add type: ignore comments for external library type stub limitations
(csv.reader, click.progressbar, IOBase.name, Callable.__name__)
- Change Iterable to Sequence for SQL where_args parameters
- Use db.table() instead of db[name] for proper Table return type
- Fix rebuild_fts return type from None to Table
- Update test_tracer to expect fewer queries (optimization side effect)
* Fix mypy type errors
- Add type: ignore comments for runtime-valid patterns mypy can't verify
- Fix new_column_types annotation to Dict[str, Set[type]]
- Add type: ignore for Default sentinel values passed to create_table
* mypy skip tests directory
* Fix CI: exclude typing imports from recipe docs, skip mypy on tests
- Add Callable and Optional to exclusion list in _generate_convert_help()
- Regenerate docs/cli-reference.rst with cog
- Add [mypy-tests.*] ignore_errors = True to skip test type errors
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 22:11:47 -08:00
|
|
|
|
def jsonify_if_needed(value: object) -> object:
|
2020-05-10 18:50:03 -07:00
|
|
|
|
if isinstance(value, decimal.Decimal):
|
|
|
|
|
|
return float(value)
|
2018-07-28 15:20:29 -07:00
|
|
|
|
if isinstance(value, (dict, list, tuple)):
|
2021-05-18 21:47:44 -05:00
|
|
|
|
return json.dumps(value, default=repr, ensure_ascii=False)
|
2019-06-25 21:18:35 -07:00
|
|
|
|
elif isinstance(value, (datetime.time, datetime.date, datetime.datetime)):
|
|
|
|
|
|
return value.isoformat()
|
2023-11-04 01:49:50 +01:00
|
|
|
|
elif isinstance(value, datetime.timedelta):
|
|
|
|
|
|
return str(value)
|
2020-07-29 18:10:25 -07:00
|
|
|
|
elif isinstance(value, uuid.UUID):
|
|
|
|
|
|
return str(value)
|
2018-07-28 15:20:29 -07:00
|
|
|
|
else:
|
|
|
|
|
|
return value
|
2019-02-23 20:36:40 -08:00
|
|
|
|
|
|
|
|
|
|
|
2021-08-18 15:25:18 -07:00
|
|
|
|
def resolve_extracts(
|
2025-05-07 17:49:50 -07:00
|
|
|
|
extracts: Optional[Union[Dict[str, str], List[str], Tuple[str]]],
|
2021-08-18 15:25:18 -07:00
|
|
|
|
) -> dict:
|
2019-07-23 10:00:42 -07:00
|
|
|
|
if extracts is None:
|
|
|
|
|
|
extracts = {}
|
|
|
|
|
|
if isinstance(extracts, (list, tuple)):
|
|
|
|
|
|
extracts = {item: item for item in extracts}
|
|
|
|
|
|
return extracts
|
2020-02-26 20:55:17 -08:00
|
|
|
|
|
|
|
|
|
|
|
More type annotations (#697)
* Add comprehensive type annotations
- mypy.ini: expanded configuration with module-specific settings
- hookspecs.py: type annotations for hook functions
- plugins.py: typed get_plugins() return value
- recipes.py: full type annotations for parsedate, parsedatetime, jsonsplit
- utils.py: extensive type annotations including Row type alias,
TypeTracker, ValueTracker, and all utility functions
- db.py: type annotations for Database methods (__exit__,
ensure_autocommit_off, tracer, register_function, etc.) and
Queryable class methods
- tests/test_docs.py: updated to match new signature display format
* Fix type errors caught by ty check
- Add type: ignore comments for external library type stub limitations
(csv.reader, click.progressbar, IOBase.name, Callable.__name__)
- Change Iterable to Sequence for SQL where_args parameters
- Use db.table() instead of db[name] for proper Table return type
- Fix rebuild_fts return type from None to Table
- Update test_tracer to expect fewer queries (optimization side effect)
* Fix mypy type errors
- Add type: ignore comments for runtime-valid patterns mypy can't verify
- Fix new_column_types annotation to Dict[str, Set[type]]
- Add type: ignore for Default sentinel values passed to create_table
* mypy skip tests directory
* Fix CI: exclude typing imports from recipe docs, skip mypy on tests
- Add Callable and Optional to exclusion list in _generate_convert_help()
- Regenerate docs/cli-reference.rst with cog
- Add [mypy-tests.*] ignore_errors = True to skip test type errors
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 22:11:47 -08:00
|
|
|
|
def _decode_default_value(value: str) -> object:
|
2022-08-27 15:41:10 -07:00
|
|
|
|
if value.startswith("'") and value.endswith("'"):
|
|
|
|
|
|
# It's a string
|
|
|
|
|
|
return value[1:-1]
|
2026-07-14 14:48:28 +00:00
|
|
|
|
if re.fullmatch(r"-?\d+", value):
|
2022-08-27 15:41:10 -07:00
|
|
|
|
# It's an integer
|
|
|
|
|
|
return int(value)
|
|
|
|
|
|
if value.startswith("X'") and value.endswith("'"):
|
|
|
|
|
|
# It's a binary string, stored as hex
|
|
|
|
|
|
to_decode = value[2:-1]
|
|
|
|
|
|
return binascii.unhexlify(to_decode)
|
|
|
|
|
|
# If it is a string containing a floating point number:
|
|
|
|
|
|
try:
|
|
|
|
|
|
return float(value)
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
pass
|
|
|
|
|
|
return value
|