Aller au contenu principal

Ajouter une règle de validation côté serveur

Objectif​

Inscrire une règle côté serveur qui rejette un enregistrement invalide avant qu’il ne soit enregistré — par exemple, refuser une facture dont le total est négatif.

Une règle de ce genre est une étape de plug-in : un script BS Lang attaché à l’écriture d’une table. C’est le même langage qu’exécute une API personnalisée, mais branché sur un enregistrement plutôt que sur une route.

Prérequis​

  • Une table à laquelle attacher l’étape (par exemple par_invoice, avec un champ par_total).
  • Le rôle Maker.
  • Une solution ouverte — l’étape devient l’un de ses composants.

Étapes​

  1. Dans l’espace créateur, ouvrez votre solution et choisissez Étapes de plug-in.

  2. Appuyez sur Nouveau.

  3. Donnez à l’étape un nom d’affichage (« Rejeter un total négatif »). Le nom logique se remplit au fil de la saisie, préfixé par le préfixe de l’éditeur de votre solution — l’étape appartient à la solution, quelle que soit la table qu’elle surveille.

  4. Choisissez la table que surveille l’étape, l’écriture à laquelle elle réagit (Création, Mise à jour ou Suppression), et la phase :

    • Avant l’écriture est l’endroit où refuser ou modifier un enregistrement. Un rejet à ce stade signifie que l’enregistrement n’est jamais stocké.
    • Après l’écriture est l’endroit où agir sur ce qui a été stocké. L’étape s’exécute une fois l’enregistrement stocké, elle ne peut donc pas le refuser : si elle appelle throwValidationError, le formulaire passe sur l’enregistrement stocké et indique La modification a été enregistrée, mais une étape qui s’exécute après l’écriture a ensuite indiqué….
    • Avant la validation s’exécute en premier, pour des contrôles grossiers. Elle est dans la même transaction qu’Avant l’écriture : un rejet à ce stade signifie aussi que rien n’est stocké.
  5. Laissez Exécution sur Pendant l’écriture, sauf si vous avez une raison de faire autrement — voir la remarque plus bas.

  6. Définissez l’ordre si plusieurs étapes partagent cette table, cette écriture et cette phase ; le plus petit numéro s’exécute en premier.

  7. Appuyez sur Enregistrer. L’étape existe, avec un script de départ.

  8. Sélectionnez-la dans la liste et appuyez sur Modifier le code. La liste est remplacée par l’éditeur :

    if (getValue("par_total") < 0) {
    throwValidationError("Total cannot be negative.");
    }

    Appuyez sur l’œil de la barre d’outils pour voir toutes les fonctions que le script peut appeler, avec ce que chacune prend et renvoie. Appuyez sur Enregistrer, puis sur Fermer pour revenir à la liste.

  9. Essayez de créer un enregistrement qui enfreint la règle. L’enregistrement est rejeté avec la phrase que vous avez passée à throwValidationError, et rien n’est stocké.

Ce qui se passe en coulisses​

Lorsqu’un enregistrement est créé, mis à jour ou supprimé, la plateforme exécute toutes les étapes activées inscrites pour cette table et cette écriture, par ordre croissant, à la phase correspondante. Une étape Avant l’écriture s’exécute dans la transaction : un rejet à ce stade signifie que l’enregistrement n’est jamais stocké. Une étape peut aussi fournir une valeur par défaut ou corriger une valeur avec setValue("field", value) à cette phase.

L’inscription est validée au moment où vous l’enregistrez — l’enveloppe par rapport au schéma des étapes de plug-in, le script par rapport à la grammaire BS Lang — si bien qu’un script qui ne s’analyse pas est refusé avec la ligne du problème, au lieu d’échouer plus tard devant un utilisateur.

Pourquoi cela et pas une règle métier​

Une règle métier s’exécute dans le navigateur, sur un formulaire. C’est le bon outil pour ce que voit une personne : afficher un champ, le rendre obligatoire, proposer une valeur par défaut pendant la saisie.

Une étape de plug-in s’exécute sur le serveur, dans la transaction. Elle s’applique quelle que soit la manière dont l’enregistrement a été enregistré — depuis un formulaire, depuis l’API, lors de l’importation d’une solution, depuis une application personnalisée. Si une contrainte doit réellement être respectée, c’est ici qu’elle a sa place. Les deux sont complémentaires : la règle rend le formulaire agréable, l’étape rend les données justes.

Lire et écrire d’autres enregistrements​

Une étape peut atteindre d’autres enregistrements que celui en cours d’enregistrement :

// Read a record. Its fields are the properties of what comes back, plus id.
var account = getRecord("par_account", getValue("par_account_id"));
if (account.par_status == "on hold") {
throwValidationError("That account is on hold.");
}

// Read several, and walk them.
var open = queryRecords("par_invoice", "par_status", "open");
for (var i: int = 0; i < open.length; i++) {
// open[i].par_total, open[i].id, ...
}

// Write: the fields you want, in one go. You get the new record's id back.
var noteId = createRecord("par_audit_note", { par_text: "Approved automatically." });
updateRecord("par_account", account.id, { par_reviewed: true });

Trois choses à savoir :

  • Elle s’exécute au nom de la personne qui a enregistré l’enregistrement, pas en tant qu’administrateur. Si cette personne ne peut pas lire une table, votre étape non plus. Un script n’est jamais un moyen de contourner une autorisation.
  • Une écriture est une seule écriture. Passez tous les champs dans un seul objet. Elle exécute les règles ordinaires : une étape inscrite sur cette table-là s’exécute donc aussi.
  • N’écrivez pas dans la table que surveille votre étape sans garde-fou — l’écriture déclenche à nouveau votre propre étape. La plateforme interrompt la boucle au bout de huit fois, mais la solution est une condition, pas la limite. depth() vous indique à quelle profondeur vous êtes.

Un enregistrement inexistant revient vide : if (isEmpty(getRecord(...))) est donc la manière de vérifier avant d’y lire un champ. La lecture de plusieurs enregistrements est plafonnée à 200 lignes — une question portant sur toutes les lignes est un rapport, et c’est le concepteur de requêtes qui s’en charge. countRecords n’est pas plafonné : il demande un décompte à la base de données au lieu de récupérer les lignes, et reste donc juste quel que soit leur nombre.

Poser une question plus complexe​

queryRecords compare un seul champ à une seule valeur. Quand cela ne suffit pas — une plage, plusieurs conditions, un tri, une colonne d’une table jointe — utilisez runQuery, qui prend le document de requête propre à la plateforme : celui que produit le générateur de Recherche avancée et que stocke une requête enregistrée.

var cheap = runQuery({
entity: "par_product",
filter: {
connector: "And",
conditions: [
{ field: "par_price", operator: "LessThan", value: 10 },
{ field: "par_discontinued", operator: "Equals", value: false }
]
},
order: [{ field: "par_price", descending: true }],
top: 20
});

Les opérateurs sont Equals, NotEquals, Contains, GreaterThan, LessThan, GreaterOrEqual, LessOrEqual, IsNull, IsNotNull et In — et In prend une liste, si bien que « le statut est Nouveau ou Actif » tient en une seule condition au lieu de deux à combiner à la main.

Elle sait aussi compter et regrouper. Ajoutez un aggregate et vous récupérez des groupes au lieu de lignes, chacun sous la forme d’un objet plat contenant ses clés et ses mesures :

var byOwner = runQuery({
entity: "par_order",
aggregate: {
measures: [{ alias: "total", function: "Sum", field: "par_amount" }],
groupBy: [{ field: "par_owner" }]
}
});
// byOwner[0].par_owner, byOwner[0].total

C’est une question à laquelle aucun nombre d’appels à queryRecords ne pourrait répondre, car additionner dans un script suppose de récupérer d’abord toutes les lignes — exactement ce qu’interdit le plafond de 200 lignes.

Interroger deux tables à la fois​

Une facture connaît l’identifiant de son client et rien d’autre à son sujet. « Les factures impayées appartenant à un client dont la limite de crédit est inférieure à 5000 » est donc une question qui porte sur deux tables, et links joint la seconde sous un alias :

var rows = runQuery({
entity: "par_invoice",
select: ["par_name", "par_amount"],
links: [
{
alias: "cust",
entity: "par_customer",
'from': "par_customerid",
to: "id",
kind: "Inner",
select: ["par_name", "par_credit_limit"]
}
],
filter: { conditions: [
{ field: "cust.par_credit_limit", operator: "LessThan", value: 5000 }
] }
});

report(rows[0].cust.par_name);

Trois choses à savoir, et la première vous piégera :

Mettez 'from' entre guillemets. from est un mot que BS Lang utilise déjà : un from: sans guillemets est une erreur d’analyse — votre script ne s’exécute pas, et le message parle du langage plutôt que de la requête. to, select, operator et les autres sont des noms ordinaires et n’ont pas besoin de guillemets. Dans page, number est la seule autre clé qui en a besoin.

from et to indiquent le sens du parcours. from est le champ de la table d’où vous venez ; to est le champ de la table vers laquelle vous allez. Si une jointure ne renvoie rien alors que vous êtes sûr du contraire, c’est la première chose à vérifier.

Une colonne jointe se lit sous son alias — rows[0].cust.par_name, et non rows[0].par_name. C’est à cela que sert l’alias : deux tables peuvent toutes deux avoir un par_name, et au sein d’une même ligne elles doivent rester distinctes. Un filtre désigne une colonne jointe de la même manière, par cust.par_credit_limit.

