Skip to content

Commit 3ab38bc

Browse files
jeandetclaude
andcommitted
Fix event/catalogue membership ORM bugs and bump 0.5.0 → 0.5.1
Two bugs surfaced when tscat_gui (and SciQLop, via the tscat_gui driver) moved to 0.5.0 — both visible as ValueError raised from Backend.{add,remove}_events_to_catalogue: * remove_events_from_catalogue compared membership by Python id() instead of the SQL primary key. Any caller that resolved an Event via get_events(uuid=...) (the new fast path that returns wrappers backed by _LazyBackendEntity) ended up with a different Python instance from the one stored in catalogue.events and got "Event is not in catalogue." Fixed by comparing on e.id, matching add_events_to_catalogue. * After event.remove(permanently=True), catalogue.events kept the deleted ORM Event in the in-memory collection. A subsequent add_events_to_catalogue() with a freshly-created Event reusing the same UUID then hit the existing-id check and was rejected, even though tscat.get_events(catalogue) reported the catalogue empty. Backend.remove now detaches an Event from every catalogue.events collection that holds it before session.delete(). The fast-path query also let _LazyBackendEntity proxies leak into SQLAlchemy relationships (catalogue.events.extend), which the membership fix would still trip over later. Added a _resolve_be helper in base.py and used it at the wrapper→backend boundary for add/remove_events_to_catalogue and _BackendBasedEntity.remove so SQLAlchemy only ever sees identity-mapped ORM entities. Two regression tests cover both scenarios. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 148ee03 commit 3ab38bc

5 files changed

Lines changed: 70 additions & 11 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ requires = ["hatchling"]
44

