t-sql 4.8.0__tar.gz → 4.9.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.8.0 → t_sql-4.9.0}/PKG-INFO +1 -1
- {t_sql-4.8.0 → t_sql-4.9.0}/pyproject.toml +1 -1
- {t_sql-4.8.0 → t_sql-4.9.0}/tests/test_query_builder.py +101 -0
- {t_sql-4.8.0 → t_sql-4.9.0}/tsql/query_builder.py +76 -0
- {t_sql-4.8.0 → t_sql-4.9.0}/.dockerignore +0 -0
- {t_sql-4.8.0 → t_sql-4.9.0}/.github/workflows/publish.yml +0 -0
- {t_sql-4.8.0 → t_sql-4.9.0}/.github/workflows/test.yml +0 -0
- {t_sql-4.8.0 → t_sql-4.9.0}/.gitignore +0 -0
- {t_sql-4.8.0 → t_sql-4.9.0}/Dockerfile +0 -0
- {t_sql-4.8.0 → t_sql-4.9.0}/LICENSE +0 -0
- {t_sql-4.8.0 → t_sql-4.9.0}/README.md +0 -0
- {t_sql-4.8.0 → t_sql-4.9.0}/compose.yaml +0 -0
- {t_sql-4.8.0 → t_sql-4.9.0}/context7.json +0 -0
- {t_sql-4.8.0 → t_sql-4.9.0}/pytest.ini +0 -0
- {t_sql-4.8.0 → t_sql-4.9.0}/tests/test_alembic_integration.py +0 -0
- {t_sql-4.8.0 → t_sql-4.9.0}/tests/test_asyncpg_integration.py +0 -0
- {t_sql-4.8.0 → t_sql-4.9.0}/tests/test_deep_nesting.py +0 -0
- {t_sql-4.8.0 → t_sql-4.9.0}/tests/test_different_object_types.py +0 -0
- {t_sql-4.8.0 → t_sql-4.9.0}/tests/test_error_messages.py +0 -0
- {t_sql-4.8.0 → t_sql-4.9.0}/tests/test_escaped.py +0 -0
- {t_sql-4.8.0 → t_sql-4.9.0}/tests/test_escaped_binary_hex.py +0 -0
- {t_sql-4.8.0 → t_sql-4.9.0}/tests/test_helper_functions.py +0 -0
- {t_sql-4.8.0 → t_sql-4.9.0}/tests/test_injection_edge_cases.py +0 -0
- {t_sql-4.8.0 → t_sql-4.9.0}/tests/test_injection_protection_validation.py +0 -0
- {t_sql-4.8.0 → t_sql-4.9.0}/tests/test_injections_for_escaped.py +0 -0
- {t_sql-4.8.0 → t_sql-4.9.0}/tests/test_like_patterns.py +0 -0
- {t_sql-4.8.0 → t_sql-4.9.0}/tests/test_mysql_integration.py +0 -0
- {t_sql-4.8.0 → t_sql-4.9.0}/tests/test_parameter_names.py +0 -0
- {t_sql-4.8.0 → t_sql-4.9.0}/tests/test_sqlalchemy_integration.py +0 -0
- {t_sql-4.8.0 → t_sql-4.9.0}/tests/test_sqlite_integration.py +0 -0
- {t_sql-4.8.0 → t_sql-4.9.0}/tests/test_string_based_builders.py +0 -0
- {t_sql-4.8.0 → t_sql-4.9.0}/tests/test_styles.py +0 -0
- {t_sql-4.8.0 → t_sql-4.9.0}/tests/test_template_in_builders.py +0 -0
- {t_sql-4.8.0 → t_sql-4.9.0}/tests/test_tsql.py +0 -0
- {t_sql-4.8.0 → t_sql-4.9.0}/tests/test_type_processor.py +0 -0
- {t_sql-4.8.0 → t_sql-4.9.0}/tsql/__init__.py +0 -0
- {t_sql-4.8.0 → t_sql-4.9.0}/tsql/row.py +0 -0
- {t_sql-4.8.0 → t_sql-4.9.0}/tsql/styles.py +0 -0
- {t_sql-4.8.0 → t_sql-4.9.0}/tsql/type_processor.py +0 -0
|
@@ -1431,3 +1431,104 @@ def test_nested_querybuilder_with_different_styles():
|
|
|
1431
1431
|
assert len(params) == 2
|
|
1432
1432
|
assert 42 in params.values() and 5 in params.values()
|
|
1433
1433
|
assert 'SELECT posts.user_id FROM posts WHERE posts.id = :' in sql
|
|
1434
|
+
|
|
1435
|
+
|
|
1436
|
+
# CTE Tests
|
|
1437
|
+
|
|
1438
|
+
def test_basic_cte():
|
|
1439
|
+
"""Test basic CTE with query builder"""
|
|
1440
|
+
from tsql.query_builder import SelectQueryBuilder
|
|
1441
|
+
|
|
1442
|
+
query = (
|
|
1443
|
+
SelectQueryBuilder.from_table('active_users')
|
|
1444
|
+
.with_cte('active_users', Users.select(Users.id, Users.username).where(Users.email == 'test@example.com'))
|
|
1445
|
+
.select('id', 'username')
|
|
1446
|
+
)
|
|
1447
|
+
|
|
1448
|
+
sql, params = query.render()
|
|
1449
|
+
assert sql == "WITH active_users AS (SELECT users.id, users.username FROM users WHERE users.email = ?) SELECT id, username FROM active_users"
|
|
1450
|
+
assert params == ['test@example.com']
|
|
1451
|
+
|
|
1452
|
+
|
|
1453
|
+
def test_cte_with_tstring():
|
|
1454
|
+
"""Test CTE with raw t-string query"""
|
|
1455
|
+
from tsql.query_builder import SelectQueryBuilder
|
|
1456
|
+
|
|
1457
|
+
query = (
|
|
1458
|
+
SelectQueryBuilder.from_table('counts')
|
|
1459
|
+
.with_cte('counts', t'SELECT COUNT(*) as total FROM users')
|
|
1460
|
+
.select('total')
|
|
1461
|
+
)
|
|
1462
|
+
|
|
1463
|
+
sql, _ = query.render()
|
|
1464
|
+
assert "WITH counts AS (SELECT COUNT(*) as total FROM users)" in sql
|
|
1465
|
+
assert "SELECT total FROM counts" in sql
|
|
1466
|
+
|
|
1467
|
+
|
|
1468
|
+
def test_multiple_ctes():
|
|
1469
|
+
"""Test chaining multiple CTEs"""
|
|
1470
|
+
from tsql.query_builder import SelectQueryBuilder
|
|
1471
|
+
|
|
1472
|
+
query = (
|
|
1473
|
+
SelectQueryBuilder.from_table('filtered')
|
|
1474
|
+
.with_cte('jennifers', Users.select(Users.id, Users.username).where(Users.username == 'Jennifer'))
|
|
1475
|
+
.with_cte('filtered', t'SELECT id FROM jennifers WHERE id > 18')
|
|
1476
|
+
)
|
|
1477
|
+
|
|
1478
|
+
sql, _ = query.render()
|
|
1479
|
+
assert "WITH jennifers AS" in sql
|
|
1480
|
+
assert ", filtered AS" in sql
|
|
1481
|
+
assert "SELECT * FROM filtered" in sql
|
|
1482
|
+
|
|
1483
|
+
|
|
1484
|
+
def test_recursive_cte():
|
|
1485
|
+
"""Test recursive CTE with t-string"""
|
|
1486
|
+
from tsql.query_builder import SelectQueryBuilder
|
|
1487
|
+
|
|
1488
|
+
query = (
|
|
1489
|
+
SelectQueryBuilder.from_table('category_tree')
|
|
1490
|
+
.with_cte('category_tree', t'''
|
|
1491
|
+
SELECT id, name, parent_id, 1 as level
|
|
1492
|
+
FROM categories
|
|
1493
|
+
WHERE parent_id IS NULL
|
|
1494
|
+
UNION ALL
|
|
1495
|
+
SELECT c.id, c.name, c.parent_id, ct.level + 1
|
|
1496
|
+
FROM categories c
|
|
1497
|
+
JOIN category_tree ct ON c.parent_id = ct.id
|
|
1498
|
+
''', recursive=True)
|
|
1499
|
+
)
|
|
1500
|
+
|
|
1501
|
+
sql, _ = query.render()
|
|
1502
|
+
assert sql.startswith("WITH RECURSIVE category_tree AS")
|
|
1503
|
+
assert "SELECT * FROM category_tree" in sql
|
|
1504
|
+
|
|
1505
|
+
|
|
1506
|
+
def test_cte_name_validation():
|
|
1507
|
+
"""Test CTE name is validated as identifier"""
|
|
1508
|
+
from tsql.query_builder import SelectQueryBuilder
|
|
1509
|
+
import pytest
|
|
1510
|
+
|
|
1511
|
+
query = SelectQueryBuilder.from_table('test')
|
|
1512
|
+
|
|
1513
|
+
with pytest.raises(ValueError, match="Invalid CTE name"):
|
|
1514
|
+
query.with_cte('invalid-name', t'SELECT 1')
|
|
1515
|
+
|
|
1516
|
+
with pytest.raises(ValueError, match="Invalid CTE name"):
|
|
1517
|
+
query.with_cte('123start', t'SELECT 1')
|
|
1518
|
+
|
|
1519
|
+
|
|
1520
|
+
def test_multiple_recursive_ctes():
|
|
1521
|
+
"""Test multiple CTEs where one is recursive"""
|
|
1522
|
+
from tsql.query_builder import SelectQueryBuilder
|
|
1523
|
+
|
|
1524
|
+
query = (
|
|
1525
|
+
SelectQueryBuilder.from_table('result')
|
|
1526
|
+
.with_cte('normal', t'SELECT id FROM users')
|
|
1527
|
+
.with_cte('tree', t'SELECT id FROM tree UNION ALL SELECT id+1 FROM tree WHERE id < 10', recursive=True)
|
|
1528
|
+
)
|
|
1529
|
+
|
|
1530
|
+
sql, _ = query.render()
|
|
1531
|
+
# Should use WITH RECURSIVE if ANY CTE is recursive
|
|
1532
|
+
assert sql.startswith("WITH RECURSIVE")
|
|
1533
|
+
assert "normal AS" in sql
|
|
1534
|
+
assert "tree AS" in sql
|
|
@@ -1161,6 +1161,7 @@ class SelectQueryBuilder(QueryBuilder):
|
|
|
1161
1161
|
self._order_by_columns: List[tuple[Union[Column, str], str]] = []
|
|
1162
1162
|
self._limit_value: Optional[int] = None
|
|
1163
1163
|
self._offset_value: Optional[int] = None
|
|
1164
|
+
self._ctes: List[tuple[str, Union[Template, TSQL, 'SelectQueryBuilder'], bool]] = []
|
|
1164
1165
|
|
|
1165
1166
|
@classmethod
|
|
1166
1167
|
def from_table(cls, table_name: str, schema: Optional[str] = None) -> 'SelectQueryBuilder':
|
|
@@ -1283,10 +1284,85 @@ class SelectQueryBuilder(QueryBuilder):
|
|
|
1283
1284
|
self._offset_value = n
|
|
1284
1285
|
return self
|
|
1285
1286
|
|
|
1287
|
+
def with_cte(self, name: str,
|
|
1288
|
+
query: Union[Template, TSQL, 'SelectQueryBuilder'],
|
|
1289
|
+
recursive: bool = False) -> 'SelectQueryBuilder':
|
|
1290
|
+
"""Add a CTE to this query's WITH clause.
|
|
1291
|
+
|
|
1292
|
+
Multiple CTEs can be chained by calling this method multiple times.
|
|
1293
|
+
|
|
1294
|
+
Args:
|
|
1295
|
+
name: CTE name (validated as valid identifier)
|
|
1296
|
+
query: The CTE query (SelectQueryBuilder, t-string Template, or TSQL)
|
|
1297
|
+
recursive: Whether this CTE is recursive (adds RECURSIVE keyword)
|
|
1298
|
+
|
|
1299
|
+
Returns:
|
|
1300
|
+
Self for method chaining
|
|
1301
|
+
|
|
1302
|
+
Example:
|
|
1303
|
+
# Basic CTE
|
|
1304
|
+
query = (
|
|
1305
|
+
SelectQueryBuilder.from_table('active_users')
|
|
1306
|
+
.with_cte('active_users', Users.select().where(Users.active == True))
|
|
1307
|
+
.select('id', 'name')
|
|
1308
|
+
)
|
|
1309
|
+
|
|
1310
|
+
# Multiple CTEs
|
|
1311
|
+
query = (
|
|
1312
|
+
SelectQueryBuilder.from_table('filtered')
|
|
1313
|
+
.with_cte('jennifers', Users.select().where(...))
|
|
1314
|
+
.with_cte('filtered', t'SELECT id FROM jennifers WHERE age > 18')
|
|
1315
|
+
.select('*')
|
|
1316
|
+
)
|
|
1317
|
+
|
|
1318
|
+
# Recursive CTE
|
|
1319
|
+
query = (
|
|
1320
|
+
SelectQueryBuilder.from_table('tree')
|
|
1321
|
+
.with_cte('tree', t'''
|
|
1322
|
+
SELECT id, name, parent_id FROM categories WHERE parent_id IS NULL
|
|
1323
|
+
UNION ALL
|
|
1324
|
+
SELECT c.id, c.name, c.parent_id FROM categories c
|
|
1325
|
+
JOIN tree t ON c.parent_id = t.id
|
|
1326
|
+
''', recursive=True)
|
|
1327
|
+
.select('*')
|
|
1328
|
+
)
|
|
1329
|
+
"""
|
|
1330
|
+
if not name.isidentifier():
|
|
1331
|
+
raise ValueError(f"Invalid CTE name: {name!r}. Must be a valid Python identifier.")
|
|
1332
|
+
|
|
1333
|
+
self._ctes.append((name, query, recursive))
|
|
1334
|
+
return self
|
|
1335
|
+
|
|
1286
1336
|
def to_tsql(self) -> TSQL:
|
|
1287
1337
|
"""Build the final TSQL object"""
|
|
1288
1338
|
parts: List[Template] = []
|
|
1289
1339
|
|
|
1340
|
+
# Render CTEs if present
|
|
1341
|
+
if self._ctes:
|
|
1342
|
+
has_recursive = any(recursive for _, _, recursive in self._ctes)
|
|
1343
|
+
cte_parts = []
|
|
1344
|
+
|
|
1345
|
+
for name, query, _ in self._ctes:
|
|
1346
|
+
# Convert CTE query to TSQL
|
|
1347
|
+
if hasattr(query, 'to_tsql'):
|
|
1348
|
+
cte_sql = query.to_tsql()
|
|
1349
|
+
elif isinstance(query, Template):
|
|
1350
|
+
cte_sql = TSQL(query)
|
|
1351
|
+
else:
|
|
1352
|
+
cte_sql = query
|
|
1353
|
+
|
|
1354
|
+
# Render as: cte_name AS (query)
|
|
1355
|
+
cte_parts.append(t'{name:literal} AS ({cte_sql})')
|
|
1356
|
+
|
|
1357
|
+
# Join all CTEs with commas
|
|
1358
|
+
cte_clause = t_join(t', ', cte_parts)
|
|
1359
|
+
|
|
1360
|
+
# Add WITH or WITH RECURSIVE
|
|
1361
|
+
if has_recursive:
|
|
1362
|
+
parts.append(t'WITH RECURSIVE {cte_clause}')
|
|
1363
|
+
else:
|
|
1364
|
+
parts.append(t'WITH {cte_clause}')
|
|
1365
|
+
|
|
1290
1366
|
if self._columns:
|
|
1291
1367
|
# Build column list, handling Column objects, Template (t-string) objects, and strings
|
|
1292
1368
|
column_parts = []
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|