t-sql 2.2.1__tar.gz → 3.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. {t_sql-2.2.1 → t_sql-3.0.0}/PKG-INFO +97 -1
  2. {t_sql-2.2.1 → t_sql-3.0.0}/README.md +96 -0
  3. {t_sql-2.2.1 → t_sql-3.0.0}/pyproject.toml +1 -1
  4. {t_sql-2.2.1 → t_sql-3.0.0}/tests/test_helper_functions.py +2 -7
  5. {t_sql-2.2.1 → t_sql-3.0.0}/tests/test_mysql_integration.py +2 -2
  6. {t_sql-2.2.1 → t_sql-3.0.0}/tests/test_query_builder.py +9 -9
  7. {t_sql-2.2.1 → t_sql-3.0.0}/tests/test_sqlite_integration.py +3 -3
  8. {t_sql-2.2.1 → t_sql-3.0.0}/tsql/__init__.py +8 -4
  9. {t_sql-2.2.1 → t_sql-3.0.0}/tsql/query_builder.py +6 -2
  10. {t_sql-2.2.1 → t_sql-3.0.0}/.dockerignore +0 -0
  11. {t_sql-2.2.1 → t_sql-3.0.0}/.github/workflows/publish.yml +0 -0
  12. {t_sql-2.2.1 → t_sql-3.0.0}/.github/workflows/test.yml +0 -0
  13. {t_sql-2.2.1 → t_sql-3.0.0}/.gitignore +0 -0
  14. {t_sql-2.2.1 → t_sql-3.0.0}/Dockerfile +0 -0
  15. {t_sql-2.2.1 → t_sql-3.0.0}/LICENSE +0 -0
  16. {t_sql-2.2.1 → t_sql-3.0.0}/compose.yaml +0 -0
  17. {t_sql-2.2.1 → t_sql-3.0.0}/context7.json +0 -0
  18. {t_sql-2.2.1 → t_sql-3.0.0}/pytest.ini +0 -0
  19. {t_sql-2.2.1 → t_sql-3.0.0}/tests/test_alembic_integration.py +0 -0
  20. {t_sql-2.2.1 → t_sql-3.0.0}/tests/test_asyncpg_integration.py +0 -0
  21. {t_sql-2.2.1 → t_sql-3.0.0}/tests/test_different_object_types.py +0 -0
  22. {t_sql-2.2.1 → t_sql-3.0.0}/tests/test_escaped.py +0 -0
  23. {t_sql-2.2.1 → t_sql-3.0.0}/tests/test_escaped_binary_hex.py +0 -0
  24. {t_sql-2.2.1 → t_sql-3.0.0}/tests/test_injection_edge_cases.py +0 -0
  25. {t_sql-2.2.1 → t_sql-3.0.0}/tests/test_injection_protection_validation.py +0 -0
  26. {t_sql-2.2.1 → t_sql-3.0.0}/tests/test_injections_for_escaped.py +0 -0
  27. {t_sql-2.2.1 → t_sql-3.0.0}/tests/test_parameter_names.py +0 -0
  28. {t_sql-2.2.1 → t_sql-3.0.0}/tests/test_sqlalchemy_integration.py +0 -0
  29. {t_sql-2.2.1 → t_sql-3.0.0}/tests/test_styles.py +0 -0
  30. {t_sql-2.2.1 → t_sql-3.0.0}/tests/test_tsql.py +0 -0
  31. {t_sql-2.2.1 → t_sql-3.0.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.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
@@ -559,3 +559,99 @@ execute_sql_query("SELECT * FROM users") # ✗ Type error!
559
559
  ```
560
560
 
561
561
  The `TSQLQuery` type is a union of `TSQL`, `Template` (t-strings), and `QueryBuilder`, ensuring all queries are safe from SQL injection.
562
+
563
+ # Security Considerations
564
+
565
+ ## Overview
566
+
567
+ 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.
568
+
569
+ ## How t-sql Prevents SQL Injection
570
+
571
+ ### 1. Automatic Parameterization (Primary Defense)
572
+
573
+ By default, all interpolated values in t-strings are converted to parameterized queries:
574
+
575
+ ```python
576
+ # User input (potentially malicious)
577
+ user_input = "admin' OR 1=1 --"
578
+
579
+ # t-sql automatically parameterizes this
580
+ sql, params = tsql.render(t"SELECT * FROM users WHERE name = {user_input}")
581
+ # Result: ('SELECT * FROM users WHERE name = ?', ["admin' OR 1=1 --"])
582
+ ```
583
+
584
+ 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.
585
+
586
+ **Attack vectors prevented:**
587
+ - Classic injection: `' OR 1=1 --`
588
+ - Union-based: `' UNION SELECT * FROM secrets --`
589
+ - Stacked queries: `'; DROP TABLE users; --`
590
+ - Boolean-based blind: `' AND SLEEP(5) --`
591
+ - Authentication bypass: `admin'--`
592
+
593
+ ### 2. Literal Validation (Identifier Safety)
594
+
595
+ For table and column names that cannot be parameterized, use `:literal`:
596
+
597
+ ```python
598
+ table = "users"
599
+ col = "name"
600
+ sql, params = tsql.render(t"SELECT * FROM {table:literal} WHERE {col:literal} = {value}")
601
+ ```
602
+
603
+ **Validation rules:**
604
+ - Must be valid Python identifiers (`str.isidentifier()`)
605
+ - Supports qualified names: `table.column` or `schema.table.column` (max 3 parts)
606
+ - Rejects anything with spaces, quotes, or special characters
607
+
608
+ ```python
609
+ # These are REJECTED with ValueError:
610
+ bad_table = "users; DROP TABLE secrets" # Contains semicolon
611
+ bad_col = "name' OR 1=1" # Contains quote
612
+ bad_schema = "schema.table.column.extra" # Too many parts
613
+
614
+ tsql.render(t"SELECT * FROM {bad_table:literal}") # Raises ValueError
615
+ ```
616
+
617
+ **Attack vectors prevented:**
618
+ - Table/column injection: `users; DROP TABLE secrets`
619
+ - Second-order injection via identifiers
620
+ - Schema manipulation
621
+
622
+ ### 3. Escape-based Protection (ESCAPED Style)
623
+
624
+ For databases or scenarios where parameterization isn't available, the `ESCAPED` style properly escapes values:
625
+
626
+ ```python
627
+ malicious = "'; DROP TABLE users; --"
628
+ sql, _ = tsql.render(t"SELECT * FROM users WHERE name = {malicious}", style=tsql.styles.ESCAPED)
629
+ # Result: "SELECT * FROM users WHERE name = '''; DROP TABLE users; --'"
630
+ # (single quotes are doubled, making it literal data)
631
+ ```
632
+
633
+ **Important:** While effective, parameterization is always preferred when available. Use `ESCAPED` only when necessary.
634
+
635
+ ## Danger Zones: Where You Can Still Get Hurt
636
+
637
+ ### The :unsafe Format Spec
638
+
639
+ The `:unsafe` format spec **bypasses all safety mechanisms**:
640
+
641
+ ```python
642
+ # DANGEROUS - no validation or parameterization!
643
+ dynamic_sql = "age > 18 OR role = 'admin'" # If this comes from user input, you're vulnerable
644
+ sql, params = tsql.render(t"SELECT * FROM users WHERE {dynamic_sql:unsafe}")
645
+ ```
646
+
647
+ **When :unsafe is acceptable:**
648
+ - Hard-coded SQL fragments in your own code
649
+ - SQL generated by trusted, validated builder logic
650
+ - Dynamic ORDER BY clauses (after validation)
651
+
652
+ **When :unsafe is DANGEROUS:**
653
+ - **Never** with user input (even "validated" input)
654
+ - Dynamic WHERE clauses from external sources
655
+ - Any data from forms, APIs, or databases
656
+
657
+ **Recommendation:** Treat `:unsafe` like `eval()` in your code reviews. Every usage should be scrutinized and documented.
@@ -549,3 +549,99 @@ execute_sql_query("SELECT * FROM users") # ✗ Type error!
549
549
  ```
550
550
 
551
551
  The `TSQLQuery` type is a union of `TSQL`, `Template` (t-strings), and `QueryBuilder`, ensuring all queries are safe from SQL injection.
552
+
553
+ # Security Considerations
554
+
555
+ ## Overview
556
+
557
+ 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.
558
+
559
+ ## How t-sql Prevents SQL Injection
560
+
561
+ ### 1. Automatic Parameterization (Primary Defense)
562
+
563
+ By default, all interpolated values in t-strings are converted to parameterized queries:
564
+
565
+ ```python
566
+ # User input (potentially malicious)
567
+ user_input = "admin' OR 1=1 --"
568
+
569
+ # t-sql automatically parameterizes this
570
+ sql, params = tsql.render(t"SELECT * FROM users WHERE name = {user_input}")
571
+ # Result: ('SELECT * FROM users WHERE name = ?', ["admin' OR 1=1 --"])
572
+ ```
573
+
574
+ 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.
575
+
576
+ **Attack vectors prevented:**
577
+ - Classic injection: `' OR 1=1 --`
578
+ - Union-based: `' UNION SELECT * FROM secrets --`
579
+ - Stacked queries: `'; DROP TABLE users; --`
580
+ - Boolean-based blind: `' AND SLEEP(5) --`
581
+ - Authentication bypass: `admin'--`
582
+
583
+ ### 2. Literal Validation (Identifier Safety)
584
+
585
+ For table and column names that cannot be parameterized, use `:literal`:
586
+
587
+ ```python
588
+ table = "users"
589
+ col = "name"
590
+ sql, params = tsql.render(t"SELECT * FROM {table:literal} WHERE {col:literal} = {value}")
591
+ ```
592
+
593
+ **Validation rules:**
594
+ - Must be valid Python identifiers (`str.isidentifier()`)
595
+ - Supports qualified names: `table.column` or `schema.table.column` (max 3 parts)
596
+ - Rejects anything with spaces, quotes, or special characters
597
+
598
+ ```python
599
+ # These are REJECTED with ValueError:
600
+ bad_table = "users; DROP TABLE secrets" # Contains semicolon
601
+ bad_col = "name' OR 1=1" # Contains quote
602
+ bad_schema = "schema.table.column.extra" # Too many parts
603
+
604
+ tsql.render(t"SELECT * FROM {bad_table:literal}") # Raises ValueError
605
+ ```
606
+
607
+ **Attack vectors prevented:**
608
+ - Table/column injection: `users; DROP TABLE secrets`
609
+ - Second-order injection via identifiers
610
+ - Schema manipulation
611
+
612
+ ### 3. Escape-based Protection (ESCAPED Style)
613
+
614
+ For databases or scenarios where parameterization isn't available, the `ESCAPED` style properly escapes values:
615
+
616
+ ```python
617
+ malicious = "'; DROP TABLE users; --"
618
+ sql, _ = tsql.render(t"SELECT * FROM users WHERE name = {malicious}", style=tsql.styles.ESCAPED)
619
+ # Result: "SELECT * FROM users WHERE name = '''; DROP TABLE users; --'"
620
+ # (single quotes are doubled, making it literal data)
621
+ ```
622
+
623
+ **Important:** While effective, parameterization is always preferred when available. Use `ESCAPED` only when necessary.
624
+
625
+ ## Danger Zones: Where You Can Still Get Hurt
626
+
627
+ ### The :unsafe Format Spec
628
+
629
+ The `:unsafe` format spec **bypasses all safety mechanisms**:
630
+
631
+ ```python
632
+ # DANGEROUS - no validation or parameterization!
633
+ dynamic_sql = "age > 18 OR role = 'admin'" # If this comes from user input, you're vulnerable
634
+ sql, params = tsql.render(t"SELECT * FROM users WHERE {dynamic_sql:unsafe}")
635
+ ```
636
+
637
+ **When :unsafe is acceptable:**
638
+ - Hard-coded SQL fragments in your own code
639
+ - SQL generated by trusted, validated builder logic
640
+ - Dynamic ORDER BY clauses (after validation)
641
+
642
+ **When :unsafe is DANGEROUS:**
643
+ - **Never** with user input (even "validated" input)
644
+ - Dynamic WHERE clauses from external sources
645
+ - Any data from forms, APIs, or databases
646
+
647
+ **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.0.0"
8
8
  description = "Safe SQL. SQL queries for python t-strings (PEP 750)"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.14"
@@ -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)
@@ -351,7 +351,7 @@ def test_schema_in_update():
351
351
  id: Column
