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.
- {t_sql-3.2.0 → t_sql-4.1.0}/PKG-INFO +46 -20
- {t_sql-3.2.0 → t_sql-4.1.0}/README.md +45 -19
- {t_sql-3.2.0 → t_sql-4.1.0}/pyproject.toml +4 -1
- {t_sql-3.2.0 → t_sql-4.1.0}/tests/test_query_builder.py +67 -9
- {t_sql-3.2.0 → t_sql-4.1.0}/tests/test_sqlalchemy_integration.py +32 -0
- {t_sql-3.2.0 → t_sql-4.1.0}/tsql/query_builder.py +71 -34
- {t_sql-3.2.0 → t_sql-4.1.0}/.dockerignore +0 -0
- {t_sql-3.2.0 → t_sql-4.1.0}/.github/workflows/publish.yml +0 -0
- {t_sql-3.2.0 → t_sql-4.1.0}/.github/workflows/test.yml +0 -0
- {t_sql-3.2.0 → t_sql-4.1.0}/.gitignore +0 -0
- {t_sql-3.2.0 → t_sql-4.1.0}/Dockerfile +0 -0
- {t_sql-3.2.0 → t_sql-4.1.0}/LICENSE +0 -0
- {t_sql-3.2.0 → t_sql-4.1.0}/compose.yaml +0 -0
- {t_sql-3.2.0 → t_sql-4.1.0}/context7.json +0 -0
- {t_sql-3.2.0 → t_sql-4.1.0}/pytest.ini +0 -0
- {t_sql-3.2.0 → t_sql-4.1.0}/tests/test_alembic_integration.py +0 -0
- {t_sql-3.2.0 → t_sql-4.1.0}/tests/test_asyncpg_integration.py +0 -0
- {t_sql-3.2.0 → t_sql-4.1.0}/tests/test_different_object_types.py +0 -0
- {t_sql-3.2.0 → t_sql-4.1.0}/tests/test_escaped.py +0 -0
- {t_sql-3.2.0 → t_sql-4.1.0}/tests/test_escaped_binary_hex.py +0 -0
- {t_sql-3.2.0 → t_sql-4.1.0}/tests/test_helper_functions.py +0 -0
- {t_sql-3.2.0 → t_sql-4.1.0}/tests/test_injection_edge_cases.py +0 -0
- {t_sql-3.2.0 → t_sql-4.1.0}/tests/test_injection_protection_validation.py +0 -0
- {t_sql-3.2.0 → t_sql-4.1.0}/tests/test_injections_for_escaped.py +0 -0
- {t_sql-3.2.0 → t_sql-4.1.0}/tests/test_mysql_integration.py +0 -0
- {t_sql-3.2.0 → t_sql-4.1.0}/tests/test_parameter_names.py +0 -0
- {t_sql-3.2.0 → t_sql-4.1.0}/tests/test_sqlite_integration.py +0 -0
- {t_sql-3.2.0 → t_sql-4.1.0}/tests/test_styles.py +0 -0
- {t_sql-3.2.0 → t_sql-4.1.0}/tests/test_tsql.py +0 -0
- {t_sql-3.2.0 → t_sql-4.1.0}/tsql/__init__.py +0 -0
- {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
|
+
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
|
|
506
|
+
**2. SQLAlchemy with SAColumn wrapper** (recommended for type checkers):
|
|
472
507
|
|
|
473
508
|
```python
|
|
474
|
-
from sqlalchemy import MetaData,
|
|
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 =
|
|
481
|
-
email =
|
|
482
|
-
name =
|
|
483
|
-
age =
|
|
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
|
|
523
|
+
# Use for queries
|
|
489
524
|
query = Users.select().where(Users.age > 18)
|
|
490
525
|
```
|
|
491
526
|
|
|
492
|
-
|
|
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
|
|
496
|
+
**2. SQLAlchemy with SAColumn wrapper** (recommended for type checkers):
|
|
462
497
|
|
|
463
498
|
```python
|
|
464
|
-
from sqlalchemy import MetaData,
|
|
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 =
|
|
471
|
-
email =
|
|
472
|
-
name =
|
|
473
|
-
age =
|
|
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
|
|
513
|
+
# Use for queries
|
|
479
514
|
query = Users.select().where(Users.age > 18)
|
|
480
515
|
```
|
|
481
516
|
|
|
482
|
-
|
|
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 = "
|
|
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
|
|
842
|
-
"""Test
|
|
843
|
-
|
|
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
|
|
849
|
-
"""Test is_not_null
|
|
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
|
|
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,
|
|
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
|
|
197
|
+
For SQLAlchemy integration, use the SAColumn wrapper for type checker compatibility:
|
|
201
198
|
|
|
202
|
-
from sqlalchemy import
|
|
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 =
|
|
206
|
-
name =
|
|
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
|
|
274
|
-
setattr(cls, field_name,
|
|
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
|
|
286
|
-
setattr(cls, field_name,
|
|
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
|
|
297
|
-
setattr(cls, field_name,
|
|
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
|
|
306
|
-
setattr(cls, field_name,
|
|
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
|
|
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
|