RUBY ON RAILS · 17 MIN READ ·

Rails Samengestelde Primaire Sleutels: CPK, Legacy Schema's en Natuurlijke Sleutels in ActiveRecord

Rails samengestelde primaire sleutels laten ActiveRecord werken met meerkolomse PKs. Leer CPK-setup, associaties, legacy schema's en valkuilen in productie.

Rails Samengestelde Primaire Sleutels: CPK, Legacy Schema's en Natuurlijke Sleutels in ActiveRecord

Het schema was een erfenis van een tien jaar oude Oracle-migratie. Elke tabel had een company_id-kolom en een id dat alleen uniek was binnen een bedrijf. De applicatie die het schema had gegenereerd, had nog nooit van Rails gehoord. Toen de klant wilde herbouwen op Rails — “Rails regelt toch alles?” — belandde het probleem op mijn bord.

Voor Rails 7.1 was het antwoord pijnlijk: een surrogaat auto-increment primaire sleutel die aan elke tabel werd gelijmd, een aangepaste find-override om enkelvoudige lookups te vermijden, en eindeloze where(company_id: current_company.id)-bewakers die junior-ontwikkelaars steeds vergaten. Zeventien maanden later vonden we een querypad in de facturatiemodule zonder die bewaker. We hebben tweeënzestig klanten terugbetaald die elkaars facturen hadden gezien.

Rails samengestelde primaire sleutels, standaard beschikbaar in 7.1, lossen deze klasse problemen op op modelniveau. Hier is wat ze zijn, hoe ze werken, en de randgevallen die je verrassen als niemand je waarschuwt.

Wat Zijn Rails Samengestelde Primaire Sleutels?

Een samengestelde primaire sleutel bestaat uit twee of meer kolommen. In plaats van één id die een rij overal in de tabel uniek identificeert, dwingt de database uniciteit af op de combinatie van waarden. De meest voorkomende voorbeelden:

  • (shop_id, order_id) — orders gekoppeld aan een winkel in een multi-tenant systeem
  • (user_id, post_id) — een koppeltabel die ook het canonieke record is
  • (event_date, event_id) — gepartitioneerde tabellen waarbij id per partitie opnieuw begint
  • (account_id, id) — een legacy Oracle-schema waarbij id bedrijfsgebonden is

Rails kon altijd met zulke tabellen praten via ruwe SQL, maar elke hulpmethode — Model.find, belongs_to, has_many, URL-helpers — ging uit van één primaire sleutelkolom genaamd id. Rails 7.1 heeft die aanname definitief losgelaten.

Samengestelde Primaire Sleutels Declareren in Rails

De declaratie is een enkele regel op het model:

class Order < ApplicationRecord
  self.primary_key = [:shop_id, :id]
end

De volgorde is belangrijk. Rails gebruikt de array als een geordend tuple; [:shop_id, :id] en [:id, :shop_id] produceren andere SQL bij een find-aanroep. Stem de volgorde af op hoe je wilt opzoeken.

In een migratie declareer je de samengestelde primaire sleutel expliciet:

class CreateOrders < ActiveRecord::Migration[7.1]
  def change
    create_table :orders, primary_key: [:shop_id, :id] do |t|
      t.bigint :shop_id, null: false
      t.bigint :id, null: false
      t.string :status, null: false
      t.timestamps
    end
  end
end

Als id nog steeds auto-increment gedrag nodig heeft op de tweede kolom, regelt PostgreSQL dit met een sequence die los staat van de primaire sleutelbeperking:

CREATE SEQUENCE orders_id_seq;

CREATE TABLE orders (
  shop_id bigint NOT NULL,
  id      bigint NOT NULL DEFAULT nextval('orders_id_seq'),
  status  varchar NOT NULL,
  created_at timestamp NOT NULL,
  updated_at timestamp NOT NULL,
  PRIMARY KEY (shop_id, id)
);

Je kunt dit via execute in een migratie aanroepen. Voor de meeste applicaties is id als globale auto-increment houden en (tenant_id, id) als samengestelde primaire sleutel declareren het schoonste pad: de samengestelde sleutel dwingt de relationele invariant af, en id behoudt een globale eigenschap die je kunt gebruiken wanneer je autorisatiecontext beschikbaar is.

find, find_by en where met Samengestelde Primaire Sleutels

Model.find met een samengestelde primaire sleutel neemt een array:

Order.find([42, 1001])  # shop_id: 42, id: 1001

Dit genereert:

SELECT * FROM orders WHERE shop_id = 42 AND id = 1001 LIMIT 1

Meerdere records ophalen:

Order.find([[42, 1001], [42, 1002], [42, 1003]])