352
352
  name: Column
353
353
 
354
- query = Accounts.update({'name': 'Updated Account'}).where(Accounts.id == '1')
354
+ query = Accounts.update(name='Updated Account').where(Accounts.id == '1')
355
355
  sql, params = query.render()
356
356
 
357
357
  assert 'UPDATE other.accounts SET' in sql
@@ -601,7 +601,7 @@ def test_table_insert_chained_with_returning():
601
601
 
602
602
  def test_table_update_with_where():
603
603
  """Test table.update() with WHERE clause"""
604
- builder = Users.update({'username': 'bob_updated'}).where(Users.id == 5)
604
+ builder = Users.update(username='bob_updated').where(Users.id == 5)
605
605
  assert isinstance(builder, UpdateBuilder)
606
606
 
607
607
  sql, params = builder.render()
@@ -615,7 +615,7 @@ def test_table_update_with_where():
615
615
 
616
616
  def test_table_update_multiple_conditions():
617
617
  """Test table.update() with multiple WHERE conditions"""
618
- builder = (Users.update({'username': 'bob_updated', 'email': 'new@example.com'})
618
+ builder = (Users.update(username='bob_updated', email='new@example.com')
619
619
  .where(Users.id > 10)
620
620
  .where(Users.created_at == None))
621
621
 
@@ -630,7 +630,7 @@ def test_table_update_multiple_conditions():
630
630
 
631
631
  def test_table_update_with_returning():
632
632
  """Test table.update() with RETURNING"""
633
- builder = Users.update({'username': 'bob_updated'}).where(Users.id == 5).returning()
633
+ builder = Users.update(username='bob_updated').where(Users.id == 5).returning()
634
634
  sql, params = builder.render()
635
635
 
636
636
  assert 'UPDATE users SET' in sql
@@ -681,7 +681,7 @@ def test_table_delete_with_returning():
681
681
  def test_update_with_t_string_where():
682
682
  """Test UpdateBuilder with raw t-string WHERE clause"""
683
683
  min_age = 18
684
- builder = Users.update({'username': 'adult'}).where(t"age >= {min_age}")
684
+ builder = Users.update(username='adult').where(t"age >= {min_age}")
685
685
 
686
686
  sql, params = builder.render()
687
687
 
@@ -1062,7 +1062,7 @@ def test_update_with_onupdate_default():
1062
1062
  title = SAColumn(String)
1063
1063
  updated_at = SAColumn(String, onupdate=lambda: 'updated_timestamp')
1064
1064
 
1065
- query = Articles.update({'title': 'Updated Title'}).where(Articles.id == '123')
1065
+ query = Articles.update(title='Updated Title').where(Articles.id == '123')
1066
1066
  sql, params = query.render()
1067
1067
 
1068
1068
  assert 'UPDATE articles SET' in sql
@@ -1082,7 +1082,7 @@ def test_update_overrides_onupdate():
1082
1082
  title = SAColumn(String)
1083
1083
  updated_at = SAColumn(String, onupdate=lambda: 'auto_timestamp')
1084
1084
 
1085
- query = Articles.update({'title': 'Updated', 'updated_at': 'manual_timestamp'}).where(Articles.id == '123')
1085
+ query = Articles.update(title='Updated', updated_at='manual_timestamp').where(Articles.id == '123')
1086
1086
  sql, params = query.render()
1087
1087
 
1088
1088
  assert 'UPDATE articles SET' in sql
@@ -1140,14 +1140,14 @@ def test_returning_cols_validation_update():
1140
1140
  import pytest
1141
1141
 
1142
1142
  # Test with malicious returning column
1143
- query = Users.update({'username': 'hacked'})
1143
+ query = Users.update(username='hacked')
1144
1144
  builder = query.returning("id, (SELECT password FROM admin_users LIMIT 1) AS stolen")
1145
1145
 
1146
1146
  with pytest.raises(ValueError, match="Invalid RETURNING column name"):
1147
1147
  builder.render()
1148
1148
 
1149
1149
  # Test with valid returning columns (should work)
1150
- query2 = Users.update({'username': 'updated'})
1150
+ query2 = Users.update(username='updated')
1151
1151
  builder2 = query2.where(Users.id == 5).returning("id", "username")
1152
1152
  sql, params = builder2.render()
1153
1153
 
@@ -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)
@@ -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
 
@@ -298,14 +298,18 @@ class Table:
298
298
  return InsertBuilder(cls, values)
299
299
 
300
300
  @classmethod
301
- def update(cls, values: dict[str, Any]) -> 'UpdateBuilder':
301
+ def update(cls, **values: Any) -> 'UpdateBuilder':
302
302
  """Start building an UPDATE query
303
303
 
304
304
  Args:
305
- values: Dictionary of column names and values to update
305
+ **values: Column names and values to update as keyword arguments
306
306
 
307
307
  Returns:
308
308
  UpdateBuilder for adding WHERE conditions
309
+
310
+ Example:
311
+ Users.update(username='bob', email='bob@example.com')
312
+ Or with dict unpacking: Users.update(**my_dict)
309
313
  """
310
314
  return UpdateBuilder(cls, values)
311
315
 
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