t-sql 4.5.1__tar.gz → 4.5.2__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 (36) hide show
  1. {t_sql-4.5.1 → t_sql-4.5.2}/PKG-INFO +1 -1
  2. {t_sql-4.5.1 → t_sql-4.5.2}/pyproject.toml +1 -1
  3. t_sql-4.5.2/tests/test_error_messages.py +98 -0
  4. {t_sql-4.5.1 → t_sql-4.5.2}/tests/test_query_builder.py +53 -0
  5. {t_sql-4.5.1 → t_sql-4.5.2}/tsql/__init__.py +43 -6
  6. {t_sql-4.5.1 → t_sql-4.5.2}/.dockerignore +0 -0
  7. {t_sql-4.5.1 → t_sql-4.5.2}/.github/workflows/publish.yml +0 -0
  8. {t_sql-4.5.1 → t_sql-4.5.2}/.github/workflows/test.yml +0 -0
  9. {t_sql-4.5.1 → t_sql-4.5.2}/.gitignore +0 -0
  10. {t_sql-4.5.1 → t_sql-4.5.2}/Dockerfile +0 -0
  11. {t_sql-4.5.1 → t_sql-4.5.2}/LICENSE +0 -0
  12. {t_sql-4.5.1 → t_sql-4.5.2}/README.md +0 -0
  13. {t_sql-4.5.1 → t_sql-4.5.2}/compose.yaml +0 -0
  14. {t_sql-4.5.1 → t_sql-4.5.2}/context7.json +0 -0
  15. {t_sql-4.5.1 → t_sql-4.5.2}/pytest.ini +0 -0
  16. {t_sql-4.5.1 → t_sql-4.5.2}/tests/test_alembic_integration.py +0 -0
  17. {t_sql-4.5.1 → t_sql-4.5.2}/tests/test_asyncpg_integration.py +0 -0
  18. {t_sql-4.5.1 → t_sql-4.5.2}/tests/test_different_object_types.py +0 -0
  19. {t_sql-4.5.1 → t_sql-4.5.2}/tests/test_escaped.py +0 -0
  20. {t_sql-4.5.1 → t_sql-4.5.2}/tests/test_escaped_binary_hex.py +0 -0
  21. {t_sql-4.5.1 → t_sql-4.5.2}/tests/test_helper_functions.py +0 -0
  22. {t_sql-4.5.1 → t_sql-4.5.2}/tests/test_injection_edge_cases.py +0 -0
  23. {t_sql-4.5.1 → t_sql-4.5.2}/tests/test_injection_protection_validation.py +0 -0
  24. {t_sql-4.5.1 → t_sql-4.5.2}/tests/test_injections_for_escaped.py +0 -0
  25. {t_sql-4.5.1 → t_sql-4.5.2}/tests/test_mysql_integration.py +0 -0
  26. {t_sql-4.5.1 → t_sql-4.5.2}/tests/test_parameter_names.py +0 -0
  27. {t_sql-4.5.1 → t_sql-4.5.2}/tests/test_sqlalchemy_integration.py +0 -0
  28. {t_sql-4.5.1 → t_sql-4.5.2}/tests/test_sqlite_integration.py +0 -0
  29. {t_sql-4.5.1 → t_sql-4.5.2}/tests/test_styles.py +0 -0
  30. {t_sql-4.5.1 → t_sql-4.5.2}/tests/test_template_in_builders.py +0 -0
  31. {t_sql-4.5.1 → t_sql-4.5.2}/tests/test_tsql.py +0 -0
  32. {t_sql-4.5.1 → t_sql-4.5.2}/tests/test_type_processor.py +0 -0
  33. {t_sql-4.5.1 → t_sql-4.5.2}/tsql/query_builder.py +0 -0
  34. {t_sql-4.5.1 → t_sql-4.5.2}/tsql/row.py +0 -0
  35. {t_sql-4.5.1 → t_sql-4.5.2}/tsql/styles.py +0 -0
  36. {t_sql-4.5.1 → t_sql-4.5.2}/tsql/type_processor.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: t-sql
3
- Version: 4.5.1
3
+ Version: 4.5.2
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "t-sql"
7
- version = "4.5.1"
7
+ version = "4.5.2"
8
8
  description = "Safe SQL. SQL queries for python t-strings (PEP 750)"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.14"
