Layout da tabela
Redesenhe a tabela para armazenar o horário de funcionamento (horário de funcionamento) como um conjunto de
tsrange
(intervalo de timestamp without time zone
) valores. Requer Postgres 9.2 ou posterior . Escolha uma semana aleatória para organizar seu horário de funcionamento. Eu gosto da semana:
1996-01-01 (segunda-feira) para 1996-01-07 (domingo)
Esse é o ano bissexto mais recente em que 1º de janeiro convenientemente é uma segunda-feira. Mas pode ser qualquer semana aleatória para este caso. Basta ser consistente.
Instale o módulo adicional
btree_gist
primeiro:CREATE EXTENSION btree_gist;
Ver:
- Equivalente à restrição de exclusão composta por inteiro e intervalo
Em seguida, crie a tabela assim:
CREATE TABLE hoo (
hoo_id serial PRIMARY KEY
, shop_id int NOT NULL -- REFERENCES shop(shop_id) -- reference to shop
, hours tsrange NOT NULL
, CONSTRAINT hoo_no_overlap EXCLUDE USING gist (shop_id with =, hours WITH &&)
, CONSTRAINT hoo_bounds_inclusive CHECK (lower_inc(hours) AND upper_inc(hours))
, CONSTRAINT hoo_standard_week CHECK (hours <@ tsrange '[1996-01-01 0:0, 1996-01-08 0:0]')
);
O um coluna
hours
substitui todas as suas colunas:opens_on, closes_on, opens_at, closes_at
Por exemplo, horário de funcionamento a partir de quarta-feira, 18h30 até quinta-feira, 05:00 UTC são inseridos como:
'[1996-01-03 18:30, 1996-01-04 05:00]'
A restrição de exclusão
hoo_no_overlap
impede entradas sobrepostas por loja. Ele é implementado com um índice GiST , que também dá suporte às nossas consultas. Considere o capítulo "Índice e desempenho" abaixo discutindo estratégias de indexação. A restrição de verificação
hoo_bounds_inclusive
impõe limites inclusivos para seus intervalos, com duas consequências dignas de nota:- Um ponto no tempo que cai exatamente no limite inferior ou superior é sempre incluído.
- Entradas adjacentes para a mesma loja não são permitidas. Com limites inclusivos, eles se "sobreporiam" e a restrição de exclusão geraria uma exceção. As entradas adjacentes devem ser mescladas em uma única linha. Exceto quando eles envolvem a meia-noite de domingo , nesse caso eles devem ser divididos em duas linhas. A função
f_hoo_hours()
abaixo cuida disso.
A restrição de verificação
hoo_standard_week
impõe os limites externos da semana de preparação usando o operador "intervalo contido por" <@
. Com inclusivo limites, você deve observar um caso de canto onde o tempo termina na meia-noite de domingo:
'1996-01-01 00:00+0' = '1996-01-08 00:00+0'
Mon 00:00 = Sun 24:00 (= next Mon 00:00)
Você tem que procurar os dois timestamps de uma só vez. Aqui está um caso relacionado com exclusivo limite superior que não exibiria essa deficiência:
- Evitando entradas adjacentes/sobrepostas com EXCLUDE no PostgreSQL
Função f_hoo_time(timestamptz)
Para "normalizar" qualquer
timestamp with time zone
:CREATE OR REPLACE FUNCTION f_hoo_time(timestamptz)
RETURNS timestamp
LANGUAGE sql IMMUTABLE PARALLEL SAFE AS
$func$
SELECT timestamp '1996-01-01' + ($1 AT TIME ZONE 'UTC' - date_trunc('week', $1 AT TIME ZONE 'UTC'))
$func$;
PARALLEL SAFE
apenas para Postgres 9.6 ou posterior. A função recebe
timestamptz
e retorna timestamp
. Adiciona o intervalo decorrido da respectiva semana ($1 - date_trunc('week', $1)
em hora UTC para o ponto de partida da nossa semana de preparação. (date
+ interval
produz timestamp
.) Função f_hoo_hours(timestamptz, timestamptz)
Para normalizar os intervalos e dividir aqueles que cruzam Seg 00:00. Esta função leva qualquer intervalo (como dois
timestamptz
) e produz um ou dois tsrange
normalizados valores. Abrange qualquer entrada legal e não permite o resto:CREATE OR REPLACE FUNCTION f_hoo_hours(_from timestamptz, _to timestamptz)
RETURNS TABLE (hoo_hours tsrange)
LANGUAGE plpgsql IMMUTABLE PARALLEL SAFE COST 500 ROWS 1 AS
$func$
DECLARE
ts_from timestamp := f_hoo_time(_from);
ts_to timestamp := f_hoo_time(_to);
BEGIN
-- sanity checks (optional)
IF _to <= _from THEN
RAISE EXCEPTION '%', '_to must be later than _from!';
ELSIF _to > _from + interval '1 week' THEN
RAISE EXCEPTION '%', 'Interval cannot span more than a week!';
END IF;
IF ts_from > ts_to THEN -- split range at Mon 00:00
RETURN QUERY
VALUES (tsrange('1996-01-01', ts_to , '[]'))
, (tsrange(ts_from, '1996-01-08', '[]'));
ELSE -- simple case: range in standard week
hoo_hours := tsrange(ts_from, ts_to, '[]');
RETURN NEXT;
END IF;
RETURN;
END
$func$;
Para
INSERT
um único linha de entrada:INSERT INTO hoo(shop_id, hours)
SELECT 123, f_hoo_hours('2016-01-11 00:00+04', '2016-01-11 08:00+04');
Para qualquer número de linhas de entrada:
INSERT INTO hoo(shop_id, hours)
SELECT id, f_hoo_hours(f, t)
FROM (
VALUES (7, timestamptz '2016-01-11 00:00+0', timestamptz '2016-01-11 08:00+0')
, (8, '2016-01-11 00:00+1', '2016-01-11 08:00+1')
) t(id, f, t);
Cada um pode inserir duas linhas se um intervalo precisar ser dividido em Mon 00:00 UTC.
Consulta
Com o design ajustado, toda sua consulta grande, complexa e cara pode ser substituído por ... isto:
SELECT *
FROM hoo
WHERE hours @> f_hoo_time(now());
Para um pouco de suspense, coloquei uma placa de spoiler sobre a solução. Mova o mouse isso.
A consulta é apoiada pelo referido índice GiST e rápida, mesmo para tabelas grandes.
db<>mexa aqui (com mais exemplos)
Old sqlfiddle
Se você quiser calcular o total de horas de funcionamento (por loja), aqui está uma receita:
- Calcular horas de trabalho entre 2 datas no PostgreSQL
Índice e desempenho
O operador de contenção para tipos de intervalo pode ser compatível com um GiST ou SP-GiST índice. Qualquer um pode ser usado para implementar uma restrição de exclusão, mas apenas o GiST suporta índices de várias colunas:
Atualmente, apenas os tipos de índice B-tree, GiST, GIN e BRIN suportam índices de várias colunas.
E a ordem das colunas de índice é importante:
Um índice GiST de várias colunas pode ser usado com condições de consulta que envolvem qualquer subconjunto das colunas do índice. As condições em colunas adicionais restringem as entradas retornadas pelo índice, mas a condição na primeira coluna é a mais importante para determinar quanto do índice precisa ser verificado. Um índice GiST será relativamente ineficaz se sua primeira coluna tiver apenas alguns valores distintos, mesmo que haja muitos valores distintos em colunas adicionais.
Portanto, temos interesses conflitantes aqui. Para tabelas grandes, haverá muito mais valores distintos para
shop_id
do que para hours
. - Um índice GiST com
shop_id
principal é mais rápido escrever e aplicar a restrição de exclusão. - Mas estamos pesquisando
hours
em nossa consulta. Ter essa coluna primeiro seria melhor. - Se precisarmos procurar
shop_id
em outras consultas, um índice btree simples é muito mais rápido para isso. - Para completar, encontrei um SP-GiST indexe em apenas
hours
para ser mais rápido para a consulta.
Referência
Novo teste com Postgres 12 em um laptop antigo.Meu script para gerar dados fictícios:
INSERT INTO hoo(shop_id, hours)
SELECT id
, f_hoo_hours(((date '1996-01-01' + d) + interval '4h' + interval '15 min' * trunc(32 * random())) AT TIME ZONE 'UTC'
, ((date '1996-01-01' + d) + interval '12h' + interval '15 min' * trunc(64 * random() * random())) AT TIME ZONE 'UTC')
FROM generate_series(1, 30000) id
JOIN generate_series(0, 6) d ON random() > .33;
Resulta em ~ 141 mil linhas geradas aleatoriamente, ~ 30 mil
shop_id
distintos , ~ 12.000 hours
distintas . Tamanho da tabela 8 MB. Eu larguei e recriei a restrição de exclusão:
ALTER TABLE hoo
DROP CONSTRAINT hoo_no_overlap
, ADD CONSTRAINT hoo_no_overlap EXCLUDE USING gist (shop_id WITH =, hours WITH &&); -- 3.5 sec; index 8 MB
ALTER TABLE hoo
DROP CONSTRAINT hoo_no_overlap
, ADD CONSTRAINT hoo_no_overlap EXCLUDE USING gist (hours WITH &&, shop_id WITH =); -- 13.6 sec; index 12 MB
shop_id
primeiro é ~ 4x mais rápido para esta distribuição. Além disso, testei mais dois para desempenho de leitura:
CREATE INDEX hoo_hours_gist_idx on hoo USING gist (hours);
CREATE INDEX hoo_hours_spgist_idx on hoo USING spgist (hours); -- !!
Após
VACUUM FULL ANALYZE hoo;
, executei duas consultas:- 1º trimestre :tarde da noite, encontrando apenas 35 linhas
- 2º trimestre :à tarde, encontrar 4547 linhas .
Resultados
Obteve uma varredura somente de índice para cada (exceto para "sem índice", é claro):
index idx size Q1 Q2
------------------------------------------------
no index 38.5 ms 38.5 ms
gist (shop_id, hours) 8MB 17.5 ms 18.4 ms
gist (hours, shop_id) 12MB 0.6 ms 3.4 ms
gist (hours) 11MB 0.3 ms 3.1 ms
spgist (hours) 9MB 0.7 ms 1.8 ms -- !
- SP-GiST e GiST estão no mesmo nível para consultas que encontram poucos resultados (GiST é ainda mais rápido para muito poucos).
- O SP-GiST é melhor dimensionado com um número crescente de resultados e também é menor.
Se você lê muito mais do que escreve (caso de uso típico), mantenha a restrição de exclusão conforme sugerido no início e crie um índice SP-GiST adicional para otimizar o desempenho de leitura.