0

I've built a RoR app and implemented a simple booking system. The user is able to look for a space and can book it per day or per hour. Everything works well, but I would now like to make the user able to look for a space depending on its availability. I want to user to be able to select a start/end date and a start/end time and to show only spaces that don't have any booking included in this period.

I am using pg search at the moment to look for a space by category and location, but I have no idea how to implement a search by date and time, as it uses a different logic. I've tried to do it by hand by creating an array of bookings for each space so I could compare it with the params, but it sounded very complicated and not clean (and I started being stuck anyway, as making it available for one hour or several hours or several days makes it even more complicated)

Is there a gem that could do this for me? If I have to do it by hand, what's the best way to begin?

Thanks a lot

Jessicascn
  • 151
  • 1
  • 13

2 Answers2

4

Just create an instance method available? which tests there are no bookings that overlap the from to range. You can use none? on the relationship.

class Space
  has_many :bookings
  def available?(from, to)
    bookings.where('start_booking <= ? AND end_booking >= ?', to, from).none?
  end
end
SteveTurczyn
  • 36,057
  • 6
  • 41
  • 53
  • Yes, I was struggling with that. @3limin4t0r ... I didn't want to say `empty?` and the correct expression escaped me. – SteveTurczyn Sep 18 '19 at 13:45
  • I think it would be better to use the exists? method though, as the SQL query is better performing and not loading the whole record. – Uelb Sep 18 '19 at 14:44
  • 1
    @Uelb yes, you're right. My lazy research made me think `none?` was a SQL query but it's an enumerator. – SteveTurczyn Sep 18 '19 at 15:40
  • @SteveTurczyn `records.none?` **without a block** performs the same query as `!records.exists?` If you check the source of [`none?`](https://api.rubyonrails.org/classes/ActiveRecord/Relation.html#method-i-none-3F) you find that it simply forwards to [`empty?`](https://api.rubyonrails.org/classes/ActiveRecord/Relation.html#method-i-empty-3F) which in turn calls `!exists?` if the current collection is not yet loaded. If you do provide a block to `#none?` the *Enumerable* version is used (loading all record data). – 3limin4t0r Sep 18 '19 at 17:02
  • @3limin4t0r ha! I'll change it again, before I lose the will to live. – SteveTurczyn Sep 19 '19 at 10:51
  • @SteveTurczyn Just posted that bit of info to counter misinformation. Without the intent that you update the question, but I guess you've already done so. The reason I know the info is because I previously looked it up for a Rails Style Guide [issue](https://github.com/rubocop-hq/rails-style-guide/issues/232#issuecomment-425848425). – 3limin4t0r Sep 19 '19 at 12:06
  • @3limin4t0r I'm not really complaining. I used it yesterday in some actual real world code (where previously I wouldv'e done negation on `records.exists?` Looks cleaner. – SteveTurczyn Sep 20 '19 at 07:08
1

Taking some inspiration from the answer of SteveTurczyn. The following might give you some inspiration.

class Space < ApplicationRecord
  # attributes: id
  has_many :bookings

  def self.available(period)
    bookings = Booking.overlap(period)
    where.not(id: bookings.select(:space_id))
  end

  def available?(period)
    if bookings.loaded?
      bookings.none? { |booking| booking.overlap?(period) }
    else
      bookings.overlap(period).none?
    end
  end
end

class Booking < ApplicationRecord
  # attributes: id, space_id, start, end
  belongs_to :space

  def self.overlap(period)
    period = FormatConverters.to_period(period)

    # lteq = less than or equal to, gteq = greater than or equal to
    # Other methods available on attributes can be found here:
    # https://www.rubydoc.info/gems/arel/Arel/Attributes/Attribute
    where(arel_table[:start].lteq(period.end).and(arel_table[:end].gteq(period.start)))
  end

  def overlap?(period)
    period = FormatConverters.to_period(period)
    self.start <= period.end && self.end >= period.start
  end

  module FormatConverters

    module_function

    def to_period(obj)
      return obj if obj.respond_to?(:start) && obj.respond_to?(:end)
      obj..obj
    end
  end
end

With the above implemented you can query a single space if it is available during a period:

from   = Time.new(2019, 10, 1, 9, 30)
to     = Time.new(2019, 10, 5, 17, 30)
period = from..to
space.available?(period) # true/false

You can get all spaces available:

spaces = Space.available(period) # all available spaces during the period

Note that class methods will also be available on the scope chain:

spaces = Space.scope_01.scope_02.available(period)

I've also added the overlap scope and overlap? helper to simplify creating the above helpers.

Since in my version Booking has a start and end attribute (similar to Range) you can also provide it to any methods accepting a period.

booking_01.overlap?(booking_02) # true/false

To retrieve all bookings that that overlap this very moment:

bookings = Booking.overlap(Time.now) # all bookings overlapping the period

Hope this gave you some inspiration. If you'd like to know how the overlap checking works I have to forward you to this question.

Note: This answer assumes that the provided period is valid. A.k.a. start <= end. If you for some reason provide Time.new(2019, 10, 1)..Time.new(2019, 9, 23) the results are going to be skewed.

3limin4t0r
  • 19,353
  • 2
  • 31
  • 52