t-sql 4.10.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.10.0/README.md → t_sql-4.12.0/PKG-INFO +18 -0
  2. t_sql-4.10.0/PKG-INFO → t_sql-4.12.0/README.md +8 -10
  3. {t_sql-4.10.0 → t_sql-4.12.0}/pyproject.toml +1 -1
  4. {t_sql-4.10.0 → t_sql-4.12.0}/tests/test_asyncpg_integration.py +32 -1
  5. {t_sql-4.10.0 → t_sql-4.12.0}/tests/test_query_builder.py +293 -1
  6. {t_sql-4.10.0 → t_sql-4.12.0}/tests/test_sqlite_integration.py +26 -0
  7. {t_sql-4.10.0 → t_sql-4.12.0}/tests/test_tsql.py +1 -1
  8. {t_sql-4.10.0 → t_sql-4.12.0}/tsql/query_builder.py +110 -34
  9. {t_sql-4.10.0 → t_sql-4.12.0}/.dockerignore +0 -0
  10. {t_sql-4.10.0 → t_sql-4.12.0}/.github/workflows/publish.yml +0 -0
  11. {t_sql-4.10.0 → t_sql-4.12.0}/.github/workflows/test.yml +0 -0
  12. {t_sql-4.10.0 → t_sql-4.12.0}/.gitignore +0 -0
  13. {t_sql-4.10.0 → t_sql-4.12.0}/Dockerfile +0 -0
  14. {t_sql-4.10.0 → t_sql-4.12.0}/LICENSE +0 -0
  15. {t_sql-4.10.0 → t_sql-4.12.0}/compose.yaml +0 -0
  16. {t_sql-4.10.0 → t_sql-4.12.0}/context7.json +0 -0
  17. {t_sql-4.10.0 → t_sql-4.12.0}/pytest.ini +0 -0
  18. {t_sql-4.10.0 → t_sql-4.12.0}/tests/test_alembic_integration.py +0 -0
  19. {t_sql-4.10.0 → t_sql-4.12.0}/tests/test_deep_nesting.py +0 -0
  20. {t_sql-4.10.0 → t_sql-4.12.0}/tests/test_different_object_types.py +0 -0
  21. {t_sql-4.10.0 → t_sql-4.12.0}/tests/test_error_messages.py +0 -0
  22. {t_sql-4.10.0 → t_sql-4.12.0}/tests/test_escaped.py +0 -0
  23. {t_sql-4.10.0 → t_sql-4.12.0}/tests/test_escaped_binary_hex.py +0 -0
  24. {t_sql-4.10.0 → t_sql-4.12.0}/tests/test_helper_functions.py +0 -0
  25. {t_sql-4.10.0 → t_sql-4.12.0}/tests/test_injection_edge_cases.py +0 -0
  26. {t_sql-4.10.0 → t_sql-4.12.0}/tests/test_injection_protection_validation.py +0 -0
  27. {t_sql-4.10.0 → t_sql-4.12.0}/tests/test_injections_for_escaped.py +0 -0
  28. {t_sql-4.10.0 → t_sql-4.12.0}/tests/test_like_patterns.py +0 -0
  29. {t_sql-4.10.0 → t_sql-4.12.0}/tests/test_mysql_integration.py +0 -0
  30. {t_sql-4.10.0 → t_sql-4.12.0}/tests/test_parameter_names.py +0 -0
  31. {t_sql-4.10.0 → t_sql-4.12.0}/tests/test_sqlalchemy_integration.py +0 -0
  32. {t_sql-4.10.0 → t_sql-4.12.0}/tests/test_string_based_builders.py +0 -0
  33. {t_sql-4.10.0 → t_sql-4.12.0}/tests/test_styles.py +0 -0
  34. {t_sql-4.10.0 → t_sql-4.12.0}/tests/test_template_in_builders.py +0 -0
  35. {t_sql-4.10.0 → t_sql-4.12.0}/tests/test_type_processor.py +0 -0
  36. {t_sql-4.10.0 → t_sql-4.12.0}/tsql/__init__.py +0 -0
  37. {t_sql-4.10.0 → t_sql-4.12.0}/tsql/row.py +0 -0
  38. {t_sql-4.10.0 → t_sql-4.12.0}/tsql/styles.py +0 -0
  39. {t_sql-4.10.0 → t_sql-4.12.0}/tsql/type_processor.py +0 -0
