sql_querystring_builder 0.1.1__py3-none-any.whl → 0.3.0__py3-none-any.whl
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/__init__.py +30 -3
- sql_querystring_builder/clauses.py +143 -23
- sql_querystring_builder/conditions.py +194 -0
- sql_querystring_builder/exceptions.py +2 -0
- sql_querystring_builder/query_builder.py +26 -3
- sql_querystring_builder/tables.py +91 -31
- sql_querystring_builder/values.py +139 -0
- {sql_querystring_builder-0.1.1.dist-info → sql_querystring_builder-0.3.0.dist-info}/METADATA +71 -12
- sql_querystring_builder-0.3.0.dist-info/RECORD +14 -0
- sql_querystring_builder-0.1.1.dist-info/RECORD +0 -11
- {sql_querystring_builder-0.1.1.dist-info → sql_querystring_builder-0.3.0.dist-info}/WHEEL +0 -0
- {sql_querystring_builder-0.1.1.dist-info → sql_querystring_builder-0.3.0.dist-info}/licenses/LICENSE +0 -0
- {sql_querystring_builder-0.1.1.dist-info → sql_querystring_builder-0.3.0.dist-info}/top_level.txt +0 -0
|
@@ -1,3 +1,30 @@
|
|
|
1
|
-
from .clauses import
|
|
2
|
-
|
|
3
|
-
|
|
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
|
|
@@ -1,19 +1,34 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
|
|
3
5
|
from .abstract_clause import Clause
|
|
6
|
+
from .conditions import Condition, Junction, check_operand, render_operand
|
|
7
|
+
from .exceptions import SqlQueryBuilderError
|
|
4
8
|
from .tables import Aliasable, Field, Fields, Table, TempTable
|
|
5
9
|
|
|
6
10
|
|
|
7
|
-
class IncorrectUpdateException(
|
|
11
|
+
class IncorrectUpdateException(SqlQueryBuilderError):
|
|
8
12
|
pass
|
|
9
13
|
|
|
10
14
|
|
|
11
15
|
# TODO Add INSERT
|
|
12
|
-
|
|
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
|
|
13
26
|
|
|
14
27
|
|
|
15
28
|
class Select(Clause):
|
|
16
|
-
def __init__(self, *fields: Fields | Aliasable | Table) -> None:
|
|
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)
|
|
17
32
|
self.fields: list[Aliasable] = []
|
|
18
33
|
for el in fields:
|
|
19
34
|
if isinstance(el, Fields):
|
|
@@ -22,6 +37,8 @@ class Select(Clause):
|
|
|
22
37
|
self.fields.extend(el.fields.as_list())
|
|
23
38
|
else:
|
|
24
39
|
self.fields.append(el)
|
|
40
|
+
self.distinct: bool = distinct
|
|
41
|
+
self.top: int | None = top
|
|
25
42
|
self.temp_table: TempTable | None = None
|
|
26
43
|
|
|
27
44
|
@property
|
|
@@ -29,9 +46,13 @@ class Select(Clause):
|
|
|
29
46
|
return 0
|
|
30
47
|
|
|
31
48
|
def build(self) -> str:
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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:
|
|
35
56
|
ret += "\nINTO " + self.temp_table.build()
|
|
36
57
|
return ret
|
|
37
58
|
|
|
@@ -40,7 +61,10 @@ class Select(Clause):
|
|
|
40
61
|
return self.temp_table
|
|
41
62
|
|
|
42
63
|
def __repr__(self) -> str:
|
|
43
|
-
|
|
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})"
|
|
44
68
|
|
|
45
69
|
|
|
46
70
|
class From(Clause):
|
|
@@ -59,8 +83,10 @@ class From(Clause):
|
|
|
59
83
|
|
|
60
84
|
|
|
61
85
|
class Where(Clause):
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
64
90
|
|
|
65
91
|
@property
|
|
66
92
|
def place(self) -> int:
|
|
@@ -69,20 +95,44 @@ class Where(Clause):
|
|
|
69
95
|
def build(self) -> str:
|
|
70
96
|
ret: list[str] = ["WHERE"]
|
|
71
97
|
for condition in self.conditions:
|
|
72
|
-
|
|
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)
|
|
73
104
|
return " ".join(ret)
|
|
74
105
|
|
|
75
106
|
def __repr__(self) -> str:
|
|
76
107
|
return f"{self.__class__.__name__}(conditions={self.conditions})"
|
|
77
108
|
|
|
78
109
|
|
|
79
|
-
class
|
|
80
|
-
|
|
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__}.")
|
|
81
132
|
self.table: Table = table
|
|
82
|
-
self.on_left: Field = on_left
|
|
83
|
-
self.on_right: Field =
|
|
84
|
-
|
|
85
|
-
)
|
|
133
|
+
self.on_left: Field | None = on_left
|
|
134
|
+
self.on_right: Field | None = on_right
|
|
135
|
+
self.on: Condition = on
|
|
86
136
|
|
|
87
137
|
@property
|
|
88
138
|
def place(self) -> int:
|
|
@@ -93,17 +143,60 @@ class InnerJoin(Clause):
|
|
|
93
143
|
return False
|
|
94
144
|
|
|
95
145
|
def build(self) -> str:
|
|
96
|
-
return f"
|
|
146
|
+
return f"{self.keyword} {self.table.build(use_name=True)} on {self.on.build()}"
|
|
97
147
|
|
|
98
148
|
def __repr__(self) -> str:
|
|
149
|
+
if self.on_left is None:
|
|
150
|
+
return f"{self.__class__.__name__}(table={self.table}, on={self.on})"
|
|
99
151
|
return f"{self.__class__.__name__}(table={self.table}, on_left={self.on_left}, on_right={self.on_right})"
|
|
100
152
|
|
|
101
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
|
+
|
|
102
189
|
class Update(Clause):
|
|
103
|
-
|
|
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:
|
|
104
197
|
if not updates:
|
|
105
198
|
raise IncorrectUpdateException("The Update is empty.")
|
|
106
|
-
self.updates: dict[Field,
|
|
199
|
+
self.updates: dict[Field, object] = {}
|
|
107
200
|
first_table: Table | None = None
|
|
108
201
|
for field, value in updates.items():
|
|
109
202
|
if first_table is None:
|
|
@@ -116,7 +209,7 @@ class Update(Clause):
|
|
|
116
209
|
raise IncorrectUpdateException(
|
|
117
210
|
f"Two different tables cannot be updated in the same Update: '{first}' and '{other}'."
|
|
118
211
|
)
|
|
119
|
-
self.updates[field] = value
|
|
212
|
+
self.updates[field] = value if isinstance(value, str) else check_operand(value)
|
|
120
213
|
self.table: Table = field.table
|
|
121
214
|
|
|
122
215
|
@property
|
|
@@ -124,7 +217,11 @@ class Update(Clause):
|
|
|
124
217
|
return 0
|
|
125
218
|
|
|
126
219
|
def build(self) -> str:
|
|
127
|
-
|
|
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
|
+
]
|
|
128
225
|
return f"UPDATE {self.table.build(use_name=False, use_alias=True)}\nSET " + ", ".join(field_strings)
|
|
129
226
|
|
|
130
227
|
def __repr__(self) -> str:
|
|
@@ -134,20 +231,43 @@ class Update(Clause):
|
|
|
134
231
|
class OrderBy(Clause):
|
|
135
232
|
def __init__(self, field: Aliasable, descending: bool = False) -> None:
|
|
136
233
|
self.ordering: list[tuple[Aliasable, bool]] = [(field, descending)]
|
|
234
|
+
self.offset_rows: int | None = None
|
|
235
|
+
self.fetch_rows: int | None = None
|
|
137
236
|
|
|
138
237
|
@property
|
|
139
238
|
def place(self) -> int:
|
|
140
239
|
return 5
|
|
141
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
|
+
|
|
142
245
|
def add(self, field: Aliasable, descending: bool = False) -> OrderBy:
|
|
143
246
|
self.ordering.append((field, descending))
|
|
144
247
|
return self
|
|
145
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
|
+
|
|
146
257
|
def build(self) -> str:
|
|
147
258
|
ret_list: list[str] = []
|
|
148
259
|
for field, desc in self.ordering:
|
|
149
260
|
ret_list.append(f"{field.build(use_name=False, use_alias=True)}{' DESC' if desc else ''}")
|
|
150
|
-
|
|
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
|
|
151
268
|
|
|
152
269
|
def __repr__(self) -> str:
|
|
153
|
-
|
|
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})"
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Conditions for WHERE and JOIN ON, made by the methods of fields: `f.eq(1) & g.like("A%")`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from collections.abc import Iterable
|
|
7
|
+
|
|
8
|
+
from .values import check_value, render_value
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Comparable(ABC):
|
|
12
|
+
"""Base of fields, functions on fields and strings: its methods make the conditions that compare them.
|
|
13
|
+
|
|
14
|
+
A compared value is another Comparable, written by its qualified name (`t.f = u.g`), or any value
|
|
15
|
+
`values.py` can write into SQL. `eq` and `ne` are methods, not `==`: a Field is used as a dict key.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
@abstractmethod
|
|
19
|
+
def build(self) -> str: ...
|
|
20
|
+
|
|
21
|
+
def eq(self, value: object) -> Condition:
|
|
22
|
+
"""`= value`, or `IS NULL` when value is None."""
|
|
23
|
+
return IsNull(self) if value is None else Comparison(self, "=", value)
|
|
24
|
+
|
|
25
|
+
def ne(self, value: object) -> Condition:
|
|
26
|
+
"""`<> value`, or `IS NOT NULL` when value is None."""
|
|
27
|
+
return IsNull(self, negated=True) if value is None else Comparison(self, "<>", value)
|
|
28
|
+
|
|
29
|
+
def lt(self, value: object) -> Condition:
|
|
30
|
+
return Comparison(self, "<", value)
|
|
31
|
+
|
|
32
|
+
def le(self, value: object) -> Condition:
|
|
33
|
+
return Comparison(self, "<=", value)
|
|
34
|
+
|
|
35
|
+
def gt(self, value: object) -> Condition:
|
|
36
|
+
return Comparison(self, ">", value)
|
|
37
|
+
|
|
38
|
+
def ge(self, value: object) -> Condition:
|
|
39
|
+
return Comparison(self, ">=", value)
|
|
40
|
+
|
|
41
|
+
def like(self, pattern: object) -> Condition:
|
|
42
|
+
return Comparison(self, "LIKE", pattern)
|
|
43
|
+
|
|
44
|
+
def in_(self, values: Iterable[object]) -> Condition:
|
|
45
|
+
return In(self, values)
|
|
46
|
+
|
|
47
|
+
def between(self, low: object, high: object) -> Condition:
|
|
48
|
+
return Between(self, low, high)
|
|
49
|
+
|
|
50
|
+
def is_null(self) -> Condition:
|
|
51
|
+
return IsNull(self)
|
|
52
|
+
|
|
53
|
+
def is_not_null(self) -> Condition:
|
|
54
|
+
return IsNull(self, negated=True)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def render_operand(value: object) -> str:
|
|
58
|
+
"""Writes a Comparable by its qualified name, and any other value through `values.py`."""
|
|
59
|
+
return value.build() if isinstance(value, Comparable) else render_value(value)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def check_operand(value: object) -> object:
|
|
63
|
+
"""Returns `value`, or raises TypeError or ValueError now if it is neither a Comparable nor a SQL value."""
|
|
64
|
+
if isinstance(value, Condition): # T-SQL has no boolean values; name the public class, not And or Or
|
|
65
|
+
raise TypeError("Unsupported value type: Condition.")
|
|
66
|
+
if not isinstance(value, Comparable):
|
|
67
|
+
check_value(value)
|
|
68
|
+
return value
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Condition(ABC):
|
|
72
|
+
"""A test for WHERE or JOIN ON, made by the methods of a field, such as `f.eq(1)`.
|
|
73
|
+
|
|
74
|
+
Combine conditions with `&`, `|` and `~`, or with `and_()`, `or_()` and `not_()`.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
# T-SQL binds a predicate (`a = 1`, `NOT (…)`) tighter than AND, and AND tighter than OR.
|
|
78
|
+
_binding: int = 3
|
|
79
|
+
|
|
80
|
+
@abstractmethod
|
|
81
|
+
def build(self) -> str: ...
|
|
82
|
+
|
|
83
|
+
def _build_within(self, binding: int) -> str:
|
|
84
|
+
"""Builds the condition, in parentheses when it binds more loosely than what surrounds it."""
|
|
85
|
+
return f"({self.build()})" if self._binding < binding else self.build()
|
|
86
|
+
|
|
87
|
+
def and_(self, other: Condition) -> Condition:
|
|
88
|
+
return And(self, _condition("and_", other))
|
|
89
|
+
|
|
90
|
+
def or_(self, other: Condition) -> Condition:
|
|
91
|
+
return Or(self, _condition("or_", other))
|
|
92
|
+
|
|
93
|
+
def not_(self) -> Condition:
|
|
94
|
+
return Not(self)
|
|
95
|
+
|
|
96
|
+
def __and__(self, other: object) -> Condition:
|
|
97
|
+
return self.and_(other) if isinstance(other, Condition) else NotImplemented
|
|
98
|
+
|
|
99
|
+
def __or__(self, other: object) -> Condition:
|
|
100
|
+
return self.or_(other) if isinstance(other, Condition) else NotImplemented
|
|
101
|
+
|
|
102
|
+
def __invert__(self) -> Condition:
|
|
103
|
+
return self.not_()
|
|
104
|
+
|
|
105
|
+
def __bool__(self) -> bool:
|
|
106
|
+
# Python's and, or, not and if all call __bool__: `c1 and c2` would silently keep only c2.
|
|
107
|
+
raise TypeError(
|
|
108
|
+
"Conditions are not booleans: combine them with &, | and ~ (or and_(), or_() and not_()), "
|
|
109
|
+
"not with Python's and, or and not."
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
def __repr__(self) -> str:
|
|
113
|
+
return f"Condition({self.build()!r})"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _condition(method: str, other: object) -> Condition:
|
|
117
|
+
if not isinstance(other, Condition):
|
|
118
|
+
raise TypeError(f"{method}() takes a Condition, got {type(other).__name__}.")
|
|
119
|
+
return other
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class Comparison(Condition):
|
|
123
|
+
"""`left <operator> right`, such as `t.f = 5` or `t.f LIKE 'A%'`."""
|
|
124
|
+
|
|
125
|
+
def __init__(self, left: Comparable, operator: str, right: object) -> None:
|
|
126
|
+
self.left: Comparable = left
|
|
127
|
+
self.operator: str = operator
|
|
128
|
+
self.right: object = check_operand(right)
|
|
129
|
+
|
|
130
|
+
def build(self) -> str:
|
|
131
|
+
return f"{self.left.build()} {self.operator} {render_operand(self.right)}"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class IsNull(Condition):
|
|
135
|
+
def __init__(self, operand: Comparable, negated: bool = False) -> None:
|
|
136
|
+
self.operand: Comparable = operand
|
|
137
|
+
self.negated: bool = negated
|
|
138
|
+
|
|
139
|
+
def build(self) -> str:
|
|
140
|
+
return f"{self.operand.build()} IS {'NOT ' if self.negated else ''}NULL"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class In(Condition):
|
|
144
|
+
def __init__(self, left: Comparable, values: Iterable[object]) -> None:
|
|
145
|
+
# A str is iterable too, but `in_("ab")` would test each of its letters.
|
|
146
|
+
if isinstance(values, (str, bytes)) or not isinstance(values, Iterable):
|
|
147
|
+
raise TypeError(f"in_() takes a collection of values, got {type(values).__name__}.")
|
|
148
|
+
self.left: Comparable = left
|
|
149
|
+
self.values: tuple[object, ...] = tuple(check_operand(value) for value in values)
|
|
150
|
+
if not self.values:
|
|
151
|
+
raise ValueError("in_() needs at least one value.")
|
|
152
|
+
|
|
153
|
+
def build(self) -> str:
|
|
154
|
+
return f"{self.left.build()} IN ({', '.join(render_operand(value) for value in self.values)})"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class Between(Condition):
|
|
158
|
+
def __init__(self, left: Comparable, low: object, high: object) -> None:
|
|
159
|
+
self.left: Comparable = left
|
|
160
|
+
self.low: object = check_operand(low)
|
|
161
|
+
self.high: object = check_operand(high)
|
|
162
|
+
|
|
163
|
+
def build(self) -> str:
|
|
164
|
+
return f"{self.left.build()} BETWEEN {render_operand(self.low)} AND {render_operand(self.high)}"
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class Junction(Condition):
|
|
168
|
+
"""Conditions joined by AND or OR; each subclass only sets its `keyword` and how tightly it binds."""
|
|
169
|
+
|
|
170
|
+
keyword: str
|
|
171
|
+
|
|
172
|
+
def __init__(self, *conditions: Condition) -> None:
|
|
173
|
+
self.conditions: tuple[Condition, ...] = conditions
|
|
174
|
+
|
|
175
|
+
def build(self) -> str:
|
|
176
|
+
return f" {self.keyword} ".join(condition._build_within(self._binding) for condition in self.conditions)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class And(Junction):
|
|
180
|
+
keyword = "AND"
|
|
181
|
+
_binding = 2
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class Or(Junction):
|
|
185
|
+
keyword = "OR"
|
|
186
|
+
_binding = 1
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class Not(Condition):
|
|
190
|
+
def __init__(self, condition: Condition) -> None:
|
|
191
|
+
self.condition: Condition = condition
|
|
192
|
+
|
|
193
|
+
def build(self) -> str:
|
|
194
|
+
return f"NOT ({self.condition.build()})"
|
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from collections.abc import Iterable
|
|
4
|
+
from typing import Literal
|
|
4
5
|
|
|
5
6
|
from .abstract_clause import Clause
|
|
7
|
+
from .clauses import OrderBy, Select
|
|
8
|
+
from .exceptions import SqlQueryBuilderError
|
|
9
|
+
from .values import parameterize
|
|
6
10
|
|
|
7
11
|
|
|
8
|
-
class UnavailablePlaceException(
|
|
12
|
+
class UnavailablePlaceException(SqlQueryBuilderError):
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class IncompatibleClausesException(SqlQueryBuilderError):
|
|
9
17
|
pass
|
|
10
18
|
|
|
11
19
|
|
|
@@ -17,7 +25,7 @@ class QueryBuilder:
|
|
|
17
25
|
|
|
18
26
|
def add(self, clause: Clause) -> QueryBuilder:
|
|
19
27
|
old_clause: Clause | None = self._place_is_unavailable(clause)
|
|
20
|
-
if old_clause:
|
|
28
|
+
if old_clause is not None:
|
|
21
29
|
raise UnavailablePlaceException(
|
|
22
30
|
f"Cannot add a new clause with the same exclusive place in the query. New clause='{clause.build()}'. Old clause='{old_clause.build()}'"
|
|
23
31
|
)
|
|
@@ -30,7 +38,22 @@ class QueryBuilder:
|
|
|
30
38
|
return self
|
|
31
39
|
|
|
32
40
|
def build(self) -> str:
|
|
33
|
-
|
|
41
|
+
clauses: list[Clause] = self._sorted_clauses()
|
|
42
|
+
# Checked at build time: TOP and paging can both be set after the clauses were added.
|
|
43
|
+
has_top: bool = any(isinstance(clause, Select) and clause.top is not None for clause in clauses)
|
|
44
|
+
has_paging: bool = any(isinstance(clause, OrderBy) and clause.has_paging for clause in clauses)
|
|
45
|
+
if has_top and has_paging:
|
|
46
|
+
raise IncompatibleClausesException("TOP cannot be combined with OFFSET/FETCH in the same query.")
|
|
47
|
+
return "\n".join(clause.build() for clause in clauses)
|
|
48
|
+
|
|
49
|
+
def build_with_params(self, style: Literal["qmark", "format"] = "qmark") -> tuple[str, tuple[object, ...]]:
|
|
50
|
+
"""Builds the query with a placeholder for each value, and returns it with the values, in order.
|
|
51
|
+
|
|
52
|
+
Pass both to the database driver, as in `cursor.execute(sql, params)`, so that values never enter
|
|
53
|
+
the SQL text. "qmark" (`?`) suits pyodbc; "format" (`%s`) suits pymssql, and doubles every other `%`.
|
|
54
|
+
NULL, `Raw` and fields stay in the SQL, as do the TOP, OFFSET and FETCH counts.
|
|
55
|
+
"""
|
|
56
|
+
return parameterize(self.build, style)
|
|
34
57
|
|
|
35
58
|
def _place_is_unavailable(self, clause: Clause) -> Clause | None:
|
|
36
59
|
if clause.place in self.places and clause.is_exclusive:
|
|
@@ -1,30 +1,34 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from abc import
|
|
3
|
+
from abc import abstractmethod
|
|
4
|
+
from collections.abc import Iterator
|
|
4
5
|
from string import ascii_letters, digits
|
|
5
6
|
|
|
7
|
+
from .conditions import Comparable
|
|
8
|
+
from .exceptions import SqlQueryBuilderError
|
|
6
9
|
|
|
7
|
-
|
|
10
|
+
|
|
11
|
+
class AliasException(SqlQueryBuilderError):
|
|
8
12
|
pass
|
|
9
13
|
|
|
10
14
|
|
|
11
|
-
class AlreadyExistingFieldException(
|
|
15
|
+
class AlreadyExistingFieldException(SqlQueryBuilderError):
|
|
12
16
|
pass
|
|
13
17
|
|
|
14
18
|
|
|
15
|
-
class InvalidNameException(
|
|
19
|
+
class InvalidNameException(SqlQueryBuilderError):
|
|
16
20
|
pass
|
|
17
21
|
|
|
18
22
|
|
|
19
|
-
class NonExistingAliasException(
|
|
23
|
+
class NonExistingAliasException(SqlQueryBuilderError):
|
|
20
24
|
pass
|
|
21
25
|
|
|
22
26
|
|
|
23
|
-
class NonExistingFieldException(AttributeError):
|
|
27
|
+
class NonExistingFieldException(SqlQueryBuilderError, AttributeError):
|
|
24
28
|
pass
|
|
25
29
|
|
|
26
30
|
|
|
27
|
-
class TableException(
|
|
31
|
+
class TableException(SqlQueryBuilderError):
|
|
28
32
|
pass
|
|
29
33
|
|
|
30
34
|
|
|
@@ -50,8 +54,18 @@ def validate_name(name: str, is_hashtag_allowed: bool = False) -> str:
|
|
|
50
54
|
------
|
|
51
55
|
InvalidNameException
|
|
52
56
|
Raised if the name cannot reprensent an element in a strict MS SQL conventions.
|
|
57
|
+
|
|
58
|
+
Notes
|
|
59
|
+
-----
|
|
60
|
+
Each dot-separated part is either a regular identifier (letters, digits and underscores) or a
|
|
61
|
+
bracketed one (`[My Table]`), which may hold any character, dots included; `]]` stands for `]`.
|
|
53
62
|
"""
|
|
54
63
|
|
|
64
|
+
def forbidden_character(pos: int) -> InvalidNameException:
|
|
65
|
+
return InvalidNameException(
|
|
66
|
+
f"Forbidden character '{name[pos]}' was found in the name '{name}' at position {pos}. Only letters, digits and undersores are allowed."
|
|
67
|
+
)
|
|
68
|
+
|
|
55
69
|
def validate_subname(subname: str, startpos: int = 0, is_temp_table_start: bool = False) -> None:
|
|
56
70
|
max_length: int = 116 if is_temp_table_start else 128
|
|
57
71
|
if len(subname) > max_length:
|
|
@@ -62,25 +76,51 @@ def validate_name(name: str, is_hashtag_allowed: bool = False) -> str:
|
|
|
62
76
|
if is_temp_table_start and pos == 0 and c == "#":
|
|
63
77
|
continue # is indeed a temp table starting with #
|
|
64
78
|
if c not in NAMEABLE_CHARS:
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
79
|
+
raise forbidden_character(pos + startpos)
|
|
80
|
+
|
|
81
|
+
def bracketed_end(start: int) -> int:
|
|
82
|
+
"""Returns the position just after the `]` that closes the `[` at `start`."""
|
|
83
|
+
pos: int = start + 1
|
|
84
|
+
while True:
|
|
85
|
+
close: int = name.find("]", pos)
|
|
86
|
+
if close == -1:
|
|
87
|
+
raise InvalidNameException(f"The name {name} has an unclosed '[' at position {start}.")
|
|
88
|
+
if not name.startswith("]]", close):
|
|
89
|
+
return close + 1
|
|
90
|
+
pos = close + 2 # "]]" is an escaped "]" inside the brackets
|
|
69
91
|
|
|
70
92
|
if not name:
|
|
71
93
|
raise InvalidNameException("Empty names are forbidden.")
|
|
72
|
-
if name[0] not in ascii_letters:
|
|
94
|
+
if name[0] not in ascii_letters and name[0] != "[":
|
|
73
95
|
if not is_hashtag_allowed or name[0] != "#":
|
|
74
96
|
raise InvalidNameException(f"The name {name} does not start with a letter.")
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
97
|
+
pos: int = 0
|
|
98
|
+
is_first_part: bool = True
|
|
99
|
+
while True:
|
|
100
|
+
if name.startswith("[", pos):
|
|
101
|
+
end: int = bracketed_end(pos)
|
|
102
|
+
content: str = name[pos + 1 : end - 1].replace("]]", "]")
|
|
103
|
+
if not content:
|
|
104
|
+
raise InvalidNameException(f"The name {name} has an empty bracketed part at position {pos}.")
|
|
105
|
+
if len(content) > 128:
|
|
106
|
+
raise InvalidNameException(
|
|
107
|
+
f"The name {name} is too long or has a component that is too long (got {len(content)}, max is 128)."
|
|
108
|
+
)
|
|
109
|
+
else:
|
|
110
|
+
end = name.find(".", pos)
|
|
111
|
+
if end == -1:
|
|
112
|
+
end = len(name)
|
|
113
|
+
validate_subname(name[pos:end], startpos=pos, is_temp_table_start=is_first_part and is_hashtag_allowed)
|
|
114
|
+
pos = end
|
|
115
|
+
if pos == len(name):
|
|
116
|
+
return name
|
|
117
|
+
if name[pos] != ".":
|
|
118
|
+
raise forbidden_character(pos)
|
|
119
|
+
pos += 1
|
|
120
|
+
is_first_part = False
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class Aliasable(Comparable):
|
|
84
124
|
def __init__(self, name: str, alias: str | None) -> None:
|
|
85
125
|
self.name: str = name
|
|
86
126
|
self.alias: str | None = alias
|
|
@@ -166,31 +206,52 @@ class Fields:
|
|
|
166
206
|
|
|
167
207
|
def __getattr__(self, attr_name: str) -> Field:
|
|
168
208
|
# copy and pickle look attributes up on instances whose __init__ never ran, so `fields` may be missing.
|
|
169
|
-
fields
|
|
170
|
-
if fields is None:
|
|
209
|
+
if "fields" not in self.__dict__:
|
|
171
210
|
raise AttributeError(attr_name)
|
|
172
|
-
|
|
211
|
+
return self[attr_name]
|
|
212
|
+
|
|
213
|
+
# Only dunder methods below: a new public method name would shadow fields named like it.
|
|
214
|
+
def __getitem__(self, name: str) -> Field:
|
|
215
|
+
res: Field | None = self.fields.get(name)
|
|
173
216
|
if res is None:
|
|
174
|
-
raise NonExistingFieldException(f"No field named '{
|
|
217
|
+
raise NonExistingFieldException(f"No field named '{name}' in the table {self.table}.")
|
|
175
218
|
return res
|
|
176
219
|
|
|
220
|
+
def __contains__(self, item: object) -> bool:
|
|
221
|
+
if isinstance(item, Field):
|
|
222
|
+
return self.fields.get(item.name) is item
|
|
223
|
+
return isinstance(item, str) and item in self.fields
|
|
224
|
+
|
|
225
|
+
def __iter__(self) -> Iterator[Field]:
|
|
226
|
+
return iter(self.fields.values())
|
|
227
|
+
|
|
228
|
+
def __len__(self) -> int:
|
|
229
|
+
return len(self.fields)
|
|
230
|
+
|
|
177
231
|
def __repr__(self) -> str:
|
|
178
232
|
return f"Fields(table='{self.table.name}', [{','.join(repr(field) for field in self.fields.values())}])"
|
|
179
233
|
|
|
180
234
|
|
|
181
235
|
class Table:
|
|
182
|
-
def __init__(
|
|
183
|
-
self
|
|
236
|
+
def __init__(
|
|
237
|
+
self,
|
|
238
|
+
name: str,
|
|
239
|
+
alias: str | None = None,
|
|
240
|
+
field_names: list[str] | None = None,
|
|
241
|
+
*,
|
|
242
|
+
is_hashtag_allowed: bool = False,
|
|
243
|
+
) -> None:
|
|
244
|
+
self.name: str = validate_name(name, is_hashtag_allowed)
|
|
184
245
|
self.alias: str | None = alias
|
|
185
246
|
self.fields: Fields = Fields(self)
|
|
186
247
|
if field_names:
|
|
187
248
|
self.add(*field_names)
|
|
188
249
|
|
|
189
|
-
def with_fields(self, *
|
|
250
|
+
def with_fields(self, *fields: Field | str) -> Table:
|
|
190
251
|
"""
|
|
191
252
|
Alias to self.add()
|
|
192
253
|
"""
|
|
193
|
-
return self.add(*
|
|
254
|
+
return self.add(*fields)
|
|
194
255
|
|
|
195
256
|
def add(self, *fields: Field | str) -> Table:
|
|
196
257
|
self.fields.add(*fields)
|
|
@@ -220,8 +281,7 @@ class Table:
|
|
|
220
281
|
class TempTable(Table):
|
|
221
282
|
def __init__(self, name: str, *fields: str | Aliasable | Fields) -> None:
|
|
222
283
|
if not isinstance(name, str):
|
|
223
|
-
|
|
224
|
-
raise Exception("Argument 'name' is not a string.") # noqa: TRY002, TRY004
|
|
284
|
+
raise TypeError("Argument 'name' is not a string.")
|
|
225
285
|
super().__init__(name=f"#{name}", is_hashtag_allowed=True)
|
|
226
286
|
field_names: list[str] = []
|
|
227
287
|
# Columns are named as SELECT ... INTO names them: after the alias when there is one.
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Python values written into SQL: as T-SQL literals by `build()`, or as parameters by `build_with_params()`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from contextvars import ContextVar
|
|
9
|
+
from datetime import date, datetime, time
|
|
10
|
+
from decimal import Decimal
|
|
11
|
+
from uuid import UUID
|
|
12
|
+
|
|
13
|
+
_PLACEHOLDERS: dict[str, str] = {"qmark": "?", "format": "%s"}
|
|
14
|
+
|
|
15
|
+
# The values collected while parameterize() runs; None the rest of the time, when values become literals.
|
|
16
|
+
_params: ContextVar[list[object] | None] = ContextVar("sql_querystring_builder_params", default=None)
|
|
17
|
+
# Until the whole query is built, a collected value stands in the SQL as "\x00<index>\x00". The placeholders
|
|
18
|
+
# then follow the order of the final SQL text, whatever order the clauses rendered their values in.
|
|
19
|
+
_SLOT: re.Pattern[str] = re.compile("\x00([0-9]+)\x00")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Raw:
|
|
23
|
+
"""SQL written verbatim where a value goes, such as `Raw("GETDATE()")`: never escaped."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, sql: str) -> None:
|
|
26
|
+
if not isinstance(sql, str):
|
|
27
|
+
raise TypeError(f"Raw takes a str, got {type(sql).__name__}.")
|
|
28
|
+
self.sql: str = sql
|
|
29
|
+
|
|
30
|
+
def __repr__(self) -> str:
|
|
31
|
+
return f"{self.__class__.__name__}({self.sql!r})"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Str:
|
|
35
|
+
"""A string value, written as an escaped literal: `'O''Brien'`, or `N'café'` when it is not ASCII.
|
|
36
|
+
|
|
37
|
+
Conditions already take a plain str as a value. Str is for the places where a plain str is raw SQL,
|
|
38
|
+
such as `Update`.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, text: str) -> None:
|
|
42
|
+
if not isinstance(text, str):
|
|
43
|
+
raise TypeError(f"{self.__class__.__name__} takes a str, got {type(text).__name__}.")
|
|
44
|
+
self.text: str = text
|
|
45
|
+
|
|
46
|
+
def __repr__(self) -> str:
|
|
47
|
+
return f"{self.__class__.__name__}({self.text!r})"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class NStr(Str):
|
|
51
|
+
"""A string value always written as a Unicode literal, `N'…'`, even when it is ASCII."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def check_value(value: object) -> None:
|
|
55
|
+
"""Raises TypeError or ValueError now, rather than at build time, if `value` cannot be written into SQL."""
|
|
56
|
+
_literal(value)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def render_value(value: object) -> str:
|
|
60
|
+
"""Writes a value as a T-SQL literal, or as a parameter while parameterize() runs.
|
|
61
|
+
|
|
62
|
+
NULL and Raw stay in the SQL text: NULL holds no data, and Raw is SQL.
|
|
63
|
+
"""
|
|
64
|
+
literal: str = _literal(value) # checks the value in both modes
|
|
65
|
+
params: list[object] | None = _params.get()
|
|
66
|
+
if params is None or value is None or isinstance(value, Raw):
|
|
67
|
+
return literal
|
|
68
|
+
params.append(value.text if isinstance(value, Str) else value)
|
|
69
|
+
return f"\x00{len(params) - 1}\x00"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def parameterize(build: Callable[[], str], style: str) -> tuple[str, tuple[object, ...]]:
|
|
73
|
+
"""Runs `build` with its values collected as parameters: returns the SQL with placeholders, and the values.
|
|
74
|
+
|
|
75
|
+
The values are a tuple because pymssql, the driver for the "format" style, refuses a list.
|
|
76
|
+
"""
|
|
77
|
+
if style not in _PLACEHOLDERS:
|
|
78
|
+
raise ValueError(f"style must be 'qmark' or 'format', got {style!r}.")
|
|
79
|
+
collected: list[object] = []
|
|
80
|
+
token = _params.set(collected)
|
|
81
|
+
try:
|
|
82
|
+
sql: str = build()
|
|
83
|
+
finally:
|
|
84
|
+
_params.reset(token)
|
|
85
|
+
if style == "format":
|
|
86
|
+
sql = sql.replace("%", "%%") # pymssql reads a lone % as the start of a placeholder
|
|
87
|
+
params: list[object] = []
|
|
88
|
+
|
|
89
|
+
def placeholder(slot: re.Match[str]) -> str:
|
|
90
|
+
params.append(collected[int(slot.group(1))])
|
|
91
|
+
return _PLACEHOLDERS[style]
|
|
92
|
+
|
|
93
|
+
return _SLOT.sub(placeholder, sql), tuple(params)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _literal(value: object) -> str:
|
|
97
|
+
if value is None:
|
|
98
|
+
return "NULL"
|
|
99
|
+
if isinstance(value, Raw):
|
|
100
|
+
return value.sql
|
|
101
|
+
if isinstance(value, Str):
|
|
102
|
+
return _string(value.text, unicode=isinstance(value, NStr))
|
|
103
|
+
if isinstance(value, str):
|
|
104
|
+
return _string(value)
|
|
105
|
+
if isinstance(value, bool): # before int: bool is a subclass of int
|
|
106
|
+
return "1" if value else "0"
|
|
107
|
+
if isinstance(value, int):
|
|
108
|
+
return str(int(value)) # int() turns an IntEnum member into its number
|
|
109
|
+
if isinstance(value, float):
|
|
110
|
+
if not math.isfinite(value):
|
|
111
|
+
raise ValueError(f"{value!r} is not a valid T-SQL number.")
|
|
112
|
+
return repr(float(value))
|
|
113
|
+
if isinstance(value, Decimal):
|
|
114
|
+
if not value.is_finite():
|
|
115
|
+
raise ValueError(f"{value!r} is not a valid T-SQL number.")
|
|
116
|
+
return format(value, "f") # never an exponent, which T-SQL would read as a float
|
|
117
|
+
if isinstance(value, datetime): # before date: datetime is a subclass of date
|
|
118
|
+
return _string(value.isoformat(timespec=_timespec(value.microsecond)))
|
|
119
|
+
if isinstance(value, date):
|
|
120
|
+
# Unlike 'YYYY-MM-DD', this form reads the same under every language setting.
|
|
121
|
+
return f"'{value.year:04}{value.month:02}{value.day:02}'"
|
|
122
|
+
if isinstance(value, time):
|
|
123
|
+
return _string(value.isoformat(timespec=_timespec(value.microsecond)))
|
|
124
|
+
if isinstance(value, bytes):
|
|
125
|
+
return "0x" + value.hex().upper()
|
|
126
|
+
if isinstance(value, UUID):
|
|
127
|
+
return _string(str(value))
|
|
128
|
+
raise TypeError(f"Unsupported value type: {type(value).__name__}.")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _string(text: str, unicode: bool = False) -> str:
|
|
132
|
+
return ("N" if unicode or not text.isascii() else "") + "'" + text.replace("'", "''") + "'"
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _timespec(microsecond: int) -> str:
|
|
136
|
+
"""Milliseconds when they are exact, as `datetime` columns accept no more; microseconds otherwise."""
|
|
137
|
+
if microsecond == 0:
|
|
138
|
+
return "seconds"
|
|
139
|
+
return "milliseconds" if microsecond % 1000 == 0 else "microseconds"
|
{sql_querystring_builder-0.1.1.dist-info → sql_querystring_builder-0.3.0.dist-info}/METADATA
RENAMED
|
@@ -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,14 @@
|
|
|
1
|
+
sql_querystring_builder/__init__.py,sha256=led5wLchgLCroLb3he49GPCuQImWlSZ5hhVVcuDg2x8,693
|
|
2
|
+
sql_querystring_builder/abstract_clause.py,sha256=l1dGyhsu-k4QoNU16kXfDxLS4lXc02TJtlaNoUpHJXw,261
|
|
3
|
+
sql_querystring_builder/clauses.py,sha256=uSvCv7m3JSsM3THEH__JsUkintgdyxLNqsirR68tPVw,9956
|
|
4
|
+
sql_querystring_builder/conditions.py,sha256=UaaZe1HBxAXSK25icOe0gR84zfOO0zxIKnFNSmndICg,7017
|
|
5
|
+
sql_querystring_builder/exceptions.py,sha256=hdKvQD0Waqc251fRoNy6-eAnFKhQFyNtWqCaeqwd4VQ,140
|
|
6
|
+
sql_querystring_builder/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
sql_querystring_builder/query_builder.py,sha256=MxWzmISZBNzNlFmpbTLENQWSPAzZsts1BZHrjAuzE4k,2928
|
|
8
|
+
sql_querystring_builder/tables.py,sha256=T7QQjMoxpe1Iu1D3dOm8W8tWX1Z0kYIprxpaK3iGSJE,10897
|
|
9
|
+
sql_querystring_builder/values.py,sha256=ef326zMUVxUxGHZ7Y0uZBooqbU473_f-DMMW6A-DlKI,5691
|
|
10
|
+
sql_querystring_builder-0.3.0.dist-info/licenses/LICENSE,sha256=IwGE9guuL-ryRPEKi6wFPI_zOhg7zDZbTYuHbSt_SAk,35823
|
|
11
|
+
sql_querystring_builder-0.3.0.dist-info/METADATA,sha256=7L53tLZwnT18Gwcl31vL6Q3gG3c83LkqRWX6Ut-4n_4,10159
|
|
12
|
+
sql_querystring_builder-0.3.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
13
|
+
sql_querystring_builder-0.3.0.dist-info/top_level.txt,sha256=U1YNBUlpIiidNaXjnScKqzNgTNVIhucoRPG3f287txU,24
|
|
14
|
+
sql_querystring_builder-0.3.0.dist-info/RECORD,,
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
sql_querystring_builder/__init__.py,sha256=Vyiejs1sPHpZC9Qzo97TQVFDHurW8eGraFeWyUe46I0,190
|
|
2
|
-
sql_querystring_builder/abstract_clause.py,sha256=l1dGyhsu-k4QoNU16kXfDxLS4lXc02TJtlaNoUpHJXw,261
|
|
3
|
-
sql_querystring_builder/clauses.py,sha256=G4XCk2ssmUYIQBctTle_tz6qwZ946jmL62dFqgz-ZsQ,5294
|
|
4
|
-
sql_querystring_builder/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
-
sql_querystring_builder/query_builder.py,sha256=n9kkifkAXTJos--T2wlGcsqA9xIXRzYa-SjPPpi0qxk,1601
|
|
6
|
-
sql_querystring_builder/tables.py,sha256=e_FFuc44o4ZAkM3iy37f61ddPp7xdGBiBgsOmMCIv6A,8831
|
|
7
|
-
sql_querystring_builder-0.1.1.dist-info/licenses/LICENSE,sha256=IwGE9guuL-ryRPEKi6wFPI_zOhg7zDZbTYuHbSt_SAk,35823
|
|
8
|
-
sql_querystring_builder-0.1.1.dist-info/METADATA,sha256=5wVbwJqPLg8QFLh1uU940n4HcEUebqQMdgJfz8GN5_E,6053
|
|
9
|
-
sql_querystring_builder-0.1.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
10
|
-
sql_querystring_builder-0.1.1.dist-info/top_level.txt,sha256=U1YNBUlpIiidNaXjnScKqzNgTNVIhucoRPG3f287txU,24
|
|
11
|
-
sql_querystring_builder-0.1.1.dist-info/RECORD,,
|
|
File without changes
|
{sql_querystring_builder-0.1.1.dist-info → sql_querystring_builder-0.3.0.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|
{sql_querystring_builder-0.1.1.dist-info → sql_querystring_builder-0.3.0.dist-info}/top_level.txt
RENAMED
|
File without changes
|