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.
Files changed (34) hide show
  1. {t_sql-4.4.1 → t_sql-4.5.0}/PKG-INFO +1 -1
  2. {t_sql-4.4.1 → t_sql-4.5.0}/pyproject.toml +1 -1
  3. {t_sql-4.4.1 → t_sql-4.5.0}/tests/test_type_processor.py +62 -0
  4. {t_sql-4.4.1 → t_sql-4.5.0}/tsql/__init__.py +2 -0
  5. {t_sql-4.4.1 → t_sql-4.5.0}/tsql/query_builder.py +32 -14
  6. t_sql-4.5.0/tsql/row.py +39 -0
  7. {t_sql-4.4.1 → t_sql-4.5.0}/.dockerignore +0 -0
  8. {t_sql-4.4.1 → t_sql-4.5.0}/.github/workflows/publish.yml +0 -0
  9. {t_sql-4.4.1 → t_sql-4.5.0}/.github/workflows/test.yml +0 -0
  10. {t_sql-4.4.1 → t_sql-4.5.0}/.gitignore +0 -0
  11. {t_sql-4.4.1 → t_sql-4.5.0}/Dockerfile +0 -0
  12. {t_sql-4.4.1 → t_sql-4.5.0}/LICENSE +0 -0
  13. {t_sql-4.4.1 → t_sql-4.5.0}/README.md +0 -0
  14. {t_sql-4.4.1 → t_sql-4.5.0}/compose.yaml +0 -0
  15. {t_sql-4.4.1 → t_sql-4.5.0}/context7.json +0 -0
  16. {t_sql-4.4.1 → t_sql-4.5.0}/pytest.ini +0 -0
  17. {t_sql-4.4.1 → t_sql-4.5.0}/tests/test_alembic_integration.py +0 -0
  18. {t_sql-4.4.1 → t_sql-4.5.0}/tests/test_asyncpg_integration.py +0 -0
  19. {t_sql-4.4.1 → t_sql-4.5.0}/tests/test_different_object_types.py +0 -0
  20. {t_sql-4.4.1 → t_sql-4.5.0}/tests/test_escaped.py +0 -0
  21. {t_sql-4.4.1 → t_sql-4.5.0}/tests/test_escaped_binary_hex.py +0 -0
  22. {t_sql-4.4.1 → t_sql-4.5.0}/tests/test_helper_functions.py +0 -0
  23. {t_sql-4.4.1 → t_sql-4.5.0}/tests/test_injection_edge_cases.py +0 -0
  24. {t_sql-4.4.1 → t_sql-4.5.0}/tests/test_injection_protection_validation.py +0 -0
  25. {t_sql-4.4.1 → t_sql-4.5.0}/tests/test_injections_for_escaped.py +0 -0
  26. {t_sql-4.4.1 → t_sql-4.5.0}/tests/test_mysql_integration.py +0 -0
  27. {t_sql-4.4.1 → t_sql-4.5.0}/tests/test_parameter_names.py +0 -0
  28. {t_sql-4.4.1 → t_sql-4.5.0}/tests/test_query_builder.py +0 -0
  29. {t_sql-4.4.1 → t_sql-4.5.0}/tests/test_sqlalchemy_integration.py +0 -0
  30. {t_sql-4.4.1 → t_sql-4.5.0}/tests/test_sqlite_integration.py +0 -0
  31. {t_sql-4.4.1 → t_sql-4.5.0}/tests/test_styles.py +0 -0
  32. {t_sql-4.4.1 → t_sql-4.5.0}/tests/test_tsql.py +0 -0
  33. {t_sql-4.4.1 → t_sql-4.5.0}/tsql/styles.py +0 -0
  34. {t_sql-4.4.1 → t_sql-4.5.0}/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.1
3
+ Version: 4.5.0
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.1"
7
+ version = "4.5.0"
8
8
  description = "Safe SQL. SQL queries for python t-strings (PEP 750)"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.14"
@@ -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[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
- Preserves the original row object type when possible by updating values
570
- in place rather than creating new dictionaries.
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 dictionaries from database query results
574
+ rows: List of row objects from database query results
574
575
 
575
576
  Returns:
576
- List of dictionaries with transformed values
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
- transformed_rows = query.map_results(rows) # ssn decrypted, metadata deserialized
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
- # 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
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 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
-
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
@@ -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