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.
- {t_sql-3.1.0 → t_sql-3.2.0}/PKG-INFO +36 -7
- {t_sql-3.1.0 → t_sql-3.2.0}/README.md +35 -6
- {t_sql-3.1.0 → t_sql-3.2.0}/pyproject.toml +1 -1
- {t_sql-3.1.0 → t_sql-3.2.0}/tests/test_query_builder.py +45 -2
- {t_sql-3.1.0 → t_sql-3.2.0}/tsql/__init__.py +3 -0
- {t_sql-3.1.0 → t_sql-3.2.0}/tsql/query_builder.py +52 -0
- {t_sql-3.1.0 → t_sql-3.2.0}/.dockerignore +0 -0
- {t_sql-3.1.0 → t_sql-3.2.0}/.github/workflows/publish.yml +0 -0
- {t_sql-3.1.0 → t_sql-3.2.0}/.github/workflows/test.yml +0 -0
- {t_sql-3.1.0 → t_sql-3.2.0}/.gitignore +0 -0
- {t_sql-3.1.0 → t_sql-3.2.0}/Dockerfile +0 -0
- {t_sql-3.1.0 → t_sql-3.2.0}/LICENSE +0 -0
- {t_sql-3.1.0 → t_sql-3.2.0}/compose.yaml +0 -0
- {t_sql-3.1.0 → t_sql-3.2.0}/context7.json +0 -0
- {t_sql-3.1.0 → t_sql-3.2.0}/pytest.ini +0 -0
- {t_sql-3.1.0 → t_sql-3.2.0}/tests/test_alembic_integration.py +0 -0
- {t_sql-3.1.0 → t_sql-3.2.0}/tests/test_asyncpg_integration.py +0 -0
- {t_sql-3.1.0 → t_sql-3.2.0}/tests/test_different_object_types.py +0 -0
- {t_sql-3.1.0 → t_sql-3.2.0}/tests/test_escaped.py +0 -0
- {t_sql-3.1.0 → t_sql-3.2.0}/tests/test_escaped_binary_hex.py +0 -0
- {t_sql-3.1.0 → t_sql-3.2.0}/tests/test_helper_functions.py +0 -0
- {t_sql-3.1.0 → t_sql-3.2.0}/tests/test_injection_edge_cases.py +0 -0
- {t_sql-3.1.0 → t_sql-3.2.0}/tests/test_injection_protection_validation.py +0 -0
- {t_sql-3.1.0 → t_sql-3.2.0}/tests/test_injections_for_escaped.py +0 -0
- {t_sql-3.1.0 → t_sql-3.2.0}/tests/test_mysql_integration.py +0 -0
- {t_sql-3.1.0 → t_sql-3.2.0}/tests/test_parameter_names.py +0 -0
- {t_sql-3.1.0 → t_sql-3.2.0}/tests/test_sqlalchemy_integration.py +0 -0
- {t_sql-3.1.0 → t_sql-3.2.0}/tests/test_sqlite_integration.py +0 -0
- {t_sql-3.1.0 → t_sql-3.2.0}/tests/test_styles.py +0 -0
- {t_sql-3.1.0 → t_sql-3.2.0}/tests/test_tsql.py +0 -0
- {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.
|
|
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
|
-
#
|
|
336
|
+
# UPDATE requires WHERE clause or explicit .all_rows() for safety
|
|
337
337
|
query = Users.update(email='newemail@example.com')
|
|
338
|
-
|
|
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
|
-
#
|
|
365
|
+
# DELETE requires WHERE clause or explicit .all_rows() for safety
|
|
362
366
|
query = Users.delete()
|
|
363
|
-
|
|
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
|
-
#
|
|
326
|
+
# UPDATE requires WHERE clause or explicit .all_rows() for safety
|
|
327
327
|
query = Users.update(email='newemail@example.com')
|
|
328
|
-
|
|
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
|
-
#
|
|
355
|
+
# DELETE requires WHERE clause or explicit .all_rows() for safety
|
|
352
356
|
query = Users.delete()
|
|
353
|
-
|
|
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
|
|
@@ -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
|
|
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
|