79531686

Date: 2025-03-24 17:05:33
Score: 1
Natty:
Report link

There are some fine and informative solutions slaready,. but I thoguht I'd share my solution. This solution is a method which can be added to any mode class, to give single instances a rough equivalent to the queryset .update(...) method, with the same arguments syntax. It makes use of the update_fields keyword argument to the model's save method, which enables more efficient behind-the-scenes database updating.

Essentially, it is a wrapper around calling instance.save(...) (whether you have overridden it in your model or not) that behaves the same, argument-wise, as queryset.update(...), and is more effient that calling the "full" save method (for most purposes anyway), and also calls the pre- and post-save signals (but provides an argument for ignoring these like a true queryset update, as well). It also allows passing in an arbitrary number of dictionaries as positional args, which will be automatically converted to keyword args for you.

from django.db.models import ForeignKey, ManyToManyField, OneToOneField, JSONField
from django.contrib.postgres.fields import ArrayField
from django.db.models.manager import Manager

class YourModel(models.Model):
    ...
    
    def log(self, *args, exception=None, **kwargs):
        """ Optionally, usethis method to define how to use your logging setup to log messages related to this
        instance specifically. For this SO opost I wuill simply assume 'you've got a logger defined globally,
        and this method calls it.  But some creative logging could produce highly useful organizational/
        informational enhancements. """
        
        (logger.exception if isinstance(exception, Exception) else logger.log)(*args, instance=self, **kwargs)  
        
        # Presuming that you've setup your logging to accept
        # an instance object, which modifies where it is logged
        # to, or something.  Feel free to modify this method however
        # you see fit.
        
    
    def update(self, *update_by_dicts, skip_signals=False, use_query=False, refresh_instance=True, 
               validate_kwargs=False, allow_updating_relations=True, **kwargs):
        """
        update instance method
        This method enables the calling of instance.update(...) in  approximately the same manner
        as the update method of a queryset, allowing seamless behavior for updating either a 
        query or a single instance that's been loaded into memory.  It provides options via the 
        keyword args as described below.

        NOTE that providing positional (non-keyword) arguments can be done; if it is, they must each be
        a dictionary, which will be unpacked into keywpord arguments as if each key/value pair 
        had been passed in as a keyword argument to this method.

        Args:
            skip_signals:  If True, then both the pre- and post-save siognals will be ignored after .save is 
                called at the end of this method's execution (the behavior of a queryset's update method).
                You can also pass this in as 'pre' or 'post'; if you do, then the pre_save/ or post_save
                signal, respectively, will be skipped, while the other will execute.  The default value for
                this argument is False, meaning that both pre and post_save are called, like a normal save 
                method call.
            use_query:  Normally, this method obviates the need to query "self" (which has already been loaded
                fr0om the database into an instance variable, after all) by utilizing the save method, but if
                for some reason you wouold prefer not to have this behavior, passing in use_query=True will
                cause the method to use a different approach, and will self-query using the ORM, and then call
                the typical update method on the resulting one-element queryset.  In this case, signals will be
                skipped regardless of the value specifyt for the skip_signals argument.  However, any positional
                argument dicts provided will still be unpacked as passed in as keyword args.
            refresh_instance: Only does anything if use_query is True; then, if refresh_instance is True, it will
                call refresh_from_db after the ORM update(s), to make sure this instance isn't outdarted.  If you're
                not goiong to use the instance anymore afterwards, specifying refresh_instance=False saves some time
                since it won't re-query it from the database.
            validate_kwargs:  Normally, all keyword arguments (and keys from positional argument dicts, if any)
                will be blindly passed to self.save.  Hpwever, if there is any chance that keys supplied may contain
                values that do not correspond to existing fields in the model (such as in some system using 
                polymorphism or other forms of inheritance, where each child model may have some fields unique only to 
                that model), you can specify validate_fields=True to check all of the fields against their presence in the
                instance (using hasattr and discrading if it returns False); specifying a list of argument names instead
                will only check those arguments.  This adda a nominal amount of overhead time cost to the execution of 
                Python, so it shoyuld ony be used if it is needed, but it solves a couple of issues related to model
                inheritance and/or polymorphism, and protects against dynamic instance updating situations going wrong, too.
            allow_updating_relations: If True (which is the default), it enables passing in fields of models accessed through
                relations (like ForeignKeys or ManyToManyFields, or their reverse relationships), via standard Djkango query
                syntax using double underscores.   These updates are done through a normal ORM queryset update, for 
                efficiency.  If False, any argument whose name contains double underscores will not be valid, unless
                it is use to reference a key in a JSONField, or an index in an ArrayField.  
                * PLEASE NOTE that manytomany and reverse foreignkey fields WILL NOT WORK without being validated by providing 
                  validate_kwargs=True or including the related_name relation set manager name in the list provided to validate 
                  kwargs. 
            kwargs: All other keyword arguments will be interpreted as field names to update, and the values to update them
                to.  Please note also that any positional argument dicts will be unpacked and literally merged in with
                the kwargs dict, with priority in the case of duplicate keys being given to those given in kwargs.

        Returns:
            A dict of the fields that were not successfully updated with the error message associated with why it failed.  
            All expected potential exceptions are caught and logged and gracefully handled/logged, to avoid interruption 
            of your app/program, so the return value can be used to tell you if there were any issues.
        """
        
        if skip_signals is True:
            skip_signals = set(['pre_save', 'post_save'])
        elif isinstance(skip_signals, str):
            skip_signals = set([skip_signals])
        elif isinstance(skip_signals, (list, tuple)):
            skip_signals = set(skip_signals)
        
        failed_keys = set()
        separate_queries = []
        self_query_updates = dict()
        # Merging all keys and their values from any posotional dictionaries provided, directly into kwargs for ease of 
        # processing later.
        for more_args in update_by_dicts:
            for key, val in more_args.items():
                if key not in kwargs:  # If key was passed explicitly as a kwarg, then we prioritize that and ignore it here
                    kwargs[key] = val
        
        #  If argument validation is desired, we'll do that here, and log any removed after removing those entries from kwargs
        if validate_kwargs is True:
            # If it is the literal value True, we'll convert it to a list containing the names of every field we requested to update
            validate_kwargs = [ key for key in kwargs ]
        if validate_kwargs:
            # Unless it is False/None, we have at least one field to validate, and will do so now using hasattr, and delete amy 
            # key/value pairs where the key is not a valid name of an attribute in this instance (or a relation, if applicable).
            for field_name in validate_kwargs:
                if not (result := check_field_name_validity(self, field_name, value, allow_relations=allow_updating_relations)):
                    failed_keys.add(field_name)
                    del kwargs[field_name]
                else:
                    if isinstance(result, dict):
                        # This is a related query, and the function has returned information about that query
                        separate_queries.append(result)
                        del kwargs[field_name]  # Deleting from kwargs, since it will be called due to its reference in separate_queries
                        
                    #elif (result is True) and use_query:
                    #    # Converting any True result to dicts representing ORM queries to use, if use_query argument is True
                    #    separate_queries.append({
                    #        'type': 'self', 
                    #        'manager': type(self).objects.filter(pk=self.pk),
                    #        'update_statement': field_name,
                    #    })
                    #    del kwargs[field_name]
       
       if len(kwargs) > 0:
           upd_fields = set()
           if isinstance(skip_signals, list):
               self.__skip_signals = skip_signals
       else:
           try:
               del self.__skip_signals
           except:
               pass

         for field_name, value in kwargs.items():
             # Looping through any keys remaining in kwargs in order to modify this instance's fields accordingly, and then call 
             # self.save(update_fields=[...]) to perform a database UPDATE only on the changed fields for efficiency.
             # If one or both save signals are to be skipped, we'll add attributes to the instance; I will lave it to the reader to 
             # modify the signal receiver(s) to check for the presence of said attributes, and return without doing anything if they're
             # present and True.
             # If any exceptions are raised, we'll catch, log, and inform the caller in the returned list of failed field names
             try:
                 if use_query:
                      self_query_updates[field_name] = value # Advantage to using a self query is we don't pre-process, just execute updates as-is
                 else:
                     if '__' in field_name:
                         # traversing the path of indexes/attrbute names for the field, since it has double underwcores.  The code below will handle JSONFields, ArrayFields,
                         # and relations for ForeignKeys and OneToOneFields, in the case that those fields were not validated
                         path_toks = field_name.split('__')
                         real_obj = None
                         if hasattr(self, path_toks[0]) and isinstance(getattr(self, path_toks[0]), models.Model):
                             result = check_field_name_validity(self, field_name, value, allow_relations=allow_updating_relations)
                             if not result:
                                 raise ValueError(f"Non-validated kwarg '{field_name}' appears to be a related instance, but there was a problem with the field")
                             else:
                                 separate_queries.append(result)  # If it validates, adding it to separate_queries to avoid code duplication
                                 continue
                                 
                         for attr in path_toks:
                             real_obj = self if not real_obj else ( else real_obj[last_key])
                             last_key = int(attr) if attr.isdigit() else attr
                         
                         else:
                             upd_fields.add(path_toks[0])  # Add just the root of the field's 'path', as that is the JSONField/etc that we'll update in save()
                             real_obj[last_key] = value
                     
                     else:
                         setattr(self, field_name, value)
                         upd_fields.add(field_name)
                     
             
             except Exception as e:
                self.log("Exception encountered during processing of field '{field_name}'", exception=e)
                failed_keys.add(field_name)
           
        if use_query:
            # Executing "self-query" by filtering model class for the single instance and using atomic update method of queryset
            type(self).objects.filter(pk=self.pk).update(**self_query_updates)
            
            # Then, refreshing the instance from DB so we don't have outdated field values (unless not needed, via arguments)
            if refresh_instance:
            self.refresh_from_db()
     
     else:
         # Finally, calling save with update_fields 
         self.save(update_fields=upd_fields)
     
     try:
         del self.__skip_signals
     except:
         pass
     
     # Lastly, executing whatever separate queries may have been requested due to related model fields
     for qrydef in separate_queries:
         try:
             qrydef['manager'].update(**qrydef['update_statement'])
         except Exception as e:
             self.log(f"Exception while processing separarte model query defined by {qrydef}", exception=e)
             faled_keys.add(qrydef['key'])
 
     return failed_keys




