brainlessdb 0.17.2__tar.gz → 0.17.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {brainlessdb-0.17.2 → brainlessdb-0.17.3}/CHANGELOG.md +7 -0
- {brainlessdb-0.17.2 → brainlessdb-0.17.3}/PKG-INFO +1 -1
- {brainlessdb-0.17.2 → brainlessdb-0.17.3}/brainlessdb/__init__.py +1 -1
- {brainlessdb-0.17.2 → brainlessdb-0.17.3}/brainlessdb/collection.py +34 -21
- {brainlessdb-0.17.2 → brainlessdb-0.17.3}/brainlessdb/struct.py +24 -10
- {brainlessdb-0.17.2 → brainlessdb-0.17.3}/pyproject.toml +1 -1
- brainlessdb-0.17.3/tests/test_update_atomic.py +243 -0
- {brainlessdb-0.17.2 → brainlessdb-0.17.3}/uv.lock +1 -1
- {brainlessdb-0.17.2 → brainlessdb-0.17.3}/.gitignore +0 -0
- {brainlessdb-0.17.2 → brainlessdb-0.17.3}/.python-version +0 -0
- {brainlessdb-0.17.2 → brainlessdb-0.17.3}/README.md +0 -0
- {brainlessdb-0.17.2 → brainlessdb-0.17.3}/brainlessdb/bucket.py +0 -0
- {brainlessdb-0.17.2 → brainlessdb-0.17.3}/brainlessdb/client.py +0 -0
- {brainlessdb-0.17.2 → brainlessdb-0.17.3}/brainlessdb/fields.py +0 -0
- {brainlessdb-0.17.2 → brainlessdb-0.17.3}/brainlessdb/registry.py +0 -0
- {brainlessdb-0.17.2 → brainlessdb-0.17.3}/cliff.toml +0 -0
- {brainlessdb-0.17.2 → brainlessdb-0.17.3}/code_style_guide.md +0 -0
- {brainlessdb-0.17.2 → brainlessdb-0.17.3}/justfile +0 -0
- {brainlessdb-0.17.2 → brainlessdb-0.17.3}/test_results.txt +0 -0
- {brainlessdb-0.17.2 → brainlessdb-0.17.3}/tests/__init__.py +0 -0
- {brainlessdb-0.17.2 → brainlessdb-0.17.3}/tests/conftest.py +0 -0
- {brainlessdb-0.17.2 → brainlessdb-0.17.3}/tests/mock_nats.py +0 -0
- {brainlessdb-0.17.2 → brainlessdb-0.17.3}/tests/test_bucket.py +0 -0
- {brainlessdb-0.17.2 → brainlessdb-0.17.3}/tests/test_client.py +0 -0
- {brainlessdb-0.17.2 → brainlessdb-0.17.3}/tests/test_collection.py +0 -0
- {brainlessdb-0.17.2 → brainlessdb-0.17.3}/tests/test_dead_letter.py +0 -0
- {brainlessdb-0.17.2 → brainlessdb-0.17.3}/tests/test_deserialization.py +0 -0
- {brainlessdb-0.17.2 → brainlessdb-0.17.3}/tests/test_duplicates.py +0 -0
- {brainlessdb-0.17.2 → brainlessdb-0.17.3}/tests/test_fields.py +0 -0
- {brainlessdb-0.17.2 → brainlessdb-0.17.3}/tests/test_flush_errors.py +0 -0
- {brainlessdb-0.17.2 → brainlessdb-0.17.3}/tests/test_load_errors.py +0 -0
- {brainlessdb-0.17.2 → brainlessdb-0.17.3}/tests/test_registry.py +0 -0
- {brainlessdb-0.17.2 → brainlessdb-0.17.3}/tests/test_sync.py +0 -0
- {brainlessdb-0.17.2 → brainlessdb-0.17.3}/tests/test_thread_safety.py +0 -0
- {brainlessdb-0.17.2 → brainlessdb-0.17.3}/tests/test_version.py +0 -0
|
@@ -3,6 +3,13 @@
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
5
|
|
|
6
|
+
## [0.17.3] - 2026-10-21
|
|
7
|
+
|
|
8
|
+
### 🐛 Bug Fixes
|
|
9
|
+
|
|
10
|
+
- Make update() atomic and unindex by recorded values, not mutated attributes
|
|
11
|
+
|
|
12
|
+
|
|
6
13
|
## [0.17.2] - 2026-08-22
|
|
7
14
|
|
|
8
15
|
### 🐛 Bug Fixes
|
|
@@ -188,6 +188,10 @@ class Collection(Generic[T]):
|
|
|
188
188
|
self._indexes: dict[str, dict[Any, set[str]]] = {f: {} for f in self._analysis.indexed_fields}
|
|
189
189
|
# Uniques: field -> value -> uid
|
|
190
190
|
self._uniques: dict[str, dict[Any, str]] = {f: {} for f in self._analysis.unique_fields}
|
|
191
|
+
# What _index_add actually recorded per uid: (indexed values, unique values).
|
|
192
|
+
# Removal goes by these, never by the entity's current attributes - the
|
|
193
|
+
# stored object is often mutated in place before save() re-indexes it.
|
|
194
|
+
self._indexed: dict[str, tuple[dict[str, Any], dict[str, Any]]] = {}
|
|
191
195
|
|
|
192
196
|
# Callbacks: list of (callback, trigger_local)
|
|
193
197
|
self._on_change: list[tuple[Callable, bool]] = []
|
|
@@ -234,13 +238,19 @@ class Collection(Generic[T]):
|
|
|
234
238
|
# === Indexing ===
|
|
235
239
|
|
|
236
240
|
def _index_add(self, obj: T) -> None:
|
|
241
|
+
"""(Re)index `obj`, replacing whatever was indexed for its uid before."""
|
|
237
242
|
uid = str(obj.uuid)
|
|
243
|
+
self._index_remove(uid)
|
|
244
|
+
indexed: dict[str, Any] = {}
|
|
245
|
+
uniques: dict[str, Any] = {}
|
|
238
246
|
for field in self._analysis.indexed_fields:
|
|
239
247
|
val = getattr(obj, field, None)
|
|
240
248
|
self._indexes[field].setdefault(val, set()).add(uid)
|
|
249
|
+
indexed[field] = val
|
|
241
250
|
for field in self._analysis.unique_fields:
|
|
242
251
|
val = getattr(obj, field, None)
|
|
243
252
|
if val is not None:
|
|
253
|
+
uniques[field] = val
|
|
244
254
|
prev = self._uniques[field].get(val)
|
|
245
255
|
if prev is not None and prev != uid:
|
|
246
256
|
_log.warning(
|
|
@@ -248,18 +258,22 @@ class Collection(Generic[T]):
|
|
|
248
258
|
self.name, field, val, prev, uid,
|
|
249
259
|
)
|
|
250
260
|
self._uniques[field][val] = uid
|
|
261
|
+
self._indexed[uid] = (indexed, uniques)
|
|
251
262
|
|
|
252
|
-
def _index_remove(self,
|
|
253
|
-
uid
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
263
|
+
def _index_remove(self, uid: str) -> None:
|
|
264
|
+
"""Drop the index/unique entries recorded for `uid` by _index_add."""
|
|
265
|
+
recorded = self._indexed.pop(uid, None)
|
|
266
|
+
if recorded is None:
|
|
267
|
+
return
|
|
268
|
+
indexed, uniques = recorded
|
|
269
|
+
for field, val in indexed.items():
|
|
270
|
+
uids = self._indexes[field].get(val)
|
|
271
|
+
if uids is not None:
|
|
272
|
+
uids.discard(uid)
|
|
273
|
+
if not uids:
|
|
259
274
|
del self._indexes[field][val]
|
|
260
|
-
for field in
|
|
261
|
-
|
|
262
|
-
if val is not None and self._uniques[field].get(val) == uid:
|
|
275
|
+
for field, val in uniques.items():
|
|
276
|
+
if self._uniques[field].get(val) == uid:
|
|
263
277
|
del self._uniques[field][val]
|
|
264
278
|
|
|
265
279
|
def _check_unique(self, obj: T) -> None:
|
|
@@ -289,7 +303,7 @@ class Collection(Generic[T]):
|
|
|
289
303
|
obj = self._entities.pop(uid, None)
|
|
290
304
|
if obj is None:
|
|
291
305
|
return None
|
|
292
|
-
self._index_remove(
|
|
306
|
+
self._index_remove(uid)
|
|
293
307
|
_unregister(obj.uuid)
|
|
294
308
|
self._metadata.pop(uid, None)
|
|
295
309
|
self._dirty.discard(uid)
|
|
@@ -468,8 +482,11 @@ class Collection(Generic[T]):
|
|
|
468
482
|
|
|
469
483
|
# === CRUD ===
|
|
470
484
|
|
|
471
|
-
def add(self, obj: Union[T, dict[str, Any]]):
|
|
472
|
-
"""Add or update struct in collection.
|
|
485
|
+
def add(self, obj: Union[T, dict[str, Any]]) -> T:
|
|
486
|
+
"""Add or update struct in collection.
|
|
487
|
+
|
|
488
|
+
Stores a validated copy (never `obj` itself) and returns it. Raises before
|
|
489
|
+
touching the collection when validation or a unique constraint fails."""
|
|
473
490
|
# Pre-process dict to inject local class type tag if needed
|
|
474
491
|
if isinstance(obj, dict) and "_" in obj and isinstance(obj["_"], dict):
|
|
475
492
|
local = obj["_"]
|
|
@@ -487,15 +504,13 @@ class Collection(Generic[T]):
|
|
|
487
504
|
|
|
488
505
|
self._check_unique(obj)
|
|
489
506
|
|
|
490
|
-
if old:
|
|
491
|
-
self._index_remove(old)
|
|
492
|
-
|
|
493
507
|
self._entities[uid] = obj
|
|
494
508
|
self._index_add(obj)
|
|
495
509
|
self._dirty.add(uid)
|
|
496
510
|
_register(obj.uuid, self)
|
|
497
511
|
self._client.schedule_flush()
|
|
498
512
|
self._fire_change(old, obj, local=True)
|
|
513
|
+
return obj
|
|
499
514
|
|
|
500
515
|
def get(self, uuid: str) -> Optional[T]:
|
|
501
516
|
return self._entities.get(uuid)
|
|
@@ -506,7 +521,7 @@ class Collection(Generic[T]):
|
|
|
506
521
|
if not item:
|
|
507
522
|
return False
|
|
508
523
|
|
|
509
|
-
self._index_remove(
|
|
524
|
+
self._index_remove(uid)
|
|
510
525
|
_unregister(item.uuid)
|
|
511
526
|
del self._entities[uid]
|
|
512
527
|
self._dirty.discard(uid)
|
|
@@ -528,6 +543,7 @@ class Collection(Generic[T]):
|
|
|
528
543
|
idx.clear()
|
|
529
544
|
for uq in self._uniques.values():
|
|
530
545
|
uq.clear()
|
|
546
|
+
self._indexed.clear()
|
|
531
547
|
|
|
532
548
|
# === Filtering ===
|
|
533
549
|
|
|
@@ -730,7 +746,7 @@ class Collection(Generic[T]):
|
|
|
730
746
|
if e.operation == "DEL":
|
|
731
747
|
item = self._entities.get(uid)
|
|
732
748
|
if item:
|
|
733
|
-
self._index_remove(
|
|
749
|
+
self._index_remove(uid)
|
|
734
750
|
_unregister(item.uuid)
|
|
735
751
|
del self._entities[uid]
|
|
736
752
|
self._metadata.pop(uid, None)
|
|
@@ -760,8 +776,6 @@ class Collection(Generic[T]):
|
|
|
760
776
|
continue
|
|
761
777
|
|
|
762
778
|
self._metadata[uid] = w.m
|
|
763
|
-
if old:
|
|
764
|
-
self._index_remove(old)
|
|
765
779
|
self._entities[uid] = new
|
|
766
780
|
self._index_add(new)
|
|
767
781
|
_register(new.uuid, self)
|
|
@@ -802,7 +816,6 @@ class Collection(Generic[T]):
|
|
|
802
816
|
if old_data == new_data:
|
|
803
817
|
continue
|
|
804
818
|
|
|
805
|
-
self._index_remove(old)
|
|
806
819
|
if kind == "state":
|
|
807
820
|
new = self._from_parts(e.key, self._to_config(old), new_data, self._to_local(old))
|
|
808
821
|
else:
|
|
@@ -91,7 +91,7 @@ class BrainlessBucket(Struct, kw_only=True, tag=True, tag_field="TYPE"):
|
|
|
91
91
|
def save(self) -> None:
|
|
92
92
|
"""Mark entity as dirty in its collection."""
|
|
93
93
|
collection = _registry.get(self._uuid)
|
|
94
|
-
if collection:
|
|
94
|
+
if collection is not None:
|
|
95
95
|
collection.add(self)
|
|
96
96
|
|
|
97
97
|
@classmethod
|
|
@@ -146,17 +146,31 @@ class BrainlessBucket(Struct, kw_only=True, tag=True, tag_field="TYPE"):
|
|
|
146
146
|
return cls(**kwargs)
|
|
147
147
|
|
|
148
148
|
def update(self, data: dict) -> "BrainlessBucket":
|
|
149
|
-
"""
|
|
149
|
+
"""Apply `data` atomically, validate, save and return self.
|
|
150
|
+
|
|
151
|
+
Keys that are not struct fields are ignored; a dict given for a field
|
|
152
|
+
holding a nested struct is merged into it. The merged result is fully
|
|
153
|
+
converted (lists of dicts become structs, ...) and saved before `self`
|
|
154
|
+
is touched: when validation or the collection (UniqueConstraintError)
|
|
155
|
+
rejects it, `self` and the collection are left unchanged. On success
|
|
156
|
+
`self` holds the converted values; like after save(), the collection
|
|
157
|
+
stores its own validated copy, so re-fetch to get the stored object.
|
|
158
|
+
"""
|
|
159
|
+
fields = self.__struct_fields__
|
|
160
|
+
merged = to_builtins(self)
|
|
150
161
|
for key, value in data.items():
|
|
151
|
-
if not
|
|
162
|
+
if key not in fields:
|
|
152
163
|
continue
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
164
|
+
current = getattr(self, key)
|
|
165
|
+
if isinstance(value, dict) and hasattr(current, "__struct_fields__"):
|
|
166
|
+
value = {**to_builtins(current), **value}
|
|
167
|
+
merged[key] = to_builtins(value)
|
|
168
|
+
new = convert(merged, type(self))
|
|
169
|
+
collection = _registry.get(self._uuid)
|
|
170
|
+
if collection is not None:
|
|
171
|
+
collection.add(new)
|
|
172
|
+
for f in fields:
|
|
173
|
+
setattr(self, f, getattr(new, f))
|
|
160
174
|
return self
|
|
161
175
|
|
|
162
176
|
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""Atomic update() and index bookkeeping.
|
|
2
|
+
|
|
3
|
+
Production incident (Uniqa, ucs-server device sync): `update()` setattr'd the
|
|
4
|
+
incoming values on the live entity before `save()` validated them. A unique
|
|
5
|
+
clash made `save()` raise, leaving the live Device with `lines` as plain dicts
|
|
6
|
+
(every `line.line_id` predicate then crashed) and the indexes keyed by values
|
|
7
|
+
the entity no longer held. A second defect made it worse: `Collection.add()`
|
|
8
|
+
removed index entries using the *old* object, which was the same live object
|
|
9
|
+
the caller had just mutated, so the previously indexed values were never
|
|
10
|
+
removed and later swaps clashed on values nobody held any more.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from typing import Annotated, Optional, Union
|
|
14
|
+
|
|
15
|
+
import msgspec
|
|
16
|
+
import pytest
|
|
17
|
+
from msgspec import Meta
|
|
18
|
+
|
|
19
|
+
from brainlessdb import BrainlessBucket, BrainlessDBFeat, BrainlessStruct, UniqueConstraintError
|
|
20
|
+
from tests.conftest import UniqueUserV1
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class LineExtensionV1(BrainlessStruct):
|
|
24
|
+
line_id: int = 0
|
|
25
|
+
extension_id: int = 0
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class LineTrunkV1(BrainlessStruct):
|
|
29
|
+
line_id: int = 0
|
|
30
|
+
trunk_id: int = 0
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class SettingsV1(BrainlessStruct):
|
|
34
|
+
codec: str = "opus"
|
|
35
|
+
volume: int = 5
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class GadgetV1(BrainlessBucket):
|
|
39
|
+
@staticmethod
|
|
40
|
+
def allows_app(app: str) -> bool:
|
|
41
|
+
return True
|
|
42
|
+
|
|
43
|
+
id: int = 0
|
|
44
|
+
mac: Annotated[Optional[str], Meta(extra={"brainlessdb_flags": BrainlessDBFeat.UNIQUE})] = None
|
|
45
|
+
group: Annotated[int, Meta(extra={"brainlessdb_flags": BrainlessDBFeat.INDEX})] = 0
|
|
46
|
+
lines: list[Union[LineExtensionV1, LineTrunkV1]] = []
|
|
47
|
+
settings: Optional[SettingsV1] = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@pytest.fixture
|
|
51
|
+
async def gadgets(db):
|
|
52
|
+
coll = db.collection(GadgetV1)
|
|
53
|
+
await db.start()
|
|
54
|
+
return coll
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _snapshot(coll) -> tuple:
|
|
58
|
+
return (
|
|
59
|
+
{f: {v: set(u) for v, u in idx.items()} for f, idx in coll._indexes.items()},
|
|
60
|
+
{f: dict(uq) for f, uq in coll._uniques.items()},
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class TestUpdateAtomic:
|
|
65
|
+
@pytest.mark.asyncio
|
|
66
|
+
async def test_rejected_by_unique_clash_leaves_entity_and_indexes_unchanged(self, gadgets):
|
|
67
|
+
gadgets.add(GadgetV1(id=1, mac="aa", group=1, lines=[LineExtensionV1(line_id=1, extension_id=10)]))
|
|
68
|
+
gadgets.add(GadgetV1(id=2, mac="bb", group=1))
|
|
69
|
+
live = gadgets.find(id=2)
|
|
70
|
+
before = msgspec.to_builtins(live)
|
|
71
|
+
indexes = _snapshot(gadgets)
|
|
72
|
+
|
|
73
|
+
with pytest.raises(UniqueConstraintError):
|
|
74
|
+
live.update(
|
|
75
|
+
{
|
|
76
|
+
"mac": "aa",
|
|
77
|
+
"group": 7,
|
|
78
|
+
"lines": [{"TYPE": "LineExtensionV1", "line_id": 5, "extension_id": 50}],
|
|
79
|
+
"settings": {"volume": 9},
|
|
80
|
+
}
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
assert gadgets.find(id=2) is live
|
|
84
|
+
assert msgspec.to_builtins(live) == before
|
|
85
|
+
assert live.lines == []
|
|
86
|
+
assert live.settings is None
|
|
87
|
+
assert _snapshot(gadgets) == indexes
|
|
88
|
+
|
|
89
|
+
@pytest.mark.asyncio
|
|
90
|
+
async def test_rejected_by_validation_leaves_entity_unchanged(self, gadgets):
|
|
91
|
+
gadgets.add(GadgetV1(id=1, mac="aa", lines=[LineTrunkV1(line_id=1, trunk_id=3)]))
|
|
92
|
+
live = gadgets.find(id=1)
|
|
93
|
+
before = msgspec.to_builtins(live)
|
|
94
|
+
|
|
95
|
+
with pytest.raises(msgspec.ValidationError):
|
|
96
|
+
live.update({"id": 9, "lines": [{"TYPE": "LineExtensionV1", "line_id": "not-an-int"}]})
|
|
97
|
+
|
|
98
|
+
assert msgspec.to_builtins(live) == before
|
|
99
|
+
assert isinstance(live.lines[0], LineTrunkV1)
|
|
100
|
+
|
|
101
|
+
@pytest.mark.asyncio
|
|
102
|
+
async def test_list_of_dicts_becomes_structs(self, gadgets):
|
|
103
|
+
gadgets.add(GadgetV1(id=1, mac="aa"))
|
|
104
|
+
live = gadgets.find(id=1)
|
|
105
|
+
|
|
106
|
+
result = live.update(
|
|
107
|
+
{
|
|
108
|
+
"lines": [
|
|
109
|
+
{"TYPE": "LineExtensionV1", "line_id": 1, "extension_id": 10},
|
|
110
|
+
{"TYPE": "LineTrunkV1", "line_id": 2, "trunk_id": 20},
|
|
111
|
+
]
|
|
112
|
+
}
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
assert result is live
|
|
116
|
+
for obj in (live, gadgets.find(id=1)):
|
|
117
|
+
assert [type(line) for line in obj.lines] == [LineExtensionV1, LineTrunkV1]
|
|
118
|
+
assert [line.line_id for line in obj.lines] == [1, 2]
|
|
119
|
+
|
|
120
|
+
@pytest.mark.asyncio
|
|
121
|
+
async def test_dict_merges_into_nested_struct_and_unknown_keys_ignored(self, gadgets):
|
|
122
|
+
gadgets.add(GadgetV1(id=1, mac="aa", settings=SettingsV1(codec="pcma", volume=3)))
|
|
123
|
+
live = gadgets.find(id=1)
|
|
124
|
+
|
|
125
|
+
live.update({"settings": {"volume": 8}, "nope": 1, "uuid": "ignored", "save": None})
|
|
126
|
+
|
|
127
|
+
stored = gadgets.find(id=1)
|
|
128
|
+
assert stored.settings == SettingsV1(codec="pcma", volume=8)
|
|
129
|
+
assert live.settings == SettingsV1(codec="pcma", volume=8)
|
|
130
|
+
|
|
131
|
+
@pytest.mark.asyncio
|
|
132
|
+
async def test_dict_for_empty_nested_struct_is_converted(self, gadgets):
|
|
133
|
+
gadgets.add(GadgetV1(id=1, mac="aa"))
|
|
134
|
+
live = gadgets.find(id=1)
|
|
135
|
+
|
|
136
|
+
live.update({"settings": {"volume": 4}})
|
|
137
|
+
|
|
138
|
+
assert live.settings == SettingsV1(volume=4)
|
|
139
|
+
assert gadgets.find(id=1).settings == SettingsV1(volume=4)
|
|
140
|
+
|
|
141
|
+
@pytest.mark.asyncio
|
|
142
|
+
async def test_update_detached_entity_validates_without_collection(self):
|
|
143
|
+
g = GadgetV1(id=1, lines=[])
|
|
144
|
+
with pytest.raises(msgspec.ValidationError):
|
|
145
|
+
g.update({"id": "x"})
|
|
146
|
+
assert g.id == 1
|
|
147
|
+
g.update({"lines": [{"TYPE": "LineTrunkV1", "line_id": 4}]})
|
|
148
|
+
assert isinstance(g.lines[0], LineTrunkV1)
|
|
149
|
+
|
|
150
|
+
@pytest.mark.asyncio
|
|
151
|
+
async def test_swap_unique_values_via_temporary(self, gadgets):
|
|
152
|
+
gadgets.add(GadgetV1(id=1, mac="aa"))
|
|
153
|
+
gadgets.add(GadgetV1(id=2, mac="bb"))
|
|
154
|
+
|
|
155
|
+
gadgets.find(id=1).update({"mac": "tmp"})
|
|
156
|
+
gadgets.find(id=2).update({"mac": "aa"})
|
|
157
|
+
gadgets.find(id=1).update({"mac": "bb"})
|
|
158
|
+
|
|
159
|
+
assert gadgets.find(id=1).mac == "bb"
|
|
160
|
+
assert gadgets.find(id=2).mac == "aa"
|
|
161
|
+
one, two = str(gadgets.find(id=1).uuid), str(gadgets.find(id=2).uuid)
|
|
162
|
+
assert gadgets._uniques["mac"] == {"bb": one, "aa": two}
|
|
163
|
+
|
|
164
|
+
@pytest.mark.asyncio
|
|
165
|
+
async def test_swap_unique_values_via_save(self, gadgets):
|
|
166
|
+
gadgets.add(GadgetV1(id=1, mac="aa"))
|
|
167
|
+
gadgets.add(GadgetV1(id=2, mac="bb"))
|
|
168
|
+
|
|
169
|
+
g1 = gadgets.find(id=1)
|
|
170
|
+
g1.mac = "tmp"
|
|
171
|
+
g1.save()
|
|
172
|
+
g2 = gadgets.find(id=2)
|
|
173
|
+
g2.mac = "aa"
|
|
174
|
+
g2.save()
|
|
175
|
+
g1 = gadgets.find(id=1)
|
|
176
|
+
g1.mac = "bb"
|
|
177
|
+
g1.save()
|
|
178
|
+
|
|
179
|
+
one, two = str(gadgets.find(id=1).uuid), str(gadgets.find(id=2).uuid)
|
|
180
|
+
assert gadgets._uniques["mac"] == {"bb": one, "aa": two}
|
|
181
|
+
|
|
182
|
+
@pytest.mark.asyncio
|
|
183
|
+
async def test_index_moves_on_update(self, gadgets):
|
|
184
|
+
gadgets.add(GadgetV1(id=1, mac="aa", group=1))
|
|
185
|
+
uid = str(gadgets.find(id=1).uuid)
|
|
186
|
+
|
|
187
|
+
gadgets.find(id=1).update({"group": 2})
|
|
188
|
+
|
|
189
|
+
assert gadgets._indexes["group"] == {2: {uid}}
|
|
190
|
+
assert gadgets.filter(group=1) == []
|
|
191
|
+
assert [g.id for g in gadgets.filter(group=2)] == [1]
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class TestInPlaceMutationIndexes:
|
|
195
|
+
@pytest.mark.asyncio
|
|
196
|
+
async def test_in_place_mutation_then_unique_change_leaves_no_stale_entries(self, unique_users):
|
|
197
|
+
unique_users.add(UniqueUserV1(id=1, email="a@x"))
|
|
198
|
+
uid = str(unique_users.find(id=1).uuid)
|
|
199
|
+
|
|
200
|
+
live = unique_users.find(id=1)
|
|
201
|
+
live.email = "b@x"
|
|
202
|
+
live.save()
|
|
203
|
+
live = unique_users.find(id=1)
|
|
204
|
+
live.email = "c@x"
|
|
205
|
+
live.save()
|
|
206
|
+
|
|
207
|
+
assert unique_users._uniques["email"] == {"c@x": uid}
|
|
208
|
+
# both previous values are free again
|
|
209
|
+
unique_users.add(UniqueUserV1(id=2, email="a@x"))
|
|
210
|
+
unique_users.add(UniqueUserV1(id=3, email="b@x"))
|
|
211
|
+
|
|
212
|
+
@pytest.mark.asyncio
|
|
213
|
+
async def test_own_unique_value_change_does_not_clash_with_itself(self, unique_users):
|
|
214
|
+
unique_users.add(UniqueUserV1(id=1, email="a@x"))
|
|
215
|
+
live = unique_users.find(id=1)
|
|
216
|
+
live.email = "b@x"
|
|
217
|
+
live.save()
|
|
218
|
+
live = unique_users.find(id=1)
|
|
219
|
+
live.email = "a@x"
|
|
220
|
+
live.save()
|
|
221
|
+
assert unique_users.find(id=1).email == "a@x"
|
|
222
|
+
assert unique_users._uniques["email"] == {"a@x": str(live.uuid)}
|
|
223
|
+
|
|
224
|
+
@pytest.mark.asyncio
|
|
225
|
+
async def test_delete_after_in_place_mutation_frees_indexed_value(self, unique_users):
|
|
226
|
+
unique_users.add(UniqueUserV1(id=1, email="a@x"))
|
|
227
|
+
live = unique_users.find(id=1)
|
|
228
|
+
live.email = "b@x" # mutated, never saved
|
|
229
|
+
unique_users.delete(live)
|
|
230
|
+
|
|
231
|
+
assert unique_users._uniques["email"] == {}
|
|
232
|
+
unique_users.add(UniqueUserV1(id=2, email="a@x"))
|
|
233
|
+
|
|
234
|
+
@pytest.mark.asyncio
|
|
235
|
+
async def test_rejected_save_keeps_index_on_stored_values(self, unique_users):
|
|
236
|
+
unique_users.add(UniqueUserV1(id=1, email="a@x"))
|
|
237
|
+
unique_users.add(UniqueUserV1(id=2, email="b@x"))
|
|
238
|
+
live = unique_users.find(id=2)
|
|
239
|
+
live.email = "a@x"
|
|
240
|
+
with pytest.raises(UniqueConstraintError):
|
|
241
|
+
live.save()
|
|
242
|
+
one = str(unique_users.find(id=1).uuid)
|
|
243
|
+
assert unique_users._uniques["email"] == {"a@x": one, "b@x": str(live.uuid)}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|