ic-python-db 0.13.0__py3-none-any.whl → 0.15.0__py3-none-any.whl

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.
ic_python_db/__init__.py CHANGED
@@ -22,7 +22,7 @@ from .schema import SchemaIncompatibleError, build_schema, diff_schemas, schema_
22
22
  from .storage import MemoryStorage, Storage
23
23
  from .system_time import SystemTime
24
24
 
25
- __version__ = "0.13.0"
25
+ __version__ = "0.15.0"
26
26
  __all__ = [
27
27
  "Database",
28
28
  "Entity",
ic_python_db/db_engine.py CHANGED
@@ -22,6 +22,14 @@ _HEX = frozenset("0123456789abcdef")
22
22
  _ID_WIDTH = 12
23
23
  _HASH_WIDTH = 24
24
24
  _SCAN_PAGE = 500
25
+ # Field-index key families repair_field_indexes walks (see "Repairing field
26
+ # indexes")
27
+ _FI_FAMILIES = ("_fi:", "_fx:")
28
+ # Most ids of an old-form list the repair converts in one call: about 245
29
+ # thousand instructions an id, about 345 thousand where other old lists are
30
+ # among its entry keys (one more read an id), so 24.5 billion, or 34.5 billion
31
+ # at worst, in a call of 40 billion
32
+ _CONVERT_MAX_IDS = 100_000
25
33
  _BUCKETS = (("n", _ID_WIDTH, ":"), ("h", _HASH_WIDTH, "g"))
26
34
  # Purge queue of deleted index sets (see _ix_clear): fixed-width positions
27
35
  # keep its entries contiguous in key order
@@ -30,6 +38,11 @@ _PQ_HEAD = "_pq:head"
30
38
  _PQ_TAIL = "_pq:tail"
31
39
  # Entries of deleted sets each index write removes on the side
32
40
  _PURGE_PER_WRITE = 20
41
+ # Sorted indexes (see "Sorted indexes" below): each field's value is encoded
42
+ # in _SORT_WIDTH decimal digits, an entity id in _SORT_ID_WIDTH characters
43
+ _SORT_WIDTH = 20
44
+ _SORT_ID_WIDTH = 13
45
+ _INVERT = str.maketrans("0123456789", "9876543210")
33
46
 
34
47
 
35
48
  def _is_sequential_id(id_str: str) -> bool:
@@ -37,6 +50,72 @@ def _is_sequential_id(id_str: str) -> bool:
37
50
  return bool(id_str) and id_str[0] != "0" and all(c in _DIGITS for c in id_str)
38
51
 
39
52
 
53
+ def _id_digest(entity_id: str) -> str:
54
+ """The digest a posting-list entry of a custom id is keyed by."""
55
+ return hashlib.sha256(entity_id.encode("utf-8")).hexdigest()[:_HASH_WIDTH]
56
+
57
+
58
+ def _is_hash_entry_key(key: str) -> bool:
59
+ """True for a key that ends like the entry of a custom (hashed) id."""
60
+ digest = key[-_HASH_WIDTH:]
61
+ return key.endswith("|h" + digest) and all(c in _HEX for c in digest)
62
+
63
+
64
+ def _is_hash_entry(key: str, value: str) -> bool:
65
+ """True for the posting-list entry of a custom id: it is keyed by the digest
66
+ of its value. An old list stored under a value that ends like such a key
67
+ does not match it; only a value made for it does (``V|h`` and the digest of
68
+ the list's own JSON text), and that list then reads as an entry of ``V``."""
69
+ return _is_hash_entry_key(key) and _id_digest(value) == key[-_HASH_WIDTH:]
70
+
71
+
72
+ def _escape_key_text(text: str) -> str:
73
+ """``text`` with no ``|`` in it, and still told apart from every other text.
74
+
75
+ ``%`` is written ``%25`` and ``|`` is written ``%7C``. Escaping ``%`` too is
76
+ what keeps two texts from sharing a result (``a|`` and ``a%7C``).
77
+ """
78
+ return text.replace("%", "%25").replace("|", "%7C")
79
+
80
+
81
+ def _unescape_key_text(text: str) -> str:
82
+ """The text ``_escape_key_text`` was given."""
83
+ return text.replace("%7C", "|").replace("%25", "%")
84
+
85
+
86
+ def _entry_base(key: str) -> Optional[str]:
87
+ """The base a posting-list entry key belongs to; None for any other key
88
+ (a count, a mark, an old list)."""
89
+ tail = key[-_ID_WIDTH:]
90
+ if key.endswith("|n" + tail) and all(c in _DIGITS for c in tail):
91
+ return key[: -_ID_WIDTH - 2]
92
+ if _is_hash_entry_key(key):
93
+ return key[: -_HASH_WIDTH - 2]
94
+ return None
95
+
96
+
97
+ def _old_list_size(key: str, value: str) -> int:
98
+ """Ids in the list a release before 0.13 stored as one JSON array under
99
+ ``key``, a key of a value with a "|"; 0 for any other key or value. (The
100
+ entry of a custom id may read as an array: see ``_is_hash_entry``.)"""
101
+ if (
102
+ key.startswith("_fi:")
103
+ and "|" in key
104
+ and value.startswith("[")
105
+ and not _is_hash_entry(key, value)
106
+ ):
107
+ return value.count(",") + 1
108
+ return 0
109
+
110
+
111
+ def _listing_order(entity_id: str) -> Tuple[int, int]:
112
+ """Sort key of the order ``_ix_all`` lists ids in: sequential ids
113
+ ascending, then the others."""
114
+ if _is_sequential_id(entity_id) and len(entity_id) <= _ID_WIDTH:
115
+ return (0, int(entity_id))
116
+ return (1, 0)
117
+
118
+
40
119
  def _next_sequential_id(id_str: str, digits: int) -> Optional[int]:
41
120
  """Smallest ``digits``-digit sequential id that sorts after ``id_str``.
42
121
 
@@ -123,6 +202,9 @@ class Database:
123
202
  )
124
203
 
125
204
  self._entity_types = {}
205
+ # {target class name: reverse counter names}, built by the first
206
+ # delete after the registry last changed (see reverse_count_names)
207
+ self._reverse_count_names: Optional[Dict[str, List[str]]] = None
126
208
  # Identity map: {(type_name, entity_id): entity}. Weak, so an entity
127
209
  # lives only as long as some caller holds it, and its entry goes with it.
128
210
  self._entity_registry = weakref.WeakValueDictionary()
@@ -391,6 +473,16 @@ class Database:
391
473
  return type_name.split("::")[-1]
392
474
  return type_name
393
475
 
476
+ def _set_entity_type(self, name: str, type_obj) -> None:
477
+ """Point a registry name at a class, dropping what is derived from the registry.
478
+
479
+ Every Entity construction registers its class again, so an unchanged
480
+ entry must leave the derived data alone.
481
+ """
482
+ if self._entity_types.get(name) is not type_obj:
483
+ self._entity_types[name] = type_obj
484
+ self._reverse_count_names = None
485
+
394
486
  def register_entity_type(self, type_obj, type_name: str = None):
395
487
  """Register an entity type with the database.
396
488
 
@@ -405,7 +497,7 @@ class Database:
405
497
  )
406
498
 
407
499
  # Always register under the full type name
408
- self._entity_types[type_name] = type_obj
500
+ self._set_entity_type(type_name, type_obj)
409
501
 
410
502
  # For backward compatibility, also register under class name if:
411
503
  # 1. No namespace (type_name == class name) - allows non-namespaced lookups, OR
@@ -416,7 +508,7 @@ class Database:
416
508
  type_name == type_obj.__name__
417
509
  or type_obj.__name__ not in self._entity_types
418
510
  ):
419
- self._entity_types[type_obj.__name__] = type_obj
511
+ self._set_entity_type(type_obj.__name__, type_obj)
420
512
  else:
421
513
  # Class name already registered - log warning about potential collision
422
514
  existing = self._entity_types[type_obj.__name__]
@@ -545,16 +637,35 @@ class Database:
545
637
  # fixed-width suffixes keep one base's entries contiguous, and the "n"
546
638
  # entries come back in ascending numeric id order.
547
639
  #
