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.
- {t_sql-4.5.1 → t_sql-4.5.2}/PKG-INFO +1 -1
- {t_sql-4.5.1 → t_sql-4.5.2}/pyproject.toml +1 -1
- t_sql-4.5.2/tests/test_error_messages.py +98 -0
- {t_sql-4.5.1 → t_sql-4.5.2}/tests/test_query_builder.py +53 -0
- {t_sql-4.5.1 → t_sql-4.5.2}/tsql/__init__.py +43 -6
- {t_sql-4.5.1 → t_sql-4.5.2}/.dockerignore +0 -0
- {t_sql-4.5.1 → t_sql-4.5.2}/.github/workflows/publish.yml +0 -0
- {t_sql-4.5.1 → t_sql-4.5.2}/.github/workflows/test.yml +0 -0
- {t_sql-4.5.1 → t_sql-4.5.2}/.gitignore +0 -0
- {t_sql-4.5.1 → t_sql-4.5.2}/Dockerfile +0 -0
- {t_sql-4.5.1 → t_sql-4.5.2}/LICENSE +0 -0
- {t_sql-4.5.1 → t_sql-4.5.2}/README.md +0 -0
- {t_sql-4.5.1 → t_sql-4.5.2}/compose.yaml +0 -0
- {t_sql-4.5.1 → t_sql-4.5.2}/context7.json +0 -0
- {t_sql-4.5.1 → t_sql-4.5.2}/pytest.ini +0 -0
- {t_sql-4.5.1 → t_sql-4.5.2}/tests/test_alembic_integration.py +0 -0
- {t_sql-4.5.1 → t_sql-4.5.2}/tests/test_asyncpg_integration.py +0 -0
- {t_sql-4.5.1 → t_sql-4.5.2}/tests/test_different_object_types.py +0 -0
- {t_sql-4.5.1 → t_sql-4.5.2}/tests/test_escaped.py +0 -0
- {t_sql-4.5.1 → t_sql-4.5.2}/tests/test_escaped_binary_hex.py +0 -0
- {t_sql-4.5.1 → t_sql-4.5.2}/tests/test_helper_functions.py +0 -0
- {t_sql-4.5.1 → t_sql-4.5.2}/tests/test_injection_edge_cases.py +0 -0
- {t_sql-4.5.1 → t_sql-4.5.2}/tests/test_injection_protection_validation.py +0 -0
- {t_sql-4.5.1 → t_sql-4.5.2}/tests/test_injections_for_escaped.py +0 -0
- {t_sql-4.5.1 → t_sql-4.5.2}/tests/test_mysql_integration.py +0 -0
- {t_sql-4.5.1 → t_sql-4.5.2}/tests/test_parameter_names.py +0 -0
- {t_sql-4.5.1 → t_sql-4.5.2}/tests/test_sqlalchemy_integration.py +0 -0
- {t_sql-4.5.1 → t_sql-4.5.2}/tests/test_sqlite_integration.py +0 -0
- {t_sql-4.5.1 → t_sql-4.5.2}/tests/test_styles.py +0 -0
- {t_sql-4.5.1 → t_sql-4.5.2}/tests/test_template_in_builders.py +0 -0
- {t_sql-4.5.1 → t_sql-4.5.2}/tests/test_tsql.py +0 -0
- {t_sql-4.5.1 → t_sql-4.5.2}/tests/test_type_processor.py +0 -0
- {t_sql-4.5.1 → t_sql-4.5.2}/tsql/query_builder.py +0 -0
- {t_sql-4.5.1 → t_sql-4.5.2}/tsql/row.py +0 -0
- {t_sql-4.5.1 → t_sql-4.5.2}/tsql/styles.py +0 -0
- {t_sql-4.5.1 → t_sql-4.5.2}/tsql/type_processor.py +0 -0
|
@@ -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(
|
|
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(
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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(
|
|
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
|
|
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
|