sql_querystring_builder 0.1.1__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. {sql_querystring_builder-0.1.1/src/sql_querystring_builder.egg-info → sql_querystring_builder-0.3.0}/PKG-INFO +71 -12
  2. sql_querystring_builder-0.3.0/README.md +193 -0
  3. {sql_querystring_builder-0.1.1 → sql_querystring_builder-0.3.0}/pyproject.toml +2 -1
  4. sql_querystring_builder-0.3.0/src/sql_querystring_builder/__init__.py +30 -0
  5. sql_querystring_builder-0.3.0/src/sql_querystring_builder/clauses.py +273 -0
  6. sql_querystring_builder-0.3.0/src/sql_querystring_builder/conditions.py +194 -0
  7. sql_querystring_builder-0.3.0/src/sql_querystring_builder/exceptions.py +2 -0
  8. sql_querystring_builder-0.3.0/src/sql_querystring_builder/query_builder.py +67 -0
  9. {sql_querystring_builder-0.1.1 → sql_querystring_builder-0.3.0}/src/sql_querystring_builder/tables.py +91 -31
  10. sql_querystring_builder-0.3.0/src/sql_querystring_builder/values.py +139 -0
  11. {sql_querystring_builder-0.1.1 → sql_querystring_builder-0.3.0/src/sql_querystring_builder.egg-info}/PKG-INFO +71 -12
  12. {sql_querystring_builder-0.1.1 → sql_querystring_builder-0.3.0}/src/sql_querystring_builder.egg-info/SOURCES.txt +5 -0
  13. {sql_querystring_builder-0.1.1 → sql_querystring_builder-0.3.0}/tests/test_characterization.py +60 -11
  14. sql_querystring_builder-0.3.0/tests/test_clauses.py +311 -0
  15. sql_querystring_builder-0.3.0/tests/test_conditions.py +258 -0
  16. sql_querystring_builder-0.3.0/tests/test_queries.py +159 -0
  17. sql_querystring_builder-0.3.0/tests/test_sql_syntax.py +153 -0
  18. {sql_querystring_builder-0.1.1 → sql_querystring_builder-0.3.0}/tests/test_tables.py +34 -0
  19. sql_querystring_builder-0.1.1/README.md +0 -134
  20. sql_querystring_builder-0.1.1/src/sql_querystring_builder/__init__.py +0 -3
  21. sql_querystring_builder-0.1.1/src/sql_querystring_builder/clauses.py +0 -153
  22. sql_querystring_builder-0.1.1/src/sql_querystring_builder/query_builder.py +0 -44
  23. sql_querystring_builder-0.1.1/tests/test_clauses.py +0 -74
  24. sql_querystring_builder-0.1.1/tests/test_queries.py +0 -47
  25. {sql_querystring_builder-0.1.1 → sql_querystring_builder-0.3.0}/LICENSE +0 -0
  26. {sql_querystring_builder-0.1.1 → sql_querystring_builder-0.3.0}/setup.cfg +0 -0
  27. {sql_querystring_builder-0.1.1 → sql_querystring_builder-0.3.0}/src/sql_querystring_builder/abstract_clause.py +0 -0
  28. {sql_querystring_builder-0.1.1 → sql_querystring_builder-0.3.0}/src/sql_querystring_builder/py.typed +0 -0
  29. {sql_querystring_builder-0.1.1 → sql_querystring_builder-0.3.0}/src/sql_querystring_builder.egg-info/dependency_links.txt +0 -0
  30. {sql_querystring_builder-0.1.1 → sql_querystring_builder-0.3.0}/src/sql_querystring_builder.egg-info/top_level.txt +0 -0
  31. {sql_querystring_builder-0.1.1 → sql_querystring_builder-0.3.0}/tests/test_readme.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sql_querystring_builder
3
- Version: 0.1.1
3
+ Version: 0.3.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
@@ -30,25 +30,26 @@ It provides first level validation.
30
30
  - [x] Field
31
31
  - [x] Function on field
32
32
  - [x] String
33
+ - [x] Conditions: eq, ne, lt, le, gt, ge, like, in_, between, is_null, is_not_null, combined with & | ~
34
+ - [x] Values: text, numbers, booleans, None, dates and times, bytes, UUIDs, and Raw SQL
33
35
  2. Using:
34
- - [x] Select
35
- - [x] Update
36
+ - [x] Select (with DISTINCT and TOP)
37
+ - [x] Update (with typed values)
36
38
  - [x] From
