[Fixed]-Django multiple models, same table

8đź‘Ť

Yes, it is possible. Here is an example:

models.py

from django.db import models

# Create your models here.
class NodeA(models.Model):

    name_a = models.CharField(max_length=75, blank=True, null=True)

    class Meta:
        db_table = 'Nodes'
        managed = False

class NodeB(models.Model):

    name_b = models.CharField(max_length=75, blank=True, null=True)

    class Meta:
        db_table = 'Nodes'
        managed = False

class NodeC(models.Model):

    name_c = models.CharField(max_length=75, blank=True, null=True)

    class Meta:
        db_table = 'Nodes'
        managed = False

Database schema (SQLITE)

 Nodes {
    id        integer   primary key
    name_a    TEXT
    name_b    TEXT
    name_c    TEXT }

Proof of concept

import NodeA, NodeB, NodeC

a = NodeA()
a.name_a = 'Node A'
a.save()

b = NodeB()
b.name_b = 'Node B'
b.save()

c = NodeC()
c.name_c = 'Node C'
c.save()

This produces:

id        name_a        name_b        name_c
1         Node A
2                       Node B
3                                     Node C
👤D.A

6đź‘Ť

I use a somewhat different approach, which plays nicely with south by creating a perspective. A perspective is a proxy which renames some fields in the model, but keep the name of the column.

For me it was an example to show the flexibility of the django ORM. I am not sure if you want to use this in production code. Therefore it is not tested enough, but it will give you some idea.

The idea

A perspective let the user creating different models to one table, which can have their own methods and have different field names, but share the underlying model and table.

It can store different types in the same table, which can be handy for logging or event systems. Every perspective can only see it’s own entries, because it is filtered on a field name action_type.

The models are unmanaged, but have a custom manager, so south doesn’t create new tables for it.

Usage

The implementation is a class decorator, which modifies the meta data of the django model. It takes in an “base” model and a dictionary of aliased fields.

Let first look at an example:

class UserLog(models.Model):
"""
A user action log system, user is not in this class, because it clutters import
"""
date_created = models.DateTimeField(_("Date created"), auto_now_add=True)

# Action type is obligatory

action_type = models.CharField(_("Action Type"), max_length=255)
integer_field1 = models.IntegerField()
integer_field2 = models.IntegerField()
char_field1 = models.CharField(max_length=255)
char_field2 = models.CharField(max_length=255)


@ModelPerspective({
    'x': 'integer_field1',
    'y': 'integer_field2',
    'target': 'char_field1'
}, UserLog)
class UserClickLog(models.Model):
    pass

This creates a model, which maps the property x to integer_field1, y to integer_field2 and target to char_field1 and where the underlying table is the same as the table as UserLog.

Usage is not different then any other model and south will only create the UserLog table.

Now let’s look how to implement this.

Implementation

How does it work?

If the class is evaluated the decorator receives the class. This will monkey patch the class, so it instances will reflect the base table as you provided.

Adding the aliases

If we walk somewhat deeper into the code. The aliased dictionary is read and for every field the base field is looked up. If we found the field in the base table, the name is changed. This has a small side effect, that it also changes the column. So we have to retrieve the field column from the base field. Then the field is added to the class with the contribute_to_class method, which take care of all the bookkeeping.

Then all not aliased properties are added to the model. This is not needed perse, but I have chosen to add them.

Setting the properties

Now we have all the fields, we have to set a couple of properties. The managed property will trick south in ignoring the table, but it has a side effect. The class will not have a manager. (We will fix that later). We also copy the table name (db_table) from the base model and make the action_type field default to the class name.

The last thing we need to do is to provide a manager. Some care has to be taken, because django states there is only one QuerySet manager. We solve this by copying the manager with deepcopy and then add a filter statement, which filters on the class name.

deepcopy(QuerySet()).filter(action_type = cls.class.name)

This let our table return only relevant records. Now wrap it up into a decorator and it is done.

This is the code:

from django.db import models
from django.db.models.query import QuerySet

def ModelPerspective(aliases, model):
  """
  This class decorator creates a perspective from a model, which is
  a proxy with aliased fields.

  First it will loop over all provided aliases
  these are pairs of new_field, old_field.
  Then it will copy the old_fields found in the
  class to the new fields and change their name,
  but keep their columnnames.

  After that it will copy all the fields, which are not aliased.

 Then it will copy all the properties of the model to the new model.

  Example:
    @ModelPerspective({
        'lusername': 'username',
        'phonenumber': 'field1'
    }, User)
    class Luser(models.Model):
        pass

  """
  from copy import deepcopy

  def copy_fields(cls):

    all_fields = set(map(lambda x: x.name, model._meta.fields))
    all_fields.remove('id')
    # Copy alias fields

    for alias_field in aliases:

        real_field = aliases[alias_field]

        # Get field from model
        old_field = model._meta.get_field(real_field)
        oldname, columnname = old_field.get_attname_column()
        new_field = deepcopy(old_field)

        # Setting field properties
        new_field.name = alias_field
        new_field.db_column = columnname
        new_field.verbose_name = alias_field

        new_field.contribute_to_class(cls, "_%s" % alias_field)
        all_fields.remove(real_field)

    for field in all_fields:
        new_field = deepcopy(model._meta.get_field(field))
        new_field.contribute_to_class(cls, "_%s" % new_field.name)

  def copy_properties(cls):
    # Copy db table
    cls._meta.db_table = model._meta.db_table


  def create_manager(cls):
    from copy import deepcopy
    field = cls._meta.get_field('action_type')
    field.default = cls.__name__
    # Only query on relevant records
    qs = deepcopy(cls.objects)
    cls.objects = qs.filter(action_type=cls.__name__)

  def wrapper(cls):

    # Set it unmanaged
    cls._meta.managed = False

    copy_properties(cls)
    copy_fields(cls)
    create_manager(cls)

    return cls
  return wrapper

Is this ready for production?

I wouldn’t use it in production code, for me it was an exercise to show the flexibility of django, but with sufficient testing it could be used in code if one wants.

Another argument against using in production would be that the code uses a fair amount of internal workings of the django ORM. I am not sure it the api will suffices to be stable.

And this solution is not the best solution you could think up. There are more possibilities to solve this problem of storing dynamic fields in a database.

👤Edgar Klerks

Leave a comment