t-sql 3.1.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.1.0 → t_sql-3.2.0}/PKG-INFO +36 -7
  2. {t_sql-3.1.0 → t_sql-3.2.0}/README.md +35 -6
  3. {t_sql-3.1.0 → t_sql-3.2.0}/pyproject.toml +1 -1
  4. {t_sql-3.1.0 → t_sql-3.2.0}/tests/test_query_builder.py +45 -2
  5. {t_sql-3.1.0 → t_sql-3.2.0}/tsql/__init__.py +3 -0
  6. {t_sql-3.1.0 → t_sql-3.2.0}/tsql/query_builder.py +52 -0
  7. {t_sql-3.1.0 → t_sql-3.2.0}/.dockerignore +0 -0
  8. {t_sql-3.1.0 → t_sql-3.2.0}/.github/workflows/publish.yml +0 -0
  9. {t_sql-3.1.0 → t_sql-3.2.0}/.github/workflows/test.yml +0 -0
  10. {t_sql-3.1.0 → t_sql-3.2.0}/.gitignore +0 -0
  11. {t_sql-3.1.0 → t_sql-3.2.0}/Dockerfile +0 -0
  12. {t_sql-3.1.0 → t_sql-3.2.0}/LICENSE +0 -0
  13. {t_sql-3.1.0 → t_sql-3.2.0}/compose.yaml +0 -0
  14. {t_sql-3.1.0 → t_sql-3.2.0}/context7.json +0 -0
  15. {t_sql-3.1.0 → t_sql-3.2.0}/pytest.ini +0 -0
  16. {t_sql-3.1.0 → t_sql-3.2.0}/tests/test_alembic_integration.py +0 -0
  17. {t_sql-3.1.0 → t_sql-3.2.0}/tests/test_asyncpg_integration.py +0 -0
  18. {t_sql-3.1.0 → t_sql-3.2.0}/tests/test_different_object_types.py +0 -0
  19. {t_sql-3.1.0 → t_sql-3.2.0}/tests/test_escaped.py +0 -0
  20. {t_sql-3.1.0 → t_sql-3.2.0}/tests/test_escaped_binary_hex.py +0 -0
  21. {t_sql-3.1.0 → t_sql-3.2.0}/tests/test_helper_functions.py +0 -0
  22. {t_sql-3.1.0 → t_sql-3.2.0}/tests/test_injection_edge_cases.py +0 -0
  23. {t_sql-3.1.0 → t_sql-3.2.0}/tests/test_injection_protection_validation.py +0 -0
  24. {t_sql-3.1.0 → t_sql-3.2.0}/tests/test_injections_for_escaped.py +0 -0
  25. {t_sql-3.1.0 → t_sql-3.2.0}/tests/test_mysql_integration.py +0 -0
  26. {t_sql-3.1.0 → t_sql-3.2.0}/tests/test_parameter_names.py +0 -0
  27. {t_sql-3.1.0 → t_sql-3.2.0}/tests/test_sqlalchemy_integration.py +0 -0
  28. {t_sql-3.1.0 → t_sql-3.2.0}/tests/test_sqlite_integration.py +0 -0
  29. {t_sql-3.1.0 → t_sql-3.2.0}/tests/test_styles.py +0 -0
  30. {t_sql-3.1.0 → t_sql-3.2.0}/tests/test_tsql.py +0 -0
  31. {t_sql-3.1.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.1.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
@@ -333,10 +333,9 @@ query = (Users.insert(id='abc123', username='john', email='john@example.com')
333
333
  ### UPDATE
334
334
 
335
335
  ```python
336
- # Basic update (no WHERE = updates all rows!)
336
+ # UPDATE requires WHERE clause or explicit .all_rows() for safety
337
337
  query = Users.update(email='newemail@example.com')
338
- sql, params = query.render()
339
- # ('UPDATE users SET email = ?', ['newemail@example.com'])
338
+ # Raises UnsafeQueryError: UPDATE without WHERE requires .all_rows()
340
339
 
341
340
  # UPDATE with WHERE
342
341
  query = Users.update(email='newemail@example.com').where(Users.id == 'abc123')
@@ -348,6 +347,11 @@ query = (Users.update(email='newemail@example.com')
348
347
  .where(Users.id == 'abc123')
349
348
  .where(Users.age > 18))
350
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
+
351
355
  # With RETURNING (Postgres/SQLite)
352
356
  query = (Users.update(email='new@example.com')
353
357
  .where(Users.id == 'abc123')
@@ -358,10 +362,9 @@ query = (Users.update(email='new@example.com')
358
362
  ### DELETE
359
363
 
360
364
  ```python
361
- # Basic delete (no WHERE = deletes all rows!)
365
+ # DELETE requires WHERE clause or explicit .all_rows() for safety
362
366
  query = Users.delete()
363
- sql, params = query.render()
364
- # ('DELETE FROM users', [])
367
+ # Raises UnsafeQueryError: DELETE without WHERE requires .all_rows()
365
368
 
366
369
  # DELETE with WHERE
367
370
  query = Users.delete().where(Users.id == 'abc123')
@@ -371,6 +374,11 @@ sql, params = query.render()
371
374
  # Multiple conditions
372
375
  query = Users.delete().where(Users.age < 18).where(Users.active == False)
373
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
+
374
382
  # With RETURNING (Postgres/SQLite)
375
383
  query = Users.delete().where(Users.id == 'abc123').returning()
376
384
  # ('DELETE FROM users WHERE users.id = ? RETURNING *', ['abc123'])
@@ -626,6 +634,27 @@ sql, _ = tsql.render(t"SELECT * FROM users WHERE name = {malicious}", style=tsql
626
634
 
627
635
  **Important:** While effective, parameterization is always preferred when available. Use `ESCAPED` only when necessary.
628
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
+
629
658
  ## Danger Zones: Where You Can Still Get Hurt
630
659
 
631
660
  ### The :unsafe Format Spec
@@ -323,10 +323,9 @@ query = (Users.insert(id='abc123', username='john', email='john@example.com')
323
323
  ### UPDATE
324
324
 
325
325
  ```python
326
- # Basic update (no WHERE = updates all rows!)
326
+ # UPDATE requires WHERE clause or explicit .all_rows() for safety
327
327
  query = Users.update(email='newemail@example.com')
328
- sql, params = query.render()
329
- # ('UPDATE users SET email = ?', ['newemail@example.com'])
328
+ # Raises UnsafeQueryError: UPDATE without WHERE requires .all_rows()
330
329
 
331
330
  # UPDATE with WHERE
332
331
  query = Users.update(email='newemail@example.com').where(Users.id == 'abc123')
@@ -338,6 +337,11 @@ query = (Users.update(email='newemail@example.com')
338
337
  .where(Users.id == 'abc123')
339
338
  .where(Users.age > 18))
340
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
+
341
345
  # With RETURNING (Postgres/SQLite)
342
346
  query = (Users.update(email='new@example.com')
343
347
  .where(Users.id == 'abc123')
@@ -348,10 +352,9 @@ query = (Users.update(email='new@example.com')
348
352
  ### DELETE
349
353
 
350
354
  ```python
351
- # Basic delete (no WHERE = deletes all rows!)
355
+ # DELETE requires WHERE clause or explicit .all_rows() for safety
352
356
  query = Users.delete()
353
- sql, params = query.render()
354
- # ('DELETE FROM users', [])
357
+ # Raises UnsafeQueryError: DELETE without WHERE requires .all_rows()
355
358
 
356
359
  # DELETE with WHERE
357
360
  query = Users.delete().where(Users.id == 'abc123')
@@ -361,6 +364,11 @@ sql, params = query.render()
361
364
  # Multiple conditions
362
365
  query = Users.delete().where(Users.age < 18).where(Users.active == False)
363
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
+
364
372
  # With RETURNING (Postgres/SQLite)
365
373
  query = Users.delete().where(Users.id == 'abc123').returning()
366
374
  # ('DELETE FROM users WHERE users.id = ? RETURNING *', ['abc123'])
@@ -616,6 +624,27 @@ sql, _ = tsql.render(t"SELECT * FROM users WHERE name = {malicious}", style=tsql
616
624
 
617
625
  **Important:** While effective, parameterization is always preferred when available. Use `ESCAPED` only when necessary.
618
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
+
619
648
  ## Danger Zones: Where You Can Still Get Hurt
620
649
 
621
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.1.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"
@@ -1172,7 +1172,7 @@ def test_returning_cols_validation_update():
1172
1172
  import pytest
1173
1173
 
1174
1174
  # Test with malicious returning column
1175
- query = Users.update(username='hacked')
1175
+ query = Users.update(username='hacked').all_rows()
1176
1176
  builder = query.returning("id, (SELECT password FROM admin_users LIMIT 1) AS stolen")
1177
1177
 
1178
1178
  with pytest.raises(ValueError, match="Invalid RETURNING column name"):
@@ -1191,7 +1191,7 @@ def test_returning_cols_validation_delete():
1191
1191
  import pytest
1192
1192
 
1193
1193
  # Test with malicious returning column
1194
- query = Users.delete()
1194
+ query = Users.delete().all_rows()
1195
1195
  builder = query.returning("* FROM users; DROP TABLE secrets; --")
1196
1196
 
1197
1197
  with pytest.raises(ValueError, match="Invalid RETURNING column name"):
@@ -1222,3 +1222,46 @@ def test_conflict_cols_list_validation():
1222
1222
  sql, params = builder2.render()
1223
1223
 
1224
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 == []
@@ -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
@@ -687,10 +695,27 @@ class UpdateBuilder(QueryBuilder):
687
695
 
688
696
  self._conditions: List[Union[Condition, Template]] = []
689
697
  self._returning_cols: Optional[List[str]] = None
698
+ self._requires_where: bool = True
690
699
 
691
700
  def where(self, condition: Union[Condition, Template]) -> 'UpdateBuilder':
692
701
  """Add a WHERE condition (multiple calls are ANDed together)"""
693
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
694
719
  return self
695
720
 
696
721
  def returning(self, *columns: str) -> 'UpdateBuilder':
@@ -736,6 +761,11 @@ class UpdateBuilder(QueryBuilder):
736
761
 
737
762
  def render(self, style=None):
738
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
+ )
739
769
  return self.to_tsql().render(style)
740
770
 
741
771
  def __repr__(self) -> str:
@@ -756,10 +786,27 @@ class DeleteBuilder(QueryBuilder):
756
786
  self.base_table = base_table
757
787
  self._conditions: List[Union[Condition, Template]] = []
758
788
  self._returning_cols: Optional[List[str]] = None
789
+ self._requires_where: bool = True
759
790
 
760
791
  def where(self, condition: Union[Condition, Template]) -> 'DeleteBuilder':
761
792
  """Add a WHERE condition (multiple calls are ANDed together)"""
762
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
763
810
  return self
764
811
 
765
812
  def returning(self, *columns: str) -> 'DeleteBuilder':
@@ -804,6 +851,11 @@ class DeleteBuilder(QueryBuilder):
804
851
 
805
852
  def render(self, style=None):
806
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
+ )
807
859
  return self.to_tsql().render(style)
808
860
 
809
861
  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