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.
Files changed (24) hide show
  1. {sql_querystring_builder-0.1.0 → sql_querystring_builder-0.2.0}/PKG-INFO +46 -13
  2. sql_querystring_builder-0.1.0/src/sql_querystring_builder.egg-info/PKG-INFO → sql_querystring_builder-0.2.0/README.md +39 -27
  3. {sql_querystring_builder-0.1.0 → sql_querystring_builder-0.2.0}/pyproject.toml +10 -2
  4. sql_querystring_builder-0.2.0/src/sql_querystring_builder/__init__.py +28 -0
  5. {sql_querystring_builder-0.1.0 → sql_querystring_builder-0.2.0}/src/sql_querystring_builder/clauses.py +95 -13
  6. sql_querystring_builder-0.2.0/src/sql_querystring_builder/exceptions.py +2 -0
  7. sql_querystring_builder-0.2.0/src/sql_querystring_builder/py.typed +0 -0
  8. {sql_querystring_builder-0.1.0 → sql_querystring_builder-0.2.0}/src/sql_querystring_builder/query_builder.py +15 -3
  9. {sql_querystring_builder-0.1.0 → sql_querystring_builder-0.2.0}/src/sql_querystring_builder/tables.py +86 -27
  10. sql_querystring_builder-0.1.0/README.md → sql_querystring_builder-0.2.0/src/sql_querystring_builder.egg-info/PKG-INFO +60 -13
  11. {sql_querystring_builder-0.1.0 → sql_querystring_builder-0.2.0}/src/sql_querystring_builder.egg-info/SOURCES.txt +3 -0
  12. {sql_querystring_builder-0.1.0 → sql_querystring_builder-0.2.0}/tests/test_characterization.py +56 -11
  13. sql_querystring_builder-0.2.0/tests/test_clauses.py +233 -0
  14. sql_querystring_builder-0.2.0/tests/test_sql_syntax.py +93 -0
  15. {sql_querystring_builder-0.1.0 → sql_querystring_builder-0.2.0}/tests/test_tables.py +34 -0
  16. sql_querystring_builder-0.1.0/src/sql_querystring_builder/__init__.py +0 -3
  17. sql_querystring_builder-0.1.0/tests/test_clauses.py +0 -74
  18. {sql_querystring_builder-0.1.0 → sql_querystring_builder-0.2.0}/LICENSE +0 -0
  19. {sql_querystring_builder-0.1.0 → sql_querystring_builder-0.2.0}/setup.cfg +0 -0
  20. {sql_querystring_builder-0.1.0 → sql_querystring_builder-0.2.0}/src/sql_querystring_builder/abstract_clause.py +0 -0
  21. {sql_querystring_builder-0.1.0 → sql_querystring_builder-0.2.0}/src/sql_querystring_builder.egg-info/dependency_links.txt +0 -0
  22. {sql_querystring_builder-0.1.0 → sql_querystring_builder-0.2.0}/src/sql_querystring_builder.egg-info/top_level.txt +0 -0
  23. {sql_querystring_builder-0.1.0 → sql_querystring_builder-0.2.0}/tests/test_queries.py +0 -0
  24. {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.1.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
- - [ ] Insert
39
- - [ ] Compound (sub-)queries
40
- - [ ] Functions
41
- - [ ] Proper Conditions
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 yopu to pass it to a SQL engine.
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 takes as a FROM
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, "gt 20000 AND", customers_table.fields.customerId, "LIKE 'A1C%'")
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 gt 20000 AND Customers.customerId LIKE 'A1C%'
99
+ # > WHERE acc.accountId > 20000 AND Customers.customerId LIKE 'A1C%'
91
100
  # > ORDER BY cid DESC
92
101
  ```
93
- 4. Manipulate temporary tables which components are inferred:
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
- - [ ] Insert
39
- - [ ] Compound (sub-)queries
40
- - [ ] Functions
41
- - [ ] Proper Conditions
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 yopu to pass it to a SQL engine.
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 takes as a FROM
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, "gt 20000 AND", customers_table.fields.customerId, "LIKE 'A1C%'")
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 gt 20000 AND Customers.customerId LIKE 'A1C%'
78
+ # > WHERE acc.accountId > 20000 AND Customers.customerId LIKE 'A1C%'
91
79
  # > ORDER BY cid DESC
92
80
  ```
93
- 4. Manipulate temporary tables which components are inferred:
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.1.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
- "Operating System :: OS Independent"
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(Exception):
8
+ class IncorrectUpdateException(SqlQueryBuilderError):
8
9
  pass
9
10
 
10
11
 
@@ -12,8 +13,20 @@ class IncorrectUpdateException(Exception):
12
13
  # TODO Add concept of condition
13
14
 
14
15
 
16
+ def _row_count(keyword: str, value: int, minimum: int) -> int:
17
+ """Validates a row count for TOP, OFFSET or FETCH."""
18
+ if isinstance(value, bool) or not isinstance(value, int):
19
+ raise TypeError(f"{keyword} must be an int, got {type(value).__name__}.")
20
+ if value < minimum:
21
+ expected: str = "zero or more" if minimum == 0 else f"at least {minimum}"
22
+ raise ValueError(f"{keyword} must be {expected}, got {value}.")
23
+ return value
24
+
25
+
15
26
  class Select(Clause):
16
- def __init__(self, *fields: Fields | Aliasable | Table) -> None:
27
+ def __init__(self, *fields: Fields | Aliasable | Table, distinct: bool = False, top: int | None = None) -> None:
28
+ if top is not None:
29
+ _row_count("TOP", top, minimum=0)
17
30
  self.fields: list[Aliasable] = []
18
31
  for el in fields:
19
32
  if isinstance(el, Fields):
@@ -22,6 +35,8 @@ class Select(Clause):
22
35
  self.fields.extend(el.fields.as_list())
23
36
  else:
24
37
  self.fields.append(el)
38
+ self.distinct: bool = distinct
39
+ self.top: int | None = top
25
40
  self.temp_table: TempTable | None = None
26
41
 
27
42
  @property
@@ -29,9 +44,13 @@ class Select(Clause):
29
44
  return 0
30
45
 
31
46
  def build(self) -> str:
32
- ret: str = "SELECT " if self.fields else "SELECT *"
33
- ret += ", ".join([field.build(True, True) for field in self.fields])
34
- if self.temp_table:
47
+ head: str = "SELECT"
48
+ if self.distinct:
49
+ head += " DISTINCT"
50
+ if self.top is not None:
51
+ head += f" TOP ({self.top})"
52
+ ret: str = f"{head} " + (", ".join([field.build(True, True) for field in self.fields]) if self.fields else "*")
53
+ if self.temp_table is not None:
35
54
  ret += "\nINTO " + self.temp_table.build()
36
55
  return ret
37
56
 
@@ -40,7 +59,10 @@ class Select(Clause):
40
59
  return self.temp_table
41
60
 
42
61
  def __repr__(self) -> str:
43
- return f"{self.__class__.__name__}(fields={self.fields})"
62
+ options: str = (", distinct=True" if self.distinct else "") + (
63
+ f", top={self.top}" if self.top is not None else ""
64
+ )
65
+ return f"{self.__class__.__name__}(fields={self.fields}{options})"
44
66
 
45
67
 
46
68
  class From(Clause):
@@ -76,13 +98,15 @@ class Where(Clause):
76
98
  return f"{self.__class__.__name__}(conditions={self.conditions})"
77
99
 
78
100
 
79
- class InnerJoin(Clause):
101
+ class Join(Clause):
102
+ """A join on `on_left = on_right`; each subclass only sets its `keyword`."""
103
+
104
+ keyword: str
105
+
80
106
  def __init__(self, table: Table, on_left: Field, on_right: Field | Table) -> None:
81
107
  self.table: Table = table
82
108
  self.on_left: Field = on_left
83
- self.on_right: Field = (
84
- on_right if isinstance(on_right, Aliasable) else on_right.fields.__getattr__(on_left.name)
85
- )
109
+ self.on_right: Field = on_right if isinstance(on_right, Aliasable) else on_right.fields[on_left.name]
86
110
 
87
111
  @property
88
112
  def place(self) -> int:
@@ -93,12 +117,47 @@ class InnerJoin(Clause):
93
117
  return False
94
118
 
95
119
  def build(self) -> str:
96
- return f"INNER JOIN {self.table.build(use_name=True)} on {self.on_left.build()} = {self.on_right.build()}"
120
+ return f"{self.keyword} {self.table.build(use_name=True)} on {self.on_left.build()} = {self.on_right.build()}"
97
121
 
98
122
  def __repr__(self) -> str:
99
123
  return f"{self.__class__.__name__}(table={self.table}, on_left={self.on_left}, on_right={self.on_right})"
100
124
 
101
125
 
126
+ class InnerJoin(Join):
127
+ keyword = "INNER JOIN"
128
+
129
+
130
+ class LeftJoin(Join):
131
+ keyword = "LEFT JOIN"
132
+
133
+
134
+ class RightJoin(Join):
135
+ keyword = "RIGHT JOIN"
136
+
137
+
138
+ class FullJoin(Join):
139
+ keyword = "FULL JOIN"
140
+
141
+
142
+ class CrossJoin(Clause):
143
+ def __init__(self, table: Table) -> None:
144
+ self.table: Table = table
145
+
146
+ @property
147
+ def place(self) -> int:
148
+ return 3
149
+
150
+ @property
151
+ def is_exclusive(self) -> bool:
152
+ return False
153
+
154
+ def build(self) -> str:
155
+ return f"CROSS JOIN {self.table.build(use_name=True)}"
156
+
157
+ def __repr__(self) -> str:
158
+ return f"{self.__class__.__name__}(table={self.table})"
159
+
160
+
102
161
  class Update(Clause):
103
162
  def __init__(self, updates: dict[Field, str]) -> None:
104
163
  if not updates:
@@ -134,20 +193,43 @@ class Update(Clause):
134
193
  class OrderBy(Clause):
135
194
  def __init__(self, field: Aliasable, descending: bool = False) -> None:
136
195
  self.ordering: list[tuple[Aliasable, bool]] = [(field, descending)]
196
+ self.offset_rows: int | None = None
197
+ self.fetch_rows: int | None = None
137
198
 
138
199
  @property
139
200
  def place(self) -> int:
140
201
  return 5
141
202
 
203
+ @property
204
+ def has_paging(self) -> bool:
205
+ return self.offset_rows is not None or self.fetch_rows is not None
206
+
142
207
  def add(self, field: Aliasable, descending: bool = False) -> OrderBy:
143
208
  self.ordering.append((field, descending))
144
209
  return self
145
210
 
211
+ def offset(self, rows: int) -> OrderBy:
212
+ self.offset_rows = _row_count("OFFSET", rows, minimum=0)
213
+ return self
214
+
215
+ def fetch(self, rows: int) -> OrderBy:
216
+ self.fetch_rows = _row_count("FETCH", rows, minimum=1)
217
+ return self
218
+
146
219
  def build(self) -> str:
147
220
  ret_list: list[str] = []
148
221
  for field, desc in self.ordering:
149
222
  ret_list.append(f"{field.build(use_name=False, use_alias=True)}{' DESC' if desc else ''}")
150
- return "ORDER BY " + ", ".join(ret_list)
223
+ ret: str = "ORDER BY " + ", ".join(ret_list)
224
+ if self.has_paging:
225
+ # T-SQL requires OFFSET before FETCH: fetch() alone starts at the first row.
226
+ ret += f"\nOFFSET {self.offset_rows or 0} ROWS"
227
+ if self.fetch_rows is not None:
228
+ ret += f" FETCH NEXT {self.fetch_rows} ROWS ONLY"
229
+ return ret
151
230
 
152
231
  def __repr__(self) -> str:
153
- return f"{self.__class__.__name__}(ordering={self.ordering})"
232
+ paging: str = (f", offset={self.offset_rows}" if self.offset_rows is not None else "") + (
233
+ f", fetch={self.fetch_rows}" if self.fetch_rows is not None else ""
234
+ )
235
+ return f"{self.__class__.__name__}(ordering={self.ordering}{paging})"
@@ -0,0 +1,2 @@
1
+ class SqlQueryBuilderError(Exception):
2
+ """Base class of every exception the library raises, so callers can catch them all at once."""
@@ -3,9 +3,15 @@ from __future__ import annotations
3
3
  from collections.abc import Iterable
4
4
 
5
5
  from .abstract_clause import Clause
6
+ from .clauses import OrderBy, Select
7
+ from .exceptions import SqlQueryBuilderError
6
8
 
7
9
 
8
- class UnavailablePlaceException(Exception):
10
+ class UnavailablePlaceException(SqlQueryBuilderError):
11
+ pass
12
+
13
+
14
+ class IncompatibleClausesException(SqlQueryBuilderError):
9
15
  pass
10
16
 
11
17
 
@@ -17,7 +23,7 @@ class QueryBuilder:
17
23
 
18
24
  def add(self, clause: Clause) -> QueryBuilder:
19
25
  old_clause: Clause | None = self._place_is_unavailable(clause)
20
- if old_clause:
26
+ if old_clause is not None:
21
27
  raise UnavailablePlaceException(
22
28
  f"Cannot add a new clause with the same exclusive place in the query. New clause='{clause.build()}'. Old clause='{old_clause.build()}'"
23
29
  )
@@ -30,7 +36,13 @@ class QueryBuilder:
30
36
  return self
31
37
 
32
38
  def build(self) -> str:
33
- return "\n".join(clause.build() for clause in self._sorted_clauses())
39
+ clauses: list[Clause] = self._sorted_clauses()
40
+ # Checked at build time: TOP and paging can both be set after the clauses were added.
41
+ has_top: bool = any(isinstance(clause, Select) and clause.top is not None for clause in clauses)
42
+ has_paging: bool = any(isinstance(clause, OrderBy) and clause.has_paging for clause in clauses)
43
+ if has_top and has_paging:
44
+ raise IncompatibleClausesException("TOP cannot be combined with OFFSET/FETCH in the same query.")
45
+ return "\n".join(clause.build() for clause in clauses)
34
46
 
35
47
  def _place_is_unavailable(self, clause: Clause) -> Clause | None:
36
48
  if clause.place in self.places and clause.is_exclusive: