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 +1 -1
- ic_python_db/db_engine.py +524 -39
- ic_python_db/entity.py +255 -29
- ic_python_db/properties.py +20 -2
- {ic_python_db-0.13.0.dist-info → ic_python_db-0.15.0.dist-info}/METADATA +47 -1
- {ic_python_db-0.13.0.dist-info → ic_python_db-0.15.0.dist-info}/RECORD +9 -9
- {ic_python_db-0.13.0.dist-info → ic_python_db-0.15.0.dist-info}/WHEEL +0 -0
- {ic_python_db-0.13.0.dist-info → ic_python_db-0.15.0.dist-info}/licenses/LICENSE +0 -0
- {ic_python_db-0.13.0.dist-info → ic_python_db-0.15.0.dist-info}/top_level.txt +0 -0
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.
|
|
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.
|
|
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.
|
|
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(
|
|
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
|
|
660
|
+
lo = storage_sort_key(start)
|
|
661
|
+
hi = None if end is None else storage_sort_key(end)
|
|
556
662
|
keys = sorted(
|
|
557
|
-
(
|
|
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
|
-
|
|
567
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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)
|
|
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))
|
|
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
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
651
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
632
|
-
|
|
633
|
-
|
|
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
|
-
|
|
919
|
-
|
|
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
|
ic_python_db/properties.py
CHANGED
|
@@ -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="",
|
|
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="",
|
|
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.
|
|
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=
|
|
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=
|
|
6
|
-
ic_python_db/entity.py,sha256=
|
|
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=
|
|
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.
|
|
16
|
-
ic_python_db-0.
|
|
17
|
-
ic_python_db-0.
|
|
18
|
-
ic_python_db-0.
|
|
19
|
-
ic_python_db-0.
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|