Невыразимое невалидное. Часть 1. Данные

от автора

Любой, кто писал конструктор запросов или фильтр над пользовательским вводом, знает, как это начинается. Сначала модель данных — это один простой класс, и кажется на этом всё. Через пару месяцев в нём value: Any, рядом валидатор строк на сто, и где-то посередине ветка, которую ты дописал ночью перед релизом, потому что кто-то прислал диапазон туда, где ждали число. Тесты зелёные: на эту комбинацию их просто не было. Падает, разумеется, в проде.

Как не довести до такой ситуации? Способ контринтуитивный: нужно не добавить ещё проверок, а наоборот — сделать так, чтобы неправильное состояние нельзя было даже собрать. Не запретить, а лишить возможности выразить.

У этой мысли есть формулировка, которую вы наверняка слышали: make illegal states unrepresentable, «сделай невалидные состояния невыразимыми». По индустрии её разнёс Ярон Мински из Jane Street, рассказывая, как они пишут на OCaml биржевую торговлю, где лишнее состояние в типе стоит реальных денег. Изначально эта идея из алгебраических типов ML и Haskell — и из обратного примера, который знают все.

Чтобы немного облачить дальнейшие идеи в практические примеры, всё дальше будет крутиться вокруг реального примера типа фильтров для дашборда. Пользователь собирает условие «поле — оператор — значение»: выручка больше тысячи, статус из списка, дата в промежутке. С фронта это прилетает JSON’ом, на бэкенде надо провалидировать, собрать параметризованный SQL и заодно показать человеку текстом, что он нафильтровал. Пока операторов три, всё прекрасно. Когда их два десятка, а типов полей пять, выясняется, что осмысленна хорошо если четверть сочетаний: contains к числу или > к списку — бессмыслица, которую модель тем не менее позволяет собрать.

В этой части будет про сами данные: как описать их так, чтобы мусор не выражался. Поведение (как всё это превратить в SQL и текст), разговор про потолок типов оставим на вторую.

Как обычно начинают (наивная реализация)

Первая версия почти всегда выглядит так:

from enum import StrEnumfrom typing import Anyfrom pydantic import BaseModel, model_validatorclass Operator(StrEnum):    EQ = "="    LT = "<"    GT = ">"    IN_RANGE = "in_range"    IN = "in"    CONTAINS = "contains"class Predicate(BaseModel):    field: Field    operator: Operator    value: Any

Узкое место — value: Any. Оператор < хочет число или дату, in_range — пару границ, in — список строк, contains — строку. Типы про это не знают ничего, поэтому связь «оператор ↔ форма значения» приходится тащить руками:

    @model_validator(mode="after")    def _check(self) -> Predicate:        if self.operator is Operator.IN_RANGE:            if not (isinstance(self.value, (list, tuple)) and len(self.value) == 2):                raise ValueError("in_range требует пару [min, max]")        elif self.operator in (Operator.LT, Operator.GT):            if not isinstance(self.value, (int, float, date)):                raise ValueError(f"{self.operator} требует число или дату")        elif self.operator is Operator.IN:            if not (isinstance(self.value, list) and all(isinstance(x, str) for x in self.value)):                raise ValueError("in требует список строк")        # ... и так для каждого нового оператора и каждого типа поля        return self

Этот валидатор по сути самопальная система типов. Только хуже настоящей: работает в рантайме, и её ещё надо не забыть вызвать. И, главное, саму модель он не лечит. Predicate(operator=Operator.LT, value=["a", "b"]) по-прежнему отлично конструируется — value: Any, чекер слеп. Ошибку поймает либо рантайм, либо ревьюер, либо никто. Чем больше операторов и типов полей, тем длиннее простыня из if, и тем дальше по коду протекает value: Any: каждая следующая функция, получив Predicate, снова не знает, что там внутри, и снова сужает сама. За тем, что удаётся увести в типы, а что остаётся проверкой на рантайме, дальше и любопытно следить.

Перечислить варианты вместо одного «на всё»

Развернём всё ровно наоборот. Вместо одного типа, который умеет всё и оттого не гарантирует ничего, выпишем узкие варианты и в каждом намертво свяжем операторную группу с типом значения:

