sustained 2.0.0__tar.gz → 2.2.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.
- {sustained-2.0.0 → sustained-2.2.0}/.pre-commit-config.yaml +5 -2
- {sustained-2.0.0 → sustained-2.2.0}/CHANGELOG.md +32 -0
- {sustained-2.0.0 → sustained-2.2.0}/DEVELOPERS.md +21 -4
- {sustained-2.0.0 → sustained-2.2.0}/PKG-INFO +2 -2
- {sustained-2.0.0 → sustained-2.2.0}/README.md +1 -1
- sustained-2.2.0/docs/executing.md +183 -0
- {sustained-2.0.0 → sustained-2.2.0}/docs/filtering.md +26 -0
- {sustained-2.0.0 → sustained-2.2.0}/docs/index.md +1 -0
- {sustained-2.0.0 → sustained-2.2.0}/docs/queries.md +34 -0
- sustained-2.2.0/docs/schema.md +64 -0
- {sustained-2.0.0 → sustained-2.2.0}/pyproject.toml +1 -1
- {sustained-2.0.0 → sustained-2.2.0}/src/sustained/__init__.py +6 -0
- sustained-2.2.0/src/sustained/aio.py +356 -0
- {sustained-2.0.0 → sustained-2.2.0}/src/sustained/builder.py +489 -49
- {sustained-2.0.0 → sustained-2.2.0}/src/sustained/builder.pyi +60 -7
- {sustained-2.0.0 → sustained-2.2.0}/src/sustained/builders/conditional_clause_builder.py +41 -0
- sustained-2.2.0/src/sustained/builders/group_by_builder.py +65 -0
- sustained-2.2.0/src/sustained/builders/group_by_builder.pyi +12 -0
- {sustained-2.0.0 → sustained-2.2.0}/src/sustained/builders/select_clause_builder.py +3 -0
- {sustained-2.0.0 → sustained-2.2.0}/src/sustained/compilers/base.py +115 -2
- sustained-2.2.0/src/sustained/compilers/duckdb.py +31 -0
- sustained-2.2.0/src/sustained/compilers/mssql.py +107 -0
- {sustained-2.0.0 → sustained-2.2.0}/src/sustained/compilers/postgres.py +16 -0
- {sustained-2.0.0 → sustained-2.2.0}/src/sustained/compilers/presto.py +18 -0
- {sustained-2.0.0 → sustained-2.2.0}/src/sustained/dialects.py +2 -0
- sustained-2.2.0/src/sustained/execution.py +366 -0
- {sustained-2.0.0 → sustained-2.2.0}/src/sustained/expressions.py +166 -1
- {sustained-2.0.0 → sustained-2.2.0}/src/sustained/functions.py +71 -4
- sustained-2.2.0/src/sustained/migrations.py +181 -0
- {sustained-2.0.0 → sustained-2.2.0}/src/sustained/model.py +144 -1
- sustained-2.2.0/src/sustained/pool.py +112 -0
- {sustained-2.0.0 → sustained-2.2.0}/src/sustained/rendering.py +19 -0
- sustained-2.2.0/src/sustained/schema.py +190 -0
- {sustained-2.0.0 → sustained-2.2.0}/src/sustained/types.py +2 -0
- sustained-2.2.0/tests/test_analyst_sql.py +182 -0
- sustained-2.2.0/tests/test_async.py +155 -0
- {sustained-2.0.0 → sustained-2.2.0}/tests/test_dialect_functions.py +12 -14
- sustained-2.2.0/tests/test_duckdb_dialect.py +59 -0
- sustained-2.2.0/tests/test_etl_statements.py +122 -0
- {sustained-2.0.0 → sustained-2.2.0}/tests/test_execution.py +90 -0
- sustained-2.2.0/tests/test_migrations.py +132 -0
- sustained-2.2.0/tests/test_pool.py +148 -0
- sustained-2.2.0/tests/test_predicates.py +148 -0
- sustained-2.2.0/tests/test_raw_bindings.py +55 -0
- sustained-2.2.0/tests/test_result_formats.py +92 -0
- sustained-2.2.0/tests/test_schema_ddl.py +168 -0
- sustained-2.2.0/tests/test_transactions.py +126 -0
- sustained-2.2.0/tests/test_upsert.py +139 -0
- sustained-2.0.0/docs/executing.md +0 -96
- sustained-2.0.0/src/sustained/builders/group_by_builder.py +0 -40
- sustained-2.0.0/src/sustained/builders/group_by_builder.pyi +0 -7
- sustained-2.0.0/src/sustained/compilers/mssql.py +0 -44
- sustained-2.0.0/src/sustained/execution.py +0 -118
- {sustained-2.0.0 → sustained-2.2.0}/.gitignore +0 -0
- {sustained-2.0.0 → sustained-2.2.0}/LICENSE +0 -0
- {sustained-2.0.0 → sustained-2.2.0}/deploy.py +0 -0
- {sustained-2.0.0 → sustained-2.2.0}/docs/CNAME +0 -0
- {sustained-2.0.0 → sustained-2.2.0}/docs/_config.yml +0 -0
- {sustained-2.0.0 → sustained-2.2.0}/docs/_layouts/default.html +0 -0
- {sustained-2.0.0 → sustained-2.2.0}/docs/assets/css/style.scss +0 -0
- {sustained-2.0.0 → sustained-2.2.0}/docs/grouping.md +0 -0
- {sustained-2.0.0 → sustained-2.2.0}/docs/models.md +0 -0
- {sustained-2.0.0 → sustained-2.2.0}/docs/relations.md +0 -0
- {sustained-2.0.0 → sustained-2.2.0}/src/sustained/builders/__init__.py +0 -0
- {sustained-2.0.0 → sustained-2.2.0}/src/sustained/builders/__init__.pyi +0 -0
- {sustained-2.0.0 → sustained-2.2.0}/src/sustained/builders/conditional_clause_builder.pyi +0 -0
- {sustained-2.0.0 → sustained-2.2.0}/src/sustained/builders/having_builder.py +0 -0
- {sustained-2.0.0 → sustained-2.2.0}/src/sustained/builders/having_builder.pyi +0 -0
- {sustained-2.0.0 → sustained-2.2.0}/src/sustained/builders/join_builder.py +0 -0
- {sustained-2.0.0 → sustained-2.2.0}/src/sustained/builders/join_builder.pyi +0 -0
- {sustained-2.0.0 → sustained-2.2.0}/src/sustained/builders/order_by_builder.py +0 -0
- {sustained-2.0.0 → sustained-2.2.0}/src/sustained/builders/order_by_builder.pyi +0 -0
- {sustained-2.0.0 → sustained-2.2.0}/src/sustained/builders/select_clause_builder.pyi +0 -0
- {sustained-2.0.0 → sustained-2.2.0}/src/sustained/builders/where_builder.py +0 -0
- {sustained-2.0.0 → sustained-2.2.0}/src/sustained/builders/where_builder.pyi +0 -0
- {sustained-2.0.0 → sustained-2.2.0}/src/sustained/compilers/__init__.py +0 -0
- {sustained-2.0.0 → sustained-2.2.0}/src/sustained/exceptions.py +0 -0
- {sustained-2.0.0 → sustained-2.2.0}/src/sustained/py.typed +0 -0
- {sustained-2.0.0 → sustained-2.2.0}/tests/__init__.py +0 -0
- {sustained-2.0.0 → sustained-2.2.0}/tests/test_builder_ergonomics.py +0 -0
- {sustained-2.0.0 → sustained-2.2.0}/tests/test_builder_robustness.py +0 -0
- {sustained-2.0.0 → sustained-2.2.0}/tests/test_dialect.py +0 -0
- {sustained-2.0.0 → sustained-2.2.0}/tests/test_dialect_behaviors.py +0 -0
- {sustained-2.0.0 → sustained-2.2.0}/tests/test_dml.py +0 -0
- {sustained-2.0.0 → sustained-2.2.0}/tests/test_expressions.py +0 -0
- {sustained-2.0.0 → sustained-2.2.0}/tests/test_functions.py +0 -0
- {sustained-2.0.0 → sustained-2.2.0}/tests/test_having_builder.py +0 -0
- {sustained-2.0.0 → sustained-2.2.0}/tests/test_join_builder.py +0 -0
- {sustained-2.0.0 → sustained-2.2.0}/tests/test_lambda_join_builder.py +0 -0
- {sustained-2.0.0 → sustained-2.2.0}/tests/test_model.py +0 -0
- {sustained-2.0.0 → sustained-2.2.0}/tests/test_model_features.py +0 -0
- {sustained-2.0.0 → sustained-2.2.0}/tests/test_mssql_compiler.py +0 -0
- {sustained-2.0.0 → sustained-2.2.0}/tests/test_order_by_builder.py +0 -0
- {sustained-2.0.0 → sustained-2.2.0}/tests/test_parameterization.py +0 -0
- {sustained-2.0.0 → sustained-2.2.0}/tests/test_postgres_compiler.py +0 -0
- {sustained-2.0.0 → sustained-2.2.0}/tests/test_query_builder.py +0 -0
- {sustained-2.0.0 → sustained-2.2.0}/tests/test_select_clause_builder.py +0 -0
- {sustained-2.0.0 → sustained-2.2.0}/tests/test_where_builder.py +0 -0
|
@@ -31,8 +31,11 @@ repos:
|
|
|
31
31
|
- repo: local
|
|
32
32
|
hooks:
|
|
33
33
|
- id: unit-tests
|
|
34
|
-
name: Run unit tests
|
|
35
|
-
entry:
|
|
34
|
+
name: Run unit tests with coverage
|
|
35
|
+
entry: >
|
|
36
|
+
bash -c "PYTHONPATH=src python3 -m coverage run --branch
|
|
37
|
+
--source=src/sustained -m unittest discover -s tests
|
|
38
|
+
&& python3 -m coverage report --fail-under=90 > /dev/null"
|
|
36
39
|
language: system
|
|
37
40
|
types: [python]
|
|
38
41
|
pass_filenames: false
|
|
@@ -1,5 +1,37 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.2.0 (2026-08-14)
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- Typed column definitions: models declare `tableColumns` with `Integer`, `BigInteger`, `String`, `Text`, `Boolean`, `Float`, `Numeric`, `Date`, `Timestamp`, and `Json`, including composite primary keys, defaults, unique constraints, foreign key references, and autoincrement. Strict column access derives automatically.
|
|
8
|
+
- Model-driven DDL: `create_table_sql()`, `create_table()`, `drop_table()` with per-dialect type mapping and identity syntax. DuckDB and Presto raise for autoincrement.
|
|
9
|
+
- Migration runner: ordered `Migration` objects with up/down steps (SQL, statement lists, or callables), a self-creating tracking table, transactional application, stop-after targets, and newest-first reverts. `create_table_migration()` derives create/drop pairs from models. No catalog diffing.
|
|
10
|
+
- `ConnectionPool`: thread-safe, lazy, bounded pooling for DB-API connections. `Model.bind()` and all execution entry points accept a pool; transactions pin one checked-out connection to the thread; nested blocks reuse it via savepoints.
|
|
11
|
+
- Async execution: `arun()`, `afirst()`, `ato_dicts()` through an adapter interface with `DbApiAsyncAdapter` (any sync driver via worker threads), `AiosqliteAdapter`, and `AsyncpgAdapter` (`%s` to `$n` conversion). `Model.bind_async()` and `async_transaction()` with ContextVar pinning. Async through-relation eager loading and nested async transactions are not supported yet.
|
|
12
|
+
|
|
13
|
+
## 2.1.0 (2026-08-14)
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- Typed predicates: `Model.c.age > 21` and `col()` build composable `Predicate` objects combinable with `&`, `|`, `~`; accepted by `where()` and `having()`.
|
|
18
|
+
- `whereRaw()` / `havingRaw()`: raw predicates with `?` value markers that parameterize like every other clause.
|
|
19
|
+
- `Model.transaction()` context manager with savepoint nesting; `run()` defers commits inside a transaction.
|
|
20
|
+
- `set_statement_listener()` observer with SQL, parameters, and duration for every executed statement.
|
|
21
|
+
- Upserts: `insert().onConflict(cols).merge()` / `.ignore()`. ON CONFLICT on Postgres/SQLite/DuckDB, MERGE on MSSQL, DialectError on Presto.
|
|
22
|
+
- `insert_from()` (INSERT ... SELECT) and `create_table_as()` (CTAS; MSSQL raises).
|
|
23
|
+
- Multi-row inserts execute through the driver's `executemany()` when there is no RETURNING clause.
|
|
24
|
+
- Result formats: `to_dicts()`, `to_df()` (pandas optional), `to_arrow()` (pyarrow optional).
|
|
25
|
+
- DuckDB dialect: quoting, native ILIKE, qmark placeholders, upserts, RETURNING, CTAS, QUALIFY.
|
|
26
|
+
- Recursive CTEs via `with_(..., recursive=True)`; MSSQL renders plain WITH.
|
|
27
|
+
- Set operations: `intersect()` and `except_()`.
|
|
28
|
+
- Analyst clauses: `distinctOn()`, `groupByRollup()`, `groupByCube()`, `groupByGroupingSets()`, `qualify()`.
|
|
29
|
+
- `for_update(skip_locked, nowait)` row locking on Postgres.
|
|
30
|
+
- `total()` count helper and `cursor_page()` keyset pagination.
|
|
31
|
+
- `explain(analyze=False)` plan inspection.
|
|
32
|
+
- Through-relation (`ManyToManyRelation`) eager loading in `withGraphFetched()`.
|
|
33
|
+
- Per-dialect function name translation: `NOW()` renders as `GETDATE()` on MSSQL and the reverse; `LENGTH()` renders as `LEN()` on MSSQL.
|
|
34
|
+
|
|
3
35
|
## 2.0.0 (2026-08-14)
|
|
4
36
|
|
|
5
37
|
### Breaking changes
|
|
@@ -21,7 +21,8 @@ The `QueryBuilder` (`sustained/builder.py`) is the central component of the libr
|
|
|
21
21
|
|
|
22
22
|
- **State Management:** It does not manage the complex state of the query directly. Instead, it holds instances of several specialized `*ClauseBuilder` objects.
|
|
23
23
|
- **Composition:** When a method like `.where()` or `.select()` is called on the `QueryBuilder`, it delegates that call to the appropriate internal builder (e.g., `self._where_builder` or `self._select_clause_builder`).
|
|
24
|
-
- **Assembly:**
|
|
24
|
+
- **Assembly:** Rendering happens through a `RenderContext` (`sustained/rendering.py`) that carries the compiler and the value-handling mode. `str(query)` renders with values inlined as SQL literals. `to_sql()` renders with dialect placeholders and returns the collected parameters. Clauses that hold user values store deferred render functions instead of finished strings, so both modes share one code path.
|
|
25
|
+
- **Execution:** `run()` and `first()` (`sustained/execution.py`) execute the parameterized statement on a DB-API 2.0 connection and hydrate result rows into model instances.
|
|
25
26
|
|
|
26
27
|
### The `*ClauseBuilder`s
|
|
27
28
|
|
|
@@ -47,9 +48,25 @@ Understanding the lifecycle of a query is key to understanding the architecture.
|
|
|
47
48
|
1. **Instantiation:** A user calls `MyModel.query()`. The `Model` creates a `QueryBuilder` instance, passing it the currently configured `Dialect`.
|
|
48
49
|
2. **Construction:** The user chains methods like `.select()`, `.where()`, and `.orderBy()`. Each of these calls is delegated to the corresponding internal `*ClauseBuilder`, which updates its internal state.
|
|
49
50
|
3. **Compilation:** The user calls `str(query_builder)` to get the final SQL string.
|
|
50
|
-
4. **Assembly:**
|
|
51
|
-
5. **Dialect-Specific Rendering:** For parts of the query that are dialect-dependent (like `LIMIT`/`OFFSET
|
|
52
|
-
6. **Final String:** The `QueryBuilder` joins all the rendered fragments together into the final, complete SQL
|
|
51
|
+
4. **Assembly:** `QueryBuilder._render_sql(ctx)` walks the statement in SQL order. It hoists CTEs, renders each internal builder, and threads the `RenderContext` into every clause that holds user values. `__str__()` calls it with an inline-literal context; `to_sql()` calls it with a parameterizing context and returns `(sql, params)`.
|
|
52
|
+
5. **Dialect-Specific Rendering:** For parts of the query that are dialect-dependent (like `LIMIT`/`OFFSET`, identifier quoting, booleans, and ILIKE), the builders call methods on the configured `Compiler` instance.
|
|
53
|
+
6. **Final String:** The `QueryBuilder` joins all the rendered fragments together into the final, complete SQL statement.
|
|
54
|
+
|
|
55
|
+
## Development Setup
|
|
56
|
+
|
|
57
|
+
Tests, linting, formatting, and type checking are enforced locally through pre-commit hooks. Install the tools and the hooks once:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pip install pre-commit coverage
|
|
61
|
+
pre-commit install
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The unit-test hook runs the suite under coverage and fails the commit when total branch coverage drops below 90 percent. Run it manually with:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
PYTHONPATH=src python3 -m coverage run --branch --source=src/sustained -m unittest discover -s tests
|
|
68
|
+
python3 -m coverage report
|
|
69
|
+
```
|
|
53
70
|
|
|
54
71
|
## Extending the ORM
|
|
55
72
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: sustained
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.2.0
|
|
4
4
|
Summary: A Python query builder inspired by Objection.js
|
|
5
5
|
Project-URL: Homepage, https://github.com/wetherc/sustained
|
|
6
6
|
Project-URL: Issues, https://github.com/wetherc/sustained/issues
|
|
@@ -16,7 +16,7 @@ Description-Content-Type: text/markdown
|
|
|
16
16
|
|
|
17
17
|
A Python query builder and lightweight ORM inspired by [Objection.js](https://vincit.github.io/objection.js/).
|
|
18
18
|
|
|
19
|
-
Sustained builds parameterized SQL for the default (ANSI), Postgres, MSSQL, and
|
|
19
|
+
Sustained builds parameterized SQL for the default (ANSI), Postgres, MSSQL, Presto, and DuckDB dialects. It executes queries against any DB-API 2.0 connection or connection pool with transactions, hydrates rows into model instances or DataFrames, writes data with `insert()`, `update()`, `delete()`, upserts, and `INSERT ... SELECT`, and eager loads relations. Filters compose as typed predicates: `User.query().where((User.c.age > 21) & User.c.name.like('A%'))`. Models can declare typed columns and generate their DDL, an explicit migration runner manages schema changes, and async services run the same queries through driver adapters with `await query.arun()`.
|
|
20
20
|
|
|
21
21
|
## Installation
|
|
22
22
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
A Python query builder and lightweight ORM inspired by [Objection.js](https://vincit.github.io/objection.js/).
|
|
4
4
|
|
|
5
|
-
Sustained builds parameterized SQL for the default (ANSI), Postgres, MSSQL, and
|
|
5
|
+
Sustained builds parameterized SQL for the default (ANSI), Postgres, MSSQL, Presto, and DuckDB dialects. It executes queries against any DB-API 2.0 connection or connection pool with transactions, hydrates rows into model instances or DataFrames, writes data with `insert()`, `update()`, `delete()`, upserts, and `INSERT ... SELECT`, and eager loads relations. Filters compose as typed predicates: `User.query().where((User.c.age > 21) & User.c.name.like('A%'))`. Models can declare typed columns and generate their DDL, an explicit migration runner manages schema changes, and async services run the same queries through driver adapters with `await query.arun()`.
|
|
6
6
|
|
|
7
7
|
## Installation
|
|
8
8
|
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
---
|
|
2
|
+
layout: default
|
|
3
|
+
title: Executing Queries
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Sustained can execute the queries it builds. It works with any DB-API 2.0 connection, such as `sqlite3`, `psycopg`, or `pyodbc`. Every statement runs parameterized: user values travel as parameters, never as text inside the SQL.
|
|
7
|
+
|
|
8
|
+
The connection's parameter style must match the dialect. The default and MSSQL dialects use `?` (qmark). The Postgres dialect uses `%s` (format).
|
|
9
|
+
|
|
10
|
+
## Binding a Connection
|
|
11
|
+
|
|
12
|
+
Bind a connection once with `Model.bind()`. Every query on that model can then run without passing the connection each time.
|
|
13
|
+
|
|
14
|
+
```python
|
|
15
|
+
import sqlite3
|
|
16
|
+
from sustained import Model
|
|
17
|
+
|
|
18
|
+
class User(Model):
|
|
19
|
+
tableName = 'users'
|
|
20
|
+
|
|
21
|
+
conn = sqlite3.connect('app.db')
|
|
22
|
+
User.bind(conn)
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Bind on `Model` itself to share one connection across all models. Bind on a subclass to scope it. Call `Model.unbind()` to remove a binding. You can also pass a connection directly to `run()` or `first()`, which overrides any binding.
|
|
26
|
+
|
|
27
|
+
## Running SELECT Queries
|
|
28
|
+
|
|
29
|
+
`run()` executes the query and hydrates each row into a model instance. Column names come from the cursor description.
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
users = User.query().where('active', '=', True).orderBy('name').run()
|
|
33
|
+
|
|
34
|
+
for user in users:
|
|
35
|
+
print(user.name)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`first()` runs the query with `LIMIT 1` and returns one instance, or `None` when there is no match. The original query is not changed.
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
user = User.query().where('email', '=', 'ada@example.com').first()
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Writing Data
|
|
45
|
+
|
|
46
|
+
`insert()`, `update()`, and `delete()` turn the builder into a write statement. They use the same `where()` methods as SELECT and the same parameterized rendering.
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
# INSERT INTO users (name, email) VALUES (?, ?)
|
|
50
|
+
User.query().insert({'name': 'Ada', 'email': 'ada@example.com'}).run()
|
|
51
|
+
|
|
52
|
+
# Multi-row insert. All rows must have the same columns.
|
|
53
|
+
User.query().insert([
|
|
54
|
+
{'name': 'Ada'},
|
|
55
|
+
{'name': 'Grace'},
|
|
56
|
+
]).run()
|
|
57
|
+
|
|
58
|
+
# UPDATE users SET active = ? WHERE id = ?
|
|
59
|
+
User.query().update({'active': False}).where('id', '=', 1).run()
|
|
60
|
+
|
|
61
|
+
# DELETE FROM users WHERE active = ?
|
|
62
|
+
User.query().delete().where('active', '=', False).run()
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Write statements commit after they run and return the affected row count. Multi-row inserts without a RETURNING clause execute through the driver's `executemany()` with a single-row template, which is the fast path for bulk loads.
|
|
66
|
+
|
|
67
|
+
### Transactions
|
|
68
|
+
|
|
69
|
+
`Model.transaction()` opens a context that commits when the block finishes and rolls back when it raises. Statements inside the block share one transaction; `run()` stops committing per statement. Nested blocks use savepoints, so an inner failure rolls back only the inner block.
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
with User.transaction():
|
|
73
|
+
Account.query().update({'balance': 0}).where('id', '=', 1).run()
|
|
74
|
+
AuditLog.query().insert({'event': 'reset', 'account_id': 1}).run()
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Upserts
|
|
78
|
+
|
|
79
|
+
Chain `onConflict(columns)` after `insert()`, then choose `merge()` to update the existing row or `ignore()` to skip it. `merge()` updates every inserted column except the conflict columns, or an explicit list.
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
User.query().insert({'email': 'a@x.com', 'name': 'Ada'}) \
|
|
83
|
+
.onConflict('email').merge().run()
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Postgres, SQLite, and DuckDB render `ON CONFLICT`; MSSQL renders a `MERGE` statement; Presto raises.
|
|
87
|
+
|
|
88
|
+
### INSERT ... SELECT and CREATE TABLE AS
|
|
89
|
+
|
|
90
|
+
`insert_from(columns, query)` inserts the result of another query. `create_table_as(name, temporary=False)` turns a SELECT into a CTAS statement. MSSQL raises for CTAS; use `SELECT INTO` through raw SQL there.
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
inactive = User.query().select('id', 'name').where('active', '=', False)
|
|
94
|
+
Archive.query().insert_from(['id', 'name'], inactive).run()
|
|
95
|
+
|
|
96
|
+
User.query().select('id').where('active', '=', True).create_table_as('active_ids').run()
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Safety Rule for UPDATE and DELETE
|
|
100
|
+
|
|
101
|
+
An `update()` or `delete()` without a `where()` clause raises a `ValueError`, because an unfiltered write usually means a missing filter. To write every row on purpose, add an always-true raw predicate:
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
User.query().update({'active': True}).where(QueryBuilder.raw('1'), '=', 1).run()
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### RETURNING
|
|
108
|
+
|
|
109
|
+
`returning()` adds a `RETURNING` clause on dialects that support it. The statement then returns a list of dicts instead of a row count. MSSQL and Presto raise a `DialectError`.
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
rows = User.query().insert({'name': 'Ada'}).returning('id').run()
|
|
113
|
+
# [{'id': 42}]
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Eager Loading Relations
|
|
117
|
+
|
|
118
|
+
`withGraphFetched()` loads relations from `relationMappings` when the query runs. Each relation costs one extra query. `HasManyRelation` attaches a list to each instance. The to-one relation types attach a single instance or `None`.
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
owners = Owner.query().withGraphFetched('pets').run()
|
|
122
|
+
|
|
123
|
+
for owner in owners:
|
|
124
|
+
for pet in owner.pets:
|
|
125
|
+
print(owner.name, pet.name)
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Eager loading needs the join key columns in both result sets, so keep them in your `select()` or select all columns. Through relations (`ManyToManyRelation`) load with one query that joins the related table to the through table.
|
|
129
|
+
|
|
130
|
+
## Result Formats
|
|
131
|
+
|
|
132
|
+
`run()` returns model instances. For other shapes:
|
|
133
|
+
|
|
134
|
+
* **`to_dicts()`**: rows as plain dicts keyed by column name.
|
|
135
|
+
* **`to_df()`**: a pandas DataFrame, keeping the query's column names even when empty. Requires pandas.
|
|
136
|
+
* **`to_arrow()`**: a pyarrow Table. Requires pyarrow.
|
|
137
|
+
|
|
138
|
+
pandas and pyarrow are optional; the methods raise a clear error when the library is missing.
|
|
139
|
+
|
|
140
|
+
## Connection Pooling
|
|
141
|
+
|
|
142
|
+
`ConnectionPool` creates connections lazily from a factory up to `max_size` and reuses released ones. Bind it like a connection; every statement checks a connection out for its duration.
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
from sustained.pool import ConnectionPool
|
|
146
|
+
|
|
147
|
+
pool = ConnectionPool(lambda: psycopg2.connect(DSN), max_size=10)
|
|
148
|
+
User.bind(pool)
|
|
149
|
+
|
|
150
|
+
users = User.query().where('active', '=', True).run()
|
|
151
|
+
|
|
152
|
+
with User.transaction():
|
|
153
|
+
# One connection is pinned to this thread for the whole block.
|
|
154
|
+
User.query().insert({...}).run()
|
|
155
|
+
Account.query().update({...}).where(...).run()
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
An exhausted pool raises `PoolTimeout` after the configured timeout. `pool.close()` closes idle connections.
|
|
159
|
+
|
|
160
|
+
## Async Execution
|
|
161
|
+
|
|
162
|
+
Queries run asynchronously through an adapter. `DbApiAsyncAdapter` wraps any synchronous DB-API connection in a worker thread; `AiosqliteAdapter` and `AsyncpgAdapter` wrap their native drivers. The asyncpg adapter converts `%s` placeholders to `$1..$n`.
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
from sustained.aio import DbApiAsyncAdapter
|
|
166
|
+
|
|
167
|
+
adapter = DbApiAsyncAdapter(sqlite3.connect('app.db', check_same_thread=False))
|
|
168
|
+
User.bind_async(adapter)
|
|
169
|
+
|
|
170
|
+
users = await User.query().where('active', '=', True).arun()
|
|
171
|
+
user = await User.query().where('id', '=', 1).afirst()
|
|
172
|
+
rows = await User.query().ato_dicts()
|
|
173
|
+
|
|
174
|
+
async with User.async_transaction():
|
|
175
|
+
await User.query().insert({...}).arun()
|
|
176
|
+
await Account.query().update({...}).where(...).arun()
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
`arun()` mirrors `run()`: hydration, RETURNING rows, batched multi-row inserts, and eager loading of basic relations. Async eager loading of through relations is not supported yet, and async transactions do not nest.
|
|
180
|
+
|
|
181
|
+
## Statement Logging
|
|
182
|
+
|
|
183
|
+
`sustained.execution.set_statement_listener(fn)` registers an observer called after every executed statement with the SQL text, the parameter tuple, and the duration in seconds. Pass `None` to remove it.
|
|
@@ -52,6 +52,32 @@ Movie.query().where("tagline", "=", None)
|
|
|
52
52
|
|
|
53
53
|
Column names quote per dialect when they are plain identifier paths. Values render as SQL literals in `str(query)` and as placeholders in `to_sql()`. See [Executing Queries](./executing).
|
|
54
54
|
|
|
55
|
+
## Typed Predicates
|
|
56
|
+
|
|
57
|
+
Every model exposes a typed column namespace at `Model.c`, and the `col()` helper wraps any dotted path. Python comparison operators build `Predicate` objects, which combine with `&` (AND), `|` (OR), and `~` (NOT). Pass the result to `where()` or `having()`.
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from sustained import col
|
|
61
|
+
|
|
62
|
+
# SELECT * FROM movies WHERE ((rating > 8 AND genre = 'Sci-Fi') OR NOT (archived = TRUE))
|
|
63
|
+
Movie.query().where(
|
|
64
|
+
(Movie.c.rating > 8) & (Movie.c.genre == "Sci-Fi") | ~(Movie.c.archived == True)
|
|
65
|
+
)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Column methods cover the rest of the operators: `.like()`, `.not_like()`, `.ilike()`, `.in_()`, `.not_in()`, `.between()`, `.not_between()`, `.is_null()`, `.not_null()`. Values parameterize in `to_sql()` and columns quote per dialect. Comparing with `== None` or `!= None` renders `IS NULL` / `IS NOT NULL`. A `Predicate` raises `TypeError` in a boolean context, which catches accidental use of the `and`/`or` keywords.
|
|
69
|
+
|
|
70
|
+
## Raw Predicates with Bound Values: `whereRaw`
|
|
71
|
+
|
|
72
|
+
For a predicate the builder cannot express, use `whereRaw(sql, params)`. Mark each value with `?`; the values travel as parameters, never as text in the SQL. `havingRaw` works the same way for HAVING.
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
# SELECT * FROM movies WHERE (release_year % ? = ?)
|
|
76
|
+
Movie.query().whereRaw("release_year % ? = ?", [2, 0])
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
The marker count must match the parameter count, and the fragment renders wrapped in parentheses.
|
|
80
|
+
|
|
55
81
|
## `whereIn` and `whereNotIn`
|
|
56
82
|
|
|
57
83
|
To filter against a list of values, use the `whereIn` and `whereNotIn` methods.
|
|
@@ -18,6 +18,7 @@ If you are new to Sustained, it's recommended to read the guides in the followin
|
|
|
18
18
|
5. **[Filtering](./filtering):** Dive into the various `where` methods for filtering your results.
|
|
19
19
|
6. **[Relations and Joins](./relations):** Learn how to define relationships between models and join them in your queries.
|
|
20
20
|
7. **[Executing Queries](./executing):** Run queries against a database, hydrate results into models, write data, and eager load relations.
|
|
21
|
+
8. **[Schema and Migrations](./schema):** Declare typed columns on models, generate DDL, and run ordered migrations.
|
|
21
22
|
|
|
22
23
|
## API Reference
|
|
23
24
|
|
|
@@ -388,6 +388,15 @@ print(posts_query)
|
|
|
388
388
|
# ON posts.user_id = active_users.id
|
|
389
389
|
```
|
|
390
390
|
|
|
391
|
+
### Recursive CTEs
|
|
392
|
+
|
|
393
|
+
Pass `recursive=True` for a self-referencing CTE. The WITH clause renders as `WITH RECURSIVE`, except on MSSQL, where T-SQL spells recursive CTEs with plain `WITH`.
|
|
394
|
+
|
|
395
|
+
```python
|
|
396
|
+
tree = Employee.query().select('id', 'manager_id') # anchor plus recursion via raw()
|
|
397
|
+
query = Employee.query().with_('tree', tree, recursive=True).from_('tree')
|
|
398
|
+
```
|
|
399
|
+
|
|
391
400
|
## Combining Queries with UNION
|
|
392
401
|
|
|
393
402
|
You can combine multiple queries into a single result set using `UNION` and `UNION ALL`.
|
|
@@ -422,6 +431,30 @@ print(all_users)
|
|
|
422
431
|
final_query = active_users.union(pending_users).offset(50)
|
|
423
432
|
```
|
|
424
433
|
|
|
434
|
+
### INTERSECT and EXCEPT
|
|
435
|
+
|
|
436
|
+
`intersect()` keeps only rows present in every query. `except_()` removes rows that appear in the given queries. The trailing underscore avoids the Python `except` keyword. Both work like `union()`.
|
|
437
|
+
|
|
438
|
+
## Analyst Clauses
|
|
439
|
+
|
|
440
|
+
* **`distinctOn(*columns)`**: Postgres/DuckDB `SELECT DISTINCT ON (...)`, which keeps the first row per group; pair it with `orderBy()` on the same leading columns. Other dialects raise `DialectError`.
|
|
441
|
+
* **`groupByRollup(*columns)` / `groupByCube(*columns)` / `groupByGroupingSets(*tuples)`**: subtotal and multi-grain aggregation forms of GROUP BY.
|
|
442
|
+
* **`qualify(condition)`**: filters on window function results without a wrapping subquery. Takes a `Predicate` or a raw string. Supported on DuckDB.
|
|
443
|
+
* **`for_update(skip_locked=False, nowait=False)`**: row locking on Postgres; rejected with unions and on dialects without it.
|
|
444
|
+
|
|
445
|
+
## Counting and Keyset Pagination
|
|
446
|
+
|
|
447
|
+
`total()` runs `SELECT COUNT(*)` over the query with ORDER BY, LIMIT, and OFFSET stripped, and returns the count without changing the query. `cursor_page(column, page_size, after=None)` applies keyset pagination: it orders by the column, filters rows greater than the last seen value, and limits to the page size. On large tables this avoids the scan cost that grows with OFFSET.
|
|
448
|
+
|
|
449
|
+
```python
|
|
450
|
+
first_page = User.query().cursor_page('id', 100).run()
|
|
451
|
+
second_page = User.query().cursor_page('id', 100, after=first_page[-1].id).run()
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
## Inspecting Plans
|
|
455
|
+
|
|
456
|
+
`explain()` runs the dialect's EXPLAIN on the query and returns the plan rows. `explain(analyze=True)` uses EXPLAIN ANALYZE, which actually executes the statement. MSSQL raises because T-SQL has no EXPLAIN.
|
|
457
|
+
|
|
425
458
|
## SQL Dialects
|
|
426
459
|
|
|
427
460
|
Sustained supports generating SQL for different database dialects. This allows you to take advantage of dialect-specific features and syntax. By default, Sustained generates standard ANSI SQL.
|
|
@@ -432,6 +465,7 @@ Currently, the following dialects are supported:
|
|
|
432
465
|
* **`Dialects.PRESTO`**: SQL dialect for the Presto query engine.
|
|
433
466
|
* **`Dialects.MSSQL`**: SQL dialect for Microsoft SQL Server.
|
|
434
467
|
* **`Dialects.POSTGRES`**: SQL dialect for PostgreSQL.
|
|
468
|
+
* **`Dialects.DUCKDB`**: SQL dialect for DuckDB.
|
|
435
469
|
|
|
436
470
|
### Setting the Dialect
|
|
437
471
|
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
---
|
|
2
|
+
layout: default
|
|
3
|
+
title: Schema and Migrations
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Models can declare their physical shape and generate DDL from it, and the migration runner applies ordered schema changes.
|
|
7
|
+
|
|
8
|
+
## Typed Columns
|
|
9
|
+
|
|
10
|
+
Declare `tableColumns` as a dict of column name to a typed definition. The factories are `Integer`, `BigInteger`, `String(length)`, `Text`, `Boolean`, `Float`, `Numeric(precision, scale)`, `Date`, `Timestamp`, and `Json`.
|
|
11
|
+
|
|
12
|
+
```python
|
|
13
|
+
from sustained import Model
|
|
14
|
+
from sustained.schema import Boolean, Integer, String, Timestamp
|
|
15
|
+
from sustained.types import Expression
|
|
16
|
+
|
|
17
|
+
class User(Model):
|
|
18
|
+
tableName = 'users'
|
|
19
|
+
tableColumns = {
|
|
20
|
+
'id': Integer(primary_key=True, autoincrement=True),
|
|
21
|
+
'email': String(120, unique=True, nullable=False),
|
|
22
|
+
'active': Boolean(default=True),
|
|
23
|
+
'created_at': Timestamp(default=Expression('CURRENT_TIMESTAMP')),
|
|
24
|
+
}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Definitions support composite primary keys (mark several columns `primary_key=True`), `unique`, literal defaults or raw `Expression` defaults, and foreign keys through `references='table.column'`. `autoincrement` requires a single integer primary key; DuckDB and Presto raise because they have no identity columns.
|
|
28
|
+
|
|
29
|
+
A model with `tableColumns` also gets strict column-name access automatically: a typo'd column raises `AttributeError`.
|
|
30
|
+
|
|
31
|
+
## Generating and Running DDL
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
User.create_table_sql() # dialect-specific CREATE TABLE
|
|
35
|
+
User.create_table(conn) # execute it
|
|
36
|
+
User.drop_table(conn) # DROP TABLE IF EXISTS
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Types map per dialect: `BIT`, `NVARCHAR`, and `DATETIME2` on MSSQL; `JSONB` and identity columns on Postgres; plain `INTEGER PRIMARY KEY` rowid behavior on the default dialect.
|
|
40
|
+
|
|
41
|
+
## Migrations
|
|
42
|
+
|
|
43
|
+
Migrations are explicit and ordered. Each pairs an id with an up step and an optional down step; steps are a SQL string, a list of statements, or a callable receiving the connection.
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from sustained.migrations import Migration, Migrator, create_table_migration
|
|
47
|
+
|
|
48
|
+
migrations = [
|
|
49
|
+
create_table_migration(User),
|
|
50
|
+
Migration(
|
|
51
|
+
'add_last_login',
|
|
52
|
+
up='ALTER TABLE users ADD COLUMN last_login TEXT',
|
|
53
|
+
down='ALTER TABLE users DROP COLUMN last_login',
|
|
54
|
+
),
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
migrator = Migrator(conn, migrations)
|
|
58
|
+
migrator.up() # apply all pending
|
|
59
|
+
migrator.up(target='create_users') # stop after a target
|
|
60
|
+
migrator.status() # [(id, applied), ...]
|
|
61
|
+
migrator.down() # revert the newest applied migration
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Applied ids live in a tracking table that the migrator creates on first use. Each migration runs inside a transaction, so a failing step leaves the schema at the previous migration. There is no automatic diffing against the database catalog; write migrations explicitly or derive create/drop pairs from models with `create_table_migration()`.
|
|
@@ -16,9 +16,12 @@ from sustained.expressions import (
|
|
|
16
16
|
AggregateExpression,
|
|
17
17
|
CaseExpression,
|
|
18
18
|
Column,
|
|
19
|
+
ColumnExpr,
|
|
19
20
|
Func,
|
|
20
21
|
Literal,
|
|
22
|
+
Predicate,
|
|
21
23
|
WindowExpression,
|
|
24
|
+
col,
|
|
22
25
|
)
|
|
23
26
|
from sustained.model import Model, create_model
|
|
24
27
|
from sustained.types import (
|
|
@@ -36,6 +39,9 @@ __all__ = [
|
|
|
36
39
|
"DialectError",
|
|
37
40
|
# from expressions
|
|
38
41
|
"Column",
|
|
42
|
+
"ColumnExpr",
|
|
43
|
+
"Predicate",
|
|
44
|
+
"col",
|
|
39
45
|
"Func",
|
|
40
46
|
"Literal",
|
|
41
47
|
"AggregateExpression",
|