sql_querystring_builder 0.3.0__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. {sql_querystring_builder-0.3.0/src/sql_querystring_builder.egg-info → sql_querystring_builder-0.4.0}/PKG-INFO +33 -2
  2. {sql_querystring_builder-0.3.0 → sql_querystring_builder-0.4.0}/README.md +32 -1
  3. {sql_querystring_builder-0.3.0 → sql_querystring_builder-0.4.0}/pyproject.toml +1 -1
  4. {sql_querystring_builder-0.3.0 → sql_querystring_builder-0.4.0}/src/sql_querystring_builder/__init__.py +2 -0
  5. {sql_querystring_builder-0.3.0 → sql_querystring_builder-0.4.0}/src/sql_querystring_builder/clauses.py +95 -3
  6. {sql_querystring_builder-0.3.0 → sql_querystring_builder-0.4.0}/src/sql_querystring_builder/query_builder.py +26 -4
  7. {sql_querystring_builder-0.3.0 → sql_querystring_builder-0.4.0/src/sql_querystring_builder.egg-info}/PKG-INFO +33 -2
  8. {sql_querystring_builder-0.3.0 → sql_querystring_builder-0.4.0}/src/sql_querystring_builder.egg-info/SOURCES.txt +1 -0
  9. {sql_querystring_builder-0.3.0 → sql_querystring_builder-0.4.0}/tests/test_characterization.py +2 -0
  10. sql_querystring_builder-0.4.0/tests/test_insert_delete.py +227 -0
  11. {sql_querystring_builder-0.3.0 → sql_querystring_builder-0.4.0}/tests/test_sql_syntax.py +23 -2
  12. {sql_querystring_builder-0.3.0 → sql_querystring_builder-0.4.0}/LICENSE +0 -0
  13. {sql_querystring_builder-0.3.0 → sql_querystring_builder-0.4.0}/setup.cfg +0 -0
  14. {sql_querystring_builder-0.3.0 → sql_querystring_builder-0.4.0}/src/sql_querystring_builder/abstract_clause.py +0 -0
  15. {sql_querystring_builder-0.3.0 → sql_querystring_builder-0.4.0}/src/sql_querystring_builder/conditions.py +0 -0
  16. {sql_querystring_builder-0.3.0 → sql_querystring_builder-0.4.0}/src/sql_querystring_builder/exceptions.py +0 -0
  17. {sql_querystring_builder-0.3.0 → sql_querystring_builder-0.4.0}/src/sql_querystring_builder/py.typed +0 -0
  18. {sql_querystring_builder-0.3.0 → sql_querystring_builder-0.4.0}/src/sql_querystring_builder/tables.py +0 -0
  19. {sql_querystring_builder-0.3.0 → sql_querystring_builder-0.4.0}/src/sql_querystring_builder/values.py +0 -0
  20. {sql_querystring_builder-0.3.0 → sql_querystring_builder-0.4.0}/src/sql_querystring_builder.egg-info/dependency_links.txt +0 -0
  21. {sql_querystring_builder-0.3.0 → sql_querystring_builder-0.4.0}/src/sql_querystring_builder.egg-info/top_level.txt +0 -0
  22. {sql_querystring_builder-0.3.0 → sql_querystring_builder-0.4.0}/tests/test_clauses.py +0 -0
  23. {sql_querystring_builder-0.3.0 → sql_querystring_builder-0.4.0}/tests/test_conditions.py +0 -0
  24. {sql_querystring_builder-0.3.0 → sql_querystring_builder-0.4.0}/tests/test_queries.py +0 -0
  25. {sql_querystring_builder-0.3.0 → sql_querystring_builder-0.4.0}/tests/test_readme.py +0 -0
  26. {sql_querystring_builder-0.3.0 → sql_querystring_builder-0.4.0}/tests/test_tables.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sql_querystring_builder
3
- Version: 0.3.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`,
@@ -14,6 +14,8 @@ It provides first level validation.
14
14
  2. Using:
15
15
  - [x] Select (with DISTINCT and TOP)
16
16
  - [x] Update (with typed values)
17
+ - [x] Insert (multi-row VALUES)
18
+ - [x] Delete (with joins; refused without a WHERE unless asked)
17
19
  - [x] From
18
20
  - [x] InnerJoin, LeftJoin, RightJoin, FullJoin (on two fields or on any condition), CrossJoin
19
21
  - [x] Where (with conditions)
@@ -26,7 +28,6 @@ It provides first level validation.
26
28
 
27
29
  ## Not implemented (yet)
28
30
  In planned order:
29
- - [ ] Insert and Delete
30
31
  - [ ] Functions (aggregates and others), GROUP BY, HAVING, CASE
31
32
  - [ ] Compound queries (UNION, INTERSECT, EXCEPT) and sub-queries
32
33
 
@@ -113,6 +114,23 @@ print(params)
113
114
  # > (20000, 'A1C%')
114
115
  # With pyodbc: cursor.execute(sql, params). For pymssql, build_with_params("format") writes %s instead.
115
116
  ```
117
+ 6. Insert and delete rows:
118
+ ```
119
+ # Inserts rows: one tuple per row, with the values in column order
120
+ insert = Insert(customers_table.fields.customerId, customers_table.fields.mainPhone).values(
121
+ ("A1C42", "555-0100"), ("B7", None)
122
+ )
123
+ print(QueryBuilder(insert).build())
124
+ # > INSERT INTO Customers (customerId, mainPhone)
125
+ # > VALUES ('A1C42', '555-0100'), ('B7', NULL)
126
+
127
+ # Deletes the accounts of customer B7; a DELETE needs a WHERE, unless built with where_required=False
128
+ delete = QueryBuilder(Delete(accounts_table), From(accounts_table), Where(accounts_table.fields.customerId.eq("B7")))
129
+ print(delete.build())
130
+ # > DELETE acc
131
+ # > FROM Accounts acc
132
+ # > WHERE acc.customerId = 'B7'
133
+ ```
116
134
 
117
135
  (Find it directly in the module example.py)
118
136
 
@@ -123,6 +141,19 @@ Python 3.10 and above.
123
141
  (None)
124
142
 
125
143
  ## Changelog
144
+ ### 0.4.0
145
+ Queries built with 0.3.0 produce the same SQL.
146
+ - `Insert(*columns).values(*rows)` renders `INSERT INTO Customers (id, name)` and
147
+ `VALUES (1, 'Ann'), (2, 'O''Brien')`. The columns are fields of one table, or a whole table. Each row is a
148
+ tuple of values in column order, with at most 1000 rows per INSERT, as in T-SQL. Values are escaped as in
149
+ conditions, plain strings included, and `Raw("DEFAULT")` writes SQL. An Insert stands alone in its query.
150
+ - `Delete(table)` renders `DELETE` and the table's alias, or its name, and works with `From`, joins and
151
+ `Where`: `DELETE acc FROM Accounts acc INNER JOIN ...`. A query with a DELETE but no WHERE raises
152
+ `IncompatibleClausesException`, unless written `Delete(table, where_required=False)`; so does a DELETE with
153
+ ORDER BY.
154
+ - Both work with `build_with_params()`. SQL Server accepts about 2,100 parameters per query, so split big
155
+ parameterized inserts.
156
+
126
157
  ### 0.3.0
127
158
  Queries built with 0.2.0 produce the same SQL, except for the fixed `Update` values below.
128
159
  - Conditions are methods of fields, functions on fields and strings: `eq`, `ne`, `lt`, `le`, `gt`, `ge`,
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "sql_querystring_builder"
7
- version = "0.3.0"
7
+ version = "0.4.0"
8
8
  authors = [
9
9
  { name="Wael GRIBAA", email="g.wael@outlook.fr" },
10
10
  ]
@@ -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`,
@@ -17,6 +17,7 @@ src/sql_querystring_builder.egg-info/top_level.txt
17
17
  tests/test_characterization.py
18
18
  tests/test_clauses.py
19
19
  tests/test_conditions.py
20
+ tests/test_insert_delete.py
20
21
  tests/test_queries.py
21
22
  tests/test_readme.py
22
23
  tests/test_sql_syntax.py
@@ -32,11 +32,13 @@ class TestPublicApi(TestCase):
32
32
  "AliasableString",
33
33
  "Condition",
34
34
  "CrossJoin",
35
+ "Delete",
35
36
  "Field",
36
37
  "From",
37
38
  "FullJoin",
38
39
  "FunctionOnField",
39
40
  "InnerJoin",
41
+ "Insert",
40
42
  "LeftJoin",
41
43
  "NStr",
42
44
  "OrderBy",
@@ -0,0 +1,227 @@
1
+ from collections.abc import Callable
2
+ from datetime import date
3
+ from unittest import TestCase
4
+
5
+ from sql_querystring_builder.clauses import Delete, From, InnerJoin, Insert, OrderBy, Select, Update, Where
6
+ from sql_querystring_builder.conditions import Condition
7
+ from sql_querystring_builder.query_builder import (
8
+ IncompatibleClausesException,
9
+ QueryBuilder,
10
+ UnavailablePlaceException,
11
+ )
12
+ from sql_querystring_builder.tables import Table, TempTable
13
+ from sql_querystring_builder.values import NStr, Raw, Str
14
+
15
+
16
+ class TestInsert(TestCase):
17
+ def setUp(self) -> None:
18
+ self.maxDiff = None
19
+ self.customers: Table = Table("Customers", "c", field_names=["id", "name", "phone"])
20
+
21
+ def test_rows(self):
22
+ c = self.customers.fields
23
+ insert = Insert(c.id, c.name).values((1, "Ann"), [2, "O'Brien"]).values((3, None))
24
+ expected_result: str = "INSERT INTO Customers (id, name)\nVALUES (1, 'Ann'), (2, 'O''Brien'), (3, NULL)"
25
+ self.assertEqual(insert.build(), expected_result)
26
+
27
+ def test_whole_table_without_aliases(self):
28
+ # T-SQL refuses an alias after INSERT INTO, and qualified column names: neither is written.
29
+ self.customers.fields.name.set_alias("customerName")
30
+ actual_result: str = Insert(self.customers).values((1, "Ann", "555-0100")).build()
31
+ self.assertEqual(actual_result, "INSERT INTO Customers (id, name, phone)\nVALUES (1, 'Ann', '555-0100')")
32
+
33
+ def test_temporary_and_bracketed_tables(self):
34
+ temp_table = TempTable("t", "a")
35
+ bracketed = Table("dbo.[My Table]", field_names=["x"])
36
+ self.assertEqual(Insert(temp_table).values((1,)).build(), "INSERT INTO #t (a)\nVALUES (1)")
37
+ self.assertEqual(Insert(bracketed).values((1,)).build(), "INSERT INTO dbo.[My Table] (x)\nVALUES (1)")
38
+
39
+ def test_values(self):
40
+ c = self.customers.fields
41
+ insert = Insert(c.id, c.name, c.phone).values(
42
+ (Raw("DEFAULT"), NStr("Zoé"), date(2026, 10, 11)), (True, "", 1.5)
43
+ )
44
+ expected_result: str = (
45
+ "INSERT INTO Customers (id, name, phone)\nVALUES (DEFAULT, N'Zoé', '20261011'), (1, '', 1.5)"
46
+ )
47
+ self.assertEqual(insert.build(), expected_result)
48
+
49
+ def test_columns(self):
50
+ c = self.customers.fields
51
+ accounts = Table("Accounts", field_names=["id"])
52
+ expected_err_msgs: dict[str, tuple[type, Callable[[], Insert]]] = {
53
+ "Insert needs at least one column.": (ValueError, lambda: Insert()),
54
+ "Insert takes fields or tables, got str.": (TypeError, lambda: Insert("id")),
55
+ "Insert columns must come from one table, got 'Customers' and 'Accounts'.": (
56
+ ValueError,
57
+ lambda: Insert(c.id, accounts.fields.id),
58
+ ),
59
+ "Insert column 'id' appears twice.": (ValueError, lambda: Insert(c.id, self.customers)),
60
+ }
61
+ for expected_err_msg, (error, make_insert) in expected_err_msgs.items():
62
+ with self.subTest(expected_err_msg=expected_err_msg):
63
+ with self.assertRaises(error) as exc:
64
+ make_insert()
65
+ self.assertEqual(str(exc.exception), expected_err_msg)
66
+ with self.assertRaises(ValueError) as exc:
67
+ Insert(Table("Empty"))
68
+ self.assertEqual(str(exc.exception), "Insert needs at least one column.")
69
+
70
+ def test_rows_are_checked_when_added(self):
71
+ c = self.customers.fields
72
+ insert = Insert(c.id, c.name).values((1, "Ann"))
73
+ expected_err_msgs: dict[str, tuple[type, tuple[object, ...]]] = {
74
+ "Insert row 2 needs 2 values, got 1.": (ValueError, ((2,),)),
75
+ "Insert row 3 needs 2 values, got 3.": (ValueError, ((2, "Bob"), (3, "Cy", "x"))),
76
+ "An Insert row must be a tuple or list of values, got str.": (TypeError, ("ab",)),
77
+ "An Insert row must be a tuple or list of values, got dict.": (TypeError, ({c.id: 1, c.name: "x"},)),
78
+ "Unsupported value type: list.": (TypeError, ((2, ["Bob"]),)),
79
+ "Unsupported value type: Field.": (TypeError, ((2, c.phone),)), # VALUES cannot read columns
80
+ "Unsupported value type: Condition.": (TypeError, ((2, c.id.eq(1)),)),
81
+ "nan is not a valid T-SQL number.": (ValueError, ((float("nan"), "Bob"),)),
82
+ }
83
+ for expected_err_msg, (error, rows) in expected_err_msgs.items():
84
+ with self.subTest(expected_err_msg=expected_err_msg):
85
+ with self.assertRaises(error) as exc:
86
+ insert.values(*rows)
87
+ self.assertEqual(str(exc.exception), expected_err_msg)
88
+ self.assertEqual(len(insert.rows), 1) # a refused call adds none of its rows
89
+ one_column = Insert(c.id)
90
+ with self.assertRaises(ValueError) as exc:
91
+ one_column.values((1, 2))
92
+ self.assertEqual(str(exc.exception), "Insert row 1 needs 1 value, got 2.")
93
+
94
+ def test_at_most_1000_rows(self):
95
+ insert = Insert(self.customers.fields.id).values(*[(n,) for n in range(999)])
96
+ with self.assertRaises(ValueError) as exc:
97
+ insert.values((999,), (1000,)) # the first row would fit, but a call adds its rows all or none
98
+ self.assertEqual(str(exc.exception), "An INSERT takes at most 1000 rows, got 1001.")
99
+ self.assertEqual(len(insert.rows), 999)
100
+ insert.values((999,))
101
+ with self.assertRaises(ValueError) as exc:
102
+ insert.values((1000,))
103
+ self.assertEqual(str(exc.exception), "An INSERT takes at most 1000 rows, got 1001.")
104
+ self.assertEqual(len(insert.rows), 1000)
105
+
106
+ def test_no_rows(self):
107
+ with self.assertRaises(ValueError) as exc:
108
+ Insert(self.customers).build()
109
+ self.assertEqual(str(exc.exception), "The Insert has no rows: add them with values().")
110
+
111
+ def test_repr(self):
112
+ insert = Insert(self.customers.fields.id).values((1,), (2,))
113
+ self.assertEqual(repr(insert), "Insert(columns=[Field(name='id', alias='None', table='Customers')], rows=2)")
114
+
115
+
116
+ class TestInsertQueries(TestCase):
117
+ def setUp(self) -> None:
118
+ self.customers: Table = Table("Customers", "c", field_names=["id", "name"])
119
+ c = self.customers.fields
120
+ self.insert: Insert = Insert(c.id, c.name).values((1, "O'Brien"), (2, None), (Raw("DEFAULT"), "Bob"))
121
+
122
+ def test_insert_alone(self):
123
+ expected_result: str = "INSERT INTO Customers (id, name)\nVALUES (1, 'O''Brien'), (2, NULL), (DEFAULT, 'Bob')"
124
+ self.assertEqual(QueryBuilder(self.insert).build(), expected_result)
125
+
126
+ def test_parameters(self):
127
+ expected_result: tuple[str, tuple[object, ...]] = (
128
+ "INSERT INTO Customers (id, name)\nVALUES (?, ?), (?, NULL), (DEFAULT, ?)",
129
+ (1, "O'Brien", 2, "Bob"),
130
+ )
131
+ self.assertEqual(QueryBuilder(self.insert).build_with_params(), expected_result)
132
+
133
+ def test_insert_stands_alone(self):
134
+ query_builder = QueryBuilder(self.insert, Where(self.customers.fields.id.eq(1)))
135
+ with self.assertRaises(IncompatibleClausesException) as exc:
136
+ query_builder.build()
137
+ self.assertEqual(str(exc.exception), "INSERT cannot be combined with other clauses in the same query.")
138
+ with self.assertRaises(UnavailablePlaceException):
139
+ QueryBuilder(self.insert, Select(self.customers))
140
+
141
+ def test_clash_with_an_insert_without_rows(self):
142
+ # An Insert without rows cannot be built, so the message shows its repr instead of hiding the clash.
143
+ with self.assertRaises(UnavailablePlaceException) as exc:
144
+ QueryBuilder(Select(self.customers.fields.name), Insert(self.customers.fields.id))
145
+ self.assertEqual(
146
+ str(exc.exception),
147
+ "Cannot add a new clause with the same exclusive place in the query. "
148
+ "New clause='Insert(columns=[Field(name='id', alias='None', table='Customers')], rows=0)'. "
149
+ "Old clause='SELECT c.name'",
150
+ )
151
+
152
+
153
+ class TestDelete(TestCase):
154
+ def setUp(self) -> None:
155
+ self.maxDiff = None
156
+ self.customers: Table = Table("Customers", "c", field_names=["id", "name"])
157
+ self.accounts: Table = Table("Accounts", "acc", field_names=["id", "customerId", "closed"])
158
+ self.logs: Table = Table("Logs", field_names=["id", "level"])
159
+
160
+ def test_delete_with_a_join(self):
161
+ accounts, customers = self.accounts, self.customers
162
+ query_builder = QueryBuilder(
163
+ Delete(accounts),
164
+ From(accounts),
165
+ InnerJoin(customers, on=customers.fields.id.eq(accounts.fields.customerId)),
166
+ Where(customers.fields.name.eq("O'Brien") & accounts.fields.closed.eq(True)),
167
+ )
168
+ expected_result: str = """DELETE acc
169
+ FROM Accounts acc
170
+ INNER JOIN Customers c on c.id = acc.customerId
171
+ WHERE c.name = 'O''Brien' AND acc.closed = 1"""
172
+ self.assertEqual(query_builder.build(), expected_result)
173
+
174
+ def test_delete_without_alias(self):
175
+ query_builder = QueryBuilder(Delete(self.logs), Where(self.logs.fields.id.eq(1)))
176
+ self.assertEqual(query_builder.build(), "DELETE Logs\nWHERE Logs.id = 1")
177
+
178
+ def test_parameters(self):
179
+ query_builder = QueryBuilder(Delete(self.logs), Where(self.logs.fields.level.in_(["debug", "trace"])))
180
+ expected_result: tuple[str, tuple[object, ...]] = (
181
+ "DELETE Logs\nWHERE Logs.level IN (?, ?)",
182
+ ("debug", "trace"),
183
+ )
184
+ self.assertEqual(query_builder.build_with_params(), expected_result)
185
+
186
+ def test_a_where_is_required(self):
187
+ expected_err_msg: str = "A DELETE needs a WHERE, unless built with Delete(table, where_required=False)."
188
+ on: Condition = self.customers.fields.id.eq(self.accounts.fields.customerId)
189
+ query_builders: dict[str, QueryBuilder] = {
190
+ "alone": QueryBuilder(Delete(self.logs)),
191
+ "a join is no WHERE": QueryBuilder(
192
+ Delete(self.accounts), From(self.accounts), InnerJoin(self.customers, on=on)
193
+ ),
194
+ }
195
+ for name, query_builder in query_builders.items():
196
+ with self.subTest(name=name):
197
+ with self.assertRaises(IncompatibleClausesException) as exc:
198
+ query_builder.build()
199
+ self.assertEqual(str(exc.exception), expected_err_msg)
200
+ self.assertEqual(QueryBuilder(Delete(self.logs, where_required=False)).build(), "DELETE Logs")
201
+
202
+ def test_the_where_is_checked_when_building(self):
203
+ query_builder = QueryBuilder(Delete(self.logs))
204
+ query_builder.add(Where(self.logs.fields.level.eq("debug")))
205
+ self.assertEqual(query_builder.build(), "DELETE Logs\nWHERE Logs.level = 'debug'")
206
+
207
+ def test_order_by_is_refused(self):
208
+ logs = self.logs.fields
209
+ query_builder = QueryBuilder(Delete(self.logs), Where(logs.id.gt(1)), OrderBy(logs.id))
210
+ with self.assertRaises(IncompatibleClausesException) as exc:
211
+ query_builder.build()
212
+ self.assertEqual(str(exc.exception), "ORDER BY cannot be combined with DELETE in the same query.")
213
+
214
+ def test_one_statement_per_query(self):
215
+ logs = self.logs.fields
216
+ for other in (Select(self.logs), Update({logs.level: Str("x")}), Insert(self.logs).values((1, "x"))):
217
+ with self.subTest(other=type(other).__name__), self.assertRaises(UnavailablePlaceException):
218
+ QueryBuilder(Delete(self.logs), other)
219
+
220
+ def test_delete_takes_a_table(self):
221
+ with self.assertRaises(TypeError) as exc:
222
+ Delete("Logs")
223
+ self.assertEqual(str(exc.exception), "Delete takes a Table, got str.")
224
+
225
+ def test_repr(self):
226
+ self.assertTrue(repr(Delete(self.logs)).startswith("Delete(table=Table(name='Logs'"))
227
+ self.assertTrue(repr(Delete(self.logs, where_required=False)).endswith(")), where_required=False)"))
@@ -15,10 +15,12 @@ from sqlglot.errors import SqlglotError
15
15
  from sql_querystring_builder import (
16
16
  AliasableString,
17
17
  CrossJoin,
18
+ Delete,
18
19
  From,
19
20
  FullJoin,
20
21
  FunctionOnField,
21
22
  InnerJoin,
23
+ Insert,
22
24
  LeftJoin,
23
25
  NStr,
24
26
  OrderBy,
@@ -134,15 +136,34 @@ class TestSqlSyntax(TestCase):
134
136
  From(accounts),
135
137
  Where(accounts.fields.id.eq(1)),
136
138
  ).build_with_params()[0],
139
+ # Accounts has an alias, which must not reach the INSERT.
140
+ "insert": QueryBuilder(
141
+ Insert(accounts.fields.id, accounts.fields.customerId).values(
142
+ (1, "O'Brien"), (Raw("DEFAULT"), NStr("é")), (3, None)
143
+ )
144
+ ).build(),
145
+ "parameterized insert": QueryBuilder(Insert(accounts).values((1, "x", 2, b"\x01"))).build_with_params()[0],
146
+ "delete with a join": QueryBuilder(
147
+ Delete(accounts),
148
+ From(accounts),
149
+ InnerJoin(customers, on=customers.fields.id.eq(accounts.fields.customerId)),
150
+ Where(customers.fields.mainPhone.like("555%") & accounts.fields.card.eq(None)),
151
+ ).build(),
152
+ "delete without alias": QueryBuilder(Delete(customers), Where(customers.fields.id.eq(1))).build(),
153
+ "parameterized delete": QueryBuilder(
154
+ Delete(customers), Where(customers.fields.id.in_([1, 2]))
155
+ ).build_with_params()[0],
137
156
  }
138
157
 
139
158
  def test_known_bad_output_is_rejected(self):
140
- # Outputs the library produced before 0.1.0, and an unescaped quote: if these parsed, the test below
141
- # would prove nothing.
159
+ # Outputs the library produced before 0.1.0, an unescaped quote, and the two INSERT forms T-SQL refuses
160
+ # (an alias after INSERT INTO, a qualified column): if these parsed, the test below would prove nothing.
142
161
  for sql in (
143
162
  "UPDATE acc\nSET acc.a AS A=1, acc.b=2",
144
163
  "SELECT #tt.id, #tt.sum of card\nFROM #tt",
145
164
  "SELECT c.id\nFROM Customers c\nWHERE c.name = 'O'Brien'",
165
+ "INSERT INTO Accounts acc (id)\nVALUES (1)",
166
+ "INSERT INTO Accounts (acc.id)\nVALUES (1)",
146
167
  ):
147
168
  with self.subTest(sql=sql), self.assertRaises(SqlglotError):
148
169
  sqlglot.parse_one(sql, read="tsql")