t-sql 4.6.0__tar.gz → 4.7.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 (38) hide show
  1. t_sql-4.6.0/README.md → t_sql-4.7.1/PKG-INFO +26 -0
  2. t_sql-4.6.0/PKG-INFO → t_sql-4.7.1/README.md +16 -10
  3. {t_sql-4.6.0 → t_sql-4.7.1}/pyproject.toml +1 -1
  4. {t_sql-4.6.0 → t_sql-4.7.1}/tests/test_alembic_integration.py +142 -1
  5. t_sql-4.7.1/tests/test_deep_nesting.py +535 -0
  6. {t_sql-4.6.0 → t_sql-4.7.1}/tests/test_sqlalchemy_integration.py +168 -0
  7. {t_sql-4.6.0 → t_sql-4.7.1}/tests/test_tsql.py +15 -4
  8. {t_sql-4.6.0 → t_sql-4.7.1}/tsql/__init__.py +3 -2
  9. {t_sql-4.6.0 → t_sql-4.7.1}/tsql/query_builder.py +6 -1
  10. {t_sql-4.6.0 → t_sql-4.7.1}/.dockerignore +0 -0
  11. {t_sql-4.6.0 → t_sql-4.7.1}/.github/workflows/publish.yml +0 -0
  12. {t_sql-4.6.0 → t_sql-4.7.1}/.github/workflows/test.yml +0 -0
  13. {t_sql-4.6.0 → t_sql-4.7.1}/.gitignore +0 -0
  14. {t_sql-4.6.0 → t_sql-4.7.1}/Dockerfile +0 -0
  15. {t_sql-4.6.0 → t_sql-4.7.1}/LICENSE +0 -0
  16. {t_sql-4.6.0 → t_sql-4.7.1}/compose.yaml +0 -0
  17. {t_sql-4.6.0 → t_sql-4.7.1}/context7.json +0 -0
  18. {t_sql-4.6.0 → t_sql-4.7.1}/pytest.ini +0 -0
  19. {t_sql-4.6.0 → t_sql-4.7.1}/tests/test_asyncpg_integration.py +0 -0
  20. {t_sql-4.6.0 → t_sql-4.7.1}/tests/test_different_object_types.py +0 -0
  21. {t_sql-4.6.0 → t_sql-4.7.1}/tests/test_error_messages.py +0 -0
  22. {t_sql-4.6.0 → t_sql-4.7.1}/tests/test_escaped.py +0 -0
  23. {t_sql-4.6.0 → t_sql-4.7.1}/tests/test_escaped_binary_hex.py +0 -0
  24. {t_sql-4.6.0 → t_sql-4.7.1}/tests/test_helper_functions.py +0 -0
  25. {t_sql-4.6.0 → t_sql-4.7.1}/tests/test_injection_edge_cases.py +0 -0
  26. {t_sql-4.6.0 → t_sql-4.7.1}/tests/test_injection_protection_validation.py +0 -0
  27. {t_sql-4.6.0 → t_sql-4.7.1}/tests/test_injections_for_escaped.py +0 -0
  28. {t_sql-4.6.0 → t_sql-4.7.1}/tests/test_mysql_integration.py +0 -0
  29. {t_sql-4.6.0 → t_sql-4.7.1}/tests/test_parameter_names.py +0 -0
  30. {t_sql-4.6.0 → t_sql-4.7.1}/tests/test_query_builder.py +0 -0
  31. {t_sql-4.6.0 → t_sql-4.7.1}/tests/test_sqlite_integration.py +0 -0
  32. {t_sql-4.6.0 → t_sql-4.7.1}/tests/test_string_based_builders.py +0 -0
  33. {t_sql-4.6.0 → t_sql-4.7.1}/tests/test_styles.py +0 -0
  34. {t_sql-4.6.0 → t_sql-4.7.1}/tests/test_template_in_builders.py +0 -0
  35. {t_sql-4.6.0 → t_sql-4.7.1}/tests/test_type_processor.py +0 -0
  36. {t_sql-4.6.0 → t_sql-4.7.1}/tsql/row.py +0 -0
  37. {t_sql-4.6.0 → t_sql-4.7.1}/tsql/styles.py +0 -0
  38. {t_sql-4.6.0 → t_sql-4.7.1}/tsql/type_processor.py +0 -0
@@ -1,3 +1,13 @@
1
+ Metadata-Version: 2.4
2
+ Name: t-sql
3
+ Version: 4.7.1
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).
@@ -105,6 +115,22 @@ sql, params = tsql.render(t"UPDATE users SET {values:as_set} WHERE id='abc123'")
105
115
  # ('UPDATE users SET name = ?, email = ? WHERE id='abc123'', ['joe', 'joe@example.com'])
106
116
  ```
107
117
 
118
+ #### Tuples for IN clauses
119
+
120
+ Use tuples to expand lists of values for SQL IN clauses:
121
+
122
+ ```python
123
+ # Convert list to tuple for IN clause
124
+ my_ids = ['123', '234', '531']
125
+ sql, params = tsql.render(t"SELECT * FROM mytable WHERE id IN {tuple(my_ids)}")
126
+ # ('SELECT * FROM mytable WHERE id IN (?, ?, ?)', ['123', '234', '531'])
127
+
128
+ # Or use a tuple directly
129
+ active_statuses = ('active', 'pending', 'approved')
130
+ sql, params = tsql.render(t"SELECT * FROM orders WHERE status IN {active_statuses}")
131
+ # ('SELECT * FROM orders WHERE status IN (?, ?, ?)', ['active', 'pending', 'approved'])
132
+ ```
133
+
108
134
  ### Helper Functions
109
135
 
110
136
  t-sql provides several convenience functions for common SQL operations:
@@ -1,13 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: t-sql
3
- Version: 4.6.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).
@@ -115,6 +105,22 @@ sql, params = tsql.render(t"UPDATE users SET {values:as_set} WHERE id='abc123'")
115
105
  # ('UPDATE users SET name = ?, email = ? WHERE id='abc123'', ['joe', 'joe@example.com'])
116
106
  ```
