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.
Files changed (30) hide show
  1. {t_sql-2.0.0 → t_sql-2.1.2}/PKG-INFO +2 -1
  2. {t_sql-2.0.0 → t_sql-2.1.2}/context7.json +10 -0
  3. {t_sql-2.0.0 → t_sql-2.1.2}/pyproject.toml +4 -2
  4. t_sql-2.1.2/tests/test_alembic_integration.py +363 -0
  5. {t_sql-2.0.0 → t_sql-2.1.2}/tests/test_helper_functions.py +2 -8
  6. {t_sql-2.0.0 → t_sql-2.1.2}/tests/test_injection_edge_cases.py +153 -1
  7. {t_sql-2.0.0 → t_sql-2.1.2}/tests/test_mysql_integration.py +5 -10
  8. {t_sql-2.0.0 → t_sql-2.1.2}/tests/test_query_builder.py +357 -10
  9. {t_sql-2.0.0 → t_sql-2.1.2}/tests/test_sqlite_integration.py +6 -12
  10. {t_sql-2.0.0 → t_sql-2.1.2}/tests/test_styles.py +24 -0
  11. {t_sql-2.0.0 → t_sql-2.1.2}/tsql/__init__.py +29 -7
  12. {t_sql-2.0.0 → t_sql-2.1.2}/tsql/query_builder.py +176 -38
  13. {t_sql-2.0.0 → t_sql-2.1.2}/.dockerignore +0 -0
  14. {t_sql-2.0.0 → t_sql-2.1.2}/.github/workflows/publish.yml +0 -0
  15. {t_sql-2.0.0 → t_sql-2.1.2}/.github/workflows/test.yml +0 -0
  16. {t_sql-2.0.0 → t_sql-2.1.2}/.gitignore +0 -0
  17. {t_sql-2.0.0 → t_sql-2.1.2}/Dockerfile +0 -0
  18. {t_sql-2.0.0 → t_sql-2.1.2}/LICENSE +0 -0
  19. {t_sql-2.0.0 → t_sql-2.1.2}/README.md +0 -0
  20. {t_sql-2.0.0 → t_sql-2.1.2}/compose.yaml +0 -0
  21. {t_sql-2.0.0 → t_sql-2.1.2}/pytest.ini +0 -0
  22. {t_sql-2.0.0 → t_sql-2.1.2}/tests/test_asyncpg_integration.py +0 -0
  23. {t_sql-2.0.0 → t_sql-2.1.2}/tests/test_different_object_types.py +0 -0
  24. {t_sql-2.0.0 → t_sql-2.1.2}/tests/test_escaped.py +0 -0
  25. {t_sql-2.0.0 → t_sql-2.1.2}/tests/test_escaped_binary_hex.py +0 -0
  26. {t_sql-2.0.0 → t_sql-2.1.2}/tests/test_injection_protection_validation.py +0 -0
  27. {t_sql-2.0.0 → t_sql-2.1.2}/tests/test_injections_for_escaped.py +0 -0
  28. {t_sql-2.0.0 → t_sql-2.1.2}/tests/test_sqlalchemy_integration.py +0 -0
  29. {t_sql-2.0.0 → t_sql-2.1.2}/tests/test_tsql.py +0 -0
  30. {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.0.0
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.0.0"
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
- values = {
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
- values1 = {'name': 'Alice', 'email': 'alice@example.com'}
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
- values2 = {'name': 'Bob', 'email': 'alice@example.com'}
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
- values1 = {'name': 'Alice', 'email': 'alice@example.com', 'age': 30}
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
- values2 = {'name': 'Alice Updated', 'email': 'alice@example.com', 'age': 31}
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
- values = {'name': 'Bob', 'email': 'bob@example.com', 'age': 25}
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)