t-sql 4.3.0__tar.gz → 4.4.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 (33) hide show
  1. {t_sql-4.3.0 → t_sql-4.4.1}/PKG-INFO +77 -1
  2. {t_sql-4.3.0 → t_sql-4.4.1}/README.md +76 -0
  3. {t_sql-4.3.0 → t_sql-4.4.1}/pyproject.toml +1 -1
  4. t_sql-4.4.1/tests/test_type_processor.py +260 -0
  5. {t_sql-4.3.0 → t_sql-4.4.1}/tsql/__init__.py +2 -0
  6. {t_sql-4.3.0 → t_sql-4.4.1}/tsql/query_builder.py +142 -25
  7. t_sql-4.4.1/tsql/type_processor.py +69 -0
  8. {t_sql-4.3.0 → t_sql-4.4.1}/.dockerignore +0 -0
  9. {t_sql-4.3.0 → t_sql-4.4.1}/.github/workflows/publish.yml +0 -0
  10. {t_sql-4.3.0 → t_sql-4.4.1}/.github/workflows/test.yml +0 -0
  11. {t_sql-4.3.0 → t_sql-4.4.1}/.gitignore +0 -0
  12. {t_sql-4.3.0 → t_sql-4.4.1}/Dockerfile +0 -0
  13. {t_sql-4.3.0 → t_sql-4.4.1}/LICENSE +0 -0
  14. {t_sql-4.3.0 → t_sql-4.4.1}/compose.yaml +0 -0
  15. {t_sql-4.3.0 → t_sql-4.4.1}/context7.json +0 -0
  16. {t_sql-4.3.0 → t_sql-4.4.1}/pytest.ini +0 -0
  17. {t_sql-4.3.0 → t_sql-4.4.1}/tests/test_alembic_integration.py +0 -0
  18. {t_sql-4.3.0 → t_sql-4.4.1}/tests/test_asyncpg_integration.py +0 -0
  19. {t_sql-4.3.0 → t_sql-4.4.1}/tests/test_different_object_types.py +0 -0
  20. {t_sql-4.3.0 → t_sql-4.4.1}/tests/test_escaped.py +0 -0
  21. {t_sql-4.3.0 → t_sql-4.4.1}/tests/test_escaped_binary_hex.py +0 -0
  22. {t_sql-4.3.0 → t_sql-4.4.1}/tests/test_helper_functions.py +0 -0
  23. {t_sql-4.3.0 → t_sql-4.4.1}/tests/test_injection_edge_cases.py +0 -0
  24. {t_sql-4.3.0 → t_sql-4.4.1}/tests/test_injection_protection_validation.py +0 -0
  25. {t_sql-4.3.0 → t_sql-4.4.1}/tests/test_injections_for_escaped.py +0 -0
  26. {t_sql-4.3.0 → t_sql-4.4.1}/tests/test_mysql_integration.py +0 -0
  27. {t_sql-4.3.0 → t_sql-4.4.1}/tests/test_parameter_names.py +0 -0
  28. {t_sql-4.3.0 → t_sql-4.4.1}/tests/test_query_builder.py +0 -0
  29. {t_sql-4.3.0 → t_sql-4.4.1}/tests/test_sqlalchemy_integration.py +0 -0
  30. {t_sql-4.3.0 → t_sql-4.4.1}/tests/test_sqlite_integration.py +0 -0
  31. {t_sql-4.3.0 → t_sql-4.4.1}/tests/test_styles.py +0 -0
  32. {t_sql-4.3.0 → t_sql-4.4.1}/tests/test_tsql.py +0 -0
  33. {t_sql-4.3.0 → t_sql-4.4.1}/tsql/styles.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: t-sql
3
- Version: 4.3.0
3
+ Version: 4.4.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
@@ -569,6 +569,82 @@ class Users(Table, metadata=metadata, comment='Application user accounts'):
569
569
 
570
570
  Table comments appear in database introspection tools and migration files, making your schema self-documenting.
571
571
 
