t-sql 4.9.3__tar.gz → 4.11.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.
Files changed (39) hide show
  1. t_sql-4.9.3/README.md → t_sql-4.11.0/PKG-INFO +18 -0
  2. t_sql-4.9.3/PKG-INFO → t_sql-4.11.0/README.md +8 -10
  3. {t_sql-4.9.3 → t_sql-4.11.0}/pyproject.toml +1 -1
  4. {t_sql-4.9.3 → t_sql-4.11.0}/tests/test_asyncpg_integration.py +32 -1
  5. {t_sql-4.9.3 → t_sql-4.11.0}/tests/test_query_builder.py +79 -0
  6. {t_sql-4.9.3 → t_sql-4.11.0}/tests/test_sqlite_integration.py +26 -0
  7. {t_sql-4.9.3 → t_sql-4.11.0}/tests/test_tsql.py +1 -1
  8. {t_sql-4.9.3 → t_sql-4.11.0}/tsql/query_builder.py +19 -5
  9. {t_sql-4.9.3 → t_sql-4.11.0}/.dockerignore +0 -0
  10. {t_sql-4.9.3 → t_sql-4.11.0}/.github/workflows/publish.yml +0 -0
  11. {t_sql-4.9.3 → t_sql-4.11.0}/.github/workflows/test.yml +0 -0
  12. {t_sql-4.9.3 → t_sql-4.11.0}/.gitignore +0 -0
  13. {t_sql-4.9.3 → t_sql-4.11.0}/Dockerfile +0 -0
  14. {t_sql-4.9.3 → t_sql-4.11.0}/LICENSE +0 -0
  15. {t_sql-4.9.3 → t_sql-4.11.0}/compose.yaml +0 -0
  16. {t_sql-4.9.3 → t_sql-4.11.0}/context7.json +0 -0
  17. {t_sql-4.9.3 → t_sql-4.11.0}/pytest.ini +0 -0
  18. {t_sql-4.9.3 → t_sql-4.11.0}/tests/test_alembic_integration.py +0 -0
  19. {t_sql-4.9.3 → t_sql-4.11.0}/tests/test_deep_nesting.py +0 -0
  20. {t_sql-4.9.3 → t_sql-4.11.0}/tests/test_different_object_types.py +0 -0
  21. {t_sql-4.9.3 → t_sql-4.11.0}/tests/test_error_messages.py +0 -0
  22. {t_sql-4.9.3 → t_sql-4.11.0}/tests/test_escaped.py +0 -0
  23. {t_sql-4.9.3 → t_sql-4.11.0}/tests/test_escaped_binary_hex.py +0 -0
  24. {t_sql-4.9.3 → t_sql-4.11.0}/tests/test_helper_functions.py +0 -0
  25. {t_sql-4.9.3 → t_sql-4.11.0}/tests/test_injection_edge_cases.py +0 -0
  26. {t_sql-4.9.3 → t_sql-4.11.0}/tests/test_injection_protection_validation.py +0 -0
  27. {t_sql-4.9.3 → t_sql-4.11.0}/tests/test_injections_for_escaped.py +0 -0
  28. {t_sql-4.9.3 → t_sql-4.11.0}/tests/test_like_patterns.py +0 -0
  29. {t_sql-4.9.3 → t_sql-4.11.0}/tests/test_mysql_integration.py +0 -0
  30. {t_sql-4.9.3 → t_sql-4.11.0}/tests/test_parameter_names.py +0 -0
  31. {t_sql-4.9.3 → t_sql-4.11.0}/tests/test_sqlalchemy_integration.py +0 -0
  32. {t_sql-4.9.3 → t_sql-4.11.0}/tests/test_string_based_builders.py +0 -0
  33. {t_sql-4.9.3 → t_sql-4.11.0}/tests/test_styles.py +0 -0
  34. {t_sql-4.9.3 → t_sql-4.11.0}/tests/test_template_in_builders.py +0 -0
  35. {t_sql-4.9.3 → t_sql-4.11.0}/tests/test_type_processor.py +0 -0
  36. {t_sql-4.9.3 → t_sql-4.11.0}/tsql/__init__.py +0 -0
  37. {t_sql-4.9.3 → t_sql-4.11.0}/tsql/row.py +0 -0
  38. {t_sql-4.9.3 → t_sql-4.11.0}/tsql/styles.py +0 -0
  39. {t_sql-4.9.3 → t_sql-4.11.0}/tsql/type_processor.py +0 -0
