Hello I use a custom model field to use two database columns.
My idea was to use a field acting as a python descriptor.
class Operators(models.TextChoices):
"""Comparison operators."""
EQ = "="
INF = "<"
SUP = ">"
INF_EQ = "<="
SUP_EQ = ">="
@dataclass
class Comparator:
operator: str = ">="
value: int | float = 1
class ComparatorField(models.Field, property):
"""A field that stores a comparator."""
description = _("Comparator field, example: '> 1', '<= 3'")
def __init__(self, *args, is_decimal: bool = False, **kwargs) -> None:
self.is_decimal = is_decimal
super().__init__(*args, **kwargs)
def deconstruct(self) -> tuple[str, str, Any, Any]:
"""Adds is_decimal to field args."""
name, path, args, kwargs = super().deconstruct()
if self.is_decimal:
kwargs["is_decimal"] = True
return name, path, args, kwargs
@property
def non_db_attrs(self) -> tuple[str, ...]:
return super().non_db_attrs + ("is_decimal",)
def contribute_to_class(
self,
cls: Type[models.Model],
name: str,
*args: Any,
**kwargs: Any,
) -> None:
"""Add operator and value fields to model."""
# Set attributes
self.set_attributes_from_name(name)
# Use a descriptor to access fields as a Comparator instance
setattr(cls, name, self)
# Create fields
operator_field = models.CharField(
max_length=2, choices=Operators.choices, default=Operators.SUP_EQ
)
if self.is_decimal is True:
value_field = models.DecimalField(max_digits=5, decimal_places=2, default=1)
else:
value_field = models.IntegerField(default=1)
# Add fields to model
cls.add_to_class(f"{name}_operator", operator_field)
cls.add_to_class(f"{name}_value", value_field)
def __get__(self, instance: models.Model, owner: Type[models.Model]) -> Comparator:
"""Get a Comparator instance from fields."""
if instance is None:
raise AttributeError("Can only be accessed via an instance.")
return Comparator(
operator=instance.__dict__[f"{self.name}_operator"],
value=instance.__dict__[f"{self.name}_value"],
)
def __set__(self, instance: models.Model, value: Comparator) -> None:
"""Set fields from a Comparator instance."""
if not isinstance(value, Comparator):
raise ValueError("Value must be a Comparator instance.")
instance.__dict__[f"{self.name}_operator"] = value.operator
instance.__dict__[f"{self.name}_value"] = value.value
It’s a custom field to save an operator and a value. Sure it can be easily implemented as two model fields, but this is provided as an example.
The trick is to use setattr(cls, name, self)
to add the descriptor in the model.
Some questions here:
- I’m not sure that
deconstruct
&non_db_attrs
are needed here? - I’m inheriting
property
because of the wayOptions
works
If we defined aComparatorField
on a model and pass it inobjects.create()
or to__init__
we would have aTypeError
: “got unexpected keyword arguments”.
This is because it does not correspond to any model properties or concrete field.
The property_names
property is set in Options
@cached_property
def _property_names(self):
"""Return a set of the names of the properties defined on the model."""
names = []
for name in dir(self.model):
attr = inspect.getattr_static(self.model, name)
if isinstance(attr, property):
names.append(name)
return frozenset(names)
So a way to treat this field as a property is to inherit the property class.
But it looks like code smell. Is there a django way of doing this? The Field class uses
cls._meta.add_field(self, private=private_only)
but I cannot make it work with this field.