117
107
 
108
+ #### Tuples for IN clauses
109
+
110
+ Use tuples to expand lists of values for SQL IN clauses:
111
+
112
+ ```python
113
+ # Convert list to tuple for IN clause
114
+ my_ids = ['123', '234', '531']
115
+ sql, params = tsql.render(t"SELECT * FROM mytable WHERE id IN {tuple(my_ids)}")
116
+ # ('SELECT * FROM mytable WHERE id IN (?, ?, ?)', ['123', '234', '531'])
117
+
118
+ # Or use a tuple directly
119
+ active_statuses = ('active', 'pending', 'approved')
120
+ sql, params = tsql.render(t"SELECT * FROM orders WHERE status IN {active_statuses}")
121
+ # ('SELECT * FROM orders WHERE status IN (?, ?, ?)', ['active', 'pending', 'approved'])
122
+ ```
123
+
118
124
  ### Helper Functions
119
125
 
120
126
  t-sql provides several convenience functions for common SQL operations:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "t-sql"
7
- version = "4.6.0"
7
+ version = "4.7.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"
@@ -465,4 +465,145 @@ def test_alembic_detects_table_comment(temp_alembic_env):
465
465
  assert len(add_table_ops) == 1
466
466
 
467
467
  table = add_table_ops[0][1]
