sql_querystring_builder 0.1.1__tar.gz → 0.2.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 (24) hide show
  1. {sql_querystring_builder-0.1.1/src/sql_querystring_builder.egg-info → sql_querystring_builder-0.2.0}/PKG-INFO +24 -7
  2. {sql_querystring_builder-0.1.1 → sql_querystring_builder-0.2.0}/README.md +23 -6
  3. {sql_querystring_builder-0.1.1 → sql_querystring_builder-0.2.0}/pyproject.toml +2 -1
  4. sql_querystring_builder-0.2.0/src/sql_querystring_builder/__init__.py +28 -0
  5. {sql_querystring_builder-0.1.1 → sql_querystring_builder-0.2.0}/src/sql_querystring_builder/clauses.py +95 -13
  6. sql_querystring_builder-0.2.0/src/sql_querystring_builder/exceptions.py +2 -0
  7. {sql_querystring_builder-0.1.1 → sql_querystring_builder-0.2.0}/src/sql_querystring_builder/query_builder.py +15 -3
  8. {sql_querystring_builder-0.1.1 → sql_querystring_builder-0.2.0}/src/sql_querystring_builder/tables.py +86 -27
  9. {sql_querystring_builder-0.1.1 → sql_querystring_builder-0.2.0/src/sql_querystring_builder.egg-info}/PKG-INFO +24 -7
  10. {sql_querystring_builder-0.1.1 → sql_querystring_builder-0.2.0}/src/sql_querystring_builder.egg-info/SOURCES.txt +2 -0
  11. {sql_querystring_builder-0.1.1 → sql_querystring_builder-0.2.0}/tests/test_characterization.py +56 -11
  12. sql_querystring_builder-0.2.0/tests/test_clauses.py +233 -0
  13. sql_querystring_builder-0.2.0/tests/test_sql_syntax.py +93 -0
  14. {sql_querystring_builder-0.1.1 → sql_querystring_builder-0.2.0}/tests/test_tables.py +34 -0
  15. sql_querystring_builder-0.1.1/src/sql_querystring_builder/__init__.py +0 -3
  16. sql_querystring_builder-0.1.1/tests/test_clauses.py +0 -74
  17. {sql_querystring_builder-0.1.1 → sql_querystring_builder-0.2.0}/LICENSE +0 -0
  18. {sql_querystring_builder-0.1.1 → sql_querystring_builder-0.2.0}/setup.cfg +0 -0
  19. {sql_querystring_builder-0.1.1 → sql_querystring_builder-0.2.0}/src/sql_querystring_builder/abstract_clause.py +0 -0
  20. {sql_querystring_builder-0.1.1 → sql_querystring_builder-0.2.0}/src/sql_querystring_builder/py.typed +0 -0
  21. {sql_querystring_builder-0.1.1 → sql_querystring_builder-0.2.0}/src/sql_querystring_builder.egg-info/dependency_links.txt +0 -0
  22. {sql_querystring_builder-0.1.1 → sql_querystring_builder-0.2.0}/src/sql_querystring_builder.egg-info/top_level.txt +0 -0
  23. {sql_querystring_builder-0.1.1 → sql_querystring_builder-0.2.0}/tests/test_queries.py +0 -0
  24. {sql_querystring_builder-0.1.1 → sql_querystring_builder-0.2.0}/tests/test_readme.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sql_querystring_builder
3
- Version: 0.1.1
3
+ Version: 0.2.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
@@ -31,19 +31,18 @@ It provides first level validation.
31
31
  - [x] Function on field
32
32
  - [x] String
33
33
  2. Using:
34
- - [x] Select
34
+ - [x] Select (with DISTINCT and TOP)
35
35
  - [x] Update
36
36
  - [x] From
37
- - [x] InnerJoin
37
+ - [x] InnerJoin, LeftJoin, RightJoin, FullJoin, CrossJoin
38
38
  - [x] Where
39
- - [x] OrderBy
39
+ - [x] OrderBy (with OFFSET/FETCH paging)
40
40
  3. Handling automatically:
41
41
  - [x] Table aliases
42
42
  - [x] Field aliases
43
43
 
44
44
  ## Not implemented (yet)
45
45
  In planned order:
46
- - [ ] LEFT, RIGHT, FULL and CROSS joins; SELECT DISTINCT and TOP; paging (OFFSET/FETCH)
47
46
  - [ ] Proper conditions: typed values, AND/OR/NOT, IN, BETWEEN, LIKE, IS NULL
48
47
  - [ ] Insert and Delete
49
48
  - [ ] Functions (aggregates and others), GROUP BY, HAVING, CASE