640
+ # A base that holds a "|" can make keys of its own that look like another
641
+ # base's entries (base "a|n0000000000" has the key "a|n0000000000|c", as
642
+ # long as the entries "a|n000000000100"). A field index never has one (see
643
+ # _fi_key). A reverse index base, _ri:{Type}:{parent id}:{relation}, is
644
+ # not escaped, and a custom parent id may hold a "|": but the relation name
645
+ # ends the base, so a look-alike has to end with it as well, which is
646
+ # rarely possible. Either way a range read does not depend on it: it skips
647
+ # any key that is not an entry of its base and goes on (see _ix_bucket).
648
+ #
548
649
  # Before 0.13 a base key held the whole set as one JSON array, which hit
549
650
  # the value-size limit at ~10k ids. Such an array is converted to entries
550
651
  # the first time its base is touched (see _ix_count).
551
652
 
552
- def _scan(self, start: str, end: str, limit: int) -> List[Tuple[str, str]]:
653
+ def _scan(
654
+ self, start: str, end: Optional[str], limit: int
655
+ ) -> List[Tuple[str, str]]:
656
+ """Up to ``limit`` pairs with ``start <= key < end``, in key order;
657
+ ``end=None`` is no end."""
553
658
  if supports_range(self._db_storage):
554
659
  return self._db_storage.range(start, end, limit)
555
- lo, hi = storage_sort_key(start), storage_sort_key(end)
660
+ lo = storage_sort_key(start)
661
+ hi = None if end is None else storage_sort_key(end)
556
662
  keys = sorted(
557
- (k for k in self._db_storage.keys() if lo <= storage_sort_key(k) < hi),
663
+ (
664
+ k
665
+ for k in self._db_storage.keys()
666
+ if lo <= storage_sort_key(k)
667
+ and (hi is None or storage_sort_key(k) < hi)
668
+ ),
558
669
  key=storage_sort_key,
559
670
  )
560
671
  return [(k, self._db_storage.get(k)) for k in keys[:limit]]
@@ -563,16 +674,37 @@ class Database:
563
674
  def _ix_entry_key(base: str, entity_id: str) -> str:
564
675
  if _is_sequential_id(entity_id) and len(entity_id) <= _ID_WIDTH:
565
676
  return f"{base}|n{entity_id.zfill(_ID_WIDTH)}"
566
- digest = hashlib.sha256(entity_id.encode("utf-8")).hexdigest()
567
- return f"{base}|h{digest[:_HASH_WIDTH]}"
677
+ return f"{base}|h{_id_digest(entity_id)}"
678
+
679
+ def _ix_own(self, key: str) -> Optional[str]:
680
+ """What is stored under a key of this list's own (its count, its mark,
681
+ an entry of it), after an old list of another value that is stored
682
+ there is converted, which moves it out of the way.
683
+
684
+ Before 0.13 a list was one array under the bare key of its value, and
685
+ the bare key of the value "V|c" is the key of V's count; "V|d" is V's
686
+ mark and "V|n" and 12 digits an entry of V.
687
+ """
688
+ value = self._db_storage.get(key)
689
+ if value is not None and value.startswith("["):
690
+ # (An entry of a custom id that reads as an array is left: see
691
+ # _is_hash_entry)
692
+ self._ix_count(key)
693
+ value = self._db_storage.get(key)
694
+ return value
568
695
 
569
696
  def _ix_count(self, base: str) -> int:
570
- raw = self._db_storage.get(f"{base}|c")
697
+ raw = self._ix_own(f"{base}|c")
571
698
  if raw is not None:
572
699
  return int(raw)
573
700
  legacy = self._db_storage.get(base)
574
701
  if legacy is None:
575
702
  return 0
703
+ if _is_hash_entry(base, legacy):
704
+ # An entry holds a custom id, which may read as a list: it is not
705
+ # the array an old list was stored as, and converting it would
706
+ # remove the entry
707
+ return 0
576
708
  try:
577
709
  ids = json.loads(legacy)
578
710
  except ValueError:
@@ -581,8 +713,17 @@ class Database:
581
713
  return 0
582
714
  self._db_storage.remove(base)
583
715
  unique = {str(i) for i in ids}
716
+ # Old lists of other values among the entry keys (rare: two reads to
717
+ # know) are converted before an entry is written over them
718
+ crowded = any(
719
+ self._scan(f"{base}|{tag}{'0' * width}", f"{base}|{tag}{top * width}", 1)
720
+ for tag, width, top in _BUCKETS
721
+ )
584
722
  for entity_id in unique:
585
- self._db_storage.insert(self._ix_entry_key(base, entity_id), entity_id)
723
+ key = self._ix_entry_key(base, entity_id)
724
+ if crowded:
725
+ self._ix_own(key)
726
+ self._db_storage.insert(key, entity_id)
586
727
  if unique:
587
728
  self._db_storage.insert(f"{base}|c", str(len(unique)))
588
729
  return len(unique)
@@ -593,7 +734,7 @@ class Database:
593
734
  if count == 0:
594
735
  self._ix_revive(base)
595
736
  key = self._ix_entry_key(base, entity_id)
596
- if self._db_storage.get(key) is not None:
737
+ if self._ix_own(key) == entity_id:
597
738
  return
598
739
  self._db_storage.insert(key, entity_id)
599
740
  self._db_storage.insert(f"{base}|c", str(count + 1))
@@ -604,8 +745,8 @@ class Database:
604
745
  if count == 0:
605
746
  return
606
747
  key = self._ix_entry_key(base, entity_id)
607
- if self._db_storage.get(key) is None:
608
- return
748
+ if self._db_storage.get(key) != entity_id:
749
+ return # not listed (the key may be an old list of another value)
609
750
  self._db_storage.remove(key)
610
751
  if count > 1:
611
752
  self._db_storage.insert(f"{base}|c", str(count - 1))
@@ -615,7 +756,7 @@ class Database:
615
756
  def _ix_contains(self, base: str, entity_id: str) -> bool:
616
757
  if self._ix_count(base) == 0:
617
758
  return False
618
- return self._db_storage.get(self._ix_entry_key(base, entity_id)) is not None
759
+ return self._db_storage.get(self._ix_entry_key(base, entity_id)) == entity_id
619
760
 
620
761
  def _ix_page(self, base: str, from_id: int, count: int) -> List[str]:
621
762
  """Up to ``count`` sequential ids >= ``from_id``, ascending."""
@@ -625,35 +766,55 @@ class Database:
625
766
  raise ValueError("count must be at least 1")
626
767
  if self._ix_count(base) == 0 or len(str(from_id)) > _ID_WIDTH:
627
768
  return []
628
- start = f"{base}|n{str(from_id).zfill(_ID_WIDTH)}"
629
- end = f"{base}|n{':' * _ID_WIDTH}"
630
- cut = len(base) + 2
631
- # A key of another base can fall inside the range only if its suffix
632
- # is not all digits (e.g. value "x|n0;..." next to value "x").
633
- return [
634
- value
635
- for key, value in self._scan(start, end, count)
636
- if all(c in _DIGITS for c in key[cut:])
637
- ]
769
+ ids: List[str] = []
770
+ first = str(from_id).zfill(_ID_WIDTH)
771
+ for _, value in self._ix_bucket(base, "n", _ID_WIDTH, ":", count, first):
772
+ ids.append(value)
773
+ if len(ids) == count:
774
+ break
775
+ return ids
638
776
 
639
777
  def _ix_bucket(
640
- self, base: str, tag: str, width: int, top: str, page_size: int = _SCAN_PAGE
778
+ self,
779
+ base: str,
780
+ tag: str,
781
+ width: int,
782
+ top: str,
783
+ page_size: int = _SCAN_PAGE,
784
+ first: Optional[str] = None,
641
785
  ):
642
- start = f"{base}|{tag}{'0' * width}"
786
+ """The entries of ``base`` under ``tag`` (from the entry suffix
787
+ ``first``, when given), in key order.
788
+
789
+ A key of another base can fall in the range (see "Posting lists"), and
790
+ so can the old list of another value (see _ix_own): neither is an entry,
791
+ which holds the id its key spells. A page that came back short is the
792
+ end of the range; one that is full but held such keys is not, so the
793
+ scan goes on from its last key.
794
+ """
795
+ start = f"{base}|{tag}{first or '0' * width}"
643
796
  end = f"{base}|{tag}{top * width}"
644
797
  cut = len(base) + 2
645
798
  valid = _DIGITS if tag == "n" else _HEX
799
+ last = None
646
800
  while True:
