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.
- {t_sql-4.2.1 → t_sql-4.4.0}/PKG-INFO +119 -1
- {t_sql-4.2.1 → t_sql-4.4.0}/README.md +118 -0
- {t_sql-4.2.1 → t_sql-4.4.0}/pyproject.toml +1 -1
- {t_sql-4.2.1 → t_sql-4.4.0}/tests/test_alembic_integration.py +106 -1
- {t_sql-4.2.1 → t_sql-4.4.0}/tests/test_sqlalchemy_integration.py +156 -0
- t_sql-4.4.0/tests/test_type_processor.py +260 -0
- {t_sql-4.2.1 → t_sql-4.4.0}/tsql/__init__.py +2 -0
- {t_sql-4.2.1 → t_sql-4.4.0}/tsql/query_builder.py +173 -27
- t_sql-4.4.0/tsql/type_processor.py +69 -0
- {t_sql-4.2.1 → t_sql-4.4.0}/.dockerignore +0 -0
- {t_sql-4.2.1 → t_sql-4.4.0}/.github/workflows/publish.yml +0 -0
- {t_sql-4.2.1 → t_sql-4.4.0}/.github/workflows/test.yml +0 -0
- {t_sql-4.2.1 → t_sql-4.4.0}/.gitignore +0 -0
- {t_sql-4.2.1 → t_sql-4.4.0}/Dockerfile +0 -0
- {t_sql-4.2.1 → t_sql-4.4.0}/LICENSE +0 -0
- {t_sql-4.2.1 → t_sql-4.4.0}/compose.yaml +0 -0
- {t_sql-4.2.1 → t_sql-4.4.0}/context7.json +0 -0
- {t_sql-4.2.1 → t_sql-4.4.0}/pytest.ini +0 -0
- {t_sql-4.2.1 → t_sql-4.4.0}/tests/test_asyncpg_integration.py +0 -0
- {t_sql-4.2.1 → t_sql-4.4.0}/tests/test_different_object_types.py +0 -0
- {t_sql-4.2.1 → t_sql-4.4.0}/tests/test_escaped.py +0 -0
- {t_sql-4.2.1 → t_sql-4.4.0}/tests/test_escaped_binary_hex.py +0 -0
- {t_sql-4.2.1 → t_sql-4.4.0}/tests/test_helper_functions.py +0 -0
- {t_sql-4.2.1 → t_sql-4.4.0}/tests/test_injection_edge_cases.py +0 -0
- {t_sql-4.2.1 → t_sql-4.4.0}/tests/test_injection_protection_validation.py +0 -0
- {t_sql-4.2.1 → t_sql-4.4.0}/tests/test_injections_for_escaped.py +0 -0
- {t_sql-4.2.1 → t_sql-4.4.0}/tests/test_mysql_integration.py +0 -0
- {t_sql-4.2.1 → t_sql-4.4.0}/tests/test_parameter_names.py +0 -0
- {t_sql-4.2.1 → t_sql-4.4.0}/tests/test_query_builder.py +0 -0
- {t_sql-4.2.1 → t_sql-4.4.0}/tests/test_sqlite_integration.py +0 -0
- {t_sql-4.2.1 → t_sql-4.4.0}/tests/test_styles.py +0 -0
- {t_sql-4.2.1 → t_sql-4.4.0}/tests/test_tsql.py +0 -0
- {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.
|
|
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
|
|
|
@@ -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']
|