AnnanFay
AnnanFay

Reputation: 9739

Searching for availability with MySQL (and PHP)?

I have two MySQL (MyIsAm) tables that represent letting units and bookings:

Where F_LU_ID is a foreign key to the unit.

What is the best way to search for units that are available during a certain time frame? The search is passed a Start, End and Duration.

I'd be interested to know if it's even possible to do this in MySQL, however if not then the best way to do it in PHP.

Example

In answer to the answers below I feel an example will help explain the problem.

A LettingUnit:

Some LettingUnitBookings:

If we search for:

Then we want the unit to show up. Because there is availability for a 5 day booking within the search range.

If the duration is 10 then the unit won't show up as there are no 10 consecutive unbooked days within the search range.

Upvotes: 3

Views: 578

Answers (2)

Bill Karwin
Bill Karwin

Reputation: 562230

Here's a solution that seems to work:

SELECT t.*, DATEDIFF(t.LatestAvailable, t.EarliestAvailable) AS LengthAvailable
FROM 
   (SELECT u.*,
      COALESCE(b1.End, @StartOfWindow) AS EarliestAvailable,
      COALESCE(b2.Start, @EndOfWindow) AS LatestAvailable
    FROM LettingUnits u
    LEFT OUTER JOIN LettingUnitBookings b1
      ON (u.ID = b1.F_LU_ID AND b1.End BETWEEN @StartOfWindow AND @EndOfWindow)
    LEFT OUTER JOIN LettingUnitBookings b2
      ON (u.ID = b2.F_LU_ID AND b2.Start BETWEEN @StartOfWindow AND @EndOfWindow
          AND b2.Start >= b1.End) -- edit: new term
    ) AS t
LEFT OUTER JOIN LettingUnitBookings x
  ON (t.ID = x.F_LU_ID AND x.Start < t.LatestAvailable AND x.End > t.EarliestAvailable)
WHERE x.ID IS NULL AND DATEDIFF(t.LatestAvailable, t.EarliestAvailable) >= @WindowSize;

The output is:

+-----+-------------+-------------------+-----------------+-----------------+
| ID  | Name        | EarliestAvailable | LatestAvailable | LengthAvailable |
+-----+-------------+-------------------+-----------------+-----------------+
| 123 | Foo Cottage | 2009-01-05        | 2009-01-10      |               5 |
| 123 | Foo Cottage | 2009-01-20        | 2009-01-25      |               5 |
| 456 | Bar Cottage | 2009-01-20        | 2009-01-31      |              11 |
+-----+-------------+-------------------+-----------------+-----------------+

Analyzing this with EXPLAIN shows that it employs indexes pretty well:

+----+-------------+------------+--------+---------------+---------+---------+-------+------+-------------------------+
| id | select_type | table      | type   | possible_keys | key     | key_len | ref   | rows | Extra                   |
+----+-------------+------------+--------+---------------+---------+---------+-------+------+-------------------------+
|  1 | PRIMARY     | <derived2> | ALL    | NULL          | NULL    | NULL    | NULL  |    9 | Using where             |
|  1 | PRIMARY     | x          | ref    | F_LU_ID       | F_LU_ID | 8       | t.ID  |    2 | Using where; Not exists |
|  2 | DERIVED     | u          | system | NULL          | NULL    | NULL    | NULL  |    1 |                         |
|  2 | DERIVED     | b1         | ref    | F_LU_ID       | F_LU_ID | 8       | const |    0 |                         |
|  2 | DERIVED     | b2         | ref    | F_LU_ID       | F_LU_ID | 8       | const |    0 |                         |
+----+-------------+------------+--------+---------------+---------+---------+-------+------+-------------------------+

Compare with the EXPLAIN report for the solution given by @martin clayton:

+----+--------------+---------------------+--------+---------------+---------+---------+------+------+---------------------------------+
| id | select_type  | table               | type   | possible_keys | key     | key_len | ref  | rows | Extra                           |
+----+--------------+---------------------+--------+---------------+---------+---------+------+------+---------------------------------+
|  1 | PRIMARY      | lu                  | system | PRIMARY,ID    | NULL    | NULL    | NULL |    1 |                                 |
|  1 | PRIMARY      | <derived2>          | ALL    | NULL          | NULL    | NULL    | NULL |    4 | Using where                     |
|  2 | DERIVED      | <derived3>          | ALL    | NULL          | NULL    | NULL    | NULL |    4 | Using temporary; Using filesort |
|  2 | DERIVED      | <derived5>          | ALL    | NULL          | NULL    | NULL    | NULL |    4 | Using where; Using join buffer  |
|  5 | DERIVED      | LettingUnitBookings | ALL    | NULL          | NULL    | NULL    | NULL |    3 |                                 |
|  6 | UNION        | LettingUnitBookings | index  | NULL          | F_LU_ID | 8       | NULL |    3 | Using index                     |
| NULL | UNION RESULT | <union5,6>          | ALL    | NULL          | NULL    | NULL    | NULL | NULL |                                 |
|  3 | DERIVED      | LettingUnitBookings | ALL    | NULL          | NULL    | NULL    | NULL |    3 |                                 |
|  4 | UNION        | LettingUnitBookings | index  | NULL          | F_LU_ID | 8       | NULL |    3 | Using index                     |
| NULL | UNION RESULT | <union3,4>          | ALL    | NULL          | NULL    | NULL    | NULL | NULL |                                 |
+----+--------------+---------------------+--------+---------------+---------+---------+------+------+---------------------------------+

