t-sql 4.4.1__tar.gz → 4.5.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {t_sql-4.4.1 → t_sql-4.5.0}/PKG-INFO +1 -1
- {t_sql-4.4.1 → t_sql-4.5.0}/pyproject.toml +1 -1
- {t_sql-4.4.1 → t_sql-4.5.0}/tests/test_type_processor.py +62 -0
- {t_sql-4.4.1 → t_sql-4.5.0}/tsql/__init__.py +2 -0
- {t_sql-4.4.1 → t_sql-4.5.0}/tsql/query_builder.py +32 -14
- t_sql-4.5.0/tsql/row.py +39 -0
- {t_sql-4.4.1 → t_sql-4.5.0}/.dockerignore +0 -0
- {t_sql-4.4.1 → t_sql-4.5.0}/.github/workflows/publish.yml +0 -0
- {t_sql-4.4.1 → t_sql-4.5.0}/.github/workflows/test.yml +0 -0
- {t_sql-4.4.1 → t_sql-4.5.0}/.gitignore +0 -0
- {t_sql-4.4.1 → t_sql-4.5.0}/Dockerfile +0 -0
- {t_sql-4.4.1 → t_sql-4.5.0}/LICENSE +0 -0
- {t_sql-4.4.1 → t_sql-4.5.0}/README.md +0 -0
- {t_sql-4.4.1 → t_sql-4.5.0}/compose.yaml +0 -0
- {t_sql-4.4.1 → t_sql-4.5.0}/context7.json +0 -0
- {t_sql-4.4.1 → t_sql-4.5.0}/pytest.ini +0 -0
- {t_sql-4.4.1 → t_sql-4.5.0}/tests/test_alembic_integration.py +0 -0
- {t_sql-4.4.1 → t_sql-4.5.0}/tests/test_asyncpg_integration.py +0 -0
- {t_sql-4.4.1 → t_sql-4.5.0}/tests/test_different_object_types.py +0 -0
- {t_sql-4.4.1 → t_sql-4.5.0}/tests/test_escaped.py +0 -0
- {t_sql-4.4.1 → t_sql-4.5.0}/tests/test_escaped_binary_hex.py +0 -0
- {t_sql-4.4.1 → t_sql-4.5.0}/tests/test_helper_functions.py +0 -0
- {t_sql-4.4.1 → t_sql-4.5.0}/tests/test_injection_edge_cases.py +0 -0
- {t_sql-4.4.1 → t_sql-4.5.0}/tests/test_injection_protection_validation.py +0 -0
- {t_sql-4.4.1 → t_sql-4.5.0}/tests/test_injections_for_escaped.py +0 -0
- {t_sql-4.4.1 → t_sql-4.5.0}/tests/test_mysql_integration.py +0 -0
- {t_sql-4.4.1 → t_sql-4.5.0}/tests/test_parameter_names.py +0 -0
- {t_sql-4.4.1 → t_sql-4.5.0}/tests/test_query_builder.py +0 -0
- {t_sql-4.4.1 → t_sql-4.5.0}/tests/test_sqlalchemy_integration.py +0 -0
- {t_sql-4.4.1 → t_sql-4.5.0}/tests/test_sqlite_integration.py +0 -0
- {t_sql-4.4.1 → t_sql-4.5.0}/tests/test_styles.py +0 -0
- {t_sql-4.4.1 → t_sql-4.5.0}/tests/test_tsql.py +0 -0
- {t_sql-4.4.1 → t_sql-4.5.0}/tsql/styles.py +0 -0
- {t_sql-4.4.1 → t_sql-4.5.0}/tsql/type_processor.py +0 -0
|
@@ -258,3 +258,65 @@ def test_type_processor_not_applied_to_column_comparisons():
|
|
|
258
258
|
# No parameters should be generated (column-to-column comparison)
|
|
259
259
|
assert len(params) == 0
|
|
260
260
|
assert "user.ssn = user.backup_ssn" in sql
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
@pytest.mark.skipif(not HAS_SQLALCHEMY, reason="SQLAlchemy not installed")
|
|
264
|
+
def test_map_results_with_joins_and_aliases():
|
|
265
|
+
"""Test that map_results handles JOINs and column aliases correctly"""
|
|
266
|
+
metadata = MetaData()
|
|
267
|
+
|
|
268
|
+
class User(Table, metadata=metadata):
|
|
269
|
+
id = SAColumn(Integer, primary_key=True)
|
|
270
|
+
name = SAColumn(String(100))
|
|
271
|
+
ssn = SAColumn(String(255), type_processor=EncryptedString(key="secret123"))
|
|
272
|
+
|
|
273
|
+
class Profile(Table, metadata=metadata):
|
|
274
|
+
id = SAColumn(Integer, primary_key=True)
|
|
275
|
+
user_id = SAColumn(Integer)
|
|
276
|
+
metadata_col = SAColumn(String(255), type_processor=JSONType())
|
|
277
|
+
|
|
278
|
+
# Test with explicit columns including alias
|
|
279
|
+
query = (
|
|
280
|
+
User.select(User.id, User.name, User.ssn.as_('social'), Profile.metadata_col)
|
|
281
|
+
.join(Profile, on=User.id == Profile.user_id)
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
# Simulate database rows (using the format that EncryptedString produces)
|
|
285
|
+
rows = [
|
|
286
|
+
{
|
|
287
|
+
"id": 1,
|
|
288
|
+
"name": "Alice",
|
|
289
|
+
"social": "encrypted_123-45-6789_secret123", # Aliased column
|
|
290
|
+
"metadata_col": '{"key": "value"}'
|
|
291
|
+
}
|
|
292
|
+
]
|
|
293
|
+
|
|
294
|
+
results = query.map_results(rows)
|
|
295
|
+
|
|
296
|
+
# Check aliased column was decrypted
|
|
297
|
+
assert results[0]["social"] == "123-45-6789"
|
|
298
|
+
assert results[0].social == "123-45-6789" # Test attribute access
|
|
299
|
+
# Check joined table column was deserialized
|
|
300
|
+
assert results[0]["metadata_col"] == {"key": "value"}
|
|
301
|
+
assert results[0].metadata_col == {"key": "value"} # Test attribute access
|
|
302
|
+
|
|
303
|
+
# Test with SELECT * (should process all columns from all tables)
|
|
304
|
+
query_star = User.select().join(Profile, on=User.id == Profile.user_id)
|
|
305
|
+
|
|
306
|
+
rows_star = [
|
|
307
|
+
{
|
|
308
|
+
"id": 1,
|
|
309
|
+
"name": "Alice",
|
|
310
|
+
"ssn": "encrypted_987-65-4321_secret123",
|
|
311
|
+
"user_id": 1,
|
|
312
|
+
"metadata_col": '{"foo": "bar"}'
|
|
313
|
+
}
|
|
314
|
+
]
|
|
315
|
+
|
|
316
|
+
results_star = query_star.map_results(rows_star)
|
|
317
|
+
|
|
318
|
+
# Both processors should be applied
|
|
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
|
|
@@ -362,6 +362,7 @@ def delete(table: str, id: str | int) -> TSQL:
|
|
|
362
362
|
|
|
363
363
|
from tsql.query_builder import UnsafeQueryError
|
|
364
364
|
from tsql.type_processor import TypeProcessor
|
|
365
|
+
from tsql.row import Row
|
|
365
366
|
|
|
366
367
|
__all__ = [
|
|
367
368
|
'TSQL',
|
|
@@ -375,5 +376,6 @@ __all__ = [
|
|
|
375
376
|
'set_style',
|
|
376
377
|
'UnsafeQueryError',
|
|
377
378
|
'TypeProcessor',
|
|
379
|
+
'Row',
|
|
378
380
|
]
|
|
379
381
|
|
|
@@ -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,41 +560,58 @@ class QueryBuilder(ABC):
|
|
|
559
560
|
"""
|
|
560
561
|
return self.to_tsql().render(style)
|
|
561
562
|
|
|
562
|
-
def map_results(self, rows: List[Dict[str, Any]]) -> List[
|
|
563
|
+
def map_results(self, rows: List[Dict[str, Any]]) -> List[Row]:
|
|
563
564
|
"""Transform database rows with type processors applied.
|
|
564
565
|
|
|
565
566
|
This method applies process_result_value from type processors to convert
|
|
566
567
|
database values back to Python values (e.g., decrypt encrypted fields,
|
|
567
568
|
deserialize JSON, etc.).
|
|
568
569
|
|
|
569
|
-
|
|
570
|
-
|
|
570
|
+
Returns Row objects (dict subclass with attribute access) for consistent
|
|
571
|
+
API regardless of input type (dict, asyncpg Record, etc.).
|
|
571
572
|
|
|
572
573
|
Args:
|
|
573
|
-
rows: List of
|
|
574
|
+
rows: List of row objects from database query results
|
|
574
575
|
|
|
575
576
|
Returns:
|
|
576
|
-
List of
|
|
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
|
-
|
|
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")
|
|
585
588
|
|
|
589
|
+
# Build a map of result_column_name -> processor
|
|
590
|
+
processors = {}
|
|
591
|
+
|
|
592
|
+
if hasattr(self, '_columns') and self._columns is not None:
|
|
593
|
+
# Explicit columns - we know exactly which ones and from which table
|
|
594
|
+
for col in self._columns:
|
|
595
|
+
if isinstance(col, Column) and col.type_processor:
|
|
596
|
+
# The result key is the alias if present, otherwise column_name
|
|
597
|
+
result_key = col.alias if col.alias else col.column_name
|
|
598
|
+
processors[result_key] = col.type_processor
|
|
599
|
+
else:
|
|
600
|
+
# SELECT * - check all tables involved (base table + joins)
|
|
601
|
+
tables = [self.base_table]
|
|
602
|
+
if hasattr(self, '_joins'):
|
|
603
|
+
tables.extend(join.table for join in self._joins)
|
|
604
|
+
|
|
605
|
+
for table in tables:
|
|
606
|
+
processors.update(table._type_processors)
|
|
607
|
+
|
|
608
|
+
# Convert to Row objects and apply processors
|
|
586
609
|
results = []
|
|
587
610
|
for row in rows:
|
|
588
|
-
|
|
589
|
-
row_dict = dict(row) if not isinstance(row, dict) else row
|
|
590
|
-
|
|
591
|
-
# Apply type processors in place
|
|
611
|
+
row_dict = Row(row) # Converts dict/Record to Row
|
|
592
612
|
for col_name in list(row_dict.keys()):
|
|
593
|
-
if col_name in
|
|
594
|
-
|
|
595
|
-
row_dict[col_name] = processor.process_result_value(row_dict[col_name])
|
|
596
|
-
|
|
613
|
+
if col_name in processors:
|
|
614
|
+
row_dict[col_name] = processors[col_name].process_result_value(row_dict[col_name])
|
|
597
615
|
results.append(row_dict)
|
|
598
616
|
|
|
599
617
|
return results
|
t_sql-4.5.0/tsql/row.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Row object - dict with attribute access support."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Row(dict):
|
|
5
|
+
"""A dict subclass that supports attribute-style access.
|
|
6
|
+
|
|
7
|
+
Provides a nicer API for accessing row data while maintaining
|
|
8
|
+
full dict compatibility.
|
|
9
|
+
|
|
10
|
+
Example:
|
|
11
|
+
row = Row({'id': 1, 'name': 'Alice'})
|
|
12
|
+
print(row.id) # 1 (attribute access)
|
|
13
|
+
print(row['name']) # Alice (dict access)
|
|
14
|
+
row.age = 30 # Set via attribute
|
|
15
|
+
print(row['age']) # 30
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __getattr__(self, key: str):
|
|
19
|
+
"""Allow attribute-style access to dict keys."""
|
|
20
|
+
try:
|
|
21
|
+
return self[key]
|
|
22
|
+
except KeyError:
|
|
23
|
+
raise AttributeError(f"Row has no attribute {key!r}") from None
|
|
24
|
+
|
|
25
|
+
def __setattr__(self, key: str, value):
|
|
26
|
+
"""Allow attribute-style setting of dict keys."""
|
|
27
|
+
self[key] = value
|
|
28
|
+
|
|
29
|
+
def __delattr__(self, key: str):
|
|
30
|
+
"""Allow attribute-style deletion of dict keys."""
|
|
31
|
+
try:
|
|
32
|
+
del self[key]
|
|
33
|
+
except KeyError:
|
|
34
|
+
raise AttributeError(f"Row has no attribute {key!r}") from None
|
|
35
|
+
|
|
36
|
+
def __repr__(self) -> str:
|
|
37
|
+
"""Nice repr for debugging."""
|
|
38
|
+
items = ', '.join(f'{k}={v!r}' for k, v in self.items())
|
|
39
|
+
return f"Row({items})"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|