t-sql 3.2.0__tar.gz → 4.1.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-3.2.0 → t_sql-4.1.0}/PKG-INFO +46 -20
  2. {t_sql-3.2.0 → t_sql-4.1.0}/README.md +45 -19
  3. {t_sql-3.2.0 → t_sql-4.1.0}/pyproject.toml +4 -1
  4. {t_sql-3.2.0 → t_sql-4.1.0}/tests/test_query_builder.py +67 -9
  5. {t_sql-3.2.0 → t_sql-4.1.0}/tests/test_sqlalchemy_integration.py +32 -0
  6. {t_sql-3.2.0 → t_sql-4.1.0}/tsql/query_builder.py +71 -34
  7. {t_sql-3.2.0 → t_sql-4.1.0}/.dockerignore +0 -0
  8. {t_sql-3.2.0 → t_sql-4.1.0}/.github/workflows/publish.yml +0 -0
  9. {t_sql-3.2.0 → t_sql-4.1.0}/.github/workflows/test.yml +0 -0
  10. {t_sql-3.2.0 → t_sql-4.1.0}/.gitignore +0 -0
  11. {t_sql-3.2.0 → t_sql-4.1.0}/Dockerfile +0 -0
  12. {t_sql-3.2.0 → t_sql-4.1.0}/LICENSE +0 -0
  13. {t_sql-3.2.0 → t_sql-4.1.0}/compose.yaml +0 -0
  14. {t_sql-3.2.0 → t_sql-4.1.0}/context7.json +0 -0
  15. {t_sql-3.2.0 → t_sql-4.1.0}/pytest.ini +0 -0
  16. {t_sql-3.2.0 → t_sql-4.1.0}/tests/test_alembic_integration.py +0 -0
  17. {t_sql-3.2.0 → t_sql-4.1.0}/tests/test_asyncpg_integration.py +0 -0
  18. {t_sql-3.2.0 → t_sql-4.1.0}/tests/test_different_object_types.py +0 -0
  19. {t_sql-3.2.0 → t_sql-4.1.0}/tests/test_escaped.py +0 -0
  20. {t_sql-3.2.0 → t_sql-4.1.0}/tests/test_escaped_binary_hex.py +0 -0
  21. {t_sql-3.2.0 → t_sql-4.1.0}/tests/test_helper_functions.py +0 -0
  22. {t_sql-3.2.0 → t_sql-4.1.0}/tests/test_injection_edge_cases.py +0 -0
  23. {t_sql-3.2.0 → t_sql-4.1.0}/tests/test_injection_protection_validation.py +0 -0
  24. {t_sql-3.2.0 → t_sql-4.1.0}/tests/test_injections_for_escaped.py +0 -0
  25. {t_sql-3.2.0 → t_sql-4.1.0}/tests/test_mysql_integration.py +0 -0
  26. {t_sql-3.2.0 → t_sql-4.1.0}/tests/test_parameter_names.py +0 -0
  27. {t_sql-3.2.0 → t_sql-4.1.0}/tests/test_sqlite_integration.py +0 -0
  28. {t_sql-3.2.0 → t_sql-4.1.0}/tests/test_styles.py +0 -0
  29. {t_sql-3.2.0 → t_sql-4.1.0}/tests/test_tsql.py +0 -0
  30. {t_sql-3.2.0 → t_sql-4.1.0}/tsql/__init__.py +0 -0
  31. {t_sql-3.2.0 → t_sql-4.1.0}/tsql/styles.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: t-sql