468
- assert table.comment == 'Application configuration'
468
+ assert table.comment == 'Application configuration'
469
+
470
+
471
+ def test_alembic_detects_indexes(temp_alembic_env):
472
+ """Test that Alembic autogenerate detects indexes from indexes attribute"""
473
+ from sqlalchemy import Index
474
+
475
+ temp_dir, alembic_ini = temp_alembic_env
476
+ metadata = MetaData()
477
+ engine = create_engine("sqlite:///:memory:")
478
+
479
+ class Users(Table, table_name='users', metadata=metadata):
480
+ id = Column(String, primary_key=True)
481
+ email = Column(String, nullable=False)
482
+ username = Column(String)
483
+
484
+ indexes = [
485
+ Index('ix_users_email', 'email'),
486
+ Index('ix_users_username', 'username')
487
+ ]
488
+
489
+ cfg = Config(str(alembic_ini))
490
+ cfg.attributes['target_metadata'] = metadata
491
+ cfg.attributes['connection'] = engine
492
+
493
+ with engine.begin() as connection:
494
+ mc = MigrationContext.configure(connection)
495
+ diff = compare_metadata(mc, metadata)
496
+
497
+ # Should detect the new table
498
+ add_table_ops = [op for op in diff if op[0] == 'add_table']
499
+ assert len(add_table_ops) == 1
500
+
501
+ table = add_table_ops[0][1]
502
+ assert table.name == 'users'
503
+
504
+ # Alembic detects indexes as separate add_index operations
505
+ add_index_ops = [op for op in diff if op[0] == 'add_index']
506
+ assert len(add_index_ops) == 2
507
+
508
+ idx_names = {op[1].name for op in add_index_ops}
509
+ assert idx_names == {'ix_users_email', 'ix_users_username'}
510
+
511
+
512
+ def test_alembic_detects_gin_indexes(temp_alembic_env):
513
+ """Test that Alembic autogenerate detects GIN indexes with PostgreSQL options"""
514
+ from sqlalchemy import Index
515
+
516
+ temp_dir, alembic_ini = temp_alembic_env
517
+ metadata = MetaData()
518
+ engine = create_engine("sqlite:///:memory:")
519
+
520
+ class Documents(Table, table_name='documents', metadata=metadata):
521
+ id = Column(String, primary_key=True)
522
+ title = Column(String)
523
+ content = Column(String)
524
+
525
+ indexes = [
526
+ Index('ix_documents_title_gin', 'title',
527
+ postgresql_using='gin',
528
+ postgresql_ops={'title': 'gin_trgm_ops'}),
529
+ Index('ix_documents_content_gin', 'content',
530
+ postgresql_using='gin',
531
+ postgresql_ops={'content': 'gin_trgm_ops'})
532
+ ]
533
+
534
+ cfg = Config(str(alembic_ini))
535
+ cfg.attributes['target_metadata'] = metadata
536
+ cfg.attributes['connection'] = engine
537
+
538
+ with engine.begin() as connection:
539
+ mc = MigrationContext.configure(connection)
540
+ diff = compare_metadata(mc, metadata)
541
+
542
+ add_table_ops = [op for op in diff if op[0] == 'add_table']
543
+ assert len(add_table_ops) == 1
544
+
545
+ # Alembic detects indexes as separate add_index operations
546
+ add_index_ops = [op for op in diff if op[0] == 'add_index']
547
+ assert len(add_index_ops) == 2
548
+
549
+ idx_names = {op[1].name for op in add_index_ops}
550
+ assert 'ix_documents_title_gin' in idx_names
551
+ assert 'ix_documents_content_gin' in idx_names
552
+
553
+ # Verify PostgreSQL-specific options are preserved
554
+ for op in add_index_ops:
555
+ idx = op[1]
556
+ if idx.name == 'ix_documents_title_gin':
557
+ assert idx.dialect_options['postgresql']['using'] == 'gin'
558
+ assert idx.dialect_options['postgresql']['ops'] == {'title': 'gin_trgm_ops'}
559
+ elif idx.name == 'ix_documents_content_gin':
560
+ assert idx.dialect_options['postgresql']['using'] == 'gin'
561
+ assert idx.dialect_options['postgresql']['ops'] == {'content': 'gin_trgm_ops'}
562
+
563
+
564
+ def test_alembic_detects_indexes_and_constraints(temp_alembic_env):
565
+ """Test that Alembic autogenerate detects both indexes and constraints together"""
566
+ from sqlalchemy import Index, UniqueConstraint
567
+
568
+ temp_dir, alembic_ini = temp_alembic_env
569
+ metadata = MetaData()
570
+ engine = create_engine("sqlite:///:memory:")
571
+
572
+ class Products(Table, table_name='products', metadata=metadata):
573
+ id = Column(String, primary_key=True)
574
+ sku = Column(String, nullable=False)
575
+ name = Column(String)
576
+ category = Column(String)
577
+
578
+ constraints = [
579
+ UniqueConstraint('sku', name='uq_products_sku')
580
+ ]
581
+
582
+ indexes = [
583
+ Index('ix_products_category', 'category'),
584
+ Index('ix_products_name', 'name')
585
+ ]
586
+
587
+ cfg = Config(str(alembic_ini))
588
+ cfg.attributes['target_metadata'] = metadata
589
+ cfg.attributes['connection'] = engine
590
+
591
+ with engine.begin() as connection:
592
+ mc = MigrationContext.configure(connection)
593
+ diff = compare_metadata(mc, metadata)
594
+
595
+ add_table_ops = [op for op in diff if op[0] == 'add_table']
596
+ assert len(add_table_ops) == 1
597
+
598
+ table = add_table_ops[0][1]
599
+
600
+ # Verify constraint is in the table definition
601
+ unique_constraints = [c for c in table.constraints if isinstance(c, UniqueConstraint)]
602
+ assert len(unique_constraints) == 1
603
+ assert unique_constraints[0].name == 'uq_products_sku'
604
+
605
+ # Verify indexes are detected as separate operations
606
+ add_index_ops = [op for op in diff if op[0] == 'add_index']
607
+ assert len(add_index_ops) == 2
608
+ idx_names = {op[1].name for op in add_index_ops}
609
+ assert idx_names == {'ix_products_category', 'ix_products_name'}
@@ -0,0 +1,535 @@
1
+ """Tests for deep template nesting, CTEs, subqueries, and complex query composition"""
2
+ import pytest
3
+ import tsql
4
+ from tsql import TSQL
5
+ from tsql.query_builder import Table, Column
6
+ from tsql import styles
7
+
8
+
9
+ class Users(Table):
10
+ id: Column
11
+ username: Column
12
+ email: Column
13
+ created_at: Column
14
+
15
+
16
+ class Posts(Table):
17
+ id: Column
18
+ user_id: Column
19
+ title: Column
20
+ content: Column
21
+
22
+
23
+ class Comments(Table):
24
+ id: Column
25
+ post_id: Column
26
+ user_id: Column
27
+ content: Column
28
+
29
+
30
+ def test_simple_subquery_in_where():
31
+ """Test basic subquery nesting with t-strings"""
32
+ min_id = 100
33
+ subquery = t"SELECT MAX(id) FROM users WHERE id > {min_id}"
34
+ query = t"SELECT * FROM posts WHERE user_id = ({subquery})"
35
+
36
+ sql, params = tsql.render(query, style=styles.QMARK)
37
+
38
+ assert sql == "SELECT * FROM posts WHERE user_id = (SELECT MAX(id) FROM users WHERE id > ?)"
39
+ assert params == [100]
40
+
41
+
42
+ def test_subquery_in_select():
43
+ """Test subquery in SELECT clause"""
44
+ user_id = 42
45
+ subquery = t"SELECT COUNT(*) FROM posts WHERE user_id = {user_id}"
46
+ query = t"SELECT username, ({subquery}) as post_count FROM users WHERE id = {user_id}"
47
+
48
+ sql, params = tsql.render(query, style=styles.QMARK)
49
+
50
+ assert "SELECT username, (SELECT COUNT(*) FROM posts WHERE user_id = ?) as post_count" in sql
51
+ assert params == [42, 42]
52
+
53
+
54
+ def test_nested_subqueries_three_levels():
55
+ """Test three levels of nested subqueries"""
56
+ user_id = 123
57
+
58
+ # Level 3: innermost query
59
+ innermost = t"SELECT post_id FROM comments WHERE user_id = {user_id}"
60
+ # Level 2: middle query
61
+ middle = t"SELECT user_id FROM posts WHERE id IN ({innermost})"
62
+ # Level 1: outer query
63
+ outer = t"SELECT username FROM users WHERE id IN ({middle})"
64
+
65
+ sql, params = tsql.render(outer, style=styles.QMARK)
66
+
67
+ assert "SELECT username FROM users WHERE id IN (SELECT user_id FROM posts WHERE id IN (SELECT post_id FROM comments WHERE user_id = ?))" in sql
68
+ assert params == [123]
69
+
70
+
71
+ def test_simple_cte():
72
+ """Test basic CTE (Common Table Expression)"""
73
+ min_posts = 5
74
+ cte = t"WITH active_users AS (SELECT user_id, COUNT(*) as post_count FROM posts GROUP BY user_id HAVING COUNT(*) > {min_posts})"
75
+ query = t"{cte} SELECT u.username FROM users u JOIN active_users au ON u.id = au.user_id"
76
+
77
+ sql, params = tsql.render(query, style=styles.QMARK)
78
+
79
+ assert "WITH active_users AS" in sql
80
+ assert "HAVING COUNT(*) > ?" in sql
81
+ assert params == [5]
82
+
83
+
84
+ def test_multiple_ctes():
85
+ """Test multiple CTEs chained together"""
86
+ min_posts = 10
87
+ min_comments = 5
88
+
89
+ cte1 = t"active_posters AS (SELECT user_id FROM posts GROUP BY user_id HAVING COUNT(*) > {min_posts})"
90
+ cte2 = t"active_commenters AS (SELECT user_id FROM comments GROUP BY user_id HAVING COUNT(*) > {min_comments})"
91
+ query = t"WITH {cte1}, {cte2} SELECT DISTINCT u.username FROM users u JOIN active_posters ap ON u.id = ap.user_id JOIN active_commenters ac ON u.id = ac.user_id"
92
+
93
+ sql, params = tsql.render(query, style=styles.QMARK)
94
+
95
+ assert "WITH active_posters AS" in sql
96
+ assert "active_commenters AS" in sql
97
+ assert params == [10, 5]
98
+
99
+
100
+ def test_cte_referencing_previous_cte():
101
+ """Test CTE that references another CTE"""
102
+ threshold = 100
103
+
104
+ cte1 = t"popular_posts AS (SELECT id, user_id FROM posts WHERE id > {threshold})"
105
+ cte2 = t"top_users AS (SELECT user_id, COUNT(*) as count FROM popular_posts GROUP BY user_id)"
106
+ query = t"WITH {cte1}, {cte2} SELECT u.username, tu.count FROM users u JOIN top_users tu ON u.id = tu.user_id"
107
+
108
+ sql, params = tsql.render(query, style=styles.QMARK)
109
+
110
+ assert "WITH popular_posts AS" in sql
111
+ assert "top_users AS" in sql
112
+ assert "FROM popular_posts" in sql
113
+ assert params == [threshold]
114
+
115
+
116
+ def test_query_builder_in_subquery():
117
+ """Test mixing query builder with t-string subquery"""
118
+ user_id = 42
119
+
120
+ # Use query builder for inner query
121
+ subquery = Users.select(Users.id).where(Users.username == "admin")
122
+
123
+ # Embed in t-string
124
+ outer = t"SELECT * FROM posts WHERE user_id IN ({subquery})"
125
+
126
+ sql, params = tsql.render(outer, style=styles.QMARK)
127
+
128
+ assert "SELECT users.id FROM users WHERE users.username = ?" in sql
129
+ assert "SELECT * FROM posts WHERE user_id IN" in sql
130
+ assert params == ["admin"]
131
+
132
+
133
+ def test_tstring_in_query_builder_where():
134
+ """Test t-string condition in query builder WHERE clause"""
135
+ max_id = 1000
136
+ username = "alice"
137
+
138
+ # T-string condition
139
+ condition = t"id < {max_id} OR username = {username}"
140
+
141
+ query = Users.select().where(condition)
142
+ sql, params = query.render(style=styles.QMARK)
143
+
144
+ assert "WHERE (id < ? OR username = ?)" in sql
145
+ assert params == [1000, "alice"]
146
+
147
+
148
+ def test_query_builder_with_subquery_builder():
149
+ """Test query builder with another query builder as subquery"""
150
+ # Inner query builder
151
+ active_users = Users.select(Users.id).where(Users.created_at > "2024-01-01")
152
+
153
+ # Outer query builder using inner as condition
154
+ posts_query = Posts.select().where(t"user_id IN ({active_users})")
155
+
156
+ sql, params = posts_query.render(style=styles.QMARK)
157
+
158
+ # SELECT with no columns specified defaults to SELECT *
159
+ assert "SELECT * FROM posts" in sql
160
+ assert "WHERE (user_id IN (SELECT users.id FROM users WHERE users.created_at > ?))" in sql
161
+ assert params == ["2024-01-01"]
162
+
163
+
164
+ def test_deeply_nested_mixed_composition():
165
+ """Test complex nesting mixing query builders and t-strings"""
166
+ # Level 4: query builder
167
+ comment_authors = Users.select(Users.id).where(Users.username == "alice")
168
+
169
+ # Level 3: t-string using query builder
170
+ commented_posts = t"SELECT post_id FROM comments WHERE user_id IN ({comment_authors})"
171
+
172
+ # Level 2: query builder using t-string
173
+ posts_with_comments = Posts.select(Posts.user_id).where(t"id IN ({commented_posts})")
174
+
175
+ # Level 1: final t-string query
176
+ final_query = t"SELECT username FROM users WHERE id IN ({posts_with_comments})"
177
+
178
+ sql, params = tsql.render(final_query, style=styles.QMARK)
179
+
180
+ # Verify the nesting structure exists
181
+ assert "SELECT username FROM users WHERE id IN" in sql
182
+ assert "SELECT posts.user_id FROM posts WHERE (id IN" in sql
183
+ assert "SELECT post_id FROM comments WHERE user_id IN" in sql
184
+ assert "SELECT users.id FROM users WHERE users.username = ?" in sql
185
+ assert params == ["alice"]
186
+
187
+
188
+ def test_cte_with_query_builder():
189
+ """Test CTE containing a query builder"""
190
+ # Build CTE content with query builder
191
+ active_users_query = Users.select(Users.id, Users.username).where(Users.created_at > "2024-01-01")
192
+
193
+ # Use in CTE
194
+ query = t"WITH active_users AS ({active_users_query}) SELECT au.username, COUNT(p.id) FROM active_users au LEFT JOIN posts p ON au.id = p.user_id GROUP BY au.username"
195
+
196
+ sql, params = tsql.render(query, style=styles.QMARK)
197
+
198
+ assert "WITH active_users AS (SELECT users.id, users.username FROM users WHERE users.created_at > ?)" in sql
199
+ assert params == ["2024-01-01"]
200
+
201
+
202
+ def test_cte_chain_with_mixed_types():
203
+ """Test chain of CTEs mixing query builders and t-strings"""
204
+ # First CTE uses query builder
205
+ cte1_query = Users.select(Users.id).where(Users.email != None)
206
+
207
+ # Second CTE uses t-string and references first
208
+ user_id = 100
209
+ cte2 = t"post_counts AS (SELECT user_id, COUNT(*) as count FROM posts WHERE user_id > {user_id} GROUP BY user_id)"
210
+
211
+ # Final query combines everything
212
+ query = t"WITH verified_users AS ({cte1_query}), {cte2} SELECT vu.id, pc.count FROM verified_users vu JOIN post_counts pc ON vu.id = pc.user_id"
213
+
214
+ sql, params = tsql.render(query, style=styles.QMARK)
215
+
216
+ # != None is correctly converted to IS NOT NULL (no parameter)
217
+ assert "WITH verified_users AS (SELECT users.id FROM users WHERE users.email IS NOT NULL)" in sql
218
+ assert "post_counts AS (SELECT user_id, COUNT(*) as count FROM posts" in sql
219
+ assert params == [100]
220
+
221
+
222
+ def test_correlated_subquery():
223
+ """Test correlated subquery where inner query references outer query"""
224
+ min_count = 5
225
+
226
+ # Correlated subquery - note the reference to outer 'u' table
227
+ query = t"""
228
+ SELECT u.username
229
+ FROM users u
230
+ WHERE (
231
+ SELECT COUNT(*)
232
+ FROM posts p
233
+ WHERE p.user_id = u.id
234
+ ) > {min_count}
235
+ """
236
+
237
+ sql, params = tsql.render(query, style=styles.QMARK)
238
+
239
+ assert "SELECT COUNT(*)" in sql
240
+ assert "WHERE p.user_id = u.id" in sql
241
+ assert ") > ?" in sql
242
+ assert params == [5]
243
+
244
+
245
+ def test_union_with_nested_queries():
246
+ """Test UNION with nested subqueries"""
247
+ active_threshold = 100
248
+ admin_role = "admin"
249
+
250
+ query1 = t"SELECT id, username FROM users WHERE id > {active_threshold}"
251
+ query2 = Users.select(Users.id, Users.username).where(Users.email == admin_role)
252
+
253
+ union_query = t"{query1} UNION {query2}"
254
+
255
+ sql, params = tsql.render(union_query, style=styles.QMARK)
256
+
257
+ assert "SELECT id, username FROM users WHERE id > ?" in sql
258
+ assert "UNION SELECT users.id, users.username FROM users WHERE users.email = ?" in sql
259
+ assert params == [100, "admin"]
260
+
261
+
262
+ def test_exists_with_nested_query():
263
+ """Test EXISTS clause with nested query"""
264
+ user_id = 42
265
+
266
+ subquery = Posts.select(Posts.id).where(Posts.user_id == user_id)
267
+ query = t"SELECT username FROM users WHERE EXISTS ({subquery})"
268
+
269
+ sql, params = tsql.render(query, style=styles.QMARK)
270
+
271
+ assert "WHERE EXISTS (SELECT posts.id FROM posts WHERE posts.user_id = ?)" in sql
272
+ assert params == [42]
273
+
274
+
275
+ def test_window_function_with_subquery():
276
+ """Test window function with subquery in partition"""
277
+ min_id = 1000
278
+
279
+ query = t"""
280
+ SELECT
281
+ username,
282
+ ROW_NUMBER() OVER (
283
+ PARTITION BY (
284
+ SELECT COUNT(*) FROM posts WHERE user_id = users.id AND id > {min_id}
285
+ )
286
+ ) as rank
287
+ FROM users
288
+ """
289
+
290
+ sql, params = tsql.render(query, style=styles.QMARK)
291
+
292
+ assert "ROW_NUMBER() OVER" in sql
293
+ assert "PARTITION BY" in sql
294
+ assert params == [1000]
295
+
296
+
297
+ def test_case_expression_with_subqueries():
298
+ """Test CASE expression containing subqueries"""
299
+ threshold = 10
300
+
301
+ query = t"""
302
+ SELECT
303
+ username,
304
+ CASE
305
+ WHEN (SELECT COUNT(*) FROM posts WHERE user_id = users.id) > {threshold}
306
+ THEN 'active'
307
+ ELSE 'inactive'
308
+ END as status
309
+ FROM users
310
+ """
311
+
312
+ sql, params = tsql.render(query, style=styles.QMARK)
313
+
314
+ assert "CASE" in sql
315
+ assert "WHEN (SELECT COUNT(*)" in sql
316
+ assert "> ?" in sql
317
+ assert "THEN 'active'" in sql
318
+ assert params == [10]
319
+
320
+
321
+ def test_recursive_cte():
322
+ """Test recursive CTE pattern"""
323
+ start_id = 1
324
+
325
+ query = t"""
326
+ WITH RECURSIVE user_hierarchy AS (
327
+ SELECT id, username, 0 as level
328
+ FROM users
329
+ WHERE id = {start_id}
330
+
331
+ UNION ALL
332
+
333
+ SELECT u.id, u.username, uh.level + 1
334
+ FROM users u
335
+ JOIN user_hierarchy uh ON u.id = uh.id + 1
336
+ WHERE uh.level < 10
337
+ )
338
+ SELECT * FROM user_hierarchy
339
+ """
340
+
341
+ sql, params = tsql.render(query, style=styles.QMARK)
342
+
343
+ assert "WITH RECURSIVE user_hierarchy AS" in sql
344
+ assert "UNION ALL" in sql
345
+ assert params == [1]
346
+
347
+
348
+ def test_five_level_nesting():
349
+ """Stress test with five levels of nesting"""
350
+ val1, val2, val3, val4, val5 = 1, 2, 3, 4, 5
351
+
352
+ level5 = t"SELECT id FROM comments WHERE id > {val5}"
353
+ level4 = t"SELECT post_id FROM comments WHERE id IN ({level5}) AND user_id > {val4}"
354
+ level3 = t"SELECT user_id FROM posts WHERE id IN ({level4}) AND title LIKE {val3}"
355
+ level2 = t"SELECT id FROM users WHERE id IN ({level3}) AND email IS NOT NULL AND id > {val2}"
356
+ level1 = t"SELECT username FROM users WHERE id IN ({level2}) AND created_at IS NOT NULL AND id > {val1}"
357
+
358
+ sql, params = tsql.render(level1, style=styles.QMARK)
359
+
360
+ # Verify all values are captured (order is reversed due to depth-first processing)
361
+ assert params == [5, 4, 3, 2, 1]
362
+
363
+ # Verify nesting structure
364
+ assert sql.count("SELECT") == 5
365
+ assert sql.count("WHERE") == 5
366
+
367
+
368
+ def test_lateral_join_with_subquery():
369
+ """Test LATERAL join (PostgreSQL) with subquery"""
370
+ limit = 3
371
+
372
+ query = t"""
373
+ SELECT u.username, recent.title
374
+ FROM users u
375
+ LEFT JOIN LATERAL (
376
+ SELECT title
377
+ FROM posts
378
+ WHERE user_id = u.id
379
+ ORDER BY id DESC
380
+ LIMIT {limit}
381
+ ) recent ON true
382
+ """
383
+
384
+ sql, params = tsql.render(query, style=styles.QMARK)
385
+
386
+ assert "LEFT JOIN LATERAL" in sql
387
+ assert "LIMIT ?" in sql
388
+ assert params == [3]
389
+
390
+
391
+ def test_values_clause_with_nested_select():
392
+ """Test VALUES clause combined with nested SELECT"""
393
+ user_id = 42
394
+
395
+ subquery = t"SELECT MAX(id) FROM posts WHERE user_id = {user_id}"
396
+ query = t"""
397
+ INSERT INTO posts (user_id, title)
398
+ SELECT * FROM (
399
+ VALUES (({subquery}), 'New Post')
400
+ ) AS v(user_id, title)
401
+ """
402
+
403
+ sql, params = tsql.render(query, style=styles.QMARK)
404
+
405
+ assert "VALUES ((SELECT MAX(id) FROM posts WHERE user_id = ?), 'New Post')" in sql
406
+ assert params == [42]
407
+
408
+
409
+ def test_multiple_independent_subqueries():
410
+ """Test query with multiple independent subqueries at same level"""
411
+ threshold1 = 100
412
+ threshold2 = 50
413
+ date = "2024-01-01"
414
+
415
+ subquery1 = t"SELECT COUNT(*) FROM posts WHERE user_id = users.id AND id > {threshold1}"
416
+ subquery2 = t"SELECT COUNT(*) FROM comments WHERE user_id = users.id AND id > {threshold2}"
417
+
418
+ query = t"""
419
+ SELECT
420
+ username,
421
+ ({subquery1}) as post_count,
422
+ ({subquery2}) as comment_count
423
+ FROM users
424
+ WHERE created_at > {date}
425
+ """
426
+
427
+ sql, params = tsql.render(query, style=styles.QMARK)
428
+
429
+ assert sql.count("SELECT COUNT(*)") == 2
430
+ assert params == [100, 50, "2024-01-01"]
431
+
432
+
433
+ def test_array_agg_with_subquery():
434
+ """Test aggregate function with subquery (PostgreSQL syntax)"""
435
+ user_id = 42
436
+
437
+ query = t"""
438
+ SELECT
439
+ username,
440
+ ARRAY_AGG((SELECT title FROM posts WHERE id = p.post_id)) as post_titles
441
+ FROM users u
442
+ JOIN posts p ON u.id = p.user_id
443
+ WHERE u.id = {user_id}
444
+ GROUP BY username
445
+ """
446
+
447
+ sql, params = tsql.render(query, style=styles.QMARK)
448
+
449
+ assert "ARRAY_AGG((SELECT title FROM posts WHERE id = p.post_id))" in sql
450
+ assert params == [42]
451
+
452
+
453
+ def test_with_literal_identifiers_in_nested_queries():
454
+ """Test that :literal format spec works correctly in nested contexts"""
455
+ table_name = "users"
456
+ column_name = "username"
457
+ value = "alice"
458
+
459
+ subquery = t"SELECT id FROM {table_name:literal} WHERE {column_name:literal} = {value}"
460
+ query = t"SELECT * FROM posts WHERE user_id IN ({subquery})"
461
+
462
+ sql, params = tsql.render(query, style=styles.QMARK)
463
+
464
+ assert "SELECT id FROM users WHERE username = ?" in sql
465
+ assert params == [value]
466
+
467
+
468
+ def test_complex_real_world_analytics_query():
469
+ """Test realistic complex analytics query with multiple CTEs and nesting"""
470
+ start_date = "2024-01-01"
471
+ end_date = "2024-12-31"
472
+ min_engagement = 10
473
+
474
+ # CTE 1: Active users
475
+ active_users = Users.select(Users.id, Users.username).where(Users.created_at >= start_date)
476
+
477
+ # CTE 2: Engagement metrics (t-string)
478
+ engagement_cte = t"""
479
+ engagement AS (
480
+ SELECT
481
+ user_id,
482
+ COUNT(DISTINCT post_id) as posts_commented,
483
+ COUNT(*) as total_comments
484
+ FROM comments
485
+ WHERE user_id IN (SELECT id FROM active_users)
486
+ GROUP BY user_id
487
+ HAVING COUNT(*) > {min_engagement}
488
+ )
489
+ """
490
+
491
+ # CTE 3: Post stats (mixed)
492
+ post_count_subquery = t"SELECT user_id, COUNT(*) as post_count FROM posts WHERE user_id IN (SELECT id FROM active_users) GROUP BY user_id"
493
+
494
+ # Final query
495
+ final = t"""
496
+ WITH active_users AS ({active_users}),
497
+ {engagement_cte},
498
+ post_stats AS ({post_count_subquery})
499
+
500
+ SELECT
501
+ au.username,
502
+ COALESCE(ps.post_count, 0) as posts,
503
+ COALESCE(e.total_comments, 0) as comments,
504
+ COALESCE(e.posts_commented, 0) as posts_with_comments
505
+ FROM active_users au
506
+ LEFT JOIN post_stats ps ON au.id = ps.user_id
507
+ LEFT JOIN engagement e ON au.id = e.user_id
508
+ ORDER BY posts DESC, comments DESC
509
+ """
510
+
511
+ sql, params = tsql.render(final, style=styles.QMARK)
512
+
513
+ # Verify structure
514
+ assert "WITH active_users AS" in sql
515
+ assert "engagement AS" in sql
516
+ assert "post_stats AS" in sql
517
+ assert params == [start_date, min_engagement]
518
+
519
+ # Verify all CTEs are present and properly nested
520
+ assert sql.count("SELECT") >= 4 # Multiple selects across CTEs
521
+
522
+
523
+ def test_json_operations_with_subquery():
524
+ """Test JSON operations (PostgreSQL) with nested queries"""
525
+ user_id = 42
526
+ key = "metadata"
527
+
528
+ subquery = t"SELECT email FROM users WHERE id = {user_id}"
529
+ query = t"SELECT data->>'{key:literal}' as metadata FROM posts WHERE user_id = ({subquery})"
530
+
531
+ sql, params = tsql.render(query, style=styles.QMARK)
532
+
533
+ assert "data->>'metadata'" in sql
534
+ assert "WHERE user_id = (SELECT email FROM users WHERE id = ?)" in sql
535
+ assert params == [42]
@@ -420,3 +420,171 @@ def test_table_with_constraints_and_comment():
420
420
  sql, params = query.render()
