sql >> Base de Datos >  >> RDS >> PostgreSQL

Realice esta consulta de horas de funcionamiento en PostgreSQL

Disposición de la mesa

Rediseñar la tabla para almacenar los horarios de apertura (horas de funcionamiento) como un conjunto de tsrange (rango de timestamp without time zone ) valores. Requiere Postgres 9.2 o posterior .

Elija una semana al azar para organizar su horario de apertura. Me gusta la semana:
1996-01-01 (lunes) al 1996-01-07 (domingo)
Ese es el año bisiesto más reciente donde el 1 de enero resulta convenientemente un lunes. Pero puede ser cualquier semana aleatoria para este caso. Solo sé consistente.

Instale el módulo adicional btree_gist primero:

CREATE EXTENSION btree_gist;

Ver:

  • Equivalente a la restricción de exclusión compuesta por un número entero y un rango

Luego crea la tabla así:

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]')
);

El uno columna hours reemplaza todas sus columnas:

opens_on, closes_on, opens_at, closes_at

Por ejemplo, el horario de atención a partir del miércoles a las 18:30 al jueves, 05:00 UTC se ingresan como:

'[1996-01-03 18:30, 1996-01-04 05:00]'

La restricción de exclusión hoo_no_overlap evita la superposición de entradas por tienda. Se implementa con un índice GiST , que también respalda nuestras consultas. Considere el capítulo "Índice y rendimiento" a continuación se analizan las estrategias de indexación.

La restricción de comprobación hoo_bounds_inclusive impone límites inclusivos para sus rangos, con dos consecuencias notables:

  • Siempre se incluye un punto en el tiempo que se encuentra exactamente en el límite inferior o superior.
  • Las entradas adyacentes para la misma tienda no están permitidas. Con límites inclusivos, estos se "superpondrán" y la restricción de exclusión generaría una excepción. En su lugar, las entradas adyacentes deben combinarse en una sola fila. Excepto cuando alrededor de la medianoche del domingo , en cuyo caso deben dividirse en dos filas. La función f_hoo_hours() a continuación se encarga de esto.

La restricción de comprobación hoo_standard_week hace cumplir los límites exteriores de la semana de preparación utilizando el operador <@ "el rango está contenido por" .

Con inclusivo límites, tienes que observar un caso de esquina donde el tiempo termina en la medianoche del domingo:

'1996-01-01 00:00+0' = '1996-01-08 00:00+0'
 Mon 00:00 = Sun 24:00 (= next Mon 00:00)

Tienes que buscar ambas marcas de tiempo a la vez. Aquí hay un caso relacionado con exclusivo límite superior que no mostraría esta deficiencia:

  • Evitar entradas adyacentes/superpuestas con EXCLUDE en PostgreSQL

Función f_hoo_time(timestamptz)

Para "normalizar" cualquier 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 solo para Postgres 9.6 o posterior.

La función toma timestamptz y devuelve timestamp . Agrega el intervalo transcurrido de la semana respectiva ($1 - date_trunc('week', $1) en horario UTC hasta el punto de inicio de nuestra semana de preparación. (date + interval produce timestamp .)

Función f_hoo_hours(timestamptz, timestamptz)

Para normalizar rangos y dividir los que cruzan lun 00:00. Esta función toma cualquier intervalo (como dos timestamptz ) y produce uno o dos tsrange normalizados valores. Cubre cualquier entrada legal y rechaza el 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 un soltero fila 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 cualquier número de filas 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 uno puede insertar dos filas si es necesario dividir un rango a las 00:00 UTC del lunes.

Consulta

Con el diseño ajustado, toda su consulta grande, compleja y costosa se puede reemplazar con... esto:

SELECT *
FROM hoo
WHERE hours @> f_hoo_time(now());

Para un poco de suspenso, puse un spoiler sobre la solución. Mueve el mouse sobre eso.

La consulta está respaldada por dicho índice GiST y es rápida, incluso para tablas grandes.

db<>violín aquí (con más ejemplos)
Sqlfiddle antiguo

Si quieres calcular el horario total de apertura (por tienda), aquí tienes una receta:

  • Calcular horas de trabajo entre 2 fechas en PostgreSQL

Índice y rendimiento

El operador de contención para tipos de rango se puede admitir con GiST o SP-GiST índice. Cualquiera puede usarse para implementar una restricción de exclusión, pero solo GiST admite índices de varias columnas:

Actualmente, solo los tipos de índice B-tree, GiST, GIN y BRIN admiten índices de varias columnas.

Y el orden de las columnas de índice importa:

Se puede usar un índice GiST de varias columnas con condiciones de consulta que involucren cualquier subconjunto de las columnas del índice. Las condiciones en columnas adicionales restringen las entradas devueltas por el índice, pero la condición en la primera columna es la más importante para determinar cuánto del índice debe escanearse. Un índice GiST será relativamente ineficaz si su primera columna tiene solo unos pocos valores distintos, incluso si hay muchos valores distintos en columnas adicionales.

Así que tenemos intereses en conflicto aquí. Para tablas grandes, habrá muchos más valores distintos para shop_id que por hours .

  • Un índice GiST con shop_id líder es más rápido de escribir y de hacer cumplir la restricción de exclusión.
  • Pero estamos buscando hours en nuestra consulta. Tener esa columna primero sería mejor.
  • Si necesitamos buscar shop_id en otras consultas, un índice btree simple es mucho más rápido para eso.
  • Para colmo, encontré un SP-GiST índice en solo hours ser más rápido para la consulta.

Valor de referencia

Nueva prueba con Postgres 12 en una laptop antigua. Mi script para generar datos ficticios:

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;

Da como resultado ~ 141 000 filas generadas aleatoriamente, ~ 30 000 shop_id distintos , ~ 12k hours distintas . Tamaño de la tabla 8 MB.

Solté y recreé la restricción de exclusión:

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 first es ~ 4 veces más rápido para esta distribución.

Además, probé dos más para el rendimiento de lectura:

CREATE INDEX hoo_hours_gist_idx   on hoo USING gist (hours);
CREATE INDEX hoo_hours_spgist_idx on hoo USING spgist (hours);  -- !!

Después de VACUUM FULL ANALYZE hoo; , realicé dos consultas:

  • Q1 :tarde en la noche, encontrando solo 35 filas
  • Q2 :por la tarde, encontrando 4547 filas .

Resultados

Obtuve un escaneo de solo índice para cada uno (excepto "sin índice", por supuesto):

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 y GiST están a la par para consultas que encuentran pocos resultados (GiST es incluso más rápido para muy pocos).
  • SP-GiST escala mejor con un número creciente de resultados y también es más pequeño.

Si lee mucho más de lo que escribe (caso de uso típico), mantenga la restricción de exclusión como se sugirió al principio y cree un índice SP-GiST adicional para optimizar el rendimiento de lectura.