sql-athame 0.4.0a13__tar.gz → 0.4.0a14__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.
- {sql_athame-0.4.0a13 → sql_athame-0.4.0a14}/.bumpversion.cfg +1 -1
- {sql_athame-0.4.0a13 → sql_athame-0.4.0a14}/PKG-INFO +1 -1
- {sql_athame-0.4.0a13 → sql_athame-0.4.0a14}/pyproject.toml +1 -1
- {sql_athame-0.4.0a13 → sql_athame-0.4.0a14}/sql_athame/dataclasses.py +29 -5
- {sql_athame-0.4.0a13 → sql_athame-0.4.0a14}/tests/test_asyncpg.py +168 -0
- {sql_athame-0.4.0a13 → sql_athame-0.4.0a14}/uv.lock +350 -350
- {sql_athame-0.4.0a13 → sql_athame-0.4.0a14}/.editorconfig +0 -0
- {sql_athame-0.4.0a13 → sql_athame-0.4.0a14}/.github/workflows/publish.yml +0 -0
- {sql_athame-0.4.0a13 → sql_athame-0.4.0a14}/.github/workflows/test.yml +0 -0
- {sql_athame-0.4.0a13 → sql_athame-0.4.0a14}/.gitignore +0 -0
- {sql_athame-0.4.0a13 → sql_athame-0.4.0a14}/LICENSE +0 -0
- {sql_athame-0.4.0a13 → sql_athame-0.4.0a14}/README.md +0 -0
- {sql_athame-0.4.0a13 → sql_athame-0.4.0a14}/docker-compose.yml +0 -0
- {sql_athame-0.4.0a13 → sql_athame-0.4.0a14}/run +0 -0
- {sql_athame-0.4.0a13 → sql_athame-0.4.0a14}/sql_athame/__init__.py +0 -0
- {sql_athame-0.4.0a13 → sql_athame-0.4.0a14}/sql_athame/base.py +0 -0
- {sql_athame-0.4.0a13 → sql_athame-0.4.0a14}/sql_athame/escape.py +0 -0
- {sql_athame-0.4.0a13 → sql_athame-0.4.0a14}/sql_athame/py.typed +0 -0
- {sql_athame-0.4.0a13 → sql_athame-0.4.0a14}/sql_athame/sqlalchemy.py +0 -0
- {sql_athame-0.4.0a13 → sql_athame-0.4.0a14}/sql_athame/types.py +0 -0
- {sql_athame-0.4.0a13 → sql_athame-0.4.0a14}/tests/__init__.py +0 -0
- {sql_athame-0.4.0a13 → sql_athame-0.4.0a14}/tests/test_basic.py +0 -0
- {sql_athame-0.4.0a13 → sql_athame-0.4.0a14}/tests/test_dataclasses.py +0 -0
- {sql_athame-0.4.0a13 → sql_athame-0.4.0a14}/tests/test_sqlalchemy.py +0 -0
@@ -46,6 +46,7 @@ class ColumnInfo:
|
|
46
46
|
serialize: Function to transform Python values before database storage
|
47
47
|
deserialize: Function to transform database values back to Python objects
|
48
48
|
insert_only: Whether this field should only be set on INSERT, not UPDATE in upsert operations
|
49
|
+
replace_ignore: Whether this field should be ignored for `replace_multiple`
|
49
50
|
|
50
51
|
Example:
|
51
52
|
>>> from dataclasses import dataclass
|
@@ -72,6 +73,7 @@ class ColumnInfo:
|
|
72
73
|
serialize: Optional[Callable[[Any], Any]] = None
|
73
74
|
deserialize: Optional[Callable[[Any], Any]] = None
|
74
75
|
insert_only: Optional[bool] = None
|
76
|
+
replace_ignore: Optional[bool] = None
|
75
77
|
|
76
78
|
def __post_init__(self, constraints: Union[str, Iterable[str], None]) -> None:
|
77
79
|
if constraints is not None:
|
@@ -98,6 +100,9 @@ class ColumnInfo:
|
|
98
100
|
serialize=b.serialize if b.serialize is not None else a.serialize,
|
99
101
|
deserialize=b.deserialize if b.deserialize is not None else a.deserialize,
|
100
102
|
insert_only=b.insert_only if b.insert_only is not None else a.insert_only,
|
103
|
+
replace_ignore=(
|
104
|
+
b.replace_ignore if b.replace_ignore is not None else a.replace_ignore
|
105
|
+
),
|
101
106
|
)
|
102
107
|
|
103
108
|
|
@@ -118,6 +123,7 @@ class ConcreteColumnInfo:
|
|
118
123
|
serialize: Optional serialization function
|
119
124
|
deserialize: Optional deserialization function
|
120
125
|
insert_only: Whether this field should only be set on INSERT, not UPDATE
|
126
|
+
replace_ignore: Whether this field should be ignored for `replace_multiple`
|
121
127
|
"""
|
122
128
|
|
123
129
|
field: Field
|
@@ -126,9 +132,10 @@ class ConcreteColumnInfo:
|
|
126
132
|
create_type: str
|
127
133
|
nullable: bool
|
128
134
|
constraints: tuple[str, ...]
|
129
|
-
serialize: Optional[Callable[[Any], Any]]
|
130
|
-
deserialize: Optional[Callable[[Any], Any]]
|
131
|
-
insert_only: bool
|
135
|
+
serialize: Optional[Callable[[Any], Any]]
|
136
|
+
deserialize: Optional[Callable[[Any], Any]]
|
137
|
+
insert_only: bool
|
138
|
+
replace_ignore: bool
|
132
139
|
|
133
140
|
@staticmethod
|
134
141
|
def from_column_info(
|
@@ -163,6 +170,7 @@ class ConcreteColumnInfo:
|
|
163
170
|
serialize=info.serialize,
|
164
171
|
deserialize=info.deserialize,
|
165
172
|
insert_only=bool(info.insert_only),
|
173
|
+
replace_ignore=bool(info.replace_ignore),
|
166
174
|
)
|
167
175
|
|
168
176
|
def create_table_string(self) -> str:
|
@@ -386,6 +394,20 @@ class ModelBase:
|
|
386
394
|
},
|
387
395
|
)
|
388
396
|
|
397
|
+
@classmethod
|
398
|
+
def replace_ignore_field_names(cls) -> set[str]:
|
399
|
+
"""Get set of field names marked as replace_ignore in ColumnInfo.
|
400
|
+
|
401
|
+
Returns:
|
402
|
+
Set of field names that should be ignored for `replace_multiple`
|
403
|
+
"""
|
404
|
+
return cls._cached(
|
405
|
+
("replace_ignore_field_names",),
|
406
|
+
lambda: {
|
407
|
+
ci.field.name for ci in cls.column_info().values() if ci.replace_ignore
|
408
|
+
},
|
409
|
+
)
|
410
|
+
|
389
411
|
@classmethod
|
390
412
|
def field_names_sql(
|
391
413
|
cls,
|
@@ -1366,7 +1388,8 @@ class ModelBase:
|
|
1366
1388
|
"""
|
1367
1389
|
# For comparison purposes, combine auto-detected insert_only fields with manual ones
|
1368
1390
|
all_insert_only = cls.insert_only_field_names() | set(insert_only)
|
1369
|
-
|
1391
|
+
default_ignore = cls.replace_ignore_field_names() - set(force_update)
|
1392
|
+
ignore = sorted(set(ignore) | default_ignore | all_insert_only)
|
1370
1393
|
equal_ignoring = cls._cached(
|
1371
1394
|
("equal_ignoring", tuple(ignore)),
|
1372
1395
|
lambda: cls._get_equal_ignoring_fn(ignore),
|
@@ -1512,7 +1535,8 @@ class ModelBase:
|
|
1512
1535
|
"""
|
1513
1536
|
# For comparison purposes, combine auto-detected insert_only fields with manual ones
|
1514
1537
|
all_insert_only = cls.insert_only_field_names() | set(insert_only)
|
1515
|
-
|
1538
|
+
default_ignore = cls.replace_ignore_field_names() - set(force_update)
|
1539
|
+
ignore = sorted(set(ignore) | default_ignore | all_insert_only)
|
1516
1540
|
differences_ignoring = cls._cached(
|
1517
1541
|
("differences_ignoring", tuple(ignore)),
|
1518
1542
|
lambda: cls._get_differences_ignoring_fn(ignore),
|
@@ -396,3 +396,171 @@ async def test_upsert_insert_only(conn):
|
|
396
396
|
assert result[0].created_at == "2023-01-04" # Should be updated
|
397
397
|
assert result[0].name == "Alice Force"
|
398
398
|
assert result[0].count == 20
|
399
|
+
|
400
|
+
|
401
|
+
async def test_replace_multiple_with_replace_ignore(conn):
|
402
|
+
"""Test replace_ignore ColumnInfo attribute."""
|
403
|
+
|
404
|
+
@dataclass(order=True)
|
405
|
+
class Test(ModelBase, table_name="test", primary_key="id"):
|
406
|
+
id: int
|
407
|
+
name: str
|
408
|
+
count: int
|
409
|
+
# metadata field should be ignored during comparison
|
410
|
+
metadata: Annotated[str, ColumnInfo(replace_ignore=True)]
|
411
|
+
|
412
|
+
await conn.execute(*Test.create_table_sql())
|
413
|
+
|
414
|
+
# Insert initial data
|
415
|
+
data = [
|
416
|
+
Test(1, "Alice", 10, "meta1"),
|
417
|
+
Test(2, "Bob", 20, "meta2"),
|
418
|
+
Test(3, "Charlie", 30, "meta3"),
|
419
|
+
]
|
420
|
+
await Test.insert_multiple(conn, data)
|
421
|
+
|
422
|
+
# Replace with same data but different metadata
|
423
|
+
# Since metadata is ignored, no updates should happen
|
424
|
+
new_data = [
|
425
|
+
Test(1, "Alice", 10, "different_meta"),
|
426
|
+
Test(2, "Bob", 20, "different_meta"),
|
427
|
+
Test(3, "Charlie", 30, "different_meta"),
|
428
|
+
]
|
429
|
+
c, u, d = await Test.replace_multiple(conn, new_data, where=[])
|
430
|
+
assert not c # No creates
|
431
|
+
assert not u # No updates because metadata is ignored
|
432
|
+
assert not d # No deletes
|
433
|
+
|
434
|
+
# Verify original metadata is preserved
|
435
|
+
result = await Test.select(conn, order_by="id")
|
436
|
+
assert result[0].metadata == "meta1"
|
437
|
+
assert result[1].metadata == "meta2"
|
438
|
+
assert result[2].metadata == "meta3"
|
439
|
+
|
440
|
+
# Now change a non-ignored field - should trigger update
|
441
|
+
# The metadata will be updated too (it's only ignored for comparison)
|
442
|
+
new_data[0] = Test(1, "Alice Updated", 10, "still_different")
|
443
|
+
c, u, d = await Test.replace_multiple(conn, new_data, where=[])
|
444
|
+
assert not c
|
445
|
+
assert len(u) == 1 # Should update because name changed
|
446
|
+
assert not d
|
447
|
+
|
448
|
+
# Verify update happened - metadata gets updated along with other fields
|
449
|
+
result = await Test.select(conn, where=sql("id = 1"))
|
450
|
+
assert result[0].name == "Alice Updated"
|
451
|
+
assert result[0].metadata == "still_different" # Updated along with name
|
452
|
+
|
453
|
+
|
454
|
+
async def test_replace_multiple_replace_ignore_with_force_update(conn):
|
455
|
+
"""Test that force_update overrides replace_ignore."""
|
456
|
+
|
457
|
+
@dataclass(order=True)
|
458
|
+
class Test(ModelBase, table_name="test", primary_key="id"):
|
459
|
+
id: int
|
460
|
+
name: str
|
461
|
+
metadata: Annotated[str, ColumnInfo(replace_ignore=True)]
|
462
|
+
|
463
|
+
await conn.execute(*Test.create_table_sql())
|
464
|
+
|
465
|
+
# Insert initial data
|
466
|
+
data = [Test(1, "Alice", "meta1"), Test(2, "Bob", "meta2")]
|
467
|
+
await Test.insert_multiple(conn, data)
|
468
|
+
|
469
|
+
# Replace with different metadata, using force_update
|
470
|
+
new_data = [Test(1, "Alice", "new_meta1"), Test(2, "Bob", "new_meta2")]
|
471
|
+
c, u, d = await Test.replace_multiple(
|
472
|
+
conn, new_data, where=[], force_update={"metadata"}
|
473
|
+
)
|
474
|
+
assert not c
|
475
|
+
assert len(u) == 2 # Should update because force_update overrides replace_ignore
|
476
|
+
assert not d
|
477
|
+
|
478
|
+
# Verify metadata was updated
|
479
|
+
result = await Test.select(conn, order_by="id")
|
480
|
+
assert result[0].metadata == "new_meta1"
|
481
|
+
assert result[1].metadata == "new_meta2"
|
482
|
+
|
483
|
+
|
484
|
+
async def test_replace_multiple_replace_ignore_with_insert_only(conn):
|
485
|
+
"""Test interaction between replace_ignore and insert_only."""
|
486
|
+
|
487
|
+
@dataclass(order=True)
|
488
|
+
class Test(ModelBase, table_name="test", primary_key="id"):
|
489
|
+
id: int
|
490
|
+
name: str
|
491
|
+
# Both replace_ignore and insert_only
|
492
|
+
created_at: Annotated[str, ColumnInfo(replace_ignore=True, insert_only=True)]
|
493
|
+
# Only replace_ignore
|
494
|
+
metadata: Annotated[str, ColumnInfo(replace_ignore=True)]
|
495
|
+
|
496
|
+
await conn.execute(*Test.create_table_sql())
|
497
|
+
|
498
|
+
# Insert initial data
|
499
|
+
data = [Test(1, "Alice", "2023-01-01", "meta1")]
|
500
|
+
await Test.insert_multiple(conn, data)
|
501
|
+
|
502
|
+
# Try to replace with different created_at and metadata
|
503
|
+
new_data = [Test(1, "Alice", "2023-01-02", "meta2")]
|
504
|
+
c, u, d = await Test.replace_multiple(conn, new_data, where=[])
|
505
|
+
assert not c
|
506
|
+
assert not u # No update because both fields are ignored
|
507
|
+
assert not d
|
508
|
+
|
509
|
+
# Verify original values preserved
|
510
|
+
result = await Test.select(conn)
|
511
|
+
assert result[0].created_at == "2023-01-01"
|
512
|
+
assert result[0].metadata == "meta1"
|
513
|
+
|
514
|
+
# Change name - should trigger update
|
515
|
+
# created_at is preserved (insert_only), metadata is updated (only ignored for comparison)
|
516
|
+
new_data = [Test(1, "Alice Updated", "2023-01-03", "meta3")]
|
517
|
+
c, u, d = await Test.replace_multiple(conn, new_data, where=[])
|
518
|
+
assert not c
|
519
|
+
assert len(u) == 1
|
520
|
+
assert not d
|
521
|
+
|
522
|
+
# Verify update happened
|
523
|
+
result = await Test.select(conn)
|
524
|
+
assert result[0].name == "Alice Updated"
|
525
|
+
assert result[0].created_at == "2023-01-01" # Preserved (insert_only)
|
526
|
+
assert result[0].metadata == "meta3" # Updated (only ignored for comparison)
|
527
|
+
|
528
|
+
|
529
|
+
async def test_replace_multiple_replace_ignore_partial_match(conn):
|
530
|
+
"""Test replace_ignore when only some records match."""
|
531
|
+
|
532
|
+
@dataclass(order=True)
|
533
|
+
class Test(ModelBase, table_name="test", primary_key="id"):
|
534
|
+
id: int
|
535
|
+
category: str
|
536
|
+
value: int
|
537
|
+
metadata: Annotated[str, ColumnInfo(replace_ignore=True)]
|
538
|
+
|
539
|
+
await conn.execute(*Test.create_table_sql())
|
540
|
+
|
541
|
+
# Insert data with different categories
|
542
|
+
data = [
|
543
|
+
Test(1, "A", 10, "meta1"),
|
544
|
+
Test(2, "A", 20, "meta2"),
|
545
|
+
Test(3, "B", 30, "meta3"),
|
546
|
+
]
|
547
|
+
await Test.insert_multiple(conn, data)
|
548
|
+
|
549
|
+
# Replace only category A with different metadata
|
550
|
+
new_data = [
|
551
|
+
Test(1, "A", 10, "new_meta1"),
|
552
|
+
Test(2, "A", 25, "new_meta2"), # value changed
|
553
|
+
]
|
554
|
+
c, u, d = await Test.replace_multiple(conn, new_data, where=sql("category = 'A'"))
|
555
|
+
assert not c
|
556
|
+
assert len(u) == 1 # Only id=2 should update (value changed)
|
557
|
+
assert not d # Category B record not affected by where clause
|
558
|
+
|
559
|
+
# Verify results
|
560
|
+
result = await Test.select(conn, order_by="id")
|
561
|
+
assert len(result) == 3
|
562
|
+
assert result[0].metadata == "meta1" # Unchanged (no update happened)
|
563
|
+
assert result[0].value == 10
|
564
|
+
assert result[1].metadata == "new_meta2" # Updated along with value
|
565
|
+
assert result[1].value == 25 # Updated
|
566
|
+
assert result[2] == data[2] # Category B unchanged
|