t-sql 2.2.1__tar.gz → 3.1.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-2.2.1 → t_sql-3.1.0}/PKG-INFO +115 -25
  2. {t_sql-2.2.1 → t_sql-3.1.0}/README.md +114 -24
  3. {t_sql-2.2.1 → t_sql-3.1.0}/pyproject.toml +1 -1
  4. {t_sql-2.2.1 → t_sql-3.1.0}/tests/test_helper_functions.py +2 -7
  5. {t_sql-2.2.1 → t_sql-3.1.0}/tests/test_mysql_integration.py +2 -2
  6. {t_sql-2.2.1 → t_sql-3.1.0}/tests/test_query_builder.py +45 -13
  7. {t_sql-2.2.1 → t_sql-3.1.0}/tests/test_sqlite_integration.py +4 -4
  8. {t_sql-2.2.1 → t_sql-3.1.0}/tsql/__init__.py +8 -4
  9. {t_sql-2.2.1 → t_sql-3.1.0}/tsql/query_builder.py +54 -6
  10. {t_sql-2.2.1 → t_sql-3.1.0}/.dockerignore +0 -0
  11. {t_sql-2.2.1 → t_sql-3.1.0}/.github/workflows/publish.yml +0 -0
  12. {t_sql-2.2.1 → t_sql-3.1.0}/.github/workflows/test.yml +0 -0
  13. {t_sql-2.2.1 → t_sql-3.1.0}/.gitignore +0 -0
  14. {t_sql-2.2.1 → t_sql-3.1.0}/Dockerfile +0 -0
  15. {t_sql-2.2.1 → t_sql-3.1.0}/LICENSE +0 -0
  16. {t_sql-2.2.1 → t_sql-3.1.0}/compose.yaml +0 -0
  17. {t_sql-2.2.1 → t_sql-3.1.0}/context7.json +0 -0
  18. {t_sql-2.2.1 → t_sql-3.1.0}/pytest.ini +0 -0
  19. {t_sql-2.2.1 → t_sql-3.1.0}/tests/test_alembic_integration.py +0 -0
  20. {t_sql-2.2.1 → t_sql-3.1.0}/tests/test_asyncpg_integration.py +0 -0
  21. {t_sql-2.2.1 → t_sql-3.1.0}/tests/test_different_object_types.py +0 -0
  22. {t_sql-2.2.1 → t_sql-3.1.0}/tests/test_escaped.py +0 -0
  23. {t_sql-2.2.1 → t_sql-3.1.0}/tests/test_escaped_binary_hex.py +0 -0
  24. {t_sql-2.2.1 → t_sql-3.1.0}/tests/test_injection_edge_cases.py +0 -0
  25. {t_sql-2.2.1 → t_sql-3.1.0}/tests/test_injection_protection_validation.py +0 -0
  26. {t_sql-2.2.1 → t_sql-3.1.0}/tests/test_injections_for_escaped.py +0 -0
  27. {t_sql-2.2.1 → t_sql-3.1.0}/tests/test_parameter_names.py +0 -0
  28. {t_sql-2.2.1 → t_sql-3.1.0}/tests/test_sqlalchemy_integration.py +0 -0
  29. {t_sql-2.2.1 → t_sql-3.1.0}/tests/test_styles.py +0 -0
  30. {t_sql-2.2.1 → t_sql-3.1.0}/tests/test_tsql.py +0 -0
  31. {t_sql-2.2.1 → t_sql-3.1.0}/tsql/styles.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: t-sql
3
- Version: 2.2.1
3
+ Version: 3.1.0
4
4
  Summary: Safe SQL. SQL queries for python t-strings (PEP 750)
5
5
  Project-URL: Homepage, https://github.com/nhumrich/t-sql
6
6
  License-File: LICENSE
@@ -159,8 +159,7 @@ sql, params = query.render()
159
159
  Quick INSERT queries:
160
160
 
161
161
  ```python
162
- values = {'id': 'abc123', 'name': 'bob', 'email': 'bob@example.com'}
163
- query = tsql.insert('users', values)
162
+ query = tsql.insert('users', id='abc123', name='bob', email='bob@example.com')
164
163
  sql, params = query.render()
165
164
  # ('INSERT INTO users (id, name, email) VALUES (?, ?, ?)', ['abc123', 'bob', 'bob@example.com'])
166
165
  ```
@@ -171,14 +170,9 @@ Quick UPDATE queries:
171
170
 
172
171
  ```python
173
172
  # Update by ID
174
- query = tsql.update('users', {'email': 'new@example.com'}, id_value='abc123')
173
+ query = tsql.update('users', 'abc123', email='new@example.com')
175
174
  sql, params = query.render()
176
175
  # ('UPDATE users SET email = ? WHERE id = ?', ['new@example.com', 'abc123'])
177
-
178
- # Update with custom WHERE
179
- query = tsql.update('users', {'email': 'new@example.com'}, where={'age': 25})
180
- sql, params = query.render()
181
- # ('UPDATE users SET email = ? WHERE age = ?', ['new@example.com', 25])
182
176
  ```
183
177
 
184
178
  #### delete
@@ -271,8 +265,9 @@ query = Users.select().where(Users.id.in_([1, 2, 3]))
271
265
  query = Users.select().where(Users.username.like('%john%'))
272
266
 
273
267
  # ORDER BY
