Custom model field with two db columns

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 way Options works
    If we defined a ComparatorField on a model and pass it in objects.create() or to __init__ we would have a TypeError : “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.

Can you explain, what real issue behind you are trying to solve?

To me it currently looks like you mix field values with SQL evaluation (much like a wanted explicit SQL injection), something that the ORM wont let you do easily (you can get there with a custom Func, still I am unsure if this isnt an xy problem).