t-sql 4.0.0__tar.gz → 4.2.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 (31) hide show
  1. {t_sql-4.0.0 → t_sql-4.2.0}/PKG-INFO +11 -20
  2. {t_sql-4.0.0 → t_sql-4.2.0}/README.md +10 -19
  3. {t_sql-4.0.0 → t_sql-4.2.0}/pyproject.toml +4 -1
  4. {t_sql-4.0.0 → t_sql-4.2.0}/tsql/query_builder.py +105 -63
  5. {t_sql-4.0.0 → t_sql-4.2.0}/.dockerignore +0 -0
  6. {t_sql-4.0.0 → t_sql-4.2.0}/.github/workflows/publish.yml +0 -0
  7. {t_sql-4.0.0 → t_sql-4.2.0}/.github/workflows/test.yml +0 -0
  8. {t_sql-4.0.0 → t_sql-4.2.0}/.gitignore +0 -0
  9. {t_sql-4.0.0 → t_sql-4.2.0}/Dockerfile +0 -0
  10. {t_sql-4.0.0 → t_sql-4.2.0}/LICENSE +0 -0
  11. {t_sql-4.0.0 → t_sql-4.2.0}/compose.yaml +0 -0
  12. {t_sql-4.0.0 → t_sql-4.2.0}/context7.json +0 -0
  13. {t_sql-4.0.0 → t_sql-4.2.0}/pytest.ini +0 -0
  14. {t_sql-4.0.0 → t_sql-4.2.0}/tests/test_alembic_integration.py +0 -0
  15. {t_sql-4.0.0 → t_sql-4.2.0}/tests/test_asyncpg_integration.py +0 -0
  16. {t_sql-4.0.0 → t_sql-4.2.0}/tests/test_different_object_types.py +0 -0
  17. {t_sql-4.0.0 → t_sql-4.2.0}/tests/test_escaped.py +0 -0
  18. {t_sql-4.0.0 → t_sql-4.2.0}/tests/test_escaped_binary_hex.py +0 -0
  19. {t_sql-4.0.0 → t_sql-4.2.0}/tests/test_helper_functions.py +0 -0
  20. {t_sql-4.0.0 → t_sql-4.2.0}/tests/test_injection_edge_cases.py +0 -0
  21. {t_sql-4.0.0 → t_sql-4.2.0}/tests/test_injection_protection_validation.py +0 -0
  22. {t_sql-4.0.0 → t_sql-4.2.0}/tests/test_injections_for_escaped.py +0 -0
  23. {t_sql-4.0.0 → t_sql-4.2.0}/tests/test_mysql_integration.py +0 -0
  24. {t_sql-4.0.0 → t_sql-4.2.0}/tests/test_parameter_names.py +0 -0
  25. {t_sql-4.0.0 → t_sql-4.2.0}/tests/test_query_builder.py +0 -0
  26. {t_sql-4.0.0 → t_sql-4.2.0}/tests/test_sqlalchemy_integration.py +0 -0
  27. {t_sql-4.0.0 → t_sql-4.2.0}/tests/test_sqlite_integration.py +0 -0
  28. {t_sql-4.0.0 → t_sql-4.2.0}/tests/test_styles.py +0 -0
  29. {t_sql-4.0.0 → t_sql-4.2.0}/tests/test_tsql.py +0 -0
  30. {t_sql-4.0.0 → t_sql-4.2.0}/tsql/__init__.py +0 -0
  31. {t_sql-4.0.0 → t_sql-4.2.0}/tsql/styles.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: t-sql
3
- Version: 4.0.0
3
+ Version: 4.2.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
@@ -495,7 +495,7 @@ uv add t-sql --optional sqlalchemy
495
495
  **1. Simple Column annotations** (for query builder only):
496
496
 
497
497
  ```python
498
- from tsql import Table, Column
498
+ from tsql.query_builder import Table, Column
499
499
 
500
500
  class Users(Table):
501
501
  id: Column
@@ -503,38 +503,29 @@ class Users(Table):
503
503
  age: Column
504
504
  ```
