Skip to content

Commit 91820ba

Browse files
committed
Add LIKE pattern format specs with automatic wildcard escaping
1 parent 1be5aa4 commit 91820ba

5 files changed

Lines changed: 530 additions & 1 deletion

File tree

README.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,45 @@ sql, params = tsql.render(t"UPDATE users SET {values:as_set} WHERE id='abc123'")
105105
# ('UPDATE users SET name = ?, email = ? WHERE id='abc123'', ['joe', 'joe@example.com'])
106106
```
107107

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+
108147
#### Tuples for IN clauses
109148

110149
Use tuples to expand lists of values for SQL IN clauses:

tests/test_asyncpg_integration.py

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -268,4 +268,105 @@ async def test_datetime_comparison_with_asyncpg(conn):
268268
# Should find User2 and User3 (created within last 15 minutes)
269269
assert len(rows) == 2
270270
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 commit comments

Comments
 (0)