sql_querystring_builder 0.2.0__py3-none-any.whl → 0.4.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.
@@ -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
@@ -1,16 +1,23 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from collections.abc import Mapping, Sequence
4
+
3
5
  from .abstract_clause import Clause
6
+ from .conditions import Comparable, Condition, Junction, check_operand, render_operand
4
7
  from .exceptions import SqlQueryBuilderError
5
8
  from .tables import Aliasable, Field, Fields, Table, TempTable
9
+ from .values import render_value
6
10
 
7
11
 
8
12
  class IncorrectUpdateException(SqlQueryBuilderError):
9
13
  pass
10
14
 
11
15
 
12
- # TODO Add INSERT
13
- # TODO Add concept of condition
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)
14
21
 
15
22
 
16
23
  def _row_count(keyword: str, value: int, minimum: int) -> int:
@@ -81,8 +88,10 @@ class From(Clause):
81
88
 
82
89
 
83
90
  class Where(Clause):
84
- def __init__(self, *conditions: str | Aliasable) -> None:
85
- self.conditions: tuple[str | Aliasable, ...] = conditions
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
86
95
 
87
96
  @property
88
97
  def place(self) -> int:
@@ -91,7 +100,12 @@ class Where(Clause):
91
100
  def build(self) -> str:
92
101
  ret: list[str] = ["WHERE"]
93
102
  for condition in self.conditions:
94
- ret.append(condition.build() if isinstance(condition, Aliasable) else condition)
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)
95
109
  return " ".join(ret)
96
110
 
97
111
  def __repr__(self) -> str:
@@ -99,14 +113,31 @@ class Where(Clause):
99
113
 
100
114
 
101
115
  class Join(Clause):
102
- """A join on `on_left = on_right`; each subclass only sets its `keyword`."""
116
+ """A join on `on_left = on_right`, or on any condition given as `on`; each subclass only sets its `keyword`."""
103
117
 
104
118
  keyword: str
105
119
 
106
- def __init__(self, table: Table, on_left: Field, on_right: Field | Table) -> None:
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__}.")
107
137
  self.table: Table = table
108
- self.on_left: Field = on_left
109
- self.on_right: Field = on_right if isinstance(on_right, Aliasable) else on_right.fields[on_left.name]
138
+ self.on_left: Field | None = on_left
139
+ self.on_right: Field | None = on_right
140
+ self.on: Condition = on
110
141
 
111
142
  @property
112
143
  def place(self) -> int:
@@ -117,9 +148,11 @@ class Join(Clause):
117
148
  return False
118
149
 
119
150
  def build(self) -> str:
120
- return f"{self.keyword} {self.table.build(use_name=True)} on {self.on_left.build()} = {self.on_right.build()}"
151
+ return f"{self.keyword} {self.table.build(use_name=True)} on {self.on.build()}"
121
152
 
122
153
  def __repr__(self) -> str:
154
+ if self.on_left is None:
155
+ return f"{self.__class__.__name__}(table={self.table}, on={self.on})"
123
156
  return f"{self.__class__.__name__}(table={self.table}, on_left={self.on_left}, on_right={self.on_right})"
124
157
 
125
158
 
@@ -159,10 +192,16 @@ class CrossJoin(Clause):
159
192
 
160
193
 
161
194
  class Update(Clause):
162
- def __init__(self, updates: dict[Field, str]) -> None:
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:
163
202
  if not updates:
164
203
  raise IncorrectUpdateException("The Update is empty.")
165
- self.updates: dict[Field, str] = {}
204
+ self.updates: dict[Field, object] = {}
166
205
  first_table: Table | None = None
167
206
  for field, value in updates.items():
168
207
  if first_table is None:
@@ -175,7 +214,7 @@ class Update(Clause):
175
214
  raise IncorrectUpdateException(
176
215
  f"Two different tables cannot be updated in the same Update: '{first}' and '{other}'."
177
216
  )
178
- self.updates[field] = value
217
+ self.updates[field] = value if isinstance(value, str) else check_operand(value)
179
218
  self.table: Table = field.table
180
219
 
181
220
  @property
@@ -183,13 +222,104 @@ class Update(Clause):
183
222
  return 0
184
223
 
185
224
  def build(self) -> str:
186
- field_strings: list[str] = [f"{field.build()}={value}" for field, value in self.updates.items()]
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
+ ]
187
230
  return f"UPDATE {self.table.build(use_name=False, use_alias=True)}\nSET " + ", ".join(field_strings)