37
- - [x] InnerJoin
38
- - [x] Where
39
- - [x] OrderBy
39
+ - [x] InnerJoin, LeftJoin, RightJoin, FullJoin (on two fields or on any condition), CrossJoin
40
+ - [x] Where (with conditions)
41
+ - [x] OrderBy (with OFFSET/FETCH paging)
40
42
  3. Handling automatically:
41
43
  - [x] Table aliases
42
44
  - [x] Field aliases
45
+ - [x] Escaped values: 'O''Brien', N'café'
46
+ - [x] Parameterized queries: build_with_params() writes ? (pyodbc) or %s (pymssql) and returns the values
43
47
 
44
48
  ## Not implemented (yet)
45
49
  In planned order:
46
- - [ ] LEFT, RIGHT, FULL and CROSS joins; SELECT DISTINCT and TOP; paging (OFFSET/FETCH)
47
- - [ ] Proper conditions: typed values, AND/OR/NOT, IN, BETWEEN, LIKE, IS NULL
48
50
  - [ ] Insert and Delete
49
51
  - [ ] Functions (aggregates and others), GROUP BY, HAVING, CASE
50
52
  - [ ] Compound queries (UNION, INTERSECT, EXCEPT) and sub-queries
51
- - [ ] Parameterized queries
52
53
 
53
54
  ## What it does not do
54
55
  Connect to a database server and run the queries by itself.
@@ -73,8 +74,8 @@ atemptable = select.to_temptable("a_temp_table")
73
74
  innerjoin = InnerJoin(accounts_table, accounts_table.fields.customerId, customers_table)
74
75
  # Defines an Order By a defined field
75
76
  orderby = OrderBy(customers_table.fields.customerId, descending=True)
76
- # Adds a Where filter
77
- where = Where(accounts_table.fields.accountId, "gt 20000 AND", customers_table.fields.customerId, "LIKE 'A1C%'")
77
+ # Adds a Where filter: conditions are methods of fields, combined with & (and_), | (or_) and ~ (not_)
78
+ where = Where(accounts_table.fields.accountId.gt(20000) & customers_table.fields.customerId.like("A1C%"))
78
79
  ```
79
80
  2. Inspect what those elements are made of:
80
81
  ```
@@ -97,7 +98,7 @@ print(query_str)
97
98
  # > INTO #a_temp_table
98
99
  # > FROM Customers
99
100
  # > INNER JOIN Accounts acc on acc.customerId = Customers.customerId
100
- # > WHERE acc.accountId gt 20000 AND Customers.customerId LIKE 'A1C%'
101
+ # > WHERE acc.accountId > 20000 AND Customers.customerId LIKE 'A1C%'
101
102
  # > ORDER BY cid DESC
102
103
  ```
103
104
  4. Manipulate temporary tables whose components are inferred:
@@ -118,6 +119,21 @@ print(QueryBuilder(
118
119
  # > SELECT #a_temp_table.id, #a_temp_table.cid, #a_temp_table.mainPhone, #a_temp_table.accountId
119
120
  # > FROM #a_temp_table
120
121
  ```
122
+ 5. Send the values as parameters, so that they never enter the SQL text:
123
+ ```
124
+ # Builds the same query with a placeholder for each value, and returns the values in order
125
+ sql, params = query_builder.build_with_params()
126
+ print(sql)
127
+ # > SELECT Customers.id, Customers.customerId AS cid, Customers.mainPhone, acc.accountId
128
+ # > INTO #a_temp_table
129
+ # > FROM Customers
130
+ # > INNER JOIN Accounts acc on acc.customerId = Customers.customerId
131
+ # > WHERE acc.accountId > ? AND Customers.customerId LIKE ?
132
+ # > ORDER BY cid DESC
133
+ print(params)
134
+ # > (20000, 'A1C%')
135
+ # With pyodbc: cursor.execute(sql, params). For pymssql, build_with_params("format") writes %s instead.
136
+ ```
121
137
 
122
138
  (Find it directly in the module example.py)
123
139
 
@@ -128,6 +144,49 @@ Python 3.10 and above.
128
144
  (None)
129
145
 
130
146
  ## Changelog
147
+ ### 0.3.0
148
+ Queries built with 0.2.0 produce the same SQL, except for the fixed `Update` values below.
149
+ - Conditions are methods of fields, functions on fields and strings: `eq`, `ne`, `lt`, `le`, `gt`, `ge`,
150
+ `like`, `in_`, `between`, `is_null` and `is_not_null`; `eq(None)` renders `IS NULL`. Combine them with
151
+ `&`, `|` and `~`, or with `and_()`, `or_()` and `not_()`; parentheses are added where T-SQL needs them.
152
+ Python's `and`, `or` and `not` raise `TypeError`, since `c1 and c2` would silently keep only `c2`.
153
+ - Values are escaped: text becomes `'O''Brien'`, or `N'café'` when it is not ASCII (`NStr("...")` forces
154
+ `N'...'`). Numbers, `True`/`False` (1/0), `None` (`NULL`), dates (`'20261011'`), datetimes and times
155
+ (ISO), bytes (`0x...`) and UUIDs are supported; `Raw("GETDATE()")` is written verbatim.
156
+ - `Where(condition)`, also next to raw strings, and `on=condition` on every join, for multi-column joins.
157
+ - `QueryBuilder.build_with_params()` returns the SQL with `?` placeholders and the values, for
158
+ `cursor.execute(sql, params)` with pyodbc; `build_with_params("format")` writes `%s` for pymssql.
159
+ - `Update` values: a plain string is still raw SQL (`{f: "'x'"}`), `Str("O'Brien")` is an escaped string,
160
+ and other values render as in conditions.
161
+ - New top-level names: `Condition`, `Raw`, `Str` and `NStr`.
162
+
163
+ Fixed:
164
+ - `Update` values `None`, `True`/`False`, dates and fields rendered invalid or wrong SQL: `f=None`,
165
+ `f=True`, `f=2026-10-11` (a subtraction) or the field's description. They now render `NULL`, `1`/`0`,
166
+ `'20261011'` and the field's qualified name. A `Decimal` with an exponent is written in full (`1000`, not
167
+ `1E+3`, which T-SQL reads as a float).
168
+
169
+ Breaking:
170
+ - `Update` values of an unsupported type raise `TypeError` instead of being written with `str()`.
171
+
172
+ ### 0.2.0
173
+ Queries built with 0.1.x produce the same SQL; see "Breaking" for the few behavior changes.
174
+ - `LeftJoin`, `RightJoin` and `FullJoin` (same arguments as `InnerJoin`), and `CrossJoin(table)`.
175
+ - `Select(..., distinct=True, top=10)` renders `SELECT DISTINCT TOP (10) ...`.
176
+ - `OrderBy(...).offset(20).fetch(10)` adds `OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY`. Combining paging with
177
+ TOP raises `IncompatibleClausesException`.
178
+ - Every exception derives from `SqlQueryBuilderError` and can be imported from `sql_querystring_builder`.
179
+ - `table.fields["name"]`, `"name" in table.fields`, iteration and `len()`. `table.fields["add"]` also
180
+ reaches fields whose names clash with `Fields` methods.
181
+ - Table names accept bracketed parts anywhere: `[Order]`, `dbo.[My Table]` (`]]` stands for `]`).
182
+ - The README example uses `>` instead of `gt`, which is not a T-SQL operator.
183
+
184
+ Breaking:
185
+ - `TempTable(3)` raises `TypeError` instead of a bare `Exception` (same message).
186
+ - Unknown keyword arguments to `Table`, such as `fields=[...]`, raise `TypeError` instead of being ignored.
187
+ - Malformed bracketed names such as `dbo.Order]` or `dbo.[Order` are rejected.
188
+ - An empty `table.fields` is now falsy.
189
+
131
190
  ### 0.1.1
132
191
  No change to the generated SQL.
133
192
  - The package ships a `py.typed` marker, so type checkers such as mypy now use its type hints.
@@ -0,0 +1,193 @@
1
+ # SqlQueryBuilder
2
+ Wraps string building of MS SQL queries.
3
+ It provides first level validation.
4
+
5
+ ## Current features
6
+ 1. Provides a string building facility for:
7
+ - [x] Table
8
+ - [x] Temporary Table
9
+ - [x] Field
10
+ - [x] Function on field
11
+ - [x] String
12
+ - [x] Conditions: eq, ne, lt, le, gt, ge, like, in_, between, is_null, is_not_null, combined with & | ~
13
+ - [x] Values: text, numbers, booleans, None, dates and times, bytes, UUIDs, and Raw SQL
14
+ 2. Using:
15
+ - [x] Select (with DISTINCT and TOP)
16
+ - [x] Update (with typed values)
17
+ - [x] From
18
+ - [x] InnerJoin, LeftJoin, RightJoin, FullJoin (on two fields or on any condition), CrossJoin
19
+ - [x] Where (with conditions)
20
+ - [x] OrderBy (with OFFSET/FETCH paging)
21
+ 3. Handling automatically:
22
+ - [x] Table aliases
23
+ - [x] Field aliases
24
+ - [x] Escaped values: 'O''Brien', N'café'
25
+ - [x] Parameterized queries: build_with_params() writes ? (pyodbc) or %s (pymssql) and returns the values
26
+
27
+ ## Not implemented (yet)
28
+ In planned order:
29
+ - [ ] Insert and Delete
30
+ - [ ] Functions (aggregates and others), GROUP BY, HAVING, CASE
31
+ - [ ] Compound queries (UNION, INTERSECT, EXCEPT) and sub-queries
32
+
33
+ ## What it does not do
34
+ Connect to a database server and run the queries by itself.
35
+ It gives you the query as a string, it's then up to you to pass it to a SQL engine.
36
+
37
+ ## Example
38
+ 1. Define the elements of your query:
39
+ ```
40
+ # Defines a Table for customers
41
+ customers_table = Table("Customers").add("id", "customerId", "mainPhone")
42
+ # Defines a Table for accounts
43
+ accounts_table = Table("Accounts", "acc").add("id", "customerId", "accountId")
44
+ # Selects the fields
45
+ select = Select(customers_table, accounts_table.fields.accountId)
46
+ # Sets an alias for an existing field
47
+ customers_table.fields.customerId.set_alias("cid")
48
+ # Specifies the table to take as a FROM
49
+ from_ = From(customers_table)
50
+ # Specifies a temporary table for the select
51
+ atemptable = select.to_temptable("a_temp_table")
52
+ # Defines the InnerJoin
53
+ innerjoin = InnerJoin(accounts_table, accounts_table.fields.customerId, customers_table)
54
+ # Defines an Order By a defined field
55
+ orderby = OrderBy(customers_table.fields.customerId, descending=True)
56
+ # Adds a Where filter: conditions are methods of fields, combined with & (and_), | (or_) and ~ (not_)
57
+ where = Where(accounts_table.fields.accountId.gt(20000) & customers_table.fields.customerId.like("A1C%"))
58
+ ```
59
+ 2. Inspect what those elements are made of:
60
+ ```
61
+ # See what the object Select looks like
62
+ print(select)
63
+ # > Select(fields=[Field(name='id', alias='None', table='Customers'),
64
+ # ... Field(name='customerId', alias='cid', table='Customers'),
65
+ # ... Field(name='mainPhone', alias='None', table='Customers'),
66
+ # ... Field(name='accountId', alias='None', table='Accounts')])
67
+
68
+ # Initializes the QueryBuilder
69
+ query_builder = QueryBuilder(select, where, orderby, innerjoin, from_)
70
+ ```
71
+ 3. Build your query:
72
+ ```
73
+ # Builds the query
74
+ query_str: str = query_builder.build()
75
+ print(query_str)
76
+ # > SELECT Customers.id, Customers.customerId AS cid, Customers.mainPhone, acc.accountId
77
+ # > INTO #a_temp_table
78
+ # > FROM Customers
79
+ # > INNER JOIN Accounts acc on acc.customerId = Customers.customerId
80
+ # > WHERE acc.accountId > 20000 AND Customers.customerId LIKE 'A1C%'
81
+ # > ORDER BY cid DESC
82
+ ```
83
+ 4. Manipulate temporary tables whose components are inferred:
84
+ ```
85
+ # Is able to infer fields from temporary tables and use it as with a regular table.
86
+ temp_select = Select(atemptable)
87
+ print(temp_select)
88
+ # > Select(fields=[Field(name='id', alias='None', table='#a_temp_table'),
89
+ # ... Field(name='cid', alias='None', table='#a_temp_table'),
90
+ # ... Field(name='mainPhone', alias='None', table='#a_temp_table'),
91
+ # ... Field(name='accountId', alias='None', table='#a_temp_table')])
92
+
93
+ # Builds the query from the temporary table
94
+ print(QueryBuilder(
95
+ temp_select,
96
+ From(atemptable)
97
+ ).build())
98
+ # > SELECT #a_temp_table.id, #a_temp_table.cid, #a_temp_table.mainPhone, #a_temp_table.accountId
99
+ # > FROM #a_temp_table
100
+ ```
101
+ 5. Send the values as parameters, so that they never enter the SQL text:
102
+ ```
103
+ # Builds the same query with a placeholder for each value, and returns the values in order
104
+ sql, params = query_builder.build_with_params()
105
+ print(sql)
106
+ # > SELECT Customers.id, Customers.customerId AS cid, Customers.mainPhone, acc.accountId
107
+ # > INTO #a_temp_table
108
+ # > FROM Customers
109
+ # > INNER JOIN Accounts acc on acc.customerId = Customers.customerId
110
+ # > WHERE acc.accountId > ? AND Customers.customerId LIKE ?
111
+ # > ORDER BY cid DESC
112
+ print(params)
113
+ # > (20000, 'A1C%')
114
+ # With pyodbc: cursor.execute(sql, params). For pymssql, build_with_params("format") writes %s instead.
115
+ ```
116
+
117
+ (Find it directly in the module example.py)
118
+
119
+ ## Compatible versions
120
+ Python 3.10 and above.
121
+
122
+ ## Dependencies
123
+ (None)
124
+
125
+ ## Changelog
126
+ ### 0.3.0
127
+ Queries built with 0.2.0 produce the same SQL, except for the fixed `Update` values below.
128
+ - Conditions are methods of fields, functions on fields and strings: `eq`, `ne`, `lt`, `le`, `gt`, `ge`,
129
+ `like`, `in_`, `between`, `is_null` and `is_not_null`; `eq(None)` renders `IS NULL`. Combine them with
130
+ `&`, `|` and `~`, or with `and_()`, `or_()` and `not_()`; parentheses are added where T-SQL needs them.
131
+ Python's `and`, `or` and `not` raise `TypeError`, since `c1 and c2` would silently keep only `c2`.
132
+ - Values are escaped: text becomes `'O''Brien'`, or `N'café'` when it is not ASCII (`NStr("...")` forces
133
+ `N'...'`). Numbers, `True`/`False` (1/0), `None` (`NULL`), dates (`'20261011'`), datetimes and times
134
+ (ISO), bytes (`0x...`) and UUIDs are supported; `Raw("GETDATE()")` is written verbatim.
135
+ - `Where(condition)`, also next to raw strings, and `on=condition` on every join, for multi-column joins.
136
+ - `QueryBuilder.build_with_params()` returns the SQL with `?` placeholders and the values, for
137
+ `cursor.execute(sql, params)` with pyodbc; `build_with_params("format")` writes `%s` for pymssql.
138
+ - `Update` values: a plain string is still raw SQL (`{f: "'x'"}`), `Str("O'Brien")` is an escaped string,
139
+ and other values render as in conditions.
140
+ - New top-level names: `Condition`, `Raw`, `Str` and `NStr`.
141
+
142
+ Fixed:
143
+ - `Update` values `None`, `True`/`False`, dates and fields rendered invalid or wrong SQL: `f=None`,
144
+ `f=True`, `f=2026-10-11` (a subtraction) or the field's description. They now render `NULL`, `1`/`0`,
145
+ `'20261011'` and the field's qualified name. A `Decimal` with an exponent is written in full (`1000`, not
146
+ `1E+3`, which T-SQL reads as a float).
147
+
148
+ Breaking:
149
+ - `Update` values of an unsupported type raise `TypeError` instead of being written with `str()`.
150
+
151
+ ### 0.2.0
152
+ Queries built with 0.1.x produce the same SQL; see "Breaking" for the few behavior changes.
153
+ - `LeftJoin`, `RightJoin` and `FullJoin` (same arguments as `InnerJoin`), and `CrossJoin(table)`.
154
+ - `Select(..., distinct=True, top=10)` renders `SELECT DISTINCT TOP (10) ...`.
155
+ - `OrderBy(...).offset(20).fetch(10)` adds `OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY`. Combining paging with
156
+ TOP raises `IncompatibleClausesException`.
157
+ - Every exception derives from `SqlQueryBuilderError` and can be imported from `sql_querystring_builder`.
158
+ - `table.fields["name"]`, `"name" in table.fields`, iteration and `len()`. `table.fields["add"]` also
159
+ reaches fields whose names clash with `Fields` methods.
160
+ - Table names accept bracketed parts anywhere: `[Order]`, `dbo.[My Table]` (`]]` stands for `]`).
161
+ - The README example uses `>` instead of `gt`, which is not a T-SQL operator.
162
+
163
+ Breaking:
164
+ - `TempTable(3)` raises `TypeError` instead of a bare `Exception` (same message).
165
+ - Unknown keyword arguments to `Table`, such as `fields=[...]`, raise `TypeError` instead of being ignored.
166
+ - Malformed bracketed names such as `dbo.Order]` or `dbo.[Order` are rejected.
167
+ - An empty `table.fields` is now falsy.
168
+
169
+ ### 0.1.1
170
+ No change to the generated SQL.
171
+ - The package ships a `py.typed` marker, so type checkers such as mypy now use its type hints.
172
+ - PyPI classifiers for the supported Python versions (3.10 to 3.14), typing and topic.
173
+ - The "Not implemented (yet)" list shows the planned features in order; typo fixes.
174
+
175
+ ### 0.1.0
176
+ This release changes the generated SQL: check your queries when upgrading.
177
+ - WHERE conditions and the right side of `INNER JOIN ... on` use the field's qualified name (`t1.KeyId`)
178
+ instead of its SELECT alias, which SQL Server only accepts in ORDER BY. ORDER BY still uses the alias.
179
+ - `UPDATE ... SET` no longer writes `AS alias` after aliased fields (`SET acc.a=1`).
180
+ - Temporary tables made with `Select.to_temptable()` name their columns after the field aliases, as
181
+ `SELECT ... INTO` does.
182
+ - Adding the same field name twice in one call (`Table("t").add("a", "a")`), including duplicate
183
+ temporary-table columns, raises `AlreadyExistingFieldException`.
184
+ - An `Update` mixing two tables with the same name but different aliases raises `IncorrectUpdateException`.
185
+ - `NonExistingFieldException` is now an `AttributeError`: `hasattr()` and `getattr(..., default)` work on
186
+ `table.fields`, and tables can be copied and pickled.
187
+ - Requires Python 3.10 or later (was 3.7).
188
+ - The license metadata uses the SPDX expression `GPL-3.0-only`.
189
+
190
+ ## Authors
191
+ Wael GRIBAA
192
+ Contact: g.wael@outlook.fr
193
+ 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.1.1"
7
+ version = "0.3.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,30 @@
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 .conditions import Condition
15
+ from .exceptions import SqlQueryBuilderError
16
+ from .query_builder import IncompatibleClausesException, QueryBuilder, UnavailablePlaceException
17
+ from .tables import (
18
+ AliasableString,
19
+ AliasException,
20
+ AlreadyExistingFieldException,
21
+ Field,
22
+ FunctionOnField,
23
+ InvalidNameException,
24
+ NonExistingAliasException,
25
+ NonExistingFieldException,
26
+ Table,
27
+ TableException,
28
+ TempTable,
29
+ )
30
+ from .values import NStr, Raw, Str
@@ -0,0 +1,273 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+
5
+ from .abstract_clause import Clause
6
+ from .conditions import Condition, Junction, check_operand, render_operand
7
+ from .exceptions import SqlQueryBuilderError
8
+ from .tables import Aliasable, Field, Fields, Table, TempTable
9
+
10
+
11
+ class IncorrectUpdateException(SqlQueryBuilderError):
12
+ pass
13
+
14
+
15
+ # TODO Add INSERT
16
+
17
+
18
+ def _row_count(keyword: str, value: int, minimum: int) -> int:
19
+ """Validates a row count for TOP, OFFSET or FETCH."""
20
+ if isinstance(value, bool) or not isinstance(value, int):
21
+ raise TypeError(f"{keyword} must be an int, got {type(value).__name__}.")
22
+ if value < minimum:
23
+ expected: str = "zero or more" if minimum == 0 else f"at least {minimum}"
24
+ raise ValueError(f"{keyword} must be {expected}, got {value}.")
25
+ return value
26
+
27
+
28
+ class Select(Clause):
29
+ def __init__(self, *fields: Fields | Aliasable | Table, distinct: bool = False, top: int | None = None) -> None:
30
+ if top is not None:
31
+ _row_count("TOP", top, minimum=0)
32
+ self.fields: list[Aliasable] = []
33
+ for el in fields:
34
+ if isinstance(el, Fields):
35
+ self.fields.extend(el.as_list())
36
+ elif isinstance(el, Table):
37
+ self.fields.extend(el.fields.as_list())
38
+ else:
39
+ self.fields.append(el)
40
+ self.distinct: bool = distinct
41
+ self.top: int | None = top
42
+ self.temp_table: TempTable | None = None
43
+
44
+ @property
45
+ def place(self) -> int:
46
+ return 0
47
+
48
+ def build(self) -> str:
49
+ head: str = "SELECT"
50
+ if self.distinct:
51
+ head += " DISTINCT"
52
+ if self.top is not None:
53
+ head += f" TOP ({self.top})"
54
+ ret: str = f"{head} " + (", ".join([field.build(True, True) for field in self.fields]) if self.fields else "*")
55
+ if self.temp_table is not None:
56
+ ret += "\nINTO " + self.temp_table.build()
57
+ return ret
58
+
59
+ def to_temptable(self, name: str) -> TempTable:
60
+ self.temp_table = TempTable(name, *self.fields)
61
+ return self.temp_table
62
+
63
+ def __repr__(self) -> str:
64
+ options: str = (", distinct=True" if self.distinct else "") + (
65
+ f", top={self.top}" if self.top is not None else ""
66
+ )
67
+ return f"{self.__class__.__name__}(fields={self.fields}{options})"
68
+
69
+
70
+ class From(Clause):
71
+ def __init__(self, table: Table) -> None:
72
+ self.table: Table = table
73
+
74
+ @property
75
+ def place(self) -> int:
76
+ return 2
77
+
78
+ def build(self) -> str:
79
+ return f"FROM {self.table.build(True)}"
80
+
81
+ def __repr__(self) -> str:
82
+ return f"{self.__class__.__name__}(table={self.table})"
83
+
84
+
85
+ class Where(Clause):
86
+ """`WHERE` and its parts, joined by spaces: conditions, fields, and strings written as raw SQL."""
87
+
88
+ def __init__(self, *conditions: str | Aliasable | Condition) -> None:
89
+ self.conditions: tuple[str | Aliasable | Condition, ...] = conditions
90
+
91
+ @property
92
+ def place(self) -> int:
93
+ return 4
94
+
95
+ def build(self) -> str:
96
+ ret: list[str] = ["WHERE"]
97
+ for condition in self.conditions:
98
+ if isinstance(condition, Condition):
99
+ sql: str = condition.build()
100
+ # Next to raw strings, an AND or OR condition keeps its meaning only in parentheses.
101
+ ret.append(f"({sql})" if isinstance(condition, Junction) and len(self.conditions) > 1 else sql)
102
+ else:
103
+ ret.append(condition.build() if isinstance(condition, Aliasable) else condition)
104
+ return " ".join(ret)
105
+
106
+ def __repr__(self) -> str:
107
+ return f"{self.__class__.__name__}(conditions={self.conditions})"
108
+
109
+
110
+ class Join(Clause):
111
+ """A join on `on_left = on_right`, or on any condition given as `on`; each subclass only sets its `keyword`."""
112
+
113
+ keyword: str
114
+
115
+ def __init__(
116
+ self,
117
+ table: Table,
118
+ on_left: Field | None = None,
119
+ on_right: Field | Table | None = None,
120
+ *,
121
+ on: Condition | None = None,
122
+ ) -> None:
123
+ if on is None:
124
+ if on_left is None or on_right is None:
125
+ raise TypeError(f"{self.__class__.__name__} needs on_left and on_right, or on.")
126
+ on_right = on_right if isinstance(on_right, Aliasable) else on_right.fields[on_left.name]
127
+ on = on_left.eq(on_right)
128
+ elif on_left is not None or on_right is not None:
129
+ raise TypeError(f"{self.__class__.__name__} takes on_left and on_right, or on, not both.")
130
+ elif not isinstance(on, Condition):
131
+ raise TypeError(f"on must be a Condition, got {type(on).__name__}.")
132
+ self.table: Table = table
133
+ self.on_left: Field | None = on_left
134
+ self.on_right: Field | None = on_right
135
+ self.on: Condition = on
136
+
137
+ @property
138
+ def place(self) -> int:
139
+ return 3
140
+
141
+ @property
142
+ def is_exclusive(self) -> bool:
143
+ return False
144
+
145
+ def build(self) -> str:
146
+ return f"{self.keyword} {self.table.build(use_name=True)} on {self.on.build()}"
147
+
148
+ def __repr__(self) -> str:
149
+ if self.on_left is None:
150
+ return f"{self.__class__.__name__}(table={self.table}, on={self.on})"
151
+ return f"{self.__class__.__name__}(table={self.table}, on_left={self.on_left}, on_right={self.on_right})"
152
+
153
+
154
+ class InnerJoin(Join):
155
+ keyword = "INNER JOIN"
156
+
157
+
158
+ class LeftJoin(Join):
159
+ keyword = "LEFT JOIN"
160
+
161
+
162
+ class RightJoin(Join):
163
+ keyword = "RIGHT JOIN"
164
+
165
+
166
+ class FullJoin(Join):
167
+ keyword = "FULL JOIN"
168
+
169
+
170
+ class CrossJoin(Clause):
171
+ def __init__(self, table: Table) -> None:
172
+ self.table: Table = table
173
+
174
+ @property
175
+ def place(self) -> int:
176
+ return 3
177
+
178
+ @property
179
+ def is_exclusive(self) -> bool:
180
+ return False
181
+
182
+ def build(self) -> str:
183
+ return f"CROSS JOIN {self.table.build(use_name=True)}"
184
+
185
+ def __repr__(self) -> str:
186
+ return f"{self.__class__.__name__}(table={self.table})"
187
+
188
+
189
+ class Update(Clause):
190
+ """`UPDATE … SET field=value, …`: a plain str value is raw SQL (`"'x'"`), `Str("x")` an escaped string.
191
+
192
+ Any other value goes through `values.py`, like a value in a condition: `None` is `NULL`, a field is its
193
+ qualified name.
194
+ """
195
+
196
+ def __init__(self, updates: Mapping[Field, object]) -> None:
197
+ if not updates:
198
+ raise IncorrectUpdateException("The Update is empty.")
199
+ self.updates: dict[Field, object] = {}
200
+ first_table: Table | None = None
201
+ for field, value in updates.items():
202
+ if first_table is None:
203
+ first_table = field.table
204
+ elif (first_table.name, first_table.alias) != (field.table.name, field.table.alias):
205
+ # The same name under another alias is still another table in the query: show the aliases.
206
+ same_name: bool = first_table.name == field.table.name
207
+ first: str = first_table.build(use_name=True) if same_name else first_table.name
208
+ other: str = field.table.build(use_name=True) if same_name else field.table.name
209
+ raise IncorrectUpdateException(
210
+ f"Two different tables cannot be updated in the same Update: '{first}' and '{other}'."
211
+ )
212
+ self.updates[field] = value if isinstance(value, str) else check_operand(value)
213
+ self.table: Table = field.table
214
+
215
+ @property
216
+ def place(self) -> int:
217
+ return 0
218
+
219
+ def build(self) -> str:
220
+ # A plain str stays raw SQL until 1.0 (D4 in ROADMAP.md): turning it into a value would quote it.
221
+ field_strings: list[str] = [
222
+ f"{field.build()}={value if isinstance(value, str) else render_operand(value)}"
223
+ for field, value in self.updates.items()
224
+ ]
225
+ return f"UPDATE {self.table.build(use_name=False, use_alias=True)}\nSET " + ", ".join(field_strings)
226
+
227
+ def __repr__(self) -> str:
228
+ return f"{self.__class__.__name__}(updates={self.updates})"
229
+
230
+
231
+ class OrderBy(Clause):
232
+ def __init__(self, field: Aliasable, descending: bool = False) -> None:
233
+ self.ordering: list[tuple[Aliasable, bool]] = [(field, descending)]
234
+ self.offset_rows: int | None = None
235
+ self.fetch_rows: int | None = None
236
+
237
+ @property
238
+ def place(self) -> int:
239
+ return 5
240
+
241
+ @property
242
+ def has_paging(self) -> bool:
243
+ return self.offset_rows is not None or self.fetch_rows is not None
244
+
245
+ def add(self, field: Aliasable, descending: bool = False) -> OrderBy:
246
+ self.ordering.append((field, descending))
247
+ return self
248
+
249
+ def offset(self, rows: int) -> OrderBy:
250
+ self.offset_rows = _row_count("OFFSET", rows, minimum=0)
251
+ return self
252
+
253
+ def fetch(self, rows: int) -> OrderBy:
254
+ self.fetch_rows = _row_count("FETCH", rows, minimum=1)
255
+ return self
256
+
257
+ def build(self) -> str:
258
+ ret_list: list[str] = []
259
+ for field, desc in self.ordering:
260
+ ret_list.append(f"{field.build(use_name=False, use_alias=True)}{' DESC' if desc else ''}")
261
+ ret: str = "ORDER BY " + ", ".join(ret_list)
262
+ if self.has_paging:
263
+ # T-SQL requires OFFSET before FETCH: fetch() alone starts at the first row.
264
+ ret += f"\nOFFSET {self.offset_rows or 0} ROWS"
265
+ if self.fetch_rows is not None:
266
+ ret += f" FETCH NEXT {self.fetch_rows} ROWS ONLY"
267
+ return ret
268
+
269
+ def __repr__(self) -> str:
270
+ paging: str = (f", offset={self.offset_rows}" if self.offset_rows is not None else "") + (
271
+ f", fetch={self.fetch_rows}" if self.fetch_rows is not None else ""
272
+ )
273
+ return f"{self.__class__.__name__}(ordering={self.ordering}{paging})"