3
- Version: 3.2.0
3
+ Version: 4.1.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
@@ -257,12 +257,47 @@ query = (Posts.select()
257
257
 
258
258
  ## Query Features
259
259
 
260
+ ### Selecting All Columns from a Table
261
+
262
+ Use `Table.ALL` to select all columns from a specific table:
263
+
260
264
  ```python
265
+ # Select all columns from posts
266
+ query = Posts.select(Posts.ALL)
267
+ # ('SELECT posts.* FROM posts', [])
268
+
269
+ # Select all columns from posts + specific columns from joined tables
270
+ query = (Posts.select(Posts.ALL, Users.username, Users.email)
271
+ .join(Users, Posts.user_id == Users.id))
272
+ # ('SELECT posts.*, users.username, users.email FROM posts INNER JOIN users ON ...', [])
273
+
274
+ # Select all columns from multiple tables
275
+ query = Posts.select(Posts.ALL, Users.ALL).join(Users, Posts.user_id == Users.id)
276
+ # ('SELECT posts.*, users.* FROM posts INNER JOIN users ON ...', [])
277
+ ```
278
+
279
+ This is particularly useful when joining tables where you want all columns from one table but only specific columns from others.
280
+
281
+ ### NULL Checks and Other Operators
282
+
283
+ ```python
284
+ # NULL checks
285
+ query = Users.select().where(Users.email.is_null())
286
+ query = Users.select().where(Users.email.is_not_null())
287
+
261
288
  # IN clause
262
289
  query = Users.select().where(Users.id.in_([1, 2, 3]))
290
+ query = Users.select().where(Users.id.not_in([1, 2, 3]))
263
291
 
264
292
  # LIKE clause
265
293
  query = Users.select().where(Users.username.like('%john%'))
294
+ query = Users.select().where(Users.username.not_like('%john%'))
295
+ query = Users.select().where(Users.username.ilike('%JOHN%')) # case-insensitive
296
+ query = Users.select().where(Users.username.not_ilike('%JOHN%'))
297
+
298
+ # BETWEEN clause
299
+ query = Users.select().where(Users.age.between(18, 65))
300
+ query = Users.select().where(Users.age.not_between(18, 65))
266
301
 
267
302
  # ORDER BY
268
303
  query = Posts.select().order_by(Posts.id) # defaults to ASC
@@ -460,7 +495,7 @@ uv add t-sql --optional sqlalchemy
460
495
  **1. Simple Column annotations** (for query builder only):
461
496
 
462
497
  ```python
463
- from tsql import Table, Column
498
+ from tsql.query_builder import Table, Column
464
499
 
465
500
  class Users(Table):
466
501
  id: Column
@@ -468,38 +503,29 @@ class Users(Table):
468
503
  age: Column
469
504
  ```
470
505
 
471
- **2. SQLAlchemy Column objects** (for alembic integration):
506
+ **2. SQLAlchemy with SAColumn wrapper** (recommended for type checkers):
472
507
 
473
508
  ```python
474
- from sqlalchemy import MetaData, Column, String, Integer, ForeignKey
475
- from tsql.query_builder import Table
509
+ from sqlalchemy import MetaData, Integer, String
510
+ from tsql.query_builder import Table, SAColumn
476
511
 
477
512
  metadata = MetaData()
478
513
 
479
514
  class Users(Table, metadata=metadata):
480
- id = Column(String, primary_key=True)
481
- email = Column(String(255), unique=True, nullable=False)
482
- name = Column(String(100))
483
- 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)
484
519
 
485
520
  # Use for alembic
486
521
  target_metadata = metadata
487
522
 
488
- # Use for queries (works identically!)
523
+ # Use for queries
489
524
  query = Users.select().where(Users.age > 18)
490
525
  ```
491
526
 
492
- You can mix both approaches:
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.
493
528
 
494
- ```python
495
- from sqlalchemy import Column, String, DateTime
496
- from sqlalchemy.sql.functions import now
497
-
498
- class Events(Table, metadata=metadata):
499
- id = Column(String, primary_key=True)
500
- topic: Column # Simple annotation - becomes nullable String column
501
- created_at = Column(DateTime, server_default=now())
502
- ```
503
529
 
504
530
  ## Schema Support
505
531
 
@@ -247,12 +247,47 @@ query = (Posts.select()
247
247
 
248
248
  ## Query Features
249
249
 
250
+ ### Selecting All Columns from a Table
251
+
252
+ Use `Table.ALL` to select all columns from a specific table:
253
+
250
254
  ```python
