如何在Postgres中从预订中找到第一个可用的开始时间

6

除周日和公共节假日外,人们的工作时间是从上午10点到晚上9点。

他们的工作安排以15分钟为间隔。工作时长为15分钟至4小时不等。整个任务必须在单日内完成。

如何在Postgres 9.3中从当前日期和时间开始查找最近的未保留给定持续时间的空闲开始时间?

例如,玛丽已经预订了12:30..16:00,约翰已经预订了12:00至13:00

Reservat表包含预订,yksus2表包含工人,pyha表包含公共假期。表结构如下。如果有助于此,则可更改Reservat结构。

持续1.5小时的最早开始时间查询应返回

John 2014-10-28 10:00
Mary 2014-10-28 10:00
John 2014-10-28 10:15
Mary 2014-10-28 10:15
John 2014-10-28 10:30
Mary 2014-10-28 10:30
Mary 2014-10-28 11:00
John 2014-10-28 13:00
Mary 2014-10-28 16:00
Mary 2014-10-28 16:15
Mary 2014-10-28 16:30
... etc and also starting from next days

我尝试了以下基于此如何从PostgreSQL预订中仅返回工作时间?的答案进行查询,但结果错误:

MARY  2014-10-28 13:00:00
MARY  2014-10-29 22:34:40.850255
JOHN  2014-10-30 22:34:40.850255
MARY  2014-10-31 22:34:40.850255
MARY  2014-11-03 22:34:40.850255

同时,滑动开始时间10:00、10:30等也没有返回。
如何获取正确的第一次预订?

返回错误结果的查询语句为:

insert into reservat (objekt2, during) values 
('MARY', '[2014-10-28 11:30:00,2014-10-28 13:00:00)'), 
('JOHN', '[2014-10-28 10:00:00,2014-10-28 11:30:00)');

with gaps as (
    select
        yksus, 
        upper(during) as start,
        lead(lower(during),1,upper(during)) over (ORDER BY during) - upper(during) as gap
    from (
        select 
           yksus2.yksus,
           during
          from reservat join yksus2 on reservat.objekt2=yksus2.yksus 
          where  upper(during)>= current_date
        union all
        select
            yksus2.yksus,
            unnest(case
                when pyha is not null then array[tsrange1(d, d + interval '1 day')]
                when date_part('dow', d) in (0, 6) then array[tsrange1(d, d + interval '1 day')]
                when d::date =  current_Date then array[
                            tsrange1(d, current_timestamp ), 
                            tsrange1(d + interval '20 hours', d + interval '1 day')]
                else array[tsrange1(d, d + interval '8 hours'), 
                           tsrange1(d + interval '20 hours', d + interval '1 day')]
            end)
        from yksus2, generate_series(
            current_timestamp,
            current_timestamp + interval '1 month',
            interval '1 day'
        ) as s(d) 
        left join pyha on pyha = d::date
    ) as x 
)

select yksus, start
  from gaps 
where gap >= interval'1hour 30 minutes'
order by start
limit 30

模式:

CREATE EXTENSION btree_gist;
CREATE TABLE Reservat (
      id serial primary key,
      objekt2 char(10) not null references yksus2 on update cascade deferrable,
      during tsrange not null check(
         lower(during)::date = upper(during)::date
         and lower(during) between current_date and current_date+ interval'1 month'

         and (lower(during)::time >= '10:00'::time and upper(during)::time < '21:00'::time) 
         AND EXTRACT(MINUTE FROM lower(during)) IN (0, 15, 30,45)
         AND EXTRACT(MINUTE FROM upper(during)) IN (0, 15, 30, 45)
         and (date_part('dow', lower(during)) in (1,2,3,4,5,6) 
         and date_part('dow', upper(during)) in (1,2,3,4,5,6)) 
      ),

      EXCLUDE USING gist (objekt2 WITH =, during WITH &&)
    );  