421
421
  assert 'WHERE api_keys.user_id = ?' in sql
422
422
  assert params == ['user123']
423
+
424
+
425
+ def test_table_with_single_index():
426
+ """Test that a single index is properly added to SA table"""
427
+ from sqlalchemy import Index
428
+
429
+ metadata = MetaData()
430
+
431
+ class Users(Table, table_name='users', metadata=metadata):
432
+ id = Column(String, primary_key=True)
433
+ email = Column(String, nullable=False)
434
+ created_at = Column(Integer)
435
+
436
+ indexes = [
437
+ Index('ix_users_email', 'email')
438
+ ]
439
+
440
+ sa_table = metadata.tables['users']
441
+
442
+ # Verify index is present
443
+ assert len(sa_table.indexes) == 1
444
+ idx = list(sa_table.indexes)[0]
445
+ assert idx.name == 'ix_users_email'
446
+ assert set(c.name for c in idx.columns) == {'email'}
447
+
448
+ # Query builder still works
449
+ query = Users.select(Users.email)
450
+ sql, params = query.render()
451
+ assert 'SELECT users.email FROM users' in sql
452
+
453
+
454
+ def test_table_with_gin_index():
455
+ """Test that GIN index with PostgreSQL-specific options works"""
456
+ from sqlalchemy import Index
457
+
458
+ metadata = MetaData()
459
+
460
+ class Documents(Table, table_name='documents', metadata=metadata):
461
+ id = Column(String, primary_key=True)
462
+ title = Column(String)
463
+ content = Column(String)
464
+
465
+ indexes = [
466
+ Index('ix_documents_title_gin', 'title',
467
+ postgresql_using='gin',
468
+ postgresql_ops={'title': 'gin_trgm_ops'}),
469
+ Index('ix_documents_content_gin', 'content',
470
+ postgresql_using='gin',
471
+ postgresql_ops={'content': 'gin_trgm_ops'})
472
+ ]
473
+
474
+ sa_table = metadata.tables['documents']
475
+
476
+ # Verify both indexes are present
477
+ assert len(sa_table.indexes) == 2
478
+
479
+ idx_names = {idx.name for idx in sa_table.indexes}
480
+ assert 'ix_documents_title_gin' in idx_names
481
+ assert 'ix_documents_content_gin' in idx_names
482
+
483
+ # Verify PostgreSQL-specific options
484
+ for idx in sa_table.indexes:
485
+ if idx.name == 'ix_documents_title_gin':
486
+ assert idx.dialect_options['postgresql']['using'] == 'gin'
487
+ assert idx.dialect_options['postgresql']['ops'] == {'title': 'gin_trgm_ops'}
488
+ elif idx.name == 'ix_documents_content_gin':
489
+ assert idx.dialect_options['postgresql']['using'] == 'gin'
490
+ assert idx.dialect_options['postgresql']['ops'] == {'content': 'gin_trgm_ops'}
491
+
492
+
493
+ def test_table_with_multiple_indexes():
494
+ """Test that multiple indexes can be added together"""
495
+ from sqlalchemy import Index
496
+
497
+ metadata = MetaData()
498
+
499
+ class Posts(Table, table_name='posts', metadata=metadata):
500
+ id = Column(String, primary_key=True)
501
+ author_id = Column(String, nullable=False)
502
+ status = Column(String)
503
+ published_at = Column(Integer)
504
+
505
+ indexes = [
506
+ Index('ix_posts_author', 'author_id'),
507
+ Index('ix_posts_status', 'status'),
508
+ Index('ix_posts_author_status', 'author_id', 'status')
509
+ ]
510
+
511
+ sa_table = metadata.tables['posts']
512
+
513
+ # Verify all indexes are present
514
+ assert len(sa_table.indexes) == 3
515
+
516
+ idx_names = {idx.name for idx in sa_table.indexes}
517
+ assert idx_names == {'ix_posts_author', 'ix_posts_status', 'ix_posts_author_status'}
518
+
519
+ # Verify multi-column index
520
+ multi_idx = [idx for idx in sa_table.indexes if idx.name == 'ix_posts_author_status'][0]
521
+ assert set(c.name for c in multi_idx.columns) == {'author_id', 'status'}
522
+
523
+
524
+ def test_table_with_indexes_as_tuple():
525
+ """Test that indexes attribute works with tuple format"""
526
+ from sqlalchemy import Index
527
+
528
+ metadata = MetaData()
529
+
530
+ class Comments(Table, table_name='comments', metadata=metadata):
531
+ id = Column(Integer, primary_key=True)
532
+ post_id = Column(String)
533
+ user_id = Column(String)
534
+
535
+ indexes = (
536
+ Index('ix_comments_post', 'post_id'),
537
+ Index('ix_comments_user', 'user_id'),
538
+ )
539
+
540
+ sa_table = metadata.tables['comments']
541
+
542
+ # Verify indexes are present
543
+ assert len(sa_table.indexes) == 2
544
+ idx_names = {idx.name for idx in sa_table.indexes}
545
+ assert idx_names == {'ix_comments_post', 'ix_comments_user'}
546
+
547
+
548
+ def test_table_with_indexes_and_constraints():
549
+ """Test that indexes and constraints work together"""
550
+ from sqlalchemy import Index, UniqueConstraint, CheckConstraint
551
+
552
+ metadata = MetaData()
553
+
554
+ class Products(Table, table_name='products', metadata=metadata):
555
+ id = Column(String, primary_key=True)
556
+ sku = Column(String, nullable=False)
557
+ name = Column(String)
558
+ price = Column(Integer)
559
+ category = Column(String)
560
+
561
+ constraints = [
562
+ UniqueConstraint('sku', name='uq_products_sku'),
563
+ CheckConstraint('price > 0', name='ck_products_positive_price')
564
+ ]
565
+
566
+ indexes = [
567
+ Index('ix_products_category', 'category'),
568
+ Index('ix_products_name_gin', 'name',
569
+ postgresql_using='gin',
570
+ postgresql_ops={'name': 'gin_trgm_ops'})
571
+ ]
572
+
573
+ sa_table = metadata.tables['products']
574
+
575
+ # Verify constraints
576
+ unique_constraints = [c for c in sa_table.constraints if isinstance(c, UniqueConstraint)]
577
+ check_constraints = [c for c in sa_table.constraints if isinstance(c, CheckConstraint)]
578
+ assert len(unique_constraints) == 1
579
+ assert len(check_constraints) == 1
580
+
581
+ # Verify indexes
582
+ assert len(sa_table.indexes) == 2
583
+ idx_names = {idx.name for idx in sa_table.indexes}
584
+ assert idx_names == {'ix_products_category', 'ix_products_name_gin'}
585
+
586
+ # Query builder still works
587
+ query = Products.select().where(Products.category == 'electronics')
588
+ sql, params = query.render()
589
+ assert 'WHERE products.category = ?' in sql
590
+ assert params == ['electronics']
@@ -35,15 +35,26 @@ def test_merges_literals_using_exsiting_tstring():
35
35
  assert result._sql == 'hello there'
