t-sql 4.7.1__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.7.1/README.md → t_sql-4.9.0/PKG-INFO +49 -0
  2. t_sql-4.7.1/PKG-INFO → t_sql-4.9.0/README.md +39 -10
  3. {t_sql-4.7.1 → t_sql-4.9.0}/pyproject.toml +1 -1
  4. {t_sql-4.7.1 → t_sql-4.9.0}/tests/test_asyncpg_integration.py +102 -1
  5. t_sql-4.9.0/tests/test_like_patterns.py +282 -0
  6. {t_sql-4.7.1 → t_sql-4.9.0}/tests/test_query_builder.py +101 -0
  7. {t_sql-4.7.1 → t_sql-4.9.0}/tests/test_sqlite_integration.py +80 -0
  8. {t_sql-4.7.1 → t_sql-4.9.0}/tsql/__init__.py +27 -0
  9. {t_sql-4.7.1 → t_sql-4.9.0}/tsql/query_builder.py +76 -0
  10. {t_sql-4.7.1 → t_sql-4.9.0}/.dockerignore +0 -0
  11. {t_sql-4.7.1 → t_sql-4.9.0}/.github/workflows/publish.yml +0 -0
  12. {t_sql-4.7.1 → t_sql-4.9.0}/.github/workflows/test.yml +0 -0
  13. {t_sql-4.7.1 → t_sql-4.9.0}/.gitignore +0 -0
  14. {t_sql-4.7.1 → t_sql-4.9.0}/Dockerfile +0 -0
  15. {t_sql-4.7.1 → t_sql-4.9.0}/LICENSE +0 -0
  16. {t_sql-4.7.1 → t_sql-4.9.0}/compose.yaml +0 -0
  17. {t_sql-4.7.1 → t_sql-4.9.0}/context7.json +0 -0
  18. {t_sql-4.7.1 → t_sql-4.9.0}/pytest.ini +0 -0
  19. {t_sql-4.7.1 → t_sql-4.9.0}/tests/test_alembic_integration.py +0 -0
  20. {t_sql-4.7.1 → t_sql-4.9.0}/tests/test_deep_nesting.py +0 -0
  21. {t_sql-4.7.1 → t_sql-4.9.0}/tests/test_different_object_types.py +0 -0
  22. {t_sql-4.7.1 → t_sql-4.9.0}/tests/test_error_messages.py +0 -0
  23. {t_sql-4.7.1 → t_sql-4.9.0}/tests/test_escaped.py +0 -0
  24. {t_sql-4.7.1 → t_sql-4.9.0}/tests/test_escaped_binary_hex.py +0 -0
  25. {t_sql-4.7.1 → t_sql-4.9.0}/tests/test_helper_functions.py +0 -0
  26. {t_sql-4.7.1 → t_sql-4.9.0}/tests/test_injection_edge_cases.py +0 -0
  27. {t_sql-4.7.1 → t_sql-4.9.0}/tests/test_injection_protection_validation.py +0 -0
  28. {t_sql-4.7.1 → t_sql-4.9.0}/tests/test_injections_for_escaped.py +0 -0
  29. {t_sql-4.7.1 → t_sql-4.9.0}/tests/test_mysql_integration.py +0 -0
  30. {t_sql-4.7.1 → t_sql-4.9.0}/tests/test_parameter_names.py +0 -0
  31. {t_sql-4.7.1 → t_sql-4.9.0}/tests/test_sqlalchemy_integration.py +0 -0
  32. {t_sql-4.7.1 → t_sql-4.9.0}/tests/test_string_based_builders.py +0 -0
  33. {t_sql-4.7.1 → t_sql-4.9.0}/tests/test_styles.py +0 -0
  34. {t_sql-4.7.1 → t_sql-4.9.0}/tests/test_template_in_builders.py +0 -0
  35. {t_sql-4.7.1 → t_sql-4.9.0}/tests/test_tsql.py +0 -0
  36. {t_sql-4.7.1 → t_sql-4.9.0}/tests/test_type_processor.py +0 -0
  37. {t_sql-4.7.1 → t_sql-4.9.0}/tsql/row.py +0 -0
  38. {t_sql-4.7.1 → t_sql-4.9.0}/tsql/styles.py +0 -0
  39. {t_sql-4.7.1 → t_sql-4.9.0}/tsql/type_processor.py +0 -0
