Przejdź do głównej zawartości

Projektowanie baz danych i zapytania

Patrzysz na migrację, która dotyka milionów wierszy, musi zachować integralność referencyjną i nie może wyłączyć aplikacji. Schemat ma osiemnaście tabel, które tylko po części pamiętasz, oraz graf kluczy obcych, którego nikt nie narysował od 2022 roku. Wklejasz okrojony zrzut do okna czatu, a AI zgaduje relacje, których nie widzi; zwrócony SQL odwołuje się do kolumn, które nie istnieją.

Serwer MCP do baz danych likwiduje ten martwy punkt. Daje Cursorowi, Claude Code i Codeksowi żywe, strukturalne połączenie z bazą danych, dzięki czemu mogą zbadać prawdziwy schemat, odczytać rzeczywiste wyjście EXPLAIN i sprawdzić ograniczenia, zanim zaproponują choćby jeden wiersz DDL. Praca przesuwa się z opisywania bazy danych na wspólne rozumowanie o niej.

  • Serwer MCP do bazy danych tylko do odczytu wpięty do Cursora, Claude Code i Codeksa (konfiguracja jest niemal identyczna we wszystkich trzech).
  • Gotowy do ponownego użycia prompt do projektowania nowego schematu, który pasuje do istniejących tabel.
  • Przepływ optymalizacji zapytań, który czyta prawdziwe plany wykonania zamiast zgadywać indeksy.
  • Szablon migracji bez przestojów dla zmian kolumn w dużych tabelach.
  • Krótką listę trybów awarii, które dopadają jako pierwsze, oraz sposobów na wyjście z nich.

Żywy schemat, nie migawka

Model widzi prawdziwe tabele, kolumny, typy i ograniczenia przez połączenie, więc przestaje wymyślać klucze obce, które nie istnieją.

Prawdziwe plany wykonania

Utrzymywany serwer potrafi uruchomić EXPLAIN/EXPLAIN ANALYZE i odczytać pg_stat_statements, więc porady dotyczące indeksów opierają się na selektywności, a nie na przeczuciach.

Bezpieczniejsze migracje

Mając rzeczywisty schemat w kontekście, AI może zaplanować addytywne, odwracalne kroki i wygenerować rollback równolegle ze skryptem do przodu.

Ograniczony dostęp

Skieruj serwer na rolę tylko do odczytu na replice deweloperskiej lub staging. Model zyskuje pełną introspekcję bez dostępu zapisu do czegokolwiek, co ma znaczenie.

Konfiguracja ma ten sam kształt we wszystkich trzech narzędziach: serwer stdio uruchamiany przez uvx (serwery w Pythonie) lub npx (serwery w Node), z łańcuchem połączenia przekazywanym przez zmienną środowiskową, dzięki czemu nigdy nie ląduje w historii powłoki ani w konfiguracji wrzuconej do repozytorium.

Rekomendowany serwer to Postgres MCP Pro od Crystal DBA (postgres-mcp na PyPI). Oprócz zwykłego SQL dodaje analizę EXPLAIN, rekomendacje strojenia indeksów i kontrole kondycji bazy danych, a do tego dostarcza flagę --access-mode restricted, która wymusza wykonywanie tylko do odczytu, pojedynczych instrukcji.

Dodaj do .cursor/mcp.json (projekt) lub ~/.cursor/mcp.json (globalnie):

{
"mcpServers": {
"postgres": {
"command": "uvx",
"args": ["postgres-mcp", "--access-mode", "restricted"],
"env": { "DATABASE_URI": "postgresql://readonly:secret@localhost:5432/mydb" }
}
}
}

Jeśli korzystasz z Supabase, Supabase MCP (@supabase/mcp-server-supabase) jest gotową do podmiany alternatywą, która uwierzytelnia się osobistym tokenem dostępu i obsługuje --read-only.

Pozostałe trzy działają dokładnie według tego samego wzorca, co Postgres powyżej: blok command/args/env w .cursor/mcp.json, wiersz claude mcp add ... -- ... lub tabela [mcp_servers.<name>] w ~/.codex/config.toml. Zmienia się tylko nazwa pakietu i zmienna środowiskowa połączenia. Poniżej pokazano formę dla Claude Code; odpowiedniki dla Cursora i Codeksa mapują się jeden do jednego.

Serwer: mysql-mcp-server (PyPI). Czyta osobne zmienne środowiskowe, a nie pojedynczy URL połączenia.

Okno terminala
claude mcp add mysql \
-e MYSQL_HOST=localhost -e MYSQL_PORT=3306 \
-e MYSQL_USER=readonly -e MYSQL_PASSWORD=secret -e MYSQL_DATABASE=mydb \
-- uvx mysql-mcp-server

