t-sql 4.2.1__tar.gz → 4.3.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 (31) hide show
  1. {t_sql-4.2.1 → t_sql-4.3.0}/PKG-INFO +43 -1
  2. {t_sql-4.2.1 → t_sql-4.3.0}/README.md +42 -0
  3. {t_sql-4.2.1 → t_sql-4.3.0}/pyproject.toml +1 -1
  4. {t_sql-4.2.1 → t_sql-4.3.0}/tests/test_alembic_integration.py +106 -1
  5. {t_sql-4.2.1 → t_sql-4.3.0}/tests/test_sqlalchemy_integration.py +156 -0
  6. {t_sql-4.2.1 → t_sql-4.3.0}/tsql/query_builder.py +31 -2
  7. {t_sql-4.2.1 → t_sql-4.3.0}/.dockerignore +0 -0
  8. {t_sql-4.2.1 → t_sql-4.3.0}/.github/workflows/publish.yml +0 -0
  9. {t_sql-4.2.1 → t_sql-4.3.0}/.github/workflows/test.yml +0 -0
  10. {t_sql-4.2.1 → t_sql-4.3.0}/.gitignore +0 -0
  11. {t_sql-4.2.1 → t_sql-4.3.0}/Dockerfile +0 -0
  12. {t_sql-4.2.1 → t_sql-4.3.0}/LICENSE +0 -0
  13. {t_sql-4.2.1 → t_sql-4.3.0}/compose.yaml +0 -0
  14. {t_sql-4.2.1 → t_sql-4.3.0}/context7.json +0 -0
  15. {t_sql-4.2.1 → t_sql-4.3.0}/pytest.ini +0 -0
  16. {t_sql-4.2.1 → t_sql-4.3.0}/tests/test_asyncpg_integration.py +0 -0
  17. {t_sql-4.2.1 → t_sql-4.3.0}/tests/test_different_object_types.py +0 -0
  18. {t_sql-4.2.1 → t_sql-4.3.0}/tests/test_escaped.py +0 -0
  19. {t_sql-4.2.1 → t_sql-4.3.0}/tests/test_escaped_binary_hex.py +0 -0
  20. {t_sql-4.2.1 → t_sql-4.3.0}/tests/test_helper_functions.py +0 -0
  21. {t_sql-4.2.1 → t_sql-4.3.0}/tests/test_injection_edge_cases.py +0 -0
  22. {t_sql-4.2.1 → t_sql-4.3.0}/tests/test_injection_protection_validation.py +0 -0
  23. {t_sql-4.2.1 → t_sql-4.3.0}/tests/test_injections_for_escaped.py +0 -0
  24. {t_sql-4.2.1 → t_sql-4.3.0}/tests/test_mysql_integration.py +0 -0
  25. {t_sql-4.2.1 → t_sql-4.3.0}/tests/test_parameter_names.py +0 -0
  26. {t_sql-4.2.1 → t_sql-4.3.0}/tests/test_query_builder.py +0 -0
  27. {t_sql-4.2.1 → t_sql-4.3.0}/tests/test_sqlite_integration.py +0 -0
  28. {t_sql-4.2.1 → t_sql-4.3.0}/tests/test_styles.py +0 -0
  29. {t_sql-4.2.1 → t_sql-4.3.0}/tests/test_tsql.py +0 -0
  30. {t_sql-4.2.1 → t_sql-4.3.0}/tsql/__init__.py +0 -0
  31. {t_sql-4.2.1 → t_sql-4.3.0}/tsql/styles.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: t-sql
3
- Version: 4.2.1
3
+ Version: 4.3.0
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
@@ -526,6 +526,48 @@ query = Users.select().where(Users.age > 18)
526
526
 
527
527
  The `SAColumn` wrapper tells type checkers it returns a tsql `Column`, while at runtime it creates a SQLAlchemy `Column`. This gives you proper IDE completions for methods like `.is_null()`, `.like()`, etc.
528
528
 
