t-sql 3.1.0__tar.gz → 4.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.
Files changed (31) hide show
  1. {t_sql-3.1.0 → t_sql-4.0.0}/PKG-INFO +71 -7
  2. {t_sql-3.1.0 → t_sql-4.0.0}/README.md +70 -6
  3. {t_sql-3.1.0 → t_sql-4.0.0}/pyproject.toml +1 -1
  4. {t_sql-3.1.0 → t_sql-4.0.0}/tests/test_query_builder.py +112 -11
  5. {t_sql-3.1.0 → t_sql-4.0.0}/tests/test_sqlalchemy_integration.py +32 -0
  6. {t_sql-3.1.0 → t_sql-4.0.0}/tsql/__init__.py +3 -0
  7. {t_sql-3.1.0 → t_sql-4.0.0}/tsql/query_builder.py +89 -12
  8. {t_sql-3.1.0 → t_sql-4.0.0}/.dockerignore +0 -0
  9. {t_sql-3.1.0 → t_sql-4.0.0}/.github/workflows/publish.yml +0 -0
  10. {t_sql-3.1.0 → t_sql-4.0.0}/.github/workflows/test.yml +0 -0
  11. {t_sql-3.1.0 → t_sql-4.0.0}/.gitignore +0 -0
  12. {t_sql-3.1.0 → t_sql-4.0.0}/Dockerfile +0 -0
  13. {t_sql-3.1.0 → t_sql-4.0.0}/LICENSE +0 -0
  14. {t_sql-3.1.0 → t_sql-4.0.0}/compose.yaml +0 -0
  15. {t_sql-3.1.0 → t_sql-4.0.0}/context7.json +0 -0
  16. {t_sql-3.1.0 → t_sql-4.0.0}/pytest.ini +0 -0
  17. {t_sql-3.1.0 → t_sql-4.0.0}/tests/test_alembic_integration.py +0 -0
  18. {t_sql-3.1.0 → t_sql-4.0.0}/tests/test_asyncpg_integration.py +0 -0
  19. {t_sql-3.1.0 → t_sql-4.0.0}/tests/test_different_object_types.py +0 -0
  20. {t_sql-3.1.0 → t_sql-4.0.0}/tests/test_escaped.py +0 -0
  21. {t_sql-3.1.0 → t_sql-4.0.0}/tests/test_escaped_binary_hex.py +0 -0
  22. {t_sql-3.1.0 → t_sql-4.0.0}/tests/test_helper_functions.py +0 -0
  23. {t_sql-3.1.0 → t_sql-4.0.0}/tests/test_injection_edge_cases.py +0 -0
  24. {t_sql-3.1.0 → t_sql-4.0.0}/tests/test_injection_protection_validation.py +0 -0
  25. {t_sql-3.1.0 → t_sql-4.0.0}/tests/test_injections_for_escaped.py +0 -0
  26. {t_sql-3.1.0 → t_sql-4.0.0}/tests/test_mysql_integration.py +0 -0
  27. {t_sql-3.1.0 → t_sql-4.0.0}/tests/test_parameter_names.py +0 -0
  28. {t_sql-3.1.0 → t_sql-4.0.0}/tests/test_sqlite_integration.py +0 -0
  29. {t_sql-3.1.0 → t_sql-4.0.0}/tests/test_styles.py +0 -0
  30. {t_sql-3.1.0 → t_sql-4.0.0}/tests/test_tsql.py +0 -0
  31. {t_sql-3.1.0 → t_sql-4.0.0}/tsql/styles.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: t-sql
3
- Version: 3.1.0
3
+ Version: 4.0.0
4
4
  Summary: Safe SQL. SQL queries for python t-strings (PEP 750)
5
5
  Project-URL: Homepage, https://github.com/nhumrich/t-sql
6
6
  License-File: LICENSE
