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.
Files changed (36) hide show
  1. {t_sql-4.5.0 → t_sql-4.5.2}/PKG-INFO +1 -1
  2. {t_sql-4.5.0 → t_sql-4.5.2}/pyproject.toml +1 -1
  3. {t_sql-4.5.0 → t_sql-4.5.2}/tests/test_asyncpg_integration.py +35 -1
  4. t_sql-4.5.2/tests/test_error_messages.py +98 -0
  5. {t_sql-4.5.0 → t_sql-4.5.2}/tests/test_query_builder.py +53 -0
  6. t_sql-4.5.2/tests/test_template_in_builders.py +104 -0
  7. {t_sql-4.5.0 → t_sql-4.5.2}/tests/test_tsql.py +44 -0
  8. {t_sql-4.5.0 → t_sql-4.5.2}/tsql/__init__.py +74 -8
  9. {t_sql-4.5.0 → t_sql-4.5.2}/tsql/query_builder.py +32 -20
  10. {t_sql-4.5.0 → t_sql-4.5.2}/.dockerignore +0 -0
  11. {t_sql-4.5.0 → t_sql-4.5.2}/.github/workflows/publish.yml +0 -0
  12. {t_sql-4.5.0 → t_sql-4.5.2}/.github/workflows/test.yml +0 -0
  13. {t_sql-4.5.0 → t_sql-4.5.2}/.gitignore +0 -0
  14. {t_sql-4.5.0 → t_sql-4.5.2}/Dockerfile +0 -0
  15. {t_sql-4.5.0 → t_sql-4.5.2}/LICENSE +0 -0
  16. {t_sql-4.5.0 → t_sql-4.5.2}/README.md +0 -0
  17. {t_sql-4.5.0 → t_sql-4.5.2}/compose.yaml +0 -0
  18. {t_sql-4.5.0 → t_sql-4.5.2}/context7.json +0 -0
  19. {t_sql-4.5.0 → t_sql-4.5.2}/pytest.ini +0 -0
  20. {t_sql-4.5.0 → t_sql-4.5.2}/tests/test_alembic_integration.py +0 -0
  21. {t_sql-4.5.0 → t_sql-4.5.2}/tests/test_different_object_types.py +0 -0
  22. {t_sql-4.5.0 → t_sql-4.5.2}/tests/test_escaped.py +0 -0
  23. {t_sql-4.5.0 → t_sql-4.5.2}/tests/test_escaped_binary_hex.py +0 -0
  24. {t_sql-4.5.0 → t_sql-4.5.2}/tests/test_helper_functions.py +0 -0
  25. {t_sql-4.5.0 → t_sql-4.5.2}/tests/test_injection_edge_cases.py +0 -0
  26. {t_sql-4.5.0 → t_sql-4.5.2}/tests/test_injection_protection_validation.py +0 -0
  27. {t_sql-4.5.0 → t_sql-4.5.2}/tests/test_injections_for_escaped.py +0 -0
  28. {t_sql-4.5.0 → t_sql-4.5.2}/tests/test_mysql_integration.py +0 -0
  29. {t_sql-4.5.0 → t_sql-4.5.2}/tests/test_parameter_names.py +0 -0
  30. {t_sql-4.5.0 → t_sql-4.5.2}/tests/test_sqlalchemy_integration.py +0 -0
  31. {t_sql-4.5.0 → t_sql-4.5.2}/tests/test_sqlite_integration.py +0 -0
  32. {t_sql-4.5.0 → t_sql-4.5.2}/tests/test_styles.py +0 -0
  33. {t_sql-4.5.0 → t_sql-4.5.2}/tests/test_type_processor.py +0 -0
  34. {t_sql-4.5.0 → t_sql-4.5.2}/tsql/row.py +0 -0
  35. {t_sql-4.5.0 → t_sql-4.5.2}/tsql/styles.py +0 -0
  36. {t_sql-4.5.0 → t_sql-4.5.2}/tsql/type_processor.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: t-sql
3
- Version: 4.5.0
3
+ Version: 4.5.2
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "t-sql"
7
- version = "4.5.0"
7
+ version = "4.5.2"
8
8
  description = "Safe SQL. SQL queries for python t-strings (PEP 750)"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.14"
@@ -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(f"Invalid literal {val}")
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(f"Invalid literal {val}: too many parts (max 3 for schema.table.column)")
84
-
85
- if not parts or not all(part.isidentifier() for part in parts):
86
- raise ValueError(f"Invalid literal {val}")
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(re.sub(r'\s+', ' ', item))
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
- value_parts.append(Parameter(f'value_{i}', value))
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
- set_parts.append(Parameter(f'value_{i}', value))
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
- if col_name in self.base_table._type_processors:
723
- processor = self.base_table._type_processors[col_name]
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
- if col_name in self.base_table._type_processors:
762
- processor = self.base_table._type_processors[col_name]
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
- if col_name in self.base_table._type_processors:
790
- processor = self.base_table._type_processors[col_name]
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
- if col_name in self.base_table._type_processors:
917
- processor = self.base_table._type_processors[col_name]
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