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.
- t_sql-4.10.0/README.md → t_sql-4.12.0/PKG-INFO +18 -0
- t_sql-4.10.0/PKG-INFO → t_sql-4.12.0/README.md +8 -10
- {t_sql-4.10.0 → t_sql-4.12.0}/pyproject.toml +1 -1
- {t_sql-4.10.0 → t_sql-4.12.0}/tests/test_asyncpg_integration.py +32 -1
- {t_sql-4.10.0 → t_sql-4.12.0}/tests/test_query_builder.py +293 -1
- {t_sql-4.10.0 → t_sql-4.12.0}/tests/test_sqlite_integration.py +26 -0
- {t_sql-4.10.0 → t_sql-4.12.0}/tests/test_tsql.py +1 -1
- {t_sql-4.10.0 → t_sql-4.12.0}/tsql/query_builder.py +110 -34
- {t_sql-4.10.0 → t_sql-4.12.0}/.dockerignore +0 -0
- {t_sql-4.10.0 → t_sql-4.12.0}/.github/workflows/publish.yml +0 -0
- {t_sql-4.10.0 → t_sql-4.12.0}/.github/workflows/test.yml +0 -0
- {t_sql-4.10.0 → t_sql-4.12.0}/.gitignore +0 -0
- {t_sql-4.10.0 → t_sql-4.12.0}/Dockerfile +0 -0
- {t_sql-4.10.0 → t_sql-4.12.0}/LICENSE +0 -0
- {t_sql-4.10.0 → t_sql-4.12.0}/compose.yaml +0 -0
- {t_sql-4.10.0 → t_sql-4.12.0}/context7.json +0 -0
- {t_sql-4.10.0 → t_sql-4.12.0}/pytest.ini +0 -0
- {t_sql-4.10.0 → t_sql-4.12.0}/tests/test_alembic_integration.py +0 -0
- {t_sql-4.10.0 → t_sql-4.12.0}/tests/test_deep_nesting.py +0 -0
- {t_sql-4.10.0 → t_sql-4.12.0}/tests/test_different_object_types.py +0 -0
- {t_sql-4.10.0 → t_sql-4.12.0}/tests/test_error_messages.py +0 -0
- {t_sql-4.10.0 → t_sql-4.12.0}/tests/test_escaped.py +0 -0
- {t_sql-4.10.0 → t_sql-4.12.0}/tests/test_escaped_binary_hex.py +0 -0
- {t_sql-4.10.0 → t_sql-4.12.0}/tests/test_helper_functions.py +0 -0
- {t_sql-4.10.0 → t_sql-4.12.0}/tests/test_injection_edge_cases.py +0 -0
- {t_sql-4.10.0 → t_sql-4.12.0}/tests/test_injection_protection_validation.py +0 -0
- {t_sql-4.10.0 → t_sql-4.12.0}/tests/test_injections_for_escaped.py +0 -0
- {t_sql-4.10.0 → t_sql-4.12.0}/tests/test_like_patterns.py +0 -0
- {t_sql-4.10.0 → t_sql-4.12.0}/tests/test_mysql_integration.py +0 -0
- {t_sql-4.10.0 → t_sql-4.12.0}/tests/test_parameter_names.py +0 -0
- {t_sql-4.10.0 → t_sql-4.12.0}/tests/test_sqlalchemy_integration.py +0 -0
- {t_sql-4.10.0 → t_sql-4.12.0}/tests/test_string_based_builders.py +0 -0
- {t_sql-4.10.0 → t_sql-4.12.0}/tests/test_styles.py +0 -0
- {t_sql-4.10.0 → t_sql-4.12.0}/tests/test_template_in_builders.py +0 -0
- {t_sql-4.10.0 → t_sql-4.12.0}/tests/test_type_processor.py +0 -0
- {t_sql-4.10.0 → t_sql-4.12.0}/tsql/__init__.py +0 -0
- {t_sql-4.10.0 → t_sql-4.12.0}/tsql/row.py +0 -0
- {t_sql-4.10.0 → t_sql-4.12.0}/tsql/styles.py +0 -0
- {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()
|
|
@@ -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):
|
|
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
|
-
|
|
763
|
-
|
|
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(
|
|
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
|
-
|
|
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()),
|
|
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
|
|
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
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
|
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
|