@@ -1,3 +1,13 @@
1
+ Metadata-Version: 2.4
2
+ Name: t-sql
3
+ Version: 4.12.0
4
+ Summary: Safe SQL. SQL queries for python t-strings (PEP 750)
5
+ Project-URL: Homepage, https://github.com/nhumrich/t-sql
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.14
8
+ Requires-Dist: alembic>=1.17.0
9
+ Description-Content-Type: text/markdown
10
+
1
11
  # t-sql
2
12
 
3
13
  A lightweight SQL templating library that leverages Python 3.14's t-strings (PEP 750).
@@ -375,6 +385,14 @@ query = Users.insert(id='abc123', username='john', email='john@example.com').ret
375
385
  sql, params = query.render()
376
386
  # ('INSERT INTO users (id, username, email) VALUES (?, ?, ?) RETURNING *', [...])
377
387
 
388
+ # INSERT with all column defaults — no values provided (Postgres/SQLite)
389
+ # Emits `DEFAULT VALUES`; useful when every column has a DB/SA default.
390
+ # Note: MySQL does not support `DEFAULT VALUES` syntax. For MySQL, provide at
391
+ # least one column or use raw SQL (`INSERT INTO t () VALUES ()`).
392
+ query = Users.insert().returning('id')
393
+ sql, params = query.render()
394
+ # ('INSERT INTO users DEFAULT VALUES RETURNING id', [])
395
+
378
396
  # INSERT IGNORE (MySQL)
379
397
  query = Users.insert(id='abc123', username='john', email='john@example.com').ignore()
380
398
  sql, params = query.render()
@@ -1,13 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: t-sql
3
- Version: 4.10.0
4
- Summary: Safe SQL. SQL queries for python t-strings (PEP 750)
5
- Project-URL: Homepage, https://github.com/nhumrich/t-sql
6
- License-File: LICENSE
7
- Requires-Python: >=3.14
8
- Requires-Dist: alembic>=1.17.0
9
- Description-Content-Type: text/markdown
10
-
11
1
  # t-sql
12
2
 
13
3
  A lightweight SQL templating library that leverages Python 3.14's t-strings (PEP 750).
@@ -385,6 +375,14 @@ query = Users.insert(id='abc123', username='john', email='john@example.com').ret
385
375
  sql, params = query.render()
386
376
  # ('INSERT INTO users (id, username, email) VALUES (?, ?, ?) RETURNING *', [...])
387
377
 
378
+ # INSERT with all column defaults — no values provided (Postgres/SQLite)
379
+ # Emits `DEFAULT VALUES`; useful when every column has a DB/SA default.
380
+ # Note: MySQL does not support `DEFAULT VALUES` syntax. For MySQL, provide at
381
+ # least one column or use raw SQL (`INSERT INTO t () VALUES ()`).
382
+ query = Users.insert().returning('id')
383
+ sql, params = query.render()
384
+ # ('INSERT INTO users DEFAULT VALUES RETURNING id', [])
385
+
388
386
  # INSERT IGNORE (MySQL)
389
387
  query = Users.insert(id='abc123', username='john', email='john@example.com').ignore()
390
388
  sql, params = query.render()
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "t-sql"
7
- version = "4.10.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"
@@ -457,4 +457,35 @@ async def test_array_with_list(conn):
457
457
  row = await conn.fetchrow("SELECT tags FROM test_array WHERE id = 1")
458
458
  assert row['tags'] == my_list
459
459
  finally:
460
- await conn.execute("DROP TABLE IF EXISTS test_array")
460
+ await conn.execute("DROP TABLE IF EXISTS test_array")
461
+
462
+
463
+ async def test_insert_all_defaults_with_returning(conn):
464
+ """Insert with empty values uses DEFAULT VALUES; all columns have defaults."""
465
+ from tsql.query_builder import Table, Column
466
+
467
+ await conn.execute("""
468
+ CREATE TABLE IF NOT EXISTS test_defaults (
469
+ id SERIAL PRIMARY KEY,
470
+ status VARCHAR(20) DEFAULT 'draft',
471
+ created_at TIMESTAMP DEFAULT NOW()
472
+ )
473
+ """)
474
+
475
+ try:
476
+ class TestDefaults(Table, table_name='test_defaults'):
477
+ id: Column
478
+ status: Column
479
+ created_at: Column
480
+
481
+ query, params = TestDefaults.insert().returning('id', 'status').render(
482
+ style=tsql.styles.NUMERIC_DOLLAR
483
+ )
484
+ assert query == 'INSERT INTO test_defaults DEFAULT VALUES RETURNING id, status'
485
+ assert params == []
486
+
487
+ row = await conn.fetchrow(query, *params)
488
+ assert row['id'] is not None
489
+ assert row['status'] == 'draft'
490
+ finally:
491
+ await conn.execute("DROP TABLE IF EXISTS test_defaults")
@@ -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)
@@ -1208,6 +1259,43 @@ def test_insert_without_metadata_ignores_defaults():
1208
1259
  assert params == ['1', 'test']
1209
1260
 
1210
1261
 
1262
+ class _DefaultsTable(Table, table_name='mytable'):
1263
+ id: Column
1264
+ name: Column
1265
+
1266
+
1267
+ def test_insert_all_defaults():
1268
+ sql, params = _DefaultsTable.insert().render()
1269
+ assert sql == 'INSERT INTO mytable DEFAULT VALUES'
1270
+ assert params == []
1271
+
1272
+
1273
+ def test_insert_all_defaults_with_returning():
1274
+ sql, params = _DefaultsTable.insert().returning('id').render()
1275
+ assert sql == 'INSERT INTO mytable DEFAULT VALUES RETURNING id'
1276
+ assert params == []
1277
+
1278
+
1279
+ def test_insert_all_defaults_with_returning_star():
1280
+ sql, params = _DefaultsTable.insert().returning().render()
1281
+ assert sql == 'INSERT INTO mytable DEFAULT VALUES RETURNING *'
1282
+ assert params == []
1283
+
1284
+
1285
+ def test_insert_all_defaults_with_on_conflict_do_nothing():
1286
+ sql, params = _DefaultsTable.insert().on_conflict_do_nothing().render()
1287
+ assert sql == 'INSERT INTO mytable DEFAULT VALUES ON CONFLICT DO NOTHING'
1288
+ assert params == []
1289
+
1290
+
1291
+ def test_insert_all_defaults_mysql_on_duplicate_raises():
1292
+ import pytest
1293
+
1294
+ builder = _DefaultsTable.insert().on_duplicate_key_update()
1295
+ with pytest.raises(ValueError, match='DEFAULT VALUES'):
1296
+ builder.render()
1297
+
1298
+
1211
1299
  def test_update_with_onupdate_default():
1212
1300
  """Test that onupdate defaults are applied during UPDATE"""
1213
1301
  from sqlalchemy import MetaData, Column as SAColumn, String
@@ -1587,3 +1675,207 @@ def test_delete_where_with_bare_boolean_column():
1587
1675
  assert 'DELETE FROM contacts' in sql
1588
1676
  assert 'WHERE contacts.is_primary' in sql
1589
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 == []
@@ -429,3 +429,29 @@ async def test_like_pattern_format_specs(conn):
429
429
  # Should match john_doe
430
430
  assert len(rows) == 1
431
431
  assert rows[0][0] == 'john_doe'
432
+
433
+
434
+ async def test_insert_all_defaults_with_returning(conn):
435
+ """Insert with empty values uses DEFAULT VALUES on SQLite."""
436
+ from tsql.query_builder import Column
437
+
438
+ await conn.execute("""
439
+ CREATE TABLE test_defaults (
440
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
441
+ status TEXT DEFAULT 'draft'
442
+ )
443
+ """)
444
+ await conn.commit()
445
+
446
+ class TestDefaults(Table, table_name='test_defaults'):
447
+ id: Column
448
+ status: Column
449
+
450
+ sql, params = TestDefaults.insert().returning('id', 'status').render()
451
+ assert sql == 'INSERT INTO test_defaults DEFAULT VALUES RETURNING id, status'
452
+ assert params == []
453
+
454
+ cursor = await conn.execute(sql, params)
455
+ row = await cursor.fetchone()
456
+ assert row[0] is not None
457
+ assert row[1] == 'draft'
@@ -69,7 +69,7 @@ def test_correct_final_query_with_literals():
69
69
  def test_disallows_bad_literals():