@@ -1,3 +1,13 @@
1
+ Metadata-Version: 2.4
2
+ Name: t-sql
3
+ Version: 4.11.0
4
+ Summary: Safe SQL. SQL queries for python t-strings (PEP 750)
5
+ Project-URL: Homepage, https://github.com/nhumrich/t-sql
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.14
8
+ Requires-Dist: alembic>=1.17.0
9
+ Description-Content-Type: text/markdown
10
+
1
11
  # t-sql
2
12
 
3
13
  A lightweight SQL templating library that leverages Python 3.14's t-strings (PEP 750).
@@ -375,6 +385,14 @@ query = Users.insert(id='abc123', username='john', email='john@example.com').ret
375
385
  sql, params = query.render()
376
386
  # ('INSERT INTO users (id, username, email) VALUES (?, ?, ?) RETURNING *', [...])
377
387
 
388
+ # INSERT with all column defaults — no values provided (Postgres/SQLite)
389
+ # Emits `DEFAULT VALUES`; useful when every column has a DB/SA default.
390
+ # Note: MySQL does not support `DEFAULT VALUES` syntax. For MySQL, provide at
391
+ # least one column or use raw SQL (`INSERT INTO t () VALUES ()`).
392
+ query = Users.insert().returning('id')
393
+ sql, params = query.render()
394
+ # ('INSERT INTO users DEFAULT VALUES RETURNING id', [])
395
+
378
396
  # INSERT IGNORE (MySQL)
379
397
  query = Users.insert(id='abc123', username='john', email='john@example.com').ignore()
380
398
  sql, params = query.render()
@@ -1,13 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: t-sql
3
- Version: 4.9.3
4
- Summary: Safe SQL. SQL queries for python t-strings (PEP 750)
5
- Project-URL: Homepage, https://github.com/nhumrich/t-sql
6
- License-File: LICENSE
7
- Requires-Python: >=3.14
8
- Requires-Dist: alembic>=1.17.0
9
- Description-Content-Type: text/markdown
10
-
11
1
  # t-sql
12
2
 
13
3
  A lightweight SQL templating library that leverages Python 3.14's t-strings (PEP 750).
@@ -385,6 +375,14 @@ query = Users.insert(id='abc123', username='john', email='john@example.com').ret
385
375
  sql, params = query.render()
386
376
  # ('INSERT INTO users (id, username, email) VALUES (?, ?, ?) RETURNING *', [...])
387
377
 
378
+ # INSERT with all column defaults — no values provided (Postgres/SQLite)
379
+ # Emits `DEFAULT VALUES`; useful when every column has a DB/SA default.
380
+ # Note: MySQL does not support `DEFAULT VALUES` syntax. For MySQL, provide at
381
+ # least one column or use raw SQL (`INSERT INTO t () VALUES ()`).
382
+ query = Users.insert().returning('id')
383
+ sql, params = query.render()
384
+ # ('INSERT INTO users DEFAULT VALUES RETURNING id', [])
385
+
388
386
  # INSERT IGNORE (MySQL)
389
387
  query = Users.insert(id='abc123', username='john', email='john@example.com').ignore()
390
388
  sql, params = query.render()
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "t-sql"
7
- version = "4.9.3"
7
+ version = "4.11.0"
8
8
  description = "Safe SQL. SQL queries for python t-strings (PEP 750)"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.14"