255
+ # Select all columns from posts
256
+ query = Posts.select(Posts.ALL)
257
+ # ('SELECT posts.* FROM posts', [])
258
+
259
+ # Select all columns from posts + specific columns from joined tables
260
+ query = (Posts.select(Posts.ALL, Users.username, Users.email)
261
+ .join(Users, Posts.user_id == Users.id))
262
+ # ('SELECT posts.*, users.username, users.email FROM posts INNER JOIN users ON ...', [])
263
+
264
+ # Select all columns from multiple tables
265
+ query = Posts.select(Posts.ALL, Users.ALL).join(Users, Posts.user_id == Users.id)
266
+ # ('SELECT posts.*, users.* FROM posts INNER JOIN users ON ...', [])
267
+ ```
268
+
269
+ This is particularly useful when joining tables where you want all columns from one table but only specific columns from others.
270
+
271
+ ### NULL Checks and Other Operators
272
+
273
+ ```python
274
+ # NULL checks
275
+ query = Users.select().where(Users.email.is_null())
276
+ query = Users.select().where(Users.email.is_not_null())
277
+
251
278
  # IN clause
252
279
  query = Users.select().where(Users.id.in_([1, 2, 3]))
280
+ query = Users.select().where(Users.id.not_in([1, 2, 3]))
253
281
 
254
282
  # LIKE clause
255
283
  query = Users.select().where(Users.username.like('%john%'))
284
+ query = Users.select().where(Users.username.not_like('%john%'))
285
+ query = Users.select().where(Users.username.ilike('%JOHN%')) # case-insensitive
286
+ query = Users.select().where(Users.username.not_ilike('%JOHN%'))
287
+
288
+ # BETWEEN clause
289
+ query = Users.select().where(Users.age.between(18, 65))
290
+ query = Users.select().where(Users.age.not_between(18, 65))
256
291
 
257
292
  # ORDER BY
258
293
  query = Posts.select().order_by(Posts.id) # defaults to ASC
@@ -450,7 +485,7 @@ uv add t-sql --optional sqlalchemy
450
485
  **1. Simple Column annotations** (for query builder only):
451
486
 
452
487
  ```python
453
- from tsql import Table, Column
488
+ from tsql.query_builder import Table, Column
454
489
 
455
490
  class Users(Table):
456
491
  id: Column
@@ -458,38 +493,29 @@ class Users(Table):
458
493
  age: Column
459
494
  ```
460
495
 
461
- **2. SQLAlchemy Column objects** (for alembic integration):
496
+ **2. SQLAlchemy with SAColumn wrapper** (recommended for type checkers):
462
497
 
463
498
  ```python
464
- from sqlalchemy import MetaData, Column, String, Integer, ForeignKey
465
- from tsql.query_builder import Table
499
+ from sqlalchemy import MetaData, Integer, String
500
+ from tsql.query_builder import Table, SAColumn
466
501
 
467
502
  metadata = MetaData()
468
503
 
469
504
  class Users(Table, metadata=metadata):
470
- id = Column(String, primary_key=True)
471
- email = Column(String(255), unique=True, nullable=False)
472
- name = Column(String(100))
473
- 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)
474
509
 
475
510
  # Use for alembic
476
511
  target_metadata = metadata
477
512
 
478
- # Use for queries (works identically!)
513
+ # Use for queries
479
514
  query = Users.select().where(Users.age > 18)
480
515
  ```
481
516
 
482
- You can mix both approaches:
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.
483
518
 
484
- ```python
485
- from sqlalchemy import Column, String, DateTime
486
- from sqlalchemy.sql.functions import now
487
-
488
- class Events(Table, metadata=metadata):
489
- id = Column(String, primary_key=True)
490
- topic: Column # Simple annotation - becomes nullable String column
491
- created_at = Column(DateTime, server_default=now())
492
- ```
493
519
 
494
520
  ## Schema Support
495
521
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "t-sql"
7
- version = "3.2.0"
7
+ version = "4.1.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]
@@ -838,23 +838,81 @@ def test_tstring_with_params_in_select():
838
838
  assert params == [' - ', 5]
839
839
 
840
840
 
