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.
@@ -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