sql_querystring_builder 0.1.0__py3-none-any.whl → 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sql_querystring_builder/__init__.py +28 -3
- sql_querystring_builder/clauses.py +95 -13
- sql_querystring_builder/exceptions.py +2 -0
- sql_querystring_builder/py.typed +0 -0
- sql_querystring_builder/query_builder.py +15 -3
- sql_querystring_builder/tables.py +86 -27
- {sql_querystring_builder-0.1.0.dist-info → sql_querystring_builder-0.2.0.dist-info}/METADATA +46 -13
- sql_querystring_builder-0.2.0.dist-info/RECORD +12 -0
- sql_querystring_builder-0.1.0.dist-info/RECORD +0 -10
- {sql_querystring_builder-0.1.0.dist-info → sql_querystring_builder-0.2.0.dist-info}/WHEEL +0 -0
- {sql_querystring_builder-0.1.0.dist-info → sql_querystring_builder-0.2.0.dist-info}/licenses/LICENSE +0 -0
- {sql_querystring_builder-0.1.0.dist-info → sql_querystring_builder-0.2.0.dist-info}/top_level.txt +0 -0
|
@@ -1,3 +1,28 @@
|
|
|
1
|
-
from .clauses import
|
|
2
|
-
|
|
3
|
-
|
|
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})"
|
|
File without changes
|
|
@@ -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.
|
{sql_querystring_builder-0.1.0.dist-info → sql_querystring_builder-0.2.0.dist-info}/METADATA
RENAMED
|
@@ -1,12 +1,19 @@
|
|
|
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
|
|
7
7
|
Keywords: sql,query,string
|
|
8
8
|
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
9
14
|
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Topic :: Database
|
|
16
|
+
Classifier: Typing :: Typed
|
|
10
17
|
Requires-Python: >=3.10
|
|
11
18
|
Description-Content-Type: text/markdown
|
|
12
19
|
License-File: LICENSE
|
|
@@ -24,25 +31,27 @@ It provides first level validation.
|
|
|
24
31
|
- [x] Function on field
|
|
25
32
|
- [x] String
|
|
26
33
|
2. Using:
|
|
27
|
-
- [x] Select
|
|
34
|
+
- [x] Select (with DISTINCT and TOP)
|
|
28
35
|
- [x] Update
|
|
29
36
|
- [x] From
|
|
30
|
-
- [x] InnerJoin
|
|
37
|
+
- [x] InnerJoin, LeftJoin, RightJoin, FullJoin, CrossJoin
|
|
31
38
|
- [x] Where
|
|
32
|
-
- [x] OrderBy
|
|
39
|
+
- [x] OrderBy (with OFFSET/FETCH paging)
|
|
33
40
|
3. Handling automatically:
|
|
34
41
|
- [x] Table aliases
|
|
35
42
|
- [x] Field aliases
|
|
36
43
|
|
|
37
44
|
## Not implemented (yet)
|
|
38
|
-
|
|
39
|
-
- [ ]
|
|
40
|
-
- [ ]
|
|
41
|
-
- [ ]
|
|
45
|
+
In planned order:
|
|
46
|
+
- [ ] Proper conditions: typed values, AND/OR/NOT, IN, BETWEEN, LIKE, IS NULL
|
|
47
|
+
- [ ] Insert and Delete
|
|
48
|
+
- [ ] Functions (aggregates and others), GROUP BY, HAVING, CASE
|
|
49
|
+
- [ ] Compound queries (UNION, INTERSECT, EXCEPT) and sub-queries
|
|
50
|
+
- [ ] Parameterized queries
|
|
42
51
|
|
|
43
52
|
## What it does not do
|
|
44
53
|
Connect to a database server and run the queries by itself.
|
|
45
|
-
It gives you the query as a string, it's then up to
|
|
54
|
+
It gives you the query as a string, it's then up to you to pass it to a SQL engine.
|
|
46
55
|
|
|
47
56
|
## Example
|
|
48
57
|
1. Define the elements of your query:
|
|
@@ -55,7 +64,7 @@ accounts_table = Table("Accounts", "acc").add("id", "customerId", "accountId")
|
|
|
55
64
|
select = Select(customers_table, accounts_table.fields.accountId)
|
|
56
65
|
# Sets an alias for an existing field
|
|
57
66
|
customers_table.fields.customerId.set_alias("cid")
|
|
58
|
-
# Specifies the table to
|
|
67
|
+
# Specifies the table to take as a FROM
|
|
59
68
|
from_ = From(customers_table)
|
|
60
69
|
# Specifies a temporary table for the select
|
|
61
70
|
atemptable = select.to_temptable("a_temp_table")
|
|
@@ -64,7 +73,7 @@ innerjoin = InnerJoin(accounts_table, accounts_table.fields.customerId, customer
|
|
|
64
73
|
# Defines an Order By a defined field
|
|
65
74
|
orderby = OrderBy(customers_table.fields.customerId, descending=True)
|
|
66
75
|
# Adds a Where filter
|
|
67
|
-
where = Where(accounts_table.fields.accountId, "
|
|
76
|
+
where = Where(accounts_table.fields.accountId, "> 20000 AND", customers_table.fields.customerId, "LIKE 'A1C%'")
|
|
68
77
|
```
|
|
69
78
|
2. Inspect what those elements are made of:
|
|
70
79
|
```
|
|
@@ -87,10 +96,10 @@ print(query_str)
|
|
|
87
96
|
# > INTO #a_temp_table
|
|
88
97
|
# > FROM Customers
|
|
89
98
|
# > INNER JOIN Accounts acc on acc.customerId = Customers.customerId
|
|
90
|
-
# > WHERE acc.accountId
|
|
99
|
+
# > WHERE acc.accountId > 20000 AND Customers.customerId LIKE 'A1C%'
|
|
91
100
|
# > ORDER BY cid DESC
|
|
92
101
|
```
|
|
93
|
-
4. Manipulate temporary tables
|
|
102
|
+
4. Manipulate temporary tables whose components are inferred:
|
|
94
103
|
```
|
|
95
104
|
# Is able to infer fields from temporary tables and use it as with a regular table.
|
|
96
105
|
temp_select = Select(atemptable)
|
|
@@ -118,6 +127,30 @@ Python 3.10 and above.
|
|
|
118
127
|
(None)
|
|
119
128
|
|
|
120
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
|
+
|
|
148
|
+
### 0.1.1
|
|
149
|
+
No change to the generated SQL.
|
|
150
|
+
- The package ships a `py.typed` marker, so type checkers such as mypy now use its type hints.
|
|
151
|
+
- PyPI classifiers for the supported Python versions (3.10 to 3.14), typing and topic.
|
|
152
|
+
- The "Not implemented (yet)" list shows the planned features in order; typo fixes.
|
|
153
|
+
|
|
121
154
|
### 0.1.0
|
|
122
155
|
This release changes the generated SQL: check your queries when upgrading.
|
|
123
156
|
- WHERE conditions and the right side of `INNER JOIN ... on` use the field's qualified name (`t1.KeyId`)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
sql_querystring_builder/__init__.py,sha256=55ATxTSWWCkAFkMWPQBZv-9wznXDFNyQi826MA9mhx8,622
|
|
2
|
+
sql_querystring_builder/abstract_clause.py,sha256=l1dGyhsu-k4QoNU16kXfDxLS4lXc02TJtlaNoUpHJXw,261
|
|
3
|
+
sql_querystring_builder/clauses.py,sha256=Rxmuypxbum-QGdGRmz4BqjoEDW3aneAHFRYg38JT_ow,8070
|
|
4
|
+
sql_querystring_builder/exceptions.py,sha256=hdKvQD0Waqc251fRoNy6-eAnFKhQFyNtWqCaeqwd4VQ,140
|
|
5
|
+
sql_querystring_builder/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
sql_querystring_builder/query_builder.py,sha256=10MUAVrUj9JJbQ_1aRt8H5YBJ9Xi3SgdWWFwRsX25U4,2275
|
|
7
|
+
sql_querystring_builder/tables.py,sha256=1AKnPTDUeO8h3zYE9-UeJYMdmpDfgADsXRx1UrT1BMo,10859
|
|
8
|
+
sql_querystring_builder-0.2.0.dist-info/licenses/LICENSE,sha256=IwGE9guuL-ryRPEKi6wFPI_zOhg7zDZbTYuHbSt_SAk,35823
|
|
9
|
+
sql_querystring_builder-0.2.0.dist-info/METADATA,sha256=wfzcT-_QCjfzGOW76bIQjNbLUwqppCMA4Vzvv-H2w7o,7267
|
|
10
|
+
sql_querystring_builder-0.2.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
11
|
+
sql_querystring_builder-0.2.0.dist-info/top_level.txt,sha256=U1YNBUlpIiidNaXjnScKqzNgTNVIhucoRPG3f287txU,24
|
|
12
|
+
sql_querystring_builder-0.2.0.dist-info/RECORD,,
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
sql_querystring_builder/__init__.py,sha256=Vyiejs1sPHpZC9Qzo97TQVFDHurW8eGraFeWyUe46I0,190
|
|
2
|
-
sql_querystring_builder/abstract_clause.py,sha256=l1dGyhsu-k4QoNU16kXfDxLS4lXc02TJtlaNoUpHJXw,261
|
|
3
|
-
sql_querystring_builder/clauses.py,sha256=G4XCk2ssmUYIQBctTle_tz6qwZ946jmL62dFqgz-ZsQ,5294
|
|
4
|
-
sql_querystring_builder/query_builder.py,sha256=n9kkifkAXTJos--T2wlGcsqA9xIXRzYa-SjPPpi0qxk,1601
|
|
5
|
-
sql_querystring_builder/tables.py,sha256=e_FFuc44o4ZAkM3iy37f61ddPp7xdGBiBgsOmMCIv6A,8831
|
|
6
|
-
sql_querystring_builder-0.1.0.dist-info/licenses/LICENSE,sha256=IwGE9guuL-ryRPEKi6wFPI_zOhg7zDZbTYuHbSt_SAk,35823
|
|
7
|
-
sql_querystring_builder-0.1.0.dist-info/METADATA,sha256=uaurvWIAli1OZnvB2ne2_pT7wjd18mCilcVGSvqVxZQ,5133
|
|
8
|
-
sql_querystring_builder-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
9
|
-
sql_querystring_builder-0.1.0.dist-info/top_level.txt,sha256=U1YNBUlpIiidNaXjnScKqzNgTNVIhucoRPG3f287txU,24
|
|
10
|
-
sql_querystring_builder-0.1.0.dist-info/RECORD,,
|
|
File without changes
|
{sql_querystring_builder-0.1.0.dist-info → sql_querystring_builder-0.2.0.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|
{sql_querystring_builder-0.1.0.dist-info → sql_querystring_builder-0.2.0.dist-info}/top_level.txt
RENAMED
|
File without changes
|