import typingfrom pydantic import BaseModelclass NumericPredicate(BaseModel):    kind: typing.Literal["numeric"] = "numeric"    field: Field    operator: EqualityOperator | OrderOperator     # =, !=, <, >, <=, >=    value: floatclass NumericRangePredicate(BaseModel):    kind: typing.Literal["numeric_range"] = "numeric_range"    field: Field    operator: RangeOperator                          # in_range, out_of_range    value: Range[float]class SetPredicate(BaseModel):    kind: typing.Literal["set"] = "set"    field: Field    operator: SetOperator                            # in, not_in    value: list[str]

NumericRangePredicate не примет одиночное число: value у него Range[float], и обойти это нельзя. Пара «диапазонный оператор + скаляр» теперь не «запрещена валидатором» — её невозможно набрать руками. Невалидное перестало быть выразимым.

Операторы тоже разнесены по маленьким группам, а не свалены в один enum:

class EqualityOperator(StrEnum):    EQUAL = "="    NOT_EQUAL = "!="class OrderOperator(StrEnum):    LESS_THAN = "<"    GREATER_THAN = ">"    ...class RangeOperator(StrEnum):    IN_RANGE = "in_range"    OUT_OF_RANGE = "out_of_range"class SetOperator(StrEnum):    IN = "in"    NOT_IN = "not_in"

Таких вариантов получается семь: числовой, числовой-диапазон, дата, дата-диапазон, текст, категория, множество. Если захочется терминов, то старая модель была произведением типов «любой оператор × любое значение», декартов квадрат, где осмысленны единицы клеток. Новая же суммой: допустимых состояний ровно столько, сколько выписано. В Rust это enum, в Haskell data, в TypeScript discriminated union; в Python мы только что собрали то же самое почти вручную.

Пространство представимых состояний: до и после

Пространство представимых состояний: до и после

Pydantic в доменной модели?

Здесь можно погрузиться в осуждение, ведь pydantic это про валидацию и сериализацию, инфраструктура, а доменным типам положено быть чистыми, без сторонних зависимостей. Претензия резонная, и до известной границы я её разделяю.

Но pydantic v2 я тут использую не как «валидатор на входе», а как способ описать форму данных. Дискриминированные union’ы, ограничения в Annotated, инварианты прямо рядом с полями. На голых dataclass всё это пришлось бы писать руками и получить тот же pydantic, только свой и с собственными багами. По мне, размен честный: одна зависимость (которую проект всё равно тянет в моем случае) в обмен на невалидные состояния, которые просто не собираются. А если и так перебор, то почти всё переносится на attrs или ручные проверки, будет лишь многословнее.

Один инвариант в типах Python всё равно не выразишь, нельзя проверить, что тип поля совпадает с вариантом предиката (нельзя нацепить числовой предикат на текстовую колонку). Его и оставляем рантайму, в общей базе:

class _PredicateBase(BaseModel):    _field_types: typing.ClassVar[frozenset[FieldType]]    field: Field    @model_validator(mode="after")    def _check_field_type(self) -> typing.Self:        if self.field.type not in self._field_types:            raise ValueError(f"{type(self).__name__} не применим к полю {self.field.type}")        return self

На этом валидатор и кончился. Стострочную простыню заменила одна проверка, а пара «оператор–значение» держится теперь типами, а не дисциплиной.

Собрать варианты в один тип

Семь классов это хорошо, но наружу хочется отдавать один тип Predicate, который умеет (де)сериализоваться. Собираем их в union с дискриминатором:

Predicate = typing.Annotated[    NumericPredicate | NumericRangePredicate | DatePredicate | DateRangePredicate    | TextPredicate | CategoryPredicate | SetPredicate,    Field(discriminator="kind"),]

То самое поле kind: Literal["numeric"], которое я молча приписал каждому варианту, — это и есть дискриминатор, тег. Literal["numeric"] это тип, у которого допустимо ровно одно значение: строка "numeric", не какая-то строка, а именно эта. Он играет роль связующего звена между значением и типом: по значению поля чекер точно знает, с каким вариантом имеет дело.

И тег решает, как union разбирается из JSON. Без него pydantic, получив словарь, примеряет варианты по очереди, пока какой-нибудь не подойдёт: медленно (в худшем случае перебор всех), неоднозначно (под одни данные могут подойти два варианта), и ошибки выходят мутные, без намёка, что не так. С дискриминатором гадания нет: pydantic смотрит на kind, сразу берёт нужный вариант и валидирует только его.

Дискриминатор против перебора вариантов

