Compare commits

...

146 commits

Author SHA1 Message Date
Simon Willison
a947dc6739 Changelog now links to CLI and Python API in most recent entry 2026-07-12 17:14:01 -07:00
Simon Willison
458b3ab5b1
Release 4.1.1
Refs #791, #792, #794, #795
2026-07-12 13:52:14 -07:00
Simon Willison
f66ddcb215
Transform now refuses to run inside a transaction if destructive foreign keys exist (#795)
* Transform now refuses to run inside a transaction if destructive foreign keys exist

Closes #794

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014StVTWQJpFhfZJK2CYVBwv
2026-07-12 08:43:51 -07:00
Simon Willison
d714200659
Add test: transform does not cascade-delete referencing records (#792)
> Add a test that covers what happens if you run transform against a table with ON CASCADE DELETE for one of its foreign keys - those records should not be deleted during the transform even though the table is dropped as part of that procedure
2026-07-12 05:00:32 -07:00
Simon Willison
3f0471701b
Add cross-reference notes between CLI and Python API documentation (#791) 2026-07-11 21:45:21 -07:00
Simon Willison
5e8822efd2
Clarify usage of named parameters in CLI documentation 2026-07-11 18:52:09 -07:00
Simon Willison
57c1617391 Release 4.1
Refs #131, #626, #684, #765, #787
2026-07-11 16:48:41 -07:00
Simon Willison
b74b727035
.transform(strict=) and sqlite-utils transform --strict/--no-strict (#788)
* .transform(strict=) and sqlite-utils transform --strict/--no-strict

Closes #787
2026-07-11 16:43:37 -07:00
Simon Willison
dc61f75a0b sqlite-utils upsert optional --pk in changelog 2026-07-11 16:42:59 -07:00
Simon Willison
6531a57863 Delete obsolete test_memory_attribute_for_existing_connection test
Refs https://github.com/simonw/sqlite-utils/issues/789#issuecomment-4949146177

Closes #789
2026-07-11 16:28:36 -07:00
Simon Willison
0f2d525d06 Clarify transaction documentation
Based on extensive digging into how this stuff all works.
2026-07-11 16:20:37 -07:00
Simon Willison
7a52214624 drop-index command and table.drop_index(index_name)
Closes #626
2026-07-07 21:00:43 -07:00
Sai Asish Y
d302835d57 feat(db): document and test Database.memory and Database.memory_name
Refs #590, closes #734
2026-07-07 19:40:56 -07:00
Simon Willison
d2ac3765ed sqlite-utils insert/upsert --type colunm-name type option, closes #131 2026-07-07 19:17:29 -07:00
Simon Willison
092f0919c3 Changelog tweak refs #684 2026-07-07 18:57:27 -07:00
Simon Willison
569608e40f sqlite-utils insert --code option, closes #684 2026-07-07 18:49:25 -07:00
Simon Willison
23a21c1d6b Ran Black 2026-07-07 18:11:34 -07:00
Simon Willison
ebafb84c93 Allow sqlite-utils upsert to infer --pk from existing table 2026-07-07 18:09:56 -07:00
Simon Willison
aa300942bf Update sqlite-utils convert --help, refs #686 2026-07-07 18:03:25 -07:00
Simon Willison
cf3373e7b7 Refactor --functions option, improve help 2026-07-07 17:59:58 -07:00
Simon Willison
8ee0b7c65c Fix for rogue quote in enable-fts --help 2026-07-07 17:57:35 -07:00
Simon Willison
fa5d66bf53 Update create-table --help to mention real 2026-07-07 17:56:57 -07:00
Simon Willison
619770bf42 sqlite-utils query - to read SQL from stdin, closes #765 2026-07-07 17:52:26 -07:00
Simon Willison
353baf280d Corrected imports in migrations docs 2026-07-07 11:51:53 -07:00
Simon Willison
8bc9213a8e Release 4.0
Refs #781, #783, #769
2026-07-07 08:40:31 -07:00
Simon Willison
60811e7305
Fix rowid pk and last_rowid regressions in insert/upsert
Closes #781, #783

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

* Fix rowid pk and last_rowid regressions in insert/upsert

Two behaviour regressions in the 4.0 insert/upsert rewrite broke callers
(notably Datasette's write API) that operate on tables without an explicit
primary key. Both are fixed here with regression tests.

1. rowid (and its aliases _rowid_/oid) were rejected as a primary key.
   Table.pks already reports ["rowid"] for a rowid table, but the new pk
   validation raised InvalidColumns because rowid is not listed among the
   table's columns, and the insert success path then raised KeyError when
   looking up the pk value. rowid aliases are now accepted for rowid tables
   and resolve directly to the rowid.

2. An ignored insert (INSERT OR IGNORE that matched an existing row) no
   longer populated last_rowid, and only set last_pk when an explicit pk=
   was passed. It now locates the existing conflicting row by its primary
   key values and reports that row's rowid and pk, rather than relying on
   the connection's last inserted rowid.

Add a shared ROWID_ALIASES constant for the rowid alias names.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E7af8SxFZqiCerJB6MqKnY
2026-07-07 08:24:24 -07:00
Simon Willison
d314d04215 Bump GitHub Actions versions 2026-07-06 22:40:37 -07:00
Simon Willison
d34f1bea0b Release 4.0rc4
Refs #186, #554, #566, #625, #732, #769
2026-07-06 22:33:50 -07:00
Simon Willison
9a2c582465 README tweak ready for v4, refs #769 2026-07-06 22:26:58 -07:00
Simon Willison
e5c772823f
Fixes for final review in issue #769
PR #779
2026-07-06 22:21:59 -07:00
Simon Willison
2582446784 Skip RETURNING-based trigger test on SQLite older than 3.35
The query() exception-masking test uses INSERT ... RETURNING, which is
a syntax error on SQLite 3.23.1 - skip it there like the other
RETURNING tests.

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 22:06:51 -07:00
Simon Willison
d9a0fd26e0 Transaction cleanup no longer masks transaction-destroying errors
A RAISE(ROLLBACK) trigger or INSERT OR ROLLBACK conflict rolls back the
entire transaction and destroys every savepoint. The cleanup paths in
atomic() and query() then raised OperationalError ("no such savepoint" /
"cannot rollback - no transaction is active"), masking the original
IntegrityError - breaking user code that catches sqlite3.IntegrityError.
Cleanup now checks conn.in_transaction first: if the error already
destroyed the transaction there is nothing left to undo, and the
original exception propagates.

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 22:04:00 -07:00
Simon Willison
c2a1774409 Fix two tests that assumed modern SQLite behavior
SQLite 3.23.1 rejects a UTF-8 byte order mark before the first token,
so the BOM variant of the execute()-prefixed-BEGIN test now skips when
the SQLite version does not accept a leading BOM. And versions before
3.36 allowed selecting rowid from a view, returning NULL, rather than
raising an error - the pks_and_rows_where() view test now accepts
either behavior.

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 22:02:41 -07:00
Simon Willison
93640a7dde migrate --list is read-only with legacy sqlite-migrate classes too
The docs promise --list will not create the database file or the
_sqlite_migrations table, but legacy sqlite_migrate.Migrations classes
create the table (in the legacy schema) from their pending()/applied()
methods. The listing now runs inside a transaction that is rolled back,
keeping --list read-only regardless of what the migration class does.

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 21:58:52 -07:00
Simon Willison
548a886ca1 Clean CLI errors for InvalidColumns from insert --pk and extract
sqlite-utils insert db t - --pk badcol and sqlite-utils extract db t
nosuchcol dumped raw InvalidColumns tracebacks - the insert error
handling caught NoTable and OperationalError but not the InvalidColumns
introduced for #732, and the extract command had no handling at all
(including for NoTable when pointed at a view). Both now exit with
click-style Error: messages.

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 21:57:14 -07:00
Simon Willison
8572d1e39c extract() no longer duplicates NULL-containing rows in shared lookups
The lookup-table insert relied on INSERT OR IGNORE and the unique index
to dedupe against existing rows, but SQLite unique indexes treat NULLs
as distinct - extracting a second table into the same lookup table
re-inserted every NULL-containing value, growing orphan rows on each
extract. The insert now also has an IS-based NOT EXISTS guard, matching
how the foreign keys themselves are resolved.

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 21:55:21 -07:00
Simon Willison
16bbfb582d add_foreign_keys() errors instead of silently dropping action changes
The existing-foreign-key dedup compared only columns and other_table, so
requesting an FK that already exists with different ON DELETE/ON UPDATE
actions was a silent no-op - and with add_foreign_key() raising "already
exists", there was no signal that the requested actions were dropped.
An exact match (including actions) is still skipped for idempotency;
a mismatch now raises AlterError pointing at table.transform().

Also validates that compound foreign keys passed as 4-tuples have the
same number of columns on both sides - extra other-columns were being
silently discarded, e.g. ("t", ("a",), "other", ("a", "b")) created a
single-column key referencing just "a".

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 21:53:48 -07:00
Simon Willison
a0387791e5 ensure_autocommit_on() raises TransactionError inside a transaction
Assigning conn.isolation_level commits any pending transaction as a side
effect, so entering the context manager with a transaction open silently
committed the caller's work and made a later rollback() a no-op. All
internal callers already ensure no transaction is open; the public API
now enforces it, matching enable_wal() and disable_wal().

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 21:51:31 -07:00
Simon Willison
b3aa3f47b7 migrate --stop-before an already-applied migration is now an error
The CLI validated --stop-before names against both pending and applied
migrations, but Migrations.apply() only looked for the stop name among
pending ones - naming an applied migration passed validation and then
silently applied every migration after it, the exact outcome the option
exists to prevent. apply() now raises ValueError before applying
anything if a stop_before name matches an applied migration in its set;
names not in the set are still ignored, since unqualified CLI values are
offered to every set. The migrate command reports it as a clean error.

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 21:50:05 -07:00
Simon Willison
3de8507c6b insert(pk=, alter=True) can add the pk column from records again
The InvalidColumns check added for #732 fired before alter=True had a
chance to add the missing pk column - a regression from 3.x, where
insert({"id": 5, "a": 2}, pk="id", alter=True) against a table without
an id column worked. With alter=True the check is now deferred until the
first batch of record keys is known: a pk column supplied by the records
passes, one found in neither the table nor the records still raises
InvalidColumns before anything is inserted. An empty record iterator
with alter=True returns without error, matching the 3.x no-op.

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 21:47:59 -07:00
Simon Willison
884574685f insert/upsert --csv no longer rewrites column types of existing tables
Type detection is the 4.0 default for CSV/TSV data, and the detected-type
transform ran even when the target table already existed - inserting a
CSV into a table with a TEXT zip column converted the column to INTEGER,
corrupting values with leading zeros ('01234' became 1234) with no
warning. Detected types now only apply to tables the command created.

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 21:44:56 -07:00
Simon Willison
1ed95e4ad2 Fix pks_and_rows_where() on views
The previous compound-pk-ordering commit switched pks_and_rows_where()
to Table-only properties, but the method is defined on Queryable and
views exposed it too - calling it on a View raised AttributeError.

Restored Queryable-safe logic, and stopped double-quoting the
synthesized rowid column: SQLite turns a double-quoted identifier that
does not resolve into a string literal, so on a view the generated
select "rowid" silently produced the string 'rowid' and a confusing
KeyError, where 3.x's [rowid] quoting raised OperationalError cleanly.

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 21:42:02 -07:00
Simon Willison
29ca9d27e2 foreign_keys= accepts mixed ForeignKey objects, tuples and strings again
resolve_foreign_keys() used all-or-nothing isinstance checks, so mixing
ForeignKey objects with tuples raised "foreign_keys= should be a list of
tuples" - a regression from 3.x, where ForeignKey was a namedtuple and
passed the tuple check. Each item is now normalized individually, which
also allows bare column-name strings in a mixed list.

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 21:37:56 -07:00
Simon Willison
404e935b63 ForeignKey is now a frozen dataclass, restoring hashability
The namedtuple-to-dataclass change made ForeignKey unhashable, breaking
set(table.foreign_keys) and dict-key usage that worked in 3.x. frozen=True
restores immutability and hashability. Equality and hashing cover all
compared fields including on_delete/on_update - two foreign keys differing
only in their actions are different constraints.

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 21:35:45 -07:00
Simon Willison
8e015d024c Compound primary keys now resolve in PRIMARY KEY declaration order
PRAGMA table_info sets is_pk to the 1-based position of each column
within the PRIMARY KEY, which can differ from table column order.
table.pks previously returned table column order, so an implicit
compound FOREIGN KEY ... REFERENCES other was introspected with its
referenced columns inverted, and transform() baked that inverted order
into the rewritten schema - failing with IntegrityError on valid data,
or silently reversing the constraint with foreign keys off.

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 21:34:02 -07:00
Simon Willison
66934918c6 Document that rejected write PRAGMAs in db.query() still take effect
db.query() promises that a statement rejected with ValueError is rolled
back and has no effect. PRAGMA statements are the exception: some of them
refuse to run inside a transaction, so they execute outside the savepoint
guard - a row-less PRAGMA such as "PRAGMA user_version = 5" therefore
takes effect despite the ValueError. Documenting this as a known
limitation rather than fixing it, since a fix would need a hardcoded list
of row-returning PRAGMAs.

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 21:31:19 -07:00
Simon Willison
adc10df981 Fix for db.query("; COMMIT") bypasses the first-token scanner
Refs https://github.com/simonw/sqlite-utils/issues/769#issuecomment-4900034150
2026-07-06 21:23:36 -07:00
Simon Willison
7d86118168 Fix failed db.execute() write leaves a phantom transaction open
Refs https://github.com/simonw/sqlite-utils/issues/769#issuecomment-4900034150
2026-07-06 21:20:18 -07:00
Simon Willison
f2fbcf60d8 --no-headers fix in changelog, refs #566 2026-07-06 21:03:15 -07:00
Simon Willison
2616dec795 .extract() and .lookup() no longer extract null values, closes #186
- table.extract() and the sqlite-utils extract command now skip rows
  where every extracted column is null: the new foreign key column is
  left null and no all-null record is added to the lookup table. Rows
  with at least one non-null extracted column are extracted as before.
- The extracts= option to insert() and friends keeps None values as
  null instead of creating a lookup record for them - previously each
  insert batch added a duplicate null row to the lookup table.
- table.lookup() compares values using IS rather than = so lookup
  values containing None match existing rows correctly, instead of
  inserting a duplicate row on every call.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 20:41:10 -07:00
Simon Willison
b8aa136857 Remove beanbag-docutils - we stopped needing that in 5f81752 2026-07-06 20:40:01 -07:00
Simon Willison
221774f25a Fix for table.insert(..., pk=..., ignore=True), closes #554 2026-07-06 20:22:08 -07:00
Simon Willison
6225eba5c8 Raise InvalidColumns on insert(pk="invalid")
Closes #732
2026-07-06 20:16:28 -07:00
Simon Willison
815b6a7d3d
JSON output no longer escapes non-ASCII characters, new --ascii option (#777)
Closes #625

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JaHan1NhaTRAxJ9LQtSLf9
2026-07-06 11:10:07 -07:00
Johnson K C
d516e58543
Honor --no-headers for --fmt and --table output (#566) (#751)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 09:54:17 -07:00
Simon Willison
5f81752cf5 Fix for frustrating error in 'just docs' locally
fatal: refusing to fetch into branch 'refs/heads/main' checked out at '/Users/simon/Dropbox/dev/sqlite-utils'

I'm not going to pretend to understand this fix, it works.
2026-07-05 23:19:40 -07:00
Simon Willison
af3894a096 Changelog headings for 4.0rc3 2026-07-05 23:19:00 -07:00
Simon Willison
d1f5e06816 Add subheadings to 4.0rc3 release notes
So I can link to those sections.
2026-07-05 23:12:54 -07:00
Simon Willison
3e8b7403a2 Release 4.0rc3
Refs https://github.com/simonw/sqlite-utils/issues/769#issuecomment-4889420844

Refs #530, #594, #624, #705, #760, #764, #769, #770, #771, #772, #774, #775
2026-07-05 22:36:16 -07:00
Vincent Gao
a4acc3958c
Fix transform() corrupting TRUE/FALSE/NULL column defaults into strings
* Fix transform() corrupting TRUE/FALSE/NULL column defaults

quote_default_value() passed keyword-literal defaults (TRUE, FALSE, NULL)
through self.quote(), wrapping them in quotes. So transform() rebuilt a
column declared "INTEGER DEFAULT TRUE" as "INTEGER DEFAULT 'TRUE'", and a
later default insert stored the text 'TRUE' instead of the integer 1
(likewise 'FALSE' for 0 and 'NULL' for null) - silent value corruption.

Return these keyword literals unquoted, as already done for the
CURRENT_TIME/DATE/TIMESTAMP literals.

PR #764
2026-07-05 22:33:55 -07:00
Simon Willison
77d241959c Match column names case-insensitively, closes #760
Column names passed to Python API methods are now resolved against the
table schema case-insensitively, mirroring how SQLite itself compares
identifiers (ASCII-only case folding). Fixes KeyError populating
last_pk from insert()/upsert(), silently ignored transform()
rename/drop/types options, duplicate column errors from
create_table(transform=True), redundant lookup() indexes, and
case-sensitive foreign key validation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 22:11:22 -07:00
Simon Willison
50938ee6f8
Rename ensure_autocommit_off() to ensure_autocommit_on(), closes #705 2026-07-05 21:54:30 -07:00
Simon Willison
02281f77ed
Vendor SQLite version setup action (#775)
Vendor 71ea0de37a and make it more robust against `sqlite.org` timeouts.

Closes #774
2026-07-05 21:42:22 -07:00
Simon Willison
07b603e562
Preserve duplicate column names in query results
Queries returning duplicate column names - e.g. joins between tables
sharing column names - silently lost values because rows were built
with dict(zip(keys, row)), where the last duplicate wins.

Later occurrences are now renamed with a numeric suffix: id, id
becomes id, id_2 - skipping any suffix that would collide with a
real column in the same query.

The new utils.dedupe_keys() helper transforms the key list once per
query, so the per-row dict construction is unchanged and there is no
measurable performance impact.

Applied in Database.query() (including the PRAGMA and RETURNING
paths), Table.rows_where(), Table.search() and the CLI's JSON output.
CSV, TSV and table output keep the original duplicate headers.

Closes #624
2026-07-05 21:20:39 -07:00
Simon Willison
a00ed60efc Apply Black to tests/test_gis.py
Fixes formatting left behind by afbfd95, which was merged while the
Black check on its pull request was still failing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 21:17:41 -07:00
Simon Willison
afbfd95273
Remove sqlean.py support (#772)
Closes #771, refs #769
2026-07-05 16:00:52 -07:00
Simon Willison
9a3936531d
Compound foreign key support (PR #770)
Closes #594, closes #530

- **Breaking:** `ForeignKey` is now a dataclass, not a namedtuple - tuple unpacking/indexing raises `TypeError`; access fields by name. New fields: `columns`, `other_columns`, `is_compound`, `on_delete`, `on_update`.
- `table.foreign_keys` returns a compound foreign key as a single `ForeignKey` with `is_compound=True`, instead of one misleading entry per column.
- Create compound foreign keys anywhere with tuples of columns: `foreign_keys=[(("campus_name", "dept_code"), "departments")]` - also in `add_foreign_key()` and `db.add_foreign_keys()`. Rendered as table-level `FOREIGN KEY` constraints.
- `transform()` round-trips compound keys: `rename=` applies to member columns, dropping a member column drops the constraint, `drop_foreign_keys=` accepts a column name or an exact column tuple.
- `ON DELETE`/`ON UPDATE` actions are captured, rendered and preserved - `transform()` no longer strips `ON DELETE CASCADE`. New `add_foreign_key(..., on_delete="CASCADE")` parameters.
- Fixes: sorting mixed compound/single keys no longer raises `TypeError`; implicit `REFERENCES other_table` resolves to the other table's primary key; `index_foreign_keys()` creates a composite index for compound keys.
2026-07-05 15:54:44 -07:00
Simon Willison
0ec0180405 add_foreign_key() on_delete= and on_update= parameters, closes #530
table.add_foreign_key() now accepts on_delete= and on_update= to
create foreign keys with ON DELETE/ON UPDATE actions:

    table.add_foreign_key("author_id", "authors", "id", on_delete="CASCADE")

Works for compound foreign keys too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 15:49:20 -07:00
Simon Willison
658185d297 Fix compound foreign key test failure against sqlean
test_create_table_compound_foreign_key_enforced caught the stdlib
sqlite3.IntegrityError, but sqlean's IntegrityError is not a subclass
of it. Import sqlite3 from sqlite_utils.utils like the other test
modules, so the right exception is used whichever backend is installed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 15:46:16 -07:00
Simon Willison
5b61530965 db.add_foreign_keys() no longer drops ON DELETE/ON UPDATE actions
ForeignKey objects passed to db.add_foreign_keys() were flattened to
plain (table, column, other_table, other_column) tuples before being
handed to transform(), silently discarding their on_delete and
on_update actions. The method now carries ForeignKey objects through
to transform() intact - tuple inputs are converted to ForeignKey
objects up front, which also simplifies the validation loop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 15:42:41 -07:00
Simon Willison
2f599fc7c6 Run ty in just lint, matching CI
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 15:38:05 -07:00
Simon Willison
8dfbfa80b8 Fix ty errors in transform_sql foreign key closures
The fk_should_be_dropped and fk_with_renamed_columns closures captured
the drop and rename parameters, but type checkers do not narrow
captured variables inside nested functions so ty saw their Optional
declared types. Bind the already-normalized values to fresh names for
the closures to use.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 15:36:46 -07:00
Simon Willison
d100264e9c Normalize compound foreign key columns to tuples, promote tuples in docs
ForeignKey.columns and .other_columns are now tuples rather than lists,
both when introspected and when constructed directly (lists are
normalized in __post_init__). Tuples are now the documented form for
specifying compound foreign keys everywhere - in create_table()
foreign_keys=, add_foreign_key() and drop_foreign_keys=:

    foreign_keys=[(("campus_name", "dept_code"), "departments")]

Lists continue to work as input but are no longer documented.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 15:10:21 -07:00
Simon Willison
c16edb2dc4 Better type signatures for compound foreign key columns
New ForeignKeyColumns type alias - a column name or a list/tuple of
column names - used by add_foreign_key(), add_foreign_keys() (via the
ForeignKeyTuple alias, replacing its Any slots) and the
ForeignKeyIndicator union, which it also simplifies. Tuples now
type-check anywhere lists are accepted.

ForeignKey.__post_init__ normalizes tuple columns/other_columns to
lists so direct construction with tuples compares equal to
introspected foreign keys.

Refs https://github.com/simonw/sqlite-utils/pull/770/changes#r3525703477

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 15:03:43 -07:00
Simon Willison
42c1dd0d5f Documentation for compound foreign key support, refs #594
- Changelog entries for all the compound foreign key work
- Upgrading guide notes the transform() behavior changes
- ForeignKey added to the API reference
- Updated .foreign_keys introspection example output

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 14:45:08 -07:00
Simon Willison
8443d7f3ba Resolve implicit primary key references in table.foreign_keys
For foreign keys declared as "REFERENCES other_table" with no explicit
columns, PRAGMA foreign_key_list returns None for the referenced
column. The foreign_keys property now resolves these to the other
table's primary key columns, so other_column=None unambiguously
indicates a compound foreign key.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 14:42:30 -07:00
Simon Willison
be27a96484 Capture and preserve foreign key ON DELETE/ON UPDATE actions
ForeignKey gains on_delete and on_update fields (default "NO ACTION"),
populated from PRAGMA foreign_key_list. create_table_sql() renders the
corresponding clauses for both inline and compound table-level foreign
keys, which means table.transform() now preserves actions such as
ON DELETE CASCADE - previously they were silently stripped whenever a
table was transformed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 14:40:57 -07:00
Simon Willison
b75edf4b30 add_foreign_key() accepts compound keys, composite FK indexes, refs #594
table.add_foreign_key() and db.add_foreign_keys() now accept lists of
column names to create compound foreign keys:

    table.add_foreign_key(
        ["campus_name", "dept_code"], "departments"
    )

Omitting the other columns uses the compound primary key of the other
table. Duplicate detection compares the full column lists.

db.index_foreign_keys() now creates a single composite index across the
columns of a compound foreign key, rather than an index per column.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 14:37:32 -07:00
Simon Willison
7d43fd50e1 transform() now round-trips compound foreign keys, refs #594
Compound foreign keys are passed through table.transform() intact
instead of being degraded to per-column single foreign keys:

- rename= applies to each member column of a compound key
- drop= of any member column drops the whole constraint, matching
  the existing single-column behavior
- drop_foreign_keys= accepts a bare column name (drops any foreign
  key that column participates in) or a tuple of columns (drops the
  compound key with exactly those columns)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 14:33:15 -07:00
Simon Willison
577f3011e5 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
Simon Willison
d5bf51df35 Fix TypeError when sorting mixed compound/single foreign keys
sorted() on a foreign_keys list containing both compound (column=None)
and single-column ForeignKeys raised TypeError because dataclass
ordering compared None against str. column/other_column are now
excluded from comparison - ordering and equality use the always
populated columns/other_columns lists instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 14:26:56 -07:00
Simon Willison
f10459cffb table.foreign_keys now handles compound foreign keys, refs #594
This is a breaking change, since the return type is now a dataclass
and not a namedtuple.
2026-07-05 13:59:25 -07:00
Simon Willison
623331b3f4 Fix for test failure against sqlean 2026-07-05 12:16:11 -07:00
Simon Willison
61619498fa Give db.query() SQL restrictions top billing in upgrading docs
Rejecting statements that return no rows is the change most likely
to require code edits when upgrading to 4.0, so it now leads the
Python API changes section - before db.table() - with the full list
of rejected statement types and a before/after example.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 11:50:10 -07:00
Simon Willison
87cf1c5a00 Replace SQL prefix matching with a first-token scanner
query() and execute() classified statements using
sql.lstrip().upper().startswith(...), which a leading SQL comment
defeated: db.query('/* c */ COMMIT') inside db.atomic() committed
the caller's transaction and masked the ValueError with a 'no such
savepoint' error, comment-prefixed PRAGMAs ran inside the savepoint
guard where journal mode changes are refused, and a comment-prefixed
BEGIN passed to db.execute() was instantly auto-committed.

The new _first_keyword() helper skips leading whitespace and -- or
/* */ comments - the only things SQLite's tokenizer allows before
the first token - and returns that token for exact comparison, so
keyword detection now matches what SQLite itself will see.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 11:49:59 -07:00
Simon Willison
a8e3a643f2 Release 4.0rc2
Refs #759, #629, #761, #767, #768
2026-07-04 17:45:42 -07:00
Simon Willison
0566a9f128
Improve db.query() error handling and transaction safety
db.query() rejected statements roll back, RETURNING commits immediately.

Fixes two transaction bugs in db.query():

- A non-row-returning statement such as an UPDATE was executed and
  auto-committed by execute() before the ValueError was raised, so the
  write took effect despite being rejected. query() now runs the
  statement inside a savepoint and rolls it back before raising, so a
  rejected statement has no effect on the database - in every
  connection mode, including autocommit=True.

- INSERT ... RETURNING only committed once the returned generator was
  fully exhausted, so calling query() without iterating - or partially
  iterating with next() - left the transaction open and the write could
  be rolled back on close. The write is now completed and committed at
  call time, as the documentation already promised. Plain SELECTs are
  still fetched lazily.

Transaction control statements, VACUUM, ATTACH and DETACH never return
rows, so query() now rejects them without executing them. PRAGMA
statements skip the savepoint guard because some of them - such as
pragma journal_mode=wal - refuse to run inside a transaction.

Claude-Session: https://claude.ai/code/session_012U3iRfJoTZ5vd22cBSF2nJ
2026-07-04 17:40:48 -07:00
Simon Willison
04f8971546
Merge changes based on a Fable review of 4.0rc1
Review of what it found: 0c369a447e/fable-review-4.0rc1.md

Full details here in the PR: https://github.com/simonw/sqlite-utils/pull/767
2026-07-04 16:36:13 -07:00
Claude
7a015cff52
Unwrap hard-wrapped prose in the documentation
The documentation style for this project is one line per paragraph,
not text-wrapped RST. Unwraps three recently added paragraphs in
python-api.rst and two older ones in plugins.rst.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UnLnhsH25Nnv7LHhekUfPd
2026-07-04 23:33:48 +00:00
Simon Willison
89af511f79
Also run "pytest --sqlite-autocommit" for one matrix item in CI
To test that our code works correctly with the new autocommit option introduced in Python 3.13+

https://github.com/simonw/sqlite-utils/pull/767#discussion_r3523983636
2026-07-04 16:24:07 -07:00
Claude
b49af65a6d
Fix PytestRemovedIn10Warning in test_sniff
parametrize received a generator from Path.glob(), which pytest 10
will reject. sorted() materializes the list and makes the parameter
order deterministic as a bonus.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UnLnhsH25Nnv7LHhekUfPd
2026-07-04 23:20:31 +00:00
Claude
2510745de4
Add --sqlite-autocommit pytest option
Runs the entire test suite against connections created with the
Python 3.12+ sqlite3.connect(autocommit=True) mode, by patching
connect() to default autocommit=True for internally-created
connections. Errors on older Python versions. The full suite
passes in both modes with no test changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UnLnhsH25Nnv7LHhekUfPd
2026-07-04 23:19:51 +00:00
Claude
f7ff3e2027
db.execute() write statements now commit automatically
Raw write statements previously opened an implicit transaction that
stayed open until something committed it - the write was visible on
the same connection, making it look saved, but was silently rolled
back when the connection closed. execute() now commits any implicit
transaction it opens, so raw writes behave like every other write
in the library: committed as soon as they run, unless an explicit
transaction (db.begin() or db.atomic()) is open, in which case they
join it.

Row-returning writes such as INSERT ... RETURNING via db.query()
commit once their rows have been iterated. atomic(), commit() and
rollback() now issue literal COMMIT/ROLLBACK statements, which have
identical semantics in every sqlite3 connection mode. The CLI bulk
command uses db.atomic() instead of 'with db.conn:'.

This simplifies the transaction contract to: everything commits
immediately unless you explicitly opened a transaction. Docs
updated throughout; changelog and upgrading guide cover the
breaking change for code that relied on rolling back uncommitted
execute() writes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UnLnhsH25Nnv7LHhekUfPd
2026-07-04 23:15:01 +00:00
Claude
bcd9a26560
Reject Python 3.12+ autocommit connections in the Database constructor
Connections created with sqlite3.connect(autocommit=True) make
commit() a documented no-op, and autocommit=False connections are
permanently inside a transaction - in both modes every write made
by this library appeared to work in-process but was silently
discarded when the connection closed. Database() now raises
TransactionError for these connections instead of losing data.

Tests are skipped on Python versions before 3.12, where the
autocommit parameter does not exist.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UnLnhsH25Nnv7LHhekUfPd
2026-07-04 22:38:31 +00:00
Claude
96793668ed
Raise TransactionError instead of RuntimeError from enable_wal/disable_wal
A dedicated sqlite_utils.db.TransactionError exception is clearer
to catch than a generic RuntimeError, and gives future
transaction-state guards a home.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UnLnhsH25Nnv7LHhekUfPd
2026-07-04 22:32:36 +00:00
Claude
788c393a30
Add db.begin(), db.commit() and db.rollback() methods
Manual transaction control previously required mixing raw
db.execute("begin") strings with methods on db.conn. These thin
wrappers give the documentation and callers a single consistent
idiom. Docs updated to use them throughout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UnLnhsH25Nnv7LHhekUfPd
2026-07-04 22:31:22 +00:00
Claude
6c88067ab7
Explain why the Database context manager rolls back instead of committing
Expands the closing-a-database docs: exit is equivalent to close(),
open transactions can only come from manual transaction control,
auto-committing could silently persist half-finished work, and the
behavior deliberately differs from sqlite3.Connection's own context
manager.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UnLnhsH25Nnv7LHhekUfPd
2026-07-04 22:25:28 +00:00
Claude
ff76fb7740
Clarify that db.atomic() is fine inside transactional migrations
The manual-transaction warning could be read as covering
db.atomic() too. Nested atomic() blocks are safe - they become
savepoints inside the migration's transaction, preserving the
all-or-nothing rollback guarantee.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UnLnhsH25Nnv7LHhekUfPd
2026-07-04 22:20:37 +00:00
Claude
0fca5908c1
Group the Unreleased changelog into breaking changes and everything else
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UnLnhsH25Nnv7LHhekUfPd
2026-07-04 22:19:47 +00:00
Claude
adea475a61
Remove View.enable_fts()
The method existed only to raise NotImplementedError, since
full-text search is not supported for views, and it showed up in
the generated API reference as a documented View method. Calling
enable_fts() on a View now raises AttributeError like any other
missing method. The sqlite-utils enable-fts command uses db.table()
and shows a clean error when pointed at a view instead of a
traceback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UnLnhsH25Nnv7LHhekUfPd
2026-07-04 20:44:04 +00:00
Claude
70055d7432
Document the transaction model as a first-class concept
New top-level 'Transactions and saving your changes' section in the
Python API documentation, stating the fundamental contract up
front: every write method commits its own changes before returning,
no commit() call is ever needed. The db.atomic() documentation
moves under it, alongside new subsections covering the two cases
where the user owns the commit - raw db.execute() writes and
manually managed transactions - and the supported connection modes.

Cross-referenced from Getting started (the first insert example now
says the rows are saved immediately), a warning on db.execute(),
the closing-a-database section and the upgrading guide.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UnLnhsH25Nnv7LHhekUfPd
2026-07-04 20:16:15 +00:00
Claude
509d87b292
Remove review notes files
These were working documents for the 4.0 pre-release review - the
actionable findings have all been fixed on this branch and the
changelog and upgrading guide carry the user-facing record.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UnLnhsH25Nnv7LHhekUfPd
2026-07-04 19:56:42 +00:00
Claude
910a90970d
Add upgrading guide to the documentation
New docs page describing the changes needed to upgrade between
major versions, with a detailed 3.x to 4.0 section covering CLI,
Python API and packaging changes, plus brief sections for the
2.x to 3.0 and 1.x to 2.0 upgrades.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UnLnhsH25Nnv7LHhekUfPd
2026-07-04 19:21:34 +00:00
Claude
a2e6ea2eca
Raise ValueError instead of AssertionError for invalid arguments
User-facing argument validation in db.py previously used bare
assert statements, which vanish entirely under python -O and raise
AssertionError - an exception type callers should not have to
catch for input validation. Fifteen validation sites now raise
ValueError with the same messages. Internal invariants (an
unreachable branch and a post-update rowcount check) remain
asserts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UnLnhsH25Nnv7LHhekUfPd
2026-07-04 19:19:24 +00:00
Claude
f1cdceaca9
Remove the no-op -d/--detect-types flag from insert and upsert
Type detection has been the default for CSV/TSV data since 4.0a1,
making this flag a no-op kept only for backwards compatibility.
4.0 is the release where it can be removed - the flag now errors,
prompting scripts to drop it. --no-detect-types is unchanged. The
dead detect_types parameter has been removed from
insert_upsert_implementation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UnLnhsH25Nnv7LHhekUfPd
2026-07-04 19:15:31 +00:00
Claude
397cdcc491
Polish: clearer errors, corrected docstrings, setuptools pin
- insert/upsert into a name that is actually a view now shows a
  clean error instead of a traceback
- db.view() on a name that is a table now says so in its error
- Database.__getitem__ docstring documents that views are returned
  for view names; hash_id docstring corrected (it is a column name,
  not a boolean)
- Registering two migrations with the same name in one set now
  raises ValueError at registration time instead of executing both
  and failing with IntegrityError at apply time
- build-system requires setuptools>=77, needed for the PEP 639
  license expression

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UnLnhsH25Nnv7LHhekUfPd
2026-07-04 19:13:15 +00:00
Claude
30cc95c0a6
migrate --list is now read-only
pending() and applied() no longer create the _sqlite_migrations
table or perform the one-way legacy schema upgrade - that now only
happens in apply(). The CLI --list path no longer creates the
database file when it does not exist. applied() results are now
explicitly ordered by insertion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UnLnhsH25Nnv7LHhekUfPd
2026-07-04 19:10:56 +00:00
Claude
0bad21280f
migrate --stop-before: validate names and fix legacy compatibility
An unknown --stop-before value previously matched nothing and every
migration was silently applied, including the one the user meant to
stop before. The CLI now errors unless each value matches a known
migration.

The CLI also always passed stop_before as a list, but older
duck-typed sqlite-migrate Migrations objects compare it against a
single string name - so the flag was silently ignored for exactly
the migration files the compatibility shim exists to support. Legacy
sets now receive a single string, with an error if multiple values
target one legacy set.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UnLnhsH25Nnv7LHhekUfPd
2026-07-04 19:08:53 +00:00
Claude
fd867282b3
Document atomic() deferred BEGIN, savepoint behavior and connection modes
Documents that atomic() opens a deferred BEGIN, that calling it
while the connection is already inside a transaction produces a
savepoint whose changes only persist when the outer transaction
commits, and that Python 3.12+ autocommit=True/False connections
are not supported. Adds a test pinning the savepoint-inside-manual-
BEGIN behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UnLnhsH25Nnv7LHhekUfPd
2026-07-04 19:06:44 +00:00
Claude
a6fcb5af62
Pin the Database context manager contract
with Database(...) closes the connection on exit without
committing, so uncommitted changes are rolled back. This was the
existing behavior but was undocumented and untested - it is now
stated in the docs and pinned by a test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UnLnhsH25Nnv7LHhekUfPd
2026-07-04 19:05:59 +00:00
Claude
70b48a3b16
Use the sqlite3/sqlean shim in test_query.py
test_query_executes_eagerly failed on the sqlean CI matrix jobs
because sqlean.dbapi2.OperationalError is not a subclass of the
stdlib sqlite3.OperationalError. Import sqlite3 via
sqlite_utils.utils like the other test modules do.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UnLnhsH25Nnv7LHhekUfPd
2026-07-04 18:51:55 +00:00
Claude
c76aad50ae
Correct applied_at type annotation to str
_AppliedMigration.applied_at was annotated datetime.datetime but
the value is the TEXT timestamp read straight from the
_sqlite_migrations table - always a string, matching the format
written by both this module and the older sqlite-migrate package.
Added a test pinning the runtime type.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UnLnhsH25Nnv7LHhekUfPd
2026-07-04 18:47:08 +00:00
Claude
ffec11cfb7
enable_wal() and disable_wal() refuse to run inside a transaction
Changing the journal mode assigns conn.isolation_level, which
commits any open transaction as a side effect - silently breaking
the rollback guarantee of atomic() blocks and of user-managed
transactions. Both methods now raise RuntimeError if a transaction
is open. Calling them when the database is already in the requested
mode remains a no-op.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UnLnhsH25Nnv7LHhekUfPd
2026-07-04 18:46:09 +00:00
Claude
d6753a9f57
upsert() now requires a value for every primary key column
A record missing a primary key value - or with None for one - can
never match an existing row, because a NULL primary key never
satisfies ON CONFLICT. Such records were previously inserted as
brand new rows (silently, for multi-record upsert_all calls) or
triggered a KeyError after the insert had already happened. Both
cases, including upserting an empty record, now raise
PrimaryKeyRequired before any SQL is executed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UnLnhsH25Nnv7LHhekUfPd
2026-07-04 18:43:37 +00:00
Claude
286a87dba4
Add Unreleased changelog section covering changes since 4.0rc1
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UnLnhsH25Nnv7LHhekUfPd
2026-07-04 18:37:20 +00:00
Claude
e762656d06
optimize() and rebuild_fts() now commit their changes
Both ran their INSERT INTO fts(fts) statements via a bare execute()
with no commit, leaving the connection inside an open implicit
transaction - the FTS operation and all subsequent writes were then
silently rolled back when the connection closed. Both are now
wrapped in db.atomic(), matching delete_where() and the other
write operations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UnLnhsH25Nnv7LHhekUfPd
2026-07-04 18:35:45 +00:00
Claude
45af93d463
db.query() now executes its SQL immediately
Previously query() was a generator function, so nothing - including
the SQL itself - ran until the result was first iterated. A write
statement passed to query() silently did nothing, and SQL errors
surfaced far from the call site. The SQL now executes as soon as
query() is called, while rows are still fetched lazily during
iteration.

Statements that return no rows now raise a ValueError directing
callers to execute() instead. As a side effect, statements like
INSERT ... RETURNING now work naturally with query().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UnLnhsH25Nnv7LHhekUfPd
2026-07-04 18:24:52 +00:00
Claude
862944bc38
drop-table and drop-view now refuse to drop the wrong object type
drop-table on a view name silently dropped the view, and drop-view
on a table name silently dropped the table, because both used
db[name].drop() which dispatches on the actual object type. They
now use db.table() / db.view() and exit with an explanatory error
pointing at the correct command. --ignore still exits cleanly but
no longer drops anything.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UnLnhsH25Nnv7LHhekUfPd
2026-07-04 18:13:50 +00:00
Claude
d36639ed73
Run each migration inside a transaction
Migrations.apply() now wraps each migration function and its
_sqlite_migrations tracking row in db.atomic(), so they commit
together: a failing migration rolls back cleanly, is not recorded,
and stays pending, eliminating the double-apply hazard where a
partially-failed migration left committed side effects behind.

Migrations that cannot run inside a transaction (VACUUM, journal
mode changes, manual transaction management) can opt out by
registering with @migrations(transactional=False).

Also fixed the test fixtures which used db.query() for INSERT
statements - query() is a lazy generator, so those inserts never
actually executed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UnLnhsH25Nnv7LHhekUfPd
2026-07-04 18:06:06 +00:00
Claude
b17c37144e
Fix delete_where() leaving the connection in an open transaction
Table.delete_where() ran its DELETE via a bare execute() with no
commit, unlike Table.delete() which wraps in db.atomic(). The
connection was left with an open implicit transaction, so the
deletion (and all subsequent writes, including later atomic()
blocks which switched to savepoint mode) was silently rolled back
when the connection closed.

Wrap the DELETE in db.atomic() and remove the now-unnecessary
atomic() wrapper from the delete_where documentation example.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UnLnhsH25Nnv7LHhekUfPd
2026-07-04 17:57:35 +00:00
Claude
f3c012774a
Add review feedback for the create-table-parser branch
Assessment of the CREATE TABLE parser as the foundation for 4.1
transform() improvements: fit for 4.1, two tokenizer bugs to fix
first (comments, numeric literal defaults), test wiring, and
design decisions around round-trip fidelity and API visibility.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UnLnhsH25Nnv7LHhekUfPd
2026-07-04 17:39:12 +00:00
Claude
0c369a447e
Add 4.0rc1 pre-release review notes
Consolidated findings from a final review before the stable 4.0
release: five release blockers, semantic decisions to lock in now,
polish items, and verified-sound areas.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UnLnhsH25Nnv7LHhekUfPd
2026-07-04 17:33:23 +00:00
Simon Willison
79117b9d11 Pin click>=8.4.2 in dev dependencies to fix Cog
Closes #761
2026-06-24 13:31:52 -07:00
Simon Willison
e71b4ca3ee Ran cog 2026-06-24 13:11:29 -07:00
Simon Willison
bfd74a35bb upsert() detects existing compound primary keys
Closes #629
2026-06-24 10:43:53 -07:00
Simon Willison
b5d0080cd1 Handle db.table(table_name).insert({}), closes #759 2026-06-22 12:00:05 -07:00
Simon Willison
401fb6949c Fix broken tracer test 2026-06-21 16:29:32 -07:00
Simon Willison
c754a0ebaf Release 4.0rc1 2026-06-21 16:26:41 -07:00
Rami Abdelrazzaq
f448b61f5e
fix: Plugins are still loaded when running tests (#719)
* fix: defer plugin entrypoint loading until runtime

Fixes simonw/sqlite-utils#713

* Lazy-load plugins when listing them

Keep plugin entrypoints from loading at module import time, but preserve the existing behavior of get_plugins() by loading entrypoints the first time plugins are listed outside tests.

---------

Co-authored-by: Simon Willison <swillison@gmail.com>
2026-06-21 16:14:45 -07:00
Simon Willison
733a67490f Test for CSV headers with memory command, refs #702, #707 2026-06-21 16:05:10 -07:00
Rami Abdelrazzaq
2b0cc04c8d
Fix issue #702: Handle CSV with only header row in --detect-types (#707)
When inserting a CSV file that contains only a header row (no data rows),
the --detect-types option would crash with an AssertionError because it
tried to transform a table that didn't exist.

This fix adds a check to ensure the table exists before attempting to
apply type transformations. The fix is applied in two places:
1. insert_upsert_implementation() for the insert command
2. memory() command for CSV/TSV files

Added test case: test_insert_csv_headers_only

Co-authored-by: Test User <test@example.com>
2026-06-21 15:57:06 -07:00
Simon Willison
1a28416e10 Fix for detect_fts failing on [], refs #694 2026-06-21 15:54:19 -07:00
Simon Willison
6729ea3f60
db.atomic() method
Closes #755
2026-06-21 10:43:19 -07:00
Simon Willison
3cc27d69bc
New migrations system, ported from sqlite-migrate (#754)
Closes #752
2026-06-21 09:40:21 -07:00
Simon Willison
b702a51256 Add test for transform ... --sql command
Coverage spotted this was missinge.
2026-06-21 09:37:05 -07:00
Simon Willison
8f0c06e188
Test against Python 3.15-dev, bump ty and Black (#738)
* Add Python 3.15-dev to test matrix
* Run ty check only on 3.14
* Bump Black version
* Update tabulate and use that in 
* Bump to latest ty
2026-05-17 16:52:48 -07:00
Simon Willison
8d74ffc932
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
Simon Willison
871038505b
Type fixes now enforced by ty
* Fix type warning for pipe.stdout possibly being None

Add conditional check before calling .read() on pipe.stdout since
Popen can return None for stdout.

* Use ctx.meta instead of dynamic attribute for database cleanup

Click's Context.meta dictionary is the proper way to store arbitrary
data on the context object, avoiding type checker warnings about
dynamic attribute assignment.

* Add assert for tables.callback before calling

Click's callback attribute is typed as Optional[Callable], so add
assert to satisfy type checker that it's not None.

* Fix type errors in cli.py and db.py

- Add type annotation for Database.conn to fix context manager errors
- Convert exception objects to str() when raising ClickException
- Handle None return from find_spatialite() with proper error message

* Fix remaining type errors in cli.py

- Add typing import and type annotations for dict kwargs
- Use db.table() instead of db[] for extract command
- Fix missing str() conversion for exception

* Fix type errors in db.py

- Add type annotation for Database.conn
- Add type: ignore for optional sqlite_dump import
- Update execute/query parameter types to Sequence|Dict for sqlite3 compatibility
- Use getattr for fn.__name__ access to handle callables without __name__
- Handle None return from find_spatialite() with OSError
- Fix pk_values assignment to use local variable

* Add type: ignore for optional pysqlite3 and sqlean imports

These are alternative sqlite3 implementations that may not be installed.

* Fix type errors in tests and plugins

- Add type: ignore for monkey-patching Database.__init__ in conftest
- Fix CLI test to pass string "2" instead of integer to Click invoke
- Add type: ignore for optional sqlean import
- Fix add_geometry_column test to use "XY" instead of integer 2
- Add type: ignore for click.Context as context manager
- Add type: ignore for enable_fts test that intentionally omits argument
- Add type: ignore for sys._called_from_test dynamic attribute
- Fix rows_from_file test type error for intentional wrong argument
- Handle None from pm.get_hookcallers in plugins.py

* Use db.table() instead of db[] for Table-specific operations

Changes db[table] to db.table(table) in CLI commands where we know
we're working with tables, not views. This resolves most of the
Table | View disambiguation type warnings since db.table() returns
Table directly rather than Table | View.

* Fix remaining type warnings in sqlite_utils package

- Add assert for sniff_buffer not being None
- Handle cursor.fetchone() potentially returning None
- Use db.table() for counts_table and index_foreign_keys
- Add type: ignore for cursor union type in raw mode

* Ran Black

* Run ty in CI

* ty check sqlite_utils

* Skip running ty on Windows

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 19:34:45 -08:00
Simon Willison
fd5b09f64b
Database as a context manager, fixed many pytest warnings
* Database can now work as a context manager
* Claude Code helped fix a ton of .close() warnings

https://gistpreview.github.io/?730f0c5dc38528a1dd0615f330bd5481

* New autouse fixture to help with test warnings

Refs https://github.com/simonw/sqlite-utils/issues/692#issuecomment-3644371889

* Fix all remaining resource warnings

https://gistpreview.github.io/?0bb8e869b82f6ff0db647de755182502

Closes #692
2025-12-11 16:56:12 -08:00
Simon Willison
066d0f3e8d pip install . --group dev 2025-12-11 15:33:25 -08:00
Simon Willison
29d84bc95d
dependency-groups.dev and uv in docs (#691)
* dependency-groups.dev and switch to uv in docs
* Separate dependency group for docs
2025-12-11 15:32:00 -08:00
Simon Willison
5a7f522980
sqlite-utils convert can now use callable references, closes #686
This now works:

    echo '{"date": "13th January 2025"}' | sqlite-utils insert dates.db dates -
    sqlite-utils convert dates.db dates date r.parsedate
    sqlite-utils rows dates.db dates

Previously the command would have been:

    sqlite-utils convert dates.db dates date 'r.parsedate(value)'
2025-11-29 14:43:02 -08:00
Simon Willison
f9e5fbb3bd Backport release notes for 3.39, closes #688 2025-11-24 10:46:34 -08:00
Copilot
e66e82f2c8
Remove Sentinel workaround for Click 8.3.1+ (#685)
Closes #666

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2025-11-24 09:11:18 -08:00
Simon Willison
a0d8bc3932 Add pip as a dependency to ensure plugin installation, closes #687 2025-11-24 07:58:54 -08:00
Simon Willison
ae58faf274 Add 3.14 to the publish.yml workflow 2025-11-23 22:34:59 -08:00
71 changed files with 8725 additions and 1163 deletions

View file

@ -0,0 +1,39 @@
name: "Setup SQLite version"
description: "Build and activate a specific SQLite version from its amalgamation archive"
inputs:
version:
description: "The SQLite version to install"
required: true
cflags:
description: "CFLAGS to use when compiling SQLite"
required: false
default: ""
skip-activate:
description: "Set to true to skip modifying the library path"
required: false
default: "false"
fallback-urls:
description: "Whitespace-separated fallback download URLs to try after sqlite.org"
required: false
default: ""
outputs:
sqlite-location:
description: "Directory containing the compiled SQLite library"
value: ${{ steps.build.outputs.sqlite-location }}
runs:
using: "composite"
steps:
- shell: bash
run: mkdir -p "$RUNNER_TEMP/sqlite-versions/downloads"
- uses: actions/cache@v6
with:
path: ${{ runner.temp }}/sqlite-versions/downloads
key: setup-sqlite-version-${{ inputs.version }}-amalgamation-v1
- id: build
shell: bash
run: bash "$GITHUB_ACTION_PATH/setup-sqlite-version.sh"
env:
SQLITE_VERSION: ${{ inputs.version }}
SQLITE_CFLAGS: ${{ inputs.cflags }}
SQLITE_SKIP_ACTIVATE: ${{ inputs.skip-activate }}
SQLITE_EXTRA_FALLBACK_URLS: ${{ inputs.fallback-urls }}

View file

@ -0,0 +1,144 @@
#!/usr/bin/env bash
set -euo pipefail
version_spec="${SQLITE_VERSION:?SQLITE_VERSION is required}"
cflags="${SQLITE_CFLAGS:-}"
skip_activate="${SQLITE_SKIP_ACTIVATE:-false}"
extra_fallback_urls="${SQLITE_EXTRA_FALLBACK_URLS:-}"
case "$version_spec" in
3.46 | 3.46.0)
sqlite_version="3.46.0"
sqlite_year="2024"
amalgamation_id="3460000"
builtin_fallback_urls="https://static.simonwillison.net/static/2026/sqlite-amalgamation-3460000.zip"
;;
3.23.1)
sqlite_version="3.23.1"
sqlite_year="2018"
amalgamation_id="3230100"
builtin_fallback_urls="https://static.simonwillison.net/static/2026/sqlite-amalgamation-3230100.zip"
;;
*)
echo "::error::Unsupported SQLite version '$version_spec'. Add its release year and amalgamation id to $GITHUB_ACTION_PATH/setup-sqlite-version.sh."
exit 1
;;
esac
case "$(uname -s)" in
Linux)
library_name="libsqlite3.so.0"
library_path_var="LD_LIBRARY_PATH"
;;
Darwin)
library_name="libsqlite3.dylib"
library_path_var="DYLD_LIBRARY_PATH"
;;
*)
echo "::error::Unsupported platform $(uname -s)"
exit 1
;;
esac
runner_temp="${RUNNER_TEMP:-}"
if [ -z "$runner_temp" ]; then
runner_temp="$(mktemp -d)"
fi
filename="sqlite-amalgamation-${amalgamation_id}"
official_url="https://www.sqlite.org/${sqlite_year}/${filename}.zip"
download_dir="${runner_temp}/sqlite-versions/downloads"
source_root="${runner_temp}/sqlite-versions/source"
source_dir="${source_root}/${filename}"
build_dir="${runner_temp}/sqlite-versions/build/${sqlite_version}"
archive_path="${download_dir}/${filename}.zip"
mkdir -p "$download_dir" "$source_root" "$build_dir"
download_archive() {
local url
local candidate_path="${archive_path}.tmp"
local urls=("$official_url")
for url in $builtin_fallback_urls $extra_fallback_urls; do
urls+=("$url")
done
rm -f "$candidate_path"
for url in "${urls[@]}"; do
echo "Downloading SQLite ${sqlite_version} amalgamation from ${url}"
if curl \
--fail \
--location \
--show-error \
--retry 5 \
--retry-delay 2 \
--retry-max-time 180 \
--retry-all-errors \
--connect-timeout 20 \
--max-time 240 \
--output "$candidate_path" \
"$url"; then
mv "$candidate_path" "$archive_path"
return 0
fi
echo "::warning::Download failed from ${url}"
rm -f "$candidate_path"
done
echo "::error::Could not download SQLite ${sqlite_version} amalgamation"
return 1
}
if [ ! -f "${source_dir}/sqlite3.c" ]; then
if [ ! -f "$archive_path" ]; then
download_archive
fi
rm -rf "$source_dir"
unzip -q "$archive_path" -d "$source_root"
fi
if [ ! -f "${source_dir}/sqlite3.c" ]; then
echo "::error::Expected ${source_dir}/sqlite3.c after extracting ${archive_path}"
exit 1
fi
read -r -a cflag_args <<< "$cflags"
echo "Compiling SQLite ${sqlite_version} to ${build_dir}/${library_name}"
gcc \
-fPIC \
-shared \
"${cflag_args[@]}" \
"${source_dir}/sqlite3.c" \
"-I${source_dir}" \
-o "${build_dir}/${library_name}"
if [ "$library_name" = "libsqlite3.so.0" ]; then
ln -sf "$library_name" "${build_dir}/libsqlite3.so"
fi
if [ -n "${GITHUB_OUTPUT:-}" ]; then
echo "sqlite-location=${build_dir}" >> "$GITHUB_OUTPUT"
else
echo "sqlite-location=${build_dir}"
fi
case "$(printf '%s' "$skip_activate" | tr '[:upper:]' '[:lower:]')" in
true | 1 | yes)
echo "Skipping ${library_path_var} activation"
;;
*)
existing_value="${!library_path_var:-}"
if [ -n "${GITHUB_ENV:-}" ]; then
if [ -n "$existing_value" ]; then
echo "${library_path_var}=${build_dir}:${existing_value}" >> "$GITHUB_ENV"
else
echo "${library_path_var}=${build_dir}" >> "$GITHUB_ENV"
fi
fi
echo "Added ${build_dir} to ${library_path_var}"
;;
esac

View file

@ -9,19 +9,19 @@ jobs:
runs-on: ${{ matrix.os }}
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"]
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
os: [ubuntu-latest, windows-latest, macos-latest]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
cache: pip
cache-dependency-path: pyproject.toml
- name: Install dependencies
run: |
pip install -e '.[test]'
pip install . --group dev
- name: Run tests
run: |
pytest
@ -29,11 +29,11 @@ jobs:
runs-on: ubuntu-latest
needs: [test]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: '3.13'
python-version: '3.14'
cache: pip
cache-dependency-path: pyproject.toml
- name: Install dependencies

View file

@ -15,7 +15,7 @@ jobs:
cache-dependency-path: pyproject.toml
- name: Install dependencies
run: |
pip install -e '.[docs]'
pip install . --group docs
- name: Check spelling
run: |
codespell docs/*.rst --ignore-words docs/codespell-ignore-words.txt

View file

@ -12,9 +12,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out repo
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: "3.11"
cache: pip
@ -24,7 +24,7 @@ jobs:
- name: Install Python dependencies
run: |
python -m pip install --upgrade pip
python -m pip install -e .[test]
python -m pip install . --group dev
python -m pip install pytest-cov
- name: Run tests
run: |-

View file

@ -18,23 +18,23 @@ jobs:
"3.23.1", # 2018-04-10, before UPSERT
]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
allow-prereleases: true
cache: pip
cache-dependency-path: pyproject.toml
- name: Set up SQLite ${{ matrix.sqlite-version }}
uses: asg017/sqlite-versions@71ea0de37ae739c33e447af91ba71dda8fcf22e6
uses: ./.github/actions/setup-sqlite-version
with:
version: ${{ matrix.sqlite-version }}
cflags: "-DSQLITE_ENABLE_DESERIALIZE -DSQLITE_ENABLE_FTS5 -DSQLITE_ENABLE_FTS4 -DSQLITE_ENABLE_FTS3_PARENTHESIS -DSQLITE_ENABLE_RTREE -DSQLITE_ENABLE_JSON1"
- run: python3 -c "import sqlite3; print(sqlite3.sqlite_version)"
- name: Install dependencies
run: |
pip install -e '.[test]'
pip install . --group dev
pip freeze
- name: Run tests
run: |

View file

@ -10,13 +10,13 @@ jobs:
runs-on: ${{ matrix.os }}
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14", "3.15-dev"]
numpy: [0, 1]
os: [ubuntu-latest, macos-latest, windows-latest, macos-14]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
allow-prereleases: true
@ -24,18 +24,13 @@ jobs:
cache-dependency-path: pyproject.toml
- name: Install dependencies
run: |
pip install -e '.[test,mypy,flake8]'
- name: Optionally install tui dependencies
run: pip install -e '.[tui]'
pip install . --group dev
- name: Optionally install numpy
if: matrix.numpy == 1
run: pip install numpy
- name: Install SpatiaLite
if: matrix.os == 'ubuntu-latest'
run: sudo apt-get install libsqlite3-mod-spatialite
- name: On macOS with Python 3.10 test with sqlean.py
if: matrix.os == 'macos-latest' && matrix.python-version == '3.10'
run: pip install sqlean.py sqlite-dump
- name: Build extension for --load-extension test
if: matrix.os == 'ubuntu-latest'
run: |-
@ -43,10 +38,18 @@ jobs:
- name: Run tests
run: |
pytest -v
- name: Run autocommit tests just on 3.14/Ubuntu
if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.14'
run: pytest --sqlite-autocommit
- name: run mypy
run: mypy sqlite_utils tests
- name: run flake8
run: flake8
- name: run ty
if: matrix.os != 'windows-latest' && matrix.python-version == '3.14'
run: |
pip install uv
uv run ty check sqlite_utils
- name: Check formatting
run: black . --check
- name: Check if cog needs to be run

View file

@ -1,2 +0,0 @@
tasks:
- init: pip install '.[test]'

View file

@ -4,16 +4,13 @@ sphinx:
configuration: docs/conf.py
build:
os: ubuntu-22.04
os: ubuntu-24.04
tools:
python: "3.11"
python:
install:
- method: pip
path: .
extra_requirements:
- docs
python: "3.13"
jobs:
install:
- pip install --upgrade pip
- pip install . --group docs
formats:
- pdf

View file

@ -3,30 +3,31 @@
# Run pytest with supplied options
@test *options:
just run pytest {{options}}
uv run pytest {{options}}
@run *options:
uv run --isolated --with-editable '.[test,mypy,flake8,docs]' -- {{options}}
uv run -- {{options}}
# Run linters: black, flake8, mypy, cog
# Run linters: black, flake8, mypy, ty, cog
@lint:
just run black . --check
just run flake8
just run mypy sqlite_utils tests
just run cog --check README.md docs/*.rst
just run codespell docs/*.rst --ignore-words docs/codespell-ignore-words.txt
uv run flake8
uv run mypy sqlite_utils tests
uv run ty check sqlite_utils
uv run cog --check README.md docs/*.rst
uv run --group docs codespell docs/*.rst --ignore-words docs/codespell-ignore-words.txt
# Rebuild docs with cog
@cog:
just run cog -r README.md docs/*.rst
uv run --group docs cog -r README.md docs/*.rst
# Serve live docs on localhost:8000
@docs: cog
#!/usr/bin/env bash
cd docs
uv run --isolated --with-editable '../.[test,docs]' make livehtml
uv run --group docs make livehtml
# Apply Black
@black:
just run black .
uv run black .

View file

@ -18,9 +18,12 @@ Python CLI utility and library for manipulating SQLite databases.
- [Configure SQLite full-text search](https://sqlite-utils.datasette.io/en/stable/cli.html#configuring-full-text-search) against your database tables and run search queries against them, ordered by relevance
- Run [transformations against your tables](https://sqlite-utils.datasette.io/en/stable/cli.html#transforming-tables) to make schema changes that SQLite `ALTER TABLE` does not directly support, such as changing the type of a column
- [Extract columns](https://sqlite-utils.datasette.io/en/stable/cli.html#extracting-columns-into-a-separate-table) into separate tables to better normalize your existing data
- [Manage database migrations](https://sqlite-utils.datasette.io/en/stable/migrations.html) using Python migration files and the `sqlite-utils migrate` command
- [Install plugins](https://sqlite-utils.datasette.io/en/stable/plugins.html) to add custom SQL functions and additional features
Read more on my blog, in this series of posts on [New features in sqlite-utils](https://simonwillison.net/series/sqlite-utils-features/) and other [entries tagged sqliteutils](https://simonwillison.net/tags/sqliteutils/).
Upgrading from sqlite-utils 3.x? See the [4.0 upgrade guide](https://sqlite-utils.datasette.io/en/stable/upgrading.html#upgrading-from-3-x-to-4-0).
Read more on my blog, in this series of posts on [New features in sqlite-utils](https://simonwillison.net/series/sqlite-utils-features/) and other [entries tagged sqlite-utils](https://simonwillison.net/tags/sqlite-utils/).
## Installation

View file

@ -4,6 +4,186 @@
Changelog
===========
.. _v4_1_1:
4.1.1 (2026-07-12)
------------------
- ``table.transform()`` now raises a ``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 - ``CASCADE``, ``SET NULL`` or ``SET DEFAULT``. The pragma cannot be changed inside a transaction, so previously dropping the old table as part of the transform could fire those actions and silently delete or modify referencing rows. See :ref:`python_api_transform_foreign_keys_transactions` for details and workarounds. (:issue:`794`)
- The :ref:`CLI <cli>` and :ref:`Python API <python_api>` documentation now cross-reference each other: CLI sections link to the equivalent Python API functionality and Python API sections link back to the corresponding CLI command. (:issue:`791`)
.. _v4_1:
4.1 (2026-07-11)
----------------
- ``sqlite-utils insert`` and ``sqlite-utils upsert`` now accept a ``--code`` option for :ref:`providing a block of Python code <cli_insert_code>` (or a path to a ``.py`` file) that defines a ``rows()`` function or ``rows`` iterable of rows to insert, as an alternative to importing from a file. (:issue:`684`)
- ``sqlite-utils insert`` and ``sqlite-utils upsert`` now accept ``--type column-name type`` to :ref:`override the type automatically chosen when the table is created <cli_insert_csv_tsv_column_types>`. This is useful for CSV or TSV columns such as ZIP codes that look like integers but should be stored as ``TEXT`` to preserve leading zeros. (:issue:`131`)
- New ``table.drop_index(name)`` method and ``sqlite-utils drop-index`` command for dropping an index by name. Both accept ``ignore=True``/``--ignore`` to ignore a missing index. (:issue:`626`)
- ``sqlite-utils query`` can now read the SQL query from standard input by passing ``-`` in place of the query, for example ``echo "select * from dogs" | sqlite-utils query dogs.db -``. (:issue:`765`)
- ``sqlite-utils upsert`` can now infer the primary key of an existing table, so ``--pk`` can be omitted when upserting into a table that already has a primary key.
- ``table.transform()`` and ``table.transform_sql()`` now accept ``strict=True`` or ``strict=False`` to change a table's `SQLite strict mode <https://www.sqlite.org/stricttables.html>`__. Omitting the option preserves the existing mode. (:issue:`787`)
- The ``sqlite-utils transform`` command now accepts ``--strict`` and ``--no-strict`` to change a table's strict mode. (:issue:`787`)
.. _v4_0:
4.0 (2026-07-07)
----------------
The 4.0 release includes some minor backwards-incompatible fixes (hence the major version number bump) and introduces three major new features:
- :ref:`Database migrations <migrations>`, providing a structured mechanism for evolving a project's schema over time. (:issue:`752`)
- :ref:`Nested transaction support <python_api_atomic>` via ``db.atomic()``, plus numerous improvements to how transactions work across the library. (:issue:`755`)
- Support for :ref:`compound foreign keys <python_api_compound_foreign_keys>`, including creation, transformation and introspection through :ref:`table.foreign_keys <python_api_introspection_foreign_keys>`. (:issue:`594`)
Other notable changes include:
- Upserts now use SQLite's ``INSERT ... ON CONFLICT ... DO UPDATE SET`` syntax, detect existing table primary keys automatically and reject records that are missing required primary key values. (:issue:`652`)
- ``db.query()`` now executes immediately and rejects statements that do not return rows; use ``db.execute()`` for writes and DDL.
- CSV and TSV imports now detect column types by default, while inserts into existing tables preserve those tables' column types. (:issue:`679`)
- Foreign key handling now preserves ``ON DELETE``/``ON UPDATE`` actions during transforms and resolves referenced primary keys more accurately. (:issue:`530`)
- Column names passed to Python API methods are now matched case-insensitively, mirroring SQLite's own identifier behavior. (:issue:`760`)
- The command-line tool now emits UTF-8 JSON output by default, with ``--ascii`` available to restore escaped output. (:issue:`625`)
- ``table.extract()`` and ``extracts=`` no longer create lookup table records for all-``null`` values. (:issue:`186`)
See :ref:`upgrading_3_to_4` for details on backwards-incompatible changes.
The detailed release notes for the features and fixes shipped during the 4.0 pre-release cycle are available in :ref:`4.0a0 <v4_0a0>`, :ref:`4.0a1 <v4_0a1>`, :ref:`4.0rc1 <v4_0rc1>`, :ref:`4.0rc2 <v4_0rc2>`, :ref:`4.0rc3 <v4_0rc3>` and :ref:`4.0rc4 <v4_0rc4>`.
Bug fixes since 4.0rc4
~~~~~~~~~~~~~~~~~~~~~~
- Fixed 4.0 regressions in ``insert``/``upsert`` against tables that use SQLite's implicit ``rowid`` primary key. Passing ``pk="rowid"``, ``pk="_rowid_"`` or ``pk="oid"`` now works again for rowid tables, and ``last_pk`` is set correctly. (:issue:`781`)
- Fixed ``insert(..., ignore=True)`` and ``insert_all(..., ignore=True)`` so an ignored insert that conflicts with an existing primary key row now reports that existing row in ``last_rowid`` and ``last_pk`` where possible. This also works for compound primary keys and list-mode inserts. (:issue:`783`)
.. _v4_0rc4:
4.0rc4 (2026-07-06)
-------------------
- **Breaking change**: ``table.extract()`` - and the ``sqlite-utils extract`` command - no longer extract rows where every extracted column is ``null``. Those rows now keep a ``null`` value in the new foreign key column instead of pointing at an all-``null`` record in the lookup table. When extracting multiple columns, rows are still extracted if at least one of the columns has a value. (:issue:`186`)
- The ``extracts=`` option to ``table.insert()`` and friends no longer creates a lookup table record for ``None`` values - the column value stays ``null``. Previously every batch of inserted rows containing a ``None`` value would add a duplicate ``null`` record to the lookup table.
- Fixed a bug where ``table.lookup()`` inserted a duplicate row on every call if any of the lookup values were ``None``. Lookup values are now compared using ``IS`` so that ``None`` values match existing rows correctly.
- JSON output from the command-line tool no longer escapes non-ASCII characters, so ``sqlite-utils data.db "select '日本語' as text"`` now outputs ``[{"text": "日本語"}]``. This matches how values were already stored by ``insert`` and how CSV/TSV output already behaved. A new ``--ascii`` option restores the previous behavior of escaping non-ASCII characters, for output destinations that cannot handle UTF-8 - see :ref:`cli_query_json_ascii`. The option is available on the ``query``, ``rows``, ``search``, ``tables``, ``views``, ``triggers``, ``indexes`` and ``memory`` commands. The ``convert --multi --dry-run`` preview and ``plugins`` output also no longer escape non-ASCII characters. (:issue:`625`)
- ``--no-headers`` now omits the header row from ``--fmt`` and ``--table`` output, not just CSV and TSV output. (:issue:`566`)
- ``table.insert_all(..., pk=...)`` now raises ``InvalidColumns`` if ``pk=`` names columns that do not exist in an existing table. Previously this behaved inconsistently, with single-row inserts raising a ``KeyError`` while other row counts succeeded. (:issue:`732`)
- Fixed an ``IndexError`` from ``table.insert(..., pk=..., ignore=True)`` when an ignored insert followed writes to another table on the same connection. ``last_pk`` is now populated from the explicit primary key value instead of looking up a stale ``lastrowid``. (:issue:`554`)
- Fixed a bug where a failed write statement executed with ``db.execute()`` left the driver's implicit transaction open. Every subsequent write then joined that phantom transaction, which nothing committed, so their work was silently rolled back when the connection was closed. The implicit transaction opened by a failed statement is now rolled back before the exception is raised. A failed write inside a transaction opened with ``db.begin()`` or ``db.atomic()`` leaves that transaction open and untouched, as before.
- Fixed a bug where transaction-control statements prefixed with an empty statement - ``db.query("; COMMIT")`` - or a UTF-8 byte order mark slipped past the check that rejects them, committing the caller's open transaction before raising a confusing ``OperationalError``. The keyword scanner used by ``db.query()`` and ``db.execute()`` now skips leading ``;`` and byte order marks, matching what the ``sqlite3`` driver tolerates before the first token, so these statements are rejected with a ``ValueError`` without being executed. The same fix means ``db.execute("; BEGIN")`` no longer auto-commits the transaction it just opened.
- Documented a limitation of ``db.query()``: a ``PRAGMA`` statement that returns no rows raises a ``ValueError`` but still takes effect, because PRAGMA statements run outside the savepoint guard used to roll back other rejected statements. Use ``db.execute()`` for row-less PRAGMA statements.
- Fixed exception masking when a statement destroys the enclosing transaction. An error such as a ``RAISE(ROLLBACK)`` trigger or ``INSERT OR ROLLBACK`` conflict rolls back the whole transaction, destroying every savepoint - the cleanup in ``db.atomic()`` and ``db.query()`` then failed with ``OperationalError: no such savepoint`` (or ``cannot rollback - no transaction is active``), hiding the original ``IntegrityError`` from code that tried to catch it. Cleanup now checks whether a transaction is still open first, so the original exception propagates.
- ``sqlite-utils migrate --list`` is now read-only even when the migrations file uses the legacy ``sqlite_migrate.Migrations`` class, whose listing methods create the ``_sqlite_migrations`` table as a side effect. The listing now runs inside a transaction that is rolled back.
- ``sqlite-utils insert ... --pk <missing column>`` and ``sqlite-utils extract <missing column>`` now show a clean ``Error:`` message instead of a raw Python traceback. The ``extract`` command also shows a clean error when pointed at a view.
- Fixed a bug where running ``table.extract()`` more than once against the same lookup table inserted duplicate rows for values containing ``null`` - SQLite unique indexes treat ``NULL`` values as distinct, so ``INSERT OR IGNORE`` alone could not dedupe them. Each repeat extract added another copy that nothing referenced. The insert now uses an ``IS``-based ``NOT EXISTS`` guard so ``null``-containing rows match existing lookup rows.
- ``db.add_foreign_keys()`` no longer silently ignores requested ``ON DELETE``/``ON UPDATE`` actions when a foreign key with the same columns already exists - it raises ``AlterError`` suggesting ``table.transform()``, since the actions of an existing foreign key cannot be changed in place. Exact duplicates, including actions, are still skipped so repeated calls stay idempotent. The method also now validates that compound foreign keys have the same number of columns on both sides, instead of silently discarding the extra columns.
- ``db.ensure_autocommit_on()`` now raises ``TransactionError`` if called while a transaction is open. Assigning ``isolation_level`` commits any pending transaction as a side effect, so entering the block silently committed the caller's open transaction and made a later ``rollback()`` a no-op.
- ``sqlite-utils migrate --stop-before`` now exits with an error if the named migration has already been applied. Previously the name passed validation but was only checked against pending migrations, so every migration after it was silently applied - the exact outcome ``--stop-before`` exists to prevent. ``Migrations.apply(db, stop_before=...)`` raises ``ValueError`` in the same situation, before applying anything.
- Fixed a regression where ``table.insert(..., pk=..., alter=True)`` raised ``InvalidColumns`` if the primary key column did not exist in the table yet. With ``alter=True`` the check now waits until the record keys are known, so a pk column supplied by the records is added by the alter as it was in 3.x. A pk column found in neither the table nor the records still raises ``InvalidColumns``.
- Fixed a bug where inserting CSV or TSV data into an existing table rewrote that table's column types to match the incoming file. Type detection is the default in 4.0, so ``sqlite-utils insert data.db places places.csv --csv`` against a table with a ``TEXT`` zip code column would convert the column to ``INTEGER`` and corrupt values with leading zeros - ``"01234"`` became ``1234``. Detected types are now only applied when the ``insert`` or ``upsert`` command creates the table.
- Fixed ``pks_and_rows_where()`` raising ``AttributeError`` when called on a view, and no longer double-quotes the synthesized ``rowid`` column in its generated SQL - SQLite turns a double-quoted identifier that does not resolve into a string literal, which on a view produced a confusing ``KeyError`` instead of the ``OperationalError`` raised in 3.x. Compound primary keys returned by this method now follow ``PRIMARY KEY`` declaration order.
- The ``foreign_keys=`` argument to ``create()`` and ``insert()`` accepts a mixed list of ``ForeignKey`` objects, tuples and column name strings again. In 4.0 pre-releases mixing ``ForeignKey`` objects with tuples raised a ``ValueError`` - a regression from 3.x, where ``ForeignKey`` was a ``namedtuple`` and passed the tuple checks.
- ``ForeignKey`` objects are hashable again. The 4.0 change from ``namedtuple`` to dataclass accidentally made them unhashable, breaking patterns like ``set(table.foreign_keys)`` that worked in 3.x. ``ForeignKey`` is now a frozen dataclass - immutable and hashable, like the namedtuple was.
- Fixed a bug where compound primary key columns were returned in table column order instead of ``PRIMARY KEY`` declaration order. For a table declared as ``CREATE TABLE other (b TEXT, a TEXT, PRIMARY KEY (a, b))`` an implicit ``FOREIGN KEY (x, y) REFERENCES other`` was introspected as referencing ``(b, a)`` when SQLite resolves it as ``(a, b)`` - running ``transform()`` on such a table then rewrote the schema with the inverted column order, silently reversing the meaning of the constraint and causing foreign key errors on valid data. ``table.pks``, compound foreign key guessing and ``transform()`` now all use the primary key declaration order, and ``transform()`` no longer reorders a compound ``PRIMARY KEY (b, a)`` into table column order.
.. _v4_0rc3:
4.0rc3 (2026-07-05)
-------------------
Breaking changes
~~~~~~~~~~~~~~~~
- :ref:`table.foreign_keys <python_api_introspection_foreign_keys>` now returns ``ForeignKey`` objects that are dataclasses rather than ``namedtuple`` instances, so they can no longer be unpacked or indexed as ``(table, column, other_table, other_column)`` tuples - access their fields by name instead. Compound (multi-column) foreign keys are now represented as a single ``ForeignKey`` with ``is_compound=True`` and populated ``columns``/``other_columns`` tuples, where ``column`` and ``other_column`` are ``None``. Previously they were returned as one ``ForeignKey`` per column, misleadingly suggesting several independent foreign keys. See :ref:`upgrading_3_to_4` for details. (:issue:`594`)
- Removed support for using ``sqlean.py`` as a drop-in replacement for the Python standard library ``sqlite3`` module. ``sqlite-utils`` will now use ``pysqlite3`` if it is installed, otherwise it will use ``sqlite3`` from the standard library.
- The ``db.ensure_autocommit_off()`` context manager has been renamed to ``db.ensure_autocommit_on()``, because the old name described the opposite of what it did. The method temporarily puts the connection into driver-level autocommit mode - by setting ``isolation_level = None`` - so that statements such as ``PRAGMA journal_mode=wal`` can run outside of an implicit transaction. (:issue:`705`)
Compound foreign key support
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
- Tables can now be created with :ref:`compound foreign keys <python_api_compound_foreign_keys>`, by passing tuples of column names in ``foreign_keys=``: ``foreign_keys=[(("campus_name", "dept_code"), "departments")]``. The referenced columns default to the compound primary key of the other table. Compound keys are rendered as table-level ``FOREIGN KEY`` constraints in the generated schema.
- ``table.transform()`` now preserves compound foreign keys, applying any column renames to them. Dropping a column that is part of a compound foreign key drops the whole constraint, matching the existing single-column behavior. ``drop_foreign_keys=`` accepts a bare column name - dropping any foreign key that column participates in - or a tuple of columns to target a compound key precisely.
- ``table.add_foreign_key()`` and ``db.add_foreign_keys()`` accept tuples of column names to add a compound foreign key to an existing table.
- ``db.index_foreign_keys()`` creates a single composite index for a compound foreign key.
Other foreign key improvements
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
- ``ForeignKey`` now exposes ``on_delete`` and ``on_update`` fields reflecting the foreign key's ``ON DELETE``/``ON UPDATE`` actions, and ``table.transform()`` preserves those actions. Previously a transform silently stripped clauses such as ``ON DELETE CASCADE`` from the table schema.
- ``table.add_foreign_key()`` accepts new ``on_delete=`` and ``on_update=`` parameters for creating foreign keys with actions, e.g. ``table.add_foreign_key("author_id", "authors", "id", on_delete="CASCADE")``. (:issue:`530`)
- Foreign keys declared as ``REFERENCES other_table`` with no explicit column are now resolved to the other table's primary key by ``table.foreign_keys``, instead of reporting ``other_column=None``.
- Fixed a ``TypeError`` when sorting ``ForeignKey`` objects where some were compound.
Case-insensitive column matching
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Column names passed to Python API methods are now matched against the table schema case-insensitively, mirroring how SQLite itself treats identifiers. Previously many methods accepted mixed-case identifiers in the SQL they generated but then failed - or silently did nothing - when performing Python-side comparisons against the schema. (:issue:`760`) Fixes include:
- ``table.insert()`` and ``table.upsert()`` now populate ``table.last_pk`` correctly when the ``pk=`` argument uses different casing to the table schema or the record keys - previously this raised a ``KeyError`` after the row had already been written.
- Upserts no longer raise or misbehave when the casing of ``pk=`` differs from the casing of the record keys. The primary key columns are correctly excluded from the generated ``DO UPDATE SET`` clause.
- ``table.transform()`` arguments ``types=``, ``rename=``, ``drop=``, ``pk=``, ``not_null=``, ``defaults=``, ``column_order=`` and ``drop_foreign_keys=`` all resolve column names case-insensitively. Previously options like ``rename={"name": "title"}`` against a column called ``Name`` were silently ignored.
- ``db.create_table(..., transform=True)`` now recognizes existing columns that differ only by case, instead of attempting to add them again and failing with ``duplicate column name``. The casing used in the existing schema is preserved.
- ``table.lookup()`` returns the primary key value even if ``pk=`` casing differs from the schema, and recognizes existing unique indexes case-insensitively instead of creating redundant ones.
- ``table.extract()`` and ``table.convert()`` - including ``multi=True`` and ``output=`` - accept column names in any casing.
- Foreign key columns are validated and recorded using the casing of the actual schema columns, in ``foreign_keys=`` when creating tables, ``db.add_foreign_keys()``, ``table.add_foreign_key()`` and ``table.add_column(fk_col=...)``. Duplicate foreign key detection is also case-insensitive.
- ``table.create()`` with ``pk=``, ``not_null=``, ``defaults=`` or ``column_order=`` referencing columns using different casing no longer creates an unwanted extra primary key column or raises a ``ValueError``.
Everything else
~~~~~~~~~~~~~~~
- Fixed a bug where ``table.transform()`` could convert ``DEFAULT TRUE``, ``DEFAULT FALSE`` and ``DEFAULT NULL`` column defaults into quoted string defaults when rebuilding a table. Thanks, `Vincent Gao <https://github.com/gaoflow>`__. (`#764 <https://github.com/simonw/sqlite-utils/pull/764>`__)
.. _v4_0rc2:
4.0rc2 (2026-07-04)
-------------------
Breaking changes:
- Write statements executed with ``db.execute()`` are now committed automatically, unless a transaction is already open in which case they join it. Previously they opened an implicit transaction that stayed open until something committed it - writes appeared to work when read on the same connection but were silently rolled back when the connection closed. Code that relied on rolling back uncommitted ``db.execute()`` writes should use the new ``db.begin()`` method to open an explicit transaction first. The transaction model is documented in full at :ref:`python_api_transactions`.
- ``db.query()`` now executes its SQL as soon as it is called, rather than waiting until the returned generator is first iterated. Rows are still fetched lazily during iteration. SQL errors are now raised at the call site, statements such as ``INSERT ... RETURNING`` are executed and committed immediately without needing to iterate over their results, and passing a statement that returns no rows - previously a silent no-op - now raises a ``ValueError`` recommending ``db.execute()`` instead. A statement rejected this way is rolled back before the error is raised, so it has no effect on the database.
- Python API validation errors now raise ``ValueError`` instead of ``AssertionError``. Previously invalid arguments - such as ``create_table()`` with no columns, ``transform()`` on a table that does not exist, or passing both ``ignore=True`` and ``replace=True`` - were rejected using bare ``assert`` statements, which are silently skipped when Python runs with the ``-O`` flag. Code that caught ``AssertionError`` for these cases should catch ``ValueError`` instead.
- ``table.upsert()`` and ``table.upsert_all()`` now raise ``PrimaryKeyRequired`` if a record is missing a value for any primary key column, or has a value of ``None`` for one. Previously such records - which can never match an existing row - were quietly inserted as brand new rows, or triggered a confusing ``KeyError`` after the insert had already taken place.
- ``db.enable_wal()`` and ``db.disable_wal()`` now raise a ``sqlite_utils.db.TransactionError`` if called while a transaction is open. Previously they would silently commit the open transaction as a side effect of changing the journal mode, breaking the rollback guarantee of ``db.atomic()`` and of user-managed transactions.
- The ``View`` class no longer has an ``enable_fts()`` method. It existed only to raise ``NotImplementedError``, since full-text search is not supported for views - calling it now raises ``AttributeError`` instead, and the method no longer appears in the API reference. The ``sqlite-utils enable-fts`` command shows a clean error when pointed at a view.
- The no-op ``-d/--detect-types`` flag has been removed from the ``insert`` and ``upsert`` commands. Type detection has been the default for CSV/TSV data since 4.0a1, so the flag did nothing - invocations using it should simply drop it. ``--no-detect-types`` remains available to disable detection.
- ``Database()`` now raises a ``sqlite_utils.db.TransactionError`` if passed a connection created with the Python 3.12+ ``sqlite3.connect(..., autocommit=True)`` or ``autocommit=False`` options. ``commit()`` and ``rollback()`` behave differently on those connections, which previously caused every write made by the library to be silently discarded when the connection closed.
Everything else:
- Fixed a bug where ``table.delete_where()``, ``table.optimize()`` and ``table.rebuild_fts()`` did not commit their changes, leaving the connection inside an open transaction. Their work - and any subsequent writes - could then be silently rolled back when the connection was closed. All three now use ``db.atomic()``, consistent with the other write methods.
- The ``sqlite-utils drop-table`` command now refuses to drop a view, and ``drop-view`` refuses to drop a table. Previously each would silently drop the wrong type of object if the name matched. Both now exit with an error suggesting the correct command to use.
- Migrations applied by the new :ref:`migrations system <migrations>` now run inside a transaction, together with the record of the migration having been applied. If a migration raises an exception its changes are rolled back and it stays pending, so it can be safely re-applied after the error is fixed. Migrations that cannot run inside a transaction, such as those executing ``VACUUM``, can opt out using ``@migrations(transactional=False)`` - see :ref:`migrations_transactions`.
- ``table.upsert()`` and ``table.upsert_all()`` now detect the primary key or compound primary key of an existing table, so the ``pk=`` argument is no longer required when upserting into a table that already has a primary key.
- ``db.table(table_name).insert({})`` can now be used to insert a row consisting entirely of default values into an existing table, using ``INSERT INTO ... DEFAULT VALUES``. (:issue:`759`)
- Improvements to the ``sqlite-utils migrate`` command: ``--stop-before`` values that do not match any known migration are now an error instead of being silently ignored, ``--stop-before`` now works correctly with migration files that still use the older ``sqlite_migrate.Migrations`` class, and ``--list`` is now a read-only operation that no longer creates the database file or the migrations tracking table. ``migrations.applied()`` now returns migrations in the order they were applied.
- New ``db.begin()``, ``db.commit()`` and ``db.rollback()`` methods for taking manual control of transactions, as an alternative to the ``db.atomic()`` context manager.
- New documentation: :ref:`python_api_transactions` describes how transactions work and when changes are committed, and a new :ref:`upgrading` page details the changes needed to move between major versions.
.. _v4_0rc1:
4.0rc1 (2026-06-21)
-------------------
- New :ref:`database migrations system <migrations>`, incorporating functionality that was previously provided by the separate `sqlite-migrate <https://github.com/simonw/sqlite-migrate>`__ plugin. Define migration sets using the new :class:`sqlite_utils.Migrations` class and apply them using the ``sqlite-utils migrate`` command or the :ref:`migrations Python API <migrations_python>`. (:issue:`752`)
- New ``db.atomic()`` :ref:`context manager providing nested transaction support <python_api_atomic>` using SQLite transactions and savepoints. Internal multi-step operations such as ``table.transform()`` now use this mechanism to avoid unexpectedly committing an existing transaction. (:issue:`755`)
- ``Database`` objects can now be :ref:`used as context managers <python_api_close>`, automatically closing the connection when the ``with`` block exits. The CLI also now closes database and file handles more reliably, resolving a number of ``ResourceWarning`` warnings. (:issue:`692`)
- The ``sqlite-utils convert`` command can now accept a direct callable reference such as ``r.parsedate`` or ``json.loads --import json`` as the conversion code, as an alternative to calling it explicitly with ``r.parsedate(value)``. (:issue:`686`)
- Fixed a bug where CSV or TSV files with only a header row could crash ``sqlite-utils insert`` and ``sqlite-utils memory`` when type detection was enabled. Thanks, `Rami Abdelrazzaq <https://github.com/RamiNoodle733>`__. (:issue:`702`, `#707 <https://github.com/simonw/sqlite-utils/pull/707>`__)
- Fixed a bug where installed plugins could be loaded while running the test suite, despite the test-mode safeguard that disables plugin loading. Thanks, `Rami Abdelrazzaq <https://github.com/RamiNoodle733>`__. (:issue:`713`, `#719 <https://github.com/simonw/sqlite-utils/pull/719>`__)
- ``table.detect_fts()`` now recognizes legacy FTS virtual tables that quote the ``content=`` table name using square brackets, allowing ``table.enable_fts(..., replace=True)`` to replace them correctly. (:issue:`694`)
- Now depends on Click 8.3.1 or later, removing compatibility workarounds for Click's ``Sentinel`` default values. (:issue:`666`)
- Improved type annotations throughout the package, with ``ty`` now run in CI. (:issue:`697`)
- Development tooling now uses ``uv`` dependency groups, with separate ``dev`` and ``docs`` groups. (:issue:`691`)
- The test suite now runs against Python 3.15-dev. (:issue:`738`)
.. _v3_39:
3.39 (2025-11-24)
-----------------
- Fixed a bug with ``sqlite-utils install`` when the tool had been installed using ``uv``. (:issue:`687`)
- The ``--functions`` argument now optionally accepts a path to a Python file as an alternative to a string full of code, and can be specified multiple times - see :ref:`cli_query_functions`. (:issue:`659`)
- ``sqlite-utils`` now requires Python 3.10 or higher.
.. _v4_0a1:
4.0a1 (2025-11-23)
@ -173,7 +353,7 @@ This release introduces a new :ref:`plugin system <plugins>`. Read more about th
- Conversion functions passed to :ref:`table.convert(...) <python_api_convert>` can now return lists or dictionaries, which will be inserted into the database as JSON strings. (:issue:`495`)
- ``sqlite-utils install`` and ``sqlite-utils uninstall`` commands for installing packages into the same virtual environment as ``sqlite-utils``, :ref:`described here <cli_install>`. (:issue:`483`)
- New :ref:`sqlite_utils.utils.flatten() <reference_utils_flatten>` utility function. (:issue:`500`)
- Documentation on :ref:`using Just <contributing_just>` to run tests, linters and build documentation.
- Documentation on :ref:`using Just <contributing_just>` to run tests, linters and build documentation.
- Documentation now covers the :ref:`release_process` for this package.
.. _v3_29:

View file

@ -19,8 +19,8 @@ This page lists the ``--help`` for every ``sqlite-utils`` CLI sub-command.
go_first = [
"query", "memory", "insert", "upsert", "bulk", "search", "transform", "extract",
"schema", "insert-files", "analyze-tables", "convert", "tables", "views", "rows",
"triggers", "indexes", "create-database", "create-table", "create-index",
"enable-fts", "populate-fts", "rebuild-fts", "disable-fts"
"triggers", "indexes", "create-database", "create-table", "create-index", "drop-index",
"migrate", "enable-fts", "populate-fts", "rebuild-fts", "disable-fts"
]
refs = {
"query": "cli_query",
@ -46,9 +46,11 @@ This page lists the ``--help`` for every ``sqlite-utils`` CLI sub-command.
"add-foreign-keys": "cli_add_foreign_keys",
"index-foreign-keys": "cli_index_foreign_keys",
"create-index": "cli_create_index",
"drop-index": "cli_drop_index",
"enable-wal": "cli_wal",
"enable-counts": "cli_enable_counts",
"bulk": "cli_bulk",
"migrate": "cli_migrate",
"create-database": "cli_create_database",
"create-table": "cli_create_table",
"drop-table": "cli_drop_table",
@ -108,6 +110,10 @@ See :ref:`cli_query`.
"select * from chickens where age > :age" \
-p age 1
Pass "-" as the SQL to read the query from standard input:
echo "select * from chickens" | sqlite-utils data.db -
Options:
--attach <TEXT FILE>... Additional databases to attach - specify alias and
filepath
@ -115,24 +121,26 @@ See :ref:`cli_query`.
--arrays Output rows as arrays instead of objects
--csv Output CSV
--tsv Output TSV
--no-headers Omit CSV headers
--no-headers Omit headers from CSV/TSV and table/--fmt output
-t, --table Output as a formatted table
--fmt TEXT Table format - one of asciidoc, double_grid,
double_outline, fancy_grid, fancy_outline, github,
grid, heavy_grid, heavy_outline, html, jira,
latex, latex_booktabs, latex_longtable, latex_raw,
mediawiki, mixed_grid, mixed_outline, moinmoin,
orgtbl, outline, pipe, plain, presto, pretty,
psql, rounded_grid, rounded_outline, rst, simple,
simple_grid, simple_outline, textile, tsv,
unsafehtml, youtrack
--fmt TEXT Table format - one of asciidoc, colon_grid,
double_grid, double_outline, fancy_grid,
fancy_outline, github, grid, heavy_grid,
heavy_outline, html, jira, latex, latex_booktabs,
latex_longtable, latex_raw, mediawiki, mixed_grid,
mixed_outline, moinmoin, orgtbl, outline, pipe,
plain, presto, pretty, psql, rounded_grid,
rounded_outline, rst, simple, simple_grid,
simple_outline, textile, tsv, unsafehtml, youtrack
--json-cols Detect JSON cols and output them as JSON, not
escaped strings
--ascii Escape non-ASCII characters in JSON output as
\uXXXX
-r, --raw Raw output, first column of first row
--raw-lines Raw output, first column of each row
-p, --param <TEXT TEXT>... Named :parameters for SQL query
--functions TEXT Python code or file path defining custom SQL
functions
--functions TEXT Python code or a file path defining custom SQL
functions; can be used multiple times
--load-extension TEXT Path to SQLite extension, with optional
:entrypoint
-h, --help Show this message and exit.
@ -174,8 +182,8 @@ See :ref:`cli_memory`.
sqlite-utils memory animals.csv --schema
Options:
--functions TEXT Python code or file path defining custom SQL
functions
--functions TEXT Python code or a file path defining custom SQL
functions; can be used multiple times
--attach <TEXT FILE>... Additional databases to attach - specify alias and
filepath
--flatten Flatten nested JSON objects, so {"foo": {"bar":
@ -184,19 +192,21 @@ See :ref:`cli_memory`.
--arrays Output rows as arrays instead of objects
--csv Output CSV
--tsv Output TSV
--no-headers Omit CSV headers
--no-headers Omit headers from CSV/TSV and table/--fmt output
-t, --table Output as a formatted table
--fmt TEXT Table format - one of asciidoc, double_grid,
double_outline, fancy_grid, fancy_outline, github,
grid, heavy_grid, heavy_outline, html, jira,
latex, latex_booktabs, latex_longtable, latex_raw,
mediawiki, mixed_grid, mixed_outline, moinmoin,
orgtbl, outline, pipe, plain, presto, pretty,
psql, rounded_grid, rounded_outline, rst, simple,
simple_grid, simple_outline, textile, tsv,
unsafehtml, youtrack
--fmt TEXT Table format - one of asciidoc, colon_grid,
double_grid, double_outline, fancy_grid,
fancy_outline, github, grid, heavy_grid,
heavy_outline, html, jira, latex, latex_booktabs,
latex_longtable, latex_raw, mediawiki, mixed_grid,
mixed_outline, moinmoin, orgtbl, outline, pipe,
plain, presto, pretty, psql, rounded_grid,
rounded_outline, rst, simple, simple_grid,
simple_outline, textile, tsv, unsafehtml, youtrack
--json-cols Detect JSON cols and output them as JSON, not
escaped strings
--ascii Escape non-ASCII characters in JSON output as
\uXXXX
-r, --raw Raw output, first column of first row
--raw-lines Raw output, first column of each row
-p, --param <TEXT TEXT>... Named :parameters for SQL query
@ -221,7 +231,7 @@ See :ref:`cli_inserting_data`, :ref:`cli_insert_csv_tsv`, :ref:`cli_insert_unstr
::
Usage: sqlite-utils insert [OPTIONS] PATH TABLE FILE
Usage: sqlite-utils insert [OPTIONS] PATH TABLE [FILE]
Insert records from FILE into a table, creating the table if it does not
already exist.
@ -237,6 +247,9 @@ See :ref:`cli_inserting_data`, :ref:`cli_insert_csv_tsv`, :ref:`cli_insert_unstr
- Use --lines to write each incoming line to a column called "line"
- Use --text to write the entire input to a column called "text"
Use --type column-name type to override the type automatically chosen when the
table is created.
You can also use --convert to pass a fragment of Python code that will be used
to convert each input.
@ -263,8 +276,20 @@ See :ref:`cli_inserting_data`, :ref:`cli_insert_csv_tsv`, :ref:`cli_insert_unstr
echo 'A bunch of words' | sqlite-utils insert words.db words - \
--text --convert '({"word": w} for w in text.split())'
Instead of a FILE you can use --code to provide a block of Python code that
defines the rows to insert, as either a rows() function that yields
dictionaries or a "rows" iterable. --code can also be a path to a .py file:
sqlite-utils insert data.db creatures --code '
def rows():
yield {"id": 1, "name": "Cleo"}
yield {"id": 2, "name": "Suna"}
' --pk id
Options:
--pk TEXT Columns to use as the primary key, e.g. id
--code TEXT Python code defining a rows() function or iterable
of rows to insert
--flatten Flatten nested JSON objects, so {"a": {"b": 1}}
becomes {"a_b": 1}
--nl Expect newline-delimited JSON
@ -285,7 +310,7 @@ See :ref:`cli_inserting_data`, :ref:`cli_insert_csv_tsv`, :ref:`cli_insert_unstr
--alter Alter existing table to add any missing columns
--not-null TEXT Columns that should be created as NOT NULL
--default <TEXT TEXT>... Default value that should be set for a column
-d, --detect-types Detect types for columns in CSV/TSV data (default)
--type <TEXT CHOICE>... Column types to use when creating the table
--no-detect-types Treat all CSV/TSV columns as TEXT
--analyze Run ANALYZE at the end of this operation
--load-extension TEXT Path to SQLite extension, with optional :entrypoint
@ -307,12 +332,17 @@ See :ref:`cli_upsert`.
::
Usage: sqlite-utils upsert [OPTIONS] PATH TABLE FILE
Usage: sqlite-utils upsert [OPTIONS] PATH TABLE [FILE]
Upsert records based on their primary key. Works like 'insert' but if an
incoming record has a primary key that matches an existing record the existing
record will be updated.
If the table already exists and has a primary key, --pk can be omitted.
Use --type column-name type to override the type automatically chosen when the
table is created.
Example:
echo '[
@ -322,7 +352,8 @@ See :ref:`cli_upsert`.
Options:
--pk TEXT Columns to use as the primary key, e.g. id
[required]
--code TEXT Python code defining a rows() function or iterable
of rows to insert
--flatten Flatten nested JSON objects, so {"a": {"b": 1}}
becomes {"a_b": 1}
--nl Expect newline-delimited JSON
@ -343,7 +374,7 @@ See :ref:`cli_upsert`.
--alter Alter existing table to add any missing columns
--not-null TEXT Columns that should be created as NOT NULL
--default <TEXT TEXT>... Default value that should be set for a column
-d, --detect-types Detect types for columns in CSV/TSV data (default)
--type <TEXT CHOICE>... Column types to use when creating the table
--no-detect-types Treat all CSV/TSV columns as TEXT
--analyze Run ANALYZE at the end of this operation
--load-extension TEXT Path to SQLite extension, with optional :entrypoint
@ -376,7 +407,8 @@ See :ref:`cli_bulk`.
Options:
--batch-size INTEGER Commit every X records
--functions TEXT Python code or file path defining custom SQL functions
--functions TEXT Python code or a file path defining custom SQL
functions; can be used multiple times
--flatten Flatten nested JSON objects, so {"a": {"b": 1}} becomes
{"a_b": 1}
--nl Expect newline-delimited JSON
@ -423,18 +455,20 @@ See :ref:`cli_search`.
--arrays Output rows as arrays instead of objects
--csv Output CSV
--tsv Output TSV
--no-headers Omit CSV headers
--no-headers Omit headers from CSV/TSV and table/--fmt output
-t, --table Output as a formatted table
--fmt TEXT Table format - one of asciidoc, double_grid,
double_outline, fancy_grid, fancy_outline, github,
grid, heavy_grid, heavy_outline, html, jira, latex,
latex_booktabs, latex_longtable, latex_raw, mediawiki,
mixed_grid, mixed_outline, moinmoin, orgtbl, outline,
pipe, plain, presto, pretty, psql, rounded_grid,
rounded_outline, rst, simple, simple_grid,
simple_outline, textile, tsv, unsafehtml, youtrack
--fmt TEXT Table format - one of asciidoc, colon_grid,
double_grid, double_outline, fancy_grid, fancy_outline,
github, grid, heavy_grid, heavy_outline, html, jira,
latex, latex_booktabs, latex_longtable, latex_raw,
mediawiki, mixed_grid, mixed_outline, moinmoin, orgtbl,
outline, pipe, plain, presto, pretty, psql,
rounded_grid, rounded_outline, rst, simple,
simple_grid, simple_outline, textile, tsv, unsafehtml,
youtrack
--json-cols Detect JSON cols and output them as JSON, not escaped
strings
--ascii Escape non-ASCII characters in JSON output as \uXXXX
--load-extension TEXT Path to SQLite extension, with optional :entrypoint
-h, --help Show this message and exit.
@ -474,6 +508,8 @@ See :ref:`cli_transform_table`.
Add a foreign key constraint from a column to
another table with another column
--drop-foreign-key TEXT Drop foreign key constraint for this column
--strict / --no-strict Enable or disable STRICT mode (default:
preserve current mode)
--sql Output SQL without executing it
--load-extension TEXT Path to SQLite extension, with optional
:entrypoint
@ -611,27 +647,33 @@ See :ref:`cli_convert`.
"value" is a variable with the column value to be converted.
CODE can also be a reference to a callable that takes the value, for example:
sqlite-utils convert my.db mytable date r.parsedate
sqlite-utils convert my.db mytable data json.loads --import json
Use "-" for CODE to read Python code from standard input.
The following common operations are available as recipe functions:
r.jsonsplit(value, delimiter=',', type=<class 'str'>)
r.jsonsplit(value: 'str', delimiter: 'str' = ',', type: 'Callable[[str],
object]' = <class 'str'>) -> 'str'
Convert a string like a,b,c into a JSON array ["a", "b", "c"]
r.parsedate(value, dayfirst=False, yearfirst=False, errors=None)
r.parsedate(value: 'str', dayfirst: 'bool' = False, yearfirst: 'bool' = False,
errors: 'Optional[object]' = None) -> 'Optional[str]'
Parse a date and convert it to ISO date format: yyyy-mm-dd
- dayfirst=True: treat xx as the day in xx/yy/zz
- yearfirst=True: treat xx as the year in xx/yy/zz
- errors=r.IGNORE to ignore values that cannot be parsed
- errors=r.SET_NULL to set values that cannot be parsed to null
r.parsedatetime(value, dayfirst=False, yearfirst=False, errors=None)
r.parsedatetime(value: 'str', dayfirst: 'bool' = False, yearfirst: 'bool' =
False, errors: 'Optional[object]' = None) -> 'Optional[str]'
Parse a datetime and convert it to ISO datetime format: yyyy-mm-ddTHH:MM:SS
- dayfirst=True: treat xx as the day in xx/yy/zz
- yearfirst=True: treat xx as the year in xx/yy/zz
- errors=r.IGNORE to ignore values that cannot be parsed
@ -685,18 +727,20 @@ See :ref:`cli_tables`.
--arrays Output rows as arrays instead of objects
--csv Output CSV
--tsv Output TSV
--no-headers Omit CSV headers
--no-headers Omit headers from CSV/TSV and table/--fmt output
-t, --table Output as a formatted table
--fmt TEXT Table format - one of asciidoc, double_grid,
double_outline, fancy_grid, fancy_outline, github,
grid, heavy_grid, heavy_outline, html, jira, latex,
latex_booktabs, latex_longtable, latex_raw, mediawiki,
mixed_grid, mixed_outline, moinmoin, orgtbl, outline,
pipe, plain, presto, pretty, psql, rounded_grid,
rounded_outline, rst, simple, simple_grid,
simple_outline, textile, tsv, unsafehtml, youtrack
--fmt TEXT Table format - one of asciidoc, colon_grid,
double_grid, double_outline, fancy_grid, fancy_outline,
github, grid, heavy_grid, heavy_outline, html, jira,
latex, latex_booktabs, latex_longtable, latex_raw,
mediawiki, mixed_grid, mixed_outline, moinmoin, orgtbl,
outline, pipe, plain, presto, pretty, psql,
rounded_grid, rounded_outline, rst, simple,
simple_grid, simple_outline, textile, tsv, unsafehtml,
youtrack
--json-cols Detect JSON cols and output them as JSON, not escaped
strings
--ascii Escape non-ASCII characters in JSON output as \uXXXX
--columns Include list of columns for each table
--schema Include schema for each table
--load-extension TEXT Path to SQLite extension, with optional :entrypoint
@ -726,18 +770,20 @@ See :ref:`cli_views`.
--arrays Output rows as arrays instead of objects
--csv Output CSV
--tsv Output TSV
--no-headers Omit CSV headers
--no-headers Omit headers from CSV/TSV and table/--fmt output
-t, --table Output as a formatted table
--fmt TEXT Table format - one of asciidoc, double_grid,
double_outline, fancy_grid, fancy_outline, github,
grid, heavy_grid, heavy_outline, html, jira, latex,
latex_booktabs, latex_longtable, latex_raw, mediawiki,
mixed_grid, mixed_outline, moinmoin, orgtbl, outline,
pipe, plain, presto, pretty, psql, rounded_grid,
rounded_outline, rst, simple, simple_grid,
simple_outline, textile, tsv, unsafehtml, youtrack
--fmt TEXT Table format - one of asciidoc, colon_grid,
double_grid, double_outline, fancy_grid, fancy_outline,
github, grid, heavy_grid, heavy_outline, html, jira,
latex, latex_booktabs, latex_longtable, latex_raw,
mediawiki, mixed_grid, mixed_outline, moinmoin, orgtbl,
outline, pipe, plain, presto, pretty, psql,
rounded_grid, rounded_outline, rst, simple,
simple_grid, simple_outline, textile, tsv, unsafehtml,
youtrack
--json-cols Detect JSON cols and output them as JSON, not escaped
strings
--ascii Escape non-ASCII characters in JSON output as \uXXXX
--columns Include list of columns for each view
--schema Include schema for each view
--load-extension TEXT Path to SQLite extension, with optional :entrypoint
@ -772,19 +818,21 @@ See :ref:`cli_rows`.
--arrays Output rows as arrays instead of objects
--csv Output CSV
--tsv Output TSV
--no-headers Omit CSV headers
--no-headers Omit headers from CSV/TSV and table/--fmt output
-t, --table Output as a formatted table
--fmt TEXT Table format - one of asciidoc, double_grid,
double_outline, fancy_grid, fancy_outline, github,
grid, heavy_grid, heavy_outline, html, jira,
latex, latex_booktabs, latex_longtable, latex_raw,
mediawiki, mixed_grid, mixed_outline, moinmoin,
orgtbl, outline, pipe, plain, presto, pretty,
psql, rounded_grid, rounded_outline, rst, simple,
simple_grid, simple_outline, textile, tsv,
unsafehtml, youtrack
--fmt TEXT Table format - one of asciidoc, colon_grid,
double_grid, double_outline, fancy_grid,
fancy_outline, github, grid, heavy_grid,
heavy_outline, html, jira, latex, latex_booktabs,
latex_longtable, latex_raw, mediawiki, mixed_grid,
mixed_outline, moinmoin, orgtbl, outline, pipe,
plain, presto, pretty, psql, rounded_grid,
rounded_outline, rst, simple, simple_grid,
simple_outline, textile, tsv, unsafehtml, youtrack
--json-cols Detect JSON cols and output them as JSON, not
escaped strings
--ascii Escape non-ASCII characters in JSON output as
\uXXXX
--load-extension TEXT Path to SQLite extension, with optional
:entrypoint
-h, --help Show this message and exit.
@ -812,18 +860,20 @@ See :ref:`cli_triggers`.
--arrays Output rows as arrays instead of objects
--csv Output CSV
--tsv Output TSV
--no-headers Omit CSV headers
--no-headers Omit headers from CSV/TSV and table/--fmt output
-t, --table Output as a formatted table
--fmt TEXT Table format - one of asciidoc, double_grid,
double_outline, fancy_grid, fancy_outline, github,
grid, heavy_grid, heavy_outline, html, jira, latex,
latex_booktabs, latex_longtable, latex_raw, mediawiki,
mixed_grid, mixed_outline, moinmoin, orgtbl, outline,
pipe, plain, presto, pretty, psql, rounded_grid,
rounded_outline, rst, simple, simple_grid,
simple_outline, textile, tsv, unsafehtml, youtrack
--fmt TEXT Table format - one of asciidoc, colon_grid,
double_grid, double_outline, fancy_grid, fancy_outline,
github, grid, heavy_grid, heavy_outline, html, jira,
latex, latex_booktabs, latex_longtable, latex_raw,
mediawiki, mixed_grid, mixed_outline, moinmoin, orgtbl,
outline, pipe, plain, presto, pretty, psql,
rounded_grid, rounded_outline, rst, simple,
simple_grid, simple_outline, textile, tsv, unsafehtml,
youtrack
--json-cols Detect JSON cols and output them as JSON, not escaped
strings
--ascii Escape non-ASCII characters in JSON output as \uXXXX
--load-extension TEXT Path to SQLite extension, with optional :entrypoint
-h, --help Show this message and exit.
@ -851,18 +901,20 @@ See :ref:`cli_indexes`.
--arrays Output rows as arrays instead of objects
--csv Output CSV
--tsv Output TSV
--no-headers Omit CSV headers
--no-headers Omit headers from CSV/TSV and table/--fmt output
-t, --table Output as a formatted table
--fmt TEXT Table format - one of asciidoc, double_grid,
double_outline, fancy_grid, fancy_outline, github,
grid, heavy_grid, heavy_outline, html, jira, latex,
latex_booktabs, latex_longtable, latex_raw, mediawiki,
mixed_grid, mixed_outline, moinmoin, orgtbl, outline,
pipe, plain, presto, pretty, psql, rounded_grid,
rounded_outline, rst, simple, simple_grid,
simple_outline, textile, tsv, unsafehtml, youtrack
--fmt TEXT Table format - one of asciidoc, colon_grid,
double_grid, double_outline, fancy_grid, fancy_outline,
github, grid, heavy_grid, heavy_outline, html, jira,
latex, latex_booktabs, latex_longtable, latex_raw,
mediawiki, mixed_grid, mixed_outline, moinmoin, orgtbl,
outline, pipe, plain, presto, pretty, psql,
rounded_grid, rounded_outline, rst, simple,
simple_grid, simple_outline, textile, tsv, unsafehtml,
youtrack
--json-cols Detect JSON cols and output them as JSON, not escaped
strings
--ascii Escape non-ASCII characters in JSON output as \uXXXX
--load-extension TEXT Path to SQLite extension, with optional :entrypoint
-h, --help Show this message and exit.
@ -908,10 +960,10 @@ See :ref:`cli_create_table`.
sqlite-utils create-table my.db people \
id integer \
name text \
height float \
height real \
photo blob --pk id
Valid column types are text, integer, float and blob.
Valid column types are text, integer, real, float and blob.
Options:
--pk TEXT Column to use as primary key
@ -957,6 +1009,67 @@ See :ref:`cli_create_index`.
-h, --help Show this message and exit.
.. _cli_ref_drop_index:
drop-index
==========
See :ref:`cli_drop_index`.
::
Usage: sqlite-utils drop-index [OPTIONS] PATH TABLE INDEX
Drop an index by index name from the specified table
Example:
sqlite-utils drop-index chickens.db chickens idx_chickens_name
Options:
--ignore Ignore if index does not exist
--load-extension TEXT Path to SQLite extension, with optional :entrypoint
-h, --help Show this message and exit.
.. _cli_ref_migrate:
migrate
=======
See :ref:`cli_migrate`.
::
Usage: sqlite-utils migrate [OPTIONS] DB_PATH [MIGRATIONS]...
Apply pending database migrations.
Usage:
sqlite-utils migrate database.db
This will find the migrations.py file in the current directory or
subdirectories and apply any pending migrations.
Or pass paths to one or more migrations.py files directly:
sqlite-utils migrate database.db path/to/migrations.py
Pass --list to see a list of applied and pending migrations without applying
them.
Use --stop-before migration_set:name to stop before a migration. This option
can be used multiple times.
Options:
--stop-before TEXT Stop before applying this migration. Use set:name to
target a migration set.
--list List migrations without running them
-v, --verbose Show verbose output
-h, --help Show this message and exit.
.. _cli_ref_enable_fts:
enable-fts
@ -968,7 +1081,7 @@ See :ref:`cli_fts`.
Usage: sqlite-utils enable-fts [OPTIONS] PATH TABLE COLUMN...
Enable full-text search for specific table and columns"
Enable full-text search for specific table and columns
Example:
@ -1144,7 +1257,7 @@ See :ref:`cli_add_column`.
::
Usage: sqlite-utils add-column [OPTIONS] PATH TABLE COL_NAME
[[integer|int|float|real|text|str|blob|bytes]]
[integer|int|float|real|text|str|blob|bytes]
Add a column to the specified table

View file

@ -29,6 +29,16 @@ The ``sqlite-utils query`` command lets you run queries directly against a SQLit
.. note::
In Python: :ref:`db.query() <python_api_query>` CLI reference: :ref:`sqlite-utils query <cli_ref_query>`
Pass ``-`` as the SQL query to read the query from standard input. This is useful for longer queries that would otherwise require careful shell escaping, or for piping in SQL generated by another tool:
.. code-block:: bash
echo "select * from dogs" | sqlite-utils query dogs.db -
.. code-block:: bash
sqlite-utils query dogs.db - < query.sql
.. _cli_query_json:
Returning JSON
@ -45,6 +55,8 @@ The default format returned for queries is JSON:
[{"id": 1, "age": 4, "name": "Cleo"},
{"id": 2, "age": 2, "name": "Pancakes"}]
If the query returns more than one column with the same name, later occurrences are renamed with a numeric suffix - ``select 1 as id, 2 as id`` returns ``[{"id": 1, "id_2": 2}]``. This only applies to JSON output: :ref:`CSV and TSV <cli_query_csv>` and :ref:`table <cli_query_table>` output keep the duplicate column headers unchanged.
.. _cli_query_nl:
Newline-delimited JSON
@ -109,6 +121,33 @@ If you want to pretty-print the output further, you can pipe it through ``python
}
]
.. _cli_query_json_ascii:
Unicode characters in JSON
~~~~~~~~~~~~~~~~~~~~~~~~~~
JSON output includes unicode characters directly, without escaping them:
.. code-block:: bash
sqlite-utils dogs.db "select '日本語' as text"
.. code-block:: output
[{"text": "日本語"}]
Use ``--ascii`` to escape non-ASCII characters as ``\uXXXX`` sequences instead:
.. code-block:: bash
sqlite-utils dogs.db "select '日本語' as text" --ascii
.. code-block:: output
[{"text": "\u65e5\u672c\u8a9e"}]
The ``--ascii`` option can help on systems that cannot display or process UTF-8, such as Windows consoles using a legacy code page. On Windows, setting the ``PYTHONUTF8=1`` environment variable is an alternative fix for ``UnicodeEncodeError`` crashes when redirecting output to a file.
.. _cli_query_binary_json:
Binary data in JSON
@ -257,6 +296,7 @@ Available ``--fmt`` options are:
.. ]]]
- ``asciidoc``
- ``colon_grid``
- ``double_grid``
- ``double_outline``
- ``fancy_grid``
@ -321,7 +361,7 @@ To return the first column of each result as raw data, separated by newlines, us
Using named parameters
----------------------
You can pass named parameters to the query using ``-p``:
You can pass named parameters to the query using ``-p name value``:
.. code-block:: bash
@ -384,6 +424,9 @@ The ``--functions`` option can be used multiple times to load functions from mul
from urllib.parse import urlparse
return urlparse(url).path'
.. note::
In Python: :ref:`db.register_function() <python_api_register_function>`
.. _cli_query_extensions:
SQLite extensions
@ -984,6 +1027,9 @@ To show more than 10 common values, use ``--common-limit 20``. To skip the most
sqlite-utils analyze-tables github.db tags --common-limit 20 --no-least
.. note::
In Python: :ref:`table.analyze_column() <python_api_analyze_column>` CLI reference: :ref:`sqlite-utils analyze-tables <cli_ref_analyze_tables>`
.. _cli_analyze_tables_save:
Saving the analyzed table details
@ -1057,6 +1103,37 @@ That will look for SpatiaLite in a set of predictable locations. To load it from
sqlite-utils create-database empty.db --init-spatialite --load-extension /path/to/spatialite.so
.. _cli_migrate:
Running migrations
==================
The ``migrate`` command applies pending Python migrations to a database. For the full migration file format and Python API, see :ref:`migrations`.
.. code-block:: bash
sqlite-utils migrate creatures.db path/to/migrations.py
If you omit the migration path it will search the current directory and subdirectories for files called ``migrations.py``:
.. code-block:: bash
sqlite-utils migrate creatures.db
Use ``--list`` to list applied and pending migrations without running them:
.. code-block:: bash
sqlite-utils migrate creatures.db --list
Use ``--stop-before`` to stop before a named migration. The option can be passed more than once, and can target a specific migration set using ``migration_set:migration_name``:
.. code-block:: bash
sqlite-utils migrate creatures.db path/to/migrations.py \
--stop-before creatures:add_weight \
--stop-before sales:drop_index
.. _cli_inserting_data:
Inserting JSON data
@ -1120,6 +1197,9 @@ You can delete all the existing rows in the table before inserting the new recor
You can add the ``--analyze`` option to run ``ANALYZE`` against the table after the rows have been inserted.
.. note::
In Python: :ref:`table.insert_all() <python_api_bulk_inserts>` CLI reference: :ref:`sqlite-utils insert <cli_ref_insert>`
.. _cli_inserting_data_binary:
Inserting binary data
@ -1265,6 +1345,8 @@ A progress bar is displayed when inserting data from a file. You can hide the pr
By default, column types are automatically detected for CSV or TSV files - resulting in a mix of ``TEXT``, ``INTEGER`` and ``REAL`` columns. To disable type detection and treat all columns as ``TEXT``, use the ``--no-detect-types`` option.
Detected types are only applied when the table is created by the command. Inserting CSV or TSV data into a table that already exists leaves the existing column types unchanged - values are inserted using the table's existing schema.
For example, given a ``creatures.csv`` file containing this:
.. code-block::
@ -1293,6 +1375,25 @@ Will produce this schema with automatically detected types:
"weight" REAL
);
.. _cli_insert_csv_tsv_column_types:
Overriding column types
-----------------------
Use ``--type column-name type`` to override the type automatically chosen when the table is created. This option can be used more than once, and works with both ``insert`` and ``upsert``:
.. code-block:: bash
sqlite-utils insert places.db places places.csv --csv \
--type zipcode text \
--type score real
This is useful for values such as ZIP codes, which may look like integers but should be stored as ``TEXT`` to preserve leading zeros.
The column type should be one of ``TEXT``, ``INTEGER``, ``FLOAT``, ``REAL`` or ``BLOB``. Column types are matched case-insensitively.
As with detected column types, ``--type`` only affects tables created by the command. If the table already exists, its existing column types are left unchanged.
To disable type detection and treat all columns as TEXT, use ``--no-detect-types``:
.. code-block:: bash
@ -1488,6 +1589,27 @@ The result looks like this:
COMMIT;
.. _cli_insert_code:
Inserting rows generated by Python code
=======================================
Instead of providing a ``FILE`` to import, you can use the ``--code`` option to pass a block of Python code that generates the rows to insert. This is the command-line equivalent of calling ``db["creatures"].insert_all(rows())`` from the :ref:`Python API <python_api>`.
Your code should define either a ``rows()`` function that returns or yields dictionaries, or a ``rows`` iterable such as a list of dictionaries:
.. code-block:: bash
sqlite-utils insert data.db creatures --code '
def rows():
yield {"id": 1, "name": "Cleo"}
yield {"id": 2, "name": "Suna"}
' --pk id
``--code`` can also be given a path to a Python ``.py`` file.
The ``--code`` option works with both ``sqlite-utils insert`` and ``sqlite-utils upsert``, and composes with table options such as ``--pk``, ``--replace``, ``--alter``, ``--not-null`` and ``--default``. It cannot be combined with a ``FILE`` argument or with input format options such as ``--csv`` or ``--convert``.
.. _cli_insert_replace:
Insert-replacing data
@ -1502,6 +1624,9 @@ To replace a dog with in ID of 2 with a new record, run the following:
echo '{"id": 2, "name": "Pancakes", "age": 3}' | \
sqlite-utils insert dogs.db dogs - --pk=id --replace
.. note::
In Python: :ref:`table.insert(..., replace=True) <python_api_insert_replace>` CLI reference: :ref:`sqlite-utils insert <cli_ref_insert>`
.. _cli_upsert:
Upserting data
@ -1520,12 +1645,17 @@ For example:
This will update the dog with an ID of 2 to have an age of 4, creating a new record (with a null name) if one does not exist. If a row DOES exist the name will be left as-is.
If the table already exists and has a primary key, you can omit the ``--pk`` option and ``sqlite-utils`` will use that existing primary key.
The command will fail if you reference columns that do not exist on the table. To automatically create missing columns, use the ``--alter`` option.
.. note::
``upsert`` in sqlite-utils 1.x worked like ``insert ... --replace`` does in 2.x. See `issue #66 <https://github.com/simonw/sqlite-utils/issues/66>`__ for details of this change.
.. note::
In Python: :ref:`table.upsert() <python_api_upsert>` CLI reference: :ref:`sqlite-utils upsert <cli_ref_upsert>`
.. _cli_bulk:
Executing SQL in bulk
@ -1727,6 +1857,9 @@ You can include named parameters in your where clause and populate them using on
The ``--dry-run`` option will output a preview of the conversion against the first ten rows, without modifying the database.
.. note::
In Python: :ref:`table.convert() <python_api_convert>` CLI reference: :ref:`sqlite-utils convert <cli_ref_convert>`
.. _cli_convert_import:
Importing additional modules
@ -1849,7 +1982,19 @@ These recipes can be used in the code passed to ``sqlite-utils convert`` like th
sqlite-utils convert my.db mytable mycolumn \
'r.jsonsplit(value)'
To use any of the documented parameters, do this:
You can also pass the recipe function directly without the ``(value)`` part - sqlite-utils will detect that it is a callable and use it automatically:
.. code-block:: bash
sqlite-utils convert my.db mytable mycolumn r.parsedate
This shorter syntax works for any callable, including functions from imported modules:
.. code-block:: bash
sqlite-utils convert my.db mytable mycolumn json.loads --import json
To use any of the documented parameters, use the full function call syntax:
.. code-block:: bash
@ -2013,6 +2158,9 @@ If a table with the same name already exists, you will get an error. You can cho
You can also pass ``--transform`` to transform the existing table to match the new schema. See :ref:`python_api_explicit_create` in the Python library documentation for details of how this option works.
.. note::
In Python: :ref:`table.create() <python_api_explicit_create>` CLI reference: :ref:`sqlite-utils create-table <cli_ref_create_table>`
.. _cli_renaming_tables:
Renaming a table
@ -2026,6 +2174,9 @@ Yo ucan rename a table using the ``rename-table`` command:
Pass ``--ignore`` to ignore any errors caused by the table not existing, or the new name already being in use.
.. note::
In Python: :ref:`db.rename_table() <python_api_rename_table>` CLI reference: :ref:`sqlite-utils rename-table <cli_ref_rename_table>`
.. _cli_duplicate_table:
Duplicating tables
@ -2037,6 +2188,9 @@ The ``duplicate`` command duplicates a table - creating a new table with the sam
sqlite-utils duplicate books.db authors authors_copy
.. note::
In Python: :ref:`table.duplicate() <python_api_duplicate>` CLI reference: :ref:`sqlite-utils duplicate <cli_ref_duplicate>`
.. _cli_drop_table:
Dropping tables
@ -2050,12 +2204,15 @@ You can drop a table using the ``drop-table`` command:
Use ``--ignore`` to ignore the error if the table does not exist.
.. note::
In Python: :ref:`table.drop() <python_api_drop>` CLI reference: :ref:`sqlite-utils drop-table <cli_ref_drop_table>`
.. _cli_transform_table:
Transforming tables
===================
The ``transform`` command allows you to apply complex transformations to a table that cannot be implemented using a regular SQLite ``ALTER TABLE`` command. See :ref:`python_api_transform` for details of how this works. The ``transform`` command preserves a table's ``STRICT`` mode.
The ``transform`` command allows you to apply complex transformations to a table that cannot be implemented using a regular SQLite ``ALTER TABLE`` command. See :ref:`python_api_transform` for details of how this works. By default, the ``transform`` command preserves a table's ``STRICT`` mode.
.. code-block:: bash
@ -2101,6 +2258,12 @@ Every option for this table (with the exception of ``--pk-none``) can be specifi
``--add-foreign-key column other_table other_column``
Add a foreign key constraint to ``column`` pointing to ``other_table.other_column``.
``--strict``
Convert the table to a `SQLite STRICT table <https://www.sqlite.org/stricttables.html>`__. The command fails if the available SQLite version does not support strict tables. If existing rows contain values that are incompatible with their declared column types the transformation fails and the original table is left unchanged.
``--no-strict``
Convert a strict table back to a regular non-strict table.
If you want to see the SQL that will be executed to make the change without actually executing it, add the ``--sql`` flag. For example:
.. code-block:: bash
@ -2127,6 +2290,9 @@ If you want to see the SQL that will be executed to make the change without actu
DROP TABLE "roadside_attractions";
ALTER TABLE "roadside_attractions_new_4033a60276b9" RENAME TO "roadside_attractions";
.. note::
In Python: :ref:`table.transform() <python_api_transform>` CLI reference: :ref:`sqlite-utils transform <cli_ref_transform>`
.. _cli_transform_table_add_primary_key_to_rowid:
Adding a primary key to a rowid table
@ -2213,6 +2379,8 @@ The ``sqlite-utils extract`` command can be used to extract specified columns in
Take a look at the Python API documentation for :ref:`python_api_extract` for a detailed description of how this works, including examples of table schemas before and after running an extraction operation.
Rows where every extracted column is ``null`` are not extracted - those rows get a ``null`` value in their new foreign key column and no record is created for them in the lookup table.
The command takes a database, table and one or more columns that should be extracted. To extract the ``species`` column from the ``trees`` table you would run:
.. code-block:: bash
@ -2302,6 +2470,9 @@ After running the above, the command ``sqlite-utils schema global.db`` reveals t
CREATE UNIQUE INDEX "idx_countries_country_name"
ON "countries" ("country", "name");
.. note::
In Python: :ref:`table.extract() <python_api_extract>` CLI reference: :ref:`sqlite-utils extract <cli_ref_extract>`
.. _cli_create_view:
Creating views
@ -2323,6 +2494,9 @@ You can create a view using the ``create-view`` command:
Use ``--replace`` to replace an existing view of the same name, and ``--ignore`` to do nothing if a view already exists.
.. note::
In Python: :ref:`db.create_view() <python_api_create_view>` CLI reference: :ref:`sqlite-utils create-view <cli_ref_create_view>`
.. _cli_drop_view:
Dropping views
@ -2336,6 +2510,9 @@ You can drop a view using the ``drop-view`` command:
Use ``--ignore`` to ignore the error if the view does not exist.
.. note::
In Python: :ref:`view.drop() <python_api_drop>` CLI reference: :ref:`sqlite-utils drop-view <cli_ref_drop_view>`
.. _cli_add_column:
Adding columns
@ -2376,6 +2553,9 @@ You can set a ``NOT NULL DEFAULT 'x'`` constraint on the new column using ``--no
sqlite-utils add-column mydb.db dogs friends_count integer --not-null-default 0
.. note::
In Python: :ref:`table.add_column() <python_api_add_column>` CLI reference: :ref:`sqlite-utils add-column <cli_ref_add_column>`
.. _cli_add_column_alter:
Adding columns automatically on insert/update
@ -2387,6 +2567,9 @@ You can use the ``--alter`` option to automatically add new columns if the data
sqlite-utils insert dogs.db dogs new-dogs.json --pk=id --alter
.. note::
In Python: :ref:`table.insert(..., alter=True) <python_api_add_column_alter>`
.. _cli_add_foreign_key:
Adding foreign key constraints
@ -2414,6 +2597,9 @@ Add ``--ignore`` to ignore an existing foreign key (as opposed to returning an e
See :ref:`python_api_add_foreign_key` in the Python API documentation for further details, including how the automatic table guessing mechanism works.
.. note::
In Python: :ref:`table.add_foreign_key() <python_api_add_foreign_key>` CLI reference: :ref:`sqlite-utils add-foreign-key <cli_ref_add_foreign_key>`
.. _cli_add_foreign_keys:
Adding multiple foreign keys at once
@ -2429,6 +2615,9 @@ Adding a foreign key requires a ``VACUUM``. On large databases this can be an ex
When you are using this command each foreign key needs to be defined in full, as four arguments - the table, column, other table and other column.
.. note::
In Python: :ref:`db.add_foreign_keys() <python_api_add_foreign_keys>` CLI reference: :ref:`sqlite-utils add-foreign-keys <cli_ref_add_foreign_keys>`
.. _cli_index_foreign_keys:
Adding indexes for all foreign keys
@ -2440,6 +2629,9 @@ If you want to ensure that every foreign key column in your database has a corre
sqlite-utils index-foreign-keys books.db
.. note::
In Python: :ref:`db.index_foreign_keys() <python_api_index_foreign_keys>` CLI reference: :ref:`sqlite-utils index-foreign-keys <cli_ref_index_foreign_keys>`
.. _cli_defaults_not_null:
Setting defaults and not null constraints
@ -2455,6 +2647,9 @@ You can use the ``--not-null`` and ``--default`` options (to both ``insert`` and
--default age 2 \
--default score 5
.. note::
In Python: :ref:`not_null= and defaults= arguments <python_api_defaults_not_null>`
.. _cli_create_index:
Creating indexes
@ -2486,6 +2681,25 @@ If your column names are already prefixed with a hyphen you'll need to manually
Add the ``--analyze`` option to run ``ANALYZE`` against the index after it has been created.
.. note::
In Python: :ref:`table.create_index() <python_api_create_index>` CLI reference: :ref:`sqlite-utils create-index <cli_ref_create_index>`
.. _cli_drop_index:
Dropping indexes
================
You can drop an index from an existing table using the ``drop-index`` command:
.. code-block:: bash
sqlite-utils drop-index mydb.db mytable idx_mytable_col1
Use ``--ignore`` to ignore the error if the index does not exist on that table.
.. note::
In Python: :ref:`table.drop_index() <python_api_create_index>` CLI reference: :ref:`sqlite-utils drop-index <cli_ref_drop_index>`
.. _cli_fts:
Configuring full-text search
@ -2539,6 +2753,9 @@ You can rebuild every FTS table by running ``rebuild-fts`` without passing any t
sqlite-utils rebuild-fts mydb.db
.. note::
In Python: :ref:`table.enable_fts() <python_api_fts_enable>` CLI reference: :ref:`sqlite-utils enable-fts <cli_ref_enable_fts>`
.. _cli_search:
Executing searches
@ -2595,6 +2812,9 @@ Use the ``--sql`` option to output the SQL that would be executed, rather than r
order by
"documents_fts".rank
.. note::
In Python: :ref:`table.search() <python_api_fts_search>` CLI reference: :ref:`sqlite-utils search <cli_ref_search>`
.. _cli_enable_counts:
Enabling cached counts
@ -2618,6 +2838,9 @@ If the ``_counts`` table ever becomes out-of-sync with the actual table counts y
sqlite-utils reset-counts mydb.db
.. note::
In Python: :ref:`table.enable_counts() <python_api_cached_table_counts>` CLI reference: :ref:`sqlite-utils enable-counts <cli_ref_enable_counts>`
.. _cli_analyze:
Optimizing index usage with ANALYZE
@ -2641,6 +2864,9 @@ You can run it against specific tables, or against specific named indexes, by pa
You can also run ``ANALYZE`` as part of another command using the ``--analyze`` option. This is supported by the ``create-index``, ``insert`` and ``upsert`` commands.
.. note::
In Python: :ref:`db.analyze() <python_api_analyze>` CLI reference: :ref:`sqlite-utils analyze <cli_ref_analyze>`
.. _cli_vacuum:
Vacuum
@ -2652,6 +2878,9 @@ You can run VACUUM to optimize your database like so:
sqlite-utils vacuum mydb.db
.. note::
In Python: :ref:`db.vacuum() <python_api_vacuum>` CLI reference: :ref:`sqlite-utils vacuum <cli_ref_vacuum>`
.. _cli_optimize:
Optimize
@ -2675,6 +2904,9 @@ To optimize specific tables rather than every FTS table, pass those tables as ex
sqlite-utils optimize mydb.db table_1 table_2
.. note::
In Python: :ref:`table.optimize() <python_api_fts_optimize>` CLI reference: :ref:`sqlite-utils optimize <cli_ref_optimize>`
.. _cli_wal:
WAL mode
@ -2694,6 +2926,9 @@ You can disable WAL mode using ``disable-wal``:
Both of these commands accept one or more database files as arguments.
.. note::
In Python: :ref:`db.enable_wal() and db.disable_wal() <python_api_wal>` CLI reference: :ref:`sqlite-utils enable-wal <cli_ref_enable_wal>`
.. _cli_dump:
Dumping the database to SQL
@ -2709,6 +2944,9 @@ The ``dump`` command outputs a SQL dump of the schema and full contents of the s
...
COMMIT;
.. note::
In Python: :ref:`db.iterdump() <python_api_itedump>` CLI reference: :ref:`sqlite-utils dump <cli_ref_dump>`
.. _cli_load_extension:
Loading SQLite extensions
@ -2756,6 +2994,9 @@ Eight (case-insensitive) types are allowed:
* GEOMETRYCOLLECTION
* GEOMETRY
.. note::
In Python: :ref:`table.add_geometry_column() <python_api_gis_add_geometry_column>` CLI reference: :ref:`sqlite-utils add-geometry-column <cli_ref_add_geometry_column>`
.. _cli_spatialite_indexes:
Adding spatial indexes
@ -2769,6 +3010,9 @@ Once you have a geometry column, you can speed up bounding box queries by adding
See this `SpatiaLite Cookbook recipe <http://www.gaia-gis.it/gaia-sins/spatialite-cookbook-5/cookbook_topics.03.html#topic_Wonderful_RTree_Spatial_Index>`__ for examples of how to use a spatial index.
.. note::
In Python: :ref:`table.create_spatial_index() <python_api_gis_create_spatial_index>` CLI reference: :ref:`sqlite-utils create-spatial-index <cli_ref_create_spatial_index>`
.. _cli_install:
Installing packages

View file

@ -1,8 +1,10 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from subprocess import Popen, PIPE
from beanbag_docutils.sphinx.ext.github import github_linkcode_resolve
import inspect
from pathlib import Path
from subprocess import Popen, PIPE, check_output
import sys
# This file is execfile()d with the current directory set to its
# containing dir.
@ -45,14 +47,52 @@ extlinks = {
}
def _linkcode_git_ref():
try:
return check_output(["git", "rev-parse", "HEAD"]).decode("utf8").strip()
except Exception:
return "main"
def linkcode_resolve(domain, info):
return github_linkcode_resolve(
domain=domain,
info=info,
allowed_module_names=["sqlite_utils"],
github_org_id="simonw",
github_repo_id="sqlite-utils",
branch="main",
if domain != "py":
return None
module_name = info.get("module")
if not module_name or module_name.split(".")[0] != "sqlite_utils":
return None
module = sys.modules.get(module_name)
if module is None:
return None
obj = module
for part in info.get("fullname", "").split("."):
obj = getattr(obj, part, None)
if obj is None:
return None
if isinstance(obj, property):
obj = obj.fget
try:
obj = inspect.unwrap(obj)
source_file = inspect.getsourcefile(obj)
_, line_number = inspect.getsourcelines(obj)
except Exception:
return None
if source_file is None:
return None
try:
filename = Path(source_file).resolve().relative_to(Path(__file__).parent.parent)
except ValueError:
return None
return (
"https://github.com/simonw/sqlite-utils/blob/"
f"{_linkcode_git_ref()}/{filename}#L{line_number}"
)
@ -79,7 +119,7 @@ author = "Simon Willison"
#
# The short X.Y version.
pipe = Popen("git describe --tags --always", stdout=PIPE, shell=True)
git_version = pipe.stdout.read().decode("utf8")
git_version = pipe.stdout.read().decode("utf8") if pipe.stdout else ""
if git_version:
version = git_version.rsplit("-", 1)[0]

View file

@ -13,78 +13,61 @@ All improvements to the software should start with an issue. Read `How I build a
Obtaining the code
==================
To work on this library locally, first checkout the code. Then create a new virtual environment::
To work on this library locally, first checkout the code::
git clone git@github.com:simonw/sqlite-utils
cd sqlite-utils
python3 -mvenv venv
source venv/bin/activate
Or if you are using ``pipenv``::
Use ``uv run`` to run the development version of the tool::
pipenv shell
Within the virtual environment running ``sqlite-utils`` should run your locally editable version of the tool. You can use ``which sqlite-utils`` to confirm that you are running the version that lives in your virtual environment.
uv run sqlite-utils --help
.. _contributing_tests:
Running the tests
=================
To install the dependencies and test dependencies::
Use ``uv run`` to run the tests::
pip install -e '.[test]'
To run the tests::
pytest
uv run pytest
.. _contributing_docs:
Building the documentation
==========================
To build the documentation, first install the documentation dependencies::
To build the documentation run this command::
pip install -e '.[docs]'
uv run make livehtml --directory docs
Then run ``make livehtml`` from the ``docs/`` directory to start a server on port 8000 that will serve the documentation and live-reload any time you make an edit to a ``.rst`` file::
cd docs
make livehtml
This will start a server on port 8000 that will serve the documentation and live-reload any time you make an edit to a ``.rst`` file.
The `cog <https://github.com/nedbat/cog>`__ tool is used to maintain portions of the documentation. You can run it like so::
cog -r docs/*.rst
uv run cog -r docs/*.rst
.. _contributing_linting:
Linting and formatting
======================
``sqlite-utils`` uses `Black <https://black.readthedocs.io/>`__ for code formatting, and `flake8 <https://flake8.pycqa.org/>`__ and `mypy <https://mypy.readthedocs.io/>`__ for linting and type checking.
``sqlite-utils`` uses `Black <https://black.readthedocs.io/>`__ for code formatting, and `flake8 <https://flake8.pycqa.org/>`__ and `mypy <https://mypy.readthedocs.io/>`__ for linting and type checking::
Black is installed as part of ``pip install -e '.[test]'`` - you can then format your code by running it in the root of the project::
uv run black .
black .
Linting tools can be run like this::
To install ``mypy`` and ``flake8`` run the following::
pip install -e '.[flake8,mypy]'
Both commands can then be run in the root of the project like this::
flake8
mypy sqlite_utils
uv run flake8
uv run mypy sqlite_utils
All three of these tools are run by our CI mechanism against every commit and pull request.
.. _contributing_just:
Using Just and uv
=================
Using Just
==========
If you install `Just <https://github.com/casey/just>`__ and `uv <https://github.com/astral-sh/uv>`__ you can use them to manage your local development environment.
If you install `Just <https://github.com/casey/just>`__ you can use it to manage your local development environment.
To run all of the tests and linters::

View file

@ -36,8 +36,10 @@ Contents
installation
cli
python-api
migrations
plugins
reference
cli-reference
upgrading
contributing
changelog

View file

@ -52,15 +52,15 @@ On some platforms the ability to load additional extensions (via ``conn.load_ext
You may also see the error ``sqlite3.OperationalError: table sqlite_master may not be modified`` when trying to alter an existing table.
You can work around these limitations by installing either the `pysqlite3 <https://pypi.org/project/pysqlite3/>`__ package or the `sqlean.py <https://pypi.org/project/sqlean.py/>`__ package, both of which provide drop-in replacements for the standard library ``sqlite3`` module but with a recent version of SQLite and full support for loading extensions.
You can work around these limitations by installing the `pysqlite3 <https://pypi.org/project/pysqlite3/>`__ package, which provides a drop-in replacement for the standard library ``sqlite3`` module but with a recent version of SQLite and full support for loading extensions.
To install ``sqlean.py`` (which has compiled binary wheels available for all major platforms) run the following:
To install ``pysqlite3`` run the following:
.. code-block:: bash
sqlite-utils install sqlean.py
sqlite-utils install pysqlite3
``pysqlite3`` and ``sqlean.py`` do not provide implementations of the ``.iterdump()`` method. To use that method (see :ref:`python_api_itedump`) or the ``sqlite-utils dump`` command you should also install the ``sqlite-dump`` package:
``pysqlite3`` does not provide an implementation of the ``.iterdump()`` method. To use that method (see :ref:`python_api_itedump`) or the ``sqlite-utils dump`` command you should also install the ``sqlite-dump`` package:
.. code-block:: bash
@ -87,4 +87,4 @@ For ``zsh``:
Add this code to ``~/.zshrc`` or ``~/.bashrc`` to automatically run it when you start a new shell.
See `the Click documentation <https://click.palletsprojects.com/en/8.1.x/shell-completion/>`__ for more details.
See `the Click documentation <https://click.palletsprojects.com/en/8.1.x/shell-completion/>`__ for more details.

194
docs/migrations.rst Normal file
View file

@ -0,0 +1,194 @@
.. _migrations:
=====================
Database migrations
=====================
``sqlite-utils`` includes a migration system for applying repeatable changes to SQLite database files.
A migration is a Python function that receives a :class:`sqlite_utils.Database` instance and then executes Python code to modify that database - creating or transforming tables, adding indexes, inserting rows, or any other operation supported by SQLite.
Migrations are grouped into named sets using the :class:`sqlite_utils.Migrations` class, and each applied migration is recorded in the ``_sqlite_migrations`` table in that database.
This means you can run the migrate operation multiple times and it will only apply migrations that have not previously been recorded.
.. _migrations_define:
Defining migrations
===================
Ordered migration sets are defined by first creating a :class:`sqlite_utils.Migrations` object.
Individual migrations are Python functions that are then registered with that migration set. Each migration function is passed a single argument that is a :ref:`sqlite_utils.Database <reference_db_database>` instance.
The name passed to ``Migrations("creatures")`` identifies that set of migrations. Use a name that is unique for your project, since multiple migration sets can be applied to the same database.
Here is a simple example of a ``migrations.py`` file which creates a table, then adds an extra column to that table in a second migration:
.. code-block:: python
from sqlite_utils import Migrations
migrations = Migrations("creatures")
@migrations()
def create_table(db):
db["creatures"].create(
{"id": int, "name": str, "species": str},
pk="id",
)
@migrations()
def add_weight(db):
db["creatures"].add_column("weight", float)
.. _migrations_python:
Applying migrations in Python
=============================
Once you have a ``Migrations(name)`` collection with one or more migrations registered to it, you can execute them in Python code like this:
.. code-block:: python
from sqlite_utils import Database
db = Database("creatures.db")
migrations.apply(db)
Running ``migrations.apply(db)`` repeatedly is safe. Migrations that already have a matching ``migration_set`` and ``name`` row in ``_sqlite_migrations`` will be skipped.
Migration functions are applied in the order that they were registered. The function name is used as the migration name unless you pass one explicitly:
.. code-block:: python
@migrations(name="001_create_table")
def create_table(db):
db["creatures"].create({"id": int, "name": str}, pk="id")
When you apply a set of migrations you can stop part way through by specifying a ``stop_before=`` migration name:
.. code-block:: python
migrations.apply(db, stop_before="add_weight")
.. _migrations_transactions:
Migrations and transactions
===========================
Each migration runs inside a transaction, together with the ``_sqlite_migrations`` record of it having been applied. If a migration function raises an exception, everything it did is rolled back, no record is written and the migration stays pending - so fixing the error and re-applying will run that migration again from a clean state. Migrations that completed earlier in the same ``apply()`` run stay applied.
Some operations cannot run inside a transaction, for example ``VACUUM`` or changing the journal mode with ``db.enable_wal()``. Register migrations like these with ``transactional=False``:
.. code-block:: python
@migrations(transactional=False)
def compact(db):
db.execute("VACUUM")
A migration registered with ``transactional=False`` runs without a wrapping transaction, so if it fails part way through any changes it already made will not be rolled back, and re-applying will run the whole function again.
Avoid calling ``db.commit()`` or otherwise managing transactions manually inside a transactional migration - register the migration with ``transactional=False`` if it needs to control its own transactions. Using ``with db.atomic():`` blocks inside a migration is fine: they nest as savepoints within the migration's transaction, so the migration as a whole still commits or rolls back as a single unit. See :ref:`python_api_transactions`.
Applying migrations using the CLI
=================================
Run migrations using the ``sqlite-utils migrate`` command:
.. code-block:: bash
sqlite-utils migrate creatures.db path/to/migrations.py
The first argument is the database file. The remaining arguments can be paths to migration files or directories containing migration files.
If you omit migration paths, ``sqlite-utils`` searches the current directory and subdirectories for files called ``migrations.py``:
.. code-block:: bash
sqlite-utils migrate creatures.db
You can also pass a directory. Every ``migrations.py`` file in that directory tree will be considered:
.. code-block:: bash
sqlite-utils migrate creatures.db path/to/project/
Running the command repeatedly is safe. Migrations that already have a matching ``migration_set`` and ``name`` row in ``_sqlite_migrations`` will be skipped.
Listing migrations
==================
Use ``--list`` to show applied and pending migrations without running them. This is a read-only operation - it will not create the database file or the ``_sqlite_migrations`` table:
.. code-block:: bash
sqlite-utils migrate creatures.db --list
Example output:
.. code-block:: output
Migrations for: creatures
Applied:
create_table - 2026-06-09 17:23:12.048092+00:00
add_weight - 2026-06-09 17:23:12.051249+00:00
Pending:
add_age
Stopping before a migration
===========================
When applying migrations using the CLI, you can stop before a named migration:
.. code-block:: bash
sqlite-utils migrate creatures.db path/to/migrations.py --stop-before add_weight
This applies any pending migrations before ``add_weight`` and leaves ``add_weight`` and later migrations pending. An unqualified migration name matches in any migration set.
You can also target a specific migration set using ``migration_set:migration_name``. This is useful if a migrations file contains more than one migration set, or if multiple sets use the same migration name:
.. code-block:: bash
sqlite-utils migrate creatures.db path/to/migrations.py \
--stop-before creatures:add_weight \
--stop-before sales:drop_index
The ``--stop-before`` option can be passed more than once.
If a ``--stop-before`` value does not match any known migration the command exits with an error, rather than silently applying everything. Naming a migration that has already been applied is also an error - stopping before it is impossible to honor - and no pending migrations are applied.
Verbose output
==============
Use ``--verbose`` or ``-v`` to show the schema before and after migrations are applied, plus a unified diff when the schema changes:
.. code-block:: bash
sqlite-utils migrate creatures.db --verbose
Migrating from sqlite-migrate
=============================
This system uses the same migration table format as the older `sqlite-migrate <https://github.com/simonw/sqlite-migrate>`__ package. To use existing migration files directly with ``sqlite-utils``, update their import from ``sqlite_migrate`` to ``sqlite_utils``:
.. code-block:: python
from sqlite_utils import Migrations
migration = Migrations("creatures")
@migration()
def create_table(db):
db["creatures"].create({"id": int, "name": str}, pk="id")
Python API
==========
.. autoclass:: sqlite_utils.migrations.Migrations
:members:
:undoc-members:
:exclude-members: _Migration, _AppliedMigration

View file

@ -140,9 +140,7 @@ As a special niche feature, if your plugin needs to import some files and then a
prepare_connection(conn)
~~~~~~~~~~~~~~~~~~~~~~~~
This hook is called when a new SQLite database connection is created. You can
use it to `register custom SQL functions <https://docs.python.org/2/library/sqlite3.html#sqlite3.Connection.create_function>`_,
aggregates and collations. For example:
This hook is called when a new SQLite database connection is created. You can use it to `register custom SQL functions <https://docs.python.org/2/library/sqlite3.html#sqlite3.Connection.create_function>`_, aggregates and collations. For example:
.. code-block:: python
@ -154,8 +152,7 @@ aggregates and collations. For example:
"hello", 1, lambda name: f"Hello, {name}!"
)
This registers a SQL function called ``hello`` which takes a single
argument and can be called like this:
This registers a SQL function called ``hello`` which takes a single argument and can be called like this:
.. code-block:: sql

View file

@ -33,6 +33,8 @@ Here's how to create a new SQLite database file containing a new ``chickens`` ta
"color": "black",
}])
The inserted rows are saved to the database file straight away - methods like ``insert_all()`` commit their own changes, so no ``commit()`` call is needed. See :ref:`python_api_transactions` for how this works.
You can loop through those rows like this:
.. code-block:: python
@ -93,6 +95,8 @@ Instead of a file path you can pass in an existing SQLite connection:
db = Database(sqlite3.connect("my_database.db"))
The connection must use Python's default transaction handling. Connections created with the Python 3.12+ ``sqlite3.connect(..., autocommit=True)`` or ``autocommit=False`` options are rejected with a ``sqlite_utils.db.TransactionError`` - see :ref:`python_api_transactions_modes`.
If you want to create an in-memory database, you can do so like this:
.. code-block:: python
@ -105,6 +109,14 @@ You can also create a named in-memory database. Unlike regular memory databases
db = Database(memory_name="my_shared_database")
After creating a ``Database`` you can use ``db.memory`` and ``db.memory_name`` to tell whether it is backed by an in-memory database and to read the shared cache name. ``db.memory`` is ``True`` for any in-memory database and ``db.memory_name`` holds the name passed to ``memory_name=``, or ``None`` otherwise.
.. code-block:: python
db = Database(memory_name="shared")
db.memory # True
db.memory_name # "shared"
Connections use ``PRAGMA recursive_triggers=on`` by default. If you don't want to use `recursive triggers <https://www.sqlite.org/pragma.html#pragma_recursive_triggers>`__ you can turn them off using:
.. code-block:: python
@ -123,6 +135,33 @@ You can pass ``strict=True`` to enable `SQLite STRICT mode <https://www.sqlite.o
db = Database("my_database.db", strict=True)
.. _python_api_close:
Closing a database
------------------
Database objects maintain a connection to the underlying SQLite database. You can explicitly close this connection using the ``.close()`` method:
.. code-block:: python
db = Database("my_database.db")
# ... use the database ...
db.close()
The ``Database`` object also works as a context manager, which will automatically close the connection when the ``with`` block exits:
.. code-block:: python
with Database("my_database.db") as db:
db["my_table"].insert({"name": "Example"})
# Connection is automatically closed here
Exiting the block is equivalent to calling ``db.close()``: the connection is closed and any transaction still open at that point is rolled back. This matches SQLite's own behavior when a connection closes.
This rarely matters in practice. Everything that writes to the database - including raw ``db.execute()`` statements - commits automatically, so a transaction can only be open here if you explicitly started one with ``db.begin()`` and have not yet committed it. In that case the decision to commit stays with you: committing automatically on exit could silently persist half-finished work, for example if your code returned early from the block. Call ``db.commit()`` when the work is complete.
Note this differs from the ``sqlite3.Connection`` context manager in the standard library, which commits on success but does not close the connection. See :ref:`python_api_transactions` for the full transaction model.
.. _python_api_attach:
Attaching additional databases
@ -145,6 +184,9 @@ You can attach an additional database using the ``.attach()`` method, providing
You can reference tables in the attached database using the alias value you passed to ``db.attach(alias, filepath)`` as a prefix, for example the ``second.table_in_second`` reference in the SQL query above.
.. note::
In the CLI: :ref:`sqlite-utils --attach <cli_query_attach>`
.. _python_api_tracing:
Tracing queries
@ -198,6 +240,26 @@ The ``db.query(sql)`` function executes a SQL query and returns an iterator over
# {'name': 'Cleo'}
# {'name': 'Pancakes'}
The SQL query is executed as soon as ``db.query()`` is called. The resulting rows are fetched lazily as you iterate, so large result sets are not loaded into memory all at once. Because execution is immediate, an error in your SQL will raise an exception straight away, and a statement such as ``INSERT ... RETURNING`` will take effect - and be committed, unless a transaction is open - even if you do not iterate over its results.
``db.query()`` can only be used with SQL that returns rows. Passing a statement that returns no rows - an ``INSERT`` or ``UPDATE`` without a ``RETURNING`` clause, for example - will raise a ``ValueError``. The rejected statement is rolled back, so it has no effect on the database. Use :ref:`db.execute() <python_api_execute>` for those statements instead.
There is one exception to the rolled-back guarantee: a ``PRAGMA`` statement that returns no rows, such as ``PRAGMA user_version = 5``, still raises a ``ValueError`` but will already have taken effect. Some PRAGMA statements refuse to run inside a transaction, so PRAGMAs are executed outside the savepoint that is used to roll back other rejected statements. Use ``db.execute()`` for PRAGMA statements that do not return rows.
If a query returns more than one column with the same name - a join between two tables that share column names, for example - later occurrences are renamed with a numeric suffix, so every value is included in the dictionary:
.. code-block:: python
row = next(db.query("select 1 as id, 2 as id, 3 as id"))
print(row)
# Outputs:
# {'id': 1, 'id_2': 2, 'id_3': 3}
A suffix that would collide with another column in the query is skipped - ``select 1 as id, 2 as id, 3 as id_2`` returns ``{'id': 1, 'id_3': 2, 'id_2': 3}``. The same renaming is applied by ``table.rows_where()`` and ``table.search()``.
.. note::
In the CLI: :ref:`sqlite-utils query <cli_query>`
.. _python_api_execute:
db.execute(sql, params)
@ -218,6 +280,9 @@ The ``db.execute()`` and ``db.executescript()`` methods provide wrappers around
Other cursor methods such as ``.fetchone()`` and ``.fetchall()`` are also available, see the `standard library documentation <https://docs.python.org/3/library/sqlite3.html#sqlite3.Cursor>`__.
.. note::
Write statements executed this way are committed automatically, unless a transaction is already open in which case they become part of it - see :ref:`python_api_transactions_execute`.
.. _python_api_parameters:
Passing parameters
@ -246,6 +311,146 @@ Named parameters using ``:name`` can be filled using a dictionary:
In this example ``next()`` is used to retrieve the first result in the iterator returned by the ``db.query()`` method.
.. _python_api_transactions:
Transactions and saving your changes
====================================
Every method in this library that writes to the database - ``insert()``, ``upsert()``, ``update()``, ``delete()``, ``delete_where()``, ``transform()``, ``create_table()``, ``create_index()``, ``enable_fts()`` and the rest - runs inside its own transaction and commits it before returning. Your changes are saved to disk as soon as the method call finishes:
.. code-block:: python
db = Database("data.db")
db.table("news").insert({"headline": "Dog wins award"})
# The new row is already saved - no commit() required
The same applies to raw SQL executed with :ref:`db.execute() <python_api_transactions_execute>` - a write statement is committed as soon as it has run.
Another way to think about this is that each sqlite-utils method call is its own unit of work. If several method calls must either all succeed or all fail, use ``db.atomic()`` to turn them into a single unit of work.
You never need to call ``commit()``, and you do not need to close the database to persist your changes. There are exactly two situations where you need to think about transactions:
1. You want to group several write operations together, so they either all succeed or all fail - use :ref:`db.atomic() <python_api_atomic>`.
2. You are :ref:`managing a transaction yourself <python_api_transactions_manual>` with ``db.begin()``, in which case nothing is committed until you commit - the library will never commit a transaction you opened.
``with Database(...) as db:`` is not a transaction block. It manages the lifetime of the database connection and closes it on exit. Use ``with db.atomic():`` for a transaction.
.. _python_api_atomic:
Grouping changes with db.atomic()
---------------------------------
Use ``db.atomic()`` to group multiple operations in a single transaction:
.. code-block:: python
with db.atomic():
db.table("dogs").insert({"id": 1, "name": "Cleo"}, pk="id")
db.table("dogs").insert({"id": 2, "name": "Pancakes"})
The transaction commits when the block exits. If an exception is raised, changes made inside the block will be rolled back.
This matters when several operations represent a single logical change. Without ``db.atomic()``, an earlier method call remains committed if a later one fails:
.. code-block:: python
# These are two separate transactions
db.table("accounts").update(1, {"balance": 90})
db.table("accounts").update(2, {"balance": 110})
# These updates either both succeed or both fail
with db.atomic():
db.table("accounts").update(1, {"balance": 90})
db.table("accounts").update(2, {"balance": 110})
Transactions can also improve performance. Calling ``insert()`` repeatedly outside ``db.atomic()`` creates and commits a separate transaction for every call. For bulk inserts, prefer :ref:`insert_all() <python_api_bulk_inserts>`. If you need to call several different methods in a loop, wrap the loop in ``db.atomic()``:
.. code-block:: python
with db.atomic():
for row in rows:
db.table("events").insert(row)
``db.atomic()`` can be nested. Nested blocks use SQLite savepoints, so an exception in an inner block can roll back to that savepoint without rolling back the entire outer transaction:
.. code-block:: python
with db.atomic():
db.table("dogs").insert({"id": 1, "name": "Cleo"}, pk="id")
try:
with db.atomic():
db.table("dogs").insert({"id": 2, "name": "Pancakes"})
raise ValueError("skip this one")
except ValueError:
pass
db.table("dogs").insert({"id": 3, "name": "Marnie"})
The transaction is opened with a deferred ``BEGIN`` - SQLite takes the necessary locks when the first statement inside the block runs.
.. _python_api_transactions_execute:
Raw SQL writes with db.execute()
--------------------------------
Write statements executed with :ref:`db.execute() <python_api_execute>` follow the same rule as everything else: they are committed automatically as soon as they have run.
.. code-block:: python
db.execute("insert into news (headline) values (?)", ["Dog wins award"])
# Already committed
``db.execute()`` participates in sqlite-utils transaction handling. Calling ``db.conn.execute()`` directly bypasses that policy and leaves transaction handling to Python's underlying ``sqlite3.Connection``. Prefer ``db.execute()`` unless you deliberately need the lower-level API.
If a transaction is open - because the call happens inside a ``db.atomic()`` block, or after ``db.begin()`` - the statement becomes part of that transaction instead, and commits when the transaction commits:
.. code-block:: python
with db.atomic():
db.execute("insert into news (headline) values (?)", ["Dog wins award"])
db.execute("insert into news (headline) values (?)", ["Cat unimpressed"])
# Both rows committed together
One corner case: a row-returning write such as ``INSERT ... RETURNING`` executed through ``db.execute()`` cannot be auto-committed, because its rows have not been read yet - call ``db.commit()`` after fetching them, or use :ref:`db.query() <python_api_query>` for those statements, which executes the write and commits it immediately.
.. _python_api_transactions_manual:
Managing transactions yourself
------------------------------
You can take full manual control using the ``db.begin()``, ``db.commit()`` and ``db.rollback()`` methods:
.. code-block:: python
db.begin()
db.table("news").insert({"headline": "Dog wins award"})
if all_looks_good:
db.commit()
else:
db.rollback()
``db.begin()`` raises ``sqlite3.OperationalError`` if a transaction is already open. ``db.commit()`` and ``db.rollback()`` do nothing if there is no open transaction.
The library will never commit a transaction you opened. If you call write methods such as ``insert()`` - or use ``db.atomic()`` - while your transaction is open, they participate in it using SQLite savepoints instead of committing: exiting an ``atomic()`` block releases its savepoint, but nothing is saved to disk until you commit the outer transaction yourself. If you roll back, their changes are rolled back too.
Prefer ``db.atomic()`` or ``db.begin()``, ``db.commit()`` and ``db.rollback()`` over mixing sqlite-utils transaction methods with calls to ``db.conn.commit()``, ``db.conn.rollback()`` or raw transaction-control SQL. Mixing the two layers makes it much harder to tell which layer owns the current transaction.
Some related safeguards to be aware of:
- ``db.enable_wal()`` and ``db.disable_wal()`` raise a ``sqlite_utils.db.TransactionError`` if called while a transaction is open, because changing the journal mode would commit it as a side effect.
- ``table.transform()`` raises a ``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, because the pragma cannot be turned off mid-transaction to protect those referencing rows - see :ref:`python_api_transform_foreign_keys_transactions`.
- Closing the database - explicitly with ``db.close()``, or by exiting a ``with Database(...) as db:`` block - rolls back any transaction that is still open, see :ref:`python_api_close`.
.. _python_api_transactions_modes:
Supported connection modes
--------------------------
``db.atomic()`` and the automatic per-method transactions currently require a connection using Python's legacy transaction control mode (``sqlite3.LEGACY_TRANSACTION_CONTROL`` on Python 3.12 and later). Passing a connection created with the Python 3.12+ ``sqlite3.connect(..., autocommit=True)`` or ``autocommit=False`` options to ``Database()`` raises a ``sqlite_utils.db.TransactionError``.
Connections using ``autocommit=False`` are not supported because Python keeps a transaction open continuously. sqlite-utils uses ``Connection.in_transaction`` to distinguish its own transactions from transactions opened by its caller, and that distinction is not available in this mode.
Connections using ``autocommit=True`` are also currently rejected because sqlite-utils has not formally exposed that as a supported configuration.
.. _python_api_table:
Accessing tables
@ -299,6 +504,9 @@ You can also iterate through the table objects themselves using the ``.tables``
>>> db.tables
[<Table dogs>]
.. note::
In the CLI: :ref:`sqlite-utils tables <cli_tables>`
.. _python_api_views:
Listing views
@ -324,6 +532,9 @@ View objects are similar to Table objects, except that any attempts to insert or
* ``rows_where(where, where_args, order_by, select)``
* ``drop()``
.. note::
In the CLI: :ref:`sqlite-utils views <cli_views>`
.. _python_api_rows:
Listing rows
@ -379,6 +590,9 @@ This method also accepts ``offset=`` and ``limit=`` arguments, for specifying an
... print(row)
{'id': 1, 'age': 4, 'name': 'Cleo'}
.. note::
In the CLI: :ref:`sqlite-utils rows <cli_rows>`
.. _python_api_rows_count_where:
Counting rows
@ -463,6 +677,9 @@ The ``db.schema`` property returns the full SQL schema for the database as a str
"name" TEXT
);
.. note::
In the CLI: :ref:`sqlite-utils schema <cli_schema>`
.. _python_api_creating_tables:
Creating tables
@ -611,6 +828,9 @@ You can pass ``strict=True`` to create a table in ``STRICT`` mode:
"name": str,
}, strict=True)
.. note::
In the CLI: :ref:`sqlite-utils create-table <cli_create_table>`
.. _python_api_compound_primary_keys:
Compound primary keys
@ -671,6 +891,61 @@ You can leave off the third item in the tuple to have the referenced column auto
("author_id", "authors")
])
.. _python_api_compound_foreign_keys:
Compound foreign keys
~~~~~~~~~~~~~~~~~~~~~
To create a compound (multi-column) foreign key, use tuples of column names in place of the single column names:
.. code-block:: python
db.table("courses").create({
"course_code": str,
"campus_name": str,
"dept_code": str,
}, pk="course_code", foreign_keys=[
(("campus_name", "dept_code"), "departments", ("campus_name", "dept_code"))
])
This creates a table-level constraint:
.. code-block:: sql
CREATE TABLE "courses" (
"course_code" TEXT PRIMARY KEY,
"campus_name" TEXT,
"dept_code" TEXT,
FOREIGN KEY ("campus_name", "dept_code") REFERENCES "departments"("campus_name", "dept_code")
)
As with single columns, you can leave off the tuple of other columns to reference the compound primary key of the other table:
.. code-block:: python
foreign_keys=[
(("campus_name", "dept_code"), "departments")
]
To specify ``ON DELETE`` or ``ON UPDATE`` actions, pass ``ForeignKey`` objects instead:
.. code-block:: python
from sqlite_utils.db import ForeignKey
db.table("books").create({
"id": int,
"author_id": int,
}, pk="id", foreign_keys=[
ForeignKey(
table="books", column="author_id",
other_table="authors", other_column="id",
on_delete="CASCADE",
)
])
Foreign key actions are preserved by :ref:`table.transform() <python_api_transform>` - prior to sqlite-utils 4.0 they were silently dropped when a table was transformed.
.. _python_api_table_configuration:
Table configuration options
@ -736,6 +1011,9 @@ Here's an example that uses these features:
# )
.. note::
In the CLI: :ref:`sqlite-utils insert --not-null and --default <cli_defaults_not_null>`
.. _python_api_rename_table:
Renaming a table
@ -753,6 +1031,9 @@ This executes the following SQL:
ALTER TABLE [my_table] RENAME TO [new_name_for_my_table]
.. note::
In the CLI: :ref:`sqlite-utils rename-table <cli_renaming_tables>`
.. _python_api_duplicate:
Duplicating tables
@ -768,6 +1049,9 @@ The new ``authors_copy`` table will now contain a duplicate copy of the data fro
This method raises ``sqlite_utils.db.NoTable`` if the table does not exist.
.. note::
In the CLI: :ref:`sqlite-utils duplicate <cli_duplicate_table>`
.. _python_api_bulk_inserts:
Bulk inserts
@ -810,6 +1094,9 @@ You can delete all the existing rows in the table before inserting the new recor
Pass ``analyze=True`` to run ``ANALYZE`` against the table after inserting the new records.
.. note::
In the CLI: :ref:`sqlite-utils insert <cli_inserting_data>`
.. _python_api_insert_lists:
Inserting data from a list or tuple iterator
@ -887,6 +1174,9 @@ To replace any existing records that have a matching primary key, use the ``repl
.. note::
Prior to sqlite-utils 2.0 the ``.upsert()`` and ``.upsert_all()`` methods worked the same way as ``.insert(replace=True)`` does today. See :ref:`python_api_upsert` for the new behaviour of those methods introduced in 2.0.
.. note::
In the CLI: :ref:`sqlite-utils insert --replace <cli_insert_replace>`
.. _python_api_update:
Updating a specific record
@ -934,8 +1224,7 @@ You can delete all records in a table that match a specific WHERE statement usin
>>> db = sqlite_utils.Database("dogs.db")
>>> # Delete every dog with age less than 3
>>> with db.conn:
>>> db.table("dogs").delete_where("age < ?", [3])
>>> db.table("dogs").delete_where("age < ?", [3])
Calling ``table.delete_where()`` with no other arguments will delete every row in the table.
@ -968,9 +1257,14 @@ Note that the ``pk`` and ``column_order`` parameters here are optional if you ar
An ``upsert_all()`` method is also available, which behaves like ``insert_all()`` but performs upserts instead.
Every record passed to ``upsert()`` or ``upsert_all()`` must include a value for each primary key column - a record without one could never match an existing row, so a ``sqlite_utils.db.PrimaryKeyRequired`` exception is raised instead of quietly inserting a new row.
.. note::
``.upsert()`` and ``.upsert_all()`` in sqlite-utils 1.x worked like ``.insert(..., replace=True)`` and ``.insert_all(..., replace=True)`` do in 2.x. See `issue #66 <https://github.com/simonw/sqlite-utils/issues/66>`__ for details of this change.
.. note::
In the CLI: :ref:`sqlite-utils upsert <cli_upsert>`
.. _python_api_old_upsert:
Alternative upserts using INSERT OR IGNORE
@ -1066,6 +1360,8 @@ To create a species record with a note on when it was first seen, you can use th
The first time this is called the record will be created for ``name="Palm"``. Any subsequent calls with that name will ignore the second argument, even if it includes different values.
``None`` values are matched correctly: calling ``.lookup()`` a second time with the same values will return the primary key of the existing row even if some of those values are ``None``.
``.lookup()`` also accepts keyword arguments, which are passed through to the :ref:`insert() method <python_api_creating_tables>` and can be used to influence the shape of the created table. Supported parameters are:
- ``pk`` - which defaults to ``id``
@ -1111,6 +1407,8 @@ To extract the ``species`` column out to a separate ``Species`` table, you can d
"species": "Common Juniper"
}, extracts={"species": "Species"})
``None`` values are not extracted: no record is created for them in the lookup table and the column value stays ``null``.
.. _python_api_m2m:
Working with many-to-many relationships
@ -1318,6 +1616,9 @@ You can set a ``NOT NULL DEFAULT 'x'`` constraint on the new column using ``not_
db.table("dogs").add_column("friends_count", int, not_null_default=0)
.. note::
In the CLI: :ref:`sqlite-utils add-column <cli_add_column>`
.. _python_api_add_column_alter:
Adding columns automatically on insert/update
@ -1341,6 +1642,9 @@ You can insert or update data that includes new columns and have the table autom
new_table = db.table("new_table", alter=True)
new_table.insert({"name": "Gareth", "age": 32, "shoe_size": 11})
.. note::
In the CLI: :ref:`sqlite-utils insert --alter <cli_add_column_alter>`
.. _python_api_add_foreign_key:
Adding foreign key constraints
@ -1379,6 +1683,29 @@ To ignore the case where the key already exists, use ``ignore=True``:
db.table("books").add_foreign_key("author_id", "authors", "id", ignore=True)
To add a compound foreign key, pass tuples of columns:
.. code-block:: python
db.table("courses").add_foreign_key(
("campus_name", "dept_code"), "departments", ("campus_name", "dept_code")
)
As with single columns, omitting the other columns will use the compound primary key of the other table. ``other_table`` must always be specified for a compound foreign key.
Use ``on_delete=`` and ``on_update=`` to specify ``ON DELETE`` and ``ON UPDATE`` actions for the foreign key:
.. code-block:: python
db.table("books").add_foreign_key(
"author_id", "authors", "id", on_delete="CASCADE"
)
This creates a foreign key with an ``ON DELETE CASCADE`` clause, so deleting an author will also delete their books (provided foreign key enforcement is enabled with ``PRAGMA foreign_keys = ON``). Valid actions are ``"SET NULL"``, ``"SET DEFAULT"``, ``"CASCADE"``, ``"RESTRICT"`` and the default ``"NO ACTION"``.
.. note::
In the CLI: :ref:`sqlite-utils add-foreign-key <cli_add_foreign_key>`
.. _python_api_add_foreign_keys:
Adding multiple foreign key constraints at once
@ -1397,6 +1724,11 @@ Here's an example adding two foreign keys at once:
This method runs the same checks as ``.add_foreign_keys()`` and will raise ``sqlite_utils.db.AlterError`` if those checks fail.
Foreign keys that already exist are silently skipped, so repeated calls are idempotent - but only if they match exactly. Requesting a foreign key that exists with different ``ON DELETE``/``ON UPDATE`` actions raises ``AlterError``: use ``table.transform()`` to change the actions of an existing foreign key.
.. note::
In the CLI: :ref:`sqlite-utils add-foreign-keys <cli_add_foreign_keys>`
.. _python_api_index_foreign_keys:
Adding indexes for all foreign keys
@ -1408,6 +1740,11 @@ If you want to ensure that every foreign key column in your database has a corre
db.index_foreign_keys()
Compound foreign keys get a single composite index across their columns.
.. note::
In the CLI: :ref:`sqlite-utils index-foreign-keys <cli_index_foreign_keys>`
.. _python_api_drop:
Dropping a table or view
@ -1429,6 +1766,9 @@ Pass ``ignore=True`` if you want to ignore the error caused by the table or view
db.table("my_table").drop(ignore=True)
.. note::
In the CLI: :ref:`sqlite-utils drop-table <cli_drop_table>` and :ref:`sqlite-utils drop-view <cli_drop_view>`
.. _python_api_transform:
Transforming a table
@ -1457,6 +1797,9 @@ To keep the original table around instead of dropping it, pass the ``keep_table=
This method raises a ``sqlite_utils.db.TransformError`` exception if the table cannot be transformed, usually because there are existing constraints or indexes that are incompatible with modifications to the columns.
.. note::
In the CLI: :ref:`sqlite-utils transform <cli_transform_table>`
.. _python_api_transform_alter_column_types:
Altering column types
@ -1471,6 +1814,29 @@ To alter the type of a column, use the ``types=`` argument:
See :ref:`python_api_add_column` for a list of available types.
.. _python_api_transform_strict:
Changing strict mode
--------------------
The optional ``strict=`` parameter can change whether a table uses `SQLite STRICT mode <https://www.sqlite.org/stricttables.html>`__. Pass ``strict=True`` to convert a regular table to a strict table:
.. code-block:: python
table.transform(strict=True)
Pass ``strict=False`` to convert a strict table back to a regular non-strict table:
.. code-block:: python
table.transform(strict=False)
The default is ``strict=None``, which preserves the table's existing strict mode.
Passing ``strict=True`` raises ``sqlite_utils.db.TransformError`` if the available SQLite version does not support strict tables.
Converting to a strict table validates all existing rows as they are copied into the replacement table. If a value is incompatible with its declared column type, SQLite raises ``sqlite3.IntegrityError`` and the transformation is rolled back, leaving the original table and its data unchanged.
.. _python_api_transform_rename_columns:
Renaming columns
@ -1610,6 +1976,16 @@ This example drops two foreign keys - the one from ``places.country`` to ``count
drop_foreign_keys=("country", "continent")
)
A bare column name drops any foreign key that column participates in, including compound foreign keys. To target a compound foreign key precisely, pass a tuple of its columns:
.. code-block:: python
db.table("courses").transform(
drop_foreign_keys=[("campus_name", "dept_code")]
)
Renaming a column with ``rename=`` updates any foreign keys that use it, and dropping a column with ``drop=`` also drops any foreign keys it participates in - for a compound foreign key this removes the whole constraint.
.. _python_api_transform_sql:
Custom transformations with .transform_sql()
@ -1621,6 +1997,36 @@ If you want to do something more advanced, you can call the ``table.transform_sq
This method will return a list of SQL statements that should be executed to implement the change. You can then make modifications to that SQL - or add additional SQL statements - before executing it yourself.
.. _python_api_transform_foreign_keys_transactions:
Foreign keys and transactions
-----------------------------
Because ``.transform()`` drops the old table, running it with ``PRAGMA foreign_keys`` enabled could fire ``ON DELETE`` actions on any tables that reference it - an inbound ``ON DELETE CASCADE`` foreign key would silently delete those referencing rows. To prevent this, ``.transform()`` turns ``PRAGMA foreign_keys`` off for the duration of the operation and restores it afterwards, running ``PRAGMA foreign_key_check`` before committing.
``PRAGMA foreign_keys`` cannot be changed inside a transaction, so this protection is impossible if you call ``.transform()`` while a transaction is already open - for example inside a ``with db.atomic():`` block or after ``db.begin()``. If ``PRAGMA foreign_keys`` is on and another table references the table being transformed with a destructive ``ON DELETE`` action - ``CASCADE``, ``SET NULL`` or ``SET DEFAULT`` - the method will refuse to run and raise a ``sqlite_utils.db.TransactionError``:
.. code-block:: python
from sqlite_utils.db import TransactionError
try:
with db.atomic():
db["authors"].transform(types={"id": str})
except TransactionError as ex:
print("Could not transform in transaction:", ex)
To transform such a table either call ``.transform()`` outside of the transaction, or execute ``PRAGMA foreign_keys = off`` before opening it:
.. code-block:: python
db.execute("PRAGMA foreign_keys = off")
with db.atomic():
db["authors"].transform(types={"id": str})
db.execute("PRAGMA foreign_keys = on")
Tables referenced by foreign keys without a destructive action (the default ``NO ACTION``, or ``RESTRICT``) can still be transformed inside a transaction - sqlite-utils uses ``PRAGMA defer_foreign_keys`` to postpone the foreign key checks until the transaction commits.
.. _python_api_extract:
Extracting columns into a separate table
@ -1777,6 +2183,11 @@ This produces a lookup table like so:
"latin" TEXT
)
Rows where every extracted column is ``null`` are not extracted: no record is created for them in the lookup table and their foreign key column is left as ``null``. When extracting multiple columns, rows where at least one of the extracted columns has a value will be extracted as usual.
.. note::
In the CLI: :ref:`sqlite-utils extract <cli_extract>`
.. _python_api_hash:
Setting an ID based on the hash of the row contents
@ -1838,6 +2249,9 @@ You can pass ``ignore=True`` to silently ignore an existing view and do nothing,
select * from dogs where is_good_dog = 1
""", replace=True)
.. note::
In the CLI: :ref:`sqlite-utils create-view <cli_create_view>`
Storing JSON
============
@ -1950,12 +2364,15 @@ The ``db.iterdump()`` method returns a sequence of SQL strings representing a co
This uses the `sqlite3.Connection.iterdump() <https://docs.python.org/3/library/sqlite3.html#sqlite3.Connection.iterdump>`__ method.
If you are using ``pysqlite3`` or ``sqlean.py`` the underlying method may be missing. If you install the `sqlite-dump <https://pypi.org/project/sqlite-dump/>`__ package then the ``db.iterdump()`` method will use that implementation instead:
If you are using ``pysqlite3`` the underlying method may be missing. If you install the `sqlite-dump <https://pypi.org/project/sqlite-dump/>`__ package then the ``db.iterdump()`` method will use that implementation instead:
.. code-block:: bash
pip install sqlite-dump
.. note::
In the CLI: :ref:`sqlite-utils dump <cli_dump>`
.. _python_api_introspection:
Introspecting tables and views
@ -2057,17 +2474,39 @@ Almost all SQLite tables have a ``rowid`` column, but a table with no explicitly
.foreign_keys
-------------
The ``.foreign_keys`` property returns any foreign key relationships for the table, as a list of ``ForeignKey(table, column, other_table, other_column)`` named tuples. It is not available on views.
The ``.foreign_keys`` property returns any foreign key relationships for the table, as a list of ``ForeignKey`` objects. It is not available on views.
Each ``ForeignKey`` has the following attributes:
``table``
The table the foreign key is defined on.
``column``
The column on this table, or ``None`` for a compound foreign key.
``other_table``
The table being referenced.
``other_column``
The referenced column, or ``None`` for a compound foreign key.
``columns``
A tuple of the columns on this table, always populated (a one-item tuple for single-column foreign keys).
``other_columns``
A tuple of the referenced columns.
``is_compound``
``True`` if this is a compound (multi-column) foreign key.
``on_delete``
The ``ON DELETE`` action, e.g. ``"CASCADE"`` - ``"NO ACTION"`` if not set.
``on_update``
The ``ON UPDATE`` action - ``"NO ACTION"`` if not set.
``ForeignKey`` was a ``namedtuple`` prior to sqlite-utils 4.0. It is now a dataclass and can no longer be unpacked or indexed as a tuple - access its fields by name instead. See :ref:`upgrading_3_to_4` for details.
::
>>> db.table("Street_Tree_List").foreign_keys
[ForeignKey(table='Street_Tree_List', column='qLegalStatus', other_table='qLegalStatus', other_column='id'),
ForeignKey(table='Street_Tree_List', column='qCareAssistant', other_table='qCareAssistant', other_column='id'),
ForeignKey(table='Street_Tree_List', column='qSiteInfo', other_table='qSiteInfo', other_column='id'),
ForeignKey(table='Street_Tree_List', column='qSpecies', other_table='qSpecies', other_column='id'),
ForeignKey(table='Street_Tree_List', column='qCaretaker', other_table='qCaretaker', other_column='id'),
ForeignKey(table='Street_Tree_List', column='PlantType', other_table='PlantType', other_column='id')]
[ForeignKey(table='Street_Tree_List', column='qLegalStatus', other_table='qLegalStatus', other_column='id', columns=('qLegalStatus',), other_columns=('id',), is_compound=False, on_delete='NO ACTION', on_update='NO ACTION'),
ForeignKey(table='Street_Tree_List', column='qCareAssistant', other_table='qCareAssistant', other_column='id', columns=('qCareAssistant',), other_columns=('id',), is_compound=False, on_delete='NO ACTION', on_update='NO ACTION'),
...]
Compound foreign keys - defined with ``FOREIGN KEY (col_a, col_b) REFERENCES other(col_a, col_b)`` - are returned as a single ``ForeignKey`` with ``is_compound=True``, ``column`` and ``other_column`` set to ``None``, and the participating columns available in the ``columns`` and ``other_columns`` tuples.
.. _python_api_introspection_schema:
@ -2133,6 +2572,9 @@ The ``.indexes`` property returns all indexes created for a table, as a list of
Index(seq=4, name='"Street_Tree_List_qCaretaker"', unique=0, origin='c', partial=0, columns=['qCaretaker']),
Index(seq=5, name='"Street_Tree_List_PlantType"', unique=0, origin='c', partial=0, columns=['PlantType'])]
.. note::
In the CLI: :ref:`sqlite-utils indexes <cli_indexes>`
.. _python_api_introspection_xindexes:
.xindexes
@ -2176,6 +2618,9 @@ The ``.triggers`` property lists database triggers. It can be used on both datab
>>> db.triggers
... similar output to db.table("authors").triggers
.. note::
In the CLI: :ref:`sqlite-utils triggers <cli_triggers>`
.. _python_api_introspection_triggers_dict:
.triggers_dict
@ -2324,6 +2769,9 @@ To remove the FTS tables and triggers you created, use the ``disable_fts()`` tab
db.table("dogs").disable_fts()
.. note::
In the CLI: :ref:`sqlite-utils enable-fts <cli_fts>`
.. _python_api_quote_fts:
Quoting characters for use in search
@ -2388,6 +2836,9 @@ To return just the title and published columns for three matches for ``"dog"`` w
):
print(article)
.. note::
In the CLI: :ref:`sqlite-utils search <cli_search>`
.. _python_api_fts_search_sql:
Building SQL queries with table.search_sql()
@ -2472,6 +2923,9 @@ This runs the following SQL::
INSERT INTO dogs_fts (dogs_fts) VALUES ("rebuild");
.. note::
In the CLI: :ref:`sqlite-utils rebuild-fts <cli_fts>`
.. _python_api_fts_optimize:
Optimizing a full-text search table
@ -2487,6 +2941,9 @@ This runs the following SQL::
INSERT INTO dogs_fts (dogs_fts) VALUES ("optimize");
.. note::
In the CLI: :ref:`sqlite-utils optimize <cli_optimize>`
.. _python_api_cached_table_counts:
Cached table counts using triggers
@ -2549,6 +3006,9 @@ If the ``_counts`` table ever becomes out-of-sync with the actual table counts y
db.reset_counts()
.. note::
In the CLI: :ref:`sqlite-utils enable-counts <cli_enable_counts>`
.. _python_api_create_index:
Creating indexes
@ -2592,6 +3052,17 @@ Use ``if_not_exists=True`` to do nothing if an index with that name already exis
Pass ``analyze=True`` to run ``ANALYZE`` against the new index after creating it.
You can drop an index from a table using ``.drop_index(index_name)``:
.. code-block:: python
db.table("dogs").drop_index("idx_dogs_name")
Use ``ignore=True`` to ignore the error if the index does not exist.
.. note::
In the CLI: :ref:`sqlite-utils create-index <cli_create_index>` and :ref:`sqlite-utils drop-index <cli_drop_index>`
.. _python_api_analyze:
Optimizing index usage with ANALYZE
@ -2619,6 +3090,9 @@ To run against all indexes attached to a specific table, you can either pass the
db.table("dogs").analyze()
.. note::
In the CLI: :ref:`sqlite-utils analyze <cli_analyze>`
.. _python_api_vacuum:
Vacuum
@ -2630,6 +3104,9 @@ You can optimize your database by running VACUUM against it like so:
Database("my_database.db").vacuum()
.. note::
In the CLI: :ref:`sqlite-utils vacuum <cli_vacuum>`
.. _python_api_wal:
WAL mode
@ -2647,6 +3124,8 @@ You can disable WAL mode using ``.disable_wal()``:
Database("my_database.db").disable_wal()
The journal mode can only be changed outside of a transaction. Calling either method while a transaction is open - inside a ``db.atomic()`` block, for example - raises a ``sqlite_utils.db.TransactionError``, unless the database is already in the requested mode in which case the call is a no-op.
You can check the current journal mode for a database using the ``journal_mode`` property:
.. code-block:: python
@ -2655,6 +3134,9 @@ You can check the current journal mode for a database using the ``journal_mode``
This will usually be ``wal`` or ``delete`` (meaning WAL is disabled), but can have other values - see the `PRAGMA journal_mode <https://www.sqlite.org/pragma.html#pragma_journal_mode>`__ documentation.
.. note::
In the CLI: :ref:`sqlite-utils enable-wal and disable-wal <cli_wal>`
.. _python_api_suggest_column_types:
Suggesting column types
@ -2795,6 +3277,9 @@ You can cause ``sqlite3`` to return more useful errors, including the traceback
sqlite3.enable_callback_tracebacks(True)
.. note::
In the CLI: :ref:`sqlite-utils query --functions <cli_query_functions>`
.. _python_api_quote:
Quoting strings for use in SQL
@ -2927,6 +3412,9 @@ Initialize SpatiaLite
.. automethod:: sqlite_utils.db.Database.init_spatialite
:noindex:
.. note::
In the CLI: :ref:`sqlite-utils create-database --init-spatialite <cli_create_database>`
.. _python_api_gis_find_spatialite:
Finding SpatiaLite
@ -2942,6 +3430,9 @@ Adding geometry columns
.. automethod:: sqlite_utils.db.Table.add_geometry_column
:noindex:
.. note::
In the CLI: :ref:`sqlite-utils add-geometry-column <cli_spatialite>`
.. _python_api_gis_create_spatial_index:
Creating a spatial index
@ -2949,3 +3440,6 @@ Creating a spatial index
.. automethod:: sqlite_utils.db.Table.create_spatial_index
:noindex:
.. note::
In the CLI: :ref:`sqlite-utils create-spatial-index <cli_spatialite_indexes>`

View file

@ -70,6 +70,13 @@ sqlite_utils.db.ColumnDetails
.. autoclass:: sqlite_utils.db.ColumnDetails
.. _reference_db_other_foreign_key:
sqlite_utils.db.ForeignKey
--------------------------
.. autoclass:: sqlite_utils.db.ForeignKey
sqlite_utils.utils
==================

146
docs/upgrading.rst Normal file
View file

@ -0,0 +1,146 @@
.. _upgrading:
===========
Upgrading
===========
This page describes the changes you may need to make to your own code or scripts when upgrading between major versions of ``sqlite-utils``.
For the full list of changes in every release see the :ref:`changelog`.
.. _upgrading_3_to_4:
Upgrading from 3.x to 4.0
=========================
Requirements
------------
- Python 3.10 or higher is required.
- The ``click`` dependency must be version 8.3.1 or later.
Command-line changes
--------------------
**Type detection is now the default for CSV and TSV imports.** ``sqlite-utils insert`` and ``sqlite-utils upsert`` now detect column types when importing CSV or TSV data - previously every column was created as ``TEXT`` unless you passed ``--detect-types``. To restore the old behavior pass the new ``--no-detect-types`` flag:
.. code-block:: bash
sqlite-utils insert data.db rows data.csv --csv --no-detect-types
Two related things have been removed:
- The ``SQLITE_UTILS_DETECT_TYPES`` environment variable.
- The old ``-d/--detect-types`` flag itself. Since detection is now the default the flag did nothing - remove it from any scripts that used it.
**The convert command no longer skips falsey values.** ``sqlite-utils convert`` previously skipped values that evaluated to ``False`` (empty strings, ``0``) unless you passed ``--no-skip-false``. All values are now converted and the ``--no-skip-false`` flag has been removed.
**drop-table and drop-view check the object type.** ``sqlite-utils drop-table`` now refuses to drop a view, and ``drop-view`` refuses to drop a table. Previously each would silently drop the wrong type of object if the name matched. If you relied on that (unlikely), use the matching command instead.
**sqlite-utils tui has moved to a plugin.** The optional terminal interface is now provided by the `sqlite-utils-tui <https://github.com/simonw/sqlite-utils-tui>`__ plugin:
.. code-block:: bash
sqlite-utils install sqlite-utils-tui
Python API changes
------------------
**db.query() now rejects SQL that does not return rows.** This is likely the most common change you will need to make to existing code. ``db.query()`` used to accept any SQL statement - passing one that returns no rows, such as an ``INSERT`` or ``UPDATE`` without a ``RETURNING`` clause or a ``CREATE TABLE``, did nothing at all, silently. Those statements now raise a ``ValueError``, and are rolled back so they have no effect on the database. Transaction control statements (``BEGIN``, ``COMMIT``, ``END``, ``ROLLBACK``, ``SAVEPOINT``, ``RELEASE``) plus ``VACUUM``, ``ATTACH`` and ``DETACH`` are also rejected with a ``ValueError``, without being executed at all. Use ``db.execute()`` for statements that do not return rows:
.. code-block:: python
# 3.x accepted this but silently did nothing:
db.query("update dogs set name = 'Cleopaws'")
# In 4.0 use execute() for SQL that does not return rows:
db.execute("update dogs set name = 'Cleopaws'")
**db.query() executes immediately.** ``db.query(sql)`` previously returned a generator that did not execute the SQL until you started iterating over it. The SQL now runs as soon as the method is called - rows are still fetched lazily, but errors in your SQL raise at the ``db.query()`` call site rather than on first iteration, and a write with a ``RETURNING`` clause takes effect even if you never iterate over its results.
**db.table() no longer returns views.** ``db.table(name)`` now raises a ``sqlite_utils.db.NoTable`` exception if ``name`` is a SQL view. Use the new ``db.view(name)`` method for views:
.. code-block:: python
table = db.table("my_table")
view = db.view("my_view")
``db["name"]`` still returns either a ``Table`` or a ``View`` depending on what exists in the database.
**Upserts use INSERT ... ON CONFLICT.** Upsert operations now use SQLite's ``INSERT ... ON CONFLICT SET`` syntax rather than the previous ``INSERT OR IGNORE`` followed by ``UPDATE``. If your code depends on the old behavior, pass ``use_old_upsert=True`` to the ``Database()`` constructor - see :ref:`python_api_old_upsert`.
**Upsert records must include their primary keys.** ``table.upsert()`` and ``table.upsert_all()`` now raise ``sqlite_utils.db.PrimaryKeyRequired`` if a record is missing a value for any primary key column (or has ``None`` for one). Previously such records were quietly inserted as new rows. Relatedly, ``pk=`` is now optional when the table already exists with a primary key - it is detected automatically.
**Floating point columns are now REAL.** Auto-detected floating point columns are created with the correct SQLite type ``REAL`` instead of ``FLOAT``. Code that inspects column types should expect ``REAL``.
**Generated schemas use double quotes.** Tables created by this library now wrap table and column names in ``"double-quotes"`` where they previously used ``[square-braces]``. If you compare ``table.schema`` strings against expected values you will need to update them.
**table.convert() no longer skips falsey values.** Matching the CLI change above, ``table.convert()`` now converts every value. The ``skip_false`` parameter has been removed - previously it defaulted to ``True``, skipping empty strings and other falsey values.
**Null values are no longer extracted into lookup tables.** ``table.extract()`` and the ``sqlite-utils extract`` command leave rows alone if every extracted column is ``null`` - the new foreign key column is left as ``null`` instead of pointing at an all-``null`` record in the lookup table. The ``extracts=`` insert option similarly keeps ``None`` values as ``null``. Relatedly, ``table.lookup()`` now compares values using ``IS`` so that looking up a value containing ``None`` returns the existing matching row - previously it inserted a duplicate row on every call.
**ensure_autocommit_off() is now ensure_autocommit_on().** The ``db.ensure_autocommit_off()`` context manager has been renamed to ``db.ensure_autocommit_on()``. The old name described the opposite of what the method did: it temporarily puts the connection into driver-level autocommit mode (by setting ``isolation_level = None``), so that statements such as ``PRAGMA journal_mode=wal`` can run outside of an implicit transaction. The behavior is unchanged - update any calls to use the new name.
**View.enable_fts() has been removed.** The ``View`` class previously had an ``enable_fts()`` method that existed only to raise ``NotImplementedError`` - full-text search is not supported for views. Calling it now raises ``AttributeError`` like any other missing method.
**ForeignKey is now a dataclass, not a namedtuple.** The ``ForeignKey`` objects returned by ``table.foreign_keys`` gained new fields - ``columns``, ``other_columns``, ``is_compound``, ``on_delete`` and ``on_update`` - so that compound (multi-column) foreign keys and foreign key actions can be represented. To make room for those fields cleanly ``ForeignKey`` is now a dataclass rather than a ``namedtuple``, so it can no longer be unpacked or indexed as a tuple. Access its fields by name instead:
.. code-block:: python
# 3.x - tuple unpacking, no longer works:
for table, column, other_table, other_column in db["courses"].foreign_keys:
...
# 4.0 - access fields by name:
for fk in db["courses"].foreign_keys:
fk.table, fk.column, fk.other_table, fk.other_column
Attempting the old unpacking or ``fk[0]`` indexing now raises ``TypeError``, so any code using those patterns will fail loudly rather than silently misbehave. Like the old namedtuple, ``ForeignKey`` instances are immutable and hashable - they can be collected into sets and used as dictionary keys. Note that equality now includes the ``on_delete`` and ``on_update`` actions: a ``ForeignKey`` with ``ON DELETE CASCADE`` is not equal to one without.
Compound foreign keys - previously returned as one ``ForeignKey`` per column, misleadingly suggesting several independent single-column keys - are now returned as a single ``ForeignKey`` with ``is_compound=True``. For these the scalar ``column`` and ``other_column`` fields are ``None``; use the ``columns`` and ``other_columns`` tuples instead. Single-column foreign keys are unaffected apart from the class change: ``column``/``other_column`` behave as before and ``columns``/``other_columns`` are one-item tuples.
Two related behavior changes to ``table.transform()``: compound foreign keys now survive a transform (previously they were split into separate single-column keys), and ``ON DELETE``/``ON UPDATE`` actions such as ``ON DELETE CASCADE`` are now preserved (previously they were silently stripped from the schema).
**Validation errors raise ValueError.** Invalid arguments to Python API methods - for example ``create_table()`` with no columns, or ``ignore=True`` together with ``replace=True`` - now raise ``ValueError``. They previously raised ``AssertionError`` from bare ``assert`` statements, which were silently skipped under ``python -O``.
**Transaction behavior is now well-defined.** 4.0 introduces the :ref:`db.atomic() <python_api_atomic>` context manager and uses it consistently for every write operation - the full model is described in :ref:`python_api_transactions`. Changes you may notice:
- Write statements executed with raw ``db.execute()`` calls now commit automatically, unless a transaction is already open in which case they join it. Previously they opened an implicit transaction that nothing committed - if your code used ``db.execute()`` for writes and relied on ``db.conn.rollback()`` to undo them, open an explicit transaction with the new ``db.begin()`` method first.
- Multi-step operations such as ``table.transform()`` no longer commit an existing transaction you have open - they use savepoints inside it instead.
- ``db.enable_wal()`` and ``db.disable_wal()`` raise a ``sqlite_utils.db.TransactionError`` if called while a transaction is open, instead of silently committing it.
- Using ``Database`` as a context manager (``with Database(path) as db:``) closes the connection on exit *without* committing - a transaction you explicitly opened with ``db.begin()`` and did not commit is rolled back.
- ``Database()`` rejects connections created with the Python 3.12+ ``sqlite3.connect(..., autocommit=True)`` or ``autocommit=False`` options, raising ``sqlite_utils.db.TransactionError``. On those connections every write the library made was silently discarded when the connection closed.
Packaging changes
-----------------
- ``sqlite-utils`` now uses ``pyproject.toml`` in place of ``setup.py``.
- ``pip`` is now a runtime dependency, used by the ``sqlite-utils install`` and ``uninstall`` commands.
New features to be aware of
---------------------------
Not breaking changes, but new in 4.0 and worth knowing about when you upgrade:
- A :ref:`database migrations system <migrations>`, incorporating the functionality of the ``sqlite-migrate`` plugin. If you used that plugin, the built-in system reads the same ``_sqlite_migrations`` table - your applied migrations will not run again. Update your migration files to use ``from sqlite_utils import Migrations``.
- :ref:`db.atomic() <python_api_atomic>` for nested transaction support.
- ``table.insert_all()`` and ``table.upsert_all()`` accept an iterator of lists or tuples as an alternative to dictionaries - see :ref:`python_api_insert_lists`.
.. _upgrading_2_to_3:
Upgrading from 2.x to 3.0
=========================
The 3.0 release redesigned search. The breaking changes were minor:
- ``table.search()`` returns a generator of dictionaries, sorted by relevance. It previously returned a list of tuples sorted by ``rowid``.
- The ``-c`` shortcut for ``--csv`` and the ``-f`` shortcut for ``--fmt`` were removed from the CLI - use the full option names.
.. _upgrading_1_to_2:
Upgrading from 1.x to 2.0
=========================
The 2.0 release changed the meaning of *upsert*. In 1.x, ``table.upsert()`` and ``table.upsert_all()`` actually performed ``INSERT OR REPLACE`` operations - entirely replacing the existing row. Since 2.0 an upsert updates only the columns you provide, leaving other columns untouched.
If you want the 1.x behavior, use ``table.insert(..., replace=True)`` or ``table.insert_all(..., replace=True)`` instead.

View file

@ -1,4 +1,32 @@
[mypy]
python_version = 3.10
warn_return_any = False
warn_unused_configs = True
warn_redundant_casts = False
warn_unused_ignores = False
check_untyped_defs = True
disallow_untyped_defs = False
disallow_incomplete_defs = False
no_implicit_optional = True
strict_equality = True
[mypy-pysqlite3,sqlean,sqlite_dump]
ignore_missing_imports = True
[mypy-sqlite_utils.cli]
ignore_errors = True
[mypy-pysqlite3.*]
ignore_missing_imports = True
[mypy-sqlite_dump.*]
ignore_missing_imports = True
[mypy-sqlite_fts4.*]
ignore_missing_imports = True
[mypy-pandas.*]
ignore_missing_imports = True
[mypy-numpy.*]
ignore_missing_imports = True
[mypy-tests.*]
ignore_errors = True

View file

@ -1,6 +1,6 @@
[project]
name = "sqlite-utils"
version = "4.0a1"
version = "4.1.1"
description = "CLI tool and Python library for manipulating SQLite databases"
readme = { file = "README.md", content-type = "text/markdown" }
authors = [
@ -22,12 +22,42 @@ classifiers = [
]
dependencies = [
"click",
"click>=8.3.1",
"click-default-group>=1.2.3",
"pluggy",
"python-dateutil",
"sqlite-fts4",
"tabulate",
"pip",
]
[dependency-groups]
dev = [
"black>=26.3.1",
"click>=8.4.2",
"cogapp",
"hypothesis",
"pytest",
# mypy
"data-science-types",
"mypy",
"types-click",
"types-pluggy",
"types-python-dateutil",
"types-tabulate",
# flake8
"flake8",
"flake8-pyproject",
"ty>=0.0.37",
# For stable cog:
"tabulate>=0.10.0",
]
docs = [
"codespell",
"furo",
"pygments-csv-lexer",
"sphinx-autobuild",
"sphinx-copybutton",
]
[project.urls]
@ -40,36 +70,9 @@ CI = "https://github.com/simonw/sqlite-utils/actions"
[project.scripts]
sqlite-utils = "sqlite_utils.cli:cli"
[project.optional-dependencies]
test = [
"black>=24.1.1",
"cogapp",
"hypothesis",
"pytest",
]
docs = [
"beanbag-docutils>=2.0",
"codespell",
"furo",
"pygments-csv-lexer",
"sphinx-autobuild",
"sphinx-copybutton",
]
mypy = [
"data-science-types",
"mypy",
"types-click",
"types-pluggy",
"types-python-dateutil",
"types-tabulate",
]
flake8 = [
"flake8",
"flake8-pyproject",
]
[build-system]
requires = ["setuptools"]
# setuptools 77+ is needed for the PEP 639 license = "Apache-2.0" expression
requires = ["setuptools>=77"]
build-backend = "setuptools.build_meta"
[tool.flake8]

View file

@ -2,5 +2,6 @@ from .utils import suggest_column_types
from .hookspecs import hookimpl
from .hookspecs import hookspec
from .db import Database
from .migrations import Migrations
__all__ = ["Database", "suggest_column_types", "hookimpl", "hookspec"]
__all__ = ["Database", "Migrations", "suggest_column_types", "hookimpl", "hookspec"]

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,3 +1,6 @@
import sqlite3
import click
from pluggy import HookimplMarker
from pluggy import HookspecMarker
@ -6,10 +9,10 @@ hookimpl = HookimplMarker("sqlite_utils")
@hookspec
def register_commands(cli):
def register_commands(cli: click.Group) -> None:
"""Register additional CLI commands, e.g. 'sqlite-utils mycommand ...'"""
@hookspec
def prepare_connection(conn):
def prepare_connection(conn: sqlite3.Connection) -> None:
"""Modify SQLite connection in some way e.g. register custom SQL functions"""

177
sqlite_utils/migrations.py Normal file
View file

@ -0,0 +1,177 @@
from collections.abc import Iterable
from dataclasses import dataclass
import datetime
from typing import Callable, cast, TYPE_CHECKING
if TYPE_CHECKING:
from sqlite_utils.db import Database, Table
class Migrations:
migrations_table = "_sqlite_migrations"
@dataclass
class _Migration:
name: str
fn: Callable
transactional: bool = True
@dataclass
class _AppliedMigration:
name: str
# A string timestamp such as "2026-07-04 12:00:00.000000+00:00" -
# stored as TEXT in the _sqlite_migrations table
applied_at: str
def __init__(self, name: str):
"""
:param name: The name of the migration set. This should be unique.
"""
self.name = name
self._migrations: list[Migrations._Migration] = []
def __call__(
self, *, name: str | None = None, transactional: bool = True
) -> Callable:
"""
:param name: The name to use for this migration - if not provided,
the name of the function will be used.
:param transactional: If ``True`` (the default) the migration and the
record of it having been applied are wrapped in a transaction, which
will be rolled back if the migration raises an exception. Pass
``False`` for migrations that cannot run inside a transaction, for
example those that execute ``VACUUM``.
"""
def inner(func: Callable) -> Callable:
migration_name = name or getattr(func, "__name__")
if any(m.name == migration_name for m in self._migrations):
raise ValueError(
"Migration '{}' is already registered in set '{}'".format(
migration_name, self.name
)
)
self._migrations.append(
self._Migration(migration_name, func, transactional)
)
return func
return inner
def pending(self, db: "Database") -> list["Migrations._Migration"]:
"""
Return a list of pending migrations.
This is a read-only operation - it does not write to the database.
"""
already_applied = {migration.name for migration in self.applied(db)}
return [
migration
for migration in self._migrations
if migration.name not in already_applied
]
def applied(self, db: "Database") -> list["Migrations._AppliedMigration"]:
"""
Return a list of applied migrations, in the order they were applied.
This is a read-only operation - it does not write to the database.
"""
table = _table(db, self.migrations_table)
if not table.exists():
return []
return [
self._AppliedMigration(name=row["name"], applied_at=row["applied_at"])
for row in table.rows_where(
"migration_set = ?", [self.name], order_by="rowid"
)
]
def apply(self, db: "Database", *, stop_before: str | Iterable[str] | None = None):
"""
Apply any pending migrations to the database.
Each migration runs inside a transaction, together with the record of
it having been applied - if the migration raises an exception its
changes are rolled back, no record is written and the migration stays
pending. Migrations registered with ``transactional=False`` run
outside of a transaction.
:raises ValueError: if a ``stop_before`` name matches a migration in
this set that has already been applied - stopping before it is
impossible to honor, and no pending migrations are applied
"""
if stop_before is None:
stop_before_names = set()
elif isinstance(stop_before, str):
stop_before_names = {stop_before}
else:
stop_before_names = set(stop_before)
# A stop_before naming an already-applied migration cannot be
# honored - error rather than applying everything after it. Names
# not in this set at all are ignored, because unqualified CLI
# values are offered to every migration set
already_applied = stop_before_names.intersection(
migration.name for migration in self.applied(db)
)
if already_applied:
raise ValueError(
"Cannot stop before migration{} {} in set '{}' - already "
"been applied".format(
"s" if len(already_applied) > 1 else "",
", ".join(sorted(already_applied)),
self.name,
)
)
self.ensure_migrations_table(db)
for migration in self.pending(db):
name = migration.name
if name in stop_before_names:
return
if migration.transactional:
with db.atomic():
migration.fn(db)
self._record_applied(db, name)
else:
migration.fn(db)
self._record_applied(db, name)
def _record_applied(self, db: "Database", name: str):
_table(db, self.migrations_table).insert(
{
"migration_set": self.name,
"name": name,
"applied_at": str(datetime.datetime.now(datetime.timezone.utc)),
}
)
def ensure_migrations_table(self, db: "Database"):
"""
Ensure the _sqlite_migrations table exists and has the correct schema.
"""
table = _table(db, self.migrations_table)
if not table.exists():
table.create(
{
"id": int,
"migration_set": str,
"name": str,
"applied_at": str,
},
pk="id",
)
table.create_index(["migration_set", "name"], unique=True)
elif table.pks != ["id"]:
table.transform(pk="id")
unique_indexes = {tuple(index.columns) for index in table.indexes}
if ("migration_set", "name") not in unique_indexes:
table.create_index(["migration_set", "name"], unique=True)
def __repr__(self):
return "<Migrations '{}': [{}]>".format(
self.name, ", ".join(m.name for m in self._migrations)
)
def _table(db: "Database", name: str) -> "Table":
return cast("Table", db[name])

View file

@ -1,22 +1,31 @@
from typing import Dict, List, Union
import pluggy
import sys
from . import hookspecs
pm = pluggy.PluginManager("sqlite_utils")
pm: pluggy.PluginManager = pluggy.PluginManager("sqlite_utils")
pm.add_hookspecs(hookspecs)
_plugins_loaded = False
if not getattr(sys, "_called_from_test", False):
# Only load plugins if not running tests
def ensure_plugins_loaded() -> None:
global _plugins_loaded
if _plugins_loaded or getattr(sys, "_called_from_test", False):
return
pm.load_setuptools_entrypoints("sqlite_utils")
_plugins_loaded = True
def get_plugins():
plugins = []
def get_plugins() -> List[Dict[str, Union[str, List[str]]]]:
ensure_plugins_loaded()
plugins: List[Dict[str, Union[str, List[str]]]] = []
plugin_to_distinfo = dict(pm.list_plugin_distinfo())
for plugin in pm.get_plugins():
plugin_info = {
hookcallers = pm.get_hookcallers(plugin) or []
plugin_info: Dict[str, Union[str, List[str]]] = {
"name": plugin.__name__,
"hooks": [h.name for h in pm.get_hookcallers(plugin)],
"hooks": [h.name for h in hookcallers],
}
distinfo = plugin_to_distinfo.get(plugin)
if distinfo:

View file

@ -1,11 +1,20 @@
from __future__ import annotations
from typing import Callable, Optional
from dateutil import parser
import json
IGNORE = object()
SET_NULL = object()
IGNORE: object = object()
SET_NULL: object = object()
def parsedate(value, dayfirst=False, yearfirst=False, errors=None):
def parsedate(
value: str,
dayfirst: bool = False,
yearfirst: bool = False,
errors: Optional[object] = None,
) -> Optional[str]:
"""
Parse a date and convert it to ISO date format: yyyy-mm-dd
\b
@ -31,7 +40,12 @@ def parsedate(value, dayfirst=False, yearfirst=False, errors=None):
raise
def parsedatetime(value, dayfirst=False, yearfirst=False, errors=None):
def parsedatetime(
value: str,
dayfirst: bool = False,
yearfirst: bool = False,
errors: Optional[object] = None,
) -> Optional[str]:
"""
Parse a datetime and convert it to ISO datetime format: yyyy-mm-ddTHH:MM:SS
\b
@ -53,7 +67,9 @@ def parsedatetime(value, dayfirst=False, yearfirst=False, errors=None):
raise
def jsonsplit(value, delimiter=",", type=str):
def jsonsplit(
value: str, delimiter: str = ",", type: Callable[[str], object] = str
) -> str:
"""
Convert a string like a,b,c into a JSON array ["a", "b", "c"]
"""

View file

@ -3,26 +3,44 @@ import contextlib
import csv
import enum
import hashlib
import importlib
import io
import itertools
import json
import os
import sys
from . import recipes
from typing import Dict, cast, BinaryIO, Iterable, Optional, Tuple, Type
from typing import (
Any,
BinaryIO,
Callable,
Dict,
Generator,
Iterable,
Iterator,
List,
Optional,
Set,
Tuple,
Type,
TYPE_CHECKING,
TypeVar,
Union,
cast,
)
import click
try:
import pysqlite3 as sqlite3 # noqa: F401
from pysqlite3 import dbapi2 # noqa: F401
from . import recipes
if TYPE_CHECKING:
import sqlite3 # noqa: F401
from sqlite3 import dbapi2 # noqa: F401
OperationalError = dbapi2.OperationalError
except ImportError:
else:
try:
import sqlean as sqlite3 # noqa: F401
from sqlean import dbapi2 # noqa: F401
sqlite3 = importlib.import_module("pysqlite3")
dbapi2 = importlib.import_module("pysqlite3.dbapi2")
OperationalError = dbapi2.OperationalError
except ImportError:
import sqlite3 # noqa: F401
@ -42,8 +60,35 @@ SPATIALITE_PATHS = (
# Mainly so we can restore it if needed in the tests:
ORIGINAL_CSV_FIELD_SIZE_LIMIT = csv.field_size_limit()
# Type alias for row dictionaries - values can be various SQLite-compatible types
RowValue = Union[None, int, float, str, bytes, bool, List[str]]
Row = Dict[str, RowValue]
def maximize_csv_field_size_limit():
T = TypeVar("T")
class _CloseableIterator(Iterator[Row]):
"""Iterator wrapper that closes a file when iteration is complete."""
def __init__(self, iterator: Iterator[Row], closeable: io.IOBase) -> None:
self._iterator = iterator
self._closeable = closeable
def __iter__(self) -> "_CloseableIterator":
return self
def __next__(self) -> Row:
try:
return next(self._iterator)
except StopIteration:
self._closeable.close()
raise
def close(self) -> None:
self._closeable.close()
def maximize_csv_field_size_limit() -> None:
"""
Increase the CSV field size limit to the maximum possible.
"""
@ -86,20 +131,25 @@ def find_spatialite() -> Optional[str]:
return None
def suggest_column_types(records):
all_column_types = {}
def suggest_column_types(
records: Iterable[Dict[str, Any]],
) -> Dict[str, type]:
all_column_types: Dict[str, Set[type]] = {}
for record in records:
for key, value in record.items():
all_column_types.setdefault(key, set()).add(type(value))
return types_for_column_types(all_column_types)
def types_for_column_types(all_column_types):
column_types = {}
def types_for_column_types(
all_column_types: Dict[str, Set[type]],
) -> Dict[str, type]:
column_types: Dict[str, type] = {}
for key, types in all_column_types.items():
# Ignore null values if at least one other type present:
if len(types) > 1:
types.discard(None.__class__)
t: type
if {None.__class__} == types:
t = str
elif len(types) == 1:
@ -121,7 +171,7 @@ def types_for_column_types(all_column_types):
return column_types
def column_affinity(column_type):
def column_affinity(column_type: str) -> type:
# Implementation of SQLite affinity rules from
# https://www.sqlite.org/datatype3.html#determination_of_column_affinity
assert isinstance(column_type, str)
@ -140,38 +190,42 @@ def column_affinity(column_type):
return float
def decode_base64_values(doc):
def decode_base64_values(doc: Dict[str, Any]) -> Dict[str, Any]:
# Looks for '{"$base64": true..., "encoded": ...}' values and decodes them
to_fix = [
k
for k in doc
if isinstance(doc[k], dict)
and doc[k].get("$base64") is True
and "encoded" in doc[k]
and cast(dict, doc[k]).get("$base64") is True
and "encoded" in cast(dict, doc[k])
]
if not to_fix:
return doc
return dict(doc, **{k: base64.b64decode(doc[k]["encoded"]) for k in to_fix})
return dict(
doc, **{k: base64.b64decode(cast(dict, doc[k])["encoded"]) for k in to_fix}
)
class UpdateWrapper:
def __init__(self, wrapped, update):
def __init__(self, wrapped: io.IOBase, update: Callable[[int], None]) -> None:
self._wrapped = wrapped
self._update = update
def __iter__(self):
def __iter__(self) -> Iterator[bytes]:
for line in self._wrapped:
self._update(len(line))
yield line
def read(self, size=-1):
def read(self, size: int = -1) -> bytes:
data = self._wrapped.read(size)
self._update(len(data))
return data
@contextlib.contextmanager
def file_progress(file, silent=False, **kwargs):
def file_progress(
file: io.IOBase, silent: bool = False, **kwargs: object
) -> Generator[Union[io.IOBase, "UpdateWrapper"], None, None]:
if silent:
yield file
return
@ -184,8 +238,8 @@ def file_progress(file, silent=False, **kwargs):
if fileno == 0: # 0 means stdin
yield file
else:
file_length = os.path.getsize(file.name)
with click.progressbar(length=file_length, **kwargs) as bar:
file_length = os.path.getsize(file.name) # type: ignore
with click.progressbar(length=file_length, **kwargs) as bar: # type: ignore
yield UpdateWrapper(file, bar.update)
@ -209,28 +263,30 @@ class RowError(Exception):
def _extra_key_strategy(
reader: Iterable[dict],
reader: Iterable[Dict[Optional[str], object]],
ignore_extras: Optional[bool] = False,
extras_key: Optional[str] = None,
) -> Iterable[dict]:
) -> Iterable[Row]:
# Logic for handling CSV rows with more values than there are headings
for row in reader:
# DictReader adds a 'None' key with extra row values
if None not in row:
yield row
yield cast(Row, row)
elif ignore_extras:
# ignoring row.pop(none) because of this issue:
# https://github.com/simonw/sqlite-utils/issues/440#issuecomment-1155358637
row.pop(None) # type: ignore
yield row
row.pop(None)
yield cast(Row, row)
elif not extras_key:
extras = row.pop(None) # type: ignore
extras = row.pop(None)
raise RowError(
"Row {} contained these extra values: {}".format(row, extras)
)
else:
row[extras_key] = row.pop(None) # type: ignore
yield row
extras_value = row.pop(None)
row_out = cast(Row, row)
row_out[extras_key] = cast(RowValue, extras_value)
yield row_out
def rows_from_file(
@ -240,7 +296,7 @@ def rows_from_file(
encoding: Optional[str] = None,
ignore_extras: Optional[bool] = False,
extras_key: Optional[str] = None,
) -> Tuple[Iterable[dict], Format]:
) -> Tuple[Iterable[Row], Format]:
"""
Load a sequence of dictionaries from a file-like object containing one of four different formats.
@ -299,12 +355,20 @@ def rows_from_file(
reader = csv.DictReader(decoded_fp, dialect=dialect)
else:
reader = csv.DictReader(decoded_fp)
return _extra_key_strategy(reader, ignore_extras, extras_key), Format.CSV
rows = _extra_key_strategy(reader, ignore_extras, extras_key)
return _CloseableIterator(iter(rows), decoded_fp), Format.CSV
elif format == Format.TSV:
rows = rows_from_file(
rows, _ = rows_from_file(
fp, format=Format.CSV, dialect=csv.excel_tab, encoding=encoding
)[0]
return _extra_key_strategy(rows, ignore_extras, extras_key), Format.TSV
)
return (
_extra_key_strategy(
cast(Iterable[Dict[Optional[str], object]], rows),
ignore_extras,
extras_key,
),
Format.TSV,
)
elif format is None:
# Detect the format, then call this recursively
buffered = io.BufferedReader(cast(io.RawIOBase, fp), buffer_size=4096)
@ -326,8 +390,15 @@ def rows_from_file(
buffered, format=Format.CSV, dialect=dialect, encoding=encoding
)
# Make sure we return the format we detected
format = Format.TSV if dialect.delimiter == "\t" else Format.CSV
return _extra_key_strategy(rows, ignore_extras, extras_key), format
detected_format = Format.TSV if dialect.delimiter == "\t" else Format.CSV
return (
_extra_key_strategy(
cast(Iterable[Dict[Optional[str], object]], rows),
ignore_extras,
extras_key,
),
detected_format,
)
else:
raise RowsFromFileError("Bad format")
@ -353,10 +424,10 @@ class TypeTracker:
db["creatures"].transform(types=tracker.types)
"""
def __init__(self):
self.trackers = {}
def __init__(self) -> None:
self.trackers: Dict[str, "ValueTracker"] = {}
def wrap(self, iterator: Iterable[dict]) -> Iterable[dict]:
def wrap(self, iterator: Iterable[Dict[str, Any]]) -> Iterable[Dict[str, Any]]:
"""
Use this to loop through an existing iterator, tracking the column types
as part of the iteration.
@ -379,27 +450,29 @@ class TypeTracker:
class ValueTracker:
def __init__(self):
couldbe: Dict[str, Callable[[object], bool]]
def __init__(self) -> None:
self.couldbe = {key: getattr(self, "test_" + key) for key in self.get_tests()}
@classmethod
def get_tests(cls):
def get_tests(cls) -> List[str]:
return [
key.split("test_")[-1]
for key in cls.__dict__.keys()
if key.startswith("test_")
]
def test_integer(self, value):
def test_integer(self, value: object) -> bool:
try:
int(value)
int(cast(Any, value))
return True
except (ValueError, TypeError):
return False
def test_float(self, value):
def test_float(self, value: object) -> bool:
try:
float(value)
float(cast(Any, value))
return True
except (ValueError, TypeError):
return False
@ -408,7 +481,7 @@ class ValueTracker:
return self.guessed_type + ": possibilities = " + repr(self.couldbe)
@property
def guessed_type(self):
def guessed_type(self) -> str:
options = set(self.couldbe.keys())
# Return based on precedence
for key in self.get_tests():
@ -416,10 +489,10 @@ class ValueTracker:
return key
return "text"
def evaluate(self, value):
def evaluate(self, value: object) -> None:
if not value or not self.couldbe:
return
not_these = []
not_these: List[str] = []
for name, test in self.couldbe.items():
if not test(value):
not_these.append(name)
@ -428,35 +501,50 @@ class ValueTracker:
class NullProgressBar:
def __init__(self, *args):
def __init__(self, *args: Iterable[T]) -> None:
self.args = args
def __iter__(self):
yield from self.args[0]
def __iter__(self) -> Iterator[T]:
yield from self.args[0] # type: ignore
def update(self, value):
def update(self, value: int) -> None:
pass
@contextlib.contextmanager
def progressbar(*args, **kwargs):
def progressbar(*args: Iterable[T], **kwargs: Any) -> Generator[Any, None, None]:
silent = kwargs.pop("silent")
if silent:
yield NullProgressBar(*args)
else:
with click.progressbar(*args, **kwargs) as bar:
with click.progressbar(*args, **kwargs) as bar: # type: ignore
yield bar
def _compile_code(code, imports, variable="value"):
globals = {"r": recipes, "recipes": recipes}
def _compile_code(
code: str, imports: Iterable[str], variable: str = "value"
) -> Callable[..., Any]:
globals_dict: Dict[str, Any] = {"r": recipes, "recipes": recipes}
# Handle imports first so they're available for all approaches
for import_ in imports:
globals_dict[import_.split(".")[0]] = __import__(import_)
# If user defined a convert() function, return that
try:
exec(code, globals)
return globals["convert"]
exec(code, globals_dict)
return cast(Callable[..., object], globals_dict["convert"])
except (AttributeError, SyntaxError, NameError, KeyError, TypeError):
pass
# Check if code is a direct callable reference
# e.g. "r.parsedate" instead of "r.parsedate(value)"
try:
fn = eval(code, globals_dict)
if callable(fn):
return cast(Callable[..., object], fn)
except Exception:
pass
# Try compiling their code as a function instead
body_variants = [code]
# If single line and no 'return', try adding the return
@ -478,13 +566,11 @@ def _compile_code(code, imports, variable="value"):
if code_o is None:
raise SyntaxError("Could not compile code")
for import_ in imports:
globals[import_.split(".")[0]] = __import__(import_)
exec(code_o, globals)
return globals["fn"]
exec(code_o, globals_dict)
return cast(Callable[..., object], globals_dict["fn"])
def chunks(sequence: Iterable, size: int) -> Iterable[Iterable]:
def chunks(sequence: Iterable[T], size: int) -> Iterable[Iterable[T]]:
"""
Iterate over chunks of the sequence of the given size.
@ -496,7 +582,7 @@ def chunks(sequence: Iterable, size: int) -> Iterable[Iterable]:
yield itertools.chain([item], itertools.islice(iterator, size - 1))
def hash_record(record: Dict, keys: Optional[Iterable[str]] = None):
def hash_record(record: Dict[str, Any], keys: Optional[Iterable[str]] = None) -> str:
"""
``record`` should be a Python dictionary. Returns a sha1 hash of the
keys and values in that record.
@ -517,7 +603,7 @@ def hash_record(record: Dict, keys: Optional[Iterable[str]] = None):
:param record: Record to generate a hash for
:param keys: Subset of keys to use for that hash
"""
to_hash = record
to_hash: Dict[str, Any] = record
if keys is not None:
to_hash = {key: record[key] for key in keys}
return hashlib.sha1(
@ -527,7 +613,38 @@ def hash_record(record: Dict, keys: Optional[Iterable[str]] = None):
).hexdigest()
def _flatten(d):
def dedupe_keys(keys: Iterable[str]) -> List[str]:
"""
Rename duplicates in a list of column names so every name is unique,
by appending ``_2``, ``_3``... to later occurrences - skipping any
suffix that would collide with another column in the list.
Used when converting SQL query rows to dictionaries, where duplicate
column names would otherwise silently overwrite each other.
:param keys: List of column names, possibly containing duplicates
"""
keys = list(keys)
taken = set(keys)
if len(taken) == len(keys):
# No duplicates - the common case
return keys
seen: set = set()
result = []
for key in keys:
if key in seen:
new_key = key
suffix = 2
while new_key in seen or new_key in taken:
new_key = "{}_{}".format(key, suffix)
suffix += 1
key = new_key
seen.add(key)
result.append(key)
return result
def _flatten(d: Dict[str, Any]) -> Generator[Tuple[str, Any], None, None]:
for key, value in d.items():
if isinstance(value, dict):
for key2, value2 in _flatten(value):
@ -536,7 +653,7 @@ def _flatten(d):
yield key, value
def flatten(row: dict) -> dict:
def flatten(row: Dict[str, Any]) -> Dict[str, Any]:
"""
Turn a nested dict e.g. ``{"a": {"b": 1}}`` into a flat dict: ``{"a_b": 1}``

View file

@ -8,10 +8,55 @@ create table Gosh2 (c1 text, c2 text, c3 text);
"""
def pytest_addoption(parser):
parser.addoption(
"--sqlite-autocommit",
action="store_true",
default=False,
help=(
"Run every test against connections created with the Python 3.12+ "
"sqlite3.connect(autocommit=True) mode"
),
)
def pytest_configure(config):
import sys
sys._called_from_test = True
sys._called_from_test = True # type: ignore[attr-defined]
if config.getoption("--sqlite-autocommit"):
if sys.version_info < (3, 12):
raise pytest.UsageError(
"--sqlite-autocommit requires Python 3.12 or higher"
)
real_connect = sqlite3.connect
def autocommit_connect(*args, **kwargs):
kwargs.setdefault("autocommit", True)
return real_connect(*args, **kwargs)
sqlite3.connect = autocommit_connect
@pytest.fixture(autouse=True)
def close_all_databases():
"""Automatically close all Database objects created during a test."""
databases = []
original_init = Database.__init__
def tracking_init(self, *args, **kwargs):
original_init(self, *args, **kwargs)
databases.append(self)
Database.__init__ = tracking_init # type: ignore[method-assign]
yield
Database.__init__ = original_init # type: ignore[method-assign]
for db in databases:
try:
db.close()
except Exception:
pass
@pytest.fixture
@ -22,14 +67,12 @@ def fresh_db():
@pytest.fixture
def existing_db():
database = Database(memory=True)
database.executescript(
"""
database.executescript("""
CREATE TABLE foo (text TEXT);
INSERT INTO foo (text) values ("one");
INSERT INTO foo (text) values ("two");
INSERT INTO foo (text) values ("three");
"""
)
""")
return database
@ -38,4 +81,5 @@ def db_path(tmpdir):
path = str(tmpdir / "test.db")
db = sqlite3.connect(path)
db.executescript(CREATE_TABLES)
db.close()
return path

View file

@ -137,15 +137,13 @@ def db_to_analyze_path(db_to_analyze, tmpdir):
db = sqlite3.connect(path)
sql = "\n".join(db_to_analyze.iterdump())
db.executescript(sql)
db.close()
return path
def test_analyze_table(db_to_analyze_path):
result = CliRunner().invoke(cli.cli, ["analyze-tables", db_to_analyze_path])
assert (
result.output.strip()
== (
"""
assert result.output.strip() == ("""
stuff.id: (1/3)
Total rows: 8
@ -178,9 +176,7 @@ stuff.size: (3/3)
Most common:
5: 5
3: 4"""
).strip()
)
3: 4""").strip()
def test_analyze_table_save(db_to_analyze_path):

382
tests/test_atomic.py Normal file
View file

@ -0,0 +1,382 @@
import pytest
from sqlite_utils.db import Database, _iter_complete_sql_statements
from sqlite_utils.utils import sqlite3
@pytest.mark.parametrize(
"sql,expected",
(
(
"CREATE TABLE t(id); INSERT INTO t VALUES (1)",
["CREATE TABLE t(id);", "INSERT INTO t VALUES (1)"],
),
(
"INSERT INTO t VALUES ('a;b');",
["INSERT INTO t VALUES ('a;b');"],
),
(
"-- comment;\nCREATE TABLE t(id);",
["-- comment;\nCREATE TABLE t(id);"],
),
(
"""
CREATE TRIGGER t_ai AFTER INSERT ON t
BEGIN
UPDATE t SET value = 'a;b' WHERE id = new.id;
INSERT INTO log VALUES ('x;y');
END;
""",
[
"CREATE TRIGGER t_ai AFTER INSERT ON t\n"
" BEGIN\n"
" UPDATE t SET value = 'a;b' WHERE id = new.id;\n"
" INSERT INTO log VALUES ('x;y');\n"
" END;"
],
),
),
)
def test_iter_complete_sql_statements(sql, expected):
assert list(_iter_complete_sql_statements(sql)) == expected
def test_atomic_commits(fresh_db):
with fresh_db.atomic():
fresh_db["dogs"].insert({"id": 1, "name": "Cleo"}, pk="id")
assert list(fresh_db["dogs"].rows) == [{"id": 1, "name": "Cleo"}]
def test_atomic_rolls_back(fresh_db):
with pytest.raises(RuntimeError):
with fresh_db.atomic():
fresh_db["dogs"].insert({"id": 1, "name": "Cleo"}, pk="id")
raise RuntimeError("boom")
assert not fresh_db["dogs"].exists()
def test_nested_atomic_rolls_back_to_savepoint(fresh_db):
fresh_db["dogs"].create({"id": int, "name": str}, pk="id")
with fresh_db.atomic():
fresh_db["dogs"].insert({"id": 1, "name": "Cleo"})
with pytest.raises(RuntimeError):
with fresh_db.atomic():
fresh_db["dogs"].insert({"id": 2, "name": "Pancakes"})
raise RuntimeError("boom")
fresh_db["dogs"].insert({"id": 3, "name": "Marnie"})
assert list(fresh_db["dogs"].rows) == [
{"id": 1, "name": "Cleo"},
{"id": 3, "name": "Marnie"},
]
def test_outer_atomic_rolls_back_released_savepoint(fresh_db):
with pytest.raises(RuntimeError):
with fresh_db.atomic():
fresh_db["dogs"].insert({"id": 1, "name": "Cleo"}, pk="id")
with fresh_db.atomic():
fresh_db["dogs"].insert({"id": 2, "name": "Pancakes"})
raise RuntimeError("boom")
assert not fresh_db["dogs"].exists()
def test_executescript_does_not_commit_open_atomic_block(fresh_db):
with pytest.raises(RuntimeError):
with fresh_db.atomic():
fresh_db.executescript("""
CREATE TABLE dogs(id INTEGER PRIMARY KEY, name TEXT);
CREATE TRIGGER dogs_ai AFTER INSERT ON dogs
BEGIN
UPDATE dogs SET name = upper(new.name) || '; updated' WHERE id = new.id;
END;
-- This comment has a semicolon;
INSERT INTO dogs VALUES (1, 'Cleo; the first');
""")
raise RuntimeError("boom")
assert not fresh_db["dogs"].exists()
def test_transform_does_not_commit_open_atomic_block(fresh_db):
fresh_db["dogs"].insert({"id": 1, "name": "Cleo", "age": "5"}, pk="id")
with pytest.raises(RuntimeError):
with fresh_db.atomic():
fresh_db["dogs"].insert({"id": 2, "name": "Pancakes", "age": "6"})
fresh_db["dogs"].transform(rename={"age": "dog_age"})
raise RuntimeError("boom")
assert (
fresh_db["dogs"].schema
== 'CREATE TABLE "dogs" (\n "id" INTEGER PRIMARY KEY,\n "name" TEXT,\n "age" TEXT\n)'
)
assert list(fresh_db["dogs"].rows) == [
{"id": 1, "name": "Cleo", "age": "5"},
]
def test_transform_parent_table_with_foreign_keys_in_atomic(fresh_db):
fresh_db.conn.execute("PRAGMA foreign_keys=ON")
fresh_db["authors"].insert({"id": 1, "name": "Tina"}, pk="id")
fresh_db["books"].insert(
{"id": 1, "title": "Book", "author_id": 1},
pk="id",
foreign_keys={"author_id"},
)
with fresh_db.atomic():
fresh_db["authors"].transform(rename={"name": "full_name"})
assert fresh_db.conn.execute("PRAGMA foreign_keys").fetchone()[0]
assert (
fresh_db["authors"].schema
== 'CREATE TABLE "authors" (\n "id" INTEGER PRIMARY KEY,\n "full_name" TEXT\n)'
)
assert fresh_db.execute("PRAGMA foreign_key_check").fetchall() == []
def test_transform_parent_table_with_foreign_keys_rolls_back(fresh_db):
fresh_db.conn.execute("PRAGMA foreign_keys=ON")
fresh_db["authors"].insert({"id": 1, "name": "Tina"}, pk="id")
fresh_db["books"].insert(
{"id": 1, "title": "Book", "author_id": 1},
pk="id",
foreign_keys={"author_id"},
)
with pytest.raises(RuntimeError):
with fresh_db.atomic():
fresh_db["authors"].transform(rename={"name": "full_name"})
raise RuntimeError("boom")
assert (
fresh_db["authors"].schema
== 'CREATE TABLE "authors" (\n "id" INTEGER PRIMARY KEY,\n "name" TEXT\n)'
)
assert fresh_db.conn.execute("PRAGMA foreign_keys").fetchone()[0]
assert fresh_db.execute("PRAGMA foreign_key_check").fetchall() == []
def test_transform_detects_foreign_key_check_violations(fresh_db):
fresh_db.conn.execute("PRAGMA foreign_keys=ON")
fresh_db["authors"].insert({"id": 1, "name": "Tina"}, pk="id")
fresh_db["books"].insert({"id": 1, "author_id": 2}, pk="id")
with pytest.raises(sqlite3.IntegrityError):
fresh_db["books"].transform(add_foreign_keys=(("author_id", "authors", "id"),))
assert fresh_db["books"].foreign_keys == []
assert fresh_db.conn.execute("PRAGMA foreign_keys").fetchone()[0]
def test_atomic_inside_manual_transaction_uses_savepoint(fresh_db):
fresh_db["t"].insert({"id": 1}, pk="id")
fresh_db.execute("begin")
with fresh_db.atomic():
fresh_db["t"].insert({"id": 2}, pk="id")
# Nothing is committed until the user's own transaction commits
assert fresh_db.conn.in_transaction
fresh_db.rollback()
assert [r["id"] for r in fresh_db["t"].rows] == [1]
# And with a commit instead, the atomic block's writes persist
fresh_db.execute("begin")
with fresh_db.atomic():
fresh_db["t"].insert({"id": 3}, pk="id")
fresh_db.commit()
assert [r["id"] for r in fresh_db["t"].rows] == [1, 3]
def test_begin_commit_rollback(tmpdir):
path = str(tmpdir / "test.db")
db = Database(path)
db["t"].insert({"id": 1}, pk="id")
db.begin()
db["t"].insert({"id": 2}, pk="id")
assert db.conn.in_transaction
db.rollback()
assert not db.conn.in_transaction
assert [r["id"] for r in db["t"].rows] == [1]
db.begin()
db["t"].insert({"id": 3}, pk="id")
db.commit()
db.close()
db2 = Database(path)
assert [r["id"] for r in db2["t"].rows] == [1, 3]
db2.close()
def test_begin_inside_transaction_errors(fresh_db):
fresh_db.begin()
with pytest.raises(sqlite3.OperationalError):
fresh_db.begin()
fresh_db.rollback()
def test_commit_and_rollback_without_transaction_are_noops(fresh_db):
fresh_db.commit()
fresh_db.rollback()
assert not fresh_db.conn.in_transaction
def test_execute_write_commits_immediately(tmpdir):
path = str(tmpdir / "test.db")
db = Database(path)
db["t"].insert({"id": 1}, pk="id")
db.execute("insert into t (id) values (2)")
# No implicit transaction is left open
assert not db.conn.in_transaction
# A completely separate connection sees the row straight away
other = sqlite3.connect(path)
assert other.execute("select count(*) from t").fetchone()[0] == 2
other.close()
db.close()
def test_execute_write_respects_explicit_transaction(fresh_db):
fresh_db["t"].insert({"id": 1}, pk="id")
fresh_db.begin()
fresh_db.execute("insert into t (id) values (2)")
# Still inside the explicit transaction - not committed
assert fresh_db.conn.in_transaction
fresh_db.rollback()
assert [r["id"] for r in fresh_db["t"].rows] == [1]
def test_execute_comment_prefixed_begin_leaves_transaction_open(fresh_db):
# A BEGIN hidden behind a leading comment must not be auto-committed
# out from under the caller
fresh_db["t"].insert({"id": 1}, pk="id")
fresh_db.execute("-- start a transaction\nbegin")
assert fresh_db.conn.in_transaction
fresh_db.execute("insert into t (id) values (2)")
fresh_db.rollback()
assert [r["id"] for r in fresh_db["t"].rows] == [1]
def _sqlite_accepts_bom():
try:
sqlite3.connect(":memory:").execute("\ufeffselect 1")
return True
except sqlite3.OperationalError:
return False
@pytest.mark.parametrize("begin_sql", ["; begin", "\ufeffbegin"])
def test_execute_prefixed_begin_leaves_transaction_open(fresh_db, begin_sql):
# sqlite3 tolerates empty statements and a UTF-8 BOM before the first
# real token, so a BEGIN behind either must not be auto-committed
# out from under the caller
if begin_sql.startswith("\ufeff") and not _sqlite_accepts_bom():
pytest.skip("This SQLite version rejects a leading byte order mark")
fresh_db["t"].insert({"id": 1}, pk="id")
fresh_db.execute(begin_sql)
assert fresh_db.conn.in_transaction
fresh_db.execute("insert into t (id) values (2)")
fresh_db.rollback()
assert [r["id"] for r in fresh_db["t"].rows] == [1]
def test_execute_failed_write_rolls_back_implicit_transaction(tmpdir):
# A failed write must not leave the driver's implicit transaction open -
# that would silently disable auto-commit for every subsequent write
path = str(tmpdir / "test.db")
db = Database(path)
db["t"].insert({"id": 1}, pk="id")
with pytest.raises(sqlite3.IntegrityError):
db.execute("insert into t (id) values (1)")
assert not db.conn.in_transaction
# Subsequent writes commit as normal and survive closing the connection
db["other"].insert({"id": 2})
db.close()
db2 = Database(path)
assert db2["other"].exists()
db2.close()
def test_execute_failed_write_preserves_explicit_transaction(fresh_db):
# A failed write inside an explicit transaction must not roll back
# the caller's earlier work - only the caller decides that
fresh_db["t"].insert({"id": 1}, pk="id")
fresh_db.begin()
fresh_db.execute("insert into t (id) values (2)")
with pytest.raises(sqlite3.IntegrityError):
fresh_db.execute("insert into t (id) values (1)")
assert fresh_db.conn.in_transaction
fresh_db.commit()
assert [r["id"] for r in fresh_db["t"].rows] == [1, 2]
def test_execute_failed_write_inside_atomic_preserves_block(fresh_db):
# A caught failure inside an atomic() block must leave the block's
# transaction open so its other work still commits
fresh_db["t"].insert({"id": 1}, pk="id")
with fresh_db.atomic():
fresh_db.execute("insert into t (id) values (2)")
with pytest.raises(sqlite3.IntegrityError):
fresh_db.execute("insert into t (id) values (1)")
assert [r["id"] for r in fresh_db["t"].rows] == [1, 2]
def test_query_returning_commits_after_iteration(tmpdir):
if sqlite3.sqlite_version_info < (3, 35, 0):
import pytest as _pytest
_pytest.skip("RETURNING requires SQLite 3.35.0 or higher")
path = str(tmpdir / "test.db")
db = Database(path)
db["t"].insert({"id": 1}, pk="id")
rows = list(db.query("insert into t (id) values (2) returning id"))
assert rows == [{"id": 2}]
assert not db.conn.in_transaction
other = sqlite3.connect(path)
assert other.execute("select count(*) from t").fetchone()[0] == 2
other.close()
db.close()
TRIGGER_SQL = """
create trigger no_bad before insert on t
when new.v = 'bad'
begin
select raise(rollback, 'trigger says no');
end
"""
def test_atomic_preserves_error_from_transaction_destroying_trigger(fresh_db):
# RAISE(ROLLBACK) rolls back the whole transaction and destroys every
# savepoint - atomic()'s cleanup must not mask the IntegrityError
# with "cannot rollback - no transaction is active"
fresh_db.execute("create table t (id integer primary key, v text)")
fresh_db.execute(TRIGGER_SQL)
with pytest.raises(sqlite3.IntegrityError, match="trigger says no"):
with fresh_db.atomic():
fresh_db.execute("insert into t (v) values ('bad')")
assert not fresh_db.conn.in_transaction
def test_nested_atomic_preserves_error_from_transaction_destroying_trigger(
fresh_db,
):
# The nested savepoint branch previously raised
# "no such savepoint" from ROLLBACK TO SAVEPOINT
fresh_db.execute("create table t (id integer primary key, v text)")
fresh_db.execute(TRIGGER_SQL)
with pytest.raises(sqlite3.IntegrityError, match="trigger says no"):
with fresh_db.atomic():
with fresh_db.atomic():
fresh_db.execute("insert into t (v) values ('bad')")
assert not fresh_db.conn.in_transaction
def test_atomic_preserves_error_from_insert_or_rollback(fresh_db):
fresh_db["t"].insert({"id": 1}, pk="id")
with pytest.raises(sqlite3.IntegrityError):
with fresh_db.atomic():
fresh_db.execute("insert or rollback into t (id) values (1)")
assert not fresh_db.conn.in_transaction

View file

@ -3,6 +3,7 @@ from sqlite_utils.db import Index, ForeignKey
from click.testing import CliRunner
from pathlib import Path
import subprocess
import sqlite3
import sys
import json
import os
@ -19,9 +20,11 @@ def _supports_pragma_function_list():
db = Database(memory=True)
try:
db.execute("select * from pragma_function_list()")
return True
except Exception:
return False
return True
finally:
db.close()
def _has_compiled_ext():
@ -193,6 +196,50 @@ def test_output_table(db_path, options, expected):
assert expected == result.output.strip()
@pytest.mark.parametrize(
"fmt_option", [["--fmt", "simple"], ["-t"], ["--fmt", "github"]]
)
def test_output_table_no_headers(db_path, fmt_option):
# --no-headers should omit the header row from --fmt/--table output too, not
# just from --csv/--tsv (#566). Previously the flag was silently ignored for
# tabulate formats and the column names were always printed.
db = Database(db_path)
with db.conn:
db["dogs"].insert_all(
[
{"id": 1, "name": "Cleo", "age": 4},
{"id": 2, "name": "Pancakes", "age": 2},
]
)
sql = "select id, name, age from dogs order by id"
with_headers = CliRunner().invoke(cli.cli, ["query", db_path, sql] + fmt_option)
without_headers = CliRunner().invoke(
cli.cli, ["query", db_path, sql] + fmt_option + ["--no-headers"]
)
assert with_headers.exit_code == 0
assert without_headers.exit_code == 0
# The column names appear when headers are shown, and must not appear at all
# once --no-headers is passed.
assert "name" in with_headers.output
for header in ("id", "name", "age"):
assert (
header not in without_headers.output
), f"header {header!r} leaked into --no-headers output"
# The data is still all present.
for value in ("Cleo", "Pancakes", "1", "2", "4"):
assert value in without_headers.output
# The rows command shares the same code path.
rows_no_headers = CliRunner().invoke(
cli.cli, ["rows", db_path, "dogs"] + fmt_option + ["--no-headers"]
)
assert rows_no_headers.exit_code == 0
assert "name" not in rows_no_headers.output
assert "Cleo" in rows_no_headers.output
def test_create_index(db_path):
db = Database(db_path)
assert [] == db["Gosh"].indexes
@ -245,6 +292,24 @@ def test_create_index(db_path):
)
def test_drop_index(db_path):
db = Database(db_path)
db["Gosh"].create_index(["c1"])
assert [index.name for index in db["Gosh"].indexes] == ["idx_Gosh_c1"]
result = CliRunner().invoke(cli.cli, ["drop-index", db_path, "Gosh", "idx_Gosh_c1"])
assert result.exit_code == 0
assert db["Gosh"].indexes == []
result = CliRunner().invoke(cli.cli, ["drop-index", db_path, "Gosh", "idx_Gosh_c1"])
assert result.exit_code == 1
assert "No index named idx_Gosh_c1" in result.output
result = CliRunner().invoke(
cli.cli, ["drop-index", db_path, "Gosh", "idx_Gosh_c1", "--ignore"]
)
assert result.exit_code == 0
def test_create_index_analyze(db_path):
db = Database(db_path)
assert "sqlite_stat1" not in db.table_names()
@ -736,6 +801,25 @@ def test_query_json(db_path, sql, args, expected):
assert expected == result.output.strip()
def test_query_sql_from_stdin(db_path):
# https://github.com/simonw/sqlite-utils/issues/765
db = Database(db_path)
with db.conn:
db["dogs"].insert_all(
[
{"id": 1, "age": 4, "name": "Cleo"},
{"id": 2, "age": 2, "name": "Pancakes"},
]
)
result = CliRunner().invoke(
cli.cli,
["query", db_path, "-"],
input="select name from dogs order by name",
)
assert result.exit_code == 0, result.output
assert json.loads(result.output) == [{"name": "Cleo"}, {"name": "Pancakes"}]
def test_query_json_empty(db_path):
result = CliRunner().invoke(
cli.cli,
@ -744,6 +828,26 @@ def test_query_json_empty(db_path):
assert result.output.strip() == "[]"
def test_query_json_duplicate_columns_are_deduped(db_path):
# https://github.com/simonw/sqlite-utils/issues/624
result = CliRunner().invoke(
cli.cli,
[db_path, "select 1 as id, 2 as id, 'x' as value, 'y' as value"],
)
assert result.output.strip() == (
'[{"id": 1, "id_2": 2, "value": "x", "value_2": "y"}]'
)
def test_query_csv_duplicate_columns_are_preserved(db_path):
# CSV output should keep the duplicate headers, not rename them
result = CliRunner().invoke(
cli.cli,
[db_path, "select 1 as id, 2 as id", "--csv"],
)
assert result.output.replace("\r", "").strip() == "id,id\n1,2"
def test_query_invalid_function(db_path):
result = CliRunner().invoke(
cli.cli, [db_path, "select bad()", "--functions", "def invalid_python"]
@ -965,12 +1069,9 @@ def test_query_json_with_json_cols(db_path):
result = CliRunner().invoke(
cli.cli, [db_path, "select id, name, friends from dogs"]
)
assert (
r"""
assert r"""
[{"id": 1, "name": "Cleo", "friends": "[{\"name\": \"Pancakes\"}, {\"name\": \"Bailey\"}]"}]
""".strip()
== result.output.strip()
)
""".strip() == result.output.strip()
# With --json-cols:
result = CliRunner().invoke(
cli.cli, [db_path, "select id, name, friends from dogs", "--json-cols"]
@ -984,6 +1085,34 @@ def test_query_json_with_json_cols(db_path):
assert expected == result_rows.output.strip()
def test_query_json_unicode_not_escaped_by_default(db_path):
db = Database(db_path)
with db.conn:
db["text"].insert({"id": 1, "text": "Japanese 日本語"}, pk="id")
result = CliRunner().invoke(cli.cli, [db_path, "select id, text from text"])
assert result.exit_code == 0
assert result.output.strip() == '[{"id": 1, "text": "Japanese 日本語"}]'
# Same for --nl
result = CliRunner().invoke(cli.cli, [db_path, "select id, text from text", "--nl"])
assert result.exit_code == 0
assert result.output.strip() == '{"id": 1, "text": "Japanese 日本語"}'
@pytest.mark.parametrize("command", ["query", "rows"])
def test_query_json_ascii_option(db_path, command):
db = Database(db_path)
with db.conn:
db["text"].insert({"id": 1, "text": "Japanese 日本語"}, pk="id")
if command == "query":
args = [db_path, "select id, text from text", "--ascii"]
else:
args = ["rows", db_path, "text", "--ascii"]
result = CliRunner().invoke(cli.cli, args)
assert result.exit_code == 0
expected = '[{"id": 1, "text": "Japanese ' + "\\u65e5\\u672c\\u8a9e" + '"}]'
assert result.output.strip() == expected
@pytest.mark.parametrize(
"content,is_binary",
[(b"\x00\x0fbinary", True), ("this is text", False), (1, False), (1.5, False)],
@ -1125,20 +1254,38 @@ def test_upsert(db_path, tmpdir):
]
def test_upsert_pk_required(db_path, tmpdir):
def test_upsert_pk_inferred_from_existing_table(db_path, tmpdir):
json_path = str(tmpdir / "dogs.json")
db = Database(db_path)
insert_dogs = [
{"id": 1, "name": "Cleo", "age": 4},
{"id": 2, "name": "Nixie", "age": 4},
]
write_json(json_path, insert_dogs)
result = CliRunner().invoke(
cli.cli,
["insert", db_path, "dogs", json_path, "--pk", "id"],
catch_exceptions=False,
)
assert result.exit_code == 0, result.output
write_json(
json_path,
[
{"id": 1, "age": 5},
{"id": 2, "age": 5},
],
)
result = CliRunner().invoke(
cli.cli,
["upsert", db_path, "dogs", json_path],
catch_exceptions=False,
)
assert result.exit_code == 2
assert "Error: Missing option '--pk'" in result.output
assert result.exit_code == 0, result.output
assert list(db.query("select * from dogs order by id")) == [
{"id": 1, "name": "Cleo", "age": 5},
{"id": 2, "name": "Nixie", "age": 5},
]
def test_upsert_analyze(db_path, tmpdir):
@ -1468,6 +1615,24 @@ def test_drop_table_error():
assert result.exit_code == 0
def test_drop_table_on_view_errors():
runner = CliRunner()
with runner.isolated_filesystem():
db = Database("test.db")
db["t"].insert({"id": 1})
db.create_view("v", "select * from t")
result = runner.invoke(cli.cli, ["drop-table", "test.db", "v"])
assert result.exit_code == 1
assert 'Error: "v" is a view, not a table - use drop-view to drop it' == (
result.output.strip()
)
assert "v" in db.view_names()
# --ignore exits cleanly but must still not drop the view
result = runner.invoke(cli.cli, ["drop-table", "test.db", "v", "--ignore"])
assert result.exit_code == 0
assert "v" in db.view_names()
def test_drop_view():
runner = CliRunner()
with runner.isolated_filesystem():
@ -1486,6 +1651,23 @@ def test_drop_view():
assert "hello" not in db.view_names()
def test_drop_view_on_table_errors():
runner = CliRunner()
with runner.isolated_filesystem():
db = Database("test.db")
db["t"].insert({"id": 1})
result = runner.invoke(cli.cli, ["drop-view", "test.db", "t"])
assert result.exit_code == 1
assert 'Error: "t" is a table, not a view - use drop-table to drop it' == (
result.output.strip()
)
assert "t" in db.table_names()
# --ignore exits cleanly but must still not drop the table
result = runner.invoke(cli.cli, ["drop-view", "test.db", "t", "--ignore"])
assert result.exit_code == 0
assert "t" in db.table_names()
def test_drop_view_error():
runner = CliRunner()
with runner.isolated_filesystem():
@ -1735,6 +1917,87 @@ def test_transform(db_path, args, expected_schema):
assert schema == expected_schema
def test_transform_sql(db_path):
db = Database(db_path)
with db.conn:
db["dogs"].insert(
{"id": 1, "age": 4, "name": "Cleo"},
not_null={"age"},
defaults={"age": 1},
pk="id",
)
original_schema = db["dogs"].schema
result = CliRunner().invoke(
cli.cli, ["transform", db_path, "dogs", "--drop", "name", "--sql"]
)
assert result.exit_code == 0, result.output
assert 'CREATE TABLE "dogs_new_' in result.output
assert '"age" INTEGER NOT NULL DEFAULT' in result.output
assert 'DROP TABLE "dogs";' in result.output
assert 'ALTER TABLE "dogs_new_' in result.output
assert db["dogs"].schema == original_schema
@pytest.mark.parametrize(
"initial_strict,args,expected_strict",
(
(False, [], False),
(True, [], True),
(False, ["--strict"], True),
(True, ["--no-strict"], False),
),
)
def test_transform_strict_option(db_path, initial_strict, args, expected_strict):
db = Database(db_path)
if not db.supports_strict:
pytest.skip("SQLite version does not support strict tables")
db["dogs"].create({"id": int}, strict=initial_strict)
result = CliRunner().invoke(cli.cli, ["transform", db_path, "dogs"] + args)
assert result.exit_code == 0, result.output
assert db["dogs"].strict is expected_strict
@pytest.mark.parametrize(
"initial_strict,flag,sql_is_strict",
(
(False, "--strict", True),
(True, "--no-strict", False),
),
)
def test_transform_strict_option_sql(db_path, initial_strict, flag, sql_is_strict):
db = Database(db_path)
if not db.supports_strict:
pytest.skip("SQLite version does not support strict tables")
db["dogs"].create({"id": int}, strict=initial_strict)
result = CliRunner().invoke(cli.cli, ["transform", db_path, "dogs", flag, "--sql"])
assert result.exit_code == 0, result.output
assert (") STRICT;" in result.output) is sql_is_strict
assert db["dogs"].strict is initial_strict
def test_transform_strict_option_with_invalid_data(db_path):
db = Database(db_path)
if not db.supports_strict:
pytest.skip("SQLite version does not support strict tables")
dogs = db["dogs"]
dogs.create({"id": int})
dogs.insert({"id": "not-an-integer"})
result = CliRunner().invoke(cli.cli, ["transform", db_path, "dogs", "--strict"])
assert result.exit_code == 1
assert isinstance(result.exception, sqlite3.IntegrityError)
assert dogs.strict is False
assert list(dogs.rows) == [{"id": "not-an-integer"}]
assert not any(name.startswith("dogs_new_") for name in db.table_names())
@pytest.mark.parametrize(
"extra_args,expected_schema",
(
@ -1996,12 +2259,10 @@ def test_search_quote(tmpdir):
def test_indexes(tmpdir):
db_path = str(tmpdir / "test.db")
db = Database(db_path)
db.conn.executescript(
"""
db.conn.executescript("""
create table Gosh (c1 text, c2 text, c3 text);
create index Gosh_idx on Gosh(c2, c3 desc);
"""
)
""")
result = CliRunner().invoke(
cli.cli,
["indexes", str(db_path)],
@ -2092,16 +2353,12 @@ def test_triggers(tmpdir, extra_args, expected):
pk="id",
)
db["counter"].insert({"count": 1})
db.conn.execute(
textwrap.dedent(
"""
db.conn.execute(textwrap.dedent("""
CREATE TRIGGER blah AFTER INSERT ON articles
BEGIN
UPDATE counter SET count = count + 1;
END
"""
)
)
"""))
args = ["triggers", db_path]
if extra_args:
args.extend(extra_args)
@ -2280,18 +2537,14 @@ def test_csv_insert_bom(tmpdir):
]
@pytest.mark.parametrize("option", (None, "-d", "--detect-types"))
def test_insert_detect_types(tmpdir, option):
"""Test that type detection is now the default behavior"""
def test_insert_detect_types(tmpdir):
"""Test that type detection is the default behavior"""
db_path = str(tmpdir / "test.db")
data = "name,age,weight\nCleo,6,45.5\nDori,1,3.5"
extra = []
if option:
extra = [option]
result = CliRunner().invoke(
cli.cli,
["insert", db_path, "creatures", "-", "--csv"] + extra,
["insert", db_path, "creatures", "-", "--csv"],
catch_exceptions=False,
input=data,
)
@ -2303,17 +2556,27 @@ def test_insert_detect_types(tmpdir, option):
]
@pytest.mark.parametrize("option", (None, "-d", "--detect-types"))
def test_upsert_detect_types(tmpdir, option):
"""Test that type detection is now the default behavior for upsert"""
@pytest.mark.parametrize("command", ("insert", "upsert"))
@pytest.mark.parametrize("option", ("-d", "--detect-types"))
def test_detect_types_flag_removed(tmpdir, command, option):
# The old no-op flag was removed in 4.0 - it should now error
db_path = str(tmpdir / "test.db")
data = "id,name,age,weight\n1,Cleo,6,45.5\n2,Dori,1,3.5"
extra = []
if option:
extra = [option]
result = CliRunner().invoke(
cli.cli,
["upsert", db_path, "creatures", "-", "--csv", "--pk", "id"] + extra,
[command, db_path, "creatures", "-", "--csv", "--pk", "id", option],
input="id,name\n1,Cleo",
)
assert result.exit_code == 2
assert "No such option" in result.output
def test_upsert_detect_types(tmpdir):
"""Test that type detection is the default behavior for upsert"""
db_path = str(tmpdir / "test.db")
data = "id,name,age,weight\n1,Cleo,6,45.5\n2,Dori,1,3.5"
result = CliRunner().invoke(
cli.cli,
["upsert", db_path, "creatures", "-", "--csv", "--pk", "id"],
catch_exceptions=False,
input=data,
)
@ -2609,3 +2872,22 @@ def test_insert_upsert_strict(tmpdir, method, strict):
assert result.exit_code == 0
db = Database(db_path)
assert db["items"].strict == strict or not db.supports_strict
def test_extract_bad_column_clean_error(db_path):
db = Database(db_path)
db["trees"].insert({"id": 1, "species": "Palm"}, pk="id")
result = CliRunner().invoke(cli.cli, ["extract", db_path, "trees", "nope"])
assert result.exit_code == 1
assert result.exception is None or isinstance(result.exception, SystemExit)
assert result.output.startswith("Error: Invalid columns")
def test_extract_view_clean_error(db_path):
db = Database(db_path)
db["trees"].insert({"id": 1, "species": "Palm"}, pk="id")
db.create_view("v", "select * from trees")
result = CliRunner().invoke(cli.cli, ["extract", db_path, "v", "species"])
assert result.exit_code == 1
assert result.exception is None or isinstance(result.exception, SystemExit)
assert result.output.startswith("Error:")

View file

@ -215,6 +215,25 @@ def test_convert_multi_dryrun(test_db_and_path):
)
def test_convert_multi_dryrun_unicode_not_escaped(test_db_and_path):
db_path = test_db_and_path[1]
result = CliRunner().invoke(
cli.cli,
[
"convert",
db_path,
"example",
"dt",
"{'text': 'Japanese 日本語'}",
"--dry-run",
"--multi",
],
)
assert result.exit_code == 0
# Preview should match what jsonify_if_needed() would actually store
assert '{"text": "Japanese 日本語"}' in result.output
@pytest.mark.parametrize("drop", (True, False))
def test_convert_output_column(test_db_and_path, drop):
db, db_path = test_db_and_path
@ -371,16 +390,14 @@ def test_convert_multi_complex_column_types(fresh_db_and_path):
],
pk="id",
)
code = textwrap.dedent(
"""
code = textwrap.dedent("""
if value == 1:
return {"is_str": "", "is_float": 1.2, "is_int": None}
elif value == 2:
return {"is_float": 1, "is_int": 12}
elif value == 3:
return {"is_bytes": b"blah"}
"""
)
""")
result = CliRunner().invoke(
cli.cli,
[
@ -535,7 +552,7 @@ def test_convert_where(test_db_and_path):
"id = :id",
"-p",
"id",
2,
"2",
],
)
assert result.exit_code == 0, result.output
@ -564,7 +581,7 @@ def test_convert_where_multi(fresh_db_and_path):
"id = :id",
"-p",
"id",
2,
"2",
"--multi",
],
)
@ -645,3 +662,51 @@ def test_convert_handles_falsey_values(fresh_db_and_path):
assert result.exit_code == 0, result.output
assert db["t"].get(1)["x"] == 1
assert db["t"].get(2)["x"] == 2
@pytest.mark.parametrize(
"code",
[
# Direct callable reference (issue #686)
"r.parsedate",
"recipes.parsedate",
# Traditional call syntax still works
"r.parsedate(value)",
"recipes.parsedate(value)",
],
)
def test_convert_callable_reference(test_db_and_path, code):
"""Test that callable references like r.parsedate work without (value)"""
db, db_path = test_db_and_path
result = CliRunner().invoke(
cli.cli, ["convert", db_path, "example", "dt", code], catch_exceptions=False
)
assert result.exit_code == 0, result.output
rows = list(db["example"].rows)
assert rows[0]["dt"] == "2019-10-05"
assert rows[1]["dt"] == "2019-10-06"
assert rows[2]["dt"] == ""
assert rows[3]["dt"] is None
def test_convert_callable_reference_with_import(fresh_db_and_path):
"""Test callable reference from an imported module"""
db, db_path = fresh_db_and_path
db["example"].insert({"id": 1, "data": '{"name": "test"}'})
result = CliRunner().invoke(
cli.cli,
[
"convert",
db_path,
"example",
"data",
"json.loads",
"--import",
"json",
],
catch_exceptions=False,
)
assert result.exit_code == 0, result.output
# json.loads returns a dict, which sqlite stores as JSON string
row = db["example"].get(1)
assert row["data"] == '{"name": "test"}'

View file

@ -597,3 +597,294 @@ def test_insert_streaming_batch_size_1(db_path):
proc.stdin.close()
proc.wait()
assert proc.returncode == 0
def test_insert_csv_headers_only(tmpdir):
"""Test that CSV with only header row (no data) works with --detect-types (issue #702)"""
db_path = str(tmpdir / "test.db")
csv_path = str(tmpdir / "headers_only.csv")
with open(csv_path, "w") as fp:
fp.write("id,name,age\n")
# Should not crash with --detect-types (which is now the default)
result = CliRunner().invoke(
cli.cli,
["insert", db_path, "data", csv_path, "--csv"],
catch_exceptions=False,
)
assert result.exit_code == 0
# Table should not exist since there were no data rows
db = Database(db_path)
assert not db["data"].exists()
def test_insert_into_view_errors(tmpdir):
db_path = str(tmpdir / "test.db")
db = Database(db_path)
db["t"].insert({"id": 1})
db.create_view("v", "select * from t")
db.close()
result = CliRunner().invoke(
cli.cli, ["insert", db_path, "v", "-"], input='{"id": 2}'
)
assert result.exit_code == 1
assert result.output.strip() == "Error: Table v is actually a view"
def test_insert_csv_detect_types_leaves_existing_table_alone(db_path):
# Type detection is the default for CSV/TSV inserts, but it must only
# apply to tables created by this command - transforming a pre-existing
# table would rewrite its column types and corrupt data such as
# TEXT zip codes with leading zeros
db = Database(db_path)
db["places"].insert({"name": "Boston", "zip": "01234"})
result = CliRunner().invoke(
cli.cli,
["insert", db_path, "places", "-", "--csv"],
catch_exceptions=False,
input="name,zip\nSF,94107",
)
assert result.exit_code == 0, result.output
assert db["places"].columns_dict["zip"] is str
assert list(db["places"].rows) == [
{"name": "Boston", "zip": "01234"},
{"name": "SF", "zip": "94107"},
]
def test_insert_csv_detect_types_new_table(db_path):
# A table created by the insert still gets detected types
result = CliRunner().invoke(
cli.cli,
["insert", db_path, "data", "-", "--csv"],
catch_exceptions=False,
input="name,age,weight\nCleo,5,12.5",
)
assert result.exit_code == 0, result.output
db = Database(db_path)
assert db["data"].columns_dict == {"name": str, "age": int, "weight": float}
@pytest.mark.parametrize(
"command,extra_args,input_text,expected_row",
(
(
"insert",
[],
"zipcode,score\n01234,9.5\n",
{"zipcode": "01234", "score": 9.5},
),
(
"upsert",
["--pk", "id"],
"id,zipcode,score\n1,01234,9.5\n",
{"id": 1, "zipcode": "01234", "score": 9.5},
),
),
)
def test_insert_upsert_csv_type_overrides_detected_types(
db_path, command, extra_args, input_text, expected_row
):
result = CliRunner().invoke(
cli.cli,
[
command,
db_path,
"places",
"-",
"--csv",
]
+ extra_args
+ [
"--type",
"zipcode",
"text",
],
catch_exceptions=False,
input=input_text,
)
assert result.exit_code == 0, result.output
db = Database(db_path)
expected_columns = {"zipcode": str, "score": float}
if command == "upsert":
expected_columns = {"id": int, **expected_columns}
assert db["places"].columns_dict == expected_columns
assert list(db["places"].rows) == [expected_row]
def test_upsert_csv_detect_types_leaves_existing_table_alone(db_path):
db = Database(db_path)
db["places"].insert({"id": 1, "name": "Boston", "zip": "01234"}, pk="id")
result = CliRunner().invoke(
cli.cli,
["upsert", db_path, "places", "-", "--csv", "--pk", "id"],
catch_exceptions=False,
input="id,name,zip\n2,SF,94107",
)
assert result.exit_code == 0, result.output
assert db["places"].columns_dict["zip"] is str
assert db["places"].get(1)["zip"] == "01234"
def test_insert_invalid_pk_clean_error(db_path):
# An invalid --pk against an existing table should be a clean CLI
# error, not a raw InvalidColumns traceback
db = Database(db_path)
db["t"].insert({"a": 1})
result = CliRunner().invoke(
cli.cli,
["insert", db_path, "t", "-", "--pk", "badcol"],
input='{"a": 2}',
)
assert result.exit_code == 1
assert result.exception is None or isinstance(result.exception, SystemExit)
assert result.output.startswith("Error: Invalid primary key column")
# --code tests, see https://github.com/simonw/sqlite-utils/issues/684
CODE_ROWS_FUNCTION = """
def rows():
yield {"id": 1, "name": "Cleo"}
yield {"id": 2, "name": "Suna"}
"""
CODE_ROWS_ITERABLE = """
rows = [
{"id": 1, "name": "Cleo"},
{"id": 2, "name": "Suna"},
]
"""
@pytest.mark.parametrize("code", (CODE_ROWS_FUNCTION, CODE_ROWS_ITERABLE))
def test_insert_code(tmpdir, code):
db_path = str(tmpdir / "dogs.db")
result = CliRunner().invoke(
cli.cli,
["insert", db_path, "creatures", "--code", code, "--pk", "id"],
)
assert result.exit_code == 0, result.output
db = Database(db_path)
assert db["creatures"].pks == ["id"]
assert list(db["creatures"].rows) == [
{"id": 1, "name": "Cleo"},
{"id": 2, "name": "Suna"},
]
def test_insert_code_from_file(tmpdir):
db_path = str(tmpdir / "dogs.db")
code_path = str(tmpdir / "gen.py")
with open(code_path, "w") as fp:
fp.write(CODE_ROWS_FUNCTION)
result = CliRunner().invoke(
cli.cli,
["insert", db_path, "creatures", "--code", code_path],
)
assert result.exit_code == 0, result.output
assert list(Database(db_path)["creatures"].rows) == [
{"id": 1, "name": "Cleo"},
{"id": 2, "name": "Suna"},
]
def test_upsert_code(tmpdir):
db_path = str(tmpdir / "dogs.db")
db = Database(db_path)
db["creatures"].insert_all(
[{"id": 1, "name": "old"}, {"id": 2, "name": "Suna"}], pk="id"
)
result = CliRunner().invoke(
cli.cli,
["upsert", db_path, "creatures", "--code", CODE_ROWS_FUNCTION, "--pk", "id"],
)
assert result.exit_code == 0, result.output
assert list(db["creatures"].rows) == [
{"id": 1, "name": "Cleo"},
{"id": 2, "name": "Suna"},
]
def test_insert_code_requires_file_or_code(tmpdir):
db_path = str(tmpdir / "dogs.db")
result = CliRunner().invoke(cli.cli, ["insert", db_path, "creatures"])
assert result.exit_code == 1
assert "Provide either a FILE argument or --code" in result.output
def test_insert_code_mutually_exclusive_with_file(tmpdir):
db_path = str(tmpdir / "dogs.db")
result = CliRunner().invoke(
cli.cli,
["insert", db_path, "creatures", "-", "--code", CODE_ROWS_FUNCTION],
input="{}",
)
assert result.exit_code == 1
assert "--code cannot be used with a FILE argument" in result.output
def test_insert_code_rejects_input_format_options(tmpdir):
db_path = str(tmpdir / "dogs.db")
result = CliRunner().invoke(
cli.cli,
["insert", db_path, "creatures", "--code", CODE_ROWS_FUNCTION, "--csv"],
)
assert result.exit_code == 1
assert "--code cannot be used with input format options" in result.output
def test_insert_code_missing_rows(tmpdir):
db_path = str(tmpdir / "dogs.db")
result = CliRunner().invoke(
cli.cli,
["insert", db_path, "creatures", "--code", "x = 1"],
)
assert result.exit_code == 1
assert "must define a 'rows' function or iterable" in result.output
def test_insert_code_single_dict(tmpdir):
db_path = str(tmpdir / "dogs.db")
result = CliRunner().invoke(
cli.cli,
[
"insert",
db_path,
"creatures",
"--code",
'rows = {"id": 1, "name": "Cleo"}',
"--pk",
"id",
],
)
assert result.exit_code == 0, result.output
assert list(Database(db_path)["creatures"].rows) == [{"id": 1, "name": "Cleo"}]
def test_insert_code_not_iterable(tmpdir):
db_path = str(tmpdir / "dogs.db")
result = CliRunner().invoke(
cli.cli,
["insert", db_path, "creatures", "--code", "rows = 5"],
)
assert result.exit_code == 1
assert "must define a 'rows' function or iterable" in result.output
def test_insert_code_syntax_error(tmpdir):
db_path = str(tmpdir / "dogs.db")
result = CliRunner().invoke(
cli.cli,
["insert", db_path, "creatures", "--code", "def rows(:"],
)
assert result.exit_code == 1
assert "Error in --code" in result.output
def test_insert_code_file_not_found(tmpdir):
db_path = str(tmpdir / "dogs.db")
result = CliRunner().invoke(
cli.cli,
["insert", db_path, "creatures", "--code", "missing.py"],
)
assert result.exit_code == 1
assert "File not found: missing.py" in result.output

View file

@ -156,6 +156,24 @@ def test_memory_csv_encoding(tmpdir, use_stdin):
}
def test_memory_csv_headers_only(tmpdir):
csv_path = str(tmpdir / "headers_only.csv")
with open(csv_path, "w") as fp:
fp.write("id,name,age\n")
result = CliRunner().invoke(
cli.cli,
["memory", csv_path, "", "--schema"],
catch_exceptions=False,
)
assert result.exit_code == 0
assert result.output.strip() == (
'CREATE VIEW "t1" AS select * from "headers_only";\n'
'CREATE VIEW "t" AS select * from "headers_only";'
)
@pytest.mark.parametrize("extra_args", ([], ["select 1"]))
def test_memory_dump(extra_args):
result = CliRunner().invoke(
@ -328,9 +346,10 @@ def test_memory_return_db(tmpdir):
from sqlite_utils.cli import cli
path = str(tmpdir / "dogs.csv")
open(path, "w").write("id,name\n1,Cleo")
with open(path, "w") as f:
f.write("id,name\n1,Cleo")
with click.Context(cli) as ctx:
with click.Context(cli) as ctx: # type: ignore[attr-defined]
db = ctx.invoke(cli.commands["memory"], paths=(path,), return_db=True)
assert db.table_names() == ["dogs"]

507
tests/test_cli_migrate.py Normal file
View file

@ -0,0 +1,507 @@
import pathlib
from click.testing import CliRunner
import pytest
import sqlite_utils
import sqlite_utils.cli
TWO_MIGRATIONS = """
from sqlite_utils import Migrations
m = Migrations("hello")
@m()
def foo(db):
db["foo"].insert({"hello": "world"})
@m()
def bar(db):
db["bar"].insert({"hello": "world"})
"""
@pytest.fixture
def two_migrations(tmpdir):
path = pathlib.Path(tmpdir)
(path / "foo").mkdir()
migrations_py = path / "foo" / "migrations.py"
migrations_py.write_text(TWO_MIGRATIONS, "utf-8")
return path, migrations_py
@pytest.fixture
def two_sets_same_migration_name(tmpdir):
path = pathlib.Path(tmpdir)
migrations_py = path / "migrations.py"
migrations_py.write_text(
"""
from sqlite_utils import Migrations
creatures = Migrations("creatures")
@creatures()
def create_table(db):
db["creatures"].insert({"name": "Cleo"})
@creatures()
def add_weight(db):
db["creature_weights"].insert({"weight": 4.2})
sales = Migrations("sales")
@sales()
def create_table(db):
db["sales"].insert({"id": 1})
@sales()
def add_weight(db):
db["sales_weights"].insert({"weight": 10})
""",
"utf-8",
)
return path, migrations_py
@pytest.mark.parametrize("arg", ("TMPDIR", "TMPDIR/foo/migrations.py", "TMPDIR/foo/"))
def test_basic(two_migrations, arg):
path, _ = two_migrations
db_path = str(path / "test.db")
runner = CliRunner()
def _list():
list_result = runner.invoke(
sqlite_utils.cli.cli,
["migrate", db_path, "--list", arg.replace("TMPDIR", str(path))],
)
assert list_result.exit_code == 0
return list_result.output
assert _list() == (
"Migrations for: hello\n\n"
" Applied:\n\n"
" Pending:\n"
" foo\n"
" bar\n\n"
)
result = runner.invoke(
sqlite_utils.cli.cli, ["migrate", db_path, arg.replace("TMPDIR", str(path))]
)
assert result.exit_code == 0, result.output
list_output = _list()
assert "Migrations for: hello\n\n Applied:\n " in list_output
prior_to_pending = list_output.split(" Pending")[0]
assert " foo" in prior_to_pending
assert " bar" in prior_to_pending
assert " Pending:\n (none)" in list_output
db = sqlite_utils.Database(db_path)
assert db["foo"].exists()
assert db["bar"].exists()
assert db["_sqlite_migrations"].exists()
rows = list(db["_sqlite_migrations"].rows)
assert len(rows) == 2
assert rows[0]["name"] == "foo"
assert rows[1]["name"] == "bar"
def test_list_same_migration_names_in_different_sets(capsys):
applied = sqlite_utils.Migrations("applied")
@applied(name="foo")
def applied_foo(db):
db["applied"].insert({"hello": "world"})
pending = sqlite_utils.Migrations("pending")
@pending(name="foo")
def pending_foo(db):
db["pending"].insert({"hello": "world"})
db = sqlite_utils.Database(memory=True)
applied.apply(db)
sqlite_utils.cli._display_migration_list(db, [applied, pending])
output = capsys.readouterr().out
assert (
"Migrations for: pending\n\n" " Applied:\n\n" " Pending:\n" " foo\n\n"
) in output
def test_verbose(tmpdir):
path = pathlib.Path(tmpdir)
(path / "foo").mkdir()
migrations_py = path / "foo" / "migrations.py"
migrations_py.write_text(
"""
from sqlite_utils import Migrations
m = Migrations("hello")
@m()
def foo(db):
db["dogs"].insert({"id": 1, "name": "Cleo"})
""",
"utf-8",
)
db_path = str(path / "test.db")
runner = CliRunner()
result = runner.invoke(
sqlite_utils.cli.cli, ["migrate", db_path, str(migrations_py)]
)
assert result.exit_code == 0
result = runner.invoke(
sqlite_utils.cli.cli, ["migrate", db_path, str(migrations_py), "--verbose"]
)
assert result.exit_code == 0
expected = """
Schema before:
CREATE TABLE "_sqlite_migrations" (
"id" INTEGER PRIMARY KEY,
"migration_set" TEXT,
"name" TEXT,
"applied_at" TEXT
);
CREATE UNIQUE INDEX "idx__sqlite_migrations_migration_set_name"
ON "_sqlite_migrations" ("migration_set", "name");
CREATE TABLE "dogs" (
"id" INTEGER,
"name" TEXT
);
Schema after:
(unchanged)
""".strip()
assert expected in result.output
new_migration = """
@m()
def bar(db):
db["dogs"].add_column("age", int)
db["dogs"].add_column("weight", float)
db["dogs"].transform()
"""
migrations_py.write_text(migrations_py.read_text("utf-8") + new_migration)
result = runner.invoke(
sqlite_utils.cli.cli, ["migrate", db_path, str(migrations_py), "--verbose"]
)
assert result.exit_code == 0
expected_diff = """
Schema diff:
ON "_sqlite_migrations" ("migration_set", "name");
CREATE TABLE "dogs" (
"id" INTEGER,
- "name" TEXT
+ "name" TEXT,
+ "age" INTEGER,
+ "weight" REAL
);
""".strip()
assert expected_diff in result.output
def test_stop_before(two_migrations):
path, _ = two_migrations
db_path = str(path / "test.db")
result = CliRunner().invoke(
sqlite_utils.cli.cli,
[
"migrate",
db_path,
str(path / "foo" / "migrations.py"),
"--stop-before",
"bar",
],
)
assert result.exit_code == 0
db = sqlite_utils.Database(db_path)
assert db["foo"].exists()
assert not db["bar"].exists()
def test_stop_before_multiple_sets_unqualified(two_migrations):
path, _ = two_migrations
db_path = str(path / "test.db")
(path / "foo" / "migrations2.py").write_text(
"""
from sqlite_utils import Migrations
m = Migrations("hello2")
@m()
def foo(db):
db["foo"].insert({"hello": "world"})
""",
"utf-8",
)
result = CliRunner().invoke(
sqlite_utils.cli.cli,
[
"migrate",
db_path,
str(path / "foo" / "migrations.py"),
str(path / "foo" / "migrations2.py"),
"--stop-before",
"foo",
],
)
assert result.exit_code == 0, result.output
db = sqlite_utils.Database(db_path)
assert db.table_names() == ["_sqlite_migrations"]
assert list(db["_sqlite_migrations"].rows) == []
def test_stop_before_qualified_only_affects_named_set(two_sets_same_migration_name):
path, migrations_py = two_sets_same_migration_name
db_path = str(path / "test.db")
result = CliRunner().invoke(
sqlite_utils.cli.cli,
[
"migrate",
db_path,
str(migrations_py),
"--stop-before",
"creatures:add_weight",
],
)
assert result.exit_code == 0, result.output
db = sqlite_utils.Database(db_path)
assert db["creatures"].exists()
assert not db["creature_weights"].exists()
assert db["sales"].exists()
assert db["sales_weights"].exists()
def test_stop_before_multiple_qualified(two_sets_same_migration_name):
path, migrations_py = two_sets_same_migration_name
db_path = str(path / "test.db")
result = CliRunner().invoke(
sqlite_utils.cli.cli,
[
"migrate",
db_path,
str(migrations_py),
"--stop-before",
"creatures:add_weight",
"--stop-before",
"sales:add_weight",
],
)
assert result.exit_code == 0, result.output
db = sqlite_utils.Database(db_path)
assert db["creatures"].exists()
assert not db["creature_weights"].exists()
assert db["sales"].exists()
assert not db["sales_weights"].exists()
LEGACY_MIGRATIONS = """
import datetime
class _Migration:
def __init__(self, name, fn):
self.name = name
self.fn = fn
class _Applied:
def __init__(self, name, applied_at):
self.name = name
self.applied_at = applied_at
class LegacyMigrations:
# Mimics the sqlite-migrate 0.x Migrations class, in particular
# apply(db, stop_before=None) taking a single string
migrations_table = "_sqlite_migrations"
def __init__(self, name):
self.name = name
self._migrations = []
def __call__(self, fn):
self._migrations.append(_Migration(fn.__name__, fn))
return fn
def ensure_migrations_table(self, db):
db[self.migrations_table].create(
{"migration_set": str, "name": str, "applied_at": str},
pk=("migration_set", "name"),
if_not_exists=True,
)
def applied(self, db):
self.ensure_migrations_table(db)
return [
_Applied(row["name"], row["applied_at"])
for row in db[self.migrations_table].rows_where(
"migration_set = ?", [self.name]
)
]
def pending(self, db):
applied = {m.name for m in self.applied(db)}
return [m for m in self._migrations if m.name not in applied]
def apply(self, db, stop_before=None):
for migration in self.pending(db):
if migration.name == stop_before:
return
migration.fn(db)
db[self.migrations_table].insert(
{
"migration_set": self.name,
"name": migration.name,
"applied_at": str(
datetime.datetime.now(datetime.timezone.utc)
),
}
)
legacy = LegacyMigrations("legacy_set")
@legacy
def first(db):
db["first"].insert({"hello": "world"})
@legacy
def second(db):
db["second"].insert({"hello": "world"})
"""
def test_stop_before_unknown_name_errors(two_migrations):
path, _ = two_migrations
db_path = str(path / "test.db")
runner = CliRunner()
result = runner.invoke(
sqlite_utils.cli.cli,
["migrate", db_path, str(path), "--stop-before", "fooo"],
)
assert result.exit_code == 1
assert "--stop-before did not match any migrations: fooo" in result.output
# Nothing should have been applied
db = sqlite_utils.Database(db_path)
assert "foo" not in db.table_names()
assert "bar" not in db.table_names()
def test_stop_before_with_legacy_migrations_class(tmpdir):
path = pathlib.Path(tmpdir)
(path / "migrations.py").write_text(LEGACY_MIGRATIONS, "utf-8")
db_path = str(path / "test.db")
runner = CliRunner()
result = runner.invoke(
sqlite_utils.cli.cli,
["migrate", db_path, str(path), "--stop-before", "second"],
)
assert result.exit_code == 0, result.output
db = sqlite_utils.Database(db_path)
assert "first" in db.table_names()
assert "second" not in db.table_names()
def test_stop_before_multiple_values_for_legacy_set_errors(tmpdir):
path = pathlib.Path(tmpdir)
(path / "migrations.py").write_text(LEGACY_MIGRATIONS, "utf-8")
db_path = str(path / "test.db")
runner = CliRunner()
result = runner.invoke(
sqlite_utils.cli.cli,
[
"migrate",
db_path,
str(path),
"--stop-before",
"legacy_set:first",
"--stop-before",
"legacy_set:second",
],
)
assert result.exit_code == 1
assert "single --stop-before" in result.output
def test_list_does_not_create_database_file(two_migrations):
path, _ = two_migrations
db_path = path / "test.db"
runner = CliRunner()
result = runner.invoke(
sqlite_utils.cli.cli, ["migrate", str(db_path), str(path), "--list"]
)
assert result.exit_code == 0, result.output
assert "Pending:\n foo\n bar" in result.output
# Listing migrations must not create the database file
assert not db_path.exists()
def test_list_does_not_upgrade_legacy_migrations_table(two_migrations):
path, _ = two_migrations
db_path = str(path / "test.db")
db = sqlite_utils.Database(db_path)
db["_sqlite_migrations"].create(
{"migration_set": str, "name": str, "applied_at": str},
pk=("migration_set", "name"),
)
db["_sqlite_migrations"].insert(
{"migration_set": "hello", "name": "foo", "applied_at": "x"}
)
db.close()
runner = CliRunner()
result = runner.invoke(
sqlite_utils.cli.cli, ["migrate", db_path, str(path), "--list"]
)
assert result.exit_code == 0, result.output
assert "foo - x" in result.output
# --list must not perform the one-way legacy schema upgrade
db2 = sqlite_utils.Database(db_path)
assert db2["_sqlite_migrations"].pks == ["migration_set", "name"]
db2.close()
def test_stop_before_applied_migration_errors(two_migrations):
path, _ = two_migrations
db_path = str(path / "test.db")
migrations_path = str(path / "foo" / "migrations.py")
# Apply everything first
first = CliRunner().invoke(
sqlite_utils.cli.cli,
["migrate", db_path, migrations_path, "--stop-before", "bar"],
)
assert first.exit_code == 0
# foo is now applied - stopping before it is an error, and bar
# must not be applied as a side effect
result = CliRunner().invoke(
sqlite_utils.cli.cli,
["migrate", db_path, migrations_path, "--stop-before", "foo"],
)
assert result.exit_code != 0
assert "already been applied" in result.output
db = sqlite_utils.Database(db_path)
assert not db["bar"].exists()
def test_list_with_legacy_class_is_read_only(tmpdir):
# Legacy sqlite-migrate classes create the _sqlite_migrations table
# from their pending()/applied() methods - --list must roll that
# back so it stays a read-only operation as documented
path = pathlib.Path(tmpdir)
(path / "migrations.py").write_text(LEGACY_MIGRATIONS, "utf-8")
db_path = str(path / "test.db")
db = sqlite_utils.Database(db_path)
db["existing"].insert({"id": 1})
db.close()
result = CliRunner().invoke(
sqlite_utils.cli.cli, ["migrate", db_path, str(path), "--list"]
)
assert result.exit_code == 0, result.output
assert "first" in result.output
db2 = sqlite_utils.Database(db_path)
assert "_sqlite_migrations" not in db2.table_names()
db2.close()

233
tests/test_column_casing.py Normal file
View file

@ -0,0 +1,233 @@
"""
SQLite treats column names as case-insensitive. These tests exercise the
places where sqlite-utils performs Python-side lookups of column names
provided by the caller, which should match the schema case-insensitively.
https://github.com/simonw/sqlite-utils/issues/760
"""
import pytest
from sqlite_utils import Database
from sqlite_utils.db import ForeignKey
def test_insert_populates_last_pk_case_insensitively(fresh_db):
books = fresh_db["books"]
books.create({"Id": int, "Title": str}, pk="Id")
books.insert({"Id": 1, "Title": "One"}, pk="id")
assert books.last_pk == 1
def test_insert_populates_last_pk_compound_pk_case_insensitively(fresh_db):
books = fresh_db["books"]
books.create({"Author": str, "Position": int, "Title": str})
books.insert(
{"Author": "Sue", "Position": 1, "Title": "One"}, pk=("author", "position")
)
assert books.last_pk == ("Sue", 1)
@pytest.mark.parametrize("use_old_upsert", (False, True))
def test_upsert_pk_case_differs_from_schema(use_old_upsert):
db = Database(memory=True, use_old_upsert=use_old_upsert)
books = db["books"]
books.create({"Id": int, "Title": str}, pk="Id")
books.insert({"Id": 1, "Title": "One"})
books.upsert({"id": 1, "title": "Won"}, pk="id")
assert list(books.rows) == [{"Id": 1, "Title": "Won"}]
assert books.last_pk == 1
@pytest.mark.parametrize("use_old_upsert", (False, True))
def test_upsert_record_key_case_differs_from_pk(use_old_upsert):
# all_columns comes from the record keys, pk= from the caller
db = Database(memory=True, use_old_upsert=use_old_upsert)
books = db["books"]
books.create({"Id": int, "Title": str}, pk="Id")
books.upsert({"ID": 1, "Title": "One"}, pk="id")
assert list(books.rows) == [{"Id": 1, "Title": "One"}]
assert books.last_pk == 1
def test_upsert_inferred_pk_case_differs_from_record_keys(fresh_db):
# pk is inferred from the existing schema as "Id", records use "id"
books = fresh_db["books"]
books.create({"Id": int, "Title": str}, pk="Id")
books.upsert({"id": 1, "title": "One"})
assert list(books.rows) == [{"Id": 1, "Title": "One"}]
assert books.last_pk == 1
def test_upsert_list_mode_pk_case_insensitive(fresh_db):
books = fresh_db["books"]
books.create({"Id": int, "Title": str}, pk="Id")
books.upsert_all([["id", "title"], [1, "One"]], pk="Id")
assert list(books.rows) == [{"Id": 1, "Title": "One"}]
assert books.last_pk == 1
def test_lookup_pk_case_insensitive(fresh_db):
fresh_db["species"].create({"ID": int, "Name": str}, pk="ID")
fresh_db["species"].insert({"ID": 5, "Name": "Palm"})
fresh_db["species"].create_index(["Name"], unique=True)
assert fresh_db["species"].lookup({"Name": "Palm"}, pk="id") == 5
def test_lookup_does_not_create_redundant_index(fresh_db):
fresh_db["species"].create({"id": int, "Name": str}, pk="id")
fresh_db["species"].create_index(["Name"], unique=True)
fresh_db["species"].lookup({"name": "Palm"})
assert len(fresh_db["species"].indexes) == 1
def test_create_table_transform_same_columns_different_case(fresh_db):
fresh_db["t"].create({"Name": str, "Age": int})
fresh_db["t"].insert({"Name": "Cleo", "Age": 5})
fresh_db.create_table("t", {"name": str, "age": int}, transform=True)
# Schema casing is preserved - SQLite considers these the same columns
assert fresh_db["t"].columns_dict == {"Name": str, "Age": int}
assert list(fresh_db["t"].rows) == [{"Name": "Cleo", "Age": 5}]
def test_create_table_transform_case_insensitive_with_changes(fresh_db):
fresh_db["t"].create({"Name": str, "Age": int})
fresh_db.create_table("t", {"name": str, "age": str, "size": int}, transform=True)
# age changed type, size added, Name untouched
assert fresh_db["t"].columns_dict == {"Name": str, "Age": str, "size": int}
def test_transform_types_case_insensitive(fresh_db):
fresh_db["t"].create({"Name": str, "Age": str})
fresh_db["t"].transform(types={"age": int})
assert fresh_db["t"].columns_dict == {"Name": str, "Age": int}
def test_transform_rename_case_insensitive(fresh_db):
fresh_db["t"].create({"Name": str})
fresh_db["t"].transform(rename={"name": "title"})
assert fresh_db["t"].columns_dict == {"title": str}
def test_transform_drop_case_insensitive(fresh_db):
fresh_db["t"].create({"Name": str, "Age": int})
fresh_db["t"].transform(drop=["name"])
assert fresh_db["t"].columns_dict == {"Age": int}
def test_transform_not_null_and_defaults_case_insensitive(fresh_db):
fresh_db["t"].create({"Name": str, "Age": int})
fresh_db["t"].transform(not_null={"name"}, defaults={"age": 3})
columns = {c.name: c for c in fresh_db["t"].columns}
assert columns["Name"].notnull
assert fresh_db["t"].default_values == {"Age": 3}
def test_transform_pk_case_insensitive(fresh_db):
fresh_db["t"].create({"Id": int, "Name": str})
fresh_db["t"].transform(pk="id")
assert fresh_db["t"].pks == ["Id"]
assert fresh_db["t"].columns_dict == {"Id": int, "Name": str}
def test_transform_drop_foreign_keys_case_insensitive(fresh_db):
fresh_db["parent"].create({"Id": int}, pk="Id")
fresh_db["child"].create(
{"id": int, "Parent_ID": int},
pk="id",
foreign_keys=[("Parent_ID", "parent", "Id")],
)
fresh_db["child"].transform(drop_foreign_keys=["parent_id"])
assert fresh_db["child"].foreign_keys == []
def test_add_foreign_key_case_insensitive(fresh_db):
fresh_db["parent"].create({"Id": int}, pk="Id")
fresh_db["child"].create({"id": int, "Parent_ID": int}, pk="id")
fresh_db["child"].add_foreign_key("parent_id", "parent", "id")
fks = fresh_db["child"].foreign_keys
assert len(fks) == 1
# The foreign key should use the schema casing of the columns
assert fks[0].column == "Parent_ID"
assert fks[0].other_column == "Id"
def test_add_foreign_keys_case_insensitive(fresh_db):
fresh_db["parent"].create({"Id": int}, pk="Id")
fresh_db["child"].create({"id": int, "Parent_ID": int}, pk="id")
fresh_db.add_foreign_keys([("child", "parent_id", "parent", "id")])
fks = fresh_db["child"].foreign_keys
assert len(fks) == 1
assert fks[0].column == "Parent_ID"
assert fks[0].other_column == "Id"
def test_add_foreign_key_detects_existing_case_insensitively(fresh_db):
fresh_db["parent"].create({"Id": int}, pk="Id")
fresh_db["child"].create(
{"id": int, "Parent_ID": int},
pk="id",
foreign_keys=[("Parent_ID", "parent", "Id")],
)
# ignore=True should treat this as already existing, not add a duplicate
fresh_db["child"].add_foreign_key("parent_id", "parent", "id", ignore=True)
assert len(fresh_db["child"].foreign_keys) == 1
def test_add_column_fk_col_case_insensitive(fresh_db):
fresh_db["parent"].create({"Id": int}, pk="Id")
fresh_db["child"].create({"id": int}, pk="id")
fresh_db["child"].add_column("parent_id", int, fk="parent", fk_col="id")
fks = fresh_db["child"].foreign_keys
assert len(fks) == 1
assert fks[0].other_column == "Id"
def test_extract_case_insensitive(fresh_db):
fresh_db["trees"].insert({"id": 1, "Species": "Palm"}, pk="id")
fresh_db["trees"].extract("species")
assert fresh_db["trees"].columns_dict == {"id": int, "Species_id": int}
assert list(fresh_db["Species"].rows) == [{"id": 1, "Species": "Palm"}]
def test_convert_multi_case_insensitive(fresh_db):
fresh_db["t"].insert({"id": 1, "Name": "Cleo"}, pk="id")
fresh_db["t"].convert("name", lambda v: {"upper": v.upper()}, multi=True)
assert list(fresh_db["t"].rows) == [{"id": 1, "Name": "Cleo", "upper": "CLEO"}]
def test_convert_output_case_insensitive(fresh_db):
fresh_db["t"].insert({"id": 1, "Name": "Cleo", "Upper": None}, pk="id")
fresh_db["t"].convert("name", lambda v: v.upper(), output="upper")
assert list(fresh_db["t"].rows) == [{"id": 1, "Name": "Cleo", "Upper": "CLEO"}]
def test_create_table_sql_pk_case_insensitive(fresh_db):
fresh_db["t"].create({"Id": int, "Name": str}, pk="id")
# Should not have created an extra lowercase "id" column
assert fresh_db["t"].columns_dict == {"Id": int, "Name": str}
assert fresh_db["t"].pks == ["Id"]
def test_create_table_not_null_and_defaults_case_insensitive(fresh_db):
fresh_db["t"].create(
{"Name": str, "Age": int}, not_null={"name"}, defaults={"age": 1}
)
columns = {c.name: c for c in fresh_db["t"].columns}
assert columns["Name"].notnull
assert fresh_db["t"].default_values == {"Age": 1}
def test_create_table_foreign_keys_case_insensitive(fresh_db):
fresh_db["parent"].create({"Id": int}, pk="Id")
fresh_db["child"].create(
{"id": int, "Parent_ID": int},
pk="id",
foreign_keys=[("parent_id", "parent", "id")],
)
fks = fresh_db["child"].foreign_keys
assert fks == [
ForeignKey(
table="child", column="Parent_ID", other_table="parent", other_column="Id"
)
]

View file

@ -1,6 +1,8 @@
from sqlite_utils import Database
from sqlite_utils.db import TransactionError
from sqlite_utils.utils import sqlite3
import pytest
import sys
def test_recursive_triggers():
@ -29,6 +31,24 @@ def test_sqlite_version():
assert actual == as_string
def test_database_context_manager(tmpdir):
path = str(tmpdir / "test.db")
with Database(path) as db:
db["t"].insert({"id": 1})
# Raw writes commit automatically too
db.execute("insert into t (id) values (2)")
# An explicitly opened transaction left uncommitted on purpose:
db.begin()
db.execute("insert into t (id) values (3)")
# The connection is closed...
with pytest.raises(sqlite3.ProgrammingError):
db.execute("select 1")
# ... and the open explicit transaction was rolled back, not committed
db2 = Database(path)
assert [r["id"] for r in db2["t"].rows] == [1, 2]
db2.close()
@pytest.mark.parametrize("memory", [True, False])
def test_database_close(tmpdir, memory):
if memory:
@ -39,3 +59,55 @@ def test_database_close(tmpdir, memory):
db.close()
with pytest.raises(sqlite3.ProgrammingError):
db.execute("select 1 + 1")
@pytest.mark.skipif(
sys.version_info < (3, 12),
reason="sqlite3.connect(autocommit=) requires Python 3.12",
)
@pytest.mark.parametrize("autocommit", [True, False])
def test_autocommit_connections_are_rejected(tmpdir, autocommit):
# These connection modes break commit()/rollback() in ways that
# silently lose data, so the constructor refuses them
conn = sqlite3.connect(str(tmpdir / "test.db"), autocommit=autocommit)
with pytest.raises(TransactionError):
Database(conn)
conn.close()
@pytest.mark.skipif(
sys.version_info < (3, 12),
reason="sqlite3.LEGACY_TRANSACTION_CONTROL requires Python 3.12",
)
def test_legacy_transaction_control_connection_is_accepted(tmpdir):
conn = sqlite3.connect(
str(tmpdir / "test.db"), autocommit=sqlite3.LEGACY_TRANSACTION_CONTROL
)
db = Database(conn)
db["t"].insert({"id": 1}, pk="id")
assert [r["id"] for r in db["t"].rows] == [1]
db.close()
def test_memory_attribute_for_memory_true():
db = Database(memory=True)
assert db.memory is True
assert db.memory_name is None
def test_memory_attribute_for_memory_name():
db = Database(memory_name="shared_attr")
assert db.memory is True
assert db.memory_name == "shared_attr"
def test_memory_attribute_for_memory_string_path():
db = Database(":memory:")
assert db.memory is True
assert db.memory_name is None
def test_memory_attribute_for_file_path(tmpdir):
db = Database(str(tmpdir / "file.db"))
assert db.memory is False
assert db.memory_name is None

View file

@ -77,7 +77,7 @@ def test_convert_output(fresh_db, drop, expected):
def test_convert_output_multiple_column_error(fresh_db):
table = fresh_db["table"]
with pytest.raises(AssertionError) as excinfo:
with pytest.raises(ValueError) as excinfo:
table.convert(["title", "other"], lambda v: v, output="out")
assert "output= can only be used with a single column" in str(excinfo.value)

View file

@ -3,6 +3,7 @@ from sqlite_utils.db import (
Database,
DescIndex,
AlterError,
InvalidColumns,
NoObviousTable,
OperationalError,
ForeignKey,
@ -20,7 +21,6 @@ import pathlib
import pytest
import uuid
try:
import pandas as pd # type: ignore
except ImportError:
@ -109,7 +109,7 @@ def test_create_table_with_defaults(fresh_db):
def test_create_table_with_bad_not_null(fresh_db):
with pytest.raises(AssertionError):
with pytest.raises(ValueError):
fresh_db.create_table(
"players", {"name": str, "score": int}, not_null={"mouse"}
)
@ -244,11 +244,11 @@ def test_create_table_column_order(fresh_db, use_table_factory):
# If you specify a column that doesn't point to a table, you get an error:
(("one_id", "two_id", "three_id"), NoObviousTable),
# Tuples of the wrong length get an error:
((("one_id", "one", "id", "five"), ("two_id", "two", "id")), AssertionError),
((("one_id", "one", "id", "five"), ("two_id", "two", "id")), ValueError),
# Likewise a bad column:
((("one_id", "one", "id2"),), AlterError),
# Or a list of dicts
(({"one_id": "one"},), AssertionError),
(({"one_id": "one"},), ValueError),
),
)
@pytest.mark.parametrize("use_table_factory", [True, False])
@ -701,7 +701,7 @@ def test_bulk_insert_more_than_999_values(fresh_db):
def test_error_if_more_than_999_columns(fresh_db, num_columns, should_error):
record = dict([("c{}".format(i), i) for i in range(num_columns)])
if should_error:
with pytest.raises(AssertionError):
with pytest.raises(ValueError):
fresh_db["big"].insert(record)
else:
fresh_db["big"].insert(record)
@ -809,6 +809,34 @@ def test_create_index_if_not_exists(fresh_db):
dogs.create_index(["name"], if_not_exists=True)
def test_drop_index(fresh_db):
dogs = fresh_db["dogs"]
dogs.insert({"name": "Cleo", "twitter": "cleopaws", "age": 3, "is_good_dog": True})
dogs.create_index(["name"])
assert [index.name for index in dogs.indexes] == ["idx_dogs_name"]
dogs.drop_index("idx_dogs_name")
assert dogs.indexes == []
def test_drop_index_ignore(fresh_db):
dogs = fresh_db["dogs"]
dogs.insert({"name": "Cleo"})
with pytest.raises(OperationalError, match="No index named idx_dogs_name"):
dogs.drop_index("idx_dogs_name")
dogs.drop_index("idx_dogs_name", ignore=True)
def test_drop_index_wrong_table(fresh_db):
dogs = fresh_db["dogs"]
cats = fresh_db["cats"]
dogs.insert({"name": "Cleo"})
cats.insert({"name": "Misty"})
dogs.create_index(["name"])
with pytest.raises(OperationalError, match="No index named idx_dogs_name"):
cats.drop_index("idx_dogs_name")
assert [index.name for index in dogs.indexes] == ["idx_dogs_name"]
def test_create_index_desc(fresh_db):
dogs = fresh_db["dogs"]
dogs.insert({"name": "Cleo", "twitter": "cleopaws", "age": 3, "is good dog": True})
@ -926,6 +954,58 @@ def test_insert_thousands_adds_extra_columns_after_first_100_with_alter(fresh_db
assert rows == [{"i": 101, "word": None, "extra": "Should trigger ALTER"}]
@pytest.mark.parametrize("num_rows", (0, 1, 2, 3, 10))
def test_insert_all_pk_not_in_records_raises(fresh_db, num_rows):
# https://github.com/simonw/sqlite-utils/issues/732
fresh_db.conn.execute("CREATE TABLE t (a TEXT, b INT, PRIMARY KEY (a, b))")
rows = [{"a": "x{}".format(i), "b": i} for i in range(num_rows)]
with pytest.raises(InvalidColumns) as ex:
fresh_db["t"].insert_all(rows, pk="not_a_column")
assert ex.value.args == (
"Invalid primary key column ['not_a_column'] for table t with columns ['a', 'b']",
)
assert fresh_db["t"].count == 0
@pytest.mark.parametrize("num_rows", (1, 2, 3, 10))
def test_insert_all_pk_not_in_records_alter_raises(fresh_db, num_rows):
# With alter=True the check is deferred until the record keys are
# known - a pk column that is in neither the table nor the records
# still raises
fresh_db.conn.execute("CREATE TABLE t (a TEXT, b INT, PRIMARY KEY (a, b))")
rows = [{"a": "x{}".format(i), "b": i} for i in range(num_rows)]
with pytest.raises(InvalidColumns) as ex:
fresh_db["t"].insert_all(rows, pk="not_a_column", alter=True)
assert ex.value.args == (
"Invalid primary key column ['not_a_column'] for table t with columns ['a', 'b']",
)
assert fresh_db["t"].count == 0
def test_insert_pk_in_records_with_alter_adds_column(fresh_db):
# 3.x allowed insert(pk=..., alter=True) to add the pk column from the
# records - the InvalidColumns check must not fire in that case
fresh_db["t"].insert({"a": 1})
fresh_db["t"].insert({"id": 5, "a": 2}, pk="id", alter=True)
assert fresh_db["t"].columns_dict.keys() == {"a", "id"}
assert list(fresh_db.query("select * from t order by a")) == [
{"a": 1, "id": None},
{"a": 2, "id": 5},
]
def test_insert_all_invalid_pk_alter_empty_records_is_noop(fresh_db):
# With alter=True the pk check needs record keys, so an empty iterator
# returns without error - matching the 3.x no-op for empty inserts
fresh_db.conn.execute("CREATE TABLE t (a TEXT)")
fresh_db["t"].insert_all([], pk="not_a_column", alter=True)
assert fresh_db["t"].count == 0
def test_insert_ignore(fresh_db):
fresh_db["test"].insert({"id": 1, "bar": 2}, pk="id")
# Should raise an error if we try this again
@ -938,6 +1018,114 @@ def test_insert_ignore(fresh_db):
assert rows == [{"id": 1, "bar": 2}]
def test_insert_ignore_reports_existing_row(fresh_db):
# An ignored insert (row already exists) should point last_rowid and
# last_pk at the existing conflicting row - see the Datasette insert API
fresh_db["docs"].insert({"id": 1, "title": "Exists"}, pk="id")
# Insert a conflicting row with ignore=True and no explicit pk=
table = fresh_db["docs"].insert({"id": 1, "title": "One"}, ignore=True)
assert table.last_rowid == 1
assert table.last_pk == 1
assert list(fresh_db["docs"].rows_where("rowid = ?", [table.last_rowid])) == [
{"id": 1, "title": "Exists"}
]
@pytest.mark.parametrize("rowid_alias", ("rowid", "_rowid_", "oid"))
@pytest.mark.parametrize("method", ("upsert", "insert_replace", "insert_ignore"))
def test_pk_rowid_alias_on_rowid_table(fresh_db, rowid_alias, method):
# rowid and its aliases are valid primary keys for a rowid table even
# though they are not listed among the table's columns - see the Datasette
# upsert API against tables without an explicit primary key
fresh_db["t"].insert({"title": "Hello"})
assert fresh_db["t"].pks == ["rowid"]
record = {rowid_alias: 1, "title": "Updated"}
if method == "upsert":
table = fresh_db["t"].upsert(record, pk=rowid_alias)
elif method == "insert_replace":
table = fresh_db["t"].insert(record, pk=rowid_alias, replace=True)
else:
table = fresh_db["t"].insert(record, pk=rowid_alias, ignore=True)
assert table.last_pk == 1
expected_title = "Hello" if method == "insert_ignore" else "Updated"
assert list(fresh_db["t"].rows) == [{"title": expected_title}]
def test_insert_ignore_reports_existing_row_compound_pk(fresh_db):
# Compound primary key variant of the ignored-insert lookup
fresh_db["t"].insert_all([{"a": 1, "b": 2, "note": "first"}], pk=("a", "b"))
table = fresh_db["t"].insert(
{"a": 1, "b": 2, "note": "second"}, pk=("a", "b"), ignore=True
)
assert table.last_pk == (1, 2)
assert list(fresh_db["t"].rows_where("rowid = ?", [table.last_rowid])) == [
{"a": 1, "b": 2, "note": "first"}
]
def test_insert_ignore_reports_existing_row_list_mode(fresh_db):
# List-based iteration variant of the ignored-insert lookup
fresh_db["t"].insert_all([["id", "title"], [1, "first"]], pk="id")
table = fresh_db["t"].insert_all(
[["id", "title"], [1, "second"]], pk="id", ignore=True
)
assert table.last_pk == 1
assert table.last_rowid == 1
assert list(fresh_db["t"].rows) == [{"id": 1, "title": "first"}]
def test_insert_ignore_hash_id_reports_pk(fresh_db):
# With hash_id the pk is the computed hash; the original record has no id
# column to look up so last_rowid is left unset
first = fresh_db["dogs"].insert({"name": "Cleo"}, hash_id="id")
table = fresh_db["dogs"].insert({"name": "Cleo"}, hash_id="id", ignore=True)
assert table.last_pk == first.last_pk
assert table.last_rowid is None
assert fresh_db["dogs"].count == 1
def test_insert_ignore_unresolvable_conflict_leaves_pk_unset(fresh_db):
# When the conflict cannot be resolved to a primary key lookup, last_pk and
# last_rowid are left unset rather than reporting a misleading value
# rowid table with a UNIQUE column and no primary key: no pk to look up
fresh_db["u"].db.execute("create table u (title text unique)")
fresh_db["u"].insert({"title": "x"})
table = fresh_db["u"].insert({"title": "x"}, ignore=True)
assert table.last_pk is None
assert table.last_rowid is None
assert fresh_db["u"].count == 1
# Conflict on a UNIQUE column other than the primary key: the pk value from
# the record does not match the existing row, so the lookup finds nothing
fresh_db["docs"].db.execute(
"create table docs (id integer primary key, email text unique)"
)
fresh_db["docs"].insert({"id": 1, "email": "a"}, pk="id")
table = fresh_db["docs"].insert({"id": 2, "email": "a"}, ignore=True)
assert table.last_pk is None
assert table.last_rowid is None
assert fresh_db["docs"].count == 1
def test_insert_ignore_with_pk_after_other_table_insert(fresh_db):
# https://github.com/simonw/sqlite-utils/issues/554
user = {"id": "abc", "name": "david"}
fresh_db["users"].insert(user, pk="id")
fresh_db["comments"].insert_all(
[
{"id": "def", "text": "ok"},
{"id": "ghi", "text": "great"},
],
)
table = fresh_db["users"].insert(user, pk="id", ignore=True)
assert table.last_pk == "abc"
assert list(fresh_db["users"].rows) == [user]
def test_insert_hash_id(fresh_db):
dogs = fresh_db["dogs"]
id = dogs.insert({"name": "Cleo", "twitter": "cleopaws"}, hash_id="id").last_pk
@ -1062,7 +1250,7 @@ def test_create_table_numpy(fresh_db):
def test_cannot_provide_both_filename_and_memory():
with pytest.raises(
AssertionError, match="Either specify a filename_or_conn or pass memory=True"
ValueError, match="Either specify a filename_or_conn or pass memory=True"
):
Database("/tmp/foo.db", memory=True)
@ -1215,7 +1403,7 @@ def test_create_if_not_exists(fresh_db):
def test_create_if_no_columns(fresh_db):
with pytest.raises(AssertionError) as error:
with pytest.raises(ValueError) as error:
fresh_db["t"].create({})
assert error.value.args[0] == "Tables must have at least one column"
@ -1383,7 +1571,10 @@ def test_bad_table_and_view_exceptions(fresh_db):
assert ex.value.args[0] == "Table v is actually a view"
with pytest.raises(NoView) as ex2:
fresh_db.view("t")
assert ex2.value.args[0] == "View t does not exist"
assert ex2.value.args[0] == "View t does not exist - t is a table"
with pytest.raises(NoView) as ex3:
fresh_db.view("missing")
assert ex3.value.args[0] == "View missing does not exist"
# Tests for issue #655: Table configuration should be stored in _defaults

View file

@ -15,7 +15,7 @@ def test_create_view_error(fresh_db):
def test_create_view_only_arrow_one_param(fresh_db):
with pytest.raises(AssertionError):
with pytest.raises(ValueError):
fresh_db.create_view("bar", "select 1 + 2", ignore=True, replace=True)

View file

@ -1,6 +1,5 @@
import pytest
EXAMPLES = [
("TEXT DEFAULT 'foo'", "'foo'", "'foo'"),
("TEXT DEFAULT 'foo)'", "'foo)'", "'foo)'"),
@ -22,6 +21,11 @@ EXAMPLES = [
# Strings
("TEXT DEFAULT 'CURRENT_TIMESTAMP'", "'CURRENT_TIMESTAMP'", "'CURRENT_TIMESTAMP'"),
('TEXT DEFAULT "CURRENT_TIMESTAMP"', '"CURRENT_TIMESTAMP"', '"CURRENT_TIMESTAMP"'),
# Boolean and null keyword literals must stay unquoted
("INTEGER DEFAULT TRUE", "TRUE", "TRUE"),
("INTEGER DEFAULT FALSE", "FALSE", "FALSE"),
("INTEGER DEFAULT true", "true", "true"),
("TEXT DEFAULT NULL", "NULL", "NULL"),
]
@ -32,3 +36,24 @@ def test_quote_default_value(fresh_db, column_def, initial_value, expected_value
assert expected_value == fresh_db.quote_default_value(
fresh_db["foo"].columns[0].default_value
)
def test_insert_empty_record_uses_default_values(fresh_db):
fresh_db.execute("""
CREATE TABLE has_defaults (
id INTEGER PRIMARY KEY,
name TEXT,
timestamp TEXT DEFAULT CURRENT_TIMESTAMP,
is_active INTEGER NOT NULL DEFAULT 1
)
""")
table = fresh_db["has_defaults"]
table.insert({})
rows = list(table.rows)
assert len(rows) == 1
assert rows[0]["id"] == 1
assert rows[0]["name"] is None
assert rows[0]["timestamp"] is not None
assert rows[0]["is_active"] == 1

View file

@ -1,3 +1,6 @@
import sqlite_utils
def test_delete_rowid_table(fresh_db):
table = fresh_db["table"]
table.insert({"foo": 1}).last_pk
@ -32,6 +35,21 @@ def test_delete_where_all(fresh_db):
assert table.count == 0
def test_delete_where_commits(tmpdir):
path = str(tmpdir / "test.db")
db = sqlite_utils.Database(path)
db["table"].insert_all([{"id": i} for i in range(5)], pk="id")
db["table"].delete_where("id > ?", [2])
# The connection must not be left inside an open transaction,
# otherwise subsequent atomic() blocks never commit either
assert not db.conn.in_transaction
db["table"].insert({"id": 100})
db.close()
db2 = sqlite_utils.Database(path)
assert [r["id"] for r in db2["table"].rows] == [0, 1, 2, 100]
db2.close()
def test_delete_where_analyze(fresh_db):
table = fresh_db["table"]
table.insert_all(({"id": i, "i": i} for i in range(10)), pk="id")

View file

@ -41,9 +41,9 @@ def test_convert_help():
result = CliRunner().invoke(cli.cli, ["convert", "--help"])
assert result.exit_code == 0
for expected in (
"r.jsonsplit(value, ",
"r.parsedate(value, ",
"r.parsedatetime(value, ",
"r.jsonsplit(value:",
"r.parsedate(value:",
"r.parsedatetime(value:",
):
assert expected in result.output
@ -54,7 +54,7 @@ def test_convert_help():
n
for n in dir(recipes)
if not n.startswith("_")
and n not in ("json", "parser")
and n not in ("json", "parser", "Callable", "Optional")
and callable(getattr(recipes, n))
],
)

View file

@ -5,14 +5,12 @@ import pytest
def test_duplicate(fresh_db):
# Create table using native Sqlite statement:
fresh_db.execute(
"""CREATE TABLE "table1" (
fresh_db.execute("""CREATE TABLE "table1" (
"text_col" TEXT,
"real_col" REAL,
"int_col" INTEGER,
"bool_col" INTEGER,
"datetime_col" TEXT)"""
)
"datetime_col" TEXT)""")
# Insert one row of mock data:
dt = datetime.datetime.now()
data = {

View file

@ -126,9 +126,7 @@ def test_extract_rowid_table(fresh_db):
' "common_name_latin_name_id" INTEGER REFERENCES "common_name_latin_name"("id")\n'
")"
)
assert (
fresh_db.execute(
"""
assert fresh_db.execute("""
select
tree.name,
common_name_latin_name.common_name,
@ -136,10 +134,7 @@ def test_extract_rowid_table(fresh_db):
from tree
join common_name_latin_name
on tree.common_name_latin_name_id = common_name_latin_name.id
"""
).fetchall()
== [("Tree 1", "Palm", "Arecaceae")]
)
""").fetchall() == [("Tree 1", "Palm", "Arecaceae")]
def test_reuse_lookup_table(fresh_db):
@ -194,9 +189,116 @@ def test_extract_works_with_null_values(fresh_db):
)
assert list(fresh_db["listens"].rows) == [
{"id": 1, "track_title": "foo", "album_id": 1},
{"id": 2, "track_title": "baz", "album_id": 2},
{"id": 2, "track_title": "baz", "album_id": None},
]
assert list(fresh_db["albums"].rows) == [
{"id": 1, "album_title": "bar"},
{"id": 2, "album_title": None},
]
def test_extract_null_values_single_column(fresh_db):
# https://github.com/simonw/sqlite-utils/issues/186
fresh_db["species"].insert({"id": 1, "species": "Wolf"}, pk="id")
fresh_db["individuals"].insert_all(
[
{"id": 10, "name": "Terriana", "species": "Fox"},
{"id": 11, "name": "Spenidorm", "species": None},
{"id": 12, "name": "Grantheim", "species": "Wolf"},
{"id": 13, "name": "Turnutopia", "species": None},
{"id": 14, "name": "Wargal", "species": "Wolf"},
],
pk="id",
)
fresh_db["individuals"].extract("species")
# No null row should have been added to species
assert list(fresh_db["species"].rows) == [
{"id": 1, "species": "Wolf"},
{"id": 2, "species": "Fox"},
]
assert list(fresh_db["individuals"].rows) == [
{"id": 10, "name": "Terriana", "species_id": 2},
{"id": 11, "name": "Spenidorm", "species_id": None},
{"id": 12, "name": "Grantheim", "species_id": 1},
{"id": 13, "name": "Turnutopia", "species_id": None},
{"id": 14, "name": "Wargal", "species_id": 1},
]
def test_extract_null_values_multiple_columns(fresh_db):
# A row should be extracted if at least one column is not null -
# only rows where ALL extracted columns are null are left alone
fresh_db["circulation"].insert_all(
[
{"id": 1, "title": "title one", "creator": "creator one", "year": 2018},
{"id": 2, "title": "title two", "creator": None, "year": 2019},
{"id": 3, "title": None, "creator": None, "year": 2020},
{"id": 4, "title": None, "creator": None, "year": 2021},
],
pk="id",
)
fresh_db["circulation"].extract(
["title", "creator"], table="books", fk_column="book_id"
)
assert list(fresh_db["books"].rows) == [
{"id": 1, "title": "title one", "creator": "creator one"},
{"id": 2, "title": "title two", "creator": None},
]
assert list(fresh_db["circulation"].rows) == [
{"id": 1, "book_id": 1, "year": 2018},
{"id": 2, "book_id": 2, "year": 2019},
{"id": 3, "book_id": None, "year": 2020},
{"id": 4, "book_id": None, "year": 2021},
]
def test_extract_null_values_existing_lookup_table_with_null_row(fresh_db):
# Even if the lookup table already contains an all-null row, rows where
# every extracted column is null should keep a null foreign key
fresh_db["species"].insert({"id": 1, "species": None}, pk="id")
fresh_db["individuals"].insert_all(
[
{"id": 10, "name": "Terriana", "species": "Fox"},
{"id": 11, "name": "Spenidorm", "species": None},
],
pk="id",
)
fresh_db["individuals"].extract("species")
assert list(fresh_db["species"].rows) == [
{"id": 1, "species": None},
{"id": 2, "species": "Fox"},
]
assert list(fresh_db["individuals"].rows) == [
{"id": 10, "name": "Terriana", "species_id": 2},
{"id": 11, "name": "Spenidorm", "species_id": None},
]
def test_extract_repeated_into_shared_lookup_with_nulls(fresh_db):
# Unique indexes treat NULLs as distinct, so INSERT OR IGNORE alone
# cannot dedupe NULL-containing rows against the existing lookup
# table - extracting a second table into the same lookup previously
# inserted duplicate rows that nothing pointed to
fresh_db["t1"].insert_all(
[
{"id": 1, "species": None, "common": "X"},
{"id": 2, "species": "Oak", "common": "Oak"},
],
pk="id",
)
fresh_db["t2"].insert_all([{"id": 1, "species": None, "common": "X"}], pk="id")
fresh_db["t1"].extract(["species", "common"], table="lk")
fresh_db["t2"].extract(["species", "common"], table="lk")
assert fresh_db["lk"].count == 2
# Both tables point at the same lookup row
t1_fk = fresh_db.execute("select lk_id from t1 where id = 1").fetchone()[0]
t2_fk = fresh_db.execute("select lk_id from t2 where id = 1").fetchone()[0]
assert t1_fk == t2_fk
def test_extract_repeated_into_shared_lookup_no_nulls(fresh_db):
# Non-NULL rows were already deduped by the unique index - keep it so
fresh_db["t1"].insert_all([{"id": 1, "species": "Oak"}], pk="id")
fresh_db["t2"].insert_all([{"id": 1, "species": "Oak"}], pk="id")
fresh_db["t1"].extract(["species"], table="lk")
fresh_db["t2"].extract(["species"], table="lk")
assert fresh_db["lk"].count == 1

View file

@ -67,3 +67,51 @@ def test_extracts(fresh_db, kwargs, expected_table, use_table_factory):
{"id": 2, "species_id": 1},
{"id": 3, "species_id": 2},
] == list(fresh_db["Trees"].rows)
def test_extracts_null_values(fresh_db):
# https://github.com/simonw/sqlite-utils/issues/186
# Null values should stay null, not be extracted into the lookup table
fresh_db["Trees"].insert_all(
[
{"id": 1, "species_id": "Oak"},
{"id": 2, "species_id": None},
{"id": 3, "species_id": "Palm"},
{"id": 4, "species_id": None},
],
extracts={"species_id": "Species"},
)
assert list(fresh_db["Species"].rows) == [
{"id": 1, "value": "Oak"},
{"id": 2, "value": "Palm"},
]
assert list(fresh_db["Trees"].rows) == [
{"id": 1, "species_id": 1},
{"id": 2, "species_id": None},
{"id": 3, "species_id": 2},
{"id": 4, "species_id": None},
]
def test_extracts_null_values_list_mode(fresh_db):
# Same as test_extracts_null_values but for list-based records
fresh_db["Trees"].insert_all(
[
["id", "species_id"],
[1, "Oak"],
[2, None],
[3, "Palm"],
[4, None],
],
extracts={"species_id": "Species"},
)
assert list(fresh_db["Species"].rows) == [
{"id": 1, "value": "Oak"},
{"id": 2, "value": "Palm"},
]
assert list(fresh_db["Trees"].rows) == [
{"id": 1, "species_id": 1},
{"id": 2, "species_id": None},
{"id": 3, "species_id": 2},
{"id": 4, "species_id": None},
]

691
tests/test_foreign_keys.py Normal file
View file

@ -0,0 +1,691 @@
"""Tests for compound (multi-column) foreign keys - issue #594."""
import pytest
from sqlite_utils import Database
from sqlite_utils.db import AlterError, ForeignKey
from sqlite_utils.utils import sqlite3
COMPOUND_SCHEMA = """
CREATE TABLE departments (
campus_name TEXT NOT NULL,
dept_code TEXT NOT NULL,
dept_name TEXT,
PRIMARY KEY (campus_name, dept_code)
);
CREATE TABLE courses (
course_code TEXT PRIMARY KEY,
course_name TEXT,
campus_name TEXT NOT NULL,
dept_code TEXT NOT NULL,
FOREIGN KEY (campus_name, dept_code)
REFERENCES departments(campus_name, dept_code)
);
"""
@pytest.fixture
def compound_db():
db = Database(memory=True)
db.executescript(COMPOUND_SCHEMA)
return db
def test_compound_foreign_key(compound_db):
fks = compound_db["courses"].foreign_keys
assert len(fks) == 1
fk = fks[0]
assert fk.is_compound is True
assert fk.table == "courses"
assert fk.other_table == "departments"
assert fk.columns == ("campus_name", "dept_code")
assert fk.other_columns == ("campus_name", "dept_code")
# Scalar column/other_column can't sensibly hold a compound key
assert fk.column is None
assert fk.other_column is None
def test_single_foreign_key_gets_columns_fields(fresh_db):
fresh_db["authors"].insert({"id": 1, "name": "Sally"}, pk="id")
fresh_db["books"].insert({"title": "Hedgehogs", "author_id": 1})
fresh_db["books"].add_foreign_key("author_id", "authors", "id")
fk = fresh_db["books"].foreign_keys[0]
assert fk.is_compound is False
assert fk.column == "author_id"
assert fk.other_column == "id"
assert fk.columns == ("author_id",)
assert fk.other_columns == ("id",)
def test_foreign_key_no_longer_unpacks_as_tuple(fresh_db):
# Clean break in 4.0: ForeignKey is a dataclass, not a namedtuple, so the
# old tuple unpacking and indexing patterns now fail hard.
fresh_db["authors"].insert({"id": 1, "name": "Sally"}, pk="id")
fresh_db["books"].insert({"title": "Hedgehogs", "author_id": 1})
fresh_db["books"].add_foreign_key("author_id", "authors", "id")
fk = fresh_db["books"].foreign_keys[0]
with pytest.raises(TypeError):
table, column, other_table, other_column = fk
with pytest.raises(TypeError):
fk[0]
def test_foreign_keys_are_sortable(fresh_db):
fresh_db["authors"].insert({"id": 1, "name": "Sally"}, pk="id")
fresh_db["categories"].insert({"id": 1, "name": "Wildlife"}, pk="id")
fresh_db["books"].insert({"title": "Hedgehogs", "author_id": 1, "category_id": 1})
fresh_db.add_foreign_keys(
[
("books", "author_id", "authors", "id"),
("books", "category_id", "categories", "id"),
]
)
fks = sorted(fresh_db["books"].foreign_keys)
assert fks[0].column == "author_id"
assert fks[1].column == "category_id"
def test_mixed_compound_and_single_foreign_keys_are_sortable():
# compound FKs have column=None, which must not break sorting
# against single-column FKs (None < str raises TypeError)
db = Database(memory=True)
db.executescript("""
CREATE TABLE departments (
campus_name TEXT NOT NULL,
dept_code TEXT NOT NULL,
PRIMARY KEY (campus_name, dept_code)
);
CREATE TABLE accreditations (id INTEGER PRIMARY KEY);
CREATE TABLE courses (
course_code TEXT PRIMARY KEY,
campus_name TEXT NOT NULL,
dept_code TEXT NOT NULL,
accreditation_id INTEGER REFERENCES accreditations(id),
FOREIGN KEY (campus_name, dept_code)
REFERENCES departments(campus_name, dept_code)
);
""")
fks = db["courses"].foreign_keys
assert len(fks) == 2
assert {fk.is_compound for fk in fks} == {True, False}
fks_sorted = sorted(fks)
assert fks_sorted[0].other_table == "accreditations"
assert fks_sorted[1].other_table == "departments"
@pytest.fixture
def departments_db():
db = Database(memory=True)
db.create_table(
"departments",
{"campus_name": str, "dept_code": str, "dept_name": str},
pk=("campus_name", "dept_code"),
)
return db
EXPECTED_COURSES_SCHEMA = (
'CREATE TABLE "courses" (\n'
' "course_code" TEXT PRIMARY KEY,\n'
' "campus_name" TEXT,\n'
' "dept_code" TEXT,\n'
' FOREIGN KEY ("campus_name", "dept_code") '
'REFERENCES "departments"("campus_name", "dept_code")\n'
")"
)
@pytest.mark.parametrize(
"foreign_keys",
(
[
ForeignKey(
table="courses",
column=None,
other_table="departments",
other_column=None,
columns=("campus_name", "dept_code"),
other_columns=("campus_name", "dept_code"),
is_compound=True,
)
],
[(("campus_name", "dept_code"), "departments", ("campus_name", "dept_code"))],
# Two-item form guesses the other table's primary key:
[(("campus_name", "dept_code"), "departments")],
# Lists work too, though tuples are the documented form:
[(["campus_name", "dept_code"], "departments", ["campus_name", "dept_code"])],
),
)
def test_create_table_with_compound_foreign_key(departments_db, foreign_keys):
departments_db.create_table(
"courses",
{"course_code": str, "campus_name": str, "dept_code": str},
pk="course_code",
foreign_keys=foreign_keys,
)
assert departments_db["courses"].schema == EXPECTED_COURSES_SCHEMA
fks = departments_db["courses"].foreign_keys
assert len(fks) == 1
fk = fks[0]
assert fk.is_compound is True
assert fk.columns == ("campus_name", "dept_code")
assert fk.other_table == "departments"
assert fk.other_columns == ("campus_name", "dept_code")
def test_create_table_compound_foreign_key_enforced(departments_db):
departments_db.execute("PRAGMA foreign_keys = ON")
departments_db.create_table(
"courses",
{"course_code": str, "campus_name": str, "dept_code": str},
pk="course_code",
foreign_keys=[(("campus_name", "dept_code"), "departments")],
)
departments_db["departments"].insert(
{"campus_name": "Berkeley", "dept_code": "CS", "dept_name": "Computer Science"}
)
departments_db["courses"].insert(
{"course_code": "CS101", "campus_name": "Berkeley", "dept_code": "CS"}
)
with pytest.raises(sqlite3.IntegrityError):
departments_db.execute(
"insert into courses (course_code, campus_name, dept_code) "
"values ('X1', 'Nowhere', 'NOPE')"
)
def test_create_table_compound_foreign_key_missing_other_column(departments_db):
with pytest.raises(AlterError):
departments_db.create_table(
"courses",
{"course_code": str, "campus_name": str, "dept_code": str},
pk="course_code",
foreign_keys=[
(("campus_name", "dept_code"), "departments", ("campus_name", "nope"))
],
)
def test_transform_preserves_compound_foreign_key(compound_db):
compound_db["courses"].transform(rename={"course_name": "title"})
fks = compound_db["courses"].foreign_keys
assert len(fks) == 1
fk = fks[0]
assert fk.is_compound is True
assert fk.columns == ("campus_name", "dept_code")
assert fk.other_table == "departments"
assert fk.other_columns == ("campus_name", "dept_code")
def test_transform_rename_member_column_updates_compound_foreign_key(compound_db):
compound_db["courses"].transform(rename={"campus_name": "campus"})
fks = compound_db["courses"].foreign_keys
assert len(fks) == 1
fk = fks[0]
assert fk.is_compound is True
assert fk.columns == ("campus", "dept_code")
# Referenced columns in the other table are unchanged
assert fk.other_columns == ("campus_name", "dept_code")
def test_transform_drop_member_column_drops_compound_foreign_key(compound_db):
# Matches single-column behavior: dropping the column silently
# drops the foreign key that used it
compound_db["courses"].transform(drop={"dept_code"})
assert compound_db["courses"].foreign_keys == []
assert "FOREIGN KEY" not in compound_db["courses"].schema
@pytest.mark.parametrize(
"drop_foreign_keys",
(
# A bare column name matches any foreign key it participates in:
["campus_name"],
# A tuple must match the full compound key:
[("campus_name", "dept_code")],
),
)
def test_transform_drop_compound_foreign_key(compound_db, drop_foreign_keys):
compound_db["courses"].transform(drop_foreign_keys=drop_foreign_keys)
assert compound_db["courses"].foreign_keys == []
# The columns themselves survive
assert {"campus_name", "dept_code"} <= set(
compound_db["courses"].columns_dict.keys()
)
@pytest.fixture
def courses_db(departments_db):
departments_db.create_table(
"courses",
{"course_code": str, "campus_name": str, "dept_code": str},
pk="course_code",
)
return departments_db
def test_add_compound_foreign_key(courses_db):
t = courses_db["courses"].add_foreign_key(
("campus_name", "dept_code"), "departments", ("campus_name", "dept_code")
)
# Returns self
assert t.name == "courses"
fks = courses_db["courses"].foreign_keys
assert len(fks) == 1
fk = fks[0]
assert fk.is_compound is True
assert fk.columns == ("campus_name", "dept_code")
assert fk.other_table == "departments"
assert fk.other_columns == ("campus_name", "dept_code")
def test_add_compound_foreign_key_guesses_other_columns(courses_db):
# Lists work here too, though tuples are the documented form
courses_db["courses"].add_foreign_key(["campus_name", "dept_code"], "departments")
fk = courses_db["courses"].foreign_keys[0]
assert fk.other_columns == ("campus_name", "dept_code")
def test_add_compound_foreign_key_error_if_already_exists(courses_db):
courses_db["courses"].add_foreign_key(("campus_name", "dept_code"), "departments")
with pytest.raises(AlterError) as ex:
courses_db["courses"].add_foreign_key(
("campus_name", "dept_code"), "departments"
)
assert "already exists" in ex.value.args[0]
# ignore=True should not raise
courses_db["courses"].add_foreign_key(
("campus_name", "dept_code"), "departments", ignore=True
)
def test_add_compound_foreign_key_error_if_column_missing(courses_db):
with pytest.raises(AlterError):
courses_db["courses"].add_foreign_key(("campus_name", "nope"), "departments")
def test_db_add_foreign_keys_compound(courses_db):
courses_db.add_foreign_keys(
[
(
"courses",
("campus_name", "dept_code"),
"departments",
("campus_name", "dept_code"),
)
]
)
fk = courses_db["courses"].foreign_keys[0]
assert fk.is_compound is True
assert fk.columns == ("campus_name", "dept_code")
def test_index_foreign_keys_compound_creates_composite_index(compound_db):
compound_db.index_foreign_keys()
index_columns = [i.columns for i in compound_db["courses"].indexes]
assert ["campus_name", "dept_code"] in index_columns
# No separate single-column indexes for the members
assert ["campus_name"] not in index_columns
assert ["dept_code"] not in index_columns
def test_foreign_key_captures_on_delete_and_on_update():
db = Database(memory=True)
db.executescript("""
CREATE TABLE authors (id INTEGER PRIMARY KEY);
CREATE TABLE books (
id INTEGER PRIMARY KEY,
author_id INTEGER REFERENCES authors(id)
ON DELETE CASCADE ON UPDATE RESTRICT
);
""")
fk = db["books"].foreign_keys[0]
assert fk.on_delete == "CASCADE"
assert fk.on_update == "RESTRICT"
def test_foreign_key_on_delete_defaults_to_no_action(fresh_db):
fresh_db["authors"].insert({"id": 1}, pk="id")
fresh_db["books"].insert({"id": 1, "author_id": 1}, pk="id")
fresh_db["books"].add_foreign_key("author_id", "authors", "id")
fk = fresh_db["books"].foreign_keys[0]
assert fk.on_delete == "NO ACTION"
assert fk.on_update == "NO ACTION"
def test_create_table_foreign_key_with_on_delete(fresh_db):
fresh_db["authors"].insert({"id": 1}, pk="id")
fresh_db.create_table(
"books",
{"id": int, "author_id": int},
pk="id",
foreign_keys=[
ForeignKey(
table="books",
column="author_id",
other_table="authors",
other_column="id",
on_delete="CASCADE",
)
],
)
assert "ON DELETE CASCADE" in fresh_db["books"].schema
assert fresh_db["books"].foreign_keys[0].on_delete == "CASCADE"
def test_transform_preserves_on_delete_cascade():
db = Database(memory=True)
db.executescript("""
CREATE TABLE authors (id INTEGER PRIMARY KEY);
CREATE TABLE books (
id INTEGER PRIMARY KEY,
title TEXT,
author_id INTEGER REFERENCES authors(id) ON DELETE CASCADE
);
""")
db["books"].transform(rename={"title": "book_title"})
fk = db["books"].foreign_keys[0]
assert fk.on_delete == "CASCADE"
assert fk.on_update == "NO ACTION"
assert "ON DELETE CASCADE" in db["books"].schema
def test_transform_preserves_compound_foreign_key_on_delete():
db = Database(memory=True)
db.executescript("""
CREATE TABLE departments (
campus_name TEXT NOT NULL,
dept_code TEXT NOT NULL,
PRIMARY KEY (campus_name, dept_code)
);
CREATE TABLE courses (
course_code TEXT PRIMARY KEY,
campus_name TEXT NOT NULL,
dept_code TEXT NOT NULL,
FOREIGN KEY (campus_name, dept_code)
REFERENCES departments(campus_name, dept_code) ON DELETE CASCADE
);
""")
db["courses"].transform(rename={"course_code": "code"})
fk = db["courses"].foreign_keys[0]
assert fk.is_compound is True
assert fk.on_delete == "CASCADE"
assert "ON DELETE CASCADE" in db["courses"].schema
def test_implicit_primary_key_reference_is_resolved():
# REFERENCES authors (no column) has "to" of None in the pragma -
# it should be resolved to the primary key of the other table
db = Database(memory=True)
db.executescript("""
CREATE TABLE authors (author_id INTEGER PRIMARY KEY);
CREATE TABLE books (
id INTEGER PRIMARY KEY,
author_id INTEGER REFERENCES authors
);
""")
fk = db["books"].foreign_keys[0]
assert fk.is_compound is False
assert fk.other_column == "author_id"
assert fk.other_columns == ("author_id",)
def test_implicit_compound_primary_key_reference_is_resolved():
db = Database(memory=True)
db.executescript("""
CREATE TABLE departments (
campus_name TEXT NOT NULL,
dept_code TEXT NOT NULL,
PRIMARY KEY (campus_name, dept_code)
);
CREATE TABLE courses (
course_code TEXT PRIMARY KEY,
campus_name TEXT NOT NULL,
dept_code TEXT NOT NULL,
FOREIGN KEY (campus_name, dept_code) REFERENCES departments
);
""")
fk = db["courses"].foreign_keys[0]
assert fk.is_compound is True
assert fk.other_columns == ("campus_name", "dept_code")
def test_foreign_key_normalizes_list_columns_to_tuples():
# Compound columns passed as lists are normalized to tuples, so they
# compare equal to introspected ForeignKeys
fk = ForeignKey(
table="courses",
column=None,
other_table="departments",
other_column=None,
columns=["campus_name", "dept_code"],
other_columns=["campus_name", "dept_code"],
is_compound=True,
)
assert fk.columns == ("campus_name", "dept_code")
assert fk.other_columns == ("campus_name", "dept_code")
def test_add_foreign_keys_preserves_actions(fresh_db):
# https://github.com/simonw/sqlite-utils/issues/594 review finding:
# ForeignKey objects passed to db.add_foreign_keys() were flattened
# to plain tuples, losing on_delete/on_update
fresh_db["authors"].insert({"id": 1}, pk="id")
fresh_db["books"].insert({"id": 1, "author_id": 1}, pk="id")
fresh_db.add_foreign_keys(
[ForeignKey("books", "author_id", "authors", "id", on_delete="CASCADE")]
)
fk = fresh_db["books"].foreign_keys[0]
assert fk.on_delete == "CASCADE"
assert "ON DELETE CASCADE" in fresh_db["books"].schema
def test_add_foreign_keys_preserves_actions_compound(courses_db):
courses_db.add_foreign_keys(
[
ForeignKey(
table="courses",
column=None,
other_table="departments",
other_column=None,
columns=("campus_name", "dept_code"),
other_columns=("campus_name", "dept_code"),
is_compound=True,
on_delete="CASCADE",
)
]
)
fk = courses_db["courses"].foreign_keys[0]
assert fk.is_compound is True
assert fk.on_delete == "CASCADE"
assert "ON DELETE CASCADE" in courses_db["courses"].schema
def test_add_foreign_key_on_delete_on_update(fresh_db):
fresh_db["authors"].insert({"id": 1}, pk="id")
fresh_db["books"].insert({"id": 1, "author_id": 1}, pk="id")
fresh_db["books"].add_foreign_key(
"author_id", "authors", "id", on_delete="CASCADE", on_update="RESTRICT"
)
fk = fresh_db["books"].foreign_keys[0]
assert fk.on_delete == "CASCADE"
assert fk.on_update == "RESTRICT"
assert "ON UPDATE RESTRICT ON DELETE CASCADE" in fresh_db["books"].schema
# The cascade should actually fire
fresh_db.execute("PRAGMA foreign_keys = ON")
fresh_db.execute("delete from authors where id = 1")
assert fresh_db["books"].count == 0
def test_add_compound_foreign_key_on_delete(courses_db):
courses_db["courses"].add_foreign_key(
("campus_name", "dept_code"), "departments", on_delete="SET NULL"
)
fk = courses_db["courses"].foreign_keys[0]
assert fk.is_compound is True
assert fk.on_delete == "SET NULL"
assert "ON DELETE SET NULL" in courses_db["courses"].schema
def test_implicit_compound_foreign_key_resolves_pk_declaration_order(fresh_db):
# The other table's PRIMARY KEY declares its columns in a different
# order to the table's column order. SQLite resolves the implicit
# "REFERENCES other" using PRIMARY KEY declaration order, so the
# introspected other_columns must too
fresh_db.execute("create table other (b text, a text, primary key (a, b))")
fresh_db.execute(
"create table child (x text, y text, foreign key (x, y) references other)"
)
fk = fresh_db["child"].foreign_keys[0]
assert fk.other_columns == ("a", "b")
def test_transform_implicit_compound_foreign_key_stays_valid(fresh_db):
# transform() rewrites the implicit FK with explicit columns - they
# must be in PRIMARY KEY declaration order or valid data fails the
# foreign key check with an IntegrityError
fresh_db.execute("create table other (b text, a text, primary key (a, b))")
fresh_db.execute(
"create table child (x text, y text, foreign key (x, y) references other)"
)
fresh_db.execute("PRAGMA foreign_keys = ON")
fresh_db["other"].insert({"a": "A", "b": "B"})
fresh_db["child"].insert({"x": "A", "y": "B"})
fresh_db["child"].transform(types={"x": str})
assert fresh_db["child"].foreign_keys[0].other_columns == ("a", "b")
# The constraint still points the right way around
fresh_db["child"].insert({"x": "A", "y": "B"})
with pytest.raises(sqlite3.IntegrityError):
fresh_db["child"].insert({"x": "B", "y": "A"})
def test_create_compound_foreign_key_guesses_pk_declaration_order(fresh_db):
fresh_db.execute("create table other (b text, a text, primary key (a, b))")
fresh_db["other"].insert({"a": "A", "b": "B"})
fresh_db["child"].create(
{"id": int, "x": str, "y": str},
pk="id",
foreign_keys=[(("x", "y"), "other")],
)
assert fresh_db["child"].foreign_keys[0].other_columns == ("a", "b")
fresh_db.execute("PRAGMA foreign_keys = ON")
fresh_db["child"].insert({"id": 1, "x": "A", "y": "B"})
with pytest.raises(sqlite3.IntegrityError):
fresh_db["child"].insert({"id": 2, "x": "B", "y": "A"})
def test_add_compound_foreign_key_guesses_pk_declaration_order(fresh_db):
fresh_db.execute("create table other (b text, a text, primary key (a, b))")
fresh_db["child"].insert({"id": 1, "x": "A", "y": "B"}, pk="id")
fresh_db["child"].add_foreign_key(("x", "y"), "other")
assert fresh_db["child"].foreign_keys[0].other_columns == ("a", "b")
def test_foreign_keys_are_hashable(fresh_db):
# set() over foreign_keys worked with the 3.x namedtuple and must
# keep working with the dataclass
fresh_db["p"].insert({"id": 1}, pk="id")
fresh_db["c"].insert(
{"id": 1, "pid": 1}, pk="id", foreign_keys=[("pid", "p", "id")]
)
fks = set(fresh_db["c"].foreign_keys)
assert len(fks) == 1
assert ForeignKey("c", "pid", "p", "id") in fks
# Usable as dict keys too
assert {fk: True for fk in fks}
def test_foreign_key_is_immutable():
import dataclasses
fk = ForeignKey("c", "pid", "p", "id")
with pytest.raises(dataclasses.FrozenInstanceError):
fk.table = "other"
def test_foreign_key_equality_and_hash_include_actions():
# Two foreign keys differing only in ON DELETE behavior are different
# constraints - they compare unequal and hash separately
plain = ForeignKey("c", "pid", "p", "id")
cascade = ForeignKey("c", "pid", "p", "id", on_delete="CASCADE")
assert plain != cascade
assert len({plain, cascade}) == 2
assert plain == ForeignKey("c", "pid", "p", "id")
def test_create_table_mixed_foreign_keys_list(fresh_db):
# 3.x accepted a mix of ForeignKey objects, tuples and bare column
# strings in foreign_keys= (ForeignKey was a namedtuple, so it passed
# the tuple check) - keep accepting the mix
fresh_db["authors"].insert({"id": 1}, pk="id")
fresh_db["publishers"].insert({"id": 1}, pk="id")
fresh_db["books"].create(
{"id": int, "author_id": int, "publisher_id": int},
pk="id",
foreign_keys=[
ForeignKey("books", "author_id", "authors", "id"),
("publisher_id", "publishers", "id"),
],
)
fks = {fk.column: fk.other_table for fk in fresh_db["books"].foreign_keys}
assert fks == {"author_id": "authors", "publisher_id": "publishers"}
def test_create_table_mixed_foreign_keys_with_string(fresh_db):
fresh_db["authors"].insert({"id": 1}, pk="id")
fresh_db["publishers"].insert({"id": 1}, pk="id")
fresh_db["books"].create(
{"id": int, "author_id": int, "publisher_id": int},
pk="id",
foreign_keys=[
"author_id", # bare column, table and column guessed
("publisher_id", "publishers", "id"),
],
)
fks = {fk.column: fk.other_table for fk in fresh_db["books"].foreign_keys}
assert fks == {"author_id": "authors", "publisher_id": "publishers"}
def test_add_foreign_keys_existing_with_different_actions_errors(fresh_db):
# Requesting an existing foreign key with different ON DELETE/ON UPDATE
# actions was silently skipped, dropping the requested change
fresh_db["authors"].insert({"id": 1}, pk="id")
fresh_db["books"].insert(
{"id": 1, "author_id": 1},
pk="id",
foreign_keys=[("author_id", "authors", "id")],
)
with pytest.raises(AlterError) as ex:
fresh_db.add_foreign_keys(
[ForeignKey("books", "author_id", "authors", "id", on_delete="CASCADE")]
)
assert "ON DELETE" in str(ex.value)
assert fresh_db["books"].foreign_keys[0].on_delete == "NO ACTION"
def test_add_foreign_keys_identical_existing_is_noop(fresh_db):
# An exact match, including actions, is silently skipped so repeated
# calls stay idempotent
fresh_db["authors"].insert({"id": 1}, pk="id")
fresh_db["books"].insert({"id": 1, "author_id": 1}, pk="id")
fresh_db["books"].add_foreign_key("author_id", "authors", "id", on_delete="CASCADE")
fresh_db.add_foreign_keys(
[ForeignKey("books", "author_id", "authors", "id", on_delete="CASCADE")]
)
fks = fresh_db["books"].foreign_keys
assert len(fks) == 1
assert fks[0].on_delete == "CASCADE"
def test_add_foreign_keys_compound_column_count_mismatch_errors(fresh_db):
# Previously the extra other-column was silently discarded, creating
# a single-column foreign key to just ("id")
fresh_db["departments"].insert(
{"campus": "north", "code": "cs"}, pk=("campus", "code")
)
fresh_db["courses"].insert({"id": 1, "campus": "north"}, pk="id")
with pytest.raises(ValueError) as ex:
fresh_db.add_foreign_keys(
[("courses", ("campus",), "departments", ("campus", "code"))]
)
assert "same number of columns" in str(ex.value)
assert fresh_db["courses"].foreign_keys == []

View file

@ -83,6 +83,20 @@ def test_enable_fts_escape_table_names(fresh_db):
assert [] == list(table.search("bar"))
def test_search_duplicate_columns_are_deduped(fresh_db):
# https://github.com/simonw/sqlite-utils/issues/624
table = fresh_db["t"]
table.insert_all(search_records)
table.enable_fts(["text", "country"], fts_version="FTS4")
rows = list(table.search("tanuki", columns=["text", "text"]))
assert rows == [
{
"text": "tanuki are running tricksters",
"text_2": "tanuki are running tricksters",
}
]
def test_search_limit_offset(fresh_db):
table = fresh_db["t"]
table.insert_all(search_records)
@ -336,6 +350,24 @@ def test_rebuild_fts(fresh_db):
assert len(rows2) == 2
@pytest.mark.parametrize("method", ["optimize", "rebuild_fts"])
def test_optimize_and_rebuild_fts_commit(tmpdir, method):
path = str(tmpdir / "test.db")
db = Database(path)
table = db["searchable"]
table.insert(search_records[0])
table.enable_fts(["text", "country"])
getattr(table, method)()
# The connection must not be left inside an open transaction,
# otherwise this and all subsequent writes are lost on close
assert not db.conn.in_transaction
table.insert(search_records[1])
db.close()
db2 = Database(path)
assert db2["searchable"].count == 2
db2.close()
@pytest.mark.parametrize("invalid_table", ["does_not_exist", "not_searchable"])
def test_rebuild_fts_invalid(fresh_db, invalid_table):
fresh_db["not_searchable"].insert({"foo": "bar"})
@ -420,12 +452,35 @@ def test_enable_fts_replace_does_nothing_if_args_the_same():
assert all(q[0].startswith("select ") for q in queries)
def test_enable_fts_error_message_on_views():
def test_enable_fts_replace_handles_legacy_bracket_quoted_content_table():
db = Database(memory=True)
db["books"].insert(
{
"id": 1,
"title": "Habits of Australian Marsupials",
"author": "Marlee Hawkins",
},
pk="id",
)
db.executescript("""
CREATE VIRTUAL TABLE [books_fts] USING FTS5 (
[title],
content=[books]
);
""")
db["books"].enable_fts(["title", "author"], replace=True)
assert db["books_fts"].columns_dict.keys() == {"title", "author"}
assert 'content="books"' in db["books_fts"].schema
def test_view_has_no_enable_fts():
db = Database(memory=True)
db.create_view("hello", "select 1 + 1")
with pytest.raises(NotImplementedError) as e:
db["hello"].enable_fts()
assert e.value.args[0] == "enable_fts() is supported on tables but not on views"
# Views deliberately do not have an enable_fts() method
with pytest.raises(AttributeError):
db["hello"].enable_fts() # type: ignore[union-attr]
@pytest.mark.parametrize(
@ -677,3 +732,17 @@ def test_search_quote(fresh_db):
list(table.search(query))
# No exception with quote=True
list(table.search(query, quote=True))
def test_enable_fts_cli_on_view_errors(tmpdir):
db_path = str(tmpdir / "test.db")
db = Database(db_path)
db["t"].insert({"text": "hello"})
db.create_view("v", "select * from t")
db.close()
from click.testing import CliRunner
from sqlite_utils import cli as cli_module
result = CliRunner().invoke(cli_module.cli, ["enable-fts", db_path, "v", "text"])
assert result.exit_code == 1
assert result.output.strip() == "Error: Table v is actually a view"

View file

@ -6,12 +6,6 @@ from sqlite_utils.cli import cli
from sqlite_utils.db import Database
from sqlite_utils.utils import find_spatialite, sqlite3
try:
import sqlean
except ImportError:
sqlean = None
pytestmark = [
pytest.mark.skipif(
not find_spatialite(), reason="Could not find SpatiaLite extension"
@ -20,9 +14,6 @@ pytestmark = [
not hasattr(sqlite3.Connection, "enable_load_extension"),
reason="sqlite3.Connection missing enable_load_extension",
),
pytest.mark.skipif(
sqlean is not None, reason="sqlean.py is not compatible with SpatiaLite"
),
]
@ -50,7 +41,7 @@ def test_add_geometry_column():
column_name="geometry",
geometry_type="Point",
srid=4326,
coord_dimension=2,
coord_dimension="XY",
)
assert db["geometry_columns"].get(["locations", "geometry"]) == {

View file

@ -2,6 +2,14 @@ from sqlite_utils.db import Index, View, Database, XIndex, XIndexColumn
import pytest
def _check_supports_strict():
"""Check if SQLite supports strict tables without leaking the database."""
db = Database(memory=True)
result = db.supports_strict
db.close()
return result
def test_table_names(existing_db):
assert ["foo"] == existing_db.table_names()
@ -101,13 +109,11 @@ def test_table_repr(fresh_db):
def test_indexes(fresh_db):
fresh_db.executescript(
"""
fresh_db.executescript("""
create table Gosh (c1 text, c2 text, c3 text);
create index Gosh_c1 on Gosh(c1);
create index Gosh_c2c3 on Gosh(c2, c3);
"""
)
""")
assert [
Index(
seq=0,
@ -122,13 +128,11 @@ def test_indexes(fresh_db):
def test_xindexes(fresh_db):
fresh_db.executescript(
"""
fresh_db.executescript("""
create table Gosh (c1 text, c2 text, c3 text);
create index Gosh_c1 on Gosh(c1);
create index Gosh_c2c3 on Gosh(c2, c3 desc);
"""
)
""")
assert fresh_db["Gosh"].xindexes == [
XIndex(
name="Gosh_c2c3",
@ -282,7 +286,7 @@ def test_use_rowid(fresh_db):
@pytest.mark.skipif(
not Database(memory=True).supports_strict,
not _check_supports_strict(),
reason="Needs SQLite version that supports strict",
)
@pytest.mark.parametrize(
@ -317,3 +321,18 @@ def test_table_default_values(fresh_db, value):
)
default_values = fresh_db["default_values"].default_values
assert default_values == {"value": value}
def test_pks_use_primary_key_declaration_order(fresh_db):
# PRIMARY KEY (a, b) declared against columns stored in order (b, a) -
# pks must follow the declaration order, which is what SQLite uses to
# resolve implicit foreign key references and compound pk lookups
fresh_db.execute("create table t (b text, a text, primary key (a, b))")
assert fresh_db["t"].pks == ["a", "b"]
def test_transform_preserves_compound_pk_declaration_order(fresh_db):
fresh_db.execute("create table t (a text, b text, c text, primary key (b, a))")
fresh_db["t"].transform(drop={"c"})
assert fresh_db["t"].pks == ["b", "a"]
assert 'PRIMARY KEY ("b", "a")' in fresh_db["t"].schema

View file

@ -157,3 +157,27 @@ def test_lookup_with_extra_insert_parameters(fresh_db):
def test_lookup_new_table_strict(fresh_db, strict):
fresh_db["species"].lookup({"name": "Palm"}, strict=strict)
assert fresh_db["species"].strict == strict or not fresh_db.supports_strict
def test_lookup_null_value_idempotent(fresh_db):
# https://github.com/simonw/sqlite-utils/issues/186
# Repeated lookups of a null value should return the same row,
# not insert a duplicate row each time
species = fresh_db["species"]
first_id = species.lookup({"name": None})
second_id = species.lookup({"name": None})
assert first_id == second_id
assert list(species.rows) == [{"id": first_id, "name": None}]
def test_lookup_compound_key_with_null_idempotent(fresh_db):
species = fresh_db["species"]
palm_id = species.lookup({"name": "Palm", "type": None})
oak_id = species.lookup({"name": "Oak", "type": "Tree"})
assert palm_id == species.lookup({"name": "Palm", "type": None})
assert oak_id == species.lookup({"name": "Oak", "type": "Tree"})
assert palm_id != oak_id
assert list(species.rows) == [
{"id": palm_id, "name": "Palm", "type": None},
{"id": oak_id, "name": "Oak", "type": "Tree"},
]

View file

@ -139,9 +139,9 @@ def test_m2m_lookup(fresh_db):
def test_m2m_requires_either_records_or_lookup(fresh_db):
people = fresh_db.table("people", pk="id").insert({"name": "Wahyu"})
with pytest.raises(AssertionError):
with pytest.raises(ValueError):
people.m2m("tags")
with pytest.raises(AssertionError):
with pytest.raises(ValueError):
people.m2m("tags", {"tag": "hello"}, lookup={"foo": "bar"})

246
tests/test_migrations.py Normal file
View file

@ -0,0 +1,246 @@
import pytest
import sqlite_utils
from sqlite_utils import Migrations
@pytest.fixture
def migrations():
migrations = Migrations("test")
@migrations()
def m001(db):
db["dogs"].insert({"name": "Cleo"})
@migrations()
def m002(db):
db["cats"].create({"name": str})
db.execute("insert into dogs (name) values ('Pancakes')")
return migrations
@pytest.fixture
def migrations_not_ordered_alphabetically():
# Names order alphabetically in the wrong direction but this
# should still be applied correctly.
migrations = Migrations("test")
@migrations()
def m002(db):
db["dogs"].insert({"name": "Cleo"})
@migrations()
def m001(db):
db["cats"].create({"name": str})
db.execute("insert into dogs (name) values ('Pancakes')")
return migrations
@pytest.fixture
def migrations2():
migrations = Migrations("test2")
@migrations()
def m001(db):
db["dogs2"].insert({"name": "Cleo"})
return migrations
def test_basic(migrations):
db = sqlite_utils.Database(memory=True)
assert db.table_names() == []
migrations.apply(db)
assert set(db.table_names()) == {"_sqlite_migrations", "dogs", "cats"}
def test_stop_before(migrations):
db = sqlite_utils.Database(memory=True)
assert db.table_names() == []
migrations.apply(db, stop_before="m002")
assert set(db.table_names()) == {"_sqlite_migrations", "dogs"}
migrations.apply(db)
assert set(db.table_names()) == {"_sqlite_migrations", "dogs", "cats"}
def test_two_migration_sets(migrations, migrations2):
db = sqlite_utils.Database(memory=True)
assert db.table_names() == []
migrations.apply(db)
migrations2.apply(db)
assert set(db.table_names()) == {"_sqlite_migrations", "dogs", "cats", "dogs2"}
def test_order_does_not_matter(migrations, migrations_not_ordered_alphabetically):
db1 = sqlite_utils.Database(memory=True)
db2 = sqlite_utils.Database(memory=True)
migrations.apply(db1)
migrations_not_ordered_alphabetically.apply(db2)
assert db1.schema == db2.schema
def test_applied_at_is_a_string(migrations):
db = sqlite_utils.Database(memory=True)
migrations.apply(db)
applied = migrations.applied(db)
assert len(applied) == 2
for migration in applied:
# applied_at is the TEXT timestamp straight from the
# _sqlite_migrations table, e.g. "2026-07-04 12:00:00.000000+00:00"
assert isinstance(migration.applied_at, str)
assert migration.applied_at.endswith("+00:00")
def test_failing_migration_rolls_back(migrations):
@migrations()
def m003(db):
db["birds"].create({"name": str})
db.execute("insert into dogs (name) values ('Dozer')")
raise ValueError("boom")
db = sqlite_utils.Database(memory=True)
with pytest.raises(ValueError):
migrations.apply(db)
# m001 and m002 committed before the failure and stay applied
assert set(db.table_names()) == {"_sqlite_migrations", "dogs", "cats"}
assert [r["name"] for r in db["dogs"].rows] == ["Cleo", "Pancakes"]
assert [m.name for m in migrations.applied(db)] == ["m001", "m002"]
# Everything m003 did was rolled back and it is still pending
assert [m.name for m in migrations.pending(db)] == ["m003"]
def test_rerun_after_failure_applies_each_migration_once():
state = {"fail": True}
migrations = Migrations("test")
@migrations()
def m001(db):
db["dogs"].insert({"name": "Cleo"})
@migrations()
def m002(db):
db["dogs"].insert({"name": "Pancakes"})
if state["fail"]:
raise ValueError("boom")
db = sqlite_utils.Database(memory=True)
with pytest.raises(ValueError):
migrations.apply(db)
state["fail"] = False
migrations.apply(db)
# m001 must not have been re-applied, m002 applied exactly once
assert [r["name"] for r in db["dogs"].rows] == ["Cleo", "Pancakes"]
assert [m.name for m in migrations.applied(db)] == ["m001", "m002"]
def test_non_transactional_migration_allows_vacuum(tmpdir):
path = str(tmpdir / "test.db")
db = sqlite_utils.Database(path)
migrations = Migrations("test")
@migrations()
def m001(db):
db["dogs"].insert({"name": "Cleo"})
@migrations(transactional=False)
def m002(db):
db.execute("VACUUM")
migrations.apply(db)
assert [m.name for m in migrations.applied(db)] == ["m001", "m002"]
db.close()
def test_apply_composes_inside_outer_transaction(migrations):
db = sqlite_utils.Database(memory=True)
with pytest.raises(ZeroDivisionError):
with db.atomic():
migrations.apply(db)
raise ZeroDivisionError
# The outer transaction rolled back, taking the migrations with it
assert db.table_names() == []
@pytest.mark.parametrize(
"create_table,pk",
(
(
{
"migration_set": str,
"name": str,
"applied_at": str,
},
"name",
),
(
{
"migration_set": str,
"name": str,
"applied_at": str,
},
("migration_set", "name"),
),
),
)
def test_upgrades_sqlite_migrations(migrations, create_table, pk):
db = sqlite_utils.Database(memory=True)
db["_sqlite_migrations"].create(create_table, pk=pk)
assert db.table_names() == ["_sqlite_migrations"]
assert db["_sqlite_migrations"].pks == ([pk] if isinstance(pk, str) else list(pk))
migrations.apply(db)
assert db["_sqlite_migrations"].pks == ["id"]
def test_pending_and_applied_are_read_only(migrations):
db = sqlite_utils.Database(memory=True)
assert [m.name for m in migrations.pending(db)] == ["m001", "m002"]
assert migrations.applied(db) == []
# Neither call should have created the tracking table
assert db.table_names() == []
def test_duplicate_migration_name_errors():
migrations = Migrations("test")
@migrations()
def m001(db):
pass
with pytest.raises(ValueError) as ex:
@migrations(name="m001")
def m001_again(db):
pass
assert "m001" in str(ex.value)
def test_stop_before_applied_migration_errors(migrations):
# Stopping before a migration that has already been applied is
# impossible to honor - previously the stop name was only checked
# against pending migrations, so everything after it was applied
db = sqlite_utils.Database(memory=True)
migrations.apply(db, stop_before="m002") # applies m001 only
with pytest.raises(ValueError) as ex:
migrations.apply(db, stop_before="m001")
assert "m001" in str(ex.value)
assert "already been applied" in str(ex.value)
# Nothing else was applied
assert not db["cats"].exists()
def test_stop_before_applied_migration_errors_before_any_apply(migrations):
# The error fires before any pending migration runs, even those that
# come before the already-applied stop target in registration order
db = sqlite_utils.Database(memory=True)
only_second = Migrations("test")
@only_second()
def m002(db):
db["cats"].create({"name": str})
only_second.apply(db) # m002 applied, m001 still pending
with pytest.raises(ValueError):
migrations.apply(db, stop_before="m002")
assert not db["dogs"].exists()

View file

@ -2,6 +2,7 @@ from click.testing import CliRunner
import click
import importlib
import pytest
import sys
from sqlite_utils import cli, Database, hookimpl, plugins
@ -9,9 +10,41 @@ def _supports_pragma_function_list():
db = Database(memory=True)
try:
db.execute("select * from pragma_function_list()")
return True
except Exception:
return False
return True
finally:
db.close()
def test_get_plugins_loads_setuptools_entrypoints_once(monkeypatch):
calls = []
monkeypatch.delattr(sys, "_called_from_test", raising=False)
monkeypatch.setattr(plugins, "_plugins_loaded", False)
monkeypatch.setattr(
plugins.pm,
"load_setuptools_entrypoints",
lambda group: calls.append(group) or 0,
)
plugins.get_plugins()
plugins.get_plugins()
assert calls == ["sqlite_utils"]
def test_get_plugins_does_not_load_setuptools_entrypoints_in_tests(monkeypatch):
calls = []
monkeypatch.setattr(sys, "_called_from_test", True, raising=False)
monkeypatch.setattr(plugins, "_plugins_loaded", False)
monkeypatch.setattr(
plugins.pm,
"load_setuptools_entrypoints",
lambda group: calls.append(group) or 0,
)
assert plugins.get_plugins() == []
assert calls == []
def test_register_commands():

View file

@ -1,5 +1,8 @@
import pytest
import types
from sqlite_utils.utils import sqlite3
def test_query(fresh_db):
fresh_db["dogs"].insert_all([{"name": "Cleo"}, {"name": "Pancakes"}])
@ -8,6 +11,268 @@ def test_query(fresh_db):
assert list(results) == [{"name": "Pancakes"}, {"name": "Cleo"}]
def test_query_executes_eagerly(fresh_db):
# The SQL runs when query() is called, not when the result is iterated,
# so errors are raised at the call site
with pytest.raises(sqlite3.OperationalError):
fresh_db.query("select * from missing_table")
def test_query_rejects_statements_that_return_no_rows(fresh_db):
fresh_db["dogs"].insert({"name": "Cleo"})
with pytest.raises(ValueError) as ex:
fresh_db.query("update dogs set name = 'Cleopaws'")
assert "execute()" in str(ex.value)
# The rejected update was rolled back, and no transaction is left open
assert not fresh_db.conn.in_transaction
assert [row["name"] for row in fresh_db["dogs"].rows] == ["Cleo"]
def test_query_rejected_ddl_is_rolled_back(fresh_db):
with pytest.raises(ValueError):
fresh_db.query("create table dogs (id integer primary key)")
assert not fresh_db.conn.in_transaction
assert fresh_db.table_names() == []
def test_query_rejected_write_inside_transaction_is_rolled_back(fresh_db):
fresh_db["dogs"].insert({"name": "Cleo"})
fresh_db.begin()
fresh_db.execute("insert into dogs (name) values ('Pancakes')")
with pytest.raises(ValueError):
fresh_db.query("update dogs set name = 'Cleopaws'")
# The transaction is still open and the earlier insert is intact
assert fresh_db.conn.in_transaction
fresh_db.commit()
assert [row["name"] for row in fresh_db["dogs"].rows] == ["Cleo", "Pancakes"]
@pytest.mark.parametrize(
"sql",
[
"begin",
"commit",
"rollback",
"vacuum",
"detach database foo",
"/* comment */ commit",
"-- comment\nbegin",
"/* multi\nline */ -- and another\n vacuum",
"\t /* a */ /* b */ savepoint s1",
"; commit",
";;\n ; rollback",
"; /* comment */ vacuum",
"\ufeffbegin",
],
)
def test_query_rejects_transaction_control_and_vacuum(fresh_db, sql):
with pytest.raises(ValueError) as ex:
fresh_db.query(sql)
assert "execute()" in str(ex.value)
assert not fresh_db.conn.in_transaction
def test_query_comment_prefixed_commit_does_not_commit_transaction(fresh_db):
# A COMMIT hidden behind a leading comment must not slip past the
# keyword check - previously it committed the caller's open
# transaction before the ValueError was raised
fresh_db["dogs"].insert({"name": "Cleo"})
fresh_db.begin()
fresh_db.execute("insert into dogs (name) values ('Pancakes')")
with pytest.raises(ValueError):
fresh_db.query("/* comment */ COMMIT")
# The explicit transaction is still open and can still be rolled back
assert fresh_db.conn.in_transaction
fresh_db.rollback()
assert [row["name"] for row in fresh_db["dogs"].rows] == ["Cleo"]
@pytest.mark.parametrize("sql", ["; COMMIT", "\ufeffCOMMIT"])
def test_query_prefixed_commit_does_not_commit_transaction(fresh_db, sql):
# sqlite3 tolerates empty statements and a UTF-8 BOM before the first
# real token, so the keyword scanner must skip them too - previously
# '; COMMIT' slipped past the check and committed the caller's open
# transaction before raising OperationalError
fresh_db["dogs"].insert({"name": "Cleo"})
fresh_db.begin()
fresh_db.execute("insert into dogs (name) values ('Pancakes')")
with pytest.raises(ValueError):
fresh_db.query(sql)
# The explicit transaction is still open and can still be rolled back
assert fresh_db.conn.in_transaction
fresh_db.rollback()
assert [row["name"] for row in fresh_db["dogs"].rows] == ["Cleo"]
def test_query_error_leaves_no_transaction_open(fresh_db):
with pytest.raises(sqlite3.OperationalError):
fresh_db.query("select * from missing_table")
assert not fresh_db.conn.in_transaction
def test_query_pragma(tmpdir):
from sqlite_utils import Database
db = Database(str(tmpdir / "test.db"))
# A row-returning PRAGMA works, including one that cannot run in a transaction
assert list(db.query("pragma journal_mode = wal")) == [{"journal_mode": "wal"}]
# A PRAGMA that returns no rows raises ValueError
with pytest.raises(ValueError):
db.query("pragma user_version = 5")
db.close()
def test_query_rejected_pragma_still_takes_effect(fresh_db):
# Documented limitation: PRAGMAs run outside the savepoint guard,
# because some of them refuse to run inside a transaction - so a
# row-less PRAGMA takes effect even though it raises ValueError.
# If this test starts failing because the pragma was rolled back,
# the limitation has been fixed - update the docs in python-api.rst
# and the query() docstring to remove the carve-out
with pytest.raises(ValueError):
fresh_db.query("pragma user_version = 5")
assert fresh_db.execute("pragma user_version").fetchone()[0] == 5
def test_query_comment_prefixed_pragma(tmpdir):
from sqlite_utils import Database
db = Database(str(tmpdir / "test.db"))
# A leading comment must not stop a PRAGMA being recognized as one -
# previously it was executed inside the savepoint guard, where
# journal mode changes are refused
assert list(db.query("-- set WAL mode\npragma journal_mode = wal")) == [
{"journal_mode": "wal"}
]
db.close()
def test_query_comment_prefixed_pragma_inside_transaction(fresh_db):
fresh_db.begin()
assert list(fresh_db.query("-- check version\npragma user_version")) == [
{"user_version": 0}
]
assert fresh_db.conn.in_transaction
fresh_db.rollback()
@pytest.mark.parametrize(
"sql,expected",
[
("select 1", "SELECT"),
(" \t\n select 1", "SELECT"),
("-- comment\nbegin", "BEGIN"),
("/* one */ /* two */ pragma user_version", "PRAGMA"),
("/* multi\nline */vacuum", "VACUUM"),
("insert into t values (1)", "INSERT"),
("-- only a comment", ""),
("/* unterminated", ""),
("", ""),
(" ", ""),
("123", ""),
("; commit", "COMMIT"),
(";;\n ; rollback", "ROLLBACK"),
("; -- comment\n begin", "BEGIN"),
("\ufeffcommit", "COMMIT"),
("\ufeff ; select 1", "SELECT"),
(";", ""),
],
)
def test_first_keyword(sql, expected):
from sqlite_utils.db import _first_keyword
assert _first_keyword(sql) == expected
@pytest.mark.skipif(
sqlite3.sqlite_version_info < (3, 35, 0),
reason="RETURNING requires SQLite 3.35.0 or higher",
)
def test_query_insert_returning(fresh_db):
fresh_db["dogs"].insert({"name": "Cleo"})
rows = list(
fresh_db.query("insert into dogs (name) values ('Pancakes') returning name")
)
assert rows == [{"name": "Pancakes"}]
assert fresh_db["dogs"].count == 2
@pytest.mark.skipif(
sqlite3.sqlite_version_info < (3, 35, 0),
reason="RETURNING requires SQLite 3.35.0 or higher",
)
def test_query_insert_returning_commits_without_iteration(tmpdir):
from sqlite_utils import Database
path = str(tmpdir / "test.db")
db = Database(path)
db["dogs"].insert({"name": "Cleo"})
# Never iterate over the results
db.query("insert into dogs (name) values ('Pancakes') returning name")
assert not db.conn.in_transaction
# A completely separate connection sees the new row straight away
other = sqlite3.connect(path)
assert other.execute("select count(*) from dogs").fetchone()[0] == 2
other.close()
db.close()
@pytest.mark.skipif(
sqlite3.sqlite_version_info < (3, 35, 0),
reason="RETURNING requires SQLite 3.35.0 or higher",
)
def test_query_insert_returning_partial_iteration_still_commits(tmpdir):
from sqlite_utils import Database
path = str(tmpdir / "test.db")
db = Database(path)
db["dogs"].insert({"name": "Cleo"})
row = next(
db.query(
"insert into dogs (name) values ('Pancakes'), ('Marnie') returning name"
)
)
assert row == {"name": "Pancakes"}
assert not db.conn.in_transaction
other = sqlite3.connect(path)
assert other.execute("select count(*) from dogs").fetchone()[0] == 3
other.close()
db.close()
@pytest.mark.skipif(
sqlite3.sqlite_version_info < (3, 35, 0),
reason="RETURNING requires SQLite 3.35.0 or higher",
)
def test_query_insert_returning_respects_explicit_transaction(fresh_db):
fresh_db["dogs"].insert({"name": "Cleo"})
fresh_db.begin()
rows = list(
fresh_db.query("insert into dogs (name) values ('Pancakes') returning name")
)
assert rows == [{"name": "Pancakes"}]
# Still inside the explicit transaction - not committed
assert fresh_db.conn.in_transaction
fresh_db.rollback()
assert [row["name"] for row in fresh_db["dogs"].rows] == ["Cleo"]
def test_query_duplicate_column_names_are_deduped(fresh_db):
# https://github.com/simonw/sqlite-utils/issues/624
fresh_db["one"].insert({"id": 1, "value": "left"})
fresh_db["two"].insert({"id": 2, "value": "right"})
rows = list(
fresh_db.query("select one.id, two.id, one.value, two.value from one, two")
)
assert rows == [{"id": 1, "id_2": 2, "value": "left", "value_2": "right"}]
def test_query_deduped_column_avoids_existing_names(fresh_db):
# The renamed duplicate must not overwrite a real column called id_2
rows = list(fresh_db.query("select 1 as id, 2 as id, 3 as id_2"))
assert rows == [{"id": 1, "id_3": 2, "id_2": 3}]
def test_execute_returning_dicts(fresh_db):
# Like db.query() but returns a list, included for backwards compatibility
# see https://github.com/simonw/sqlite-utils/issues/290
@ -15,3 +280,24 @@ def test_execute_returning_dicts(fresh_db):
assert fresh_db.execute_returning_dicts("select * from test") == [
{"id": 1, "bar": 2}
]
@pytest.mark.skipif(
sqlite3.sqlite_version_info < (3, 35, 0),
reason="RETURNING requires SQLite 3.35.0 or higher",
)
def test_query_preserves_error_from_transaction_destroying_trigger(fresh_db):
# RAISE(ROLLBACK) destroys the savepoint guard - the original
# IntegrityError must propagate, not "no such savepoint"
fresh_db.execute("create table t (id integer primary key, v text)")
fresh_db.execute("""
create trigger no_bad before insert on t
when new.v = 'bad'
begin
select raise(rollback, 'trigger says no');
end
""")
with pytest.raises(sqlite3.IntegrityError, match="trigger says no"):
fresh_db.query("insert into t (id, v) values (1, 'bad') returning id")
assert not fresh_db.conn.in_transaction
assert fresh_db.execute("select count(*) from t").fetchone()[0] == 0

View file

@ -14,8 +14,11 @@ def test_recreate_ignored_for_in_memory():
def test_recreate_not_allowed_for_connection():
conn = sqlite3.connect(":memory:")
with pytest.raises(AssertionError):
Database(conn, recreate=True)
try:
with pytest.raises(ValueError):
Database(conn, recreate=True)
finally:
conn.close()
@pytest.mark.parametrize(

View file

@ -42,39 +42,46 @@ def test_register_function_deterministic(fresh_db):
def test_register_function_deterministic_tries_again_if_exception_raised(fresh_db):
# Save the original connection so we can close it later
original_conn = fresh_db.conn
fresh_db.conn = MagicMock()
fresh_db.conn.create_function = MagicMock()
@fresh_db.register_function(deterministic=True)
def to_lower_2(s):
return s.lower()
try:
fresh_db.conn.create_function.assert_called_with(
"to_lower_2", 1, to_lower_2, deterministic=True
)
@fresh_db.register_function(deterministic=True)
def to_lower_2(s):
return s.lower()
first = True
fresh_db.conn.create_function.assert_called_with(
"to_lower_2", 1, to_lower_2, deterministic=True
)
def side_effect(*args, **kwargs):
# Raise exception only first time this is called
nonlocal first
if first:
first = False
raise sqlite3.NotSupportedError()
first = True
# But if sqlite3.NotSupportedError is raised, it tries again
fresh_db.conn.create_function.reset_mock()
fresh_db.conn.create_function.side_effect = side_effect
def side_effect(*args, **kwargs):
# Raise exception only first time this is called
nonlocal first
if first:
first = False
raise sqlite3.NotSupportedError()
@fresh_db.register_function(deterministic=True)
def to_lower_3(s):
return s.lower()
# But if sqlite3.NotSupportedError is raised, it tries again
fresh_db.conn.create_function.reset_mock()
fresh_db.conn.create_function.side_effect = side_effect
# Should have been called once with deterministic=True and once without
assert fresh_db.conn.create_function.call_args_list == [
call("to_lower_3", 1, to_lower_3, deterministic=True),
call("to_lower_3", 1, to_lower_3),
]
@fresh_db.register_function(deterministic=True)
def to_lower_3(s):
return s.lower()
# Should have been called once with deterministic=True and once without
assert fresh_db.conn.create_function.call_args_list == [
call("to_lower_3", 1, to_lower_3, deterministic=True),
call("to_lower_3", 1, to_lower_3),
]
finally:
# Close the original connection that was replaced with the mock
original_conn.close()
def test_register_function_replace(fresh_db):

View file

@ -104,3 +104,37 @@ def test_pks_and_rows_where_compound_pk(fresh_db):
(("number", 1), {"type": "number", "number": 1, "plusone": 2}),
(("number", 2), {"type": "number", "number": 2, "plusone": 3}),
]
def test_rows_where_duplicate_select_columns_are_deduped(fresh_db):
# https://github.com/simonw/sqlite-utils/issues/624
fresh_db["t"].insert({"id": 1, "name": "Cleo"})
rows = list(fresh_db["t"].rows_where(select="id, id, name"))
assert rows == [{"id": 1, "id_2": 1, "name": "Cleo"}]
def test_pks_and_rows_where_view(fresh_db):
# pks_and_rows_where() lives on Queryable so views expose it, but
# SQLite views have no rowid. Modern SQLite (3.36+) raises an
# OperationalError from the generated SQL; older versions returned
# NULL for a view's rowid. Either way it must not fail earlier with
# an AttributeError from View lacking Table-only properties
from sqlite_utils.utils import sqlite3
fresh_db["dogs"].insert({"id": 1, "name": "Cleo"}, pk="id")
fresh_db.create_view("dog_names", "select name from dogs")
try:
result = list(fresh_db["dog_names"].pks_and_rows_where())
except sqlite3.OperationalError:
pass # SQLite 3.36+: no such column: rowid
else:
# Older SQLite returns NULL rowids for views
assert result == [(None, {"rowid": None, "name": "Cleo"})]
def test_pks_and_rows_where_compound_pk_declaration_order(fresh_db):
# Compound pks are returned in PRIMARY KEY declaration order
fresh_db.execute("create table t (b text, a text, primary key (a, b))")
fresh_db["t"].insert({"a": "A", "b": "B"})
pks_and_rows = list(fresh_db["t"].pks_and_rows_where())
assert pks_and_rows == [(("A", "B"), {"b": "B", "a": "A"})]

View file

@ -48,7 +48,7 @@ def test_rows_from_file_extra_fields_strategies(ignore_extras, extras_key, expec
def test_rows_from_file_error_on_string_io():
with pytest.raises(TypeError) as ex:
rows_from_file(StringIO("id,name\r\n1,Cleo"))
rows_from_file(StringIO("id,name\r\n1,Cleo")) # type: ignore[arg-type]
assert ex.value.args == (
"rows_from_file() requires a file-like object that supports peek(), such as io.BytesIO",
)

View file

@ -6,7 +6,7 @@ import pytest
sniff_dir = pathlib.Path(__file__).parent / "sniff"
@pytest.mark.parametrize("filepath", sniff_dir.glob("example*"))
@pytest.mark.parametrize("filepath", sorted(sniff_dir.glob("example*")))
def test_sniff(tmpdir, filepath):
db_path = str(tmpdir / "test.db")
runner = CliRunner()

View file

@ -50,7 +50,7 @@ def test_with_tracer():
with db.tracer(tracer):
list(dogs.search("Cleopaws"))
assert len(collected) == 5
assert len(collected) == 4
assert collected == [
(
"SELECT name FROM sqlite_master\n"
@ -64,13 +64,12 @@ def test_with_tracer():
" )\n"
" )",
{
"like": '%VIRTUAL TABLE%USING FTS%content="dogs"%',
"like": "%VIRTUAL TABLE%USING FTS%content=[dogs]%",
"like2": '%VIRTUAL TABLE%USING FTS%content="dogs"%',
"table": "dogs",
},
),
("select name from sqlite_master where type = 'view'", None),
("select name from sqlite_master where type = 'view'", None),
("select sql from sqlite_master where name = ?", ("dogs_fts",)),
(
'with "original" as (\n'
@ -94,4 +93,4 @@ def test_with_tracer():
# Outside the with block collected should not be appended to
dogs.insert({"name": "Cleopaws"})
assert len(collected) == 5
assert len(collected) == 4

View file

@ -1,4 +1,6 @@
from sqlite_utils.db import ForeignKey, TransformError
import sqlite3
from sqlite_utils.db import ForeignKey, TransactionError, TransformError
from sqlite_utils.utils import OperationalError
import pytest
@ -224,6 +226,40 @@ def test_transform_rename_pk(fresh_db):
)
def test_transform_preserves_keyword_literal_defaults(fresh_db):
# transform() used to requote keyword-literal defaults (DEFAULT TRUE became
# DEFAULT 'TRUE'), so a default insert stored the text 'TRUE' instead of the
# integer 1 -- silent value corruption on every rebuilt table.
fresh_db.execute(
"CREATE TABLE t ("
" id INTEGER PRIMARY KEY,"
" is_active INTEGER DEFAULT TRUE,"
" flag INTEGER DEFAULT FALSE,"
" note TEXT DEFAULT NULL"
")"
)
table = fresh_db["t"]
table.insert({"id": 1})
before = fresh_db.execute("SELECT is_active, flag, note FROM t").fetchone()
assert before == (1, 0, None)
# Rebuild the table via an unrelated change.
table.transform(rename={"note": "note2"})
# The keyword literals stay unquoted in the schema ...
assert "DEFAULT TRUE" in table.schema
assert "DEFAULT FALSE" in table.schema
assert "DEFAULT NULL" in table.schema
assert "'TRUE'" not in table.schema
# ... and a fresh default insert still yields 1 / 0 / NULL, not strings.
table.insert({"id": 2})
after = fresh_db.execute(
"SELECT is_active, flag, note2 FROM t WHERE id = 2"
).fetchone()
assert after == (1, 0, None)
def test_transform_not_null(fresh_db):
dogs = fresh_db["dogs"]
dogs.insert({"id": 1, "name": "Cleo", "age": "5"}, pk="id")
@ -396,6 +432,165 @@ def test_transform_verify_foreign_keys(fresh_db):
assert fresh_db.conn.execute("PRAGMA foreign_keys").fetchone()[0]
@pytest.mark.parametrize("use_pragma_foreign_keys", [False, True])
def test_transform_on_delete_cascade_does_not_delete_records(
fresh_db, use_pragma_foreign_keys
):
# Transforming a table drops and recreates it - if another table references
# it with ON DELETE CASCADE and PRAGMA foreign_keys is on, that drop must
# not cascade and delete the referencing records
if use_pragma_foreign_keys:
fresh_db.conn.execute("PRAGMA foreign_keys=ON")
fresh_db.executescript("""
CREATE TABLE authors (id INTEGER PRIMARY KEY, name TEXT);
CREATE TABLE books (
id INTEGER PRIMARY KEY,
title TEXT,
author_id INTEGER REFERENCES authors(id) ON DELETE CASCADE
);
""")
fresh_db["authors"].insert({"id": 1, "name": "Ursula K. Le Guin"})
fresh_db["books"].insert({"id": 1, "title": "The Dispossessed", "author_id": 1})
# Transform the table on the other end of the cascading foreign key
fresh_db["authors"].transform(rename={"name": "author_name"})
assert list(fresh_db["authors"].rows) == [
{"id": 1, "author_name": "Ursula K. Le Guin"}
]
assert list(fresh_db["books"].rows) == [
{"id": 1, "title": "The Dispossessed", "author_id": 1}
]
# Transforming the table with the cascading foreign key should not
# delete its records either
fresh_db["books"].transform(rename={"title": "book_title"})
assert list(fresh_db["books"].rows) == [
{"id": 1, "book_title": "The Dispossessed", "author_id": 1}
]
if use_pragma_foreign_keys:
assert fresh_db.conn.execute("PRAGMA foreign_keys").fetchone()[0]
@pytest.mark.parametrize("on_delete", ["CASCADE", "SET NULL", "SET DEFAULT", "cascade"])
def test_transform_in_transaction_refuses_destructive_on_delete(fresh_db, on_delete):
# PRAGMA foreign_keys is a no-op inside a transaction, so transforming a
# table referenced by ON DELETE CASCADE / SET NULL / SET DEFAULT foreign
# keys inside an open transaction would fire those actions when the old
# table is dropped - transform() should refuse instead
fresh_db.conn.execute("PRAGMA foreign_keys=ON")
fresh_db.executescript("""
CREATE TABLE authors (id INTEGER PRIMARY KEY, name TEXT);
CREATE TABLE books (
id INTEGER PRIMARY KEY,
title TEXT,
author_id INTEGER REFERENCES authors(id) ON DELETE {}
);
""".format(on_delete))
fresh_db["authors"].insert({"id": 1, "name": "Ursula K. Le Guin"})
fresh_db["books"].insert({"id": 1, "title": "The Dispossessed", "author_id": 1})
previous_schema = fresh_db["authors"].schema
with fresh_db.atomic():
with pytest.raises(TransactionError) as excinfo:
fresh_db["authors"].transform(rename={"name": "author_name"})
message = str(excinfo.value)
assert "books" in message
assert "ON DELETE {}".format(on_delete.upper()) in message
# Nothing should have changed
assert fresh_db["authors"].schema == previous_schema
assert list(fresh_db["books"].rows) == [
{"id": 1, "title": "The Dispossessed", "author_id": 1}
]
assert fresh_db.conn.execute("PRAGMA foreign_keys").fetchone()[0]
def test_transform_in_transaction_refuses_self_referential_cascade(fresh_db):
# The copied table carries a foreign key referencing the original table
# name, so a self-referential cascade would wipe the copy too
fresh_db.conn.execute("PRAGMA foreign_keys=ON")
fresh_db.executescript("""
CREATE TABLE categories (
id INTEGER PRIMARY KEY,
name TEXT,
parent_id INTEGER REFERENCES categories(id) ON DELETE CASCADE
);
""")
fresh_db["categories"].insert_all(
[
{"id": 1, "name": "Fiction", "parent_id": None},
{"id": 2, "name": "Science Fiction", "parent_id": 1},
]
)
with fresh_db.atomic():
with pytest.raises(TransactionError) as excinfo:
fresh_db["categories"].transform(rename={"name": "title"})
assert "categories" in str(excinfo.value)
assert fresh_db["categories"].count == 2
def test_transform_in_transaction_allowed_with_no_action_foreign_key(fresh_db):
# An inbound foreign key without a destructive ON DELETE action is safe
# inside a transaction thanks to PRAGMA defer_foreign_keys
fresh_db.conn.execute("PRAGMA foreign_keys=ON")
fresh_db.executescript("""
CREATE TABLE authors (id INTEGER PRIMARY KEY, name TEXT);
CREATE TABLE books (
id INTEGER PRIMARY KEY,
title TEXT,
author_id INTEGER REFERENCES authors(id)
);
""")
fresh_db["authors"].insert({"id": 1, "name": "Ursula K. Le Guin"})
fresh_db["books"].insert({"id": 1, "title": "The Dispossessed", "author_id": 1})
with fresh_db.atomic():
fresh_db["authors"].transform(rename={"name": "author_name"})
assert list(fresh_db["authors"].rows) == [
{"id": 1, "author_name": "Ursula K. Le Guin"}
]
assert list(fresh_db["books"].rows) == [
{"id": 1, "title": "The Dispossessed", "author_id": 1}
]
assert fresh_db.conn.execute("PRAGMA foreign_keys").fetchone()[0]
def test_transform_in_transaction_allowed_for_child_table(fresh_db):
# The table being transformed only has an outbound foreign key - dropping
# it fires no ON DELETE actions, so this is allowed inside a transaction
fresh_db.conn.execute("PRAGMA foreign_keys=ON")
fresh_db.executescript("""
CREATE TABLE authors (id INTEGER PRIMARY KEY, name TEXT);
CREATE TABLE books (
id INTEGER PRIMARY KEY,
title TEXT,
author_id INTEGER REFERENCES authors(id) ON DELETE CASCADE
);
""")
fresh_db["authors"].insert({"id": 1, "name": "Ursula K. Le Guin"})
fresh_db["books"].insert({"id": 1, "title": "The Dispossessed", "author_id": 1})
with fresh_db.atomic():
fresh_db["books"].transform(rename={"title": "book_title"})
assert list(fresh_db["books"].rows) == [
{"id": 1, "book_title": "The Dispossessed", "author_id": 1}
]
def test_transform_in_transaction_allowed_with_foreign_keys_off(fresh_db):
# With PRAGMA foreign_keys off (the default) no cascades can fire, so
# transform inside a transaction is safe even with a CASCADE schema
fresh_db.executescript("""
CREATE TABLE authors (id INTEGER PRIMARY KEY, name TEXT);
CREATE TABLE books (
id INTEGER PRIMARY KEY,
title TEXT,
author_id INTEGER REFERENCES authors(id) ON DELETE CASCADE
);
""")
fresh_db["authors"].insert({"id": 1, "name": "Ursula K. Le Guin"})
fresh_db["books"].insert({"id": 1, "title": "The Dispossessed", "author_id": 1})
with fresh_db.atomic():
fresh_db["authors"].transform(rename={"name": "author_name"})
assert list(fresh_db["books"].rows) == [
{"id": 1, "title": "The Dispossessed", "author_id": 1}
]
def test_transform_add_foreign_keys_from_scratch(fresh_db):
_add_country_city_continent(fresh_db)
fresh_db["places"].insert(_CAVEAU)
@ -532,13 +727,63 @@ def test_transform_preserves_rowids(fresh_db, table_type):
assert previous_rows == next_rows
@pytest.mark.parametrize("strict", (False, True))
def test_transform_strict(fresh_db, strict):
dogs = fresh_db.table("dogs", strict=strict)
@pytest.mark.parametrize(
"initial_strict,transform_strict,expected_strict",
(
(False, None, False),
(True, None, True),
(False, True, True),
(True, False, False),
),
)
def test_transform_strict(fresh_db, initial_strict, transform_strict, expected_strict):
if not fresh_db.supports_strict:
pytest.skip("SQLite version does not support strict tables")
dogs = fresh_db.table("dogs", strict=initial_strict)
dogs.insert({"id": 1, "name": "Cleo"})
assert dogs.strict == strict or not fresh_db.supports_strict
dogs.transform(not_null={"name"})
assert dogs.strict == strict or not fresh_db.supports_strict
assert dogs.strict is initial_strict
dogs.transform(strict=transform_strict)
assert dogs.strict is expected_strict
def test_transform_to_strict_with_invalid_data(fresh_db):
if not fresh_db.supports_strict:
pytest.skip("SQLite version does not support strict tables")
dogs = fresh_db["dogs"]
dogs.create({"id": int})
dogs.insert({"id": "not-an-integer"})
with pytest.raises(sqlite3.IntegrityError):
dogs.transform(strict=True)
assert dogs.strict is False
assert list(dogs.rows) == [{"id": "not-an-integer"}]
assert fresh_db.table_names() == ["dogs"]
def test_transform_strict_updates_default(fresh_db):
if not fresh_db.supports_strict:
pytest.skip("SQLite version does not support strict tables")
table = fresh_db.table("items", strict=True)
table.create({"id": int})
table.transform(strict=False)
assert table.strict is False
table.create({"id": int}, replace=True)
assert table.strict is False
@pytest.mark.parametrize("method_name", ("transform", "transform_sql"))
def test_transform_to_strict_not_supported(fresh_db, method_name):
table = fresh_db["items"]
table.create({"id": int})
fresh_db._supports_strict = False
with pytest.raises(TransformError, match="SQLite does not support STRICT tables"):
getattr(table, method_name)(strict=True)
assert table.strict is False
@pytest.mark.parametrize(
@ -638,15 +883,13 @@ def test_transform_with_indexes_errors(fresh_db, transform_params):
def test_transform_with_unique_constraint_implicit_index(fresh_db):
dogs = fresh_db["dogs"]
# Create a table with a UNIQUE constraint on 'name', which creates an implicit index
fresh_db.execute(
"""
fresh_db.execute("""
CREATE TABLE dogs (
id INTEGER PRIMARY KEY,
name TEXT UNIQUE,
age INTEGER
);
"""
)
""")
dogs.insert({"id": 1, "name": "Cleo", "age": 5})
# Attempt to transform the table without modifying 'name'

View file

@ -49,6 +49,89 @@ def test_upsert_error_if_no_pk(fresh_db):
table.upsert({"id": 1, "name": "Cleo"})
@pytest.mark.parametrize("use_old_upsert", (False, True))
def test_upsert_empty_record_errors(use_old_upsert):
db = Database(memory=True, use_old_upsert=use_old_upsert)
table = db["table"]
table.insert({"id": 1, "name": "Cleo"}, pk="id")
with pytest.raises(PrimaryKeyRequired):
table.upsert({}, pk="id")
with pytest.raises(PrimaryKeyRequired):
table.upsert_all([{}, {}], pk="id")
# No rows can have been inserted
assert table.count == 1
@pytest.mark.parametrize("use_old_upsert", (False, True))
def test_upsert_missing_pk_value_errors(use_old_upsert):
db = Database(memory=True, use_old_upsert=use_old_upsert)
table = db["table"]
table.insert({"id": 1, "name": "Cleo"}, pk="id")
# Records that omit the pk column entirely
with pytest.raises(PrimaryKeyRequired):
table.upsert_all([{"name": "Pancakes"}, {"name": "Marnie"}], pk="id")
# A record with an explicit None pk value can never conflict
with pytest.raises(PrimaryKeyRequired):
table.upsert({"id": None, "name": "Pancakes"}, pk="id")
assert list(table.rows) == [{"id": 1, "name": "Cleo"}]
def test_upsert_missing_compound_pk_value_errors(fresh_db):
table = fresh_db["table"]
table.insert({"a": "x", "b": "y", "v": 1}, pk=("a", "b"))
# Missing one component of the detected compound primary key
with pytest.raises(PrimaryKeyRequired):
table.upsert({"a": "x", "v": 2})
assert list(table.rows) == [{"a": "x", "b": "y", "v": 1}]
def test_upsert_error_if_existing_table_has_no_pk(fresh_db):
table = fresh_db.create_table("table", {"id": int, "name": str})
with pytest.raises(PrimaryKeyRequired):
table.upsert({"id": 1, "name": "Cleo"})
@pytest.mark.parametrize("use_old_upsert", (False, True))
def test_upsert_uses_compound_pk_from_existing_table(use_old_upsert):
# https://github.com/simonw/sqlite-utils/issues/629
db = Database(memory=True, use_old_upsert=use_old_upsert)
db.execute("""
create table summary (
Source text,
Object text,
Category text,
Count integer,
primary key (Source, Object, Category)
)
""")
table = db["summary"]
table.upsert(
{
"Source": "Client A",
"Object": "Accounts",
"Category": "All",
"Count": 3,
}
)
assert table.last_pk == ("Client A", "Accounts", "All")
table.upsert(
{
"Source": "Client A",
"Object": "Accounts",
"Category": "All",
"Count": 4,
}
)
assert list(table.rows) == [
{
"Source": "Client A",
"Object": "Accounts",
"Category": "All",
"Count": 4,
}
]
def test_upsert_with_hash_id(fresh_db):
table = fresh_db["table"]
table.upsert({"foo": "bar"}, hash_id="pk")

View file

@ -83,3 +83,20 @@ def test_maximize_csv_field_size_limit():
)
def test_flatten(input, expected):
assert utils.flatten(input) == expected
@pytest.mark.parametrize(
"input,expected",
(
([], []),
(["id", "name"], ["id", "name"]),
(["id", "id"], ["id", "id_2"]),
(["id", "id", "id"], ["id", "id_2", "id_3"]),
# A renamed duplicate must not clobber a real column called id_2
(["id", "id", "id_2"], ["id", "id_3", "id_2"]),
(["id_2", "id", "id"], ["id_2", "id", "id_3"]),
(["id", "id", "id_2", "id_2"], ["id", "id_3", "id_2", "id_2_2"]),
),
)
def test_dedupe_keys(input, expected):
assert utils.dedupe_keys(input) == expected

View file

@ -1,5 +1,6 @@
import pytest
from sqlite_utils import Database
from sqlite_utils.db import TransactionError
@pytest.fixture
@ -21,3 +22,67 @@ def test_enable_disable_wal(db_path_tmpdir):
db.disable_wal()
assert "delete" == db.journal_mode
assert "test.db-wal" not in [f.basename for f in tmpdir.listdir()]
def test_enable_wal_inside_transaction_raises(db_path_tmpdir):
db, path, tmpdir = db_path_tmpdir
db["test"].insert({"id": 1}, pk="id")
with pytest.raises(TransactionError):
with db.atomic():
db["test"].insert({"id": 2}, pk="id")
db.enable_wal()
# The atomic() block must have rolled back cleanly and the
# journal mode must be unchanged
assert db.journal_mode == "delete"
assert [r["id"] for r in db["test"].rows] == [1]
def test_disable_wal_inside_transaction_raises(db_path_tmpdir):
db, path, tmpdir = db_path_tmpdir
db.enable_wal()
db["test"].insert({"id": 1}, pk="id")
with pytest.raises(TransactionError):
with db.atomic():
db["test"].insert({"id": 2}, pk="id")
db.disable_wal()
assert db.journal_mode == "wal"
assert [r["id"] for r in db["test"].rows] == [1]
def test_ensure_autocommit_on(db_path_tmpdir):
db, path, tmpdir = db_path_tmpdir
previous_isolation_level = db.conn.isolation_level
assert previous_isolation_level is not None
with db.ensure_autocommit_on():
# isolation_level of None means driver-level autocommit mode
assert db.conn.isolation_level is None
# Restored afterwards
assert db.conn.isolation_level == previous_isolation_level
def test_enable_wal_noop_inside_transaction_is_allowed(db_path_tmpdir):
# Calling enable_wal() when WAL is already enabled is a no-op,
# so it is fine inside a transaction
db, path, tmpdir = db_path_tmpdir
db.enable_wal()
with db.atomic():
db["test"].insert({"id": 1}, pk="id")
db.enable_wal()
assert [r["id"] for r in db["test"].rows] == [1]
def test_ensure_autocommit_on_inside_transaction_raises(db_path_tmpdir):
# Setting isolation_level commits any pending transaction as a side
# effect, silently breaking the caller's rollback guarantee - so
# entering autocommit mode with a transaction open is an error
db, path, tmpdir = db_path_tmpdir
db["test"].insert({"id": 1}, pk="id")
db.begin()
db.execute("insert into test (id) values (2)")
with pytest.raises(TransactionError):
with db.ensure_autocommit_on():
pass
# The transaction is still open and can still be rolled back
assert db.conn.in_transaction
db.rollback()
assert [r["id"] for r in db["test"].rows] == [1]