505
505
 
506
- **2. SQLAlchemy Column objects** (for alembic integration):
506
+ **2. SQLAlchemy with SAColumn wrapper** (recommended for type checkers):
507
507
 
508
508
  ```python
509
- from sqlalchemy import MetaData, Column, String, Integer, ForeignKey
510
- from tsql.query_builder import Table
509
+ from sqlalchemy import MetaData, Integer, String
510
+ from tsql.query_builder import Table, SAColumn
511
511
 
512
512
  metadata = MetaData()
513
513
 
514
514
  class Users(Table, metadata=metadata):
515
- id = Column(String, primary_key=True)
516
- email = Column(String(255), unique=True, nullable=False)
517
- name = Column(String(100))
518
- age = Column(Integer)
515
+ id = SAColumn(Integer, primary_key=True)
516
+ email = SAColumn(String(255), unique=True, nullable=False)
517
+ name = SAColumn(String(100))
518
+ age = SAColumn(Integer)
519
519
 
520
520
  # Use for alembic
521
521
  target_metadata = metadata
522
522
 
523
- # Use for queries (works identically!)
523
+ # Use for queries
524
524
  query = Users.select().where(Users.age > 18)
525
525
  ```
526
526
 
527
- You can mix both approaches:
528
-
529
- ```python
530
- from sqlalchemy import Column, String, DateTime
531
- from sqlalchemy.sql.functions import now
527
+ The `SAColumn` wrapper tells type checkers it returns a tsql `Column`, while at runtime it creates a SQLAlchemy `Column`. This gives you proper IDE completions for methods like `.is_null()`, `.like()`, etc.
532
528
 
533
- class Events(Table, metadata=metadata):
534
- id = Column(String, primary_key=True)
535
- topic: Column # Simple annotation - becomes nullable String column
536
- created_at = Column(DateTime, server_default=now())
537
- ```
538
529
 
539
530
  ## Schema Support
540
531
 
@@ -485,7 +485,7 @@ uv add t-sql --optional sqlalchemy
485
485
  **1. Simple Column annotations** (for query builder only):
486
486
 
487
487
  ```python
488
- from tsql import Table, Column
488
+ from tsql.query_builder import Table, Column
489
489
 
490
490
  class Users(Table):
491
491
  id: Column
@@ -493,38 +493,29 @@ class Users(Table):
493
493
  age: Column
494
494
  ```
495
495
 
496
- **2. SQLAlchemy Column objects** (for alembic integration):
496
+ **2. SQLAlchemy with SAColumn wrapper** (recommended for type checkers):
497
497
 
498
498
  ```python
499
- from sqlalchemy import MetaData, Column, String, Integer, ForeignKey
500
- from tsql.query_builder import Table
499
+ from sqlalchemy import MetaData, Integer, String
500
+ from tsql.query_builder import Table, SAColumn
501
501
 
502
502
  metadata = MetaData()
503
503
 
504
504
  class Users(Table, metadata=metadata):
505
- id = Column(String, primary_key=True)
506
- email = Column(String(255), unique=True, nullable=False)
507
- name = Column(String(100))
508
- age = Column(Integer)
505
+ id = SAColumn(Integer, primary_key=True)
506
+ email = SAColumn(String(255), unique=True, nullable=False)
507
+ name = SAColumn(String(100))
508
+ age = SAColumn(Integer)
509
509
 
510
510
  # Use for alembic
511
511
  target_metadata = metadata
512
512
 
513
- # Use for queries (works identically!)
513
+ # Use for queries
514
514
  query = Users.select().where(Users.age > 18)
515
515
  ```
516
516
 
517
- You can mix both approaches:
518
-
519
- ```python
520
- from sqlalchemy import Column, String, DateTime
521
- from sqlalchemy.sql.functions import now
517
+ The `SAColumn` wrapper tells type checkers it returns a tsql `Column`, while at runtime it creates a SQLAlchemy `Column`. This gives you proper IDE completions for methods like `.is_null()`, `.like()`, etc.
522
518
 
523
- class Events(Table, metadata=metadata):
524
- id = Column(String, primary_key=True)
525
- topic: Column # Simple annotation - becomes nullable String column
526
- created_at = Column(DateTime, server_default=now())
527
- ```
528
519
 