(Outside of the model class definition)
        
def check_field_name_validity(instance, field_name, value, allow_relations=True):
    if not hasattr(instance, field_name):
        if '__' in field_name:
            path_tokens = field_name.split('__')
            arg_valid = False
            try:
                if hasattr(instance, path_tokens[0]):
                    # Usage of isinstance allows subclasses of these field types to be recognized  
                    obj = getattr(instance, path_tokens[0])
                    if isinstance(instance._meta.get_field(path_tokens[0]), JSONField):  
                        if len(path_tokens) > 1:
                            for nextkey in path_tokens[1:]:
                                obj = obj[nextkey]
                        # If we've made it to the end of the path of keys, then this argument is valid
                        return True
                
                    elif isinstance(instance._meta.get_field(path_tokens[0]), ArrayField):  
                        value = obj
                        for index in [ (int(x) if x.isdigit() else x) for x in path_tokens[1:] ]:
                            # We converted each path "token" from the split on double underscore into
                            # an integer if it is a str representation of one, else left it as a str;
                            # this allows nested ArrayFields or ArrayFields made up of DictFields.
                            value = value[index]
                        # If we've made it to the end of the path of keys, then this argument is valid
                        return True
                    
                    elif isinstance(instance._meta.get_field(path_tokens[0]), Manager): 
                        # The fact that its class is Manager means it is a reverse relation, so we'll 
                        # need to check the validity of the rest of it by seeing if a query on it results
                        # in an exception.  We'll use __isnull since it should work for any valid field
                        if not allow_relations:
                            raise TypeError(f"Field '{path_tokens[0]}' of field name '{field_name}' is a related set manager, but allow_relations is False")
                        search_path = "__".join(path_tokens[1:])
                        try:
                            if not obj.filter(**{qry_path + '__isnull'): False).exists():
                                raise ValueError(f"Relation field argument '{field_name}' does not exist or the query results in no matches")
                        except Exception as e:
                            raise e
                        else:
                            return {
                                'key': field_name,
                                'type': 'collection',
                                'manager': obj,
                                'update_statement': {"__".join(path_tokens[1:]): value},
                            }
                    
                    else:
                        # We'll assume it is a OneToOne/ForeignKey field and if it's not it will error, which tells us it's invalid.
                        # 'obj' should contain the followed reference to the related instance already, if so.
                        # We'll recursively call this function on the reated object and return the result.  Recursion is a beautiful thing!
                        # If you don't know, now you know.
                        if not allow_relations:
                            raise TypeError(f"Field '{path_tokens[0]}' of field name '{field_name}' is a related model instance, but allow_relations is False")
                        arg_valid = check_field_name_validity(obj, "__".join(path_tokens)[1:], value, allow_relations=True)
                        if arg_valid:
                            return {
                                'key': field_name,
                                'type': 'instance',
                                'manager': type(obj).objects.filter(pk=obj.pk),
                                'update_statement': {"__".join(path_tokens[1:]): value},
                            }
                                                     
                        else:
                            raise ValueError(f"Field '{field_name}' cannot be found in the instance nor any related managers or instances")

            except Exception as e:
                # If any exception is caught at all, it means this is not a valid argument, and we'll log the exception and remove it from kwargs
                instance.log(f"Field '{field_name}' is invalid.  Reason = {e}", exception=e)
                
        return False

*NOTE: this 'is similar to what I use in my web platform I've been developing for the past couple of years, but I made changes that I havcen't tested and am prone to typos. If you use this and find issues, please let me know and I'll fix them in this post.

Reasons:
  • Whitelisted phrase (-1): solution is
  • RegEx Blacklisted phrase (2.5): please let me know
  • Long answer (-1):
  • Has code block (-0.5):
  • Low reputation (1):
Posted by: Eric