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.
Files changed (39) hide show
  1. {t_sql-4.8.0 → t_sql-4.9.0}/PKG-INFO +1 -1
  2. {t_sql-4.8.0 → t_sql-4.9.0}/pyproject.toml +1 -1
  3. {t_sql-4.8.0 → t_sql-4.9.0}/tests/test_query_builder.py +101 -0
  4. {t_sql-4.8.0 → t_sql-4.9.0}/tsql/query_builder.py +76 -0
  5. {t_sql-4.8.0 → t_sql-4.9.0}/.dockerignore +0 -0
  6. {t_sql-4.8.0 → t_sql-4.9.0}/.github/workflows/publish.yml +0 -0
  7. {t_sql-4.8.0 → t_sql-4.9.0}/.github/workflows/test.yml +0 -0
  8. {t_sql-4.8.0 → t_sql-4.9.0}/.gitignore +0 -0
  9. {t_sql-4.8.0 → t_sql-4.9.0}/Dockerfile +0 -0
  10. {t_sql-4.8.0 → t_sql-4.9.0}/LICENSE +0 -0
  11. {t_sql-4.8.0 → t_sql-4.9.0}/README.md +0 -0
  12. {t_sql-4.8.0 → t_sql-4.9.0}/compose.yaml +0 -0
  13. {t_sql-4.8.0 → t_sql-4.9.0}/context7.json +0 -0
  14. {t_sql-4.8.0 → t_sql-4.9.0}/pytest.ini +0 -0
  15. {t_sql-4.8.0 → t_sql-4.9.0}/tests/test_alembic_integration.py +0 -0
  16. {t_sql-4.8.0 → t_sql-4.9.0}/tests/test_asyncpg_integration.py +0 -0
  17. {t_sql-4.8.0 → t_sql-4.9.0}/tests/test_deep_nesting.py +0 -0
  18. {t_sql-4.8.0 → t_sql-4.9.0}/tests/test_different_object_types.py +0 -0
  19. {t_sql-4.8.0 → t_sql-4.9.0}/tests/test_error_messages.py +0 -0
  20. {t_sql-4.8.0 → t_sql-4.9.0}/tests/test_escaped.py +0 -0
  21. {t_sql-4.8.0 → t_sql-4.9.0}/tests/test_escaped_binary_hex.py +0 -0
  22. {t_sql-4.8.0 → t_sql-4.9.0}/tests/test_helper_functions.py +0 -0
  23. {t_sql-4.8.0 → t_sql-4.9.0}/tests/test_injection_edge_cases.py +0 -0
  24. {t_sql-4.8.0 → t_sql-4.9.0}/tests/test_injection_protection_validation.py +0 -0
  25. {t_sql-4.8.0 → t_sql-4.9.0}/tests/test_injections_for_escaped.py +0 -0
  26. {t_sql-4.8.0 → t_sql-4.9.0}/tests/test_like_patterns.py +0 -0
  27. {t_sql-4.8.0 → t_sql-4.9.0}/tests/test_mysql_integration.py +0 -0
  28. {t_sql-4.8.0 → t_sql-4.9.0}/tests/test_parameter_names.py +0 -0
  29. {t_sql-4.8.0 → t_sql-4.9.0}/tests/test_sqlalchemy_integration.py +0 -0
  30. {t_sql-4.8.0 → t_sql-4.9.0}/tests/test_sqlite_integration.py +0 -0
  31. {t_sql-4.8.0 → t_sql-4.9.0}/tests/test_string_based_builders.py +0 -0
  32. {t_sql-4.8.0 → t_sql-4.9.0}/tests/test_styles.py +0 -0
  33. {t_sql-4.8.0 → t_sql-4.9.0}/tests/test_template_in_builders.py +0 -0
  34. {t_sql-4.8.0 → t_sql-4.9.0}/tests/test_tsql.py +0 -0
  35. {t_sql-4.8.0 → t_sql-4.9.0}/tests/test_type_processor.py +0 -0
  36. {t_sql-4.8.0 → t_sql-4.9.0}/tsql/__init__.py +0 -0
  37. {t_sql-4.8.0 → t_sql-4.9.0}/tsql/row.py +0 -0
  38. {t_sql-4.8.0 → t_sql-4.9.0}/tsql/styles.py +0 -0
  39. {t_sql-4.8.0 → t_sql-4.9.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.8.0
3
+ Version: 4.9.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.8.0"
7
+ version = "4.9.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"
@@ -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