Mehrsprachiges Rails-Schema: jsonb schlägt 19 Tabellen/Spalten, gestufte Constraints, ->>-Queries, Indexzeitpunkt.
how2claude veröffentlicht jeden Artikel in 19 Sprachen (zh / en / ja / ko / ar / ...), und wie das in der Datenschicht gespeichert wird, muss von vornherein entschieden werden. Lass Claude diese Tabelle entwerfen, und er gibt dir höchstwahrscheinlich 19 String-Spalten oder — schlimmer — 19 Tabellen („eine articles-Tabelle pro Sprache"). Beides sind Fallen.
Die richtige Antwort ist 1 Tabelle + 3 jsonb-Spalten, jede jsonb mit der Locale als Schlüssel. Dieser Artikel behandelt, warum dieser Ansatz gewinnt, wie man das Schema schreibt, wie man abfragt, wann man indexiert, und die 4 Richtungen, in die Claude per Default falsch läuft.
Lege alle Kandidaten offen:
Option A: 19 Tabellen (articles_zh, articles_en, ...)
- Einen Artikel holen erfordert 19 Joins / 19 Queries
- 20. Sprache hinzufügen = CREATE TABLE
- Artikel löschen → Kaskade über 19 Tabellen
- Plus: Single-Table-Queries sind schnell
- Realität: außer die Präsentationslogik unterscheidet sich tatsächlich pro Sprache (so gut wie nie), ist das Über-Partitionierung
Option B: 19 String-Spalten (title_zh, title_en, ..., summary_zh, summary_en, ...)
- Die Tabelle wird riesig breit (3 Inhaltstypen × 19 Sprachen = 57 Spalten)
- Die meisten Zellen sind NULL (Thailändisch kommt meist zuletzt oder nie)
- 20. Sprache hinzufügen = ALTER TABLE ADD COLUMN × 3
- Dünn besetztes Zeilenlayout, schlechte IO-Effizienz
Option C: 3 jsonb-Spalten (title jsonb, summary jsonb, content jsonb)
- Eine Zeile pro Artikel, Locale ist ein Key im jsonb
- Sprache hinzufügen berührt das Schema nicht (nur Key einfügen)
- Erster-Klasse-Bürger in Postgres — atomare Queries, Indexe, partielle Fetches
- Query-Syntax ist etwas lauter (title->>'zh'), aber lässt sich wrappen
Nimm C. Der Rest dieses Artikels dreht sich darum, C richtig zu machen.
how2claudes tatsächliche articles-Tabelle (Auszug):
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
# ... weitere bigint, timestamp etc.
end
Warum die Constraints unterschiedlich sind:
title: null: false + default: {}. Titel ist Pflicht — ein Artikel muss mindestens einen Titel in einer Sprache haben. Selbst wenn's nur en ist, zumindest der. Der gesamte jsonb darf nicht NULL sein.summary / content: nur default: {}, NULL erlaubt. Die können leer sein (z. B. direkt nach der Erstellung, bevor Inhalt geschrieben ist), also locker handhaben.default: {} ist Pflicht. Lass es weg, und ein frisch Article.new-erter Datensatz hat title = nil, und gleich darauf fliegt article.title["en"]: NoMethodError: undefined method '[]' for nil:NilClass. Default als leerer Hash macht alle Zugriffe sicher.
Wenn Claude diese Migration schreibt, neigt er dazu:
- default: {} wegzulassen → der nil-Crash oben
- null: false zu jeder jsonb hinzuzufügen → summary kann auf neuen, noch befüllten Datensätzen nicht NULL sein, bricht den „Titel zuerst, Inhalt später"-Flow
- default: "{}" (String-Literal) zu schreiben → Postgres speichert den String-Literal "{}" statt leerem json, Typmismatch bei der Query
Regel: Der Default einer jsonb-Spalte muss {} sein — der Ruby-Hash-Literal, nicht ein String. null: false nur auf der Pflicht-jsonb.
Nur das Schema reicht nicht — das Model braucht Accessor mit 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
Drei-Stufen-Fallback: aktuelle Locale → en → was existiert. Ein chinesischer Leser auf einem japanischen Artikel — wenn Japanisch fehlt, fällt er auf Englisch; wenn Englisch auch fehlt, auf „was auch immer da ist", nie nil.
validates :title, presence: true handhabt {} automatisch — ActiveRecord betrachtet einen leeren Hash als blank, also besteht ein leerer Titel die Validierung nicht. Das ist der Nutzen der null: false + presence-Kombi.
->> vs. ->Zwei täglich genutzte Postgres-jsonb-Operatoren:
title->>'zh' → gibt text zurück (String)title->'zh' → gibt jsonb zurück (behält den json-Typ)99% der Zeit willst du ->> — einen String rausziehen und nutzen. Nutze -> nur, wenn du weiter verschachteln willst (z. B. content->'metadata'->>'author').
Article.where("title ? :loc", loc: "zh")
# SELECT * FROM articles WHERE title ? 'zh'
? ist jsonbs „Key existiert"-Operator. Gibt Artikel zurück, deren Titel-Hash den zh-Key hat.
Article.order("title->>'en' ASC")
Article.where("title ? :loc", loc: "ja")
.order("title->>'ja' ASC")
# Falsch 1: ganzen jsonb als String vergleichen
Article.where(title: '{"zh": "..."}')
# Falsch 2: in Ruby filtern
Article.all.select { |a| a.title["zh"].present? } # zieht die ganze Tabelle in den Speicher
# Falsch 3: = ohne Operator
Article.where("title->>'zh' = ?", "irgendein Titel") # das ist richtig, aber Claude mischt es mit Falsch 1
Regel: alle Filter / Sortierung / Existenzprüfungen passieren in SQL via ->> oder ?; greif nur dann auf Ruby article.title["zh"] zurück, wenn du für den Nutzer renderst.
jsonb indexiert nicht automatisch. Drei gängige Bedürfnisse → drei Indexe:
add_index :articles, :title, using: :gin
Beschleunigt title ? 'zh', title @> '{"zh": ...}' usw. GIN ist schreibintensiv und platzfressend, aber Pflicht für Key/Value-Existenzabfragen auf jsonb.
add_index :articles, "(title->>'en')", name: "idx_articles_title_en"
Beschleunigt nur title->>'en' = ? oder ORDER BY title->>'en'. Wenn du nur nach englischem Titel sortierst (z. B. englische Seite in alphabetischer Liste), reicht ein englischer Ausdrucks-Index.
jsonb-Indexe sind teure Objekte. Bei kleinem Artikel-Volumen (Tausende) und Queries nur per id/slug nicht präventiv jsonb indexieren. Warten, bis ein klarer Query-Bedarf entsteht.
how2claude hat derzeit keinen jsonb-Index auf title/summary/content — Volumen gering, Queries per Slug, ein Index wäre pure Platzverschwendung. Wird hinzugefügt, wenn Bulk-Queries häufig werden (sagen wir, 10k Artikel).
Bei dieser Art Schema-Design ist Claudes erster Reflex oft falsch. Fang jeden ab:
„Ich bin nicht sicher, was in dieses Feld soll, ich mach's jsonb..."
Umleiten: jsonb passt für Situationen mit stabiler Struktur, aber offenem oder dünn-breitem Key-Set — mehrsprachig, Feature Flags, Nutzer-Präferenzen. Keine Ausrede, Schema-Design zu überspringen. Wenn du schon ein klares Key-Set hast (sagen wir, 5 fixe Felder), nimm normale Spalten.
default: "{}" als StringUmleiten: Rails speichert das als String-Literal "{}" in Postgres, nicht als leeres json. Nach der Migration fliegt article.title["zh"] (Strings lassen sich so nicht subskriptieren). Es muss default: {} sein (Ruby-Hash-Literal).
null: falseUmleiten: Frag zuerst, was tatsächlich Pflicht ist. Title ist Pflicht (Artikel ohne Titel ist Unsinn) → null: false. summary und content dürfen leer sein (Entwurfsphase) → nur default: {}, kein null: false. Constraints staffeln, nicht flächendeckend.
„Ich mach
Article.allund.select { |a| a.title["zh"] }..."
Umleiten: Der ganze Sinn von jsonb ist native SQL-Unterstützung. Alle Filter / Existenz / Sortierung leben in Postgres via ->> / ? / @>. Zieh es nach Ruby zurück, und in dem Moment, wo die Tabelle tausend Zeilen erreicht, stirbst du.
Claude mit jsonb ein mehrsprachiges Rails-Schema entwerfen lassen — 6 Regeln:
null: false, default: {}; summary / content nur default: {}.default: {} ist ein Hash-Literal, kein String. Ein String "{}" erzeugt einen Typmismatch.current → en → .values.first. Views sehen nie nil.title ? 'zh' (Existenz), title->>'zh' (Wert/Sortierung). Nicht zum Filtern in Ruby zurückziehen.Die eigentliche Entscheidung ist nicht „jsonb oder nicht" — bei 19 Sprachen ist die Antwort offensichtlich. Die eigentlichen Entscheidungen sind welche Felder Pflicht vs. optional sind, wie die Fallback-Kette im Model verläuft, wann man von „noch kein Index" auf „Ausdrucks-Index hinzufügen" umschaltet. Das sind Produkt- und Volumenentscheidungen. Claude liefert dir Code, aber diese Entscheidungen trifft er nicht für dich.