sql_fusion 1.0.0__tar.gz → 1.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,17 @@
1
+ Metadata-Version: 2.3
2
+ Name: sql_fusion
3
+ Version: 1.1.0
4
+ Summary: Python query builder with a focus on composability and reusability.
5
+ Author: Mastermind-U
6
+ Author-email: Mastermind-U <rex49513@gmail.com>
7
+ Requires-Python: >=3.14
8
+ Project-URL: Homepage, https://github.com/Mastermind-U/sql_fusion
9
+ Project-URL: Documentation, https://github.com/Mastermind-U/sql_fusion/blob/main/README.md
10
+ Project-URL: Repository, https://github.com/Mastermind-U/sql_fusion.git
11
+ Project-URL: Source, https://github.com/Mastermind-U/sql_fusion
12
+ Project-URL: Issues, https://github.com/Mastermind-U/sql_fusion/issues
13
+ Description-Content-Type: text/markdown
14
+
1
15
  # SQL Fusion
2
16
 
3
17
  SQL Fusion is a lightweight, chainable SQL query builder for Python with zero dependencies.
@@ -17,6 +31,7 @@ That makes it easy to plug into your own connection layer.
17
31
 
18
32
  ## Table of Contents
19
33
 
34
+ - [Motivation](#motivation)
20
35
  - [What You Get](#what-you-get)
21
36
  - [Installation](#installation)
22
37
  - [Public API](#public-api)
@@ -30,6 +45,31 @@ That makes it easy to plug into your own connection layer.
30
45
  - [CTEs](#ctes)
31
46
  - [Custom Compile Expressions](#custom-compile-expressions)
32
47
  - [What To Remember](#what-to-remember)
48
+ - [Feature Comparison](#feature-comparison)
49
+
50
+ ## Motivation
51
+
52
+ SQL builders often look similar from the outside, but they make very different trade-offs in practice:
53
+
54
+ - some are template-driven and mainly render filter fragments
55
+ - some are lightweight CRUD helpers with a small API surface
56
+ - some are broad SQL toolkits with dialect systems and advanced composition features
57
+ - some keep SQL parameterized, while others render a finished SQL string directly
58
+
59
+ This README compares SQL Fusion with several other Python query builders so it is easier to see where the library fits and what it is intentionally optimized for.
60
+
61
+ ### Why SQL Fusion?
62
+
63
+ SQL Fusion is built for the middle ground:
64
+
65
+ - it stays small and chainable instead of turning into a full ORM
66
+ - it keeps SQL parameterized by default, so the caller controls execution safely
67
+ - it gives you SQL-like syntax inside Python, with type hints and a clean separation of clauses, inspired by SQLAlchemy Core but without the heavy machinery of a full expression system
68
+ - it supports real SQL building blocks like joins, subqueries, CTEs, and grouping helpers without forcing a dialect-specific API
69
+ - it adds automatic alias management so common queries stay readable even as they grow
70
+ - it exposes `compile_expression()` for the cases where the final SQL needs a backend-specific rewrite
71
+
72
+ In short, the goal is to keep the ergonomics of a lightweight builder while still covering the parts of SQL that matter in real applications.
33
73
 
34
74
  ## What You Get
35
75
 
@@ -37,13 +77,21 @@ That makes it easy to plug into your own connection layer.
37
77
  - automatic table aliases
38
78
  - composable conditions with `AND`, `OR`, and `NOT`
39
79
  - joins, subqueries, and CTEs
40
- - ordering, joins, subqueries, and CTEs
80
+ - ordering and grouping with `GROUP BY`, `ROLLUP`, `CUBE`, and `GROUPING SETS`
41
81
  - aggregate and custom SQL functions through `func`
42
82
  - backend-specific SQL rewrites through compile expressions
43
83
 
44
84
  ## Installation
45
85
 
46
86
  The project targets Python 3.14 or newer.
87
+ Install it from PyPI:
88
+
89
+ ```bash
90
+ pip install sql_fusion
91
+ ```
92
+ ```bash
93
+ uv add sql_fusion
94
+ ```
47
95
 
48
96
  For local development:
49
97
 
@@ -60,17 +108,30 @@ pip install -e .
60
108
  ## Public API
61
109
 
62
110
  ```python
63
- from sql_fusion import Alias, Table, delete, func, insert, select, update
111
+ from sql_fusion import (
112
+ Alias,
113
+ Column,
114
+ Table,
115
+ delete,
116
+ func,
117
+ insert,
118
+ select,
119
+ text,
120
+ update,
121
+ )
64
122
  ```
65
123
 
66
124
  ### Core Objects
67
125
 
68
126
  - `Table` represents a real table or a subquery.
127
+ - `Column` is the reusable column object used by `Table` when you want to
128
+ predeclare columns.
69
129
  - `select` creates a `SELECT` builder.
70
130
  - `insert` creates an `INSERT` builder.
71
131
  - `update` creates an `UPDATE` builder.
72
132
  - `delete` creates a `DELETE` builder.
73
133
  - `func` is a dynamic SQL function registry.
134
+ - `text_op` builds a condition with a raw SQL operator such as `@>`.
74
135
  - `Alias` represents a reusable SQL alias for aggregate expressions and
75
136
  `HAVING` conditions.
76
137
 
@@ -205,6 +266,26 @@ users = Table("users", alias="u")
205
266
  `Table` can also wrap a subquery. In practice, you usually pass a query
206
267
  builder directly to `from_()` or `join()`, and the library wraps it for you.
207
268
 
269
+ If you want explicit, hint-friendly columns on a table instance, pass them
270
+ when you create it:
271
+
272
+ ```python
273
+ from sql_fusion import Column, Table, select
274
+
275
+
276
+ users = Table(
277
+ "users",
278
+ Column("id"),
279
+ Column("name"),
280
+ )
281
+
282
+
283
+ query = select(users.id, users.name).from_(users)
284
+ ```
285
+
286
+ This style keeps the column list declared in one place and is verified at
287
+ runtime when you access `users.id` / `users.name`.
288
+
208
289
  ### Conditions
209
290
 
210
291
  Columns support the usual comparison operators:
@@ -222,6 +303,10 @@ They also support SQL helpers:
222
303
  - `.ilike(pattern)`
223
304
  - `.in_(values)`
224
305
  - `.not_in(values)`
306
+ - `text(column, operator, value)` for backend-specific operators such as
307
+ PostgreSQL array containment (`@>`).
308
+
309
+ Use `|` for SQL `OR`. Python's `or` cannot be overloaded for SQL expressions.
225
310
 
226
311
  Conditions can be combined with:
227
312
 
@@ -243,6 +328,19 @@ query = (
243
328
  )
244
329
  ```
245
330
 
331
+ For PostgreSQL-style array containment, `text()` lets you pass the operator
332
+ symbol directly:
333
+
334
+ ```python
335
+ users = Table("users", Column("name"), Column("tags"))
336
+
337
+ query = (
338
+ select(users.name)
339
+ .from_(users)
340
+ .where(users.name == "bob" or text_op(users.tags, "@>", ["coffee"]))
341
+ )
342
+ ```
343
+
246
344
  ### Join Example
247
345
 
248
346
  ```python
@@ -569,3 +667,31 @@ The library also exposes a few built-in compile-time helpers:
569
667
  - query builders are chainable
570
668
  - repeated calls to many methods merge rather than overwrite
571
669
  - backend support still depends on the database you execute against
670
+
671
+ ## Feature Comparison
672
+
673
+
674
+ | Project | Focus | SQL coverage | SQL injection protected | Automatic alias management | Advanced features | Dialect/output model | Takeaway |
675
+ | --- | --- | --- | --- | --- | --- | --- | --- |
676
+ | Py-QueryBuilder | Template-driven filter rendering | No direct CRUD builder; it renders a `WHERE` fragment into a Jinja template | Yes, via JinjaSQL qmark placeholders and a separate params list | No, subquery and join aliases are template-defined rather than auto-managed by the builder | Nested rule groups, operator mapping, field pruning | Jinja2 + JinjaSQL, SQL formatting | Best for UI-driven search forms, not for composing full statements |
677
+ | simple-query-builder-python | Small mutable CRUD helper | `SELECT`, `INSERT`, `UPDATE`, `DELETE` | Mostly yes, because execution uses `?` placeholders and a params tuple; `get_sql(with_values=True)` can inline values for display | No, subquery and join aliases are supplied manually in the input data | `JOIN`, `GROUP BY`, `HAVING`, `UNION`, `EXCEPT`, `INTERSECT`, `LIMIT`, `OFFSET` | SQLite-first, raw SQL string builder | Simple and approachable, but the SQL surface is modest |
678
+ | sqlquerybuilder | Django-ORM-style queryset wrapper | Basic read/write queries | No, it renders a ready SQL string with values embedded into the query text | No, subquery and join aliases are handled manually in query strings | Filters and excludes, joins, grouping, ordering, `extra()`, slicing, `with_nolock()` | SQLite-oriented, with SQL Server pagination branches in code | Convenient for ORM-like chaining, but not aimed at deep SQL composition |
679
+ | python-sql | Rich Pythonic SQL builder | `SELECT`, `INSERT`, `UPDATE`, `DELETE` | Yes, it keeps placeholders separate from args and can switch param styles via flavor | Partial, it can auto-alias tables and some subqueries, while join aliases are still often explicit | `JOIN`, subqueries, CTEs, `DISTINCT ON`, windows, `RETURNING`, `MERGE`, `UNION` / `INTERSECT` / `EXCEPT` | Dialect/flavor system with multiple param styles | Very broad SQL coverage and strong backend flexibility |
680
+ | PyPika | Mature fluent query builder | `SELECT`, `INSERT`, `UPDATE`, `DELETE` | No by default, it renders literal SQL strings with values injected into the output | Partial, it auto-aliases some subqueries and duplicate joins, but most table and join aliases are explicit | `JOIN`, subqueries, CTEs, set operations, analytics/window helpers, DDL support | Dialect-aware with vendor-specific extensions | One of the broadest and most extensible builders in the set |
681
+ | SQLFactory | General-purpose SQL builder | `SELECT`, `INSERT`, `UPDATE`, `DELETE` | Yes, it emits placeholders and keeps args separately | No, subquery and join aliases are mostly explicit and part of the statement shape | `JOIN`, subselects, CTEs, window functions, set operations, `INSERT ... SELECT`, MySQL-style duplicate-key handling | MySQL / SQLite / PostgreSQL / Oracle / custom dialects, async execution helpers | Full-featured and explicit, with a heavier API than lightweight builders |
682
+ | SQL Fusion | Lightweight chainable builder | `SELECT`, `INSERT`, `UPDATE`, `DELETE` | Yes, it returns `(sql, params)` and leaves binding to the caller | Yes, it auto-assigns stable table aliases and reuses them for subqueries and joins | `JOIN` variants including `CROSS`, `SEMI`, `ANTI`, subqueries, recursive CTEs, `ROLLUP`, `CUBE`, `GROUPING SETS`, functions, comments, `EXPLAIN` / `ANALYZE`, `DELETE RETURNING` | Backend-agnostic, `compile_expression()` hook for rewrites | Best when you want a compact, composable builder with post-processing hooks and no execution layer |
683
+
684
+ ## Syntax Comparison
685
+
686
+ The examples below are representative shapes, not copy-paste snippets for every library. Where a library exposes a `Table`
687
+ object, the snippet uses it.
688
+
689
+ | Project | Typical syntax shape |
690
+ | --- | --- |
691
+ | SQL Fusion | `users = Table("users"); orders = Table("orders"); select(users.id, users.name).from_(users).join(orders, users.id == orders.user_id).where(users.active == True).compile()` |
692
+ | PyPika | `users = Table("users"); orders = Table("orders"); Query.from_(users).join(orders).on(users.id == orders.user_id).select(users.id, users.name).where(users.active == True).get_sql()` |
693
+ | python-sql | `user = Table("users"); tuple(user.select(user.name, where=user.active == True))` |
694
+ | SQLFactory | `users = Table("users"); orders = Table("orders"); Select(users.id, users.name, table=users, join=[Join(orders, Eq("users.id", "orders.user_id"))]).where(Eq("users.active", True))` |
695
+ | simple-query-builder-python | `qb.select("users").where([["active", "=", True]]).join("orders", on=[["users.id", "=", "orders.user_id"]]).all()` |
696
+ | sqlquerybuilder | `Queryset("users").filter(active=True).join("orders", on="users.id=orders.user_id")` |
697
+ | Py-QueryBuilder | `QueryBuilder("app.users", filters).render("query.sql", query)` |
@@ -1,12 +1,3 @@
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
1
  # SQL Fusion
11
2
 
12
3
  SQL Fusion is a lightweight, chainable SQL query builder for Python with zero dependencies.
@@ -26,6 +17,7 @@ That makes it easy to plug into your own connection layer.
26
17
 
27
18
  ## Table of Contents
28
19
 
20
+ - [Motivation](#motivation)
29
21
  - [What You Get](#what-you-get)
30
22
  - [Installation](#installation)
31
23
  - [Public API](#public-api)
@@ -39,6 +31,31 @@ That makes it easy to plug into your own connection layer.
39
31
  - [CTEs](#ctes)
40
32
  - [Custom Compile Expressions](#custom-compile-expressions)
41
33
  - [What To Remember](#what-to-remember)
34
+ - [Feature Comparison](#feature-comparison)
35
+
36
+ ## Motivation
37
+
38
+ SQL builders often look similar from the outside, but they make very different trade-offs in practice:
39
+
40
+ - some are template-driven and mainly render filter fragments
41
+ - some are lightweight CRUD helpers with a small API surface
42
+ - some are broad SQL toolkits with dialect systems and advanced composition features
43
+ - some keep SQL parameterized, while others render a finished SQL string directly
44
+
45
+ This README compares SQL Fusion with several other Python query builders so it is easier to see where the library fits and what it is intentionally optimized for.
46
+
47
+ ### Why SQL Fusion?
48
+
49
+ SQL Fusion is built for the middle ground:
50
+
51
+ - it stays small and chainable instead of turning into a full ORM
52
+ - it keeps SQL parameterized by default, so the caller controls execution safely
53
+ - it gives you SQL-like syntax inside Python, with type hints and a clean separation of clauses, inspired by SQLAlchemy Core but without the heavy machinery of a full expression system
54
+ - it supports real SQL building blocks like joins, subqueries, CTEs, and grouping helpers without forcing a dialect-specific API
55
+ - it adds automatic alias management so common queries stay readable even as they grow
56
+ - it exposes `compile_expression()` for the cases where the final SQL needs a backend-specific rewrite
57
+
58
+ In short, the goal is to keep the ergonomics of a lightweight builder while still covering the parts of SQL that matter in real applications.
42
59
 
43
60
  ## What You Get
44
61
 
@@ -46,13 +63,21 @@ That makes it easy to plug into your own connection layer.
46
63
  - automatic table aliases
47
64
  - composable conditions with `AND`, `OR`, and `NOT`
48
65
  - joins, subqueries, and CTEs
49
- - ordering, joins, subqueries, and CTEs
66
+ - ordering and grouping with `GROUP BY`, `ROLLUP`, `CUBE`, and `GROUPING SETS`
50
67
  - aggregate and custom SQL functions through `func`
51
68
  - backend-specific SQL rewrites through compile expressions
52
69
 
53
70
  ## Installation
54
71
 
55
72
  The project targets Python 3.14 or newer.
73
+ Install it from PyPI:
74
+
75
+ ```bash
76
+ pip install sql_fusion
77
+ ```
78
+ ```bash
79
+ uv add sql_fusion
80
+ ```
56
81
 
57
82
  For local development:
58
83
 
@@ -69,17 +94,30 @@ pip install -e .
69
94
  ## Public API
70
95
 
71
96
  ```python
72
- from sql_fusion import Alias, Table, delete, func, insert, select, update
97
+ from sql_fusion import (
98
+ Alias,
99
+ Column,
100
+ Table,
101
+ delete,
102
+ func,
103
+ insert,
104
+ select,
105
+ text,
106
+ update,
107
+ )
73
108
  ```
74
109
 
75
110
  ### Core Objects
76
111
 
77
112
  - `Table` represents a real table or a subquery.
113
+ - `Column` is the reusable column object used by `Table` when you want to
114
+ predeclare columns.
78
115
  - `select` creates a `SELECT` builder.
79
116
  - `insert` creates an `INSERT` builder.
80
117
  - `update` creates an `UPDATE` builder.
81
118
  - `delete` creates a `DELETE` builder.
82
119
  - `func` is a dynamic SQL function registry.
120
+ - `text_op` builds a condition with a raw SQL operator such as `@>`.
83
121
  - `Alias` represents a reusable SQL alias for aggregate expressions and
84
122
  `HAVING` conditions.
85
123
 
@@ -214,6 +252,26 @@ users = Table("users", alias="u")
214
252
  `Table` can also wrap a subquery. In practice, you usually pass a query
215
253
  builder directly to `from_()` or `join()`, and the library wraps it for you.
216
254
 
255
+ If you want explicit, hint-friendly columns on a table instance, pass them
256
+ when you create it:
257
+
258
+ ```python
259
+ from sql_fusion import Column, Table, select
260
+
261
+
262
+ users = Table(
263
+ "users",
264
+ Column("id"),
265
+ Column("name"),
266
+ )
267
+
268
+
269
+ query = select(users.id, users.name).from_(users)
270
+ ```
271
+
272
+ This style keeps the column list declared in one place and is verified at
273
+ runtime when you access `users.id` / `users.name`.
274
+
217
275
  ### Conditions
218
276
 
219
277
  Columns support the usual comparison operators:
@@ -231,6 +289,10 @@ They also support SQL helpers:
231
289
  - `.ilike(pattern)`
232
290
  - `.in_(values)`
233
291
  - `.not_in(values)`
292
+ - `text(column, operator, value)` for backend-specific operators such as
293
+ PostgreSQL array containment (`@>`).
294
+
295
+ Use `|` for SQL `OR`. Python's `or` cannot be overloaded for SQL expressions.
234
296
 
235
297
  Conditions can be combined with:
236
298
 
@@ -252,6 +314,19 @@ query = (
252
314
  )
253
315
  ```
254
316
 
317
+ For PostgreSQL-style array containment, `text()` lets you pass the operator
318
+ symbol directly:
319
+
320
+ ```python
321
+ users = Table("users", Column("name"), Column("tags"))
322
+
323
+ query = (
324
+ select(users.name)
325
+ .from_(users)
326
+ .where(users.name == "bob" or text_op(users.tags, "@>", ["coffee"]))
327
+ )
328
+ ```
329
+
255
330
  ### Join Example
256
331
 
257
332
  ```python
@@ -578,3 +653,31 @@ The library also exposes a few built-in compile-time helpers:
578
653
  - query builders are chainable
579
654
  - repeated calls to many methods merge rather than overwrite
580
655
  - backend support still depends on the database you execute against
656
+
657
+ ## Feature Comparison
658
+
659
+
660
+ | Project | Focus | SQL coverage | SQL injection protected | Automatic alias management | Advanced features | Dialect/output model | Takeaway |
661
+ | --- | --- | --- | --- | --- | --- | --- | --- |
662
+ | Py-QueryBuilder | Template-driven filter rendering | No direct CRUD builder; it renders a `WHERE` fragment into a Jinja template | Yes, via JinjaSQL qmark placeholders and a separate params list | No, subquery and join aliases are template-defined rather than auto-managed by the builder | Nested rule groups, operator mapping, field pruning | Jinja2 + JinjaSQL, SQL formatting | Best for UI-driven search forms, not for composing full statements |
663
+ | simple-query-builder-python | Small mutable CRUD helper | `SELECT`, `INSERT`, `UPDATE`, `DELETE` | Mostly yes, because execution uses `?` placeholders and a params tuple; `get_sql(with_values=True)` can inline values for display | No, subquery and join aliases are supplied manually in the input data | `JOIN`, `GROUP BY`, `HAVING`, `UNION`, `EXCEPT`, `INTERSECT`, `LIMIT`, `OFFSET` | SQLite-first, raw SQL string builder | Simple and approachable, but the SQL surface is modest |
664
+ | sqlquerybuilder | Django-ORM-style queryset wrapper | Basic read/write queries | No, it renders a ready SQL string with values embedded into the query text | No, subquery and join aliases are handled manually in query strings | Filters and excludes, joins, grouping, ordering, `extra()`, slicing, `with_nolock()` | SQLite-oriented, with SQL Server pagination branches in code | Convenient for ORM-like chaining, but not aimed at deep SQL composition |
665
+ | python-sql | Rich Pythonic SQL builder | `SELECT`, `INSERT`, `UPDATE`, `DELETE` | Yes, it keeps placeholders separate from args and can switch param styles via flavor | Partial, it can auto-alias tables and some subqueries, while join aliases are still often explicit | `JOIN`, subqueries, CTEs, `DISTINCT ON`, windows, `RETURNING`, `MERGE`, `UNION` / `INTERSECT` / `EXCEPT` | Dialect/flavor system with multiple param styles | Very broad SQL coverage and strong backend flexibility |
666
+ | PyPika | Mature fluent query builder | `SELECT`, `INSERT`, `UPDATE`, `DELETE` | No by default, it renders literal SQL strings with values injected into the output | Partial, it auto-aliases some subqueries and duplicate joins, but most table and join aliases are explicit | `JOIN`, subqueries, CTEs, set operations, analytics/window helpers, DDL support | Dialect-aware with vendor-specific extensions | One of the broadest and most extensible builders in the set |
667
+ | SQLFactory | General-purpose SQL builder | `SELECT`, `INSERT`, `UPDATE`, `DELETE` | Yes, it emits placeholders and keeps args separately | No, subquery and join aliases are mostly explicit and part of the statement shape | `JOIN`, subselects, CTEs, window functions, set operations, `INSERT ... SELECT`, MySQL-style duplicate-key handling | MySQL / SQLite / PostgreSQL / Oracle / custom dialects, async execution helpers | Full-featured and explicit, with a heavier API than lightweight builders |
668
+ | SQL Fusion | Lightweight chainable builder | `SELECT`, `INSERT`, `UPDATE`, `DELETE` | Yes, it returns `(sql, params)` and leaves binding to the caller | Yes, it auto-assigns stable table aliases and reuses them for subqueries and joins | `JOIN` variants including `CROSS`, `SEMI`, `ANTI`, subqueries, recursive CTEs, `ROLLUP`, `CUBE`, `GROUPING SETS`, functions, comments, `EXPLAIN` / `ANALYZE`, `DELETE RETURNING` | Backend-agnostic, `compile_expression()` hook for rewrites | Best when you want a compact, composable builder with post-processing hooks and no execution layer |
669
+
670
+ ## Syntax Comparison
671
+
672
+ The examples below are representative shapes, not copy-paste snippets for every library. Where a library exposes a `Table`
673
+ object, the snippet uses it.
674
+
675
+ | Project | Typical syntax shape |
676
+ | --- | --- |
677
+ | SQL Fusion | `users = Table("users"); orders = Table("orders"); select(users.id, users.name).from_(users).join(orders, users.id == orders.user_id).where(users.active == True).compile()` |
678
+ | PyPika | `users = Table("users"); orders = Table("orders"); Query.from_(users).join(orders).on(users.id == orders.user_id).select(users.id, users.name).where(users.active == True).get_sql()` |
679
+ | python-sql | `user = Table("users"); tuple(user.select(user.name, where=user.active == True))` |
680
+ | SQLFactory | `users = Table("users"); orders = Table("orders"); Select(users.id, users.name, table=users, join=[Join(orders, Eq("users.id", "orders.user_id"))]).where(Eq("users.active", True))` |
681
+ | simple-query-builder-python | `qb.select("users").where([["active", "=", True]]).join("orders", on=[["users.id", "=", "orders.user_id"]]).all()` |
682
+ | sqlquerybuilder | `Queryset("users").filter(active=True).join("orders", on="users.id=orders.user_id")` |
683
+ | Py-QueryBuilder | `QueryBuilder("app.users", filters).render("query.sql", query)` |
@@ -1,14 +1,21 @@
1
1
  [project]
2
2
  name = "sql_fusion"
3
- version = "1.0.0"
4
- description = "Add your description here"
3
+ version = "1.1.0"
4
+ description = "Python query builder with a focus on composability and reusability."
5
5
  readme = "README.md"
6
6
  authors = [{ name = "Mastermind-U", email = "rex49513@gmail.com" }]
7
7
  requires-python = ">=3.14"
8
8
  dependencies = []
9
9
 
10
+ [project.urls]
11
+ Homepage = "https://github.com/Mastermind-U/sql_fusion"
12
+ Documentation = "https://github.com/Mastermind-U/sql_fusion/blob/main/README.md"
13
+ Repository = "https://github.com/Mastermind-U/sql_fusion.git"
14
+ Source = "https://github.com/Mastermind-U/sql_fusion"
15
+ Issues = "https://github.com/Mastermind-U/sql_fusion/issues"
16
+
10
17
  [project.scripts]
11
- sql_fuser = "sql_fuser:main"
18
+ sql_fusion = "sql_fusion:main"
12
19
 
13
20
  [build-system]
14
21
  requires = ["uv_build>=0.11.1,<0.12.0"]
@@ -125,6 +132,7 @@ ignore-variadic-names = true
125
132
 
126
133
  [tool.ruff.lint.per-file-ignores]
127
134
  "tests/*.py" = ["S101", "PLR2004"]
135
+ "examples/*.py" = ["S101", "T201"]
128
136
 
129
137
  [tool.ruff.lint.mccabe]
130
138
  max-complexity = 15
@@ -1,4 +1,4 @@
1
- from .composite_table import Alias, Table, func
1
+ from .composite_table import Alias, Column, Table, func, text_op
2
2
  from .query.delete import delete
3
3
  from .query.insert import insert
4
4
  from .query.select import select
@@ -6,10 +6,12 @@ from .query.update import update
6
6
 
7
7
  __all__ = [
8
8
  "Alias",
9
+ "Column",
9
10
  "Table",
10
11
  "delete",
11
12
  "func",
12
13
  "insert",
13
14
  "select",
15
+ "text_op",
14
16
  "update",
15
17
  ]
@@ -1,3 +1,4 @@
1
+ from collections.abc import Iterable
1
2
  from copy import copy
2
3
  from typing import Any, Callable, Self
3
4
 
@@ -13,12 +14,14 @@ from sql_fusion.operators import (
13
14
  LikeOperator,
14
15
  NotEqualOperator,
15
16
  NotInOperator,
17
+ TextOperator,
16
18
  )
17
19
 
18
20
  CompileExpression = Callable[
19
21
  [str, tuple[Any, ...]],
20
22
  tuple[str, tuple[Any, ...]],
21
23
  ]
24
+ OperatorFactory = Callable[[str], AbstractOperator]
22
25
 
23
26
 
24
27
  class AliasRegistry:
@@ -303,7 +306,7 @@ class AbstractQuery:
303
306
  class ComparableExpression:
304
307
  def _cond(
305
308
  self,
306
- operator: type[AbstractOperator],
309
+ operator: type[AbstractOperator] | OperatorFactory,
307
310
  other: object,
308
311
  ) -> Condition:
309
312
  return Condition(column=self, operator=operator, value=other)
@@ -412,7 +415,9 @@ class Condition:
412
415
  def __init__( # noqa: PLR0913
413
416
  self,
414
417
  column: ComparableExpression | FunctionCall | None = None,
415
- operator: type[AbstractOperator] | None = None,
418
+ operator: (
419
+ type[AbstractOperator] | AbstractOperator | OperatorFactory | None
420
+ ) = None,
416
421
  value: object | None = None,
417
422
  is_and: bool = True,
418
423
  left: Condition | None = None,
@@ -420,7 +425,9 @@ class Condition:
420
425
  negated: bool = False,
421
426
  ) -> None:
422
427
  self.column: ComparableExpression | FunctionCall | None = column
423
- self.operator: type[AbstractOperator] | None = operator
428
+ self.operator: (
429
+ type[AbstractOperator] | AbstractOperator | OperatorFactory | None
430
+ ) = operator
424
431
  self.value: object | None = value
425
432
  self.is_and: bool = is_and
426
433
  self.left: Condition | None = left
@@ -436,6 +443,15 @@ class Condition:
436
443
  return value.to_sql(alias_registry)
437
444
  return value.get_ref(alias_registry), tuple()
438
445
 
446
+ @staticmethod
447
+ def _resolve_operator(
448
+ operator: type[AbstractOperator] | AbstractOperator | OperatorFactory,
449
+ col_ref: str,
450
+ ) -> AbstractOperator:
451
+ if isinstance(operator, AbstractOperator):
452
+ return operator
453
+ return operator(col_ref)
454
+
439
455
  def __and__(self, other: Condition) -> Condition:
440
456
  return Condition(is_and=True, left=self, right=other)
441
457
 
@@ -475,28 +491,30 @@ class Condition:
475
491
  self.column,
476
492
  alias_registry,
477
493
  )
478
- operator_class = self.operator
479
- if operator_class is None:
494
+ operator_spec = self.operator
495
+ if operator_spec is None:
480
496
  return apply_negation(col_ref, col_params)
481
497
 
498
+ operator = self._resolve_operator(operator_spec, col_ref)
499
+
482
500
  if isinstance(self.value, (ComparableExpression, FunctionCall)):
483
501
  value_sql, value_params = self._render_expression(
484
502
  self.value,
485
503
  alias_registry,
486
504
  )
487
- sql, op_params = operator_class(col_ref).to_sql_ref(value_sql)
505
+ sql, op_params = operator.to_sql_ref(value_sql)
488
506
  return apply_negation(sql, col_params + value_params + op_params)
489
507
 
490
508
  if isinstance(self.value, AbstractQuery):
491
509
  subquery_sql, subquery_params = self.value.build_query(
492
510
  alias_registry,
493
511
  )
512
+ sql, op_params = operator.to_sql_ref(subquery_sql)
494
513
  return apply_negation(
495
- f"{col_ref} {operator_class.sql_symbol} ({subquery_sql})",
496
- col_params + subquery_params,
514
+ sql, col_params + subquery_params + op_params
497
515
  )
498
516
 
499
- sql, op_params = operator_class(col_ref).to_sql(self.value)
517
+ sql, op_params = operator.to_sql(self.value)
500
518
  return apply_negation(sql, col_params + op_params)
501
519
 
502
520
 
@@ -624,6 +642,18 @@ class FunctionRegistry:
624
642
  func = FunctionRegistry()
625
643
 
626
644
 
645
+ def text_op(
646
+ column: ComparableExpression | FunctionCall,
647
+ operator: str,
648
+ value: object,
649
+ ) -> Condition:
650
+ return Condition(
651
+ column=column,
652
+ operator=lambda col_ref: TextOperator(col_ref, operator),
653
+ value=value,
654
+ )
655
+
656
+
627
657
  class Column(ComparableExpression):
628
658
  def __init__(self, name: str) -> None:
629
659
  self.name: str = name
@@ -652,15 +682,22 @@ class Table:
652
682
  def __init__(
653
683
  self,
654
684
  name: str | AbstractQuery,
685
+ *columns: Column,
655
686
  ) -> None:
656
687
  self._table_name: str = ""
657
688
  self._subquery: AbstractQuery | None = None
689
+ self.columns: dict[str, Column] = {}
658
690
 
659
691
  if isinstance(name, AbstractQuery):
660
692
  self._subquery = name
661
693
  else:
662
694
  self._table_name = name
663
695
 
696
+ if columns:
697
+ for col in columns:
698
+ col._attach_table(self) # pyright: ignore[reportPrivateUsage]
699
+ self.columns[col.name] = col
700
+
664
701
  def get_name(self) -> str:
665
702
  if self._subquery is not None:
666
703
  raise ValueError("Table is a subquery, no name available")
@@ -678,12 +715,22 @@ class Table:
678
715
 
679
716
  return f'"{self._table_name}"', tuple()
680
717
 
718
+ def __dir__(self) -> Iterable[str]:
719
+ default_dir = super().__dir__()
720
+ cols = list(self.columns.keys())
721
+ cols.extend(default_dir)
722
+ return cols
723
+
681
724
  def __getattr__(self, column_name: str) -> Column:
682
725
  if column_name.startswith("_"):
683
726
  raise AttributeError(
684
727
  f"'{type(self).__name__}' "
685
728
  f"object has no attribute '{column_name}'",
686
729
  )
730
+
731
+ if self.columns:
732
+ return self.columns[column_name]
733
+
687
734
  column = Column(column_name)
688
735
  column._attach_table(self) # pyright: ignore[reportPrivateUsage]
689
736
  return column
@@ -1,6 +1,4 @@
1
- from typing import Any, Callable
2
-
3
- OPERATORS: dict[str, type[AbstractOperator]] = {}
1
+ from typing import Any
4
2
 
5
3
 
6
4
  class AbstractOperator:
@@ -16,18 +14,6 @@ class AbstractOperator:
16
14
  raise NotImplementedError()
17
15
 
18
16
 
19
- def register_operator(
20
- symbol: str,
21
- ) -> Callable[[type[AbstractOperator]], type[AbstractOperator]]:
22
- def decorator(cls: type[AbstractOperator]) -> type[AbstractOperator]:
23
- OPERATORS[symbol] = cls
24
- cls.sql_symbol = symbol
25
- return cls
26
-
27
- return decorator
28
-
29
-
30
- @register_operator("=")
31
17
  class EqualOperator(AbstractOperator):
32
18
  def to_sql(self, value: Any) -> tuple[str, tuple[Any, ...]]:
33
19
  return f"{self._col_ref} = ?", (value,)
@@ -36,7 +22,6 @@ class EqualOperator(AbstractOperator):
36
22
  return f"{self._col_ref} = {value_ref}", tuple()
37
23
 
38
24
 
39
- @register_operator("!=")
40
25
  class NotEqualOperator(AbstractOperator):
41
26
  def to_sql(self, value: Any) -> tuple[str, tuple[Any, ...]]:
42
27
  return f"{self._col_ref} != ?", (value,)
@@ -45,7 +30,6 @@ class NotEqualOperator(AbstractOperator):
45
30
  return f"{self._col_ref} != {value_ref}", tuple()
46
31
 
47
32
 
48
- @register_operator("<")
49
33
  class LessThanOperator(AbstractOperator):
50
34
  def to_sql(self, value: Any) -> tuple[str, tuple[Any, ...]]:
51
35
  return f"{self._col_ref} < ?", (value,)
@@ -54,7 +38,6 @@ class LessThanOperator(AbstractOperator):
54
38
  return f"{self._col_ref} < {value_ref}", tuple()
55
39
 
56
40
 
57
- @register_operator(">")
58
41
  class GreaterThanOperator(AbstractOperator):
59
42
  def to_sql(self, value: Any) -> tuple[str, tuple[Any, ...]]:
60
43
  return f"{self._col_ref} > ?", (value,)
@@ -63,7 +46,6 @@ class GreaterThanOperator(AbstractOperator):
63
46
  return f"{self._col_ref} > {value_ref}", tuple()
64
47
 
65
48
 
66
- @register_operator("<=")
67
49
  class LessThanOrEqualOperator(AbstractOperator):
68
50
  def to_sql(self, value: Any) -> tuple[str, tuple[Any, ...]]:
69
51
  return f"{self._col_ref} <= ?", (value,)
@@ -72,7 +54,6 @@ class LessThanOrEqualOperator(AbstractOperator):
72
54
  return f"{self._col_ref} <= {value_ref}", tuple()
73
55
 
74
56
 
75
- @register_operator(">=")
76
57
  class GreaterThanOrEqualOperator(AbstractOperator):
77
58
  def to_sql(self, value: Any) -> tuple[str, tuple[Any, ...]]:
78
59
  return f"{self._col_ref} >= ?", (value,)
@@ -81,7 +62,6 @@ class GreaterThanOrEqualOperator(AbstractOperator):
81
62
  return f"{self._col_ref} >= {value_ref}", tuple()
82
63
 
83
64
 
84
- @register_operator("LIKE")
85
65
  class LikeOperator(AbstractOperator):
86
66
  def to_sql(self, value: Any) -> tuple[str, tuple[Any, ...]]:
87
67
  return f"{self._col_ref} LIKE ?", (value,)
@@ -90,7 +70,6 @@ class LikeOperator(AbstractOperator):
90
70
  return f"{self._col_ref} LIKE {value_ref}", tuple()
91
71
 
92
72
 
93
- @register_operator("ILIKE")
94
73
  class IlikeOperator(AbstractOperator):
95
74
  def to_sql(self, value: Any) -> tuple[str, tuple[Any, ...]]:
96
75
  return f"{self._col_ref} ILIKE ?", (value,)
@@ -99,7 +78,6 @@ class IlikeOperator(AbstractOperator):
99
78
  return f"{self._col_ref} ILIKE {value_ref}", tuple()
100
79
 
101
80
 
102
- @register_operator("IN")
103
81
  class InOperator(AbstractOperator):
104
82
  def to_sql(self, value: Any) -> tuple[str, tuple[Any, ...]]:
105
83
  placeholders: str = ", ".join("?" * len(value))
@@ -109,7 +87,6 @@ class InOperator(AbstractOperator):
109
87
  return f"{self._col_ref} IN ({value_ref})", tuple()
110
88
 
111
89
 
112
- @register_operator("NOT IN")
113
90
  class NotInOperator(AbstractOperator):
114
91
  def to_sql(self, value: Any) -> tuple[str, tuple[Any, ...]]:
115
92
  placeholders: str = ", ".join("?" * len(value))
@@ -117,3 +94,15 @@ class NotInOperator(AbstractOperator):
117
94
 
118
95
  def to_sql_ref(self, value_ref: str) -> tuple[str, tuple[Any, ...]]:
119
96
  return f"{self._col_ref} NOT IN ({value_ref})", tuple()
97
+
98
+
99
+ class TextOperator(AbstractOperator):
100
+ def __init__(self, col_ref: str, sql_symbol: str) -> None:
101
+ super().__init__(col_ref)
102
+ self.sql_symbol: str = sql_symbol
103
+
104
+ def to_sql(self, value: Any) -> tuple[str, tuple[Any, ...]]:
105
+ return f"{self._col_ref} {self.sql_symbol} ?", (value,)
106
+
107
+ def to_sql_ref(self, value_ref: str) -> tuple[str, tuple[Any, ...]]:
108
+ return f"{self._col_ref} {self.sql_symbol} {value_ref}", tuple()