Rails gebruikt hier tupelvergelijkingssyntaxis — WHERE (shop_id, id) IN ((42, 1001), (42, 1002), (42, 1003)) — wat PostgreSQL efficiënt afhandelt met een samengestelde index.

find_by werkt gewoon op naam:

Order.find_by(shop_id: 42, id: 1001)

where is ongewijzigd:

Order.where(shop_id: 42).order(:id)

De verandering die je meteen merkt: Order.find(1001) gooit een ActiveRecord::StatementInvalid zodra je een samengestelde primaire sleutel declareert. Enkelvoudige find is weg. Dit is een breaking change in bestaande code en het eerste dat je moet controleren tijdens een migratie.

Associaties met Samengestelde Primaire Sleutels

Associaties zijn waar Rails CPK-ondersteuning verfijnd wordt — en waar je de meeste debugging-tijd zult doorbrengen.

Een has_many via een samengestelde primaire sleutel heeft de expliciete foreign key nodig:

class Shop < ApplicationRecord
  has_many :orders, foreign_key: :shop_id
end

class Order < ApplicationRecord
  self.primary_key = [:shop_id, :id]
  belongs_to :shop
end

belongs_to kan de foreign key vaak afleiden uit de primaire sleuteldeclaratie, maar expliciet zijn vermijdt ambiguïteit. Wanneer de foreign key op het kind zelf samengesteld is — de associatie omspant twee kolommen — gebruik je de query_constraints-optie:

class OrderLine < ApplicationRecord
  self.primary_key = [:shop_id, :order_id, :position]

  belongs_to :order, query_constraints: [:shop_id, :order_id]
end

query_constraints vertelt Rails welke kolommen deelnemen aan de associatie-join, niet alleen welke kolom de foreign key is. Zonder dit joinen Rails alleen op order_id, wat niet uniek is zonder shop_id.

Controleer of je associaties de juiste SQL genereren vóór je live gaat:

shop = Shop.find(42)
shop.orders.to_sql
# => SELECT * FROM orders WHERE orders.shop_id = 42

Als je een smallere conditie ziet dan verwacht, of een ontbrekend onderdeel van de samengestelde sleutel in de WHERE-clausule, ontbreekt query_constraints of klopt het niet.

Routes en URL-helpers met Samengestelde Primaire Sleutels

Rails URL-helpers roepen to_param aan op je model. Standaard retourneert to_param bij CPK de sleutelwaarden samengevoegd met een underscore:

order = Order.find([42, 1001])
order.to_param  # => "42_1001"

Dit genereert URLs zoals /orders/42_1001. Dat werkt maar leest slecht. Twee patronen die ik prefereer:

Patroon 1: Geneste resources

resources :shops do
  resources :orders, only: [:show, :edit, :update, :destroy]
end

shop_order_path(shop, order) neemt twee afzonderlijke parameters en de controller ontvangt params[:shop_id] en params[:id] netjes. De samengestelde sleutel verschijnt nooit in de URL, en de scoping is expliciet in het routesbestand.

Patroon 2: Override to_param

class Order < ApplicationRecord
  self.primary_key = [:shop_id, :id]

  def to_param
    id.to_s
  end
end

Als id globaal uniek is — auto-increment via een globale sequence — is dit veilig. Autorisatie gebeurt in de controller vanuit de sessie of token, niet vanuit de URL. Dit is het patroon dat de meeste teams al gebruikten vóór CPK bestond; de CPK-declaratie geeft je nu de database-invariant zonder dat je beide sleutelonderdelen in URL’s hoeft te laten zien.

Legacy Schema-migratie: De Praktijkcase

De meest voorkomende reden waarom teams Rails samengestelde primaire sleutels gebruiken is een legacy schema dat ze niet zelf hebben ontworpen. Het migratiepad van een surrogaatsleutel-workaround naar een native CPK-model:

Voor (de surrogaatsleutel-aanpak):

class Invoice < ApplicationRecord
  # :id is een auto-increment surrogaat, toegevoegd via een migratie
  # natuurlijke sleutel: [:company_id, :number]
  # :number is alleen uniek binnen een bedrijf
end

Na (native CPK):

class Invoice < ApplicationRecord
  self.primary_key = [:company_id, :number]
end

De migratie om de surrogaatsleutel te verwijderen vereist zorgvuldige volgorde. Met een zero-downtime migratiestrategie zijn de stappen:

  1. Voeg een UNIQUE-beperking toe op (company_id, number) in een aparte migratie. Dit dwingt de invariant af op databaseniveau vóór je de applicatie aanraakt.
  2. Deploy de modelwijziging (self.primary_key = [:company_id, :number]) achter een feature flag. Verifieer dat findpaden de juiste SQL genereren in staging.
  3. Controleer elke aanroepplaats die Invoice.find(id) gebruikt — grep -rn 'Invoice\.find(' app/ is je startpunt. Pas ze aan naar Invoice.find([company_id, number]) of herroute via find_by.
  4. Verwijder de surrogaat id-kolom en zijn index in een afsluitende migratie nadat een volledige deploycyclus bevestigt dat niets er meer van afhangt.

