String jako klucz główny

Ostatnio przerabiałem temat użycia stringa jako klucza głównego dla modeli. Przejrzałem pół internetu i kodu AR po drodze, więc się podzielę przepisem, bo dotarłem do paru częściowo działających rozwiązań, ale z każdym był jakiś problem…

Najpierw migracja i tu czekają dwie pułapki:

  1. Nawet jeśli jawnie zadeklarujemy kolumnę string, to wskazanie jej jako klucza głównego (opcja primary_key: coś) spowoduje utworzenie typu integer. Tworzymy więc tabelę bez klucza (id: false)
  2. Po utworzeniu tabeli klucz główny można utworzyć czystym SQL. Taka migracja będzie działać poprawnie, a efekt będzie zgodny z tym co chcemy osiągnąć. Niestety problem pojawia się przy zrzucie bazy do schema.rb - znowu klucze główne zrzucane są zawsze jako integer. Będziemy mieli więc problem przy ładowaniu struktury bazy z tego pliku.

Rozwiązanie - brak klucza głównego w ogóle w bazie. Kolumnę id tworzymy z NOT NULL oraz unikalnym indeksem na niej. Puryści bazodanowi (jak ja :P) mogą się tu skrzywić, ale trudno - ActiveRecord inaczej nie pozwoli i już.

create_table :compass_points, id: false do |t|
  t.string :id, null: false
  t.string :description
end

add_index :compass_points, :id, unique: true

Teraz model i tutaj potrzebne są dwie rzeczy:

  1. Jawne wskazanie klucza głównego: self.primary_key = :id
  2. Usunięcie domyślnego mass-assignment protection z id - jeśli potrzebujemy, a zwykle tak, bo identyfikator nie jest już nadawany automatycznie z sekwencji. Mamy kilka opcji:
  • zostawić jak jest i wstawiać id ręcznie lub przez mass-assignment z opcją without_protection: true
  • wrzucić :id do attr_accessible jeśli nie używamy strong parameters (uwaga: pominięcie id w attr_protected nie działa)
  • usunąć ochronę nadpisując metodę self.attributes_protected_by_default
class CompassPoint < ActiveRecord::Base
  self.primary_key = :id

  def self.attributes_protected_by_default
    []
  end
end

I tyle, więcej nie musimy nic robić. Efekt:

CompassPoint.create(id: 'N', description: 'North')
#  INSERT INTO "compass_points" ("description", "id") VALUES (?, ?)  [["description", "North"], ["id", "N"]]
=> #<CompassPoint id: "N", description: "North">
# ...
Direction.all
# SELECT "directions".* FROM "directions"
=> #<ActiveRecord::Relation [#<Direction id: 1, compass_point_id: "N", miles: 12>, #<Direction id: 2, compass_point_id: "E", miles: 7>]>

Na koniec - generalnie jest to wbrew konwencji i nie bez powodu, zwykle warto użyć sztucznego id nawet jeśli model ma klucz naturalny - te się lubią zmieniać nieoczekiwanie. Ale w niektórych przypadkach, z pełną świadomością konsekwencji :wink: myślę, że jest to przydatne, można pozbyć się niektórych joinów, a symbole są bardziej czytelne od abstrakcyjnych numerów.