Modélisation des contraintes sur les agrégats de sous-ensembles?


14

J'utilise PostgreSQL mais je pense que la plupart des bases de données haut de gamme doivent avoir des capacités similaires, et de plus, que leurs solutions peuvent m'inspirer des solutions, alors ne considérez pas cette spécification PostgreSQL.

Je sais que je ne suis pas le premier à essayer de résoudre ce problème, donc je pense qu'il vaut la peine de le demander ici, mais j'essaie d'évaluer les coûts de modélisation des données comptables de sorte que chaque transaction soit fondamentalement équilibrée. Les données comptables sont en annexe uniquement. La contrainte globale (écrite en pseudo-code) ici pourrait ressembler à peu près à:

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

De toute évidence, une telle contrainte de vérification ne fonctionnera jamais. Il fonctionne par ligne et peut vérifier la totalité de la base de données. Il échouera donc toujours et sera lent à le faire.

Ma question est donc quelle est la meilleure façon de modéliser cette contrainte? Jusqu'à présent, j'ai examiné deux idées. Vous vous demandez si ce sont les seuls, ou si quelqu'un a un meilleur moyen (autre que de le laisser au niveau de l'application ou d'un proc stocké).

  1. Je pourrais emprunter une page du concept du monde comptable de la différence entre un livre d'entrée original et un livre d'entrée finale (journal général vs grand livre). À cet égard, je pourrais modéliser cela comme un tableau de lignes de journal attaché à l'entrée de journal, appliquer la contrainte sur le tableau (en termes PostgreSQL, sélectionnez sum (amount) = 0 dans unnest (je.line_items). Un déclencheur pourrait se développer et enregistrez-les dans une table d'éléments de ligne, où les contraintes de colonnes individuelles pourraient plus facilement être appliquées, et où les index, etc. pourraient être plus utiles.
  2. Je pourrais essayer de coder un déclencheur de contrainte qui imposerait cela par transaction avec l'idée que la somme d'une série de 0 sera toujours 0.

Je les compare à l'approche actuelle consistant à appliquer la logique dans une procédure stockée. Le coût de la complexité est mis en balance avec l'idée que la preuve mathématique des contraintes est supérieure aux tests unitaires. L'inconvénient majeur de # 1 ci-dessus est que les types sous forme de tuples sont l'un de ces domaines dans PostgreSQL où l'on rencontre régulièrement des comportements incohérents et des changements d'hypothèses.J'espère donc même que le comportement dans ce domaine pourrait changer avec le temps. Concevoir une future version sûre n'est pas si facile.

Existe-t-il d'autres moyens de résoudre ce problème qui atteindront des millions d'enregistrements dans chaque table? Suis-je en train de manquer quelque chose? Y a-t-il un compromis que j'ai manqué?

En réponse au point de Craig ci-dessous sur les versions, au minimum, cela devra fonctionner sur PostgreSQL 9.2 et supérieur (peut-être 9.1 et supérieur, mais nous pouvons probablement aller avec 9.2 directement).

Réponses:


12

Comme nous devons couvrir plusieurs lignes, il ne peut pas être implémenté avec une simple CHECKcontrainte.

Nous pouvons également exclure les contraintes d'exclusion . Celles-ci s'étendraient sur plusieurs lignes, mais ne vérifieraient que les inégalités. Des opérations complexes comme une somme sur plusieurs lignes ne sont pas possibles.

L'outil qui semble le mieux adapté à votre cas est un CONSTRAINT TRIGGER(ou même juste un simple TRIGGER- la seule différence dans l'implémentation actuelle est que vous pouvez ajuster le timing du déclencheur avec SET CONSTRAINTS.

Voilà donc votre option 2 .

Une fois que nous pouvons compter sur l'application de la contrainte à tout moment, nous n'avons plus besoin de vérifier l'ensemble du tableau. La vérification des seules lignes insérées dans la transaction en cours - à la fin de la transaction - est suffisante. La performance devrait être correcte.

Aussi comme

Les données comptables sont en annexe uniquement.

... nous devons seulement nous soucier des lignes nouvellement insérées . (En supposant UPDATEouDELETE ne sont pas possibles.)

J'utilise la colonne système xidet la compare à la fonction txid_current()- qui renvoie la xidtransaction en cours. Pour comparer les types, le casting est nécessaire ... Cela devrait être raisonnablement sûr. Considérez cette réponse connexe et ultérieure avec une méthode plus sûre:

Démo

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

Différé , il n'est donc vérifié qu'à la fin de la transaction.

Les tests

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

Travaux.

