There are several ways to implement such restrictions, and the best is to let the database handle hard constrains.
Option one
Considering you have two models, Timetable and Appointment, add available_slots
integer column to the Timetable model and decrease that number upon appointment creation and let the database raise an exception if that number goes below zero. In this case, Postgress will lock the column while updating it at the same time, preventing race conditions.
So Timetable could look like:
+----+--------------+--------------+-----------------+
| ID | time_from | time_to | available_slots |
+----+--------------+--------------+-----------------+
| 1 | '2020-03-21' | '2020-04-21' | 2 |
| 2 | '2020-04-22' | '2020-05-21' | 3 |
+----+--------------+--------------+-----------------+
In MySQL, you would make it an unsigned integer, but since Postgres doesn't support it, you have the option to add a positive number check constrain to the available_slots
column:
Pure SQL:
ALTER TABLE timetables ADD CONSTRAINT available_slots CHECK (available_slots > 0)
A migration will look like:
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
Add to Appointment model the logic that will decrease 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
Catch the exception from 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
Option two
Another way to do it is to add a new model, for example, AvailableSlot
that will belong to Appointment and Timetable, and each record in the table will represent an available slot.
For example, if Timetable with id 1
will have three available slots, the table will look like:
Timetable.find(1).available_slots
+----+---------------+
| ID | timetable_id |
+----+---------------+
| 1 | 1 |
| 2 | 1 |
| 3 | 1 |
+----+---------------+
Then add a unique index constrain to the available_slot_id
column in the appointments table:
add_index :appointments, :available_slot_id, unique: true
So every time you create an appointment and associate it with an available slot, the database will, through an exception, if there is a record with the same available_slot_id.
You will have to add logic to find an available slot. A raw example in Appointment model:
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
That code can be simplified if you add a status column to available_slots that will be changed when an appointment is created, but I leave it to you to figure that out.
These options are based on similar approaches that I've seen on a production Rails applications with a lot of concurrent transactions going on (millions per day) that could cause raise conditions.