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.
- t_sql-4.7.1/README.md → t_sql-4.9.0/PKG-INFO +49 -0
- t_sql-4.7.1/PKG-INFO → t_sql-4.9.0/README.md +39 -10
- {t_sql-4.7.1 → t_sql-4.9.0}/pyproject.toml +1 -1
- {t_sql-4.7.1 → t_sql-4.9.0}/tests/test_asyncpg_integration.py +102 -1
- t_sql-4.9.0/tests/test_like_patterns.py +282 -0
- {t_sql-4.7.1 → t_sql-4.9.0}/tests/test_query_builder.py +101 -0
- {t_sql-4.7.1 → t_sql-4.9.0}/tests/test_sqlite_integration.py +80 -0
- {t_sql-4.7.1 → t_sql-4.9.0}/tsql/__init__.py +27 -0
- {t_sql-4.7.1 → t_sql-4.9.0}/tsql/query_builder.py +76 -0
- {t_sql-4.7.1 → t_sql-4.9.0}/.dockerignore +0 -0
- {t_sql-4.7.1 → t_sql-4.9.0}/.github/workflows/publish.yml +0 -0
- {t_sql-4.7.1 → t_sql-4.9.0}/.github/workflows/test.yml +0 -0
- {t_sql-4.7.1 → t_sql-4.9.0}/.gitignore +0 -0
- {t_sql-4.7.1 → t_sql-4.9.0}/Dockerfile +0 -0
- {t_sql-4.7.1 → t_sql-4.9.0}/LICENSE +0 -0
- {t_sql-4.7.1 → t_sql-4.9.0}/compose.yaml +0 -0
- {t_sql-4.7.1 → t_sql-4.9.0}/context7.json +0 -0
- {t_sql-4.7.1 → t_sql-4.9.0}/pytest.ini +0 -0
- {t_sql-4.7.1 → t_sql-4.9.0}/tests/test_alembic_integration.py +0 -0
- {t_sql-4.7.1 → t_sql-4.9.0}/tests/test_deep_nesting.py +0 -0
- {t_sql-4.7.1 → t_sql-4.9.0}/tests/test_different_object_types.py +0 -0
- {t_sql-4.7.1 → t_sql-4.9.0}/tests/test_error_messages.py +0 -0
- {t_sql-4.7.1 → t_sql-4.9.0}/tests/test_escaped.py +0 -0
- {t_sql-4.7.1 → t_sql-4.9.0}/tests/test_escaped_binary_hex.py +0 -0
- {t_sql-4.7.1 → t_sql-4.9.0}/tests/test_helper_functions.py +0 -0
- {t_sql-4.7.1 → t_sql-4.9.0}/tests/test_injection_edge_cases.py +0 -0
- {t_sql-4.7.1 → t_sql-4.9.0}/tests/test_injection_protection_validation.py +0 -0
- {t_sql-4.7.1 → t_sql-4.9.0}/tests/test_injections_for_escaped.py +0 -0
- {t_sql-4.7.1 → t_sql-4.9.0}/tests/test_mysql_integration.py +0 -0
- {t_sql-4.7.1 → t_sql-4.9.0}/tests/test_parameter_names.py +0 -0
- {t_sql-4.7.1 → t_sql-4.9.0}/tests/test_sqlalchemy_integration.py +0 -0
- {t_sql-4.7.1 → t_sql-4.9.0}/tests/test_string_based_builders.py +0 -0
- {t_sql-4.7.1 → t_sql-4.9.0}/tests/test_styles.py +0 -0
- {t_sql-4.7.1 → t_sql-4.9.0}/tests/test_template_in_builders.py +0 -0
- {t_sql-4.7.1 → t_sql-4.9.0}/tests/test_tsql.py +0 -0
- {t_sql-4.7.1 → t_sql-4.9.0}/tests/test_type_processor.py +0 -0
- {t_sql-4.7.1 → t_sql-4.9.0}/tsql/row.py +0 -0
- {t_sql-4.7.1 → t_sql-4.9.0}/tsql/styles.py +0 -0
- {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:
|
|
@@ -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
|
|
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
|