INSERT INTO journal_line(amount) VALUES (1);

Échoue:

ERREUR: entrées non équilibrées!

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;

Travaux. :)

Si vous devez appliquer votre contrainte avant la fin de la transaction, vous pouvez le faire à tout moment de la transaction, même au début:

SET CONSTRAINTS insaft_check_balance IMMEDIATE;

Plus rapide avec détente simple

Si vous opérez avec plusieurs lignes, INSERTil est plus efficace de déclencher par instruction - ce qui n'est pas possible avec les déclencheurs de contraintes :

Les déclencheurs de contraintes peuvent uniquement être spécifiés FOR EACH ROW.

Utilisez plutôt une gâchette simple et tirez FOR EACH STATEMENTpour ...

  • perdre l'option de SET CONSTRAINTS.
  • gagner en performance.

SUPPRIMER possible

En réponse à votre commentaire: Si cela DELETEest possible, vous pouvez ajouter un déclencheur similaire en effectuant une vérification du solde de la table entière après une suppression. Ce serait beaucoup plus cher, mais cela n'aura pas beaucoup d'importance car cela arrive rarement.


Il s'agit donc d'un vote pour le point n ° 2. L'avantage est que vous n'avez qu'une seule table pour toutes les contraintes et c'est là un gain de complexité, mais de l'autre, vous configurez des déclencheurs qui sont essentiellement procéduraux et donc si nous testons des choses non prouvées de manière déclarative, cela devient plus compliqué. Comment évalueriez-vous la différence avec un stockage imbriqué avec des contraintes déclaratives?
Chris Travers

De plus, la mise à jour n'est pas possible, la suppression peut être dans certaines circonstances * mais serait presque certainement une procédure très étroite et bien testée. Pour des raisons pratiques, la suppression peut être ignorée en tant que problème de contrainte. * Par exemple, purger toutes les données de plus de 10 ans, ce qui ne serait possible que si vous utilisez un modèle de journal, d'agrégat et d'instantané qui est de toute façon assez typique dans les systèmes comptables.
Chris Travers

@ChrisTravers. J'ai ajouté une mise à jour et adressé possible DELETE. Je ne saurais pas ce qui est typique ou requis en comptabilité - pas mon domaine d'expertise. J'essaie simplement de fournir une solution (assez efficace IMO) au problème décrit.
Erwin Brandstetter

@Erwin Brandstetter Je ne m'inquiéterais pas de cela pour les suppressions. Les suppressions, le cas échéant, seraient soumises à un ensemble beaucoup plus important de contraintes et les tests unitaires y sont pratiquement inévitables. Je me posais surtout des questions sur les coûts de complexité. Dans tous les cas, les suppressions peuvent être résolues très simplement avec une touche de suppression en cascade.
Chris Travers

4

La solution SQL Server suivante utilise uniquement des contraintes. J'utilise des approches similaires à plusieurs endroits de mon système.

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

c'est une approche intéressante. Les contraintes semblent fonctionner sur la déclaration plutôt que sur le niveau du tuple ou de la transaction, non? Cela signifie également que la commande de sous-ensembles de vos sous-ensembles est intégrée, n'est-ce pas? C'est une approche vraiment fascinante et bien qu'elle ne se traduise certainement pas directement en Pgsql, ce sont toujours des idées inspirantes. Merci!
Chris Travers

@Chris: Je pense que cela fonctionne très bien dans Postgres (après avoir supprimé le dbo.et le GO): sql-fiddle
ypercubeᵀᴹ

Ok, je l'ai mal compris. Il semble que l'on puisse utiliser une solution similaire ici. Cependant, n'auriez-vous pas besoin d'un déclencheur séparé pour rechercher le sous-total de la ligne précédente afin d'être en sécurité? Sinon, vous faites confiance à votre application pour envoyer des données sensées, non? C'est encore un modèle intéressant que je pourrais adapter.
Chris Travers

BTW, a voté pour les deux solutions. Aller lister l'autre comme préférable car il semble moins complexe. Cependant, je pense que c'est une solution très intéressante et cela ouvre de nouvelles façons de penser à des contraintes très complexes pour moi. Merci!
Chris Travers

Et vous n'avez pas besoin de déclencheur pour rechercher le sous-total de la ligne précédente afin d'être en sécurité. Ceci est pris en charge par la FK_Lines_PreviousLinecontrainte de clé étrangère.
ypercubeᵀᴹ
En utilisant notre site, vous reconnaissez avoir lu et compris notre politique liée aux cookies et notre politique de confidentialité.
Licensed under cc by-sa 3.0 with attribution required.