529
+ ### Table Constraints
530
+
531
+ For Alembic migrations, you can define table-level constraints using the `constraints` attribute:
532
+
533
+ ```python
534
+ from sqlalchemy import MetaData, String, UniqueConstraint, CheckConstraint, Index
535
+ from tsql.query_builder import Table, SAColumn
536
+
537
+ metadata = MetaData()
538
+
539
+ class Clients(Table, table_name='clients', metadata=metadata):
540
+ id = SAColumn(String, primary_key=True)
541
+ tenant_id = SAColumn(String)
542
+ email = SAColumn(String, nullable=False)
543
+
544
+ # Define table-level constraints
545
+ constraints = [
546
+ UniqueConstraint('tenant_id', 'email', name='uq_clients_tenant_email'),
547
+ CheckConstraint('length(email) > 0', name='ck_clients_email_not_empty'),
548
+ Index('ix_clients_tenant', 'tenant_id')
549
+ ]
550
+ ```
551
+
552
+ The `constraints` attribute accepts both lists and tuples, and supports all SQLAlchemy constraint types:
553
+ - `UniqueConstraint` - Multi-column unique constraints
554
+ - `CheckConstraint` - Table-level check constraints
555
+ - `Index` - Multi-column indexes
556
+ - `ForeignKeyConstraint` - Table-level foreign keys
557
+
558
+ **Note:** Single-column constraints like unique indexes and foreign keys can still be defined directly on `SAColumn` (e.g., `SAColumn(String, unique=True, index=True)`).
559
+
560
+ ### Table Comments
561
+
562
+ Add database-level documentation with the `comment` parameter:
563
+
564
+ ```python
565
+ class Users(Table, metadata=metadata, comment='Application user accounts'):
566
+ id = SAColumn(Integer, primary_key=True)
567
+ email = SAColumn(String(255), nullable=False)
568
+ ```
569
+
570
+ Table comments appear in database introspection tools and migration files, making your schema self-documenting.
529
571
 
530
572
  ## Schema Support
531
573
 
@@ -516,6 +516,48 @@ query = Users.select().where(Users.age > 18)
516
516
 
517
517
  The `SAColumn` wrapper tells type checkers it returns a tsql `Column`, while at runtime it creates a SQLAlchemy `Column`. This gives you proper IDE completions for methods like `.is_null()`, `.like()`, etc.
518
518
 
519
+ ### Table Constraints
520
+
521
+ For Alembic migrations, you can define table-level constraints using the `constraints` attribute:
522
+
523
+ ```python
524
+ from sqlalchemy import MetaData, String, UniqueConstraint, CheckConstraint, Index
525
+ from tsql.query_builder import Table, SAColumn
526
+
527
+ metadata = MetaData()
528
+
529
+ class Clients(Table, table_name='clients', metadata=metadata):
530
+ id = SAColumn(String, primary_key=True)
531
+ tenant_id = SAColumn(String)
532
+ email = SAColumn(String, nullable=False)
533
+
534
+ # Define table-level constraints
535
+ constraints = [
536
+ UniqueConstraint('tenant_id', 'email', name='uq_clients_tenant_email'),
537
+ CheckConstraint('length(email) > 0', name='ck_clients_email_not_empty'),
538
+ Index('ix_clients_tenant', 'tenant_id')
539
+ ]
540
+ ```
541
+
542
+ The `constraints` attribute accepts both lists and tuples, and supports all SQLAlchemy constraint types:
543
+ - `UniqueConstraint` - Multi-column unique constraints
544
+ - `CheckConstraint` - Table-level check constraints
545
+ - `Index` - Multi-column indexes
546
+ - `ForeignKeyConstraint` - Table-level foreign keys
547
+
548
+ **Note:** Single-column constraints like unique indexes and foreign keys can still be defined directly on `SAColumn` (e.g., `SAColumn(String, unique=True, index=True)`).
549
+
550
+ ### Table Comments
551
+
552
+ Add database-level documentation with the `comment` parameter:
553
+
554
+ ```python
555
+ class Users(Table, metadata=metadata, comment='Application user accounts'):
556
+ id = SAColumn(Integer, primary_key=True)
557
+ email = SAColumn(String(255), nullable=False)
558
+ ```
559
+
560
+ Table comments appear in database introspection tools and migration files, making your schema self-documenting.
519
561
 
520
562
  ## Schema Support
521
563
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "t-sql"
7
- version = "4.2.1"
7
+ version = "4.3.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"
@@ -360,4 +360,109 @@ def test_alembic_with_schema_parameter(temp_alembic_env):
360
360
  assert table.schema == 'public'
361
361
 
362
362
 
