t-sql 4.8.0__tar.gz → 4.9.1__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.1}/PKG-INFO +1 -1
  2. {t_sql-4.8.0 → t_sql-4.9.1}/pyproject.toml +1 -1
  3. {t_sql-4.8.0 → t_sql-4.9.1}/tests/test_query_builder.py +114 -0
  4. {t_sql-4.8.0 → t_sql-4.9.1}/tsql/query_builder.py +83 -1
  5. {t_sql-4.8.0 → t_sql-4.9.1}/.dockerignore +0 -0
  6. {t_sql-4.8.0 → t_sql-4.9.1}/.github/workflows/publish.yml +0 -0
  7. {t_sql-4.8.0 → t_sql-4.9.1}/.github/workflows/test.yml +0 -0
  8. {t_sql-4.8.0 → t_sql-4.9.1}/.gitignore +0 -0
  9. {t_sql-4.8.0 → t_sql-4.9.1}/Dockerfile +0 -0
  10. {t_sql-4.8.0 → t_sql-4.9.1}/LICENSE +0 -0
  11. {t_sql-4.8.0 → t_sql-4.9.1}/README.md +0 -0
  12. {t_sql-4.8.0 → t_sql-4.9.1}/compose.yaml +0 -0
  13. {t_sql-4.8.0 → t_sql-4.9.1}/context7.json +0 -0
  14. {t_sql-4.8.0 → t_sql-4.9.1}/pytest.ini +0 -0
  15. {t_sql-4.8.0 → t_sql-4.9.1}/tests/test_alembic_integration.py +0 -0
  16. {t_sql-4.8.0 → t_sql-4.9.1}/tests/test_asyncpg_integration.py +0 -0
  17. {t_sql-4.8.0 → t_sql-4.9.1}/tests/test_deep_nesting.py +0 -0
  18. {t_sql-4.8.0 → t_sql-4.9.1}/tests/test_different_object_types.py +0 -0
  19. {t_sql-4.8.0 → t_sql-4.9.1}/tests/test_error_messages.py +0 -0
  20. {t_sql-4.8.0 → t_sql-4.9.1}/tests/test_escaped.py +0 -0
  21. {t_sql-4.8.0 → t_sql-4.9.1}/tests/test_escaped_binary_hex.py +0 -0
  22. {t_sql-4.8.0 → t_sql-4.9.1}/tests/test_helper_functions.py +0 -0
  23. {t_sql-4.8.0 → t_sql-4.9.1}/tests/test_injection_edge_cases.py +0 -0
  24. {t_sql-4.8.0 → t_sql-4.9.1}/tests/test_injection_protection_validation.py +0 -0
  25. {t_sql-4.8.0 → t_sql-4.9.1}/tests/test_injections_for_escaped.py +0 -0
  26. {t_sql-4.8.0 → t_sql-4.9.1}/tests/test_like_patterns.py +0 -0
  27. {t_sql-4.8.0 → t_sql-4.9.1}/tests/test_mysql_integration.py +0 -0
  28. {t_sql-4.8.0 → t_sql-4.9.1}/tests/test_parameter_names.py +0 -0
  29. {t_sql-4.8.0 → t_sql-4.9.1}/tests/test_sqlalchemy_integration.py +0 -0
  30. {t_sql-4.8.0 → t_sql-4.9.1}/tests/test_sqlite_integration.py +0 -0
  31. {t_sql-4.8.0 → t_sql-4.9.1}/tests/test_string_based_builders.py +0 -0
  32. {t_sql-4.8.0 → t_sql-4.9.1}/tests/test_styles.py +0 -0
  33. {t_sql-4.8.0 → t_sql-4.9.1}/tests/test_template_in_builders.py +0 -0
  34. {t_sql-4.8.0 → t_sql-4.9.1}/tests/test_tsql.py +0 -0
  35. {t_sql-4.8.0 → t_sql-4.9.1}/tests/test_type_processor.py +0 -0
  36. {t_sql-4.8.0 → t_sql-4.9.1}/tsql/__init__.py +0 -0
  37. {t_sql-4.8.0 → t_sql-4.9.1}/tsql/row.py +0 -0
  38. {t_sql-4.8.0 → t_sql-4.9.1}/tsql/styles.py +0 -0
  39. {t_sql-4.8.0 → t_sql-4.9.1}/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.1
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.1"
8
8
  description = "Safe SQL. SQL queries for python t-strings (PEP 750)"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.14"
@@ -116,6 +116,19 @@ def test_select_specific_columns():
116
116
  assert params == []
117
117
 
118
118
 
