Contrainte - une ligne booléenne est vraie, toutes les autres lignes sont fausses

13

J'ai une colonne: standard BOOLEAN NOT NULL

Je voudrais appliquer une ligne True et toutes les autres False. Il n'y a pas de FK ou quoi que ce soit d'autre selon cette contrainte. Je sais que je peux l'accomplir avec plpgsql, mais cela ressemble à un marteau. Je préférerais quelque chose comme une contrainte CHECKou UNIQUE. Plus c'est simple, mieux c'est.

Une ligne doit être True, elles ne peuvent pas toutes être False (donc la première ligne insérée doit être True).

La ligne devra être mise à jour, ce qui signifie que je dois attendre pour vérifier les contraintes jusqu'à ce que les mises à jour soient terminées, car toutes les lignes peuvent être définies sur False en premier et une ligne sur True après.

Il existe un FK entre products.tax_rate_idet tax_rate.id, mais cela n'a rien à voir avec le taux de taxe par défaut ou standard, qui est sélectionnable par l'utilisateur pour faciliter la création de nouveaux produits.

PostgreSQL 9.5 si cela est important.

Contexte

Le tableau est le taux d'imposition. L'un des taux de taxe est la valeur par défaut ( standardcar la valeur par défaut est une commande Postgres). Lorsqu'un nouveau produit est ajouté, le taux de taxe standard est appliqué au produit. S'il n'y en a pas standard, la base de données doit faire une supposition ou toutes sortes de vérifications inutiles. La solution simple, je pensais, était de s'assurer qu'il y avait un standard.

Par «par défaut» ci-dessus, je veux dire pour la couche de présentation (UI). Il existe une option utilisateur pour modifier le taux de taxe par défaut. Je dois soit ajouter des vérifications supplémentaires pour m'assurer que l'interface graphique / l'utilisateur n'essaie pas de définir tax_rate_id sur NULL, soit simplement définir un taux de taxe par défaut.

theGtknerd
la source
Vous avez donc votre réponse?
Erwin Brandstetter
Oui, j'ai ma réponse, merci beaucoup pour votre contribution, @ErwinBrandstetter. Je me penche vers un déclencheur pour l'instant. Ceci est un projet open source sur mon propre temps. Lorsque je l'implémenterai réellement, je marquerai la réponse comme acceptée que j'utilise.
theGtknerd

Réponses:

15

Variante 1

Puisque tout ce dont vous avez besoin est une seule colonne avec standard = true, définissez la norme sur NULL dans toutes les autres lignes. Ensuite, une UNIQUEcontrainte simple fonctionne, car les valeurs NULL ne la violent pas:

CREATE TABLE taxrate (
   taxrate int PRIMARY KEY
 , standard bool DEFAULT true
 , CONSTRAINT standard_true_or_null CHECK (standard) -- yes, that's the whole constraint
 , CONSTRAINT standard_only_1_true UNIQUE (standard)
);

DEFAULTest un rappel facultatif que la première ligne entrée doit devenir la valeur par défaut. Il n'applique rien. Bien que vous ne puissiez pas définir plusieurs lignes sur standard = true, vous pouvez toujours définir toutes les lignes NULL. Il n'existe aucun moyen propre d'empêcher cela avec seulement des contraintes dans une seule table. CHECKles contraintes ne prennent pas en compte les autres lignes (sans astuces sales).

En relation:

Mettre à jour:

BEGIN;
UPDATE taxrate SET standard = NULL WHERE standard;
UPDATE taxrate SET standard = TRUE WHERE taxrate = 2;
COMMIT;

