t-sql 4.7.1__tar.gz → 4.8.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.8.0/PKG-INFO +49 -0
  2. t_sql-4.7.1/PKG-INFO → t_sql-4.8.0/README.md +39 -10
  3. {t_sql-4.7.1 → t_sql-4.8.0}/pyproject.toml +1 -1
  4. {t_sql-4.7.1 → t_sql-4.8.0}/tests/test_asyncpg_integration.py +102 -1
  5. t_sql-4.8.0/tests/test_like_patterns.py +282 -0
  6. {t_sql-4.7.1 → t_sql-4.8.0}/tests/test_sqlite_integration.py +80 -0
  7. {t_sql-4.7.1 → t_sql-4.8.0}/tsql/__init__.py +27 -0
  8. {t_sql-4.7.1 → t_sql-4.8.0}/.dockerignore +0 -0
  9. {t_sql-4.7.1 → t_sql-4.8.0}/.github/workflows/publish.yml +0 -0
  10. {t_sql-4.7.1 → t_sql-4.8.0}/.github/workflows/test.yml +0 -0
  11. {t_sql-4.7.1 → t_sql-4.8.0}/.gitignore +0 -0
  12. {t_sql-4.7.1 → t_sql-4.8.0}/Dockerfile +0 -0
  13. {t_sql-4.7.1 → t_sql-4.8.0}/LICENSE +0 -0
  14. {t_sql-4.7.1 → t_sql-4.8.0}/compose.yaml +0 -0
  15. {t_sql-4.7.1 → t_sql-4.8.0}/context7.json +0 -0
  16. {t_sql-4.7.1 → t_sql-4.8.0}/pytest.ini +0 -0
  17. {t_sql-4.7.1 → t_sql-4.8.0}/tests/test_alembic_integration.py +0 -0
  18. {t_sql-4.7.1 → t_sql-4.8.0}/tests/test_deep_nesting.py +0 -0
  19. {t_sql-4.7.1 → t_sql-4.8.0}/tests/test_different_object_types.py +0 -0
  20. {t_sql-4.7.1 → t_sql-4.8.0}/tests/test_error_messages.py +0 -0
  21. {t_sql-4.7.1 → t_sql-4.8.0}/tests/test_escaped.py +0 -0
  22. {t_sql-4.7.1 → t_sql-4.8.0}/tests/test_escaped_binary_hex.py +0 -0
  23. {t_sql-4.7.1 → t_sql-4.8.0}/tests/test_helper_functions.py +0 -0
  24. {t_sql-4.7.1 → t_sql-4.8.0}/tests/test_injection_edge_cases.py +0 -0
  25. {t_sql-4.7.1 → t_sql-4.8.0}/tests/test_injection_protection_validation.py +0 -0
  26. {t_sql-4.7.1 → t_sql-4.8.0}/tests/test_injections_for_escaped.py +0 -0
  27. {t_sql-4.7.1 → t_sql-4.8.0}/tests/test_mysql_integration.py +0 -0
  28. {t_sql-4.7.1 → t_sql-4.8.0}/tests/test_parameter_names.py +0 -0
  29. {t_sql-4.7.1 → t_sql-4.8.0}/tests/test_query_builder.py +0 -0
  30. {t_sql-4.7.1 → t_sql-4.8.0}/tests/test_sqlalchemy_integration.py +0 -0
  31. {t_sql-4.7.1 → t_sql-4.8.0}/tests/test_string_based_builders.py +0 -0
  32. {t_sql-4.7.1 → t_sql-4.8.0}/tests/test_styles.py +0 -0
  33. {t_sql-4.7.1 → t_sql-4.8.0}/tests/test_template_in_builders.py +0 -0
  34. {t_sql-4.7.1 → t_sql-4.8.0}/tests/test_tsql.py +0 -0
  35. {t_sql-4.7.1 → t_sql-4.8.0}/tests/test_type_processor.py +0 -0
  36. {t_sql-4.7.1 → t_sql-4.8.0}/tsql/query_builder.py +0 -0
  37. {t_sql-4.7.1 → t_sql-4.8.0}/tsql/row.py +0 -0
  38. {t_sql-4.7.1 → t_sql-4.8.0}/tsql/styles.py +0 -0
  39. {t_sql-4.7.1 → t_sql-4.8.0}/tsql/type_processor.py +0 -0
@@ -1,3 +1,13 @@
1
+ Metadata-Version: 2.4
2
+ Name: t-sql
3
+ Version: 4.8.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.8.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]%']
@@ -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():
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