572
+ ### Type Processors
573
+
574
+ Type processors enable automatic value transformation when reading from and writing to the database, similar to SQLAlchemy's `TypeDecorator`. This is useful for encryption, serialization, and custom data transformations.
575
+
576
+ ```python
577
+ from tsql import TypeProcessor
578
+ from tsql.query_builder import Table, SAColumn
579
+ from sqlalchemy import Integer, String, MetaData
580
+ import json
581
+
582
+ metadata = MetaData()
583
+
584
+ # Define custom type processors
585
+ class EncryptedString(TypeProcessor):
586
+ def __init__(self, key):
587
+ self.key = key
588
+
589
+ def process_bind_param(self, value):
590
+ """Transform Python value -> DB value (encrypt on write)"""
591
+ if value is None:
592
+ return None
593
+ return encrypt(value, self.key)
594
+
595
+ def process_result_value(self, value):
596
+ """Transform DB value -> Python value (decrypt on read)"""
597
+ if value is None:
598
+ return None
599
+ return decrypt(value, self.key)
600
+
601
+ class JSONType(TypeProcessor):
602
+ def process_bind_param(self, value):
603
+ """Serialize Python dict/list -> JSON string"""
604
+ return json.dumps(value) if value is not None else None
605
+
606
+ def process_result_value(self, value):
607
+ """Deserialize JSON string -> Python dict/list"""
608
+ return json.loads(value) if value is not None else None
609
+
610
+ # Use type processors in table definition
611
+ class User(Table, metadata=metadata):
612
+ id = SAColumn(Integer, primary_key=True)
613
+ ssn = SAColumn(String(255), type_processor=EncryptedString(key="secret"))
614
+ metadata_ = SAColumn(String, type_processor=JSONType())
615
+ email = SAColumn(String(255)) # No processor = no transformation
616
+
617
+ # Write - automatic encryption/serialization
618
+ User.insert(ssn="123-45-6789", metadata_={"role": "admin"})
619
+ # SQL: INSERT INTO user (ssn, metadata_) VALUES (?, ?)
620
+ # Params: [encrypt("123-45-6789", "secret"), '{"role": "admin"}']
621
+
622
+ User.update(ssn="new-ssn").where(User.id == 1)
623
+ # SQL: UPDATE user SET ssn = ? WHERE user.id = ?
624
+ # Params: [encrypt("new-ssn", "secret"), 1]
625
+
626
+ # Where clauses - automatic transformation
627
+ User.select().where(User.ssn == "123-45-6789")
628
+ # SQL: SELECT * FROM user WHERE user.ssn = ?
629
+ # Params: [encrypt("123-45-6789", "secret")]
630
+
631
+ # Read - manual decryption/deserialization with map_results()
632
+ query = User.select().where(User.id == 1)
633
+ sql, params = query.render()
634
+ rows = await connection.fetch(sql, *params) # Returns encrypted/serialized data
635
+ transformed_rows = query.map_results(rows) # Applies type processors
636
+ # transformed_rows = [{"id": 1, "ssn": "123-45-6789", "metadata_": {"role": "admin"}, ...}]
637
+ ```
638
+
639
+ **Key features:**
640
+ - **Write-side**: Automatically applied in `INSERT`, `UPDATE`, and `WHERE` clauses
641
+ - **Read-side**: Manual via `query.map_results(rows)` - you control when transformation happens
642
+ - **NULL handling**: NULL values are passed through to processors (they decide how to handle)
643
+ - **Column comparisons**: Type processors are NOT applied when comparing columns to other columns
644
+
645
+ **Why manual read-side transformation?**
646
+ The query builder stays database-agnostic and doesn't execute queries directly. You control when to apply transformations after fetching results from your specific database driver.
647
+
572
648
  ## Schema Support
573
649
 
