half-orm 0.18.9__tar.gz → 0.18.11__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 (37) hide show
  1. {half_orm-0.18.9/half_orm.egg-info → half_orm-0.18.11}/PKG-INFO +1 -1
  2. {half_orm-0.18.9 → half_orm-0.18.11}/half_orm/relation.py +100 -25
  3. half_orm-0.18.11/half_orm/version.txt +1 -0
  4. {half_orm-0.18.9 → half_orm-0.18.11/half_orm.egg-info}/PKG-INFO +1 -1
  5. half_orm-0.18.9/half_orm/version.txt +0 -1
  6. {half_orm-0.18.9 → half_orm-0.18.11}/AUTHORS +0 -0
  7. {half_orm-0.18.9 → half_orm-0.18.11}/LICENSE +0 -0
  8. {half_orm-0.18.9 → half_orm-0.18.11}/README.md +0 -0
  9. {half_orm-0.18.9 → half_orm-0.18.11}/half_orm/__init__.py +0 -0
  10. {half_orm-0.18.9 → half_orm-0.18.11}/half_orm/__main__.py +0 -0
  11. {half_orm-0.18.9 → half_orm-0.18.11}/half_orm/cli.py +0 -0
  12. {half_orm-0.18.9 → half_orm-0.18.11}/half_orm/cli_utils.py +0 -0
  13. {half_orm-0.18.9 → half_orm-0.18.11}/half_orm/field.py +0 -0
  14. {half_orm-0.18.9 → half_orm-0.18.11}/half_orm/field_errors.py +0 -0
  15. {half_orm-0.18.9 → half_orm-0.18.11}/half_orm/fkey.py +0 -0
  16. {half_orm-0.18.9 → half_orm-0.18.11}/half_orm/hotest.py +0 -0
  17. {half_orm-0.18.9 → half_orm-0.18.11}/half_orm/model.py +0 -0
  18. {half_orm-0.18.9 → half_orm-0.18.11}/half_orm/model_errors.py +0 -0
  19. {half_orm-0.18.9 → half_orm-0.18.11}/half_orm/null.py +0 -0
  20. {half_orm-0.18.9 → half_orm-0.18.11}/half_orm/pg_meta.py +0 -0
  21. {half_orm-0.18.9 → half_orm-0.18.11}/half_orm/relation_errors.py +0 -0
  22. {half_orm-0.18.9 → half_orm-0.18.11}/half_orm/relation_factory.py +0 -0
  23. {half_orm-0.18.9 → half_orm-0.18.11}/half_orm/sql_adapter.py +0 -0
  24. {half_orm-0.18.9 → half_orm-0.18.11}/half_orm/sql_ast.py +0 -0
  25. {half_orm-0.18.9 → half_orm-0.18.11}/half_orm/transaction.py +0 -0
  26. {half_orm-0.18.9 → half_orm-0.18.11}/half_orm/utils.py +0 -0
  27. {half_orm-0.18.9 → half_orm-0.18.11}/half_orm.egg-info/SOURCES.txt +0 -0
  28. {half_orm-0.18.9 → half_orm-0.18.11}/half_orm.egg-info/dependency_links.txt +0 -0
  29. {half_orm-0.18.9 → half_orm-0.18.11}/half_orm.egg-info/entry_points.txt +0 -0
  30. {half_orm-0.18.9 → half_orm-0.18.11}/half_orm.egg-info/requires.txt +0 -0
  31. {half_orm-0.18.9 → half_orm-0.18.11}/half_orm.egg-info/top_level.txt +0 -0
  32. {half_orm-0.18.9 → half_orm-0.18.11}/pyproject.toml +0 -0
  33. {half_orm-0.18.9 → half_orm-0.18.11}/setup.cfg +0 -0
  34. {half_orm-0.18.9 → half_orm-0.18.11}/setup.py +0 -0
  35. {half_orm-0.18.9 → half_orm-0.18.11}/test/test_cli.py +0 -0
  36. {half_orm-0.18.9 → half_orm-0.18.11}/test/test_main.py +0 -0
  37. {half_orm-0.18.9 → half_orm-0.18.11}/test/test_sql_ast.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: half_orm
3
- Version: 0.18.9
3
+ Version: 0.18.11
4
4
  Summary: A simple PostgreSQL to Python mapper.
5
5
  Home-page: https://github.com/half-orm/half-orm
6
6
  Author: Joël Maïzi
@@ -429,9 +429,15 @@ class Relation:
429
429
  def ho_assert_is_singleton(self):
