Modelando restrições em agregados de subconjuntos?

14

Estou usando o PostgreSQL, mas acho que a maioria dos bancos de dados de ponta precisa ter recursos semelhantes e, além disso, que as soluções para eles podem inspirar soluções para mim, então não considere isso específico para o PostgreSQL.

Sei que não sou o primeiro a tentar resolver esse problema, então acho que vale a pena perguntar aqui, mas estou tentando avaliar os custos de modelagem de dados contábeis, de modo que todas as transações sejam fundamentalmente equilibradas. Os dados contábeis são apenas anexados. A restrição geral (escrita em pseudo-código) aqui pode parecer aproximadamente:

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, essa restrição de verificação nunca funcionará. Ele opera por linha e pode verificar o banco de dados inteiro. Por isso, sempre falha e demora a fazê-lo.

Então, minha pergunta é qual é a melhor maneira de modelar essa restrição? Até agora, olhei basicamente duas idéias. Pensando se esses são os únicos ou se alguém tem uma maneira melhor (exceto deixá-lo no nível do aplicativo ou em um processo armazenado).

  1. Eu poderia pegar emprestada uma página do conceito do mundo contábil da diferença entre um livro de entrada original e um livro de entrada final (diário geral versus contabilidade). Nesse aspecto, eu poderia modelar isso como uma matriz de linhas de diário anexadas à entrada do diário, impor a restrição na matriz (em termos do PostgreSQL, selecione sum (amount) = 0 da unnest (je.line_items). salve-os em uma tabela de itens de linha, na qual restrições de colunas individuais poderiam ser aplicadas com mais facilidade e onde índices etc. poderiam ser mais úteis.
  2. Eu poderia tentar codificar um gatilho de restrição que aplicaria isso por transação, com a ideia de que a soma de uma série de 0s sempre será 0.

Eu os estou comparando com a abordagem atual de impor a lógica em um procedimento armazenado. O custo da complexidade está sendo pesado contra a ideia de que a prova matemática de restrições é superior aos testes de unidade. A principal desvantagem do nº 1 acima é que tipos como tuplas são uma daquelas áreas do PostgreSQL em que se depara com comportamentos inconsistentes e mudanças nas suposições regularmente e, portanto, eu espero que o comportamento nessa área possa mudar com o tempo. Projetar uma versão futura segura não é tão fácil.

Existem outras maneiras de resolver esse problema que escalarão até milhões de registros em cada tabela? Estou esquecendo de algo? Existe uma troca que eu perdi?

Em resposta ao ponto de Craig abaixo sobre as versões, no mínimo, isso terá que ser executado no PostgreSQL 9.2 e superior (talvez 9.1 e superior, mas provavelmente podemos seguir com o 9.2).

Chris Travers
fonte

Respostas:

12

Como temos que abranger várias linhas, ela não pode ser implementada com uma CHECKrestrição simples .

Também podemos descartar restrições de exclusão . Essas abrangem várias linhas, mas apenas verificam se há desigualdade. Operações complexas como uma soma em várias linhas não são possíveis.

A ferramenta que parece mais adequada ao seu caso é uma CONSTRAINT TRIGGER(ou apenas uma simples) TRIGGER- a única diferença na implementação atual é que você pode ajustar o tempo do gatilho SET CONSTRAINTS.

Então essa é a sua opção 2 .

Uma vez que podemos confiar na restrição que está sendo aplicada o tempo todo, não precisamos mais verificar a tabela inteira. Verificar apenas as linhas inseridas na transação atual - no final da transação - é suficiente. O desempenho deve estar bem.

Tambem como

Os dados contábeis são apenas anexados.

... precisamos nos preocupar apenas com as linhas recém- inseridas . (Supondo UPDATEou DELETEnão possível).

Uso a coluna do sistema xide a comparo com a função txid_current()- que retorna a xidtransação atual. Para comparar os tipos, a conversão é necessária ... Isso deve ser razoavelmente seguro. Considere isso relacionado, depois responda com um método mais seguro:

Demo

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 , portanto só é verificado no final da transação.

Testes

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

Trabalho.

INSERT INTO journal_line(amount) VALUES (1);

Falha:

ERRO: Entradas não 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;

Trabalho. :)

