sql_querystring_builder 0.1.0__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.0 → sql_querystring_builder-0.2.0}/PKG-INFO +46 -13
- sql_querystring_builder-0.1.0/src/sql_querystring_builder.egg-info/PKG-INFO → sql_querystring_builder-0.2.0/README.md +39 -27
- {sql_querystring_builder-0.1.0 → sql_querystring_builder-0.2.0}/pyproject.toml +10 -2
- sql_querystring_builder-0.2.0/src/sql_querystring_builder/__init__.py +28 -0
- {sql_querystring_builder-0.1.0 → 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.2.0/src/sql_querystring_builder/py.typed +0 -0
- {sql_querystring_builder-0.1.0 → sql_querystring_builder-0.2.0}/src/sql_querystring_builder/query_builder.py +15 -3
- {sql_querystring_builder-0.1.0 → sql_querystring_builder-0.2.0}/src/sql_querystring_builder/tables.py +86 -27
- sql_querystring_builder-0.1.0/README.md → sql_querystring_builder-0.2.0/src/sql_querystring_builder.egg-info/PKG-INFO +60 -13
- {sql_querystring_builder-0.1.0 → sql_querystring_builder-0.2.0}/src/sql_querystring_builder.egg-info/SOURCES.txt +3 -0
- {sql_querystring_builder-0.1.0 → 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.0 → sql_querystring_builder-0.2.0}/tests/test_tables.py +34 -0
- sql_querystring_builder-0.1.0/src/sql_querystring_builder/__init__.py +0 -3
- sql_querystring_builder-0.1.0/tests/test_clauses.py +0 -74
- {sql_querystring_builder-0.1.0 → sql_querystring_builder-0.2.0}/LICENSE +0 -0
- {sql_querystring_builder-0.1.0 → sql_querystring_builder-0.2.0}/setup.cfg +0 -0
- {sql_querystring_builder-0.1.0 → sql_querystring_builder-0.2.0}/src/sql_querystring_builder/abstract_clause.py +0 -0
- {sql_querystring_builder-0.1.0 → sql_querystring_builder-0.2.0}/src/sql_querystring_builder.egg-info/dependency_links.txt +0 -0
- {sql_querystring_builder-0.1.0 → sql_querystring_builder-0.2.0}/src/sql_querystring_builder.egg-info/top_level.txt +0 -0
- {sql_querystring_builder-0.1.0 → sql_querystring_builder-0.2.0}/tests/test_queries.py +0 -0
- {sql_querystring_builder-0.1.0 → sql_querystring_builder-0.2.0}/tests/test_readme.py +0 -0
|
@@ -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`)
|
|
@@ -1,17 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: sql_querystring_builder
|
|
3
|
-
Version: 0.1.0
|
|
4
|
-
Summary: Wraps string building of MS SQL queries
|
|
5
|
-
Author-email: Wael GRIBAA <g.wael@outlook.fr>
|
|
6
|
-
License-Expression: GPL-3.0-only
|
|
7
|
-
Keywords: sql,query,string
|
|
8
|
-
Classifier: Programming Language :: Python :: 3
|
|
9
|
-
Classifier: Operating System :: OS Independent
|
|
10
|
-
Requires-Python: >=3.10
|
|
11
|
-
Description-Content-Type: text/markdown
|
|
12
|
-
License-File: LICENSE
|
|
13
|
-
Dynamic: license-file
|
|
14
|
-
|
|
15
1
|
# SqlQueryBuilder
|
|
16
2
|
Wraps string building of MS SQL queries.
|
|
17
3
|
It provides first level validation.
|
|
@@ -24,25 +10,27 @@ It provides first level validation.
|
|
|
24
10
|
- [x] Function on field
|
|
25
11
|
- [x] String
|
|
26
12
|
2. Using:
|
|
27
|
-
- [x] Select
|
|
13
|
+
- [x] Select (with DISTINCT and TOP)
|
|
28
14
|
- [x] Update
|
|
29
15
|
- [x] From
|
|
30
|
-
- [x] InnerJoin
|
|
16
|
+
- [x] InnerJoin, LeftJoin, RightJoin, FullJoin, CrossJoin
|
|
31
17
|
- [x] Where
|
|
32
|
-
- [x] OrderBy
|
|
18
|
+
- [x] OrderBy (with OFFSET/FETCH paging)
|
|
33
19
|
3. Handling automatically:
|
|
34
20
|
- [x] Table aliases
|
|
35
21
|
- [x] Field aliases
|
|
36
22
|
|
|
37
23
|
## Not implemented (yet)
|
|
38
|
-
|
|
39
|
-
- [ ]
|
|
40
|
-
- [ ]
|
|
41
|
-
- [ ]
|
|
24
|
+
In planned order:
|
|
25
|
+
- [ ] Proper conditions: typed values, AND/OR/NOT, IN, BETWEEN, LIKE, IS NULL
|
|
26
|
+
- [ ] Insert and Delete
|
|
27
|
+
- [ ] Functions (aggregates and others), GROUP BY, HAVING, CASE
|
|
28
|
+
- [ ] Compound queries (UNION, INTERSECT, EXCEPT) and sub-queries
|
|
29
|
+
- [ ] Parameterized queries
|
|
42
30
|
|
|
43
31
|
## What it does not do
|
|
44
32
|
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
|
|
33
|
+
It gives you the query as a string, it's then up to you to pass it to a SQL engine.
|
|
46
34
|
|
|
47
35
|
## Example
|
|
48
36
|
1. Define the elements of your query:
|
|
@@ -55,7 +43,7 @@ accounts_table = Table("Accounts", "acc").add("id", "customerId", "accountId")
|
|
|
55
43
|
select = Select(customers_table, accounts_table.fields.accountId)
|
|
56
44
|
# Sets an alias for an existing field
|
|
57
45
|
customers_table.fields.customerId.set_alias("cid")
|
|
58
|
-
# Specifies the table to
|
|
46
|
+
# Specifies the table to take as a FROM
|
|
59
47
|
from_ = From(customers_table)
|
|
60
48
|
# Specifies a temporary table for the select
|
|
61
49
|
atemptable = select.to_temptable("a_temp_table")
|
|
@@ -64,7 +52,7 @@ innerjoin = InnerJoin(accounts_table, accounts_table.fields.customerId, customer
|
|
|
64
52
|
# Defines an Order By a defined field
|
|
65
53
|
orderby = OrderBy(customers_table.fields.customerId, descending=True)
|
|
66
54
|
# Adds a Where filter
|
|
67
|
-
where = Where(accounts_table.fields.accountId, "
|
|
55
|
+
where = Where(accounts_table.fields.accountId, "> 20000 AND", customers_table.fields.customerId, "LIKE 'A1C%'")
|
|
68
56
|
```
|
|
69
57
|
2. Inspect what those elements are made of:
|
|
70
58
|
```
|
|
@@ -87,10 +75,10 @@ print(query_str)
|
|
|
87
75
|
# > INTO #a_temp_table
|
|
88
76
|
# > FROM Customers
|
|
89
77
|
# > INNER JOIN Accounts acc on acc.customerId = Customers.customerId
|
|
90
|
-
# > WHERE acc.accountId
|
|
78
|
+
# > WHERE acc.accountId > 20000 AND Customers.customerId LIKE 'A1C%'
|
|
91
79
|
# > ORDER BY cid DESC
|
|
92
80
|
```
|
|
93
|
-
4. Manipulate temporary tables
|
|
81
|
+
4. Manipulate temporary tables whose components are inferred:
|
|
94
82
|
```
|
|
95
83
|
# Is able to infer fields from temporary tables and use it as with a regular table.
|
|
96
84
|
temp_select = Select(atemptable)
|
|
@@ -118,6 +106,30 @@ Python 3.10 and above.
|
|
|
118
106
|
(None)
|
|
119
107
|
|
|
120
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
|
+
|
|
127
|
+
### 0.1.1
|
|
128
|
+
No change to the generated SQL.
|
|
129
|
+
- The package ships a `py.typed` marker, so type checkers such as mypy now use its type hints.
|
|
130
|
+
- PyPI classifiers for the supported Python versions (3.10 to 3.14), typing and topic.
|
|
131
|
+
- The "Not implemented (yet)" list shows the planned features in order; typo fixes.
|
|
132
|
+
|
|
121
133
|
### 0.1.0
|
|
122
134
|
This release changes the generated SQL: check your queries when upgrading.
|
|
123
135
|
- WHERE conditions and the right side of `INNER JOIN ... on` use the field's qualified name (`t1.KeyId`)
|
|
@@ -136,4 +148,4 @@ This release changes the generated SQL: check your queries when upgrading.
|
|
|
136
148
|
## Authors
|
|
137
149
|
Wael GRIBAA
|
|
138
150
|
Contact: g.wael@outlook.fr
|
|
139
|
-
Made in September 2024
|
|
151
|
+
Made in September 2024
|
|
@@ -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
|
]
|
|
@@ -15,7 +15,14 @@ license = "GPL-3.0-only"
|
|
|
15
15
|
license-files = ["LICENSE"]
|
|
16
16
|
classifiers = [
|
|
17
17
|
"Programming Language :: Python :: 3",
|
|
18
|
-
"
|
|
18
|
+
"Programming Language :: Python :: 3.10",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
"Programming Language :: Python :: 3.14",
|
|
23
|
+
"Operating System :: OS Independent",
|
|
24
|
+
"Topic :: Database",
|
|
25
|
+
"Typing :: Typed",
|
|
19
26
|
]
|
|
20
27
|
keywords = ["sql", "query", "string"]
|
|
21
28
|
|
|
@@ -27,6 +34,7 @@ dev = [
|
|
|
27
34
|
"mypy>=2.4.0",
|
|
28
35
|
"pytest>=9.1.1",
|
|
29
36
|
"ruff>=0.17.0",
|
|
37
|
+
"sqlglot==30.19.0",
|
|
30
38
|
]
|
|
31
39
|
|
|
32
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})"
|
|
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:
|