55
[project]
66
name = "tscat"
7-
version = "0.5.0"
7+
version = "0.5.1"
88
description = "A library which stores, loads and filters time-series-events and catalogues."
99
keywords = ["satellite", "plasma-physics", "nasa-data", "amda", "cdpp", "CDF"]
1010
authors = [

tests/test_event.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,3 +206,41 @@ def test_remove_events_from_catalogue_unknown_uuid(self):
206206
e = create_event(t1, t2, "Patrick")
207207
with self.assertRaises(ValueError):
208208
tscat.remove_events_from_catalogue("nonexistent-uuid", [e])
209+
210+
def test_remove_event_fetched_via_predicate_then_member_check(self):
211+
# Regression: remove_events_from_catalogue compared by Python id() which
212+
# broke whenever the caller looked the event up via get_events(uuid=...)
213+
# (fast path → _LazyBackendEntity proxy, different object than the one
214+
# already in catalogue.events).
215+
t1 = dt.datetime.now()
216+
t2 = t1 + dt.timedelta(days=1)
217+
e = create_event(t1, t2, "Patrick")
218+
c = tscat.create_catalogue("c", "Patrick")
219+
tscat.add_events_to_catalogue(c, [e])
220+
221+
from tscat.filtering import UUID
222+
e_again = get_events(UUID(e.uuid))[0]
223+
tscat.remove_events_from_catalogue(c, [e_again])
224+
225+
events, _ = tscat.get_events(c)
226+
self.assertEqual(events, [])
227+
228+
def test_recreate_event_with_same_uuid_after_permanent_delete(self):
229+
# Regression: catalogue.events kept the deleted ORM Event, so
230+
# add_events_to_catalogue rejected a freshly-created event with the
231+
# same UUID even though tscat.get_events(catalogue) reported it empty.
232+
t1 = dt.datetime.now()
233+
t2 = t1 + dt.timedelta(days=1)
234+
e = create_event(t1, t2, "Patrick")
235+
c = tscat.create_catalogue("c", "Patrick")
236+
tscat.add_events_to_catalogue(c, [e])
237+
238+
original_uuid = e.uuid
239+
e.remove(permanently=True)
240+
241+
e2 = create_event(t1, t2, "Patrick", uuid=original_uuid)
242+
tscat.add_events_to_catalogue(c, [e2])
243+
244+
events, _ = tscat.get_events(c)
245+
self.assertEqual(len(events), 1)
246+
self.assertEqual(events[0].uuid, original_uuid)

tscat/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
__author__ = """Patrick Boettcher"""
44
__email__ = 'p@yai.se'
5-
__version__ = '0.5.0'
5+
__version__ = '0.5.1'
66

77
from .base import (create_event, create_catalogue,
88
add_events_to_catalogue, remove_events_from_catalogue,

tscat/base.py

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,20 @@ def __setattr__(self, name, value):
3939
setattr(self._resolve(), name, value)
4040

4141

42+
def _resolve_be(be):
43+
"""Resolve a _LazyBackendEntity proxy to the real ORM entity.
44+
45+
The fast-path query (get_events_raw) wraps rows in _LazyBackendEntity to
46+
defer ORM hydration. Code paths that hand the backend entity to SQLAlchemy
47+
relationships (catalogue.events.{extend,remove}, session.delete) need the
48+
real, identity-mapped ORM object — passing a proxy stores the proxy in the
49+
relationship, breaking later membership checks.
50+
"""
51+
if isinstance(be, _LazyBackendEntity):
52+
return be._resolve()
53+
return be
54+
55+
4256
def backend() -> orm_sqlalchemy.Backend:
4357
global _backend
4458
if not _backend: # pragma: no cover
@@ -91,13 +105,13 @@ def create_catalogue(self, *args: Any, **kwargs: Any) -> '_Catalogue':
91105

92106
@staticmethod
93107
def add_events_to_catalogue(catalogue: '_Catalogue', events: Union['_Event', List['_Event']]):
94-
backend().add_events_to_catalogue(catalogue._backend_entity,
95-
[event._backend_entity for event in _listify(events)])
108+
backend().add_events_to_catalogue(_resolve_be(catalogue._backend_entity),
109+
[_resolve_be(event._backend_entity) for event in _listify(events)])
96110

97111
@staticmethod
98112
def remove_events_from_catalogue(catalogue: '_Catalogue', events: Union['_Event', List['_Event']]):
99-
backend().remove_events_from_catalogue(catalogue._backend_entity,
100-
[event._backend_entity for event in _listify(events)])
113+
backend().remove_events_from_catalogue(_resolve_be(catalogue._backend_entity),
114+
[_resolve_be(event._backend_entity) for event in _listify(events)])
101115

102116

103117

@@ -161,7 +175,7 @@ def __eq__(self, o):
161175
def remove(self, permanently: bool = False) -> None:
162176
self._removed = True
163177

164-
backend().remove(self._backend_entity, permanently=permanently)
178+
backend().remove(_resolve_be(self._backend_entity), permanently=permanently)
165179
if permanently:
166180
del self._backend_entity
167181

tscat/orm_sqlalchemy/__init__.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -229,12 +229,12 @@ def add_events_to_catalogue(self, catalogue: orm.Catalogue, events: List[orm.Eve
229229
catalogue.events.extend(events)
230230

231231
def remove_events_from_catalogue(self, catalogue: orm.Catalogue, events: List[orm.Event]) -> None:
232-
existing = {id(e) for e in catalogue.events}
232+
existing_ids = {e.id for e in catalogue.events}
233233
for e in events:
234-
if id(e) not in existing:
234+
if e.id not in existing_ids:
235235
raise ValueError('Event is not in catalogue.')
236-
to_remove = {id(e) for e in events}
237-
catalogue.events = [e for e in catalogue.events if id(e) not in to_remove]
236+
to_remove_ids = {e.id for e in events}
237+
catalogue.events = [e for e in catalogue.events if e.id not in to_remove_ids]
238238

239239
def update_field(self, entity: Union[orm.Event, orm.Catalogue], key: str, value) -> None:
240240
if key == 'predicate' and value is not None:
@@ -353,6 +353,13 @@ def has_unsaved_changes(self) -> bool:
353353

354354
def remove(self, entity: Union[orm.Catalogue, orm.Event], permanently: bool = False) -> None:
355355
if permanently:
356+
if isinstance(entity, orm.Event):
357+
# Detach from any catalogue.events still holding a reference,
358+
# otherwise the in-memory collection keeps the deleted ORM
359+
# object and later add_events_to_catalogue() rejects a fresh
360+
# event reusing the same UUID.
361+
for catalogue in list(entity.catalogues): # type: ignore[attr-defined]
362+
catalogue.events.remove(entity)
356363
self.session.delete(entity)
357364
self.session.flush()
358365
else:

0 commit comments

Comments
 (0)