Add a server-side validation rule
Goal
Register a server-side rule that rejects an invalid record before it is saved — for example, refusing an invoice whose total is negative.
A rule like this is a plugin step: a BS Lang script attached to a table's write. It is the same language a Custom API runs, pointed at a save instead of at a route.
Prerequisites
- A table to attach the step to (for example,
par_invoicewith apar_totalfield). - The
Makerrole. - A solution open — the step becomes one of its components.
Steps
-
In the maker area, open your solution and choose Plugin steps.
-
Press New.
-
Give the step a display name ("Reject negative total"). The logical name fills itself in as you type, prefixed with your solution's publisher prefix — the step belongs to the solution, whichever table it watches.
-
Choose the table the step watches, the write it reacts to (Create, Update or Delete), and the stage:
- Before the write is where you refuse or change a record. Rejecting here means the record is never stored.
- After the write is where you act on what was stored. It runs once the record is saved,
so it cannot refuse it: if it calls
throwValidationError, the form lands on the saved record and says The change was saved, but a step that runs after the write then said…. - Before validation runs earliest, for coarse checks. It is inside the same transaction as Before the write, so a refusal there also means nothing is stored.
-
Leave Runs as Before the response unless you have a reason not to — see the note below.
-
Set the order if more than one step shares this table, write and stage; the lowest number runs first.
-
Press Save. The step exists with a starter script.
-
Select it in the list and press Edit code. The list is replaced by the editor:
if (getValue("par_total") < 0) {throwValidationError("Total cannot be negative.");}Press the eye in the toolbar to see every function the script can call, with what each one takes and returns. Press Save, then Close to come back to the list.
-
Try to create a record that breaks the rule. The save is rejected with the sentence you passed to
throwValidationError, and nothing is stored.
What happens behind the scenes
When a record is created, updated or deleted, the platform runs every enabled step registered
for that table and write, in ascending order, at the matching stage. A Before the write step
runs inside the transaction, so rejecting there means the record is never stored. A step can
also default or correct a value with setValue("field", value) at that stage.
The registration is validated when you save it — the wrapper against the plugin-step schema, the script against the BS Lang grammar — so a script that does not parse is refused with the line of the problem rather than failing later in front of a user.
Why this and not a business rule
A business rule runs in the browser, on a form. It is the right tool for what a person sees: showing a field, requiring one, defaulting a value as they type.
A plugin step runs on the server, inside the transaction. It applies however the record was saved — from a form, from the API, from a solution import, from a custom app. If a constraint must actually hold, it belongs here. The two are complementary: the rule makes the form pleasant, the step makes the data true.
Reading and writing other records
A step can reach records beyond the one being saved:
// 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 });
Three things worth knowing:
- It runs as whoever saved the record, not as an administrator. If they cannot read a table, neither can your step. A script is never a way around a permission.
- A write is one write. Pass every field in one bag. It runs the ordinary rules, so a step registered on that table runs too.
- Do not write the table your step watches unless you guard it — the write triggers your own
step again. The platform stops the loop after eight, but the fix is a condition, not the limit.
depth()tells you how deep you are.
A record that is not there comes back as nothing, so if (isEmpty(getRecord(...))) is how you check
before reading a field off it. Reading many is capped at 200 rows — a question about every row is
a report, and the query designer is where that belongs. countRecords is not capped: it asks the
database for a count rather than fetching rows, so it is right however many there are.
Asking a harder question
queryRecords matches one field against one value. When that is not enough — a range, several
conditions, a sort, a column on a joined table — use runQuery, which takes the platform's
own query document: the same thing the Advanced Find builder produces and a saved query stores.
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
});
The operators are Equals, NotEquals, Contains, GreaterThan, LessThan,
GreaterOrEqual, LessOrEqual, IsNull, IsNotNull and In — and In takes a list,
so "status is New or Active" is one condition rather than two you combine by hand.
It counts and groups, too. Add an aggregate and you get groups back instead of rows, each one
a flat object of its keys and its measures:
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
That is a question no number of queryRecords calls could answer, because summing in a script means
fetching every row first — which is exactly what the 200-row cap forbids.
Asking about two tables at once
An invoice knows its customer's id and nothing else about them. "Unpaid invoices belonging to a
customer whose credit limit is under 5000" is therefore a question about two tables, and
links joins the second one under an 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);
Three things to know, and the first one will bite you:
Quote 'from'. from is a word BS Lang already uses, so an unquoted from: is a parse
error — your script does not run, and the message talks about the language rather than the query.
to, select, operator and the rest are ordinary names and need no quotes. Inside page,
number is the only other key that does.
from and to are the direction of travel. from is the field on the table you are coming
from; to is the field on the table you are going to. If a join returns nothing when you are
sure it should not, this is the first thing to check.
A joined column reads under its alias — rows[0].cust.par_name, not rows[0].par_name. That
is what the alias is for: two tables can both have a par_name, and inside one row they must stay
apart. A filter names a joined column the same way, as cust.par_credit_limit.
Use kind: "Outer" to keep rows that have no match — a deal with no account is still a deal. Their
alias fields come back empty, so isEmpty(rows[0].cust.par_name) is how you check. And each
joined table is checked against your caller's permissions in its own right: a join is not a way to
read a table they could not open.
Reusing a query somebody already built
For a genuinely complicated query, do not write the document in your script at all. Build it in Advanced Find, save it, and name it:
var overdue = runSavedQuery("par_overdue_invoices");
This is usually the better answer, for three reasons that have nothing to do with typing less. The query is built and tested in a designer, by whoever understands the question. It is a solution component, so it is versioned and travels to Test and Production with everything else. And when the question changes, it changes in one place — every step and Custom API naming it moves at once.
Both run under your caller's permissions, exactly as queryRecords does, and every joined table
is checked in its own right. A saved query is not a way to read something you could not read yourself.
Sharing logic between steps
A step can import a solution script, the same way a business rule can:
import 'par_order_rules';
if (needsApproval(getValue("par_total"))) {
throwValidationError("This needs approval.");
}
Write the rule once, in Scripts, and import it from every step — and from a Custom API that
has to answer the same question. That is what stops two copies of one threshold disagreeing the day
somebody moves it. The business-rules-shared-server-logic sample is built around exactly that.
Asynchronous steps
Runs → After the response hands the step to a queue instead of running it inline. The record is saved, the caller gets their answer, and the step runs shortly afterwards.
Three things follow from that, and they are not restrictions so much as what "afterwards" means:
- It has to be After the write. The screen moves the stage for you when you choose it. A step running once the response has gone cannot change or refuse the write, so the earlier stages would be a moment that can never arrive.
- It cannot refuse anything.
throwValidationErrorin an asynchronous step records a failure against the step; the record stays saved. Anything that must block a save is synchronous. - It may run twice. Delivery is at-least-once, so write the step so that running it twice is harmless — the same rule Dataverse's asynchronous plugins follow.
- It runs as the person who saved, with the security roles they had when they saved. It can read and write records and send email just as a step running during the save could. It cannot write files to storage. On a save made by a portal visitor it can do none of these.
recordId()says which record it is. It is handed the save, not the record as it stands now, so read the record withgetRecord(entityName(), recordId())when you need its current values, and write back withupdateRecord. Check the record before acting: if your step writes to the record it runs on, that write runs the step again.
Use it for work the caller should not wait for: sending a notification, calling another system, updating a rollup.
Troubleshooting
- "Logical name must be … prefixed" — the name must start with your solution's publisher
prefix and an underscore (for example
par_). The table you pick does not change it: a step on the standard Account table is still namedpar_…. - "BS Lang script is invalid" — the script did not parse; the message names the line.
- The step did not run — check it is Running rather than Stopped, and that it targets the right table, write and stage. If it runs After the response, give it a moment: it is queued, not inline.
- Nothing blocks an update — a step watches one write. Register a second step for Update if updates must be guarded too.
importdoes not resolve — check the module name matches the solution script's logical name exactly, and that the script exports what you are calling (export myFunction;). A script with noexportstatement binds nothing.