430
430
  """Assert that this predicate identifies exactly one row, without querying the database.
431
431
 
432
- A predicate is a *singleton* when every field of a unique identifier
433
- (primary key or any ``UNIQUE NOT NULL`` constraint) is set with the
434
- ``=`` comparator. The check is purely structural no SQL is executed.
432
+ A predicate is a *singleton* when:
433
+
434
+ * every field of a unique identifier (primary key or any
435
+ ``UNIQUE NOT NULL`` constraint) is set with the ``=`` comparator, **or**
436
+ * a FK join constrains a unique identifier of this relation: the fields
437
+ on *this* side of the join form a PK or UNIQUE NOT NULL, and the
438
+ corresponding fields on the joined relation are all fixed with ``=``.
439
+
440
+ The check is purely structural — no SQL is executed.
435
441
 
436
442
  Returns:
437
443
  self — for chaining before a write operation.
@@ -451,8 +457,14 @@ class Relation:
451
457
  # Raises — last_name is not a unique identifier
452
458
  Author(last_name='Martin').ho_assert_is_singleton()
453
459
 
460
+ # OK — FK navigation: comment.post_id fixes post.id (PK)
461
+ Comment(post_id=42).fk_post().ho_assert_is_singleton()
462
+
454
463
  # Typical usage: guard a single-row write
455
464
  Author(id=42).ho_assert_is_singleton().ho_update(email='new@example.com')
465
+
466
+ # Via FK navigation: delete the post linked to a specific comment
467
+ Comment(post_id=42).fk_post().ho_assert_is_singleton().ho_delete()
456
468
  ```
457
469
 
458
470
  *New in version 0.18.0.*
@@ -465,6 +477,22 @@ class Relation:
465
477
  for ukey in self._ho_ukeys:
466
478
  if _fully_set(ukey):
467
479
  return self
480
+ # A FK join uniquely identifies self when:
481
+ # - the fields on self involved in the join form a PK or UNIQUE NOT NULL, AND
482
+ # - the corresponding fields on the joined relation are all fixed with '='.
483
+ def _all_eq(names, rel):
484
+ return all(
485
+ rel._ho_fields.get(n.strip('"')) is not None
486
+ and rel._ho_fields[n.strip('"')].is_set()
487
+ and rel._ho_fields[n.strip('"')]._comp() == '='
488
+ for n in names
489
+ )
490
+ for fkey, fk_rel in self._ho_join_to.items():
491
+ self_fields = frozenset(fkey.names)
492
+ on_pk = bool(self._ho_pkey) and self_fields == frozenset(self._ho_pkey.keys())
493
+ on_ukey = any(self_fields == frozenset(uk.keys()) for uk in self._ho_ukeys)
494
+ if (on_pk or on_ukey) and _all_eq(fkey.fk_names, fk_rel):
495
+ return self
468
496
  if not self._ho_pkey and not self._ho_ukeys:
469
497
  raise relation_errors.NotASingletonError(
470
498
  f"{self.__class__.__name__} has no primary key or unique NOT NULL constraint.")
@@ -532,20 +560,37 @@ class Relation:
532
560
  if not update_args:
533
561
  return None
534
562
  self._ho_query_type = 'update'
535
- _, where_expr = self.__where_args()
536
563
  set_clause = []
537
564
  for field_name, new_value in update_args.items():
538
565
  col_name = self._ho_fields[field_name].name
539
566
  set_clause.append((f'"{col_name}" = %s', new_value))
540
- fk_where_str, fk_values = self.__fkey_where('', [])
541
- stmt = ASTUpdate(
542
- table=self._qrn,
543
- set_clause=set_clause,
544
- where=where_expr,
545
- fk_where=fk_where_str or None,
546
- fk_values=fk_values,
547
- returning=ASTReturning(list(args)) if args else None,
548
- )
567
+ if self._ho_join_to:
568
+ # Build a subquery to avoid any DB round-trip and to correctly
569
+ # propagate JOIN constraints into the UPDATE predicate.
570
+ pk_names = list(self._ho_pkey.keys())
571
+ sub_sql, sub_vals = self._ho_prep_select(*pk_names)
572
+ if len(pk_names) == 1:
573
+ where = ASTRaw(f'"{pk_names[0]}" in ({sub_sql})', sub_vals)
574
+ else:
575
+ pk_cols = ', '.join(f'"{pk}"' for pk in pk_names)
576
+ where = ASTRaw(f'({pk_cols}) in ({sub_sql})', sub_vals)
577
+ stmt = ASTUpdate(
578
+ table=self._qrn,
579
+ set_clause=set_clause,
580
+ where=where,
581
+ returning=ASTReturning(list(args)) if args else None,
582
+ )
583
+ else:
584
+ _, where_expr = self.__where_args()
585
+ fk_where_str, fk_values = self.__fkey_where('', [])
586
+ stmt = ASTUpdate(
587
+ table=self._qrn,
588
+ set_clause=set_clause,
589
+ where=where_expr,
590
+ fk_where=fk_where_str or None,
591
+ fk_values=fk_values,
592
+ returning=ASTReturning(list(args)) if args else None,
593
+ )
549
594
  query, vals = stmt.to_sql()
550
595
  return query, tuple(vals), update_args
551
596
 
@@ -599,15 +644,31 @@ class Relation:
599
644
  f'Attempt to delete all rows from {self.__class__.__name__}'
600
645
  ' without delete_all being set to True!')
601
646
  self._ho_query_type = 'delete'
602
- _, where_expr = self.__where_args()
603
- fk_where_str, fk_values = self.__fkey_where('', [])
604
- stmt = ASTDelete(
605
- table=self._qrn,
606
- where=where_expr,
607
- fk_where=fk_where_str or None,
608
- fk_values=fk_values,
609
- returning=ASTReturning(list(args)) if args else None,
610
- )
647
+ if self._ho_join_to:
648
+ # Build a subquery to avoid any DB round-trip and to correctly
649
+ # propagate JOIN constraints into the DELETE predicate.
650
+ pk_names = list(self._ho_pkey.keys())
651
+ sub_sql, sub_vals = self._ho_prep_select(*pk_names)
652
+ if len(pk_names) == 1:
653
+ where = ASTRaw(f'"{pk_names[0]}" in ({sub_sql})', sub_vals)
654
+ else:
655
+ pk_cols = ', '.join(f'"{pk}"' for pk in pk_names)
656
+ where = ASTRaw(f'({pk_cols}) in ({sub_sql})', sub_vals)
657
+ stmt = ASTDelete(
658
+ table=self._qrn,
659
+ where=where,
660
+ returning=ASTReturning(list(args)) if args else None,
661
+ )
662
+ else:
663
+ _, where_expr = self.__where_args()
664
+ fk_where_str, fk_values = self.__fkey_where('', [])
665
+ stmt = ASTDelete(
666
+ table=self._qrn,
667
+ where=where_expr,
668
+ fk_where=fk_where_str or None,
669
+ fk_values=fk_values,
670
+ returning=ASTReturning(list(args)) if args else None,
671
+ )
611
672
  query, vals = stmt.to_sql()
612
673
  return query, tuple(vals)
613
674
 
@@ -896,10 +957,24 @@ Fkeys = {"""
896
957
  def ho_is_set(self):