@@ -1,3 +1,13 @@
1
+ Metadata-Version: 2.4
2
+ Name: t-sql
3
+ Version: 4.9.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).
@@ -105,6 +115,45 @@ 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
+ #### LIKE Pattern Matching
119
+
120
+ **Safe pattern matching with automatic wildcard escaping**:
121
+
122
+ ```python
123
+ # Contains search (%value%)
124
+ search = "john"
125
+ sql, params = tsql.render(t"SELECT * FROM users WHERE name ILIKE {search:%like%}")
126
+ # ('SELECT * FROM users WHERE name ILIKE ? ESCAPE '\\'', ['%john%'])
127
+
128
+ # Prefix search (value% - starts with)
129
+ prefix = "admin"
130
+ sql, params = tsql.render(t"SELECT * FROM users WHERE username LIKE {prefix:like%}")
131
+ # ('SELECT * FROM users WHERE username LIKE ? ESCAPE '\\'', ['admin%'])
132
+
133
+ # Suffix search (%value - ends with)
134
+ domain = "@gmail.com"
135
+ sql, params = tsql.render(t"SELECT * FROM users WHERE email LIKE {domain:%like}")
136
+ # ('SELECT * FROM users WHERE email LIKE ? ESCAPE '\\'', ['%@gmail.com'])
137
+ ```
138
+
139
+ **Security**: All LIKE format specs automatically escape `%`, `_`, and `\` wildcards in user input to prevent injection attacks:
140
+
141
+ # Wildcards in data are escaped
142
+ search = "50%_discount"
143
+ sql, params = tsql.render(t"SELECT * FROM products WHERE name LIKE {search:%like%}")
144
+ # ('SELECT * FROM products WHERE name LIKE ? ESCAPE '\\'', ['%50\\%\\_discount%'])
145
+ # Matches the literal string "50%_discount", not "50X" or "50Xdiscount"
146
+ ```
147
+
148
+ **For controlled values where you WANT wildcards**, build the pattern manually without format specs:
149
+
150
+ ```python
151
+ # Developer-controlled pattern (wildcards intentional)
152
+ pattern = f"%{category}%"
153
+ sql, params = tsql.render(t"SELECT * FROM products WHERE tags LIKE {pattern}")
154
+ # No escaping - % and _ work as wildcards
155
+ ```
156
+
108
157
  #### Tuples for IN clauses
109
158
 
110
159
  Use tuples to expand lists of values for SQL IN clauses:
@@ -1,13 +1,3 @@
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
-
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,45 @@ 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
+ #### LIKE Pattern Matching
109
+
110
+ **Safe pattern matching with automatic wildcard escaping**:
111
+
112
+ ```python
113
+ # Contains search (%value%)
114
+ search = "john"
115
+ sql, params = tsql.render(t"SELECT * FROM users WHERE name ILIKE {search:%like%}")
116
+ # ('SELECT * FROM users WHERE name ILIKE ? ESCAPE '\\'', ['%john%'])
117
+
118
+ # Prefix search (value% - starts with)
119
+ prefix = "admin"
120
+ sql, params = tsql.render(t"SELECT * FROM users WHERE username LIKE {prefix:like%}")
121
+ # ('SELECT * FROM users WHERE username LIKE ? ESCAPE '\\'', ['admin%'])
122
+
123
+ # Suffix search (%value - ends with)
124
+ domain = "@gmail.com"
125
+ sql, params = tsql.render(t"SELECT * FROM users WHERE email LIKE {domain:%like}")
126
+ # ('SELECT * FROM users WHERE email LIKE ? ESCAPE '\\'', ['%@gmail.com'])
127
+ ```
128
+
129
+ **Security**: All LIKE format specs automatically escape `%`, `_`, and `\` wildcards in user input to prevent injection attacks:
130
+
131
+ # Wildcards in data are escaped
132
+ search = "50%_discount"
133
+ sql, params = tsql.render(t"SELECT * FROM products WHERE name LIKE {search:%like%}")
134
+ # ('SELECT * FROM products WHERE name LIKE ? ESCAPE '\\'', ['%50\\%\\_discount%'])
135
+ # Matches the literal string "50%_discount", not "50X" or "50Xdiscount"
136
+ ```
137
+
138
+ **For controlled values where you WANT wildcards**, build the pattern manually without format specs:
139
+
140
+ ```python
141
+ # Developer-controlled pattern (wildcards intentional)
142
+ pattern = f"%{category}%"
143
+ sql, params = tsql.render(t"SELECT * FROM products WHERE tags LIKE {pattern}")
144
+ # No escaping - % and _ work as wildcards
145
+ ```
146
+
118
147
  #### Tuples for IN clauses
