t-sql 4.5.0__tar.gz → 4.5.1__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 (35) hide show
  1. {t_sql-4.5.0 → t_sql-4.5.1}/PKG-INFO +1 -1
  2. {t_sql-4.5.0 → t_sql-4.5.1}/pyproject.toml +1 -1
  3. {t_sql-4.5.0 → t_sql-4.5.1}/tests/test_asyncpg_integration.py +35 -1
  4. t_sql-4.5.1/tests/test_template_in_builders.py +104 -0
  5. {t_sql-4.5.0 → t_sql-4.5.1}/tests/test_tsql.py +44 -0
  6. {t_sql-4.5.0 → t_sql-4.5.1}/tsql/__init__.py +31 -2
  7. {t_sql-4.5.0 → t_sql-4.5.1}/tsql/query_builder.py +32 -20
  8. {t_sql-4.5.0 → t_sql-4.5.1}/.dockerignore +0 -0
  9. {t_sql-4.5.0 → t_sql-4.5.1}/.github/workflows/publish.yml +0 -0
  10. {t_sql-4.5.0 → t_sql-4.5.1}/.github/workflows/test.yml +0 -0
  11. {t_sql-4.5.0 → t_sql-4.5.1}/.gitignore +0 -0
  12. {t_sql-4.5.0 → t_sql-4.5.1}/Dockerfile +0 -0
  13. {t_sql-4.5.0 → t_sql-4.5.1}/LICENSE +0 -0
  14. {t_sql-4.5.0 → t_sql-4.5.1}/README.md +0 -0
  15. {t_sql-4.5.0 → t_sql-4.5.1}/compose.yaml +0 -0
  16. {t_sql-4.5.0 → t_sql-4.5.1}/context7.json +0 -0
  17. {t_sql-4.5.0 → t_sql-4.5.1}/pytest.ini +0 -0
  18. {t_sql-4.5.0 → t_sql-4.5.1}/tests/test_alembic_integration.py +0 -0
  19. {t_sql-4.5.0 → t_sql-4.5.1}/tests/test_different_object_types.py +0 -0
  20. {t_sql-4.5.0 → t_sql-4.5.1}/tests/test_escaped.py +0 -0
  21. {t_sql-4.5.0 → t_sql-4.5.1}/tests/test_escaped_binary_hex.py +0 -0
  22. {t_sql-4.5.0 → t_sql-4.5.1}/tests/test_helper_functions.py +0 -0
  23. {t_sql-4.5.0 → t_sql-4.5.1}/tests/test_injection_edge_cases.py +0 -0
  24. {t_sql-4.5.0 → t_sql-4.5.1}/tests/test_injection_protection_validation.py +0 -0
  25. {t_sql-4.5.0 → t_sql-4.5.1}/tests/test_injections_for_escaped.py +0 -0
  26. {t_sql-4.5.0 → t_sql-4.5.1}/tests/test_mysql_integration.py +0 -0
  27. {t_sql-4.5.0 → t_sql-4.5.1}/tests/test_parameter_names.py +0 -0
  28. {t_sql-4.5.0 → t_sql-4.5.1}/tests/test_query_builder.py +0 -0
  29. {t_sql-4.5.0 → t_sql-4.5.1}/tests/test_sqlalchemy_integration.py +0 -0
  30. {t_sql-4.5.0 → t_sql-4.5.1}/tests/test_sqlite_integration.py +0 -0
  31. {t_sql-4.5.0 → t_sql-4.5.1}/tests/test_styles.py +0 -0
  32. {t_sql-4.5.0 → t_sql-4.5.1}/tests/test_type_processor.py +0 -0
  33. {t_sql-4.5.0 → t_sql-4.5.1}/tsql/row.py +0 -0
  34. {t_sql-4.5.0 → t_sql-4.5.1}/tsql/styles.py +0 -0
  35. {t_sql-4.5.0 → t_sql-4.5.1}/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.1
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.1"
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,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,5 +1,6 @@
1
1
  import re
2
2
  import string
3
+ import datetime
3
4
  from typing import NamedTuple, Tuple, Any, List, Dict, Iterable, Union, TYPE_CHECKING
4
5
  from string.templatelib import Template, Interpolation
5
6
 
@@ -127,6 +128,8 @@ class TSQL:
127
128
  return [Parameter(val.expression, value)]
128
129
  case _, int():
129
130
  return [Parameter(val.expression, val.value)]
131
+ case '', datetime.datetime() | datetime.date() | datetime.time() | datetime.timedelta():
132
+ return [Parameter(val.expression, value)]
130
133
  case _, _:
131
134
  return [Parameter(val.expression, formatter.format_field(value, val.format_spec))]
132
135
 
@@ -179,7 +182,20 @@ def as_values(value_dict: dict[str, Any]):
179
182
  for i, value in enumerate(values):
180
183
  if i > 0:
181
184
  value_parts.append(', ')
182
- value_parts.append(Parameter(f'value_{i}', value))
185
+
186
+ # Handle special types that should be inlined as SQL
187
+ if isinstance(value, Template):
188
+ # Inline the Template by processing it through _sqlize
189
+ value_parts.extend(TSQL._sqlize(value))
190
+ elif isinstance(value, TSQL):
191
+ # Inline the TSQL object's parts directly
192
+ value_parts.extend(value._sql_parts)
193
+ elif hasattr(value, 'to_tsql'):
194
+ # Handle QueryBuilder objects
195
+ value_parts.extend(value.to_tsql()._sql_parts)
196
+ else:
197
+ # Normal value - create Parameter
198
+ value_parts.append(Parameter(f'value_{i}', value))
183
199
  value_parts.append(')')
184
200
 
185
201
  # Create TSQL object manually
@@ -208,7 +224,20 @@ def as_set(value_dict: dict[str, Any]):
208
224
  set_parts.append(', ')
209
225
  set_parts.append(key)
210
226
  set_parts.append(' = ')
211
- set_parts.append(Parameter(f'value_{i}', value))
227
+
228
+ # Handle special types that should be inlined as SQL
229
+ if isinstance(value, Template):
230
+ # Inline the Template by processing it through _sqlize
231
+ set_parts.extend(TSQL._sqlize(value))
232
+ elif isinstance(value, TSQL):
233
+ # Inline the TSQL object's parts directly
234
+ set_parts.extend(value._sql_parts)
235
+ elif hasattr(value, 'to_tsql'):
236
+ # Handle QueryBuilder objects
237
+ set_parts.extend(value.to_tsql()._sql_parts)
238
+ else:
239
+ # Normal value - create Parameter
240
+ set_parts.append(Parameter(f'value_{i}', value))
212
241
 
213
242
  # Create TSQL object manually
214
243
  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
File without changes