274
- query = Posts.select().order_by(Posts.id)
275
- query = Posts.select().order_by((Posts.id, 'DESC'))
268
+ query = Posts.select().order_by(Posts.id) # defaults to ASC
269
+ query = Posts.select().order_by(Posts.id.desc())
270
+ query = Posts.select().order_by(Posts.created_at.asc(), Posts.id.desc())
276
271
 
277
272
  # LIMIT and OFFSET
278
273
  query = Posts.select().limit(10).offset(20)
@@ -291,47 +286,46 @@ The query builder supports INSERT, UPDATE, and DELETE with database-agnostic con
291
286
 
292
287
  ```python
293
288
  # Basic insert
294
- values = {'id': 'abc123', 'username': 'john', 'email': 'john@example.com'}
295
- query = Users.insert(values)
289
+ query = Users.insert(id='abc123', username='john', email='john@example.com')
296
290
  sql, params = query.render()
297
291
  # ('INSERT INTO users (id, username, email) VALUES (?, ?, ?)', ['abc123', 'john', 'john@example.com'])
298
292
 
299
293
  # INSERT with RETURNING (Postgres/SQLite)
300
- query = Users.insert(values).returning()
294
+ query = Users.insert(id='abc123', username='john', email='john@example.com').returning()
301
295
  sql, params = query.render()
302
296
  # ('INSERT INTO users (id, username, email) VALUES (?, ?, ?) RETURNING *', [...])
303
297
 
304
298
  # INSERT IGNORE (MySQL)
305
- query = Users.insert(values).ignore()
299
+ query = Users.insert(id='abc123', username='john', email='john@example.com').ignore()
306
300
  sql, params = query.render()
307
301
  # ('INSERT IGNORE INTO users (id, username, email) VALUES (?, ?, ?)', [...])
308
302
 
309
303
  # ON CONFLICT DO NOTHING (Postgres/SQLite)
310
- query = Users.insert(values).on_conflict_do_nothing()
304
+ query = Users.insert(id='abc123', username='john', email='john@example.com').on_conflict_do_nothing()
311
305
  # ('INSERT INTO users (...) VALUES (...) ON CONFLICT DO NOTHING', [...])
312
306
 
313
307
  # ON CONFLICT DO NOTHING with specific conflict target (Postgres/SQLite)
314
- query = Users.insert(values).on_conflict_do_nothing(conflict_on='email')
308
+ query = Users.insert(id='abc123', username='john', email='john@example.com').on_conflict_do_nothing(conflict_on='email')
315
309
  # ('INSERT INTO users (...) VALUES (...) ON CONFLICT (email) DO NOTHING', [...])
316
310
 
317
311
  # ON CONFLICT DO UPDATE (Postgres/SQLite upsert)
318
- query = Users.insert(values).on_conflict_update(conflict_on='id')
312
+ query = Users.insert(id='abc123', username='john', email='john@example.com').on_conflict_update(conflict_on='id')
319
313
  # ('INSERT INTO users (...) VALUES (...)
320
314
  # ON CONFLICT (id) DO UPDATE SET username = EXCLUDED.username, email = EXCLUDED.email', [...])
321
315
 
322
316
  # ON CONFLICT with custom update
323
- query = Users.insert(values).on_conflict_update(
317
+ query = Users.insert(id='abc123', username='john', email='john@example.com').on_conflict_update(
324
318
  conflict_on='id',
325
319
  update={'username': 'updated_name'}
326
320
  )
327
321
 
328
322
  # ON DUPLICATE KEY UPDATE (MySQL)
329
- query = Users.insert(values).on_duplicate_key_update()
323
+ query = Users.insert(id='abc123', username='john', email='john@example.com').on_duplicate_key_update()
330
324
  # ('INSERT INTO users (...) VALUES (...)
331
325
  # ON DUPLICATE KEY UPDATE id = VALUES(id), username = VALUES(username), ...', [...])
332
326
 
333
327
  # Chain multiple modifiers
334
- query = (Users.insert(values)
328
+ query = (Users.insert(id='abc123', username='john', email='john@example.com')
335
329
  .on_conflict_update(conflict_on='id')
336
330
  .returning('id', 'username'))
337
331
  ```
