Составные первичные ключи

Это руководство представляет собой введение в составные первичные ключи для таблиц баз данных: создание таблиц, запросы, ассоциации, формы, параметры контроллеров и фикстуры для моделей с составными первичными ключами.

Это руководство представляет собой введение в составные первичные ключи для таблиц баз данных.

После прочтения этого руководства вы будете уметь:

  • Создавать таблицу с составным первичным ключом.
  • Делать запросы к модели с составным первичным ключом.
  • Подключать в модели использование составного первичного ключа для запросов и ассоциаций.
  • Создавать формы для моделей, использующих составные первичные ключи.
  • Извлекать составные первичные ключи из параметров контроллера.
  • Использовать фикстуры базы данных для таблиц с составными первичными ключами.

Что такое составные первичные ключи?

Иногда значения одного столбца недостаточно для однозначной идентификации каждой строки таблицы, и требуется комбинация из двух и более столбцов. Это может быть актуально при работе с legacy-схемой базы данных без единого столбца id в качестве первичного ключа, либо при изменении схемы для шардирования или мультитенантности.

Составные первичные ключи повышают сложность и могут работать медленнее, чем один столбец первичного ключа. Прежде чем использовать составной первичный ключ, убедитесь, что ваш сценарий действительно его требует.

Миграции для составных первичных ключей

Создать таблицу с составным первичным ключом можно, передав опцию :primary_key методу create_table со значением-массивом:

class CreateProducts < ActiveRecord::Migration[8.2]
  def change
    create_table :products, primary_key: [:store_id, :sku] do |t|
      t.integer :store_id
      t.string :sku
      t.text :description
    end
  end
end

Запросы к моделям

Использование #find

Если таблица использует составной первичный ключ, для поиска записи через #find нужно передать массив:

# Найти продукт со store_id 3 и sku "XYZ12345"
irb> product = Product.find([3, "XYZ12345"])
=> #<Product store_id: 3, sku: "XYZ12345", description: "Yellow socks">

SQL-эквивалент приведённого выше:

SELECT * FROM products WHERE store_id = 3 AND sku = "XYZ12345"

Чтобы найти сразу несколько записей с составными ID, передайте в #find массив массивов:

# Найти продукты с первичными ключами [1, "ABC98765"] и [7, "ZZZ11111"]
irb> products = Product.find([[1, "ABC98765"], [7, "ZZZ11111"]])
=> [
  #<Product store_id: 1, sku: "ABC98765", description: "Red Hat">,
  #<Product store_id: 7, sku: "ZZZ11111", description: "Green Pants">
]

SQL-эквивалент приведённого выше:

SELECT * FROM products WHERE (store_id = 1 AND sku = 'ABC98765' OR store_id = 7 AND sku = 'ZZZ11111')

Модели с составными первичными ключами также используют полный составной первичный ключ при сортировке:

irb> product = Product.first
=> #<Product store_id: 1, sku: "ABC98765", description: "Red Hat">

SQL-эквивалент приведённого выше:

SELECT * FROM products ORDER BY products.store_id ASC, products.sku ASC LIMIT 1

Аналогично last использует полный составной первичный ключ, но с обратным направлением сортировки для каждого столбца:

irb> product = Product.last
=> #<Product store_id: 7, sku: "ZZZ11111", description: "Green Pants">

SQL-эквивалент приведённого выше:

SELECT * FROM products ORDER BY products.store_id DESC, products.sku DESC LIMIT 1

Здесь направление сортировки убывающее как для store_id, так и для sku, поскольку оба столбца являются частью составного первичного ключа.

Использование #where

Хэш условий для #where можно указать в синтаксисе, похожем на кортеж. Это бывает полезно для запросов по составному первичному ключу:

Product.where(Product.primary_key => [[1, "ABC98765"], [7, "ZZZ11111"]])

Условия с :id

При указании условий в методах вроде find_by и where использование id сопоставляется с атрибутом :id модели. Это отличается от find, в который передаваемый ID должен быть значением первичного ключа.

Например, этот вызов find_by сопоставляется со столбцом :id, а не со всем составным первичным ключом:

Order.find_by(id: 5)

И вызов where ведёт себя точно так же:

Order.where(id: 5)

Будьте осторожны при использовании find_by(id:) на моделях, где :id не является первичным ключом, например на моделях с составным первичным ключом. Подробности смотрите в руководстве Интерфейс запросов Active Record.

Ассоциации между моделями с составными первичными ключами

Rails часто может вывести связи «первичный ключ — внешний ключ» между связанными моделями. Однако при работе с составными первичными ключами Rails обычно по умолчанию использует только часть составного ключа — как правило, столбец id, если явно не указано иное. Это поведение по умолчанию работает только в том случае, если составной первичный ключ модели содержит столбец :id, и этот столбец уникален для всех записей.

Рассмотрим следующий пример:

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

class Book < ApplicationRecord
  belongs_to :order
end

В этой схеме Order имеет составной первичный ключ, состоящий из [:shop_id, :id], а Book принадлежит Order. Rails предположит, что для ассоциации между заказом и его книгами в качестве первичного ключа следует использовать столбец :id. Он выведет, что столбцом внешнего ключа в таблице books является :order_id.