363
- from sqlalchemy import text as sa_text
363
+ from sqlalchemy import text as sa_text
364
+
365
+
366
+ def test_alembic_detects_unique_constraint(temp_alembic_env):
367
+ """Test that Alembic autogenerate detects UniqueConstraint"""
368
+ from sqlalchemy import UniqueConstraint
369
+
370
+ temp_dir, alembic_ini = temp_alembic_env
371
+ metadata = MetaData()
372
+ engine = create_engine("sqlite:///:memory:")
373
+
374
+ class Clients(Table, table_name='clients', metadata=metadata):
375
+ id = Column(String, primary_key=True)
376
+ tenant_id = Column(String)
377
+ email = Column(String, nullable=False)
378
+
379
+ constraints = [
380
+ UniqueConstraint('tenant_id', 'email', name='uq_clients_tenant_email')
381
+ ]
382
+
383
+ cfg = Config(str(alembic_ini))
384
+ cfg.attributes['target_metadata'] = metadata
385
+ cfg.attributes['connection'] = engine
386
+
387
+ with engine.begin() as connection:
388
+ mc = MigrationContext.configure(connection)
389
+ diff = compare_metadata(mc, metadata)
390
+
391
+ # Should detect the new table
392
+ add_table_ops = [op for op in diff if op[0] == 'add_table']
393
+ assert len(add_table_ops) == 1
394
+
395
+ table = add_table_ops[0][1]
396
+ assert table.name == 'clients'
397
+
398
+ # Verify the UniqueConstraint is in the table definition
399
+ unique_constraints = [c for c in table.constraints if isinstance(c, UniqueConstraint)]
400
+ assert len(unique_constraints) == 1
401
+
402
+ uc = unique_constraints[0]
403
+ assert uc.name == 'uq_clients_tenant_email'
404
+ assert set(c.name for c in uc.columns) == {'tenant_id', 'email'}
405
+
406
+
407
+ def test_alembic_detects_check_constraint(temp_alembic_env):
408
+ """Test that Alembic autogenerate detects CheckConstraint"""
409
+ from sqlalchemy import CheckConstraint
410
+
411
+ temp_dir, alembic_ini = temp_alembic_env
412
+ metadata = MetaData()
413
+ engine = create_engine("sqlite:///:memory:")
414
+
415
+ class Products(Table, table_name='products', metadata=metadata):
416
+ id = Column(Integer, primary_key=True)
417
+ name = Column(String, nullable=False)
418
+ price = Column(Integer)
419
+
420
+ constraints = [
421
+ CheckConstraint('price > 0', name='ck_products_positive_price')
422
+ ]
423
+
424
+ cfg = Config(str(alembic_ini))
425
+ cfg.attributes['target_metadata'] = metadata
426
+ cfg.attributes['connection'] = engine
427
+
428
+ with engine.begin() as connection:
429
+ mc = MigrationContext.configure(connection)
430
+ diff = compare_metadata(mc, metadata)
431
+
432
+ add_table_ops = [op for op in diff if op[0] == 'add_table']
433
+ assert len(add_table_ops) == 1
434
+
435
+ table = add_table_ops[0][1]
436
+
437
+ # Verify the CheckConstraint is in the table definition
438
+ check_constraints = [c for c in table.constraints if isinstance(c, CheckConstraint)]
439
+ assert len(check_constraints) == 1
440
+
441
+ cc = check_constraints[0]
442
+ assert cc.name == 'ck_products_positive_price'
443
+
444
+
445
+ def test_alembic_detects_table_comment(temp_alembic_env):
446
+ """Test that Alembic autogenerate preserves table comments"""
447
+ temp_dir, alembic_ini = temp_alembic_env
448
+ metadata = MetaData()
449
+ engine = create_engine("sqlite:///:memory:")
450
+
451
+ class Settings(Table, table_name='settings', metadata=metadata, comment='Application configuration'):
452
+ id = Column(Integer, primary_key=True)
453
+ key = Column(String, nullable=False)
454
+ value = Column(String)
455
+
456
+ cfg = Config(str(alembic_ini))
457
+ cfg.attributes['target_metadata'] = metadata
458
+ cfg.attributes['connection'] = engine
459
+
460
+ with engine.begin() as connection:
461
+ mc = MigrationContext.configure(connection)
462
+ diff = compare_metadata(mc, metadata)
463
+
464
+ add_table_ops = [op for op in diff if op[0] == 'add_table']
465
+ assert len(add_table_ops) == 1
466
+
467
+ table = add_table_ops[0][1]
468
+ assert table.comment == 'Application configuration'
@@ -264,3 +264,159 @@ def test_sa_column_annotations_are_correct_type():
264
264
  def gen_id(prefix):
265
265
  """Dummy function for test"""
266
266
  return f"{prefix}_123"