@@ -340,22 +334,22 @@ query = (Users.insert(values)
340
334
 
341
335
  ```python
342
336
  # Basic update (no WHERE = updates all rows!)
343
- query = Users.update({'email': 'newemail@example.com'})
337
+ query = Users.update(email='newemail@example.com')
344
338
  sql, params = query.render()
345
339
  # ('UPDATE users SET email = ?', ['newemail@example.com'])
346
340
 
347
341
  # UPDATE with WHERE
348
- query = Users.update({'email': 'newemail@example.com'}).where(Users.id == 'abc123')
342
+ query = Users.update(email='newemail@example.com').where(Users.id == 'abc123')
349
343
  sql, params = query.render()
350
344
  # ('UPDATE users SET email = ? WHERE users.id = ?', ['newemail@example.com', 'abc123'])
351
345
 
352
346
  # Multiple WHERE conditions
353
- query = (Users.update({'email': 'newemail@example.com'})
347
+ query = (Users.update(email='newemail@example.com')
354
348
  .where(Users.id == 'abc123')
355
349
  .where(Users.age > 18))
356
350
 
357
351
  # With RETURNING (Postgres/SQLite)
358
- query = (Users.update({'email': 'new@example.com'})
352
+ query = (Users.update(email='new@example.com')
359
353
  .where(Users.id == 'abc123')
360
354
  .returning())
361
355
  # ('UPDATE users SET email = ? WHERE users.id = ? RETURNING *', [...])
@@ -559,3 +553,99 @@ execute_sql_query("SELECT * FROM users") # ✗ Type error!
559
553
  ```
560
554
 
561
555
  The `TSQLQuery` type is a union of `TSQL`, `Template` (t-strings), and `QueryBuilder`, ensuring all queries are safe from SQL injection.
556
+
557
+ # Security Considerations
558
+
559
+ ## Overview
560
+
561
+ SQL injection is one of the most critical web application security risks (OWASP Top 10). This library is designed from the ground up to prevent SQL injection attacks through multiple layers of protection. However, understanding how these protections work—and where they can be bypassed—is essential for secure usage.
562
+
563
+ ## How t-sql Prevents SQL Injection
564
+
565
+ ### 1. Automatic Parameterization (Primary Defense)
566
+
567
+ By default, all interpolated values in t-strings are converted to parameterized queries:
568
+
569
+ ```python
570
+ # User input (potentially malicious)
571
+ user_input = "admin' OR 1=1 --"
572
+
573
+ # t-sql automatically parameterizes this
574
+ sql, params = tsql.render(t"SELECT * FROM users WHERE name = {user_input}")
575
+ # Result: ('SELECT * FROM users WHERE name = ?', ["admin' OR 1=1 --"])
576
+ ```
577
+
578
+ The malicious SQL becomes **literal string data** in the parameter, not executable SQL code. The database treats it as a string value to match, not as SQL syntax.
579
+
580
+ **Attack vectors prevented:**
581
+ - Classic injection: `' OR 1=1 --`
582
+ - Union-based: `' UNION SELECT * FROM secrets --`
583
+ - Stacked queries: `'; DROP TABLE users; --`
584
+ - Boolean-based blind: `' AND SLEEP(5) --`
585
+ - Authentication bypass: `admin'--`
586
+
587
+ ### 2. Literal Validation (Identifier Safety)
588
+
589
+ For table and column names that cannot be parameterized, use `:literal`:
590
+
591
+ ```python
592
+ table = "users"
593
+ col = "name"
594
+ sql, params = tsql.render(t"SELECT * FROM {table:literal} WHERE {col:literal} = {value}")
595
+ ```
596
+
597
+ **Validation rules:**
598
+ - Must be valid Python identifiers (`str.isidentifier()`)
599
+ - Supports qualified names: `table.column` or `schema.table.column` (max 3 parts)
600
+ - Rejects anything with spaces, quotes, or special characters
601
+
602
+ ```python
603
+ # These are REJECTED with ValueError:
604
+ bad_table = "users; DROP TABLE secrets" # Contains semicolon
605
+ bad_col = "name' OR 1=1" # Contains quote
606
+ bad_schema = "schema.table.column.extra" # Too many parts
607
+
608
+ tsql.render(t"SELECT * FROM {bad_table:literal}") # Raises ValueError
609
+ ```
610
+
611
+ **Attack vectors prevented:**
612
+ - Table/column injection: `users; DROP TABLE secrets`
613
+ - Second-order injection via identifiers
614
+ - Schema manipulation
615
+
616
+ ### 3. Escape-based Protection (ESCAPED Style)
617
+
618
+ For databases or scenarios where parameterization isn't available, the `ESCAPED` style properly escapes values:
619
+
620
+ ```python
621
+ malicious = "'; DROP TABLE users; --"
622
+ sql, _ = tsql.render(t"SELECT * FROM users WHERE name = {malicious}", style=tsql.styles.ESCAPED)
623
+ # Result: "SELECT * FROM users WHERE name = '''; DROP TABLE users; --'"
624
+ # (single quotes are doubled, making it literal data)
625
+ ```
626
+
627
+ **Important:** While effective, parameterization is always preferred when available. Use `ESCAPED` only when necessary.
628
+
629
+ ## Danger Zones: Where You Can Still Get Hurt
630
+
631
+ ### The :unsafe Format Spec
632
+
633
+ The `:unsafe` format spec **bypasses all safety mechanisms**:
634
+
635
+ ```python
636
+ # DANGEROUS - no validation or parameterization!
637
+ dynamic_sql = "age > 18 OR role = 'admin'" # If this comes from user input, you're vulnerable
638
+ sql, params = tsql.render(t"SELECT * FROM users WHERE {dynamic_sql:unsafe}")
639
+ ```
640
+
641
+ **When :unsafe is acceptable:**
642
+ - Hard-coded SQL fragments in your own code
643
+ - SQL generated by trusted, validated builder logic
644
+ - Dynamic ORDER BY clauses (after validation)
645
+
646
+ **When :unsafe is DANGEROUS:**
647
+ - **Never** with user input (even "validated" input)
648
+ - Dynamic WHERE clauses from external sources
649
+ - Any data from forms, APIs, or databases
650
+
651
+ **Recommendation:** Treat `:unsafe` like `eval()` in your code reviews. Every usage should be scrutinized and documented.
@@ -149,8 +149,7 @@ sql, params = query.render()
149
149
  Quick INSERT queries:
150
150
 
151
151
  ```python
152
- values = {'id': 'abc123', 'name': 'bob', 'email': 'bob@example.com'}
153
- query = tsql.insert('users', values)
152
+ query = tsql.insert('users', id='abc123', name='bob', email='bob@example.com')
154
153
  sql, params = query.render()
155
154
  # ('INSERT INTO users (id, name, email) VALUES (?, ?, ?)', ['abc123', 'bob', 'bob@example.com'])
156
155
  ```
@@ -161,14 +160,9 @@ Quick UPDATE queries:
161
160
 
162
161
  ```python
163
162
  # Update by ID
164
- query = tsql.update('users', {'email': 'new@example.com'}, id_value='abc123')
163
+ query = tsql.update('users', 'abc123', email='new@example.com')
165
164
  sql, params = query.render()
166
165
  # ('UPDATE users SET email = ? WHERE id = ?', ['new@example.com', 'abc123'])
167
-
168
- # Update with custom WHERE
169
- query = tsql.update('users', {'email': 'new@example.com'}, where={'age': 25})
170
- sql, params = query.render()
171
- # ('UPDATE users SET email = ? WHERE age = ?', ['new@example.com', 25])
172
166
  ```
173
167
 
174
168
  #### delete
@@ -261,8 +255,9 @@ query = Users.select().where(Users.id.in_([1, 2, 3]))
261
255
  query = Users.select().where(Users.username.like('%john%'))
262
256
 
263
257
  # ORDER BY
264
- query = Posts.select().order_by(Posts.id)
265
- query = Posts.select().order_by((Posts.id, 'DESC'))
258
+ query = Posts.select().order_by(Posts.id) # defaults to ASC
259
+ query = Posts.select().order_by(Posts.id.desc())
260
+ query = Posts.select().order_by(Posts.created_at.asc(), Posts.id.desc())
266
261
 
267
262
  # LIMIT and OFFSET
268
263
  query = Posts.select().limit(10).offset(20)
@@ -281,47 +276,46 @@ The query builder supports INSERT, UPDATE, and DELETE with database-agnostic con
281
276
 
282
277
  ```python
283
278
  # Basic insert
284
- values = {'id': 'abc123', 'username': 'john', 'email': 'john@example.com'}
285
- query = Users.insert(values)
279
+ query = Users.insert(id='abc123', username='john', email='john@example.com')
286
280
  sql, params = query.render()
287
281
  # ('INSERT INTO users (id, username, email) VALUES (?, ?, ?)', ['abc123', 'john', 'john@example.com'])
288
282
 
289
283
  # INSERT with RETURNING (Postgres/SQLite)
290
- query = Users.insert(values).returning()
284
+ query = Users.insert(id='abc123', username='john', email='john@example.com').returning()
291
285
  sql, params = query.render()
292
286
  # ('INSERT INTO users (id, username, email) VALUES (?, ?, ?) RETURNING *', [...])
293
287
 
294
288
  # INSERT IGNORE (MySQL)
295
- query = Users.insert(values).ignore()
289
+ query = Users.insert(id='abc123', username='john', email='john@example.com').ignore()
296
290
  sql, params = query.render()
297
291
  # ('INSERT IGNORE INTO users (id, username, email) VALUES (?, ?, ?)', [...])
298
292
 
299
293
  # ON CONFLICT DO NOTHING (Postgres/SQLite)
300
- query = Users.insert(values).on_conflict_do_nothing()
294
+ query = Users.insert(id='abc123', username='john', email='john@example.com').on_conflict_do_nothing()
301
295
  # ('INSERT INTO users (...) VALUES (...) ON CONFLICT DO NOTHING', [...])
302
296
 
303
297
  # ON CONFLICT DO NOTHING with specific conflict target (Postgres/SQLite)
304
- query = Users.insert(values).on_conflict_do_nothing(conflict_on='email')
298
+ query = Users.insert(id='abc123', username='john', email='john@example.com').on_conflict_do_nothing(conflict_on='email')
305
299
  # ('INSERT INTO users (...) VALUES (...) ON CONFLICT (email) DO NOTHING', [...])
306
300
 
307
301
  # ON CONFLICT DO UPDATE (Postgres/SQLite upsert)
308
- query = Users.insert(values).on_conflict_update(conflict_on='id')
302
+ query = Users.insert(id='abc123', username='john', email='john@example.com').on_conflict_update(conflict_on='id')
309
303
  # ('INSERT INTO users (...) VALUES (...)
310
304
  # ON CONFLICT (id) DO UPDATE SET username = EXCLUDED.username, email = EXCLUDED.email', [...])
311
305
 
312
306
  # ON CONFLICT with custom update
313
- query = Users.insert(values).on_conflict_update(
307
+ query = Users.insert(id='abc123', username='john', email='john@example.com').on_conflict_update(
314
308
  conflict_on='id',
315
309
  update={'username': 'updated_name'}
316
310
  )
317
311
 
318
312
  # ON DUPLICATE KEY UPDATE (MySQL)
319
- query = Users.insert(values).on_duplicate_key_update()
313
+ query = Users.insert(id='abc123', username='john', email='john@example.com').on_duplicate_key_update()
320
314
  # ('INSERT INTO users (...) VALUES (...)
321
315
  # ON DUPLICATE KEY UPDATE id = VALUES(id), username = VALUES(username), ...', [...])
322
316
 
323
317
  # Chain multiple modifiers
324
- query = (Users.insert(values)
318
+ query = (Users.insert(id='abc123', username='john', email='john@example.com')
325
319
  .on_conflict_update(conflict_on='id')
326
320
  .returning('id', 'username'))
327
321
  ```
@@ -330,22 +324,22 @@ query = (Users.insert(values)
330
324
 
331
325
  ```python
332
326
  # Basic update (no WHERE = updates all rows!)
333
- query = Users.update({'email': 'newemail@example.com'})
327
+ query = Users.update(email='newemail@example.com')
334
328
  sql, params = query.render()
335
329
  # ('UPDATE users SET email = ?', ['newemail@example.com'])
336
330
 
337
331
  # UPDATE with WHERE
338
- query = Users.update({'email': 'newemail@example.com'}).where(Users.id == 'abc123')
332
+ query = Users.update(email='newemail@example.com').where(Users.id == 'abc123')
339
333
  sql, params = query.render()
340
334
  # ('UPDATE users SET email = ? WHERE users.id = ?', ['newemail@example.com', 'abc123'])
341
335
 
342
336
  # Multiple WHERE conditions
343
- query = (Users.update({'email': 'newemail@example.com'})
337
+ query = (Users.update(email='newemail@example.com')
344
338
  .where(Users.id == 'abc123')
345
339
  .where(Users.age > 18))
346
340
 
347
341
  # With RETURNING (Postgres/SQLite)
348
- query = (Users.update({'email': 'new@example.com'})
342
+ query = (Users.update(email='new@example.com')
349
343
  .where(Users.id == 'abc123')
350
344
  .returning())
351
345
  # ('UPDATE users SET email = ? WHERE users.id = ? RETURNING *', [...])
@@ -549,3 +543,99 @@ execute_sql_query("SELECT * FROM users") # ✗ Type error!
549
543
  ```
550
544
 
551
545
  The `TSQLQuery` type is a union of `TSQL`, `Template` (t-strings), and `QueryBuilder`, ensuring all queries are safe from SQL injection.
546
+
547
+ # Security Considerations
548
+
549
+ ## Overview
550
+
551
+ SQL injection is one of the most critical web application security risks (OWASP Top 10). This library is designed from the ground up to prevent SQL injection attacks through multiple layers of protection. However, understanding how these protections work—and where they can be bypassed—is essential for secure usage.
552
+
553
+ ## How t-sql Prevents SQL Injection
554
+
555
+ ### 1. Automatic Parameterization (Primary Defense)
556
+
557
+ By default, all interpolated values in t-strings are converted to parameterized queries:
558
+
559
+ ```python
560
+ # User input (potentially malicious)
561
+ user_input = "admin' OR 1=1 --"
562
+
563
+ # t-sql automatically parameterizes this
564
+ sql, params = tsql.render(t"SELECT * FROM users WHERE name = {user_input}")
565
+ # Result: ('SELECT * FROM users WHERE name = ?', ["admin' OR 1=1 --"])
566
+ ```
567
+
568
+ The malicious SQL becomes **literal string data** in the parameter, not executable SQL code. The database treats it as a string value to match, not as SQL syntax.
569
+
570
+ **Attack vectors prevented:**
571
+ - Classic injection: `' OR 1=1 --`
572
+ - Union-based: `' UNION SELECT * FROM secrets --`
573
+ - Stacked queries: `'; DROP TABLE users; --`
574
+ - Boolean-based blind: `' AND SLEEP(5) --`
575
+ - Authentication bypass: `admin'--`
576
+
577
+ ### 2. Literal Validation (Identifier Safety)
578
+
579
+ For table and column names that cannot be parameterized, use `:literal`:
580
+
581
+ ```python
582
+ table = "users"
583
+ col = "name"
584
+ sql, params = tsql.render(t"SELECT * FROM {table:literal} WHERE {col:literal} = {value}")
585
+ ```
586
+
587
+ **Validation rules:**
588
+ - Must be valid Python identifiers (`str.isidentifier()`)
589
+ - Supports qualified names: `table.column` or `schema.table.column` (max 3 parts)
590
+ - Rejects anything with spaces, quotes, or special characters
591
+
592
+ ```python
593
+ # These are REJECTED with ValueError:
594
+ bad_table = "users; DROP TABLE secrets" # Contains semicolon
595
+ bad_col = "name' OR 1=1" # Contains quote
596
+ bad_schema = "schema.table.column.extra" # Too many parts
597
+
598
+ tsql.render(t"SELECT * FROM {bad_table:literal}") # Raises ValueError
599
+ ```
600
+
601
+ **Attack vectors prevented:**
602
+ - Table/column injection: `users; DROP TABLE secrets`
603
+ - Second-order injection via identifiers
604
+ - Schema manipulation
605
+
606
+ ### 3. Escape-based Protection (ESCAPED Style)
607
+
608
+ For databases or scenarios where parameterization isn't available, the `ESCAPED` style properly escapes values:
609
+
610
+ ```python
611
+ malicious = "'; DROP TABLE users; --"
612
+ sql, _ = tsql.render(t"SELECT * FROM users WHERE name = {malicious}", style=tsql.styles.ESCAPED)
613
+ # Result: "SELECT * FROM users WHERE name = '''; DROP TABLE users; --'"
614
+ # (single quotes are doubled, making it literal data)
615
+ ```
616
+
617
+ **Important:** While effective, parameterization is always preferred when available. Use `ESCAPED` only when necessary.
618
+
619
+ ## Danger Zones: Where You Can Still Get Hurt
620
+
621
+ ### The :unsafe Format Spec
622
+
623
+ The `:unsafe` format spec **bypasses all safety mechanisms**:
624
+
625
+ ```python
626
+ # DANGEROUS - no validation or parameterization!
627
+ dynamic_sql = "age > 18 OR role = 'admin'" # If this comes from user input, you're vulnerable
628
+ sql, params = tsql.render(t"SELECT * FROM users WHERE {dynamic_sql:unsafe}")
629
+ ```
630
+
631
+ **When :unsafe is acceptable:**
632
+ - Hard-coded SQL fragments in your own code
633
+ - SQL generated by trusted, validated builder logic
634
+ - Dynamic ORDER BY clauses (after validation)
635
+
636
+ **When :unsafe is DANGEROUS:**
637
+ - **Never** with user input (even "validated" input)
638
+ - Dynamic WHERE clauses from external sources
639
+ - Any data from forms, APIs, or databases
640
+
641
+ **Recommendation:** Treat `:unsafe` like `eval()` in your code reviews. Every usage should be scrutinized and documented.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "t-sql"
7
- version = "2.2.1"
7
+ version = "3.1.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"
@@ -59,14 +59,9 @@ def test_insert():
59
59
 
60
60
 
61
61
  def test_update():
62
- values = {
63
- 'name': 'Bob Updated',
64
- 'age': 35
65
- }
66
-
67
- query = tsql.update('users', values, 123)
62
+ query = tsql.update('users', 123, name='Bob Updated', age=35)
68
63
  result = tsql.render(query)
69
-
64
+
70
65
  assert "UPDATE users SET" in result[0]
71
66
  assert "name = ?" in result[0]
72
67
  assert "age = ?" in result[0]
@@ -170,7 +170,7 @@ async def test_update_without_returning(conn):
170
170
  age: int
171
171
 
172
172
  # Update without RETURNING
173
- query = TestUsers.update({'age': 31}).where(TestUsers.name == 'Alice')
173
+ query = TestUsers.update(age=31).where(TestUsers.name == 'Alice')
174
174
  sql, params = query.render(style=tsql.styles.FORMAT)
175
175
 
176
176
  # Should NOT have RETURNING clause
@@ -262,7 +262,7 @@ async def test_helper_functions(conn):
262
262
  assert row[1] == 25
263
263
 
264
264
  # Test update
265
- query = tsql.update('test_users', {'age': 26}, 1)
265
+ query = tsql.update('test_users', 1, age=26)
266
266
  sql, params = query.render(style=tsql.styles.FORMAT)
267
267
 
268
268
  await cursor.execute(sql, params)
@@ -213,7 +213,7 @@ def test_order_by():
213
213
 
214
214
  def test_order_by_desc():
215
215
  """Test ORDER BY with DESC"""
216
- query = Users.select().order_by((Users.id, 'DESC'))
216
+ query = Users.select().order_by(Users.id.desc())
217
217
  sql, params = query.render()
218
218
 
219
219
  assert 'ORDER BY users.id DESC' in sql
@@ -221,7 +221,39 @@ def test_order_by_desc():
221
221
 
222
222
  def test_order_by_multiple():
223
223
  """Test ORDER BY with multiple columns"""
224
- query = Users.select().order_by(Users.username, (Users.id, 'DESC'))
224
+ query = Users.select().order_by(Users.username, Users.id.desc())
225
+ sql, params = query.render()
226
+
227
+ assert 'ORDER BY users.username ASC, users.id DESC' in sql
228
+
229
+
230
+ def test_order_by_asc_method():
231
+ """Test ORDER BY with .asc() method"""
232
+ query = Users.select().order_by(Users.username.asc())
233
+ sql, params = query.render()
234
+
235
+ assert 'ORDER BY users.username ASC' in sql
236
+
237
+
238
+ def test_order_by_desc_method():
239
+ """Test ORDER BY with .desc() method"""
240
+ query = Users.select().order_by(Users.id.desc())
241
+ sql, params = query.render()
242
+
243
+ assert 'ORDER BY users.id DESC' in sql
244
+
245
+
246
+ def test_order_by_mixed_methods():
247
+ """Test ORDER BY with mixed .asc() and .desc() methods"""
248
+ query = Users.select().order_by(Users.username.asc(), Users.id.desc())
249
+ sql, params = query.render()
250
+
251
+ assert 'ORDER BY users.username ASC, users.id DESC' in sql
252
+
253
+
254
+ def test_order_by_mixed_syntax():
255
+ """Test ORDER BY with mixed method calls and bare columns"""
256
+ query = Users.select().order_by(Users.username, Users.id.desc())
225
257
  sql, params = query.render()
226
258
 
227
259
  assert 'ORDER BY users.username ASC, users.id DESC' in sql
@@ -242,7 +274,7 @@ def test_complex_query():
242
274
  .join(Users, Posts.user_id == Users.id)
243
275
  .where(Posts.id > 100)
244
276
  .where(Users.id >= 5)
245
- .order_by((Posts.id, 'DESC'))
277
+ .order_by(Posts.id.desc())
246
278
  .limit(20))
247
279
  sql, params = query.render()
248
280
 
@@ -351,7 +383,7 @@ def test_schema_in_update():
351
383
  id: Column
352
384
  name: Column
353
385
 
354
- query = Accounts.update({'name': 'Updated Account'}).where(Accounts.id == '1')
386
+ query = Accounts.update(name='Updated Account').where(Accounts.id == '1')
355
387
  sql, params = query.render()
356
388
 
357
389
  assert 'UPDATE other.accounts SET' in sql
@@ -480,7 +512,7 @@ def test_complex_aggregation_query():
480
512
  .where(Posts.id > 100)
481
513
  .group_by(Posts.user_id)
482
514
  .having(Posts.id > 5)
483
- .order_by((Posts.user_id, 'DESC'))
515
+ .order_by(Posts.user_id.desc())
484
516
  .limit(10)
485
517
  .offset(5))
