Free

Niech Claude zaprojektuje wielojęzyczny schema Rails w jsonb

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.


Trzy opcje na stole

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.

Schema: 3 kolumny jsonb + kompromis w constraintach

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.

Warstwa modelu: opakuj fallback

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.

Wzorce zapytań: ->> 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').

Artykuły z konkretnym tłumaczeniem

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.

Sortuj po tytule w konkretnym języku

Article.order("title->>'en' ASC")

Filtruj + sortuj po locale

Article.where("title ? :loc", loc: "ja")
       .order("title->>'ja' ASC")

Zapytania, które Claude domyślnie pisze źle

# Ź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.

Indeksy: co dodać, kiedy

jsonb nie indeksuje się automatycznie. Trzy typowe potrzeby → trzy indeksy:

1. Zapytania istnienia / @> zawierania → indeks GIN

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.

2. Sortowanie / równość na konkretnym locale → indeks wyrażenia

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.

3. Brak częstych zapytań → nie dodaj

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).

4 kierunki, w których Claude domyślnie błądzi

Przy takim projektowaniu schema pierwszy instynkt Claude'a często jest błędny. Łap każdy:

1. Używa jsonb jako furtki „nie znam schema"

„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.

2. default: "{}" jako string

Przekieruj: 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).

3. Każda kolumna jsonb dostaje null: false

Przekieruj: 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.

4. Filtruj jsonb w warstwie aplikacyjnej

„Robię Article.all i 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.

Lista kontrolna

Niech Claude zaprojektuje wielojęzyczny schema Rails z jsonb — 6 reguł:

  1. Najpierw odrzuć 19 tabel / 19 kolumn. Chyba że logika prezentacji różni się istotnie per locale, jsonb to jedyna odpowiedź.
  2. 3 kolumny jsonb + warstwowe constrainty: title null: false, default: {}; summary / content tylko default: {}.
  3. default: {} to literal hashu, nie string. String "{}" powoduje niezgodność typu.
  4. Model dostarcza akcesory z fallback: current → en → .values.first. View nigdy nie widzi nil.
  5. Zapytania w SQL: title ? 'zh' (istnienie), title->>'zh' (wartość/sortowanie). Nie ciągnij do Ruby, żeby filtrować.
  6. Czekaj z indeksowaniem na prawdziwą potrzebę zapytaniową. Dodanie GIN profilaktycznie to nadinżynieria; małe wolumeny z lookupami slug/id nie potrzebują żadnego indeksu jsonb.

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.