Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Skeleton.patch() for transactional read/write #1267

Merged
merged 27 commits into from
Oct 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
3c80644
Introduce Skeleton.read() and Skeleton.write()
phorward Sep 20, 2024
373965b
Replace "toDB" and "fromDB" by "read" and "write"
phorward Sep 20, 2024
a80e792
Change return signature of Skeleton.read()
phorward Sep 20, 2024
6ac9415
Move Skeleton.delete() below Skeleton.write()
phorward Sep 20, 2024
37d0552
Renamed "skelValues" into "skel", added type annotations
phorward Sep 20, 2024
2e4be4c
linter
phorward Sep 20, 2024
f11d08a
Introduction of KeyType
phorward Sep 23, 2024
0ed004f
Make `key` optional to Skeleton.read()
phorward Sep 23, 2024
b9b119d
Merge branch 'develop' into refactor-Skeleton.CRUD
phorward Sep 23, 2024
4f91407
Add deprecation warnings to fromDB() and toDB()
phorward Sep 23, 2024
a4c1a1a
Skeleton.read()/write()/delete() with key parameter
phorward Sep 23, 2024
33b921a
Docstring fixes
phorward Sep 23, 2024
b486061
Allow and execute sub-classed fromDB/toDB
phorward Sep 24, 2024
f8b2fb8
feat: `Skeleton.update()` transactional read/write
phorward Sep 24, 2024
4b852f2
Merge branch 'develop' into feat-Skeleton.update
phorward Sep 30, 2024
3557eb8
Merge branch 'develop' into feat-Skeleton.update
phorward Oct 1, 2024
ce8ecb1
Added ReadFromClientException and improved Skeleton.update()
phorward Oct 1, 2024
5540eee
Merge branch 'develop' into feat-Skeleton.update
phorward Oct 1, 2024
1955fd1
Rename Skeleton.update() into Skeleton.edit()
phorward Oct 2, 2024
807abb5
Merge branch 'develop' into feat-Skeleton.update
phorward Oct 7, 2024
569df9f
Apply suggestions from code review
phorward Oct 9, 2024
0800fa1
Merge branch 'develop' into feat-Skeleton.update
phorward Oct 9, 2024
3dfeb4b
Apply suggestions from code review
phorward Oct 14, 2024
c63420d
Apply suggestions from code review
phorward Oct 14, 2024
d26f22b
Remove whitespace, danke GitHub...
phorward Oct 14, 2024
97f598a
Apply suggestions from code review
phorward Oct 15, 2024
0aba147
Merge branch 'develop' into feat-Skeleton.update
sveneberth Oct 15, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/viur/core/bones/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
MultipleConstraints,
ReadFromClientError,
ReadFromClientErrorSeverity,
ReadFromClientException,
UniqueLockMethod,
UniqueValue,
)
Expand Down
35 changes: 35 additions & 0 deletions src/viur/core/bones/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,41 @@ class ReadFromClientError:
invalidatedFields: list[str] = None
"""A list of strings containing the names of invalidated fields, if any."""

def __str__(self):
return f"{'.'.join(self.fieldPath)}: {self.errorMessage} [{self.severity.name}]"


class ReadFromClientException(Exception):
"""
ReadFromClientError as an Exception to raise.
"""

def __init__(self, errors: ReadFromClientError | t.Iterable[ReadFromClientError]):
"""
This is an exception holding ReadFromClientErrors.

:param errors: Either one or an iterable of errors.
"""
super().__init__()

# Allow to specifiy a single ReadFromClientError
if isinstance(errors, ReadFromClientError):
errors = (ReadFromClientError, )

self.errors = tuple(error for error in errors if isinstance(error, ReadFromClientError))

# Disallow ReadFromClientException without any ReadFromClientErrors
if not self.errors:
raise ValueError("ReadFromClientException requires for at least one ReadFromClientError")

# Either show any errors with severity greater ReadFromClientErrorSeverity.NotSet to the Exception notes,
# or otherwise all errors (all have ReadFromClientErrorSeverity.NotSet then)
notes_errors = tuple(
error for error in self.errors if error.severity.value > ReadFromClientErrorSeverity.NotSet.value
)

self.add_note("\n".join(str(error) for error in notes_errors or self.errors))


