Přeskočit na obsah
·30 min čtení

Návrh databázového schématu: Vzory, které stárnou dobře

Pravidla normalizace, konvence pojmenování, soft delete, audit trail, vzory multi-tenancy, strategie verzování a rozhodnutí o schématu, kterých jsem litoval. Zaměřeno na PostgreSQL.

Sdílet:X / TwitterLinkedIn

Schéma je smlouva s vaším budoucím já. Každý sloupec, který přidáte, každé omezení, které přeskočíte, každé "uklidíme to později" — to vše se kumuluje. Pracoval jsem na systémech, kde jedno špatné rozhodnutí o schématu učiněné před třemi lety nyní stojí tým celý sprint každý kvartál na obcházkách.

Databáze přežije všechno. Váš frontendový framework se změní. Vaše API vrstva bude přepsána. Vaše strategie nasazení se vyvine. Ale data? Data zůstávají. A tvar, který jste jim dali prvního dne, vás následuje navždy, protože migrace tabulky s 200 miliony řádků není totéž co refaktoring React komponenty.

Toto je to, co jsem se naučil o rozhodnutích ohledně schématu, která vás nebudou pronásledovat. Zaměřeno na PostgreSQL, protože to je to, co používám a čemu věřím, ale většina těchto vzorů platí pro jakoukoli relační databázi.

Konvence pojmenování: Nudná věc, na které nejvíc záleží#

Viděl jsem více hádek o konvencích pojmenování než o skutečných architektonických rozhodnutích. Tady je to, na čem jsem se ustálil po letech přepínání kontextu mezi projekty:

Snake_case pro všechno. Tabulky, sloupce, indexy, omezení. Žádný camelCase, žádný PascalCase. PostgreSQL stejně převádí nekotované identifikátory na malá písmena, takže createdAt se stane createdat, pokud to všude neuzavřete do dvojitých uvozovek. Nebojujte s databází.

Množné názvy tabulek. Tabulka obsahuje mnoho řádků. users ne user. orders ne order. order_items ne order_item. V dotazech se to čte přirozeně: SELECT * FROM users WHERE ... — vybíráte z kolekce.

Přípona _id pro cizí klíče. user_id, order_id, tenant_id. Primární klíč je prostě id. Je to jednoznačné. Když spojujete tabulky, users.id = orders.user_id se čte jako angličtina.

Přípona _at pro časová razítka. created_at, updated_at, deleted_at, published_at, expires_at. Vždy víte, že jde o časový okamžik.

Předpona is_ pro booleany. is_active, is_verified, is_published. Někteří lidé používají has_ pro booleany vlastnictví (has_mfa_enabled), ale já to zjednodušuji a používám is_ pro všechno.

Přípona _count pro denormalizované čítače. comment_count, follower_count. Je jasné, že jde o cachovaný počet, ne o živý výpočet.

A tady je podstata: konzistence poráží dokonalost. Viděl jsem týmy trávit týdny debatami o tom, jestli to má být email_address nebo email nebo email_addr. Prostě si zvolte jeden vzor a prosazujte ho všude. Nejhorší konvence pojmenování je ta, která je nekonzistentně aplikovaná.