@@ -457,4 +457,35 @@ async def test_array_with_list(conn):
457
457
  row = await conn.fetchrow("SELECT tags FROM test_array WHERE id = 1")
458
458
  assert row['tags'] == my_list
459
459
  finally:
460
- await conn.execute("DROP TABLE IF EXISTS test_array")
460
+ await conn.execute("DROP TABLE IF EXISTS test_array")
461
+
462
+
463
+ async def test_insert_all_defaults_with_returning(conn):
464
+ """Insert with empty values uses DEFAULT VALUES; all columns have defaults."""
465
+ from tsql.query_builder import Table, Column
466
+
467
+ await conn.execute("""
468
+ CREATE TABLE IF NOT EXISTS test_defaults (
469
+ id SERIAL PRIMARY KEY,
470
+ status VARCHAR(20) DEFAULT 'draft',
471
+ created_at TIMESTAMP DEFAULT NOW()
472
+ )
473
+ """)
474
+
475
+ try:
476
+ class TestDefaults(Table, table_name='test_defaults'):
477
+ id: Column
478
+ status: Column
479
+ created_at: Column
480
+
481
+ query, params = TestDefaults.insert().returning('id', 'status').render(
482
+ style=tsql.styles.NUMERIC_DOLLAR
483
+ )
484
+ assert query == 'INSERT INTO test_defaults DEFAULT VALUES RETURNING id, status'
485
+ assert params == []
486
+
487
+ row = await conn.fetchrow(query, *params)
488
+ assert row['id'] is not None
489
+ assert row['status'] == 'draft'
490
+ finally:
491
+ await conn.execute("DROP TABLE IF EXISTS test_defaults")
@@ -1208,6 +1208,43 @@ def test_insert_without_metadata_ignores_defaults():
1208
1208
  assert params == ['1', 'test']
1209
1209
 
1210
1210
 
1211
+ class _DefaultsTable(Table, table_name='mytable'):
1212
+ id: Column
1213
+ name: Column
1214
+
1215
+
1216
+ def test_insert_all_defaults():
1217
+ sql, params = _DefaultsTable.insert().render()
1218
+ assert sql == 'INSERT INTO mytable DEFAULT VALUES'
1219
+ assert params == []
1220
+
1221
+
1222
+ def test_insert_all_defaults_with_returning():
1223
+ sql, params = _DefaultsTable.insert().returning('id').render()
1224
+ assert sql == 'INSERT INTO mytable DEFAULT VALUES RETURNING id'
1225
+ assert params == []
1226
+
1227
+
1228
+ def test_insert_all_defaults_with_returning_star():
1229
+ sql, params = _DefaultsTable.insert().returning().render()
1230
+ assert sql == 'INSERT INTO mytable DEFAULT VALUES RETURNING *'
1231
+ assert params == []
1232
+
1233
+
1234
+ def test_insert_all_defaults_with_on_conflict_do_nothing():
1235
+ sql, params = _DefaultsTable.insert().on_conflict_do_nothing().render()
1236
+ assert sql == 'INSERT INTO mytable DEFAULT VALUES ON CONFLICT DO NOTHING'
1237
+ assert params == []
1238
+
1239
+
1240
+ def test_insert_all_defaults_mysql_on_duplicate_raises():
1241
+ import pytest
1242
+
1243
+ builder = _DefaultsTable.insert().on_duplicate_key_update()
1244
+ with pytest.raises(ValueError, match='DEFAULT VALUES'):
1245
+ builder.render()
1246
+
1247
+
1211
1248
  def test_update_with_onupdate_default():
1212
1249
  """Test that onupdate defaults are applied during UPDATE"""
1213
1250
  from sqlalchemy import MetaData, Column as SAColumn, String
@@ -1545,3 +1582,45 @@ def test_multiple_recursive_ctes():
1545
1582
  assert sql.startswith("WITH RECURSIVE")
1546
1583
  assert "normal AS" in sql
1547
1584
  assert "tree AS" in sql