Дискриминатор против перебора вариантов

Пара мелочей, которые приятно знать. Такие union’ы вкладываются: если предикаты объединяются в булево дерево (узлы AND/OR/NOT, предикаты в листьях), это дерево тоже размечено по kind, и Predicate внутри него размечен сам, pydantic разворачивает вложенную диспетчеризацию без ручной склейки. И ещё: union это не модель, напрямую через Model.model_validate его не провалидируешь; для таких «не-моделей» есть TypeAdapter, оборачивающий любой тип (union, list[str], Range[float]) в готовый валидатор.

Строка, которая не бывает пустой

Варианты собраны и разбираются однозначно, но точность на этом не кончается. Сплошь и рядом нужно поле, которому нельзя быть пустым, имя длиннее нуля символов, email, который хоть как-то похож на email. В лоб это пишут проверкой в коде или валидатором на каждый класс. Но ограничение это часть типа, и его можно туда встроить:

from typing import Annotatedfrom pydantic import FieldNonEmptyString = Annotated[str, Field(min_length=1)]DisplayName    = Annotated[str, Field(min_length=1, max_length=200)]Email          = Annotated[str, Field(pattern=r"^[^@\s]+@[^@\s]+\.[^@\s]+$")]FieldName      = Annotated[str, Field(pattern=r"^[a-z][a-z0-9_]*$")]

Annotated[str, ...] это всё ещё str, но с приклеенным условием. Теперь display_name: DisplayName в модели сам требует непустую строку до двухсот символов, без отдельной проверки в теле класса. Тип стал у́же: вместо «любая строка» — «строка, удовлетворяющая предикату».

У этого есть точное имя — refinement-тип, уточняющий тип: базовый тип плюс предикат, вырезающий из него подмножество. NonEmptyString — это { s : str | len(s) ≥ 1 }, формально подмножество всех строк.

Уточняющий тип как подмножество базового

Уточняющий тип как подмножество базового

В Python это refinement для бедных. Проверку делает pydantic в рантайме, в момент создания модели. А тайп-чекер за NonEmptyString видит обычный str — для mypy Annotated[str, ...] и str неразличимы. Гарантия настоящая, но появляется она на границе валидации, а не при компиляции. Для большинства задач этого ровно достаточно; но запомните это место, на нём будет держаться вторая часть статьи.

Range, но не для чего попало

Диапазон нужен и числам, и датам — но не произвольному T. Сравнивать на «меньше-больше» осмысленно у упорядоченных вещей, и тип может это прямо потребовать. В синтаксисе дженериков PEP 695 (Python 3.12+) выходит так:

class Range[T: (float, datetime.date)](BaseModel):    start: T    end: T

Тонкость, на которой спотыкаются, — в скобках. T: (float, datetime.date) — это не верхняя граница, а ограничение. Разница важная:

  • Граница T: SomeBase читается как «T — это SomeBase или любой его наследник»: подойдёт что угодно ниже по иерархии.

  • Ограничение T: (float, date) читается как «T — это ровно float или ровно date, третьего не дано»: список закрыт.

То есть Range[float] и Range[date] существуют, а Range[str] чекер не пропустит. По сути это маленькая сумма-типов, только на уровне параметров: T пробегает закрытый список.

А упорядоченность снова достаётся рантайму. Что start ≤ end, типами Python не выразить, для этого нужны типы, зависящие от значений, поэтому это опять проверка при создании:

    @model_validator(mode="after")    def _check_order(self) -> Self:        if self.end < self.start:            raise ValueError("начало диапазона не должно превышать конец")        return self

Что в итоге

Данные стали точными. Вместо одного типа с value: Any мы получили сумму узких вариантов, где неправильную пару «оператор–значение» просто негде написать; тег делает разбор однозначным; строки умеют быть непустыми; Range параметризован, но не чем попало.

Часть гарантий мы увели в типы — но не все. Тип поля совпадает с вариантом, min_length, start ≤ end это по-прежнему проверки в рантайме. И про поведение мы пока не сказали ни слова: фильтр умеет существовать, но не превращаться в SQL или текст. И то, и другое — во второй части: куда вешать поведение (и почему это целая «проблема выражения»), как не дать рантайм-проверкам тихо разъехаться и где вообще проходит потолок системы типов.

ссылка на оригинал статьи https://habr.com/ru/articles/1049560/