brainlessdb 0.17.2__tar.gz → 0.17.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. brainlessdb-0.17.4/.python-version +1 -0
  2. {brainlessdb-0.17.2 → brainlessdb-0.17.4}/CHANGELOG.md +16 -0
  3. {brainlessdb-0.17.2 → brainlessdb-0.17.4}/PKG-INFO +2 -2
  4. {brainlessdb-0.17.2 → brainlessdb-0.17.4}/brainlessdb/__init__.py +1 -1
  5. {brainlessdb-0.17.2 → brainlessdb-0.17.4}/brainlessdb/collection.py +34 -21
  6. {brainlessdb-0.17.2 → brainlessdb-0.17.4}/brainlessdb/registry.py +7 -0
  7. {brainlessdb-0.17.2 → brainlessdb-0.17.4}/brainlessdb/struct.py +28 -11
  8. {brainlessdb-0.17.2 → brainlessdb-0.17.4}/pyproject.toml +1 -1
  9. {brainlessdb-0.17.2 → brainlessdb-0.17.4}/tests/test_registry.py +15 -0
  10. brainlessdb-0.17.4/tests/test_update_atomic.py +243 -0
  11. {brainlessdb-0.17.2 → brainlessdb-0.17.4}/uv.lock +23 -23
  12. brainlessdb-0.17.2/.python-version +0 -1
  13. {brainlessdb-0.17.2 → brainlessdb-0.17.4}/.gitignore +0 -0
  14. {brainlessdb-0.17.2 → brainlessdb-0.17.4}/README.md +0 -0
  15. {brainlessdb-0.17.2 → brainlessdb-0.17.4}/brainlessdb/bucket.py +0 -0
  16. {brainlessdb-0.17.2 → brainlessdb-0.17.4}/brainlessdb/client.py +0 -0
  17. {brainlessdb-0.17.2 → brainlessdb-0.17.4}/brainlessdb/fields.py +0 -0
  18. {brainlessdb-0.17.2 → brainlessdb-0.17.4}/cliff.toml +0 -0
  19. {brainlessdb-0.17.2 → brainlessdb-0.17.4}/code_style_guide.md +0 -0
  20. {brainlessdb-0.17.2 → brainlessdb-0.17.4}/justfile +0 -0
  21. {brainlessdb-0.17.2 → brainlessdb-0.17.4}/test_results.txt +0 -0
  22. {brainlessdb-0.17.2 → brainlessdb-0.17.4}/tests/__init__.py +0 -0
  23. {brainlessdb-0.17.2 → brainlessdb-0.17.4}/tests/conftest.py +0 -0
  24. {brainlessdb-0.17.2 → brainlessdb-0.17.4}/tests/mock_nats.py +0 -0
  25. {brainlessdb-0.17.2 → brainlessdb-0.17.4}/tests/test_bucket.py +0 -0
  26. {brainlessdb-0.17.2 → brainlessdb-0.17.4}/tests/test_client.py +0 -0
  27. {brainlessdb-0.17.2 → brainlessdb-0.17.4}/tests/test_collection.py +0 -0
  28. {brainlessdb-0.17.2 → brainlessdb-0.17.4}/tests/test_dead_letter.py +0 -0
  29. {brainlessdb-0.17.2 → brainlessdb-0.17.4}/tests/test_deserialization.py +0 -0
  30. {brainlessdb-0.17.2 → brainlessdb-0.17.4}/tests/test_duplicates.py +0 -0
  31. {brainlessdb-0.17.2 → brainlessdb-0.17.4}/tests/test_fields.py +0 -0
  32. {brainlessdb-0.17.2 → brainlessdb-0.17.4}/tests/test_flush_errors.py +0 -0
  33. {brainlessdb-0.17.2 → brainlessdb-0.17.4}/tests/test_load_errors.py +0 -0
  34. {brainlessdb-0.17.2 → brainlessdb-0.17.4}/tests/test_sync.py +0 -0
  35. {brainlessdb-0.17.2 → brainlessdb-0.17.4}/tests/test_thread_safety.py +0 -0
  36. {brainlessdb-0.17.2 → brainlessdb-0.17.4}/tests/test_version.py +0 -0
@@ -0,0 +1 @@
1
+ 3.14
@@ -3,6 +3,22 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
5
 
