Compare commits

...

591 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
Simon Willison
8c40732e4f Changelog markup fixes, refs #682 2025-11-23 22:32:34 -08:00
Simon Willison
9ea950e3ba Removed duplicate changelog entry, refs #682 2025-11-23 22:30:15 -08:00
Simon Willison
69ee540454 Release 4.0a1
Refs #682, #542, #645, #655, #657, #659, #672, #675, #677, #679
2025-11-23 22:27:18 -08:00
Simon Willison
6dad400e14 Release notes for 4.0a1
Refs #682, #542, #645, #655, #657, #659, #672, #675, #677, #679
2025-11-23 22:26:32 -08:00
Simon Willison
35377a874b
Detect CSV/TSV column types by default (#683)
The `--detect-types` option is now automatically turned on for all commands that deal with CSV or CSV.

A new `--no-detect-types` option can be used to have all columns treated as text.

Closes #679
2025-11-23 22:23:15 -08:00
Simon Willison
0bbc68089c
--functions can take filenames, can be used multiple times (#681)
Closes #659

The --functions option now accepts:
- File paths ending in .py (e.g., --functions my_funcs.py)
- Multiple invocations (e.g., --functions foo.py --functions 'def bar(): ...')
- Inline Python code (existing behavior)

Implementation follows the same pattern as llm's --functions flag
(simonw/llm@a880c123).

Changes:
- Added multiple=True to --functions Click option in query, bulk, and memory commands
- Modified _register_functions() to detect and read .py files
- Updated _maybe_register_functions() to iterate over multiple function sources
- Removed unused bytes/bytearray handling
- Added comprehensive tests for file paths and multiple invocations
- Updated documentation with examples

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Shorter help for --functions

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-11-23 21:46:51 -08:00
Simon Willison
c872d27bb3
Use REAL not FLOAT as SQLite column type (#680)
* Use REAL not FLOAT as SQLite column type, refs #645
* Fix for REAL columns by CSV --detect-types

Refs https://github.com/simonw/sqlite-utils/issues/645#issuecomment-3568947189

* Removed note about strict and REAL
2025-11-23 21:37:59 -08:00
Simon Willison
fb93452ea8
Use double quotes not braces for tables and columns (#678)
Closes #677
2025-11-23 20:43:26 -08:00
Simon Willison
bf1ac778a3 Use uv in the Justfile, refs #674 2025-11-23 19:50:06 -08:00
Simon Willison
d328f7e765 Fix flake8 error 2025-11-23 19:11:10 -08:00
Simon Willison
52ea7d21f4 Fix for mypy errors 2025-11-23 15:53:19 -08:00
Simon Willison
96fab69256 Removed convert skip_false and --skip-false, closes #542 2025-11-23 15:40:28 -08:00
Simon Willison
fafa966300 Table.create() now stores configuration in _defaults
When configuration parameters like pk, foreign_keys, not_null, etc.
are passed to Table.create(), they are now stored in self._defaults
so that subsequent operations on the same Table instance (like insert,
upsert) will use those settings automatically.

Previously, calling db["table"].insert({...}, pk="id") would create
the table correctly but subsequent calls to insert() or upsert() on
the same table object would not know about the pk setting, causing
issues like upsert() failing with "requires a pk" error.

Closes #655

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-23 15:18:55 -08:00
Simon Willison
d5f113de48 .gitignore uv.lock and dist 2025-11-23 14:12:18 -08:00
Simon Willison
4f12c7a452 Port from setup.py to pyproject.toml
Refs #676, closes #675
2025-11-23 14:07:11 -08:00
Benson Muite
1587feca6f Use PEP639 license metadata formatting 2025-11-23 12:18:18 -08:00
Simon Willison
7ffd5052e9 table.insert_all() and table.upsert_all() now take generators of lists/tuples
Closes #672, PR #673
2025-11-23 12:17:23 -08:00
Simon Willison
dfb2dbe967 Update GitHub Actions workflow cache configuration 2025-11-23 12:12:55 -08:00
Simon Willison
076c1a0aa2 Ran black 2025-11-23 11:59:19 -08:00
fry69
42d23f5954 test: suppress PytestUnraisableExceptionWarning in dateparse_errors 2025-11-23 11:58:06 -08:00
fry69
10957305be test: fix Python 3.14 datetime deprecation 2025-11-23 11:58:06 -08:00
Simon Willison
c479ca0f44 SQLite tests against Python 3.10 2025-10-01 13:52:10 -07:00
Simon Willison
370318c695 Lowercase for geometry types
Cog test failed in CI, but these are case insensitive anyway.
2025-10-01 13:52:10 -07:00
Simon Willison
f91e4c9e52 Run cog with --diff to show errors 2025-10-01 13:52:10 -07:00
Simon Willison
1361ed5711 A bunch of fixes for Click sentinal stuff 2025-10-01 13:52:10 -07:00
Simon Willison
bccd05c9b4 Test on 3.14 and 3.14t, drop 3.9 2025-10-01 13:52:10 -07:00
Alex Chan
cf1b407207
Add a type hint for db.close() (#663)
Closes #662
2025-10-01 13:37:05 -07:00
Simon Willison
094b010fd8
db.table() only returns tables, added db.view()
* db.table() only returns tables, added db.view(), closes #657
* Massive documentation update for db.table()

Refs #656
2025-05-08 23:19:36 -07:00
Simon Willison
d892d2ae49 Fix underline length, refs #656 2025-05-08 20:53:24 -07:00
Simon Willison
9e458dea7d Release 0.4a0
Refs #646, #648, #652, #654, #656
2025-05-08 20:51:48 -07:00
Simon Willison
8e7d018fa2
Use ON CONFLICT for upsert, refs #652
* New upsert implementation, refs #652
* supports_strict now caches on self._supports_strict

PR: https://github.com/simonw/sqlite-utils/pull/653
2025-05-08 20:37:49 -07:00
Simon Willison
72f6c820f6 Small documentation updates to fix warnings 2025-05-07 17:54:29 -07:00
Simon Willison
0e4e270d44
Test against multiple SQLite versions (#654)
* Test against pre-upsert SQLite 3.23.1

Borrowed from 8f86d2af6

* Try this on Python 3.9

* select ... from pragma_function_list()

Refs https://github.com/simonw/sqlite-utils/pull/654#issuecomment-2860898278

* Fix spelling error

* Compatible with latest black

* Skip plugin test that needs pragma_function_list

Refs https://github.com/simonw/sqlite-utils/pull/654#issuecomment-2860924225

* Ran cog
2025-05-07 17:49:50 -07:00
Simon Willison
88a83f0b7e
Link to JSON Lines instead
ndjson.org has an expired domain.
2025-05-04 22:50:10 -07:00
Simon Willison
0aefbb634d Removed trogon feature in favor of sqlite-util-tui
Refs #648, #545
2025-01-10 17:15:02 -08:00
Simon Willison
04107d3fea Remove note from docs about 3.8 and deterministic, closes #646 2024-11-23 14:49:15 -08:00
Simon Willison
c544f75446 Remove 3.8 from trove tags, refs #646 2024-11-23 14:48:55 -08:00
Simon Willison
1290c50f71 Drop Python 3.9, add Python 3.13 to all CI
Refs #646
2024-11-23 14:43:04 -08:00
Simon Willison
9d7da0606e Release 3.38
Refs #628, #633, #643, #644
2024-11-23 14:38:40 -08:00
Simon Willison
4381390cf1 Skip this test on DBs that don't support strict, refs #644 2024-11-23 14:32:58 -08:00
Simon Willison
4dc2e2e9c8 Use REAL for floating point columns if table is strict, closes #644
Refs #645
2024-11-23 14:27:21 -08:00
liunux4odoo
7423296ec7
include_rank parameter for Table.search
* Add `include_rank` parameter to `Table.search`
* Test for .search(include_rank)
* Docs for table.search(include_rank)

https://github.com/simonw/sqlite-utils/pull/628

Refs #480

---------

Co-authored-by: Simon Willison <swillison@gmail.com>
2024-11-23 12:34:27 -08:00
Mat Miller
42230709f7
Recreate indexes when calling transform when possible (#634)
* Recreate indexes when calling transform when possible and raise an error when they cannot be retained automatically
* Docs for sqlite_utils.db.TransformError

Co-authored-by: Simon Willison <swillison@gmail.com>
2024-11-23 12:17:15 -08:00
Gavin Wahl
cbddfb28f9
typo in command name (#640) 2024-11-09 08:31:21 -08:00
Simon Willison
0acb1f29c2 Release 3.38a0
Refs #643
2024-11-08 12:13:57 -08:00
Simon Willison
21e80dfbcf It's return_db, not _return_db - refs #643 2024-11-08 12:13:05 -08:00
Simon Willison
2258b431d4 Neater return_db pattern for reusing memory command, closes #643 2024-11-08 12:11:17 -08:00
Simon Willison
8906f57740 Hack to support reuse of memory command, closes #643 2024-11-08 11:17:17 -08:00
Gert Goet
4c2628873c
Fix typo (#639) 2024-09-03 01:27:16 -07:00
Simon Willison
8b004b2406 Release 3.37
Refs #618, #619, #620, #621, #630, #632
2024-07-18 11:43:07 -07:00
Thomas
896411099e
Documented the use of delete_where (#630)
Documented the use of delete_where, as shown in https://github.com/simonw/sqlite-utils/issues/159
2024-07-18 11:34:24 -07:00
Simon Willison
dc79454234
Fix for np.int8 error, refs #632
Also refs:
- https://github.com/simonw/llm/issues/531
2024-07-18 11:32:48 -07:00
Simon Willison
577078fe01
ReadTheDocs formats: - pdf - epub 2024-04-09 20:34:42 -07:00
Simon Willison
1d050dcdc7 insert-files multiple --pk support, closes #621 2024-03-16 17:33:31 -07:00
Simon Willison
1feb0c4271 Docs for insert with multiple --pk, refs #621 2024-03-16 17:27:18 -07:00
Simon Willison
da92a30679 Test for insert CLI with multiple --pk, refs #621 2024-03-16 17:26:02 -07:00
Simon Willison
23be5be1dc create-table now supports multiple --pk, refs #620 2024-03-16 17:05:39 -07:00
Konstantin Baikov
1dc5da3e5d
Use context managers for file writes in tests (#618)
This removes the need to explicitly close files
and avoids accidental unclosed files
2024-03-14 21:20:19 -07:00
Simon Willison
142bb2b937 Programming Language :: Python :: 3.13
Tested in Python 3.13 as of #619
2024-03-14 21:15:20 -07:00
Simon Willison
5bd7aec4d2
Test against Python 3.13 pre-release (#619)
* Test against Python 3.13 pre-release

* Skip tests for numpy on Python 3.13

Refs https://github.com/simonw/sqlite-utils/pull/619#issuecomment-1998798451

* Try to avoid Python 3.13 cog differences

* Hide \b characters in cli-reference

* Fixed .rST warning
2024-03-14 21:14:10 -07:00
Simon Willison
17eb8184d2
Run tests against ARM macos-14 (#615)
* Run tests against ARM macos-14

https://github.blog/changelog/2024-01-30-github-actions-introducing-the-new-m1-macos-runner-available-to-open-source/

* Skip Python 3.8 and 3.9 on macos-14
2024-01-30 19:02:37 -08:00
Simon Willison
70cc0c91ab
Drop support for Python 3.7
* Drop support for Python 3.7, closes #616
* Bumped versions on a whole lot of GitHub Actions
2024-01-30 18:37:38 -08:00
Simon Willison
ff57a97482 Upgrade to latest Black 2024-01-30 18:18:52 -08:00
Simon Willison
b7def00b8c More consistent indentation 2024-01-14 09:27:58 -08:00
Simon Willison
885a0b321d Release 3.36
Refs #344, #604, #606
2023-12-07 21:31:03 -08:00
Simon Willison
f29189a3dd db.supports_strict docs, closes #344 2023-12-07 21:22:27 -08:00
Taj Khattra
1500c19bd0
Add more STRICT table support (#604)
* Add more STRICT table support per https://github.com/simonw/sqlite-utils/issues/344#issuecomment-982014776.
* Make `table.transform()` preserve STRICT mode.
* Fix mypy failures in PR #604
* Link to SQLITE strict page in a few places
2023-12-07 21:05:27 -08:00
Simon Willison
88bd372205 str, int, bytes aliases for column types, closes #606 2023-12-06 10:49:21 -08:00
Simon Willison
9286c1ba43
Fixed some markup bugs 2023-11-03 20:55:19 -07:00
Simon Willison
c64c7d1b8c More 3.35.2 release notes
Refs #433, #522, #596, #598
2023-11-03 18:03:42 -07:00
Simon Willison
78d8dd06d3 Add 3.12 to PyPI metadata 2023-11-03 17:59:09 -07:00
Simon Willison
08c8bb7cfb Test against Python 3.12
First tested in #591
2023-11-03 17:58:31 -07:00
Simon Willison
b2e0cd066d Test and docs for timedelta support, refs #522 2023-11-03 17:56:34 -07:00
Simon Willison
8d186d33c2 Release 3.35.2
Refs #599, #600
2023-11-03 17:51:46 -07:00
Harald Nezbeda
347fdc865e
Map datetime.timedelta to text columns, closes #522 2023-11-03 17:49:50 -07:00
Luke Plant
37273d7f63
Fixed issue #433 - CLI eats cursor (#598)
The issue is that underlying iterator is not fully consumed within the body of
the `with file_progress()` block. Instead, that block creates generator
expressions like `docs = (dict(zip(headers, row)) for row in reader)`

These iterables are consumed later, outside the `with file_progress()` block,
which consumes the underlying iterator, and in turn updates the progress bar.

This means that the `ProgressBar.__exit__` method gets called before the last
time the `ProgressBar.update` method gets called. The result is that the code to
make the cursor invisible (inside the `update()` method) is called after the
cleanup code to make it visible (in the `__exit__` method).

The fix is to move consumption of the `docs` iterators within the progress bar block.

(An additional fix, to make ProgressBar more robust against this kind of misuse, would
to make it refusing to update after its `__exit__` method had been called, just
like files cannot be `read()` after they are closed. That requires a in the
click library).
2023-11-03 17:40:29 -07:00
Simon Willison
b92ea4793c Fixed spelling of wrappping 2023-11-03 17:31:49 -07:00
Mike Coats
4b3c83cd9f Add spatialite arm64 linux path
Issue: #599
2023-11-03 17:31:49 -07:00
Simon Willison
622c3a5a7d Fixed Markdown that should be rST 2023-09-08 18:25:46 -07:00
Simon Willison
60900bd80a Release notes for 3.35.1, refs #592 2023-09-08 17:49:55 -07:00
Simon Willison
cb034621fd Release 3.35.1
Refs #592
2023-09-08 17:45:45 -07:00
Simon Willison
1c6ea54338
.transform() now preserves rowid values
* .transform() now preserves rowid values, refs #592
* Test transform rowids against different table types, closes #592
2023-09-08 17:45:30 -07:00
Simon Willison
5d123f031f
Fixed bug in replacing foreign key constraints example 2023-09-06 15:33:06 -07:00
Simon Willison
02e56d1158
click-default-group>=1.2.3
Refs:
- https://github.com/click-contrib/click-default-group/issues/21
2023-09-05 19:34:53 -07:00
Simon Willison
1260bdc7bf
Link to sqlite-utils-fast-fks from changelog 2023-08-18 10:47:10 -07:00
Simon Willison
98cd11a81b
Link docs to sqlite-utils-fast-fks 2023-08-18 10:45:12 -07:00
Simon Willison
7c1618e4b1
Promote plugins in the README 2023-08-17 19:28:08 -07:00
Simon Willison
4aea34065c Run codespell as part of 'just lint' 2023-08-17 19:09:22 -07:00
Simon Willison
ba2681e769 Fixed two typos in the release notes 2023-08-17 19:05:15 -07:00
Simon Willison
87c6ceb3a4 Release 3.35
Refs #577, #581, #585
2023-08-17 19:01:48 -07:00
Simon Willison
56093de078 sqlite-utils transform --add-foreign-key option, closes #585 2023-08-17 18:51:04 -07:00
Simon Willison
70717dc0e1 Remove unneccessary warning, refs #577, #585 2023-08-17 18:28:09 -07:00
Simon Willison
d2bcdc00c6 Swapped the order of a bunch of pytest comparisons
When I wrote this I thought constant == value was a better assertion. I no longer think that.
2023-08-17 18:05:13 -07:00
Simon Willison
b4735f794a Remove test.utils.collapse_whitespace, closes #583 2023-08-17 17:59:09 -07:00
Simon Willison
509857ee87
.add_foreign_keys() uses .transform() instead of PRAGMA writable_schema
Closes #577

This should solve all sorts of problems seen by users of platforms that throw errors on writable_schema.

Also added `add_foreign_keys=` and `foreign_keys=` parameters to `table.transform()`.
2023-08-17 17:48:08 -07:00
Simon Willison
993029f466 flake8 fixes 2023-08-17 17:19:02 -07:00
Simon Willison
1dc6b5aa64 Note about execute_plugins=False, refs #575 2023-08-07 11:47:34 -07:00
Simon Willison
619cea8681 sqlite-utils convert --pdb option, closes #581 2023-07-26 14:06:05 -07:00
Simon Willison
5e9a02153d Fix bug with --editable
See also: https://github.com/simonw/llm/issues/136
2023-07-25 22:20:49 -07:00
Simon Willison
37e374e05a Improved convert() function documentation 2023-07-25 07:47:44 -07:00
Simon Willison
fba26d3564
Typo in release notes 2023-07-24 22:54:13 -07:00
Simon Willison
8bee145886
Link 3.34 notes to new blog entry 2023-07-24 10:22:20 -07:00
Simon Willison
13ebcc575d Backfilled release notes for 0.2, 0.3, 0.3.1 - closes #576 2023-07-22 22:48:14 -07:00
Simon Willison
c728c25555 Undo change which broke the tests 2023-07-22 16:25:14 -07:00
Simon Willison
3e1d467c52 Last minute changelog for register_commands() for 3.34
Refs #567, #574, #575
2023-07-22 16:22:18 -07:00
Simon Willison
778dad789e More robust _called_from_test pattern 2023-07-22 16:21:27 -07:00
Simon Willison
e337a88b45 Tweaks to prepare_connection() example in docs, refs #574 2023-07-22 16:13:46 -07:00
Simon Willison
374a816c72 Database(..., execute_plugins=False) mechanism, refs #575 2023-07-22 16:06:11 -07:00
Alex Garcia
3f80a02698
prepare_connection plugin hook
Closes:
- #574

Refs #567

---------

Co-authored-by: Simon Willison <swillison@gmail.com>
2023-07-22 15:59:08 -07:00
Simon Willison
091c63cfbf Release 3.34
Refs #561, #563, #564, #565, #567, #568, #569, #570, #571
2023-07-22 15:47:56 -07:00
Simon Willison
249d7de7b2 Link to docs on distributing plugins, refs #567 2023-07-22 15:45:23 -07:00
Simon Willison
61aaa69815 .transform(..., keep_table=name) parameter, closes #571
Also type hints for the transform_sql() method
2023-07-22 15:32:09 -07:00
Simon Willison
2c12c01346 Caling .transform() also reformats the schema, closes #564 2023-07-22 15:15:30 -07:00
Simon Willison
fedd477e01 Don't attempt tui stuff on Python 3.7, refs #572 2023-07-22 15:11:38 -07:00
Simon Willison
18f190e283 sqlite-utils rename-table command, refs #565 2023-07-22 12:48:04 -07:00
Simon Willison
82e8cd3667 db.rename_table() method, refs #565 2023-07-22 12:38:31 -07:00
Simon Willison
86a352f8b7 Fixed mypy error, refs #568 2023-07-22 12:27:41 -07:00
Simon Willison
0c563e2d13 Fixed test I broke in #568 2023-07-22 12:23:42 -07:00
Simon Willison
2d55f185ff Applied Black, refs #568 2023-07-22 12:22:15 -07:00
Simon Willison
80b5fa7f12 Documentation for replace=True/ignore=True, refs #568 2023-07-22 12:19:01 -07:00
Simon Willison
58b577279f table.create(..., replace=True / ignore = True) closes #568 2023-07-22 12:15:40 -07:00
Simon Willison
b379a2a0c3 Added pluggy and first hook, register_commands - refs #569, #567 2023-07-22 12:04:31 -07:00
Simon Willison
bff240032d pygments-csv-lexer
To fix this warning:

/Users/simon/Dropbox/Development/sqlite-utils/docs/cli.rst:1286: WARNING: Pygments lexer name 'csv' is not known
2023-07-22 11:45:45 -07:00
Simon Willison
7e48502b5a Update CLI reference docs for -e, refs #570 2023-07-22 11:37:53 -07:00
Simon Willison
ef31210bf0 sqlite-utils install -e, closes #570 2023-07-22 11:32:42 -07:00
Simon Willison
f7af23837d --empty-null option for CSV and TSV imports, closes #563 2023-07-02 22:42:26 -07:00
Simon Willison
63dc7ab1a5 Fixed a complaint from ruff check 2023-06-29 13:41:08 -07:00
Simon Willison
8c739558f7 --stop-after option, closes #561 2023-06-27 11:50:04 -07:00
Simon Willison
9d38925cde Release 3.33
Refs #235, #559, #560

Refs https://github.com/simonw/llm/issues/60

Refs https://github.com/nalgeon/sqlean.py/issues/1
2023-06-25 18:31:22 -07:00
Simon Willison
f5c63088e1
Use sqlean if available in environment (#560)
Closes #559
Closes #235

Refs https://github.com/simonw/llm/issues/60

- Uses `sqlean` in place of `sqlite3` if `sqlean.py` is installed
- Uses `sqlite-dump` if available and `conn.iterdump()` does not exist
- New `with db.ensure_autocommit_off()` method for ensuring autocommit is off, used by `enable_wal()` and `disable_wal()`.
2023-06-25 16:25:51 -07:00
Simon Willison
2747257a33 Fixed link in changelog 2023-05-21 14:11:54 -07:00
Simon Willison
6fb32d27ae Release 3.32.1
Refs #551, #552
2023-05-21 14:09:46 -07:00
Simon Willison
87bddef8fd Documented bash/zsh completion, closes #552 2023-05-21 14:03:32 -07:00
Simon Willison
8188acc1f1 Fix for missing csv Pygments support, refs #551 2023-05-21 14:03:10 -07:00
Simon Willison
d8fe1b0d89
Reformatted CLI examples in docs
Closes #551
2023-05-21 13:57:22 -07:00
Simon Willison
e240133b11 Release 3.32
Refs #544, #545, #547, #548
2023-05-21 11:53:44 -07:00
Simon Willison
718b0cba9b
Experimental TUI powered by Trogon
* sqlite-utils tui command if Trogon is installed, closes #545
* Documentation for trogon TUI
* Screenshot of TUI
* Ignore trogon mypy error
* only run flake8 on Python 3.8 or higher, closes #550
2023-05-21 11:41:56 -07:00
Simon Willison
e8c5b042e4 Validate column names in analyze-columns, closes #548 2023-05-21 10:35:48 -07:00
Simon Willison
6027f3ea69 No need to show common values if everything is null
Closes #547
2023-05-21 10:19:16 -07:00
Simon Willison
d2a7b15b2b
Analyze tables options: --common-limit, --no-most, --no-least
Closes #544
2023-05-21 09:19:30 -07:00
Simon Willison
e047cc32e9
backwards-incompatible, not compatible 2023-05-09 14:08:31 -07:00
Simon Willison
b3b100d7f5 Release 3.31
Refs #509, #517, #520, #525, #527, #532, #534, #536, #537, #538, #539
2023-05-08 15:33:57 -07:00
Simon Willison
c764a9ee8f Avoid negative hashes in lambda names, refs #543 2023-05-08 15:12:39 -07:00
Simon Willison
dab23884ae Better error message if rows_from_file called with StringIO, closes #520
Refs #448
2023-05-08 15:08:02 -07:00
Simon Willison
eebd1a26ae Removed FunctionAlreadyRegistered error, refs #526, #525 2023-05-08 14:58:28 -07:00
Simon Willison
fca3ef8cf2 Applied Black, refs #526, #525 2023-05-08 14:54:24 -07:00
Martin Carpenter
02f5c4d69d
Support repeated calls to Table.convert()
* Test repeated calls to Table.convert()
* Register Table.convert() functions under their own `lambda_hash` name
* Raise exception on registering identical function names

Refs #525
2023-05-08 14:53:58 -07:00
rhoboro
6500fed8b2
Transform no longer breaks non-string default values
Closes #509
2023-05-08 14:13:36 -07:00
Simon Willison
923768db2e Assert on exact error message, refs #537 2023-05-08 14:11:48 -07:00
Scott Perry
39ef137e67
Support self-referencing FKs in create (#537) 2023-05-08 14:10:00 -07:00
Simon Willison
9662d4ce26 Updated cog, refs #527 2023-05-08 14:05:30 -07:00
Simon Willison
e0ec4c3451 --no-skip-false option, plus docs - closes #527 2023-05-08 14:03:20 -07:00
Simon Willison
455c35b512 .convert(skip_false) option, refs #527 2023-05-08 13:52:21 -07:00
Simon Willison
e4ed372517 Show more detailed error on invalid JSON, closes #532 2023-05-08 13:31:56 -07:00
Simon Willison
a256d7de98 Fix a bunch of warnings in the tests, refs #541 2023-05-08 12:57:43 -07:00
Simon Willison
4fc2f12c88 Fix ResourceWarning in sqlite-utils insert, refs #534 2023-05-08 12:39:06 -07:00
Simon Willison
2376c452a5 upsert_all() now works with not_null - refs #538 2023-05-08 12:24:19 -07:00
Simon Willison
80763edaa2
Different approach for Python 3.11 on ReadTheDocs
Refs #540
2023-05-07 11:40:47 -07:00
Simon Willison
963518bb16
Build with 3.11 on ReadTheDocs
Refs #540
2023-05-07 11:38:54 -07:00
Simon Willison
373b7886d2 --raw-lines option, closes #539 2023-05-07 11:26:14 -07:00
Chris Amico
8f9a729e8a
Add paths for homebrew on Apple silicon (#536) 2023-04-12 18:44:43 -07:00
Simon Willison
c0251cc927
Link /latest/ to /stable/ - refs #388 2023-03-26 16:42:01 -07:00
Simon Willison
92f77c3262 Ran against updated Black 2023-03-12 14:28:43 -07:00
Simon Willison
6cd0fd2b4c Fix for Sphinx bug, closes #533, refs #531 2023-03-12 14:25:26 -07:00
Simon Willison
fc221f9b62 Try this fix for flake8 error, refs #518 2022-12-09 17:30:45 -08:00
Simon Willison
e660635cea Drop support for Python 3.6, refs #517 2022-12-09 17:25:23 -08:00
Simon Willison
ebe504ab21 Clarify column types in create-table help 2022-11-29 09:03:35 -08:00
Simon Willison
965ca0d5f5 Apply no_implicit_optional codemod, closes #512 2022-11-15 22:25:44 -08:00
Simon Willison
52ddb0b9ff
Rename utility functions to library 2022-11-15 07:53:38 -08:00
Simon Willison
529110e7d8
GitHub Actions has Python 3.11 on macOS now
Refs https://github.com/actions/setup-python/issues/531
2022-10-26 12:27:32 -07:00
Simon Willison
fb8f495582
Skip macOS 3.11 test when publishing
Refs #505
2022-10-25 15:34:30 -07:00
Simon Willison
0d45ee1102 Release 3.30
Refs #480, #483, #485, #495, #500, #502, #504
2022-10-25 15:21:34 -07:00
Simon Willison
defa2974c6 jsonify_if_needed output of convert() functions, closes #495 2022-10-25 14:23:24 -07:00
Simon Willison
c7e4308e6f Use new db.close() method, refs #504 2022-10-25 14:00:53 -07:00
Simon Willison
05e2bb85fc db.close() method, closes #504 2022-10-25 13:57:43 -07:00
Simon Willison
ba7242b1f2 Try closing the database before recreating it, refs #503 2022-10-25 13:36:17 -07:00
Simon Willison
f6b796277f Try a 0.1s sleep, refs #503 2022-10-25 13:27:18 -07:00
Simon Willison
c5d7ec1dd7 Fix for mypy issue, refs #503 2022-10-25 13:21:37 -07:00
Simon Willison
7ca497a8f5 repr improvements, refs #503 2022-10-25 13:14:41 -07:00
Simon Willison
079bf1f4dc Use tmp_path fixture in test_recreate, refs #503 2022-10-25 13:06:58 -07:00
Simon Willison
7b2d1c0ffd Update tests for Python 3.11, closes #502 2022-10-25 12:23:27 -07:00
Simon Willison
5133339d00
Skip macos-latest Python 3.11 for the moment
Refs https://github.com/actions/setup-python/issues/531
2022-10-25 12:08:58 -07:00
Simon Willison
b8526c434a
Test against Python 3.11 2022-10-25 07:17:49 -07:00
Simon Willison
9cbe19ac05 Skip cog check on Python 3.6, refs #501 2022-10-18 11:16:43 -07:00
Simon Willison
eb67fc69a2 Run cog -r against latest tabulate, refs #501 2022-10-18 11:08:34 -07:00
Simon Willison
34e75ed0dd sqlite_utils.utils.flatten() function, closes #500 2022-10-18 11:00:35 -07:00
Simon Willison
d792dad1cf
Clarify wording 2022-09-26 19:23:17 -07:00
Simon Willison
cbed080782
Typo 2022-09-26 17:10:59 -07:00
Simon Willison
cf9861216b Comment for 'just docs' command
This shows up in 'just -l'
2022-09-26 12:36:09 -07:00
Simon Willison
c7cad6fc25 Documentation for Just, closes #494
Also added 'just init' and fixed a couple of missing pipenv run calls.
2022-09-26 12:26:31 -07:00
Simon Willison
6b268a1b36 language = "en" to fix Sphinx warning 2022-09-26 12:26:04 -07:00
Simon Willison
afbd2b2cba Link to convert command too, refs #483 2022-09-26 12:09:32 -07:00
Simon Willison
9a5add659d 'just docs' command for running the livehtml docs server 2022-09-26 12:09:00 -07:00
Simon Willison
ee74bd5f81 Fix heading levels, refs #483 2022-09-26 12:03:54 -07:00
Simon Willison
85247038f7 install and uninstall commands, closes #483 2022-09-26 11:57:11 -07:00
Mischa Untaga
0b315d3fa8
progressbar for inserts/upserts of other file formats
* progressbar for inserts/upserts of other file formats, closes #485
* Pin to Python 3.10.6 for the moment as workaround for mypy error

Co-authored-by: Simon Willison <swillison@gmail.com>
2022-09-15 13:37:51 -07:00
Simon Willison
d9b9e075f0 Documented the release process 2022-09-06 20:45:36 -07:00
Simon Willison
5b969273f1
Markup tweak 2022-09-01 18:44:56 -07:00
Simon Willison
686eed9a49
Typo in release notes 2022-09-01 18:37:13 -07:00
Jacob Chapman
ecf1d40112
table.search_sql(include_rank=True) option (#480)
* search_sql add include_rank option
* add test
* add FTS4 test
* Apply Black

Thanks, @chapmanjacobd
2022-08-30 20:40:35 -07:00
Simon Willison
087753cd42 sites.db is better name than dogs.db in this example 2022-08-27 21:01:55 -07:00
Simon Willison
b491f22d81 Release 3.29
Refs #423, #458, #467, #469, #470, #471, #472, #475

Closes #487
2022-08-27 20:48:36 -07:00
Simon Willison
165bc5fcb0 test_extract_works_with_null_values, refs #423, #455 2022-08-27 20:38:37 -07:00
Simon Willison
365f62520f will, not may - refs #468 2022-08-27 20:38:30 -07:00
Simon Willison
104f37fa4d
db[table].create(..., transform=True) and create-table --transform
Closes #467
2022-08-27 16:17:55 -07:00
Simon Willison
36ffcafb1a table.default_values property, closes #475
Refs #468
2022-08-27 15:42:58 -07:00
Forest Gregg
c5f8a2eb1a
in extract code, check equality witH IS instead of = for nulls (#455)
sqlite "IS" is equivalent to SQL "IS NOT DISTINCT FROM"

close #423
2022-08-27 07:45:03 -07:00
Simon Willison
19dd077944
Support entrypoints for --load-extension (#473)
* Entrypoint support, closes #470
2022-08-26 22:55:47 -07:00
Simon Willison
a46a5e3a9e Improved code compilation pattern, closes #472 2022-08-26 22:20:09 -07:00
Simon Willison
23ef1d6c20 bulk --functions, closes #471 2022-08-26 22:10:43 -07:00
Simon Willison
59e2cfbdc1 sqlite-utils memory --functions, refs #471 2022-08-26 22:03:53 -07:00
Simon Willison
85e7411bbd Skip test if pragma_function_list not supported, refs #471 2022-08-26 22:01:58 -07:00
Simon Willison
31f062d4a7 sqlite-utils query --functions option, refs #471 2022-08-26 21:53:55 -07:00
Simon Willison
7a9a6363ff sqlite-utils rows --order option, closes #469 2022-08-26 21:10:20 -07:00
Simon Willison
f4fb78fa95
Cross-link CLI to Python docs (#460)
* Start cross-linking CLI to Python docs, refs #426
* More links to Python from CLI page, refs #426
2022-08-18 14:58:07 -07:00
Simon Willison
f8ffac8787
beanbag-docutils>=2.0 (#465)
* beanbag-docutils>=2.0

Closes #464
2022-08-17 16:38:02 -07:00
Manuel Kaufmann
83e7339255
Use Read the Docs action v1 (#463)
Read the Docs repository was renamed from `readthedocs/readthedocs-preview` to `readthedocs/actions/`. Now, the `preview` action is under `readthedocs/actions/preview` and is tagged as `v1`
2022-08-17 16:11:15 -07:00
Simon Willison
45e24deffe Link API docs to GitHub source code, refs #464 2022-08-13 09:24:18 -07:00
Simon Willison
271433fdd1
Discord badge (#462) 2022-08-02 14:15:52 -07:00
Simon Willison
98a28cbfe6
Oops, fixed project slug
Refs:
- https://github.com/readthedocs/readthedocs-preview/issues/10
- https://github.com/simonw/sqlite-utils/pull/460
2022-08-02 13:35:56 -07:00
Simon Willison
1856002e3c
readthedocs/readthedocs-preview
Tip from https://twitter.com/readthedocs/status/1552354156056395778
2022-08-02 09:02:43 -07:00
Simon Willison
1acc04c071
Link to new tutorial
Refs https://github.com/simonw/datasette.io/issues/108
2022-07-31 12:12:37 -07:00
Simon Willison
b5e902fcb0 Applied Black 2022-07-28 08:13:47 -07:00
Simon Willison
573de14ab6 Improved docstring comments for Table class and db.table() 2022-07-27 17:28:46 -07:00
Simon Willison
1491b66dd7 register_function(name=...) argument, closes #458 2022-07-27 17:13:49 -07:00
Simon Willison
bfbe69646e Fixed incorrect register_function code example 2022-07-27 17:08:50 -07:00
Simon Willison
77ca051d4f
Link to installation instructions (#457) 2022-07-27 10:57:50 -07:00
Simon Willison
9e6cceac1c
Fixed incorrect issue number 2022-07-20 16:09:53 -07:00
Simon Willison
855bce8c38 Release 3.28
Refs #441, #443, #445, #449, #450, #454
2022-07-15 15:56:01 -07:00
Simon Willison
b9a89a0f2c duplicate --ignore option, refs #450 2022-07-15 15:35:58 -07:00
Simon Willison
5fa823f03f add-column --ignore option, refs #450 2022-07-15 15:31:37 -07:00
Simon Willison
2c77f4467e Add --ignore to create-index as alias of --if-not-exists, refs #450 2022-07-15 15:25:49 -07:00
Simon Willison
40b6947255 enable-fts --replace option, refs #450
Also fixed up some sqlite3.OperationalError imports.
2022-07-15 15:20:26 -07:00
Simon Willison
9dd4cf891d Improve CLI help for drop-table and drop-view, refs #450 2022-07-15 15:09:07 -07:00
Simon Willison
e10536c7f5 utils.chunks() is now a documented API, closes #451 2022-07-15 14:59:30 -07:00
Simon Willison
015c663464 Fixed lint errors, refs #454 2022-07-15 14:59:06 -07:00
Simon Willison
c710ade644 sqlite-utils duplicate command, closes #454, refs #449
Also made it so .duplicate() raises new NoTable exception rather than raising
an AssertionError if the source table does not exist.
2022-07-15 14:45:47 -07:00
Simon Willison
da030d49fd Documentation for .duplicate(), refs #449 2022-07-15 14:29:52 -07:00
Simon Willison
4af4762521 test_duplicate_fails_if_table_does_not_exist, refs #449 2022-07-15 14:23:57 -07:00
David
b366e68deb
table.duplicate(new_table_name) feature, closes #449
Thanks, @davidleejy
2022-07-15 14:21:36 -07:00
Simon Willison
42440d6345 Use parametrize for FTS test 2022-06-27 23:41:13 +00:00
Simon Willison
2dca2210d9 Ignore build in .gitignore 2022-06-27 23:39:23 +00:00
Simon Willison
9a6495fbef Configure GitPod 2022-06-27 23:38:45 +00:00
Simon Willison
ba8cf54908 Add documentation strings to Justfile, ref #446 2022-06-21 12:28:01 -07:00
Simon Willison
2768effe07 Run cog using just as well, refs #446 2022-06-21 12:24:23 -07:00
Simon Willison
5754311494
Only syntax highlight if a code-block is used
Refs #447
2022-06-21 08:00:17 -07:00
Simon Willison
3ddacb7bdc Justfile for tests and linting, closes #446 2022-06-20 12:54:46 -07:00
Simon Willison
8a9fe6498f Applied Black, refs #445 2022-06-20 12:50:15 -07:00
Simon Willison
773f2b6b20 Documented TypeTracker, closes #445 2022-06-20 12:46:49 -07:00
Simon Willison
c80971d28a sqlite_utils.utils.rows_from_file in reference.html, refs #443 2022-06-20 12:22:44 -07:00
Simon Willison
3fbe8a784c
Link to annotated release notes for 3.27 2022-06-18 20:30:24 -07:00
Simon Willison
2d84577202 Fix tiny typo in 3.27 release notes 2022-06-14 21:41:16 -07:00
Simon Willison
679d608119 Release 3.27
Refs #434, #435, #436, #440, #441, #442, #443
2022-06-14 21:30:47 -07:00
Simon Willison
b8af3b96f5 Fix bug with detect_fts() and similar table names, closes #434 2022-06-14 16:24:06 -07:00
Simon Willison
1b09538bc6 where= and where_args= parameters to search() and search_sql()
Closes #441
2022-06-14 14:54:35 -07:00
Simon Willison
0b6aba696d Documentation for maximize_csv_field_size_limit, closes #442 2022-06-14 14:31:45 -07:00
Simon Willison
9d1bac4a99 Test for maximize_csv_field_size_limit, refs #442 2022-06-14 14:23:54 -07:00
Simon Willison
0cee77b176 Update test for renamed restkey, refs #440, #443 2022-06-14 14:14:20 -07:00
Simon Willison
ce670e2d44 Docs for rows_from_file
Closes #440, closes #443
2022-06-14 13:12:42 -07:00
Simon Willison
f142bb1212
flake8 fix, refs #440 2022-06-14 09:14:57 -07:00
Simon Willison
d9c715a2fc One more typing fix, refs #440 2022-06-14 09:07:57 -07:00
Simon Willison
19efee2746 mypy fixes, refs #440 2022-06-14 08:39:08 -07:00
Simon Willison
ad96bd18c3 Tests for rows_from_file, refs #440 2022-06-14 08:14:38 -07:00
Simon Willison
d379f430f8 rows_from_file(... ignore_extras: bool, restkey: str), refs #440 2022-06-14 08:14:02 -07:00
Simon Willison
7ddf530088
A less potentially confusing parameter name 2022-06-13 08:22:59 -07:00
Yuri
9fedfc69d7
docs to dogs (#437) 2022-05-30 14:32:41 -07:00
Simon Willison
59be60c471 Update copyright dates on docs 2022-05-20 14:57:49 -07:00
Simon Willison
2238e9baf9 sphinx-copybutton extension, closes #436 2022-05-20 14:57:26 -07:00
Simon Willison
397183debd Switch docs theme to Furo, refs #435 2022-05-20 14:52:00 -07:00
Simon Willison
841ad44bac
Fixed typo 2022-05-02 11:17:19 -07:00
Simon Willison
56571775a1 Release 3.26.1
Refs #429
2022-05-02 11:14:54 -07:00
Simon Willison
ed6fd51608
Depend on click-default-group-wheel (#429)
To get this to work with Pyodide.

Refs: https://github.com/simonw/click-default-group-wheel/issues/3
2022-05-02 11:05:00 -07:00
Simon Willison
e3a14c33a0
Run tests against pull requests 2022-04-19 17:21:04 -07:00
Simon Willison
6915fbcce2 Release 3.26
Refs #415, #416, #420, #421, #425
2022-04-13 15:51:48 -07:00
Simon Willison
cbaad1f153 Removed unused sys import, refs #425 2022-04-13 15:40:57 -07:00
Simon Willison
0e60f3c80c Better error message if table has no columns, closes #424 2022-04-13 15:39:48 -07:00
Simon Willison
4433eafff7 Fix for register() on Python 3.7, refs #425 2022-04-13 15:35:57 -07:00
Simon Willison
6f3ae864f1 Better support check for deterministic=True, closes #425
Bug first discovered in #421
2022-04-13 15:31:37 -07:00
Simon Willison
95522ad919 Clarified support for newline-delimited JSON, closes #417 2022-03-25 14:17:10 -07:00
Simon Willison
0b7b80bd40 Document the convert() with initialization pattern, closes #420 2022-03-25 13:07:29 -07:00
Chris Amico
396f80fcc6
Ignore common generated files (#419)
Thanks, @eyeseast
2022-03-24 14:01:43 -07:00
Simon Willison
93fa79d30b Ignore flake8 lambda errors, refs #415 2022-03-20 21:22:09 -07:00
Simon Willison
751ab205ac Fix for --multi combined with --dry-run, closes #415 2022-03-20 21:18:18 -07:00
Simon Willison
878d5f5cea errors=r.SET_NULL/r.IGNORE options for parsedate/parsedatetime, closes #416 2022-03-20 21:01:35 -07:00
Simon Willison
433813612f Move sqls=[] closer to where it is populated 2022-03-11 13:44:07 -08:00
Simon Willison
9388edf57a Changelog item for 3.25.1
Refs #413, #414
2022-03-11 10:34:44 -08:00
Simon Willison
40b76f6f56
Release 3.25.1
Refs #413
2022-03-11 10:08:20 -08:00
Simon Willison
6fd7c138e2 Fixed .transform() method which I broke in #413 2022-03-11 09:54:17 -08:00
Simon Willison
26e6d2622c Use :param x: for docstring comments, refs #413 2022-03-11 09:39:17 -08:00
Simon Willison
7f56f90d30
Fixed rST mistake 2022-03-01 23:01:07 -08:00
Simon Willison
b2b04aec01 Release 3.25
Refs #343, #408
2022-03-01 22:34:46 -08:00
Simon Willison
d25cdd37a3 db.sqlite_version property and fix for deterministic=True on SQLite 3.8.3
Closes #408
2022-03-01 16:37:53 -08:00
Simon Willison
521921b849 Fixed mypy error, refs #343 2022-03-01 16:05:11 -08:00
Simon Willison
931b1e1513 .insert(hash_id_columns=) parameter, closes #343 2022-03-01 16:00:51 -08:00
Edward Betts
b6c9dfce0b
Correct spelling mistakes (found with codespell) (#410) 2022-03-01 13:05:29 -08:00
Simon Willison
7a098aa0c5
Link to my blog series 2022-02-17 07:39:54 -08:00
Simon Willison
757f103ae2 Release 3.24
Refs ##397, #398, #401, #403, #404, #405, #407
2022-02-15 17:39:13 -08:00
Simon Willison
4bc06a2437 memory_name= feature, closes #405 2022-02-15 17:23:11 -08:00
Simon Willison
8f528ed2b1 Fix ReST warning 2022-02-15 17:21:07 -08:00
Simon Willison
3e5a4f60cc Tweaked SpatiaLite CLI docs, refs #398 2022-02-15 17:06:49 -08:00
Chris Amico
a692c56659
Add SpatiaLite helpers to CLI (#407)
* Add SpatiaLite CLI helpers
* Add docs for spaitalite helpers
* Fix flake8 issues and add more detail on spatial types
* Run cog and add some help text.
* Use SpatiaLite when calculating coverage, refs #407

Co-authored-by: Simon Willison <swillison@gmail.com>
2022-02-15 16:58:07 -08:00
Simon Willison
e7f040106b Add an example of --text too, refs #404 2022-02-08 23:03:04 -08:00
Simon Willison
7142dbd58d Fixed typo in --help 2022-02-08 22:57:21 -08:00
Simon Willison
79a5ece62e Add --convert example to sqlite-utils insert --help, closes #404 2022-02-08 22:55:01 -08:00
Simon Willison
3acc2f1772 Adding a primary key to a rowid table, closes #403 2022-02-08 11:33:45 -08:00
Simon Willison
fea8c9bcc5 Improved SpatiaLite example, closes #401 2022-02-05 18:03:21 -08:00
Simon Willison
aa24903113 Create table if_not_exists=True argument, closes #397 2022-02-05 17:28:53 -08:00
Simon Willison
79b5b58354 Basic test for db[t].create(...), refs #397 2022-02-05 17:19:39 -08:00
Simon Willison
088d899822 Release 3.23
Refs #79, #363, #392, #393, #395, #396
2022-02-03 22:41:46 -08:00
Simon Willison
20fe3b8abf Fixed RST warning about empty line 2022-02-03 22:32:57 -08:00
Simon Willison
44894c6f6c Fix warning about duplicate object description 2022-02-03 22:31:13 -08:00
Simon Willison
482fcc0da7 Fix for flake8, refs #79 2022-02-03 22:13:17 -08:00
Simon Willison
0fe0f476a7 Fix for mypy error, closes #396
Should help tests pass for #395 and #79
2022-02-03 22:10:28 -08:00
Simon Willison
e46798959e
Looks like Actions if: clauses prefer single quotes
Refs #395, #79
2022-02-03 22:06:23 -08:00
Simon Willison
7494187284
Only install SpatiaLite on Ubuntu, refs #395
For tests added to #79
2022-02-03 22:05:12 -08:00
Simon Willison
cea25c28ba Capitalization of SpatiaLite 2022-02-03 21:59:59 -08:00
Simon Willison
4a2a3e2fd0
Install SpatiaLite in tests
To run tests for #79, #385
2022-02-03 21:56:34 -08:00
Chris Amico
ee11274fcb
New spatialite helper methods, closes #79
- db.init_spatialite()
- table.add_geometry_column()
- table.create_spatial_index()

Co-authored-by: Simon Willison <swillison@gmail.com>
2022-02-03 21:55:09 -08:00
Simon Willison
9dcb099905 Better error messages for --convert, closes #363 2022-02-03 14:51:25 -08:00
Simon Willison
7d928f8308 Better insert-replace CLI documentation, refs #393 2022-02-03 14:11:38 -08:00
Simon Willison
813b6d07ab Much improved insert-replace documentation, refs #393 2022-02-03 14:11:38 -08:00
Simon Willison
b2ab08e048
Don't test main against 3.11-dev yet
It breaks on Windows. Refs #394
2022-02-03 13:07:00 -08:00
Simon Willison
44cbddff8a
Run tests against Python 3.11-dev
Refs #394
2022-02-02 14:21:38 -08:00
Simon Willison
a6da26a856
Simplified example 2022-01-30 07:24:13 -08:00
Simon Willison
feb01c1ddd
Fixed duplicated example 2022-01-30 07:22:39 -08:00
Simon Willison
6663d28952
SQL injection, not XSS 2022-01-30 07:17:20 -08:00
Simon Willison
d1e9f09c06 Removed unneccessary import, refs #392 2022-01-26 10:23:48 -08:00
Simon Willison
d1d2a8e6fa sqlite-utils bulk --batch-size option, closes #392 2022-01-26 10:15:23 -08:00
Simon Willison
a9fca7efa4 Release 3.22.1
Refs #384, #387, #389
2022-01-25 19:28:30 -08:00
Simon Willison
be1e89da5f Fixed flake8 errors 2022-01-25 19:22:06 -08:00
Simon Willison
2b20957b18 Better validation for upsert --pk, closes #390 2022-01-25 19:19:39 -08:00
Simon Willison
4c6023452c Examples in --help for remaining commands, closes #384 2022-01-25 19:14:59 -08:00
Simon Willison
6e85a4bbbe Added examples to more --help output, refs #384 2022-01-25 18:56:44 -08:00
Simon Willison
25d8c820de Correct domain for Plausible, refs #389 2022-01-25 18:06:02 -08:00
Simon Willison
89c01103ec Custom layout template for docs
Adds plausible analytics, closes #389

Implements banner on latest page, closes #388
2022-01-25 18:00:16 -08:00
Simon Willison
b3efb29212 SQLite can drop columns now
It gained that ability in 3.35.0 in 2021-03-12:
https://www.sqlite.org/changes.html#version_3_35_0
2022-01-25 17:48:57 -08:00
Simon Willison
8d51ae48ab Getting started section for Python library, closes #387 2022-01-25 17:35:26 -08:00
Simon Willison
82ea42ffee Added missing docstring for db.supports_strict 2022-01-24 20:12:32 -08:00
Simon Willison
3091e6b6e9 Clearer help for --drop-foreign-key 2022-01-24 20:06:40 -08:00
Simon Willison
7fdff5019d
Link to article from contributing, closes #386 2022-01-18 18:15:21 -08:00
Simon Willison
74586d3cb2 Release 3.22
Refs #381, #382, #383
2022-01-11 15:44:48 -08:00
Simon Willison
3b632f0a7e sqlite-utils rows --where and -p options, closes #382 2022-01-11 15:32:43 -08:00
Simon Willison
324ebc3130 sqlite-utils rows --limit and --offset options, closes #381 2022-01-11 15:19:29 -08:00
Simon Willison
1d44b0cc27 CLI reference page, maintained by cog, closes #383 2022-01-11 13:43:56 -08:00
Simon Willison
5f38c81601
Fixed typo 2022-01-11 11:20:34 -08:00
Simon Willison
5737a3aab4
Link to annotated release notes 2022-01-11 11:18:35 -08:00
Simon Willison
2448e45ddb
upsert command, not update command 2022-01-11 10:06:50 -08:00
Simon Willison
7c637b1180 Release 3.21
Refs #348, #364, #366, #368, #371, #372, #374, #375, #376, #379

Closes #380
2022-01-10 18:34:21 -08:00
Simon Willison
129141572f
sqlite-utils bulk command
* sqlite-utils bulk command, closes #375
* Refactor import_options and insert_upsert_options, refs #377
* Tests for sqlite-utils bulk, refs #377
* Documentation for sqlite-utils bulk, refs #377
2022-01-10 18:10:54 -08:00
Simon Willison
1b84c175b4 --analyze option for create-index, insert, update commands, closes #379, closes #365 2022-01-10 17:36:41 -08:00
Simon Willison
e0ef9288fe sqlite-utils analyze command, refs #379 2022-01-10 17:24:17 -08:00
Simon Willison
389cbd5792 delete_where(analyze=True), closes #378 2022-01-10 17:08:05 -08:00
Simon Willison
ab392157f7 analyze=True for insert_all/upsert_all, refs #378 2022-01-10 17:00:34 -08:00
Simon Willison
0142c2a3c2 Improved test_create_index_analyze test, refs #378 2022-01-10 12:02:08 -08:00
Simon Willison
0d10402f7b table.create_index(..., analyze=True), refs #378 2022-01-10 12:00:24 -08:00
Simon Willison
541f64ddb0 db.analyze() and table.analyze() methods, refs #366 2022-01-10 11:48:38 -08:00
Simon Willison
b6dad08a83 Keep trying up to ten times, refs #364 2022-01-09 21:04:51 -08:00
Simon Willison
046e5246c9 Longer delay to hopefully get test to pass, refs #364 2022-01-09 20:51:07 -08:00
Simon Willison
e6ae643497 Did not need WAL after all, refs #364 2022-01-09 20:41:00 -08:00
Simon Willison
cfb3f12358 Only buffer input if --sniff, closes #364 2022-01-09 20:39:58 -08:00
Simon Willison
d2a79d200f --nl now ignores blank lines, closes #376 2022-01-09 20:12:39 -08:00
Simon Willison
2f8879235a Renamed function to fix lint error, refs #348 2022-01-09 12:39:14 -08:00
Simon Willison
1d64cd2e5b sqlite-utils create-database command, closes #348 2022-01-09 12:33:16 -08:00
Simon Willison
f08fe6fd4d Fixed error in docs: it's suffix not extension, refs #372 2022-01-09 12:18:31 -08:00
Simon Willison
c9ecd0d6a3 stem and suffix columns for insert-files, closes #372 2022-01-09 12:16:52 -08:00
Simon Willison
49a54ffb2f Fix for cog error
Should help tests pass for #374, #371
2022-01-09 12:08:03 -08:00
Simon Willison
22c8d10dd3 --convert function can now modify row in place, closes #371 2022-01-09 12:06:02 -08:00
Simon Willison
b8c134059e --fmt now implies --table, closes #374 2022-01-09 10:07:48 -08:00
Simon Willison
148e9c7aee Use cog to maintain --fmt list, closes #373 2022-01-09 09:48:48 -08:00
Simon Willison
e0c476bc38 Fix test for Python 3.6, refs #368 2022-01-08 18:37:53 -08:00
Simon Willison
539f5ccd90 Support 'python -m sqlite_utils', closes #368
Refs #364
2022-01-08 18:33:22 -08:00
Simon Willison
a8f9cc6f64 Add test for chunks(), refs #364 2022-01-08 13:16:52 -08:00
Simon Willison
6e46b99134
Renamed ip to source in example code 2022-01-06 10:01:35 -08:00
Simon Willison
3d464893ee Release 3.20
Refs #344, #353, #356, #362
2022-01-05 22:55:35 -08:00
Simon Willison
a7b29bfaa9 Fixed bug with sqlite-utils upsert --detect-types, closes #362 2022-01-05 22:28:29 -08:00
Simon Willison
413f8ed754 --convert --text for iterators, docs for --convert 2022-01-05 22:24:53 -08:00
Simon Willison
2e4847e493 Implemented --convert for different things, renamed --all to --text 2022-01-05 22:24:53 -08:00
Simon Willison
e66299c6ed Implemented and documented sqlite-utils insert --all 2022-01-05 22:24:53 -08:00
Simon Willison
f1569c9f7f Implemented sqlite-utils insert --lines 2022-01-05 22:24:53 -08:00
Simon Willison
d1ed2f423d Refactored sqlite-utils insert tests into test_cli_insert.py 2022-01-05 22:24:53 -08:00
Simon Willison
9e286cc6d2 New help for --lines and --all and --convert and --import, refs #356 2022-01-05 22:24:53 -08:00
Simon Willison
f3fd861311
Removed unneccessary pytest-runner, closes #357 2021-12-16 12:43:12 -08:00
Simon Willison
ee13f98c2c Better test for rebuild, refs #354 2021-12-10 16:59:37 -08:00
Simon Willison
3b2a7c0e5b Refactor test for #149, spitting it from other rebuild test
Also refs #354
2021-12-10 16:56:50 -08:00
Simon Willison
500a35ad4d Also support def convert(value), closes #355
Plus added custom syntax error display
2021-12-10 16:49:28 -08:00
Simon Willison
7a43af232e Support nested imports, closes #351 2021-12-10 16:11:22 -08:00
Simon Willison
a3df483c80 sqlite-utils convert db table column -, refs #353 2021-12-10 16:01:02 -08:00
Simon Willison
e328db8eba
Improved schema example for sqlite-utils extract 2021-12-10 13:12:38 -08:00
Simon Willison
213a0ff177 Really fix flake8 error, refs #344, #345 2021-11-29 14:34:40 -08:00
Simon Willison
1f8178f7e4 Fix flake8 error, refs #344, #345 2021-11-29 14:29:57 -08:00
Simon Willison
e3f108e0f3 db.supports_strict and table.strict properties, refs #344 2021-11-29 14:19:30 -08:00
Simon Willison
126703706e Release 3.19
Refs #342
2021-11-20 20:41:56 -08:00
Simon Willison
33176ad47b
Run pytest with colors
Tip from https://twitter.com/cjolowicz/status/1461266663681187841
2021-11-19 00:09:16 -08:00
Simon Willison
8f386a0d30
Release 3.19a0
Refs #342
2021-11-18 23:27:41 -08:00
Simon Willison
93b21c230a Extra parameters for .lookup(), passed to .insert() - closes #342 2021-11-18 23:26:50 -08:00
Simon Willison
3b8abe6087 Release 3.18
Refs #324, #329, #330, #331, #332, #335, #338, #339
2021-11-14 19:15:23 -08:00
Simon Willison
ffb54427d3 insert now replaces square braces in column name with underscore, closes #341 2021-11-14 18:56:35 -08:00
Simon Willison
fb9d61754a Better type signature for hash_id, closes #341 2021-11-14 18:19:28 -08:00
Simon Willison
54a2269e91 Optional second argument to .lookup() to populate extra columns, closes #339 2021-11-14 18:01:56 -08:00
Simon Willison
9cda5b070f Handle dict/tuple/list mapping to TEXT, closes #338 2021-11-14 16:36:00 -08:00
Simon Willison
84007dffa8 Applied Black, refs #322, #328 2021-11-14 16:28:53 -08:00
Mina Rizk
271b894af5
Map dict to TEXT
Thanks, @minaeid90
2021-11-14 16:27:40 -08:00
Denys Pavlov
bc4c42d688
Use python-dateutil package instead of dateutils (#324) 2021-11-14 15:25:40 -08:00
Simon Willison
adea5bc396 flake8 fix, refs #331 2021-11-14 15:24:15 -08:00
Simon Willison
73e214a976 py.typed file so mypy picks up the types, closes #331 2021-11-14 15:21:04 -08:00
Simon Willison
12b8c9de25 sqlite-utils memory --flatten, closes #332 2021-11-14 15:05:00 -08:00
Simon Willison
13195d8747 Test demonstrating fix for #335 2021-11-14 14:55:42 -08:00
Simon Willison
e8d958109e create_index(..., find_unique_name=True) option, refs #335 2021-11-14 14:49:42 -08:00
Simon Willison
92aa5c9c5d
Fixed typo 2021-11-11 12:50:22 -08:00
Simon Willison
fda4dad23a
Test against Python 3.10 (#330)
* Test against Python 3.10
* Added 3.10 to classifiers
* Test on Python 3.10 before publish
2021-10-13 15:25:05 -07:00
Simon Willison
718a8f61bc
Clarified description of --quote 2021-10-02 09:54:39 -07:00
Simon Willison
54191d4dc1 Release 3.17.1
Refs #325, #328
2021-09-22 13:49:36 -07:00
Simon Willison
c1b26eed03 sqlite-utils memory handles files with same filename, closes #325 2021-09-22 13:45:37 -07:00
Simon Willison
7427a9137f Output [] in JSON mode if no rows, closes #328 2021-09-22 13:20:04 -07:00
Simon Willison
77c240df56 Release 3.17
Refs #319, #320
2021-08-24 16:40:18 -07:00
Simon Willison
49a010c93d
Ability to insert file contents as text, in addition to blob (#321) 2021-08-24 16:31:13 -07:00
Simon Willison
9258f4bd84 sqlite-utils memory --analyze, closes #320 2021-08-22 08:44:25 -07:00
Simon Willison
d7b1024d3a
Corrected stdin example 2021-08-18 16:02:55 -07:00
Simon Willison
b30f725d98
Small improvement to example 2021-08-18 16:02:07 -07:00
Simon Willison
ddfdff657f
Fixed incorrecte output example 2021-08-18 16:01:00 -07:00
Simon Willison
5912878d62 Release 3.16
Refs #37, #246, #294, #295, #296, #314, #316
2021-08-18 15:36:32 -07:00
Simon Willison
c79737bb4f Type signatures for .create_table() and .create_table_sql() and .create() and Table.__init__
Closes #314
2021-08-18 15:25:18 -07:00
Simon Willison
282e81362a Applied Black plus some extra type hints 2021-08-18 14:55:37 -07:00
Simon Willison
c62363ebdc Run mypy against tests/ too, refs #37 2021-08-18 14:49:12 -07:00
Simon Willison
7479933bc4
More sqlite-utils memory examples in README closes #294 2021-08-18 14:40:02 -07:00
Simon Willison
7e2dcbbbea Fixed bug with --no-headers --tsv, closes #295 2021-08-18 13:18:54 -07:00
Simon Willison
61b60f58ce Nice capitalization of API reference 2021-08-18 13:13:52 -07:00
Simon Willison
ccf128cd6d sqlite-utils search --quote option, closes #296 2021-08-18 13:10:44 -07:00
Simon Willison
8ae77a6961 table.search(quote=True) parameter, refs #296 2021-08-18 12:55:53 -07:00
Simon Willison
f0fd19267f Black/flake8, refs #246 2021-08-18 11:56:36 -07:00
Simon Willison
1fa5a12a49 Documentation for db.quote_fts(), refs #246 2021-08-18 11:55:50 -07:00
Simon Willison
e6b1022791 Fix markup warning in docstring 2021-08-18 11:55:19 -07:00
Mark Neumann
53fec0d863
db.quote_fts() method, thanks Mark Neumann
Refs #296, closes #246.
2021-08-18 11:43:11 -07:00
Simon Willison
1fe73c898b
Remove link to older code example 2021-08-18 11:31:20 -07:00
Simon Willison
7a19822ac9
Updated tagline 2021-08-17 08:42:02 -07:00
Simon Willison
7ee7b628e1
Fixed some rogue backticks, closes #316 2021-08-13 22:10:47 -07:00
Simon Willison
b966c44ef8
Minor markup fix 2021-08-13 04:32:40 -07:00
Simon Willison
6de0a5d46a
Typo fix 2021-08-11 05:03:07 -07:00
Simon Willison
af89c5f851
How to create a Database instance 2021-08-11 04:56:54 -07:00
Simon Willison
3091bec4f7
Don't show inheritance for Database class 2021-08-11 04:54:00 -07:00
Simon Willison
bde3725257 Fixed spelling existent 2021-08-10 16:55:12 -07:00
Simon Willison
86fc9fb5c8 Release 3.15.1
Refs #311, #312, #313, #315
2021-08-10 16:53:10 -07:00
Simon Willison
6155da72c8
Add reference page to documentation using Sphinx autodoc (#312)
* Docstrings and type annotations for almost every method
* New /reference API documentation page using Sphink autodoc
* Custom Read The Docs config, to get autodoc working
* Fix for #313 (add_foreign_keys() doesn't reject being called with a View)
* Fixed #315 (.delete_where() returns [] when it should return self)
2021-08-10 16:09:28 -07:00
Simon Willison
ee469e3122 Corrected tiny mistake in --flatten examples 2021-08-09 15:50:59 -07:00
Simon Willison
8757de84b2
Link to stable docs, not latest 2021-08-09 15:44:26 -07:00
89 changed files with 22324 additions and 3498 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

@ -0,0 +1,16 @@
name: Read the Docs Pull Request Preview
on:
pull_request_target:
types:
- opened
permissions:
pull-requests: write
jobs:
documentation-links:
runs-on: ubuntu-latest
steps:
- uses: readthedocs/actions/preview@v1
with:
project-slug: "sqlite-utils"

View file

@ -9,24 +9,19 @@ jobs:
runs-on: ${{ matrix.os }}
strategy:
matrix:
python-version: [3.6, 3.7, 3.8, 3.9]
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
os: [ubuntu-latest, windows-latest, macos-latest]
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v7
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
- uses: actions/cache@v2
name: Configure pip caching
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/setup.py') }}
restore-keys: |
${{ runner.os }}-pip-
cache: pip
cache-dependency-path: pyproject.toml
- name: Install dependencies
run: |
pip install -e '.[test]'
pip install . --group dev
- name: Run tests
run: |
pytest
@ -34,25 +29,20 @@ jobs:
runs-on: ubuntu-latest
needs: [test]
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v7
- name: Set up Python
uses: actions/setup-python@v2
uses: actions/setup-python@v6
with:
python-version: '3.9'
- uses: actions/cache@v2
name: Configure pip caching
with:
path: ~/.cache/pip
key: ${{ runner.os }}-publish-pip-${{ hashFiles('**/setup.py') }}
restore-keys: |
${{ runner.os }}-publish-pip-
python-version: '3.14'
cache: pip
cache-dependency-path: pyproject.toml
- name: Install dependencies
run: |
pip install setuptools wheel twine
pip install build twine
- name: Publish
env:
TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
run: |
python setup.py sdist bdist_wheel
python -m build
twine upload dist/*

View file

@ -6,21 +6,16 @@ jobs:
spellcheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: 3.9
- uses: actions/cache@v2
name: Configure pip caching
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/setup.py') }}
restore-keys: |
${{ runner.os }}-pip-
python-version: "3.12"
cache: pip
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,22 +12,19 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out repo
uses: actions/checkout@v2
uses: actions/checkout@v7
- name: Set up Python
uses: actions/setup-python@v2
uses: actions/setup-python@v6
with:
python-version: 3.9
- uses: actions/cache@v2
name: Configure pip caching
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/setup.py') }}
restore-keys: |
${{ runner.os }}-pip-
python-version: "3.11"
cache: pip
cache-dependency-path: pyproject.toml
- name: Install SpatiaLite
run: sudo apt-get install libsqlite3-mod-spatialite
- 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

@ -0,0 +1,41 @@
name: Test SQLite versions
on: [push, pull_request]
permissions:
contents: read
jobs:
test:
runs-on: ${{ matrix.platform }}
continue-on-error: true
strategy:
matrix:
platform: [ubuntu-latest]
python-version: ["3.10"]
sqlite-version: [
"3.46",
"3.23.1", # 2018-04-10, before UPSERT
]
steps:
- uses: actions/checkout@v7
- name: Set up Python ${{ matrix.python-version }}
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: ./.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 . --group dev
pip freeze
- name: Run tests
run: |
python -m pytest

View file

@ -1,40 +1,57 @@
name: Test
on: [push]
on: [push, pull_request]
env:
FORCE_COLOR: 1
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
matrix:
python-version: [3.6, 3.7, 3.8, 3.9]
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]
os: [ubuntu-latest, macos-latest, windows-latest, macos-14]
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v7
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
- uses: actions/cache@v2
name: Configure pip caching
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/setup.py') }}
restore-keys: |
${{ runner.os }}-pip-
allow-prereleases: true
cache: pip
cache-dependency-path: pyproject.toml
- name: Install dependencies
run: |
pip install -e '.[test,mypy,flake8]'
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: Build extension for --load-extension test
if: matrix.os == 'ubuntu-latest'
run: |-
(cd tests && gcc ext.c -fPIC -shared -o ext.so && ls -lah)
- name: Run tests
run: |
pytest
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
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
run: |
cog --check --diff README.md docs/*.rst

9
.gitignore vendored
View file

@ -1,4 +1,6 @@
.venv
dist
build
*.db
__pycache__/
*.py[cod]
@ -12,3 +14,10 @@ venv
.coverage
.schema
.vscode
.hypothesis
Pipfile
Pipfile.lock
uv.lock
tests/*.dylib
tests/*.so
tests/*.dll

17
.readthedocs.yaml Normal file
View file

@ -0,0 +1,17 @@
version: 2
sphinx:
configuration: docs/conf.py
build:
os: ubuntu-24.04
tools:
python: "3.13"
jobs:
install:
- pip install --upgrade pip
- pip install . --group docs
formats:
- pdf
- epub

33
Justfile Normal file
View file

@ -0,0 +1,33 @@
# Run tests and linters
@default: test lint
# Run pytest with supplied options
@test *options:
uv run pytest {{options}}
@run *options:
uv run -- {{options}}
# Run linters: black, flake8, mypy, ty, cog
@lint:
just run black . --check
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:
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 --group docs make livehtml
# Apply Black
@black:
uv run black .

View file

@ -1,24 +1,29 @@
# sqlite-utils
[![PyPI](https://img.shields.io/pypi/v/sqlite-utils.svg)](https://pypi.org/project/sqlite-utils/)
[![Changelog](https://img.shields.io/github/v/release/simonw/sqlite-utils?include_prereleases&label=changelog)](https://sqlite-utils.datasette.io/en/latest/changelog.html)
[![Changelog](https://img.shields.io/github/v/release/simonw/sqlite-utils?include_prereleases&label=changelog)](https://sqlite-utils.datasette.io/en/stable/changelog.html)
[![Python 3.x](https://img.shields.io/pypi/pyversions/sqlite-utils.svg?logo=python&logoColor=white)](https://pypi.org/project/sqlite-utils/)
[![Tests](https://github.com/simonw/sqlite-utils/workflows/Test/badge.svg)](https://github.com/simonw/sqlite-utils/actions?query=workflow%3ATest)
[![Documentation Status](https://readthedocs.org/projects/sqlite-utils/badge/?version=latest)](http://sqlite-utils.datasette.io/en/latest/?badge=latest)
[![Documentation Status](https://readthedocs.org/projects/sqlite-utils/badge/?version=stable)](http://sqlite-utils.datasette.io/en/stable/?badge=stable)
[![codecov](https://codecov.io/gh/simonw/sqlite-utils/branch/main/graph/badge.svg)](https://codecov.io/gh/simonw/sqlite-utils)
[![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](https://github.com/simonw/sqlite-utils/blob/main/LICENSE)
[![discord](https://img.shields.io/discord/823971286308356157?label=discord)](https://discord.gg/Ass7bCAMDw)
Python CLI utility and library for manipulating SQLite databases.
## Some feature highlights
- [Pipe JSON](https://sqlite-utils.datasette.io/en/stable/cli.html#inserting-json-data) (or [CSV or TSV](https://sqlite-utils.datasette.io/en/stable/cli.html#inserting-csv-or-tsv-data)) directly into a new SQLite database file, automatically creating a table with the appropriate schema
- [Run in-memory SQL queries](https://sqlite-utils.datasette.io/en/stable/cli.html#querying-data-directly-using-an-in-memory-database), including joins, directly against data in CSV, TSV or JSON files and view the results
- [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 dropping columns
- 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: [
sqlite-utils: a Python library and CLI tool for building SQLite databases](https://simonwillison.net/2019/Feb/25/sqlite-utils/) 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
@ -32,12 +37,19 @@ Or if you use [Homebrew](https://brew.sh/) for macOS:
Now you can do things with the CLI utility like this:
$ sqlite-utils memory dogs.csv "select * from t"
[{"id": 1, "age": 4, "name": "Cleo"},
{"id": 2, "age": 2, "name": "Pancakes"}]
$ sqlite-utils insert dogs.db dogs dogs.csv --csv
[####################################] 100%
$ sqlite-utils tables dogs.db --counts
[{"table": "dogs", "count": 2}]
$ sqlite-utils dogs.db "select * from dogs"
[{"id": 1, "age": 4, "name": "Cleo"},
{"id": 2, "age": 2, "name": "Pancakes"}]
$ sqlite-utils dogs.db "select id, name from dogs"
[{"id": 1, "name": "Cleo"},
{"id": 2, "name": "Pancakes"}]
$ sqlite-utils dogs.db "select * from dogs" --csv
id,age,name
@ -61,7 +73,7 @@ Or for data in a CSV file:
`sqlite-utils memory` lets you import CSV or JSON data into an in-memory database and run SQL queries against it in a single command:
$ cat dogs.csv | sqlite-utils memory - "select name, age from dogs"
$ cat dogs.csv | sqlite-utils memory - "select name, age from stdin"
See the [full CLI documentation](https://sqlite-utils.datasette.io/en/stable/cli.html) for comprehensive coverage of many more commands.

View file

@ -20,4 +20,4 @@ help:
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
livehtml:
sphinx-autobuild -b html "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(0)
sphinx-autobuild -a -b html "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(0) --watch ../sqlite_utils

23
docs/_static/js/custom.js vendored Normal file
View file

@ -0,0 +1,23 @@
jQuery(function ($) {
// Show banner linking to /stable/ if this is a /latest/ page
if (!/\/latest\//.test(location.pathname)) {
return;
}
var stableUrl = location.pathname.replace("/latest/", "/stable/");
// Check it's not a 404
fetch(stableUrl, { method: "HEAD" }).then((response) => {
if (response.status == 200) {
var warning = $(
`<div class="admonition warning">
<p class="first admonition-title">Note</p>
<p class="last">
This documentation covers the <strong>development version</strong> of <code>sqlite-utils</code>.</p>
<p>See <a href="${stableUrl}">this page</a> for the current stable release.
</p>
</div>`
);
warning.find("a").attr("href", stableUrl);
$("article[role=main]").prepend(warning);
}
});
});

42
docs/_templates/base.html vendored Normal file
View file

@ -0,0 +1,42 @@
{%- extends "!base.html" %}
{% block site_meta %}
{{ super() }}
<script defer data-domain="sqlite-utils.datasette.io" src="https://plausible.io/js/plausible.js"></script>
{% endblock %}
{% block scripts %}
{{ super() }}
<style type="text/css">
.highlight-output .highlight {
border-left: 9px solid #30c94f;
}
</style>
<script>
document.addEventListener("DOMContentLoaded", function() {
// Show banner linking to /stable/ if this is a /latest/ page
if (!/\/latest\//.test(location.pathname)) {
return;
}
var stableUrl = location.pathname.replace("/latest/", "/stable/");
// Check it's not a 404
fetch(stableUrl, { method: "HEAD" }).then((response) => {
if (response.status === 200) {
var warning = document.createElement("div");
warning.className = "admonition warning";
warning.innerHTML = `
<p class="first admonition-title">Note</p>
<p class="last">
This documentation covers the <strong>development version</strong> of Datasette.
</p>
<p>
See <a href="${stableUrl}">this page</a> for the current stable release.
</p>
`;
var mainArticle = document.querySelector("article[role=main]");
mainArticle.insertBefore(warning, mainArticle.firstChild);
}
});
});
</script>
{% endblock %}

View file

@ -1,7 +1,593 @@
.. _changelog:
===========
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)
------------------
- **Breaking change**: The ``db.table(table_name)`` method now only works with tables. To access a SQL view use ``db.view(view_name)`` instead. (:issue:`657`)
- The ``table.insert_all()`` and ``table.upsert_all()`` methods can now accept an iterator of lists or tuples as an alternative to dictionaries. The first item should be a list/tuple of column names. See :ref:`python_api_insert_lists` for details. (:issue:`672`)
- **Breaking change**: The default floating point column type has been changed from ``FLOAT`` to ``REAL``, which is the correct SQLite type for floating point values. This affects auto-detected columns when inserting data. (:issue:`645`)
- Now uses ``pyproject.toml`` in place of ``setup.py`` for packaging. (:issue:`675`)
- Tables in the Python API now do a much better job of remembering the primary key and other schema details from when they were first created. (:issue:`655`)
- **Breaking change**: The ``table.convert()`` and ``sqlite-utils convert`` mechanisms no longer skip values that evaluate to ``False``. Previously the ``--skip-false`` option was needed, this has been removed. (:issue:`542`)
- **Breaking change**: Tables created by this library now wrap table and column names in ``"double-quotes"`` in the schema. Previously they would use ``[square-braces]``. (:issue:`677`)
- The ``--functions`` CLI argument now accepts a path to a Python file in addition to accepting a string full of Python code. It can also now be specified multiple times. (:issue:`659`)
- **Breaking change:** Type detection is now the default behavior for the ``insert`` and ``upsert`` CLI commands when importing CSV or TSV data. Previously all columns were treated as ``TEXT`` unless the ``--detect-types`` flag was passed. Use the new ``--no-detect-types`` flag to restore the old behavior. The ``SQLITE_UTILS_DETECT_TYPES`` environment variable has been removed. (:issue:`679`)
.. _v4_0a0:
4.0a0 (2025-05-08)
------------------
- Upsert operations now use SQLite's ``INSERT ... ON CONFLICT SET`` syntax on all SQLite versions later than 3.23.1. This is a very slight breaking change for apps that depend on the previous ``INSERT OR IGNORE`` followed by ``UPDATE`` behavior. (:issue:`652`)
- Python library users can opt-in to the previous implementation by passing ``use_old_upsert=True`` to the ``Database()`` constructor, see :ref:`python_api_old_upsert`.
- Dropped support for Python 3.8, added support for Python 3.13. (:issue:`646`)
- ``sqlite-utils tui`` is now provided by the `sqlite-utils-tui <https://github.com/simonw/sqlite-utils-tui>`__ plugin. (:issue:`648`)
- Test suite now also runs against SQLite 3.23.1, the last version (from 2018-04-10) before the new ``INSERT ... ON CONFLICT SET`` syntax was added. (:issue:`654`)
.. _v3_38:
3.38 (2024-11-23)
-----------------
- Plugins can now reuse the implementation of the ``sqlite-utils memory`` CLI command with the new ``return_db=True`` parameter. (:issue:`643`)
- ``table.transform()`` now recreates indexes after transforming a table. A new ``sqlite_utils.db.TransformError`` exception is raised if these indexes cannot be recreated due to conflicting changes to the table such as a column rename. Thanks, `Mat Miller <https://github.com/matdmiller>`__. (:issue:`633`)
- ``table.search()`` now accepts a ``include_rank=True`` parameter, causing the resulting rows to have a ``rank`` column showing the calculated relevance score. Thanks, `liunux4odoo <https://github.com/liunux4odoo>`__. (`#628 <https://github.com/simonw/sqlite-utils/pull/628>`__)
- Fixed an error that occurred when creating a strict table with at least one floating point column. These ``FLOAT`` columns are now correctly created as ``REAL`` as well, but only for strict tables. (:issue:`644`)
.. _v3_37:
3.37 (2024-07-18)
-----------------
- The ``create-table`` and ``insert-files`` commands all now accept multiple ``--pk`` options for compound primary keys. (:issue:`620`)
- Now tested against Python 3.13 pre-release. (`#619 <https://github.com/simonw/sqlite-utils/pull/619>`__)
- Fixed a crash that can occur in environments with a broken ``numpy`` installation, producing a ``module 'numpy' has no attribute 'int8'``. (:issue:`632`)
.. _v3_36:
3.36 (2023-12-07)
-----------------
- Support for creating tables in `SQLite STRICT mode <https://www.sqlite.org/stricttables.html>`__. Thanks, `Taj Khattra <https://github.com/tkhattra>`__. (:issue:`344`)
- CLI commands ``create-table``, ``insert`` and ``upsert`` all now accept a ``--strict`` option.
- Python methods that can create a table - ``table.create()`` and ``insert/upsert/insert_all/upsert_all`` all now accept an optional ``strict=True`` parameter.
- The ``transform`` command and ``table.transform()`` method preserve strict mode when transforming a table.
- The ``sqlite-utils create-table`` command now accepts ``str``, ``int`` and ``bytes`` as aliases for ``text``, ``integer`` and ``blob`` respectively. (:issue:`606`)
.. _v3_35_2:
3.35.2 (2023-11-03)
-------------------
- The ``--load-extension=spatialite`` option and :ref:`find_spatialite() <python_api_gis_find_spatialite>` utility function now both work correctly on ``arm64`` Linux. Thanks, `Mike Coats <https://github.com/MikeCoats>`__. (:issue:`599`)
- Fix for bug where ``sqlite-utils insert`` could cause your terminal cursor to disappear. Thanks, `Luke Plant <https://github.com/spookylukey>`__. (:issue:`433`)
- ``datetime.timedelta`` values are now stored as ``TEXT`` columns. Thanks, `Harald Nezbeda <https://github.com/nezhar>`__. (:issue:`522`)
- Test suite is now also run against Python 3.12.
.. _v3_35_1:
3.35.1 (2023-09-08)
-------------------
- Fixed a bug where :ref:`table.transform() <python_api_transform>` would sometimes re-assign the ``rowid`` values for a table rather than keeping them consistent across the operation. (:issue:`592`)
.. _v3_35:
3.35 (2023-08-17)
-----------------
Adding foreign keys to a table no longer uses ``PRAGMA writable_schema = 1`` to directly manipulate the ``sqlite_master`` table. This was resulting in errors in some Python installations where the SQLite library was compiled in a way that prevented this from working, in particular on macOS. Foreign keys are now added using the :ref:`table transformation <python_api_transform>` mechanism instead. (:issue:`577`)
This new mechanism creates a full copy of the table, so it is likely to be significantly slower for large tables, but will no longer trigger ``table sqlite_master may not be modified`` errors on platforms that do not support ``PRAGMA writable_schema = 1``.
A new plugin, `sqlite-utils-fast-fks <https://github.com/simonw/sqlite-utils-fast-fks>`__, is now available for developers who still want to use that faster but riskier implementation.
Other changes:
- The :ref:`table.transform() method <python_api_transform>` has two new parameters: ``foreign_keys=`` allows you to replace the foreign key constraints defined on a table, and ``add_foreign_keys=`` lets you specify new foreign keys to add. These complement the existing ``drop_foreign_keys=`` parameter. (:issue:`577`)
- The :ref:`sqlite-utils transform <cli_transform_table>` command has a new ``--add-foreign-key`` option which can be called multiple times to add foreign keys to a table that is being transformed. (:issue:`585`)
- :ref:`sqlite-utils convert <cli_convert>` now has a ``--pdb`` option for opening a debugger on the first encountered error in your conversion script. (:issue:`581`)
- Fixed a bug where ``sqlite-utils install -e '.[test]'`` option did not work correctly.
.. _v3_34:
3.34 (2023-07-22)
-----------------
This release introduces a new :ref:`plugin system <plugins>`. Read more about this in `sqlite-utils now supports plugins <https://simonwillison.net/2023/Jul/24/sqlite-utils-plugins/>`__. (:issue:`567`)
- Documentation describing :ref:`how to build a plugin <plugins_building>`.
- Plugin hook: :ref:`plugins_hooks_register_commands`, for plugins to add extra commands to ``sqlite-utils``. (:issue:`569`)
- Plugin hook: :ref:`plugins_hooks_prepare_connection`. Plugins can use this to help prepare the SQLite connection to do things like registering custom SQL functions. Thanks, `Alex Garcia <https://github.com/asg017>`__. (:issue:`574`)
- ``sqlite_utils.Database(..., execute_plugins=False)`` option for disabling plugin execution. (:issue:`575`)
- ``sqlite-utils install -e path-to-directory`` option for installing editable code. This option is useful during the development of a plugin. (:issue:`570`)
- ``table.create(...)`` method now accepts ``replace=True`` to drop and replace an existing table with the same name, or ``ignore=True`` to silently do nothing if a table already exists with the same name. (:issue:`568`)
- ``sqlite-utils insert ... --stop-after 10`` option for stopping the insert after a specified number of records. Works for the ``upsert`` command as well. (:issue:`561`)
- The ``--csv`` and ``--tsv`` modes for ``insert`` now accept a ``--empty-null`` option, which causes empty strings in the CSV file to be stored as ``null`` in the database. (:issue:`563`)
- New ``db.rename_table(table_name, new_name)`` method for renaming tables. (:issue:`565`)
- ``sqlite-utils rename-table my.db table_name new_name`` command for renaming tables. (:issue:`565`)
- The ``table.transform(...)`` method now takes an optional ``keep_table=new_table_name`` parameter, which will cause the original table to be renamed to ``new_table_name`` rather than being dropped at the end of the transformation. (:issue:`571`)
- Documentation now notes that calling ``table.transform()`` without any arguments will reformat the SQL schema stored by SQLite to be more aesthetically pleasing. (:issue:`564`)
.. _v3_33:
3.33 (2023-06-25)
-----------------
- ``sqlite-utils`` will now use `sqlean.py <https://github.com/nalgeon/sqlean.py>`__ in place of ``sqlite3`` if it is installed in the same virtual environment. This is useful for Python environments with either an outdated version of SQLite or with restrictions on SQLite such as disabled extension loading or restrictions resulting in the ``sqlite3.OperationalError: table sqlite_master may not be modified`` error. (:issue:`559`)
- New ``with db.ensure_autocommit_off()`` context manager, which ensures that the database is in autocommit mode for the duration of a block of code. This is used by ``db.enable_wal()`` and ``db.disable_wal()`` to ensure they work correctly with ``pysqlite3`` and ``sqlean.py``.
- New ``db.iterdump()`` method, providing an iterator over SQL strings representing a dump of the database. This uses ``sqlite-dump`` if it is available, otherwise falling back on the ``conn.iterdump()`` method from ``sqlite3``. Both ``pysqlite3`` and ``sqlean.py`` omit support for ``iterdump()`` - this method helps paper over that difference.
.. _v3_32_1:
3.32.1 (2023-05-21)
-------------------
- Examples in the :ref:`CLI documentation <cli>` can now all be copied and pasted without needing to remove a leading ``$``. (:issue:`551`)
- Documentation now covers :ref:`installation_completion` for ``bash`` and ``zsh``. (:issue:`552`)
.. _v3_32:
3.32 (2023-05-21)
-----------------
- New experimental ``sqlite-utils tui`` interface for interactively building command-line invocations, powered by `Trogon <https://github.com/Textualize/trogon>`__. This requires an optional dependency, installed using ``sqlite-utils install trogon``. (:issue:`545`)
- ``sqlite-utils analyze-tables`` command (:ref:`documentation <cli_analyze_tables>`) now has a ``--common-limit 20`` option for changing the number of common/least-common values shown for each column. (:issue:`544`)
- ``sqlite-utils analyze-tables --no-most`` and ``--no-least`` options for disabling calculation of most-common and least-common values.
- If a column contains only ``null`` values, ``analyze-tables`` will no longer attempt to calculate the most common and least common values for that column. (:issue:`547`)
- Calling ``sqlite-utils analyze-tables`` with non-existent columns in the ``-c/--column`` option now results in an error message. (:issue:`548`)
- The ``table.analyze_column()`` method (:ref:`documented here <python_api_analyze_column>`) now accepts ``most_common=False`` and ``least_common=False`` options for disabling calculation of those values.
.. _v3_31:
3.31 (2023-05-08)
-----------------
- Dropped support for Python 3.6. Tests now ensure compatibility with Python 3.11. (:issue:`517`)
- Automatically locates the SpatiaLite extension on Apple Silicon. Thanks, Chris Amico. (`#536 <https://github.com/simonw/sqlite-utils/pull/536>`__)
- New ``--raw-lines`` option for the ``sqlite-utils query`` and ``sqlite-utils memory`` commands, which outputs just the raw value of the first column of every row. (:issue:`539`)
- Fixed a bug where ``table.upsert_all()`` failed if the ``not_null=`` option was passed. (:issue:`538`)
- Fixed a ``ResourceWarning`` when using ``sqlite-utils insert``. (:issue:`534`)
- Now shows a more detailed error message when ``sqlite-utils insert`` is called with invalid JSON. (:issue:`532`)
- ``table.convert(..., skip_false=False)`` and ``sqlite-utils convert --no-skip-false`` options, for avoiding a misfeature where the :ref:`convert() <python_api_convert>` mechanism skips rows in the database with a falsey value for the specified column. Fixing this by default would be a backwards-incompatible change and is under consideration for a 4.0 release in the future. (:issue:`527`)
- Tables can now be created with self-referential foreign keys. Thanks, Scott Perry. (`#537 <https://github.com/simonw/sqlite-utils/pull/537>`__)
- ``sqlite-utils transform`` no longer breaks if a table defines default values for columns. Thanks, Kenny Song. (:issue:`509`)
- Fixed a bug where repeated calls to ``table.transform()`` did not work correctly. Thanks, Martin Carpenter. (:issue:`525`)
- Improved error message if ``rows_from_file()`` is passed a non-binary-mode file-like object. (:issue:`520`)
.. _v3_30:
3.30 (2022-10-25)
-----------------
- Now tested against Python 3.11. (:issue:`502`)
- New ``table.search_sql(include_rank=True)`` option, which adds a ``rank`` column to the generated SQL. Thanks, Jacob Chapman. (`#480 <https://github.com/simonw/sqlite-utils/pull/480>`__)
- Progress bars now display for newline-delimited JSON files using the ``--nl`` option. Thanks, Mischa Untaga. (:issue:`485`)
- New ``db.close()`` method. (:issue:`504`)
- 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 now covers the :ref:`release_process` for this package.
.. _v3_29:
3.29 (2022-08-27)
-----------------
- The ``sqlite-utils query``, ``memory`` and ``bulk`` commands now all accept a new ``--functions`` option. This can be passed a string of Python code, and any callable objects defined in that code will be made available to SQL queries as custom SQL functions. See :ref:`cli_query_functions` for details. (:issue:`471`)
- ``db[table].create(...)`` method now accepts a new ``transform=True`` parameter. If the table already exists it will be :ref:`transformed <python_api_transform>` to match the schema configuration options passed to the function. This may result in columns being added or dropped, column types being changed, column order being updated or not null and default values for columns being set. (:issue:`467`)
- Related to the above, the ``sqlite-utils create-table`` command now accepts a ``--transform`` option.
- New introspection property: ``table.default_values`` returns a dictionary mapping each column name with a default value to the configured default value. (:issue:`475`)
- The ``--load-extension`` option can now be provided a path to a compiled SQLite extension module accompanied by the name of an entrypoint, separated by a colon - for example ``--load-extension ./lines0:sqlite3_lines0_noread_init``. This feature is modelled on code first `contributed to Datasette <https://github.com/simonw/datasette/pull/1789>`__ by Alex Garcia. (:issue:`470`)
- Functions registered using the :ref:`db.register_function() <python_api_register_function>` method can now have a custom name specified using the new ``db.register_function(fn, name=...)`` parameter. (:issue:`458`)
- :ref:`sqlite-utils rows <cli_rows>` has a new ``--order`` option for specifying the sort order for the returned rows. (:issue:`469`)
- All of the CLI options that accept Python code blocks can now all be used to define functions that can access modules imported in that same block of code without needing to use the ``global`` keyword. (:issue:`472`)
- Fixed bug where ``table.extract()`` would not behave correctly for columns containing null values. Thanks, Forest Gregg. (:issue:`423`)
- New tutorial: `Cleaning data with sqlite-utils and Datasette <https://datasette.io/tutorials/clean-data>`__ shows how to use ``sqlite-utils`` to import and clean an example CSV file.
- Datasette and ``sqlite-utils`` now have a Discord community. `Join the Discord here <https://discord.gg/Ass7bCAMDw>`__.
.. _v3_28:
3.28 (2022-07-15)
-----------------
- New :ref:`table.duplicate(new_name) <python_api_duplicate>` method for creating a copy of a table with a matching schema and row contents. Thanks, `David <https://github.com/davidleejy>`__. (:issue:`449`)
- New ``sqlite-utils duplicate data.db table_name new_name`` CLI command for :ref:`cli_duplicate_table`. (:issue:`454`)
- ``sqlite_utils.utils.rows_from_file()`` is now a :ref:`documented API <reference_utils_rows_from_file>`. It can be used to read a sequence of dictionaries from a file-like object containing CSV, TSV, JSON or newline-delimited JSON. It can be passed an explicit format or can attempt to detect the format automatically. (:issue:`443`)
- ``sqlite_utils.utils.TypeTracker`` is now a documented API for detecting the likely column types for a sequence of string rows, see :ref:`python_api_typetracker`. (:issue:`445`)
- ``sqlite_utils.utils.chunks()`` is now a documented API for :ref:`splitting an iterator into chunks <reference_utils_chunks>`. (:issue:`451`)
- ``sqlite-utils enable-fts`` now has a ``--replace`` option for replacing the existing FTS configuration for a table. (:issue:`450`)
- The ``create-index``, ``add-column`` and ``duplicate`` commands all now take a ``--ignore`` option for ignoring errors should the database not be in the right state for them to operate. (:issue:`450`)
.. _v3_27:
3.27 (2022-06-14)
-----------------
See also `the annotated release notes <https://simonwillison.net/2022/Jun/19/weeknotes/#sqlite-utils-3-27>`__ for this release.
- Documentation now uses the `Furo <https://github.com/pradyunsg/furo>`__ Sphinx theme. (:issue:`435`)
- Code examples in documentation now have a "copy to clipboard" button. (:issue:`436`)
- ``sqlite_utils.utils.utils.rows_from_file()`` is now a documented API, see :ref:`python_api_rows_from_file`. (:issue:`443`)
- ``rows_from_file()`` has two new parameters to help handle CSV files with rows that contain more values than are listed in that CSV file's headings: ``ignore_extras=True`` and ``extras_key="name-of-key"``. (:issue:`440`)
- ``sqlite_utils.utils.maximize_csv_field_size_limit()`` helper function for increasing the field size limit for reading CSV files to its maximum, see :ref:`python_api_maximize_csv_field_size_limit`. (:issue:`442`)
- ``table.search(where=, where_args=)`` parameters for adding additional ``WHERE`` clauses to a search query. The ``where=`` parameter is available on ``table.search_sql(...)`` as well. See :ref:`python_api_fts_search`. (:issue:`441`)
- Fixed bug where ``table.detect_fts()`` and other search-related functions could fail if two FTS-enabled tables had names that were prefixes of each other. (:issue:`434`)
.. _v3_26_1:
3.26.1 (2022-05-02)
-------------------
- Now depends on `click-default-group-wheel <https://github.com/simonw/click-default-group-wheel>`__, a pure Python wheel package. This means you can install and use this package with `Pyodide <https://pyodide.org/>`__, which can run Python entirely in your browser using WebAssembly. (`#429 <https://github.com/simonw/sqlite-utils/pull/429>`__)
Try that out using the `Pyodide REPL <https://pyodide.org/en/stable/console.html>`__:
.. code-block:: python
>>> import micropip
>>> await micropip.install("sqlite-utils")
>>> import sqlite_utils
>>> db = sqlite_utils.Database(memory=True)
>>> list(db.query("select 3 * 5"))
[{'3 * 5': 15}]
.. _v3_26:
3.26 (2022-04-13)
-----------------
- New ``errors=r.IGNORE/r.SET_NULL`` parameter for the ``r.parsedatetime()`` and ``r.parsedate()`` :ref:`convert recipes <cli_convert_recipes>`. (:issue:`416`)
- Fixed a bug where ``--multi`` could not be used in combination with ``--dry-run`` for the :ref:`convert <cli_convert>` command. (:issue:`415`)
- New documentation: :ref:`cli_convert_complex`. (:issue:`420`)
- More robust detection for whether or not ``deterministic=True`` is supported. (:issue:`425`)
.. _v3_25_1:
3.25.1 (2022-03-11)
-------------------
- Improved display of type information and parameters in the :ref:`API reference documentation <reference>`. (:issue:`413`)
.. _v3_25:
3.25 (2022-03-01)
-----------------
- New ``hash_id_columns=`` parameter for creating a primary key that's a hash of the content of specific columns - see :ref:`python_api_hash` for details. (:issue:`343`)
- New :ref:`db.sqlite_version <python_api_sqlite_version>` property, returning a tuple of integers representing the version of SQLite, for example ``(3, 38, 0)``.
- Fixed a bug where :ref:`register_function(deterministic=True) <python_api_register_function>` caused errors on versions of SQLite prior to 3.8.3. (:issue:`408`)
- New documented :ref:`hash_record(record, keys=...) <reference_utils_hash_record>` function.
.. _v3_24:
3.24 (2022-02-15)
-----------------
- SpatiaLite helpers for the ``sqlite-utils`` command-line tool - thanks, Chris Amico. (:issue:`398`)
- :ref:`sqlite-utils create-database <cli_create_database>` ``--init-spatialite`` option for initializing SpatiaLite on a newly created database.
- :ref:`sqlite-utils add-geometry-column <cli_spatialite>` command for adding geometry columns.
- :ref:`sqlite-utils create-spatial-index <cli_spatialite_indexes>` command for adding spatial indexes.
- ``db[table].create(..., if_not_exists=True)`` option for :ref:`creating a table <python_api_explicit_create>` only if it does not already exist. (:issue:`397`)
- ``Database(memory_name="my_shared_database")`` parameter for creating a :ref:`named in-memory database <python_api_connect>` that can be shared between multiple connections. (:issue:`405`)
- Documentation now describes :ref:`how to add a primary key to a rowid table <cli_transform_table_add_primary_key_to_rowid>` using ``sqlite-utils transform``. (:issue:`403`)
.. _v3_23:
3.23 (2022-02-03)
-----------------
This release introduces four new utility methods for working with `SpatiaLite <https://www.gaia-gis.it/fossil/libspatialite/index>`__. Thanks, Chris Amico. (`#385 <https://github.com/simonw/sqlite-utils/pull/385>`__)
- ``sqlite_utils.utils.find_spatialite()`` :ref:`finds the location of the SpatiaLite module <python_api_gis_find_spatialite>` on disk.
- ``db.init_spatialite()`` :ref:`initializes SpatiaLite <python_api_gis_init_spatialite>` for the given database.
- ``table.add_geometry_column(...)`` :ref:`adds a geometry column <python_api_gis_add_geometry_column>` to an existing table.
- ``table.create_spatial_index(...)`` :ref:`creates a spatial index <python_api_gis_create_spatial_index>` for a column.
- ``sqlite-utils batch`` now accepts a ``--batch-size`` option. (:issue:`392`)
.. _v3_22_1:
3.22.1 (2022-01-25)
-------------------
- All commands now include example usage in their ``--help`` - see :ref:`cli_reference`. (:issue:`384`)
- Python library documentation has a new :ref:`python_api_getting_started` section. (:issue:`387`)
- Documentation now uses `Plausible analytics <https://plausible.io/>`__. (:issue:`389`)
.. _v3_22:
3.22 (2022-01-11)
-----------------
- New :ref:`cli_reference` documentation page, listing the output of ``--help`` for every one of the CLI commands. (:issue:`383`)
- ``sqlite-utils rows`` now has ``--limit`` and ``--offset`` options for paginating through data. (:issue:`381`)
- ``sqlite-utils rows`` now has ``--where`` and ``-p`` options for filtering the table using a ``WHERE`` query, see :ref:`cli_rows`. (:issue:`382`)
.. _v3_21:
3.21 (2022-01-10)
-----------------
CLI and Python library improvements to help run `ANALYZE <https://www.sqlite.org/lang_analyze.html>`__ after creating indexes or inserting rows, to gain better performance from the SQLite query planner when it runs against indexes.
Three new CLI commands: ``create-database``, ``analyze`` and ``bulk``.
More details and examples can be found in `the annotated release notes <https://simonwillison.net/2022/Jan/11/sqlite-utils/>`__.
- New ``sqlite-utils create-database`` command for creating new empty database files. (:issue:`348`)
- New Python methods for running ``ANALYZE`` against a database, table or index: ``db.analyze()`` and ``table.analyze()``, see :ref:`python_api_analyze`. (:issue:`366`)
- New :ref:`sqlite-utils analyze command <cli_analyze>` for running ``ANALYZE`` using the CLI. (:issue:`379`)
- The ``create-index``, ``insert`` and ``upsert`` commands now have a new ``--analyze`` option for running ``ANALYZE`` after the command has completed. (:issue:`379`)
- New :ref:`sqlite-utils bulk command <cli_bulk>` which can import records in the same way as ``sqlite-utils insert`` (from JSON, CSV or TSV) and use them to bulk execute a parametrized SQL query. (:issue:`375`)
- The CLI tool can now also be run using ``python -m sqlite_utils``. (:issue:`368`)
- Using ``--fmt`` now implies ``--table``, so you don't need to pass both options. (:issue:`374`)
- The ``--convert`` function applied to rows can now modify the row in place. (:issue:`371`)
- The :ref:`insert-files command <cli_insert_files>` supports two new columns: ``stem`` and ``suffix``. (:issue:`372`)
- The ``--nl`` import option now ignores blank lines in the input. (:issue:`376`)
- Fixed bug where streaming input to the ``insert`` command with ``--batch-size 1`` would appear to only commit after several rows had been ingested, due to unnecessary input buffering. (:issue:`364`)
.. _v3_20:
3.20 (2022-01-05)
-----------------
- ``sqlite-utils insert ... --lines`` to insert the lines from a file into a table with a single ``line`` column, see :ref:`cli_insert_unstructured`.
- ``sqlite-utils insert ... --text`` to insert the contents of the file into a table with a single ``text`` column and a single row.
- ``sqlite-utils insert ... --convert`` allows a Python function to be provided that will be used to convert each row that is being inserted into the database. See :ref:`cli_insert_convert`, including details on special behavior when combined with ``--lines`` and ``--text``. (:issue:`356`)
- ``sqlite-utils convert`` now accepts a code value of ``-`` to read code from standard input. (:issue:`353`)
- ``sqlite-utils convert`` also now accepts code that defines a named ``convert(value)`` function, see :ref:`cli_convert`.
- ``db.supports_strict`` property showing if the database connection supports `SQLite strict tables <https://www.sqlite.org/stricttables.html>`__.
- ``table.strict`` property (see :ref:`python_api_introspection_strict`) indicating if the table uses strict mode. (:issue:`344`)
- Fixed bug where ``sqlite-utils upsert ... --detect-types`` ignored the ``--detect-types`` option. (:issue:`362`)
.. _v3_19:
3.19 (2021-11-20)
-----------------
- The :ref:`table.lookup() method <python_api_lookup_tables>` now accepts keyword arguments that match those on the underlying ``table.insert()`` method: ``foreign_keys=``, ``column_order=``, ``not_null=``, ``defaults=``, ``extracts=``, ``conversions=`` and ``columns=``. You can also now pass ``pk=`` to specify a different column name to use for the primary key. (:issue:`342`)
.. _v3_18:
3.18 (2021-11-14)
-----------------
- The ``table.lookup()`` method now has an optional second argument which can be used to populate columns only the first time the record is created, see :ref:`python_api_lookup_tables`. (:issue:`339`)
- ``sqlite-utils memory`` now has a ``--flatten`` option for :ref:`flattening nested JSON objects <cli_inserting_data_flatten>` into separate columns, consistent with ``sqlite-utils insert``. (:issue:`332`)
- ``table.create_index(..., find_unique_name=True)`` parameter, which finds an available name for the created index even if the default name has already been taken. This means that ``index-foreign-keys`` will work even if one of the indexes it tries to create clashes with an existing index name. (:issue:`335`)
- Added ``py.typed`` to the module, so `mypy <http://mypy-lang.org/>`__ should now correctly pick up the type annotations. Thanks, Andreas Longo. (:issue:`331`)
- Now depends on ``python-dateutil`` instead of depending on ``dateutils``. Thanks, Denys Pavlov. (:issue:`324`)
- ``table.create()`` (see :ref:`python_api_explicit_create`) now handles ``dict``, ``list`` and ``tuple`` types, mapping them to ``TEXT`` columns in SQLite so that they can be stored encoded as JSON. (:issue:`338`)
- Inserted data with square braces in the column names (for example a CSV file containing a ``item[price]``) column now have the braces converted to underscores: ``item_price_``. Previously such columns would be rejected with an error. (:issue:`329`)
- Now also tested against Python 3.10. (`#330 <https://github.com/simonw/sqlite-utils/pull/330>`__)
.. _v3_17.1:
3.17.1 (2021-09-22)
-------------------
- :ref:`sqlite-utils memory <cli_memory>` now works if files passed to it share the same file name. (:issue:`325`)
- :ref:`sqlite-utils query <cli_query>` now returns ``[]`` in JSON mode if no rows are returned. (:issue:`328`)
.. _v3_17:
3.17 (2021-08-24)
-----------------
- The :ref:`sqlite-utils memory <cli_memory>` command has a new ``--analyze`` option, which runs the equivalent of the :ref:`analyze-tables <cli_analyze_tables>` command directly against the in-memory database created from the incoming CSV or JSON data. (:issue:`320`)
- :ref:`sqlite-utils insert-files <cli_insert_files>` now has the ability to insert file contents in to ``TEXT`` columns in addition to the default ``BLOB``. Pass the ``--text`` option or use ``content_text`` as a column specifier. (:issue:`319`)
.. _v3_16:
3.16 (2021-08-18)
-----------------
- Type signatures added to more methods, including ``table.resolve_foreign_keys()``, ``db.create_table_sql()``, ``db.create_table()`` and ``table.create()``. (:issue:`314`)
- New ``db.quote_fts(value)`` method, see :ref:`python_api_quote_fts` - thanks, Mark Neumann. (:issue:`246`)
- ``table.search()`` now accepts an optional ``quote=True`` parameter. (:issue:`296`)
- CLI command ``sqlite-utils search`` now accepts a ``--quote`` option. (:issue:`296`)
- Fixed bug where ``--no-headers`` and ``--tsv`` options to :ref:`sqlite-utils insert <cli_insert_csv_tsv>` could not be used together. (:issue:`295`)
- Various small improvements to :ref:`reference` documentation.
.. _v3_15.1:
3.15.1 (2021-08-10)
-------------------
- Python library now includes type annotations on almost all of the methods, plus detailed docstrings describing each one. (:issue:`311`)
- New :ref:`reference` documentation page, powered by those docstrings.
- Fixed bug where ``.add_foreign_keys()`` failed to raise an error if called against a ``View``. (:issue:`313`)
- Fixed bug where ``.delete_where()`` returned a ``[]`` instead of returning ``self`` if called against a non-existent table. (:issue:`315`)
.. _v3_15:
3.15 (2021-08-09)
@ -953,3 +1539,19 @@ A few other changes:
----------------
- ``enable_fts()``, ``populate_fts()`` and ``search()`` table methods
0.3.1 (2018-07-31)
------------------
- Documented related projects
- Added badges to the documentation
0.3 (2018-07-31)
----------------
- New ``Table`` class representing a table in the SQLite database
0.2 (2018-07-28)
----------------
- Initial release to PyPI

1647
docs/cli-reference.rst Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,10 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from subprocess import Popen, PIPE
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.
@ -30,12 +33,69 @@ from subprocess import Popen, PIPE
# Add any Sphinx extension module names here, as strings. They can be
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones.
extensions = ["sphinx.ext.extlinks"]
extensions = [
"sphinx.ext.extlinks",
"sphinx.ext.autodoc",
"sphinx_copybutton",
"sphinx.ext.linkcode",
]
autodoc_member_order = "bysource"
autodoc_typehints = "description"
extlinks = {
"issue": ("https://github.com/simonw/sqlite-utils/issues/%s", "#"),
"issue": ("https://github.com/simonw/sqlite-utils/issues/%s", "#%s"),
}
def _linkcode_git_ref():
try:
return check_output(["git", "rev-parse", "HEAD"]).decode("utf8").strip()
except Exception:
return "main"
def linkcode_resolve(domain, info):
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}"
)
# Add any paths that contain templates here, relative to this directory.
templates_path = ["_templates"]
@ -50,7 +110,7 @@ master_doc = "index"
# General information about the project.
project = "sqlite-utils"
copyright = "2018-2021, Simon Willison"
copyright = "2018-2022, Simon Willison"
author = "Simon Willison"
# The version info for the project you're documenting, acts as replacement for
@ -59,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]
@ -73,7 +133,7 @@ else:
#
# This is also used if you do content translation via gettext catalogs.
# Usually you set "language" from the command line for these cases.
language = None
language = "en"
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
@ -83,6 +143,9 @@ exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
# The name of the Pygments (syntax highlighting) style to use.
pygments_style = "sphinx"
# Only syntax highlight of code-block is used:
highlight_language = "none"
# If true, `todo` and `todoList` produce output, else they produce nothing.
todo_include_todos = False
@ -92,7 +155,8 @@ todo_include_todos = False
# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
#
html_theme = "sphinx_rtd_theme"
html_theme = "furo"
html_title = "sqlite-utils"
# Theme options are theme-specific and customize the look and feel of a theme
# further. For a list of options available for each theme, see the
@ -105,18 +169,7 @@ html_theme = "sphinx_rtd_theme"
# so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ["_static"]
# Custom sidebar templates, must be a dictionary that maps document names
# to template names.
#
# This is required for the alabaster theme
# refs: http://alabaster.readthedocs.io/en/latest/installation.html#sidebars
html_sidebars = {
"**": [
"relations.html", # needs 'show_related': True theme option to display
"searchbox.html",
]
}
html_js_files = ["js/custom.js"]
# -- Options for HTMLHelp output ------------------------------------------
@ -174,7 +227,7 @@ texinfo_documents = [
"sqlite-utils documentation",
author,
"sqlite-utils",
"Python utility functions for manipulating SQLite databases",
"Python library for manipulating SQLite databases",
"Miscellaneous",
)
]

View file

@ -4,64 +4,134 @@
Contributing
==============
To work on this library locally, first checkout the code. Then create a new virtual environment::
Development of ``sqlite-utils`` takes place in the `sqlite-utils GitHub repository <https://github.com/simonw/sqlite-utils>`__.
All improvements to the software should start with an issue. Read `How I build a feature <https://simonwillison.net/2022/Jan/12/how-i-build-a-feature/>`__ for a detailed description of the recommended process for building bug fixes or enhancements.
.. _contributing_checkout:
Obtaining the code
==================
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::
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.
cd docs
make livehtml
The `cog <https://github.com/nedbat/cog>`__ tool is used to maintain portions of the documentation. You can run it like so::
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
==========
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::
just
To run tests, or run a specific test module or test by name::
just test # All tests
just test tests/test_cli_memory.py # Just this module
just test -k test_memory_no_detect_types # Just this test
To run just the linters::
just lint
To apply Black to your code::
just black
To update documentation using Cog::
just cog
To run the live documentation server (this will run Cog first)::
just docs
And to list all available commands::
just -l
.. _release_process:
Release process
===============
Releases are performed using tags. When a new release is published on GitHub, a `GitHub Actions workflow <https://github.com/simonw/sqlite-utils/blob/main/.github/workflows/publish.yml>`__ will perform the following:
* Run the unit tests against all supported Python versions. If the tests pass...
* Build a wheel bundle of the underlying Python source code
* Push that new wheel up to PyPI: https://pypi.org/project/sqlite-utils/
To deploy new releases you will need to have push access to the GitHub repository.
``sqlite-utils`` follows `Semantic Versioning <https://semver.org/>`__::
major.minor.patch
We increment ``major`` for backwards-incompatible releases.
We increment ``minor`` for new features.
We increment ``patch`` for bugfix releass.
To release a new version, first create a commit that updates the version number in ``pyproject.toml`` and the :ref:`the changelog <changelog>` with highlights of the new version. An example `commit can be seen here <https://github.com/simonw/sqlite-utils/commit/b491f22d817836829965516983a3f4c3c72c05fc>`__::
# Update changelog
git commit -m " Release 3.29
Refs #423, #458, #467, #469, #470, #471, #472, #475" -a
git push
Referencing the issues that are part of the release in the commit message ensures the name of the release shows up on those issue pages, e.g. `here <https://github.com/simonw/sqlite-utils/issues/458#ref-commit-b491f22>`__.
You can generate the list of issue references for a specific release by copying and pasting text from the release notes or GitHub changes-since-last-release view into this `Extract issue numbers from pasted text <https://observablehq.com/@simonw/extract-issue-numbers-from-pasted-text>`__ tool.
To create the tag for the release, create `a new release <https://github.com/simonw/sqlite-utils/releases/new>`__ on GitHub matching the new version number. You can convert the release notes to Markdown by copying and pasting the rendered HTML into this `Paste to Markdown tool <https://euangoddard.github.io/clipboard2markdown/>`__.

View file

@ -2,7 +2,7 @@
sqlite-utils |version|
=======================
|PyPI| |Changelog| |CI| |License|
|PyPI| |Changelog| |CI| |License| |discord|
.. |PyPI| image:: https://img.shields.io/pypi/v/sqlite-utils.svg
:target: https://pypi.org/project/sqlite-utils/
@ -12,8 +12,10 @@
:target: https://github.com/simonw/sqlite-utils/actions
.. |License| image:: https://img.shields.io/badge/license-Apache%202.0-blue.svg
:target: https://github.com/simonw/sqlite-utils/blob/main/LICENSE
.. |discord| image:: https://img.shields.io/discord/823971286308356157?label=discord
:target: https://discord.gg/Ass7bCAMDw
*Python utility functions for manipulating SQLite databases*
*CLI tool and Python library for manipulating SQLite databases*
This library and command-line utility helps create SQLite databases from an existing collection of data.
@ -23,6 +25,8 @@ sqlite-utils is not intended to be a full ORM: the focus is utility helpers to m
It is designed as a useful complement to `Datasette <https://datasette.io/>`_.
`Cleaning data with sqlite-utils and Datasette <https://datasette.io/tutorials/clean-data>`_ provides a tutorial introduction (and accompanying ten minute video) about using this tool.
Contents
--------
@ -32,7 +36,10 @@ Contents
installation
cli
python-api
migrations
plugins
reference
cli-reference
upgrading
contributing
changelog
Take a look at `this script <https://github.com/simonw/russian-ira-facebook-ads-datasette/blob/master/fetch_and_build_russian_ads.py>`_ for an example of this library in action.

View file

@ -38,3 +38,53 @@ Using pipx
`pipx <https://pypi.org/project/pipx/>`__ is a tool for installing Python command-line applications in their own isolated environments. You can use ``pipx`` to install the ``sqlite-utils`` command-line tool like this::
pipx install sqlite-utils
.. _installation_sqlite3_alternatives:
Alternatives to sqlite3
=======================
By default, ``sqlite-utils`` uses the ``sqlite3`` package bundled with the Python standard library.
Depending on your operating system, this may come with some limitations.
On some platforms the ability to load additional extensions (via ``conn.load_extension(...)`` or ``--load-extension=/path/to/extension``) may be disabled.
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 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 ``pysqlite3`` run the following:
.. code-block:: bash
sqlite-utils install pysqlite3
``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
sqlite-utils install sqlite-dump
.. _installation_completion:
Setting up shell completion
===========================
You can configure shell tab completion for the ``sqlite-utils`` command using these commands.
For ``bash``:
.. code-block:: bash
eval "$(_SQLITE_UTILS_COMPLETE=bash_source sqlite-utils)"
For ``zsh``:
.. code-block:: zsh
eval "$(_SQLITE_UTILS_COMPLETE=zsh_source sqlite-utils)"
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.

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

159
docs/plugins.rst Normal file
View file

@ -0,0 +1,159 @@
.. _plugins:
=========
Plugins
=========
``sqlite-utils`` supports plugins, which can be used to add extra features to the software.
Plugins can add new commands, for example ``sqlite-utils some-command ...``
Plugins can be installed using the ``sqlite-utils install`` command:
.. code-block:: bash
sqlite-utils install sqlite-utils-name-of-plugin
You can see a JSON list of plugins that have been installed by running this:
.. code-block:: bash
sqlite-utils plugins
Plugin hooks such as :ref:`plugins_hooks_prepare_connection` affect each instance of the ``Database`` class. You can opt-out of these plugins by creating that class instance like so:
.. code-block:: python
db = Database(memory=True, execute_plugins=False)
.. _plugins_building:
Building a plugin
-----------------
Plugins are created in a directory named after the plugin. To create a "hello world" plugin, first create a ``hello-world`` directory:
.. code-block:: bash
mkdir hello-world
cd hello-world
In that folder create two files. The first is a ``pyproject.toml`` file describing the plugin:
.. code-block:: toml
[project]
name = "sqlite-utils-hello-world"
version = "0.1"
[project.entry-points.sqlite_utils]
hello_world = "sqlite_utils_hello_world"
The ``[project.entry-points.sqlite_utils]`` section tells ``sqlite-utils`` which module to load when executing the plugin.
Then create ``sqlite_utils_hello_world.py`` with the following content:
.. code-block:: python
import click
import sqlite_utils
@sqlite_utils.hookimpl
def register_commands(cli):
@cli.command()
def hello_world():
"Say hello world"
click.echo("Hello world!")
Install the plugin in "editable" mode - so you can make changes to the code and have them picked up instantly by ``sqlite-utils`` - like this:
.. code-block:: bash
sqlite-utils install -e .
Or pass the path to your plugin directory:
.. code-block:: bash
sqlite-utils install -e /dev/sqlite-utils-hello-world
Now, running this should execute your new command:
.. code-block:: bash
sqlite-utils hello-world
Your command will also be listed in the output of ``sqlite-utils --help``.
See the `LLM plugin documentation <https://llm.datasette.io/en/stable/plugins/tutorial-model-plugin.html#distributing-your-plugin>`__ for tips on distributing your plugin.
.. _plugins_hooks:
Plugin hooks
------------
Plugin hooks allow ``sqlite-utils`` to be customized.
.. _plugins_hooks_register_commands:
register_commands(cli)
~~~~~~~~~~~~~~~~~~~~~~
This hook can be used to register additional commands with the ``sqlite-utils`` CLI. It is called with the ``cli`` object, which is a ``click.Group`` instance.
Example implementation:
.. code-block:: python
import click
import sqlite_utils
@sqlite_utils.hookimpl
def register_commands(cli):
@cli.command()
def hello_world():
"Say hello world"
click.echo("Hello world!")
New commands implemented by plugins can invoke existing commands using the `context.invoke <https://click.palletsprojects.com/en/stable/api/#click.Context.invoke>`__ mechanism.
As a special niche feature, if your plugin needs to import some files and then act against an in-memory database containing those files you can forward to the :ref:`sqlite-utils memory command <cli_memory>` and pass it ``return_db=True``:
.. code-block:: python
@cli.command()
@click.pass_context
@click.argument(
"paths",
type=click.Path(file_okay=True, dir_okay=False, allow_dash=True),
required=False,
nargs=-1,
)
def show_schema_for_files(ctx, paths):
from sqlite_utils.cli import memory
db = ctx.invoke(memory, paths=paths, return_db=True)
# Now do something with that database
click.echo(db.schema)
.. _plugins_hooks_prepare_connection:
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:
.. code-block:: python
import sqlite_utils
@sqlite_utils.hookimpl
def prepare_connection(conn):
conn.create_function(
"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:
.. code-block:: sql
select hello("world"); -- "Hello, world!"

File diff suppressed because it is too large Load diff

117
docs/reference.rst Normal file
View file

@ -0,0 +1,117 @@
.. _reference:
===============
API reference
===============
.. contents:: :local:
:class: this-will-duplicate-information-and-it-is-still-useful-here
.. _reference_db_database:
sqlite_utils.db.Database
========================
.. autoclass:: sqlite_utils.db.Database
:members:
:undoc-members:
:special-members: __getitem__
:exclude-members: use_counts_table, execute_returning_dicts, resolve_foreign_keys
.. _reference_db_queryable:
sqlite_utils.db.Queryable
=========================
:ref:`Table <reference_db_table>` and :ref:`View <reference_db_view>` are both subclasses of ``Queryable``, providing access to the following methods:
.. autoclass:: sqlite_utils.db.Queryable
:members:
:undoc-members:
:exclude-members: execute_count
.. _reference_db_table:
sqlite_utils.db.Table
=====================
.. autoclass:: sqlite_utils.db.Table
:members:
:undoc-members:
:show-inheritance:
:exclude-members: guess_foreign_column, value_or_default, build_insert_queries_and_params, insert_chunk, add_missing_columns
.. _reference_db_view:
sqlite_utils.db.View
====================
.. autoclass:: sqlite_utils.db.View
:members:
:undoc-members:
:show-inheritance:
.. _reference_db_other:
Other
=====
.. _reference_db_other_column:
sqlite_utils.db.Column
----------------------
.. autoclass:: sqlite_utils.db.Column
.. _reference_db_other_column_details:
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
==================
.. _reference_utils_hash_record:
sqlite_utils.utils.hash_record
------------------------------
.. autofunction:: sqlite_utils.utils.hash_record
.. _reference_utils_rows_from_file:
sqlite_utils.utils.rows_from_file
---------------------------------
.. autofunction:: sqlite_utils.utils.rows_from_file
.. _reference_utils_typetracker:
sqlite_utils.utils.TypeTracker
------------------------------
.. autoclass:: sqlite_utils.utils.TypeTracker
:members: wrap, types
.. _reference_utils_chunks:
sqlite_utils.utils.chunks
-------------------------
.. autofunction:: sqlite_utils.utils.chunks
.. _reference_utils_flatten:
sqlite_utils.utils.flatten
--------------------------
.. autofunction:: sqlite_utils.utils.flatten

View file

@ -39,10 +39,8 @@
"Requirement already satisfied: sqlite-fts4 in /usr/local/lib/python3.9/site-packages (from sqlite_utils) (1.0.1)\n",
"Requirement already satisfied: click in /Users/simon/Library/Python/3.9/lib/python/site-packages (from sqlite_utils) (7.1.2)\n",
"Requirement already satisfied: tabulate in /usr/local/lib/python3.9/site-packages (from sqlite_utils) (0.8.7)\n",
"Requirement already satisfied: dateutils in /usr/local/Cellar/jupyterlab/3.0.16_1/libexec/lib/python3.9/site-packages (from sqlite_utils) (0.6.12)\n",
"Requirement already satisfied: python-dateutil in /usr/local/Cellar/jupyterlab/3.0.16_1/libexec/lib/python3.9/site-packages (from dateutils->sqlite_utils) (2.8.1)\n",
"Requirement already satisfied: pytz in /usr/local/Cellar/jupyterlab/3.0.16_1/libexec/lib/python3.9/site-packages (from dateutils->sqlite_utils) (2021.1)\n",
"Requirement already satisfied: six>=1.5 in /usr/local/Cellar/jupyterlab/3.0.16_1/libexec/lib/python3.9/site-packages (from python-dateutil->dateutils->sqlite_utils) (1.16.0)\n",
"Requirement already satisfied: python-dateutil in /usr/local/Cellar/jupyterlab/3.0.16_1/libexec/lib/python3.9/site-package (from sqlite-utils) (2.8.1)\n",
"Requirement already satisfied: six>=1.5 in /usr/local/Cellar/jupyterlab/3.0.16_1/libexec/lib/python3.9/site-package (from python-dateutil->sqlite-utils) (1.16.0)\n",
"\u001b[33mWARNING: You are using pip version 21.1.1; however, version 21.2.2 is available.\n",
"You should consider upgrading via the '/usr/local/Cellar/jupyterlab/3.0.16_1/libexec/bin/python3.9 -m pip install --upgrade pip' command.\u001b[0m\n",
"Note: you may need to restart the kernel to use updated packages.\n"
@ -182,10 +180,10 @@
"name": "stdout",
"output_type": "stream",
"text": [
"CREATE TABLE [creatures] (\n",
" [name] TEXT,\n",
" [species] TEXT,\n",
" [age] FLOAT\n",
"CREATE TABLE \"creatures\" (\n",
" \"name\" TEXT,\n",
" \"species\" TEXT,\n",
" \"age\" FLOAT\n",
")\n"
]
}
@ -536,11 +534,11 @@
"name": "stdout",
"output_type": "stream",
"text": [
"CREATE TABLE [creatures] (\n",
" [id] INTEGER PRIMARY KEY,\n",
" [name] TEXT,\n",
" [species] TEXT,\n",
" [age] FLOAT\n",
"CREATE TABLE \"creatures\" (\n",
" \"id\" INTEGER PRIMARY KEY,\n",
" \"name\" TEXT,\n",
" \"species\" TEXT,\n",
" \"age\" FLOAT\n",
")\n"
]
}
@ -931,11 +929,11 @@
"output_type": "stream",
"text": [
"CREATE TABLE \"creatures\" (\n",
" [id] INTEGER PRIMARY KEY,\n",
" [name] TEXT,\n",
" [species_id] INTEGER,\n",
" [age] FLOAT,\n",
" FOREIGN KEY([species_id]) REFERENCES [species]([id])\n",
" \"id\" INTEGER PRIMARY KEY,\n",
" \"name\" TEXT,\n",
" \"species_id\" INTEGER,\n",
" \"age\" FLOAT,\n",
" FOREIGN KEY(\"species_id\") REFERENCES \"species\"(\"id\")\n",
")\n",
"[{'id': 1, 'name': 'Cleo', 'species_id': 1, 'age': 6.0}, {'id': 2, 'name': 'Lila', 'species_id': 2, 'age': 0.8}, {'id': 3, 'name': 'Bants', 'species_id': 2, 'age': 0.8}, {'id': 4, 'name': 'Azi', 'species_id': 2, 'age': 0.8}, {'id': 5, 'name': 'Snowy', 'species_id': 2, 'age': 0.9}, {'id': 6, 'name': 'Blue', 'species_id': 2, 'age': 0.9}]\n"
]
@ -964,9 +962,9 @@
"name": "stdout",
"output_type": "stream",
"text": [
"CREATE TABLE [species] (\n",
" [id] INTEGER PRIMARY KEY,\n",
" [species] TEXT\n",
"CREATE TABLE \"species\" (\n",
" \"id\" INTEGER PRIMARY KEY,\n",
" \"species\" TEXT\n",
")\n",
"[{'id': 1, 'species': 'dog'}, {'id': 2, 'species': 'chicken'}]\n"
]

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.

32
mypy.ini Normal file
View file

@ -0,0 +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-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

85
pyproject.toml Normal file
View file

@ -0,0 +1,85 @@
[project]
name = "sqlite-utils"
version = "4.1.1"
description = "CLI tool and Python library for manipulating SQLite databases"
readme = { file = "README.md", content-type = "text/markdown" }
authors = [
{ name = "Simon Willison" },
]
license = "Apache-2.0"
requires-python = ">=3.10"
classifiers = [
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"Intended Audience :: End Users/Desktop",
"Intended Audience :: Science/Research",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Topic :: Database",
]
dependencies = [
"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]
Homepage = "https://github.com/simonw/sqlite-utils"
Documentation = "https://sqlite-utils.datasette.io/en/stable/"
Changelog = "https://sqlite-utils.datasette.io/en/stable/changelog.html"
Issues = "https://github.com/simonw/sqlite-utils/issues"
CI = "https://github.com/simonw/sqlite-utils/actions"
[project.scripts]
sqlite-utils = "sqlite_utils.cli:cli"
[build-system]
# setuptools 77+ is needed for the PEP 639 license = "Apache-2.0" expression
requires = ["setuptools>=77"]
build-backend = "setuptools.build_meta"
[tool.flake8]
max-line-length = 160
# Black compatibility, E203 whitespace before ':':
extend-ignore = ["E203"]
extend-exclude = [".venv", "build", "dist", "docs", "sqlite_utils.egg-info"]
[tool.setuptools.package-data]
sqlite_utils = ["py.typed"]

View file

@ -1,3 +0,0 @@
[flake8]
max-line-length = 160
extend-ignore = E203 # for Black

View file

@ -1,65 +0,0 @@
from setuptools import setup, find_packages
import io
import os
VERSION = "3.15"
def get_long_description():
with io.open(
os.path.join(os.path.dirname(os.path.abspath(__file__)), "README.md"),
encoding="utf8",
) as fp:
return fp.read()
setup(
name="sqlite-utils",
description="CLI tool and Python utility functions for manipulating SQLite databases",
long_description=get_long_description(),
long_description_content_type="text/markdown",
author="Simon Willison",
version=VERSION,
license="Apache License, Version 2.0",
packages=find_packages(exclude=["tests", "tests.*"]),
install_requires=[
"sqlite-fts4",
"click",
"click-default-group",
"tabulate",
"dateutils",
],
setup_requires=["pytest-runner"],
extras_require={
"test": ["pytest", "black", "hypothesis"],
"docs": ["sphinx_rtd_theme", "sphinx-autobuild", "codespell"],
"mypy": ["mypy", "types-click", "types-tabulate", "types-python-dateutil"],
"flake8": ["flake8"],
},
entry_points="""
[console_scripts]
sqlite-utils=sqlite_utils.cli:cli
""",
tests_require=["sqlite-utils[test]"],
url="https://github.com/simonw/sqlite-utils",
project_urls={
"Documentation": "https://sqlite-utils.datasette.io/en/stable/",
"Changelog": "https://sqlite-utils.datasette.io/en/stable/changelog.html",
"Source code": "https://github.com/simonw/sqlite-utils",
"Issues": "https://github.com/simonw/sqlite-utils/issues",
"CI": "https://github.com/simonw/sqlite-utils/actions",
},
python_requires=">=3.6",
classifiers=[
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"Intended Audience :: Science/Research",
"Intended Audience :: End Users/Desktop",
"Topic :: Database",
"License :: OSI Approved :: Apache Software License",
"Programming Language :: Python :: 3.6",
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
],
)

View file

@ -1,4 +1,7 @@
from .db import Database
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"]
__all__ = ["Database", "Migrations", "suggest_column_types", "hookimpl", "hookspec"]

4
sqlite_utils/__main__.py Normal file
View file

@ -0,0 +1,4 @@
from .cli import cli
if __name__ == "__main__":
cli()

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

18
sqlite_utils/hookspecs.py Normal file
View file

@ -0,0 +1,18 @@
import sqlite3
import click
from pluggy import HookimplMarker
from pluggy import HookspecMarker
hookspec = HookspecMarker("sqlite_utils")
hookimpl = HookimplMarker("sqlite_utils")
@hookspec
def register_commands(cli: click.Group) -> None:
"""Register additional CLI commands, e.g. 'sqlite-utils mycommand ...'"""
@hookspec
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])

35
sqlite_utils/plugins.py Normal file
View file

@ -0,0 +1,35 @@
from typing import Dict, List, Union
import pluggy
import sys
from . import hookspecs
pm: pluggy.PluginManager = pluggy.PluginManager("sqlite_utils")
pm.add_hookspecs(hookspecs)
_plugins_loaded = False
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() -> 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():
hookcallers = pm.get_hookcallers(plugin) or []
plugin_info: Dict[str, Union[str, List[str]]] = {
"name": plugin.__name__,
"hooks": [h.name for h in hookcallers],
}
distinfo = plugin_to_distinfo.get(plugin)
if distinfo:
plugin_info["version"] = distinfo.version
plugin_info["name"] = distinfo.project_name
plugins.append(plugin_info)
return plugins

0
sqlite_utils/py.typed Normal file
View file

View file

@ -1,19 +1,76 @@
from __future__ import annotations
from typing import Callable, Optional
from dateutil import parser
import json
def parsedate(value, dayfirst=False, yearfirst=False):
"Parse a date and convert it to ISO date format: yyyy-mm-dd"
return (
parser.parse(value, dayfirst=dayfirst, yearfirst=yearfirst).date().isoformat()
)
IGNORE: object = object()
SET_NULL: object = object()
def parsedatetime(value, dayfirst=False, yearfirst=False):
"Parse a datetime and convert it to ISO datetime format: yyyy-mm-ddTHH:MM:SS"
return parser.parse(value, dayfirst=dayfirst, yearfirst=yearfirst).isoformat()
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
- 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
"""
if not value:
return value
try:
return (
parser.parse(value, dayfirst=dayfirst, yearfirst=yearfirst)
.date()
.isoformat()
)
except parser.ParserError:
if errors is IGNORE:
return value
elif errors is SET_NULL:
return None
else:
raise
def jsonsplit(value, delimiter=",", type=str):
'Convert a string like a,b,c into a JSON array ["a", "b", "c"]'
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
- 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
"""
if not value:
return value
try:
return parser.parse(value, dayfirst=dayfirst, yearfirst=yearfirst).isoformat()
except parser.ParserError:
if errors is IGNORE:
return value
elif errors is SET_NULL:
return None
else:
raise
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"]
"""
return json.dumps([type(s.strip()) for s in value.split(delimiter)])

View file

@ -2,44 +2,154 @@ import base64
import contextlib
import csv
import enum
import hashlib
import importlib
import io
import itertools
import json
import os
from typing import cast, BinaryIO, Iterable, Optional, Tuple, Type
import sys
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 # type: ignore
import pysqlite3.dbapi2 # type: ignore
from . import recipes
OperationalError = pysqlite3.dbapi2.OperationalError
except ImportError:
# https://github.com/python/mypy/issues/1153#issuecomment-253842414
import sqlite3 # type: ignore
if TYPE_CHECKING:
import sqlite3 # noqa: F401
from sqlite3 import dbapi2 # noqa: F401
OperationalError = dbapi2.OperationalError
else:
try:
sqlite3 = importlib.import_module("pysqlite3")
dbapi2 = importlib.import_module("pysqlite3.dbapi2")
OperationalError = dbapi2.OperationalError
except ImportError:
import sqlite3 # noqa: F401
from sqlite3 import dbapi2 # noqa: F401
OperationalError = dbapi2.OperationalError
OperationalError = sqlite3.OperationalError
SPATIALITE_PATHS = (
"/usr/lib/x86_64-linux-gnu/mod_spatialite.so",
"/usr/lib/aarch64-linux-gnu/mod_spatialite.so",
"/usr/local/lib/mod_spatialite.dylib",
"/usr/local/lib/mod_spatialite.so",
"/opt/homebrew/lib/mod_spatialite.dylib",
)
# Mainly so we can restore it if needed in the tests:
ORIGINAL_CSV_FIELD_SIZE_LIMIT = csv.field_size_limit()
def suggest_column_types(records):
all_column_types = {}
# 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]
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.
"""
# https://stackoverflow.com/a/15063941
field_size_limit = sys.maxsize
while True:
try:
csv.field_size_limit(field_size_limit)
break
except OverflowError:
field_size_limit = int(field_size_limit / 10)
def find_spatialite() -> Optional[str]:
"""
The ``find_spatialite()`` function searches for the `SpatiaLite <https://www.gaia-gis.it/fossil/libspatialite/index>`__
SQLite extension in some common places. It returns a string path to the location, or ``None`` if SpatiaLite was not found.
You can use it in code like this:
.. code-block:: python
from sqlite_utils import Database
from sqlite_utils.utils import find_spatialite
db = Database("mydb.db")
spatialite = find_spatialite()
if spatialite:
db.conn.enable_load_extension(True)
db.conn.load_extension(spatialite)
# or use with db.init_spatialite like this
db.init_spatialite(find_spatialite())
"""
for path in SPATIALITE_PATHS:
if os.path.exists(path):
return path
return None
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:
@ -61,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)
@ -80,40 +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})
def find_spatialite():
for path in SPATIALITE_PATHS:
if os.path.exists(path):
return path
return None
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: 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
@ -126,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)
@ -146,12 +258,87 @@ class RowsFromFileBadJSON(RowsFromFileError):
pass
class RowError(Exception):
pass
def _extra_key_strategy(
reader: Iterable[Dict[Optional[str], object]],
ignore_extras: Optional[bool] = False,
extras_key: Optional[str] = None,
) -> 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 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)
yield cast(Row, row)
elif not extras_key:
extras = row.pop(None)
raise RowError(
"Row {} contained these extra values: {}".format(row, extras)
)
else:
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(
fp: BinaryIO,
format: Optional[Format] = None,
dialect: Optional[Type[csv.Dialect]] = None,
encoding: Optional[str] = None,
) -> Tuple[Iterable[dict], Format]:
ignore_extras: Optional[bool] = False,
extras_key: Optional[str] = None,
) -> Tuple[Iterable[Row], Format]:
"""
Load a sequence of dictionaries from a file-like object containing one of four different formats.
.. code-block:: python
from sqlite_utils.utils import rows_from_file
import io
rows, format = rows_from_file(io.StringIO("id,name\\n1,Cleo")))
print(list(rows), format)
# Outputs [{'id': '1', 'name': 'Cleo'}] Format.CSV
This defaults to attempting to automatically detect the format of the data, or you can pass in an
explicit format using the format= option.
Returns a tuple of ``(rows_generator, format_used)`` where ``rows_generator`` can be iterated over
to return dictionaries, while ``format_used`` is a value from the ``sqlite_utils.utils.Format`` enum:
.. code-block:: python
class Format(enum.Enum):
CSV = 1
TSV = 2
JSON = 3
NL = 4
If a CSV or TSV file includes rows with more fields than are declared in the header a
``sqlite_utils.utils.RowError`` exception will be raised when you loop over the generator.
You can instead ignore the extra data by passing ``ignore_extras=True``.
Or pass ``extras_key="rest"`` to put those additional values in a list in a key called ``rest``.
:param fp: a file-like object containing binary data
:param format: the format to use - omit this to detect the format
:param dialect: the CSV dialect to use - omit this to detect the dialect
:param encoding: the character encoding to use when reading CSV/TSV data
:param ignore_extras: ignore any extra fields on rows
:param extras_key: put any extra fields in a list with this key
"""
if ignore_extras and extras_key:
raise ValueError("Cannot use ignore_extras= and extras_key= together")
if format == Format.JSON:
decoded = json.load(fp)
if isinstance(decoded, dict):
@ -168,18 +355,30 @@ def rows_from_file(
reader = csv.DictReader(decoded_fp, dialect=dialect)
else:
reader = csv.DictReader(decoded_fp)
return reader, 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(
fp, format=Format.CSV, dialect=csv.excel_tab, encoding=encoding
)
return (
rows_from_file(
fp, format=Format.CSV, dialect=csv.excel_tab, encoding=encoding
)[0],
_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)
first_bytes = buffered.peek(2048).strip()
try:
first_bytes = buffered.peek(2048).strip()
except AttributeError:
# Likely the user passed a TextIO when this needs a BytesIO
raise TypeError(
"rows_from_file() requires a file-like object that supports peek(), such as io.BytesIO"
)
if first_bytes.startswith(b"[") or first_bytes.startswith(b"{"):
# TODO: Detect newline-JSON
return rows_from_file(buffered, format=Format.JSON)
@ -187,18 +386,54 @@ def rows_from_file(
dialect = csv.Sniffer().sniff(
first_bytes.decode(encoding or "utf-8-sig", "ignore")
)
return rows_from_file(
rows, _ = rows_from_file(
buffered, format=Format.CSV, dialect=dialect, encoding=encoding
)
# Make sure we return the format we detected
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")
class TypeTracker:
def __init__(self):
self.trackers = {}
"""
Wrap an iterator of dictionaries and keep track of which SQLite column
types are the most likely fit for each of their keys.
def wrap(self, iterator):
Example usage:
.. code-block:: python
from sqlite_utils.utils import TypeTracker
import sqlite_utils
db = sqlite_utils.Database(memory=True)
tracker = TypeTracker()
rows = [{"id": "1", "name": "Cleo", "id": "2", "name": "Cardi"}]
db["creatures"].insert_all(tracker.wrap(rows))
print(tracker.types)
# Outputs {'id': 'integer', 'name': 'text'}
db["creatures"].transform(types=tracker.types)
"""
def __init__(self) -> None:
self.trackers: Dict[str, "ValueTracker"] = {}
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.
:param iterator: The iterator to wrap
"""
for row in iterator:
for key, value in row.items():
tracker = self.trackers.setdefault(key, ValueTracker())
@ -206,41 +441,47 @@ class TypeTracker:
yield row
@property
def types(self):
def types(self) -> Dict[str, str]:
"""
A dictionary mapping column names to their detected types. This can be passed
to the ``db[table_name].transform(types=tracker.types)`` method.
"""
return {key: tracker.guessed_type for key, tracker in self.trackers.items()}
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
def __repr__(self):
def __repr__(self) -> str:
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():
@ -248,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)
@ -260,21 +501,162 @@ 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: 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_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
if "\n" not in code and not code.strip().startswith("return "):
body_variants.insert(0, "return {}".format(code))
code_o = None
for variant in body_variants:
new_code = ["def fn({}):".format(variable)]
for line in variant.split("\n"):
new_code.append(" {}".format(line))
try:
code_o = compile("\n".join(new_code), "<string>", "exec")
break
except SyntaxError:
# Try another variant, e.g. for 'return row["column"] = 1'
continue
if code_o is None:
raise SyntaxError("Could not compile code")
exec(code_o, globals_dict)
return cast(Callable[..., object], globals_dict["fn"])
def chunks(sequence: Iterable[T], size: int) -> Iterable[Iterable[T]]:
"""
Iterate over chunks of the sequence of the given size.
:param sequence: Any Python iterator
:param size: The size of each chunk
"""
iterator = iter(sequence)
for item in iterator:
yield itertools.chain([item], itertools.islice(iterator, size - 1))
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.
If ``keys=`` is provided, uses just those keys to generate the hash.
Example usage::
from sqlite_utils.utils import hash_record
hashed = hash_record({"name": "Cleo", "twitter": "CleoPaws"})
# Or with the keys= option:
hashed = hash_record(
{"name": "Cleo", "twitter": "CleoPaws", "age": 7},
keys=("name", "twitter")
)
:param record: Record to generate a hash for
:param keys: Subset of keys to use for that hash
"""
to_hash: Dict[str, Any] = record
if keys is not None:
to_hash = {key: record[key] for key in keys}
return hashlib.sha1(
json.dumps(to_hash, separators=(",", ":"), sort_keys=True, default=repr).encode(
"utf8"
)
).hexdigest()
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):
yield key + "_" + key2, value2
else:
yield key, value
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}``
:param row: A Python dictionary, optionally with nested dictionaries
"""
return dict(_flatten(row))

View file

@ -1,6 +1,63 @@
from sqlite_utils import Database
from sqlite_utils.utils import sqlite3
import pytest
CREATE_TABLES = """
create table Gosh (c1 text, c2 text, c3 text);
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 # 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
def fresh_db():
@ -10,12 +67,19 @@ 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
@pytest.fixture
def db_path(tmpdir):
path = str(tmpdir / "test.db")
db = sqlite3.connect(path)
db.executescript(CREATE_TABLES)
db.close()
return path

48
tests/ext.c Normal file
View file

@ -0,0 +1,48 @@
/*
** This file implements a SQLite extension with multiple entrypoints.
**
** The default entrypoint, sqlite3_ext_init, has a single function "a".
** The 1st alternate entrypoint, sqlite3_ext_b_init, has a single function "b".
** The 2nd alternate entrypoint, sqlite3_ext_c_init, has a single function "c".
**
** Compiling instructions:
** https://www.sqlite.org/loadext.html#compiling_a_loadable_extension
**
*/
#include "sqlite3ext.h"
SQLITE_EXTENSION_INIT1
// SQL function that returns back the value supplied during sqlite3_create_function()
static void func(sqlite3_context *context, int argc, sqlite3_value **argv) {
sqlite3_result_text(context, (char *) sqlite3_user_data(context), -1, SQLITE_STATIC);
}
// The default entrypoint, since it matches the "ext.dylib"/"ext.so" name
#ifdef _WIN32
__declspec(dllexport)
#endif
int sqlite3_ext_init(sqlite3 *db, char **pzErrMsg, const sqlite3_api_routines *pApi) {
SQLITE_EXTENSION_INIT2(pApi);
return sqlite3_create_function(db, "a", 0, 0, "a", func, 0, 0);
}
// Alternate entrypoint #1
#ifdef _WIN32
__declspec(dllexport)
#endif
int sqlite3_ext_b_init(sqlite3 *db, char **pzErrMsg, const sqlite3_api_routines *pApi) {
SQLITE_EXTENSION_INIT2(pApi);
return sqlite3_create_function(db, "b", 0, 0, "b", func, 0, 0);
}
// Alternate entrypoint #2
#ifdef _WIN32
__declspec(dllexport)
#endif
int sqlite3_ext_c_init(sqlite3 *db, char **pzErrMsg, const sqlite3_api_routines *pApi) {
SQLITE_EXTENSION_INIT2(pApi);
return sqlite3_create_function(db, "c", 0, 0, "c", func, 0, 0);
}

51
tests/test_analyze.py Normal file
View file

@ -0,0 +1,51 @@
import pytest
@pytest.fixture
def db(fresh_db):
fresh_db["one_index"].insert({"id": 1, "name": "Cleo"}, pk="id")
fresh_db["one_index"].create_index(["name"])
fresh_db["two_indexes"].insert({"id": 1, "name": "Cleo", "species": "dog"}, pk="id")
fresh_db["two_indexes"].create_index(["name"])
fresh_db["two_indexes"].create_index(["species"])
return fresh_db
def test_analyze_whole_database(db):
assert set(db.table_names()) == {"one_index", "two_indexes"}
db.analyze()
assert set(db.table_names()).issuperset(
{"one_index", "two_indexes", "sqlite_stat1"}
)
assert list(db["sqlite_stat1"].rows) == [
{"tbl": "two_indexes", "idx": "idx_two_indexes_species", "stat": "1 1"},
{"tbl": "two_indexes", "idx": "idx_two_indexes_name", "stat": "1 1"},
{"tbl": "one_index", "idx": "idx_one_index_name", "stat": "1 1"},
]
@pytest.mark.parametrize("method", ("db_method_with_name", "table_method"))
def test_analyze_one_table(db, method):
assert set(db.table_names()).issuperset({"one_index", "two_indexes"})
if method == "db_method_with_name":
db.analyze("one_index")
elif method == "table_method":
db["one_index"].analyze()
assert set(db.table_names()).issuperset(
{"one_index", "two_indexes", "sqlite_stat1"}
)
assert list(db["sqlite_stat1"].rows) == [
{"tbl": "one_index", "idx": "idx_one_index_name", "stat": "1 1"}
]
def test_analyze_index_by_name(db):
assert set(db.table_names()) == {"one_index", "two_indexes"}
db.analyze("idx_two_indexes_species")
assert set(db.table_names()).issuperset(
{"one_index", "two_indexes", "sqlite_stat1"}
)
assert list(db["sqlite_stat1"].rows) == [
{"tbl": "two_indexes", "idx": "idx_two_indexes_species", "stat": "1 1"},
]

View file

@ -24,11 +24,35 @@ def db_to_analyze(fresh_db):
return fresh_db
@pytest.fixture
def big_db_to_analyze_path(tmpdir):
path = str(tmpdir / "test.db")
db = Database(path)
categories = {
"A": 40,
"B": 30,
"C": 20,
"D": 10,
}
to_insert = []
for category, count in categories.items():
for _ in range(count):
to_insert.append(
{
"category": category,
"all_null": None,
}
)
db["stuff"].insert_all(to_insert)
return path
@pytest.mark.parametrize(
"column,expected",
"column,extra_kwargs,expected",
[
(
"id",
{},
ColumnDetails(
table="stuff",
column="id",
@ -42,6 +66,7 @@ def db_to_analyze(fresh_db):
),
(
"owner",
{},
ColumnDetails(
table="stuff",
column="owner",
@ -55,6 +80,7 @@ def db_to_analyze(fresh_db):
),
(
"size",
{},
ColumnDetails(
table="stuff",
column="size",
@ -66,11 +92,41 @@ def db_to_analyze(fresh_db):
least_common=None,
),
),
(
"owner",
{"most_common": False},
ColumnDetails(
table="stuff",
column="owner",
total_rows=8,
num_null=0,
num_blank=0,
num_distinct=4,
most_common=None,
least_common=[("Anne", 1), ("Terry...", 2)],
),
),
(
"owner",
{"least_common": False},
ColumnDetails(
table="stuff",
column="owner",
total_rows=8,
num_null=0,
num_blank=0,
num_distinct=4,
most_common=[("Joan", 3), ("Kumar", 2)],
least_common=None,
),
),
],
)
def test_analyze_column(db_to_analyze, column, expected):
def test_analyze_column(db_to_analyze, column, extra_kwargs, expected):
assert (
db_to_analyze["stuff"].analyze_column(column, common_limit=2, value_truncate=5)
db_to_analyze["stuff"].analyze_column(
column, common_limit=2, value_truncate=5, **extra_kwargs
)
== expected
)
@ -79,16 +135,15 @@ def test_analyze_column(db_to_analyze, column, expected):
def db_to_analyze_path(db_to_analyze, tmpdir):
path = str(tmpdir / "test.db")
db = sqlite3.connect(path)
db.executescript("\n".join(db_to_analyze.conn.iterdump()))
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
@ -121,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):
@ -164,3 +217,101 @@ def test_analyze_table_save(db_to_analyze_path):
"least_common": None,
},
]
@pytest.mark.parametrize(
"no_most,no_least",
(
(False, False),
(True, False),
(False, True),
(True, True),
),
)
def test_analyze_table_save_no_most_no_least_options(
no_most, no_least, big_db_to_analyze_path
):
args = [
"analyze-tables",
big_db_to_analyze_path,
"--save",
"--common-limit",
"2",
"--column",
"category",
]
if no_most:
args.append("--no-most")
if no_least:
args.append("--no-least")
result = CliRunner().invoke(cli.cli, args)
assert result.exit_code == 0
rows = list(Database(big_db_to_analyze_path)["_analyze_tables_"].rows)
expected = {
"table": "stuff",
"column": "category",
"total_rows": 100,
"num_null": 0,
"num_blank": 0,
"num_distinct": 4,
"most_common": None,
"least_common": None,
}
if not no_most:
expected["most_common"] = '[["A", 40], ["B", 30]]'
if not no_least:
expected["least_common"] = '[["D", 10], ["C", 20]]'
assert rows == [expected]
def test_analyze_table_column_all_nulls(big_db_to_analyze_path):
result = CliRunner().invoke(
cli.cli,
["analyze-tables", big_db_to_analyze_path, "stuff", "--column", "all_null"],
)
assert result.exit_code == 0
assert result.output == (
"stuff.all_null: (1/1)\n\n Total rows: 100\n"
" Null rows: 100\n"
" Blank rows: 0\n"
"\n"
" Distinct values: 0\n\n"
)
@pytest.mark.parametrize(
"args,expected_error",
(
(["-c", "bad_column"], "These columns were not found: bad_column\n"),
(["one", "-c", "age"], "These columns were not found: age\n"),
(["two", "-c", "age"], None),
(
["one", "-c", "age", "--column", "bad"],
"These columns were not found: age, bad\n",
),
),
)
def test_analyze_table_validate_columns(tmpdir, args, expected_error):
path = str(tmpdir / "test_validate_columns.db")
db = Database(path)
db["one"].insert(
{
"id": 1,
"name": "one",
}
)
db["two"].insert(
{
"id": 1,
"age": 5,
}
)
result = CliRunner().invoke(
cli.cli,
["analyze-tables", path] + args,
catch_exceptions=False,
)
assert result.exit_code == (1 if expected_error else 0)
if expected_error:
assert expected_error in result.output

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

File diff suppressed because it is too large Load diff

123
tests/test_cli_bulk.py Normal file
View file

@ -0,0 +1,123 @@
from click.testing import CliRunner
from sqlite_utils import cli, Database
import pathlib
import pytest
import subprocess
import sys
import time
@pytest.fixture
def test_db_and_path(tmpdir):
db_path = str(pathlib.Path(tmpdir) / "data.db")
db = Database(db_path)
db["example"].insert_all(
[
{"id": 1, "name": "One"},
{"id": 2, "name": "Two"},
],
pk="id",
)
return db, db_path
def test_cli_bulk(test_db_and_path):
db, db_path = test_db_and_path
result = CliRunner().invoke(
cli.cli,
[
"bulk",
db_path,
"insert into example (id, name) values (:id, myupper(:name))",
"-",
"--nl",
"--functions",
"myupper = lambda s: s.upper()",
],
input='{"id": 3, "name": "Three"}\n{"id": 4, "name": "Four"}\n',
)
assert result.exit_code == 0, result.output
assert [
{"id": 1, "name": "One"},
{"id": 2, "name": "Two"},
{"id": 3, "name": "THREE"},
{"id": 4, "name": "FOUR"},
] == list(db["example"].rows)
def test_cli_bulk_multiple_functions(test_db_and_path):
db, db_path = test_db_and_path
result = CliRunner().invoke(
cli.cli,
[
"bulk",
db_path,
"insert into example (id, name) values (:id, myupper(mylower(:name)))",
"-",
"--nl",
"--functions",
"myupper = lambda s: s.upper()",
"--functions",
"mylower = lambda s: s.lower()",
],
input='{"id": 3, "name": "ThReE"}\n{"id": 4, "name": "FoUr"}\n',
)
assert result.exit_code == 0, result.output
assert [
{"id": 1, "name": "One"},
{"id": 2, "name": "Two"},
{"id": 3, "name": "THREE"},
{"id": 4, "name": "FOUR"},
] == list(db["example"].rows)
def test_cli_bulk_batch_size(test_db_and_path):
db, db_path = test_db_and_path
proc = subprocess.Popen(
[
sys.executable,
"-m",
"sqlite_utils",
"bulk",
db_path,
"insert into example (id, name) values (:id, :name)",
"-",
"--nl",
"--batch-size",
"2",
],
stdin=subprocess.PIPE,
stdout=sys.stdout,
)
# Writing one record should not commit
proc.stdin.write(b'{"id": 3, "name": "Three"}\n\n')
proc.stdin.flush()
time.sleep(1)
assert db["example"].count == 2
# Writing another should trigger a commit:
proc.stdin.write(b'{"id": 4, "name": "Four"}\n\n')
proc.stdin.flush()
time.sleep(1)
assert db["example"].count == 4
proc.stdin.close()
proc.wait()
assert proc.returncode == 0
def test_cli_bulk_error(test_db_and_path):
_, db_path = test_db_and_path
result = CliRunner().invoke(
cli.cli,
[
"bulk",
db_path,
"insert into example (id, name) value (:id, :name)",
"-",
"--nl",
],
input='{"id": 3, "name": "Three"}',
)
assert result.exit_code == 1
assert result.output == 'Error: near "value": syntax error\n'

View file

@ -35,39 +35,40 @@ def fresh_db_and_path(tmpdir):
"return value.replace('October', 'Spooktober')",
# Return is optional:
"value.replace('October', 'Spooktober')",
# Multiple lines are supported:
"v = value.replace('October', 'Spooktober')\nreturn v",
# Can also define a convert() function
"def convert(value): return value.replace('October', 'Spooktober')",
# ... with imports
"import re\n\ndef convert(value): return value.replace('October', 'Spooktober')",
],
)
def test_convert_single_line(test_db_and_path, code):
db, db_path = test_db_and_path
result = CliRunner().invoke(cli.cli, ["convert", db_path, "example", "dt", code])
assert 0 == result.exit_code, result.output
assert [
{"id": 1, "dt": "5th Spooktober 2019 12:04"},
{"id": 2, "dt": "6th Spooktober 2019 00:05:06"},
{"id": 3, "dt": ""},
{"id": 4, "dt": None},
] == list(db["example"].rows)
def test_convert_multiple_lines(test_db_and_path):
db, db_path = test_db_and_path
def test_convert_code(fresh_db_and_path, code):
db, db_path = fresh_db_and_path
db["t"].insert({"text": "October"})
result = CliRunner().invoke(
cli.cli,
[
"convert",
db_path,
"example",
"dt",
"v = value.replace('October', 'Spooktober')\nreturn v.upper()",
],
cli.cli, ["convert", db_path, "t", "text", code], catch_exceptions=False
)
assert 0 == result.exit_code, result.output
assert [
{"id": 1, "dt": "5TH SPOOKTOBER 2019 12:04"},
{"id": 2, "dt": "6TH SPOOKTOBER 2019 00:05:06"},
{"id": 3, "dt": ""},
{"id": 4, "dt": None},
] == list(db["example"].rows)
assert result.exit_code == 0, result.output
value = list(db["t"].rows)[0]["text"]
assert value == "Spooktober"
@pytest.mark.parametrize(
"bad_code",
(
"def foo(value)",
"$",
),
)
def test_convert_code_errors(fresh_db_and_path, bad_code):
db, db_path = fresh_db_and_path
db["t"].insert({"text": "October"})
result = CliRunner().invoke(
cli.cli, ["convert", db_path, "t", "text", bad_code], catch_exceptions=False
)
assert result.exit_code == 1
assert result.output == "Error: Could not compile code\n"
def test_convert_import(test_db_and_path):
@ -79,12 +80,12 @@ def test_convert_import(test_db_and_path):
db_path,
"example",
"dt",
"return re.sub('O..', 'OXX', value)",
"return re.sub('O..', 'OXX', value) if value else value",
"--import",
"re",
],
)
assert 0 == result.exit_code, result.output
assert result.exit_code == 0, result.output
assert [
{"id": 1, "dt": "5th OXXober 2019 12:04"},
{"id": 2, "dt": "6th OXXober 2019 00:05:06"},
@ -93,6 +94,27 @@ def test_convert_import(test_db_and_path):
] == list(db["example"].rows)
def test_convert_import_nested(fresh_db_and_path):
db, db_path = fresh_db_and_path
db["example"].insert({"xml": '<item name="Cleo" />'})
result = CliRunner().invoke(
cli.cli,
[
"convert",
db_path,
"example",
"xml",
'xml.etree.ElementTree.fromstring(value).attrib["name"]',
"--import",
"xml.etree.ElementTree",
],
)
assert result.exit_code == 0, result.output
assert [
{"xml": "Cleo"},
] == list(db["example"].rows)
def test_convert_dryrun(test_db_and_path):
db, db_path = test_db_and_path
result = CliRunner().invoke(
@ -157,6 +179,61 @@ def test_convert_dryrun(test_db_and_path):
assert result.output.strip().split("\n")[-1] == "Would affect 1 row"
def test_convert_multi_dryrun(test_db_and_path):
db_path = test_db_and_path[1]
result = CliRunner().invoke(
cli.cli,
[
"convert",
db_path,
"example",
"dt",
"{'foo': 'bar', 'baz': 1}",
"--dry-run",
"--multi",
],
)
assert result.exit_code == 0
assert result.output.strip() == (
"5th October 2019 12:04\n"
" --- becomes:\n"
'{"foo": "bar", "baz": 1}\n'
"\n"
"6th October 2019 00:05:06\n"
" --- becomes:\n"
'{"foo": "bar", "baz": 1}\n'
"\n"
"\n"
" --- becomes:\n"
"\n"
"\n"
"None\n"
" --- becomes:\n"
"None\n"
"\n"
"Would affect 4 rows"
)
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
@ -165,14 +242,14 @@ def test_convert_output_column(test_db_and_path, drop):
db_path,
"example",
"dt",
"value.replace('October', 'Spooktober')",
"value.replace('October', 'Spooktober') if value else value",
"--output",
"newcol",
]
if drop:
args += ["--drop"]
result = CliRunner().invoke(cli.cli, args)
assert 0 == result.exit_code, result.output
assert result.exit_code == 0, result.output
expected = [
{
"id": 1,
@ -219,7 +296,7 @@ def test_convert_output_column_output_type(test_db_and_path, output_type, expect
cli.cli,
args,
)
assert 0 == result.exit_code, result.output
assert result.exit_code == 0, result.output
assert expected == list(db.execute("select id, new_id from example"))
@ -313,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,
[
@ -348,9 +423,9 @@ def test_convert_multi_complex_column_types(fresh_db_and_path):
{"id": 4, "is_str": None, "is_float": None, "is_int": None, "is_bytes": None},
]
assert db["rows"].schema == (
"CREATE TABLE [rows] (\n"
" [id] INTEGER PRIMARY KEY\n"
", [is_str] TEXT, [is_float] FLOAT, [is_int] INTEGER, [is_bytes] BLOB)"
'CREATE TABLE "rows" (\n'
' "id" INTEGER PRIMARY KEY\n'
', "is_str" TEXT, "is_float" REAL, "is_int" INTEGER, "is_bytes" BLOB)'
)
@ -370,7 +445,7 @@ def test_recipe_jsonsplit(tmpdir, delimiter):
code = 'recipes.jsonsplit(value, delimiter="{}")'.format(delimiter)
args = ["convert", db_path, "example", "tags", code]
result = CliRunner().invoke(cli.cli, args)
assert 0 == result.exit_code, result.output
assert result.exit_code == 0, result.output
assert list(db["example"].rows) == [
{"id": 1, "tags": '["foo", "bar"]'},
{"id": 2, "tags": '["bar", "baz"]'},
@ -398,7 +473,7 @@ def test_recipe_jsonsplit_type(fresh_db_and_path, type, expected_array):
code = "recipes.jsonsplit(value, type={})".format(type)
args = ["convert", db_path, "example", "records", code]
result = CliRunner().invoke(cli.cli, args)
assert 0 == result.exit_code, result.output
assert result.exit_code == 0, result.output
assert json.loads(db["example"].get(1)["records"]) == expected_array
@ -416,7 +491,7 @@ def test_recipe_jsonsplit_output(fresh_db_and_path, drop):
if drop:
args += ["--drop"]
result = CliRunner().invoke(cli.cli, args)
assert 0 == result.exit_code, result.output
assert result.exit_code == 0, result.output
expected = {
"id": 1,
"records": "1,2,3",
@ -477,7 +552,7 @@ def test_convert_where(test_db_and_path):
"id = :id",
"-p",
"id",
2,
"2",
],
)
assert result.exit_code == 0, result.output
@ -506,12 +581,132 @@ def test_convert_where_multi(fresh_db_and_path):
"id = :id",
"-p",
"id",
2,
"2",
"--multi",
],
)
assert 0 == result.exit_code, result.output
assert result.exit_code == 0, result.output
assert list(db["names"].rows) == [
{"id": 1, "name": "Cleo", "upper": None},
{"id": 2, "name": "Bants", "upper": "BANTS"},
]
def test_convert_code_standard_input(fresh_db_and_path):
db, db_path = fresh_db_and_path
db["names"].insert_all([{"id": 1, "name": "Cleo"}], pk="id")
result = CliRunner().invoke(
cli.cli,
[
"convert",
db_path,
"names",
"name",
"-",
],
input="value.upper()",
)
assert result.exit_code == 0, result.output
assert list(db["names"].rows) == [
{"id": 1, "name": "CLEO"},
]
def test_convert_hyphen_workaround(fresh_db_and_path):
db, db_path = fresh_db_and_path
db["names"].insert_all([{"id": 1, "name": "Cleo"}], pk="id")
result = CliRunner().invoke(
cli.cli,
["convert", db_path, "names", "name", '"-"'],
)
assert result.exit_code == 0, result.output
assert list(db["names"].rows) == [
{"id": 1, "name": "-"},
]
def test_convert_initialization_pattern(fresh_db_and_path):
db, db_path = fresh_db_and_path
db["names"].insert_all([{"id": 1, "name": "Cleo"}], pk="id")
result = CliRunner().invoke(
cli.cli,
[
"convert",
db_path,
"names",
"name",
"-",
],
input="import random\nrandom.seed(1)\ndef convert(value): return random.randint(0, 100)",
)
assert result.exit_code == 0, result.output
assert list(db["names"].rows) == [
{"id": 1, "name": "17"},
]
def test_convert_handles_falsey_values(fresh_db_and_path):
# Falsey values like 0 should be converted (issue #527)
db, db_path = fresh_db_and_path
args = [
"convert",
db_path,
"t",
"x",
"-",
]
db["t"].insert_all([{"x": 0}, {"x": 1}])
assert db["t"].get(1)["x"] == 0
assert db["t"].get(2)["x"] == 1
result = CliRunner().invoke(cli.cli, args, input="value + 1")
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"}'

890
tests/test_cli_insert.py Normal file
View file

@ -0,0 +1,890 @@
from sqlite_utils import cli, Database
from click.testing import CliRunner
import json
import pytest
import subprocess
import sys
import time
def test_insert_simple(tmpdir):
json_path = str(tmpdir / "dog.json")
db_path = str(tmpdir / "dogs.db")
with open(json_path, "w") as fp:
fp.write(json.dumps({"name": "Cleo", "age": 4}))
result = CliRunner().invoke(cli.cli, ["insert", db_path, "dogs", json_path])
assert result.exit_code == 0
assert [{"age": 4, "name": "Cleo"}] == list(
Database(db_path).query("select * from dogs")
)
db = Database(db_path)
assert ["dogs"] == db.table_names()
assert [] == db["dogs"].indexes
def test_insert_from_stdin(tmpdir):
db_path = str(tmpdir / "dogs.db")
result = CliRunner().invoke(
cli.cli,
["insert", db_path, "dogs", "-"],
input=json.dumps({"name": "Cleo", "age": 4}),
)
assert result.exit_code == 0
assert [{"age": 4, "name": "Cleo"}] == list(
Database(db_path).query("select * from dogs")
)
def test_insert_invalid_json_error(tmpdir):
db_path = str(tmpdir / "dogs.db")
result = CliRunner().invoke(
cli.cli,
["insert", db_path, "dogs", "-"],
input="name,age\nCleo,4",
)
assert result.exit_code == 1
assert result.output == (
"Error: Invalid JSON - use --csv for CSV or --tsv for TSV files\n\n"
"JSON error: Expecting value: line 1 column 1 (char 0)\n"
)
def test_insert_json_flatten(tmpdir):
db_path = str(tmpdir / "flat.db")
result = CliRunner().invoke(
cli.cli,
["insert", db_path, "items", "-", "--flatten"],
input=json.dumps({"nested": {"data": 4}}),
)
assert result.exit_code == 0
assert list(Database(db_path).query("select * from items")) == [{"nested_data": 4}]
def test_insert_json_flatten_nl(tmpdir):
db_path = str(tmpdir / "flat.db")
result = CliRunner().invoke(
cli.cli,
["insert", db_path, "items", "-", "--flatten", "--nl"],
input="\n".join(
json.dumps(item)
for item in [{"nested": {"data": 4}}, {"nested": {"other": 3}}]
),
)
assert result.exit_code == 0
assert list(Database(db_path).query("select * from items")) == [
{"nested_data": 4, "nested_other": None},
{"nested_data": None, "nested_other": 3},
]
@pytest.mark.parametrize(
"args,expected_pks",
(
(["--pk", "id"], ["id"]),
(["--pk", "id", "--pk", "name"], ["id", "name"]),
),
)
def test_insert_with_primary_keys(db_path, tmpdir, args, expected_pks):
json_path = str(tmpdir / "dog.json")
with open(json_path, "w") as fp:
fp.write(json.dumps({"id": 1, "name": "Cleo", "age": 4}))
result = CliRunner().invoke(cli.cli, ["insert", db_path, "dogs", json_path] + args)
assert result.exit_code == 0
assert [{"id": 1, "age": 4, "name": "Cleo"}] == list(
Database(db_path).query("select * from dogs")
)
db = Database(db_path)
assert db["dogs"].pks == expected_pks
def test_insert_multiple_with_primary_key(db_path, tmpdir):
json_path = str(tmpdir / "dogs.json")
dogs = [{"id": i, "name": "Cleo {}".format(i), "age": i + 3} for i in range(1, 21)]
with open(json_path, "w") as fp:
fp.write(json.dumps(dogs))
result = CliRunner().invoke(
cli.cli, ["insert", db_path, "dogs", json_path, "--pk", "id"]
)
assert result.exit_code == 0
db = Database(db_path)
assert dogs == list(db.query("select * from dogs order by id"))
assert ["id"] == db["dogs"].pks
def test_insert_multiple_with_compound_primary_key(db_path, tmpdir):
json_path = str(tmpdir / "dogs.json")
dogs = [
{"breed": "mixed", "id": i, "name": "Cleo {}".format(i), "age": i + 3}
for i in range(1, 21)
]
with open(json_path, "w") as fp:
fp.write(json.dumps(dogs))
result = CliRunner().invoke(
cli.cli, ["insert", db_path, "dogs", json_path, "--pk", "id", "--pk", "breed"]
)
assert result.exit_code == 0
db = Database(db_path)
assert dogs == list(db.query("select * from dogs order by breed, id"))
assert {"breed", "id"} == set(db["dogs"].pks)
assert (
'CREATE TABLE "dogs" (\n'
' "breed" TEXT,\n'
' "id" INTEGER,\n'
' "name" TEXT,\n'
' "age" INTEGER,\n'
' PRIMARY KEY ("id", "breed")\n'
")"
) == db["dogs"].schema
def test_insert_not_null_default(db_path, tmpdir):
json_path = str(tmpdir / "dogs.json")
dogs = [
{"id": i, "name": "Cleo {}".format(i), "age": i + 3, "score": 10}
for i in range(1, 21)
]
with open(json_path, "w") as fp:
fp.write(json.dumps(dogs))
result = CliRunner().invoke(
cli.cli,
["insert", db_path, "dogs", json_path, "--pk", "id"]
+ ["--not-null", "name", "--not-null", "age"]
+ ["--default", "score", "5", "--default", "age", "1"],
)
assert result.exit_code == 0
db = Database(db_path)
assert (
'CREATE TABLE "dogs" (\n'
' "id" INTEGER PRIMARY KEY,\n'
' "name" TEXT NOT NULL,\n'
" \"age\" INTEGER NOT NULL DEFAULT '1',\n"
" \"score\" INTEGER DEFAULT '5'\n)"
) == db["dogs"].schema
def test_insert_binary_base64(db_path):
result = CliRunner().invoke(
cli.cli,
["insert", db_path, "files", "-"],
input=r'{"content": {"$base64": true, "encoded": "aGVsbG8="}}',
)
assert result.exit_code == 0, result.output
db = Database(db_path)
actual = list(db.query("select content from files"))
assert actual == [{"content": b"hello"}]
def test_insert_newline_delimited(db_path):
result = CliRunner().invoke(
cli.cli,
["insert", db_path, "from_json_nl", "-", "--nl"],
input='{"foo": "bar", "n": 1}\n\n{"foo": "baz", "n": 2}',
)
assert result.exit_code == 0, result.output
db = Database(db_path)
assert [
{"foo": "bar", "n": 1},
{"foo": "baz", "n": 2},
] == list(db.query("select foo, n from from_json_nl"))
def test_insert_ignore(db_path, tmpdir):
db = Database(db_path)
db["dogs"].insert({"id": 1, "name": "Cleo"}, pk="id")
json_path = str(tmpdir / "dogs.json")
with open(json_path, "w") as fp:
fp.write(json.dumps([{"id": 1, "name": "Bailey"}]))
# Should raise error without --ignore
result = CliRunner().invoke(
cli.cli, ["insert", db_path, "dogs", json_path, "--pk", "id"]
)
assert result.exit_code != 0, result.output
# If we use --ignore it should run OK
result = CliRunner().invoke(
cli.cli, ["insert", db_path, "dogs", json_path, "--pk", "id", "--ignore"]
)
assert result.exit_code == 0, result.output
# ... but it should actually have no effect
assert [{"id": 1, "name": "Cleo"}] == list(db.query("select * from dogs"))
@pytest.mark.parametrize(
"content,options",
[
("foo\tbar\tbaz\n1\t2\tcat,dog", ["--tsv"]),
('foo,bar,baz\n1,2,"cat,dog"', ["--csv"]),
('foo;bar;baz\n1;2;"cat,dog"', ["--csv", "--delimiter", ";"]),
# --delimiter implies --csv:
('foo;bar;baz\n1;2;"cat,dog"', ["--delimiter", ";"]),
("foo,bar,baz\n1,2,|cat,dog|", ["--csv", "--quotechar", "|"]),
("foo,bar,baz\n1,2,|cat,dog|", ["--quotechar", "|"]),
],
)
def test_insert_csv_tsv(content, options, db_path, tmpdir):
db = Database(db_path)
file_path = str(tmpdir / "insert.csv-tsv")
with open(file_path, "w") as fp:
fp.write(content)
result = CliRunner().invoke(
cli.cli,
["insert", db_path, "data", file_path] + options + ["--no-detect-types"],
catch_exceptions=False,
)
assert result.exit_code == 0
assert [{"foo": "1", "bar": "2", "baz": "cat,dog"}] == list(db["data"].rows)
@pytest.mark.parametrize("empty_null", (True, False))
def test_insert_csv_empty_null(db_path, empty_null):
options = ["--csv", "--no-detect-types"]
if empty_null:
options.append("--empty-null")
result = CliRunner().invoke(
cli.cli,
["insert", db_path, "data", "-"] + options,
catch_exceptions=False,
input="foo,bar,baz\n1,,cat,dog",
)
assert result.exit_code == 0
db = Database(db_path)
assert [r for r in db["data"].rows] == [
{"foo": "1", "bar": None if empty_null else "", "baz": "cat"}
]
@pytest.mark.parametrize(
"input,args",
(
(
json.dumps(
[{"name": "One"}, {"name": "Two"}, {"name": "Three"}, {"name": "Four"}]
),
[],
),
("name\nOne\nTwo\nThree\nFour\n", ["--csv"]),
),
)
def test_insert_stop_after(tmpdir, input, args):
db_path = str(tmpdir / "data.db")
result = CliRunner().invoke(
cli.cli,
["insert", db_path, "rows", "-", "--stop-after", "2"] + args,
input=input,
)
assert result.exit_code == 0
assert [{"name": "One"}, {"name": "Two"}] == list(
Database(db_path).query("select * from rows")
)
@pytest.mark.parametrize(
"options",
(
["--tsv", "--nl"],
["--tsv", "--csv"],
["--csv", "--nl"],
["--csv", "--nl", "--tsv"],
),
)
def test_only_allow_one_of_nl_tsv_csv(options, db_path, tmpdir):
file_path = str(tmpdir / "insert.csv-tsv")
with open(file_path, "w") as fp:
fp.write("foo")
result = CliRunner().invoke(
cli.cli, ["insert", db_path, "data", file_path] + options
)
assert result.exit_code != 0
assert "Error: Use just one of --nl, --csv or --tsv" == result.output.strip()
def test_insert_replace(db_path, tmpdir):
test_insert_multiple_with_primary_key(db_path, tmpdir)
json_path = str(tmpdir / "insert-replace.json")
db = Database(db_path)
assert db["dogs"].count == 20
insert_replace_dogs = [
{"id": 1, "name": "Insert replaced 1", "age": 4},
{"id": 2, "name": "Insert replaced 2", "age": 4},
{"id": 21, "name": "Fresh insert 21", "age": 6},
]
with open(json_path, "w") as fp:
fp.write(json.dumps(insert_replace_dogs))
result = CliRunner().invoke(
cli.cli, ["insert", db_path, "dogs", json_path, "--pk", "id", "--replace"]
)
assert result.exit_code == 0, result.output
assert db["dogs"].count == 21
assert (
list(db.query("select * from dogs where id in (1, 2, 21) order by id"))
== insert_replace_dogs
)
def test_insert_truncate(db_path):
result = CliRunner().invoke(
cli.cli,
["insert", db_path, "from_json_nl", "-", "--nl", "--batch-size=1"],
input='{"foo": "bar", "n": 1}\n{"foo": "baz", "n": 2}',
)
assert result.exit_code == 0, result.output
db = Database(db_path)
assert [
{"foo": "bar", "n": 1},
{"foo": "baz", "n": 2},
] == list(db.query("select foo, n from from_json_nl"))
# Truncate and insert new rows
result = CliRunner().invoke(
cli.cli,
[
"insert",
db_path,
"from_json_nl",
"-",
"--nl",
"--truncate",
"--batch-size=1",
],
input='{"foo": "bam", "n": 3}\n{"foo": "bat", "n": 4}',
)
assert result.exit_code == 0, result.output
assert [
{"foo": "bam", "n": 3},
{"foo": "bat", "n": 4},
] == list(db.query("select foo, n from from_json_nl"))
def test_insert_alter(db_path, tmpdir):
result = CliRunner().invoke(
cli.cli,
["insert", db_path, "from_json_nl", "-", "--nl"],
input='{"foo": "bar", "n": 1}\n{"foo": "baz", "n": 2}',
)
assert result.exit_code == 0, result.output
# Should get an error with incorrect shaped additional data
result = CliRunner().invoke(
cli.cli,
["insert", db_path, "from_json_nl", "-", "--nl"],
input='{"foo": "bar", "baz": 5}',
)
assert result.exit_code != 0, result.output
# If we run it again with --alter it should work correctly
result = CliRunner().invoke(
cli.cli,
["insert", db_path, "from_json_nl", "-", "--nl", "--alter"],
input='{"foo": "bar", "baz": 5}',
)
assert result.exit_code == 0, result.output
# Soundness check the database itself
db = Database(db_path)
assert {"foo": str, "n": int, "baz": int} == db["from_json_nl"].columns_dict
assert [
{"foo": "bar", "n": 1, "baz": None},
{"foo": "baz", "n": 2, "baz": None},
{"foo": "bar", "baz": 5, "n": None},
] == list(db.query("select foo, n, baz from from_json_nl"))
def test_insert_analyze(db_path):
db = Database(db_path)
db["rows"].insert({"foo": "x", "n": 3})
db["rows"].create_index(["n"])
assert "sqlite_stat1" not in db.table_names()
result = CliRunner().invoke(
cli.cli,
["insert", db_path, "rows", "-", "--nl", "--analyze"],
input='{"foo": "bar", "n": 1}\n{"foo": "baz", "n": 2}',
)
assert result.exit_code == 0, result.output
assert "sqlite_stat1" in db.table_names()
def test_insert_lines(db_path):
result = CliRunner().invoke(
cli.cli,
["insert", db_path, "from_lines", "-", "--lines"],
input='First line\nSecond line\n{"foo": "baz"}',
)
assert result.exit_code == 0, result.output
db = Database(db_path)
assert [
{"line": "First line"},
{"line": "Second line"},
{"line": '{"foo": "baz"}'},
] == list(db.query("select line from from_lines"))
def test_insert_text(db_path):
result = CliRunner().invoke(
cli.cli,
["insert", db_path, "from_text", "-", "--text"],
input='First line\nSecond line\n{"foo": "baz"}',
)
assert result.exit_code == 0, result.output
db = Database(db_path)
assert [{"text": 'First line\nSecond line\n{"foo": "baz"}'}] == list(
db.query("select text from from_text")
)
@pytest.mark.parametrize(
"options,input",
(
([], '[{"id": "1", "name": "Bob"}, {"id": "2", "name": "Cat"}]'),
(["--csv", "--no-detect-types"], "id,name\n1,Bob\n2,Cat"),
(["--nl"], '{"id": "1", "name": "Bob"}\n{"id": "2", "name": "Cat"}'),
),
)
def test_insert_convert_json_csv_jsonnl(db_path, options, input):
result = CliRunner().invoke(
cli.cli,
["insert", db_path, "rows", "-", "--convert", '{**row, **{"extra": 1}}']
+ options,
input=input,
)
assert result.exit_code == 0, result.output
db = Database(db_path)
rows = list(db.query("select id, name, extra from rows"))
assert rows == [
{"id": "1", "name": "Bob", "extra": 1},
{"id": "2", "name": "Cat", "extra": 1},
]
def test_insert_convert_text(db_path):
result = CliRunner().invoke(
cli.cli,
[
"insert",
db_path,
"text",
"-",
"--text",
"--convert",
'{"text": text.upper()}',
],
input="This is text\nwill be upper now",
)
assert result.exit_code == 0, result.output
db = Database(db_path)
rows = list(db.query('select "text" from "text"'))
assert rows == [{"text": "THIS IS TEXT\nWILL BE UPPER NOW"}]
def test_insert_convert_text_returning_iterator(db_path):
result = CliRunner().invoke(
cli.cli,
[
"insert",
db_path,
"text",
"-",
"--text",
"--convert",
'({"word": w} for w in text.split())',
],
input="A bunch of words",
)
assert result.exit_code == 0, result.output
db = Database(db_path)
rows = list(db.query('select "word" from "text"'))
assert rows == [{"word": "A"}, {"word": "bunch"}, {"word": "of"}, {"word": "words"}]
def test_insert_convert_lines(db_path):
result = CliRunner().invoke(
cli.cli,
[
"insert",
db_path,
"all",
"-",
"--lines",
"--convert",
'{"line": line.upper()}',
],
input="This is text\nwill be upper now",
)
assert result.exit_code == 0, result.output
db = Database(db_path)
rows = list(db.query('select "line" from "all"'))
assert rows == [{"line": "THIS IS TEXT"}, {"line": "WILL BE UPPER NOW"}]
def test_insert_convert_row_modifying_in_place(db_path):
result = CliRunner().invoke(
cli.cli,
[
"insert",
db_path,
"rows",
"-",
"--convert",
'row["is_chicken"] = True',
],
input='{"name": "Azi"}',
)
assert result.exit_code == 0, result.output
db = Database(db_path)
rows = list(db.query("select name, is_chicken from rows"))
assert rows == [{"name": "Azi", "is_chicken": 1}]
@pytest.mark.parametrize(
"options,expected_error",
(
(
["--text", "--convert", "1"],
"Error: --convert must return dict or iterator\n",
),
(["--convert", "1"], "Error: Rows must all be dictionaries, got: 1\n"),
),
)
def test_insert_convert_error_messages(db_path, options, expected_error):
result = CliRunner().invoke(
cli.cli,
[
"insert",
db_path,
"rows",
"-",
]
+ options,
input='{"name": "Azi"}',
)
assert result.exit_code == 1
assert result.output == expected_error
def test_insert_streaming_batch_size_1(db_path):
# https://github.com/simonw/sqlite-utils/issues/364
# Streaming with --batch-size 1 should commit on each record
# Can't use CliRunner().invoke() here bacuse we need to
# run assertions in between writing to process stdin
proc = subprocess.Popen(
[
sys.executable,
"-m",
"sqlite_utils",
"insert",
db_path,
"rows",
"-",
"--nl",
"--batch-size",
"1",
],
stdin=subprocess.PIPE,
stdout=sys.stdout,
)
proc.stdin.write(b'{"name": "Azi"}\n')
proc.stdin.flush()
def try_until(expected):
tries = 0
while True:
rows = list(Database(db_path)["rows"].rows)
if rows == expected:
return
tries += 1
if tries > 10:
assert False, "Expected {}, got {}".format(expected, rows)
time.sleep(tries * 0.1)
try_until([{"name": "Azi"}])
proc.stdin.write(b'{"name": "Suna"}\n')
proc.stdin.flush()
try_until([{"name": "Azi"}, {"name": "Suna"}])
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

@ -1,5 +1,5 @@
import click
import json
import pytest
from click.testing import CliRunner
@ -24,7 +24,8 @@ def test_memory_csv(tmpdir, sql_from, use_stdin):
sql_from = "stdin"
else:
csv_path = str(tmpdir / "test.csv")
open(csv_path, "w").write(content)
with open(csv_path, "w") as fp:
fp.write(content)
result = CliRunner().invoke(
cli.cli,
["memory", csv_path, "select * from {}".format(sql_from), "--nl"],
@ -46,7 +47,8 @@ def test_memory_tsv(tmpdir, use_stdin):
else:
input = None
path = str(tmpdir / "chickens.tsv")
open(path, "w").write(data)
with open(path, "w") as fp:
fp.write(data)
path = path + ":tsv"
sql_from = "chickens"
result = CliRunner().invoke(
@ -71,7 +73,8 @@ def test_memory_json(tmpdir, use_stdin):
else:
input = None
path = str(tmpdir / "chickens.json")
open(path, "w").write(data)
with open(path, "w") as fp:
fp.write(data)
path = path + ":json"
sql_from = "chickens"
result = CliRunner().invoke(
@ -96,7 +99,8 @@ def test_memory_json_nl(tmpdir, use_stdin):
else:
input = None
path = str(tmpdir / "chickens.json")
open(path, "w").write(data)
with open(path, "w") as fp:
fp.write(data)
path = path + ":nl"
sql_from = "chickens"
result = CliRunner().invoke(
@ -152,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(
@ -160,18 +182,21 @@ def test_memory_dump(extra_args):
input="id,name\n1,Cleo\n2,Bants",
)
assert result.exit_code == 0
assert result.output.strip() == (
expected = (
"BEGIN TRANSACTION;\n"
'CREATE TABLE "stdin" (\n'
" [id] INTEGER,\n"
" [name] TEXT\n"
'CREATE TABLE IF NOT EXISTS "stdin" (\n'
' "id" INTEGER,\n'
' "name" TEXT\n'
");\n"
"INSERT INTO \"stdin\" VALUES(1,'Cleo');\n"
"INSERT INTO \"stdin\" VALUES(2,'Bants');\n"
"CREATE VIEW t1 AS select * from [stdin];\n"
"CREATE VIEW t AS select * from [stdin];\n"
'CREATE VIEW "t1" AS select * from "stdin";\n'
'CREATE VIEW "t" AS select * from "stdin";\n'
"COMMIT;"
)
# Using sqlite-dump it won't have IF NOT EXISTS
expected_alternative = expected.replace("IF NOT EXISTS ", "")
assert result.output.strip() in (expected, expected_alternative)
@pytest.mark.parametrize("extra_args", ([], ["select 1"]))
@ -184,11 +209,11 @@ def test_memory_schema(extra_args):
assert result.exit_code == 0
assert result.output.strip() == (
'CREATE TABLE "stdin" (\n'
" [id] INTEGER,\n"
" [name] TEXT\n"
' "id" INTEGER,\n'
' "name" TEXT\n'
");\n"
"CREATE VIEW t1 AS select * from [stdin];\n"
"CREATE VIEW t AS select * from [stdin];"
'CREATE VIEW "t1" AS select * from "stdin";\n'
'CREATE VIEW "t" AS select * from "stdin";'
)
@ -220,3 +245,111 @@ def test_memory_no_detect_types(option):
{"id": "1", "name": "Cleo", "weight": "45.5"},
{"id": "2", "name": "Bants", "weight": "3.5"},
]
def test_memory_flatten():
result = CliRunner().invoke(
cli.cli,
["memory", "-", "select * from stdin", "--flatten"],
input=json.dumps(
{
"httpRequest": {
"latency": "0.112114537s",
"requestMethod": "GET",
},
"insertId": "6111722f000b5b4c4d4071e2",
}
),
)
assert result.exit_code == 0, result.output
assert json.loads(result.output.strip()) == [
{
"httpRequest_latency": "0.112114537s",
"httpRequest_requestMethod": "GET",
"insertId": "6111722f000b5b4c4d4071e2",
}
]
def test_memory_analyze():
result = CliRunner().invoke(
cli.cli,
["memory", "-", "--analyze"],
input="id,name\n1,Cleo\n2,Bants",
)
assert result.exit_code == 0
assert result.output == (
"stdin.id: (1/2)\n\n"
" Total rows: 2\n"
" Null rows: 0\n"
" Blank rows: 0\n\n"
" Distinct values: 2\n\n"
"stdin.name: (2/2)\n\n"
" Total rows: 2\n"
" Null rows: 0\n"
" Blank rows: 0\n\n"
" Distinct values: 2\n\n"
)
def test_memory_two_files_with_same_stem(tmpdir):
(tmpdir / "one").mkdir()
(tmpdir / "two").mkdir()
one = tmpdir / "one" / "data.csv"
two = tmpdir / "two" / "data.csv"
one.write_text("id,name\n1,Cleo\n2,Bants", encoding="utf-8")
two.write_text("id,name\n3,Blue\n4,Lila", encoding="utf-8")
result = CliRunner().invoke(cli.cli, ["memory", str(one), str(two), "", "--schema"])
assert result.exit_code == 0
assert result.output == (
'CREATE TABLE "data" (\n'
' "id" INTEGER,\n'
' "name" TEXT\n'
");\n"
'CREATE VIEW "t1" AS select * from "data";\n'
'CREATE VIEW "t" AS select * from "data";\n'
'CREATE TABLE "data_2" (\n'
' "id" INTEGER,\n'
' "name" TEXT\n'
");\n"
'CREATE VIEW "t2" AS select * from "data_2";\n'
)
def test_memory_functions():
result = CliRunner().invoke(
cli.cli,
["memory", "select hello()", "--functions", "hello = lambda: 'Hello'"],
)
assert result.exit_code == 0
assert result.output.strip() == '[{"hello()": "Hello"}]'
def test_memory_functions_multiple():
result = CliRunner().invoke(
cli.cli,
[
"memory",
"select triple(2), quadruple(2)",
"--functions",
"def triple(x):\n return x * 3",
"--functions",
"def quadruple(x):\n return x * 4",
],
)
assert result.exit_code == 0
assert result.output.strip() == '[{"triple(2)": 6, "quadruple(2)": 8}]'
def test_memory_return_db(tmpdir):
# https://github.com/simonw/sqlite-utils/issues/643
from sqlite_utils.cli import cli
path = str(tmpdir / "dogs.csv")
with open(path, "w") as f:
f.write("id,name\n1,Cleo")
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,4 +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():
@ -9,3 +13,101 @@ def test_recursive_triggers():
def test_recursive_triggers_off():
db = Database(memory=True, recursive_triggers=False)
assert not db.execute("PRAGMA recursive_triggers").fetchone()[0]
def test_memory_name():
db1 = Database(memory_name="shared")
db2 = Database(memory_name="shared")
db1["dogs"].insert({"name": "Cleo"})
assert list(db2["dogs"].rows) == [{"name": "Cleo"}]
def test_sqlite_version():
db = Database(memory=True)
version = db.sqlite_version
assert isinstance(version, tuple)
as_string = ".".join(map(str, version))
actual = next(db.query("select sqlite_version() as v"))["v"]
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:
db = Database(memory=True)
else:
db = Database(str(tmpdir / "test.db"))
assert db.execute("select 1 + 1").fetchone()[0] == 2
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

@ -15,6 +15,14 @@ import pytest
lambda value: value.upper(),
{"title": "MIXED CASE", "abstract": "ABSTRACT"},
),
(
"title",
lambda value: {"upper": value.upper(), "lower": value.lower()},
{
"title": '{"upper": "MIXED CASE", "lower": "mixed case"}',
"abstract": "Abstract",
},
),
),
)
def test_convert(fresh_db, columns, fn, expected):
@ -42,6 +50,17 @@ def test_convert_where(fresh_db, where, where_args):
assert list(table.rows) == [{"id": 1, "title": "One"}, {"id": 2, "title": "TWO"}]
def test_convert_handles_falsey_values(fresh_db):
# Falsey values like 0 should be converted (issue #527)
table = fresh_db["table"]
table.insert_all([{"x": 0}, {"x": 1}])
assert table.get(1)["x"] == 0
assert table.get(2)["x"] == 1
table.convert("x", lambda x: x + 1)
assert table.get(1)["x"] == 1
assert table.get(2)["x"] == 2
@pytest.mark.parametrize(
"drop,expected",
(
@ -58,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)
@ -81,10 +100,24 @@ def test_convert_multi(fresh_db):
table = fresh_db["table"]
table.insert({"title": "Mixed Case"})
table.convert(
"title", lambda v: {"upper": v.upper(), "lower": v.lower()}, multi=True
"title",
lambda v: {
"upper": v.upper(),
"lower": v.lower(),
"both": {
"upper": v.upper(),
"lower": v.lower(),
},
},
multi=True,
)
assert list(table.rows) == [
{"title": "Mixed Case", "upper": "MIXED CASE", "lower": "mixed case"}
{
"title": "Mixed Case",
"upper": "MIXED CASE",
"lower": "mixed case",
"both": '{"upper": "MIXED CASE", "lower": "mixed case"}',
}
]
@ -115,3 +148,12 @@ def test_convert_multi_exception(fresh_db):
table.insert({"title": "Mixed Case"})
with pytest.raises(BadMultiValues):
table.convert("title", lambda v: v.upper(), multi=True)
def test_convert_repeated(fresh_db):
table = fresh_db["table"]
col = "num"
table.insert({col: 1})
table.convert(col, lambda x: x * 2)
table.convert(col, lambda _x: 0)
assert table.get(1) == {col: 0}

File diff suppressed because it is too large Load diff

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

@ -0,0 +1,59 @@
import pytest
EXAMPLES = [
("TEXT DEFAULT 'foo'", "'foo'", "'foo'"),
("TEXT DEFAULT 'foo)'", "'foo)'", "'foo)'"),
("INTEGER DEFAULT '1'", "'1'", "'1'"),
("INTEGER DEFAULT 1", "1", "'1'"),
("INTEGER DEFAULT (1)", "1", "'1'"),
# Expressions
(
"TEXT DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW'))",
"STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')",
"(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW'))",
),
# Special values
("TEXT DEFAULT CURRENT_TIME", "CURRENT_TIME", "CURRENT_TIME"),
("TEXT DEFAULT CURRENT_DATE", "CURRENT_DATE", "CURRENT_DATE"),
("TEXT DEFAULT CURRENT_TIMESTAMP", "CURRENT_TIMESTAMP", "CURRENT_TIMESTAMP"),
("TEXT DEFAULT current_timestamp", "current_timestamp", "current_timestamp"),
("TEXT DEFAULT (CURRENT_TIMESTAMP)", "CURRENT_TIMESTAMP", "CURRENT_TIMESTAMP"),
# 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"),
]
@pytest.mark.parametrize("column_def,initial_value,expected_value", EXAMPLES)
def test_quote_default_value(fresh_db, column_def, initial_value, expected_value):
fresh_db.execute("create table foo (col {})".format(column_def))
assert initial_value == fresh_db["foo"].columns[0].default_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
@ -18,15 +21,44 @@ def test_delete_where(fresh_db):
table = fresh_db["table"]
for i in range(1, 11):
table.insert({"id": i}, pk="id")
assert 10 == table.count
assert table.count == 10
table.delete_where("id > ?", [5])
assert 5 == table.count
assert table.count == 5
def test_delete_where_all(fresh_db):
table = fresh_db["table"]
for i in range(1, 11):
table.insert({"id": i}, pk="id")
assert 10 == table.count
assert table.count == 10
table.delete_where()
assert 0 == table.count
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")
table.create_index(["i"], analyze=True)
assert "sqlite_stat1" in fresh_db.table_names()
assert list(fresh_db["sqlite_stat1"].rows) == [
{"tbl": "table", "idx": "idx_table_i", "stat": "10 1"}
]
table.delete_where("id > ?", [5], analyze=True)
assert list(fresh_db["sqlite_stat1"].rows) == [
{"tbl": "table", "idx": "idx_table_i", "stat": "6 1"}
]

View file

@ -5,13 +5,15 @@ import pytest
import re
docs_path = Path(__file__).parent.parent / "docs"
commands_re = re.compile(r"(?:\$ | )sqlite-utils (\S+) ")
commands_re = re.compile(r"(?:\$ | )sqlite-utils (\S+)")
recipes_re = re.compile(r"r\.(\w+)\(")
@pytest.fixture(scope="session")
def documented_commands():
rst = (docs_path / "cli.rst").read_text()
rst = ""
for doc in ("cli.rst", "plugins.rst"):
rst += (docs_path / doc).read_text()
return {
command
for command in commands_re.findall(rst)
@ -39,16 +41,22 @@ 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
@pytest.mark.parametrize(
"recipe",
[n for n in dir(recipes) if not n.startswith("_") and n not in ("json", "parser")],
[
n
for n in dir(recipes)
if not n.startswith("_")
and n not in ("json", "parser", "Callable", "Optional")
and callable(getattr(recipes, n))
],
)
def test_recipes_are_documented(documented_recipes, recipe):
assert recipe in documented_recipes

41
tests/test_duplicate.py Normal file
View file

@ -0,0 +1,41 @@
from sqlite_utils.db import NoTable
import datetime
import pytest
def test_duplicate(fresh_db):
# Create table using native Sqlite statement:
fresh_db.execute("""CREATE TABLE "table1" (
"text_col" TEXT,
"real_col" REAL,
"int_col" INTEGER,
"bool_col" INTEGER,
"datetime_col" TEXT)""")
# Insert one row of mock data:
dt = datetime.datetime.now()
data = {
"text_col": "Cleo",
"real_col": 3.14,
"int_col": -255,
"bool_col": True,
"datetime_col": str(dt),
}
table1 = fresh_db["table1"]
row_id = table1.insert(data).last_rowid
# Duplicate table:
table2 = table1.duplicate("table2")
# Ensure data integrity:
assert data == table2.get(row_id)
# Ensure schema integrity:
assert [
{"name": "text_col", "type": "TEXT"},
{"name": "real_col", "type": "REAL"},
{"name": "int_col", "type": "INT"},
{"name": "bool_col", "type": "INT"},
{"name": "datetime_col", "type": "TEXT"},
] == [{"name": col.name, "type": col.type} for col in table2.columns]
def test_duplicate_fails_if_table_does_not_exist(fresh_db):
with pytest.raises(NoTable):
fresh_db["not_a_table"].duplicate("duplicated")

View file

@ -15,25 +15,25 @@ def test_enable_counts_specific_table(fresh_db):
foo.enable_counts()
assert foo.triggers_dict == {
"foo_counts_insert": (
"CREATE TRIGGER [foo_counts_insert] AFTER INSERT ON [foo]\n"
'CREATE TRIGGER "foo_counts_insert" AFTER INSERT ON "foo"\n'
"BEGIN\n"
" INSERT OR REPLACE INTO [_counts]\n"
' INSERT OR REPLACE INTO "_counts"\n'
" VALUES (\n 'foo',\n"
" COALESCE(\n"
" (SELECT count FROM [_counts] WHERE [table] = 'foo'),\n"
' (SELECT count FROM "_counts" WHERE "table" = \'foo\'),\n'
" 0\n"
" ) + 1\n"
" );\n"
"END"
),
"foo_counts_delete": (
"CREATE TRIGGER [foo_counts_delete] AFTER DELETE ON [foo]\n"
'CREATE TRIGGER "foo_counts_delete" AFTER DELETE ON "foo"\n'
"BEGIN\n"
" INSERT OR REPLACE INTO [_counts]\n"
' INSERT OR REPLACE INTO "_counts"\n'
" VALUES (\n"
" 'foo',\n"
" COALESCE(\n"
" (SELECT count FROM [_counts] WHERE [table] = 'foo'),\n"
' (SELECT count FROM "_counts" WHERE "table" = \'foo\'),\n'
" 0\n"
" ) - 1\n"
" );\n"
@ -129,19 +129,19 @@ def test_uses_counts_after_enable_counts(counts_db_path):
db = Database(counts_db_path)
logged = []
with db.tracer(lambda sql, parameters: logged.append((sql, parameters))):
assert db["foo"].count == 1
assert db.table("foo").count == 1
assert logged == [
("select name from sqlite_master where type = 'view'", None),
("select count(*) from [foo]", []),
('select count(*) from "foo"', []),
]
logged.clear()
assert not db.use_counts_table
db.enable_counts()
assert db.use_counts_table
assert db["foo"].count == 1
assert db.table("foo").count == 1
assert logged == [
(
"CREATE TABLE IF NOT EXISTS [_counts](\n [table] TEXT PRIMARY KEY,\n count INTEGER DEFAULT 0\n);",
'CREATE TABLE IF NOT EXISTS "_counts"(\n "table" TEXT PRIMARY KEY,\n count INTEGER DEFAULT 0\n);',
None,
),
("select name from sqlite_master where type = 'table'", None),
@ -157,7 +157,7 @@ def test_uses_counts_after_enable_counts(counts_db_path):
("SELECT quote(:value)", {"value": "baz"}),
("select sql from sqlite_master where name = ?", ("_counts",)),
("select name from sqlite_master where type = 'view'", None),
("select [table], count from _counts where [table] in (?)", ["foo"]),
('select "table", count from _counts where "table" in (?)', ["foo"]),
]

View file

@ -24,19 +24,16 @@ def test_extract_single_column(fresh_db, table, fk_column):
fresh_db["tree"].extract("species", table=table, fk_column=fk_column)
assert fresh_db["tree"].schema == (
'CREATE TABLE "tree" (\n'
" [id] INTEGER PRIMARY KEY,\n"
" [name] TEXT,\n"
" [{}] INTEGER,\n".format(expected_fk)
+ " [end] INTEGER,\n"
+ " FOREIGN KEY([{}]) REFERENCES [{}]([id])\n".format(
expected_fk, expected_table
)
' "id" INTEGER PRIMARY KEY,\n'
' "name" TEXT,\n'
' "{}" INTEGER REFERENCES "{}"("id"),\n'.format(expected_fk, expected_table)
+ ' "end" INTEGER\n'
+ ")"
)
assert fresh_db[expected_table].schema == (
"CREATE TABLE [{}] (\n".format(expected_table)
+ " [id] INTEGER PRIMARY KEY,\n"
" [species] TEXT\n"
'CREATE TABLE "{}" (\n'.format(expected_table)
+ ' "id" INTEGER PRIMARY KEY,\n'
' "species" TEXT\n'
")"
)
assert list(fresh_db[expected_table].rows) == [
@ -74,17 +71,16 @@ def test_extract_multiple_columns_with_rename(fresh_db):
)
assert fresh_db["tree"].schema == (
'CREATE TABLE "tree" (\n'
" [id] INTEGER PRIMARY KEY,\n"
" [name] TEXT,\n"
" [common_name_latin_name_id] INTEGER,\n"
" FOREIGN KEY([common_name_latin_name_id]) REFERENCES [common_name_latin_name]([id])\n"
' "id" INTEGER PRIMARY KEY,\n'
' "name" TEXT,\n'
' "common_name_latin_name_id" INTEGER REFERENCES "common_name_latin_name"("id")\n'
")"
)
assert fresh_db["common_name_latin_name"].schema == (
"CREATE TABLE [common_name_latin_name] (\n"
" [id] INTEGER PRIMARY KEY,\n"
" [name] TEXT,\n"
" [latin_name] TEXT\n"
'CREATE TABLE "common_name_latin_name" (\n'
' "id" INTEGER PRIMARY KEY,\n'
' "name" TEXT,\n'
' "latin_name" TEXT\n'
")"
)
assert list(fresh_db["common_name_latin_name"].rows) == [
@ -126,14 +122,11 @@ def test_extract_rowid_table(fresh_db):
fresh_db["tree"].extract(["common_name", "latin_name"])
assert fresh_db["tree"].schema == (
'CREATE TABLE "tree" (\n'
" [name] TEXT,\n"
" [common_name_latin_name_id] INTEGER,\n"
" FOREIGN KEY([common_name_latin_name_id]) REFERENCES [common_name_latin_name]([id])\n"
' "name" TEXT,\n'
' "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,
@ -141,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):
@ -157,17 +147,15 @@ def test_reuse_lookup_table(fresh_db):
fresh_db["individuals"].extract("species", rename={"species": "name"})
assert fresh_db["sightings"].schema == (
'CREATE TABLE "sightings" (\n'
" [id] INTEGER PRIMARY KEY,\n"
" [species_id] INTEGER,\n"
" FOREIGN KEY([species_id]) REFERENCES [species]([id])\n"
' "id" INTEGER PRIMARY KEY,\n'
' "species_id" INTEGER REFERENCES "species"("id")\n'
")"
)
assert fresh_db["individuals"].schema == (
'CREATE TABLE "individuals" (\n'
" [id] INTEGER PRIMARY KEY,\n"
" [name] TEXT,\n"
" [species_id] INTEGER,\n"
" FOREIGN KEY([species_id]) REFERENCES [species]([id])\n"
' "id" INTEGER PRIMARY KEY,\n'
' "name" TEXT,\n'
' "species_id" INTEGER REFERENCES "species"("id")\n'
")"
)
assert list(fresh_db["species"].rows) == [
@ -186,3 +174,131 @@ def test_extract_error_on_incompatible_existing_lookup_table(fresh_db):
fresh_db["species2"].insert({"id": 1, "common_name": 3.5})
with pytest.raises(InvalidColumns):
fresh_db["tree"].extract("common_name", table="species2")
def test_extract_works_with_null_values(fresh_db):
fresh_db["listens"].insert_all(
[
{"id": 1, "track_title": "foo", "album_title": "bar"},
{"id": 2, "track_title": "baz", "album_title": None},
],
pk="id",
)
fresh_db["listens"].extract(
columns=["album_title"], table="albums", fk_column="album_id"
)
assert list(fresh_db["listens"].rows) == [
{"id": 1, "track_title": "foo", "album_id": 1},
{"id": 2, "track_title": "baz", "album_id": None},
]
assert list(fresh_db["albums"].rows) == [
{"id": 1, "album_title": "bar"},
]
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

@ -25,18 +25,18 @@ def test_extracts(fresh_db, kwargs, expected_table, use_table_factory):
{"id": 2, "species_id": "Oak"},
{"id": 3, "species_id": "Palm"},
],
**insert_kwargs
**insert_kwargs,
)
# Should now have two tables: Trees and Species
assert {expected_table, "Trees"} == set(fresh_db.table_names())
assert (
"CREATE TABLE [{}] (\n [id] INTEGER PRIMARY KEY,\n [value] TEXT\n)".format(
'CREATE TABLE "{}" (\n "id" INTEGER PRIMARY KEY,\n "value" TEXT\n)'.format(
expected_table
)
== fresh_db[expected_table].schema
)
assert (
"CREATE TABLE [Trees] (\n [id] INTEGER,\n [species_id] INTEGER REFERENCES [{}]([id])\n)".format(
'CREATE TABLE "Trees" (\n "id" INTEGER,\n "species_id" INTEGER REFERENCES "{}"("id")\n)'.format(
expected_table
)
== fresh_db["Trees"].schema
@ -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

@ -1,6 +1,7 @@
import pytest
from sqlite_utils import Database
from sqlite_utils.utils import sqlite3
from unittest.mock import ANY
search_records = [
{
@ -82,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)
@ -94,6 +109,64 @@ def test_search_limit_offset(fresh_db):
)
@pytest.mark.parametrize("fts_version", ("FTS4", "FTS5"))
def test_search_where(fresh_db, fts_version):
table = fresh_db["t"]
table.insert_all(search_records)
table.enable_fts(["text", "country"], fts_version=fts_version)
results = list(
table.search("are", where="country = :country", where_args={"country": "Japan"})
)
assert results == [
{
"rowid": 1,
"text": "tanuki are running tricksters",
"country": "Japan",
"not_searchable": "foo",
}
]
def test_search_where_args_disallows_query(fresh_db):
table = fresh_db["t"]
with pytest.raises(ValueError) as ex:
list(
table.search(
"x", where="author = :query", where_args={"query": "not allowed"}
)
)
assert (
ex.value.args[0]
== "'query' is a reserved key and cannot be passed to where_args for .search()"
)
def test_search_include_rank(fresh_db):
table = fresh_db["t"]
table.insert_all(search_records)
table.enable_fts(["text", "country"], fts_version="FTS5")
results = list(table.search("are", include_rank=True))
assert results == [
{
"rowid": 1,
"text": "tanuki are running tricksters",
"country": "Japan",
"not_searchable": "foo",
"rank": ANY,
},
{
"rowid": 2,
"text": "racoons are biting trash pandas",
"country": "USA",
"not_searchable": "bar",
"rank": ANY,
},
]
assert isinstance(results[0]["rank"], float)
assert isinstance(results[1]["rank"], float)
assert results[0]["rank"] < results[1]["rank"]
def test_enable_fts_table_names_containing_spaces(fresh_db):
table = fresh_db["test"]
table.insert({"column with spaces": "in its name"})
@ -148,32 +221,32 @@ def test_populate_fts_escape_table_names(fresh_db):
] == list(table.search("usa"))
def test_fts_tokenize(fresh_db):
for fts_version in ("4", "5"):
table_name = "searchable_{}".format(fts_version)
table = fresh_db[table_name]
table.insert_all(search_records)
# Test without porter stemming
table.enable_fts(
["text", "country"],
fts_version="FTS{}".format(fts_version),
)
assert [] == list(table.search("bite"))
# Test WITH stemming
table.disable_fts()
table.enable_fts(
["text", "country"],
fts_version="FTS{}".format(fts_version),
tokenize="porter",
)
rows = list(table.search("bite", order_by="rowid"))
assert len(rows) == 1
assert {
"rowid": 2,
"text": "racoons are biting trash pandas",
"country": "USA",
"not_searchable": "bar",
}.items() <= rows[0].items()
@pytest.mark.parametrize("fts_version", ("4", "5"))
def test_fts_tokenize(fresh_db, fts_version):
table_name = "searchable_{}".format(fts_version)
table = fresh_db[table_name]
table.insert_all(search_records)
# Test without porter stemming
table.enable_fts(
["text", "country"],
fts_version="FTS{}".format(fts_version),
)
assert [] == list(table.search("bite"))
# Test WITH stemming
table.disable_fts()
table.enable_fts(
["text", "country"],
fts_version="FTS{}".format(fts_version),
tokenize="porter",
)
rows = list(table.search("bite", order_by="rowid"))
assert len(rows) == 1
assert {
"rowid": 2,
"text": "racoons are biting trash pandas",
"country": "USA",
"not_searchable": "bar",
}.items() <= rows[0].items()
def test_optimize_fts(fresh_db):
@ -254,13 +327,12 @@ def test_disable_fts(fresh_db, create_triggers):
assert ["searchable"] == fresh_db.table_names()
@pytest.mark.parametrize("table_to_fix", ["searchable", "searchable_fts"])
def test_rebuild_fts(fresh_db, table_to_fix):
def test_rebuild_fts(fresh_db):
table = fresh_db["searchable"]
table.insert(search_records[0])
table.enable_fts(["text", "country"])
# Run a search
rows = list(table.search("tanuki"))
rows = list(table.search("are"))
assert len(rows) == 1
assert {
"rowid": 1,
@ -268,21 +340,32 @@ def test_rebuild_fts(fresh_db, table_to_fix):
"country": "Japan",
"not_searchable": "foo",
}.items() <= rows[0].items()
# Delete from searchable_fts_data
fresh_db["searchable_fts_data"].delete_where()
# This should have broken the index
with pytest.raises(sqlite3.DatabaseError):
list(table.search("tanuki"))
# Insert another record
table.insert(search_records[1])
# This should NOT show up in searches
assert len(list(table.search("are"))) == 1
# Running rebuild_fts() should fix it
fresh_db[table_to_fix].rebuild_fts()
rows2 = list(table.search("tanuki"))
assert len(rows2) == 1
assert {
"rowid": 1,
"text": "tanuki are running tricksters",
"country": "Japan",
"not_searchable": "foo",
}.items() <= rows2[0].items()
table.rebuild_fts()
rows2 = list(table.search("are"))
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"])
@ -369,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(
@ -384,85 +490,107 @@ def test_enable_fts_error_message_on_views():
{},
"FTS5",
(
"with original as (\n"
'with "original" as (\n'
" select\n"
" rowid,\n"
" *\n"
" from [books]\n"
' from "books"\n'
")\n"
"select\n"
" [original].*\n"
' "original".*\n'
"from\n"
" [original]\n"
" join [books_fts] on [original].rowid = [books_fts].rowid\n"
' "original"\n'
' join "books_fts" on "original".rowid = "books_fts".rowid\n'
"where\n"
" [books_fts] match :query\n"
' "books_fts" match :query\n'
"order by\n"
" [books_fts].rank"
' "books_fts".rank'
),
),
(
{"columns": ["title"], "order_by": "rowid", "limit": 10},
"FTS5",
(
"with original as (\n"
'with "original" as (\n'
" select\n"
" rowid,\n"
" [title]\n"
" from [books]\n"
' "title"\n'
' from "books"\n'
")\n"
"select\n"
" [original].[title]\n"
' "original"."title"\n'
"from\n"
" [original]\n"
" join [books_fts] on [original].rowid = [books_fts].rowid\n"
' "original"\n'
' join "books_fts" on "original".rowid = "books_fts".rowid\n'
"where\n"
" [books_fts] match :query\n"
' "books_fts" match :query\n'
"order by\n"
" rowid\n"
"limit 10"
),
),
(
{"where": "author = :author"},
"FTS5",
(
'with "original" as (\n'
" select\n"
" rowid,\n"
" *\n"
' from "books"\n'
" where author = :author\n"
")\n"
"select\n"
' "original".*\n'
"from\n"
' "original"\n'
' join "books_fts" on "original".rowid = "books_fts".rowid\n'
"where\n"
' "books_fts" match :query\n'
"order by\n"
' "books_fts".rank'
),
),
(
{"columns": ["title"]},
"FTS4",
(
"with original as (\n"
'with "original" as (\n'
" select\n"
" rowid,\n"
" [title]\n"
" from [books]\n"
' "title"\n'
' from "books"\n'
")\n"
"select\n"
" [original].[title]\n"
' "original"."title"\n'
"from\n"
" [original]\n"
" join [books_fts] on [original].rowid = [books_fts].rowid\n"
' "original"\n'
' join "books_fts" on "original".rowid = "books_fts".rowid\n'
"where\n"
" [books_fts] match :query\n"
' "books_fts" match :query\n'
"order by\n"
" rank_bm25(matchinfo([books_fts], 'pcnalx'))"
" rank_bm25(matchinfo(\"books_fts\", 'pcnalx'))"
),
),
(
{"offset": 1, "limit": 1},
"FTS4",
(
"with original as (\n"
'with "original" as (\n'
" select\n"
" rowid,\n"
" *\n"
" from [books]\n"
' from "books"\n'
")\n"
"select\n"
" [original].*\n"
' "original".*\n'
"from\n"
" [original]\n"
" join [books_fts] on [original].rowid = [books_fts].rowid\n"
' "original"\n'
' join "books_fts" on "original".rowid = "books_fts".rowid\n'
"where\n"
" [books_fts] match :query\n"
' "books_fts" match :query\n'
"order by\n"
" rank_bm25(matchinfo([books_fts], 'pcnalx'))\n"
" rank_bm25(matchinfo(\"books_fts\", 'pcnalx'))\n"
"limit 1 offset 1"
),
),
@ -470,24 +598,90 @@ def test_enable_fts_error_message_on_views():
{"limit": 2},
"FTS4",
(
"with original as (\n"
'with "original" as (\n'
" select\n"
" rowid,\n"
" *\n"
" from [books]\n"
' from "books"\n'
")\n"
"select\n"
" [original].*\n"
' "original".*\n'
"from\n"
" [original]\n"
" join [books_fts] on [original].rowid = [books_fts].rowid\n"
' "original"\n'
' join "books_fts" on "original".rowid = "books_fts".rowid\n'
"where\n"
" [books_fts] match :query\n"
' "books_fts" match :query\n'
"order by\n"
" rank_bm25(matchinfo([books_fts], 'pcnalx'))\n"
" rank_bm25(matchinfo(\"books_fts\", 'pcnalx'))\n"
"limit 2"
),
),
(
{"where": "author = :author"},
"FTS4",
(
'with "original" as (\n'
" select\n"
" rowid,\n"
" *\n"
' from "books"\n'
" where author = :author\n"
")\n"
"select\n"
' "original".*\n'
"from\n"
' "original"\n'
' join "books_fts" on "original".rowid = "books_fts".rowid\n'
"where\n"
' "books_fts" match :query\n'
"order by\n"
" rank_bm25(matchinfo(\"books_fts\", 'pcnalx'))"
),
),
(
{"include_rank": True},
"FTS5",
(
'with "original" as (\n'
" select\n"
" rowid,\n"
" *\n"
' from "books"\n'
")\n"
"select\n"
' "original".*,\n'
' "books_fts".rank rank\n'
"from\n"
' "original"\n'
' join "books_fts" on "original".rowid = "books_fts".rowid\n'
"where\n"
' "books_fts" match :query\n'
"order by\n"
' "books_fts".rank'
),
),
(
{"include_rank": True},
"FTS4",
(
'with "original" as (\n'
" select\n"
" rowid,\n"
" *\n"
' from "books"\n'
")\n"
"select\n"
' "original".*,\n'
" rank_bm25(matchinfo(\"books_fts\", 'pcnalx')) rank\n"
"from\n"
' "original"\n'
' join "books_fts" on "original".rowid = "books_fts".rowid\n'
"where\n"
' "books_fts" match :query\n'
"order by\n"
" rank_bm25(matchinfo(\"books_fts\", 'pcnalx'))"
),
),
],
)
def test_search_sql(kwargs, fts, expected):
@ -501,3 +695,54 @@ def test_search_sql(kwargs, fts, expected):
db["books"].enable_fts(["title", "author"], fts_version=fts)
sql = db["books"].search_sql(**kwargs)
assert sql == expected
@pytest.mark.parametrize(
"input,expected",
(
("dog", '"dog"'),
("cat,", '"cat,"'),
("cat's", '"cat\'s"'),
("dog.", '"dog."'),
("cat dog", '"cat" "dog"'),
# If a phrase is already double quoted, leave it so
('"cat dog"', '"cat dog"'),
('"cat dog" fish', '"cat dog" "fish"'),
# Sensibly handle unbalanced double quotes
('cat"', '"cat"'),
('"cat dog" "fish', '"cat dog" "fish"'),
),
)
def test_quote_fts_query(fresh_db, input, expected):
table = fresh_db["searchable"]
table.insert_all(search_records)
table.enable_fts(["text", "country"])
quoted = fresh_db.quote_fts(input)
assert quoted == expected
# Executing query does not crash.
list(table.search(quoted))
def test_search_quote(fresh_db):
table = fresh_db["searchable"]
table.insert_all(search_records)
table.enable_fts(["text", "country"])
query = "cat's"
with pytest.raises(sqlite3.OperationalError):
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"

236
tests/test_gis.py Normal file
View file

@ -0,0 +1,236 @@
import json
import pytest
from click.testing import CliRunner
from sqlite_utils.cli import cli
from sqlite_utils.db import Database
from sqlite_utils.utils import find_spatialite, sqlite3
pytestmark = [
pytest.mark.skipif(
not find_spatialite(), reason="Could not find SpatiaLite extension"
),
pytest.mark.skipif(
not hasattr(sqlite3.Connection, "enable_load_extension"),
reason="sqlite3.Connection missing enable_load_extension",
),
]
# python API tests
def test_find_spatialite():
spatialite = find_spatialite()
assert spatialite is None or isinstance(spatialite, str)
def test_init_spatialite():
db = Database(memory=True)
spatialite = find_spatialite()
db.init_spatialite(spatialite)
assert "spatial_ref_sys" in db.table_names()
def test_add_geometry_column():
db = Database(memory=True)
spatialite = find_spatialite()
db.init_spatialite(spatialite)
# create a table first
table = db.create_table("locations", {"id": str, "properties": str})
table.add_geometry_column(
column_name="geometry",
geometry_type="Point",
srid=4326,
coord_dimension="XY",
)
assert db["geometry_columns"].get(["locations", "geometry"]) == {
"f_table_name": "locations",
"f_geometry_column": "geometry",
"geometry_type": 1, # point
"coord_dimension": 2,
"srid": 4326,
"spatial_index_enabled": 0,
}
def test_create_spatial_index():
db = Database(memory=True)
spatialite = find_spatialite()
assert db.init_spatialite(spatialite)
# create a table, add a geometry column with default values
table = db.create_table("locations", {"id": str, "properties": str})
assert table.add_geometry_column("geometry", "Point")
# index it
assert table.create_spatial_index("geometry")
assert "idx_locations_geometry" in db.table_names()
def test_double_create_spatial_index():
db = Database(memory=True)
spatialite = find_spatialite()
db.init_spatialite(spatialite)
# create a table, add a geometry column with default values
table = db.create_table("locations", {"id": str, "properties": str})
table.add_geometry_column("geometry", "Point")
# index it, return True
assert table.create_spatial_index("geometry")
assert "idx_locations_geometry" in db.table_names()
# call it again, return False
assert not table.create_spatial_index("geometry")
# cli tests
@pytest.mark.parametrize("use_spatialite_shortcut", [True, False])
def test_query_load_extension(use_spatialite_shortcut):
# Without --load-extension:
result = CliRunner().invoke(cli, [":memory:", "select spatialite_version()"])
assert result.exit_code == 1
assert "no such function: spatialite_version" in result.output
# With --load-extension:
if use_spatialite_shortcut:
load_extension = "spatialite"
else:
load_extension = find_spatialite()
result = CliRunner().invoke(
cli,
[
":memory:",
"select spatialite_version()",
"--load-extension={}".format(load_extension),
],
)
assert result.exit_code == 0, result.stdout
assert ["spatialite_version()"] == list(json.loads(result.output)[0].keys())
def test_cli_create_spatialite(tmpdir):
# sqlite-utils create test.db --init-spatialite
db_path = tmpdir / "created.db"
result = CliRunner().invoke(
cli, ["create-database", str(db_path), "--init-spatialite"]
)
assert result.exit_code == 0
assert db_path.exists()
assert db_path.read_binary()[:16] == b"SQLite format 3\x00"
db = Database(str(db_path))
assert "spatial_ref_sys" in db.table_names()
def test_cli_add_geometry_column(tmpdir):
# create a rowid table with one column
db_path = tmpdir / "spatial.db"
db = Database(str(db_path))
db.init_spatialite()
table = db["locations"].create({"name": str})
result = CliRunner().invoke(
cli,
[
"add-geometry-column",
str(db_path),
table.name,
"geometry",
"--type",
"POINT",
],
)
assert result.exit_code == 0
assert db["geometry_columns"].get(["locations", "geometry"]) == {
"f_table_name": "locations",
"f_geometry_column": "geometry",
"geometry_type": 1, # point
"coord_dimension": 2,
"srid": 4326,
"spatial_index_enabled": 0,
}
def test_cli_add_geometry_column_options(tmpdir):
# create a rowid table with one column
db_path = tmpdir / "spatial.db"
db = Database(str(db_path))
db.init_spatialite()
table = db["locations"].create({"name": str})
result = CliRunner().invoke(
cli,
[
"add-geometry-column",
str(db_path),
table.name,
"geometry",
"-t",
"POLYGON",
"--srid",
"3857", # https://epsg.io/3857
"--not-null",
],
)
assert result.exit_code == 0
assert db["geometry_columns"].get(["locations", "geometry"]) == {
"f_table_name": "locations",
"f_geometry_column": "geometry",
"geometry_type": 3, # polygon
"coord_dimension": 2,
"srid": 3857,
"spatial_index_enabled": 0,
}
column = table.columns[1]
assert column.notnull
def test_cli_add_geometry_column_invalid_type(tmpdir):
# create a rowid table with one column
db_path = tmpdir / "spatial.db"
db = Database(str(db_path))
db.init_spatialite()
table = db["locations"].create({"name": str})
result = CliRunner().invoke(
cli,
[
"add-geometry-column",
str(db_path),
table.name,
"geometry",
"--type",
"NOT-A-TYPE",
],
)
assert 2 == result.exit_code
def test_cli_create_spatial_index(tmpdir):
# create a rowid table with one column
db_path = tmpdir / "spatial.db"
db = Database(str(db_path))
db.init_spatialite()
table = db["locations"].create({"name": str})
table.add_geometry_column("geometry", "POINT")
result = CliRunner().invoke(
cli, ["create-spatial-index", str(db_path), table.name, "geometry"]
)
assert result.exit_code == 0
assert "idx_locations_geometry" in db.table_names()

View file

@ -3,10 +3,18 @@ from click.testing import CliRunner
import os
import pathlib
import pytest
import sys
@pytest.mark.parametrize("silent", (False, True))
def test_insert_files(silent):
@pytest.mark.parametrize(
"pk_args,expected_pks",
(
(["--pk", "path"], ["path"]),
(["--pk", "path", "--pk", "name"], ["path", "name"]),
),
)
def test_insert_files(silent, pk_args, expected_pks):
runner = CliRunner()
with runner.isolated_filesystem():
tmpdir = pathlib.Path(".")
@ -14,7 +22,7 @@ def test_insert_files(silent):
(tmpdir / "one.txt").write_text("This is file one", "utf-8")
(tmpdir / "two.txt").write_text("Two is shorter", "utf-8")
(tmpdir / "nested").mkdir()
(tmpdir / "nested" / "three.txt").write_text("Three is nested", "utf-8")
(tmpdir / "nested" / "three.zz.txt").write_text("Three is nested", "utf-8")
coltypes = (
"name",
"path",
@ -23,6 +31,7 @@ def test_insert_files(silent):
"md5",
"mode",
"content",
"content_text",
"mtime",
"ctime",
"mtime_int",
@ -30,6 +39,8 @@ def test_insert_files(silent):
"mtime_iso",
"ctime_iso",
"size",
"suffix",
"stem",
)
cols = []
for coltype in coltypes:
@ -38,7 +49,7 @@ def test_insert_files(silent):
cli.cli,
["insert-files", db_path, "files", str(tmpdir)]
+ cols
+ ["--pk", "path"]
+ pk_args
+ (["--silent"] if silent else []),
catch_exceptions=False,
)
@ -48,31 +59,40 @@ def test_insert_files(silent):
one, two, three = (
rows_by_path["one.txt"],
rows_by_path["two.txt"],
rows_by_path[os.path.join("nested", "three.txt")],
rows_by_path[os.path.join("nested", "three.zz.txt")],
)
assert {
"content": b"This is file one",
"content_text": "This is file one",
"md5": "556dfb57fce9ca301f914e2273adf354",
"name": "one.txt",
"path": "one.txt",
"sha256": "e34138f26b5f7368f298b4e736fea0aad87ddec69fbd04dc183b20f4d844bad5",
"size": 16,
"stem": "one",
"suffix": ".txt",
}.items() <= one.items()
assert {
"content": b"Two is shorter",
"content_text": "Two is shorter",
"md5": "f86f067b083af1911043eb215e74ac70",
"name": "two.txt",
"path": "two.txt",
"sha256": "9368988ed16d4a2da0af9db9b686d385b942cb3ffd4e013f43aed2ec041183d9",
"size": 14,
"stem": "two",
"suffix": ".txt",
}.items() <= two.items()
assert {
"content": b"Three is nested",
"content_text": "Three is nested",
"md5": "12580f341781f5a5b589164d3cd39523",
"name": "three.txt",
"path": os.path.join("nested", "three.txt"),
"name": "three.zz.txt",
"path": os.path.join("nested", "three.zz.txt"),
"sha256": "6dd45aaaaa6b9f96af19363a92c8fca5d34791d3c35c44eb19468a6a862cc8cd",
"size": 15,
"stem": "three.zz",
"suffix": ".txt",
}.items() <= three.items()
# Assert the other int/str/float columns exist and are of the right types
expected_types = {
@ -84,24 +104,68 @@ def test_insert_files(silent):
"mtime_iso": str,
"mode": int,
"fullpath": str,
"content": bytes,
"content_text": str,
"stem": str,
"suffix": str,
}
for colname, expected_type in expected_types.items():
for row in (one, two, three):
assert isinstance(row[colname], expected_type)
assert set(db["files"].pks) == set(expected_pks)
def test_insert_files_stdin():
@pytest.mark.parametrize(
"use_text,encoding,input,expected",
(
(False, None, "hello world", b"hello world"),
(True, None, "hello world", "hello world"),
(False, None, b"S\xe3o Paulo", b"S\xe3o Paulo"),
(True, "latin-1", b"S\xe3o Paulo", "S\xe3o Paulo"),
),
)
def test_insert_files_stdin(use_text, encoding, input, expected):
runner = CliRunner()
with runner.isolated_filesystem():
tmpdir = pathlib.Path(".")
db_path = str(tmpdir / "files.db")
args = ["insert-files", db_path, "files", "-", "--name", "stdin-name"]
if use_text:
args += ["--text"]
if encoding is not None:
args += ["--encoding", encoding]
result = runner.invoke(
cli.cli,
["insert-files", db_path, "files", "-", "--name", "stdin-name"],
args,
catch_exceptions=False,
input="hello world",
input=input,
)
assert result.exit_code == 0, result.stdout
db = Database(db_path)
row = list(db["files"].rows)[0]
assert {"path": "stdin-name", "content": b"hello world", "size": 11} == row
key = "content"
if use_text:
key = "content_text"
assert {"path": "stdin-name", key: expected}.items() <= row.items()
@pytest.mark.skipif(
sys.platform.startswith("win"),
reason="Windows has a different way of handling default encodings",
)
def test_insert_files_bad_text_encoding_error():
runner = CliRunner()
with runner.isolated_filesystem():
tmpdir = pathlib.Path(".")
latin = tmpdir / "latin.txt"
latin.write_bytes(b"S\xe3o Paulo")
db_path = str(tmpdir / "files.db")
result = runner.invoke(
cli.cli,
["insert-files", db_path, "files", str(latin), "--text"],
catch_exceptions=False,
)
assert result.exit_code == 1, result.output
assert result.output.strip().startswith(
"Error: Could not read file '{}' as text".format(str(latin.resolve()))
)

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()
@ -36,19 +44,36 @@ def test_detect_fts(existing_db):
assert existing_db["foo"].detect_fts() is None
@pytest.mark.parametrize("reverse_order", (True, False))
def test_detect_fts_similar_tables(fresh_db, reverse_order):
# https://github.com/simonw/sqlite-utils/issues/434
table1, table2 = ("demo", "demo2")
if reverse_order:
table1, table2 = table2, table1
fresh_db[table1].insert({"title": "Hello"}).enable_fts(
["title"], fts_version="FTS4"
)
fresh_db[table2].insert({"title": "Hello"}).enable_fts(
["title"], fts_version="FTS4"
)
assert fresh_db[table1].detect_fts() == "{}_fts".format(table1)
assert fresh_db[table2].detect_fts() == "{}_fts".format(table2)
def test_tables(existing_db):
assert 1 == len(existing_db.tables)
assert "foo" == existing_db.tables[0].name
assert len(existing_db.tables) == 1
assert existing_db.tables[0].name == "foo"
def test_views(fresh_db):
fresh_db.create_view("foo_view", "select 1")
assert 1 == len(fresh_db.views)
assert len(fresh_db.views) == 1
view = fresh_db.views[0]
assert isinstance(view, View)
assert "foo_view" == view.name
assert "<View foo_view (1)>" == repr(view)
assert {"1": str} == view.columns_dict
assert view.name == "foo_view"
assert repr(view) == "<View foo_view (1)>"
assert view.columns_dict == {"1": str}
def test_count(existing_db):
@ -84,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,
@ -105,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",
@ -183,19 +204,19 @@ def test_triggers_and_triggers_dict(fresh_db):
}
expected_triggers = {
"authors_ai": (
"CREATE TRIGGER [authors_ai] AFTER INSERT ON [authors] BEGIN\n"
" INSERT INTO [authors_fts] (rowid, [name], [famous_works]) VALUES (new.rowid, new.[name], new.[famous_works]);\n"
'CREATE TRIGGER "authors_ai" AFTER INSERT ON "authors" BEGIN\n'
' INSERT INTO "authors_fts" (rowid, "name", "famous_works") VALUES (new.rowid, new."name", new."famous_works");\n'
"END"
),
"authors_ad": (
"CREATE TRIGGER [authors_ad] AFTER DELETE ON [authors] BEGIN\n"
" INSERT INTO [authors_fts] ([authors_fts], rowid, [name], [famous_works]) VALUES('delete', old.rowid, old.[name], old.[famous_works]);\n"
'CREATE TRIGGER "authors_ad" AFTER DELETE ON "authors" BEGIN\n'
' INSERT INTO "authors_fts" ("authors_fts", rowid, "name", "famous_works") VALUES(\'delete\', old.rowid, old."name", old."famous_works");\n'
"END"
),
"authors_au": (
"CREATE TRIGGER [authors_au] AFTER UPDATE ON [authors] BEGIN\n"
" INSERT INTO [authors_fts] ([authors_fts], rowid, [name], [famous_works]) VALUES('delete', old.rowid, old.[name], old.[famous_works]);\n"
" INSERT INTO [authors_fts] (rowid, [name], [famous_works]) VALUES (new.rowid, new.[name], new.[famous_works]);\nEND"
'CREATE TRIGGER "authors_au" AFTER UPDATE ON "authors" BEGIN\n'
' INSERT INTO "authors_fts" ("authors_fts", rowid, "name", "famous_works") VALUES(\'delete\', old.rowid, old."name", old."famous_works");\n'
' INSERT INTO "authors_fts" (rowid, "name", "famous_works") VALUES (new.rowid, new."name", new."famous_works");\nEND'
),
}
assert authors.triggers_dict == expected_triggers
@ -252,15 +273,66 @@ def test_has_counts_triggers(fresh_db):
),
],
)
def test_virtual_table_using(sql, expected_name, expected_using):
db = Database(memory=True)
db.execute(sql)
assert db[expected_name].virtual_table_using == expected_using
def test_virtual_table_using(fresh_db, sql, expected_name, expected_using):
fresh_db.execute(sql)
assert fresh_db[expected_name].virtual_table_using == expected_using
def test_use_rowid():
db = Database(memory=True)
db["rowid_table"].insert({"name": "Cleo"})
db["regular_table"].insert({"id": 1, "name": "Cleo"}, pk="id")
assert db["rowid_table"].use_rowid
assert not db["regular_table"].use_rowid
def test_use_rowid(fresh_db):
fresh_db["rowid_table"].insert({"name": "Cleo"})
fresh_db["regular_table"].insert({"id": 1, "name": "Cleo"}, pk="id")
assert fresh_db["rowid_table"].use_rowid
assert not fresh_db["regular_table"].use_rowid
@pytest.mark.skipif(
not _check_supports_strict(),
reason="Needs SQLite version that supports strict",
)
@pytest.mark.parametrize(
"create_table,expected_strict",
(
("create table t (id integer) strict", True),
("create table t (id integer) STRICT", True),
("create table t (id integer primary key) StriCt, WITHOUT ROWID", True),
("create table t (id integer primary key) WITHOUT ROWID", False),
("create table t (id integer)", False),
),
)
def test_table_strict(fresh_db, create_table, expected_strict):
fresh_db.execute(create_table)
table = fresh_db["t"]
assert table.strict == expected_strict
@pytest.mark.parametrize(
"value",
(
1,
1.3,
"foo",
True,
b"binary",
),
)
def test_table_default_values(fresh_db, value):
fresh_db["default_values"].insert(
{"nodefault": 1, "value": value}, defaults={"value": value}
)
default_values = fresh_db["default_values"].default_values
assert default_values == {"value": value}
def test_pks_use_primary_key_declaration_order(fresh_db):
# PRIMARY KEY (a, b) declared against columns stored in order (b, a) -
# pks must follow the declaration order, which is what SQLite uses to
# resolve implicit foreign key references and compound pk lookups
fresh_db.execute("create table t (b text, a text, primary key (a, b))")
assert fresh_db["t"].pks == ["a", "b"]
def test_transform_preserves_compound_pk_declaration_order(fresh_db):
fresh_db.execute("create table t (a text, b text, c text, primary key (b, a))")
fresh_db["t"].transform(drop={"c"})
assert fresh_db["t"].pks == ["b", "a"]
assert 'PRIMARY KEY ("b", "a")' in fresh_db["t"].schema

288
tests/test_list_mode.py Normal file
View file

@ -0,0 +1,288 @@
"""
Tests for list-based iteration in insert_all and upsert_all
"""
import pytest
from sqlite_utils import Database
def test_insert_all_list_mode_basic():
"""Test basic insert_all with list-based iteration"""
db = Database(memory=True)
def data_generator():
# First yield column names
yield ["id", "name", "age"]
# Then yield data rows
yield [1, "Alice", 30]
yield [2, "Bob", 25]
yield [3, "Charlie", 35]
db["people"].insert_all(data_generator())
rows = list(db["people"].rows)
assert len(rows) == 3
assert rows[0] == {"id": 1, "name": "Alice", "age": 30}
assert rows[1] == {"id": 2, "name": "Bob", "age": 25}
assert rows[2] == {"id": 3, "name": "Charlie", "age": 35}
def test_insert_all_list_mode_with_pk():
"""Test insert_all with list mode and primary key"""
db = Database(memory=True)
def data_generator():
yield ["id", "name", "score"]
yield [1, "Alice", 95]
yield [2, "Bob", 87]
db["scores"].insert_all(data_generator(), pk="id")
assert db["scores"].pks == ["id"]
rows = list(db["scores"].rows)
assert len(rows) == 2
def test_upsert_all_list_mode():
"""Test upsert_all with list-based iteration"""
db = Database(memory=True)
# Initial insert
def initial_data():
yield ["id", "name", "value"]
yield [1, "Alice", 100]
yield [2, "Bob", 200]
db["data"].insert_all(initial_data(), pk="id")
# Upsert with some updates and new records
def upsert_data():
yield ["id", "name", "value"]
yield [1, "Alice", 150] # Update existing
yield [3, "Charlie", 300] # Insert new
db["data"].upsert_all(upsert_data(), pk="id")
rows = list(db["data"].rows_where(order_by="id"))
assert len(rows) == 3
assert rows[0] == {"id": 1, "name": "Alice", "value": 150}
assert rows[1] == {"id": 2, "name": "Bob", "value": 200}
assert rows[2] == {"id": 3, "name": "Charlie", "value": 300}
def test_list_mode_with_various_types():
"""Test list mode with different data types"""
db = Database(memory=True)
def data_generator():
yield ["id", "name", "score", "active"]
yield [1, "Alice", 95.5, True]
yield [2, "Bob", 87.3, False]
yield [3, "Charlie", None, True]
db["mixed"].insert_all(data_generator())
rows = list(db["mixed"].rows)
assert len(rows) == 3
assert rows[0]["score"] == 95.5
assert rows[1]["active"] == 0 # SQLite stores boolean as int
assert rows[2]["score"] is None
def test_list_mode_error_non_string_columns():
"""Test that non-string column names raise an error"""
db = Database(memory=True)
def bad_data():
yield [1, 2, 3] # Non-string column names
yield ["a", "b", "c"]
with pytest.raises(ValueError, match="must be a list of column name strings"):
db["bad"].insert_all(bad_data())
def test_list_mode_error_mixed_types():
"""Test that mixing list and dict raises an error"""
db = Database(memory=True)
def bad_data():
yield ["id", "name"]
yield {"id": 1, "name": "Alice"} # Should be a list, not dict
with pytest.raises(ValueError, match="must also be lists"):
db["bad"].insert_all(bad_data())
def test_list_mode_empty_after_headers():
"""Test that only headers without data works gracefully"""
db = Database(memory=True)
def data_generator():
yield ["id", "name", "age"]
# No data rows
result = db["people"].insert_all(data_generator())
assert result is not None
assert not db["people"].exists()
def test_list_mode_batch_processing():
"""Test list mode with large dataset requiring batching"""
db = Database(memory=True)
def large_data():
yield ["id", "value"]
for i in range(1000):
yield [i, f"value_{i}"]
db["large"].insert_all(large_data(), batch_size=100)
count = db.execute("SELECT COUNT(*) as c FROM large").fetchone()[0]
assert count == 1000
def test_list_mode_shorter_rows():
"""Test that rows shorter than column list get NULL values"""
db = Database(memory=True)
def data_generator():
yield ["id", "name", "age", "city"]
yield [1, "Alice", 30, "NYC"]
yield [2, "Bob"] # Missing age and city
yield [3, "Charlie", 35] # Missing city
db["people"].insert_all(data_generator())
rows = list(db["people"].rows_where(order_by="id"))
assert rows[0] == {"id": 1, "name": "Alice", "age": 30, "city": "NYC"}
assert rows[1] == {"id": 2, "name": "Bob", "age": None, "city": None}
assert rows[2] == {"id": 3, "name": "Charlie", "age": 35, "city": None}
def test_backwards_compatibility_dict_mode():
"""Ensure dict mode still works (backward compatibility)"""
db = Database(memory=True)
# Traditional dict-based insert
data = [
{"id": 1, "name": "Alice", "age": 30},
{"id": 2, "name": "Bob", "age": 25},
]
db["people"].insert_all(data)
rows = list(db["people"].rows)
assert len(rows) == 2
assert rows[0] == {"id": 1, "name": "Alice", "age": 30}
def test_insert_all_tuple_mode_basic():
"""Test basic insert_all with tuple-based iteration"""
db = Database(memory=True)
def data_generator():
# First yield column names as tuple
yield ("id", "name", "age")
# Then yield data rows as tuples
yield (1, "Alice", 30)
yield (2, "Bob", 25)
yield (3, "Charlie", 35)
db["people"].insert_all(data_generator())
rows = list(db["people"].rows)
assert len(rows) == 3
assert rows[0] == {"id": 1, "name": "Alice", "age": 30}
assert rows[1] == {"id": 2, "name": "Bob", "age": 25}
assert rows[2] == {"id": 3, "name": "Charlie", "age": 35}
def test_insert_all_mixed_list_tuple():
"""Test insert_all with mixed lists and tuples for data rows"""
db = Database(memory=True)
def data_generator():
# Column names as list
yield ["id", "name", "age"]
# Mix of list and tuple data rows
yield [1, "Alice", 30]
yield (2, "Bob", 25)
yield [3, "Charlie", 35]
yield (4, "Diana", 40)
db["people"].insert_all(data_generator())
rows = list(db["people"].rows)
assert len(rows) == 4
assert rows[0] == {"id": 1, "name": "Alice", "age": 30}
assert rows[1] == {"id": 2, "name": "Bob", "age": 25}
assert rows[2] == {"id": 3, "name": "Charlie", "age": 35}
assert rows[3] == {"id": 4, "name": "Diana", "age": 40}
def test_upsert_all_tuple_mode():
"""Test upsert_all with tuple-based iteration"""
db = Database(memory=True)
# Initial insert with tuples
def initial_data():
yield ("id", "name", "value")
yield (1, "Alice", 100)
yield (2, "Bob", 200)
db["data"].insert_all(initial_data(), pk="id")
# Upsert with tuples
def upsert_data():
yield ("id", "name", "value")
yield (1, "Alice", 150) # Update existing
yield (3, "Charlie", 300) # Insert new
db["data"].upsert_all(upsert_data(), pk="id")
rows = list(db["data"].rows_where(order_by="id"))
assert len(rows) == 3
assert rows[0] == {"id": 1, "name": "Alice", "value": 150}
assert rows[1] == {"id": 2, "name": "Bob", "value": 200}
assert rows[2] == {"id": 3, "name": "Charlie", "value": 300}
def test_tuple_mode_shorter_rows():
"""Test that tuple rows shorter than column list get NULL values"""
db = Database(memory=True)
def data_generator():
yield "id", "name", "age", "city"
yield 1, "Alice", 30, "NYC"
yield 2, "Bob" # Missing age and city
yield 3, "Charlie", 35 # Missing city
db["people"].insert_all(data_generator())
rows = list(db["people"].rows_where(order_by="id"))
assert rows[0] == {"id": 1, "name": "Alice", "age": 30, "city": "NYC"}
assert rows[1] == {"id": 2, "name": "Bob", "age": None, "city": None}
assert rows[2] == {"id": 3, "name": "Charlie", "age": 35, "city": None}
def test_list_mode_single_record_upsert_last_pk():
"""Test that last_pk is populated correctly for single-record upserts in list mode"""
db = Database(memory=True)
# Create table first
db["data"].insert({"id": 1, "name": "Alice", "value": 100}, pk="id")
# Now upsert a single record using list mode
def upsert_data():
yield ["id", "name", "value"]
yield [1, "Alice", 150] # Update existing
table = db["data"]
table.upsert_all(upsert_data(), pk="id")
# Verify the data was updated
rows = list(db["data"].rows)
assert rows == [{"id": 1, "name": "Alice", "value": 150}]
# Verify last_pk is populated correctly
assert table.last_pk == 1

View file

@ -66,3 +66,118 @@ def test_lookup_fails_if_constraint_cannot_be_added(fresh_db):
# This will fail because the name column is not unique
with pytest.raises(Exception, match="UNIQUE constraint failed"):
species.lookup({"name": "Palm"})
def test_lookup_with_extra_values(fresh_db):
species = fresh_db["species"]
id = species.lookup({"name": "Palm", "type": "Tree"}, {"first_seen": "2020-01-01"})
assert species.get(id) == {
"id": 1,
"name": "Palm",
"type": "Tree",
"first_seen": "2020-01-01",
}
# A subsequent lookup() should ignore the second dictionary
id2 = species.lookup({"name": "Palm", "type": "Tree"}, {"first_seen": "2021-02-02"})
assert id2 == id
assert species.get(id2) == {
"id": 1,
"name": "Palm",
"type": "Tree",
"first_seen": "2020-01-01",
}
def test_lookup_with_extra_insert_parameters(fresh_db):
other_table = fresh_db["other_table"]
other_table.insert({"id": 1, "name": "Name"}, pk="id")
species = fresh_db["species"]
id = species.lookup(
{"name": "Palm", "type": "Tree"},
{
"first_seen": "2020-01-01",
"make_not_null": 1,
"fk_to_other": 1,
"default_is_dog": "cat",
"extract_this": "This is extracted",
"convert_to_upper": "upper",
"make_this_integer": "2",
"this_at_front": 1,
},
pk="renamed_id",
foreign_keys=(("fk_to_other", "other_table", "id"),),
column_order=("this_at_front",),
not_null={"make_not_null"},
defaults={"default_is_dog": "dog"},
extracts=["extract_this"],
conversions={"convert_to_upper": "upper(?)"},
columns={"make_this_integer": int},
)
assert species.schema == (
'CREATE TABLE "species" (\n'
' "renamed_id" INTEGER PRIMARY KEY,\n'
' "this_at_front" INTEGER,\n'
' "name" TEXT,\n'
' "type" TEXT,\n'
' "first_seen" TEXT,\n'
' "make_not_null" INTEGER NOT NULL,\n'
' "fk_to_other" INTEGER REFERENCES "other_table"("id"),\n'
" \"default_is_dog\" TEXT DEFAULT 'dog',\n"
' "extract_this" INTEGER REFERENCES "extract_this"("id"),\n'
' "convert_to_upper" TEXT,\n'
' "make_this_integer" INTEGER\n'
")"
)
assert species.get(id) == {
"renamed_id": id,
"this_at_front": 1,
"name": "Palm",
"type": "Tree",
"first_seen": "2020-01-01",
"make_not_null": 1,
"fk_to_other": 1,
"default_is_dog": "cat",
"extract_this": 1,
"convert_to_upper": "UPPER",
"make_this_integer": 2,
}
assert species.indexes == [
Index(
seq=0,
name="idx_species_name_type",
unique=1,
origin="c",
partial=0,
columns=["name", "type"],
)
]
@pytest.mark.parametrize("strict", (False, True))
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

@ -109,9 +109,9 @@ def test_m2m_with_table_objects(fresh_db):
)
expected_tables = {"dogs", "humans", "dogs_humans"}
assert expected_tables == set(fresh_db.table_names())
assert 1 == dogs.count
assert 2 == humans.count
assert 2 == fresh_db["dogs_humans"].count
assert dogs.count == 1
assert humans.count == 2
assert fresh_db["dogs_humans"].count == 2
def test_m2m_lookup(fresh_db):
@ -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()

128
tests/test_plugins.py Normal file
View file

@ -0,0 +1,128 @@
from click.testing import CliRunner
import click
import importlib
import pytest
import sys
from sqlite_utils import cli, Database, hookimpl, plugins
def _supports_pragma_function_list():
db = Database(memory=True)
try:
db.execute("select * from pragma_function_list()")
return True
except Exception:
return False
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():
importlib.reload(cli)
assert plugins.get_plugins() == []
class HelloWorldPlugin:
__name__ = "HelloWorldPlugin"
@hookimpl
def register_commands(self, cli):
@cli.command(name="hello-world")
def hello_world():
"Print hello world"
click.echo("Hello world!")
try:
plugins.pm.register(HelloWorldPlugin(), name="HelloWorldPlugin")
importlib.reload(cli)
assert plugins.get_plugins() == [
{"name": "HelloWorldPlugin", "hooks": ["register_commands"]}
]
runner = CliRunner()
result = runner.invoke(cli.cli, ["hello-world"])
assert result.exit_code == 0
assert result.output == "Hello world!\n"
finally:
plugins.pm.unregister(name="HelloWorldPlugin")
importlib.reload(cli)
assert plugins.get_plugins() == []
@pytest.mark.skipif(
not _supports_pragma_function_list(),
reason="Needs SQLite version that supports pragma_function_list()",
)
def test_prepare_connection():
importlib.reload(cli)
assert plugins.get_plugins() == []
class HelloFunctionPlugin:
__name__ = "HelloFunctionPlugin"
@hookimpl
def prepare_connection(self, conn):
conn.create_function("hello", 1, lambda name: f"Hello, {name}!")
db = Database(memory=True)
def _functions(db):
return [
row[0]
for row in db.execute(
"select distinct name from pragma_function_list() order by 1"
).fetchall()
]
assert "hello" not in _functions(db)
try:
plugins.pm.register(HelloFunctionPlugin(), name="HelloFunctionPlugin")
assert plugins.get_plugins() == [
{"name": "HelloFunctionPlugin", "hooks": ["prepare_connection"]}
]
db = Database(memory=True)
assert "hello" in _functions(db)
result = db.execute('select hello("world")').fetchone()[0]
assert result == "Hello, world!"
# Test execute_plugins=False
db2 = Database(memory=True, execute_plugins=False)
assert "hello" not in _functions(db2)
finally:
plugins.pm.unregister(name="HelloFunctionPlugin")
assert plugins.get_plugins() == []

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

@ -1,4 +1,5 @@
from sqlite_utils import recipes
from sqlite_utils.utils import sqlite3
import json
import pytest
@ -61,6 +62,39 @@ def test_dayfirst_yearfirst(fresh_db, recipe, kwargs, expected):
]
@pytest.mark.filterwarnings("ignore::pytest.PytestUnraisableExceptionWarning")
@pytest.mark.parametrize("fn", ("parsedate", "parsedatetime"))
def test_dateparse_errors_raises(fresh_db, fn):
"""Test that invalid dates raise errors when errors=None"""
fresh_db["example"].insert_all(
[
{"id": 1, "dt": "invalid"},
],
pk="id",
)
# Exception in SQLite callback surfaces as OperationalError
with pytest.raises(sqlite3.OperationalError):
fresh_db["example"].convert("dt", lambda value: getattr(recipes, fn)(value))
@pytest.mark.parametrize("fn", ("parsedate", "parsedatetime"))
@pytest.mark.parametrize("errors", (recipes.SET_NULL, recipes.IGNORE))
def test_dateparse_errors_handled(fresh_db, fn, errors):
"""Test error handling modes for invalid dates"""
fresh_db["example"].insert_all(
[
{"id": 1, "dt": "invalid"},
],
pk="id",
)
fresh_db["example"].convert(
"dt", lambda value: getattr(recipes, fn)(value, errors=errors)
)
rows = list(fresh_db["example"].rows)
expected = [{"id": 1, "dt": None if errors is recipes.SET_NULL else "invalid"}]
assert rows == expected
@pytest.mark.parametrize("delimiter", [None, ";", "-"])
def test_jsonsplit(fresh_db, delimiter):
fresh_db["example"].insert_all(
@ -70,12 +104,14 @@ def test_jsonsplit(fresh_db, delimiter):
],
pk="id",
)
fn = recipes.jsonsplit
if delimiter is not None:
def fn(value):
return recipes.jsonsplit(value, delimiter=delimiter)
else:
fn = recipes.jsonsplit
fresh_db["example"].convert("tags", fn)
assert list(fresh_db["example"].rows) == [
{"id": 1, "tags": '["foo", "bar"]'},
@ -98,11 +134,13 @@ def test_jsonsplit_type(fresh_db, type, expected):
],
pk="id",
)
fn = recipes.jsonsplit
if type is not None:
def fn(value):
return recipes.jsonsplit(value, type=type)
else:
fn = recipes.jsonsplit
fresh_db["example"].convert("records", fn)
assert json.loads(fresh_db["example"].get(1)["records"]) == expected

View file

@ -14,19 +14,25 @@ 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(
"use_path,file_exists", [(True, True), (True, False), (False, True), (False, False)]
"use_path,create_file_first",
[(True, True), (True, False), (False, True), (False, False)],
)
def test_recreate(tmpdir, use_path, file_exists):
filepath = str(tmpdir / "data.db")
def test_recreate(tmp_path, use_path, create_file_first):
filepath = str(tmp_path / "data.db")
if use_path:
filepath = pathlib.Path(filepath)
if file_exists:
Database(filepath)["t1"].insert({"foo": "bar"})
assert ["t1"] == Database(filepath).table_names()
if create_file_first:
db = Database(filepath)
db["t1"].insert({"foo": "bar"})
assert ["t1"] == db.table_names()
db.close()
Database(filepath, recreate=True)["t2"].insert({"foo": "bar"})
assert ["t2"] == Database(filepath).table_names()

View file

@ -1,7 +1,8 @@
# flake8: noqa
import pytest
import sys
from unittest.mock import MagicMock
from unittest.mock import MagicMock, call
from sqlite_utils.utils import sqlite3
def test_register_function(fresh_db):
@ -13,6 +14,15 @@ def test_register_function(fresh_db):
assert result == "olleh"
def test_register_function_custom_name(fresh_db):
@fresh_db.register_function(name="revstr")
def reverse_string(s):
return "".join(reversed(list(s)))
result = fresh_db.execute('select revstr("hello")').fetchone()[0]
assert result == "olleh"
def test_register_function_multiple_arguments(fresh_db):
@fresh_db.register_function
def a_times_b_plus_c(a, b, c):
@ -31,20 +41,47 @@ def test_register_function_deterministic(fresh_db):
assert result == "bob"
@pytest.mark.skipif(
sys.version_info < (3, 8), reason="deterministic=True was added in Python 3.8"
)
def test_register_function_deterministic_registered(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()
fresh_db.conn.create_function.assert_called_with(
"to_lower_2", 1, to_lower_2, deterministic=True
)
first = True
def side_effect(*args, **kwargs):
# Raise exception only first time this is called
nonlocal first
if first:
first = False
raise sqlite3.NotSupportedError()
# 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
@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):
@ -54,7 +91,7 @@ def test_register_function_replace(fresh_db):
assert "one" == fresh_db.execute("select one()").fetchone()[0]
# This will fail to replace the function:
# This will silently fail to replaec the function
@fresh_db.register_function()
def one(): # noqa
return "two"

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

@ -0,0 +1,54 @@
from sqlite_utils.utils import rows_from_file, Format, RowError
from io import BytesIO, StringIO
import pytest
@pytest.mark.parametrize(
"input,expected_format",
(
(b"id,name\n1,Cleo", Format.CSV),
(b"id\tname\n1\tCleo", Format.TSV),
(b'[{"id": "1", "name": "Cleo"}]', Format.JSON),
),
)
def test_rows_from_file_detect_format(input, expected_format):
rows, format = rows_from_file(BytesIO(input))
assert format == expected_format
rows_list = list(rows)
assert rows_list == [{"id": "1", "name": "Cleo"}]
@pytest.mark.parametrize(
"ignore_extras,extras_key,expected",
(
(True, None, [{"id": "1", "name": "Cleo"}]),
(False, "_rest", [{"id": "1", "name": "Cleo", "_rest": ["oops"]}]),
# expected of None means expect an error:
(False, False, None),
),
)
def test_rows_from_file_extra_fields_strategies(ignore_extras, extras_key, expected):
try:
rows, format = rows_from_file(
BytesIO(b"id,name\r\n1,Cleo,oops"),
format=Format.CSV,
ignore_extras=ignore_extras,
extras_key=extras_key,
)
list_rows = list(rows)
except RowError:
if expected is None:
# This is fine,
return
else:
# We did not expect an error
raise
assert list_rows == expected
def test_rows_from_file_error_on_string_io():
with pytest.raises(TypeError) as ex:
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,13 +6,13 @@ 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()
result = runner.invoke(
cli.cli,
["insert", db_path, "creatures", str(filepath), "--sniff"],
["insert", db_path, "creatures", str(filepath), "--sniff", "--no-detect-types"],
catch_exceptions=False,
)
assert result.exit_code == 0, result.stdout

View file

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

View file

@ -1,4 +1,6 @@
from sqlite_utils.db import ForeignKey
import sqlite3
from sqlite_utils.db import ForeignKey, TransactionError, TransformError
from sqlite_utils.utils import OperationalError
import pytest
@ -10,80 +12,90 @@ import pytest
(
{},
[
"CREATE TABLE [dogs_new_suffix] (\n [id] INTEGER PRIMARY KEY,\n [name] TEXT,\n [age] TEXT\n);",
"INSERT INTO [dogs_new_suffix] ([id], [name], [age])\n SELECT [id], [name], [age] FROM [dogs];",
"DROP TABLE [dogs];",
"ALTER TABLE [dogs_new_suffix] RENAME TO [dogs];",
'CREATE TABLE "dogs_new_suffix" (\n "id" INTEGER PRIMARY KEY,\n "name" TEXT,\n "age" TEXT\n);',
'INSERT INTO "dogs_new_suffix" ("rowid", "id", "name", "age")\n SELECT "rowid", "id", "name", "age" FROM "dogs";',
'DROP TABLE "dogs";',
'ALTER TABLE "dogs_new_suffix" RENAME TO "dogs";',
],
),
# Change column type
(
{"types": {"age": int}},
[
"CREATE TABLE [dogs_new_suffix] (\n [id] INTEGER PRIMARY KEY,\n [name] TEXT,\n [age] INTEGER\n);",
"INSERT INTO [dogs_new_suffix] ([id], [name], [age])\n SELECT [id], [name], [age] FROM [dogs];",
"DROP TABLE [dogs];",
"ALTER TABLE [dogs_new_suffix] RENAME TO [dogs];",
'CREATE TABLE "dogs_new_suffix" (\n "id" INTEGER PRIMARY KEY,\n "name" TEXT,\n "age" INTEGER\n);',
'INSERT INTO "dogs_new_suffix" ("rowid", "id", "name", "age")\n SELECT "rowid", "id", "name", "age" FROM "dogs";',
'DROP TABLE "dogs";',
'ALTER TABLE "dogs_new_suffix" RENAME TO "dogs";',
],
),
# Rename a column
(
{"rename": {"age": "dog_age"}},
[
"CREATE TABLE [dogs_new_suffix] (\n [id] INTEGER PRIMARY KEY,\n [name] TEXT,\n [dog_age] TEXT\n);",
"INSERT INTO [dogs_new_suffix] ([id], [name], [dog_age])\n SELECT [id], [name], [age] FROM [dogs];",
"DROP TABLE [dogs];",
"ALTER TABLE [dogs_new_suffix] RENAME TO [dogs];",
'CREATE TABLE "dogs_new_suffix" (\n "id" INTEGER PRIMARY KEY,\n "name" TEXT,\n "dog_age" TEXT\n);',
'INSERT INTO "dogs_new_suffix" ("rowid", "id", "name", "dog_age")\n SELECT "rowid", "id", "name", "age" FROM "dogs";',
'DROP TABLE "dogs";',
'ALTER TABLE "dogs_new_suffix" RENAME TO "dogs";',
],
),
# Drop a column
(
{"drop": ["age"]},
[
"CREATE TABLE [dogs_new_suffix] (\n [id] INTEGER PRIMARY KEY,\n [name] TEXT\n);",
"INSERT INTO [dogs_new_suffix] ([id], [name])\n SELECT [id], [name] FROM [dogs];",
"DROP TABLE [dogs];",
"ALTER TABLE [dogs_new_suffix] RENAME TO [dogs];",
'CREATE TABLE "dogs_new_suffix" (\n "id" INTEGER PRIMARY KEY,\n "name" TEXT\n);',
'INSERT INTO "dogs_new_suffix" ("rowid", "id", "name")\n SELECT "rowid", "id", "name" FROM "dogs";',
'DROP TABLE "dogs";',
'ALTER TABLE "dogs_new_suffix" RENAME TO "dogs";',
],
),
# Convert type AND rename column
(
{"types": {"age": int}, "rename": {"age": "dog_age"}},
[
"CREATE TABLE [dogs_new_suffix] (\n [id] INTEGER PRIMARY KEY,\n [name] TEXT,\n [dog_age] INTEGER\n);",
"INSERT INTO [dogs_new_suffix] ([id], [name], [dog_age])\n SELECT [id], [name], [age] FROM [dogs];",
"DROP TABLE [dogs];",
"ALTER TABLE [dogs_new_suffix] RENAME TO [dogs];",
'CREATE TABLE "dogs_new_suffix" (\n "id" INTEGER PRIMARY KEY,\n "name" TEXT,\n "dog_age" INTEGER\n);',
'INSERT INTO "dogs_new_suffix" ("rowid", "id", "name", "dog_age")\n SELECT "rowid", "id", "name", "age" FROM "dogs";',
'DROP TABLE "dogs";',
'ALTER TABLE "dogs_new_suffix" RENAME TO "dogs";',
],
),
# Change primary key
(
{"pk": "age"},
[
"CREATE TABLE [dogs_new_suffix] (\n [id] INTEGER,\n [name] TEXT,\n [age] TEXT PRIMARY KEY\n);",
"INSERT INTO [dogs_new_suffix] ([id], [name], [age])\n SELECT [id], [name], [age] FROM [dogs];",
"DROP TABLE [dogs];",
"ALTER TABLE [dogs_new_suffix] RENAME TO [dogs];",
'CREATE TABLE "dogs_new_suffix" (\n "id" INTEGER,\n "name" TEXT,\n "age" TEXT PRIMARY KEY\n);',
'INSERT INTO "dogs_new_suffix" ("rowid", "id", "name", "age")\n SELECT "rowid", "id", "name", "age" FROM "dogs";',
'DROP TABLE "dogs";',
'ALTER TABLE "dogs_new_suffix" RENAME TO "dogs";',
],
),
# Change primary key to a compound pk
(
{"pk": ("age", "name")},
[
"CREATE TABLE [dogs_new_suffix] (\n [id] INTEGER,\n [name] TEXT,\n [age] TEXT,\n PRIMARY KEY ([age], [name])\n);",
"INSERT INTO [dogs_new_suffix] ([id], [name], [age])\n SELECT [id], [name], [age] FROM [dogs];",
"DROP TABLE [dogs];",
"ALTER TABLE [dogs_new_suffix] RENAME TO [dogs];",
'CREATE TABLE "dogs_new_suffix" (\n "id" INTEGER,\n "name" TEXT,\n "age" TEXT,\n PRIMARY KEY ("age", "name")\n);',
'INSERT INTO "dogs_new_suffix" ("rowid", "id", "name", "age")\n SELECT "rowid", "id", "name", "age" FROM "dogs";',
'DROP TABLE "dogs";',
'ALTER TABLE "dogs_new_suffix" RENAME TO "dogs";',
],
),
# Remove primary key, creating a rowid table
(
{"pk": None},
[
"CREATE TABLE [dogs_new_suffix] (\n [id] INTEGER,\n [name] TEXT,\n [age] TEXT\n);",
"INSERT INTO [dogs_new_suffix] ([id], [name], [age])\n SELECT [id], [name], [age] FROM [dogs];",
"DROP TABLE [dogs];",
"ALTER TABLE [dogs_new_suffix] RENAME TO [dogs];",
'CREATE TABLE "dogs_new_suffix" (\n "id" INTEGER,\n "name" TEXT,\n "age" TEXT\n);',
'INSERT INTO "dogs_new_suffix" ("rowid", "id", "name", "age")\n SELECT "rowid", "id", "name", "age" FROM "dogs";',
'DROP TABLE "dogs";',
'ALTER TABLE "dogs_new_suffix" RENAME TO "dogs";',
],
),
# Keeping the table
(
{"drop": ["age"], "keep_table": "kept_table"},
[
'CREATE TABLE "dogs_new_suffix" (\n "id" INTEGER PRIMARY KEY,\n "name" TEXT\n);',
'INSERT INTO "dogs_new_suffix" ("rowid", "id", "name")\n SELECT "rowid", "id", "name" FROM "dogs";',
'ALTER TABLE "dogs" RENAME TO "kept_table";',
'ALTER TABLE "dogs_new_suffix" RENAME TO "dogs";',
],
),
],
@ -123,40 +135,40 @@ def test_transform_sql_table_with_primary_key(
(
{},
[
"CREATE TABLE [dogs_new_suffix] (\n [id] INTEGER,\n [name] TEXT,\n [age] TEXT\n);",
"INSERT INTO [dogs_new_suffix] ([id], [name], [age])\n SELECT [id], [name], [age] FROM [dogs];",
"DROP TABLE [dogs];",
"ALTER TABLE [dogs_new_suffix] RENAME TO [dogs];",
'CREATE TABLE "dogs_new_suffix" (\n "id" INTEGER,\n "name" TEXT,\n "age" TEXT\n);',
'INSERT INTO "dogs_new_suffix" ("rowid", "id", "name", "age")\n SELECT "rowid", "id", "name", "age" FROM "dogs";',
'DROP TABLE "dogs";',
'ALTER TABLE "dogs_new_suffix" RENAME TO "dogs";',
],
),
# Change column type
(
{"types": {"age": int}},
[
"CREATE TABLE [dogs_new_suffix] (\n [id] INTEGER,\n [name] TEXT,\n [age] INTEGER\n);",
"INSERT INTO [dogs_new_suffix] ([id], [name], [age])\n SELECT [id], [name], [age] FROM [dogs];",
"DROP TABLE [dogs];",
"ALTER TABLE [dogs_new_suffix] RENAME TO [dogs];",
'CREATE TABLE "dogs_new_suffix" (\n "id" INTEGER,\n "name" TEXT,\n "age" INTEGER\n);',
'INSERT INTO "dogs_new_suffix" ("rowid", "id", "name", "age")\n SELECT "rowid", "id", "name", "age" FROM "dogs";',
'DROP TABLE "dogs";',
'ALTER TABLE "dogs_new_suffix" RENAME TO "dogs";',
],
),
# Rename a column
(
{"rename": {"age": "dog_age"}},
[
"CREATE TABLE [dogs_new_suffix] (\n [id] INTEGER,\n [name] TEXT,\n [dog_age] TEXT\n);",
"INSERT INTO [dogs_new_suffix] ([id], [name], [dog_age])\n SELECT [id], [name], [age] FROM [dogs];",
"DROP TABLE [dogs];",
"ALTER TABLE [dogs_new_suffix] RENAME TO [dogs];",
'CREATE TABLE "dogs_new_suffix" (\n "id" INTEGER,\n "name" TEXT,\n "dog_age" TEXT\n);',
'INSERT INTO "dogs_new_suffix" ("rowid", "id", "name", "dog_age")\n SELECT "rowid", "id", "name", "age" FROM "dogs";',
'DROP TABLE "dogs";',
'ALTER TABLE "dogs_new_suffix" RENAME TO "dogs";',
],
),
# Make ID a primary key
(
{"pk": "id"},
[
"CREATE TABLE [dogs_new_suffix] (\n [id] INTEGER PRIMARY KEY,\n [name] TEXT,\n [age] TEXT\n);",
"INSERT INTO [dogs_new_suffix] ([id], [name], [age])\n SELECT [id], [name], [age] FROM [dogs];",
"DROP TABLE [dogs];",
"ALTER TABLE [dogs_new_suffix] RENAME TO [dogs];",
'CREATE TABLE "dogs_new_suffix" (\n "id" INTEGER PRIMARY KEY,\n "name" TEXT,\n "age" TEXT\n);',
'INSERT INTO "dogs_new_suffix" ("rowid", "id", "name", "age")\n SELECT "rowid", "id", "name", "age" FROM "dogs";',
'DROP TABLE "dogs";',
'ALTER TABLE "dogs_new_suffix" RENAME TO "dogs";',
],
),
],
@ -194,13 +206,13 @@ def test_transform_sql_with_no_primary_key_to_primary_key_of_id(fresh_db):
dogs.insert({"id": 1, "name": "Cleo", "age": "5"})
assert (
dogs.schema
== "CREATE TABLE [dogs] (\n [id] INTEGER,\n [name] TEXT,\n [age] TEXT\n)"
== 'CREATE TABLE "dogs" (\n "id" INTEGER,\n "name" TEXT,\n "age" TEXT\n)'
)
dogs.transform(pk="id")
# Slight oddity: [dogs] becomes "dogs" during the rename:
assert (
dogs.schema
== 'CREATE TABLE "dogs" (\n [id] INTEGER PRIMARY KEY,\n [name] TEXT,\n [age] TEXT\n)'
== 'CREATE TABLE "dogs" (\n "id" INTEGER PRIMARY KEY,\n "name" TEXT,\n "age" TEXT\n)'
)
@ -210,17 +222,51 @@ def test_transform_rename_pk(fresh_db):
dogs.transform(rename={"id": "pk"})
assert (
dogs.schema
== 'CREATE TABLE "dogs" (\n [pk] INTEGER PRIMARY KEY,\n [name] TEXT,\n [age] TEXT\n)'
== 'CREATE TABLE "dogs" (\n "pk" INTEGER PRIMARY KEY,\n "name" TEXT,\n "age" TEXT\n)'
)
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")
dogs.transform(not_null={"name"})
assert (
dogs.schema
== 'CREATE TABLE "dogs" (\n [id] INTEGER PRIMARY KEY,\n [name] TEXT NOT NULL,\n [age] TEXT\n)'
== 'CREATE TABLE "dogs" (\n "id" INTEGER PRIMARY KEY,\n "name" TEXT NOT NULL,\n "age" TEXT\n)'
)
@ -230,7 +276,7 @@ def test_transform_remove_a_not_null(fresh_db):
dogs.transform(not_null={"name": True, "age": False})
assert (
dogs.schema
== 'CREATE TABLE "dogs" (\n [id] INTEGER PRIMARY KEY,\n [name] TEXT NOT NULL,\n [age] TEXT\n)'
== 'CREATE TABLE "dogs" (\n "id" INTEGER PRIMARY KEY,\n "name" TEXT NOT NULL,\n "age" TEXT\n)'
)
@ -241,7 +287,7 @@ def test_transform_add_not_null_with_rename(fresh_db, not_null):
dogs.transform(not_null=not_null, rename={"age": "dog_age"})
assert (
dogs.schema
== 'CREATE TABLE "dogs" (\n [id] INTEGER PRIMARY KEY,\n [name] TEXT,\n [dog_age] TEXT NOT NULL\n)'
== 'CREATE TABLE "dogs" (\n "id" INTEGER PRIMARY KEY,\n "name" TEXT,\n "dog_age" TEXT NOT NULL\n)'
)
@ -251,7 +297,7 @@ def test_transform_defaults(fresh_db):
dogs.transform(defaults={"age": 1})
assert (
dogs.schema
== 'CREATE TABLE "dogs" (\n [id] INTEGER PRIMARY KEY,\n [name] TEXT,\n [age] INTEGER DEFAULT 1\n)'
== 'CREATE TABLE "dogs" (\n "id" INTEGER PRIMARY KEY,\n "name" TEXT,\n "age" INTEGER DEFAULT 1\n)'
)
@ -261,7 +307,7 @@ def test_transform_defaults_and_rename_column(fresh_db):
dogs.transform(rename={"age": "dog_age"}, defaults={"age": 1})
assert (
dogs.schema
== 'CREATE TABLE "dogs" (\n [id] INTEGER PRIMARY KEY,\n [name] TEXT,\n [dog_age] INTEGER DEFAULT 1\n)'
== 'CREATE TABLE "dogs" (\n "id" INTEGER PRIMARY KEY,\n "name" TEXT,\n "dog_age" INTEGER DEFAULT 1\n)'
)
@ -271,7 +317,7 @@ def test_remove_defaults(fresh_db):
dogs.transform(defaults={"age": None})
assert (
dogs.schema
== 'CREATE TABLE "dogs" (\n [id] INTEGER PRIMARY KEY,\n [name] TEXT,\n [age] INTEGER\n)'
== 'CREATE TABLE "dogs" (\n "id" INTEGER PRIMARY KEY,\n "name" TEXT,\n "age" INTEGER\n)'
)
@ -319,22 +365,29 @@ def test_transform_foreign_keys_survive_renamed_column(
]
def _add_country_city_continent(db):
db["country"].insert({"id": 1, "name": "France"}, pk="id")
db["continent"].insert({"id": 2, "name": "Europe"}, pk="id")
db["city"].insert({"id": 24, "name": "Paris"}, pk="id")
_CAVEAU = {
"id": 32,
"name": "Caveau de la Huchette",
"country": 1,
"continent": 2,
"city": 24,
}
@pytest.mark.parametrize("use_pragma_foreign_keys", [False, True])
def test_transform_drop_foreign_keys(fresh_db, use_pragma_foreign_keys):
if use_pragma_foreign_keys:
fresh_db.conn.execute("PRAGMA foreign_keys=ON")
# Create table with three foreign keys so we can drop two of them
fresh_db["country"].insert({"id": 1, "name": "France"}, pk="id")
fresh_db["continent"].insert({"id": 2, "name": "Europe"}, pk="id")
fresh_db["city"].insert({"id": 24, "name": "Paris"}, pk="id")
_add_country_city_continent(fresh_db)
fresh_db["places"].insert(
{
"id": 32,
"name": "Caveau de la Huchette",
"country": 1,
"continent": 2,
"city": 24,
},
_CAVEAU,
foreign_keys=("country", "continent", "city"),
)
assert fresh_db["places"].foreign_keys == [
@ -374,6 +427,480 @@ def test_transform_verify_foreign_keys(fresh_db):
# This should have rolled us back
assert (
fresh_db["authors"].schema
== "CREATE TABLE [authors] (\n [id] INTEGER PRIMARY KEY,\n [name] TEXT\n)"
== 'CREATE TABLE "authors" (\n "id" INTEGER PRIMARY KEY,\n "name" TEXT\n)'
)
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)
# Should have no foreign keys
assert fresh_db["places"].foreign_keys == []
# Now add them using .transform()
fresh_db["places"].transform(add_foreign_keys=("country", "continent", "city"))
# Should now have all three:
assert fresh_db["places"].foreign_keys == [
ForeignKey(
table="places", column="city", other_table="city", other_column="id"
),
ForeignKey(
table="places",
column="continent",
other_table="continent",
other_column="id",
),
ForeignKey(
table="places", column="country", other_table="country", other_column="id"
),
]
assert fresh_db["places"].schema == (
'CREATE TABLE "places" (\n'
' "id" INTEGER,\n'
' "name" TEXT,\n'
' "country" INTEGER REFERENCES "country"("id"),\n'
' "continent" INTEGER REFERENCES "continent"("id"),\n'
' "city" INTEGER REFERENCES "city"("id")\n'
")"
)
@pytest.mark.parametrize(
"add_foreign_keys",
(
("country", "continent"),
# Fully specified
(
("country", "country", "id"),
("continent", "continent", "id"),
),
),
)
def test_transform_add_foreign_keys_from_partial(fresh_db, add_foreign_keys):
_add_country_city_continent(fresh_db)
fresh_db["places"].insert(
_CAVEAU,
foreign_keys=("city",),
)
# Should have one foreign keys
assert fresh_db["places"].foreign_keys == [
ForeignKey(table="places", column="city", other_table="city", other_column="id")
]
# Now add three more using .transform()
fresh_db["places"].transform(add_foreign_keys=add_foreign_keys)
# Should now have all three:
assert fresh_db["places"].foreign_keys == [
ForeignKey(
table="places", column="city", other_table="city", other_column="id"
),
ForeignKey(
table="places",
column="continent",
other_table="continent",
other_column="id",
),
ForeignKey(
table="places", column="country", other_table="country", other_column="id"
),
]
@pytest.mark.parametrize(
"foreign_keys",
(
("country", "continent"),
# Fully specified
(
("country", "country", "id"),
("continent", "continent", "id"),
),
),
)
def test_transform_replace_foreign_keys(fresh_db, foreign_keys):
_add_country_city_continent(fresh_db)
fresh_db["places"].insert(
_CAVEAU,
foreign_keys=("city",),
)
assert len(fresh_db["places"].foreign_keys) == 1
# Replace with two different ones
fresh_db["places"].transform(foreign_keys=foreign_keys)
assert fresh_db["places"].schema == (
'CREATE TABLE "places" (\n'
' "id" INTEGER,\n'
' "name" TEXT,\n'
' "country" INTEGER REFERENCES "country"("id"),\n'
' "continent" INTEGER REFERENCES "continent"("id"),\n'
' "city" INTEGER\n'
")"
)
@pytest.mark.parametrize("table_type", ("id_pk", "rowid", "compound_pk"))
def test_transform_preserves_rowids(fresh_db, table_type):
pk = None
if table_type == "id_pk":
pk = "id"
elif table_type == "compound_pk":
pk = ("id", "name")
elif table_type == "rowid":
pk = None
fresh_db["places"].insert_all(
[
{"id": "1", "name": "Paris", "country": "France"},
{"id": "2", "name": "London", "country": "UK"},
{"id": "3", "name": "New York", "country": "USA"},
],
pk=pk,
)
# Now delete and insert a row to mix up the `rowid` sequence
fresh_db["places"].delete_where("id = ?", ["2"])
fresh_db["places"].insert({"id": "4", "name": "London", "country": "UK"})
previous_rows = list(
tuple(row) for row in fresh_db.execute("select rowid, id, name from places")
)
# Transform it
fresh_db["places"].transform(column_order=("country", "name"))
# Should be the same
next_rows = list(
tuple(row) for row in fresh_db.execute("select rowid, id, name from places")
)
assert previous_rows == next_rows
@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 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(
"indexes, transform_params",
[
([["name"]], {"types": {"age": str}}),
([["name"], ["age", "breed"]], {"types": {"age": str}}),
([], {"types": {"age": str}}),
([["name"]], {"types": {"age": str}, "keep_table": "old_dogs"}),
],
)
def test_transform_indexes(fresh_db, indexes, transform_params):
# https://github.com/simonw/sqlite-utils/issues/633
# New table should have same indexes as old table after transformation
dogs = fresh_db["dogs"]
dogs.insert({"id": 1, "name": "Cleo", "age": 5, "breed": "Labrador"}, pk="id")
for index in indexes:
dogs.create_index(index)
indexes_before_transform = dogs.indexes
dogs.transform(**transform_params)
assert sorted(
[
{k: v for k, v in idx._asdict().items() if k != "seq"}
for idx in dogs.indexes
],
key=lambda x: x["name"],
) == sorted(
[
{k: v for k, v in idx._asdict().items() if k != "seq"}
for idx in indexes_before_transform
],
key=lambda x: x["name"],
), f"Indexes before transform: {indexes_before_transform}\nIndexes after transform: {dogs.indexes}"
if "keep_table" in transform_params:
assert all(
index.origin == "pk"
for index in fresh_db[transform_params["keep_table"]].indexes
)
def test_transform_retains_indexes_with_foreign_keys(fresh_db):
dogs = fresh_db["dogs"]
owners = fresh_db["owners"]
dogs.insert({"id": 1, "name": "Cleo", "owner_id": 1}, pk="id")
owners.insert({"id": 1, "name": "Alice"}, pk="id")
dogs.create_index(["name"])
indexes_before_transform = dogs.indexes
fresh_db.add_foreign_keys([("dogs", "owner_id", "owners", "id")]) # calls transform
assert sorted(
[
{k: v for k, v in idx._asdict().items() if k != "seq"}
for idx in dogs.indexes
],
key=lambda x: x["name"],
) == sorted(
[
{k: v for k, v in idx._asdict().items() if k != "seq"}
for idx in indexes_before_transform
],
key=lambda x: x["name"],
), f"Indexes before transform: {indexes_before_transform}\nIndexes after transform: {dogs.indexes}"
@pytest.mark.parametrize(
"transform_params",
[
{"rename": {"age": "dog_age"}},
{"drop": ["age"]},
],
)
def test_transform_with_indexes_errors(fresh_db, transform_params):
# Should error with a compound (name, age) index if age is renamed or dropped
dogs = fresh_db["dogs"]
dogs.insert({"id": 1, "name": "Cleo", "age": 5}, pk="id")
dogs.create_index(["name", "age"])
with pytest.raises(TransformError) as excinfo:
dogs.transform(**transform_params)
assert (
"Index 'idx_dogs_name_age' column 'age' is not in updated table 'dogs'. "
"You must manually drop this index prior to running this transformation"
in str(excinfo.value)
)
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("""
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'
with pytest.raises(TransformError) as excinfo:
dogs.transform(types={"age": str})
assert (
"Index 'sqlite_autoindex_dogs_1' on table 'dogs' does not have a CREATE INDEX statement."
in str(excinfo.value)
)
assert (
"You must manually drop this index prior to running this transformation and manually recreate the new index after running this transformation."
in str(excinfo.value)
)

View file

@ -70,20 +70,21 @@ def test_update_alter(fresh_db):
] == list(table.rows)
def test_update_alter_with_invalid_column_characters(fresh_db):
def test_update_alter_with_special_column_characters(fresh_db):
# With double-quote escaping, columns with special characters are now valid
table = fresh_db["table"]
rowid = table.insert({"foo": "bar"}).last_pk
with pytest.raises(AssertionError):
table.update(rowid, {"new_col[abc]": 1.2}, alter=True)
table.update(rowid, {"new_col[abc]": 1.2}, alter=True)
assert list(table.rows) == [{"foo": "bar", "new_col[abc]": 1.2}]
def test_update_with_no_values_sets_last_pk(fresh_db):
table = fresh_db.table("dogs", pk="id")
table.insert_all([{"id": 1, "name": "Cleo"}, {"id": 2, "name": "Pancakes"}])
table.update(1)
assert 1 == table.last_pk
assert table.last_pk == 1
table.update(2)
assert 2 == table.last_pk
assert table.last_pk == 2
with pytest.raises(NotFoundError):
table.update(3)

View file

@ -1,33 +1,46 @@
from sqlite_utils.db import PrimaryKeyRequired
from sqlite_utils import Database
import pytest
def test_upsert(fresh_db):
table = fresh_db["table"]
@pytest.mark.parametrize("use_old_upsert", (False, True))
def test_upsert(use_old_upsert):
db = Database(memory=True, use_old_upsert=use_old_upsert)
table = db["table"]
table.insert({"id": 1, "name": "Cleo"}, pk="id")
table.upsert({"id": 1, "age": 5}, pk="id", alter=True)
assert [{"id": 1, "name": "Cleo", "age": 5}] == list(table.rows)
assert 1 == table.last_pk
assert list(table.rows) == [{"id": 1, "name": "Cleo", "age": 5}]
assert table.last_pk == 1
def test_upsert_all(fresh_db):
table = fresh_db["table"]
table.upsert_all([{"id": 1, "name": "Cleo"}, {"id": 2, "name": "Nixie"}], pk="id")
table.upsert_all([{"id": 1, "age": 5}, {"id": 2, "age": 5}], pk="id", alter=True)
assert [
assert list(table.rows) == [
{"id": 1, "name": "Cleo", "age": 5},
{"id": 2, "name": "Nixie", "age": 5},
] == list(table.rows)
]
assert table.last_pk is None
def test_upsert_all_single_column(fresh_db):
table = fresh_db["table"]
table.upsert_all([{"name": "Cleo"}], pk="name")
assert [{"name": "Cleo"}] == list(table.rows)
assert list(table.rows) == [{"name": "Cleo"}]
assert table.pks == ["name"]
def test_upsert_all_not_null(fresh_db):
# https://github.com/simonw/sqlite-utils/issues/538
fresh_db["comments"].upsert_all(
[{"id": 1, "name": "Cleo"}],
pk="id",
not_null=["name"],
)
assert list(fresh_db["comments"].rows) == [{"id": 1, "name": "Cleo"}]
def test_upsert_error_if_no_pk(fresh_db):
table = fresh_db["table"]
with pytest.raises(PrimaryKeyRequired):
@ -36,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")
@ -45,6 +141,30 @@ def test_upsert_with_hash_id(fresh_db):
assert "a5e744d0164540d33b1d7ea616c28f2fa97e754a" == table.last_pk
@pytest.mark.parametrize("hash_id", (None, "custom_id"))
def test_upsert_with_hash_id_columns(fresh_db, hash_id):
table = fresh_db["table"]
table.upsert({"a": 1, "b": 2, "c": 3}, hash_id=hash_id, hash_id_columns=("a", "b"))
assert list(table.rows) == [
{
hash_id or "id": "4acc71e0547112eb432f0a36fb1924c4a738cb49",
"a": 1,
"b": 2,
"c": 3,
}
]
assert table.last_pk == "4acc71e0547112eb432f0a36fb1924c4a738cb49"
table.upsert({"a": 1, "b": 2, "c": 4}, hash_id=hash_id, hash_id_columns=("a", "b"))
assert list(table.rows) == [
{
hash_id or "id": "4acc71e0547112eb432f0a36fb1924c4a738cb49",
"a": 1,
"b": 2,
"c": 4,
}
]
def test_upsert_compound_primary_key(fresh_db):
table = fresh_db["table"]
table.upsert_all(

View file

@ -1,4 +1,6 @@
from sqlite_utils import utils
import csv
import io
import pytest
@ -22,6 +24,79 @@ def test_decode_base64_values(input, expected, should_be_is):
assert actual == expected
def test_find_spatialite():
spatialite = utils.find_spatialite()
assert spatialite is None or isinstance(spatialite, str)
@pytest.mark.parametrize(
"size,expected",
(
(1, [["a"], ["b"], ["c"], ["d"]]),
(2, [["a", "b"], ["c", "d"]]),
(3, [["a", "b", "c"], ["d"]]),
(4, [["a", "b", "c", "d"]]),
),
)
def test_chunks(size, expected):
input = ["a", "b", "c", "d"]
chunks = list(map(list, utils.chunks(input, size)))
assert chunks == expected
def test_hash_record():
expected = "d383e7c0ba88f5ffcdd09be660de164b3847401a"
assert utils.hash_record({"name": "Cleo", "twitter": "CleoPaws"}) == expected
assert (
utils.hash_record(
{"name": "Cleo", "twitter": "CleoPaws", "age": 7}, keys=("name", "twitter")
)
== expected
)
assert (
utils.hash_record({"name": "Cleo", "twitter": "CleoPaws", "age": 7}) != expected
)
def test_maximize_csv_field_size_limit():
# Reset to default in case other tests have changed it
csv.field_size_limit(utils.ORIGINAL_CSV_FIELD_SIZE_LIMIT)
long_value = "a" * 131073
long_csv = "id,text\n1,{}".format(long_value)
fp = io.BytesIO(long_csv.encode("utf-8"))
# Using rows_from_file should error
with pytest.raises(csv.Error):
rows, _ = utils.rows_from_file(fp, utils.Format.CSV)
list(rows)
# But if we call maximize_csv_field_size_limit() first it should be OK:
utils.maximize_csv_field_size_limit()
fp2 = io.BytesIO(long_csv.encode("utf-8"))
rows2, _ = utils.rows_from_file(fp2, utils.Format.CSV)
rows_list2 = list(rows2)
assert len(rows_list2) == 1
assert rows_list2[0]["id"] == "1"
assert rows_list2[0]["text"] == long_value
@pytest.mark.parametrize(
"input,expected",
(
({"foo": {"bar": 1}}, {"foo_bar": 1}),
({"foo": {"bar": [1, 2, {"baz": 3}]}}, {"foo_bar": [1, 2, {"baz": 3}]}),
({"foo": {"bar": 1, "baz": {"three": 3}}}, {"foo_bar": 1, "foo_baz_three": 3}),
),
)
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]

View file

@ -1,7 +0,0 @@
import re
COLLAPSE_RE = re.compile(r"\s+")
def collapse_whitespace(s):
return COLLAPSE_RE.sub(" ", s)