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.
Files changed (35) hide show
  1. {t_sql-4.4.2 → t_sql-4.5.1}/PKG-INFO +1 -1
  2. {t_sql-4.4.2 → t_sql-4.5.1}/pyproject.toml +1 -1
  3. {t_sql-4.4.2 → 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.4.2 → t_sql-4.5.1}/tests/test_tsql.py +44 -0
  6. {t_sql-4.4.2 → t_sql-4.5.1}/tests/test_type_processor.py +10 -6
  7. {t_sql-4.4.2 → t_sql-4.5.1}/tsql/__init__.py +33 -2
  8. {t_sql-4.4.2 → t_sql-4.5.1}/tsql/query_builder.py +47 -29
  9. t_sql-4.5.1/tsql/row.py +39 -0
  10. {t_sql-4.4.2 → t_sql-4.5.1}/.dockerignore +0 -0
  11. {t_sql-4.4.2 → t_sql-4.5.1}/.github/workflows/publish.yml +0 -0
  12. {t_sql-4.4.2 → t_sql-4.5.1}/.github/workflows/test.yml +0 -0
  13. {t_sql-4.4.2 → t_sql-4.5.1}/.gitignore +0 -0
  14. {t_sql-4.4.2 → t_sql-4.5.1}/Dockerfile +0 -0
  15. {t_sql-4.4.2 → t_sql-4.5.1}/LICENSE +0 -0
  16. {t_sql-4.4.2 → t_sql-4.5.1}/README.md +0 -0
  17. {t_sql-4.4.2 → t_sql-4.5.1}/compose.yaml +0 -0
  18. {t_sql-4.4.2 → t_sql-4.5.1}/context7.json +0 -0
  19. {t_sql-4.4.2 → t_sql-4.5.1}/pytest.ini +0 -0
  20. {t_sql-4.4.2 → t_sql-4.5.1}/tests/test_alembic_integration.py +0 -0
  21. {t_sql-4.4.2 → t_sql-4.5.1}/tests/test_different_object_types.py +0 -0
  22. {t_sql-4.4.2 → t_sql-4.5.1}/tests/test_escaped.py +0 -0
  23. {t_sql-4.4.2 → t_sql-4.5.1}/tests/test_escaped_binary_hex.py +0 -0
  24. {t_sql-4.4.2 → t_sql-4.5.1}/tests/test_helper_functions.py +0 -0
  25. {t_sql-4.4.2 → t_sql-4.5.1}/tests/test_injection_edge_cases.py +0 -0
  26. {t_sql-4.4.2 → t_sql-4.5.1}/tests/test_injection_protection_validation.py +0 -0
  27. {t_sql-4.4.2 → t_sql-4.5.1}/tests/test_injections_for_escaped.py +0 -0
  28. {t_sql-4.4.2 → t_sql-4.5.1}/tests/test_mysql_integration.py +0 -0
  29. {t_sql-4.4.2 → t_sql-4.5.1}/tests/test_parameter_names.py +0 -0
  30. {t_sql-4.4.2 → t_sql-4.5.1}/tests/test_query_builder.py +0 -0
  31. {t_sql-4.4.2 → t_sql-4.5.1}/tests/test_sqlalchemy_integration.py +0 -0
  32. {t_sql-4.4.2 → t_sql-4.5.1}/tests/test_sqlite_integration.py +0 -0
  33. {t_sql-4.4.2 → t_sql-4.5.1}/tests/test_styles.py +0 -0
  34. {t_sql-4.4.2 → t_sql-4.5.1}/tsql/styles.py +0 -0
  35. {t_sql-4.4.2 → 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.4.2
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.4.2"
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
 
@@ -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 rows[0]["social"] == "123-45-6789"
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 rows[0]["metadata_col"] == {"key": "value"}
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 rows_star[0]["ssn"] == "987-65-4321"
318
- assert rows_star[0]["metadata_col"] == {"foo": "bar"}
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
- 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)
@@ -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[Dict[str, Any]]:
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
- Modifies row objects in place to preserve driver-specific row types
570
- (e.g., asyncpg Record objects).
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
- The same list of rows with transformed values (mutated in place)
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) # ssn decrypted, metadata deserialized (in place)
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
- # Apply processors to rows in place
608
+ # Convert to Row objects and apply processors
609
+ results = []
606
610
  for row in rows:
607
- for col_name in list(row.keys()):
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
- row[col_name] = processors[col_name].process_result_value(row[col_name])
614
+ row_dict[col_name] = processors[col_name].process_result_value(row_dict[col_name])
615
+ results.append(row_dict)
610
616
 
611
- return rows
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
- if col_name in self.base_table._type_processors:
717
- processor = self.base_table._type_processors[col_name]
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
- if col_name in self.base_table._type_processors:
756
- processor = self.base_table._type_processors[col_name]
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
- if col_name in self.base_table._type_processors:
784
- processor = self.base_table._type_processors[col_name]
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
- if col_name in self.base_table._type_processors:
911
- processor = self.base_table._type_processors[col_name]
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
 
@@ -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