Любой, кто писал конструктор запросов или фильтр над пользовательским вводом, знает, как это начинается. Сначала модель данных — это один простой класс, и кажется на этом всё. Через пару месяцев в нём 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/