sql
-- Dobře: konzistentní, čitelné, žádná překvapení
CREATE TABLE users (
    id              BIGSERIAL PRIMARY KEY,
    email           TEXT NOT NULL,
    display_name    TEXT NOT NULL,
    password_hash   TEXT NOT NULL,
    is_active       BOOLEAN NOT NULL DEFAULT true,
    is_verified     BOOLEAN NOT NULL DEFAULT false,
    login_count     INTEGER NOT NULL DEFAULT 0,
    last_login_at   TIMESTAMPTZ,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
-- Špatně: nekonzistentní velikost písmen, smíšené konvence, nejednoznačné názvy
CREATE TABLE User (
    UserID          SERIAL PRIMARY KEY,
    EmailAddress    VARCHAR(255),
    name            VARCHAR(100),
    passwd          VARCHAR(255),
    active          BOOLEAN,
    verified        INT,             -- je to 0/1? úroveň ověření?
    lastLogin       TIMESTAMP,       -- camelCase ve snake_case světě
    created         TIMESTAMP        -- vytvořeno co? kdy?
);

Ještě jedna věc: nikdy nepoužívejte rezervovaná slova jako názvy sloupců. type, order, user, group, table — všechny fungují, pokud je uzavřete do uvozovek, ale spálí vás v ORM, query builderech a každém nástroji pro dynamické generování SQL. Používejte kind místo type, sort_order místo order. Vaše budoucí já vám poděkuje.

Standardní sloupce: Co dostane každá tabulka#

Každá tabulka v mých schématech začíná se stejnou kostrou. Žádné výjimky.

sql
CREATE TABLE things (
    id          BIGSERIAL PRIMARY KEY,
    -- ... doménové sloupce ...
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

Debata UUID vs BIGSERIAL#

Toto je jedno z těch rozhodnutí, které generuje mnohem více žáru než světla. Zde je skutečný kompromis:

BIGSERIAL (auto-inkrementující celé číslo):

  • 8 bajtů. Kompaktní. Rychlý na indexování a spojování.
  • Seřazený — můžete třídit podle id a získat pořadí vkládání.
  • Předvídatelný — uživatel může uhádnout jiná ID inkrementací svého.
  • Nefunguje dobře v distribuovaných systémech (vyžaduje koordinaci).

UUID v4 (náhodný):

  • 16 bajtů. Větší indexy, pomalejší spojování (ale zřídka je to úzké hrdlo).
  • Nepředvídatelný — žádný únik informací.
  • Funguje v distribuovaných systémech bez koordinace.
  • Hrozná lokalita indexu — náhodné UUID fragmentují B-tree indexy.

UUID v7 (časově seřazený, RFC 9562):

  • 16 bajtů, ale časově seřazený, takže lokalita B-tree je vynikající.
  • Dostatečně nepředvídatelný pro externí použití.
  • Relativně nový, ale PostgreSQL 17+ má gen_random_uuid() a můžete použít uuid_generate_v7() s rozšířeními.

Můj současný postoj: BIGSERIAL pro interní tabulky, UUID v7 pro cokoliv vystaveného vnějšímu světu. Pokud se ID někdy objeví v URL, v odpovědi API nebo ve webhooku, použijte UUID. Pokud jde o čistou spojovací tabulku, kterou uživatelé nikdy nevidí, BIGSERIAL je v pořádku.

sql
-- Pro API-facing zdroj
CREATE TABLE projects (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name        TEXT NOT NULL,
    owner_id    UUID NOT NULL REFERENCES users(id),
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
-- Pro interní mapovací tabulku
CREATE TABLE project_members (
    id          BIGSERIAL PRIMARY KEY,
    project_id  UUID NOT NULL REFERENCES projects(id),
    user_id     UUID NOT NULL REFERENCES users(id),
    role        TEXT NOT NULL DEFAULT 'member',
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    UNIQUE (project_id, user_id)
);

Časová razítka: Vždy TIMESTAMPTZ#

Používejte TIMESTAMPTZ, ne TIMESTAMP. Verze s "TZ" ukládá hodnotu v UTC a při čtení ji převádí na základě časové zóny relace. Verze bez TZ ukládá cokoliv, co jí dáte, bez kontextu časové zóny — což znamená, že pokud dva servery v různých časových zónách zapisují do stejné tabulky, dostanete tiché poškození dat.

sql
-- Vždy toto
created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
 
-- Nikdy toto
created_at  TIMESTAMP NOT NULL DEFAULT NOW()

Trigger pro updated_at#

PostgreSQL nemá MySQL-ové ON UPDATE CURRENT_TIMESTAMP. Potřebujete trigger:

sql
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
    NEW.updated_at = NOW();
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;
 
-- Aplikujte na každou tabulku, která má updated_at
CREATE TRIGGER set_updated_at
    BEFORE UPDATE ON users
    FOR EACH ROW
    EXECUTE FUNCTION update_updated_at_column();
 
CREATE TRIGGER set_updated_at
    BEFORE UPDATE ON projects
    FOR EACH ROW
    EXECUTE FUNCTION update_updated_at_column();

Ano, potřebujete jeden trigger na tabulku. Ano, je to únavné. Napište si migrační helper, který trigger vytvoří automaticky, když přidáte tabulku. Stojí to za to, protože alternativou je pamatovat si nastavit updated_at = NOW() v každém UPDATE dotazu napříč celou aplikací — a zapomenete.

Normalizace: Kdy porušit pravidla#

Každý kurz informatiky učí normalizaci až do 3NF (třetí normální forma). Pravidla jsou:

  • 1NF: Každý sloupec obsahuje jednu atomickou hodnotu. Žádná pole, žádné čárkami oddělené seznamy.
  • 2NF: Každý neklíčový sloupec závisí na celém primárním klíči (relevantní pro složené klíče).
  • 3NF: Žádné tranzitivní závislosti. Pokud sloupec A určuje sloupec B a B určuje C, pak C by neměl být ve stejné tabulce jako A.

V praxi je 3NF ideální bod pro transakční tabulky. Měli byste začít tam a odchylovat se pouze tehdy, když máte konkrétní, měřitelný důvod.

Zde je správně normalizovaný objednávkový systém:

sql
CREATE TABLE customers (
    id              BIGSERIAL PRIMARY KEY,
    email           TEXT NOT NULL UNIQUE,
    display_name    TEXT NOT NULL,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
CREATE TABLE products (
    id              BIGSERIAL PRIMARY KEY,
    sku             TEXT NOT NULL UNIQUE,
    name            TEXT NOT NULL,
    price_cents     INTEGER NOT NULL,
    is_active       BOOLEAN NOT NULL DEFAULT true,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
CREATE TABLE orders (
    id              BIGSERIAL PRIMARY KEY,
    customer_id     BIGINT NOT NULL REFERENCES customers(id),
    status          TEXT NOT NULL DEFAULT 'pending'
                    CHECK (status IN ('pending', 'confirmed', 'shipped', 'delivered', 'cancelled')),
    total_cents     INTEGER NOT NULL,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
CREATE TABLE order_items (
    id              BIGSERIAL PRIMARY KEY,
    order_id        BIGINT NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
    product_id      BIGINT NOT NULL REFERENCES products(id),
    quantity        INTEGER NOT NULL CHECK (quantity > 0),
    unit_price_cents INTEGER NOT NULL,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

Všimněte si unit_price_cents na order_items. Toto je záměrná denormalizace. Snímkujeme cenu v okamžiku objednávky, protože cena produktu se může později změnit. Toto je jeden z nejčastějších a nejsprávnějších důvodů pro denormalizaci.

Kdy je denormalizace správnou volbou#

Reportovací tabulky. Pokud váš analytický dashboard potřebuje spojit 8 tabulek pro vykreslení, vytvořte denormalizovanou reportovací tabulku a naplňujte ji úlohou na pozadí. Vaše transakční schéma zůstane čisté a vaše reportovací dotazy rychlé.

sql
-- Denormalizovaná reportovací tabulka, naplněná cron jobem
CREATE TABLE order_reports (
    id                  BIGSERIAL PRIMARY KEY,
    order_id            BIGINT NOT NULL,
    customer_email      TEXT NOT NULL,
    customer_name       TEXT NOT NULL,
    product_names       TEXT[] NOT NULL,        -- pole je zde v pořádku
    item_count          INTEGER NOT NULL,
    total_cents         INTEGER NOT NULL,
    order_status        TEXT NOT NULL,
    ordered_at          TIMESTAMPTZ NOT NULL,
    report_generated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

Materializované pohledy. PostgreSQL-ový MATERIALIZED VIEW je podceňovaný. Je to denormalizovaný snímek, který obnovujete na vyžádání. Perfektní pro dashboardy.

sql
CREATE MATERIALIZED VIEW monthly_revenue AS
SELECT
    DATE_TRUNC('month', o.created_at) AS month,
    COUNT(DISTINCT o.id) AS order_count,
    SUM(o.total_cents) AS revenue_cents,
    COUNT(DISTINCT o.customer_id) AS unique_customers
FROM orders o
WHERE o.status != 'cancelled'
GROUP BY DATE_TRUNC('month', o.created_at)
ORDER BY month DESC;
 
-- Obnovujte každou noc
REFRESH MATERIALIZED VIEW CONCURRENTLY monthly_revenue;

JSON sloupce pro dynamické atributy. Když různé řádky potřebují různá pole — varianty produktů, odeslané formuláře, uživatelské preference — JSONB sloupec je často lepší než noční můra EAV (Entity-Attribute-Value) tabulky. Více o tom později.

Pravidlo, které nikdy neporušuji#

Nikdy nedenormalizujte vaše zdrojové tabulky. Denormalizujte kopie, snímky, reporty a cache. Kanonická data zůstávají normalizovaná. Když se denormalizovaná kopie stane zastaralou nebo poškodí (a stane se), přestavíte ji z normalizovaného zdroje.

Cizí klíče a omezení: Nejlepší kód, který nikdy nenapíšete#

Slyšel jsem každou výmluvu pro vynechání cizích klíčů. "Zpomalují zápisy." "Vynucujeme to v aplikaci." "Potřebujeme flexibilitu."

Všechny jsou špatné.

Cizí klíče jsou to nejúčinnější, co můžete do schématu přidat. Zabraňují celým kategoriím chyb, které žádný množství aplikačního kódu nemůže zachytit — race conditions, částečné selhání, osiřelé řádky z neúspěšných transakcí. Cizí klíč je záruka od samotného databázového enginu, vynucená na úrovni úložiště. Hook beforeDelete vašeho ORM je jen doporučení.

sql
-- Vždy dělejte toto
ALTER TABLE orders
    ADD CONSTRAINT fk_orders_customer
    FOREIGN KEY (customer_id) REFERENCES customers(id);
 
-- Alternativa "vyřešíme to v kódu":
-- Naděje. Naděje není strategie.

Strategie ON DELETE#

Tady to začíná být nuancované. Co se stane, když smažete nadřazený řádek?

RESTRICT (výchozí): Smazání selže, pokud existují podřízené řádky. Používejte toto pro většinu vztahů. Nemůžete smazat zákazníka, který má objednávky — to je obchodní logika zakódovaná ve schématu.

sql
-- Zákazník nemůže být smazán, dokud má objednávky
ALTER TABLE orders
    ADD CONSTRAINT fk_orders_customer
    FOREIGN KEY (customer_id) REFERENCES customers(id)
    ON DELETE RESTRICT;

CASCADE: Podřízené řádky jsou automaticky smazány. Používejte toto střídmě a záměrně. Dobré pro vztahy "součást", kde podřízený prvek nemá smysl bez nadřazeného.

sql
-- Smazání objednávky smaže její položky — samy o sobě nemají smysl
ALTER TABLE order_items
    ADD CONSTRAINT fk_order_items_order
    FOREIGN KEY (order_id) REFERENCES orders(id)
    ON DELETE CASCADE;
 
-- Smazání projektu smaže jeho členství
ALTER TABLE project_members
    ADD CONSTRAINT fk_project_members_project
    FOREIGN KEY (project_id) REFERENCES projects(id)
    ON DELETE CASCADE;

SET NULL: Sloupec cizího klíče je nastaven na NULL. Používejte toto, když je vztah nepovinný a podřízený řádek má stále smysl sám o sobě.

sql
-- Pokud manažer odejde, jeho podřízení stále existují — jen bez přiřazení
ALTER TABLE employees
    ADD CONSTRAINT fk_employees_manager
    FOREIGN KEY (manager_id) REFERENCES employees(id)
    ON DELETE SET NULL;

Mé pravidlo: výchozí je RESTRICT, použijte CASCADE pro kompoziční vztahy, použijte SET NULL pro nepovinné asociace. Pokud si nejste jisti, RESTRICT je vždy bezpečná volba — je jednodušší uvolnit omezení než obnovit smazaná data.

Check Constraints: Levné pojištění#

Check constraints nestojí téměř nic při zápisu a navždy zabraňují odpadkovým datům vstoupit do vašeho systému:

sql
CREATE TABLE products (
    id              BIGSERIAL PRIMARY KEY,
    name            TEXT NOT NULL CHECK (LENGTH(name) > 0),
    price_cents     INTEGER NOT NULL CHECK (price_cents >= 0),
    discount_pct    NUMERIC(5,2) CHECK (discount_pct BETWEEN 0 AND 100),
    weight_kg       NUMERIC(10,3) CHECK (weight_kg > 0),
    status          TEXT NOT NULL CHECK (status IN ('draft', 'active', 'archived')),
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

Každý check constraint, který přidáte, je jedna chyba validace méně, kterou budete kdy muset ladit v produkci. Databáze je poslední linie obrany. Používejte ji.

Unikátní omezení a parciální unikátní indexy#

Unikátní omezení jsou přímočará pro jednoduché případy:

sql
ALTER TABLE users ADD CONSTRAINT uq_users_email UNIQUE (email);

Ale stávají se zajímavými s parciálními indexy — unikátními omezeními, která se vztahují pouze na určité řádky:

sql
-- Pouze jedno aktivní předplatné na uživatele (ale mohou mít mnoho zrušených)
CREATE UNIQUE INDEX uq_active_subscription
    ON subscriptions (user_id)
    WHERE status = 'active';
 
-- Pouze jedna primární adresa na uživatele
CREATE UNIQUE INDEX uq_primary_address
    ON addresses (user_id)
    WHERE is_primary = true;

Toto je jedna z klíčových funkcí PostgreSQL. Používejte ji.

Soft Delete: Vzor, který všichni milují nenávidět#

Soft delete je v konceptu jednoduchý: místo DELETE FROM users WHERE id = 42 uděláte UPDATE users SET deleted_at = NOW() WHERE id = 42. Řádek zůstane v databázi, ale je odfiltrován z normálních dotazů.

sql
CREATE TABLE users (
    id          BIGSERIAL PRIMARY KEY,
    email       TEXT NOT NULL,
    display_name TEXT NOT NULL,
    is_active   BOOLEAN NOT NULL DEFAULT true,
    deleted_at  TIMESTAMPTZ,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
-- Parciální index: efektivní filtrování smazaných řádků
CREATE INDEX idx_users_active ON users (email) WHERE deleted_at IS NULL;

Každý dotaz ve vaší aplikaci nyní potřebuje WHERE deleted_at IS NULL. Každý. Jediný. Dotaz. ORM s tím pomáhají (Prisma má middleware, Drizzle má .where(isNull(deletedAt))), ale je to daň na každé operaci čtení. Vynechejte ji jednou a ukazujete "smazaná" data uživatelům.

Problém s unikátním omezením#

Tady se soft delete stává ošklivým. Pokud máte UNIQUE (email) a uživatel soft-smaže svůj účet, jeho email je stále v tabulce. Nemůže se znovu zaregistrovat se stejným emailem. Nový uživatel s tímto emailem se také nemůže zaregistrovat.

Řešením je parciální unikátní index:

sql
-- Email musí být unikátní, ale pouze mezi nesmazanými uživateli
CREATE UNIQUE INDEX uq_users_email_active
    ON users (email)
    WHERE deleted_at IS NULL;

Toto funguje, ale nyní si musíte pamatovat tento vzor pro každý unikátní sloupec na každé soft-deletovatelné tabulce. Je to zvládnutelné s disciplínou, ale je to složitost, kterou jste se rozhodli přijmout.

Problém s cizím klíčem#

Soft delete špatně interaguje s cizími klíči. Pokud orders.user_id odkazuje na users.id s ON DELETE RESTRICT a soft-smažete uživatele... nic se nestane. FK se nespustí, protože jste řádek ve skutečnosti nesmazali. Uživatel je "pryč" z pohledu aplikace, ale stále velmi přítomen v databázi.

To znamená, že váš aplikační kód musí zvládnout případ, kdy je odkazovaná entita soft-smazaná. Každý join, každé vyhledávání, pokaždé když následujete cizí klíč — musíte zkontrolovat deleted_at IS NULL i na odkazované tabulce. Nebo to neuděláte a vaše aplikace zobrazí "Objednávka od [smazaný uživatel]", což je buď chyba, nebo funkce, záleží na tom, koho se zeptáte.

Můj přístup#

Soft delete používám pouze pro entity viditelné uživatelem, kde je obnova obchodním požadavkem — uživatelské účty, projekty, dokumenty. Věci, kde pracovník podpory možná potřebuje obnovit smazání. Pro všechno ostatní používám hard delete s audit trailem (o tom více dále).

sql
-- Soft delete: viditelný uživatelem, obnovitelný
CREATE TABLE documents (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    title       TEXT NOT NULL,
    content     TEXT NOT NULL,
    owner_id    UUID NOT NULL REFERENCES users(id),
    deleted_at  TIMESTAMPTZ,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
CREATE INDEX idx_documents_active ON documents (owner_id) WHERE deleted_at IS NULL;
 
-- Hard delete: interní, neobnovitelný z UI (ale auditovaný)
CREATE TABLE api_keys (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id     UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    key_hash    TEXT NOT NULL,
    last_used_at TIMESTAMPTZ,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Když je smazán, je pryč. audit_log zaznamenává, že existoval.

Audit trail: Vědět, co se změnilo a kdo to udělal#

Každá netriviální aplikace v určitém okamžiku potřebuje odpověď na "co se stalo s tímto záznamem?". Audit trail je způsob, jak tuto odpověď poskytnout bez prohrabávání se aplikačními logy.

Vzor oddělené audit tabulky#

Nejjednodušší přístup: jedna audit_log tabulka, která zaznamenává každou změnu.

sql
CREATE TABLE audit_log (
    id              BIGSERIAL PRIMARY KEY,
    table_name      TEXT NOT NULL,
    record_id       TEXT NOT NULL,          -- text pro zvládnutí jak UUID, tak BIGINT PK
    operation       TEXT NOT NULL CHECK (operation IN ('INSERT', 'UPDATE', 'DELETE')),
    old_values      JSONB,                  -- NULL pro INSERT
    new_values      JSONB,                  -- NULL pro DELETE
    changed_fields  TEXT[],                 -- které sloupce se změnily (pro UPDATE)
    changed_by      UUID REFERENCES users(id),
    changed_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    ip_address      INET,
    user_agent      TEXT
);
 
CREATE INDEX idx_audit_log_table_record ON audit_log (table_name, record_id);
CREATE INDEX idx_audit_log_changed_by ON audit_log (changed_by);
CREATE INDEX idx_audit_log_changed_at ON audit_log (changed_at);

Výzvou je její naplňování. Můžete to udělat v aplikačním kódu (explicitní, ale snadno se zapomene) nebo pomocí triggerů (automatické, ale obtížnější předat kontext jako changed_by).

Přístup založený na triggerech#

Triggery zachycují každou změnu automaticky, i z čistého SQL nebo operací databázového administrátora:

sql
CREATE OR REPLACE FUNCTION audit_trigger_function()
RETURNS TRIGGER AS $$
DECLARE
    old_data JSONB;
    new_data JSONB;
    changed  TEXT[];
    col      TEXT;
BEGIN
    IF TG_OP = 'DELETE' THEN
        old_data := to_jsonb(OLD);
        INSERT INTO audit_log (table_name, record_id, operation, old_values, changed_at)
        VALUES (TG_TABLE_NAME, OLD.id::TEXT, 'DELETE', old_data, NOW());
        RETURN OLD;
    ELSIF TG_OP = 'UPDATE' THEN
        old_data := to_jsonb(OLD);
        new_data := to_jsonb(NEW);
        -- Najít změněná pole
        FOR col IN SELECT key FROM jsonb_each(new_data)
        LOOP
            IF old_data->col IS DISTINCT FROM new_data->col THEN
                changed := array_append(changed, col);
            END IF;
        END LOOP;
        -- Logovat pouze pokud se něco skutečně změnilo
        IF array_length(changed, 1) > 0 THEN
            INSERT INTO audit_log (table_name, record_id, operation, old_values, new_values, changed_fields, changed_at)
            VALUES (TG_TABLE_NAME, NEW.id::TEXT, 'UPDATE', old_data, new_data, changed, NOW());
        END IF;
        RETURN NEW;
    ELSIF TG_OP = 'INSERT' THEN
        new_data := to_jsonb(NEW);
        INSERT INTO audit_log (table_name, record_id, operation, new_values, changed_at)
        VALUES (TG_TABLE_NAME, NEW.id::TEXT, 'INSERT', new_data, NOW());
        RETURN NEW;
    END IF;
    RETURN NULL;
END;
$$ LANGUAGE plpgsql;
 
-- Aplikujte na tabulky, které chcete auditovat
CREATE TRIGGER audit_users
    AFTER INSERT OR UPDATE OR DELETE ON users
    FOR EACH ROW EXECUTE FUNCTION audit_trigger_function();
 
CREATE TRIGGER audit_orders
    AFTER INSERT OR UPDATE OR DELETE ON orders
    FOR EACH ROW EXECUTE FUNCTION audit_trigger_function();

Nevýhoda: triggery neví, který uživatel aplikace provedl změnu. Můžete to obejít pomocí session proměnných:

sql
-- Ve vaší aplikaci, před dotazem:
SET LOCAL app.current_user_id = 'uuid-of-current-user';
 
-- Ve funkci triggeru:
changed_by_id := current_setting('app.current_user_id', true)::UUID;

Toto funguje, ale působí křehce. V praxi používám hybridní přístup: triggery pro zachycení dat a aplikační kód pro nastavení kontextu relace.

Vzor tabulky historie#

Pro tabulky, kde potřebujete úplnou historii verzí (nejen "co se změnilo", ale "jaký byl stav v čase T"), je vyhrazená tabulka historie čistší:

sql
CREATE TABLE documents (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    title           TEXT NOT NULL,
    content         TEXT NOT NULL,
    version         INTEGER NOT NULL DEFAULT 1,
    owner_id        UUID NOT NULL REFERENCES users(id),
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
CREATE TABLE document_history (
    id              BIGSERIAL PRIMARY KEY,
    document_id     UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
    title           TEXT NOT NULL,
    content         TEXT NOT NULL,
    version         INTEGER NOT NULL,
    changed_by      UUID REFERENCES users(id),
    changed_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    UNIQUE (document_id, version)
);

Před každou aktualizací documents zkopírujte aktuální stav do document_history a inkrementujte verzi. Nyní můžete rekonstruovat dokument v libovolném bodě v čase, zobrazit rozdíly mezi verzemi a dokonce obnovit staré verze.

Kompromis je úložiště. Pokud je váš sloupec content velký a mění se často, tabulka historie může rychle růst. Pro většinu aplikací je to v pořádku — úložiště je levné a můžete archivovat staré verze do studeného úložiště, pokud je to potřeba.

Multi-tenancy: Tři přístupy, vyberte si svou bolest#

Multi-tenancy je jedna z věcí, která se snadno přidá na začátku a téměř nemožně přidá později. Pokud existuje jakákoliv šance, že vaše aplikace bude obsluhovat více organizací, zabudujte to od prvního dne.

Na úrovni řádků: tenant_id na každé tabulce#

Nejčastější přístup. Každá tabulka má sloupec tenant_id a každý dotaz filtruje podle něj.

sql
CREATE TABLE tenants (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name        TEXT NOT NULL,
    slug        TEXT NOT NULL UNIQUE,
    plan        TEXT NOT NULL DEFAULT 'free' CHECK (plan IN ('free', 'pro', 'enterprise')),
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
CREATE TABLE projects (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id   UUID NOT NULL REFERENCES tenants(id),
    name        TEXT NOT NULL,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
CREATE TABLE tasks (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id   UUID NOT NULL REFERENCES tenants(id),
    project_id  UUID NOT NULL REFERENCES projects(id),
    title       TEXT NOT NULL,
    status      TEXT NOT NULL DEFAULT 'todo',
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
-- Každý index by měl obsahovat tenant_id pro výkon dotazů
CREATE INDEX idx_projects_tenant ON projects (tenant_id);
CREATE INDEX idx_tasks_tenant_project ON tasks (tenant_id, project_id);

Rizikem je únik dat — jedno vynechané WHERE tenant_id = ... a ukazujete data Tenanta A Tenantu B. PostgreSQL-ové Row-Level Security (RLS) eliminuje tuto třídu chyb:

sql
-- Povolit RLS na tabulce
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
 
-- Vytvořit politiku na základě session proměnné
CREATE POLICY tenant_isolation ON projects
    USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
 
-- Vynutit RLS i pro vlastníky tabulky
ALTER TABLE projects FORCE ROW LEVEL SECURITY;

Nyní, i když váš aplikační kód zapomene klauzuli WHERE tenant_id = ..., PostgreSQL ji přidá automaticky. Toto je obrana do hloubky a je to jeden z nejsilnějších argumentů pro PostgreSQL v multi-tenant systémech.

sql
-- V middlewaru připojení vaší aplikace:
SET LOCAL app.current_tenant_id = 'tenant-uuid-here';
 
-- Nyní jsou všechny dotazy na tabulky s RLS automaticky filtrovány
SELECT * FROM projects;
-- PostgreSQL interně přidá: WHERE tenant_id = 'tenant-uuid-here'

Výhody: Jedna databáze, jednoduchý provoz, efektivní využití zdrojů, snadné cross-tenant dotazy pro administrátora. Nevýhody: Vyžaduje disciplínu (nebo RLS), každý dotaz se dotýká tenant_id, obtížnější dát tenantům vlastní zálohu/obnovu.

Schéma na tenanta#

Každý tenant dostane vlastní PostgreSQL schéma. Všechna schémata sdílejí stejnou databázi, ale tabulky jsou izolované jmenným prostorem.

sql
-- Vytvořit schéma pro každého tenanta
CREATE SCHEMA tenant_acme;
CREATE SCHEMA tenant_globex;
 
-- Tabulky žijí ve schématu tenanta
CREATE TABLE tenant_acme.projects (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name        TEXT NOT NULL,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
-- Nastavit search_path pro aktuálního tenanta
SET search_path TO tenant_acme, public;
 
-- Nyní nekvalifikované dotazy míří do schématu tenanta
SELECT * FROM projects;  -- dotazuje tenant_acme.projects

Výhody: Silná izolace, žádné riziko úniku dat mezi tenanty, snadná záloha/obnova per tenant, tenanti mohou mít varianty schématu. Nevýhody: Složitost migrace schématu (musíte migrovat N schémat), správa connection poolu, PostgreSQL má praktické limity kolem ~10 000 schémat.

Databáze na tenanta#

Každý tenant dostane vlastní databázi. Maximální izolace.

Výhody: Kompletní izolace, nezávislé škálování, snadná záloha/obnova, velcí tenanti mohou být na dedikovaném hardware. Nevýhody: Noční můra správy připojení, cross-tenant dotazy nemožné, migrace musí běžet N-krát, značná provozní režie.

Který zvolit?#

Pro většinu SaaS aplikací: začněte s row-level + RLS. Je to nejjednodušší na provozování a RLS vám dá dostatečně silnou izolaci pro naprostou většinu případů použití. Přejděte na schéma na tenanta pouze pokud máte smluvní požadavky na izolaci (enterprise zákazníci, regulovaná odvětví). Databáze na tenanta je pro případy, kdy musíte absolutně garantovat fyzickou izolaci — a i tehdy zvažte spravované databáze, kde je provozní zátěž vyřízena za vás.

JSON/JSONB sloupce: Únikový východ#

PostgreSQL-ový JSONB je pozoruhodný. Dává vám flexibilitu dokumentové databáze uvnitř relačního systému. Ale jako každý mocný nástroj, snadno se s ním přežene.

Kdy je JSONB správná volba#

Dynamické uživatelsky definované atributy. E-commerce platforma, kde každá kategorie produktů má různé atributy — boty mají size a color, elektronika má voltage a wattage. Místo EAV tabulky nebo sloupce pro každý možný atribut:

sql
CREATE TABLE products (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name            TEXT NOT NULL,
    category_id     UUID NOT NULL REFERENCES categories(id),
    price_cents     INTEGER NOT NULL CHECK (price_cents >= 0),
    attributes      JSONB NOT NULL DEFAULT '{}',
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
-- Příklad dat:
-- Bota: {"size": "10", "color": "black", "material": "leather"}
-- Notebook: {"ram_gb": 16, "storage_gb": 512, "screen_inches": 14.0}

Nastavení aplikace a konfigurace. Uživatelské preference, feature flagy, nastavení notifikací — věci, které často mění tvar a nepotřebují relační integritu.

sql
CREATE TABLE user_settings (
    user_id     UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
    preferences JSONB NOT NULL DEFAULT '{
        "theme": "system",
        "notifications": {"email": true, "push": false},
        "locale": "en",
        "timezone": "UTC"
    }',
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

Payloady webhooků, odpovědi API, data událostí. Cokoliv, kde je schéma řízeno externím systémem a může se změnit bez upozornění.

GIN indexy na JSONB#

Bez indexů vyžaduje dotazování uvnitř JSONB úplný sken tabulky. GIN indexy to zrychlí:

sql
-- Indexovat celý JSONB sloupec (dobré pro @> containment dotazy)
CREATE INDEX idx_products_attributes ON products USING GIN (attributes);
 
-- Dotaz: najít všechny produkty s color = "black"
SELECT * FROM products
WHERE attributes @> '{"color": "black"}';
 
-- Dotaz: najít produkty s libovolným z těchto atributů
SELECT * FROM products
WHERE attributes ?| ARRAY['color', 'size'];

Pro konkrétní cesty, na které se často dotazujete, je cílený index efektivnější:

sql
-- Indexovat konkrétní cestu
CREATE INDEX idx_products_color ON products ((attributes->>'color'));
 
-- Nyní je to běžné B-tree vyhledávání
SELECT * FROM products
WHERE attributes->>'color' = 'black';

Past migrace#

Tady vás JSONB kousne: nemůžete snadno přidat NOT NULL omezení nebo výchozí hodnoty pro pole uvnitř JSON. Pokud přidáte nové požadované pole do atributů produktu, musíte zpětně naplnit každý existující řádek. S běžným sloupcem to migrace vyřeší atomicky. S JSONB píšete UPDATE, který se dotkne každého řádku, a doufáte, že váš aplikační kód zvládne chybějící pole elegantně, dokud zpětné naplnění neskončí.

sql
-- Přidání nového sloupce: čisté, atomické, jeden příkaz
ALTER TABLE products ADD COLUMN weight_kg NUMERIC(10,3);
 
-- Přidání nového JSONB pole: chaotické, vyžaduje zpětné naplnění
UPDATE products
SET attributes = attributes || '{"weight_kg": null}'
WHERE NOT (attributes ? 'weight_kg');
-- Plus aktualizovat aplikační kód pro zvládnutí chybějícího weight_kg
-- Plus aktualizovat validační logiku
-- Plus aktualizovat každou odpověď API, která obsahuje atributy

Mé pravidlo: pokud se na JSONB pole dotazujete ve WHERE klauzuli více než dvakrát týdně, mělo by to pravděpodobně být sloupec. JSONB je skvělý únikový východ. Je to hrozný výchozí stav.

Migrace schématu: Nerozbijte produkci ve 3 ráno#

Migrace schématu jsou místem, kde teorie potkává realitu. Vaše schéma vypadá skvěle na papíře, ale nyní potřebujete změnit tabulku s 50 miliony řádků během pracovní doby bez výpadku.

Nástroje pro migraci#

Použil jsem většinu z nich. Stručné hodnocení:

Drizzle (TypeScript): Můj současný favorit. Schéma jako kód, typově bezpečné dotazy generované ze schématu, čisté migrační SQL. Příkaz push pro vývoj je rychlý.

Prisma (TypeScript): Skvělý DX pro jednoduchá schémata. Zápasí s pokročilými funkcemi PostgreSQL (parciální indexy, vlastní typy, RLS). Migrační engine může dělat překvapivá rozhodnutí.

Flyway (Java/CLI): Spolehlivý jako skála, prověřený v bitvách, SQL-first. Pokud píšete čisté SQL migrace, Flyway je spolehlivě sleduje. Žádná magie, žádná překvapení.

golang-migrate (Go/CLI): Podobný Flyway, ale lehčí. Skvělý pro Go projekty nebo když chcete prostě jednoduchý up/down migrační runner.

Problém nulového výpadku#

Nejnebezpečnější změny schématu jsou ty, které zamknou tabulku. V PostgreSQL ALTER TABLE ... ADD COLUMN s výchozí hodnotou dříve zamykal celou tabulku po dobu přepisu. Od PostgreSQL 11 jsou jednoduché výchozí hodnoty (konstanty) pouze metadatové a okamžité. Ale jiné operace stále zamykají:

sql
-- BEZPEČNÉ: pouze metadata, okamžité (PostgreSQL 11+)
ALTER TABLE users ADD COLUMN bio TEXT;
ALTER TABLE users ADD COLUMN is_premium BOOLEAN NOT NULL DEFAULT false;
 
-- NEBEZPEČNÉ: přepisuje celou tabulku, úplný zámek tabulky
ALTER TABLE users ALTER COLUMN email TYPE VARCHAR(500);
ALTER TABLE users ADD COLUMN score INTEGER NOT NULL DEFAULT compute_score();
 
-- NEBEZPEČNÉ: skenuje celou tabulku pro validaci
ALTER TABLE orders ADD CONSTRAINT check_total CHECK (total_cents >= 0);

Bezpečné přidání NOT NULL sloupců#

Nemůžete prostě přidat NOT NULL na existující tabulku s daty — selže to, protože existující řádky nemají hodnotu. Naivní přístup:

sql
-- Toto zamkne tabulku a přepíše ji. Nedělejte to na velké tabulce.
ALTER TABLE users ADD COLUMN avatar_url TEXT NOT NULL DEFAULT 'https://example.com/default.png';

Od PostgreSQL 11 je toto ve skutečnosti bezpečné pro konstantní výchozí hodnoty — je to pouze metadatové. Ale pokud vaše výchozí hodnota je funkce nebo potřebujete zpětné naplnění s vypočtenými hodnotami, použijte vzor expand-contract.

Vzor Expand-Contract#

Toto je zlatý standard pro změny schématu s nulovým výpadkem. Tři fáze:

Fáze 1: Expand — Přidejte nový sloupec jako nullable. Nasaďte aplikační kód, který zapisuje do starého i nového sloupce.

sql
-- Migrace 1: Přidat nullable sloupec
ALTER TABLE users ADD COLUMN normalized_email TEXT;

Fáze 2: Migrace — Zpětně naplňte existující řádky v dávkách. Vaše aplikace již zapisuje do nového sloupce pro nová data.

sql
-- Migrace 2: Zpětné naplnění v dávkách (nedělejte to v jednom příkazu pro velké tabulky)
UPDATE users SET normalized_email = LOWER(TRIM(email))
WHERE normalized_email IS NULL
LIMIT 10000;
-- Opakujte dokud nejsou všechny řádky naplněny

Fáze 3: Contract — Jakmile jsou všechny řádky naplněny, přidejte NOT NULL omezení a odstraňte starý sloupec (pokud je to relevantní).

sql
-- Migrace 3: Přidat omezení (použijte NOT VALID pro vyhnutí se úplnému skenu tabulky, pak validujte zvlášť)
ALTER TABLE users ADD CONSTRAINT users_normalized_email_not_null
    CHECK (normalized_email IS NOT NULL) NOT VALID;
 
-- Migrace 4: Validovat omezení (vezme ShareUpdateExclusiveLock, ne AccessExclusiveLock)
ALTER TABLE users VALIDATE CONSTRAINT users_normalized_email_not_null;

Trik NOT VALID + VALIDATE CONSTRAINT je klíčový. Přidání check constraint normálně skenuje celou tabulku s těžkým zámkem. NOT VALID přidá omezení bez skenování (platí pouze pro nové zápisy) a VALIDATE CONSTRAINT skenuje s lehčím zámkem, který neblokuje čtení ani zápisy.

Vytváření indexů#

Vytváření indexů na velkých tabulkách ve výchozím nastavení blokuje zápisy. Vždy používejte CONCURRENTLY:

sql
-- BLOKUJE ZÁPISY: nedělejte toto na živé tabulce
CREATE INDEX idx_users_email ON users (email);
 
-- NEBLOKUJÍCÍ: použijte místo toho toto
CREATE INDEX CONCURRENTLY idx_users_email ON users (email);

CONCURRENTLY trvá déle a nemůže běžet uvnitř transakce, ale nezamyká tabulku. Kompromis vždy stojí za to v produkci.

Rozhodnutí, kterých jsem litoval#

Každé schéma má své lítosti. Zde jsou moje.

Použití SERIAL místo UUID pro externí ID#

Na začátku projektu jsem používal SERIAL primární klíče a vystavoval je přímo v URL: /users/42, /orders/1337. Toto prozrazovalo informace (konkurenti mohli odhadnout náš počet uživatelů), paginace byla uhodnutelná a rozbilo se to, když jsme potřebovali sloučit databáze ze dvou regionů. Přechod na UUID pro externě viditelná ID vyžadoval několikaměsíční migraci.

Poučení: použijte UUID pro cokoliv vystavené mimo vaši databázi. Používejte SERIAL/BIGSERIAL pro interní spojovací tabulky, pokud chcete, ale nikdy nenechte auto-inkrementující celé číslo objevit se v URL.

Žádná omezení ("Přidáme je později")#

Spustili jsme tabulku bez check constraints, protože "jdeme rychle a přidáme je později." Do dvou týdnů data obsahovala záporné ceny, prázdná jména a hodnotu order_status "oof", kterou někdo napsal při testování a nikdy neuklidil.

Přidání omezení zpětně vyžadovalo:

  1. Najít všechna neplatná data
  2. Rozhodnout, co s nimi dělat (opravit, smazat, nebo ponechat jako výjimku)
  3. Napsat migraci, která zpětně opraví data A přidá omezení

Toto trvalo déle než přidání omezení prvního dne. Začněte s omezeními. Uvolněte je, pokud musíte. Nikdy obráceně.

Čárkami oddělené řetězce místo polí nebo spojovacích tabulek#

Jednou jsem ukládal tagy jako čárkami oddělený řetězec: "javascript,react,nextjs". Dotazování bylo noční můrou:

sql
-- Takto se dotazujete na čárkami oddělené hodnoty. Nedělejte to.
SELECT * FROM posts WHERE tags LIKE '%react%';
-- Toto také odpovídá "react-native" a "preact"
 
-- Co jsem měl udělat:
CREATE TABLE post_tags (
    post_id     UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
    tag_id      UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
    PRIMARY KEY (post_id, tag_id)
);

Nebo minimálně použijte nativní datový typ pole PostgreSQL:

sql
CREATE TABLE posts (
    id      UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    title   TEXT NOT NULL,
    tags    TEXT[] NOT NULL DEFAULT '{}',
    -- ...
);
 
CREATE INDEX idx_posts_tags ON posts USING GIN (tags);
 
-- Čisté dotazy
SELECT * FROM posts WHERE 'react' = ANY(tags);
SELECT * FROM posts WHERE tags @> ARRAY['react', 'nextjs'];

Pole jsou přijatelná pro jednoduché seznamy, které nepotřebují vlastní atributy. Ve chvíli, kdy potřebujete metadata o vztahu (jako "kdo přidal tento tag" nebo "kdy byl přidán"), potřebujete spojovací tabulku.

Použití "type" jako názvu sloupce#

sql
-- Tehdy to vypadalo dobře
CREATE TABLE notifications (
    id      BIGSERIAL PRIMARY KEY,
    type    TEXT NOT NULL,  -- "email", "sms", "push"
    -- ...
);
 
-- Pak se to pokusíte dotazovat v doslova jakémkoliv ORM:
-- notification.type  ← koliduje s typovým systémem každého jazyka
-- "type" je rezervované slovo ve většině SQL dialektů
-- Skončíte tak, že ho všude uvozujete: SELECT "type" FROM notifications

Používejte kind, category nebo notification_type. Cokoliv kromě type. Podobně se vyhněte order (použijte sort_order nebo position), user (použijte account nebo to prefixujte) a group (použijte team nebo group_name).

Nepřidání created_at od začátku#

Jedna tabulka nedostala created_at, protože "to nepotřebujeme." O tři měsíce později jsme potřebovali ladit problém a neměli jsme tušení, kdy byly záznamy vytvořeny. Přidání zpětně znamenalo, že všechny existující řádky dostaly stejné časové razítko (časové razítko migrace), čímž se historická data stala nepoužitelnými.

Každá tabulka dostane created_at. Žádné výjimky. Cena je jeden sloupec. Cena jeho absence je neznámá, dokud ho nepotřebujete.

Vložení obchodní logiky do databázových pohledů#

Jednou jsem vytvořil řetězec pohledů — active_users filtroval users, premium_active_users filtroval active_users a reportovací pohled spojoval všechny. Fungovalo to skvěle, dokud někdo nezměnil tabulku users a všechny tři pohledy se tiše rozbily. Plánovač dotazů se také potýkal s optimalizací přes několik vrstev pohledů.

Pohledy jsou skvělé pro pohodlí a read-only přístupové vzory. Jsou hrozné jako místo pro kódování obchodní logiky, která se mění. Držte obchodní pravidla v aplikačním kódu, kde jsou verzovaná, testovaná a nasaditelná. Používejte pohledy pro reportovací zkratky, ne jako architektonické stavební bloky.

Spojení všeho dohromady#

Zde je to, jak vypadá dobře navržené schéma, když tyto vzory zkombinujete. Zjednodušený systém řízení projektů:

sql
-- Rozšíření
CREATE EXTENSION IF NOT EXISTS "pgcrypto";  -- pro gen_random_uuid()
 
-- Funkce triggeru updated_at (znovupoužitelná)
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
    NEW.updated_at = NOW();
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;
 
-- Tenanti
CREATE TABLE tenants (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name            TEXT NOT NULL CHECK (LENGTH(name) > 0),
    slug            TEXT NOT NULL UNIQUE CHECK (slug ~ '^[a-z0-9-]+$'),
    plan            TEXT NOT NULL DEFAULT 'free'
                    CHECK (plan IN ('free', 'pro', 'enterprise')),
    settings        JSONB NOT NULL DEFAULT '{}',
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
CREATE TRIGGER set_updated_at BEFORE UPDATE ON tenants
    FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
 
-- Uživatelé
CREATE TABLE users (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id       UUID NOT NULL REFERENCES tenants(id),
    email           TEXT NOT NULL,
    display_name    TEXT NOT NULL CHECK (LENGTH(display_name) > 0),
    password_hash   TEXT NOT NULL,
    is_active       BOOLEAN NOT NULL DEFAULT true,
    is_verified     BOOLEAN NOT NULL DEFAULT false,
    last_login_at   TIMESTAMPTZ,
    deleted_at      TIMESTAMPTZ,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
CREATE UNIQUE INDEX uq_users_email_per_tenant
    ON users (tenant_id, email)
    WHERE deleted_at IS NULL;
 
CREATE INDEX idx_users_tenant ON users (tenant_id) WHERE deleted_at IS NULL;
 
CREATE TRIGGER set_updated_at BEFORE UPDATE ON users
    FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
 
-- Povolit RLS
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON users
    USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
 
-- Projekty
CREATE TABLE projects (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id       UUID NOT NULL REFERENCES tenants(id),
    name            TEXT NOT NULL CHECK (LENGTH(name) > 0),
    description     TEXT NOT NULL DEFAULT '',
    status          TEXT NOT NULL DEFAULT 'active'
                    CHECK (status IN ('active', 'archived', 'deleted')),
    owner_id        UUID NOT NULL REFERENCES users(id),
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
CREATE INDEX idx_projects_tenant ON projects (tenant_id);
CREATE INDEX idx_projects_owner ON projects (owner_id);
 
CREATE TRIGGER set_updated_at BEFORE UPDATE ON projects
    FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
 
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON projects
    USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
 
-- Úkoly
CREATE TABLE tasks (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id       UUID NOT NULL REFERENCES tenants(id),
    project_id      UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
    title           TEXT NOT NULL CHECK (LENGTH(title) > 0),
    description     TEXT NOT NULL DEFAULT '',
    status          TEXT NOT NULL DEFAULT 'todo'
                    CHECK (status IN ('todo', 'in_progress', 'review', 'done')),
    priority        INTEGER NOT NULL DEFAULT 0 CHECK (priority BETWEEN 0 AND 4),
    assignee_id     UUID REFERENCES users(id) ON DELETE SET NULL,
    due_at          TIMESTAMPTZ,
    completed_at    TIMESTAMPTZ,
    sort_order      INTEGER NOT NULL DEFAULT 0,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
CREATE INDEX idx_tasks_tenant_project ON tasks (tenant_id, project_id);
CREATE INDEX idx_tasks_assignee ON tasks (assignee_id) WHERE assignee_id IS NOT NULL;
CREATE INDEX idx_tasks_status ON tasks (tenant_id, status) WHERE status != 'done';
 
CREATE TRIGGER set_updated_at BEFORE UPDATE ON tasks
    FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
 
ALTER TABLE tasks ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON tasks
    USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
 
-- Komentáře k úkolům
CREATE TABLE task_comments (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id       UUID NOT NULL REFERENCES tenants(id),
    task_id         UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
    author_id       UUID NOT NULL REFERENCES users(id),
    body            TEXT NOT NULL CHECK (LENGTH(body) > 0),
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
CREATE INDEX idx_task_comments_task ON task_comments (task_id);
 
CREATE TRIGGER set_updated_at BEFORE UPDATE ON task_comments
    FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
 
ALTER TABLE task_comments ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON task_comments
    USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
 
-- Audit log (bez RLS — tabulka pouze pro administrátory)
CREATE TABLE audit_log (
    id              BIGSERIAL PRIMARY KEY,
    tenant_id       UUID NOT NULL REFERENCES tenants(id),
    table_name      TEXT NOT NULL,
    record_id       TEXT NOT NULL,
    operation       TEXT NOT NULL CHECK (operation IN ('INSERT', 'UPDATE', 'DELETE')),
    old_values      JSONB,
    new_values      JSONB,
    changed_fields  TEXT[],
    changed_by      UUID REFERENCES users(id),
    changed_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
CREATE INDEX idx_audit_log_tenant_table ON audit_log (tenant_id, table_name, record_id);
CREATE INDEX idx_audit_log_changed_at ON audit_log (changed_at);

Všimněte si vzorů:

  • Každá tabulka má tenant_id a povolené RLS (kromě tenants a audit_log).
  • Každá tabulka má created_at a updated_at s triggery.
  • UUID pro všechny primární klíče (externě viditelné entity).
  • Check constraints na statusových enumech, délkách a rozsazích.
  • Cizí klíče s odpovídajícím ON DELETE chováním.
  • Indexy navržené pro přístupové vzory (tenant + project, tenant + status).
  • Soft delete pouze na users (kde je potřeba obnova účtu), archivace na základě statusu na projects.

Závěrečné myšlenky#

Návrh schématu není okázalý. Nikdo nikdy nedal konferenční přednášku s názvem "Přidal jsem Check Constraints na každou tabulku a ušetřilo nám to šest měsíců ladění." Ale přesně to dobrý návrh schématu dělá — zabraňuje problémům tak tiše, že nikdy ani nevíte, že by existovaly.

Vzory v tomto příspěvku nejsou nové. Jsou výsledkem let psaní migrací, ladění poškozených dat a refaktoringu schémat pod produkční zátěží. Každý z nich existuje, protože jsem já, nebo někdo, s kým jsem pracoval, to nejdřív udělal jinak a zaplatil za to.

Začněte s omezeními. Používejte cizí klíče. Přidejte created_at ke všemu. Zvolte konvenci pojmenování a prosazujte ji nemilosrdně. Používejte RLS pro multi-tenancy. Buďte opatrní s JSONB. Testujte své migrace proti datům produkční velikosti, než je nasadíte.

Databáze je základ. Udělejte ji správně a vše postavené nad ní se stane jednodušším. Udělejte ji špatně a žádné množství chytrého aplikačního kódu vás nezachrání.

Související články