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.
- {t_sql-4.3.0 → t_sql-4.4.1}/PKG-INFO +77 -1
- {t_sql-4.3.0 → t_sql-4.4.1}/README.md +76 -0
- {t_sql-4.3.0 → t_sql-4.4.1}/pyproject.toml +1 -1
- t_sql-4.4.1/tests/test_type_processor.py +260 -0
- {t_sql-4.3.0 → t_sql-4.4.1}/tsql/__init__.py +2 -0
- {t_sql-4.3.0 → t_sql-4.4.1}/tsql/query_builder.py +142 -25
- t_sql-4.4.1/tsql/type_processor.py +69 -0
- {t_sql-4.3.0 → t_sql-4.4.1}/.dockerignore +0 -0
- {t_sql-4.3.0 → t_sql-4.4.1}/.github/workflows/publish.yml +0 -0
- {t_sql-4.3.0 → t_sql-4.4.1}/.github/workflows/test.yml +0 -0
- {t_sql-4.3.0 → t_sql-4.4.1}/.gitignore +0 -0
- {t_sql-4.3.0 → t_sql-4.4.1}/Dockerfile +0 -0
- {t_sql-4.3.0 → t_sql-4.4.1}/LICENSE +0 -0
- {t_sql-4.3.0 → t_sql-4.4.1}/compose.yaml +0 -0
- {t_sql-4.3.0 → t_sql-4.4.1}/context7.json +0 -0
- {t_sql-4.3.0 → t_sql-4.4.1}/pytest.ini +0 -0
- {t_sql-4.3.0 → t_sql-4.4.1}/tests/test_alembic_integration.py +0 -0
- {t_sql-4.3.0 → t_sql-4.4.1}/tests/test_asyncpg_integration.py +0 -0
- {t_sql-4.3.0 → t_sql-4.4.1}/tests/test_different_object_types.py +0 -0
- {t_sql-4.3.0 → t_sql-4.4.1}/tests/test_escaped.py +0 -0
- {t_sql-4.3.0 → t_sql-4.4.1}/tests/test_escaped_binary_hex.py +0 -0
- {t_sql-4.3.0 → t_sql-4.4.1}/tests/test_helper_functions.py +0 -0
- {t_sql-4.3.0 → t_sql-4.4.1}/tests/test_injection_edge_cases.py +0 -0
- {t_sql-4.3.0 → t_sql-4.4.1}/tests/test_injection_protection_validation.py +0 -0
- {t_sql-4.3.0 → t_sql-4.4.1}/tests/test_injections_for_escaped.py +0 -0
- {t_sql-4.3.0 → t_sql-4.4.1}/tests/test_mysql_integration.py +0 -0
- {t_sql-4.3.0 → t_sql-4.4.1}/tests/test_parameter_names.py +0 -0
- {t_sql-4.3.0 → t_sql-4.4.1}/tests/test_query_builder.py +0 -0
- {t_sql-4.3.0 → t_sql-4.4.1}/tests/test_sqlalchemy_integration.py +0 -0
- {t_sql-4.3.0 → t_sql-4.4.1}/tests/test_sqlite_integration.py +0 -0
- {t_sql-4.3.0 → t_sql-4.4.1}/tests/test_styles.py +0 -0
- {t_sql-4.3.0 → t_sql-4.4.1}/tests/test_tsql.py +0 -0
- {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
|
+
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|