@@ -74,7 +73,7 @@ innerjoin = InnerJoin(accounts_table, accounts_table.fields.customerId, customer
74
73
  # Defines an Order By a defined field
75
74
  orderby = OrderBy(customers_table.fields.customerId, descending=True)
76
75
  # Adds a Where filter
77
- where = Where(accounts_table.fields.accountId, "gt 20000 AND", customers_table.fields.customerId, "LIKE 'A1C%'")
76
+ where = Where(accounts_table.fields.accountId, "> 20000 AND", customers_table.fields.customerId, "LIKE 'A1C%'")
78
77
  ```
79
78
  2. Inspect what those elements are made of:
80
79
  ```
@@ -97,7 +96,7 @@ print(query_str)
97
96
  # > INTO #a_temp_table
98
97
  # > FROM Customers
99
98
  # > INNER JOIN Accounts acc on acc.customerId = Customers.customerId
100
- # > WHERE acc.accountId gt 20000 AND Customers.customerId LIKE 'A1C%'
99
+ # > WHERE acc.accountId > 20000 AND Customers.customerId LIKE 'A1C%'
101
100
  # > ORDER BY cid DESC
102
101
  ```
103
102
  4. Manipulate temporary tables whose components are inferred:
@@ -128,6 +127,24 @@ Python 3.10 and above.
128
127
  (None)
129
128
 
130
129
  ## Changelog
130
+ ### 0.2.0
131
+ Queries built with 0.1.x produce the same SQL; see "Breaking" for the few behavior changes.
132
+ - `LeftJoin`, `RightJoin` and `FullJoin` (same arguments as `InnerJoin`), and `CrossJoin(table)`.
133
+ - `Select(..., distinct=True, top=10)` renders `SELECT DISTINCT TOP (10) ...`.
134
+ - `OrderBy(...).offset(20).fetch(10)` adds `OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY`. Combining paging with
135
+ TOP raises `IncompatibleClausesException`.
136
+ - Every exception derives from `SqlQueryBuilderError` and can be imported from `sql_querystring_builder`.
137
+ - `table.fields["name"]`, `"name" in table.fields`, iteration and `len()`. `table.fields["add"]` also
138
+ reaches fields whose names clash with `Fields` methods.
139
+ - Table names accept bracketed parts anywhere: `[Order]`, `dbo.[My Table]` (`]]` stands for `]`).
140
+ - The README example uses `>` instead of `gt`, which is not a T-SQL operator.
141
+
142
+ Breaking:
143
+ - `TempTable(3)` raises `TypeError` instead of a bare `Exception` (same message).
144
+ - Unknown keyword arguments to `Table`, such as `fields=[...]`, raise `TypeError` instead of being ignored.
145
+ - Malformed bracketed names such as `dbo.Order]` or `dbo.[Order` are rejected.
146
+ - An empty `table.fields` is now falsy.
147
+
131
148
  ### 0.1.1
132
149
  No change to the generated SQL.
133
150
  - The package ships a `py.typed` marker, so type checkers such as mypy now use its type hints.
@@ -10,19 +10,18 @@ It provides first level validation.
10
10
  - [x] Function on field
11
11
  - [x] String
12
12
  2. Using:
13
- - [x] Select
13
+ - [x] Select (with DISTINCT and TOP)
14
14
  - [x] Update
15
15
  - [x] From
16
- - [x] InnerJoin
16
+ - [x] InnerJoin, LeftJoin, RightJoin, FullJoin, CrossJoin
17
17
  - [x] Where
18
- - [x] OrderBy
18
+ - [x] OrderBy (with OFFSET/FETCH paging)
19
19
  3. Handling automatically:
20
20
  - [x] Table aliases
21
21
  - [x] Field aliases
22
22
 
23
23
  ## Not implemented (yet)
24
24
  In planned order:
25
- - [ ] LEFT, RIGHT, FULL and CROSS joins; SELECT DISTINCT and TOP; paging (OFFSET/FETCH)
26
25
  - [ ] Proper conditions: typed values, AND/OR/NOT, IN, BETWEEN, LIKE, IS NULL
27
26
  - [ ] Insert and Delete
28
27
  - [ ] Functions (aggregates and others), GROUP BY, HAVING, CASE
@@ -53,7 +52,7 @@ innerjoin = InnerJoin(accounts_table, accounts_table.fields.customerId, customer
53
52
  # Defines an Order By a defined field
54
53
  orderby = OrderBy(customers_table.fields.customerId, descending=True)
55
54
  # Adds a Where filter
56
- where = Where(accounts_table.fields.accountId, "gt 20000 AND", customers_table.fields.customerId, "LIKE 'A1C%'")
55
+ where = Where(accounts_table.fields.accountId, "> 20000 AND", customers_table.fields.customerId, "LIKE 'A1C%'")
57
56
  ```
58
57
  2. Inspect what those elements are made of:
59
58
  ```
@@ -76,7 +75,7 @@ print(query_str)
76
75
  # > INTO #a_temp_table
77
76
  # > FROM Customers
78
77
  # > INNER JOIN Accounts acc on acc.customerId = Customers.customerId
79
- # > WHERE acc.accountId gt 20000 AND Customers.customerId LIKE 'A1C%'
78
+ # > WHERE acc.accountId > 20000 AND Customers.customerId LIKE 'A1C%'
80
79
  # > ORDER BY cid DESC
81
80
  ```
82
81
  4. Manipulate temporary tables whose components are inferred:
@@ -107,6 +106,24 @@ Python 3.10 and above.
107
106
  (None)
108
107
 
109
108
  ## Changelog
109
+ ### 0.2.0
110
+ Queries built with 0.1.x produce the same SQL; see "Breaking" for the few behavior changes.
111
+ - `LeftJoin`, `RightJoin` and `FullJoin` (same arguments as `InnerJoin`), and `CrossJoin(table)`.
112
+ - `Select(..., distinct=True, top=10)` renders `SELECT DISTINCT TOP (10) ...`.
113
+ - `OrderBy(...).offset(20).fetch(10)` adds `OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY`. Combining paging with
114
+ TOP raises `IncompatibleClausesException`.
115
+ - Every exception derives from `SqlQueryBuilderError` and can be imported from `sql_querystring_builder`.
116
+ - `table.fields["name"]`, `"name" in table.fields`, iteration and `len()`. `table.fields["add"]` also
117
+ reaches fields whose names clash with `Fields` methods.
118
+ - Table names accept bracketed parts anywhere: `[Order]`, `dbo.[My Table]` (`]]` stands for `]`).
119
+ - The README example uses `>` instead of `gt`, which is not a T-SQL operator.
120
+
121
+ Breaking:
122
+ - `TempTable(3)` raises `TypeError` instead of a bare `Exception` (same message).
123
+ - Unknown keyword arguments to `Table`, such as `fields=[...]`, raise `TypeError` instead of being ignored.
124
+ - Malformed bracketed names such as `dbo.Order]` or `dbo.[Order` are rejected.
125
+ - An empty `table.fields` is now falsy.
126
+
110
127
  ### 0.1.1
111
128
  No change to the generated SQL.
112
129
  - The package ships a `py.typed` marker, so type checkers such as mypy now use its type hints.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "sql_querystring_builder"
7
- version = "0.1.1"
7
+ version = "0.2.0"
8
8
  authors = [
9
9
  { name="Wael GRIBAA", email="g.wael@outlook.fr" },
10
10
  ]
@@ -34,6 +34,7 @@ dev = [
34
34
  "mypy>=2.4.0",
35
35
  "pytest>=9.1.1",
36
36
  "ruff>=0.17.0",
37
+ "sqlglot==30.19.0",
37
38
  ]
38
39
 
39
40
  [tool.ruff]
@@ -0,0 +1,28 @@
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 .exceptions import SqlQueryBuilderError
15
+ from .query_builder import IncompatibleClausesException, QueryBuilder, UnavailablePlaceException
16
+ from .tables import (
17
+ AliasableString,
18
+ AliasException,
19
+ AlreadyExistingFieldException,
20
+ Field,
21
+ FunctionOnField,
22
+ InvalidNameException,
23
+ NonExistingAliasException,
24
+ NonExistingFieldException,
25
+ Table,
26
+ TableException,
27
+ TempTable,
28
+ )
@@ -1,10 +1,11 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from .abstract_clause import Clause
4
+ from .exceptions import SqlQueryBuilderError
4
5
  from .tables import Aliasable, Field, Fields, Table, TempTable
5
6
 
6
7
 
7
- class IncorrectUpdateException(Exception):
8
+ class IncorrectUpdateException(SqlQueryBuilderError):
8
9
  pass
9
10
 
10
11
 
@@ -12,8 +13,20 @@ class IncorrectUpdateException(Exception):
12
13
  # TODO Add concept of condition
13
14
 
14
15
 
16
+ def _row_count(keyword: str, value: int, minimum: int) -> int:
17
+ """Validates a row count for TOP, OFFSET or FETCH."""
18
+ if isinstance(value, bool) or not isinstance(value, int):
19
+ raise TypeError(f"{keyword} must be an int, got {type(value).__name__}.")
20
+ if value < minimum:
21
+ expected: str = "zero or more" if minimum == 0 else f"at least {minimum}"
22
+ raise ValueError(f"{keyword} must be {expected}, got {value}.")
23
+ return value
24
+
25
+
15
26
  class Select(Clause):
16
- def __init__(self, *fields: Fields | Aliasable | Table) -> None:
27
+ def __init__(self, *fields: Fields | Aliasable | Table, distinct: bool = False, top: int | None = None) -> None:
28
+ if top is not None:
29
+ _row_count("TOP", top, minimum=0)
17
30
  self.fields: list[Aliasable] = []
18
31
  for el in fields:
19
32
  if isinstance(el, Fields):
@@ -22,6 +35,8 @@ class Select(Clause):
22
35
  self.fields.extend(el.fields.as_list())
23
36
  else:
24
37
  self.fields.append(el)
38
+ self.distinct: bool = distinct
39
+ self.top: int | None = top
25
40
  self.temp_table: TempTable | None = None
26
41
 
27
42
  @property
@@ -29,9 +44,13 @@ class Select(Clause):
29
44
  return 0
30
45
 
31
46
  def build(self) -> str:
32
- ret: str = "SELECT " if self.fields else "SELECT *"
33
- ret += ", ".join([field.build(True, True) for field in self.fields])
34
- if self.temp_table:
47
+ head: str = "SELECT"
48
+ if self.distinct:
49
+ head += " DISTINCT"
50
+ if self.top is not None:
51
+ head += f" TOP ({self.top})"
52
+ ret: str = f"{head} " + (", ".join([field.build(True, True) for field in self.fields]) if self.fields else "*")
53
+ if self.temp_table is not None:
35
54
  ret += "\nINTO " + self.temp_table.build()
36
55
  return ret
37
56
 
@@ -40,7 +59,10 @@ class Select(Clause):
40
59
  return self.temp_table
41
60
 
42
61
  def __repr__(self) -> str:
43
- return f"{self.__class__.__name__}(fields={self.fields})"
62
+ options: str = (", distinct=True" if self.distinct else "") + (
63
+ f", top={self.top}" if self.top is not None else ""
64
+ )
65
+ return f"{self.__class__.__name__}(fields={self.fields}{options})"
44
66
 
45
67
 
46
68
  class From(Clause):
@@ -76,13 +98,15 @@ class Where(Clause):
76
98
  return f"{self.__class__.__name__}(conditions={self.conditions})"
77
99
 
78
100
 
79
- class InnerJoin(Clause):
101
+ class Join(Clause):
102
+ """A join on `on_left = on_right`; each subclass only sets its `keyword`."""
103
+
104
+ keyword: str
105
+
80
106
  def __init__(self, table: Table, on_left: Field, on_right: Field | Table) -> None:
81
107
  self.table: Table = table
82
108
  self.on_left: Field = on_left
83
- self.on_right: Field = (
84
- on_right if isinstance(on_right, Aliasable) else on_right.fields.__getattr__(on_left.name)
85
- )
109
+ self.on_right: Field = on_right if isinstance(on_right, Aliasable) else on_right.fields[on_left.name]
86
110
 
87
111
  @property
88
112
  def place(self) -> int:
@@ -93,12 +117,47 @@ class InnerJoin(Clause):
93
117
  return False
94
118
 
95
119
  def build(self) -> str:
96
- return f"INNER JOIN {self.table.build(use_name=True)} on {self.on_left.build()} = {self.on_right.build()}"
120
+ return f"{self.keyword} {self.table.build(use_name=True)} on {self.on_left.build()} = {self.on_right.build()}"
97
121
 
98
122
  def __repr__(self) -> str:
99
123
  return f"{self.__class__.__name__}(table={self.table}, on_left={self.on_left}, on_right={self.on_right})"
100
124
 
101
125
 
126
+ class InnerJoin(Join):
127
+ keyword = "INNER JOIN"
128
+
129
+
130
+ class LeftJoin(Join):
131
+ keyword = "LEFT JOIN"
132
+
133
+
134
+ class RightJoin(Join):
135
+ keyword = "RIGHT JOIN"
136
+
137
+
138
+ class FullJoin(Join):
139
+ keyword = "FULL JOIN"
140
+
141
+
142
+ class CrossJoin(Clause):
143
+ def __init__(self, table: Table) -> None:
144
+ self.table: Table = table
145
+
146
+ @property
147
+ def place(self) -> int:
148
+ return 3
149
+
150
+ @property
151
+ def is_exclusive(self) -> bool:
152
+ return False
153
+
154
+ def build(self) -> str:
155
+ return f"CROSS JOIN {self.table.build(use_name=True)}"
156
+
157
+ def __repr__(self) -> str:
158
+ return f"{self.__class__.__name__}(table={self.table})"
159
+
160
+
102
161
  class Update(Clause):
103
162
  def __init__(self, updates: dict[Field, str]) -> None:
104
163
  if not updates:
@@ -134,20 +193,43 @@ class Update(Clause):
134
193
  class OrderBy(Clause):
135
194
  def __init__(self, field: Aliasable, descending: bool = False) -> None:
136
195
  self.ordering: list[tuple[Aliasable, bool]] = [(field, descending)]
196
+ self.offset_rows: int | None = None
197
+ self.fetch_rows: int | None = None
137
198
 
138
199
  @property
139
200
  def place(self) -> int:
140
201
  return 5
141
202
 
203
+ @property
204
+ def has_paging(self) -> bool:
205
+ return self.offset_rows is not None or self.fetch_rows is not None
206
+
142
207
  def add(self, field: Aliasable, descending: bool = False) -> OrderBy:
143
208
  self.ordering.append((field, descending))
144
209
  return self
145
210
 
211
+ def offset(self, rows: int) -> OrderBy:
212
+ self.offset_rows = _row_count("OFFSET", rows, minimum=0)
213
+ return self
214
+
215
+ def fetch(self, rows: int) -> OrderBy:
216
+ self.fetch_rows = _row_count("FETCH", rows, minimum=1)
217
+ return self
218
+
146
219
  def build(self) -> str:
147
220
  ret_list: list[str] = []
148
221
  for field, desc in self.ordering:
149
222
  ret_list.append(f"{field.build(use_name=False, use_alias=True)}{' DESC' if desc else ''}")
150
- return "ORDER BY " + ", ".join(ret_list)
223
+ ret: str = "ORDER BY " + ", ".join(ret_list)
224
+ if self.has_paging:
225
+ # T-SQL requires OFFSET before FETCH: fetch() alone starts at the first row.
226
+ ret += f"\nOFFSET {self.offset_rows or 0} ROWS"
227
+ if self.fetch_rows is not None:
228
+ ret += f" FETCH NEXT {self.fetch_rows} ROWS ONLY"
229
+ return ret
151
230
 
152
231
  def __repr__(self) -> str:
153
- return f"{self.__class__.__name__}(ordering={self.ordering})"
232
+ paging: str = (f", offset={self.offset_rows}" if self.offset_rows is not None else "") + (
233
+ f", fetch={self.fetch_rows}" if self.fetch_rows is not None else ""
234
+ )
235
+ return f"{self.__class__.__name__}(ordering={self.ordering}{paging})"
@@ -0,0 +1,2 @@
1
+ class SqlQueryBuilderError(Exception):
2
+ """Base class of every exception the library raises, so callers can catch them all at once."""
@@ -3,9 +3,15 @@ from __future__ import annotations
3
3
  from collections.abc import Iterable
4
4
 
5
5
  from .abstract_clause import Clause
6
+ from .clauses import OrderBy, Select
7
+ from .exceptions import SqlQueryBuilderError
6
8
 
7
9
 
8
- class UnavailablePlaceException(Exception):
10
+ class UnavailablePlaceException(SqlQueryBuilderError):
11
+ pass
12
+
13
+
14
+ class IncompatibleClausesException(SqlQueryBuilderError):
9
15
  pass
10
16
 
11
17
 
@@ -17,7 +23,7 @@ class QueryBuilder:
17
23
 
18
24
  def add(self, clause: Clause) -> QueryBuilder:
19
25
  old_clause: Clause | None = self._place_is_unavailable(clause)
20
- if old_clause:
26
+ if old_clause is not None:
21
27
  raise UnavailablePlaceException(
22
28
  f"Cannot add a new clause with the same exclusive place in the query. New clause='{clause.build()}'. Old clause='{old_clause.build()}'"
23
29
  )
@@ -30,7 +36,13 @@ class QueryBuilder:
30
36
  return self
31
37
 
32
38
  def build(self) -> str:
33
- return "\n".join(clause.build() for clause in self._sorted_clauses())
39
+ clauses: list[Clause] = self._sorted_clauses()
40
+ # Checked at build time: TOP and paging can both be set after the clauses were added.
41
+ has_top: bool = any(isinstance(clause, Select) and clause.top is not None for clause in clauses)
42
+ has_paging: bool = any(isinstance(clause, OrderBy) and clause.has_paging for clause in clauses)
43
+ if has_top and has_paging:
44
+ raise IncompatibleClausesException("TOP cannot be combined with OFFSET/FETCH in the same query.")
45
+ return "\n".join(clause.build() for clause in clauses)
34
46
 
35
47
  def _place_is_unavailable(self, clause: Clause) -> Clause | None:
36
48
  if clause.place in self.places and clause.is_exclusive:
@@ -1,30 +1,33 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from abc import ABC, abstractmethod
4
+ from collections.abc import Iterator
4
5
  from string import ascii_letters, digits
5
6
 
7
+ from .exceptions import SqlQueryBuilderError
6
8
 
7
- class AliasException(Exception):
9
+
10
+ class AliasException(SqlQueryBuilderError):
8
11
  pass
9
12
 
10
13
 
11
- class AlreadyExistingFieldException(Exception):
14
+ class AlreadyExistingFieldException(SqlQueryBuilderError):
12
15
  pass
13
16
 
14
17
 
15
- class InvalidNameException(Exception):
18
+ class InvalidNameException(SqlQueryBuilderError):
16
19
  pass
17
20
 
18
21
 
19
- class NonExistingAliasException(Exception):
22
+ class NonExistingAliasException(SqlQueryBuilderError):
20
23
  pass
21
24
 
22
25
 
23
- class NonExistingFieldException(AttributeError):
26
+ class NonExistingFieldException(SqlQueryBuilderError, AttributeError):
24
27
  pass
25
28
 
26
29
 
27
- class TableException(Exception):
30
+ class TableException(SqlQueryBuilderError):
28
31
  pass
29
32
 
30
33
 
@@ -50,8 +53,18 @@ def validate_name(name: str, is_hashtag_allowed: bool = False) -> str:
50
53
  ------
51
54
  InvalidNameException
52
55
  Raised if the name cannot reprensent an element in a strict MS SQL conventions.
56
+
57
+ Notes
58
+ -----
59
+ Each dot-separated part is either a regular identifier (letters, digits and underscores) or a
60
+ bracketed one (`[My Table]`), which may hold any character, dots included; `]]` stands for `]`.
53
61
  """
54
62
 
63
+ def forbidden_character(pos: int) -> InvalidNameException:
64
+ return InvalidNameException(
65
+ f"Forbidden character '{name[pos]}' was found in the name '{name}' at position {pos}. Only letters, digits and undersores are allowed."
66
+ )
67
+
55
68
  def validate_subname(subname: str, startpos: int = 0, is_temp_table_start: bool = False) -> None:
56
69
  max_length: int = 116 if is_temp_table_start else 128
57
70
  if len(subname) > max_length:
@@ -62,22 +75,48 @@ def validate_name(name: str, is_hashtag_allowed: bool = False) -> str:
62
75
  if is_temp_table_start and pos == 0 and c == "#":
63
76
  continue # is indeed a temp table starting with #
64
77
  if c not in NAMEABLE_CHARS:
65
- if (c != "[" or pos != 0) and (c != "]" or pos != len(subname) - 1):
66
- raise InvalidNameException(
67
- f"Forbidden character '{c}' was found in the name '{name}' at position {pos + startpos}. Only letters, digits and undersores are allowed."
68
- )
78
+ raise forbidden_character(pos + startpos)
79
+
80
+ def bracketed_end(start: int) -> int:
81
+ """Returns the position just after the `]` that closes the `[` at `start`."""
82
+ pos: int = start + 1
83
+ while True:
84
+ close: int = name.find("]", pos)
85
+ if close == -1:
86
+ raise InvalidNameException(f"The name {name} has an unclosed '[' at position {start}.")
87
+ if not name.startswith("]]", close):
88
+ return close + 1
89
+ pos = close + 2 # "]]" is an escaped "]" inside the brackets
69
90
 
70
91
  if not name:
71
92
  raise InvalidNameException("Empty names are forbidden.")
72
- if name[0] not in ascii_letters:
93
+ if name[0] not in ascii_letters and name[0] != "[":
73
94
  if not is_hashtag_allowed or name[0] != "#":
74
95
  raise InvalidNameException(f"The name {name} does not start with a letter.")
75
- absolute_pos: int = 0
76
- for i_split, subname in enumerate(name.split(".")):
77
- is_temp_table_start: bool = i_split == 0 and is_hashtag_allowed
78
- validate_subname(subname, startpos=absolute_pos, is_temp_table_start=is_temp_table_start)
79
- absolute_pos += len(subname) + 1 # +1 to account for the '.'
80
- return name
96
+ pos: int = 0
97
+ is_first_part: bool = True
98
+ while True:
99
+ if name.startswith("[", pos):
100
+ end: int = bracketed_end(pos)
101
+ content: str = name[pos + 1 : end - 1].replace("]]", "]")
102
+ if not content:
103
+ raise InvalidNameException(f"The name {name} has an empty bracketed part at position {pos}.")
104
+ if len(content) > 128:
105
+ raise InvalidNameException(
106
+ f"The name {name} is too long or has a component that is too long (got {len(content)}, max is 128)."
107
+ )
108
+ else:
109
+ end = name.find(".", pos)
110
+ if end == -1:
111
+ end = len(name)
112
+ validate_subname(name[pos:end], startpos=pos, is_temp_table_start=is_first_part and is_hashtag_allowed)
113
+ pos = end
114
+ if pos == len(name):
115
+ return name
116
+ if name[pos] != ".":
117
+ raise forbidden_character(pos)
118
+ pos += 1
119
+ is_first_part = False
81
120
 
82
121
 
83
122
  class Aliasable(ABC):
@@ -166,31 +205,52 @@ class Fields:
166
205
 
167
206
  def __getattr__(self, attr_name: str) -> Field:
168
207
  # copy and pickle look attributes up on instances whose __init__ never ran, so `fields` may be missing.
169
- fields: dict[str, Field] | None = self.__dict__.get("fields")
170
- if fields is None:
208
+ if "fields" not in self.__dict__:
171
209
  raise AttributeError(attr_name)
172
- res: Field | None = fields.get(attr_name)
210
+ return self[attr_name]
211
+
212
+ # Only dunder methods below: a new public method name would shadow fields named like it.
213
+ def __getitem__(self, name: str) -> Field:
214
+ res: Field | None = self.fields.get(name)
173
215
  if res is None:
174
- raise NonExistingFieldException(f"No field named '{attr_name}' in the table {self.table}.")
216
+ raise NonExistingFieldException(f"No field named '{name}' in the table {self.table}.")
175
217
  return res
176
218
 
219
+ def __contains__(self, item: object) -> bool:
220
+ if isinstance(item, Field):
221
+ return self.fields.get(item.name) is item
222
+ return isinstance(item, str) and item in self.fields
223
+
224
+ def __iter__(self) -> Iterator[Field]:
225
+ return iter(self.fields.values())
226
+
227
+ def __len__(self) -> int:
228
+ return len(self.fields)
229
+
177
230
  def __repr__(self) -> str:
178
231
  return f"Fields(table='{self.table.name}', [{','.join(repr(field) for field in self.fields.values())}])"
179
232
 
180
233
 
181
234
  class Table:
182
- def __init__(self, name: str, alias: str | None = None, field_names: list[str] | None = None, **kwargs) -> None:
183
- self.name: str = validate_name(name, kwargs.get("is_hashtag_allowed", False))
235
+ def __init__(
236
+ self,
237
+ name: str,
238
+ alias: str | None = None,
239
+ field_names: list[str] | None = None,
240
+ *,
241
+ is_hashtag_allowed: bool = False,
242
+ ) -> None:
243
+ self.name: str = validate_name(name, is_hashtag_allowed)
184
244
  self.alias: str | None = alias
185
245
  self.fields: Fields = Fields(self)
186
246
  if field_names:
187
247
  self.add(*field_names)
188
248
 
189
- def with_fields(self, *args, **kwargs) -> Table:
249
+ def with_fields(self, *fields: Field | str) -> Table:
190
250
  """
191
251
  Alias to self.add()
192
252
  """
193
- return self.add(*args, **kwargs)
253
+ return self.add(*fields)
194
254
 
195
255
  def add(self, *fields: Field | str) -> Table:
196
256
  self.fields.add(*fields)
@@ -220,8 +280,7 @@ class Table:
220
280
  class TempTable(Table):
221
281
  def __init__(self, name: str, *fields: str | Aliasable | Fields) -> None:
222
282
  if not isinstance(name, str):
223
- # A bare Exception is public behavior, pinned by test_characterization.
224
- raise Exception("Argument 'name' is not a string.") # noqa: TRY002, TRY004
283
+ raise TypeError("Argument 'name' is not a string.")
225
284
  super().__init__(name=f"#{name}", is_hashtag_allowed=True)
226
285
  field_names: list[str] = []
227
286
  # Columns are named as SELECT ... INTO names them: after the alias when there is one.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sql_querystring_builder
3
- Version: 0.1.1
3
+ Version: 0.2.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
@@ -31,19 +31,18 @@ It provides first level validation.
31
31
  - [x] Function on field
32
32
  - [x] String
33
33
  2. Using:
34
- - [x] Select
34
+ - [x] Select (with DISTINCT and TOP)
35
35
  - [x] Update
36
36
  - [x] From
37
- - [x] InnerJoin
37
+ - [x] InnerJoin, LeftJoin, RightJoin, FullJoin, CrossJoin
38
38
  - [x] Where
39
- - [x] OrderBy
39
+ - [x] OrderBy (with OFFSET/FETCH paging)
40
40
  3. Handling automatically:
41
41
  - [x] Table aliases
42
42
  - [x] Field aliases
43
43
 
44
44
  ## Not implemented (yet)
45
45
  In planned order:
46
- - [ ] LEFT, RIGHT, FULL and CROSS joins; SELECT DISTINCT and TOP; paging (OFFSET/FETCH)
47
46
  - [ ] Proper conditions: typed values, AND/OR/NOT, IN, BETWEEN, LIKE, IS NULL
48
47
  - [ ] Insert and Delete
49
48
  - [ ] Functions (aggregates and others), GROUP BY, HAVING, CASE
@@ -74,7 +73,7 @@ innerjoin = InnerJoin(accounts_table, accounts_table.fields.customerId, customer
74
73
  # Defines an Order By a defined field
75
74
  orderby = OrderBy(customers_table.fields.customerId, descending=True)
76
75
  # Adds a Where filter
77
- where = Where(accounts_table.fields.accountId, "gt 20000 AND", customers_table.fields.customerId, "LIKE 'A1C%'")
76
+ where = Where(accounts_table.fields.accountId, "> 20000 AND", customers_table.fields.customerId, "LIKE 'A1C%'")
78
77
  ```
79
78
  2. Inspect what those elements are made of:
80
79
  ```
@@ -97,7 +96,7 @@ print(query_str)
97
96
  # > INTO #a_temp_table
98
97
  # > FROM Customers
99
98
  # > INNER JOIN Accounts acc on acc.customerId = Customers.customerId
100
- # > WHERE acc.accountId gt 20000 AND Customers.customerId LIKE 'A1C%'
99
+ # > WHERE acc.accountId > 20000 AND Customers.customerId LIKE 'A1C%'
101
100
  # > ORDER BY cid DESC
102
101
  ```
103
102
  4. Manipulate temporary tables whose components are inferred:
@@ -128,6 +127,24 @@ Python 3.10 and above.
128
127
  (None)
129
128
 
130
129
  ## Changelog
130
+ ### 0.2.0
131
+ Queries built with 0.1.x produce the same SQL; see "Breaking" for the few behavior changes.
132
+ - `LeftJoin`, `RightJoin` and `FullJoin` (same arguments as `InnerJoin`), and `CrossJoin(table)`.
133
+ - `Select(..., distinct=True, top=10)` renders `SELECT DISTINCT TOP (10) ...`.
134
+ - `OrderBy(...).offset(20).fetch(10)` adds `OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY`. Combining paging with
135
+ TOP raises `IncompatibleClausesException`.
136
+ - Every exception derives from `SqlQueryBuilderError` and can be imported from `sql_querystring_builder`.
137
+ - `table.fields["name"]`, `"name" in table.fields`, iteration and `len()`. `table.fields["add"]` also
138
+ reaches fields whose names clash with `Fields` methods.
139
+ - Table names accept bracketed parts anywhere: `[Order]`, `dbo.[My Table]` (`]]` stands for `]`).
140
+ - The README example uses `>` instead of `gt`, which is not a T-SQL operator.
141
+
142
+ Breaking:
143
+ - `TempTable(3)` raises `TypeError` instead of a bare `Exception` (same message).
144
+ - Unknown keyword arguments to `Table`, such as `fields=[...]`, raise `TypeError` instead of being ignored.
145
+ - Malformed bracketed names such as `dbo.Order]` or `dbo.[Order` are rejected.
146
+ - An empty `table.fields` is now falsy.
147
+
131
148
  ### 0.1.1
132
149
  No change to the generated SQL.
133
150
  - The package ships a `py.typed` marker, so type checkers such as mypy now use its type hints.
@@ -4,6 +4,7 @@ pyproject.toml
4
4
  src/sql_querystring_builder/__init__.py
5
5
  src/sql_querystring_builder/abstract_clause.py
6
6
  src/sql_querystring_builder/clauses.py
7
+ src/sql_querystring_builder/exceptions.py
7
8
  src/sql_querystring_builder/py.typed
8
9
  src/sql_querystring_builder/query_builder.py
9
10
  src/sql_querystring_builder/tables.py
@@ -15,4 +16,5 @@ tests/test_characterization.py
15
16
  tests/test_clauses.py
16
17
  tests/test_queries.py
17
18
  tests/test_readme.py
19
+ tests/test_sql_syntax.py
18
20
  tests/test_tables.py
@@ -2,7 +2,7 @@
2
2
 
3
3
  A failure here means rendered output or behavior changed. If that is intended, update the
4
4
  expectation in the same commit and say so in the commit message. Some tests pin surprising behavior
5
- on purpose (verbatim strings, ignored keyword arguments, shadowed field names); changing it is the
5
+ on purpose (verbatim strings, shadowed field names); changing it is the
6
6
  owner's call.
7
7
  """
8
8
 
@@ -30,17 +30,32 @@ class TestPublicApi(TestCase):
30
30
  def test_top_level_names(self):
31
31
  expected_names: set[str] = {
32
32
  "AliasableString",
33
+ "CrossJoin",
33
34
  "Field",
34
35
  "From",
36
+ "FullJoin",
35
37
  "FunctionOnField",
36
38
  "InnerJoin",
39
+ "LeftJoin",
37
40
  "OrderBy",
41
+ "RightJoin",
38
42
  "QueryBuilder",
39
43
  "Select",
40
44
  "Table",
41
45
  "TempTable",
42
46
  "Update",
43
47
  "Where",
48
+ # Exceptions, also importable from their submodules.
49
+ "AliasException",
50
+ "AlreadyExistingFieldException",
51
+ "IncompatibleClausesException",
52
+ "IncorrectUpdateException",
53
+ "InvalidNameException",
54
+ "NonExistingAliasException",
55
+ "NonExistingFieldException",
56
+ "SqlQueryBuilderError",
57
+ "TableException",
58
+ "UnavailablePlaceException",
44
59
  }
45
60
  actual_names: set[str] = {name for name in dir(sql_querystring_builder) if name[0].isupper()}
46
61
  self.assertEqual(actual_names, expected_names)
@@ -56,12 +71,14 @@ class TestPublicApi(TestCase):
56
71
  "TableException",
57
72
  ],