strong_migrations signaleert het verwijderen van een kolom als potentieel onveilig — gebruik safety_assured pas nadat je hebt bevestigd dat de kolom nergens meer naar wordt verwezen.

Samengestelde Primaire Sleutels en Multi-Tenancy

Het scenario uit de inleiding — company_id-gebonden IDs in een legacy Oracle-schema — komt direct overeen met samengestelde primaire sleutels. Maar CPK vervangt applicatieniveau-scoping niet; het vult het aan.

Met (company_id, id) als primaire sleutel gooit Order.find([42, 1001]) een RecordNotFound als die combinatie niet bestaat. Dit is een nuttige invariant. Het verhindert niet dat Order.find([99, 1001]) de order van een ander bedrijf teruggeeft als de aanroeper company_id beheert. In een webapplicatie waar company_id uit de sessie komt, is de samengestelde sleutel prima. In een API waar company_id een requestparameter kan zijn, heb je nog steeds expliciete scoping nodig.

Als je multi-tenant Rails-architectuur bouwt, behandel CPK als een structurele invariant — de database kan geen verkeerd gesleutelde rijen aanmaken — niet als een toegangscontrolemechanisme. De applicatie moet nog steeds afdwingen wie wat mag opzoeken.

Het schoonste patroon is een scoped find via de associatie:

class Shop < ApplicationRecord
  has_many :orders, foreign_key: :shop_id
end

class Order < ApplicationRecord
  self.primary_key = [:shop_id, :id]
  belongs_to :shop
end

# In de controller — altijd via de associatie opzoeken
@order = current_shop.orders.find(params[:id])

current_shop.orders.find(params[:id]) past de WHERE-clausule van de associatie toe vóór de primaire sleutelconditie. Zelfs als een client een andere shop_id stuurt, beperkt de associatiescope de resultaten tot current_shop. De samengestelde primaire sleutel geeft daarna een tweede laag structurele zekerheid.

Prestatieoverwegingen

Een samengestelde primaire sleutel is een B-tree-index op meerdere kolommen, in declaratievolgorde. Queries die filteren op (shop_id, id) gebruiken de index volledig. Queries die alleen op id filteren kunnen de samengestelde primaire sleutelindex niet gebruiken — die hebben een aparte index op id nodig als je op die kolom alleen wilt opzoeken.

# Voeg een ondersteunende index toe als je snelle lookups op id alleen nodig hebt
add_index :orders, :id

Voor gepartitioneerde tabellen waarbij id per partitie opnieuw begint, dwingt Postgres-tabelpartitionering uniciteit per partitie af, niet globaal. Een samengestelde primaire sleutel die de partitiesleutel bevat — (event_date, id) — geeft lokaal unieke beperkingen op elke partitie. Globale uniciteit over alle partities vereist een globale sequence voor id, die je onafhankelijk van de samengestelde PK-beperking kunt instellen.

Het schrijfpad heeft geen overhead ten opzichte van een enkelvoudige primaire sleutel. De samengestelde primaire sleutel wordt afgedwongen op B-tree-niveau, identiek aan hoe een enkelvoudige PK wordt afgedwongen — alleen op meer kolommen.

Samengestelde Primaire Sleutels Testen

Standaard Minitest- en RSpec-patronen werken met kleine aanpassingen:

# Minitest
test "vindt order via samengestelde sleutel" do
  order = Order.create!(shop_id: 1, status: "pending")
  found = Order.find([1, order.id])
  assert_equal order, found
end

test "scoped find faalt bij verkeerde winkel" do
  other_shop = shops(:other)
  order = Order.create!(shop_id: 1, status: "pending")
  assert_raises(ActiveRecord::RecordNotFound) do
    other_shop.orders.find(order.id)
  end
end

In FactoryBot: zorg dat shop_id nooit per ongeluk ontbreekt — met een samengestelde primaire sleutel weigert de database een rij waarbij een NOT NULL-onderdeel ontbreekt:

FactoryBot.define do
  factory :order do
    association :shop
    shop_id { shop.id }
    status  { "pending" }
  end
end

Een veelgemaakte fout: Order.create! gebruiken in een test zonder shop_id op te geven terwijl de kolom NOT NULL is en deel uitmaakt van de primaire sleutel. De PostgreSQL-foutmelding is duidelijk, maar het is makkelijk over het hoofd te zien in testsetup als je gewend bent aan enkelvoudige PKs die achter auto-increment standaardwaarden verborgen zitten.

