¿Restricciones de modelado en agregados de subconjuntos?

14

Estoy usando PostgreSQL pero creo que la mayoría de los db de gama alta deben tener algunas capacidades similares y, además, que las soluciones para ellos pueden inspirar soluciones para mí, así que no considere esto específico de PostgreSQL.

Sé que no soy el primero en tratar de resolver este problema, así que creo que vale la pena preguntar aquí, pero estoy tratando de evaluar los costos de modelar datos contables de manera que cada transacción sea fundamentalmente equilibrada. Los datos contables son de solo anexo. La restricción general (escrita en pseudocódigo) aquí podría verse más o menos así:

CREATE TABLE journal_entry (
    id bigserial not null unique, --artificial candidate key
    journal_type_id int references  journal_type(id),
    reference text, -- source document identifier, unique per journal
    date_posted date not null,
    PRIMARY KEY (journal_type_id, reference)
);

CREATE TABLE journal_line (
    entry_id bigint references journal_entry(id),
    account_id int not null references account(id),
    amount numeric not null,
    line_id bigserial not null unique,
    CHECK ((sum(amount) over (partition by entry_id) = 0) -- this won't work
);

Obviamente, tal restricción de verificación nunca funcionará. Funciona por fila y puede verificar toda la base de datos. Por lo tanto, siempre fallará y será lento al hacerlo.

Entonces, mi pregunta es ¿cuál es la mejor manera de modelar esta restricción? Básicamente, he visto dos ideas hasta ahora. Preguntándose si estos son los únicos, o si alguien tiene una mejor manera (aparte de dejarla en el nivel de la aplicación o un proceso almacenado).

  1. Podría tomar prestada una página del concepto del mundo contable de la diferencia entre un libro de entrada original y un libro de entrada final (diario general vs libro mayor). En este sentido, podría modelar esto como una matriz de líneas de diario adjuntas a la entrada de diario, imponer la restricción en la matriz (en términos de PostgreSQL, seleccionar sum (cantidad) = 0 de unnest (je.line_items). Un disparador podría expandirse y guárdelos en una tabla de elementos de línea, donde las restricciones de columnas individuales podrían aplicarse más fácilmente, y donde los índices, etc. podrían ser más útiles. Esta es la dirección en la que me estoy inclinando.
  2. Podría intentar codificar un activador de restricción que impondría esto por transacción con la idea de que la suma de una serie de 0 siempre será 0.

Las estoy comparando con el enfoque actual de imponer la lógica en un procedimiento almacenado. El costo de la complejidad se compara con la idea de que la prueba matemática de las restricciones es superior a las pruebas unitarias. El principal inconveniente del n. ° 1 anterior es que los tipos como tuplas son una de esas áreas en PostgreSQL donde uno se encuentra con un comportamiento inconsistente y cambios en las suposiciones regularmente, por lo que incluso espero que el comportamiento en esta área pueda cambiar con el tiempo. Diseñar una futura versión segura no es tan fácil.

¿Hay otras formas de resolver este problema que se escalen hasta millones de registros en cada tabla? ¿Me estoy perdiendo de algo? ¿Hay una compensación que me he perdido?

En respuesta al punto de Craig a continuación sobre las versiones, como mínimo, esto tendrá que ejecutarse en PostgreSQL 9.2 y superior (tal vez 9.1 y superior, pero probablemente podamos seguir con la versión 9.2).

Chris Travers
fuente

Respuestas:

12

Como tenemos que abarcar varias filas, no se puede implementar con una CHECKrestricción simple .

También podemos descartar restricciones de exclusión . Esos abarcarían varias filas, pero solo verificarían la desigualdad. Las operaciones complejas como una suma en varias filas no son posibles.

La herramienta que parece ajustarse mejor a su caso es una CONSTRAINT TRIGGER(O incluso simplemente TRIGGER: la única diferencia en la implementación actual es que puede ajustar el tiempo del disparador con SET CONSTRAINTS.

Entonces esa es tu opción 2 .

Una vez que podemos confiar en que la restricción se aplica en todo momento, ya no necesitamos verificar toda la tabla. Verificar solo las filas insertadas en la transacción actual , al final de la transacción, es suficiente. El rendimiento debería estar bien.

Tambien como

Los datos contables son de solo anexo.

... solo necesitamos preocuparnos por las filas recién insertadas . (Suponiendo UPDATEo DELETEno son posibles).

Utilizo la columna del sistema xidy la comparo con la función txid_current(), que devuelve la xidde la transacción actual. Para comparar los tipos, se necesita fundición ... Esto debería ser razonablemente seguro. Considere esta respuesta relacionada más tarde con un método más seguro:

Manifestación

CREATE TABLE journal_line(amount int); -- simplistic table for demo

CREATE OR REPLACE FUNCTION trg_insaft_check_balance()
    RETURNS trigger AS
$func$
BEGIN
   IF sum(amount) <> 0
      FROM journal_line 
      WHERE xmin::text::bigint = txid_current()  -- consider link above
         THEN
      RAISE EXCEPTION 'Entries not balanced!';
   END IF;

   RETURN NULL;  -- RETURN value of AFTER trigger is ignored anyway
END;
$func$ LANGUAGE plpgsql;

CREATE CONSTRAINT TRIGGER insaft_check_balance
    AFTER INSERT ON journal_line
    DEFERRABLE INITIALLY DEFERRED
    FOR EACH ROW
    EXECUTE PROCEDURE trg_insaft_check_balance();

Diferido , por lo que solo se verifica al final de la transacción.

Pruebas

INSERT INTO journal_line(amount) VALUES (1), (-1);

Trabajos.

INSERT INTO journal_line(amount) VALUES (1);

Falla:

ERROR: ¡Entradas no equilibradas!

BEGIN;
INSERT INTO journal_line(amount) VALUES (7), (-5);
-- do other stuff
SELECT * FROM journal_line;
INSERT INTO journal_line(amount) VALUES (-2);
-- INSERT INTO journal_line(amount) VALUES (-1); -- make it fail
COMMIT;

Trabajos. :)

Si necesita aplicar su restricción antes del final de la transacción, puede hacerlo en cualquier momento de la transacción, incluso al principio:

SET CONSTRAINTS insaft_check_balance IMMEDIATE;

Más rápido con gatillo simple

Si opera con varias filas INSERT, es más efectivo disparar por declaración, lo que no es posible con disparadores de restricción :

Los desencadenantes de restricción solo se pueden especificar FOR EACH ROW.

Use un disparador simple en su lugar y dispare FOR EACH STATEMENTpara ...

  • perderá la opción de SET CONSTRAINTS.
  • ganar rendimiento

BORRAR posible

En respuesta a su comentario: Si DELETEes posible, puede agregar un disparador similar haciendo una verificación de saldo de toda la tabla después de que haya ocurrido una ELIMINACIÓN. Esto sería mucho más costoso, pero no importará mucho, ya que rara vez sucede.

Erwin Brandstetter
fuente
Entonces este es un voto para el ítem # 2. La ventaja es que solo tiene una sola tabla para todas las restricciones y eso es una ganancia de complejidad allí, pero por otro lado, está configurando desencadenantes que son esencialmente de procedimiento y, por lo tanto, si estamos probando unidades que no se prueban declarativamente, entonces eso se vuelve más Complicado. ¿Cómo calificaría el sombrero contra tener un almacenamiento anidado con restricciones declarativas?
Chris Travers
Tampoco es posible la actualización, la eliminación puede ser en ciertas circunstancias *, pero casi con certeza sería un procedimiento muy limitado y bien probado. Para fines prácticos, la eliminación se puede ignorar como un problema de restricción. * Por ejemplo, purgar todos los datos de más de 10 años, lo cual sería posible solo si se utiliza un modelo de registro, agregado e instantánea que, de todos modos, es bastante típico en los sistemas de contabilidad.
Chris Travers
@ChrisTravers. Agregué una actualización y abordé posible DELETE. No sabría lo que es típico o requerido en contabilidad, no es mi área de especialización. Solo trato de proporcionar una solución (IMO bastante efectiva) al problema descrito.
Erwin Brandstetter
@Erwin Brandstetter No me preocuparía por eso para las eliminaciones. Las eliminaciones, si corresponde, estarían sujetas a un conjunto de restricciones mucho mayor y las pruebas unitarias son prácticamente inevitables allí. Sobre todo me preguntaba sobre los pensamientos sobre los costos de complejidad. En cualquier caso, las eliminaciones se pueden resolver de manera muy simple con una tecla de borrado en cascada.
Chris Travers
4

La siguiente solución de SQL Server usa solo restricciones. Estoy usando enfoques similares en varios lugares de mi sistema.

CREATE TABLE dbo.Lines
  (
    EntryID INT NOT NULL ,
    LineNumber SMALLINT NOT NULL ,
    CONSTRAINT PK_Lines PRIMARY KEY ( EntryID, LineNumber ) ,
    PreviousLineNumber SMALLINT NOT NULL ,
    CONSTRAINT UNQ_Lines UNIQUE ( EntryID, PreviousLineNumber ) ,
    CONSTRAINT CHK_Lines_PreviousLineNumber_Valid CHECK ( ( LineNumber > 0
            AND PreviousLineNumber = LineNumber - 1
          )
          OR ( LineNumber = 0 ) ) ,
    Amount INT NOT NULL ,
    RunningTotal INT NOT NULL ,
    CONSTRAINT UNQ_Lines_FkTarget UNIQUE ( EntryID, LineNumber, RunningTotal ) ,
    PreviousRunningTotal INT NOT NULL ,
    CONSTRAINT CHK_Lines_PreviousRunningTotal_Valid CHECK 
        ( PreviousRunningTotal + Amount = RunningTotal ) ,
    CONSTRAINT CHK_Lines_TotalAmount_Zero CHECK ( 
            ( LineNumber = 0
                AND PreviousRunningTotal = 0
              )
              OR ( LineNumber > 0 ) ),
    CONSTRAINT FK_Lines_PreviousLine 
        FOREIGN KEY ( EntryID, PreviousLineNumber, PreviousRunningTotal )
        REFERENCES dbo.Lines ( EntryID, LineNumber, RunningTotal )
  ) ;
GO

-- valid subset inserts
INSERT INTO dbo.Lines(EntryID ,
        LineNumber ,
        PreviousLineNumber ,
        Amount ,
        RunningTotal ,
        PreviousRunningTotal )
VALUES(1, 0, 2, 10, 10, 0),
(1, 1, 0, -5, 5, 10),
(1, 2, 1, -5, 0, 5);

-- invalid subset fails
INSERT INTO dbo.Lines(EntryID ,
        LineNumber ,
        PreviousLineNumber ,
        Amount ,
        RunningTotal ,
        PreviousRunningTotal )
VALUES(2, 0, 1, 10, 10, 5),
(2, 1, 0, -5, 5, 10) ;
Alaska
fuente
Ese es un enfoque interesante. Las restricciones allí parecen funcionar en la declaración en lugar de la tupla o el nivel de transacción, ¿verdad? También significa que sus subconjuntos tienen orden de subconjunto incorporado, ¿correcto? Es un enfoque realmente fascinante y, aunque definitivamente no se traduce directamente a Pgsql, sigue inspirando ideas. ¡Gracias!
Chris Travers
@Chris: Creo que funciona bien en Postgres (después de eliminar el dbo.y el GO): sql-fiddle
ypercubeᵀᴹ
Ok, lo estaba entendiendo mal. Parece que uno podría usar una solución similar aquí. Sin embargo, ¿no necesitarías un disparador por separado para buscar el subtotal de la línea anterior para estar seguro? De lo contrario, está confiando en que su aplicación envíe datos sanos, ¿verdad? Todavía es un modelo interesante que podría adaptar.
Chris Travers
Por cierto, votaron a favor de ambas soluciones. Va a enumerar al otro como preferible porque parece menos complejo. Sin embargo, creo que esta es una solución muy interesante y me abre nuevas formas de pensar sobre restricciones muy complejas para mí. ¡Gracias!
Chris Travers
Y no necesita ningún disparador para buscar el subtotal de la línea anterior para estar seguro. Esto se soluciona con la FK_Lines_PreviousLinerestricción de clave externa.
ypercubeᵀᴹ