58
73
  clauses: ["IncorrectUpdateException"],
59
- query_builder: ["UnavailablePlaceException"],
74
+ query_builder: ["IncompatibleClausesException", "UnavailablePlaceException"],
60
75
  }
61
76
  for module, names in expected_exceptions.items():
62
77
  for name in names:
63
78
  with self.subTest(module=module.__name__, name=name):
64
- self.assertTrue(issubclass(getattr(module, name), Exception))
79
+ exception = getattr(module, name)
80
+ self.assertTrue(issubclass(exception, sql_querystring_builder.SqlQueryBuilderError))
81
+ self.assertIs(getattr(sql_querystring_builder, name), exception)
65
82
 
66
83
 
67
84
  class TestLateBinding(TestCase):
@@ -105,9 +122,8 @@ class TestTempTables(TestCase):
105
122
  self.assertEqual(str(exc.exception), "The field '#d.field.card' already exists.")
106
123
 
107
124
  def test_name_must_be_a_string(self):
108
- with self.assertRaises(Exception) as exc:
125
+ with self.assertRaises(TypeError) as exc:
109
126
  TempTable(3)
110
- self.assertIs(type(exc.exception), Exception)
111
127
  self.assertEqual(str(exc.exception), "Argument 'name' is not a string.")
112
128
 
