Skip to content

Commit 8d6054a

Browse files
authored
Merge pull request #31 from bdowning/replace-ignore
Add `replace_ignore` ColumnInfo
2 parents 6555931 + 0afa4f1 commit 8d6054a

5 files changed

Lines changed: 549 additions & 357 deletions

File tree

.bumpversion.cfg

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[bumpversion]
2-
current_version = 0.4.0-alpha-13
2+
current_version = 0.4.0-alpha-14
33
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(-(?P<release>.*)-(?P<build>\d+))?
44
serialize =
55
{major}.{minor}.{patch}-{release}-{build}

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ dependencies = [
88
"typing-extensions",
99
]
1010
name = "sql-athame"
11-
version = "0.4.0-alpha-13"
11+
version = "0.4.0-alpha-14"
1212
description = "Python tool for slicing and dicing SQL"
1313
readme = "README.md"
1414

sql_athame/dataclasses.py

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ class ColumnInfo:
4646
serialize: Function to transform Python values before database storage
4747
deserialize: Function to transform database values back to Python objects
4848
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`
4950
5051
Example:
5152
>>> from dataclasses import dataclass
@@ -72,6 +73,7 @@ class ColumnInfo:
7273
serialize: Optional[Callable[[Any], Any]] = None
7374
deserialize: Optional[Callable[[Any], Any]] = None
7475
insert_only: Optional[bool] = None
76+
replace_ignore: Optional[bool] = None
7577

7678
def __post_init__(self, constraints: Union[str, Iterable[str], None]) -> None:
7779
if constraints is not None:
@@ -98,6 +100,9 @@ def merge(a: "ColumnInfo", b: "ColumnInfo") -> "ColumnInfo":
98100
serialize=b.serialize if b.serialize is not None else a.serialize,
99101
deserialize=b.deserialize if b.deserialize is not None else a.deserialize,
100102
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+
),
101106
)
102107

103108

@@ -118,6 +123,7 @@ class ConcreteColumnInfo:
118123
serialize: Optional serialization function
119124
deserialize: Optional deserialization function
120125
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`
121127
"""
122128

123129
field: Field
@@ -126,9 +132,10 @@ class ConcreteColumnInfo:
126132
create_type: str
127133
nullable: bool
128134
constraints: tuple[str, ...]
129-
serialize: Optional[Callable[[Any], Any]] = None
130-
deserialize: Optional[Callable[[Any], Any]] = None
131-
insert_only: bool = False
135+
serialize: Optional[Callable[[Any], Any]]
136+
deserialize: Optional[Callable[[Any], Any]]
137+
insert_only: bool
138+
replace_ignore: bool
132139

133140
@staticmethod
134141
def from_column_info(
@@ -163,6 +170,7 @@ def from_column_info(
163170
serialize=info.serialize,
164171
deserialize=info.deserialize,
165172
insert_only=bool(info.insert_only),
173+
replace_ignore=bool(info.replace_ignore),
166174
)
167175

168176
def create_table_string(self) -> str:
@@ -386,6 +394,20 @@ def insert_only_field_names(cls) -> set[str]:
386394
},
387395
)
388396

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+
389411
@classmethod
390412
def field_names_sql(
391413
cls,
@@ -1366,7 +1388,8 @@ async def plan_replace_multiple(
13661388
"""
13671389
# For comparison purposes, combine auto-detected insert_only fields with manual ones
13681390
all_insert_only = cls.insert_only_field_names() | set(insert_only)
1369-
ignore = sorted(set(ignore) | all_insert_only)
1391+
default_ignore = cls.replace_ignore_field_names() - set(force_update)
1392+
ignore = sorted(set(ignore) | default_ignore | all_insert_only)
13701393
equal_ignoring = cls._cached(
13711394
("equal_ignoring", tuple(ignore)),
13721395
lambda: cls._get_equal_ignoring_fn(ignore),
@@ -1512,7 +1535,8 @@ async def replace_multiple_reporting_differences(
15121535
"""
15131536
# For comparison purposes, combine auto-detected insert_only fields with manual ones
15141537
all_insert_only = cls.insert_only_field_names() | set(insert_only)
1515-
ignore = sorted(set(ignore) | all_insert_only)
1538+
default_ignore = cls.replace_ignore_field_names() - set(force_update)
1539+
ignore = sorted(set(ignore) | default_ignore | all_insert_only)
15161540
differences_ignoring = cls._cached(
15171541
("differences_ignoring", tuple(ignore)),
15181542
lambda: cls._get_differences_ignoring_fn(ignore),

tests/test_asyncpg.py

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,3 +396,171 @@ class Test(ModelBase, table_name="test_upsert", primary_key="id"):
396396
assert result[0].created_at == "2023-01-04" # Should be updated
397397
assert result[0].name == "Alice Force"
398398
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

0 commit comments

Comments
 (0)