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.
Files changed (31) hide show
  1. {t_sql-3.0.0 → t_sql-3.2.0}/PKG-INFO +54 -31
  2. {t_sql-3.0.0 → t_sql-3.2.0}/README.md +53 -30
  3. {t_sql-3.0.0 → t_sql-3.2.0}/pyproject.toml +1 -1
  4. {t_sql-3.0.0 → t_sql-3.2.0}/tests/test_query_builder.py +81 -6
  5. {t_sql-3.0.0 → t_sql-3.2.0}/tests/test_sqlite_integration.py +1 -1
  6. {t_sql-3.0.0 → t_sql-3.2.0}/tsql/__init__.py +3 -0
  7. {t_sql-3.0.0 → t_sql-3.2.0}/tsql/query_builder.py +100 -4
  8. {t_sql-3.0.0 → t_sql-3.2.0}/.dockerignore +0 -0
  9. {t_sql-3.0.0 → t_sql-3.2.0}/.github/workflows/publish.yml +0 -0
  10. {t_sql-3.0.0 → t_sql-3.2.0}/.github/workflows/test.yml +0 -0
  11. {t_sql-3.0.0 → t_sql-3.2.0}/.gitignore +0 -0
  12. {t_sql-3.0.0 → t_sql-3.2.0}/Dockerfile +0 -0
  13. {t_sql-3.0.0 → t_sql-3.2.0}/LICENSE +0 -0
  14. {t_sql-3.0.0 → t_sql-3.2.0}/compose.yaml +0 -0
  15. {t_sql-3.0.0 → t_sql-3.2.0}/context7.json +0 -0
  16. {t_sql-3.0.0 → t_sql-3.2.0}/pytest.ini +0 -0
  17. {t_sql-3.0.0 → t_sql-3.2.0}/tests/test_alembic_integration.py +0 -0
  18. {t_sql-3.0.0 → t_sql-3.2.0}/tests/test_asyncpg_integration.py +0 -0
  19. {t_sql-3.0.0 → t_sql-3.2.0}/tests/test_different_object_types.py +0 -0
  20. {t_sql-3.0.0 → t_sql-3.2.0}/tests/test_escaped.py +0 -0
  21. {t_sql-3.0.0 → t_sql-3.2.0}/tests/test_escaped_binary_hex.py +0 -0
  22. {t_sql-3.0.0 → t_sql-3.2.0}/tests/test_helper_functions.py +0 -0
  23. {t_sql-3.0.0 → t_sql-3.2.0}/tests/test_injection_edge_cases.py +0 -0
  24. {t_sql-3.0.0 → t_sql-3.2.0}/tests/test_injection_protection_validation.py +0 -0
  25. {t_sql-3.0.0 → t_sql-3.2.0}/tests/test_injections_for_escaped.py +0 -0
  26. {t_sql-3.0.0 → t_sql-3.2.0}/tests/test_mysql_integration.py +0 -0
  27. {t_sql-3.0.0 → t_sql-3.2.0}/tests/test_parameter_names.py +0 -0
  28. {t_sql-3.0.0 → t_sql-3.2.0}/tests/test_sqlalchemy_integration.py +0 -0
  29. {t_sql-3.0.0 → t_sql-3.2.0}/tests/test_styles.py +0 -0
  30. {t_sql-3.0.0 → t_sql-3.2.0}/tests/test_tsql.py +0 -0
  31. {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.0.0
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
- values = {'id': 'abc123', 'name': 'bob', 'email': 'bob@example.com'}
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', {'email': 'new@example.com'}, id_value='abc123')
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((Posts.id, 'DESC'))
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
- values = {'id': 'abc123', 'username': 'john', 'email': 'john@example.com'}
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(values).returning()
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(values).ignore()
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(values).on_conflict_do_nothing()
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(values).on_conflict_do_nothing(conflict_on='email')
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(values).on_conflict_update(conflict_on='id')
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(values).on_conflict_update(
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(values).on_duplicate_key_update()
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(values)
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
- # Basic update (no WHERE = updates all rows!)
343
- query = Users.update({'email': 'newemail@example.com'})
344
- sql, params = query.render()
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({'email': 'newemail@example.com'}).where(Users.id == 'abc123')
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({'email': 'newemail@example.com'})
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({'email': 'new@example.com'})
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
- # Basic delete (no WHERE = deletes all rows!)
365
+ # DELETE requires WHERE clause or explicit .all_rows() for safety
368
366
  query = Users.delete()
369
- sql, params = query.render()
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
- values = {'id': 'abc123', 'name': 'bob', 'email': 'bob@example.com'}
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', {'email': 'new@example.com'}, id_value='abc123')
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((Posts.id, 'DESC'))
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
- values = {'id': 'abc123', 'username': 'john', 'email': 'john@example.com'}
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(values).returning()
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(values).ignore()
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(values).on_conflict_do_nothing()
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(values).on_conflict_do_nothing(conflict_on='email')
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(values).on_conflict_update(conflict_on='id')
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(values).on_conflict_update(
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(values).on_duplicate_key_update()
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(values)
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
- # Basic update (no WHERE = updates all rows!)
333
- query = Users.update({'email': 'newemail@example.com'})
334
- sql, params = query.render()
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({'email': 'newemail@example.com'}).where(Users.id == 'abc123')
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({'email': 'newemail@example.com'})
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({'email': 'new@example.com'})
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
- # Basic delete (no WHERE = deletes all rows!)
355
+ # DELETE requires WHERE clause or explicit .all_rows() for safety
358
356
  query = Users.delete()
359
- sql, params = query.render()
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "t-sql"
7
- version = "3.0.0"
7
+ version = "3.2.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"
@@ -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((Users.id, 'DESC'))
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, (Users.id, 'DESC'))
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((Posts.id, 'DESC'))
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((Posts.user_id, 'DESC'))
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((TestUsers.age, 'DESC'))
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, tuple[Column, str]]) -> 'SelectQueryBuilder':
842
- """Add ORDER BY clause. Pass (column, 'DESC') for descending"""
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, tuple):
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