1

I discovered the new generated columns functionality of MySQL 5.7, and wanted to replace some properties of my models by those kind of columns. Here is a sample of a model:

class Ligne_commande(models.Model):
    Quantite = models.IntegerField()
    Prix = models.DecimalField(max_digits=8, decimal_places=3)
    Discount = models.DecimalField(max_digits=5, decimal_places=3, blank=True, null=True)

@property
def Prix_net(self):
    if self.Discount:
        return (1 - self.Discount) * self.Prix
    return self.Prix
@property
def Prix_total(self):
    return self.Quantite * self.Prix_net

I defined generated field classes as subclasses of Django fields (e.g. GeneratedDecimalField as a subclass of DecimalField). This worked in a read-only context and Django migrations handles it correctly, except a detail : generated columns of MySQL does not support forward references and django migrations does not respect the order the fields are defined in a model, so the migration file must be edited to reorder operations.

After that, trying to create or modify an instance returned the mysql error : 'error totally whack'. I suppose Django tries to write generated field and MySQL doesn't like that. After taking a look to django code I realized that, at the lowest level, django uses the _meta.local_concrete_fields list and send it to MySQL. Removing the generated fields from this list fixed the problem.

I encountered another problem: during the modification of an instance, generated fields don't reflect the change that have been made to the fields from which they are computed. If generated fields are used during instance modification, as in my case, this is problematic. To fix that point, I created a "generated field descriptor".

Here is the final code of all of this.

Creation of generated fields in the model, replacing the properties defined above:

Prix_net = mk_generated_field(models.DecimalField, max_digits=8, decimal_places=3,
    sql_expr='if(Discount is null, Prix, (1.0 - Discount) * Prix)',
    pyfunc=lambda x: x.Prix if not x.Discount else (1 - x.Discount) * x.Prix)
Prix_total = mk_generated_field(models.DecimalField, max_digits=10, decimal_places=2,
    sql_expr='Prix_net * Quantite',
    pyfunc=lambda x: x.Prix_net * x.Quantite)

Function that creates generated fields. Classes are dynamically created for simplicity:

from django.db.models import fields
def mk_generated_field(field_klass, *args, sql_expr=None, pyfunc=None, **kwargs):
    assert issubclass(field_klass, fields.Field)
    assert sql_expr

    generated_name = 'Generated' + field_klass.__name__
    try:
        generated_klass = globals()[generated_name]
    except KeyError:
        globals()[generated_name] = generated_klass = type(generated_name, (field_klass,), {})

        def __init__(self, sql_expr, pyfunc=None, *args, **kwargs):
            self.sql_expr = sql_expr
            self.pyfunc = pyfunc
            self.is_generated = True # mark the field
            # null must be True otherwise migration will ask for a default value
            kwargs.update(null=True, editable=False)
            super(generated_klass, self).__init__(*args, **kwargs)

        def db_type(self, connection):
            assert connection.settings_dict['ENGINE'] == 'django.db.backends.mysql'
            result = super(generated_klass, self).db_type(connection)
            # double single '%' if any because it will clash with later Django format
            sql_expr = re.sub('(?<!%)%(?!%)', '%%', self.sql_expr)
            result += ' GENERATED ALWAYS AS (%s)' % sql_expr
            return result

        def deconstruct(self):
            name, path, args, kwargs = super(generated_klass, self).deconstruct()
            kwargs.update(sql_expr=self.sql_expr)
            return name, path, args, kwargs

        generated_klass.__init__ = __init__
        generated_klass.db_type = db_type
        generated_klass.deconstruct = deconstruct
    return generated_klass(sql_expr, pyfunc, *args, **kwargs)

The function to register generated fields in a model. It must be called at django start-up, for example in the ready method of the AppConfig of the application.

from django.utils.datastructures import ImmutableList
def register_generated_fields(model):
    local_concrete_fields = list(model._meta.local_concrete_fields[:])
    generated_fields = []
    for field in model._meta.fields:
        if hasattr(field, 'is_generated'):
            local_concrete_fields.remove(field)
            generated_fields.append(field)
            if field.pyfunc:
                setattr(model, field.name, GeneratedFieldDescriptor(field.pyfunc))
    if generated_fields:
        model._meta.local_concrete_fields = ImmutableList(local_concrete_fields)

And the descriptor. Note that it is used only if a pyfunc is defined for the field.

class GeneratedFieldDescriptor(object):
    attr_prefix = '_GFD_'
    def __init__(self, pyfunc, name=None):
        self.pyfunc = pyfunc
        self.nickname = self.attr_prefix + (name or str(id(self)))

    def __get__(self, instance, owner):
        if instance is None:
            return self
        if hasattr(instance, self.nickname) and not instance.has_changed:
            return getattr(instance, self.nickname)
        return self.pyfunc(instance)

    def __set__(self, instance, value):
        setattr(instance, self.nickname, value)

    def __delete__(self, instance):
        delattr(instance, self.nickname)

Note the instance.has_changed that must tell if the instance is being modified. If found a solution for this here. I have done extensive tests of my application and it works fine, but I am far from using all django functionalities. My question is: could this settings clash with some use cases of django ?

Community
  • 1
  • 1
albar
  • 3,020
  • 1
  • 14
  • 27

0 Answers0