Utilisez kind: "Outer" pour conserver les lignes sans correspondance — une affaire sans compte reste une affaire. Leurs champs d’alias reviennent vides : isEmpty(rows[0].cust.par_name) est la façon de le vérifier. Et chaque table jointe est contrôlée à part entière par rapport aux autorisations de l’appelant : une jointure ne permet pas de lire une table qu’il ne pourrait pas ouvrir.

Réutiliser une requête que quelqu’un a déjà construite​

Pour une requête réellement complexe, n’écrivez pas du tout le document dans votre script. Construisez-la dans la Recherche avancée, enregistrez-la et appelez-la par son nom :

var overdue = runSavedQuery("par_overdue_invoices");

C’est généralement la meilleure réponse, pour trois raisons qui n’ont rien à voir avec le fait de moins taper. La requête est construite et testée dans un concepteur, par la personne qui comprend la question. C’est un composant de solution : elle est donc versionnée et voyage vers Test et Production avec tout le reste. Et quand la question change, elle change à un seul endroit — toutes les étapes et API personnalisées qui la nomment suivent en même temps.

Les deux s’exécutent avec les autorisations de l’appelant, exactement comme queryRecords, et chaque table jointe est contrôlée à part entière. Une requête enregistrée ne permet pas de lire ce que vous ne pourriez pas lire vous-même.

Partager de la logique entre étapes​

Une étape peut importer un script de solution, de la même manière qu’une règle métier :

import 'par_order_rules';

if (needsApproval(getValue("par_total"))) {
throwValidationError("This needs approval.");
}

Écrivez la règle une seule fois, dans Scripts, et importez-la depuis chaque étape — et depuis une API personnalisée qui doit répondre à la même question. C’est ce qui empêche deux copies d’un même seuil de se contredire le jour où quelqu’un le déplace. L’exemple business-rules-shared-server-logic est construit précisément autour de cela.

Étapes asynchrones​

Exécution → Après la réponse confie l’étape à une file d’attente au lieu de l’exécuter en ligne. L’enregistrement est sauvegardé, l’appelant reçoit sa réponse, et l’étape s’exécute peu après.

Plusieurs conséquences en découlent ; ce sont moins des restrictions que ce que « après » veut dire :

  • Elle doit être en phase Après l’écriture. L’écran change la phase pour vous quand vous choisissez cette option. Une étape qui s’exécute une fois la réponse partie ne peut ni modifier ni refuser l’écriture : les phases antérieures seraient un moment qui ne peut jamais arriver.
  • Elle ne peut rien refuser. throwValidationError dans une étape asynchrone enregistre un échec pour l’étape ; l’enregistrement reste sauvegardé. Tout ce qui doit bloquer un enregistrement est synchrone.
  • Elle peut s’exécuter deux fois. La livraison est « au moins une fois » : écrivez donc l’étape de sorte qu’une double exécution soit sans conséquence — la même règle que suivent les plug-ins asynchrones de Dataverse.
  • Elle s’exécute au nom de la personne qui a enregistré, avec les rôles de sécurité qu’elle avait au moment de l’enregistrement. Elle peut lire et écrire des enregistrements et envoyer des e-mails, tout comme une étape exécutée pendant l’enregistrement. Elle ne peut pas écrire de fichiers dans le stockage. Pour un enregistrement effectué par un visiteur de portail, elle ne peut rien faire de tout cela.
  • recordId() indique de quel enregistrement il s’agit. L’étape reçoit l’enregistrement tel qu’il a été sauvegardé, pas tel qu’il est maintenant : lisez-le avec getRecord(entityName(), recordId()) quand vous avez besoin de ses valeurs actuelles, et réécrivez-le avec updateRecord. Vérifiez l’enregistrement avant d’agir : si votre étape écrit dans l’enregistrement sur lequel elle s’exécute, cette écriture relance l’étape.

Utilisez-la pour le travail que l’appelant ne devrait pas attendre : envoyer une notification, appeler un autre système, mettre à jour un cumul.

Dépannage​

  • « Logical name must be … prefixed » — le nom doit commencer par le préfixe de l’éditeur de votre solution suivi d’un trait de soulignement (par exemple par_). La table choisie n’y change rien : une étape sur la table standard Account s’appelle toujours par_….
  • « BS Lang script is invalid » — le script ne s’est pas analysé ; le message indique la ligne.
  • L’étape ne s’est pas exécutée — vérifiez qu’elle est En cours d’exécution et non Arrêtée, et qu’elle cible la bonne table, la bonne écriture et la bonne phase. Si elle s’exécute Après la réponse, laissez-lui un instant : elle est mise en file d’attente, pas exécutée en ligne.
  • Rien ne bloque une mise à jour — une étape surveille une seule écriture. Inscrivez une seconde étape pour Mise à jour si les mises à jour doivent aussi être contrôlées.
  • import ne se résout pas — vérifiez que le nom du module correspond exactement au nom logique du script de solution, et que le script exporte ce que vous appelez (export myFunction;). Un script sans instruction export ne lie rien.

Scénarios associés​