Najlepsze praktyki ORM
Dobre praktyki ORM dla agentów AI sprowadzają się do trzech ruchów: przypięcia wersji Prisma 7 lub TypeORM 1.x w kontekście agenta, żeby przestał pisać usunięte API, promptowania o kształty zapytań i niezmienniki zamiast o kod oraz testów integracyjnych na prawdziwym Postgresie jako dowodu. Asercja liczby zapytań łapie regresje N+1, a test współbieżności łapie sprzedaż ponad stan.
Ta receptura jest dla programistów, którzy oddają warstwę ORM serwisu w TypeScripcie Claude Code, Codeksowi lub Cursorowi. Schemat od agenta wygląda dobrze, testy na mockach przechodzą. Potem produkcja pokazuje N+1 na liście zamówień, dwóch klientów kupuje ostatnią sztukę towaru, a fragment z prisma.$use wywraca się przy starcie, bo Prisma 7 już tego nie ma.
Co dają te receptury ORM
Dział zatytułowany „Co dają te receptury ORM”- Blok przypięcia wersji do
AGENTS.mdlubCLAUDE.md, który powstrzymuje agenta przed generowaniem kodu Prisma 6 lub TypeORM 0.3 - Prompt na wielodostępny schemat Prisma 7 oraz konfigurację i klienta Prisma 7, które agenci wciąż mylą
- Test liczby zapytań, który zamienia regresję N+1 w czerwony build w CI
- Przepływ składania zamówienia, który nie sprzeda więcej, niż jest na stanie, i nigdy nie trzyma blokady wiersza w trakcie wywołania płatności
- Zestaw testów Testcontainers, który agent uruchamia i naprawia w pętli, oraz tabelę bramek zatwierdzających zmianę
Najpierw przypnij wersję ORM w kontekście agenta
Dział zatytułowany „Najpierw przypnij wersję ORM w kontekście agenta”Agenci uczyli się na latach kodu Prisma 4–6 i TypeORM 0.2–0.3. Bez przypiętej wersji odtwarzają go z pełnym przekonaniem. Wpisz wersje i zakazane API do pliku kontekstu projektu, który czyta twoje narzędzie (AGENTS.md dla Codeksa i Cursora, CLAUDE.md dla Claude Code albo oba), żeby każda sesja od nich startowała:
## Database layer- Prisma 7.10 (`prisma-client` generator, output in src/generated/prisma) with @prisma/adapter-pg. TypeORM 1.1 in services/billing.- The datasource URL lives in prisma.config.ts, not schema.prisma. PrismaClient always gets an `adapter`.- Never use: prisma.$use, prisma.$metrics, `url =` in the datasource block, @EntityRepository, getCustomRepository, getManager(), new Connection(). Use $extends, OpenTelemetry, prisma.config.ts, Repository.extend(), new DataSource().- Money: Decimal @db.Decimal(12, 2) plus a currency column. Never @db.Money or Float.- Every data-access change ships with an integration test on Postgres (Testcontainers), not a mock.Usunięte API z tabeli sprawdzono w opublikowanych definicjach typów przypiętych wersji:
| Agent pisze | Stan | Pisz zamiast tego |
|---|---|---|
url = env("DATABASE_URL") w bloku datasource | Przeniesione do prisma.config.ts w Prisma 7.0.0 | datasource: { url: env('DATABASE_URL') } w prisma.config.ts |
new PrismaClient() bez opcji | Prisma 7 wymaga adaptera sterownika albo accelerateUrl | new PrismaClient({ adapter }) |
middleware prisma.$use(...) | Nie ma go w kliencie @prisma/client 7.10.0 | rozszerzenie query przez $extends |
prisma.$metrics | Funkcja preview metryk usunięta w Prisma 7.0.0 | śledzenie OpenTelemetry |
@EntityRepository, getCustomRepository, AbstractRepository | typeorm 1.1.1 ich nie eksportuje | dataSource.getRepository(User).extend({...}) |
getManager(), new Connection(...) | typeorm 1.1.1 ich nie eksportuje | dataSource.manager, new DataSource(...) |
Zaprojektuj schemat Prisma 7, którego agent nie zrozumie opacznie
Dział zatytułowany „Zaprojektuj schemat Prisma 7, którego agent nie zrozumie opacznie”Agenci szybko rozumują o relacjach i indeksach. Twoja rola to podać model wielodostępności, regułę miękkiego usuwania i zapytania, które naprawdę wykonujesz, żeby indeksy pasowały do rzeczywistych kształtów zapytań, a nie do zgadywania.
Dobra odpowiedź wygląda jak ten fragment. Zwróć uwagę na blok datasource z samym dostawcą, kolumnę Decimal i indeks złożony ułożony pod zapytanie:
generator client { provider = "prisma-client" output = "../src/generated/prisma"}
datasource db { provider = "postgresql"}
model Product { id String @id @default(cuid()) organizationId String slug String category String basePrice Decimal @db.Decimal(12, 2) currency String @default("USD") @db.Char(3) deletedAt DateTime?
organization Organization @relation(fields: [organizationId], references: [id]) variants ProductVariant[]
@@unique([organizationId, slug]) @@index([organizationId, category, deletedAt]) @@map("products")}Adres połączenia (connection string) jest teraz w prisma.config.ts, a klient dostaje adapter sterownika. Prisma 7 przestała też sama wczytywać .env, więc zrób to jawnie:
import 'dotenv/config';import { defineConfig, env } from 'prisma/config';
export default defineConfig({ schema: 'prisma/schema.prisma', migrations: { path: 'prisma/migrations' }, datasource: { url: env('DATABASE_URL') },});
// src/db.tsimport { PrismaPg } from '@prisma/adapter-pg';import { PrismaClient } from './generated/prisma/client';
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL, max: 10 });export const prisma = new PrismaClient({ adapter });Ta sama zmiana schematu przebiega w każdym narzędziu inaczej:
Uruchom pętlę edycja–walidacja bez interfejsu, z Bashem ograniczonym do poleceń Prismy:
claude -p "Add a soft-deletable AuditLog model to prisma/schema.prisma, run 'npx prisma validate', \then 'npx prisma migrate dev --name add_audit_log --create-only'. Print the generated SQL and \flag any DROP, ALTER TYPE or NOT NULL without a default." \ --allowedTools Read Edit "Bash(npx prisma *)"--create-only zapisuje SQL migracji bez stosowania go, więc czytasz skutki blokad, zanim cokolwiek dotknie bazy. Polecenie i tak łączy się z DATABASE_URL i tworzy bazę cieniową (shadow database), więc kieruj je na lokalnego Postgresa albo kontener z Testcontainers, nigdy na bazę współdzieloną.
Zmiana schematu regeneruje typy klienta w całym repozytorium, więc daj Codeksowi własny zarządzany worktree:
codex --worktree -c default_permissions=":workspace" \ "Add an AuditLog model with organizationId and deletedAt, run npx prisma generate, \ and fix every call site that now fails npx tsc --noEmit."--worktree (Codex CLI 0.157.1) sam tworzy worktree. Profile uprawnień są w wersji beta; starszy odpowiednik to --sandbox workspace-write. Te dwa mechanizmy się nie łączą, więc wybierz jeden.
Otwórz prisma/schema.prisma i daj agentowi Cursora prompt z góry. Przejrzyj diff każdego pliku, najuważniej linie @@index, zanim go zaakceptujesz. Z zainstalowanym rozszerzeniem Prisma błędy walidacji pojawiają się w panelu Problems, a „fix the schema errors in the Problems panel” to kompletny prompt uzupełniający.
Używaj Repository.extend() w TypeORM 1.x
Dział zatytułowany „Używaj Repository.extend() w TypeORM 1.x”W TypeORM najczęstszy błąd agenta to @EntityRepository(User) class UserRepository extends Repository<User>. TypeORM 1.1.1 nie eksportuje tego dekoratora, więc kod się nie kompiluje. Aktualny idiom to Repository.extend():
export const UserRepository = dataSource.getRepository(User).extend({ findActiveByEmail(email: string) { return this.createQueryBuilder('user') .innerJoinAndSelect('user.organization', 'org') .where('user.email = :email', { email }) .andWhere('user.deletedAt IS NULL AND org.deletedAt IS NULL') .getOne(); },});Znajdź i usuń zapytania N+1 testem liczby zapytań
Dział zatytułowany „Znajdź i usuń zapytania N+1 testem liczby zapytań”N+1 to sztandarowa awaria ORM: niewinna pętla wysyła jedno zapytanie na każdy wiersz. Agenci dobrze to naprawiają, gdy widzą pętlę i SQL razem. Równie chętnie wprowadzają problem z powrotem przy późniejszym refaktorze, więc poprawka to tylko połowa pracy. Druga połowa to test, który liczy zapytania.
Poprawka to zagnieżdżony include, który Prisma rozwiązuje bez zapytania na każdego użytkownika:
const users = await prisma.user.findMany({ where: { organizationId, deletedAt: null }, include: { orders: { where: { status: 'COMPLETED', deletedAt: null } } },});To test pilnuje, żeby poprawka przetrwała. Liczba zapytań nie może rosnąć razem z liczbą wierszy:
const queries: string[] = [];const prisma = new PrismaClient({ adapter, log: [{ emit: 'event', level: 'query' }] });prisma.$on('query', (e) => queries.push(e.query));
it('loads users with orders in a constant number of queries', async () => { await seedOrg('small', 3); await seedOrg('large', 20);
queries.length = 0; await listUsersWithOrders(prisma, 'small'); const small = queries.length;
queries.length = 0; await listUsersWithOrders(prisma, 'large'); expect(queries.length).toBe(small);});Gdy sam SQL jest wolny, a nie powtarzany, przejdź do wzorców optymalizacji SQL, które czytają plan EXPLAIN.
Napisz przepływ zamówienia bez sprzedaży ponad stan i podwójnego obciążenia
Dział zatytułowany „Napisz przepływ zamówienia bez sprzedaży ponad stan i podwójnego obciążenia”Składanie zamówień to miejsce, w którym kod ORM od agenta kosztuje pieniądze. Domyślnie agent pisze jedną dużą transakcję, która blokuje stany, woła operatora płatności i zatwierdza. Trzyma przez to blokady wierszy przez cały czas trwania wywołania HTTP, a w Prismie transakcja interaktywna domyślnie kończy się timeoutem po pięciu sekundach. Bezpieczniejszy kształt ma trzy fazy:
- Rezerwacja w krótkiej transakcji: utwórz zamówienie
PENDINGi zmniejsz stan tylko tam, gdzie wystarcza towaru. - Obciążenie poza jakąkolwiek transakcją, z identyfikatorem zamówienia jako kluczem idempotencji.
- Potwierdzenie: oznacz zamówienie jako
PAIDalbo zwolnij rezerwację, jeśli płatność się nie udała.
Warunkowy updateMany łączy sprawdzenie stanu i dekrementację w jedną atomową instrukcję. Przy domyślnej izolacji READ COMMITTED w PostgreSQL współbieżna aktualizacja ponownie ocenia klauzulę WHERE względem zatwierdzonego wiersza, więc dwóch kupujących nie weźmie ostatniej sztuki:
// Phase 1: reserve (Prisma 7)const order = await prisma.$transaction(async (tx) => { for (const item of items) { const { count } = await tx.productVariant.updateMany({ where: { id: item.variantId, stockQuantity: { gte: item.quantity } }, data: { stockQuantity: { decrement: item.quantity } }, }); if (count === 0) throw new InsufficientStockError(item.variantId); // rolls back all items } const total = orderTotal(items); // sum of unitPrice * quantity, computed as Decimal return tx.order.create({ data: { organizationId, userId, status: 'PENDING', total, currency, items: { create: items.map(({ variantId, quantity, unitPrice }) => ({ variantId, quantity, unitPrice })) }, }, });});
// Phase 2: charge outside the transaction; the provider deduplicates on the keylet payment: { id: string };try { payment = await payments.charge({ amount: order.total, currency: order.currency, idempotencyKey: order.id });} catch (err) { await releaseReservation(order.id); // restores stock, marks the order CANCELLED throw err;}
// Phase 3: confirm; only a PENDING order can become PAIDawait prisma.order.updateMany({ where: { id: order.id, status: 'PENDING' }, data: { status: 'PAID', paymentId: payment.id },});Jeśli faza 3 się nie powiedzie, klient zapłacił za zamówienie PENDING. Nie zwracaj pieniędzy na ślepo z bloku catch. Niech zadanie uzgadniające porównuje zamówienia PENDING starsze niż kilka minut z danymi operatora płatności i je domyka albo anuluje. W TypeORM ten sam krok rezerwacji używa dataSource.transaction('READ COMMITTED', ...) z .setLock('pessimistic_write') na wariantach przed sprawdzeniem stanu.
Ustaw pulę połączeń tam, gdzie ORM naprawdę ją czyta
Dział zatytułowany „Ustaw pulę połączeń tam, gdzie ORM naprawdę ją czyta”Agenci często piszą blok datasource z connectionLimit albo obiektem pool = { ... }. Żadne z tych pól nie istnieje, a prisma validate je odrzuca. W Prisma 7 pula należy do sterownika: @prisma/adapter-pg przyjmuje konfigurację puli pg (to max: 10 w src/db.ts wyżej) albo gotowy pg.Pool. Parametr URL ?connection_limit= z tutoriali o Prisma 6 to idiom Prisma 6; zamiast niego skonfiguruj pulę pg. TypeORM przekazuje opcje puli przez extra:
const dataSource = new DataSource({ type: 'postgres', url: process.env.DATABASE_URL, extra: { max: 10, idleTimeoutMillis: 30_000, statement_timeout: 60_000 },});Rozmiar puli ustalasz na proces, potem mnożysz przez liczbę procesów. Dziesięć połączeń w każdej z 50 instancji serverless to 500 połączeń, czyli więcej niż domyślne max_connections równe 100 w PostgreSQL. W środowisku serverless postaw przed bazą pooler, np. PgBouncer, i trzymaj max nisko.
Udowodnij to na prawdziwym Postgresie z Testcontainers
Dział zatytułowany „Udowodnij to na prawdziwym Postgresie z Testcontainers”Mockowanie query buildera testuje mocka. @testcontainers/postgresql uruchamia jednorazowego Postgresa w Dockerze, więc blokady, ograniczenia i migracje działają naprawdę. W wersji 12 konstruktor wymaga obrazu, a .start() zwraca StartedPostgreSqlContainer. Agenci często pomijają obraz.
import { execSync } from 'node:child_process';import { PostgreSqlContainer, StartedPostgreSqlContainer } from '@testcontainers/postgresql';
let container: StartedPostgreSqlContainer;
beforeAll(async () => { container = await new PostgreSqlContainer('postgres:17').start(); process.env.DATABASE_URL = container.getConnectionUri(); execSync('npx prisma migrate deploy', { env: process.env, stdio: 'inherit' });}, 120_000);
afterAll(async () => { await container.stop();});Każde narzędzie prowadzi pętlę czerwone–zielone po swojemu:
claude -p "Run 'npx vitest run tests/integration/order-service.test.ts'. If the concurrency \test fails, find the missing guard in placeOrder and fix the code, not the test. Re-run until green." \ --allowedTools Read Edit "Bash(npx vitest *)" "Bash(npx prisma *)"Tryb bez interfejsu pasuje do tej pętli: kontener startuje wolno, a ty nie musisz pilnować każdego przebiegu.
codex --worktree -c default_permissions=":workspace" \ "Run npx vitest run tests/integration/order-service.test.ts and fix placeOrder until it passes. \ Do not edit the assertions."Testcontainers potrzebuje dostępnego demona Dockera. Jeśli sandbox blokuje gniazdo Dockera, uruchom zestaw w CI i wklej błąd z powrotem, zamiast poszerzać sandbox.
Daj agentowi Cursora prompt z góry i pozwól mu uruchomić zestaw w zintegrowanym terminalu. Gdy test współbieżności padnie, wklej wynik i poproś, żeby najpierw wyjaśnił, jaki przeplot go wywołał, a dopiero potem zmieniał kod.
Pokaż agentowi żywy schemat przez serwer MCP dla Postgresa
Dział zatytułowany „Pokaż agentowi żywy schemat przez serwer MCP dla Postgresa”Przy pracy nad schematem i N+1 agent radzi sobie lepiej, gdy czyta prawdziwe indeksy, szacunki liczby wierszy i wynik EXPLAIN zamiast twojego pliku modelu. Daje mu to Postgres MCP Pro (PyPI postgres-mcp 0.3.0, od Crystal DBA; ostatnie wydanie 2025-05-16, więc przypnij wersję i obserwuj repozytorium); uruchamiaj go na bazie deweloperskiej lub stagingowej z rolą tylko do odczytu:
# Claude Code: project scope, written to .mcp.jsonclaude mcp add -s project postgres -e 'DATABASE_URI=${DEV_DATABASE_URL}' \ -- uvx postgres-mcp --access-mode=restricted
# Codex: user scope, written to ~/.codex/config.tomlcodex mcp add postgres --env DATABASE_URI="$DEV_DATABASE_URL" \ -- uvx postgres-mcp --access-mode=restrictedPojedyncze cudzysłowy zostawiają ${DEV_DATABASE_URL} dosłownie w .mcp.json. Claude Code rozwija tę zmienną ze środowiska każdego dewelopera, więc do repozytorium nie trafia żadne hasło. W linii Codeksa są podwójne cudzysłowy, bo ~/.codex/config.toml jest plikiem użytkownika i zapisuje wartość rozwiniętą przez powłokę. Cursor przyjmuje to samo polecenie i argumenty we własnej konfiguracji MCP. Serwery MCP dla baz danych zawierają wszystkie trzy konfiguracje i rolę tylko do odczytu, którą trzeba utworzyć najpierw.
Jak weryfikować kod ORM od agenta bez czytania każdej linii
Dział zatytułowany „Jak weryfikować kod ORM od agenta bez czytania każdej linii”Czytaj SQL migracji i niezmienniki. Resztę niech sprawdzą bramki:
| Bramka | Co udowadnia | Gdzie działa |
|---|---|---|
npx prisma validate i npx tsc --noEmit | Schemat jest poprawny dla Prisma 7, a każde wywołanie zgadza się z wygenerowanymi typami | Hook pre-commit i CI |
| Grep za zakazanymi API z bloku przypięcia | Żadne usunięte API nie wróciło | CI |
| Testy liczby zapytań | Regresje N+1 psują build | CI, na Testcontainers |
| Testy współbieżności i częściowej awarii | Brak sprzedaży ponad stan i osieroconych płatności | CI, na Testcontainers |
npx prisma migrate status na docelowej bazie | Zastosowane migracje zgadzają się z repozytorium; brak ręcznego dryfu | Job wdrożenia, przed migrate deploy |
| Lektura SQL migracji przez człowieka | Blokady, usunięcia i backfille są zamierzone | Review PR |
Programista, który otwiera PR, odpowiada za niezmienniki w testach. Recenzent zatwierdza SQL migracji i nazwy testów, a nie każdą linię wygenerowanego kodu repozytorium. Kolejność bramek, która składa to w pipeline, opisuje strona testowanie w cyklu życia z agentem.
Kiedy kod ORM od agenta psuje się na produkcji
Dział zatytułowany „Kiedy kod ORM od agenta psuje się na produkcji”- Wygenerowany kod celuje w martwą wersję major. Objawy:
prisma.$use is not a function, błąd walidacjiurlw blokudatasourcealbo importy TypeORM, które się nie rozwiązują. Naprawa: dodaj blok przypięcia z pierwszej sekcji do pliku kontekstu i wklej błąd z dopiskiem „this is Prisma 7.10 / TypeORM 1.1”. Potem dodaj do CI grep za zakazanymi API. - Przez
npx prismaprzyszła wersja kandydująca. Objawy: wersja CLI różni się od@prisma/client, a wynikgeneratesię zmienia. Naprawa: przypnij oba pakiety do 7.10.0 wpackage.jsoni uruchamiaj CLI znode_modules. - Wyczerpanie puli pod obciążeniem. Objaw: timeouty przy pobieraniu połączenia. Naprawa: policz
max× liczba procesów względemmax_connections, zmniejszmaxi dodaj PgBouncer w środowisku serverless. - Burze ponowień serializacji. Objaw: serie błędów
40001zamienione w odpowiedzi 500. Naprawa: zejdź doREAD COMMITTEDz warunkową aktualizacją lub blokadą wiersza albo dodaj ograniczone ponowienia tylko dla40001. - Osierocone płatności. Objaw: klient zapłacił, a zamówienie wisi jako
PENDING. Naprawa: uruchom zadanie uzgadniające i dodaj test częściowej awarii z promptu Testcontainers, żeby ta ścieżka została pokryta. - N+1 wraca po refaktorze. Objaw: opóźnienie rośnie z rozmiarem strony. Naprawa: test liczby zapytań powinien już być czerwony; jeśli jest zielony, dane testowe są za małe, żeby pokazać wzrost, więc dodaj więcej wierszy.
- Dryf migracji. Objaw:
migrate deploypada w CI, bo ktoś zmienił bazę ręcznie. Naprawa: niech wdrożenie zatrzymuje się, gdymigrate statuswykaże rozbieżność, i nigdy nie uruchamiajprisma db pushna współdzielonej bazie. Przepisanie bez przestoju opisują wzorce migracji baz danych.
Dokąd dalej w pracy z ORM
Dział zatytułowany „Dokąd dalej w pracy z ORM”- Receptury baz danych: przegląd, który dobiera recepturę do zadania przed tobą
- Wzorce migracji baz danych: expand and contract, bramki lintera i wycofywanie migracji, które generuje ta strona
- Wzorce optymalizacji SQL: plany
EXPLAINi projekt indeksów, gdy zapytanie jest wolne, a nie powtarzane - Rozwój baz danych z agentami AI: pełny przepływ od specyfikacji do wdrożenia
- Testy integracyjne z AI: wzorce Testcontainers poza warstwą bazy danych