897
958
  """Return True if one field at least is set or if self has been
898
959
  constrained by at least one of its foreign keys or self is the
899
- result of a combination of Relations (using set operators).
960
+ result of a combination of Relations (using set operators) where
961
+ at least one operand is itself constrained.
900
962
  """
901
- joined_to = bool(self._ho_join_to)
902
- return (joined_to or bool(self._ho_set_operators.operator) or bool(self._ho_neg) or
963
+ joined_to = any(jt_.ho_is_set() for jt_ in self._ho_join_to.values())
964
+ op = self._ho_set_operators.operator
965
+ if op:
966
+ left_set = self._ho_set_operators.left.ho_is_set()
967
+ right = self._ho_set_operators.right
968
+ right_set = right is not None and right.ho_is_set()
969
+ if op == "or":
970
+ # A() | B → all rows if any operand is unconstrained
971
+ set_op_constrained = left_set and right_set
972
+ else:
973
+ # "and" / "and not": unconstrained operand is transparent
974
+ set_op_constrained = left_set or right_set
975
+ else:
976
+ set_op_constrained = False
977
+ return (joined_to or set_op_constrained or bool(self._ho_neg) or
903
978
  bool({field for field in self._ho_fields.values() if field.is_set()}))
904
979
 
905
980
  def __get_set_fields(self):
@@ -0,0 +1 @@
1
+ 0.18.11
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: half_orm
3
- Version: 0.18.9
3
+ Version: 0.18.11
4
4
  Summary: A simple PostgreSQL to Python mapper.
5
5
  Home-page: https://github.com/half-orm/half-orm
6
6
  Author: Joël Maïzi
@@ -1 +0,0 @@
1
- 0.18.9
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes