t-sql 2.1.2__tar.gz → 2.2.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-2.1.2 → t_sql-2.2.0}/PKG-INFO +38 -8
- {t_sql-2.1.2 → t_sql-2.2.0}/README.md +37 -7
- t_sql-2.2.0/RELEASE_NOTES.md +41 -0
- {t_sql-2.1.2 → t_sql-2.2.0}/pyproject.toml +1 -1
- t_sql-2.2.0/tests/test_parameter_names.py +200 -0
- {t_sql-2.1.2 → t_sql-2.2.0}/tests/test_styles.py +4 -4
- {t_sql-2.1.2 → t_sql-2.2.0}/tsql/__init__.py +56 -2
- {t_sql-2.1.2 → t_sql-2.2.0}/tsql/styles.py +46 -4
- {t_sql-2.1.2 → t_sql-2.2.0}/.dockerignore +0 -0
- {t_sql-2.1.2 → t_sql-2.2.0}/.github/workflows/publish.yml +0 -0
- {t_sql-2.1.2 → t_sql-2.2.0}/.github/workflows/test.yml +0 -0
- {t_sql-2.1.2 → t_sql-2.2.0}/.gitignore +0 -0
- {t_sql-2.1.2 → t_sql-2.2.0}/Dockerfile +0 -0
- {t_sql-2.1.2 → t_sql-2.2.0}/LICENSE +0 -0
- {t_sql-2.1.2 → t_sql-2.2.0}/compose.yaml +0 -0
- {t_sql-2.1.2 → t_sql-2.2.0}/context7.json +0 -0
- {t_sql-2.1.2 → t_sql-2.2.0}/pytest.ini +0 -0
- {t_sql-2.1.2 → t_sql-2.2.0}/tests/test_alembic_integration.py +0 -0
- {t_sql-2.1.2 → t_sql-2.2.0}/tests/test_asyncpg_integration.py +0 -0
- {t_sql-2.1.2 → t_sql-2.2.0}/tests/test_different_object_types.py +0 -0
- {t_sql-2.1.2 → t_sql-2.2.0}/tests/test_escaped.py +0 -0
- {t_sql-2.1.2 → t_sql-2.2.0}/tests/test_escaped_binary_hex.py +0 -0
- {t_sql-2.1.2 → t_sql-2.2.0}/tests/test_helper_functions.py +0 -0
- {t_sql-2.1.2 → t_sql-2.2.0}/tests/test_injection_edge_cases.py +0 -0
- {t_sql-2.1.2 → t_sql-2.2.0}/tests/test_injection_protection_validation.py +0 -0
- {t_sql-2.1.2 → t_sql-2.2.0}/tests/test_injections_for_escaped.py +0 -0
- {t_sql-2.1.2 → t_sql-2.2.0}/tests/test_mysql_integration.py +0 -0
- {t_sql-2.1.2 → t_sql-2.2.0}/tests/test_query_builder.py +0 -0
- {t_sql-2.1.2 → t_sql-2.2.0}/tests/test_sqlalchemy_integration.py +0 -0
- {t_sql-2.1.2 → t_sql-2.2.0}/tests/test_sqlite_integration.py +0 -0
- {t_sql-2.1.2 → t_sql-2.2.0}/tests/test_tsql.py +0 -0
- {t_sql-2.1.2 → t_sql-2.2.0}/tsql/query_builder.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: t-sql
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.2.0
|
|
4
4
|
Summary: Safe SQL. SQL queries for python t-strings (PEP 750)
|
|
5
5
|
Project-URL: Homepage, https://github.com/nhumrich/t-sql
|
|
6
6
|
License-File: LICENSE
|
|
@@ -515,17 +515,47 @@ class Users(Table, table_name='user_accounts', schema='public'):
|
|
|
515
515
|
name: Column
|
|
516
516
|
```
|
|
517
517
|
|
|
518
|
-
#
|
|
518
|
+
# Rendering Queries
|
|
519
519
|
|
|
520
|
-
|
|
520
|
+
All query types (t-strings, TSQL objects, and QueryBuilder objects) can be rendered using `tsql.render()`:
|
|
521
521
|
|
|
522
522
|
```python
|
|
523
|
-
from string.templatelib import Template
|
|
524
523
|
import tsql
|
|
524
|
+
from tsql.query_builder import Table, Column
|
|
525
|
+
|
|
526
|
+
class Users(Table):
|
|
527
|
+
id: Column
|
|
528
|
+
name: Column
|
|
529
|
+
|
|
530
|
+
# All of these work with tsql.render():
|
|
531
|
+
sql, params = tsql.render(t"SELECT * FROM users WHERE id = {user_id}")
|
|
532
|
+
sql, params = tsql.render(Users.select().where(Users.id == user_id))
|
|
533
|
+
sql, params = tsql.render(tsql.select('users', user_id))
|
|
534
|
+
|
|
535
|
+
# Or call .render() directly on TSQL/QueryBuilder objects:
|
|
536
|
+
query = Users.select().where(Users.age > 18)
|
|
537
|
+
sql, params = query.render()
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
# Type Safety & Preventing SQL Injection
|
|
541
|
+
|
|
542
|
+
This library should ideally be used in middleware or library code to enforce safe query construction. Use the `TSQLQuery` type to prevent raw strings:
|
|
525
543
|
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
544
|
+
```python
|
|
545
|
+
from tsql import TSQLQuery, render
|
|
546
|
+
|
|
547
|
+
def execute_sql_query(query: TSQLQuery):
|
|
548
|
+
"""Only accepts safe, parameterized queries"""
|
|
549
|
+
sql, params = render(query)
|
|
550
|
+
return sql_engine.execute(sql, params)
|
|
529
551
|
|
|
530
|
-
|
|
552
|
+
# Type checker allows these:
|
|
553
|
+
execute_sql_query(t"SELECT * FROM users WHERE id = {user_id}") # ✓
|
|
554
|
+
execute_sql_query(Users.select()) # ✓
|
|
555
|
+
execute_sql_query(tsql.select('users')) # ✓
|
|
556
|
+
|
|
557
|
+
# Type checker rejects raw strings:
|
|
558
|
+
execute_sql_query("SELECT * FROM users") # ✗ Type error!
|
|
531
559
|
```
|
|
560
|
+
|
|
561
|
+
The `TSQLQuery` type is a union of `TSQL`, `Template` (t-strings), and `QueryBuilder`, ensuring all queries are safe from SQL injection.
|
|
@@ -505,17 +505,47 @@ class Users(Table, table_name='user_accounts', schema='public'):
|
|
|
505
505
|
name: Column
|
|
506
506
|
```
|
|
507
507
|
|
|
508
|
-
#
|
|
508
|
+
# Rendering Queries
|
|
509
509
|
|
|
510
|
-
|
|
510
|
+
All query types (t-strings, TSQL objects, and QueryBuilder objects) can be rendered using `tsql.render()`:
|
|
511
511
|
|
|
512
512
|
```python
|
|
513
|
-
from string.templatelib import Template
|
|
514
513
|
import tsql
|
|
514
|
+
from tsql.query_builder import Table, Column
|
|
515
|
+
|
|
516
|
+
class Users(Table):
|
|
517
|
+
id: Column
|
|
518
|
+
name: Column
|
|
519
|
+
|
|
520
|
+
# All of these work with tsql.render():
|
|
521
|
+
sql, params = tsql.render(t"SELECT * FROM users WHERE id = {user_id}")
|
|
522
|
+
sql, params = tsql.render(Users.select().where(Users.id == user_id))
|
|
523
|
+
sql, params = tsql.render(tsql.select('users', user_id))
|
|
524
|
+
|
|
525
|
+
# Or call .render() directly on TSQL/QueryBuilder objects:
|
|
526
|
+
query = Users.select().where(Users.age > 18)
|
|
527
|
+
sql, params = query.render()
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
# Type Safety & Preventing SQL Injection
|
|
531
|
+
|
|
532
|
+
This library should ideally be used in middleware or library code to enforce safe query construction. Use the `TSQLQuery` type to prevent raw strings:
|
|
515
533
|
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
534
|
+
```python
|
|
535
|
+
from tsql import TSQLQuery, render
|
|
536
|
+
|
|
537
|
+
def execute_sql_query(query: TSQLQuery):
|
|
538
|
+
"""Only accepts safe, parameterized queries"""
|
|
539
|
+
sql, params = render(query)
|
|
540
|
+
return sql_engine.execute(sql, params)
|
|
519
541
|
|
|
520
|
-
|
|
542
|
+
# Type checker allows these:
|
|
543
|
+
execute_sql_query(t"SELECT * FROM users WHERE id = {user_id}") # ✓
|
|
544
|
+
execute_sql_query(Users.select()) # ✓
|
|
545
|
+
execute_sql_query(tsql.select('users')) # ✓
|
|
546
|
+
|
|
547
|
+
# Type checker rejects raw strings:
|
|
548
|
+
execute_sql_query("SELECT * FROM users") # ✗ Type error!
|
|
521
549
|
```
|
|
550
|
+
|
|
551
|
+
The `TSQLQuery` type is a union of `TSQL`, `Template` (t-strings), and `QueryBuilder`, ensuring all queries are safe from SQL injection.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Release Notes
|
|
2
|
+
|
|
3
|
+
## Version 2.2.0 (2025-01-XX)
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
**Parameter Name Sanitization for NAMED/PYFORMAT Styles**
|
|
8
|
+
|
|
9
|
+
Fixed a bug where complex Python expressions in t-strings would generate syntactically invalid SQL parameter names when using NAMED (`:name`) or PYFORMAT (`%(name)s`) parameter styles.
|
|
10
|
+
|
|
11
|
+
**Problem:**
|
|
12
|
+
- Complex expressions like `{data['key']}`, `{obj.attr}`, or `{func()}` would generate invalid SQL: `:data['key']`, `:obj.attr`, `:func()`
|
|
13
|
+
- These invalid parameter names caused database errors with SQLite, PostgreSQL, and other databases that use named parameters
|
|
14
|
+
- Example: `{a + b}` would generate `:a + b`, which databases would misinterpret as column references
|
|
15
|
+
|
|
16
|
+
**Solution:**
|
|
17
|
+
- Parameter names are now sanitized to valid SQL identifiers by replacing invalid characters with underscores
|
|
18
|
+
- Simple variable names are preserved for readability: `{user_input}` → `:user_input`
|
|
19
|
+
- Complex expressions are sanitized: `{data['key']}` → `:data__key__`, `{obj.name}` → `:obj_name`
|
|
20
|
+
- Collision detection ensures unique parameter names even with edge cases
|
|
21
|
+
|
|
22
|
+
**Breaking Change:**
|
|
23
|
+
- NAMED and PYFORMAT styles now correctly return `dict` parameters instead of `list`
|
|
24
|
+
- This aligns with SQL database driver expectations (SQLite, asyncpg, etc.)
|
|
25
|
+
- If you were manually handling parameters as lists, update to use dicts:
|
|
26
|
+
```python
|
|
27
|
+
# Before (incorrect):
|
|
28
|
+
sql, params = render(query, style=NAMED)
|
|
29
|
+
# params was ['value1', 'value2'] # Wrong!
|
|
30
|
+
|
|
31
|
+
# After (correct):
|
|
32
|
+
sql, params = render(query, style=NAMED)
|
|
33
|
+
# params is {'param1': 'value1', 'param2': 'value2'}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
**Impact:**
|
|
37
|
+
- Queries using NAMED/PYFORMAT styles with complex expressions now work correctly
|
|
38
|
+
- All 247 existing tests continue to pass
|
|
39
|
+
- Added 10 new tests covering parameter name edge cases
|
|
40
|
+
|
|
41
|
+
This fix ensures t-sql generates valid SQL across all parameter styles and database drivers.
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Tests for parameter name generation with NAMED and PYFORMAT styles.
|
|
2
|
+
|
|
3
|
+
This tests SECURITY-2: ensuring complex expression names don't break SQL syntax
|
|
4
|
+
or cause issues with parameter name generation.
|
|
5
|
+
"""
|
|
6
|
+
import tsql
|
|
7
|
+
from tsql.styles import NAMED, PYFORMAT
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_simple_variable_name():
|
|
11
|
+
"""Simple variable names should work fine."""
|
|
12
|
+
user_input = "test"
|
|
13
|
+
query = t"SELECT * FROM users WHERE name = {user_input}"
|
|
14
|
+
|
|
15
|
+
# NAMED style - returns dict
|
|
16
|
+
sql, params = tsql.render(query, style=NAMED)
|
|
17
|
+
assert sql == "SELECT * FROM users WHERE name = :user_input"
|
|
18
|
+
assert params == {"user_input": "test"}
|
|
19
|
+
|
|
20
|
+
# PYFORMAT style - returns dict
|
|
21
|
+
sql, params = tsql.render(query, style=PYFORMAT)
|
|
22
|
+
assert sql == "SELECT * FROM users WHERE name = %(user_input)s"
|
|
23
|
+
assert params == {"user_input": "test"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_dict_access_expression():
|
|
27
|
+
"""Dictionary access like data['key'] creates complex expression names."""
|
|
28
|
+
data = {'key': 'value'}
|
|
29
|
+
query = t"SELECT * FROM users WHERE name = {data['key']}"
|
|
30
|
+
|
|
31
|
+
# NAMED style - should sanitize to :data__key__
|
|
32
|
+
sql, params = tsql.render(query, style=NAMED)
|
|
33
|
+
print(f"NAMED SQL: {sql}")
|
|
34
|
+
print(f"NAMED params: {params}")
|
|
35
|
+
assert sql == "SELECT * FROM users WHERE name = :data__key__"
|
|
36
|
+
assert params == {"data__key__": 'value'}
|
|
37
|
+
|
|
38
|
+
# PYFORMAT style - should sanitize to %(data__key__)s
|
|
39
|
+
sql, params = tsql.render(query, style=PYFORMAT)
|
|
40
|
+
print(f"PYFORMAT SQL: {sql}")
|
|
41
|
+
print(f"PYFORMAT params: {params}")
|
|
42
|
+
assert sql == "SELECT * FROM users WHERE name = %(data__key__)s"
|
|
43
|
+
assert params == {"data__key__": 'value'}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_attribute_access_expression():
|
|
47
|
+
"""Attribute access like obj.attr creates dotted expression names."""
|
|
48
|
+
class User:
|
|
49
|
+
name = "Alice"
|
|
50
|
+
|
|
51
|
+
obj = User()
|
|
52
|
+
query = t"SELECT * FROM users WHERE name = {obj.name}"
|
|
53
|
+
|
|
54
|
+
# NAMED style - should sanitize to :obj_name
|
|
55
|
+
sql, params = tsql.render(query, style=NAMED)
|
|
56
|
+
print(f"NAMED SQL: {sql}")
|
|
57
|
+
print(f"NAMED params: {params}")
|
|
58
|
+
assert sql == "SELECT * FROM users WHERE name = :obj_name"
|
|
59
|
+
assert params == {"obj_name": 'Alice'}
|
|
60
|
+
|
|
61
|
+
# PYFORMAT style
|
|
62
|
+
sql, params = tsql.render(query, style=PYFORMAT)
|
|
63
|
+
print(f"PYFORMAT SQL: {sql}")
|
|
64
|
+
print(f"PYFORMAT params: {params}")
|
|
65
|
+
assert sql == "SELECT * FROM users WHERE name = %(obj_name)s"
|
|
66
|
+
assert params == {"obj_name": 'Alice'}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_function_call_expression():
|
|
70
|
+
"""Function calls like func() create complex expression names."""
|
|
71
|
+
def get_name():
|
|
72
|
+
return "Bob"
|
|
73
|
+
|
|
74
|
+
query = t"SELECT * FROM users WHERE name = {get_name()}"
|
|
75
|
+
|
|
76
|
+
# NAMED style - should sanitize to :get_name__
|
|
77
|
+
sql, params = tsql.render(query, style=NAMED)
|
|
78
|
+
print(f"NAMED SQL: {sql}")
|
|
79
|
+
print(f"NAMED params: {params}")
|
|
80
|
+
assert sql == "SELECT * FROM users WHERE name = :get_name__"
|
|
81
|
+
assert params == {"get_name__": 'Bob'}
|
|
82
|
+
|
|
83
|
+
# PYFORMAT style
|
|
84
|
+
sql, params = tsql.render(query, style=PYFORMAT)
|
|
85
|
+
print(f"PYFORMAT SQL: {sql}")
|
|
86
|
+
print(f"PYFORMAT params: {params}")
|
|
87
|
+
assert sql == "SELECT * FROM users WHERE name = %(get_name__)s"
|
|
88
|
+
assert params == {"get_name__": 'Bob'}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_complex_expression_with_operators():
|
|
92
|
+
"""Complex expressions with operators."""
|
|
93
|
+
a = 5
|
|
94
|
+
b = 3
|
|
95
|
+
query = t"SELECT * FROM users WHERE age = {a + b}"
|
|
96
|
+
|
|
97
|
+
# NAMED style - should sanitize to :a___b
|
|
98
|
+
sql, params = tsql.render(query, style=NAMED)
|
|
99
|
+
print(f"NAMED SQL: {sql}")
|
|
100
|
+
print(f"NAMED params: {params}")
|
|
101
|
+
assert sql == "SELECT * FROM users WHERE age = :a___b"
|
|
102
|
+
assert params == {"a___b": 8}
|
|
103
|
+
|
|
104
|
+
# PYFORMAT style
|
|
105
|
+
sql, params = tsql.render(query, style=PYFORMAT)
|
|
106
|
+
print(f"PYFORMAT SQL: {sql}")
|
|
107
|
+
print(f"PYFORMAT params: {params}")
|
|
108
|
+
assert sql == "SELECT * FROM users WHERE age = %(a___b)s"
|
|
109
|
+
assert params == {"a___b": 8}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_method_chain_expression():
|
|
113
|
+
"""Method chaining creates very complex expression names."""
|
|
114
|
+
text = " Alice "
|
|
115
|
+
query = t"SELECT * FROM users WHERE name = {text.strip().lower()}"
|
|
116
|
+
|
|
117
|
+
# NAMED style
|
|
118
|
+
sql, params = tsql.render(query, style=NAMED)
|
|
119
|
+
print(f"NAMED SQL: {sql}")
|
|
120
|
+
print(f"NAMED params: {params}")
|
|
121
|
+
assert 'alice' in params.values()
|
|
122
|
+
|
|
123
|
+
# PYFORMAT style
|
|
124
|
+
sql, params = tsql.render(query, style=PYFORMAT)
|
|
125
|
+
print(f"PYFORMAT SQL: {sql}")
|
|
126
|
+
print(f"PYFORMAT params: {params}")
|
|
127
|
+
assert 'alice' in params.values()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_list_index_expression():
|
|
131
|
+
"""List indexing like items[0] creates indexed expression names."""
|
|
132
|
+
items = ['first', 'second', 'third']
|
|
133
|
+
query = t"SELECT * FROM users WHERE name = {items[0]}"
|
|
134
|
+
|
|
135
|
+
# NAMED style
|
|
136
|
+
sql, params = tsql.render(query, style=NAMED)
|
|
137
|
+
print(f"NAMED SQL: {sql}")
|
|
138
|
+
print(f"NAMED params: {params}")
|
|
139
|
+
assert 'first' in params.values()
|
|
140
|
+
|
|
141
|
+
# PYFORMAT style
|
|
142
|
+
sql, params = tsql.render(query, style=PYFORMAT)
|
|
143
|
+
print(f"PYFORMAT SQL: {sql}")
|
|
144
|
+
print(f"PYFORMAT params: {params}")
|
|
145
|
+
assert 'first' in params.values()
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_ternary_expression():
|
|
149
|
+
"""Ternary/conditional expressions."""
|
|
150
|
+
age = 25
|
|
151
|
+
query = t"SELECT * FROM users WHERE category = {'adult' if age >= 18 else 'minor'}"
|
|
152
|
+
|
|
153
|
+
# NAMED style
|
|
154
|
+
sql, params = tsql.render(query, style=NAMED)
|
|
155
|
+
print(f"NAMED SQL: {sql}")
|
|
156
|
+
print(f"NAMED params: {params}")
|
|
157
|
+
assert 'adult' in params.values()
|
|
158
|
+
|
|
159
|
+
# PYFORMAT style
|
|
160
|
+
sql, params = tsql.render(query, style=PYFORMAT)
|
|
161
|
+
print(f"PYFORMAT SQL: {sql}")
|
|
162
|
+
print(f"PYFORMAT params: {params}")
|
|
163
|
+
assert 'adult' in params.values()
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_multiple_complex_expressions():
|
|
167
|
+
"""Multiple complex expressions in one query - ensure unique parameter names."""
|
|
168
|
+
data = {'first': 'Alice', 'last': 'Smith'}
|
|
169
|
+
query = t"SELECT * FROM users WHERE first_name = {data['first']} AND last_name = {data['last']}"
|
|
170
|
+
|
|
171
|
+
# NAMED style
|
|
172
|
+
sql, params = tsql.render(query, style=NAMED)
|
|
173
|
+
print(f"NAMED SQL: {sql}")
|
|
174
|
+
print(f"NAMED params: {params}")
|
|
175
|
+
assert set(params.values()) == {'Alice', 'Smith'}
|
|
176
|
+
|
|
177
|
+
# PYFORMAT style
|
|
178
|
+
sql, params = tsql.render(query, style=PYFORMAT)
|
|
179
|
+
print(f"PYFORMAT SQL: {sql}")
|
|
180
|
+
print(f"PYFORMAT params: {params}")
|
|
181
|
+
assert set(params.values()) == {'Alice', 'Smith'}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_expression_with_quotes():
|
|
185
|
+
"""Expression containing quotes (from dict keys with quotes)."""
|
|
186
|
+
data = {"user's name": "Bob"}
|
|
187
|
+
key = "user's name"
|
|
188
|
+
query = t"SELECT * FROM users WHERE name = {data[key]}"
|
|
189
|
+
|
|
190
|
+
# NAMED style
|
|
191
|
+
sql, params = tsql.render(query, style=NAMED)
|
|
192
|
+
print(f"NAMED SQL: {sql}")
|
|
193
|
+
print(f"NAMED params: {params}")
|
|
194
|
+
assert 'Bob' in params.values()
|
|
195
|
+
|
|
196
|
+
# PYFORMAT style
|
|
197
|
+
sql, params = tsql.render(query, style=PYFORMAT)
|
|
198
|
+
print(f"PYFORMAT SQL: {sql}")
|
|
199
|
+
print(f"PYFORMAT params: {params}")
|
|
200
|
+
assert 'Bob' in params.values()
|
|
@@ -40,7 +40,7 @@ def test_named_style():
|
|
|
40
40
|
assert val1 == ':name'
|
|
41
41
|
assert val2 == ':foo'
|
|
42
42
|
assert val3 == ':bar'
|
|
43
|
-
assert q.params ==
|
|
43
|
+
assert q.params == {'name': 'a', 'foo': 'b', 'bar': 'c'}
|
|
44
44
|
|
|
45
45
|
|
|
46
46
|
def test_format_style():
|
|
@@ -68,7 +68,7 @@ def test_pyformat_style():
|
|
|
68
68
|
assert val1 == '%(name)s'
|
|
69
69
|
assert val2 == '%(foo)s'
|
|
70
70
|
assert val3 == '%(bar)s'
|
|
71
|
-
assert q.params ==
|
|
71
|
+
assert q.params == {'name': 'a', 'foo': 'b', 'bar': 'c'}
|
|
72
72
|
|
|
73
73
|
|
|
74
74
|
def test_numeric_dollar_style():
|
|
@@ -112,7 +112,7 @@ def test_named_style_with_integer_values():
|
|
|
112
112
|
result = render(t'SELECT * FROM users WHERE age = {age}', style=NAMED)
|
|
113
113
|
|
|
114
114
|
assert result.sql == 'SELECT * FROM users WHERE age = :age'
|
|
115
|
-
assert result.values ==
|
|
115
|
+
assert result.values == {'age': 25}
|
|
116
116
|
|
|
117
117
|
|
|
118
118
|
def test_pyformat_style_with_integer_values():
|
|
@@ -124,5 +124,5 @@ def test_pyformat_style_with_integer_values():
|
|
|
124
124
|
result = render(t'SELECT * FROM users WHERE age = {age}', style=PYFORMAT)
|
|
125
125
|
|
|
126
126
|
assert result.sql == 'SELECT * FROM users WHERE age = %(age)s'
|
|
127
|
-
assert result.values ==
|
|
127
|
+
assert result.values == {'age': 25}
|
|
128
128
|
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import re
|
|
2
2
|
import string
|
|
3
|
-
from typing import NamedTuple, Tuple, Any, List, Dict, Iterable
|
|
3
|
+
from typing import NamedTuple, Tuple, Any, List, Dict, Iterable, Union, TYPE_CHECKING
|
|
4
4
|
from string.templatelib import Template, Interpolation
|
|
5
5
|
|
|
6
6
|
from tsql.styles import ParamStyle, QMARK
|
|
7
7
|
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from tsql.query_builder import QueryBuilder
|
|
10
|
+
|
|
8
11
|
default_style = QMARK
|
|
9
12
|
|
|
10
13
|
def set_style(style: type[ParamStyle]):
|
|
@@ -213,7 +216,57 @@ def as_set(value_dict: dict[str, Any]):
|
|
|
213
216
|
return tsql_obj
|
|
214
217
|
|
|
215
218
|
|
|
216
|
-
|
|
219
|
+
# Type alias for safe, parameterized queries
|
|
220
|
+
TSQLQuery = Union[TSQL, Template, 'QueryBuilder']
|
|
221
|
+
"""Type alias representing safe, parameterized SQL queries.
|
|
222
|
+
|
|
223
|
+
This type includes all valid ways to construct safe queries in tsql:
|
|
224
|
+
- TSQL: Rendered t-string queries
|
|
225
|
+
- Template: Raw t-string templates (PEP 750)
|
|
226
|
+
- QueryBuilder: Type-safe query builder objects
|
|
227
|
+
|
|
228
|
+
Use this type to ensure functions only accept safe, parameterized queries
|
|
229
|
+
and never raw strings that could be vulnerable to SQL injection.
|
|
230
|
+
|
|
231
|
+
Examples:
|
|
232
|
+
Accept any safe query type::
|
|
233
|
+
|
|
234
|
+
def execute_query(query: TSQLQuery) -> None:
|
|
235
|
+
sql, params = render(query)
|
|
236
|
+
cursor.execute(sql, params)
|
|
237
|
+
|
|
238
|
+
Type checking prevents unsafe usage::
|
|
239
|
+
|
|
240
|
+
execute_query(t"SELECT * FROM users WHERE id = {user_id}") # OK
|
|
241
|
+
execute_query(select("users")) # OK
|
|
242
|
+
execute_query(User.select().where(User.id == 1)) # OK
|
|
243
|
+
execute_query("SELECT * FROM users") # Type error!
|
|
244
|
+
"""
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def render(query: TSQLQuery, style=None) -> RenderedQuery:
|
|
248
|
+
"""Render a safe query to SQL and parameters.
|
|
249
|
+
|
|
250
|
+
Args:
|
|
251
|
+
query: A TSQLQuery (TSQL, Template, or QueryBuilder)
|
|
252
|
+
style: Optional parameter style (defaults to global default_style)
|
|
253
|
+
|
|
254
|
+
Returns:
|
|
255
|
+
RenderedQuery with sql string and parameter values
|
|
256
|
+
|
|
257
|
+
Raises:
|
|
258
|
+
TypeError: If query is a raw string (use t-strings instead)
|
|
259
|
+
"""
|
|
260
|
+
# Handle QueryBuilder (duck typing to avoid circular import)
|
|
261
|
+
if hasattr(query, 'build') and callable(query.build):
|
|
262
|
+
query = query.build()
|
|
263
|
+
|
|
264
|
+
if isinstance(query, str):
|
|
265
|
+
raise TypeError(
|
|
266
|
+
"Cannot render raw strings - they are vulnerable to SQL injection. "
|
|
267
|
+
"Use t-strings instead: t'SELECT * FROM users WHERE id = {user_id}'"
|
|
268
|
+
)
|
|
269
|
+
|
|
217
270
|
if not isinstance(query, TSQL):
|
|
218
271
|
query = TSQL(query)
|
|
219
272
|
|
|
@@ -305,6 +358,7 @@ def delete(table: str, id: str | int) -> TSQL:
|
|
|
305
358
|
|
|
306
359
|
__all__ = [
|
|
307
360
|
'TSQL',
|
|
361
|
+
'TSQLQuery',
|
|
308
362
|
'render',
|
|
309
363
|
't_join',
|
|
310
364
|
'select',
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import abc
|
|
2
|
+
import re
|
|
2
3
|
from itertools import count
|
|
3
4
|
|
|
4
5
|
|
|
@@ -10,6 +11,33 @@ class ParamStyle(abc.ABC):
|
|
|
10
11
|
def __iter__(self):
|
|
11
12
|
raise NotImplementedError()
|
|
12
13
|
|
|
14
|
+
def _init_dict_params(self):
|
|
15
|
+
"""Initialize params as dict for named parameter styles."""
|
|
16
|
+
self.params = {}
|
|
17
|
+
|
|
18
|
+
@staticmethod
|
|
19
|
+
def _sanitize_param_name(name: str, used_names: set) -> str:
|
|
20
|
+
"""Sanitize parameter name to be a valid SQL identifier.
|
|
21
|
+
|
|
22
|
+
Preserves readable names when possible, sanitizes complex expressions.
|
|
23
|
+
Handles collisions by appending numbers.
|
|
24
|
+
"""
|
|
25
|
+
if name.isidentifier() and name not in used_names:
|
|
26
|
+
return name
|
|
27
|
+
|
|
28
|
+
sanitized = re.sub(r'[^a-zA-Z0-9_]', '_', name)
|
|
29
|
+
|
|
30
|
+
if not sanitized or not (sanitized[0].isalpha() or sanitized[0] == '_'):
|
|
31
|
+
sanitized = 'param_' + sanitized if sanitized else 'param'
|
|
32
|
+
|
|
33
|
+
if sanitized in used_names:
|
|
34
|
+
counter = 1
|
|
35
|
+
while f"{sanitized}_{counter}" in used_names:
|
|
36
|
+
counter += 1
|
|
37
|
+
sanitized = f"{sanitized}_{counter}"
|
|
38
|
+
|
|
39
|
+
return sanitized
|
|
40
|
+
|
|
13
41
|
class QMARK(ParamStyle):
|
|
14
42
|
# WHERE name=?
|
|
15
43
|
def __iter__(self):
|
|
@@ -32,11 +60,18 @@ class NUMERIC(ParamStyle):
|
|
|
32
60
|
|
|
33
61
|
class NAMED(ParamStyle):
|
|
34
62
|
# WHERE name=:name
|
|
63
|
+
def __init__(self):
|
|
64
|
+
super().__init__()
|
|
65
|
+
self._init_dict_params()
|
|
66
|
+
|
|
35
67
|
def __iter__(self):
|
|
36
68
|
name, value = yield
|
|
69
|
+
used_names = set()
|
|
37
70
|
while True:
|
|
38
|
-
self.
|
|
39
|
-
|
|
71
|
+
param_name = self._sanitize_param_name(name, used_names)
|
|
72
|
+
used_names.add(param_name)
|
|
73
|
+
self.params[param_name] = value
|
|
74
|
+
name, value = yield f':{param_name}'
|
|
40
75
|
|
|
41
76
|
|
|
42
77
|
class FORMAT(ParamStyle):
|
|
@@ -50,11 +85,18 @@ class FORMAT(ParamStyle):
|
|
|
50
85
|
|
|
51
86
|
class PYFORMAT(FORMAT):
|
|
52
87
|
# WHERE name=%(name)s
|
|
88
|
+
def __init__(self):
|
|
89
|
+
super().__init__()
|
|
90
|
+
self._init_dict_params()
|
|
91
|
+
|
|
53
92
|
def __iter__(self):
|
|
54
93
|
name, value = yield
|
|
94
|
+
used_names = set()
|
|
55
95
|
while True:
|
|
56
|
-
self.
|
|
57
|
-
|
|
96
|
+
param_name = self._sanitize_param_name(name, used_names)
|
|
97
|
+
used_names.add(param_name)
|
|
98
|
+
self.params[param_name] = value
|
|
99
|
+
name, value = yield f'%({param_name})s'
|
|
58
100
|
|
|
59
101
|
|
|
60
102
|
class NUMERIC_DOLLAR(ParamStyle):
|
|
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
|