t-sql 4.2.1__tar.gz → 4.4.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 (33) hide show
  1. {t_sql-4.2.1 → t_sql-4.4.0}/PKG-INFO +119 -1
  2. {t_sql-4.2.1 → t_sql-4.4.0}/README.md +118 -0
  3. {t_sql-4.2.1 → t_sql-4.4.0}/pyproject.toml +1 -1
  4. {t_sql-4.2.1 → t_sql-4.4.0}/tests/test_alembic_integration.py +106 -1
  5. {t_sql-4.2.1 → t_sql-4.4.0}/tests/test_sqlalchemy_integration.py +156 -0
  6. t_sql-4.4.0/tests/test_type_processor.py +260 -0
  7. {t_sql-4.2.1 → t_sql-4.4.0}/tsql/__init__.py +2 -0
  8. {t_sql-4.2.1 → t_sql-4.4.0}/tsql/query_builder.py +173 -27
  9. t_sql-4.4.0/tsql/type_processor.py +69 -0
  10. {t_sql-4.2.1 → t_sql-4.4.0}/.dockerignore +0 -0
  11. {t_sql-4.2.1 → t_sql-4.4.0}/.github/workflows/publish.yml +0 -0
  12. {t_sql-4.2.1 → t_sql-4.4.0}/.github/workflows/test.yml +0 -0
  13. {t_sql-4.2.1 → t_sql-4.4.0}/.gitignore +0 -0
  14. {t_sql-4.2.1 → t_sql-4.4.0}/Dockerfile +0 -0
  15. {t_sql-4.2.1 → t_sql-4.4.0}/LICENSE +0 -0
  16. {t_sql-4.2.1 → t_sql-4.4.0}/compose.yaml +0 -0
  17. {t_sql-4.2.1 → t_sql-4.4.0}/context7.json +0 -0
  18. {t_sql-4.2.1 → t_sql-4.4.0}/pytest.ini +0 -0
  19. {t_sql-4.2.1 → t_sql-4.4.0}/tests/test_asyncpg_integration.py +0 -0
  20. {t_sql-4.2.1 → t_sql-4.4.0}/tests/test_different_object_types.py +0 -0
  21. {t_sql-4.2.1 → t_sql-4.4.0}/tests/test_escaped.py +0 -0
  22. {t_sql-4.2.1 → t_sql-4.4.0}/tests/test_escaped_binary_hex.py +0 -0
  23. {t_sql-4.2.1 → t_sql-4.4.0}/tests/test_helper_functions.py +0 -0
  24. {t_sql-4.2.1 → t_sql-4.4.0}/tests/test_injection_edge_cases.py +0 -0
  25. {t_sql-4.2.1 → t_sql-4.4.0}/tests/test_injection_protection_validation.py +0 -0
  26. {t_sql-4.2.1 → t_sql-4.4.0}/tests/test_injections_for_escaped.py +0 -0
  27. {t_sql-4.2.1 → t_sql-4.4.0}/tests/test_mysql_integration.py +0 -0
  28. {t_sql-4.2.1 → t_sql-4.4.0}/tests/test_parameter_names.py +0 -0
  29. {t_sql-4.2.1 → t_sql-4.4.0}/tests/test_query_builder.py +0 -0
  30. {t_sql-4.2.1 → t_sql-4.4.0}/tests/test_sqlite_integration.py +0 -0
  31. {t_sql-4.2.1 → t_sql-4.4.0}/tests/test_styles.py +0 -0
  32. {t_sql-4.2.1 → t_sql-4.4.0}/tests/test_tsql.py +0 -0
  33. {t_sql-4.2.1 → t_sql-4.4.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.4.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,124 @@ 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.
571
+
572
+ ### Type Processors
573
+
574
+ Type processors enable automatic value transformation when reading from and writing to the database, similar to SQLAlchemy's `TypeDecorator`. This is useful for encryption, serialization, and custom data transformations.
575
+
576
+ ```python
577
+ from tsql import TypeProcessor
578
+ from tsql.query_builder import Table, SAColumn
579
+ from sqlalchemy import Integer, String, MetaData
580
+ import json
581
+
582
+ metadata = MetaData()
583
+
584
+ # Define custom type processors
585
+ class EncryptedString(TypeProcessor):
586
+ def __init__(self, key):
587
+ self.key = key
588
+
589
+ def process_bind_param(self, value):
590
+ """Transform Python value -> DB value (encrypt on write)"""
591
+ if value is None:
592
+ return None
593
+ return encrypt(value, self.key)
594
+
595
+ def process_result_value(self, value):
596
+ """Transform DB value -> Python value (decrypt on read)"""
597
+ if value is None:
598
+ return None
599
+ return decrypt(value, self.key)
600
+
601
+ class JSONType(TypeProcessor):
602
+ def process_bind_param(self, value):
603
+ """Serialize Python dict/list -> JSON string"""
604
+ return json.dumps(value) if value is not None else None
605
+
606
+ def process_result_value(self, value):
607
+ """Deserialize JSON string -> Python dict/list"""
608
+ return json.loads(value) if value is not None else None
609
+
610
+ # Use type processors in table definition
611
+ class User(Table, metadata=metadata):
612
+ id = SAColumn(Integer, primary_key=True)
613
+ ssn = SAColumn(String(255), type_processor=EncryptedString(key="secret"))
614
+ metadata_ = SAColumn(String, type_processor=JSONType())
615
+ email = SAColumn(String(255)) # No processor = no transformation
616
+
617
+ # Write - automatic encryption/serialization
618
+ User.insert(ssn="123-45-6789", metadata_={"role": "admin"})
619
+ # SQL: INSERT INTO user (ssn, metadata_) VALUES (?, ?)
620
+ # Params: [encrypt("123-45-6789", "secret"), '{"role": "admin"}']
621
+
622
+ User.update(ssn="new-ssn").where(User.id == 1)
623
+ # SQL: UPDATE user SET ssn = ? WHERE user.id = ?
624
+ # Params: [encrypt("new-ssn", "secret"), 1]
625
+
626
+ # Where clauses - automatic transformation
627
+ User.select().where(User.ssn == "123-45-6789")
628
+ # SQL: SELECT * FROM user WHERE user.ssn = ?
629
+ # Params: [encrypt("123-45-6789", "secret")]
630
+
631
+ # Read - manual decryption/deserialization with map_results()
632
+ query = User.select().where(User.id == 1)
633
+ sql, params = query.render()
634
+ rows = await connection.fetch(sql, *params) # Returns encrypted/serialized data
635
+ transformed_rows = query.map_results(rows) # Applies type processors
636
+ # transformed_rows = [{"id": 1, "ssn": "123-45-6789", "metadata_": {"role": "admin"}, ...}]
637
+ ```
638
+
639
+ **Key features:**
640
+ - **Write-side**: Automatically applied in `INSERT`, `UPDATE`, and `WHERE` clauses
641
+ - **Read-side**: Manual via `query.map_results(rows)` - you control when transformation happens
642
+ - **NULL handling**: NULL values are passed through to processors (they decide how to handle)
643
+ - **Column comparisons**: Type processors are NOT applied when comparing columns to other columns
644
+
645
+ **Why manual read-side transformation?**
646
+ The query builder stays database-agnostic and doesn't execute queries directly. You control when to apply transformations after fetching results from your specific database driver.
529
647
 
530
648
  ## Schema Support
531
649
 
@@ -516,6 +516,124 @@ 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.
561
+
562
+ ### Type Processors
563
+
564
+ Type processors enable automatic value transformation when reading from and writing to the database, similar to SQLAlchemy's `TypeDecorator`. This is useful for encryption, serialization, and custom data transformations.
565
+
566
+ ```python
567
+ from tsql import TypeProcessor
568
+ from tsql.query_builder import Table, SAColumn
569
+ from sqlalchemy import Integer, String, MetaData
570
+ import json
571
+
572
+ metadata = MetaData()
573
+
574
+ # Define custom type processors
575
+ class EncryptedString(TypeProcessor):
576
+ def __init__(self, key):
577
+ self.key = key
578
+
579
+ def process_bind_param(self, value):
580
+ """Transform Python value -> DB value (encrypt on write)"""
581
+ if value is None:
582
+ return None
583
+ return encrypt(value, self.key)
584
+
585
+ def process_result_value(self, value):
586
+ """Transform DB value -> Python value (decrypt on read)"""
587
+ if value is None:
588
+ return None
589
+ return decrypt(value, self.key)
590
+
591
+ class JSONType(TypeProcessor):
592
+ def process_bind_param(self, value):
593
+ """Serialize Python dict/list -> JSON string"""
594
+ return json.dumps(value) if value is not None else None
595
+
596
+ def process_result_value(self, value):
597
+ """Deserialize JSON string -> Python dict/list"""
598
+ return json.loads(value) if value is not None else None
599
+
600
+ # Use type processors in table definition
601
+ class User(Table, metadata=metadata):
602
+ id = SAColumn(Integer, primary_key=True)
603
+ ssn = SAColumn(String(255), type_processor=EncryptedString(key="secret"))
604
+ metadata_ = SAColumn(String, type_processor=JSONType())
605
+ email = SAColumn(String(255)) # No processor = no transformation
606
+
607
+ # Write - automatic encryption/serialization
608
+ User.insert(ssn="123-45-6789", metadata_={"role": "admin"})
609
+ # SQL: INSERT INTO user (ssn, metadata_) VALUES (?, ?)
610
+ # Params: [encrypt("123-45-6789", "secret"), '{"role": "admin"}']
611
+
612
+ User.update(ssn="new-ssn").where(User.id == 1)
613
+ # SQL: UPDATE user SET ssn = ? WHERE user.id = ?
614
+ # Params: [encrypt("new-ssn", "secret"), 1]
615
+
616
+ # Where clauses - automatic transformation
617
+ User.select().where(User.ssn == "123-45-6789")
618
+ # SQL: SELECT * FROM user WHERE user.ssn = ?
619
+ # Params: [encrypt("123-45-6789", "secret")]
620
+
621
+ # Read - manual decryption/deserialization with map_results()
622
+ query = User.select().where(User.id == 1)
623
+ sql, params = query.render()
624
+ rows = await connection.fetch(sql, *params) # Returns encrypted/serialized data
625
+ transformed_rows = query.map_results(rows) # Applies type processors
626
+ # transformed_rows = [{"id": 1, "ssn": "123-45-6789", "metadata_": {"role": "admin"}, ...}]
627
+ ```
628
+
629
+ **Key features:**
630
+ - **Write-side**: Automatically applied in `INSERT`, `UPDATE`, and `WHERE` clauses
631
+ - **Read-side**: Manual via `query.map_results(rows)` - you control when transformation happens
632
+ - **NULL handling**: NULL values are passed through to processors (they decide how to handle)
633
+ - **Column comparisons**: Type processors are NOT applied when comparing columns to other columns
634
+
635
+ **Why manual read-side transformation?**
636
+ The query builder stays database-agnostic and doesn't execute queries directly. You control when to apply transformations after fetching results from your specific database driver.
519
637
 
520
638
  ## Schema Support
521
639
 
@@ -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.4.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']