Free

Claude mit jsonb mehrsprachige Rails-Schemas modellieren lassen

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.


Die drei Optionen auf dem Tisch

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.

Schema: 3 jsonb-Spalten + Constraint-Kompromisse

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.

Model-Schicht: Fallback umwickeln

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.

Query-Patterns: ->> 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').

Artikel mit einer bestimmten Übersetzung

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.

Nach Titel in bestimmter Sprache sortieren

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

Nach Locale filtern + sortieren

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

Queries, die Claude per Default falsch schreibt

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

Indexe: was hinzufügen, wann

jsonb indexiert nicht automatisch. Drei gängige Bedürfnisse → drei Indexe:

1. Existenz- / @>-Containment-Queries → GIN-Index

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.

2. Sortierung / Gleichheit auf bestimmter Locale → Ausdrucks-Index

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.

3. Keine häufige Query → nicht hinzufügen

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

Die 4 Richtungen, in die Claude per Default falsch läuft

Bei dieser Art Schema-Design ist Claudes erster Reflex oft falsch. Fang jeden ab:

1. Nutzt jsonb als „Ich kenne das Schema nicht"-Notausgang

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

2. default: "{}" als String

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

3. Jede jsonb-Spalte bekommt null: false

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

4. jsonb auf der Anwendungsschicht filtern

„Ich mach Article.all und .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.

Checkliste

Claude mit jsonb ein mehrsprachiges Rails-Schema entwerfen lassen — 6 Regeln:

  1. 19 Tabellen / 19 Spalten zuerst ausschließen. Außer die Präsentationslogik unterscheidet sich substanziell pro Locale, ist jsonb die einzige Antwort.
  2. 3 jsonb-Spalten + abgestufte Constraints: title null: false, default: {}; summary / content nur default: {}.
  3. default: {} ist ein Hash-Literal, kein String. Ein String "{}" erzeugt einen Typmismatch.
  4. Model bietet Accessor mit Fallback: current → en → .values.first. Views sehen nie nil.
  5. Queries in SQL: title ? 'zh' (Existenz), title->>'zh' (Wert/Sortierung). Nicht zum Filtern in Ruby zurückziehen.
  6. Warte auf echten Query-Bedarf, bevor du indexierst. GIN präventiv zu setzen ist Over-Engineering; kleine Volumen mit Slug/ID-Lookups brauchen keinen jsonb-Index.

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.