sql_querystring_builder 0.2.0__tar.gz → 0.4.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.2.0/src/sql_querystring_builder.egg-info → sql_querystring_builder-0.4.0}/PKG-INFO +82 -9
- {sql_querystring_builder-0.2.0 → sql_querystring_builder-0.4.0}/README.md +81 -8
- {sql_querystring_builder-0.2.0 → sql_querystring_builder-0.4.0}/pyproject.toml +1 -1
- {sql_querystring_builder-0.2.0 → sql_querystring_builder-0.4.0}/src/sql_querystring_builder/__init__.py +4 -0
- sql_querystring_builder-0.4.0/src/sql_querystring_builder/clauses.py +365 -0
- sql_querystring_builder-0.4.0/src/sql_querystring_builder/conditions.py +194 -0
- sql_querystring_builder-0.4.0/src/sql_querystring_builder/query_builder.py +89 -0
- {sql_querystring_builder-0.2.0 → sql_querystring_builder-0.4.0}/src/sql_querystring_builder/tables.py +3 -2
- sql_querystring_builder-0.4.0/src/sql_querystring_builder/values.py +139 -0
- {sql_querystring_builder-0.2.0 → sql_querystring_builder-0.4.0/src/sql_querystring_builder.egg-info}/PKG-INFO +82 -9
- {sql_querystring_builder-0.2.0 → sql_querystring_builder-0.4.0}/src/sql_querystring_builder.egg-info/SOURCES.txt +4 -0
- {sql_querystring_builder-0.2.0 → sql_querystring_builder-0.4.0}/tests/test_characterization.py +6 -0
- {sql_querystring_builder-0.2.0 → sql_querystring_builder-0.4.0}/tests/test_clauses.py +79 -1
- sql_querystring_builder-0.4.0/tests/test_conditions.py +258 -0
- sql_querystring_builder-0.4.0/tests/test_insert_delete.py +227 -0
- sql_querystring_builder-0.4.0/tests/test_queries.py +159 -0
- sql_querystring_builder-0.4.0/tests/test_sql_syntax.py +174 -0
- sql_querystring_builder-0.2.0/src/sql_querystring_builder/clauses.py +0 -235
- sql_querystring_builder-0.2.0/src/sql_querystring_builder/query_builder.py +0 -56
- sql_querystring_builder-0.2.0/tests/test_queries.py +0 -47
- sql_querystring_builder-0.2.0/tests/test_sql_syntax.py +0 -93
- {sql_querystring_builder-0.2.0 → sql_querystring_builder-0.4.0}/LICENSE +0 -0
- {sql_querystring_builder-0.2.0 → sql_querystring_builder-0.4.0}/setup.cfg +0 -0
- {sql_querystring_builder-0.2.0 → sql_querystring_builder-0.4.0}/src/sql_querystring_builder/abstract_clause.py +0 -0
- {sql_querystring_builder-0.2.0 → sql_querystring_builder-0.4.0}/src/sql_querystring_builder/exceptions.py +0 -0
- {sql_querystring_builder-0.2.0 → sql_querystring_builder-0.4.0}/src/sql_querystring_builder/py.typed +0 -0
- {sql_querystring_builder-0.2.0 → sql_querystring_builder-0.4.0}/src/sql_querystring_builder.egg-info/dependency_links.txt +0 -0
- {sql_querystring_builder-0.2.0 → sql_querystring_builder-0.4.0}/src/sql_querystring_builder.egg-info/top_level.txt +0 -0
- {sql_querystring_builder-0.2.0 → sql_querystring_builder-0.4.0}/tests/test_readme.py +0 -0
- {sql_querystring_builder-0.2.0 → sql_querystring_builder-0.4.0}/tests/test_tables.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.4.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,24 +30,27 @@ 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
36
|
- [x] Select (with DISTINCT and TOP)
|
|
35
|
-
- [x] Update
|
|
37
|
+
- [x] Update (with typed values)
|
|
38
|
+
- [x] Insert (multi-row VALUES)
|
|
39
|
+
- [x] Delete (with joins; refused without a WHERE unless asked)
|
|
36
40
|
- [x] From
|
|
37
|
-
- [x] InnerJoin, LeftJoin, RightJoin, FullJoin, CrossJoin
|
|
38
|
-
- [x] Where
|
|
41
|
+
- [x] InnerJoin, LeftJoin, RightJoin, FullJoin (on two fields or on any condition), CrossJoin
|
|
42
|
+
- [x] Where (with conditions)
|
|
39
43
|
- [x] OrderBy (with OFFSET/FETCH paging)
|
|
40
44
|
3. Handling automatically:
|
|
41
45
|
- [x] Table aliases
|
|
42
46
|
- [x] Field aliases
|
|
47
|
+
- [x] Escaped values: 'O''Brien', N'café'
|
|
48
|
+
- [x] Parameterized queries: build_with_params() writes ? (pyodbc) or %s (pymssql) and returns the values
|
|
43
49
|
|
|
44
50
|
## Not implemented (yet)
|
|
45
51
|
In planned order:
|
|
46
|
-
- [ ] Proper conditions: typed values, AND/OR/NOT, IN, BETWEEN, LIKE, IS NULL
|
|
47
|
-
- [ ] Insert and Delete
|
|
48
52
|
- [ ] Functions (aggregates and others), GROUP BY, HAVING, CASE
|
|
49
53
|
- [ ] Compound queries (UNION, INTERSECT, EXCEPT) and sub-queries
|
|
50
|
-
- [ ] Parameterized queries
|
|
51
54
|
|
|
52
55
|
## What it does not do
|
|
53
56
|
Connect to a database server and run the queries by itself.
|
|
@@ -72,8 +75,8 @@ atemptable = select.to_temptable("a_temp_table")
|
|
|
72
75
|
innerjoin = InnerJoin(accounts_table, accounts_table.fields.customerId, customers_table)
|
|
73
76
|
# Defines an Order By a defined field
|
|
74
77
|
orderby = OrderBy(customers_table.fields.customerId, descending=True)
|
|
75
|
-
# Adds a Where filter
|
|
76
|
-
where = Where(accounts_table.fields.accountId
|
|
78
|
+
# Adds a Where filter: conditions are methods of fields, combined with & (and_), | (or_) and ~ (not_)
|
|
79
|
+
where = Where(accounts_table.fields.accountId.gt(20000) & customers_table.fields.customerId.like("A1C%"))
|
|
77
80
|
```
|
|
78
81
|
2. Inspect what those elements are made of:
|
|
79
82
|
```
|
|
@@ -117,6 +120,38 @@ print(QueryBuilder(
|
|
|
117
120
|
# > SELECT #a_temp_table.id, #a_temp_table.cid, #a_temp_table.mainPhone, #a_temp_table.accountId
|
|
118
121
|
# > FROM #a_temp_table
|
|
119
122
|
```
|
|
123
|
+
5. Send the values as parameters, so that they never enter the SQL text:
|
|
124
|
+
```
|
|
125
|
+
# Builds the same query with a placeholder for each value, and returns the values in order
|
|
126
|
+
sql, params = query_builder.build_with_params()
|
|
127
|
+
print(sql)
|
|
128
|
+
# > SELECT Customers.id, Customers.customerId AS cid, Customers.mainPhone, acc.accountId
|
|
129
|
+
# > INTO #a_temp_table
|
|
130
|
+
# > FROM Customers
|
|
131
|
+
# > INNER JOIN Accounts acc on acc.customerId = Customers.customerId
|
|
132
|
+
# > WHERE acc.accountId > ? AND Customers.customerId LIKE ?
|
|
133
|
+
# > ORDER BY cid DESC
|
|
134
|
+
print(params)
|
|
135
|
+
# > (20000, 'A1C%')
|
|
136
|
+
# With pyodbc: cursor.execute(sql, params). For pymssql, build_with_params("format") writes %s instead.
|
|
137
|
+
```
|
|
138
|
+
6. Insert and delete rows:
|
|
139
|
+
```
|
|
140
|
+
# Inserts rows: one tuple per row, with the values in column order
|
|
141
|
+
insert = Insert(customers_table.fields.customerId, customers_table.fields.mainPhone).values(
|
|
142
|
+
("A1C42", "555-0100"), ("B7", None)
|
|
143
|
+
)
|
|
144
|
+
print(QueryBuilder(insert).build())
|
|
145
|
+
# > INSERT INTO Customers (customerId, mainPhone)
|
|
146
|
+
# > VALUES ('A1C42', '555-0100'), ('B7', NULL)
|
|
147
|
+
|
|
148
|
+
# Deletes the accounts of customer B7; a DELETE needs a WHERE, unless built with where_required=False
|
|
149
|
+
delete = QueryBuilder(Delete(accounts_table), From(accounts_table), Where(accounts_table.fields.customerId.eq("B7")))
|
|
150
|
+
print(delete.build())
|
|
151
|
+
# > DELETE acc
|
|
152
|
+
# > FROM Accounts acc
|
|
153
|
+
# > WHERE acc.customerId = 'B7'
|
|
154
|
+
```
|
|
120
155
|
|
|
121
156
|
(Find it directly in the module example.py)
|
|
122
157
|
|
|
@@ -127,6 +162,44 @@ Python 3.10 and above.
|
|
|
127
162
|
(None)
|
|
128
163
|
|
|
129
164
|
## Changelog
|
|
165
|
+
### 0.4.0
|
|
166
|
+
Queries built with 0.3.0 produce the same SQL.
|
|
167
|
+
- `Insert(*columns).values(*rows)` renders `INSERT INTO Customers (id, name)` and
|
|
168
|
+
`VALUES (1, 'Ann'), (2, 'O''Brien')`. The columns are fields of one table, or a whole table. Each row is a
|
|
169
|
+
tuple of values in column order, with at most 1000 rows per INSERT, as in T-SQL. Values are escaped as in
|
|
170
|
+
conditions, plain strings included, and `Raw("DEFAULT")` writes SQL. An Insert stands alone in its query.
|
|
171
|
+
- `Delete(table)` renders `DELETE` and the table's alias, or its name, and works with `From`, joins and
|
|
172
|
+
`Where`: `DELETE acc FROM Accounts acc INNER JOIN ...`. A query with a DELETE but no WHERE raises
|
|
173
|
+
`IncompatibleClausesException`, unless written `Delete(table, where_required=False)`; so does a DELETE with
|
|
174
|
+
ORDER BY.
|
|
175
|
+
- Both work with `build_with_params()`. SQL Server accepts about 2,100 parameters per query, so split big
|
|
176
|
+
parameterized inserts.
|
|
177
|
+
|
|
178
|
+
### 0.3.0
|
|
179
|
+
Queries built with 0.2.0 produce the same SQL, except for the fixed `Update` values below.
|
|
180
|
+
- Conditions are methods of fields, functions on fields and strings: `eq`, `ne`, `lt`, `le`, `gt`, `ge`,
|
|
181
|
+
`like`, `in_`, `between`, `is_null` and `is_not_null`; `eq(None)` renders `IS NULL`. Combine them with
|
|
182
|
+
`&`, `|` and `~`, or with `and_()`, `or_()` and `not_()`; parentheses are added where T-SQL needs them.
|
|
183
|
+
Python's `and`, `or` and `not` raise `TypeError`, since `c1 and c2` would silently keep only `c2`.
|
|
184
|
+
- Values are escaped: text becomes `'O''Brien'`, or `N'café'` when it is not ASCII (`NStr("...")` forces
|
|
185
|
+
`N'...'`). Numbers, `True`/`False` (1/0), `None` (`NULL`), dates (`'20261011'`), datetimes and times
|
|
186
|
+
(ISO), bytes (`0x...`) and UUIDs are supported; `Raw("GETDATE()")` is written verbatim.
|
|
187
|
+
- `Where(condition)`, also next to raw strings, and `on=condition` on every join, for multi-column joins.
|
|
188
|
+
- `QueryBuilder.build_with_params()` returns the SQL with `?` placeholders and the values, for
|
|
189
|
+
`cursor.execute(sql, params)` with pyodbc; `build_with_params("format")` writes `%s` for pymssql.
|
|
190
|
+
- `Update` values: a plain string is still raw SQL (`{f: "'x'"}`), `Str("O'Brien")` is an escaped string,
|
|
191
|
+
and other values render as in conditions.
|
|
192
|
+
- New top-level names: `Condition`, `Raw`, `Str` and `NStr`.
|
|
193
|
+
|
|
194
|
+
Fixed:
|
|
195
|
+
- `Update` values `None`, `True`/`False`, dates and fields rendered invalid or wrong SQL: `f=None`,
|
|
196
|
+
`f=True`, `f=2026-10-11` (a subtraction) or the field's description. They now render `NULL`, `1`/`0`,
|
|
197
|
+
`'20261011'` and the field's qualified name. A `Decimal` with an exponent is written in full (`1000`, not
|
|
198
|
+
`1E+3`, which T-SQL reads as a float).
|
|
199
|
+
|
|
200
|
+
Breaking:
|
|
201
|
+
- `Update` values of an unsupported type raise `TypeError` instead of being written with `str()`.
|
|
202
|
+
|
|
130
203
|
### 0.2.0
|
|
131
204
|
Queries built with 0.1.x produce the same SQL; see "Breaking" for the few behavior changes.
|
|
132
205
|
- `LeftJoin`, `RightJoin` and `FullJoin` (same arguments as `InnerJoin`), and `CrossJoin(table)`.
|
|
@@ -9,24 +9,27 @@ It provides first level validation.
|
|
|
9
9
|
- [x] Field
|
|
10
10
|
- [x] Function on field
|
|
11
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
|
|
12
14
|
2. Using:
|
|
13
15
|
- [x] Select (with DISTINCT and TOP)
|
|
14
|
-
- [x] Update
|
|
16
|
+
- [x] Update (with typed values)
|
|
17
|
+
- [x] Insert (multi-row VALUES)
|
|
18
|
+
- [x] Delete (with joins; refused without a WHERE unless asked)
|
|
15
19
|
- [x] From
|
|
16
|
-
- [x] InnerJoin, LeftJoin, RightJoin, FullJoin, CrossJoin
|
|
17
|
-
- [x] Where
|
|
20
|
+
- [x] InnerJoin, LeftJoin, RightJoin, FullJoin (on two fields or on any condition), CrossJoin
|
|
21
|
+
- [x] Where (with conditions)
|
|
18
22
|
- [x] OrderBy (with OFFSET/FETCH paging)
|
|
19
23
|
3. Handling automatically:
|
|
20
24
|
- [x] Table aliases
|
|
21
25
|
- [x] Field aliases
|
|
26
|
+
- [x] Escaped values: 'O''Brien', N'café'
|
|
27
|
+
- [x] Parameterized queries: build_with_params() writes ? (pyodbc) or %s (pymssql) and returns the values
|
|
22
28
|
|
|
23
29
|
## Not implemented (yet)
|
|
24
30
|
In planned order:
|
|
25
|
-
- [ ] Proper conditions: typed values, AND/OR/NOT, IN, BETWEEN, LIKE, IS NULL
|
|
26
|
-
- [ ] Insert and Delete
|
|
27
31
|
- [ ] Functions (aggregates and others), GROUP BY, HAVING, CASE
|
|
28
32
|
- [ ] Compound queries (UNION, INTERSECT, EXCEPT) and sub-queries
|
|
29
|
-
- [ ] Parameterized queries
|
|
30
33
|
|
|
31
34
|
## What it does not do
|
|
32
35
|
Connect to a database server and run the queries by itself.
|
|
@@ -51,8 +54,8 @@ atemptable = select.to_temptable("a_temp_table")
|
|
|
51
54
|
innerjoin = InnerJoin(accounts_table, accounts_table.fields.customerId, customers_table)
|
|
52
55
|
# Defines an Order By a defined field
|
|
53
56
|
orderby = OrderBy(customers_table.fields.customerId, descending=True)
|
|
54
|
-
# Adds a Where filter
|
|
55
|
-
where = Where(accounts_table.fields.accountId
|
|
57
|
+
# Adds a Where filter: conditions are methods of fields, combined with & (and_), | (or_) and ~ (not_)
|
|
58
|
+
where = Where(accounts_table.fields.accountId.gt(20000) & customers_table.fields.customerId.like("A1C%"))
|
|
56
59
|
```
|
|
57
60
|
2. Inspect what those elements are made of:
|
|
58
61
|
```
|
|
@@ -96,6 +99,38 @@ print(QueryBuilder(
|
|
|
96
99
|
# > SELECT #a_temp_table.id, #a_temp_table.cid, #a_temp_table.mainPhone, #a_temp_table.accountId
|
|
97
100
|
# > FROM #a_temp_table
|
|
98
101
|
```
|
|
102
|
+
5. Send the values as parameters, so that they never enter the SQL text:
|
|
103
|
+
```
|
|
104
|
+
# Builds the same query with a placeholder for each value, and returns the values in order
|
|
105
|
+
sql, params = query_builder.build_with_params()
|
|
106
|
+
print(sql)
|
|
107
|
+
# > SELECT Customers.id, Customers.customerId AS cid, Customers.mainPhone, acc.accountId
|
|
108
|
+
# > INTO #a_temp_table
|
|
109
|
+
# > FROM Customers
|
|
110
|
+
# > INNER JOIN Accounts acc on acc.customerId = Customers.customerId
|
|
111
|
+
# > WHERE acc.accountId > ? AND Customers.customerId LIKE ?
|
|
112
|
+
# > ORDER BY cid DESC
|
|
113
|
+
print(params)
|
|
114
|
+
# > (20000, 'A1C%')
|
|
115
|
+
# With pyodbc: cursor.execute(sql, params). For pymssql, build_with_params("format") writes %s instead.
|
|
116
|
+
```
|
|
117
|
+
6. Insert and delete rows:
|
|
118
|
+
```
|
|
119
|
+
# Inserts rows: one tuple per row, with the values in column order
|
|
120
|
+
insert = Insert(customers_table.fields.customerId, customers_table.fields.mainPhone).values(
|
|
121
|
+
("A1C42", "555-0100"), ("B7", None)
|
|
122
|
+
)
|
|
123
|
+
print(QueryBuilder(insert).build())
|
|
124
|
+
# > INSERT INTO Customers (customerId, mainPhone)
|
|
125
|
+
# > VALUES ('A1C42', '555-0100'), ('B7', NULL)
|
|
126
|
+
|
|
127
|
+
# Deletes the accounts of customer B7; a DELETE needs a WHERE, unless built with where_required=False
|
|
128
|
+
delete = QueryBuilder(Delete(accounts_table), From(accounts_table), Where(accounts_table.fields.customerId.eq("B7")))
|
|
129
|
+
print(delete.build())
|
|
130
|
+
# > DELETE acc
|
|
131
|
+
# > FROM Accounts acc
|
|
132
|
+
# > WHERE acc.customerId = 'B7'
|
|
133
|
+
```
|
|
99
134
|
|
|
100
135
|
(Find it directly in the module example.py)
|
|
101
136
|
|
|
@@ -106,6 +141,44 @@ Python 3.10 and above.
|
|
|
106
141
|
(None)
|
|
107
142
|
|
|
108
143
|
## Changelog
|
|
144
|
+
### 0.4.0
|
|
145
|
+
Queries built with 0.3.0 produce the same SQL.
|
|
146
|
+
- `Insert(*columns).values(*rows)` renders `INSERT INTO Customers (id, name)` and
|
|
147
|
+
`VALUES (1, 'Ann'), (2, 'O''Brien')`. The columns are fields of one table, or a whole table. Each row is a
|
|
148
|
+
tuple of values in column order, with at most 1000 rows per INSERT, as in T-SQL. Values are escaped as in
|
|
149
|
+
conditions, plain strings included, and `Raw("DEFAULT")` writes SQL. An Insert stands alone in its query.
|
|
150
|
+
- `Delete(table)` renders `DELETE` and the table's alias, or its name, and works with `From`, joins and
|
|
151
|
+
`Where`: `DELETE acc FROM Accounts acc INNER JOIN ...`. A query with a DELETE but no WHERE raises
|
|
152
|
+
`IncompatibleClausesException`, unless written `Delete(table, where_required=False)`; so does a DELETE with
|
|
153
|
+
ORDER BY.
|
|
154
|
+
- Both work with `build_with_params()`. SQL Server accepts about 2,100 parameters per query, so split big
|
|
155
|
+
parameterized inserts.
|
|
156
|
+
|
|
157
|
+
### 0.3.0
|
|
158
|
+
Queries built with 0.2.0 produce the same SQL, except for the fixed `Update` values below.
|
|
159
|
+
- Conditions are methods of fields, functions on fields and strings: `eq`, `ne`, `lt`, `le`, `gt`, `ge`,
|
|
160
|
+
`like`, `in_`, `between`, `is_null` and `is_not_null`; `eq(None)` renders `IS NULL`. Combine them with
|
|
161
|
+
`&`, `|` and `~`, or with `and_()`, `or_()` and `not_()`; parentheses are added where T-SQL needs them.
|
|
162
|
+
Python's `and`, `or` and `not` raise `TypeError`, since `c1 and c2` would silently keep only `c2`.
|
|
163
|
+
- Values are escaped: text becomes `'O''Brien'`, or `N'café'` when it is not ASCII (`NStr("...")` forces
|
|
164
|
+
`N'...'`). Numbers, `True`/`False` (1/0), `None` (`NULL`), dates (`'20261011'`), datetimes and times
|
|
165
|
+
(ISO), bytes (`0x...`) and UUIDs are supported; `Raw("GETDATE()")` is written verbatim.
|
|
166
|
+
- `Where(condition)`, also next to raw strings, and `on=condition` on every join, for multi-column joins.
|
|
167
|
+
- `QueryBuilder.build_with_params()` returns the SQL with `?` placeholders and the values, for
|
|
168
|
+
`cursor.execute(sql, params)` with pyodbc; `build_with_params("format")` writes `%s` for pymssql.
|
|
169
|
+
- `Update` values: a plain string is still raw SQL (`{f: "'x'"}`), `Str("O'Brien")` is an escaped string,
|
|
170
|
+
and other values render as in conditions.
|
|
171
|
+
- New top-level names: `Condition`, `Raw`, `Str` and `NStr`.
|
|
172
|
+
|
|
173
|
+
Fixed:
|
|
174
|
+
- `Update` values `None`, `True`/`False`, dates and fields rendered invalid or wrong SQL: `f=None`,
|
|
175
|
+
`f=True`, `f=2026-10-11` (a subtraction) or the field's description. They now render `NULL`, `1`/`0`,
|
|
176
|
+
`'20261011'` and the field's qualified name. A `Decimal` with an exponent is written in full (`1000`, not
|
|
177
|
+
`1E+3`, which T-SQL reads as a float).
|
|
178
|
+
|
|
179
|
+
Breaking:
|
|
180
|
+
- `Update` values of an unsupported type raise `TypeError` instead of being written with `str()`.
|
|
181
|
+
|
|
109
182
|
### 0.2.0
|
|
110
183
|
Queries built with 0.1.x produce the same SQL; see "Breaking" for the few behavior changes.
|
|
111
184
|
- `LeftJoin`, `RightJoin` and `FullJoin` (same arguments as `InnerJoin`), and `CrossJoin(table)`.
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
from .clauses import (
|
|
2
2
|
CrossJoin,
|
|
3
|
+
Delete,
|
|
3
4
|
From,
|
|
4
5
|
FullJoin,
|
|
5
6
|
IncorrectUpdateException,
|
|
6
7
|
InnerJoin,
|
|
8
|
+
Insert,
|
|
7
9
|
LeftJoin,
|
|
8
10
|
OrderBy,
|
|
9
11
|
RightJoin,
|
|
@@ -11,6 +13,7 @@ from .clauses import (
|
|
|
11
13
|
Update,
|
|
12
14
|
Where,
|
|
13
15
|
)
|
|
16
|
+
from .conditions import Condition
|
|
14
17
|
from .exceptions import SqlQueryBuilderError
|
|
15
18
|
from .query_builder import IncompatibleClausesException, QueryBuilder, UnavailablePlaceException
|
|
16
19
|
from .tables import (
|
|
@@ -26,3 +29,4 @@ from .tables import (
|
|
|
26
29
|
TableException,
|
|
27
30
|
TempTable,
|
|
28
31
|
)
|
|
32
|
+
from .values import NStr, Raw, Str
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping, Sequence
|
|
4
|
+
|
|
5
|
+
from .abstract_clause import Clause
|
|
6
|
+
from .conditions import Comparable, Condition, Junction, check_operand, render_operand
|
|
7
|
+
from .exceptions import SqlQueryBuilderError
|
|
8
|
+
from .tables import Aliasable, Field, Fields, Table, TempTable
|
|
9
|
+
from .values import render_value
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class IncorrectUpdateException(SqlQueryBuilderError):
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _insert_value(value: object) -> object:
|
|
17
|
+
# VALUES cannot read columns: unlike in a condition, a field or expression is no value here.
|
|
18
|
+
if isinstance(value, Comparable):
|
|
19
|
+
raise TypeError(f"Unsupported value type: {type(value).__name__}.")
|
|
20
|
+
return check_operand(value)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _row_count(keyword: str, value: int, minimum: int) -> int:
|
|
24
|
+
"""Validates a row count for TOP, OFFSET or FETCH."""
|
|
25
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
26
|
+
raise TypeError(f"{keyword} must be an int, got {type(value).__name__}.")
|
|
27
|
+
if value < minimum:
|
|
28
|
+
expected: str = "zero or more" if minimum == 0 else f"at least {minimum}"
|
|
29
|
+
raise ValueError(f"{keyword} must be {expected}, got {value}.")
|
|
30
|
+
return value
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Select(Clause):
|
|
34
|
+
def __init__(self, *fields: Fields | Aliasable | Table, distinct: bool = False, top: int | None = None) -> None:
|
|
35
|
+
if top is not None:
|
|
36
|
+
_row_count("TOP", top, minimum=0)
|
|
37
|
+
self.fields: list[Aliasable] = []
|
|
38
|
+
for el in fields:
|
|
39
|
+
if isinstance(el, Fields):
|
|
40
|
+
self.fields.extend(el.as_list())
|
|
41
|
+
elif isinstance(el, Table):
|
|
42
|
+
self.fields.extend(el.fields.as_list())
|
|
43
|
+
else:
|
|
44
|
+
self.fields.append(el)
|
|
45
|
+
self.distinct: bool = distinct
|
|
46
|
+
self.top: int | None = top
|
|
47
|
+
self.temp_table: TempTable | None = None
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def place(self) -> int:
|
|
51
|
+
return 0
|
|
52
|
+
|
|
53
|
+
def build(self) -> str:
|
|
54
|
+
head: str = "SELECT"
|
|
55
|
+
if self.distinct:
|
|
56
|
+
head += " DISTINCT"
|
|
57
|
+
if self.top is not None:
|
|
58
|
+
head += f" TOP ({self.top})"
|
|
59
|
+
ret: str = f"{head} " + (", ".join([field.build(True, True) for field in self.fields]) if self.fields else "*")
|
|
60
|
+
if self.temp_table is not None:
|
|
61
|
+
ret += "\nINTO " + self.temp_table.build()
|
|
62
|
+
return ret
|
|
63
|
+
|
|
64
|
+
def to_temptable(self, name: str) -> TempTable:
|
|
65
|
+
self.temp_table = TempTable(name, *self.fields)
|
|
66
|
+
return self.temp_table
|
|
67
|
+
|
|
68
|
+
def __repr__(self) -> str:
|
|
69
|
+
options: str = (", distinct=True" if self.distinct else "") + (
|
|
70
|
+
f", top={self.top}" if self.top is not None else ""
|
|
71
|
+
)
|
|
72
|
+
return f"{self.__class__.__name__}(fields={self.fields}{options})"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class From(Clause):
|
|
76
|
+
def __init__(self, table: Table) -> None:
|
|
77
|
+
self.table: Table = table
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def place(self) -> int:
|
|
81
|
+
return 2
|
|
82
|
+
|
|
83
|
+
def build(self) -> str:
|
|
84
|
+
return f"FROM {self.table.build(True)}"
|
|
85
|
+
|
|
86
|
+
def __repr__(self) -> str:
|
|
87
|
+
return f"{self.__class__.__name__}(table={self.table})"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class Where(Clause):
|
|
91
|
+
"""`WHERE` and its parts, joined by spaces: conditions, fields, and strings written as raw SQL."""
|
|
92
|
+
|
|
93
|
+
def __init__(self, *conditions: str | Aliasable | Condition) -> None:
|
|
94
|
+
self.conditions: tuple[str | Aliasable | Condition, ...] = conditions
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def place(self) -> int:
|
|
98
|
+
return 4
|
|
99
|
+
|
|
100
|
+
def build(self) -> str:
|
|
101
|
+
ret: list[str] = ["WHERE"]
|
|
102
|
+
for condition in self.conditions:
|
|
103
|
+
if isinstance(condition, Condition):
|
|
104
|
+
sql: str = condition.build()
|
|
105
|
+
# Next to raw strings, an AND or OR condition keeps its meaning only in parentheses.
|
|
106
|
+
ret.append(f"({sql})" if isinstance(condition, Junction) and len(self.conditions) > 1 else sql)
|
|
107
|
+
else:
|
|
108
|
+
ret.append(condition.build() if isinstance(condition, Aliasable) else condition)
|
|
109
|
+
return " ".join(ret)
|
|
110
|
+
|
|
111
|
+
def __repr__(self) -> str:
|
|
112
|
+
return f"{self.__class__.__name__}(conditions={self.conditions})"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class Join(Clause):
|
|
116
|
+
"""A join on `on_left = on_right`, or on any condition given as `on`; each subclass only sets its `keyword`."""
|
|
117
|
+
|
|
118
|
+
keyword: str
|
|
119
|
+
|
|
120
|
+
def __init__(
|
|
121
|
+
self,
|
|
122
|
+
table: Table,
|
|
123
|
+
on_left: Field | None = None,
|
|
124
|
+
on_right: Field | Table | None = None,
|
|
125
|
+
*,
|
|
126
|
+
on: Condition | None = None,
|
|
127
|
+
) -> None:
|
|
128
|
+
if on is None:
|
|
129
|
+
if on_left is None or on_right is None:
|
|
130
|
+
raise TypeError(f"{self.__class__.__name__} needs on_left and on_right, or on.")
|
|
131
|
+
on_right = on_right if isinstance(on_right, Aliasable) else on_right.fields[on_left.name]
|
|
132
|
+
on = on_left.eq(on_right)
|
|
133
|
+
elif on_left is not None or on_right is not None:
|
|
134
|
+
raise TypeError(f"{self.__class__.__name__} takes on_left and on_right, or on, not both.")
|
|
135
|
+
elif not isinstance(on, Condition):
|
|
136
|
+
raise TypeError(f"on must be a Condition, got {type(on).__name__}.")
|
|
137
|
+
self.table: Table = table
|
|
138
|
+
self.on_left: Field | None = on_left
|
|
139
|
+
self.on_right: Field | None = on_right
|
|
140
|
+
self.on: Condition = on
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def place(self) -> int:
|
|
144
|
+
return 3
|
|
145
|
+
|
|
146
|
+
@property
|
|
147
|
+
def is_exclusive(self) -> bool:
|
|
148
|
+
return False
|
|
149
|
+
|
|
150
|
+
def build(self) -> str:
|
|
151
|
+
return f"{self.keyword} {self.table.build(use_name=True)} on {self.on.build()}"
|
|
152
|
+
|
|
153
|
+
def __repr__(self) -> str:
|
|
154
|
+
if self.on_left is None:
|
|
155
|
+
return f"{self.__class__.__name__}(table={self.table}, on={self.on})"
|
|
156
|
+
return f"{self.__class__.__name__}(table={self.table}, on_left={self.on_left}, on_right={self.on_right})"
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class InnerJoin(Join):
|
|
160
|
+
keyword = "INNER JOIN"
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class LeftJoin(Join):
|
|
164
|
+
keyword = "LEFT JOIN"
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class RightJoin(Join):
|
|
168
|
+
keyword = "RIGHT JOIN"
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class FullJoin(Join):
|
|
172
|
+
keyword = "FULL JOIN"
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class CrossJoin(Clause):
|
|
176
|
+
def __init__(self, table: Table) -> None:
|
|
177
|
+
self.table: Table = table
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def place(self) -> int:
|
|
181
|
+
return 3
|
|
182
|
+
|
|
183
|
+
@property
|
|
184
|
+
def is_exclusive(self) -> bool:
|
|
185
|
+
return False
|
|
186
|
+
|
|
187
|
+
def build(self) -> str:
|
|
188
|
+
return f"CROSS JOIN {self.table.build(use_name=True)}"
|
|
189
|
+
|
|
190
|
+
def __repr__(self) -> str:
|
|
191
|
+
return f"{self.__class__.__name__}(table={self.table})"
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class Update(Clause):
|
|
195
|
+
"""`UPDATE … SET field=value, …`: a plain str value is raw SQL (`"'x'"`), `Str("x")` an escaped string.
|
|
196
|
+
|
|
197
|
+
Any other value goes through `values.py`, like a value in a condition: `None` is `NULL`, a field is its
|
|
198
|
+
qualified name.
|
|
199
|
+
"""
|
|
200
|
+
|
|
201
|
+
def __init__(self, updates: Mapping[Field, object]) -> None:
|
|
202
|
+
if not updates:
|
|
203
|
+
raise IncorrectUpdateException("The Update is empty.")
|
|
204
|
+
self.updates: dict[Field, object] = {}
|
|
205
|
+
first_table: Table | None = None
|
|
206
|
+
for field, value in updates.items():
|
|
207
|
+
if first_table is None:
|
|
208
|
+
first_table = field.table
|
|
209
|
+
elif (first_table.name, first_table.alias) != (field.table.name, field.table.alias):
|
|
210
|
+
# The same name under another alias is still another table in the query: show the aliases.
|
|
211
|
+
same_name: bool = first_table.name == field.table.name
|
|
212
|
+
first: str = first_table.build(use_name=True) if same_name else first_table.name
|
|
213
|
+
other: str = field.table.build(use_name=True) if same_name else field.table.name
|
|
214
|
+
raise IncorrectUpdateException(
|
|
215
|
+
f"Two different tables cannot be updated in the same Update: '{first}' and '{other}'."
|
|
216
|
+
)
|
|
217
|
+
self.updates[field] = value if isinstance(value, str) else check_operand(value)
|
|
218
|
+
self.table: Table = field.table
|
|
219
|
+
|
|
220
|
+
@property
|
|
221
|
+
def place(self) -> int:
|
|
222
|
+
return 0
|
|
223
|
+
|
|
224
|
+
def build(self) -> str:
|
|
225
|
+
# A plain str stays raw SQL until 1.0 (D4 in ROADMAP.md): turning it into a value would quote it.
|
|
226
|
+
field_strings: list[str] = [
|
|
227
|
+
f"{field.build()}={value if isinstance(value, str) else render_operand(value)}"
|
|
228
|
+
for field, value in self.updates.items()
|
|
229
|
+
]
|
|
230
|
+
return f"UPDATE {self.table.build(use_name=False, use_alias=True)}\nSET " + ", ".join(field_strings)
|
|
231
|
+
|
|
232
|
+
def __repr__(self) -> str:
|
|
233
|
+
return f"{self.__class__.__name__}(updates={self.updates})"
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class Insert(Clause):
|
|
237
|
+
"""`INSERT INTO table (columns)` and `VALUES (…), (…)`, with the rows added by `values()`.
|
|
238
|
+
|
|
239
|
+
The columns are fields of one table, or whole tables, as `Select` takes them. Unlike in `Update`, a plain
|
|
240
|
+
str is a value here. An Insert stands alone in its query (see `QueryBuilder.build()`).
|
|
241
|
+
"""
|
|
242
|
+
|
|
243
|
+
def __init__(self, *columns: Field | Table) -> None:
|
|
244
|
+
tables: list[Table] = []
|
|
245
|
+
self.columns: list[Field] = []
|
|
246
|
+
for column in columns:
|
|
247
|
+
if isinstance(column, Table):
|
|
248
|
+
tables.append(column)
|
|
249
|
+
self.columns.extend(column.fields.as_list())
|
|
250
|
+
elif isinstance(column, Field):
|
|
251
|
+
tables.append(column.table)
|
|
252
|
+
self.columns.append(column)
|
|
253
|
+
else:
|
|
254
|
+
raise TypeError(f"Insert takes fields or tables, got {type(column).__name__}.")
|
|
255
|
+
if not self.columns:
|
|
256
|
+
raise ValueError("Insert needs at least one column.")
|
|
257
|
+
for table in tables[1:]:
|
|
258
|
+
if table.name != tables[0].name:
|
|
259
|
+
raise ValueError(f"Insert columns must come from one table, got '{tables[0].name}' and '{table.name}'.")
|
|
260
|
+
names: set[str] = set()
|
|
261
|
+
for column in self.columns:
|
|
262
|
+
if column.name in names:
|
|
263
|
+
raise ValueError(f"Insert column '{column.name}' appears twice.")
|
|
264
|
+
names.add(column.name)
|
|
265
|
+
self.table: Table = tables[0]
|
|
266
|
+
self.rows: list[tuple[object, ...]] = []
|
|
267
|
+
|
|
268
|
+
@property
|
|
269
|
+
def place(self) -> int:
|
|
270
|
+
return 0
|
|
271
|
+
|
|
272
|
+
def values(self, *rows: Sequence[object]) -> Insert:
|
|
273
|
+
"""Adds rows, each a tuple or list of values in column order, and returns the Insert."""
|
|
274
|
+
if len(self.rows) + len(rows) > 1000:
|
|
275
|
+
raise ValueError(f"An INSERT takes at most 1000 rows, got {len(self.rows) + len(rows)}.")
|
|
276
|
+
checked: list[tuple[object, ...]] = []
|
|
277
|
+
expected: str = f"{len(self.columns)} value{'s' if len(self.columns) > 1 else ''}"
|
|
278
|
+
for number, row in enumerate(rows, start=len(self.rows) + 1):
|
|
279
|
+
if isinstance(row, (str, bytes)) or not isinstance(row, Sequence):
|
|
280
|
+
raise TypeError(f"An Insert row must be a tuple or list of values, got {type(row).__name__}.")
|
|
281
|
+
if len(row) != len(self.columns):
|
|
282
|
+
raise ValueError(f"Insert row {number} needs {expected}, got {len(row)}.")
|
|
283
|
+
checked.append(tuple(_insert_value(value) for value in row))
|
|
284
|
+
self.rows.extend(checked)
|
|
285
|
+
return self
|
|
286
|
+
|
|
287
|
+
def build(self) -> str:
|
|
288
|
+
if not self.rows:
|
|
289
|
+
raise ValueError("The Insert has no rows: add them with values().")
|
|
290
|
+
# T-SQL refuses an alias after INSERT INTO, and qualified column names.
|
|
291
|
+
columns: str = ", ".join(column.name for column in self.columns)
|
|
292
|
+
rows: str = ", ".join(f"({', '.join(render_value(value) for value in row)})" for row in self.rows)
|
|
293
|
+
return f"INSERT INTO {self.table.name} ({columns})\nVALUES {rows}"
|
|
294
|
+
|
|
295
|
+
def __repr__(self) -> str:
|
|
296
|
+
return f"{self.__class__.__name__}(columns={self.columns}, rows={len(self.rows)})"
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
class Delete(Clause):
|
|
300
|
+
"""`DELETE` and the table's alias, or its name; T-SQL reads an alias from `From(table)`, as for `Update`.
|
|
301
|
+
|
|
302
|
+
A query with a Delete needs a Where unless `where_required=False`: an empty filter would delete every row.
|
|
303
|
+
"""
|
|
304
|
+
|
|
305
|
+
def __init__(self, table: Table, *, where_required: bool = True) -> None:
|
|
306
|
+
if not isinstance(table, Table):
|
|
307
|
+
raise TypeError(f"Delete takes a Table, got {type(table).__name__}.")
|
|
308
|
+
self.table: Table = table
|
|
309
|
+
self.where_required: bool = where_required
|
|
310
|
+
|
|
311
|
+
@property
|
|
312
|
+
def place(self) -> int:
|
|
313
|
+
return 0
|
|
314
|
+
|
|
315
|
+
def build(self) -> str:
|
|
316
|
+
return f"DELETE {self.table.build(use_name=False, use_alias=True)}"
|
|
317
|
+
|
|
318
|
+
def __repr__(self) -> str:
|
|
319
|
+
option: str = "" if self.where_required else ", where_required=False"
|
|
320
|
+
return f"{self.__class__.__name__}(table={self.table}{option})"
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
class OrderBy(Clause):
|
|
324
|
+
def __init__(self, field: Aliasable, descending: bool = False) -> None:
|
|
325
|
+
self.ordering: list[tuple[Aliasable, bool]] = [(field, descending)]
|
|
326
|
+
self.offset_rows: int | None = None
|
|
327
|
+
self.fetch_rows: int | None = None
|
|
328
|
+
|
|
329
|
+
@property
|
|
330
|
+
def place(self) -> int:
|
|
331
|
+
return 5
|
|
332
|
+
|
|
333
|
+
@property
|
|
334
|
+
def has_paging(self) -> bool:
|
|
335
|
+
return self.offset_rows is not None or self.fetch_rows is not None
|
|
336
|
+
|
|
337
|
+
def add(self, field: Aliasable, descending: bool = False) -> OrderBy:
|
|
338
|
+
self.ordering.append((field, descending))
|
|
339
|
+
return self
|
|
340
|
+
|
|
341
|
+
def offset(self, rows: int) -> OrderBy:
|
|
342
|
+
self.offset_rows = _row_count("OFFSET", rows, minimum=0)
|
|
343
|
+
return self
|
|
344
|
+
|
|
345
|
+
def fetch(self, rows: int) -> OrderBy:
|
|
346
|
+
self.fetch_rows = _row_count("FETCH", rows, minimum=1)
|
|
347
|
+
return self
|
|
348
|
+
|
|
349
|
+
def build(self) -> str:
|
|
350
|
+
ret_list: list[str] = []
|
|
351
|
+
for field, desc in self.ordering:
|
|
352
|
+
ret_list.append(f"{field.build(use_name=False, use_alias=True)}{' DESC' if desc else ''}")
|
|
353
|
+
ret: str = "ORDER BY " + ", ".join(ret_list)
|
|
354
|
+
if self.has_paging:
|
|
355
|
+
# T-SQL requires OFFSET before FETCH: fetch() alone starts at the first row.
|
|
356
|
+
ret += f"\nOFFSET {self.offset_rows or 0} ROWS"
|
|
357
|
+
if self.fetch_rows is not None:
|
|
358
|
+
ret += f" FETCH NEXT {self.fetch_rows} ROWS ONLY"
|
|
359
|
+
return ret
|
|
360
|
+
|
|
361
|
+
def __repr__(self) -> str:
|
|
362
|
+
paging: str = (f", offset={self.offset_rows}" if self.offset_rows is not None else "") + (
|
|
363
|
+
f", fetch={self.fetch_rows}" if self.fetch_rows is not None else ""
|
|
364
|
+
)
|
|
365
|
+
return f"{self.__class__.__name__}(ordering={self.ordering}{paging})"
|