t-sql 4.5.0__tar.gz → 4.5.2__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.5.0 → t_sql-4.5.2}/PKG-INFO +1 -1
- {t_sql-4.5.0 → t_sql-4.5.2}/pyproject.toml +1 -1
- {t_sql-4.5.0 → t_sql-4.5.2}/tests/test_asyncpg_integration.py +35 -1
- t_sql-4.5.2/tests/test_error_messages.py +98 -0
- {t_sql-4.5.0 → t_sql-4.5.2}/tests/test_query_builder.py +53 -0
- t_sql-4.5.2/tests/test_template_in_builders.py +104 -0
- {t_sql-4.5.0 → t_sql-4.5.2}/tests/test_tsql.py +44 -0
- {t_sql-4.5.0 → t_sql-4.5.2}/tsql/__init__.py +74 -8
- {t_sql-4.5.0 → t_sql-4.5.2}/tsql/query_builder.py +32 -20
- {t_sql-4.5.0 → t_sql-4.5.2}/.dockerignore +0 -0
- {t_sql-4.5.0 → t_sql-4.5.2}/.github/workflows/publish.yml +0 -0
- {t_sql-4.5.0 → t_sql-4.5.2}/.github/workflows/test.yml +0 -0
- {t_sql-4.5.0 → t_sql-4.5.2}/.gitignore +0 -0
- {t_sql-4.5.0 → t_sql-4.5.2}/Dockerfile +0 -0
- {t_sql-4.5.0 → t_sql-4.5.2}/LICENSE +0 -0
- {t_sql-4.5.0 → t_sql-4.5.2}/README.md +0 -0
- {t_sql-4.5.0 → t_sql-4.5.2}/compose.yaml +0 -0
- {t_sql-4.5.0 → t_sql-4.5.2}/context7.json +0 -0
- {t_sql-4.5.0 → t_sql-4.5.2}/pytest.ini +0 -0
- {t_sql-4.5.0 → t_sql-4.5.2}/tests/test_alembic_integration.py +0 -0
- {t_sql-4.5.0 → t_sql-4.5.2}/tests/test_different_object_types.py +0 -0
- {t_sql-4.5.0 → t_sql-4.5.2}/tests/test_escaped.py +0 -0
- {t_sql-4.5.0 → t_sql-4.5.2}/tests/test_escaped_binary_hex.py +0 -0
- {t_sql-4.5.0 → t_sql-4.5.2}/tests/test_helper_functions.py +0 -0
- {t_sql-4.5.0 → t_sql-4.5.2}/tests/test_injection_edge_cases.py +0 -0
- {t_sql-4.5.0 → t_sql-4.5.2}/tests/test_injection_protection_validation.py +0 -0
- {t_sql-4.5.0 → t_sql-4.5.2}/tests/test_injections_for_escaped.py +0 -0
- {t_sql-4.5.0 → t_sql-4.5.2}/tests/test_mysql_integration.py +0 -0
- {t_sql-4.5.0 → t_sql-4.5.2}/tests/test_parameter_names.py +0 -0
- {t_sql-4.5.0 → t_sql-4.5.2}/tests/test_sqlalchemy_integration.py +0 -0
- {t_sql-4.5.0 → t_sql-4.5.2}/tests/test_sqlite_integration.py +0 -0
- {t_sql-4.5.0 → t_sql-4.5.2}/tests/test_styles.py +0 -0
- {t_sql-4.5.0 → t_sql-4.5.2}/tests/test_type_processor.py +0 -0
- {t_sql-4.5.0 → t_sql-4.5.2}/tsql/row.py +0 -0
- {t_sql-4.5.0 → t_sql-4.5.2}/tsql/styles.py +0 -0
- {t_sql-4.5.0 → t_sql-4.5.2}/tsql/type_processor.py +0 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import asyncpg
|
|
2
|
+
import datetime
|
|
2
3
|
import os
|
|
3
4
|
import pytest
|
|
4
5
|
|
|
@@ -234,4 +235,37 @@ async def test_escaped_handles_comment_injection(conn):
|
|
|
234
235
|
malicious_input = "admin'--"
|
|
235
236
|
query, _ = tsql.render(t"SELECT * FROM test_users WHERE name = {malicious_input}", style=tsql.styles.ESCAPED)
|
|
236
237
|
rows = await conn.fetch(query)
|
|
237
|
-
assert len(rows) == 0
|
|
238
|
+
assert len(rows) == 0
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
async def test_datetime_comparison_with_asyncpg(conn):
|
|
242
|
+
"""Test that datetime objects work correctly with asyncpg (bug fix verification)"""
|
|
243
|
+
# Insert some test data with specific timestamps
|
|
244
|
+
base_time = datetime.datetime.now()
|
|
245
|
+
|
|
246
|
+
await conn.execute(
|
|
247
|
+
"INSERT INTO test_users (name, created_at) VALUES ($1, $2), ($3, $4), ($5, $6)",
|
|
248
|
+
'User1', base_time - datetime.timedelta(minutes=30),
|
|
249
|
+
'User2', base_time - datetime.timedelta(minutes=10),
|
|
250
|
+
'User3', base_time - datetime.timedelta(minutes=5)
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
# Use a datetime object in a WHERE clause (this was broken before the fix)
|
|
254
|
+
cutoff_time = base_time - datetime.timedelta(minutes=15)
|
|
255
|
+
|
|
256
|
+
query, params = tsql.render(
|
|
257
|
+
t"SELECT name FROM test_users WHERE created_at > {cutoff_time}",
|
|
258
|
+
style=tsql.styles.NUMERIC_DOLLAR
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
# Verify the parameter is still a datetime object (not stringified)
|
|
262
|
+
assert len(params) == 1
|
|
263
|
+
assert isinstance(params[0], datetime.datetime)
|
|
264
|
+
|
|
265
|
+
# This should work without asyncpg throwing a DataError
|
|
266
|
+
rows = await conn.fetch(query, *params)
|
|
267
|
+
|
|
268
|
+
# Should find User2 and User3 (created within last 15 minutes)
|
|
269
|
+
assert len(rows) == 2
|
|
270
|
+
names = sorted([row['name'] for row in rows])
|
|
271
|
+
assert names == ['User2', 'User3']
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Tests for error message clarity and helpfulness"""
|
|
2
|
+
import pytest
|
|
3
|
+
import tsql
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_literal_non_string_gets_parameterized():
|
|
7
|
+
"""Non-string values with :literal format spec just get parameterized (not an error)"""
|
|
8
|
+
# This is actually fine behavior - the :literal format spec is ignored for non-strings
|
|
9
|
+
# and they just get parameterized normally
|
|
10
|
+
sql, params = tsql.render(t"SELECT * FROM table WHERE id = {123:literal}")
|
|
11
|
+
assert sql == "SELECT * FROM table WHERE id = ?"
|
|
12
|
+
assert params == [123]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_literal_too_many_parts_error():
|
|
16
|
+
"""Too many parts should show expected count and list all parts"""
|
|
17
|
+
table = "a.b.c.d"
|
|
18
|
+
with pytest.raises(ValueError) as exc_info:
|
|
19
|
+
tsql.render(t"SELECT * FROM {table:literal}")
|
|
20
|
+
|
|
21
|
+
error_msg = str(exc_info.value)
|
|
22
|
+
assert "too many parts" in error_msg
|
|
23
|
+
assert "expected at most 3" in error_msg
|
|
24
|
+
assert "got 4" in error_msg
|
|
25
|
+
assert "'a'" in error_msg and "'b'" in error_msg and "'c'" in error_msg and "'d'" in error_msg
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_literal_empty_string_error():
|
|
29
|
+
"""Empty string should explain it's not a valid identifier and show examples"""
|
|
30
|
+
table = ""
|
|
31
|
+
with pytest.raises(ValueError) as exc_info:
|
|
32
|
+
tsql.render(t"SELECT * FROM {table:literal}")
|
|
33
|
+
|
|
34
|
+
error_msg = str(exc_info.value)
|
|
35
|
+
assert "empty string is not a valid identifier" in error_msg
|
|
36
|
+
assert "users" in error_msg or "public.users" in error_msg # Should show examples
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_literal_invalid_identifier_error():
|
|
40
|
+
"""Invalid identifiers should show which parts are invalid and explain rules"""
|
|
41
|
+
table = "my-table" # Hyphens not allowed
|
|
42
|
+
with pytest.raises(ValueError) as exc_info:
|
|
43
|
+
tsql.render(t"SELECT * FROM {table:literal}")
|
|
44
|
+
|
|
45
|
+
error_msg = str(exc_info.value)
|
|
46
|
+
assert "invalid identifier" in error_msg
|
|
47
|
+
assert "'my-table'" in error_msg
|
|
48
|
+
assert "valid Python identifier" in error_msg
|
|
49
|
+
assert "letters, digits, underscores" in error_msg
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_literal_starts_with_digit_error():
|
|
53
|
+
"""Identifiers starting with digits should be caught with helpful message"""
|
|
54
|
+
table = "123users"
|
|
55
|
+
with pytest.raises(ValueError) as exc_info:
|
|
56
|
+
tsql.render(t"SELECT * FROM {table:literal}")
|
|
57
|
+
|
|
58
|
+
error_msg = str(exc_info.value)
|
|
59
|
+
assert "invalid identifier" in error_msg
|
|
60
|
+
assert "'123users'" in error_msg
|
|
61
|
+
assert "valid Python identifier" in error_msg
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_literal_special_chars_error():
|
|
65
|
+
"""Special characters should be caught and suggest :unsafe if needed"""
|
|
66
|
+
table = "users@prod" # @ not allowed
|
|
67
|
+
with pytest.raises(ValueError) as exc_info:
|
|
68
|
+
tsql.render(t"SELECT * FROM {table:literal}")
|
|
69
|
+
|
|
70
|
+
error_msg = str(exc_info.value)
|
|
71
|
+
assert "invalid identifier" in error_msg
|
|
72
|
+
assert "'users@prod'" in error_msg
|
|
73
|
+
assert ":unsafe" in error_msg # Suggests alternative
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_literal_qualified_name_with_invalid_part():
|
|
77
|
+
"""Qualified names with one invalid part should identify which part is bad"""
|
|
78
|
+
table = "public.my-table" # Second part has hyphen
|
|
79
|
+
with pytest.raises(ValueError) as exc_info:
|
|
80
|
+
tsql.render(t"SELECT * FROM {table:literal}")
|
|
81
|
+
|
|
82
|
+
error_msg = str(exc_info.value)
|
|
83
|
+
assert "invalid identifier" in error_msg
|
|
84
|
+
assert "'my-table'" in error_msg # Should identify the bad part
|
|
85
|
+
# Should NOT complain about 'public' since it's valid
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_literal_sql_keyword_suggestion():
|
|
89
|
+
"""Error for SQL keywords should suggest :unsafe workaround"""
|
|
90
|
+
# Note: SQL keywords like 'select' are actually valid Python identifiers,
|
|
91
|
+
# so this tests the general suggestion about :unsafe for special cases
|
|
92
|
+
table = "table-name"
|
|
93
|
+
with pytest.raises(ValueError) as exc_info:
|
|
94
|
+
tsql.render(t"SELECT * FROM {table:literal}")
|
|
95
|
+
|
|
96
|
+
error_msg = str(exc_info.value)
|
|
97
|
+
assert ":unsafe" in error_msg
|
|
98
|
+
assert "caution" in error_msg
|
|
@@ -1378,3 +1378,56 @@ def test_delete_with_all_rows_works():
|
|
|
1378
1378
|
assert 'DELETE FROM users' in sql
|
|
1379
1379
|
assert 'WHERE' not in sql
|
|
1380
1380
|
assert params == []
|
|
1381
|
+
|
|
1382
|
+
|
|
1383
|
+
def test_nested_querybuilder_in_tstring():
|
|
1384
|
+
"""Test that QueryBuilder can be interpolated into t-strings as subqueries"""
|
|
1385
|
+
# Create a subquery
|
|
1386
|
+
subquery = Posts.select(Posts.user_id).where(Posts.id == 42)
|
|
1387
|
+
|
|
1388
|
+
# Interpolate it into a regular t-string
|
|
1389
|
+
query = tsql.TSQL(t'SELECT *, ({subquery}) as post_user FROM users')
|
|
1390
|
+
sql, params = query.render()
|
|
1391
|
+
|
|
1392
|
+
# The subquery SQL should be inlined, not parameterized
|
|
1393
|
+
assert 'SELECT *,' in sql
|
|
1394
|
+
assert 'SELECT posts.user_id FROM posts WHERE posts.id = ?' in sql
|
|
1395
|
+
assert 'as post_user FROM users' in sql
|
|
1396
|
+
# The parameter from the subquery should be in the params list
|
|
1397
|
+
assert params == [42]
|
|
1398
|
+
|
|
1399
|
+
|
|
1400
|
+
def test_nested_querybuilder_with_multiple_params():
|
|
1401
|
+
"""Test nested QueryBuilder with multiple parameters from both queries"""
|
|
1402
|
+
# Create a subquery with a parameter
|
|
1403
|
+
subquery = Posts.select(Posts.id).where(Posts.user_id == 100)
|
|
1404
|
+
|
|
1405
|
+
# Use it in a main query that also has parameters
|
|
1406
|
+
query = tsql.TSQL(t'SELECT * FROM users WHERE id = {5} AND post_count > ({subquery})')
|
|
1407
|
+
sql, params = query.render()
|
|
1408
|
+
|
|
1409
|
+
# Both parameters should be in the list
|
|
1410
|
+
assert params == [5, 100]
|
|
1411
|
+
# The subquery should be inlined
|
|
1412
|
+
assert 'SELECT posts.id FROM posts WHERE posts.user_id = ?' in sql
|
|
1413
|
+
|
|
1414
|
+
|
|
1415
|
+
def test_nested_querybuilder_with_different_styles():
|
|
1416
|
+
"""Test that nested QueryBuilders work with different parameter styles"""
|
|
1417
|
+
from tsql.styles import NUMERIC_DOLLAR, NAMED
|
|
1418
|
+
|
|
1419
|
+
subquery = Posts.select(Posts.user_id).where(Posts.id == 42)
|
|
1420
|
+
|
|
1421
|
+
# Test with NUMERIC_DOLLAR style
|
|
1422
|
+
query = tsql.TSQL(t'SELECT *, ({subquery}) as post_user FROM users WHERE id = {5}')
|
|
1423
|
+
sql, params = query.render(style=NUMERIC_DOLLAR)
|
|
1424
|
+
assert '$1' in sql and '$2' in sql
|
|
1425
|
+
assert params == [42, 5]
|
|
1426
|
+
|
|
1427
|
+
# Test with NAMED style
|
|
1428
|
+
sql, params = query.render(style=NAMED)
|
|
1429
|
+
# Check that both parameters are present (names may vary)
|
|
1430
|
+
assert isinstance(params, dict)
|
|
1431
|
+
assert len(params) == 2
|
|
1432
|
+
assert 42 in params.values() and 5 in params.values()
|
|
1433
|
+
assert 'SELECT posts.user_id FROM posts WHERE posts.id = :' in sql
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Tests for Template/TSQL values in INSERT/UPDATE builders"""
|
|
2
|
+
import pytest
|
|
3
|
+
from string.templatelib import Template
|
|
4
|
+
|
|
5
|
+
import tsql
|
|
6
|
+
from tsql import TSQL
|
|
7
|
+
from tsql.query_builder import Table, SAColumn
|
|
8
|
+
from tsql import styles
|
|
9
|
+
|
|
10
|
+
# Test tables
|
|
11
|
+
class TestTable(Table, table_name='test_table'):
|
|
12
|
+
id = ...
|
|
13
|
+
name = ...
|
|
14
|
+
created_ts = ...
|
|
15
|
+
updated_ts = ...
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_update_with_template_value():
|
|
19
|
+
"""Template values in UPDATE should be inlined, not parameterized"""
|
|
20
|
+
query = TestTable.update(updated_ts=t"NOW()").where(TestTable.id == 123)
|
|
21
|
+
|
|
22
|
+
sql, params = query.render(style=styles.QMARK)
|
|
23
|
+
|
|
24
|
+
# The Template should be inlined, not parameterized
|
|
25
|
+
assert "NOW()" in sql
|
|
26
|
+
assert sql == "UPDATE test_table SET updated_ts = NOW() WHERE test_table.id = ?"
|
|
27
|
+
# Only the id comparison should be parameterized
|
|
28
|
+
assert params == [123]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_insert_with_template_value():
|
|
32
|
+
"""Template values in INSERT should be inlined, not parameterized"""
|
|
33
|
+
query = TestTable.insert(name="Alice", created_ts=t"NOW()")
|
|
34
|
+
|
|
35
|
+
sql, params = query.render(style=styles.QMARK)
|
|
36
|
+
|
|
37
|
+
# The Template should be inlined, not parameterized
|
|
38
|
+
assert "NOW()" in sql
|
|
39
|
+
assert sql == "INSERT INTO test_table (name, created_ts) VALUES (?, NOW())"
|
|
40
|
+
# Only the name should be parameterized
|
|
41
|
+
assert params == ["Alice"]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_insert_on_conflict_update_with_template():
|
|
45
|
+
"""Template values in ON CONFLICT UPDATE should be inlined"""
|
|
46
|
+
query = (TestTable.insert(id=1, name="Alice", updated_ts=t"NOW()")
|
|
47
|
+
.on_conflict_update('id', update={'updated_ts': t"NOW()"}))
|
|
48
|
+
|
|
49
|
+
sql, params = query.render(style=styles.QMARK)
|
|
50
|
+
|
|
51
|
+
# Both Templates should be inlined
|
|
52
|
+
assert sql.count("NOW()") == 2
|
|
53
|
+
assert "ON CONFLICT (id) DO UPDATE SET updated_ts = NOW()" in sql
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_insert_on_duplicate_key_with_template():
|
|
57
|
+
"""Template values in ON DUPLICATE KEY UPDATE should be inlined (MySQL)"""
|
|
58
|
+
query = (TestTable.insert(id=1, name="Alice", updated_ts=t"NOW()")
|
|
59
|
+
.on_duplicate_key_update(update={'updated_ts': t"NOW()"}))
|
|
60
|
+
|
|
61
|
+
sql, params = query.render(style=styles.QMARK)
|
|
62
|
+
|
|
63
|
+
# Both Templates should be inlined
|
|
64
|
+
assert sql.count("NOW()") == 2
|
|
65
|
+
assert "ON DUPLICATE KEY UPDATE updated_ts = NOW()" in sql
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_update_with_tsql_object():
|
|
69
|
+
"""TSQL objects in UPDATE should be inlined"""
|
|
70
|
+
now_expr = TSQL(t"NOW()")
|
|
71
|
+
query = TestTable.update(updated_ts=now_expr).where(TestTable.id == 123)
|
|
72
|
+
|
|
73
|
+
sql, params = query.render(style=styles.QMARK)
|
|
74
|
+
|
|
75
|
+
assert "NOW()" in sql
|
|
76
|
+
assert params == [123]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_update_multiple_template_values():
|
|
80
|
+
"""Multiple Template values should all be inlined"""
|
|
81
|
+
query = (TestTable.update(
|
|
82
|
+
created_ts=t"NOW()",
|
|
83
|
+
updated_ts=t"CURRENT_TIMESTAMP",
|
|
84
|
+
name="Bob"
|
|
85
|
+
).where(TestTable.id == 456))
|
|
86
|
+
|
|
87
|
+
sql, params = query.render(style=styles.QMARK)
|
|
88
|
+
|
|
89
|
+
# Both SQL expressions should be inlined
|
|
90
|
+
assert "NOW()" in sql
|
|
91
|
+
assert "CURRENT_TIMESTAMP" in sql
|
|
92
|
+
# Only name and id should be parameterized
|
|
93
|
+
assert params == ["Bob", 456]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_insert_with_sql_expression_postgres_style():
|
|
97
|
+
"""Template with SQL expression should work with different parameter styles"""
|
|
98
|
+
query = TestTable.insert(name="Charlie", created_ts=t"NOW() - INTERVAL '5 minutes'")
|
|
99
|
+
|
|
100
|
+
sql, params = query.render(style=styles.NUMERIC_DOLLAR)
|
|
101
|
+
|
|
102
|
+
assert "NOW() - INTERVAL '5 minutes'" in sql
|
|
103
|
+
assert sql == "INSERT INTO test_table (name, created_ts) VALUES ($1, NOW() - INTERVAL '5 minutes')"
|
|
104
|
+
assert params == ["Charlie"]
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import pytest
|
|
2
|
+
import datetime
|
|
2
3
|
|
|
3
4
|
import tsql
|
|
4
5
|
import tsql.styles
|
|
@@ -111,6 +112,49 @@ def test_prevents_sql_injection():
|
|
|
111
112
|
assert result[0] == "SELECT * FROM table WHERE col=?"
|
|
112
113
|
|
|
113
114
|
|
|
115
|
+
def test_datetime_preserved_as_native_type():
|
|
116
|
+
"""datetime objects should be passed through unchanged, not stringified"""
|
|
117
|
+
dt = datetime.datetime(2025, 10, 21, 12, 30, 45, 123456, tzinfo=datetime.timezone.utc)
|
|
118
|
+
result = tsql.render(t"SELECT * FROM table WHERE created_at > {dt}")
|
|
119
|
+
assert result[0] == "SELECT * FROM table WHERE created_at > ?"
|
|
120
|
+
assert result[1] == [dt]
|
|
121
|
+
assert isinstance(result[1][0], datetime.datetime)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_date_preserved_as_native_type():
|
|
125
|
+
"""date objects should be passed through unchanged"""
|
|
126
|
+
d = datetime.date(2025, 10, 21)
|
|
127
|
+
result = tsql.render(t"SELECT * FROM table WHERE date_col = {d}")
|
|
128
|
+
assert result[0] == "SELECT * FROM table WHERE date_col = ?"
|
|
129
|
+
assert result[1] == [d]
|
|
130
|
+
assert isinstance(result[1][0], datetime.date)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_time_preserved_as_native_type():
|
|
134
|
+
"""time objects should be passed through unchanged"""
|
|
135
|
+
t = datetime.time(12, 30, 45)
|
|
136
|
+
result = tsql.render(t"SELECT * FROM table WHERE time_col = {t}")
|
|
137
|
+
assert result[0] == "SELECT * FROM table WHERE time_col = ?"
|
|
138
|
+
assert result[1] == [t]
|
|
139
|
+
assert isinstance(result[1][0], datetime.time)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_timedelta_preserved_as_native_type():
|
|
143
|
+
"""timedelta objects should be passed through unchanged"""
|
|
144
|
+
td = datetime.timedelta(days=15, hours=3, minutes=30)
|
|
145
|
+
result = tsql.render(t"SELECT * FROM table WHERE interval_col = {td}")
|
|
146
|
+
assert result[0] == "SELECT * FROM table WHERE interval_col = ?"
|
|
147
|
+
assert result[1] == [td]
|
|
148
|
+
assert isinstance(result[1][0], datetime.timedelta)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_datetime_with_format_spec_converts_to_string():
|
|
152
|
+
"""When a format spec is provided, datetime should be formatted as string"""
|
|
153
|
+
dt = datetime.datetime(2025, 10, 21, 12, 30, 45)
|
|
154
|
+
result = tsql.render(t"SELECT * FROM table WHERE date_str = {dt:%Y-%m-%d}")
|
|
155
|
+
assert result[0] == "SELECT * FROM table WHERE date_str = ?"
|
|
156
|
+
assert result[1] == ['2025-10-21']
|
|
157
|
+
assert isinstance(result[1][0], str)
|
|
114
158
|
|
|
115
159
|
|
|
116
160
|
|
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
import re
|
|
2
2
|
import string
|
|
3
|
+
import datetime
|
|
4
|
+
import logging
|
|
3
5
|
from typing import NamedTuple, Tuple, Any, List, Dict, Iterable, Union, TYPE_CHECKING
|
|
4
6
|
from string.templatelib import Template, Interpolation
|
|
5
7
|
|
|
6
8
|
from tsql.styles import ParamStyle, QMARK
|
|
7
9
|
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
# Pre-compile regex for whitespace collapsing to avoid cache lookup overhead
|
|
13
|
+
_WHITESPACE_RE = re.compile(r'\s+')
|
|
14
|
+
|
|
8
15
|
if TYPE_CHECKING:
|
|
9
16
|
from tsql.query_builder import QueryBuilder
|
|
10
17
|
|
|
@@ -12,6 +19,7 @@ default_style = QMARK
|
|
|
12
19
|
|
|
13
20
|
def set_style(style: type[ParamStyle]):
|
|
14
21
|
global default_style
|
|
22
|
+
logger.debug("Setting default parameter style to: %s", style.__name__)
|
|
15
23
|
default_style = style
|
|
16
24
|
|
|
17
25
|
|
|
@@ -49,6 +57,7 @@ class TSQL:
|
|
|
49
57
|
def render(self, style:ParamStyle = None) -> RenderedQuery:
|
|
50
58
|
if style is None:
|
|
51
59
|
style = default_style
|
|
60
|
+
logger.debug("Rendering query with style: %s", style.__name__)
|
|
52
61
|
result = ''
|
|
53
62
|
|
|
54
63
|
style_instance = style()
|
|
@@ -60,6 +69,8 @@ class TSQL:
|
|
|
60
69
|
else:
|
|
61
70
|
result += part
|
|
62
71
|
|
|
72
|
+
logger.debug("Rendered SQL: %s", result)
|
|
73
|
+
logger.debug("Parameters (%d): %s", len(style_instance.params), style_instance.params)
|
|
63
74
|
return RenderedQuery(result, style_instance.params)
|
|
64
75
|
|
|
65
76
|
|
|
@@ -74,16 +85,34 @@ class TSQL:
|
|
|
74
85
|
@classmethod
|
|
75
86
|
def _check_literal(cls, val: str):
|
|
76
87
|
if not isinstance(val, str):
|
|
77
|
-
raise ValueError(
|
|
88
|
+
raise ValueError(
|
|
89
|
+
f"Invalid literal {val!r}: literals must be strings, got {type(val).__name__}. "
|
|
90
|
+
f"Use {{value}} without :literal to parameterize non-string values."
|
|
91
|
+
)
|
|
78
92
|
|
|
79
93
|
# Allow qualified identifiers (table.column, schema.table.column)
|
|
80
94
|
parts = val.split('.')
|
|
81
95
|
|
|
82
96
|
if len(parts) > 3:
|
|
83
|
-
raise ValueError(
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
97
|
+
raise ValueError(
|
|
98
|
+
f"Invalid literal {val!r}: too many parts (expected at most 3 for schema.table.column, got {len(parts)}). "
|
|
99
|
+
f"Parts: {', '.join(repr(p) for p in parts)}"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
# Check for empty string or empty parts
|
|
103
|
+
if not val or any(not p for p in parts):
|
|
104
|
+
raise ValueError(
|
|
105
|
+
f"Invalid literal {val!r}: empty string is not a valid identifier. "
|
|
106
|
+
f"Literals must be valid Python identifiers (e.g., 'users', 'public.users', 'schema.table.column')."
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
invalid_parts = [p for p in parts if not p.isidentifier()]
|
|
110
|
+
if invalid_parts:
|
|
111
|
+
raise ValueError(
|
|
112
|
+
f"Invalid literal {val!r}: contains invalid identifier(s): {', '.join(repr(p) for p in invalid_parts)}. "
|
|
113
|
+
f"Each part must be a valid Python identifier (letters, digits, underscores; cannot start with digit). "
|
|
114
|
+
f"If you need special characters or SQL keywords, consider using {{value:unsafe}} with caution."
|
|
115
|
+
)
|
|
87
116
|
return val
|
|
88
117
|
|
|
89
118
|
@classmethod
|
|
@@ -95,11 +124,17 @@ class TSQL:
|
|
|
95
124
|
if val.conversion:
|
|
96
125
|
value = formatter.convert_field(value, val.conversion)
|
|
97
126
|
|
|
127
|
+
logger.debug("Processing interpolation: expression=%r, format_spec=%r, value_type=%s",
|
|
128
|
+
val.expression, val.format_spec, type(value).__name__)
|
|
129
|
+
|
|
98
130
|
match val.format_spec, value:
|
|
99
131
|
case 'literal', str():
|
|
132
|
+
logger.debug("Validating literal: %r", value)
|
|
100
133
|
cls._check_literal(value)
|
|
134
|
+
logger.debug("Literal validated, inlining: %r", value)
|
|
101
135
|
return [value]
|
|
102
136
|
case 'unsafe', str():
|
|
137
|
+
logger.debug("Using unsafe inline value: %r", value)
|
|
103
138
|
return [value]
|
|
104
139
|
case 'as_values', dict():
|
|
105
140
|
return as_values(value)._sql_parts
|
|
@@ -109,6 +144,9 @@ class TSQL:
|
|
|
109
144
|
return val.value._sql_parts
|
|
110
145
|
case "", Template():
|
|
111
146
|
return TSQL(value)._sql_parts
|
|
147
|
+
case '', x if hasattr(x, 'to_tsql'):
|
|
148
|
+
logger.debug("Inlining QueryBuilder object: %s", type(x).__name__)
|
|
149
|
+
return x.to_tsql()._sql_parts
|
|
112
150
|
case '', None:
|
|
113
151
|
return [Parameter(val.expression, None)]
|
|
114
152
|
# case 'as_array', list():
|
|
@@ -127,6 +165,8 @@ class TSQL:
|
|
|
127
165
|
return [Parameter(val.expression, value)]
|
|
128
166
|
case _, int():
|
|
129
167
|
return [Parameter(val.expression, val.value)]
|
|
168
|
+
case '', datetime.datetime() | datetime.date() | datetime.time() | datetime.timedelta():
|
|
169
|
+
return [Parameter(val.expression, value)]
|
|
130
170
|
case _, _:
|
|
131
171
|
return [Parameter(val.expression, formatter.format_field(value, val.format_spec))]
|
|
132
172
|
|
|
@@ -136,7 +176,7 @@ class TSQL:
|
|
|
136
176
|
if isinstance(item, Interpolation):
|
|
137
177
|
result.extend(cls._sqlize(item))
|
|
138
178
|
else:
|
|
139
|
-
result.append(
|
|
179
|
+
result.append(_WHITESPACE_RE.sub(' ', item))
|
|
140
180
|
return result
|
|
141
181
|
|
|
142
182
|
raise ValueError(f"UNSAFE {val}") # this shouldnt happen and is for debugging
|
|
@@ -179,7 +219,20 @@ def as_values(value_dict: dict[str, Any]):
|
|
|
179
219
|
for i, value in enumerate(values):
|
|
180
220
|
if i > 0:
|
|
181
221
|
value_parts.append(', ')
|
|
182
|
-
|
|
222
|
+
|
|
223
|
+
# Handle special types that should be inlined as SQL
|
|
224
|
+
if isinstance(value, Template):
|
|
225
|
+
# Inline the Template by processing it through _sqlize
|
|
226
|
+
value_parts.extend(TSQL._sqlize(value))
|
|
227
|
+
elif isinstance(value, TSQL):
|
|
228
|
+
# Inline the TSQL object's parts directly
|
|
229
|
+
value_parts.extend(value._sql_parts)
|
|
230
|
+
elif hasattr(value, 'to_tsql'):
|
|
231
|
+
# Handle QueryBuilder objects
|
|
232
|
+
value_parts.extend(value.to_tsql()._sql_parts)
|
|
233
|
+
else:
|
|
234
|
+
# Normal value - create Parameter
|
|
235
|
+
value_parts.append(Parameter(f'value_{i}', value))
|
|
183
236
|
value_parts.append(')')
|
|
184
237
|
|
|
185
238
|
# Create TSQL object manually
|
|
@@ -208,7 +261,20 @@ def as_set(value_dict: dict[str, Any]):
|
|
|
208
261
|
set_parts.append(', ')
|
|
209
262
|
set_parts.append(key)
|
|
210
263
|
set_parts.append(' = ')
|
|
211
|
-
|
|
264
|
+
|
|
265
|
+
# Handle special types that should be inlined as SQL
|
|
266
|
+
if isinstance(value, Template):
|
|
267
|
+
# Inline the Template by processing it through _sqlize
|
|
268
|
+
set_parts.extend(TSQL._sqlize(value))
|
|
269
|
+
elif isinstance(value, TSQL):
|
|
270
|
+
# Inline the TSQL object's parts directly
|
|
271
|
+
set_parts.extend(value._sql_parts)
|
|
272
|
+
elif hasattr(value, 'to_tsql'):
|
|
273
|
+
# Handle QueryBuilder objects
|
|
274
|
+
set_parts.extend(value.to_tsql()._sql_parts)
|
|
275
|
+
else:
|
|
276
|
+
# Normal value - create Parameter
|
|
277
|
+
set_parts.append(Parameter(f'value_{i}', value))
|
|
212
278
|
|
|
213
279
|
# Create TSQL object manually
|
|
214
280
|
tsql_obj = TSQL.__new__(TSQL)
|
|
@@ -719,11 +719,8 @@ class InsertBuilder(QueryBuilder):
|
|
|
719
719
|
# Apply type processors to values
|
|
720
720
|
values_dict = {}
|
|
721
721
|
for col_name, value in self.values.items():
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
values_dict[col_name] = processor.process_bind_param(value)
|
|
725
|
-
else:
|
|
726
|
-
values_dict[col_name] = value
|
|
722
|
+
processor = self.base_table._type_processors.get(col_name)
|
|
723
|
+
values_dict[col_name] = _process_value_for_builder(value, processor)
|
|
727
724
|
|
|
728
725
|
# MySQL INSERT IGNORE
|
|
729
726
|
if self._ignore:
|
|
@@ -758,11 +755,8 @@ class InsertBuilder(QueryBuilder):
|
|
|
758
755
|
# User specified which columns to update - apply type processors
|
|
759
756
|
update_dict = {}
|
|
760
757
|
for col_name, value in self._update_cols.items():
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
update_dict[col_name] = processor.process_bind_param(value)
|
|
764
|
-
else:
|
|
765
|
-
update_dict[col_name] = value
|
|
758
|
+
processor = self.base_table._type_processors.get(col_name)
|
|
759
|
+
update_dict[col_name] = _process_value_for_builder(value, processor)
|
|
766
760
|
parts.append(t'ON CONFLICT ({conflict_cols_str:unsafe}) DO UPDATE SET {update_dict:as_set}')
|
|
767
761
|
else:
|
|
768
762
|
# Default: update all non-conflict columns with EXCLUDED.*
|
|
@@ -786,11 +780,8 @@ class InsertBuilder(QueryBuilder):
|
|
|
786
780
|
# Apply type processors
|
|
787
781
|
update_dict = {}
|
|
788
782
|
for col_name, value in self._update_cols.items():
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
update_dict[col_name] = processor.process_bind_param(value)
|
|
792
|
-
else:
|
|
793
|
-
update_dict[col_name] = value
|
|
783
|
+
processor = self.base_table._type_processors.get(col_name)
|
|
784
|
+
update_dict[col_name] = _process_value_for_builder(value, processor)
|
|
794
785
|
parts.append(t'ON DUPLICATE KEY UPDATE {update_dict:as_set}')
|
|
795
786
|
else:
|
|
796
787
|
# Default: update all columns with alias.column (new MySQL syntax)
|
|
@@ -830,6 +821,30 @@ class InsertBuilder(QueryBuilder):
|
|
|
830
821
|
return f"InsertBuilder(<error rendering: {e}>)"
|
|
831
822
|
|
|
832
823
|
|
|
824
|
+
def _process_value_for_builder(value: Any, type_processor: Any = None) -> Any:
|
|
825
|
+
"""Apply type processor to a value if appropriate.
|
|
826
|
+
|
|
827
|
+
This helper ensures Template, TSQL, and QueryBuilder objects are not processed
|
|
828
|
+
by TypeProcessors, allowing them to be inlined as SQL instead of parameterized.
|
|
829
|
+
|
|
830
|
+
Args:
|
|
831
|
+
value: The value to potentially process
|
|
832
|
+
type_processor: Optional TypeProcessor instance
|
|
833
|
+
|
|
834
|
+
Returns:
|
|
835
|
+
Processed value (or unchanged if special type or no processor)
|
|
836
|
+
"""
|
|
837
|
+
# Don't process special types that should be inlined as SQL
|
|
838
|
+
if isinstance(value, (Column, Template)) or hasattr(value, 'to_tsql'):
|
|
839
|
+
return value
|
|
840
|
+
|
|
841
|
+
# Apply processor if present (this handles None correctly - processors can transform it)
|
|
842
|
+
if type_processor is not None:
|
|
843
|
+
return type_processor.process_bind_param(value)
|
|
844
|
+
|
|
845
|
+
return value
|
|
846
|
+
|
|
847
|
+
|
|
833
848
|
class UpdateBuilder(QueryBuilder):
|
|
834
849
|
"""Fluent interface for building UPDATE queries"""
|
|
835
850
|
|
|
@@ -913,11 +928,8 @@ class UpdateBuilder(QueryBuilder):
|
|
|
913
928
|
# Apply type processors to values
|
|
914
929
|
values_dict = {}
|
|
915
930
|
for col_name, value in self.values.items():
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
values_dict[col_name] = processor.process_bind_param(value)
|
|
919
|
-
else:
|
|
920
|
-
values_dict[col_name] = value
|
|
931
|
+
processor = self.base_table._type_processors.get(col_name)
|
|
932
|
+
values_dict[col_name] = _process_value_for_builder(value, processor)
|
|
921
933
|
|
|
922
934
|
parts.append(t'UPDATE {table_name:literal} SET {values_dict:as_set}')
|
|
923
935
|
|
|
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
|