119
+ def test_multiple_select_calls_append():
120
+ """Test that multiple .select() calls append columns instead of replacing them"""
121
+ query = Users.select(Users.id)
122
+ query.select(Users.username, Users.email)
123
+ sql, params = query.render()
124
+
125
+ # All three columns should be present
126
+ assert 'users.id' in sql
127
+ assert 'users.username' in sql
128
+ assert 'users.email' in sql
129
+ assert params == []
130
+
131
+
119
132
  def test_select_with_where():
120
133
  """Test SELECT with WHERE clause"""
121
134
  query = Users.select(Users.id, Users.username).where(Users.id == 5)
@@ -1431,3 +1444,104 @@ def test_nested_querybuilder_with_different_styles():
1431
1444
  assert len(params) == 2
1432
1445
  assert 42 in params.values() and 5 in params.values()
1433
1446
  assert 'SELECT posts.user_id FROM posts WHERE posts.id = :' in sql
1447
+
1448
+
1449
+ # CTE Tests
1450
+
1451
+ def test_basic_cte():
1452
+ """Test basic CTE with query builder"""
1453
+ from tsql.query_builder import SelectQueryBuilder
1454
+
1455
+ query = (
1456
+ SelectQueryBuilder.from_table('active_users')
1457
+ .with_cte('active_users', Users.select(Users.id, Users.username).where(Users.email == 'test@example.com'))
1458
+ .select('id', 'username')
1459
+ )
1460
+
1461
+ sql, params = query.render()
1462
+ assert sql == "WITH active_users AS (SELECT users.id, users.username FROM users WHERE users.email = ?) SELECT id, username FROM active_users"
1463
+ assert params == ['test@example.com']
1464
+
1465
+
1466
+ def test_cte_with_tstring():
1467
+ """Test CTE with raw t-string query"""
1468
+ from tsql.query_builder import SelectQueryBuilder
1469
+
1470
+ query = (
1471
+ SelectQueryBuilder.from_table('counts')
1472
+ .with_cte('counts', t'SELECT COUNT(*) as total FROM users')
1473
+ .select('total')
1474
+ )
1475
+
1476
+ sql, _ = query.render()
1477
+ assert "WITH counts AS (SELECT COUNT(*) as total FROM users)" in sql
1478
+ assert "SELECT total FROM counts" in sql
1479
+
1480
+
1481
+ def test_multiple_ctes():
1482
+ """Test chaining multiple CTEs"""
1483
+ from tsql.query_builder import SelectQueryBuilder
1484
+
1485
+ query = (
1486
+ SelectQueryBuilder.from_table('filtered')
1487
+ .with_cte('jennifers', Users.select(Users.id, Users.username).where(Users.username == 'Jennifer'))
1488
+ .with_cte('filtered', t'SELECT id FROM jennifers WHERE id > 18')
1489
+ )
1490
+
1491
+ sql, _ = query.render()
1492
+ assert "WITH jennifers AS" in sql
1493
+ assert ", filtered AS" in sql
1494
+ assert "SELECT * FROM filtered" in sql
1495
+
1496
+
1497
+ def test_recursive_cte():
1498
+ """Test recursive CTE with t-string"""
1499
+ from tsql.query_builder import SelectQueryBuilder
1500
+
1501
+ query = (
1502
+ SelectQueryBuilder.from_table('category_tree')
1503
+ .with_cte('category_tree', t'''
1504
+ SELECT id, name, parent_id, 1 as level
1505
+ FROM categories
1506
+ WHERE parent_id IS NULL
1507
+ UNION ALL
1508
+ SELECT c.id, c.name, c.parent_id, ct.level + 1
1509
+ FROM categories c
1510
+ JOIN category_tree ct ON c.parent_id = ct.id
1511
+ ''', recursive=True)
1512
+ )
1513
+
1514
+ sql, _ = query.render()
1515
+ assert sql.startswith("WITH RECURSIVE category_tree AS")
1516
+ assert "SELECT * FROM category_tree" in sql
1517
+
1518
+
1519
+ def test_cte_name_validation():
1520
+ """Test CTE name is validated as identifier"""
1521
+ from tsql.query_builder import SelectQueryBuilder
1522
+ import pytest
1523
+
1524
+ query = SelectQueryBuilder.from_table('test')
1525
+
1526
+ with pytest.raises(ValueError, match="Invalid CTE name"):
1527
+ query.with_cte('invalid-name', t'SELECT 1')
1528
+
1529
+ with pytest.raises(ValueError, match="Invalid CTE name"):
1530
+ query.with_cte('123start', t'SELECT 1')
1531
+
1532
+
1533
+ def test_multiple_recursive_ctes():
1534
+ """Test multiple CTEs where one is recursive"""
1535
+ from tsql.query_builder import SelectQueryBuilder
1536
+
1537
+ query = (
1538
+ SelectQueryBuilder.from_table('result')
1539
+ .with_cte('normal', t'SELECT id FROM users')
1540
+ .with_cte('tree', t'SELECT id FROM tree UNION ALL SELECT id+1 FROM tree WHERE id < 10', recursive=True)
1541
+ )
1542
+
1543
+ sql, _ = query.render()
1544
+ # Should use WITH RECURSIVE if ANY CTE is recursive
1545
+ assert sql.startswith("WITH RECURSIVE")
1546
+ assert "normal AS" in sql
1547
+ 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':
@@ -1200,7 +1201,13 @@ class SelectQueryBuilder(QueryBuilder):
1200
1201
  # No columns specified selects all (SELECT *)