W przypadku Cursora umieść te same pięć kluczy MYSQL_* w obiekcie env obok "command": "uvx", "args": ["mysql-mcp-server"]. Samo MYSQL_URL nic nie da — serwer wystartuje bez poświadczeń i nie nawiąże połączenia.

Mając schemat w kontekście, projektowanie przestaje być odizolowanym planowaniem i staje się dialogiem. Model bada twoje prawdziwe tabele users i orders, a następnie proponuje dodatki, które pasują do twoich konwencji nazewnictwa, typów i ograniczeń.

Typowy wynik, osadzony w twojej rzeczywistej strukturze:

-- Required for the EXCLUDE constraint below: GiST equality on uuid/text
-- columns needs the btree_gist operator classes.
CREATE EXTENSION IF NOT EXISTS btree_gist;
CREATE TABLE subscription_plans (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(100) NOT NULL,
price DECIMAL(10,2) NOT NULL,
billing_cycle INTERVAL NOT NULL, -- e.g. '1 month'::interval
features JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE user_subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
plan_id UUID NOT NULL REFERENCES subscription_plans(id),
status VARCHAR(20) NOT NULL DEFAULT 'active',
current_period_start TIMESTAMPTZ NOT NULL,
current_period_end TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- One active subscription per user, enforced at the DB level
CONSTRAINT one_active_subscription
EXCLUDE (user_id WITH =) WHERE (status = 'active')
);

Jeśli wolisz pominąć rozszerzenie, tę samą regułę można wyrazić jako częściowy unikalny indeks: CREATE UNIQUE INDEX one_active_sub ON user_subscriptions(user_id) WHERE status = 'active';. Wartość pracy przez serwer MCP polega na tym, że model już wie, iż users(id) jest typu UUID, i dopasowuje się do niego, zamiast zgadywać BIGINT.

Schematy rzadko wyłaniają się od razu doskonałe. Ponieważ serwer utrzymuje kontekst między turami, możesz iterować: „Teraz dodaj dziennik audytu, który zapisuje każdą zmianę planu z planem poprzednim i nowym, powodem (upgrade, downgrade, renewal) oraz kwotą proporcjonalnego rozliczenia”, a kolejny DDL poprawnie odwoła się do user_subscriptions(id).

Najmocniejszą częścią integracji MCP z bazą danych jest przekładanie pytania biznesowego na efektywny SQL, a następnie walidowanie go względem prawdziwego planu wykonania.

SELECT
c.id,
c.email,
COUNT(o.id) AS order_count,
SUM(o.total_amount) AS total_revenue,
AVG(o.total_amount) AS avg_order_value,
MAX(o.created_at) AS last_order_at
FROM customers c
JOIN orders o ON c.id = o.customer_id
WHERE o.created_at >= date_trunc('quarter', CURRENT_DATE)
AND o.status <> 'cancelled'
AND c.is_test_account = false
GROUP BY c.id, c.email
ORDER BY total_revenue DESC
LIMIT 10;

Ponieważ serwer potrafi odczytać plan, porada dotycząca indeksu jest konkretna, a nie ogólnikowa:

-- Supports the join + WHERE + sort, scoped to live orders
CREATE INDEX CONCURRENTLY idx_orders_customer_created_active
ON orders (customer_id, created_at)
WHERE status <> 'cancelled';

Gdy coś działa wolno na produkcji, przepływem pracy jest śledztwo, a nie zgadywanie.

Osadzona w danych odpowiedź przechodzi plan krok po kroku, a potem proponuje celowane poprawki:

  1. Wstępnie zagreguj recenzje w CTE, tak aby średnia ocena była liczona raz na produkt, zamiast mnożyć się przez join.
  2. Dodaj indeks pokrywający dla filtru price + created_at na tabeli products.
  3. Uruchom ponownie EXPLAIN ANALYZE, aby potwierdzić, że skanowanie sekwencyjne zmieniło się w skanowanie indeksu.
CREATE INDEX CONCURRENTLY idx_products_price_created
ON products (price, created_at)
INCLUDE (name, category_id)
WHERE created_at > CURRENT_DATE - INTERVAL '2 years';
WITH product_stats AS (
SELECT product_id, AVG(rating) AS avg_rating, COUNT(*) AS review_count
FROM reviews
GROUP BY product_id
)
SELECT p.id, p.name, p.price, c.name AS category_name,
COALESCE(ps.avg_rating, 0) AS avg_rating,
COALESCE(ps.review_count, 0) AS review_count
FROM products p
LEFT JOIN categories c ON p.category_id = c.id
LEFT JOIN product_stats ps ON p.id = ps.product_id
WHERE p.price BETWEEN 100 AND 500
AND p.created_at > CURRENT_DATE - INTERVAL '2 years'
ORDER BY ps.avg_rating DESC NULLS LAST, ps.review_count DESC NULLS LAST;

