Перейти к содержанию

Свои поля

Своё поле — подкласс hare.fields.Field.

Что переопределяет поле

Обычно переопределяют:

  • field_type — тип значения поля в Python, атрибут класса: field_type = int.
  • SQL_TYPE — строка или @property.
  • to_db_value(self, value, instance) / from_db_value(self, value) — своё преобразование при записи в базу и чтении из неё; to_lookup_value(self, value, instance) преобразует значение фильтра (по умолчанию — через to_db_value()). to_db_value() преобразует значение и не меняет instance: то, что запись ставит самому объекту (отметку auto_now, слепой индекс), ставится до преобразования значений.
  • to_python(self, value) — приводит к нужному виду значение, только что переданное в Model(**kwargs). Нужен, только если такое значение должно проходить ту же обработку, что from_db_value делает для прочитанного из базы (см. ниже); если пара to_db_value/from_db_value несимметрична (шифрование, маскирование и т. п.), не трогайте его.
  • get_json_value(self, value) — форма значения в JSON, то, что хранит событие outbox у захваченного изменения. По умолчанию пишет собственные значения JSON и значения из таблицы OutboxJsonEncoding, в том числе элементы списков и словарей; поле, у значений которого там нет формы (свой объект), возвращает форму, которую прочтут потребители, — иначе TypeError, и падает запись, а не событие.
  • get_computed_field(self, computation) — поле, которым читается результат, вычисленный из значений поля; computation — ValueComputation (hare.fields.enums: SUM, AVERAGE, STATISTIC, ARITHMETIC). По умолчанию само поле; поле, с которым база считает в более широком типе, возвращает поле этого типа — Float32Field ClickHouse даёт FloatField для суммы, среднего и арифметики.
  • get_column_type_options(self) — что, кроме класса, задаёт тип колонки поля, кортежем: приведение к полю (Cast(value, field)) хранит план отдельно для каждого набора этих параметров. По умолчанию (); ClickhouseDatetimeField ClickHouse отдаёт свою точность и часовой пояс.
  • deconstruct() — для автоматического создания миграций, если поле принимает аргументы конструктора помимо базовых.
  • migration_import_path — неизменный путь к классу через точку, если модуль поля может переехать.
from hare.fields import Field


class MoneyField(Field[int]):
    """Хранит сумму в копейках целым числом."""

    field_type = int
    SQL_TYPE = "BIGINT"

    def to_db_value(self, value, instance):
        self.validate(value)
        return None if value is None else int(value)

    def from_db_value(self, value):
        return None if value is None else int(value)

Только что присвоенные значения: to_python

При создании объекта через Model(**kwargs)/Model.objects.create(**kwargs) каждое значение проходит через to_python(self, value), а не через from_db_value(): значение, прочитанное из базы, и значение, которое только что передал вызывающий код, — разные вещи, хотя обрабатывать их часто нужно одинаково. По умолчанию метод возвращает значение без изменений — это безопасно для любого поля, у которого пара to_db_value/from_db_value не является точным и повторяемым без вреда кодированием и раскодированием уже готового значения Python.

Переопределите to_python() (обычно достаточно вызвать self.from_db_value(value) — то же преобразование, что получает значение из базы), если только что присвоенному значению действительно нужно такое же приведение, например разбор строки ISO в настоящий datetime.

Не переопределяйте его (оставьте поведение по умолчанию), если to_db_value/from_db_value несимметричны — кодирование и раскодирование, которые не отменяют друг друга для уже раскодированного значения, как при шифровании или маскировании. Если применить from_db_value (раскодирование) к значению, которое никогда не проходило через to_db_value, оно тихо испортится в памяти, а следующий save() закодирует уже испорченное значение; у поля с шифрованием это значит, что в базу запишется открытый текст вместо шифротекста.

class EncryptedField(Field[str]):
    field_type = str
    SQL_TYPE = "TEXT"

    def to_db_value(self, value, instance):
        self.validate(value)
        return None if value is None else encrypt(value)

    def from_db_value(self, value):
        return None if value is None else decrypt(value)

    # to_python() не переопределён, и это правильно: Model(secret="hello") должен
    # хранить в памяти "hello", а не вызывать decrypt() для значения, которое никто не шифровал.

Хранение на разных диалектах

У каждого диалекта есть реестр типов: для класса поля он задаёт, как поле хранится, — тип колонки, команду создания первичного ключа, который генерирует база, и приведение типа, которым колонка оборачивается везде, где её сравнивают или сортируют. Класс поля использует запись ближайшего зарегистрированного базового класса, поэтому MoneyField выше везде получает BIGINT, пока диалект не зарегистрирует для него что-то другое:

from hare.dialects.base.types import TypeMapping
from hare.dialects.dialect_registry import DialectRegistry

DialectRegistry.get_dialect("sqlite").types.register(MoneyField, TypeMapping(column_type="INTEGER"))

Поле с COLUMN_TYPE_FROM_DIALECT = True существует там, где типы диалекта дают ему тип колонки (TypeMapping.column_type, зарегистрированный для его класса или класса между ним и тем, что задаёт этот флаг), и больше нигде; модель с ним, привязанная к подключению другого диалекта, даёт UnSupportedError (Hare.init()), как и поле, объявление которого тип диалекта не принимает (PointField(null=True) на ClickHouse: точка там не хранит NULL). Поле одной базы — такое поле, его тип регистрирует диалект этой базы; назвать тип само поле при этом может:

class LtreeField(Field[str]):
    SQL_TYPE = "ltree"
    COLUMN_TYPE_FROM_DIALECT = True


DialectRegistry.get_dialect("postgresql").types.register(
    LtreeField, TypeMapping(column_type=lambda field: field.SQL_TYPE, extension="ltree")
)

Поле, которое хранят несколько диалектов, каждый в своём типе, не называет ни один из них: оно задаёт COLUMN_TYPE_FROM_DIALECT = True и существует там, где типы диалекта дают ему тип колонки (TypeMapping.column_type), — таковы GeometryField и VectorField. Диалект, построенный на типах другого, убирает такое поле пустым описанием: types.register(GeometryField, TypeMapping()).

Всё, чего TypeMapping не задаёт, остаётся за полем:

Атрибут Что задаёт
column_type Тип колонки или функция (field) -> str, которая его строит.
generated_sql DDL первичного ключа, который генерирует база.
function_cast Функция (field, term) -> term, которая оборачивает колонку везде, где её сравнивают, сортируют или копируют.
to_db Функция (field, value, instance) -> value вместо to_db_value() поля.
to_db_refuses_only to_db только отклоняет значение — то, которое native_write_check поля оставляет ему, — а любое другое пишет так же, как поле: ускоритель записи сам пишет все остальные значения (так FloatField в SQLite отклоняет NaN).
to_lookup Функция (field, value, instance) -> value вместо to_lookup_value() поля.
to_python Функция (field, value) -> value вместо from_db_value() поля.
json_term Функция (field, term) -> term, дающая текст, которым значение записывается в объект JSON, — для значения, хранящегося в виде, который JSON не вмещает (UUID в 16 байтах).
naive_datetime_is_utc Собственный from_db_value() поля, а дата-время без пояса от драйвера читается как момент в UTC.
extension Расширение базы, которое нужно типу колонки, — создаётся везде, где используется поле этого класса (postgis для GeometryField, vector для VectorField в PostgreSQL).
inserted_by_select INSERT, записывающий колонку, пишет строки как SELECT из них, а не VALUES, — для значения, записанного выражением, которое VALUES диалекта не читает; или (field) -> bool, решающая это для каждого поля.

Результат читают field.get_column_type(dialect), field.get_generated_sql(dialect) и field.get_function_cast(dialect); поле, которое вычисляет их из собственных настроек, переопределяет эти методы.

Операторы фильтра, пути внутри значения и вычисляемые колонки

Поле, значение которого — не простое скалярное значение, объясняет hare, как по нему фильтровать, переопределяя эти методы Field. Так устроены собственные поля массивов, диапазонов и hstore в hare, поэтому стороннее поле получает те же возможности без изменений в самом hare:

Метод / атрибут Что даёт
get_lookups() Операторы фильтра поля по окончанию ("" — обычное равенство), каждый — FieldLookup, как у register_lookup(); по умолчанию общий набор. Поле со своим набором возвращает его вместо общего, и фильтр с окончанием не из него даёт FieldError. Операторы из register_lookup() добавляются к нему.
get_path_transform(segment) Как часть пути после имени поля читается внутри его значения — (function building the term from the field's term, field of the value read) или None. Этому следуют фильтры (tags__0__startswith), F(), values() и order_by().
get_lookup_value_description(lookup) (LookupValueShape, type) значения оператора, если это не значение типа поля (contains у массива принимает список элементов), — для get_lookup_info(); иначе None.
get_like_text_function() Как значение превращается в текст для contains/icontains/startswith/... и операторов по шаблону; None приводит его к VARCHAR.
get_generated_from_field_names() / with_renamed_generated_from_field(old, new) Другие поля модели (по имени), из которых вычисляется генерируемая колонка: миграция не даст удалить такое поле, пока эта колонка его читает, а переименование поля переименовывает его и здесь.
holds_container_value True для массива, диапазона или значения вроде JSON: вычисляемое значение такого поля получает его собственные операторы фильтра.
holds_intervals True для колонки интервального типа базы: длительность прибавляется к моменту так, как это делает база, а не как число микросекунд.
import operator
from functools import partial

from hare.fields import CharField
from hare.query.filters import FieldLookup
from hare.sql.terms import Function

class CodeField(CharField):
    def get_lookups(self):
        def starts_with(term, value):
            return term.like(f"{value}%")

        return {"": FieldLookup(operator.eq), "prefix": FieldLookup(starts_with)}

    def get_path_transform(self, segment):
        if segment == "upper":
            return partial(Function, "UPPER"), self
        return None

Диалект может добавить часть пути к классам полей, которые определил не он, — как name__unaccent у текстовых полей в PostgreSQL, — через FieldClass.register_transform(segment, get_transform, required_extension=...) — get_transform(field) возвращает ту же пару, что и get_path_transform(), — в своём Dialect.install(), который выполняется один раз при регистрации диалекта.