t-sql 1.1.0__tar.gz → 1.2.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.
- {t_sql-1.1.0 → t_sql-1.2.0}/PKG-INFO +1 -1
- t_sql-1.2.0/context7.json +26 -0
- {t_sql-1.1.0 → t_sql-1.2.0}/pyproject.toml +1 -1
- {t_sql-1.1.0 → t_sql-1.2.0}/tests/test_asyncpg_integration.py +92 -1
- {t_sql-1.1.0 → t_sql-1.2.0}/tests/test_helper_functions.py +76 -1
- {t_sql-1.1.0 → t_sql-1.2.0}/tests/test_query_builder.py +143 -1
- {t_sql-1.1.0 → t_sql-1.2.0}/tsql/__init__.py +69 -2
- {t_sql-1.1.0 → t_sql-1.2.0}/tsql/query_builder.py +152 -1
- {t_sql-1.1.0 → t_sql-1.2.0}/.dockerignore +0 -0
- {t_sql-1.1.0 → t_sql-1.2.0}/.github/workflows/publish.yml +0 -0
- {t_sql-1.1.0 → t_sql-1.2.0}/.github/workflows/test.yml +0 -0
- {t_sql-1.1.0 → t_sql-1.2.0}/.gitignore +0 -0
- {t_sql-1.1.0 → t_sql-1.2.0}/Dockerfile +0 -0
- {t_sql-1.1.0 → t_sql-1.2.0}/LICENSE +0 -0
- {t_sql-1.1.0 → t_sql-1.2.0}/README.md +0 -0
- {t_sql-1.1.0 → t_sql-1.2.0}/compose.yaml +0 -0
- {t_sql-1.1.0 → t_sql-1.2.0}/pytest.ini +0 -0
- {t_sql-1.1.0 → t_sql-1.2.0}/tests/test_different_object_types.py +0 -0
- {t_sql-1.1.0 → t_sql-1.2.0}/tests/test_escaped.py +0 -0
- {t_sql-1.1.0 → t_sql-1.2.0}/tests/test_escaped_binary_hex.py +0 -0
- {t_sql-1.1.0 → t_sql-1.2.0}/tests/test_injection_edge_cases.py +0 -0
- {t_sql-1.1.0 → t_sql-1.2.0}/tests/test_injection_protection_validation.py +0 -0
- {t_sql-1.1.0 → t_sql-1.2.0}/tests/test_injections_for_escaped.py +0 -0
- {t_sql-1.1.0 → t_sql-1.2.0}/tests/test_sqlalchemy_integration.py +0 -0
- {t_sql-1.1.0 → t_sql-1.2.0}/tests/test_styles.py +0 -0
- {t_sql-1.1.0 → t_sql-1.2.0}/tests/test_tsql.py +0 -0
- {t_sql-1.1.0 → t_sql-1.2.0}/tsql/styles.py +0 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"excludeFolders": [
|
|
3
|
+
"tests",
|
|
4
|
+
".github",
|
|
5
|
+
".git",
|
|
6
|
+
"__pycache__",
|
|
7
|
+
"*.egg-info"
|
|
8
|
+
],
|
|
9
|
+
"excludeFiles": [
|
|
10
|
+
"LICENSE",
|
|
11
|
+
"CHANGELOG.md",
|
|
12
|
+
".gitignore"
|
|
13
|
+
],
|
|
14
|
+
"rules": [
|
|
15
|
+
"CRITICAL: This library requires Python 3.14+ due to PEP 750 t-strings. Never suggest it for older Python versions.",
|
|
16
|
+
"Install with 'uv add t-sql' or 'pip install t-sql'. Use 't-sql[sqlalchemy]' for SQLAlchemy/Alembic integration.",
|
|
17
|
+
"Always use t-strings (t\"...\") not regular strings (\"\") or f-strings (f\"\"\") for SQL queries. The library is designed so raw strings won't work - this prevents SQL injection by design.",
|
|
18
|
+
"Use the query builder (@table decorator with typed columns) for complex multi-table joins and structured queries. Use t-string templating for simpler queries or custom SQL logic that the query builder doesn't support.",
|
|
19
|
+
"The :literal format spec is for dynamic table/column names that cannot be parameterized. It sanitizes against valid SQL identifiers.",
|
|
20
|
+
"The :unsafe format spec bypasses all safety checks. Only use with hardcoded strings, never with user input.",
|
|
21
|
+
"The :as_values format spec converts dicts to INSERT VALUES format. The :as_set format spec converts dicts to UPDATE SET format.",
|
|
22
|
+
"When mixing query builder with t-strings using .where(), t-string conditions are automatically wrapped in parentheses for proper operator precedence.",
|
|
23
|
+
"The @table decorator returns an instance - use the decorated class directly, don't instantiate it.",
|
|
24
|
+
"Default parameter style is QMARK (?). Use tsql.styles.NUMERIC_DOLLAR for PostgreSQL ($1, $2), or other styles as needed."
|
|
25
|
+
]
|
|
26
|
+
}
|
|
@@ -233,4 +233,95 @@ async def test_escaped_handles_comment_injection(conn):
|
|
|
233
233
|
malicious_input = "admin'--"
|
|
234
234
|
query, _ = tsql.render(t"SELECT * FROM test_users WHERE name = {malicious_input}", style=tsql.styles.ESCAPED)
|
|
235
235
|
rows = await conn.fetch(query)
|
|
236
|
-
assert len(rows) == 0
|
|
236
|
+
assert len(rows) == 0
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
async def test_upsert_insert_new_row(conn):
|
|
240
|
+
"""Test upsert inserts a new row when no conflict exists"""
|
|
241
|
+
values = {
|
|
242
|
+
'id': 1,
|
|
243
|
+
'name': 'Alice',
|
|
244
|
+
'age': 30
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
query = tsql.upsert('test_users', values, conflict_on='id')
|
|
248
|
+
sql, params = query.render(style=tsql.styles.NUMERIC_DOLLAR)
|
|
249
|
+
|
|
250
|
+
result = await conn.fetchrow(sql, *params)
|
|
251
|
+
|
|
252
|
+
assert result['id'] == 1
|
|
253
|
+
assert result['name'] == 'Alice'
|
|
254
|
+
assert result['age'] == 30
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
async def test_upsert_updates_on_conflict(conn):
|
|
258
|
+
"""Test upsert updates existing row on conflict"""
|
|
259
|
+
# Insert initial row
|
|
260
|
+
await conn.execute(
|
|
261
|
+
"INSERT INTO test_users (id, name, age) VALUES ($1, $2, $3)",
|
|
262
|
+
1, 'Alice', 30
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
# Upsert with same id but different values
|
|
266
|
+
values = {
|
|
267
|
+
'id': 1,
|
|
268
|
+
'name': 'Alice Updated',
|
|
269
|
+
'age': 31
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
query = tsql.upsert('test_users', values, conflict_on='id')
|
|
273
|
+
sql, params = query.render(style=tsql.styles.NUMERIC_DOLLAR)
|
|
274
|
+
|
|
275
|
+
result = await conn.fetchrow(sql, *params)
|
|
276
|
+
|
|
277
|
+
# Should update the existing row
|
|
278
|
+
assert result['id'] == 1
|
|
279
|
+
assert result['name'] == 'Alice Updated'
|
|
280
|
+
assert result['age'] == 31
|
|
281
|
+
|
|
282
|
+
# Verify only one row exists
|
|
283
|
+
count = await conn.fetchval("SELECT COUNT(*) FROM test_users")
|
|
284
|
+
assert count == 1
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
async def test_upsert_multiple_conflict_columns(conn):
|
|
288
|
+
"""Test upsert with composite unique constraint"""
|
|
289
|
+
# Create a table with composite unique constraint
|
|
290
|
+
await conn.execute("""
|
|
291
|
+
CREATE TABLE IF NOT EXISTS test_emails (
|
|
292
|
+
user_id INTEGER,
|
|
293
|
+
email VARCHAR(100),
|
|
294
|
+
verified BOOLEAN,
|
|
295
|
+
UNIQUE(user_id, email)
|
|
296
|
+
)
|
|
297
|
+
""")
|
|
298
|
+
|
|
299
|
+
try:
|
|
300
|
+
# Insert initial row
|
|
301
|
+
await conn.execute(
|
|
302
|
+
"INSERT INTO test_emails (user_id, email, verified) VALUES ($1, $2, $3)",
|
|
303
|
+
1, 'alice@example.com', False
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
# Upsert with same user_id and email
|
|
307
|
+
values = {
|
|
308
|
+
'user_id': 1,
|
|
309
|
+
'email': 'alice@example.com',
|
|
310
|
+
'verified': True
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
query = tsql.upsert('test_emails', values, conflict_on=['user_id', 'email'])
|
|
314
|
+
sql, params = query.render(style=tsql.styles.NUMERIC_DOLLAR)
|
|
315
|
+
|
|
316
|
+
result = await conn.fetchrow(sql, *params)
|
|
317
|
+
|
|
318
|
+
# Should update verified status
|
|
319
|
+
assert result['user_id'] == 1
|
|
320
|
+
assert result['email'] == 'alice@example.com'
|
|
321
|
+
assert result['verified'] is True
|
|
322
|
+
|
|
323
|
+
# Verify only one row exists
|
|
324
|
+
count = await conn.fetchval("SELECT COUNT(*) FROM test_emails")
|
|
325
|
+
assert count == 1
|
|
326
|
+
finally:
|
|
327
|
+
await conn.execute("DROP TABLE IF EXISTS test_emails")
|
|
@@ -151,4 +151,79 @@ def test_select_int_id_safe():
|
|
|
151
151
|
|
|
152
152
|
# Int should be parameterized
|
|
153
153
|
assert "?" in query
|
|
154
|
-
assert params == [42]
|
|
154
|
+
assert params == [42]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_upsert_single_conflict():
|
|
158
|
+
"""Test upsert with a single conflict column"""
|
|
159
|
+
values = {
|
|
160
|
+
'email': 'test@example.com',
|
|
161
|
+
'name': 'Alice',
|
|
162
|
+
'age': 30
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
query = tsql.upsert('users', values, conflict_on='email')
|
|
166
|
+
result = tsql.render(query)
|
|
167
|
+
|
|
168
|
+
assert "INSERT INTO users" in result[0]
|
|
169
|
+
assert "email" in result[0] and "name" in result[0] and "age" in result[0]
|
|
170
|
+
assert "ON CONFLICT (email)" in result[0]
|
|
171
|
+
assert "DO UPDATE SET" in result[0]
|
|
172
|
+
assert "name = EXCLUDED.name" in result[0]
|
|
173
|
+
assert "age = EXCLUDED.age" in result[0]
|
|
174
|
+
assert "email = EXCLUDED.email" not in result[0] # Conflict column shouldn't be in UPDATE
|
|
175
|
+
assert result[1] == ['test@example.com', 'Alice', 30]
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def test_upsert_multiple_conflicts():
|
|
179
|
+
"""Test upsert with multiple conflict columns"""
|
|
180
|
+
values = {
|
|
181
|
+
'email': 'test@example.com',
|
|
182
|
+
'username': 'alice',
|
|
183
|
+
'name': 'Alice Smith'
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
query = tsql.upsert('users', values, conflict_on=['email', 'username'])
|
|
187
|
+
result = tsql.render(query)
|
|
188
|
+
|
|
189
|
+
assert "INSERT INTO users" in result[0]
|
|
190
|
+
assert "ON CONFLICT (email, username)" in result[0]
|
|
191
|
+
assert "DO UPDATE SET" in result[0]
|
|
192
|
+
assert "name = EXCLUDED.name" in result[0]
|
|
193
|
+
assert "email = EXCLUDED.email" not in result[0]
|
|
194
|
+
assert "username = EXCLUDED.username" not in result[0]
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def test_upsert_all_conflict_columns():
|
|
198
|
+
"""Test upsert where all columns are conflict columns (should DO NOTHING)"""
|
|
199
|
+
values = {
|
|
200
|
+
'email': 'test@example.com'
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
query = tsql.upsert('users', values, conflict_on='email')
|
|
204
|
+
result = tsql.render(query)
|
|
205
|
+
|
|
206
|
+
assert "INSERT INTO users" in result[0]
|
|
207
|
+
assert "ON CONFLICT (email)" in result[0]
|
|
208
|
+
assert "DO NOTHING" in result[0]
|
|
209
|
+
assert "DO UPDATE" not in result[0]
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def test_delete():
|
|
213
|
+
"""Test the delete helper function"""
|
|
214
|
+
query = tsql.delete('users', 123)
|
|
215
|
+
result = tsql.render(query)
|
|
216
|
+
|
|
217
|
+
assert "DELETE FROM users" in result[0]
|
|
218
|
+
assert "WHERE id = ?" in result[0]
|
|
219
|
+
assert result[1] == [123]
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def test_delete_string_id():
|
|
223
|
+
"""Test delete with string ID"""
|
|
224
|
+
query = tsql.delete('users', 'abc-123')
|
|
225
|
+
result = tsql.render(query)
|
|
226
|
+
|
|
227
|
+
assert "DELETE FROM users" in result[0]
|
|
228
|
+
assert "WHERE id = ?" in result[0]
|
|
229
|
+
assert result[1] == ['abc-123']
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import tsql
|
|
2
|
-
from tsql.query_builder import table, Column, Condition
|
|
2
|
+
from tsql.query_builder import table, Column, Condition, UpdateBuilder, DeleteBuilder
|
|
3
3
|
|
|
4
4
|
|
|
5
5
|
@table('users')
|
|
@@ -388,3 +388,145 @@ def test_where_with_tstring_complex():
|
|
|
388
388
|
assert 'WHERE users.id > ?' in sql
|
|
389
389
|
assert 'AND (username ILIKE ? OR email ILIKE ?)' in sql
|
|
390
390
|
assert params == [5, 'john', 'jane']
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def test_table_insert():
|
|
394
|
+
"""Test table.insert() method"""
|
|
395
|
+
query = Users.insert({'username': 'bob', 'email': 'bob@example.com'})
|
|
396
|
+
sql, params = query.render()
|
|
397
|
+
|
|
398
|
+
assert 'INSERT INTO users' in sql
|
|
399
|
+
assert 'username' in sql and 'email' in sql
|
|
400
|
+
assert 'VALUES' in sql
|
|
401
|
+
assert 'RETURNING *' in sql
|
|
402
|
+
assert params == ['bob', 'bob@example.com']
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def test_table_insert_ignore_conflict():
|
|
406
|
+
"""Test table.insert() with ignore_conflict"""
|
|
407
|
+
query = Users.insert({'username': 'bob', 'email': 'bob@example.com'}, ignore_conflict=True)
|
|
408
|
+
sql, params = query.render()
|
|
409
|
+
|
|
410
|
+
assert 'INSERT INTO users' in sql
|
|
411
|
+
assert 'ON CONFLICT DO NOTHING' in sql
|
|
412
|
+
assert 'RETURNING *' in sql
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def test_table_upsert():
|
|
416
|
+
"""Test table.upsert() method"""
|
|
417
|
+
query = Users.upsert({'email': 'bob@example.com', 'username': 'bob'}, conflict_on='email')
|
|
418
|
+
sql, params = query.render()
|
|
419
|
+
|
|
420
|
+
assert 'INSERT INTO users' in sql
|
|
421
|
+
assert 'ON CONFLICT (email)' in sql
|
|
422
|
+
assert 'DO UPDATE SET' in sql
|
|
423
|
+
assert 'username = EXCLUDED.username' in sql
|
|
424
|
+
assert 'RETURNING *' in sql
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def test_table_upsert_with_column_object():
|
|
428
|
+
"""Test table.upsert() with Column object"""
|
|
429
|
+
query = Users.upsert({'email': 'bob@example.com', 'username': 'bob'}, conflict_on=Users.email)
|
|
430
|
+
sql, params = query.render()
|
|
431
|
+
|
|
432
|
+
assert 'INSERT INTO users' in sql
|
|
433
|
+
assert 'ON CONFLICT (email)' in sql
|
|
434
|
+
assert 'DO UPDATE SET' in sql
|
|
435
|
+
assert 'username = EXCLUDED.username' in sql
|
|
436
|
+
assert 'RETURNING *' in sql
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def test_table_upsert_with_multiple_column_objects():
|
|
440
|
+
"""Test table.upsert() with multiple Column objects"""
|
|
441
|
+
query = Users.upsert(
|
|
442
|
+
{'email': 'bob@example.com', 'username': 'bob', 'created_at': '2024-01-01'},
|
|
443
|
+
conflict_on=[Users.email, Users.username]
|
|
444
|
+
)
|
|
445
|
+
sql, params = query.render()
|
|
446
|
+
|
|
447
|
+
assert 'INSERT INTO users' in sql
|
|
448
|
+
assert 'ON CONFLICT (email, username)' in sql
|
|
449
|
+
assert 'DO UPDATE SET' in sql
|
|
450
|
+
assert 'created_at = EXCLUDED.created_at' in sql
|
|
451
|
+
assert 'RETURNING *' in sql
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def test_table_update_with_where():
|
|
455
|
+
"""Test table.update() with WHERE clause"""
|
|
456
|
+
builder = Users.update({'username': 'bob_updated'}).where(Users.id == 5)
|
|
457
|
+
assert isinstance(builder, UpdateBuilder)
|
|
458
|
+
|
|
459
|
+
sql, params = builder.render()
|
|
460
|
+
|
|
461
|
+
assert 'UPDATE users SET' in sql
|
|
462
|
+
assert 'username = ?' in sql
|
|
463
|
+
assert 'WHERE users.id = ?' in sql
|
|
464
|
+
assert 'RETURNING *' in sql
|
|
465
|
+
assert params == ['bob_updated', 5]
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def test_table_update_multiple_conditions():
|
|
469
|
+
"""Test table.update() with multiple WHERE conditions"""
|
|
470
|
+
builder = (Users.update({'username': 'bob_updated', 'email': 'new@example.com'})
|
|
471
|
+
.where(Users.id > 10)
|
|
472
|
+
.where(Users.created_at == None))
|
|
473
|
+
|
|
474
|
+
sql, params = builder.render()
|
|
475
|
+
|
|
476
|
+
assert 'UPDATE users SET' in sql
|
|
477
|
+
assert 'WHERE users.id > ?' in sql
|
|
478
|
+
assert 'AND users.created_at IS NULL' in sql
|
|
479
|
+
assert 'RETURNING *' in sql
|
|
480
|
+
assert params == ['bob_updated', 'new@example.com', 10]
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def test_table_delete_with_where():
|
|
484
|
+
"""Test table.delete() with WHERE clause"""
|
|
485
|
+
builder = Users.delete().where(Users.id == 5)
|
|
486
|
+
assert isinstance(builder, DeleteBuilder)
|
|
487
|
+
|
|
488
|
+
sql, params = builder.render()
|
|
489
|
+
|
|
490
|
+
assert 'DELETE FROM users' in sql
|
|
491
|
+
assert 'WHERE users.id = ?' in sql
|
|
492
|
+
assert 'RETURNING *' in sql
|
|
493
|
+
assert params == [5]
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def test_table_delete_multiple_conditions():
|
|
497
|
+
"""Test table.delete() with multiple WHERE conditions"""
|
|
498
|
+
builder = (Users.delete()
|
|
499
|
+
.where(Users.id > 100)
|
|
500
|
+
.where(Users.email == None))
|
|
501
|
+
|
|
502
|
+
sql, params = builder.render()
|
|
503
|
+
|
|
504
|
+
assert 'DELETE FROM users' in sql
|
|
505
|
+
assert 'WHERE users.id > ?' in sql
|
|
506
|
+
assert 'AND users.email IS NULL' in sql
|
|
507
|
+
assert 'RETURNING *' in sql
|
|
508
|
+
assert params == [100]
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def test_update_with_t_string_where():
|
|
512
|
+
"""Test UpdateBuilder with raw t-string WHERE clause"""
|
|
513
|
+
min_age = 18
|
|
514
|
+
builder = Users.update({'username': 'adult'}).where(t"age >= {min_age}")
|
|
515
|
+
|
|
516
|
+
sql, params = builder.render()
|
|
517
|
+
|
|
518
|
+
assert 'UPDATE users SET' in sql
|
|
519
|
+
assert 'WHERE (age >= ?)' in sql
|
|
520
|
+
assert params == ['adult', 18]
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def test_delete_with_t_string_where():
|
|
524
|
+
"""Test DeleteBuilder with raw t-string WHERE clause"""
|
|
525
|
+
pattern = '%test%'
|
|
526
|
+
builder = Users.delete().where(t"email LIKE {pattern}")
|
|
527
|
+
|
|
528
|
+
sql, params = builder.render()
|
|
529
|
+
|
|
530
|
+
assert 'DELETE FROM users' in sql
|
|
531
|
+
assert 'WHERE (email LIKE ?)' in sql
|
|
532
|
+
assert params == ['%test%']
|
|
@@ -72,7 +72,12 @@ class TSQL:
|
|
|
72
72
|
|
|
73
73
|
@classmethod
|
|
74
74
|
def _check_literal(cls, val: str):
|
|
75
|
-
if not isinstance(val, str)
|
|
75
|
+
if not isinstance(val, str):
|
|
76
|
+
raise ValueError(f"Invalid literal {val}")
|
|
77
|
+
|
|
78
|
+
# Allow qualified identifiers (table.column, schema.table.column)
|
|
79
|
+
parts = val.split('.')
|
|
80
|
+
if not parts or not all(part.isidentifier() for part in parts):
|
|
76
81
|
raise ValueError(f"Invalid literal {val}")
|
|
77
82
|
return val
|
|
78
83
|
|
|
@@ -239,6 +244,68 @@ def update(table: str, values: dict[str, Any], id: str):
|
|
|
239
244
|
|
|
240
245
|
if not isinstance(values, dict):
|
|
241
246
|
raise ValueError("values must be a dictionary")
|
|
242
|
-
|
|
247
|
+
|
|
243
248
|
return TSQL(t"UPDATE {table:literal} SET {values:as_set} WHERE id = {id} RETURNING *")
|
|
244
249
|
|
|
250
|
+
|
|
251
|
+
def upsert(table: str, values: dict[str, Any], conflict_on: str | list[str]):
|
|
252
|
+
"""Helper function to build INSERT ... ON CONFLICT DO UPDATE (upsert) queries
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
table: Table name
|
|
256
|
+
values: Dictionary of column names and values to insert
|
|
257
|
+
conflict_on: Column name(s) that define the conflict constraint
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
TSQL object representing the upsert query
|
|
261
|
+
"""
|
|
262
|
+
if not isinstance(values, dict):
|
|
263
|
+
raise TypeError("values must be a dict")
|
|
264
|
+
|
|
265
|
+
# Normalize conflict_on to a list
|
|
266
|
+
conflict_cols = [conflict_on] if isinstance(conflict_on, str) else conflict_on
|
|
267
|
+
|
|
268
|
+
# Build the conflict target: ON CONFLICT (col1, col2)
|
|
269
|
+
conflict_target_parts = ['(']
|
|
270
|
+
for i, col in enumerate(conflict_cols):
|
|
271
|
+
if i > 0:
|
|
272
|
+
conflict_target_parts.append(', ')
|
|
273
|
+
conflict_target_parts.append(col)
|
|
274
|
+
conflict_target_parts.append(')')
|
|
275
|
+
|
|
276
|
+
# Build the UPDATE SET clause with EXCLUDED.* for non-conflict columns
|
|
277
|
+
update_cols = {k: v for k, v in values.items() if k not in conflict_cols}
|
|
278
|
+
|
|
279
|
+
if not update_cols:
|
|
280
|
+
# If all columns are conflict columns, just do nothing
|
|
281
|
+
conflict_clause_parts = [' ON CONFLICT '] + conflict_target_parts + [' DO NOTHING']
|
|
282
|
+
else:
|
|
283
|
+
update_set_parts = []
|
|
284
|
+
for i, key in enumerate(update_cols.keys()):
|
|
285
|
+
if i > 0:
|
|
286
|
+
update_set_parts.append(', ')
|
|
287
|
+
update_set_parts.append(key)
|
|
288
|
+
update_set_parts.append(' = EXCLUDED.')
|
|
289
|
+
update_set_parts.append(key)
|
|
290
|
+
|
|
291
|
+
conflict_clause_parts = [' ON CONFLICT '] + conflict_target_parts + [' DO UPDATE SET '] + update_set_parts
|
|
292
|
+
|
|
293
|
+
# Create the conflict clause TSQL object
|
|
294
|
+
conflict_tsql = TSQL.__new__(TSQL)
|
|
295
|
+
conflict_tsql._sql_parts = conflict_clause_parts
|
|
296
|
+
|
|
297
|
+
return TSQL(t"INSERT INTO {table:literal} {values:as_values}{conflict_tsql} RETURNING *")
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def delete(table: str, id: str|int):
|
|
301
|
+
"""Helper function to build DELETE queries for a single row
|
|
302
|
+
|
|
303
|
+
Args:
|
|
304
|
+
table: Table name
|
|
305
|
+
id: ID value to delete
|
|
306
|
+
|
|
307
|
+
Returns:
|
|
308
|
+
TSQL object representing the DELETE query
|
|
309
|
+
"""
|
|
310
|
+
return TSQL(t"DELETE FROM {table:literal} WHERE id = {id}")
|
|
311
|
+
|
|
@@ -3,7 +3,7 @@ from string.templatelib import Template
|
|
|
3
3
|
from functools import wraps
|
|
4
4
|
from datetime import datetime
|
|
5
5
|
|
|
6
|
-
from tsql import TSQL, t_join
|
|
6
|
+
from tsql import TSQL, t_join, insert as tsql_insert, upsert as tsql_upsert, delete as tsql_delete, as_set
|
|
7
7
|
|
|
8
8
|
# Optional SQLAlchemy support
|
|
9
9
|
try:
|
|
@@ -128,6 +128,104 @@ class Join:
|
|
|
128
128
|
return t'{join_type:unsafe} JOIN {table_name:literal} ON {condition_tsql}'
|
|
129
129
|
|
|
130
130
|
|
|
131
|
+
class UpdateBuilder:
|
|
132
|
+
"""Fluent interface for building UPDATE queries"""
|
|
133
|
+
|
|
134
|
+
def __init__(self, base_table: 'Table', values: dict[str, Any]):
|
|
135
|
+
self.base_table = base_table
|
|
136
|
+
self.values = values
|
|
137
|
+
self._conditions: List[Union[Condition, Template]] = []
|
|
138
|
+
|
|
139
|
+
def where(self, condition: Union[Condition, Template]) -> 'UpdateBuilder':
|
|
140
|
+
"""Add a WHERE condition (multiple calls are ANDed together)"""
|
|
141
|
+
self._conditions.append(condition)
|
|
142
|
+
return self
|
|
143
|
+
|
|
144
|
+
def to_tsql(self) -> TSQL:
|
|
145
|
+
"""Build the final TSQL object"""
|
|
146
|
+
parts: List[Template] = []
|
|
147
|
+
|
|
148
|
+
table_name = self.base_table.table_name
|
|
149
|
+
values_dict = self.values
|
|
150
|
+
parts.append(t'UPDATE {table_name:literal} SET {values_dict:as_set}')
|
|
151
|
+
|
|
152
|
+
if self._conditions:
|
|
153
|
+
where_parts = []
|
|
154
|
+
for cond in self._conditions:
|
|
155
|
+
if isinstance(cond, Template):
|
|
156
|
+
where_parts.append(t'({cond})')
|
|
157
|
+
else:
|
|
158
|
+
where_parts.append(cond.to_tsql())
|
|
159
|
+
combined_where = t_join(t' AND ', where_parts)
|
|
160
|
+
parts.append(t'WHERE {combined_where}')
|
|
161
|
+
|
|
162
|
+
parts.append(t'RETURNING *')
|
|
163
|
+
|
|
164
|
+
return TSQL(t_join(t' ', parts))
|
|
165
|
+
|
|
166
|
+
def render(self, style=None):
|
|
167
|
+
"""Convenience method to render the query directly"""
|
|
168
|
+
return self.to_tsql().render(style)
|
|
169
|
+
|
|
170
|
+
def __repr__(self) -> str:
|
|
171
|
+
"""Show the rendered SQL query for debugging"""
|
|
172
|
+
try:
|
|
173
|
+
query, params = self.to_tsql().render()
|
|
174
|
+
if params:
|
|
175
|
+
return f"UpdateBuilder(\n SQL: {query}\n Params: {params}\n)"
|
|
176
|
+
return f"UpdateBuilder({query})"
|
|
177
|
+
except Exception as e:
|
|
178
|
+
return f"UpdateBuilder(<error rendering: {e}>)"
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class DeleteBuilder:
|
|
182
|
+
"""Fluent interface for building DELETE queries"""
|
|
183
|
+
|
|
184
|
+
def __init__(self, base_table: 'Table'):
|
|
185
|
+
self.base_table = base_table
|
|
186
|
+
self._conditions: List[Union[Condition, Template]] = []
|
|
187
|
+
|
|
188
|
+
def where(self, condition: Union[Condition, Template]) -> 'DeleteBuilder':
|
|
189
|
+
"""Add a WHERE condition (multiple calls are ANDed together)"""
|
|
190
|
+
self._conditions.append(condition)
|
|
191
|
+
return self
|
|
192
|
+
|
|
193
|
+
def to_tsql(self) -> TSQL:
|
|
194
|
+
"""Build the final TSQL object"""
|
|
195
|
+
parts: List[Template] = []
|
|
196
|
+
|
|
197
|
+
table_name = self.base_table.table_name
|
|
198
|
+
parts.append(t'DELETE FROM {table_name:literal}')
|
|
199
|
+
|
|
200
|
+
if self._conditions:
|
|
201
|
+
where_parts = []
|
|
202
|
+
for cond in self._conditions:
|
|
203
|
+
if isinstance(cond, Template):
|
|
204
|
+
where_parts.append(t'({cond})')
|
|
205
|
+
else:
|
|
206
|
+
where_parts.append(cond.to_tsql())
|
|
207
|
+
combined_where = t_join(t' AND ', where_parts)
|
|
208
|
+
parts.append(t'WHERE {combined_where}')
|
|
209
|
+
|
|
210
|
+
parts.append(t'RETURNING *')
|
|
211
|
+
|
|
212
|
+
return TSQL(t_join(t' ', parts))
|
|
213
|
+
|
|
214
|
+
def render(self, style=None):
|
|
215
|
+
"""Convenience method to render the query directly"""
|
|
216
|
+
return self.to_tsql().render(style)
|
|
217
|
+
|
|
218
|
+
def __repr__(self) -> str:
|
|
219
|
+
"""Show the rendered SQL query for debugging"""
|
|
220
|
+
try:
|
|
221
|
+
query, params = self.to_tsql().render()
|
|
222
|
+
if params:
|
|
223
|
+
return f"DeleteBuilder(\n SQL: {query}\n Params: {params}\n)"
|
|
224
|
+
return f"DeleteBuilder({query})"
|
|
225
|
+
except Exception as e:
|
|
226
|
+
return f"DeleteBuilder(<error rendering: {e}>)"
|
|
227
|
+
|
|
228
|
+
|
|
131
229
|
class QueryBuilder:
|
|
132
230
|
"""Fluent interface for building SQL queries"""
|
|
133
231
|
|
|
@@ -374,7 +472,60 @@ def table(name: str, *, metadata: Optional[Any] = None, schema: Optional[str] =
|
|
|
374
472
|
builder.select(*columns)
|
|
375
473
|
return builder
|
|
376
474
|
|
|
475
|
+
def insert(self, values: dict[str, Any], ignore_conflict: bool = False) -> TSQL:
|
|
476
|
+
"""Insert a row into the table
|
|
477
|
+
|
|
478
|
+
Args:
|
|
479
|
+
values: Dictionary of column names and values
|
|
480
|
+
ignore_conflict: If True, adds ON CONFLICT DO NOTHING
|
|
481
|
+
|
|
482
|
+
Returns:
|
|
483
|
+
TSQL object representing the INSERT query
|
|
484
|
+
"""
|
|
485
|
+
return tsql_insert(self.table_name, values, ignore_conflict=ignore_conflict)
|
|
486
|
+
|
|
487
|
+
def upsert(self, values: dict[str, Any], conflict_on: str | list[str] | Column | list[Column]) -> TSQL:
|
|
488
|
+
"""Upsert (INSERT ... ON CONFLICT DO UPDATE) a row
|
|
489
|
+
|
|
490
|
+
Args:
|
|
491
|
+
values: Dictionary of column names and values
|
|
492
|
+
conflict_on: Column name(s) or Column object(s) that define the conflict constraint
|
|
493
|
+
|
|
494
|
+
Returns:
|
|
495
|
+
TSQL object representing the UPSERT query
|
|
496
|
+
"""
|
|
497
|
+
# Convert Column objects to strings
|
|
498
|
+
if isinstance(conflict_on, Column):
|
|
499
|
+
conflict_on = conflict_on.column_name
|
|
500
|
+
elif isinstance(conflict_on, list):
|
|
501
|
+
conflict_on = [col.column_name if isinstance(col, Column) else col for col in conflict_on]
|
|
502
|
+
|
|
503
|
+
return tsql_upsert(self.table_name, values, conflict_on=conflict_on)
|
|
504
|
+
|
|
505
|
+
def update(self, values: dict[str, Any]) -> UpdateBuilder:
|
|
506
|
+
"""Start building an UPDATE query
|
|
507
|
+
|
|
508
|
+
Args:
|
|
509
|
+
values: Dictionary of column names and values to update
|
|
510
|
+
|
|
511
|
+
Returns:
|
|
512
|
+
UpdateBuilder for adding WHERE conditions
|
|
513
|
+
"""
|
|
514
|
+
return UpdateBuilder(self, values)
|
|
515
|
+
|
|
516
|
+
def delete(self) -> DeleteBuilder:
|
|
517
|
+
"""Start building a DELETE query
|
|
518
|
+
|
|
519
|
+
Returns:
|
|
520
|
+
DeleteBuilder for adding WHERE conditions
|
|
521
|
+
"""
|
|
522
|
+
return DeleteBuilder(self)
|
|
523
|
+
|
|
377
524
|
cls.select = select
|
|
525
|
+
cls.insert = insert
|
|
526
|
+
cls.upsert = upsert
|
|
527
|
+
cls.update = update
|
|
528
|
+
cls.delete = delete
|
|
378
529
|
|
|
379
530
|
# Return an instance instead of the class
|
|
380
531
|
return cls()
|
|
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
|