sqrrl 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. sqrrl-0.1.0/.gitignore +16 -0
  2. sqrrl-0.1.0/LICENSE +21 -0
  3. sqrrl-0.1.0/MANIFEST.in +5 -0
  4. sqrrl-0.1.0/PKG-INFO +289 -0
  5. sqrrl-0.1.0/README.md +256 -0
  6. sqrrl-0.1.0/benchmarks/bench_imports.py +163 -0
  7. sqrrl-0.1.0/benchmarks/bench_sqrrl.py +177 -0
  8. sqrrl-0.1.0/examples/__init__.py +0 -0
  9. sqrrl-0.1.0/examples/main.py +23 -0
  10. sqrrl-0.1.0/examples/migrations/000001_initial.json +195 -0
  11. sqrrl-0.1.0/examples/migrations/000001_initial.sql +25 -0
  12. sqrrl-0.1.0/examples/models.py +195 -0
  13. sqrrl-0.1.0/examples/schema.py +36 -0
  14. sqrrl-0.1.0/examples/sqrrl.json +5 -0
  15. sqrrl-0.1.0/pyproject.toml +77 -0
  16. sqrrl-0.1.0/setup.cfg +4 -0
  17. sqrrl-0.1.0/sqrrl/__init__.py +13 -0
  18. sqrrl-0.1.0/sqrrl/__main__.py +3 -0
  19. sqrrl-0.1.0/sqrrl/cli.py +193 -0
  20. sqrrl-0.1.0/sqrrl/errors.py +17 -0
  21. sqrrl-0.1.0/sqrrl/generate.py +226 -0
  22. sqrrl-0.1.0/sqrrl/migrate.py +548 -0
  23. sqrrl-0.1.0/sqrrl/py.typed +0 -0
  24. sqrrl-0.1.0/sqrrl/runtime.py +557 -0
  25. sqrrl-0.1.0/sqrrl/schema.py +450 -0
  26. sqrrl-0.1.0/sqrrl.egg-info/PKG-INFO +289 -0
  27. sqrrl-0.1.0/sqrrl.egg-info/SOURCES.txt +38 -0
  28. sqrrl-0.1.0/sqrrl.egg-info/dependency_links.txt +1 -0
  29. sqrrl-0.1.0/sqrrl.egg-info/entry_points.txt +2 -0
  30. sqrrl-0.1.0/sqrrl.egg-info/requires.txt +11 -0
  31. sqrrl-0.1.0/sqrrl.egg-info/top_level.txt +1 -0
  32. sqrrl-0.1.0/tests/conftest.py +66 -0
  33. sqrrl-0.1.0/tests/test_async.py +258 -0
  34. sqrrl-0.1.0/tests/test_cli.py +84 -0
  35. sqrrl-0.1.0/tests/test_generate.py +155 -0
  36. sqrrl-0.1.0/tests/test_migrate.py +270 -0
  37. sqrrl-0.1.0/tests/test_pass_regressions.py +119 -0
  38. sqrrl-0.1.0/tests/test_regressions.py +377 -0
  39. sqrrl-0.1.0/tests/test_runtime.py +142 -0
  40. sqrrl-0.1.0/tests/test_schema.py +39 -0
