12. Oktober 2026
Das Problem: Der Scope ist kein Schreibschutz
Eine ActiveRecord Assoziation mit Scope wirkt auf den ersten Blick eindeutig:
class Manga < ApplicationRecord
has_many :labelings
has_many :artists, -> { artist }, through: :labelings
endDie Rails has_many through scope sorgt dafür, dass manga.artists nur Datensätze liefert, auf die der Scope artist zutrifft. Das ist ein Lesefilter. Genau darin liegt die Falle: Der Scope beschreibt nicht automatisch, welche Join-Einträge beim Schreiben erhalten bleiben müssen.
Ich halte das für besonders riskant in gewachsenen Codebases. Der Name artists suggeriert eine abgegrenzte Collection. Tatsächlich existiert darunter eine has_many through-Beziehung über labelings, und dort können weitere Datensätze liegen, die der Reader ausblendet.
Wie Daten verschwinden
Angenommen, ein Manga hat mehrere Join-Einträge. Einer verweist auf einen echten Artist, ein anderer auf einen Datensatz, der nicht durch den Scope artist sichtbar ist.
Dann sieht dieser Code harmlos aus:
manga.artists = [label]Die Erwartung wäre: Ersetze nur die aktuell sichtbaren Artists. Der Schreibpfad behandelt die Zuordnung aber als Ersetzen der Through-Collection. Rails entfernt dabei Join-Einträge, die nicht in der neuen Liste stehen. Der zuvor vorhandene echte Artist kann verschwinden. Gleichzeitig kann ein anderer Eintrag erneut angelegt werden, wenn er als label zugewiesen wird.
Das Ergebnis ist Rails Datenverlust ohne Exception. Es ist kein Rails-Bug, sondern dokumentiertes, aber überraschendes Verhalten von has_many through: Scope und Schreiboperation sind unterschiedliche Pfade.
Warum der Test im Speicher täuscht
Nach der Zuweisung ist dieser Check wenig wert:
manga.artistsDer In-Memory-Reader zeigt die gerade zugewiesene Collection. Das sieht konsistent aus und kann einen Test grün machen, obwohl die Join-Tabelle bereits einen unerwarteten Zustand enthält.
Erst ein Reload zwingt Active Record, den Zustand erneut aus der Datenbank zu lesen:
manga.reload.artists
manga.reload.labelingsDer zweite Aufruf ist wichtig. Die gefilterte artists-Assoziation kann weiterhin plausibel aussehen, während die ungescopte Join-Collection bereits falsche oder fehlende Einträge enthält.
Lesepfad und Schreibpfad getrennt absichern
Mein Rat: Eine gescopte has_many through-Assoziation sollte entweder bewusst read-only sein oder jeden Schreibweg validieren. Nur den sichtbaren Reader zu testen reicht nicht.
Für Operationen auf dem CollectionProxy müssen insbesondere concat, << und replace abgesichert werden. Die konkrete Bedingung hängt vom Modell ab. Im Beispiel prüft sie, ob alle Records tatsächlich dem artist-Scope entsprechen.
class Manga < ApplicationRecord
has_many :labelings
has_many :artists, -> { artist }, through: :labelings do
def concat(*records)
ensure_artists!(records.flatten)
super
end
def <<(*records)
ensure_artists!(records.flatten)
super
end
def replace(records)
ensure_artists!(records)
super
end
private
def ensure_artists!(records)
allowed_ids = Artist.artist.where(id: records.map(&:id)).pluck(:id)
return if allowed_ids.sort == records.map(&:id).sort
raise ArgumentError, "artists darf nur Records aus dem artist-Scope enthalten"
end
end
endZusätzlich braucht der ID-Writer einen eigenen Schutz. artist_ids= umgeht den normalen Objektpfad und muss daher separat prüfen:
class Manga < ApplicationRecord
def artist_ids=(ids)
normalized_ids = Array(ids).reject(&:blank?)
allowed_ids = Artist.artist.where(id: normalized_ids).pluck(:id).map(&:to_s)
unless normalized_ids.map(&:to_s).sort == allowed_ids.sort
raise ArgumentError, "artist_ids enthält Records außerhalb des artist-Scopes"
end
super
end
endDie genaue Implementierung kann anders aussehen. Entscheidend ist das Prinzip: CollectionProxy und _ids= sind getrennte Eingänge. Wer nur einen davon schützt, lässt einen weiteren Schreibpfad offen.
Der Test muss die Datenbank prüfen
Ein Regressionstest sollte die fragliche Zuweisung ausführen und danach reloaden. Außerdem sollte er die Join-Datensätze prüfen, nicht nur die gescopte ActiveRecord Assoziation.
it "verändert keine nicht sichtbaren Join-Einträge" do
expect do
manga.artists = [label]
end.to raise_error(ArgumentError)
expect(manga.reload.labelings).to include(existing_artist_labeling)
endSucht in eurer Codebase nach has_many through mit Scope – und schaut, wo darauf geschrieben wird.