Serwer MongoDB wnosi ten sam wzorzec do agregacji. Poproś o „użytkowników pogrupowanych według miesiąca rejestracji, ze średnią liczbą zamówień na użytkownika i liczbą użytkowników bez zamówienia w ostatnich 90 dniach”, a wygeneruje potok wraz z indeksami, które przyspieszają jego działanie (db.orders.createIndex({ user_id: 1, created_at: -1 })).

Migracje dużych tabel wymagają addytywnych, odwracalnych kroków. Mając rzeczywisty schemat w kontekście, model może zaplanować fazy i wygenerować rollback równolegle ze skryptem do przodu.

  1. Dodaj nową kolumnę z ograniczeniem CHECK (bez przepisywania tabeli, bez blokady).
  2. Podwójny zapis z aplikacji, aby nowe wiersze wypełniały obie kolumny.
  3. Uzupełnij istniejące wiersze partiami w okresach niskiego ruchu.
  4. Dodaj unikalny indeks przez CREATE INDEX CONCURRENTLY (bez blokady zapisu).
  5. Przełącz odczyty, a po walidacji usuń starą kolumnę.
-- Phase 1: additive, no rewrite
ALTER TABLE users
ADD COLUMN email_new VARCHAR(320)
CHECK (email_new ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$');
-- Phase 3: batched backfill, keeps locks short
DO $$
DECLARE batch INTEGER := 10000; affected INTEGER;
BEGIN
LOOP
UPDATE users SET email_new = email
WHERE id IN (
SELECT id FROM users WHERE email_new IS NULL LIMIT batch
);
GET DIAGNOSTICS affected = ROW_COUNT;
EXIT WHEN affected = 0;
PERFORM pg_sleep(0.1); -- breathe between batches
END LOOP;
END $$;
-- Phase 4: enforce without blocking writes
CREATE UNIQUE INDEX CONCURRENTLY idx_users_email_new ON users (email_new);
ALTER TABLE users ALTER COLUMN email_new SET NOT NULL;
-- Phase 5 (after app cutover)
ALTER TABLE users DROP COLUMN email;
ALTER TABLE users RENAME COLUMN email_new TO email;

Przez cały czas proś model o generowanie zapytań walidacyjnych — wykrywanie duplikatów, liczenie naruszeń ograniczeń, sprawdzanie osieroconych kluczy obcych — abyś mógł udowodnić integralność przed każdą fazą.

Konwersja aplikacji jednodostępnej na bezpieczeństwo na poziomie wierszy to klasyczna zmiana wysokiego ryzyka. Mając schemat w kontekście, model może zaproponować kolumny dzierżawcy wraz z politykami RLS, które pasują do twoich ról.

ALTER TABLE orders ADD COLUMN tenant_id UUID REFERENCES tenants(id);
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_orders ON orders
FOR ALL TO application_role
USING (tenant_id = current_setting('app.tenant_id')::UUID);
CREATE INDEX idx_orders_tenant_customer
ON orders (tenant_id, customer_id, created_at);

W przypadku danych szeregów czasowych o dużej objętości poproś o tabelę PARTITION BY RANGE z partycjami miesięcznymi i funkcją automatycznego tworzenia. Trzymaj przykładowe partycje na bieżących, przyszłych miesiącach, aby granice pozostały poprawne:

CREATE TABLE sensor_readings (
id BIGSERIAL,
device_id INTEGER NOT NULL,
metric_type VARCHAR(50) NOT NULL,
value DOUBLE PRECISION NOT NULL,
recorded_at TIMESTAMPTZ NOT NULL DEFAULT now()
) PARTITION BY RANGE (recorded_at);
CREATE TABLE sensor_readings_2026_06 PARTITION OF sensor_readings
FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');
CREATE TABLE sensor_readings_2026_07 PARTITION OF sensor_readings
FOR VALUES FROM ('2026-07-01') TO ('2026-08-01');

Stosuj zasadę najmniejszych uprawnień podczas tworzenia roli, jako która łączy się serwer MCP:

CREATE ROLE mcp_readonly WITH LOGIN PASSWORD 'use-a-secret-manager';
GRANT CONNECT ON DATABASE myapp TO mcp_readonly;
GRANT USAGE ON SCHEMA public TO mcp_readonly;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO mcp_readonly;
-- Let the AI read plans and stats without touching data
GRANT pg_read_all_stats TO mcp_readonly;

W połączeniu z własnym trybem tylko do odczytu serwera (--access-mode restricted dla postgres-mcp, --read-only dla Supabase, --readOnly dla MongoDB) daje to modelowi pełną introspekcję i zero dostępu do zapisu.