mirror of
https://github.com/microsoft/BCQuality.git
synced 2026-08-06 17:36:53 +01:00
Merge remote-tracking branch 'origin/main' into pr49-review-fixes
This commit is contained in:
commit
28041e80e1
111 changed files with 2331 additions and 65 deletions
|
|
@ -0,0 +1,43 @@
|
|||
// Anti-pattern: an own object with no affix. Another app that also defines a
|
||||
// "Loyalty Tier" table cannot be installed alongside this one.
|
||||
table 50379 "Loyalty Tier"
|
||||
{
|
||||
Caption = 'Loyalty Tier';
|
||||
DataClassification = CustomerContent;
|
||||
|
||||
fields
|
||||
{
|
||||
field(1; "Code"; Code[20])
|
||||
{
|
||||
Caption = 'Code';
|
||||
}
|
||||
field(10; Description; Text[100])
|
||||
{
|
||||
Caption = 'Description';
|
||||
}
|
||||
}
|
||||
|
||||
keys
|
||||
{
|
||||
key(PK; "Code")
|
||||
{
|
||||
Clustered = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Anti-pattern (the common half-measure): the extension object carries the
|
||||
// affix, but the field it adds to the standard Customer table does not. That
|
||||
// unaffixed field still collides with any other app that adds "Loyalty Points"
|
||||
// to Customer, and AS0011 flags it.
|
||||
tableextension 50378 "ABC Customer Ext" extends Customer
|
||||
{
|
||||
fields
|
||||
{
|
||||
field(50378; "Loyalty Points"; Integer)
|
||||
{
|
||||
Caption = 'Loyalty Points';
|
||||
DataClassification = CustomerContent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
// Own object: the affix "ABC" is carried at object-name level.
|
||||
table 50377 "ABC Loyalty Tier"
|
||||
{
|
||||
Caption = 'Loyalty Tier';
|
||||
DataClassification = CustomerContent;
|
||||
|
||||
fields
|
||||
{
|
||||
field(1; "Code"; Code[20])
|
||||
{
|
||||
Caption = 'Code';
|
||||
}
|
||||
field(10; Description; Text[100])
|
||||
{
|
||||
Caption = 'Description';
|
||||
}
|
||||
}
|
||||
|
||||
keys
|
||||
{
|
||||
key(PK; "Code")
|
||||
{
|
||||
Clustered = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extension of a standard object: the added field is individually affixed,
|
||||
// because the object name (Customer) belongs to the base application.
|
||||
tableextension 50376 "ABC Customer Ext" extends Customer
|
||||
{
|
||||
fields
|
||||
{
|
||||
field(50376; "Loyalty Points ABC"; Integer)
|
||||
{
|
||||
Caption = 'Loyalty Points';
|
||||
DataClassification = CustomerContent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: appsource
|
||||
keywords: [object-affix, prefix, suffix, as0011, appsourcecop, collision, tableextension]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Apply a reserved affix to objects and to members added to base objects
|
||||
|
||||
## Description
|
||||
|
||||
An AppSource extension must carry a reserved affix — a prefix or a suffix of at least three characters — on the names of the objects it owns **and** on any field, key, control, or action it adds to a base-application object. The affix is registered with Microsoft; when two coexisting extensions would otherwise collide, the registrant of the affix wins. Without it, two apps that both add a `Loyalty Points` field to `Customer`, or both define a `Loyalty Tier` table, cannot be installed side by side.
|
||||
|
||||
AppSourceCop enforces this. The primary rule is AS0011 ("An affix is required"); the affixes are configured through `mandatoryAffixes` (and `mandatoryPrefix`) in `AppSourceCop.json`. Two placements matter and are easy to get half-right: an object you define carries the affix at **object-name** level, while a member you add to a **standard** object carries the affix on that **member's** name. Adding an affixed object is not enough — an unaffixed field bolted onto `Customer` still collides and still fails validation.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Own objects are named with the affix (e.g. a table `ABC Loyalty Tier`), and every field or action added to a standard object is individually affixed (e.g. `Loyalty Points ABC` on a `Customer` tableextension).
|
||||
|
||||
See sample: `object-affixes-prevent-collisions.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Unaffixed object or member names, or the common half-measure: the extension object carries the affix but a field it adds to a standard table does not. AS0011 flags the missing affix and the field can still collide with another app.
|
||||
|
||||
See sample: `object-affixes-prevent-collisions.bad.al`.
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
table 50372 "Loyalty Member"
|
||||
{
|
||||
Caption = 'Loyalty Member';
|
||||
DataClassification = CustomerContent;
|
||||
|
||||
fields
|
||||
{
|
||||
field(1; "No."; Code[20])
|
||||
{
|
||||
Caption = 'No.';
|
||||
}
|
||||
field(10; Name; Text[100])
|
||||
{
|
||||
Caption = 'Name';
|
||||
}
|
||||
field(20; Blocked; Boolean)
|
||||
{
|
||||
Caption = 'Blocked';
|
||||
}
|
||||
}
|
||||
|
||||
keys
|
||||
{
|
||||
key(PK; "No.")
|
||||
{
|
||||
Clustered = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Anti-pattern: the block check sits in the master's own trigger. Editing a
|
||||
// blocked member is rare; referencing it is constant, and references never
|
||||
// fire OnModify. So this stops nothing that matters.
|
||||
trigger OnModify()
|
||||
begin
|
||||
TestField(Blocked, false);
|
||||
end;
|
||||
}
|
||||
|
||||
table 50373 "Loyalty Point Entry"
|
||||
{
|
||||
Caption = 'Loyalty Point Entry';
|
||||
DataClassification = CustomerContent;
|
||||
|
||||
fields
|
||||
{
|
||||
field(1; "Entry No."; Integer)
|
||||
{
|
||||
Caption = 'Entry No.';
|
||||
AutoIncrement = true;
|
||||
}
|
||||
field(10; "Member No."; Code[20])
|
||||
{
|
||||
Caption = 'Member No.';
|
||||
TableRelation = "Loyalty Member"."No.";
|
||||
// No block check on the referencing side: a line can freely
|
||||
// reference a blocked member, and posting proceeds unchecked.
|
||||
}
|
||||
field(20; Points; Integer)
|
||||
{
|
||||
Caption = 'Points';
|
||||
}
|
||||
}
|
||||
|
||||
keys
|
||||
{
|
||||
key(PK; "Entry No.")
|
||||
{
|
||||
Clustered = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
table 50370 "Loyalty Member"
|
||||
{
|
||||
Caption = 'Loyalty Member';
|
||||
DataClassification = CustomerContent;
|
||||
|
||||
fields
|
||||
{
|
||||
field(1; "No."; Code[20])
|
||||
{
|
||||
Caption = 'No.';
|
||||
}
|
||||
field(10; Name; Text[100])
|
||||
{
|
||||
Caption = 'Name';
|
||||
}
|
||||
// Blocked is inert data here: the master carries the flag but no logic.
|
||||
field(20; Blocked; Boolean)
|
||||
{
|
||||
Caption = 'Blocked';
|
||||
}
|
||||
}
|
||||
|
||||
keys
|
||||
{
|
||||
key(PK; "No.")
|
||||
{
|
||||
Clustered = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
table 50371 "Loyalty Point Entry"
|
||||
{
|
||||
Caption = 'Loyalty Point Entry';
|
||||
DataClassification = CustomerContent;
|
||||
|
||||
fields
|
||||
{
|
||||
field(1; "Entry No."; Integer)
|
||||
{
|
||||
Caption = 'Entry No.';
|
||||
AutoIncrement = true;
|
||||
}
|
||||
field(10; "Member No."; Code[20])
|
||||
{
|
||||
Caption = 'Member No.';
|
||||
TableRelation = "Loyalty Member"."No.";
|
||||
|
||||
trigger OnValidate()
|
||||
var
|
||||
LoyaltyMember: Record "Loyalty Member";
|
||||
begin
|
||||
if "Member No." = '' then
|
||||
exit;
|
||||
// Enforcement lives at the point of use: reject a blocked master
|
||||
// as soon as a line references it.
|
||||
LoyaltyMember.Get("Member No.");
|
||||
LoyaltyMember.TestField(Blocked, false);
|
||||
end;
|
||||
}
|
||||
field(20; Points; Integer)
|
||||
{
|
||||
Caption = 'Points';
|
||||
}
|
||||
}
|
||||
|
||||
keys
|
||||
{
|
||||
key(PK; "Entry No.")
|
||||
{
|
||||
Clustered = true;
|
||||
}
|
||||
}
|
||||
|
||||
procedure Post()
|
||||
var
|
||||
LoyaltyMember: Record "Loyalty Member";
|
||||
begin
|
||||
// Re-check before committing the transaction, in case the member was
|
||||
// blocked after the line was created.
|
||||
LoyaltyMember.Get("Member No.");
|
||||
LoyaltyMember.TestField(Blocked, false);
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: data-modeling
|
||||
keywords: [blocked-field, testfield, referencing-code, point-of-use, enforcement, journal-line]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Enforce `Blocked` where the master is used, not in the master itself
|
||||
|
||||
## Description
|
||||
|
||||
The `Blocked` field on a master record (`Item`, `Customer`, `Resource`, or a custom master) is inert data. The master table holds **no** logic that acts on it. Enforcement belongs in the **consuming** code: when a journal line, document line, or posting routine references the master by its `No.`, that referencing object tests the flag at the point of use, e.g. `LoyaltyMember.Get("Member No."); LoyaltyMember.TestField(Blocked, false);` in the line's `OnValidate` and again before posting.
|
||||
|
||||
Putting the block check inside the master's own `OnInsert`/`OnModify` does nothing to stop transactional use: a blocked master is edited rarely, but it is *referenced* constantly, and those references never touch the master's own triggers. Base BC follows this split — `Item.Blocked` is checked by sales/purchase/journal code, not by the `Item` table. A boolean `Blocked` uses `TestField(Blocked, false)`; an option-style block (e.g. `Sales`/`All`) needs the specific option compared at each relevant path.
|
||||
|
||||
## Best Practice
|
||||
|
||||
The referencing line validates `Master.TestField(Blocked, false)` in `OnValidate` of the reference field and re-checks before posting. The master table stays logic-free on `Blocked`.
|
||||
|
||||
See sample: `check-blocked-in-referencing-code-not-in-master.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
The block check sits in the master's own `OnModify`/`OnInsert` (so referencing and posting proceed unchecked), or there is no check at all on the referencing side.
|
||||
|
||||
See sample: `check-blocked-in-referencing-code-not-in-master.bad.al`.
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
table 50361 "Loyalty Member"
|
||||
{
|
||||
Caption = 'Loyalty Member';
|
||||
DataClassification = CustomerContent;
|
||||
|
||||
fields
|
||||
{
|
||||
// Anti-pattern: an autoincrement Integer surrogate used as the business key.
|
||||
field(1; "Entry No."; Integer)
|
||||
{
|
||||
Caption = 'Entry No.';
|
||||
AutoIncrement = true;
|
||||
}
|
||||
field(10; Name; Text[100])
|
||||
{
|
||||
Caption = 'Name';
|
||||
}
|
||||
}
|
||||
|
||||
keys
|
||||
{
|
||||
key(PK; "Entry No.")
|
||||
{
|
||||
Clustered = true;
|
||||
}
|
||||
}
|
||||
|
||||
// No OnInsert, no number series, no "No." code, and no "No. Series" field.
|
||||
// Records get an opaque integer users never see and cannot quote on the phone,
|
||||
// and the master is cut off from BC's standard numbering and manual-entry flow.
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
table 50360 "Loyalty Member"
|
||||
{
|
||||
Caption = 'Loyalty Member';
|
||||
DataClassification = CustomerContent;
|
||||
|
||||
fields
|
||||
{
|
||||
field(1; "No."; Code[20])
|
||||
{
|
||||
Caption = 'No.';
|
||||
NotBlank = true;
|
||||
}
|
||||
field(2; "No. Series"; Code[20])
|
||||
{
|
||||
Caption = 'No. Series';
|
||||
Editable = false;
|
||||
TableRelation = "No. Series";
|
||||
}
|
||||
field(10; Name; Text[100])
|
||||
{
|
||||
Caption = 'Name';
|
||||
}
|
||||
}
|
||||
|
||||
keys
|
||||
{
|
||||
key(PK; "No.")
|
||||
{
|
||||
Clustered = true;
|
||||
}
|
||||
}
|
||||
|
||||
trigger OnInsert()
|
||||
var
|
||||
LoyaltySetup: Record "Loyalty Setup";
|
||||
NoSeries: Codeunit "No. Series";
|
||||
begin
|
||||
if "No." = '' then begin
|
||||
LoyaltySetup.Get();
|
||||
LoyaltySetup.TestField("Member Nos.");
|
||||
"No. Series" := LoyaltySetup."Member Nos.";
|
||||
"No." := NoSeries.GetNextNo("No. Series");
|
||||
end;
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: data-modeling
|
||||
keywords: [no-series, primary-key, code20, oninsert, autoincrement, number-assignment]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# A master table's `No.` primary key comes from a number series in `OnInsert`
|
||||
|
||||
## Description
|
||||
|
||||
In Business Central, a master table (Customer, Vendor, Item, and any custom equivalent) uses a single primary-key field named `No.` of type `Code[20]`. It is populated from a number series — configured on the feature's application-area setup table — inside the table's `OnInsert` trigger, but only when `No.` is still blank (so a user may still type a manual number when the series allows it). The record also keeps a non-editable `No. Series` `Code[20]` field recording which series produced the number.
|
||||
|
||||
This is not an `Integer` `AutoIncrement` key, a GUID, or the `SystemId`. Those are surrogate/system identifiers that users never see and cannot quote; BC's whole document flow — lookups, filtering, printed references, telephone support — depends on a short, human-readable, business-controlled `No.`. Use the modern assignment API described in `use-no-series-codeunit-not-noseriesmanagement.md`.
|
||||
|
||||
## Best Practice
|
||||
|
||||
`No.` `Code[20]` is the sole primary key; a non-editable `No. Series` `Code[20]` field records the source series. `OnInsert` checks `if "No." = ''`, reads the setup table, `TestField`s the configured series, stores it in `No. Series`, and assigns `No.` from the series.
|
||||
|
||||
See sample: `master-table-no-from-number-series-in-oninsert.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
An `Integer` `AutoIncrement` (or GUID / `SystemId`) primary key used as the business key, with no `OnInsert` number assignment. Records get an opaque identifier no user can reference, and the master no longer participates in the standard numbering and manual-entry behavior every other BC master follows.
|
||||
|
||||
See sample: `master-table-no-from-number-series-in-oninsert.bad.al`.
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
table 50369 "Loyalty Member"
|
||||
{
|
||||
Caption = 'Loyalty Member';
|
||||
DataClassification = CustomerContent;
|
||||
|
||||
fields
|
||||
{
|
||||
field(1; "No."; Code[20])
|
||||
{
|
||||
Caption = 'No.';
|
||||
}
|
||||
field(10; Name; Text[100])
|
||||
{
|
||||
Caption = 'Name';
|
||||
}
|
||||
field(20; "Last Date Modified"; Date)
|
||||
{
|
||||
Caption = 'Last Date Modified';
|
||||
Editable = false;
|
||||
}
|
||||
}
|
||||
|
||||
keys
|
||||
{
|
||||
key(PK; "No.")
|
||||
{
|
||||
Clustered = true;
|
||||
}
|
||||
}
|
||||
|
||||
trigger OnModify()
|
||||
begin
|
||||
"Last Date Modified" := Today();
|
||||
end;
|
||||
|
||||
// Missing OnRename: renaming the member changes the primary key without
|
||||
// firing OnModify, so "Last Date Modified" keeps its old, stale value and
|
||||
// change-detection logic downstream skips the renamed record.
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
table 50368 "Loyalty Member"
|
||||
{
|
||||
Caption = 'Loyalty Member';
|
||||
DataClassification = CustomerContent;
|
||||
|
||||
fields
|
||||
{
|
||||
field(1; "No."; Code[20])
|
||||
{
|
||||
Caption = 'No.';
|
||||
}
|
||||
field(10; Name; Text[100])
|
||||
{
|
||||
Caption = 'Name';
|
||||
}
|
||||
field(20; "Last Date Modified"; Date)
|
||||
{
|
||||
Caption = 'Last Date Modified';
|
||||
Editable = false;
|
||||
}
|
||||
}
|
||||
|
||||
keys
|
||||
{
|
||||
key(PK; "No.")
|
||||
{
|
||||
Clustered = true;
|
||||
}
|
||||
}
|
||||
|
||||
trigger OnModify()
|
||||
begin
|
||||
"Last Date Modified" := Today();
|
||||
end;
|
||||
|
||||
trigger OnRename()
|
||||
begin
|
||||
"Last Date Modified" := Today();
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: data-modeling
|
||||
keywords: [last-date-modified, onmodify, onrename, audit-field, non-editable, stale-value]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Refresh `Last Date Modified` in both `OnModify` and `OnRename`
|
||||
|
||||
## Description
|
||||
|
||||
Master tables carry a non-editable `Last Date Modified` field of type `Date`. It records when the record last changed and is refreshed by table triggers, not by the user. The refresh must happen in **both** `OnModify` and `OnRename`.
|
||||
|
||||
The reason is a BC-specific trap: renaming a record changes its primary key and fires `OnRename` — it does **not** fire `OnModify`. A table that updates `Last Date Modified` only in `OnModify` therefore leaves a stale date behind every rename. Downstream logic that keys on this field (incremental sync, integration deltas, "changed since" reports) then silently skips the renamed record. Assign `Today` (the system date), not `WorkDate`, because the field reflects the real modification moment.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Both `OnModify` and `OnRename` set `"Last Date Modified" := Today();`, and the field is declared `Editable = false` so only the triggers maintain it.
|
||||
|
||||
See sample: `set-last-date-modified-in-onmodify-and-onrename.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Only `OnModify` assigns `Last Date Modified`. After a rename the value is stale, and any process that trusts it to detect changes misses the record.
|
||||
|
||||
See sample: `set-last-date-modified-in-onmodify-and-onrename.bad.al`.
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
table 50366 "Loyalty Setup"
|
||||
{
|
||||
Caption = 'Loyalty Setup';
|
||||
DataClassification = CustomerContent;
|
||||
|
||||
fields
|
||||
{
|
||||
// Anti-pattern: an autoincrement key lets the table hold many rows,
|
||||
// so "the setup" is no longer a single, well-known record.
|
||||
field(1; "Entry No."; Integer)
|
||||
{
|
||||
Caption = 'Entry No.';
|
||||
AutoIncrement = true;
|
||||
}
|
||||
field(10; "Member Nos."; Code[20])
|
||||
{
|
||||
Caption = 'Member Nos.';
|
||||
TableRelation = "No. Series";
|
||||
}
|
||||
}
|
||||
|
||||
keys
|
||||
{
|
||||
key(PK; "Entry No.")
|
||||
{
|
||||
Clustered = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
page 50367 "Loyalty Setup List"
|
||||
{
|
||||
// Anti-pattern: a List page over a setup table invites multiple rows and
|
||||
// never guarantees that a row exists to read.
|
||||
Caption = 'Loyalty Setup List';
|
||||
PageType = List;
|
||||
SourceTable = "Loyalty Setup";
|
||||
UsageCategory = Administration;
|
||||
ApplicationArea = All;
|
||||
|
||||
layout
|
||||
{
|
||||
area(Content)
|
||||
{
|
||||
repeater(Group)
|
||||
{
|
||||
field("Member Nos."; Rec."Member Nos.")
|
||||
{
|
||||
ApplicationArea = All;
|
||||
ToolTip = 'Specifies the number series used to assign member numbers.';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
table 50364 "Loyalty Setup"
|
||||
{
|
||||
Caption = 'Loyalty Setup';
|
||||
DataClassification = CustomerContent;
|
||||
|
||||
fields
|
||||
{
|
||||
field(1; "Primary Key"; Code[10])
|
||||
{
|
||||
Caption = 'Primary Key';
|
||||
}
|
||||
field(10; "Member Nos."; Code[20])
|
||||
{
|
||||
Caption = 'Member Nos.';
|
||||
TableRelation = "No. Series";
|
||||
}
|
||||
}
|
||||
|
||||
keys
|
||||
{
|
||||
key(PK; "Primary Key")
|
||||
{
|
||||
Clustered = true;
|
||||
}
|
||||
}
|
||||
|
||||
procedure GetRecordOnce()
|
||||
begin
|
||||
if Rec.Get() then
|
||||
exit;
|
||||
Rec.Init();
|
||||
Rec.Insert();
|
||||
end;
|
||||
}
|
||||
|
||||
page 50365 "Loyalty Setup"
|
||||
{
|
||||
Caption = 'Loyalty Setup';
|
||||
PageType = Card;
|
||||
SourceTable = "Loyalty Setup";
|
||||
UsageCategory = Administration;
|
||||
ApplicationArea = All;
|
||||
InsertAllowed = false;
|
||||
DeleteAllowed = false;
|
||||
|
||||
layout
|
||||
{
|
||||
area(Content)
|
||||
{
|
||||
group(Numbering)
|
||||
{
|
||||
Caption = 'Numbering';
|
||||
field("Member Nos."; Rec."Member Nos.")
|
||||
{
|
||||
ApplicationArea = All;
|
||||
ToolTip = 'Specifies the number series used to assign member numbers.';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
trigger OnOpenPage()
|
||||
begin
|
||||
Rec.Reset();
|
||||
if not Rec.Get() then begin
|
||||
Rec.Init();
|
||||
Rec.Insert();
|
||||
end;
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: data-modeling
|
||||
keywords: [setup-table, insertallowed, deleteallowed, getrecordonce, primary-key, card-page]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# A setup table is a singleton: one blank-keyed row, no insert or delete
|
||||
|
||||
## Description
|
||||
|
||||
An application-area setup table (`Sales & Receivables Setup`, `Inventory Setup`, and any custom `* Setup`) holds exactly one record per company. Its primary key is a single `Code[10]` field named `Primary Key`, and the row's value is left blank. Nothing else identifies the row — there is only ever one.
|
||||
|
||||
The setup **card** page enforces the singleton: `InsertAllowed = false` and `DeleteAllowed = false` stop a second row or an empty table, and the page guarantees the row exists on first open — typically `OnOpenPage` with `if not Rec.Get() then begin Rec.Init(); Rec.Insert(); end;`, or a `GetRecordOnce` helper on the table. Consuming code then reads it with a plain `Get()`. The read side needs no access optimization — see `singleton-setup-tables-need-no-access-optimization.md`.
|
||||
|
||||
## Best Practice
|
||||
|
||||
`Primary Key` `Code[10]` is the sole key; the setup is surfaced through a Card page with `InsertAllowed = false`, `DeleteAllowed = false`, and an open-time guard that inserts the blank row if it is missing.
|
||||
|
||||
See sample: `setup-table-is-a-singleton.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
An `Integer` / `AutoIncrement` key, a page that allows insert or delete, or a List page over the setup table. Any of these lets the table hold zero or many rows, so "the setup" becomes ambiguous and `Get()` may fail or read the wrong record.
|
||||
|
||||
See sample: `setup-table-is-a-singleton.bad.al`.
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
table 50363 "Loyalty Member"
|
||||
{
|
||||
Caption = 'Loyalty Member';
|
||||
DataClassification = CustomerContent;
|
||||
|
||||
fields
|
||||
{
|
||||
field(1; "No."; Code[20])
|
||||
{
|
||||
Caption = 'No.';
|
||||
|
||||
trigger OnValidate()
|
||||
begin
|
||||
if "No." = xRec."No." then
|
||||
exit;
|
||||
LoyaltySetup.Get();
|
||||
// Obsolete-pending: NoSeriesManagement.TestManual raises a
|
||||
// deprecation warning and is scheduled for removal.
|
||||
NoSeriesMgt.TestManual(LoyaltySetup."Member Nos.");
|
||||
"No. Series" := '';
|
||||
end;
|
||||
}
|
||||
field(2; "No. Series"; Code[20])
|
||||
{
|
||||
Caption = 'No. Series';
|
||||
Editable = false;
|
||||
}
|
||||
}
|
||||
|
||||
keys
|
||||
{
|
||||
key(PK; "No.")
|
||||
{
|
||||
Clustered = true;
|
||||
}
|
||||
}
|
||||
|
||||
var
|
||||
LoyaltySetup: Record "Loyalty Setup";
|
||||
NoSeriesMgt: Codeunit NoSeriesManagement;
|
||||
|
||||
trigger OnInsert()
|
||||
begin
|
||||
if "No." = '' then begin
|
||||
LoyaltySetup.Get();
|
||||
LoyaltySetup.TestField("Member Nos.");
|
||||
// Obsolete-pending legacy assignment call; use codeunit "No. Series".
|
||||
NoSeriesMgt.InitSeries(LoyaltySetup."Member Nos.", xRec."No. Series", 0D, "No.", "No. Series");
|
||||
end;
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
table 50362 "Loyalty Member"
|
||||
{
|
||||
Caption = 'Loyalty Member';
|
||||
DataClassification = CustomerContent;
|
||||
|
||||
fields
|
||||
{
|
||||
field(1; "No."; Code[20])
|
||||
{
|
||||
Caption = 'No.';
|
||||
|
||||
trigger OnValidate()
|
||||
var
|
||||
NoSeries: Codeunit "No. Series";
|
||||
begin
|
||||
if "No." = xRec."No." then
|
||||
exit;
|
||||
LoyaltySetup.Get();
|
||||
if not NoSeries.IsManual(LoyaltySetup."Member Nos.") then
|
||||
Error(ManualNosNotAllowedErr);
|
||||
"No. Series" := '';
|
||||
end;
|
||||
}
|
||||
field(2; "No. Series"; Code[20])
|
||||
{
|
||||
Caption = 'No. Series';
|
||||
Editable = false;
|
||||
TableRelation = "No. Series";
|
||||
}
|
||||
}
|
||||
|
||||
keys
|
||||
{
|
||||
key(PK; "No.")
|
||||
{
|
||||
Clustered = true;
|
||||
}
|
||||
}
|
||||
|
||||
var
|
||||
LoyaltySetup: Record "Loyalty Setup";
|
||||
ManualNosNotAllowedErr: Label 'Numbers are assigned automatically. Allow manual numbers on the No. Series to enter one by hand.';
|
||||
|
||||
trigger OnInsert()
|
||||
var
|
||||
NoSeries: Codeunit "No. Series";
|
||||
begin
|
||||
if "No." = '' then begin
|
||||
LoyaltySetup.Get();
|
||||
LoyaltySetup.TestField("Member Nos.");
|
||||
"No. Series" := LoyaltySetup."Member Nos.";
|
||||
"No." := NoSeries.GetNextNo("No. Series");
|
||||
end;
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
---
|
||||
bc-version: [22..]
|
||||
domain: data-modeling
|
||||
keywords: [no-series, getnextno, ismanual, noseriesmanagement, obsolete-pending, testmanual]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Assign numbers with codeunit `"No. Series"`, not the obsolete `NoSeriesManagement`
|
||||
|
||||
## Description
|
||||
|
||||
Since 2023 release wave 1 (v22) the number-series API is codeunit **310** `"No. Series"`, called by name in AL. Its methods include `GetNextNo`, `PeekNextNo`, `IsManual`, `TestManual`, and `LookupRelatedNoSeries`. The older codeunit **396** `NoSeriesManagement` and its `InitSeries` / `SelectSeries` / `SetSeries` / `TestManual` methods are marked obsolete-pending: they still compile but raise a deprecation warning and are scheduled for removal, so they must not appear in new code.
|
||||
|
||||
LLMs reproduce the legacy `NoSeriesManagement` pattern because it dominates pre-2023 training data. Prefer the new codeunit: it has a cleaner surface and is the only version that survives the deprecation. (The numbers matter — `310` is the current codeunit; `396` is the legacy one being retired.) Verify signatures on learn.microsoft.com or in the `microsoft/BCApps` source before use.
|
||||
|
||||
## Best Practice
|
||||
|
||||
`OnInsert` assigns the number with `NoSeries.GetNextNo("No. Series")` where `NoSeries` is `Codeunit "No. Series"`. The `No.` field's `OnValidate` guards manual entry by calling `NoSeries.IsManual(...)` (or `TestManual`) before clearing `No. Series`.
|
||||
|
||||
See sample: `use-no-series-codeunit-not-noseriesmanagement.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
`NoSeriesMgt.InitSeries(...)` for assignment and `NoSeriesMgt.TestManual(...)` for the manual check, where `NoSeriesMgt` is `Codeunit NoSeriesManagement`. Both are obsolete-pending and emit compiler warnings.
|
||||
|
||||
See sample: `use-no-series-codeunit-not-noseriesmanagement.bad.al`.
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
// Demonstration only. Shows the wrong pattern: raising the integration event inside a TryFunction body.
|
||||
|
||||
codeunit 50116 "Payment Processor Bad"
|
||||
{
|
||||
[IntegrationEvent(false, false)]
|
||||
procedure OnBeforeSubmitPayment(var PaymentAmount: Decimal; var Cancel: Boolean)
|
||||
begin
|
||||
end;
|
||||
|
||||
procedure SubmitPayment(PaymentAmount: Decimal)
|
||||
var
|
||||
Success: Boolean;
|
||||
begin
|
||||
// TryFunction wraps both the event raise and the gateway call.
|
||||
Success := TrySubmitPaymentInternal(PaymentAmount);
|
||||
if not Success then
|
||||
Error('Payment gateway call failed. Check connectivity and retry.');
|
||||
end;
|
||||
|
||||
[TryFunction]
|
||||
local procedure TrySubmitPaymentInternal(PaymentAmount: Decimal)
|
||||
var
|
||||
Cancel: Boolean;
|
||||
Client: HttpClient;
|
||||
Response: HttpResponseMessage;
|
||||
begin
|
||||
Cancel := false;
|
||||
// BAD: event raised inside TryFunction. Any Error() thrown by a subscriber is caught here
|
||||
// and silently swallowed - the subscriber's error never reaches the caller.
|
||||
// A subscriber setting Cancel := true is also lost when TryFunction returns false.
|
||||
OnBeforeSubmitPayment(PaymentAmount, Cancel);
|
||||
if Cancel then
|
||||
exit;
|
||||
Client.Get('https://payments.example.com/submit?amount=' + Format(PaymentAmount), Response);
|
||||
if not Response.IsSuccessStatusCode() then
|
||||
Error('HTTP %1', Response.HttpStatusCode());
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
// Demonstration only. Shows the correct pattern: raise the integration event before entering TryFunction.
|
||||
|
||||
codeunit 50114 "Payment Processor"
|
||||
{
|
||||
[IntegrationEvent(false, false)]
|
||||
procedure OnBeforeSubmitPayment(var PaymentAmount: Decimal; var Cancel: Boolean)
|
||||
begin
|
||||
end;
|
||||
|
||||
procedure SubmitPayment(PaymentAmount: Decimal)
|
||||
var
|
||||
Cancel: Boolean;
|
||||
Success: Boolean;
|
||||
begin
|
||||
Cancel := false;
|
||||
// Event raised outside the try scope - subscriber errors propagate normally to the caller.
|
||||
OnBeforeSubmitPayment(PaymentAmount, Cancel);
|
||||
if Cancel then
|
||||
exit;
|
||||
|
||||
// Only the operation that can fail transiently lives inside TryFunction.
|
||||
Success := TryCallPaymentGateway(PaymentAmount);
|
||||
if not Success then
|
||||
Error('Payment gateway call failed. Check connectivity and retry.');
|
||||
end;
|
||||
|
||||
[TryFunction]
|
||||
local procedure TryCallPaymentGateway(PaymentAmount: Decimal)
|
||||
var
|
||||
Client: HttpClient;
|
||||
Response: HttpResponseMessage;
|
||||
begin
|
||||
// ... build request, set headers ...
|
||||
Client.Get('https://payments.example.com/submit?amount=' + Format(PaymentAmount), Response);
|
||||
if not Response.IsSuccessStatusCode() then
|
||||
Error('HTTP %1', Response.HttpStatusCode());
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: events
|
||||
keywords: [tryfunction, integration-event, subscriber, error-handling, silent-failure, event-publisher]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Do not raise integration events inside a TryFunction
|
||||
|
||||
## Description
|
||||
|
||||
A `TryFunction` catches all errors — including errors thrown by event subscribers. When an `[IntegrationEvent]` is raised inside a `TryFunction` body, any error a subscriber raises is silently swallowed by the TryFunction's error boundary. The subscriber's logic fails, the caller sees no error, and the calling code continues as if nothing happened. Subscribers have no way to signal failure to the caller.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Raise the integration event before entering the TryFunction scope. The event and its subscribers execute outside the error boundary, so subscriber errors propagate normally to the caller. Move only the operation that genuinely needs error isolation (such as an HTTP call or a posting step) inside the TryFunction.
|
||||
|
||||
See sample: `avoid-raising-events-inside-try-functions.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Raising an integration event inside a TryFunction body. Subscriber failures are caught and discarded by the TryFunction. The subscriber contract — that a subscriber can signal failure to the caller — is silently broken.
|
||||
|
||||
See sample: `avoid-raising-events-inside-try-functions.bad.al`.
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
codeunit 50100 "Event Audit Buffer"
|
||||
{
|
||||
SingleInstance = true;
|
||||
|
||||
// Unbounded global: every event fires adds an entry for the lifetime of the session.
|
||||
var
|
||||
AllEventIds: List of [Guid];
|
||||
|
||||
[EventSubscriber(ObjectType::Table, Database::"Sales Header", OnAfterInsertEvent, '', false, false)]
|
||||
local procedure OnAfterInsertSalesHeader(var Rec: Record "Sales Header")
|
||||
begin
|
||||
// No cap. No eviction. No reset. A session that sees ten thousand inserts
|
||||
// keeps ten thousand GUIDs in memory until the user signs out.
|
||||
AllEventIds.Add(Rec.SystemId);
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
codeunit 50100 "Event Audit Buffer"
|
||||
{
|
||||
SingleInstance = true;
|
||||
|
||||
var
|
||||
RecentEventIds: List of [Guid];
|
||||
MaxBuffered: Integer;
|
||||
|
||||
trigger OnRun()
|
||||
begin
|
||||
MaxBuffered := 50;
|
||||
end;
|
||||
|
||||
[EventSubscriber(ObjectType::Table, Database::"Sales Header", OnAfterInsertEvent, '', false, false)]
|
||||
local procedure OnAfterInsertSalesHeader(var Rec: Record "Sales Header")
|
||||
begin
|
||||
// Bounded cache: drop the oldest entry when the cap is reached.
|
||||
RecentEventIds.Add(Rec.SystemId);
|
||||
if RecentEventIds.Count() > MaxBuffered then
|
||||
RecentEventIds.RemoveAt(1);
|
||||
end;
|
||||
|
||||
procedure ResetAtBusinessProcessBoundary()
|
||||
begin
|
||||
// Explicit reset point at a natural boundary in the workflow.
|
||||
Clear(RecentEventIds);
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: performance
|
||||
keywords: [singleinstance, subscriber, event, memory, session]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Avoid growing globals in SingleInstance subscribers
|
||||
|
||||
> Contributions welcome — open a PR to refine or extend this article.
|
||||
|
||||
## Description
|
||||
|
||||
A codeunit with `SingleInstance = true` is allocated once per session and lives until the session ends. Global variables on it are never collected between event fires. A subscriber that accumulates data into a global — buffering payloads, appending to a list, caching without a cap — steadily grows its session footprint for the entire user session. The symptom is memory that only recovers on sign-out, and it surfaces only on long-running sessions.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Keep the global footprint on a SingleInstance subscriber bounded and intentional: a handful of flags, a setup record, a bounded cache with a maximum size. When cross-event state is genuinely needed, define an explicit reset point — end of a business process, arrival of a specific terminal event — that clears the growing collection.
|
||||
|
||||
See sample: `avoid-growing-globals-in-singleinstance-subscribers.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
A SingleInstance subscriber that appends each event's payload to a global list, dictionary, or temporary record without a cap or cleanup trigger. The list grows for hours, memory pressure builds quietly, and debugging the root cause on a live environment is substantially harder than noticing the unbounded append in code review.
|
||||
|
||||
See sample: `avoid-growing-globals-in-singleinstance-subscribers.bad.al`.
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
table 50100 "Item Ledger Entry (Demo)"
|
||||
{
|
||||
fields
|
||||
{
|
||||
field(1; "Entry No."; Integer) { DataClassification = SystemMetadata; }
|
||||
field(2; "Item No."; Code[20]) { DataClassification = CustomerContent; }
|
||||
field(3; "Posting Date"; Date) { DataClassification = CustomerContent; }
|
||||
field(4; Quantity; Decimal) { DataClassification = CustomerContent; }
|
||||
field(5; "Cost Amount"; Decimal) { DataClassification = CustomerContent; }
|
||||
}
|
||||
|
||||
keys
|
||||
{
|
||||
key(PK; "Entry No.") { Clustered = true; }
|
||||
|
||||
// Write-heavy ledger key: aggregates on this key are read rarely relative
|
||||
// to INSERT frequency. Keeping SIFT live on every write is net-negative.
|
||||
key(ByItemAndDate; "Item No.", "Posting Date")
|
||||
{
|
||||
SumIndexFields = Quantity, "Cost Amount";
|
||||
MaintainSIFTIndex = false;
|
||||
}
|
||||
|
||||
// Dashboard-facing key: aggregates read on every session load, underlying
|
||||
// rows updated infrequently. Keeping SIFT live pays for itself.
|
||||
key(ByItem; "Item No.")
|
||||
{
|
||||
SumIndexFields = Quantity;
|
||||
MaintainSIFTIndex = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: performance
|
||||
keywords: [maintainsiftindex, sift, calcsums, flowfield, write-cost]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Choose MaintainSIFTIndex by read-write ratio
|
||||
|
||||
> Contributions welcome — open a PR to refine or extend this article.
|
||||
|
||||
## Description
|
||||
|
||||
`MaintainSIFTIndex` on a key decides whether the SIFT aggregate structure is updated on every `INSERT`, `MODIFY`, and `DELETE` that touches the key's fields. With `Yes`, `CalcSums` and FlowField reads are immediate — but every write pays the cost of updating the aggregate. With `No`, writes are cheaper but the first aggregate read after a change has to rebuild. Neither value is universally correct; the right choice depends on how often the aggregate is read versus how often the underlying rows are written.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Measure read-to-write ratios for the key's SIFT fields under realistic workloads. Set `MaintainSIFTIndex = Yes` only on keys whose aggregates are read far more often than the rows are written (reporting keys on reference tables, dashboards). Set `No` on keys whose rows are written heavily and whose aggregates are read rarely (transactional ledger entries, import-staging tables).
|
||||
|
||||
See sample: `choose-maintainsiftindex-by-read-write-ratio.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Leaving `MaintainSIFTIndex = Yes` on every key by reflex or convenience. On write-heavy tables the cumulative cost turns every INSERT or MODIFY into several additional aggregate updates, and the impact compounds in batch imports and posting routines — often without any code-review signal that the property is the cause.
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
codeunit 50100 "Sales Document Processor"
|
||||
{
|
||||
procedure ProcessDocument(var SalesHeader: Record "Sales Header")
|
||||
begin
|
||||
// Single top-level load pulls every field any branch might touch.
|
||||
// Order records pay for Posting Date and Amount Including VAT that
|
||||
// only the Invoice branch reads, and vice versa.
|
||||
SalesHeader.SetLoadFields(
|
||||
"Document Type", "No.", "Sell-to Customer No.",
|
||||
"Order Date", "Shipment Date", "Completely Shipped",
|
||||
"Posting Date", "Amount Including VAT");
|
||||
|
||||
case SalesHeader."Document Type" of
|
||||
SalesHeader."Document Type"::Order:
|
||||
ProcessOrder(SalesHeader);
|
||||
SalesHeader."Document Type"::Invoice:
|
||||
ProcessInvoice(SalesHeader);
|
||||
end;
|
||||
end;
|
||||
|
||||
local procedure ProcessOrder(var SalesHeader: Record "Sales Header") begin end;
|
||||
local procedure ProcessInvoice(var SalesHeader: Record "Sales Header") begin end;
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
codeunit 50100 "Sales Document Processor"
|
||||
{
|
||||
procedure ProcessDocument(var SalesHeader: Record "Sales Header")
|
||||
begin
|
||||
// Tier 1: the discriminator and any fields every branch reads.
|
||||
SalesHeader.SetLoadFields("Document Type", "No.", "Sell-to Customer No.");
|
||||
|
||||
case SalesHeader."Document Type" of
|
||||
SalesHeader."Document Type"::Order:
|
||||
begin
|
||||
// Tier 2: extend the load only on the branch that needs these fields.
|
||||
SalesHeader.SetLoadFields("Order Date", "Shipment Date", "Completely Shipped");
|
||||
ProcessOrder(SalesHeader);
|
||||
end;
|
||||
SalesHeader."Document Type"::Invoice:
|
||||
begin
|
||||
SalesHeader.SetLoadFields("Posting Date", "Amount Including VAT");
|
||||
ProcessInvoice(SalesHeader);
|
||||
end;
|
||||
end;
|
||||
end;
|
||||
|
||||
local procedure ProcessOrder(var SalesHeader: Record "Sales Header") begin end;
|
||||
local procedure ProcessInvoice(var SalesHeader: Record "Sales Header") begin end;
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: performance
|
||||
keywords: [setloadfields, case, conditional, branch, field-loading]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Load common fields before branching on case
|
||||
|
||||
> Contributions welcome — open a PR to refine or extend this article.
|
||||
|
||||
## Description
|
||||
|
||||
When record processing branches on state, different branches typically read different fields. A single `SetLoadFields` at the top listing every field any branch might touch pulls more data than any individual execution path needs — on the hot path, the rest is loaded for nothing. A two-tier approach matches loading to actual usage: load the fields the `case` expression evaluates plus any fields every branch uses, then add a branch-local `SetLoadFields` inside each branch for that branch's extra fields.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Before the `case`, call `SetLoadFields` with the minimal set — the discriminator field and fields common to every branch. Inside each branch, before the first access to a branch-specific field, add a second `SetLoadFields` covering those fields. The platform honors the in-branch call for the next record operation, so the extra data is fetched only when the branch runs.
|
||||
|
||||
See sample: `load-common-fields-before-branching-on-case.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
A single top-level `SetLoadFields` enumerating every field any branch might read. On records whose state routes them to the fast common branch, the rarely-needed fields are still loaded — the optimization becomes a net-neutral or net-negative change on the hot path.
|
||||
|
||||
See sample: `load-common-fields-before-branching-on-case.bad.al`.
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
codeunit 50100 "Item Reindex Queue"
|
||||
{
|
||||
procedure QueueItemsForReindex(CategoryCode: Code[20])
|
||||
var
|
||||
Item: Record Item;
|
||||
ReindexQueue: Codeunit "Reindex Queue";
|
||||
begin
|
||||
// Default full-record load. Description, Unit Price, Inventory, and
|
||||
// every other column are fetched across the wire and held in memory
|
||||
// for the whole loop - the body only ever reads "No.".
|
||||
Item.SetRange("Item Category Code", CategoryCode);
|
||||
|
||||
if Item.FindSet() then
|
||||
repeat
|
||||
ReindexQueue.Enqueue(Item."No.");
|
||||
until Item.Next() = 0;
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
codeunit 50100 "Item Reindex Queue"
|
||||
{
|
||||
procedure QueueItemsForReindex(CategoryCode: Code[20])
|
||||
var
|
||||
Item: Record Item;
|
||||
ReindexQueue: Codeunit "Reindex Queue";
|
||||
begin
|
||||
// Only the primary key is used in the loop body; load nothing else.
|
||||
Item.SetLoadFields("No.");
|
||||
Item.SetRange("Item Category Code", CategoryCode);
|
||||
|
||||
if Item.FindSet() then
|
||||
repeat
|
||||
ReindexQueue.Enqueue(Item."No.");
|
||||
until Item.Next() = 0;
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: performance
|
||||
keywords: [setloadfields, primary-key, reference, existence-check, memory]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Load only primary key fields for reference work
|
||||
|
||||
> Contributions welcome — open a PR to refine or extend this article.
|
||||
|
||||
## Description
|
||||
|
||||
Work that uses a record only for its identity — passing it to another procedure that will re-fetch what it needs, queueing a key for later processing, running existence checks, or building a reference collection — does not need non-key payload fields. `SetLoadFields` with only the primary key fields loads the minimum that preserves record identity while skipping everything else. On wide tables with large text, BLOB, or media fields the difference in memory and transfer is substantial.
|
||||
|
||||
## Best Practice
|
||||
|
||||
When the iterating code's body touches only primary key fields (or passes the record to another procedure that will apply its own `SetLoadFields`), declare `SetLoadFields` with just the primary key fields before applying filters and calling `FindSet`. Callers downstream that need more fields issue their own `Get` or extend the load explicitly.
|
||||
|
||||
See sample: `load-only-primary-key-fields-for-reference-work.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Using the default full-record load in loops whose body only reads the primary key, or forwards the record to another codeunit that immediately re-queries. The non-key payload is fetched across the wire and held in memory for the duration of the loop, then discarded unread.
|
||||
|
||||
See sample: `load-only-primary-key-fields-for-reference-work.bad.al`.
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
codeunit 50100 "Recent Orders Summary"
|
||||
{
|
||||
procedure SummarizeRecentOrders(StartDate: Date; EndDate: Date)
|
||||
var
|
||||
SalesHeader: Record "Sales Header";
|
||||
begin
|
||||
// "Document Type" and "Document Date" are listed in SetLoadFields even
|
||||
// though they appear only in filters. Per-row values are transferred
|
||||
// for columns the processing body never reads.
|
||||
SalesHeader.SetLoadFields(
|
||||
"Document Type", "Document Date",
|
||||
"No.", "Sell-to Customer No.", "Amount Including VAT");
|
||||
|
||||
SalesHeader.SetRange("Document Type", SalesHeader."Document Type"::Order);
|
||||
SalesHeader.SetRange("Document Date", StartDate, EndDate);
|
||||
|
||||
if SalesHeader.FindSet() then
|
||||
repeat
|
||||
Emit(SalesHeader."No.", SalesHeader."Sell-to Customer No.", SalesHeader."Amount Including VAT");
|
||||
until SalesHeader.Next() = 0;
|
||||
end;
|
||||
|
||||
local procedure Emit(No: Code[20]; CustNo: Code[20]; Amount: Decimal) begin end;
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
codeunit 50100 "Recent Orders Summary"
|
||||
{
|
||||
procedure SummarizeRecentOrders(StartDate: Date; EndDate: Date)
|
||||
var
|
||||
SalesHeader: Record "Sales Header";
|
||||
begin
|
||||
// "Document Type" and "Document Date" are used only in the filters below.
|
||||
// The database index handles them; there is no need to load their values
|
||||
// into AL memory for every row.
|
||||
SalesHeader.SetLoadFields("No.", "Sell-to Customer No.", "Amount Including VAT");
|
||||
|
||||
SalesHeader.SetRange("Document Type", SalesHeader."Document Type"::Order);
|
||||
SalesHeader.SetRange("Document Date", StartDate, EndDate);
|
||||
|
||||
if SalesHeader.FindSet() then
|
||||
repeat
|
||||
Emit(SalesHeader."No.", SalesHeader."Sell-to Customer No.", SalesHeader."Amount Including VAT");
|
||||
until SalesHeader.Next() = 0;
|
||||
end;
|
||||
|
||||
local procedure Emit(No: Code[20]; CustNo: Code[20]; Amount: Decimal) begin end;
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: performance
|
||||
keywords: [setloadfields, filter, field-exclusion, index]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Omit filter-only fields from SetLoadFields
|
||||
|
||||
> Contributions welcome — open a PR to refine or extend this article.
|
||||
|
||||
## Description
|
||||
|
||||
Fields used only in `SetRange` and `SetFilter` do their work at the database level using indexes; their values never need to be loaded into AL memory for the filter to apply. Listing such fields in `SetLoadFields` costs the transfer and memory footprint of every row's value for no functional benefit. Distinguishing filter-only fields from processing fields keeps the loaded column set as narrow as the iterating code actually reads.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Include in `SetLoadFields` exactly the fields the iterating code reads. Fields referenced only in `SetRange`/`SetFilter` stay out of the list — filtering continues to work correctly because the database uses the index. Treat the audit as "what does the `repeat…until` block touch?" rather than "what does this procedure mention?".
|
||||
|
||||
See sample: `omit-filter-only-fields-from-setloadfields.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Listing every field the procedure mentions in `SetLoadFields`, including date-range or status fields that appear only in filters. The loaded record now carries per-row values for columns the processing body never reads, inflating memory and network cost without changing any behavior.
|
||||
|
||||
See sample: `omit-filter-only-fields-from-setloadfields.bad.al`.
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
codeunit 50100 "Document Router"
|
||||
{
|
||||
procedure Route(SalesHeader: Record "Sales Header")
|
||||
begin
|
||||
// Alphabetical ordering. Every Order (the ~85% common case) evaluates
|
||||
// "Credit Memo", "Invoice", and "Quote" before matching.
|
||||
case SalesHeader."Document Type" of
|
||||
SalesHeader."Document Type"::"Credit Memo":
|
||||
RouteCreditMemo(SalesHeader);
|
||||
SalesHeader."Document Type"::Invoice:
|
||||
RouteInvoice(SalesHeader);
|
||||
SalesHeader."Document Type"::Quote:
|
||||
RouteQuote(SalesHeader);
|
||||
SalesHeader."Document Type"::Order:
|
||||
RouteOrder(SalesHeader);
|
||||
SalesHeader."Document Type"::"Return Order":
|
||||
RouteReturnOrder(SalesHeader);
|
||||
end;
|
||||
end;
|
||||
|
||||
local procedure RouteOrder(SalesHeader: Record "Sales Header") begin end;
|
||||
local procedure RouteInvoice(SalesHeader: Record "Sales Header") begin end;
|
||||
local procedure RouteQuote(SalesHeader: Record "Sales Header") begin end;
|
||||
local procedure RouteCreditMemo(SalesHeader: Record "Sales Header") begin end;
|
||||
local procedure RouteReturnOrder(SalesHeader: Record "Sales Header") begin end;
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
codeunit 50100 "Document Router"
|
||||
{
|
||||
procedure Route(SalesHeader: Record "Sales Header")
|
||||
begin
|
||||
// In this deployment Orders are ~85% of posting calls, Invoices ~12%,
|
||||
// and the rest are edge cases. The hot branch goes first.
|
||||
case SalesHeader."Document Type" of
|
||||
SalesHeader."Document Type"::Order:
|
||||
RouteOrder(SalesHeader);
|
||||
SalesHeader."Document Type"::Invoice:
|
||||
RouteInvoice(SalesHeader);
|
||||
SalesHeader."Document Type"::"Credit Memo":
|
||||
RouteCreditMemo(SalesHeader);
|
||||
SalesHeader."Document Type"::"Return Order":
|
||||
RouteReturnOrder(SalesHeader);
|
||||
else
|
||||
Error('Unexpected document type %1', SalesHeader."Document Type");
|
||||
end;
|
||||
end;
|
||||
|
||||
local procedure RouteOrder(SalesHeader: Record "Sales Header") begin end;
|
||||
local procedure RouteInvoice(SalesHeader: Record "Sales Header") begin end;
|
||||
local procedure RouteCreditMemo(SalesHeader: Record "Sales Header") begin end;
|
||||
local procedure RouteReturnOrder(SalesHeader: Record "Sales Header") begin end;
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: performance
|
||||
keywords: [case, branch, frequency, control-flow, hot-path]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Order case branches by frequency
|
||||
|
||||
> Contributions welcome — open a PR to refine or extend this article.
|
||||
|
||||
## Description
|
||||
|
||||
The AL `case` statement evaluates branches in the order they appear. When the distribution of the discriminator is heavily skewed — one or two values handle the vast majority of records, and the rest handle edge cases — the average cost of the statement is dominated by how many branches precede the common one. For evenly distributed discriminators the order does not matter; for skewed distributions it changes the hot-path cost of every call site.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Where the runtime frequency of values is known or measurable, list the common branches first. An `else` arm that handles unexpected values belongs last. When the common branch is also the simplest to evaluate, the placement compounds: the hot path is both short and cheap, and the uncommon branches are never touched on typical records.
|
||||
|
||||
See sample: `order-case-branches-by-frequency.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Ordering branches alphabetically, by enum declaration order, or by "logical grouping" when the runtime distribution is heavily skewed. Every common record pays the cost of evaluating every uncommon branch first; on a posting routine processing thousands of rows the overhead is measurable.
|
||||
|
||||
See sample: `order-case-branches-by-frequency.bad.al`.
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
codeunit 50100 "Stale Quote Cleanup"
|
||||
{
|
||||
procedure ClearExpiredQuotes(CutoffDate: Date)
|
||||
var
|
||||
SalesHeader: Record "Sales Header";
|
||||
begin
|
||||
SalesHeader.SetRange("Document Type", SalesHeader."Document Type"::Quote);
|
||||
SalesHeader.SetFilter("Document Date", '<%1', CutoffDate);
|
||||
SalesHeader.SetRange(Status, SalesHeader.Status::Open);
|
||||
|
||||
// One SQL DELETE per row. On a 10k-row cleanup, minutes instead of
|
||||
// under a second - and the OnDelete trigger has no logic this call
|
||||
// needs to run.
|
||||
if SalesHeader.FindSet() then
|
||||
repeat
|
||||
SalesHeader.Delete();
|
||||
until SalesHeader.Next() = 0;
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
codeunit 50100 "Stale Quote Cleanup"
|
||||
{
|
||||
procedure ClearExpiredQuotes(CutoffDate: Date)
|
||||
var
|
||||
SalesHeader: Record "Sales Header";
|
||||
begin
|
||||
// OnDelete on Sales Header carries no logic this call depends on:
|
||||
// expired quotes have no ledger entries, shipments, or downstream state.
|
||||
SalesHeader.SetRange("Document Type", SalesHeader."Document Type"::Quote);
|
||||
SalesHeader.SetFilter("Document Date", '<%1', CutoffDate);
|
||||
SalesHeader.SetRange(Status, SalesHeader.Status::Open);
|
||||
|
||||
// Single SQL DELETE. Orders of magnitude faster than FindSet + Delete
|
||||
// once the filtered set exceeds a handful of rows.
|
||||
SalesHeader.DeleteAll();
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: performance
|
||||
keywords: [deleteall, bulk-delete, sql, ondelete, trigger-bypass]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Use DeleteAll for filtered bulk deletion
|
||||
|
||||
> Contributions welcome — open a PR to refine or extend this article.
|
||||
|
||||
## Description
|
||||
|
||||
`DeleteAll` translates to a single SQL `DELETE` with the record variable's current filters applied as the WHERE clause. A loop of `FindSet` + `Delete` instead issues one SQL statement per row. On any dataset larger than a handful of records, the gap is an order of magnitude or more. The tradeoff is that `DeleteAll` bypasses the `OnDelete` table trigger, so the decision hinges on whether that trigger's logic is required for this specific deletion.
|
||||
|
||||
## Best Practice
|
||||
|
||||
After narrowing the record set with `SetRange`/`SetFilter`, use `DeleteAll` whenever the `OnDelete` trigger has no logic that this call depends on — typically the case for housekeeping routines, staging-table cleanup, and deletions already validated upstream. When the trigger IS required, either keep the explicit loop-plus-`Delete` pattern and comment why, or pre-run the trigger logic against a temporary buffer and then `DeleteAll` the primary table.
|
||||
|
||||
See sample: `use-deleteall-for-filtered-bulk-deletion.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Iterating with `FindSet` + `Delete` to clear a filtered set of records that carry no meaningful `OnDelete` logic. Every row pays a full AL round-trip; on a ten-thousand-row cleanup the loop can take minutes where `DeleteAll` takes under a second.
|
||||
|
||||
See sample: `use-deleteall-for-filtered-bulk-deletion.bad.al`.
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
bc-version: [21..]
|
||||
domain: style
|
||||
keywords: [abouttitle, abouttext, teaching-tip, onboarding, page]
|
||||
technologies: [al]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
page 50375 "Sample App Area Bad"
|
||||
{
|
||||
PageType = Card;
|
||||
SourceTable = Customer;
|
||||
layout
|
||||
{
|
||||
area(Content)
|
||||
{
|
||||
group(General)
|
||||
{
|
||||
// Anti-pattern: no ApplicationArea. AS0062 flags this control,
|
||||
// and it is silently hidden in the Web client for profiles whose
|
||||
// enabled areas do not already cover it.
|
||||
field("No."; Rec."No.")
|
||||
{
|
||||
ToolTip = 'Specifies the number that identifies the customer.';
|
||||
}
|
||||
field(Name; Rec.Name)
|
||||
{
|
||||
ToolTip = 'Specifies the customer''s name.';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
page 50374 "Sample App Area Good"
|
||||
{
|
||||
PageType = Card;
|
||||
SourceTable = Customer;
|
||||
layout
|
||||
{
|
||||
area(Content)
|
||||
{
|
||||
group(General)
|
||||
{
|
||||
field("No."; Rec."No.")
|
||||
{
|
||||
ApplicationArea = All;
|
||||
ToolTip = 'Specifies the number that identifies the customer.';
|
||||
}
|
||||
field(Name; Rec.Name)
|
||||
{
|
||||
ApplicationArea = All;
|
||||
ToolTip = 'Specifies the customer''s name.';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
actions
|
||||
{
|
||||
area(Processing)
|
||||
{
|
||||
action(Refresh)
|
||||
{
|
||||
ApplicationArea = All;
|
||||
ToolTip = 'Reloads the current record.';
|
||||
|
||||
trigger OnAction()
|
||||
begin
|
||||
CurrPage.Update(false);
|
||||
end;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: style
|
||||
keywords: [application-area, page-control, as0062, appsourcecop, hidden-control, web-client]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Every page control needs an `ApplicationArea` (AppSourceCop AS0062)
|
||||
|
||||
## Description
|
||||
|
||||
A field control on a page or pageextension that has no `ApplicationArea` property is silently hidden in the Web client for every profile whose enabled application areas do not cover it. There is no error and no warning at runtime — the field simply does not appear, which reads as data loss to the user. AppSourceCop AS0062 flags any page control or action that is missing the `ApplicationArea` property, and AppSource technical validation rejects the app until it is set.
|
||||
|
||||
Set the property to an area the app actually enables. `All` makes the control visible under every profile and is the common default; if the app declares narrower areas in `app.json`, use one of those. The property applies to field controls and to actions. This is a sibling concern to `caption-required-on-page-fields.md` and `tooltip-required-on-page-fields.md`; note that the ToolTip requirement is the separate CodeCop rule AA0218, not AS0062.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Every field control and action carries `ApplicationArea = All;` (or a declared area of the app). The value is set once per control and keeps the control visible in the Web client.
|
||||
|
||||
See sample: `applicationarea-required-on-page-controls.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
A field control with no `ApplicationArea`. AS0062 flags it, and the control is invisible in the Web client for any profile that does not already enable a matching area.
|
||||
|
||||
See sample: `applicationarea-required-on-page-controls.bad.al`.
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
bc-version: [25..]
|
||||
domain: style
|
||||
keywords: [this, codeunit, self-reference, aa0248, scope]
|
||||
technologies: [al]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
codeunit 50409 "Test AssertError Bad"
|
||||
{
|
||||
Subtype = Test;
|
||||
|
||||
[Test]
|
||||
procedure BlankNameIsRejectedWithSpecificError()
|
||||
var
|
||||
Customer: Record Customer;
|
||||
begin
|
||||
Customer.Init();
|
||||
Customer.Name := '';
|
||||
|
||||
// Bare asserterror: passes if ANY error is raised. A relation error,
|
||||
// a permission error, or a typo elsewhere would all satisfy it — so
|
||||
// this never proves the blank-name guard is the thing that fired.
|
||||
asserterror Customer.TestField(Name);
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
codeunit 50408 "Test AssertError Good"
|
||||
{
|
||||
Subtype = Test;
|
||||
|
||||
[Test]
|
||||
procedure BlankNameIsRejectedWithSpecificError()
|
||||
var
|
||||
Customer: Record Customer;
|
||||
begin
|
||||
Customer.Init();
|
||||
Customer.Name := '';
|
||||
|
||||
// [WHEN] a mandatory field is blank
|
||||
asserterror Customer.TestField(Name);
|
||||
|
||||
// [THEN] verify the SPECIFIC failure through a reusable Library helper
|
||||
// instead of hardcoding the localized message and the 'TestField' code.
|
||||
// ExpectedTestFieldError centralizes that knowledge, so the test keeps
|
||||
// working when the caption or code changes; FieldCaption avoids pinning
|
||||
// the field name as a literal.
|
||||
Assert.ExpectedTestFieldError(Customer.FieldCaption(Name), '');
|
||||
end;
|
||||
|
||||
var
|
||||
Assert: Codeunit "Library Assert";
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: testing
|
||||
keywords: [asserterror, expectederror, expectederrorcode, negative-test, error-code]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Pin asserterror to a specific error with ExpectedError and ExpectedErrorCode
|
||||
|
||||
## Description
|
||||
|
||||
`asserterror` passes when the guarded statement raises any error at all. That is too permissive for a negative test: a typo, a missing setup record, or a permission failure all raise errors, so a bare `asserterror` can go green while never exercising the rule it claims to verify — false confidence that the validation works. Constrain it. `Assert.ExpectedError(text)` checks the message of the error that was actually raised, and `Assert.ExpectedErrorCode(code)` checks its error code. Together they assert that the specific failure occurred, turning "something went wrong" into "the right thing went wrong for the right reason".
|
||||
|
||||
## Best Practice
|
||||
|
||||
Follow every `asserterror` with a verification of the error it expects, and prefer the reusable `Library Assert` helpers over hardcoded literals. For a mandatory-field check, `Assert.ExpectedTestFieldError(FieldCaption, ExpectedValue)` encapsulates both the message and the `TestField` code, so the test survives caption or code changes and does not repeat that knowledge in every method. For other errors, pair `Assert.ExpectedError` with a stable substring — ideally a shared `Label`, not an inline sentence — and, where known, `Assert.ExpectedErrorCode`. When a needed check is missing from the shared library, extend `Library Assert` (or your own assert library) with a helper rather than hardcoding message text and codes across tests; matching on a code or an invariant fragment keeps the test from going blind to the wrong error when a caption is localized.
|
||||
|
||||
See sample: `asserterror-needs-expectederror-and-code.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
`asserterror DoInvalid();` with nothing after it. The test asserts only that the call failed somehow; swap the validation for a different bug and the test still passes, certifying a guard that may no longer fire. A negative test that cannot tell one error from another verifies almost nothing.
|
||||
|
||||
See sample: `asserterror-needs-expectederror-and-code.bad.al`.
|
||||
43
microsoft/knowledge/testing/ui-handlers-in-tests.bad.al
Normal file
43
microsoft/knowledge/testing/ui-handlers-in-tests.bad.al
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
codeunit 50401 "Test UI Handlers Bad"
|
||||
{
|
||||
Subtype = Test;
|
||||
|
||||
// Several wiring mistakes, each of which fails at runtime rather than as a
|
||||
// clean assertion the reviewer can read:
|
||||
// * A UI call with no listed handler -> "unhandled UI" abort (the Message
|
||||
// below has no handler).
|
||||
// * The mirror mistake, listing a handler the path never hits, instead
|
||||
// fails with "handler function was not executed".
|
||||
// * A handler that hardcodes its answer and asserts inline, with no
|
||||
// enqueue/dequeue -> nothing proves the RIGHT dialog fired the RIGHT
|
||||
// number of times, and a failed inline assert can be swallowed by the
|
||||
// calling UI operation.
|
||||
[Test]
|
||||
[HandlerFunctions('ConfirmHandler')]
|
||||
procedure PostDocumentConfirmsAndMessages()
|
||||
begin
|
||||
// No Initialize(): a value leaked by an earlier test corrupts this one.
|
||||
RunPostingThatConfirmsAndMessages();
|
||||
// No AssertEmpty(): a missing or extra dialog goes unnoticed.
|
||||
end;
|
||||
|
||||
local procedure RunPostingThatConfirmsAndMessages()
|
||||
begin
|
||||
// Raises a Confirm AND a Message, but only ConfirmHandler is listed:
|
||||
// the Message has nothing to intercept it -> unhandled-UI runtime abort.
|
||||
if Confirm('Post this document?', false) then
|
||||
Message('Posting completed.');
|
||||
end;
|
||||
|
||||
[ConfirmHandler]
|
||||
procedure ConfirmHandler(Question: Text[1024]; var Reply: Boolean)
|
||||
begin
|
||||
// Hardcoded expectation and hardcoded reply. If the wrong dialog fires,
|
||||
// this inline assert may never surface as the test's verdict.
|
||||
Assert.AreEqual('Post this document?', Question, 'Wrong confirm.');
|
||||
Reply := true;
|
||||
end;
|
||||
|
||||
var
|
||||
Assert: Codeunit "Library Assert";
|
||||
}
|
||||
57
microsoft/knowledge/testing/ui-handlers-in-tests.good.al
Normal file
57
microsoft/knowledge/testing/ui-handlers-in-tests.good.al
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
codeunit 50400 "Test UI Handlers Good"
|
||||
{
|
||||
Subtype = Test;
|
||||
|
||||
[Test]
|
||||
[HandlerFunctions('ConfirmHandler,PostMessageHandler')]
|
||||
procedure PostDocumentConfirmsAndMessages()
|
||||
begin
|
||||
Initialize();
|
||||
|
||||
// [GIVEN] the test enqueues, in interaction order, what each handler
|
||||
// will see and how it should answer: the Confirm's expected
|
||||
// question plus the reply to return, then the expected Message.
|
||||
LibraryVariableStorage.Enqueue('Post this document?'); // expected question (substring)
|
||||
LibraryVariableStorage.Enqueue(true); // reply ConfirmHandler returns
|
||||
LibraryVariableStorage.Enqueue('Posting completed.'); // expected message (substring)
|
||||
|
||||
// [WHEN] the code under test raises the Confirm and then the Message
|
||||
RunPostingThatConfirmsAndMessages();
|
||||
|
||||
// [THEN] every enqueued expectation was consumed exactly once
|
||||
LibraryVariableStorage.AssertEmpty();
|
||||
end;
|
||||
|
||||
local procedure Initialize()
|
||||
begin
|
||||
// Clear leftover values so a value leaked by an earlier test cannot
|
||||
// cascade into this one.
|
||||
LibraryVariableStorage.Clear();
|
||||
end;
|
||||
|
||||
local procedure RunPostingThatConfirmsAndMessages()
|
||||
begin
|
||||
// Stands in for the production routine that confirms, then messages.
|
||||
if Confirm('Post this document?', false) then
|
||||
Message('Posting completed.');
|
||||
end;
|
||||
|
||||
[ConfirmHandler]
|
||||
procedure ConfirmHandler(Question: Text[1024]; var Reply: Boolean)
|
||||
begin
|
||||
// Verify the RIGHT dialog fired (substring match), then return the
|
||||
// reply the test enqueued for it.
|
||||
Assert.ExpectedConfirm(LibraryVariableStorage.DequeueText(), Question);
|
||||
Reply := LibraryVariableStorage.DequeueBoolean();
|
||||
end;
|
||||
|
||||
[MessageHandler]
|
||||
procedure PostMessageHandler(Message: Text[1024])
|
||||
begin
|
||||
Assert.ExpectedMessage(LibraryVariableStorage.DequeueText(), Message);
|
||||
end;
|
||||
|
||||
var
|
||||
Assert: Codeunit "Library Assert";
|
||||
LibraryVariableStorage: Codeunit "Library - Variable Storage";
|
||||
}
|
||||
28
microsoft/knowledge/testing/ui-handlers-in-tests.md
Normal file
28
microsoft/knowledge/testing/ui-handlers-in-tests.md
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: testing
|
||||
keywords: [handler, handlerfunctions, confirm, message, strmenu, variable-storage, enqueue, unhandled-ui]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Wire and verify UI handlers with enqueue-driven expectations
|
||||
|
||||
## Description
|
||||
|
||||
A test runs headless: there is no interactive user to answer a dialog. Every UI call the executed path raises — `Confirm`, `Message`, error dialogs, `Page.Run`/`RunModal`, `Report.Run`/`RunModal`, request pages, `StrMenu`, `Notification.Send` — must be intercepted by a handler carrying the matching attribute (`[ConfirmHandler]`, `[MessageHandler]`, `[StrMenuHandler]`, `[ModalPageHandler]`, …) and named in the method's `[HandlerFunctions(...)]`. The list is a two-sided contract: raise a UI call with no listed handler and the platform aborts with an *unhandled UI* error; list a handler the path never hits and it fails with *"handler function was not executed"*. Both are runtime failures — the test never reaches its verdict, so a reviewer sees an infrastructure error instead of a result on the behavior under test.
|
||||
|
||||
Getting the handler *present* is only half the job; the handler must also verify the *right* dialog fired the *right* number of times. Do that by driving handlers from the test, not by hardcoding answers inside them.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Make the test own the expectations and the handlers consume them. Before acting, the test `Enqueue`s — in interaction order — the expected text (a stable substring) and any reply each handler must return. The handler `Dequeue`s the expected text, verifies it with the purpose-built asserts (`Assert.ExpectedMessage`, `Assert.ExpectedConfirm`, `Assert.ExpectedStrMenu` — which match on a fragment, not the full localized caption), then `Dequeue`s and returns its reply. Finish the test body with `LibraryVariableStorage.AssertEmpty` to prove every enqueued interaction fired exactly once, and start each test with an `Initialize` that calls `LibraryVariableStorage.Clear` so a value leaked by an earlier test cannot cascade. List in `[HandlerFunctions]` precisely the handlers the scenario triggers — no superset "just in case", no subset that happens to work today.
|
||||
|
||||
See sample: `ui-handlers-in-tests.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Omitting a handler for a UI call the path raises (unhandled-UI abort), padding the list with a handler the path never reaches ("handler function was not executed"), or writing handlers that hardcode their answer and assert inline with no enqueue/dequeue. The last is the subtle one: nothing proves the correct dialog fired the expected number of times, and an inline assertion that fails inside a handler can be swallowed by the calling UI operation, leaving the suite green while the behavior is broken. Skipping `Initialize`/`AssertEmpty` hides both a leaked queue and a missing or extra dialog.
|
||||
|
||||
See sample: `ui-handlers-in-tests.bad.al`.
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
codeunit 50411 "Test Library Fixtures Bad"
|
||||
{
|
||||
Subtype = Test;
|
||||
|
||||
[Test]
|
||||
procedure OrderUsesHandRolledFixtures()
|
||||
var
|
||||
Customer: Record Customer;
|
||||
SalesHeader: Record "Sales Header";
|
||||
begin
|
||||
// Hand-rolled customer: a chosen "No." with no number-series entry and
|
||||
// none of the mandatory fields a real customer carries. Bypasses the
|
||||
// setup production code assumes and breaks when the schema adds a
|
||||
// required field this test does not set.
|
||||
Customer.Init();
|
||||
Customer."No." := 'X';
|
||||
Customer.Insert();
|
||||
|
||||
SalesHeader.Init();
|
||||
SalesHeader."Document Type" := SalesHeader."Document Type"::Order;
|
||||
SalesHeader."No." := 'SO-X';
|
||||
SalesHeader.Validate("Sell-to Customer No.", Customer."No.");
|
||||
SalesHeader.Insert(true);
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
codeunit 50410 "Test Library Fixtures Good"
|
||||
{
|
||||
Subtype = Test;
|
||||
|
||||
[Test]
|
||||
procedure OrderUsesLibraryCreatedFixtures()
|
||||
var
|
||||
Customer: Record Customer;
|
||||
Item: Record Item;
|
||||
SalesHeader: Record "Sales Header";
|
||||
SalesLine: Record "Sales Line";
|
||||
begin
|
||||
// Library codeunits create valid parents: number series, mandatory
|
||||
// fields and table relations are all handled for you.
|
||||
LibrarySales.CreateCustomer(Customer);
|
||||
LibraryInventory.CreateItem(Item);
|
||||
LibrarySales.CreateSalesHeader(SalesHeader, SalesHeader."Document Type"::Order, Customer."No.");
|
||||
LibrarySales.CreateSalesLine(SalesLine, SalesHeader, SalesLine.Type::Item, Item."No.", LibraryRandom.RandInt(10));
|
||||
|
||||
Assert.AreEqual(Customer."No.", SalesHeader."Sell-to Customer No.", 'Header should use the created customer.');
|
||||
end;
|
||||
|
||||
var
|
||||
Assert: Codeunit "Library Assert";
|
||||
LibrarySales: Codeunit "Library - Sales";
|
||||
LibraryInventory: Codeunit "Library - Inventory";
|
||||
LibraryRandom: Codeunit "Library - Random";
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: testing
|
||||
keywords: [library-codeunits, fixtures, test-data, number-series, prerequisite]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Build fixtures with the test Library codeunits, not hand-rolled Init/Insert
|
||||
|
||||
## Description
|
||||
|
||||
BC ships a layer of test Library codeunits — `LibrarySales`, `LibraryPurchase`, `LibraryERM`, `LibraryInventory`, `LibraryRandom` and many more — whose job is to create valid records. `CreateCustomer` assigns a number from the customer number series, fills the mandatory fields, and satisfies the table relations the platform enforces; `CreateItem` does the same for items. Hand-rolling `Customer.Init`/`Customer.Insert` with invented values skips the number series and any field a future app version adds as mandatory, so the fixture is invalid the moment it is created and rots silently as the schema evolves. The library codeunits also encode fixture *ordering*: because a `TableRelation` field is checked on `Validate` and `Insert(true)`, every parent a foreign key points to must already exist when the dependent record is built. Assemble fixtures top-down — customer and item before the sales line that references them — or the relation check aborts the test at runtime with a data error rather than an assertion. Prefer the Library codeunits for prerequisite data: they encode the setup the platform requires and are maintained alongside the base app.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Reach for the matching Library codeunit before writing manual record setup: `LibrarySales.CreateCustomer`, `LibrarySales.CreateSalesHeader`/`CreateSalesLine`, `LibraryInventory.CreateItem`, `LibraryERM.CreateGLAccount`, and `LibraryRandom.RandInt`/`RandDec` for values. Create the prerequisite parents first and reference their primary keys from dependent records, and `Validate` the foreign-key field so the `TableRelation` — and any field-validation logic — runs exactly as it would in production. Pass the records they return into the code under test. The fixtures stay valid across upgrades because the library — not your test — owns the knowledge of what a well-formed record requires.
|
||||
|
||||
See sample: `use-library-codeunits-for-test-fixtures.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
`Customer.Init(); Customer."No." := 'X'; Customer.Insert();` — a record with a hand-picked primary key, no number-series entry, and none of the mandatory fields a real customer needs. It compiles and may even insert, but it bypasses setup the production code assumes, and it breaks the first time the schema gains a required field the test does not know about.
|
||||
|
||||
See sample: `use-library-codeunits-for-test-fixtures.bad.al`.
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
page 50100 "Integration Log Entries"
|
||||
{
|
||||
PageType = List;
|
||||
SourceTable = "Integration Log Entry";
|
||||
ApplicationArea = All;
|
||||
UsageCategory = History;
|
||||
Caption = 'Integration Log Entries';
|
||||
|
||||
// No descending default sort: the page opens oldest-first.
|
||||
|
||||
layout
|
||||
{
|
||||
area(Content)
|
||||
{
|
||||
repeater(General)
|
||||
{
|
||||
field("Entry No."; Rec."Entry No.")
|
||||
{
|
||||
}
|
||||
field(Status; Rec.Status)
|
||||
{
|
||||
}
|
||||
field(Message; Rec.Message)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
page 50100 "Integration Log Entries"
|
||||
{
|
||||
PageType = List;
|
||||
SourceTable = "Integration Log Entry";
|
||||
ApplicationArea = All;
|
||||
UsageCategory = History;
|
||||
Caption = 'Integration Log Entries';
|
||||
|
||||
// Historical pages should open with the newest records first.
|
||||
SourceTableView = order(descending);
|
||||
|
||||
layout
|
||||
{
|
||||
area(Content)
|
||||
{
|
||||
repeater(General)
|
||||
{
|
||||
field("Entry No."; Rec."Entry No.")
|
||||
{
|
||||
}
|
||||
field(Status; Rec.Status)
|
||||
{
|
||||
}
|
||||
field(Message; Rec.Message)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: ui
|
||||
keywords: [historical-table, list-page, descending-sort, log-entry, ledger-entry, archive]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Default descending sort on historical pages
|
||||
|
||||
## Description
|
||||
Historical list pages should default to showing the newest records first. On pages such as log entries, ledger entries, archives, and other history lists, an oldest-first default order does not align with the primary use of the page, which is typically to review recent activity.
|
||||
|
||||
## Best Practice
|
||||
Set descending sort as the default on list pages whose primary purpose is to present historical records. This is the expected default for entry, log, archive, and posted-history pages unless there is a specific requirement to begin with the oldest record.
|
||||
|
||||
See sample: `default-descending-sort-on-historical-pages.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
Using an oldest-first default order on a historical list page where users are primarily interested in recent activity. Typical signs include history, log, or entry pages that regularly need to be re-sorted to descending during normal use.
|
||||
|
||||
See sample: `default-descending-sort-on-historical-pages.bad.al`.
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
// Bad: SetSelectionFilter with cursor-only (no explicit multi-selection) produces
|
||||
// a primary key filter for just that one row. The codeunit receives only that row;
|
||||
// the rest of the visible list is silently skipped with no error raised.
|
||||
trigger OnAction()
|
||||
var
|
||||
PriceListHeader: Record "Price List Header";
|
||||
TempErrorMessage: Record "Error Message" temporary;
|
||||
ProcessingCodeunit: Codeunit "My Batch Processor";
|
||||
begin
|
||||
CurrPage.SetSelectionFilter(PriceListHeader);
|
||||
ProcessingCodeunit.RunBatch(PriceListHeader, TempErrorMessage);
|
||||
end;
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
// Good: check MarkedOnly before deciding which scope to process.
|
||||
// When MarkedOnly is false (cursor-only or Ctrl+A) fall back to Copy(Rec)
|
||||
// so every record visible in the page view is included.
|
||||
trigger OnAction()
|
||||
var
|
||||
PriceListHeader: Record "Price List Header";
|
||||
TempErrorMessage: Record "Error Message" temporary;
|
||||
ProcessingCodeunit: Codeunit "My Batch Processor";
|
||||
begin
|
||||
CurrPage.SetSelectionFilter(PriceListHeader);
|
||||
if not PriceListHeader.MarkedOnly then
|
||||
PriceListHeader.Copy(Rec);
|
||||
ProcessingCodeunit.RunBatch(PriceListHeader, TempErrorMessage);
|
||||
end;
|
||||
28
microsoft/knowledge/ui/set-selection-filter-list-scope.md
Normal file
28
microsoft/knowledge/ui/set-selection-filter-list-scope.md
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: ui
|
||||
keywords: [set-selection-filter, marked-only, list-page, bulk-action, batch-action, selection-scope, copy-rec]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Preserve list scope after `SetSelectionFilter`
|
||||
|
||||
## Description
|
||||
|
||||
`CurrPage.SetSelectionFilter(Rec)` behaves differently depending on whether the user explicitly multi-selected rows. When no rows are marked — the cursor is simply positioned on a row — the method writes a primary key filter for that single row and leaves `MarkedOnly` as false. When the user explicitly selected multiple rows, the method marks those records and sets `MarkedOnly` to true. A batch action that calls `SetSelectionFilter` and then passes the record directly to a processing codeunit will therefore silently restrict to one row whenever the user has not made an explicit selection, which is almost never the intended behaviour for an action labelled "Verify All" or "Post All".
|
||||
|
||||
The base platform avoids this ambiguity by routing batch list actions through Reports: the Report request page shows the derived filter and lets the user correct it before running. A direct codeunit call has no such safety net and must resolve the scope explicitly.
|
||||
|
||||
## Best Practice
|
||||
|
||||
After calling `SetSelectionFilter`, test `MarkedOnly`. When it is false — meaning the user made no explicit selection, or selected all rows with Ctrl+A — discard the single-row primary key filter by copying the page source record (`Copy(Rec)`), which carries the full page view including all active filter groups. When `MarkedOnly` is true the user made a deliberate selection and that filter should be respected as-is. Refer to `set-selection-filter-list-scope.good.al` for the pattern.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Passing the result of `SetSelectionFilter` directly to a processing codeunit without checking `MarkedOnly`. When the user runs the action with the cursor on row three and no rows highlighted, the codeunit receives a filter that matches only row three. The action appears to succeed but processes a fraction of the intended scope. The defect is hard to notice because no error is raised and the single-row run completes without complaint. See `set-selection-filter-list-scope.bad.al`.
|
||||
|
||||
## See also
|
||||
|
||||
`Page.SetSelectionFilter` — https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/methods-auto/page/page-setselectionfilter-method
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
bc-version: [21..]
|
||||
domain: upgrade
|
||||
keywords: [datatransfer, large-dataset, bulk-update, modifyall, copyfields, new-field]
|
||||
technologies: [al]
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
bc-version: [21..]
|
||||
domain: upgrade
|
||||
keywords: [datatransfer, validate-trigger, event-subscriber, side-effects, business-logic]
|
||||
technologies: [al]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue