Wysyłaj bezpieczniejsze aplikacje generowane przez AI, polegając najpierw na ograniczeniach PostgreSQL (NOT NULL, CHECK, UNIQUE, FOREIGN KEY), a dopiero potem na kodzie i testach.

Kod wygenerowany przez AI często wygląda poprawnie, ponieważ obsługuje ścieżkę szczęścia. Prawdziwe aplikacje zawodzą w „brudnym środku”: formularz wysyła pusty ciąg zamiast null, zadanie w tle próbuje ponownie i tworzy ten sam rekord dwa razy, albo usunięcie usuwa wiersz rodzica i zostawia dzieci osierocone. To nie są egzotyczne błędy. Objawiają się jako puste wymagane pola, powtarzające się wartości „unikatowe” i osierocone wiersze wskazujące na nic.
Przechodzą też przez code review i podstawowe testy z prostego powodu: recenzenci czytają intencję, nie każdy przypadek brzegowy. Testy zwykle obejmują kilka typowych przykładów, nie tygodnie rzeczywistego zachowania użytkowników, importy z CSV, niestabilne retry czy równoległe żądania. Jeżeli asystent wygenerował kod, może pominąć drobne, ale krytyczne sprawdzenia, takie jak przycinanie spacji, walidacja zakresów czy zabezpieczenie przed wyścigami.
"Ograniczenia najpierw, kod później" oznacza, że umieszczasz niepodważalne reguły w bazie danych, aby złe dane nie mogły zostać zapisane, niezależnie od ścieżki, która próbuje je zapisać. Aplikacja powinna dalej walidować wejście, by dawać lepsze komunikaty błędów, ale prawda jest egzekwowana przez bazę. I tu PostgreSQL błyszczy: ograniczenia chronią Cię przed całymi kategoriami pomyłek.
Krótki przykład: wyobraź sobie małe CRM. Skrypt importujący kontakty wygenerowany przez AI tworzy kontakty. Jeden wiersz ma email "" (pusty), dwa wiersze powtarzają ten sam email w różnych wielkościach liter, a jeden kontakt odnosi się do account_id, którego nie ma, bo konto zostało usunięte w innym procesie. Bez ograniczeń wszystko to może trafić do produkcji i później psuć raporty.
Dzięki odpowiednim regułom w bazie zapisy te odrzucane są natychmiast, blisko źródła. Pola wymagane nie mogą być puste, duplikaty nie mogą się wślizgnąć przy retry, relacje nie mogą wskazywać na usunięte lub nieistniejące rekordy, a wartości nie mogą wykraczać poza dozwolone zakresy.
Ograniczenia nie zapobiegną każdemu błędowi. Nie naprawią mylącego UI, błędnego obliczenia rabatu czy wolnego zapytania. Ale zatrzymują gromadzenie się złych danych, które często powoduje, że „błędy brzegowe generowane przez AI” stają się kosztowne.
Twoja aplikacja rzadko jest jednym repozytorium mówiącym do jednego użytkownika. Typowy produkt ma UI webowe, aplikację mobilną, ekrany administracyjne, zadania w tle, importy z CSV i czasem integracje z zewnętrznymi systemami. Każda ścieżka może tworzyć lub zmieniać dane. Jeśli każda ścieżka ma pamiętać te same reguły, jedna z nich zawiedzie.
Baza danych to jedno miejsce, które jest wspólne dla wszystkich. Traktując ją jako ostatecznego strażnika, reguły stosują się automatycznie do wszystkich ścieżek. Ograniczenia PostgreSQL zamieniają „zakładamy, że to prawda” na „to musi być prawdą, albo zapis się nie powiedzie”.
Kod generowany przez AI sprawia, że to jeszcze ważniejsze. Model może dodać walidację w formularzu React, ale pominąć przypadek brzegowy w zadaniu w tle. Albo obsłużyć dane z happy-path, a następnie złamać się, gdy prawdziwy klient wpisze coś nieoczekiwanego. Ograniczenia łapią problemy w momencie, gdy złe dane próbują wejść do bazy, nie tygodnie później, gdy debugujesz dziwne raporty.
Gdy pomijasz ograniczenia, złe dane często są ciche. Zapis się udaje, aplikacja idzie dalej, a problem pojawia się później jako zgłoszenie do wsparcia, niezgodność rozliczeń lub pulpit, któremu nikt nie ufa. Czyszczenie jest kosztowne, bo naprawiasz historię, a nie jedno żądanie.
Złe dane zwykle wkradają się przez codzienne sytuacje: nowa wersja klienta wysyła pole jako puste zamiast nieobecne, retry tworzy duplikaty, edycja administracyjna omija sprawdzenia UI, plik importu ma niespójny format, albo dwóch użytkowników aktualizuje powiązane rekordy jednocześnie.
Przydatny model myślowy: akceptuj dane tylko jeśli są poprawne na granicy systemu. W praktyce ta granica powinna obejmować bazę danych, bo ona widzi wszystkie zapisy.
NOT NULL to najprostsze ograniczenie PostgreSQL i zapobiega zaskakująco dużej klasie błędów. Jeśli wartość musi istnieć, aby wiersz miał sens, niech baza to wymusza.
NOT NULL zwykle ma sens dla identyfikatorów, wymaganych nazw i znaczników czasu. Jeśli nie da się utworzyć poprawnego rekordu bez tej wartości, nie pozwól, by była pusta. W małym CRM lead bez właściciela lub czasu utworzenia nie jest „częściowym leadem”. To zepsute dane, które później powodują dziwne zachowania.
NULL wkrada się częściej przy kodzie generowanym przez AI, bo łatwo utworzyć „opcjonalne” ścieżki bez zauważenia. Pole formularza może być opcjonalne w UI, API może przyjmować brakujący klucz, a jedna gałąź funkcji tworzącej może pominąć przypisanie wartości. Wszystko się kompiluje i testy happy-path przechodzą. Potem użytkownicy importują CSV z pustymi komórkami albo klient mobilny wysyła inny ładunek, i NULL trafia do bazy.
Dobrym wzorcem jest łączenie NOT NULL z sensowną wartością domyślną dla pól, które system tworzy:
created_at TIMESTAMP NOT NULL DEFAULT now()status TEXT NOT NULL DEFAULT 'new'is_active BOOLEAN NOT NULL DEFAULT trueDomyślne wartości nie zawsze są dobre. Nie ustawiaj domyślnych wartości dla pól podawanych przez użytkownika, takich jak email czy company_name, tylko po to, by spełnić NOT NULL. Pusty ciąg nie jest „bardziej poprawny” niż NULL — tylko ukrywa problem.
Gdy nie jesteś pewien, zdecyduj, czy wartość jest rzeczywiście nieznana, czy reprezentuje inny stan. Jeśli „jeszcze nie podano” ma znaczenie, rozważ osobną kolumnę stanu zamiast dopuszczać NULL wszędzie. Na przykład: zostaw phone nullable, ale dodaj phone_status z wartościami missing, requested lub verified. To zachowuje spójność znaczenia w kodzie.
Ograniczenie CHECK to obietnica tabeli: każdy wiersz musi spełniać regułę, za każdym razem. To jeden z najłatwiejszych sposobów, żeby uniemożliwić tworzenie wierszy, które w kodzie wyglądają poprawnie, ale w rzeczywistości nie mają sensu.
CHECK najlepiej sprawdza się dla reguł zależnych tylko od wartości w tym samym wierszu: zakresy liczbowe, dozwolone wartości i proste zależności między kolumnami.
-- 1) Totals should never be negative
ALTER TABLE invoices
ADD CONSTRAINT invoices_total_nonnegative
CHECK (total_cents >= 0);
-- 2) Enum-like allowed values without adding a custom type
ALTER TABLE tickets
ADD CONSTRAINT tickets_status_allowed
CHECK (status IN ('new', 'open', 'waiting', 'closed'));
-- 3) Date order rules
ALTER TABLE subscriptions
ADD CONSTRAINT subscriptions_date_order
CHECK (end_date IS NULL OR end_date >= start_date);
Dobry CHECK jest czytelny na pierwszy rzut oka. Traktuj go jak dokumentację dla swoich danych. Preferuj krótkie wyrażenia, jasne nazwy ograniczeń i przewidywalne wzorce.
CHECK nie nadaje się do wszystkiego. Jeśli reguła musi sprawdzać inne wiersze, agregaty lub porównania między tabelami (np. „konto nie może przekroczyć limitu planu”), zostaw to w logice aplikacji, triggerach lub kontrolowanym zadaniu w tle.
Reguła UNIQUE jest prosta: baza odmówi zapisania dwóch wierszy, które mają tę samą wartość w ograniczonej kolumnie (lub tę samą kombinację wartości). To eliminuje klasę błędów, gdzie ścieżka „create” uruchamia się dwa razy, następuje retry lub dwóch użytkowników wysyła to samo jednocześnie.
UNIQUE gwarantuje brak duplikatów dla dokładnych wartości, które zdefiniujesz. Nie gwarantuje, że wartość istnieje (NOT NULL), że ma poprawny format (CHECK) ani że odpowiada Twojemu pojęciu równości (wielkość liter, spacje, interpunkcja), chyba że to uwzględnisz.
Typowe miejsca, gdzie chcesz unikalności, to email w tabeli użytkowników, external_id z innego systemu lub nazwa, która musi być unikalna w obrębie konta, np. (account_id, name).
Uwaga: NULL i UNIQUE. W PostgreSQL NULL traktowany jest jako „nieznany”, więc wiele wartości NULL jest dozwolonych pod UNIQUE. Jeśli chodzi Ci o „wartość musi istnieć i musi być unikalna”, połącz UNIQUE z NOT NULL.
Praktyczny wzorzec dla identyfikatorów widocznych dla użytkownika to unikalność bez uwzględniania wielkości liter. Ludzie wpiszą „[email protected]”, a potem „[email protected]” i oczekują, że to to samo.
-- Case-insensitive unique email
CREATE UNIQUE INDEX users_email_unique_ci
ON users (lower(email));
-- Unique contact name per account
ALTER TABLE contacts
ADD CONSTRAINT contacts_account_name_unique UNIQUE (account_id, name);
Zdefiniuj, co dla Twoich użytkowników oznacza „duplikat” (wielkość liter, spacje, per-account vs globalnie), a potem zakoduj to raz, aby każda ścieżka kodu stosowała tę samą regułę.
FOREIGN KEY mówi: „ten wiersz musi wskazywać na prawdziwy wiersz tam”. Bez niego kod może cicho tworzyć rekordy-osierocone, które wyglądają poprawnie same w sobie, ale psują aplikację później. Na przykład: notatka odnosi się do klienta, który został usunięty, albo faktura wskazuje na identyfikator użytkownika, który nigdy nie istniał.
Klucze obce mają znaczenie szczególnie wtedy, gdy dwie akcje dzieją się blisko siebie: usunięcie i utworzenie, retry po timeout, albo zadanie w tle działające na przestarzałych danych. Baza danych lepiej egzekwuje spójność niż każda ścieżka aplikacji pamiętająca, by to sprawdzić.
Opcja ON DELETE powinna odzwierciedlać realne znaczenie relacji. Zadaj pytanie: „Jeśli rodzic zniknie, czy dziecko powinno nadal istnieć?”
RESTRICT (albo NO ACTION): blokuje usunięcie rodzica, jeśli istnieją dzieci.CASCADE: usuwając rodzica, usuwa się też dzieci.SET NULL: zostawiasz dziecko, ale usuwasz powiązanie.Uważaj z CASCADE. Może być poprawne, ale też usunąć więcej niż się spodziewasz, gdy błąd lub działanie administratora skasuje rodzica.
W aplikacjach multi-tenant klucze obce to nie tylko poprawność. Zapobiegają też wyciekom między kontami. Typowy wzorzec to dodanie account_id w każdej tabeli należącej do tenantów i powiązanie relacji przez to pole.
CREATE TABLE contacts (
account_id bigint NOT NULL,
id bigint GENERATED ALWAYS AS IDENTITY,
PRIMARY KEY (account_id, id)
);
CREATE TABLE notes (
account_id bigint NOT NULL,
id bigint GENERATED ALWAYS AS IDENTITY,
contact_id bigint NOT NULL,
body text NOT NULL,
PRIMARY KEY (account_id, id),
FOREIGN KEY (account_id, contact_id)
REFERENCES contacts (account_id, id)
ON DELETE RESTRICT
);
To wymusza w schemacie „kto jest właścicielem czego”: notatka nie może wskazywać kontaktu z innego konta, nawet jeśli kod aplikacji (lub zapytanie wygenerowane przez LLM) spróbuje to zrobić.
Zacznij od krótkiej listy inwariantów: faktów, które zawsze muszą być prawdziwe. Formułuj je prosto. „Każdy kontakt potrzebuje emaila.” „Status musi być jedną z kilku dozwolonych wartości.” „Faktura musi należeć do prawdziwego klienta.” To reguły, które chcesz, by baza egzekwowała za każdym razem.
Wprowadzaj zmiany w małych migracjach, żeby produkcja nie została zaskoczona:
NOT NULL, UNIQUE, CHECK, FOREIGN KEY).Najtrudniejsza część to istniejące złe dane. Zaplanuj je. Dla duplikatów wybierz wiersz-wygranego, scal resztę i zostaw małą notatkę audytową. Dla brakujących pól wybierz bezpieczną wartość domyślną tylko jeśli naprawdę jest bezpieczna; w przeciwnym razie poddaj do kwarantanny. Dla uszkodzonych relacji albo przypisz wiersze potomne do poprawnego rodzica, albo usuń złe wiersze.
Po każdej migracji zweryfikuj kilkoma zapisami, które powinny się nie powieść: wstaw wiersz z brakującą wymaganą wartością, wstaw duplikat klucza, wstaw wartość poza zakresem, odwołaj się do brakującego wiersza rodzica. Nieudane zapisy to wartościowe sygnały. Pokazują, gdzie aplikacja cicho polegała na zachowaniu „best effort”.
Wyobraź sobie mały CRM: konta (każdy klient twojego SaaS), firmy, z którymi pracują, kontakty w tych firmach i transakcje powiązane z firmą.
To dokładnie ten rodzaj aplikacji, który ludzie szybko generują za pomocą narzędzia do czatu. Na demo wygląda dobrze, ale prawdziwe dane szybko się psują. Dwa błędy pojawiają się wcześnie: zduplikowane kontakty (ten sam email wprowadzony dwukrotnie w nieco inny sposób) oraz transakcje utworzone bez firmy, bo jedna ścieżka zapomniala ustawić company_id. Innym klasykiem jest ujemna wartość transakcji po refaktorze lub błędzie parsowania.
Naprawa to nie więcej if-ów. To kilka dobrze dobranych ograniczeń, które uniemożliwiają zapisanie złych danych.
-- Contacts: prevent duplicates per account
ALTER TABLE contacts
ADD CONSTRAINT contacts_account_email_uniq UNIQUE (account_id, email);
-- Deals: require a company and keep the relationship valid
ALTER TABLE deals
ALTER COLUMN company_id SET NOT NULL,
ADD CONSTRAINT deals_company_fk
FOREIGN KEY (company_id) REFERENCES companies(id);
-- Deals: deal value cannot be negative
ALTER TABLE deals
ADD CONSTRAINT deals_value_nonneg CHECK (deal_value >= 0);
-- A few obvious required fields
ALTER TABLE companies
ALTER COLUMN name SET NOT NULL;
ALTER TABLE contacts
ALTER COLUMN email SET NOT NULL;
To nie chodzi o surowość dla samej surowości. Zamieniasz niejasne oczekiwania w reguły, które baza będzie egzekwować za każdym razem, niezależnie od tego, która część aplikacji zapisuje dane.
Gdy ograniczenia są na miejscu, aplikacja staje się prostsza. Można usunąć wiele defensywnych sprawdzeń próbujących wykryć duplikaty po fakcie. Błędy stają się jasne i możliwe do działania (np. „email już istnieje dla tego konta” zamiast dziwnego zachowania w downstream). Gdy wygenerowana trasa API zapomni pola lub źle obsłuży wartość, zapis po prostu nie powiedzie się od razu zamiast cicho uszkadzać bazę.
Ograniczenia działają najlepiej, gdy odpowiadają rzeczywistemu działaniu biznesu. Większość bólu pochodzi z dodawania reguł, które w danej chwili wydają się „bezpieczne”, ale potem zaskakują.
Częsta pułapka to używanie ON DELETE CASCADE wszędzie. Wygląda schludnie, aż ktoś skasuje rekord rodzica i baza usunie połowę systemu. Cascady mogą być właściwe dla naprawdę zależnych danych (np. elementy robocze, które nigdy nie powinny istnieć same), ale dla ważnych rekordów (klienci, faktury, zgłoszenia) są ryzykowne. Jeśli nie jesteś pewien, preferuj RESTRICT i obsługuj usunięcia świadomie.
Innym problemem są CHECKi zbyt wąskie. „Status musi być 'new', 'won' lub 'lost'” brzmi dobrze, aż pojawi się potrzeba 'paused' lub 'archived'. Dobry CHECK opisuje trwałą prawdę, nie chwilowy wybór UI. amount >= 0 starzeje się dobrze. country IN (...) często nie.
Kilka powtarzających się problemów, gdy zespoły dodają ograniczenia po tym, jak wygenerowany kod już działa:
CASCADE jako narzędzia do sprzątania, a potem usuwanie więcej danych niż zamierzono.O wydajności: PostgreSQL automatycznie tworzy indeks dla UNIQUE, ale klucze obce nie indeksują automatycznie kolumny referencyjnej. Bez takiego indeksu aktualizacje i usunięcia rodzica mogą być wolne, bo Postgres musi przeskanować tabelę dzieci, żeby sprawdzić referencje.
Zanim zaostrzysz regułę, znajdź istniejące wiersze, które by jej nie przeszły, zdecyduj czy je naprawić czy poddać kwarantannie i wprowadzaj zmiany etapami.
Zanim wypuścisz, poświęć pięć minut na każdą tabelę i zapisz, co zawsze musi być prawdą. Jeśli potrafisz to powiedzieć prostą angielszczyzną, zwykle możesz to wymusić ograniczeniem.
Zadaj te pytania dla każdej tabeli:
Jeśli używasz narzędzia budowanego przez czat, traktuj te inwarianty jako kryteria akceptacji dla danych, a nie opcjonalne notatki. Na przykład: „Kwota transakcji musi być nieujemna”, „Email kontaktu jest unikalny w ramach workspace”, „Zadanie musi wskazywać prawdziwy kontakt”. Im bardziej sprecyzowane reguły, tym mniej miejsca na przypadkowe przypadki brzegowe.
Koder.ai (koder.ai) zawiera funkcje takie jak tryb planowania, snapshoty i rollback oraz eksport kodu źródłowego, które ułatwiają iterowanie nad zmianami schematu bezpiecznie, gdy zaostrzysz ograniczenia z czasem.
Prosty wzorzec wdrożeniowy, który działa w rzeczywistych zespołach: wybierz jedną wysokowartościową tabelę (użytkownicy, zamówienia, faktury, kontakty), dodaj 1–2 ograniczenia zapobiegające najgorszym awariom (często NOT NULL i UNIQUE), napraw zapisy, które zawiodły, a potem powtórz. Zaostrzanie reguł krok po kroku bije jedną dużą ryzykowną migrację.