267
+
268
+
269
+ def test_table_with_unique_constraint():
270
+ """Test that UniqueConstraint is properly added to SA table"""
271
+ from sqlalchemy import UniqueConstraint
272
+
273
+ metadata = MetaData()
274
+
275
+ class Clients(Table, table_name='clients', metadata=metadata):
276
+ id = Column(String, primary_key=True)
277
+ tenant_id = Column(String, ForeignKey('tenants.id'))
278
+ email = Column(String, nullable=False)
279
+
280
+ constraints = [
281
+ UniqueConstraint('tenant_id', 'email', name='uq_clients_tenant_email')
282
+ ]
283
+
284
+ assert 'clients' in metadata.tables
285
+ sa_table = metadata.tables['clients']
286
+
287
+ # Find the unique constraint
288
+ unique_constraints = [c for c in sa_table.constraints if isinstance(c, UniqueConstraint)]
289
+ assert len(unique_constraints) == 1
290
+
291
+ uc = unique_constraints[0]
292
+ assert uc.name == 'uq_clients_tenant_email'
293
+ assert set(c.name for c in uc.columns) == {'tenant_id', 'email'}
294
+
295
+ # Query builder still works
296
+ query = Clients.select(Clients.id, Clients.email)
297
+ sql, params = query.render()
298
+ assert 'SELECT clients.id, clients.email FROM clients' in sql
299
+
300
+
301
+ def test_table_with_check_constraint():
302
+ """Test that CheckConstraint is properly added to SA table"""
303
+ from sqlalchemy import CheckConstraint
304
+
305
+ metadata = MetaData()
306
+
307
+ class Products(Table, table_name='products', metadata=metadata):
308
+ id = Column(Integer, primary_key=True)
309
+ name = Column(String, nullable=False)
310
+ price = Column(Integer)
311
+
312
+ constraints = [
313
+ CheckConstraint('price > 0', name='ck_products_positive_price')
314
+ ]
315
+
316
+ sa_table = metadata.tables['products']
317
+
318
+ # Find the check constraint
319
+ check_constraints = [c for c in sa_table.constraints if isinstance(c, CheckConstraint)]
320
+ assert len(check_constraints) == 1
321
+
322
+ cc = check_constraints[0]
323
+ assert cc.name == 'ck_products_positive_price'
324
+
325
+
326
+ def test_table_with_multiple_constraints():
327
+ """Test that multiple constraints can be added together"""
328
+ from sqlalchemy import UniqueConstraint, CheckConstraint, Index
329
+
330
+ metadata = MetaData()
331
+
332
+ class Orders(Table, table_name='orders', metadata=metadata):
333
+ id = Column(String, primary_key=True)
334
+ user_id = Column(String, nullable=False)
335
+ order_number = Column(String, nullable=False)
336
+ amount = Column(Integer)
337
+ status = Column(String)
338
+
339
+ constraints = [
340
+ UniqueConstraint('order_number', name='uq_orders_order_number'),
341
+ CheckConstraint('amount >= 0', name='ck_orders_non_negative_amount'),
342
+ Index('ix_orders_user_status', 'user_id', 'status')
343
+ ]
344
+
345
+ sa_table = metadata.tables['orders']
346
+
347
+ # Verify all constraints are present
348
+ unique_constraints = [c for c in sa_table.constraints if isinstance(c, UniqueConstraint)]
349
+ check_constraints = [c for c in sa_table.constraints if isinstance(c, CheckConstraint)]
350
+
351
+ assert len(unique_constraints) == 1
352
+ assert len(check_constraints) == 1
353
+
354
+ # Verify index
355
+ assert len(sa_table.indexes) == 1
356
+ idx = list(sa_table.indexes)[0]
357
+ assert idx.name == 'ix_orders_user_status'
358
+
359
+
360
+ def test_table_with_constraints_as_tuple():
361
+ """Test that constraints attribute works with tuple format"""
362
+ from sqlalchemy import UniqueConstraint
363
+
364
+ metadata = MetaData()
365
+
366
+ class Items(Table, table_name='items', metadata=metadata):
367
+ id = Column(Integer, primary_key=True)
368
+ category = Column(String)
369
+ code = Column(String)
370
+
371
+ constraints = (
372
+ UniqueConstraint('category', 'code', name='uq_items_category_code'),
373
+ )
374
+
375
+ sa_table = metadata.tables['items']
376
+
377
+ unique_constraints = [c for c in sa_table.constraints if isinstance(c, UniqueConstraint)]
378
+ assert len(unique_constraints) == 1
379
+ assert unique_constraints[0].name == 'uq_items_category_code'
380
+
381
+
382
+ def test_table_with_comment():
383
+ """Test that comment parameter is passed to SQLAlchemy table"""
384
+ metadata = MetaData()
385
+
386
+ class Settings(Table, table_name='settings', metadata=metadata, comment='Application settings and configuration'):
387
+ id = Column(Integer, primary_key=True)
388
+ key = Column(String, nullable=False)
389
+ value = Column(String)
390
+
391
+ sa_table = metadata.tables['settings']
392
+ assert sa_table.comment == 'Application settings and configuration'
393
+
394
+
395
+ def test_table_with_constraints_and_comment():
396
+ """Test that both constraints and comment work together"""
397
+ from sqlalchemy import UniqueConstraint
398
+
399
+ metadata = MetaData()
400
+
401
+ class ApiKeys(Table, table_name='api_keys', metadata=metadata, comment='API authentication keys'):
402
+ id = Column(String, primary_key=True)
403
+ user_id = Column(String, nullable=False)
404
+ key_hash = Column(String, nullable=False)
405
+
406
+ constraints = [
407
+ UniqueConstraint('key_hash', name='uq_api_keys_key_hash')
408
+ ]
409
+
410
+ sa_table = metadata.tables['api_keys']
411
+
412
+ assert sa_table.comment == 'API authentication keys'
413
+
414
+ unique_constraints = [c for c in sa_table.constraints if isinstance(c, UniqueConstraint)]
415
+ assert len(unique_constraints) == 1
416
+ assert unique_constraints[0].name == 'uq_api_keys_key_hash'
417
+
418
+ # Query builder still works
419
+ query = ApiKeys.select().where(ApiKeys.user_id == 'user123')
420
+ sql, params = query.render()
421
+ assert 'WHERE api_keys.user_id = ?' in sql
422
+ assert params == ['user123']
@@ -210,11 +210,30 @@ class Table:
210
210
  class Users(Table, metadata=metadata):