647
- page = self._scan(start, end, page_size)
801
+ # A range starts at its last key, which is read twice: one more
802
+ # than a page. (No "next key" is computed: the successor of a
803
+ # char can be longer in utf-8, so the key after it is not the
804
+ # same length.)
805
+ wanted = page_size if last is None else page_size + 1
806
+ page = self._scan(start, end, wanted)
648
807
  for key, value in page:
649
- if all(c in valid for c in key[cut:]):
650
- yield key, value
651
- if len(page) < page_size:
808
+ if key != last and all(c in valid for c in key[cut:]):
809
+ if tag == "n":
810
+ own = value == str(int(key[cut:]))
811
+ else:
812
+ own = not value.startswith("[") or _is_hash_entry(key, value)
813
+ if own:
814
+ yield key, value
815
+ if len(page) < wanted:
652
816
  return
653
- # Smallest same-length key after the last one: append the lowest
654
- # char and drop the last, i.e. bump by one in the key order.
655
- last = page[-1][0]
656
- start = last[:-1] + chr(ord(last[-1]) + 1)
817
+ last = start = page[-1][0]
657
818
 
658
819
  def _ix_all(self, base: str) -> List[str]:
659
820
  """Every id in the set: sequential ids ascending, then the others."""
@@ -705,7 +866,7 @@ class Database:
705
866
 
706
867
  def _ix_revive(self, base: str) -> None:
707
868
  """Before reusing a deleted set: purge its leftovers, drop its queue entry."""
708
- position = self._db_storage.get(f"{base}|d")
869
+ position = self._ix_own(f"{base}|d")
709
870
  if position is None:
710
871
  return
711
872
  while self._ix_drain(base, _SCAN_PAGE) == _SCAN_PAGE:
@@ -823,38 +984,322 @@ class Database:
823
984
  # Allow equality lookups (Entity.find_by) without scanning all entities.
824
985
  #
825
986
  # Base key: _fi:{Type}:{field}:{value} (posting list)
987
+ #
988
+ # A value is text a caller chooses, so its base must not hold a "|" (see
989
+ # "Posting lists"). One that holds a "|" goes under _fx instead, with "|"
990
+ # and "%" written as %7C and %25 (so that no two values share an escaped
991
+ # form): _fx:{Type}:{field}:{escaped value}. Every other value, "%" alone
992
+ # included, keeps the key it always had, so nothing moved for them.
993
+ #
994
+ # Such a value used to be filed under _fi with no escape. Its entries stay
995
+ # readable there: each call below also looks at the old key of a value that
996
+ # has one, and adding or removing an id takes it off the old list.
997
+ # repair_field_indexes moves them all, those of custom ids too (which
998
+ # Entity.rebuild_field_indexes does not reach), and converts the lists
999
+ # stored before 0.13 as one array. Once it has run to its end on every
1000
+ # realm, _fi_old_key and what uses it can go, but for what it cannot judge
1001
+ # and leaves (see its docstring). (_fx, not _fi, so that an old key can
1002
+ # never be taken for the new key of another value.)
1003
+
1004
+ @staticmethod
1005
+ def _fi_needs_escape(value: str) -> bool:
1006
+ return "|" in value
826
1007
 
827
1008
  def _fi_key(self, type_name: str, field_name: str, value: str) -> str:
1009
+ if self._fi_needs_escape(value):
1010
+ return f"_fx:{type_name}:{field_name}:{_escape_key_text(value)}"
828
1011
  return f"_fi:{type_name}:{field_name}:{value}"
829
1012
 
1013
+ def _fi_old_key(self, type_name: str, field_name: str, value: str) -> Optional[str]:
1014
+ """Where ``value`` was filed before it was escaped, if not where it is now."""
1015
+ if self._fi_needs_escape(value):
1016
+ return f"_fi:{type_name}:{field_name}:{value}"
1017
+ return None
1018
+
830
1019
  def field_index_get(self, type_name: str, field_name: str, value: str) -> List[str]:
831
1020
  """All ids of entities whose ``field_name`` equals ``value``
832
1021
  (O(matches); prefer ``field_index_page``)."""
833
- return self._ix_all(self._fi_key(type_name, field_name, value))
1022
+ ids = self._ix_all(self._fi_key(type_name, field_name, value))
1023
+ old = self._fi_old_key(type_name, field_name, value)
1024
+ if old is None:
1025
+ return ids
1026
+ # An id on both lists (a release that did not know the new keys wrote
1027
+ # to the old one) is listed once
1028
+ ids = list(dict.fromkeys(ids + self._ix_all(old)))
1029
+ return sorted(ids, key=_listing_order)
834
1030
 
835
1031
  def field_index_page(
836
1032
  self, type_name: str, field_name: str, value: str, from_id: int, count: int
837
1033
  ) -> List[str]:
838
1034
  """Up to ``count`` sequential ids >= ``from_id`` whose ``field_name``
839
- equals ``value``, ascending. One range read."""
840
- return self._ix_page(self._fi_key(type_name, field_name, value), from_id, count)
1035
+ equals ``value``, ascending. One range read; two for a value with a
1036
+ "|" while its old key may hold entries, and one more for each key of
1037
+ another value in the way."""
1038
+ key = self._fi_key(type_name, field_name, value)
1039
+ ids = self._ix_page(key, from_id, count)
1040
+ old = self._fi_old_key(type_name, field_name, value)
1041
+ if old is None:
1042
+ return ids
1043
+ # The first ``count`` of both lists together are among the first
1044
+ # ``count`` of each
1045
+ ids += self._ix_page(old, from_id, count)
1046
+ return sorted(set(ids), key=int)[:count]
841
1047
 
842
1048
  def field_index_count(self, type_name: str, field_name: str, value: str) -> int:
843
- """Number of entities whose ``field_name`` equals ``value`` (one read)."""
844
- return self._ix_count(self._fi_key(type_name, field_name, value))
1049
+ """Number of entities whose ``field_name`` equals ``value`` (one read;
1050
+ two for a value with a "|" while its old key may hold entries, and an id
1051
+ on both lists is counted twice until repair_field_indexes has run)."""
1052
+ total = self._ix_count(self._fi_key(type_name, field_name, value))
1053
+ old = self._fi_old_key(type_name, field_name, value)
1054
+ if old is not None:
1055
+ total += self._ix_count(old)
1056
+ return total
845
1057
 
846
1058
  def field_index_add(
847
1059
  self, type_name: str, field_name: str, value: str, entity_id: str
848
1060
  ) -> None:
849
1061
  """Add an entity ID to the index entry for ``value``."""
1062
+ old = self._fi_old_key(type_name, field_name, value)
1063
+ if old is not None:
1064
+ self._ix_remove(old, entity_id)
850
1065
  self._ix_add(self._fi_key(type_name, field_name, value), entity_id)
851
1066
 
852
1067
  def field_index_remove(
853
1068
  self, type_name: str, field_name: str, value: str, entity_id: str
854
1069
  ) -> None:
855
1070
  """Remove an entity ID from the index entry for ``value``."""
1071
+ old = self._fi_old_key(type_name, field_name, value)
1072
+ if old is not None:
1073
+ self._ix_remove(old, entity_id)
856
1074
  self._ix_remove(self._fi_key(type_name, field_name, value), entity_id)
857
1075
 
1076
+ # ── Repairing field indexes ────────────────────────────────────────────────
1077
+ #
1078
+ # A release that does not know the _fx keys files a value with a "|" under
1079
+ # its _fi key, and does not take an id off the _fx list when the entity's
1080
+ # value changes or the entity is deleted. After an upgrade from it (or
1081
+ # after a rollback to it and a new upgrade) entries are under the old key,
1082
+ # or stale on a list, or on both; a list stored before 0.13 may still be
1083
+ # one JSON array. Entity.rebuild_field_indexes walks the entities, so it
1084
+ # can add or move an entry, but it never finds a stale one and does not
1085
+ # reach custom ids. This walks the keys instead, and checks each entry of a
1086
+ # value with a "|" against its entity.
1087
+
1088
+ def _index_ranges(self, after: str):
1089
+ """The (start, end) key ranges that hold field-index keys, in the order
1090
+ repair_field_indexes walks them, from the one that holds ``after``.
1091
+
1092
+ Storage orders keys by length first, so each length of each family is a
1093
+ range of its own. Only lengths at which the map holds a key are
1094
+ visited (the shortest key at least as long as the last length tells
1095
+ the next), so no length is out of reach.
1096
+ """
1097
+ resume = after[:4] if after else ""
1098
+ first = _FI_FAMILIES.index(resume) if resume else 0
1099
+ for family in _FI_FAMILIES[first:]:
1100
+ length = len(family) + 1
1101
+ if family == resume:
1102
+ length = len(after.encode("utf-8"))
1103
+ while True:
1104
+ shortest = self._scan("\x00" * length, None, 1)
1105
+ if not shortest:
1106
+ break
1107
+ length = len(shortest[0][0].encode("utf-8"))
1108
+ fill = "\x00" * (length - len(family))
1109
+ resumed = family == resume and length == len(after.encode("utf-8"))
1110
+ yield (after if resumed else family + fill), (
1111
+ family[:-1] + chr(ord(family[-1]) + 1) + fill
1112
+ )
1113
+ length += 1
1114
+
1115
+ def _field_of_base(self, rest: str) -> Optional[Tuple[str, str, str]]:
1116
+ """(type name, field, text) of ``{Type}:{field}:{text}``, when the type
1117
+ is registered and the field indexed; None otherwise."""
1118
+ for entity_class in self._entity_types.values():
1119
+ type_name = entity_class.get_full_type_name()
1120
+ if not rest.startswith(type_name + ":"):
1121
+ continue
1122
+ skip = len(type_name) + 1
1123
+ field, colon, text = rest[skip:].partition(":")
1124
+ if colon and field in entity_class._indexed_properties():
1125
+ return type_name, field, text
1126
+ return None
1127
+
1128
+ def _repair_entry(self, key: str, value: str) -> None:
1129
+ size = _old_list_size(key, value)
1130
+ if size:
1131
+ if size <= _CONVERT_MAX_IDS:
1132
+ # Converted into entries, which are longer keys: the walk
1133
+ # reaches them later
1134
+ self._ix_count(key)
1135
+ else:
1136
+ logger.warning(
1137
+ f"repair_field_indexes: the old list {key!r} has {size} ids, "
1138
+ f"more than a call can convert; left as it is"
1139
+ )
1140
+ return
1141
+ base = _entry_base(key)
1142
+ if base is None:
1143
+ return
1144
+ rest, old_style = base[4:], key.startswith("_fi:")
1145
+ if old_style and "|" not in rest:
1146
+ return # a value without "|": its key never changed
1147
+ found = self._field_of_base(rest)
1148
+ if found is None:
1149
+ return # not a type and field this code can judge
1150
+ type_name, field, text = found
1151
+ text = text if old_style else _unescape_key_text(text)
1152
+ row = self.load(type_name, value) # an entry's value is its entity id
1153
+ if row is not None and field not in row:
1154
+ return # a row from before the field: nothing to compare with
1155
+ if row is None or row[field] is None or str(row[field]) != text:
1156
+ self._ix_remove(base, value)
1157
+ elif old_style:
1158
+ self.field_index_add(type_name, field, text, value)
1159
+
1160
+ def repair_field_indexes(self, after: str = "", limit: int = 500) -> Optional[str]:
1161
+ """Check the field-index entries of values with a ``|`` against the
1162
+ entities, at most ``limit`` keys at a time; resumable.
1163
+
1164
+ An entry whose entity is gone, or no longer has that value, is removed.
1165
+ One under the old key (before escaping) whose entity has the value is
1166
+ moved to its present key, so an id is listed once. A list a release
1167
+ before 0.13 stored as one array under the old key of such a value is
1168
+ converted into entries first, and they are checked later in the walk.
1169
+ Entries of values without a ``|`` are only looked at.
1170
+
1171
+ cursor = ""
1172
+ while cursor is not None:
1173
+ cursor = Database.get_instance().repair_field_indexes(cursor)
1174
+
1175
+ Run it to its end after upgrading from a release before escaping, and
1176
+ again after a rollback to one and a new upgrade, **from ``after=""``**:
1177
+ a cursor, or a note that it was done, saved before the rollback skips
1178
+ what the older release wrote. Nothing else needs to run first. Once it
1179
+ has run to its end, no old key holds an entry and counts are exact,
1180
+ except for what it cannot judge and leaves as it is: entries of a type
1181
+ or field this code does not index; entries whose row has no such field;
1182
+ an old-form list of more than 100 thousand ids (it is logged; a read of
1183
+ its value converts it where it fits the message, about 160 thousand ids
1184
+ in an update and 20 thousand in a query, and traps where it does not,
1185
+ as before); and an old-form list of a value made to end with ``|h`` and
1186
+ the digest of its own text, which reads as an entry of the value it
1187
+ extends (only such a value matches; ``find_by`` is not affected). A
1188
+ chain of old lists of ``V``, ``V|c``, ``V|c|c`` and so on deeper than
1189
+ about 500 raises ``RecursionError``: also a value made for it.
1190
+
1191
+ Cost, measured on the IC: a key read about 0.1 million instructions; an
1192
+ entry of a value with a ``|`` that is right about 0.73 million (its row
1193
+ is loaded); an old entry moved about 2 million, so a call of 500 moves
1194
+ is about 1 billion; and about 0.58 million for each distinct key length
1195
+ in the whole map that a call walks past (only the lengths at which the
1196
+ map holds a key are visited). A call is O(``limit`` + those lengths). A
1197
+ list it converts counts its ids against ``limit``, and one longer than
1198
+ ``limit`` is converted whole, alone in its call, at about 245 thousand
1199
+ instructions an id: a map with limited value size holds at most about
1200
+ 10 thousand ids in a list, but a realm's is not limited (a list of
1201
+ 127,000 ids is on record: about 31 billion; it is over the cap, so the
1202
+ repair leaves it, and a read of its value in an update converts it).
1203
+
1204
+ Returns:
1205
+ The ``after`` of the call that continues, None when the walk is done.
1206
+ """
1207
+ if limit < 1:
1208
+ raise ValueError("limit must be at least 1")
1209
+ if after and after[:4] not in _FI_FAMILIES:
1210
+ raise ValueError("after is not a cursor of repair_field_indexes")
1211
+ seen, last = 0, after
1212
+ for start, end in self._index_ranges(after):
1213
+ # One more than is left: a range starts at its last key. A range
1214
+ # with more keys than that fills the call, and ends it below
1215
+ wanted = limit - seen + 1
1216
+ for key, value in self._scan(start, end, wanted):
1217
+ if key == last:
1218
+ continue
1219
+ cost = _old_list_size(key, value) or 1
1220
+ if seen and seen + cost > limit:
1221
+ return last
1222
+ self._repair_entry(key, value)
1223
+ last, seen = key, seen + cost
1224
+ if seen >= limit:
1225
+ return last
1226
+ return None
1227
+
1228
+ # ── Sorted indexes ─────────────────────────────────────────────────────────
1229
+ #
1230
+ # A sorted index keeps one entry per entity whose key starts with the
1231
+ # entity's sort values, so the storage's own key order is the sort order
1232
+ # and a "top N" is one range read:
1233
+ #
1234
+ # _si:{Type}:{fields}:a|{values}{id} -> entity id lowest first
1235
+ # _si:{Type}:{fields}:d|{values}{id} -> entity id highest first
1236
+ # _sb:{Type}:{fields}:{entity id} -> {values} what is indexed now
1237
+ #
1238
+ # {values} is each field's value as _SORT_WIDTH digits (Entity encodes
1239
+ # them), {id} the id in _SORT_ID_WIDTH characters. Every key of one index
1240
+ # and direction has the same length, which keeps them contiguous in key
1241
+ # order (storage_sort_key). The "d" entry stores every digit inverted, so
1242
+ # ascending key order is descending value order; ties go to the higher id.
1243
+ #
1244
+ # The back pointer (_sb) lets an update or a rebuild remove the stale
1245
+ # entry without knowing the old value, so an index survives data
1246
+ # migrations and type changes.
1247
+
1248
+ def _si_prefix(self, type_name: str, index: str, descending: bool) -> str:
1249
+ return f"_si:{type_name}:{index}:{'d' if descending else 'a'}|"
1250
+
1251
+ @staticmethod
1252
+ def _si_id(entity_id: str, descending: bool) -> str:
1253
+ if _is_sequential_id(entity_id) and len(entity_id) < _SORT_ID_WIDTH:
1254
+ digits = entity_id.zfill(_SORT_ID_WIDTH - 1)
1255
+ return "n" + (digits.translate(_INVERT) if descending else digits)
1256
+ digest = hashlib.sha256(entity_id.encode("utf-8")).hexdigest()
1257
+ return "h" + digest[: _SORT_ID_WIDTH - 1]
1258
+
1259
+ def sorted_index_set(
1260
+ self, type_name: str, index: str, entity_id: str, values: Optional[str]
1261
+ ) -> None:
1262
+ """Index ``entity_id`` under ``values`` (encoded sort values), or drop
1263
+ it when ``values`` is None. A no-op when nothing changed."""
1264
+ back = f"_sb:{type_name}:{index}:{entity_id}"
1265
+ old = self._db_storage.get(back)
1266
+ if old == values:
1267
+ return
1268
+ storage = self._db_storage
1269
+ for descending in (False, True):
1270
+ prefix = self._si_prefix(type_name, index, descending)
1271
+ ident = self._si_id(entity_id, descending)
1272
+ if old is not None:
1273
+ key = prefix + (old.translate(_INVERT) if descending else old) + ident
1274
+ if storage.get(key) is not None:
1275
+ storage.remove(key)
1276
+ if values is not None:
1277
+ key = prefix + (values.translate(_INVERT) if descending else values)
1278
+ storage.insert(key + ident, entity_id)
1279
+ if values is None:
1280
+ storage.remove(back)
1281
+ else:
1282
+ storage.insert(back, values)
1283
+
1284
+ def sorted_index_scan(
1285
+ self,
1286
+ type_name: str,
1287
+ index: str,
1288
+ fields: int,
1289
+ descending: bool,
1290
+ start: Optional[str],
1291
+ limit: int,
1292
+ ) -> List[Tuple[str, str]]:
1293
+ """Up to ``limit`` (key, entity id) pairs of an index in sort order,
1294
+ from key ``start`` (inclusive; None for the first)."""
1295
+ prefix = self._si_prefix(type_name, index, descending)
1296
+ width = _SORT_WIDTH * fields + _SORT_ID_WIDTH
1297
+ if start is None:
1298
+ start = prefix + "0" * width
1299
+ elif not start.startswith(prefix) or len(start) != len(prefix) + width:
1300
+ raise ValueError("cursor does not belong to this sorted index")
1301
+ return self._scan(start, prefix + "~" * width, limit)
1302
+
858
1303
  # Reverse counters (unidirectional relations)
859
1304
  # Key format: "_rc:{parent_type}:{parent_id}:{relation_name}"
860
1305
  # Value: integer count of related entities.
@@ -908,6 +1353,46 @@ class Database:
908
1353
  if self._db_storage.get(key) is not None:
909
1354
  self._db_storage.remove(key)
910
1355
 
1356
+ def reverse_count_names(self, type_name: str) -> List[str]:
1357
+ """Names of the reverse counters that entities of a type carry.
1358
+
1359
+ These are the ``reverse_name`` of every unidirectional ManyToMany that
1360
+ a registered class declares towards ``type_name``; a deleted entity
1361
+ must delete its counters. Finding them reads every attribute of every
1362
+ registered class, which costs millions of IC instructions with a
1363
+ realm's 93 types, so the answer is kept until ``register_entity_type``
1364
+ changes the registry.
1365
+ """
1366
+ if self._reverse_count_names is None:
1367
+ self._reverse_count_names = self._find_reverse_count_names()
1368
+ return self._reverse_count_names.get(self._extract_class_name(type_name), [])
1369
+
1370
+ def _find_reverse_count_names(self) -> Dict[str, List[str]]:
1371
+ """{target class name: reverse counter names}, from the registered classes."""
1372
+ from .properties import ManyToMany
1373
+
1374
+ names: Dict[str, List[str]] = {}
1375
+ read = set() # a base class is shared by many registered classes
1376
+ for entity_class in self._entity_types.values():
1377
+ if not isinstance(entity_class, type):
1378
+ continue
1379
+ for cls in entity_class.__mro__:
1380
+ if cls in read:
1381
+ continue
1382
+ read.add(cls)
1383
+ for attr_name, attr in cls.__dict__.items():
1384
+ if attr_name.startswith("_"):
1385
+ continue
1386
+ if not isinstance(attr, ManyToMany) or not attr.unidirectional:
1387
+ continue
1388
+ for target in attr._get_allowed_types():
1389
+ reverse_names = names.setdefault(
1390
+ self._extract_class_name(target), []
1391
+ )
1392
+ if attr.reverse_name not in reverse_names:
1393
+ reverse_names.append(attr.reverse_name)
1394
+ return names
1395
+
911
1396
  def get_audit(
912
1397
  self, id_from: Optional[int] = None, id_to: Optional[int] = None
913
1398
  ) -> Dict[str, str]:
ic_python_db/entity.py CHANGED
@@ -1,6 +1,6 @@
1
1
  """Enhanced entity implementation with support for mixins and entity types."""
2
2
 
3
- from typing import Any, Dict, List, Optional, Set, Tuple, Type, TypeVar
3
+ from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Type, TypeVar
4
4
 
5
5
  from ic_python_logging import get_logger
6
6
 
@@ -12,6 +12,44 @@ logger = get_logger(__name__)
12
12
 
13
13
  T = TypeVar("T", bound="Entity")
14
14
 
15
+ _I64_MIN, _I64_LIMIT = -(1 << 63), 1 << 63
16
+ _INF = float("inf")
17
+
18
+
19
+ def _float_bits(value: float) -> int:
20
+ """The IEEE 754 bit pattern of abs(value), read from float.hex().
21
+
22
+ The canister's ``struct`` module is a stub without pack/unpack.
23
+ """
24
+ mantissa, exponent = float.hex(abs(value))[2:].split("p")
25
+ lead, fraction = mantissa.split(".")
26
+ fraction_bits = int(fraction.ljust(13, "0"), 16)
27
+ if lead == "0":
28
+ return fraction_bits # zero or subnormal: biased exponent 0
29
+ return ((int(exponent) + 1023) << 52) | fraction_bits
30
+
31
+
32
+ def _sort_encode(value: Any) -> str:
33
+ """A value as 20 decimal digits whose string order is the value's order.
34
+
35
+ Integers are offset into 0..2**64-1; finite floats use their IEEE 754
36
+ bits around 2**63, so that negative numbers come first.
37
+ """
38
+ if isinstance(value, bool):
39
+ value = int(value)
40
+ if isinstance(value, int):
41
+ if not _I64_MIN <= value < _I64_LIMIT:
42
+ raise ValueError(f"sorted Integer values must fit in 64 bits: {value}")
43
+ unsigned = value - _I64_MIN
44
+ elif isinstance(value, float):
45
+ if value != value or value in (_INF, -_INF):
46
+ raise ValueError(f"sorted Float values must be finite: {value}")
47
+ bits = _float_bits(value)
48
+ unsigned = (1 << 63) - bits if value < 0 else (1 << 63) + bits
49
+ else:
50
+ raise TypeError(f"sorted fields hold numbers, got {type(value).__name__}")
51
+ return str(unsigned).zfill(20)
52
+
15
53
 
16
54
  class Entity:
17
55
  """Base class for database entities with enhanced features.