113
129
  def test_empty_select(self):
@@ -214,17 +230,34 @@ class TestRendering(TestCase):
214
230
 
215
231
  class TestNameValidation(TestCase):
216
232
  def test_accepted_names(self):
217
- for name in ["dbo.Customers", "dbo.[Order]", "a..b", "a."]:
233
+ names: list[str] = [
234
+ "dbo.Customers",
235
+ "dbo.[Order]",
236
+ "a..b",
237
+ "a.",
238
+ "[Order]",
239
+ "dbo.[My Table]",
240
+ "[dbo].[My Table]",
241
+ "[my.table]", # dots are allowed inside brackets
242
+ "[a]]b]", # "]]" is an escaped "]"
243
+ ]
244
+ for name in names:
218
245
  with self.subTest(name=name):
219
246
  self.assertEqual(Table(name).name, name)
220
247
 
221
248
  def test_rejected_names(self):
222
249
  expected_err_msgs: dict[str, str] = {
223
- "[Order]": "The name [Order] does not start with a letter.",
224
- "dbo.[My Table]": "Forbidden character ' ' was found in the name 'dbo.[My Table]' at position 7. "
225
- "Only letters, digits and undersores are allowed.",
226
250
  "dbo.Cust-omers": "Forbidden character '-' was found in the name 'dbo.Cust-omers' at position 8. "
227
251
  "Only letters, digits and undersores are allowed.",
252
+ "[Order": "The name [Order has an unclosed '[' at position 0.",
253
+ "dbo.[x": "The name dbo.[x has an unclosed '[' at position 4.",
254
+ "[]": "The name [] has an empty bracketed part at position 0.",
255
+ "[a]b": "Forbidden character 'b' was found in the name '[a]b' at position 3. "
256
+ "Only letters, digits and undersores are allowed.",
257
+ "dbo.Order]": "Forbidden character ']' was found in the name 'dbo.Order]' at position 9. "
258
+ "Only letters, digits and undersores are allowed.",
259
+ "x[y]": "Forbidden character '[' was found in the name 'x[y]' at position 1. "
260
+ "Only letters, digits and undersores are allowed.",
228
261
  }