36
36
 
37
37
 
38
- def test_strips_literal_whitespace():
39
- result = tsql.render(t"SELECT \n * \n FROM table")
38
+ def test_strips_horizontal_whitespace():
39
+ # Horizontal whitespace (spaces/tabs) is collapsed, but newlines are preserved
40
+ result = tsql.render(t"SELECT * FROM table")
40
41
  assert result[0] == 'SELECT * FROM table'
41
42
 
42
43
 
44
+ def test_preserves_newlines_for_sql_comments():
45
+ # Newlines must be preserved so -- style SQL comments work correctly
46
+ query = t"""SELECT * FROM users
47
+ -- Filter by active status
48
+ WHERE active = true"""
49
+ result = tsql.render(query)
50
+ assert '-- Filter by active status\n' in result[0]
51
+ assert 'WHERE active = true' in result[0]
52
+
53
+
43
54
  def test_doesnt_strip_whitespace_in_values():
44
55
  user_input = 'Some string\nWith whitespace. With Formating that is \n just right'
45
- result = tsql.render(t'INSERT \n INTO table (vals) VALUES({user_input})')
46
- assert result[0] == f'INSERT INTO table (vals) VALUES(?)'
56
+ result = tsql.render(t'INSERT INTO table (vals) VALUES({user_input})')
57
+ assert result[0] == 'INSERT INTO table (vals) VALUES(?)'
47
58
  assert result[1] == [user_input]