6
+ ## [0.17.4] - 2026-11-03
7
+
8
+ ### 🐛 Bug Fixes
9
+
10
+ - Validate registry on save so invalid values are never flushed
11
+ - Plain attribute assignment is not type-checked by msgspec, so e.g. None in
12
+ a non-Optional field was flushed and every reader dead-lettered the entry.
13
+
14
+
15
+ ## [0.17.3] - 2026-10-21
16
+
17
+ ### 🐛 Bug Fixes
18
+
19
+ - Make update() atomic and unindex by recorded values, not mutated attributes
20
+
21
+
6
22
  ## [0.17.2] - 2026-08-22
7
23
 
8
24
  ### 🐛 Bug Fixes
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: brainlessdb
3
- Version: 0.17.2
3
+ Version: 0.17.4
4
4
  Summary: Typed collections backed by NATS JetStream KV
5
5
  Author-email: "INSOFT s.r.o." <helpdesk@insoft.cz>
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  """Brainless DB - Typed collections backed by NATS JetStream KV."""
2
2
 
3
- __version__ = "0.17.2"
3
+ __version__ = "0.17.4"
4
4
 
5
5
  from typing import Any, Optional, TypeVar
6
6
 
@@ -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, obj: T) -> None:
253
- uid = str(obj.uuid)
254
- for field in self._analysis.indexed_fields:
255
- val = getattr(obj, field, None)
256
- if val in self._indexes[field]:
257
- self._indexes[field][val].discard(uid)
258
- if not self._indexes[field][val]:
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 self._analysis.unique_fields:
261
- val = getattr(obj, field, None)
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(obj)
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(item)
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(item)
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:
@@ -81,6 +81,13 @@ class Registry(Generic[T]):
81
81
  # === Dirty / save ===
82
82
 
83
83
  def mark_dirty(self) -> None:
84
+ """Validate the instance and schedule it for flush.
85
+
86
+ Plain attribute assignment is not type-checked by msgspec, so an invalid
87
+ value (e.g. None for a non-Optional field) is caught here and raises
88
+ msgspec.ValidationError before it can be flushed and break every reader.
89
+ """
90
+ self._apply(self._to_data())
84
91
  self._dirty = True
85
92
  self._client.schedule_flush()
86
93
 
@@ -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
- """Update fields from dict, supports nested structs."""
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 hasattr(self, key):
162
+ if key not in fields:
152
163
  continue
153
- if isinstance(value, dict):
154
- current = getattr(self, key)
155
- if current is not None and hasattr(current, "__struct_fields__"):
156
- merged = {**to_builtins(current), **value}
157
- value = convert(merged, type(current))
158
- setattr(self, key, value)
159
- self.save()
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
 
@@ -191,7 +205,10 @@ class BrainlessRegistry(Struct, kw_only=True, tag=True, tag_field="TYPE", dict=T
191
205
  return uuid5(_REGISTRY_NS, type(self).__key__)
192
206
 
193
207
  def save(self) -> None:
194
- """Mark this registry entry dirty in its Registry."""
208
+ """Validate and mark this registry entry dirty in its Registry.
209
+
210
+ Raises msgspec.ValidationError when a field holds a value of wrong type.
211
+ """
195
212
  owner_ref = getattr(self, "_owner", None)
196
213
  owner = owner_ref() if owner_ref is not None else None
197
214
  if owner is not None:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "brainlessdb"
3
- version = "0.17.2"
3
+ version = "0.17.4"
4
4
  description = "Typed collections backed by NATS JetStream KV"
5
5
  authors = [
6
6
  {name = "INSOFT s.r.o.", email = "helpdesk@insoft.cz"}
@@ -62,6 +62,21 @@ class TestRegistryBasics:
62
62
  class TestRegistryPersistence:
63
63
  """Save / reload through the shared bucket."""
64
64
 
65
+ @pytest.mark.asyncio
66
+ async def test_save_rejects_invalid_value(self):
67
+ nats = MockNats()
68
+ db = BrainlessDB(nats, namespace="loc1", flush_interval=0)
69
+ sip = db.registry(SipConfigV1)
70
+ await db.start()
71
+
72
+ sip.host = None # not Optional
73
+ with pytest.raises(msgspec.ValidationError):
74
+ sip.save()
75
+ assert await db.flush() == 0
76
+ assert SipConfigV1.storage_key() not in await nats.jetstream()._buckets[REGISTRY_BUCKET].keys()
77
+
78
+ await db.stop()
79
+
65
80
  @pytest.mark.asyncio
66
81
  async def test_save_and_reload(self):
67
82
  nats = MockNats()
@@ -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)}
@@ -1,5 +1,5 @@
1
1
  version = 1