70
70
  table = "users'"
71
71
  col = "name"
72
- with pytest.raises(ValueError): # TODO: change to appropriate error
72
+ with pytest.raises(ValueError):
73
73
  result = tsql.render(t'select id, {col:literal} from {table:literal}')
74
74
 
75
75
 
@@ -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)
@@ -759,8 +760,9 @@ class InsertBuilder(QueryBuilder):
759
760
  processor = self.base_table._type_processors.get(col_name)
760
761
  values_dict[col_name] = _process_value_for_builder(value, processor)
761
762
 
762
- # MySQL INSERT IGNORE
763
- if self._ignore:
763
+ if not values_dict:
764
+ parts.append(t'INSERT INTO {table_name:literal} DEFAULT VALUES')
765
+ elif self._ignore:
764
766
  parts.append(t'INSERT IGNORE INTO {table_name:literal} {values_dict:as_values}')
765
767
  else:
766
768
  parts.append(t'INSERT INTO {table_name:literal} {values_dict:as_values}')
@@ -821,6 +823,10 @@ class InsertBuilder(QueryBuilder):
821
823
  update_dict[col_name] = _process_value_for_builder(value, processor)
822
824
  parts.append(t'ON DUPLICATE KEY UPDATE {update_dict:as_set}')
823
825
  else:
826
+ if not self.values:
827
+ raise ValueError(
828
+ "ON DUPLICATE KEY UPDATE requires explicit columns when inserting DEFAULT VALUES"
829
+ )
824
830
  # Default: update all columns with alias.column (new MySQL syntax)
825
831
  update_parts = []
826
832
  for i, key in enumerate(self.values.keys()):
@@ -1161,32 +1167,48 @@ class SelectQueryBuilder(QueryBuilder):
1161
1167
  self.base_table = base_table
1162
1168
  self._columns: Optional[List[Union[Column, str]]] = None
1163
1169
  self._conditions: List[Union[Condition, Template]] = []
1164
- self._joins: List[Join] = []
1165
- 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]] = []
1166
1172
  self._having_conditions: List[Union[Condition, Template]] = []
1167
- self._order_by_columns: List[tuple[Union[Column, str], str]] = []
1173
+ self._order_by_columns: List[tuple[Union[Column, Template, str], str]] = []
1168
1174
  self._limit_value: Optional[int] = None
1169
1175
  self._offset_value: Optional[int] = None
1170
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
1171
1180
 
1172
1181
  @classmethod
1173
- 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':
1174
1190
  """Create a SelectQueryBuilder from a string table name.
1175
1191
 
1176
1192
  Args:
1177
1193
  table_name: Name of the table
1178
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)
1179
1198
 
1180
1199
  Returns:
1181
1200
  SelectQueryBuilder instance
1182
1201
 
1183
1202
  Example:
1184
- SelectQueryBuilder.from_table('users', schema='public') \\
1185
- .select('id', 'name') \\
1186
- .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}')
1187
1206
  """
1188
1207
  string_table = _StringTable(table_name, schema)
1189
- return cls(string_table)
1208
+ builder = cls(string_table)
1209
+ builder._only = only
1210
+ builder._alias = alias
1211
+ return builder
1190
1212
 
1191
1213
  def select(self, *columns: Union[Column, Template, str]) -> 'SelectQueryBuilder':
1192
1214
  """Specify columns to select
@@ -1216,6 +1238,20 @@ class SelectQueryBuilder(QueryBuilder):
1216
1238
  self._columns = None
1217
1239
  return self
1218
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
+
1219
1255
  def where(self, condition: Union[Condition, Template, Column]) -> 'SelectQueryBuilder':
1220
1256
  """Add a WHERE condition (multiple calls are ANDed together)
1221
1257
 
@@ -1229,6 +1265,23 @@ class SelectQueryBuilder(QueryBuilder):
1229
1265
  self._joins.append(Join(table, on, join_type))
1230
1266
  return self
1231
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
+
1232
1285
  def left_join(self, table: type['Table'], on: Condition) -> 'SelectQueryBuilder':
1233
1286
  """Add a LEFT JOIN clause"""
1234
1287
  return self.join(table, on, 'LEFT')
@@ -1237,11 +1290,13 @@ class SelectQueryBuilder(QueryBuilder):
1237
1290
  """Add a RIGHT JOIN clause"""