Se você precisar impor sua restrição antes do final da transação, poderá fazê-lo em qualquer ponto da transação, mesmo no início:

SET CONSTRAINTS insaft_check_balance IMMEDIATE;

Mais rápido com gatilho simples

Se você opera com várias linhas INSERT, é mais eficaz acionar por instrução - o que não é possível com acionadores de restrição :

Os gatilhos de restrição podem ser especificados apenas FOR EACH ROW.

Use um gatilho comum e atire FOR EACH STATEMENTpara ...

  • perder a opção de SET CONSTRAINTS.
  • ganhar desempenho.

EXCLUIR possível

Em resposta ao seu comentário: Se DELETEpossível, você pode adicionar um gatilho semelhante, fazendo uma verificação do saldo de toda a tabela após a ocorrência de um DELETE. Isso seria muito mais caro, mas não importará muito, pois raramente acontece.

Erwin Brandstetter
fonte
Portanto, este é um voto para o item # 2. A vantagem é que você só tem uma tabela para todas as restrições e isso é uma vitória da complexidade, mas, por outro lado, você está configurando gatilhos que são essencialmente procedurais e, portanto, se estivermos testando unidades as coisas que não são comprovadas declarativamente, isso ganha mais complicado. Como você consideraria um armazenamento aninhado com restrições declarativas?
Chris Travers
Além disso, a atualização não é possível, a exclusão pode estar sob certas circunstâncias *, mas certamente seria um procedimento muito restrito e bem testado. Para fins práticos, a exclusão pode ser ignorada como um problema de restrição. * Por exemplo, eliminar todos os dados com mais de 10 anos de idade, o que só seria possível se você usasse um modelo de log, agregado e instantâneo, o que é bastante típico nos sistemas de contabilidade.
Chris Travers
@ChrisTravers. Eu adicionei uma atualização e resolvi possível DELETE. Eu não saberia o que é típico ou exigido em contabilidade - não minha área de especialização. Apenas tentando fornecer uma solução (IMO bastante eficaz) para o problema descrito.
Erwin Brandstetter
@ Erwin Brandstetter Eu não me preocuparia com isso para exclusões. As exclusões, se aplicáveis, estariam sujeitas a um conjunto muito maior de restrições e os testes de unidade são praticamente inevitáveis ​​lá. Eu estava pensando principalmente sobre pensamentos sobre custos de complexidade. De qualquer forma, as exclusões podem ser resolvidas de maneira muito simples com um botão de exclusão em cascata.
Chris Travers
4

A solução a seguir do SQL Server usa apenas restrições. Estou usando abordagens semelhantes em vários locais do meu 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) ;
AK
fonte
essa é uma abordagem interessante. As restrições parecem funcionar mais no demonstrativo do que no nível da tupla ou da transação, certo? Também significa que seus subconjuntos têm ordem de subconjuntos incorporada, correto? Essa é uma abordagem realmente fascinante e, embora definitivamente não se traduza diretamente no Pgsql, ainda é idéias inspiradoras. Obrigado!
Chris Travers
@ Chris: Eu acho que ele funciona muito bem em Postgres (depois de remover o dbo.eo GO): sql-fiddle
ypercubeᵀᴹ
Ok, eu estava entendendo errado. Parece que alguém poderia usar uma solução semelhante aqui. No entanto, você não precisaria de um gatilho separado para procurar o subtotal da linha anterior para estar seguro? Caso contrário, você está confiando no seu aplicativo para enviar dados sãos, certo? Ainda é um modelo interessante que talvez eu possa adaptar.
Chris Travers
BTW, votou nas duas soluções. Vai listar o outro como preferível, porque parece menos complexo. No entanto, acho que essa é uma solução muito interessante e abre novas maneiras de pensar sobre restrições muito complexas para mim. Obrigado!
Chris Travers
E você não precisa de nenhum gatilho para procurar o subtotal da linha anterior para estar seguro. Isso é resolvido pela FK_Lines_PreviousLinerestrição de chave estrangeira.
precisa saber é o seguinte