48
59
 
49
60
 
@@ -9,8 +9,9 @@ from tsql.styles import ParamStyle, QMARK
9
9
 
10
10
  logger = logging.getLogger(__name__)
11
11
 
12
- # Pre-compile regex for whitespace collapsing to avoid cache lookup overhead
13
- _WHITESPACE_RE = re.compile(r'\s+')
12
+ # Pre-compile regex for horizontal whitespace collapsing (spaces/tabs only)
13
+ # Preserves newlines so that -- style SQL comments work correctly
14
+ _WHITESPACE_RE = re.compile(r'[ \t]+')
14
15
 
15
16
  if TYPE_CHECKING:
16
17
  from tsql.query_builder import QueryBuilder
@@ -410,12 +410,17 @@ class Table:
410
410
  if isinstance(table_constraints, tuple):
411
411
  table_constraints = list(table_constraints)
412
412
 
413
+ # Extract indexes from class attribute (supports both tuple and list)
414
+ table_indexes = getattr(cls, 'indexes', [])
415
+ if isinstance(table_indexes, tuple):
416
+ table_indexes = list(table_indexes)
417
+
413
418
  # Build keyword args for SATable
414
419
  table_kwargs = {'schema': schema}
415
420
  if comment is not None:
416
421
  table_kwargs['comment'] = comment
417
422
 
418
- cls._sa_table = SATable(cls.table_name, metadata, *sa_columns, *table_constraints, **table_kwargs)
423
+ cls._sa_table = SATable(cls.table_name, metadata, *sa_columns, *table_constraints, *table_indexes, **table_kwargs)
419
424
 
420
425
  # Add the ALL column for wildcard column selection
421
426
  cls.ALL = Column(cls.table_name, '*', schema=schema)
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