@@ -439,6 +477,7 @@ class Entity:
439
477
  f"Version mismatch for {type_name}@{entity_id}: "
440
478
  f"stored={stored_version}, current={current_version}"
441
479
  )
480
+ stored = dict(data) # migrate() may change data in place
442
481
  # Apply custom migration first (developer logic takes priority)
443
482
  data = cls.migrate(data, stored_version, current_version)
444
483
  # Auto-inject defaults for new fields not handled by migrate()
@@ -451,12 +490,25 @@ class Entity:
451
490
  # Create instance first
452
491
  entity = cls(**data, _loaded=True)
453
492
 
454
- # If migration was applied, persist the migrated data
493
+ # If migration was applied, persist the migrated data and move its
494
+ # index entries along with any value migrate() changed
455
495
  if stored_version != current_version:
456
496
  entity._save()
497
+ entity._reindex_after_migration(stored)
457
498
 
458
499
  return entity
459
500
 
501
+ def _reindex_after_migration(self, stored: dict) -> None:
502
+ db = self.db()
503
+ for field in self._indexed_properties():
504
+ old, new = stored.get(field), getattr(self, field, None)
505
+ if old != new:
506
+ if old is not None:
507
+ db.field_index_remove(self._type, field, str(old), self._id)
508
+ if new is not None:
509
+ db.field_index_add(self._type, field, str(new), self._id)
510
+ self._sync_sorted()
511
+
460
512
  @classmethod
461
513
  def _require_unpaged(cls, method: str) -> None:
462
514
  """Raise TooLargeError before ``method`` loads more than MAX_UNPAGED entities."""
@@ -605,7 +657,9 @@ class Entity:
605
657
  while cursor is not None:
606
658
  cursor = Proposal.rebuild_field_index("org_scope", from_id=cursor)
607
659
 
608
- Idempotent — re-indexing an already-indexed entity is a no-op.
660
+ Idempotent — re-indexing an already-indexed entity is a no-op. An
661
+ entity whose value holds ``|`` and was indexed under the key such a
662
+ value had before it was escaped is moved to its present key.
609
663
 
610
664
  Returns:
611
665
  The from_id for the next batch, or None when the rebuild is done.
@@ -614,6 +668,33 @@ class Entity:
614
668
  ValueError: If the field is not declared with indexed=True
615
669
  """
616
670
  cls._require_indexed_field(field)
671
+ return cls._rebuild_field_indexes([field], from_id, batch)
672
+
673
+ @classmethod
674
+ def rebuild_field_indexes(
675
+ cls: Type[T],
676
+ from_id: int = 1,
677
+ batch: int = 200,
678
+ ) -> Optional[int]:
679
+ """``rebuild_field_index`` for every indexed field of the type at once.
680
+
681
+ Moves the entries of values holding ``|`` that an earlier version filed
682
+ under their own text, for entities with sequential ids: until they are
683
+ moved they are still found, under the old key. It adds an entry that is
684
+ missing but never removes one, and does not reach custom ids:
685
+ ``Database.repair_field_indexes`` does both, and is what completes the
686
+ move after an upgrade.
687
+ Same cursor and batch as ``rebuild_field_index``; one batch costs the
688
+ entities it loads once, and a write per indexed field of each.
689
+ """
690
+ return cls._rebuild_field_indexes(
691
+ list(cls._indexed_properties()), from_id, batch
692
+ )
693
+
694
+ @classmethod
695
+ def _rebuild_field_indexes(
696
+ cls: Type[T], fields: List[str], from_id: int, batch: int
697
+ ) -> Optional[int]:
617
698
  if from_id < 1:
618
699
  raise ValueError("from_id must be at least 1")
619
700
  if batch < 1:
@@ -628,9 +709,10 @@ class Entity:
628
709
  # One range read per batch of *live* entities (deleted ids cost nothing)
629
710
  entities = cls.load_some(from_id, batch)
630
711
  for entity in entities:
631
- value = getattr(entity, field, None)
632
- if value is not None:
633
- db.field_index_add(type_name, field, str(value), entity._id)
712
+ for field in fields:
713
+ value = getattr(entity, field, None)
714
+ if value is not None:
715
+ db.field_index_add(type_name, field, str(value), entity._id)
634
716
 
635
717
  if len(entities) < batch:
636
718
  return None
@@ -723,6 +805,168 @@ class Entity:
723
805
  next_from_id = int(rows[count][0]) if len(rows) > count else None
724
806
  return entities, next_from_id
725
807
 
808
+ @classmethod
809
+ def _sorted_indexes(cls) -> Dict[str, Tuple[str, ...]]:
810
+ """{index name: fields} for this type's sorted indexes.
811
+
812
+ A property declared ``sorted=True`` gets its own index; a class's
813
+ ``__sorted_indexes__`` adds indexes over several fields, compared in
814
+ order (the second breaks ties in the first, and so on).
815
+ """
816
+ cached = cls.__dict__.get("_sorted_cache")
817
+ if cached is not None:
818
+ return cached
819
+ from .properties import Property
820
+
821
+ props: Dict[str, Any] = {}
822
+ for klass in reversed(cls.__mro__):
823
+ for attr_name, attr_value in klass.__dict__.items():
824
+ if not attr_name.startswith("_") and isinstance(attr_value, Property):
825
+ props[attr_name] = attr_value
826
+ indexes: Dict[str, Tuple[str, ...]] = {
827
+ name: (name,) for name, prop in props.items() if prop.sorted
828
+ }
829
+ for spec in getattr(cls, "__sorted_indexes__", ()) or ():
830
+ fields = (spec,) if isinstance(spec, str) else tuple(spec)
831
+ for field in fields:
832
+ prop = props.get(field)
833
+ if prop is None or prop.type not in (int, float):
834
+ raise TypeError(
835
+ f"{cls.__name__}.__sorted_indexes__: {field} is not an "
836
+ f"Integer or Float property"
837
+ )
838
+ indexes[",".join(fields)] = fields
839
+ cls._sorted_cache = indexes
840
+ return indexes
841
+
842
+ def _sync_sorted(
843
+ self, field: Optional[str] = None, index: Optional[str] = None
844
+ ) -> None:
845
+ """Bring this entity's sorted index entries up to date: the indexes
846
+ that contain ``field``, only ``index``, or all of them."""
847
+ if self._id is None:
848
+ return
849
+ db = self.db()
850
+ for name, fields in self._sorted_indexes().items():
851
+ if (field is not None and field not in fields) or (
852
+ index is not None and name != index
853
+ ):
854
+ continue
855
+ values = [getattr(self, f, None) for f in fields]
856
+ encoded = (
857
+ None
858
+ if any(v is None for v in values)
859
+ else "".join(_sort_encode(v) for v in values)
860
+ )
861
+ db.sorted_index_set(self._type, name, self._id, encoded)
862
+
863
+ @classmethod
864
+ def _sorted_index_name(cls, fields) -> Tuple[str, Tuple[str, ...]]:
865
+ key = (fields,) if isinstance(fields, str) else tuple(fields)
866
+ name = ",".join(key)
867
+ if name not in cls._sorted_indexes():
868
+ raise ValueError(
869
+ f"{cls.__name__} has no sorted index on {name}: declare the "
870
+ f"property sorted=True, or list the fields in __sorted_indexes__"
871
+ )
872
+ return name, key
873
+
874
+ @classmethod
875
+ def top_by(
876
+ cls: Type[T],
877
+ fields,
878
+ count: int = 20,
879
+ cursor: Optional[str] = None,
880
+ descending: bool = True,
881
+ where: Optional[Callable[[Any], bool]] = None,
882
+ scan_limit: int = 200,
883
+ ) -> Tuple[List[T], Optional[str]]:
884
+ """One page of entities in the order of a sorted index.
885
+
886
+ ``fields`` is a field declared ``sorted=True``, or a tuple listed in
887
+ ``__sorted_indexes__``. Highest first unless ``descending=False``;
888
+ ties go to the higher id. Entities with None in any of the fields are
889
+ not indexed.
890
+
891
+ Returns (entities, cursor); pass the cursor back for the next page,
892
+ None once the index is exhausted. ``where`` drops entities that fail
893
+ it. At most ``scan_limit`` entries are read per call, so with a
894
+ selective ``where`` a page can come back short, or empty, with a
895
+ cursor: call again. Cost is bounded by ``scan_limit`` loads, however
896
+ many entities exist::
897
+
898
+ top, cursor = Listing.top_by("installs", count=20)
899
+ top, cursor = Listing.top_by(("installs", "likes"), count=20)
900
+ """
901
+ if count < 1:
902
+ raise ValueError("count must be at least 1")
903
+ if scan_limit < 1:
904
+ raise ValueError("scan_limit must be at least 1")
905
+ name, key = cls._sorted_index_name(fields)
906
+ db = cls.db()
907
+ type_name = cls.get_full_type_name()
908
+ found: List[T] = []
909
+ read = 0
910
+ start = cursor
911
+ chunk = count # with `where`, doubled each round: rows read track rows kept
912
+ while len(found) < count and read < scan_limit:
913
+ want = min(
914
+ count - len(found) if where is None else chunk, scan_limit - read, 100
915
+ )
916
+ chunk *= 2
917
+ rows = db.sorted_index_scan(
918
+ type_name, name, len(key), descending, start, want
919
+ )
920
+ for row_key, entity_id in rows:
921
+ read += 1
922
+ # The next same-length key: where a later page starts
923
+ start = row_key[:-1] + chr(ord(row_key[-1]) + 1)
924
+ entity = cls.load(entity_id)
925
+ if entity is not None and (where is None or where(entity)):
926
+ found.append(entity)
927
+ if len(found) >= count:
928
+ break
929
+ else:
930
+ if len(rows) < want:
931
+ return found, None # index exhausted
932
+ if start is None or not db.sorted_index_scan(
933
+ type_name, name, len(key), descending, start, 1
934
+ ):
935
+ return found, None
936
+ return found, start
937
+
938
+ @classmethod
939
+ def rebuild_sorted_index(
940
+ cls: Type[T], fields=None, from_id: int = 1, batch: int = 200
941
+ ) -> Optional[int]:
942
+ """Index one batch of existing entities in a sorted index (all of
943
+ them when ``fields`` is None); resumable and idempotent.
944
+
945
+ Use it after declaring a field sorted, or after changing a sorted
946
+ field's type: each entity's stale entry is replaced, whatever it was::
947
+
948
+ cursor = 1
949
+ while cursor is not None:
950
+ cursor = Listing.rebuild_sorted_index("installs", from_id=cursor)
951
+
952
+ Returns the from_id for the next batch, or None when done.
953
+ """
954
+ if from_id < 1:
955
+ raise ValueError("from_id must be at least 1")
956
+ if batch < 1:
957
+ raise ValueError("batch must be at least 1")
958
+ name = None if fields is None else cls._sorted_index_name(fields)[0]
959
+ max_id = cls.max_id()
960
+ if max_id == 0 or from_id > max_id:
961
+ return None
962
+ entities = cls.load_some(from_id, batch)
963
+ for entity in entities:
964
+ entity._sync_sorted(index=name)
965
+ if len(entities) < batch:
966
+ return None
967
+ next_from_id = int(entities[-1]._id) + 1
968
+ return next_from_id if next_from_id <= max_id else None
969
+
726
970
  @classmethod
727
971
  def count(cls: Type[T]) -> int:
728
972
  """Get the total count of entities of this type.