@@ -257,12 +257,47 @@ query = (Posts.select()
257
257
 
258
258
  ## Query Features
259
259
 
260
+ ### Selecting All Columns from a Table
261
+
262
+ Use `Table.ALL` to select all columns from a specific table:
263
+
264
+ ```python
265
+ # Select all columns from posts
266
+ query = Posts.select(Posts.ALL)
267
+ # ('SELECT posts.* FROM posts', [])
268
+
269
+ # Select all columns from posts + specific columns from joined tables
270
+ query = (Posts.select(Posts.ALL, Users.username, Users.email)
271
+ .join(Users, Posts.user_id == Users.id))
272
+ # ('SELECT posts.*, users.username, users.email FROM posts INNER JOIN users ON ...', [])
273
+
274
+ # Select all columns from multiple tables
275
+ query = Posts.select(Posts.ALL, Users.ALL).join(Users, Posts.user_id == Users.id)
276
+ # ('SELECT posts.*, users.* FROM posts INNER JOIN users ON ...', [])
277
+ ```
278
+
279
+ This is particularly useful when joining tables where you want all columns from one table but only specific columns from others.
280
+
281
+ ### NULL Checks and Other Operators
282
+
260
283
  ```python
284
+ # NULL checks
285
+ query = Users.select().where(Users.email.is_null())
286
+ query = Users.select().where(Users.email.is_not_null())
287
+
261
288
  # IN clause
262
289
  query = Users.select().where(Users.id.in_([1, 2, 3]))
290
+ query = Users.select().where(Users.id.not_in([1, 2, 3]))
263
291
 
264
292
  # LIKE clause
265
293
  query = Users.select().where(Users.username.like('%john%'))
294
+ query = Users.select().where(Users.username.not_like('%john%'))
295
+ query = Users.select().where(Users.username.ilike('%JOHN%')) # case-insensitive
296
+ query = Users.select().where(Users.username.not_ilike('%JOHN%'))
297
+
298
+ # BETWEEN clause
299
+ query = Users.select().where(Users.age.between(18, 65))
300
+ query = Users.select().where(Users.age.not_between(18, 65))
266
301
 
267
302
  # ORDER BY
268
303
  query = Posts.select().order_by(Posts.id) # defaults to ASC
@@ -333,10 +368,9 @@ query = (Users.insert(id='abc123', username='john', email='john@example.com')
333
368
  ### UPDATE
334
369
 
335
370
  ```python
336
- # Basic update (no WHERE = updates all rows!)
371
+ # UPDATE requires WHERE clause or explicit .all_rows() for safety
337
372
  query = Users.update(email='newemail@example.com')
338
- sql, params = query.render()
339
- # ('UPDATE users SET email = ?', ['newemail@example.com'])
373
+ # Raises UnsafeQueryError: UPDATE without WHERE requires .all_rows()
340
374
 
341
375
  # UPDATE with WHERE
342
376
  query = Users.update(email='newemail@example.com').where(Users.id == 'abc123')
@@ -348,6 +382,11 @@ query = (Users.update(email='newemail@example.com')
348
382
  .where(Users.id == 'abc123')
349
383
  .where(Users.age > 18))
350
384
 
385
+ # Explicitly update all rows (use with caution!)
386
+ query = Users.update(status='inactive').all_rows()
387
+ sql, params = query.render()
388
+ # ('UPDATE users SET status = ?', ['inactive'])
389
+
351
390
  # With RETURNING (Postgres/SQLite)
352
391
  query = (Users.update(email='new@example.com')
353
392
  .where(Users.id == 'abc123')
@@ -358,10 +397,9 @@ query = (Users.update(email='new@example.com')
358
397
  ### DELETE
359
398
 
360
399
  ```python
361
- # Basic delete (no WHERE = deletes all rows!)
400
+ # DELETE requires WHERE clause or explicit .all_rows() for safety
362
401
  query = Users.delete()
363
- sql, params = query.render()
364
- # ('DELETE FROM users', [])
402
+ # Raises UnsafeQueryError: DELETE without WHERE requires .all_rows()
365
403
 
366
404
  # DELETE with WHERE
367
405
  query = Users.delete().where(Users.id == 'abc123')
@@ -371,6 +409,11 @@ sql, params = query.render()
371
409
  # Multiple conditions
372
410
  query = Users.delete().where(Users.age < 18).where(Users.active == False)
373
411
 
412
+ # Explicitly delete all rows (use with extreme caution!)
413
+ query = Users.delete().all_rows()
414
+ sql, params = query.render()
415
+ # ('DELETE FROM users', [])
416
+
374
417
  # With RETURNING (Postgres/SQLite)
375
418
  query = Users.delete().where(Users.id == 'abc123').returning()
376
419
  # ('DELETE FROM users WHERE users.id = ? RETURNING *', ['abc123'])
@@ -626,6 +669,27 @@ sql, _ = tsql.render(t"SELECT * FROM users WHERE name = {malicious}", style=tsql
626
669
 
627
670
  **Important:** While effective, parameterization is always preferred when available. Use `ESCAPED` only when necessary.
628
671
 
672
+ ### 4. Query Builder Safety: UPDATE/DELETE Protection
673
+
674
+ The query builder prevents accidental mass UPDATE/DELETE operations by requiring an explicit WHERE clause or `.all_rows()` call:
675
+
676
+ ```python
677
+ from tsql import UnsafeQueryError
678
+
679
+ # This raises UnsafeQueryError at render time
680
+ Users.update(status='inactive').render() # ❌ Error!
681
+ Users.delete().render() # ❌ Error!
682
+
683
+ # Must add WHERE clause
684
+ Users.update(status='inactive').where(Users.id == user_id).render() # ✅
685
+
686
+ # Or explicitly confirm mass operation
687
+ Users.update(status='inactive').all_rows().render() # ✅
688
+ Users.delete().all_rows().render() # ✅
689
+ ```
690
+
691
+ This protection catches the most common and dangerous SQL mistake: forgetting the WHERE clause.
692
+
629
693
  ## Danger Zones: Where You Can Still Get Hurt
630
694
 
631
695
  ### The :unsafe Format Spec
@@ -247,12 +247,47 @@ query = (Posts.select()
247
247
 
248
248
  ## Query Features
249
249
 
250
+ ### Selecting All Columns from a Table
251
+
252
+ Use `Table.ALL` to select all columns from a specific table:
253
+
254
+ ```python
255
+ # Select all columns from posts
256
+ query = Posts.select(Posts.ALL)
257
+ # ('SELECT posts.* FROM posts', [])
258
+
259
+ # Select all columns from posts + specific columns from joined tables
260
+ query = (Posts.select(Posts.ALL, Users.username, Users.email)
261
+ .join(Users, Posts.user_id == Users.id))
262
+ # ('SELECT posts.*, users.username, users.email FROM posts INNER JOIN users ON ...', [])
263
+
264
+ # Select all columns from multiple tables
265
+ query = Posts.select(Posts.ALL, Users.ALL).join(Users, Posts.user_id == Users.id)
266
+ # ('SELECT posts.*, users.* FROM posts INNER JOIN users ON ...', [])
267
+ ```
268
+
269
+ This is particularly useful when joining tables where you want all columns from one table but only specific columns from others.
270
+
271
+ ### NULL Checks and Other Operators
272
+
250
273
  ```python
274
+ # NULL checks
275
+ query = Users.select().where(Users.email.is_null())
276
+ query = Users.select().where(Users.email.is_not_null())
277
+
251
278
  # IN clause
252
279
  query = Users.select().where(Users.id.in_([1, 2, 3]))
280
+ query = Users.select().where(Users.id.not_in([1, 2, 3]))
253
281
 
254
282
  # LIKE clause
255
283
  query = Users.select().where(Users.username.like('%john%'))
284
+ query = Users.select().where(Users.username.not_like('%john%'))
285
+ query = Users.select().where(Users.username.ilike('%JOHN%')) # case-insensitive
286
+ query = Users.select().where(Users.username.not_ilike('%JOHN%'))
287
+
288
+ # BETWEEN clause
289
+ query = Users.select().where(Users.age.between(18, 65))
290
+ query = Users.select().where(Users.age.not_between(18, 65))
256
291
 
257
292
  # ORDER BY
258
293
  query = Posts.select().order_by(Posts.id) # defaults to ASC
@@ -323,10 +358,9 @@ query = (Users.insert(id='abc123', username='john', email='john@example.com')
323
358
  ### UPDATE
324
359
 
325
360
  ```python
326
- # Basic update (no WHERE = updates all rows!)
361
+ # UPDATE requires WHERE clause or explicit .all_rows() for safety
327
362
  query = Users.update(email='newemail@example.com')
328
- sql, params = query.render()
329
- # ('UPDATE users SET email = ?', ['newemail@example.com'])
363
+ # Raises UnsafeQueryError: UPDATE without WHERE requires .all_rows()
330
364
 
331
365
  # UPDATE with WHERE
332
366
  query = Users.update(email='newemail@example.com').where(Users.id == 'abc123')
@@ -338,6 +372,11 @@ query = (Users.update(email='newemail@example.com')
338
372
  .where(Users.id == 'abc123')
339
373
  .where(Users.age > 18))
340
374
 
375
+ # Explicitly update all rows (use with caution!)
376
+ query = Users.update(status='inactive').all_rows()
377
+ sql, params = query.render()
378
+ # ('UPDATE users SET status = ?', ['inactive'])
379
+
341
380
  # With RETURNING (Postgres/SQLite)
342
381
  query = (Users.update(email='new@example.com')
343
382
  .where(Users.id == 'abc123')
@@ -348,10 +387,9 @@ query = (Users.update(email='new@example.com')
348
387
  ### DELETE
349
388
 
350
389
  ```python
351
- # Basic delete (no WHERE = deletes all rows!)
390
+ # DELETE requires WHERE clause or explicit .all_rows() for safety
352
391
  query = Users.delete()
353
- sql, params = query.render()
354
- # ('DELETE FROM users', [])
392
+ # Raises UnsafeQueryError: DELETE without WHERE requires .all_rows()
355
393
 
356
394
  # DELETE with WHERE
357
395
  query = Users.delete().where(Users.id == 'abc123')
@@ -361,6 +399,11 @@ sql, params = query.render()
361
399
  # Multiple conditions
362
400
  query = Users.delete().where(Users.age < 18).where(Users.active == False)
363
401
 
402
+ # Explicitly delete all rows (use with extreme caution!)
403
+ query = Users.delete().all_rows()
404
+ sql, params = query.render()
405
+ # ('DELETE FROM users', [])
406
+
364
407
  # With RETURNING (Postgres/SQLite)
365
408
  query = Users.delete().where(Users.id == 'abc123').returning()
366
409
  # ('DELETE FROM users WHERE users.id = ? RETURNING *', ['abc123'])
@@ -616,6 +659,27 @@ sql, _ = tsql.render(t"SELECT * FROM users WHERE name = {malicious}", style=tsql
616
659
 
617
660
  **Important:** While effective, parameterization is always preferred when available. Use `ESCAPED` only when necessary.
618
661
 
662
+ ### 4. Query Builder Safety: UPDATE/DELETE Protection
663
+
664
+ The query builder prevents accidental mass UPDATE/DELETE operations by requiring an explicit WHERE clause or `.all_rows()` call:
665
+
666
+ ```python
667
+ from tsql import UnsafeQueryError
668
+
669
+ # This raises UnsafeQueryError at render time
670
+ Users.update(status='inactive').render() # ❌ Error!
671
+ Users.delete().render() # ❌ Error!
672
+
673
+ # Must add WHERE clause
674
+ Users.update(status='inactive').where(Users.id == user_id).render() # ✅
675
+
676
+ # Or explicitly confirm mass operation
677
+ Users.update(status='inactive').all_rows().render() # ✅
678
+ Users.delete().all_rows().render() # ✅
679
+ ```
680
+
681
+ This protection catches the most common and dangerous SQL mistake: forgetting the WHERE clause.
682
+
619
683
  ## Danger Zones: Where You Can Still Get Hurt
620
684
 
621
685
  ### 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.1.0"
7
+ version = "4.0.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"
@@ -838,23 +838,81 @@ def test_tstring_with_params_in_select():
838
838
  assert params == [' - ', 5]
839
839
 
840
840
 
841
- def test_column_is_null_property():
842
- """Test is_null property"""
843
- condition = Users.email.is_null
841
+ def test_select_table_all():
842
+ """Test selecting all columns from a table using Table.ALL"""
843
+ query = Posts.select(Posts.ALL)
844
+ sql, params = query.render()
845
+
846
+ assert 'SELECT posts.*' in sql
847
+ assert 'FROM posts' in sql
848
+ assert params == []
849
+
850
+
851
+ def test_select_table_all_with_other_columns():
852
+ """Test mixing Table.ALL with specific columns from another table"""
853
+ query = Posts.select(Posts.ALL, Users.username, Users.email)
854
+ sql, params = query.render()
855
+
856
+ assert 'SELECT posts.*, users.username, users.email' in sql
857
+ assert 'FROM posts' in sql
858
+ assert params == []
859
+
860
+
861
+ def test_select_multiple_table_alls():
862
+ """Test selecting all columns from multiple tables"""
863
+ query = Posts.select(Posts.ALL, Users.ALL)
864
+ sql, params = query.render()
865
+
866
+ assert 'SELECT posts.*, users.*' in sql
867
+ assert 'FROM posts' in sql
868
+ assert params == []
869
+
870
+
871
+ def test_select_table_all_with_join():
872
+ """Test Table.ALL in a real-world join scenario"""
873
+ query = (Posts.select(Posts.ALL, Users.username)
874
+ .join(Users, Posts.user_id == Users.id)
875
+ .where(Posts.id > 100))
876
+ sql, params = query.render()
877
+
878
+ assert 'SELECT posts.*, users.username' in sql
879
+ assert 'FROM posts' in sql
880
+ assert 'INNER JOIN users ON posts.user_id = users.id' in sql
881
+ assert 'WHERE posts.id > ?' in sql
882
+ assert params == [100]
883
+
884
+
885
+ def test_select_table_all_with_schema():
886
+ """Test that Table.ALL works with schema-qualified tables"""
887
+ class Accounts(Table, table_name='accounts', schema='other'):
888
+ id: Column
889
+ name: Column
890
+
891
+ query = Accounts.select(Accounts.ALL)
892
+ sql, params = query.render()
893
+
894
+ assert 'SELECT other.accounts.*' in sql
895
+ assert 'FROM other.accounts' in sql
896
+ assert params == []
897
+
898
+
899
+ def test_column_is_null_method():
900
+ """Test is_null method"""
901
+ condition = Users.email.is_null()
844
902
  assert condition.operator == 'IS'
845
903
  assert condition.right is None
846
904
 
847
905
 
848
- def test_column_is_not_null_property():
849
- """Test is_not_null property"""
850
- condition = Users.email.is_not_null
906
+ def test_column_is_not_null_method():
907
+ """Test is_not_null method"""
908
+ condition = Users.email.is_not_null()
851
909
  assert condition.operator == 'IS NOT'
852
910
  assert condition.right is None
853
911
 
854
912
 
855
913
  def test_where_with_is_null():
856
914
  """Test WHERE with is_null"""
857
- query = Users.select().where(Users.email.is_null)
915
+ query = Users.select().where(Users.email.is_null())
858
916
  sql, params = query.render()
859
917
 
860
918
  assert 'WHERE users.email IS NULL' in sql
@@ -863,7 +921,7 @@ def test_where_with_is_null():
863
921
 
864
922
  def test_where_with_is_not_null():
865
923
  """Test WHERE with is_not_null"""
866
- query = Users.select().where(Users.email.is_not_null)
924
+ query = Users.select().where(Users.email.is_not_null())
867
925
  sql, params = query.render()
868
926
 
869
927
  assert 'WHERE users.email IS NOT NULL' in sql
@@ -1002,7 +1060,7 @@ def test_complex_query_with_new_operators():
1002
1060
  """Test complex query using multiple new operators"""
1003
1061
  query = (Users.select()
1004
1062
  .where(Users.id.between(1, 100))
1005
- .where(Users.email.is_not_null)
1063
+ .where(Users.email.is_not_null())
1006
1064
  .where(Users.id.not_in([5, 10, 15]))
1007
1065
  .where(Users.username.ilike('%test%')))
1008
1066
  sql, params = query.render()
@@ -1172,7 +1230,7 @@ def test_returning_cols_validation_update():
1172
1230
  import pytest
1173
1231
 
1174
1232
  # Test with malicious returning column
1175
- query = Users.update(username='hacked')
1233
+ query = Users.update(username='hacked').all_rows()
1176
1234
  builder = query.returning("id, (SELECT password FROM admin_users LIMIT 1) AS stolen")
1177
1235
 
1178
1236
  with pytest.raises(ValueError, match="Invalid RETURNING column name"):
@@ -1191,7 +1249,7 @@ def test_returning_cols_validation_delete():
1191
1249
  import pytest
1192
1250
 
1193
1251
  # Test with malicious returning column
1194
- query = Users.delete()
1252
+ query = Users.delete().all_rows()
1195
1253
  builder = query.returning("* FROM users; DROP TABLE secrets; --")
1196
1254
 
1197
1255
  with pytest.raises(ValueError, match="Invalid RETURNING column name"):
@@ -1222,3 +1280,46 @@ def test_conflict_cols_list_validation():
1222
1280
  sql, params = builder2.render()
1223
1281
 
1224
1282
  assert 'ON CONFLICT (id, email)' in sql
1283
+
1284
+
1285
+ def test_update_without_where_raises_error():
1286
+ """Test that UPDATE without WHERE clause raises UnsafeQueryError"""
1287
+ import pytest
1288
+ from tsql.query_builder import UnsafeQueryError
1289
+
1290
+ builder = Users.update(username='updated')
1291
+
1292
+ with pytest.raises(UnsafeQueryError, match="UPDATE without WHERE clause requires explicit .all_rows()"):
1293
+ builder.render()
1294
+
1295
+
1296
+ def test_update_with_all_rows_works():
1297
+ """Test that UPDATE with .all_rows() bypasses safety check"""
1298
+ builder = Users.update(username='updated').all_rows()
1299
+ sql, params = builder.render()
1300
+
1301
+ assert 'UPDATE users SET' in sql
1302
+ assert 'username = ?' in sql
1303
+ assert 'WHERE' not in sql
1304
+ assert params == ['updated']
1305
+
1306
+
1307
+ def test_delete_without_where_raises_error():
1308
+ """Test that DELETE without WHERE clause raises UnsafeQueryError"""
1309
+ import pytest
1310
+ from tsql.query_builder import UnsafeQueryError
1311
+
1312
+ builder = Users.delete()
1313
+
1314
+ with pytest.raises(UnsafeQueryError, match="DELETE without WHERE clause requires explicit .all_rows()"):
1315
+ builder.render()
1316
+
1317
+
1318
+ def test_delete_with_all_rows_works():
1319
+ """Test that DELETE with .all_rows() bypasses safety check"""
1320
+ builder = Users.delete().all_rows()
1321
+ sql, params = builder.render()
1322
+
1323
+ assert 'DELETE FROM users' in sql
1324
+ assert 'WHERE' not in sql
1325
+ assert params == []
@@ -229,6 +229,38 @@ def test_mixing_query_builder_with_tsql():
229
229
  assert params == [18, 'john', 'john', 25]
230
230
 
231
231
 
232
+ def test_sa_column_annotations_are_correct_type():
233
+ """Test that SA Column assignments get correct type annotations for IDE autocomplete"""
234
+ from tsql.query_builder import Column as TsqlColumn
235
+
236
+ metadata = MetaData()
237
+
238
+ class MyTable(Table, table_name='mytable', metadata=metadata):
239
+ my_column = Column(TIMESTAMP())
240
+ another = Column(Integer())
241
+ text_field = Column(String(100))
242
+
243
+ # Verify that __annotations__ has been updated to reflect tsql.Column
244
+ assert 'my_column' in MyTable.__annotations__
245
+ assert 'another' in MyTable.__annotations__
246
+ assert 'text_field' in MyTable.__annotations__
247
+
248
+ assert MyTable.__annotations__['my_column'] == TsqlColumn
249
+ assert MyTable.__annotations__['another'] == TsqlColumn
250
+ assert MyTable.__annotations__['text_field'] == TsqlColumn
251
+
252
+ # Verify that the columns actually work as tsql.Column objects
253
+ col = MyTable.my_column
254
+ assert isinstance(col, TsqlColumn)
255
+ assert hasattr(col, 'is_null')
256
+ assert hasattr(col, 'asc')
257
+ assert hasattr(col, 'desc')
258
+
259
+ # Verify is_null works
260
+ condition = col.is_null()
261
+ assert condition.operator == 'IS'
262
+ assert condition.right is None
263
+
232
264
  def gen_id(prefix):
233
265
  """Dummy function for test"""
234
266
  return f"{prefix}_123"
@@ -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
@@ -30,10 +38,9 @@ class OrderByClause:
30
38
  class Column:
31
39
  """Represents a bound column (table + column name) for building queries"""
32
40
 
33
- def __init__(self, table_name: str = None, column_name: str = None, python_type: type = None, alias: str = None, schema: str = None):
41
+ def __init__(self, table_name: str = None, column_name: str = None, alias: str = None, schema: str = None):
34
42
  self.table_name = table_name
35
43
  self.column_name = column_name
36
- self.python_type = python_type
37
44
  self.alias = alias
38
45
  self.schema = schema
39
46
 
@@ -63,7 +70,7 @@ class Column:
63
70
  Example:
64
71
  users.select(users.first_name.as_('first'), users.last_name.as_('last'))
65
72
  """
66
- return Column(self.table_name, self.column_name, self.python_type, alias, self.schema)
73
+ return Column(self.table_name, self.column_name, alias, self.schema)
67
74
 
68
75
  def __eq__(self, other) -> 'Condition':
69
76
  if other is None:
@@ -141,12 +148,10 @@ class Column:
141
148
  """
142
149
  return Condition(self, 'NOT BETWEEN', (start, end))
143
150
 
144
- @property
145
151
  def is_null(self) -> 'Condition':
146
152
  """Create an IS NULL condition"""
147
153
  return Condition(self, 'IS', None)
148
154
 
149
- @property
150
155
  def is_not_null(self) -> 'Condition':
151
156
  """Create an IS NOT NULL condition"""
152
157
  return Condition(self, 'IS NOT', None)
@@ -263,7 +268,11 @@ class Table:
263
268
  sa_columns.append(sa_col)
264
269
 
265
270
  # Create query builder ColumnDescriptor
266
- setattr(cls, field_name, ColumnDescriptor(field_name, field_type))
271
+ setattr(cls, field_name, ColumnDescriptor(field_name))
272
+ # Update annotation to reflect the descriptor's return type
273
+ if not hasattr(cls, '__annotations__'):
274
+ cls.__annotations__ = {}
275
+ cls.__annotations__[field_name] = Column
267
276
  continue
268
277
 
269
278
  # Check if it's a Column instance (for column_name remapping)
@@ -275,7 +284,11 @@ class Table:
275
284
  db_column_name = field_name
276
285
 
277
286
  # Create query builder ColumnDescriptor with the DB column name
278
- setattr(cls, field_name, ColumnDescriptor(db_column_name, field_type))
287
+ setattr(cls, field_name, ColumnDescriptor(db_column_name))
288
+ # Update annotation to reflect the descriptor's return type
289
+ if not hasattr(cls, '__annotations__'):
290
+ cls.__annotations__ = {}
291
+ cls.__annotations__[field_name] = Column
279
292
 
280
293
  # Create SQLAlchemy column if metadata provided
281
294
  if metadata is not None and HAS_SQLALCHEMY:
@@ -286,7 +299,11 @@ class Table:
286
299
  # Check if it's an Ellipsis (...) declaration
287
300
  if field_value is ...:
288
301
  # Create query builder ColumnDescriptor
289
- setattr(cls, field_name, ColumnDescriptor(field_name, None))
302
+ setattr(cls, field_name, ColumnDescriptor(field_name))
303
+ # Update annotation to reflect the descriptor's return type
304
+ if not hasattr(cls, '__annotations__'):
305
+ cls.__annotations__ = {}
306
+ cls.__annotations__[field_name] = Column
290
307
  continue
291
308
 
292
309
  # Otherwise, handle type annotations
@@ -295,7 +312,11 @@ class Table:
295
312
  continue
296
313
 
297
314
  # Create query builder ColumnDescriptor for type-annotated fields
298
- setattr(cls, field_name, ColumnDescriptor(field_name, field_type))
315
+ setattr(cls, field_name, ColumnDescriptor(field_name))
316
+ # Update annotation to reflect the descriptor's return type
317
+ if not hasattr(cls, '__annotations__'):
318
+ cls.__annotations__ = {}
319
+ cls.__annotations__[field_name] = Column
299
320
 
300
321
  # Create SQLAlchemy column if metadata provided
301
322
  if metadata is not None and HAS_SQLALCHEMY:
@@ -306,6 +327,9 @@ class Table:
306
327
  if metadata is not None and HAS_SQLALCHEMY:
307
328
  cls._sa_table = SATable(cls.table_name, metadata, *sa_columns, schema=schema)
308
329
 
330
+ # Add the ALL descriptor for wildcard column selection
331
+ cls.ALL = AllColumnsDescriptor()
332
+
309
333
  @classmethod
310
334
  def select(cls, *columns: Union['Column', Template]) -> 'SelectQueryBuilder':
311
335
  """Start building a SELECT query"""
@@ -359,9 +383,8 @@ class Table:
359
383
  class ColumnDescriptor:
360
384
  """Descriptor that creates Column objects when accessed on Table classes or instances"""
361
385
 
362
- def __init__(self, column_name: str, python_type: type = None):
386
+ def __init__(self, column_name: str):
363
387
  self.column_name = column_name
364
- self.python_type = python_type
365
388
 
366
389
  def __set_name__(self, owner, name):
367
390
  self.column_name = name
@@ -370,7 +393,17 @@ class ColumnDescriptor:
370
393
  if objtype is None:
371
394
  objtype = type(obj)
372
395
  schema = getattr(objtype, 'schema', None)
373
- return Column(objtype.table_name, self.column_name, self.python_type, schema=schema)
396
+ return Column(objtype.table_name, self.column_name, schema=schema)
397
+
398
+
399
+ class AllColumnsDescriptor:
400
+ """Descriptor that creates a Column with wildcard (*) for selecting all columns from a table"""
401
+
402
+ def __get__(self, obj, objtype=None) -> Column:
403
+ if objtype is None:
404
+ objtype = type(obj)
405
+ schema = getattr(objtype, 'schema', None)
406
+ return Column(objtype.table_name, '*', schema=schema)
374
407
 
375
408
 
376
409
  class Condition:
@@ -687,10 +720,27 @@ class UpdateBuilder(QueryBuilder):
687
720
 
688
721
  self._conditions: List[Union[Condition, Template]] = []
689
722
  self._returning_cols: Optional[List[str]] = None
723
+ self._requires_where: bool = True
690
724
 
691
725
  def where(self, condition: Union[Condition, Template]) -> 'UpdateBuilder':
692
726
  """Add a WHERE condition (multiple calls are ANDed together)"""
693
727
  self._conditions.append(condition)
728
+ self._requires_where = False
729
+ return self
730
+
731
+ def all_rows(self) -> 'UpdateBuilder':
732
+ """Explicitly confirm intent to update all rows without a WHERE clause.
733
+
734
+ By default, UPDATE queries without WHERE clauses will raise UnsafeQueryError
735
+ at render time. Call this method to bypass that safety check.
736
+
737
+ Returns:
738
+ self for method chaining
739
+
740
+ Example:
741
+ Users.update(status='inactive').all_rows()
742
+ """
743
+ self._requires_where = False
694
744
  return self
695
745
 
696
746
  def returning(self, *columns: str) -> 'UpdateBuilder':
@@ -736,6 +786,11 @@ class UpdateBuilder(QueryBuilder):
736
786
 
737
787
  def render(self, style=None):
738
788
  """Convenience method to render the query directly"""
789
+ if self._requires_where:
790
+ raise UnsafeQueryError(
791
+ "UPDATE without WHERE clause requires explicit .all_rows() call to confirm intent. "
792
+ "This prevents accidentally updating all rows in the table."
793
+ )
739
794
  return self.to_tsql().render(style)
740
795
 
741
796
  def __repr__(self) -> str:
@@ -756,10 +811,27 @@ class DeleteBuilder(QueryBuilder):
756
811
  self.base_table = base_table
757
812
  self._conditions: List[Union[Condition, Template]] = []
758
813
  self._returning_cols: Optional[List[str]] = None
814
+ self._requires_where: bool = True
759
815
 
760
816
  def where(self, condition: Union[Condition, Template]) -> 'DeleteBuilder':
761
817
  """Add a WHERE condition (multiple calls are ANDed together)"""
762
818
  self._conditions.append(condition)
819
+ self._requires_where = False
820
+ return self
821
+
822
+ def all_rows(self) -> 'DeleteBuilder':
823
+ """Explicitly confirm intent to delete all rows without a WHERE clause.
824
+
825
+ By default, DELETE queries without WHERE clauses will raise UnsafeQueryError
826
+ at render time. Call this method to bypass that safety check.
827
+
828
+ Returns:
829
+ self for method chaining
830
+
831
+ Example:
832
+ Users.delete().all_rows()
833
+ """
834
+ self._requires_where = False
763
835
  return self
764
836
 
765
837
  def returning(self, *columns: str) -> 'DeleteBuilder':
@@ -804,6 +876,11 @@ class DeleteBuilder(QueryBuilder):
804
876
 
805
877
  def render(self, style=None):
806
878
  """Convenience method to render the query directly"""
879
+ if self._requires_where:
880
+ raise UnsafeQueryError(
881
+ "DELETE without WHERE clause requires explicit .all_rows() call to confirm intent. "
882
+ "This prevents accidentally deleting all rows in the table."
883
+ )
807
884
  return self.to_tsql().render(style)
808
885
 
809
886
  def __repr__(self) -> str:
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