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.
- {sql_querystring_builder-0.1.1/src/sql_querystring_builder.egg-info → sql_querystring_builder-0.2.0}/PKG-INFO +24 -7
- {sql_querystring_builder-0.1.1 → sql_querystring_builder-0.2.0}/README.md +23 -6
- {sql_querystring_builder-0.1.1 → sql_querystring_builder-0.2.0}/pyproject.toml +2 -1
- sql_querystring_builder-0.2.0/src/sql_querystring_builder/__init__.py +28 -0
- {sql_querystring_builder-0.1.1 → sql_querystring_builder-0.2.0}/src/sql_querystring_builder/clauses.py +95 -13
- sql_querystring_builder-0.2.0/src/sql_querystring_builder/exceptions.py +2 -0
- {sql_querystring_builder-0.1.1 → sql_querystring_builder-0.2.0}/src/sql_querystring_builder/query_builder.py +15 -3
- {sql_querystring_builder-0.1.1 → sql_querystring_builder-0.2.0}/src/sql_querystring_builder/tables.py +86 -27
- {sql_querystring_builder-0.1.1 → sql_querystring_builder-0.2.0/src/sql_querystring_builder.egg-info}/PKG-INFO +24 -7
- {sql_querystring_builder-0.1.1 → sql_querystring_builder-0.2.0}/src/sql_querystring_builder.egg-info/SOURCES.txt +2 -0
- {sql_querystring_builder-0.1.1 → sql_querystring_builder-0.2.0}/tests/test_characterization.py +56 -11
- sql_querystring_builder-0.2.0/tests/test_clauses.py +233 -0
- sql_querystring_builder-0.2.0/tests/test_sql_syntax.py +93 -0
- {sql_querystring_builder-0.1.1 → sql_querystring_builder-0.2.0}/tests/test_tables.py +34 -0
- sql_querystring_builder-0.1.1/src/sql_querystring_builder/__init__.py +0 -3
- sql_querystring_builder-0.1.1/tests/test_clauses.py +0 -74
- {sql_querystring_builder-0.1.1 → sql_querystring_builder-0.2.0}/LICENSE +0 -0
- {sql_querystring_builder-0.1.1 → sql_querystring_builder-0.2.0}/setup.cfg +0 -0
- {sql_querystring_builder-0.1.1 → sql_querystring_builder-0.2.0}/src/sql_querystring_builder/abstract_clause.py +0 -0
- {sql_querystring_builder-0.1.1 → sql_querystring_builder-0.2.0}/src/sql_querystring_builder/py.typed +0 -0
- {sql_querystring_builder-0.1.1 → sql_querystring_builder-0.2.0}/src/sql_querystring_builder.egg-info/dependency_links.txt +0 -0
- {sql_querystring_builder-0.1.1 → sql_querystring_builder-0.2.0}/src/sql_querystring_builder.egg-info/top_level.txt +0 -0
- {sql_querystring_builder-0.1.1 → sql_querystring_builder-0.2.0}/tests/test_queries.py +0 -0
- {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.
|
|
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, "
|
|
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
|
|
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, "
|
|
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
|
|
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.
|
|
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(
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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
|
|
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"
|
|
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
|
-
|
|
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
|
-
|
|
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})"
|
|
@@ -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(
|
|
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
|
-
|
|
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
|
-
|
|
9
|
+
|
|
10
|
+
class AliasException(SqlQueryBuilderError):
|
|
8
11
|
pass
|
|
9
12
|
|
|
10
13
|
|
|
11
|
-
class AlreadyExistingFieldException(
|
|
14
|
+
class AlreadyExistingFieldException(SqlQueryBuilderError):
|
|
12
15
|
pass
|
|
13
16
|
|
|
14
17
|
|
|
15
|
-
class InvalidNameException(
|
|
18
|
+
class InvalidNameException(SqlQueryBuilderError):
|
|
16
19
|
pass
|
|
17
20
|
|
|
18
21
|
|
|
19
|
-
class NonExistingAliasException(
|
|
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(
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
170
|
-
if fields is None:
|
|
208
|
+
if "fields" not in self.__dict__:
|
|
171
209
|
raise AttributeError(attr_name)
|
|
172
|
-
|
|
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 '{
|
|
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__(
|
|
183
|
-
self
|
|
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, *
|
|
249
|
+
def with_fields(self, *fields: Field | str) -> Table:
|
|
190
250
|
"""
|
|
191
251
|
Alias to self.add()
|
|
192
252
|
"""
|
|
193
|
-
return self.add(*
|
|
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
|
-
|
|
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.
|
|
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, "
|
|
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
|
|
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
|
{sql_querystring_builder-0.1.1 → sql_querystring_builder-0.2.0}/tests/test_characterization.py
RENAMED
|
@@ -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,
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
287
|
-
self.
|
|
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,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
|
-
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sql_querystring_builder-0.1.1 → sql_querystring_builder-0.2.0}/src/sql_querystring_builder/py.typed
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|