sql_querystring_builder 0.3.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,
@@ -1,18 +1,23 @@
1
1
  from __future__ import annotations
2
2
 
3
- from collections.abc import Mapping
3
+ from collections.abc import Mapping, Sequence
4
4
 
5
5
  from .abstract_clause import Clause
6
- from .conditions import Condition, Junction, check_operand, render_operand
6
+ from .conditions import Comparable, Condition, Junction, check_operand, render_operand
7
7
  from .exceptions import SqlQueryBuilderError
8
8
  from .tables import Aliasable, Field, Fields, Table, TempTable
9
+ from .values import render_value
9
10
 
10
11
 
11
12
  class IncorrectUpdateException(SqlQueryBuilderError):
12
13
  pass
13
14
 
14
15
 
15
- # TODO Add INSERT
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)
16
21
 
17
22
 
18
23
  def _row_count(keyword: str, value: int, minimum: int) -> int:
@@ -228,6 +233,93 @@ class Update(Clause):
228
233
  return f"{self.__class__.__name__}(updates={self.updates})"
229
234
 
230
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
+
231
323
  class OrderBy(Clause):
232
324
  def __init__(self, field: Aliasable, descending: bool = False) -> None:
233
325
  self.ordering: list[tuple[Aliasable, bool]] = [(field, descending)]
@@ -4,7 +4,7 @@ from collections.abc import Iterable
4
4
  from typing import Literal
5
5
 
6
6
  from .abstract_clause import Clause
7
- from .clauses import OrderBy, Select
7
+ from .clauses import Delete, Insert, OrderBy, Select, Where
8
8
  from .exceptions import SqlQueryBuilderError
9
9
  from .values import parameterize
10
10
 
@@ -17,6 +17,14 @@ class IncompatibleClausesException(SqlQueryBuilderError):
17
17
  pass
18
18
 
19
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
+
20
28
  class QueryBuilder:
21
29
  def __init__(self, *initial_clauses: Clause) -> None:
22
30
  self.places: dict[int, list[Clause]] = {}
@@ -27,7 +35,7 @@ class QueryBuilder:
27
35
  old_clause: Clause | None = self._place_is_unavailable(clause)
28
36
  if old_clause is not None:
29
37
  raise UnavailablePlaceException(
30
- 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)}'"
31
39
  )
32
40
  self.places[clause.place] = self.places.get(clause.place, []) + [clause]
33
41
  return self
@@ -39,12 +47,26 @@ class QueryBuilder:
39
47
 
40
48
  def build(self) -> str:
41
49
  clauses: list[Clause] = self._sorted_clauses()
42
- # 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."""
43
56
  has_top: bool = any(isinstance(clause, Select) and clause.top is not None for clause in clauses)
44
57
  has_paging: bool = any(isinstance(clause, OrderBy) and clause.has_paging for clause in clauses)
45
58
  if has_top and has_paging:
46
59
  raise IncompatibleClausesException("TOP cannot be combined with OFFSET/FETCH in the same query.")
47
- 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
+ )
48
70
 
49
71
  def build_with_params(self, style: Literal["qmark", "format"] = "qmark") -> tuple[str, tuple[object, ...]]:
50
72
  """Builds the query with a placeholder for each value, and returns it with the values, in order.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sql_querystring_builder
3
- Version: 0.3.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
@@ -35,6 +35,8 @@ It provides first level validation.
35
35
  2. Using:
36
36
  - [x] Select (with DISTINCT and TOP)
37
37
  - [x] Update (with typed values)
38
+ - [x] Insert (multi-row VALUES)
39
+ - [x] Delete (with joins; refused without a WHERE unless asked)
38
40
  - [x] From
39
41
  - [x] InnerJoin, LeftJoin, RightJoin, FullJoin (on two fields or on any condition), CrossJoin
40
42
  - [x] Where (with conditions)
@@ -47,7 +49,6 @@ It provides first level validation.
47
49
 
48
50
  ## Not implemented (yet)
49
51
  In planned order:
50
- - [ ] Insert and Delete
51
52
  - [ ] Functions (aggregates and others), GROUP BY, HAVING, CASE
52
53
  - [ ] Compound queries (UNION, INTERSECT, EXCEPT) and sub-queries
53
54
 
@@ -134,6 +135,23 @@ print(params)
134
135
  # > (20000, 'A1C%')
135
136
  # With pyodbc: cursor.execute(sql, params). For pymssql, build_with_params("format") writes %s instead.
136
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
+ ```
137
155
 
138
156
  (Find it directly in the module example.py)
139
157
 
@@ -144,6 +162,19 @@ Python 3.10 and above.
144
162
  (None)
145
163
 
146
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
+
147
178
  ### 0.3.0
148
179
  Queries built with 0.2.0 produce the same SQL, except for the fixed `Update` values below.
149
180
  - Conditions are methods of fields, functions on fields and strings: `eq`, `ne`, `lt`, `le`, `gt`, `ge`,
@@ -1,14 +1,14 @@
1
- sql_querystring_builder/__init__.py,sha256=led5wLchgLCroLb3he49GPCuQImWlSZ5hhVVcuDg2x8,693
1
+ sql_querystring_builder/__init__.py,sha256=SZd2MlbJBCH49T1eE2W3B6fIwspRQDq1isImmbyHOwM,719
2
2
  sql_querystring_builder/abstract_clause.py,sha256=l1dGyhsu-k4QoNU16kXfDxLS4lXc02TJtlaNoUpHJXw,261
3
- sql_querystring_builder/clauses.py,sha256=uSvCv7m3JSsM3THEH__JsUkintgdyxLNqsirR68tPVw,9956
3
+ sql_querystring_builder/clauses.py,sha256=QF9iHWrd7gnQy9MD4fQ3oEbFXwIEXuOYDpMVwSJJPNc,14361
4
4
  sql_querystring_builder/conditions.py,sha256=UaaZe1HBxAXSK25icOe0gR84zfOO0zxIKnFNSmndICg,7017
5
5
  sql_querystring_builder/exceptions.py,sha256=hdKvQD0Waqc251fRoNy6-eAnFKhQFyNtWqCaeqwd4VQ,140
6
6
  sql_querystring_builder/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
- sql_querystring_builder/query_builder.py,sha256=MxWzmISZBNzNlFmpbTLENQWSPAzZsts1BZHrjAuzE4k,2928
7
+ sql_querystring_builder/query_builder.py,sha256=V5hi8M73TnzM5qohxi88oEhPqa6919SuBNKiE4EeWiY,4115
8
8
  sql_querystring_builder/tables.py,sha256=T7QQjMoxpe1Iu1D3dOm8W8tWX1Z0kYIprxpaK3iGSJE,10897
9
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,,
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,,