841
- def test_column_is_null_property():
842
- """Test is_null property"""
843
- condition = Users.email.is_null
841
+ def test_select_table_all():
842
+ """Test selecting all columns from a table using Table.ALL"""
843
+ query = Posts.select(Posts.ALL)
844
+ sql, params = query.render()
845
+
846
+ assert 'SELECT posts.*' in sql
847
+ assert 'FROM posts' in sql
848
+ assert params == []
849
+
850
+
851
+ def test_select_table_all_with_other_columns():
852
+ """Test mixing Table.ALL with specific columns from another table"""
853
+ query = Posts.select(Posts.ALL, Users.username, Users.email)
854
+ sql, params = query.render()
855
+
856
+ assert 'SELECT posts.*, users.username, users.email' in sql
857
+ assert 'FROM posts' in sql
858
+ assert params == []
859
+
860
+
861
+ def test_select_multiple_table_alls():
862
+ """Test selecting all columns from multiple tables"""
863
+ query = Posts.select(Posts.ALL, Users.ALL)
864
+ sql, params = query.render()
865
+
866
+ assert 'SELECT posts.*, users.*' in sql
867
+ assert 'FROM posts' in sql
868
+ assert params == []
869
+
870
+
871
+ def test_select_table_all_with_join():
872
+ """Test Table.ALL in a real-world join scenario"""
873
+ query = (Posts.select(Posts.ALL, Users.username)
874
+ .join(Users, Posts.user_id == Users.id)
875
+ .where(Posts.id > 100))
876
+ sql, params = query.render()
877
+
878
+ assert 'SELECT posts.*, users.username' in sql
879
+ assert 'FROM posts' in sql
880
+ assert 'INNER JOIN users ON posts.user_id = users.id' in sql
881
+ assert 'WHERE posts.id > ?' in sql
882
+ assert params == [100]
883
+
884
+
885
+ def test_select_table_all_with_schema():
886
+ """Test that Table.ALL works with schema-qualified tables"""
887
+ class Accounts(Table, table_name='accounts', schema='other'):
888
+ id: Column
889
+ name: Column
890
+
891
+ query = Accounts.select(Accounts.ALL)
892
+ sql, params = query.render()
893
+
894
+ assert 'SELECT other.accounts.*' in sql
895
+ assert 'FROM other.accounts' in sql
896
+ assert params == []
897
+
898
+
899
+ def test_column_is_null_method():
900
+ """Test is_null method"""
901
+ condition = Users.email.is_null()
844
902
  assert condition.operator == 'IS'
845
903
  assert condition.right is None
846
904
 
847
905
 
848
- def test_column_is_not_null_property():
849
- """Test is_not_null property"""
850
- condition = Users.email.is_not_null
906
+ def test_column_is_not_null_method():
907
+ """Test is_not_null method"""
908
+ condition = Users.email.is_not_null()
851
909
  assert condition.operator == 'IS NOT'
852
910
  assert condition.right is None
853
911
 
854
912
 
855
913
  def test_where_with_is_null():
856
914
  """Test WHERE with is_null"""
857
- query = Users.select().where(Users.email.is_null)
915
+ query = Users.select().where(Users.email.is_null())
858
916
  sql, params = query.render()
859
917
 
860
918
  assert 'WHERE users.email IS NULL' in sql
@@ -863,7 +921,7 @@ def test_where_with_is_null():
863
921
 
864
922
  def test_where_with_is_not_null():
865
923
  """Test WHERE with is_not_null"""
866
- query = Users.select().where(Users.email.is_not_null)
924
+ query = Users.select().where(Users.email.is_not_null())
867
925
  sql, params = query.render()
868
926
 
869
927
  assert 'WHERE users.email IS NOT NULL' in sql
@@ -1002,7 +1060,7 @@ def test_complex_query_with_new_operators():
1002
1060
  """Test complex query using multiple new operators"""
1003
1061
  query = (Users.select()
1004
1062
  .where(Users.id.between(1, 100))
1005
- .where(Users.email.is_not_null)
1063
+ .where(Users.email.is_not_null())
1006
1064
  .where(Users.id.not_in([5, 10, 15]))
1007
1065
  .where(Users.username.ilike('%test%')))
1008
1066
  sql, params = query.render()
@@ -229,6 +229,38 @@ def test_mixing_query_builder_with_tsql():
229
229
  assert params == [18, 'john', 'john', 25]
230
230
 
231
231
 
