opening hours table design postgresql: how to query this? - postgresql

I want to store opening hours of a store in a PostgreSQL database. I have a few question about this. First of all, my table design:
Table: opening_hours
--------------------
* id
* store_id
* start_day (0->6)
* start_time (HH:mm:ss)
* duration (HH:mm:ss)
* timezone (default: Europe/Brussels)
Example:
--------
1 1 0 08:00:00 12:00:00 Europe/Brussels
// store ID 1 is open on sundays from 08AM to 08PM (12 hours in total) local time
2 2 6 20:00:00 04:00:00 Europe/Brussels
// store ID 2 is open on saturday from 08PM till 04AM (on sunday) (this is like a pub or something with opening hours at night) local time (so 20:00:00 in Belgium open = 19:00:00 in London open)
I think this is a good design, because now I can make opening hours spanning more than 1 day (like when a pub is open at night). Before that, I was storing opening hours in each day separately so I had to enter 'saturday 20:00:00 -> 23:59:59' + 'sunday 00:00:00 -> 04:00:00' for the opening hours for store ID 2.
How can I query against these rows?
I want to check if a time already exists in the database before adding a new one, and I want to check if a row exists based on the current time of the user (2 different queries).
CURRENT ROWS:
-------------
1 1 1 08:00:00 08:00:00 Europe/Brussels
// open on monday from 08AM until 04PM
2 1 6 20:00:00 12:00:00 Europe/Brussels
// open on saturday from 08PM until sunday 08AM
NEW ROWS:
---------
3 1 1 14:00:00 04:00:00 Europe/Brussels (should not insert this row because it interferes with row ID 1)
// open on monday from 02PM until 06PM
4 1 1 17:00:00 04:00:00 Europe/Brussels (can insert because not interference with row 1)
// open on monday from 05PM until 09PM
5 1 0 07:00:00 05:00:00 Europe/Brussels (can't insert this row because it interferes with row 2).
// open on sunday from 07AM until 12PM
I hope my question is clear. If not, please correct me, I'll try to ask it differently then.

With your new table design actually, it is now a bit tricky, because of durations that can possibly span over the end of a week cycle. Consider for example:
start_day | start_time | duration
-----------+------------+----------
6 | 14:00:00 | 18:00:00
It actually extends into day=0, so it would need to match day=0, t=07:00:00.
When looking for matches against a specific time, you need to check two possibilities (either day, time or day + 7, time fall into one of your intervals). Same thing for overlaps (3 possibilities).
You can define some helper functions:
-- oh: opening hours helper functions
-- oh_tst: convert dow, time into a timestamp after the Epoch
-- (note: Epoch was not a Monday (dow 0), but it doesn't matter,
-- we could use any arbitrary date)
create or replace function oh_tst(dow int, t time) returns timestamp as $$
select '1970-01-01'::timestamp + $1 * interval'1 day' + $2;
$$
language sql immutable;
-- oh_single_matches: internal function (no handling of wrap around)
create or replace function oh_single_matches(start_day int, start_time time, d interval, day int, t time) returns boolean as $$
select oh_tst($4, $5) between oh_tst($1, $2) and oh_tst($1, $2) + $3 - interval'1 millisecond';
$$
language sql immutable;
-- oh_matches: tests if a (day, time) is within an oh interval
-- handle wrap around at end of week
create or replace function oh_matches(start_day int, start_time time, d interval, day int, t time) returns boolean as $$
select oh_single_matches($1, $2, $3, $4, $5)
or oh_single_matches($1, $2, $3, $4, $5 + 7);
$$
language sql immutable;
-- oh_overlaps: test for oh defs overlap (incl wrap-around)
create or replace function oh_overlaps(adow int, astart_time time, aduration interval,
bdow int, bstart_time time, bduration interval) returns boolean as $$
select (oh_tst($1, $2), $3) overlaps (oh_tst($4, $5), $6)
or (oh_tst($1, $2), $3) overlaps (oh_tst($4 + 7, $5), $6)
or (oh_tst($1, $2), $3) overlaps (oh_tst($4 - 7, $5), $6);
$$
language sql immutable;
Examples:
Single match (one day,time against an opening hours definition):
-- intervals are left-close:
select oh_matches(6, '14:00:00'::time, interval'2 hours', 6, '14:00:00'::time);
oh_matches
------------
t
-- ...and right-open (as Nature intended):
select oh_matches(6, '14:00:00'::time, interval'2 hours', 6, '15:59:59'::time);
t
select oh_matches(6, '14:00:00'::time, interval'2 hours', 6, '16:00:00'::time);
f
-- wrap around the end of week
select oh_matches(6, '14:00:00'::time, interval'18 hours', 0, '02:00:00'::time);
t
Interval overlaps:
select oh_overlaps(2, '14:00:00'::time, interval'8 hours',
2, '04:00:00'::time, interval'8 hours');
f
select oh_overlaps(2, '14:00:00'::time, interval'8 hours',
2, '08:00:00'::time, interval'8 hours');
t
-- wraparoud ok
select oh_overlaps(0, '01:00:00'::time, interval'8 hours',
6, '22:00:00'::time, interval'18 hours');
t
Test against a table:
Single timestamp (now()):
select * from mytable
where store_id=5
and oh_matches(start_day, start_time, duration, now()::timestamp);
Check if prospective rows in a candidates table would overlap existing definitions in mytable:
select * from candidates a inner join mytable b using (store_id)
where oh_overlaps(b.start_day, b.start_time, b.duration,
a.start_day, a.start_time, a.duration);

Related

Week Day Starting from a Certain Day (01 Jan 2021) in Postgres

I am trying to get week numbers in a Year starting from a certain day
I've checked the stack but quite confused.
SELECT EXTRACT(WEEK FROM TIMESTAMP '2021-01-01'),
extract('year' from TIMESTAMP '2021-01-01')
The output is 53|2021
I want it to be 01|2021
I understand the principle of the isoweek but I want the year to start in 01-01-2021
The aim is to use intervals from this day to determine week numbers
Week N0| End Date
1 | 01-01-2021
2 | 01-08-2021
5 | 01-29-2021
...
This is really strange way to determine the week number, but in the end it's a simple math operation: the number of days since January first divided by 7.
You can create a function for this:
create function custom_week(p_input date)
returns int
as
$$
select (p_input - date_trunc('year', p_input)::date) / 7 + 1;
$$
language sql
immutable;
So this:
select date, custom_week(date)
from (
values
(date '2021-01-01'),
(date '2021-01-08'),
(date '2021-01-29')
) as v(date)
yields
date | custom_week
-----------+------------
2021-01-01 | 1
2021-01-08 | 2
2021-01-29 | 5

postgresql list of time slots from 'Monday' | 09:00:00 | 11:00:00

I’m building a booking system where a user will set their availability eg: I’m available Monday’s from 9am to 11am, Tuesdays from 9am to 5pm etc… and need to generate a list of time slots 15mins apart from their availability.
I have the following table (but am flexible to changing this):
availabilities(day_of_week text, start_time: time, end_time: time)
which returns records like:
‘Monday’ | 09:00:00 | 11:00:00
‘Monday’ | 13:00:00 | 17:00:00
‘Tuesday’ | 08:00:00 | 17:00:00
So I’m trying to build a stored procedure to generate a list of time slots so far I've got this:
create or replace function timeslots ()
return setof timeslots as $$
declare
rec record;
begin
for rec in select * from availabilities loop
/*
convert 'Monday' | 09:00:00 | 11:00:00 into:
2020-02-03 09:00:00
2020-02-03 09:15:00
2020-02-03 09:30:00
2020-02-03 09:45:00
2020-02-03 10:00:00
and so on...
*/
return next
end loop
$$ language plpgsql stable;
I return a setof instead of a table as I'm using Hasura and it needs to return a setof so I just create a blank table.
I think I'm on the right track but am currently stuck on:
how do I create a timestamp from 'Monday' 09:00:00 for the next monday as I only care about timeslots from today onwards?
how do I convert 'Monday' | 09:00:00 | 11:00:00 into a list of time slots 15 mins apart?
how do I create a timestamp from 'Monday' 09:00:00 for the next monday
as I only care about timeslots from today onwards?
You can use date_trunc for this (see this question for more info):
SELECT date_trunc('week', current_date) + interval '1 week';
From the docs re week:
The number of the ISO 8601 week-numbering week of the year. By
definition, ISO weeks start on Mondays
So taking this value and adding a week gives next Monday (you may need to ammend this behaviour based upon what you want to do if today is monday!).
how do I convert 'Monday' | 09:00:00 | 11:00:00 into a list of time
slots 15 mins apart?
This is a little tricker; generate_series will give you the timeslots but the trick is getting it into a result set. The following should do the job (I have included your sample data; change the values bit to refer to your table) - dbfiddle :
with avail_times as (
select
date_trunc('week', current_date) + interval '1 week' + case day_of_week when 'Monday' then interval '0 day' when 'Tuesday' then interval '1 day' end + start_time as start_time,
date_trunc('week', current_date) + interval '1 week' + case day_of_week when 'Monday' then interval '0 day' when 'Tuesday' then interval '1 day' end + end_time as end_time
from
(
values
('Monday','09:00:00'::time,'11:00:00'::time),
('Monday','13:00:00'::time,'17:00:00'::time),
('Tuesday','08:00:00'::time,'17:00:00'::time)
) as availabilities (day_of_week,
start_time,
end_time) )
select
g.ts
from
(
select
start_time,
end_time
from
avail_times) avail,
generate_series(avail.start_time, avail.end_time - interval '1ms', '15 minutes') g(ts);
A few notes:
The CTE avail_times is used to simplify things; it generates two columns (start_time and end_time) which are the full timestamps (so including the date). In this example the first row is "2020-02-03 09:00:00, 2020-02-03 11:00:00" (I'm running this on 2020-02-02 so 2020-02-03 is next Monday).
The way I'm converting 'monday' etc to a day of the week is a bit of a hack (and I have not bothered to do the full week); there is probably a better way but storing the day of week as an integer would make this simpler.
I subtract 1ms from the end time because I'm assuming you dont want this in the result set.
The main query is using a LATERAL Subquery. See this question for more info.
Aditional Question
how to adjust this so I can pass in a start and end date so I can get
time slots for a particular period
You could do something like the following (just adjust the dates CTE to return whatever days you want to include; you could convert to a function or just pass the dates in as parameters).
Note that as #Belayer mentions my original solution did not cater for shifts over midnight so this addresses that too.
with dates as (
select
day
from
generate_series('2020-02-20'::date, '2020-03-10'::date, '1 day') as day ),
availabilities as (
select
*
from
(
values (1,'09:00:00'::time,'11:00:00'::time),
(1,'13:00:00'::time,'17:00:00'::time),
(2,'08:00:00'::time,'17:00:00'::time),
(3,'23:00:00'::time,'01:00:00'::time)
) as availabilities
(day_of_week, -- 1 = monday
start_time,
end_time) ) ,
avail_times as (
select
d.day + start_time as start_time,
case
end_time > start_time
when true then d.day
else d.day + interval '1 day' end + end_time as end_time
from
availabilities a
inner join dates d on extract(ISODOW from d.day) = a.day_of_week )
select
g.ts
from
(
select
start_time,
end_time
from
avail_times) avail,
generate_series(avail.start_time, avail.end_time - interval '1ms', '15 minutes') g(ts)
order by
g.ts;
The following uses much of the techniques mentioned by #Brits. They present some very good information, so I'll not repeat but suggest you review it (and the links).
I do however take a slightly different approach. First a couple table changes. I use the ISO day of week 1-7 (Monday-Sunday) rather than the day name. The day name is easily extracted for the dater later.
Also I use interval instead to time for start and end times. ( A time data type works for most scenarios but there is one it doesn't (more later).
One thing your description does not make clear is whether the ending time is included it the available time or not. If included the last interval would be 11:00-11:15. If excluded the last interval is 10:45-11:00. I have assumed to excluded it. In the final results the end time is to be read as "up to but not including".
-- setup
create table availabilities (weekday integer, start_time interval, end_time interval);
insert into availabilities (weekday , start_time , end_time )
select wkday
, start_time
, end_time
from (select *
from (values (1, '09:00'::interval, '11:00'::interval)
, (1, '13:00'::interval, '17:00'::interval)
, (2, '08:00'::interval, '17:00'::interval)
, (3, '08:30'::interval, '10:45'::interval)
, (4, '10:30'::interval, '12:45'::interval)
) as v(wkday,start_time,end_time)
) r ;
select * from availabilities;
The Query
It begins with a CTE (next_week) generates a entry for each day of the week beginning Monday and the appropriate ISO day number for it. The main query joins these with the availabilities table to pick up times for matching days. Finally that result is cross joined with a generated timestamp to get the 15 minute intervals.
-- Main
with next_week (wkday,tm) as
(SELECT n+1, date_trunc('week', current_date) + interval '1 week' + n*interval '1 day'
from generate_series (0, 6) n
)
select to_char(gdtm,'Day'), gdtm start_time, gdtm+interval '15 min' end_time
from ( select wkday, tm, start_time, end_time
from next_week nw
join availabilities av
on (av.weekday = nw.wkday)
) s
cross join lateral
generate_series(start_time+tm, end_time+tm- interval '1 sec', interval '15 min') gdtm ;
The outlier
As mentioned there is one scenario where a time data type does not work satisfactory, but you may not nee it. What happens when a shift worker says they available time is 23:00-01:30. Believe me when a shift worker goes to work at 22:00 of Friday, 01:30 is still Friday night, even though the calendar might not agree. (I worked that shift for many years.) The following using interval handles that issue. Loading the same data as prior with an addition for the this case.
insert into availabilities (weekday, start_time, end_time )
select wkday
, start_time
, end_time + case when end_time < start_time
then interval '1 day'
else interval '0 day'
end
from (select *
from (values (1, '09:00'::interval, '11:00'::interval)
, (1, '13:00'::interval, '17:00'::interval)
, (2, '08:00'::interval, '17:00'::interval)
, (3, '08:30'::interval, '10:45'::interval)
, (5, '23:30'::interval, '02:30'::interval) -- Friday Night - Saturday Morning
) as v(wkday,start_time,end_time)
) r
;
select * from availabilities;
Hope this helps.

generate series based on particular day in each month -postgresql

i have following query in postgresql for dates between 2 ranges.
select generate_series('2019-04-01'::timestamp, '2020-03-31', '1 month')
as g_date
I need to generate specific date in every month .i.e 15 th of every month. Following is my query to generate series
DO $$
DECLARE
compdate date = '2019-04-15';
BEGIN
CREATE TEMP TABLE tmp_table ON COMMIT DROP AS
select *,
case
when extract('day' from d) <> extract('day' from compdate) then 0
when ( extract('month' from d)::int - extract('month' from compdate)::int ) % 1 = 0 then 1
else 0
end as c
from generate_series('2019-04-01'::timestamp, '2020-03-31', '1 day') d;
END $$;
SELECT * FROM tmp_table
where c=1;
;
But every thing is perfect if input date between (1..29)-04-2019 ..
2019-04-25
2019-05-25
2019-06-25
2019-07-25
2019-08-25
2019-09-25
2019-10-25
2019-11-25
2019-12-25
2020-01-25
2020-02-25
2020-03-25
but if i give compdate: 31-04-2019 or 30-04-2019 giving out put:
2019-05-31
2019-07-31
2019-08-31
2019-10-31
2019-12-31
2020-01-31
2020-03-31
Expected Output:
date flag
2019-04-01 0 ----start_date
2019-04-30 1
2019-05-31 1
2019-06-30 1
2019-07-31 1
2019-08-31 1
2019-09-30 1
2019-10-31 1
2019-11-30 1
2019-12-31 1
2020-01-31 1
2020-02-29 1
2020-03-31 0 ---end_date
If matched day not found in the result it should take last day of that month..i.e if 31 not found in month of feb it
should take 29-02-2019 and also in april month instead of 31 it should take 2019-04-30.
Please suggest.
to generate the last days of the month, just generate first days & subtract a 1 day interval
example: the following generates all last day of month in the year 2010
SELECT x - interval '1 day' FROM
GENERATE_SERIES('2010-02-01', '2011-01-01', interval '1 month') x
You cannot accomplish what you want with generate_series. This results due to that process applying a fixed increment from the previous generated value. Your case 1 month. Now Postgres will successfully compute correct end-of-month date from 1 month to the next. So for example 1month from 31-Jan yields 28-Feb (or 29), because 31-Feb would be an invalid date, Postgres handles it. However, that same interval from 28-Feb gives the valid date 28-Mar so no end-of-month adjustment is needed. Generate_Series will return 28th of the month from then on. The same applies to 30 vs. 31 day months.
But you can achieve what your after with a recursive CTE by employing a varying interval to the same initial start date. If the resulting date is invalid for date the necessary end-of-month adjustment will be made. The following does that:
create or replace function constant_monthly_date
( start_date timestamp
, end_date timestamp
)
returns setof date
language sql strict
as $$
with recursive date_set as
(select start_date ds, start_date sd, end_date ed, 1 cnt
union all
select (sd + cnt*interval '1 month') ds, sd, ed, cnt+1
from date_set
where ds<end_date
)
select ds::date from date_set;
$$;
-- test
select * from constant_monthly_date(date '2020-01-15', date '2020-12-15' );
select * from constant_monthly_date(date '2020-01-31', date '2020-12-31' );
Use the least function to get the least one between the computed day and end of month.
create or replace function test1(day int) returns table (t timestamptz) as $$
select least(date_trunc('day', t) + make_interval(days => day-1), date_trunc('day', t) + interval '1 month' - interval '1 day') from generate_series('2019-04-01', '2020-03-31', interval '1 month') t
$$ language sql;
select test1(31);

postgres: generate series of timestamps respecting time zone

I'm stumped by a tricky issue regarding time zone changes from daylight savings to non daylight savings.
I'm trying to generate a series of timestamps, 6 hrs apart. This is later joined with data with corresponding timestamps at the 00, 06, 12, 18 hrs for each day in the dataset.
This works fine normally, using:
generate_series(extract(epoch from start_ts)::integer, extract(epoch from end_ts)::integer, 21600)
where start_ts is 00 hr on the first date, and end_ts is 00 hr on the last date exclusive.
However, when timezone offset goes from +11 to +10 half way through the series, it will no longer match any records since the series elements become 1 hr off.
Does anyone have suggestions on how to generate a series of 'epoch integers' or timestamps which would match 00,06,12,18 hr timestamps while respecting the timezone's offset?
This will generate it (using PostgreSQL 9.5+), starting from today and for 10 days:
select (current_date::timestamp + ((a-1)||' days')::interval)::timestamptz
from generate_series(1, 10, .25) a
Test it on a whole year:
select *, date_part('hour', d::timestamp), d::timestamp
from (
select (current_date::timestamp + ((a-1)||' days')::interval)::timestamptz AS d
from generate_series(1, 365, .25) a
) x
where date_part('hour', d) not in (0, 6, 12, 18)
Edit: The version below works with versions of PostgreSQL older than 9.5:
select (current_date::timestamp + (((a-1)/4.0)||' days')::interval)::timestamptz
from generate_series(1, 4* 10 ) a -- 10 days
#Ziggy's answer is great, use that. however here's how I solved it in my application which can't use decimals in generate_series (v9.4):
_min timestamp with time zone, -- the first timestamp in the series
_max timestamp with time zone, -- the last timestamp in the series
_inc integer, -- the increment in seconds, eg 21600 (6hr)
_tz text
creates a series from the _max down using the tz offset of the _max,
creates a series from the _min up using the tz offset of the _min,
merges the results
validates each result is divisible by the _inc in the tz of the result, discards if not
query:
select t1 from (
select ser,
to_timestamp(ser) t1,
extract(epoch from
to_timestamp(ser) at time zone _tz
- date_trunc('day', to_timestamp(ser) at time zone _tz)
)::integer % _inc = 0 is_good
from (
select 'ser1' s, generate_series(extract(epoch from _min)::integer, extract(epoch from _max)::integer, _inc) ser
union all
select 'ser2' s, generate_series(extract(epoch from _max)::integer, extract(epoch from _min)::integer, _inc * -1) ser
) x
group by ser, _tz, _inc
order by ser asc
) x
where is_good
;

PostgreSql - Adding YEar and Month to Table

I am creating a Customer table and i want one of the attributes to be Expiry Date of credit card.I want the format to be 'Month Year'. What data type should i use? i want to use date but the format is year/month/day. Is there any other way to restrict format to only Month and year?
You can constrain the date to the first day of the month:
create table customer (
cc_expire date check (cc_expire = date_trunc('month', cc_expire))
);
Now this fails:
insert into customer (cc_expire) values ('2014-12-02');
ERROR: new row for relation "customer" violates check constraint "customer_cc_expire_check"
DETAIL: Failing row contains (2014-12-02).
And this works:
insert into customer (cc_expire) values ('2014-12-01');
INSERT 0 1
But it does not matter what day is entered. You will only check the month:
select
date_trunc('month', cc_expire) > current_date as valid
from customer;
valid
-------
t
Extract year and month separately:
select extract(year from cc_expire) "year", extract(month from cc_expire) "month"
from customer
;
year | month
------+-------
2014 | 12
Or concatenated:
select to_char(cc_expire, 'YYYYMM') "month"
from customer
;
month
--------
201412
Use either
char(5) for two-digit years, or
char(7) for four-digit years.
Code below assumes two-digit years, which is the form that matches all my credit cards. First, let's create a table of valid expiration dates.
create table valid_expiration_dates (
exp_date char(5) primary key
);
Now let's populate it. This code is just for 2013. You can easily adjust the range by changing the starting date (currently '2013-01-01'), and the "number" of months (currently 11, which lets you get all of 2013 by adding from 0 to 11 months to the starting date).
with all_months as (
select '2013-01-01'::date + (n || ' months')::interval months
from generate_series(0, 11) n
)
insert into valid_expiration_dates
select to_char(months, 'MM') || '/' || to_char(months, 'YY') exp_date
from all_months;
Now, in your data table, create a char(5) column, and set a foreign key reference from it to valid_expiration_dates.exp_date.
While you're busy with this, think hard about whether "exp_month" might be a better name for that column than "exp_date". (I think it would.)
As another idea you could essentially create some brief utilities to do this for you using int[]:
CREATE OR REPLACE FUNCTION exp_valid(int[]) returns bool LANGUAGE SQL IMMUTABLE as
$$
SELECT $1[1] <= 12 AND (select count(*) = 2 FROM unnest($1));
$$;
CREATE OR REPLACE FUNCTION first_invalid_day(int[]) RETURNS date LANGUAGE SQL IMMUTABLE AS
$$
SELECT (to_date($1[2]::text || $1[1]::text, CASE WHEN $1[2] < 100 THEN 'YYMM' ELSE 'YYYYMM' END) + '1 month'::interval)::date;
$$;
These work:
postgres=# select exp_valid('{04,13}');
exp_valid
-----------
t
(1 row)
postgres=# select exp_valid('{13,04}');
exp_valid
-----------
f
(1 row)
postgres=# select exp_valid('{04,13,12}');
exp_valid
-----------
f
(1 row)
Then we can convert these into a date:
postgres=# select first_invalid_day('{04,13}');
first_invalid_day
-------------------
2013-05-01
(1 row)
This use of arrays does not violate any normalization rules because the array as a whole represents a single value in its domain. We are storing two integers representing a single date. '{12,2}' is December of 2002, while '{2,12}' is Feb of 2012. Each represents a single value of the domain and is therefore perfectly atomic.