t-sql 0.1.2__tar.gz → 1.0.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.
@@ -1,7 +1,8 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: t-sql
3
- Version: 0.1.2
3
+ Version: 1.0.0
4
4
  Summary: Safe SQL. SQL queries for python t-strings (PEP 750)
5
+ Project-URL: Homepage, https://github.com/nhumrich/tsql
5
6
  Requires-Python: >=3.14
6
7
  Description-Content-Type: text/markdown
7
8
  License-File: LICENSE
@@ -14,10 +15,27 @@ A lightweight SQL templating library that leverages Python 3.14's t-strings (PEP
14
15
  TSQL provides a safe way to write SQL queries using Python's template strings (t-strings) while preventing SQL injection attacks through multiple parameter styling options.
15
16
 
16
17
  ## ⚠️ Python Version Requirement
17
- This library requires Python 3.14b1 or newer.
18
+ This library requires Python 3.14+
18
19
 
19
20
  TSQL is built specifically to take advantage of the new t-string feature introduced in PEP 750, which is only available in Python 3.14+.
20
21
 
22
+ ## Installing
23
+
24
+ ```
25
+ # with pip
26
+ pip install t-sql
27
+
28
+ # with uv
29
+ uv add t-sql
30
+ ```
31
+
32
+ ## using
33
+
34
+ ```
35
+ import tsql
36
+
37
+ tsql.render(t"select * from users where name={name)")
38
+ ```
21
39
 
22
40
  ## Parameter Styles
23
41
 
@@ -95,7 +113,7 @@ You can use the "unsafe" format spec for these
95
113
  cases:
96
114
  ```python
97
115
  dynamic_where = input('type where clause')
98
- tsql.render(f"SELECT * FROM users WHERE {dynamic_where:unsafe}")
116
+ tsql.render(t"SELECT * FROM users WHERE {dynamic_where:unsafe}")
99
117
  ```
100
118
 
101
119
  ### as_values
@@ -103,6 +121,11 @@ tsql.render(f"SELECT * FROM users WHERE {dynamic_where:unsafe}")
103
121
  The spec `:as_values` formats a dictionary into the format:
104
122
  `(key1, key2, ...) VALUES (value1, value2, ...)` for uses in insert statements.
105
123
 
124
+ ### as_set
125
+
126
+ The spec `:as_set` formats a dictionary into the format:
127
+ `key1='?', key2='?'` for uses in update statements.
128
+
106
129
  ### traditional format_spec
107
130
 
108
131
  All other format specs should be handled as they would in a normal f-string.
@@ -1,12 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: t-sql
3
- Version: 0.1.2
4
- Summary: Safe SQL. SQL queries for python t-strings (PEP 750)
5
- Requires-Python: >=3.14
6
- Description-Content-Type: text/markdown
7
- License-File: LICENSE
8
- Dynamic: license-file
9
-
10
1
  # tsql
11
2
 
12
3
  A lightweight SQL templating library that leverages Python 3.14's t-strings (PEP 750).
@@ -14,10 +5,27 @@ A lightweight SQL templating library that leverages Python 3.14's t-strings (PEP
14
5
  TSQL provides a safe way to write SQL queries using Python's template strings (t-strings) while preventing SQL injection attacks through multiple parameter styling options.
15
6
 
16
7
  ## ⚠️ Python Version Requirement
17
- This library requires Python 3.14b1 or newer.
8
+ This library requires Python 3.14+
18
9
 
19
10
  TSQL is built specifically to take advantage of the new t-string feature introduced in PEP 750, which is only available in Python 3.14+.
20
11
 
12
+ ## Installing
13
+
14
+ ```
15
+ # with pip
16
+ pip install t-sql
17
+
18
+ # with uv
19
+ uv add t-sql
20
+ ```
21
+
22
+ ## using
23
+
24
+ ```
25
+ import tsql
26
+
27
+ tsql.render(t"select * from users where name={name)")
28
+ ```
21
29
 
22
30
  ## Parameter Styles
23
31
 
@@ -95,7 +103,7 @@ You can use the "unsafe" format spec for these
95
103
  cases:
96
104
  ```python
97
105
  dynamic_where = input('type where clause')
98
- tsql.render(f"SELECT * FROM users WHERE {dynamic_where:unsafe}")
106
+ tsql.render(t"SELECT * FROM users WHERE {dynamic_where:unsafe}")
99
107
  ```
100
108
 
101
109
  ### as_values
@@ -103,6 +111,11 @@ tsql.render(f"SELECT * FROM users WHERE {dynamic_where:unsafe}")
103
111
  The spec `:as_values` formats a dictionary into the format:
104
112
  `(key1, key2, ...) VALUES (value1, value2, ...)` for uses in insert statements.
105
113
 
114
+ ### as_set
115
+
116
+ The spec `:as_set` formats a dictionary into the format:
117
+ `key1='?', key2='?'` for uses in update statements.
118
+
106
119
  ### traditional format_spec
107
120
 
108
121
  All other format specs should be handled as they would in a normal f-string.
@@ -1,11 +1,15 @@
1
1
  [project]
2
2
  name = "t-sql"
3
- version = "0.1.2"
3
+ version = "1.0.0"
4
4
  description = "Safe SQL. SQL queries for python t-strings (PEP 750)"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.14"
7
7
  dependencies = []
8
8
 
9
+ [project.urls]
10
+ Homepage = "https://github.com/nhumrich/tsql"
11
+
12
+
9
13
  [dependency-groups]