574
650
  ```python
@@ -559,6 +559,82 @@ class Users(Table, metadata=metadata, comment='Application user accounts'):
559
559
 
560
560
  Table comments appear in database introspection tools and migration files, making your schema self-documenting.
561
561
 
562
+ ### Type Processors
563
+
564
+ Type processors enable automatic value transformation when reading from and writing to the database, similar to SQLAlchemy's `TypeDecorator`. This is useful for encryption, serialization, and custom data transformations.
565
+
566
+ ```python
567
+ from tsql import TypeProcessor
568
+ from tsql.query_builder import Table, SAColumn
569
+ from sqlalchemy import Integer, String, MetaData
570
+ import json
571
+
572
+ metadata = MetaData()
573
+
574
+ # Define custom type processors
575
+ class EncryptedString(TypeProcessor):
576
+ def __init__(self, key):
577
+ self.key = key
578
+
579
+ def process_bind_param(self, value):
580
+ """Transform Python value -> DB value (encrypt on write)"""
581
+ if value is None:
582
+ return None
583
+ return encrypt(value, self.key)
584
+
585
+ def process_result_value(self, value):
586
+ """Transform DB value -> Python value (decrypt on read)"""
587
+ if value is None:
588
+ return None
589
+ return decrypt(value, self.key)
590
+
591
+ class JSONType(TypeProcessor):
592
+ def process_bind_param(self, value):
593
+ """Serialize Python dict/list -> JSON string"""
594
+ return json.dumps(value) if value is not None else None
595
+
596
+ def process_result_value(self, value):
597
+ """Deserialize JSON string -> Python dict/list"""
598
+ return json.loads(value) if value is not None else None
599
+
600
+ # Use type processors in table definition
601
+ class User(Table, metadata=metadata):
602
+ id = SAColumn(Integer, primary_key=True)
603
+ ssn = SAColumn(String(255), type_processor=EncryptedString(key="secret"))
604
+ metadata_ = SAColumn(String, type_processor=JSONType())
605
+ email = SAColumn(String(255)) # No processor = no transformation
606
+
607
+ # Write - automatic encryption/serialization
608
+ User.insert(ssn="123-45-6789", metadata_={"role": "admin"})
609
+ # SQL: INSERT INTO user (ssn, metadata_) VALUES (?, ?)
610
+ # Params: [encrypt("123-45-6789", "secret"), '{"role": "admin"}']
611
+
612
+ User.update(ssn="new-ssn").where(User.id == 1)
613
+ # SQL: UPDATE user SET ssn = ? WHERE user.id = ?
614
+ # Params: [encrypt("new-ssn", "secret"), 1]
615
+
616
+ # Where clauses - automatic transformation
617
+ User.select().where(User.ssn == "123-45-6789")
618
+ # SQL: SELECT * FROM user WHERE user.ssn = ?
619
+ # Params: [encrypt("123-45-6789", "secret")]
620
+
621
+ # Read - manual decryption/deserialization with map_results()
622
+ query = User.select().where(User.id == 1)
623
+ sql, params = query.render()
624
+ rows = await connection.fetch(sql, *params) # Returns encrypted/serialized data
625
+ transformed_rows = query.map_results(rows) # Applies type processors
626
+ # transformed_rows = [{"id": 1, "ssn": "123-45-6789", "metadata_": {"role": "admin"}, ...}]
627
+ ```
628
+
629
+ **Key features:**
630
+ - **Write-side**: Automatically applied in `INSERT`, `UPDATE`, and `WHERE` clauses
631
+ - **Read-side**: Manual via `query.map_results(rows)` - you control when transformation happens
632
+ - **NULL handling**: NULL values are passed through to processors (they decide how to handle)
633
+ - **Column comparisons**: Type processors are NOT applied when comparing columns to other columns
634
+
635
+ **Why manual read-side transformation?**
636
+ The query builder stays database-agnostic and doesn't execute queries directly. You control when to apply transformations after fetching results from your specific database driver.
637
+
562
638
  ## Schema Support
563
639
 
564
640
  ```python
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "t-sql"
7
- version = "4.3.0"
7
+ version = "4.4.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"
@@ -0,0 +1,260 @@
1
+ import json
2
+ import pytest
3
+ from tsql import TypeProcessor
4
+ from tsql.query_builder import Table, SAColumn
5
+
6
+ try:
7
+ from sqlalchemy import Integer, String, MetaData
8
+ HAS_SQLALCHEMY = True
9
+ except ImportError:
10
+ HAS_SQLALCHEMY = False
11
+
12
+
13
+ class EncryptedString(TypeProcessor):
14
+ """Simple encryption simulator for testing"""
15
+
16
+ def __init__(self, key: str):
17
+ self.key = key
18
+
19
+ def process_bind_param(self, value):
20
+ if value is None:
21
+ return None
22
+ return f"encrypted_{value}_{self.key}"
23
+
24
+ def process_result_value(self, value):
25
+ if value is None:
26
+ return None
27
+ if value.startswith(f"encrypted_") and value.endswith(f"_{self.key}"):
28
+ return value[len(f"encrypted_"):-len(f"_{self.key}")]
29
+ return value
30
+
31
+
32
+ class JSONType(TypeProcessor):
33
+ """JSON serialization for testing"""
34
+
35
+ def process_bind_param(self, value):
36
+ if value is None:
37
+ return None
38
+ return json.dumps(value)
39
+
40
+ def process_result_value(self, value):
41
+ if value is None:
42
+ return None
43
+ if isinstance(value, str):
44
+ return json.loads(value)
45
+ return value
46
+
47
+
48
+ @pytest.mark.skipif(not HAS_SQLALCHEMY, reason="SQLAlchemy not installed")
49
+ def test_insert_with_type_processor():
50
+ """Test that type processors are applied during INSERT"""
51
+ metadata = MetaData()
52
+
53
+ class User(Table, metadata=metadata):
54
+ id = SAColumn(Integer, primary_key=True)
55
+ ssn = SAColumn(String(255), type_processor=EncryptedString(key="secret123"))
56
+ metadata_ = SAColumn(String, type_processor=JSONType())
57
+
58
+ # Build insert query
59
+ query = User.insert(id=1, ssn="123-45-6789", metadata_={"foo": "bar"})
60
+ sql, params = query.render()
61
+
62
+ # Check that values were transformed
63
+ assert params[1] == "encrypted_123-45-6789_secret123"
64
+ assert params[2] == '{"foo": "bar"}'
65
+
66
+
67
+ @pytest.mark.skipif(not HAS_SQLALCHEMY, reason="SQLAlchemy not installed")
68
+ def test_update_with_type_processor():
69
+ """Test that type processors are applied during UPDATE"""
70
+ metadata = MetaData()
71
+
72
+ class User(Table, metadata=metadata):
73
+ id = SAColumn(Integer, primary_key=True)
74
+ ssn = SAColumn(String(255), type_processor=EncryptedString(key="secret123"))
75
+
76
+ # Build update query
77
+ query = User.update(ssn="new-ssn").where(User.id == 1)
78
+ sql, params = query.render()
79
+
80
+ # Check that value was transformed
81
+ assert "encrypted_new-ssn_secret123" in params
82
+
83
+
84
+ @pytest.mark.skipif(not HAS_SQLALCHEMY, reason="SQLAlchemy not installed")
85
+ def test_where_clause_with_type_processor():
86
+ """Test that type processors are applied in WHERE clauses"""
87
+ metadata = MetaData()
88
+
89
+ class User(Table, metadata=metadata):
90
+ id = SAColumn(Integer, primary_key=True)
91
+ ssn = SAColumn(String(255), type_processor=EncryptedString(key="secret123"))
92
+
93
+ # Build select query with WHERE
94
+ query = User.select().where(User.ssn == "123-45-6789")
95
+ sql, params = query.render()
96
+
97
+ # Check that value was transformed
98
+ assert params[0] == "encrypted_123-45-6789_secret123"
99
+
100
+
101
+ @pytest.mark.skipif(not HAS_SQLALCHEMY, reason="SQLAlchemy not installed")
102
+ def test_in_clause_with_type_processor():
103
+ """Test that type processors are applied to IN clause values"""
104
+ metadata = MetaData()
105
+
106
+ class User(Table, metadata=metadata):
107
+ id = SAColumn(Integer, primary_key=True)
108
+ ssn = SAColumn(String(255), type_processor=EncryptedString(key="secret123"))
109
+
110
+ # Build select query with IN
111
+ query = User.select().where(User.ssn.in_(["123-45-6789", "987-65-4321"]))
112
+ sql, params = query.render()
113
+
114
+ # Check that values were transformed
115
+ assert params[0] == "encrypted_123-45-6789_secret123"
116
+ assert params[1] == "encrypted_987-65-4321_secret123"
117
+
118
+
119
+ @pytest.mark.skipif(not HAS_SQLALCHEMY, reason="SQLAlchemy not installed")
120
+ def test_between_with_type_processor():
121
+ """Test that type processors are applied to BETWEEN values"""
122
+ metadata = MetaData()
123
+
124
+ class User(Table, metadata=metadata):
125
+ id = SAColumn(Integer, primary_key=True)
126
+ score = SAColumn(Integer, type_processor=EncryptedString(key="secret123"))
127
+
128
+ # Build select query with BETWEEN
129
+ query = User.select().where(User.score.between(10, 20))
130
+ sql, params = query.render()
131
+
132
+ # Check that values were transformed
133
+ assert params[0] == "encrypted_10_secret123"
134
+ assert params[1] == "encrypted_20_secret123"
135
+
136
+
137
+ @pytest.mark.skipif(not HAS_SQLALCHEMY, reason="SQLAlchemy not installed")
138
+ def test_map_results():
139
+ """Test that map_results applies type processors to query results"""
140
+ metadata = MetaData()
141
+
142
+ class User(Table, metadata=metadata):
143
+ id = SAColumn(Integer, primary_key=True)
144
+ ssn = SAColumn(String(255), type_processor=EncryptedString(key="secret123"))
145
+ metadata_ = SAColumn(String, type_processor=JSONType())
146
+
147
+ # Simulate database results
148
+ rows = [
149
+ {"id": 1, "ssn": "encrypted_123-45-6789_secret123", "metadata_": '{"foo": "bar"}'},
150
+ {"id": 2, "ssn": "encrypted_987-65-4321_secret123", "metadata_": '{"baz": "qux"}'},
151
+ ]
152
+
153
+ # Build query and map results
154
+ query = User.select()
155
+ transformed_rows = query.map_results(rows)
156
+
157
+ # Check that values were decrypted/deserialized
158
+ assert transformed_rows[0]["ssn"] == "123-45-6789"
159
+ assert transformed_rows[0]["metadata_"] == {"foo": "bar"}
160
+ assert transformed_rows[1]["ssn"] == "987-65-4321"
161
+ assert transformed_rows[1]["metadata_"] == {"baz": "qux"}
162
+
163
+
164
+ @pytest.mark.skipif(not HAS_SQLALCHEMY, reason="SQLAlchemy not installed")
165
+ def test_null_handling():
166
+ """Test that NULL values are passed through to processors"""
167
+ metadata = MetaData()
168
+
169
+ class NullAwareProcessor(TypeProcessor):
170
+ def process_bind_param(self, value):
171
+ if value is None:
172
+ return "NULL_MARKER"
173
+ return value
174
+
175
+ def process_result_value(self, value):
176
+ if value == "NULL_MARKER":
177
+ return None
178
+ return value
179
+
180
+ class User(Table, metadata=metadata):
181
+ id = SAColumn(Integer, primary_key=True)
182
+ optional_field = SAColumn(String, type_processor=NullAwareProcessor())
183
+
184
+ # Test insert with None
185
+ query = User.insert(id=1, optional_field=None)
186
+ sql, params = query.render()
187
+ assert params[1] == "NULL_MARKER"
188
+
189
+ # Test map_results with marker
190
+ rows = [{"id": 1, "optional_field": "NULL_MARKER"}]
191
+ query = User.select()
192
+ transformed_rows = query.map_results(rows)
193
+ assert transformed_rows[0]["optional_field"] is None
194
+
195
+
196
+ @pytest.mark.skipif(not HAS_SQLALCHEMY, reason="SQLAlchemy not installed")
197
+ def test_on_conflict_with_type_processor():
198
+ """Test that type processors are applied in ON CONFLICT UPDATE"""
199
+ metadata = MetaData()
200
+
201
+ class User(Table, metadata=metadata):
202
+ id = SAColumn(Integer, primary_key=True)
203
+ ssn = SAColumn(String(255), type_processor=EncryptedString(key="secret123"))
204
+
205
+ # Build insert with on_conflict_update
206
+ query = User.insert(id=1, ssn="123-45-6789").on_conflict_update(
207
+ conflict_on="id",
208
+ update={"ssn": "new-ssn"}
209
+ )
210
+ sql, params = query.render()
211
+
212
+ # Check that both insert and update values were transformed
213
+ assert "encrypted_123-45-6789_secret123" in params
214
+ assert "encrypted_new-ssn_secret123" in params
215
+
216
+
217
+ @pytest.mark.skipif(not HAS_SQLALCHEMY, reason="SQLAlchemy not installed")
218
+ def test_comparison_operators_with_type_processor():
219
+ """Test that all comparison operators apply type processors"""
220
+ metadata = MetaData()
221
+
222
+ class User(Table, metadata=metadata):
223
+ id = SAColumn(Integer, primary_key=True)
224
+ score = SAColumn(Integer, type_processor=EncryptedString(key="secret123"))
225
+
226
+ # Test various operators
227
+ ops = [
228
+ (User.score == 100, "encrypted_100_secret123"),
229
+ (User.score != 100, "encrypted_100_secret123"),
230
+ (User.score < 100, "encrypted_100_secret123"),
231
+ (User.score <= 100, "encrypted_100_secret123"),
232
+ (User.score > 100, "encrypted_100_secret123"),
233
+ (User.score >= 100, "encrypted_100_secret123"),
234
+ (User.score.like("10%"), "encrypted_10%_secret123"),
235
+ (User.score.not_like("10%"), "encrypted_10%_secret123"),
236
+ ]
237
+
238
+ for condition, expected_value in ops:
239
+ query = User.select().where(condition)
240
+ sql, params = query.render()
241
+ assert expected_value in params, f"Failed for condition: {condition}"
242
+
243
+
244
+ @pytest.mark.skipif(not HAS_SQLALCHEMY, reason="SQLAlchemy not installed")
245
+ def test_type_processor_not_applied_to_column_comparisons():
246
+ """Test that type processors are NOT applied when comparing columns to columns"""
247
+ metadata = MetaData()
248
+
249
+ class User(Table, metadata=metadata):
250
+ id = SAColumn(Integer, primary_key=True)
251
+ ssn = SAColumn(String(255), type_processor=EncryptedString(key="secret123"))
252
+ backup_ssn = SAColumn(String(255), type_processor=EncryptedString(key="secret123"))
253
+
254
+ # Build query comparing two columns
255
+ query = User.select().where(User.ssn == User.backup_ssn)
256
+ sql, params = query.render()
257
+
258
+ # No parameters should be generated (column-to-column comparison)
259
+ assert len(params) == 0
260
+ assert "user.ssn = user.backup_ssn" in sql
@@ -361,6 +361,7 @@ def delete(table: str, id: str | int) -> TSQL:
361
361
 