@@ -915,29 +1159,8 @@ class Entity:
915
1159
  # via unidirectional ManyToMany relations targeting our type. Their
916
1160
  # forward indexes may retain a dangling ID (skipped on load); the
917
1161
  # counter itself must not outlive us.
918
- seen_classes = set()
919
- for entity_class in db._entity_types.values():
920
- if entity_class in seen_classes or not isinstance(entity_class, type):
921
- continue
922
- seen_classes.add(entity_class)
923
- for cls in reversed(entity_class.__mro__):
924
- for attr_name, attr_value in cls.__dict__.items():
925
- if attr_name.startswith("_"):
926
- continue
927
- if isinstance(attr_value, ManyToMany) and attr_value.unidirectional:
928
- my_short = (
929
- self._type.split("::")[-1]
930
- if "::" in self._type
931
- else self._type
932
- )
933
- targets = [
934
- t.split("::")[-1] if "::" in t else t
935
- for t in attr_value._get_allowed_types()
936
- ]
937
- if my_short in targets:
938
- db.reverse_count_delete(
939
- self._type, self._id, attr_value.reverse_name
940
- )
1162
+ for reverse_name in db.reverse_count_names(self._type):
1163
+ db.reverse_count_delete(self._type, self._id, reverse_name)
941
1164
 
942
1165
  # Remove from field indexes (issue #11)
943
1166
  for field_name in self._indexed_properties():
@@ -945,6 +1168,9 @@ class Entity:
945
1168
  if value is not None:
946
1169
  db.field_index_remove(self._type, field_name, str(value), self._id)
947
1170
 
1171
+ for name in self._sorted_indexes():
1172
+ db.sorted_index_set(self._type, name, self._id, None)
1173
+
948
1174
  db.delete(self._type, self._id)
949
1175
 
950
1176
  # Remove from entity registry
@@ -42,6 +42,7 @@ class Property(Generic[T]):
42
42
  default: Optional[T]
43
43
  validator: Optional[Callable[[T], bool]]
44
44
  indexed: bool
45
+ sorted: bool
45
46
 
46
47
  def __init__(
47
48
  self,
@@ -50,12 +51,14 @@ class Property(Generic[T]):
50
51
  default: Optional[T] = None,
51
52
  validator: Optional[Callable[[T], bool]] = None,
52
53
  indexed: bool = False,
54
+ sorted: bool = False,
53
55
  ):
54
56
  self.name = name
55
57
  self.type = type
56
58
  self.default = default
57
59
  self.validator = validator
58
60
  self.indexed = indexed
61
+ self.sorted = sorted
59
62
 
60
63
  def __set_name__(self, owner: type, name: str) -> None:
61
64
  self.name = name
@@ -121,6 +124,9 @@ class Property(Generic[T]):
121
124
  if value is not None:
122
125
  db.field_index_add(obj._type, self.name, str(value), obj._id)
123
126
 
127
+ if old_value != value and obj._id is not None and hasattr(obj, "_sync_sorted"):
128
+ obj._sync_sorted(self.name)
129
+
124
130
  obj._save()
125
131
 
126
132
 
@@ -155,6 +161,7 @@ class Integer(Property[int]):
155
161
  max_value: Optional[int] = None,
156
162
  default: Optional[int] = None,
157
163
  indexed: bool = False,
164
+ sorted: bool = False,
158
165
  ):
159
166
  def validator(value: int) -> bool:
160
167
  if min_value is not None and value < min_value:
@@ -164,7 +171,12 @@ class Integer(Property[int]):
164
171
  return True
165
172
 
166
173
  super().__init__(
167
- name="", type=int, default=default, validator=validator, indexed=indexed
174
+ name="",
175
+ type=int,
176
+ default=default,
177
+ validator=validator,
178
+ indexed=indexed,
179
+ sorted=sorted,
168
180
  )
169
181
 
170
182
 
@@ -177,6 +189,7 @@ class Float(Property[float]):
177
189
  max_value: Optional[float] = None,
178
190
  default: Optional[float] = None,
179
191
  indexed: bool = False,
192
+ sorted: bool = False,
180
193
  ):
181
194
  def validator(value: float) -> bool:
182
195
  if min_value is not None and value < min_value:
@@ -186,7 +199,12 @@ class Float(Property[float]):
186
199
  return True
187
200
 
188
201
  super().__init__(
189
- name="", type=float, default=default, validator=validator, indexed=indexed
202
+ name="",
203
+ type=float,
204
+ default=default,
205
+ validator=validator,
206
+ indexed=indexed,
207
+ sorted=sorted,
190
208
  )
191
209
 
192
210
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ic-python-db
3
- Version: 0.13.0
3
+ Version: 0.15.0
4
4
  Summary: A lightweight key-value database written in Python, intended for use on the Internet Computer (IC)
5
5
  Home-page: https://github.com/smart-social-contracts/ic-python-db
6
6
  Author: Smart Social Contracts
@@ -232,6 +232,52 @@ The rebuild is idempotent. The `indexed` flag is not part of the schema descript
232
232
 
233
233
  Each matching id is its own storage entry, so a value can match any number of entities without reaching the backing store's `max_value_size`.
234
234
 