sqrrl-0.1.0/.gitignore ADDED
@@ -0,0 +1,16 @@
1
+ .venv/
2
+ .idea/
3
+ __pycache__/
4
+ *.py[cod]
5
+ *.egg-info/
6
+ .pytest_cache/
7
+ .mypy_cache/
8
+ .ruff_cache/
9
+ build/
10
+ dist/
11
+ .coverage
12
+ .coverage.*
13
+ htmlcov/
14
+ *.db
15
+ *.db-wal
16
+ *.db-shm
sqrrl-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Caprine Logic
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,5 @@
1
+ include .gitignore
2
+ recursive-include tests *.py
3
+ recursive-include benchmarks *.py
4
+ recursive-include examples *.py *.json *.sql
5
+ global-exclude __pycache__ *.py[cod] *.pyd *.so
sqrrl-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,289 @@
1
+ Metadata-Version: 2.4
2
+ Name: sqrrl
3
+ Version: 0.1.0
4
+ Summary: Generated typed async data access and checked migrations for SQLite
5
+ Author: depthbomb
6
+ License-Expression: MIT
7
+ Project-URL: Documentation, https://github.com/depthbomb/sqrrl#readme
8
+ Project-URL: Source, https://github.com/depthbomb/sqrrl
9
+ Project-URL: Issues, https://github.com/depthbomb/sqrrl/issues
10
+ Project-URL: Releases, https://github.com/depthbomb/sqrrl/releases
11
+ Keywords: sqlite,async,database,migrations,code-generation,typing
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Classifier: Typing :: Typed
19
+ Requires-Python: >=3.14
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: aiosqlite>=0.22
23
+ Provides-Extra: dev
24
+ Requires-Dist: build>=1.2; extra == "dev"
25
+ Requires-Dist: mypy>=1.18.2; extra == "dev"
26
+ Requires-Dist: pyright>=1.1.407; extra == "dev"
27
+ Requires-Dist: pytest>=8; extra == "dev"
28
+ Requires-Dist: pytest-asyncio>=1; extra == "dev"
29
+ Requires-Dist: pytest-cov>=6; extra == "dev"
30
+ Requires-Dist: ruff>=0.14; extra == "dev"
31
+ Requires-Dist: twine>=6; extra == "dev"
32
+ Dynamic: license-file
33
+
34
+ # sqrrl (pronounced squirrel)
35
+
36
+ Typed async SQLite access, generated from a Python schema, with easy migration management.
37
+
38
+ Describe your tables once, and sqrrl generates dataclasses, repositories, and
39
+ column helpers with real type annotations. Your editor knows which fields a
40
+ query returns and which arguments a write accepts. Database calls run through
41
+ `aiosqlite`.
42
+
43
+ Requires **Python 3.14+** and **SQLite 3.37+**. This is an early release, and the
44
+ API is still taking shape.
45
+
46
+ ## Getting started
47
+
48
+ With your virtual environment active:
49
+
50
+ ```sh
51
+ python -m pip install sqrrl
52
+ ```
53
+
54
+ In your app's directory, create a starter schema and configuration:
55
+
56
+ ```sh
57
+ sqrrl init
58
+ ```
59
+
60
+ That gives you `schema.py` with a small notes table and a `sqrrl.json` file:
61
+
62
+ ```json
63
+ {
64
+ "schema": "schema:schema",
65
+ "output": "models.py",
66
+ "migrations": "migrations"
67
+ }
68
+ ```
69
+
70
+ Generate the Python code and your first migration, then create the database:
71
+
72
+ ```sh
73
+ sqrrl generate
74
+ sqrrl migrate diff initial
75
+ # Review migrations/000001_initial.sql before applying it.
76
+ sqrrl migrate up --db app.db
77
+ ```
78
+
79
+ Save this as `main.py` next to the generated `models.py`:
80
+
81
+ ```python
82
+ from asyncio import run
83
+ from sqrrl import Database
84
+ from models import Client, NoteColumns
85
+
86
+
87
+ async def main() -> None:
88
+ async with await Database.open("app.db") as database:
89
+ client = Client(database)
90
+ note = await client.notes.create(title="Try sqrrl")
91
+ print(note.id, note.title, note.done)
92
+
93
+ pending = await client.notes.query().where(NoteColumns.done.eq(False)).all()
94
+ print([item.title for item in pending])
95
+
96
+ await client.notes.update(note.id, done=True)
97
+
98
+
99
+ run(main())
100
+ ```
101
+
102
+ Run it with `python main.py`. `Database.open()` requires an existing file;
103
+ `Database.create()` creates one if it's missing. Neither applies migrations
104
+ automatically. `sqrrl migrate up` creates the database when needed and applies
105
+ pending migrations.
106
+
107
+ You can use `python -m sqrrl` anywhere you'd use `sqrrl`.
108
+
109
+ ## Defining a schema
110
+
111
+ A schema is a regular Python object. Here's the notes table from the starter:
112
+
113
+ ```python
114
+ from sqrrl.schema import Schema, Table, boolean, integer, text
115
+
116
+ schema = Schema(
117
+ tables=(
118
+ Table(
119
+ "notes",
120
+ model="Note",
121
+ fields=(
122
+ integer("id").primary_key(),
123
+ text("title"),
124
+ boolean("done").default("0"),
125
+ ),
126
+ ),
127
+ )
128
+ )
129
+ ```
130
+
131
+ The field helpers are `integer`, `text`, `boolean`, `real`, and `blob`. Fields
132
+ are required unless you add `.nullable()`. You can also declare unique fields,
133
+ foreign keys, immutable fields, indexes, checks, and composite primary keys.
134
+ Tables use SQLite's `STRICT` mode.
135
+
136
+ Defaults are **SQL expressions**, so `.default('0')` stores zero and
137
+ `.default("'draft'")` stores the text `draft`. `.immutable()` leaves a field out
138
+ of the generated update method; it doesn't prevent changes through raw SQL.
139
+
140
+ The [example schema](https://github.com/depthbomb/sqrrl/blob/master/examples/schema.py)
141
+ includes users, tasks, foreign keys, an index, and a composite key. Its
142
+ [generated models](https://github.com/depthbomb/sqrrl/blob/master/examples/models.py)
143
+ show what sqrrl produces.
144
+
145
+ Configuration paths are relative to the configuration file. `schema` names an
146
+ importable module and its exported object, such as `myapp.schema:schema`.
147
+ Use `--config path/to/sqrrl.json` on a command to select another configuration.
148
+ The output file and migrations directory must stay inside that configuration's
149
+ directory. Schema modules are imported during generation and schema checks, so
150
+ keep them free of side effects and stdout output.
151
+
152
+ ## Reading and writing
153
+
154
+ Each generated repository has `create`, `get`, `update`, `delete`, and `query`.
155
+ Using the starter's `client`:
156
+
157
+ ```python
158
+ note = await client.notes.create(title="Ship something small")
159
+ note = await client.notes.get(note.id)
160
+ note = await client.notes.update(note.id, title="Ship sqrrl")
161
+ await client.notes.delete(note.id)
162
+ ```
163
+
164
+ Rows are frozen dataclasses. Writes return new rows instead of changing the
165
+ objects you already have. A single integer primary key can be omitted on
166
+ creation. For composite keys, the generator provides a key dataclass, such as
167
+ `SettingKey(user_id=1, key='theme')` in the example.
168
+
169
+ Omitting an optional write argument uses `UNSET`: on creation, SQLite gets to
170
+ apply its default; on update, that field stays unchanged. Passing `None`
171
+ explicitly writes SQL `NULL` and requires a nullable field.
172
+
173
+ Build queries with the generated column helpers:
174
+
175
+ ```python
176
+ query = client.notes.query().where(NoteColumns.done.eq(False))
177
+ latest = await query.order_by(NoteColumns.id.desc()).limit(10).all()
178
+ first = await query.order_by(NoteColumns.id.asc()).first()
179
+ count = await query.count()
180
+ has_notes = await query.exists()
181
+ ```
182
+
183
+ Query builders return new queries, so you can reuse a base query. Values are
184
+ bound as SQL parameters. Column helpers support `eq`, `ne`, `gt`, `lt`, `in_`,
185
+ `is_null`, and `is_not_null`. Combine predicates with `&`, `|`, and `~`:
186
+
187
+ ```python
188
+ matching = await client.notes.query().where(NoteColumns.done.eq(False) & NoteColumns.id.gt(10)).all()
189
+ ```
190
+
191
+ `first()` returns `None` when nothing matches. `only()` requires exactly one row:
192
+ it raises `NotFoundError` for no matches and `NotSingularError` for multiple
193
+ matches. `get()` and `update()` also raise `NotFoundError` for a missing key;
194
+ deleting a missing row is fine. These errors are available from `sqrrl`.
195
+
196
+ ## Transactions and connections
197
+
198
+ Group writes with a transaction:
199
+
200
+ ```python
201
+ async with client.transaction() as transaction:
202
+ first = await transaction.notes.create(title="Write the README")
203
+ await transaction.notes.create(title="Publish the package")
204
+ await transaction.notes.update(first.id, done=True)
205
+ ```
206
+
207
+ The transaction commits on success and rolls back on errors. Nested
208
+ transactions use savepoints. Cancellation waits for queued SQLite work to
209
+ settle; a commit that has already started can finish before cancellation arrives.
210
+
211
+ Each `Database` owns one connection and serializes repository access between
212
+ tasks. Keep a transaction's work in the task that opened it. Awaiting another
213
+ task that needs the same connection while holding a transaction can deadlock.
214
+ Use separate connections for concurrent work inside that scope.
215
+
216
+ Foreign keys are enabled. `Database.open()` and `Database.create()` also accept
217
+ `wal=True`, `timeout=5.0` (seconds), and `immediate=True` for `BEGIN IMMEDIATE`
218
+ transactions. WAL is opt-in.
219
+
220
+ For SQL beyond the query builder, `database.connection` exposes the underlying
221
+ `aiosqlite` connection. Use `database.transaction()` to coordinate raw SQL with
222
+ repository operations, close your cursors, and keep the connection in its owning
223
+ task during a transaction.
224
+
225
+ ## Changing the database
226
+
227
+ After editing `schema.py`, regenerate the models and create a migration:
228
+
229
+ ```sh
230
+ sqrrl generate
231
+ sqrrl migrate diff add_description
232
+ sqrrl migrate check
233
+ sqrrl migrate status --db app.db
234
+ sqrrl migrate up --db app.db
235
+ ```
236
+
237
+ Each migration has a readable `.sql` file and a matching `.json` file containing
238
+ schema metadata and checksums. Commit both, along with your generated models.
239
+ Review the SQL before applying it. Don't edit migration history in place:
240
+ sqrrl checks that the SQL, metadata, and checksum chain agree.
241
+
242
+ `migrate check` replays history in a scratch database and compares the result
243
+ with your declared schema. Applying migrations checks the live schema for drift
244
+ and applies the pending batch in one transaction, including foreign key checks.
245
+
246
+ Some changes need a little planning:
247
+
248
+ - Dropping tables or fields requires `migrate diff NAME --allow-drop`.
249
+ - For a field rename, keep its old identity with
250
+ `text('new_name').identity('old_name')`. For a table rename, preserve its `key`.
251
+ - A new required field needs a default or a staged backfill. Automatic primary
252
+ key changes and field representation changes are unsupported.
253
+ - `sqrrl migrate custom backfill --sql backfill.sql` records data-only SQL.
254
+ Custom schema objects, such as views and triggers, aren't supported.
255
+ - `sqrrl migrate baseline --db existing.db --version 1` adopts a database only
256
+ when its schema matches that migration exactly. Equivalent DDL written
257
+ differently can still be rejected.
258
+
259
+ There is no downgrade command. Make further changes with new migrations.
260
+
261
+ For an app's CI, these commands catch stale models and missing migrations:
262
+
263
+ ```sh
264
+ sqrrl generate --check
265
+ sqrrl migrate check
266
+ ```
267
+
268
+ ## Development
269
+
270
+ From a checkout, create a Python 3.14+ virtual environment with
271
+ `python -m venv .venv`, then activate it (`.venv\Scripts\Activate.ps1` in
272
+ PowerShell, or `source .venv/bin/activate` in bash).
273
+
274
+ ```sh
275
+ python -m pip install -e ".[dev]"
276
+ python -m pytest --cov=sqrrl --cov-branch
277
+ python -m ruff check .
278
+ python -m mypy
279
+ python -m pyright
280
+ python -m sqrrl generate --config examples/sqrrl.json --check
281
+ python -m sqrrl migrate check --config examples/sqrrl.json
282
+ python -m examples.main
283
+ python -m build --outdir dist/release
284
+ python -m twine check --strict dist/release/*
285
+ ```
286
+
287
+ The example uses a temporary database and cleans up after itself. The
288
+ `benchmarks/` directory has separate runners for database operations and import
289
+ overhead.
sqrrl-0.1.0/README.md ADDED
@@ -0,0 +1,256 @@
1
+ # sqrrl (pronounced squirrel)
2
+
3
+ Typed async SQLite access, generated from a Python schema, with easy migration management.
4
+
5
+ Describe your tables once, and sqrrl generates dataclasses, repositories, and
6
+ column helpers with real type annotations. Your editor knows which fields a
7
+ query returns and which arguments a write accepts. Database calls run through
8
+ `aiosqlite`.
9
+
10
+ Requires **Python 3.14+** and **SQLite 3.37+**. This is an early release, and the
11
+ API is still taking shape.
12
+
13
+ ## Getting started
14
+
15
+ With your virtual environment active:
16
+
17
+ ```sh
18
+ python -m pip install sqrrl
19
+ ```
20
+
21
+ In your app's directory, create a starter schema and configuration:
22
+
23
+ ```sh
24
+ sqrrl init
25
+ ```
26
+
27
+ That gives you `schema.py` with a small notes table and a `sqrrl.json` file:
28
+
29
+ ```json
30
+ {
31
+ "schema": "schema:schema",
32
+ "output": "models.py",
33
+ "migrations": "migrations"
34
+ }
35
+ ```
36
+
37
+ Generate the Python code and your first migration, then create the database:
38
+
39
+ ```sh
40
+ sqrrl generate
41
+ sqrrl migrate diff initial
42
+ # Review migrations/000001_initial.sql before applying it.
43
+ sqrrl migrate up --db app.db
44
+ ```
45
+
46
+ Save this as `main.py` next to the generated `models.py`:
47
+
48
+ ```python
49
+ from asyncio import run
50
+ from sqrrl import Database
51
+ from models import Client, NoteColumns
52
+
53
+
54
+ async def main() -> None:
55
+ async with await Database.open("app.db") as database:
56
+ client = Client(database)
57
+ note = await client.notes.create(title="Try sqrrl")
58
+ print(note.id, note.title, note.done)
59
+
60
+ pending = await client.notes.query().where(NoteColumns.done.eq(False)).all()
61
+ print([item.title for item in pending])
62
+
63
+ await client.notes.update(note.id, done=True)
64
+
65
+
66
+ run(main())
67
+ ```
68
+
69
+ Run it with `python main.py`. `Database.open()` requires an existing file;
70
+ `Database.create()` creates one if it's missing. Neither applies migrations
71
+ automatically. `sqrrl migrate up` creates the database when needed and applies
72
+ pending migrations.
73
+
74
+ You can use `python -m sqrrl` anywhere you'd use `sqrrl`.
75
+
76
+ ## Defining a schema
77
+
78
+ A schema is a regular Python object. Here's the notes table from the starter:
79
+
80
+ ```python
81
+ from sqrrl.schema import Schema, Table, boolean, integer, text
82
+
83
+ schema = Schema(
84
+ tables=(
85
+ Table(
86
+ "notes",
87
+ model="Note",
88
+ fields=(
89
+ integer("id").primary_key(),
90
+ text("title"),
91
+ boolean("done").default("0"),
92
+ ),
93
+ ),
94
+ )
95
+ )
96
+ ```
97
+
98
+ The field helpers are `integer`, `text`, `boolean`, `real`, and `blob`. Fields
99
+ are required unless you add `.nullable()`. You can also declare unique fields,
100
+ foreign keys, immutable fields, indexes, checks, and composite primary keys.
101
+ Tables use SQLite's `STRICT` mode.
102
+
103
+ Defaults are **SQL expressions**, so `.default('0')` stores zero and
104
+ `.default("'draft'")` stores the text `draft`. `.immutable()` leaves a field out
105
+ of the generated update method; it doesn't prevent changes through raw SQL.
106
+
107
+ The [example schema](https://github.com/depthbomb/sqrrl/blob/master/examples/schema.py)
108
+ includes users, tasks, foreign keys, an index, and a composite key. Its
109
+ [generated models](https://github.com/depthbomb/sqrrl/blob/master/examples/models.py)
110
+ show what sqrrl produces.
111
+
112
+ Configuration paths are relative to the configuration file. `schema` names an
113
+ importable module and its exported object, such as `myapp.schema:schema`.
114
+ Use `--config path/to/sqrrl.json` on a command to select another configuration.
115
+ The output file and migrations directory must stay inside that configuration's
116
+ directory. Schema modules are imported during generation and schema checks, so
117
+ keep them free of side effects and stdout output.
118
+
119
+ ## Reading and writing
120
+
121
+ Each generated repository has `create`, `get`, `update`, `delete`, and `query`.
122
+ Using the starter's `client`:
123
+
124
+ ```python
125
+ note = await client.notes.create(title="Ship something small")
126
+ note = await client.notes.get(note.id)
127
+ note = await client.notes.update(note.id, title="Ship sqrrl")
128
+ await client.notes.delete(note.id)
129
+ ```
130
+
131
+ Rows are frozen dataclasses. Writes return new rows instead of changing the
132
+ objects you already have. A single integer primary key can be omitted on
133
+ creation. For composite keys, the generator provides a key dataclass, such as
134
+ `SettingKey(user_id=1, key='theme')` in the example.
135
+
136
+ Omitting an optional write argument uses `UNSET`: on creation, SQLite gets to
137
+ apply its default; on update, that field stays unchanged. Passing `None`
138
+ explicitly writes SQL `NULL` and requires a nullable field.
139
+
140
+ Build queries with the generated column helpers:
141
+
142
+ ```python
143
+ query = client.notes.query().where(NoteColumns.done.eq(False))
144
+ latest = await query.order_by(NoteColumns.id.desc()).limit(10).all()
145
+ first = await query.order_by(NoteColumns.id.asc()).first()
146
+ count = await query.count()
147
+ has_notes = await query.exists()
148
+ ```
149
+
150
+ Query builders return new queries, so you can reuse a base query. Values are
151
+ bound as SQL parameters. Column helpers support `eq`, `ne`, `gt`, `lt`, `in_`,
152
+ `is_null`, and `is_not_null`. Combine predicates with `&`, `|`, and `~`:
153
+
154
+ ```python
155
+ matching = await client.notes.query().where(NoteColumns.done.eq(False) & NoteColumns.id.gt(10)).all()
156
+ ```
157
+
158
+ `first()` returns `None` when nothing matches. `only()` requires exactly one row:
159
+ it raises `NotFoundError` for no matches and `NotSingularError` for multiple
160
+ matches. `get()` and `update()` also raise `NotFoundError` for a missing key;
161
+ deleting a missing row is fine. These errors are available from `sqrrl`.
162
+
163
+ ## Transactions and connections
164
+
165
+ Group writes with a transaction:
166
+
167
+ ```python
168
+ async with client.transaction() as transaction:
169
+ first = await transaction.notes.create(title="Write the README")
170
+ await transaction.notes.create(title="Publish the package")
171
+ await transaction.notes.update(first.id, done=True)
172
+ ```
173
+
174
+ The transaction commits on success and rolls back on errors. Nested
175
+ transactions use savepoints. Cancellation waits for queued SQLite work to
176
+ settle; a commit that has already started can finish before cancellation arrives.
177
+
178
+ Each `Database` owns one connection and serializes repository access between
179
+ tasks. Keep a transaction's work in the task that opened it. Awaiting another
180
+ task that needs the same connection while holding a transaction can deadlock.
181
+ Use separate connections for concurrent work inside that scope.
182
+
183
+ Foreign keys are enabled. `Database.open()` and `Database.create()` also accept
184
+ `wal=True`, `timeout=5.0` (seconds), and `immediate=True` for `BEGIN IMMEDIATE`
185
+ transactions. WAL is opt-in.
186
+
187
+ For SQL beyond the query builder, `database.connection` exposes the underlying
188
+ `aiosqlite` connection. Use `database.transaction()` to coordinate raw SQL with
189
+ repository operations, close your cursors, and keep the connection in its owning
190
+ task during a transaction.
191
+
192
+ ## Changing the database
193
+
194
+ After editing `schema.py`, regenerate the models and create a migration:
195
+
196
+ ```sh
197
+ sqrrl generate
198
+ sqrrl migrate diff add_description
199
+ sqrrl migrate check
200
+ sqrrl migrate status --db app.db
201
+ sqrrl migrate up --db app.db
202
+ ```
203
+
204
+ Each migration has a readable `.sql` file and a matching `.json` file containing
205
+ schema metadata and checksums. Commit both, along with your generated models.
206
+ Review the SQL before applying it. Don't edit migration history in place:
207
+ sqrrl checks that the SQL, metadata, and checksum chain agree.
208
+
209
+ `migrate check` replays history in a scratch database and compares the result
210
+ with your declared schema. Applying migrations checks the live schema for drift
211
+ and applies the pending batch in one transaction, including foreign key checks.
212
+
213
+ Some changes need a little planning:
214
+
215
+ - Dropping tables or fields requires `migrate diff NAME --allow-drop`.
216
+ - For a field rename, keep its old identity with
217
+ `text('new_name').identity('old_name')`. For a table rename, preserve its `key`.
218
+ - A new required field needs a default or a staged backfill. Automatic primary
219
+ key changes and field representation changes are unsupported.
220
+ - `sqrrl migrate custom backfill --sql backfill.sql` records data-only SQL.
221
+ Custom schema objects, such as views and triggers, aren't supported.
222
+ - `sqrrl migrate baseline --db existing.db --version 1` adopts a database only
223
+ when its schema matches that migration exactly. Equivalent DDL written
224
+ differently can still be rejected.
225
+
226
+ There is no downgrade command. Make further changes with new migrations.
227
+
228
+ For an app's CI, these commands catch stale models and missing migrations:
229
+
230
+ ```sh
231
+ sqrrl generate --check
232
+ sqrrl migrate check
233
+ ```
234
+
235
+ ## Development
236
+
237
+ From a checkout, create a Python 3.14+ virtual environment with
238
+ `python -m venv .venv`, then activate it (`.venv\Scripts\Activate.ps1` in
239
+ PowerShell, or `source .venv/bin/activate` in bash).
240
+
241
+ ```sh
242
+ python -m pip install -e ".[dev]"
243
+ python -m pytest --cov=sqrrl --cov-branch
244
+ python -m ruff check .
245
+ python -m mypy
246
+ python -m pyright
247
+ python -m sqrrl generate --config examples/sqrrl.json --check
248
+ python -m sqrrl migrate check --config examples/sqrrl.json
249
+ python -m examples.main
250
+ python -m build --outdir dist/release
251
+ python -m twine check --strict dist/release/*
252
+ ```
253
+
254
+ The example uses a temporary database and cleans up after itself. The
255
+ `benchmarks/` directory has separate runners for database operations and import
256
+ overhead.