211
211
  id: Column = SACol(Integer, primary_key=True)
212
212
  name: Column = SACol(String(100))
213
+
214
+ Table-level constraints (for SQLAlchemy/Alembic migrations):
215
+
216
+ from sqlalchemy import UniqueConstraint, CheckConstraint
217
+
218
+ class Users(Table, metadata=metadata):
219
+ id = SAColumn(String, primary_key=True)
220
+ tenant_id = SAColumn(String)
221
+ email = SAColumn(String)
222
+
223
+ constraints = [
224
+ UniqueConstraint('tenant_id', 'email', name='uq_users_tenant_email'),
225
+ CheckConstraint('length(email) > 0', name='ck_users_email_not_empty')
226
+ ]
227
+
228
+ Table comment (for database documentation in migrations):
229
+
230
+ class Users(Table, metadata=metadata, comment='Application user accounts'):
231
+ id = SAColumn(Integer, primary_key=True)
213
232
  """
214
233
  table_name: ClassVar[str]
215
234
  schema: ClassVar[Optional[str]] = None
216
235
 
217
- def __init_subclass__(cls, table_name: Optional[str] = None, metadata: Optional[Any] = None, schema: Optional[str] = None, **kwargs):
236
+ def __init_subclass__(cls, table_name: Optional[str] = None, metadata: Optional[Any] = None, schema: Optional[str] = None, comment: Optional[str] = None, **kwargs):
218
237
  super().__init_subclass__(**kwargs)
219
238
 
220
239
  # Set table_name: use provided name, or default to lowercase class name
@@ -334,7 +353,17 @@ class Table:
334
353
 
335
354
  # Create SQLAlchemy Table if metadata provided
336
355
  if metadata is not None and HAS_SQLALCHEMY:
337
- cls._sa_table = SATable(cls.table_name, metadata, *sa_columns, schema=schema)
356
+ # Extract constraints from class attribute (supports both tuple and list)
357
+ table_constraints = getattr(cls, 'constraints', [])
358
+ if isinstance(table_constraints, tuple):
359
+ table_constraints = list(table_constraints)
360
+
361
+ # Build keyword args for SATable
362
+ table_kwargs = {'schema': schema}
363
+ if comment is not None:
364
+ table_kwargs['comment'] = comment
365
+
366
+ cls._sa_table = SATable(cls.table_name, metadata, *sa_columns, *table_constraints, **table_kwargs)
338
367
 
339
368
  # Add the ALL column for wildcard column selection
340
369
  cls.ALL = Column(cls.table_name, '*', schema=schema)
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