1238
1291
  return self.join(table, on, 'RIGHT')
1239
1292
 
1240
- 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':
1241
1294
  """Add ORDER BY clause
1242
1295
 
1243
1296
  Args:
1244
- 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
1245
1300
  direction: Sort direction ('ASC' or 'DESC') for columns that don't have explicit direction
1246
1301
 
1247
1302
  Examples:
@@ -1265,15 +1320,19 @@ class SelectQueryBuilder(QueryBuilder):
1265
1320
  self._order_by_columns.append((column, direction.upper()))
1266
1321
  return self
1267
1322
 
1268
- def group_by(self, *columns: Union[Column, str]) -> 'SelectQueryBuilder':
1323
+ def group_by(self, *columns: Union[Column, Template, str]) -> 'SelectQueryBuilder':
1269
1324
  """Add GROUP BY clause
1270
1325
 
1271
1326
  Args:
1272
- columns: Column objects or string column names
1327
+ columns: Column objects, raw t-string Templates (emitted verbatim), or
1328
+ string column names
1273
1329
 
1274
1330
  Examples:
1275
1331
  # String-based GROUP BY
1276
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)')
1277
1336
  """
1278
1337
  self._group_by_columns.extend(columns)
1279
1338
  return self
@@ -1345,6 +1404,18 @@ class SelectQueryBuilder(QueryBuilder):
1345
1404
  self._ctes.append((name, query, recursive))
1346
1405
  return self
1347
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
+
1348
1419
  def to_tsql(self) -> TSQL:
1349
1420
  """Build the final TSQL object"""
1350
1421
  parts: List[Template] = []
@@ -1375,32 +1446,32 @@ class SelectQueryBuilder(QueryBuilder):
1375
1446
  else:
1376
1447
  parts.append(t'WITH {cte_clause}')
1377
1448
 
1378
- if self._columns:
1379
- # Build column list, handling Column objects, Template (t-string) objects, and strings
1380
- column_parts = []
1381
- for col in self._columns:
1382
- if isinstance(col, Template):
1383
- column_parts.append(col)
1384
- elif isinstance(col, str):
1385
- # String column name, use :literal for validation
1386
- column_parts.append(t'{col:literal}')
1387
- else:
1388
- # Column object, convert to string
1389
- 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''
1390
1455
 
1456
+ if self._columns:
1457
+ column_parts = [self._coerce_column(col) for col in self._columns]
1391
1458
  columns_template = t_join(t', ', column_parts)
1392
- parts.append(t'SELECT {columns_template}')
1459
+ parts.append(t'SELECT {distinct_clause}{columns_template}')
1393
1460
  else:
1394
- parts.append(t'SELECT *')
1461
+ parts.append(t'SELECT {distinct_clause}*')
1395
1462
 
1396
1463
  if self.base_table.schema:
1397
1464
  table_name = f"{self.base_table.schema}.{self.base_table.table_name}"
1398
1465
  else:
1399
1466
  table_name = self.base_table.table_name
1400
- 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}')
1401
1472
 
1402
1473
  for join in self._joins:
1403
- parts.append(join.to_tsql())
1474
+ parts.append(join if isinstance(join, Template) else join.to_tsql())
1404
1475
 
1405
1476
  if self._conditions:
1406
1477
  where_parts = []
@@ -1418,7 +1489,9 @@ class SelectQueryBuilder(QueryBuilder):
1418
1489
  if self._group_by_columns:
1419
1490
  group_by_parts = []
1420
1491
  for col in self._group_by_columns:
1421
- if isinstance(col, str):
1492
+ if isinstance(col, Template):
1493
+ group_by_parts.append(col)
1494
+ elif isinstance(col, str):
1422
1495
  group_by_parts.append(t'{col:literal}')
1423
1496
  else:
1424
1497
  col_str = str(col)
@@ -1439,7 +1512,10 @@ class SelectQueryBuilder(QueryBuilder):
1439
1512
  if self._order_by_columns:
1440
1513
  order_parts = []
1441
1514
  for col, direction in self._order_by_columns:
1442
- 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):
1443
1519
  # String column name - validate with :literal
1444
1520
  order_parts.append(t'{col:literal} {direction:unsafe}')
1445
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