486
518
  sql, params = query.render()
@@ -601,7 +633,7 @@ def test_table_insert_chained_with_returning():
601
633
 
602
634
  def test_table_update_with_where():
603
635
  """Test table.update() with WHERE clause"""
604
- builder = Users.update({'username': 'bob_updated'}).where(Users.id == 5)
636
+ builder = Users.update(username='bob_updated').where(Users.id == 5)
605
637
  assert isinstance(builder, UpdateBuilder)
606
638
 
607
639
  sql, params = builder.render()
@@ -615,7 +647,7 @@ def test_table_update_with_where():
615
647
 
616
648
  def test_table_update_multiple_conditions():
617
649
  """Test table.update() with multiple WHERE conditions"""
618
- builder = (Users.update({'username': 'bob_updated', 'email': 'new@example.com'})
650
+ builder = (Users.update(username='bob_updated', email='new@example.com')
619
651
  .where(Users.id > 10)
620
652
  .where(Users.created_at == None))
621
653
 
@@ -630,7 +662,7 @@ def test_table_update_multiple_conditions():
630
662
 
631
663
  def test_table_update_with_returning():
632
664
  """Test table.update() with RETURNING"""
633
- builder = Users.update({'username': 'bob_updated'}).where(Users.id == 5).returning()
665
+ builder = Users.update(username='bob_updated').where(Users.id == 5).returning()
634
666
  sql, params = builder.render()
635
667
 
636
668
  assert 'UPDATE users SET' in sql
@@ -681,7 +713,7 @@ def test_table_delete_with_returning():
681
713
  def test_update_with_t_string_where():
682
714
  """Test UpdateBuilder with raw t-string WHERE clause"""
683
715
  min_age = 18
684
- builder = Users.update({'username': 'adult'}).where(t"age >= {min_age}")
716
+ builder = Users.update(username='adult').where(t"age >= {min_age}")
685
717
 
686
718
  sql, params = builder.render()
687
719
 
@@ -1062,7 +1094,7 @@ def test_update_with_onupdate_default():
1062
1094
  title = SAColumn(String)
1063
1095
  updated_at = SAColumn(String, onupdate=lambda: 'updated_timestamp')
1064
1096
 
1065
- query = Articles.update({'title': 'Updated Title'}).where(Articles.id == '123')
1097
+ query = Articles.update(title='Updated Title').where(Articles.id == '123')
1066
1098
  sql, params = query.render()
1067
1099
 
1068
1100
  assert 'UPDATE articles SET' in sql
@@ -1082,7 +1114,7 @@ def test_update_overrides_onupdate():
1082
1114
  title = SAColumn(String)
1083
1115
  updated_at = SAColumn(String, onupdate=lambda: 'auto_timestamp')
1084
1116
 
1085
- query = Articles.update({'title': 'Updated', 'updated_at': 'manual_timestamp'}).where(Articles.id == '123')
1117
+ query = Articles.update(title='Updated', updated_at='manual_timestamp').where(Articles.id == '123')
1086
1118
  sql, params = query.render()
1087
1119
 
1088
1120
  assert 'UPDATE articles SET' in sql
@@ -1140,14 +1172,14 @@ def test_returning_cols_validation_update():
1140
1172
  import pytest
1141
1173
 
1142
1174
  # Test with malicious returning column
1143
- query = Users.update({'username': 'hacked'})
1175
+ query = Users.update(username='hacked')
1144
1176
  builder = query.returning("id, (SELECT password FROM admin_users LIMIT 1) AS stolen")
1145
1177
 
1146
1178
  with pytest.raises(ValueError, match="Invalid RETURNING column name"):
1147
1179
  builder.render()
1148
1180
 
1149
1181
  # Test with valid returning columns (should work)
1150
- query2 = Users.update({'username': 'updated'})
1182
+ query2 = Users.update(username='updated')
1151
1183
  builder2 = query2.where(Users.id == 5).returning("id", "username")
1152
1184
  sql, params = builder2.render()
1153
1185
 
@@ -136,7 +136,7 @@ async def test_update_with_returning(conn):
136
136
  age: int
137
137
 
138
138
  # Update with RETURNING
139
- query = TestUsers.update({'age': 31}).where(TestUsers.name == 'Alice').returning()
139
+ query = TestUsers.update(age=31).where(TestUsers.name == 'Alice').returning()
140
140
  sql, params = query.render()
141
141
 
142
142
  assert 'RETURNING *' in sql
@@ -196,7 +196,7 @@ async def test_update_without_returning(conn):
196
196
  age: int
197
197
 
198
198
  # Update without RETURNING
199
- query = TestUsers.update({'age': 31}).where(TestUsers.name == 'Alice')
199
+ query = TestUsers.update(age=31).where(TestUsers.name == 'Alice')
200
200
  sql, params = query.render()
201
201
 
202
202
  # Should NOT have RETURNING clause
@@ -278,7 +278,7 @@ async def test_helper_functions(conn):
278
278
  assert row[1] == 25
279
279
 
280
280
  # Test update
281
- query = tsql.update('test_users', {'age': 26}, 1)
281
+ query = tsql.update('test_users', 1, age=26)
282
282
  sql, params = query.render()
283
283
 
284
284
  await conn.execute(sql, params)
@@ -331,7 +331,7 @@ async def test_query_builder_select(conn):
331
331
  assert rows[0][0] == 'Alice'
332
332
 
333
333
  # Test ORDER BY
334
- query = TestUsers.select().order_by((TestUsers.age, 'DESC'))
334
+ query = TestUsers.select().order_by(TestUsers.age.desc())
335
335
  sql, params = query.render()
336
336
 
337
337
  cursor = await conn.execute(sql, params)
@@ -326,19 +326,23 @@ def insert(table: str, **values: Any) -> TSQL:
326
326
  return TSQL(t"INSERT INTO {table:literal} {values:as_values}")
327
327
 
328
328
 
329
- def update(table: str, values: dict[str, Any], id: str | int) -> TSQL:
329
+ def update(table: str, id: str | int, **values: Any) -> TSQL:
330
330
  """Helper function to build UPDATE queries for a single row
331
331
 
332
332
  Args:
333
333
  table: Table name
334
- values: Dictionary of column names and values to update
335
334
  id: ID value to update
335
+ **values: Column names and values to update as keyword arguments
336
336
 
337
337
  Returns:
338
338
  TSQL object representing the UPDATE query
339
+
340
+ Example:
341
+ update('users', 123, name='Bob', age=35)
342
+ Or with dict unpacking: update('users', 123, **my_dict)
339
343
  """
340
- if not isinstance(values, dict):
341
- raise ValueError("values must be a dictionary")
344
+ if not values:
345
+ raise ValueError("update requires at least one column value")
342
346
 
343
347
  return TSQL(t"UPDATE {table:literal} SET {values:as_set} WHERE id = {id}")
344
348
 
@@ -16,6 +16,17 @@ except ImportError:
16
16
  SAColumnType = None
17
17
 
18
18
 
19
+ class OrderByClause:
20
+ """Represents a column with an ORDER BY direction (ASC/DESC)"""
21
+
22
+ def __init__(self, column: 'Column', direction: str):
23
+ self.column = column
24
+ self.direction = direction.upper()
25
+
26
+ def __repr__(self) -> str:
27
+ return f"OrderByClause({self.column!r}, {self.direction!r})"
28
+
29
+
19
30
  class Column:
20
31
  """Represents a bound column (table + column name) for building queries"""
21
32
 
@@ -140,6 +151,28 @@ class Column:
140
151
  """Create an IS NOT NULL condition"""
141
152
  return Condition(self, 'IS NOT', None)
142
153
 
154
+ def asc(self) -> OrderByClause:
155
+ """Create an ascending ORDER BY clause
156
+
157
+ Returns:
158
+ OrderByClause for use in order_by()
159
+
160
+ Example:
161
+ Users.select().order_by(Users.username.asc())
162
+ """
163
+ return OrderByClause(self, 'ASC')
164
+
165
+ def desc(self) -> OrderByClause:
166
+ """Create a descending ORDER BY clause
167
+
168
+ Returns:
169
+ OrderByClause for use in order_by()
170
+
171
+ Example:
172
+ Users.select().order_by(Users.created_at.desc())
173
+ """
174
+ return OrderByClause(self, 'DESC')
175
+
143
176
 
144
177
  class Table:
145
178
  """Base class for all table definitions. Provides query builder methods.
@@ -298,14 +331,18 @@ class Table:
298
331
  return InsertBuilder(cls, values)
299
332
 
300
333
  @classmethod
301
- def update(cls, values: dict[str, Any]) -> 'UpdateBuilder':
334
+ def update(cls, **values: Any) -> 'UpdateBuilder':
302
335
  """Start building an UPDATE query
303
336
 
304
337
  Args:
305
- values: Dictionary of column names and values to update
338
+ **values: Column names and values to update as keyword arguments
306
339
 
307
340
  Returns:
308
341
  UpdateBuilder for adding WHERE conditions
342
+
343
+ Example:
344
+ Users.update(username='bob', email='bob@example.com')
345
+ Or with dict unpacking: Users.update(**my_dict)
309
346
  """
310
347
  return UpdateBuilder(cls, values)
311
348
 
@@ -834,11 +871,22 @@ class SelectQueryBuilder(QueryBuilder):
834
871
  """Add a RIGHT JOIN clause"""
835
872
  return self.join(table, on, 'RIGHT')
836
873
 
837
- def order_by(self, *columns: Union[Column, tuple[Column, str]]) -> 'SelectQueryBuilder':
838
- """Add ORDER BY clause. Pass (column, 'DESC') for descending"""
874
+ def order_by(self, *columns: Union[Column, OrderByClause]) -> 'SelectQueryBuilder':
875
+ """Add ORDER BY clause
876
+
877
+ Args:
878
+ columns: Column objects or OrderByClause objects (from .asc()/.desc())
879
+
880
+ Examples:
881
+ # Using .asc() and .desc() methods
882
+ Users.select().order_by(Users.username.asc(), Users.id.desc())
883
+
884
+ # Bare column defaults to ASC
885
+ Users.select().order_by(Users.username)
886
+ """
839
887
  for col in columns:
840
- if isinstance(col, tuple):
841
- self._order_by_columns.append(col)
888
+ if isinstance(col, OrderByClause):
889
+ self._order_by_columns.append((col.column, col.direction))
842
890
  else:
843
891
  self._order_by_columns.append((col, 'ASC'))
844
892
  return self
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes