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.
Files changed (24) hide show
  1. {sql_athame-0.4.0a13 → sql_athame-0.4.0a14}/.bumpversion.cfg +1 -1
  2. {sql_athame-0.4.0a13 → sql_athame-0.4.0a14}/PKG-INFO +1 -1
  3. {sql_athame-0.4.0a13 → sql_athame-0.4.0a14}/pyproject.toml +1 -1
  4. {sql_athame-0.4.0a13 → sql_athame-0.4.0a14}/sql_athame/dataclasses.py +29 -5
  5. {sql_athame-0.4.0a13 → sql_athame-0.4.0a14}/tests/test_asyncpg.py +168 -0
  6. {sql_athame-0.4.0a13 → sql_athame-0.4.0a14}/uv.lock +350 -350
  7. {sql_athame-0.4.0a13 → sql_athame-0.4.0a14}/.editorconfig +0 -0
  8. {sql_athame-0.4.0a13 → sql_athame-0.4.0a14}/.github/workflows/publish.yml +0 -0
  9. {sql_athame-0.4.0a13 → sql_athame-0.4.0a14}/.github/workflows/test.yml +0 -0
  10. {sql_athame-0.4.0a13 → sql_athame-0.4.0a14}/.gitignore +0 -0
  11. {sql_athame-0.4.0a13 → sql_athame-0.4.0a14}/LICENSE +0 -0
  12. {sql_athame-0.4.0a13 → sql_athame-0.4.0a14}/README.md +0 -0
  13. {sql_athame-0.4.0a13 → sql_athame-0.4.0a14}/docker-compose.yml +0 -0
  14. {sql_athame-0.4.0a13 → sql_athame-0.4.0a14}/run +0 -0
  15. {sql_athame-0.4.0a13 → sql_athame-0.4.0a14}/sql_athame/__init__.py +0 -0
  16. {sql_athame-0.4.0a13 → sql_athame-0.4.0a14}/sql_athame/base.py +0 -0
  17. {sql_athame-0.4.0a13 → sql_athame-0.4.0a14}/sql_athame/escape.py +0 -0
  18. {sql_athame-0.4.0a13 → sql_athame-0.4.0a14}/sql_athame/py.typed +0 -0
  19. {sql_athame-0.4.0a13 → sql_athame-0.4.0a14}/sql_athame/sqlalchemy.py +0 -0
  20. {sql_athame-0.4.0a13 → sql_athame-0.4.0a14}/sql_athame/types.py +0 -0
  21. {sql_athame-0.4.0a13 → sql_athame-0.4.0a14}/tests/__init__.py +0 -0
  22. {sql_athame-0.4.0a13 → sql_athame-0.4.0a14}/tests/test_basic.py +0 -0
  23. {sql_athame-0.4.0a13 → sql_athame-0.4.0a14}/tests/test_dataclasses.py +0 -0
  24. {sql_athame-0.4.0a13 → sql_athame-0.4.0a14}/tests/test_sqlalchemy.py +0 -0
@@ -1,5 +1,5 @@
1
1
  [bumpversion]
2
- current_version = 0.4.0-alpha-13
2
+ current_version = 0.4.0-alpha-14
3
3
  parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(-(?P<release>.*)-(?P<build>\d+))?
4
4
  serialize =
5
5
  {major}.{minor}.{patch}-{release}-{build}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sql-athame
3
- Version: 0.4.0a13
3
+ Version: 0.4.0a14
4
4
  Summary: Python tool for slicing and dicing SQL
5
5
  Project-URL: homepage, https://github.com/bdowning/sql-athame
6
6
  Project-URL: repository, https://github.com/bdowning/sql-athame
@@ -8,7 +8,7 @@ dependencies = [
8
8
  "typing-extensions",
9
9
  ]
10
10
  name = "sql-athame"
11
- version = "0.4.0-alpha-13"
11
+ version = "0.4.0-alpha-14"
12
12
  description = "Python tool for slicing and dicing SQL"
13
13
  readme = "README.md"
14
14
 
@@ -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]] = 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
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
- 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)
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
- 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)
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