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.
- {t_sql-3.1.0 → t_sql-4.0.0}/PKG-INFO +71 -7
- {t_sql-3.1.0 → t_sql-4.0.0}/README.md +70 -6
- {t_sql-3.1.0 → t_sql-4.0.0}/pyproject.toml +1 -1
- {t_sql-3.1.0 → t_sql-4.0.0}/tests/test_query_builder.py +112 -11
- {t_sql-3.1.0 → t_sql-4.0.0}/tests/test_sqlalchemy_integration.py +32 -0
- {t_sql-3.1.0 → t_sql-4.0.0}/tsql/__init__.py +3 -0
- {t_sql-3.1.0 → t_sql-4.0.0}/tsql/query_builder.py +89 -12
- {t_sql-3.1.0 → t_sql-4.0.0}/.dockerignore +0 -0
- {t_sql-3.1.0 → t_sql-4.0.0}/.github/workflows/publish.yml +0 -0
- {t_sql-3.1.0 → t_sql-4.0.0}/.github/workflows/test.yml +0 -0
- {t_sql-3.1.0 → t_sql-4.0.0}/.gitignore +0 -0
- {t_sql-3.1.0 → t_sql-4.0.0}/Dockerfile +0 -0
- {t_sql-3.1.0 → t_sql-4.0.0}/LICENSE +0 -0
- {t_sql-3.1.0 → t_sql-4.0.0}/compose.yaml +0 -0
- {t_sql-3.1.0 → t_sql-4.0.0}/context7.json +0 -0
- {t_sql-3.1.0 → t_sql-4.0.0}/pytest.ini +0 -0
- {t_sql-3.1.0 → t_sql-4.0.0}/tests/test_alembic_integration.py +0 -0
- {t_sql-3.1.0 → t_sql-4.0.0}/tests/test_asyncpg_integration.py +0 -0
- {t_sql-3.1.0 → t_sql-4.0.0}/tests/test_different_object_types.py +0 -0
- {t_sql-3.1.0 → t_sql-4.0.0}/tests/test_escaped.py +0 -0
- {t_sql-3.1.0 → t_sql-4.0.0}/tests/test_escaped_binary_hex.py +0 -0
- {t_sql-3.1.0 → t_sql-4.0.0}/tests/test_helper_functions.py +0 -0
- {t_sql-3.1.0 → t_sql-4.0.0}/tests/test_injection_edge_cases.py +0 -0
- {t_sql-3.1.0 → t_sql-4.0.0}/tests/test_injection_protection_validation.py +0 -0
- {t_sql-3.1.0 → t_sql-4.0.0}/tests/test_injections_for_escaped.py +0 -0
- {t_sql-3.1.0 → t_sql-4.0.0}/tests/test_mysql_integration.py +0 -0
- {t_sql-3.1.0 → t_sql-4.0.0}/tests/test_parameter_names.py +0 -0
- {t_sql-3.1.0 → t_sql-4.0.0}/tests/test_sqlite_integration.py +0 -0
- {t_sql-3.1.0 → t_sql-4.0.0}/tests/test_styles.py +0 -0
- {t_sql-3.1.0 → t_sql-4.0.0}/tests/test_tsql.py +0 -0
- {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
|
+
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
|
-
#
|
|
371
|
+
# UPDATE requires WHERE clause or explicit .all_rows() for safety
|
|
337
372
|
query = Users.update(email='newemail@example.com')
|
|
338
|
-
|
|
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
|
-
#
|
|
400
|
+
# DELETE requires WHERE clause or explicit .all_rows() for safety
|
|
362
401
|
query = Users.delete()
|
|
363
|
-
|
|
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
|
-
#
|
|
361
|
+
# UPDATE requires WHERE clause or explicit .all_rows() for safety
|
|
327
362
|
query = Users.update(email='newemail@example.com')
|
|
328
|
-
|
|
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
|
-
#
|
|
390
|
+
# DELETE requires WHERE clause or explicit .all_rows() for safety
|
|
352
391
|
query = Users.delete()
|
|
353
|
-
|
|
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
|
|
@@ -838,23 +838,81 @@ def test_tstring_with_params_in_select():
|
|
|
838
838
|
assert params == [' - ', 5]
|
|
839
839
|
|
|
840
840
|
|
|
841
|
-
def
|
|
842
|
-
"""Test
|
|
843
|
-
|
|
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
|
|
849
|
-
"""Test is_not_null
|
|
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,
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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
|