2
- revision = 3
2
+ revision = 5
3
3
  requires-python = ">=3.9"
4
4
  resolution-markers = [
5
5
  "python_full_version >= '3.10'",
@@ -17,7 +17,7 @@ wheels = [
17
17
 
18
18
  [[package]]
19
19
  name = "brainlessdb"
20
- version = "0.17.1"
20
+ version = "0.17.3"
21
21
  source = { editable = "." }
22
22
  dependencies = [
23
23
  { name = "msgspec" },
@@ -60,7 +60,7 @@ name = "exceptiongroup"
60
60
  version = "1.3.1"
61
61
  source = { registry = "https://pypi.org/simple" }
62
62
  dependencies = [
63
- { name = "typing-extensions", marker = "python_full_version < '3.13'" },
63
+ { name = "typing-extensions" },
64
64
  ]
65
65
  sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
66
66
  wheels = [
@@ -196,13 +196,13 @@ resolution-markers = [
196
196
  "python_full_version < '3.10'",
197
197
  ]
198
198
  dependencies = [
199
- { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" },
200
- { name = "exceptiongroup", marker = "python_full_version < '3.10'" },
201
- { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
202
- { name = "packaging", marker = "python_full_version < '3.10'" },
203
- { name = "pluggy", marker = "python_full_version < '3.10'" },
204
- { name = "pygments", marker = "python_full_version < '3.10'" },
205
- { name = "tomli", marker = "python_full_version < '3.10'" },
199
+ { name = "colorama", marker = "sys_platform == 'win32'" },
200
+ { name = "exceptiongroup" },
201
+ { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" } },
202
+ { name = "packaging" },
203
+ { name = "pluggy" },
204
+ { name = "pygments" },
205
+ { name = "tomli" },
206
206
  ]
207
207
  sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" }
208
208
  wheels = [
@@ -217,13 +217,13 @@ resolution-markers = [
217
217
  "python_full_version >= '3.10'",
218
218
  ]
219
219
  dependencies = [
220
- { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" },
221
- { name = "exceptiongroup", marker = "python_full_version == '3.10.*'" },
222
- { name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
223
- { name = "packaging", marker = "python_full_version >= '3.10'" },
224
- { name = "pluggy", marker = "python_full_version >= '3.10'" },
225
- { name = "pygments", marker = "python_full_version >= '3.10'" },
226
- { name = "tomli", marker = "python_full_version == '3.10.*'" },
220
+ { name = "colorama", marker = "sys_platform == 'win32'" },
221
+ { name = "exceptiongroup", marker = "python_full_version < '3.11'" },
222
+ { name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" } },
223
+ { name = "packaging" },
224
+ { name = "pluggy" },
225
+ { name = "pygments" },
226
+ { name = "tomli", marker = "python_full_version < '3.11'" },
227
227
  ]
228
228
  sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
229
229
  wheels = [
@@ -238,9 +238,9 @@ resolution-markers = [
238
238
  "python_full_version < '3.10'",
239
239
  ]
240
240
  dependencies = [
241
- { name = "backports-asyncio-runner", marker = "python_full_version < '3.10'" },
242
- { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
243
- { name = "typing-extensions", marker = "python_full_version < '3.10'" },
241
+ { name = "backports-asyncio-runner" },
242
+ { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" } },
243
+ { name = "typing-extensions" },
244
244
  ]
245
245
  sdist = { url = "https://files.pythonhosted.org/packages/42/86/9e3c5f48f7b7b638b216e4b9e645f54d199d7abbbab7a64a13b4e12ba10f/pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57", size = 50119, upload-time = "2025-09-12T07:33:53.816Z" }
246
246
  wheels = [
@@ -255,9 +255,9 @@ resolution-markers = [
255
255
  "python_full_version >= '3.10'",
256
256
  ]
257
257
  dependencies = [
258
- { name = "backports-asyncio-runner", marker = "python_full_version == '3.10.*'" },
259
- { name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
260
- { name = "typing-extensions", marker = "python_full_version >= '3.10' and python_full_version < '3.13'" },
258
+ { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" },
259
+ { name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" } },
260
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
261
261
  ]
262
262
  sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" }
263
263
  wheels = [
@@ -1 +0,0 @@
1
- 3.9
File without changes
File without changes
File without changes
File without changes