Vincoli di modellazione su aggregati di sottoinsieme?


14

Sto usando PostgreSQL ma immagino che la maggior parte dei db di fascia alta debba avere alcune funzionalità simili e, inoltre, che le soluzioni per loro possano ispirare soluzioni per me, quindi non considerare questo specifico di PostgreSQL.

So di non essere il primo a cercare di risolvere questo problema, quindi immagino che valga la pena chiedere qui, ma sto cercando di valutare i costi della modellizzazione dei dati contabili in modo tale che ogni transazione sia sostanzialmente bilanciata. I dati contabili sono solo appendici. Il vincolo generale (scritto in pseudo-codice) qui potrebbe apparire approssimativamente come:

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

Ovviamente un tale vincolo di controllo non funzionerà mai. Funziona per riga e potrebbe controllare l'intero db. Quindi fallirà sempre e sarà lento nel farlo.

Quindi la mia domanda è qual è il modo migliore per modellare questo vincolo? Finora ho praticamente esaminato due idee. Mi chiedo se questi sono gli unici o se qualcuno ha un modo migliore (oltre a lasciarlo al livello di app o ad un proc memorizzato).

  1. Potrei prendere in prestito una pagina dal concetto del mondo contabile della differenza tra un libro di entrata originale e un libro di entrata finale (giornale generale vs libro mastro). A questo proposito, potrei modellarlo come un array di righe di giornale associate alla voce di journal, applicare il vincolo sull'array (in termini PostgreSQL, selezionare sum (amount) = 0 da unest (je.line_items). Un trigger potrebbe espandersi e salvali in una tabella degli elementi pubblicitari, in cui i vincoli delle singole colonne potrebbero essere applicati più facilmente e dove gli indici ecc. potrebbero essere più utili. Questa è la direzione in cui mi sto appoggiando.
  2. Potrei provare a codificare un trigger di vincolo che lo imponga per transazione con l'idea che la somma di una serie di 0 sarà sempre 0.

Li sto valutando rispetto all'attuale approccio di applicazione della logica in una procedura memorizzata. Il costo della complessità viene valutato rispetto all'idea che la prova matematica dei vincoli è superiore ai test unitari. Il principale svantaggio del n. 1 sopra è che i tipi come tuple sono una di quelle aree di PostgreSQL in cui si incontrano comportamenti inconsistenti e cambiano assunzioni regolarmente e quindi spero persino che i comportamenti in quest'area possano cambiare nel tempo. Progettare una versione futura sicura non è così semplice.

Esistono altri modi per risolvere questo problema che scaleranno fino a milioni di record in ogni tabella? Mi sto perdendo qualcosa? C'è un compromesso che mi sono perso?

In risposta al punto di Craig che segue sulle versioni, almeno, questo dovrà essere eseguito su PostgreSQL 9.2 e versioni successive (forse 9.1 e versioni successive, ma probabilmente possiamo procedere con le versioni 9.2).

Risposte:


12

Poiché dobbiamo estendere più righe, non può essere implementato con un semplice CHECKvincolo.

Possiamo anche escludere vincoli di esclusione . Si estenderebbero su più file, ma verificherebbero solo le disuguaglianze. Operazioni complesse come una somma su più righe non sono possibili.

Lo strumento che sembra adattarsi meglio al tuo caso è un CONSTRAINT TRIGGER(o anche solo un semplice TRIGGER- l'unica differenza nell'attuale implementazione è che puoi regolare i tempi del trigger con SET CONSTRAINTS.

Quindi questa è la tua opzione 2 .

Una volta che possiamo fare affidamento sul vincolo che viene applicato in ogni momento, non è più necessario controllare l'intera tabella. È sufficiente controllare solo le righe inserite nella transazione corrente - al termine della transazione. Le prestazioni dovrebbero essere ok.

Inoltre, come

I dati contabili sono solo appendici.

... dobbiamo solo preoccuparci delle righe appena inserite . (Supponendo UPDATEo DELETEnon sono possibili.)

Uso la colonna di sistema xide la confronto con la funzione txid_current(), che restituisce la xidtransazione corrente. Per confrontare i tipi, è necessario il casting ... Questo dovrebbe essere ragionevolmente sicuro. Considera questa risposta correlata e successiva con un metodo più sicuro:

dimostrazione

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();

Rinviato , quindi viene verificato solo al termine della transazione.

test

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

Lavori.

INSERT INTO journal_line(amount) VALUES (1);

Non riesce:

ERRORE: voci non bilanciate!

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;

Lavori. :)

