Compare commits

..

62 commits

Author SHA1 Message Date
ikatyal2110
6a456830ca
Fix _decode_default_value to unescape doubled single quotes in string defaults (#811)
* Fix _decode_default_value to unescape doubled single quotes in string defaults

SQLite stores string defaults with single quotes doubled (e.g. DEFAULT 'O''Brien'
is stored as the literal "'O''Brien'" in sqlite_master). The previous code
stripped the outer quotes with value[1:-1] but never converted '' back to ',
so default_values returned the raw escaped form instead of the true string value.

* Test for doubled single quotes in string defaults
2026-07-25 21:52:04 -07:00
Simon Willison
a7b734946f Changelog entry for 3.39.1
Refs #815

Copied from e1d55de8f8
2026-07-25 21:50:59 -07:00
Simon Willison
c621499ed1 codespell should check sqlite_utils as well
It did in CI but did not in the Justfile
2026-07-25 14:53:46 -07:00
Simon Willison
69a1c0d960
Fixes for Ruff>=0.16.0 (#814)
* Automated upgrades by Ruff

    uvx --with 'ruff>=0.16.0' ruff check . --fix --unsafe-fixes

* Fix remaining Ruff errors with GPT-5.6 Sol high

https://gist.github.com/simonw/6da7906a9fea6e90da131c21a9055199

* Fix flake E501 long lines
* New Protocol for migrations to make ty happy
2026-07-25 14:53:12 -07:00
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
68 changed files with 3855 additions and 1215 deletions

View file

@ -12,9 +12,9 @@ jobs:
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
os: [ubuntu-latest, windows-latest, macos-latest] os: [ubuntu-latest, windows-latest, macos-latest]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v7
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5 uses: actions/setup-python@v6
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
cache: pip cache: pip
@ -29,9 +29,9 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [test] needs: [test]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v7
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v5 uses: actions/setup-python@v6
with: with:
python-version: '3.14' python-version: '3.14'
cache: pip cache: pip

View file

@ -12,9 +12,9 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@v4 uses: actions/checkout@v7
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v5 uses: actions/setup-python@v6
with: with:
python-version: "3.11" python-version: "3.11"
cache: pip cache: pip

View file

@ -14,9 +14,9 @@ jobs:
numpy: [0, 1] numpy: [0, 1]
os: [ubuntu-latest, macos-latest, windows-latest, macos-14] os: [ubuntu-latest, macos-latest, windows-latest, macos-14]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v7
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5 uses: actions/setup-python@v6
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
allow-prereleases: true allow-prereleases: true

1
.gitignore vendored
View file

@ -15,6 +15,7 @@ venv
.schema .schema
.vscode .vscode
.hypothesis .hypothesis
.claude/
Pipfile Pipfile
Pipfile.lock Pipfile.lock
uv.lock uv.lock

View file

@ -16,6 +16,7 @@
uv run ty check sqlite_utils uv run ty check sqlite_utils
uv run cog --check README.md docs/*.rst uv run cog --check README.md docs/*.rst
uv run --group docs codespell docs/*.rst --ignore-words docs/codespell-ignore-words.txt uv run --group docs codespell docs/*.rst --ignore-words docs/codespell-ignore-words.txt
uv run --group docs codespell sqlite_utils --ignore-words docs/codespell-ignore-words.txt
# Rebuild docs with cog # Rebuild docs with cog
@cog: @cog:

View file

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

View file

@ -4,32 +4,123 @@
Changelog Changelog
=========== ===========
.. _v3_39_1:
3.39.1 (2026-07-25)
-------------------
- Fixed a bug where ``table.delete_where()`` left the connection in an open transaction, causing deleted rows to be silently restored when the connection was closed. (:issue:`815`)
.. _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: .. _v4_0rc3:
4.0rc3 (2026-07-05) 4.0rc3 (2026-07-05)
------------------- -------------------
Breaking changes: Breaking changes
~~~~~~~~~~~~~~~~
- ``table.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`) - :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. - 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`) - 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: Compound foreign key support
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
- Tables can now be created with 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. See :ref:`python_api_compound_foreign_keys`. - 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.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. - ``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. - ``db.index_foreign_keys()`` creates a single composite index for a compound foreign key.
Other foreign key improvements: 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. - ``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`) - ``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``. - 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. - Fixed a ``TypeError`` when sorting ``ForeignKey`` objects where some were compound.
Case-insensitive column matching: 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: 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:
@ -42,7 +133,8 @@ Column names passed to Python API methods are now matched against the table sche
- 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. - 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``. - ``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: 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>`__) - 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>`__)

View file

@ -19,7 +19,7 @@ This page lists the ``--help`` for every ``sqlite-utils`` CLI sub-command.
go_first = [ go_first = [
"query", "memory", "insert", "upsert", "bulk", "search", "transform", "extract", "query", "memory", "insert", "upsert", "bulk", "search", "transform", "extract",
"schema", "insert-files", "analyze-tables", "convert", "tables", "views", "rows", "schema", "insert-files", "analyze-tables", "convert", "tables", "views", "rows",
"triggers", "indexes", "create-database", "create-table", "create-index", "triggers", "indexes", "create-database", "create-table", "create-index", "drop-index",
"migrate", "enable-fts", "populate-fts", "rebuild-fts", "disable-fts" "migrate", "enable-fts", "populate-fts", "rebuild-fts", "disable-fts"
] ]
refs = { refs = {
@ -46,6 +46,7 @@ This page lists the ``--help`` for every ``sqlite-utils`` CLI sub-command.
"add-foreign-keys": "cli_add_foreign_keys", "add-foreign-keys": "cli_add_foreign_keys",
"index-foreign-keys": "cli_index_foreign_keys", "index-foreign-keys": "cli_index_foreign_keys",
"create-index": "cli_create_index", "create-index": "cli_create_index",
"drop-index": "cli_drop_index",
"enable-wal": "cli_wal", "enable-wal": "cli_wal",
"enable-counts": "cli_enable_counts", "enable-counts": "cli_enable_counts",
"bulk": "cli_bulk", "bulk": "cli_bulk",
@ -109,6 +110,10 @@ See :ref:`cli_query`.
"select * from chickens where age > :age" \ "select * from chickens where age > :age" \
-p age 1 -p age 1
Pass "-" as the SQL to read the query from standard input:
echo "select * from chickens" | sqlite-utils data.db -
Options: Options:
--attach <TEXT FILE>... Additional databases to attach - specify alias and --attach <TEXT FILE>... Additional databases to attach - specify alias and
filepath filepath
@ -116,7 +121,7 @@ See :ref:`cli_query`.
--arrays Output rows as arrays instead of objects --arrays Output rows as arrays instead of objects
--csv Output CSV --csv Output CSV
--tsv Output TSV --tsv Output TSV
--no-headers Omit CSV headers --no-headers Omit headers from CSV/TSV and table/--fmt output
-t, --table Output as a formatted table -t, --table Output as a formatted table
--fmt TEXT Table format - one of asciidoc, colon_grid, --fmt TEXT Table format - one of asciidoc, colon_grid,
double_grid, double_outline, fancy_grid, double_grid, double_outline, fancy_grid,
@ -129,11 +134,13 @@ See :ref:`cli_query`.
simple_outline, textile, tsv, unsafehtml, youtrack simple_outline, textile, tsv, unsafehtml, youtrack
--json-cols Detect JSON cols and output them as JSON, not --json-cols Detect JSON cols and output them as JSON, not
escaped strings escaped strings
--ascii Escape non-ASCII characters in JSON output as
\uXXXX
-r, --raw Raw output, first column of first row -r, --raw Raw output, first column of first row
--raw-lines Raw output, first column of each row --raw-lines Raw output, first column of each row
-p, --param <TEXT TEXT>... Named :parameters for SQL query -p, --param <TEXT TEXT>... Named :parameters for SQL query
--functions TEXT Python code or file path defining custom SQL --functions TEXT Python code or a file path defining custom SQL
functions functions; can be used multiple times
--load-extension TEXT Path to SQLite extension, with optional --load-extension TEXT Path to SQLite extension, with optional
:entrypoint :entrypoint
-h, --help Show this message and exit. -h, --help Show this message and exit.
@ -175,8 +182,8 @@ See :ref:`cli_memory`.
sqlite-utils memory animals.csv --schema sqlite-utils memory animals.csv --schema
Options: Options:
--functions TEXT Python code or file path defining custom SQL --functions TEXT Python code or a file path defining custom SQL
functions functions; can be used multiple times
--attach <TEXT FILE>... Additional databases to attach - specify alias and --attach <TEXT FILE>... Additional databases to attach - specify alias and
filepath filepath
--flatten Flatten nested JSON objects, so {"foo": {"bar": --flatten Flatten nested JSON objects, so {"foo": {"bar":
@ -185,7 +192,7 @@ See :ref:`cli_memory`.
--arrays Output rows as arrays instead of objects --arrays Output rows as arrays instead of objects
--csv Output CSV --csv Output CSV
--tsv Output TSV --tsv Output TSV
--no-headers Omit CSV headers --no-headers Omit headers from CSV/TSV and table/--fmt output
-t, --table Output as a formatted table -t, --table Output as a formatted table
--fmt TEXT Table format - one of asciidoc, colon_grid, --fmt TEXT Table format - one of asciidoc, colon_grid,
double_grid, double_outline, fancy_grid, double_grid, double_outline, fancy_grid,
@ -198,6 +205,8 @@ See :ref:`cli_memory`.
simple_outline, textile, tsv, unsafehtml, youtrack simple_outline, textile, tsv, unsafehtml, youtrack
--json-cols Detect JSON cols and output them as JSON, not --json-cols Detect JSON cols and output them as JSON, not
escaped strings escaped strings
--ascii Escape non-ASCII characters in JSON output as
\uXXXX
-r, --raw Raw output, first column of first row -r, --raw Raw output, first column of first row
--raw-lines Raw output, first column of each row --raw-lines Raw output, first column of each row
-p, --param <TEXT TEXT>... Named :parameters for SQL query -p, --param <TEXT TEXT>... Named :parameters for SQL query
@ -222,7 +231,7 @@ See :ref:`cli_inserting_data`, :ref:`cli_insert_csv_tsv`, :ref:`cli_insert_unstr
:: ::
Usage: sqlite-utils insert [OPTIONS] PATH TABLE FILE Usage: sqlite-utils insert [OPTIONS] PATH TABLE [FILE]
Insert records from FILE into a table, creating the table if it does not Insert records from FILE into a table, creating the table if it does not
already exist. already exist.
@ -238,6 +247,9 @@ See :ref:`cli_inserting_data`, :ref:`cli_insert_csv_tsv`, :ref:`cli_insert_unstr
- Use --lines to write each incoming line to a column called "line" - Use --lines to write each incoming line to a column called "line"
- Use --text to write the entire input to a column called "text" - Use --text to write the entire input to a column called "text"
Use --type column-name type to override the type automatically chosen when the
table is created.
You can also use --convert to pass a fragment of Python code that will be used You can also use --convert to pass a fragment of Python code that will be used
to convert each input. to convert each input.
@ -264,8 +276,20 @@ See :ref:`cli_inserting_data`, :ref:`cli_insert_csv_tsv`, :ref:`cli_insert_unstr
echo 'A bunch of words' | sqlite-utils insert words.db words - \ echo 'A bunch of words' | sqlite-utils insert words.db words - \
--text --convert '({"word": w} for w in text.split())' --text --convert '({"word": w} for w in text.split())'
Instead of a FILE you can use --code to provide a block of Python code that
defines the rows to insert, as either a rows() function that yields
dictionaries or a "rows" iterable. --code can also be a path to a .py file:
sqlite-utils insert data.db creatures --code '
def rows():
yield {"id": 1, "name": "Cleo"}
yield {"id": 2, "name": "Suna"}
' --pk id
Options: Options:
--pk TEXT Columns to use as the primary key, e.g. id --pk TEXT Columns to use as the primary key, e.g. id
--code TEXT Python code defining a rows() function or iterable
of rows to insert
--flatten Flatten nested JSON objects, so {"a": {"b": 1}} --flatten Flatten nested JSON objects, so {"a": {"b": 1}}
becomes {"a_b": 1} becomes {"a_b": 1}
--nl Expect newline-delimited JSON --nl Expect newline-delimited JSON
@ -286,6 +310,7 @@ See :ref:`cli_inserting_data`, :ref:`cli_insert_csv_tsv`, :ref:`cli_insert_unstr
--alter Alter existing table to add any missing columns --alter Alter existing table to add any missing columns
--not-null TEXT Columns that should be created as NOT NULL --not-null TEXT Columns that should be created as NOT NULL
--default <TEXT TEXT>... Default value that should be set for a column --default <TEXT TEXT>... Default value that should be set for a column
--type <TEXT CHOICE>... Column types to use when creating the table
--no-detect-types Treat all CSV/TSV columns as TEXT --no-detect-types Treat all CSV/TSV columns as TEXT
--analyze Run ANALYZE at the end of this operation --analyze Run ANALYZE at the end of this operation
--load-extension TEXT Path to SQLite extension, with optional :entrypoint --load-extension TEXT Path to SQLite extension, with optional :entrypoint
@ -307,12 +332,17 @@ See :ref:`cli_upsert`.
:: ::
Usage: sqlite-utils upsert [OPTIONS] PATH TABLE FILE Usage: sqlite-utils upsert [OPTIONS] PATH TABLE [FILE]
Upsert records based on their primary key. Works like 'insert' but if an Upsert records based on their primary key. Works like 'insert' but if an
incoming record has a primary key that matches an existing record the existing incoming record has a primary key that matches an existing record the existing
record will be updated. record will be updated.
If the table already exists and has a primary key, --pk can be omitted.
Use --type column-name type to override the type automatically chosen when the
table is created.
Example: Example:
echo '[ echo '[
@ -322,7 +352,8 @@ See :ref:`cli_upsert`.
Options: Options:
--pk TEXT Columns to use as the primary key, e.g. id --pk TEXT Columns to use as the primary key, e.g. id
[required] --code TEXT Python code defining a rows() function or iterable
of rows to insert
--flatten Flatten nested JSON objects, so {"a": {"b": 1}} --flatten Flatten nested JSON objects, so {"a": {"b": 1}}
becomes {"a_b": 1} becomes {"a_b": 1}
--nl Expect newline-delimited JSON --nl Expect newline-delimited JSON
@ -343,6 +374,7 @@ See :ref:`cli_upsert`.
--alter Alter existing table to add any missing columns --alter Alter existing table to add any missing columns
--not-null TEXT Columns that should be created as NOT NULL --not-null TEXT Columns that should be created as NOT NULL
--default <TEXT TEXT>... Default value that should be set for a column --default <TEXT TEXT>... Default value that should be set for a column
--type <TEXT CHOICE>... Column types to use when creating the table
--no-detect-types Treat all CSV/TSV columns as TEXT --no-detect-types Treat all CSV/TSV columns as TEXT
--analyze Run ANALYZE at the end of this operation --analyze Run ANALYZE at the end of this operation
--load-extension TEXT Path to SQLite extension, with optional :entrypoint --load-extension TEXT Path to SQLite extension, with optional :entrypoint
@ -375,7 +407,8 @@ See :ref:`cli_bulk`.
Options: Options:
--batch-size INTEGER Commit every X records --batch-size INTEGER Commit every X records
--functions TEXT Python code or file path defining custom SQL functions --functions TEXT Python code or a file path defining custom SQL
functions; can be used multiple times
--flatten Flatten nested JSON objects, so {"a": {"b": 1}} becomes --flatten Flatten nested JSON objects, so {"a": {"b": 1}} becomes
{"a_b": 1} {"a_b": 1}
--nl Expect newline-delimited JSON --nl Expect newline-delimited JSON
@ -422,7 +455,7 @@ See :ref:`cli_search`.
--arrays Output rows as arrays instead of objects --arrays Output rows as arrays instead of objects
--csv Output CSV --csv Output CSV
--tsv Output TSV --tsv Output TSV
--no-headers Omit CSV headers --no-headers Omit headers from CSV/TSV and table/--fmt output
-t, --table Output as a formatted table -t, --table Output as a formatted table
--fmt TEXT Table format - one of asciidoc, colon_grid, --fmt TEXT Table format - one of asciidoc, colon_grid,
double_grid, double_outline, fancy_grid, fancy_outline, double_grid, double_outline, fancy_grid, fancy_outline,
@ -435,6 +468,7 @@ See :ref:`cli_search`.
youtrack youtrack
--json-cols Detect JSON cols and output them as JSON, not escaped --json-cols Detect JSON cols and output them as JSON, not escaped
strings strings
--ascii Escape non-ASCII characters in JSON output as \uXXXX
--load-extension TEXT Path to SQLite extension, with optional :entrypoint --load-extension TEXT Path to SQLite extension, with optional :entrypoint
-h, --help Show this message and exit. -h, --help Show this message and exit.
@ -474,6 +508,8 @@ See :ref:`cli_transform_table`.
Add a foreign key constraint from a column to Add a foreign key constraint from a column to
another table with another column another table with another column
--drop-foreign-key TEXT Drop foreign key constraint for this column --drop-foreign-key TEXT Drop foreign key constraint for this column
--strict / --no-strict Enable or disable STRICT mode (default:
preserve current mode)
--sql Output SQL without executing it --sql Output SQL without executing it
--load-extension TEXT Path to SQLite extension, with optional --load-extension TEXT Path to SQLite extension, with optional
:entrypoint :entrypoint
@ -611,6 +647,11 @@ See :ref:`cli_convert`.
"value" is a variable with the column value to be converted. "value" is a variable with the column value to be converted.
CODE can also be a reference to a callable that takes the value, for example:
sqlite-utils convert my.db mytable date r.parsedate
sqlite-utils convert my.db mytable data json.loads --import json
Use "-" for CODE to read Python code from standard input. Use "-" for CODE to read Python code from standard input.
The following common operations are available as recipe functions: The following common operations are available as recipe functions:
@ -621,20 +662,18 @@ See :ref:`cli_convert`.
Convert a string like a,b,c into a JSON array ["a", "b", "c"] Convert a string like a,b,c into a JSON array ["a", "b", "c"]
r.parsedate(value: 'str', dayfirst: 'bool' = False, yearfirst: 'bool' = False, r.parsedate(value: 'str', dayfirst: 'bool' = False, yearfirst: 'bool' = False,
errors: 'Optional[object]' = None) -> 'Optional[str]' errors: 'object | None' = None) -> 'str | None'
Parse a date and convert it to ISO date format: yyyy-mm-dd Parse a date and convert it to ISO date format: yyyy-mm-dd
- dayfirst=True: treat xx as the day in xx/yy/zz - dayfirst=True: treat xx as the day in xx/yy/zz
- yearfirst=True: treat xx as the year 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.IGNORE to ignore values that cannot be parsed
- errors=r.SET_NULL to set values that cannot be parsed to null - errors=r.SET_NULL to set values that cannot be parsed to null
r.parsedatetime(value: 'str', dayfirst: 'bool' = False, yearfirst: 'bool' = r.parsedatetime(value: 'str', dayfirst: 'bool' = False, yearfirst: 'bool' =
False, errors: 'Optional[object]' = None) -> 'Optional[str]' False, errors: 'object | None' = None) -> 'str | None'
Parse a datetime and convert it to ISO datetime format: yyyy-mm-ddTHH:MM:SS Parse a datetime and convert it to ISO datetime format: yyyy-mm-ddTHH:MM:SS
- dayfirst=True: treat xx as the day in xx/yy/zz - dayfirst=True: treat xx as the day in xx/yy/zz
- yearfirst=True: treat xx as the year 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.IGNORE to ignore values that cannot be parsed
@ -688,7 +727,7 @@ See :ref:`cli_tables`.
--arrays Output rows as arrays instead of objects --arrays Output rows as arrays instead of objects
--csv Output CSV --csv Output CSV
--tsv Output TSV --tsv Output TSV
--no-headers Omit CSV headers --no-headers Omit headers from CSV/TSV and table/--fmt output
-t, --table Output as a formatted table -t, --table Output as a formatted table
--fmt TEXT Table format - one of asciidoc, colon_grid, --fmt TEXT Table format - one of asciidoc, colon_grid,
double_grid, double_outline, fancy_grid, fancy_outline, double_grid, double_outline, fancy_grid, fancy_outline,
@ -701,6 +740,7 @@ See :ref:`cli_tables`.
youtrack youtrack
--json-cols Detect JSON cols and output them as JSON, not escaped --json-cols Detect JSON cols and output them as JSON, not escaped
strings strings
--ascii Escape non-ASCII characters in JSON output as \uXXXX
--columns Include list of columns for each table --columns Include list of columns for each table
--schema Include schema for each table --schema Include schema for each table
--load-extension TEXT Path to SQLite extension, with optional :entrypoint --load-extension TEXT Path to SQLite extension, with optional :entrypoint
@ -730,7 +770,7 @@ See :ref:`cli_views`.
--arrays Output rows as arrays instead of objects --arrays Output rows as arrays instead of objects
--csv Output CSV --csv Output CSV
--tsv Output TSV --tsv Output TSV
--no-headers Omit CSV headers --no-headers Omit headers from CSV/TSV and table/--fmt output
-t, --table Output as a formatted table -t, --table Output as a formatted table
--fmt TEXT Table format - one of asciidoc, colon_grid, --fmt TEXT Table format - one of asciidoc, colon_grid,
double_grid, double_outline, fancy_grid, fancy_outline, double_grid, double_outline, fancy_grid, fancy_outline,
@ -743,6 +783,7 @@ See :ref:`cli_views`.
youtrack youtrack
--json-cols Detect JSON cols and output them as JSON, not escaped --json-cols Detect JSON cols and output them as JSON, not escaped
strings strings
--ascii Escape non-ASCII characters in JSON output as \uXXXX
--columns Include list of columns for each view --columns Include list of columns for each view
--schema Include schema for each view --schema Include schema for each view
--load-extension TEXT Path to SQLite extension, with optional :entrypoint --load-extension TEXT Path to SQLite extension, with optional :entrypoint
@ -777,7 +818,7 @@ See :ref:`cli_rows`.
--arrays Output rows as arrays instead of objects --arrays Output rows as arrays instead of objects
--csv Output CSV --csv Output CSV
--tsv Output TSV --tsv Output TSV
--no-headers Omit CSV headers --no-headers Omit headers from CSV/TSV and table/--fmt output
-t, --table Output as a formatted table -t, --table Output as a formatted table
--fmt TEXT Table format - one of asciidoc, colon_grid, --fmt TEXT Table format - one of asciidoc, colon_grid,
double_grid, double_outline, fancy_grid, double_grid, double_outline, fancy_grid,
@ -790,6 +831,8 @@ See :ref:`cli_rows`.
simple_outline, textile, tsv, unsafehtml, youtrack simple_outline, textile, tsv, unsafehtml, youtrack
--json-cols Detect JSON cols and output them as JSON, not --json-cols Detect JSON cols and output them as JSON, not
escaped strings escaped strings
--ascii Escape non-ASCII characters in JSON output as
\uXXXX
--load-extension TEXT Path to SQLite extension, with optional --load-extension TEXT Path to SQLite extension, with optional
:entrypoint :entrypoint
-h, --help Show this message and exit. -h, --help Show this message and exit.
@ -817,7 +860,7 @@ See :ref:`cli_triggers`.
--arrays Output rows as arrays instead of objects --arrays Output rows as arrays instead of objects
--csv Output CSV --csv Output CSV
--tsv Output TSV --tsv Output TSV
--no-headers Omit CSV headers --no-headers Omit headers from CSV/TSV and table/--fmt output
-t, --table Output as a formatted table -t, --table Output as a formatted table
--fmt TEXT Table format - one of asciidoc, colon_grid, --fmt TEXT Table format - one of asciidoc, colon_grid,
double_grid, double_outline, fancy_grid, fancy_outline, double_grid, double_outline, fancy_grid, fancy_outline,
@ -830,6 +873,7 @@ See :ref:`cli_triggers`.
youtrack youtrack
--json-cols Detect JSON cols and output them as JSON, not escaped --json-cols Detect JSON cols and output them as JSON, not escaped
strings strings
--ascii Escape non-ASCII characters in JSON output as \uXXXX
--load-extension TEXT Path to SQLite extension, with optional :entrypoint --load-extension TEXT Path to SQLite extension, with optional :entrypoint
-h, --help Show this message and exit. -h, --help Show this message and exit.
@ -857,7 +901,7 @@ See :ref:`cli_indexes`.
--arrays Output rows as arrays instead of objects --arrays Output rows as arrays instead of objects
--csv Output CSV --csv Output CSV
--tsv Output TSV --tsv Output TSV
--no-headers Omit CSV headers --no-headers Omit headers from CSV/TSV and table/--fmt output
-t, --table Output as a formatted table -t, --table Output as a formatted table
--fmt TEXT Table format - one of asciidoc, colon_grid, --fmt TEXT Table format - one of asciidoc, colon_grid,
double_grid, double_outline, fancy_grid, fancy_outline, double_grid, double_outline, fancy_grid, fancy_outline,
@ -870,6 +914,7 @@ See :ref:`cli_indexes`.
youtrack youtrack
--json-cols Detect JSON cols and output them as JSON, not escaped --json-cols Detect JSON cols and output them as JSON, not escaped
strings strings
--ascii Escape non-ASCII characters in JSON output as \uXXXX
--load-extension TEXT Path to SQLite extension, with optional :entrypoint --load-extension TEXT Path to SQLite extension, with optional :entrypoint
-h, --help Show this message and exit. -h, --help Show this message and exit.
@ -915,10 +960,10 @@ See :ref:`cli_create_table`.
sqlite-utils create-table my.db people \ sqlite-utils create-table my.db people \
id integer \ id integer \
name text \ name text \
height float \ height real \
photo blob --pk id photo blob --pk id
Valid column types are text, integer, float and blob. Valid column types are text, integer, real, float and blob.
Options: Options:
--pk TEXT Column to use as primary key --pk TEXT Column to use as primary key
@ -964,6 +1009,29 @@ See :ref:`cli_create_index`.
-h, --help Show this message and exit. -h, --help Show this message and exit.
.. _cli_ref_drop_index:
drop-index
==========
See :ref:`cli_drop_index`.
::
Usage: sqlite-utils drop-index [OPTIONS] PATH TABLE INDEX
Drop an index by index name from the specified table
Example:
sqlite-utils drop-index chickens.db chickens idx_chickens_name
Options:
--ignore Ignore if index does not exist
--load-extension TEXT Path to SQLite extension, with optional :entrypoint
-h, --help Show this message and exit.
.. _cli_ref_migrate: .. _cli_ref_migrate:
migrate migrate
@ -1013,7 +1081,7 @@ See :ref:`cli_fts`.
Usage: sqlite-utils enable-fts [OPTIONS] PATH TABLE COLUMN... Usage: sqlite-utils enable-fts [OPTIONS] PATH TABLE COLUMN...
Enable full-text search for specific table and columns" Enable full-text search for specific table and columns
Example: Example:

View file

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

View file

@ -1,8 +1,7 @@
#!/usr/bin/env python3 import inspect
# -*- coding: utf-8 -*- import sys
from pathlib import Path
from subprocess import Popen, PIPE from subprocess import PIPE, CalledProcessError, Popen, check_output
from beanbag_docutils.sphinx.ext.github import github_linkcode_resolve
# This file is execfile()d with the current directory set to its # This file is execfile()d with the current directory set to its
# containing dir. # containing dir.
@ -45,14 +44,52 @@ extlinks = {
} }
def _linkcode_git_ref():
try:
return check_output(["git", "rev-parse", "HEAD"]).decode("utf8").strip()
except (CalledProcessError, OSError):
return "main"
def linkcode_resolve(domain, info): def linkcode_resolve(domain, info):
return github_linkcode_resolve( if domain != "py":
domain=domain, return None
info=info,
allowed_module_names=["sqlite_utils"], module_name = info.get("module")
github_org_id="simonw", if not module_name or module_name.split(".")[0] != "sqlite_utils":
github_repo_id="sqlite-utils", return None
branch="main",
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 (OSError, TypeError, ValueError):
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}"
) )

View file

@ -27,7 +27,7 @@ Here is a simple example of a ``migrations.py`` file which creates a table, then
.. code-block:: python .. code-block:: python
from sqlite_utils import Database, Migrations from sqlite_utils import Migrations
migrations = Migrations("creatures") migrations = Migrations("creatures")
@ -51,6 +51,8 @@ Once you have a ``Migrations(name)`` collection with one or more migrations regi
.. code-block:: python .. code-block:: python
from sqlite_utils import Database
db = Database("creatures.db") db = Database("creatures.db")
migrations.apply(db) migrations.apply(db)
@ -157,7 +159,7 @@ You can also target a specific migration set using ``migration_set:migration_nam
The ``--stop-before`` option can be passed more than once. 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. 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 Verbose output
============== ==============

View file

@ -109,6 +109,14 @@ You can also create a named in-memory database. Unlike regular memory databases
db = Database(memory_name="my_shared_database") db = Database(memory_name="my_shared_database")
After creating a ``Database`` you can use ``db.memory`` and ``db.memory_name`` to tell whether it is backed by an in-memory database and to read the shared cache name. ``db.memory`` is ``True`` for any in-memory database and ``db.memory_name`` holds the name passed to ``memory_name=``, or ``None`` otherwise.
.. code-block:: python
db = Database(memory_name="shared")
db.memory # True
db.memory_name # "shared"
Connections use ``PRAGMA recursive_triggers=on`` by default. If you don't want to use `recursive triggers <https://www.sqlite.org/pragma.html#pragma_recursive_triggers>`__ you can turn them off using: Connections use ``PRAGMA recursive_triggers=on`` by default. If you don't want to use `recursive triggers <https://www.sqlite.org/pragma.html#pragma_recursive_triggers>`__ you can turn them off using:
.. code-block:: python .. code-block:: python
@ -176,6 +184,9 @@ You can attach an additional database using the ``.attach()`` method, providing
You can reference tables in the attached database using the alias value you passed to ``db.attach(alias, filepath)`` as a prefix, for example the ``second.table_in_second`` reference in the SQL query above. You can reference tables in the attached database using the alias value you passed to ``db.attach(alias, filepath)`` as a prefix, for example the ``second.table_in_second`` reference in the SQL query above.
.. note::
In the CLI: :ref:`sqlite-utils --attach <cli_query_attach>`
.. _python_api_tracing: .. _python_api_tracing:
Tracing queries Tracing queries
@ -233,6 +244,8 @@ The SQL query is executed as soon as ``db.query()`` is called. The resulting row
``db.query()`` can only be used with SQL that returns rows. Passing a statement that returns no rows - an ``INSERT`` or ``UPDATE`` without a ``RETURNING`` clause, for example - will raise a ``ValueError``. The rejected statement is rolled back, so it has no effect on the database. Use :ref:`db.execute() <python_api_execute>` for those statements instead. ``db.query()`` can only be used with SQL that returns rows. Passing a statement that returns no rows - an ``INSERT`` or ``UPDATE`` without a ``RETURNING`` clause, for example - will raise a ``ValueError``. The rejected statement is rolled back, so it has no effect on the database. Use :ref:`db.execute() <python_api_execute>` for those statements instead.
There is one exception to the rolled-back guarantee: a ``PRAGMA`` statement that returns no rows, such as ``PRAGMA user_version = 5``, still raises a ``ValueError`` but will already have taken effect. Some PRAGMA statements refuse to run inside a transaction, so PRAGMAs are executed outside the savepoint that is used to roll back other rejected statements. Use ``db.execute()`` for PRAGMA statements that do not return rows.
If a query returns more than one column with the same name - a join between two tables that share column names, for example - later occurrences are renamed with a numeric suffix, so every value is included in the dictionary: If a query returns more than one column with the same name - a join between two tables that share column names, for example - later occurrences are renamed with a numeric suffix, so every value is included in the dictionary:
.. code-block:: python .. code-block:: python
@ -244,6 +257,9 @@ If a query returns more than one column with the same name - a join between two
A suffix that would collide with another column in the query is skipped - ``select 1 as id, 2 as id, 3 as id_2`` returns ``{'id': 1, 'id_3': 2, 'id_2': 3}``. The same renaming is applied by ``table.rows_where()`` and ``table.search()``. A suffix that would collide with another column in the query is skipped - ``select 1 as id, 2 as id, 3 as id_2`` returns ``{'id': 1, 'id_3': 2, 'id_2': 3}``. The same renaming is applied by ``table.rows_where()`` and ``table.search()``.
.. note::
In the CLI: :ref:`sqlite-utils query <cli_query>`
.. _python_api_execute: .. _python_api_execute:
db.execute(sql, params) db.execute(sql, params)
@ -310,11 +326,15 @@ Every method in this library that writes to the database - ``insert()``, ``upser
The same applies to raw SQL executed with :ref:`db.execute() <python_api_transactions_execute>` - a write statement is committed as soon as it has run. The same applies to raw SQL executed with :ref:`db.execute() <python_api_transactions_execute>` - a write statement is committed as soon as it has run.
Another way to think about this is that each sqlite-utils method call is its own unit of work. If several method calls must either all succeed or all fail, use ``db.atomic()`` to turn them into a single unit of work.
You never need to call ``commit()``, and you do not need to close the database to persist your changes. There are exactly two situations where you need to think about transactions: You never need to call ``commit()``, and you do not need to close the database to persist your changes. There are exactly two situations where you need to think about transactions:
1. You want to group several write operations together, so they either all succeed or all fail - use :ref:`db.atomic() <python_api_atomic>`. 1. You want to group several write operations together, so they either all succeed or all fail - use :ref:`db.atomic() <python_api_atomic>`.
2. You are :ref:`managing a transaction yourself <python_api_transactions_manual>` with ``db.begin()``, in which case nothing is committed until you commit - the library will never commit a transaction you opened. 2. You are :ref:`managing a transaction yourself <python_api_transactions_manual>` with ``db.begin()``, in which case nothing is committed until you commit - the library will never commit a transaction you opened.
``with Database(...) as db:`` is not a transaction block. It manages the lifetime of the database connection and closes it on exit. Use ``with db.atomic():`` for a transaction.
.. _python_api_atomic: .. _python_api_atomic:
Grouping changes with db.atomic() Grouping changes with db.atomic()
@ -330,6 +350,27 @@ Use ``db.atomic()`` to group multiple operations in a single transaction:
The transaction commits when the block exits. If an exception is raised, changes made inside the block will be rolled back. The transaction commits when the block exits. If an exception is raised, changes made inside the block will be rolled back.
This matters when several operations represent a single logical change. Without ``db.atomic()``, an earlier method call remains committed if a later one fails:
.. code-block:: python
# These are two separate transactions
db.table("accounts").update(1, {"balance": 90})
db.table("accounts").update(2, {"balance": 110})
# These updates either both succeed or both fail
with db.atomic():
db.table("accounts").update(1, {"balance": 90})
db.table("accounts").update(2, {"balance": 110})
Transactions can also improve performance. Calling ``insert()`` repeatedly outside ``db.atomic()`` creates and commits a separate transaction for every call. For bulk inserts, prefer :ref:`insert_all() <python_api_bulk_inserts>`. If you need to call several different methods in a loop, wrap the loop in ``db.atomic()``:
.. code-block:: python
with db.atomic():
for row in rows:
db.table("events").insert(row)
``db.atomic()`` can be nested. Nested blocks use SQLite savepoints, so an exception in an inner block can roll back to that savepoint without rolling back the entire outer transaction: ``db.atomic()`` can be nested. Nested blocks use SQLite savepoints, so an exception in an inner block can roll back to that savepoint without rolling back the entire outer transaction:
.. code-block:: python .. code-block:: python
@ -358,6 +399,8 @@ Write statements executed with :ref:`db.execute() <python_api_execute>` follow t
db.execute("insert into news (headline) values (?)", ["Dog wins award"]) db.execute("insert into news (headline) values (?)", ["Dog wins award"])
# Already committed # Already committed
``db.execute()`` participates in sqlite-utils transaction handling. Calling ``db.conn.execute()`` directly bypasses that policy and leaves transaction handling to Python's underlying ``sqlite3.Connection``. Prefer ``db.execute()`` unless you deliberately need the lower-level API.
If a transaction is open - because the call happens inside a ``db.atomic()`` block, or after ``db.begin()`` - the statement becomes part of that transaction instead, and commits when the transaction commits: If a transaction is open - because the call happens inside a ``db.atomic()`` block, or after ``db.begin()`` - the statement becomes part of that transaction instead, and commits when the transaction commits:
.. code-block:: python .. code-block:: python
@ -389,9 +432,12 @@ You can take full manual control using the ``db.begin()``, ``db.commit()`` and `
The library will never commit a transaction you opened. If you call write methods such as ``insert()`` - or use ``db.atomic()`` - while your transaction is open, they participate in it using SQLite savepoints instead of committing: exiting an ``atomic()`` block releases its savepoint, but nothing is saved to disk until you commit the outer transaction yourself. If you roll back, their changes are rolled back too. The library will never commit a transaction you opened. If you call write methods such as ``insert()`` - or use ``db.atomic()`` - while your transaction is open, they participate in it using SQLite savepoints instead of committing: exiting an ``atomic()`` block releases its savepoint, but nothing is saved to disk until you commit the outer transaction yourself. If you roll back, their changes are rolled back too.
Two related safeguards to be aware of: Prefer ``db.atomic()`` or ``db.begin()``, ``db.commit()`` and ``db.rollback()`` over mixing sqlite-utils transaction methods with calls to ``db.conn.commit()``, ``db.conn.rollback()`` or raw transaction-control SQL. Mixing the two layers makes it much harder to tell which layer owns the current transaction.
Some related safeguards to be aware of:
- ``db.enable_wal()`` and ``db.disable_wal()`` raise a ``sqlite_utils.db.TransactionError`` if called while a transaction is open, because changing the journal mode would commit it as a side effect. - ``db.enable_wal()`` and ``db.disable_wal()`` raise a ``sqlite_utils.db.TransactionError`` if called while a transaction is open, because changing the journal mode would commit it as a side effect.
- ``table.transform()`` raises a ``sqlite_utils.db.TransactionError`` if called while a transaction is open with ``PRAGMA foreign_keys`` enabled and the table is referenced by foreign keys with destructive ``ON DELETE`` actions, because the pragma cannot be turned off mid-transaction to protect those referencing rows - see :ref:`python_api_transform_foreign_keys_transactions`.
- Closing the database - explicitly with ``db.close()``, or by exiting a ``with Database(...) as db:`` block - rolls back any transaction that is still open, see :ref:`python_api_close`. - Closing the database - explicitly with ``db.close()``, or by exiting a ``with Database(...) as db:`` block - rolls back any transaction that is still open, see :ref:`python_api_close`.
.. _python_api_transactions_modes: .. _python_api_transactions_modes:
@ -399,9 +445,11 @@ Two related safeguards to be aware of:
Supported connection modes Supported connection modes
-------------------------- --------------------------
``db.atomic()`` and the automatic per-method transactions require a connection in Python's default transaction handling mode. Passing a connection created with the Python 3.12+ ``sqlite3.connect(..., autocommit=True)`` or ``autocommit=False`` options to ``Database()`` raises a ``sqlite_utils.db.TransactionError``. ``db.atomic()`` and the automatic per-method transactions currently require a connection using Python's legacy transaction control mode (``sqlite3.LEGACY_TRANSACTION_CONTROL`` on Python 3.12 and later). Passing a connection created with the Python 3.12+ ``sqlite3.connect(..., autocommit=True)`` or ``autocommit=False`` options to ``Database()`` raises a ``sqlite_utils.db.TransactionError``.
This is because ``commit()`` and ``rollback()`` behave differently on those connections - under ``autocommit=True`` they are documented no-ops - which would cause every write made by this library to be silently discarded when the connection closed, rather than failing loudly. Connections using ``autocommit=False`` are not supported because Python keeps a transaction open continuously. sqlite-utils uses ``Connection.in_transaction`` to distinguish its own transactions from transactions opened by its caller, and that distinction is not available in this mode.
Connections using ``autocommit=True`` are also currently rejected because sqlite-utils has not formally exposed that as a supported configuration.
.. _python_api_table: .. _python_api_table:
@ -456,6 +504,9 @@ You can also iterate through the table objects themselves using the ``.tables``
>>> db.tables >>> db.tables
[<Table dogs>] [<Table dogs>]
.. note::
In the CLI: :ref:`sqlite-utils tables <cli_tables>`
.. _python_api_views: .. _python_api_views:
Listing views Listing views
@ -481,6 +532,9 @@ View objects are similar to Table objects, except that any attempts to insert or
* ``rows_where(where, where_args, order_by, select)`` * ``rows_where(where, where_args, order_by, select)``
* ``drop()`` * ``drop()``
.. note::
In the CLI: :ref:`sqlite-utils views <cli_views>`
.. _python_api_rows: .. _python_api_rows:
Listing rows Listing rows
@ -536,6 +590,9 @@ This method also accepts ``offset=`` and ``limit=`` arguments, for specifying an
... print(row) ... print(row)
{'id': 1, 'age': 4, 'name': 'Cleo'} {'id': 1, 'age': 4, 'name': 'Cleo'}
.. note::
In the CLI: :ref:`sqlite-utils rows <cli_rows>`
.. _python_api_rows_count_where: .. _python_api_rows_count_where:
Counting rows Counting rows
@ -620,6 +677,9 @@ The ``db.schema`` property returns the full SQL schema for the database as a str
"name" TEXT "name" TEXT
); );
.. note::
In the CLI: :ref:`sqlite-utils schema <cli_schema>`
.. _python_api_creating_tables: .. _python_api_creating_tables:
Creating tables Creating tables
@ -768,6 +828,9 @@ You can pass ``strict=True`` to create a table in ``STRICT`` mode:
"name": str, "name": str,
}, strict=True) }, strict=True)
.. note::
In the CLI: :ref:`sqlite-utils create-table <cli_create_table>`
.. _python_api_compound_primary_keys: .. _python_api_compound_primary_keys:
Compound primary keys Compound primary keys
@ -948,6 +1011,9 @@ Here's an example that uses these features:
# ) # )
.. note::
In the CLI: :ref:`sqlite-utils insert --not-null and --default <cli_defaults_not_null>`
.. _python_api_rename_table: .. _python_api_rename_table:
Renaming a table Renaming a table
@ -965,6 +1031,9 @@ This executes the following SQL:
ALTER TABLE [my_table] RENAME TO [new_name_for_my_table] ALTER TABLE [my_table] RENAME TO [new_name_for_my_table]
.. note::
In the CLI: :ref:`sqlite-utils rename-table <cli_renaming_tables>`
.. _python_api_duplicate: .. _python_api_duplicate:
Duplicating tables Duplicating tables
@ -980,6 +1049,9 @@ The new ``authors_copy`` table will now contain a duplicate copy of the data fro
This method raises ``sqlite_utils.db.NoTable`` if the table does not exist. This method raises ``sqlite_utils.db.NoTable`` if the table does not exist.
.. note::
In the CLI: :ref:`sqlite-utils duplicate <cli_duplicate_table>`
.. _python_api_bulk_inserts: .. _python_api_bulk_inserts:
Bulk inserts Bulk inserts
@ -1022,6 +1094,9 @@ You can delete all the existing rows in the table before inserting the new recor
Pass ``analyze=True`` to run ``ANALYZE`` against the table after inserting the new records. Pass ``analyze=True`` to run ``ANALYZE`` against the table after inserting the new records.
.. note::
In the CLI: :ref:`sqlite-utils insert <cli_inserting_data>`
.. _python_api_insert_lists: .. _python_api_insert_lists:
Inserting data from a list or tuple iterator Inserting data from a list or tuple iterator
@ -1099,6 +1174,9 @@ To replace any existing records that have a matching primary key, use the ``repl
.. note:: .. note::
Prior to sqlite-utils 2.0 the ``.upsert()`` and ``.upsert_all()`` methods worked the same way as ``.insert(replace=True)`` does today. See :ref:`python_api_upsert` for the new behaviour of those methods introduced in 2.0. Prior to sqlite-utils 2.0 the ``.upsert()`` and ``.upsert_all()`` methods worked the same way as ``.insert(replace=True)`` does today. See :ref:`python_api_upsert` for the new behaviour of those methods introduced in 2.0.
.. note::
In the CLI: :ref:`sqlite-utils insert --replace <cli_insert_replace>`
.. _python_api_update: .. _python_api_update:
Updating a specific record Updating a specific record
@ -1184,6 +1262,9 @@ Every record passed to ``upsert()`` or ``upsert_all()`` must include a value for
.. note:: .. note::
``.upsert()`` and ``.upsert_all()`` in sqlite-utils 1.x worked like ``.insert(..., replace=True)`` and ``.insert_all(..., replace=True)`` do in 2.x. See `issue #66 <https://github.com/simonw/sqlite-utils/issues/66>`__ for details of this change. ``.upsert()`` and ``.upsert_all()`` in sqlite-utils 1.x worked like ``.insert(..., replace=True)`` and ``.insert_all(..., replace=True)`` do in 2.x. See `issue #66 <https://github.com/simonw/sqlite-utils/issues/66>`__ for details of this change.
.. note::
In the CLI: :ref:`sqlite-utils upsert <cli_upsert>`
.. _python_api_old_upsert: .. _python_api_old_upsert:
Alternative upserts using INSERT OR IGNORE Alternative upserts using INSERT OR IGNORE
@ -1279,6 +1360,8 @@ To create a species record with a note on when it was first seen, you can use th
The first time this is called the record will be created for ``name="Palm"``. Any subsequent calls with that name will ignore the second argument, even if it includes different values. The first time this is called the record will be created for ``name="Palm"``. Any subsequent calls with that name will ignore the second argument, even if it includes different values.
``None`` values are matched correctly: calling ``.lookup()`` a second time with the same values will return the primary key of the existing row even if some of those values are ``None``.
``.lookup()`` also accepts keyword arguments, which are passed through to the :ref:`insert() method <python_api_creating_tables>` and can be used to influence the shape of the created table. Supported parameters are: ``.lookup()`` also accepts keyword arguments, which are passed through to the :ref:`insert() method <python_api_creating_tables>` and can be used to influence the shape of the created table. Supported parameters are:
- ``pk`` - which defaults to ``id`` - ``pk`` - which defaults to ``id``
@ -1324,6 +1407,8 @@ To extract the ``species`` column out to a separate ``Species`` table, you can d
"species": "Common Juniper" "species": "Common Juniper"
}, extracts={"species": "Species"}) }, extracts={"species": "Species"})
``None`` values are not extracted: no record is created for them in the lookup table and the column value stays ``null``.
.. _python_api_m2m: .. _python_api_m2m:
Working with many-to-many relationships Working with many-to-many relationships
@ -1531,6 +1616,9 @@ You can set a ``NOT NULL DEFAULT 'x'`` constraint on the new column using ``not_
db.table("dogs").add_column("friends_count", int, not_null_default=0) db.table("dogs").add_column("friends_count", int, not_null_default=0)
.. note::
In the CLI: :ref:`sqlite-utils add-column <cli_add_column>`
.. _python_api_add_column_alter: .. _python_api_add_column_alter:
Adding columns automatically on insert/update Adding columns automatically on insert/update
@ -1554,6 +1642,9 @@ You can insert or update data that includes new columns and have the table autom
new_table = db.table("new_table", alter=True) new_table = db.table("new_table", alter=True)
new_table.insert({"name": "Gareth", "age": 32, "shoe_size": 11}) new_table.insert({"name": "Gareth", "age": 32, "shoe_size": 11})
.. note::
In the CLI: :ref:`sqlite-utils insert --alter <cli_add_column_alter>`
.. _python_api_add_foreign_key: .. _python_api_add_foreign_key:
Adding foreign key constraints Adding foreign key constraints
@ -1612,6 +1703,9 @@ Use ``on_delete=`` and ``on_update=`` to specify ``ON DELETE`` and ``ON UPDATE``
This creates a foreign key with an ``ON DELETE CASCADE`` clause, so deleting an author will also delete their books (provided foreign key enforcement is enabled with ``PRAGMA foreign_keys = ON``). Valid actions are ``"SET NULL"``, ``"SET DEFAULT"``, ``"CASCADE"``, ``"RESTRICT"`` and the default ``"NO ACTION"``. This creates a foreign key with an ``ON DELETE CASCADE`` clause, so deleting an author will also delete their books (provided foreign key enforcement is enabled with ``PRAGMA foreign_keys = ON``). Valid actions are ``"SET NULL"``, ``"SET DEFAULT"``, ``"CASCADE"``, ``"RESTRICT"`` and the default ``"NO ACTION"``.
.. note::
In the CLI: :ref:`sqlite-utils add-foreign-key <cli_add_foreign_key>`
.. _python_api_add_foreign_keys: .. _python_api_add_foreign_keys:
Adding multiple foreign key constraints at once Adding multiple foreign key constraints at once
@ -1630,6 +1724,11 @@ Here's an example adding two foreign keys at once:
This method runs the same checks as ``.add_foreign_keys()`` and will raise ``sqlite_utils.db.AlterError`` if those checks fail. This method runs the same checks as ``.add_foreign_keys()`` and will raise ``sqlite_utils.db.AlterError`` if those checks fail.
Foreign keys that already exist are silently skipped, so repeated calls are idempotent - but only if they match exactly. Requesting a foreign key that exists with different ``ON DELETE``/``ON UPDATE`` actions raises ``AlterError``: use ``table.transform()`` to change the actions of an existing foreign key.
.. note::
In the CLI: :ref:`sqlite-utils add-foreign-keys <cli_add_foreign_keys>`
.. _python_api_index_foreign_keys: .. _python_api_index_foreign_keys:
Adding indexes for all foreign keys Adding indexes for all foreign keys
@ -1643,6 +1742,9 @@ If you want to ensure that every foreign key column in your database has a corre
Compound foreign keys get a single composite index across their columns. Compound foreign keys get a single composite index across their columns.
.. note::
In the CLI: :ref:`sqlite-utils index-foreign-keys <cli_index_foreign_keys>`
.. _python_api_drop: .. _python_api_drop:
Dropping a table or view Dropping a table or view
@ -1664,6 +1766,9 @@ Pass ``ignore=True`` if you want to ignore the error caused by the table or view
db.table("my_table").drop(ignore=True) db.table("my_table").drop(ignore=True)
.. note::
In the CLI: :ref:`sqlite-utils drop-table <cli_drop_table>` and :ref:`sqlite-utils drop-view <cli_drop_view>`
.. _python_api_transform: .. _python_api_transform:
Transforming a table Transforming a table
@ -1692,6 +1797,9 @@ To keep the original table around instead of dropping it, pass the ``keep_table=
This method raises a ``sqlite_utils.db.TransformError`` exception if the table cannot be transformed, usually because there are existing constraints or indexes that are incompatible with modifications to the columns. This method raises a ``sqlite_utils.db.TransformError`` exception if the table cannot be transformed, usually because there are existing constraints or indexes that are incompatible with modifications to the columns.
.. note::
In the CLI: :ref:`sqlite-utils transform <cli_transform_table>`
.. _python_api_transform_alter_column_types: .. _python_api_transform_alter_column_types:
Altering column types Altering column types
@ -1706,6 +1814,29 @@ To alter the type of a column, use the ``types=`` argument:
See :ref:`python_api_add_column` for a list of available types. See :ref:`python_api_add_column` for a list of available types.
.. _python_api_transform_strict:
Changing strict mode
--------------------
The optional ``strict=`` parameter can change whether a table uses `SQLite STRICT mode <https://www.sqlite.org/stricttables.html>`__. Pass ``strict=True`` to convert a regular table to a strict table:
.. code-block:: python
table.transform(strict=True)
Pass ``strict=False`` to convert a strict table back to a regular non-strict table:
.. code-block:: python
table.transform(strict=False)
The default is ``strict=None``, which preserves the table's existing strict mode.
Passing ``strict=True`` raises ``sqlite_utils.db.TransformError`` if the available SQLite version does not support strict tables.
Converting to a strict table validates all existing rows as they are copied into the replacement table. If a value is incompatible with its declared column type, SQLite raises ``sqlite3.IntegrityError`` and the transformation is rolled back, leaving the original table and its data unchanged.
.. _python_api_transform_rename_columns: .. _python_api_transform_rename_columns:
Renaming columns Renaming columns
@ -1866,6 +1997,36 @@ If you want to do something more advanced, you can call the ``table.transform_sq
This method will return a list of SQL statements that should be executed to implement the change. You can then make modifications to that SQL - or add additional SQL statements - before executing it yourself. This method will return a list of SQL statements that should be executed to implement the change. You can then make modifications to that SQL - or add additional SQL statements - before executing it yourself.
.. _python_api_transform_foreign_keys_transactions:
Foreign keys and transactions
-----------------------------
Because ``.transform()`` drops the old table, running it with ``PRAGMA foreign_keys`` enabled could fire ``ON DELETE`` actions on any tables that reference it - an inbound ``ON DELETE CASCADE`` foreign key would silently delete those referencing rows. To prevent this, ``.transform()`` turns ``PRAGMA foreign_keys`` off for the duration of the operation and restores it afterwards, running ``PRAGMA foreign_key_check`` before committing.
``PRAGMA foreign_keys`` cannot be changed inside a transaction, so this protection is impossible if you call ``.transform()`` while a transaction is already open - for example inside a ``with db.atomic():`` block or after ``db.begin()``. If ``PRAGMA foreign_keys`` is on and another table references the table being transformed with a destructive ``ON DELETE`` action - ``CASCADE``, ``SET NULL`` or ``SET DEFAULT`` - the method will refuse to run and raise a ``sqlite_utils.db.TransactionError``:
.. code-block:: python
from sqlite_utils.db import TransactionError
try:
with db.atomic():
db["authors"].transform(types={"id": str})
except TransactionError as ex:
print("Could not transform in transaction:", ex)
To transform such a table either call ``.transform()`` outside of the transaction, or execute ``PRAGMA foreign_keys = off`` before opening it:
.. code-block:: python
db.execute("PRAGMA foreign_keys = off")
with db.atomic():
db["authors"].transform(types={"id": str})
db.execute("PRAGMA foreign_keys = on")
Tables referenced by foreign keys without a destructive action (the default ``NO ACTION``, or ``RESTRICT``) can still be transformed inside a transaction - sqlite-utils uses ``PRAGMA defer_foreign_keys`` to postpone the foreign key checks until the transaction commits.
.. _python_api_extract: .. _python_api_extract:
Extracting columns into a separate table Extracting columns into a separate table
@ -2022,6 +2183,11 @@ This produces a lookup table like so:
"latin" TEXT "latin" TEXT
) )
Rows where every extracted column is ``null`` are not extracted: no record is created for them in the lookup table and their foreign key column is left as ``null``. When extracting multiple columns, rows where at least one of the extracted columns has a value will be extracted as usual.
.. note::
In the CLI: :ref:`sqlite-utils extract <cli_extract>`
.. _python_api_hash: .. _python_api_hash:
Setting an ID based on the hash of the row contents Setting an ID based on the hash of the row contents
@ -2083,6 +2249,9 @@ You can pass ``ignore=True`` to silently ignore an existing view and do nothing,
select * from dogs where is_good_dog = 1 select * from dogs where is_good_dog = 1
""", replace=True) """, replace=True)
.. note::
In the CLI: :ref:`sqlite-utils create-view <cli_create_view>`
Storing JSON Storing JSON
============ ============
@ -2201,6 +2370,9 @@ If you are using ``pysqlite3`` the underlying method may be missing. If you inst
pip install sqlite-dump pip install sqlite-dump
.. note::
In the CLI: :ref:`sqlite-utils dump <cli_dump>`
.. _python_api_introspection: .. _python_api_introspection:
Introspecting tables and views Introspecting tables and views
@ -2400,6 +2572,9 @@ The ``.indexes`` property returns all indexes created for a table, as a list of
Index(seq=4, name='"Street_Tree_List_qCaretaker"', unique=0, origin='c', partial=0, columns=['qCaretaker']), Index(seq=4, name='"Street_Tree_List_qCaretaker"', unique=0, origin='c', partial=0, columns=['qCaretaker']),
Index(seq=5, name='"Street_Tree_List_PlantType"', unique=0, origin='c', partial=0, columns=['PlantType'])] Index(seq=5, name='"Street_Tree_List_PlantType"', unique=0, origin='c', partial=0, columns=['PlantType'])]
.. note::
In the CLI: :ref:`sqlite-utils indexes <cli_indexes>`
.. _python_api_introspection_xindexes: .. _python_api_introspection_xindexes:
.xindexes .xindexes
@ -2443,6 +2618,9 @@ The ``.triggers`` property lists database triggers. It can be used on both datab
>>> db.triggers >>> db.triggers
... similar output to db.table("authors").triggers ... similar output to db.table("authors").triggers
.. note::
In the CLI: :ref:`sqlite-utils triggers <cli_triggers>`
.. _python_api_introspection_triggers_dict: .. _python_api_introspection_triggers_dict:
.triggers_dict .triggers_dict
@ -2591,6 +2769,9 @@ To remove the FTS tables and triggers you created, use the ``disable_fts()`` tab
db.table("dogs").disable_fts() db.table("dogs").disable_fts()
.. note::
In the CLI: :ref:`sqlite-utils enable-fts <cli_fts>`
.. _python_api_quote_fts: .. _python_api_quote_fts:
Quoting characters for use in search Quoting characters for use in search
@ -2655,6 +2836,9 @@ To return just the title and published columns for three matches for ``"dog"`` w
): ):
print(article) print(article)
.. note::
In the CLI: :ref:`sqlite-utils search <cli_search>`
.. _python_api_fts_search_sql: .. _python_api_fts_search_sql:
Building SQL queries with table.search_sql() Building SQL queries with table.search_sql()
@ -2739,6 +2923,9 @@ This runs the following SQL::
INSERT INTO dogs_fts (dogs_fts) VALUES ("rebuild"); INSERT INTO dogs_fts (dogs_fts) VALUES ("rebuild");
.. note::
In the CLI: :ref:`sqlite-utils rebuild-fts <cli_fts>`
.. _python_api_fts_optimize: .. _python_api_fts_optimize:
Optimizing a full-text search table Optimizing a full-text search table
@ -2754,6 +2941,9 @@ This runs the following SQL::
INSERT INTO dogs_fts (dogs_fts) VALUES ("optimize"); INSERT INTO dogs_fts (dogs_fts) VALUES ("optimize");
.. note::
In the CLI: :ref:`sqlite-utils optimize <cli_optimize>`
.. _python_api_cached_table_counts: .. _python_api_cached_table_counts:
Cached table counts using triggers Cached table counts using triggers
@ -2816,6 +3006,9 @@ If the ``_counts`` table ever becomes out-of-sync with the actual table counts y
db.reset_counts() db.reset_counts()
.. note::
In the CLI: :ref:`sqlite-utils enable-counts <cli_enable_counts>`
.. _python_api_create_index: .. _python_api_create_index:
Creating indexes Creating indexes
@ -2859,6 +3052,17 @@ Use ``if_not_exists=True`` to do nothing if an index with that name already exis
Pass ``analyze=True`` to run ``ANALYZE`` against the new index after creating it. Pass ``analyze=True`` to run ``ANALYZE`` against the new index after creating it.
You can drop an index from a table using ``.drop_index(index_name)``:
.. code-block:: python
db.table("dogs").drop_index("idx_dogs_name")
Use ``ignore=True`` to ignore the error if the index does not exist.
.. note::
In the CLI: :ref:`sqlite-utils create-index <cli_create_index>` and :ref:`sqlite-utils drop-index <cli_drop_index>`
.. _python_api_analyze: .. _python_api_analyze:
Optimizing index usage with ANALYZE Optimizing index usage with ANALYZE
@ -2886,6 +3090,9 @@ To run against all indexes attached to a specific table, you can either pass the
db.table("dogs").analyze() db.table("dogs").analyze()
.. note::
In the CLI: :ref:`sqlite-utils analyze <cli_analyze>`
.. _python_api_vacuum: .. _python_api_vacuum:
Vacuum Vacuum
@ -2897,6 +3104,9 @@ You can optimize your database by running VACUUM against it like so:
Database("my_database.db").vacuum() Database("my_database.db").vacuum()
.. note::
In the CLI: :ref:`sqlite-utils vacuum <cli_vacuum>`
.. _python_api_wal: .. _python_api_wal:
WAL mode WAL mode
@ -2924,6 +3134,9 @@ You can check the current journal mode for a database using the ``journal_mode``
This will usually be ``wal`` or ``delete`` (meaning WAL is disabled), but can have other values - see the `PRAGMA journal_mode <https://www.sqlite.org/pragma.html#pragma_journal_mode>`__ documentation. This will usually be ``wal`` or ``delete`` (meaning WAL is disabled), but can have other values - see the `PRAGMA journal_mode <https://www.sqlite.org/pragma.html#pragma_journal_mode>`__ documentation.
.. note::
In the CLI: :ref:`sqlite-utils enable-wal and disable-wal <cli_wal>`
.. _python_api_suggest_column_types: .. _python_api_suggest_column_types:
Suggesting column types Suggesting column types
@ -3064,6 +3277,9 @@ You can cause ``sqlite3`` to return more useful errors, including the traceback
sqlite3.enable_callback_tracebacks(True) sqlite3.enable_callback_tracebacks(True)
.. note::
In the CLI: :ref:`sqlite-utils query --functions <cli_query_functions>`
.. _python_api_quote: .. _python_api_quote:
Quoting strings for use in SQL Quoting strings for use in SQL
@ -3196,6 +3412,9 @@ Initialize SpatiaLite
.. automethod:: sqlite_utils.db.Database.init_spatialite .. automethod:: sqlite_utils.db.Database.init_spatialite
:noindex: :noindex:
.. note::
In the CLI: :ref:`sqlite-utils create-database --init-spatialite <cli_create_database>`
.. _python_api_gis_find_spatialite: .. _python_api_gis_find_spatialite:
Finding SpatiaLite Finding SpatiaLite
@ -3211,6 +3430,9 @@ Adding geometry columns
.. automethod:: sqlite_utils.db.Table.add_geometry_column .. automethod:: sqlite_utils.db.Table.add_geometry_column
:noindex: :noindex:
.. note::
In the CLI: :ref:`sqlite-utils add-geometry-column <cli_spatialite>`
.. _python_api_gis_create_spatial_index: .. _python_api_gis_create_spatial_index:
Creating a spatial index Creating a spatial index
@ -3218,3 +3440,6 @@ Creating a spatial index
.. automethod:: sqlite_utils.db.Table.create_spatial_index .. automethod:: sqlite_utils.db.Table.create_spatial_index
:noindex: :noindex:
.. note::
In the CLI: :ref:`sqlite-utils create-spatial-index <cli_spatialite_indexes>`

View file

@ -77,6 +77,8 @@ Python API changes
**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. **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. **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. **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.
@ -93,7 +95,7 @@ Python API changes
for fk in db["courses"].foreign_keys: for fk in db["courses"].foreign_keys:
fk.table, fk.column, fk.other_table, fk.other_column 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. 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. 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.

View file

@ -1,6 +1,6 @@
[project] [project]
name = "sqlite-utils" name = "sqlite-utils"
version = "4.0rc3" version = "4.1.1"
description = "CLI tool and Python library for manipulating SQLite databases" description = "CLI tool and Python library for manipulating SQLite databases"
readme = { file = "README.md", content-type = "text/markdown" } readme = { file = "README.md", content-type = "text/markdown" }
authors = [ authors = [
@ -53,7 +53,6 @@ dev = [
"tabulate>=0.10.0", "tabulate>=0.10.0",
] ]
docs = [ docs = [
"beanbag-docutils>=2.0",
"codespell", "codespell",
"furo", "furo",
"pygments-csv-lexer", "pygments-csv-lexer",
@ -80,7 +79,14 @@ build-backend = "setuptools.build_meta"
max-line-length = 160 max-line-length = 160
# Black compatibility, E203 whitespace before ':': # Black compatibility, E203 whitespace before ':':
extend-ignore = ["E203"] extend-ignore = ["E203"]
extend-exclude = [".venv", "build", "dist", "docs", "sqlite_utils.egg-info"] extend-exclude = [
".venv",
".claude",
"build",
"dist",
"docs",
"sqlite_utils.egg-info",
]
[tool.setuptools.package-data] [tool.setuptools.package-data]
sqlite_utils = ["py.typed"] sqlite_utils = ["py.typed"]

View file

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

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,8 +1,7 @@
import sqlite3 import sqlite3
import click import click
from pluggy import HookimplMarker from pluggy import HookimplMarker, HookspecMarker
from pluggy import HookspecMarker
hookspec = HookspecMarker("sqlite_utils") hookspec = HookspecMarker("sqlite_utils")
hookimpl = HookimplMarker("sqlite_utils") hookimpl = HookimplMarker("sqlite_utils")

View file

@ -1,19 +1,28 @@
from collections.abc import Iterable
from dataclasses import dataclass
import datetime import datetime
from typing import Callable, cast, TYPE_CHECKING from collections.abc import Callable, Iterable
from dataclasses import dataclass
from typing import TYPE_CHECKING, Protocol, TypeVar, cast
if TYPE_CHECKING: if TYPE_CHECKING:
from sqlite_utils.db import Database, Table from sqlite_utils.db import Database, Table
class _MigrationFunction(Protocol):
__name__: str
def __call__(self, db: "Database", /) -> None: ...
_MigrationFunctionT = TypeVar("_MigrationFunctionT", bound=_MigrationFunction)
class Migrations: class Migrations:
migrations_table = "_sqlite_migrations" migrations_table = "_sqlite_migrations"
@dataclass @dataclass
class _Migration: class _Migration:
name: str name: str
fn: Callable fn: _MigrationFunction
transactional: bool = True transactional: bool = True
@dataclass @dataclass
@ -32,7 +41,7 @@ class Migrations:
def __call__( def __call__(
self, *, name: str | None = None, transactional: bool = True self, *, name: str | None = None, transactional: bool = True
) -> Callable: ) -> Callable[[_MigrationFunctionT], _MigrationFunctionT]:
""" """
:param name: The name to use for this migration - if not provided, :param name: The name to use for this migration - if not provided,
the name of the function will be used. the name of the function will be used.
@ -43,13 +52,11 @@ class Migrations:
example those that execute ``VACUUM``. example those that execute ``VACUUM``.
""" """
def inner(func: Callable) -> Callable: def inner(func: _MigrationFunctionT) -> _MigrationFunctionT:
migration_name = name or getattr(func, "__name__") migration_name = name or func.__name__
if any(m.name == migration_name for m in self._migrations): if any(m.name == migration_name for m in self._migrations):
raise ValueError( raise ValueError(
"Migration '{}' is already registered in set '{}'".format( f"Migration '{migration_name}' is already registered in set '{self.name}'"
migration_name, self.name
)
) )
self._migrations.append( self._migrations.append(
self._Migration(migration_name, func, transactional) self._Migration(migration_name, func, transactional)
@ -96,14 +103,34 @@ class Migrations:
changes are rolled back, no record is written and the migration stays changes are rolled back, no record is written and the migration stays
pending. Migrations registered with ``transactional=False`` run pending. Migrations registered with ``transactional=False`` run
outside of a transaction. 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
""" """
self.ensure_migrations_table(db)
if stop_before is None: if stop_before is None:
stop_before_names = set() stop_before_names = set()
elif isinstance(stop_before, str): elif isinstance(stop_before, str):
stop_before_names = {stop_before} stop_before_names = {stop_before}
else: else:
stop_before_names = set(stop_before) 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): for migration in self.pending(db):
name = migration.name name = migration.name
if name in stop_before_names: if name in stop_before_names:

View file

@ -1,7 +1,7 @@
from typing import Dict, List, Union import sys
import pluggy import pluggy
import sys
from . import hookspecs from . import hookspecs
pm: pluggy.PluginManager = pluggy.PluginManager("sqlite_utils") pm: pluggy.PluginManager = pluggy.PluginManager("sqlite_utils")
@ -17,13 +17,13 @@ def ensure_plugins_loaded() -> None:
_plugins_loaded = True _plugins_loaded = True
def get_plugins() -> List[Dict[str, Union[str, List[str]]]]: def get_plugins() -> list[dict[str, str | list[str]]]:
ensure_plugins_loaded() ensure_plugins_loaded()
plugins: List[Dict[str, Union[str, List[str]]]] = [] plugins: list[dict[str, str | list[str]]] = []
plugin_to_distinfo = dict(pm.list_plugin_distinfo()) plugin_to_distinfo = dict(pm.list_plugin_distinfo())
for plugin in pm.get_plugins(): for plugin in pm.get_plugins():
hookcallers = pm.get_hookcallers(plugin) or [] hookcallers = pm.get_hookcallers(plugin) or []
plugin_info: Dict[str, Union[str, List[str]]] = { plugin_info: dict[str, str | list[str]] = {
"name": plugin.__name__, "name": plugin.__name__,
"hooks": [h.name for h in hookcallers], "hooks": [h.name for h in hookcallers],
} }

View file

@ -1,9 +1,9 @@
from __future__ import annotations from __future__ import annotations
from typing import Callable, Optional import json
from collections.abc import Callable
from dateutil import parser from dateutil import parser
import json
IGNORE: object = object() IGNORE: object = object()
SET_NULL: object = object() SET_NULL: object = object()
@ -13,8 +13,8 @@ def parsedate(
value: str, value: str,
dayfirst: bool = False, dayfirst: bool = False,
yearfirst: bool = False, yearfirst: bool = False,
errors: Optional[object] = None, errors: object | None = None,
) -> Optional[str]: ) -> str | None:
""" """
Parse a date and convert it to ISO date format: yyyy-mm-dd Parse a date and convert it to ISO date format: yyyy-mm-dd
\b \b
@ -44,8 +44,8 @@ def parsedatetime(
value: str, value: str,
dayfirst: bool = False, dayfirst: bool = False,
yearfirst: bool = False, yearfirst: bool = False,
errors: Optional[object] = None, errors: object | None = None,
) -> Optional[str]: ) -> str | None:
""" """
Parse a datetime and convert it to ISO datetime format: yyyy-mm-ddTHH:MM:SS Parse a datetime and convert it to ISO datetime format: yyyy-mm-ddTHH:MM:SS
\b \b

View file

@ -9,20 +9,11 @@ import itertools
import json import json
import os import os
import sys import sys
from collections.abc import Callable, Generator, Iterable, Iterator
from typing import ( from typing import (
TYPE_CHECKING,
Any, Any,
BinaryIO, BinaryIO,
Callable,
Dict,
Generator,
Iterable,
Iterator,
List,
Optional,
Set,
Tuple,
Type,
TYPE_CHECKING,
TypeVar, TypeVar,
Union, Union,
cast, cast,
@ -33,8 +24,8 @@ import click
from . import recipes from . import recipes
if TYPE_CHECKING: if TYPE_CHECKING:
import sqlite3 # noqa: F401 import sqlite3
from sqlite3 import dbapi2 # noqa: F401 from sqlite3 import dbapi2
OperationalError = dbapi2.OperationalError OperationalError = dbapi2.OperationalError
else: else:
@ -44,7 +35,7 @@ else:
OperationalError = dbapi2.OperationalError OperationalError = dbapi2.OperationalError
except ImportError: except ImportError:
import sqlite3 # noqa: F401 import sqlite3 # noqa: F401
from sqlite3 import dbapi2 # noqa: F401 from sqlite3 import dbapi2
OperationalError = dbapi2.OperationalError OperationalError = dbapi2.OperationalError
@ -61,8 +52,8 @@ SPATIALITE_PATHS = (
ORIGINAL_CSV_FIELD_SIZE_LIMIT = csv.field_size_limit() ORIGINAL_CSV_FIELD_SIZE_LIMIT = csv.field_size_limit()
# Type alias for row dictionaries - values can be various SQLite-compatible types # Type alias for row dictionaries - values can be various SQLite-compatible types
RowValue = Union[None, int, float, str, bytes, bool, List[str]] RowValue = None | int | float | str | bytes | bool | list[str]
Row = Dict[str, RowValue] Row = dict[str, RowValue]
T = TypeVar("T") T = TypeVar("T")
@ -103,7 +94,7 @@ def maximize_csv_field_size_limit() -> None:
field_size_limit = int(field_size_limit / 10) field_size_limit = int(field_size_limit / 10)
def find_spatialite() -> Optional[str]: def find_spatialite() -> str | None:
""" """
The ``find_spatialite()`` function searches for the `SpatiaLite <https://www.gaia-gis.it/fossil/libspatialite/index>`__ 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. SQLite extension in some common places. It returns a string path to the location, or ``None`` if SpatiaLite was not found.
@ -132,9 +123,9 @@ def find_spatialite() -> Optional[str]:
def suggest_column_types( def suggest_column_types(
records: Iterable[Dict[str, Any]], records: Iterable[dict[str, Any]],
) -> Dict[str, type]: ) -> dict[str, type]:
all_column_types: Dict[str, Set[type]] = {} all_column_types: dict[str, set[type]] = {}
for record in records: for record in records:
for key, value in record.items(): for key, value in record.items():
all_column_types.setdefault(key, set()).add(type(value)) all_column_types.setdefault(key, set()).add(type(value))
@ -142,9 +133,9 @@ def suggest_column_types(
def types_for_column_types( def types_for_column_types(
all_column_types: Dict[str, Set[type]], all_column_types: dict[str, set[type]],
) -> Dict[str, type]: ) -> dict[str, type]:
column_types: Dict[str, type] = {} column_types: dict[str, type] = {}
for key, types in all_column_types.items(): for key, types in all_column_types.items():
# Ignore null values if at least one other type present: # Ignore null values if at least one other type present:
if len(types) > 1: if len(types) > 1:
@ -153,7 +144,7 @@ def types_for_column_types(
if {None.__class__} == types: if {None.__class__} == types:
t = str t = str
elif len(types) == 1: elif len(types) == 1:
t = list(types)[0] t = next(iter(types))
# But if it's a subclass of list / tuple / dict, use str # But if it's a subclass of list / tuple / dict, use str
# instead as we will be storing it as JSON in the table # instead as we will be storing it as JSON in the table
for superclass in (list, tuple, dict): for superclass in (list, tuple, dict):
@ -190,7 +181,7 @@ def column_affinity(column_type: str) -> type:
return float return float
def decode_base64_values(doc: Dict[str, Any]) -> Dict[str, Any]: def decode_base64_values(doc: dict[str, Any]) -> dict[str, Any]:
# Looks for '{"$base64": true..., "encoded": ...}' values and decodes them # Looks for '{"$base64": true..., "encoded": ...}' values and decodes them
to_fix = [ to_fix = [
k k
@ -263,9 +254,9 @@ class RowError(Exception):
def _extra_key_strategy( def _extra_key_strategy(
reader: Iterable[Dict[Optional[str], object]], reader: Iterable[dict[str | None, object]],
ignore_extras: Optional[bool] = False, ignore_extras: bool | None = False,
extras_key: Optional[str] = None, extras_key: str | None = None,
) -> Iterable[Row]: ) -> Iterable[Row]:
# Logic for handling CSV rows with more values than there are headings # Logic for handling CSV rows with more values than there are headings
for row in reader: for row in reader:
@ -279,9 +270,7 @@ def _extra_key_strategy(
yield cast(Row, row) yield cast(Row, row)
elif not extras_key: elif not extras_key:
extras = row.pop(None) extras = row.pop(None)
raise RowError( raise RowError(f"Row {row} contained these extra values: {extras}")
"Row {} contained these extra values: {}".format(row, extras)
)
else: else:
extras_value = row.pop(None) extras_value = row.pop(None)
row_out = cast(Row, row) row_out = cast(Row, row)
@ -291,12 +280,12 @@ def _extra_key_strategy(
def rows_from_file( def rows_from_file(
fp: BinaryIO, fp: BinaryIO,
format: Optional[Format] = None, format: Format | None = None,
dialect: Optional[Type[csv.Dialect]] = None, dialect: type[csv.Dialect] | None = None,
encoding: Optional[str] = None, encoding: str | None = None,
ignore_extras: Optional[bool] = False, ignore_extras: bool | None = False,
extras_key: Optional[str] = None, extras_key: str | None = None,
) -> Tuple[Iterable[Row], Format]: ) -> tuple[Iterable[Row], Format]:
""" """
Load a sequence of dictionaries from a file-like object containing one of four different formats. Load a sequence of dictionaries from a file-like object containing one of four different formats.
@ -363,7 +352,7 @@ def rows_from_file(
) )
return ( return (
_extra_key_strategy( _extra_key_strategy(
cast(Iterable[Dict[Optional[str], object]], rows), cast(Iterable[dict[str | None, object]], rows),
ignore_extras, ignore_extras,
extras_key, extras_key,
), ),
@ -379,7 +368,7 @@ def rows_from_file(
raise TypeError( raise TypeError(
"rows_from_file() requires a file-like object that supports peek(), such as io.BytesIO" "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"{"): if first_bytes.startswith((b"[", b"{")):
# TODO: Detect newline-JSON # TODO: Detect newline-JSON
return rows_from_file(buffered, format=Format.JSON) return rows_from_file(buffered, format=Format.JSON)
else: else:
@ -393,7 +382,7 @@ def rows_from_file(
detected_format = Format.TSV if dialect.delimiter == "\t" else Format.CSV detected_format = Format.TSV if dialect.delimiter == "\t" else Format.CSV
return ( return (
_extra_key_strategy( _extra_key_strategy(
cast(Iterable[Dict[Optional[str], object]], rows), cast(Iterable[dict[str | None, object]], rows),
ignore_extras, ignore_extras,
extras_key, extras_key,
), ),
@ -425,9 +414,9 @@ class TypeTracker:
""" """
def __init__(self) -> None: def __init__(self) -> None:
self.trackers: Dict[str, "ValueTracker"] = {} self.trackers: dict[str, ValueTracker] = {}
def wrap(self, iterator: Iterable[Dict[str, Any]]) -> Iterable[Dict[str, Any]]: def wrap(self, iterator: Iterable[dict[str, Any]]) -> Iterable[dict[str, Any]]:
""" """
Use this to loop through an existing iterator, tracking the column types Use this to loop through an existing iterator, tracking the column types
as part of the iteration. as part of the iteration.
@ -441,7 +430,7 @@ class TypeTracker:
yield row yield row
@property @property
def types(self) -> Dict[str, str]: def types(self) -> dict[str, str]:
""" """
A dictionary mapping column names to their detected types. This can be passed A dictionary mapping column names to their detected types. This can be passed
to the ``db[table_name].transform(types=tracker.types)`` method. to the ``db[table_name].transform(types=tracker.types)`` method.
@ -450,17 +439,15 @@ class TypeTracker:
class ValueTracker: class ValueTracker:
couldbe: Dict[str, Callable[[object], bool]] couldbe: dict[str, Callable[[object], bool]]
def __init__(self) -> None: def __init__(self) -> None:
self.couldbe = {key: getattr(self, "test_" + key) for key in self.get_tests()} self.couldbe = {key: getattr(self, "test_" + key) for key in self.get_tests()}
@classmethod @classmethod
def get_tests(cls) -> List[str]: def get_tests(cls) -> list[str]:
return [ return [
key.split("test_")[-1] key.split("test_")[-1] for key in cls.__dict__ if key.startswith("test_")
for key in cls.__dict__.keys()
if key.startswith("test_")
] ]
def test_integer(self, value: object) -> bool: def test_integer(self, value: object) -> bool:
@ -492,7 +479,7 @@ class ValueTracker:
def evaluate(self, value: object) -> None: def evaluate(self, value: object) -> None:
if not value or not self.couldbe: if not value or not self.couldbe:
return return
not_these: List[str] = [] not_these: list[str] = []
for name, test in self.couldbe.items(): for name, test in self.couldbe.items():
if not test(value): if not test(value):
not_these.append(name) not_these.append(name)
@ -524,14 +511,14 @@ def progressbar(*args: Iterable[T], **kwargs: Any) -> Generator[Any, None, None]
def _compile_code( def _compile_code(
code: str, imports: Iterable[str], variable: str = "value" code: str, imports: Iterable[str], variable: str = "value"
) -> Callable[..., Any]: ) -> Callable[..., Any]:
globals_dict: Dict[str, Any] = {"r": recipes, "recipes": recipes} globals_dict: dict[str, Any] = {"r": recipes, "recipes": recipes}
# Handle imports first so they're available for all approaches # Handle imports first so they're available for all approaches
for import_ in imports: for import_ in imports:
globals_dict[import_.split(".")[0]] = __import__(import_) globals_dict[import_.split(".")[0]] = __import__(import_)
# If user defined a convert() function, return that # If user defined a convert() function, return that
try: try:
exec(code, globals_dict) exec(code, globals_dict) # noqa: S102
return cast(Callable[..., object], globals_dict["convert"]) return cast(Callable[..., object], globals_dict["convert"])
except (AttributeError, SyntaxError, NameError, KeyError, TypeError): except (AttributeError, SyntaxError, NameError, KeyError, TypeError):
pass pass
@ -542,20 +529,20 @@ def _compile_code(
fn = eval(code, globals_dict) fn = eval(code, globals_dict)
if callable(fn): if callable(fn):
return cast(Callable[..., object], fn) return cast(Callable[..., object], fn)
except Exception: except Exception: # noqa: BLE001, S110
pass pass
# Try compiling their code as a function instead # Try compiling their code as a function instead
body_variants = [code] body_variants = [code]
# If single line and no 'return', try adding the return # If single line and no 'return', try adding the return
if "\n" not in code and not code.strip().startswith("return "): if "\n" not in code and not code.strip().startswith("return "):
body_variants.insert(0, "return {}".format(code)) body_variants.insert(0, f"return {code}")
code_o = None code_o = None
for variant in body_variants: for variant in body_variants:
new_code = ["def fn({}):".format(variable)] new_code = [f"def fn({variable}):"]
for line in variant.split("\n"): for line in variant.split("\n"):
new_code.append(" {}".format(line)) new_code.append(f" {line}")
try: try:
code_o = compile("\n".join(new_code), "<string>", "exec") code_o = compile("\n".join(new_code), "<string>", "exec")
break break
@ -566,7 +553,7 @@ def _compile_code(
if code_o is None: if code_o is None:
raise SyntaxError("Could not compile code") raise SyntaxError("Could not compile code")
exec(code_o, globals_dict) exec(code_o, globals_dict) # noqa: S102
return cast(Callable[..., object], globals_dict["fn"]) return cast(Callable[..., object], globals_dict["fn"])
@ -582,7 +569,7 @@ def chunks(sequence: Iterable[T], size: int) -> Iterable[Iterable[T]]:
yield itertools.chain([item], itertools.islice(iterator, size - 1)) yield itertools.chain([item], itertools.islice(iterator, size - 1))
def hash_record(record: Dict[str, Any], keys: Optional[Iterable[str]] = None) -> str: def hash_record(record: dict[str, Any], keys: Iterable[str] | None = None) -> str:
""" """
``record`` should be a Python dictionary. Returns a sha1 hash of the ``record`` should be a Python dictionary. Returns a sha1 hash of the
keys and values in that record. keys and values in that record.
@ -603,7 +590,7 @@ def hash_record(record: Dict[str, Any], keys: Optional[Iterable[str]] = None) ->
:param record: Record to generate a hash for :param record: Record to generate a hash for
:param keys: Subset of keys to use for that hash :param keys: Subset of keys to use for that hash
""" """
to_hash: Dict[str, Any] = record to_hash: dict[str, Any] = record
if keys is not None: if keys is not None:
to_hash = {key: record[key] for key in keys} to_hash = {key: record[key] for key in keys}
return hashlib.sha1( return hashlib.sha1(
@ -613,7 +600,7 @@ def hash_record(record: Dict[str, Any], keys: Optional[Iterable[str]] = None) ->
).hexdigest() ).hexdigest()
def dedupe_keys(keys: Iterable[str]) -> List[str]: def dedupe_keys(keys: Iterable[str]) -> list[str]:
""" """
Rename duplicates in a list of column names so every name is unique, Rename duplicates in a list of column names so every name is unique,
by appending ``_2``, ``_3``... to later occurrences - skipping any by appending ``_2``, ``_3``... to later occurrences - skipping any
@ -636,7 +623,7 @@ def dedupe_keys(keys: Iterable[str]) -> List[str]:
new_key = key new_key = key
suffix = 2 suffix = 2
while new_key in seen or new_key in taken: while new_key in seen or new_key in taken:
new_key = "{}_{}".format(key, suffix) new_key = f"{key}_{suffix}"
suffix += 1 suffix += 1
key = new_key key = new_key
seen.add(key) seen.add(key)
@ -644,7 +631,7 @@ def dedupe_keys(keys: Iterable[str]) -> List[str]:
return result return result
def _flatten(d: Dict[str, Any]) -> Generator[Tuple[str, Any], None, None]: def _flatten(d: dict[str, Any]) -> Generator[tuple[str, Any], None, None]:
for key, value in d.items(): for key, value in d.items():
if isinstance(value, dict): if isinstance(value, dict):
for key2, value2 in _flatten(value): for key2, value2 in _flatten(value):
@ -653,7 +640,7 @@ def _flatten(d: Dict[str, Any]) -> Generator[Tuple[str, Any], None, None]:
yield key, value yield key, value
def flatten(row: Dict[str, Any]) -> Dict[str, Any]: 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}`` Turn a nested dict e.g. ``{"a": {"b": 1}}`` into a flat dict: ``{"a_b": 1}``

View file

@ -1,6 +1,7 @@
import pytest
from sqlite_utils import Database from sqlite_utils import Database
from sqlite_utils.utils import sqlite3 from sqlite_utils.utils import sqlite3
import pytest
CREATE_TABLES = """ CREATE_TABLES = """
create table Gosh (c1 text, c2 text, c3 text); create table Gosh (c1 text, c2 text, c3 text);
@ -55,7 +56,7 @@ def close_all_databases():
for db in databases: for db in databases:
try: try:
db.close() db.close()
except Exception: except sqlite3.Error:
pass pass

View file

@ -1,9 +1,11 @@
from sqlite_utils.db import Database, ColumnDetails
from sqlite_utils import cli
from click.testing import CliRunner
import pytest
import sqlite3 import sqlite3
import pytest
from click.testing import CliRunner
from sqlite_utils import cli
from sqlite_utils.db import ColumnDetails, Database
@pytest.fixture @pytest.fixture
def db_to_analyze(fresh_db): def db_to_analyze(fresh_db):

View file

@ -28,11 +28,13 @@ from sqlite_utils.utils import sqlite3
END; END;
""", """,
[ [
(
"CREATE TRIGGER t_ai AFTER INSERT ON t\n" "CREATE TRIGGER t_ai AFTER INSERT ON t\n"
" BEGIN\n" " BEGIN\n"
" UPDATE t SET value = 'a;b' WHERE id = new.id;\n" " UPDATE t SET value = 'a;b' WHERE id = new.id;\n"
" INSERT INTO log VALUES ('x;y');\n" " INSERT INTO log VALUES ('x;y');\n"
" END;" " END;"
)
], ],
), ),
), ),
@ -49,8 +51,7 @@ def test_atomic_commits(fresh_db):
def test_atomic_rolls_back(fresh_db): def test_atomic_rolls_back(fresh_db):
with pytest.raises(RuntimeError): with pytest.raises(RuntimeError), fresh_db.atomic():
with fresh_db.atomic():
fresh_db["dogs"].insert({"id": 1, "name": "Cleo"}, pk="id") fresh_db["dogs"].insert({"id": 1, "name": "Cleo"}, pk="id")
raise RuntimeError("boom") raise RuntimeError("boom")
@ -62,8 +63,7 @@ def test_nested_atomic_rolls_back_to_savepoint(fresh_db):
with fresh_db.atomic(): with fresh_db.atomic():
fresh_db["dogs"].insert({"id": 1, "name": "Cleo"}) fresh_db["dogs"].insert({"id": 1, "name": "Cleo"})
with pytest.raises(RuntimeError): with pytest.raises(RuntimeError), fresh_db.atomic():
with fresh_db.atomic():
fresh_db["dogs"].insert({"id": 2, "name": "Pancakes"}) fresh_db["dogs"].insert({"id": 2, "name": "Pancakes"})
raise RuntimeError("boom") raise RuntimeError("boom")
fresh_db["dogs"].insert({"id": 3, "name": "Marnie"}) fresh_db["dogs"].insert({"id": 3, "name": "Marnie"})
@ -75,8 +75,7 @@ def test_nested_atomic_rolls_back_to_savepoint(fresh_db):
def test_outer_atomic_rolls_back_released_savepoint(fresh_db): def test_outer_atomic_rolls_back_released_savepoint(fresh_db):
with pytest.raises(RuntimeError): with pytest.raises(RuntimeError), fresh_db.atomic():
with fresh_db.atomic():
fresh_db["dogs"].insert({"id": 1, "name": "Cleo"}, pk="id") fresh_db["dogs"].insert({"id": 1, "name": "Cleo"}, pk="id")
with fresh_db.atomic(): with fresh_db.atomic():
fresh_db["dogs"].insert({"id": 2, "name": "Pancakes"}) fresh_db["dogs"].insert({"id": 2, "name": "Pancakes"})
@ -86,8 +85,7 @@ def test_outer_atomic_rolls_back_released_savepoint(fresh_db):
def test_executescript_does_not_commit_open_atomic_block(fresh_db): def test_executescript_does_not_commit_open_atomic_block(fresh_db):
with pytest.raises(RuntimeError): with pytest.raises(RuntimeError), fresh_db.atomic():
with fresh_db.atomic():
fresh_db.executescript(""" fresh_db.executescript("""
CREATE TABLE dogs(id INTEGER PRIMARY KEY, name TEXT); CREATE TABLE dogs(id INTEGER PRIMARY KEY, name TEXT);
CREATE TRIGGER dogs_ai AFTER INSERT ON dogs CREATE TRIGGER dogs_ai AFTER INSERT ON dogs
@ -105,8 +103,7 @@ def test_executescript_does_not_commit_open_atomic_block(fresh_db):
def test_transform_does_not_commit_open_atomic_block(fresh_db): def test_transform_does_not_commit_open_atomic_block(fresh_db):
fresh_db["dogs"].insert({"id": 1, "name": "Cleo", "age": "5"}, pk="id") fresh_db["dogs"].insert({"id": 1, "name": "Cleo", "age": "5"}, pk="id")
with pytest.raises(RuntimeError): with pytest.raises(RuntimeError), fresh_db.atomic():
with fresh_db.atomic():
fresh_db["dogs"].insert({"id": 2, "name": "Pancakes", "age": "6"}) fresh_db["dogs"].insert({"id": 2, "name": "Pancakes", "age": "6"})
fresh_db["dogs"].transform(rename={"age": "dog_age"}) fresh_db["dogs"].transform(rename={"age": "dog_age"})
raise RuntimeError("boom") raise RuntimeError("boom")
@ -149,8 +146,7 @@ def test_transform_parent_table_with_foreign_keys_rolls_back(fresh_db):
foreign_keys={"author_id"}, foreign_keys={"author_id"},
) )
with pytest.raises(RuntimeError): with pytest.raises(RuntimeError), fresh_db.atomic():
with fresh_db.atomic():
fresh_db["authors"].transform(rename={"name": "full_name"}) fresh_db["authors"].transform(rename={"name": "full_name"})
raise RuntimeError("boom") raise RuntimeError("boom")
@ -258,6 +254,70 @@ def test_execute_comment_prefixed_begin_leaves_transaction_open(fresh_db):
assert [r["id"] for r in fresh_db["t"].rows] == [1] 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): def test_query_returning_commits_after_iteration(tmpdir):
if sqlite3.sqlite_version_info < (3, 35, 0): if sqlite3.sqlite_version_info < (3, 35, 0):
import pytest as _pytest import pytest as _pytest
@ -273,3 +333,49 @@ def test_query_returning_commits_after_iteration(tmpdir):
assert other.execute("select count(*) from t").fetchone()[0] == 2 assert other.execute("select count(*) from t").fetchone()[0] == 2
other.close() other.close()
db.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"),
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"),
fresh_db.atomic(),
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), fresh_db.atomic():
fresh_db.execute("insert or rollback into t (id) values (1)")
assert not fresh_db.conn.in_transaction

View file

@ -1,13 +1,16 @@
from sqlite_utils import cli, Database
from sqlite_utils.db import Index, ForeignKey
from click.testing import CliRunner
from pathlib import Path
import subprocess
import sys
import json import json
import os import os
import pytest import sqlite3
import subprocess
import sys
import textwrap import textwrap
from pathlib import Path
import pytest
from click.testing import CliRunner
from sqlite_utils import Database, cli
from sqlite_utils.db import ForeignKey, Index
def write_json(file_path, data): def write_json(file_path, data):
@ -20,7 +23,7 @@ def _supports_pragma_function_list():
try: try:
db.execute("select * from pragma_function_list()") db.execute("select * from pragma_function_list()")
return True return True
except Exception: except sqlite3.DatabaseError:
return False return False
finally: finally:
db.close() db.close()
@ -183,9 +186,9 @@ def test_output_table(db_path, options, expected):
db["rows"].insert_all( db["rows"].insert_all(
[ [
{ {
"c1": "verb{}".format(i), "c1": f"verb{i}",
"c2": "noun{}".format(i), "c2": f"noun{i}",
"c3": "adjective{}".format(i), "c3": f"adjective{i}",
} }
for i in range(4) for i in range(4)
] ]
@ -195,6 +198,50 @@ def test_output_table(db_path, options, expected):
assert expected == result.output.strip() assert expected == result.output.strip()
@pytest.mark.parametrize(
"fmt_option", [["--fmt", "simple"], ["-t"], ["--fmt", "github"]]
)
def test_output_table_no_headers(db_path, fmt_option):
# --no-headers should omit the header row from --fmt/--table output too, not
# just from --csv/--tsv (#566). Previously the flag was silently ignored for
# tabulate formats and the column names were always printed.
db = Database(db_path)
with db.conn:
db["dogs"].insert_all(
[
{"id": 1, "name": "Cleo", "age": 4},
{"id": 2, "name": "Pancakes", "age": 2},
]
)
sql = "select id, name, age from dogs order by id"
with_headers = CliRunner().invoke(cli.cli, ["query", db_path, sql] + fmt_option)
without_headers = CliRunner().invoke(
cli.cli, ["query", db_path, sql] + fmt_option + ["--no-headers"]
)
assert with_headers.exit_code == 0
assert without_headers.exit_code == 0
# The column names appear when headers are shown, and must not appear at all
# once --no-headers is passed.
assert "name" in with_headers.output
for header in ("id", "name", "age"):
assert (
header not in without_headers.output
), f"header {header!r} leaked into --no-headers output"
# The data is still all present.
for value in ("Cleo", "Pancakes", "1", "2", "4"):
assert value in without_headers.output
# The rows command shares the same code path.
rows_no_headers = CliRunner().invoke(
cli.cli, ["rows", db_path, "dogs"] + fmt_option + ["--no-headers"]
)
assert rows_no_headers.exit_code == 0
assert "name" not in rows_no_headers.output
assert "Cleo" in rows_no_headers.output
def test_create_index(db_path): def test_create_index(db_path):
db = Database(db_path) db = Database(db_path)
assert [] == db["Gosh"].indexes assert [] == db["Gosh"].indexes
@ -247,6 +294,24 @@ def test_create_index(db_path):
) )
def test_drop_index(db_path):
db = Database(db_path)
db["Gosh"].create_index(["c1"])
assert [index.name for index in db["Gosh"].indexes] == ["idx_Gosh_c1"]
result = CliRunner().invoke(cli.cli, ["drop-index", db_path, "Gosh", "idx_Gosh_c1"])
assert result.exit_code == 0
assert db["Gosh"].indexes == []
result = CliRunner().invoke(cli.cli, ["drop-index", db_path, "Gosh", "idx_Gosh_c1"])
assert result.exit_code == 1
assert "No index named idx_Gosh_c1" in result.output
result = CliRunner().invoke(
cli.cli, ["drop-index", db_path, "Gosh", "idx_Gosh_c1", "--ignore"]
)
assert result.exit_code == 0
def test_create_index_analyze(db_path): def test_create_index_analyze(db_path):
db = Database(db_path) db = Database(db_path)
assert "sqlite_stat1" not in db.table_names() assert "sqlite_stat1" not in db.table_names()
@ -615,9 +680,9 @@ def test_optimize(db_path, tables):
db[table].insert_all( db[table].insert_all(
[ [
{ {
"c1": "verb{}".format(i), "c1": f"verb{i}",
"c2": "noun{}".format(i), "c2": f"noun{i}",
"c3": "adjective{}".format(i), "c3": f"adjective{i}",
} }
for i in range(10000) for i in range(10000)
] ]
@ -641,9 +706,9 @@ def test_rebuild_fts_fixes_docsize_error(db_path):
db = Database(db_path, recursive_triggers=False) db = Database(db_path, recursive_triggers=False)
records = [ records = [
{ {
"c1": "verb{}".format(i), "c1": f"verb{i}",
"c2": "noun{}".format(i), "c2": f"noun{i}",
"c3": "adjective{}".format(i), "c3": f"adjective{i}",
} }
for i in range(10000) for i in range(10000)
] ]
@ -738,6 +803,25 @@ def test_query_json(db_path, sql, args, expected):
assert expected == result.output.strip() assert expected == result.output.strip()
def test_query_sql_from_stdin(db_path):
# https://github.com/simonw/sqlite-utils/issues/765
db = Database(db_path)
with db.conn:
db["dogs"].insert_all(
[
{"id": 1, "age": 4, "name": "Cleo"},
{"id": 2, "age": 2, "name": "Pancakes"},
]
)
result = CliRunner().invoke(
cli.cli,
["query", db_path, "-"],
input="select name from dogs order by name",
)
assert result.exit_code == 0, result.output
assert json.loads(result.output) == [{"name": "Cleo"}, {"name": "Pancakes"}]
def test_query_json_empty(db_path): def test_query_json_empty(db_path):
result = CliRunner().invoke( result = CliRunner().invoke(
cli.cli, cli.cli,
@ -937,7 +1021,6 @@ def test_query_json_binary(db_path):
"data": { "data": {
"$base64": True, "$base64": True,
"encoded": ( "encoded": (
(
"eJzt0c1xAyEMBeC7q1ABHleR3HxNAQrIjmb4M0gelx+RTY7p4N2WBYT0vmufUknH" "eJzt0c1xAyEMBeC7q1ABHleR3HxNAQrIjmb4M0gelx+RTY7p4N2WBYT0vmufUknH"
"8kq5lz5pqRFXsTOl3pYkE/NJnHXoStruJEVjc0mOCyTqq/ZMJnXEZW1Js2ZvRm5U+" "8kq5lz5pqRFXsTOl3pYkE/NJnHXoStruJEVjc0mOCyTqq/ZMJnXEZW1Js2ZvRm5U+"
"DPKk9hRWqjyvTFx0YfzhT6MpGmN2lR1fzxjyfVMD9dFrS+bnkleMpMam/ZGXgrX1I" "DPKk9hRWqjyvTFx0YfzhT6MpGmN2lR1fzxjyfVMD9dFrS+bnkleMpMam/ZGXgrX1I"
@ -946,7 +1029,6 @@ def test_query_json_binary(db_path):
"iCnG7Jql7RR3UvFo8jJ4z039dtOkTFmWzL1be9lt8A5II471m6vXy+l0BR/4wAc+8" "iCnG7Jql7RR3UvFo8jJ4z039dtOkTFmWzL1be9lt8A5II471m6vXy+l0BR/4wAc+8"
"IEPfOADH/jABz7wgQ984AMf+MAHPvCBD3zgAx/4wAc+8IEPfOADH/jABz7wgQ984A" "IEPfOADH/jABz7wgQ984AMf+MAHPvCBD3zgAx/4wAc+8IEPfOADH/jABz7wgQ984A"
"Mf+MAHPvCBD3zgAx/4wAc+8IEPfOADH/jABz7wgQ984PuP7xubBoN9" "Mf+MAHPvCBD3zgAx/4wAc+8IEPfOADH/jABz7wgQ984PuP7xubBoN9"
)
), ),
}, },
} }
@ -1003,6 +1085,34 @@ def test_query_json_with_json_cols(db_path):
assert expected == result_rows.output.strip() assert expected == result_rows.output.strip()
def test_query_json_unicode_not_escaped_by_default(db_path):
db = Database(db_path)
with db.conn:
db["text"].insert({"id": 1, "text": "Japanese 日本語"}, pk="id")
result = CliRunner().invoke(cli.cli, [db_path, "select id, text from text"])
assert result.exit_code == 0
assert result.output.strip() == '[{"id": 1, "text": "Japanese 日本語"}]'
# Same for --nl
result = CliRunner().invoke(cli.cli, [db_path, "select id, text from text", "--nl"])
assert result.exit_code == 0
assert result.output.strip() == '{"id": 1, "text": "Japanese 日本語"}'
@pytest.mark.parametrize("command", ["query", "rows"])
def test_query_json_ascii_option(db_path, command):
db = Database(db_path)
with db.conn:
db["text"].insert({"id": 1, "text": "Japanese 日本語"}, pk="id")
if command == "query":
args = [db_path, "select id, text from text", "--ascii"]
else:
args = ["rows", db_path, "text", "--ascii"]
result = CliRunner().invoke(cli.cli, args)
assert result.exit_code == 0
expected = '[{"id": 1, "text": "Japanese ' + "\\u65e5\\u672c\\u8a9e" + '"}]'
assert result.output.strip() == expected
@pytest.mark.parametrize( @pytest.mark.parametrize(
"content,is_binary", "content,is_binary",
[(b"\x00\x0fbinary", True), ("this is text", False), (1, False), (1.5, False)], [(b"\x00\x0fbinary", True), ("this is text", False), (1, False), (1.5, False)],
@ -1144,20 +1254,38 @@ def test_upsert(db_path, tmpdir):
] ]
def test_upsert_pk_required(db_path, tmpdir): def test_upsert_pk_inferred_from_existing_table(db_path, tmpdir):
json_path = str(tmpdir / "dogs.json") json_path = str(tmpdir / "dogs.json")
db = Database(db_path)
insert_dogs = [ insert_dogs = [
{"id": 1, "name": "Cleo", "age": 4}, {"id": 1, "name": "Cleo", "age": 4},
{"id": 2, "name": "Nixie", "age": 4}, {"id": 2, "name": "Nixie", "age": 4},
] ]
write_json(json_path, insert_dogs) write_json(json_path, insert_dogs)
result = CliRunner().invoke(
cli.cli,
["insert", db_path, "dogs", json_path, "--pk", "id"],
catch_exceptions=False,
)
assert result.exit_code == 0, result.output
write_json(
json_path,
[
{"id": 1, "age": 5},
{"id": 2, "age": 5},
],
)
result = CliRunner().invoke( result = CliRunner().invoke(
cli.cli, cli.cli,
["upsert", db_path, "dogs", json_path], ["upsert", db_path, "dogs", json_path],
catch_exceptions=False, catch_exceptions=False,
) )
assert result.exit_code == 2 assert result.exit_code == 0, result.output
assert "Error: Missing option '--pk'" in result.output assert list(db.query("select * from dogs order by id")) == [
{"id": 1, "name": "Cleo", "age": 5},
{"id": 2, "name": "Nixie", "age": 5},
]
def test_upsert_analyze(db_path, tmpdir): def test_upsert_analyze(db_path, tmpdir):
@ -1812,6 +1940,64 @@ def test_transform_sql(db_path):
assert db["dogs"].schema == original_schema assert db["dogs"].schema == original_schema
@pytest.mark.parametrize(
"initial_strict,args,expected_strict",
(
(False, [], False),
(True, [], True),
(False, ["--strict"], True),
(True, ["--no-strict"], False),
),
)
def test_transform_strict_option(db_path, initial_strict, args, expected_strict):
db = Database(db_path)
if not db.supports_strict:
pytest.skip("SQLite version does not support strict tables")
db["dogs"].create({"id": int}, strict=initial_strict)
result = CliRunner().invoke(cli.cli, ["transform", db_path, "dogs"] + args)
assert result.exit_code == 0, result.output
assert db["dogs"].strict is expected_strict
@pytest.mark.parametrize(
"initial_strict,flag,sql_is_strict",
(
(False, "--strict", True),
(True, "--no-strict", False),
),
)
def test_transform_strict_option_sql(db_path, initial_strict, flag, sql_is_strict):
db = Database(db_path)
if not db.supports_strict:
pytest.skip("SQLite version does not support strict tables")
db["dogs"].create({"id": int}, strict=initial_strict)
result = CliRunner().invoke(cli.cli, ["transform", db_path, "dogs", flag, "--sql"])
assert result.exit_code == 0, result.output
assert (") STRICT;" in result.output) is sql_is_strict
assert db["dogs"].strict is initial_strict
def test_transform_strict_option_with_invalid_data(db_path):
db = Database(db_path)
if not db.supports_strict:
pytest.skip("SQLite version does not support strict tables")
dogs = db["dogs"]
dogs.create({"id": int})
dogs.insert({"id": "not-an-integer"})
result = CliRunner().invoke(cli.cli, ["transform", db_path, "dogs", "--strict"])
assert result.exit_code == 1
assert isinstance(result.exception, sqlite3.IntegrityError)
assert dogs.strict is False
assert list(dogs.rows) == [{"id": "not-an-integer"}]
assert not any(name.startswith("dogs_new_") for name in db.table_names())
@pytest.mark.parametrize( @pytest.mark.parametrize(
"extra_args,expected_schema", "extra_args,expected_schema",
( (
@ -1928,11 +2114,13 @@ _common_other_schema = (
), ),
( (
["--rename", "name", "name2"], ["--rename", "name", "name2"],
(
'CREATE TABLE "trees" (\n' 'CREATE TABLE "trees" (\n'
' "id" INTEGER PRIMARY KEY,\n' ' "id" INTEGER PRIMARY KEY,\n'
' "address" TEXT,\n' ' "address" TEXT,\n'
' "species_id" INTEGER REFERENCES "species"("id")\n' ' "species_id" INTEGER REFERENCES "species"("id")\n'
")", ")"
),
'CREATE TABLE "species" (\n "id" INTEGER PRIMARY KEY,\n "species" TEXT\n)', 'CREATE TABLE "species" (\n "id" INTEGER PRIMARY KEY,\n "species" TEXT\n)',
), ),
], ],
@ -1951,9 +2139,9 @@ def test_extract(db_path, args, expected_table_schema, expected_other_schema):
assert result.exit_code == 0 assert result.exit_code == 0
schema = db["trees"].schema schema = db["trees"].schema
assert schema == expected_table_schema assert schema == expected_table_schema
other_schema = [t for t in db.tables if t.name not in ("trees", "Gosh", "Gosh2")][ other_schema = next(
0 t for t in db.tables if t.name not in ("trees", "Gosh", "Gosh2")
].schema ).schema
assert other_schema == expected_other_schema assert other_schema == expected_other_schema
@ -2245,7 +2433,7 @@ def test_long_csv_column_value(tmpdir):
with open(csv_path, "w") as csv_file: with open(csv_path, "w") as csv_file:
long_string = "a" * 131073 long_string = "a" * 131073
csv_file.write("id,text\n") csv_file.write("id,text\n")
csv_file.write("1,{}\n".format(long_string)) csv_file.write(f"1,{long_string}\n")
result = CliRunner().invoke( result = CliRunner().invoke(
cli.cli, cli.cli,
["insert", db_path, "bigtable", csv_path, "--csv"], ["insert", db_path, "bigtable", csv_path, "--csv"],
@ -2271,8 +2459,8 @@ def test_import_no_headers(tmpdir, args, tsv):
csv_path = str(tmpdir / "test.csv") csv_path = str(tmpdir / "test.csv")
with open(csv_path, "w") as csv_file: with open(csv_path, "w") as csv_file:
sep = "\t" if tsv else "," sep = "\t" if tsv else ","
csv_file.write("Cleo{sep}Dog{sep}5\n".format(sep=sep)) csv_file.write(f"Cleo{sep}Dog{sep}5\n")
csv_file.write("Tracy{sep}Spider{sep}7\n".format(sep=sep)) csv_file.write(f"Tracy{sep}Spider{sep}7\n")
result = CliRunner().invoke( result = CliRunner().invoke(
cli.cli, cli.cli,
["insert", db_path, "creatures", csv_path] + args + ["--no-detect-types"], ["insert", db_path, "creatures", csv_path] + args + ["--no-detect-types"],
@ -2504,7 +2692,9 @@ def test_integer_overflow_error(tmpdir):
def test_python_dash_m(): def test_python_dash_m():
"Tool can be run using python -m sqlite_utils" "Tool can be run using python -m sqlite_utils"
result = subprocess.run( result = subprocess.run(
[sys.executable, "-m", "sqlite_utils", "--help"], stdout=subprocess.PIPE [sys.executable, "-m", "sqlite_utils", "--help"],
stdout=subprocess.PIPE,
check=False,
) )
assert result.returncode == 0 assert result.returncode == 0
assert b"Commands for interacting with a SQLite database" in result.stdout assert b"Commands for interacting with a SQLite database" in result.stdout
@ -2644,14 +2834,14 @@ def test_load_extension(entrypoint, should_pass, should_fail):
for func in should_pass: for func in should_pass:
result = CliRunner().invoke( result = CliRunner().invoke(
cli.cli, cli.cli,
["memory", "select {}()".format(func), "--load-extension", ext], ["memory", f"select {func}()", "--load-extension", ext],
catch_exceptions=False, catch_exceptions=False,
) )
assert result.exit_code == 0 assert result.exit_code == 0
for func in should_fail: for func in should_fail:
result = CliRunner().invoke( result = CliRunner().invoke(
cli.cli, cli.cli,
["memory", "select {}()".format(func), "--load-extension", ext], ["memory", f"select {func}()", "--load-extension", ext],
catch_exceptions=False, catch_exceptions=False,
) )
assert result.exit_code == 1 assert result.exit_code == 1
@ -2686,3 +2876,22 @@ def test_insert_upsert_strict(tmpdir, method, strict):
assert result.exit_code == 0 assert result.exit_code == 0
db = Database(db_path) db = Database(db_path)
assert db["items"].strict == strict or not db.supports_strict assert db["items"].strict == strict or not db.supports_strict
def test_extract_bad_column_clean_error(db_path):
db = Database(db_path)
db["trees"].insert({"id": 1, "species": "Palm"}, pk="id")
result = CliRunner().invoke(cli.cli, ["extract", db_path, "trees", "nope"])
assert result.exit_code == 1
assert result.exception is None or isinstance(result.exception, SystemExit)
assert result.output.startswith("Error: Invalid columns")
def test_extract_view_clean_error(db_path):
db = Database(db_path)
db["trees"].insert({"id": 1, "species": "Palm"}, pk="id")
db.create_view("v", "select * from trees")
result = CliRunner().invoke(cli.cli, ["extract", db_path, "v", "species"])
assert result.exit_code == 1
assert result.exception is None or isinstance(result.exception, SystemExit)
assert result.output.startswith("Error:")

View file

@ -1,11 +1,13 @@
from click.testing import CliRunner
from sqlite_utils import cli, Database
import pathlib import pathlib
import pytest
import subprocess import subprocess
import sys import sys
import time import time
import pytest
from click.testing import CliRunner
from sqlite_utils import Database, cli
@pytest.fixture @pytest.fixture
def test_db_and_path(tmpdir): def test_db_and_path(tmpdir):

View file

@ -1,10 +1,12 @@
from click.testing import CliRunner
from sqlite_utils import cli
import sqlite_utils
import json import json
import textwrap
import pathlib import pathlib
import textwrap
import pytest import pytest
from click.testing import CliRunner
import sqlite_utils
from sqlite_utils import cli
@pytest.fixture @pytest.fixture
@ -50,7 +52,7 @@ def test_convert_code(fresh_db_and_path, code):
cli.cli, ["convert", db_path, "t", "text", code], catch_exceptions=False cli.cli, ["convert", db_path, "t", "text", code], catch_exceptions=False
) )
assert result.exit_code == 0, result.output assert result.exit_code == 0, result.output
value = list(db["t"].rows)[0]["text"] value = next(iter(db["t"].rows))["text"]
assert value == "Spooktober" assert value == "Spooktober"
@ -215,6 +217,25 @@ def test_convert_multi_dryrun(test_db_and_path):
) )
def test_convert_multi_dryrun_unicode_not_escaped(test_db_and_path):
db_path = test_db_and_path[1]
result = CliRunner().invoke(
cli.cli,
[
"convert",
db_path,
"example",
"dt",
"{'text': 'Japanese 日本語'}",
"--dry-run",
"--multi",
],
)
assert result.exit_code == 0
# Preview should match what jsonify_if_needed() would actually store
assert '{"text": "Japanese 日本語"}' in result.output
@pytest.mark.parametrize("drop", (True, False)) @pytest.mark.parametrize("drop", (True, False))
def test_convert_output_column(test_db_and_path, drop): def test_convert_output_column(test_db_and_path, drop):
db, db_path = test_db_and_path db, db_path = test_db_and_path
@ -423,7 +444,7 @@ def test_recipe_jsonsplit(tmpdir, delimiter):
) )
code = "r.jsonsplit(value)" code = "r.jsonsplit(value)"
if delimiter: if delimiter:
code = 'recipes.jsonsplit(value, delimiter="{}")'.format(delimiter) code = f'recipes.jsonsplit(value, delimiter="{delimiter}")'
args = ["convert", db_path, "example", "tags", code] args = ["convert", db_path, "example", "tags", code]
result = CliRunner().invoke(cli.cli, args) result = CliRunner().invoke(cli.cli, args)
assert result.exit_code == 0, result.output assert result.exit_code == 0, result.output
@ -451,7 +472,7 @@ def test_recipe_jsonsplit_type(fresh_db_and_path, type, expected_array):
) )
code = "r.jsonsplit(value)" code = "r.jsonsplit(value)"
if type: if type:
code = "recipes.jsonsplit(value, type={})".format(type) code = f"recipes.jsonsplit(value, type={type})"
args = ["convert", db_path, "example", "records", code] args = ["convert", db_path, "example", "records", code]
result = CliRunner().invoke(cli.cli, args) result = CliRunner().invoke(cli.cli, args)
assert result.exit_code == 0, result.output assert result.exit_code == 0, result.output

View file

@ -1,11 +1,13 @@
from sqlite_utils import cli, Database
from click.testing import CliRunner
import json import json
import pytest
import subprocess import subprocess
import sys import sys
import time import time
import pytest
from click.testing import CliRunner
from sqlite_utils import Database, cli
def test_insert_simple(tmpdir): def test_insert_simple(tmpdir):
json_path = str(tmpdir / "dog.json") json_path = str(tmpdir / "dog.json")
@ -99,7 +101,7 @@ def test_insert_with_primary_keys(db_path, tmpdir, args, expected_pks):
def test_insert_multiple_with_primary_key(db_path, tmpdir): def test_insert_multiple_with_primary_key(db_path, tmpdir):
json_path = str(tmpdir / "dogs.json") json_path = str(tmpdir / "dogs.json")
dogs = [{"id": i, "name": "Cleo {}".format(i), "age": i + 3} for i in range(1, 21)] dogs = [{"id": i, "name": f"Cleo {i}", "age": i + 3} for i in range(1, 21)]
with open(json_path, "w") as fp: with open(json_path, "w") as fp:
fp.write(json.dumps(dogs)) fp.write(json.dumps(dogs))
result = CliRunner().invoke( result = CliRunner().invoke(
@ -114,7 +116,7 @@ def test_insert_multiple_with_primary_key(db_path, tmpdir):
def test_insert_multiple_with_compound_primary_key(db_path, tmpdir): def test_insert_multiple_with_compound_primary_key(db_path, tmpdir):
json_path = str(tmpdir / "dogs.json") json_path = str(tmpdir / "dogs.json")
dogs = [ dogs = [
{"breed": "mixed", "id": i, "name": "Cleo {}".format(i), "age": i + 3} {"breed": "mixed", "id": i, "name": f"Cleo {i}", "age": i + 3}
for i in range(1, 21) for i in range(1, 21)
] ]
with open(json_path, "w") as fp: with open(json_path, "w") as fp:
@ -140,8 +142,7 @@ def test_insert_multiple_with_compound_primary_key(db_path, tmpdir):
def test_insert_not_null_default(db_path, tmpdir): def test_insert_not_null_default(db_path, tmpdir):
json_path = str(tmpdir / "dogs.json") json_path = str(tmpdir / "dogs.json")
dogs = [ dogs = [
{"id": i, "name": "Cleo {}".format(i), "age": i + 3, "score": 10} {"id": i, "name": f"Cleo {i}", "age": i + 3, "score": 10} for i in range(1, 21)
for i in range(1, 21)
] ]
with open(json_path, "w") as fp: with open(json_path, "w") as fp:
fp.write(json.dumps(dogs)) fp.write(json.dumps(dogs))
@ -587,7 +588,7 @@ def test_insert_streaming_batch_size_1(db_path):
return return
tries += 1 tries += 1
if tries > 10: if tries > 10:
assert False, "Expected {}, got {}".format(expected, rows) assert False, f"Expected {expected}, got {rows}"
time.sleep(tries * 0.1) time.sleep(tries * 0.1)
try_until([{"name": "Azi"}]) try_until([{"name": "Azi"}])
@ -628,3 +629,263 @@ def test_insert_into_view_errors(tmpdir):
) )
assert result.exit_code == 1 assert result.exit_code == 1
assert result.output.strip() == "Error: Table v is actually a view" 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,6 @@
import click
import json import json
import click
import pytest import pytest
from click.testing import CliRunner from click.testing import CliRunner
@ -28,7 +29,7 @@ def test_memory_csv(tmpdir, sql_from, use_stdin):
fp.write(content) fp.write(content)
result = CliRunner().invoke( result = CliRunner().invoke(
cli.cli, cli.cli,
["memory", csv_path, "select * from {}".format(sql_from), "--nl"], ["memory", csv_path, f"select * from {sql_from}", "--nl"],
input=input, input=input,
) )
assert result.exit_code == 0 assert result.exit_code == 0
@ -53,7 +54,7 @@ def test_memory_tsv(tmpdir, use_stdin):
sql_from = "chickens" sql_from = "chickens"
result = CliRunner().invoke( result = CliRunner().invoke(
cli.cli, cli.cli,
["memory", path, "select * from {}".format(sql_from)], ["memory", path, f"select * from {sql_from}"],
input=input, input=input,
) )
assert result.exit_code == 0, result.output assert result.exit_code == 0, result.output
@ -79,7 +80,7 @@ def test_memory_json(tmpdir, use_stdin):
sql_from = "chickens" sql_from = "chickens"
result = CliRunner().invoke( result = CliRunner().invoke(
cli.cli, cli.cli,
["memory", path, "select * from {}".format(sql_from)], ["memory", path, f"select * from {sql_from}"],
input=input, input=input,
) )
assert result.exit_code == 0, result.output assert result.exit_code == 0, result.output
@ -105,7 +106,7 @@ def test_memory_json_nl(tmpdir, use_stdin):
sql_from = "chickens" sql_from = "chickens"
result = CliRunner().invoke( result = CliRunner().invoke(
cli.cli, cli.cli,
["memory", path, "select * from {}".format(sql_from)], ["memory", path, f"select * from {sql_from}"],
input=input, input=input,
) )
assert result.exit_code == 0, result.output assert result.exit_code == 0, result.output
@ -135,7 +136,7 @@ def test_memory_csv_encoding(tmpdir, use_stdin):
CliRunner() CliRunner()
.invoke( .invoke(
cli.cli, cli.cli,
["memory", csv_path, "select * from {}".format(sql_from), "--nl"], ["memory", csv_path, f"select * from {sql_from}", "--nl"],
input=input, input=input,
) )
.exit_code .exit_code

View file

@ -1,7 +1,8 @@
import pathlib import pathlib
from click.testing import CliRunner
import pytest import pytest
from click.testing import CliRunner
import sqlite_utils import sqlite_utils
import sqlite_utils.cli import sqlite_utils.cli
@ -463,3 +464,45 @@ def test_list_does_not_upgrade_legacy_migrations_table(two_migrations):
db2 = sqlite_utils.Database(db_path) db2 = sqlite_utils.Database(db_path)
assert db2["_sqlite_migrations"].pks == ["migration_set", "name"] assert db2["_sqlite_migrations"].pks == ["migration_set", "name"]
db2.close() 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()

View file

@ -1,4 +1,5 @@
import pytest import pytest
from sqlite_utils.utils import column_affinity from sqlite_utils.utils import column_affinity
EXAMPLES = [ EXAMPLES = [
@ -41,5 +42,5 @@ def test_column_affinity(column_def, expected_type):
@pytest.mark.parametrize("column_def,expected_type", EXAMPLES) @pytest.mark.parametrize("column_def,expected_type", EXAMPLES)
def test_columns_dict(fresh_db, column_def, expected_type): def test_columns_dict(fresh_db, column_def, expected_type):
fresh_db.execute("create table foo (col {})".format(column_def)) fresh_db.execute(f"create table foo (col {column_def})")
assert {"col": expected_type} == fresh_db["foo"].columns_dict assert {"col": expected_type} == fresh_db["foo"].columns_dict

View file

@ -1,8 +1,10 @@
import sys
import pytest
from sqlite_utils import Database from sqlite_utils import Database
from sqlite_utils.db import TransactionError from sqlite_utils.db import TransactionError
from sqlite_utils.utils import sqlite3 from sqlite_utils.utils import sqlite3
import pytest
import sys
def test_recursive_triggers(): def test_recursive_triggers():
@ -87,3 +89,27 @@ def test_legacy_transaction_control_connection_is_accepted(tmpdir):
db["t"].insert({"id": 1}, pk="id") db["t"].insert({"id": 1}, pk="id")
assert [r["id"] for r in db["t"].rows] == [1] assert [r["id"] for r in db["t"].rows] == [1]
db.close() 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

@ -1,6 +1,7 @@
from sqlite_utils.db import BadMultiValues
import pytest import pytest
from sqlite_utils.db import BadMultiValues
@pytest.mark.parametrize( @pytest.mark.parametrize(
"columns,fn,expected", "columns,fn,expected",

View file

@ -1,25 +1,28 @@
from sqlite_utils.db import (
Index,
Database,
DescIndex,
AlterError,
NoObviousTable,
OperationalError,
ForeignKey,
Table,
View,
NoTable,
NoView,
)
from sqlite_utils.utils import hash_record, sqlite3
import collections import collections
import datetime import datetime
import decimal import decimal
import json import json
import pathlib import pathlib
import pytest
import uuid import uuid
import pytest
from sqlite_utils.db import (
AlterError,
Database,
DescIndex,
ForeignKey,
Index,
InvalidColumns,
NoObviousTable,
NoTable,
NoView,
OperationalError,
Table,
View,
)
from sqlite_utils.utils import hash_record, sqlite3
try: try:
import pandas as pd # type: ignore import pandas as pd # type: ignore
except ImportError: except ImportError:
@ -698,7 +701,7 @@ def test_bulk_insert_more_than_999_values(fresh_db):
"num_columns,should_error", ((900, False), (999, False), (1000, True)) "num_columns,should_error", ((900, False), (999, False), (1000, True))
) )
def test_error_if_more_than_999_columns(fresh_db, num_columns, should_error): def test_error_if_more_than_999_columns(fresh_db, num_columns, should_error):
record = dict([("c{}".format(i), i) for i in range(num_columns)]) record = {f"c{i}": i for i in range(num_columns)}
if should_error: if should_error:
with pytest.raises(ValueError): with pytest.raises(ValueError):
fresh_db["big"].insert(record) fresh_db["big"].insert(record)
@ -717,17 +720,9 @@ def test_columns_not_in_first_record_should_not_cause_batch_to_be_too_large(fres
records = [ records = [
{"c0": "first record"}, # one column in first record -> batch size = 999 {"c0": "first record"}, # one column in first record -> batch size = 999
# fill out the batch with 99 records with enough columns to exceed THRESHOLD # fill out the batch with 99 records with enough columns to exceed THRESHOLD
*[ *[{f"c{i}": j for i in range(extra_columns)} for j in range(batch_size - 1)],
dict([("c{}".format(i), j) for i in range(extra_columns)])
for j in range(batch_size - 1)
],
] ]
try: fresh_db["too_many_columns"].insert_all(records, alter=True, batch_size=batch_size)
fresh_db["too_many_columns"].insert_all(
records, alter=True, batch_size=batch_size
)
except sqlite3.OperationalError:
raise
@pytest.mark.parametrize( @pytest.mark.parametrize(
@ -808,6 +803,34 @@ def test_create_index_if_not_exists(fresh_db):
dogs.create_index(["name"], if_not_exists=True) dogs.create_index(["name"], if_not_exists=True)
def test_drop_index(fresh_db):
dogs = fresh_db["dogs"]
dogs.insert({"name": "Cleo", "twitter": "cleopaws", "age": 3, "is_good_dog": True})
dogs.create_index(["name"])
assert [index.name for index in dogs.indexes] == ["idx_dogs_name"]
dogs.drop_index("idx_dogs_name")
assert dogs.indexes == []
def test_drop_index_ignore(fresh_db):
dogs = fresh_db["dogs"]
dogs.insert({"name": "Cleo"})
with pytest.raises(OperationalError, match="No index named idx_dogs_name"):
dogs.drop_index("idx_dogs_name")
dogs.drop_index("idx_dogs_name", ignore=True)
def test_drop_index_wrong_table(fresh_db):
dogs = fresh_db["dogs"]
cats = fresh_db["cats"]
dogs.insert({"name": "Cleo"})
cats.insert({"name": "Misty"})
dogs.create_index(["name"])
with pytest.raises(OperationalError, match="No index named idx_dogs_name"):
cats.drop_index("idx_dogs_name")
assert [index.name for index in dogs.indexes] == ["idx_dogs_name"]
def test_create_index_desc(fresh_db): def test_create_index_desc(fresh_db):
dogs = fresh_db["dogs"] dogs = fresh_db["dogs"]
dogs.insert({"name": "Cleo", "twitter": "cleopaws", "age": 3, "is good dog": True}) dogs.insert({"name": "Cleo", "twitter": "cleopaws", "age": 3, "is good dog": True})
@ -881,7 +904,7 @@ def test_insert_list_nested_unicode(fresh_db):
def test_insert_uuid(fresh_db): def test_insert_uuid(fresh_db):
uuid4 = uuid.uuid4() uuid4 = uuid.uuid4()
fresh_db["test"].insert({"uuid": uuid4}) fresh_db["test"].insert({"uuid": uuid4})
row = list(fresh_db["test"].rows)[0] row = next(iter(fresh_db["test"].rows))
assert {"uuid"} == row.keys() assert {"uuid"} == row.keys()
assert isinstance(row["uuid"], str) assert isinstance(row["uuid"], str)
assert row["uuid"] == str(uuid4) assert row["uuid"] == str(uuid4)
@ -889,16 +912,14 @@ def test_insert_uuid(fresh_db):
def test_insert_memoryview(fresh_db): def test_insert_memoryview(fresh_db):
fresh_db["test"].insert({"data": memoryview(b"hello")}) fresh_db["test"].insert({"data": memoryview(b"hello")})
row = list(fresh_db["test"].rows)[0] row = next(iter(fresh_db["test"].rows))
assert {"data"} == row.keys() assert {"data"} == row.keys()
assert isinstance(row["data"], bytes) assert isinstance(row["data"], bytes)
assert row["data"] == b"hello" assert row["data"] == b"hello"
def test_insert_thousands_using_generator(fresh_db): def test_insert_thousands_using_generator(fresh_db):
fresh_db["test"].insert_all( fresh_db["test"].insert_all({"i": i, "word": f"word_{i}"} for i in range(10000))
{"i": i, "word": "word_{}".format(i)} for i in range(10000)
)
assert [{"name": "i", "type": "INTEGER"}, {"name": "word", "type": "TEXT"}] == [ assert [{"name": "i", "type": "INTEGER"}, {"name": "word", "type": "TEXT"}] == [
{"name": col.name, "type": col.type} for col in fresh_db["test"].columns {"name": col.name, "type": col.type} for col in fresh_db["test"].columns
] ]
@ -909,7 +930,7 @@ def test_insert_thousands_raises_exception_with_extra_columns_after_first_100(fr
# https://github.com/simonw/sqlite-utils/issues/139 # https://github.com/simonw/sqlite-utils/issues/139
with pytest.raises(Exception, match="table test has no column named extra"): with pytest.raises(Exception, match="table test has no column named extra"):
fresh_db["test"].insert_all( fresh_db["test"].insert_all(
[{"i": i, "word": "word_{}".format(i)} for i in range(100)] [{"i": i, "word": f"word_{i}"} for i in range(100)]
+ [{"i": 101, "extra": "This extra column should cause an exception"}], + [{"i": 101, "extra": "This extra column should cause an exception"}],
) )
@ -917,7 +938,7 @@ def test_insert_thousands_raises_exception_with_extra_columns_after_first_100(fr
def test_insert_thousands_adds_extra_columns_after_first_100_with_alter(fresh_db): def test_insert_thousands_adds_extra_columns_after_first_100_with_alter(fresh_db):
# https://github.com/simonw/sqlite-utils/issues/139 # https://github.com/simonw/sqlite-utils/issues/139
fresh_db["test"].insert_all( fresh_db["test"].insert_all(
[{"i": i, "word": "word_{}".format(i)} for i in range(100)] [{"i": i, "word": f"word_{i}"} for i in range(100)]
+ [{"i": 101, "extra": "Should trigger ALTER"}], + [{"i": 101, "extra": "Should trigger ALTER"}],
alter=True, alter=True,
) )
@ -925,6 +946,58 @@ def test_insert_thousands_adds_extra_columns_after_first_100_with_alter(fresh_db
assert rows == [{"i": 101, "word": None, "extra": "Should trigger ALTER"}] assert rows == [{"i": 101, "word": None, "extra": "Should trigger ALTER"}]
@pytest.mark.parametrize("num_rows", (0, 1, 2, 3, 10))
def test_insert_all_pk_not_in_records_raises(fresh_db, num_rows):
# https://github.com/simonw/sqlite-utils/issues/732
fresh_db.conn.execute("CREATE TABLE t (a TEXT, b INT, PRIMARY KEY (a, b))")
rows = [{"a": f"x{i}", "b": i} for i in range(num_rows)]
with pytest.raises(InvalidColumns) as ex:
fresh_db["t"].insert_all(rows, pk="not_a_column")
assert ex.value.args == (
"Invalid primary key column ['not_a_column'] for table t with columns ['a', 'b']",
)
assert fresh_db["t"].count == 0
@pytest.mark.parametrize("num_rows", (1, 2, 3, 10))
def test_insert_all_pk_not_in_records_alter_raises(fresh_db, num_rows):
# With alter=True the check is deferred until the record keys are
# known - a pk column that is in neither the table nor the records
# still raises
fresh_db.conn.execute("CREATE TABLE t (a TEXT, b INT, PRIMARY KEY (a, b))")
rows = [{"a": f"x{i}", "b": i} for i in range(num_rows)]
with pytest.raises(InvalidColumns) as ex:
fresh_db["t"].insert_all(rows, pk="not_a_column", alter=True)
assert ex.value.args == (
"Invalid primary key column ['not_a_column'] for table t with columns ['a', 'b']",
)
assert fresh_db["t"].count == 0
def test_insert_pk_in_records_with_alter_adds_column(fresh_db):
# 3.x allowed insert(pk=..., alter=True) to add the pk column from the
# records - the InvalidColumns check must not fire in that case
fresh_db["t"].insert({"a": 1})
fresh_db["t"].insert({"id": 5, "a": 2}, pk="id", alter=True)
assert fresh_db["t"].columns_dict.keys() == {"a", "id"}
assert list(fresh_db.query("select * from t order by a")) == [
{"a": 1, "id": None},
{"a": 2, "id": 5},
]
def test_insert_all_invalid_pk_alter_empty_records_is_noop(fresh_db):
# With alter=True the pk check needs record keys, so an empty iterator
# returns without error - matching the 3.x no-op for empty inserts
fresh_db.conn.execute("CREATE TABLE t (a TEXT)")
fresh_db["t"].insert_all([], pk="not_a_column", alter=True)
assert fresh_db["t"].count == 0
def test_insert_ignore(fresh_db): def test_insert_ignore(fresh_db):
fresh_db["test"].insert({"id": 1, "bar": 2}, pk="id") fresh_db["test"].insert({"id": 1, "bar": 2}, pk="id")
# Should raise an error if we try this again # Should raise an error if we try this again
@ -937,6 +1010,114 @@ def test_insert_ignore(fresh_db):
assert rows == [{"id": 1, "bar": 2}] assert rows == [{"id": 1, "bar": 2}]
def test_insert_ignore_reports_existing_row(fresh_db):
# An ignored insert (row already exists) should point last_rowid and
# last_pk at the existing conflicting row - see the Datasette insert API
fresh_db["docs"].insert({"id": 1, "title": "Exists"}, pk="id")
# Insert a conflicting row with ignore=True and no explicit pk=
table = fresh_db["docs"].insert({"id": 1, "title": "One"}, ignore=True)
assert table.last_rowid == 1
assert table.last_pk == 1
assert list(fresh_db["docs"].rows_where("rowid = ?", [table.last_rowid])) == [
{"id": 1, "title": "Exists"}
]
@pytest.mark.parametrize("rowid_alias", ("rowid", "_rowid_", "oid"))
@pytest.mark.parametrize("method", ("upsert", "insert_replace", "insert_ignore"))
def test_pk_rowid_alias_on_rowid_table(fresh_db, rowid_alias, method):
# rowid and its aliases are valid primary keys for a rowid table even
# though they are not listed among the table's columns - see the Datasette
# upsert API against tables without an explicit primary key
fresh_db["t"].insert({"title": "Hello"})
assert fresh_db["t"].pks == ["rowid"]
record = {rowid_alias: 1, "title": "Updated"}
if method == "upsert":
table = fresh_db["t"].upsert(record, pk=rowid_alias)
elif method == "insert_replace":
table = fresh_db["t"].insert(record, pk=rowid_alias, replace=True)
else:
table = fresh_db["t"].insert(record, pk=rowid_alias, ignore=True)
assert table.last_pk == 1
expected_title = "Hello" if method == "insert_ignore" else "Updated"
assert list(fresh_db["t"].rows) == [{"title": expected_title}]
def test_insert_ignore_reports_existing_row_compound_pk(fresh_db):
# Compound primary key variant of the ignored-insert lookup
fresh_db["t"].insert_all([{"a": 1, "b": 2, "note": "first"}], pk=("a", "b"))
table = fresh_db["t"].insert(
{"a": 1, "b": 2, "note": "second"}, pk=("a", "b"), ignore=True
)
assert table.last_pk == (1, 2)
assert list(fresh_db["t"].rows_where("rowid = ?", [table.last_rowid])) == [
{"a": 1, "b": 2, "note": "first"}
]
def test_insert_ignore_reports_existing_row_list_mode(fresh_db):
# List-based iteration variant of the ignored-insert lookup
fresh_db["t"].insert_all([["id", "title"], [1, "first"]], pk="id")
table = fresh_db["t"].insert_all(
[["id", "title"], [1, "second"]], pk="id", ignore=True
)
assert table.last_pk == 1
assert table.last_rowid == 1
assert list(fresh_db["t"].rows) == [{"id": 1, "title": "first"}]
def test_insert_ignore_hash_id_reports_pk(fresh_db):
# With hash_id the pk is the computed hash; the original record has no id
# column to look up so last_rowid is left unset
first = fresh_db["dogs"].insert({"name": "Cleo"}, hash_id="id")
table = fresh_db["dogs"].insert({"name": "Cleo"}, hash_id="id", ignore=True)
assert table.last_pk == first.last_pk
assert table.last_rowid is None
assert fresh_db["dogs"].count == 1
def test_insert_ignore_unresolvable_conflict_leaves_pk_unset(fresh_db):
# When the conflict cannot be resolved to a primary key lookup, last_pk and
# last_rowid are left unset rather than reporting a misleading value
# rowid table with a UNIQUE column and no primary key: no pk to look up
fresh_db["u"].db.execute("create table u (title text unique)")
fresh_db["u"].insert({"title": "x"})
table = fresh_db["u"].insert({"title": "x"}, ignore=True)
assert table.last_pk is None
assert table.last_rowid is None
assert fresh_db["u"].count == 1
# Conflict on a UNIQUE column other than the primary key: the pk value from
# the record does not match the existing row, so the lookup finds nothing
fresh_db["docs"].db.execute(
"create table docs (id integer primary key, email text unique)"
)
fresh_db["docs"].insert({"id": 1, "email": "a"}, pk="id")
table = fresh_db["docs"].insert({"id": 2, "email": "a"}, ignore=True)
assert table.last_pk is None
assert table.last_rowid is None
assert fresh_db["docs"].count == 1
def test_insert_ignore_with_pk_after_other_table_insert(fresh_db):
# https://github.com/simonw/sqlite-utils/issues/554
user = {"id": "abc", "name": "david"}
fresh_db["users"].insert(user, pk="id")
fresh_db["comments"].insert_all(
[
{"id": "def", "text": "ok"},
{"id": "ghi", "text": "great"},
],
)
table = fresh_db["users"].insert(user, pk="id", ignore=True)
assert table.last_pk == "abc"
assert list(fresh_db["users"].rows) == [user]
def test_insert_hash_id(fresh_db): def test_insert_hash_id(fresh_db):
dogs = fresh_db["dogs"] dogs = fresh_db["dogs"]
id = dogs.insert({"name": "Cleo", "twitter": "cleopaws"}, hash_id="id").last_pk id = dogs.insert({"name": "Cleo", "twitter": "cleopaws"}, hash_id="id").last_pk
@ -957,7 +1138,7 @@ def test_insert_hash_id_columns(fresh_db, use_table_factory):
insert_kwargs = {} insert_kwargs = {}
else: else:
dogs = fresh_db["dogs"] dogs = fresh_db["dogs"]
insert_kwargs = dict(hash_id_columns=("name", "twitter")) insert_kwargs = {"hash_id_columns": ("name", "twitter")}
id = dogs.insert( id = dogs.insert(
{"name": "Cleo", "twitter": "cleopaws", "age": 5}, {"name": "Cleo", "twitter": "cleopaws", "age": 5},
@ -1465,7 +1646,7 @@ def test_upsert_uses_pk_from_prior_insert_655(fresh_db):
# Upsert should work without specifying pk again # Upsert should work without specifying pk again
table.upsert({"id": 1, "name": "Alice Updated"}) table.upsert({"id": 1, "name": "Alice Updated"})
assert table.count == 1 assert table.count == 1
assert list(table.rows)[0]["name"] == "Alice Updated" assert next(iter(table.rows))["name"] == "Alice Updated"
def test_upsert_all_uses_pk_from_prior_insert_655(fresh_db): def test_upsert_all_uses_pk_from_prior_insert_655(fresh_db):

View file

@ -1,4 +1,5 @@
import pytest import pytest
from sqlite_utils.utils import OperationalError from sqlite_utils.utils import OperationalError

View file

@ -31,7 +31,7 @@ EXAMPLES = [
@pytest.mark.parametrize("column_def,initial_value,expected_value", EXAMPLES) @pytest.mark.parametrize("column_def,initial_value,expected_value", EXAMPLES)
def test_quote_default_value(fresh_db, column_def, initial_value, expected_value): def test_quote_default_value(fresh_db, column_def, initial_value, expected_value):
fresh_db.execute("create table foo (col {})".format(column_def)) fresh_db.execute(f"create table foo (col {column_def})")
assert initial_value == fresh_db["foo"].columns[0].default_value assert initial_value == fresh_db["foo"].columns[0].default_value
assert expected_value == fresh_db.quote_default_value( assert expected_value == fresh_db.quote_default_value(
fresh_db["foo"].columns[0].default_value fresh_db["foo"].columns[0].default_value

View file

@ -3,7 +3,7 @@ import sqlite_utils
def test_delete_rowid_table(fresh_db): def test_delete_rowid_table(fresh_db):
table = fresh_db["table"] table = fresh_db["table"]
table.insert({"foo": 1}).last_pk table.insert({"foo": 1})
rowid = table.insert({"foo": 2}).last_pk rowid = table.insert({"foo": 2}).last_pk
table.delete(rowid) table.delete(rowid)
assert [{"foo": 1}] == list(table.rows) assert [{"foo": 1}] == list(table.rows)

View file

@ -1,8 +1,10 @@
from click.testing import CliRunner
from sqlite_utils import cli, recipes
from pathlib import Path
import pytest
import re import re
from pathlib import Path
import pytest
from click.testing import CliRunner
from sqlite_utils import cli, recipes
docs_path = Path(__file__).parent.parent / "docs" docs_path = Path(__file__).parent.parent / "docs"
commands_re = re.compile(r"(?:\$ | )sqlite-utils (\S+)") commands_re = re.compile(r"(?:\$ | )sqlite-utils (\S+)")
@ -34,7 +36,7 @@ def test_commands_are_documented(documented_commands, command):
@pytest.mark.parametrize("command", cli.cli.commands.values()) @pytest.mark.parametrize("command", cli.cli.commands.values())
def test_commands_have_help(command): def test_commands_have_help(command):
assert command.help, "{} is missing its help".format(command) assert command.help, f"{command} is missing its help"
def test_convert_help(): def test_convert_help():

View file

@ -1,7 +1,9 @@
from sqlite_utils.db import NoTable
import datetime import datetime
import pytest import pytest
from sqlite_utils.db import NoTable
def test_duplicate(fresh_db): def test_duplicate(fresh_db):
# Create table using native Sqlite statement: # Create table using native Sqlite statement:
@ -12,7 +14,7 @@ def test_duplicate(fresh_db):
"bool_col" INTEGER, "bool_col" INTEGER,
"datetime_col" TEXT)""") "datetime_col" TEXT)""")
# Insert one row of mock data: # Insert one row of mock data:
dt = datetime.datetime.now() dt = datetime.datetime.now(datetime.timezone.utc)
data = { data = {
"text_col": "Cleo", "text_col": "Cleo",
"real_col": 3.14, "real_col": 3.14,

View file

@ -1,14 +1,14 @@
from sqlite_utils import Database
from sqlite_utils import cli
from click.testing import CliRunner
import pytest import pytest
from click.testing import CliRunner
from sqlite_utils import Database, cli
def test_enable_counts_specific_table(fresh_db): def test_enable_counts_specific_table(fresh_db):
foo = fresh_db["foo"] foo = fresh_db["foo"]
assert fresh_db.table_names() == [] assert fresh_db.table_names() == []
for i in range(10): for i in range(10):
foo.insert({"name": "item {}".format(i)}) foo.insert({"name": f"item {i}"})
assert fresh_db.table_names() == ["foo"] assert fresh_db.table_names() == ["foo"]
assert foo.count == 10 assert foo.count == 10
# Now enable counts # Now enable counts
@ -44,7 +44,7 @@ def test_enable_counts_specific_table(fresh_db):
assert list(fresh_db["_counts"].rows) == [{"count": 10, "table": "foo"}] assert list(fresh_db["_counts"].rows) == [{"count": 10, "table": "foo"}]
# Add some items to test the triggers # Add some items to test the triggers
for i in range(5): for i in range(5):
foo.insert({"name": "item {}".format(10 + i)}) foo.insert({"name": f"item {10 + i}"})
assert foo.count == 15 assert foo.count == 15
assert list(fresh_db["_counts"].rows) == [{"count": 15, "table": "foo"}] assert list(fresh_db["_counts"].rows) == [{"count": 15, "table": "foo"}]
# Delete some items # Delete some items

View file

@ -1,19 +1,21 @@
from sqlite_utils.db import InvalidColumns
import itertools import itertools
import pytest import pytest
from sqlite_utils.db import InvalidColumns
@pytest.mark.parametrize("table", [None, "Species"]) @pytest.mark.parametrize("table", [None, "Species"])
@pytest.mark.parametrize("fk_column", [None, "species"]) @pytest.mark.parametrize("fk_column", [None, "species"])
def test_extract_single_column(fresh_db, table, fk_column): def test_extract_single_column(fresh_db, table, fk_column):
expected_table = table or "species" expected_table = table or "species"
expected_fk = fk_column or "{}_id".format(expected_table) expected_fk = fk_column or f"{expected_table}_id"
iter_species = itertools.cycle(["Palm", "Spruce", "Mangrove", "Oak"]) iter_species = itertools.cycle(["Palm", "Spruce", "Mangrove", "Oak"])
fresh_db["tree"].insert_all( fresh_db["tree"].insert_all(
( (
{ {
"id": i, "id": i,
"name": "Tree {}".format(i), "name": f"Tree {i}",
"species": next(iter_species), "species": next(iter_species),
"end": 1, "end": 1,
} }
@ -26,13 +28,12 @@ def test_extract_single_column(fresh_db, table, fk_column):
'CREATE TABLE "tree" (\n' 'CREATE TABLE "tree" (\n'
' "id" INTEGER PRIMARY KEY,\n' ' "id" INTEGER PRIMARY KEY,\n'
' "name" TEXT,\n' ' "name" TEXT,\n'
' "{}" INTEGER REFERENCES "{}"("id"),\n'.format(expected_fk, expected_table) f' "{expected_fk}" INTEGER REFERENCES "{expected_table}"("id"),\n'
+ ' "end" INTEGER\n' + ' "end" INTEGER\n'
+ ")" + ")"
) )
assert fresh_db[expected_table].schema == ( assert fresh_db[expected_table].schema == (
'CREATE TABLE "{}" (\n'.format(expected_table) f'CREATE TABLE "{expected_table}" (\n' + ' "id" INTEGER PRIMARY KEY,\n'
+ ' "id" INTEGER PRIMARY KEY,\n'
' "species" TEXT\n' ' "species" TEXT\n'
")" ")"
) )
@ -57,7 +58,7 @@ def test_extract_multiple_columns_with_rename(fresh_db):
( (
{ {
"id": i, "id": i,
"name": "Tree {}".format(i), "name": f"Tree {i}",
"common_name": next(iter_common), "common_name": next(iter_common),
"latin_name": next(iter_latin), "latin_name": next(iter_latin),
} }
@ -189,9 +190,116 @@ def test_extract_works_with_null_values(fresh_db):
) )
assert list(fresh_db["listens"].rows) == [ assert list(fresh_db["listens"].rows) == [
{"id": 1, "track_title": "foo", "album_id": 1}, {"id": 1, "track_title": "foo", "album_id": 1},
{"id": 2, "track_title": "baz", "album_id": 2}, {"id": 2, "track_title": "baz", "album_id": None},
] ]
assert list(fresh_db["albums"].rows) == [ assert list(fresh_db["albums"].rows) == [
{"id": 1, "album_title": "bar"}, {"id": 1, "album_title": "bar"},
{"id": 2, "album_title": None},
] ]
def test_extract_null_values_single_column(fresh_db):
# https://github.com/simonw/sqlite-utils/issues/186
fresh_db["species"].insert({"id": 1, "species": "Wolf"}, pk="id")
fresh_db["individuals"].insert_all(
[
{"id": 10, "name": "Terriana", "species": "Fox"},
{"id": 11, "name": "Spenidorm", "species": None},
{"id": 12, "name": "Grantheim", "species": "Wolf"},
{"id": 13, "name": "Turnutopia", "species": None},
{"id": 14, "name": "Wargal", "species": "Wolf"},
],
pk="id",
)
fresh_db["individuals"].extract("species")
# No null row should have been added to species
assert list(fresh_db["species"].rows) == [
{"id": 1, "species": "Wolf"},
{"id": 2, "species": "Fox"},
]
assert list(fresh_db["individuals"].rows) == [
{"id": 10, "name": "Terriana", "species_id": 2},
{"id": 11, "name": "Spenidorm", "species_id": None},
{"id": 12, "name": "Grantheim", "species_id": 1},
{"id": 13, "name": "Turnutopia", "species_id": None},
{"id": 14, "name": "Wargal", "species_id": 1},
]
def test_extract_null_values_multiple_columns(fresh_db):
# A row should be extracted if at least one column is not null -
# only rows where ALL extracted columns are null are left alone
fresh_db["circulation"].insert_all(
[
{"id": 1, "title": "title one", "creator": "creator one", "year": 2018},
{"id": 2, "title": "title two", "creator": None, "year": 2019},
{"id": 3, "title": None, "creator": None, "year": 2020},
{"id": 4, "title": None, "creator": None, "year": 2021},
],
pk="id",
)
fresh_db["circulation"].extract(
["title", "creator"], table="books", fk_column="book_id"
)
assert list(fresh_db["books"].rows) == [
{"id": 1, "title": "title one", "creator": "creator one"},
{"id": 2, "title": "title two", "creator": None},
]
assert list(fresh_db["circulation"].rows) == [
{"id": 1, "book_id": 1, "year": 2018},
{"id": 2, "book_id": 2, "year": 2019},
{"id": 3, "book_id": None, "year": 2020},
{"id": 4, "book_id": None, "year": 2021},
]
def test_extract_null_values_existing_lookup_table_with_null_row(fresh_db):
# Even if the lookup table already contains an all-null row, rows where
# every extracted column is null should keep a null foreign key
fresh_db["species"].insert({"id": 1, "species": None}, pk="id")
fresh_db["individuals"].insert_all(
[
{"id": 10, "name": "Terriana", "species": "Fox"},
{"id": 11, "name": "Spenidorm", "species": None},
],
pk="id",
)
fresh_db["individuals"].extract("species")
assert list(fresh_db["species"].rows) == [
{"id": 1, "species": None},
{"id": 2, "species": "Fox"},
]
assert list(fresh_db["individuals"].rows) == [
{"id": 10, "name": "Terriana", "species_id": 2},
{"id": 11, "name": "Spenidorm", "species_id": None},
]
def test_extract_repeated_into_shared_lookup_with_nulls(fresh_db):
# Unique indexes treat NULLs as distinct, so INSERT OR IGNORE alone
# cannot dedupe NULL-containing rows against the existing lookup
# table - extracting a second table into the same lookup previously
# inserted duplicate rows that nothing pointed to
fresh_db["t1"].insert_all(
[
{"id": 1, "species": None, "common": "X"},
{"id": 2, "species": "Oak", "common": "Oak"},
],
pk="id",
)
fresh_db["t2"].insert_all([{"id": 1, "species": None, "common": "X"}], pk="id")
fresh_db["t1"].extract(["species", "common"], table="lk")
fresh_db["t2"].extract(["species", "common"], table="lk")
assert fresh_db["lk"].count == 2
# Both tables point at the same lookup row
t1_fk = fresh_db.execute("select lk_id from t1 where id = 1").fetchone()[0]
t2_fk = fresh_db.execute("select lk_id from t2 where id = 1").fetchone()[0]
assert t1_fk == t2_fk
def test_extract_repeated_into_shared_lookup_no_nulls(fresh_db):
# Non-NULL rows were already deduped by the unique index - keep it so
fresh_db["t1"].insert_all([{"id": 1, "species": "Oak"}], pk="id")
fresh_db["t2"].insert_all([{"id": 1, "species": "Oak"}], pk="id")
fresh_db["t1"].extract(["species"], table="lk")
fresh_db["t2"].extract(["species"], table="lk")
assert fresh_db["lk"].count == 1

View file

@ -1,13 +1,14 @@
from sqlite_utils.db import Index
import pytest import pytest
from sqlite_utils.db import Index
@pytest.mark.parametrize( @pytest.mark.parametrize(
"kwargs,expected_table", "kwargs,expected_table",
[ [
(dict(extracts={"species_id": "Species"}), "Species"), ({"extracts": {"species_id": "Species"}}, "Species"),
(dict(extracts=["species_id"]), "species_id"), ({"extracts": ["species_id"]}, "species_id"),
(dict(extracts=("species_id",)), "species_id"), ({"extracts": ("species_id",)}, "species_id"),
], ],
) )
@pytest.mark.parametrize("use_table_factory", [True, False]) @pytest.mark.parametrize("use_table_factory", [True, False])
@ -30,15 +31,11 @@ def test_extracts(fresh_db, kwargs, expected_table, use_table_factory):
# Should now have two tables: Trees and Species # Should now have two tables: Trees and Species
assert {expected_table, "Trees"} == set(fresh_db.table_names()) assert {expected_table, "Trees"} == set(fresh_db.table_names())
assert ( assert (
'CREATE TABLE "{}" (\n "id" INTEGER PRIMARY KEY,\n "value" TEXT\n)'.format( f'CREATE TABLE "{expected_table}" (\n "id" INTEGER PRIMARY KEY,\n "value" TEXT\n)'
expected_table
)
== fresh_db[expected_table].schema == fresh_db[expected_table].schema
) )
assert ( assert (
'CREATE TABLE "Trees" (\n "id" INTEGER,\n "species_id" INTEGER REFERENCES "{}"("id")\n)'.format( f'CREATE TABLE "Trees" (\n "id" INTEGER,\n "species_id" INTEGER REFERENCES "{expected_table}"("id")\n)'
expected_table
)
== fresh_db["Trees"].schema == fresh_db["Trees"].schema
) )
# Should have a foreign key reference # Should have a foreign key reference
@ -51,7 +48,7 @@ def test_extracts(fresh_db, kwargs, expected_table, use_table_factory):
assert [ assert [
Index( Index(
seq=0, seq=0,
name="idx_{}_value".format(expected_table), name=f"idx_{expected_table}_value",
unique=1, unique=1,
origin="c", origin="c",
partial=0, partial=0,
@ -67,3 +64,51 @@ def test_extracts(fresh_db, kwargs, expected_table, use_table_factory):
{"id": 2, "species_id": 1}, {"id": 2, "species_id": 1},
{"id": 3, "species_id": 2}, {"id": 3, "species_id": 2},
] == list(fresh_db["Trees"].rows) ] == 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},
]

View file

@ -1,6 +1,7 @@
"""Tests for compound (multi-column) foreign keys - issue #594.""" """Tests for compound (multi-column) foreign keys - issue #594."""
import pytest import pytest
from sqlite_utils import Database from sqlite_utils import Database
from sqlite_utils.db import AlterError, ForeignKey from sqlite_utils.db import AlterError, ForeignKey
from sqlite_utils.utils import sqlite3 from sqlite_utils.utils import sqlite3
@ -64,7 +65,7 @@ def test_foreign_key_no_longer_unpacks_as_tuple(fresh_db):
fresh_db["books"].add_foreign_key("author_id", "authors", "id") fresh_db["books"].add_foreign_key("author_id", "authors", "id")
fk = fresh_db["books"].foreign_keys[0] fk = fresh_db["books"].foreign_keys[0]
with pytest.raises(TypeError): with pytest.raises(TypeError):
table, column, other_table, other_column = fk _table, _column, _other_table, _other_column = fk
with pytest.raises(TypeError): with pytest.raises(TypeError):
fk[0] fk[0]
@ -524,3 +525,168 @@ def test_add_compound_foreign_key_on_delete(courses_db):
assert fk.is_compound is True assert fk.is_compound is True
assert fk.on_delete == "SET NULL" assert fk.on_delete == "SET NULL"
assert "ON DELETE SET NULL" in courses_db["courses"].schema 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,7 +1,9 @@
from unittest.mock import ANY
import pytest import pytest
from sqlite_utils import Database from sqlite_utils import Database
from sqlite_utils.utils import sqlite3 from sqlite_utils.utils import sqlite3
from unittest.mock import ANY
search_records = [ search_records = [
{ {
@ -103,9 +105,10 @@ def test_search_limit_offset(fresh_db):
table.enable_fts(["text", "country"], fts_version="FTS4") table.enable_fts(["text", "country"], fts_version="FTS4")
assert len(list(table.search("are"))) == 2 assert len(list(table.search("are"))) == 2
assert len(list(table.search("are", limit=1))) == 1 assert len(list(table.search("are", limit=1))) == 1
assert list(table.search("are", limit=1, order_by="rowid"))[0]["rowid"] == 1 assert next(iter(table.search("are", limit=1, order_by="rowid")))["rowid"] == 1
assert ( assert (
list(table.search("are", limit=1, offset=1, order_by="rowid"))[0]["rowid"] == 2 next(iter(table.search("are", limit=1, offset=1, order_by="rowid")))["rowid"]
== 2
) )
@ -223,20 +226,20 @@ def test_populate_fts_escape_table_names(fresh_db):
@pytest.mark.parametrize("fts_version", ("4", "5")) @pytest.mark.parametrize("fts_version", ("4", "5"))
def test_fts_tokenize(fresh_db, fts_version): def test_fts_tokenize(fresh_db, fts_version):
table_name = "searchable_{}".format(fts_version) table_name = f"searchable_{fts_version}"
table = fresh_db[table_name] table = fresh_db[table_name]
table.insert_all(search_records) table.insert_all(search_records)
# Test without porter stemming # Test without porter stemming
table.enable_fts( table.enable_fts(
["text", "country"], ["text", "country"],
fts_version="FTS{}".format(fts_version), fts_version=f"FTS{fts_version}",
) )
assert [] == list(table.search("bite")) assert [] == list(table.search("bite"))
# Test WITH stemming # Test WITH stemming
table.disable_fts() table.disable_fts()
table.enable_fts( table.enable_fts(
["text", "country"], ["text", "country"],
fts_version="FTS{}".format(fts_version), fts_version=f"FTS{fts_version}",
tokenize="porter", tokenize="porter",
) )
rows = list(table.search("bite", order_by="rowid")) rows = list(table.search("bite", order_by="rowid"))
@ -251,10 +254,10 @@ def test_fts_tokenize(fresh_db, fts_version):
def test_optimize_fts(fresh_db): def test_optimize_fts(fresh_db):
for fts_version in ("4", "5"): for fts_version in ("4", "5"):
table_name = "searchable_{}".format(fts_version) table_name = f"searchable_{fts_version}"
table = fresh_db[table_name] table = fresh_db[table_name]
table.insert_all(search_records) table.insert_all(search_records)
table.enable_fts(["text", "country"], fts_version="FTS{}".format(fts_version)) table.enable_fts(["text", "country"], fts_version=f"FTS{fts_version}")
# You can call optimize successfully against the tables OR their _fts equivalents: # You can call optimize successfully against the tables OR their _fts equivalents:
for table_name in ( for table_name in (
"searchable_4", "searchable_4",
@ -310,12 +313,12 @@ def test_disable_fts(fresh_db, create_triggers):
expected_triggers = {"searchable_ai", "searchable_ad", "searchable_au"} expected_triggers = {"searchable_ai", "searchable_ad", "searchable_au"}
else: else:
expected_triggers = set() expected_triggers = set()
assert expected_triggers == set( assert expected_triggers == {
r[0] r[0]
for r in fresh_db.execute( for r in fresh_db.execute(
"select name from sqlite_master where type = 'trigger'" "select name from sqlite_master where type = 'trigger'"
).fetchall() ).fetchall()
) }
# Now run .disable_fts() and confirm it worked # Now run .disable_fts() and confirm it worked
table.disable_fts() table.disable_fts()
assert ( assert (
@ -424,7 +427,7 @@ def test_enable_fts_replace(kwargs):
db["books"].enable_fts(**kwargs, replace=True) db["books"].enable_fts(**kwargs, replace=True)
# Check that the new configuration is correct # Check that the new configuration is correct
if should_have_changed_columns: if should_have_changed_columns:
assert db["books_fts"].columns_dict.keys() == set(["title"]) assert db["books_fts"].columns_dict.keys() == {"title"}
if "create_triggers" in kwargs: if "create_triggers" in kwargs:
assert db["books"].triggers assert db["books"].triggers
if "fts_version" in kwargs: if "fts_version" in kwargs:
@ -741,6 +744,7 @@ def test_enable_fts_cli_on_view_errors(tmpdir):
db.create_view("v", "select * from t") db.create_view("v", "select * from t")
db.close() db.close()
from click.testing import CliRunner from click.testing import CliRunner
from sqlite_utils import cli as cli_module from sqlite_utils import cli as cli_module
result = CliRunner().invoke(cli_module.cli, ["enable-fts", db_path, "v", "text"]) result = CliRunner().invoke(cli_module.cli, ["enable-fts", db_path, "v", "text"])

View file

@ -1,4 +1,5 @@
import pytest import pytest
from sqlite_utils.db import NotFoundError from sqlite_utils.db import NotFoundError

View file

@ -1,7 +1,8 @@
import json import json
import pytest
import pytest
from click.testing import CliRunner from click.testing import CliRunner
from sqlite_utils.cli import cli from sqlite_utils.cli import cli
from sqlite_utils.db import Database from sqlite_utils.db import Database
from sqlite_utils.utils import find_spatialite, sqlite3 from sqlite_utils.utils import find_spatialite, sqlite3
@ -104,7 +105,7 @@ def test_query_load_extension(use_spatialite_shortcut):
[ [
":memory:", ":memory:",
"select spatialite_version()", "select spatialite_version()",
"--load-extension={}".format(load_extension), f"--load-extension={load_extension}",
], ],
) )
assert result.exit_code == 0, result.stdout assert result.exit_code == 0, result.stdout

View file

@ -1,5 +1,6 @@
from hypothesis import given
import hypothesis.strategies as st import hypothesis.strategies as st
from hypothesis import given
import sqlite_utils import sqlite_utils

View file

@ -1,10 +1,12 @@
from sqlite_utils import cli, Database
from click.testing import CliRunner
import os import os
import pathlib import pathlib
import pytest
import sys import sys
import pytest
from click.testing import CliRunner
from sqlite_utils import Database, cli
@pytest.mark.parametrize("silent", (False, True)) @pytest.mark.parametrize("silent", (False, True))
@pytest.mark.parametrize( @pytest.mark.parametrize(
@ -44,7 +46,7 @@ def test_insert_files(silent, pk_args, expected_pks):
) )
cols = [] cols = []
for coltype in coltypes: for coltype in coltypes:
cols += ["-c", "{}:{}".format(coltype, coltype)] cols += ["-c", f"{coltype}:{coltype}"]
result = runner.invoke( result = runner.invoke(
cli.cli, cli.cli,
["insert-files", db_path, "files", str(tmpdir)] ["insert-files", db_path, "files", str(tmpdir)]
@ -142,7 +144,7 @@ def test_insert_files_stdin(use_text, encoding, input, expected):
) )
assert result.exit_code == 0, result.stdout assert result.exit_code == 0, result.stdout
db = Database(db_path) db = Database(db_path)
row = list(db["files"].rows)[0] row = next(iter(db["files"].rows))
key = "content" key = "content"
if use_text: if use_text:
key = "content_text" key = "content_text"
@ -167,5 +169,5 @@ def test_insert_files_bad_text_encoding_error():
) )
assert result.exit_code == 1, result.output assert result.exit_code == 1, result.output
assert result.output.strip().startswith( assert result.output.strip().startswith(
"Error: Could not read file '{}' as text".format(str(latin.resolve())) f"Error: Could not read file '{latin.resolve()!s}' as text"
) )

View file

@ -1,6 +1,7 @@
from sqlite_utils.db import Index, View, Database, XIndex, XIndexColumn
import pytest import pytest
from sqlite_utils.db import Database, Index, View, XIndex, XIndexColumn
def _check_supports_strict(): def _check_supports_strict():
"""Check if SQLite supports strict tables without leaking the database.""" """Check if SQLite supports strict tables without leaking the database."""
@ -57,8 +58,8 @@ def test_detect_fts_similar_tables(fresh_db, reverse_order):
fresh_db[table2].insert({"title": "Hello"}).enable_fts( fresh_db[table2].insert({"title": "Hello"}).enable_fts(
["title"], fts_version="FTS4" ["title"], fts_version="FTS4"
) )
assert fresh_db[table1].detect_fts() == "{}_fts".format(table1) assert fresh_db[table1].detect_fts() == f"{table1}_fts"
assert fresh_db[table2].detect_fts() == "{}_fts".format(table2) assert fresh_db[table2].detect_fts() == f"{table2}_fts"
def test_tables(existing_db): def test_tables(existing_db):
@ -311,6 +312,7 @@ def test_table_strict(fresh_db, create_table, expected_strict):
1, 1,
1.3, 1.3,
"foo", "foo",
"O'Brien",
True, True,
b"binary", b"binary",
), ),
@ -321,3 +323,28 @@ def test_table_default_values(fresh_db, value):
) )
default_values = fresh_db["default_values"].default_values default_values = fresh_db["default_values"].default_values
assert default_values == {"value": value} assert default_values == {"value": value}
def test_table_default_values_escaped_quotes(fresh_db):
# SQLite stores string defaults with single quotes doubled, so
# introspection needs to unescape them again
fresh_db.execute(
"create table t (id integer primary key, name text default 'O''Brien')"
)
assert "default 'O''Brien'" in fresh_db["t"].schema
assert fresh_db["t"].default_values == {"name": "O'Brien"}
def test_pks_use_primary_key_declaration_order(fresh_db):
# PRIMARY KEY (a, b) declared against columns stored in order (b, a) -
# pks must follow the declaration order, which is what SQLite uses to
# resolve implicit foreign key references and compound pk lookups
fresh_db.execute("create table t (b text, a text, primary key (a, b))")
assert fresh_db["t"].pks == ["a", "b"]
def test_transform_preserves_compound_pk_declaration_order(fresh_db):
fresh_db.execute("create table t (a text, b text, c text, primary key (b, a))")
fresh_db["t"].transform(drop={"c"})
assert fresh_db["t"].pks == ["b", "a"]
assert 'PRIMARY KEY ("b", "a")' in fresh_db["t"].schema

View file

@ -3,6 +3,7 @@ Tests for list-based iteration in insert_all and upsert_all
""" """
import pytest import pytest
from sqlite_utils import Database from sqlite_utils import Database

View file

@ -1,6 +1,7 @@
from sqlite_utils.db import Index
import pytest import pytest
from sqlite_utils.db import Index
def test_lookup_new_table(fresh_db): def test_lookup_new_table(fresh_db):
species = fresh_db["species"] species = fresh_db["species"]
@ -157,3 +158,27 @@ def test_lookup_with_extra_insert_parameters(fresh_db):
def test_lookup_new_table_strict(fresh_db, strict): def test_lookup_new_table_strict(fresh_db, strict):
fresh_db["species"].lookup({"name": "Palm"}, strict=strict) fresh_db["species"].lookup({"name": "Palm"}, strict=strict)
assert fresh_db["species"].strict == strict or not fresh_db.supports_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

@ -1,6 +1,7 @@
from sqlite_utils.db import ForeignKey, NoObviousTable
import pytest import pytest
from sqlite_utils.db import ForeignKey, NoObviousTable
def test_insert_m2m_single(fresh_db): def test_insert_m2m_single(fresh_db):
dogs = fresh_db["dogs"] dogs = fresh_db["dogs"]
@ -65,8 +66,7 @@ def test_insert_m2m_iterable(fresh_db):
iterable_records = ({"id": 1, "name": "Phineas"}, {"id": 2, "name": "Ferb"}) iterable_records = ({"id": 1, "name": "Phineas"}, {"id": 2, "name": "Ferb"})
def iterable(): def iterable():
for record in iterable_records: yield from iterable_records
yield record
platypuses = fresh_db["platypuses"] platypuses = fresh_db["platypuses"]
platypuses.insert({"id": 1, "name": "Perry"}, pk="id").m2m( platypuses.insert({"id": 1, "name": "Perry"}, pk="id").m2m(

View file

@ -1,4 +1,5 @@
import pytest import pytest
import sqlite_utils import sqlite_utils
from sqlite_utils import Migrations from sqlite_utils import Migrations
@ -154,8 +155,7 @@ def test_non_transactional_migration_allows_vacuum(tmpdir):
def test_apply_composes_inside_outer_transaction(migrations): def test_apply_composes_inside_outer_transaction(migrations):
db = sqlite_utils.Database(memory=True) db = sqlite_utils.Database(memory=True)
with pytest.raises(ZeroDivisionError): with pytest.raises(ZeroDivisionError), db.atomic():
with db.atomic():
migrations.apply(db) migrations.apply(db)
raise ZeroDivisionError raise ZeroDivisionError
# The outer transaction rolled back, taking the migrations with it # The outer transaction rolled back, taking the migrations with it
@ -214,3 +214,33 @@ def test_duplicate_migration_name_errors():
pass pass
assert "m001" in str(ex.value) assert "m001" in str(ex.value)
def test_stop_before_applied_migration_errors(migrations):
# Stopping before a migration that has already been applied is
# impossible to honor - previously the stop name was only checked
# against pending migrations, so everything after it was applied
db = sqlite_utils.Database(memory=True)
migrations.apply(db, stop_before="m002") # applies m001 only
with pytest.raises(ValueError) as ex:
migrations.apply(db, stop_before="m001")
assert "m001" in str(ex.value)
assert "already been applied" in str(ex.value)
# Nothing else was applied
assert not db["cats"].exists()
def test_stop_before_applied_migration_errors_before_any_apply(migrations):
# The error fires before any pending migration runs, even those that
# come before the already-applied stop target in registration order
db = sqlite_utils.Database(memory=True)
only_second = Migrations("test")
@only_second()
def m002(db):
db["cats"].create({"name": str})
only_second.apply(db) # m002 applied, m001 still pending
with pytest.raises(ValueError):
migrations.apply(db, stop_before="m002")
assert not db["dogs"].exists()

View file

@ -1,9 +1,12 @@
from click.testing import CliRunner
import click
import importlib import importlib
import pytest import sqlite3
import sys import sys
from sqlite_utils import cli, Database, hookimpl, plugins
import click
import pytest
from click.testing import CliRunner
from sqlite_utils import Database, cli, hookimpl, plugins
def _supports_pragma_function_list(): def _supports_pragma_function_list():
@ -11,7 +14,7 @@ def _supports_pragma_function_list():
try: try:
db.execute("select * from pragma_function_list()") db.execute("select * from pragma_function_list()")
return True return True
except Exception: except sqlite3.DatabaseError:
return False return False
finally: finally:
db.close() db.close()

View file

@ -1,6 +1,7 @@
import pytest
import types import types
import pytest
from sqlite_utils.utils import sqlite3 from sqlite_utils.utils import sqlite3
@ -59,6 +60,10 @@ def test_query_rejected_write_inside_transaction_is_rolled_back(fresh_db):
"-- comment\nbegin", "-- comment\nbegin",
"/* multi\nline */ -- and another\n vacuum", "/* multi\nline */ -- and another\n vacuum",
"\t /* a */ /* b */ savepoint s1", "\t /* a */ /* b */ savepoint s1",
"; commit",
";;\n ; rollback",
"; /* comment */ vacuum",
"\ufeffbegin",
], ],
) )
def test_query_rejects_transaction_control_and_vacuum(fresh_db, sql): def test_query_rejects_transaction_control_and_vacuum(fresh_db, sql):
@ -83,6 +88,23 @@ def test_query_comment_prefixed_commit_does_not_commit_transaction(fresh_db):
assert [row["name"] for row in fresh_db["dogs"].rows] == ["Cleo"] 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): def test_query_error_leaves_no_transaction_open(fresh_db):
with pytest.raises(sqlite3.OperationalError): with pytest.raises(sqlite3.OperationalError):
fresh_db.query("select * from missing_table") fresh_db.query("select * from missing_table")
@ -101,6 +123,18 @@ def test_query_pragma(tmpdir):
db.close() 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): def test_query_comment_prefixed_pragma(tmpdir):
from sqlite_utils import Database from sqlite_utils import Database
@ -137,6 +171,12 @@ def test_query_comment_prefixed_pragma_inside_transaction(fresh_db):
("", ""), ("", ""),
(" ", ""), (" ", ""),
("123", ""), ("123", ""),
("; commit", "COMMIT"),
(";;\n ; rollback", "ROLLBACK"),
("; -- comment\n begin", "BEGIN"),
("\ufeffcommit", "COMMIT"),
("\ufeff ; select 1", "SELECT"),
(";", ""),
], ],
) )
def test_first_keyword(sql, expected): def test_first_keyword(sql, expected):
@ -241,3 +281,24 @@ def test_execute_returning_dicts(fresh_db):
assert fresh_db.execute_returning_dicts("select * from test") == [ assert fresh_db.execute_returning_dicts("select * from test") == [
{"id": 1, "bar": 2} {"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,7 +1,9 @@
import json
import pytest
from sqlite_utils import recipes from sqlite_utils import recipes
from sqlite_utils.utils import sqlite3 from sqlite_utils.utils import sqlite3
import json
import pytest
@pytest.fixture @pytest.fixture

View file

@ -1,8 +1,10 @@
from sqlite_utils import Database
import sqlite3
import pathlib import pathlib
import sqlite3
import pytest import pytest
from sqlite_utils import Database
def test_recreate_ignored_for_in_memory(): def test_recreate_ignored_for_in_memory():
# None of these should raise an exception: # None of these should raise an exception:

View file

@ -111,3 +111,30 @@ def test_rows_where_duplicate_select_columns_are_deduped(fresh_db):
fresh_db["t"].insert({"id": 1, "name": "Cleo"}) fresh_db["t"].insert({"id": 1, "name": "Cleo"})
rows = list(fresh_db["t"].rows_where(select="id, id, name")) rows = list(fresh_db["t"].rows_where(select="id, id, name"))
assert rows == [{"id": 1, "id_2": 1, "name": "Cleo"}] 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

@ -1,7 +1,9 @@
from sqlite_utils.utils import rows_from_file, Format, RowError
from io import BytesIO, StringIO from io import BytesIO, StringIO
import pytest import pytest
from sqlite_utils.utils import Format, RowError, rows_from_file
@pytest.mark.parametrize( @pytest.mark.parametrize(
"input,expected_format", "input,expected_format",
@ -29,7 +31,7 @@ def test_rows_from_file_detect_format(input, expected_format):
) )
def test_rows_from_file_extra_fields_strategies(ignore_extras, extras_key, expected): def test_rows_from_file_extra_fields_strategies(ignore_extras, extras_key, expected):
try: try:
rows, format = rows_from_file( rows, _format = rows_from_file(
BytesIO(b"id,name\r\n1,Cleo,oops"), BytesIO(b"id,name\r\n1,Cleo,oops"),
format=Format.CSV, format=Format.CSV,
ignore_extras=ignore_extras, ignore_extras=ignore_extras,

View file

@ -1,7 +1,9 @@
from sqlite_utils import cli, Database
from click.testing import CliRunner
import pathlib import pathlib
import pytest import pytest
from click.testing import CliRunner
from sqlite_utils import Database, cli
sniff_dir = pathlib.Path(__file__).parent / "sniff" sniff_dir = pathlib.Path(__file__).parent / "sniff"

View file

@ -1,5 +1,7 @@
import pytest
from collections import OrderedDict from collections import OrderedDict
import pytest
from sqlite_utils.utils import suggest_column_types from sqlite_utils.utils import suggest_column_types

View file

@ -52,6 +52,7 @@ def test_with_tracer():
assert len(collected) == 4 assert len(collected) == 4
assert collected == [ assert collected == [
(
( (
"SELECT name FROM sqlite_master\n" "SELECT name FROM sqlite_master\n"
" WHERE rootpage = 0\n" " WHERE rootpage = 0\n"
@ -62,7 +63,8 @@ def test_with_tracer():
" tbl_name = :table\n" " tbl_name = :table\n"
" AND sql LIKE '%VIRTUAL TABLE%USING FTS%'\n" " AND sql LIKE '%VIRTUAL TABLE%USING FTS%'\n"
" )\n" " )\n"
" )", " )"
),
{ {
"like": "%VIRTUAL TABLE%USING FTS%content=[dogs]%", "like": "%VIRTUAL TABLE%USING FTS%content=[dogs]%",
"like2": '%VIRTUAL TABLE%USING FTS%content="dogs"%', "like2": '%VIRTUAL TABLE%USING FTS%content="dogs"%',
@ -71,6 +73,7 @@ def test_with_tracer():
), ),
("select name from sqlite_master where type = 'view'", None), ("select name from sqlite_master where type = 'view'", None),
("select sql from sqlite_master where name = ?", ("dogs_fts",)), ("select sql from sqlite_master where name = ?", ("dogs_fts",)),
(
( (
'with "original" as (\n' 'with "original" as (\n'
" select\n" " select\n"
@ -86,7 +89,8 @@ def test_with_tracer():
"where\n" "where\n"
' "dogs_fts" match :query\n' ' "dogs_fts" match :query\n'
"order by\n" "order by\n"
' "dogs_fts".rank', ' "dogs_fts".rank'
),
{"query": "Cleopaws"}, {"query": "Cleopaws"},
), ),
] ]

View file

@ -1,7 +1,10 @@
from sqlite_utils.db import ForeignKey, TransformError import sqlite3
from sqlite_utils.utils import OperationalError
import pytest import pytest
from sqlite_utils.db import ForeignKey, TransactionError, TransformError
from sqlite_utils.utils import OperationalError
@pytest.mark.parametrize( @pytest.mark.parametrize(
"params,expected_sql", "params,expected_sql",
@ -111,7 +114,7 @@ def test_transform_sql_table_with_primary_key(
if use_pragma_foreign_keys: if use_pragma_foreign_keys:
fresh_db.conn.execute("PRAGMA foreign_keys=ON") fresh_db.conn.execute("PRAGMA foreign_keys=ON")
dogs.insert({"id": 1, "name": "Cleo", "age": "5"}, pk="id") dogs.insert({"id": 1, "name": "Cleo", "age": "5"}, pk="id")
sql = dogs.transform_sql(**{**params, **{"tmp_suffix": "suffix"}}) sql = dogs.transform_sql(**{**params, "tmp_suffix": "suffix"})
assert sql == expected_sql assert sql == expected_sql
# Check that .transform() runs without exceptions: # Check that .transform() runs without exceptions:
with fresh_db.tracer(tracer): with fresh_db.tracer(tracer):
@ -184,7 +187,7 @@ def test_transform_sql_table_with_no_primary_key(
if use_pragma_foreign_keys: if use_pragma_foreign_keys:
fresh_db.conn.execute("PRAGMA foreign_keys=ON") fresh_db.conn.execute("PRAGMA foreign_keys=ON")
dogs.insert({"id": 1, "name": "Cleo", "age": "5"}) dogs.insert({"id": 1, "name": "Cleo", "age": "5"})
sql = dogs.transform_sql(**{**params, **{"tmp_suffix": "suffix"}}) sql = dogs.transform_sql(**{**params, "tmp_suffix": "suffix"})
assert sql == expected_sql assert sql == expected_sql
# Check that .transform() runs without exceptions: # Check that .transform() runs without exceptions:
with fresh_db.tracer(tracer): with fresh_db.tracer(tracer):
@ -430,6 +433,163 @@ def test_transform_verify_foreign_keys(fresh_db):
assert fresh_db.conn.execute("PRAGMA foreign_keys").fetchone()[0] 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(f"""
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 {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(), pytest.raises(TransactionError) as excinfo:
fresh_db["authors"].transform(rename={"name": "author_name"})
message = str(excinfo.value)
assert "books" in message
assert f"ON DELETE {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(), 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): def test_transform_add_foreign_keys_from_scratch(fresh_db):
_add_country_city_continent(fresh_db) _add_country_city_continent(fresh_db)
fresh_db["places"].insert(_CAVEAU) fresh_db["places"].insert(_CAVEAU)
@ -554,25 +714,75 @@ def test_transform_preserves_rowids(fresh_db, table_type):
# Now delete and insert a row to mix up the `rowid` sequence # Now delete and insert a row to mix up the `rowid` sequence
fresh_db["places"].delete_where("id = ?", ["2"]) fresh_db["places"].delete_where("id = ?", ["2"])
fresh_db["places"].insert({"id": "4", "name": "London", "country": "UK"}) fresh_db["places"].insert({"id": "4", "name": "London", "country": "UK"})
previous_rows = list( previous_rows = [
tuple(row) for row in fresh_db.execute("select rowid, id, name from places") tuple(row) for row in fresh_db.execute("select rowid, id, name from places")
) ]
# Transform it # Transform it
fresh_db["places"].transform(column_order=("country", "name")) fresh_db["places"].transform(column_order=("country", "name"))
# Should be the same # Should be the same
next_rows = list( next_rows = [
tuple(row) for row in fresh_db.execute("select rowid, id, name from places") tuple(row) for row in fresh_db.execute("select rowid, id, name from places")
) ]
assert previous_rows == next_rows assert previous_rows == next_rows
@pytest.mark.parametrize("strict", (False, True)) @pytest.mark.parametrize(
def test_transform_strict(fresh_db, strict): "initial_strict,transform_strict,expected_strict",
dogs = fresh_db.table("dogs", strict=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"}) dogs.insert({"id": 1, "name": "Cleo"})
assert dogs.strict == strict or not fresh_db.supports_strict assert dogs.strict is initial_strict
dogs.transform(not_null={"name"}) dogs.transform(strict=transform_strict)
assert dogs.strict == strict or not fresh_db.supports_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( @pytest.mark.parametrize(

View file

@ -43,7 +43,7 @@ def test_update_compound_pk_table(fresh_db):
) )
def test_update_invalid_pk(fresh_db, pk, update_pk): def test_update_invalid_pk(fresh_db, pk, update_pk):
table = fresh_db["table"] table = fresh_db["table"]
table.insert({"id1": 5, "id2": 3, "v": 1}, pk=pk).last_pk table.insert({"id1": 5, "id2": 3, "v": 1}, pk=pk)
with pytest.raises(NotFoundError): with pytest.raises(NotFoundError):
table.update(update_pk, {"v": 2}) table.update(update_pk, {"v": 2})

View file

@ -1,7 +1,8 @@
from sqlite_utils.db import PrimaryKeyRequired
from sqlite_utils import Database
import pytest import pytest
from sqlite_utils import Database
from sqlite_utils.db import PrimaryKeyRequired
@pytest.mark.parametrize("use_old_upsert", (False, True)) @pytest.mark.parametrize("use_old_upsert", (False, True))
def test_upsert(use_old_upsert): def test_upsert(use_old_upsert):

View file

@ -1,8 +1,10 @@
from sqlite_utils import utils
import csv import csv
import io import io
import pytest import pytest
from sqlite_utils import utils
@pytest.mark.parametrize( @pytest.mark.parametrize(
"input,expected,should_be_is", "input,expected,should_be_is",
@ -57,7 +59,7 @@ def test_maximize_csv_field_size_limit():
# Reset to default in case other tests have changed it # Reset to default in case other tests have changed it
csv.field_size_limit(utils.ORIGINAL_CSV_FIELD_SIZE_LIMIT) csv.field_size_limit(utils.ORIGINAL_CSV_FIELD_SIZE_LIMIT)
long_value = "a" * 131073 long_value = "a" * 131073
long_csv = "id,text\n1,{}".format(long_value) long_csv = f"id,text\n1,{long_value}"
fp = io.BytesIO(long_csv.encode("utf-8")) fp = io.BytesIO(long_csv.encode("utf-8"))
# Using rows_from_file should error # Using rows_from_file should error
with pytest.raises(csv.Error): with pytest.raises(csv.Error):

View file

@ -1,4 +1,5 @@
import pytest import pytest
from sqlite_utils import Database from sqlite_utils import Database
from sqlite_utils.db import TransactionError from sqlite_utils.db import TransactionError
@ -11,7 +12,7 @@ def db_path_tmpdir(tmpdir):
def test_enable_disable_wal(db_path_tmpdir): def test_enable_disable_wal(db_path_tmpdir):
db, path, tmpdir = db_path_tmpdir db, _path, tmpdir = db_path_tmpdir
assert len(tmpdir.listdir()) == 1 assert len(tmpdir.listdir()) == 1
assert "delete" == db.journal_mode assert "delete" == db.journal_mode
assert "test.db-wal" not in [f.basename for f in tmpdir.listdir()] assert "test.db-wal" not in [f.basename for f in tmpdir.listdir()]
@ -25,10 +26,9 @@ def test_enable_disable_wal(db_path_tmpdir):
def test_enable_wal_inside_transaction_raises(db_path_tmpdir): def test_enable_wal_inside_transaction_raises(db_path_tmpdir):
db, path, tmpdir = db_path_tmpdir db, _path, _tmpdir = db_path_tmpdir
db["test"].insert({"id": 1}, pk="id") db["test"].insert({"id": 1}, pk="id")
with pytest.raises(TransactionError): with pytest.raises(TransactionError), db.atomic():
with db.atomic():
db["test"].insert({"id": 2}, pk="id") db["test"].insert({"id": 2}, pk="id")
db.enable_wal() db.enable_wal()
# The atomic() block must have rolled back cleanly and the # The atomic() block must have rolled back cleanly and the
@ -38,11 +38,10 @@ def test_enable_wal_inside_transaction_raises(db_path_tmpdir):
def test_disable_wal_inside_transaction_raises(db_path_tmpdir): def test_disable_wal_inside_transaction_raises(db_path_tmpdir):
db, path, tmpdir = db_path_tmpdir db, _path, _tmpdir = db_path_tmpdir
db.enable_wal() db.enable_wal()
db["test"].insert({"id": 1}, pk="id") db["test"].insert({"id": 1}, pk="id")
with pytest.raises(TransactionError): with pytest.raises(TransactionError), db.atomic():
with db.atomic():
db["test"].insert({"id": 2}, pk="id") db["test"].insert({"id": 2}, pk="id")
db.disable_wal() db.disable_wal()
assert db.journal_mode == "wal" assert db.journal_mode == "wal"
@ -50,7 +49,7 @@ def test_disable_wal_inside_transaction_raises(db_path_tmpdir):
def test_ensure_autocommit_on(db_path_tmpdir): def test_ensure_autocommit_on(db_path_tmpdir):
db, path, tmpdir = db_path_tmpdir db, _path, _tmpdir = db_path_tmpdir
previous_isolation_level = db.conn.isolation_level previous_isolation_level = db.conn.isolation_level
assert previous_isolation_level is not None assert previous_isolation_level is not None
with db.ensure_autocommit_on(): with db.ensure_autocommit_on():
@ -63,9 +62,25 @@ def test_ensure_autocommit_on(db_path_tmpdir):
def test_enable_wal_noop_inside_transaction_is_allowed(db_path_tmpdir): def test_enable_wal_noop_inside_transaction_is_allowed(db_path_tmpdir):
# Calling enable_wal() when WAL is already enabled is a no-op, # Calling enable_wal() when WAL is already enabled is a no-op,
# so it is fine inside a transaction # so it is fine inside a transaction
db, path, tmpdir = db_path_tmpdir db, _path, _tmpdir = db_path_tmpdir
db.enable_wal() db.enable_wal()
with db.atomic(): with db.atomic():
db["test"].insert({"id": 1}, pk="id") db["test"].insert({"id": 1}, pk="id")
db.enable_wal() db.enable_wal()
assert [r["id"] for r in db["test"].rows] == [1] 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), 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]