t-sql 1.2.0__tar.gz → 2.0.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-2.0.0/.github/workflows/test.yml +68 -0
- t_sql-2.0.0/PKG-INFO +530 -0
- t_sql-2.0.0/README.md +521 -0
- t_sql-2.0.0/compose.yaml +29 -0
- {t_sql-1.2.0 → t_sql-2.0.0}/pyproject.toml +5 -5
- {t_sql-1.2.0 → t_sql-2.0.0}/tests/test_asyncpg_integration.py +3 -93
- {t_sql-1.2.0 → t_sql-2.0.0}/tests/test_helper_functions.py +0 -55
- {t_sql-1.2.0 → t_sql-2.0.0}/tests/test_injection_protection_validation.py +2 -1
- {t_sql-1.2.0 → t_sql-2.0.0}/tests/test_injections_for_escaped.py +2 -1
- t_sql-2.0.0/tests/test_mysql_integration.py +291 -0
- {t_sql-1.2.0 → t_sql-2.0.0}/tests/test_query_builder.py +363 -50
- {t_sql-1.2.0 → t_sql-2.0.0}/tests/test_sqlalchemy_integration.py +20 -31
- t_sql-2.0.0/tests/test_sqlite_integration.py +357 -0
- {t_sql-1.2.0 → t_sql-2.0.0}/tsql/__init__.py +44 -61
- t_sql-2.0.0/tsql/query_builder.py +822 -0
- t_sql-1.2.0/.github/workflows/test.yml +0 -31
- t_sql-1.2.0/PKG-INFO +0 -425
- t_sql-1.2.0/README.md +0 -414
- t_sql-1.2.0/compose.yaml +0 -14
- t_sql-1.2.0/tsql/query_builder.py +0 -532
- {t_sql-1.2.0 → t_sql-2.0.0}/.dockerignore +0 -0
- {t_sql-1.2.0 → t_sql-2.0.0}/.github/workflows/publish.yml +0 -0
- {t_sql-1.2.0 → t_sql-2.0.0}/.gitignore +0 -0
- {t_sql-1.2.0 → t_sql-2.0.0}/Dockerfile +0 -0
- {t_sql-1.2.0 → t_sql-2.0.0}/LICENSE +0 -0
- {t_sql-1.2.0 → t_sql-2.0.0}/context7.json +0 -0
- {t_sql-1.2.0 → t_sql-2.0.0}/pytest.ini +0 -0
- {t_sql-1.2.0 → t_sql-2.0.0}/tests/test_different_object_types.py +0 -0
- {t_sql-1.2.0 → t_sql-2.0.0}/tests/test_escaped.py +0 -0
- {t_sql-1.2.0 → t_sql-2.0.0}/tests/test_escaped_binary_hex.py +0 -0
- {t_sql-1.2.0 → t_sql-2.0.0}/tests/test_injection_edge_cases.py +0 -0
- {t_sql-1.2.0 → t_sql-2.0.0}/tests/test_styles.py +0 -0
- {t_sql-1.2.0 → t_sql-2.0.0}/tests/test_tsql.py +0 -0
- {t_sql-1.2.0 → t_sql-2.0.0}/tsql/styles.py +0 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
name: Test
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [ main ]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [ main ]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
strategy:
|
|
13
|
+
matrix:
|
|
14
|
+
python-version: ["3.14"]
|
|
15
|
+
|
|
16
|
+
services:
|
|
17
|
+
postgres:
|
|
18
|
+
image: postgres:18
|
|
19
|
+
env:
|
|
20
|
+
POSTGRES_PASSWORD: password
|
|
21
|
+
POSTGRES_USER: postgres
|
|
22
|
+
POSTGRES_DB: postgres
|
|
23
|
+
ports:
|
|
24
|
+
- 5432:5432
|
|
25
|
+
options: >-
|
|
26
|
+
--health-cmd pg_isready
|
|
27
|
+
--health-interval 10s
|
|
28
|
+
--health-timeout 5s
|
|
29
|
+
--health-retries 5
|
|
30
|
+
|
|
31
|
+
mysql:
|
|
32
|
+
image: mysql:8.0
|
|
33
|
+
env:
|
|
34
|
+
MYSQL_ROOT_PASSWORD: password
|
|
35
|
+
MYSQL_DATABASE: testdb
|
|
36
|
+
MYSQL_USER: testuser
|
|
37
|
+
MYSQL_PASSWORD: password
|
|
38
|
+
ports:
|
|
39
|
+
- 3306:3306
|
|
40
|
+
options: >-
|
|
41
|
+
--health-cmd "mysqladmin ping -h localhost -u root -ppassword"
|
|
42
|
+
--health-interval 10s
|
|
43
|
+
--health-timeout 5s
|
|
44
|
+
--health-retries 5
|
|
45
|
+
|
|
46
|
+
steps:
|
|
47
|
+
- uses: actions/checkout@v4
|
|
48
|
+
|
|
49
|
+
- name: Set up Python ${{ matrix.python-version }}
|
|
50
|
+
uses: actions/setup-python@v5
|
|
51
|
+
with:
|
|
52
|
+
python-version: ${{ matrix.python-version }}
|
|
53
|
+
|
|
54
|
+
- name: Install uv
|
|
55
|
+
uses: astral-sh/setup-uv@v4
|
|
56
|
+
|
|
57
|
+
- name: Install dependencies
|
|
58
|
+
run: uv sync
|
|
59
|
+
|
|
60
|
+
- name: Run tests
|
|
61
|
+
env:
|
|
62
|
+
DATABASE_URL: postgresql://postgres:password@localhost:5432/postgres
|
|
63
|
+
MYSQL_HOST: localhost
|
|
64
|
+
MYSQL_PORT: 3306
|
|
65
|
+
MYSQL_USER: testuser
|
|
66
|
+
MYSQL_PASSWORD: password
|
|
67
|
+
MYSQL_DB: testdb
|
|
68
|
+
run: uv run pytest -v
|
t_sql-2.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: t-sql
|
|
3
|
+
Version: 2.0.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
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
|
|
10
|
+
# t-sql
|
|
11
|
+
|
|
12
|
+
A lightweight SQL templating library that leverages Python 3.14's t-strings (PEP 750).
|
|
13
|
+
(Note: This library has absolutely nothing to do with Microsoft SQLServer)
|
|
14
|
+
|
|
15
|
+
t-sql provides a safe way to write SQL queries using Python's template strings (t-strings) while preventing SQL injection attacks through multiple parameter styling options.
|
|
16
|
+
|
|
17
|
+
## ⚠️ Python Version Requirement
|
|
18
|
+
This library requires Python 3.14+
|
|
19
|
+
|
|
20
|
+
t-sql is built specifically to take advantage of the new t-string feature introduced in PEP 750, which is only available in Python 3.14+.
|
|
21
|
+
|
|
22
|
+
## Installing
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
# with pip
|
|
26
|
+
pip install t-sql
|
|
27
|
+
|
|
28
|
+
# with uv
|
|
29
|
+
uv add t-sql
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Quick Start
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
import tsql
|
|
36
|
+
|
|
37
|
+
# Basic usage
|
|
38
|
+
name = 'billy'
|
|
39
|
+
query = t'select * from users where name={name}'
|
|
40
|
+
|
|
41
|
+
# Render with default QMARK style
|
|
42
|
+
sql, params = tsql.render(query)
|
|
43
|
+
# ('select * from users where name = ?', ['billy'])
|
|
44
|
+
|
|
45
|
+
# Or use a different parameter style
|
|
46
|
+
sql, params = tsql.render(query, style=tsql.styles.NUMERIC_DOLLAR)
|
|
47
|
+
# ('select * from users where name = $1', ['billy'])
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Parameter Styles
|
|
51
|
+
|
|
52
|
+
- **QMARK** (default): Uses `?` placeholders
|
|
53
|
+
- **NUMERIC**: Uses `:1`, `:2`, etc. placeholders
|
|
54
|
+
- **NAMED**: Uses `:name` placeholders
|
|
55
|
+
- **FORMAT**: Uses `%s` placeholders
|
|
56
|
+
- **PYFORMAT**: Uses `%(name)s` placeholders
|
|
57
|
+
- **NUMERIC_DOLLAR**: Uses `$1`, `$2`, etc. (PostgreSQL native)
|
|
58
|
+
- **ESCAPED**: Escapes values directly into SQL (no parameters)
|
|
59
|
+
|
|
60
|
+
## Core Features
|
|
61
|
+
|
|
62
|
+
### SQL Injection Prevention
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
# SQL injection prevention works automatically
|
|
66
|
+
name = "billy ' and 1=1 --"
|
|
67
|
+
sql, params = tsql.render(t'select * from users where name={name}')
|
|
68
|
+
# Even with ESCAPED style, quotes are properly escaped
|
|
69
|
+
sql, _ = tsql.render(t'select * from users where name={name}', style=tsql.styles.ESCAPED)
|
|
70
|
+
# ("select * from users where name = 'billy '' and 1=1 --'", [])
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Format-spec helpers
|
|
74
|
+
|
|
75
|
+
#### Literal
|
|
76
|
+
|
|
77
|
+
For table/column names that can't be parameterized:
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
table = "users"
|
|
81
|
+
col = "name"
|
|
82
|
+
val = "billy"
|
|
83
|
+
query = t'select * from {table:literal} where {col:literal}={val}'
|
|
84
|
+
sql, params = tsql.render(query)
|
|
85
|
+
# ('select * from users where name = ?', ['billy'])
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
#### unsafe
|
|
89
|
+
|
|
90
|
+
For cases where you need to bypass safety (use with extreme caution):
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
dynamic_where = "age > 18 AND active = true"
|
|
94
|
+
sql, params = tsql.render(t"SELECT * FROM users WHERE {dynamic_where:unsafe}")
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
#### as_values
|
|
98
|
+
|
|
99
|
+
Formats a dictionary for INSERT statements:
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
values = {'id': 'abc123', 'name': 'bob', 'email': 'bob@example.com'}
|
|
103
|
+
sql, params = tsql.render(t"INSERT INTO users {values:as_values}")
|
|
104
|
+
# ('INSERT INTO users (id, name, email) VALUES (?, ?, ?)', ['abc123', 'bob', 'bob@example.com'])
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
#### as_set
|
|
108
|
+
|
|
109
|
+
Formats a dictionary for UPDATE statements:
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
values = {'name': 'joe', 'email': 'joe@example.com'}
|
|
113
|
+
sql, params = tsql.render(t"UPDATE users SET {values:as_set} WHERE id='abc123'")
|
|
114
|
+
# ('UPDATE users SET name = ?, email = ? WHERE id='abc123'', ['joe', 'joe@example.com'])
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### Helper Functions
|
|
118
|
+
|
|
119
|
+
t-sql provides several convenience functions for common SQL operations:
|
|
120
|
+
|
|
121
|
+
#### t_join
|
|
122
|
+
|
|
123
|
+
Joins multiple t-strings together:
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
import tsql
|
|
127
|
+
|
|
128
|
+
min_age = 18
|
|
129
|
+
parts = [t"SELECT *", t"FROM users", t"WHERE age > {min_age}"]
|
|
130
|
+
query = tsql.t_join(t" ", parts)
|
|
131
|
+
sql, params = tsql.render(query)
|
|
132
|
+
# ('SELECT * FROM users WHERE age > ?', [18])
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
#### select
|
|
136
|
+
|
|
137
|
+
Quick SELECT queries:
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
# Select all columns
|
|
141
|
+
query = tsql.select('users')
|
|
142
|
+
sql, params = query.render()
|
|
143
|
+
# ('SELECT * FROM users', [])
|
|
144
|
+
|
|
145
|
+
# Select specific columns
|
|
146
|
+
query = tsql.select('users', columns=['name', 'email'])
|
|
147
|
+
sql, params = query.render()
|
|
148
|
+
# ('SELECT name, email FROM users', [])
|
|
149
|
+
|
|
150
|
+
# With WHERE clause
|
|
151
|
+
query = tsql.select('users', columns=['name', 'email'], where={'age': 18})
|
|
152
|
+
sql, params = query.render()
|
|
153
|
+
# ('SELECT name, email FROM users WHERE age = ?', [18])
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
#### insert
|
|
157
|
+
|
|
158
|
+
Quick INSERT queries:
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
values = {'id': 'abc123', 'name': 'bob', 'email': 'bob@example.com'}
|
|
162
|
+
query = tsql.insert('users', values)
|
|
163
|
+
sql, params = query.render()
|
|
164
|
+
# ('INSERT INTO users (id, name, email) VALUES (?, ?, ?)', ['abc123', 'bob', 'bob@example.com'])
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
#### update
|
|
168
|
+
|
|
169
|
+
Quick UPDATE queries:
|
|
170
|
+
|
|
171
|
+
```python
|
|
172
|
+
# Update by ID
|
|
173
|
+
query = tsql.update('users', {'email': 'new@example.com'}, id_value='abc123')
|
|
174
|
+
sql, params = query.render()
|
|
175
|
+
# ('UPDATE users SET email = ? WHERE id = ?', ['new@example.com', 'abc123'])
|
|
176
|
+
|
|
177
|
+
# Update with custom WHERE
|
|
178
|
+
query = tsql.update('users', {'email': 'new@example.com'}, where={'age': 25})
|
|
179
|
+
sql, params = query.render()
|
|
180
|
+
# ('UPDATE users SET email = ? WHERE age = ?', ['new@example.com', 25])
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
#### delete
|
|
184
|
+
|
|
185
|
+
Quick DELETE queries:
|
|
186
|
+
|
|
187
|
+
```python
|
|
188
|
+
# Delete by ID
|
|
189
|
+
query = tsql.delete('users', id_value='abc123')
|
|
190
|
+
sql, params = query.render()
|
|
191
|
+
# ('DELETE FROM users WHERE id = ?', ['abc123'])
|
|
192
|
+
|
|
193
|
+
# Delete with custom WHERE
|
|
194
|
+
query = tsql.delete('users', where={'age': 18})
|
|
195
|
+
sql, params = query.render()
|
|
196
|
+
# ('DELETE FROM users WHERE age = ?', [18])
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
**Note:** These helper functions return query builder objects, so you can chain additional methods:
|
|
200
|
+
|
|
201
|
+
```python
|
|
202
|
+
query = tsql.select('users').where(t'age > {min_age}').limit(10)
|
|
203
|
+
sql, params = query.render()
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
# Query Builder
|
|
207
|
+
|
|
208
|
+
For a more structured approach, t-sql includes an optional query builder with a fluent interface and type-safe column references.
|
|
209
|
+
|
|
210
|
+
## Basic Usage
|
|
211
|
+
|
|
212
|
+
```python
|
|
213
|
+
from tsql.query_builder import Table, Column
|
|
214
|
+
|
|
215
|
+
class Users(Table):
|
|
216
|
+
id: Column
|
|
217
|
+
username: Column
|
|
218
|
+
email: Column
|
|
219
|
+
age: Column
|
|
220
|
+
|
|
221
|
+
# Simple SELECT
|
|
222
|
+
query = Users.select(Users.id, Users.username)
|
|
223
|
+
sql, params = query.render()
|
|
224
|
+
# ('SELECT users.id, users.username FROM users', [])
|
|
225
|
+
|
|
226
|
+
# With WHERE clause
|
|
227
|
+
query = Users.select().where(Users.age > 18)
|
|
228
|
+
sql, params = query.render()
|
|
229
|
+
# ('SELECT * FROM users WHERE users.age > ?', [18])
|
|
230
|
+
|
|
231
|
+
# Multiple conditions (ANDed together)
|
|
232
|
+
query = (Users.select(Users.username, Users.email)
|
|
233
|
+
.where(Users.age > 18)
|
|
234
|
+
.where(Users.email != None))
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
**Table Names:** The table name defaults to the lowercase class name. To specify a custom name:
|
|
238
|
+
|
|
239
|
+
```python
|
|
240
|
+
class UserAccount(Table, table_name='user_accounts'):
|
|
241
|
+
id: Column
|
|
242
|
+
username: Column
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
## Joins
|
|
246
|
+
|
|
247
|
+
```python
|
|
248
|
+
class Posts(Table):
|
|
249
|
+
id: Column
|
|
250
|
+
user_id: Column
|
|
251
|
+
title: Column
|
|
252
|
+
|
|
253
|
+
# INNER JOIN
|
|
254
|
+
query = (Posts.select(Posts.title, Users.username)
|
|
255
|
+
.join(Users, on=Posts.user_id == Users.id)
|
|
256
|
+
.where(Posts.id > 100))
|
|
257
|
+
|
|
258
|
+
# LEFT JOIN
|
|
259
|
+
query = (Posts.select()
|
|
260
|
+
.left_join(Users, on=Posts.user_id == Users.id))
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
## Query Features
|
|
264
|
+
|
|
265
|
+
```python
|
|
266
|
+
# IN clause
|
|
267
|
+
query = Users.select().where(Users.id.in_([1, 2, 3]))
|
|
268
|
+
|
|
269
|
+
# LIKE clause
|
|
270
|
+
query = Users.select().where(Users.username.like('%john%'))
|
|
271
|
+
|
|
272
|
+
# ORDER BY
|
|
273
|
+
query = Posts.select().order_by(Posts.id)
|
|
274
|
+
query = Posts.select().order_by((Posts.id, 'DESC'))
|
|
275
|
+
|
|
276
|
+
# LIMIT and OFFSET
|
|
277
|
+
query = Posts.select().limit(10).offset(20)
|
|
278
|
+
|
|
279
|
+
# GROUP BY and HAVING
|
|
280
|
+
query = (Posts.select()
|
|
281
|
+
.group_by(Posts.user_id)
|
|
282
|
+
.having(t'COUNT(*) > {min_count}'))
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
## Write Operations
|
|
286
|
+
|
|
287
|
+
The query builder supports INSERT, UPDATE, and DELETE with database-agnostic conflict handling.
|
|
288
|
+
|
|
289
|
+
### INSERT
|
|
290
|
+
|
|
291
|
+
```python
|
|
292
|
+
# Basic insert
|
|
293
|
+
values = {'id': 'abc123', 'username': 'john', 'email': 'john@example.com'}
|
|
294
|
+
query = Users.insert(values)
|
|
295
|
+
sql, params = query.render()
|
|
296
|
+
# ('INSERT INTO users (id, username, email) VALUES (?, ?, ?)', ['abc123', 'john', 'john@example.com'])
|
|
297
|
+
|
|
298
|
+
# INSERT with RETURNING (Postgres/SQLite)
|
|
299
|
+
query = Users.insert(values).returning()
|
|
300
|
+
sql, params = query.render()
|
|
301
|
+
# ('INSERT INTO users (id, username, email) VALUES (?, ?, ?) RETURNING *', [...])
|
|
302
|
+
|
|
303
|
+
# INSERT IGNORE (MySQL)
|
|
304
|
+
query = Users.insert(values).ignore()
|
|
305
|
+
sql, params = query.render()
|
|
306
|
+
# ('INSERT IGNORE INTO users (id, username, email) VALUES (?, ?, ?)', [...])
|
|
307
|
+
|
|
308
|
+
# ON CONFLICT DO NOTHING (Postgres/SQLite)
|
|
309
|
+
query = Users.insert(values).on_conflict_do_nothing()
|
|
310
|
+
# ('INSERT INTO users (...) VALUES (...) ON CONFLICT DO NOTHING', [...])
|
|
311
|
+
|
|
312
|
+
# ON CONFLICT DO NOTHING with specific conflict target (Postgres/SQLite)
|
|
313
|
+
query = Users.insert(values).on_conflict_do_nothing(conflict_on='email')
|
|
314
|
+
# ('INSERT INTO users (...) VALUES (...) ON CONFLICT (email) DO NOTHING', [...])
|
|
315
|
+
|
|
316
|
+
# ON CONFLICT DO UPDATE (Postgres/SQLite upsert)
|
|
317
|
+
query = Users.insert(values).on_conflict_update(conflict_on='id')
|
|
318
|
+
# ('INSERT INTO users (...) VALUES (...)
|
|
319
|
+
# ON CONFLICT (id) DO UPDATE SET username = EXCLUDED.username, email = EXCLUDED.email', [...])
|
|
320
|
+
|
|
321
|
+
# ON CONFLICT with custom update
|
|
322
|
+
query = Users.insert(values).on_conflict_update(
|
|
323
|
+
conflict_on='id',
|
|
324
|
+
update={'username': 'updated_name'}
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
# ON DUPLICATE KEY UPDATE (MySQL)
|
|
328
|
+
query = Users.insert(values).on_duplicate_key_update()
|
|
329
|
+
# ('INSERT INTO users (...) VALUES (...)
|
|
330
|
+
# ON DUPLICATE KEY UPDATE id = VALUES(id), username = VALUES(username), ...', [...])
|
|
331
|
+
|
|
332
|
+
# Chain multiple modifiers
|
|
333
|
+
query = (Users.insert(values)
|
|
334
|
+
.on_conflict_update(conflict_on='id')
|
|
335
|
+
.returning('id', 'username'))
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
### UPDATE
|
|
339
|
+
|
|
340
|
+
```python
|
|
341
|
+
# Basic update (no WHERE = updates all rows!)
|
|
342
|
+
query = Users.update({'email': 'newemail@example.com'})
|
|
343
|
+
sql, params = query.render()
|
|
344
|
+
# ('UPDATE users SET email = ?', ['newemail@example.com'])
|
|
345
|
+
|
|
346
|
+
# UPDATE with WHERE
|
|
347
|
+
query = Users.update({'email': 'newemail@example.com'}).where(Users.id == 'abc123')
|
|
348
|
+
sql, params = query.render()
|
|
349
|
+
# ('UPDATE users SET email = ? WHERE users.id = ?', ['newemail@example.com', 'abc123'])
|
|
350
|
+
|
|
351
|
+
# Multiple WHERE conditions
|
|
352
|
+
query = (Users.update({'email': 'newemail@example.com'})
|
|
353
|
+
.where(Users.id == 'abc123')
|
|
354
|
+
.where(Users.age > 18))
|
|
355
|
+
|
|
356
|
+
# With RETURNING (Postgres/SQLite)
|
|
357
|
+
query = (Users.update({'email': 'new@example.com'})
|
|
358
|
+
.where(Users.id == 'abc123')
|
|
359
|
+
.returning())
|
|
360
|
+
# ('UPDATE users SET email = ? WHERE users.id = ? RETURNING *', [...])
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
### DELETE
|
|
364
|
+
|
|
365
|
+
```python
|
|
366
|
+
# Basic delete (no WHERE = deletes all rows!)
|
|
367
|
+
query = Users.delete()
|
|
368
|
+
sql, params = query.render()
|
|
369
|
+
# ('DELETE FROM users', [])
|
|
370
|
+
|
|
371
|
+
# DELETE with WHERE
|
|
372
|
+
query = Users.delete().where(Users.id == 'abc123')
|
|
373
|
+
sql, params = query.render()
|
|
374
|
+
# ('DELETE FROM users WHERE users.id = ?', ['abc123'])
|
|
375
|
+
|
|
376
|
+
# Multiple conditions
|
|
377
|
+
query = Users.delete().where(Users.age < 18).where(Users.active == False)
|
|
378
|
+
|
|
379
|
+
# With RETURNING (Postgres/SQLite)
|
|
380
|
+
query = Users.delete().where(Users.id == 'abc123').returning()
|
|
381
|
+
# ('DELETE FROM users WHERE users.id = ? RETURNING *', ['abc123'])
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
## Database Compatibility
|
|
385
|
+
|
|
386
|
+
The query builder is database-agnostic - all methods are available regardless of which database you're using. It's your responsibility to use the appropriate methods for your database:
|
|
387
|
+
|
|
388
|
+
**PostgreSQL:**
|
|
389
|
+
- ✅ `.returning()` - RETURNING clause
|
|
390
|
+
- ✅ `.on_conflict_do_nothing()` - ON CONFLICT DO NOTHING
|
|
391
|
+
- ✅ `.on_conflict_update()` - ON CONFLICT DO UPDATE with EXCLUDED.*
|
|
392
|
+
- ❌ `.ignore()` - Not supported
|
|
393
|
+
- ❌ `.on_duplicate_key_update()` - Not supported
|
|
394
|
+
|
|
395
|
+
**MySQL:**
|
|
396
|
+
- ❌ `.returning()` - Not supported (MySQL limitation)
|
|
397
|
+
- ✅ `.ignore()` - INSERT IGNORE
|
|
398
|
+
- ✅ `.on_duplicate_key_update()` - ON DUPLICATE KEY UPDATE with VALUES()
|
|
399
|
+
- ❌ `.on_conflict_do_nothing()` - Not supported
|
|
400
|
+
- ❌ `.on_conflict_update()` - Not supported
|
|
401
|
+
|
|
402
|
+
**SQLite:**
|
|
403
|
+
- ✅ `.returning()` - RETURNING clause (SQLite 3.35+)
|
|
404
|
+
- ✅ `.on_conflict_do_nothing()` - ON CONFLICT DO NOTHING
|
|
405
|
+
- ✅ `.on_conflict_update()` - ON CONFLICT DO UPDATE
|
|
406
|
+
- ❌ `.ignore()` - Not supported
|
|
407
|
+
- ❌ `.on_duplicate_key_update()` - Not supported
|
|
408
|
+
|
|
409
|
+
If you use an unsupported method, your database will raise a syntax error when you execute the query.
|
|
410
|
+
|
|
411
|
+
## Mixing Query Builder with T-Strings
|
|
412
|
+
|
|
413
|
+
You can combine the query builder with raw t-strings for complex logic:
|
|
414
|
+
|
|
415
|
+
```python
|
|
416
|
+
from tsql.query_builder import Table, Column
|
|
417
|
+
|
|
418
|
+
class Users(Table):
|
|
419
|
+
id: Column
|
|
420
|
+
name: Column
|
|
421
|
+
age: Column
|
|
422
|
+
email: Column
|
|
423
|
+
|
|
424
|
+
# Start with query builder
|
|
425
|
+
query = Users.select(Users.id, Users.name, Users.email)
|
|
426
|
+
|
|
427
|
+
# Add structured condition
|
|
428
|
+
query = query.where(Users.age > 18)
|
|
429
|
+
|
|
430
|
+
# Add complex t-string condition for OR logic
|
|
431
|
+
search_term = "john"
|
|
432
|
+
name_col = str(Users.name)
|
|
433
|
+
email_col = str(Users.email)
|
|
434
|
+
complex_condition = t"{name_col:literal} LIKE '%' || {search_term} || '%' OR {email_col:literal} LIKE '%' || {search_term} || '%'"
|
|
435
|
+
query = query.where(complex_condition)
|
|
436
|
+
|
|
437
|
+
sql, params = query.render()
|
|
438
|
+
# SELECT users.id, users.name, users.email FROM users
|
|
439
|
+
# WHERE users.age > ? AND (users.name LIKE '%' || ? || '%' OR users.email LIKE '%' || ? || '%')
|
|
440
|
+
# params: [18, 'john', 'john']
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
Note: T-string conditions passed to `.where()` are automatically wrapped in parentheses to ensure proper operator precedence.
|
|
444
|
+
|
|
445
|
+
## SQLAlchemy & Alembic Integration
|
|
446
|
+
|
|
447
|
+
The query builder can integrate with SQLAlchemy's metadata system for alembic autogenerate:
|
|
448
|
+
|
|
449
|
+
```bash
|
|
450
|
+
pip install t-sql[sqlalchemy]
|
|
451
|
+
# or
|
|
452
|
+
uv add t-sql --optional sqlalchemy
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
### Two Ways to Define Columns
|
|
456
|
+
|
|
457
|
+
**1. Simple Column annotations** (for query builder only):
|
|
458
|
+
|
|
459
|
+
```python
|
|
460
|
+
from tsql import Table, Column
|
|
461
|
+
|
|
462
|
+
class Users(Table):
|
|
463
|
+
id: Column
|
|
464
|
+
name: Column
|
|
465
|
+
age: Column
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
**2. SQLAlchemy Column objects** (for alembic integration):
|
|
469
|
+
|
|
470
|
+
```python
|
|
471
|
+
from sqlalchemy import MetaData, Column, String, Integer, ForeignKey
|
|
472
|
+
from tsql.query_builder import Table
|
|
473
|
+
|
|
474
|
+
metadata = MetaData()
|
|
475
|
+
|
|
476
|
+
class Users(Table, metadata=metadata):
|
|
477
|
+
id = Column(String, primary_key=True)
|
|
478
|
+
email = Column(String(255), unique=True, nullable=False)
|
|
479
|
+
name = Column(String(100))
|
|
480
|
+
age = Column(Integer)
|
|
481
|
+
|
|
482
|
+
# Use for alembic
|
|
483
|
+
target_metadata = metadata
|
|
484
|
+
|
|
485
|
+
# Use for queries (works identically!)
|
|
486
|
+
query = Users.select().where(Users.age > 18)
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
You can mix both approaches:
|
|
490
|
+
|
|
491
|
+
```python
|
|
492
|
+
from sqlalchemy import Column, String, DateTime
|
|
493
|
+
from sqlalchemy.sql.functions import now
|
|
494
|
+
|
|
495
|
+
class Events(Table, metadata=metadata):
|
|
496
|
+
id = Column(String, primary_key=True)
|
|
497
|
+
topic: Column # Simple annotation - becomes nullable String column
|
|
498
|
+
created_at = Column(DateTime, server_default=now())
|
|
499
|
+
```
|
|
500
|
+
|
|
501
|
+
## Schema Support
|
|
502
|
+
|
|
503
|
+
```python
|
|
504
|
+
class Users(Table, schema='public'):
|
|
505
|
+
id: Column
|
|
506
|
+
name: Column
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
Or with custom table name and schema:
|
|
510
|
+
|
|
511
|
+
```python
|
|
512
|
+
class Users(Table, table_name='user_accounts', schema='public'):
|
|
513
|
+
id: Column
|
|
514
|
+
name: Column
|
|
515
|
+
```
|
|
516
|
+
|
|
517
|
+
# Note on Usage
|
|
518
|
+
|
|
519
|
+
This library should ideally be used in middleware or library code right before making a query. It can enforce the use of t-strings and prevent raw strings:
|
|
520
|
+
|
|
521
|
+
```python
|
|
522
|
+
from string.templatelib import Template
|
|
523
|
+
import tsql
|
|
524
|
+
|
|
525
|
+
def execute_sql_query(query):
|
|
526
|
+
if not isinstance(query, Template):
|
|
527
|
+
raise TypeError('Cannot make a query without using t-strings')
|
|
528
|
+
|
|
529
|
+
return sql_engine.execute(*tsql.render(query))
|
|
530
|
+
```
|