2604: Really fix creation of deep structures using import in update mode r=mergify[bot] a=ghostwheel42

## What type of PR?

bug-fix

## What does this PR do?

Fix creation of deep structures using import in update mode

### Related issue(s)

- closes #2493


Co-authored-by: Alexander Graf <ghostwheel42@users.noreply.github.com>
main
bors[bot] 1 year ago committed by GitHub
commit cc6c808838
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -5,6 +5,7 @@ from copy import deepcopy
from collections import Counter from collections import Counter
from datetime import timezone from datetime import timezone
import inspect
import json import json
import logging import logging
import yaml import yaml
@ -669,20 +670,15 @@ class Storage:
context = {} context = {}
def _bind(self, key, bind): def store(self, key, value):
if bind is True:
return (self.__class__, key)
if isinstance(bind, str):
return (get_schema(self.recall(bind).__class__), key)
return (bind, key)
def store(self, key, value, bind=None):
""" store value under key """ """ store value under key """
self.context.setdefault('_track', {})[self._bind(key, bind)]= value key = f'{self.__class__.__name__}.{key}'
self.context.setdefault('_track', {})[key] = value
def recall(self, key, bind=None): def recall(self, key):
""" recall value from key """ """ recall value from key """
return self.context['_track'][self._bind(key, bind)] key = f'{self.__class__.__name__}.{key}'
return self.context['_track'][key]
class BaseOpts(SQLAlchemyAutoSchemaOpts): class BaseOpts(SQLAlchemyAutoSchemaOpts):
""" Option class with sqla session """ Option class with sqla session
@ -790,10 +786,16 @@ class BaseSchema(ma.SQLAlchemyAutoSchema, Storage):
for key, value in data.items() for key, value in data.items()
} }
def _call_and_store(self, *args, **kwargs): def get_parent(self):
""" track current parent field for pruning """ """ helper to determine parent of current object """
self.store('field', kwargs['field_name'], True) for x in inspect.stack():
return super()._call_and_store(*args, **kwargs) loc = x[0].f_locals
if 'ret_d' in loc:
if isinstance(loc['self'], MailuSchema):
return self.context.get('config'), loc['attr_name']
else:
return loc['self'].get_instance(loc['ret_d']), loc['attr_name']
return None, None
# this is only needed to work around the declared attr "email" primary key in model # this is only needed to work around the declared attr "email" primary key in model
def get_instance(self, data): def get_instance(self, data):
@ -803,9 +805,13 @@ class BaseSchema(ma.SQLAlchemyAutoSchema, Storage):
if keys := getattr(self.Meta, 'primary_keys', None): if keys := getattr(self.Meta, 'primary_keys', None):
filters = {key: data.get(key) for key in keys} filters = {key: data.get(key) for key in keys}
if None not in filters.values(): if None not in filters.values():
res= self.session.query(self.opts.model).filter_by(**filters).first() try:
return res res = self.session.query(self.opts.model).filter_by(**filters).first()
res= super().get_instance(data) except sqlalchemy.exc.StatementError as exc:
raise ValidationError(f'Invalid {keys[0]}: {data.get(keys[0])!r}', data.get(keys[0])) from exc
else:
return res
res = super().get_instance(data)
return res return res
@pre_load(pass_many=True) @pre_load(pass_many=True)
@ -829,6 +835,10 @@ class BaseSchema(ma.SQLAlchemyAutoSchema, Storage):
want_prune = [] want_prune = []
def patch(count, data): def patch(count, data):
# we only process objects here
if type(data) is not dict:
raise ValidationError(f'Invalid item. {self.Meta.model.__tablename__.title()} needs to be an object.', f'{data!r}')
# don't allow __delete__ coming from input # don't allow __delete__ coming from input
if '__delete__' in data: if '__delete__' in data:
raise ValidationError('Unknown field.', f'{count}.__delete__') raise ValidationError('Unknown field.', f'{count}.__delete__')
@ -882,10 +892,10 @@ class BaseSchema(ma.SQLAlchemyAutoSchema, Storage):
] ]
# remember if prune was requested for _prune_items@post_load # remember if prune was requested for _prune_items@post_load
self.store('prune', bool(want_prune), True) self.store('prune', bool(want_prune))
# remember original items to stabilize password-changes in _add_instance@post_load # remember original items to stabilize password-changes in _add_instance@post_load
self.store('original', items, True) self.store('original', items)
return items return items
@ -909,23 +919,18 @@ class BaseSchema(ma.SQLAlchemyAutoSchema, Storage):
# stabilize import of auto-increment primary keys (not required), # stabilize import of auto-increment primary keys (not required),
# by matching import data to existing items and setting primary key # by matching import data to existing items and setting primary key
if not self._primary in data: if not self._primary in data:
parent = self.recall('parent') parent, field = self.get_parent()
if parent is not None: if parent is not None:
for item in getattr(parent, self.recall('field', 'parent')): for item in getattr(parent, field):
existing = self.dump(item, many=False) existing = self.dump(item, many=False)
this = existing.pop(self._primary) this = existing.pop(self._primary)
if data == existing: if data == existing:
instance = item self.instance = item
data[self._primary] = this data[self._primary] = this
break break
# try to load instance # try to load instance
instance = self.instance or self.get_instance(data) instance = self.instance or self.get_instance(data)
# remember instance as parent for pruning siblings
if not self.Meta.sibling and self.context.get('update'):
self.store('parent', instance)
if instance is None: if instance is None:
if '__delete__' in data: if '__delete__' in data:
@ -1001,7 +1006,7 @@ class BaseSchema(ma.SQLAlchemyAutoSchema, Storage):
return items return items
# get prune flag from _patch_many@pre_load # get prune flag from _patch_many@pre_load
want_prune = self.recall('prune', True) want_prune = self.recall('prune')
# prune: determine if existing items in db need to be added or marked for deletion # prune: determine if existing items in db need to be added or marked for deletion
add_items = False add_items = False
@ -1018,16 +1023,17 @@ class BaseSchema(ma.SQLAlchemyAutoSchema, Storage):
del_items = True del_items = True
if add_items or del_items: if add_items or del_items:
parent = self.recall('parent') parent, field = self.get_parent()
if parent is not None: if parent is not None:
existing = {item[self._primary] for item in items if self._primary in item} existing = {item[self._primary] for item in items if self._primary in item}
for item in getattr(parent, self.recall('field', 'parent')): for item in getattr(parent, field):
key = getattr(item, self._primary) key = getattr(item, self._primary)
if key not in existing: if key not in existing:
if add_items: if add_items:
items.append({self._primary: key}) items.append({self._primary: key})
else: else:
items.append({self._primary: key, '__delete__': '?'}) if self.context.get('update'):
self.opts.sqla_session.delete(self.instance or self.get_instance({self._primary: key}))
return items return items
@ -1048,7 +1054,7 @@ class BaseSchema(ma.SQLAlchemyAutoSchema, Storage):
# did we hash a new plaintext password? # did we hash a new plaintext password?
original = None original = None
pkey = getattr(item, self._primary) pkey = getattr(item, self._primary)
for data in self.recall('original', True): for data in self.recall('original'):
if 'hash_password' in data and data.get(self._primary) == pkey: if 'hash_password' in data and data.get(self._primary) == pkey:
original = data['password'] original = data['password']
break break
@ -1244,12 +1250,6 @@ class MailuSchema(Schema, Storage):
if field in fieldlist: if field in fieldlist:
fieldlist[field] = fieldlist.pop(field) fieldlist[field] = fieldlist.pop(field)
def _call_and_store(self, *args, **kwargs):
""" track current parent and field for pruning """
self.store('field', kwargs['field_name'], True)
self.store('parent', self.context.get('config'))
return super()._call_and_store(*args, **kwargs)
@pre_load @pre_load
def _clear_config(self, data, many, **kwargs): # pylint: disable=unused-argument def _clear_config(self, data, many, **kwargs): # pylint: disable=unused-argument
""" create config object in context if missing """ create config object in context if missing

Loading…
Cancel
Save