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.
Files changed (98) hide show
  1. {sustained-2.0.0 → sustained-2.2.0}/.pre-commit-config.yaml +5 -2
  2. {sustained-2.0.0 → sustained-2.2.0}/CHANGELOG.md +32 -0
  3. {sustained-2.0.0 → sustained-2.2.0}/DEVELOPERS.md +21 -4
  4. {sustained-2.0.0 → sustained-2.2.0}/PKG-INFO +2 -2
  5. {sustained-2.0.0 → sustained-2.2.0}/README.md +1 -1
  6. sustained-2.2.0/docs/executing.md +183 -0
  7. {sustained-2.0.0 → sustained-2.2.0}/docs/filtering.md +26 -0
  8. {sustained-2.0.0 → sustained-2.2.0}/docs/index.md +1 -0
  9. {sustained-2.0.0 → sustained-2.2.0}/docs/queries.md +34 -0
  10. sustained-2.2.0/docs/schema.md +64 -0
  11. {sustained-2.0.0 → sustained-2.2.0}/pyproject.toml +1 -1
  12. {sustained-2.0.0 → sustained-2.2.0}/src/sustained/__init__.py +6 -0
  13. sustained-2.2.0/src/sustained/aio.py +356 -0
  14. {sustained-2.0.0 → sustained-2.2.0}/src/sustained/builder.py +489 -49
  15. {sustained-2.0.0 → sustained-2.2.0}/src/sustained/builder.pyi +60 -7
  16. {sustained-2.0.0 → sustained-2.2.0}/src/sustained/builders/conditional_clause_builder.py +41 -0
  17. sustained-2.2.0/src/sustained/builders/group_by_builder.py +65 -0
  18. sustained-2.2.0/src/sustained/builders/group_by_builder.pyi +12 -0
  19. {sustained-2.0.0 → sustained-2.2.0}/src/sustained/builders/select_clause_builder.py +3 -0
  20. {sustained-2.0.0 → sustained-2.2.0}/src/sustained/compilers/base.py +115 -2
  21. sustained-2.2.0/src/sustained/compilers/duckdb.py +31 -0
  22. sustained-2.2.0/src/sustained/compilers/mssql.py +107 -0
  23. {sustained-2.0.0 → sustained-2.2.0}/src/sustained/compilers/postgres.py +16 -0
  24. {sustained-2.0.0 → sustained-2.2.0}/src/sustained/compilers/presto.py +18 -0
  25. {sustained-2.0.0 → sustained-2.2.0}/src/sustained/dialects.py +2 -0
  26. sustained-2.2.0/src/sustained/execution.py +366 -0
  27. {sustained-2.0.0 → sustained-2.2.0}/src/sustained/expressions.py +166 -1
  28. {sustained-2.0.0 → sustained-2.2.0}/src/sustained/functions.py +71 -4
  29. sustained-2.2.0/src/sustained/migrations.py +181 -0
  30. {sustained-2.0.0 → sustained-2.2.0}/src/sustained/model.py +144 -1
  31. sustained-2.2.0/src/sustained/pool.py +112 -0
  32. {sustained-2.0.0 → sustained-2.2.0}/src/sustained/rendering.py +19 -0
  33. sustained-2.2.0/src/sustained/schema.py +190 -0
  34. {sustained-2.0.0 → sustained-2.2.0}/src/sustained/types.py +2 -0
  35. sustained-2.2.0/tests/test_analyst_sql.py +182 -0
  36. sustained-2.2.0/tests/test_async.py +155 -0
  37. {sustained-2.0.0 → sustained-2.2.0}/tests/test_dialect_functions.py +12 -14
  38. sustained-2.2.0/tests/test_duckdb_dialect.py +59 -0
  39. sustained-2.2.0/tests/test_etl_statements.py +122 -0
  40. {sustained-2.0.0 → sustained-2.2.0}/tests/test_execution.py +90 -0
  41. sustained-2.2.0/tests/test_migrations.py +132 -0
  42. sustained-2.2.0/tests/test_pool.py +148 -0
  43. sustained-2.2.0/tests/test_predicates.py +148 -0
  44. sustained-2.2.0/tests/test_raw_bindings.py +55 -0
  45. sustained-2.2.0/tests/test_result_formats.py +92 -0
  46. sustained-2.2.0/tests/test_schema_ddl.py +168 -0
  47. sustained-2.2.0/tests/test_transactions.py +126 -0
  48. sustained-2.2.0/tests/test_upsert.py +139 -0
  49. sustained-2.0.0/docs/executing.md +0 -96
  50. sustained-2.0.0/src/sustained/builders/group_by_builder.py +0 -40
  51. sustained-2.0.0/src/sustained/builders/group_by_builder.pyi +0 -7
  52. sustained-2.0.0/src/sustained/compilers/mssql.py +0 -44
  53. sustained-2.0.0/src/sustained/execution.py +0 -118
  54. {sustained-2.0.0 → sustained-2.2.0}/.gitignore +0 -0
  55. {sustained-2.0.0 → sustained-2.2.0}/LICENSE +0 -0
  56. {sustained-2.0.0 → sustained-2.2.0}/deploy.py +0 -0
  57. {sustained-2.0.0 → sustained-2.2.0}/docs/CNAME +0 -0
  58. {sustained-2.0.0 → sustained-2.2.0}/docs/_config.yml +0 -0
  59. {sustained-2.0.0 → sustained-2.2.0}/docs/_layouts/default.html +0 -0
  60. {sustained-2.0.0 → sustained-2.2.0}/docs/assets/css/style.scss +0 -0
  61. {sustained-2.0.0 → sustained-2.2.0}/docs/grouping.md +0 -0
  62. {sustained-2.0.0 → sustained-2.2.0}/docs/models.md +0 -0
  63. {sustained-2.0.0 → sustained-2.2.0}/docs/relations.md +0 -0
  64. {sustained-2.0.0 → sustained-2.2.0}/src/sustained/builders/__init__.py +0 -0
  65. {sustained-2.0.0 → sustained-2.2.0}/src/sustained/builders/__init__.pyi +0 -0
  66. {sustained-2.0.0 → sustained-2.2.0}/src/sustained/builders/conditional_clause_builder.pyi +0 -0
  67. {sustained-2.0.0 → sustained-2.2.0}/src/sustained/builders/having_builder.py +0 -0
  68. {sustained-2.0.0 → sustained-2.2.0}/src/sustained/builders/having_builder.pyi +0 -0
  69. {sustained-2.0.0 → sustained-2.2.0}/src/sustained/builders/join_builder.py +0 -0
  70. {sustained-2.0.0 → sustained-2.2.0}/src/sustained/builders/join_builder.pyi +0 -0
  71. {sustained-2.0.0 → sustained-2.2.0}/src/sustained/builders/order_by_builder.py +0 -0
  72. {sustained-2.0.0 → sustained-2.2.0}/src/sustained/builders/order_by_builder.pyi +0 -0
  73. {sustained-2.0.0 → sustained-2.2.0}/src/sustained/builders/select_clause_builder.pyi +0 -0
  74. {sustained-2.0.0 → sustained-2.2.0}/src/sustained/builders/where_builder.py +0 -0
  75. {sustained-2.0.0 → sustained-2.2.0}/src/sustained/builders/where_builder.pyi +0 -0
  76. {sustained-2.0.0 → sustained-2.2.0}/src/sustained/compilers/__init__.py +0 -0
  77. {sustained-2.0.0 → sustained-2.2.0}/src/sustained/exceptions.py +0 -0
  78. {sustained-2.0.0 → sustained-2.2.0}/src/sustained/py.typed +0 -0
  79. {sustained-2.0.0 → sustained-2.2.0}/tests/__init__.py +0 -0
  80. {sustained-2.0.0 → sustained-2.2.0}/tests/test_builder_ergonomics.py +0 -0
  81. {sustained-2.0.0 → sustained-2.2.0}/tests/test_builder_robustness.py +0 -0
  82. {sustained-2.0.0 → sustained-2.2.0}/tests/test_dialect.py +0 -0
  83. {sustained-2.0.0 → sustained-2.2.0}/tests/test_dialect_behaviors.py +0 -0
  84. {sustained-2.0.0 → sustained-2.2.0}/tests/test_dml.py +0 -0
  85. {sustained-2.0.0 → sustained-2.2.0}/tests/test_expressions.py +0 -0
  86. {sustained-2.0.0 → sustained-2.2.0}/tests/test_functions.py +0 -0
  87. {sustained-2.0.0 → sustained-2.2.0}/tests/test_having_builder.py +0 -0
  88. {sustained-2.0.0 → sustained-2.2.0}/tests/test_join_builder.py +0 -0
  89. {sustained-2.0.0 → sustained-2.2.0}/tests/test_lambda_join_builder.py +0 -0
  90. {sustained-2.0.0 → sustained-2.2.0}/tests/test_model.py +0 -0
  91. {sustained-2.0.0 → sustained-2.2.0}/tests/test_model_features.py +0 -0
  92. {sustained-2.0.0 → sustained-2.2.0}/tests/test_mssql_compiler.py +0 -0
  93. {sustained-2.0.0 → sustained-2.2.0}/tests/test_order_by_builder.py +0 -0
  94. {sustained-2.0.0 → sustained-2.2.0}/tests/test_parameterization.py +0 -0
  95. {sustained-2.0.0 → sustained-2.2.0}/tests/test_postgres_compiler.py +0 -0
  96. {sustained-2.0.0 → sustained-2.2.0}/tests/test_query_builder.py +0 -0
  97. {sustained-2.0.0 → sustained-2.2.0}/tests/test_select_clause_builder.py +0 -0
  98. {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: bash -c "PYTHONPATH=src python3 -m unittest discover -s tests"
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:** When `str(query)` is finally called, the `QueryBuilder` is responsible for assembling the final SQL string by calling `str()` on each of its internal builders in the correct order and passing the result through the `Compiler`.
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:** The `QueryBuilder.__str__()` method is invoked. It calls `str()` on each of its internal builders (`_select_clause_builder`, `_where_builder`, etc.) to get their rendered SQL fragments.
51
- 5. **Dialect-Specific Rendering:** For parts of the query that are dialect-dependent (like `LIMIT`/`OFFSET`), the `QueryBuilder` calls methods on its configured `Compiler` instance (e.g., `self._compiler.compile_limit_offset(...)`).
52
- 6. **Final String:** The `QueryBuilder` joins all the rendered fragments together into the final, complete SQL string.
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.0.0
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 Presto dialects. It can also execute queries against any DB-API 2.0 connection, hydrate rows into model instances, write data with `insert()`, `update()`, and `delete()`, and eager load relations.
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 Presto dialects. It can also execute queries against any DB-API 2.0 connection, hydrate rows into model instances, write data with `insert()`, `update()`, and `delete()`, and eager load relations.
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()`.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "sustained"
7
- version = "2.0.0"
7
+ version = "2.2.0"
8
8
  description = "A Python query builder inspired by Objection.js"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -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",