529
520
  ## Schema Support
530
521
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "t-sql"
7
- version = "4.0.0"
7
+ version = "4.2.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"
@@ -23,9 +23,12 @@ dev = [
23
23
  "anyio>=4.9.0",
24
24
  "asyncpg>=0.30.0",
25
25
  "cryptography>=46.0.2",
26
+ "mypy>=1.18.2",
27
+ "pyright>=1.1.406",
26
28
  "pytest>=8.3.5",
27
29
  "pytest-asyncio>=0.24.0",
28
30
  "sqlalchemy>=2.0.0",
31
+ "ty>=0.0.1a23",
29
32
  ]
30
33
 
31
34
  [tool.hatch.build.targets.wheel]
@@ -38,7 +38,7 @@ 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, column_name: str = None, alias: str = None, schema: str = None):
41
+ def __init__(self, table_name: str | None = None, column_name: str | None = None, alias: str | None = None, schema: str | None = None):
42
42
  self.table_name = table_name
43
43
  self.column_name = column_name
44
44
  self.alias = alias
@@ -194,13 +194,22 @@ class Table:
194
194
  class Users(Table, table_name='user_accounts'):
195
195
  id: Column
196
196
 
197
- For SQLAlchemy integration, use SQLAlchemy Column objects:
197
+ For SQLAlchemy integration, use the SAColumn wrapper for type checker compatibility:
198
198
 
199
- from sqlalchemy import Column, Integer, String
199
+ from sqlalchemy import Integer, String
200
+ from tsql.query_builder import Table, SAColumn
200
201
 
201
202
  class Users(Table, metadata=metadata, schema='public'):
