# ORM — полная документация > PostgreSQL-нативная ORM для Go. Структуры ваши, схема — PostgreSQL, а генератор доказывает, что они согласованы. Перед тем как писать код, прочитайте /api/orm.txt. Он порождён из самих пакетов и перечисляет каждый экспортированный символ. Если имени там нет — его не существует, каким бы правдоподобным оно ни выглядело. https://ormgo.vercel.app/api/orm.txt --- # Введение > Что делает эта библиотека, чего она принципиально не делает и почему разница важна. https://ormgo.vercel.app/ru/docs/introduction/ ## Тезис > Структуры — ваши. Схема — PostgreSQL. Генератор доказывает, что они совпадают. Большинство Go-мапперов выбирают сторону. Либо структуры — источник истины, и схема порождается из них, либо наоборот. Оба направления дают код, который кто-то руками держит в согласии, как только реальность расходится. Здесь ничего не порождается из другого. Структуры пишете вы. Схемой владеют миграции. Команда `orm` читает и то и другое, показывает каждое расхождение и генерирует типизированные метаданные **только из отображения, которое смогла доказать**. Следствие и есть смысл: если запрос компилируется, база сможет на него ответить. ## Что это даёт Сгенерированные дескрипторы несут параметры типов, поэтому компилятор требует того, что доказала сверка: ```go // Predicate[User] не дотянется до запроса по Post. db.Orders.Query().Where(Users.Email.Eq("a@example.com")) // не компилируется // У текстовой колонки есть Like. У целочисленной нет. Users.Email.ILike("%@example.com") // нормально Users.Age.ILike("%") // не компилируется // У NOT NULL колонки нет IsNull. Users.Bio.IsNull() // нормально: bio nullable Users.Email.IsNull() // не компилируется ``` Это не соглашение об именовании и не правило линтера. Это следует из типа дескриптора, который пришёл из каталога. ## Чего она не делает Инструмент определяется и тем, чего он делать не станет: - **Нет ленивой загрузки.** Связь загружается потому, что вы её попросили. Цикл по срезу не превратится незаметно в запрос на строку. - **Нет отслеживания изменений, identity map и `Save`.** `Insert`, `Update` и `Delete` выражают намерение. Ничего не додумывается. - **Нет неявной обработки нулевых значений.** Структура с `Active: false` сохранит `false`, потому что библиотека не отличит это от поля, которого никто не касался. Попросить значение по умолчанию — это [`orm.Default`](/ru/docs/writing/), и это отдельное явное действие. - **Нет сборки SQL из строк.** Любое значение становится параметром привязки. `Expr` и `Raw` принимают текст SQL намеренно; ни один не принимает значения, вставленные в него. - **Нет случайных `UPDATE` без `WHERE`.** Обновление или удаление без условий отвергается с `ErrMissingWhere`, если только `All` не сказал, что имелись в виду все строки. ## Откуда берутся гарантии Три слоя, у каждого своя работа. | Слой | Владеет | Доказывает | | --- | --- | --- | | Миграции | Схемой | Что базу можно собрать с нуля | | Сверка | Отображением | Что у каждого поля есть колонка совместимого типа | | Сгенерированный код | Дескрипторами | Что неверный запрос не компилируется | Если сверка не может доказать поле, дескриптор для него не появится — будет ошибка с именем поля, колонки и способом починки. Отката к `any` нет. ## Поддерживаемые версии PostgreSQL 14, 15, 16, 17 и 18. Не «должно работать»: набор тестов совместимости отказывается запускаться меньше чем на всех пяти, потому что заявленной, но ни разу не проверенной версии верить нельзя. ## Куда дальше - [Установка](/ru/docs/installation/) — модуль и CLI. - [Быстрый старт](/ru/docs/quickstart/) — схема, структура и запрос за несколько минут. - [Ключевые идеи](/ru/docs/concepts/) — словарь, которым пользуется остальная документация. ## Разобранные примеры Пример того, что делает за вас компилятор, — в четырёх несвязанных схемах. ```go // Магазин. У текста есть Like, и компилятор это знает, потому что так сказал каталог. db.Products.Query().Where(Products.Name.ILike("%lamp%")) // Бухгалтерия. Проверка баланса в WHERE, поэтому овердрафт — это обновление, // не нашедшее строк, а не гонка. db.Accounts.Update(). Set(Accounts.Balance.SetExpr(Accounts.Balance.Sub(amount))). Where(Accounts.ID.Eq(id)). Where(Accounts.Balance.Gte(amount)). Exec(ctx) // Календарь. Пересечение — один предикат, а не четыре сравнения, которые надо не перепутать. db.Bookings.Query().Where(Bookings.During.Overlaps(orm.ClosedOpen(from, to))) // Автопарк. Три уровня за три запроса, сколько бы ни было строк. db.Depots.Query().With(Depots.Vehicles.With(Vehicles.Services)).All(ctx) ``` И четыре вещи, которые не компилируются, — то же утверждение с другой стороны: ```go Products.PriceCents.ILike("%") // у целого числа нет ILike Products.Name.IsNull() // name объявлена NOT NULL db.Accounts.Query().Where(Products.Name.Eq("x")) // не та сущность orm.UnionAll[Row](byEmail, byAge) // ветви расходятся по форме ``` --- # Установка > Модуль, CLI и файл конфигурации. https://ormgo.vercel.app/ru/docs/installation/ ## Модуль ```bash go get github.com/AlexAli29/orm ``` Рантайм зависит только от стандартной библиотеки и `github.com/jackc/pgx/v5`. Эта граница проверяется тестом, поэтому она не размывается со временем. ## CLI Генератор и планировщик миграций — один бинарник: ```bash go install github.com/AlexAli29/orm/cmd/orm@latest ``` Или без установки — так это обычно и попадает в `Makefile`: ```bash go run github.com/AlexAli29/orm/cmd/orm generate ``` Зафиксируйте версию в `tools.go`, если хотите отслеживать её вместе со всем остальным. ## Дополнительные адаптеры Каждый — отдельный модуль, поэтому проект, который им не пользуется, его и не компилирует: ```bash go get github.com/AlexAli29/orm/ormotel # трейсинг OpenTelemetry go get github.com/AlexAli29/orm/ormtest/postgres # помощники Testcontainers ``` `ormslog`, `ormhealth` и `ormtest` лежат в основном модуле и ничего не стоят, пока не импортированы. ## orm.yaml Файл конфигурации лежит в корне проекта: ```yaml version: 1 schema: # managed: схемой владеют декларации, миграции её применяют. # Уберите mode для режима database-first, где авторитетна база. mode: managed dsn: ${DATABASE_URL} search_path: - public migrations: dir: migrations packages: - path: ./internal/domain output: same # Типы, которых в Go нет, приходят через конфигурацию. Библиотека отказывается # выбирать за вас пакет для uuid: популярные варианты не взаимозаменяемы. types: uuid: go: github.com/google/uuid.UUID codec: uuid ``` `${DATABASE_URL}` подставляется из окружения, поэтому файл не содержит секретов и его можно коммитить. ## Проверка установки ```bash orm check ``` В пустом проекте это сообщит, что деклараций не найдено — правильный ответ, доказывающий, что CLI дотянулся до базы. ## Разобранные примеры ### Database-first, поверх существующей базы Ни `mode`, ни каталога миграций: авторитетна база, а вы пишете декларации, описывающие её: ```yaml version: 1 schema: dsn: ${DATABASE_URL} search_path: - public - reporting packages: - path: ./internal/domain output: same ``` ### Managed с несколькими ограниченными контекстами У каждого контекста свой пакет, и генератор пишет рядом с каждым: ```yaml version: 1 schema: mode: managed dsn: ${DATABASE_URL} search_path: - public - billing - identity migrations: dir: migrations packages: - path: ./internal/billing/domain output: same - path: ./internal/identity/domain output: same - path: ./internal/catalog/domain output: same ``` Два контекста могут владеть таблицами с одинаковым именем в разных схемах: у них будут разные дескрипторы и разное состояние миграций. ### Типы, которых в Go нет ```yaml types: uuid: go: github.com/google/uuid.UUID codec: uuid numeric: go: github.com/shopspring/decimal.Decimal codec: decimal ``` Это те два, которые библиотека отказывается выбирать за вас: популярные пакеты не взаимозаменяемы, а неверный `numeric` тихо портит деньги. ### Makefile, который держит всё в согласии ```makefile generate: go run github.com/AlexAli29/orm/cmd/orm makemigrations go run github.com/AlexAli29/orm/cmd/orm migrate go run github.com/AlexAli29/orm/cmd/orm generate check: go run github.com/AlexAli29/orm/cmd/orm makemigrations --check go run github.com/AlexAli29/orm/cmd/orm check --generated ``` ## Запуск CLI из контейнера Если ставить Go на CI-раннер не хочется, CLI публикуется образом. Их два, потому что командам нужно разное: ```console $ docker run --rm -v "$PWD":/work -e DATABASE_URL \ ghcr.io/alexali29/orm:latest migrate --config /work/orm.yaml Applying 0001_initial ... OK ``` `ghcr.io/alexali29/orm` — это CLI на distroless: около 16 МБ, без оболочки, без libc, запуск не от root. Он покрывает `migrate`, `sqlmigrate` и `inspect` — набор для развёртывания. `ghcr.io/alexali29/orm:latest-toolchain` добавляет Go. `check`, `generate` и `makemigrations` читают исходники сущностей через `go list`, поэтому им нужен тулчейн и модуль, который разрешается, — а смонтировать свой модуль недостаточно, если его зависимостей рядом нет: ```console $ docker run --rm -v "$PWD":/work -e DATABASE_URL \ ghcr.io/alexali29/orm:latest-toolchain check --config /work/orm.yaml reconciliation clean: the entities and the schema agree ``` Если попросить у маленького образа одну из этих трёх команд, он сразу падает и говорит почему — `go command required, not found`, — а не выдаёт неверный ответ. Ради этого разделение и существует, вместо одного образа, который иногда работает. Оба собираются под `linux/amd64` и `linux/arm64`. Теги следуют за релизами: `1.2.3`, `1.2`, `latest` и `edge` для вершины main. ### В пайплайне ```yaml migrate: image: ghcr.io/alexali29/orm:latest script: - orm migrate --config orm.yaml ``` Точка входа — сам бинарник, поэтому аргументы и есть команда. Оболочка нигде не нужна, что ценно в шаге, окружение которого держит строку подключения к базе. --- # Быстрый старт > От пустого каталога до типизированного запроса. https://ormgo.vercel.app/ru/docs/quickstart/ Здесь описан режим managed, где схемой владеют декларации. Про database-first — существующую базу, на которую вы наводите генератор, — в конце. ## 1. Опишите сущность ```go // internal/domain/entities.go package domain import "time" //orm:table public.users type User struct { ID int64 `orm:"pk,identity"` Email string `orm:"unique"` Bio *string Active bool CreatedAt time.Time } ``` Указатель означает nullable-колонку. `orm:"pk"` задаёт первичный ключ, `identity` говорит, что значение генерирует PostgreSQL. ## 2. Спланируйте и примените миграцию ```bash orm makemigrations orm migrate ``` `makemigrations` сравнивает декларации со схемой, которую описывают существующие миграции, и пишет артефакт. `migrate` применяет его в транзакции и записывает в историю. Посмотрите план до применения: ```bash orm makemigrations --dry-run --sql ``` ## 3. Сгенерируйте ```bash orm generate ``` Команда читает базу, сверяет её с декларациями и пишет дескрипторы рядом с сущностями. Для поля, которое не удалось доказать, не генерируется ничего. ## 4. Запрос ```go package main import ( "context" "log" "os" "github.com/jackc/pgx/v5/pgxpool" "example.com/app/internal/domain" ) func main() { ctx := context.Background() pool, err := pgxpool.New(ctx, os.Getenv("DATABASE_URL")) if err != nil { log.Fatal(err) } defer pool.Close() db := domain.New(pool) users, err := db.Users.Query(). Where(domain.Users.Active.Eq(true)). OrderBy(domain.Users.CreatedAt.Desc()). Limit(20). All(ctx) if err != nil { log.Fatal(err) } log.Printf("%d users", len(users)) } ``` ## 5. Держите это честным в CI Две команды должны быть в каждом пайплайне: ```bash orm makemigrations --check # декларация без миграции роняет сборку orm check --generated # закоммиченный сгенерированный код актуален ``` ## Вместо этого — database-first Уберите `mode: managed` и наведите `dsn` на существующую базу. Тогда вы пишете декларации, описывающие то, что уже есть, а `orm check` показывает расхождения. Миграции не генерируются: авторитетна база. ## Разобранные примеры ### Те же пять шагов в другой форме Сервис подписок вместо таблицы пользователей — чтобы показать, что шаги определяются формой работы, а не схемой. ```go // internal/domain/entities.go package domain import "time" //orm:table public.plans //orm:index plans_code_key (Code) unique type Plan struct { ID int64 `orm:"pk,identity"` Code string Cents int32 } //orm:table public.subscriptions //orm:index subs_customer_idx (CustomerID, StartedAt) type Subscription struct { ID int64 `orm:"pk,identity"` CustomerID int64 PlanID int64 StartedAt time.Time `orm:"default:now()"` CancelledAt *time.Time Plan orm.One[Plan] `orm:"fk:plan_id"` } ``` ```bash orm makemigrations && orm migrate && orm generate ``` ```go // Активные подписки вместе с тарифом, сначала свежие. subs, err := db.Subscriptions.Query(). Where(Subscriptions.CancelledAt.IsNull()). With(Subscriptions.Plan). OrderBy(Subscriptions.StartedAt.Desc()). Limit(50). All(ctx) // Выручка по кодам тарифов, один запрос. var revenue = orm.Project2( Plans.Code, orm.Count[Subscription](), func(code string, n int64) Row { return Row{code, n} }, ) ``` `CancelledAt` — указатель, поэтому у него есть `IsNull`, и он читается как «не отменена». У `StartedAt`, объявленной `NOT NULL`, этого метода просто нет, и ошибиться нечем. --- # Ключевые идеи > Словарь, которым пользуется остальная документация. https://ormgo.vercel.app/ru/docs/concepts/ ## Сущность (entity) Go-структура с директивой `//orm:table`. Её пишете вы; ничто её не генерирует. ## Дескриптор Сгенерированная типизированная ручка колонки: `Users.Email`, `Orders.Placed`. Её Go-тип кодирует то, что сказал каталог, — тип значения, nullable ли она и какие сравнения PostgreSQL для неё определяет. ```go Users.Email // orm.TextCol[User] — есть Like, ILike Users.ID // orm.OrdCol[User, int64] — есть Gt, Between, Asc Users.Bio // orm.NullTextCol[User] — есть IsNull Users.Tags // orm.Col[User, []string] — только равенство ``` Дескрипторы доступны только для чтения и безопасны для совместного использования. Всё, что похоже на мутацию — алиас таблицы, настройка связи, — возвращает копию. ## Capability Что тип колонки умеет в SQL, а не что с ним умеет Go. `uuid` упорядочен, потому что его упорядочивает PostgreSQL; `jsonb` — нет, потому что сравнение двух jsonb-документов не отвечает ни на чей вопрос. Поэтому порядок не выводится из Go-шного `cmp.Ordered`. ## Источник (source) Одно вхождение отношения в запрос. Алиас таблицы порождает второй источник, и дескриптор, построенный от одного, нельзя применить к другому — именно это делает self-join безопасным. ## Repo `Repo[E]` связывает сгенерированные метаданные с исполнителем: `*pgxpool.Pool`, `*pgx.Conn` или `pgx.Tx`. Сгенерированный код даёт структуру `DB`, где по одному на сущность. ## Query, SelectQuery, ComposedQuery Три строителя, три задачи: - `Query[E]` читает целые сущности из одной таблицы. - `SelectQuery[E, R]` читает проекцию — выбранную форму результата — из одной таблицы. - `ComposedQuery[R]` читает проекцию из источников, которые вы собрали сами: джойны, CTE, производные таблицы. Все трое изменяемы, одноразовы и не потокобезопасны. `Clone` ответвляет копию. ## Проекция Две вещи вместе: **какие выражения выбрать** и **функция, превращающая эти значения в ваш тип результата**. `Project2` принимает два выражения, поэтому его функция принимает два параметра — в том же порядке и с теми типами, которые имеют эти колонки. ```go type Summary struct { ID int64 Email string } var Summaries = orm.Project2( Users.ID, // 1-е выражение → 1-й параметр, int64, потому что id — bigint Users.Email, // 2-е выражение → 2-й параметр, string, потому что email — text func(id int64, email string) Summary { return Summary{ID: id, Email: email} }, ) rows, _ := orm.Select(db.Users, Summaries).All(ctx) // []Summary — SELECT id, email FROM users ``` Это значение, а не запрос: постройте одну и используйте из многих запросов. Подробно — в разделе [Проекции](/ru/docs/projections/). ## Nullability и откуда она берётся Значение становится nullable по двум разным причинам: 1. **Колонка** nullable. `Users.Bio` — это `NullTextCol`. 2. **Запрос** делает её такой. Колонка `NOT NULL`, прочитанная через `LEFT JOIN`, может быть NULL для строки без совпадения. Второе — nullability, наведённая источником. Ради неё существует `orm.Opt`, и поэтому список выборки, читающий outer-joined источник через `orm.Of`, отвергается. ## Ошибки Ошибки-сентинелы (`ErrNotFound`, `ErrMissingWhere`) оборачиваются, а не подменяются, поэтому `errors.Is` работает сквозь контекст каждого слоя, а собственный `*pgconn.PgError` остаётся доступен через `errors.As`. Ни один экспортированный API не паникует из-за плохого запроса, ошибки базы или неудачного сканирования. ## Разобранные примеры ### Как читать тип дескриптора Тип и есть документация. Когда непонятно, что умеет колонка, объявление отвечает: ```go Products.Name // orm.TextCol[Product] — Like, ILike Products.PriceCents // orm.OrdCol[Product, int32] — Gt, Between, Asc Products.Discount // orm.NullOrdCol[Product, int32] — то же плюс IsNull Products.Tags // orm.Col[Product, []string] — только равенство Products.Meta // orm.Col[Product, map[string]any] — только равенство ``` Метод, который вы ожидали и не нашли, — обычно ответ «PostgreSQL не определяет этого для такого типа». ### Один источник или два ```go managers := Employees.As("mgr") orm.Compose(pool, shape). From(Employees.Source()). LeftJoin(managers.Source(), orm.Eq(managers.ID, Employees.ManagerID)) ``` `Employees.ID` и `managers.ID` — одна и та же колонка двух разных вхождений, и компилятор не позволит подменить одно другим. Именно это делает self-join безопасным, а не упражнением в именовании. ### Три состояния связи ```go p, _ := db.Products.Query().Where(Products.ID.Eq(id)).One(ctx) reviews, ok := p.Reviews.Get() switch { case !ok: // не загружено — никто не просил case len(reviews) == 0: // загружено, и их действительно нет default: // загружено, и вот они } ``` Первый случай другие библиотеки схлопывают во второй — так «отзывов нет» оказывается на странице, которая отзывов не запрашивала. --- # Документация для агентов > Документация в виде обычного текста и порождённый список символов — для кодовых ассистентов. https://ormgo.vercel.app/ru/docs/agents/ ## Что доступно Всё, что есть на этом сайте, публикуется и обычным текстом: кодовый ассистент получает один URL и то, что за ним лежит, — а за страницей иначе лежит React-приложение. | URL | Что это | | --- | --- | | [`/llms.txt`](/llms.txt) | Указатель. Каждая страница — одной строкой, с описанием. | | [`/llms-full.txt`](/llms-full.txt) | Вся английская документация целиком, в порядке навигации. Один запрос. | | [`/llms-full.ru.txt`](/llms-full.ru.txt) | То же на русском. | | [`/api/orm.txt`](/api/orm.txt) | Каждый экспортированный символ библиотеки. | | `…/<страница>.md` | Любая страница как её исходный markdown. | Последнее — суффикс, а не отдельный сайт. Допишите `.md` к адресу страницы и получите тот markdown, из которого она отрисована: ```text https://ormgo.vercel.app/en/docs/projections/ the page https://ormgo.vercel.app/en/docs/projections.md its source ``` Каждая отрисованная страница к тому же объявляет свой markdown ссылкой `rel="alternate"`, так что инструменту, который такие ссылки ищет, не нужно заранее знать соглашение. ## Начинать стоит со списка символов Если читать один файл — читайте [`/api/orm.txt`](/api/orm.txt). Каждая строка кода, написанная под библиотеку, — это догадка о том, какие имена существуют, и дорого обходятся именно правдоподобные догадки. `orm.Returning` выглядит как функция — а это обобщённый тип. `EqCol` выглядит как очевидный способ сравнить две колонки. `Users.Table()` выглядит как то, что есть у любой ORM. Ничего из этого здесь нет, и по форме запроса это не видно до тех пор, пока не скажет компилятор. `orm.txt` порождается из самих пакетов тем же инструментом, которым CI сравнивает публичный API, поэтому он исчерпывающий, а не выборочный: ```text package github.com/AlexAli29/orm const BoundEmpty BoundKind = 0 func Project12[E any, T1 any, ...](...) method (*ViewRepo) Query() *Query[E] ``` Правило, которое из этого следует, достаточно короткое, чтобы отдать его ассистенту как есть: **если имени нет в `orm.txt` — его не существует.** Правдоподобие тут ничего не меняет, а проверка — это поиск, а не сборка. Два меньших манифеста покрывают пакеты, которые не являются самой ORM: [`/api/ormtest-postgres.txt`](/api/ormtest-postgres.txt) — тестовые помощники, а [`/api/ormotel.txt`](/api/ormotel.txt) — интеграция с OpenTelemetry. ## Как направить инструмент Большинство ассистентов читают файл с инструкциями проекта — `CLAUDE.md`, `AGENTS.md`, правило Cursor. Трёх строк в таком файле достаточно: ```text This project uses github.com/AlexAli29/orm. Docs: https://ormgo.vercel.app/llms.txt Symbols: https://ormgo.vercel.app/api/orm.txt Before using any orm.* name, confirm it appears in api/orm.txt. If it is not there it does not exist, however plausible it looks. Do not guess at method names on generated columns — the generator decides them, and the manifest lists them. ``` По умолчанию лучше указывать на `llms.txt`, а не на `llms-full.txt`: указатель маленький и позволяет ассистенту забрать ровно ту страницу, которая нужна, вместо того чтобы носить с собой весь справочник. `llms-full.txt` пригодится, когда инструмент не умеет ходить по ссылкам или когда проще заплатить один раз за всё сразу. ## Что порождается и когда Ничего по этим адресам не написано руками. Список страниц — это навигация самого сайта, текст — тот же markdown, из которого рендерятся страницы, а манифесты копируются из репозитория, где CI перегенерирует и сравнивает их при каждом изменении публичного API. Отсюда следствие, которое стоит сказать прямо: текстовая документация не может отстать от сайта, потому что собирается из него в том же шаге. Она может быть настолько же свежей, насколько свеж деплой, — та же гарантия, что и у HTML. ## Разобранные примеры ### Проверить имя до того, как его использовать Вопрос, который ассистенту стоит задать перед тем, как написать `orm.Something`: ```text $ curl -s https://ormgo.vercel.app/api/orm.txt | grep '^type Returning' type Returning[E any, R any] struct ``` Это тип, причём с двумя параметрами, — значит, вызова `orm.Returning(Summaries)` не существует, а добраться до него можно через `orm.UpdateReturning(upd, shape)`. Манифест сказал это раньше компилятора. ### Забрать одну страницу вместо всего справочника Ассистенту, которого попросили добавить материализованное представление, нужна одна страница, а не тридцать: ```text https://ormgo.vercel.app/en/docs/views.md ``` Файл начинается с заголовка страницы, её описания, канонического адреса и ссылки на список символов, дальше идёт markdown — двадцатая часть того, во что обошёлся бы весь справочник. ### Отдать ревьюеру всё сразу Проходу по большому диффу нужен весь справочник в контексте и не нужны тридцать запросов: ```text https://ormgo.vercel.app/llms-full.txt ``` Один файл, все английские страницы, в порядке навигации. ### Работа на русском Русская документация — перевод текста, а не кода: каждый пример побайтово совпадает в обеих языковых версиях, и это проверяется тестом. Ассистент, читающий `/llms-full.ru.txt`, получает русские объяснения того же Go, что и в английских страницах: ```text https://ormgo.vercel.app/llms.ru.txt https://ormgo.vercel.app/llms-full.ru.txt ``` --- # Сущности и теги > Директивы и теги структур, которые читает генератор. https://ormgo.vercel.app/ru/docs/entities/ ## Директивы Директива — это комментарий над типом. Она говорит, чем тип является. ```go //orm:table public.users type User struct { /* ... */ } //orm:view public.active_users //orm:definition `SELECT id, email FROM users WHERE active` //orm:depends-on public.users type ActiveUser struct { /* ... */ } //orm:materialized-view public.user_summaries //orm:definition `SELECT user_id, count(*) AS orders FROM user_orders GROUP BY user_id` //orm:depends-on public.user_orders //orm:index user_summaries_key (UserID) unique type UserSummary struct { /* ... */ } ``` Имя отношения указывается со схемой. `public` не подразумевается: в проекте с двумя схемами у одного имени было бы два смысла. ## Грамматика тегов Всё остальное — теги структуры под ключом `orm`: | Директива | Значение | | --- | --- | | `pk` | Часть первичного ключа | | `identity` | Значение генерирует PostgreSQL (`identity:always` — `GENERATED ALWAYS`) | | `unique` | Уникальное ограничение по одной колонке | | `column:name` | Имя колонки, если отличается от поля | | `pgtype:uuid` | Тип PostgreSQL, если Go-тип его не задаёт | | `type:name` | Ключ настроенного отображения типов | | `default:expr` | `DEFAULT` колонки | | `generated:expr` | Генерируемая колонка | | `fk:user_id` | Колонка внешнего ключа для связи | | `side:...` | Сторона связи | | `ondelete:cascade` | Действие `ON DELETE` для связи | | `onupdate:cascade` | Действие `ON UPDATE` для связи | | `-` | Полностью игнорировать поле | ```go //orm:table public.orders //orm:index orders_user_idx (UserID) type Order struct { ID uuid.UUID `orm:"pk,pgtype:uuid"` UserID uuid.UUID `orm:"pgtype:uuid"` Label string `orm:"column:title"` Total string `orm:"pgtype:numeric"` CreatedAt time.Time `orm:"default:now()"` Internal string `orm:"-"` User orm.One[User] `orm:"fk:user_id"` } ``` ### Ссылочные действия `ondelete` и `onupdate` принимают `cascade`, `restrict`, `setnull`, `setdefault` или `noaction`. Пишутся без пробела, потому что для всего, что читает структурный тег, это один токен: ```go //orm:table comments type Comment struct { ID int64 `orm:"pk,identity"` PostID int64 // Deleting a post deletes its comments, in the database, in one statement. Post orm.One[Post] `orm:"side:local,ondelete:cascade"` // Deleting a user with comments is refused instead. Author orm.One[User] `orm:"side:local,ondelete:restrict"` } ``` Читаются только в управляемом режиме. В database-first ограничение уже существует, и решает ответ PostgreSQL, так что тег с другим требованием был бы пожеланием, а не фактом. Ничего не написать — значит `NO ACTION`, умолчание PostgreSQL, и в управляемом режиме это утверждение, а не отсутствие. База, где ограничение говорит `CASCADE`, и объявление, которое молчит, расходятся, и `makemigrations` запланирует замену каскада. Если вы переводите на управляемый режим базу, где каскад уже есть, напишите тег до первого плана. Это каскад уровня базы данных — не то же самое, что каскады уровня приложения, которых в этой библиотеке нет. Схемой владеет PostgreSQL, и `ON DELETE CASCADE` — часть схемы; ничто здесь не удаляет строки на стороне Go за вас. ## Nullability Указатель — это nullable-колонка. Больше знать нечего: ```go Bio *string // bio text OptionalID *uuid.UUID // optional_id uuid Tags []string // tags text[] NOT NULL ``` Пустой срез и NULL-массив — разные значения, и библиотека их различает. Если колонка-массив nullable, пишите `*[]string`. ## Нулевые значения — это значения ```go db.Users.Insert(ctx, User{Active: false}) // сохранит FALSE db.Users.Insert(ctx, User{}, orm.Default(Users.Active)) // сохранит DEFAULT колонки ``` Библиотека не отличит «false» от «поля, которого никто не касался», а угадывание — это способ получить строку со значением, которого никто не выбирал. Запрос значения по умолчанию — отдельное явное действие. ## Связи `One` и `Many` объявляют связи и различают три состояния: не загружено, загружено и пусто, загружено и есть. ```go type User struct { ID int64 `orm:"pk,identity"` Orders orm.Many[Order] } type Order struct { ID int64 `orm:"pk,identity"` UserID int64 User orm.One[User] `orm:"fk:user_id"` } ``` Нулевое значение — «не загружено», поэтому литерал структуры без связи говорит «я этого не просил», а не «там пусто». Читается через `Get() ([]T, bool)` или `MustGet()`. ## Индексы Объявляются на типе, потому что индекс принадлежит отношению, а не колонке: ```go //orm:index users_email_key (Email) unique //orm:index users_active_idx (Active, CreatedAt) //orm:index users_lower_email_idx ("lower(email)") //orm:index users_tags_gin_idx (Tags) using gin //orm:index users_paid_idx (CreatedAt) where "paid_at IS NOT NULL" ``` Поля называются по Go-именам; строка в кавычках — выражение SQL. ## Разобранные примеры ### Мультиарендная таблица Всё, что нужно колонке арендатора: тег, составной ключ и индекс, который делает выборку ограниченной, а не отфильтрованной. ```go //orm:table public.documents //orm:index documents_tenant_idx (TenantID, UpdatedAt) //orm:index documents_slug_key (TenantID, Slug) unique type Document struct { TenantID int64 `orm:"pk"` ID int64 `orm:"pk,identity"` Slug string Title string Body *string UpdatedAt time.Time `orm:"default:now()"` } ``` Два поля с `pk` — это составной ключ. Уникальный индекс построен по паре, поэтому два арендатора могут использовать один и тот же slug, а один арендатор — нет. ### Таблица, где колонки названы иначе ```go //orm:table billing.invoice_lines type InvoiceLine struct { ID int64 `orm:"pk,identity"` InvoiceID int64 `orm:"column:inv_id"` Cents int32 `orm:"column:amount_cents"` Note string `orm:"-"` // вообще не колонка } ``` `column:` — для схемы, которую выбирали не вы. `-` — для поля, которое ваше и только ваше: кэш, помощник форматирования; генератор его искать не станет. ### Генерируемые колонки и значения по умолчанию ```go //orm:table public.people type Person struct { ID int64 `orm:"pk,identity:always"` First string Last string Full string `orm:"generated:first || ' ' || last"` JoinedAt time.Time `orm:"default:now()"` Ref uuid.UUID `orm:"pgtype:uuid,default:gen_random_uuid()"` } ``` `identity:always` означает, что PostgreSQL отвергнет присланное вами значение, — это строже обычного `identity`. ### Индексы, которые стоит объявлять ```go //orm:index orders_open_idx (PlacedAt) where "shipped_at IS NULL" //orm:index orders_lower_ref_idx ("lower(reference)") //orm:index orders_tags_gin_idx (Tags) using gin ``` Частичный индекс по открытым заказам меньше индекса по всем и остаётся маленьким, пока таблица растёт. --- # Отображение типов > Как тип PostgreSQL становится типом Go и что происходит, когда эквивалента нет. https://ormgo.vercel.app/ru/docs/types/ ## Встроенные скаляры Им не нужна настройка. Справа — тип, который выдаёт генератор. | PostgreSQL | Go | | --- | --- | | `bool` | `bool` | | `int2`, `int4`, `int8` | `int16`, `int32`, `int64` | | `float4`, `float8` | `float32`, `float64` | | `text`, `varchar`, `bpchar`, `citext`, `name` | `string` | | `bytea` | `[]byte` | | `date`, `timestamp`, `timestamptz` | `time.Time` | | `uuid` | *настраивается* | | `numeric` | *настраивается* | | `json`, `jsonb` | `orm.JSON` / `orm.JSONB` | | `inet`, `cidr` | `netip.Prefix` / `netip.Addr` | | `macaddr` | `net.HardwareAddr` | | `interval` | `orm.Interval` | | `tsvector`, `tsquery` | `orm.TSVector`, `orm.TSQuery` | | `int4range`, `daterange`, … | `orm.Range[T]` | | `int4multirange`, … | `orm.Multirange[T]` | | `T[]` | `[]T` | ## Типов, которых в Go нет Два из них отвергаются, а не угадываются, и отказ здесь — это фича. ### numeric Для десятичного числа произвольной точности в Go нет типа без потерь, а отображение в `float64` тихо испортило бы деньги. Поэтому его нужно настроить: ```yaml types: numeric: go: github.com/shopspring/decimal.Decimal codec: decimal ``` ### uuid В Go нет типа `uuid`, а популярные сторонние не взаимозаменяемы. Библиотека отказывается выбирать; выбирает проект, и этот выбор — зависимость проекта: ```yaml types: uuid: go: github.com/google/uuid.UUID codec: uuid ``` Сама библиотека никогда не зависит от `google/uuid`. Это проверяется в CI, потому что обязательная зависимость от uuid — ровно то, ради чего существуют настраиваемые отображения. ## Одна асимметрия, о которой стоит знать Настроенное отображение работает в одну сторону. | Режим | Тег | Результат | | --- | --- | --- | | Database-first | нет | работает — `uuid` → `uuid.UUID` | | Managed | нет | **отказ** — нет типа PostgreSQL для `uuid.UUID` | | Managed | `pgtype:uuid` | работает | Database-first начинает с типа PostgreSQL и ищет Go-тип, поэтому отображение применяется само. Managed начинает с Go-типа, а обратного поиска нет: конфигурация, отображающая два Go-типа в один тип PostgreSQL, дала бы два ответа и никакого способа выбрать. Поэтому managed нужно сказать явно: ```go ID uuid.UUID `orm:"pk,pgtype:uuid"` Tags []uuid.UUID `orm:"pgtype:uuid[]"` ``` ## Домены Домены поддержаны обобщённо: сверка идёт от домена к типу, на котором он построен, поэтому колонка типа `tenant_uuid` над `uuid` обслуживается единственным настроенным отображением `uuid` без отдельной записи. Указывайте имя со схемой. Неквалифицированное написание мигрирует, а читается обратно квалифицированным, и эти два не равны — неизменившийся проект начнёт сообщать о дрейфе: ```go TenantID uuid.UUID `orm:"pgtype:public.tenant_uuid"` // правильно TenantID uuid.UUID `orm:"pgtype:tenant_uuid"` // постоянный ложный дрейф ``` ## Диапазоны сохраняют границы Пара концов не скажет, включена граница, исключена или бесконечна, поэтому `Range[T]` несёт всю модель. Какой именно из `daterange`, `tsrange` и `tstzrange` перед вами, берётся из каталога, а не угадывается по Go-типу. ```go r := orm.ClosedOpen(start, end) db.Bookings.Query().Where(Bookings.During.Overlaps(r)) ``` Значения, которые PostgreSQL канонизирует — дискретные диапазоны и все мультидиапазоны, — возвращаются такими, какими их держит сервер. ## Interval — это не Duration `Interval` держит месяцы, дни и микросекунды раздельно и отказывается становиться `time.Duration`, когда содержит календарную составляющую. У месяца нет фиксированной длины, и ошибка говорит именно это, а не тихо берёт 30 дней. ```go d, err := iv.Duration() if errors.Is(err, orm.ErrCalendarInterval) { // есть месяцы или дни; что они значат, решает вызывающий } ``` ## Неподдержанные типы отвергаются Колонка, для типа которой нет отображения, останавливает генерацию с диагностикой: имя колонки, тип и способ починки. Она никогда не деградирует до `any`, `string` или `[]byte` — заглушка, которая «сканируется», хуже упавшей сборки, потому что ломается позже и дальше от причины. ## Разобранные примеры ### Деньги без float ```go //orm:table public.invoices type Invoice struct { ID int64 `orm:"pk,identity"` Cents int64 // простой ответ Total decimal.Decimal `orm:"pgtype:numeric"` // точный } ``` Целые копейки годятся, пока не понадобится третий знак после запятой или ставка. `numeric` точен при любом масштабе, и его отображение требует записи `types.numeric` — библиотека не выберет пакет для десятичных за вас. ### Адреса и сети ```go //orm:table public.sessions type Session struct { ID int64 `orm:"pk,identity"` Client netip.Addr `orm:"pgtype:inet"` Subnet netip.Prefix `orm:"pgtype:cidr"` Device net.HardwareAddr `orm:"pgtype:macaddr"` } ``` Они упорядочиваются и индексируются как адреса, а не как текст, поэтому диапазон подсети — это диапазон, а не `LIKE`. ### Массивы, которые что-то значат ```go //orm:table public.articles type Article struct { ID int64 `orm:"pk,identity"` Tags []string // NOT NULL, может быть пустым Authors *[]int64 // nullable: списка нет вовсе } ``` Пустой массив и NULL-массив — разные значения, и библиотека их различает. Что именно вам нужно — решение о схеме, а указатель — способ его высказать. ### Домен, чтобы правило несла сама схема ```go // CREATE DOMAIN email AS citext CHECK (VALUE ~ '@'); type Contact struct { Address string `orm:"pgtype:public.email"` } ``` Сверка идёт от домена к `citext` и отображает его в `string`. Имя указывайте со схемой, иначе мигрированное и прочитанное имена не совпадут. --- # Диапазоны и мультидиапазоны > Промежуток значений вместе с границами, а не две колонки, притворяющиеся одной. https://ormgo.vercel.app/ru/docs/ranges/ ## Зачем нужен отдельный тип Пара концов не скажет, включена граница, исключена или бесконечна. `[1,10)` и `(1,10]` содержат разные числа, а двум `int`-колонкам `lo` и `hi` негде записать, что именно имелось в виду. `Range[T]` несёт всю модель: два значения и два вида границ, плюс пустой диапазон — а это не то же самое, что диапазон нулевой ширины. ## Как построить ```go orm.Closed(1, 10) // [1,10] оба конца включены orm.ClosedOpen(1, 10) // [1,10) обычный вариант для дат и времени orm.OpenClosed(1, 10) // (1,10] orm.RangeFrom(t) // [t,) без верхней границы orm.RangeUntil(t) // (,t) без нижней границы orm.UnboundedRange[int]() // (,) orm.EmptyRange[int]() // пустой ``` `NewRange` — явная форма, когда границы вычисляются: ```go orm.NewRange(lo, orm.BoundInclusive, hi, orm.BoundExclusive) ``` Чтение обратно: ```go lo, loKind := r.LowerBound() hi, hiKind := r.UpperBound() if r.IsEmpty() { /* ... */ } ``` ## Объявление колонки ```go //orm:table public.bookings type Booking struct { ID int64 `orm:"pk,identity"` During orm.Range[time.Time] `orm:"pgtype:tstzrange"` Prices *orm.Range[int32] `orm:"pgtype:int4range"` } ``` Какой именно тип у `Range[time.Time]` — `daterange`, `tsrange` или `tstzrange` — берётся из каталога, а не угадывается по Go-типу, поэтому его называет тег. ## Запросы ```go db.Bookings.Query().Where(Bookings.During.Overlaps(r)) // && db.Bookings.Query().Where(Bookings.During.Contains(t)) // @> значение db.Bookings.Query().Where(Bookings.During.ContainsRange(r)) // @> диапазон db.Bookings.Query().Where(Bookings.During.ContainedBy(r)) // <@ db.Bookings.Query().Where(Bookings.During.Adjacent(r)) // -|- db.Bookings.Query().Where(Bookings.During.StrictlyLeftOf(r)) // << db.Bookings.Query().Where(Bookings.During.StrictlyRightOf(r)) // >> db.Bookings.Query().Where(Bookings.During.NotLeftOf(r)) // &> db.Bookings.Query().Where(Bookings.During.NotRightOf(r)) // &< ``` `Contains` принимает значение, `ContainsRange` — диапазон. В PostgreSQL это разные операторы, и здесь это разные методы, поэтому вы получаете именно тот, который имели в виду. Сравнение с другой колонкой той же сущности: ```go Bookings.During.OverlapsCol(Bookings.Requested) Bookings.During.ContainsCol(Bookings.Requested) ``` ## Чтение границ в SQL ```go Bookings.During.Lower() // lower(during) -> *T Bookings.During.Upper() // upper(during) -> *T Bookings.During.LowerInc() // lower_inc(during) -> bool Bookings.During.LowerInf() // lower_inf(during) -> bool Bookings.During.IsEmpty() // isempty(during) -> bool ``` `Lower` и `Upper` nullable, потому что у бесконечного конца нет значения. Чтобы фильтровать по пустоте, используйте форму-предикат, а не сравнение значения: ```go db.Bookings.Query().Where(Bookings.During.IsEmptyIs(true)) ``` ## Мультидиапазоны Мультидиапазон — это упорядоченный набор непересекающихся диапазонов: то, что получается при объединении двух диапазонов, которые не соприкасаются. ```go //orm:table public.schedules type Schedule struct { Free orm.Multirange[time.Time] `orm:"pgtype:tstzmultirange"` } ``` ```go Schedules.Free.Contains(t) // значение Schedules.Free.ContainsRange(r) // один диапазон Schedules.Free.ContainsMultirange(m) // целый мультидиапазон Schedules.Free.Overlaps(m) Schedules.Free.OverlapsRange(r) Schedules.Free.Merge() // range_merge -> Range[T] Schedules.Free.IsEmpty() ``` `Merge` схлопывает мультидиапазон в один охватывающий диапазон — наименьший, содержащий все элементы вместе с промежутками. ## Что канонизирует PostgreSQL Дискретные диапазоны — `int4range`, `int8range`, `daterange` — возвращаются в канонической форме, поэтому `[1,10]` приходит как `[1,11)`. Мультидиапазоны канонизируются все. Это нормализация сервера, а не пакета, и вы читаете именно те значения, которые он держит. ## Разобранные примеры ### Переговорная Двойное бронирование — это один предикат, а не пара сравнений, которые надо не перепутать: ```go wanted := orm.ClosedOpen(start, end) clash, err := db.Bookings.Query(). Where(Bookings.RoomID.Eq(roomID)). Where(Bookings.During.Overlaps(wanted)). Exists(ctx) ``` `ClosedOpen` — правильная форма для времени: бронь, кончающаяся в 10:00, и бронь, начинающаяся в 10:00, не пересекаются, и `[start, end)` говорит именно это. ### Цена с окном действия Цена, действующая на дату, и строки, у которых конца ещё нет: ```go current, err := db.Tariffs.Query(). Where(Tariffs.ProductID.Eq(id)). Where(Tariffs.Valid.Contains(on)). One(ctx) open, err := db.Tariffs.Query(). Where(Tariffs.Valid.Overlaps(orm.RangeFrom(time.Now()))). All(ctx) ``` ### График смен Где покрытие кончается, а следующая смена ещё не началась, — смежность и промежутки: ```go // Смены, которые соприкасаются, но не пересекаются. db.Shifts.Query().Where(Shifts.Hours.Adjacent(other)) // Всё, что целиком раньше границы. db.Shifts.Query().Where(Shifts.Hours.StrictlyLeftOf(orm.RangeFrom(cutoff))) // Границы, прочитанные в SQL. var span = orm.Project2( Shifts.Hours.Lower(), Shifts.Hours.Upper(), func(from, to *time.Time) Span { return Span{from, to} }, ) ``` Обе границы nullable, потому что у открытой смены там нет значения. ### Доступность как мультидиапазон ```go // Какое-то из свободных окон целиком покрывает приём. db.Calendars.Query().Where(Calendars.Free.ContainsRange(appointment)) // Промежуток от первой свободной минуты до последней, вместе с дырами. var span = orm.Project1( Calendars.Free.Merge(), func(r orm.Range[time.Time]) orm.Range[time.Time] { return r }, ) ``` --- # JSON и JSONB > Чтение внутрь документа и почему любое чтение возвращает nullable. https://ormgo.vercel.app/ru/docs/json/ ## Колонка Колонка `jsonb` отображается в тот Go-тип, который вы для неё объявили: обычно это карта, иногда структура: ```go //orm:table public.users type User struct { ID int64 `orm:"pk,identity"` Settings map[string]any `orm:"pgtype:jsonb"` Profile *Profile `orm:"pgtype:jsonb"` } ``` Использовать стоит `jsonb`. `json` хранит исходный текст вместе с пробелами и дублирующимися ключами; `jsonb` хранит разобранную структуру, а именно она нужна индексам и операторам вхождения. ## Всё это свободные функции Чтения и проверки — свободные функции, а не методы, потому что с любой стороны может быть колонка или выражение. Они принимают `Optional`, поэтому не-nullable колонка поднимается через `orm.Opt`: ```go meta := orm.Opt(Users.Settings) ``` Они дают `Predicate[Composed]` или `Expression`, поэтому их место в составном запросе. ## Проверки ```go orm.JSONHasKey(meta, "plan") // ? orm.JSONHasAnyKeys(meta, "plan", "tier") // ?| orm.JSONHasAllKeys(meta, "plan", "tier") // ?& orm.JSONContains(meta, orm.Val(v)) // @> orm.JSONContainedBy(meta, orm.Val(v)) // <@ orm.JSONPathExists(meta, "$.billing.tier") // @? orm.JSONMatches(meta, "$.age > 18") // @@ ``` `JSONPathExists` и `JSONMatches` принимают синтаксис SQL/JSON path — самый выразительный: `$.items[*].price`, фильтры, шаблоны. ## Чтение ```go orm.JSONGet(meta, "billing") // -> ключ, возвращает jsonb orm.JSONText(meta, "plan") // ->> ключ, возвращает text orm.JSONIndex(meta, 0) // -> индекс массива orm.JSONIndexText(meta, 0) // ->> индекс orm.JSONPathGet(meta, "billing", "tier") // #> путь, возвращает jsonb orm.JSONPathText(meta, "billing", "tier") // #>> путь, возвращает text orm.JSONArrayLength(meta) // jsonb_array_length orm.JSONTypeOf(meta) // jsonb_typeof ``` **Все они nullable, и это не осторожность.** `->` по отсутствующему ключу — это NULL. `->>` по несуществующему пути — NULL. `jsonb_typeof` от NULL-документа — NULL. Документ — это форма, которую никто не проверял, поэтому чтение, обещающее не-NULL, врало бы про самый частый случай. Поэтому вы приводите тип, а не сравниваете напрямую: ```go age := orm.CastNull(orm.JSONPathText(meta, "profile", "age"), orm.Integer) // Expression[*int32, *int32] ``` ## Запись ```go orm.JSONSet(Users.Settings, []string{"billing", "tier"}, v, true) orm.JSONInsert(Users.Settings, []string{"tags", "0"}, v, false) orm.JSONStripNulls(Users.Settings) ``` Последний аргумент `JSONSet` — это `create_missing`: добавлять ли ключ, если пути нет. У `JSONInsert` это `insert_after`. Оба — булевы в собственной сигнатуре PostgreSQL, и они переданы как есть, а не переименованы: читатель, заглянувший в руководство, должен найти тот же аргумент. Они возвращают `Value`, поэтому их место в обновлении: ```go db.Users.Update(). Set(Users.Settings.SetExpr(orm.JSONSet(Users.Settings, []string{"plan"}, newPlan, true))). Where(Users.ID.Eq(id)). Exec(ctx) ``` ## Индексы Запросу на вхождение нужен GIN-индекс: ```go //orm:index users_settings_gin_idx (Settings) using gin ``` `jsonb_path_ops` меньше и быстрее для одного лишь `@>`, но поддерживает меньше операторов — объявляйте его как индекс по выражению, если он нужен. ## Разобранные примеры ### Флаги функций у аккаунта ```go settings := orm.Opt(Accounts.Settings) // Аккаунты, включившие бету. orm.Compose(pool, shape).From(Accounts.Source()). Where(orm.JSONContains(settings, orm.Val(map[string]any{"beta": true}))) // Аккаунты, где ключ вообще не задавали, — это другой вопрос. orm.Compose(pool, shape).From(Accounts.Source()). Where(orm.Not(orm.JSONHasKey(settings, "beta"))) ``` `Contains` спрашивает про значение, `HasKey` — принимал ли кто-то решение. Флаг, которого нет, и флаг, равный `false`, — разные состояния, и так их различают. ### Полезная нагрузка события Чтение вложенного значения и сравнение его как числа: ```go payload := orm.Opt(Events.Payload) amount := orm.CastNull(orm.JSONPathText(payload, "order", "total"), orm.Integer) var big = orm.Project2( orm.Of(Events.ID), amount, func(id int64, total *int32) Big { return Big{id, total} }, ) orm.Compose(pool, big).From(Events.Source()). Where(orm.JSONPathExists(payload, "$.order.total")). All(ctx) ``` Тип определяется приведением. `->>` возвращает текст, что бы ни лежало в документе, и сравнение с числом обязано это сказать. ### Документ профиля, правка на месте ```go db.Profiles.Update(). Set(Profiles.Doc.SetExpr(orm.JSONSet(Profiles.Doc, []string{"contact", "email"}, newEmail, true))). Where(Profiles.ID.Eq(id)). Exec(ctx) ``` `true` — это `create_missing`: добавить `contact.email`, если пути нет. С `false` обновление ничего не сделает с документом, где его никогда не было. ### Вопросы о форме ```go orm.JSONTypeOf(orm.Opt(Events.Payload)) // "object", "array", "string"… orm.JSONArrayLength(orm.Opt(Events.Items)) // *int32, NULL, если это не массив ``` --- # Даты и интервалы > Усечение, извлечение и тип интервала, который отказывается врать про месяцы. https://ormgo.vercel.app/ru/docs/datetime/ ## Interval — это не Duration `time.Duration` — это количество наносекунд. `interval` в PostgreSQL состоит из трёх независимых частей — месяцы, дни и микросекунды — и держит их порознь, потому что они не переводятся друг в друга: - в месяце от 28 до 31 дня - в сутках 23, 24 или 25 часов на границе перевода часов ```go iv := orm.IntervalOf(months, days, micros) iv := orm.IntervalFromDuration(90 * time.Minute) // без месяцев и дней ``` Обратное преобразование работает, только если внутри нет ничего календарного: ```go d, err := iv.Duration() if errors.Is(err, orm.ErrCalendarInterval) { // есть месяцы или дни; что они значат, зависит от точки отсчёта, // и библиотека не выберет 30 дней за вас } ``` Эта ошибка и есть весь замысел. Библиотека, которая молча вернула бы 720 часов за месяц, была бы права почти всегда и неправа на каждой границе месяца. ## Арифметика ```go orm.AddInterval(Events.At, orm.Val(iv)) // метка времени + интервал orm.SubInterval(Events.At, orm.Val(iv)) orm.IntervalPlus(a, b) // interval + interval orm.IntervalMinus(a, b) orm.IntervalTimes(a, 3) // interval * n ``` У каждой есть форма `…Null` для nullable-входов, потому что арифметика с NULL даёт NULL. ## Усечение ```go orm.DateTrunc(orm.Month, Events.At) // date_trunc('month', at) orm.DateTrunc(orm.Day, Events.At) orm.DateTrunc(orm.Hour, Events.At) ``` Классическое применение — группировка временного ряда по корзинам: ```go bucket := orm.DateTrunc(orm.Day, Events.At) var perDay = orm.Project2( bucket, orm.Count[Event](), func(day time.Time, n int64) Bucket { return Bucket{day, n} }, ) orm.Select(db.Events, perDay). Where(Events.At.Gte(since)). GroupBy(bucket). OrderBy(bucket.Asc()). All(ctx) ``` Группируйте по **тому же выражению**, которое выбрали. Два вызова `DateTrunc` с одинаковыми аргументами дают одинаковый SQL, и PostgreSQL их сопоставит, — но переменная говорит это явно и читается лучше. ## Извлечение ```go orm.Extract(orm.Year, Events.At, orm.Integer) // -> int32 orm.Extract(orm.DayOfWeek, Events.At, orm.Integer) // 0 = воскресенье orm.Extract(orm.EpochSecond, Events.At, orm.BigInt) // -> int64 ``` Третий аргумент — тип, который вы хотите получить, в виде значения `PGType`. `extract` в PostgreSQL возвращает `numeric`, поэтому кто-то должен сказать, во что его привести; сказанное здесь означает, что Go-тип определён, а не утверждён задним числом. Поля: ```go orm.Year orm.Quarter orm.Month orm.Week orm.Day orm.Hour orm.Minute orm.Second orm.DayOfWeek orm.DayOfYear orm.EpochSecond ``` ## Сравнение Метки времени — упорядоченные колонки, поэтому работают обычные предикаты: ```go db.Events.Query().Where(Events.At.Between(dayStart, dayEnd)) db.Events.Query().Where(Events.At.Gte(cutoff)) db.Events.Query().OrderBy(Events.At.Desc()) ``` Для «за последние N» вычисляйте границу в Go, а не в SQL, когда это возможно: параметр привязки — лучший вход для планировщика, чем выражение, которое ему приходится вычислять на каждой строке. ## Разобранные примеры ### График регистраций по дням ```go day := orm.DateTrunc(orm.Day, Accounts.CreatedAt) var perDay = orm.Project2( day, orm.Count[Account](), func(d time.Time, n int64) Point { return Point{d, n} }, ) rows, err := orm.Select(db.Accounts, perDay). Where(Accounts.CreatedAt.Gte(since)). GroupBy(day). OrderBy(day.Asc()). All(ctx) ``` По месяцам — тот же запрос с одним изменённым словом; ради этого корзину и называют. ### Часы посещаемости ```go hour := orm.Extract(orm.Hour, Visits.At, orm.Integer) dow := orm.Extract(orm.DayOfWeek, Visits.At, orm.Integer) var heat = orm.Project3( dow, hour, orm.Count[Visit](), func(d, h int32, n int64) Cell { return Cell{d, h, n} }, ) orm.Select(db.Visits, heat).GroupBy(dow, hour).All(ctx) ``` ### Пробный период, который истекает ```go // Пробные периоды, кончающиеся в ближайшие трое суток. soon := time.Now().Add(72 * time.Hour) db.Trials.Query().Where(Trials.EndsAt.Between(time.Now(), soon)) // Продление — в SQL, без предварительного чтения. db.Trials.Update(). Set(Trials.EndsAt.SetExpr(orm.AddInterval(Trials.EndsAt, orm.Val(orm.IntervalOf(0, 14, 0))))). Where(Trials.ID.Eq(id)). Exec(ctx) ``` `IntervalOf(0, 14, 0)` — это четырнадцать **дней**, а не 336 часов. На границе перевода часов это разные моменты, и интервал сохраняет различие, которое `Duration` выбросил бы. --- # Полнотекстовый поиск > tsvector, tsquery и ранжирование — части, которые есть в PostgreSQL, оставленные порознь. https://ormgo.vercel.app/ru/docs/fulltext/ ## Два типа `tsvector` — это документ, разобранный на лексемы. `tsquery` — поисковое выражение. Это разные типы, а совпадение — оператор между ними; поэтому здесь нет одного метода `Search(string)`: вектор обычно лежит в колонке, а запрос строится на каждый вызов. ```go //orm:table public.articles type Article struct { ID int64 `orm:"pk,identity"` Title string Body string Search orm.TSVector `orm:"pgtype:tsvector"` } ``` ## Совпадение ```go q := orm.PlainToTSQuery(orm.English, userInput) articles, err := db.Articles.Query(). Where(orm.Matches(Articles.Search, q)). All(ctx) ``` ```sql search @@ plainto_tsquery('english', $1) ``` `orm.Matches` — свободная функция, принимающая вектор и запрос, потому что с любой стороны может быть колонка или выражение. ## Построение запроса Четыре конструктора, и различаются они тем, как обходятся с текстом пользователя: ```go orm.PlainToTSQuery(orm.English, "postgres indexing") // все слова через AND, пунктуация игнорируется. Безопасное умолчание для строки поиска. orm.PhraseToTSQuery(orm.English, "index only scan") // слова в этом порядке, подряд orm.WebSearchToTSQuery(orm.English, `"index only" -bitmap`) // синтаксис в духе Google: кавычки, OR и минус впереди как NOT orm.ToTSQuery(orm.English, "index & postgres") // сырой синтаксис tsquery — & | ! <-> — и он падает на некорректном вводе ``` Только последний принимает синтаксис операторов, поэтому только он может упасть на том, что ввёл пользователь. Именно его не стоит подключать к публичной строке поиска. Комбинирование: ```go orm.AndTSQuery(a, b) orm.OrTSQuery(a, b) orm.NotTSQuery(a) ``` ## Конфигурации Первый аргумент — конфигурация полнотекстового поиска, она определяет стемминг и стоп-слова: ```go orm.English // "english" orm.Simple // "simple" — без стемминга и стоп-слов orm.TextSearchConfig("russian") ``` Это именованный строковый тип, поэтому доступна любая конфигурация, которая есть на сервере, — не дожидаясь появления константы. ## Ранжирование ```go q := orm.PlainToTSQuery(orm.English, input) rank := orm.TSRank(Articles.Search, q) type Hit struct { Title string Rank float32 } var hits = orm.Project2( Articles.Title, rank, func(title string, r float32) Hit { return Hit{title, r} }, ) rows, err := orm.Select(db.Articles, hits). Where(orm.Matches(Articles.Search, q)). OrderBy(rank.Desc()). Limit(20). All(ctx) ``` `TSRankCD` — ранжирование по плотности покрытия: оно учитывает, насколько близко совпавшие лексемы друг к другу. У обоих есть формы `…Null` для nullable-вектора. Обратите внимание: запрос строится **один раз** и используется дважды — в `WHERE` и в ранжировании. Построить его дважды значило бы положить один и тот же текст в два параметра и заставить планировщик работать больше без причины. ## Построение вектора в SQL Когда в колонке текст, а не хранимый `tsvector`: ```go vec := orm.ToTSVector(orm.English, Articles.Body) db.Articles.Query().Where(orm.Matches(vec, q)) ``` Так нельзя воспользоваться индексом по `tsvector`, поэтому это для разовых запросов, а не для основного поиска. Для основного — храните вектор в колонке, обычно генерируемой, и индексируйте её. ## Веса ```go title := orm.SetWeight(orm.ToTSVector(orm.English, Articles.Title), orm.WeightA) body := orm.SetWeight(orm.ToTSVector(orm.English, Articles.Body), orm.WeightB) both := orm.Concat2TSVector(title, body) ``` `WeightA`…`WeightD` — то, из-за чего совпадение в заголовке весит больше, чем в теле. Ранжирование их читает, поиск совпадений — игнорирует. ## Разобранные примеры ### База знаний Ранжированные результаты, запрос построен один раз: ```go q := orm.PlainToTSQuery(orm.English, input) rank := orm.TSRank(Articles.Search, q) var hits = orm.Project3( Articles.Slug, Articles.Title, rank, func(slug, title string, r float32) Hit { return Hit{slug, title, r} }, ) rows, err := orm.Select(db.Articles, hits). Where(orm.Matches(Articles.Search, q)). OrderBy(rank.Desc(), Articles.Title.Asc()). Limit(20). All(ctx) ``` Дополнительная сортировка по заголовку важна: без неё две статьи с одинаковым рангом приходят в том порядке, который выдал план, а он меняется между запусками. ### Строка поиска, принимающая операторы ```go // Пользователь может ввести: "index only" -bitmap q := orm.WebSearchToTSQuery(orm.English, input) ``` `WebSearchToTSQuery` понимает кавычки, `OR` и минус впереди и не падает на некорректном вводе. `ToTSQuery` принимает сырой синтаксис `& | ! <->` и падает на лишнем операторе, поэтому ему место за админской формой, а не перед публикой. ### Заголовок весит больше тела ```go title := orm.SetWeight(orm.ToTSVector(orm.English, Recipes.Title), orm.WeightA) body := orm.SetWeight(orm.ToTSVector(orm.English, Recipes.Method), orm.WeightB) doc := orm.Concat2TSVector(title, body) orm.Compose(pool, shape).From(Recipes.Source()). Where(orm.Matches(doc, q)). OrderBy(orm.TSRank(doc, q).Desc()). All(ctx) ``` Вычисленный так, он не может воспользоваться индексом, поэтому подходит для админского отчёта. Для основного поиска храните взвешенный вектор в колонке и индексируйте её. ### Фильтрация и поиск вместе ```go orm.Select(db.Articles, hits). Where(Articles.Locale.Eq("en")). Where(Articles.Published.Eq(true)). Where(orm.Matches(Articles.Search, q)). OrderBy(rank.Desc()). All(ctx) ``` --- # PostGIS > Пространственные типы, которые остаются пространственными: geometry и geography порознь. https://ormgo.vercel.app/ru/docs/postgis/ ## Подключается отдельно Поддержка PostGIS — отдельный пакет. Проект, который его не импортирует, никогда не увидит пространственного API, а корневая библиотека ничего не знает о геометрии: ```go import "github.com/AlexAli29/orm/postgis" ``` Всё в нём собирается через ту единственную границу расширения, которую открывает корневой пакет. Нет ни второго компилятора запросов, ни второй модели выражений: пространственный предикат — это такой же `orm.Predicate`, и он вкладывается в составные запросы, CTE и производные таблицы без изменений. ## Два различения, которые никогда не смешиваются **geometry** — декартова, в тех единицах, которые задаёт система координат SRID. **geography** — на сфероиде, расстояния и длины в метрах. Это разные типы PostgreSQL с разным поведением индексов и разными ответами, поэтому здесь это разные типы Go. Преобразование между ними — то, что вы пишете сами, а не то, что случается с вами. И два факта путешествуют вместе с каждым значением и каждой колонкой: - **форма** — Point, LineString, Polygon и их множественные варианты; - **SRID** — в какой системе координат эти числа. Потеря любого из них — это способ получить запрос, сравнивающий метры с градусами и возвращающий число. ## Объявление пространственной колонки Тег `pgtype` несёт форму и систему координат, потому что из Go-типа не выводится ни то, ни другое: ```go //orm:table public.places type Place struct { ID int64 `orm:"pk,identity"` Name string // На сфероиде. Расстояния приходят в метрах. Spot postgis.Geography `orm:"pgtype:geography(Point,4326)"` // Декартова, в градусах WGS 84. Location postgis.Geometry `orm:"pgtype:geometry(Point,4326)"` // То же место в web Mercator. Соотнести его с Location без преобразования — // ошибка, которую SRID делает видимой. Projected *postgis.Geometry `orm:"pgtype:geometry(Point,3857)"` Footprint *postgis.Geometry `orm:"pgtype:geometry(Polygon,4326)"` } ``` Указатель — nullable-колонка, как и везде. Генератор выдаёт `GeomCol`, `GeogCol` и их nullable-формы, каждая несёт объявленные SRID, вид и размерность. ## Запросы `postgis.Of` поднимает колонку geometry в пространственное выражение, `postgis.OfGeog` — то же для geography: ```go // Всё в пределах 5 км от точки, на сфероиде — в метрах, потому что geography // измеряет в метрах. here := postgis.GeographyPoint(-0.1276, 51.5072) places, err := db.Places.Query(). Where(postgis.OfGeog(Places.Spot). DWithin(postgis.GeogValue[Place](here), 5000)). All(ctx) ``` ```go // Декартовы отношения, на geometry. db.Places.Query().Where(postgis.Of(Places.Location).Intersects(v)) db.Places.Query().Where(postgis.Of(Places.Location).Within(v)) db.Places.Query().Where(postgis.Of(Places.Location).Contains(v)) ``` ### Операторы по ограничивающей рамке названы именно так ```go postgis.Of(Places.Location).BBoxIntersects(v) // && postgis.Of(Places.Location).BBoxContains(v) // ~ postgis.Of(Places.Location).BBoxWithin(v) // @ ``` `&&` — это не `ST_Intersects`. Он сравнивает ограничивающие рамки: это дешевле и отвечает на другой вопрос, поэтому у него другое имя, а не вид «быстрой версии» точного оператора. ## Измерения и преобразования Они возвращают обычный `orm.Value`, поэтому годятся в проекции и сортировки как всё остальное: ```go distance := postgis.OfGeog(Places.Spot).Distance(postgis.GeogValue[Place](here)) type Near struct { Name string Metres float64 } var near = orm.Project2( Places.Name, distance, func(name string, m float64) Near { return Near{name, m} }, ) rows, err := orm.Select(db.Places, near). OrderBy(distance.Asc()). Limit(20). All(ctx) ``` На выражении доступны также `Area`, `Length`, `Centroid`, `Buffer`, `Boundary`, `Azimuth`, `AsText`, `AsEWKT`, `AsGeoJSON`, `AsBinary`, `AsEWKB` и `AsGeography` — то самое преобразование, которое вы делаете осознанно. У каждого есть форма `…Null` для nullable-колонки, потому что измерение NULL-геометрии — это NULL. ## Агрегаты ```go postgis.Collect(g) // ST_Collect -> *Geometry postgis.UnionAgg(g) // ST_Union -> *Geometry postgis.Extent(g) // ST_Extent -> *Box2D postgis.Extent3D(g) // ST_3DExtent -> *Box3D ``` ## Регистрация типов pgx нужно рассказать о типах PostGIS на каждом соединении: ```go cfg.AfterConnect = func(ctx context.Context, conn *pgx.Conn) error { return postgis.Register(ctx, conn) } ``` `RegisterIfPresent` — терпимая форма: она сообщает, было ли расширение, вместо того чтобы падать. Это то, что нужно бинарнику, который работает и с пространственными базами, и с обычными. ## На каких версиях это доказано PostgreSQL 17 с PostGIS 3.5, 16 с 3.4 и 14 с 3.4. Пространственный набор тестов пропускается, когда расширения нет, — правильно на машине разработчика и неправильно в CI, поэтому CI ставит `ORM_REQUIRE_POSTGIS=1`, что превращает пропуск в падение. Заявленной поддержке, которую ничто не проверяет, верить нельзя. Библиотека никогда не создаёт расширение. `CREATE EXTENSION postgis` — привилегированная операция того, кто владеет базой. ## Разобранные примеры ### Магазины рядом ```go here := postgis.GeographyPoint(lon, lat) type Near struct { Name string Metres float64 } distance := postgis.OfGeog(Shops.Spot).Distance(postgis.GeogValue[Shop](here)) var near = orm.Project2( Shops.Name, distance, func(name string, m float64) Near { return Near{name, m} }, ) rows, err := orm.Select(db.Shops, near). Where(postgis.OfGeog(Shops.Spot).DWithin(postgis.GeogValue[Shop](here), 2000)). OrderBy(distance.Asc()). Limit(10). All(ctx) ``` Важно, что `DWithin` идёт до `Distance`: первый может воспользоваться пространственным индексом, второй — нет. Сначала отфильтровать, потом отсортировать — это разница между запросом и полным сканированием. ### Какая зона доставки покрывает адрес ```go zone, err := db.Zones.Query(). Where(postgis.Of(Zones.Area).Contains(postgis.Of(Addresses.Point))). One(ctx) ``` ### Прямоугольник видимой области карты ```go box := postgis.MakeEnvelope(west, south, east, north, 4326) pins, err := db.Pins.Query(). Where(postgis.Of(Pins.Location).BBoxIntersects(box)). Limit(500). All(ctx) ``` `BBoxIntersects` — это `&&`, сравнение ограничивающих рамок. Для прямоугольной области это ровно тот вопрос, который нужен, и он дешёвый. ### Выгрузка для картового клиента ```go var geo = orm.Project2( Zones.Name, postgis.Of(Zones.Area).AsGeoJSON(), func(name, geom string) Feature { return Feature{name, geom} }, ) ``` ### Регистрация типов ```go cfg.AfterConnect = func(ctx context.Context, conn *pgx.Conn) error { return postgis.Register(ctx, conn) } ``` --- # Миграции > Планирование, применение и доказательство изменений схемы. https://ormgo.vercel.app/ru/docs/migrations/ ## Модель В режиме managed декларации — это желаемое состояние. `makemigrations` сравнивает их с состоянием, которое описывают существующие артефакты миграций, — **не** с живой базой — и пишет разницу артефактом. Разница существенна: планирование по живой базе дало бы миграцию, зависящую от той базы, на которой её планировали. ```bash orm makemigrations # спланировать и записать orm makemigrations --dry-run --sql # показать SQL, ничего не писать orm makemigrations --check # упасть, если что-то не спланировано orm migrate # применить orm migrate --plan # показать, что будет применено orm showmigrations # что применено, что ожидает ``` ## Артефакты переносимы Миграция — это JSON с описанием операций, а не SQL-скрипт. Два следствия: - Она одинаково воспроизводится на любом поддерживаемом мажоре PostgreSQL. - В ней нет ничего серверно-локального: ни OID, ни имени базы, ни версии сервера, ни разобранного сервером определения, ни абсолютных путей. Артефакт с любым из этого сходится на машине, где его написали, и больше нигде. ## Транзакции Каждая миграция применяется в одной транзакции вместе с записью в свою историю. Упавшая миграция откатывается и **не записывается** — то есть неудачный запуск оставляет базу ровно такой, какой она была, а повторный запуск падает так же, а не применяется наполовину. ```text Applying 0002_add_orders ... FAILED orm migrate: migration 0002_add_orders failed at operation 1 (alter column public.orders.total: type text -> numeric); the transaction was rolled back and the migration is not recorded: ERROR: column "total" cannot be cast automatically to type numeric (SQLSTATE 42804) ``` Обратите внимание, что здесь произошло: планировщик спланировал, а отказал **PostgreSQL**. Библиотека не придумывает выражение `USING`, чтобы такая миграция прошла, — это было бы решением инструмента за вас о том, что делать со строками, которые не конвертируются. ## Разрушающие изменения проходят через шлюз Удаление колонки или таблицы не планируется молча. Шлюз существует потому, что цена ошибочного `DROP` неограниченна, а цена лишнего подтверждения — одна команда. ## Чего миграции не делают Две замороженные границы: - **Миграции не создают схемы.** `CREATE SCHEMA` — ваш. - **Миграции не создают домены и расширения.** По той же причине. Это предпосылки, а не изменение схемы, и притворяться иначе значило бы делать `orm migrate` командой для суперпользователя. ## Данные и аварийный выход в сырой SQL Порождённая миграция описывает операции над схемой, и в `create table` или `add column` строкам места нет. Но это не вся правда, а документация до сих пор позволяла думать, что вся. `orm makemigrations --empty` создаст её за вас, чтобы вы правили файл, а не выдумывали его с нуля: ```console $ orm makemigrations --empty --name seed_tags wrote migrations/0002_seed_tags.json Fill in Up with the SQL to run, and Down with the SQL that undoes it. ``` Она пишется независимо от того, менялись ли модели: данные — ровно тот случай, которого не видит сравнение схем. Оставленная заготовка не «ничего не делает», а бросает исключение, поэтому созданная и забытая миграция упадёт, а не будет записана как применённая. Артефакт — это JSON, а операция называется `raw_sql`: ```json { "op": "raw_sql", "args": { "Up": "INSERT INTO user_tags (text) VALUES ('music'), ('sports') ON CONFLICT (text) DO NOTHING", "Down": "DELETE FROM user_tags WHERE text IN ('music', 'sports')", "Atomic": true, "Description": "seed the starting tags" } } ``` `Down` необязателен, и именно его отсутствие делает операцию необратимой: это сказано прямо, а не подделано пустышкой, которая якобы что-то откатила. `Atomic` говорит, можно ли выполнять это внутри транзакции. Это же делает возможным изменение колонки в три шага, недостижимое ни для одного инструмента, который умеет только схему: 1. добавить колонку как nullable; 2. `raw_sql`, чтобы её заполнить; 3. поставить `NOT NULL`. Движок не разбирает SQL, поэтому `raw_sql` ничего не меняет в состоянии миграций и объявляет себя разрушительной операцией — осторожное предположение о том, что прочитать нельзя. Если ваш SQL всё-таки меняет схему, добавьте рядом `state_only`, чтобы состояние осталось правдой; иначе следующий план попытается внести это изменение снова. Начальные данные — случай попроще и тот же механизм. Где им место, в миграции или в отдельном шаге, — это настоящий выбор: миграция выполняется по разу на базу и просматривается вместе с тем изменением схемы, к которому относится, а файл начальных данных, выполняемый при каждом развёртывании, требует `ON CONFLICT DO NOTHING` и уникального ограничения, по которому это сработает. Справочные данные, без которых схема бессмысленна, относятся к миграции. Удобные фикстуры разработчика — нет. ## Материализованные представления Материализованное представление хранит строки, вычисленные по телу запроса, поэтому смена этого тела — не изменение колонки: после неё строки просто неверны. Планировщик отказывается менять определение молча и просит написать явную миграцию. Индексы на матпредставлении планируются отдельно от самого отношения — именно это делает пригодность к конкурентному обновлению фактом, который генератор может записать. См. [Представления](/ru/docs/views/). ## Проверка в CI ```bash orm makemigrations --check # за каждой декларацией стоит миграция orm check --generated # закоммиченный сгенерированный код актуален ``` Первая падает, когда кто-то поменял структуру и забыл спланировать. Вторая — когда спланировал и забыл перегенерировать. ## Разобранные примеры ### Безопасное добавление колонки Добавить nullable-колонку — мгновенно. Добавить `NOT NULL` без умолчания — переписать таблицу и заблокировать запись на это время, поэтому это три миграции, а не одна: ```go // 1. Добавить nullable. Currency *string // 2. Заполнить, вне миграции, пачками. // 3. Затем сделать NOT NULL. Currency string `orm:"default:'EUR'"` ``` `orm makemigrations --dry-run --sql` показывает, что из этого PostgreSQL сделает дёшево, — до того как вы узнаете это на проде. ### Переименование без простоя Планировщик видит удалённую колонку и добавленную, а не переименование, а удаление колонки удаляет её данные. Добавить, писать в обе, заполнить, удалить — четыре выката: ```bash orm makemigrations --dry-run --sql # прочитайте, прежде чем поверить ``` ### Проверка, что выкат завершён ```bash orm showmigrations # что применено, что ожидает orm migrate --plan # что именно сделает следующий запуск orm check --generated # закоммиченный код соответствует схеме ``` ### Шлюз в CI ```yaml - run: orm makemigrations --check # декларация, которую никто не спланировал - run: orm check --generated # план, под который никто не перегенерировал ``` Первая падает, когда структуру изменили и забыли. Вторая — когда спланировали и забыли перегенерировать. Вдвоём они не дают трём представлениям разойтись. --- # Представления > Источники чтения первого класса и жизненный цикл обновления. https://ormgo.vercel.app/ru/docs/views/ ## Представления Представление объявляется как таблица плюс определение и зависимости: ```go //orm:view public.user_orders //orm:definition `SELECT u.id AS user_id, o.id AS order_id, o.label // FROM users u JOIN orders o ON o.user_id = u.id` //orm:depends-on public.users //orm:depends-on public.orders type UserOrder struct { UserID int64 OrderID int64 Label string } ``` `depends-on` задаёт порядок в плане миграции. Представление, созданное раньше таблицы, из которой оно выбирает, — это миграция, падающая на чистой базе и работающая на вашей. ## Колонки представления nullable Nullability результата представления недоказуема по определению, поэтому все колонки приходят nullable-дескрипторами: ```go UserOrders.UserID // NullOrdCol[UserOrder, int64], а не OrdCol ``` Это честно, а не осторожно: `SELECT ... FROM a LEFT JOIN b` может дать NULL в колонке, база которой `NOT NULL`, а представление не хранит, какая именно. ## Как из него читать `ViewRepo` даёт `Query` и `QueryFrom` — и больше ничего. Это тот же строитель запросов, что и у таблицы: те же предикаты, сортировка, страницы, проекции и композиция: ```go rows, err := db.MonthlyRevenues.Query(). Where(MonthlyRevenues.Plan.Eq("pro")). OrderBy(MonthlyRevenues.Month.Desc()). Limit(12). All(ctx) ``` Чего он **не** даёт — записи. У репозитория нет ни `Insert`, ни `Update`, ни `Delete`, потому что их нет и у PostgreSQL для представления без правила или триггера, — значит, генерировать тут нечего даже в принципе. ### Nullable-колонки меняют чтение предикатов Все колонки представления — nullable-дескрипторы, поэтому `IsNull` есть у каждой, а тип значения обычный: ```go // Сравнение принимает обычную строку: nullable колонка, а не аргумент. db.MonthlyRevenues.Query().Where(MonthlyRevenues.Plan.Eq("pro")) // И это доступно у каждой колонки, чего не было бы у таблицы. db.MonthlyRevenues.Query().Where(MonthlyRevenues.Cents.IsNull()) ``` Если для представления, где колонки заведомо никогда не NULL, это лишний шум, — решение в том, чтобы сканировать в указатели или спроецировать нужные колонки своей формой, а не объявлять их не-nullable: доказать это библиотека не может. ### Матпредставление отдаёт снимок ```go rows, err := db.SearchRows.Query(). Where(SearchRows.Name.ILike("%lamp%")). Limit(20). All(ctx) ``` Ровно как запрос к таблице — в этом и смысл: работа была проделана во время обновления. Строки настолько же старые, насколько давним было последнее удачное обновление; это и есть размен, на который вы пошли, выбрав материализованное представление вместо обычного. ### Джойн представления с таблицей Представление — такой же источник, как любой другой, поэтому оно составляется: ```go shape := orm.Project2( orm.Opt(MonthlyRevenues.Cents), orm.Of(Plans.Name), func(cents *int64, name string) Row { return Row{cents, name} }, ) rows, err := orm.Compose(pool, shape). From(Plans.Source()). LeftJoin(MonthlyRevenues.Source(), orm.Eq(MonthlyRevenues.Plan, Plans.Code)). OrderBy(orm.Of(Plans.Name).Asc()). All(ctx) ``` ### Два вхождения одного представления ```go thisYear := MonthlyRevenues.As("this_year") orm.Compose(pool, shape). From(thisYear.Source()). Join(MonthlyRevenues.Source(), orm.Eq(MonthlyRevenues.Plan, thisYear.Plan)) ``` `QueryFrom` — эквивалент для запроса по сущности, принимающий источник, который вы отальясили. ## Материализованные представления ```go //orm:materialized-view public.user_summaries //orm:definition `SELECT user_id, count(*) AS orders // FROM user_orders GROUP BY user_id` //orm:depends-on public.user_orders //orm:index user_summaries_key (UserID) unique type UserSummary struct { UserID int64 Orders int64 } ``` `db.UserSummaries` — это `MaterializedViewRepo`. Он даёт то же, что представление, плюс `Refresh`, и никаких записей: в PostgreSQL нет `INSERT` для матпредставления, поэтому генерировать тут нечего даже в принципе. ## Обновление ```go err := db.UserSummaries.Refresh(ctx) // REFRESH MATERIALIZED VIEW err := db.UserSummaries.Refresh(ctx, orm.Concurrently()) // ... CONCURRENTLY err := db.UserSummaries.Refresh(ctx, orm.WithNoData()) // ... WITH NO DATA ``` `CONCURRENTLY` требует уникального индекса по непартиальному набору обычных колонок. Генератор вычисляет это и записывает ответ в дескриптор, поэтому проверка не стоит обращения к серверу: ```text orm: Refresh public.user_summaries: CONCURRENTLY needs a unique index over plain columns covering every row, and this materialized view has none. A partial or expression unique index does not qualify. Add one, or refresh without Concurrently ``` ## Два способа устареть Ответ о пригодности — факт о схеме **на момент генерации**, а схема продолжает меняться. Два получающихся состояния ломаются в противоположные стороны, и понимать, в каком вы находитесь, — главная причина перегенерировать. **Позади базы.** Индекс появился, дескриптор не перегенерирован. Код отказывает локально и ничего не отправляет. Ничего не сломано — что-то недоступно. `orm check --generated` это показывает. **Впереди базы.** Индекс исчез, дескриптор всё ещё говорит «да». Запрос уходит, и PostgreSQL его отвергает: ```go if err := db.UserSummaries.Refresh(ctx, orm.Concurrently()); err != nil { var pge *pgconn.PgError if errors.As(err, &pge) && pge.Code == "55000" { // object not in prerequisite state — индекса больше нет } } ``` Ошибка приходит собственная, от PostgreSQL. Переписывание её в общее «обновление не удалось» потеряло бы SQLSTATE и всё, на что вызывающий мог бы отреагировать. ## Выбор индекса детерминирован Когда подходит несколько индексов, побеждает наименьшее имя. Иначе нельзя: сгенерированный дескриптор и отпечаток, посчитанный по нему, обязаны называть один и тот же индекс на двух прогонах по одной схеме, иначе каждая перегенерация даёт диф. ## Разобранные примеры ### Отчётное представление ```go //orm:view analytics.monthly_revenue //orm:definition `SELECT date_trunc('month', issued_at) AS month, // plan, sum(amount_cents) AS cents // FROM billing.invoices GROUP BY 1, 2` //orm:depends-on billing.invoices type MonthlyRevenue struct { Month time.Time Plan string Cents int64 } ``` Все колонки приходят nullable, потому что nullability результата представления недоказуема: `sum` по пустому множеству — это NULL, а определение не хранит, какие колонки такими быть могут. ### Матпредставление с конкурентным обновлением ```go //orm:materialized-view analytics.search_index //orm:definition `SELECT p.id, p.name, p.tags FROM catalog.products p WHERE p.listed` //orm:depends-on catalog.products //orm:index search_index_id_key (ID) unique type SearchRow struct { ID int64 Name string Tags []string } ``` Уникальный индекс по одной обычной колонке — то, что делает `Concurrently` возможным. Без него обновление берёт монопольную блокировку, и сайт не отвечает, пока оно идёт. ```go if err := db.SearchRows.Refresh(ctx, orm.Concurrently()); err != nil { var pge *pgconn.PgError if errors.As(err, &pge) && pge.Code == "55000" { // индекса нет; перегенерировать и выкатить } return err } ``` ### Обновление по расписанию ```go func refreshLoop(ctx context.Context, db *domain.DB) { t := time.NewTicker(5 * time.Minute) defer t.Stop() for { select { case <-ctx.Done(): return case <-t.C: if err := db.SearchRows.Refresh(ctx, orm.Concurrently()); err != nil { log.Printf("refresh: %v", err) } } } } ``` Конкурентное обновление не блокирует читателей, поэтому тик раз в пять минут — это трата процессора, а не доступности. --- # Запросы > Чтение сущностей — фильтры, сортировка, страницы и терминальные операции. https://ormgo.vercel.app/ru/docs/queries/ ## Форма ```go users, err := db.Users.Query(). Where(Users.Active.Eq(true)). OrderBy(Users.CreatedAt.Desc()). Limit(50). All(ctx) ``` `Query` изменяем и одноразов. `Clone` ответвляет копию, когда нужна база: ```go base := db.Users.Query().Where(Users.Active.Eq(true)) recent := base.Clone().Where(Users.CreatedAt.Gte(cutoff)) count, _ := base.Clone().Count(ctx) ``` ## Терминалы | Метод | Возвращает | | --- | --- | | `All(ctx)` | `[]E` | | `One(ctx)` | `E` или `ErrNotFound` | | `Count(ctx)` | `int64` | | `Exists(ctx)` | `bool` | | `Rows(ctx)` | `iter.Seq2[E, error]` — потоком | | `SQL()` | запрос и аргументы, без выполнения | Ошибки построения накапливаются и приходят вместе из терминала, поэтому запрос, который нельзя построить, никогда не доходит до PostgreSQL: ```go _, err := db.Users.Query().Where(broken).OrderBy(alsoBroken).All(ctx) // err сообщает про обе, а не только про первую ``` ## Where Несколько вызовов `Where` объединяются через AND. Это частый случай, и он оставляет динамическую фильтрацию читаемой: ```go q := db.Users.Query() if email != "" { q = q.Where(Users.Email.ILike("%" + email + "%")) } if onlyActive { q = q.Where(Users.Active.Eq(true)) } users, err := q.All(ctx) ``` Для OR — явно: ```go db.Users.Query().Where(orm.Or( Users.Email.Eq("a@example.com"), Users.Email.Eq("b@example.com"), )) ``` `orm.And()` по пустому срезу даёт запрос вообще без `WHERE`, а не `WHERE TRUE`. ## Сортировка и страницы ```go db.Users.Query(). OrderBy(Users.CreatedAt.Desc(), Users.ID.Asc()). Limit(20). Offset(40) ``` `Limit(0)` — законный запрос, возвращающий ничего. Ошибка — только отрицательное значение. На больших таблицах keyset-пагинация лучше `OFFSET`, и типизированный API выражает её прямо: ```go db.Users.Query(). Where(orm.Or( Users.CreatedAt.Lt(lastSeenAt), orm.And(Users.CreatedAt.Eq(lastSeenAt), Users.ID.Lt(lastSeenID)), )). OrderBy(Users.CreatedAt.Desc(), Users.ID.Desc()). Limit(20) ``` ## Потоковое чтение `Rows` отдаёт строки по мере их прихода, поэтому большой результат никогда не существует целиком: ```go for user, err := range db.Users.Query().Rows(ctx) { if err != nil { return err } if err := handle(user); err != nil { return err } } ``` Связи требуют отдельного запроса, а для него нужно увидеть все строки — ровно то, чего потоковое чтение и избегает. Поэтому `Rows` отказывает `With`, а не буферизует молча. ## Блокировки ```go db.Users.Query().Where(Users.ID.Eq(id)).ForUpdate() db.Users.Query().Lock(orm.ForUpdateStrong, orm.SkipLocked()) db.Users.Query().Lock(orm.ForShare, orm.NoWait()) ``` Блокировать nullable-сторону outer join PostgreSQL отказывается, поэтому при наличии джойнов блокировка явно называет корневую таблицу. ## Посмотреть SQL ```go sql, args, err := db.Users.Query().Where(Users.Active.Eq(true)).SQL() // SELECT "users"."id", ... FROM "public"."users" WHERE "users"."active" = $1 // args: [true] ``` Значений в SQL нет никогда. Каждое — параметр привязки, включая те, что внутри фрагментов `Expr`. ## Разобранные примеры Три разные схемы: фильтр читается по-разному в зависимости от того, что он фильтрует. ### Отслеживание посылок Отправления, которые вышли со склада и не пришли, — сначала самые старые: очередь, которую разбирает диспетчер. ```go stuck, err := db.Shipments.Query(). Where(Shipments.DepartedAt.IsNotNull()). Where(Shipments.ArrivedAt.IsNull()). Where(Shipments.DepartedAt.Lt(time.Now().Add(-48*time.Hour))). OrderBy(Shipments.DepartedAt.Asc()). Limit(100). All(ctx) ``` Две проверки на NULL — это и есть весь запрос: отправлено проставлено, доставлено нет. На nullable-колонке это читается так же просто, как звучит, а на `NOT NULL` ни одного из этих методов просто нет. ### Бухгалтерская книга Последняя строка выписки по счёту — и есть ли они вообще. ```go latest, err := db.Entries.Query(). Where(Entries.AccountID.Eq(accountID)). OrderBy(Entries.PostedAt.Desc(), Entries.ID.Desc()). One(ctx) if errors.Is(err, orm.ErrNotFound) { // новый счёт, а не сломанный } any, err := db.Entries.Query().Where(Entries.AccountID.Eq(accountID)).Exists(ctx) ``` `ErrNotFound` от `One` — нормальный ответ на нормальный вопрос. `Exists` выбирает константу, а не строку, поэтому вопрос ничего не стоит на декодировании. ### Таблица телеметрии Показания набора устройств за окно времени, потоком — их слишком много, чтобы держать целиком. ```go for reading, err := range db.Readings.Query(). Where(Readings.DeviceID.In(deviceIDs...)). Where(Readings.At.Between(from, to)). OrderBy(Readings.At.Asc()). Rows(ctx) { if err != nil { return err } if err := accumulate(reading); err != nil { return err } } ``` `Rows` отдаёт строки по мере прихода. Всё окно никогда не лежит в памяти целиком — в этом разница между отчётом, который отрабатывает, и тем, который убивают. --- # Предикаты > Какие сравнения даёт каждый тип колонки и почему наборы различаются. https://ormgo.vercel.app/ru/docs/predicates/ ## Набор зависит от типа Предикат есть у дескриптора тогда, когда PostgreSQL определяет операцию для этого типа. Поэтому списки различаются, и поэтому разница — ошибка компиляции, а не рантайма. ### Любая колонка ```go Users.Email.Eq("a@example.com") Users.Email.Ne("a@example.com") Users.Email.In("a@example.com", "b@example.com") Users.ID.In(ids...) // для готового среза ``` `NotIn` намеренно нет. `orm.Not(Users.ID.In(...))` говорит то же самое и говорит один раз. ### Упорядоченные колонки Любой тип, который PostgreSQL упорядочивает, — целые, дробные, текст, даты, `uuid`, `inet`, `interval`: ```go Users.CreatedAt.Gt(t) Users.CreatedAt.Gte(t) Users.CreatedAt.Lt(t) Users.CreatedAt.Lte(t) Users.CreatedAt.Between(from, to) Users.CreatedAt.Asc() Users.CreatedAt.Desc() ``` У `jsonb` и `bytea` есть полный порядок для индексов, но сравнение двух таких значений не отвечает ни на чей вопрос, поэтому они остаются на равенстве. ### Текстовые колонки ```go Users.Email.Like("%@example.com") Users.Email.ILike("%@EXAMPLE.com") orm.Not(Users.Email.Like("%@spam.test")) Users.Email.Like("admin%") Users.Email.Like("%.org") Users.Email.Like("%example%") ``` ### Nullable-колонки Только у них есть эти методы, потому что на `NOT NULL` колонке они отвечали бы на невозможный вопрос: ```go Users.Bio.IsNull() Users.Bio.IsNotNull() Users.Bio.Eq("hello") // тоже доступно: это bio = 'hello' ``` ### Массивы Вхождение в массив — это свободные функции, а не методы, и они дают `Predicate[Composed]`. Не-nullable колонка поднимается через `orm.Opt`: ```go orm.ArrayContains(orm.Opt(Users.Tags), orm.Val([]string{"go"})) // @> orm.ArrayContainedBy(orm.Opt(Users.Tags), orm.Val(all)) // <@ orm.ArrayOverlaps(orm.Opt(Users.Tags), orm.Val([]string{"a", "b"})) // && ``` ### JSONB Тоже свободные функции и по той же причине — с любой стороны может быть колонка или выражение. Весь набор — в разделе [JSON и JSONB](/ru/docs/json/): ```go orm.JSONHasKey(orm.Opt(Users.Meta), "plan") orm.JSONPathExists(orm.Opt(Users.Meta), "$.billing.tier") orm.JSONPathText(orm.Opt(Users.Meta), "billing", "tier") ``` ### Диапазоны ```go Bookings.During.Overlaps(r) Bookings.During.Contains(t) Bookings.During.StrictlyLeftOf(other) Bookings.During.Adjacent(other) ``` ### Полнотекстовый поиск ```go orm.Matches(Docs.Search, orm.PlainToTSQuery(orm.English, "postgres mapper")) orm.TSRank(Docs.Search, query).Desc() ``` ## Комбинирование ```go orm.And(a, b, c) orm.Or(a, b) orm.Not(a) ``` Они вкладываются, и компилятор удерживает их на одной сущности: `orm.And`, смешивающий `Predicate[User]` и `Predicate[Order]`, не компилируется. Это не педантизм — такой предикат дал бы SQL, называющий таблицу, которой в запросе нет. ## Сравнение двух колонок Сравнения «колонка с колонкой» — это свободные функции, а не методы, и они дают `Predicate[Composed]`, поэтому их место в составном запросе, а не в `Where` по сущности: ```go orm.Compose(pool, shape). From(Orders.Source()). Where(orm.Gt(Orders.Total, Orders.Paid)) ``` `Eq`, `Ne`, `Gt`, `Gte`, `Lt` и `Lte` принимают два типизированных значения, и обе стороны обязаны нести один тип значения. Справа может стоять выражение: ```go orm.Eq(Orders.Total, Orders.Net.AddCol(Orders.Tax)) ``` Арифметика на колонке — это метод: `Add`, `Sub`, `Mul`, `Div` со значением и `AddCol` или `SubCol` с другой колонкой той же сущности. ## Сырые фрагменты Когда типизированной формы нет: ```go db.Users.Query().Where(orm.Expr[User]("age(created_at) > interval ?", "1 year")) ``` `Expr` принимает текст SQL намеренно. Значения, вставленные в него, — нет: каждый `?` становится параметром привязки, а плейсхолдеры фрагмента проверяются против переданных аргументов. ## Разобранные примеры ### Доска вакансий Три фильтра, читающиеся как три предложения, и один, которого в SQL нет, пока вы его не напишете. ```go // Удалённые вакансии этого месяца с зарплатой не ниже порога. db.Postings.Query().Where( Postings.Remote.Eq(true), Postings.PostedAt.Gte(monthStart), Postings.SalaryMin.Gte(60000), ) // Всё, кроме заблокированных агентств. db.Postings.Query().Where(orm.Not(Postings.CompanyID.In(blocked...))) // Заголовок или описание — один OR, написанный один раз. db.Postings.Query().Where(orm.Or( Postings.Title.ILike("%golang%"), Postings.Description.ILike("%golang%"), )) ``` ### Очередь модерации Случаи с NULL — там живёт большинство ошибок в фильтрах: ```go // Ни разу не просмотрено: reviewed_at никогда не проставляли. db.Comments.Query().Where(Comments.ReviewedAt.IsNull()) // Просмотрено и одобрено: проставлено, причина не записана. db.Comments.Query().Where( Comments.ReviewedAt.IsNotNull(), Comments.RejectReason.IsNull(), ) // Просмотрено и отклонено с причиной, которая не пустая строка. db.Comments.Query().Where( Comments.RejectReason.IsNotNull(), orm.Not(Comments.RejectReason.Eq("")), ) ``` У `NOT NULL` колонки нет `IsNull`, поэтому первые два по ошибке к ней не применить. ### Прайс-лист Сравнение двух колонок — это составной запрос, а не запрос по сущности: ```go // Всё, что сейчас продаётся ниже себестоимости. orm.Compose(pool, shape). From(Prices.Source()). Where(orm.Lt(Prices.Retail, Prices.Cost)). All(ctx) // Маржа ниже порога, вычисленная, а не хранимая. orm.Compose(pool, shape). From(Prices.Source()). Where(orm.Lt(Prices.Retail.SubCol(Prices.Cost), orm.Val(int32(500)))). All(ctx) ``` --- # Связи > Загружается то, что попросили, за предсказуемое число запросов. https://ormgo.vercel.app/ru/docs/relations/ ## Объявление ```go //orm:table public.users type User struct { ID int64 `orm:"pk,identity"` Orders orm.Many[Order] } //orm:table public.orders type Order struct { ID int64 `orm:"pk,identity"` UserID int64 User orm.One[User] `orm:"fk:user_id"` } ``` Внешний ключ называется на той стороне, которая его держит. Генератор проверяет, что он существует и указывает туда, куда вы сказали. ## Загрузка ```go users, err := db.Users.Query().With(Users.Orders).All(ctx) ``` `With` загружает то, что ему дали, и ничего больше. Ленивой загрузки нет, поэтому цикл по результату не превратится в запрос на строку. ## Предсказуемое число запросов Загрузка идёт в ширину и пакетами. Число запросов зависит от формы запрошенного дерева, а не от количества строк: ```go db.Users.Query(). With(Users.Orders.With(Orders.Items)). All(ctx) // три запроса: пользователи, затем все их заказы, затем все позиции ``` Десять пользователей или десять тысяч — их три. ## Настройка связи `Rel` несёт опции, и они применяются к каждому родителю — именно поэтому «пять последних заказов каждого пользователя» это один запрос, а не N: ```go db.Users.Query(). With(Users.Orders. Where(Orders.Status.Eq("paid")). OrderBy(Orders.Placed.Desc()). Limit(5)). All(ctx) ``` ## Фильтрация по связи без загрузки ```go // пользователи хотя бы с одним оплаченным заказом db.Users.Query().Where(Users.Orders.Any(Orders.Status.Eq("paid"))) // и без единого db.Users.Query().Where(Users.Orders.None(Orders.Status.Eq("refunded"))) ``` Компилируются в полусоединения. Ничего не загружают, поэтому и читать нечего. ## Чтение результата ```go for _, u := range users { orders, ok := u.Orders.Get() if !ok { // не загружено — это не то же самое, что «загружено и пусто» continue } fmt.Println(len(orders)) } ``` Три различимых состояния: не загружено, загружено и пусто, загружено и есть. Нулевое значение — «не загружено», поэтому литерал без связи говорит «я не просил», а не «там пусто». ## Что определяет связанность PostgreSQL. Строки связываются по тому, что база считает равными ключами, поэтому `citext`, `numeric`, домены и составные ключи ведут себя как в базе, а не как Go-шное равенство. ## Разобранные примеры ### Программа конференции Каждый поток со своими докладами, каждый доклад со спикерами — три уровня, три запроса, сколько бы ни было строк. ```go tracks, err := db.Tracks.Query(). Where(Tracks.ConferenceID.Eq(confID)). With(Tracks.Talks. OrderBy(Talks.StartsAt.Asc()). With(Talks.Speakers)). OrderBy(Tracks.Name.Asc()). All(ctx) ``` Сортировка внутри `With` — это сортировка самих докладов. Отсортировать их потом в Go тоже можно, но это значит забрать их в том порядке, в каком их нашёл сервер. ### Инвентаризация склада Товары, которые ни разу не пересчитывали, — фильтр по отсутствию связи, без загрузки: ```go uncounted, err := db.Products.Query(). Where(Products.Counts.None()). OrderBy(Products.SKU.Asc()). All(ctx) ``` И наоборот, с условием на потомке: ```go disputed, err := db.Products.Query(). Where(Products.Counts.Any(Counts.Variance.Gt(0))). All(ctx) ``` Оба компилируются в полусоединения. Ни один не привезёт ни одной строки пересчёта, потому что вы её не просили. ### Входящие поддержки Открытые обращения только с последним сообщением — работу делает лимит на родителя: ```go tickets, err := db.Tickets.Query(). Where(Tickets.Status.Eq("open")). With(Tickets.Messages. OrderBy(Messages.SentAt.Desc()). Limit(1)). OrderBy(Tickets.OpenedAt.Asc()). All(ctx) ``` `Limit(1)` относится к обращению, а не к результату. Один запрос возвращает свежайшее сообщение для каждого из них. --- # Проекции > Выбор нужных колонок в тип, который выбрали вы. https://ormgo.vercel.app/ru/docs/projections/ ## Что такое проекция Проекция — это две вещи, связанные вместе: 1. **какие выражения выбрать** и 2. **функция, которая превращает эти значения в ваш тип результата**. Вот и вся идея. Всё дальше — это она же, но с большим числом колонок. ## Начнём с одной колонки Запрос по сущности возвращает целые сущности: ```go users, err := db.Users.Query().All(ctx) // []User — SELECT id, email, bio, active, created_at FROM users ``` Допустим, нужны только адреса. Скажите это прямо: ```go var Emails = orm.Project1( Users.Email, // выбрать вот это func(email string) string { // и вернуть вот так return email }, ) emails, err := orm.Select(db.Users, Emails).All(ctx) // []string — SELECT email FROM users ``` `[]string`, а не `[]User`. База прислала одну колонку вместо пяти, а тип результата — тот, который вернула ваша функция. ## Две колонки, в структуру Тип результата ваш. Обычно это объявленная вами структура: ```go type Summary struct { ID int64 Email string } var Summaries = orm.Project2( Users.ID, // 1-е выражение Users.Email, // 2-е выражение func(id int64, email string) Summary { // ↑ 1-й параметр ↑ 2-й параметр return Summary{ID: id, Email: email} }, ) rows, err := orm.Select(db.Users, Summaries).All(ctx) // []Summary — SELECT id, email FROM users ``` **Читайте вызов сверху вниз.** `Project2` принимает два выражения, поэтому функция принимает два параметра — в том же порядке. Первый параметр — первая колонка, второй — вторая. **Типы параметров выбираете не вы.** `Users.ID` — это `bigint`, поэтому первый параметр обязан быть `int64`. `Users.Email` — это `text`, поэтому второй обязан быть `string`. Напишете `func(id string, ...)` — не скомпилируется, и несоответствие поймают там, где вы его написали, а не когда придёт строка. Поэтому число стоит прямо в имени: `Project1` для одного выражения, `Project2` для двух и так далее — до `Project50`. ## Всё, что даёт значение Выражение не обязано быть простой колонкой. Агрегат — тоже выражение: ```go type ByStatus struct { Status string Count int64 } var byStatus = orm.Project2( Orders.Status, // колонка orm.Count[Order](), // count(*) func(status string, n int64) ByStatus { return ByStatus{Status: status, Count: n} }, ) rows, err := orm.Select(db.Orders, byStatus). GroupBy(Orders.Status). All(ctx) // []ByStatus — SELECT status, count(*) FROM orders GROUP BY status ``` `orm.Count[Order]()` возвращает `int64`, поэтому второй параметр — `int64`. Правило то же самое. ## Где можно использовать проекцию Проекция говорит, *что выбрать*. Откуда — говорит что-то другое: ```go orm.Select(db.Users, Summaries) // из таблицы users orm.SelectFrom(db.Users, Users.As("u"), Summaries) // из её алиаса orm.Compose(pool, shape) // из источников, которые вы соединили ``` `Projection` — это значение, а не запрос. Постройте её один раз на уровне пакета и используйте из скольких угодно запросов: она неизменяема и безопасна для совместного использования. ```go // одна форма, три запроса active, _ := orm.Select(db.Users, Summaries).Where(Users.Active.Eq(true)).All(ctx) recent, _ := orm.Select(db.Users, Summaries).OrderBy(Users.ID.Desc()).Limit(10).All(ctx) count, _ := orm.Select(db.Users, Summaries).Count(ctx) ``` ## Когда она нужна | Что использовать | Когда | | --- | --- | | Запрос по сущности | Нужна строка и большая часть её колонок | | Проекцию | Нужны несколько колонок, агрегат или форма, которая не является строкой таблицы | Проекция — ещё и единственный способ выбрать то, что вообще не является колонкой: счётчик, сумму, выражение, значение из CTE. ## Агрегаты ```go orm.Count[User]() // count(*) -> int64 orm.CountOf(Users.Bio) // count(bio) -> int64 orm.Max(Orders.Total) // max(total) -> *T orm.Min(Orders.Placed) // min(placed) -> *T orm.SumInt32(Orders.Qty) // sum(qty) -> *int64 orm.AvgInt64(Orders.Qty) // avg(qty) -> *N orm.SumNumeric[Order, Decimal](Orders.Total) ``` Большинство возвращает **указатель**, и это не осторожность. `max` по пустому множеству — это NULL, а результату без указателя его некуда положить: ```go var maxTotal = orm.Project1( orm.Max(Orders.Total), func(v *int64) *int64 { return v }, // nil, когда таблица пуста ) ``` Исключение — `count`: по пустому множеству он ноль, поэтому это обычный `int64`. ## Группировка и HAVING ```go orm.Select(db.Orders, byStatus). Where(Orders.Placed.Gte(cutoff)). GroupBy(Orders.Status). Having(orm.Count[Order]().Gt(100)). OrderBy(Orders.Status.Asc()). All(ctx) ``` Порядок группировки ваш и никогда не сортируется — он определяет группировку, которую выполнит PostgreSQL. ## DISTINCT ```go orm.Select(db.Orders, shape).Distinct() orm.Select(db.Orders, shape).DistinctOn(Orders.UserID) ``` `DISTINCT ON` оставляет первую строку каждой группы равных значений — это другая конструкция, чем `DISTINCT`. Одновременно их задать нельзя: строитель скажет об этом сам, а не выдаст SQL, который отвергнет сервер. ## Имена результатов Когда проекция становится производной таблицей или CTE, её колонкам нужны имена, потому что к ним будут обращаться снаружи: ```go userID := orm.Named("user_id", orm.Of(Orders.UserID)) total := orm.Named("total", orm.Count[orm.Composed]()) ``` Имя обязательно, а не выводится. У `count(*)` его нет, а придумывание имени по отрисованному выражению сделало бы колонку производной таблицы зависящей от того, как её записал компилятор. См. [Композицию](/ru/docs/composition/). ## Насколько широкой может быть проекция `Project50`. Пятьдесят выражений, пятьдесят параметров, один тип строки. Это намного больше, чем проекции нужно обычно. Широкий край существует затем, чтобы библиотека никогда не была причиной, по которой запрос нельзя написать, — а не потому, что функция на пятьдесят параметров считается хорошим стилем. Строка отчёта на шестнадцать колонок — обычное дело, и раньше у неё здесь не было ответа; строка на пятьдесят — сигнал, что понятнее будет запрос по сущности или проекция в структуру, собранную из нескольких меньших. Два замечания по широким конструкторам: - **Параметры позиционные, и имена их не типизируют.** На четырёх колонках перепутанный порядок виден сразу; на тридцати два соседних `string`, поменянные местами, компилируются и работают неверно. Там, где у соседних колонок один тип, называйте параметры функции по колонкам и собирайте результат по именам полей, а не позиционно. - **Связывает их только порядок.** N-е выражение попадает в N-й параметр. Вставка колонки в середину конструктора сдвигает все параметры после неё, и компилятор заметит это, только если типы перестанут совпадать. ## Почему число стоит в имени Эта часть — про Go, а не про SQL, и она здесь для любопытных, а не потому, что она нужна для работы. Go не умеет выразить «список выражений, у которых все типы разные и все запомнены». У вариадика один тип, а пакета параметров типа не существует. Библиотеки, которые делают вид, что умеют, делают это через `[]any` и приведения в рантайме — то есть переносят ошибку с компилятора на пользователя. Выписанное число аргументов — это и есть то, что покупает проверки выше. Оно же оставляет горячий путь строки без рефлексии, без карты и без приведений: сканирование — это N типизированных локальных переменных, один `Scan` и один вызов — на пятидесяти колонках ровно столько же, сколько на двух. От `Project1` до `Project8` конструкторы написаны руками. Остальные порождены из тех же двенадцати строк, и отдельный тест читает порождённый файл обратно и проверяет, что у каждого конструктора выражения, приёмники и аргументы вызова идут в одном порядке, — потому что перестановка в порождённом коде компилируется, сканирует без ошибки и молча возвращает не то число. ## Разобранные примеры ### Отчёт по выставленным счетам Выручка по тарифам за месяц и число списаний рядом — та форма, которая нужна странице финансов: ```go type PlanRevenue struct { Plan string Charges int64 Total *int64 } var planRevenue = orm.Project3( Invoices.Plan, orm.Count[Invoice](), orm.SumInt32(Invoices.AmountCents), func(plan string, n int64, total *int64) PlanRevenue { return PlanRevenue{Plan: plan, Charges: n, Total: total} }, ) rows, err := orm.Select(db.Invoices, planRevenue). Where(Invoices.IssuedAt.Between(monthStart, monthEnd)). GroupBy(Invoices.Plan). OrderBy(Invoices.Plan.Asc()). All(ctx) ``` `Total` — это `*int64`, потому что `sum` по пустому множеству даёт NULL, а тариф без счетов в этом окне — ровно такой случай. Счётчик рядом не указатель, потому что `count` по пустому множеству равен нулю. ### Список устройств Одна колонка в срез — без структуры, потому что держать нечего: ```go var serials = orm.Project1( Devices.Serial, func(s string) string { return s }, ) offline, err := orm.Select(db.Devices, serials). Where(Devices.LastSeenAt.Lt(cutoff)). OrderBy(Devices.Serial.Asc()). All(ctx) // []string ``` ### Широкая строка выгрузки Тот случай, который упирался в предел из восьми колонок: ночная выгрузка, набор колонок которой диктует получатель файла, а не соображения аккуратности. ```go type Shipment struct { Reference string Carrier string Service string Origin string Destination string Weight int32 Pieces int32 Declared *int64 Booked time.Time Collected *time.Time Delivered *time.Time Status string } var shipmentExport = orm.Project12( Shipments.Reference, Shipments.Carrier, Shipments.Service, Shipments.Origin, Shipments.Destination, Shipments.WeightGrams, Shipments.Pieces, Shipments.DeclaredValue, Shipments.BookedAt, Shipments.CollectedAt, Shipments.DeliveredAt, Shipments.Status, func( reference, carrier, service, origin, destination string, weight, pieces int32, declared *int64, booked time.Time, collected, delivered *time.Time, status string, ) Shipment { return Shipment{ Reference: reference, Carrier: carrier, Service: service, Origin: origin, Destination: destination, Weight: weight, Pieces: pieces, Declared: declared, Booked: booked, Collected: collected, Delivered: delivered, Status: status, } }, ) rows, err := orm.Select(db.Shipments, shipmentExport). Where(Shipments.BookedAt.Gte(since)). OrderBy(Shipments.BookedAt.Asc()). All(ctx) ``` Две привычки делают такую широкую проекцию безопасной для правок. Параметры названы по своим колонкам, а не `a, b, c`, — читатель может сверить порядок с конструктором выше, не пересчитывая позиции. И структура собирается по именам полей, так что переставленная пара `string`-параметров, которую компилятор увидеть не может, хотя бы заметна в диффе. Три указателя здесь не для красоты. `CollectedAt` и `DeliveredAt` — NULL у отправления, которое ещё в пути, а `DeclaredValue` — NULL, когда клиент не объявил ценность, и это другой факт, чем объявленный ноль. ### Схема зала Две колонки в ключ карты: тип результата — это то, что вернула функция, и он не обязан быть структурой: ```go type Seat struct{ Row, Number int32 } var seats = orm.Project2( Tickets.SeatRow, Tickets.SeatNumber, func(r, n int32) Seat { return Seat{Row: r, Number: n} }, ) taken, err := orm.Select(db.Tickets, seats). Where(Tickets.EventID.Eq(eventID)). Where(Tickets.CancelledAt.IsNull()). All(ctx) occupied := make(map[Seat]bool, len(taken)) for _, s := range taken { occupied[s] = true } ``` --- # Выражения > Условия, coalesce, приведения, строковые функции и аварийный выход для всего остального. https://ormgo.vercel.app/ru/docs/expressions/ Всё здесь даёт `Expression` или `Value`, а значит, годится везде, где годятся они: в списке выборки, в `WHERE`, в `ORDER BY`, в `GROUP BY` или внутри другого выражения. ## Литералы ```go orm.Val("pending") // Expression[string, *string] orm.Val(int64(0)) orm.Val(true) ``` Литерал становится параметром привязки, а не текстом в SQL. Это верно для любого значения в пакете — и поэтому ни одна из этих функций не принимает строку формата. ## CASE ```go tier := orm.Case(orm.Cond(Orders.Total.Gte(1000)), orm.Val("gold")). When(orm.Cond(Orders.Total.Gte(100)), orm.Val("silver")). Else(orm.Val("bronze")) ``` ```sql CASE WHEN total >= $1 THEN $2 WHEN total >= $3 THEN $4 ELSE $5 END ``` `Case` принимает первое условие и его результат, `When` добавляет следующие, `Else` закрывает. Все ветви несут один тип, поэтому `CASE`, смешивающий строку и число, не компилируется. **`Else` и `End` — разные завершения.** `Else` даёт запасное значение, поэтому результат не может быть NULL и его тип — `T`. `End` закрывает без него, поэтому результат равен NULL, когда ничего не совпало, и тип расширяется до `N`: ```go grade := orm.Case(orm.Cond(Users.Score.Gte(90)), orm.Val("A")).End() // Expression[*string, *string] — NULL, если балл ниже 90 ``` ## COALESCE и NULLIF ```go orm.Coalesce(Users.Nickname, orm.Of(Users.Email)) // ник, а если он NULL — почта -> string, никогда не NULL ``` `Coalesce` принимает сначала nullable-значение, затем запасные. Результат не nullable, потому что последнее запасное таковым не является, — в этом и смысл. `CoalesceNull` — форма, где nullable все входы и результат тоже может им быть. ```go orm.NullIf(orm.Of(Users.Bio), orm.Val("")) // NULL, если bio — пустая строка, иначе bio ``` ## Приведения типов ```go orm.Cast(Users.ID, orm.Text) // id::text -> string orm.Cast(Users.Score, orm.BigInt) // score::bigint orm.CastNull(Users.Bio, orm.Text) // nullable-форма ``` Целевой тип — это значение `PGType`, а не строка, поэтому Go-тип результата определяется приведением, а не утверждается после. Встроенные: ```go orm.Text orm.SmallInt orm.Integer orm.BigInt orm.Boolean orm.ByteA orm.DoublePrecision orm.Date orm.Timestamptz ``` ## Строковые функции ```go orm.Upper(Users.Email) orm.Lower(Users.Email) orm.Trim(Users.Name) orm.Concat(orm.Of(Users.First), orm.Val(" "), orm.Of(Users.Last)) ``` У каждой есть форма `…Null`, принимающая nullable-колонку и возвращающая nullable-результат, потому что `upper(NULL)` — это NULL. ## Арифметика Упорядоченные колонки несут операторы прямо на себе: ```go Orders.Total.Add(10) Orders.Total.Sub(10) Orders.Total.Mul(2) Orders.Total.Div(2) Orders.Total.AddCol(Orders.Tax) // колонка + колонка ``` ## Всё остальное, что есть в PostgreSQL `Fn` вызывает функцию, которую пакет не оборачивает. Вы задаёте имя, аргументы и — через параметр типа — то, что она возвращает: ```go // pg_size_pretty(pg_total_relation_size('users')) size := orm.Fn[User, string]("pg_size_pretty", orm.ArgRaw("pg_total_relation_size('users')")) // greatest(score, 0) floor := orm.Fn[User, int32]("greatest", orm.ArgOf(Users.Score), orm.ArgValue(0)) ``` | Форма | Возвращает | Для чего | | --- | --- | --- | | `Fn[E, T]` | `Value[E, T]` | значение в запросе по сущности | | `FnNull[E, T]` | `Value[E, *T]` | когда может быть NULL | | `FnExpr[T]` | `Expression[T, *T]` | значение в составном запросе | | `FnExprNull[T]` | `Expression[*T, *T]` | | | `FnPredicate[E]` | `Predicate[E]` | функция, возвращающая boolean | Аргументы строятся, а не форматируются: ```go orm.ArgValue(v) // параметр привязки orm.ArgOf(Users.Email) // колонка orm.ArgOpt(Users.Bio) // nullable-колонка orm.ArgCast(v, "uuid") // параметр с явным приведением orm.ArgRaw("now()") // текст SQL, без значений внутри ``` Параметр типа — это обещание о том, что вернёт функция, и оно не проверяется по PostgreSQL. Ошибётесь — упадёт сканирование. Это аварийный выход, и он честно им является. ## Сырые фрагменты Когда даже `Fn` не той формы: ```go db.Users.Query().Where(orm.Expr[User]("age(created_at) > interval ?", "1 year")) ``` `Expr` принимает текст SQL намеренно. Значения, вставленные в него, — нет: каждый `?` становится параметром привязки, а плейсхолдеры фрагмента считаются против переданных аргументов, поэтому несоответствие — это ошибка сборки, а не невнятная ошибка сервера. ## Разобранные примеры ### Тарифная категория посылки `CASE`, превращающий число в метку прямо в SQL, чтобы по ней можно было группировать: ```go band := orm.Case(orm.Cond(Parcels.Grams.Lt(500)), orm.Val("letter")). When(orm.Cond(Parcels.Grams.Lt(2000)), orm.Val("small")). When(orm.Cond(Parcels.Grams.Lt(20000)), orm.Val("parcel")). Else(orm.Val("freight")) var byBand = orm.Project2( band, orm.Count[orm.Composed](), func(b string, n int64) Band { return Band{b, n} }, ) orm.Compose(pool, byBand).From(Parcels.Source()).GroupBy(band).All(ctx) ``` Сделать это в Go значило бы вытащить все посылки, чтобы посчитать четыре числа. ### Отображаемое имя, которое никогда не пустое ```go name := orm.Coalesce(Members.Nickname, orm.Of(Members.Email)) ``` `Nickname` nullable, `Email` — нет, поэтому результат не может быть NULL и его тип — `string`. Цепочка запасных значений это доказывает, а не подразумевает. ### Пустое как отсутствующее ```go // Пустая заметка — не заметка. note := orm.NullIf(orm.Of(Tickets.Note), orm.Val("")) ``` ### Регистронезависимое сравнение, которое пользуется индексом ```go // С индексом по lower(email) это им воспользуется; ILike — нет. orm.Compose(pool, shape).From(Members.Source()). Where(orm.Eq(orm.Lower(Members.Email), orm.Lower(orm.Val(input)))) ``` ### То, что есть в PostgreSQL и не обёрнуто пакетом ```go // greatest(stock - reserved, 0) available := orm.Fn[Item, int32]("greatest", orm.ArgOf(Items.Stock.SubCol(Items.Reserved)), orm.ArgValue(int32(0))) // Функция, возвращающая boolean, в роли предиката. db.Items.Query().Where(orm.FnPredicate[Item]("pg_try_advisory_lock", orm.ArgOf(Items.ID))) ``` Параметр типа — ваше обещание о типе результата. По PostgreSQL оно не проверяется, и именно это делает такой вызов аварийным выходом, а не основной дорогой. --- # Оконные функции > Ранжирование, соседние строки и накопительные итоги — по строке, не схлопывая их. https://ormgo.vercel.app/ru/docs/windows/ ## Что они делают Агрегат схлопывает строки: `count(*)` по десяти строкам вернёт одну. Оконная функция считает по строкам и **сохраняет каждую** — у каждой строки свой ответ, вычисленный по строкам вокруг неё. ```go rn := orm.RowNumber().Over(orm.Window(). PartitionBy(orm.Of(Posts.AuthorID)). OrderBy(orm.Of(Posts.CreatedAt).Desc())) ``` ```sql row_number() OVER (PARTITION BY author_id ORDER BY created_at DESC) ``` Читается так: **начинать нумерацию заново для каждого автора** и **внутри автора сортировать от новых к старым**. ## Две половины Оконная функция — это всегда функция плюс окно: ```go orm.RowNumber().Over(orm.Window()...) // └ функция └ окно, через которое она смотрит ``` Окно строит `orm.Window()`: | Метод | Добавляет | | --- | --- | | `PartitionBy(...)` | `PARTITION BY` — начинать заново для каждой группы | | `OrderBy(...)` | `ORDER BY` — порядок внутри секции | | `Rows(start, end)` | рамку `ROWS` | | `Range(start, end)` | рамку `RANGE` | | `Groups(start, end)` | рамку `GROUPS` | Любое можно опустить. `Over(orm.Window())` без настроек — одно окно на весь результат. ## Как её использовать Оконная функция — это выражение, поэтому она идёт в проекцию как любое другое: ```go type Ranked struct { Title string N int64 } rn := orm.RowNumber().Over(orm.Window(). PartitionBy(orm.Of(Posts.AuthorID)). OrderBy(orm.Of(Posts.CreatedAt).Desc())) shape := orm.Project2( orm.Of(Posts.Title), rn, func(title string, n int64) Ranked { return Ranked{title, n} }, ) rows, err := orm.Compose(pool, shape).From(Posts.Source()).All(ctx) ``` ## Функции ### Ранжирование ```go orm.RowNumber() // 1, 2, 3, 4 -> int64 orm.Rank() // 1, 2, 2, 4 -> int64 (при равенстве делят и пропускают) orm.DenseRank() // 1, 2, 2, 3 -> int64 (делят без пропуска) orm.PercentRank() // 0.0 … 1.0 -> float64 orm.CumeDist() // накопленная доля -> float64 orm.Ntile(4) // номер квартиля -> int32 ``` `Rank` и `DenseRank` различаются только тем, что происходит после равенства, — и именно это чаще всего понимают неправильно: `Rank` оставляет дыру, `DenseRank` нет. ### Доступ к другим строкам ```go orm.Lag(Posts.Score) // значение предыдущей строки orm.LagN(Posts.Score, 3) // на три строки назад orm.Lead(Posts.Score) // значение следующей строки orm.LeadN(Posts.Score, 3) orm.FirstValue(Posts.Score) // первое в рамке orm.LastValue(Posts.Score) // последнее в рамке orm.NthValue(Posts.Score, 2) // второе ``` Все они возвращают **nullable**-форму типа колонки. У первой строки нет предыдущей, у последней нет следующей — поэтому `Lag` по `NOT NULL` колонке всё равно `*T`, и тип это говорит, вместо того чтобы дать NULL прийти туда, где его негде держать. ### Агрегаты как оконные функции Любой агрегат становится оконной функцией через `Over`: ```go running := orm.SumInt64[Order, int64](Orders.Total).Over(orm.Window(). OrderBy(orm.Of(Orders.Placed).Asc()). Rows(orm.UnboundedPreceding(), orm.CurrentRow())) ``` ```sql sum(total) OVER (ORDER BY placed ASC ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) ``` Это накопительный итог: каждая строка видит себя и всё, что было до неё. ## Рамки Рамка сужает набор строк секции, которые видит функция. Границы: ```go orm.UnboundedPreceding() // начало секции orm.Preceding(3) // на три строки назад orm.CurrentRow() orm.Following(3) orm.UnboundedFollowing() // конец секции ``` ```go // скользящее среднее по семи строкам orm.AvgInt64[Order, float64](Orders.Total).Over(orm.Window(). OrderBy(orm.Of(Orders.Placed).Asc()). Rows(orm.Preceding(6), orm.CurrentRow())) ``` `Rows` считает строки. `Range` считает по значению, поэтому равные по `ORDER BY` попадают вместе. `Groups` считает группы равных. На данных с повторами это разные ответы — поэтому есть все три, а не одна. ## Куда оконную функцию поставить нельзя Ни в `WHERE`, ни в `HAVING`. PostgreSQL вычисляет окна после этих конструкций, поэтому значения там ещё не существует. Чтобы отфильтровать по нему, вычислите его в производной таблице и фильтруйте снаружи — это и есть рецепт Top-N: ```go rank := orm.Named("rn", orm.RowNumber().Over(orm.Window(). PartitionBy(orm.Of(Orders.UserID)). OrderBy(orm.Of(Orders.Placed).Desc()))) ranked := orm.Sub("ranked", orm.Rows( orm.Named("id", orm.Of(Orders.ID)), rank, ).From(Orders.Source())) rows, err := orm.Compose(pool, shape). From(ranked). Where(orm.Ref(ranked, rank).Lte(3)). // три последних заказа каждого All(ctx) ``` Целиком этот рецепт — в разделе [Сложные запросы](/ru/docs/cookbook/insane/). ## Разобранные примеры ### Таблица лидеров с учётом равенства ```go w := orm.Window().OrderBy(orm.Of(Scores.Points).Desc()) var board = orm.Project3( orm.Of(Scores.Player), orm.Rank().Over(w), // 1, 2, 2, 4 — равенство оставляет дыру orm.DenseRank().Over(w), // 1, 2, 2, 3 — не оставляет func(p string, r, d int64) Row { return Row{p, r, d} }, ) orm.Compose(pool, board).From(Scores.Source()).All(ctx) ``` Какой вариант верен, зависит от того, должно ли существовать «третье место», когда двое делят второе. Это продуктовое решение, и две функции позволяют его принять. ### Изменение с прошлого показания ```go w := orm.Window(). PartitionBy(orm.Of(Meters.MeterID)). OrderBy(orm.Of(Meters.ReadAt).Asc()) previous := orm.Lag(Meters.Value).Over(w) var deltas = orm.Project3( orm.Of(Meters.MeterID), orm.Of(Meters.Value), previous, func(id int64, now int32, before *int32) Delta { return Delta{id, now, before} }, ) ``` `before` — указатель, потому что у первого показания каждого счётчика позади ничего нет. Секционирование начинает отсчёт заново на каждом счётчике. ### Нарастающий баланс ```go running := orm.SumInt32[orm.Composed](orm.Of(Entries.AmountCents)). Over(orm.Window(). PartitionBy(orm.Of(Entries.AccountID)). OrderBy(orm.Of(Entries.PostedAt).Asc()). Rows(orm.UnboundedPreceding(), orm.CurrentRow())) ``` ### Скользящее среднее за семь дней ```go avg := orm.AvgInt64[orm.Composed, float64](orm.Of(Daily.Total)). Over(orm.Window(). OrderBy(orm.Of(Daily.Day).Asc()). Rows(orm.Preceding(6), orm.CurrentRow())) ``` `Rows(6 preceding, current)` — это семь строк вместе с текущей. `Range` вместо него сгруппировал бы дни с равными значениями, а скользящее среднее означает не это. --- # Композиция > Джойны, CTE, производные таблицы и подзапросы — один компилятор, один запрос. https://ormgo.vercel.app/ru/docs/composition/ ## Типизированный запрос — это типизированный источник В этом вся идея. `Sub` делает из запроса производную таблицу, `CTE` — элемент `WITH`, а `Compose` строит запрос над несколькими из них. Всё вкладывается через один компилятор, поэтому у запроса с CTE, производной таблицей, коррелированным подзапросом и оконной функцией **один список параметров**, пронумерованный в порядке написания SQL. ## Compose и джойны ```go type Row struct { Email string Total *int64 } shape := orm.Project2( orm.Of(Users.Email), orm.Opt(Orders.Total), func(email string, total *int64) Row { return Row{email, total} }, ) rows, err := orm.Compose(pool, shape). From(Users.Source()). LeftJoin(Orders.Source(), orm.Eq(Orders.UserID, Users.ID)). Where(orm.Cond(Users.Active.Eq(true))). OrderBy(orm.Of(Users.Email).Asc()). All(ctx) ``` Три функции переносят типизированные вещи в составной запрос: - `orm.Of(col)` — сохраняет собственный тип колонки. - `orm.Opt(col)` — nullable-форма, для outer-joined источника. - `orm.Cond(pred)` — предикат сущности как составной. ## Nullability, наведённая источником `orders.total` может быть `NOT NULL` и всё равно оказаться NULL здесь, потому что джойн способен дать строку, где правого источника нет вовсе. Поэтому тип расширяется, а чтение через `Of` отвергается: ```text select-list expression 2 reads public.orders, which an outer join can leave with no row, into a result that cannot hold NULL an outer join makes every value of that source nullable, whatever the column's own constraint says; read it with Opt or OptRef, which widen the result type ``` Это отказ на этапе сборки, а не ошибка сканирования, обнаруженная на той строке, которая случайно не совпала. ## Производные таблицы ```go userID := orm.Named("user_id", orm.Of(Orders.UserID)) count := orm.Named("order_count", orm.Count[orm.Composed]()) stats := orm.Sub("post_stats", orm.Rows(userID, count). From(Orders.Source()). GroupBy(orm.Of(Orders.UserID))) rows, err := orm.Compose(pool, shape). From(Users.Source()). LeftJoin(stats, orm.Eq(orm.Ref(stats, userID), orm.Of(Users.ID))). All(ctx) ``` `Ref(src, out)` читает колонку источника строк, типизированную по объявлению, а не по строковому имени. `OptRef` — его форма для outer join. ## CTE ```go active := orm.CTE("active_users", orm.Rows( orm.Named("id", orm.Of(Users.ID)), ).From(Users.Source()).Where(orm.Cond(Users.Active.Eq(true)))) rows, err := orm.Compose(pool, shape). With(active). From(active). Join(Orders.Source(), orm.Eq(Orders.UserID, orm.Ref(active, id))). All(ctx) ``` Возвращаемое значение — одновременно объявление, которое отрисует `With`, и ссылка, которую принимают `From` и джойны. Алиас через `As` даёт вторую ссылку на тот же элемент — так один CTE джойнится сам с собой. `Materialized()` и `NotMaterialized()` доступны там, где оценка планировщика неверна. Оставить это незаданным — правильное умолчание. ## Рекурсивные CTE ```go tree := orm.RecursiveCTE("tree", anchor, // нерекурсивный терм recursive, // терм, ссылающийся на "tree" ) ``` Это единственное место, где внутри библиотеки появляется `UNION`, потому что грамматика PostgreSQL требует его между якорем рекурсивного CTE и рекурсивным термом. ## Подзапросы ```go orm.Exists[User](sub) // EXISTS (...) orm.NotExists[User](sub) orm.InSub(Users.ID, sub) // id IN (SELECT ...) orm.Scalar[User, int64](sub) // скалярный подзапрос — всегда nullable ``` Скалярный подзапрос всегда nullable, и это семантика PostgreSQL, а не осторожность: ноль строк даёт NULL, одна — значение, а две — ошибку времени выполнения на сервере. ## Область видимости проверяется последовательно Ссылка на вхождение, которого запрос не вводит, отвергается. Правила — собственные правила SQL: условие джойна видит источники, написанные левее, и тот, который присоединяет, и ничего правее. ```go // отказ: c присоединяется после условия, которое его называет q.From(a).Join(b, orm.Eq(b.X, c.X)).Join(c, ...) ``` Проверка по готовому множеству источников приняла бы это и оставила PostgreSQL жаловаться позже, своими словами, про колонку — а не про порядок написания. ## Один список параметров ```go sql, args, _ := q.SQL() // ... WHERE a = $1 AND b = $2 ... (SELECT ... WHERE c = $3) ... LIMIT ... ``` Вложенные запросы разделяют писателя, поэтому нумерация продолжается на всех уровнях. Ничто не отрисовывается отдельно и не склеивается. ## Разобранные примеры ### Панель автопарка Каждая машина с последним известным показанием, причём машина, которая ни разу не отчиталась, всё равно появляется — ради этого и нужен outer join. ```go type Status struct { Plate string Fuel *int32 } shape := orm.Project2( orm.Of(Vehicles.Plate), orm.Opt(Readings.FuelPercent), func(plate string, fuel *int32) Status { return Status{plate, fuel} }, ) rows, err := orm.Compose(pool, shape). From(Vehicles.Source()). LeftJoin(Readings.Source(), orm.Eq(Readings.VehicleID, Vehicles.ID)). Where(orm.Cond(Vehicles.Retired.Eq(false))). OrderBy(orm.Of(Vehicles.Plate).Asc()). All(ctx) ``` `Opt`, а не `Of`: машина без показаний даёт строку, в которой источника показаний нет вовсе. `*int32` — это тот же факт, выраженный типом. ### Каталог со счётчиками Производная таблица считает отзывы и джойнится обратно, чтобы товары без отзывов тоже попали в список: ```go productID := orm.Named("product_id", orm.Of(Reviews.ProductID)) reviews := orm.Named("reviews", orm.Count[orm.Composed]()) stats := orm.Sub("review_stats", orm.Rows(productID, reviews). From(Reviews.Source()). GroupBy(orm.Of(Reviews.ProductID))) shape := orm.Project2( orm.Of(Products.Name), orm.OptRef(stats, reviews), func(name string, n *int64) Listing { return Listing{name, n} }, ) rows, err := orm.Compose(pool, shape). From(Products.Source()). LeftJoin(stats, orm.Eq(orm.Ref(stats, productID), orm.Of(Products.ID))). All(ctx) ``` `OptRef` — это `Ref` для источника, который outer join может оставить пустым. Счётчик внутри производной таблицы не может быть NULL; прочитанный через этот джойн — может. ### Когорта, названная один раз CTE оправдан, когда один и тот же набор нужен дважды или когда имя делает запрос читаемым: ```go signups := orm.CTE("recent_signups", orm.Rows( orm.Named("id", orm.Of(Accounts.ID)), ).From(Accounts.Source()). Where(orm.Cond(Accounts.CreatedAt.Gte(weekStart)))) rows, err := orm.Compose(pool, shape). With(signups). From(signups). Join(Invoices.Source(), orm.Eq(Invoices.AccountID, orm.Ref(signups, id))). All(ctx) ``` --- # UNION ALL > Композиция двух типизированных SELECT в один, с сохранением дубликатов. https://ormgo.vercel.app/ru/docs/union-all/ ## Область v1 составляет `UNION ALL` и ничего больше. `UNION`, `INTERSECT` и `EXCEPT` в неё не входят, и компилятор отвергает любую другую операцию, а не оставляет дыру, которую кто-то обнаружит в рантайме. `UNION ALL` сохраняет дубликаты. В этом и состоит операция: если их нужно было убрать, вам нужна была другая. ## Как её написать Ветвь — это любой типизированный запрос: `Query` по сущности, `SelectQuery`, `ComposedQuery` или другой `UnionQuery`. Все ветви дают один и тот же Go-тип результата, а аргумент типа пишется явно: Go не выводит его из интерфейса, который ветвь случайно удовлетворяет. ```go type Row struct { ID int64 Label string } // Одна форма на обе ветви. Имена важны: составной запрос сортируется по имени // колонки результата, поэтому объявляем их через As. shape := orm.Project2( orm.Of(Users.ID).As("thing_id"), orm.Of(Users.Email).As("label"), func(id int64, label string) Row { return Row{ID: id, Label: label} }, ) fromUsers := orm.Compose(pool, shape).From(Users.Source()) fromPosts := orm.Compose(pool, shape).From(Posts.Source()) rows, err := orm.UnionAll[Row](fromUsers, fromPosts).All(ctx) ``` ```sql SELECT "users"."id" AS "thing_id", "users"."email" AS "label" FROM "public"."users" UNION ALL SELECT "posts"."id" AS "thing_id", "posts"."title" AS "label" FROM "public"."posts" ``` `All`, `One`, `Rows` и `SQL` работают так же, как в любом другом запросе. Если ветви строились без исполнителя, дайте его составному запросу через `Using`: ```go rows, err := orm.UnionAll[Row](fromUsers, fromPosts).Using(pool).All(ctx) ``` ## Сортировка результата `OrderBy` у составного запроса принимает **объявление колонки результата**, а не колонку, — потому что `ORDER BY` составного запроса может называть только колонку результата: ```go label := orm.Named("label", orm.Of(Users.Email)) rows, err := orm.UnionAll[Row](fromUsers, fromPosts). OrderBy(label.Asc()). Limit(20). All(ctx) ``` Сортировка по колонке не скомпилируется: ```go .OrderBy(Users.ID.Asc()) // не компилируется .OrderBy(orm.Of(Users.ID).Asc()) // тоже не компилируется ``` Это намеренно. Оба варианта отрисовали бы терм, который PostgreSQL отвергает сразу, и `OutputOrder` существует, чтобы сделать их ненаписуемыми, а не чтобы поймать позже. ## Правила У ветвей должны быть **в точности** совместимые формы результата — то же число колонок, тот же порядок, те же Go-типы, та же nullability. Контракт v1 намеренно строже неявных приведений PostgreSQL: - `int32` и `int64` не сливаются. - `uuid.UUID` и `string` не сливаются. - Nullable и не-nullable не сливаются. Для части этих случаев PostgreSQL нашёл бы общий тип. Разрешить это значило бы поставить Go-тип результата в зависимость от правила приведения, которого никто не читает, поэтому ответ — отказ с указанием расхождения. ## ORDER BY и LIMIT внутри ветви Ветвь может нести собственные `ORDER BY`, `LIMIT` и `OFFSET`, и это значит именно то, что написано: ```sql (SELECT ... ORDER BY placed DESC LIMIT 2) UNION ALL (SELECT ... ORDER BY placed DESC LIMIT 2) LIMIT 3 ``` Скобки — это грамматика, а не стиль. Без них PostgreSQL относит эти конструкции ко всему составному запросу, и ветвь, которая выглядит ограниченной, таковой не является. Компилятор ставит скобки ровно тогда, когда ветвь несёт что-то из этого. ## ORDER BY и LIMIT составного запроса `ORDER BY`, `LIMIT` и `OFFSET` на составном запросе применяются к полному результату, после обеих ветвей: ```sql SELECT ... UNION ALL SELECT ... ORDER BY "email" ASC LIMIT 10 ``` `ORDER BY` составного запроса может называть **колонку результата** и ничего больше. Это правило PostgreSQL: квалифицированная ссылка даёт `missing FROM-clause entry`, а выражение — `invalid UNION/INTERSECT/EXCEPT ORDER BY clause`. Без внешнего `ORDER BY` порядок результата не обещан. ## Плейсхолдеры глобальны Ветви делят один список параметров: ```sql SELECT ... WHERE email = $1 UNION ALL SELECT ... WHERE label = $2 ``` Перезапуск нумерации во второй ветви дал бы SQL, который PostgreSQL принимает и в который подставляет не те значения, — поэтому именно это свойство стоит первым в реализации. ## Область видимости — своя у каждой ветви Ветвь видит элементы `WITH` составного запроса и собственные источники. Чужие — нет. Ветвь не является механизмом разделения области видимости, и это структурно: каждая ветвь открывает свой кадр области. ## Вложенность `A UNION ALL B UNION ALL C` — это одна операция над тремя входами, поэтому она отрисовывается плоско. Построение как `A UNION ALL (B UNION ALL C)` заключает внутренний в скобки, потому что вы этого попросили — и потому что собственные `ORDER BY` и `LIMIT` внутреннего иначе привязались бы к внешнему. ## Один запрос Составной запрос — это один SQL-оператор. Это никогда не два запроса, строки которых складываются в Go: так потерялись бы общий `ORDER BY` и `LIMIT`, а один поход к серверу стал бы двумя. ## Разобранные примеры ### Одна лента активности из трёх таблиц Комментарии, лайки и подписки, перемешанные по времени. Одинаковую форму им задаёт проекция: ```go type Item struct { At time.Time Kind string Text string } feed := func(at orm.Expression[time.Time, *time.Time], kind string, text orm.Expression[string, *string]) orm.Projection[orm.Composed, Item] { return orm.Project3(at, orm.Val(kind), text, func(a time.Time, k, t string) Item { return Item{a, k, t} }) } when := orm.Named("at", orm.Of(Comments.PostedAt)) rows, err := orm.UnionAll[Item]( orm.Compose(pool, feed(orm.Of(Comments.PostedAt), "comment", orm.Of(Comments.Body))). From(Comments.Source()), orm.Compose(pool, feed(orm.Of(Likes.LikedAt), "like", orm.Of(Likes.Target))). From(Likes.Source()), orm.Compose(pool, feed(orm.Of(Follows.At), "follow", orm.Of(Follows.Handle))). From(Follows.Source()), ).OrderBy(when.Desc()).Limit(50).All(ctx) ``` Три таблицы, один оператор, один `ORDER BY` по всему результату. Забирать каждую отдельно и сливать в Go значило бы получить все три целиком, прежде чем взять пятьдесят свежайших. ### Живые строки и архивные Одна и та же форма из двух таблиц, которые суть одна таблица, разделённая по возрасту: ```go rows, err := orm.UnionAll[Row]( orm.Compose(pool, shape).From(Orders.Source()). Where(orm.Cond(Orders.PlacedAt.Gte(cutoff))), orm.Compose(pool, shape).From(ArchivedOrders.Source()). Where(orm.Cond(ArchivedOrders.PlacedAt.Lt(cutoff))), ).All(ctx) ``` ### Лимиты ветвей и лимит составного запроса ```go // По две свежайших из каждой, затем три свежайших в целом. rows, err := orm.UnionAll[Row]( orm.Compose(pool, shape).From(Inbox.Source()). OrderBy(orm.Of(Inbox.At).Desc()).Limit(2), orm.Compose(pool, shape).From(Archive.Source()). OrderBy(orm.Of(Archive.At).Desc()).Limit(2), ).OrderBy(when.Desc()).Limit(3).All(ctx) ``` Забираются четыре строки, возвращаются три. Лимиты ветвей заключены в скобки, поэтому остаются лимитами ветвей. --- # Запись данных > Insert, update, delete и COPY — всё явно. https://ormgo.vercel.app/ru/docs/writing/ ## Insert ```go user, err := db.Users.Insert(ctx, User{ Email: "a@example.com", Active: true, }) // user.ID заполнен из RETURNING ``` Много сразу: ```go users, err := db.Users.InsertMany(ctx, []User{u1, u2, u3}) ``` ## Значения по умолчанию Нулевое значение Go — это значение. Запрос значения по умолчанию отдельный и явный: ```go db.Users.Insert(ctx, User{}, orm.Default(Users.Active, Users.CreatedAt)) ``` Названные колонки полностью исключаются из `INSERT`, поэтому применяется `DEFAULT` колонки — или её последовательность, или NULL для nullable-колонки без умолчания. ## Update ```go n, err := db.Users.Update(). Set(Users.Active.Set(false)). Where(Users.CreatedAt.Lt(cutoff)). Exec(ctx) ``` Обновление без `WHERE` отвергается: ```go _, err := db.Users.Update().Set(Users.Active.Set(false)).Exec(ctx) // errors.Is(err, orm.ErrMissingWhere) ``` Если только вы не скажете, что имелись в виду все строки: ```go db.Users.Update().Set(Users.Active.Set(false)).All().Exec(ctx) ``` Присвоение выражения вместо значения: ```go db.Orders.Update(). Set(Orders.Total.SetExpr(Orders.Net.AddCol(Orders.Tax))). Where(Orders.ID.Eq(id)) ``` ## Delete ```go n, err := db.Users.Delete().Where(Users.ID.Eq(id)).Exec(ctx) ``` То же правило `ErrMissingWhere` и по той же причине. ## Upsert ```go db.Users.Insert(ctx, user, orm.OnConflict(Users.Email).DoUpdateSet( Users.Active.Set(true), ), ) db.Users.Insert(ctx, user, orm.OnConflict(Users.Email).DoNothing()) ``` Цель конфликта — список колонок, по которому PostgreSQL его определяет; `DO UPDATE` видит конфликтную строку и `EXCLUDED`. ## Upsert подробно `OnConflict` называет колонки, по которым PostgreSQL определяет конфликт, — обычно это колонки уникального ограничения. ```go // Ничего не делать, если запись уже есть. db.Users.Insert(ctx, user, orm.OnConflict(Users.Email).DoNothing()) // Взять значения пришедшей строки для названных колонок. db.Users.Insert(ctx, user, orm.OnConflict(Users.Email).DoUpdate(Users.Name, Users.Seen)) // Или задать их самому. db.Users.Insert(ctx, user, orm.OnConflict(Users.Email).DoUpdateSet( Users.Seen.Set(time.Now()), Users.Hits.SetExpr(Users.Hits.Add(1)), )) ``` `DoUpdate` — частый случай, читается как «эти колонки берут новые значения». `DoUpdateSet` — когда новое значение вычисляется: инкремент счётчика, выбор большего из двух чисел, добавление в массив. Частичному индексу нужен тот же предикат в конструкции конфликта: ```go orm.OnConflict(Users.Email).Where(Users.Active.Eq(true)).DoNothing() ``` ## RETURNING PostgreSQL умеет возвращать строки, которых коснулась запись. Библиотека использует это тремя способами, и разницу стоит знать: два из них происходят сами, а третий — нет. ### Вставка возвращает всегда ```go user, err := db.Users.Insert(ctx, User{Email: "a@example.com"}) // user.ID заполнен; как и CreatedAt, и всё, что заполнила база ``` Поэтому `Insert` возвращает `(E, error)`, а не одну ошибку. Ключ identity, `DEFAULT now()`, генерируемая колонка и правки триггера — всё приходит в возвращённом значении, поэтому назад вы получаете строку в том виде, в каком она существует, а не ту структуру, которую отправили. `InsertMany` делает то же для среза, по порядку: ```go users, err := db.Users.InsertMany(ctx, []User{a, b, c}) // users[1].ID — ключ b ``` Список колонок всегда явный. `RETURNING *` определял бы порядок сканирования на сервере, где сгенерированный сканер его не видит. ### Upsert возвращает выжившую строку ```go user, err := db.Users.Insert(ctx, incoming, orm.OnConflict(Users.Email).DoUpdate(Users.Name, Users.Seen)) ``` Вставила она или обновила — назад приходит строка, которая теперь лежит в таблице. Это обычная причина брать `DoUpdate`, а не `DoNothing`: при конфликте `DoNothing` не возвращает **ни одной** строки, поэтому вы получаете нулевую сущность и по ней не отличите «уже была» от «только что записана». ### Обновление и удаление — только если попросить `Exec` возвращает счётчик: ```go n, err := db.Users.Update(). Set(Users.Active.Set(false)). Where(Users.CreatedAt.Lt(cutoff)). Exec(ctx) // n — сколько строк изменилось ``` Счётчик отвечает на «сколько». Когда нужно «какие», оберните строитель: ```go updated, err := orm.UpdateReturningEntity( db.Users.Update().Set(Users.Active.Set(false)).Where(Users.CreatedAt.Lt(cutoff)), ).All(ctx) // []User — каждая совпавшая строка в том виде, в каком она после обновления ``` ```go deleted, err := orm.DeleteReturningEntity( db.Users.Delete().Where(Users.ID.Eq(id)), ).One(ctx) // строка в том виде, в каком она была прямо перед тем, как перестать существовать ``` Обратите внимание на время. Обновление возвращает **новые** значения, удаление — строку, которой уже нет. И то и другое — единственный шанс: после оператора одну уже не запросить, а другая больше не хранит старых значений. ### Вернуть форму, а не сущность Когда нужны только две колонки изменившегося: ```go type Changed struct { ID int64 Email string } var changed = orm.Project2( Users.ID, Users.Email, func(id int64, email string) Changed { return Changed{id, email} }, ) rows, err := orm.UpdateReturning( db.Users.Update().Set(Users.Active.Set(false)).Where(cond), changed, ).All(ctx) // []Changed ``` `DeleteReturning` принимает проекцию так же. ### Терминалы У `Returning` их три и больше никаких: | Метод | Для чего | | --- | --- | | `All(ctx)` | все строки, которых коснулась запись | | `One(ctx)` | ровно одна или `ErrNotFound`; больше одной — ошибка | | `SQL()` | оператор и аргументы, без выполнения | `Exec` у `Returning` нет: оператор, у которого вы запросили строки и тут же их выбросили, — это оператор, которому с самого начала был нужен `Exec`. ### Это по-прежнему запись `ErrMissingWhere` действует ровно так же, как и без `RETURNING`: обёртка не делает безусловное обновление безопасным: ```go _, err := orm.UpdateReturningEntity( db.Users.Update().Set(Users.Active.Set(false)), ).All(ctx) // errors.Is(err, orm.ErrMissingWhere) ``` И это один оператор. Строки приходят из самой записи, а не из `SELECT` после неё, — именно поэтому это те строки, которых коснулась запись, даже при конкурентном доступе, а не те, что подходят сейчас. ## COPY Для массовой загрузки `COPY` на порядок быстрее `INSERT`: ```go n, err := db.Events.CopyFrom(ctx, events) ``` Потоком, чтобы строки не существовали все сразу: ```go n, err := db.Events.CopyFromSeq(ctx, func(yield func(Event, error) bool) { for scanner.Scan() { ev, err := parse(scanner.Text()) if !yield(ev, err) { return } } }) ``` Подмножество колонок: ```go n, err := orm.CopyColumns(ctx, db.Events, events, Events.ID, Events.Kind) ``` Упавший `COPY` падает как один оператор — не применяется ничего. Если он должен быть успешным вместе с другой работой, оберните в транзакцию. ## Разобранные примеры ### Импорт, который запускают дважды Идемпотентен по построению: второй запуск обновляет, а не дублирует. ```go for _, row := range parsed { _, err := db.Products.Insert(ctx, row, orm.OnConflict(Products.SKU).DoUpdate( Products.Name, Products.PriceCents, Products.UpdatedAt)) if err != nil { return err } } ``` Для большого файла — один оператор на пачку вместо одного на строку: ```go for chunk := range slices.Chunk(parsed, 1000) { if _, err := db.Products.InsertMany(ctx, chunk, orm.OnConflict(Products.SKU).DoUpdate(Products.PriceCents)); err != nil { return err } } ``` ### Бронирование, которое нельзя продать дважды Запись и проверка — один оператор, поэтому между ними ничто не вклинится: ```go seat, err := db.Seats.Update(). Set(Seats.HeldBy.Set(customerID)). Set(Seats.HeldUntil.Set(time.Now().Add(10*time.Minute))). Where(Seats.ID.Eq(seatID)). Where(Seats.HeldBy.IsNull()). Exec(ctx) if seat == 0 { return ErrAlreadyHeld // успел кто-то другой } ``` `HeldBy.IsNull()` в `WHERE` и есть блокировка. У «прочитать, потом записать» был бы зазор; здесь его нет. ### Задача очистки Удалить и сохранить удалённое для журнала аудита: ```go gone, err := orm.DeleteReturningEntity( db.Sessions.Delete().Where(Sessions.ExpiresAt.Lt(time.Now())), ).All(ctx) for _, s := range gone { recordAudit("session.expired", s.ID, s.UserID) } ``` ### Счётчик, который не читает сначала ```go db.PageViews.Update(). Set(PageViews.Hits.SetExpr(PageViews.Hits.Add(1))). Where(PageViews.Path.Eq(path)). Exec(ctx) ``` Прочитать строку, прибавить единицу в Go и записать обратно — значит терять инкременты при конкурентном доступе. Здесь это невозможно. --- # Транзакции > Один колбэк, одна транзакция, никакого скрытого состояния. https://ormgo.vercel.app/ru/docs/transactions/ ## Форма ```go err := db.Tx(ctx, func(tx *domain.DB) error { user, err := tx.Users.Insert(ctx, User{Email: email}) if err != nil { return err } _, err = tx.Orders.Insert(ctx, Order{UserID: user.ID}) return err }) ``` Колбэк получает `DB`, привязанный к транзакции. Тот, на котором его вызвали, не затрагивается — поэтому нет «текущей транзакции» в окружении и нет способа случайно записать мимо неё. Возврат nil фиксирует транзакцию. Возврат ошибки откатывает её. Паника откатывает и паникует дальше. **Ничего не повторяется** — политика повторов зависит от того, чем была работа, а библиотека этого не знает. ## Опции ```go err := db.TxOptions(ctx, pgx.TxOptions{ IsoLevel: pgx.Serializable, AccessMode: pgx.ReadWrite, }, func(tx *domain.DB) error { return nil }) ``` ## Ошибки сериализации На уровне `Serializable` PostgreSQL может прервать транзакцию, которая нарушила бы сериализуемость. Это не ошибка для лога — это указание попробовать снова: ```go for attempt := range 3 { err := db.TxOptions(ctx, opts, work) var pge *pgconn.PgError if errors.As(err, &pge) && pge.Code == "40001" { continue // serialization_failure } return err } ``` Цикл повторов ваш, потому что задержка, предел и сама безопасность повтора — тоже ваши. ## Без сгенерированного кода `RunTx` принимает любой исполнитель: ```go err := orm.RunTx(ctx, pool, func(ex orm.Executor) error { repo := orm.NewRepo(ex, &meta) return nil }) ``` ## Чем транзакция не является Она не единица работы, отслеживающая ваши изменения. Нет ни грязного отслеживания, ни flush: оператор выполняется тогда, когда вы его вызвали. Благодаря этому порядок операторов в логе — это порядок операторов в вашем коде, а это то самое свойство, которое нужно в три часа ночи. ## Разобранные примеры ### Перевод между счетами Обе части или ни одной. Классика — и причина, по которой у транзакции такая форма с колбэком: ```go err := db.Tx(ctx, func(tx *domain.DB) error { if _, err := tx.Accounts.Update(). Set(Accounts.Balance.SetExpr(Accounts.Balance.Sub(amount))). Where(Accounts.ID.Eq(from)). Where(Accounts.Balance.Gte(amount)). // не даёт уйти в минус Exec(ctx); err != nil { return err } _, err := tx.Accounts.Update(). Set(Accounts.Balance.SetExpr(Accounts.Balance.Add(amount))). Where(Accounts.ID.Eq(to)). Exec(ctx) return err }) ``` Проверка баланса стоит в `WHERE`, а не в Go, поэтому овердрафт — это обновление, не нашедшее строк, а не гонка. ### Заказ и его позиции ```go err := db.Tx(ctx, func(tx *domain.DB) error { order, err := tx.Orders.Insert(ctx, Order{CustomerID: id}) if err != nil { return err } for i := range lines { lines[i].OrderID = order.ID // ключ, который вернула вставка } _, err = tx.OrderLines.InsertMany(ctx, lines) return err }) ``` ### Воркер, забирающий пачку `SKIP LOCKED` — то, что позволяет двум воркерам выполнять один и тот же запрос и не сталкиваться: ```go err := db.Tx(ctx, func(tx *domain.DB) error { jobs, err := tx.Jobs.Query(). Where(Jobs.State.Eq("queued")). OrderBy(Jobs.Priority.Desc(), Jobs.QueuedAt.Asc()). Limit(20). Lock(orm.ForUpdateStrong, orm.SkipLocked()). All(ctx) if err != nil { return err } for _, j := range jobs { if _, err := tx.Jobs.Update(). Set(Jobs.State.Set("running")). Where(Jobs.ID.Eq(j.ID)). Exec(ctx); err != nil { return err } } return nil }) ``` --- # Рецепты запросов > Повседневные формы, выписанные целиком — фильтры, страницы, агрегаты, соединения, окна, upsert, поиск. https://ormgo.vercel.app/ru/docs/cookbook/queries/ Каждый рецепт здесь достаточно полный, чтобы его вставить к себе. `db` — это порождённый `*domain.DB`; `Users`, `Orders` и прочие — порождённые дескрипторы. Предметные области меняются от рецепта к рецепту нарочно: важна форма, а форму, которую вы видели только на `users`, приходится сначала перевести на свою задачу. ## Фильтрация ### Необязательные фильтры из запроса ```go func (s *Store) Search(ctx context.Context, f Filter) ([]User, error) { q := s.db.Users.Query() if f.Email != "" { q = q.Where(Users.Email.ILike("%" + f.Email + "%")) } if f.Active != nil { q = q.Where(Users.Active.Eq(*f.Active)) } if !f.Since.IsZero() { q = q.Where(Users.CreatedAt.Gte(f.Since)) } return q.OrderBy(Users.CreatedAt.Desc()).Limit(f.Limit).All(ctx) } ``` Ни одного фильтра — значит вообще никакого `WHERE`, а не `WHERE TRUE`. ### Или одно, или другое ```go db.Users.Query().Where(orm.Or( Users.Email.ILike("%@example.com"), Users.Email.ILike("%@example.org"), )) ``` ### Всё, кроме ```go db.Users.Query().Where(orm.Not(Users.ID.In(banned...))) ``` ### NULL против пустоты ```go db.Users.Query().Where(Users.Bio.IsNull()) // never set db.Users.Query().Where(Users.Bio.Eq("")) // set to empty db.Users.Query().Where(orm.Or( Users.Bio.IsNull(), Users.Bio.Eq(""), )) // either ``` ### Одна колонка против другой Отправление, которое весит больше, чем было заявлено: ```go db.Shipments.Query().Where(orm.OpPredicate[Shipment]( ">", orm.ArgOf(Shipments.ActualGrams), orm.ArgOf(Shipments.QuotedGrams), )) ``` ### Диапазон значений ```go db.Readings.Query().Where(Readings.Celsius.Between(-10, 45)) ``` Двусторонний и включающий — именно это `BETWEEN` и значит в SQL. Если нужна строгая граница с одной стороны, пишите `Gte` и `Lt`: тогда читателю видно, какая. ### Множество значений из среза ```go db.Flights.Query().Where(Flights.Origin.In("LHR", "CDG", "AMS")) db.Flights.Query().Where(Flights.Origin.In(hubs...)) ``` ### Пустой срез — не ошибка ```go // In() over nothing matches nothing, which is the SQL answer and rarely // the one a caller expected. Decide it where the intent is. if len(codes) == 0 { return nil, nil } db.Flights.Query().Where(Flights.Origin.In(codes...)) ``` ### Без учёта регистра и без функции на колонке ```go db.Artists.Query().Where(Artists.Name.ILike(input)) ``` `ILike` без шаблонных символов — это равенство без учёта регистра, и оно остаётся пригодным для индекса на колонке `citext` или при подходящем индексе по выражению, чего `lower(name) = lower($1)` не даёт, если именно такого индекса нет. ### Поиск по префиксу, который может использовать индекс ```go db.Artists.Query().Where(Artists.Name.Like(prefix + "%")) ``` Ведущий шаблонный символ (`"%" + s`) B-дерево использовать не даёт. Если он нужен, вам нужен [полнотекстовый поиск](/ru/docs/fulltext/) или триграммный индекс, а не `LIKE`. ### Три состояния nullable-булева ```go db.Applications.Query().Where(Applications.Approved.Eq(true)) // approved db.Applications.Query().Where(Applications.Approved.Eq(false)) // rejected db.Applications.Query().Where(Applications.Approved.IsNull()) // undecided ``` ### Вложенные и/или, оставшиеся читаемыми ```go db.Tickets.Query().Where(orm.And( Tickets.EventID.Eq(eventID), orm.Or( Tickets.Status.Eq("reserved"), orm.And( Tickets.Status.Eq("pending"), Tickets.HeldUntil.Gt(time.Now()), ), ), )) ``` ### Фильтр, собранный из набора ```go q := db.Devices.Query() for _, f := range []struct { want string eq func(string) orm.Predicate[Device] }{ {model, Devices.Model.Eq}, {region, Devices.Region.Eq}, } { if f.want != "" { q = q.Where(f.eq(f.want)) } } ``` ### Исключение подзапросом ```go db.Users.Query().Where(orm.NotInSub( orm.Of(Users.ID), orm.Compose(pool, blockedIDs).From(Blocks.Source()), )) ``` ## Сортировка ### Два ключа в разные стороны ```go db.Leaderboard.Query().OrderBy( Leaderboard.Score.Desc(), Leaderboard.AchievedAt.Asc(), ) ``` Дополнительный ключ здесь не для красоты. Без него порядок равных очков — тот, что выдал план, и он меняется от запуска к запуску. ### NULL там, где вы их хотите ```go db.Tasks.Query().OrderBy(Tasks.DueAt.Asc()) ``` PostgreSQL кладёт NULL последними при `ASC` и первыми при `DESC`. Если задачи без срока должны быть в конце списка `DESC`, сортируйте по выражению с подстановкой: ```go due := orm.CoalesceNull(orm.Of(Tasks.DueAt), orm.Val(farFuture)) orm.Select(db.Tasks, shape).OrderBy(due.Desc()) ``` ### Сортировка по тому, что вы же и выбрали ```go distance := postgis.OfGeog(Stops.Spot).Distance(postgis.GeogValue[Stop](here)) orm.Select(db.Stops, nearest).OrderBy(distance.Asc()).Limit(10) ``` ### Сортировка по агрегату ```go orm.Select(db.Orders, byCustomer). GroupBy(Orders.CustomerID). OrderBy(orm.Count[Order]().Desc()). Limit(25) ``` ### Устойчивый порядок для выгрузок ```go db.Invoices.Query().OrderBy(Invoices.IssuedAt.Asc(), Invoices.ID.Asc()) ``` Любой выгрузке, которую сравнивают между двумя запусками, нужен полный порядок. Первичный ключ в конце — самый дешёвый способ его гарантировать. ### Случайная выборка ```go db.Photos.Query(). OrderBy(orm.Fn[Photo, float64]("random").Asc()). Limit(10) ``` Годится на сотне тысяч строк и неверно на сотне миллионов: сортируется вся таблица. На таких объёмах выбирайте по диапазону ключа. ## Постраничный вывод ### По смещению ```go db.Users.Query().OrderBy(Users.ID.Asc()).Limit(20).Offset(page * 20) ``` ### По ключу Корректно на изменяющейся таблице и остаётся быстрым на пятитысячной странице: ```go q := db.Users.Query().OrderBy(Users.CreatedAt.Desc(), Users.ID.Desc()).Limit(20) if cursor != nil { q = q.Where(orm.Or( Users.CreatedAt.Lt(cursor.At), orm.And(Users.CreatedAt.Eq(cursor.At), Users.ID.Lt(cursor.ID)), )) } ``` ### По ключу, когда ключ уникален Когда колонка сортировки уже уникальна, сравнение кортежей схлопывается: ```go q := db.Events.Query().OrderBy(Events.Seq.Asc()).Limit(500) if after > 0 { q = q.Where(Events.Seq.Gt(after)) } ``` ### Всего и страница — по одному обращению на каждое ```go total, err := db.Users.Query().Where(cond).Count(ctx) page, err := db.Users.Query().Where(cond).Limit(20).All(ctx) ``` ### Есть ли следующая страница Дешевле счётчика и обычно это всё, что нужно интерфейсу: ```go rows, err := db.Users.Query().OrderBy(Users.ID.Asc()).Limit(21).All(ctx) hasNext := len(rows) > 20 if hasNext { rows = rows[:20] } ``` ### Прочитать таблицу целиком, не держа её в памяти ```go rows, err := db.Events.Query().OrderBy(Events.ID.Asc()).Rows(ctx) if err != nil { return err } defer rows.Close() for rows.Next() { e, err := rows.Value() if err != nil { return err } if err := sink(e); err != nil { return err } } return rows.Err() ``` ## Агрегация ### Счётчик по группам ```go type ByStatus struct { Status string N int64 } var byStatus = orm.Project2( Orders.Status, orm.Count[Order](), func(s string, n int64) ByStatus { return ByStatus{s, n} }, ) rows, _ := orm.Select(db.Orders, byStatus). GroupBy(Orders.Status). OrderBy(Orders.Status.Asc()). All(ctx) ``` ### Только нагруженные группы ```go orm.Select(db.Orders, byStatus). GroupBy(Orders.Status). Having(orm.Count[Order]().Gt(100)) ``` ### Агрегаты по пустому множеству ```go var maxShape = orm.Project1(orm.Max(Orders.Total), func(v *int64) *int64 { return v }) // nil when there are no rows — max over nothing is NULL, and the type says so ``` ### Счётчик nullable-колонки — это не count(*) ```go var coverage = orm.Project2( orm.Count[User](), // every row orm.CountOf(Users.Bio), // rows whose bio is not NULL func(all, withBio int64) Coverage { return Coverage{all, withBio} }, ) ``` ### Счётчик различных значений ```go var uniqueVisitors = orm.Project1( orm.CountOf(Visits.SessionID).Distinct(), func(n int64) int64 { return n }, ) ``` ### Несколько агрегатов за один проход Ровно то, зачем нужен `GROUP BY`: один проход, пять чисел: ```go type Daily struct { Day time.Time Orders int64 Revenue *int64 Largest *int64 Average *float64 } day := orm.DateTrunc(orm.Day, Orders.PlacedAt) var daily = orm.Project5( orm.Named("day", day), orm.Count[Order](), orm.SumInt32(Orders.TotalCents), orm.Max(Orders.TotalCents), orm.AvgInt32(Orders.TotalCents), func(d time.Time, n int64, sum, max *int64, avg *float64) Daily { return Daily{Day: d, Orders: n, Revenue: sum, Largest: max, Average: avg} }, ) ``` ### Условные агрегаты Посчитать две вещи сразу, без двух запросов: ```go paid := orm.Count[Order]().Filter(Orders.Status.Eq("paid")) refunded := orm.Count[Order]().Filter(Orders.Status.Eq("refunded")) var split = orm.Project3( Orders.CustomerID, paid, refunded, func(id int64, p, r int64) Split { return Split{id, p, r} }, ) ``` `FILTER` — это конструкция ровно для такого случая. `sum(case when … then 1 else 0 end)` даёт тот же ответ, но записан менее внятно. ### Группировка по вычисленному значению ```go month := orm.DateTrunc(orm.Month, Subscriptions.StartedAt) orm.Select(db.Subscriptions, monthly). GroupBy(month). OrderBy(month.Asc()) ``` Группируйте по выражению, а не по псевдониму: там, где вычисляется `GROUP BY`, псевдонима ещё не существует. ### Два ключа группировки ```go orm.Select(db.Sales, byRegionAndQuarter). GroupBy(Sales.Region, quarter). OrderBy(Sales.Region.Asc(), quarter.Asc()) ``` ### Средние, которые не врут ```go orm.AvgInt32(Ratings.Stars) // *float64 — NULL over no rows orm.SumInt32(Ratings.Stars) // *int64 — NULL over no rows orm.Count[Rating]() // int64 — zero over no rows ``` Указатель здесь не перестраховка. `avg` по пустой группе — это NULL, а у `float64` нет значения, означающего «усреднять было нечего». ### Доля от общего ```go var share = orm.Project2( Sales.Region, orm.Named("pct", orm.Op( orm.Op(orm.SumInt32(Sales.Cents), "*", orm.Val(int64(100))), "/", orm.Fn[Sale, int64]("sum", orm.Of(Sales.Cents)).Over(orm.Window()), )), func(region string, pct *int64) Share { return Share{region, pct} }, ) ``` ### Самый загруженный час каждого дня ```go hour := orm.DateTrunc(orm.Hour, Rides.StartedAt) day := orm.DateTrunc(orm.Day, Rides.StartedAt) ranked := orm.RowNumber().Over( orm.Window().PartitionBy(day).OrderBy(orm.Count[Ride]().Desc()), ) ``` ### DISTINCT ON: по одной строке на группу, дёшево ```go orm.Select(db.Prices, latest). DistinctOn(Prices.SKU). OrderBy(Prices.SKU.Asc(), Prices.ObservedAt.Desc()) ``` `ORDER BY` обязан начинаться с колонок `DISTINCT ON` — именно он решает, какая строка каждой группы выживет. Здесь — самая свежая цена по каждому SKU. ## Оконные функции ### Нумерация строк внутри группы ```go rank := orm.RowNumber().Over( orm.Window(). PartitionBy(orm.Of(Results.HeatID)). OrderBy(orm.Of(Results.TimeMillis).Asc()), ) ``` ### Rank, dense rank и разница между ними ```go w := orm.Window().OrderBy(orm.Of(Scores.Points).Desc()) orm.Rank().Over(w) // 1, 2, 2, 4 — gaps after ties orm.DenseRank().Over(w) // 1, 2, 2, 3 — no gaps orm.RowNumber().Over(w) // 1, 2, 3, 4 — arbitrary among ties ``` ### Накопительный итог ```go w := orm.Window(). OrderBy(orm.Of(Entries.At).Asc()). Rows(orm.UnboundedPreceding(), orm.CurrentRow()) running := orm.Fn[Entry, int64]("sum", orm.Of(Entries.Cents)).Over(w) ``` ### Изменение относительно предыдущей строки ```go prev := orm.Lag(orm.Of(Readings.Celsius)).Over( orm.Window(). PartitionBy(orm.Of(Readings.SensorID)). OrderBy(orm.Of(Readings.At).Asc()), ) ``` ### Скользящее среднее ```go w := orm.Window(). OrderBy(orm.Of(Ticks.At).Asc()). Rows(orm.Preceding(6), orm.CurrentRow()) sevenDay := orm.Fn[Tick, float64]("avg", orm.Of(Ticks.Price)).Over(w) ``` ### Первое и последнее в разделе ```go w := orm.Window(). PartitionBy(orm.Of(Events.SessionID)). OrderBy(orm.Of(Events.At).Asc()). Rows(orm.UnboundedPreceding(), orm.UnboundedFollowing()) orm.FirstValue(orm.Of(Events.Page)).Over(w) // the landing page orm.LastValue(orm.Of(Events.Page)).Over(w) // the exit page ``` `LastValue` требует явной рамки. С рамкой по умолчанию он возвращает текущую строку — это самый частый сюрприз оконных функций. ### Квартили ```go orm.Ntile(4).Over( orm.Window().OrderBy(orm.Of(Customers.LifetimeCents).Desc()), ) ``` ### Одно окно, использованное несколько раз ```go w := orm.Window(). PartitionBy(orm.Of(Orders.CustomerID)). OrderBy(orm.Of(Orders.PlacedAt).Asc()) seq := orm.RowNumber().Over(w) prevAt := orm.Lag(orm.Of(Orders.PlacedAt)).Over(w) firstAt := orm.FirstValue(orm.Of(Orders.PlacedAt)).Over(w) ``` ## Соединения и композиция ### Внутреннее соединение с проекцией ```go type Line struct { Order int64 Product string Qty int32 } var lines = orm.Project3( orm.Of(Items.OrderID), orm.Of(Products.Name), orm.Of(Items.Qty), func(o int64, p string, q int32) Line { return Line{o, p, q} }, ) rows, err := orm.Compose(pool, lines). From(Items.Source()). Join(Products.Source(), orm.Of(Items.ProductID).EqCol(orm.Of(Products.ID))). All(ctx) ``` ### Левое соединение и та nullable-ность, которую оно навязывает ```go var withLast = orm.Project2( orm.Of(Users.Email), orm.Opt(Orders.PlacedAt), func(email string, last *time.Time) Row { return Row{email, last} }, ) orm.Compose(pool, withLast). From(Users.Source()). LeftJoin(Orders.Source(), orm.Of(Users.ID).EqCol(orm.Of(Orders.UserID))) ``` `orm.Opt` здесь не «на всякий случай». Внешнее соединение может дать NULL для каждой колонки правой стороны, а приёмник типа `time.Time` этого не вместит. ### Соединение трёх таблиц ```go orm.Compose(pool, shape). From(Orders.Source()). Join(Customers.Source(), orm.Of(Orders.CustomerID).EqCol(orm.Of(Customers.ID))). Join(Regions.Source(), orm.Of(Customers.RegionID).EqCol(orm.Of(Regions.ID))) ``` ### Самосоединение через псевдоним Сотрудники и их руководители из одной таблицы: ```go mgr := Employees.As("mgr") orm.Compose(pool, pairs). From(Employees.Source()). LeftJoin(mgr.Source(), orm.Of(Employees.ManagerID).EqCol(orm.Of(mgr.ID))) ``` Псевдоним — это второе вхождение той же таблицы, и его дескрипторы привязаны именно к нему, так что `mgr.ID` не может случайно означать `Employees.ID`. ### LATERAL для «первых N на каждую строку» ```go recent := orm.Compose(pool, orderShape). From(Orders.Source()). Where(orm.Of(Orders.CustomerID).EqCol(orm.Of(Customers.ID))). OrderBy(orm.Of(Orders.PlacedAt).Desc()). Limit(3) orm.Compose(pool, shape). From(Customers.Source()). LeftJoinLateral(recent.As("recent")) ``` ### CTE, использованный дважды ```go active := orm.CTE("active", orm.Compose(pool, userShape). From(Users.Source()). Where(orm.Of(Users.Active).Eq(true))) orm.Compose(pool, shape). With(active). From(active.Source()). Join(Orders.Source(), orm.Of(Orders.UserID).EqCol(orm.Of(active.ID))) ``` ### UNION ALL двух форм ```go orm.UnionAll( orm.Compose(pool, feed).From(Posts.Source()), orm.Compose(pool, feed).From(Comments.Source()), ).OrderBy(orm.Of(feedAt).Desc()).Limit(50) ``` Обе ветви обязаны давать одну форму: то же число колонок, те же типы, ту же nullable-ность. Это проверяется при сборке, а не когда запрос выполнит PostgreSQL. ### Антисоединение, двумя способами ```go // Correlated NOT EXISTS — usually the plan you want. orm.Compose(pool, shape).From(Users.Source()).Where( orm.NotExists(orm.Compose(pool, one). From(Orders.Source()). Where(orm.Of(Orders.UserID).EqCol(orm.Of(Users.ID)))), ) // Left join and test for NULL — the same rows, a different plan. orm.Compose(pool, shape). From(Users.Source()). LeftJoin(Orders.Source(), orm.Of(Users.ID).EqCol(orm.Of(Orders.UserID))). Where(orm.Opt(Orders.ID).IsNull()) ``` ### Скалярный подзапрос в списке выборки ```go orderCount := orm.Scalar(orm.Compose(pool, countShape). From(Orders.Source()). Where(orm.Of(Orders.UserID).EqCol(orm.Of(Users.ID)))) var withCount = orm.Project2( orm.Of(Users.Email), orm.Named("orders", orderCount), func(email string, n *int64) Row { return Row{email, n} }, ) ``` ### Перекрёстное соединение для плотного календаря Каждый день на каждый товар, чтобы у дня без продаж всё равно была строка: ```go orm.Compose(pool, shape). From(days.Source()). CrossJoin(Products.Source()). LeftJoin(Sales.Source(), orm.And( orm.Of(Sales.Day).EqCol(orm.Of(days.Day)), orm.Of(Sales.ProductID).EqCol(orm.Of(Products.ID)), )) ``` ## Связи ### Пять самых свежих на каждого родителя Один запрос, а не по одному на пользователя: ```go db.Users.Query(). With(Users.Orders.OrderBy(Orders.Placed.Desc()).Limit(5)). All(ctx) ``` ### Родители, у которых есть потомок ```go db.Users.Query().Where(Users.Orders.Any(Orders.Status.Eq("paid"))) ``` ### Родители, у которых потомков нет ```go db.Users.Query().Where(Users.Orders.None()) ``` ### Глубокая загрузка ```go db.Users.Query(). With(Users.Orders.With(Orders.Items.With(Items.Product))). All(ctx) // four statements regardless of row counts ``` ### Загрузка отфильтрованной ветви ```go db.Users.Query(). With(Users.Orders.Where(Orders.Status.Eq("paid"))). All(ctx) ``` Фильтр применяется к загрузке потомков, а не к родителям. Пользователи без оплаченных заказов всё равно вернутся — с пустым срезом. ### Фильтрация родителей по полю потомка ```go db.Albums.Query().Where(Albums.Tracks.Any(Tracks.DurationMs.Gt(600_000))) ``` ### Родители, у которых подходят все потомки Выражено как «ни один потомок не подходит под обратное» — именно это SQL и умеет проверить: ```go db.Orders.Query().Where(orm.Not(Orders.Items.Any(Items.InStock.Eq(false)))) ``` ### Связь и агрегат рядом ```go db.Playlists.Query(). With(Playlists.Tracks.Limit(3)). // a preview of the contents All(ctx) // the true size, separately, because a limited load cannot tell you it orm.Select(db.Tracks, byPlaylist).GroupBy(Tracks.PlaylistID) ``` ### Многие-ко-многим через таблицу связи ```go db.Students.Query(). With(Students.Courses). All(ctx) ``` ### Незагруженный случай виден ```go u, _ := db.Users.Query().One(ctx) // no With // u.Orders is nil, and nil means "not loaded", not "none exist". // Nothing fetches it behind the field access — a loop cannot become N queries. ``` ## Запись ### Вставить одну строку и забрать то, что решила база ```go u, err := db.Users.Insert(ctx, User{Email: "ada@example.com", Name: "Ada"}) if err != nil { return err } // the returned value carries what the database decided: u.ID, u.CreatedAt ``` ### Вставить много одним запросом ```go saved, err := db.Tags.InsertMany(ctx, tags) if err != nil { return err } ``` ### Значение по умолчанию вместо нулевого значения ```go db.Users.Insert(ctx, u, orm.Default(Users.Role)) ``` `Role: ""` означает пустую строку, потому что нулевое значение Go — это значение. Попросить умолчание колонки — отдельное намерение, поэтому и вызов отдельный. ### Обновление по первичному ключу ```go n, err := db.Users.Update(). Set(Users.Name.Set("Ada Lovelace")). Where(Users.ID.Eq(id)). Exec(ctx) ``` ### Инкремент без предварительного чтения ```go db.Counters.Update(). Set(Counters.Hits.SetExpr(Counters.Hits.Add(1))). Where(Counters.Key.Eq(key)). Exec(ctx) ``` ### Колонка из другой колонки ```go db.Invoices.Update(). Set(Invoices.BalanceCents.SetExpr(Invoices.TotalCents.SubCol(Invoices.PaidCents))). Where(Invoices.ID.Eq(id)). Exec(ctx) ``` ### Очистить nullable-колонку ```go db.Users.Update(). Set(Users.DeactivatedAt.SetNull()). Where(Users.ID.Eq(id)). Exec(ctx) ``` ### Обновить и сразу прочитать результат ```go type Moved struct { ID int64 State string } var moved = orm.Project2( Jobs.ID, Jobs.Status, func(id int64, s string) Moved { return Moved{id, s} }, ) rows, err := orm.UpdateReturning( db.Jobs.Update().Set(Jobs.Status.Set("running")).Where(Jobs.Status.Eq("pending")), moved, ).All(ctx) ``` Один запрос. Альтернатива — обновить, а потом выбрать то, что обновили, — это два запроса и гонка между ними. ### Удалить и сохранить удалённое ```go gone, err := orm.DeleteReturningEntity( db.Sessions.Delete().Where(Sessions.ExpiresAt.Lt(time.Now())), ).All(ctx) ``` ### Массовая загрузка ```go n, err := db.Events.CopyFrom(ctx, batch) ``` `COPY`, а не многострочный `INSERT`. Он несравнимо быстрее и не поддерживает `ON CONFLICT` — если нужно и то и другое, грузите во временную таблицу и сливайте оттуда. ### Массовая загрузка из потока ```go n, err := db.Events.CopyFromSeq(ctx, func(yield func(Event) bool) { for scanner.Scan() { e, err := parse(scanner.Text()) if err != nil { return } if !yield(e) { return } } }) ``` Целиком файл нигде не держится. Строки уходят на сервер по мере разбора. ### Удаление ограниченными порциями Удаление десяти миллионов строк как последовательность коротких транзакций, чтобы ничто не держало блокировку час: ```go for { n, err := db.Events.Delete(). Where(Events.ID.In(nextIDs...)). Exec(ctx) if err != nil { return err } if n == 0 { return nil } } ``` ### TRUNCATE, осознанно ```go if err := ormtest.TruncateWith(ctx, pool, []ormtest.TruncateOption{ormtest.RestartIdentity()}, Staging, ); err != nil { return err } ``` Это не `DELETE` без `WHERE`. Это другой оператор с другими блокировками и без построчной работы, и пишется он иначе — так что до него нельзя добраться, забыв условие. ## Upsert и конфликты ### Upsert по естественному ключу ```go db.Users.Insert(ctx, user, // Take the new row's values for these columns. orm.OnConflict(Users.Email).DoUpdate(Users.Name, Users.UpdatedAt), ) ``` ### Вставить, если нет; промолчать, если есть ```go db.Tags.Insert(ctx, tag, orm.OnConflict(Tags.Slug).DoNothing()) ``` ### Upsert, который вычисляет новое значение Для счётчика «побеждает последний» неверно; здесь значение прибавляется: ```go db.Counters.Insert(ctx, c, orm.OnConflict(Counters.Key).DoUpdateSet( Counters.Hits.SetExpr(Counters.Hits.AddCol(orm.Excluded(Counters.Hits))), ), ) ``` `orm.Excluded` — это строка, которую предложили и отвергли, псевдотаблица `EXCLUDED`, названная так же, как в SQL. ### Перезаписывать, только если пришедшее свежее ```go db.Prices.Insert(ctx, p, orm.OnConflict(Prices.SKU). DoUpdate(Prices.Cents, Prices.ObservedAt). Where(Prices.ObservedAt.Lt(orm.Excluded(Prices.ObservedAt))), ) ``` ### Upsert целой пачки ```go db.Inventory.InsertMany(ctx, rows, orm.OnConflict(Inventory.SKU, Inventory.WarehouseID). DoUpdate(Inventory.OnHand), ) ``` Цель конфликта — колонки уникального ограничения, в любом порядке, — но оно должно существовать, иначе PostgreSQL нечем обнаруживать конфликт. ## JSON и массивы Это свободные функции, дающие `Predicate[Composed]`, поэтому они идут в составной запрос. `orm.Opt` поднимает не-nullable колонку: ```go meta := orm.Opt(Users.Meta) tags := orm.Opt(Users.Tags) orm.Compose(pool, shape).From(Users.Source()).Where( orm.JSONHasKey(meta, "plan"), ) orm.Compose(pool, shape).From(Users.Source()).Where( orm.JSONContains(meta, orm.Val(map[string]any{"plan": "pro"})), ) // the text at a path, cast to something comparable tier := orm.CastNull(orm.JSONPathText(meta, "billing", "tier"), orm.Text) orm.Compose(pool, shape).From(Users.Source()).Where( orm.ArrayContains(tags, orm.Val([]string{"go", "sql"})), orm.ArrayOverlaps(tags, orm.Val([]string{"go"})), ) ``` ### Любой из ключей, все ключи ```go orm.JSONHasAnyKeys(meta, "plan", "trial") orm.JSONHasAllKeys(meta, "plan", "seats") ``` ### Достать вложенное значение ```go city := orm.JSONPathText(orm.Opt(Profiles.Data), "address", "city") var byCity = orm.Project2( orm.Of(Profiles.UserID), orm.Named("city", city), func(id int64, city *string) Row { return Row{id, city} }, ) ``` ### Элемент по индексу ```go first := orm.JSONIndexText(orm.Opt(Orders.Lines), 0) ``` ### Запись внутрь JSON-документа ```go db.Profiles.Update(). Set(Profiles.Data.SetExpr(orm.JSONSet( Profiles.Data, []string{"verified"}, orm.Val(true), true, ))). Where(Profiles.UserID.Eq(id)). Exec(ctx) ``` ### Убрать null перед сохранением ```go orm.JSONStripNulls(orm.Opt(Profiles.Data)) ``` ### Длина массива ```go tagCount := orm.Fn[Post, int32]("array_length", orm.ArgOf(Posts.Tags), orm.ArgValue(1)) orm.Select(db.Posts, shape).Where(tagCount.Gt(3)) ``` ### Массив, содержащий все перечисленные значения ```go orm.ArrayContains(orm.Opt(Posts.Tags), orm.Val([]string{"go", "postgres"})) ``` «Содержит» значит «является надмножеством». Для «есть хотя бы одно из» нужен `ArrayOverlaps` — это разные вопросы, и операторы у них тоже разные. ### Массив, вложенный в список разрешённого ```go orm.ArrayContainedBy(orm.Opt(Roles.Granted), orm.Val(allowed)) ``` ## Полнотекстовый поиск ```go q := orm.PlainToTSQuery(orm.English, input) type Hit struct { ID int64 Title string Rank float32 } var hits = orm.Project3( Docs.ID, Docs.Title, orm.TSRank(Docs.Search, q), func(id int64, title string, rank float32) Hit { return Hit{id, title, rank} }, ) orm.Select(db.Docs, hits). Where(orm.Matches(Docs.Search, q)). OrderBy(orm.TSRank(Docs.Search, q).Desc()). Limit(20). All(ctx) ``` ### Приём поискового синтаксиса от пользователя ```go q := orm.WebSearchToTSQuery(orm.English, input) ``` `websearch_to_tsquery` принимает кавычки для фраз, `or` и `-исключение` и никогда не падает с синтаксической ошибкой на бессмыслице — именно это и нужно текстовому полю. `to_tsquery` падает, поэтому направлять его на пользовательский ввод неправильно. ### Фраза, в порядке слов ```go q := orm.PhraseToTSQuery(orm.English, "ada lovelace") ``` ### Комбинирование запросов ```go must := orm.PlainToTSQuery(orm.English, required) nice := orm.PlainToTSQuery(orm.English, optional) orm.AndTSQuery(must, orm.NotTSQuery(nice)) ``` ### Заголовок весомее тела ```go vec := orm.Concat2TSVector( orm.SetWeight(orm.ToTSVector(orm.English, orm.Of(Docs.Title)), "A"), orm.SetWeight(orm.ToTSVector(orm.English, orm.Of(Docs.Body)), "B"), ) ``` ### Ранжирование с учётом расстояния ```go orm.TSRankCD(Docs.Search, q).Desc() ``` ## Время, даты и диапазоны ```go db.Bookings.Query().Where(Bookings.During.Overlaps( orm.ClosedOpen(from, to), )) db.Events.Query().Where(Events.At.Between(dayStart, dayEnd)) ``` ### Округление до периода ```go month := orm.DateTrunc(orm.Month, Invoices.IssuedAt) ``` ### Достать поле ```go dow := orm.Extract(orm.DayOfWeek, Rides.StartedAt, orm.Integer) year := orm.Extract(orm.Year, Rides.StartedAt, orm.Integer) ``` ### Время сервера, а не клиента ```go db.Sessions.Update(). Set(Sessions.SeenAt.SetExpr(orm.Now())). Where(Sessions.ID.Eq(id)). Exec(ctx) ``` Часы базы — те, с которыми уже согласована каждая строка. Часы сервера приложения могут отличаться на секунды, а в кластере — отличаться друг от друга. ### Прибавить интервал ```go expires := orm.AddInterval(Tokens.IssuedAt, orm.Val(orm.IntervalOf(0, 1, 0))) ``` ### Всё, что истекает в ближайший час ```go db.Tokens.Query().Where(Tokens.ExpiresAt.Between(now, now.Add(time.Hour))) ``` ### Диапазон, содержащий точку ```go db.Rates.Query().Where(Rates.Effective.Contains(when)) ``` ### Две брони, которые столкнутся ```go db.Bookings.Query().Where(orm.And( Bookings.RoomID.Eq(room), Bookings.During.Overlaps(orm.ClosedOpen(from, to)), )) ``` Вопрос именно про пересечение, и диапазонный тип отвечает на него одним оператором. Записанное как четыре сравнения по двум колонкам — тот же запрос, но с большим числом мест, где можно ошибиться в границе. ### Границы, включающие и нет ```go orm.ClosedOpen(from, to) // [from, to) — the usual one for time orm.Closed(from, to) // [from, to] orm.Open(from, to) // (from, to) orm.OpenClosed(from, to) // (from, to] ``` Полуоткрытый — правильное умолчание для времени: два соседних `[a, b)` смыкаются без пересечения, а `[a, b]` — нет. ### Пустой и неограниченный ```go orm.RangeFrom(start) // [start, ∞) orm.RangeUntil(end) // (-∞, end) orm.EmptyRange[Booking]() // matches nothing, and is not the same as NULL ``` ## Транзакции и блокировки ### Транзакция ```go err := db.Tx(ctx, func(tx *domain.DB) error { if err := tx.Accounts.Update(). Set(Accounts.Cents.SetExpr(Accounts.Cents.Sub(amount))). Where(Accounts.ID.Eq(from)). Exec(ctx); err != nil { return err } return tx.Accounts.Update(). Set(Accounts.Cents.SetExpr(Accounts.Cents.Add(amount))). Where(Accounts.ID.Eq(to)). Exec(ctx) }) ``` Возврат ошибки откатывает. Глобальной транзакции нет, и ничего не происходит неявно: `tx` — другой хэндл, не `db`, поэтому случайный вызов `db` внутри замыкания виден на ревью. ### Безопасно забрать работу Шаблон очереди, в котором два обработчика никогда не возьмут одну строку: ```go err := db.Tx(ctx, func(tx *domain.DB) error { jobs, err := tx.Jobs.Query(). Where(Jobs.Status.Eq("pending")). OrderBy(Jobs.Created.Asc()). Limit(10). Lock(orm.ForUpdateStrong, orm.SkipLocked()). All(ctx) if err != nil { return err } for _, j := range jobs { if _, err := tx.Jobs.Update(). Set(Jobs.Status.Set("running")). Where(Jobs.ID.Eq(j.ID)). Exec(ctx); err != nil { return err } } return nil }) ``` ### Упасть, а не ждать ```go db.Accounts.Query(). Where(Accounts.ID.Eq(id)). Lock(orm.ForUpdateStrong, orm.NoWait()). One(ctx) ``` ### Чтение, которое не должно блокировать пишущих ```go db.Reports.Query().Lock(orm.ForShare).All(ctx) ``` ### Уровень изоляции ```go err := db.TxOptions(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable}, func(tx *domain.DB) error { return transfer(ctx, tx) }) ``` Serializable может завершиться ошибкой сериализации, которую безопасно повторить. Этот повтор — в вашем коде, потому что только вы знаете, идемпотентна ли работа. ## Аварийные выходы ### Фрагмент внутри собранного запроса ```go db.Users.Query().Where( orm.Expr[User]("age(created_at) > interval ?", "1 year"), ) ``` ### Целый запрос, но с сохранённым порождённым сканером ```go users, err := orm.Raw[User](db.Users, ` SELECT * FROM users WHERE ctid = ANY (?) `, ctids).All(ctx) ``` Оба принимают текст SQL осознанно. Ни один не принимает значения, вставленные в этот текст. ### Вызов функции, которую библиотека не оборачивает ```go soundex := orm.Fn[Person, string]("soundex", orm.ArgOf(People.Surname)) wanted := orm.Fn[Person, string]("soundex", orm.ArgValue(input)) orm.Select(db.People, shape).Where(soundex.EqCol(wanted)) ``` ### Оператор, который библиотека не оборачивает ```go similar := orm.OpPredicate[Product]("%>", orm.ArgOf(Products.Name), orm.ArgValue(input)) ``` ### Посмотреть SQL до выполнения ```go sql, args, err := db.Users.Query().Where(Users.Active.Eq(true)).SQL() ``` ### Прочитать план ```go plan, err := db.Users.Query().Where(Users.Email.Eq(addr)).Explain(ctx) ``` `ExplainAnalyze` выполняет запрос. На `SELECT` это обычно не страшно; на всём, что пишет, это не предпросмотр — работа будет сделана. --- # Сложные запросы > Те, ради которых обычно берутся за сырой SQL, — собранные, типизированные и по-прежнему одним запросом. https://ormgo.vercel.app/ru/docs/cookbook/insane/ Каждый из них — один SQL-запрос с одним списком параметров. Ни один не склеен из строк. Здесь используется API композиции, а не запрос по сущности, потому что нужно именно оно: производная таблица, CTE, LATERAL, операция над множествами. Словарь небольшой и повторяется. `orm.Rows` перечисляет колонки, которые отдаёт подзапрос, `orm.Named` даёт одной из них имя, `orm.Sub` превращает это в производную таблицу, `orm.Ref` читает именованную колонку обратно, а `orm.Cond` поднимает предикат по сущности в область составного запроса. Всё дальше — эти пять вещей и соединения. ## Первые N в каждой группе Классика. Оконная функция внутри производной таблицы, фильтрация снаружи — потому что оконная функция не может стоять в `WHERE`. ```go rank := orm.Named("rn", orm.RowNumber(). PartitionBy(orm.Of(Orders.UserID)). OrderBy(orm.Of(Orders.Placed).Desc())) ranked := orm.Sub("ranked", orm.Rows( orm.Named("id", orm.Of(Orders.ID)), orm.Named("user_id", orm.Of(Orders.UserID)), orm.Named("placed", orm.Of(Orders.Placed)), rank, ).From(Orders.Source())) rows, err := orm.Compose(pool, shape). From(ranked). Where(orm.Ref(ranked, rank).Lte(3)). OrderBy(orm.Ref(ranked, userID).Asc()). All(ctx) ``` ### То же самое через LATERAL, что часто быстрее Когда родительское множество невелико, а у дочерней таблицы есть индекс по ключу соединения, LATERAL выигрывает у ранжирования всей дочерней таблицы с последующим выбрасыванием почти всего: ```go top := orm.Sub("top", orm.Rows( orm.Named("id", orm.Of(Orders.ID)), orm.Named("placed", orm.Of(Orders.Placed)), ).From(Orders.Source()). Where(orm.Eq(Orders.UserID, Users.ID)). OrderBy(orm.Of(Orders.Placed).Desc()). Limit(3)) orm.Compose(pool, shape).From(Users.Source()).LeftJoinLateral(top) ``` Два плана на один вопрос. Измеряйте, а не предполагайте — `Explain` под рукой. ## Накопительный итог ```go running := orm.Named("running", orm.SumInt64[orm.Composed, int64](orm.Of(Orders.Total)).Over(orm.Window(). OrderBy(orm.Of(Orders.Placed).Asc()). Rows(orm.UnboundedPreceding(), orm.CurrentRow()))) ``` ### Накопительный итог, сбрасывающийся каждый месяц ```go month := orm.DateTrunc(orm.Month, Orders.Placed) perMonth := orm.Named("mtd", orm.SumInt64[orm.Composed](orm.Of(Orders.Total)).Over(orm.Window(). PartitionBy(month). OrderBy(orm.Of(Orders.Placed).Asc()). Rows(orm.UnboundedPreceding(), orm.CurrentRow()))) ``` Сброс — это раздел. Больше ничего не меняется. ### Баланс после каждой проводки Форма, которая нужна банковской выписке: каждая строка несёт баланс на себя саму: ```go balance := orm.Named("balance", orm.SumInt64[orm.Composed](orm.Of(Entries.Cents)).Over(orm.Window(). PartitionBy(orm.Of(Entries.AccountID)). OrderBy(orm.Of(Entries.At).Asc(), orm.Of(Entries.ID).Asc()). Rows(orm.UnboundedPreceding(), orm.CurrentRow()))) ``` `ID` в сортировке здесь не для красоты. Две проводки в одну микросекунду иначе получат порядок, который выбрал план, а выписка, где балансы меняются от запуска к запуску, хуже, чем просто неверная. ## Разрывы и острова Непрерывные периоды активности, найденные по разнице между номером строки и датой. ```go grp := orm.Named("grp", orm.Sub( orm.Of(Events.Day), orm.RowNumber().OrderBy(orm.Of(Events.Day).Asc()), )) islands := orm.Sub("islands", orm.Rows( orm.Named("day", orm.Of(Events.Day)), grp, ).From(Events.Source())) // then group by grp and take min(day), max(day) ``` ### Длина серии, числом Группировка островов даёт длины серий — серия входов, окно бесперебойной работы, число дней подряд с отгрузками: ```go orm.Compose(pool, streaks). From(islands). GroupBy(orm.Ref(islands, grp)). Having(orm.Count[orm.Composed]().Gte(3)). OrderBy(orm.Ref(islands, grp).Asc()) ``` ### Разрывы: периоды, когда ничего не происходило Обратная сторона той же идеи: каждая строка в паре с предыдущей и расстояние между ними: ```go prev := orm.Named("prev", orm.Lag(Readings.At).Over(orm.Window(). PartitionBy(orm.Of(Readings.SensorID)). OrderBy(orm.Of(Readings.At).Asc()))) gaps := orm.Sub("gaps", orm.Rows( orm.Named("sensor_id", orm.Of(Readings.SensorID)), orm.Named("at", orm.Of(Readings.At)), prev, ).From(Readings.Source())) ``` Датчик, который должен отчитываться каждую минуту и имеет двухчасовой разрыв, — это неисправность, и вот запрос, который её находит, не вытягивая в Go годовой объём измерений. ## Рекурсивная иерархия Оргструктура любой глубины, одним запросом: ```go anchor := orm.Rows( orm.Named("id", orm.Of(Employees.ID)), orm.Named("manager_id", orm.Opt(Employees.ManagerID)), orm.Named("depth", orm.Val(0)), ).From(Employees.Source()).Where(orm.Cond(Employees.ManagerID.IsNull())) tree := orm.RecursiveCTE("tree", anchor, func(self *orm.Source) orm.Term { return orm.Rows( orm.Named("id", orm.Of(Employees.ID)), orm.Named("manager_id", orm.Opt(Employees.ManagerID)), orm.Named("depth", orm.Ref(self, depth).Add(1)), ).From(Employees.Source()). Join(self, orm.Eq(Employees.ManagerID, orm.Ref(self, id))) }) ``` `UNION` встречается здесь и только здесь: грамматика PostgreSQL требует его между якорем рекурсивного CTE и рекурсивной частью. Это не общая операция над множествами, она — [UNION ALL](/ru/docs/union-all/). ### Всё, что лежит под одним узлом Тот же обход, начатый не от корня: поддерево, папка, ветка комментариев: ```go anchor := orm.Rows( orm.Named("id", orm.Of(Categories.ID)), orm.Named("parent_id", orm.Opt(Categories.ParentID)), ).From(Categories.Source()).Where(orm.Cond(Categories.ID.Eq(root))) ``` ### Материализованный путь, собираемый по дороге вниз Чтобы «хлебные крошки» не стоили по запросу на уровень: ```go tree := orm.RecursiveCTE("tree", anchor, func(self *orm.Source) orm.Term { return orm.Rows( orm.Named("id", orm.Of(Categories.ID)), orm.Named("path", orm.Concat(orm.Ref(self, path), orm.Val(" / "), orm.Of(Categories.Name))), ).From(Categories.Source()). Join(self, orm.Eq(Categories.ParentID, orm.Ref(self, id))) }) ``` ### Спецификация изделия, с умножением количеств вниз Каждый шаг рекурсии умножает на количество родителя, поэтому число у листа — это то, что действительно нужно закупить: ```go tree := orm.RecursiveCTE("bom", anchor, func(self *orm.Source) orm.Term { return orm.Rows( orm.Named("part_id", orm.Of(Assemblies.ChildID)), orm.Named("qty", orm.Ref(self, qty).Mul(orm.Of(Assemblies.Qty))), ).From(Assemblies.Source()). Join(self, orm.Eq(Assemblies.ParentID, orm.Ref(self, partID))) }) ``` ## Коррелированный подзапрос в списке выборки Самый свежий заказ каждого пользователя, без соединения: ```go last := orm.Scalar[User, time.Time]( db.Orders.Query(). Where(orm.Eq(Orders.UserID, Users.ID)). OrderBy(Orders.Placed.Desc()). Limit(1), ) var shape = orm.Project2( Users.Email, last, func(email string, at *time.Time) Row { return Row{email, at} }, ) ``` Скалярный подзапрос всегда nullable, потому что «ни одной строки» даёт NULL. Тип это и говорит. ### Счётчик рядом с каждой строкой ```go n := orm.Scalar[User, int64]( db.Orders.Query().Where(orm.Eq(Orders.UserID, Users.ID)), ) ``` Удобно — и по одному подзапросу на строку. Когда список длинный, один `GROUP BY` с соединением даёт тот же ответ дешевле: эта форма оправдана на странице из двадцати строк, а не на выгрузке из двухсот тысяч. ### Два коррелированных значения без двух подзапросов ```go stats := orm.Sub("stats", orm.Rows( orm.Named("user_id", orm.Of(Orders.UserID)), orm.Named("n", orm.Count[orm.Composed]()), orm.Named("last", orm.Max(Orders.Placed)), ).From(Orders.Source()).GroupBy(orm.Of(Orders.UserID))) orm.Compose(pool, shape). From(Users.Source()). LeftJoin(stats, orm.Eq(Users.ID, orm.Ref(stats, userID))) ``` ## Антисоединение, двумя способами ```go // NOT EXISTS — usually the planner's favourite db.Users.Query().Where(orm.NotExists( db.Orders.Query().Where(orm.Eq(Orders.UserID, Users.ID)), )) // LEFT JOIN ... IS NULL, when you also want columns from the right side orm.Compose(pool, shape). From(Users.Source()). LeftJoin(Orders.Source(), orm.Eq(Orders.UserID, Users.ID)). Where(orm.Opt(Orders.ID).IsNull()) ``` ### Полусоединение: родители, у которых есть хотя бы одно совпадение, по разу каждый ```go db.Users.Query().Where(orm.Exists[User]( db.Orders.Query().Where(orm.And( orm.Eq(Orders.UserID, Users.ID), Orders.Status.Eq("paid"), )), )) ``` Соединение дало бы по строке на каждый подходящий заказ. `EXISTS` даёт по строке на пользователя — а это и значит «пользователи, которые платили». ### Почему NOT IN — тот, которого стоит избегать ```go db.Users.Query().Where(orm.NotExists[User]( db.Blocks.Query().Where(orm.Eq(Blocks.UserID, Users.ID)), )) ``` Если подзапрос `NOT IN` вернёт хотя бы один NULL, весь результат окажется пуст — молча и только в те дни, когда NULL в данных есть. У `NOT EXISTS` такого люка нет, поэтому на этой странице показан он, а не первый. ## LATERAL Два самых свежих заказа каждого пользователя: подзапрос коррелирует со строкой слева от него: ```go recent := orm.Sub("recent", orm.Rows( orm.Named("id", orm.Of(Orders.ID)), orm.Named("placed", orm.Of(Orders.Placed)), ).From(Orders.Source()). Where(orm.Cond(orm.Eq(Orders.UserID, Users.ID))). OrderBy(orm.Of(Orders.Placed).Desc()). Limit(2)) orm.Compose(pool, shape). From(Users.Source()). LeftJoinLateral(recent) ``` ### Агрегат на строку, который соединением не выразить Траты каждого клиента в окне, своём для каждой строки: ```go window := orm.Sub("window", orm.Rows( orm.Named("spent", orm.SumInt64[orm.Composed](orm.Of(Orders.Total))), ).From(Orders.Source()).Where(orm.Cond(orm.And( orm.Eq(Orders.UserID, Users.ID), Orders.Placed.Between(from, to), )))) orm.Compose(pool, shape).From(Users.Source()).LeftJoinLateral(window) ``` ### Внутренний LATERAL, чтобы отбросить строки без совпадений ```go orm.Compose(pool, shape).From(Users.Source()).JoinLateral(recent) ``` `LeftJoinLateral` оставляет пользователей без заказов и даёт им NULL. `JoinLateral` их отбрасывает. Выбор ровно тот же, что и у обычного соединения. ## Разворот в колонки Счётчики по статусам — колонками, а не строками: ```go var pivot = orm.Project3( Orders.UserID, orm.Count[Order]().Filter(Orders.Status.Eq("paid")), orm.Count[Order]().Filter(Orders.Status.Eq("refunded")), func(id int64, paid, refunded int64) Pivot { return Pivot{id, paid, refunded} }, ) orm.Select(db.Orders, pivot).GroupBy(Orders.UserID) ``` `FILTER` здесь лучше `CASE WHEN`: он говорит то, что имеется в виду, и планировщик читает его лучше. ### Суммы по корзинам, а не только счётчики ```go var revenue = orm.Project4( Sales.Region, orm.SumInt32(Sales.Cents).Filter(Sales.Channel.Eq("web")), orm.SumInt32(Sales.Cents).Filter(Sales.Channel.Eq("retail")), orm.SumInt32(Sales.Cents).Filter(Sales.Channel.Eq("partner")), func(region string, web, retail, partner *int64) Revenue { return Revenue{region, web, retail, partner} }, ) ``` Набор колонок фиксирован на этапе компиляции, и это честное ограничение: SQL не умеет выдавать набор колонок, зависящий от данных, и типизированный API тоже не умеет. Если корзины выясняются во время выполнения, ответ — строки, а разворот происходит на стороне потребителя. ## Композиция множеств поверх разных источников Таблица, представление и материализованное представление в одном результате — законно, потому что проекции совпадают: ```go shape := orm.Project2( orm.Of(Users.ID), orm.Of(Users.Email), func(id uuid.UUID, email string) Row { return Row{id, email} }, ) email := orm.Named("email", orm.Of(Users.Email)) rows, err := orm.UnionAll[Row]( orm.Compose(pool, shape).From(Users.Source()), orm.Compose(pool, shape).From(ActiveUsers.Source()), orm.Compose(pool, shape).From(UserSummaries.Source()), ).OrderBy(email.Asc()).Limit(50).All(ctx) ``` Никакой особой обработки по виду источника. Источник для чтения — это источник для чтения. ### Объединённая лента событий Три таблицы, у которых общего — только отметка времени и метка: ```go rows, err := orm.UnionAll[Item]( orm.Compose(pool, feed).From(Posts.Source()), orm.Compose(pool, feed).From(Comments.Source()), orm.Compose(pool, feed).From(Follows.Source()), ).OrderBy(at.Desc()).Limit(50).All(ctx) ``` `ORDER BY` и `LIMIT` применяются к объединению, а не к ветви: одна отсортированная лента, а не три отсортированных списка подряд. ### Строки, которые есть в одной таблице и нет в другой, в обе стороны ```go orm.UnionAll[Diff]( orm.Compose(pool, diff).From(Expected.Source()).Where(orm.NotExists[orm.Composed]( orm.Compose(pool, one).From(Actual.Source()).Where(orm.Eq(Actual.Key, Expected.Key)))), orm.Compose(pool, diff).From(Actual.Source()).Where(orm.NotExists[orm.Composed]( orm.Compose(pool, one).From(Expected.Source()).Where(orm.Eq(Expected.Key, Actual.Key)))), ) ``` Сверка — чего не хватает и что лишнее — одним запросом. ## Самосоединение через псевдоним ```go mgr := Employees.As("mgr") orm.Compose(pool, shape). From(Employees.Source()). LeftJoin(mgr.Source(), orm.Eq(mgr.ID, Employees.ManagerID)) ``` `As` возвращает второй источник, и дескриптор, построенный от одного, нельзя применить к другому. Именно это делает самосоединение безопасным, а не просто упражнением в именовании. ### Поиск дублей сравнением таблицы с собой ```go other := Contacts.As("other") orm.Compose(pool, pairs). From(Contacts.Source()). Join(other.Source(), orm.And( orm.Eq(Contacts.Email, other.Email), orm.Cond(Contacts.ID.Lt(other.ID)), )) ``` `Lt` — то, что не даёт каждой паре появиться дважды, а каждой строке совпасть с самой собой. ## Дедупликация ### Оставить самую свежую строку по ключу ```go rank := orm.Named("rn", orm.RowNumber(). PartitionBy(orm.Of(Imports.ExternalID)). OrderBy(orm.Of(Imports.SeenAt).Desc())) deduped := orm.Sub("deduped", orm.Rows( orm.Named("id", orm.Of(Imports.ID)), orm.Named("external_id", orm.Of(Imports.ExternalID)), rank, ).From(Imports.Source())) orm.Compose(pool, shape).From(deduped).Where(orm.Ref(deduped, rank).Eq(1)) ``` ### Или через DISTINCT ON, что короче ```go orm.Compose(pool, shape). From(Imports.Source()). DistinctOn(orm.Of(Imports.ExternalID)). OrderBy(orm.Of(Imports.ExternalID).Asc(), orm.Of(Imports.SeenAt).Desc()) ``` Ответ тот же. Вариант с оконной функцией переносится на другие базы; этот быстрее и говорит то, что имеется в виду. Поскольку библиотека всё равно только под PostgreSQL, предпочитайте его. ### Удалить дубли, а не отфильтровывать их ```go db.Imports.Delete().Where(orm.InSub( Imports.ID, orm.Compose(pool, ids).From(deduped).Where(orm.Ref(deduped, rank).Gt(1)), )).Exec(ctx) ``` ## Аналитические формы ### Гистограмма ```go bucket := orm.Named("bucket", orm.Fn[orm.Composed, int32]("width_bucket", orm.ArgOf(Response.Millis), orm.ArgValue(0), orm.ArgValue(1000), orm.ArgValue(10))) orm.Compose(pool, histogram). From(Response.Source()). GroupBy(bucket). OrderBy(bucket.Asc()) ``` ### Когортное удержание ```go cohort := orm.Named("cohort", orm.DateTrunc(orm.Month, Users.CreatedAt)) active := orm.Named("active", orm.DateTrunc(orm.Month, Events.At)) orm.Compose(pool, retention). From(Users.Source()). Join(Events.Source(), orm.Eq(Events.UserID, Users.ID)). GroupBy(cohort, active). OrderBy(cohort.Asc(), active.Asc()) ``` Два округления и группировка. Сетка, которую образует результат, и есть когортная таблица, а разворот в колонки — дело потребителя. ### Ближайшие по расстоянию ```go distance := postgis.OfGeog(Stops.Spot).Distance(postgis.GeogValue[Stop](here)) orm.Select(db.Stops, nearest). Where(postgis.OfGeog(Stops.Spot).DWithin(postgis.GeogValue[Stop](here), 2000)). OrderBy(distance.Asc()). Limit(5) ``` `DWithin` перед сортировкой — то, что позволяет индексу сделать работу. ## Запись, составными запросами ### Удалить и прочитать удалённое, одним запросом ```go archived := orm.WritingCTE("archived", db.Events.Delete(). Where(Events.At.Lt(cutoff))) orm.Compose(pool, shape).With(archived).From(archived) ``` Сделать это двумя запросами — значит дать строкам измениться между ними. ### Узнать, какие строки действительно изменились ```go changed, err := orm.UpdateReturning( db.Prices.Update(). Set(Prices.Cents.Set(cents)). Where(Prices.SKU.Eq(sku)). Where(Prices.Cents.Ne(cents)), priceShape, ).All(ctx) ``` Хитрость во втором `Where`: обновление, которое ничего бы не изменило, ни во что не попадает, поэтому вернувшиеся строки — ровно те, что сдвинулись. Именно по этому множеству стоит публиковать события. ## Пакетный upsert целого набора ```go err := db.Tx(ctx, func(tx *domain.DB) error { for chunk := range slices.Chunk(rows, 1000) { if _, err := tx.Prices.InsertMany(ctx, chunk, orm.OnConflict(Prices.SKU).DoUpdate(Prices.Amount), ); err != nil { return err } } return nil }) ``` ## Прочитать план, прежде чем чему-либо здесь верить ```go plan, err := q.Explain(ctx) // EXPLAIN, never runs the statement plan, err := q.ExplainAnalyze(ctx) // runs it, and the name says so report, err := q.PerformanceReport(ctx) // plan, shape and fingerprint ``` Имена различаются, потому что опасно различается поведение. Здесь ничего не советуется: планирует PostgreSQL, а решение о том, что менять, требует всей нагрузки, а не одного запроса. ### Сравнить два написания одного вопроса ```go a, _ := withNotExists.Explain(ctx) b, _ := withLeftJoin.Explain(ctx) ``` У антисоединения выше две формы, и эта страница отказывается говорить, какая быстрее: это зависит от ваших объёмов и ваших индексов. Вот как выяснить — на своих данных, примерно за минуту. ### Проверить, что SQL — тот самый ```go sql, args, err := q.SQL() ``` В эту строку не подставлено ни одно значение: аргументы возвращаются рядом, и это ровно то, что получает сервер. --- # Архитектуры проектов > Четыре рабочие компоновки и для чего на самом деле нужна каждая. https://ormgo.vercel.app/ru/docs/cookbook/architecture/ Репозиторий содержит все четыре как компилирующиеся модули с тестами. Ниже — форма и рассуждение; код лежит в `examples/`. ## 1. Плоская — небольшой сервис ```text cmd/api/main.go internal/domain/entities.go ← это пишете вы internal/domain/orm_*.gen.go ← генерируется рядом internal/http/handlers.go orm.yaml migrations/ ``` Обработчики принимают `*domain.DB` напрямую. Слоя репозиториев нет, потому что на таком размере интерфейс репозитория с одной реализацией — это файл, который сопровождают просто так. Пора менять, когда в обработчиках заводятся бизнес-правила или когда их хочется тестировать без базы. ## 2. Гексагональная — порты и адаптеры ```text internal/core/ ← сущности, сервисы, интерфейсы портов. Без SQL и HTTP. internal/adapters/postgres/ internal/adapters/http/ cmd/api/ ``` Ядро объявляет, что ему нужно: ```go package core type UserStore interface { ByID(ctx context.Context, id int64) (User, error) Save(ctx context.Context, u User) error } ``` Адаптер реализует это через ORM. Ядро не импортирует ни `orm`, ни `net/http`, поэтому его тестам не нужно ни то, ни другое. **Правило, на котором всё держится:** интерфейсы объявляются там, где их *используют*, а не там, где реализуют. `UserStore`, объявленный в пакете адаптера, — это `UserStore`, от которого ядро не сможет зависеть, не зависимя от адаптера. Берите, когда у домена есть реальные правила или когда транспортов больше одного. ## 3. Модульный монолит — ограниченные контексты ```text internal/billing/ domain/ · store/ · service.go · port.go internal/catalog/ domain/ · store/ · service.go · port.go internal/identity/ domain/ · store/ · service.go · port.go cmd/api/main.go ← связывает их вместе ``` Каждый контекст владеет своей схемой — своими сущностями, своей записью в `packages`, иногда своей схемой PostgreSQL. Контексты общаются через `port.go` и никогда не импортируют чужой `store/`. ```yaml packages: - path: ./internal/billing/domain output: same - path: ./internal/catalog/domain output: same ``` Два контекста, владеющие таблицами с одинаковым именем, — это нормально, и кросс-схемные тесты существуют, чтобы это доказать: `billing.users` и `identity.users` дают разные дескрипторы, разное состояние миграций и разные результаты. Это компоновка, которая переживает последующее разделение на сервисы, потому что швы уже прорезаны. ## 4. Production — полный стек Модуль `examples/production` — эталон всего, что нужно настоящему развёртыванию: - **Четыре HTTP-транспорта** — net/http, chi, gin и fiber — над одним сервисным слоем, чтобы доказать, что сервисный слой ни об одном из них не знает. - **Наблюдаемость**, подключённая один раз на старте: `orm.Traced(pool, tracer)`, и ничто ниже не упоминает телеметрию. - **Health-проверки** через `ormhealth`, включая то, применены ли миграции. - **Мягкое завершение** в правильном порядке: перестать принимать, дорасследовать начатое, закрыть пул. ```go func main() { pool, err := pgxpool.NewWithConfig(ctx, cfg) // ... db := domain.New(orm.Traced(pool, observability.New(log, obsCfg))) svc := service.New(db) srv := server.New(httpapi.Routes(svc)) // ... } ``` Один вызов, на старте, на исполнителе. Транзакция, начатая от него, наследует трейсинг. ## Как выбрать | | Плоская | Гексагональная | Модульный монолит | Production | | --- | --- | --- | --- | --- | | Правила домена | мало | много | много | много | | Транспорты | один | один и больше | один и больше | несколько | | Размер команды | 1–3 | 2–6 | 5+ | любой | | Разделение потом | больно | возможно | заложено | заложено | ## Приёмы, общие для всех четырёх Схема раскладки меняется, а это — нет. ### Сборка, один раз, при старте ```go func main() { ctx := context.Background() cfg, err := pgxpool.ParseConfig(os.Getenv("DATABASE_URL")) if err != nil { log.Fatal(err) } pool, err := pgxpool.NewWithConfig(ctx, cfg) if err != nil { log.Fatal(err) } defer pool.Close() db := domain.New(pool) svc := service.New(db) log.Fatal(http.ListenAndServe(":8080", routes(svc))) } ``` Всё, что ниже `main`, получает нужное ему. Ничто не тянется к пакетной переменной — поэтому любую часть можно протестировать на другой базе без build-тегов. ### Сервис принимает хэндл, а не пул ```go type Service struct { db *domain.DB } func New(db *domain.DB) *Service { return &Service{db: db} } ``` Сервис, держащий `*pgxpool.Pool`, вынужден был бы сам собирать хэндл, и тогда в него нельзя было бы передать транзакцию — а это следующий приём. ### Транзакция поверх нескольких хранилищ ```go func (s *Service) Checkout(ctx context.Context, cart Cart) error { return s.db.Tx(ctx, func(tx *domain.DB) error { order, err := tx.Orders.Insert(ctx, Order{UserID: cart.UserID}) if err != nil { return err } for _, line := range cart.Lines { if _, err := tx.Items.Insert(ctx, Item{OrderID: order.ID, SKU: line.SKU}); err != nil { return err } } return nil }) } ``` `tx` — не тот же хэндл, что `s.db`, поэтому вызов, случайно ушедший через внешний, виден на ревью, а не тихо оказывается вне транзакции. ### Функция, работающая и внутри транзакции, и вне её Принимайте хэндл параметром и позвольте решать вызывающему: ```go func reserve(ctx context.Context, db *domain.DB, sku string, n int32) error { _, err := db.Inventory.Update(). Set(Inventory.OnHand.SetExpr(Inventory.OnHand.Sub(n))). Where(Inventory.SKU.Eq(sku)). Where(Inventory.OnHand.Gte(n)). Exec(ctx) return err } ``` Вызванная с `s.db` она сама себе запрос; вызванная с `tx` внутри `Tx` — часть транзакции. В самой функции ничего не меняется. ### Исполнитель, обёрнутый один раз ```go db := domain.New(orm.Traced(pool, ormslog.New(logger))) ``` `orm.Traced` оборачивает исполнителя, поэтому каждый запрос через этот хэндл трассируется, а всё ниже этой строки о телеметрии не знает. Транзакция, начатая от него, наследует обёртку. ### Порты называют то, что нужно ядру ```go package core type UserStore interface { ByID(ctx context.Context, id int64) (User, error) Save(ctx context.Context, u User) error } ``` ### И адаптер, который их реализует ```go package postgres type UserStore struct{ db *domain.DB } func (s UserStore) ByID(ctx context.Context, id int64) (core.User, error) { row, err := s.db.Users.Query().Where(Users.ID.Eq(id)).One(ctx) if err != nil { return core.User{}, err } return core.User{ID: row.ID, Email: row.Email}, nil } ``` Перевод между строкой таблицы и доменным типом — вся работа адаптера. Пропустить его — значит сделать типом ядра то, чем случайно оказалась таблица, и тогда порт перестаёт быть границей. ### Отображение ошибок базы на границе ```go func (s UserStore) ByID(ctx context.Context, id int64) (core.User, error) { row, err := s.db.Users.Query().Where(Users.ID.Eq(id)).One(ctx) switch { case errors.Is(err, orm.ErrNotFound): return core.User{}, core.ErrNotFound case err != nil: return core.User{}, fmt.Errorf("loading user %d: %w", id, err) } return core.User{ID: row.ID, Email: row.Email}, nil } ``` Ядру не следует импортировать `orm`, чтобы узнать, что строки не нашлось. Один перевод здесь это обеспечивает. ### Постраничность, не протекающая курсором в домен ```go type Page[T any] struct { Items []T Cursor string } ``` ### Контекст до самого низа ```go func (s *Service) List(ctx context.Context, f Filter) ([]core.User, error) { return s.store.Search(ctx, f) } ``` Каждый вызов библиотеки принимает контекст и ни один его не сохраняет. Отменённый запрос останавливает начатый им SQL — но лишь если контекст протянут, а не заменён где-то посередине на `context.Background()`. ### У фонового обработчика свой хэндл ```go func worker(ctx context.Context, db *domain.DB) { tick := time.NewTicker(time.Minute) defer tick.Stop() for { select { case <-ctx.Done(): return case <-tick.C: if err := sweep(ctx, db); err != nil { log.Error("sweep", "err", err) } } } } ``` Делить пул правильно, делить транзакцию — нет. Обработчик, взявший `tx`, держал бы её открытой между тиками. ### Проверки здоровья отвечают на разные вопросы ```go mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { if rep := ormhealth.Quick(r.Context(), pool); !rep.OK() { http.Error(w, rep.String(), http.StatusServiceUnavailable) return } w.WriteHeader(http.StatusOK) }) ``` ```go mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, r *http.Request) { rep := ormhealth.Deep(r.Context(), pool, ormhealth.WithSchemaCheck("public")) if !rep.OK() { http.Error(w, rep.String(), http.StatusServiceUnavailable) return } w.WriteHeader(http.StatusOK) }) ``` `Quick` спрашивает, отвечает ли база. `Deep` — та ли это база, которую ожидает эта сборка, включая применённость миграций. Направить liveness-пробу на глубокую значит перезапускать здоровый процесс из-за неприменённой миграции, то есть ровно обратное задуманному. ### Завершение в том порядке, который важен ```go srv := &http.Server{Addr: ":8080", Handler: routes(svc)} go func() { <-ctx.Done() shutdown, cancel := context.WithTimeout(context.Background(), 20*time.Second) defer cancel() _ = srv.Shutdown(shutdown) // stop accepting, let in-flight requests finish pool.Close() // only then close the pool }() ``` Закрыть пул первым — значит превратить каждый запрос в полёте в ошибку, а это хуже, чем двадцать секунд ожидания. ### Мультиарендность по схемам ```yaml packages: - path: ./internal/tenanta/domain schema: tenant_a output: same - path: ./internal/tenantb/domain schema: tenant_b output: same ``` Два пакета, два набора дескрипторов, один бинарник. Запрос, собранный от одного, нельзя выполнить против другого, потому что дескрипторы несут схему. ### Реплика для отчётов ```go reports := domain.New(replicaPool) rows, err := orm.Select(reports.Orders, monthly).GroupBy(Orders.Status).All(ctx) ``` Второй хэндл поверх второго пула. Больше ничего не меняется, а попытка записи через него упадёт на сервере, а не уйдёт молча не туда. ### Тестирование ядра без базы ```go type fakeUsers struct{ byID map[int64]core.User } func (f fakeUsers) ByID(_ context.Context, id int64) (core.User, error) { u, ok := f.byID[id] if !ok { return core.User{}, core.ErrNotFound } return u, nil } ``` Ровно это и покупает порт. Тесты ядра — это карта и никакого сервера. ### Тестирование адаптера против настоящей базы ```go func TestUserStore(t *testing.T) { ex := ormtest.Tx(t, pool) store := postgres.UserStore{DB: domain.New(ex)} // ... } ``` Адаптер — тот слой, чья работа целиком состоит в разговоре с PostgreSQL, поэтому его тесты разговаривают с PostgreSQL. Подделка базы здесь тестировала бы подделку. ### Конфигурация, прочитанная один раз ```go type Config struct { DatabaseURL string MaxConns int32 Addr string } ``` Структура, заполняемая при старте и передаваемая вниз, вместо `os.Getenv`, рассыпанного по всем пакетам, которым случилось понадобиться значение. ## Правила, верные во всех четырёх **Сгенерированный код лежит рядом со своими сущностями.** `output: same` кладёт дескрипторы в тот же пакет, что и структуры, поэтому цикл импортов невозможен. **Один исполнитель, передаваемый вниз.** Никакой глобальной базы, соединения в `init()` и транзакции в окружении. Функция может дотянуться до того, что ей передали. **Миграции — это код-ревью.** Это закоммиченные артефакты, спланированные командой и прочитанные человеком до запуска. **Две команды в CI не опциональны:** ```bash orm makemigrations --check orm check --generated ``` Первая падает, когда структура изменилась и никто этого не спланировал. Вторая — когда спланировали и забыли перегенерировать. Вдвоём они не дают трём представлениям разойтись. --- # Настоящее приложение > Слой персистентности работающего сервиса — взят из исходников, а не придуман. https://ormgo.vercel.app/ru/docs/cookbook/real-world/ Все остальные страницы здесь написаны, чтобы что-то объяснить. Эта написана, чтобы работать в проде, и воспроизведена из слоя репозиториев проекта [devbubble-api](https://github.com/AlexAli29/devbubble-api/tree/orm/internal/repository) — бэкенда чата с пользователями, тегами, приватными чатами, сообщениями и кодами входа по почте. Её стоит прочитать, потому что здесь формы, которые породила настоящая схема, и потому что некоторые из них — ответ на вопрос, который остальная документация ставит, но не закрывает. ## Схема, объявленная Управляемый режим: схемой владеют сущности, миграции планируются от них. ```go //orm:table public.users type User struct { ID uuid.UUID `orm:"pk,pgtype:uuid,default:gen_random_uuid()"` CreatedAt time.Time `orm:"pgtype:timestamptz,default:now()"` Email string `orm:"unique"` Description *string Name string IsVerified bool `orm:"default:false"` AuthCodes orm.Many[AuthCode] Tags orm.Many[UserUserTag] Messages orm.Many[Message] Participants orm.Many[ChatParticipant] } ``` `Description` — указатель, остальные поля — нет; вот и вся история nullable-ности для этой таблицы. `gen_random_uuid()` и `now()` — умолчания базы, а не значения, которые вычисляет Go. ### Два внешних ключа на одну таблицу У подписки есть подписчик и тот, на кого подписаны, и оба — пользователи. Имя колонки выводится из поля, а вот *ограничение* вывести нельзя: два кандидата на одной таблице неразличимы, пока не сказать прямо: ```go //orm:table public.user_follows type UserFollow struct { FollowerID uuid.UUID `orm:"pk,pgtype:uuid"` FolloweeID uuid.UUID `orm:"pk,pgtype:uuid"` Follower orm.One[User] `orm:"fk:user_follows_follower_id_fkey,ondelete:cascade"` Followee orm.One[User] `orm:"fk:user_follows_followee_id_fkey,ondelete:cascade"` } ``` Ровно для этого и нужен `fk:`. Без него у генератора две связи, указывающие на одну таблицу, и никакого способа понять, какое ограничение имеется в виду. ### Составной ключ и индекс, который нужен другому запросу ```go //orm:table public.chat_participants //orm:index chat_participants_user_idx (UserID) type ChatParticipant struct { ChatID uuid.UUID `orm:"pk,pgtype:uuid"` UserID uuid.UUID `orm:"pk,pgtype:uuid"` Chat orm.One[PrivateChat] `orm:"ondelete:cascade"` User orm.One[User] `orm:"ondelete:cascade"` } ``` Первичный ключ — `(chat_id, user_id)`, поэтому поиск участников чата индексирован, а список чатов одного пользователя — нет. Второй запрос и есть список чатов, ради него индекс и существует. ### Каскады, объявленные там же, где связь ```go //orm:table public.messages //orm:index messages_chat_created_idx (ChatID, CreatedAt) type Message struct { ID uuid.UUID `orm:"pk,pgtype:uuid,default:gen_random_uuid()"` Text string UserID uuid.UUID `orm:"pgtype:uuid"` ChatID uuid.UUID `orm:"pgtype:uuid"` CreatedAt time.Time `orm:"pgtype:timestamptz,default:now()"` User orm.One[User] `orm:"ondelete:cascade"` Chat orm.One[PrivateChat] `orm:"ondelete:cascade"` } ``` ## Сборка Один пул, один хэндл, все репозитории привязаны к нему: ```go type Repositories struct { Users *UserRepo AuthCodes *AuthCodeRepo Chats *ChatRepo Messages *MessageRepo Tags *TagRepo } func NewRepositories(pool *pgxpool.Pool) *Repositories { return newRepositories(New(pool)) } // Binding to any executor is what lets the tests bind to a transaction they // roll back. func newRepositories(db *DB) *Repositories { return &Repositories{ Users: NewUserRepo(db), AuthCodes: NewAuthCodeRepo(db), Chats: NewChatRepo(db), Messages: NewMessageRepo(db), Tags: NewTagRepo(db), } } ``` ### Один перевод ошибок, на границе ```go func wrapNotFound(what string, err error) error { if errors.Is(err, orm.ErrNotFound) { return fmt.Errorf("%s: %w", what, core.ErrNotFound) } return fmt.Errorf("%s: %w", what, err) } ``` Ничто выше этого пакета не импортирует `orm`, чтобы узнать, что строки не нашлось. ## Чтение ### Строка и «не найдено», которое что-то значит ```go func (r *UserRepo) ByEmail(ctx context.Context, email string) (core.User, error) { user, err := r.db.Users.Query(). Where(Users.Email.Eq(email)). One(ctx) if err != nil { return core.User{}, wrapNotFound("get user by email", err) } return toCoreUser(user), nil } ``` ### Связь на два уровня вглубь Теги, которые взял пользователь, — через таблицу связи: ```go user, err := r.db.Users.Query(). Where(Users.ID.Eq(userID)). With(Users.Tags.With(UserUserTags.Tag)). One(ctx) if err != nil { return core.User{}, nil, wrapNotFound("get user", err) } links, _ := user.Tags.Get() tags := make([]core.Tag, 0, len(links)) for _, link := range links { tag, ok := link.Tag.Get() if !ok || tag == nil { continue } tags = append(tags, toCoreTag(*tag)) } ``` `Get` возвращает загруженное значение и признак того, загружали ли его вообще: именно так незагруженная связь отличается от пустой. Это заменило blob из `json_agg`, который разбирал вызывающий код. ### Антисоединение, без загрузки другой стороны Теги, которые этот пользователь ещё *не* взял: ```go tags, err := r.db.UserTags.Query(). Where(UserTags.Users.None(UserUserTags.UserID.Eq(user))). All(ctx) ``` `None` фильтрует по отсутствию строки связи. Ничего из таблицы связи не выбирается, и второй запрос не выполняется. ### Самый свежий потомок на родителя, одним запросом Список чатов: все чаты, где состоит вызывающий, с другим участником и самым свежим сообщением в каждом. ```go participations, err := r.db.ChatParticipants.Query(). Where(ChatParticipants.UserID.Eq(user)). With(ChatParticipants.Chat. With(PrivateChats.Participants. Where(ChatParticipants.UserID.Ne(user)). With(ChatParticipants.User)). With(PrivateChats.Messages. OrderBy(Messages.CreatedAt.Desc()). Limit(1). With(Messages.User))). All(ctx) ``` Пять запросов при любом числе чатов. `Limit(1)` действует *на каждый чат* — именно это делает «самое свежее сообщение» одним запросом, а не одним на чат, и ради этого вложенность и оправдана. ### Проекция вместо связи, и почему Истории чата нужны пять значений, одно из которых лежит в `users`. `With(Messages.User)` тоже был бы одним запросом — связь «к одному» компилируется в `LEFT JOIN`, — но он выбирает все шесть колонок пользователя на каждое сообщение ради одного имени. ```go shape := orm.Project5( orm.Of(Messages.ID), orm.Of(Messages.Text), orm.Of(Messages.CreatedAt), orm.Of(Messages.UserID), orm.Of(Users.Name), func(id uuid.UUID, text string, createdAt time.Time, userID uuid.UUID, senderName string) core.ChatMessage { return core.ChatMessage{ Id: id.String(), Text: text, CreatedAt: createdAt, UserId: userID.String(), SenderName: senderName, IsFromMe: userID == viewer, } }, ) out, err := orm.Compose(r.db.Executor(), shape). From(Messages.Source()). Join(Users.Source(), orm.Eq(Messages.UserID, Users.ID)). Where(orm.Cond(Messages.ChatID.Eq(chat))). OrderBy(orm.Of(Messages.CreatedAt).Asc()). All(ctx) ``` Соединение внутреннее, потому что `messages.user_id` — `NOT NULL` и ссылается на `users`, так что строку оно потерять не может. Это факт о схеме, и проверен он там же, в схеме. Обратите внимание, где какая форма оправдана: *список* чатов оставляет загрузку связей, потому что его стоимость ограничена числом чатов. *История* чата — нет, потому что её стоимость ограничена числом сообщений. ## Запись ### Вставка, с умолчаниями, которыми владеет база ```go user, err := r.db.Users.Insert(ctx, User{ Name: name, Email: email, }, orm.Default(Users.ID, Users.CreatedAt, Users.IsVerified)) ``` Иначе `IsVerified` сохранился бы как `false`: нулевое значение Go — это значение. Попросить умолчание колонки — отдельный явный вызов. ### Частичное обновление, собранное из того, что прислали Настоящий ответ на «обновить только присланные поля», который раньше был `COALESCE(NULLIF(...))` в SQL: ```go assignments := make([]orm.Assign[User], 0, 2) if name != "" { assignments = append(assignments, Users.Name.Set(name)) } if description != "" { assignments = append(assignments, Users.Description.Set(description)) } if len(assignments) == 0 { return nil } if _, err := r.db.Users.Update(). Set(assignments...). Where(Users.ID.Eq(userID)). Exec(ctx); err != nil { return fmt.Errorf("update user: %w", err) } ``` `Set` вариадичен по `orm.Assign[E]`, поэтому набор колонок решается во время выполнения, а типы по-прежнему решаются при компиляции. ### Upsert, у которого «ничего не сделал» и есть успех ```go _, err = r.db.UserUserTags.Insert(ctx, UserUserTag{ UserID: user, TagID: tag, }, orm.OnConflict(UserUserTags.UserID, UserUserTags.TagID).DoNothing()) if err != nil && !errors.Is(err, orm.ErrConflictIgnored) { return fmt.Errorf("add tag: %w", err) } ``` `DO NOTHING` не возвращает строки, и библиотека об этом сообщает, а не скрывает. Здесь это значит, что тег у пользователя уже есть, — то есть ровно то, чего просили, поэтому сигнальная ошибка ловится и вызов считается успешным. ### Проверка владения, которую нельзя проиграть в гонке ```go if _, err := r.db.Messages.Delete(). Where(Messages.ID.Eq(message)). Where(Messages.UserID.Eq(user)). Exec(ctx); err != nil { return fmt.Errorf("remove message: %w", err) } ``` Проверка стоит в `WHERE`, поэтому между чтением владельца и удалением строки нет окна. ### Каскад, делающий работу ```go if _, err := tx.PrivateChats.Delete(). Where(PrivateChats.ID.Eq(chat)). Exec(ctx); err != nil { return fmt.Errorf("remove chat: %w", err) } ``` Один запрос к `private_chats`. Сообщения и участники уходят вместе с ним, потому что оба внешних ключа объявлены `ON DELETE CASCADE`, — это заменило ручное удаление каждой дочерней таблицы по порядку. ## Транзакции ### Две записи, которые должны произойти обе ```go if err := tx(ctx, r.db, func(tx *DB) error { chat, err := tx.PrivateChats.Insert(ctx, PrivateChat{ID: uuid.New()}) if err != nil { return fmt.Errorf("create chat: %w", err) } chatID = chat.ID if _, err := tx.ChatParticipants.InsertMany(ctx, []ChatParticipant{ {ChatID: chat.ID, UserID: first}, {ChatID: chat.ID, UserID: second}, }); err != nil { return fmt.Errorf("add chat participants: %w", err) } return nil }); err != nil { return "", err } ``` У `private_chats` нет колонок, кроме ключа, поэтому умолчанию неоткуда взяться и идентификатор порождается в Go. ### Условное удаление, которое сообщает, что сделало ```go err = tx(ctx, r.db, func(tx *DB) error { isParticipant, err := tx.ChatParticipants.Query(). Where(ChatParticipants.ChatID.Eq(chat)). Where(ChatParticipants.UserID.Eq(user)). Exists(ctx) if err != nil { return fmt.Errorf("check chat participant: %w", err) } if !isParticipant { return nil } if _, err := tx.PrivateChats.Delete(). Where(PrivateChats.ID.Eq(chat)). Exec(ctx); err != nil { return fmt.Errorf("remove chat: %w", err) } removed = true return nil }) ``` `Exists` задаёт вопрос, не читая строку. Вызывающий отличит «нельзя» от «уже удалено», потому что булево значение возвращается отдельно от ошибки. ### Предикаты, решаемые во время выполнения Сопоставление кода входа: тот же запрос, но с дополнительным условием, когда аккаунт уже должен быть подтверждён. ```go match := []orm.Predicate[User]{Users.Email.Eq(email)} if requireVerified { match = append(match, Users.IsVerified.Eq(true)) } authCode, err := tx.AuthCodes.Query(). Where(AuthCodes.Code.Eq(code)). Where(AuthCodes.UpdatedAt.Gt(time.Now().Add(-authCodeTTL))). Where(AuthCodes.User.Any(match...)). With(AuthCodes.User). One(ctx) ``` Срез `orm.Predicate[User]`, переданный в `Any`, — фильтр по *связанной* таблице, собранный во время выполнения и всё равно проверенный против `User` при компиляции. `Predicate[Message]` в этом срезе не соберётся. ### Принять и заменить, неделимо ```go if markVerified { if _, err := tx.Users.Update(). Set(Users.IsVerified.Set(true)). Where(Users.ID.Eq(userID)). Exec(ctx); err != nil { return fmt.Errorf("mark user verified: %w", err) } } if _, err := tx.AuthCodes.Update(). Set(AuthCodes.Code.Set(next)). Set(AuthCodes.UpdatedAt.Set(time.Now())). Where(AuthCodes.ID.Eq(authCode.ID)). Exec(ctx); err != nil { return fmt.Errorf("rotate auth code: %w", err) } ``` Принятый код не должен остаться принимаемым, поэтому сопоставление и замена — одна транзакция. Это забота слоя персистентности, поэтому она живёт здесь, а не в сервисе выше. --- # Трейсинг и здоровье > Один трейсер, подключённый один раз, и health-проверка, знающая про миграции. https://ormgo.vercel.app/ru/docs/observability/ ## Контракт Библиотека определяет интерфейс и два типа событий и не импортирует ни одной телеметрической библиотеки. ORM, который импортировал бы такую, заставил бы каждый использующий его проект зависеть от её версии, её транзитивного дерева и её мнений. ```go type Tracer interface { Start(ctx context.Context, e observe.StartEvent) context.Context End(ctx context.Context, e observe.EndEvent) } ``` ## Правило, которое стоит назвать первым **Стартовое событие никогда не несёт значения параметров.** Не по соглашению и не за опцией, которую можно выключить, — такого поля просто нет. Трейсинг ORM видит каждый запрос программы. Трейсер, получающий значения, положил бы каждый пароль, токен и адрес, который обрабатывает программа, туда, куда он пишет. SQL при этом есть — с плейсхолдерами, потому что `WHERE email = $1` полезен и ничего не говорит о том, чей это адрес. Исключение — SQL, который написали вы: библиотека не может вычистить литерал из сырого запроса, не разобрав SQL, а написать разборщик SQL значило бы построить ровно то, ради отсутствия чего она существует. `StartEvent.Raw` показывает, какие запросы такие. ## Подключение ```go db := domain.New(orm.Traced(pool, tracer)) ``` Один вызов, на старте, на исполнителе. Ничто ниже — ни сервис, ни хранилище, ни сгенерированный код — не упоминает телеметрию, а транзакция от этого исполнителя её наследует. ## Два назначения Исполнитель несёт **один** трейсер. Двойная обёртка даёт исполнитель, у которого трейсер внешний, а внутренний не вызывается никогда — молча, без ошибки: ```go ex := orm.Traced(orm.Traced(pool, logging), tracing) // НЕВЕРНО ``` Чтобы попасть в два места, нужен один трейсер, раздающий в оба: ```go type Multi []observe.Tracer func (m Multi) Start(ctx context.Context, e observe.StartEvent) context.Context { for _, t := range m { if t != nil { ctx = t.Start(ctx, e) // контекст протягивается: второй видит то, что добавил первый } } return ctx } func (m Multi) End(ctx context.Context, e observe.EndEvent) { for i := len(m) - 1; i >= 0; i-- { // в обратном порядке: вложенность закрывается изнутри if m[i] != nil { m[i].End(ctx, e) } } } ``` ## slog ```go import "github.com/AlexAli29/orm/ormslog" tracer := ormslog.New(log, ormslog.WithSQL(true), ormslog.WithSlowThreshold(200*time.Millisecond), ormslog.WithRawSQL(false), // тумблер, который пустил бы литералы в лог ) ``` ## OpenTelemetry Отдельный модуль, поэтому проект, который им не пользуется, его не компилирует: ```go import "github.com/AlexAli29/orm/ormotel" tracer := ormotel.New(otelTracer, ormotel.WithSQL(true), ormotel.WithRawSQL(false), ormotel.WithErrorMessages(false), ) ``` `WithErrorMessages(false)` — умолчание, которое стоит оставить: сообщение PostgreSQL может процитировать значение из строки, нарушившей ограничение, а спан уходит туда, где аудитория другая, чем у лога приложения. ## Health ```go import "github.com/AlexAli29/orm/ormhealth" // Дешёвый ответ про живость: дотягивается ли пул до PostgreSQL. report := ormhealth.Quick(ctx, pool) // Ответ про готовность: он же спрашивает, та ли это схема, которую описывают // декларации, и все ли миграции применены. report := ormhealth.Deep(ctx, pool, ormhealth.WithMigrationState(migrationsDir), ormhealth.WithSchemaCheck("orm.yaml"), ) ``` `WithMigrationState` ловит наполовину случившийся деплой: пул поднят, запросы работают, а схема на версию отстала. Liveness скажет «нормально»; эта проверка — нет. ## Разобранные примеры ### Подключение один раз, на старте ```go func main() { pool, err := pgxpool.NewWithConfig(ctx, cfg) if err != nil { log.Fatal(err) } defer pool.Close() tracer := ormslog.New(logger, ormslog.WithSQL(true), ormslog.WithSlowThreshold(200*time.Millisecond), ormslog.WithRawSQL(false)) db := domain.New(orm.Traced(pool, tracer)) // ниже этой строки телеметрия не упоминается } ``` ### Эндпоинт готовности, знающий про миграции ```go http.HandleFunc("/readyz", func(w http.ResponseWriter, r *http.Request) { report := ormhealth.Deep(r.Context(), pool, ormhealth.WithMigrationState("migrations"), ormhealth.WithSchemaCheck("orm.yaml")) if report.Status != ormhealth.StatusUp { w.WriteHeader(http.StatusServiceUnavailable) } writeJSON(w, report) }) http.HandleFunc("/livez", func(w http.ResponseWriter, r *http.Request) { if ormhealth.Quick(r.Context(), pool).Status != ormhealth.StatusUp { w.WriteHeader(http.StatusServiceUnavailable) } }) ``` Liveness спрашивает, надо ли перезапустить процесс. Readiness — надо ли слать ему трафик; поду, у которого схема на версию отстала, слать не надо, и ради этого случая существует `WithMigrationState`. ### И лог, и спан ```go type Multi []observe.Tracer func (m Multi) Start(ctx context.Context, e observe.StartEvent) context.Context { for _, t := range m { ctx = t.Start(ctx, e) } return ctx } func (m Multi) End(ctx context.Context, e observe.EndEvent) { for i := len(m) - 1; i >= 0; i-- { m[i].End(ctx, e) } } db := domain.New(orm.Traced(pool, Multi{slogTracer, otelTracer})) ``` Двойная обёртка `Traced` этого не даёт: внутренний трейсер не вызывается никогда, и об ошибке никто не сообщает. --- # Производительность > Чтение планов, отпечатки запросов и работа, которую библиотека не делает. https://ormgo.vercel.app/ru/docs/performance/ ## Чего здесь нет Нет советчика по индексам и рекомендаций по тюнингу. Планирует PostgreSQL, а решение о схеме или сервере требует всей нагрузки, а не одного запроса. Здесь только отчёты; рекомендаций нет. ## Explain ```go plan, err := q.Explain(ctx) // EXPLAIN — запрос не выполняется plan, err := q.ExplainAnalyze(ctx) // EXPLAIN ANALYZE — выполняется, чтобы измерить ``` Имена различаются, потому что опасно различается поведение. `ExplainAnalyze` на `DELETE` удаляет. ```go plan, _ := q.Explain(ctx) fmt.Println(plan.TotalCost, plan.PlanRows) for _, node := range plan.Walk() { if node.Type == "Seq Scan" && node.PlanRows > 10000 { // крупное последовательное сканирование — сообщено, а не продиагностировано } } ``` ## Диагностика без базы ```go report, err := q.Diagnostics() ``` Только структура: сколько джойнов, есть ли `LIMIT`, коррелирован ли запрос. Соединение не нужно, поэтому это работает в юнит-тесте. ## Отпечатки Отпечаток опознаёт *форму* запроса независимо от значений: ```go fp, err := q.Fingerprint() // v1:9f2c... — одинаков для любого набора аргументов ``` Два запроса, различающиеся только значениями, дают один отпечаток; различающиеся `LIMIT` — разные, потому что маленький лимит и есть то, из-за чего планировщик предпочитает индекс, который иначе бы проигнорировал. Группировка по «это один и тот же запрос» полезнее группировки по «выглядит похоже». ## Отчёт ```go report, err := q.PerformanceReport(ctx) ``` План, форма и отпечаток вместе, всегда через обычный `EXPLAIN` и никогда через `ANALYZE`. ## Что быстро из-за формы API **Связи загружаются пакетами.** Число запросов зависит от формы дерева, а не от количества строк. N+1 негде взяться, потому что нет ленивой загрузки. **Сканирование без рефлексии.** Проекция читается в N типизированных локальных переменных одним `Scan`. Сущности — через сгенерированные метаданные. **Дескрипторы разделяются, а не копируются.** Они доступны только для чтения, поэтому все запросы по таблице используют один набор. **`Count` и `Exists` выбирают константу.** Строка на совпадение и никаких значений из неё: запрашивать колонки значило бы заставить сервер доставать и декодировать данные, которые никто не читает. **Для массовой загрузки есть COPY.** `CopyFrom` и `CopyFromSeq` на порядок быстрее `INSERT`, а потоковая форма никогда не держит всю пачку. ## Измерение В репозитории есть бенчмарки, которые компилируются и запускаются в CI, поэтому регрессия в работе между вызывающим и pgx проявится падением сборки, а не отсутствием сигнала: ```bash go test -run '^$' -bench . -benchtime 10x ./... ``` ## Разобранные примеры ### Прочитать план, прежде чем верить запросу ```go q := db.Orders.Query(). Where(Orders.CustomerID.Eq(id)). OrderBy(Orders.PlacedAt.Desc()). Limit(20) plan, err := q.Explain(ctx) fmt.Println(plan.TotalCost, plan.PlanRows) ``` `Explain` не выполняет оператор. `ExplainAnalyze` выполняет — поэтому у них разные имена: запуск второго на `DELETE` удаляет. ### Группировка медленных запросов по форме ```go fp, err := q.Fingerprint() metrics.Observe(fp.String(), elapsed) ``` Два вызова с разными идентификаторами клиента дают один отпечаток; тот же запрос с другим `LIMIT` — другой, потому что маленький лимит и заставляет планировщик выбрать индекс, который иначе он пропустил бы. ### Проверка формы запроса в юнит-тесте ```go report, err := q.Diagnostics() // база не нужна if report.Joins > 3 { t.Errorf("this grew a join nobody meant to add") } ``` ### Считать запросы, а не догадываться ```go db := domain.New(orm.Traced(pool, counter)) _, _ = db.Customers.Query().With(Customers.Orders).All(ctx) // counter увидел 2 оператора, сколько бы ни было строк ``` Трейсер — честный способ утверждать, что N+1 нет: число наблюдают, а не выводят рассуждением. --- # Тестирование > Настоящий PostgreSQL, одноразовые базы и никакого мокинга SQL. https://ormgo.vercel.app/ru/docs/testing/ ## Позиция Не мокайте базу. Мок SQL-драйвера проверяет, что вы умеете писать моки; а то, что ломается в проде — ограничения, типы, семантика NULL, изоляция транзакций, — ровно то, о чём у мока нет мнения. Всё здесь сделано так, чтобы запускаться против настоящего PostgreSQL и чтобы это было дёшево. ## База на тестовый бинарник `ormtest` не создаёт базы. Он даёт то, что делает настоящую базу удобной: применение миграций, проверку, что схема — это та, которую описывает ваш конфиг, и запуск теста внутри транзакции, которая всегда откатывается. ```go import "github.com/AlexAli29/orm/ormtest" func TestMain(m *testing.M) { conn, _ := pgx.Connect(ctx, os.Getenv("TEST_DSN")) ormtest.Migrate(t, conn, "migrations") // применить закоммиченные артефакты os.Exit(m.Run()) } ``` `ormtest.CheckSchema(ctx, "orm.yaml")` — та проверка, которую стоит поставить в CI: она падает, когда база не соответствует схеме из деклараций, а иначе это всплывает как странно сломавшийся посторонний тест. `RequireSchemaClean` — та же проверка в виде жёсткого требования. ## Контейнеры ```go import ormpg "github.com/AlexAli29/orm/ormtest/postgres" func TestMain(m *testing.M) { ormpg.Run(m, ormpg.WithImage("postgres:17")) } ``` Отдельный модуль, потому что Testcontainers — тяжёлая зависимость, и проект, у которого PostgreSQL уже есть, платить за неё не должен. ## Очистка таблиц между тестами `Truncate` живёт в `ormtest`, а не в API запросов, потому что очистка таблицы — это операция фикстуры, а не то, что делает приложение: ```go import "github.com/AlexAli29/orm/ormtest" ormtest.MustTruncate(t, pool, domain.Users, domain.Orders) err := ormtest.TruncateWith(ctx, pool, []ormtest.TruncateOption{ormtest.RestartIdentity(), ormtest.Cascade()}, domain.Users, ) ``` Она принимает сгенерированные ручки таблиц напрямую. `RestartIdentity` сбрасывает последовательности identity — обычно этого фикстура и хочет; `Cascade` идёт по внешним ключам и опустошит таблицы, которых вы не называли, поэтому включается явно. Очистка нескольких таблиц одним вызовом — не удобство: это единственный способ опустошить таблицы, ссылающиеся друг на друга, без `Cascade`. ## Транзакция на тест Быстро и изолированно, без удаления чего-либо: ```go func TestSomething(t *testing.T) { ormtest.TxFunc(t, pool, func(ex orm.Executor) { db := domain.New(ex) // ... тест ... транзакция откатывается при выходе }) } ``` ## Проверка SQL без базы ```go sql, args, err := q.SQL() if !strings.Contains(sql, "LEFT JOIN") { /* ... */ } ``` Полезно для формы запроса. Не замена запуску: SQL, который выглядит правильным и возвращает не те строки, — это тот самый режим отказа, против которого выстроен весь проект. ## Что должно быть в CI ```bash orm makemigrations --check # декларация без миграции orm check --generated # сгенерированный код разошёлся go test -race ./... ``` ## Тесты на всех мажорах Собственный набор совместимости проекта отказывается запускаться меньше чем на всех пяти поддерживаемых мажорах, и идею стоит позаимствовать: матрица, тихо отработавшая на том сервере, который случайно был поднят, доказывает меньше, чем заявляет. ```yaml strategy: matrix: postgres: ['14', '15', '16', '17', '18'] ``` ## Разобранные примеры ### Тест, который ничего после себя не оставляет ```go func TestPlaceOrder(t *testing.T) { ormtest.TxFunc(t, pool, func(ex orm.Executor) { db := domain.New(ex) customer, err := db.Customers.Insert(t.Context(), Customer{Email: "a@example.com"}) if err != nil { t.Fatal(err) } order, err := db.Orders.Insert(t.Context(), Order{CustomerID: customer.ID}) if err != nil { t.Fatal(err) } if order.ID == 0 { t.Error("the insert returned no key") } }) } ``` Всё откатывается при выходе из колбэка, поэтому тесты можно запускать в любом порядке и ни один не видит строк другого. ### Сброс фикстур между наборами ```go func resetFixtures(t *testing.T, pool *pgxpool.Pool) { ormtest.MustTruncate(t, pool, domain.OrderLines, domain.Orders, domain.Customers) } ``` Перечисление таблиц одним вызовом и позволяет им ссылаться друг на друга без `Cascade`. ### Проверка, что схема — та, которую вы объявили ```go func TestMain(m *testing.M) { if err := ormtest.CheckSchema(context.Background(), "orm.yaml"); err != nil { log.Fatalf("the test database is not the declared schema: %v", err) } os.Exit(m.Run()) } ``` Это превращает «тест странно упал» в «база отстала на миграцию», а это гораздо более короткий разбор. ### Проверка запроса без базы ```go sql, args, err := db.Orders.Query(). Where(Orders.CustomerID.Eq(7)). OrderBy(Orders.PlacedAt.Desc()). SQL() if !strings.Contains(sql, "ORDER BY") { t.Error("the ordering was dropped") } if len(args) != 1 { t.Errorf("args = %d, want the customer id as a parameter", len(args)) } ``` Полезно для формы. Не замена запуску: SQL, который выглядит правильным и возвращает не те строки, — тот самый режим отказа, против которого выстроен весь проект. --- # Совместимость > Какие версии PostgreSQL и Go и что здесь означает стабильность. https://ormgo.vercel.app/ru/docs/compatibility/ ## PostgreSQL **14, 15, 16, 17 и 18.** Не «должно работать». Набор тестов совместимости отказывается запускаться, если доступны не все пять сразу: ```text the compatibility matrix requires every supported major and [ORM_TEST_DSN_PG14 ... PG18] are unset. Skipping here would report a five-major claim proven by however many servers happened to be running ``` Заявленной, но ни разу не проверенной версии верить нельзя. 14 покинет список, когда разработчики PostgreSQL прекратят её поддержку 12 ноября 2026 года. ## Что доказано на всех пяти - Весь пользовательский сценарий: миграция, генерация, проверка, запись, чтение, джойн, обновление матпредставления. - **Побайтово одинаковые артефакты.** Сгенерированный Go, `orm.lock` и артефакты миграций одинаковы на 14 и на 18. Команда с разными серверами не получает диф при чекауте. - Ничего серверно-локального ни в одном артефакте: ни OID, ни имени базы, ни версии сервера, ни разобранного определения, ни абсолютных путей. ## Go Минимальная версия — та, что в `go.mod`, сейчас **1.24**, и её повышение — решение, а не побочный эффект появления нового тулчейна. Отдельная задача в CI фиксирует `GOTOOLCHAIN=local` и собирает только те модули, которые на этой планке, — то есть утверждение доказывается, а не предполагается. Некоторые периферийные модули заявляют версию выше, потому что этого требуют их собственные зависимости: помощник для Testcontainers и часть примеров хотят 1.25. Планку библиотеки это не двигает, и задачи разделены так, чтобы не могло. ## PostGIS Доказано на тех сочетаниях, которые проект действительно заявляет: PostgreSQL 17 с PostGIS 3.5, 16 с 3.4 и 14 с 3.4. Пространственный набор пропускается, когда расширения нет, — правильно на машине разработчика, неправильно в CI, — поэтому CI ставит `ORM_REQUIRE_POSTGIS=1`, что превращает пропуск в падение. ## Стабильность Публичный API заморожен на v1 и отслеживается сгенерированным манифестом. Удалённый символ, изменённая сигнатура, ужесточённое ограничение и метод, добавленный в интерфейс, который реализуют потребители, — всё это роняет сборку. Сам инструмент манифеста протестирован на то, что замечает каждое из этого. ## Расширения `citext`, `hstore`, `pg_trgm`, `uuid-ossp` и PostGIS распознаются, когда установлены. Ни одно не обязательно, и библиотека никогда не создаёт расширение — это привилегированная операция того, кто владеет базой. ## Разобранные примеры ### Матрица CI, которая не может тихо сократиться ```yaml strategy: fail-fast: false matrix: postgres: ['14', '15', '16', '17', '18'] ``` И тест, который отказывается запускаться на меньшем числе, вместо того чтобы объявить поддержку пяти версий, доказанную тем, сколько серверов оказалось поднято: ```go func requireEveryMajor(t *testing.T) map[string]string { var missing []string for _, v := range []string{"14", "15", "16", "17", "18"} { if os.Getenv("PG_DSN_"+v) == "" { missing = append(missing, v) } } if len(missing) > 0 { t.Fatalf("missing servers for %v; skipping here would report a claim "+ "nobody proved", missing) } return nil } ``` ### Зафиксировать минимальную версию Go и доказать её ```yaml - name: the library builds on its declared floor env: GOTOOLCHAIN: local # не тянуть молча более новый Go run: go build ./... ``` Без `GOTOOLCHAIN=local` более новый тулчейн скачивается по требованию, и планка не проверяется никогда. ### Сделать необязательное расширение обязательным в CI ```yaml - env: ORM_REQUIRE_POSTGIS: '1' run: go test ./postgis/... ``` Пространственный набор пропускается, когда PostGIS нет: на ноутбуке это верно, в CI — нет. Переменная превращает пропуск в падение.