t-sql 4.4.2__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.
- {t_sql-4.4.2 → t_sql-4.5.1}/PKG-INFO +1 -1
- {t_sql-4.4.2 → t_sql-4.5.1}/pyproject.toml +1 -1
- {t_sql-4.4.2 → t_sql-4.5.1}/tests/test_asyncpg_integration.py +35 -1
- t_sql-4.5.1/tests/test_template_in_builders.py +104 -0
- {t_sql-4.4.2 → t_sql-4.5.1}/tests/test_tsql.py +44 -0
- {t_sql-4.4.2 → t_sql-4.5.1}/tests/test_type_processor.py +10 -6
- {t_sql-4.4.2 → t_sql-4.5.1}/tsql/__init__.py +33 -2
- {t_sql-4.4.2 → t_sql-4.5.1}/tsql/query_builder.py +47 -29
- t_sql-4.5.1/tsql/row.py +39 -0
- {t_sql-4.4.2 → t_sql-4.5.1}/.dockerignore +0 -0
- {t_sql-4.4.2 → t_sql-4.5.1}/.github/workflows/publish.yml +0 -0
- {t_sql-4.4.2 → t_sql-4.5.1}/.github/workflows/test.yml +0 -0
- {t_sql-4.4.2 → t_sql-4.5.1}/.gitignore +0 -0
- {t_sql-4.4.2 → t_sql-4.5.1}/Dockerfile +0 -0
- {t_sql-4.4.2 → t_sql-4.5.1}/LICENSE +0 -0
- {t_sql-4.4.2 → t_sql-4.5.1}/README.md +0 -0
- {t_sql-4.4.2 → t_sql-4.5.1}/compose.yaml +0 -0
- {t_sql-4.4.2 → t_sql-4.5.1}/context7.json +0 -0
- {t_sql-4.4.2 → t_sql-4.5.1}/pytest.ini +0 -0
- {t_sql-4.4.2 → t_sql-4.5.1}/tests/test_alembic_integration.py +0 -0
- {t_sql-4.4.2 → t_sql-4.5.1}/tests/test_different_object_types.py +0 -0
- {t_sql-4.4.2 → t_sql-4.5.1}/tests/test_escaped.py +0 -0
- {t_sql-4.4.2 → t_sql-4.5.1}/tests/test_escaped_binary_hex.py +0 -0
- {t_sql-4.4.2 → t_sql-4.5.1}/tests/test_helper_functions.py +0 -0
- {t_sql-4.4.2 → t_sql-4.5.1}/tests/test_injection_edge_cases.py +0 -0
- {t_sql-4.4.2 → t_sql-4.5.1}/tests/test_injection_protection_validation.py +0 -0
- {t_sql-4.4.2 → t_sql-4.5.1}/tests/test_injections_for_escaped.py +0 -0
- {t_sql-4.4.2 → t_sql-4.5.1}/tests/test_mysql_integration.py +0 -0
- {t_sql-4.4.2 → t_sql-4.5.1}/tests/test_parameter_names.py +0 -0
- {t_sql-4.4.2 → t_sql-4.5.1}/tests/test_query_builder.py +0 -0
- {t_sql-4.4.2 → t_sql-4.5.1}/tests/test_sqlalchemy_integration.py +0 -0
- {t_sql-4.4.2 → t_sql-4.5.1}/tests/test_sqlite_integration.py +0 -0
- {t_sql-4.4.2 → t_sql-4.5.1}/tests/test_styles.py +0 -0
- {t_sql-4.4.2 → t_sql-4.5.1}/tsql/styles.py +0 -0
- {t_sql-4.4.2 → t_sql-4.5.1}/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,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
|
|
|
@@ -291,12 +291,14 @@ def test_map_results_with_joins_and_aliases():
|
|
|
291
291
|
}
|
|
292
292
|
]
|
|
293
293
|
|
|
294
|
-
query.map_results(rows)
|
|
294
|
+
results = query.map_results(rows)
|
|
295
295
|
|
|
296
296
|
# Check aliased column was decrypted
|
|
297
|
-
assert
|
|
297
|
+
assert results[0]["social"] == "123-45-6789"
|
|
298
|
+
assert results[0].social == "123-45-6789" # Test attribute access
|
|
298
299
|
# Check joined table column was deserialized
|
|
299
|
-
assert
|
|
300
|
+
assert results[0]["metadata_col"] == {"key": "value"}
|
|
301
|
+
assert results[0].metadata_col == {"key": "value"} # Test attribute access
|
|
300
302
|
|
|
301
303
|
# Test with SELECT * (should process all columns from all tables)
|
|
302
304
|
query_star = User.select().join(Profile, on=User.id == Profile.user_id)
|
|
@@ -311,8 +313,10 @@ def test_map_results_with_joins_and_aliases():
|
|
|
311
313
|
}
|
|
312
314
|
]
|
|
313
315
|
|
|
314
|
-
query_star.map_results(rows_star)
|
|
316
|
+
results_star = query_star.map_results(rows_star)
|
|
315
317
|
|
|
316
318
|
# Both processors should be applied
|
|
317
|
-
assert
|
|
318
|
-
assert
|
|
319
|
+
assert results_star[0]["ssn"] == "987-65-4321"
|
|
320
|
+
assert results_star[0].ssn == "987-65-4321" # Test attribute access
|
|
321
|
+
assert results_star[0]["metadata_col"] == {"foo": "bar"}
|
|
322
|
+
assert results_star[0].metadata_col == {"foo": "bar"} # Test attribute access
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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)
|
|
@@ -362,6 +391,7 @@ def delete(table: str, id: str | int) -> TSQL:
|
|
|
362
391
|
|
|
363
392
|
from tsql.query_builder import UnsafeQueryError
|
|
364
393
|
from tsql.type_processor import TypeProcessor
|
|
394
|
+
from tsql.row import Row
|
|
365
395
|
|
|
366
396
|
__all__ = [
|
|
367
397
|
'TSQL',
|
|
@@ -375,5 +405,6 @@ __all__ = [
|
|
|
375
405
|
'set_style',
|
|
376
406
|
'UnsafeQueryError',
|
|
377
407
|
'TypeProcessor',
|
|
408
|
+
'Row',
|
|
378
409
|
]
|
|
379
410
|
|
|
@@ -4,6 +4,7 @@ from datetime import datetime
|
|
|
4
4
|
from abc import ABC, abstractmethod
|
|
5
5
|
|
|
6
6
|
from tsql import TSQL, t_join
|
|
7
|
+
from tsql.row import Row
|
|
7
8
|
|
|
8
9
|
|
|
9
10
|
class UnsafeQueryError(Exception):
|
|
@@ -559,26 +560,28 @@ class QueryBuilder(ABC):
|
|
|
559
560
|
"""
|
|
560
561
|
return self.to_tsql().render(style)
|
|
561
562
|
|
|
562
|
-
def map_results(self, rows: List[Dict[str, Any]]) -> List[
|
|
563
|
+
def map_results(self, rows: List[Dict[str, Any]]) -> List[Row]:
|
|
563
564
|
"""Transform database rows with type processors applied.
|
|
564
565
|
|
|
565
566
|
This method applies process_result_value from type processors to convert
|
|
566
567
|
database values back to Python values (e.g., decrypt encrypted fields,
|
|
567
568
|
deserialize JSON, etc.).
|
|
568
569
|
|
|
569
|
-
|
|
570
|
-
(
|
|
570
|
+
Returns Row objects (dict subclass with attribute access) for consistent
|
|
571
|
+
API regardless of input type (dict, asyncpg Record, etc.).
|
|
571
572
|
|
|
572
573
|
Args:
|
|
573
574
|
rows: List of row objects from database query results
|
|
574
575
|
|
|
575
576
|
Returns:
|
|
576
|
-
|
|
577
|
+
List of Row objects with transformed values
|
|
577
578
|
|
|
578
579
|
Example:
|
|
579
580
|
query = User.select().where(User.id == 1)
|
|
580
581
|
rows = await conn.fetch(*query.render())
|
|
581
|
-
query.map_results(rows)
|
|
582
|
+
results = query.map_results(rows)
|
|
583
|
+
print(results[0].name) # Attribute access
|
|
584
|
+
print(results[0]['name']) # Dict access
|
|
582
585
|
"""
|
|
583
586
|
if not hasattr(self, 'base_table'):
|
|
584
587
|
raise AttributeError("map_results requires a base_table attribute")
|
|
@@ -602,13 +605,16 @@ class QueryBuilder(ABC):
|
|
|
602
605
|
for table in tables:
|
|
603
606
|
processors.update(table._type_processors)
|
|
604
607
|
|
|
605
|
-
#
|
|
608
|
+
# Convert to Row objects and apply processors
|
|
609
|
+
results = []
|
|
606
610
|
for row in rows:
|
|
607
|
-
|
|
611
|
+
row_dict = Row(row) # Converts dict/Record to Row
|
|
612
|
+
for col_name in list(row_dict.keys()):
|
|
608
613
|
if col_name in processors:
|
|
609
|
-
|
|
614
|
+
row_dict[col_name] = processors[col_name].process_result_value(row_dict[col_name])
|
|
615
|
+
results.append(row_dict)
|
|
610
616
|
|
|
611
|
-
return
|
|
617
|
+
return results
|
|
612
618
|
|
|
613
619
|
|
|
614
620
|
class InsertBuilder(QueryBuilder):
|
|
@@ -713,11 +719,8 @@ class InsertBuilder(QueryBuilder):
|
|
|
713
719
|
# Apply type processors to values
|
|
714
720
|
values_dict = {}
|
|
715
721
|
for col_name, value in self.values.items():
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
values_dict[col_name] = processor.process_bind_param(value)
|
|
719
|
-
else:
|
|
720
|
-
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)
|
|
721
724
|
|
|
722
725
|
# MySQL INSERT IGNORE
|
|
723
726
|
if self._ignore:
|
|
@@ -752,11 +755,8 @@ class InsertBuilder(QueryBuilder):
|
|
|
752
755
|
# User specified which columns to update - apply type processors
|
|
753
756
|
update_dict = {}
|
|
754
757
|
for col_name, value in self._update_cols.items():
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
update_dict[col_name] = processor.process_bind_param(value)
|
|
758
|
-
else:
|
|
759
|
-
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)
|
|
760
760
|
parts.append(t'ON CONFLICT ({conflict_cols_str:unsafe}) DO UPDATE SET {update_dict:as_set}')
|
|
761
761
|
else:
|
|
762
762
|
# Default: update all non-conflict columns with EXCLUDED.*
|
|
@@ -780,11 +780,8 @@ class InsertBuilder(QueryBuilder):
|
|
|
780
780
|
# Apply type processors
|
|
781
781
|
update_dict = {}
|
|
782
782
|
for col_name, value in self._update_cols.items():
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
update_dict[col_name] = processor.process_bind_param(value)
|
|
786
|
-
else:
|
|
787
|
-
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)
|
|
788
785
|
parts.append(t'ON DUPLICATE KEY UPDATE {update_dict:as_set}')
|
|
789
786
|
else:
|
|
790
787
|
# Default: update all columns with alias.column (new MySQL syntax)
|
|
@@ -824,6 +821,30 @@ class InsertBuilder(QueryBuilder):
|
|
|
824
821
|
return f"InsertBuilder(<error rendering: {e}>)"
|
|
825
822
|
|
|
826
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
|
+
|
|
827
848
|
class UpdateBuilder(QueryBuilder):
|
|
828
849
|
"""Fluent interface for building UPDATE queries"""
|
|
829
850
|
|
|
@@ -907,11 +928,8 @@ class UpdateBuilder(QueryBuilder):
|
|
|
907
928
|
# Apply type processors to values
|
|
908
929
|
values_dict = {}
|
|
909
930
|
for col_name, value in self.values.items():
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
values_dict[col_name] = processor.process_bind_param(value)
|
|
913
|
-
else:
|
|
914
|
-
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)
|
|
915
933
|
|
|
916
934
|
parts.append(t'UPDATE {table_name:literal} SET {values_dict:as_set}')
|
|
917
935
|
|
t_sql-4.5.1/tsql/row.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Row object - dict with attribute access support."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Row(dict):
|
|
5
|
+
"""A dict subclass that supports attribute-style access.
|
|
6
|
+
|
|
7
|
+
Provides a nicer API for accessing row data while maintaining
|
|
8
|
+
full dict compatibility.
|
|
9
|
+
|
|
10
|
+
Example:
|
|
11
|
+
row = Row({'id': 1, 'name': 'Alice'})
|
|
12
|
+
print(row.id) # 1 (attribute access)
|
|
13
|
+
print(row['name']) # Alice (dict access)
|
|
14
|
+
row.age = 30 # Set via attribute
|
|
15
|
+
print(row['age']) # 30
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __getattr__(self, key: str):
|
|
19
|
+
"""Allow attribute-style access to dict keys."""
|
|
20
|
+
try:
|
|
21
|
+
return self[key]
|
|
22
|
+
except KeyError:
|
|
23
|
+
raise AttributeError(f"Row has no attribute {key!r}") from None
|
|
24
|
+
|
|
25
|
+
def __setattr__(self, key: str, value):
|
|
26
|
+
"""Allow attribute-style setting of dict keys."""
|
|
27
|
+
self[key] = value
|
|
28
|
+
|
|
29
|
+
def __delattr__(self, key: str):
|
|
30
|
+
"""Allow attribute-style deletion of dict keys."""
|
|
31
|
+
try:
|
|
32
|
+
del self[key]
|
|
33
|
+
except KeyError:
|
|
34
|
+
raise AttributeError(f"Row has no attribute {key!r}") from None
|
|
35
|
+
|
|
36
|
+
def __repr__(self) -> str:
|
|
37
|
+
"""Nice repr for debugging."""
|
|
38
|
+
items = ', '.join(f'{k}={v!r}' for k, v in self.items())
|
|
39
|
+
return f"Row({items})"
|
|
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
|