table.add_foreign_key() now accepts on_delete= and on_update= to
create foreign keys with ON DELETE/ON UPDATE actions:
table.add_foreign_key("author_id", "authors", "id", on_delete="CASCADE")
Works for compound foreign keys too.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ForeignKey.columns and .other_columns are now tuples rather than lists,
both when introspected and when constructed directly (lists are
normalized in __post_init__). Tuples are now the documented form for
specifying compound foreign keys everywhere - in create_table()
foreign_keys=, add_foreign_key() and drop_foreign_keys=:
foreign_keys=[(("campus_name", "dept_code"), "departments")]
Lists continue to work as input but are no longer documented.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Changelog entries for all the compound foreign key work
- Upgrading guide notes the transform() behavior changes
- ForeignKey added to the API reference
- Updated .foreign_keys introspection example output
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ForeignKey gains on_delete and on_update fields (default "NO ACTION"),
populated from PRAGMA foreign_key_list. create_table_sql() renders the
corresponding clauses for both inline and compound table-level foreign
keys, which means table.transform() now preserves actions such as
ON DELETE CASCADE - previously they were silently stripped whenever a
table was transformed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
table.add_foreign_key() and db.add_foreign_keys() now accept lists of
column names to create compound foreign keys:
table.add_foreign_key(
["campus_name", "dept_code"], "departments"
)
Omitting the other columns uses the compound primary key of the other
table. Duplicate detection compares the full column lists.
db.index_foreign_keys() now creates a single composite index across the
columns of a compound foreign key, rather than an index per column.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Compound foreign keys are passed through table.transform() intact
instead of being degraded to per-column single foreign keys:
- rename= applies to each member column of a compound key
- drop= of any member column drops the whole constraint, matching
the existing single-column behavior
- drop_foreign_keys= accepts a bare column name (drops any foreign
key that column participates in) or a tuple of columns (drops the
compound key with exactly those columns)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
create_table() and friends now accept compound foreign keys, specified
as lists of column names in the existing tuple forms:
foreign_keys=[
(["campus_name", "dept_code"], "departments"),
(["campus_name", "dept_code"], "departments", ["campus_name", "dept_code"]),
]
The two-item form guesses the compound primary key of the other table.
Compound keys are rendered as table-level FOREIGN KEY constraints,
while single-column keys keep their inline REFERENCES clauses.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Rejecting statements that return no rows is the change most likely
to require code edits when upgrading to 4.0, so it now leads the
Python API changes section - before db.table() - with the full list
of rejected statement types and a before/after example.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
db.query() rejected statements roll back, RETURNING commits immediately.
Fixes two transaction bugs in db.query():
- A non-row-returning statement such as an UPDATE was executed and
auto-committed by execute() before the ValueError was raised, so the
write took effect despite being rejected. query() now runs the
statement inside a savepoint and rolls it back before raising, so a
rejected statement has no effect on the database - in every
connection mode, including autocommit=True.
- INSERT ... RETURNING only committed once the returned generator was
fully exhausted, so calling query() without iterating - or partially
iterating with next() - left the transaction open and the write could
be rolled back on close. The write is now completed and committed at
call time, as the documentation already promised. Plain SELECTs are
still fetched lazily.
Transaction control statements, VACUUM, ATTACH and DETACH never return
rows, so query() now rejects them without executing them. PRAGMA
statements skip the savepoint guard because some of them - such as
pragma journal_mode=wal - refuse to run inside a transaction.
Claude-Session: https://claude.ai/code/session_012U3iRfJoTZ5vd22cBSF2nJ
The documentation style for this project is one line per paragraph,
not text-wrapped RST. Unwraps three recently added paragraphs in
python-api.rst and two older ones in plugins.rst.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UnLnhsH25Nnv7LHhekUfPd
Raw write statements previously opened an implicit transaction that
stayed open until something committed it - the write was visible on
the same connection, making it look saved, but was silently rolled
back when the connection closed. execute() now commits any implicit
transaction it opens, so raw writes behave like every other write
in the library: committed as soon as they run, unless an explicit
transaction (db.begin() or db.atomic()) is open, in which case they
join it.
Row-returning writes such as INSERT ... RETURNING via db.query()
commit once their rows have been iterated. atomic(), commit() and
rollback() now issue literal COMMIT/ROLLBACK statements, which have
identical semantics in every sqlite3 connection mode. The CLI bulk
command uses db.atomic() instead of 'with db.conn:'.
This simplifies the transaction contract to: everything commits
immediately unless you explicitly opened a transaction. Docs
updated throughout; changelog and upgrading guide cover the
breaking change for code that relied on rolling back uncommitted
execute() writes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UnLnhsH25Nnv7LHhekUfPd
Connections created with sqlite3.connect(autocommit=True) make
commit() a documented no-op, and autocommit=False connections are
permanently inside a transaction - in both modes every write made
by this library appeared to work in-process but was silently
discarded when the connection closed. Database() now raises
TransactionError for these connections instead of losing data.
Tests are skipped on Python versions before 3.12, where the
autocommit parameter does not exist.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UnLnhsH25Nnv7LHhekUfPd
A dedicated sqlite_utils.db.TransactionError exception is clearer
to catch than a generic RuntimeError, and gives future
transaction-state guards a home.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UnLnhsH25Nnv7LHhekUfPd
Manual transaction control previously required mixing raw
db.execute("begin") strings with methods on db.conn. These thin
wrappers give the documentation and callers a single consistent
idiom. Docs updated to use them throughout.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UnLnhsH25Nnv7LHhekUfPd
Expands the closing-a-database docs: exit is equivalent to close(),
open transactions can only come from manual transaction control,
auto-committing could silently persist half-finished work, and the
behavior deliberately differs from sqlite3.Connection's own context
manager.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UnLnhsH25Nnv7LHhekUfPd
The manual-transaction warning could be read as covering
db.atomic() too. Nested atomic() blocks are safe - they become
savepoints inside the migration's transaction, preserving the
all-or-nothing rollback guarantee.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UnLnhsH25Nnv7LHhekUfPd
The method existed only to raise NotImplementedError, since
full-text search is not supported for views, and it showed up in
the generated API reference as a documented View method. Calling
enable_fts() on a View now raises AttributeError like any other
missing method. The sqlite-utils enable-fts command uses db.table()
and shows a clean error when pointed at a view instead of a
traceback.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UnLnhsH25Nnv7LHhekUfPd
New top-level 'Transactions and saving your changes' section in the
Python API documentation, stating the fundamental contract up
front: every write method commits its own changes before returning,
no commit() call is ever needed. The db.atomic() documentation
moves under it, alongside new subsections covering the two cases
where the user owns the commit - raw db.execute() writes and
manually managed transactions - and the supported connection modes.
Cross-referenced from Getting started (the first insert example now
says the rows are saved immediately), a warning on db.execute(),
the closing-a-database section and the upgrading guide.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UnLnhsH25Nnv7LHhekUfPd
New docs page describing the changes needed to upgrade between
major versions, with a detailed 3.x to 4.0 section covering CLI,
Python API and packaging changes, plus brief sections for the
2.x to 3.0 and 1.x to 2.0 upgrades.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UnLnhsH25Nnv7LHhekUfPd
User-facing argument validation in db.py previously used bare
assert statements, which vanish entirely under python -O and raise
AssertionError - an exception type callers should not have to
catch for input validation. Fifteen validation sites now raise
ValueError with the same messages. Internal invariants (an
unreachable branch and a post-update rowcount check) remain
asserts.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UnLnhsH25Nnv7LHhekUfPd
Type detection has been the default for CSV/TSV data since 4.0a1,
making this flag a no-op kept only for backwards compatibility.
4.0 is the release where it can be removed - the flag now errors,
prompting scripts to drop it. --no-detect-types is unchanged. The
dead detect_types parameter has been removed from
insert_upsert_implementation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UnLnhsH25Nnv7LHhekUfPd
pending() and applied() no longer create the _sqlite_migrations
table or perform the one-way legacy schema upgrade - that now only
happens in apply(). The CLI --list path no longer creates the
database file when it does not exist. applied() results are now
explicitly ordered by insertion.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UnLnhsH25Nnv7LHhekUfPd
An unknown --stop-before value previously matched nothing and every
migration was silently applied, including the one the user meant to
stop before. The CLI now errors unless each value matches a known
migration.
The CLI also always passed stop_before as a list, but older
duck-typed sqlite-migrate Migrations objects compare it against a
single string name - so the flag was silently ignored for exactly
the migration files the compatibility shim exists to support. Legacy
sets now receive a single string, with an error if multiple values
target one legacy set.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UnLnhsH25Nnv7LHhekUfPd
Documents that atomic() opens a deferred BEGIN, that calling it
while the connection is already inside a transaction produces a
savepoint whose changes only persist when the outer transaction
commits, and that Python 3.12+ autocommit=True/False connections
are not supported. Adds a test pinning the savepoint-inside-manual-
BEGIN behavior.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UnLnhsH25Nnv7LHhekUfPd
with Database(...) closes the connection on exit without
committing, so uncommitted changes are rolled back. This was the
existing behavior but was undocumented and untested - it is now
stated in the docs and pinned by a test.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UnLnhsH25Nnv7LHhekUfPd
Changing the journal mode assigns conn.isolation_level, which
commits any open transaction as a side effect - silently breaking
the rollback guarantee of atomic() blocks and of user-managed
transactions. Both methods now raise RuntimeError if a transaction
is open. Calling them when the database is already in the requested
mode remains a no-op.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UnLnhsH25Nnv7LHhekUfPd
A record missing a primary key value - or with None for one - can
never match an existing row, because a NULL primary key never
satisfies ON CONFLICT. Such records were previously inserted as
brand new rows (silently, for multi-record upsert_all calls) or
triggered a KeyError after the insert had already happened. Both
cases, including upserting an empty record, now raise
PrimaryKeyRequired before any SQL is executed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UnLnhsH25Nnv7LHhekUfPd
Previously query() was a generator function, so nothing - including
the SQL itself - ran until the result was first iterated. A write
statement passed to query() silently did nothing, and SQL errors
surfaced far from the call site. The SQL now executes as soon as
query() is called, while rows are still fetched lazily during
iteration.
Statements that return no rows now raise a ValueError directing
callers to execute() instead. As a side effect, statements like
INSERT ... RETURNING now work naturally with query().
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UnLnhsH25Nnv7LHhekUfPd
Migrations.apply() now wraps each migration function and its
_sqlite_migrations tracking row in db.atomic(), so they commit
together: a failing migration rolls back cleanly, is not recorded,
and stays pending, eliminating the double-apply hazard where a
partially-failed migration left committed side effects behind.
Migrations that cannot run inside a transaction (VACUUM, journal
mode changes, manual transaction management) can opt out by
registering with @migrations(transactional=False).
Also fixed the test fixtures which used db.query() for INSERT
statements - query() is a lazy generator, so those inserts never
actually executed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UnLnhsH25Nnv7LHhekUfPd
Table.delete_where() ran its DELETE via a bare execute() with no
commit, unlike Table.delete() which wraps in db.atomic(). The
connection was left with an open implicit transaction, so the
deletion (and all subsequent writes, including later atomic()
blocks which switched to savepoint mode) was silently rolled back
when the connection closed.
Wrap the DELETE in db.atomic() and remove the now-unnecessary
atomic() wrapper from the delete_where documentation example.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UnLnhsH25Nnv7LHhekUfPd
* Add comprehensive type annotations
- mypy.ini: expanded configuration with module-specific settings
- hookspecs.py: type annotations for hook functions
- plugins.py: typed get_plugins() return value
- recipes.py: full type annotations for parsedate, parsedatetime, jsonsplit
- utils.py: extensive type annotations including Row type alias,
TypeTracker, ValueTracker, and all utility functions
- db.py: type annotations for Database methods (__exit__,
ensure_autocommit_off, tracer, register_function, etc.) and
Queryable class methods
- tests/test_docs.py: updated to match new signature display format
* Fix type errors caught by ty check
- Add type: ignore comments for external library type stub limitations
(csv.reader, click.progressbar, IOBase.name, Callable.__name__)
- Change Iterable to Sequence for SQL where_args parameters
- Use db.table() instead of db[name] for proper Table return type
- Fix rebuild_fts return type from None to Table
- Update test_tracer to expect fewer queries (optimization side effect)
* Fix mypy type errors
- Add type: ignore comments for runtime-valid patterns mypy can't verify
- Fix new_column_types annotation to Dict[str, Set[type]]
- Add type: ignore for Default sentinel values passed to create_table
* mypy skip tests directory
* Fix CI: exclude typing imports from recipe docs, skip mypy on tests
- Add Callable and Optional to exclusion list in _generate_convert_help()
- Regenerate docs/cli-reference.rst with cog
- Add [mypy-tests.*] ignore_errors = True to skip test type errors
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
* Fix type warning for pipe.stdout possibly being None
Add conditional check before calling .read() on pipe.stdout since
Popen can return None for stdout.
* Use ctx.meta instead of dynamic attribute for database cleanup
Click's Context.meta dictionary is the proper way to store arbitrary
data on the context object, avoiding type checker warnings about
dynamic attribute assignment.
* Add assert for tables.callback before calling
Click's callback attribute is typed as Optional[Callable], so add
assert to satisfy type checker that it's not None.
* Fix type errors in cli.py and db.py
- Add type annotation for Database.conn to fix context manager errors
- Convert exception objects to str() when raising ClickException
- Handle None return from find_spatialite() with proper error message
* Fix remaining type errors in cli.py
- Add typing import and type annotations for dict kwargs
- Use db.table() instead of db[] for extract command
- Fix missing str() conversion for exception
* Fix type errors in db.py
- Add type annotation for Database.conn
- Add type: ignore for optional sqlite_dump import
- Update execute/query parameter types to Sequence|Dict for sqlite3 compatibility
- Use getattr for fn.__name__ access to handle callables without __name__
- Handle None return from find_spatialite() with OSError
- Fix pk_values assignment to use local variable
* Add type: ignore for optional pysqlite3 and sqlean imports
These are alternative sqlite3 implementations that may not be installed.
* Fix type errors in tests and plugins
- Add type: ignore for monkey-patching Database.__init__ in conftest
- Fix CLI test to pass string "2" instead of integer to Click invoke
- Add type: ignore for optional sqlean import
- Fix add_geometry_column test to use "XY" instead of integer 2
- Add type: ignore for click.Context as context manager
- Add type: ignore for enable_fts test that intentionally omits argument
- Add type: ignore for sys._called_from_test dynamic attribute
- Fix rows_from_file test type error for intentional wrong argument
- Handle None from pm.get_hookcallers in plugins.py
* Use db.table() instead of db[] for Table-specific operations
Changes db[table] to db.table(table) in CLI commands where we know
we're working with tables, not views. This resolves most of the
Table | View disambiguation type warnings since db.table() returns
Table directly rather than Table | View.
* Fix remaining type warnings in sqlite_utils package
- Add assert for sniff_buffer not being None
- Handle cursor.fetchone() potentially returning None
- Use db.table() for counts_table and index_foreign_keys
- Add type: ignore for cursor union type in raw mode
* Ran Black
* Run ty in CI
* ty check sqlite_utils
* Skip running ty on Windows
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This now works:
echo '{"date": "13th January 2025"}' | sqlite-utils insert dates.db dates -
sqlite-utils convert dates.db dates date r.parsedate
sqlite-utils rows dates.db dates
Previously the command would have been:
sqlite-utils convert dates.db dates date 'r.parsedate(value)'
The `--detect-types` option is now automatically turned on for all commands that deal with CSV or CSV.
A new `--no-detect-types` option can be used to have all columns treated as text.
Closes#679
Closes#659
The --functions option now accepts:
- File paths ending in .py (e.g., --functions my_funcs.py)
- Multiple invocations (e.g., --functions foo.py --functions 'def bar(): ...')
- Inline Python code (existing behavior)
Implementation follows the same pattern as llm's --functions flag
(simonw/llm@a880c123).
Changes:
- Added multiple=True to --functions Click option in query, bulk, and memory commands
- Modified _register_functions() to detect and read .py files
- Updated _maybe_register_functions() to iterate over multiple function sources
- Removed unused bytes/bytearray handling
- Added comprehensive tests for file paths and multiple invocations
- Updated documentation with examples
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* Shorter help for --functions
---------
Co-authored-by: Claude <noreply@anthropic.com>