sql_querystring_builder 0.1.1__tar.gz → 0.3.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_querystring_builder-0.1.1/src/sql_querystring_builder.egg-info → sql_querystring_builder-0.3.0}/PKG-INFO +71 -12
- sql_querystring_builder-0.3.0/README.md +193 -0
- {sql_querystring_builder-0.1.1 → sql_querystring_builder-0.3.0}/pyproject.toml +2 -1
- sql_querystring_builder-0.3.0/src/sql_querystring_builder/__init__.py +30 -0
- sql_querystring_builder-0.3.0/src/sql_querystring_builder/clauses.py +273 -0
- sql_querystring_builder-0.3.0/src/sql_querystring_builder/conditions.py +194 -0
- sql_querystring_builder-0.3.0/src/sql_querystring_builder/exceptions.py +2 -0
- sql_querystring_builder-0.3.0/src/sql_querystring_builder/query_builder.py +67 -0
- {sql_querystring_builder-0.1.1 → sql_querystring_builder-0.3.0}/src/sql_querystring_builder/tables.py +91 -31
- sql_querystring_builder-0.3.0/src/sql_querystring_builder/values.py +139 -0
- {sql_querystring_builder-0.1.1 → sql_querystring_builder-0.3.0/src/sql_querystring_builder.egg-info}/PKG-INFO +71 -12
- {sql_querystring_builder-0.1.1 → sql_querystring_builder-0.3.0}/src/sql_querystring_builder.egg-info/SOURCES.txt +5 -0
- {sql_querystring_builder-0.1.1 → sql_querystring_builder-0.3.0}/tests/test_characterization.py +60 -11
- sql_querystring_builder-0.3.0/tests/test_clauses.py +311 -0
- sql_querystring_builder-0.3.0/tests/test_conditions.py +258 -0
- sql_querystring_builder-0.3.0/tests/test_queries.py +159 -0
- sql_querystring_builder-0.3.0/tests/test_sql_syntax.py +153 -0
- {sql_querystring_builder-0.1.1 → sql_querystring_builder-0.3.0}/tests/test_tables.py +34 -0
- sql_querystring_builder-0.1.1/README.md +0 -134
- sql_querystring_builder-0.1.1/src/sql_querystring_builder/__init__.py +0 -3
- sql_querystring_builder-0.1.1/src/sql_querystring_builder/clauses.py +0 -153
- sql_querystring_builder-0.1.1/src/sql_querystring_builder/query_builder.py +0 -44
- sql_querystring_builder-0.1.1/tests/test_clauses.py +0 -74
- sql_querystring_builder-0.1.1/tests/test_queries.py +0 -47
- {sql_querystring_builder-0.1.1 → sql_querystring_builder-0.3.0}/LICENSE +0 -0
- {sql_querystring_builder-0.1.1 → sql_querystring_builder-0.3.0}/setup.cfg +0 -0
- {sql_querystring_builder-0.1.1 → sql_querystring_builder-0.3.0}/src/sql_querystring_builder/abstract_clause.py +0 -0
- {sql_querystring_builder-0.1.1 → sql_querystring_builder-0.3.0}/src/sql_querystring_builder/py.typed +0 -0
- {sql_querystring_builder-0.1.1 → sql_querystring_builder-0.3.0}/src/sql_querystring_builder.egg-info/dependency_links.txt +0 -0
- {sql_querystring_builder-0.1.1 → sql_querystring_builder-0.3.0}/src/sql_querystring_builder.egg-info/top_level.txt +0 -0
- {sql_querystring_builder-0.1.1 → sql_querystring_builder-0.3.0}/tests/test_readme.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sql_querystring_builder
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Wraps string building of MS SQL queries
|
|
5
5
|
Author-email: Wael GRIBAA <g.wael@outlook.fr>
|
|
6
6
|
License-Expression: GPL-3.0-only
|
|
@@ -30,25 +30,26 @@ It provides first level validation.
|
|
|
30
30
|
- [x] Field
|
|
31
31
|
- [x] Function on field
|
|
32
32
|
- [x] String
|
|
33
|
+
- [x] Conditions: eq, ne, lt, le, gt, ge, like, in_, between, is_null, is_not_null, combined with & | ~
|
|
34
|
+
- [x] Values: text, numbers, booleans, None, dates and times, bytes, UUIDs, and Raw SQL
|
|
33
35
|
2. Using:
|
|
34
|
-
- [x] Select
|
|
35
|
-
- [x] Update
|
|
36
|
+
- [x] Select (with DISTINCT and TOP)
|
|
37
|
+
- [x] Update (with typed values)
|
|
36
38
|
- [x] From
|
|
37
|
-
- [x] InnerJoin
|
|
38
|
-
- [x] Where
|
|
39
|
-
- [x] OrderBy
|
|
39
|
+
- [x] InnerJoin, LeftJoin, RightJoin, FullJoin (on two fields or on any condition), CrossJoin
|
|
40
|
+
- [x] Where (with conditions)
|
|
41
|
+
- [x] OrderBy (with OFFSET/FETCH paging)
|
|
40
42
|
3. Handling automatically:
|
|
41
43
|
- [x] Table aliases
|
|
42
44
|
- [x] Field aliases
|
|
45
|
+
- [x] Escaped values: 'O''Brien', N'café'
|
|
46
|
+
- [x] Parameterized queries: build_with_params() writes ? (pyodbc) or %s (pymssql) and returns the values
|
|
43
47
|
|
|
44
48
|
## Not implemented (yet)
|
|
45
49
|
In planned order:
|
|
46
|
-
- [ ] LEFT, RIGHT, FULL and CROSS joins; SELECT DISTINCT and TOP; paging (OFFSET/FETCH)
|
|
47
|
-
- [ ] Proper conditions: typed values, AND/OR/NOT, IN, BETWEEN, LIKE, IS NULL
|
|
48
50
|
- [ ] Insert and Delete
|
|
49
51
|
- [ ] Functions (aggregates and others), GROUP BY, HAVING, CASE
|
|
50
52
|
- [ ] Compound queries (UNION, INTERSECT, EXCEPT) and sub-queries
|
|
51
|
-
- [ ] Parameterized queries
|
|
52
53
|
|
|
53
54
|
## What it does not do
|
|
54
55
|
Connect to a database server and run the queries by itself.
|
|
@@ -73,8 +74,8 @@ atemptable = select.to_temptable("a_temp_table")
|
|
|
73
74
|
innerjoin = InnerJoin(accounts_table, accounts_table.fields.customerId, customers_table)
|
|
74
75
|
# Defines an Order By a defined field
|
|
75
76
|
orderby = OrderBy(customers_table.fields.customerId, descending=True)
|
|
76
|
-
# Adds a Where filter
|
|
77
|
-
where = Where(accounts_table.fields.accountId
|
|
77
|
+
# Adds a Where filter: conditions are methods of fields, combined with & (and_), | (or_) and ~ (not_)
|
|
78
|
+
where = Where(accounts_table.fields.accountId.gt(20000) & customers_table.fields.customerId.like("A1C%"))
|
|
78
79
|
```
|
|
79
80
|
2. Inspect what those elements are made of:
|
|
80
81
|
```
|
|
@@ -97,7 +98,7 @@ print(query_str)
|
|
|
97
98
|
# > INTO #a_temp_table
|
|
98
99
|
# > FROM Customers
|
|
99
100
|
# > INNER JOIN Accounts acc on acc.customerId = Customers.customerId
|
|
100
|
-
# > WHERE acc.accountId
|
|
101
|
+
# > WHERE acc.accountId > 20000 AND Customers.customerId LIKE 'A1C%'
|
|
101
102
|
# > ORDER BY cid DESC
|
|
102
103
|
```
|
|
103
104
|
4. Manipulate temporary tables whose components are inferred:
|
|
@@ -118,6 +119,21 @@ print(QueryBuilder(
|
|
|
118
119
|
# > SELECT #a_temp_table.id, #a_temp_table.cid, #a_temp_table.mainPhone, #a_temp_table.accountId
|
|
119
120
|
# > FROM #a_temp_table
|
|
120
121
|
```
|
|
122
|
+
5. Send the values as parameters, so that they never enter the SQL text:
|
|
123
|
+
```
|
|
124
|
+
# Builds the same query with a placeholder for each value, and returns the values in order
|
|
125
|
+
sql, params = query_builder.build_with_params()
|
|
126
|
+
print(sql)
|
|
127
|
+
# > SELECT Customers.id, Customers.customerId AS cid, Customers.mainPhone, acc.accountId
|
|
128
|
+
# > INTO #a_temp_table
|
|
129
|
+
# > FROM Customers
|
|
130
|
+
# > INNER JOIN Accounts acc on acc.customerId = Customers.customerId
|
|
131
|
+
# > WHERE acc.accountId > ? AND Customers.customerId LIKE ?
|
|
132
|
+
# > ORDER BY cid DESC
|
|
133
|
+
print(params)
|
|
134
|
+
# > (20000, 'A1C%')
|
|
135
|
+
# With pyodbc: cursor.execute(sql, params). For pymssql, build_with_params("format") writes %s instead.
|
|
136
|
+
```
|
|
121
137
|
|
|
122
138
|
(Find it directly in the module example.py)
|
|
123
139
|
|
|
@@ -128,6 +144,49 @@ Python 3.10 and above.
|
|
|
128
144
|
(None)
|
|
129
145
|
|
|
130
146
|
## Changelog
|
|
147
|
+
### 0.3.0
|
|
148
|
+
Queries built with 0.2.0 produce the same SQL, except for the fixed `Update` values below.
|
|
149
|
+
- Conditions are methods of fields, functions on fields and strings: `eq`, `ne`, `lt`, `le`, `gt`, `ge`,
|
|
150
|
+
`like`, `in_`, `between`, `is_null` and `is_not_null`; `eq(None)` renders `IS NULL`. Combine them with
|
|
151
|
+
`&`, `|` and `~`, or with `and_()`, `or_()` and `not_()`; parentheses are added where T-SQL needs them.
|
|
152
|
+
Python's `and`, `or` and `not` raise `TypeError`, since `c1 and c2` would silently keep only `c2`.
|
|
153
|
+
- Values are escaped: text becomes `'O''Brien'`, or `N'café'` when it is not ASCII (`NStr("...")` forces
|
|
154
|
+
`N'...'`). Numbers, `True`/`False` (1/0), `None` (`NULL`), dates (`'20261011'`), datetimes and times
|
|
155
|
+
(ISO), bytes (`0x...`) and UUIDs are supported; `Raw("GETDATE()")` is written verbatim.
|
|
156
|
+
- `Where(condition)`, also next to raw strings, and `on=condition` on every join, for multi-column joins.
|
|
157
|
+
- `QueryBuilder.build_with_params()` returns the SQL with `?` placeholders and the values, for
|
|
158
|
+
`cursor.execute(sql, params)` with pyodbc; `build_with_params("format")` writes `%s` for pymssql.
|
|
159
|
+
- `Update` values: a plain string is still raw SQL (`{f: "'x'"}`), `Str("O'Brien")` is an escaped string,
|
|
160
|
+
and other values render as in conditions.
|
|
161
|
+
- New top-level names: `Condition`, `Raw`, `Str` and `NStr`.
|
|
162
|
+
|
|
163
|
+
Fixed:
|
|
164
|
+
- `Update` values `None`, `True`/`False`, dates and fields rendered invalid or wrong SQL: `f=None`,
|
|
165
|
+
`f=True`, `f=2026-10-11` (a subtraction) or the field's description. They now render `NULL`, `1`/`0`,
|
|
166
|
+
`'20261011'` and the field's qualified name. A `Decimal` with an exponent is written in full (`1000`, not
|
|
167
|
+
`1E+3`, which T-SQL reads as a float).
|
|
168
|
+
|
|
169
|
+
Breaking:
|
|
170
|
+
- `Update` values of an unsupported type raise `TypeError` instead of being written with `str()`.
|
|
171
|
+
|
|
172
|
+
### 0.2.0
|
|
173
|
+
Queries built with 0.1.x produce the same SQL; see "Breaking" for the few behavior changes.
|
|
174
|
+
- `LeftJoin`, `RightJoin` and `FullJoin` (same arguments as `InnerJoin`), and `CrossJoin(table)`.
|
|
175
|
+
- `Select(..., distinct=True, top=10)` renders `SELECT DISTINCT TOP (10) ...`.
|
|
176
|
+
- `OrderBy(...).offset(20).fetch(10)` adds `OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY`. Combining paging with
|
|
177
|
+
TOP raises `IncompatibleClausesException`.
|
|
178
|
+
- Every exception derives from `SqlQueryBuilderError` and can be imported from `sql_querystring_builder`.
|
|
179
|
+
- `table.fields["name"]`, `"name" in table.fields`, iteration and `len()`. `table.fields["add"]` also
|
|
180
|
+
reaches fields whose names clash with `Fields` methods.
|
|
181
|
+
- Table names accept bracketed parts anywhere: `[Order]`, `dbo.[My Table]` (`]]` stands for `]`).
|
|
182
|
+
- The README example uses `>` instead of `gt`, which is not a T-SQL operator.
|
|
183
|
+
|
|
184
|
+
Breaking:
|
|
185
|
+
- `TempTable(3)` raises `TypeError` instead of a bare `Exception` (same message).
|
|
186
|
+
- Unknown keyword arguments to `Table`, such as `fields=[...]`, raise `TypeError` instead of being ignored.
|
|
187
|
+
- Malformed bracketed names such as `dbo.Order]` or `dbo.[Order` are rejected.
|
|
188
|
+
- An empty `table.fields` is now falsy.
|
|
189
|
+
|
|
131
190
|
### 0.1.1
|
|
132
191
|
No change to the generated SQL.
|
|
133
192
|
- The package ships a `py.typed` marker, so type checkers such as mypy now use its type hints.
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# SqlQueryBuilder
|
|
2
|
+
Wraps string building of MS SQL queries.
|
|
3
|
+
It provides first level validation.
|
|
4
|
+
|
|
5
|
+
## Current features
|
|
6
|
+
1. Provides a string building facility for:
|
|
7
|
+
- [x] Table
|
|
8
|
+
- [x] Temporary Table
|
|
9
|
+
- [x] Field
|
|
10
|
+
- [x] Function on field
|
|
11
|
+
- [x] String
|
|
12
|
+
- [x] Conditions: eq, ne, lt, le, gt, ge, like, in_, between, is_null, is_not_null, combined with & | ~
|
|
13
|
+
- [x] Values: text, numbers, booleans, None, dates and times, bytes, UUIDs, and Raw SQL
|
|
14
|
+
2. Using:
|
|
15
|
+
- [x] Select (with DISTINCT and TOP)
|
|
16
|
+
- [x] Update (with typed values)
|
|
17
|
+
- [x] From
|
|
18
|
+
- [x] InnerJoin, LeftJoin, RightJoin, FullJoin (on two fields or on any condition), CrossJoin
|
|
19
|
+
- [x] Where (with conditions)
|
|
20
|
+
- [x] OrderBy (with OFFSET/FETCH paging)
|
|
21
|
+
3. Handling automatically:
|
|
22
|
+
- [x] Table aliases
|
|
23
|
+
- [x] Field aliases
|
|
24
|
+
- [x] Escaped values: 'O''Brien', N'café'
|
|
25
|
+
- [x] Parameterized queries: build_with_params() writes ? (pyodbc) or %s (pymssql) and returns the values
|
|
26
|
+
|
|
27
|
+
## Not implemented (yet)
|
|
28
|
+
In planned order:
|
|
29
|
+
- [ ] Insert and Delete
|
|
30
|
+
- [ ] Functions (aggregates and others), GROUP BY, HAVING, CASE
|
|
31
|
+
- [ ] Compound queries (UNION, INTERSECT, EXCEPT) and sub-queries
|
|
32
|
+
|
|
33
|
+
## What it does not do
|
|
34
|
+
Connect to a database server and run the queries by itself.
|
|
35
|
+
It gives you the query as a string, it's then up to you to pass it to a SQL engine.
|
|
36
|
+
|
|
37
|
+
## Example
|
|
38
|
+
1. Define the elements of your query:
|
|
39
|
+
```
|
|
40
|
+
# Defines a Table for customers
|
|
41
|
+
customers_table = Table("Customers").add("id", "customerId", "mainPhone")
|
|
42
|
+
# Defines a Table for accounts
|
|
43
|
+
accounts_table = Table("Accounts", "acc").add("id", "customerId", "accountId")
|
|
44
|
+
# Selects the fields
|
|
45
|
+
select = Select(customers_table, accounts_table.fields.accountId)
|
|
46
|
+
# Sets an alias for an existing field
|
|
47
|
+
customers_table.fields.customerId.set_alias("cid")
|
|
48
|
+
# Specifies the table to take as a FROM
|
|
49
|
+
from_ = From(customers_table)
|
|
50
|
+
# Specifies a temporary table for the select
|
|
51
|
+
atemptable = select.to_temptable("a_temp_table")
|
|
52
|
+
# Defines the InnerJoin
|
|
53
|
+
innerjoin = InnerJoin(accounts_table, accounts_table.fields.customerId, customers_table)
|
|
54
|
+
# Defines an Order By a defined field
|
|
55
|
+
orderby = OrderBy(customers_table.fields.customerId, descending=True)
|
|
56
|
+
# Adds a Where filter: conditions are methods of fields, combined with & (and_), | (or_) and ~ (not_)
|
|
57
|
+
where = Where(accounts_table.fields.accountId.gt(20000) & customers_table.fields.customerId.like("A1C%"))
|
|
58
|
+
```
|
|
59
|
+
2. Inspect what those elements are made of:
|
|
60
|
+
```
|
|
61
|
+
# See what the object Select looks like
|
|
62
|
+
print(select)
|
|
63
|
+
# > Select(fields=[Field(name='id', alias='None', table='Customers'),
|
|
64
|
+
# ... Field(name='customerId', alias='cid', table='Customers'),
|
|
65
|
+
# ... Field(name='mainPhone', alias='None', table='Customers'),
|
|
66
|
+
# ... Field(name='accountId', alias='None', table='Accounts')])
|
|
67
|
+
|
|
68
|
+
# Initializes the QueryBuilder
|
|
69
|
+
query_builder = QueryBuilder(select, where, orderby, innerjoin, from_)
|
|
70
|
+
```
|
|
71
|
+
3. Build your query:
|
|
72
|
+
```
|
|
73
|
+
# Builds the query
|
|
74
|
+
query_str: str = query_builder.build()
|
|
75
|
+
print(query_str)
|
|
76
|
+
# > SELECT Customers.id, Customers.customerId AS cid, Customers.mainPhone, acc.accountId
|
|
77
|
+
# > INTO #a_temp_table
|
|
78
|
+
# > FROM Customers
|
|
79
|
+
# > INNER JOIN Accounts acc on acc.customerId = Customers.customerId
|
|
80
|
+
# > WHERE acc.accountId > 20000 AND Customers.customerId LIKE 'A1C%'
|
|
81
|
+
# > ORDER BY cid DESC
|
|
82
|
+
```
|
|
83
|
+
4. Manipulate temporary tables whose components are inferred:
|
|
84
|
+
```
|
|
85
|
+
# Is able to infer fields from temporary tables and use it as with a regular table.
|
|
86
|
+
temp_select = Select(atemptable)
|
|
87
|
+
print(temp_select)
|
|
88
|
+
# > Select(fields=[Field(name='id', alias='None', table='#a_temp_table'),
|
|
89
|
+
# ... Field(name='cid', alias='None', table='#a_temp_table'),
|
|
90
|
+
# ... Field(name='mainPhone', alias='None', table='#a_temp_table'),
|
|
91
|
+
# ... Field(name='accountId', alias='None', table='#a_temp_table')])
|
|
92
|
+
|
|
93
|
+
# Builds the query from the temporary table
|
|
94
|
+
print(QueryBuilder(
|
|
95
|
+
temp_select,
|
|
96
|
+
From(atemptable)
|
|
97
|
+
).build())
|
|
98
|
+
# > SELECT #a_temp_table.id, #a_temp_table.cid, #a_temp_table.mainPhone, #a_temp_table.accountId
|
|
99
|
+
# > FROM #a_temp_table
|
|
100
|
+
```
|
|
101
|
+
5. Send the values as parameters, so that they never enter the SQL text:
|
|
102
|
+
```
|
|
103
|
+
# Builds the same query with a placeholder for each value, and returns the values in order
|
|
104
|
+
sql, params = query_builder.build_with_params()
|
|
105
|
+
print(sql)
|
|
106
|
+
# > SELECT Customers.id, Customers.customerId AS cid, Customers.mainPhone, acc.accountId
|
|
107
|
+
# > INTO #a_temp_table
|
|
108
|
+
# > FROM Customers
|
|
109
|
+
# > INNER JOIN Accounts acc on acc.customerId = Customers.customerId
|
|
110
|
+
# > WHERE acc.accountId > ? AND Customers.customerId LIKE ?
|
|
111
|
+
# > ORDER BY cid DESC
|
|
112
|
+
print(params)
|
|
113
|
+
# > (20000, 'A1C%')
|
|
114
|
+
# With pyodbc: cursor.execute(sql, params). For pymssql, build_with_params("format") writes %s instead.
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
(Find it directly in the module example.py)
|
|
118
|
+
|
|
119
|
+
## Compatible versions
|
|
120
|
+
Python 3.10 and above.
|
|
121
|
+
|
|
122
|
+
## Dependencies
|
|
123
|
+
(None)
|
|
124
|
+
|
|
125
|
+
## Changelog
|
|
126
|
+
### 0.3.0
|
|
127
|
+
Queries built with 0.2.0 produce the same SQL, except for the fixed `Update` values below.
|
|
128
|
+
- Conditions are methods of fields, functions on fields and strings: `eq`, `ne`, `lt`, `le`, `gt`, `ge`,
|
|
129
|
+
`like`, `in_`, `between`, `is_null` and `is_not_null`; `eq(None)` renders `IS NULL`. Combine them with
|
|
130
|
+
`&`, `|` and `~`, or with `and_()`, `or_()` and `not_()`; parentheses are added where T-SQL needs them.
|
|
131
|
+
Python's `and`, `or` and `not` raise `TypeError`, since `c1 and c2` would silently keep only `c2`.
|
|
132
|
+
- Values are escaped: text becomes `'O''Brien'`, or `N'café'` when it is not ASCII (`NStr("...")` forces
|
|
133
|
+
`N'...'`). Numbers, `True`/`False` (1/0), `None` (`NULL`), dates (`'20261011'`), datetimes and times
|
|
134
|
+
(ISO), bytes (`0x...`) and UUIDs are supported; `Raw("GETDATE()")` is written verbatim.
|
|
135
|
+
- `Where(condition)`, also next to raw strings, and `on=condition` on every join, for multi-column joins.
|
|
136
|
+
- `QueryBuilder.build_with_params()` returns the SQL with `?` placeholders and the values, for
|
|
137
|
+
`cursor.execute(sql, params)` with pyodbc; `build_with_params("format")` writes `%s` for pymssql.
|
|
138
|
+
- `Update` values: a plain string is still raw SQL (`{f: "'x'"}`), `Str("O'Brien")` is an escaped string,
|
|
139
|
+
and other values render as in conditions.
|
|
140
|
+
- New top-level names: `Condition`, `Raw`, `Str` and `NStr`.
|
|
141
|
+
|
|
142
|
+
Fixed:
|
|
143
|
+
- `Update` values `None`, `True`/`False`, dates and fields rendered invalid or wrong SQL: `f=None`,
|
|
144
|
+
`f=True`, `f=2026-10-11` (a subtraction) or the field's description. They now render `NULL`, `1`/`0`,
|
|
145
|
+
`'20261011'` and the field's qualified name. A `Decimal` with an exponent is written in full (`1000`, not
|
|
146
|
+
`1E+3`, which T-SQL reads as a float).
|
|
147
|
+
|
|
148
|
+
Breaking:
|
|
149
|
+
- `Update` values of an unsupported type raise `TypeError` instead of being written with `str()`.
|
|
150
|
+
|
|
151
|
+
### 0.2.0
|
|
152
|
+
Queries built with 0.1.x produce the same SQL; see "Breaking" for the few behavior changes.
|
|
153
|
+
- `LeftJoin`, `RightJoin` and `FullJoin` (same arguments as `InnerJoin`), and `CrossJoin(table)`.
|
|
154
|
+
- `Select(..., distinct=True, top=10)` renders `SELECT DISTINCT TOP (10) ...`.
|
|
155
|
+
- `OrderBy(...).offset(20).fetch(10)` adds `OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY`. Combining paging with
|
|
156
|
+
TOP raises `IncompatibleClausesException`.
|
|
157
|
+
- Every exception derives from `SqlQueryBuilderError` and can be imported from `sql_querystring_builder`.
|
|
158
|
+
- `table.fields["name"]`, `"name" in table.fields`, iteration and `len()`. `table.fields["add"]` also
|
|
159
|
+
reaches fields whose names clash with `Fields` methods.
|
|
160
|
+
- Table names accept bracketed parts anywhere: `[Order]`, `dbo.[My Table]` (`]]` stands for `]`).
|
|
161
|
+
- The README example uses `>` instead of `gt`, which is not a T-SQL operator.
|
|
162
|
+
|
|
163
|
+
Breaking:
|
|
164
|
+
- `TempTable(3)` raises `TypeError` instead of a bare `Exception` (same message).
|
|
165
|
+
- Unknown keyword arguments to `Table`, such as `fields=[...]`, raise `TypeError` instead of being ignored.
|
|
166
|
+
- Malformed bracketed names such as `dbo.Order]` or `dbo.[Order` are rejected.
|
|
167
|
+
- An empty `table.fields` is now falsy.
|
|
168
|
+
|
|
169
|
+
### 0.1.1
|
|
170
|
+
No change to the generated SQL.
|
|
171
|
+
- The package ships a `py.typed` marker, so type checkers such as mypy now use its type hints.
|
|
172
|
+
- PyPI classifiers for the supported Python versions (3.10 to 3.14), typing and topic.
|
|
173
|
+
- The "Not implemented (yet)" list shows the planned features in order; typo fixes.
|
|
174
|
+
|
|
175
|
+
### 0.1.0
|
|
176
|
+
This release changes the generated SQL: check your queries when upgrading.
|
|
177
|
+
- WHERE conditions and the right side of `INNER JOIN ... on` use the field's qualified name (`t1.KeyId`)
|
|
178
|
+
instead of its SELECT alias, which SQL Server only accepts in ORDER BY. ORDER BY still uses the alias.
|
|
179
|
+
- `UPDATE ... SET` no longer writes `AS alias` after aliased fields (`SET acc.a=1`).
|
|
180
|
+
- Temporary tables made with `Select.to_temptable()` name their columns after the field aliases, as
|
|
181
|
+
`SELECT ... INTO` does.
|
|
182
|
+
- Adding the same field name twice in one call (`Table("t").add("a", "a")`), including duplicate
|
|
183
|
+
temporary-table columns, raises `AlreadyExistingFieldException`.
|
|
184
|
+
- An `Update` mixing two tables with the same name but different aliases raises `IncorrectUpdateException`.
|
|
185
|
+
- `NonExistingFieldException` is now an `AttributeError`: `hasattr()` and `getattr(..., default)` work on
|
|
186
|
+
`table.fields`, and tables can be copied and pickled.
|
|
187
|
+
- Requires Python 3.10 or later (was 3.7).
|
|
188
|
+
- The license metadata uses the SPDX expression `GPL-3.0-only`.
|
|
189
|
+
|
|
190
|
+
## Authors
|
|
191
|
+
Wael GRIBAA
|
|
192
|
+
Contact: g.wael@outlook.fr
|
|
193
|
+
Made in September 2024
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "sql_querystring_builder"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
authors = [
|
|
9
9
|
{ name="Wael GRIBAA", email="g.wael@outlook.fr" },
|
|
10
10
|
]
|
|
@@ -34,6 +34,7 @@ dev = [
|
|
|
34
34
|
"mypy>=2.4.0",
|
|
35
35
|
"pytest>=9.1.1",
|
|
36
36
|
"ruff>=0.17.0",
|
|
37
|
+
"sqlglot==30.19.0",
|
|
37
38
|
]
|
|
38
39
|
|
|
39
40
|
[tool.ruff]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from .clauses import (
|
|
2
|
+
CrossJoin,
|
|
3
|
+
From,
|
|
4
|
+
FullJoin,
|
|
5
|
+
IncorrectUpdateException,
|
|
6
|
+
InnerJoin,
|
|
7
|
+
LeftJoin,
|
|
8
|
+
OrderBy,
|
|
9
|
+
RightJoin,
|
|
10
|
+
Select,
|
|
11
|
+
Update,
|
|
12
|
+
Where,
|
|
13
|
+
)
|
|
14
|
+
from .conditions import Condition
|
|
15
|
+
from .exceptions import SqlQueryBuilderError
|
|
16
|
+
from .query_builder import IncompatibleClausesException, QueryBuilder, UnavailablePlaceException
|
|
17
|
+
from .tables import (
|
|
18
|
+
AliasableString,
|
|
19
|
+
AliasException,
|
|
20
|
+
AlreadyExistingFieldException,
|
|
21
|
+
Field,
|
|
22
|
+
FunctionOnField,
|
|
23
|
+
InvalidNameException,
|
|
24
|
+
NonExistingAliasException,
|
|
25
|
+
NonExistingFieldException,
|
|
26
|
+
Table,
|
|
27
|
+
TableException,
|
|
28
|
+
TempTable,
|
|
29
|
+
)
|
|
30
|
+
from .values import NStr, Raw, Str
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
|
|
5
|
+
from .abstract_clause import Clause
|
|
6
|
+
from .conditions import Condition, Junction, check_operand, render_operand
|
|
7
|
+
from .exceptions import SqlQueryBuilderError
|
|
8
|
+
from .tables import Aliasable, Field, Fields, Table, TempTable
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class IncorrectUpdateException(SqlQueryBuilderError):
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# TODO Add INSERT
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _row_count(keyword: str, value: int, minimum: int) -> int:
|
|
19
|
+
"""Validates a row count for TOP, OFFSET or FETCH."""
|
|
20
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
21
|
+
raise TypeError(f"{keyword} must be an int, got {type(value).__name__}.")
|
|
22
|
+
if value < minimum:
|
|
23
|
+
expected: str = "zero or more" if minimum == 0 else f"at least {minimum}"
|
|
24
|
+
raise ValueError(f"{keyword} must be {expected}, got {value}.")
|
|
25
|
+
return value
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Select(Clause):
|
|
29
|
+
def __init__(self, *fields: Fields | Aliasable | Table, distinct: bool = False, top: int | None = None) -> None:
|
|
30
|
+
if top is not None:
|
|
31
|
+
_row_count("TOP", top, minimum=0)
|
|
32
|
+
self.fields: list[Aliasable] = []
|
|
33
|
+
for el in fields:
|
|
34
|
+
if isinstance(el, Fields):
|
|
35
|
+
self.fields.extend(el.as_list())
|
|
36
|
+
elif isinstance(el, Table):
|
|
37
|
+
self.fields.extend(el.fields.as_list())
|
|
38
|
+
else:
|
|
39
|
+
self.fields.append(el)
|
|
40
|
+
self.distinct: bool = distinct
|
|
41
|
+
self.top: int | None = top
|
|
42
|
+
self.temp_table: TempTable | None = None
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def place(self) -> int:
|
|
46
|
+
return 0
|
|
47
|
+
|
|
48
|
+
def build(self) -> str:
|
|
49
|
+
head: str = "SELECT"
|
|
50
|
+
if self.distinct:
|
|
51
|
+
head += " DISTINCT"
|
|
52
|
+
if self.top is not None:
|
|
53
|
+
head += f" TOP ({self.top})"
|
|
54
|
+
ret: str = f"{head} " + (", ".join([field.build(True, True) for field in self.fields]) if self.fields else "*")
|
|
55
|
+
if self.temp_table is not None:
|
|
56
|
+
ret += "\nINTO " + self.temp_table.build()
|
|
57
|
+
return ret
|
|
58
|
+
|
|
59
|
+
def to_temptable(self, name: str) -> TempTable:
|
|
60
|
+
self.temp_table = TempTable(name, *self.fields)
|
|
61
|
+
return self.temp_table
|
|
62
|
+
|
|
63
|
+
def __repr__(self) -> str:
|
|
64
|
+
options: str = (", distinct=True" if self.distinct else "") + (
|
|
65
|
+
f", top={self.top}" if self.top is not None else ""
|
|
66
|
+
)
|
|
67
|
+
return f"{self.__class__.__name__}(fields={self.fields}{options})"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class From(Clause):
|
|
71
|
+
def __init__(self, table: Table) -> None:
|
|
72
|
+
self.table: Table = table
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def place(self) -> int:
|
|
76
|
+
return 2
|
|
77
|
+
|
|
78
|
+
def build(self) -> str:
|
|
79
|
+
return f"FROM {self.table.build(True)}"
|
|
80
|
+
|
|
81
|
+
def __repr__(self) -> str:
|
|
82
|
+
return f"{self.__class__.__name__}(table={self.table})"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class Where(Clause):
|
|
86
|
+
"""`WHERE` and its parts, joined by spaces: conditions, fields, and strings written as raw SQL."""
|
|
87
|
+
|
|
88
|
+
def __init__(self, *conditions: str | Aliasable | Condition) -> None:
|
|
89
|
+
self.conditions: tuple[str | Aliasable | Condition, ...] = conditions
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def place(self) -> int:
|
|
93
|
+
return 4
|
|
94
|
+
|
|
95
|
+
def build(self) -> str:
|
|
96
|
+
ret: list[str] = ["WHERE"]
|
|
97
|
+
for condition in self.conditions:
|
|
98
|
+
if isinstance(condition, Condition):
|
|
99
|
+
sql: str = condition.build()
|
|
100
|
+
# Next to raw strings, an AND or OR condition keeps its meaning only in parentheses.
|
|
101
|
+
ret.append(f"({sql})" if isinstance(condition, Junction) and len(self.conditions) > 1 else sql)
|
|
102
|
+
else:
|
|
103
|
+
ret.append(condition.build() if isinstance(condition, Aliasable) else condition)
|
|
104
|
+
return " ".join(ret)
|
|
105
|
+
|
|
106
|
+
def __repr__(self) -> str:
|
|
107
|
+
return f"{self.__class__.__name__}(conditions={self.conditions})"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class Join(Clause):
|
|
111
|
+
"""A join on `on_left = on_right`, or on any condition given as `on`; each subclass only sets its `keyword`."""
|
|
112
|
+
|
|
113
|
+
keyword: str
|
|
114
|
+
|
|
115
|
+
def __init__(
|
|
116
|
+
self,
|
|
117
|
+
table: Table,
|
|
118
|
+
on_left: Field | None = None,
|
|
119
|
+
on_right: Field | Table | None = None,
|
|
120
|
+
*,
|
|
121
|
+
on: Condition | None = None,
|
|
122
|
+
) -> None:
|
|
123
|
+
if on is None:
|
|
124
|
+
if on_left is None or on_right is None:
|
|
125
|
+
raise TypeError(f"{self.__class__.__name__} needs on_left and on_right, or on.")
|
|
126
|
+
on_right = on_right if isinstance(on_right, Aliasable) else on_right.fields[on_left.name]
|
|
127
|
+
on = on_left.eq(on_right)
|
|
128
|
+
elif on_left is not None or on_right is not None:
|
|
129
|
+
raise TypeError(f"{self.__class__.__name__} takes on_left and on_right, or on, not both.")
|
|
130
|
+
elif not isinstance(on, Condition):
|
|
131
|
+
raise TypeError(f"on must be a Condition, got {type(on).__name__}.")
|
|
132
|
+
self.table: Table = table
|
|
133
|
+
self.on_left: Field | None = on_left
|
|
134
|
+
self.on_right: Field | None = on_right
|
|
135
|
+
self.on: Condition = on
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def place(self) -> int:
|
|
139
|
+
return 3
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def is_exclusive(self) -> bool:
|
|
143
|
+
return False
|
|
144
|
+
|
|
145
|
+
def build(self) -> str:
|
|
146
|
+
return f"{self.keyword} {self.table.build(use_name=True)} on {self.on.build()}"
|
|
147
|
+
|
|
148
|
+
def __repr__(self) -> str:
|
|
149
|
+
if self.on_left is None:
|
|
150
|
+
return f"{self.__class__.__name__}(table={self.table}, on={self.on})"
|
|
151
|
+
return f"{self.__class__.__name__}(table={self.table}, on_left={self.on_left}, on_right={self.on_right})"
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class InnerJoin(Join):
|
|
155
|
+
keyword = "INNER JOIN"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class LeftJoin(Join):
|
|
159
|
+
keyword = "LEFT JOIN"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class RightJoin(Join):
|
|
163
|
+
keyword = "RIGHT JOIN"
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class FullJoin(Join):
|
|
167
|
+
keyword = "FULL JOIN"
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class CrossJoin(Clause):
|
|
171
|
+
def __init__(self, table: Table) -> None:
|
|
172
|
+
self.table: Table = table
|
|
173
|
+
|
|
174
|
+
@property
|
|
175
|
+
def place(self) -> int:
|
|
176
|
+
return 3
|
|
177
|
+
|
|
178
|
+
@property
|
|
179
|
+
def is_exclusive(self) -> bool:
|
|
180
|
+
return False
|
|
181
|
+
|
|
182
|
+
def build(self) -> str:
|
|
183
|
+
return f"CROSS JOIN {self.table.build(use_name=True)}"
|
|
184
|
+
|
|
185
|
+
def __repr__(self) -> str:
|
|
186
|
+
return f"{self.__class__.__name__}(table={self.table})"
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class Update(Clause):
|
|
190
|
+
"""`UPDATE … SET field=value, …`: a plain str value is raw SQL (`"'x'"`), `Str("x")` an escaped string.
|
|
191
|
+
|
|
192
|
+
Any other value goes through `values.py`, like a value in a condition: `None` is `NULL`, a field is its
|
|
193
|
+
qualified name.
|
|
194
|
+
"""
|
|
195
|
+
|
|
196
|
+
def __init__(self, updates: Mapping[Field, object]) -> None:
|
|
197
|
+
if not updates:
|
|
198
|
+
raise IncorrectUpdateException("The Update is empty.")
|
|
199
|
+
self.updates: dict[Field, object] = {}
|
|
200
|
+
first_table: Table | None = None
|
|
201
|
+
for field, value in updates.items():
|
|
202
|
+
if first_table is None:
|
|
203
|
+
first_table = field.table
|
|
204
|
+
elif (first_table.name, first_table.alias) != (field.table.name, field.table.alias):
|
|
205
|
+
# The same name under another alias is still another table in the query: show the aliases.
|
|
206
|
+
same_name: bool = first_table.name == field.table.name
|
|
207
|
+
first: str = first_table.build(use_name=True) if same_name else first_table.name
|
|
208
|
+
other: str = field.table.build(use_name=True) if same_name else field.table.name
|
|
209
|
+
raise IncorrectUpdateException(
|
|
210
|
+
f"Two different tables cannot be updated in the same Update: '{first}' and '{other}'."
|
|
211
|
+
)
|
|
212
|
+
self.updates[field] = value if isinstance(value, str) else check_operand(value)
|
|
213
|
+
self.table: Table = field.table
|
|
214
|
+
|
|
215
|
+
@property
|
|
216
|
+
def place(self) -> int:
|
|
217
|
+
return 0
|
|
218
|
+
|
|
219
|
+
def build(self) -> str:
|
|
220
|
+
# A plain str stays raw SQL until 1.0 (D4 in ROADMAP.md): turning it into a value would quote it.
|
|
221
|
+
field_strings: list[str] = [
|
|
222
|
+
f"{field.build()}={value if isinstance(value, str) else render_operand(value)}"
|
|
223
|
+
for field, value in self.updates.items()
|
|
224
|
+
]
|
|
225
|
+
return f"UPDATE {self.table.build(use_name=False, use_alias=True)}\nSET " + ", ".join(field_strings)
|
|
226
|
+
|
|
227
|
+
def __repr__(self) -> str:
|
|
228
|
+
return f"{self.__class__.__name__}(updates={self.updates})"
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class OrderBy(Clause):
|
|
232
|
+
def __init__(self, field: Aliasable, descending: bool = False) -> None:
|
|
233
|
+
self.ordering: list[tuple[Aliasable, bool]] = [(field, descending)]
|
|
234
|
+
self.offset_rows: int | None = None
|
|
235
|
+
self.fetch_rows: int | None = None
|
|
236
|
+
|
|
237
|
+
@property
|
|
238
|
+
def place(self) -> int:
|
|
239
|
+
return 5
|
|
240
|
+
|
|
241
|
+
@property
|
|
242
|
+
def has_paging(self) -> bool:
|
|
243
|
+
return self.offset_rows is not None or self.fetch_rows is not None
|
|
244
|
+
|
|
245
|
+
def add(self, field: Aliasable, descending: bool = False) -> OrderBy:
|
|
246
|
+
self.ordering.append((field, descending))
|
|
247
|
+
return self
|
|
248
|
+
|
|
249
|
+
def offset(self, rows: int) -> OrderBy:
|
|
250
|
+
self.offset_rows = _row_count("OFFSET", rows, minimum=0)
|
|
251
|
+
return self
|
|
252
|
+
|
|
253
|
+
def fetch(self, rows: int) -> OrderBy:
|
|
254
|
+
self.fetch_rows = _row_count("FETCH", rows, minimum=1)
|
|
255
|
+
return self
|
|
256
|
+
|
|
257
|
+
def build(self) -> str:
|
|
258
|
+
ret_list: list[str] = []
|
|
259
|
+
for field, desc in self.ordering:
|
|
260
|
+
ret_list.append(f"{field.build(use_name=False, use_alias=True)}{' DESC' if desc else ''}")
|
|
261
|
+
ret: str = "ORDER BY " + ", ".join(ret_list)
|
|
262
|
+
if self.has_paging:
|
|
263
|
+
# T-SQL requires OFFSET before FETCH: fetch() alone starts at the first row.
|
|
264
|
+
ret += f"\nOFFSET {self.offset_rows or 0} ROWS"
|
|
265
|
+
if self.fetch_rows is not None:
|
|
266
|
+
ret += f" FETCH NEXT {self.fetch_rows} ROWS ONLY"
|
|
267
|
+
return ret
|
|
268
|
+
|
|
269
|
+
def __repr__(self) -> str:
|
|
270
|
+
paging: str = (f", offset={self.offset_rows}" if self.offset_rows is not None else "") + (
|
|
271
|
+
f", fetch={self.fetch_rows}" if self.fetch_rows is not None else ""
|
|
272
|
+
)
|
|
273
|
+
return f"{self.__class__.__name__}(ordering={self.ordering}{paging})"
|