Wielojęzyczny schema Rails: jsonb bije 19 tabel/kolumn, warstwowe constrainty, zapytania ->>, kiedy indeksować.
how2claude publikuje każdy artykuł w 19 językach (zh / en / ja / ko / ar / ...), a jak to przechowywać w warstwie danych trzeba zdecydować na początku. Poproś Claude'a o zaprojektowanie tej tabeli i pewnie dostaniesz 19 kolumn string, albo gorzej, 19 tabel — „jedna tabela articles na język". Obie to pułapki.
Właściwa odpowiedź to 1 tabela + 3 kolumny jsonb, każda jsonb kluczowana po locale. Ten artykuł opisuje, dlaczego to podejście wygrywa, jak napisać schema, jak robić zapytania, kiedy indeksować i 4 kierunki, w których Claude domyślnie błądzi.
Rozłóż wszystkich kandydatów:
Opcja A: 19 tabel (articles_zh, articles_en, ...)
- Pobranie jednego artykułu wymaga 19 joinów / 19 zapytań
- Dodanie 20. języka = CREATE TABLE
- Usunięcie artykułu → kaskada po 19 tabelach
- Plus: zapytania single-table szybkie
- Rzeczywistość: chyba że logika prezentacji naprawdę różni się między językami (niemal nigdy), to nadmierna partycja
Opcja B: 19 kolumn string (title_zh, title_en, ..., summary_zh, summary_en, ...)
- Tabela staje się ogromnie szeroka (3 typy × 19 języków = 57 kolumn)
- Większość komórek NULL (tłumaczenie na tajski zwykle dociera ostatnio albo nigdy)
- Dodanie 20. języka = ALTER TABLE ADD COLUMN × 3
- Rzadko wypełniony układ wiersza, zła wydajność IO
Opcja C: 3 kolumny jsonb (title jsonb, summary jsonb, content jsonb)
- Jeden wiersz na artykuł, locale to klucz wewnątrz jsonb
- Dodanie języka nie rusza schema (wystarczy wstawić klucz)
- Obywatel pierwszej klasy w Postgres — atomowe zapytania, indeksy, częściowe fetche
- Składnia zapytań trochę głośniejsza (title->>'zh'), ale da się opakować
Idziesz z C. Reszta tego artykułu to jak zrobić C dobrze.
Rzeczywista tabela articles how2claude (wycinek):
create_table :articles do |t|
t.jsonb :title, default: {}, null: false
t.jsonb :summary, default: {}
t.jsonb :content, default: {}
t.string :slug, null: false
t.boolean :free, default: false, null: false
# ... inne bigint, timestamp itd.
end
Dlaczego constrainty się różnią:
title: null: false + default: {}. Tytuł jest obowiązkowy — artykuł musi mieć tytuł przynajmniej w jednym języku. Choćby en, najmniej tyle. Cały jsonb nie może być NULL.summary / content: tylko default: {}, NULL dozwolony. Te mogą być puste (zaraz po utworzeniu, zanim treść zostanie napisana), więc luzuj.default: {} jest obowiązkowy. Pomiń go i świeżo Article.new-owany rekord ma title = nil, i zaraz potem article.title["en"] wybucha: NoMethodError: undefined method '[]' for nil:NilClass. Domyślne puste hash czyni wszystkie dostępy bezpieczne.
Gdy Claude pisze tę migrację, często:
- Pomija default: {} → crash nil powyżej
- Dodaje null: false do każdego jsonb → summary nie może być NULL na świeżych rekordach jeszcze uzupełnianych, psuje flow „najpierw tytuł, potem treść"
- Pisze default: "{}" (literal string) → Postgres zapisuje literał stringa "{}" zamiast pustego json, niezgodność typów przy zapytaniu
Reguła: default kolumny jsonb musi być {} — literal hashu Ruby, nie string. null: false tylko na obowiązkowej jsonb.
Sama schema nie wystarczy — model potrzebuje akcesorów z fallback:
class Article < ApplicationRecord
validates :title, presence: true
def title_for(locale = I18n.locale)
title[locale.to_s] || title["en"] || title.values.first
end
def summary_for(locale = I18n.locale)
summary[locale.to_s] || summary["en"]
end
def content_for(locale = I18n.locale)
content[locale.to_s] || content["en"]
end
end
Trzyetapowy fallback: bieżący locale → en → cokolwiek istnieje. Chiński czytelnik trafiający na japoński artykuł, jeśli nie ma japońskiego spada na angielski; jeśli angielskiego też nie, spada na „cokolwiek jest", nigdy nil.
validates :title, presence: true obsługuje {} automatycznie — ActiveRecord traktuje pusty hash jako blank, więc pusty title nie przechodzi walidacji. To korzyść combo null: false + presence.
->> vs ->Dwa codzienne operatory jsonb Postgres:
title->>'zh' → zwraca text (string)title->'zh' → zwraca jsonb (zachowuje typ json)99% czasu chcesz ->> — wyciągasz string i używasz. Używaj -> tylko gdy zagnieżdżasz dalej (np. content->'metadata'->>'author').
Article.where("title ? :loc", loc: "zh")
# SELECT * FROM articles WHERE title ? 'zh'
? to operator „klucz istnieje" jsonb. Zwraca artykuły, w których hash title ma klucz zh.
Article.order("title->>'en' ASC")
Article.where("title ? :loc", loc: "ja")
.order("title->>'ja' ASC")
# Źle 1: porównywanie całego jsonb jako stringa
Article.where(title: '{"zh": "..."}')
# Źle 2: filtrowanie w Ruby
Article.all.select { |a| a.title["zh"].present? } # ciągnie całą tabelę do pamięci
# Źle 3: = bez operatora
Article.where("title->>'zh' = ?", "jakiś tytuł") # to jest poprawne, ale Claude miesza z Źle 1
Reguła: każdy filtr / sortowanie / check istnienia dzieje się w SQL przez ->> lub ?; sięgasz po article.title["zh"] w Ruby tylko gdy renderujesz dla użytkownika.
jsonb nie indeksuje się automatycznie. Trzy typowe potrzeby → trzy indeksy:
add_index :articles, :title, using: :gin
Przyspiesza title ? 'zh', title @> '{"zh": ...}' itd. GIN jest ciężki w zapisie i zajmuje miejsce, ale obowiązkowy dla zapytań istnienia klucza/wartości jsonb.
add_index :articles, "(title->>'en')", name: "idx_articles_title_en"
Przyspiesza tylko title->>'en' = ? lub ORDER BY title->>'en'. Jeśli sortujesz tylko po tytule angielskim (np. alfabetyczna lista strony angielskiej), jeden indeks wyrażenia angielskiego wystarczy.
Indeksy jsonb to drogie obiekty. Jeśli wolumen artykułów jest mały (tysiące) i wszystkie zapytania są po id/slug, nie preindeksuj jsonb. Czekaj, aż pojawi się wyraźna potrzeba zapytania.
how2claude obecnie nie ma indeksu jsonb na title/summary/content — wolumen niski, zapytania po slug, indeks byłby tylko marnowaniem miejsca. Dodawany, gdy zapytania wsadowe zaczną pojawiać się często (powiedzmy, 10k artykułów).
Przy takim projektowaniu schema pierwszy instynkt Claude'a często jest błędny. Łap każdy:
„Nie jestem pewien, co to pole ma trzymać, dam jsonb..."
Przekieruj: jsonb pasuje do sytuacji ze stabilną strukturą, ale otwartym lub rzadko-szerokim zbiorem kluczy — wielojęzyczność, feature flags, preferencje użytkownika. To nie wymówka, żeby pomijać projektowanie schema. Jeśli masz już jasny zbiór kluczy (powiedzmy 5 stałych pól), użyj normalnych kolumn.
default: "{}" jako stringPrzekieruj: Rails zapisze to jako literal stringa "{}" w Postgres, nie jako puste json. Po migracji article.title["zh"] wybucha (stringi nie subscrypują się tak). Musi być default: {} (literal hashu Ruby).
null: falsePrzekieruj: Najpierw pytaj, która jest naprawdę obowiązkowa. Title jest obowiązkowy (artykuł bez tytułu to nonsens) → null: false. summary i content mogą być puste (faza draftu) → tylko default: {}, bez null: false. Gradacja constraintów; nie strzelaj na oślep.
„Robię
Article.alli potem.select { |a| a.title["zh"] }..."
Przekieruj: Cała sól jsonb to natywne wsparcie SQL. Wszystkie filtry / istnienie / sortowanie żyją w Postgres przez ->> / ? / @>. Przeciągniesz do Ruby i w momencie, gdy tabela uderzy w tysiąc wierszy, umierasz.
Niech Claude zaprojektuje wielojęzyczny schema Rails z jsonb — 6 reguł:
null: false, default: {}; summary / content tylko default: {}.default: {} to literal hashu, nie string. String "{}" powoduje niezgodność typu.current → en → .values.first. View nigdy nie widzi nil.title ? 'zh' (istnienie), title->>'zh' (wartość/sortowanie). Nie ciągnij do Ruby, żeby filtrować.Prawdziwa decyzja to nie „jsonb czy nie" — przy 19 językach odpowiedź jest oczywista. Prawdziwe decyzje to które pola są obowiązkowe vs opcjonalne, jak działa łańcuch fallback modelu, kiedy przejść z „jeszcze bez indeksu" na „dodaj indeks wyrażenia". To decyzje produktowe i wolumenowe. Claude daje ci kod, ale tych decyzji za ciebie nie podejmuje.