119
148
 
120
149
  Use tuples to expand lists of values for SQL IN clauses:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "t-sql"
7
- version = "4.7.1"
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"
@@ -268,4 +268,105 @@ async def test_datetime_comparison_with_asyncpg(conn):
268
268
  # Should find User2 and User3 (created within last 15 minutes)
269
269
  assert len(rows) == 2
270
270
  names = sorted([row['name'] for row in rows])
271
- assert names == ['User2', 'User3']
271
+ assert names == ['User2', 'User3']
272
+
273
+
274
+ async def test_like_pattern_format_specs_with_postgres(conn):
275
+ """Test LIKE pattern format specs with PostgreSQL"""
276
+ # Insert test data with special characters
277
+ await conn.execute(
278
+ "INSERT INTO test_users (name) VALUES ($1), ($2), ($3), ($4)",
279
+ 'john_doe', 'john%smith', 'alice', 'admin_50%'
280
+ )
281
+
282
+ # Test contains pattern (%like%)
283
+ search = "john"
284
+ sql, params = tsql.render(
285
+ t"SELECT name FROM test_users WHERE name LIKE {search:%like%} ORDER BY name",
286
+ style=tsql.styles.NUMERIC_DOLLAR
287
+ )
288
+
289
+ assert "ESCAPE '\\'" in sql
290
+ assert params == ['%john%']
291
+
292
+ rows = await conn.fetch(sql, *params)
293
+
294
+ # Should match both john_doe and john%smith
295
+ assert len(rows) == 2
296
+ names = sorted([row['name'] for row in rows])
297
+ assert names == ['john%smith', 'john_doe']
298
+
299
+ # Test prefix pattern (like%)
300
+ prefix = "admin"
301
+ sql, params = tsql.render(
302
+ t"SELECT name FROM test_users WHERE name LIKE {prefix:like%}",
303
+ style=tsql.styles.NUMERIC_DOLLAR
304
+ )
305
+
306
+ assert params == ['admin%']
307
+
308
+ rows = await conn.fetch(sql, *params)
309
+
310
+ # Should match admin_50%
311
+ assert len(rows) == 1
312
+ assert rows[0]['name'] == 'admin_50%'
313
+
314
+ # Test wildcard escaping - searching for literal underscore
315
+ search = "john_"
316
+ sql, params = tsql.render(
317
+ t"SELECT name FROM test_users WHERE name LIKE {search:%like%}",
318
+ style=tsql.styles.NUMERIC_DOLLAR
319
+ )
320
+
321
+ # Should escape the underscore
322
+ assert params == ['%john\\_%']
323
+
324
+ rows = await conn.fetch(sql, *params)
325
+
326
+ # Should match only john_doe (literal underscore after "john")
327
+ assert len(rows) == 1
328
+ assert rows[0]['name'] == 'john_doe'
329
+
330
+ # Test wildcard escaping - searching for literal percent
331
+ search = "50%"
332
+ sql, params = tsql.render(
333
+ t"SELECT name FROM test_users WHERE name LIKE {search:%like%}",
334
+ style=tsql.styles.NUMERIC_DOLLAR
335
+ )
336
+
337
+ # Should escape the percent
338
+ assert params == ['%50\\%%']
339
+
340
+ rows = await conn.fetch(sql, *params)
341
+
342
+ # Should match admin_50%
343
+ assert len(rows) == 1
344
+ assert rows[0]['name'] == 'admin_50%'
345
+
346
+ # Test suffix pattern (%like)
347
+ suffix = "_doe"
348
+ sql, params = tsql.render(
349
+ t"SELECT name FROM test_users WHERE name LIKE {suffix:%like}",
350
+ style=tsql.styles.NUMERIC_DOLLAR
351
+ )
352
+
353
+ # Underscore should be escaped
354
+ assert params == ['%\\_doe']
355
+
356
+ rows = await conn.fetch(sql, *params)
357
+
358
+ # Should match john_doe
359
+ assert len(rows) == 1
360
+ assert rows[0]['name'] == 'john_doe'
361
+
362
+ # Test ILIKE (case-insensitive) works too
363
+ search = "JOHN"
364
+ sql, params = tsql.render(
365
+ t"SELECT name FROM test_users WHERE name ILIKE {search:%like%}",
366
+ style=tsql.styles.NUMERIC_DOLLAR
367
+ )
368
+
369
+ rows = await conn.fetch(sql, *params)
370
+
371
+ # Should match both john_doe and john%smith (case-insensitive)
372
+ assert len(rows) == 2
@@ -0,0 +1,282 @@
1
+ """Tests for LIKE pattern format specs."""
2
+
3
+ import pytest
4
+ import tsql
5
+ from tsql.styles import QMARK, NUMERIC, NAMED, FORMAT, PYFORMAT, NUMERIC_DOLLAR
6
+
7
+
8
+ class TestLikePatternBasics:
9
+ """Test basic LIKE pattern functionality."""
10
+
11
+ def test_like_contains_pattern(self):
12
+ """Test %like% format spec produces contains pattern."""
13
+ search = "john"
14
+ sql, params = tsql.render(t"SELECT * FROM users WHERE name LIKE {search:%like%}")
15
+
16
+ assert sql == "SELECT * FROM users WHERE name LIKE ? ESCAPE '\\'"
17
+ assert params == ['%john%']
18
+
19
+ def test_like_prefix_pattern(self):
20
+ """Test like% format spec produces starts-with pattern."""
21
+ search = "admin"
22
+ sql, params = tsql.render(t"SELECT * FROM users WHERE name LIKE {search:like%}")
23
+
24
+ assert sql == "SELECT * FROM users WHERE name LIKE ? ESCAPE '\\'"
25
+ assert params == ['admin%']
26
+
27
+ def test_like_suffix_pattern(self):
28
+ """Test %like format spec produces ends-with pattern."""
29
+ search = ".com"
30
+ sql, params = tsql.render(t"SELECT * FROM emails WHERE address LIKE {search:%like}")
31
+
32
+ assert sql == "SELECT * FROM emails WHERE address LIKE ? ESCAPE '\\'"
33
+ assert params == ['%.com']
34
+
35
+
36
+ class TestWildcardEscaping:
37
+ """Test that wildcards are properly escaped."""
38
+
39
+ def test_escapes_percent_wildcard(self):
40
+ """Test that % is escaped to \\%."""
41
+ search = "50%"
42
+ sql, params = tsql.render(t"SELECT * FROM products WHERE discount LIKE {search:%like%}")
43
+
44
+ assert params == ['%50\\%%']
45
+
46
+ def test_escapes_underscore_wildcard(self):
47
+ """Test that _ is escaped to \\_."""
48
+ search = "user_name"
49
+ sql, params = tsql.render(t"SELECT * FROM logs WHERE field LIKE {search:%like%}")
50
+
51
+ assert params == ['%user\\_name%']
52
+
53
+ def test_escapes_backslash(self):
54
+ """Test that \\ is escaped to \\\\."""
55
+ search = "C:\\Users"
56
+ sql, params = tsql.render(t"SELECT * FROM paths WHERE path LIKE {search:%like%}")
57
+
58
+ assert params == ['%C:\\\\Users%']
59
+
60
+ def test_escapes_all_wildcards_together(self):
61
+ """Test multiple wildcards in one value."""
62
+ search = "test_50%\\path"
63
+ sql, params = tsql.render(t"SELECT * FROM mixed WHERE value LIKE {search:%like%}")
64
+
65
+ assert params == ['%test\\_50\\%\\\\path%']
66
+
67
+ def test_empty_string(self):
68
+ """Test empty string produces just the pattern."""
69
+ search = ""
70
+ sql, params = tsql.render(t"SELECT * FROM users WHERE name LIKE {search:%like%}")
71
+
72
+ assert params == ['%%']
73
+
74
+
75
+ class TestSecurityScenarios:
76
+ """Test that injection attempts are neutralized."""
77
+
78
+ def test_prevents_wildcard_injection_contains(self):
79
+ """Test that user can't inject wildcards to expand search."""
80
+ malicious = "%admin" # Trying to search for anything containing 'admin'
81
+ sql, params = tsql.render(t"SELECT * FROM users WHERE username LIKE {malicious:like%}")
82
+
83
+ # Should match literally '%admin' followed by anything
84
+ assert params == ['\\%admin%']
85
+
86
+ def test_prevents_full_table_scan(self):
87
+ """Test that % alone doesn't create %%."""
88
+ malicious = "%"
89
+ sql, params = tsql.render(t"SELECT * FROM users WHERE name LIKE {malicious:%like%}")
90
+
91
+ # Should match literally '%' anywhere
92
+ assert params == ['%\\%%']
93
+
94
+ def test_prevents_sql_injection_attempt(self):
95
+ """Test that SQL injection attempts are treated as literals."""
96
+ malicious = "%'; DROP TABLE users; --"
97
+ sql, params = tsql.render(t"SELECT * FROM logs WHERE message LIKE {malicious:%like%}")
98
+
99
+ # Everything is escaped and parameterized
100
+ assert "DROP TABLE" in params[0] # Present in parameter value
101
+ assert "DROP TABLE" not in sql # Not in SQL itself
102
+ assert params == ["%\\%'; DROP TABLE users; --%"]
103
+
104
+
105
+ class TestTypeHandling:
106
+ """Test type conversion and None handling."""
107
+
108
+ def test_converts_int_to_string(self):
109
+ """Test that integers are converted to strings."""
110
+ number = 42
111
+ sql, params = tsql.render(t"SELECT * FROM products WHERE code LIKE {number:%like%}")
112
+
113
+ assert params == ['%42%']
114
+
115
+ def test_converts_float_to_string(self):
116
+ """Test that floats are converted to strings."""
117
+ number = 3.14
118
+ sql, params = tsql.render(t"SELECT * FROM values WHERE val LIKE {number:%like%}")
119
+
120
+ assert params == ['%3.14%']
121
+
122
+ def test_none_raises_error_contains(self):
123
+ """Test that None raises ValueError for %like%."""
124
+ value = None
125
+ with pytest.raises(ValueError, match="LIKE pattern value cannot be None"):
126
+ tsql.render(t"SELECT * FROM users WHERE name LIKE {value:%like%}")
127
+
128
+ def test_none_raises_error_prefix(self):
129
+ """Test that None raises ValueError for like%."""
130
+ value = None
131
+ with pytest.raises(ValueError, match="LIKE pattern value cannot be None"):
132
+ tsql.render(t"SELECT * FROM users WHERE name LIKE {value:like%}")
133
+
134
+ def test_none_raises_error_suffix(self):
135
+ """Test that None raises ValueError for %like."""
136
+ value = None
137
+ with pytest.raises(ValueError, match="LIKE pattern value cannot be None"):
138
+ tsql.render(t"SELECT * FROM users WHERE name LIKE {value:%like}")
139
+
140
+
141
+ class TestMultipleParameters:
142
+ """Test queries with multiple LIKE clauses."""
143
+
144
+ def test_multiple_like_patterns_in_one_query(self):
145
+ """Test that multiple LIKE patterns work correctly."""
146
+ name = "john"
147
+ email = "gmail"
148
+ sql, params = tsql.render(t"SELECT * FROM users WHERE name LIKE {name:%like%} OR email LIKE {email:%like}")
149
+
150
+ assert sql == "SELECT * FROM users WHERE name LIKE ? ESCAPE '\\' OR email LIKE ? ESCAPE '\\'"
151
+ assert params == ['%john%', '%gmail']
152
+
153
+ def test_mixed_like_patterns(self):
154
+ """Test different pattern types in one query."""
155
+ prefix = "admin"
156
+ suffix = ".com"
157
+ contains = "test"
158
+ sql, params = tsql.render(t"""
159
+ SELECT * FROM data
160
+ WHERE username LIKE {prefix:like%}
161
+ AND email LIKE {suffix:%like}
162
+ AND description LIKE {contains:%like%}
163
+ """)
164
+
165
+ assert params == ['admin%', '%.com', '%test%']
166
+ assert sql.count("ESCAPE '\\'") == 3
167
+
168
+
169
+ class TestParameterStyles:
170
+ """Test that LIKE patterns work with different parameter styles."""
171
+
172
+ def test_qmark_style(self):
173
+ """Test with ? placeholders."""
174
+ search = "test"
175
+ sql, params = tsql.render(t"SELECT * FROM users WHERE name LIKE {search:%like%}", QMARK)
176
+
177
+ assert sql == "SELECT * FROM users WHERE name LIKE ? ESCAPE '\\'"
178
+ assert params == ['%test%']
179
+
180
+ def test_numeric_style(self):
181
+ """Test with :1, :2, ... placeholders."""
182
+ search = "test"
183
+ sql, params = tsql.render(t"SELECT * FROM users WHERE name LIKE {search:%like%}", NUMERIC)
184
+
185
+ assert sql == "SELECT * FROM users WHERE name LIKE :1 ESCAPE '\\'"
186
+ assert params == ['%test%']
187
+
188
+ def test_numeric_dollar_style(self):
189
+ """Test with $1, $2, ... placeholders."""
190
+ search = "test"
191
+ sql, params = tsql.render(t"SELECT * FROM users WHERE name LIKE {search:%like%}", NUMERIC_DOLLAR)
192
+
193
+ assert sql == "SELECT * FROM users WHERE name LIKE $1 ESCAPE '\\'"
194
+ assert params == ['%test%']
195
+
196
+ def test_named_style(self):
197
+ """Test with :name placeholders."""
198
+ search = "test"
199
+ sql, params = tsql.render(t"SELECT * FROM users WHERE name LIKE {search:%like%}", NAMED)
200
+
201
+ assert sql == "SELECT * FROM users WHERE name LIKE :search ESCAPE '\\'"
202
+ assert params == {'search': '%test%'}
203
+
204
+ def test_format_style(self):
205
+ """Test with %s placeholders."""
206
+ search = "test"
207
+ sql, params = tsql.render(t"SELECT * FROM users WHERE name LIKE {search:%like%}", FORMAT)
208
+
209
+ assert sql == "SELECT * FROM users WHERE name LIKE %s ESCAPE '\\'"
210
+ assert params == ['%test%']
211
+
212
+ def test_pyformat_style(self):
213
+ """Test with %(name)s placeholders."""
214
+ search = "test"
215
+ sql, params = tsql.render(t"SELECT * FROM users WHERE name LIKE {search:%like%}", PYFORMAT)
216
+
217
+ assert sql == "SELECT * FROM users WHERE name LIKE %(search)s ESCAPE '\\'"
218
+ assert params == {'search': '%test%'}
219
+
220
+
221
+ class TestCaseSensitivity:
222
+ """Test that LIKE vs ILIKE is orthogonal to pattern specs."""
223
+
224
+ def test_like_case_sensitive(self):
225
+ """Test LIKE with pattern."""
226
+ search = "John"
227
+ sql, params = tsql.render(t"SELECT * FROM users WHERE name LIKE {search:%like%}")
228
+
229
+ assert "LIKE" in sql
230
+ assert "ILIKE" not in sql
231
+ assert params == ['%John%']
232
+
233
+ def test_ilike_case_insensitive(self):
234
+ """Test ILIKE with pattern."""
235
+ search = "John"
236
+ sql, params = tsql.render(t"SELECT * FROM users WHERE name ILIKE {search:%like%}")
237
+
238
+ assert "ILIKE" in sql
239
+ assert params == ['%John%']
240
+
241
+
242
+ class TestRealWorldScenarios:
243
+ """Test realistic usage patterns."""
244
+
245
+ def test_user_search_box(self):
246
+ """Test typical user search functionality."""
247
+ user_input = "john doe"
248
+ sql, params = tsql.render(t"""
249
+ SELECT id, name, email
250
+ FROM users
251
+ WHERE name ILIKE {user_input:%like%}
252
+ ORDER BY name
253
+ """)
254
+
255
+ assert params == ['%john doe%']
256
+
257
+ def test_email_domain_filter(self):
258
+ """Test filtering by email domain."""
259
+ domain = "@gmail.com"
260
+ sql, params = tsql.render(t"SELECT * FROM users WHERE email LIKE {domain:%like}")
261
+
262
+ assert params == ['%@gmail.com']
263
+
264
+ def test_username_prefix_search(self):
265
+ """Test username autocomplete."""
266
+ prefix = "adm"
267
+ sql, params = tsql.render(t"""
268
+ SELECT username
269
+ FROM users
270
+ WHERE username LIKE {prefix:like%}
271
+ LIMIT 10
272
+ """)
273
+
274
+ assert params == ['adm%']
275
+
276
+ def test_log_message_search_with_special_chars(self):
277
+ """Test searching logs that might contain special characters."""
278
+ search = "Error: 50% complete [user_123]"
279
+ sql, params = tsql.render(t"SELECT * FROM logs WHERE message LIKE {search:%like%}")
280
+
281
+ # All special chars should be escaped
282
+ assert params == ['%Error: 50\\% complete [user\\_123]%']
@@ -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
@@ -349,3 +349,83 @@ async def test_query_builder_select(conn):
349
349
  rows = await cursor.fetchall()