class UniqueLockMethod(Enum):
"""
Expand Down
123 changes: 119 additions & 4 deletions src/viur/core/skeleton.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,18 @@
import os
import string
import sys
import time
import typing as t
import warnings
from deprecated.sphinx import deprecated
from functools import partial
from itertools import chain
from time import time
from viur.core import conf, current, db, email, errors, translate, utils
from viur.core.bones import (
BaseBone,
DateBone,
KeyBone,
ReadFromClientException,
RelationalBone,
RelationalConsistency,
RelationalUpdateLevel,
Expand Down Expand Up @@ -54,6 +55,7 @@ class MetaBaseSkel(type):
"clone",
"cursor",
"delete",
"patch",
"fromClient",
"fromDB",
"get",
Expand Down Expand Up @@ -293,6 +295,7 @@ def __getattr__(self, item: str):
elif item in {
"all",
"delete",
"patch",
"fromClient",
"fromDB",
"getCurrentSEOKeys",
Expand Down Expand Up @@ -1343,7 +1346,7 @@ def __txn_write(write_skel):
skel.dbEntity["viur"]["viurLastRequestedSeoKeys"] = current_seo_keys

# mark entity as "dirty" when update_relations is set, to zero otherwise.
skel.dbEntity["viur"]["delayedUpdateTag"] = time() if update_relations else 0
skel.dbEntity["viur"]["delayedUpdateTag"] = time.time() if update_relations else 0

skel.dbEntity = skel.preProcessSerializedData(skel.dbEntity)

Expand Down Expand Up @@ -1425,9 +1428,9 @@ def fixDotNames(entity):
if update_relations and not is_add:
if change_list and len(change_list) < 5: # Only a few bones have changed, process these individually
for idx, changed_bone in enumerate(change_list):
updateRelations(key, time() + 1, changed_bone, _countdown=10 * idx)
updateRelations(key, time.time() + 1, changed_bone, _countdown=10 * idx)
else: # Update all inbound relations, regardless of which bones they mirror
updateRelations(key, time() + 1, None)
updateRelations(key, time.time() + 1, None)

# Trigger the database adapter of the changes made to the entry
for adapter in skel.database_adapters:
Expand Down Expand Up @@ -1521,6 +1524,118 @@ def __txn_delete(skel: SkeletonInstance, key: db.Key):
for adapter in skel.database_adapters:
adapter.delete(skel)

@classmethod
def patch(
cls,
skel: SkeletonInstance,
values: t.Optional[dict | t.Callable[[SkeletonInstance], None]] = {},
*,
key: t.Optional[db.Key | int | str] = None,
check: t.Optional[dict | t.Callable[[SkeletonInstance], None]] = None,
create: t.Optional[bool | dict | t.Callable[[SkeletonInstance], None]] = None,
update_relations: bool = True,
retry: int = 0,
) -> SkeletonInstance:
"""
Performs an edit operation on a Skeleton within a transaction.

The transaction performs a read, sets bones and afterwards does a write with exclusive access on the
given Skeleton and its underlying database entity.

All value-dicts that are being fed to this function are provided to `skel.fromClient()`. Instead of dicts,
a callable can also be given that can individually modify the Skeleton that is edited.

:param values: A dict of key-values to update on the entry, or a callable that is executed within
the transaction.

This dict allows for a special notation: Keys starting with "+" or "-" are added or substracted to the
given value, which can be used for counters.
:param key: A :class:`viur.core.db.Key`, string, or int; from which the data shall be fetched.
If not provided, skel["key"] will be used.
:param check: An optional dict of key-values or a callable to check on the Skeleton before updating.
If something fails within this check, an AssertionError is being raised.
:param create: Allows to specify a dict or initial callable that is executed in case the Skeleton with the
given key does not exist.
:param update_relations: Trigger update relations task on success. Defaults to False.
:param retry: On ViurDatastoreError, retry for this amount of times.

If the function does not raise an Exception, all went well. The function always returns the input Skeleton.

Raises:
ValueError: In case parameters where given wrong or incomplete.
AssertionError: In case an asserted check parameter did not match.
ReadFromClientException: In case a skel.fromClient() failed with a high severity.
"""

# Transactional function
def __update_txn():
# Try to read the skeleton, create on demand
if not skel.read(key):
phorward marked this conversation as resolved.
Show resolved Hide resolved
if create is None or create is False:
raise ValueError("Creation during update is forbidden - explicitly provide `create=True` to allow.")

if not (key or skel["key"]) and create in (False, None):
return ValueError("No valid key provided")

if key or skel["key"]:
skel["key"] = db.keyHelper(key or skel["key"], skel.kindName)

if isinstance(create, dict):
if create and not skel.fromClient(create, amend=True):
raise ReadFromClientException(skel.errors)
elif callable(create):
create(skel)
elif create is not True:
raise ValueError("'create' must either be dict or a callable.")
phorward marked this conversation as resolved.
Show resolved Hide resolved

# Handle check
if isinstance(check, dict):
for bone, value in check.items():
if skel[bone] != value:
raise AssertionError(f"{bone} contains {skel[bone]!r}, expecting {value!r}")

elif callable(check):
check(skel)

# Set values
if isinstance(values, dict):
if values and not skel.fromClient(values, amend=True):
raise ReadFromClientException(skel.errors)

# Special-feature: "+" and "-" prefix for simple calculations
# TODO: This can maybe integrated into skel.fromClient() later...
for name, value in values.items():
match name[0]:
case "+": # Increment by value?
skel[name[1:]] += value
case "-": # Decrement by value?
skel[name[1:]] -= value

elif callable(values):
values(skel)

else:
raise ValueError("'values' must either be dict or a callable.")

return skel.write(update_relations=update_relations)

# Retry loop
while True:
try:
if db.IsInTransaction:
return __update_txn()
else:
return db.RunInTransaction(__update_txn)

except db.ViurDatastoreError as e:
retry -= 1
if retry < 0:
raise

logging.debug(f"{e}, retrying {retry} more times")

time.sleep(1)

@classmethod
def preProcessBlobLocks(cls, skel: SkeletonInstance, locks):
"""
Expand Down
Loading