188
231
 
189
232
  def __repr__(self) -> str:
190
233
  return f"{self.__class__.__name__}(updates={self.updates})"
191
234
 
192
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
+
193
323
  class OrderBy(Clause):
194
324
  def __init__(self, field: Aliasable, descending: bool = False) -> None:
195
325
  self.ordering: list[tuple[Aliasable, bool]] = [(field, descending)]
@@ -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,10 +1,12 @@
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
6
- from .clauses import OrderBy, Select
7
+ from .clauses import Delete, Insert, OrderBy, Select, Where
7
8
  from .exceptions import SqlQueryBuilderError
9
+ from .values import parameterize
8
10
 
9
11
 
10
12
  class UnavailablePlaceException(SqlQueryBuilderError):
@@ -15,6 +17,14 @@ class IncompatibleClausesException(SqlQueryBuilderError):
15
17
  pass
16
18
 
17
19
 
20
+ def _describe(clause: Clause) -> str:
21
+ """A clause's SQL for an error message, or its repr while it cannot be built (an Insert without rows)."""
22
+ try:
23
+ return clause.build()
24
+ except ValueError:
25
+ return repr(clause)
26
+
27
+
18
28
  class QueryBuilder:
19
29
  def __init__(self, *initial_clauses: Clause) -> None:
20
30
  self.places: dict[int, list[Clause]] = {}
@@ -25,7 +35,7 @@ class QueryBuilder:
25
35
  old_clause: Clause | None = self._place_is_unavailable(clause)
26
36
  if old_clause is not None:
27
37
  raise UnavailablePlaceException(
28
- f"Cannot add a new clause with the same exclusive place in the query. New clause='{clause.build()}'. Old clause='{old_clause.build()}'"
38
+ f"Cannot add a new clause with the same exclusive place in the query. New clause='{_describe(clause)}'. Old clause='{_describe(old_clause)}'"
29
39
  )
30
40
  self.places[clause.place] = self.places.get(clause.place, []) + [clause]
31
41
  return self
@@ -37,12 +47,35 @@ class QueryBuilder:
37
47
 
38
48
  def build(self) -> str:
39
49
  clauses: list[Clause] = self._sorted_clauses()
40
- # Checked at build time: TOP and paging can both be set after the clauses were added.
50
+ self._check_combination(clauses)
51
+ return "\n".join(clause.build() for clause in clauses)
52
+
53
+ @staticmethod
54
+ def _check_combination(clauses: list[Clause]) -> None:
55
+ """Rules across clauses, checked at build time: clauses stay mutable after being added."""
41
56
  has_top: bool = any(isinstance(clause, Select) and clause.top is not None for clause in clauses)
42
57
  has_paging: bool = any(isinstance(clause, OrderBy) and clause.has_paging for clause in clauses)
43
58
  if has_top and has_paging:
44
59
  raise IncompatibleClausesException("TOP cannot be combined with OFFSET/FETCH in the same query.")
45
- return "\n".join(clause.build() for clause in clauses)
60
+ if len(clauses) > 1 and any(isinstance(clause, Insert) for clause in clauses):
61
+ raise IncompatibleClausesException("INSERT cannot be combined with other clauses in the same query.")
62
+ deletes: list[Delete] = [clause for clause in clauses if isinstance(clause, Delete)]
63
+ if deletes:
64
+ if any(isinstance(clause, OrderBy) for clause in clauses):
65
+ raise IncompatibleClausesException("ORDER BY cannot be combined with DELETE in the same query.")
66
+ if deletes[0].where_required and not any(isinstance(clause, Where) for clause in clauses):
67
+ raise IncompatibleClausesException(
68
+ "A DELETE needs a WHERE, unless built with Delete(table, where_required=False)."
69
+ )
70
+
71
+ def build_with_params(self, style: Literal["qmark", "format"] = "qmark") -> tuple[str, tuple[object, ...]]:
72
+ """Builds the query with a placeholder for each value, and returns it with the values, in order.
73
+
74
+ Pass both to the database driver, as in `cursor.execute(sql, params)`, so that values never enter
75
+ the SQL text. "qmark" (`?`) suits pyodbc; "format" (`%s`) suits pymssql, and doubles every other `%`.
76
+ NULL, `Raw` and fields stay in the SQL, as do the TOP, OFFSET and FETCH counts.
77
+ """
78
+ return parameterize(self.build, style)
46
79
 