202
- id = Column(Integer, primary_key=True)
203
- name = Column(String(100))
203
+ id = SAColumn(Integer, primary_key=True)
204
+ name = SAColumn(String(100))
205
+
206
+ Alternative: Use explicit type annotations with SQLAlchemy Column:
207
+
208
+ from sqlalchemy import Column as SACol
209
+
210
+ class Users(Table, metadata=metadata):
211
+ id: Column = SACol(Integer, primary_key=True)
212
+ name: Column = SACol(String(100))
204
213
  """
205
214
  table_name: ClassVar[str]
206
215
  schema: ClassVar[Optional[str]] = None
@@ -231,7 +240,7 @@ class Table:
231
240
  continue
232
241
  field_value = getattr(cls, field_name, None)
233
242
 
234
- # Check for Ellipsis syntax: id = ...
243
+ # Check for Ellipsis syntax: id = ...~
235
244
  if field_value is ...:
236
245
  if field_name not in all_fields:
237
246
  all_fields[field_name] = {
@@ -267,9 +276,9 @@ class Table:
267
276
  sa_col.name = field_name
268
277
  sa_columns.append(sa_col)
269
278
 
270
- # Create query builder ColumnDescriptor
271
- setattr(cls, field_name, ColumnDescriptor(field_name))
272
- # Update annotation to reflect the descriptor's return type
279
+ # Create query builder Column directly
280
+ setattr(cls, field_name, Column(cls.table_name, field_name, schema=schema))
281
+ # Update annotation to reflect the Column type
273
282
  if not hasattr(cls, '__annotations__'):
274
283
  cls.__annotations__ = {}
275
284
  cls.__annotations__[field_name] = Column
@@ -283,9 +292,9 @@ class Table:
283
292
  # No column_name specified, use field_name
284
293
  db_column_name = field_name
285
294
 
286
- # Create query builder ColumnDescriptor with the DB column name
287
- setattr(cls, field_name, ColumnDescriptor(db_column_name))
288
- # Update annotation to reflect the descriptor's return type
295
+ # Create query builder Column directly with the DB column name
296
+ setattr(cls, field_name, Column(cls.table_name, db_column_name, schema=schema))
297
+ # Update annotation to reflect the Column type
289
298
  if not hasattr(cls, '__annotations__'):
290
299
  cls.__annotations__ = {}
291
300
  cls.__annotations__[field_name] = Column
@@ -298,9 +307,9 @@ class Table:
298
307
 
299
308
  # Check if it's an Ellipsis (...) declaration
300
309
  if field_value is ...:
301
- # Create query builder ColumnDescriptor
302
- setattr(cls, field_name, ColumnDescriptor(field_name))
303
- # Update annotation to reflect the descriptor's return type
310
+ # Create query builder Column directly
311
+ setattr(cls, field_name, Column(cls.table_name, field_name, schema=schema))
312
+ # Update annotation to reflect the Column type
304
313
  if not hasattr(cls, '__annotations__'):
305
314
  cls.__annotations__ = {}
306
315
  cls.__annotations__[field_name] = Column
@@ -311,9 +320,9 @@ class Table:
311
320
  # No type annotation, Ellipsis, or SA Column - skip
312
321
  continue
313
322
 
314
- # Create query builder ColumnDescriptor for type-annotated fields
315
- setattr(cls, field_name, ColumnDescriptor(field_name))
316
- # Update annotation to reflect the descriptor's return type
323
+ # Create query builder Column directly for type-annotated fields
324
+ setattr(cls, field_name, Column(cls.table_name, field_name, schema=schema))
325
+ # Update annotation to reflect the Column type
317
326
  if not hasattr(cls, '__annotations__'):
318
327
  cls.__annotations__ = {}
319
328
  cls.__annotations__[field_name] = Column
@@ -327,8 +336,8 @@ class Table:
327
336
  if metadata is not None and HAS_SQLALCHEMY:
328
337
  cls._sa_table = SATable(cls.table_name, metadata, *sa_columns, schema=schema)
329
338
 
330
- # Add the ALL descriptor for wildcard column selection
331
- cls.ALL = AllColumnsDescriptor()
339
+ # Add the ALL column for wildcard column selection
340
+ cls.ALL = Column(cls.table_name, '*', schema=schema)
332
341
 
333
342
  @classmethod
334
343
  def select(cls, *columns: Union['Column', Template]) -> 'SelectQueryBuilder':
@@ -380,32 +389,6 @@ class Table:
380
389
  return DeleteBuilder(cls)
381
390
 
382
391
 
383
- class ColumnDescriptor:
384
- """Descriptor that creates Column objects when accessed on Table classes or instances"""
385
-
386
- def __init__(self, column_name: str):
387
- self.column_name = column_name
388
-
389
- def __set_name__(self, owner, name):
390
- self.column_name = name
391
-
392
- def __get__(self, obj, objtype=None) -> Column:
393
- if objtype is None:
394
- objtype = type(obj)
395
- schema = getattr(objtype, 'schema', None)
396
- return Column(objtype.table_name, self.column_name, schema=schema)
397
-
398
-
399
- class AllColumnsDescriptor:
400
- """Descriptor that creates a Column with wildcard (*) for selecting all columns from a table"""
401
-
402
- def __get__(self, obj, objtype=None) -> Column:
403
- if objtype is None:
404
- objtype = type(obj)
405
- schema = getattr(objtype, 'schema', None)
406
- return Column(objtype.table_name, '*', schema=schema)
407
-
408
-
409
392
  class Condition:
410
393
  """Represents a WHERE clause condition"""
411
394
 
@@ -469,7 +452,7 @@ class Condition:
469
452
  class Join:
470
453
  """Represents a JOIN clause"""
471
454
 
472
- def __init__(self, table: 'Table', condition: Condition, join_type: str = 'INNER'):
455
+ def __init__(self, table: type['Table'], condition: Condition, join_type: str = 'INNER'):
473
456
  self.table = table
474
457
  self.condition = condition
475
458
  self.join_type = join_type
@@ -512,7 +495,7 @@ class QueryBuilder(ABC):
512
495
  class InsertBuilder(QueryBuilder):
513
496
  """Fluent interface for building INSERT queries"""
514
497
 
515
- def __init__(self, base_table: 'Table', values: dict[str, Any]):
498
+ def __init__(self, base_table: type['Table'], values: dict[str, Any]):
516
499
  self.base_table = base_table
517
500
 
518
501
  # Apply defaults from SQLAlchemy columns if available
@@ -580,13 +563,23 @@ class InsertBuilder(QueryBuilder):
580
563
  self._update_cols = update
581
564
  return self
582
565
 
583
- def returning(self, *columns: str) -> 'InsertBuilder':
566
+ def returning(self, *columns: Union[str, Column]) -> 'InsertBuilder':
584
567
  """Add RETURNING clause (Postgres/SQLite only)