Se è necessario applicare il proprio vincolo prima della fine della transazione, è possibile farlo in qualsiasi momento della transazione, anche all'inizio:

SET CONSTRAINTS insaft_check_balance IMMEDIATE;

Più veloce con grilletto semplice

Se si opera con più righe INSERT, è più efficace eseguire il trigger per istruzione, il che non è possibile con i trigger di vincolo :

I trigger di vincolo possono essere specificati solo FOR EACH ROW.

Usa invece un semplice grilletto e spara FOR EACH STATEMENTper ...

  • perdere l'opzione di SET CONSTRAINTS.
  • ottenere prestazioni.

ELIMINA possibile

In risposta al tuo commento: Se DELETEè possibile, potresti aggiungere un trigger simile eseguendo un controllo del saldo dell'intera tabella dopo che si è verificato un ELIMINA. Questo sarebbe molto più costoso, ma non importa molto come accade raramente.


Quindi questo è un voto per l'articolo # 2. Il vantaggio è che hai una sola tabella per tutti i vincoli e che è una vittoria vincente lì, ma dall'altro, stai impostando trigger che sono essenzialmente procedurali e quindi se stiamo testando unità che non sono provate in modo dichiarativo, allora si ottiene di più complicato. Come sopporteresti il ​​fatto di non avere una memoria nidificata con vincoli dichiarativi?
Chris Travers,

Anche l'aggiornamento non è possibile, l'eliminazione potrebbe avvenire in determinate circostanze * ma sarebbe quasi certamente una procedura molto ristretta e ben collaudata. A fini pratici, l'eliminazione può essere ignorata come problema di vincolo. * Ad esempio, eliminare tutti i dati di età superiore ai 10 anni, il che sarebbe possibile solo se si utilizza un modello di log, aggregato e snapshot che è comunque abbastanza tipico nei sistemi contabili.
Chris Travers,

@ChrisTravers. Ho aggiunto un aggiornamento e indirizzato possibile DELETE. Non saprei cosa è tipico o richiesto in contabilità, non la mia area di competenza. Sto solo cercando di fornire una soluzione (abbastanza efficace IMO) al problema descritto.
Erwin Brandstetter,

@Erwin Brandstetter Non me ne preoccuperei per le cancellazioni. Le eliminazioni, se applicabile, sarebbero soggette a una serie molto più ampia di vincoli e i test unitari sono praticamente del tutto inevitabili lì. Mi chiedevo principalmente di pensare ai costi della complessità. In ogni caso, le eliminazioni possono essere risolte in modo molto semplice con un tasto cascade on delete.
Chris Travers,

4

La seguente soluzione di SQL Server utilizza solo vincoli. Sto usando approcci simili in più punti del mio 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) ;

questo è un approccio interessante. I vincoli sembrano funzionare sull'istruzione anziché sulla tupla o sul livello della transazione, giusto? Inoltre, significa che i tuoi sottoinsiemi hanno l'ordinamento dei sottoinsiemi integrato, giusto? Questo è un approccio davvero affascinante e anche se sicuramente non si traduce direttamente in Pgsql, è comunque fonte di ispirazione. Grazie!
Chris Travers,

@ Chris: Penso che funzioni bene in Postgres (dopo aver rimosso il dbo.e il GO): sql-fiddle
ypercubeᵀᴹ

Ok, lo stavo fraintendendo. Sembra che si possa usare una soluzione simile qui. Tuttavia non avresti bisogno di un trigger separato per cercare il totale parziale della riga precedente per essere sicuro? Altrimenti ti stai fidando della tua app per inviare dati sani, giusto? È ancora un modello interessante che potrei essere in grado di adattare.
Chris Travers,

A proposito, ho votato entrambe le soluzioni. Elencare l'altro come preferibile perché sembra meno complesso. Tuttavia, penso che questa sia una soluzione molto interessante e mi apre nuovi modi di pensare a vincoli molto complessi per me. Grazie!
Chris Travers,

E non è necessario alcun trigger per cercare il totale parziale della riga precedente per essere al sicuro. Ciò è gestito dal FK_Lines_PreviousLinevincolo di chiave esterna.
ypercubeᵀᴹ
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.