We have a MySQL database containing bookings on different courts. Table properties (shortened):
CREATE TABLE `booking` (
`startDate` datetime NOT NULL,
`endDate` datetime NOT NULL,
`courtId` varchar(36),
FOREIGN KEY (`courtId`) REFERENCES `court` (`id`) ON DELETE CASCADE
)
Usually, bookings are paid, but under certain conditions (which I can check in the WHERE-part of a query), bookings can be free.
Given a court and booking duration, I want to query the next datetime at which the booking can be created for free. The conditions are not the problem, the problem is how to query not for entities but for datetime values.
How to realize this efficiently in MySQL?
EDIT: Maybe it helps to outline the conditions under which bookings are free:
The conditions under which bookings are free are dependent on how many courts are offered at the startDate by someone (courts are always offered except if there are special "not-offered"-bookings on that court) and how many other bookings overlapping the startDate are already free. This means bookings can be (and probably are) free even if there are no bookings at all in the database.
Solution
Finding available slot before the last booking :
Find the difference between each booking with it's following one. If the difference is greater than the number of days of the new booking, you can use that slot.
Finding available slot after the last booking :
If there is no such slot, you can assign a day after the end date of the last booking.
If this query returns null, it means there is no booking for the court. You can handle that in the client side.
Code
SET #c := 1; # Court id
SET #n := 2; # Number of days
/*
Previous booking
*/
SET #i := 0;
CREATE TEMPORARY TABLE bp AS
SELECT #i := #i + 1 AS id, startDate, endDate FROM booking
WHERE courtId = #c
ORDER BY startDate;
/*
Next booking
*/
SET #i := -1;
CREATE TEMPORARY TABLE bn AS
SELECT #i := #i + 1 AS id, startDate, endDate FROM booking
WHERE courtId = #c
ORDER BY startDate;
/*
Finding available slot before the last booking (Intermediate slot).
*/
SELECT DATE_ADD(MIN(bp.endDate), INTERVAL 1 DAY) INTO #si FROM
bp
JOIN
bn
ON bn.id = bp.id
WHERE DATEDIFF(bn.startDate, bp.endDate) > #n;
/*
Finding available slot after the last booking
*/
SELECT DATE_ADD(MAX(endDate), INTERVAL 1 DAY) INTO #sa FROM bn;
SELECT IFNULL(#si, #sa);
Using the code
Just replace the values of the variables #c and #n.
An idea to solve this is to rephrase it as: for the given :court_id parameter, give me the smallest future end_time for which no other booking starts within the given :duration parameter.
This can be expressed in different ways in SQL.
With a not exists condition and a correlated subquery that ensures that no further booking on the same court starts within :duration minutes.
select min(b.end_date) next_possible_start_date
from bookings b
where
b.court_id = :court_id
and b.end_date > now()
and not exists (
select 1
from bookings b1
where
b.court_id = :court_id
and b1.start_date > b.end_date
and b1.start_date < DATE_ADD(b.end_date, interval :duration minute)
)
Note: if you have additional conditions, they must be repeated in the where clause of the query and of the subquery.
The same logic as not exists can be impemented with a left join antipattern
select min(b.end_date) next_possible_start_date
from bookings b
left join booking b1
on b1.court_id = b.court_id
and bi1.start_date > b.end_date
and b1.start < DATE_ADD(b.end_date, interval :duration minute)
where
b.court_id = :court_id
and b.end_date > now()
and b1.court_id is null
In MySQL 8.0, it is also possible to use window functions: lag() retrieves the start_date of the next booking, which can then be compared with the end_date of the current booking.
select min(end_date) next_possible_start_date
from (
select
end_date,
lead(start_date) over(partition by court_id order by start_date) next_start_date
from booking b
where court_id = :court_id
)
where
next_start_date is null
or next_start_date >= DATE_ADD(end_date, interval :duration minute)
Edit
Here is a new version of the query that adresses the use case when the court is immediatly free at the time when the search is performed:
select
court_id,
greatest(min(b.end_date), now()) next_possible_start_date
from bookings b
where
-- b.court_id = :court_id and
not exists (
select 1
from bookings b1
where
b1.court_id = b.court_id
and b1.start_date > b.end_date
and b1.start_date < date_add(greatest(b.end_date, now()), interval ::duration minute)
)
group by court_id
Note: this searches for all available courts at once; you can uncomment the where clause to filter on a specific court.
Given this sample data:
court_id | start_date | end_date
-------: | :------------------ | :------------------
1 | 2019-10-29 13:00:00 | 2019-10-29 13:30:00
1 | 2019-10-29 14:00:00 | 2019-10-29 15:00:00
2 | 2019-10-29 23:14:05 | 2019-10-30 00:14:05
2 | 2019-10-30 01:14:05 | 2019-10-30 02:14:05
Court 1 is immedialty free. Court 2 is booked for next hour, then there is a 60 minutes vacancy before the next booking.
If we run the query for a duration of 60 minutes, we get:
court_id | next_possible_start_date
-------: | :-----------------------
1 | 2019-10-29 23:14:05 -- available right now
2 | 2019-10-30 00:14:05 -- available in 1 hour
While for 90 minutes, we get:
court_id | next_possible_start_date
-------: | :-----------------------
1 | 2019-10-29 23:14:05 -- available right now
2 | 2019-10-30 02:14:05 -- available in 3 hours
Demo on DB Fiddle
I'm logging statistics of the gamers in my community. For both their online and in-game states I'm registering when they "begin" and when they "end". In order to show the most active day and hour of the day I'd like to use an SQL statement that measures the most active moments based on the "begin" and "end" datetime values.
Looking at SQL - select most 'active' time from db I can see similarities, but I need to also include the moments between the start and end time.
Perhaps the easiest way is to write a cron that does the calculations, but I hope this question might teach me how to address this issue in SQL instead.
I've been searching for an SQL statement that allows to create a datetime period and use that to substract single hours and days. But to no avail.
--- update
As I'm thinking more about this, I'm wondering whether it might be wise to run 24 queries based on each hour of the day (for most active hour) and several queries for the most active day. But that seems like a waste of performance. But this solution might make a query possible like:
SELECT COUNT(`userID`), DATE_FORMAT("%H",started) AS starthour,
DATE_FORMAT("%H",ended) AS endhour
FROM gameactivity
WHERE starthour >= $hour
AND endhour <= $hour GROUP BY `userID`
($hour is added for example purposes, of course I'm using PDO. Columns are also just for example purposes, whatever you think is easy for you to use in explaining that is identifiable as start and end is ok with me)
Additional information; PHP 5.5+, PDO, MySQL 5+
Table layout for ingame would be: gameactivity: activityid, userid, gameid, started, ended
DDL:
CREATE TABLE IF NOT EXISTS `steamonlineactivity` (
`activityID` int(13) NOT NULL AUTO_INCREMENT,
`userID` varchar(255) NOT NULL,
`online` datetime DEFAULT NULL,
`offline` datetime DEFAULT NULL,
PRIMARY KEY (`activityID`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 AUTO_INCREMENT=1;
If I understood your requirements correctly, if this graph represents user activity:
Day
12/1 12/2 12/3 12/4 ...
Hour 0 xx x x xx
1 x xx xx
2 xxx x x xx
3 x x
4 x x
5 x x
6 x
...
You want to know that 02:00 is the time of the day with the highest average activity (a row with 7 x), and 12/4 was most active day (a column with 10 x). Note that this doesn't imply that 02:00 of 12/4 was the most active hour ever, as you can see in the example. If this is not what you want please clarify with concrete examples of input and desired result.
We make a couple assumptions:
An activity record can start on one date and finish on the next one. For instance: online 2013-12-02 23:35, offline 2013-12-03 00:13.
No activity record has a duration longer than 23 hours, or the number of such records is negligible.
And we need to define what does 'activity' mean. I picked the criteria that were easier to compute in each case. Both can be made more accurate if needed, at the cost of having more complex queries.
The most active time of day will be the hour with which more activity records overlap. Note that if a user starts and stops more than once during the hour it will be counted more than once.
The most active day will be the one for which there were more unique users that were active at any time of the day.
For the most active time of day we'll use a small auxiliary table holding the 24 possible hours. It can also be generated and joined on the fly with the techniques described in other answers.
CREATE TABLE hour ( hour tinyint not null, primary key(hour) );
INSERT hour (hour)
VALUES (0), (1), (2), (3), (4), (5), (6), (7), (8), (9), (10)
, (11), (12), (13), (14), (15), (16), (17), (18), (19), (20)
, (21), (22), (23);
Then the following queries give the required results:
SELECT hour, count(*) AS activity
FROM steamonlineactivity, hour
WHERE ( hour BETWEEN hour(online) AND hour(offline)
OR hour(online) BETWEEN hour(offline) AND hour
OR hour(offline) BETWEEN hour AND hour(online) )
GROUP BY hour
ORDER BY activity DESC;
SELECT date, count(DISTINCT userID) AS activity
FROM (
SELECT userID, date(online) AS date
FROM steamonlineactivity
UNION
SELECT userID, date(offline) AS date
FROM steamonlineactivity
) AS x
GROUP BY date
ORDER BY activity DESC;
You need a sequence to get values for hours where there was no activity (e.g. hours where nobody starting or finishing, but there were people on-line who had started but had not finished in that time). Unfortunately there is no nice way to create a sequence in MySQL so you will have to create the sequence manually;
CREATE TABLE `hour_sequence` (
`ID` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`hour` datetime NOT NULL,
KEY (`hour`),
PRIMARY KEY (`ID`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1;
# this is not great
INSERT INTO `hour_sequence` (`hour`) VALUES
("2013-12-01 00:00:00"),
("2013-12-01 01:00:00"),
("2013-12-01 02:00:00"),
("2013-12-01 03:00:00"),
("2013-12-01 04:00:00"),
("2013-12-01 05:00:00"),
("2013-12-01 06:00:00"),
("2013-12-01 07:00:00"),
("2013-12-01 08:00:00"),
("2013-12-01 09:00:00"),
("2013-12-01 10:00:00"),
("2013-12-01 11:00:00"),
("2013-12-01 12:00:00");
Now create some test data
CREATE TABLE `log_table` (
`ID` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`userID` bigint(20) unsigned NOT NULL,
`started` datetime NOT NULL,
`finished` datetime NOT NULL,
KEY (`started`),
KEY (`finished`),
PRIMARY KEY (`ID`)
) ENGINE=InnoDB DEFAULT CHARSET latin1;
INSERT INTO `log_table` (`userID`,`started`,`finished`) VALUES
(1, "2013-12-01 00:00:12", "2013-12-01 02:25:00"),
(2, "2013-12-01 07:25:00", "2013-12-01 08:23:00"),
(1, "2013-12-01 04:25:00", "2013-12-01 07:23:00");
Now the query - for every hour we keep a tally (accumulation/running total/integral etc) of how many people started a session hour-on-hour
SELECT
HS.hour as period_starting,
COUNT(LT.userID) AS starts
FROM `hour_sequence` HS
LEFT JOIN `log_table` LT ON HS.hour > LT.started
GROUP BY
HS.hour
And also how many people went off-line likewise
SELECT
HS.hour as period_starting,
COUNT(LT.userID) AS finishes
FROM `hour_sequence` HS
LEFT JOIN `log_table` LT ON HS.hour > LT.finished
GROUP BY
HS.hour
By subtracting the accumulation of people that had gone off-line at a point in time from the accumulation of people that have come on-line at that point in time we get the number of people who were on-line at that point in time (presuming there were zero people on-line when the data starts, of course).
SELECT
starts.period_starting,
starts.starts as users_started,
finishes.finishes as users_finished,
starts.starts - finishes.finishes as users_online
FROM
(
SELECT
HS.hour as period_starting,
COUNT(LT.userID) AS starts
FROM `hour_sequence` HS
LEFT JOIN `log_table` LT ON HS.hour > LT.started
GROUP BY
HS.hour
) starts
LEFT JOIN (
SELECT
HS.hour as period_starting,
COUNT(LT.userID) AS finishes
FROM `hour_sequence` HS
LEFT JOIN `log_table` LT ON HS.hour > LT.finished
GROUP BY
HS.hour
) finishes ON starts.period_starting = finishes.period_starting;
Now a few caveats. First of all you will need a process to keep your sequence table populated with the hourly timestamps as time progresses. Additionally the accumulators do not scale well with large amounts of log data due to the tenuous join - it would be wise to constrain access to the log table by timestamp in both the starts and finishes subquery, and the sequence table while you are at it.
SELECT
HS.hour as period_starting,
COUNT(LT.userID) AS finishes
FROM `hour_sequence` HS
LEFT JOIN `log_table` LT ON HS.hour > LT.finished
WHERE
LT.finished BETWEEN ? AND ? AND HS.hour BETWEEN ? AND ?
GROUP BY
HS.hour
If you start constraining your log_table data to specific time ranges bear in mind you will have an offset issue if, at the point you start looking at the log data, there were already people on-line. If there were 1000 people on-line at the point where you start looking at your log data then you threw them all off the server from the query it would look like we went from 0 people on-line to -1000 people on-line!
#rsanchez had an amazing answer, but the query for most active time of day has a weird behaviour when handling session times that started and ended on the same hour (a short session). The query seems to calculate them to last for 24 hours.
With trial and error I corrected his query from that part to be following
SELECT hour, count(*) AS activity
FROM steamonlineactivity, hour
WHERE ( hour >= HOUR(online) AND hour <= HOUR(offline)
OR HOUR(online) > HOUR(offline) AND HOUR(online) <= hour
OR HOUR(offline) >= hour AND HOUR(offline) < HOUR(online) )
GROUP BY hour
ORDER BY activity DESC;
So with following structure:
CREATE TABLE hour ( hour tinyint not null, primary key(hour) );
INSERT hour (hour)
VALUES (0), (1), (2), (3), (4), (5), (6), (7), (8), (9), (10)
, (11), (12), (13), (14), (15), (16), (17), (18), (19), (20)
, (21), (22), (23);
CREATE TABLE `steamonlineactivity` (
`activityID` int(13) NOT NULL AUTO_INCREMENT,
`userID` varchar(255) NOT NULL,
`online` datetime DEFAULT NULL,
`offline` datetime DEFAULT NULL,
PRIMARY KEY (`activityID`)
);
INSERT INTO `steamonlineactivity` (`activityID`, `userID`, `online`, `offline`) VALUES
(1, '1', '2014-01-01 16:01:00', '2014-01-01 19:01:00'),
(2, '2', '2014-01-02 16:01:00', '2014-01-02 19:01:00'),
(3, '3', '2014-01-01 22:01:00', '2014-01-02 02:01:00'),
(4, '4', '2014-01-01 16:01:00', '2014-01-01 16:05:00');
The top query to get the most active times output following:
+------+----------+
| hour | activity |
+------+----------+
| 16 | 3 |
| 17 | 2 |
| 18 | 2 |
| 19 | 2 |
| 22 | 1 |
| 23 | 1 |
| 0 | 1 |
| 1 | 1 |
| 2 | 1 |
+------+----------+
Instead of the original query which gives following erronous result:
+------+----------+
| hour | activity |
+------+----------+
| 16 | 3 |
| 17 | 3 |
| 18 | 3 |
| 19 | 3 |
| 0 | 2 |
| 1 | 2 |
| 2 | 2 |
| 22 | 2 |
| 23 | 2 |
| 11 | 1 |
| 12 | 1 |
| 13 | 1 |
| 14 | 1 |
| 15 | 1 |
| 3 | 1 |
| 4 | 1 |
| 20 | 1 |
| 5 | 1 |
| 21 | 1 |
| 6 | 1 |
| 7 | 1 |
| 8 | 1 |
| 9 | 1 |
| 10 | 1 |
+------+----------+
This query is for oracle, but you can get idea from it:
SELECT
H, M,
COUNT(BEGIN)
FROM
-- temporary table that should return numbers from 0 to 1439
-- each number represents minute of the day, for example 0 represents 0:00, 100 represents 1:40, etc.
-- in oracle you can use CONNECT BY clause which is designated to do recursive queries
(SELECT LEVEL - 1 DAYMIN, FLOOR((LEVEL - 1) / 60) H, MOD((LEVEL - 1), 60) M FROM dual CONNECT BY LEVEL <= 1440) T LEFT JOIN
-- join stats to each row from T by converting discarding date and converting time to minute of a day
STATS S ON 60 * TO_NUMBER(TO_CHAR(S.BEGIN, 'HH24')) + TO_NUMBER(TO_CHAR(S.BEGIN, 'MI')) <= T.DAYMIN AND
60 * TO_NUMBER(TO_CHAR(S.END, 'HH24')) + TO_NUMBER(TO_CHAR(S.END, 'MI')) > T.DAYMIN
GROUP BY H, M
HAVING COUNT(BEGIN) > 0
ORDER BY H, M
GROUP BY H, M
HAVING COUNT(BEGIN) > 0
ORDER BY H, M
Fiddle: http://sqlfiddle.com/#!4/e5e31/9
The idea is to have some temp table or view with one row for time point, and left join to it. In my example there is one row for every minute in day. In mysql you can use variables to create such view on-the-fly.
MySQL version:
SELECT
FLOOR(T.DAYMIN / 60), -- hour
MOD(T.DAYMIN, 60), -- minute
-- T.DAYMIN, -- minute of the day
COUNT(S.BEGIN) -- count not null stats
FROM
-- temporary table that should return numbers from 0 to 1439
-- each number represents minute of the day, for example 0 represents 0:00, 100 represents 1:40, etc.
-- in mysql you must have some table which has at least 1440 rows;
-- I use (INFORMATION_SCHEMA.COLLATIONSxINFORMATION_SCHEMA.COLLATIONS) for that purpose - it should be
-- in every database
(
SELECT
#counter := #counter + 1 AS DAYMIN
FROM
INFORMATION_SCHEMA.COLLATIONS A CROSS JOIN
INFORMATION_SCHEMA.COLLATIONS B CROSS JOIN
(SELECT #counter := -1) C
LIMIT 1440
) T LEFT JOIN
-- join stats to each row from T by converting discarding date and converting time to minute of a day
STATS S ON (
(60 * DATE_FORMAT(S.BEGIN, '%H')) + (1 * DATE_FORMAT(S.BEGIN, '%i')) <= T.DAYMIN AND
(60 * DATE_FORMAT(S.END, '%H')) + (1 * DATE_FORMAT(S.END, '%i')) > T.DAYMIN
)
GROUP BY T.DAYMIN
HAVING COUNT(S.BEGIN) > 0 -- filter empty counters
ORDER BY T.DAYMIN
Fiddle: http://sqlfiddle.com/#!2/de01c/1
I've been overthinking this question myself and based on everyone's answers I think it's obvious to conclude with the following;
In general it's probably easy to implement some kind of separate table that has the hours of the day and do inner selects from that separate table. Other examples without a separate table have many sub selects, even with four tiers, which makes me believe they will probably not scale. Cron solutions have come to my mind as well, but the question was asked - out of curiosity - to focus on SQL queries and not other solutions.
In my own case and completely outside the scope of my own question, I believe the best solution is to create a separate table with two fields (hour [Y-m-d H], onlinecount, playingcount) that counts the number of people online at a certain hour and the people playing at a certain hour. When a player stops playing or goes offline we update the count (+1) based on the start and end times. Thus I can easily deduce tables and graphs from this separate table.
Please, let me know whether you come to the same conclusion. My thanks to #lolo, #rsanchez and #abasterfield. I wish I could split the bounty :)
sqlFiddle, this query will give you the period that has the most userCount, the period could be between anytime, it just gives you the start time and end time that has the most userCount
SELECT StartTime,EndTime,COUNT(*)as UserCount FROM
(
SELECT T3.StartTime,T3.EndTime,GA.Started,GA.Ended FROM
(SELECT starttime,(SELECT MIN(endtime) FROM
(SELECT DISTINCT started as endtime FROM gameactivity WHERE started BETWEEN '1970-01-01 00:00:00' AND '1970-01-01 23:59:59'
UNION
SELECT DISTINCT ended as endtime FROM gameactivity WHERE ended BETWEEN '1970-01-01 00:00:00' AND '1970-01-01 23:59:59'
)T1
WHERE T1.endtime > T2.starttime
)as endtime
FROM
(SELECT DISTINCT started as starttime FROM gameactivity WHERE started BETWEEN '1970-01-01 00:00:00' AND '1970-01-01 23:59:59'
UNION
SELECT DISTINCT ended as starttime FROM gameactivity WHERE ended BETWEEN '1970-01-01 00:00:00' AND '1970-01-01 23:59:59'
)T2
)T3,
GameActivity GA
WHERE T3.StartTime BETWEEN GA.Started AND GA.Ended
AND T3.EndTime BETWEEN GA.Started AND GA.Ended
)FinalTable
GROUP BY StartTime,EndTime
ORDER BY UserCount DESC
LIMIT 1
just change the date of '1970-01-01' occurences to the date you're trying to get data from.
What the query does it selects all the times in the inner queries and then create intervals out of them, then join with GameActivity and count occurrences of users within those intervals and return the interval with the most userCount(most activity).
here's an sqlFiddle with one less tier
SELECT StartTime,EndTime,COUNT(*)as UserCount FROM
(
SELECT T3.StartTime,T3.EndTime,GA.Started,GA.Ended FROM
(SELECT DISTINCT started as starttime,(SELECT MIN(ended)as endtime FROM
gameactivity T1 WHERE ended BETWEEN '1970-01-01 00:00:00' AND '1970-01-01 23:59:59'
AND T1.ended > T2.started
)as endtime
FROM
gameactivity T2
WHERE started BETWEEN '1970-01-01 00:00:00' AND '1970-01-01 23:59:59'
)T3,
GameActivity GA
WHERE T3.StartTime BETWEEN GA.Started AND GA.Ended
AND T3.EndTime BETWEEN GA.Started AND GA.Ended
)FinalTable
GROUP BY StartTime,EndTime
ORDER BY UserCount DESC
LIMIT 1
or according to your query in your question above, you don't seem to care about dates, but only hour statistics across all dates then the below query might do it (your query just looks at the HOUR of started and ended and ignore users that play longer than 1 hour.
the below query might do it for you sqlFiddle
SELECT COUNT(*) as UserCount,
HOURSTABLE.StartHour,
HOURSTABLE.EndHour
FROM
(SELECT #hour as StartHour,
#hour:=#hour + 1 as EndHour
FROM
gameActivity as OrAnyTableWith24RowsOrMore,
(SELECT #hour:=0)as InitialValue
LIMIT 24) as HOURSTABLE,
gameActivity GA
WHERE HOUR(GA.started) >= HOURSTABLE.StartHour
AND HOUR(GA.ended) <= HOURSTABLE.EndHour
GROUP BY HOURSTABLE.StartHour,HOURSTABLE.EndHour
ORDER BY UserCount DESC
LIMIT 1
just delete the LIMIT 1 if you want to see userCount for other hours as well.
The easiest solution is to run a cron at the top of each hour of who has a start time but no end time (null end time? if you reset it when they login) and log that count. This will give you a count of currently logged in at each hour without needing to do funky schema changes or wild queries.
Now when you check the next hour and they had logged out they would fall out of your results. This query would work if you reset end time when they login.
SELECT CONCAT(CURDATE(), ' ', HOUR(NOW()), ' ', COUNT(*)) FROM activity WHERE DATE(start) = CURDATE() AND end IS NULL;
Then you can log this at your hearts content to a file or to another table (Of course you might need to adjust the select per your log table). For example you can have a table that gets one entry per day and only gets updated once.
Assume a log table like:
current_date | peak_hour | peak_count
SELECT IF(peak_count< $peak_count, true, false) FROM log where DATE(current_date) = NOW();
where $peak_count is a variable coming from your cron. If you find that you have a new bigger peak count you do an update, if the record does not exist for the day do an insert into log. Otherwise, no you have not beat a peak_hour from earlier in the day, don't do an update. This means each day will give you only 1 row in your table. Then you don't need to do any aggregation, it is all right there for you to see the date and hour over the course of a week or month or whatever.
A rental system uses a booking table to store all bookings and reservations:
booking | item | startdate | enddate
1 | 42 | 2013-10-25 16:00 | 2013-10-27 12:00
2 | 42 | 2013-10-27 14:00 | 2013-10-28 18:00
3 | 42 | 2013-10-30 09:00 | 2013-11-01 09:00
…
Let’s say a user wants to rent item 42 from 2013-10-27 12:00 until 2013-10-28 12:00 which is a period of one day. The system will tell him, that the item is not available in the given time frame, since booking no. 2 collides.
Now I want to suggest the earliest rental date and time when the selected item is available again. Of course considering the user’s requested period (1 day) beginning with the user’s desired date and time.
So in the case above, I’m looking for an SQL query that returns 2013-10-28 18:00, since the earliest date since 2013-10-27 12:00 at which item 42 will be available for 1 day, is from 2013-10-28 18:00 until 2013-10-29 18:00.
So I need to to find a gap between bookings, that is big enough to hold the user’s reservation and that is as close a possible to the desired start date.
Or in other words: I need to find the first booking for a given item, after which there’s enough free time to place the user’s booking.
Is this possible in plain SQL without having to iterate over every booking and its successor?
If you can't redesign your database to use something more efficient, this will get the answer. You'll obviously want to parameterize it. It says find either the desired date, or the earliest end date where the hire interval doesn't overlap an existing booking:
Select
min(startdate)
From (
select
cast('2013-10-27 12:00' as datetime) startdate
from
dual
union all
select
enddate
from
booking
where
enddate > cast('2013-10-27 12:00' as datetime) and
item = 42
) b1
Where
not exists (
select
'x'
from
booking b2
where
item = 42 and
b1.startdate < b2.enddate and
b2.startdate < date_add(b1.startdate, interval 24 hour)
);
Example Fiddle
SELECT startfree,secondsfree FROM (
SELECT
#lastenddate AS startfree,
UNIX_TIMESTAMP(startdate)-UNIX_TIMESTAMP(#lastenddate) AS secondsfree,
#lastenddate:=enddate AS ignoreme
FROM
(SELECT startdate,enddate FROM bookings WHERE item=42) AS schedule,
(SELECT #lastenddate:=NOW()) AS init
ORDER BY startdate
) AS baseview
WHERE startfree>='2013-10-27 12:00:00'
AND secondsfree>=86400
ORDER BY startfree
LIMIT 1
;
Some explanation: The inner query uses a variable to move the iteration into SQL, the outer query finds the needed row.
That said, I would not do this in SQL, if the DB structure is like the given. You could reduce the iteration count by using some smort WHERE in the inner query to a sane timespan, but chances are, this won't perform well.
EDIT
A caveat: I did not check, but I assume, this won't work, if there are no prior reservations in the list - this should not be a problem, as in this case your first reservation attempt (original time) will work.
EDIT
SQLfiddle
Searching for overlapping date ranges generally yields poor performance in SQL. For that reason having a "Calendar" of available slots often makes things a lot more efficient.
For example, the booking 2013-10-25 16:00 => 2013-10-27 12:00 would actually be represented by 44 records, each one hour long.
The "gap" until the next booking at 2013-10-27 14:00 would then be represented by 2 records, each one hours long.
Then, each record could also have the duration (in time, or number of slots) until the next change.
slot_start_time | booking | item | remaining_duration
------------------+---------+------+--------------------
2013-10-27 10:00 | 1 | 42 | 2
2013-10-27 11:00 | 1 | 42 | 1
2013-10-27 12:00 | NULL | 42 | 2
2013-10-27 13:00 | NULL | 42 | 1
2013-10-27 14:00 | 2 | 42 | 28
2013-10-27 15:00 | 2 | 42 | 27
... | ... | ... | ...
2013-10-28 17:00 | 2 | 42 | 1
2013-10-28 18:00 | NULL | 42 | 39
2013-10-28 19:00 | NULL | 42 | 38
Then your query just becomes:
SELECT
*
FROM
slots
WHERE
slot_start_time >= '2013-10-27 12:00'
AND remaining_duration >= 24
AND booking IS NULL
ORDER BY
slot_start_time ASC
LIMIT
1
OK this isn't pretty in MySQL. That's because we have to fake rownum values in subqueries.
The basic approach is to join the appropriate subset of the booking table to itself offset by one.
Here's the basic list of reservations for item 42, ordered by reservation time. We can't order by booking_id, because those aren't guaranteed to be in order of reservation time. (You're trying to insert a new reservation between two existing ones, eh?) http://sqlfiddle.com/#!2/62383/9/0
SELECT #aserial := #aserial+1 AS rownum,
booking.*
FROM booking,
(SELECT #aserial:= 0) AS q
WHERE item = 42
ORDER BY startdate, enddate
Here is that subset joined to itself. The trick is the a.rownum+1 = b.rownum, which joins each row to the one that comes right after it in the booking table subset. http://sqlfiddle.com/#!2/62383/8/0
SELECT a.booking_id, a.startdate asta, a.enddate aend,
b.startdate bsta, b.enddate bend
FROM (
SELECT #aserial := #aserial+1 AS rownum,
booking.*
FROM booking,
(SELECT #aserial:= 0) AS q
WHERE item = 42
ORDER BY startdate, enddate
) AS a
JOIN (
SELECT #bserial := #bserial+1 AS rownum,
booking.*
FROM booking,
(SELECT #bserial:= 0) AS q
WHERE item = 42
ORDER BY startdate, enddate
) AS b ON a.rownum+1 = b.rownum
Here it is again, showing each reservation (except the last one) and the number of hours following it. http://sqlfiddle.com/#!2/62383/15/0
SELECT a.booking_id, a.startdate, a.enddate,
TIMESTAMPDIFF(HOUR, a.enddate, b.startdate) gaphours
FROM (
SELECT #aserial := #aserial+1 AS rownum,
booking.*
FROM booking,
(SELECT #aserial:= 0) AS q
WHERE item = 42
ORDER BY startdate, enddate
) AS a
JOIN (
SELECT #bserial := #bserial+1 AS rownum,
booking.*
FROM booking,
(SELECT #bserial:= 0) AS q
WHERE item = 42
ORDER BY startdate, enddate
) AS b ON a.rownum+1 = b.rownum
So, if you're looking for the starting time and ending time of the earliest twelve-hour slot you can use that result set to do this: http://sqlfiddle.com/#!2/62383/18/0
SELECT MIN(enddate) startdate, MIN(enddate) + INTERVAL 12 HOUR as enddate
FROM (
SELECT a.booking_id, a.startdate, a.enddate,
TIMESTAMPDIFF(HOUR, a.enddate, b.startdate) gaphours
FROM (
SELECT #aserial := #aserial+1 AS rownum,
booking.*
FROM booking,
(SELECT #aserial:= 0) AS q
WHERE item = 42
ORDER BY startdate, enddate
) AS a
JOIN (
SELECT #bserial := #bserial+1 AS rownum,
booking.*
FROM booking,
(SELECT #bserial:= 0) AS q
WHERE item = 42
ORDER BY startdate, enddate
) AS b ON a.rownum+1 = b.rownum
) AS gaps
WHERE gaphours >= 12
here is the query, it will return needed date, obvious condition - there should be some bookings in table, but as I see from question - you do this check:
SELECT min(enddate)
FROM
(
select a.enddate from table4 as a
where
a.item=42
and
DATE_ADD(a.enddate, INTERVAL 1 day) <= ifnull(
(select min(b.startdate)
from table4 as b where b.startdate>=a.enddate and a.item=b.item),
a.enddate)
and
a.enddate>=now()
union all
select greatest(ifnull(max(enddate), now()),now()) from table4
) as q
you change change INTERVAL 1 day to INTERVAL ### hour
If I have understood your requirements correctly, you could try self-JOINing book with itself, to get the "empty" spaces, and then fit. This is MySQL only (I believe it can be adapted to others - certainly PostgreSQL):
SELECT book.*, TIMESTAMPDIFF(MINUTE, book.enddate, book.best) AS width FROM
(
SELECT book.*, MIN(book1.startdate) AS best
FROM book
JOIN book AS book1 USING (item)
WHERE item = 42 AND book1.startdate >= book.enddate
GROUP BY book.booking
) AS book HAVING width > 110 ORDER BY startdate LIMIT 1;
In the above example, "110" is the looked-for minimum width in minutes.
Same thing, a bit less readable (for me), a SELECT removed (very fast SELECT, so little advantage):
SELECT book.*, MIN(book1.startdate) AS best
FROM book
JOIN book AS book1 ON (book.item = book1.item AND book.item = 42)
WHERE book1.startdate >= book.enddate
GROUP BY book.booking
HAVING TIMESTAMPDIFF(MINUTE, book.enddate, best) > 110
ORDER BY startdate LIMIT 1;
In your case, one day is 1440 minutes and
SELECT book.*, MIN(book1.startdate) AS best FROM book JOIN book AS book1 ON (book.item = book1.item AND book.item = 42) WHERE book1.startdate >= book.enddate GROUP BY book.booking HAVING TIMESTAMPDIFF(MINUTE, book.enddate, best) >= 1440 ORDER BY startdate LIMIT 1;
+---------+------+---------------------+---------------------+---------------------+
| booking | item | startdate | enddate | best |
+---------+------+---------------------+---------------------+---------------------+
| 2 | 42 | 2013-10-27 14:00:00 | 2013-10-28 18:00:00 | 2013-10-30 09:00:00 |
+---------+------+---------------------+---------------------+---------------------+
1 row in set (0.00 sec)
...the period returned is 2, i.e., at the end of booking 2, and until "best" which is booking 3, a period of at least 1440 minutes is available.
An issue could be that if no periods are available, the query returns nothing -- then you need another query to fetch the farthest enddate. You can do this with an UNION and LIMIT 1 of course, but I think it would be best to only run the 'recovery' query on demand, programmatically (i.e. if empty(query) then new_query...).
Also, in the inner WHERE you should add a check for NOW() to avoid dates in the past. If expired bookings are moved to inactive storage, this could be unnecessary.