Ниже мы создаём Order и связанную с ним Book:

order = Order.create!(id: [1, 2], status: "pending")
book = order.books.create!(title: "A Cool Book")

Чтобы получить доступ к заказу книги, мы перезагружаем ассоциацию:

book.reload.order

В этом случае Rails сгенерирует следующий SQL для доступа к заказу:

SELECT * FROM orders WHERE id = 2

Видно, что Rails использует в запросе только id заказа, а не shop_id и id вместе. В данном случае id достаточно, потому что составной первичный ключ модели действительно содержит столбец :id, и этот столбец уникален для всех записей.

Однако если приведённые выше требования не соблюдаются, либо вы хотите использовать в ассоциациях полный составной первичный ключ, можно задать опцию foreign_key: на ассоциации. Эта опция указывает составной внешний ключ для ассоциации; все столбцы внешнего ключа будут использоваться при запросе связанной записи (или записей). Например:

class Author < ApplicationRecord
  self.primary_key = [:first_name, :last_name]
  has_many :books, foreign_key: [:first_name, :last_name]
end

class Book < ApplicationRecord
  belongs_to :author, foreign_key: [:author_first_name, :author_last_name]
end

В этой схеме Author имеет составной первичный ключ, состоящий из [:first_name, :last_name], а Book принадлежит Author с составным внешним ключом [:author_first_name, :author_last_name].

Создадим Author и связанную с ним Book:

author = Author.create!(first_name: "Jane", last_name: "Doe")
book = author.books.create!(title: "A Cool Book", author_first_name: "Jane", author_last_name: "Doe")

Чтобы получить доступ к автору книги, перезагружаем ассоциацию:

book.reload.author

Теперь Rails будет использовать :first_name и :last_name из составного первичного ключа в SQL-запросе:

SELECT * FROM authors WHERE first_name = 'Jane' AND last_name = 'Doe'

Формы для моделей с составными первичными ключами

Формы также можно строить для моделей с составными первичными ключами. Подробнее о синтаксисе построителя форм смотрите в руководстве Хелперы форм.

Предположим, у нас есть объект модели @book с составным ключом [:author_id, :id]:

@book = Book.find([2, 25])
# => #<Book id: 25, title: "Some book", author_id: 2>

Следующая форма:

<%= form_with model: @book do |form| %>
  <%= form.text_field :title %>
  <%= form.submit %>
<% end %>

Выводит:

<form action="/books/2_25" method="post" accept-charset="UTF-8" >
  <input name="authenticity_token" type="hidden" value="..." />
  <input type="text" name="book[title]" id="book_title" value="My book" />
  <input type="submit" name="commit" value="Update Book" data-disable-with="Update Book">
</form>

Обратите внимание, что в сгенерированном URL значения author_id и id разделены символом подчёркивания. После отправки формы контроллер может извлечь значения первичного ключа из параметров и обновить запись. Подробности смотрите в следующем разделе.

Параметры составного ключа

Параметры составного ключа содержат несколько значений в одном параметре. По этой причине нам нужно уметь извлекать каждое значение и передавать их в Active Record. Для такого сценария можно воспользоваться методом extract_value.

Возьмём следующий контроллер:

class BooksController < ApplicationController
  def show
    # Извлекаем значение составного ID из параметров URL.
    id = params.extract_value(:id)
    # Находим книгу по составному ID.
    @book = Book.find(id)
    # используем рендеринг по умолчанию для отображения вью show.
  end
end

И следующий маршрут:

get "/books/:id", to: "books#show"

Когда пользователь открывает URL /books/4_2, контроллер извлечёт значение составного ключа ["4", "2"] и передаст его в Book.find для отображения нужной записи во вью. Метод extract_value можно использовать для извлечения массивов из любых разделённых параметров.

Фикстуры для составных первичных ключей

Фикстуры для таблиц с составными первичными ключами достаточно похожи на фикстуры для обычных таблиц. При использовании столбца id его, как обычно, можно опустить:

class Book < ApplicationRecord
  self.primary_key = [:author_id, :id]
  belongs_to :author
end
# books.yml
alices_adventure_in_wonderland:
  author_id: <%= ActiveRecord::FixtureSet.identify(:lewis_carroll) %>
  title: "Alice's Adventures in Wonderland"

Однако для поддержки связей с составными первичными ключами необходимо использовать метод composite_identify:

class BookOrder < ApplicationRecord
  self.primary_key = [:shop_id, :id]
  belongs_to :order, foreign_key: [:shop_id, :order_id]
  belongs_to :book, foreign_key: [:author_id, :book_id]
end
# book_orders.yml
alices_adventure_in_wonderland_in_books:
  author: lewis_carroll
  book_id: <%= ActiveRecord::FixtureSet.composite_identify(
              :alices_adventure_in_wonderland, Book.primary_key)[:id] %>
  shop: book_store
  order_id: <%= ActiveRecord::FixtureSet.composite_identify(
              :books, Order.primary_key)[:id] %>

On this page