1585
+
1586
+
1587
+ def test_where_with_bare_boolean_column():
1588
+ """Test WHERE with bare boolean column (truthy check)"""
1589
+ class Contacts(Table):
1590
+ id: Column
1591
+ is_primary: Column
1592
+
1593
+ query = Contacts.select().where(Contacts.is_primary)
1594
+ sql, params = query.render()
1595
+
1596
+ assert 'WHERE contacts.is_primary' in sql
1597
+ assert params == []
1598
+
1599
+
1600
+ def test_update_where_with_bare_boolean_column():
1601
+ """Test UPDATE WHERE with bare boolean column"""
1602
+ class Contacts(Table):
1603
+ id: Column
1604
+ is_primary: Column
1605
+ name: Column
1606
+
1607
+ query = Contacts.update(name='updated').where(Contacts.is_primary)
1608
+ sql, params = query.render()
1609
+
1610
+ assert 'UPDATE contacts SET' in sql
1611
+ assert 'WHERE contacts.is_primary' in sql
1612
+ assert params == ['updated']
1613
+
1614
+
1615
+ def test_delete_where_with_bare_boolean_column():
1616
+ """Test DELETE WHERE with bare boolean column"""
1617
+ class Contacts(Table):
1618
+ id: Column
1619
+ is_primary: Column
1620
+
1621
+ query = Contacts.delete().where(Contacts.is_primary)
1622
+ sql, params = query.render()
1623
+
1624
+ assert 'DELETE FROM contacts' in sql
1625
+ assert 'WHERE contacts.is_primary' in sql
1626
+ assert params == []
@@ -429,3 +429,29 @@ async def test_like_pattern_format_specs(conn):
429
429
  # Should match john_doe
430
430
  assert len(rows) == 1
431
431
  assert rows[0][0] == 'john_doe'
432
+
433
+
434
+ async def test_insert_all_defaults_with_returning(conn):
435
+ """Insert with empty values uses DEFAULT VALUES on SQLite."""
436
+ from tsql.query_builder import Column
437
+
438
+ await conn.execute("""
439
+ CREATE TABLE test_defaults (
440
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
441
+ status TEXT DEFAULT 'draft'
442
+ )
443
+ """)
444
+ await conn.commit()
445
+
446
+ class TestDefaults(Table, table_name='test_defaults'):
447
+ id: Column
448
+ status: Column
449
+
450
+ sql, params = TestDefaults.insert().returning('id', 'status').render()
451
+ assert sql == 'INSERT INTO test_defaults DEFAULT VALUES RETURNING id, status'
452
+ assert params == []
453
+
454
+ cursor = await conn.execute(sql, params)
455
+ row = await cursor.fetchone()
456
+ assert row[0] is not None
457
+ assert row[1] == 'draft'
@@ -69,7 +69,7 @@ def test_correct_final_query_with_literals():
69
69
  def test_disallows_bad_literals():
70
70
  table = "users'"
71
71
  col = "name"
72
- with pytest.raises(ValueError): # TODO: change to appropriate error
72
+ with pytest.raises(ValueError):
73
73
  result = tsql.render(t'select id, {col:literal} from {table:literal}')
74
74
 
75
75
 
@@ -759,8 +759,9 @@ class InsertBuilder(QueryBuilder):
759
759
  processor = self.base_table._type_processors.get(col_name)
760
760
  values_dict[col_name] = _process_value_for_builder(value, processor)
761
761
 
762
- # MySQL INSERT IGNORE
763
- if self._ignore:
762
+ if not values_dict:
763
+ parts.append(t'INSERT INTO {table_name:literal} DEFAULT VALUES')
764
+ elif self._ignore:
764
765
  parts.append(t'INSERT IGNORE INTO {table_name:literal} {values_dict:as_values}')
765
766
  else:
766
767
  parts.append(t'INSERT INTO {table_name:literal} {values_dict:as_values}')