232
+ def test_sa_column_annotations_are_correct_type():
233
+ """Test that SA Column assignments get correct type annotations for IDE autocomplete"""
234
+ from tsql.query_builder import Column as TsqlColumn
235
+
236
+ metadata = MetaData()
237
+
238
+ class MyTable(Table, table_name='mytable', metadata=metadata):
239
+ my_column = Column(TIMESTAMP())
240
+ another = Column(Integer())
241
+ text_field = Column(String(100))
242
+
243
+ # Verify that __annotations__ has been updated to reflect tsql.Column
244
+ assert 'my_column' in MyTable.__annotations__
245
+ assert 'another' in MyTable.__annotations__
246
+ assert 'text_field' in MyTable.__annotations__
247
+
248
+ assert MyTable.__annotations__['my_column'] == TsqlColumn
249
+ assert MyTable.__annotations__['another'] == TsqlColumn
250
+ assert MyTable.__annotations__['text_field'] == TsqlColumn
251
+
252
+ # Verify that the columns actually work as tsql.Column objects
253
+ col = MyTable.my_column
254
+ assert isinstance(col, TsqlColumn)
255
+ assert hasattr(col, 'is_null')
256
+ assert hasattr(col, 'asc')
257
+ assert hasattr(col, 'desc')
258
+
259
+ # Verify is_null works
260
+ condition = col.is_null()
261
+ assert condition.operator == 'IS'
262
+ assert condition.right is None
263
+
232
264
  def gen_id(prefix):
233
265
  """Dummy function for test"""
234
266
  return f"{prefix}_123"
@@ -38,10 +38,9 @@ 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, python_type: type = 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
- self.python_type = python_type
45
44
  self.alias = alias
46
45
  self.schema = schema
47
46
 
@@ -71,7 +70,7 @@ class Column:
71
70
  Example:
72
71
  users.select(users.first_name.as_('first'), users.last_name.as_('last'))
73
72
  """
74
- return Column(self.table_name, self.column_name, self.python_type, alias, self.schema)
73
+ return Column(self.table_name, self.column_name, alias, self.schema)
75
74
 
76
75
  def __eq__(self, other) -> 'Condition':
77
76
  if other is None:
@@ -149,12 +148,10 @@ class Column:
149
148
  """
150
149
  return Condition(self, 'NOT BETWEEN', (start, end))
151
150
 
152
- @property
153
151
  def is_null(self) -> 'Condition':
154
152
  """Create an IS NULL condition"""
155
153
  return Condition(self, 'IS', None)
156
154
 
157
- @property
158
155
  def is_not_null(self) -> 'Condition':
159
156
  """Create an IS NOT NULL condition"""
160
157
  return Condition(self, 'IS NOT', None)
@@ -197,13 +194,22 @@ class Table:
197
194
  class Users(Table, table_name='user_accounts'):
198
195
  id: Column
199
196
 
200
- For SQLAlchemy integration, use SQLAlchemy Column objects:
197
+ For SQLAlchemy integration, use the SAColumn wrapper for type checker compatibility:
201
198
 
202
- from sqlalchemy import Column, Integer, String
199
+ from sqlalchemy import Integer, String
200
+ from tsql.query_builder import Table, SAColumn
203
201
 
204
202
  class Users(Table, metadata=metadata, schema='public'):