350
350
 
351
351
  assert len(rows) == 1
352
+
353
+
354
+ async def test_like_pattern_format_specs(conn):
355
+ """Test LIKE pattern format specs with SQLite"""
356
+ # Insert test data with special characters
357
+ await conn.execute(
358
+ "INSERT INTO test_users (name) VALUES (?), (?), (?), (?)",
359
+ ('john_doe', 'john%smith', 'alice', 'admin_50%')
360
+ )
361
+ await conn.commit()
362
+
363
+ # Test contains pattern (%like%)
364
+ search = "john"
365
+ sql, params = tsql.render(t"SELECT name FROM test_users WHERE name LIKE {search:%like%} ORDER BY name")
366
+
367
+ assert "ESCAPE '\\'" in sql
368
+ assert params == ['%john%']
369
+
370
+ cursor = await conn.execute(sql, params)
371
+ rows = await cursor.fetchall()
372
+
373
+ # Should match both john_doe and john%smith
374
+ assert len(rows) == 2
375
+ assert rows[0][0] == 'john%smith'
376
+ assert rows[1][0] == 'john_doe'
377
+
378
+ # Test prefix pattern (like%)
379
+ prefix = "admin"
380
+ sql, params = tsql.render(t"SELECT name FROM test_users WHERE name LIKE {prefix:like%}")
381
+
382
+ assert params == ['admin%']
383
+
384
+ cursor = await conn.execute(sql, params)
385
+ rows = await cursor.fetchall()
386
+
387
+ # Should match admin_50%
388
+ assert len(rows) == 1
389
+ assert rows[0][0] == 'admin_50%'
390
+
391
+ # Test wildcard escaping - searching for literal underscore
392
+ search = "john_"
393
+ sql, params = tsql.render(t"SELECT name FROM test_users WHERE name LIKE {search:%like%}")
394
+
395
+ # Should escape the underscore
396
+ assert params == ['%john\\_%']
397
+
398
+ cursor = await conn.execute(sql, params)
399
+ rows = await cursor.fetchall()
400
+
401
+ # Should match only john_doe (literal underscore after "john")
402
+ assert len(rows) == 1
403
+ assert rows[0][0] == 'john_doe'
404
+
405
+ # Test wildcard escaping - searching for literal percent
406
+ search = "50%"
407
+ sql, params = tsql.render(t"SELECT name FROM test_users WHERE name LIKE {search:%like%}")
408
+
409
+ # Should escape the percent
410
+ assert params == ['%50\\%%']
411
+
412
+ cursor = await conn.execute(sql, params)
413
+ rows = await cursor.fetchall()
414
+
415
+ # Should match admin_50%
416
+ assert len(rows) == 1
417
+ assert rows[0][0] == 'admin_50%'
418
+
419
+ # Test suffix pattern (%like)
420
+ suffix = "_doe"
421
+ sql, params = tsql.render(t"SELECT name FROM test_users WHERE name LIKE {suffix:%like}")
422
+
423
+ # Underscore should be escaped
424
+ assert params == ['%\\_doe']
425
+
426
+ cursor = await conn.execute(sql, params)
427
+ rows = await cursor.fetchall()
428
+
429
+ # Should match john_doe
430
+ assert len(rows) == 1
431
+ assert rows[0][0] == 'john_doe'
@@ -24,6 +24,11 @@ def set_style(style: type[ParamStyle]):
24
24
  default_style = style
