corvic-engine 0.3.0rc77__cp38-abi3-win_amd64.whl → 0.3.0rc78__cp38-abi3-win_amd64.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.
corvic/orm/mixins.py DELETED
@@ -1,480 +0,0 @@
1
- """Mixin models for corvic orm tables."""
2
-
3
- from __future__ import annotations
4
-
5
- from collections.abc import Callable, Sequence
6
- from datetime import UTC, datetime
7
- from typing import Any, LiteralString, cast
8
-
9
- import sqlalchemy as sa
10
- from google.protobuf import timestamp_pb2
11
- from sqlalchemy import event, exc
12
- from sqlalchemy import orm as sa_orm
13
- from sqlalchemy.ext import hybrid
14
- from sqlalchemy.ext.hybrid import hybrid_property
15
-
16
- import corvic.context
17
- from corvic.orm.base import EventBase, EventKey, OrgBase
18
- from corvic.orm.errors import (
19
- DeletedObjectError,
20
- RequestedObjectsForNobodyError,
21
- )
22
- from corvic.orm.func import gen_uuid
23
- from corvic.orm.ids import OrgID
24
- from corvic.orm.keys import ForeignKey
25
- from corvic.result import InvalidArgumentError
26
- from corvic_generated.status.v1 import event_pb2
27
-
28
-
29
- def _filter_org_objects(orm_execute_state: sa_orm.ORMExecuteState):
30
- if all(
31
- not issubclass(mapper.class_, BelongsToOrgMixin | OrgBase)
32
- for mapper in orm_execute_state.all_mappers
33
- ):
34
- # operation has nothing to do with models owned by org
35
- return
36
- if orm_execute_state.is_select:
37
- requester = corvic.context.get_requester()
38
- org_id = OrgID(requester.org_id)
39
- if org_id.is_super_user:
40
- return
41
-
42
- if org_id.is_nobody:
43
- raise RequestedObjectsForNobodyError(
44
- "requester org from context was nobody"
45
- )
46
- # we need the real value in in expression world and
47
- # because of sqlalchemys weird runtime parsing of this it
48
- # needs to be a real local with a name
49
- db_id = org_id.to_db().unwrap_or_raise()
50
-
51
- # this goofy syntax doesn't typecheck well, but is the documented way to apply
52
- # these operations to all subclasses (recursive). Sqlalchemy is inspecting the
53
- # lambda rather than just executing it so a function won't work.
54
- # https://docs.sqlalchemy.org/en/20/orm/queryguide/api.html#sqlalchemy.orm.with_loader_criteria
55
- check_org_id_lambda: Callable[ # noqa: E731
56
- [type[BelongsToOrgMixin]], sa.ColumnElement[bool]
57
- ] = lambda cls: cls.org_id == db_id
58
- orm_execute_state.statement = orm_execute_state.statement.options(
59
- sa_orm.with_loader_criteria(
60
- BelongsToOrgMixin,
61
- cast(Any, check_org_id_lambda),
62
- include_aliases=True,
63
- track_closure_variables=False,
64
- ),
65
- sa_orm.with_loader_criteria(
66
- OrgBase,
67
- OrgBase.id == org_id,
68
- include_aliases=True,
69
- track_closure_variables=False,
70
- ),
71
- )
72
-
73
-
74
- class BadDeleteError(DeletedObjectError):
75
- """Raised when deleting deleted objects."""
76
-
77
- def __init__(self):
78
- super().__init__(message="deleting an object that is already deleted")
79
-
80
-
81
- def _filter_deleted_objects_when_orm_loading(
82
- execute_state: sa_orm.session.ORMExecuteState,
83
- ):
84
- # check if the orm operation was submitted with an option to force load despite
85
- # soft-load status and if so just skip this event
86
- if any(
87
- isinstance(opt, SoftDeleteMixin.ForceLoadOption)
88
- for opt in execute_state.user_defined_options
89
- ) or any(
90
- isinstance(opt, SoftDeleteMixin.ForceLoadOption)
91
- for opt in execute_state.local_execution_options.values()
92
- ):
93
- return
94
-
95
- def where_criteria(cls: type[SoftDeleteMixin]) -> sa.ColumnElement[bool]:
96
- return ~cls.is_deleted
97
-
98
- execute_state.statement = execute_state.statement.options(
99
- sa_orm.with_loader_criteria(
100
- entity_or_base=SoftDeleteMixin,
101
- # suppressing pyright is unfortunately required as there seems to be a
102
- # problem with sqlalchemy.orm.util::LoaderCriteriaOption which will
103
- # construct a 'DeferredLambdaElement' when `where_criteria` is callable.
104
- # However, the type annotations are not consistent with the implementation.
105
- # The implementation, on callables criteria, passes to the lambda the
106
- # mapping class for using in constructing the `ColumnElement[bool]` result
107
- # needed. For this reason we ignore the argument type.
108
- where_criteria=where_criteria,
109
- include_aliases=True,
110
- )
111
- )
112
-
113
-
114
- class SoftDeleteMixin(sa_orm.MappedAsDataclass):
115
- """Mixin to make corvic orm models use soft-delete.
116
-
117
- Modifications to objects which are marked as deleted will result in
118
- an error.
119
- """
120
-
121
- class ForceLoadOption(sa_orm.UserDefinedOption):
122
- """Option for ignoring soft delete status when loading."""
123
-
124
- _deleted_at: sa_orm.Mapped[datetime | None] = sa_orm.mapped_column(
125
- "deleted_at",
126
- sa.DateTime(timezone=True),
127
- server_default=None,
128
- default=None,
129
- )
130
- is_live: sa_orm.Mapped[bool | None] = sa_orm.mapped_column(
131
- init=False,
132
- default=True,
133
- )
134
-
135
- @hybrid.hybrid_property
136
- def deleted_at(self) -> datetime | None:
137
- if not self._deleted_at:
138
- return None
139
- return self._deleted_at.replace(tzinfo=UTC)
140
-
141
- def reset_delete(self):
142
- self._deleted_at = None
143
-
144
- @classmethod
145
- def _force_load_option(cls):
146
- return cls.ForceLoadOption()
147
-
148
- @classmethod
149
- def force_load_options(cls):
150
- """Options to force load soft-deleted objects when using session.get."""
151
- return [cls._force_load_option()]
152
-
153
- @classmethod
154
- def force_load_execution_options(cls):
155
- """Options to force load soft-deleted objects when using session.execute.
156
-
157
- Also works with session.scalars.
158
- """
159
- return {"ignored_option_name": cls._force_load_option()}
160
-
161
- def mark_deleted(self):
162
- """Updates soft-delete object.
163
-
164
- Note: users should not use this directly and instead should use
165
- `session.delete(obj)`.
166
- """
167
- if self.is_deleted:
168
- raise BadDeleteError()
169
- # set is_live to None instead of False so that orm objects can use it to
170
- # build uniqueness constraints that are only enforced on non-deleted objects
171
- self.is_live = None
172
- self._deleted_at = datetime.now(tz=UTC)
173
-
174
- @hybrid_property
175
- def is_deleted(self) -> bool:
176
- """Useful when constructing queries for direct use (e.g via `session.execute`).
177
-
178
- ORM users can rely on the typical session interfaces for checking object
179
- persistence.
180
- """
181
- return not self.is_live
182
-
183
- @is_deleted.inplace.expression
184
- @classmethod
185
- def _is_deleted_expression(cls):
186
- return cls.is_live.is_not(True)
187
-
188
- @staticmethod
189
- def register_session_event_listeners(session: type[sa_orm.Session]):
190
- event.listen(
191
- session, "do_orm_execute", _filter_deleted_objects_when_orm_loading
192
- )
193
-
194
-
195
- def live_unique_constraint(
196
- column_name: LiteralString, *other_column_names: LiteralString
197
- ) -> sa.UniqueConstraint:
198
- """Construct a unique constraint that only applies to live objects.
199
-
200
- Live objects are those that support soft deletion and have not been soft deleted.
201
- """
202
- return sa.UniqueConstraint(column_name, *other_column_names, "is_live")
203
-
204
-
205
- class BelongsToOrgMixin(sa_orm.MappedAsDataclass):
206
- """Mark models that should be subject to org level access control."""
207
-
208
- @staticmethod
209
- def _current_org_id_from_context():
210
- requester = corvic.context.get_requester()
211
- return OrgID(requester.org_id)
212
-
213
- @staticmethod
214
- def _make_org_id_default() -> OrgID | None:
215
- org_id = BelongsToOrgMixin._current_org_id_from_context()
216
-
217
- if org_id.is_nobody:
218
- raise RequestedObjectsForNobodyError(
219
- "the nobody org cannot change orm objects"
220
- )
221
-
222
- if org_id.is_super_user:
223
- return None
224
-
225
- return org_id
226
-
227
- org_id: sa_orm.Mapped[OrgID | None] = sa_orm.mapped_column(
228
- ForeignKey(OrgBase).make(ondelete="CASCADE"),
229
- nullable=False,
230
- default_factory=_make_org_id_default,
231
- init=False,
232
- )
233
-
234
- @sa_orm.validates("org_id")
235
- def validate_org_id(self, _key: str, orm_id: OrgID | None):
236
- expected_org_id = self._current_org_id_from_context()
237
- if expected_org_id.is_nobody:
238
- raise RequestedObjectsForNobodyError(
239
- "the nobody org cannot change orm objects"
240
- )
241
-
242
- if expected_org_id.is_super_user:
243
- return orm_id
244
-
245
- if orm_id != expected_org_id:
246
- raise InvalidArgumentError(
247
- "provided org_id must match the current org",
248
- provided=orm_id,
249
- expected=expected_org_id,
250
- )
251
-
252
- return orm_id
253
-
254
- @staticmethod
255
- def register_session_event_listeners(session: type[sa_orm.Session]):
256
- event.listen(session, "do_orm_execute", _filter_org_objects)
257
-
258
-
259
- class Session(sa_orm.Session):
260
- """Wrapper around sqlalchemy.orm.Session."""
261
-
262
- _soft_deleted: dict[sa_orm.InstanceState[Any], Any] | None = None
263
-
264
- def _track_soft_deleted(self, instance: object):
265
- if self._soft_deleted is None:
266
- self._soft_deleted = {}
267
- self._soft_deleted[sa_orm.attributes.instance_state(instance)] = instance
268
-
269
- def _reset_soft_deleted(self):
270
- self._soft_deleted = {}
271
-
272
- def _ensure_persistence(self, instance: object):
273
- instance_state = sa_orm.attributes.instance_state(instance)
274
- if instance_state.key is None:
275
- raise exc.InvalidRequestError("Instance is not persisted")
276
-
277
- def _delete_soft_deleted(self, instance: SoftDeleteMixin):
278
- self._ensure_persistence(instance)
279
-
280
- instance.mark_deleted()
281
-
282
- # Soft deleted should be tracked so that way a deleted soft-delete instance is
283
- # correctly identified as being "deleted"
284
- self._track_soft_deleted(instance)
285
-
286
- # Flushing the objects being deleted is needed to ensure the 'soft-delete'
287
- # impact is spread. This is because sqlalchemy flush implementation is doing
288
- # the heavy lifting of updating deleted/modified state across dependencies
289
- # after flushing. Ensuring this is done necessary to ensure relationships with
290
- # cascades have valid state after a soft-delete. Otherwise divergence between
291
- # hard-delete and soft-delete will be seen here (and surprise the user).
292
- # Note: the cost is reduced by limiting the flush to the soft-delete instance.
293
- self.flush([instance])
294
-
295
- # Invalidate existing session references for expected get-after-delete behavior.
296
- if sa_orm.attributes.instance_state(instance).session_id is self.hash_key:
297
- self.expunge(instance)
298
-
299
- def commit(self):
300
- super().commit()
301
- if self._soft_deleted:
302
- self._reset_soft_deleted()
303
-
304
- def rollback(self):
305
- super().rollback()
306
- if self._soft_deleted:
307
- for obj in self._soft_deleted.values():
308
- if isinstance(obj, SoftDeleteMixin):
309
- obj.reset_delete()
310
- obj.is_live = True
311
- continue
312
- raise RuntimeError("non-soft delete object in soft deleted set")
313
- self._reset_soft_deleted()
314
-
315
- @property
316
- def deleted(self):
317
- deleted = super().deleted
318
- if self._soft_deleted:
319
- deleted.update(self._soft_deleted.values())
320
- return deleted
321
-
322
- def delete(self, instance: object, *, force_hard_delete=False):
323
- if isinstance(instance, SoftDeleteMixin) and not force_hard_delete:
324
- self._delete_soft_deleted(instance)
325
- return
326
- super().delete(instance)
327
-
328
- def recent_object_events(
329
- self,
330
- key_provider: EventKey.Provider,
331
- max_events: int | None = None,
332
- ) -> list[event_pb2.Event]:
333
- """Returns max_events (default=10) most recent events from the event log."""
334
- recent_events: Sequence[EventBase] = self.scalars(
335
- EventBase.select_latest_by_event_key(
336
- event_key=key_provider.event_key, limit=max_events or None
337
- )
338
- ).all()
339
- return [ev.as_event() for ev in recent_events]
340
-
341
-
342
- def _timestamp_or_utc_now(timestamp: datetime | None = None):
343
- if timestamp is not None:
344
- return timestamp
345
- return datetime.now(tz=UTC)
346
-
347
-
348
- class EventLoggerMixin(sa_orm.MappedAsDataclass):
349
- """Mixin to add status event logging features to corvic orm models.
350
-
351
- This mixin will add a `log_src_id` uuid value to the ORM model. This value is set
352
- by the DB on initial object persistence. ORM users can then use `orm.log_X` method
353
- to add a status event to the corvic event log which will be associated with the
354
- `log_src_id` value for the object. Supported events include done, error,
355
- pending_system, and pending_user. The `latest_event` property can be used to read
356
- the latest event for the orm object from the event log.
357
- """
358
-
359
- def _get_latest_event(self, _: Any = None) -> event_pb2.Event:
360
- obj_session = sa_orm.object_session(self)
361
- if obj_session:
362
- query = EventBase.select_latest_by_event_key(
363
- event_key=self.event_key, limit=1
364
- )
365
- last_log_entry = obj_session.scalars(query).first()
366
- if last_log_entry is not None:
367
- timestamp = timestamp_pb2.Timestamp()
368
- timestamp.FromDatetime(dt=last_log_entry.timestamp)
369
- return event_pb2.Event(
370
- timestamp=timestamp,
371
- reason=last_log_entry.reason,
372
- event_type=event_pb2.EventType.Name(last_log_entry.event),
373
- regarding=last_log_entry.regarding,
374
- )
375
- return event_pb2.Event()
376
-
377
- def _set_latest_event(self, event: event_pb2.Event, _: Any = None):
378
- if event.SerializeToString(): # initially the event is b''
379
- obj_session = sa_orm.object_session(self)
380
-
381
- if obj_session is not None:
382
- # this can occur when an event is set on a new object
383
- if not self._event_src_id:
384
- obj_session.flush()
385
-
386
- obj_session.add(
387
- EventBase(
388
- event=event.event_type,
389
- timestamp=event.timestamp.ToDatetime(tzinfo=UTC),
390
- regarding=event.regarding,
391
- reason=event.reason,
392
- event_key=str(self.event_key),
393
- )
394
- )
395
- else:
396
- raise sa_orm.exc.UnmappedInstanceError(
397
- self, msg="cannot add event to unmapped instance"
398
- )
399
-
400
- @sa_orm.declared_attr
401
- def _latest_event(self):
402
- """Get or set the latest event for this orm object."""
403
- return sa_orm.synonym(
404
- "_latest_event",
405
- default_factory=event_pb2.Event,
406
- descriptor=property(self._get_latest_event, self._set_latest_event),
407
- )
408
-
409
- @property
410
- def latest_event(self):
411
- """Returns the latest event for this entity from the event log."""
412
- return self._get_latest_event()
413
-
414
- _event_src_id: sa_orm.Mapped[str] = sa_orm.mapped_column(
415
- sa.Text, init=False, server_default=gen_uuid()
416
- )
417
-
418
- @property
419
- def event_key(self) -> EventKey:
420
- return EventKey.from_str(id=self._event_src_id)
421
-
422
- def _log_event(
423
- self, event_type: event_pb2.EventType, reason: str, regarding: str, dt: datetime
424
- ):
425
- timestamp = timestamp_pb2.Timestamp()
426
- timestamp.FromDatetime(dt=dt)
427
- return event_pb2.Event(
428
- event_type=event_type,
429
- reason=reason,
430
- regarding=regarding,
431
- timestamp=timestamp,
432
- )
433
-
434
- def notify_done(
435
- self, reason: str = "", regarding: str = "", timestamp: datetime | None = None
436
- ):
437
- """Add a finished event to the event log for this object."""
438
- self._latest_event = self._log_event(
439
- event_type=event_pb2.EVENT_TYPE_FINISHED,
440
- reason=reason,
441
- regarding=regarding,
442
- dt=_timestamp_or_utc_now(timestamp=timestamp),
443
- )
444
-
445
- def notify_error(
446
- self, reason: str = "", regarding: str = "", timestamp: datetime | None = None
447
- ):
448
- """Add an error event to the event log for this object."""
449
- self._latest_event = self._log_event(
450
- event_type=event_pb2.EVENT_TYPE_ERROR,
451
- reason=reason,
452
- regarding=regarding,
453
- dt=_timestamp_or_utc_now(timestamp=timestamp),
454
- )
455
-
456
- def notify_pending_system(
457
- self, reason: str = "", regarding: str = "", timestamp: datetime | None = None
458
- ):
459
- """Add a pending system event to the event log for this object."""
460
- self._latest_event = self._log_event(
461
- event_type=event_pb2.EVENT_TYPE_SYSTEM_PENDING,
462
- reason=reason,
463
- regarding=regarding,
464
- dt=_timestamp_or_utc_now(timestamp=timestamp),
465
- )
466
-
467
- def notify_pending_user(
468
- self, reason: str = "", regarding: str = "", timestamp: datetime | None = None
469
- ):
470
- """Add a pending user event to the event log for this object."""
471
- self._latest_event = self._log_event(
472
- event_type=event_pb2.EVENT_TYPE_USER_PENDING,
473
- reason=reason,
474
- regarding=regarding,
475
- dt=_timestamp_or_utc_now(timestamp=timestamp),
476
- )
477
-
478
-
479
- SoftDeleteMixin.register_session_event_listeners(Session)
480
- BelongsToOrgMixin.register_session_event_listeners(Session)