362
362
 
363
363
  from tsql.query_builder import UnsafeQueryError
364
+ from tsql.type_processor import TypeProcessor
364
365
 
365
366
  __all__ = [
366
367
  'TSQL',
@@ -373,5 +374,6 @@ __all__ = [
373
374
  'delete',
374
375
  'set_style',
375
376
  'UnsafeQueryError',
377
+ 'TypeProcessor',
376
378
  ]
377
379
 
@@ -38,11 +38,12 @@ class OrderByClause:
38
38
  class Column:
39
39
  """Represents a bound column (table + column name) for building queries"""
40
40
 
41
- def __init__(self, table_name: str | None = None, column_name: str | None = None, alias: str | None = None, schema: str | None = None):
41
+ def __init__(self, table_name: str | None = None, column_name: str | None = None, alias: str | None = None, schema: str | None = None, type_processor: Any | None = None):
42
42
  self.table_name = table_name
43
43
  self.column_name = column_name
44
44
  self.alias = alias
45
45
  self.schema = schema
46
+ self.type_processor = type_processor
46
47
 
47
48
  def __str__(self) -> str:
48
49
  if self.schema:
@@ -70,29 +71,48 @@ class Column:
70
71
  Example:
71
72
  users.select(users.first_name.as_('first'), users.last_name.as_('last'))
72
73
  """
73
- return Column(self.table_name, self.column_name, alias, self.schema)
74
+ return Column(self.table_name, self.column_name, alias, self.schema, self.type_processor)
75
+
76
+ def _process_value(self, value: Any) -> Any:
77
+ """Apply type processor to a comparison value if one is configured.
78
+
79
+ Args:
80
+ value: The value to process
81
+
82
+ Returns:
83
+ The processed value (or unchanged if no processor or value is special type)
84
+ """
85
+ # Don't process special types
86
+ if value is None or isinstance(value, (Column, Template)) or hasattr(value, 'to_tsql'):
87
+ return value
88
+
89
+ # Apply processor if present
90
+ if self.type_processor is not None:
91
+ return self.type_processor.process_bind_param(value)
92
+
93
+ return value
74
94
 
75
95
  def __eq__(self, other) -> 'Condition':
76
96
  if other is None:
77
97
  return Condition(self, 'IS', None)
78
- return Condition(self, '=', other)
98
+ return Condition(self, '=', self._process_value(other))
79
99
 
80
100
  def __ne__(self, other) -> 'Condition':
81
101
  if other is None:
82
102
  return Condition(self, 'IS NOT', None)
83
- return Condition(self, '!=', other)
103
+ return Condition(self, '!=', self._process_value(other))
84
104
 
85
105
  def __lt__(self, other) -> 'Condition':
86
- return Condition(self, '<', other)
106
+ return Condition(self, '<', self._process_value(other))
87
107
 
88
108
  def __le__(self, other) -> 'Condition':
89
- return Condition(self, '<=', other)
109
+ return Condition(self, '<=', self._process_value(other))
90
110
 
91
111
  def __gt__(self, other) -> 'Condition':
92
- return Condition(self, '>', other)
112
+ return Condition(self, '>', self._process_value(other))
93
113
 
94
114
  def __ge__(self, other) -> 'Condition':
95
- return Condition(self, '>=', other)
115
+ return Condition(self, '>=', self._process_value(other))
96
116
 
97
117
  def in_(self, values: Union[list, tuple, 'Column', Template, 'SelectQueryBuilder']) -> 'Condition':
98
118
  """Create an IN condition
@@ -101,7 +121,9 @@ class Column:
101
121
  values: List/tuple of values, a Column, a Template (t-string), or a SelectQueryBuilder for subqueries
102
122
  """
103
123
  if isinstance(values, (list, tuple)):
104
- return Condition(self, 'IN', tuple(values))
124
+ # Process each value in the tuple/list
125
+ processed_values = tuple(self._process_value(v) for v in values)
126
+ return Condition(self, 'IN', processed_values)
105
127
  return Condition(self, 'IN', values)
106
128
 
107
129
  def not_in(self, values: Union[list, tuple, 'Column', Template, 'SelectQueryBuilder']) -> 'Condition':
@@ -111,24 +133,26 @@ class Column:
111
133
  values: List/tuple of values, a Column, a Template (t-string), or a SelectQueryBuilder for subqueries
112
134
  """
113
135
  if isinstance(values, (list, tuple)):
114
- return Condition(self, 'NOT IN', tuple(values))
136
+ # Process each value in the tuple/list
137
+ processed_values = tuple(self._process_value(v) for v in values)
138
+ return Condition(self, 'NOT IN', processed_values)
115
139
  return Condition(self, 'NOT IN', values)
116
140
 
117
141
  def like(self, pattern: str) -> 'Condition':
118
142
  """Create a LIKE condition"""
119
- return Condition(self, 'LIKE', pattern)
143
+ return Condition(self, 'LIKE', self._process_value(pattern))
120
144
 
121
145
  def not_like(self, pattern: str) -> 'Condition':
122
146
  """Create a NOT LIKE condition"""
123
- return Condition(self, 'NOT LIKE', pattern)
147
+ return Condition(self, 'NOT LIKE', self._process_value(pattern))
124
148
 
125
149
  def ilike(self, pattern: str) -> 'Condition':
126
150
  """Create an ILIKE condition (case-insensitive, PostgreSQL/SQLite only)"""
127
- return Condition(self, 'ILIKE', pattern)
151
+ return Condition(self, 'ILIKE', self._process_value(pattern))
128
152
 
129
153
  def not_ilike(self, pattern: str) -> 'Condition':
130
154
  """Create a NOT ILIKE condition (case-insensitive, PostgreSQL/SQLite only)"""
131
- return Condition(self, 'NOT ILIKE', pattern)
155
+ return Condition(self, 'NOT ILIKE', self._process_value(pattern))
132
156
 
133
157
  def between(self, start: Any, end: Any) -> 'Condition':
134
158
  """Create a BETWEEN condition
@@ -137,7 +161,7 @@ class Column:
137
161
  start: Lower bound value
138
162
  end: Upper bound value
139
163
  """
140
- return Condition(self, 'BETWEEN', (start, end))
164
+ return Condition(self, 'BETWEEN', (self._process_value(start), self._process_value(end)))
141
165
 
142
166
  def not_between(self, start: Any, end: Any) -> 'Condition':
143
167
  """Create a NOT BETWEEN condition
@@ -146,7 +170,7 @@ class Column:
146
170
  start: Lower bound value
147
171
  end: Upper bound value
148
172
  """
149
- return Condition(self, 'NOT BETWEEN', (start, end))
173
+ return Condition(self, 'NOT BETWEEN', (self._process_value(start), self._process_value(end)))
150
174
 
151
175
  def is_null(self) -> 'Condition':
152
176
  """Create an IS NULL condition"""
@@ -242,6 +266,7 @@ class Table:
242
266
 
243
267
  annotations = getattr(cls, '__annotations__', {})
244
268
  sa_columns = []
269
+ cls._type_processors = {}
245
270
 
246
271
  # Collect all potential column fields
247
272
  all_fields = {}
@@ -295,12 +320,19 @@ class Table:
295
320
  sa_col.name = field_name
296
321
  sa_columns.append(sa_col)
297
322
 
323
+ # Extract type processor if present
324
+ type_processor = getattr(field_value, '_tsql_type_processor', None)
325
+
298
326
  # Create query builder Column directly
299
- setattr(cls, field_name, Column(cls.table_name, field_name, schema=schema))
327
+ setattr(cls, field_name, Column(cls.table_name, field_name, schema=schema, type_processor=type_processor))
300
328
  # Update annotation to reflect the Column type
301
329
  if not hasattr(cls, '__annotations__'):
302
330
  cls.__annotations__ = {}
303
331
  cls.__annotations__[field_name] = Column
332
+
333
+ # Store type processor in mapping if present
334
+ if type_processor is not None:
335
+ cls._type_processors[field_name] = type_processor
304
336
  continue
305
337
 
306
338
  # Check if it's a Column instance (for column_name remapping)
@@ -311,13 +343,20 @@ class Table:
311
343
  # No column_name specified, use field_name
312
344
  db_column_name = field_name
313
345
 
346
+ # Extract type processor if present
347
+ type_processor = field_value.type_processor
348
+
314
349
  # Create query builder Column directly with the DB column name
315
- setattr(cls, field_name, Column(cls.table_name, db_column_name, schema=schema))
350
+ setattr(cls, field_name, Column(cls.table_name, db_column_name, schema=schema, type_processor=type_processor))
316
351
  # Update annotation to reflect the Column type
317
352
  if not hasattr(cls, '__annotations__'):
318
353
  cls.__annotations__ = {}
319
354
  cls.__annotations__[field_name] = Column
320
355
 
356
+ # Store type processor in mapping if present
357
+ if type_processor is not None:
358
+ cls._type_processors[field_name] = type_processor
359
+
321
360
  # Create SQLAlchemy column if metadata provided
322
361
  if metadata is not None and HAS_SQLALCHEMY:
323
362
  sa_type = PYTHON_TO_SA.get(field_type, String)()
@@ -520,6 +559,45 @@ class QueryBuilder(ABC):
520
559
  """
521
560
  return self.to_tsql().render(style)
522
561
 
562
+ def map_results(self, rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
563
+ """Transform database rows with type processors applied.
564
+
565
+ This method applies process_result_value from type processors to convert
566
+ database values back to Python values (e.g., decrypt encrypted fields,
567
+ deserialize JSON, etc.).
568
+
569
+ Preserves the original row object type when possible by updating values
570
+ in place rather than creating new dictionaries.
571
+
572
+ Args:
573
+ rows: List of dictionaries from database query results
574
+
575
+ Returns:
576
+ List of dictionaries with transformed values
577
+
578
+ Example:
579
+ query = User.select().where(User.id == 1)
580
+ rows = await conn.fetch(*query.render())
581
+ transformed_rows = query.map_results(rows) # ssn decrypted, metadata deserialized
582
+ """
583
+ if not hasattr(self, 'base_table'):
584
+ raise AttributeError("map_results requires a base_table attribute")
585
+
586
+ results = []
587
+ for row in rows:
588
+ # Convert row to dict if it's not already (some drivers return Record objects)
589
+ row_dict = dict(row) if not isinstance(row, dict) else row
590
+
591
+ # Apply type processors in place
592
+ for col_name in list(row_dict.keys()):
593
+ if col_name in self.base_table._type_processors:
594
+ processor = self.base_table._type_processors[col_name]
595
+ row_dict[col_name] = processor.process_result_value(row_dict[col_name])
596
+
597
+ results.append(row_dict)
598
+
599
+ return results
600
+
523
601
 
524
602
  class InsertBuilder(QueryBuilder):
525
603
  """Fluent interface for building INSERT queries"""
@@ -619,7 +697,15 @@ class InsertBuilder(QueryBuilder):
619
697
  table_name = f"{self.base_table.schema}.{self.base_table.table_name}"
620
698
  else:
621
699
  table_name = self.base_table.table_name
622
- values_dict = self.values
700
+
701
+ # Apply type processors to values
702
+ values_dict = {}
703
+ for col_name, value in self.values.items():
704
+ if col_name in self.base_table._type_processors:
705
+ processor = self.base_table._type_processors[col_name]
706
+ values_dict[col_name] = processor.process_bind_param(value)
707
+ else:
708
+ values_dict[col_name] = value
623
709
 
624
710
  # MySQL INSERT IGNORE
625
711
  if self._ignore:
@@ -651,8 +737,14 @@ class InsertBuilder(QueryBuilder):
651
737
 
652
738
  # Build UPDATE SET clause
653
739
  if self._update_cols:
654
- # User specified which columns to update
655
- update_dict = self._update_cols
740
+ # User specified which columns to update - apply type processors
741
+ update_dict = {}
742
+ for col_name, value in self._update_cols.items():
743
+ if col_name in self.base_table._type_processors:
744
+ processor = self.base_table._type_processors[col_name]
745
+ update_dict[col_name] = processor.process_bind_param(value)
746
+ else:
747
+ update_dict[col_name] = value
656
748
  parts.append(t'ON CONFLICT ({conflict_cols_str:unsafe}) DO UPDATE SET {update_dict:as_set}')
657
749
  else:
658
750
  # Default: update all non-conflict columns with EXCLUDED.*
@@ -673,7 +765,14 @@ class InsertBuilder(QueryBuilder):
673
765
  # MySQL ON DUPLICATE KEY UPDATE
674
766
  elif self._on_conflict_action == 'duplicate_key':
675
767
  if self._update_cols:
676
- update_dict = self._update_cols
768
+ # Apply type processors
769
+ update_dict = {}
770
+ for col_name, value in self._update_cols.items():
771
+ if col_name in self.base_table._type_processors:
772
+ processor = self.base_table._type_processors[col_name]
773
+ update_dict[col_name] = processor.process_bind_param(value)
774
+ else:
775
+ update_dict[col_name] = value
677
776
  parts.append(t'ON DUPLICATE KEY UPDATE {update_dict:as_set}')
678
777
  else:
679
778
  # Default: update all columns with alias.column (new MySQL syntax)
@@ -792,7 +891,16 @@ class UpdateBuilder(QueryBuilder):
792
891
  table_name = f"{self.base_table.schema}.{self.base_table.table_name}"
793
892
  else:
794
893
  table_name = self.base_table.table_name
795
- values_dict = self.values
894
+
895
+ # Apply type processors to values
896
+ values_dict = {}
897
+ for col_name, value in self.values.items():
898
+ if col_name in self.base_table._type_processors:
899
+ processor = self.base_table._type_processors[col_name]
900
+ values_dict[col_name] = processor.process_bind_param(value)
901
+ else:
902
+ values_dict[col_name] = value
903
+
796
904
  parts.append(t'UPDATE {table_name:literal} SET {values_dict:as_set}')
797
905
 
798
906
  if self._conditions:
@@ -1128,7 +1236,7 @@ if HAS_SQLALCHEMY:
1128
1236
 
1129
1237
 
1130
1238
  # Helper function for type checker compatibility with SQLAlchemy columns
1131
- def SAColumn(*args: Any, **kwargs: Any) -> Column: # noqa: N802
1239
+ def SAColumn(*args: Any, type_processor: Any = None, **kwargs: Any) -> Column: # noqa: N802
1132
1240
  """Wrapper for SQLAlchemy Column that satisfies type checkers.
1133
1241
 
1134
1242
  This function returns a SQLAlchemy Column at runtime but tells type checkers
@@ -1143,6 +1251,12 @@ def SAColumn(*args: Any, **kwargs: Any) -> Column: # noqa: N802
1143
1251
  id = SAColumn(Integer, primary_key=True) # Type checker sees: tsql Column
1144
1252
  name = SAColumn(String(100))
1145
1253
 
1254
+ # With type processor:
1255
+ from tsql.type_processor import TypeProcessor
1256
+
1257
+ class Users(Table):
1258
+ ssn = SAColumn(String(255), type_processor=EncryptedString(key=MY_KEY))
1259
+
1146
1260
  Note: This shadows the SQLAlchemy Column import. Import SA Column explicitly if needed:
1147
1261
  from sqlalchemy import Column as SA_Column
1148
1262
 
@@ -1153,4 +1267,7 @@ def SAColumn(*args: Any, **kwargs: Any) -> Column: # noqa: N802
1153
1267
  if not HAS_SQLALCHEMY:
1154
1268
  raise ImportError("SQLAlchemy is not installed. Cannot use SAColumn() helper.")
1155
1269
  from sqlalchemy import Column as SA_Column
1156
- return SA_Column(*args, **kwargs) # type: ignore[return-value]
1270
+ sa_col = SA_Column(*args, **kwargs)
1271
+ if type_processor is not None:
1272
+ sa_col._tsql_type_processor = type_processor # type: ignore[attr-defined]
1273
+ return sa_col # type: ignore[return-value]
@@ -0,0 +1,69 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import Any
3
+
4
+
5
+ class TypeProcessor(ABC):
6
+ """Base class for custom column type transformations.
7
+
8
+ Type processors enable automatic value transformation when reading from
9
+ and writing to the database, similar to SQLAlchemy's TypeDecorator.
10
+
11
+ Processors can be stateful and accept configuration in __init__:
12
+
13
+ class EncryptedString(TypeProcessor):
14
+ def __init__(self, key):
15
+ self.key = key
16
+
17
+ def process_bind_param(self, value):
18
+ return encrypt(value, self.key) if value is not None else None
19
+
20
+ def process_result_value(self, value):
21
+ return decrypt(value, self.key) if value is not None else None
22
+
23
+ Example usage with Table:
24
+
25
+ class User(Table, metadata=metadata):
26
+ id = SAColumn(Integer, primary_key=True)
27
+ ssn = SAColumn(String(255), type_processor=EncryptedString(key=MY_KEY))
28
+
29
+ # Automatic encryption on insert
30
+ User.insert(ssn="123-45-6789")
31
+
32
+ # Manual decryption on select
33
+ query = User.select().where(User.id == 1)
34
+ rows = await conn.fetch(*query.render())
35
+ users = query.map_results(rows) # ssn automatically decrypted
36
+ """
37
+
38
+ @abstractmethod
39
+ def process_bind_param(self, value: Any) -> Any:
40
+ """Transform Python value to database value.
41
+
42
+ Called when inserting, updating, or comparing values in WHERE clauses.
43
+ NULL values (None) are passed through - the processor decides how to handle them.
44
+
45
+ Args:
46
+ value: The Python value to transform
47
+
48
+ Returns:
49
+ The transformed value to send to the database
50
+ """
51
+ pass
52
+
53
+ @abstractmethod
54
+ def process_result_value(self, value: Any) -> Any:
55
+ """Transform database value to Python value.
56
+
57
+ Called when reading values from query results via map_results().
58
+ NULL values (None) are passed through - the processor decides how to handle them.
59
+
60
+ Args:
61
+ value: The database value to transform
62
+
63
+ Returns:
64
+ The transformed Python value
65
+ """
66
+ pass
67
+
68
+
69
+ __all__ = ['TypeProcessor']
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