585
568
 
586
569
  Args:
587
- columns: Column names to return, or none for RETURNING *
570
+ columns: Column names (strings) or Column objects to return, or none for RETURNING *
588
571
  """
589
- self._returning_cols = list(columns) if columns else ['*']
572
+ if columns:
573
+ # Convert Column objects to their column names
574
+ col_names = []
575
+ for col in columns:
576
+ if isinstance(col, Column):
577
+ col_names.append(col.column_name)
578
+ else:
579
+ col_names.append(col)
580
+ self._returning_cols = col_names
581
+ else:
582
+ self._returning_cols = ['*']
590
583
  return self
591
584
 
592
585
  def to_tsql(self) -> TSQL:
@@ -694,7 +687,7 @@ class InsertBuilder(QueryBuilder):
694
687
  class UpdateBuilder(QueryBuilder):
695
688
  """Fluent interface for building UPDATE queries"""
696
689
 
697
- def __init__(self, base_table: 'Table', values: dict[str, Any]):
690
+ def __init__(self, base_table: type['Table'], values: dict[str, Any]):
698
691
  self.base_table = base_table
699
692
 
700
693
  # Apply onupdate defaults from SQLAlchemy columns if available
@@ -743,13 +736,23 @@ class UpdateBuilder(QueryBuilder):
743
736
  self._requires_where = False
744
737
  return self
745
738
 
746
- def returning(self, *columns: str) -> 'UpdateBuilder':
739
+ def returning(self, *columns: Union[str, Column]) -> 'UpdateBuilder':
747
740
  """Add RETURNING clause (Postgres/SQLite only)
748
741
 
749
742
  Args:
750
- columns: Column names to return, or none for RETURNING *
743
+ columns: Column names (strings) or Column objects to return, or none for RETURNING *
751
744
  """
752
- self._returning_cols = list(columns) if columns else ['*']
745
+ if columns:
746
+ # Convert Column objects to their column names
747
+ col_names = []
748
+ for col in columns:
749
+ if isinstance(col, Column):
750
+ col_names.append(col.column_name)
751
+ else:
752
+ col_names.append(col)
753
+ self._returning_cols = col_names
754
+ else:
755
+ self._returning_cols = ['*']
753
756
  return self
754
757
 
755
758
  def to_tsql(self) -> TSQL:
@@ -807,7 +810,7 @@ class UpdateBuilder(QueryBuilder):
807
810
  class DeleteBuilder(QueryBuilder):
808
811
  """Fluent interface for building DELETE queries"""
809
812
 
810
- def __init__(self, base_table: 'Table'):
813
+ def __init__(self, base_table: type['Table']):
811
814
  self.base_table = base_table
812
815
  self._conditions: List[Union[Condition, Template]] = []
813
816
  self._returning_cols: Optional[List[str]] = None
@@ -834,13 +837,23 @@ class DeleteBuilder(QueryBuilder):
834
837
  self._requires_where = False
835
838
  return self
836
839
 
837
- def returning(self, *columns: str) -> 'DeleteBuilder':
840
+ def returning(self, *columns: Union[str, Column]) -> 'DeleteBuilder':
838
841
  """Add RETURNING clause (Postgres/SQLite only)
839
842
 
840
843
  Args:
841
- columns: Column names to return, or none for RETURNING *
844
+ columns: Column names (strings) or Column objects to return, or none for RETURNING *
842
845
  """
843
- self._returning_cols = list(columns) if columns else ['*']
846
+ if columns:
847
+ # Convert Column objects to their column names
848
+ col_names = []
849
+ for col in columns:
850
+ if isinstance(col, Column):
851
+ col_names.append(col.column_name)
852
+ else:
853
+ col_names.append(col)
854
+ self._returning_cols = col_names
855
+ else:
856
+ self._returning_cols = ['*']
844
857
  return self
845
858
 
846
859
  def to_tsql(self) -> TSQL:
@@ -897,7 +910,7 @@ class DeleteBuilder(QueryBuilder):
897
910
  class SelectQueryBuilder(QueryBuilder):
898
911
  """Fluent interface for building SQL SELECT queries"""
899
912
 
900
- def __init__(self, base_table: 'Table'):
913
+ def __init__(self, base_table: type['Table']):
901
914
  self.base_table = base_table
902
915
  self._columns: Optional[List[Column]] = None
903
916
  self._conditions: List[Condition] = []
@@ -935,16 +948,16 @@ class SelectQueryBuilder(QueryBuilder):
935
948
  self._conditions.append(condition)
936
949
  return self
937
950
 
938
- def join(self, table: 'Table', on: Condition, join_type: str = 'INNER') -> 'SelectQueryBuilder':
951
+ def join(self, table: type['Table'], on: Condition, join_type: str = 'INNER') -> 'SelectQueryBuilder':
939
952
  """Add a JOIN clause"""
940
953
  self._joins.append(Join(table, on, join_type))
941
954
  return self
942
955
 
943
- def left_join(self, table: 'Table', on: Condition) -> 'SelectQueryBuilder':
956
+ def left_join(self, table: type['Table'], on: Condition) -> 'SelectQueryBuilder':
944
957
  """Add a LEFT JOIN clause"""
945
958
  return self.join(table, on, 'LEFT')
946
959
 
947
- def right_join(self, table: 'Table', on: Condition) -> 'SelectQueryBuilder':
960
+ def right_join(self, table: type['Table'], on: Condition) -> 'SelectQueryBuilder':
948
961
  """Add a RIGHT JOIN clause"""
949
962
  return self.join(table, on, 'RIGHT')
950
963
 
@@ -1083,3 +1096,32 @@ if HAS_SQLALCHEMY:
1083
1096
  datetime: DateTime,
1084
1097
  float: Float,
1085
1098
  }
1099
+
1100
+
1101
+ # Helper function for type checker compatibility with SQLAlchemy columns
1102
+ def SAColumn(*args: Any, **kwargs: Any) -> Column: # noqa: N802
1103
+ """Wrapper for SQLAlchemy Column that satisfies type checkers.
1104
+
1105
+ This function returns a SQLAlchemy Column at runtime but tells type checkers
1106
+ it returns a tsql Column. This allows you to use SQLAlchemy columns without
1107
+ explicit type annotations while still getting proper IDE completions.
1108
+
1109
+ Usage:
1110
+ from tsql.query_builder import Table, SAColumn
1111
+ from sqlalchemy import Integer, String
1112
+
1113
+ class Users(Table):
1114
+ id = SAColumn(Integer, primary_key=True) # Type checker sees: tsql Column
1115
+ name = SAColumn(String(100))
1116
+
1117
+ Note: This shadows the SQLAlchemy Column import. Import SA Column explicitly if needed:
1118
+ from sqlalchemy import Column as SA_Column
1119
+
1120
+ Alternative: Use explicit type annotations:
1121
+ from sqlalchemy import Column as SACol
1122
+ id: Column = SACol(Integer, primary_key=True)
1123
+ """
1124
+ if not HAS_SQLALCHEMY:
1125
+ raise ImportError("SQLAlchemy is not installed. Cannot use SAColumn() helper.")
1126
+ from sqlalchemy import Column as SA_Column
1127
+ return SA_Column(*args, **kwargs) # type: ignore[return-value]
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