In general, you want to avoid optimization plans that force Using filesort or Using temporary because these are performance killers. A query using GROUP BY is almost certain to cause this kind of optimization, at least in MySQL.

Upvotes: 2

martin clayton
martin clayton

Reputation: 78105

It's not pretty.

  • Join LettingUnitBookings to itself
  • Find the start and end of gaps between bookings for each F_LU_ID
  • Get the size of the gaps - the available 'slots'
  • Consider the case where there are no existing bookings that 'bracket' a suitable slot, add in outlier dates for this
  • Join that projection to the LettingUnits table and apply WHERE criteria (start, end, duration)

I've neglected to include BookingUnits that have no bookings at all.

Ends up looking like this:

SELECT @StartOfWindow := '2009-01-01',
       @EndOfWindow   := '2009-02-01',
       @WindowSize    := 5
;

SELECT
    lu.Name,
    Slots.*
FROM (
    SELECT
        lub1.F_LU_ID,
        DATE_ADD( MAX( lub2.date_time ), INTERVAL 1 DAY )     AS StartOfSlot,
        DATE_SUB( lub1.date_time, INTERVAL 1 DAY )            AS EndOfSlot,
        DATEDIFF( lub1.date_time, MAX( lub2.date_time ) ) - 1 AS AvailableDays
    FROM
    ( SELECT F_LU_ID, Start AS date_time FROM LettingUnitBookings
      UNION
      SELECT F_LU_ID, CAST( '9999-12-31' AS DATE ) FROM LettingUnitBookings
    ) AS lub1,
    ( SELECT F_LU_ID, End   AS date_time FROM LettingUnitBookings
      UNION
      SELECT F_LU_ID, CAST( '1000-01-01' AS DATE ) FROM LettingUnitBookings
    ) AS lub2
    WHERE
        lub2.date_time <= lub1.date_time
    AND lub2.F_LU_ID = lub1.F_LU_ID
    GROUP BY
        lub1.F_LU_ID,
        lub1.date_time
    ) Slots
JOIN LettingUnits lu
ON   lu.ID = Slots.F_LU_ID
WHERE
    Slots.AvailableDays >= @WindowSize
AND (
   (     DATEDIFF( Slots.EndOfSlot, @EndOfWindow )     >= @WindowSize
     AND DATEDIFF( @StartOfWindow, Slots.StartOfSlot ) >= @WindowSize
   )
   OR
   (     DATEDIFF( @EndOfWindow, Slots.StartOfSlot ) >= @WindowSize
     AND DATEDIFF( Slots.EndOfSlot, @StartOfWindow ) >= @WindowSize
   )
)

Gives

Name        F_LU_ID StartOfSlot EndOfSlot  AvailableDays
Foo Cottage 123     2009-01-06  2009-01-09 5
Foo Cottage 123     2009-01-21  2009-01-24 5

Hopefully that can be adapted to suit your needs.

Alternatively, if a booking can start on the same day that the previous booking ends, you can adapt slightly...

SELECT
    lu.Name,
    Slots.*
FROM (
    SELECT
        lub1.F_LU_ID,
        MAX( lub2.date_time ) AS StartOfSlot,
        lub1.date_time        AS EndOfSlot,
        DATEDIFF( lub1.date_time, MAX( lub2.date_time )) AS AvailableDays
    FROM
    ( SELECT F_LU_ID, Start AS date_time FROM LettingUnitBookings
      UNION
      SELECT F_LU_ID, CAST( '9999-12-31' AS DATE ) FROM LettingUnitBookings
    ) AS lub1,
    ( SELECT F_LU_ID, End   AS date_time FROM LettingUnitBookings
      UNION
      SELECT F_LU_ID, CAST( '1000-01-01' AS DATE ) FROM LettingUnitBookings
    ) AS lub2
    WHERE
        lub2.date_time <= lub1.date_time
    AND lub2.F_LU_ID = lub1.F_LU_ID
    GROUP BY
        lub1.F_LU_ID,
        lub1.date_time
    ) Slots
JOIN LettingUnits lu
ON   lu.ID = Slots.F_LU_ID
WHERE
    Slots.AvailableDays >= @WindowSize
AND
   (     DATEDIFF( Slots.EndOfSlot, @EndOfWindow )     >= @WindowSize
     AND DATEDIFF( @StartOfWindow, Slots.StartOfSlot ) >= @WindowSize
   )
   OR
   (     DATEDIFF( @EndOfWindow, Slots.StartOfSlot ) >= @WindowSize
     AND DATEDIFF( Slots.EndOfSlot, @StartOfWindow ) >= @WindowSize
   )

Upvotes: 1

Related Questions