create or replace function holiday_check() returns trigger language plpgsql stable as $$
    begin
        if exists (select * from pyha  where pyha in (lower(NEW.during)::date, upper(NEW.during)::date)) then
            raise exception 'public holiday %', lower(NEW.during) ;
        else
            return NEW;
        end if;
    end;
    $$;

create trigger holiday_check_i before insert or update on Reservat for each row execute procedure holiday_check();

CREATE OR REPLACE FUNCTION public.tsrange1(start timestamp with time zone,
    finish timestamp with time zone ) RETURNS tsrange AS
$BODY$
SELECT tsrange(start::timestamp without time zone, finish::timestamp without time zone );
$BODY$ language sql immutable;


-- Workers
create table yksus2( yksus char(10) primary key);
insert into yksus2 values ('JOHN'), ('MARY');

-- public holidays
create table pyha( pyha date primary key);

此外,还在pgsql-general邮件列表上发布了


添加了 link to your x-post on pgsql-general - Erwin Brandstetter
2个回答

2

适配模式

CREATE EXTENSION btree_gist;
CREATE TYPE timerange AS RANGE (subtype = time);  -- create type once

-- Workers
CREATE TABLE worker(
   worker_id serial PRIMARY KEY
 , worker text NOT NULL
);
INSERT INTO worker(worker) VALUES ('JOHN'), ('MARY');

-- Holidays
CREATE TABLE pyha(pyha date PRIMARY KEY);

-- Reservations
CREATE TABLE reservat (
   reservat_id serial PRIMARY KEY
 , worker_id   int NOT NULL REFERENCES worker ON UPDATE CASCADE
 , day         date NOT NULL CHECK (EXTRACT('isodow' FROM day) < 7)
 , work_from   time NOT NULL -- including lower bound
 , work_to     time NOT NULL -- excluding upper bound
 , CHECK (work_from >= '10:00' AND work_to <= '21:00'
      AND work_to - work_from BETWEEN interval '15 min' AND interval '4 h'
      AND EXTRACT('minute' FROM work_from) IN (0, 15, 30, 45)
      AND EXTRACT('minute' FROM work_from) IN (0, 15, 30, 45)
    )
 , EXCLUDE USING gist (worker_id WITH =, day WITH =
                     , timerange(work_from, work_to) WITH &&)
);
INSERT INTO reservat (worker_id, day, work_from, work_to) VALUES 
   (1, '2014-10-28', '10:00', '11:30')  -- JOHN
 , (2, '2014-10-28', '11:30', '13:00'); -- MARY

-- Trigger for volatile checks
CREATE OR REPLACE FUNCTION holiday_check()
  RETURNS trigger AS
$func$
BEGIN
   IF EXISTS (SELECT 1 FROM pyha WHERE pyha = NEW.day) THEN
      RAISE EXCEPTION 'public holiday: %', NEW.day;
   ELSIF NEW.day < now()::date OR NEW.day > now()::date + 31 THEN
      RAISE EXCEPTION 'day out of range: %', NEW.day;
   END IF;

   RETURN NEW;
END
$func$ LANGUAGE plpgsql STABLE; -- can be "STABLE"

CREATE TRIGGER insupbef_holiday_check
BEFORE INSERT OR UPDATE ON reservat
FOR EACH ROW EXECUTE PROCEDURE holiday_check();

主要要点

  • Don't use char(n). Rather varchar(n), or better yet, varchar or just text.

  • Don't use the name of a worker as primary key. It's not necessarily unique and can change. Use a surrogate primary key instead, best a serial. Also makes entries in reservat smaller, indexes smaller, queries faster, ...

  • Update: For cheaper storage (8 bytes instead of 22) and simpler handling I save start and end as time now and construct a range on the fly for the exclusion constraint:

    EXCLUDE USING gist (worker_id WITH =, day WITH =
                      , timerange(work_from, work_to) WITH &&)
    
  • Since your ranges can never cross the date border by definition, it would be more efficient to have a separate date column (day in my implementation) and a time range. The type timerange is not shipped in default installations, but easily created. This way you can largely simplify your check constraints.

  • Use EXTRACT('isodow', ...) to simplify excluding sundays

    The day of the week as Monday(1) to Sunday(7)

  • I assume you want to allow the upper border of '21:00'.

  • Borders are assumed to be including for the lower and excluding for the upper bound.

  • The check whether new / updated days lie within a month from "now" is not IMMUTABLE. Moved it from the CHECK constraint to the trigger - else you might run into problems with dump / restore! Details:

附言
除了简化输入和检查约束外,我原本期望 timerangetsrange 相比可以节省8个字节的存储空间,因为 time 只占用4个字节。但事实证明,timerange 在磁盘上占用22个字节(在RAM中为25个字节),就像 tsrange (或 tstzrange)一样。因此,您也可以选择使用 tsrange。查询和排除约束的原则是相同的。

查询

为了方便参数处理而包装成SQL函数:

CREATE OR REPLACE FUNCTION f_next_free(_start timestamp, _duration interval)
  RETURNS TABLE (worker_id int, worker text, day date
               , start_time time, end_time time) AS
$func$
   SELECT w.worker_id, w.worker
        , d.d AS day
        , t.t AS start_time
        ,(t.t + _duration) AS end_time
   FROM  (
      SELECT _start::date + i AS d
      FROM   generate_series(0, 31) i
      LEFT   JOIN pyha p ON p.pyha = _start::date + i
      WHERE  p.pyha IS NULL   -- eliminate holidays
      ) d
   CROSS  JOIN (
      SELECT t::time
      FROM   generate_series (timestamp '2000-1-1 10:00'
                            , timestamp '2000-1-1 21:00' - _duration
                            , interval '15 min') t
      ) t  -- times
   CROSS  JOIN worker w
   WHERE  d.d + t.t > _start  -- rule out past timestamps
   AND    NOT EXISTS (
      SELECT 1
      FROM   reservat r
      WHERE  r.worker_id = w.worker_id
      AND    r.day = d.d
      AND    timerange(r.work_from, r.work_to) && timerange(t.t, t.t + _duration)
      )
   ORDER  BY d.d, t.t, w.worker, w.worker_id
   LIMIT  30  -- could also be parameterized
$func$ LANGUAGE sql STABLE;

呼叫:

SELECT * FROM f_next_free('2014-10-28 12:00'::timestamp, '1.5 h'::interval);

SQL Fiddle现在支持Postgres 9.3。

解释

  • The function takes a _start timestamp as minimum starting time and _duration interval. Be careful to only rule out earlier times on the starting day, not the following days. Simplest by just adding day and time: t + d > _start.
    To book a reservation starting "now", just pass now()::timestamp:

    SELECT * FROM f_next_free(`now()::timestamp`, '1.5 h'::interval);
    
  • Subquery d generates days starting from the input value _day. Holidays excluded.

  • Days are cross-joined with possible time ranges generated in subquery t.
  • That is cross-joined to all available workers w.
  • Finally eliminate all candidates that collide with existing reservations using an NOT EXISTS anti-semi-join, and in particular the overlaps operator && .

相关文章:


@Andrus:我检查了浏览器历史记录,sqlfiddle链接应该是正确的。我担心sqlfiddle正在出现问题。性能方面:10 * 31 * 36 = 11160个候选插槽不算少,但肯定是可以管理的。我的一些观点是强烈推荐的,其余则是要考虑需求和口味。我会选择两个答案中最好的,然后测试性能。 - Erwin Brandstetter
我在查询语句的where子句中添加了 and (d.d>current_date or t.t>current_time) 条件,以排除过去的时间。这样做可以吗? - Andrus
上面的程序相关内容的翻译如下:upper: fiddle: 可能会崩溃或其他问题……pg9.3 负载过重。在 pg9.2 上添加了一个新的小提琴。upper: 看看我的备注:我假设你想允许 '21:00' 的上边界。消除过去的时间:不,你的表达式不起作用。请查看更新的答案。注意:我将函数名缩短为 f_next_free() - Erwin Brandstetter
@Andrus; 根据您的需要调整返回列。 upper(during) <='21:00' 表示预订必须在或之前结束于 21:00。NOT upper_inc(during) 实施一个排除上限。这可能就是它应该的方式。 - Erwin Brandstetter
EXCLUDE USING gist (worker_id WITH =, during WITH &&) 禁用了所有日期上的重叠时间范围。我将其更改为 EXCLUDE USING gist (objekt2 WITH =, day WITH =, during WITH &&)。这样可以吗? - Andrus
显示剩余2条评论