Wanneer Samengestelde Primaire Sleutels Niet Gebruiken

Als je een schoon schema hebt vanaf het begin. Als je het schema zelf beheert en er geen legacy-beperking is die meerkoloms uniciteit vereist, is een enkelvoudige auto-increment id plus een samengestelde unieke index op de natuurlijke sleutel eenvoudiger. Rails heeft meer dan een decennium aan conventies gebouwd rondom enkelvoudige primaire sleutels; ga de strijd alleen aan als je een echte reden hebt.

Als je globaal draagbare verwijzingen nodig hebt. UUIDs of auto-incrementintegers kunnen worden doorgegeven aan externe systemen — webhooks, e-mails, externe API’s — zonder context. Een samengestelde sleutel zoals [shop_id, 1001] vereist beide onderdelen om te decoderen; als een afnemer shop_id kwijtraakt, is de verwijzing kapot.

Als gem-compatibiliteit onzeker is. Sommige gems gaan uit van een enkelvoudige id-primaire sleutel — ActiveAdmin, bepaalde Devise-tokenpaden, oudere serializers. Rails 7.1+ heeft de interoperabiliteit aanzienlijk verbeterd, maar controleer je afhankelijkheden vóór je CPK invoert.

Als de teamkosten de structurele voordelen overtreffen. Een team van drie personen waarbij de meeste leden zes maanden geleden zijn begonnen, verliest meer tijd aan onverwachte CPK-randgevallen dan het wint aan structurele garantie. Weeg de complexiteitskosten eerlijk af.

Veelgestelde Vragen

Hoe gebruik ik Rails samengestelde primaire sleutels met find_or_create_by?

Geef alle primaire sleutelonderdelen als zoekwoorden mee:

Order.find_or_create_by(shop_id: 42, id: 1001) do |order|
  order.status = "pending"
end

Als je slechts één onderdeel van de samengestelde sleutel meegeeft, genereert Rails een query die meerdere rijen of geen kan matchen. Neem altijd alle CPK-kolommen mee in find_or_create_by-aanroepen.

Werken Rails samengestelde primaire sleutels met Devise?

Devise gaat uit van een enkelvoudige id-primaire sleutel voor sessietokens en authenticatiehulpfuncties. Devise laten draaien op een samengesteld primaire sleutelmodel vereist aanzienlijk patchen van interne Devise-methoden. De standaardoplossing is het User-model te laten met een enkelvoudige id-primaire sleutel en samengestelde PKs alleen te gebruiken op domaintabellen — orders, events, koppeltabellen — waar de legacy- of structurele beperking daadwerkelijk leeft.

Werken samengestelde primaire sleutels met Rails strong_migrations?

Ja. strong_migrations behandelt samengestelde primaire sleuteltabellen zoals elke andere tabel — het valideert het aanmaken van indexen, het verwijderen van kolommen en typewijzigingen tegen zijn veiligheidslijst. Declareer de samengestelde primaire sleutel in de initiële create_table-aanroep in plaats van via een latere ADD CONSTRAINT om de vergrendelingsproblemen te vermijden die komen bij het aanpassen van een bestaande primaire sleutel op een live tabel.

Hoe beïnvloeden samengestelde primaire sleutels ActiveRecord’s query-cache?

ActiveRecord’s query-cache en in-memory identity map slaan objecten op via hun primaire sleutelwaarde. Met CPK is de cachesleutel de array [shop_id, id]. Twee records met een verschillende shop_id maar hetzelfde id worden correct geïdentificeerd als verschillende records — een verbetering ten opzichte van het enkelvoudige geval, waarbij unscoped en associatie-scoped queries op hetzelfde id theoretisch konden botsen in de cache.

Heb je te maken met een legacy schema dat niet past in Rails-conventies, of plan je een upgrade naar Rails 7.1+ en vraag je je af welke strijd de moeite waard is? TTB Software doet dit werk. Negentien jaar Rails betekent dat we weten welke migraties hun complexiteit verdienen en welke beter met rust worden gelaten.

#rails-composite-primary-keys #activerecord-cpk #rails-legacy-schema #rails-multi-column-primary-key #rails-natural-key

Related Articles

Laatste sectie. Bel dan alsjeblieft.

Het is een telefoongesprek. Erger dan dat kan het niet worden.

Geen discovery-deck. Geen 45-minuten "kwalificatiegesprek." 30 minuten, jouw probleem, mijn mening. Als we een fit zijn weet je dat in minuut 12.

Directe lijn — Roger neemt zelf op
+31 6 5123 6132
Ma–vr, 09:00–18:00 CET · Nu beschikbaar

OF
info@ttb.software