t-sql 3.0.0__tar.gz → 3.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {t_sql-3.0.0 → t_sql-3.2.0}/PKG-INFO +54 -31
- {t_sql-3.0.0 → t_sql-3.2.0}/README.md +53 -30
- {t_sql-3.0.0 → t_sql-3.2.0}/pyproject.toml +1 -1
- {t_sql-3.0.0 → t_sql-3.2.0}/tests/test_query_builder.py +81 -6
- {t_sql-3.0.0 → t_sql-3.2.0}/tests/test_sqlite_integration.py +1 -1
- {t_sql-3.0.0 → t_sql-3.2.0}/tsql/__init__.py +3 -0
- {t_sql-3.0.0 → t_sql-3.2.0}/tsql/query_builder.py +100 -4
- {t_sql-3.0.0 → t_sql-3.2.0}/.dockerignore +0 -0
- {t_sql-3.0.0 → t_sql-3.2.0}/.github/workflows/publish.yml +0 -0
- {t_sql-3.0.0 → t_sql-3.2.0}/.github/workflows/test.yml +0 -0
- {t_sql-3.0.0 → t_sql-3.2.0}/.gitignore +0 -0
- {t_sql-3.0.0 → t_sql-3.2.0}/Dockerfile +0 -0
- {t_sql-3.0.0 → t_sql-3.2.0}/LICENSE +0 -0
- {t_sql-3.0.0 → t_sql-3.2.0}/compose.yaml +0 -0
- {t_sql-3.0.0 → t_sql-3.2.0}/context7.json +0 -0
- {t_sql-3.0.0 → t_sql-3.2.0}/pytest.ini +0 -0
- {t_sql-3.0.0 → t_sql-3.2.0}/tests/test_alembic_integration.py +0 -0
- {t_sql-3.0.0 → t_sql-3.2.0}/tests/test_asyncpg_integration.py +0 -0
- {t_sql-3.0.0 → t_sql-3.2.0}/tests/test_different_object_types.py +0 -0
- {t_sql-3.0.0 → t_sql-3.2.0}/tests/test_escaped.py +0 -0
- {t_sql-3.0.0 → t_sql-3.2.0}/tests/test_escaped_binary_hex.py +0 -0
- {t_sql-3.0.0 → t_sql-3.2.0}/tests/test_helper_functions.py +0 -0
- {t_sql-3.0.0 → t_sql-3.2.0}/tests/test_injection_edge_cases.py +0 -0
- {t_sql-3.0.0 → t_sql-3.2.0}/tests/test_injection_protection_validation.py +0 -0
- {t_sql-3.0.0 → t_sql-3.2.0}/tests/test_injections_for_escaped.py +0 -0
- {t_sql-3.0.0 → t_sql-3.2.0}/tests/test_mysql_integration.py +0 -0
- {t_sql-3.0.0 → t_sql-3.2.0}/tests/test_parameter_names.py +0 -0
- {t_sql-3.0.0 → t_sql-3.2.0}/tests/test_sqlalchemy_integration.py +0 -0
- {t_sql-3.0.0 → t_sql-3.2.0}/tests/test_styles.py +0 -0
- {t_sql-3.0.0 → t_sql-3.2.0}/tests/test_tsql.py +0 -0
- {t_sql-3.0.0 → t_sql-3.2.0}/tsql/styles.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: t-sql
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.2.0
|
|
4
4
|
Summary: Safe SQL. SQL queries for python t-strings (PEP 750)
|
|
5
5
|
Project-URL: Homepage, https://github.com/nhumrich/t-sql
|
|
6
6
|
License-File: LICENSE
|
|
@@ -159,8 +159,7 @@ sql, params = query.render()
|
|
|
159
159
|
Quick INSERT queries:
|
|
160
160
|
|
|
161
161
|
```python
|
|
162
|
-
|
|
163
|
-
query = tsql.insert('users', values)
|
|
162
|
+
query = tsql.insert('users', id='abc123', name='bob', email='bob@example.com')
|
|
164
163
|
sql, params = query.render()
|
|
165
164
|
# ('INSERT INTO users (id, name, email) VALUES (?, ?, ?)', ['abc123', 'bob', 'bob@example.com'])
|
|
166
165
|
```
|
|
@@ -171,14 +170,9 @@ Quick UPDATE queries:
|
|
|
171
170
|
|
|
172
171
|
```python
|
|
173
172
|
# Update by ID
|
|
174
|
-
query = tsql.update('users',
|
|
173
|
+
query = tsql.update('users', 'abc123', email='new@example.com')
|
|
175
174
|
sql, params = query.render()
|
|
176
175
|
# ('UPDATE users SET email = ? WHERE id = ?', ['new@example.com', 'abc123'])
|
|
177
|
-
|
|
178
|
-
# Update with custom WHERE
|
|
179
|
-
query = tsql.update('users', {'email': 'new@example.com'}, where={'age': 25})
|
|
180
|
-
sql, params = query.render()
|
|
181
|
-
# ('UPDATE users SET email = ? WHERE age = ?', ['new@example.com', 25])
|
|
182
176
|
```
|
|
183
177
|
|
|
184
178
|
#### delete
|
|
@@ -271,8 +265,9 @@ query = Users.select().where(Users.id.in_([1, 2, 3]))
|
|
|
271
265
|
query = Users.select().where(Users.username.like('%john%'))
|
|
272
266
|
|
|
273
267
|
# ORDER BY
|
|
274
|
-
query = Posts.select().order_by(Posts.id)
|
|
275
|
-
query = Posts.select().order_by(
|
|
268
|
+
query = Posts.select().order_by(Posts.id) # defaults to ASC
|
|
269
|
+
query = Posts.select().order_by(Posts.id.desc())
|
|
270
|
+
query = Posts.select().order_by(Posts.created_at.asc(), Posts.id.desc())
|
|
276
271
|
|
|
277
272
|
# LIMIT and OFFSET
|
|
278
273
|
query = Posts.select().limit(10).offset(20)
|
|
@@ -291,47 +286,46 @@ The query builder supports INSERT, UPDATE, and DELETE with database-agnostic con
|
|
|
291
286
|
|
|
292
287
|
```python
|
|
293
288
|
# Basic insert
|
|
294
|
-
|
|
295
|
-
query = Users.insert(values)
|
|
289
|
+
query = Users.insert(id='abc123', username='john', email='john@example.com')
|
|
296
290
|
sql, params = query.render()
|
|
297
291
|
# ('INSERT INTO users (id, username, email) VALUES (?, ?, ?)', ['abc123', 'john', 'john@example.com'])
|
|
298
292
|
|
|
299
293
|
# INSERT with RETURNING (Postgres/SQLite)
|
|
300
|
-
query = Users.insert(
|
|
294
|
+
query = Users.insert(id='abc123', username='john', email='john@example.com').returning()
|
|
301
295
|
sql, params = query.render()
|
|
302
296
|
# ('INSERT INTO users (id, username, email) VALUES (?, ?, ?) RETURNING *', [...])
|
|
303
297
|
|
|
304
298
|
# INSERT IGNORE (MySQL)
|
|
305
|
-
query = Users.insert(
|
|
299
|
+
query = Users.insert(id='abc123', username='john', email='john@example.com').ignore()
|
|
306
300
|
sql, params = query.render()
|
|
307
301
|
# ('INSERT IGNORE INTO users (id, username, email) VALUES (?, ?, ?)', [...])
|
|
308
302
|
|
|
309
303
|
# ON CONFLICT DO NOTHING (Postgres/SQLite)
|
|
310
|
-
query = Users.insert(
|
|
304
|
+
query = Users.insert(id='abc123', username='john', email='john@example.com').on_conflict_do_nothing()
|
|
311
305
|
# ('INSERT INTO users (...) VALUES (...) ON CONFLICT DO NOTHING', [...])
|
|
312
306
|
|
|
313
307
|
# ON CONFLICT DO NOTHING with specific conflict target (Postgres/SQLite)
|
|
314
|
-
query = Users.insert(
|
|
308
|
+
query = Users.insert(id='abc123', username='john', email='john@example.com').on_conflict_do_nothing(conflict_on='email')
|
|
315
309
|
# ('INSERT INTO users (...) VALUES (...) ON CONFLICT (email) DO NOTHING', [...])
|
|
316
310
|
|
|
317
311
|
# ON CONFLICT DO UPDATE (Postgres/SQLite upsert)
|
|
318
|
-
query = Users.insert(
|
|
312
|
+
query = Users.insert(id='abc123', username='john', email='john@example.com').on_conflict_update(conflict_on='id')
|
|
319
313
|
# ('INSERT INTO users (...) VALUES (...)
|
|
320
314
|
# ON CONFLICT (id) DO UPDATE SET username = EXCLUDED.username, email = EXCLUDED.email', [...])
|
|
321
315
|
|
|
322
316
|
# ON CONFLICT with custom update
|
|
323
|
-
query = Users.insert(
|
|
317
|
+
query = Users.insert(id='abc123', username='john', email='john@example.com').on_conflict_update(
|
|
324
318
|
conflict_on='id',
|
|
325
319
|
update={'username': 'updated_name'}
|
|
326
320
|
)
|
|
327
321
|
|
|
328
322
|
# ON DUPLICATE KEY UPDATE (MySQL)
|
|
329
|
-
query = Users.insert(
|
|
323
|
+
query = Users.insert(id='abc123', username='john', email='john@example.com').on_duplicate_key_update()
|
|
330
324
|
# ('INSERT INTO users (...) VALUES (...)
|
|
331
325
|
# ON DUPLICATE KEY UPDATE id = VALUES(id), username = VALUES(username), ...', [...])
|
|
332
326
|
|
|
333
327
|
# Chain multiple modifiers
|
|
334
|
-
query = (Users.insert(
|
|
328
|
+
query = (Users.insert(id='abc123', username='john', email='john@example.com')
|
|
335
329
|
.on_conflict_update(conflict_on='id')
|
|
336
330
|
.returning('id', 'username'))
|
|
337
331
|
```
|
|
@@ -339,23 +333,27 @@ query = (Users.insert(values)
|
|
|
339
333
|
### UPDATE
|
|
340
334
|
|
|
341
335
|
```python
|
|
342
|
-
#
|
|
343
|
-
query = Users.update(
|
|
344
|
-
|
|
345
|
-
# ('UPDATE users SET email = ?', ['newemail@example.com'])
|
|
336
|
+
# UPDATE requires WHERE clause or explicit .all_rows() for safety
|
|
337
|
+
query = Users.update(email='newemail@example.com')
|
|
338
|
+
# ❌ Raises UnsafeQueryError: UPDATE without WHERE requires .all_rows()
|
|
346
339
|
|
|
347
340
|
# UPDATE with WHERE
|
|
348
|
-
query = Users.update(
|
|
341
|
+
query = Users.update(email='newemail@example.com').where(Users.id == 'abc123')
|
|
349
342
|
sql, params = query.render()
|
|
350
343
|
# ('UPDATE users SET email = ? WHERE users.id = ?', ['newemail@example.com', 'abc123'])
|
|
351
344
|
|
|
352
345
|
# Multiple WHERE conditions
|
|
353
|
-
query = (Users.update(
|
|
346
|
+
query = (Users.update(email='newemail@example.com')
|
|
354
347
|
.where(Users.id == 'abc123')
|
|
355
348
|
.where(Users.age > 18))
|
|
356
349
|
|
|
350
|
+
# Explicitly update all rows (use with caution!)
|
|
351
|
+
query = Users.update(status='inactive').all_rows()
|
|
352
|
+
sql, params = query.render()
|
|
353
|
+
# ('UPDATE users SET status = ?', ['inactive'])
|
|
354
|
+
|
|
357
355
|
# With RETURNING (Postgres/SQLite)
|
|
358
|
-
query = (Users.update(
|
|
356
|
+
query = (Users.update(email='new@example.com')
|
|
359
357
|
.where(Users.id == 'abc123')
|
|
360
358
|
.returning())
|
|
361
359
|
# ('UPDATE users SET email = ? WHERE users.id = ? RETURNING *', [...])
|
|
@@ -364,10 +362,9 @@ query = (Users.update({'email': 'new@example.com'})
|
|
|
364
362
|
### DELETE
|
|
365
363
|
|
|
366
364
|
```python
|
|
367
|
-
#
|
|
365
|
+
# DELETE requires WHERE clause or explicit .all_rows() for safety
|
|
368
366
|
query = Users.delete()
|
|
369
|
-
|
|
370
|
-
# ('DELETE FROM users', [])
|
|
367
|
+
# ❌ Raises UnsafeQueryError: DELETE without WHERE requires .all_rows()
|
|
371
368
|
|
|
372
369
|
# DELETE with WHERE
|
|
373
370
|
query = Users.delete().where(Users.id == 'abc123')
|
|
@@ -377,6 +374,11 @@ sql, params = query.render()
|
|
|
377
374
|
# Multiple conditions
|
|
378
375
|
query = Users.delete().where(Users.age < 18).where(Users.active == False)
|
|
379
376
|
|
|
377
|
+
# Explicitly delete all rows (use with extreme caution!)
|
|
378
|
+
query = Users.delete().all_rows()
|
|
379
|
+
sql, params = query.render()
|
|
380
|
+
# ('DELETE FROM users', [])
|
|
381
|
+
|
|
380
382
|
# With RETURNING (Postgres/SQLite)
|
|
381
383
|
query = Users.delete().where(Users.id == 'abc123').returning()
|
|
382
384
|
# ('DELETE FROM users WHERE users.id = ? RETURNING *', ['abc123'])
|
|
@@ -632,6 +634,27 @@ sql, _ = tsql.render(t"SELECT * FROM users WHERE name = {malicious}", style=tsql
|
|
|
632
634
|
|
|
633
635
|
**Important:** While effective, parameterization is always preferred when available. Use `ESCAPED` only when necessary.
|
|
634
636
|
|
|
637
|
+
### 4. Query Builder Safety: UPDATE/DELETE Protection
|
|
638
|
+
|
|
639
|
+
The query builder prevents accidental mass UPDATE/DELETE operations by requiring an explicit WHERE clause or `.all_rows()` call:
|
|
640
|
+
|
|
641
|
+
```python
|
|
642
|
+
from tsql import UnsafeQueryError
|
|
643
|
+
|
|
644
|
+
# This raises UnsafeQueryError at render time
|
|
645
|
+
Users.update(status='inactive').render() # ❌ Error!
|
|
646
|
+
Users.delete().render() # ❌ Error!
|
|
647
|
+
|
|
648
|
+
# Must add WHERE clause
|
|
649
|
+
Users.update(status='inactive').where(Users.id == user_id).render() # ✅
|
|
650
|
+
|
|
651
|
+
# Or explicitly confirm mass operation
|
|
652
|
+
Users.update(status='inactive').all_rows().render() # ✅
|
|
653
|
+
Users.delete().all_rows().render() # ✅
|
|
654
|
+
```
|
|
655
|
+
|
|
656
|
+
This protection catches the most common and dangerous SQL mistake: forgetting the WHERE clause.
|
|
657
|
+
|
|
635
658
|
## Danger Zones: Where You Can Still Get Hurt
|
|
636
659
|
|
|
637
660
|
### The :unsafe Format Spec
|
|
@@ -149,8 +149,7 @@ sql, params = query.render()
|
|
|
149
149
|
Quick INSERT queries:
|
|
150
150
|
|
|
151
151
|
```python
|
|
152
|
-
|
|
153
|
-
query = tsql.insert('users', values)
|
|
152
|
+
query = tsql.insert('users', id='abc123', name='bob', email='bob@example.com')
|
|
154
153
|
sql, params = query.render()
|
|
155
154
|
# ('INSERT INTO users (id, name, email) VALUES (?, ?, ?)', ['abc123', 'bob', 'bob@example.com'])
|
|
156
155
|
```
|
|
@@ -161,14 +160,9 @@ Quick UPDATE queries:
|
|
|
161
160
|
|
|
162
161
|
```python
|
|
163
162
|
# Update by ID
|
|
164
|
-
query = tsql.update('users',
|
|
163
|
+
query = tsql.update('users', 'abc123', email='new@example.com')
|
|
165
164
|
sql, params = query.render()
|
|
166
165
|
# ('UPDATE users SET email = ? WHERE id = ?', ['new@example.com', 'abc123'])
|
|
167
|
-
|
|
168
|
-
# Update with custom WHERE
|
|
169
|
-
query = tsql.update('users', {'email': 'new@example.com'}, where={'age': 25})
|
|
170
|
-
sql, params = query.render()
|
|
171
|
-
# ('UPDATE users SET email = ? WHERE age = ?', ['new@example.com', 25])
|
|
172
166
|
```
|
|
173
167
|
|
|
174
168
|
#### delete
|
|
@@ -261,8 +255,9 @@ query = Users.select().where(Users.id.in_([1, 2, 3]))
|
|
|
261
255
|
query = Users.select().where(Users.username.like('%john%'))
|
|
262
256
|
|
|
263
257
|
# ORDER BY
|
|
264
|
-
query = Posts.select().order_by(Posts.id)
|
|
265
|
-
query = Posts.select().order_by(
|
|
258
|
+
query = Posts.select().order_by(Posts.id) # defaults to ASC
|
|
259
|
+
query = Posts.select().order_by(Posts.id.desc())
|
|
260
|
+
query = Posts.select().order_by(Posts.created_at.asc(), Posts.id.desc())
|
|
266
261
|
|
|
267
262
|
# LIMIT and OFFSET
|
|
268
263
|
query = Posts.select().limit(10).offset(20)
|
|
@@ -281,47 +276,46 @@ The query builder supports INSERT, UPDATE, and DELETE with database-agnostic con
|
|
|
281
276
|
|
|
282
277
|
```python
|
|
283
278
|
# Basic insert
|
|
284
|
-
|
|
285
|
-
query = Users.insert(values)
|
|
279
|
+
query = Users.insert(id='abc123', username='john', email='john@example.com')
|
|
286
280
|
sql, params = query.render()
|
|
287
281
|
# ('INSERT INTO users (id, username, email) VALUES (?, ?, ?)', ['abc123', 'john', 'john@example.com'])
|
|
288
282
|
|
|
289
283
|
# INSERT with RETURNING (Postgres/SQLite)
|
|
290
|
-
query = Users.insert(
|
|
284
|
+
query = Users.insert(id='abc123', username='john', email='john@example.com').returning()
|
|
291
285
|
sql, params = query.render()
|
|
292
286
|
# ('INSERT INTO users (id, username, email) VALUES (?, ?, ?) RETURNING *', [...])
|
|
293
287
|
|
|
294
288
|
# INSERT IGNORE (MySQL)
|
|
295
|
-
query = Users.insert(
|
|
289
|
+
query = Users.insert(id='abc123', username='john', email='john@example.com').ignore()
|
|
296
290
|
sql, params = query.render()
|
|
297
291
|
# ('INSERT IGNORE INTO users (id, username, email) VALUES (?, ?, ?)', [...])
|
|
298
292
|
|
|
299
293
|
# ON CONFLICT DO NOTHING (Postgres/SQLite)
|
|
300
|
-
query = Users.insert(
|
|
294
|
+
query = Users.insert(id='abc123', username='john', email='john@example.com').on_conflict_do_nothing()
|
|
301
295
|
# ('INSERT INTO users (...) VALUES (...) ON CONFLICT DO NOTHING', [...])
|
|
302
296
|
|
|
303
297
|
# ON CONFLICT DO NOTHING with specific conflict target (Postgres/SQLite)
|
|
304
|
-
query = Users.insert(
|
|
298
|
+
query = Users.insert(id='abc123', username='john', email='john@example.com').on_conflict_do_nothing(conflict_on='email')
|
|
305
299
|
# ('INSERT INTO users (...) VALUES (...) ON CONFLICT (email) DO NOTHING', [...])
|
|
306
300
|
|
|
307
301
|
# ON CONFLICT DO UPDATE (Postgres/SQLite upsert)
|
|
308
|
-
query = Users.insert(
|
|
302
|
+
query = Users.insert(id='abc123', username='john', email='john@example.com').on_conflict_update(conflict_on='id')
|
|
309
303
|
# ('INSERT INTO users (...) VALUES (...)
|
|
310
304
|
# ON CONFLICT (id) DO UPDATE SET username = EXCLUDED.username, email = EXCLUDED.email', [...])
|
|
311
305
|
|
|
312
306
|
# ON CONFLICT with custom update
|
|
313
|
-
query = Users.insert(
|
|
307
|
+
query = Users.insert(id='abc123', username='john', email='john@example.com').on_conflict_update(
|
|
314
308
|
conflict_on='id',
|
|
315
309
|
update={'username': 'updated_name'}
|
|
316
310
|
)
|
|
317
311
|
|
|
318
312
|
# ON DUPLICATE KEY UPDATE (MySQL)
|
|
319
|
-
query = Users.insert(
|
|
313
|
+
query = Users.insert(id='abc123', username='john', email='john@example.com').on_duplicate_key_update()
|
|
320
314
|
# ('INSERT INTO users (...) VALUES (...)
|
|
321
315
|
# ON DUPLICATE KEY UPDATE id = VALUES(id), username = VALUES(username), ...', [...])
|
|
322
316
|
|
|
323
317
|
# Chain multiple modifiers
|
|
324
|
-
query = (Users.insert(
|
|
318
|
+
query = (Users.insert(id='abc123', username='john', email='john@example.com')
|
|
325
319
|
.on_conflict_update(conflict_on='id')
|
|
326
320
|
.returning('id', 'username'))
|
|
327
321
|
```
|
|
@@ -329,23 +323,27 @@ query = (Users.insert(values)
|
|
|
329
323
|
### UPDATE
|
|
330
324
|
|
|
331
325
|
```python
|
|
332
|
-
#
|
|
333
|
-
query = Users.update(
|
|
334
|
-
|
|
335
|
-
# ('UPDATE users SET email = ?', ['newemail@example.com'])
|
|
326
|
+
# UPDATE requires WHERE clause or explicit .all_rows() for safety
|
|
327
|
+
query = Users.update(email='newemail@example.com')
|
|
328
|
+
# ❌ Raises UnsafeQueryError: UPDATE without WHERE requires .all_rows()
|
|
336
329
|
|
|
337
330
|
# UPDATE with WHERE
|
|
338
|
-
query = Users.update(
|
|
331
|
+
query = Users.update(email='newemail@example.com').where(Users.id == 'abc123')
|
|
339
332
|
sql, params = query.render()
|
|
340
333
|
# ('UPDATE users SET email = ? WHERE users.id = ?', ['newemail@example.com', 'abc123'])
|
|
341
334
|
|
|
342
335
|
# Multiple WHERE conditions
|
|
343
|
-
query = (Users.update(
|
|
336
|
+
query = (Users.update(email='newemail@example.com')
|
|
344
337
|
.where(Users.id == 'abc123')
|
|
345
338
|
.where(Users.age > 18))
|
|
346
339
|
|
|
340
|
+
# Explicitly update all rows (use with caution!)
|
|
341
|
+
query = Users.update(status='inactive').all_rows()
|
|
342
|
+
sql, params = query.render()
|
|
343
|
+
# ('UPDATE users SET status = ?', ['inactive'])
|
|
344
|
+
|
|
347
345
|
# With RETURNING (Postgres/SQLite)
|
|
348
|
-
query = (Users.update(
|
|
346
|
+
query = (Users.update(email='new@example.com')
|
|
349
347
|
.where(Users.id == 'abc123')
|
|
350
348
|
.returning())
|
|
351
349
|
# ('UPDATE users SET email = ? WHERE users.id = ? RETURNING *', [...])
|
|
@@ -354,10 +352,9 @@ query = (Users.update({'email': 'new@example.com'})
|
|
|
354
352
|
### DELETE
|
|
355
353
|
|
|
356
354
|
```python
|
|
357
|
-
#
|
|
355
|
+
# DELETE requires WHERE clause or explicit .all_rows() for safety
|
|
358
356
|
query = Users.delete()
|
|
359
|
-
|
|
360
|
-
# ('DELETE FROM users', [])
|
|
357
|
+
# ❌ Raises UnsafeQueryError: DELETE without WHERE requires .all_rows()
|
|
361
358
|
|
|
362
359
|
# DELETE with WHERE
|
|
363
360
|
query = Users.delete().where(Users.id == 'abc123')
|
|
@@ -367,6 +364,11 @@ sql, params = query.render()
|
|
|
367
364
|
# Multiple conditions
|
|
368
365
|
query = Users.delete().where(Users.age < 18).where(Users.active == False)
|
|
369
366
|
|
|
367
|
+
# Explicitly delete all rows (use with extreme caution!)
|
|
368
|
+
query = Users.delete().all_rows()
|
|
369
|
+
sql, params = query.render()
|
|
370
|
+
# ('DELETE FROM users', [])
|
|
371
|
+
|
|
370
372
|
# With RETURNING (Postgres/SQLite)
|
|
371
373
|
query = Users.delete().where(Users.id == 'abc123').returning()
|
|
372
374
|
# ('DELETE FROM users WHERE users.id = ? RETURNING *', ['abc123'])
|
|
@@ -622,6 +624,27 @@ sql, _ = tsql.render(t"SELECT * FROM users WHERE name = {malicious}", style=tsql
|
|
|
622
624
|
|
|
623
625
|
**Important:** While effective, parameterization is always preferred when available. Use `ESCAPED` only when necessary.
|
|
624
626
|
|
|
627
|
+
### 4. Query Builder Safety: UPDATE/DELETE Protection
|
|
628
|
+
|
|
629
|
+
The query builder prevents accidental mass UPDATE/DELETE operations by requiring an explicit WHERE clause or `.all_rows()` call:
|
|
630
|
+
|
|
631
|
+
```python
|
|
632
|
+
from tsql import UnsafeQueryError
|
|
633
|
+
|
|
634
|
+
# This raises UnsafeQueryError at render time
|
|
635
|
+
Users.update(status='inactive').render() # ❌ Error!
|
|
636
|
+
Users.delete().render() # ❌ Error!
|
|
637
|
+
|
|
638
|
+
# Must add WHERE clause
|
|
639
|
+
Users.update(status='inactive').where(Users.id == user_id).render() # ✅
|
|
640
|
+
|
|
641
|
+
# Or explicitly confirm mass operation
|
|
642
|
+
Users.update(status='inactive').all_rows().render() # ✅
|
|
643
|
+
Users.delete().all_rows().render() # ✅
|
|
644
|
+
```
|
|
645
|
+
|
|
646
|
+
This protection catches the most common and dangerous SQL mistake: forgetting the WHERE clause.
|
|
647
|
+
|
|
625
648
|
## Danger Zones: Where You Can Still Get Hurt
|
|
626
649
|
|
|
627
650
|
### The :unsafe Format Spec
|
|
@@ -213,7 +213,7 @@ def test_order_by():
|
|
|
213
213
|
|
|
214
214
|
def test_order_by_desc():
|
|
215
215
|
"""Test ORDER BY with DESC"""
|
|
216
|
-
query = Users.select().order_by(
|
|
216
|
+
query = Users.select().order_by(Users.id.desc())
|
|
217
217
|
sql, params = query.render()
|
|
218
218
|
|
|
219
219
|
assert 'ORDER BY users.id DESC' in sql
|
|
@@ -221,7 +221,39 @@ def test_order_by_desc():
|
|
|
221
221
|
|
|
222
222
|
def test_order_by_multiple():
|
|
223
223
|
"""Test ORDER BY with multiple columns"""
|
|
224
|
-
query = Users.select().order_by(Users.username,
|
|
224
|
+
query = Users.select().order_by(Users.username, Users.id.desc())
|
|
225
|
+
sql, params = query.render()
|
|
226
|
+
|
|
227
|
+
assert 'ORDER BY users.username ASC, users.id DESC' in sql
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def test_order_by_asc_method():
|
|
231
|
+
"""Test ORDER BY with .asc() method"""
|
|
232
|
+
query = Users.select().order_by(Users.username.asc())
|
|
233
|
+
sql, params = query.render()
|
|
234
|
+
|
|
235
|
+
assert 'ORDER BY users.username ASC' in sql
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def test_order_by_desc_method():
|
|
239
|
+
"""Test ORDER BY with .desc() method"""
|
|
240
|
+
query = Users.select().order_by(Users.id.desc())
|
|
241
|
+
sql, params = query.render()
|
|
242
|
+
|
|
243
|
+
assert 'ORDER BY users.id DESC' in sql
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def test_order_by_mixed_methods():
|
|
247
|
+
"""Test ORDER BY with mixed .asc() and .desc() methods"""
|
|
248
|
+
query = Users.select().order_by(Users.username.asc(), Users.id.desc())
|
|
249
|
+
sql, params = query.render()
|
|
250
|
+
|
|
251
|
+
assert 'ORDER BY users.username ASC, users.id DESC' in sql
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def test_order_by_mixed_syntax():
|
|
255
|
+
"""Test ORDER BY with mixed method calls and bare columns"""
|
|
256
|
+
query = Users.select().order_by(Users.username, Users.id.desc())
|
|
225
257
|
sql, params = query.render()
|
|
226
258
|
|
|
227
259
|
assert 'ORDER BY users.username ASC, users.id DESC' in sql
|
|
@@ -242,7 +274,7 @@ def test_complex_query():
|
|
|
242
274
|
.join(Users, Posts.user_id == Users.id)
|
|
243
275
|
.where(Posts.id > 100)
|
|
244
276
|
.where(Users.id >= 5)
|
|
245
|
-
.order_by(
|
|
277
|
+
.order_by(Posts.id.desc())
|
|
246
278
|
.limit(20))
|
|
247
279
|
sql, params = query.render()
|
|
248
280
|
|
|
@@ -480,7 +512,7 @@ def test_complex_aggregation_query():
|
|
|
480
512
|
.where(Posts.id > 100)
|
|
481
513
|
.group_by(Posts.user_id)
|
|
482
514
|
.having(Posts.id > 5)
|
|
483
|
-
.order_by(
|
|
515
|
+
.order_by(Posts.user_id.desc())
|
|
484
516
|
.limit(10)
|
|
485
517
|
.offset(5))
|
|
486
518
|
sql, params = query.render()
|
|
@@ -1140,7 +1172,7 @@ def test_returning_cols_validation_update():
|
|
|
1140
1172
|
import pytest
|
|
1141
1173
|
|
|
1142
1174
|
# Test with malicious returning column
|
|
1143
|
-
query = Users.update(username='hacked')
|
|
1175
|
+
query = Users.update(username='hacked').all_rows()
|
|
1144
1176
|
builder = query.returning("id, (SELECT password FROM admin_users LIMIT 1) AS stolen")
|
|
1145
1177
|
|
|
1146
1178
|
with pytest.raises(ValueError, match="Invalid RETURNING column name"):
|
|
@@ -1159,7 +1191,7 @@ def test_returning_cols_validation_delete():
|
|
|
1159
1191
|
import pytest
|
|
1160
1192
|
|
|
1161
1193
|
# Test with malicious returning column
|
|
1162
|
-
query = Users.delete()
|
|
1194
|
+
query = Users.delete().all_rows()
|
|
1163
1195
|
builder = query.returning("* FROM users; DROP TABLE secrets; --")
|
|
1164
1196
|
|
|
1165
1197
|
with pytest.raises(ValueError, match="Invalid RETURNING column name"):
|
|
@@ -1190,3 +1222,46 @@ def test_conflict_cols_list_validation():
|
|
|
1190
1222
|
sql, params = builder2.render()
|
|
1191
1223
|
|
|
1192
1224
|
assert 'ON CONFLICT (id, email)' in sql
|
|
1225
|
+
|
|
1226
|
+
|
|
1227
|
+
def test_update_without_where_raises_error():
|
|
1228
|
+
"""Test that UPDATE without WHERE clause raises UnsafeQueryError"""
|
|
1229
|
+
import pytest
|
|
1230
|
+
from tsql.query_builder import UnsafeQueryError
|
|
1231
|
+
|
|
1232
|
+
builder = Users.update(username='updated')
|
|
1233
|
+
|
|
1234
|
+
with pytest.raises(UnsafeQueryError, match="UPDATE without WHERE clause requires explicit .all_rows()"):
|
|
1235
|
+
builder.render()
|
|
1236
|
+
|
|
1237
|
+
|
|
1238
|
+
def test_update_with_all_rows_works():
|
|
1239
|
+
"""Test that UPDATE with .all_rows() bypasses safety check"""
|
|
1240
|
+
builder = Users.update(username='updated').all_rows()
|
|
1241
|
+
sql, params = builder.render()
|
|
1242
|
+
|
|
1243
|
+
assert 'UPDATE users SET' in sql
|
|
1244
|
+
assert 'username = ?' in sql
|
|
1245
|
+
assert 'WHERE' not in sql
|
|
1246
|
+
assert params == ['updated']
|
|
1247
|
+
|
|
1248
|
+
|
|
1249
|
+
def test_delete_without_where_raises_error():
|
|
1250
|
+
"""Test that DELETE without WHERE clause raises UnsafeQueryError"""
|
|
1251
|
+
import pytest
|
|
1252
|
+
from tsql.query_builder import UnsafeQueryError
|
|
1253
|
+
|
|
1254
|
+
builder = Users.delete()
|
|
1255
|
+
|
|
1256
|
+
with pytest.raises(UnsafeQueryError, match="DELETE without WHERE clause requires explicit .all_rows()"):
|
|
1257
|
+
builder.render()
|
|
1258
|
+
|
|
1259
|
+
|
|
1260
|
+
def test_delete_with_all_rows_works():
|
|
1261
|
+
"""Test that DELETE with .all_rows() bypasses safety check"""
|
|
1262
|
+
builder = Users.delete().all_rows()
|
|
1263
|
+
sql, params = builder.render()
|
|
1264
|
+
|
|
1265
|
+
assert 'DELETE FROM users' in sql
|
|
1266
|
+
assert 'WHERE' not in sql
|
|
1267
|
+
assert params == []
|
|
@@ -331,7 +331,7 @@ async def test_query_builder_select(conn):
|
|
|
331
331
|
assert rows[0][0] == 'Alice'
|
|
332
332
|
|
|
333
333
|
# Test ORDER BY
|
|
334
|
-
query = TestUsers.select().order_by(
|
|
334
|
+
query = TestUsers.select().order_by(TestUsers.age.desc())
|
|
335
335
|
sql, params = query.render()
|
|
336
336
|
|
|
337
337
|
cursor = await conn.execute(sql, params)
|
|
@@ -360,6 +360,8 @@ def delete(table: str, id: str | int) -> TSQL:
|
|
|
360
360
|
return TSQL(t"DELETE FROM {table:literal} WHERE id = {id}")
|
|
361
361
|
|
|
362
362
|
|
|
363
|
+
from tsql.query_builder import UnsafeQueryError
|
|
364
|
+
|
|
363
365
|
__all__ = [
|
|
364
366
|
'TSQL',
|
|
365
367
|
'TSQLQuery',
|
|
@@ -370,5 +372,6 @@ __all__ = [
|
|
|
370
372
|
'update',
|
|
371
373
|
'delete',
|
|
372
374
|
'set_style',
|
|
375
|
+
'UnsafeQueryError',
|
|
373
376
|
]
|
|
374
377
|
|
|
@@ -5,6 +5,14 @@ from abc import ABC, abstractmethod
|
|
|
5
5
|
|
|
6
6
|
from tsql import TSQL, t_join
|
|
7
7
|
|
|
8
|
+
|
|
9
|
+
class UnsafeQueryError(Exception):
|
|
10
|
+
"""Raised when attempting to render an UPDATE or DELETE query without a WHERE clause.
|
|
11
|
+
|
|
12
|
+
To perform mass updates or deletes, explicitly call .all_rows() to confirm intent.
|
|
13
|
+
"""
|
|
14
|
+
pass
|
|
15
|
+
|
|
8
16
|
# Optional SQLAlchemy support
|
|
9
17
|
try:
|
|
10
18
|
from sqlalchemy import MetaData, Table as SATable, Column as SAColumn
|
|
@@ -16,6 +24,17 @@ except ImportError:
|
|
|
16
24
|
SAColumnType = None
|
|
17
25
|
|
|
18
26
|
|
|
27
|
+
class OrderByClause:
|
|
28
|
+
"""Represents a column with an ORDER BY direction (ASC/DESC)"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, column: 'Column', direction: str):
|
|
31
|
+
self.column = column
|
|
32
|
+
self.direction = direction.upper()
|
|
33
|
+
|
|
34
|
+
def __repr__(self) -> str:
|
|
35
|
+
return f"OrderByClause({self.column!r}, {self.direction!r})"
|
|
36
|
+
|
|
37
|
+
|
|
19
38
|
class Column:
|
|
20
39
|
"""Represents a bound column (table + column name) for building queries"""
|
|
21
40
|
|
|
@@ -140,6 +159,28 @@ class Column:
|
|
|
140
159
|
"""Create an IS NOT NULL condition"""
|
|
141
160
|
return Condition(self, 'IS NOT', None)
|
|
142
161
|
|
|
162
|
+
def asc(self) -> OrderByClause:
|
|
163
|
+
"""Create an ascending ORDER BY clause
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
OrderByClause for use in order_by()
|
|
167
|
+
|
|
168
|
+
Example:
|
|
169
|
+
Users.select().order_by(Users.username.asc())
|
|
170
|
+
"""
|
|
171
|
+
return OrderByClause(self, 'ASC')
|
|
172
|
+
|
|
173
|
+
def desc(self) -> OrderByClause:
|
|
174
|
+
"""Create a descending ORDER BY clause
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
OrderByClause for use in order_by()
|
|
178
|
+
|
|
179
|
+
Example:
|
|
180
|
+
Users.select().order_by(Users.created_at.desc())
|
|
181
|
+
"""
|
|
182
|
+
return OrderByClause(self, 'DESC')
|
|
183
|
+
|
|
143
184
|
|
|
144
185
|
class Table:
|
|
145
186
|
"""Base class for all table definitions. Provides query builder methods.
|
|
@@ -654,10 +695,27 @@ class UpdateBuilder(QueryBuilder):
|
|
|
654
695
|
|
|
655
696
|
self._conditions: List[Union[Condition, Template]] = []
|
|
656
697
|
self._returning_cols: Optional[List[str]] = None
|
|
698
|
+
self._requires_where: bool = True
|
|
657
699
|
|
|
658
700
|
def where(self, condition: Union[Condition, Template]) -> 'UpdateBuilder':
|
|
659
701
|
"""Add a WHERE condition (multiple calls are ANDed together)"""
|
|
660
702
|
self._conditions.append(condition)
|
|
703
|
+
self._requires_where = False
|
|
704
|
+
return self
|
|
705
|
+
|
|
706
|
+
def all_rows(self) -> 'UpdateBuilder':
|
|
707
|
+
"""Explicitly confirm intent to update all rows without a WHERE clause.
|
|
708
|
+
|
|
709
|
+
By default, UPDATE queries without WHERE clauses will raise UnsafeQueryError
|
|
710
|
+
at render time. Call this method to bypass that safety check.
|
|
711
|
+
|
|
712
|
+
Returns:
|
|
713
|
+
self for method chaining
|
|
714
|
+
|
|
715
|
+
Example:
|
|
716
|
+
Users.update(status='inactive').all_rows()
|
|
717
|
+
"""
|
|
718
|
+
self._requires_where = False
|
|
661
719
|
return self
|
|
662
720
|
|
|
663
721
|
def returning(self, *columns: str) -> 'UpdateBuilder':
|
|
@@ -703,6 +761,11 @@ class UpdateBuilder(QueryBuilder):
|
|
|
703
761
|
|
|
704
762
|
def render(self, style=None):
|
|
705
763
|
"""Convenience method to render the query directly"""
|
|
764
|
+
if self._requires_where:
|
|
765
|
+
raise UnsafeQueryError(
|
|
766
|
+
"UPDATE without WHERE clause requires explicit .all_rows() call to confirm intent. "
|
|
767
|
+
"This prevents accidentally updating all rows in the table."
|
|
768
|
+
)
|
|
706
769
|
return self.to_tsql().render(style)
|
|
707
770
|
|
|
708
771
|
def __repr__(self) -> str:
|
|
@@ -723,10 +786,27 @@ class DeleteBuilder(QueryBuilder):
|
|
|
723
786
|
self.base_table = base_table
|
|
724
787
|
self._conditions: List[Union[Condition, Template]] = []
|
|
725
788
|
self._returning_cols: Optional[List[str]] = None
|
|
789
|
+
self._requires_where: bool = True
|
|
726
790
|
|
|
727
791
|
def where(self, condition: Union[Condition, Template]) -> 'DeleteBuilder':
|
|
728
792
|
"""Add a WHERE condition (multiple calls are ANDed together)"""
|
|
729
793
|
self._conditions.append(condition)
|
|
794
|
+
self._requires_where = False
|
|
795
|
+
return self
|
|
796
|
+
|
|
797
|
+
def all_rows(self) -> 'DeleteBuilder':
|
|
798
|
+
"""Explicitly confirm intent to delete all rows without a WHERE clause.
|
|
799
|
+
|
|
800
|
+
By default, DELETE queries without WHERE clauses will raise UnsafeQueryError
|
|
801
|
+
at render time. Call this method to bypass that safety check.
|
|
802
|
+
|
|
803
|
+
Returns:
|
|
804
|
+
self for method chaining
|
|
805
|
+
|
|
806
|
+
Example:
|
|
807
|
+
Users.delete().all_rows()
|
|
808
|
+
"""
|
|
809
|
+
self._requires_where = False
|
|
730
810
|
return self
|
|
731
811
|
|
|
732
812
|
def returning(self, *columns: str) -> 'DeleteBuilder':
|
|
@@ -771,6 +851,11 @@ class DeleteBuilder(QueryBuilder):
|
|
|
771
851
|
|
|
772
852
|
def render(self, style=None):
|
|
773
853
|
"""Convenience method to render the query directly"""
|
|
854
|
+
if self._requires_where:
|
|
855
|
+
raise UnsafeQueryError(
|
|
856
|
+
"DELETE without WHERE clause requires explicit .all_rows() call to confirm intent. "
|
|
857
|
+
"This prevents accidentally deleting all rows in the table."
|
|
858
|
+
)
|
|
774
859
|
return self.to_tsql().render(style)
|
|
775
860
|
|
|
776
861
|
def __repr__(self) -> str:
|
|
@@ -838,11 +923,22 @@ class SelectQueryBuilder(QueryBuilder):
|
|
|
838
923
|
"""Add a RIGHT JOIN clause"""
|
|
839
924
|
return self.join(table, on, 'RIGHT')
|
|
840
925
|
|
|
841
|
-
def order_by(self, *columns: Union[Column,
|
|
842
|
-
"""Add ORDER BY clause
|
|
926
|
+
def order_by(self, *columns: Union[Column, OrderByClause]) -> 'SelectQueryBuilder':
|
|
927
|
+
"""Add ORDER BY clause
|
|
928
|
+
|
|
929
|
+
Args:
|
|
930
|
+
columns: Column objects or OrderByClause objects (from .asc()/.desc())
|
|
931
|
+
|
|
932
|
+
Examples:
|
|
933
|
+
# Using .asc() and .desc() methods
|
|
934
|
+
Users.select().order_by(Users.username.asc(), Users.id.desc())
|
|
935
|
+
|
|
936
|
+
# Bare column defaults to ASC
|
|
937
|
+
Users.select().order_by(Users.username)
|
|
938
|
+
"""
|
|
843
939
|
for col in columns:
|
|
844
|
-
if isinstance(col,
|
|
845
|
-
self._order_by_columns.append(col)
|
|
940
|
+
if isinstance(col, OrderByClause):
|
|
941
|
+
self._order_by_columns.append((col.column, col.direction))
|
|
846
942
|
else:
|
|
847
943
|
self._order_by_columns.append((col, 'ASC'))
|
|
848
944
|
return self
|
|
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
|