t-sql 4.11.0__tar.gz → 4.12.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.
- {t_sql-4.11.0 → t_sql-4.12.0}/PKG-INFO +1 -1
- {t_sql-4.11.0 → t_sql-4.12.0}/pyproject.toml +1 -1
- {t_sql-4.11.0 → t_sql-4.12.0}/tests/test_query_builder.py +256 -1
- {t_sql-4.11.0 → t_sql-4.12.0}/tsql/query_builder.py +103 -32
- {t_sql-4.11.0 → t_sql-4.12.0}/.dockerignore +0 -0
- {t_sql-4.11.0 → t_sql-4.12.0}/.github/workflows/publish.yml +0 -0
- {t_sql-4.11.0 → t_sql-4.12.0}/.github/workflows/test.yml +0 -0
- {t_sql-4.11.0 → t_sql-4.12.0}/.gitignore +0 -0
- {t_sql-4.11.0 → t_sql-4.12.0}/Dockerfile +0 -0
- {t_sql-4.11.0 → t_sql-4.12.0}/LICENSE +0 -0
- {t_sql-4.11.0 → t_sql-4.12.0}/README.md +0 -0
- {t_sql-4.11.0 → t_sql-4.12.0}/compose.yaml +0 -0
- {t_sql-4.11.0 → t_sql-4.12.0}/context7.json +0 -0
- {t_sql-4.11.0 → t_sql-4.12.0}/pytest.ini +0 -0
- {t_sql-4.11.0 → t_sql-4.12.0}/tests/test_alembic_integration.py +0 -0
- {t_sql-4.11.0 → t_sql-4.12.0}/tests/test_asyncpg_integration.py +0 -0
- {t_sql-4.11.0 → t_sql-4.12.0}/tests/test_deep_nesting.py +0 -0
- {t_sql-4.11.0 → t_sql-4.12.0}/tests/test_different_object_types.py +0 -0
- {t_sql-4.11.0 → t_sql-4.12.0}/tests/test_error_messages.py +0 -0
- {t_sql-4.11.0 → t_sql-4.12.0}/tests/test_escaped.py +0 -0
- {t_sql-4.11.0 → t_sql-4.12.0}/tests/test_escaped_binary_hex.py +0 -0
- {t_sql-4.11.0 → t_sql-4.12.0}/tests/test_helper_functions.py +0 -0
- {t_sql-4.11.0 → t_sql-4.12.0}/tests/test_injection_edge_cases.py +0 -0
- {t_sql-4.11.0 → t_sql-4.12.0}/tests/test_injection_protection_validation.py +0 -0
- {t_sql-4.11.0 → t_sql-4.12.0}/tests/test_injections_for_escaped.py +0 -0
- {t_sql-4.11.0 → t_sql-4.12.0}/tests/test_like_patterns.py +0 -0
- {t_sql-4.11.0 → t_sql-4.12.0}/tests/test_mysql_integration.py +0 -0
- {t_sql-4.11.0 → t_sql-4.12.0}/tests/test_parameter_names.py +0 -0
- {t_sql-4.11.0 → t_sql-4.12.0}/tests/test_sqlalchemy_integration.py +0 -0
- {t_sql-4.11.0 → t_sql-4.12.0}/tests/test_sqlite_integration.py +0 -0
- {t_sql-4.11.0 → t_sql-4.12.0}/tests/test_string_based_builders.py +0 -0
- {t_sql-4.11.0 → t_sql-4.12.0}/tests/test_styles.py +0 -0
- {t_sql-4.11.0 → t_sql-4.12.0}/tests/test_template_in_builders.py +0 -0
- {t_sql-4.11.0 → t_sql-4.12.0}/tests/test_tsql.py +0 -0
- {t_sql-4.11.0 → t_sql-4.12.0}/tests/test_type_processor.py +0 -0
- {t_sql-4.11.0 → t_sql-4.12.0}/tsql/__init__.py +0 -0
- {t_sql-4.11.0 → t_sql-4.12.0}/tsql/row.py +0 -0
- {t_sql-4.11.0 → t_sql-4.12.0}/tsql/styles.py +0 -0
- {t_sql-4.11.0 → t_sql-4.12.0}/tsql/type_processor.py +0 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import tsql
|
|
2
|
-
from tsql.query_builder import Table, Column, Condition, InsertBuilder, UpdateBuilder, DeleteBuilder
|
|
2
|
+
from tsql.query_builder import Table, Column, Condition, InsertBuilder, UpdateBuilder, DeleteBuilder, SelectQueryBuilder
|
|
3
3
|
|
|
4
4
|
|
|
5
5
|
class Users(Table):
|
|
@@ -216,6 +216,57 @@ def test_left_join():
|
|
|
216
216
|
assert 'LEFT JOIN posts ON users.id = posts.user_id' in sql
|
|
217
217
|
|
|
218
218
|
|
|
219
|
+
def test_join_raw_template():
|
|
220
|
+
"""join_raw() splices a raw t-string Template verbatim as a whole JOIN clause."""
|
|
221
|
+
query = (SelectQueryBuilder.from_table('records', schema='dataset', alias='t')
|
|
222
|
+
.select(t't.*')
|
|
223
|
+
.join_raw(t'LEFT JOIN other o ON o.id = t.ref'))
|
|
224
|
+
sql, params = query.render()
|
|
225
|
+
|
|
226
|
+
assert sql == 'SELECT t.* FROM dataset.records AS t LEFT JOIN other o ON o.id = t.ref'
|
|
227
|
+
assert params == []
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def test_join_raw_lateral_no_on():
|
|
231
|
+
"""join_raw() handles join shapes the typed path can't: LATERAL, no ON."""
|
|
232
|
+
query = (SelectQueryBuilder.from_table('records', schema='dataset', alias='t')
|
|
233
|
+
.select(t't.*')
|
|
234
|
+
.join_raw(t'CROSS JOIN LATERAL unnest(t.tags) AS tag'))
|
|
235
|
+
sql, params = query.render()
|
|
236
|
+
|
|
237
|
+
assert sql == 'SELECT t.* FROM dataset.records AS t CROSS JOIN LATERAL unnest(t.tags) AS tag'
|
|
238
|
+
assert params == []
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def test_join_raw_template_carries_params():
|
|
242
|
+
"""A parameterized raw join Template renumbers correctly in context."""
|
|
243
|
+
query = (SelectQueryBuilder.from_table('records', schema='dataset', alias='t')
|
|
244
|
+
.select(t't.*')
|
|
245
|
+
.join_raw(t'LEFT JOIN other o ON o.id = t.ref AND o.kind = {"x"}')
|
|
246
|
+
.where(t't.active = {True}'))
|
|
247
|
+
sql, params = query.render(style=tsql.styles.NUMERIC_DOLLAR)
|
|
248
|
+
|
|
249
|
+
assert sql == (
|
|
250
|
+
'SELECT t.* FROM dataset.records AS t '
|
|
251
|
+
'LEFT JOIN other o ON o.id = t.ref AND o.kind = $1 '
|
|
252
|
+
'WHERE (t.active = $2)'
|
|
253
|
+
)
|
|
254
|
+
assert params == ['x', True]
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def test_join_raw_and_typed_mixed():
|
|
258
|
+
"""Raw and typed joins compose in insertion order."""
|
|
259
|
+
query = (Posts.select(Posts.ALL)
|
|
260
|
+
.join(Users, Posts.user_id == Users.id)
|
|
261
|
+
.join_raw(t'LEFT JOIN tags tg ON tg.post_id = posts.id'))
|
|
262
|
+
sql, params = query.render()
|
|
263
|
+
|
|
264
|
+
assert 'INNER JOIN users ON posts.user_id = users.id' in sql
|
|
265
|
+
assert 'LEFT JOIN tags tg ON tg.post_id = posts.id' in sql
|
|
266
|
+
# typed join comes first (added first), raw second
|
|
267
|
+
assert sql.index('INNER JOIN users') < sql.index('LEFT JOIN tags')
|
|
268
|
+
|
|
269
|
+
|
|
219
270
|
def test_order_by():
|
|
220
271
|
"""Test ORDER BY clause"""
|
|
221
272
|
query = Users.select().order_by(Users.username)
|
|
@@ -1624,3 +1675,207 @@ def test_delete_where_with_bare_boolean_column():
|
|
|
1624
1675
|
assert 'DELETE FROM contacts' in sql
|
|
1625
1676
|
assert 'WHERE contacts.is_primary' in sql
|
|
1626
1677
|
assert params == []
|
|
1678
|
+
|
|
1679
|
+
|
|
1680
|
+
# --------------------------------------------------------------------------- #
|
|
1681
|
+
# DISTINCT ON
|
|
1682
|
+
# --------------------------------------------------------------------------- #
|
|
1683
|
+
|
|
1684
|
+
|
|
1685
|
+
def test_distinct_on_string_column():
|
|
1686
|
+
"""distinct_on() with a string column emits SELECT DISTINCT ON (col)"""
|
|
1687
|
+
query = Users.select(Users.id, Users.username).distinct_on('username')
|
|
1688
|
+
sql, params = query.render()
|
|
1689
|
+
|
|
1690
|
+
assert sql == 'SELECT DISTINCT ON (username) users.id, users.username FROM users'
|
|
1691
|
+
assert params == []
|
|
1692
|
+
|
|
1693
|
+
|
|
1694
|
+
def test_distinct_on_multiple_columns():
|
|
1695
|
+
"""distinct_on() accepts multiple columns, comma-joined inside the parens"""
|
|
1696
|
+
query = Users.select(Users.id).distinct_on('username', 'email')
|
|
1697
|
+
sql, params = query.render()
|
|
1698
|
+
|
|
1699
|
+
assert sql == 'SELECT DISTINCT ON (username, email) users.id FROM users'
|
|
1700
|
+
assert params == []
|
|
1701
|
+
|
|
1702
|
+
|
|
1703
|
+
def test_distinct_on_column_object():
|
|
1704
|
+
"""distinct_on() accepts Column objects, coerced like select()"""
|
|
1705
|
+
query = Users.select(Users.id).distinct_on(Users.username)
|
|
1706
|
+
sql, params = query.render()
|
|
1707
|
+
|
|
1708
|
+
assert sql == 'SELECT DISTINCT ON (users.username) users.id FROM users'
|
|
1709
|
+
assert params == []
|
|
1710
|
+
|
|
1711
|
+
|
|
1712
|
+
def test_distinct_on_template_fragment():
|
|
1713
|
+
"""distinct_on() accepts raw t-string fragments (the field-type seam)"""
|
|
1714
|
+
query = Users.select(Users.id).distinct_on(t'users.username')
|
|
1715
|
+
sql, params = query.render()
|
|
1716
|
+
|
|
1717
|
+
assert sql == 'SELECT DISTINCT ON (users.username) users.id FROM users'
|
|
1718
|
+
assert params == []
|
|
1719
|
+
|
|
1720
|
+
|
|
1721
|
+
def test_distinct_on_select_star():
|
|
1722
|
+
"""distinct_on() works with SELECT * (no explicit columns)"""
|
|
1723
|
+
query = Users.select().distinct_on('username')
|
|
1724
|
+
sql, params = query.render()
|
|
1725
|
+
|
|
1726
|
+
assert sql == 'SELECT DISTINCT ON (username) * FROM users'
|
|
1727
|
+
assert params == []
|
|
1728
|
+
|
|
1729
|
+
|
|
1730
|
+
def test_no_distinct_on_unaffected():
|
|
1731
|
+
"""A query without distinct_on() is unchanged"""
|
|
1732
|
+
query = Users.select(Users.id)
|
|
1733
|
+
sql, params = query.render()
|
|
1734
|
+
|
|
1735
|
+
assert sql == 'SELECT users.id FROM users'
|
|
1736
|
+
assert params == []
|
|
1737
|
+
|
|
1738
|
+
|
|
1739
|
+
# --------------------------------------------------------------------------- #
|
|
1740
|
+
# FROM ONLY (Postgres table inheritance)
|
|
1741
|
+
# --------------------------------------------------------------------------- #
|
|
1742
|
+
|
|
1743
|
+
|
|
1744
|
+
def test_from_only_flag():
|
|
1745
|
+
"""from_table(only=True) makes the builder emit FROM ONLY {table}"""
|
|
1746
|
+
query = SelectQueryBuilder.from_table('users', only=True).select('id')
|
|
1747
|
+
sql, params = query.render()
|
|
1748
|
+
|
|
1749
|
+
assert sql == 'SELECT id FROM ONLY users'
|
|
1750
|
+
assert params == []
|
|
1751
|
+
|
|
1752
|
+
|
|
1753
|
+
def test_from_only_with_schema():
|
|
1754
|
+
"""FROM ONLY respects the schema-qualified table name"""
|
|
1755
|
+
query = SelectQueryBuilder.from_table('records', schema='dataset', only=True)
|
|
1756
|
+
sql, params = query.render()
|
|
1757
|
+
|
|
1758
|
+
assert sql == 'SELECT * FROM ONLY dataset.records'
|
|
1759
|
+
assert params == []
|
|
1760
|
+
|
|
1761
|
+
|
|
1762
|
+
def test_from_only_default_off():
|
|
1763
|
+
"""Without only=True, FROM is unchanged (no ONLY keyword)"""
|
|
1764
|
+
query = SelectQueryBuilder.from_table('users').select('id')
|
|
1765
|
+
sql, params = query.render()
|
|
1766
|
+
|
|
1767
|
+
assert 'FROM ONLY' not in sql
|
|
1768
|
+
assert 'FROM users' in sql
|
|
1769
|
+
|
|
1770
|
+
|
|
1771
|
+
def test_distinct_on_and_from_only_together():
|
|
1772
|
+
"""DISTINCT ON and FROM ONLY compose"""
|
|
1773
|
+
query = (SelectQueryBuilder.from_table('records', schema='dataset', only=True)
|
|
1774
|
+
.select(t't.*')
|
|
1775
|
+
.distinct_on('t.id'))
|
|
1776
|
+
sql, params = query.render()
|
|
1777
|
+
|
|
1778
|
+
assert sql == 'SELECT DISTINCT ON (t.id) t.* FROM ONLY dataset.records'
|
|
1779
|
+
assert params == []
|
|
1780
|
+
|
|
1781
|
+
|
|
1782
|
+
# --------------------------------------------------------------------------- #
|
|
1783
|
+
# FROM table alias
|
|
1784
|
+
# --------------------------------------------------------------------------- #
|
|
1785
|
+
|
|
1786
|
+
|
|
1787
|
+
def test_from_table_alias():
|
|
1788
|
+
"""from_table(alias=...) emits FROM {table} AS {alias}"""
|
|
1789
|
+
query = SelectQueryBuilder.from_table('records', schema='dataset', alias='t').select(t't.*')
|
|
1790
|
+
sql, params = query.render()
|
|
1791
|
+
|
|
1792
|
+
assert sql == 'SELECT t.* FROM dataset.records AS t'
|
|
1793
|
+
assert params == []
|
|
1794
|
+
|
|
1795
|
+
|
|
1796
|
+
def test_from_table_alias_and_only():
|
|
1797
|
+
"""alias and only compose: FROM ONLY {table} AS {alias}"""
|
|
1798
|
+
query = SelectQueryBuilder.from_table('records', schema='dataset', alias='t', only=True).select(
|
|
1799
|
+
t't.*'
|
|
1800
|
+
)
|
|
1801
|
+
sql, params = query.render()
|
|
1802
|
+
|
|
1803
|
+
assert sql == 'SELECT t.* FROM ONLY dataset.records AS t'
|
|
1804
|
+
assert params == []
|
|
1805
|
+
|
|
1806
|
+
|
|
1807
|
+
def test_from_table_alias_default_none():
|
|
1808
|
+
"""Without alias, no AS clause is emitted"""
|
|
1809
|
+
query = SelectQueryBuilder.from_table('users')
|
|
1810
|
+
sql, params = query.render()
|
|
1811
|
+
|
|
1812
|
+
assert ' AS ' not in sql
|
|
1813
|
+
assert sql == 'SELECT * FROM users'
|
|
1814
|
+
|
|
1815
|
+
|
|
1816
|
+
def test_from_table_alias_rejects_injection():
|
|
1817
|
+
"""alias goes through :literal and rejects a non-identifier"""
|
|
1818
|
+
import pytest
|
|
1819
|
+
|
|
1820
|
+
query = SelectQueryBuilder.from_table('users', alias='t; DROP TABLE x')
|
|
1821
|
+
with pytest.raises(ValueError):
|
|
1822
|
+
query.render()
|
|
1823
|
+
|
|
1824
|
+
|
|
1825
|
+
# --------------------------------------------------------------------------- #
|
|
1826
|
+
# Template-aware ORDER BY / GROUP BY (parity with where/having/select)
|
|
1827
|
+
# --------------------------------------------------------------------------- #
|
|
1828
|
+
|
|
1829
|
+
|
|
1830
|
+
def test_order_by_template_fragment():
|
|
1831
|
+
"""order_by() accepts a raw t-string fragment, emitted verbatim (no added direction)."""
|
|
1832
|
+
query = Users.select(Users.id).order_by(t'lower(users.username) DESC NULLS LAST')
|
|
1833
|
+
sql, params = query.render()
|
|
1834
|
+
|
|
1835
|
+
assert sql == 'SELECT users.id FROM users ORDER BY lower(users.username) DESC NULLS LAST'
|
|
1836
|
+
assert params == []
|
|
1837
|
+
|
|
1838
|
+
|
|
1839
|
+
def test_order_by_template_carries_params():
|
|
1840
|
+
"""A parameterized ORDER BY fragment renumbers correctly in context."""
|
|
1841
|
+
query = (Users.select(Users.id)
|
|
1842
|
+
.where(Users.id > 5)
|
|
1843
|
+
.order_by(t'(users.username = {"vip"}) DESC'))
|
|
1844
|
+
sql, params = query.render(style=tsql.styles.NUMERIC_DOLLAR)
|
|
1845
|
+
|
|
1846
|
+
assert sql == 'SELECT users.id FROM users WHERE users.id > $1 ORDER BY (users.username = $2) DESC'
|
|
1847
|
+
assert params == [5, 'vip']
|
|
1848
|
+
|
|
1849
|
+
|
|
1850
|
+
def test_order_by_mixed_template_and_column():
|
|
1851
|
+
"""Template fragments and Column/str order_by entries compose in order."""
|
|
1852
|
+
query = (Users.select(Users.id)
|
|
1853
|
+
.order_by(t'lower(users.username) ASC')
|
|
1854
|
+
.order_by(Users.id.desc()))
|
|
1855
|
+
sql, params = query.render()
|
|
1856
|
+
|
|
1857
|
+
assert sql == 'SELECT users.id FROM users ORDER BY lower(users.username) ASC, users.id DESC'
|
|
1858
|
+
assert params == []
|
|
1859
|
+
|
|
1860
|
+
|
|
1861
|
+
def test_group_by_template_fragment():
|
|
1862
|
+
"""group_by() accepts a raw t-string fragment, emitted verbatim."""
|
|
1863
|
+
query = Users.select(t'date_trunc({"day":unsafe}, users.created_at)').group_by(
|
|
1864
|
+
t'date_trunc(day, users.created_at)'
|
|
1865
|
+
)
|
|
1866
|
+
sql, params = query.render()
|
|
1867
|
+
|
|
1868
|
+
assert sql == (
|
|
1869
|
+
'SELECT date_trunc(day, users.created_at) FROM users '
|
|
1870
|
+
'GROUP BY date_trunc(day, users.created_at)'
|
|
1871
|
+
)
|
|
1872
|
+
assert params == []
|
|
1873
|
+
|
|
1874
|
+
|
|
1875
|
+
def test_group_by_mixed_template_and_column():
|
|
1876
|
+
"""Template fragments and Column/str group_by entries compose in order."""
|
|
1877
|
+
query = Users.select(Users.id).group_by(Users.id).group_by(t'lower(users.email)')
|
|
1878
|
+
sql, params = query.render()
|
|
1879
|
+
|
|
1880
|
+
assert sql == 'SELECT users.id FROM users GROUP BY users.id, lower(users.email)'
|
|
1881
|
+
assert params == []
|
|
@@ -614,10 +614,11 @@ class QueryBuilder(ABC):
|
|
|
614
614
|
result_key = col.alias if col.alias else col.column_name
|
|
615
615
|
processors[result_key] = col.type_processor
|
|
616
616
|
else:
|
|
617
|
-
# SELECT * - check all tables involved (base table + joins)
|
|
617
|
+
# SELECT * - check all tables involved (base table + joins).
|
|
618
|
+
# Raw-Template joins carry no introspectable table, so skip them.
|
|
618
619
|
tables = [self.base_table]
|
|
619
620
|
if hasattr(self, '_joins'):
|
|
620
|
-
tables.extend(join.table for join in self._joins)
|
|
621
|
+
tables.extend(join.table for join in self._joins if not isinstance(join, Template))
|
|
621
622
|
|
|
622
623
|
for table in tables:
|
|
623
624
|
processors.update(table._type_processors)
|
|
@@ -1166,32 +1167,48 @@ class SelectQueryBuilder(QueryBuilder):
|
|
|
1166
1167
|
self.base_table = base_table
|
|
1167
1168
|
self._columns: Optional[List[Union[Column, str]]] = None
|
|
1168
1169
|
self._conditions: List[Union[Condition, Template]] = []
|
|
1169
|
-
self._joins: List[Join] = []
|
|
1170
|
-
self._group_by_columns: List[Union[Column, str]] = []
|
|
1170
|
+
self._joins: List[Union[Join, Template]] = []
|
|
1171
|
+
self._group_by_columns: List[Union[Column, Template, str]] = []
|
|
1171
1172
|
self._having_conditions: List[Union[Condition, Template]] = []
|
|
1172
|
-
self._order_by_columns: List[tuple[Union[Column, str], str]] = []
|
|
1173
|
+
self._order_by_columns: List[tuple[Union[Column, Template, str], str]] = []
|
|
1173
1174
|
self._limit_value: Optional[int] = None
|
|
1174
1175
|
self._offset_value: Optional[int] = None
|
|
1175
1176
|
self._ctes: List[tuple[str, Union[Template, TSQL, 'SelectQueryBuilder'], bool]] = []
|
|
1177
|
+
self._distinct_on_columns: List[Union[Column, Template, str]] = []
|
|
1178
|
+
self._only: bool = False
|
|
1179
|
+
self._alias: Optional[str] = None
|
|
1176
1180
|
|
|
1177
1181
|
@classmethod
|
|
1178
|
-
def from_table(
|
|
1182
|
+
def from_table(
|
|
1183
|
+
cls,
|
|
1184
|
+
table_name: str,
|
|
1185
|
+
schema: Optional[str] = None,
|
|
1186
|
+
*,
|
|
1187
|
+
only: bool = False,
|
|
1188
|
+
alias: Optional[str] = None,
|
|
1189
|
+
) -> 'SelectQueryBuilder':
|
|
1179
1190
|
"""Create a SelectQueryBuilder from a string table name.
|
|
1180
1191
|
|
|
1181
1192
|
Args:
|
|
1182
1193
|
table_name: Name of the table
|
|
1183
1194
|
schema: Optional schema name
|
|
1195
|
+
only: Emit ``FROM ONLY {table}`` to exclude inheriting child tables (PostgreSQL)
|
|
1196
|
+
alias: Optional FROM-clause alias, emitted as ``FROM {table} AS {alias}``
|
|
1197
|
+
(validated as an identifier)
|
|
1184
1198
|
|
|
1185
1199
|
Returns:
|
|
1186
1200
|
SelectQueryBuilder instance
|
|
1187
1201
|
|
|
1188
1202
|
Example:
|
|
1189
|
-
SelectQueryBuilder.from_table('users', schema='public') \\
|
|
1190
|
-
.select('id', 'name') \\
|
|
1191
|
-
.where(t'status = {status}')
|
|
1203
|
+
SelectQueryBuilder.from_table('users', schema='public', alias='u') \\
|
|
1204
|
+
.select('u.id', 'u.name') \\
|
|
1205
|
+
.where(t'u.status = {status}')
|
|
1192
1206
|
"""
|
|
1193
1207
|
string_table = _StringTable(table_name, schema)
|
|
1194
|
-
|
|
1208
|
+
builder = cls(string_table)
|
|
1209
|
+
builder._only = only
|
|
1210
|
+
builder._alias = alias
|
|
1211
|
+
return builder
|
|
1195
1212
|
|
|
1196
1213
|
def select(self, *columns: Union[Column, Template, str]) -> 'SelectQueryBuilder':
|
|
1197
1214
|
"""Specify columns to select
|
|
@@ -1221,6 +1238,20 @@ class SelectQueryBuilder(QueryBuilder):
|
|
|
1221
1238
|
self._columns = None
|
|
1222
1239
|
return self
|
|
1223
1240
|
|
|
1241
|
+
def distinct_on(self, *columns: Union[Column, Template, str]) -> 'SelectQueryBuilder':
|
|
1242
|
+
"""Add a DISTINCT ON (...) clause (PostgreSQL).
|
|
1243
|
+
|
|
1244
|
+
Args:
|
|
1245
|
+
columns: Column objects, raw t-string Templates, or string column names,
|
|
1246
|
+
coerced exactly like select().
|
|
1247
|
+
|
|
1248
|
+
Example:
|
|
1249
|
+
SelectQueryBuilder.from_table('events').distinct_on('user_id')
|
|
1250
|
+
# SELECT DISTINCT ON (user_id) * FROM events
|
|
1251
|
+
"""
|
|
1252
|
+
self._distinct_on_columns.extend(columns)
|
|
1253
|
+
return self
|
|
1254
|
+
|
|
1224
1255
|
def where(self, condition: Union[Condition, Template, Column]) -> 'SelectQueryBuilder':
|
|
1225
1256
|
"""Add a WHERE condition (multiple calls are ANDed together)
|
|
1226
1257
|
|
|
@@ -1234,6 +1265,23 @@ class SelectQueryBuilder(QueryBuilder):
|
|
|
1234
1265
|
self._joins.append(Join(table, on, join_type))
|
|
1235
1266
|
return self
|
|
1236
1267
|
|
|
1268
|
+
def join_raw(self, clause: Template) -> 'SelectQueryBuilder':
|
|
1269
|
+
"""Splice a raw t-string Template as a complete JOIN clause, verbatim.
|
|
1270
|
+
|
|
1271
|
+
The Template must carry the *whole* clause including the join keyword, e.g.
|
|
1272
|
+
``t'LEFT JOIN LATERAL (SELECT ...) x ON TRUE'`` or
|
|
1273
|
+
``t'CROSS JOIN LATERAL unnest({col}) AS y'``. Unlike join(), nothing is added
|
|
1274
|
+
around it — no join type, no ON.
|
|
1275
|
+
|
|
1276
|
+
NOT ADVISED: this is an escape hatch for join shapes the typed join() can't
|
|
1277
|
+
express (LATERAL subqueries, ON-less cross joins, correlated set-returning
|
|
1278
|
+
functions). It bypasses every safety/structure the builder provides. Prefer
|
|
1279
|
+
join()/left_join()/right_join() with a Table + Condition. This method may be
|
|
1280
|
+
removed if those typed paths grow to cover the remaining cases.
|
|
1281
|
+
"""
|
|
1282
|
+
self._joins.append(clause)
|
|
1283
|
+
return self
|
|
1284
|
+
|
|
1237
1285
|
def left_join(self, table: type['Table'], on: Condition) -> 'SelectQueryBuilder':
|
|
1238
1286
|
"""Add a LEFT JOIN clause"""
|
|
1239
1287
|
return self.join(table, on, 'LEFT')
|
|
@@ -1242,11 +1290,13 @@ class SelectQueryBuilder(QueryBuilder):
|
|
|
1242
1290
|
"""Add a RIGHT JOIN clause"""
|
|
1243
1291
|
return self.join(table, on, 'RIGHT')
|
|
1244
1292
|
|
|
1245
|
-
def order_by(self, *columns: Union[Column, OrderByClause, str], direction: str = 'ASC') -> 'SelectQueryBuilder':
|
|
1293
|
+
def order_by(self, *columns: Union[Column, OrderByClause, Template, str], direction: str = 'ASC') -> 'SelectQueryBuilder':
|
|
1246
1294
|
"""Add ORDER BY clause
|
|
1247
1295
|
|
|
1248
1296
|
Args:
|
|
1249
|
-
columns: Column objects, OrderByClause objects (from .asc()/.desc()),
|
|
1297
|
+
columns: Column objects, OrderByClause objects (from .asc()/.desc()), raw
|
|
1298
|
+
t-string Templates (emitted verbatim — bake the direction in yourself),
|
|
1299
|
+
or string column names
|
|
1250
1300
|
direction: Sort direction ('ASC' or 'DESC') for columns that don't have explicit direction
|
|
1251
1301
|
|
|
1252
1302
|
Examples:
|
|
@@ -1270,15 +1320,19 @@ class SelectQueryBuilder(QueryBuilder):
|
|
|
1270
1320
|
self._order_by_columns.append((column, direction.upper()))
|
|
1271
1321
|
return self
|
|
1272
1322
|
|
|
1273
|
-
def group_by(self, *columns: Union[Column, str]) -> 'SelectQueryBuilder':
|
|
1323
|
+
def group_by(self, *columns: Union[Column, Template, str]) -> 'SelectQueryBuilder':
|
|
1274
1324
|
"""Add GROUP BY clause
|
|
1275
1325
|
|
|
1276
1326
|
Args:
|
|
1277
|
-
columns: Column objects
|
|
1327
|
+
columns: Column objects, raw t-string Templates (emitted verbatim), or
|
|
1328
|
+
string column names
|
|
1278
1329
|
|
|
1279
1330
|
Examples:
|
|
1280
1331
|
# String-based GROUP BY
|
|
1281
1332
|
SelectQueryBuilder.from_table('orders').select('user_id', 'COUNT(*)').group_by('user_id')
|
|
1333
|
+
|
|
1334
|
+
# Raw expression via t-string
|
|
1335
|
+
Orders.select().group_by(t'date_trunc({"day":unsafe}, orders.created_at)')
|
|
1282
1336
|
"""
|
|
1283
1337
|
self._group_by_columns.extend(columns)
|
|
1284
1338
|
return self
|
|
@@ -1350,6 +1404,18 @@ class SelectQueryBuilder(QueryBuilder):
|
|
|
1350
1404
|
self._ctes.append((name, query, recursive))
|
|
1351
1405
|
return self
|
|
1352
1406
|
|
|
1407
|
+
@staticmethod
|
|
1408
|
+
def _coerce_column(col: Union['Column', Template, str]) -> Template:
|
|
1409
|
+
"""Coerce a SELECT/DISTINCT ON column into a t-string fragment."""
|
|
1410
|
+
if isinstance(col, Template):
|
|
1411
|
+
return col
|
|
1412
|
+
elif isinstance(col, str):
|
|
1413
|
+
# String column name, use :literal for validation
|
|
1414
|
+
return t'{col:literal}'
|
|
1415
|
+
else:
|
|
1416
|
+
# Column object, convert to string
|
|
1417
|
+
return t'{str(col):unsafe}'
|
|
1418
|
+
|
|
1353
1419
|
def to_tsql(self) -> TSQL:
|
|
1354
1420
|
"""Build the final TSQL object"""
|
|
1355
1421
|
parts: List[Template] = []
|
|
@@ -1380,32 +1446,32 @@ class SelectQueryBuilder(QueryBuilder):
|
|
|
1380
1446
|
else:
|
|
1381
1447
|
parts.append(t'WITH {cte_clause}')
|
|
1382
1448
|
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
elif isinstance(col, str):
|
|
1390
|
-
# String column name, use :literal for validation
|
|
1391
|
-
column_parts.append(t'{col:literal}')
|
|
1392
|
-
else:
|
|
1393
|
-
# Column object, convert to string
|
|
1394
|
-
column_parts.append(t'{str(col):unsafe}')
|
|
1449
|
+
# DISTINCT ON (...) clause, coercing columns exactly like the SELECT list
|
|
1450
|
+
if self._distinct_on_columns:
|
|
1451
|
+
distinct_on_template = t_join(t', ', [self._coerce_column(c) for c in self._distinct_on_columns])
|
|
1452
|
+
distinct_clause = t'DISTINCT ON ({distinct_on_template}) '
|
|
1453
|
+
else:
|
|
1454
|
+
distinct_clause = t''
|
|
1395
1455
|
|
|
1456
|
+
if self._columns:
|
|
1457
|
+
column_parts = [self._coerce_column(col) for col in self._columns]
|
|
1396
1458
|
columns_template = t_join(t', ', column_parts)
|
|
1397
|
-
parts.append(t'SELECT {columns_template}')
|
|
1459
|
+
parts.append(t'SELECT {distinct_clause}{columns_template}')
|
|
1398
1460
|
else:
|
|
1399
|
-
parts.append(t'SELECT *')
|
|
1461
|
+
parts.append(t'SELECT {distinct_clause}*')
|
|
1400
1462
|
|
|
1401
1463
|
if self.base_table.schema:
|
|
1402
1464
|
table_name = f"{self.base_table.schema}.{self.base_table.table_name}"
|
|
1403
1465
|
else:
|
|
1404
1466
|
table_name = self.base_table.table_name
|
|
1405
|
-
|
|
1467
|
+
only_kw = t'ONLY ' if self._only else t''
|
|
1468
|
+
if self._alias is not None:
|
|
1469
|
+
parts.append(t'FROM {only_kw}{table_name:literal} AS {self._alias:literal}')
|
|
1470
|
+
else:
|
|
1471
|
+
parts.append(t'FROM {only_kw}{table_name:literal}')
|
|
1406
1472
|
|
|
1407
1473
|
for join in self._joins:
|
|
1408
|
-
parts.append(join.to_tsql())
|
|
1474
|
+
parts.append(join if isinstance(join, Template) else join.to_tsql())
|
|
1409
1475
|
|
|
1410
1476
|
if self._conditions:
|
|
1411
1477
|
where_parts = []
|
|
@@ -1423,7 +1489,9 @@ class SelectQueryBuilder(QueryBuilder):
|
|
|
1423
1489
|
if self._group_by_columns:
|
|
1424
1490
|
group_by_parts = []
|
|
1425
1491
|
for col in self._group_by_columns:
|
|
1426
|
-
if isinstance(col,
|
|
1492
|
+
if isinstance(col, Template):
|
|
1493
|
+
group_by_parts.append(col)
|
|
1494
|
+
elif isinstance(col, str):
|
|
1427
1495
|
group_by_parts.append(t'{col:literal}')
|
|
1428
1496
|
else:
|
|
1429
1497
|
col_str = str(col)
|
|
@@ -1444,7 +1512,10 @@ class SelectQueryBuilder(QueryBuilder):
|
|
|
1444
1512
|
if self._order_by_columns:
|
|
1445
1513
|
order_parts = []
|
|
1446
1514
|
for col, direction in self._order_by_columns:
|
|
1447
|
-
if isinstance(col,
|
|
1515
|
+
if isinstance(col, Template):
|
|
1516
|
+
# Raw fragment is self-contained (direction baked in); emit verbatim
|
|
1517
|
+
order_parts.append(col)
|
|
1518
|
+
elif isinstance(col, str):
|
|
1448
1519
|
# String column name - validate with :literal
|
|
1449
1520
|
order_parts.append(t'{col:literal} {direction:unsafe}')
|
|
1450
1521
|
else:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|