Modellierungsbeschränkungen für Teilmengenaggregate?

14

Ich verwende PostgreSQL, aber ich bin der Meinung, dass die meisten Top-End-Datenbanken über ähnliche Funktionen verfügen müssen. Außerdem können Lösungen für sie zu Lösungen für mich inspirieren. Betrachten Sie diese nicht als PostgreSQL-spezifisch.

Ich weiß, dass ich nicht der erste bin, der versucht, dieses Problem zu lösen, und daher ist es meiner Meinung nach sinnvoll, hier nachzufragen, aber ich versuche, die Kosten für die Modellierung von Buchhaltungsdaten so zu bewerten, dass jede Transaktion im Grunde genommen ausgewogen ist. Die Abrechnungsdaten können nur angehängt werden. Die allgemeine Einschränkung (in Pseudocode geschrieben) könnte hier ungefähr so ​​aussehen:

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

Offensichtlich wird eine solche Prüfbedingung niemals funktionieren. Es funktioniert pro Zeile und überprüft möglicherweise die gesamte Datenbank. So wird es immer scheitern und langsam dabei sein.

Meine Frage ist also, wie diese Einschränkung am besten modelliert werden kann. Grundsätzlich habe ich mir bisher zwei Ideen angesehen. Ich frage mich, ob dies die einzigen sind oder ob jemand einen besseren Weg hat (außer es der App-Ebene oder einem gespeicherten Prozess zu überlassen).

  1. Ich könnte mir eine Seite aus dem Konzept der Buchhaltungswelt über den Unterschied zwischen einem Buch mit ursprünglicher Erfassung und einem Buch mit endgültiger Erfassung (Hauptbuch vs. Hauptbuch) ausleihen. In dieser Hinsicht könnte ich dies als Array von Journalzeilen modellieren, die an den Journaleintrag angehängt sind, die Einschränkung für das Array erzwingen (in PostgreSQL-Begriffen wählen Sie sum (amount) = 0 aus unnest (je.line_items). Ein Trigger könnte expandieren und Speichern Sie diese in einer Werbebuchungstabelle, in der einzelne Spalteneinschränkungen einfacher durchgesetzt werden können und Indizes usw. nützlicher sind.
  2. Ich könnte versuchen, einen Einschränkungsauslöser zu codieren, der dies pro Transaktion erzwingt, mit der Idee, dass die Summe einer Reihe von Nullen immer 0 sein wird.

Ich wäge diese gegen den aktuellen Ansatz der Durchsetzung der Logik in einer gespeicherten Prozedur. Die Komplexitätskosten werden gegen die Idee abgewogen, dass der mathematische Nachweis von Einschränkungen Unit-Tests überlegen ist. Der Hauptnachteil von # 1 oben ist, dass Types as Tupel einer der Bereiche in PostgreSQL ist, in denen es zu inkonsistenten Verhaltensweisen und regelmäßigen Änderungen der Annahmen kommt, und ich würde sogar hoffen, dass sich das Verhalten in diesem Bereich im Laufe der Zeit ändern könnte. Das Entwerfen einer zukünftigen sicheren Version ist nicht so einfach.

Gibt es andere Möglichkeiten, um dieses Problem zu lösen, das sich auf Millionen von Datensätzen in jeder Tabelle skalieren lässt? Vermisse ich etwas? Gibt es einen Kompromiss, den ich verpasst habe?

Als Reaktion auf Craigs unten stehenden Punkt zu Versionen muss dies mindestens auf PostgreSQL 9.2 und höher (vielleicht 9.1 und höher, aber wahrscheinlich können wir mit Straight 9.2 weitermachen) ausgeführt werden.

Chris Travers
quelle

Antworten:

12

Da wir mehrere Zeilen überspannen müssen, kann es nicht mit einem einfachen implementiert werdenCHECK Einschränkung .

Wir können auch Ausschlussbeschränkungen ausschließen . Diese würden sich über mehrere Zeilen erstrecken, aber nur auf Ungleichheit prüfen. Komplexe Operationen wie eine Summe über mehrere Zeilen sind nicht möglich.

Das Werkzeug, das am besten zu Ihrem Fall zu passen scheint, ist ein CONSTRAINT TRIGGER(oder sogar nur ein einfacher TRIGGER- der einzige Unterschied in der aktuellen Implementierung besteht darin, dass Sie das Timing des Triggers mit anpassen können SET CONSTRAINTS.

Das ist also Ihre Option 2 .

Sobald wir uns darauf verlassen können, dass die Einschränkung jederzeit durchgesetzt wird, müssen wir nicht mehr die gesamte Tabelle überprüfen. Überprüft nur die in der aktuellen Transaktion eingefügten Zeilen am Ende der Transaktion . Die Leistung sollte in Ordnung sein.

Ebenso wie

Die Abrechnungsdaten können nur angehängt werden.

... müssen wir uns nur um neu eingefügte Zeilen kümmern . (Angenommen, UPDATEoderDELETE nicht möglich.)

Ich verwende die Systemspalte xidund vergleiche sie mit der Funktion, txid_current()die die xidder aktuellen Transaktion zurückgibt . Um die Typen zu vergleichen, muss gegossen werden ... Dies sollte einigermaßen sicher sein. Betrachten Sie diese verwandte, spätere Antwort mit einer sichereren Methode:

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

Aufgeschoben , wird also erst am Ende der Transaktion geprüft.

Tests

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

Funktioniert.

INSERT INTO journal_line(amount) VALUES (1);

Schlägt fehl:

FEHLER: Einträge nicht ausgeglichen!

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;

Funktioniert. :)

Wenn Sie Ihre Einschränkung vor dem Ende der Transaktion erzwingen müssen, können Sie dies zu jedem Zeitpunkt der Transaktion tun, auch zu Beginn:

SET CONSTRAINTS insaft_check_balance IMMEDIATE;

Schneller mit einfachem Abzug

Wenn Sie mit mehreren Zeilen arbeiten INSERT, ist es effektiver, per Anweisung auszulösen - was mit Einschränkungsauslösern nicht möglich ist :

Constraint-Trigger können nur angegeben werden FOR EACH ROW.

Benutze stattdessen einen einfachen Abzug und feuere, FOR EACH STATEMENTum ...

  • die Option verlieren von SET CONSTRAINTS.
  • Leistung gewinnen.

LÖSCHEN möglich

Als Antwort auf Ihren Kommentar: Wenn DELETEmöglich, können Sie einen ähnlichen Trigger hinzufügen , der nach einem DELETE eine Überprüfung des Gesamttabellenausgleichs durchführt . Dies wäre viel teurer, aber es spielt keine Rolle, da dies selten vorkommt.

Erwin Brandstetter
quelle
Dies ist also eine Abstimmung für Punkt # 2. Der Vorteil ist, dass Sie nur eine einzige Tabelle für alle Einschränkungen haben und das ist ein Komplexitätsgewinn, aber auf der anderen Seite richten Sie Trigger ein, die im Wesentlichen prozedural sind. Wenn wir also Unit-Tests durchführen, die nicht deklarativ bewiesen sind, wird das mehr kompliziert. Wie würden Sie es abwägen, einen verschachtelten Speicher mit deklarativen Einschränkungen zu haben?
Chris Travers
Ein Update ist ebenfalls nicht möglich. Das Löschen kann unter bestimmten Umständen * erfolgen, ist jedoch mit ziemlicher Sicherheit ein sehr enges und erprobtes Verfahren. Aus praktischen Gründen kann das Löschen als Einschränkungsproblem ignoriert werden. * Zum Beispiel das Löschen aller Daten, die älter als 10 Jahre sind und nur mit einem Protokoll-, Aggregat- und Snapshot-Modell möglich wären, das in Buchhaltungssystemen ohnehin recht typisch ist.
Chris Travers
@ ChrisTravers. Ich habe ein Update hinzugefügt und ggf. angesprochen DELETE. Ich würde nicht wissen, was in der Buchhaltung typisch oder erforderlich ist - nicht mein Fachgebiet. Ich versuche nur, eine (ziemlich effektive IMO) Lösung für das beschriebene Problem bereitzustellen.
Erwin Brandstetter
@Erwin Brandstetter Ich würde mir darüber keine Gedanken machen, wenn es um Löschvorgänge geht. Die Löschungen, falls zutreffend, würden einer viel größeren Reihe von Einschränkungen unterliegen, und Unit-Tests sind dort so gut wie unvermeidlich. Ich habe mich hauptsächlich über die Komplexitätskosten Gedanken gemacht. In jedem Fall können Löschvorgänge sehr einfach mit einer Ein-Lösch-Kaskaden-Taste gelöst werden.
Chris Travers
4

Die folgende SQL Server-Lösung verwendet nur Einschränkungen. Ich verwende ähnliche Ansätze an mehreren Stellen in meinem System.

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
quelle
Das ist ein interessanter Ansatz. Die Einschränkungen dort scheinen eher auf der Anweisung als auf der Tupel- oder Transaktionsebene zu wirken, oder? Das bedeutet auch, dass in Ihren Teilmengen eine Teilmengenreihenfolge integriert ist, richtig? Das ist ein wirklich faszinierender Ansatz und obwohl er definitiv nicht direkt in Pgsql übersetzt wird, sind es immer noch inspirierende Ideen. Vielen Dank!
Chris Travers
@ Chris: Ich denke, es funktioniert gut in Postgres (nach dem Entfernen des dbo.und des GO): SQL-Geige
Ypercubeᵀᴹ
Ok, ich habe es falsch verstanden. Es sieht so aus, als könnte man hier eine ähnliche Lösung verwenden. Benötigen Sie jedoch keinen separaten Trigger, um die Zwischensumme der vorherigen Zeile nachzuschlagen, um sicher zu sein? Ansonsten vertraust du darauf, dass deine App vernünftige Daten sendet, oder? Es ist immer noch ein interessantes Modell, das ich möglicherweise anpassen kann.
Chris Travers
Übrigens haben beide Lösungen positiv bewertet. Das andere wird als vorzuziehen eingestuft, weil es weniger komplex erscheint. Ich halte dies jedoch für eine sehr interessante Lösung, die mir neue Möglichkeiten eröffnet, über sehr komplexe Sachzwänge nachzudenken. Vielen Dank!
Chris Travers
Und Sie brauchen keinen Auslöser, um die Zwischensumme der vorherigen Zeile nachzuschlagen, um sicher zu sein. Dies wird durch die FK_Lines_PreviousLineFremdschlüsseleinschränkung sichergestellt.
ypercubeᵀᴹ