47
80
  def _place_is_unavailable(self, clause: Clause) -> Clause | None:
48
81
  if clause.place in self.places and clause.is_exclusive:
@@ -1,9 +1,10 @@
1
1
  from __future__ import annotations
2
2
 
3
- from abc import ABC, abstractmethod
3
+ from abc import abstractmethod
4
4
  from collections.abc import Iterator
5
5
  from string import ascii_letters, digits
6
6
 
7
+ from .conditions import Comparable
7
8
  from .exceptions import SqlQueryBuilderError
8
9
 
9
10
 
@@ -119,7 +120,7 @@ def validate_name(name: str, is_hashtag_allowed: bool = False) -> str:
119
120
  is_first_part = False
120
121
 
121
122
 
122
- class Aliasable(ABC):
123
+ class Aliasable(Comparable):
123
124
  def __init__(self, name: str, alias: str | None) -> None:
124
125
  self.name: str = name
125
126
  self.alias: str | None = alias
@@ -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"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sql_querystring_builder
3
- Version: 0.2.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, "> 20000 AND", customers_table.fields.customerId, "LIKE 'A1C%'")
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)`.
@@ -0,0 +1,14 @@
1
+ sql_querystring_builder/__init__.py,sha256=SZd2MlbJBCH49T1eE2W3B6fIwspRQDq1isImmbyHOwM,719
2
+ sql_querystring_builder/abstract_clause.py,sha256=l1dGyhsu-k4QoNU16kXfDxLS4lXc02TJtlaNoUpHJXw,261
3
+ sql_querystring_builder/clauses.py,sha256=QF9iHWrd7gnQy9MD4fQ3oEbFXwIEXuOYDpMVwSJJPNc,14361
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=V5hi8M73TnzM5qohxi88oEhPqa6919SuBNKiE4EeWiY,4115
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.4.0.dist-info/licenses/LICENSE,sha256=IwGE9guuL-ryRPEKi6wFPI_zOhg7zDZbTYuHbSt_SAk,35823
11
+ sql_querystring_builder-0.4.0.dist-info/METADATA,sha256=Vj0h59ZceCuUAGGpfCrBXGKsrjZy_vyHyVNTYnke4BI,11874
12
+ sql_querystring_builder-0.4.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
13
+ sql_querystring_builder-0.4.0.dist-info/top_level.txt,sha256=U1YNBUlpIiidNaXjnScKqzNgTNVIhucoRPG3f287txU,24
14
+ sql_querystring_builder-0.4.0.dist-info/RECORD,,
@@ -1,12 +0,0 @@
1
- sql_querystring_builder/__init__.py,sha256=55ATxTSWWCkAFkMWPQBZv-9wznXDFNyQi826MA9mhx8,622
2
- sql_querystring_builder/abstract_clause.py,sha256=l1dGyhsu-k4QoNU16kXfDxLS4lXc02TJtlaNoUpHJXw,261
3
- sql_querystring_builder/clauses.py,sha256=Rxmuypxbum-QGdGRmz4BqjoEDW3aneAHFRYg38JT_ow,8070
4
- sql_querystring_builder/exceptions.py,sha256=hdKvQD0Waqc251fRoNy6-eAnFKhQFyNtWqCaeqwd4VQ,140
5
- sql_querystring_builder/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
- sql_querystring_builder/query_builder.py,sha256=10MUAVrUj9JJbQ_1aRt8H5YBJ9Xi3SgdWWFwRsX25U4,2275
7
- sql_querystring_builder/tables.py,sha256=1AKnPTDUeO8h3zYE9-UeJYMdmpDfgADsXRx1UrT1BMo,10859
8
- sql_querystring_builder-0.2.0.dist-info/licenses/LICENSE,sha256=IwGE9guuL-ryRPEKi6wFPI_zOhg7zDZbTYuHbSt_SAk,35823
9
- sql_querystring_builder-0.2.0.dist-info/METADATA,sha256=wfzcT-_QCjfzGOW76bIQjNbLUwqppCMA4Vzvv-H2w7o,7267
10
- sql_querystring_builder-0.2.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
11
- sql_querystring_builder-0.2.0.dist-info/top_level.txt,sha256=U1YNBUlpIiidNaXjnScKqzNgTNVIhucoRPG3f287txU,24
12
- sql_querystring_builder-0.2.0.dist-info/RECORD,,