@@ -0,0 +1,98 @@
1
+ """Tests for error message clarity and helpfulness"""
2
+ import pytest
3
+ import tsql
4
+
5
+
6
+ def test_literal_non_string_gets_parameterized():
7
+ """Non-string values with :literal format spec just get parameterized (not an error)"""
8
+ # This is actually fine behavior - the :literal format spec is ignored for non-strings
9
+ # and they just get parameterized normally
10
+ sql, params = tsql.render(t"SELECT * FROM table WHERE id = {123:literal}")
11
+ assert sql == "SELECT * FROM table WHERE id = ?"
12
+ assert params == [123]
13
+
14
+
15
+ def test_literal_too_many_parts_error():
16
+ """Too many parts should show expected count and list all parts"""
17
+ table = "a.b.c.d"
18
+ with pytest.raises(ValueError) as exc_info:
19
+ tsql.render(t"SELECT * FROM {table:literal}")
20
+
21
+ error_msg = str(exc_info.value)
22
+ assert "too many parts" in error_msg
23
+ assert "expected at most 3" in error_msg
24
+ assert "got 4" in error_msg
25
+ assert "'a'" in error_msg and "'b'" in error_msg and "'c'" in error_msg and "'d'" in error_msg
26
+
27
+
28
+ def test_literal_empty_string_error():
29
+ """Empty string should explain it's not a valid identifier and show examples"""
30
+ table = ""
31
+ with pytest.raises(ValueError) as exc_info:
32
+ tsql.render(t"SELECT * FROM {table:literal}")
33
+
34
+ error_msg = str(exc_info.value)
35
+ assert "empty string is not a valid identifier" in error_msg
36
+ assert "users" in error_msg or "public.users" in error_msg # Should show examples
37
+
38
+
39
+ def test_literal_invalid_identifier_error():
40
+ """Invalid identifiers should show which parts are invalid and explain rules"""
41
+ table = "my-table" # Hyphens not allowed
42
+ with pytest.raises(ValueError) as exc_info:
43
+ tsql.render(t"SELECT * FROM {table:literal}")
44
+
45
+ error_msg = str(exc_info.value)
46
+ assert "invalid identifier" in error_msg
47
+ assert "'my-table'" in error_msg
48
+ assert "valid Python identifier" in error_msg
49
+ assert "letters, digits, underscores" in error_msg
50
+
51
+
52
+ def test_literal_starts_with_digit_error():
53
+ """Identifiers starting with digits should be caught with helpful message"""
54
+ table = "123users"
55
+ with pytest.raises(ValueError) as exc_info:
56
+ tsql.render(t"SELECT * FROM {table:literal}")
57
+
58
+ error_msg = str(exc_info.value)
59
+ assert "invalid identifier" in error_msg
60
+ assert "'123users'" in error_msg
61
+ assert "valid Python identifier" in error_msg
62
+
63
+
64
+ def test_literal_special_chars_error():
65
+ """Special characters should be caught and suggest :unsafe if needed"""
66
+ table = "users@prod" # @ not allowed
67
+ with pytest.raises(ValueError) as exc_info:
68
+ tsql.render(t"SELECT * FROM {table:literal}")
69
+
70
+ error_msg = str(exc_info.value)
71
+ assert "invalid identifier" in error_msg
72
+ assert "'users@prod'" in error_msg
73
+ assert ":unsafe" in error_msg # Suggests alternative
74
+
75
+
76
+ def test_literal_qualified_name_with_invalid_part():
77
+ """Qualified names with one invalid part should identify which part is bad"""
78
+ table = "public.my-table" # Second part has hyphen
79
+ with pytest.raises(ValueError) as exc_info:
80
+ tsql.render(t"SELECT * FROM {table:literal}")
81
+
82
+ error_msg = str(exc_info.value)
83
+ assert "invalid identifier" in error_msg
84
+ assert "'my-table'" in error_msg # Should identify the bad part
85
+ # Should NOT complain about 'public' since it's valid
86
+
87
+
88
+ def test_literal_sql_keyword_suggestion():
89
+ """Error for SQL keywords should suggest :unsafe workaround"""
90
+ # Note: SQL keywords like 'select' are actually valid Python identifiers,
91
+ # so this tests the general suggestion about :unsafe for special cases
92
+ table = "table-name"
93
+ with pytest.raises(ValueError) as exc_info:
94
+ tsql.render(t"SELECT * FROM {table:literal}")
95
+
96
+ error_msg = str(exc_info.value)
97
+ assert ":unsafe" in error_msg
98
+ assert "caution" in error_msg
@@ -1378,3 +1378,56 @@ def test_delete_with_all_rows_works():
1378
1378
  assert 'DELETE FROM users' in sql