235
+ ### Values that hold `|`
236
+
237
+ An index entry is a key that starts with the value, then `|`, a letter and the id (`_fi:Ticket:status:open|n000000000042`). A value is text a caller chooses, and one that ended like such a key would put keys of its own among the entries of another value, where a page read could miss some of them. So a value with a `|` in it is escaped (`|` as `%7C`, and `%` as `%25`, so that two values never share an escaped form) and filed under `_fx` instead of `_fi`: no value can hold a `|`, so the keys of two values never meet. Every other value keeps the key it always had, one with only a `%` in it included. The escaped key is longer (two more characters for each `|` or `%`), which counts against the storage map's `max_key_size`.
238
+
239
+ **Upgrading from 0.14.** Entries of values with a `|`, filed before this under `_fi` with the value as it was, stay readable: `find_by`, `count_by` and `field_index_*` also read the old key of such a value, and adding or removing an id takes it off the old list. If 0.13 or 0.14 already ran on a store of 0.12 that holds the old lists of both a value `V` and `V|n` and 12 digits, or `V|h` and the digest of a custom id in `V`'s list, converting `V` wrote its entry over the other value's old list: that value's ids are lost. `Entity.rebuild_field_indexes` re-adds the sequential ones (run it, then the repair); a custom id comes back only when its entity's field is next changed, or when `Database.field_index_add` is called for it. To move them all, run once after upgrading, in bounded calls:
240
+
241
+ ```python
242
+ db = Database.get_instance()
243
+ cursor = ""
244
+ while cursor is not None:
245
+ cursor = db.repair_field_indexes(cursor, limit=500) # keys read per call
246
+ ```
247
+
248
+ `repair_field_indexes` checks each entry of a value with a `|` against its entity: it moves an old entry to its present key and removes one whose entity is gone or has another value. It reaches custom ids and keys of any length, and it converts a list that a release before 0.13 stored as one array under the old key of such a value (a list counts its ids against `limit`; one longer than `limit` is converted whole, alone in its call). Nothing needs to run before it. `Entity.rebuild_field_indexes(from_id, batch)` does the move per type for sequential ids only, and adds entries that are missing; it never removes one.
249
+
250
+ When the repair has run to its end no old key holds an entry and counts are exact, except for what it cannot judge and leaves as it is: entries of a type or field the code does not index; entries whose row has no such field; an old-form list of more than 100 thousand ids (it is logged; a read of its value converts it where it fits the message, about 160 thousand ids in an update and 20 thousand in a query, and traps where it does not, as before); and an old-form list of a value made to end with `|h` and the digest of its own text, which reads as an entry of the value it extends (only such a value matches; `find_by` is not affected). A chain of old lists of `V`, `V|c`, `V|c|c` and so on deeper than about 500 raises `RecursionError` (also a value made for it). Only then may a release drop the old-key reads.
251
+
252
+ Cost, measured on the IC: a key read about 0.1 million instructions; an entry of a value with a `|` that is right about 0.73 million (its row is loaded); an old entry moved about 2 million, so a call of 500 moves is about 1 billion; and about 0.58 million for each distinct key length in the whole map that a call walks past. A call is O(`limit` + those lengths). An old-form list is converted whole at about 245 thousand instructions an id: a map with limited value size holds at most about 10 thousand ids in a list, but a realm's is not limited (a list of 127,000 ids is on record: about 31 billion instructions; it is over the cap of 100 thousand ids, so the repair leaves it, and a read of its value in an update converts it).
253
+
254
+ **Rolling back to a release before this one, and upgrading again.** While the older release runs, it files values with a `|` under `_fi` (this release reads them there), but it does not know `_fx`: entries filed under it are invisible to it, and it does not remove one when an entity's value changes or the entity is deleted. After the next upgrade a list can hold an entity that no longer has the value, or an id on both lists, so `count_by` and `find_by` can be wrong for those values. Run `repair_field_indexes` to its end again, **from `after=""`**: a cursor, or a note that it was done, saved before the rollback skips what the older release wrote. It puts it right.
255
+
256
+ ## Sorted Fields
257
+
258
+ Declare `sorted=True` on an `Integer` or `Float` property to read entities in its order, highest or lowest first, without loading and sorting them all. `__sorted_indexes__` adds indexes over several fields, where each field breaks ties in the one before:
259
+
260
+ ```python
261
+ class Listing(Entity):
262
+ name = String()
263
+ installs = Integer(sorted=True)
264
+ likes = Integer(sorted=True)
265
+ updated_at = Float(sorted=True)
266
+ __sorted_indexes__ = [("installs", "likes")] # installs, then likes
267
+
268
+ top, cursor = Listing.top_by("installs", count=20) # most installed
269
+ top, cursor = Listing.top_by(("installs", "likes"), count=20) # ties broken by likes
270
+ recent, cursor = Listing.top_by("updated_at", count=20)
271
+ oldest, cursor = Listing.top_by("updated_at", count=20, descending=False)
272
+ more, cursor = Listing.top_by("installs", count=20, cursor=cursor) # next page; None at the end
273
+ ```
274
+
275
+ The storage map keeps keys in order, so each entity has one index entry per sorted index whose key starts with its values. Writing a sorted field moves that entry (a few storage operations), and a page is one range read plus `count` loads, however many entities exist. Ties go to the higher id; entities with None in a field are left out of indexes on it. Integers must fit in 64 bits.
276
+
277
+ `where=` drops entities that fail a test. At most `scan_limit` entries (default 200) are read per call, so with a selective filter a page can come back short with a cursor: call again.
278
+
279
+ Fields made sorted after rows exist, or whose type changed, need a resumable rebuild (`Listing.rebuild_sorted_index("installs", from_id=cursor)`, until it returns None). A `migrate()` that changes a sorted or indexed value moves the entity's entries when it is loaded.
280
+
235
281
  ## Large Relations
236
282
 
237
283
  `OneToMany` and `ManyToMany` getters return a `RelationView`, not a list. Questions about the relation and single pages never load every member, so they stay cheap however large it grows:
@@ -1,19 +1,19 @@
1
- ic_python_db/__init__.py,sha256=amMTKKg0eRKGSc6Hz8O-ZqhZfwOZFwRbfxxB5UQfX5o,1083
1
+ ic_python_db/__init__.py,sha256=B4YsbiquQKnGlWpxE0Kfc8Q4dj2gwdaL5NHXBldcSQM,1083
2
2
  ic_python_db/_cdk.py,sha256=zTj8HzXUcPz8R73nh5bo6JMkCorp-KSW1hDutzi5LZs,387
3
3
  ic_python_db/constants.py,sha256=gs6klyqUxUjE0LFJxXpaya8QEgYeKx4ovP9Mu04rtHk,351
4
4
  ic_python_db/context.py,sha256=97F4_EAJm3adOnUfh0SaDisX3nc7F2vFIZwZUsUAy58,1080
5
- ic_python_db/db_engine.py,sha256=KniUAn9Gzn8G8bhj-8Kz0-Q1xX6dIvtcN3t3Ng-STSc,36792
6
- ic_python_db/entity.py,sha256=fRUN0gBBN-KtEXOZOh5rxXlskFltfG8kDlwq1fK6Ck0,54408
5
+ ic_python_db/db_engine.py,sha256=1Sl6mDv86AdOL9j2zp6TAG89gjI6XbGLZLttmKsOsak,60401
6
+ ic_python_db/entity.py,sha256=wlCpRLcqK2FYosPgm3YtXReR6ygZigQkzO78twQqdMc,63768
7
7
  ic_python_db/errors.py,sha256=q_2oAu2mX4B3eGySS-8xOU_CIoKoPlxKwECkbxaHQ1E,366
8
8
  ic_python_db/hooks.py,sha256=B1wV18dAMH-PootSripmMpG9Cr9M3W3lcNEF7QvPrL4,1620
9
9
  ic_python_db/mixins.py,sha256=qZmb3ssXYf2NSjHJtgmdsOlkIJdouDjPHdQ9tZmrZi8,1937
10
- ic_python_db/properties.py,sha256=yo2mPH2OjzYvGM3KKSAaiIGTep_qv2496ZT9-_T34FU,31253
10
+ ic_python_db/properties.py,sha256=FZgh9FFL8epypEo0u9y8zi0HLXz0KYl3nXtY4ILlnQE,31670
11
11
  ic_python_db/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
12
  ic_python_db/schema.py,sha256=h1RAbapExo8pMD5IQvK8ZTVkgyonbPV9qSqliSP0Y14,16249
13
13
  ic_python_db/storage.py,sha256=899qyuA3alpAFIPM23A_07vqquUB5ZG5GjjpzlHW1Os,3828
14
14
  ic_python_db/system_time.py,sha256=xrx8NfjLXCGsac3hUpuvsNwCH91tQ9jfH4N-XFo8iJk,2614
15
- ic_python_db-0.13.0.dist-info/licenses/LICENSE,sha256=6q6XYNOGnJcVSus2bAezFn7bU_2Y5T6W4aGQHBb8X-c,1079
16
- ic_python_db-0.13.0.dist-info/METADATA,sha256=lwTLW2L4sj_aLQ--SxXjAFZ0WBRb7of1UXi7YqCa83I,18319
17
- ic_python_db-0.13.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
18
- ic_python_db-0.13.0.dist-info/top_level.txt,sha256=ZaOTqhWKtJQEuXmqR920AqbDBAqv1mvsKj3TClWimDk,13
19
- ic_python_db-0.13.0.dist-info/RECORD,,
15
+ ic_python_db-0.15.0.dist-info/licenses/LICENSE,sha256=6q6XYNOGnJcVSus2bAezFn7bU_2Y5T6W4aGQHBb8X-c,1079
16
+ ic_python_db-0.15.0.dist-info/METADATA,sha256=paSQqVYU1bhS_IB-jr6exDYqdoOt_xXSx9uKdmemBjs,24628
17
+ ic_python_db-0.15.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
18
+ ic_python_db-0.15.0.dist-info/top_level.txt,sha256=ZaOTqhWKtJQEuXmqR920AqbDBAqv1mvsKj3TClWimDk,13
19
+ ic_python_db-0.15.0.dist-info/RECORD,,