Skip to content

Fixes for final review in issue #769#779

Merged
simonw merged 16 commits into
mainfrom
issue-769-review-fixes
Jul 7, 2026
Merged

Fixes for final review in issue #769#779
simonw merged 16 commits into
mainfrom
issue-769-review-fixes

Conversation

@simonw

@simonw simonw commented Jul 7, 2026

Copy link
Copy Markdown
Owner

simonw and others added 15 commits July 6, 2026 21:31
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 #769 (comment)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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 #769 (comment)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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 #769 (comment)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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 #769 (comment)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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 #769 (comment)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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 #769 (comment)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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 #769 (comment)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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 #769 (comment)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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 #769 (comment)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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 #769 (comment)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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 #769 (comment)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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 #769 (comment)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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 #769 (comment)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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 #769 (comment)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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 #769 (comment)

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

codecov Bot commented Jul 7, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.57143% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 95.15%. Comparing base (adc10df) to head (2582446).
⚠️ Report is 17 commits behind head on main.

Files with missing lines Patch % Lines
sqlite_utils/db.py 98.03% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #779      +/-   ##
==========================================
+ Coverage   95.11%   95.15%   +0.03%     
==========================================
  Files           9        9              
  Lines        3682     3712      +30     
==========================================
+ Hits         3502     3532      +30     
  Misses        180      180              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

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 #769 (comment)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@simonw
simonw marked this pull request as ready for review July 7, 2026 05:21
@simonw
simonw merged commit e5c7728 into main Jul 7, 2026
106 checks passed
@simonw
simonw deleted the issue-769-review-fixes branch July 7, 2026 05:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant