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.
Files changed (39) hide show
  1. {t_sql-4.11.0 → t_sql-4.12.0}/PKG-INFO +1 -1
  2. {t_sql-4.11.0 → t_sql-4.12.0}/pyproject.toml +1 -1
  3. {t_sql-4.11.0 → t_sql-4.12.0}/tests/test_query_builder.py +256 -1
  4. {t_sql-4.11.0 → t_sql-4.12.0}/tsql/query_builder.py +103 -32
  5. {t_sql-4.11.0 → t_sql-4.12.0}/.dockerignore +0 -0
  6. {t_sql-4.11.0 → t_sql-4.12.0}/.github/workflows/publish.yml +0 -0
  7. {t_sql-4.11.0 → t_sql-4.12.0}/.github/workflows/test.yml +0 -0
  8. {t_sql-4.11.0 → t_sql-4.12.0}/.gitignore +0 -0
  9. {t_sql-4.11.0 → t_sql-4.12.0}/Dockerfile +0 -0
  10. {t_sql-4.11.0 → t_sql-4.12.0}/LICENSE +0 -0
  11. {t_sql-4.11.0 → t_sql-4.12.0}/README.md +0 -0
  12. {t_sql-4.11.0 → t_sql-4.12.0}/compose.yaml +0 -0
  13. {t_sql-4.11.0 → t_sql-4.12.0}/context7.json +0 -0
  14. {t_sql-4.11.0 → t_sql-4.12.0}/pytest.ini +0 -0
  15. {t_sql-4.11.0 → t_sql-4.12.0}/tests/test_alembic_integration.py +0 -0
  16. {t_sql-4.11.0 → t_sql-4.12.0}/tests/test_asyncpg_integration.py +0 -0
  17. {t_sql-4.11.0 → t_sql-4.12.0}/tests/test_deep_nesting.py +0 -0
  18. {t_sql-4.11.0 → t_sql-4.12.0}/tests/test_different_object_types.py +0 -0
  19. {t_sql-4.11.0 → t_sql-4.12.0}/tests/test_error_messages.py +0 -0
  20. {t_sql-4.11.0 → t_sql-4.12.0}/tests/test_escaped.py +0 -0
  21. {t_sql-4.11.0 → t_sql-4.12.0}/tests/test_escaped_binary_hex.py +0 -0
  22. {t_sql-4.11.0 → t_sql-4.12.0}/tests/test_helper_functions.py +0 -0
  23. {t_sql-4.11.0 → t_sql-4.12.0}/tests/test_injection_edge_cases.py +0 -0
  24. {t_sql-4.11.0 → t_sql-4.12.0}/tests/test_injection_protection_validation.py +0 -0
  25. {t_sql-4.11.0 → t_sql-4.12.0}/tests/test_injections_for_escaped.py +0 -0
  26. {t_sql-4.11.0 → t_sql-4.12.0}/tests/test_like_patterns.py +0 -0
  27. {t_sql-4.11.0 → t_sql-4.12.0}/tests/test_mysql_integration.py +0 -0
  28. {t_sql-4.11.0 → t_sql-4.12.0}/tests/test_parameter_names.py +0 -0
  29. {t_sql-4.11.0 → t_sql-4.12.0}/tests/test_sqlalchemy_integration.py +0 -0
  30. {t_sql-4.11.0 → t_sql-4.12.0}/tests/test_sqlite_integration.py +0 -0
  31. {t_sql-4.11.0 → t_sql-4.12.0}/tests/test_string_based_builders.py +0 -0
  32. {t_sql-4.11.0 → t_sql-4.12.0}/tests/test_styles.py +0 -0
  33. {t_sql-4.11.0 → t_sql-4.12.0}/tests/test_template_in_builders.py +0 -0
  34. {t_sql-4.11.0 → t_sql-4.12.0}/tests/test_tsql.py +0 -0
  35. {t_sql-4.11.0 → t_sql-4.12.0}/tests/test_type_processor.py +0 -0
  36. {t_sql-4.11.0 → t_sql-4.12.0}/tsql/__init__.py +0 -0
  37. {t_sql-4.11.0 → t_sql-4.12.0}/tsql/row.py +0 -0
  38. {t_sql-4.11.0 → t_sql-4.12.0}/tsql/styles.py +0 -0
  39. {t_sql-4.11.0 → t_sql-4.12.0}/tsql/type_processor.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: t-sql
3
- Version: 4.11.0
3
+ Version: 4.12.0
4
4
  Summary: Safe SQL. SQL queries for python t-strings (PEP 750)
5
5
  Project-URL: Homepage, https://github.com/nhumrich/t-sql
6
6
  License-File: LICENSE
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "t-sql"
7
- version = "4.11.0"
7
+ version = "4.12.0"
8
8
  description = "Safe SQL. SQL queries for python t-strings (PEP 750)"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.14"
@@ -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(cls, table_name: str, schema: Optional[str] = None) -> 'SelectQueryBuilder':
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
- return cls(string_table)
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()), or string column names
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 or string column names
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
- if self._columns:
1384
- # Build column list, handling Column objects, Template (t-string) objects, and strings
1385
- column_parts = []
1386
- for col in self._columns:
1387
- if isinstance(col, Template):
1388
- column_parts.append(col)
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
- parts.append(t'FROM {table_name:literal}')
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, str):
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, str):
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