229
262
  for name, expected_err_msg in expected_err_msgs.items():
230
263
  with self.subTest(name=name):
@@ -232,6 +265,15 @@ class TestNameValidation(TestCase):
232
265
  Table(name)
233
266
  self.assertEqual(str(exc.exception), expected_err_msg)
234
267
 
268
+ def test_too_long_bracketed_part(self):
269
+ name: str = "[" + "b" * 129 + "]"
270
+ with self.assertRaises(InvalidNameException) as exc:
271
+ Table(name)
272
+ self.assertEqual(
273
+ str(exc.exception),
274
+ f"The name {name} is too long or has a component that is too long (got 129, max is 128).",
275
+ )
276
+
235
277
  def test_too_long_second_component(self):
236
278
  name: str = "a." + "b" * 129
237
279
  with self.assertRaises(InvalidNameException) as exc:
@@ -283,5 +325,8 @@ class TestFieldsQuirks(TestCase):
283
325
  self.assertEqual(Select(copied_table).build(), "SELECT tt.a")
284
326
  self.assertEqual(list(copy.copy(table.fields).fields), ["a"])
285
327
 
286
- def test_unknown_keyword_argument_is_ignored(self):
287
- self.assertEqual(list(Table("x", fields=["a"]).fields.fields), [])
328
+ def test_unknown_keyword_argument_is_rejected(self):
329
+ with self.assertRaises(TypeError) as exc:
330
+ Table("x", fields=["a"])
331
+ # Python writes this message itself, and its prefix varies between versions.
332
+ self.assertIn("unexpected keyword argument 'fields'", str(exc.exception))
@@ -0,0 +1,233 @@
1
+ from unittest import TestCase
2
+
3
+ from sql_querystring_builder.clauses import (
4
+ CrossJoin,
5
+ From,
6
+ FullJoin,
7
+ IncorrectUpdateException,
8
+ InnerJoin,
9
+ LeftJoin,
10
+ OrderBy,
11
+ RightJoin,
12
+ Select,
13
+ Update,
14
+ Where,
15
+ )
16
+ from sql_querystring_builder.query_builder import IncompatibleClausesException, QueryBuilder
17
+ from sql_querystring_builder.tables import Field, NonExistingFieldException, Table, TempTable
18
+
19
+
20
+ class TestClauses(TestCase):
21
+ def setUp(self) -> None:
22
+ self.maxDiff = None
23
+
24
+ self.table1: Table = Table(name="TestTable", alias="TT", field_names=["TestField1", "TestField2"])
25
+ self.table2: Table = Table(name="TestTable2", alias="TT2", field_names=["TestField21", "TestField22"])
26
+
27
+ self.select: Select = Select(self.table1.fields.TestField2, self.table2)
28
+ self.testtemptable: TempTable = self.select.to_temptable("ttt")
29
+ self.table1field1: Field = self.table1.fields.TestField1
30
+ self.table1field2: Field = self.table1.fields.TestField2
31
+ self.table2field21: Field = self.table2.fields.TestField21
32
+ self.table2field22: Field = self.table2.fields.TestField22
33
+
34
+ def test_init_select(self):
35
+ self.assertEqual(self.select.fields, [self.table1field2, self.table2field21, self.table2field22])
36
+
37
+ def test_select_build(self):
38
+ expected_result: str = "SELECT TT.TestField1, TT.TestField2, TT2.TestField21"
39
+ actual_result: str = Select(self.table1, self.table2.fields.TestField21).build()
40
+ self.assertEqual(actual_result, expected_result)
41
+
42
+ def test_from_build(self):
43
+ expected_result: str = "WHERE TT.TestField2 has a very unique condition"
44
+ actual_result: str = Where(self.table1field2, "has a very unique condition").build()
45
+ self.assertEqual(actual_result, expected_result)
46
+
47
+ def test_innerjoin_ok(self):
48
+ expected_result: str = "INNER JOIN TestTable2 TT2 on TT2.TestField22 = TT.TestField2"
49
+ actual_result: str = InnerJoin(self.table2, self.table2field22, self.table1field2).build()
50
+ self.assertEqual(actual_result, expected_result)
51
+
52
+ def test_innerjoin_unexisting_infered_field(self):
53
+ expected_err_msg: str = (
54
+ "No field named 'TestField22' in the table "
55
+ "Table(name='TestTable', alias='TT', fields=Fields(table='TestTable', "
56
+ "[Field(name='TestField1', alias='None', table='TestTable'),"
57
+ "Field(name='TestField2', alias='None', table='TestTable')]))."
58
+ )
59
+ with self.assertRaises(NonExistingFieldException) as exc:
60
+ InnerJoin(self.table2, self.table2field22, self.table1)
61
+ self.assertEqual(str(exc.exception), expected_err_msg)
62
+
63
+ def test_update_ok(self):
64
+ expected_result: str = "UPDATE TT\nSET TT.TestField1='hello', TT.TestField2='world'"
65
+ actual_result: str = Update({self.table1field1: "'hello'", self.table1field2: "'world'"}).build()
66
+ self.assertEqual(actual_result, expected_result)
67
+
68
+ def test_different_tables(self):
69
+ expected_err_msg: str = (
70
+ "Two different tables cannot be updated in the same Update: 'TestTable' and 'TestTable2'."
71
+ )
72
+ with self.assertRaises(IncorrectUpdateException) as exc:
73
+ Update({self.table1field1: "'hello'", self.table2field21: "'world'"}).build()
74
+ self.assertEqual(str(exc.exception), expected_err_msg)
75
+
76
+ def test_descending(self):
77
+ anonymous_field: Field = Field("anonymous", self.table1)
78
+ anonymous_field2: Field = Field("anonymous2", self.table2)
79
+
80
+ self.assertEqual(
81
+ OrderBy(self.table1field1, True).add(anonymous_field2).build(),
82
+ "ORDER BY TT.TestField1 DESC, TT2.anonymous2",
83
+ )
84
+ self.assertEqual(
85
+ OrderBy(self.table2field21).add(anonymous_field, True).build(),
86
+ "ORDER BY TT2.TestField21, TT.anonymous DESC",
87
+ )
88
+
89
+
90
+ class TestSelectOptions(TestCase):
91
+ def setUp(self) -> None:
92
+ self.table: Table = Table("Customers", "c", field_names=["id", "name"])
93
+
94
+ def test_distinct_and_top(self):
95
+ expected_results: dict[str, str] = {
96
+ "distinct": "SELECT DISTINCT c.id, c.name",
97
+ "top": "SELECT TOP (10) c.id, c.name",
98
+ "both": "SELECT DISTINCT TOP (5) c.id, c.name",
99
+ }
100
+ selects: dict[str, Select] = {
101
+ "distinct": Select(self.table, distinct=True),
102
+ "top": Select(self.table, top=10),
103
+ "both": Select(self.table, distinct=True, top=5),
104
+ }
105
+ for name, expected_result in expected_results.items():
106
+ with self.subTest(name=name):
107
+ self.assertEqual(selects[name].build(), expected_result)
108
+
109
+ def test_top_without_fields_and_with_into(self):
110
+ self.assertEqual(Select(top=3).build(), "SELECT TOP (3) *")
111
+ select = Select(self.table, top=1)
112
+ select.to_temptable("first")
113
+ self.assertEqual(select.build(), "SELECT TOP (1) c.id, c.name\nINTO #first")
114
+
115
+ def test_top_zero_is_allowed(self):
116
+ self.assertEqual(Select(self.table, top=0).build(), "SELECT TOP (0) c.id, c.name")
117
+
118
+ def test_invalid_top(self):
119
+ with self.assertRaises(ValueError) as exc:
120
+ Select(self.table, top=-1)
121
+ self.assertEqual(str(exc.exception), "TOP must be zero or more, got -1.")
122
+ for top in ("10", 2.5, True):
123
+ with self.subTest(top=top):
124
+ with self.assertRaises(TypeError) as exc:
125
+ Select(self.table, top=top)
126
+ self.assertEqual(str(exc.exception), f"TOP must be an int, got {type(top).__name__}.")
127
+
128
+ def test_repr_shows_options(self):
129
+ self.assertTrue(repr(Select(self.table, distinct=True, top=2)).endswith(", distinct=True, top=2)"))
130
+ self.assertTrue(repr(Select(self.table)).endswith("table='Customers')])"))
131
+
132
+
133
+ class TestPaging(TestCase):
134
+ def setUp(self) -> None:
135
+ self.table: Table = Table("Customers", "c", field_names=["id", "name"])
136
+
137
+ def test_offset_and_fetch(self):
138
+ expected_results: dict[str, str] = {
139
+ "offset": "ORDER BY c.id\nOFFSET 20 ROWS",
140
+ "offset and fetch": "ORDER BY c.id DESC\nOFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY",
141
+ "fetch alone": "ORDER BY c.id\nOFFSET 0 ROWS FETCH NEXT 5 ROWS ONLY",
142
+ }
143
+ orderings: dict[str, OrderBy] = {
144
+ "offset": OrderBy(self.table.fields.id).offset(20),
145
+ "offset and fetch": OrderBy(self.table.fields.id, True).offset(20).fetch(10),
146
+ "fetch alone": OrderBy(self.table.fields.id).fetch(5),
147
+ }
148
+ for name, expected_result in expected_results.items():
149
+ with self.subTest(name=name):
150
+ self.assertEqual(orderings[name].build(), expected_result)
151
+
152
+ def test_fields_added_after_paging(self):
153
+ order_by = OrderBy(self.table.fields.id).offset(1)
154
+ order_by.add(self.table.fields.name)
155
+ self.assertEqual(order_by.build(), "ORDER BY c.id, c.name\nOFFSET 1 ROWS")
156
+
157
+ def test_invalid_counts(self):
158
+ with self.assertRaises(ValueError) as exc:
159
+ OrderBy(self.table.fields.id).offset(-1)
160
+ self.assertEqual(str(exc.exception), "OFFSET must be zero or more, got -1.")
161
+ with self.assertRaises(ValueError) as exc:
162
+ OrderBy(self.table.fields.id).fetch(0)
163
+ self.assertEqual(str(exc.exception), "FETCH must be at least 1, got 0.")
164
+ with self.assertRaises(TypeError) as exc:
165
+ OrderBy(self.table.fields.id).offset("1")
166
+ self.assertEqual(str(exc.exception), "OFFSET must be an int, got str.")
167
+
168
+ def test_paged_query(self):
169
+ expected_result: str = """SELECT c.id, c.name
170
+ FROM Customers c
171
+ ORDER BY c.name
172
+ OFFSET 40 ROWS FETCH NEXT 20 ROWS ONLY"""
173
+ actual_result: str = QueryBuilder(
174
+ Select(self.table), From(self.table), OrderBy(self.table.fields.name).offset(40).fetch(20)
175
+ ).build()
176
+ self.assertEqual(actual_result, expected_result)
177
+
178
+ def test_top_and_paging_are_incompatible(self):
179
+ order_by = OrderBy(self.table.fields.id)
180
+ query_builder = QueryBuilder(Select(self.table, top=5), From(self.table), order_by)
181
+ self.assertEqual(query_builder.build(), "SELECT TOP (5) c.id, c.name\nFROM Customers c\nORDER BY c.id")
182
+ order_by.fetch(10) # paging set after the clause was added is still caught
183
+ with self.assertRaises(IncompatibleClausesException) as exc:
184
+ query_builder.build()
185
+ self.assertEqual(str(exc.exception), "TOP cannot be combined with OFFSET/FETCH in the same query.")
186
+
187
+ def test_repr_shows_paging(self):
188
+ self.assertTrue(repr(OrderBy(self.table.fields.id).offset(2).fetch(3)).endswith(", offset=2, fetch=3)"))
189
+
190
+
191
+ class TestJoins(TestCase):
192
+ def setUp(self) -> None:
193
+ self.customers: Table = Table("Customers", "c", field_names=["id", "name"])
194
+ self.accounts: Table = Table("Accounts", "a", field_names=["id", "customerId"])
195
+ self.branches: Table = Table("Branches", field_names=["id"])
196
+
197
+ def test_join_keywords(self):
198
+ expected_results: dict[type, str] = {
199
+ InnerJoin: "INNER JOIN Accounts a on a.customerId = c.id",
200
+ LeftJoin: "LEFT JOIN Accounts a on a.customerId = c.id",
201
+ RightJoin: "RIGHT JOIN Accounts a on a.customerId = c.id",
202
+ FullJoin: "FULL JOIN Accounts a on a.customerId = c.id",
203
+ }
204
+ for join, expected_result in expected_results.items():
205
+ with self.subTest(join=join.__name__):
206
+ actual_result: str = join(
207
+ self.accounts, self.accounts.fields.customerId, self.customers.fields.id
208
+ ).build()
209
+ self.assertEqual(actual_result, expected_result)
210
+
211
+ def test_join_infers_the_same_named_field(self):
212
+ expected_result: str = "LEFT JOIN Accounts a on a.id = c.id"
213
+ actual_result: str = LeftJoin(self.accounts, self.accounts.fields.id, self.customers).build()
214
+ self.assertEqual(actual_result, expected_result)
215
+
216
+ def test_cross_join(self):
217
+ self.assertEqual(CrossJoin(self.branches).build(), "CROSS JOIN Branches")
218
+ self.assertEqual(repr(CrossJoin(self.branches)).split("(")[0], "CrossJoin")
219
+
220
+ def test_joins_keep_their_order(self):
221
+ expected_result: str = """SELECT c.name
222
+ FROM Customers c
223
+ LEFT JOIN Accounts a on a.customerId = c.id
224
+ INNER JOIN Accounts a on a.id = c.id
225
+ CROSS JOIN Branches"""
226
+ actual_result: str = QueryBuilder(
227
+ Select(self.customers.fields.name),
228
+ From(self.customers),
229
+ LeftJoin(self.accounts, self.accounts.fields.customerId, self.customers.fields.id),
230
+ InnerJoin(self.accounts, self.accounts.fields.id, self.customers),
231
+ CrossJoin(self.branches),
232
+ ).build()
233
+ self.assertEqual(actual_result, expected_result)
@@ -0,0 +1,93 @@
1
+ """Every kind of query the library builds must parse as T-SQL (checked with sqlglot, a dev-only dependency).
2
+
3
+ This catches syntax errors such as the old `SET acc.a AS A=1`. It has blind spots: sqlglot accepts a JOIN
4
+ without ON and a malformed OFFSET, and it does not know which names are in scope, so a SELECT alias used
5
+ in WHERE parses fine. Only a real SQL Server would catch those.
6
+ """
7
+
8
+ from unittest import TestCase
9
+
10
+ import sqlglot
11
+ from sqlglot.errors import ParseError
12
+
13
+ from sql_querystring_builder import (
14
+ AliasableString,
15
+ CrossJoin,
16
+ From,
17
+ FullJoin,
18
+ FunctionOnField,
19
+ InnerJoin,
20
+ LeftJoin,
21
+ OrderBy,
22
+ QueryBuilder,
23
+ RightJoin,
24
+ Select,
25
+ Table,
26
+ Update,
27
+ Where,
28
+ )
29
+
30
+
31
+ class TestSqlSyntax(TestCase):
32
+ def setUp(self) -> None:
33
+ self.customers: Table = Table("Customers", field_names=["id", "customerId", "mainPhone"])
34
+ self.accounts: Table = Table("Accounts", "acc", field_names=["id", "customerId", "accountId", "card"])
35
+ self.branches: Table = Table("Branches", "b", field_names=["id"])
36
+
37
+ def build_queries(self) -> dict[str, str]:
38
+ customers, accounts = self.customers, self.accounts
39
+ select = Select(customers, accounts.fields.accountId)
40
+ customers.fields.customerId.set_alias("cid")
41
+ temp_table = select.to_temptable("a_temp_table")
42
+ bracketed = Table("dbo.[My Table]", "m", field_names=["x"])
43
+ # A temp table named from this function's name instead of its alias would hold `#totals.sum of card`,
44
+ # which does not parse (unlike `#totals.total card`, which reads as a column with an alias).
45
+ total = FunctionOnField("sum of card", "SUM(", accounts.fields.card, ")", "total_card")
46
+ totals_table = Select(accounts.fields.id, total).to_temptable("totals")
47
+ return {
48
+ "readme flow": QueryBuilder(
49
+ select,
50
+ From(customers),
51
+ InnerJoin(accounts, accounts.fields.customerId, customers),
52
+ Where(accounts.fields.accountId, "> 20000 AND", customers.fields.customerId, "LIKE 'A1C%'"),
53
+ OrderBy(customers.fields.customerId, descending=True),
54
+ ).build(),
55
+ "temp table": QueryBuilder(Select(temp_table), From(temp_table)).build(),
56
+ "temp table from a function": QueryBuilder(Select(totals_table), From(totals_table)).build(),
57
+ "update with join": QueryBuilder(
58
+ Update({customers.fields.customerId: "'x'"}), # an aliased field: SET must not write AS cid
59
+ From(customers),
60
+ InnerJoin(accounts, accounts.fields.customerId, customers),
61
+ Where(accounts.fields.id, "= 1"),
62
+ ).build(),
63
+ "all join kinds": QueryBuilder(
64
+ Select(customers.fields.id),
65
+ From(customers),
66
+ LeftJoin(accounts, accounts.fields.customerId, customers),
67
+ RightJoin(self.branches, self.branches.fields.id, accounts.fields.id),
68
+ FullJoin(accounts, accounts.fields.id, customers),
69
+ CrossJoin(self.branches),
70
+ ).build(),
71
+ "distinct top into": QueryBuilder(Select(customers, distinct=True, top=10), From(customers)).build(),
72
+ "paging": QueryBuilder(
73
+ Select(customers), From(customers), OrderBy(customers.fields.id).offset(20).fetch(10)
74
+ ).build(),
75
+ "bracketed names": QueryBuilder(
76
+ Select(bracketed), From(bracketed), Where(bracketed.fields.x, "= 1")
77
+ ).build(),
78
+ "functions and strings": QueryBuilder(
79
+ Select(total, AliasableString("one", "1", "one")), From(accounts), OrderBy(total)
80
+ ).build(),
81
+ "select star": QueryBuilder(Select(), From(customers)).build(),
82
+ }
83
+
84
+ def test_known_bad_output_is_rejected(self):
85
+ # Outputs the library produced before 0.1.0: if these parsed, the test below would prove nothing.
86
+ for sql in ("UPDATE acc\nSET acc.a AS A=1, acc.b=2", "SELECT #tt.id, #tt.sum of card\nFROM #tt"):
87
+ with self.subTest(sql=sql), self.assertRaises(ParseError):
88
+ sqlglot.parse_one(sql, read="tsql")
89
+
90
+ def test_generated_queries_parse_as_tsql(self):
91
+ for name, sql in self.build_queries().items():
92
+ with self.subTest(name=name):
93
+ sqlglot.parse_one(sql, read="tsql") # raises ParseError on invalid syntax
@@ -4,6 +4,7 @@ from sql_querystring_builder.clauses import Select
4
4
  from sql_querystring_builder.tables import (
5
5
  AliasableString,
6
6
  AlreadyExistingFieldException,
7
+ Field,
7
8
  FunctionOnField,
8
9
  InvalidNameException,
9
10
  NonExistingFieldException,
@@ -111,3 +112,36 @@ class TestTables(TestCase):
111
112
 
112
113
  expected_result: str = "SELECT testtable.field1, testtable.field2"
113
114
  self.assertEqual(select.build(), expected_result)
115
+
116
+
117
+ class TestFieldsAccess(TestCase):
118
+ def setUp(self):
119
+ self.table: Table = Table("t", field_names=["id", "add", "table"])
120
+
121
+ def test_item_access_reaches_shadowed_names(self):
122
+ self.assertNotIsInstance(self.table.fields.add, Field) # the attribute is the Fields.add method
123
+ self.assertIs(self.table.fields["add"], self.table.fields.fields["add"])
124
+ self.assertEqual(self.table.fields["table"].build(), "t.table")
125
+
126
+ def test_missing_item(self):
127
+ with self.assertRaises(NonExistingFieldException) as exc:
128
+ _ = self.table.fields["missing"]
129
+ self.assertEqual(
130
+ str(exc.exception),
131
+ "No field named 'missing' in the table Table(name='t', alias='None', fields=Fields(table='t', "
132
+ "[Field(name='id', alias='None', table='t'),Field(name='add', alias='None', table='t'),"
133
+ "Field(name='table', alias='None', table='t')])).",
134
+ )
135
+
136
+ def test_membership(self):
137
+ other_table: Table = Table("u", field_names=["id"])
138
+ self.assertIn("id", self.table.fields)
139
+ self.assertNotIn("missing", self.table.fields)
140
+ self.assertIn(self.table.fields["id"], self.table.fields)
141
+ self.assertNotIn(other_table.fields["id"], self.table.fields) # same name, other table
142
+ self.assertNotIn(42, self.table.fields)
143
+
144
+ def test_iteration_and_length(self):
145
+ self.assertEqual([field.name for field in self.table.fields], ["id", "add", "table"])
146
+ self.assertEqual(len(self.table.fields), 3)
147
+ self.assertFalse(Table("empty").fields) # an empty Fields is falsy now that it has a length
@@ -1,3 +0,0 @@
1
- from .clauses import From, InnerJoin, OrderBy, Select, Update, Where
2
- from .query_builder import QueryBuilder
3
- from .tables import AliasableString, Field, FunctionOnField, Table, TempTable
@@ -1,74 +0,0 @@
1
- from unittest import TestCase
2
-
3
- from sql_querystring_builder.clauses import IncorrectUpdateException, InnerJoin, OrderBy, Select, Update, Where
4
- from sql_querystring_builder.tables import Field, NonExistingFieldException, Table, TempTable
5
-
6
-
7
- class TestClauses(TestCase):
8
- def setUp(self) -> None:
9
- self.maxDiff = None
10
-
11
- self.table1: Table = Table(name="TestTable", alias="TT", field_names=["TestField1", "TestField2"])
12
- self.table2: Table = Table(name="TestTable2", alias="TT2", field_names=["TestField21", "TestField22"])
13
-
14
- self.select: Select = Select(self.table1.fields.TestField2, self.table2)
15
- self.testtemptable: TempTable = self.select.to_temptable("ttt")
16
- self.table1field1: Field = self.table1.fields.TestField1
17
- self.table1field2: Field = self.table1.fields.TestField2
18
- self.table2field21: Field = self.table2.fields.TestField21
19
- self.table2field22: Field = self.table2.fields.TestField22
20
-
21
- def test_init_select(self):
22
- self.assertEqual(self.select.fields, [self.table1field2, self.table2field21, self.table2field22])
23
-
24
- def test_select_build(self):
25
- expected_result: str = "SELECT TT.TestField1, TT.TestField2, TT2.TestField21"
26
- actual_result: str = Select(self.table1, self.table2.fields.TestField21).build()
27
- self.assertEqual(actual_result, expected_result)
28
-
29
- def test_from_build(self):
30
- expected_result: str = "WHERE TT.TestField2 has a very unique condition"
31
- actual_result: str = Where(self.table1field2, "has a very unique condition").build()
32
- self.assertEqual(actual_result, expected_result)
33
-
34
- def test_innerjoin_ok(self):
35
- expected_result: str = "INNER JOIN TestTable2 TT2 on TT2.TestField22 = TT.TestField2"
36
- actual_result: str = InnerJoin(self.table2, self.table2field22, self.table1field2).build()
37
- self.assertEqual(actual_result, expected_result)
38
-
39
- def test_innerjoin_unexisting_infered_field(self):
40
- expected_err_msg: str = (
41
- "No field named 'TestField22' in the table "
42
- "Table(name='TestTable', alias='TT', fields=Fields(table='TestTable', "
43
- "[Field(name='TestField1', alias='None', table='TestTable'),"
44
- "Field(name='TestField2', alias='None', table='TestTable')]))."
45
- )
46
- with self.assertRaises(NonExistingFieldException) as exc:
47
- InnerJoin(self.table2, self.table2field22, self.table1)
48
- self.assertEqual(str(exc.exception), expected_err_msg)
49
-
50
- def test_update_ok(self):
51
- expected_result: str = "UPDATE TT\nSET TT.TestField1='hello', TT.TestField2='world'"
52
- actual_result: str = Update({self.table1field1: "'hello'", self.table1field2: "'world'"}).build()
53
- self.assertEqual(actual_result, expected_result)
54
-
55
- def test_different_tables(self):
56
- expected_err_msg: str = (
57
- "Two different tables cannot be updated in the same Update: 'TestTable' and 'TestTable2'."
58
- )
59
- with self.assertRaises(IncorrectUpdateException) as exc:
60
- Update({self.table1field1: "'hello'", self.table2field21: "'world'"}).build()
61
- self.assertEqual(str(exc.exception), expected_err_msg)
62
-
63
- def test_descending(self):
64
- anonymous_field: Field = Field("anonymous", self.table1)
65
- anonymous_field2: Field = Field("anonymous2", self.table2)
66
-
67
- self.assertEqual(
68
- OrderBy(self.table1field1, True).add(anonymous_field2).build(),
69
- "ORDER BY TT.TestField1 DESC, TT2.anonymous2",
70
- )
71
- self.assertEqual(
72
- OrderBy(self.table2field21).add(anonymous_field, True).build(),
73
- "ORDER BY TT2.TestField21, TT.anonymous DESC",
74
- )