1201
1202
  users.select()
1202
1203
  """
1203
- self._columns = list(columns) if columns else None
1204
+ if columns:
1205
+ if self._columns is None:
1206
+ self._columns = []
1207
+ self._columns.extend(columns)
1208
+ else:
1209
+ # Calling select() with no args resets to SELECT *
1210
+ self._columns = None
1204
1211
  return self
1205
1212
 
1206
1213
  def where(self, condition: Union[Condition, Template]) -> 'SelectQueryBuilder':
@@ -1283,10 +1290,85 @@ class SelectQueryBuilder(QueryBuilder):
1283
1290
  self._offset_value = n
1284
1291
  return self
1285
1292
 
1293
+ def with_cte(self, name: str,
1294
+ query: Union[Template, TSQL, 'SelectQueryBuilder'],
1295
+ recursive: bool = False) -> 'SelectQueryBuilder':
1296
+ """Add a CTE to this query's WITH clause.
1297
+
1298
+ Multiple CTEs can be chained by calling this method multiple times.
1299
+
1300
+ Args:
1301
+ name: CTE name (validated as valid identifier)
1302
+ query: The CTE query (SelectQueryBuilder, t-string Template, or TSQL)
1303
+ recursive: Whether this CTE is recursive (adds RECURSIVE keyword)
1304
+
1305
+ Returns:
1306
+ Self for method chaining
1307
+
1308
+ Example:
1309
+ # Basic CTE
1310
+ query = (
1311
+ SelectQueryBuilder.from_table('active_users')
1312
+ .with_cte('active_users', Users.select().where(Users.active == True))
1313
+ .select('id', 'name')
1314
+ )
1315
+
1316
+ # Multiple CTEs
1317
+ query = (
1318
+ SelectQueryBuilder.from_table('filtered')
1319
+ .with_cte('jennifers', Users.select().where(...))
1320
+ .with_cte('filtered', t'SELECT id FROM jennifers WHERE age > 18')
1321
+ .select('*')
1322
+ )
1323
+
1324
+ # Recursive CTE
1325
+ query = (
1326
+ SelectQueryBuilder.from_table('tree')
1327
+ .with_cte('tree', t'''
1328
+ SELECT id, name, parent_id FROM categories WHERE parent_id IS NULL
1329
+ UNION ALL
1330
+ SELECT c.id, c.name, c.parent_id FROM categories c
1331
+ JOIN tree t ON c.parent_id = t.id
1332
+ ''', recursive=True)
1333
+ .select('*')
1334
+ )
1335
+ """
1336
+ if not name.isidentifier():
1337
+ raise ValueError(f"Invalid CTE name: {name!r}. Must be a valid Python identifier.")
1338
+
1339
+ self._ctes.append((name, query, recursive))
1340
+ return self
1341
+
1286
1342
  def to_tsql(self) -> TSQL:
1287
1343
  """Build the final TSQL object"""
1288
1344
  parts: List[Template] = []
1289
1345
 
1346
+ # Render CTEs if present
1347
+ if self._ctes:
1348
+ has_recursive = any(recursive for _, _, recursive in self._ctes)
1349
+ cte_parts = []
1350
+
1351
+ for name, query, _ in self._ctes:
1352
+ # Convert CTE query to TSQL
1353
+ if hasattr(query, 'to_tsql'):
1354
+ cte_sql = query.to_tsql()
1355
+ elif isinstance(query, Template):
1356
+ cte_sql = TSQL(query)
1357
+ else:
1358
+ cte_sql = query
1359
+
1360
+ # Render as: cte_name AS (query)
1361
+ cte_parts.append(t'{name:literal} AS ({cte_sql})')
1362
+
1363
+ # Join all CTEs with commas
1364
+ cte_clause = t_join(t', ', cte_parts)
1365
+
1366
+ # Add WITH or WITH RECURSIVE
1367
+ if has_recursive:
1368
+ parts.append(t'WITH RECURSIVE {cte_clause}')
1369
+ else:
1370
+ parts.append(t'WITH {cte_clause}')
1371
+
1290
1372
  if self._columns:
1291
1373
  # Build column list, handling Column objects, Template (t-string) objects, and strings
1292
1374
  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