205
- id = Column(Integer, primary_key=True)
206
- 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))
207
213
  """
208
214
  table_name: ClassVar[str]
209
215
  schema: ClassVar[Optional[str]] = None
@@ -270,8 +276,12 @@ class Table:
270
276
  sa_col.name = field_name
271
277
  sa_columns.append(sa_col)
272
278
 
273
- # Create query builder ColumnDescriptor
274
- setattr(cls, field_name, ColumnDescriptor(field_name, field_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
282
+ if not hasattr(cls, '__annotations__'):
283
+ cls.__annotations__ = {}
284
+ cls.__annotations__[field_name] = Column
275
285
  continue
276
286
 
277
287
  # Check if it's a Column instance (for column_name remapping)
@@ -282,8 +292,12 @@ class Table:
282
292
  # No column_name specified, use field_name
283
293
  db_column_name = field_name
284
294
 
285
- # Create query builder ColumnDescriptor with the DB column name
286
- setattr(cls, field_name, ColumnDescriptor(db_column_name, field_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
298
+ if not hasattr(cls, '__annotations__'):
299
+ cls.__annotations__ = {}
300
+ cls.__annotations__[field_name] = Column
287
301
 
288
302
  # Create SQLAlchemy column if metadata provided
289
303
  if metadata is not None and HAS_SQLALCHEMY:
@@ -293,8 +307,12 @@ class Table:
293
307
 
294
308
  # Check if it's an Ellipsis (...) declaration
295
309
  if field_value is ...:
296
- # Create query builder ColumnDescriptor
297
- setattr(cls, field_name, ColumnDescriptor(field_name, None))
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
313
+ if not hasattr(cls, '__annotations__'):
314
+ cls.__annotations__ = {}
315
+ cls.__annotations__[field_name] = Column
298
316
  continue
299
317
 
300
318
  # Otherwise, handle type annotations
@@ -302,8 +320,12 @@ class Table:
302
320
  # No type annotation, Ellipsis, or SA Column - skip
303
321
  continue
304
322
 
305
- # Create query builder ColumnDescriptor for type-annotated fields
306
- setattr(cls, field_name, ColumnDescriptor(field_name, field_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
326
+ if not hasattr(cls, '__annotations__'):
327
+ cls.__annotations__ = {}
328
+ cls.__annotations__[field_name] = Column
307
329
 
308
330
  # Create SQLAlchemy column if metadata provided
309
331
  if metadata is not None and HAS_SQLALCHEMY:
@@ -314,6 +336,9 @@ class Table:
314
336
  if metadata is not None and HAS_SQLALCHEMY:
315
337
  cls._sa_table = SATable(cls.table_name, metadata, *sa_columns, schema=schema)
316
338
 
339
+ # Add the ALL column for wildcard column selection
340
+ cls.ALL = Column(cls.table_name, '*', schema=schema)
341
+
317
342
  @classmethod
318
343
  def select(cls, *columns: Union['Column', Template]) -> 'SelectQueryBuilder':
319
344
  """Start building a SELECT query"""
@@ -364,23 +389,6 @@ class Table:
364
389
  return DeleteBuilder(cls)
365
390
 
366
391
 
367
- class ColumnDescriptor:
368
- """Descriptor that creates Column objects when accessed on Table classes or instances"""
369
-
370
- def __init__(self, column_name: str, python_type: type = None):
371
- self.column_name = column_name
372
- self.python_type = python_type
373
-
374
- def __set_name__(self, owner, name):
375
- self.column_name = name
376
-
377
- def __get__(self, obj, objtype=None) -> Column:
378
- if objtype is None:
379
- objtype = type(obj)
380
- schema = getattr(objtype, 'schema', None)
381
- return Column(objtype.table_name, self.column_name, self.python_type, schema=schema)
382
-
383
-
384
392
  class Condition:
385
393
  """Represents a WHERE clause condition"""
386
394
 
@@ -1058,3 +1066,32 @@ if HAS_SQLALCHEMY:
1058
1066
  datetime: DateTime,
1059
1067
  float: Float,
1060
1068
  }
1069
+
1070
+
1071
+ # Helper function for type checker compatibility with SQLAlchemy columns
1072
+ def SAColumn(*args: Any, **kwargs: Any) -> Column: # noqa: N802
1073
+ """Wrapper for SQLAlchemy Column that satisfies type checkers.
1074
+
1075
+ This function returns a SQLAlchemy Column at runtime but tells type checkers
1076
+ it returns a tsql Column. This allows you to use SQLAlchemy columns without
1077
+ explicit type annotations while still getting proper IDE completions.
1078
+
1079
+ Usage:
1080
+ from tsql.query_builder import Table, SAColumn
1081
+ from sqlalchemy import Integer, String
1082
+
1083
+ class Users(Table):
1084
+ id = SAColumn(Integer, primary_key=True) # Type checker sees: tsql Column
1085
+ name = SAColumn(String(100))
1086
+
1087
+ Note: This shadows the SQLAlchemy Column import. Import SA Column explicitly if needed:
1088
+ from sqlalchemy import Column as SA_Column
1089
+
1090
+ Alternative: Use explicit type annotations:
1091
+ from sqlalchemy import Column as SACol
1092
+ id: Column = SACol(Integer, primary_key=True)
1093
+ """
1094
+ if not HAS_SQLALCHEMY:
1095
+ raise ImportError("SQLAlchemy is not installed. Cannot use SAColumn() helper.")
1096
+ from sqlalchemy import Column as SA_Column
1097
+ 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