Как контролировать нефиксированное количество элементов в отношении в Rails?

Я пишу приложение, которое управляет встречами с различными службами. «Вместимость» каждой службы определяется одним или несколькими расписаниями, а это означает, что у службы А может быть 2 «стола» с 1 по 30 июня, а с 1 июля по 31 августа — только 1, поэтому я могу создать 2 встречи на 2020 год. 03.06 9:00, но только 1 вместо 03.07.2020 9:00. Все смоделировано правильно, и у меня есть собственный валидатор для встреч при создании, который проверяет кардинальность, но этого недостаточно, чтобы два пользователя не создавали последнюю доступную встречу одновременно, не так ли?

Как я могу контролировать правильную кардинальность такого отношения, не блокируя всю таблицу Назначений?

Создание встречи выполняется в одном месте и только в одном месте в коде, в Appointment.create_appointment(params) , есть ли способ сделать этот метод заблокированным в рельсах?


person Carlos Matesanz    schedule 01.06.2020    source источник
comment
Вы правы в том, что проверка Rails не предотвратит вставку повторяющихся данных, если возникнет состояние гонки. Возможно, это можно решить, добавив ограничение в базу данных. Какую СУБД вы используете?   -  person max    schedule 02.06.2020
comment
постгрескл. Прямо сейчас я решил эту проблему с помощью ActiveRecord::Base.connection.execute('БЛОКИРОВКА ТАБЛИЦ В ЭКСКЛЮЗИВНОМ РЕЖИМЕ'), но я чувствую, что блокировка всей таблицы не должна быть обязательной.   -  person Carlos Matesanz    schedule 02.06.2020
comment
Ты прав. Решение, вероятно, состоит в том, чтобы написать функцию базы данных (PLpgSQL), которую можно вызывать из ограничения. Возможно, я смогу помочь вам в дальнейшем, если вы предоставите пример схемы и желаемого ввода/вывода, чтобы я мог что-то запустить, не заполняя все пробелы предположениями. severalnines.com/database-blog/   -  person max    schedule 02.06.2020
comment
это очень любезно, Макс, но не влезайте в эту проблему, я бы предпочел не использовать функции БД, поскольку они часто вызывают боль при обслуживании. Я надеялся на решение, ориентированное на рельсы.   -  person Carlos Matesanz    schedule 02.06.2020
comment
Извините, но это дурацкая затея, поскольку любое решение уровня приложения (Rails) по-прежнему будет подвержено условиям гонки, поскольку у вас есть несколько веб-процессов, одновременно взаимодействующих с сервером базы данных. Эта статья довольно хорошо объясняет это thoughtbot.com/blog/the-perils-of- проверки уникальности   -  person max    schedule 02.06.2020
comment
спасибо, прочитаю как можно скорее.   -  person Carlos Matesanz    schedule 02.06.2020


Ответы (1)


Есть несколько способов реализации таких ограничений, и лучший из них — позволить базе данных обрабатывать жесткие ограничения.

Вариант первый

Учитывая, что у вас есть две модели, Расписание и Назначение, добавьте целочисленный столбец available_slots в модель Расписание и уменьшите это число при создании встречи, и пусть база данных вызовет исключение, если это число станет меньше нуля. В этом случае Postgress заблокирует столбец, одновременно обновляя его, предотвращая состояние гонки.

Итак, расписание может выглядеть так:

+----+--------------+--------------+-----------------+
| ID |  time_from   |   time_to    | available_slots |
+----+--------------+--------------+-----------------+
| 1  | '2020-03-21' | '2020-04-21' |               2 |
| 2  | '2020-04-22' | '2020-05-21' |               3 |
+----+--------------+--------------+-----------------+

В MySQL вы бы сделали это целым числом без знака, но, поскольку Postgres не поддерживает его, у вас есть возможность добавить ограничение проверки положительного числа в столбец available_slots:

Чистый SQL:

ALTER TABLE timetables ADD CONSTRAINT available_slots CHECK (available_slots > 0)

Миграция будет выглядеть так:

class AddPositiveConstraintToTimetable < ActiveRecord::Migration[6.0]
  def self.up
    execute "ALTER TABLE timetables ADD CONSTRAINT available_slots CHECK (available_slots > 0)"
  end

  def self.down
    execute "ALTER TABLE timetables DROP CONSTRAINT available_slots."
  end
end

Добавьте в модель Appointment логику, которая уменьшит available_slots:

belongs_to :timetable
before_create :decrease_slots

def decrease_slots
  # this will through an exception from the database
  # in case if available_slots are already 0
  # that will prevent the instance from being created.
  
  timetable.decrement!(:available_slots)
end

Перехватите исключение из AppointmentsController:

def create
  @appointment = Appointment.new(appointment_params)

  # here will be probably some logic to find out the timetable
  # based on the time provided by the user (or maybe it's in the model).

  if @appointment.save
    redirect_to @appointment, notice: 'Appointment was successfully created.'
  else
    render :new
  end
end

Вариант второй

Другой способ сделать это — добавить новую модель, например, AvailableSlot, которая будет принадлежать «Назначению» и «Расписанию», и каждая запись в таблице будет представлять доступный слот.

Например, если в расписании с идентификатором 1 будет три свободных слота, таблица будет выглядеть так:

Timetable.find(1).available_slots

+----+---------------+
| ID |  timetable_id |
+----+---------------+
| 1  | 1             |
| 2  | 1             |
| 3  | 1             |
+----+---------------+

Затем добавьте ограничение уникального индекса в столбец available_slot_id в таблице назначений:

add_index :appointments, :available_slot_id, unique: true

Таким образом, каждый раз, когда вы создаете встречу и связываете ее с доступным слотом, база данных будет через исключение, если есть запись с таким же available_slot_id.

Вам нужно будет добавить логику, чтобы найти доступный слот. Необработанный пример в модели назначения:

before_create :find_available_slot

def find_available_slot
  # first find a timetable
  timetable = Timetable.where("time_from >= ? AND time_to <= ?", appointment_time, appointment_time).first
  
  # then check if there are available slots
  taken_slots = Appintment.where(timetable.id: timetable.id).size
  all_slots = timetable.available_slots.size

  raise "no available slots" unless (all_slots - taken_slots).positive?

  # huzzah! there are slots, lets take the last one
  self.available_slot = timetable.available_slots.last
end

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


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

person Aleksander Lopez    schedule 03.06.2020