10
14
  dev = [
11
15
  "anyio>=4.9.0",
@@ -1,3 +1,13 @@
1
+ Metadata-Version: 2.4
2
+ Name: t-sql
3
+ Version: 1.0.0
4
+ Summary: Safe SQL. SQL queries for python t-strings (PEP 750)
5
+ Project-URL: Homepage, https://github.com/nhumrich/tsql
6
+ Requires-Python: >=3.14
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Dynamic: license-file
10
+
1
11
  # tsql
2
12
 
3
13
  A lightweight SQL templating library that leverages Python 3.14's t-strings (PEP 750).
@@ -5,10 +15,27 @@ A lightweight SQL templating library that leverages Python 3.14's t-strings (PEP
5
15
  TSQL provides a safe way to write SQL queries using Python's template strings (t-strings) while preventing SQL injection attacks through multiple parameter styling options.
6
16
 
7
17
  ## ⚠️ Python Version Requirement
8
- This library requires Python 3.14b1 or newer.
18
+ This library requires Python 3.14+
9
19
 
10
20
  TSQL is built specifically to take advantage of the new t-string feature introduced in PEP 750, which is only available in Python 3.14+.
11
21
 
22
+ ## Installing
23
+
24
+ ```
25
+ # with pip
26
+ pip install t-sql
27
+
28
+ # with uv
29
+ uv add t-sql
30
+ ```
31
+
32
+ ## using
33
+
34
+ ```
35
+ import tsql
36
+
37
+ tsql.render(t"select * from users where name={name)")
38
+ ```
12
39
 
13
40
  ## Parameter Styles
14
41
 
@@ -86,7 +113,7 @@ You can use the "unsafe" format spec for these
86
113
  cases:
87
114
  ```python
88
115
  dynamic_where = input('type where clause')
89
- tsql.render(f"SELECT * FROM users WHERE {dynamic_where:unsafe}")
116
+ tsql.render(t"SELECT * FROM users WHERE {dynamic_where:unsafe}")
90
117
  ```
91
118
 
92
119
  ### as_values
@@ -94,6 +121,11 @@ tsql.render(f"SELECT * FROM users WHERE {dynamic_where:unsafe}")
94
121
  The spec `:as_values` formats a dictionary into the format:
95
122
  `(key1, key2, ...) VALUES (value1, value2, ...)` for uses in insert statements.
96
123
 
124
+ ### as_set
125
+
126
+ The spec `:as_set` formats a dictionary into the format:
127
+ `key1='?', key2='?'` for uses in update statements.
128
+
97
129
  ### traditional format_spec
98
130
 
99
131
  All other format specs should be handled as they would in a normal f-string.
@@ -148,4 +180,4 @@ def execute_sql_query(query):
148
180
 
149
181
  return sql_engine.execute(*tsql.render(query))
150
182
 
151
- ```
183
+ ```
@@ -8,7 +8,11 @@ t_sql.egg-info/top_level.txt
8
8
  tests/test_asyncpg_integration.py
9
9
  tests/test_different_object_types.py
10
10
  tests/test_escaped.py
11
+ tests/test_escaped_binary_hex.py
11
12
  tests/test_helper_functions.py
13
+ tests/test_injection_edge_cases.py
14
+ tests/test_injection_protection_validation.py
15
+ tests/test_injections_for_escaped.py
12
16
  tests/test_styles.py
13
17
  tests/test_tsql.py
14
18
  tsql/__init__.py
@@ -6,12 +6,11 @@ import tsql.styles
6
6
 
7
7
 
8
8
  # Test configuration
9
- DATABASE_URL = "postgresql://postgres:password@localhost:5432/postgres"
9
+ DATABASE_URL = "postgresql://postgres:password@localhost:5454/postgres"
10
10
 
11
11
 
12
12
  @pytest.fixture
13
13
  async def conn():
14
- """Helper to set up a clean test table and return a connection"""
15
14
  conn = await asyncpg.connect(DATABASE_URL)
16
15
 
17
16
  await conn.execute("""
@@ -32,10 +31,6 @@ async def conn():
32
31
 
33
32
 
34
33
  async def test_escaped_style_with_postgres(conn):
35
- """Test ESCAPED style works correctly with PostgreSQL"""
36
-
37
- # Insert data using ESCAPED style
38
-
39
34
  values = dict(
40
35
  name = "John O'Connor",
41
36
  age = 30,
@@ -55,7 +50,6 @@ async def test_escaped_style_with_postgres(conn):
55
50
  assert "TRUE" in query
56
51
  assert "75000.5" in query
57
52
 
58
- # Execute the query
59
53
  await conn.execute(query)
60
54
 
61
55
  # Verify data was inserted correctly
@@ -67,10 +61,6 @@ async def test_escaped_style_with_postgres(conn):
67
61
 
68
62
 
69
63
  async def test_numeric_dollar_style_with_asyncpg(conn):
70
- """Test NUMERIC_DOLLAR style works correctly with PostgreSQL"""
71
-
72
- # Insert data using ESCAPED style
73
-
74
64
  values = dict(
75
65
  name="John O'Connor",
76
66
  age=30,
@@ -88,7 +78,6 @@ async def test_numeric_dollar_style_with_asyncpg(conn):
88
78
  assert "$3" in query
89
79
  assert "'$4" in query
90
80
 
91
- # Execute the query
92
81
  await conn.execute(query)
93
82
 
94
83
  # Verify data was inserted correctly
@@ -100,8 +89,6 @@ async def test_numeric_dollar_style_with_asyncpg(conn):
100
89
 
101
90
 
102
91
  async def test_escaped_prevents_sql_injection_in_db(conn):
103
- """Test that ESCAPED style prevents SQL injection in real database"""
104
-
105
92
  # Attempt SQL injection
106
93
  malicious_name = "'; DROP TABLE test_users; --"
107
94
  age = 25
@@ -111,11 +98,9 @@ async def test_escaped_prevents_sql_injection_in_db(conn):
111
98
  style=tsql.styles.ESCAPED
112
99
  )
113
100
 
114
- # Verify the malicious input is properly escaped
115
101
  assert params == []
116
102
  assert "'''; DROP TABLE test_users; --'" in query
117
103
 
118
- # Execute the query - this should safely insert the malicious string as data
119
104
  await conn.execute(query)
120
105
 
121
106
  # Verify the table still exists and contains the escaped data
@@ -129,8 +114,6 @@ async def test_escaped_prevents_sql_injection_in_db(conn):
129
114
 
130
115
 
131
116
  async def test_numeric_dollar_style_with_asyncpg(conn):
132
- """Test NUMERIC_DOLLAR style works natively with asyncpg"""
133
-
134
117
  name = "David Wilson"
135
118
  age = 33
136
119
 
@@ -143,7 +126,6 @@ async def test_numeric_dollar_style_with_asyncpg(conn):
143
126
  assert "$1" in query and "$2" in query
144
127
  assert params == ["David Wilson", 33]
145
128
 
146
- # Execute directly (no conversion needed)
147
129
  await conn.execute(query, *params)
148
130
 
149
131
  # Verify data was inserted correctly
@@ -153,8 +135,6 @@ async def test_numeric_dollar_style_with_asyncpg(conn):
153
135
 
154
136
 
155
137
  async def test_escaped_handles_null_values_in_db(conn):
156
- """Test ESCAPED style handles NULL values correctly in database"""
157
-
158
138
  name = None
159
139
  age = 30
160
140
 
@@ -167,17 +147,14 @@ async def test_escaped_handles_null_values_in_db(conn):
167
147
  assert "NULL" in query
168
148
  assert "30" in query
169
149
 
170
- # Execute the query
171
150
  await conn.execute(query)
172
151
 
173
- # Verify NULL was inserted correctly
174
152
  row = await conn.fetchrow("SELECT * FROM test_users WHERE age = $1", 30)
175
153
  assert row['name'] is None
176
154
  assert row['age'] == 30
177
155
 
178
156
 
179
157
  async def test_escaped_complex_query_with_db(conn):
180
- """Test ESCAPED style with complex query involving multiple conditions"""
181
158
  # Insert some test data first
182
159
  await conn.execute("""
183
160
  INSERT INTO test_users (name, age, active, salary) VALUES
@@ -201,7 +178,6 @@ async def test_escaped_complex_query_with_db(conn):
201
178
  assert "'O''Brien'" in query # Single quote should be escaped
202
179
  assert "TRUE" in query
203
180
 
204
- # Execute the query
205
181
  rows = await conn.fetch(query)
206
182
 
207
183
  # Should find Charlie O'Brien
@@ -212,21 +188,16 @@ async def test_escaped_complex_query_with_db(conn):
212
188
 
213
189
 
214
190
  async def test_compare_escaped_vs_parameterized(conn):
215
- """Compare ESCAPED vs parameterized query results"""
216
-
217
- # Test data
218
191
  name = "Test User"
219
192
  age = 25
220
193
  active = True
221
194
 
222
- # Insert using ESCAPED style
223
195
  query1, params1 = tsql.render(
224
196
  t"INSERT INTO test_users (name, age, active) VALUES ({name}, {age}, {active})",
225
197
  style=tsql.styles.ESCAPED
226
198
  )
227
199
  await conn.execute(query1)
228
200
 
229
- # Insert using NUMERIC_DOLLAR style
230
201
  query2, params2 = tsql.render(
231
202
  t"INSERT INTO test_users (name, age, active) VALUES ({name}, {age}, {active})",
232
203
  style=tsql.styles.NUMERIC_DOLLAR
@@ -245,16 +216,13 @@ async def test_compare_escaped_vs_parameterized(conn):
245
216
 
246
217
 
247
218
  async def test_escaped_handles_union_attack(conn):
248
- """Test prevention of UNION-based injection"""
249
- # malicious_input = "' UNION SELECT password FROM test_users WHERE '1'='1"
250
- malicious_input = "11"
219
+ malicious_input = "' UNION SELECT password FROM test_users WHERE '1'='1"
251
220
  query, _ = tsql.render(t"SELECT * FROM test_users WHERE name = {malicious_input}", style=tsql.styles.ESCAPED)
252
221
  rows = await conn.fetch(query)
253
222
  assert len(rows) == 0
254
223
 
255
224
 
256
225
  async def test_escaped_handles_boolean_injection(conn):
257
- """Test prevention of boolean-based injection"""
258
226
  malicious_input = "' OR '1'='1"
259
227
  query, _ = tsql.render(t"SELECT * FROM test_users WHERE name = {malicious_input}", style=tsql.styles.ESCAPED)
260
228
  rows = await conn.fetch(query)
@@ -262,7 +230,6 @@ async def test_escaped_handles_boolean_injection(conn):
262
230
 
263
231
 
264
232
  async def test_escaped_handles_comment_injection(conn):
265
- """Test prevention of comment-based injection"""
266
233
  malicious_input = "admin'--"
267
234
  query, _ = tsql.render(t"SELECT * FROM test_users WHERE name = {malicious_input}", style=tsql.styles.ESCAPED)
268
235
  rows = await conn.fetch(query)
@@ -0,0 +1,146 @@
1
+ """
2
+ Test the hybrid ESCAPED style behavior with binary data
3
+ """
4
+ import tsql
5
+ import tsql.styles
6
+
7
+
8
+ def test_escaped_hex_with_mixed_types():
9
+ """Test that ESCAPED style handles mixed data types correctly"""
10
+
11
+ # Mix of different data types including binary
12
+ filename = "test.bin"
13
+ size = 1024
14
+ active = True
15
+ binary_data = b"'; DROP TABLE files; --"
16
+
17
+ result = tsql.render(
18
+ t"INSERT INTO files (name, size, active, data) VALUES ({filename}, {size}, {active}, {binary_data})",
19
+ style=tsql.styles.ESCAPED
20
+ )
21
+
22
+ # Should escape strings, numbers, bools and convert binary to hex literal
23
+ expected_hex = binary_data.hex()
24
+ expected_query = f"INSERT INTO files (name, size, active, data) VALUES ('test.bin', 1024, TRUE, '\\x{expected_hex}')"
25
+ assert result[0] == expected_query
26
+ assert result[1] == [] # No parameters - fully escaped
27
+
28
+
29
+ def test_escaped_hex_multiple_binary_values():
30
+ """Test ESCAPED style with multiple binary values"""
31
+
32
+ binary1 = b"first'; DROP TABLE test; --"
33
+ binary2 = b"second'; DELETE FROM users; --"
34
+ name = "test"
35
+
36
+ result = tsql.render(
37
+ t"INSERT INTO files (name, data1, data2) VALUES ({name}, {binary1}, {binary2})",
38
+ style=tsql.styles.ESCAPED
39
+ )
40
+
41
+ # Should convert both binary values to hex literals
42
+ hex1 = binary1.hex()
43
+ hex2 = binary2.hex()
44
+ expected_query = f"INSERT INTO files (name, data1, data2) VALUES ('test', '\\x{hex1}', '\\x{hex2}')"
45
+ assert result[0] == expected_query
46
+ assert result[1] == [] # No parameters - fully escaped
47
+
48
+
49
+ def test_escaped_hex_binary_with_quotes():
50
+ """Test that binary data with quotes is safely converted to hex"""
51
+
52
+ # Binary data containing various injection attempts
53
+ malicious_binary = b"'; DELETE FROM users WHERE '1'='1'; --"
54
+ safe_string = "normal string"
55
+
56
+ result = tsql.render(
57
+ t"UPDATE files SET name = {safe_string}, data = {malicious_binary} WHERE id = 1",
58
+ style=tsql.styles.ESCAPED
59
+ )
60
+
61
+ expected_hex = malicious_binary.hex()
62
+ expected_query = f"UPDATE files SET name = 'normal string', data = '\\x{expected_hex}' WHERE id = 1"
63
+ assert result[0] == expected_query
64
+ assert result[1] == [] # No parameters - fully escaped
65
+
66
+
67
+ def test_escaped_all_string_no_hybrid():
68
+ """Test that pure string queries remain fully escaped (no parameters)"""
69
+
70
+ name = "file.txt"
71
+ content = "some content"
72
+
73
+ result = tsql.render(
74
+ t"INSERT INTO files (name, content) VALUES ({name}, {content})",
75
+ style=tsql.styles.ESCAPED
76
+ )
77
+
78
+ # Should be fully escaped with no parameters
79
+ expected_query = "INSERT INTO files (name, content) VALUES ('file.txt', 'some content')"
80
+ assert result[0] == expected_query
81
+ assert result[1] == []
82
+
83
+
84
+ def test_escaped_string_injection_still_escaped():
85
+ """Test that string injection attempts are still properly escaped"""
86
+
87
+ malicious_string = "'; DROP TABLE users; --"
88
+
89
+ result = tsql.render(
90
+ t"SELECT * FROM users WHERE name = {malicious_string}",
91
+ style=tsql.styles.ESCAPED
92
+ )
93
+
94
+ # String should be escaped, no parameters
95
+ expected_query = "SELECT * FROM users WHERE name = '''; DROP TABLE users; --'"
96
+ assert result[0] == expected_query
97
+ assert result[1] == []
98
+
99
+
100
+ def test_escaped_binary_only_hex():
101
+ """Test ESCAPED style with only binary data (pure hex literal)"""
102
+
103
+ binary_data = b"binary content with '; injection"
104
+
105
+ result = tsql.render(
106
+ t"INSERT INTO files (data) VALUES ({binary_data})",
107
+ style=tsql.styles.ESCAPED
108
+ )
109
+
110
+ # Should be fully escaped as hex literal
111
+ expected_hex = binary_data.hex()
112
+ expected_query = f"INSERT INTO files (data) VALUES ('\\x{expected_hex}')"
113
+ assert result[0] == expected_query
114
+ assert result[1] == [] # No parameters - fully escaped
115
+
116
+
117
+ def test_escaped_empty_binary_data():
118
+ """Test ESCAPED style with empty binary data"""
119
+
120
+ empty_binary = b""
121
+ name = "empty.bin"
122
+
123
+ result = tsql.render(
124
+ t"INSERT INTO files (name, data) VALUES ({name}, {empty_binary})",
125
+ style=tsql.styles.ESCAPED
126
+ )
127
+
128
+ expected_query = "INSERT INTO files (name, data) VALUES ('empty.bin', '\\x')"
129
+ assert result[0] == expected_query
130
+ assert result[1] == [] # No parameters - fully escaped
131
+
132
+
133
+ def test_escaped_binary_with_null_bytes():
134
+ """Test ESCAPED style with binary data containing null bytes"""
135
+
136
+ binary_with_nulls = b"data\x00with\x00nulls\x00'; DROP TABLE test; --"
137
+
138
+ result = tsql.render(
139
+ t"INSERT INTO files (data) VALUES ({binary_with_nulls})",
140
+ style=tsql.styles.ESCAPED
141
+ )
142
+
143
+ expected_hex = binary_with_nulls.hex()
144
+ expected_query = f"INSERT INTO files (data) VALUES ('\\x{expected_hex}')"
145
+ assert result[0] == expected_query
146
+ assert result[1] == [] # No parameters - fully escaped
@@ -21,7 +21,7 @@ def test_as_values():
21
21
  'age': 30
22
22
  }
23
23
  result = tsql.render(t"INSERT INTO users {values:as_values}")
24
-
24
+
25
25
  # Should generate INSERT INTO users (name, age) VALUES (?, ?)
26
26
  assert "INSERT INTO users" in result[0]
27
27
  assert "name" in result[0] and "age" in result[0]
@@ -29,6 +29,22 @@ def test_as_values():
29
29
  assert result[1] == ['John', 30]
30
30
 
31
31
 
32
+ def test_as_set():
33
+ """Test the as_set format specifier"""
34
+ values = {
35
+ 'name': 'John Updated',
36
+ 'age': 35
37
+ }
38
+ result = tsql.render(t"UPDATE users SET {values:as_set} WHERE id = {123}")
39
+
40
+ # Should generate UPDATE users SET name = ?, age = ? WHERE id = ?
41
+ assert "UPDATE users SET" in result[0]
42
+ assert "name = ?" in result[0]
43
+ assert "age = ?" in result[0]
44
+ assert "WHERE id = ?" in result[0]
45
+ assert result[1] == ['John Updated', 35, 123]
46
+
47
+
32
48
  def test_insert():
33
49
  """Test the insert helper function"""
34
50
  values = {
@@ -0,0 +1,161 @@
1
+ import pytest
2
+ import tsql
3
+ import tsql.styles
4
+
5
+
6
+ def test_literal_injection_prevention():
7
+ malicious_table = "users; DROP TABLE secrets; --"
8
+
9
+ with pytest.raises(ValueError):
10
+ tsql.render(t"SELECT * FROM {malicious_table:literal}")
11
+
12
+
13
+ def test_literal_with_quotes_prevention():
14
+ malicious_column = "name'; DROP TABLE users; --"
15
+
16
+ with pytest.raises(ValueError):
17
+ tsql.render(t"SELECT {malicious_column:literal} FROM users")
18
+
19
+
20
+ def test_binary_data_injection():
21
+ """Test handling of binary data that might contain SQL"""
22
+ # Binary data that contains SQL-like bytes
23
+ malicious_binary = b"'; DROP TABLE users; --"
24
+
25
+ # Test parameterized style (default)
26
+ result = tsql.render(t"INSERT INTO files (data) VALUES ({malicious_binary})")
27
+ assert result[0] == "INSERT INTO files (data) VALUES (?)"
28
+ # Binary data should be passed through as bytes, not converted to string
29
+ assert result[1] == [malicious_binary]
30
+ assert isinstance(result[1][0], bytes)
31
+
32
+ # Test ESCAPED style - should convert to safe hex literal (no parameters)
33
+ result_escaped = tsql.render(
34
+ t"INSERT INTO files (data) VALUES ({malicious_binary})",
35
+ style=tsql.styles.ESCAPED
36
+ )
37
+ expected_hex = malicious_binary.hex()
38
+ assert result_escaped[0] == f"INSERT INTO files (data) VALUES ('\\x{expected_hex}')"
39
+ assert result_escaped[1] == []
40
+
41
+
42
+ def test_cross_parameter_injection():
43
+ """Test prevention of injection across multiple parameters"""
44
+ param1 = "'; --"
45
+ param2 = "OR 1=1"
46
+
47
+ # Test parameterized style (default)
48
+ result = tsql.render(t"SELECT * FROM users WHERE name = {param1} AND role = {param2}")
49
+ assert result[0] == "SELECT * FROM users WHERE name = ? AND role = ?"
50
+ assert result[1] == [param1, param2]
51
+
52
+ # Test ESCAPED style
53
+ result_escaped = tsql.render(t"SELECT * FROM users WHERE name = {param1} AND role = {param2}", style=tsql.styles.ESCAPED)
54
+ expected_param1 = param1.replace("'", "''")
55
+ expected_param2 = param2.replace("'", "''")
56
+ assert result_escaped[0] == f"SELECT * FROM users WHERE name = '{expected_param1}' AND role = '{expected_param2}'"
57
+ assert result_escaped[1] == []
58
+
59
+
60
+ def test_nested_template_injection():
61
+ """Test injection prevention in nested templates"""
62
+ malicious_value = "'; DROP TABLE users; --"
63
+ inner_template = t"WHERE id = {malicious_value}"
64
+
65
+ # Test parameterized style (default)
66
+ result = tsql.render(t"SELECT * FROM users {inner_template}")
67
+ assert result[0] == "SELECT * FROM users WHERE id = ?"
68
+ assert result[1] == [malicious_value]
69
+
70
+ # Test ESCAPED style
71
+ result_escaped = tsql.render(t"SELECT * FROM users {inner_template}", style=tsql.styles.ESCAPED)
72
+ expected_escaped = malicious_value.replace("'", "''")
73
+ assert result_escaped[0] == f"SELECT * FROM users WHERE id = '{expected_escaped}'"
74
+ assert result_escaped[1] == []
75
+
76
+
77
+ def test_helper_function_injection():
78
+ """Test injection prevention in helper functions"""
79
+ malicious_table = "users; DROP TABLE secrets; --"
80
+ malicious_id = "1; DELETE FROM users; --"
81
+
82
+ # This should raise ValueError for malicious table name
83
+ with pytest.raises(ValueError):
84
+ tsql.select(malicious_table, malicious_id)
85
+
86
+
87
+ def test_large_payload_injection():
88
+ """Test handling of very large malicious payloads"""
89
+ large_payload = "'; " + "DROP TABLE users; " * 1000 + "--"
90
+
91
+ # Test parameterized style (default)
92
+ result = tsql.render(t"SELECT * FROM users WHERE name = {large_payload}")
93
+ assert result[0] == "SELECT * FROM users WHERE name = ?"
94
+ assert result[1] == [large_payload]
95
+
96
+ # Test ESCAPED style
97
+ result_escaped = tsql.render(t"SELECT * FROM users WHERE name = {large_payload}", style=tsql.styles.ESCAPED)
98
+ expected_escaped = large_payload.replace("'", "''")
99
+ assert result_escaped[0] == f"SELECT * FROM users WHERE name = '{expected_escaped}'"
100
+ assert result_escaped[1] == []
101
+
102
+
103
+ def test_encoding_bypass_attempts():
104
+ """Test prevention of encoding-based bypass attempts"""
105
+ encoding_attacks = [
106
+ "'; DROP TABLE users; --", # Normal
107
+ "\'; DROP TABLE users; --", # Backslash escaped
108
+ "\\'; DROP TABLE users; --", # Double backslash
109
+ "%27; DROP TABLE users; --", # URL encoded single quote
110
+ "' DROP TABLE users; --" # HTML entity
111
+ ]
112
+
113
+ for attack in encoding_attacks:
114
+ # Test parameterized style (default)
115
+ result = tsql.render(t"SELECT * FROM users WHERE name = {attack}")
116
+ assert result[0] == "SELECT * FROM users WHERE name = ?"
117
+ assert result[1] == [attack]
118
+
119
+ # Test ESCAPED style
120
+ result_escaped = tsql.render(t"SELECT * FROM users WHERE name = {attack}", style=tsql.styles.ESCAPED)
121
+ expected_escaped = attack.replace("'", "''")
122
+ assert result_escaped[0] == f"SELECT * FROM users WHERE name = '{expected_escaped}'"
123
+ assert result_escaped[1] == []
124
+
125
+
126
+ def test_database_specific_functions():
127
+ """Test prevention of database-specific function injection"""
128
+ db_specific_attacks = [
129
+ "'; SELECT sqlite_version(); --", # SQLite
130
+ "'; SELECT @@version; --", # SQL Server/MySQL
131
+ "'; SELECT version(); --", # PostgreSQL
132
+ "'; SELECT banner FROM v$version; --", # Oracle
133
+ ]
134
+
135
+ for attack in db_specific_attacks:
136
+ # Test parameterized style (default)
137
+ result = tsql.render(t"SELECT * FROM users WHERE id = {attack}")
138
+ assert result[0] == "SELECT * FROM users WHERE id = ?"
139
+ assert result[1] == [attack]
140
+
141
+ # Test ESCAPED style
142
+ result_escaped = tsql.render(t"SELECT * FROM users WHERE id = {attack}", style=tsql.styles.ESCAPED)
143
+ expected_escaped = attack.replace("'", "''")
144
+ assert result_escaped[0] == f"SELECT * FROM users WHERE id = '{expected_escaped}'"
145
+ assert result_escaped[1] == []
146
+
147
+
148
+ def test_unsafe_parameter_injection():
149
+ """Test that :unsafe parameters are properly handled (if implemented)"""
150
+ # Note: This assumes :unsafe is implemented - remove if not
151
+ malicious_value = "'; DROP TABLE users; --"
152
+
153
+ # :unsafe should allow the value through without parameterization
154
+ # but this is intentionally dangerous and should be used carefully
155
+ result = tsql.render(t"SELECT * FROM users WHERE debug = {malicious_value:unsafe}")
156
+ # This will depend on your :unsafe implementation
157
+ assert "DROP TABLE users" in result[0] # Should be directly embedded
158
+
159
+ # Test ESCAPED style with :unsafe (should behave the same)
160
+ result_escaped = tsql.render(t"SELECT * FROM users WHERE debug = {malicious_value:unsafe}", style=tsql.styles.ESCAPED)
161
+ assert "DROP TABLE users" in result_escaped[0] # Should be directly embedded
@@ -0,0 +1,237 @@
1
+ import asyncpg
2
+ import pytest
3
+
4
+ import tsql
5
+ import tsql.styles
6
+
7
+ # Test configuration
8
+ DATABASE_URL = "postgresql://postgres:password@localhost:5454/postgres"
9
+
10
+
11
+ @pytest.fixture
12
+ async def conn():
13
+ """Helper to set up a clean test table and return a connection"""
14
+ conn = await asyncpg.connect(DATABASE_URL)
15
+
16
+ # Create a test table with sensitive data
17
+ await conn.execute("""
18
+ CREATE TABLE IF NOT EXISTS test_users (
19
+ id SERIAL PRIMARY KEY,
20
+ name VARCHAR(100),
21
+ password VARCHAR(100),
22
+ role VARCHAR(50),
23
+ salary DECIMAL(10,2),
24
+ active BOOLEAN DEFAULT TRUE
25
+ )
26
+ """)
27
+
28
+ # Create a sensitive admin table
29
+ await conn.execute("""
30
+ CREATE TABLE IF NOT EXISTS admin_secrets (
31
+ id SERIAL PRIMARY KEY,
32
+ secret_key VARCHAR(255),
33
+ admin_password VARCHAR(100)
34
+ )
35
+ """)
36
+
37
+ # Clean up existing data
38
+ await conn.execute("DELETE FROM test_users")
39
+ await conn.execute("DELETE FROM admin_secrets")
40
+
41
+ # Insert some test data
42
+ await conn.execute("""
43
+ INSERT INTO test_users (name, password, role, salary) VALUES
44
+ ('alice', 'secret123', 'user', 50000),
45
+ ('bob', 'password456', 'user', 60000),
46
+ ('admin', 'supersecret', 'admin', 100000)
47
+ """)
48
+
49
+ await conn.execute("""
50
+ INSERT INTO admin_secrets (secret_key, admin_password) VALUES
51
+ ('TOP_SECRET_KEY_123', 'admin_master_password')
52
+ """)
53
+
54
+ yield conn
55
+
56
+ await conn.execute("DROP TABLE IF EXISTS test_users")
57
+ await conn.execute("DROP TABLE IF EXISTS admin_secrets")
58
+ await conn.close()
59
+
60
+
61
+ async def test_parameterized_injection_protection(conn):
62
+ """Verify parameterized queries properly protect against injection"""
63
+
64
+ # Classic injection attempts
65
+ injection_attempts = [
66
+ "' OR 1=1 --",
67
+ "'; DROP TABLE test_users; --",
68
+ "' UNION SELECT secret_key, admin_password, 'hacker', 0, true FROM admin_secrets --",
69
+ "admin'--"
70
+ ]
71
+
72
+ for malicious_input in injection_attempts:
73
+ query, params = tsql.render(
74
+ t"SELECT * FROM test_users WHERE name = {malicious_input}",
75
+ style=tsql.styles.NUMERIC_DOLLAR # Use asyncpg-compatible style
76
+ )
77
+
78
+ # Should be parameterized with $1 style
79
+ assert "$1" in query
80
+ assert params == [malicious_input]
81
+
82
+ # Execute the parameterized query - should safely find no matching users
83
+ rows = await conn.fetch(query, *params)
84
+ assert len(rows) == 0
85
+
86
+ # Verify database integrity - original data should be intact
87
+ user_count = await conn.fetchval("SELECT COUNT(*) FROM test_users")
88
+ admin_count = await conn.fetchval("SELECT COUNT(*) FROM admin_secrets")
89
+ assert user_count == 3 # Original test users
90
+ assert admin_count == 1 # Original admin secret
91
+
92
+
93
+ async def test_escaped_injection_protection(conn):
94
+ """Verify ESCAPED style properly protects against injection"""
95
+
96
+ injection_attempts = [
97
+ "' OR 1=1 --",
98
+ "'; DROP TABLE test_users; --",
99
+ "' UNION SELECT secret_key FROM admin_secrets --",
100
+ "admin'--"
101
+ ]
102
+
103
+ for malicious_input in injection_attempts:
104
+ query, params = tsql.render(
105
+ t"SELECT * FROM test_users WHERE name = {malicious_input}",
106
+ style=tsql.styles.ESCAPED
107
+ )
108
+
109
+ # Should be escaped (no parameters)
110
+ assert params == []
111
+ escaped_input = malicious_input.replace("'", "''")
112
+ assert f"'{escaped_input}'" in query
113
+
114
+ # Execute the escaped query - should safely find no matching users
115
+ rows = await conn.fetch(query)
116
+ assert len(rows) == 0
117
+
118
+ # Verify database integrity
119
+ user_count = await conn.fetchval("SELECT COUNT(*) FROM test_users")
120
+ admin_count = await conn.fetchval("SELECT COUNT(*) FROM admin_secrets")
121
+ assert user_count == 3
122
+ assert admin_count == 1
123
+
124
+
125
+ async def test_injection_treated_as_literal_data(conn):
126
+ """Verify injection attempts are treated as literal data, not SQL code"""
127
+
128
+ # Insert the malicious input as literal data
129
+ malicious_name = "'; DROP TABLE test_users; --"
130
+
131
+ # First, insert this as actual data using parameterized query
132
+ insert_query, insert_params = tsql.render(
133
+ t"INSERT INTO test_users (name, role) VALUES ({malicious_name}, 'test')",
134
+ style=tsql.styles.NUMERIC_DOLLAR
135
+ )
136
+ await conn.execute(insert_query, *insert_params)
137
+
138
+ # Now search for it using both styles
139
+ for style in [tsql.styles.NUMERIC_DOLLAR, tsql.styles.ESCAPED]:
140
+ query, params = tsql.render(
141
+ t"SELECT * FROM test_users WHERE name = {malicious_name}",
142
+ style=style
143
+ )
144
+
145
+ rows = await conn.fetch(query, *params if params else [])
146
+
147
+ # Should find exactly 1 row with the malicious string as literal data
148
+ assert len(rows) == 1
149
+ assert rows[0]['name'] == malicious_name
150
+ assert rows[0]['role'] == 'test'
151
+
152
+ # Verify the table wasn't dropped by the "injection"
153
+ user_count = await conn.fetchval("SELECT COUNT(*) FROM test_users")
154
+ assert user_count == 4 # 3 original + 1 with malicious name
155
+
156
+
157
+ async def test_boolean_bypass_prevention(conn):
158
+ """Verify OR 1=1 type attacks don't bypass authentication"""
159
+
160
+ # Simulate authentication bypass attempt
161
+ username = "nonexistent"
162
+ malicious_password = "' OR 1=1 --"
163
+
164
+ for style in [tsql.styles.NUMERIC_DOLLAR, tsql.styles.ESCAPED]:
165
+ query, params = tsql.render(
166
+ t"SELECT * FROM test_users WHERE name = {username} AND password = {malicious_password}",
167
+ style=style
168
+ )
169
+
170
+ rows = await conn.fetch(query, *params if params else [])
171
+
172
+ # Should return 0 rows - authentication should fail
173
+ assert len(rows) == 0
174
+
175
+
176
+ async def test_admin_escalation_prevention(conn):
177
+ """Verify injection can't escalate privileges to admin"""
178
+
179
+ malicious_inputs = [
180
+ "' OR role = 'admin' --",
181
+ "' UNION SELECT * FROM test_users WHERE role = 'admin' --",
182
+ "anything' OR 1=1 --"
183
+ ]
184
+
185
+ for malicious_input in malicious_inputs:
186
+ for style in [tsql.styles.NUMERIC_DOLLAR, tsql.styles.ESCAPED]:
187
+ query, params = tsql.render(
188
+ t"SELECT * FROM test_users WHERE name = {malicious_input} AND role = 'user'",
189
+ style=style
190
+ )
191
+
192
+ rows = await conn.fetch(query, *params if params else [])
193
+
194
+ # Should not return admin users
195
+ for row in rows:
196
+ assert row.get('role') != 'admin'
197
+ assert row.get('name') != 'admin'
198
+
199
+
200
+ async def test_literal_parameter_protection(conn):
201
+ """Verify :literal parameters reject malicious table/column names"""
202
+
203
+ malicious_table_names = [
204
+ "test_users; DROP TABLE admin_secrets; --",
205
+ "test_users UNION SELECT * FROM admin_secrets --",
206
+ "test_users' OR 1=1 --"
207
+ ]
208
+
209
+ for malicious_table in malicious_table_names:
210
+ # Should raise ValueError before reaching database
211
+ with pytest.raises(ValueError):
212
+ tsql.render(t"SELECT * FROM {malicious_table:literal}")
213
+
214
+
215
+ async def test_stacked_query_prevention(conn):
216
+ """Verify stacked queries can't execute additional commands"""
217
+
218
+ malicious_input = "'; INSERT INTO test_users (name, role) VALUES ('hacker', 'admin'); --"
219
+
220
+ original_count = await conn.fetchval("SELECT COUNT(*) FROM test_users")
221
+
222
+ for style in [tsql.styles.NUMERIC_DOLLAR, tsql.styles.ESCAPED]:
223
+ query, params = tsql.render(
224
+ t"SELECT * FROM test_users WHERE name = {malicious_input}",
225
+ style=style
226
+ )
227
+
228
+ rows = await conn.fetch(query, *params if params else [])
229
+ assert len(rows) == 0
230
+
231
+ # Verify no hacker user was inserted
232
+ final_count = await conn.fetchval("SELECT COUNT(*) FROM test_users")
233
+ assert final_count == original_count
234
+
235
+ # Explicitly check no hacker exists
236
+ hacker_count = await conn.fetchval("SELECT COUNT(*) FROM test_users WHERE name = 'hacker'")
237
+ assert hacker_count == 0
@@ -0,0 +1,163 @@
1
+ """
2
+ Proof-of-concept test demonstrating that tsql actually prevents SQL injection attacks
3
+ against a real PostgreSQL database.
4
+ """
5
+ import asyncpg
6
+ import pytest
7
+
8
+ import tsql
9
+ import tsql.styles
10
+
11
+ DATABASE_URL = "postgresql://postgres:password@localhost:5454/postgres"
12
+
13
+
14
+ @pytest.fixture
15
+ async def conn():
16
+ """Set up test database with sensitive data"""
17
+ conn = await asyncpg.connect(DATABASE_URL)
18
+
19
+ await conn.execute("""
20
+ CREATE TABLE IF NOT EXISTS users (
21
+ id SERIAL PRIMARY KEY,
22
+ username VARCHAR(50),
23
+ password VARCHAR(100),
24
+ is_admin BOOLEAN DEFAULT FALSE
25
+ )
26
+ """)
27
+
28
+ await conn.execute("""
29
+ CREATE TABLE IF NOT EXISTS secrets (
30
+ id SERIAL PRIMARY KEY,
31
+ secret_data VARCHAR(255)
32
+ )
33
+ """)
34
+
35
+ # Clean up previous data if exsist
36
+ await conn.execute("DELETE FROM users")
37
+ await conn.execute("DELETE FROM secrets")
38
+
39
+ # Insert test data
40
+ await conn.execute("""
41
+ INSERT INTO users (username, password, is_admin) VALUES
42
+ ('alice', 'password123', false),
43
+ ('admin', 'supersecret', true)
44
+ """)
45
+
46
+ await conn.execute("""
47
+ INSERT INTO secrets (secret_data) VALUES
48
+ ('TOP_SECRET_ADMIN_KEY')
49
+ """)
50
+
51
+ yield conn
52
+
53
+ # Cleanup
54
+ await conn.execute("DROP TABLE IF EXISTS users")
55
+ await conn.execute("DROP TABLE IF EXISTS secrets")
56
+ await conn.close()
57
+
58
+
59
+ async def test_basic_injection_attack_with_real_database(conn):
60
+ malicious_username = "' OR 1=1 --"
61
+ fake_password = "anything"
62
+
63
+ query, params = tsql.render(
64
+ t"SELECT * FROM users WHERE username = {malicious_username} AND password = {fake_password}",
65
+ style=tsql.styles.ESCAPED
66
+ )
67
+
68
+ rows = await conn.fetch(query)
69
+
70
+ assert len(rows) == 0
71
+
72
+ async def test_union_injection_attack_with_real_database(conn):
73
+ malicious_username = "' UNION SELECT id, secret_data, 'fake', true FROM secrets --"
74
+ query, params = tsql.render(
75
+ t"SELECT * FROM users WHERE username = {malicious_username}",
76
+ style=tsql.styles.ESCAPED
77
+ )
78
+
79
+ rows = await conn.fetch(query)
80
+
81
+ assert len(rows) == 0
82
+
83
+ async def test_stacked_injection_attack_with_real_database(conn):
84
+ malicious_username = "'; DROP TABLE secrets; --"
85
+
86
+ query, params = tsql.render(
87
+ t"SELECT * FROM users WHERE username = {malicious_username}",
88
+ style=tsql.styles.ESCAPED
89
+ )
90
+
91
+ rows = await conn.fetch(query)
92
+ assert len(rows) == 0
93
+
94
+ secret_count = await conn.fetchval("SELECT COUNT(*) FROM secrets")
95
+ assert secret_count == 1
96
+
97
+
98
+ async def test_legitimate_usage_still_works(conn):
99
+ username = "alice"
100
+
101
+ query, params = tsql.render(
102
+ t"SELECT * FROM users WHERE username = {username}",
103
+ style=tsql.styles.ESCAPED
104
+ )
105
+
106
+ rows = await conn.fetch(query)
107
+
108
+ assert len(rows) == 1
109
+ assert rows[0]['username'] == 'alice'
110
+ assert rows[0]['is_admin'] is False
111
+
112
+
113
+ async def test_malicious_data_stored_safely(conn):
114
+ """Verify malicious strings can be safely stored and retrieved as data"""
115
+
116
+ # Store what looks like SQL injection as actual data
117
+ malicious_data = "'; DROP TABLE users; --"
118
+
119
+ # Insert it safely
120
+ query, params = tsql.render(
121
+ t"INSERT INTO users (username, password, is_admin) VALUES ({malicious_data}, 'test123', false)",
122
+ style=tsql.styles.ESCAPED
123
+ )
124
+
125
+ await conn.execute(query)
126
+
127
+ # Retrieve it safely
128
+ query, params = tsql.render(
129
+ t"SELECT * FROM users WHERE username = {malicious_data}",
130
+ style=tsql.styles.ESCAPED
131
+ )
132
+
133
+ rows = await conn.fetch(query)
134
+
135
+ assert len(rows) == 1
136
+ assert rows[0]['username'] == malicious_data
137
+
138
+ # Verify the database wasn't compromised
139
+ user_count = await conn.fetchval("SELECT COUNT(*) FROM users")
140
+ assert user_count == 3 # alice + admin + our malicious_data user
141
+
142
+
143
+
144
+ async def test_error_based_injection_patterns():
145
+ error_attacks = [
146
+ "' AND (SELECT TOP 1 name FROM sysobjects WHERE xtype='U') > 0 --",
147
+ "' AND EXTRACTVALUE(1, CONCAT(0x7e, (SELECT version()), 0x7e)) --",
148
+ "' AND (SELECT * FROM (SELECT COUNT(*),CONCAT(version(),FLOOR(RAND(0)*2))x FROM information_schema.tables GROUP BY x)a) --"
149
+ ]
150
+
151
+ for attack in error_attacks:
152
+ # Test parameterized style (default)
153
+ query, params = tsql.render(t"SELECT * FROM users WHERE id = {attack}", s)
154
+
155
+ rows = await conn.fetch(query)
156
+ assert result[0] == "SELECT * FROM users WHERE id = ?"
157
+ assert result[1] == [attack]
158
+
159
+ # Test ESCAPED style
160
+ result_escaped = tsql.render(t"SELECT * FROM users WHERE id = {attack}", style=tsql.styles.ESCAPED)
161
+ expected_escaped = attack.replace("'", "''")
162
+ assert result_escaped[0] == f"SELECT * FROM users WHERE id = '{expected_escaped}'"
163
+ assert result_escaped[1] == []
@@ -44,7 +44,6 @@ class TSQL:
44
44
  self._sql_parts = self._sqlize(template_string)
45
45
 
46
46
  def render(self, style:ParamStyle = None) -> RenderedQuery:
47
- print(self._sql_parts)
48
47
  if style is None:
49
48
  style = default_style
50
49
  result = ''
@@ -86,7 +85,6 @@ class TSQL:
86
85
  if val.conversion:
87
86
  value = formatter.convert_field(value, val.conversion)
88
87
 
89
- print('i', val.format_spec, value, type(value))
90
88
  match val.format_spec, value:
91
89
  case 'literal', str():
92
90
  cls._check_literal(value)
@@ -95,6 +93,8 @@ class TSQL:
95
93
  return [value]
96
94
  case 'as_values', dict():
97
95
  return as_values(value)._sql_parts
96
+ case 'as_set', dict():
97
+ return as_set(value)._sql_parts
98
98
  case '', TSQL():
99
99
  return val.value._sql_parts
100
100
  case "", Template():
@@ -113,6 +113,8 @@ class TSQL:
113
113
  return inner
114
114
  case _, str():
115
115
  return [Parameter(val.expression, formatter.format_field(value, val.format_spec))]
116
+ case _, bytes():
117
+ return [Parameter(val.expression, value)]
116
118
  case _, int():
117
119
  return [Parameter(val.value, val.value)]
118
120
 
@@ -120,7 +122,6 @@ class TSQL:
120
122
  return [Parameter(val.expression, formatter.format_field(value, val.format_spec))]
121
123
 
122
124
  if isinstance(val, Template):
123
- print('t', val)
124
125
  result = []
125
126
  for item in val:
126
127
  if isinstance(item, Interpolation):
@@ -147,7 +148,7 @@ def as_values(value_dict: dict[str, Any]):
147
148
  """Convert a dictionary to SQL column list and VALUES clause"""
148
149
  keys = list(value_dict.keys())
149
150
  values = list(value_dict.values())
150
-
151
+
151
152
  # Build column list: (col1, col2, col3)
152
153
  column_parts = ['(']
153
154
  for i, key in enumerate(keys):
@@ -155,7 +156,7 @@ def as_values(value_dict: dict[str, Any]):
155
156
  column_parts.append(', ')
156
157
  column_parts.append(key)
157
158
  column_parts.append(')')
158
-
159
+
159
160
  # Build values list: (?, ?, ?)
160
161
  value_parts = [' VALUES (']
161
162
  for i, value in enumerate(values):
@@ -163,13 +164,33 @@ def as_values(value_dict: dict[str, Any]):
163
164
  value_parts.append(', ')
164
165
  value_parts.append(Parameter(f'value_{i}', value))
165
166
  value_parts.append(')')
166
-
167
+
167
168
  # Create TSQL object manually
168
169
  tsql_obj = TSQL.__new__(TSQL)
169
170
  tsql_obj._sql_parts = column_parts + value_parts
170
171
  return tsql_obj
171
172
 
172
173
 
174
+ def as_set(value_dict: dict[str, Any]):
175
+ """Convert a dictionary to SQL SET clause for UPDATE statements"""
176
+ keys = list(value_dict.keys())
177
+ values = list(value_dict.values())
178
+
179
+ # Build SET clause: col1 = ?, col2 = ?, col3 = ?
180
+ set_parts = []
181
+ for i, (key, value) in enumerate(zip(keys, values)):
182
+ if i > 0:
183
+ set_parts.append(', ')
184
+ set_parts.append(key)
185
+ set_parts.append(' = ')
186
+ set_parts.append(Parameter(f'value_{i}', value))
187
+
188
+ # Create TSQL object manually
189
+ tsql_obj = TSQL.__new__(TSQL)
190
+ tsql_obj._sql_parts = set_parts
191
+ return tsql_obj
192
+
193
+
173
194
  def render(query: Template|TSQL, style=None) -> RenderedQuery:
174
195
  if not isinstance(query, TSQL):
175
196
  query = TSQL(query)
@@ -219,5 +240,5 @@ def update(table: str, values: dict[str, Any], id: str):
219
240
  if not isinstance(values, dict):
220
241
  raise ValueError("values must be a dictionary")
221
242
 
222
- return TSQL(t"UPDATE {table:literal} SET {values:as_values} WHERE id = {id} RETURNING *")
243
+ return TSQL(t"UPDATE {table:literal} SET {values:as_set} WHERE id = {id} RETURNING *")
223
244
 
@@ -85,6 +85,10 @@ class ESCAPED(ParamStyle):
85
85
  return "TRUE" if value else "FALSE"
86
86
  case int() | float():
87
87
  return str(value)
88
+ case bytes():
89
+ # Convert binary data to hex literal - safe from injection since hex only contains [0-9A-F]
90
+ hex_data = value.hex()
91
+ return f"'\\x{hex_data}'"
88
92
  case _:
89
93
  # For other types, convert to string and escape
90
94
  return f"'{str(value).replace("'", "''")}'"
File without changes
File without changes
File without changes
File without changes
File without changes