Pour autoriser une commande comme (où la contrainte n'est satisfaite qu'à la fin de l'instruction):

WITH kingdead AS (
   UPDATE taxrate
   SET standard = NULL
   WHERE standard
   )
UPDATE taxrate
SET standard = TRUE
WHERE taxrate = 1;

.. la UNIQUEcontrainte devrait être DEFERRABLE. Voir:

dbfiddle ici

Variante 2

Avoir une deuxième table avec une seule ligne comme:

Créez ceci en tant que superutilisateur:

CREATE TABLE taxrate (
   taxrate int PRIMARY KEY
);

CREATE TABLE taxrate_standard (
   taxrate int PRIMARY KEY REFERENCES taxrate
);

CREATE UNIQUE INDEX taxrate_standard_singleton ON taxrate_standard ((true));  -- singleton

REVOKE DELETE ON TABLE taxrate_standard FROM public;  -- can't delete

INSERT INTO taxrate (taxrate) VALUES (42);
INSERT INTO taxrate_standard (taxrate) VALUES (42);

Maintenant, il y a toujours une seule ligne pointant vers la norme (dans ce cas simple représentant également directement le taux standard). Seul un superutilisateur pouvait le casser. Vous pouvez également interdire cela avec un déclencheur BEFORE DELETE.

dbfiddle ici

En relation:

Vous pouvez ajouter un VIEWpour voir la même chose que dans la variante 1 :

CREATE VIEW taxrate_combined AS
SELECT t.*, (ts.taxrate = t.taxrate) AS standard
FROM   taxrate t
LEFT   JOIN taxrate_standard ts USING (taxrate);

Dans les requêtes où tout ce que vous voulez est le taux standard, utilisez (uniquement) taxrate_standard.taxratedirectement.


Vous avez ajouté plus tard:

Il y a un FK entre products.tax_rate_idettax_rate.id

La mise en œuvre d' un pauvre homme de la variante 2 serait d'ajouter simplement une ligne products(ou tout autre tableau similaire) pointant vers le taux d'imposition standard; un produit factice que vous pourriez appeler "Taux de taxe standard" - si votre configuration le permet.

Les contraintes FK imposent l'intégrité référentielle. Pour le compléter, appliquez tax_rate_id IS NOT NULLpour la ligne (si ce n'est pas le cas pour la colonne en général). Et interdire sa suppression. Les deux pourraient être mis en déclencheurs. Pas de table supplémentaire, mais moins élégante et moins fiable.

Erwin Brandstetter
la source
2
Je recommande vivement l'approche à deux tables. Je suggérerais également d'ajouter un exemple de requête à cette variation afin que l'OP puisse voir comment CROSS JOINcontre la norme, LEFT JOINla spécifique, puis COALESCEentre les deux.
jpmc26
2
+1, j'ai eu la même idée du tableau supplémentaire mais pas le temps d'écrire correctement une réponse. À propos de la première table et de la CONSTRAINT standard_only_1_true UNIQUE (standard): Je suppose que la table ne sera pas grande, donc cela n'a pas beaucoup d'importance, mais comme la contrainte définira un index sur toute la table, un index unique partiel avec WHERE (standard)moins d'espace utilisera-t-il?
ypercubeᵀᴹ
@ ypercubeᵀᴹ: Oui, l'index sur toute la table est plus grand, c'est un inconvénient pour cette variante. Mais comme vous l'avez dit: c'est évidemment une petite table, donc ça n'a pas d'importance. Je visais la solution standard la plus simple avec seulement des contraintes. Preuve de concept. Personnellement, je suis avec jpmc26 et suis fortement en faveur de la variante 2.
Erwin Brandstetter
9

Vous pouvez utiliser un index filtré

create table test
(
    id int primary key,
    foo bool
);
CREATE UNIQUE INDEX only_one_row_with_column_true_uix 
    ON test (foo) WHERE (foo);  --> where foo is true
insert into test values (1, false);
insert into test values (2, true);
insert into test values (3, false);
insert into test values (4, false);
insert into test values (5, true);
ERREUR: la valeur de clé en double viole la contrainte unique "only_one_row_with_column_true_uix"
DÉTAIL: La clé (foo) = (t) existe déjà.

dbfiddle ici


Mais comme vous l'avez dit, la première ligne doit être vraie, alors vous pouvez utiliser une contrainte CHECK, mais même en utilisant une fonction, vous pouvez supprimer la première ligne plus tard.

create function check_one_true(new_foo bool)
returns int as
$$
begin
    return 
    (
        select count(*) + (case new_foo when true then 1 else 0 end)
        from test 
        where foo = true
    );
end
$$
language plpgsql stable;
alter table test 
    add constraint ck_one_true check(check_one_true(foo) = 1); 
insert into test values (1, true);
insert into test values (2, false);
insert into test values (3, false);
insert into test values (4, false);
insert into test values (5, true);
ERREUR: une nouvelle ligne pour la relation "test" viole la contrainte de vérification "ck_one_true"
DÉTAIL: La ligne défaillante contient (5, t).

select * from test;
id | foo
-: | : -
 1 | t  
 2 | F  
 3 | F  
 4 | F  
delete from test where id = 1;

dbfiddle ici


Vous pouvez le résoudre en ajoutant un déclencheur BEFORE DELETE pour vous assurer que la première ligne (foo est vrai) n'est jamais supprimée.

create function dont_delete_foo_true()
returns trigger as
$x$
begin
    if old.foo then
        raise exception 'Can''t delete row where foo is true.';
    end if;
    return old;
end;
$x$ language plpgsql;
create trigger trg_test_delete
before delete on test
for each row 
execute procedure dont_delete_foo_true();
delete from test where id = 1;

ERREUR: impossible de supprimer la ligne où foo est vrai.

dbfiddle ici

McNets
la source