1

Thom Brown在psql-general邮件列表中推荐以下解决方案。

这个方案更易读,但Erwin的回答看起来更优化。 我有10个工人和1个月的预订,从8点到20:00每隔15分钟,因此性能不应该是一个问题。 应该使用哪一个?

哪个方案更好?

create table pyha (pyha date primary key);
insert into pyha(pyha) values('2014-10-29');
create table  yksus2(yksus char(10) primary key);
insert into yksus2 values ('JOHN'),('MARY');
CREATE EXTENSION btree_gist;
CREATE TABLE reservat
(
reservat_id serial primary key,
      objekt2 char(10) not null references yksus2 on update cascade deferrable,
during tstzrange not null,

EXCLUDE USING gist (objekt2 WITH =, during WITH &&),

CONSTRAINT same_date
     CHECK (lower(during)::date = upper(during)::date),

CONSTRAINT max_1month_future 
     CHECK (lower(during) between current_date and current_date+ interval'1 month' ),

CONSTRAINT time_between_1000_and_2100
     CHECK (lower(during)::time >= '10:00'::time and upper(during)::time < '21:00'::time),

CONSTRAINT lower_bound_included
     CHECK (lower_inc(during)),

CONSTRAINT upper_bound_excluded
     CHECK (not upper_inc(during)),

CONSTRAINT start_time_at_15minute_offset
     CHECK (EXTRACT(MINUTE FROM lower(during)) IN (0, 15, 30,45)),
-- or (extract(epoch from lower(during)::time)::int % (60*15) = 0)

CONSTRAINT end_time_at_15minute_offset
     CHECK (EXTRACT(MINUTE FROM upper(during)) IN (0, 15, 30,45)),

CONSTRAINT duration_between_15min_and_4hours
     CHECK (upper(during) - lower(during) between '15 mins'::interval and '4 hours'::interval),

CONSTRAINT exclude_sundays
     CHECK (date_part('dow', lower(during)) in (1,2,3,4,5,6) )
);

create or replace function holiday_check() returns trigger language plpgsql stable as $$
    begin
        if exists (select * from pyha  where pyha between lower(NEW.during)::date and upper(NEW.during)::date) then
            raise exception 'public holiday %', lower(NEW.during) ;
        else
            return NEW;
        end if;
    end;
    $$;

create trigger holiday_check_i before insert or update on Reservat for each row execute procedure holiday_check();
INSERT INTO reservat (objekt2, during)
  VALUES ('MARY','[2014-10-29 11:30+2,2014-10-29 13:00+2)'::tstzrange);
INSERT INTO reservat (objekt2, during)
  VALUES ('JOHN','[2014-10-29 10:00+2,2014-10-29 11:30+2)'::tstzrange);


   SELECT yksus2.yksus, times.period
FROM generate_series(now()::date::timestamptz, now()::date::timestamptz + '3 months'::interval, '15 mins'::interval) times(period)
CROSS JOIN yksus2
LEFT JOIN reservat ON tstzrange(times.period,times.period + '1 hour 30 mins'::interval, '[)') && reservat.during
  AND yksus2.yksus = reservat.objekt2
LEFT JOIN pyha ON times.period::date = pyha.pyha::date
WHERE reservat.during IS NULL
  AND pyha.pyha IS NULL
  AND times.period::timetz BETWEEN '10:00'::timetz AND '21:00'::timetz - '1 hour 30 mins'::interval
  AND times.period >= now()
  AND EXTRACT(isoDOW FROM times.period) != 7 -- exclude sundays
ORDER BY 2, 1
LIMIT 300;

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接