t-sql 4.5.2__tar.gz → 4.6.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.5.2/README.md → t_sql-4.6.0/PKG-INFO +46 -0
- t_sql-4.5.2/PKG-INFO → t_sql-4.6.0/README.md +36 -10
- {t_sql-4.5.2 → t_sql-4.6.0}/pyproject.toml +1 -1
- t_sql-4.6.0/tests/test_string_based_builders.py +183 -0
- {t_sql-4.5.2 → t_sql-4.6.0}/tsql/query_builder.py +149 -25
- {t_sql-4.5.2 → t_sql-4.6.0}/.dockerignore +0 -0
- {t_sql-4.5.2 → t_sql-4.6.0}/.github/workflows/publish.yml +0 -0
- {t_sql-4.5.2 → t_sql-4.6.0}/.github/workflows/test.yml +0 -0
- {t_sql-4.5.2 → t_sql-4.6.0}/.gitignore +0 -0
- {t_sql-4.5.2 → t_sql-4.6.0}/Dockerfile +0 -0
- {t_sql-4.5.2 → t_sql-4.6.0}/LICENSE +0 -0
- {t_sql-4.5.2 → t_sql-4.6.0}/compose.yaml +0 -0
- {t_sql-4.5.2 → t_sql-4.6.0}/context7.json +0 -0
- {t_sql-4.5.2 → t_sql-4.6.0}/pytest.ini +0 -0
- {t_sql-4.5.2 → t_sql-4.6.0}/tests/test_alembic_integration.py +0 -0
- {t_sql-4.5.2 → t_sql-4.6.0}/tests/test_asyncpg_integration.py +0 -0
- {t_sql-4.5.2 → t_sql-4.6.0}/tests/test_different_object_types.py +0 -0
- {t_sql-4.5.2 → t_sql-4.6.0}/tests/test_error_messages.py +0 -0
- {t_sql-4.5.2 → t_sql-4.6.0}/tests/test_escaped.py +0 -0
- {t_sql-4.5.2 → t_sql-4.6.0}/tests/test_escaped_binary_hex.py +0 -0
- {t_sql-4.5.2 → t_sql-4.6.0}/tests/test_helper_functions.py +0 -0
- {t_sql-4.5.2 → t_sql-4.6.0}/tests/test_injection_edge_cases.py +0 -0
- {t_sql-4.5.2 → t_sql-4.6.0}/tests/test_injection_protection_validation.py +0 -0
- {t_sql-4.5.2 → t_sql-4.6.0}/tests/test_injections_for_escaped.py +0 -0
- {t_sql-4.5.2 → t_sql-4.6.0}/tests/test_mysql_integration.py +0 -0
- {t_sql-4.5.2 → t_sql-4.6.0}/tests/test_parameter_names.py +0 -0
- {t_sql-4.5.2 → t_sql-4.6.0}/tests/test_query_builder.py +0 -0
- {t_sql-4.5.2 → t_sql-4.6.0}/tests/test_sqlalchemy_integration.py +0 -0
- {t_sql-4.5.2 → t_sql-4.6.0}/tests/test_sqlite_integration.py +0 -0
- {t_sql-4.5.2 → t_sql-4.6.0}/tests/test_styles.py +0 -0
- {t_sql-4.5.2 → t_sql-4.6.0}/tests/test_template_in_builders.py +0 -0
- {t_sql-4.5.2 → t_sql-4.6.0}/tests/test_tsql.py +0 -0
- {t_sql-4.5.2 → t_sql-4.6.0}/tests/test_type_processor.py +0 -0
- {t_sql-4.5.2 → t_sql-4.6.0}/tsql/__init__.py +0 -0
- {t_sql-4.5.2 → t_sql-4.6.0}/tsql/row.py +0 -0
- {t_sql-4.5.2 → t_sql-4.6.0}/tsql/styles.py +0 -0
- {t_sql-4.5.2 → t_sql-4.6.0}/tsql/type_processor.py +0 -0
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: t-sql
|
|
3
|
+
Version: 4.6.0
|
|
4
|
+
Summary: Safe SQL. SQL queries for python t-strings (PEP 750)
|
|
5
|
+
Project-URL: Homepage, https://github.com/nhumrich/t-sql
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Python: >=3.14
|
|
8
|
+
Requires-Dist: alembic>=1.17.0
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
1
11
|
# t-sql
|
|
2
12
|
|
|
3
13
|
A lightweight SQL templating library that leverages Python 3.14's t-strings (PEP 750).
|
|
@@ -436,6 +446,42 @@ The query builder is database-agnostic - all methods are available regardless of
|
|
|
436
446
|
|
|
437
447
|
If you use an unsupported method, your database will raise a syntax error when you execute the query.
|
|
438
448
|
|
|
449
|
+
## String-Based Query Builder
|
|
450
|
+
|
|
451
|
+
t-sql also supports building queries with string table/column names instead of Table class definitions:
|
|
452
|
+
|
|
453
|
+
```python
|
|
454
|
+
from tsql.query_builder import SelectQueryBuilder, InsertBuilder, UpdateBuilder, DeleteBuilder
|
|
455
|
+
|
|
456
|
+
# SELECT
|
|
457
|
+
user_id = 123
|
|
458
|
+
status = 'active'
|
|
459
|
+
query = SelectQueryBuilder.from_table('users', schema='public') \
|
|
460
|
+
.select('id', 'name', 'email') \
|
|
461
|
+
.where(t'id = {user_id} AND status = {status}') \
|
|
462
|
+
.order_by('created_at', direction='DESC') \
|
|
463
|
+
.limit(10)
|
|
464
|
+
|
|
465
|
+
sql, params = query.render()
|
|
466
|
+
|
|
467
|
+
# INSERT
|
|
468
|
+
query = InsertBuilder.into_table('users', {'name': 'Bob', 'email': 'bob@test.com'}) \
|
|
469
|
+
.on_conflict_do_nothing('email') \
|
|
470
|
+
.returning('id')
|
|
471
|
+
|
|
472
|
+
# UPDATE
|
|
473
|
+
cutoff_date = '2024-01-01'
|
|
474
|
+
query = UpdateBuilder.table('users', {'status': 'inactive'}) \
|
|
475
|
+
.where(t'last_login < {cutoff_date}')
|
|
476
|
+
|
|
477
|
+
# DELETE
|
|
478
|
+
cutoff = '2023-01-01'
|
|
479
|
+
query = DeleteBuilder.from_table('users') \
|
|
480
|
+
.where(t'created_at < {cutoff}')
|
|
481
|
+
```
|
|
482
|
+
|
|
483
|
+
String identifiers are validated using the same `:literal` format spec as the core library, providing the same SQL injection protection.
|
|
484
|
+
|
|
439
485
|
## Mixing Query Builder with T-Strings
|
|
440
486
|
|
|
441
487
|
You can combine the query builder with raw t-strings for complex logic:
|
|
@@ -1,13 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: t-sql
|
|
3
|
-
Version: 4.5.2
|
|
4
|
-
Summary: Safe SQL. SQL queries for python t-strings (PEP 750)
|
|
5
|
-
Project-URL: Homepage, https://github.com/nhumrich/t-sql
|
|
6
|
-
License-File: LICENSE
|
|
7
|
-
Requires-Python: >=3.14
|
|
8
|
-
Requires-Dist: alembic>=1.17.0
|
|
9
|
-
Description-Content-Type: text/markdown
|
|
10
|
-
|
|
11
1
|
# t-sql
|
|
12
2
|
|
|
13
3
|
A lightweight SQL templating library that leverages Python 3.14's t-strings (PEP 750).
|
|
@@ -446,6 +436,42 @@ The query builder is database-agnostic - all methods are available regardless of
|
|
|
446
436
|
|
|
447
437
|
If you use an unsupported method, your database will raise a syntax error when you execute the query.
|
|
448
438
|
|
|
439
|
+
## String-Based Query Builder
|
|
440
|
+
|
|
441
|
+
t-sql also supports building queries with string table/column names instead of Table class definitions:
|
|
442
|
+
|
|
443
|
+
```python
|
|
444
|
+
from tsql.query_builder import SelectQueryBuilder, InsertBuilder, UpdateBuilder, DeleteBuilder
|
|
445
|
+
|
|
446
|
+
# SELECT
|
|
447
|
+
user_id = 123
|
|
448
|
+
status = 'active'
|
|
449
|
+
query = SelectQueryBuilder.from_table('users', schema='public') \
|
|
450
|
+
.select('id', 'name', 'email') \
|
|
451
|
+
.where(t'id = {user_id} AND status = {status}') \
|
|
452
|
+
.order_by('created_at', direction='DESC') \
|
|
453
|
+
.limit(10)
|
|
454
|
+
|
|
455
|
+
sql, params = query.render()
|
|
456
|
+
|
|
457
|
+
# INSERT
|
|
458
|
+
query = InsertBuilder.into_table('users', {'name': 'Bob', 'email': 'bob@test.com'}) \
|
|
459
|
+
.on_conflict_do_nothing('email') \
|
|
460
|
+
.returning('id')
|
|
461
|
+
|
|
462
|
+
# UPDATE
|
|
463
|
+
cutoff_date = '2024-01-01'
|
|
464
|
+
query = UpdateBuilder.table('users', {'status': 'inactive'}) \
|
|
465
|
+
.where(t'last_login < {cutoff_date}')
|
|
466
|
+
|
|
467
|
+
# DELETE
|
|
468
|
+
cutoff = '2023-01-01'
|
|
469
|
+
query = DeleteBuilder.from_table('users') \
|
|
470
|
+
.where(t'created_at < {cutoff}')
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
String identifiers are validated using the same `:literal` format spec as the core library, providing the same SQL injection protection.
|
|
474
|
+
|
|
449
475
|
## Mixing Query Builder with T-Strings
|
|
450
476
|
|
|
451
477
|
You can combine the query builder with raw t-strings for complex logic:
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Test string-based query builders (dynamic table/column names)"""
|
|
2
|
+
from tsql.query_builder import SelectQueryBuilder, InsertBuilder, UpdateBuilder, DeleteBuilder
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def test_select_from_string_table():
|
|
6
|
+
"""Test SELECT query with string table name"""
|
|
7
|
+
query = SelectQueryBuilder.from_table('users').select('id', 'name', 'email')
|
|
8
|
+
sql, params = query.render()
|
|
9
|
+
|
|
10
|
+
assert 'SELECT id, name, email' in sql
|
|
11
|
+
assert 'FROM users' in sql
|
|
12
|
+
assert params == []
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_select_with_schema():
|
|
16
|
+
"""Test SELECT with schema qualification"""
|
|
17
|
+
query = SelectQueryBuilder.from_table('users', schema='public').select('id', 'name')
|
|
18
|
+
sql, params = query.render()
|
|
19
|
+
|
|
20
|
+
assert 'FROM public.users' in sql
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_select_with_where_tstring():
|
|
24
|
+
"""Test SELECT with t-string WHERE clause"""
|
|
25
|
+
user_id = 123
|
|
26
|
+
status = 'active'
|
|
27
|
+
query = SelectQueryBuilder.from_table('users').select('name').where(t'id = {user_id} AND status = {status}')
|
|
28
|
+
sql, params = query.render()
|
|
29
|
+
|
|
30
|
+
assert 'WHERE' in sql
|
|
31
|
+
assert params == [123, 'active']
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_select_order_by_string():
|
|
35
|
+
"""Test ORDER BY with string column names"""
|
|
36
|
+
query = SelectQueryBuilder.from_table('users').select('id', 'name').order_by('created_at', direction='DESC')
|
|
37
|
+
sql, params = query.render()
|
|
38
|
+
|
|
39
|
+
assert 'ORDER BY created_at DESC' in sql
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_select_order_by_multiple():
|
|
43
|
+
"""Test ORDER BY with multiple columns (chained calls)"""
|
|
44
|
+
query = SelectQueryBuilder.from_table('users') \
|
|
45
|
+
.select('id', 'name') \
|
|
46
|
+
.order_by('username') \
|
|
47
|
+
.order_by('created_at', direction='DESC')
|
|
48
|
+
sql, params = query.render()
|
|
49
|
+
|
|
50
|
+
assert 'ORDER BY username ASC, created_at DESC' in sql
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_select_group_by_string():
|
|
54
|
+
"""Test GROUP BY with string column names"""
|
|
55
|
+
query = SelectQueryBuilder.from_table('orders').select('user_id', t'COUNT(*) as count').group_by('user_id')
|
|
56
|
+
sql, params = query.render()
|
|
57
|
+
|
|
58
|
+
assert 'GROUP BY user_id' in sql
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_select_limit_offset():
|
|
62
|
+
"""Test LIMIT and OFFSET with string-based query"""
|
|
63
|
+
query = SelectQueryBuilder.from_table('users').select('id').limit(10).offset(20)
|
|
64
|
+
sql, params = query.render()
|
|
65
|
+
|
|
66
|
+
assert 'LIMIT' in sql
|
|
67
|
+
assert 'OFFSET' in sql
|
|
68
|
+
assert params == [10, 20]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_insert_into_string_table():
|
|
72
|
+
"""Test INSERT with string table name"""
|
|
73
|
+
query = InsertBuilder.into_table('users', {'name': 'Bob', 'email': 'bob@test.com'})
|
|
74
|
+
sql, params = query.render()
|
|
75
|
+
|
|
76
|
+
assert 'INSERT INTO users' in sql
|
|
77
|
+
assert 'name' in sql
|
|
78
|
+
assert 'email' in sql
|
|
79
|
+
assert 'Bob' in params
|
|
80
|
+
assert 'bob@test.com' in params
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_insert_with_schema():
|
|
84
|
+
"""Test INSERT with schema qualification"""
|
|
85
|
+
query = InsertBuilder.into_table('users', {'name': 'Alice'}, schema='public')
|
|
86
|
+
sql, params = query.render()
|
|
87
|
+
|
|
88
|
+
assert 'INSERT INTO public.users' in sql
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_insert_with_returning():
|
|
92
|
+
"""Test INSERT with RETURNING clause"""
|
|
93
|
+
query = InsertBuilder.into_table('users', {'name': 'Bob'}).returning('id')
|
|
94
|
+
sql, params = query.render()
|
|
95
|
+
|
|
96
|
+
assert 'RETURNING id' in sql
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_insert_on_conflict_do_nothing():
|
|
100
|
+
"""Test INSERT with ON CONFLICT DO NOTHING"""
|
|
101
|
+
query = InsertBuilder.into_table('users', {'email': 'test@example.com'}).on_conflict_do_nothing('email')
|
|
102
|
+
sql, params = query.render()
|
|
103
|
+
|
|
104
|
+
assert 'ON CONFLICT' in sql
|
|
105
|
+
assert 'DO NOTHING' in sql
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_update_string_table():
|
|
109
|
+
"""Test UPDATE with string table name"""
|
|
110
|
+
cutoff_date = '2024-01-01'
|
|
111
|
+
query = UpdateBuilder.table('users', {'status': 'inactive'}).where(t'last_login < {cutoff_date}')
|
|
112
|
+
sql, params = query.render()
|
|
113
|
+
|
|
114
|
+
assert 'UPDATE users' in sql
|
|
115
|
+
assert 'SET status' in sql
|
|
116
|
+
assert 'WHERE' in sql
|
|
117
|
+
assert 'inactive' in params
|
|
118
|
+
assert '2024-01-01' in params
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_update_with_schema():
|
|
122
|
+
"""Test UPDATE with schema qualification"""
|
|
123
|
+
status = 'active'
|
|
124
|
+
query = UpdateBuilder.table('users', {'status': 'inactive'}, schema='public').where(t'status = {status}')
|
|
125
|
+
sql, params = query.render()
|
|
126
|
+
|
|
127
|
+
assert 'UPDATE public.users' in sql
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_delete_from_string_table():
|
|
131
|
+
"""Test DELETE with string table name"""
|
|
132
|
+
cutoff = '2023-01-01'
|
|
133
|
+
query = DeleteBuilder.from_table('users').where(t'created_at < {cutoff}')
|
|
134
|
+
sql, params = query.render()
|
|
135
|
+
|
|
136
|
+
assert 'DELETE FROM users' in sql
|
|
137
|
+
assert 'WHERE' in sql
|
|
138
|
+
assert '2023-01-01' in params
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_delete_with_schema():
|
|
142
|
+
"""Test DELETE with schema qualification"""
|
|
143
|
+
user_id = 999
|
|
144
|
+
query = DeleteBuilder.from_table('users', schema='public').where(t'id = {user_id}')
|
|
145
|
+
sql, params = query.render()
|
|
146
|
+
|
|
147
|
+
assert 'DELETE FROM public.users' in sql
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_string_based_prevents_injection():
|
|
151
|
+
"""Test that invalid identifiers raise errors via :literal validation"""
|
|
152
|
+
import pytest
|
|
153
|
+
|
|
154
|
+
# Invalid table name
|
|
155
|
+
with pytest.raises(ValueError):
|
|
156
|
+
query = SelectQueryBuilder.from_table('users; DROP TABLE users--')
|
|
157
|
+
query.render()
|
|
158
|
+
|
|
159
|
+
# Invalid column name
|
|
160
|
+
with pytest.raises(ValueError):
|
|
161
|
+
query = SelectQueryBuilder.from_table('users').select('id; DROP TABLE users--')
|
|
162
|
+
query.render()
|
|
163
|
+
|
|
164
|
+
# Invalid schema name (too many dots)
|
|
165
|
+
with pytest.raises(ValueError):
|
|
166
|
+
query = SelectQueryBuilder.from_table('users', schema='a.b.c.d')
|
|
167
|
+
query.render()
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def test_mixed_string_and_tstring_columns():
|
|
171
|
+
"""Test mixing string columns with t-string expressions"""
|
|
172
|
+
query = SelectQueryBuilder.from_table('users').select('id', 'name', t'UPPER(email) AS email_upper')
|
|
173
|
+
sql, params = query.render()
|
|
174
|
+
|
|
175
|
+
assert 'SELECT id, name, UPPER(email) AS email_upper' in sql
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def test_string_select_star():
|
|
179
|
+
"""Test SELECT * with string-based table"""
|
|
180
|
+
query = SelectQueryBuilder.from_table('users')
|
|
181
|
+
sql, params = query.render()
|
|
182
|
+
|
|
183
|
+
assert 'SELECT * FROM users' in sql
|
|
@@ -14,6 +14,18 @@ class UnsafeQueryError(Exception):
|
|
|
14
14
|
"""
|
|
15
15
|
pass
|
|
16
16
|
|
|
17
|
+
|
|
18
|
+
class _StringTable:
|
|
19
|
+
"""Minimal table representation for string-based queries.
|
|
20
|
+
|
|
21
|
+
This acts like a Table class but without the Column descriptors.
|
|
22
|
+
Used internally when queries are built with string table names.
|
|
23
|
+
"""
|
|
24
|
+
def __init__(self, table_name: str, schema: Optional[str] = None):
|
|
25
|
+
self.table_name = table_name
|
|
26
|
+
self.schema = schema
|
|
27
|
+
self._type_processors = {}
|
|
28
|
+
|
|
17
29
|
# Optional SQLAlchemy support
|
|
18
30
|
try:
|
|
19
31
|
from sqlalchemy import MetaData, Table as SATable, Column as SAColumn
|
|
@@ -620,7 +632,7 @@ class QueryBuilder(ABC):
|
|
|
620
632
|
class InsertBuilder(QueryBuilder):
|
|
621
633
|
"""Fluent interface for building INSERT queries"""
|
|
622
634
|
|
|
623
|
-
def __init__(self, base_table: type['Table'], values: dict[str, Any]):
|
|
635
|
+
def __init__(self, base_table: Union[type['Table'], _StringTable], values: dict[str, Any]):
|
|
624
636
|
self.base_table = base_table
|
|
625
637
|
|
|
626
638
|
# Apply defaults from SQLAlchemy columns if available
|
|
@@ -650,6 +662,26 @@ class InsertBuilder(QueryBuilder):
|
|
|
650
662
|
self._update_cols: Optional[dict[str, Any]] = None
|
|
651
663
|
self._returning_cols: Optional[List[str]] = None
|
|
652
664
|
|
|
665
|
+
@classmethod
|
|
666
|
+
def into_table(cls, table_name: str, values: dict[str, Any], schema: Optional[str] = None) -> 'InsertBuilder':
|
|
667
|
+
"""Create an InsertBuilder from a string table name.
|
|
668
|
+
|
|
669
|
+
Args:
|
|
670
|
+
table_name: Name of the table
|
|
671
|
+
values: Dictionary of column names and values
|
|
672
|
+
schema: Optional schema name
|
|
673
|
+
|
|
674
|
+
Returns:
|
|
675
|
+
InsertBuilder instance
|
|
676
|
+
|
|
677
|
+
Example:
|
|
678
|
+
InsertBuilder.into_table('users', {'name': 'Bob', 'email': 'bob@test.com'}) \\
|
|
679
|
+
.on_conflict_do_nothing('email') \\
|
|
680
|
+
.returning('id')
|
|
681
|
+
"""
|
|
682
|
+
string_table = _StringTable(table_name, schema)
|
|
683
|
+
return cls(string_table, values)
|
|
684
|
+
|
|
653
685
|
def ignore(self) -> 'InsertBuilder':
|
|
654
686
|
"""Add INSERT IGNORE (MySQL)"""
|
|
655
687
|
self._ignore = True
|
|
@@ -848,7 +880,7 @@ def _process_value_for_builder(value: Any, type_processor: Any = None) -> Any:
|
|
|
848
880
|
class UpdateBuilder(QueryBuilder):
|
|
849
881
|
"""Fluent interface for building UPDATE queries"""
|
|
850
882
|
|
|
851
|
-
def __init__(self, base_table: type['Table'], values: dict[str, Any]):
|
|
883
|
+
def __init__(self, base_table: Union[type['Table'], _StringTable], values: dict[str, Any]):
|
|
852
884
|
self.base_table = base_table
|
|
853
885
|
|
|
854
886
|
# Apply onupdate defaults from SQLAlchemy columns if available
|
|
@@ -876,6 +908,25 @@ class UpdateBuilder(QueryBuilder):
|
|
|
876
908
|
self._returning_cols: Optional[List[str]] = None
|
|
877
909
|
self._requires_where: bool = True
|
|
878
910
|
|
|
911
|
+
@classmethod
|
|
912
|
+
def table(cls, table_name: str, values: dict[str, Any], schema: Optional[str] = None) -> 'UpdateBuilder':
|
|
913
|
+
"""Create an UpdateBuilder from a string table name.
|
|
914
|
+
|
|
915
|
+
Args:
|
|
916
|
+
table_name: Name of the table
|
|
917
|
+
values: Dictionary of column names and values to update
|
|
918
|
+
schema: Optional schema name
|
|
919
|
+
|
|
920
|
+
Returns:
|
|
921
|
+
UpdateBuilder instance
|
|
922
|
+
|
|
923
|
+
Example:
|
|
924
|
+
UpdateBuilder.table('users', {'status': 'inactive'}, schema='public') \\
|
|
925
|
+
.where(t'last_login < {cutoff_date}')
|
|
926
|
+
"""
|
|
927
|
+
string_table = _StringTable(table_name, schema)
|
|
928
|
+
return cls(string_table, values)
|
|
929
|
+
|
|
879
930
|
def where(self, condition: Union[Condition, Template]) -> 'UpdateBuilder':
|
|
880
931
|
"""Add a WHERE condition (multiple calls are ANDed together)"""
|
|
881
932
|
self._conditions.append(condition)
|
|
@@ -977,12 +1028,30 @@ class UpdateBuilder(QueryBuilder):
|
|
|
977
1028
|
class DeleteBuilder(QueryBuilder):
|
|
978
1029
|
"""Fluent interface for building DELETE queries"""
|
|
979
1030
|
|
|
980
|
-
def __init__(self, base_table: type['Table']):
|
|
1031
|
+
def __init__(self, base_table: Union[type['Table'], _StringTable]):
|
|
981
1032
|
self.base_table = base_table
|
|
982
1033
|
self._conditions: List[Union[Condition, Template]] = []
|
|
983
1034
|
self._returning_cols: Optional[List[str]] = None
|
|
984
1035
|
self._requires_where: bool = True
|
|
985
1036
|
|
|
1037
|
+
@classmethod
|
|
1038
|
+
def from_table(cls, table_name: str, schema: Optional[str] = None) -> 'DeleteBuilder':
|
|
1039
|
+
"""Create a DeleteBuilder from a string table name.
|
|
1040
|
+
|
|
1041
|
+
Args:
|
|
1042
|
+
table_name: Name of the table
|
|
1043
|
+
schema: Optional schema name
|
|
1044
|
+
|
|
1045
|
+
Returns:
|
|
1046
|
+
DeleteBuilder instance
|
|
1047
|
+
|
|
1048
|
+
Example:
|
|
1049
|
+
DeleteBuilder.from_table('users', schema='public') \\
|
|
1050
|
+
.where(t'created_at < {cutoff}')
|
|
1051
|
+
"""
|
|
1052
|
+
string_table = _StringTable(table_name, schema)
|
|
1053
|
+
return cls(string_table)
|
|
1054
|
+
|
|
986
1055
|
def where(self, condition: Union[Condition, Template]) -> 'DeleteBuilder':
|
|
987
1056
|
"""Add a WHERE condition (multiple calls are ANDed together)"""
|
|
988
1057
|
self._conditions.append(condition)
|
|
@@ -1077,22 +1146,41 @@ class DeleteBuilder(QueryBuilder):
|
|
|
1077
1146
|
class SelectQueryBuilder(QueryBuilder):
|
|
1078
1147
|
"""Fluent interface for building SQL SELECT queries"""
|
|
1079
1148
|
|
|
1080
|
-
def __init__(self, base_table: type['Table']):
|
|
1149
|
+
def __init__(self, base_table: Union[type['Table'], _StringTable]):
|
|
1081
1150
|
self.base_table = base_table
|
|
1082
|
-
self._columns: Optional[List[Column]] = None
|
|
1083
|
-
self._conditions: List[Condition] = []
|
|
1151
|
+
self._columns: Optional[List[Union[Column, str]]] = None
|
|
1152
|
+
self._conditions: List[Union[Condition, Template]] = []
|
|
1084
1153
|
self._joins: List[Join] = []
|
|
1085
|
-
self._group_by_columns: List[Column] = []
|
|
1154
|
+
self._group_by_columns: List[Union[Column, str]] = []
|
|
1086
1155
|
self._having_conditions: List[Union[Condition, Template]] = []
|
|
1087
|
-
self._order_by_columns: List[tuple[Column, str]] = []
|
|
1156
|
+
self._order_by_columns: List[tuple[Union[Column, str], str]] = []
|
|
1088
1157
|
self._limit_value: Optional[int] = None
|
|
1089
1158
|
self._offset_value: Optional[int] = None
|
|
1090
1159
|
|
|
1091
|
-
|
|
1160
|
+
@classmethod
|
|
1161
|
+
def from_table(cls, table_name: str, schema: Optional[str] = None) -> 'SelectQueryBuilder':
|
|
1162
|
+
"""Create a SelectQueryBuilder from a string table name.
|
|
1163
|
+
|
|
1164
|
+
Args:
|
|
1165
|
+
table_name: Name of the table
|
|
1166
|
+
schema: Optional schema name
|
|
1167
|
+
|
|
1168
|
+
Returns:
|
|
1169
|
+
SelectQueryBuilder instance
|
|
1170
|
+
|
|
1171
|
+
Example:
|
|
1172
|
+
SelectQueryBuilder.from_table('users', schema='public') \\
|
|
1173
|
+
.select('id', 'name') \\
|
|
1174
|
+
.where(t'status = {status}')
|
|
1175
|
+
"""
|
|
1176
|
+
string_table = _StringTable(table_name, schema)
|
|
1177
|
+
return cls(string_table)
|
|
1178
|
+
|
|
1179
|
+
def select(self, *columns: Union[Column, Template, str]) -> 'SelectQueryBuilder':
|
|
1092
1180
|
"""Specify columns to select
|
|
1093
1181
|
|
|
1094
1182
|
Args:
|
|
1095
|
-
columns: Column objects (optionally with .as_() aliases)
|
|
1183
|
+
columns: Column objects (optionally with .as_() aliases), raw t-string Templates, or string column names
|
|
1096
1184
|
|
|
1097
1185
|
Examples:
|
|
1098
1186
|
# Using Column.as_() for aliases
|
|
@@ -1101,6 +1189,9 @@ class SelectQueryBuilder(QueryBuilder):
|
|
|
1101
1189
|
# Mixing Column objects and raw t-strings
|
|
1102
1190
|
users.select(users.id, users.email, t'users.first_name AS first')
|
|
1103
1191
|
|
|
1192
|
+
# String-based columns
|
|
1193
|
+
SelectQueryBuilder.from_table('users').select('id', 'name', 'email')
|
|
1194
|
+
|
|
1104
1195
|
# No columns specified selects all (SELECT *)
|
|
1105
1196
|
users.select()
|
|
1106
1197
|
"""
|
|
@@ -1128,11 +1219,12 @@ class SelectQueryBuilder(QueryBuilder):
|
|
|
1128
1219
|
"""Add a RIGHT JOIN clause"""
|
|
1129
1220
|
return self.join(table, on, 'RIGHT')
|
|
1130
1221
|
|
|
1131
|
-
def order_by(self, *columns: Union[Column, OrderByClause]) -> 'SelectQueryBuilder':
|
|
1222
|
+
def order_by(self, *columns: Union[Column, OrderByClause, str], direction: str = 'ASC') -> 'SelectQueryBuilder':
|
|
1132
1223
|
"""Add ORDER BY clause
|
|
1133
1224
|
|
|
1134
1225
|
Args:
|
|
1135
|
-
columns: Column objects
|
|
1226
|
+
columns: Column objects, OrderByClause objects (from .asc()/.desc()), or string column names
|
|
1227
|
+
direction: Sort direction ('ASC' or 'DESC') for columns that don't have explicit direction
|
|
1136
1228
|
|
|
1137
1229
|
Examples:
|
|
1138
1230
|
# Using .asc() and .desc() methods
|
|
@@ -1140,16 +1232,31 @@ class SelectQueryBuilder(QueryBuilder):
|
|
|
1140
1232
|
|
|
1141
1233
|
# Bare column defaults to ASC
|
|
1142
1234
|
Users.select().order_by(Users.username)
|
|
1235
|
+
|
|
1236
|
+
# String-based ordering with explicit direction
|
|
1237
|
+
SelectQueryBuilder.from_table('users').order_by('username', direction='DESC')
|
|
1238
|
+
|
|
1239
|
+
# Multiple columns in one call
|
|
1240
|
+
Users.select().order_by(Users.username, Users.id.desc())
|
|
1143
1241
|
"""
|
|
1144
|
-
for
|
|
1145
|
-
if isinstance(
|
|
1146
|
-
self._order_by_columns.append((
|
|
1242
|
+
for column in columns:
|
|
1243
|
+
if isinstance(column, OrderByClause):
|
|
1244
|
+
self._order_by_columns.append((column.column, column.direction))
|
|
1147
1245
|
else:
|
|
1148
|
-
|
|
1246
|
+
# Column or string with direction
|
|
1247
|
+
self._order_by_columns.append((column, direction.upper()))
|
|
1149
1248
|
return self
|
|
1150
1249
|
|
|
1151
|
-
def group_by(self, *columns: Column) -> 'SelectQueryBuilder':
|
|
1152
|
-
"""Add GROUP BY clause
|
|
1250
|
+
def group_by(self, *columns: Union[Column, str]) -> 'SelectQueryBuilder':
|
|
1251
|
+
"""Add GROUP BY clause
|
|
1252
|
+
|
|
1253
|
+
Args:
|
|
1254
|
+
columns: Column objects or string column names
|
|
1255
|
+
|
|
1256
|
+
Examples:
|
|
1257
|
+
# String-based GROUP BY
|
|
1258
|
+
SelectQueryBuilder.from_table('orders').select('user_id', 'COUNT(*)').group_by('user_id')
|
|
1259
|
+
"""
|
|
1153
1260
|
self._group_by_columns.extend(columns)
|
|
1154
1261
|
return self
|
|
1155
1262
|
|
|
@@ -1176,11 +1283,14 @@ class SelectQueryBuilder(QueryBuilder):
|
|
|
1176
1283
|
parts: List[Template] = []
|
|
1177
1284
|
|
|
1178
1285
|
if self._columns:
|
|
1179
|
-
# Build column list, handling
|
|
1286
|
+
# Build column list, handling Column objects, Template (t-string) objects, and strings
|
|
1180
1287
|
column_parts = []
|
|
1181
1288
|
for col in self._columns:
|
|
1182
1289
|
if isinstance(col, Template):
|
|
1183
1290
|
column_parts.append(col)
|
|
1291
|
+
elif isinstance(col, str):
|
|
1292
|
+
# String column name, use :literal for validation
|
|
1293
|
+
column_parts.append(t'{col:literal}')
|
|
1184
1294
|
else:
|
|
1185
1295
|
# Column object, convert to string
|
|
1186
1296
|
column_parts.append(t'{str(col):unsafe}')
|
|
@@ -1210,9 +1320,15 @@ class SelectQueryBuilder(QueryBuilder):
|
|
|
1210
1320
|
parts.append(t'WHERE {combined_where}')
|
|
1211
1321
|
|
|
1212
1322
|
if self._group_by_columns:
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1323
|
+
group_by_parts = []
|
|
1324
|
+
for col in self._group_by_columns:
|
|
1325
|
+
if isinstance(col, str):
|
|
1326
|
+
group_by_parts.append(t'{col:literal}')
|
|
1327
|
+
else:
|
|
1328
|
+
col_str = str(col)
|
|
1329
|
+
group_by_parts.append(t'{col_str:unsafe}')
|
|
1330
|
+
group_by_template = t_join(t', ', group_by_parts)
|
|
1331
|
+
parts.append(t'GROUP BY {group_by_template}')
|
|
1216
1332
|
|
|
1217
1333
|
if self._having_conditions:
|
|
1218
1334
|
having_parts = []
|
|
@@ -1225,9 +1341,17 @@ class SelectQueryBuilder(QueryBuilder):
|
|
|
1225
1341
|
parts.append(t'HAVING {combined_having}')
|
|
1226
1342
|
|
|
1227
1343
|
if self._order_by_columns:
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1344
|
+
order_parts = []
|
|
1345
|
+
for col, direction in self._order_by_columns:
|
|
1346
|
+
if isinstance(col, str):
|
|
1347
|
+
# String column name - validate with :literal
|
|
1348
|
+
order_parts.append(t'{col:literal} {direction:unsafe}')
|
|
1349
|
+
else:
|
|
1350
|
+
# Column object - convert to string
|
|
1351
|
+
col_str = str(col)
|
|
1352
|
+
order_parts.append(t'{col_str:unsafe} {direction:unsafe}')
|
|
1353
|
+
order_by_template = t_join(t', ', order_parts)
|
|
1354
|
+
parts.append(t'ORDER BY {order_by_template}')
|
|
1231
1355
|
|
|
1232
1356
|
if self._limit_value is not None:
|
|
1233
1357
|
limit_val = self._limit_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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|