25
25
 
26
26
 
27
+ def _escape_like(value: str) -> str:
28
+ # Order matters: escape backslash first to avoid double-escaping
29
+ return value.replace('\\', '\\\\').replace('%', '\\%').replace('_', '\\_')
30
+
31
+
27
32
  class Parameter:
28
33
  _expression: str
29
34
  _value: Any
@@ -141,6 +146,28 @@ class TSQL:
141
146
  return as_values(value)._sql_parts
142
147
  case 'as_set', dict():
143
148
  return as_set(value)._sql_parts
149
+ case fmt_spec, _ if fmt_spec in ('%like%', 'like%', '%like'): # LIKE patterns
150
+ if value is None:
151
+ raise ValueError(
152
+ f"LIKE pattern value cannot be None for {{value:{fmt_spec}}}. "
153
+ f"Use a non-None value or handle None before the query."
154
+ )
155
+ str_value = str(value)
156
+ escaped = _escape_like(str_value)
157
+
158
+ # Apply pattern based on format spec
159
+ if fmt_spec == '%like%':
160
+ wrapped = f"%{escaped}%"
161
+ pattern_type = "contains"
162
+ elif fmt_spec == 'like%':
163
+ wrapped = f"{escaped}%"
164
+ pattern_type = "prefix"
165
+ else: # '%like'
166
+ wrapped = f"%{escaped}"
167
+ pattern_type = "suffix"
168
+
169
+ logger.debug("LIKE %s pattern: %r -> %r (escaped: %r)", pattern_type, value, wrapped, escaped)
170
+ return [Parameter(val.expression, wrapped), " ESCAPE '\\'"]
144
171
  case '', TSQL():
145
172
  return val.value._sql_parts
146
173
  case "", Template():
@@ -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