t-sql 2.0.0__tar.gz → 2.1.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-2.0.0 → t_sql-2.1.2}/PKG-INFO +2 -1
- {t_sql-2.0.0 → t_sql-2.1.2}/context7.json +10 -0
- {t_sql-2.0.0 → t_sql-2.1.2}/pyproject.toml +4 -2
- t_sql-2.1.2/tests/test_alembic_integration.py +363 -0
- {t_sql-2.0.0 → t_sql-2.1.2}/tests/test_helper_functions.py +2 -8
- {t_sql-2.0.0 → t_sql-2.1.2}/tests/test_injection_edge_cases.py +153 -1
- {t_sql-2.0.0 → t_sql-2.1.2}/tests/test_mysql_integration.py +5 -10
- {t_sql-2.0.0 → t_sql-2.1.2}/tests/test_query_builder.py +357 -10
- {t_sql-2.0.0 → t_sql-2.1.2}/tests/test_sqlite_integration.py +6 -12
- {t_sql-2.0.0 → t_sql-2.1.2}/tests/test_styles.py +24 -0
- {t_sql-2.0.0 → t_sql-2.1.2}/tsql/__init__.py +29 -7
- {t_sql-2.0.0 → t_sql-2.1.2}/tsql/query_builder.py +176 -38
- {t_sql-2.0.0 → t_sql-2.1.2}/.dockerignore +0 -0
- {t_sql-2.0.0 → t_sql-2.1.2}/.github/workflows/publish.yml +0 -0
- {t_sql-2.0.0 → t_sql-2.1.2}/.github/workflows/test.yml +0 -0
- {t_sql-2.0.0 → t_sql-2.1.2}/.gitignore +0 -0
- {t_sql-2.0.0 → t_sql-2.1.2}/Dockerfile +0 -0
- {t_sql-2.0.0 → t_sql-2.1.2}/LICENSE +0 -0
- {t_sql-2.0.0 → t_sql-2.1.2}/README.md +0 -0
- {t_sql-2.0.0 → t_sql-2.1.2}/compose.yaml +0 -0
- {t_sql-2.0.0 → t_sql-2.1.2}/pytest.ini +0 -0
- {t_sql-2.0.0 → t_sql-2.1.2}/tests/test_asyncpg_integration.py +0 -0
- {t_sql-2.0.0 → t_sql-2.1.2}/tests/test_different_object_types.py +0 -0
- {t_sql-2.0.0 → t_sql-2.1.2}/tests/test_escaped.py +0 -0
- {t_sql-2.0.0 → t_sql-2.1.2}/tests/test_escaped_binary_hex.py +0 -0
- {t_sql-2.0.0 → t_sql-2.1.2}/tests/test_injection_protection_validation.py +0 -0
- {t_sql-2.0.0 → t_sql-2.1.2}/tests/test_injections_for_escaped.py +0 -0
- {t_sql-2.0.0 → t_sql-2.1.2}/tests/test_sqlalchemy_integration.py +0 -0
- {t_sql-2.0.0 → t_sql-2.1.2}/tests/test_tsql.py +0 -0
- {t_sql-2.0.0 → t_sql-2.1.2}/tsql/styles.py +0 -0
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: t-sql
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.1.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
|
|
7
7
|
Requires-Python: >=3.14
|
|
8
|
+
Requires-Dist: alembic>=1.17.0
|
|
8
9
|
Description-Content-Type: text/markdown
|
|
9
10
|
|
|
10
11
|
# t-sql
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
{
|
|
2
|
+
"$schema": "https://context7.com/schema/context7.json",
|
|
3
|
+
"projectTitle": "t-sql",
|
|
4
|
+
"description": "t-sql is a lightweight SQL templating library for Python 3.14+ that leverages t-strings to safely build SQL queries, preventing injection attacks, and includes a query builder and SQLAlchemy integration.",
|
|
5
|
+
"branch": "main",
|
|
2
6
|
"excludeFolders": [
|
|
3
7
|
"tests",
|
|
4
8
|
".github",
|
|
@@ -22,5 +26,11 @@
|
|
|
22
26
|
"When mixing query builder with t-strings using .where(), t-string conditions are automatically wrapped in parentheses for proper operator precedence.",
|
|
23
27
|
"The @table decorator returns an instance - use the decorated class directly, don't instantiate it.",
|
|
24
28
|
"Default parameter style is QMARK (?). Use tsql.styles.NUMERIC_DOLLAR for PostgreSQL ($1, $2), or other styles as needed."
|
|
29
|
+
],
|
|
30
|
+
"previousVersions": [
|
|
31
|
+
{
|
|
32
|
+
"tag": "v1_2_1",
|
|
33
|
+
"title": "v1.2.1"
|
|
34
|
+
}
|
|
25
35
|
]
|
|
26
36
|
}
|
|
@@ -4,11 +4,13 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "t-sql"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.1.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"
|
|
11
|
-
dependencies = [
|
|
11
|
+
dependencies = [
|
|
12
|
+
"alembic>=1.17.0",
|
|
13
|
+
]
|
|
12
14
|
|
|
13
15
|
[project.urls]
|
|
14
16
|
Homepage = "https://github.com/nhumrich/t-sql"
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import tempfile
|
|
2
|
+
import shutil
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from textwrap import dedent
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from sqlalchemy import MetaData, Column, String, Integer, Boolean, ForeignKey, TIMESTAMP, create_engine
|
|
9
|
+
from sqlalchemy.dialects.postgresql import JSONB
|
|
10
|
+
from alembic.config import Config
|
|
11
|
+
from alembic.script import ScriptDirectory
|
|
12
|
+
from alembic.runtime.migration import MigrationContext
|
|
13
|
+
from alembic.autogenerate import compare_metadata
|
|
14
|
+
|
|
15
|
+
from tsql.query_builder import Table
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@pytest.fixture
|
|
19
|
+
def temp_alembic_env():
|
|
20
|
+
temp_dir = tempfile.mkdtemp()
|
|
21
|
+
alembic_dir = Path(temp_dir) / "alembic"
|
|
22
|
+
alembic_dir.mkdir()
|
|
23
|
+
versions_dir = alembic_dir / "versions"
|
|
24
|
+
versions_dir.mkdir()
|
|
25
|
+
|
|
26
|
+
env_py = alembic_dir / "env.py"
|
|
27
|
+
env_py.write_text(dedent("""
|
|
28
|
+
from alembic import context
|
|
29
|
+
from sqlalchemy import engine_from_config, pool
|
|
30
|
+
|
|
31
|
+
config = context.config
|
|
32
|
+
target_metadata = config.attributes.get('target_metadata', None)
|
|
33
|
+
|
|
34
|
+
def run_migrations_offline():
|
|
35
|
+
context.configure(
|
|
36
|
+
url=config.get_main_option("sqlalchemy.url"),
|
|
37
|
+
target_metadata=target_metadata,
|
|
38
|
+
literal_binds=True,
|
|
39
|
+
dialect_opts={"paramstyle": "named"},
|
|
40
|
+
)
|
|
41
|
+
with context.begin_transaction():
|
|
42
|
+
context.run_migrations()
|
|
43
|
+
|
|
44
|
+
def run_migrations_online():
|
|
45
|
+
connectable = config.attributes.get('connection', None)
|
|
46
|
+
if connectable is None:
|
|
47
|
+
connectable = engine_from_config(
|
|
48
|
+
config.get_section(config.config_ini_section),
|
|
49
|
+
prefix="sqlalchemy.",
|
|
50
|
+
poolclass=pool.NullPool,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
with connectable.connect() as connection:
|
|
54
|
+
context.configure(
|
|
55
|
+
connection=connection,
|
|
56
|
+
target_metadata=target_metadata
|
|
57
|
+
)
|
|
58
|
+
with context.begin_transaction():
|
|
59
|
+
context.run_migrations()
|
|
60
|
+
|
|
61
|
+
if context.is_offline_mode():
|
|
62
|
+
run_migrations_offline()
|
|
63
|
+
else:
|
|
64
|
+
run_migrations_online()
|
|
65
|
+
"""))
|
|
66
|
+
|
|
67
|
+
script_py = alembic_dir / "script.py.mako"
|
|
68
|
+
script_py.write_text(dedent('''
|
|
69
|
+
"""${message}"""
|
|
70
|
+
from alembic import op
|
|
71
|
+
import sqlalchemy as sa
|
|
72
|
+
${imports if imports else ""}
|
|
73
|
+
|
|
74
|
+
revision = ${repr(up_revision)}
|
|
75
|
+
down_revision = ${repr(down_revision)}
|
|
76
|
+
branch_labels = ${repr(branch_labels)}
|
|
77
|
+
depends_on = ${repr(depends_on)}
|
|
78
|
+
|
|
79
|
+
def upgrade():
|
|
80
|
+
${upgrades if upgrades else "pass"}
|
|
81
|
+
|
|
82
|
+
def downgrade():
|
|
83
|
+
${downgrades if downgrades else "pass"}
|
|
84
|
+
'''))
|
|
85
|
+
|
|
86
|
+
alembic_ini = Path(temp_dir) / "alembic.ini"
|
|
87
|
+
alembic_ini.write_text(dedent(f"""
|
|
88
|
+
[alembic]
|
|
89
|
+
script_location = {alembic_dir}
|
|
90
|
+
sqlalchemy.url = sqlite:///:memory:
|
|
91
|
+
|
|
92
|
+
[loggers]
|
|
93
|
+
keys = root
|
|
94
|
+
|
|
95
|
+
[handlers]
|
|
96
|
+
keys = console
|
|
97
|
+
|
|
98
|
+
[formatters]
|
|
99
|
+
keys = generic
|
|
100
|
+
|
|
101
|
+
[logger_root]
|
|
102
|
+
level = WARN
|
|
103
|
+
handlers = console
|
|
104
|
+
|
|
105
|
+
[handler_console]
|
|
106
|
+
class = StreamHandler
|
|
107
|
+
args = (sys.stderr,)
|
|
108
|
+
level = NOTSET
|
|
109
|
+
formatter = generic
|
|
110
|
+
|
|
111
|
+
[formatter_generic]
|
|
112
|
+
format = %(levelname)-5.5s [%(name)s] %(message)s
|
|
113
|
+
"""))
|
|
114
|
+
|
|
115
|
+
yield temp_dir, alembic_ini
|
|
116
|
+
|
|
117
|
+
shutil.rmtree(temp_dir)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_alembic_detects_new_table_with_annotations(temp_alembic_env):
|
|
121
|
+
temp_dir, alembic_ini = temp_alembic_env
|
|
122
|
+
metadata = MetaData()
|
|
123
|
+
engine = create_engine("sqlite:///:memory:")
|
|
124
|
+
|
|
125
|
+
class Users(Table, table_name='users', metadata=metadata):
|
|
126
|
+
id: int
|
|
127
|
+
name: str
|
|
128
|
+
email: str
|
|
129
|
+
age: int
|
|
130
|
+
|
|
131
|
+
cfg = Config(str(alembic_ini))
|
|
132
|
+
cfg.attributes['target_metadata'] = metadata
|
|
133
|
+
cfg.attributes['connection'] = engine
|
|
134
|
+
|
|
135
|
+
with engine.begin() as connection:
|
|
136
|
+
mc = MigrationContext.configure(connection)
|
|
137
|
+
diff = compare_metadata(mc, metadata)
|
|
138
|
+
|
|
139
|
+
assert len(diff) > 0
|
|
140
|
+
|
|
141
|
+
add_table_ops = [op for op in diff if op[0] == 'add_table']
|
|
142
|
+
assert len(add_table_ops) == 1
|
|
143
|
+
|
|
144
|
+
table = add_table_ops[0][1]
|
|
145
|
+
assert table.name == 'users'
|
|
146
|
+
assert 'id' in [c.name for c in table.columns]
|
|
147
|
+
assert 'name' in [c.name for c in table.columns]
|
|
148
|
+
assert 'email' in [c.name for c in table.columns]
|
|
149
|
+
assert 'age' in [c.name for c in table.columns]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_alembic_detects_new_table_with_sa_columns(temp_alembic_env):
|
|
153
|
+
temp_dir, alembic_ini = temp_alembic_env
|
|
154
|
+
metadata = MetaData()
|
|
155
|
+
engine = create_engine("sqlite:///:memory:")
|
|
156
|
+
|
|
157
|
+
class Posts(Table, table_name='posts', metadata=metadata):
|
|
158
|
+
id = Column(String, primary_key=True)
|
|
159
|
+
title = Column(String(255), nullable=False)
|
|
160
|
+
content = Column(String)
|
|
161
|
+
published = Column(Boolean, server_default='false')
|
|
162
|
+
|
|
163
|
+
cfg = Config(str(alembic_ini))
|
|
164
|
+
cfg.attributes['target_metadata'] = metadata
|
|
165
|
+
cfg.attributes['connection'] = engine
|
|
166
|
+
|
|
167
|
+
with engine.begin() as connection:
|
|
168
|
+
mc = MigrationContext.configure(connection)
|
|
169
|
+
diff = compare_metadata(mc, metadata)
|
|
170
|
+
|
|
171
|
+
add_table_ops = [op for op in diff if op[0] == 'add_table']
|
|
172
|
+
assert len(add_table_ops) == 1
|
|
173
|
+
|
|
174
|
+
table = add_table_ops[0][1]
|
|
175
|
+
assert table.name == 'posts'
|
|
176
|
+
|
|
177
|
+
id_col = next(c for c in table.columns if c.name == 'id')
|
|
178
|
+
assert id_col.primary_key
|
|
179
|
+
|
|
180
|
+
title_col = next(c for c in table.columns if c.name == 'title')
|
|
181
|
+
assert not title_col.nullable
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_alembic_detects_mixed_table_definition(temp_alembic_env):
|
|
185
|
+
temp_dir, alembic_ini = temp_alembic_env
|
|
186
|
+
metadata = MetaData()
|
|
187
|
+
engine = create_engine("sqlite:///:memory:")
|
|
188
|
+
|
|
189
|
+
class Users(Table, table_name='users', metadata=metadata):
|
|
190
|
+
id = Column(String, primary_key=True)
|
|
191
|
+
|
|
192
|
+
class Comments(Table, table_name='comments', metadata=metadata):
|
|
193
|
+
id = Column(String, primary_key=True)
|
|
194
|
+
post_id: str
|
|
195
|
+
user_id = Column(String, ForeignKey('users.id'))
|
|
196
|
+
content: str
|
|
197
|
+
upvotes: int
|
|
198
|
+
created_at = Column(TIMESTAMP, nullable=False)
|
|
199
|
+
|
|
200
|
+
cfg = Config(str(alembic_ini))
|
|
201
|
+
cfg.attributes['target_metadata'] = metadata
|
|
202
|
+
cfg.attributes['connection'] = engine
|
|
203
|
+
|
|
204
|
+
with engine.begin() as connection:
|
|
205
|
+
mc = MigrationContext.configure(connection)
|
|
206
|
+
diff = compare_metadata(mc, metadata)
|
|
207
|
+
|
|
208
|
+
add_table_ops = [op for op in diff if op[0] == 'add_table']
|
|
209
|
+
assert len(add_table_ops) == 2
|
|
210
|
+
|
|
211
|
+
table = next(op[1] for op in add_table_ops if op[1].name == 'comments')
|
|
212
|
+
assert table.name == 'comments'
|
|
213
|
+
|
|
214
|
+
column_names = [c.name for c in table.columns]
|
|
215
|
+
assert 'id' in column_names
|
|
216
|
+
assert 'post_id' in column_names
|
|
217
|
+
assert 'user_id' in column_names
|
|
218
|
+
assert 'content' in column_names
|
|
219
|
+
assert 'upvotes' in column_names
|
|
220
|
+
assert 'created_at' in column_names
|
|
221
|
+
|
|
222
|
+
user_id_col = next(c for c in table.columns if c.name == 'user_id')
|
|
223
|
+
assert len(list(user_id_col.foreign_keys)) == 1
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def test_alembic_detects_column_additions(temp_alembic_env):
|
|
227
|
+
temp_dir, alembic_ini = temp_alembic_env
|
|
228
|
+
metadata = MetaData()
|
|
229
|
+
engine = create_engine("sqlite:///:memory:")
|
|
230
|
+
|
|
231
|
+
with engine.begin() as connection:
|
|
232
|
+
connection.execute(sa_text("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)"))
|
|
233
|
+
|
|
234
|
+
class Users(Table, table_name='users', metadata=metadata):
|
|
235
|
+
id: int
|
|
236
|
+
name: str
|
|
237
|
+
email: str
|
|
238
|
+
age: int
|
|
239
|
+
|
|
240
|
+
cfg = Config(str(alembic_ini))
|
|
241
|
+
cfg.attributes['target_metadata'] = metadata
|
|
242
|
+
cfg.attributes['connection'] = engine
|
|
243
|
+
|
|
244
|
+
with engine.begin() as connection:
|
|
245
|
+
mc = MigrationContext.configure(connection)
|
|
246
|
+
diff = compare_metadata(mc, metadata)
|
|
247
|
+
|
|
248
|
+
add_column_ops = [op for op in diff if op[0] == 'add_column']
|
|
249
|
+
assert len(add_column_ops) == 2
|
|
250
|
+
|
|
251
|
+
added_column_names = [op[3].name for op in add_column_ops]
|
|
252
|
+
assert 'email' in added_column_names
|
|
253
|
+
assert 'age' in added_column_names
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def test_alembic_detects_column_removals(temp_alembic_env):
|
|
257
|
+
temp_dir, alembic_ini = temp_alembic_env
|
|
258
|
+
metadata = MetaData()
|
|
259
|
+
engine = create_engine("sqlite:///:memory:")
|
|
260
|
+
|
|
261
|
+
with engine.begin() as connection:
|
|
262
|
+
connection.execute(sa_text("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, old_field TEXT, deprecated TEXT)"))
|
|
263
|
+
|
|
264
|
+
class Users(Table, table_name='users', metadata=metadata):
|
|
265
|
+
id: int
|
|
266
|
+
name: str
|
|
267
|
+
|
|
268
|
+
cfg = Config(str(alembic_ini))
|
|
269
|
+
cfg.attributes['target_metadata'] = metadata
|
|
270
|
+
cfg.attributes['connection'] = engine
|
|
271
|
+
|
|
272
|
+
with engine.begin() as connection:
|
|
273
|
+
mc = MigrationContext.configure(connection)
|
|
274
|
+
diff = compare_metadata(mc, metadata)
|
|
275
|
+
|
|
276
|
+
remove_column_ops = [op for op in diff if op[0] == 'remove_column']
|
|
277
|
+
assert len(remove_column_ops) == 2
|
|
278
|
+
|
|
279
|
+
removed_column_names = [op[3].name for op in remove_column_ops]
|
|
280
|
+
assert 'old_field' in removed_column_names
|
|
281
|
+
assert 'deprecated' in removed_column_names
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def test_alembic_no_spurious_changes_for_identical_schema(temp_alembic_env):
|
|
285
|
+
temp_dir, alembic_ini = temp_alembic_env
|
|
286
|
+
metadata = MetaData()
|
|
287
|
+
engine = create_engine("sqlite:///:memory:")
|
|
288
|
+
|
|
289
|
+
class Users(Table, table_name='users', metadata=metadata):
|
|
290
|
+
id = Column(Integer, primary_key=True)
|
|
291
|
+
name = Column(String)
|
|
292
|
+
email = Column(String)
|
|
293
|
+
|
|
294
|
+
metadata.create_all(engine)
|
|
295
|
+
|
|
296
|
+
cfg = Config(str(alembic_ini))
|
|
297
|
+
cfg.attributes['target_metadata'] = metadata
|
|
298
|
+
cfg.attributes['connection'] = engine
|
|
299
|
+
|
|
300
|
+
with engine.begin() as connection:
|
|
301
|
+
mc = MigrationContext.configure(connection)
|
|
302
|
+
diff = compare_metadata(mc, metadata)
|
|
303
|
+
|
|
304
|
+
assert len(diff) == 0
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def test_alembic_handles_foreign_keys_correctly(temp_alembic_env):
|
|
308
|
+
temp_dir, alembic_ini = temp_alembic_env
|
|
309
|
+
metadata = MetaData()
|
|
310
|
+
engine = create_engine("sqlite:///:memory:")
|
|
311
|
+
|
|
312
|
+
class Users(Table, table_name='users', metadata=metadata):
|
|
313
|
+
id = Column(String, primary_key=True)
|
|
314
|
+
|
|
315
|
+
class Posts(Table, table_name='posts', metadata=metadata):
|
|
316
|
+
id = Column(String, primary_key=True)
|
|
317
|
+
user_id = Column(String, ForeignKey('users.id', ondelete='CASCADE'))
|
|
318
|
+
title: str
|
|
319
|
+
|
|
320
|
+
cfg = Config(str(alembic_ini))
|
|
321
|
+
cfg.attributes['target_metadata'] = metadata
|
|
322
|
+
cfg.attributes['connection'] = engine
|
|
323
|
+
|
|
324
|
+
with engine.begin() as connection:
|
|
325
|
+
mc = MigrationContext.configure(connection)
|
|
326
|
+
diff = compare_metadata(mc, metadata)
|
|
327
|
+
|
|
328
|
+
add_table_ops = [op for op in diff if op[0] == 'add_table']
|
|
329
|
+
assert len(add_table_ops) == 2
|
|
330
|
+
|
|
331
|
+
posts_table = next(op[1] for op in add_table_ops if op[1].name == 'posts')
|
|
332
|
+
user_id_col = next(c for c in posts_table.columns if c.name == 'user_id')
|
|
333
|
+
|
|
334
|
+
fks = list(user_id_col.foreign_keys)
|
|
335
|
+
assert len(fks) == 1
|
|
336
|
+
assert fks[0].column.table.name == 'users'
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def test_alembic_with_schema_parameter(temp_alembic_env):
|
|
340
|
+
temp_dir, alembic_ini = temp_alembic_env
|
|
341
|
+
metadata = MetaData()
|
|
342
|
+
engine = create_engine("sqlite:///:memory:")
|
|
343
|
+
|
|
344
|
+
class Users(Table, table_name='users', metadata=metadata, schema='public'):
|
|
345
|
+
id: int
|
|
346
|
+
name: str
|
|
347
|
+
|
|
348
|
+
cfg = Config(str(alembic_ini))
|
|
349
|
+
cfg.attributes['target_metadata'] = metadata
|
|
350
|
+
cfg.attributes['connection'] = engine
|
|
351
|
+
|
|
352
|
+
with engine.begin() as connection:
|
|
353
|
+
mc = MigrationContext.configure(connection)
|
|
354
|
+
diff = compare_metadata(mc, metadata)
|
|
355
|
+
|
|
356
|
+
add_table_ops = [op for op in diff if op[0] == 'add_table']
|
|
357
|
+
assert len(add_table_ops) == 1
|
|
358
|
+
|
|
359
|
+
table = add_table_ops[0][1]
|
|
360
|
+
assert table.schema == 'public'
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
from sqlalchemy import text as sa_text
|
|
@@ -47,15 +47,9 @@ def test_as_set():
|
|
|
47
47
|
|
|
48
48
|
def test_insert():
|
|
49
49
|
"""Test the insert helper function"""
|
|
50
|
-
|
|
51
|
-
'name': 'Alice',
|
|
52
|
-
'age': 25,
|
|
53
|
-
'active': True
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
query = tsql.insert('users', values)
|
|
50
|
+
query = tsql.insert('users', name='Alice', age=25, active=True)
|
|
57
51
|
result = tsql.render(query)
|
|
58
|
-
|
|
52
|
+
|
|
59
53
|
assert "INSERT INTO users" in result[0]
|
|
60
54
|
assert "name" in result[0] and "age" in result[0] and "active" in result[0]
|
|
61
55
|
assert "VALUES" in result[0]
|
|
@@ -158,4 +158,156 @@ def test_unsafe_parameter_injection():
|
|
|
158
158
|
|
|
159
159
|
# Test ESCAPED style with :unsafe (should behave the same)
|
|
160
160
|
result_escaped = tsql.render(t"SELECT * FROM users WHERE debug = {malicious_value:unsafe}", style=tsql.styles.ESCAPED)
|
|
161
|
-
assert "DROP TABLE users" in result_escaped[0] # Should be directly embedded
|
|
161
|
+
assert "DROP TABLE users" in result_escaped[0] # Should be directly embedded
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def test_dictionary_key_injection_as_values():
|
|
165
|
+
"""Test that malicious dictionary keys in :as_values are rejected"""
|
|
166
|
+
# Classic SQL injection in dictionary key
|
|
167
|
+
malicious_dict = {
|
|
168
|
+
"name); DROP TABLE users; --": "value"
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
with pytest.raises(ValueError, match="Invalid column name"):
|
|
172
|
+
tsql.render(t"INSERT INTO users {malicious_dict:as_values}")
|
|
173
|
+
|
|
174
|
+
# Semicolon in key
|
|
175
|
+
malicious_dict2 = {
|
|
176
|
+
"name; DELETE FROM users": "value"
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
with pytest.raises(ValueError, match="Invalid column name"):
|
|
180
|
+
tsql.render(t"INSERT INTO users {malicious_dict2:as_values}")
|
|
181
|
+
|
|
182
|
+
# Quote in key
|
|
183
|
+
malicious_dict3 = {
|
|
184
|
+
"name' OR '1'='1": "value"
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
with pytest.raises(ValueError, match="Invalid column name"):
|
|
188
|
+
tsql.render(t"INSERT INTO users {malicious_dict3:as_values}")
|
|
189
|
+
|
|
190
|
+
# Comment in key
|
|
191
|
+
malicious_dict4 = {
|
|
192
|
+
"name--": "value"
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
with pytest.raises(ValueError, match="Invalid column name"):
|
|
196
|
+
tsql.render(t"INSERT INTO users {malicious_dict4:as_values}")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def test_dictionary_key_injection_as_set():
|
|
200
|
+
"""Test that malicious dictionary keys in :as_set are rejected"""
|
|
201
|
+
# Classic injection attempt in UPDATE
|
|
202
|
+
malicious_dict = {
|
|
203
|
+
"email = 'hacker@evil.com' WHERE 1=1; --": "value"
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
with pytest.raises(ValueError, match="Invalid column name"):
|
|
207
|
+
tsql.render(t"UPDATE users SET {malicious_dict:as_set} WHERE id = 1")
|
|
208
|
+
|
|
209
|
+
# Another variant
|
|
210
|
+
malicious_dict2 = {
|
|
211
|
+
"role = 'admin'--": "user"
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
with pytest.raises(ValueError, match="Invalid column name"):
|
|
215
|
+
tsql.render(t"UPDATE users SET {malicious_dict2:as_set} WHERE id = 1")
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def test_empty_dictionary_as_values():
|
|
219
|
+
"""Test that empty dictionaries are rejected in :as_values"""
|
|
220
|
+
empty_dict = {}
|
|
221
|
+
|
|
222
|
+
with pytest.raises(ValueError, match="at least one column"):
|
|
223
|
+
tsql.render(t"INSERT INTO users {empty_dict:as_values}")
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def test_empty_dictionary_as_set():
|
|
227
|
+
"""Test that empty dictionaries are rejected in :as_set"""
|
|
228
|
+
empty_dict = {}
|
|
229
|
+
|
|
230
|
+
with pytest.raises(ValueError, match="at least one column"):
|
|
231
|
+
tsql.render(t"UPDATE users SET {empty_dict:as_set} WHERE id = 1")
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def test_non_string_dictionary_keys():
|
|
235
|
+
"""Test that non-string dictionary keys are rejected"""
|
|
236
|
+
# Integer key
|
|
237
|
+
int_key_dict = {
|
|
238
|
+
123: "value"
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
with pytest.raises(ValueError, match="Invalid column name"):
|
|
242
|
+
tsql.render(t"INSERT INTO users {int_key_dict:as_values}")
|
|
243
|
+
|
|
244
|
+
# Tuple key
|
|
245
|
+
tuple_key_dict = {
|
|
246
|
+
("name", "value"): "test"
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
with pytest.raises(ValueError, match="Invalid column name"):
|
|
250
|
+
tsql.render(t"INSERT INTO users {tuple_key_dict:as_values}")
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def test_valid_dictionary_keys_still_work():
|
|
254
|
+
"""Test that valid identifiers still work in dictionaries"""
|
|
255
|
+
# Simple identifier
|
|
256
|
+
valid_dict = {
|
|
257
|
+
"name": "Alice",
|
|
258
|
+
"age": 30
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
result = tsql.render(t"INSERT INTO users {valid_dict:as_values}")
|
|
262
|
+
assert "(name, age) VALUES (?, ?)" in result[0]
|
|
263
|
+
assert result[1] == ["Alice", 30]
|
|
264
|
+
|
|
265
|
+
# Valid identifiers with underscores
|
|
266
|
+
valid_dict2 = {
|
|
267
|
+
"first_name": "Bob",
|
|
268
|
+
"last_name": "Smith"
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
result2 = tsql.render(t"UPDATE users SET {valid_dict2:as_set} WHERE id = 1")
|
|
272
|
+
assert "first_name = ?" in result2[0]
|
|
273
|
+
assert "last_name = ?" in result2[0]
|
|
274
|
+
assert result2[1] == ["Bob", "Smith"]
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def test_literal_too_many_parts():
|
|
278
|
+
"""Test that literals with more than 3 parts are rejected"""
|
|
279
|
+
# 4 parts should be rejected
|
|
280
|
+
four_part_literal = "a.b.c.d"
|
|
281
|
+
with pytest.raises(ValueError, match="too many parts"):
|
|
282
|
+
tsql.render(t"SELECT * FROM {four_part_literal:literal}")
|
|
283
|
+
|
|
284
|
+
# 5 parts should be rejected
|
|
285
|
+
five_part_literal = "a.b.c.d.e"
|
|
286
|
+
with pytest.raises(ValueError, match="too many parts"):
|
|
287
|
+
tsql.render(t"SELECT * FROM {five_part_literal:literal}")
|
|
288
|
+
|
|
289
|
+
# Many parts should be rejected
|
|
290
|
+
many_parts_literal = ".".join(["a"] * 10)
|
|
291
|
+
with pytest.raises(ValueError, match="too many parts"):
|
|
292
|
+
tsql.render(t"SELECT * FROM {many_parts_literal:literal}")
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def test_literal_valid_parts():
|
|
296
|
+
"""Test that literals with 1-3 parts are accepted"""
|
|
297
|
+
# 1 part (simple table name)
|
|
298
|
+
one_part = "users"
|
|
299
|
+
result = tsql.render(t"SELECT * FROM {one_part:literal}")
|
|
300
|
+
assert result[0] == "SELECT * FROM users"
|
|
301
|
+
assert result[1] == []
|
|
302
|
+
|
|
303
|
+
# 2 parts (schema.table)
|
|
304
|
+
two_parts = "public.users"
|
|
305
|
+
result = tsql.render(t"SELECT * FROM {two_parts:literal}")
|
|
306
|
+
assert result[0] == "SELECT * FROM public.users"
|
|
307
|
+
assert result[1] == []
|
|
308
|
+
|
|
309
|
+
# 3 parts (database.schema.table or schema.table.column)
|
|
310
|
+
three_parts = "mydb.public.users"
|
|
311
|
+
result = tsql.render(t"SELECT * FROM {three_parts:literal}")
|
|
312
|
+
assert result[0] == "SELECT * FROM mydb.public.users"
|
|
313
|
+
assert result[1] == []
|
|
@@ -85,16 +85,14 @@ async def test_insert_ignore(conn):
|
|
|
85
85
|
email: str
|
|
86
86
|
|
|
87
87
|
# Insert first row
|
|
88
|
-
|
|
89
|
-
query1 = TestUsers.insert(values1)
|
|
88
|
+
query1 = TestUsers.insert(name='Alice', email='alice@example.com')
|
|
90
89
|
sql1, params1 = query1.render(style=tsql.styles.FORMAT)
|
|
91
90
|
|
|
92
91
|
await cursor.execute(sql1, params1)
|
|
93
92
|
await connection.commit()
|
|
94
93
|
|
|
95
94
|
# Try to insert duplicate email with INSERT IGNORE
|
|
96
|
-
|
|
97
|
-
query2 = TestUsers.insert(values2).ignore()
|
|
95
|
+
query2 = TestUsers.insert(name='Bob', email='alice@example.com').ignore()
|
|
98
96
|
sql2, params2 = query2.render(style=tsql.styles.FORMAT)
|
|
99
97
|
|
|
100
98
|
assert 'INSERT IGNORE' in sql2
|
|
@@ -126,16 +124,14 @@ async def test_on_duplicate_key_update(conn):
|
|
|
126
124
|
age: int
|
|
127
125
|
|
|
128
126
|
# Insert first row
|
|
129
|
-
|
|
130
|
-
query1 = TestUsers.insert(values1)
|
|
127
|
+
query1 = TestUsers.insert(name='Alice', email='alice@example.com', age=30)
|
|
131
128
|
sql1, params1 = query1.render(style=tsql.styles.FORMAT)
|
|
132
129
|
|
|
133
130
|
await cursor.execute(sql1, params1)
|
|
134
131
|
await connection.commit()
|
|
135
132
|
|
|
136
133
|
# Insert with duplicate email, but update on conflict
|
|
137
|
-
|
|
138
|
-
query2 = TestUsers.insert(values2).on_duplicate_key_update()
|
|
134
|
+
query2 = TestUsers.insert(name='Alice Updated', email='alice@example.com', age=31).on_duplicate_key_update()
|
|
139
135
|
sql2, params2 = query2.render(style=tsql.styles.FORMAT)
|
|
140
136
|
|
|
141
137
|
assert 'ON DUPLICATE KEY UPDATE' in sql2
|
|
@@ -250,8 +246,7 @@ async def test_helper_functions(conn):
|
|
|
250
246
|
connection, cursor = conn
|
|
251
247
|
|
|
252
248
|
# Test insert
|
|
253
|
-
|
|
254
|
-
query = tsql.insert('test_users', values)
|
|
249
|
+
query = tsql.insert('test_users', name='Bob', email='bob@example.com', age=25)
|
|
255
250
|
sql, params = query.render(style=tsql.styles.FORMAT)
|
|
256
251
|
|
|
257
252
|
await cursor.execute(sql, params)
|