@@ -821,6 +822,10 @@ class InsertBuilder(QueryBuilder):
821
822
  update_dict[col_name] = _process_value_for_builder(value, processor)
822
823
  parts.append(t'ON DUPLICATE KEY UPDATE {update_dict:as_set}')
823
824
  else:
825
+ if not self.values:
826
+ raise ValueError(
827
+ "ON DUPLICATE KEY UPDATE requires explicit columns when inserting DEFAULT VALUES"
828
+ )
824
829
  # Default: update all columns with alias.column (new MySQL syntax)
825
830
  update_parts = []
826
831
  for i, key in enumerate(self.values.keys()):
@@ -932,7 +937,7 @@ class UpdateBuilder(QueryBuilder):
932
937
  string_table = _StringTable(table_name, schema)
933
938
  return cls(string_table, values)
934
939
 
935
- def where(self, condition: Union[Condition, Template]) -> 'UpdateBuilder':
940
+ def where(self, condition: Union[Condition, Template, Column]) -> 'UpdateBuilder':
936
941
  """Add a WHERE condition (multiple calls are ANDed together)"""
937
942
  self._conditions.append(condition)
938
943
  self._requires_where = False
@@ -994,6 +999,9 @@ class UpdateBuilder(QueryBuilder):
994
999
  for cond in self._conditions:
995
1000
  if isinstance(cond, Template):
996
1001
  where_parts.append(t'({cond})')
1002
+ elif isinstance(cond, Column):
1003
+ col_str = str(cond)
1004
+ where_parts.append(t'{col_str:literal}')
997
1005
  else:
998
1006
  where_parts.append(cond.to_tsql())
999
1007
  combined_where = t_join(t' AND ', where_parts)
@@ -1057,7 +1065,7 @@ class DeleteBuilder(QueryBuilder):
1057
1065
  string_table = _StringTable(table_name, schema)
1058
1066
  return cls(string_table)
1059
1067
 
1060
- def where(self, condition: Union[Condition, Template]) -> 'DeleteBuilder':
1068
+ def where(self, condition: Union[Condition, Template, Column]) -> 'DeleteBuilder':
1061
1069
  """Add a WHERE condition (multiple calls are ANDed together)"""
1062
1070
  self._conditions.append(condition)
1063
1071
  self._requires_where = False
@@ -1112,6 +1120,9 @@ class DeleteBuilder(QueryBuilder):
1112
1120
  for cond in self._conditions:
1113
1121
  if isinstance(cond, Template):
1114
1122
  where_parts.append(t'({cond})')
1123
+ elif isinstance(cond, Column):
1124
+ col_str = str(cond)
1125
+ where_parts.append(t'{col_str:literal}')
1115
1126
  else:
1116
1127
  where_parts.append(cond.to_tsql())
1117
1128
  combined_where = t_join(t' AND ', where_parts)
@@ -1210,7 +1221,7 @@ class SelectQueryBuilder(QueryBuilder):
1210
1221
  self._columns = None
1211
1222
  return self
1212
1223
 
1213
- def where(self, condition: Union[Condition, Template]) -> 'SelectQueryBuilder':
1224
+ def where(self, condition: Union[Condition, Template, Column]) -> 'SelectQueryBuilder':
1214
1225
  """Add a WHERE condition (multiple calls are ANDed together)
1215
1226
 
1216
1227
  Accepts either Condition objects from query builder or raw t-string Templates
@@ -1401,6 +1412,9 @@ class SelectQueryBuilder(QueryBuilder):
1401
1412
  for cond in self._conditions:
1402
1413
  if isinstance(cond, Template):
1403
1414
  where_parts.append(t'({cond})')
1415
+ elif isinstance(cond, Column):
1416
+ col_str = str(cond)
1417
+ where_parts.append(t'{col_str:literal}')
1404
1418
  else:
1405
1419
  where_parts.append(cond.to_tsql())
1406
1420
  combined_where = t_join(t' AND ', where_parts)
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