1379
1379
  assert 'WHERE' not in sql
1380
1380
  assert params == []
1381
+
1382
+
1383
+ def test_nested_querybuilder_in_tstring():
1384
+ """Test that QueryBuilder can be interpolated into t-strings as subqueries"""
1385
+ # Create a subquery
1386
+ subquery = Posts.select(Posts.user_id).where(Posts.id == 42)
1387
+
1388
+ # Interpolate it into a regular t-string
1389
+ query = tsql.TSQL(t'SELECT *, ({subquery}) as post_user FROM users')
1390
+ sql, params = query.render()
1391
+
1392
+ # The subquery SQL should be inlined, not parameterized
1393
+ assert 'SELECT *,' in sql
1394
+ assert 'SELECT posts.user_id FROM posts WHERE posts.id = ?' in sql
1395
+ assert 'as post_user FROM users' in sql
1396
+ # The parameter from the subquery should be in the params list
1397
+ assert params == [42]
1398
+
1399
+
1400
+ def test_nested_querybuilder_with_multiple_params():
1401
+ """Test nested QueryBuilder with multiple parameters from both queries"""
1402
+ # Create a subquery with a parameter
1403
+ subquery = Posts.select(Posts.id).where(Posts.user_id == 100)
1404
+
1405
+ # Use it in a main query that also has parameters
1406
+ query = tsql.TSQL(t'SELECT * FROM users WHERE id = {5} AND post_count > ({subquery})')
1407
+ sql, params = query.render()
1408
+
1409
+ # Both parameters should be in the list
1410
+ assert params == [5, 100]
1411
+ # The subquery should be inlined
1412
+ assert 'SELECT posts.id FROM posts WHERE posts.user_id = ?' in sql
1413
+
1414
+
1415
+ def test_nested_querybuilder_with_different_styles():
1416
+ """Test that nested QueryBuilders work with different parameter styles"""
1417
+ from tsql.styles import NUMERIC_DOLLAR, NAMED
1418
+
1419
+ subquery = Posts.select(Posts.user_id).where(Posts.id == 42)
1420
+
1421
+ # Test with NUMERIC_DOLLAR style
1422
+ query = tsql.TSQL(t'SELECT *, ({subquery}) as post_user FROM users WHERE id = {5}')
1423
+ sql, params = query.render(style=NUMERIC_DOLLAR)
1424
+ assert '$1' in sql and '$2' in sql
1425
+ assert params == [42, 5]
1426
+
1427
+ # Test with NAMED style
1428
+ sql, params = query.render(style=NAMED)
1429
+ # Check that both parameters are present (names may vary)
1430
+ assert isinstance(params, dict)
1431
+ assert len(params) == 2
1432
+ assert 42 in params.values() and 5 in params.values()
1433
+ assert 'SELECT posts.user_id FROM posts WHERE posts.id = :' in sql
@@ -1,11 +1,17 @@
1
1
  import re
2
2
  import string
3
3
  import datetime
4
+ import logging
4
5
  from typing import NamedTuple, Tuple, Any, List, Dict, Iterable, Union, TYPE_CHECKING
5
6
  from string.templatelib import Template, Interpolation
6
7
 
7
8
  from tsql.styles import ParamStyle, QMARK
8
9
 
10
+ logger = logging.getLogger(__name__)
11
+
12
+ # Pre-compile regex for whitespace collapsing to avoid cache lookup overhead
13
+ _WHITESPACE_RE = re.compile(r'\s+')
14
+
9
15
  if TYPE_CHECKING:
10
16
  from tsql.query_builder import QueryBuilder
11
17
 
@@ -13,6 +19,7 @@ default_style = QMARK
13
19
 
14
20
  def set_style(style: type[ParamStyle]):
15
21
  global default_style
22
+ logger.debug("Setting default parameter style to: %s", style.__name__)
16
23
  default_style = style
17
24
 
18
25
 
@@ -50,6 +57,7 @@ class TSQL:
50
57
  def render(self, style:ParamStyle = None) -> RenderedQuery:
51
58
  if style is None:
52
59
  style = default_style
60
+ logger.debug("Rendering query with style: %s", style.__name__)
53
61
  result = ''
54
62
 
55
63
  style_instance = style()
@@ -61,6 +69,8 @@ class TSQL:
61
69
  else:
62
70
  result += part
63
71
 
72
+ logger.debug("Rendered SQL: %s", result)
73
+ logger.debug("Parameters (%d): %s", len(style_instance.params), style_instance.params)
64
74
  return RenderedQuery(result, style_instance.params)
65
75
 
66
76
 
@@ -75,16 +85,34 @@ class TSQL:
75
85
  @classmethod
76
86
  def _check_literal(cls, val: str):
77
87
  if not isinstance(val, str):
78
- raise ValueError(f"Invalid literal {val}")
88
+ raise ValueError(
89
+ f"Invalid literal {val!r}: literals must be strings, got {type(val).__name__}. "
90
+ f"Use {{value}} without :literal to parameterize non-string values."
91
+ )
79
92
 
80
93
  # Allow qualified identifiers (table.column, schema.table.column)
81
94
  parts = val.split('.')
82
95
 
83
96
  if len(parts) > 3:
84
- raise ValueError(f"Invalid literal {val}: too many parts (max 3 for schema.table.column)")
85
-
86
- if not parts or not all(part.isidentifier() for part in parts):
87
- raise ValueError(f"Invalid literal {val}")
97
+ raise ValueError(
98
+ f"Invalid literal {val!r}: too many parts (expected at most 3 for schema.table.column, got {len(parts)}). "
99
+ f"Parts: {', '.join(repr(p) for p in parts)}"
100
+ )
101
+
102
+ # Check for empty string or empty parts
103
+ if not val or any(not p for p in parts):
104
+ raise ValueError(
105
+ f"Invalid literal {val!r}: empty string is not a valid identifier. "
106
+ f"Literals must be valid Python identifiers (e.g., 'users', 'public.users', 'schema.table.column')."
107
+ )
108
+
109
+ invalid_parts = [p for p in parts if not p.isidentifier()]
110
+ if invalid_parts:
111
+ raise ValueError(
112
+ f"Invalid literal {val!r}: contains invalid identifier(s): {', '.join(repr(p) for p in invalid_parts)}. "
113
+ f"Each part must be a valid Python identifier (letters, digits, underscores; cannot start with digit). "
114
+ f"If you need special characters or SQL keywords, consider using {{value:unsafe}} with caution."
115
+ )
88
116
  return val
89
117
 
90
118
  @classmethod
@@ -96,11 +124,17 @@ class TSQL:
96
124
  if val.conversion:
97
125
  value = formatter.convert_field(value, val.conversion)
98
126
 
127
+ logger.debug("Processing interpolation: expression=%r, format_spec=%r, value_type=%s",
128
+ val.expression, val.format_spec, type(value).__name__)
129
+
99
130
  match val.format_spec, value:
100
131
  case 'literal', str():
132
+ logger.debug("Validating literal: %r", value)
101
133
  cls._check_literal(value)
134
+ logger.debug("Literal validated, inlining: %r", value)
102
135
  return [value]
103
136
  case 'unsafe', str():
137
+ logger.debug("Using unsafe inline value: %r", value)
104
138
  return [value]
105
139
  case 'as_values', dict():
106
140
  return as_values(value)._sql_parts
@@ -110,6 +144,9 @@ class TSQL:
110
144
  return val.value._sql_parts
111
145
  case "", Template():
112
146
  return TSQL(value)._sql_parts
147
+ case '', x if hasattr(x, 'to_tsql'):
148
+ logger.debug("Inlining QueryBuilder object: %s", type(x).__name__)
149
+ return x.to_tsql()._sql_parts
113
150
  case '', None:
114
151
  return [Parameter(val.expression, None)]
115
152
  # case 'as_array', list():
@@ -139,7 +176,7 @@ class TSQL:
139
176
  if isinstance(item, Interpolation):
140
177
  result.extend(cls._sqlize(item))
141
178
  else:
142
- result.append(re.sub(r'\s+', ' ', item))
179
+ result.append(_WHITESPACE_RE.sub(' ', item))
143
180
  return result
144
181
 
145
182
  raise ValueError(f"UNSAFE {val}") # this shouldnt happen and is for debugging
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