sql_fusion 1.0.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.
- sql_fusion-1.0.0/PKG-INFO +580 -0
- sql_fusion-1.0.0/README.md +571 -0
- sql_fusion-1.0.0/pyproject.toml +142 -0
- sql_fusion-1.0.0/src/sql_fusion/__init__.py +15 -0
- sql_fusion-1.0.0/src/sql_fusion/composite_table.py +689 -0
- sql_fusion-1.0.0/src/sql_fusion/operators.py +119 -0
- sql_fusion-1.0.0/src/sql_fusion/query/__init__.py +0 -0
- sql_fusion-1.0.0/src/sql_fusion/query/delete.py +84 -0
- sql_fusion-1.0.0/src/sql_fusion/query/insert.py +72 -0
- sql_fusion-1.0.0/src/sql_fusion/query/select.py +527 -0
- sql_fusion-1.0.0/src/sql_fusion/query/update.py +76 -0
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: sql_fusion
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Add your description here
|
|
5
|
+
Author: Mastermind-U
|
|
6
|
+
Author-email: Mastermind-U <rex49513@gmail.com>
|
|
7
|
+
Requires-Python: >=3.14
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
|
|
10
|
+
# SQL Fusion
|
|
11
|
+
|
|
12
|
+
SQL Fusion is a lightweight, chainable SQL query builder for Python with zero dependencies.
|
|
13
|
+
|
|
14
|
+
It focuses on one job:
|
|
15
|
+
|
|
16
|
+
- build parameterized SQL
|
|
17
|
+
- keep the query syntax readable
|
|
18
|
+
- stay flexible enough for SQLite3, DuckDB, PostgreSQL, and other DB-API style backends
|
|
19
|
+
|
|
20
|
+
The library does not execute SQL itself. It returns:
|
|
21
|
+
|
|
22
|
+
- the SQL string
|
|
23
|
+
- the parameter tuple
|
|
24
|
+
|
|
25
|
+
That makes it easy to plug into your own connection layer.
|
|
26
|
+
|
|
27
|
+
## Table of Contents
|
|
28
|
+
|
|
29
|
+
- [What You Get](#what-you-get)
|
|
30
|
+
- [Installation](#installation)
|
|
31
|
+
- [Public API](#public-api)
|
|
32
|
+
- [Quickstart: SQLite3](#quickstart-sqlite3)
|
|
33
|
+
- [Quickstart: DuckDB](#quickstart-duckdb)
|
|
34
|
+
- [Quickstart: psycopg3](#quickstart-psycopg3)
|
|
35
|
+
- [Query Basics](#query-basics)
|
|
36
|
+
- [Subquery Example](#subquery-example)
|
|
37
|
+
- [Method Reference](#method-reference)
|
|
38
|
+
- [Functions](#functions)
|
|
39
|
+
- [CTEs](#ctes)
|
|
40
|
+
- [Custom Compile Expressions](#custom-compile-expressions)
|
|
41
|
+
- [What To Remember](#what-to-remember)
|
|
42
|
+
|
|
43
|
+
## What You Get
|
|
44
|
+
|
|
45
|
+
- `SELECT`, `INSERT`, `UPDATE`, and `DELETE` builders
|
|
46
|
+
- automatic table aliases
|
|
47
|
+
- composable conditions with `AND`, `OR`, and `NOT`
|
|
48
|
+
- joins, subqueries, and CTEs
|
|
49
|
+
- ordering, joins, subqueries, and CTEs
|
|
50
|
+
- aggregate and custom SQL functions through `func`
|
|
51
|
+
- backend-specific SQL rewrites through compile expressions
|
|
52
|
+
|
|
53
|
+
## Installation
|
|
54
|
+
|
|
55
|
+
The project targets Python 3.14 or newer.
|
|
56
|
+
|
|
57
|
+
For local development:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
uv sync
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Or install it in editable mode:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
pip install -e .
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Public API
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from sql_fusion import Alias, Table, delete, func, insert, select, update
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Core Objects
|
|
76
|
+
|
|
77
|
+
- `Table` represents a real table or a subquery.
|
|
78
|
+
- `select` creates a `SELECT` builder.
|
|
79
|
+
- `insert` creates an `INSERT` builder.
|
|
80
|
+
- `update` creates an `UPDATE` builder.
|
|
81
|
+
- `delete` creates a `DELETE` builder.
|
|
82
|
+
- `func` is a dynamic SQL function registry.
|
|
83
|
+
- `Alias` represents a reusable SQL alias for aggregate expressions and
|
|
84
|
+
`HAVING` conditions.
|
|
85
|
+
|
|
86
|
+
## Quickstart: SQLite3
|
|
87
|
+
|
|
88
|
+
SQLite3 is the easiest way to start because it accepts the default `?` placeholders directly.
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
import sqlite3
|
|
92
|
+
|
|
93
|
+
from sql_fusion import Table, insert, select, update
|
|
94
|
+
|
|
95
|
+
users = Table("users")
|
|
96
|
+
|
|
97
|
+
conn = sqlite3.connect(":memory:")
|
|
98
|
+
conn.execute(
|
|
99
|
+
"""
|
|
100
|
+
CREATE TABLE users (
|
|
101
|
+
id INTEGER PRIMARY KEY,
|
|
102
|
+
name TEXT NOT NULL,
|
|
103
|
+
status TEXT NOT NULL
|
|
104
|
+
)
|
|
105
|
+
""",
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
insert_query, insert_params = (
|
|
109
|
+
insert(users)
|
|
110
|
+
.values(id=1, name="Alice", status="active")
|
|
111
|
+
.compile()
|
|
112
|
+
)
|
|
113
|
+
conn.execute(insert_query, insert_params)
|
|
114
|
+
|
|
115
|
+
select_query, select_params = (
|
|
116
|
+
select(users.id, users.name)
|
|
117
|
+
.from_(users)
|
|
118
|
+
.where_by(status="active")
|
|
119
|
+
.compile()
|
|
120
|
+
)
|
|
121
|
+
rows = conn.execute(select_query, select_params).fetchall()
|
|
122
|
+
|
|
123
|
+
update_query, update_params = (
|
|
124
|
+
update(users)
|
|
125
|
+
.set(status="inactive")
|
|
126
|
+
.where(users.id == 1)
|
|
127
|
+
.compile()
|
|
128
|
+
)
|
|
129
|
+
conn.execute(update_query, update_params)
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Expected style of generated SQL:
|
|
133
|
+
|
|
134
|
+
```sql
|
|
135
|
+
SELECT "a"."id", "a"."name" FROM "users" AS "a" WHERE "a"."status" = ?
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Quickstart: DuckDB
|
|
139
|
+
|
|
140
|
+
DuckDB works with the default `?` placeholders directly, so you can execute
|
|
141
|
+
queries without any SQL rewriting.
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
import duckdb
|
|
145
|
+
from sql_fusion import Table, select
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
users = Table("users")
|
|
149
|
+
|
|
150
|
+
query = (
|
|
151
|
+
select(users.id, users.name)
|
|
152
|
+
.from_(users)
|
|
153
|
+
.where(users.status == "active")
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
duck_sql, duck_params = query.compile()
|
|
157
|
+
duck_conn = duckdb.connect(":memory:")
|
|
158
|
+
duck_conn.execute("CREATE TABLE users (id INTEGER, name TEXT, status TEXT)")
|
|
159
|
+
duck_conn.execute(duck_sql, duck_params).fetchall()
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## Quickstart: psycopg3
|
|
163
|
+
|
|
164
|
+
psycopg3 usually expects `%s` placeholders instead of `?`. The simplest way
|
|
165
|
+
to support it is to add a compile expression that rewrites placeholders at the
|
|
166
|
+
very end.
|
|
167
|
+
|
|
168
|
+
```python
|
|
169
|
+
from typing import Any
|
|
170
|
+
|
|
171
|
+
import psycopg
|
|
172
|
+
|
|
173
|
+
from sql_fusion import Table, select
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def to_psycopg3(sql: str, params: tuple[Any, ...]) -> tuple[str, tuple[Any, ...]]:
|
|
177
|
+
return sql.replace("?", "%s"), params
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
users = Table("users")
|
|
181
|
+
|
|
182
|
+
query = (
|
|
183
|
+
select(users.id, users.name)
|
|
184
|
+
.from_(users)
|
|
185
|
+
.where(users.status == "active")
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
pg_sql, pg_params = query.compile_expression(to_psycopg3).compile()
|
|
189
|
+
pg_conn = psycopg.connect("dbname=example user=example password=example")
|
|
190
|
+
pg_conn.execute(pg_sql, pg_params).fetchall()
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
If you only target DuckDB, no rewrite is needed. If you target psycopg3, the
|
|
194
|
+
compile expression keeps the query builder backend-agnostic while still
|
|
195
|
+
producing driver-friendly SQL.
|
|
196
|
+
|
|
197
|
+
## Query Basics
|
|
198
|
+
|
|
199
|
+
### Tables and Aliases
|
|
200
|
+
|
|
201
|
+
`Table` automatically assigns aliases in creation order:
|
|
202
|
+
|
|
203
|
+
```python
|
|
204
|
+
users = Table("users") # alias "a"
|
|
205
|
+
orders = Table("orders") # alias "b"
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
If you want a stable alias, provide one yourself:
|
|
209
|
+
|
|
210
|
+
```python
|
|
211
|
+
users = Table("users", alias="u")
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
`Table` can also wrap a subquery. In practice, you usually pass a query
|
|
215
|
+
builder directly to `from_()` or `join()`, and the library wraps it for you.
|
|
216
|
+
|
|
217
|
+
### Conditions
|
|
218
|
+
|
|
219
|
+
Columns support the usual comparison operators:
|
|
220
|
+
|
|
221
|
+
- `==`
|
|
222
|
+
- `!=`
|
|
223
|
+
- `<`
|
|
224
|
+
- `<=`
|
|
225
|
+
- `>`
|
|
226
|
+
- `>=`
|
|
227
|
+
|
|
228
|
+
They also support SQL helpers:
|
|
229
|
+
|
|
230
|
+
- `.like(pattern)`
|
|
231
|
+
- `.ilike(pattern)`
|
|
232
|
+
- `.in_(values)`
|
|
233
|
+
- `.not_in(values)`
|
|
234
|
+
|
|
235
|
+
Conditions can be combined with:
|
|
236
|
+
|
|
237
|
+
- `&` for `AND`
|
|
238
|
+
- `|` for `OR`
|
|
239
|
+
- `~` for `NOT`
|
|
240
|
+
|
|
241
|
+
Example:
|
|
242
|
+
|
|
243
|
+
```python
|
|
244
|
+
query = (
|
|
245
|
+
select(users.id, users.name)
|
|
246
|
+
.from_(users)
|
|
247
|
+
.where(
|
|
248
|
+
(users.age >= 18)
|
|
249
|
+
& ((users.status == "active") | (users.status == "pending"))
|
|
250
|
+
& users.country.not_in(["DE", "FR"])
|
|
251
|
+
)
|
|
252
|
+
)
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
### Join Example
|
|
256
|
+
|
|
257
|
+
```python
|
|
258
|
+
users = Table("users")
|
|
259
|
+
orders = Table("orders")
|
|
260
|
+
|
|
261
|
+
query = (
|
|
262
|
+
select(users.id, users.name, orders.total)
|
|
263
|
+
.from_(users)
|
|
264
|
+
.join(orders, users.id == orders.user_id)
|
|
265
|
+
.where_by(status="active")
|
|
266
|
+
)
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
This produces a standard `INNER JOIN`. If you need a different join type, use:
|
|
270
|
+
|
|
271
|
+
- `left_join()`
|
|
272
|
+
- `right_join()`
|
|
273
|
+
- `full_join()`
|
|
274
|
+
- `cross_join()`
|
|
275
|
+
- `semi_join()`
|
|
276
|
+
- `anti_join()`
|
|
277
|
+
|
|
278
|
+
### Subquery Example
|
|
279
|
+
|
|
280
|
+
Subqueries work both as a source table and inside conditions.
|
|
281
|
+
|
|
282
|
+
```python
|
|
283
|
+
orders = Table("orders")
|
|
284
|
+
users = Table("users")
|
|
285
|
+
|
|
286
|
+
paid_order_user_ids = (
|
|
287
|
+
select(orders.user_id)
|
|
288
|
+
.from_(orders)
|
|
289
|
+
.where_by(status="paid")
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
query, params = (
|
|
293
|
+
select(users.id, users.name)
|
|
294
|
+
.from_(users)
|
|
295
|
+
.where(users.id.in_(paid_order_user_ids))
|
|
296
|
+
.compile()
|
|
297
|
+
)
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
The same idea also works in `FROM`:
|
|
301
|
+
|
|
302
|
+
```python
|
|
303
|
+
orders = Table("orders")
|
|
304
|
+
|
|
305
|
+
paid_orders = (
|
|
306
|
+
select(orders.user_id, orders.total)
|
|
307
|
+
.from_(orders)
|
|
308
|
+
.where_by(status="paid")
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
query, params = select().from_(paid_orders).compile()
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
### Having Example
|
|
315
|
+
|
|
316
|
+
```python
|
|
317
|
+
orders = Table("orders")
|
|
318
|
+
count_orders = Alias("count_orders")
|
|
319
|
+
|
|
320
|
+
query = (
|
|
321
|
+
select(
|
|
322
|
+
orders.status,
|
|
323
|
+
func.count(orders.id).as_(count_orders),
|
|
324
|
+
func.sum(orders.total),
|
|
325
|
+
)
|
|
326
|
+
.from_(orders)
|
|
327
|
+
.group_by(orders.status)
|
|
328
|
+
.having(count_orders >= 3)
|
|
329
|
+
)
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
`HAVING` works after grouping and is ideal for filtering aggregates, for
|
|
333
|
+
example "only statuses with at least 3 orders".
|
|
334
|
+
|
|
335
|
+
Because `as` is a reserved Python keyword, the method is exposed as
|
|
336
|
+
`as_()`.
|
|
337
|
+
|
|
338
|
+
## Method Reference
|
|
339
|
+
|
|
340
|
+
### Shared Query Methods
|
|
341
|
+
|
|
342
|
+
These methods are available on the shared query builders.
|
|
343
|
+
|
|
344
|
+
| Method | Purpose | Notes |
|
|
345
|
+
| --- | --- | --- |
|
|
346
|
+
| `where(*conditions)` | Add explicit conditions | Multiple conditions are combined with `AND`. Repeated calls merge safely. |
|
|
347
|
+
| `where_by(**kwargs)` | Build equality filters from keyword arguments | Uses the current `FROM` table alias. `where_by(status="active")` becomes `status = ?`. |
|
|
348
|
+
| `with_(recursive=False, **ctes)` | Add one or more CTEs | Repeated calls merge CTEs. `recursive=True` emits `WITH RECURSIVE`. |
|
|
349
|
+
| `compile_expression(fn)` | Add a final SQL transformation step | `fn` receives `(sql, params)` and must return `(sql, params)`. |
|
|
350
|
+
| `comment(text, hint=False)` | Prefix the query with a SQL comment | `hint=True` renders optimizer-style comments like `/*+ ... */`. |
|
|
351
|
+
| `before_clause(clause, text, hint=False)` | Insert a comment before a clause | `clause` is case-insensitive, such as `"FROM"` or `"UPDATE"`. |
|
|
352
|
+
| `after_clause(clause, text, hint=False)` | Insert a comment after a clause keyword | Useful for hints and debug annotations. |
|
|
353
|
+
| `explain(analyze=False, verbose=False)` | Wrap the query in `EXPLAIN` | Can be chained with other compile expressions. |
|
|
354
|
+
| `analyze(verbose=False)` | Shortcut for `EXPLAIN ANALYZE` | Equivalent to `explain(analyze=True, verbose=verbose)`. |
|
|
355
|
+
| `compile()` | Build the final SQL and parameters | Returns `(sql, params)`. |
|
|
356
|
+
|
|
357
|
+
### `select(...)`
|
|
358
|
+
|
|
359
|
+
```python
|
|
360
|
+
query = select(users.id, users.name)
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
Constructor:
|
|
364
|
+
|
|
365
|
+
- `select(*columns)`
|
|
366
|
+
|
|
367
|
+
If no columns are provided, the builder emits `SELECT *`.
|
|
368
|
+
|
|
369
|
+
#### `select` Methods
|
|
370
|
+
|
|
371
|
+
| Method | Purpose | Notes |
|
|
372
|
+
| --- | --- | --- |
|
|
373
|
+
| `from_(table)` | Set the source table or subquery | Accepts a `Table` or another query builder. |
|
|
374
|
+
| `join(table, condition)` | Add an `INNER JOIN` | The default join type. |
|
|
375
|
+
| `left_join(table, condition)` | Add a `LEFT JOIN` | Keeps unmatched left rows. |
|
|
376
|
+
| `right_join(table, condition)` | Add a `RIGHT JOIN` | Keeps unmatched right rows. |
|
|
377
|
+
| `full_join(table, condition)` | Add a `FULL OUTER JOIN` | Keeps rows from both sides. |
|
|
378
|
+
| `cross_join(table)` | Add a `CROSS JOIN` | No `ON` clause. |
|
|
379
|
+
| `semi_join(table, condition)` | Add a `SEMI JOIN` | Backend support depends on the database. |
|
|
380
|
+
| `anti_join(table, condition)` | Add an `ANTI JOIN` | Backend support depends on the database. |
|
|
381
|
+
| `limit(n)` | Limit the number of rows | `n` must be non-negative. |
|
|
382
|
+
| `offset(n)` | Skip the first `n` rows | `n` must be non-negative. |
|
|
383
|
+
| `distinct()` | Add `DISTINCT` | Safe to chain more than once. |
|
|
384
|
+
| `group_by(*columns)` | Add a standard `GROUP BY` | With no columns, emits `GROUP BY ALL`. |
|
|
385
|
+
| `group_by_rollup(*columns)` | Add `GROUP BY ROLLUP (...)` | Requires at least one column. |
|
|
386
|
+
| `group_by_cube(*columns)` | Add `GROUP BY CUBE (...)` | Requires at least one column. |
|
|
387
|
+
| `group_by_grouping_sets(*column_sets)` | Add `GROUPING SETS` | Requires at least one set. Empty tuples become `()`. |
|
|
388
|
+
| `having(*conditions)` | Add a `HAVING` clause | Requires grouping. |
|
|
389
|
+
| `having_by(**kwargs)` | Add equality-based `HAVING` filters | Requires grouping. |
|
|
390
|
+
| `order_by(*columns, descending=False)` | Add `ORDER BY` | Repeated calls merge columns. `descending=True` applies `DESC`. |
|
|
391
|
+
|
|
392
|
+
### `insert(table, or_replace=False, or_ignore=False)`
|
|
393
|
+
|
|
394
|
+
```python
|
|
395
|
+
query = insert(users).values(id=1, name="Alice")
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
#### `insert` Methods
|
|
399
|
+
|
|
400
|
+
| Method | Purpose | Notes |
|
|
401
|
+
| --- | --- | --- |
|
|
402
|
+
| `values(**kwargs)` | Add column values | Multiple calls merge into one row payload. |
|
|
403
|
+
| `compile()` | Build `INSERT` SQL | Raises if no values were provided. |
|
|
404
|
+
|
|
405
|
+
Behavior notes:
|
|
406
|
+
|
|
407
|
+
- `or_replace=True` emits `INSERT OR REPLACE`
|
|
408
|
+
- `or_ignore=True` emits `INSERT OR IGNORE`
|
|
409
|
+
- both flags together raise an error
|
|
410
|
+
|
|
411
|
+
### `update(table)`
|
|
412
|
+
|
|
413
|
+
```python
|
|
414
|
+
query = update(users).set(status="inactive")
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
#### `update` Methods
|
|
418
|
+
|
|
419
|
+
| Method | Purpose | Notes |
|
|
420
|
+
| --- | --- | --- |
|
|
421
|
+
| `set(**kwargs)` | Add assignments for the `SET` clause | Multiple calls merge assignments. |
|
|
422
|
+
| `where(...)` / `where_by(...)` | Restrict the rows to update | Works like the shared query methods. |
|
|
423
|
+
| `compile()` | Build `UPDATE` SQL | Raises if no values were provided. |
|
|
424
|
+
|
|
425
|
+
Behavior notes:
|
|
426
|
+
|
|
427
|
+
- column references in `SET` are table-qualified by default
|
|
428
|
+
- if a backend needs a different style, use `compile_expression(...)`
|
|
429
|
+
|
|
430
|
+
### `delete(table=None)`
|
|
431
|
+
|
|
432
|
+
```python
|
|
433
|
+
query = delete().from_(users).where(users.id == 1)
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
#### `delete` Methods
|
|
437
|
+
|
|
438
|
+
| Method | Purpose | Notes |
|
|
439
|
+
| --- | --- | --- |
|
|
440
|
+
| `from_(table)` | Set the target table | Required before compiling. |
|
|
441
|
+
| `returning(*columns)` | Add a `RETURNING` clause | With no arguments, emits `RETURNING *`. Multiple calls merge columns. |
|
|
442
|
+
| `where(...)` / `where_by(...)` | Restrict the rows to delete | Works like the shared query methods. |
|
|
443
|
+
| `compile()` | Build `DELETE` SQL | Returns `(sql, params)`. |
|
|
444
|
+
|
|
445
|
+
## Functions
|
|
446
|
+
|
|
447
|
+
`func` is a dynamic SQL function registry. It converts attribute access into an uppercased SQL function name.
|
|
448
|
+
|
|
449
|
+
```python
|
|
450
|
+
from sql_fusion import Alias, Table, func, select
|
|
451
|
+
|
|
452
|
+
orders = Table("orders")
|
|
453
|
+
count_orders = Alias("count_orders")
|
|
454
|
+
|
|
455
|
+
query = select(
|
|
456
|
+
func.count("*"),
|
|
457
|
+
func.count(orders.id).as_(count_orders),
|
|
458
|
+
func.sum(orders.total),
|
|
459
|
+
func.coalesce(orders.status, "unknown"),
|
|
460
|
+
).from_(orders)
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
Examples:
|
|
464
|
+
|
|
465
|
+
- `func.count("*")` -> `COUNT(*)`
|
|
466
|
+
- `func.sum(table.total)` -> `SUM("a"."total")`
|
|
467
|
+
- `func.my_custom_func(table.name)` -> `MY_CUSTOM_FUNC("a"."name")`
|
|
468
|
+
- nested calls are supported, for example `func.round(func.avg(...), 2)`
|
|
469
|
+
- `func.count(table.id).as_(Alias("count_orders"))` -> `COUNT("a"."id") AS "count_orders"`
|
|
470
|
+
|
|
471
|
+
String and numeric literals are parameterized automatically.
|
|
472
|
+
|
|
473
|
+
## CTEs
|
|
474
|
+
|
|
475
|
+
CTEs are supported through `with_()`.
|
|
476
|
+
|
|
477
|
+
```python
|
|
478
|
+
orders = Table("orders")
|
|
479
|
+
users = Table("users")
|
|
480
|
+
paid_orders = Table("paid_orders")
|
|
481
|
+
|
|
482
|
+
paid_orders_cte = (
|
|
483
|
+
select(orders.user_id, orders.total)
|
|
484
|
+
.from_(orders)
|
|
485
|
+
.where_by(status="paid")
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
query, params = (
|
|
489
|
+
select(users.name, func.sum(paid_orders.total))
|
|
490
|
+
.with_(paid_orders=paid_orders_cte)
|
|
491
|
+
.from_(paid_orders)
|
|
492
|
+
.join(users, paid_orders.user_id == users.id)
|
|
493
|
+
.group_by(users.name)
|
|
494
|
+
.compile()
|
|
495
|
+
)
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
### CTE Rules
|
|
499
|
+
|
|
500
|
+
- `with_()` accepts query-like objects only
|
|
501
|
+
- repeated `with_()` calls merge CTEs
|
|
502
|
+
- `recursive=True` emits `WITH RECURSIVE`
|
|
503
|
+
- parameter order is preserved across all nested queries
|
|
504
|
+
- CTE names are quoted automatically
|
|
505
|
+
|
|
506
|
+
### Recursive CTE Example
|
|
507
|
+
|
|
508
|
+
```python
|
|
509
|
+
nodes = Table("nodes")
|
|
510
|
+
|
|
511
|
+
tree = select(nodes.id, nodes.parent_id).from_(nodes).where_by(active=True)
|
|
512
|
+
|
|
513
|
+
query, params = (
|
|
514
|
+
select()
|
|
515
|
+
.with_(recursive=True, tree=tree)
|
|
516
|
+
.from_(Table("tree"))
|
|
517
|
+
.compile()
|
|
518
|
+
)
|
|
519
|
+
```
|
|
520
|
+
|
|
521
|
+
## Custom Compile Expressions
|
|
522
|
+
|
|
523
|
+
`compile_expression()` is the escape hatch for backend-specific SQL tweaks.
|
|
524
|
+
It receives the final SQL string and parameter tuple, then returns a modified pair.
|
|
525
|
+
|
|
526
|
+
This is useful for:
|
|
527
|
+
|
|
528
|
+
- placeholder rewrites
|
|
529
|
+
- backend-specific syntax adjustments
|
|
530
|
+
- adding `ORDER BY`, `LIMIT`, or other final SQL fragments
|
|
531
|
+
|
|
532
|
+
### Example: psycopg3 Placeholder Rewrite
|
|
533
|
+
|
|
534
|
+
```python
|
|
535
|
+
from typing import Any
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def to_psycopg3(sql: str, params: tuple[Any, ...]) -> tuple[str, tuple[Any, ...]]:
|
|
539
|
+
return sql.replace("?", "%s"), params
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
### Example: Append Sorting and Pagination
|
|
543
|
+
|
|
544
|
+
```python
|
|
545
|
+
def order_by_second_column_desc_limit_two(
|
|
546
|
+
sql: str,
|
|
547
|
+
params: tuple[Any, ...],
|
|
548
|
+
) -> tuple[str, tuple[Any, ...]]:
|
|
549
|
+
return f"{sql} ORDER BY 2 DESC LIMIT 2", params
|
|
550
|
+
```
|
|
551
|
+
|
|
552
|
+
Then attach it to any query:
|
|
553
|
+
|
|
554
|
+
```python
|
|
555
|
+
query, params = (
|
|
556
|
+
select(users.id, users.name)
|
|
557
|
+
.from_(users)
|
|
558
|
+
.compile_expression(order_by_second_column_desc_limit_two)
|
|
559
|
+
.compile()
|
|
560
|
+
)
|
|
561
|
+
```
|
|
562
|
+
|
|
563
|
+
### Built-in Compile Helpers
|
|
564
|
+
|
|
565
|
+
The library also exposes a few built-in compile-time helpers:
|
|
566
|
+
|
|
567
|
+
- `comment(text, hint=False)` prefixes the query with a comment
|
|
568
|
+
- `before_clause(clause, text, hint=False)` injects a comment before a clause
|
|
569
|
+
- `after_clause(clause, text, hint=False)` injects a comment after a clause
|
|
570
|
+
- `explain()` wraps the query in `EXPLAIN`
|
|
571
|
+
- `analyze()` wraps the query in `EXPLAIN ANALYZE`
|
|
572
|
+
|
|
573
|
+
## What To Remember
|
|
574
|
+
|
|
575
|
+
- `compile()` returns `(sql, params)`
|
|
576
|
+
- SQL identifiers are quoted with double quotes
|
|
577
|
+
- values are parameterized with placeholders
|
|
578
|
+
- query builders are chainable
|
|
579
|
+
- repeated calls to many methods merge rather than overwrite
|
|
580
|
+
- backend support still depends on the database you execute against
|