mirror of
https://github.com/microsoft/BCQuality.git
synced 2026-10-05 14:46:55 +01:00
Promote knowledge for Microsoft review skills (#153)
Move canonical knowledge for Microsoft-owned review domains into the Microsoft layer and document the skill/knowledge co-location policy. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: Jesper Schulz-Wedde <jesper.schulzwedde@microsoft.com> Copilot-Session: 2a6ea875-d38e-4f30-aadb-0d606f9be231
This commit is contained in:
parent
bca8f478d8
commit
4f0a13a801
88 changed files with 11 additions and 7 deletions
|
|
@ -1,48 +0,0 @@
|
|||
table 50100 "Order Header"
|
||||
{
|
||||
fields
|
||||
{
|
||||
field(1; "Entry No."; Integer)
|
||||
{
|
||||
DataClassification = CustomerContent;
|
||||
}
|
||||
}
|
||||
|
||||
keys
|
||||
{
|
||||
key(PK; "Entry No.")
|
||||
{
|
||||
Clustered = true;
|
||||
}
|
||||
}
|
||||
|
||||
// No OnDelete. Deleting a header silently orphans every Order Line that
|
||||
// belonged to it. Nothing errors, and no page shows the stranded rows.
|
||||
}
|
||||
|
||||
table 50101 "Order Line"
|
||||
{
|
||||
fields
|
||||
{
|
||||
field(1; "Line No."; Integer)
|
||||
{
|
||||
DataClassification = CustomerContent;
|
||||
}
|
||||
field(2; "Header Entry No."; Integer)
|
||||
{
|
||||
DataClassification = CustomerContent;
|
||||
// Reads like referential integrity. It is lookup and input validation
|
||||
// only: it propagates a RENAME of the parent key, and cascades nothing
|
||||
// on DELETE.
|
||||
TableRelation = "Order Header"."Entry No.";
|
||||
}
|
||||
}
|
||||
|
||||
keys
|
||||
{
|
||||
key(PK; "Line No.")
|
||||
{
|
||||
Clustered = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
table 50100 "Order Header"
|
||||
{
|
||||
// The owning table needs delete rights on what it owns. Granting D only on the
|
||||
// header is a common miss and makes OnDelete fail for a non-SUPER user.
|
||||
Permissions = tabledata "Order Line" = rd;
|
||||
|
||||
fields
|
||||
{
|
||||
field(1; "Entry No."; Integer)
|
||||
{
|
||||
DataClassification = CustomerContent;
|
||||
}
|
||||
}
|
||||
|
||||
keys
|
||||
{
|
||||
key(PK; "Entry No.")
|
||||
{
|
||||
Clustered = true;
|
||||
}
|
||||
}
|
||||
|
||||
trigger OnDelete()
|
||||
var
|
||||
OrderLine: Record "Order Line";
|
||||
begin
|
||||
OrderLine.SetRange("Header Entry No.", "Entry No.");
|
||||
// Pass false only when Order Line has no OnDelete of its own.
|
||||
OrderLine.DeleteAll(true);
|
||||
end;
|
||||
}
|
||||
|
||||
table 50101 "Order Line"
|
||||
{
|
||||
fields
|
||||
{
|
||||
field(1; "Line No."; Integer)
|
||||
{
|
||||
DataClassification = CustomerContent;
|
||||
}
|
||||
field(2; "Header Entry No."; Integer)
|
||||
{
|
||||
DataClassification = CustomerContent;
|
||||
TableRelation = "Order Header"."Entry No.";
|
||||
}
|
||||
}
|
||||
|
||||
keys
|
||||
{
|
||||
key(PK; "Line No.")
|
||||
{
|
||||
Clustered = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: data-modeling
|
||||
keywords: [ondelete, cascade, table-relation, orphan-records, header-line, dependent-records, referential-integrity]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# A table that owns dependent records must delete them in `OnDelete`
|
||||
|
||||
## Description
|
||||
|
||||
`TableRelation` looks like referential integrity but only performs lookup and input validation. AL has **no cascading delete**: deleting a parent record leaves every dependent row untouched, and no error is raised.
|
||||
|
||||
What makes this specifically missable is an asymmetry. The platform *does* keep references correct on **rename** — renaming a record updates it in all other locations that declare a `TableRelation` to it, with no code. Delete has no equivalent. Same relation, same metadata, opposite behaviour. A developer who correctly learns that `TableRelation` "keeps references consistent" from the rename case, and generalises it to delete, ships orphans.
|
||||
|
||||
Orphaned rows are usually invisible, because a dependent table rarely has a page of its own. They inflate the table, break later reconciliation, and are re-encountered by duplicate checks when the parent key is reused.
|
||||
|
||||
This applies to internal, staging and `SystemMetadata` tables too. A table having no delete action in the UI today is not protection: a permission set that grants `D` on the table is evidence that deletion is anticipated.
|
||||
|
||||
See also `validate-table-relation-false-suppresses-rename-propagation.md` for the two preconditions on the rename half of this asymmetry.
|
||||
|
||||
## Best Practice
|
||||
|
||||
The owning table implements `OnDelete` and deletes its dependents there, filtered on the foreign key. Declare `Permissions = tabledata <dependent> = rd` on the owning table — granting delete rights only on the parent is a common miss that makes the trigger fail for a non-`SUPER` user. This mirrors the base application, where every header table deletes its own lines.
|
||||
|
||||
See sample: `owning-table-must-delete-dependents-in-ondelete.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
A parent table with dependent rows and no `OnDelete` trigger, where the dependent's foreign-key field declares a `TableRelation` back to the parent. The relation reads as if it guarantees integrity; it does not.
|
||||
|
||||
Detection signal: a table declares `TableRelation` to table X, and table X has no `OnDelete` trigger. Whether a delete path currently exists in the UI is irrelevant to the finding.
|
||||
|
||||
See sample: `owning-table-must-delete-dependents-in-ondelete.bad.al`.
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
table 50123 "Transfer Source Bad"
|
||||
{
|
||||
fields
|
||||
{
|
||||
field(1; "Entry No."; Integer)
|
||||
{
|
||||
DataClassification = CustomerContent;
|
||||
}
|
||||
field(2; "Reference"; Code[20])
|
||||
{
|
||||
DataClassification = CustomerContent;
|
||||
}
|
||||
}
|
||||
|
||||
keys
|
||||
{
|
||||
key(PK; "Entry No.")
|
||||
{
|
||||
Clustered = true;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
table 50124 "Transfer Target Bad"
|
||||
{
|
||||
fields
|
||||
{
|
||||
field(1; "Entry No."; Integer)
|
||||
{
|
||||
DataClassification = CustomerContent;
|
||||
}
|
||||
field(2; "Reference"; Integer)
|
||||
{
|
||||
DataClassification = CustomerContent;
|
||||
}
|
||||
}
|
||||
|
||||
keys
|
||||
{
|
||||
key(PK; "Entry No.")
|
||||
{
|
||||
Clustered = true;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
codeunit 50492 "TransferFields Bad"
|
||||
{
|
||||
procedure CopyData(Source: Record "Transfer Source Bad"; var Target: Record "Transfer Target Bad")
|
||||
begin
|
||||
Target.TransferFields(Source, true, true);
|
||||
end;
|
||||
}
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
table 50121 "Transfer Source"
|
||||
{
|
||||
fields
|
||||
{
|
||||
field(1; "Entry No."; Integer)
|
||||
{
|
||||
DataClassification = CustomerContent;
|
||||
}
|
||||
field(2; "Reference"; Code[20])
|
||||
{
|
||||
DataClassification = CustomerContent;
|
||||
}
|
||||
}
|
||||
|
||||
keys
|
||||
{
|
||||
key(PK; "Entry No.")
|
||||
{
|
||||
Clustered = true;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
table 50122 "Transfer Target"
|
||||
{
|
||||
fields
|
||||
{
|
||||
field(1; "Entry No."; Integer)
|
||||
{
|
||||
DataClassification = CustomerContent;
|
||||
}
|
||||
field(2; "Reference"; Integer)
|
||||
{
|
||||
DataClassification = CustomerContent;
|
||||
}
|
||||
}
|
||||
|
||||
keys
|
||||
{
|
||||
key(PK; "Entry No.")
|
||||
{
|
||||
Clustered = true;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
codeunit 50491 "TransferFields Good"
|
||||
{
|
||||
procedure CopyData(Source: Record "Transfer Source"; var Target: Record "Transfer Target")
|
||||
var
|
||||
ConvertedReference: Integer;
|
||||
begin
|
||||
Target."Entry No." := Source."Entry No.";
|
||||
Evaluate(ConvertedReference, Source."Reference");
|
||||
Target.Validate("Reference", ConvertedReference);
|
||||
end;
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
---
|
||||
bc-version: [16..]
|
||||
domain: data-modeling
|
||||
keywords: [transferfields, skipfieldsnotmatchingtype, type-mismatch, field-mapping, data-transfer]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Do not use SkipFieldsNotMatchingType to hide required TransferFields mismatches
|
||||
|
||||
## Description
|
||||
|
||||
`Record.TransferFields` copies values between fields with matching field numbers. Without `SkipFieldsNotMatchingType` (or with it `false`), a type mismatch between two fields in the same extension raises a runtime error at the point of transfer. Setting `SkipFieldsNotMatchingType` to `true` removes that error: the field is skipped instead, and the rest of the transfer completes normally. The caller gets no indication that a field was not copied.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Use `TransferFields(Source)` only when every field the destination requires, including primary key fields, is guaranteed to share a matching field number and type with the source; this form defaults `InitPrimaryKeyFields` to `true`. Fields with no matching field number, and fields whose types differ across extensions, are skipped regardless of `SkipFieldsNotMatchingType` — that parameter only governs same-extension type mismatches. If the destination depends on a field that falls into either case, map and validate it explicitly in code rather than relying on `TransferFields` to catch the gap. Use `SkipFieldsNotMatchingType = true` only when skipping same-extension type mismatches is an intentional, documented part of the transfer contract.
|
||||
|
||||
See sample: `transferfields-skip-type-mismatch-can-drop-data.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Using `TransferFields(Source, InitPrimaryKeyFields, true)` as a generic way to make two evolving table schemas transfer without errors, when the destination depends on every required source field being copied. A type change on either table can turn a previously transferred field into a silently skipped one without making the transfer itself fail.
|
||||
|
||||
See sample: `transferfields-skip-type-mismatch-can-drop-data.bad.al`.
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
table 50121 "Document Link"
|
||||
{
|
||||
fields
|
||||
{
|
||||
field(1; "Entry No."; Integer)
|
||||
{
|
||||
DataClassification = CustomerContent;
|
||||
}
|
||||
field(2; "Document No."; Code[20])
|
||||
{
|
||||
DataClassification = CustomerContent;
|
||||
TableRelation = "Source Document"."No.";
|
||||
// Added to silence a validation error while the row is staged, before
|
||||
// the Source Document exists. The relation is still declared, so this
|
||||
// reads as harmless — but it also switches OFF rename propagation.
|
||||
// Renaming a Source Document now leaves this field on the old key,
|
||||
// with no error, and nothing else maintains it.
|
||||
ValidateTableRelation = false;
|
||||
}
|
||||
// Composite value: no TableRelation is possible, and no OnRename on the
|
||||
// owning table maintains it either. Rots the same way, for the other reason.
|
||||
field(3; "Source Key"; Code[50])
|
||||
{
|
||||
DataClassification = CustomerContent;
|
||||
}
|
||||
}
|
||||
|
||||
keys
|
||||
{
|
||||
key(PK; "Entry No.")
|
||||
{
|
||||
Clustered = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
table 50120 "Source Document"
|
||||
{
|
||||
fields
|
||||
{
|
||||
field(1; "No."; Code[20])
|
||||
{
|
||||
DataClassification = CustomerContent;
|
||||
}
|
||||
}
|
||||
|
||||
keys
|
||||
{
|
||||
key(PK; "No.")
|
||||
{
|
||||
Clustered = true;
|
||||
}
|
||||
}
|
||||
|
||||
// "Source Key" below cannot declare a TableRelation, so the platform cannot
|
||||
// repoint it. The owning table carries the relationship by hand.
|
||||
trigger OnRename()
|
||||
var
|
||||
DocumentLink: Record "Document Link";
|
||||
begin
|
||||
// In OnRename, xRec holds the PREVIOUS primary key while Rec holds the new
|
||||
// one — the one trigger where that is true regardless of what drove the rename.
|
||||
DocumentLink.SetRange("Source Key", MakeSourceKey(xRec."No."));
|
||||
if DocumentLink.FindSet(true) then
|
||||
repeat
|
||||
DocumentLink."Source Key" := MakeSourceKey(Rec."No.");
|
||||
DocumentLink.Modify(true);
|
||||
until DocumentLink.Next() = 0;
|
||||
end;
|
||||
|
||||
local procedure MakeSourceKey(DocumentNo: Code[20]): Code[50]
|
||||
begin
|
||||
exit(StrSubstNo('DOC|%1', DocumentNo));
|
||||
end;
|
||||
}
|
||||
|
||||
table 50121 "Document Link"
|
||||
{
|
||||
fields
|
||||
{
|
||||
field(1; "Entry No."; Integer)
|
||||
{
|
||||
DataClassification = CustomerContent;
|
||||
}
|
||||
// Default ValidateTableRelation: the platform repoints this on rename.
|
||||
field(2; "Document No."; Code[20])
|
||||
{
|
||||
DataClassification = CustomerContent;
|
||||
TableRelation = "Source Document"."No.";
|
||||
}
|
||||
// Composite value — no TableRelation can express it, so the parent's
|
||||
// OnRename above maintains it explicitly.
|
||||
field(3; "Source Key"; Code[50])
|
||||
{
|
||||
DataClassification = CustomerContent;
|
||||
}
|
||||
}
|
||||
|
||||
keys
|
||||
{
|
||||
key(PK; "Entry No.")
|
||||
{
|
||||
Clustered = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: data-modeling
|
||||
keywords: [validatetablerelation, table-relation, rename, onrename, propagation, dangling-reference, soft-relation]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# `ValidateTableRelation = false` suppresses rename propagation, not just input validation
|
||||
|
||||
## Description
|
||||
|
||||
Renaming a record updates it in all other locations that reference it through a `TableRelation`, with no code. That guarantee has **two** preconditions, and a field failing either one is silently left holding a key that no longer exists.
|
||||
|
||||
First, a `TableRelation` must exist. A field whose value is constructed or computed — a composite key, or a value derived from several fields of the target — cannot declare one, so nothing propagates. The field is still a foreign key in intent, but the platform treats it as an opaque value.
|
||||
|
||||
Second, and far less obvious: the relation must not carry `ValidateTableRelation = false`. The property name implies it only governs *input validation*, so it looks safe to disable on a field populated by code that already knows the target is valid. It is not. **Disabling it also switches off rename propagation.** The relation still documents intent and still drives lookups, but it no longer keeps the stored value correct.
|
||||
|
||||
Both failures are quiet: no error at rename time, and in the first case no input validation either, so a wrong value is never rejected on write.
|
||||
|
||||
This is verified behaviour, not inference. A parent renamed once against a child holding three fields — a normal relation, the same relation with `ValidateTableRelation = false`, and a field with no relation — updates only the first.
|
||||
|
||||
See also `owning-table-must-delete-dependents-in-ondelete.md` for the delete half of this asymmetry, and `xrec-is-a-before-image-only-in-some-triggers.md` for why `OnRename` is the one trigger where a hand-written fix-up is reliable.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Leave `ValidateTableRelation` at its default wherever the stored value must stay correct across a rename. When it must be disabled, or when the relationship cannot be expressed as a `TableRelation` at all, the table owning the referenced key carries an explicit `OnRename` that repoints the dependents itself.
|
||||
|
||||
See sample: `validate-table-relation-false-suppresses-rename-propagation.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
`ValidateTableRelation = false` added to silence a validation error, on a field expected to keep tracking its target. The field stops being maintained on rename, and the defect surfaces much later as a reference to a key that no longer exists.
|
||||
|
||||
Detection signal: any `ValidateTableRelation = false` on a field that also declares a `TableRelation`. Ask what repoints the value when the target is renamed; if the answer is "the platform", the finding stands.
|
||||
|
||||
See sample: `validate-table-relation-false-suppresses-rename-propagation.bad.al`.
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
table 50130 "Service Request"
|
||||
{
|
||||
fields
|
||||
{
|
||||
field(1; "No."; Code[20])
|
||||
{
|
||||
DataClassification = CustomerContent;
|
||||
}
|
||||
field(2; Status; Enum "Service Request Status")
|
||||
{
|
||||
DataClassification = CustomerContent;
|
||||
}
|
||||
}
|
||||
|
||||
keys
|
||||
{
|
||||
key(PK; "No.")
|
||||
{
|
||||
Clustered = true;
|
||||
}
|
||||
}
|
||||
|
||||
trigger OnModify()
|
||||
begin
|
||||
// Dead branch under any code-driven Modify: from code xRec mirrors Rec, so
|
||||
// the two Status values are always equal and LogStatusChange never runs.
|
||||
// Editing the field on a page DOES populate xRec, so this passes manual
|
||||
// testing and then silently does nothing in a job queue or API call.
|
||||
if Status <> xRec.Status then
|
||||
LogStatusChange(xRec.Status, Status);
|
||||
end;
|
||||
|
||||
local procedure LogStatusChange(FromStatus: Enum "Service Request Status"; ToStatus: Enum "Service Request Status")
|
||||
begin
|
||||
end;
|
||||
}
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
table 50130 "Service Request"
|
||||
{
|
||||
fields
|
||||
{
|
||||
field(1; "No."; Code[20])
|
||||
{
|
||||
DataClassification = CustomerContent;
|
||||
}
|
||||
field(2; Status; Enum "Service Request Status")
|
||||
{
|
||||
DataClassification = CustomerContent;
|
||||
}
|
||||
}
|
||||
|
||||
keys
|
||||
{
|
||||
key(PK; "No.")
|
||||
{
|
||||
Clustered = true;
|
||||
}
|
||||
}
|
||||
|
||||
// OnModify: xRec mirrors Rec when the write came from code, so it cannot be
|
||||
// used as a before-image. Re-read the stored row instead — this behaves the
|
||||
// same whether a page, a job queue or an API drove the write.
|
||||
trigger OnModify()
|
||||
var
|
||||
Previous: Record "Service Request";
|
||||
begin
|
||||
if Previous.Get("No.") and (Previous.Status <> Status) then
|
||||
LogStatusChange(Previous.Status, Status);
|
||||
end;
|
||||
|
||||
// OnRename: xRec IS the before-image of the primary key here, whatever drove
|
||||
// the rename. This is the one trigger where the idiom is reliable.
|
||||
trigger OnRename()
|
||||
begin
|
||||
RepointDependents(xRec."No.", "No.");
|
||||
end;
|
||||
|
||||
// OnDelete: xRec reflects the record being removed.
|
||||
trigger OnDelete()
|
||||
begin
|
||||
ArchiveRequest(xRec."No.");
|
||||
end;
|
||||
|
||||
local procedure LogStatusChange(FromStatus: Enum "Service Request Status"; ToStatus: Enum "Service Request Status")
|
||||
begin
|
||||
end;
|
||||
|
||||
local procedure RepointDependents(OldNo: Code[20]; NewNo: Code[20])
|
||||
begin
|
||||
end;
|
||||
|
||||
local procedure ArchiveRequest(RequestNo: Code[20])
|
||||
begin
|
||||
end;
|
||||
}
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: data-modeling
|
||||
keywords: [xrec, before-image, onmodify, onrename, oninsert, ondelete, table-trigger, page-driven]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# `xRec` is a before-image in `OnRename` and `OnDelete`, but mirrors `Rec` in `OnInsert` and `OnModify` from code
|
||||
|
||||
## Description
|
||||
|
||||
`xRec` is widely believed to be "the previous record" inside every table trigger, and — in reaction to that — is often dismissed with the folk rule *"`xRec` only works from a page, never from code"*. Both are wrong, and the second is wrong in the place it matters most.
|
||||
|
||||
The behaviour is per-trigger. In `OnRename` and `OnDelete`, `xRec` is a genuine before-image regardless of what drove the write. In `OnInsert` and `OnModify`, a **code-driven** write leaves `xRec` mirroring `Rec` — there is no before-image at all — while a **page-driven** write does supply one.
|
||||
|
||||
That combination produces a defect that is unusually hard to catch. A comparison such as `if Rec.Status <> xRec.Status then` inside `OnModify` works when a tester clicks through a page, and silently never fires when the same code path runs from a job queue, a batch routine, or an API call. It fails as a no-op, not as an error.
|
||||
|
||||
The `OnRename` case is the useful half: because `xRec` there holds the previous primary key even from code, it is the one place a hand-written key fix-up is reliable. Note that in `OnRename` only the key differs between `Rec` and `xRec` — non-key field values are identical on both sides.
|
||||
|
||||
See also `validate-table-relation-false-suppresses-rename-propagation.md`, which describes when such a hand-written `OnRename` fix-up is required.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Use `xRec` for the previous key in `OnRename`, and for the record being removed in `OnDelete`. In `OnModify`, obtain the before-image by re-reading the stored row rather than trusting `xRec`, so the logic behaves identically whether a page, a job queue or an API drove the write.
|
||||
|
||||
See sample: `xrec-is-a-before-image-only-in-some-triggers.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Comparing `Rec` against `xRec` inside `OnModify` (or `OnInsert`) to detect a change. From code the two are equal, so the branch is dead and whatever it guards never happens.
|
||||
|
||||
Detection signal: any read of `xRec` inside `OnModify` or `OnInsert`. Treat "but it works when I test it on the page" as confirmation of the defect rather than a refutation.
|
||||
|
||||
See sample: `xrec-is-a-before-image-only-in-some-triggers.bad.al`.
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
// Demonstration only. Shows the wrong pattern: the publisher carries no access modifier, so it is
|
||||
// public - which never was what lets extensions subscribe.
|
||||
|
||||
codeunit 50100 "Loyalty Points Mgt Bad"
|
||||
{
|
||||
procedure AwardPoints(CustomerNo: Code[20]; SalesAmount: Decimal)
|
||||
var
|
||||
Points: Decimal;
|
||||
IsHandled: Boolean;
|
||||
begin
|
||||
Points := SalesAmount / 10;
|
||||
|
||||
IsHandled := false;
|
||||
OnBeforeAwardPoints(CustomerNo, Points, IsHandled);
|
||||
if IsHandled then
|
||||
exit;
|
||||
|
||||
// ... insert the loyalty entry ...
|
||||
end;
|
||||
|
||||
// BAD: no access modifier, so this publisher is public. Public access does not enable
|
||||
// subscription - it enables raising. Narrowing it to internal after release breaks callers,
|
||||
// so the widening cannot be walked back cheaply.
|
||||
[IntegrationEvent(false, false)]
|
||||
procedure OnBeforeAwardPoints(CustomerNo: Code[20]; var Points: Decimal; var IsHandled: Boolean)
|
||||
begin
|
||||
end;
|
||||
}
|
||||
|
||||
codeunit 50101 "Loyalty Points Caller Bad"
|
||||
{
|
||||
procedure FirePublisherDirectly(CustomerNo: Code[20])
|
||||
var
|
||||
LoyaltyPointsMgt: Codeunit "Loyalty Points Mgt Bad";
|
||||
Points: Decimal;
|
||||
IsHandled: Boolean;
|
||||
begin
|
||||
// Compiles only because the publisher is public. Every subscriber runs although no points
|
||||
// were ever awarded, on a Points value nobody computed, and the IsHandled answer the
|
||||
// subscribers write is read by no one.
|
||||
LoyaltyPointsMgt.OnBeforeAwardPoints(CustomerNo, Points, IsHandled);
|
||||
end;
|
||||
}
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
// Demonstration only. Shows the correct pattern: a public facade codeunit whose event publishers
|
||||
// are internal, so only the implementation codeunit decides when they fire.
|
||||
|
||||
codeunit 50100 "Loyalty Points Mgt Good"
|
||||
{
|
||||
procedure AwardPoints(CustomerNo: Code[20]; SalesAmount: Decimal)
|
||||
var
|
||||
LoyaltyPointsImpl: Codeunit "Loyalty Points Impl Good";
|
||||
begin
|
||||
LoyaltyPointsImpl.AwardPoints(CustomerNo, SalesAmount);
|
||||
end;
|
||||
|
||||
// internal, not public: the implementation codeunit raises this and nobody else. Subscribers
|
||||
// bind through Codeunit::"Loyalty Points Mgt Good", which is public by default - that object
|
||||
// access is all a subscriber in another extension needs.
|
||||
[IntegrationEvent(false, false)]
|
||||
internal procedure OnBeforeAwardPoints(CustomerNo: Code[20]; var Points: Decimal; var IsHandled: Boolean)
|
||||
begin
|
||||
end;
|
||||
|
||||
[IntegrationEvent(false, false)]
|
||||
internal procedure OnAfterAwardPoints(CustomerNo: Code[20]; Points: Decimal)
|
||||
begin
|
||||
end;
|
||||
}
|
||||
|
||||
codeunit 50101 "Loyalty Points Impl Good"
|
||||
{
|
||||
Access = Internal;
|
||||
|
||||
procedure AwardPoints(CustomerNo: Code[20]; SalesAmount: Decimal)
|
||||
var
|
||||
LoyaltyPointsMgt: Codeunit "Loyalty Points Mgt Good";
|
||||
Points: Decimal;
|
||||
IsHandled: Boolean;
|
||||
begin
|
||||
Points := SalesAmount / 10;
|
||||
|
||||
IsHandled := false;
|
||||
LoyaltyPointsMgt.OnBeforeAwardPoints(CustomerNo, Points, IsHandled);
|
||||
if IsHandled then
|
||||
exit;
|
||||
|
||||
// ... insert the loyalty entry ...
|
||||
|
||||
LoyaltyPointsMgt.OnAfterAwardPoints(CustomerNo, Points);
|
||||
end;
|
||||
}
|
||||
|
||||
codeunit 50102 "Loyalty Points Sub Good"
|
||||
{
|
||||
// The shape a subscriber in a dependent extension takes: it names the public object, and is
|
||||
// indifferent to the publisher being internal.
|
||||
[EventSubscriber(ObjectType::Codeunit, Codeunit::"Loyalty Points Mgt Good", 'OnAfterAwardPoints', '', false, false)]
|
||||
local procedure LogAwardedPointsOnAfterAwardPoints(CustomerNo: Code[20]; Points: Decimal)
|
||||
begin
|
||||
// ... write telemetry ...
|
||||
end;
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: events
|
||||
keywords: [event-publisher, access-modifier, local, internal, integration-event, business-event, subscriber, breaking-change]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Declare event publishers local or internal
|
||||
|
||||
> Contributions welcome — open a PR to refine or extend this article.
|
||||
|
||||
## Description
|
||||
|
||||
The access modifier on an `[IntegrationEvent]` or `[BusinessEvent]` publisher controls who may *raise* the procedure, not who may *subscribe* to it. A subscriber in a dependent extension binds through the object named in its `[EventSubscriber(...)]` attribute, so the only accessibility a foreign subscriber needs is on the *object* — a codeunit left at its default public access. The publisher procedure itself can and should stay `local` or `internal`. Publishing an event is an invitation to subscribe, not an invitation to call: an omitted access modifier makes the publisher public, which hands every dependent extension the ability to fire the event on its own. The signature-compatibility consequences of a shipped publisher are covered separately by `treat-local-and-internal-events-as-subscriber-contracts`.
|
||||
|
||||
## Applies to
|
||||
|
||||
Ordinary `[IntegrationEvent]` and `[BusinessEvent]` publishers. `[InternalEvent]` has its own module-only visibility semantics, and external business events are out of scope.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Give an event publisher the narrowest access modifier that still lets the code owning the operation raise it:
|
||||
|
||||
- `local` when only the declaring object raises the event. This is the common case and the default choice.
|
||||
- `internal` when another object in the same app raises it — typically an internal implementation codeunit raising an event declared on a public facade codeunit. The facade object stays public so dependent extensions can name it in `[EventSubscriber(...)]`; the publisher stays `internal` so only the implementation decides when the event fires.
|
||||
- `public` only when a *different app* must raise the event — a hub or event-bus codeunit in a foundation app that sibling apps signal through, where `internal` cannot reach across the app boundary. This is a deliberate caller contract, not a concession to subscribers, and it is maintained like any other public API.
|
||||
|
||||
Subscribers are unaffected by any of these choices. A non-public publisher also keeps the freedom to add a parameter later, which a public publisher gives up — see `add-new-event-parameters-at-the-end`.
|
||||
|
||||
See sample: `declare-event-publishers-local-or-internal.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
An event publisher declared with no access modifier — or widened to public — in the belief that dependent extensions need that to subscribe. They do not. Two consequences follow. Any dependent extension can now call the publisher directly, firing every subscriber outside the owning routine's control flow, on state the publisher never prepared and with an `IsHandled` answer nobody reads. And because the publisher is a public procedure, it is a caller contract: narrowing it back to `local` or `internal` after release is itself a breaking change, so the mistake is not cheaply reversible.
|
||||
|
||||
Detection: an `[IntegrationEvent]` or `[BusinessEvent]` publisher that is public although every raiser is in its own app — typically raised only from its declaring object. A publisher deliberately made public so another app can raise it is not this anti-pattern; do not report it. When the surrounding repository or API context does not reveal whether an external raiser is intended, treat the public modifier as intentional rather than reporting it.
|
||||
|
||||
The mirror-image anti-pattern belongs to the reviewer, human or agent: recommending that a publisher be made public so extensions can subscribe, or reporting a `local`/`internal` publisher as unreachable dead code. Both readings mistake raising for subscribing. Neither should be raised as a finding.
|
||||
|
||||
See sample: `declare-event-publishers-local-or-internal.bad.al`.
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
// Demonstration-only AL. Not compiled by CI; illustrates the article.
|
||||
|
||||
// Anti-pattern 1: the context is kept in single-instance state.
|
||||
codeunit 50545 "Process State Bad Sample"
|
||||
{
|
||||
SingleInstance = true;
|
||||
|
||||
var
|
||||
ProcessRunning: Boolean;
|
||||
|
||||
procedure SetProcessRunning(NewProcessRunning: Boolean)
|
||||
begin
|
||||
ProcessRunning := NewProcessRunning;
|
||||
end;
|
||||
|
||||
procedure IsProcessRunning(): Boolean
|
||||
begin
|
||||
exit(ProcessRunning);
|
||||
end;
|
||||
}
|
||||
|
||||
codeunit 50546 "Process Driver Bad Sample"
|
||||
{
|
||||
procedure Run(DocumentNo: Code[20])
|
||||
var
|
||||
ProcessState: Codeunit "Process State Bad Sample";
|
||||
begin
|
||||
ProcessState.SetProcessRunning(true);
|
||||
RunSharedCode(DocumentNo);
|
||||
// An error above never reaches this line. The database writes roll
|
||||
// back, the single-instance variable does not: ProcessRunning stays
|
||||
// true until the company is closed, so every later run in this session
|
||||
// is treated as part of the process.
|
||||
ProcessState.SetProcessRunning(false);
|
||||
end;
|
||||
|
||||
local procedure RunSharedCode(DocumentNo: Code[20])
|
||||
begin
|
||||
end;
|
||||
}
|
||||
|
||||
// Anti-pattern 2: the context stays private. Flag and driver look like the
|
||||
// good sample, but the query is internal, so only the owning app can ever ask.
|
||||
codeunit 50547 "Process Ctx Bad Sample"
|
||||
{
|
||||
internal procedure IsProcessRunning(): Boolean
|
||||
var
|
||||
IsRunning: Boolean;
|
||||
begin
|
||||
OnCheckProcessRunning(IsRunning);
|
||||
exit(IsRunning);
|
||||
end;
|
||||
|
||||
[InternalEvent(false)]
|
||||
local procedure OnCheckProcessRunning(var IsRunning: Boolean)
|
||||
begin
|
||||
end;
|
||||
}
|
||||
|
||||
reportextension 50548 "Shared Report Ext Bad Sample" extends "Standard Sales - Invoice"
|
||||
{
|
||||
trigger OnPreReport()
|
||||
begin
|
||||
// No callable query exists, so the extension infers the context from
|
||||
// something it hopes only that process does - here, running without a
|
||||
// UI. The guess is wrong for every other background run, and breaks
|
||||
// silently the first time the owning app changes how it works.
|
||||
if GuiAllowed() then
|
||||
exit;
|
||||
// ... behaviour that was meant to apply only inside that process ...
|
||||
end;
|
||||
}
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
// Demonstration-only AL. Not compiled by CI; illustrates the article.
|
||||
|
||||
// The published context API - the entire public surface of the pattern.
|
||||
// Any dependent extension may call IsProcessRunning; nothing else is exposed.
|
||||
codeunit 50540 "Process Context Good Sample"
|
||||
{
|
||||
procedure IsProcessRunning(): Boolean
|
||||
var
|
||||
IsRunning: Boolean;
|
||||
begin
|
||||
OnCheckProcessRunning(IsRunning);
|
||||
exit(IsRunning);
|
||||
end;
|
||||
|
||||
// InternalEvent: only this app can subscribe, which is all the pattern
|
||||
// needs. local: only this codeunit can raise it.
|
||||
[InternalEvent(false)]
|
||||
local procedure OnCheckProcessRunning(var IsRunning: Boolean)
|
||||
begin
|
||||
end;
|
||||
}
|
||||
|
||||
// The flag - implementation, not API, hence Access = Internal. It stores
|
||||
// nothing between runs: being bound is the state.
|
||||
codeunit 50541 "Process Flag Good Sample"
|
||||
{
|
||||
Access = Internal;
|
||||
EventSubscriberInstance = Manual;
|
||||
|
||||
[EventSubscriber(ObjectType::Codeunit, Codeunit::"Process Context Good Sample", 'OnCheckProcessRunning', '', false, false)]
|
||||
local procedure SetProcessRunning(var IsRunning: Boolean)
|
||||
begin
|
||||
IsRunning := true;
|
||||
end;
|
||||
}
|
||||
|
||||
// The app that drives the process claims the context for exactly its own run.
|
||||
codeunit 50542 "Process Driver Good Sample"
|
||||
{
|
||||
procedure Run(DocumentNo: Code[20])
|
||||
var
|
||||
ProcessFlag: Codeunit "Process Flag Good Sample";
|
||||
begin
|
||||
// A fresh instance, bound for exactly this call. If the shared code
|
||||
// errors, the stack unwinds and takes the binding with it - nothing to reset.
|
||||
BindSubscription(ProcessFlag);
|
||||
RunSharedCode(DocumentNo);
|
||||
end;
|
||||
|
||||
local procedure RunSharedCode(DocumentNo: Code[20])
|
||||
begin
|
||||
// A base application report, a posting routine, or any other object
|
||||
// that extensions hook into - including a customer's own replacement.
|
||||
end;
|
||||
}
|
||||
|
||||
// An extension hooked into that shared code can now ask the question directly
|
||||
// instead of guessing which process is driving the run. The hook happens to be
|
||||
// a report extension here; a subscriber on any other shared object is the same.
|
||||
reportextension 50543 "Shared Report Ext Good Sample" extends "Standard Sales - Invoice"
|
||||
{
|
||||
trigger OnPreReport()
|
||||
var
|
||||
ProcessContext: Codeunit "Process Context Good Sample";
|
||||
begin
|
||||
if not ProcessContext.IsProcessRunning() then
|
||||
exit;
|
||||
// ... behaviour that applies only inside that process ...
|
||||
end;
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: events
|
||||
keywords: [bindsubscription, manual-binding, eventsubscriberinstance, internalevent, singleinstance, process-context, running-flag, scoped-state, rollback]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Expose process context through a manually bound flag, not a single-instance boolean
|
||||
|
||||
> Contributions welcome — open a PR to refine or extend this article.
|
||||
|
||||
## Description
|
||||
|
||||
When an extension drives a process over shared code — a base application report, a posting routine — other extensions hooked into that code cannot tell whether a run belongs to that process: AL keeps no ambient "current process", so the driving app has to publish the context itself. The reflex answer, a `SingleInstance` codeunit holding a boolean set at the start of the run and cleared at the end, is unsafe: single-instance variables are not part of the database transaction, so a failed run rolls back the writes but not the flag, which stays `true` until the company is closed and marks every later run in the session as part of the process. A manual event binding carries the same signal safely, because the platform ties its lifetime to a variable's scope instead of to cleanup code that has to run.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Publish the context as a query and let the binding itself be the state. One procedure is public; everything behind it is internal:
|
||||
|
||||
- A public context codeunit exposes `IsProcessRunning(): Boolean`, which raises an `[InternalEvent]` publisher taking a `var Boolean` and returns what comes back — the entire public surface. The publisher is internal because only the owning app subscribes, `local` because only this codeunit raises it.
|
||||
- A second codeunit, `Access = Internal` with `EventSubscriberInstance = Manual`, subscribes to that event and sets the boolean to `true`. Internal keeps it out of the API and stops other apps binding it to forge the context; it stores nothing between runs — being bound *is* the state.
|
||||
- The driving process calls `BindSubscription` on a variable whose scope is exactly the span it wants to claim: a local in the procedure that drives the run, or a global on an object that lives exactly as long as the run. While that variable is alive the query answers `true`; when it leaves scope — normally, or because an error unwound the call stack — the platform removes the binding and the query answers `false` again.
|
||||
|
||||
Bind a fresh instance per run rather than reusing one: the platform refuses to bind the same instance twice but accepts several instances of the same codeunit, so nesting and re-entrancy need no counter. The binding is session-scoped, so work the process starts in another session — a background session, a page background task, a job queue entry — cannot see it; pass the context explicitly there.
|
||||
|
||||
See sample: `expose-process-context-via-manually-bound-flag.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Two shapes.
|
||||
|
||||
First, the single-instance boolean — the failure described above. Detection: a `SingleInstance = true` codeunit with a boolean set before a process and cleared after it, read by other code to decide whether that process is running.
|
||||
|
||||
Second, the context kept private: the driving app arranges its own marker — typically a manually bound subscriber on an event added for its benefit alone — and offers no query, or only an `internal` one. Other extensions are left inferring the context from side effects, request-page values, or record state, which breaks silently the first time the process changes. Detection: a manual binding used purely as an internal run marker, with no public query procedure over it.
|
||||
|
||||
The mirror-image anti-pattern belongs to the reviewer: flagging the `BindSubscription` here as a leaked binding because no `UnbindSubscription` follows it. Scope release is the mechanism, not an omission — see `microsoft/knowledge/events/choose-static-vs-manual-subscribers-deliberately.md`, whose leak case is an instance parked on a `SingleInstance` global that never leaves scope.
|
||||
|
||||
See sample: `expose-process-context-via-manually-bound-flag.bad.al`.
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
page 50100 "CurrPage Update OAGR Bad"
|
||||
{
|
||||
PageType = List;
|
||||
SourceTable = Customer;
|
||||
ApplicationArea = All;
|
||||
|
||||
layout
|
||||
{
|
||||
area(content)
|
||||
{
|
||||
repeater(Rows)
|
||||
{
|
||||
field("No."; Rec."No.") { }
|
||||
field(Name; Rec.Name) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
trigger OnAfterGetRecord()
|
||||
begin
|
||||
// Update from OnAfterGetRecord re-enters the trigger on every row.
|
||||
CurrPage.Update(false);
|
||||
end;
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
page 50100 "CurrPage Update OAGR Good"
|
||||
{
|
||||
PageType = List;
|
||||
SourceTable = Customer;
|
||||
ApplicationArea = All;
|
||||
|
||||
layout
|
||||
{
|
||||
area(content)
|
||||
{
|
||||
repeater(Rows)
|
||||
{
|
||||
field("No."; Rec."No.") { }
|
||||
field(Warning; WarningText) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var
|
||||
WarningText: Text[50];
|
||||
|
||||
trigger OnAfterGetRecord()
|
||||
begin
|
||||
WarningText := CopyStr(Rec.Name, 1, MaxStrLen(WarningText));
|
||||
end;
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: performance
|
||||
keywords: [currpage-update, onaftergetrecord, list-page, scroll, refresh]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Do not call CurrPage.Update inside OnAfterGetRecord
|
||||
|
||||
> Contributions welcome — open a PR to refine or extend this article.
|
||||
|
||||
## Description
|
||||
|
||||
`OnAfterGetRecord` on a list already runs once per visible row on scroll and refresh. `CurrPage.Update` asks the page to reload, which fires those triggers again. The result is a refresh loop or a stutter on every row paint. Official developer performance guidance lists `CurrPage.Update()` in `OnAfterGetRecord` next to `Modify` as work that must not live there. Sibling of `do-not-modify-in-onaftergetrecord.md` (writes); this file is the client refresh half.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Put display-only results in page variables assigned in `OnAfterGetRecord` without calling `Update`. If the page must refresh after an action, call `CurrPage.Update(false)` from `OnAction` once, not per row.
|
||||
|
||||
See sample: `avoid-currpage-update-in-onaftergetrecord.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
`trigger OnAfterGetRecord() begin ... CurrPage.Update(); end;` on a list. The signal is `CurrPage.Update` inside `OnAfterGetRecord` or `OnAfterGetCurrRecord` without an explicit user action.
|
||||
|
||||
See sample: `avoid-currpage-update-in-onaftergetrecord.bad.al`.
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
codeunit 50100 "Batch NoSeries Insert Bad"
|
||||
{
|
||||
procedure InsertDraftOrders(var Customer: Record Customer)
|
||||
var
|
||||
SalesHeader: Record "Sales Header";
|
||||
SalesSetup: Record "Sales & Receivables Setup";
|
||||
NoSeries: Codeunit "No. Series";
|
||||
begin
|
||||
SalesSetup.Get();
|
||||
if Customer.FindSet() then
|
||||
repeat
|
||||
SalesHeader.Init();
|
||||
SalesHeader."Document Type" := SalesHeader."Document Type"::Order;
|
||||
// Per-row GetNextNo locks the number-series line every insert.
|
||||
SalesHeader."No." := NoSeries.GetNextNo(SalesSetup."Order Nos.", WorkDate());
|
||||
SalesHeader."Sell-to Customer No." := Customer."No.";
|
||||
SalesHeader.Insert(true);
|
||||
until Customer.Next() = 0;
|
||||
end;
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
codeunit 50100 "Batch NoSeries Insert Good"
|
||||
{
|
||||
procedure InsertDraftOrders(var Customer: Record Customer)
|
||||
var
|
||||
SalesHeader: Record "Sales Header";
|
||||
SalesSetup: Record "Sales & Receivables Setup";
|
||||
NoSeriesBatch: Codeunit "No. Series - Batch";
|
||||
begin
|
||||
SalesSetup.Get();
|
||||
if Customer.FindSet() then
|
||||
repeat
|
||||
SalesHeader.Init();
|
||||
SalesHeader."Document Type" := SalesHeader."Document Type"::Order;
|
||||
SalesHeader."No." := NoSeriesBatch.GetNextNo(SalesSetup."Order Nos.", WorkDate());
|
||||
SalesHeader."Sell-to Customer No." := Customer."No.";
|
||||
SalesHeader.Insert(true);
|
||||
until Customer.Next() = 0;
|
||||
NoSeriesBatch.SaveState();
|
||||
end;
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
---
|
||||
bc-version: [22..]
|
||||
domain: performance
|
||||
keywords: [no-series, getnextno, no-series-batch, savestate, numbersequence, lock]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Batch number-series calls instead of GetNextNo per insert
|
||||
|
||||
> Contributions welcome — open a PR to refine or extend this article.
|
||||
|
||||
## Description
|
||||
|
||||
`Codeunit "No. Series".GetNextNo` on a **gapless (Normal)** series updates and locks the number-series line on every call. A tight `Insert` loop that asks for a number per row serializes every concurrent writer on that series — the classic SaaS posting bottleneck. Training data still copies the per-row C/AL `NoSeriesManagement` shape. Series configured with **Allow Gaps** instead obtain numbers through `NumberSequence` and do not hold the series-line lock between calls, so they are not affected by this pattern. Codeunit `"No. Series - Batch"` issues gapless numbers in memory and writes the series line once via `SaveState`.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Inside a multi-row insert, call `"No. Series - Batch".GetNextNo` per row and `SaveState` once after the loop when the series must remain gapless. Use `NumberSequence.Next` when holes are allowed. Do not replace a single `OnInsert` `GetNextNo` for one master record; that path is not the hotspot.
|
||||
|
||||
See sample: `batch-number-series-instead-of-getnextno-per-row.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
`NoSeries.GetNextNo(...)` inside `repeat ... Insert ... until Next() = 0` where the series is **gapless** (Allow Gaps = false). Each iteration takes the series-line lock. The signal is `"No. Series"` (not `"No. Series - Batch"`) in a loop that inserts more than one row; do not flag the same pattern when the series has Allow Gaps enabled, as the `NumberSequence` path already avoids the lock.
|
||||
|
||||
See sample: `batch-number-series-instead-of-getnextno-per-row.bad.al`.
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
codeunit 50541 "Perf Sample NoShortCircuit Bad"
|
||||
{
|
||||
procedure ExceedsThreshold(var Thresholds: array[10] of Decimal; Index: Integer; Amount: Decimal): Boolean
|
||||
begin
|
||||
// Thresholds[Index] is evaluated even when Index is 0, so the leading range
|
||||
// check does not prevent the subscript from being read out of range.
|
||||
exit((Index >= 1) and (Index <= ArrayLen(Thresholds)) and (Amount > Thresholds[Index]));
|
||||
end;
|
||||
|
||||
procedure IsBlockedCustomer(CustomerNo: Code[20]): Boolean
|
||||
var
|
||||
Customer: Record Customer;
|
||||
begin
|
||||
// The Get runs even for an empty CustomerNo, and Blocked is read even when the
|
||||
// Get failed, so the result is taken from a record that was never loaded.
|
||||
exit((CustomerNo <> '') and Customer.Get(CustomerNo) and (Customer.Blocked <> Customer.Blocked::" "));
|
||||
end;
|
||||
|
||||
procedure IsEligibleForFreeShipping(SalesHeader: Record "Sales Header"): Boolean
|
||||
begin
|
||||
// HasActiveLoyaltyBenefit runs even when the amount alone already qualifies,
|
||||
// paying for the costly check on every evaluation instead of only the path
|
||||
// where it can still change the outcome.
|
||||
exit((SalesHeader."Amount Including VAT" >= 1000) or HasActiveLoyaltyBenefit(SalesHeader."Sell-to Customer No."));
|
||||
end;
|
||||
|
||||
local procedure HasActiveLoyaltyBenefit(CustomerNo: Code[20]): Boolean
|
||||
begin
|
||||
exit(CustomerNo <> '');
|
||||
end;
|
||||
}
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
codeunit 50540 "Perf Sample NoShortCircuit Good"
|
||||
{
|
||||
procedure ExceedsThreshold(var Thresholds: array[10] of Decimal; Index: Integer; Amount: Decimal): Boolean
|
||||
begin
|
||||
// 'and' is safe here: both operands are cheap and neither depends on the other.
|
||||
if (Index >= 1) and (Index <= ArrayLen(Thresholds)) then
|
||||
// The subscript lives in its own if, so it is never evaluated out of range.
|
||||
if Amount > Thresholds[Index] then
|
||||
exit(true);
|
||||
exit(false);
|
||||
end;
|
||||
|
||||
procedure IsBlockedCustomer(CustomerNo: Code[20]): Boolean
|
||||
var
|
||||
Customer: Record Customer;
|
||||
begin
|
||||
// The cheap test runs first, and the field is read only after Get succeeded.
|
||||
if CustomerNo = '' then
|
||||
exit(false);
|
||||
if not Customer.Get(CustomerNo) then
|
||||
exit(false);
|
||||
exit(Customer.Blocked <> Customer.Blocked::" ");
|
||||
end;
|
||||
|
||||
procedure IsEligibleForFreeShipping(SalesHeader: Record "Sales Header"): Boolean
|
||||
begin
|
||||
// 'or' is unsafe here: nesting would also be wrong, since it would drop the
|
||||
// case where the amount alone already qualifies. Exit as soon as the cheap
|
||||
// condition already decides the result; the costly lookup runs only on the
|
||||
// path where it can still change the outcome.
|
||||
if SalesHeader."Amount Including VAT" >= 1000 then
|
||||
exit(true);
|
||||
exit(HasActiveLoyaltyBenefit(SalesHeader."Sell-to Customer No."));
|
||||
end;
|
||||
|
||||
local procedure HasActiveLoyaltyBenefit(CustomerNo: Code[20]): Boolean
|
||||
begin
|
||||
// Stands in for a costly check — a webservice call or a large table scan.
|
||||
exit(CustomerNo <> '');
|
||||
end;
|
||||
}
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: performance
|
||||
keywords: [short-circuit, lazy-evaluation, boolean-operators, nested-if, guard, and-operator, or-operator, xor-operator, early-exit]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# AL boolean operators do not short-circuit
|
||||
|
||||
## Description
|
||||
|
||||
AL gives no short-circuit (lazy) evaluation guarantee for `and`, `or`, and `xor`: every operand of a boolean expression is evaluated, even when the leftmost operand already determines the result. Neither the AL operators documentation nor the boolean operators documentation defines a lazy evaluation order, so code must not depend on one. Developers arriving from C#, JavaScript, or SQL routinely assume the left operand guards the right; in AL it does not. `xor` is not actually a short-circuit candidate in any language — its result depends on both operands regardless of their values, so there is nothing to skip — but AL still evaluates both operands unconditionally, so neither should carry a cost or a risk the developer assumed the other would guard against. For `and` and `or`, the right operand still runs even when the left already decides the result, so its cost is paid on every evaluation, and a check intended to protect an unsafe expression — an array subscript, a division, a field read that is only valid after a successful `Get` — does not protect it.
|
||||
|
||||
## Best Practice
|
||||
|
||||
For an `and`-shaped guard — a condition that must hold before the next operand is safe or worth evaluating — split into nested `if` statements: the guarding or cheapest condition in the outer `if`, the dependent or expensive one in the inner `if`. This preserves the result, since `if A then if B then Action` matches `if A and B then Action` exactly. Where there is no `else` branch, nesting is a pure win; where there is one, extract the conditions into a helper procedure that exits early instead.
|
||||
|
||||
For an `or`-shaped condition, do not nest: nesting `if A then if B then Action` drops the case where `A` is true and `B` is false, silently changing the result of `A or B`. Exit as soon as the cheap or safe operand already decides the outcome, and reach the other operand only on the path where it can still change the result — `if A then exit(true); exit(B);` for a boolean return, or `if A then Action else if B then Action;` when both branches share one action.
|
||||
|
||||
`xor` has no equivalent rewrite, because its result always depends on both operands; the only actionable guidance is to keep both operands of an `xor` cheap and free of side effects, since AL evaluates both unconditionally.
|
||||
|
||||
Where a chain of `and`-guards runs past about three conditions, stop nesting and use a `case` statement instead — see `case-true-of-for-long-condition-chains.md`. Keep `and` and `or` for operands that are independently safe and cheap — in-memory field comparisons, enum tests, bound checks — where combining them reads better and costs nothing.
|
||||
|
||||
See sample: `boolean-operators-do-not-short-circuit.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
A single condition that joins a guard with an operand depending on that guard, or with an expensive operand, using `and` or `or`. The consequence is either wasted work on every evaluation — a database call or validation procedure invoked even when the outcome is already decided — or a runtime error or silently wrong result that the guard was written to prevent. Applying the `and` fix to an `or` condition is a distinct mistake: rewriting `A or B` as nested `if`s drops the `A`-true/`B`-false case instead of preserving it. Detection signals: an operand that indexes an array or list with a variable whose bounds are checked in a sibling operand; `Record.Get(...)` or a `Find`/`IsEmpty` call as one operand of `and` with a field read of the same record as another; an expensive or unsafe operand combined with `or` next to a condition that alone already makes the result true; a boolean-returning procedure call combined with a cheap field test. The pattern is common in code ported from a language that does short-circuit, and in conditions grown by appending a clause to an existing `if`.
|
||||
|
||||
See sample: `boolean-operators-do-not-short-circuit.bad.al`.
|
||||
|
||||
## See also
|
||||
|
||||
`case-true-of-for-long-condition-chains.md` covers what to do when nesting an `and`-guard chain would go more than about three levels deep. `microsoft/knowledge/performance/apply-guards-before-get.md` covers the related ordering rule for statements rather than operands.
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
codeunit 50543 "Perf Sample CaseChain Bad"
|
||||
{
|
||||
procedure IsShippableLine(SalesLine: Record "Sales Line"): Boolean
|
||||
var
|
||||
Item: Record Item;
|
||||
begin
|
||||
// Five levels of nesting to sequence five guards. The evaluation order is
|
||||
// carried by indentation alone and the body drifts steadily right.
|
||||
if SalesLine.Type = SalesLine.Type::Item then
|
||||
if SalesLine."No." <> '' then
|
||||
if SalesLine."Qty. to Ship" > 0 then
|
||||
if Item.Get(SalesLine."No.") then
|
||||
if not Item.Blocked then
|
||||
exit(true);
|
||||
exit(false);
|
||||
end;
|
||||
|
||||
procedure IsShippableLineCollapsed(SalesLine: Record "Sales Line"): Boolean
|
||||
var
|
||||
Item: Record Item;
|
||||
begin
|
||||
// The wrong escape from the ladder: flattening it into 'and' trades the
|
||||
// nesting for a defect, because every operand is still evaluated. Item
|
||||
// fields are read even when the Get failed. The parentheses are not
|
||||
// optional either — 'and' binds tighter than '=' and '<>' in AL.
|
||||
exit((SalesLine.Type = SalesLine.Type::Item) and (SalesLine."No." <> '') and
|
||||
(SalesLine."Qty. to Ship" > 0) and Item.Get(SalesLine."No.") and not Item.Blocked);
|
||||
end;
|
||||
}
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
codeunit 50542 "Perf Sample CaseChain Good"
|
||||
{
|
||||
procedure IsShippableLine(SalesLine: Record "Sales Line"): Boolean
|
||||
var
|
||||
Item: Record Item;
|
||||
begin
|
||||
// 'case false of' matches value sets in order and stops at the first match.
|
||||
// The first three checks are pure and order-independent, so they share one
|
||||
// value set. Get and Blocked are each their own value set, in order, because
|
||||
// the ordering the documentation guarantees is across value sets, not within
|
||||
// one — Item.Get must run, and succeed, before Blocked is read.
|
||||
case false of
|
||||
SalesLine.Type = SalesLine.Type::Item,
|
||||
SalesLine."No." <> '',
|
||||
SalesLine."Qty. to Ship" > 0:
|
||||
exit(false);
|
||||
Item.Get(SalesLine."No."):
|
||||
exit(false);
|
||||
not Item.Blocked:
|
||||
exit(false);
|
||||
end;
|
||||
exit(true);
|
||||
end;
|
||||
|
||||
procedure FindOpenDocumentType(CustomerNo: Code[20]): Text
|
||||
begin
|
||||
// 'case true of' stops at the first condition that holds, so the later
|
||||
// lookups never run once an earlier one matched.
|
||||
case true of
|
||||
HasOpenDocument(CustomerNo, "Sales Document Type"::Quote):
|
||||
exit('Quote');
|
||||
HasOpenDocument(CustomerNo, "Sales Document Type"::Order):
|
||||
exit('Order');
|
||||
HasOpenDocument(CustomerNo, "Sales Document Type"::Invoice):
|
||||
exit('Invoice');
|
||||
end;
|
||||
exit('None');
|
||||
end;
|
||||
|
||||
local procedure HasOpenDocument(CustomerNo: Code[20]; DocumentType: Enum "Sales Document Type"): Boolean
|
||||
var
|
||||
SalesHeader: Record "Sales Header";
|
||||
begin
|
||||
SalesHeader.SetRange("Document Type", DocumentType);
|
||||
SalesHeader.SetRange("Sell-to Customer No.", CustomerNo);
|
||||
exit(not SalesHeader.IsEmpty());
|
||||
end;
|
||||
}
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: performance
|
||||
keywords: [case-statement, case-true-of, nested-if, condition-chain, guard, lazy-evaluation, nesting-depth]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Use case true of for long chains of dependent conditions
|
||||
|
||||
## Description
|
||||
|
||||
Because AL gives no short-circuit guarantee for `and` and `or`, a chain of conditions that must be evaluated in order has to be sequenced with nested `if` statements — and past three conditions the nesting itself becomes the problem: the body drifts right, the order of evaluation is carried by indentation alone, and any shared failure path is repeated at every level. AL's `case` statement is the flat alternative. Its value sets "must be an expression or a range", so `case true of` and `case false of` accept arbitrary boolean expressions, and the statement "is evaluated, and the first matching value set executes the associated statement" — evaluation stops at the first matching value set, which is exactly the laziness the boolean operators do not provide. That guarantee is stated for value sets, plural: it orders evaluation *across* separate value sets, and says nothing about the order of the individual expressions listed inside one comma-separated value set.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Sequence two or three dependent conditions with nested `if`. Beyond that, switch to `case`: use `case false of` for a chain of guards where every condition must hold, letting control fall past `end` when all of them pass; use `case true of` for first-match dispatch, where each later probe runs only if the earlier ones did not match. Comma-separate conditions into one value set only when every one of them is a pure, order-independent test with no side effect — a field comparison, an enum check, a bound test — so it makes no difference whether AL evaluates all of them or stops early; grouping these costs nothing and removes the repeated action. A condition that guards another, or that carries a side effect or a cost of its own — a `Get`, a `Find`, a procedure call — keeps its own value set, placed immediately after the value set it depends on, so the code relies only on the ordering the documentation actually states. A value set needs no parentheses around a comparison, unlike an operand of `and` or `or`: the AL operator hierarchy places `and` and `or` above the comparison operators, so parentheses are mandatory there and the chain fills up with them. This keeps every condition at one indentation level, makes evaluation order explicit rather than implied by nesting, and preserves the stop-at-first-match behaviour it relies on. It also aligns with the AL programming convention that more than two alternatives belong in a `case` statement rather than an `if-then-else`.
|
||||
|
||||
See sample: `case-true-of-for-long-condition-chains.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
An `if` ladder four or more levels deep whose only purpose is sequencing guards. Detection: a chain of nested `if` statements with no `else`, each condition guarding the one below it, terminating in a single action or `exit`; or the same `exit`/`error` duplicated at every level of such a nested chain, purely to escape it. The second, worse form is collapsing that ladder into one `and` chain to escape the nesting — that trades indentation for a real defect, because the operands are still all evaluated. A third, subtler form is over-applying the comma-grouping itself: putting a guard and the condition it protects — for example `Item.Get(...)` and a read of a field on that same record — into one comma-separated value set. That relies on an evaluation order within a single value set that the documentation does not state; keep them in separate value sets instead. Reach for `case` over nested `if` or a collapsed `and` chain, and keep order-dependent conditions in their own value sets within it.
|
||||
|
||||
See sample: `case-true-of-for-long-condition-chains.bad.al`.
|
||||
|
||||
## See also
|
||||
|
||||
`boolean-operators-do-not-short-circuit.md` covers the underlying evaluation rule that makes the sequencing necessary in the first place.
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
codeunit 50100 "ChangeCompany Loop Bad"
|
||||
{
|
||||
procedure NamesForCustomers(var Buffer: Record Customer)
|
||||
var
|
||||
Customer: Record Customer;
|
||||
Company: Record Company;
|
||||
begin
|
||||
Customer.SetLoadFields(Name);
|
||||
if Buffer.FindSet() then
|
||||
repeat
|
||||
if Company.FindSet() then
|
||||
repeat
|
||||
// ChangeCompany per customer per company resets caches every row.
|
||||
Customer.ChangeCompany(Company.Name);
|
||||
if Customer.Get(Buffer."No.") then
|
||||
Message(Customer.Name);
|
||||
until Company.Next() = 0;
|
||||
until Buffer.Next() = 0;
|
||||
end;
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
codeunit 50100 "ChangeCompany Loop Good"
|
||||
{
|
||||
procedure NamesForCustomers(var Buffer: Record Customer)
|
||||
var
|
||||
Customer: Record Customer;
|
||||
Company: Record Company;
|
||||
begin
|
||||
Customer.SetLoadFields(Name);
|
||||
if Company.FindSet() then
|
||||
repeat
|
||||
Customer.ChangeCompany(Company.Name);
|
||||
if Buffer.FindSet() then
|
||||
repeat
|
||||
if Customer.Get(Buffer."No.") then
|
||||
Message(Customer.Name);
|
||||
until Buffer.Next() = 0;
|
||||
until Company.Next() = 0;
|
||||
end;
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: performance
|
||||
keywords: [changecompany, loop, cache, multi-company, isolation]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Do not call ChangeCompany inside a per-row loop
|
||||
|
||||
> Contributions welcome — open a PR to refine or extend this article.
|
||||
|
||||
## Description
|
||||
|
||||
`ChangeCompany` retargets a record variable to another company's data and drops the in-memory caches bound to the previous company. Calling it once per row in a multi-company scan therefore pays a cache reset on every iteration, even when consecutive rows share a company. Agents treat `ChangeCompany` like a filter. It is an isolation switch.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Group work by company. Call `ChangeCompany` once per distinct company, then `FindSet`/`Get` that company's rows. If the record variable is reused afterward, call `ChangeCompany()` without a company name to redirect it back to the current company.
|
||||
|
||||
See sample: `changecompany-in-loop-drops-caches.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
`repeat Rec.ChangeCompany(Buffer.Company); Rec.Get(Buffer."No."); until Buffer.Next() = 0` when `Buffer` is not ordered by company, or even when it is — if `ChangeCompany` still runs every row. The signal is `ChangeCompany` inside `repeat`/`while` keyed by a document line rather than by a company loop.
|
||||
|
||||
See sample: `changecompany-in-loop-drops-caches.bad.al`.
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
report 50100 "Cust List ReadOnly Bad"
|
||||
{
|
||||
UsageCategory = ReportsAndAnalysis;
|
||||
ApplicationArea = All;
|
||||
// Missing DataAccessIntent = ReadOnly; the scan hits the primary replica.
|
||||
|
||||
dataset
|
||||
{
|
||||
dataitem(Customer; Customer)
|
||||
{
|
||||
column(No; "No.") { }
|
||||
column(Name; Name) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
report 50100 "Cust List ReadOnly Good"
|
||||
{
|
||||
UsageCategory = ReportsAndAnalysis;
|
||||
ApplicationArea = All;
|
||||
DataAccessIntent = ReadOnly;
|
||||
|
||||
dataset
|
||||
{
|
||||
dataitem(Customer; Customer)
|
||||
{
|
||||
column(No; "No.") { }
|
||||
column(Name; Name) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
---
|
||||
bc-version: ["16.."]
|
||||
domain: performance
|
||||
keywords: [dataaccessintent, read-only, read-scale-out, report, api-page, query]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Set DataAccessIntent ReadOnly on analytical objects
|
||||
|
||||
> Contributions welcome — open a PR to refine or extend this article.
|
||||
|
||||
## Description
|
||||
|
||||
`DataAccessIntent` was introduced at runtime 5.0 (BC 16) and has no effect in earlier versions. Reports, API pages (`PageType = API` with `Editable = false`), and queries that only read can run against a read replica when `DataAccessIntent = ReadOnly`. For queries, replica routing only applies when the query is exposed via OData/API; running a query in AL code is unaffected. Without the property these objects hit the primary replica and compete with posting. Agents omit it because the default is read-write and the object "only reads" in AL. The replica routing is a metadata switch, not something the compiler infers from the absence of `Modify`.
|
||||
|
||||
## Best Practice
|
||||
|
||||
On report objects and `PageType = API` pages with `Editable = false` that never write, set `DataAccessIntent = ReadOnly`. For query objects, set it when the query is consumed via OData or an API endpoint. Keep the default on objects that insert, modify, or call a write codeunit from a processing-only report.
|
||||
|
||||
See sample: `dataaccessintent-readonly-on-analytical-objects.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
A listing report or API query with no `DataAccessIntent` that scans G/L or sales lines. The object is read-only in practice and still loads the primary.
|
||||
|
||||
See sample: `dataaccessintent-readonly-on-analytical-objects.bad.al`.
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
page 50100 "GuiAllowed OData Guard Bad"
|
||||
{
|
||||
PageType = List;
|
||||
SourceTable = Customer;
|
||||
ApplicationArea = All;
|
||||
|
||||
layout
|
||||
{
|
||||
area(content)
|
||||
{
|
||||
repeater(Rows)
|
||||
{
|
||||
field("No."; Rec."No.") { }
|
||||
field(Name; Rec.Name)
|
||||
{
|
||||
StyleExpr = NameStyle;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var
|
||||
NameStyle: Text;
|
||||
|
||||
trigger OnAfterGetRecord()
|
||||
begin
|
||||
// UI-only styling still runs for every OData / Edit-in-Excel row.
|
||||
Rec.CalcFields("Balance (LCY)");
|
||||
if Rec."Balance (LCY)" > 0 then
|
||||
NameStyle := 'Attention'
|
||||
else
|
||||
NameStyle := 'Standard';
|
||||
end;
|
||||
}
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
page 50100 "GuiAllowed OData Guard Good"
|
||||
{
|
||||
PageType = List;
|
||||
SourceTable = Customer;
|
||||
ApplicationArea = All;
|
||||
|
||||
layout
|
||||
{
|
||||
area(content)
|
||||
{
|
||||
repeater(Rows)
|
||||
{
|
||||
field("No."; Rec."No.") { }
|
||||
field(Name; Rec.Name)
|
||||
{
|
||||
StyleExpr = NameStyle;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var
|
||||
NameStyle: Text;
|
||||
|
||||
trigger OnAfterGetRecord()
|
||||
begin
|
||||
if not GuiAllowed then
|
||||
exit;
|
||||
Rec.CalcFields("Balance (LCY)");
|
||||
if Rec."Balance (LCY)" > 0 then
|
||||
NameStyle := 'Attention'
|
||||
else
|
||||
NameStyle := 'Standard';
|
||||
end;
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: performance
|
||||
keywords: [guiallowed, clienttype, odata, edit-in-excel, page-trigger, factbox]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Guard page trigger work with GuiAllowed for OData and Excel
|
||||
|
||||
> Contributions welcome — open a PR to refine or extend this article.
|
||||
|
||||
## Description
|
||||
|
||||
Pages exposed as OData, including Edit in Excel, still run AL page triggers for every row returned. FactBox updates, defaulting, and extra `CalcFields` in `OnAfterGetRecord` therefore run on the web-service path where no UI exists. `GuiAllowed` is false for those sessions. Agents add page logic as if only the browser client will execute it.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Wrap UI-only work — FactBox refresh, notifications, defaulting that is not part of the web-service contract — in `if GuiAllowed then`. Keep the OData path to field values the API actually returns.
|
||||
|
||||
See sample: `guiallowed-guard-on-pages-used-as-odata.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Unconditional FactBox or calculation logic in `OnAfterGetRecord` / `OnAfterGetCurrRecord` on a page that is published as a web service or used with Edit in Excel. The signal is trigger work that calls `CurrPage` parts or extra queries without a `GuiAllowed` guard.
|
||||
|
||||
See sample: `guiallowed-guard-on-pages-used-as-odata.bad.al`.
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
codeunit 50100 "HttpClient Holds Locks Bad"
|
||||
{
|
||||
procedure SyncCustomerLastName(var Customer: Record Customer)
|
||||
var
|
||||
Client: HttpClient;
|
||||
Response: HttpResponseMessage;
|
||||
begin
|
||||
Customer."Search Name" := Customer.Name;
|
||||
Customer.Modify(false);
|
||||
// Locks from Modify are held for the entire HTTP wait.
|
||||
Client.Get(StrSubstNo('https://example.local/sync/%1', Customer."No."), Response);
|
||||
end;
|
||||
}
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
codeunit 50100 "HttpClient Holds Locks Good"
|
||||
{
|
||||
procedure SyncCustomerLastName(var Customer: Record Customer)
|
||||
var
|
||||
CustomerSyncOutbox: Record "Customer Sync Outbox";
|
||||
begin
|
||||
Customer."Search Name" := Customer.Name;
|
||||
Customer.Modify(false);
|
||||
|
||||
// This work item commits or rolls back with the customer change.
|
||||
CustomerSyncOutbox."Customer No." := Customer."No.";
|
||||
CustomerSyncOutbox.Insert();
|
||||
end;
|
||||
}
|
||||
|
||||
table 50100 "Customer Sync Outbox"
|
||||
{
|
||||
DataClassification = CustomerContent;
|
||||
|
||||
fields
|
||||
{
|
||||
field(1; "Entry No."; Integer)
|
||||
{
|
||||
AutoIncrement = true;
|
||||
}
|
||||
field(2; "Customer No."; Code[20]) { }
|
||||
}
|
||||
|
||||
keys
|
||||
{
|
||||
key(PK; "Entry No.")
|
||||
{
|
||||
Clustered = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
codeunit 50101 "Customer Sync Outbox Worker"
|
||||
{
|
||||
// Configure this codeunit as a recurring job queue entry.
|
||||
TableNo = "Job Queue Entry";
|
||||
|
||||
trigger OnRun()
|
||||
var
|
||||
Customer: Record Customer;
|
||||
CustomerSyncOutbox: Record "Customer Sync Outbox";
|
||||
Client: HttpClient;
|
||||
Response: HttpResponseMessage;
|
||||
begin
|
||||
// Only committed work is visible here; a rolled-back change leaves no outbox row.
|
||||
if not CustomerSyncOutbox.FindFirst() then
|
||||
exit;
|
||||
|
||||
Customer.Get(CustomerSyncOutbox."Customer No.");
|
||||
Client.Get(StrSubstNo('https://example.local/sync/%1', Customer."No."), Response);
|
||||
if not Response.IsSuccessStatusCode() then
|
||||
Error('Customer sync failed with HTTP status %1.', Response.HttpStatusCode());
|
||||
|
||||
// Delete only after HTTP completes, so no write lock is held during the call.
|
||||
CustomerSyncOutbox.Delete();
|
||||
end;
|
||||
}
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: performance
|
||||
keywords: [httpclient, write-transaction, lock, commit, outbound-http, session-block]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Do not call HttpClient inside an open write transaction
|
||||
|
||||
> Contributions welcome — open a PR to refine or extend this article.
|
||||
|
||||
## Description
|
||||
|
||||
The first database write opens an AL write transaction that the runtime holds until the execution completes or `Commit()` runs — see `understand-implicit-transaction-boundary.md`. `HttpClient` blocks the session until the remote call returns. Any locks taken by earlier `Insert`/`Modify`/`Delete` therefore stay held for the HTTP wall-clock time, and interactive users see a spinner. This is not generic "don't block": it is the AL transaction model plus lock lifetime around outbound I/O.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Defer the HTTP call to a separate session. When the external operation must correspond to a committed database change, insert an outbox work item in the same transaction as that change and process committed outbox rows with a recurring job queue entry. The change and work item then commit or roll back together, and the worker performs HTTP before deleting the item so it holds no write lock during the call. Make the external operation idempotent because a failure after a successful HTTP response can cause the work item to be retried.
|
||||
|
||||
A directly created scheduled task is suitable only when its work is independent of the caller's commit. An immediately ready task can run concurrently with the caller, so it must not assume that the caller's writes are already committed. Do **not** use `Commit()` as a general remedy: it irrevocably commits all prior writes in the current transaction, so any subsequent failure cannot roll them back. `Commit()` is appropriate only at top-level entry points where partial persistence is intentional and understood.
|
||||
|
||||
See sample: `httpclient-inside-write-transaction-holds-locks.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
`Modify`/`Insert` followed by `HttpClient` in the same procedure with no `Commit` between them. Detection signal: any `HttpClient` use after a write on the same execution path, especially in posting, page actions, or subscribers.
|
||||
|
||||
See sample: `httpclient-inside-write-transaction-holds-locks.bad.al`.
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
codeunit 50100 "IsEmpty Before FindSet Bad"
|
||||
{
|
||||
procedure ListUsCustomerNames()
|
||||
var
|
||||
Customer: Record Customer;
|
||||
begin
|
||||
Customer.SetLoadFields(Name);
|
||||
Customer.SetRange("Country/Region Code", 'US');
|
||||
// IsEmpty does not replace FindSet; it adds a second round-trip.
|
||||
if not Customer.IsEmpty() then
|
||||
if Customer.FindSet() then
|
||||
repeat
|
||||
Message(Customer.Name);
|
||||
until Customer.Next() = 0;
|
||||
end;
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
codeunit 50100 "IsEmpty Before FindSet Good"
|
||||
{
|
||||
procedure ListUsCustomerNames()
|
||||
var
|
||||
Customer: Record Customer;
|
||||
begin
|
||||
Customer.SetLoadFields(Name);
|
||||
Customer.SetRange("Country/Region Code", 'US');
|
||||
if Customer.FindSet() then
|
||||
repeat
|
||||
Message(Customer.Name);
|
||||
until Customer.Next() = 0;
|
||||
end;
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: performance
|
||||
keywords: [isempty, findset, extra-round-trip, existence-check, false-positive]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# IsEmpty immediately before FindSet is an extra round-trip
|
||||
|
||||
> Contributions welcome — open a PR to refine or extend this article.
|
||||
|
||||
## Description
|
||||
|
||||
`IsEmpty` is the right API when the caller only needs existence — see `microsoft/knowledge/performance/use-isempty-for-existence-check.md`. It is not a cheap guard in front of a loop that will `FindSet` anyway. Both calls hit the database; `FindSet` already returns false when the filter matches nothing. Agents and reviewers often insert `if not Rec.IsEmpty() then` "for performance" and pay a second query for a result the iterator already provides.
|
||||
|
||||
## Best Practice
|
||||
|
||||
When the body iterates, open with `if Rec.FindSet() then repeat ... until Next() = 0`. Do not flag a bare `FindSet` loop as missing an `IsEmpty` precondition. Reserve `IsEmpty` for branches that never materialize the row set.
|
||||
|
||||
See sample: `isempty-before-findset-is-extra-round-trip.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
`if not Rec.IsEmpty() then if Rec.FindSet() then repeat`. Also a false-positive review comment that asks to add that guard. The second read does not avoid the first; it duplicates it.
|
||||
|
||||
See sample: `isempty-before-findset-is-extra-round-trip.bad.al`.
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
codeunit 50100 "Login Subscriber IO Bad"
|
||||
{
|
||||
[EventSubscriber(ObjectType::Codeunit, Codeunit::"System Initialization", OnAfterLogin, '', false, false)]
|
||||
local procedure OnAfterLogin()
|
||||
var
|
||||
Client: HttpClient;
|
||||
Response: HttpResponseMessage;
|
||||
GLEntry: Record "G/L Entry";
|
||||
begin
|
||||
// Blocks UI, API, and job-queue session creation until HTTP and SQL finish.
|
||||
Client.Get('https://example.local/warmup', Response);
|
||||
GLEntry.SetLoadFields("Entry No.");
|
||||
if GLEntry.FindLast() then;
|
||||
end;
|
||||
}
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
codeunit 50100 "Login Subscriber IO Good"
|
||||
{
|
||||
[EventSubscriber(ObjectType::Codeunit, Codeunit::"System Initialization", OnAfterLogin, '', false, false)]
|
||||
local procedure OnAfterLogin()
|
||||
var
|
||||
TaskId: Guid;
|
||||
StoredId: Text;
|
||||
begin
|
||||
// Guard to interactive sessions only; background task sessions also raise OnAfterLogin.
|
||||
if not (Session.CurrentClientType() in [ClientType::Web, ClientType::Windows, ClientType::Desktop, ClientType::Tablet, ClientType::Phone]) then
|
||||
exit;
|
||||
|
||||
// Idempotent: TaskExists requires the GUID returned by CreateTask, stored across logins.
|
||||
if IsolatedStorage.Get('LoginSyncTaskId', DataScope::Company, StoredId) then
|
||||
if Evaluate(TaskId, StoredId) then
|
||||
if TaskScheduler.TaskExists(TaskId) then
|
||||
exit;
|
||||
|
||||
TaskId := TaskScheduler.CreateTask(Codeunit::"Login Subscriber IO Work", 0, true, CompanyName(), CurrentDateTime() + 60000);
|
||||
IsolatedStorage.Set('LoginSyncTaskId', Format(TaskId), DataScope::Company);
|
||||
end;
|
||||
}
|
||||
|
||||
codeunit 50101 "Login Subscriber IO Work"
|
||||
{
|
||||
trigger OnRun()
|
||||
begin
|
||||
// Isolated from session creation: outbound I/O is safe here.
|
||||
end;
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: performance
|
||||
keywords: [oncompanyopen, onafterlogin, session-start, httpclient, subscriber, login]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Session-open subscribers must not do I/O
|
||||
|
||||
> Contributions welcome — open a PR to refine or extend this article.
|
||||
|
||||
## Description
|
||||
|
||||
`OnCompanyOpen`, `OnCompanyOpenCompleted`, and `System Initialization`.OnAfterLogin run while the session is being created. The platform waits until every subscriber returns before the UI, an API call, or a background session can proceed. `HttpClient` or a heavy `FindSet` here delays **every** session type, not just the user who "opened the company". Agents still put warmup sync, license checks, and HTTP probes on these events because they look like an application startup hook.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Keep company-open subscribers to cheap in-memory work: set a flag, enqueue a job-queue entry, or `TaskScheduler.CreateTask`. Perform HTTP and large SQL after the session is running, in that background work.
|
||||
|
||||
See sample: `oncompanyopen-subscribers-must-not-do-io.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
An `OnAfterLogin` / `OnCompanyOpenCompleted` subscriber that calls `HttpClient` or scans a ledger. Detection signal: `HttpClient`, `FindSet`, or `CalcFields` inside a subscriber bound to those events.
|
||||
|
||||
See sample: `oncompanyopen-subscribers-must-not-do-io.bad.al`.
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
page 50100 "Cue Background Task Bad"
|
||||
{
|
||||
PageType = CardPart;
|
||||
ApplicationArea = All;
|
||||
|
||||
layout
|
||||
{
|
||||
area(content)
|
||||
{
|
||||
cuegroup(Group)
|
||||
{
|
||||
field(OpenOrders; OpenOrderCount)
|
||||
{
|
||||
Caption = 'Open Sales Orders';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var
|
||||
OpenOrderCount: Integer;
|
||||
|
||||
trigger OnOpenPage()
|
||||
var
|
||||
SalesHeader: Record "Sales Header";
|
||||
begin
|
||||
// Blocks Role Center render on an exact count of sales headers.
|
||||
SalesHeader.SetRange("Document Type", SalesHeader."Document Type"::Order);
|
||||
OpenOrderCount := SalesHeader.Count();
|
||||
end;
|
||||
}
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
page 50100 "Cue Background Task Good"
|
||||
{
|
||||
PageType = CardPart;
|
||||
ApplicationArea = All;
|
||||
|
||||
layout
|
||||
{
|
||||
area(content)
|
||||
{
|
||||
cuegroup(Group)
|
||||
{
|
||||
field(OpenOrders; OpenOrderCount)
|
||||
{
|
||||
Caption = 'Open Sales Orders';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var
|
||||
OpenOrderCount: Integer;
|
||||
TaskId: Integer;
|
||||
|
||||
trigger OnAfterGetCurrRecord()
|
||||
var
|
||||
Args: Dictionary of [Text, Text];
|
||||
begin
|
||||
CurrPage.EnqueueBackgroundTask(TaskId, Codeunit::"Cue Open Order Count", Args);
|
||||
end;
|
||||
|
||||
trigger OnPageBackgroundTaskCompleted(CompletedTaskId: Integer; Results: Dictionary of [Text, Text])
|
||||
begin
|
||||
if Results.ContainsKey('Count') then
|
||||
Evaluate(OpenOrderCount, Results.Get('Count'));
|
||||
end;
|
||||
}
|
||||
|
||||
codeunit 50100 "Cue Open Order Count"
|
||||
{
|
||||
trigger OnRun()
|
||||
var
|
||||
SalesHeader: Record "Sales Header";
|
||||
Results: Dictionary of [Text, Text];
|
||||
begin
|
||||
SalesHeader.SetRange("Document Type", SalesHeader."Document Type"::Order);
|
||||
Results.Add('Count', Format(SalesHeader.CountApprox()));
|
||||
Page.SetBackgroundTaskResult(Results);
|
||||
end;
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
---
|
||||
bc-version: [15..]
|
||||
domain: performance
|
||||
keywords: [page-background-task, cue, rolecenter, enqueuebackgroundtask, ui-thread]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Calculate expensive cues on a page background task
|
||||
|
||||
> Contributions welcome — open a PR to refine or extend this article.
|
||||
|
||||
## Description
|
||||
|
||||
Role-center cues and CardPart totals that run `CalcFields`, scans, or HTTP on the UI thread freeze the shell until they finish. Page background tasks exist to return the page immediately and fill the number later. Enqueue mechanics, cancellation, and the read-only child session are covered in `microsoft/knowledge/ui/page-background-tasks.md`. This file is the performance trigger: a cue whose value is not needed to *open* the page must not run on the render path.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Bind the cue to a page variable, enqueue a read-only calculation from `OnAfterGetCurrRecord` (not `OnAfterGetRecord` on a list), and apply the result in `OnPageBackgroundTaskCompleted`. Show a placeholder until then.
|
||||
|
||||
See sample: `page-background-tasks-for-expensive-cues.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
`CalcFields` or a ledger `Count` in `OnOpenPage` / `OnAfterGetCurrRecord` of a CueGroup CardPart with no background task. The Role Center waits on SQL the user may never look at.
|
||||
|
||||
See sample: `page-background-tasks-for-expensive-cues.bad.al`.
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
codeunit 50100 "Pass Var Enumerator Bad"
|
||||
{
|
||||
procedure ListUsCustomerCities()
|
||||
var
|
||||
Customer: Record Customer;
|
||||
begin
|
||||
Customer.SetLoadFields(Name);
|
||||
Customer.SetRange("Country/Region Code", 'US');
|
||||
if Customer.FindSet() then
|
||||
repeat
|
||||
// By-value copy: JIT on City does not update the enumerator.
|
||||
Message(Customer.Name + ' ' + CityOf(Customer));
|
||||
until Customer.Next() = 0;
|
||||
end;
|
||||
|
||||
local procedure CityOf(Customer: Record Customer): Text
|
||||
begin
|
||||
exit(Customer.City);
|
||||
end;
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
codeunit 50100 "Pass Var Enumerator Good"
|
||||
{
|
||||
procedure ListUsCustomerCities()
|
||||
var
|
||||
Customer: Record Customer;
|
||||
begin
|
||||
Customer.SetLoadFields(Name);
|
||||
Customer.SetRange("Country/Region Code", 'US');
|
||||
if Customer.FindSet() then
|
||||
repeat
|
||||
EnsureCityLoaded(Customer);
|
||||
Message(Customer.Name + ' ' + Customer.City);
|
||||
until Customer.Next() = 0;
|
||||
end;
|
||||
|
||||
local procedure EnsureCityLoaded(var Customer: Record Customer)
|
||||
begin
|
||||
if not Customer.AreFieldsLoaded(Customer.City) then
|
||||
Customer.LoadFields(Customer.City);
|
||||
end;
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: performance
|
||||
keywords: [setloadfields, jit-load, enumerator, var-parameter, pass-by-value, next]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Pass the iterated record var so a JIT load updates the enumerator
|
||||
|
||||
> Contributions welcome — open a PR to refine or extend this article.
|
||||
|
||||
## Description
|
||||
|
||||
A `FindSet`/`Next` loop builds an enumerator from the fields selected for load. Accessing an unloaded field triggers a JIT load. When the record is passed **by value**, the copy does not share that enumerator: the JIT loads the copy and leaves the enumerator unchanged, so **every later `Next()` JIT-loads again**. Passing `var` lets the first JIT update the enumerator. `AddLoadFields` on the original record before a by-value call is the other fix. This is independent of whether `SetLoadFields` was ordered before filters.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Helpers that read extra fields on an in-flight iterator must take the record as `var`, or the caller must `AddLoadFields` those fields before the loop. Prefer declaring the extra fields up front so no JIT is needed.
|
||||
|
||||
See sample: `pass-var-record-to-preserve-partial-load-enumerator.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
A `SetLoadFields` loop that passes the iterator by value into a helper which then reads a field that was not loaded. The first row pays one JIT; every subsequent row pays it again because the enumerator never learned the extra field.
|
||||
|
||||
See sample: `pass-var-record-to-preserve-partial-load-enumerator.bad.al`.
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
tableextension 50100 "G/L Entry Extra Ext" extends "G/L Entry"
|
||||
{
|
||||
fields
|
||||
{
|
||||
// Stored companion columns are joined on every G/L Entry read.
|
||||
field(50100; "External Reference"; Text[50]) { }
|
||||
field(50101; "Integration Payload"; Blob) { }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
table 50100 "G/L Entry Extra"
|
||||
{
|
||||
Caption = 'G/L Entry Extra';
|
||||
DataClassification = CustomerContent;
|
||||
|
||||
fields
|
||||
{
|
||||
field(1; "Entry No."; Integer)
|
||||
{
|
||||
TableRelation = "G/L Entry"."Entry No.";
|
||||
}
|
||||
field(2; "External Reference"; Text[50]) { }
|
||||
}
|
||||
|
||||
keys
|
||||
{
|
||||
key(PK; "Entry No.") { Clustered = true; }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
---
|
||||
bc-version: ["23.."]
|
||||
domain: performance
|
||||
keywords: [tableextension, companion-table, gl-entry, related-table, flowfield, hot-table]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Prefer a related table over stored fields on hot ledgers
|
||||
|
||||
> Contributions welcome — open a PR to refine or extend this article.
|
||||
|
||||
## Description
|
||||
|
||||
Since v23, all extensions on the same base table share at most one companion-table join, and the platform automatically excludes that join on List, ListPart, and OData pages when partial records are in effect and no extension field is loaded. However, the join is still paid on every posting path and any AL code that accesses an extension field — or that runs without partial-record semantics. On hot tables — G/L Entry, Item Ledger Entry, Cust. Ledger Entry — even a single access per posted row adds up at volume. A related table keyed by the ledger `Entry No.`, optionally surfaced with a FlowField or FactBox, leaves the base read path entirely untouched. Agents extend G/L Entry because it is "where the posting already is".
|
||||
|
||||
## Best Practice
|
||||
|
||||
Put optional, sparse, or integration attributes in a related table with the ledger entry number as primary key. Show them from a FactBox or a FlowField.
|
||||
Use a tableextension stored field only when the value must appear as a native list column and is read on almost every access.
|
||||
|
||||
See sample: `prefer-related-table-over-extension-on-hot-ledgers.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
`tableextension` on `"G/L Entry"` (or another posting table) that adds several stored `Text`/`Blob` fields used only by one integration. The companion join is paid on every posting and on any AL code path that loads extension fields, even when those columns are not needed for the current operation.
|
||||
|
||||
See sample: `prefer-related-table-over-extension-on-hot-ledgers.bad.al`.
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
query 50100 "Query Bypass PK Cache Bad Q"
|
||||
{
|
||||
QueryType = Normal;
|
||||
|
||||
elements
|
||||
{
|
||||
dataitem(Customer; Customer)
|
||||
{
|
||||
filter(NoFilter; "No.") { }
|
||||
column(Name; Name) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
codeunit 50100 "Query Bypass PK Cache Bad"
|
||||
{
|
||||
procedure CustomerName(CustomerNo: Code[20]): Text
|
||||
var
|
||||
CustomerByNo: Query "Query Bypass PK Cache Bad Q";
|
||||
begin
|
||||
// Query Open/Read never hits the server PK cache.
|
||||
CustomerByNo.SetRange(NoFilter, CustomerNo);
|
||||
CustomerByNo.Open();
|
||||
if CustomerByNo.Read() then
|
||||
exit(CustomerByNo.Name);
|
||||
CustomerByNo.Close();
|
||||
end;
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
codeunit 50100 "Query Bypass PK Cache Good"
|
||||
{
|
||||
procedure CustomerName(CustomerNo: Code[20]): Text
|
||||
var
|
||||
Customer: Record Customer;
|
||||
begin
|
||||
// Repeated Get of the same No. is served from the transaction PK cache.
|
||||
Customer.SetLoadFields(Name);
|
||||
if Customer.Get(CustomerNo) then
|
||||
exit(Customer.Name);
|
||||
end;
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: performance
|
||||
keywords: [query, primary-key-cache, get, false-positive, n-plus-one, record-cache]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Query results bypass the primary-key cache
|
||||
|
||||
> Contributions welcome — open a PR to refine or extend this article.
|
||||
|
||||
## Description
|
||||
|
||||
The Business Central server caches primary-key `Get` calls within a transaction. Query objects do not use that cache: every `Open`/`Read` goes to SQL. `avoid-get-inside-loop-on-large-table.md` is right when an unbounded inner `Get`/`FindFirst` joins two large sets. It is wrong as a blanket rewrite of repeated `Get` on the same keys. Replacing a cached `Get` with a Query that re-executes per call can be slower. This file exists so reviewers stop treating every `Get` inside a loop as a Query candidate.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Keep `Record.Get` for repeated lookups of the same primary keys in one transaction. Use a Query when the work is a true join or aggregation that the record API would express as nested scans. Do not flag a guarded `Get` on a repeating key as an N+1 solely because a Query could express the same columns.
|
||||
|
||||
See sample: `query-results-bypass-primary-key-cache.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Rewriting a helper that `Get`s Customer by `No.` on every sales line into a Query opened inside that helper. Distinct line customers still need a lookup; repeating customers were already served from the PK cache. The Query pays SQL every time.
|
||||
|
||||
See sample: `query-results-bypass-primary-key-cache.bad.al`.
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
codeunit 50100 "Reset Clears LoadFields Bad"
|
||||
{
|
||||
procedure ListUsCustomerNames()
|
||||
var
|
||||
Customer: Record Customer;
|
||||
begin
|
||||
Customer.SetLoadFields(Name);
|
||||
// Reset restores a full-row load; the SetLoadFields above is discarded.
|
||||
Customer.Reset();
|
||||
Customer.SetRange("Country/Region Code", 'US');
|
||||
if Customer.FindSet() then
|
||||
repeat
|
||||
Message(Customer.Name);
|
||||
until Customer.Next() = 0;
|
||||
end;
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
codeunit 50100 "Reset Clears LoadFields Good"
|
||||
{
|
||||
procedure ListUsCustomerNames()
|
||||
var
|
||||
Customer: Record Customer;
|
||||
begin
|
||||
Customer.Reset();
|
||||
Customer.SetLoadFields(Name);
|
||||
Customer.SetRange("Country/Region Code", 'US');
|
||||
if Customer.FindSet() then
|
||||
repeat
|
||||
Message(Customer.Name);
|
||||
until Customer.Next() = 0;
|
||||
end;
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: performance
|
||||
keywords: [reset, setloadfields, partial-record, load-selection, findset]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Reset and empty SetLoadFields restore a full-row load
|
||||
|
||||
> Contributions welcome — open a PR to refine or extend this article.
|
||||
|
||||
## Description
|
||||
|
||||
`SetLoadFields(...)` sticks to the record variable until something clears it. `Reset()` "changes fields select for loading back to all", and `SetLoadFields()` with no arguments does the same. A later `FindSet` or `Get` then materializes every normal field. Agents often place `SetLoadFields` first, then `Reset` to apply new filters, and assume the partial selection survives. It does not.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Call `Reset` (or empty `SetLoadFields()`) first when the variable must be reused, then call `SetLoadFields` with the fields the next read actually uses, then apply filters and read. After `Reset`, a new `SetLoadFields` is required; the previous list is gone.
|
||||
|
||||
See sample: `reset-clears-partial-record-selection.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
`SetLoadFields(...)` followed by `Reset()` (or by parameterless `SetLoadFields()`) and then `FindSet` without restoring the load list. The filters look correct; the SQL still selects every column.
|
||||
|
||||
See sample: `reset-clears-partial-record-selection.bad.al`.
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
codeunit 50100 "Skip LoadFields Write Bad"
|
||||
{
|
||||
procedure CopyActiveCustomers(var TempCustomer: Record Customer temporary)
|
||||
var
|
||||
Customer: Record Customer;
|
||||
begin
|
||||
// TransferFields requires all fields; partial load forces JIT per row.
|
||||
Customer.SetLoadFields("No.", Name);
|
||||
Customer.SetRange(Blocked, Customer.Blocked::" ");
|
||||
if Customer.FindSet() then
|
||||
repeat
|
||||
TempCustomer.TransferFields(Customer);
|
||||
TempCustomer.Insert();
|
||||
until Customer.Next() = 0;
|
||||
end;
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
codeunit 50100 "Skip LoadFields Write Good"
|
||||
{
|
||||
procedure CopyActiveCustomers(var TempCustomer: Record Customer temporary)
|
||||
var
|
||||
Customer: Record Customer;
|
||||
begin
|
||||
// TransferFields needs all fields; omit SetLoadFields so the initial read loads the full row.
|
||||
Customer.SetRange(Blocked, Customer.Blocked::" ");
|
||||
if Customer.FindSet() then
|
||||
repeat
|
||||
TempCustomer.TransferFields(Customer);
|
||||
TempCustomer.Insert();
|
||||
until Customer.Next() = 0;
|
||||
end;
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: performance
|
||||
keywords: [setloadfields, partial-record, jit-load, modify, insert, transferfields, write-path]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Skip SetLoadFields on write and copy paths
|
||||
|
||||
> Contributions welcome — open a PR to refine or extend this article.
|
||||
|
||||
## Description
|
||||
|
||||
`SetLoadFields` is a read optimization. The platform's [partial-record usage guidelines](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-partial-records#usage-guidelines) list the operations that require every field to already be present: `Insert`, `Delete`, `Rename`, `TransferFields`, and copying a record into a temporary table. When those operations run on a partial record, the platform issues a just-in-time load of the missing fields. That extra round-trip costs more than loading the full row on the original `FindSet` or `Get`. Note: `Modify` itself is **not** in this list — a `Modify(false)` that only touches loaded fields is safe with a partial record.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Omit `SetLoadFields` on loops whose body performs a documented full-load operation (`Insert`, `Delete`, `Rename`, `TransferFields`, or assignment into a temporary record) on the same record variable, so the initial read already materializes every field those operations need.
|
||||
|
||||
See sample: `skip-setloadfields-on-write-and-transferfields.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Calling `SetLoadFields` immediately before a `FindSet` whose body performs `Delete`, `Rename`, `TransferFields`, or copies the record into a temporary table. The review signal is a partial-record setup on a record variable that feeds one of these documented full-load operations in the same iteration.
|
||||
|
||||
See sample: `skip-setloadfields-on-write-and-transferfields.bad.al`.
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
table 50100 "Campaign Member"
|
||||
{
|
||||
Caption = 'Campaign Member';
|
||||
// Full list as lookup runs FactBoxes and extra columns on every dropdown.
|
||||
LookupPageId = Page::"Campaign Member List";
|
||||
DrillDownPageId = Page::"Campaign Member List";
|
||||
DataClassification = CustomerContent;
|
||||
|
||||
fields
|
||||
{
|
||||
field(1; "No."; Code[20]) { }
|
||||
field(2; Name; Text[100]) { }
|
||||
field(3; "Balance (LCY)"; Decimal) { }
|
||||
}
|
||||
|
||||
keys
|
||||
{
|
||||
key(PK; "No.") { Clustered = true; }
|
||||
}
|
||||
}
|
||||
|
||||
page 50100 "Campaign Member List"
|
||||
{
|
||||
PageType = List;
|
||||
SourceTable = "Campaign Member";
|
||||
|
||||
layout
|
||||
{
|
||||
area(content)
|
||||
{
|
||||
repeater(Rows)
|
||||
{
|
||||
field("No."; Rec."No.") { }
|
||||
field(Name; Rec.Name) { }
|
||||
field("Balance (LCY)"; Rec."Balance (LCY)") { }
|
||||
}
|
||||
}
|
||||
area(factboxes)
|
||||
{
|
||||
// Full list carries this FactBox on every dropdown open — expensive.
|
||||
part(Details; "Campaign Member Details FB") { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
page 50101 "Campaign Member Details FB"
|
||||
{
|
||||
PageType = CardPart;
|
||||
SourceTable = "Campaign Member";
|
||||
|
||||
layout
|
||||
{
|
||||
area(content)
|
||||
{
|
||||
field("No."; Rec."No.") { }
|
||||
field(Name; Rec.Name) { }
|
||||
field("Balance (LCY)"; Rec."Balance (LCY)") { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
table 50100 "Campaign Member"
|
||||
{
|
||||
Caption = 'Campaign Member';
|
||||
LookupPageId = Page::"Campaign Member Lookup";
|
||||
DrillDownPageId = Page::"Campaign Member List";
|
||||
DataClassification = CustomerContent;
|
||||
|
||||
fields
|
||||
{
|
||||
field(1; "No."; Code[20]) { }
|
||||
field(2; Name; Text[100]) { }
|
||||
field(3; "Balance (LCY)"; Decimal) { }
|
||||
}
|
||||
|
||||
keys
|
||||
{
|
||||
key(PK; "No.") { Clustered = true; }
|
||||
}
|
||||
}
|
||||
|
||||
page 50100 "Campaign Member Lookup"
|
||||
{
|
||||
PageType = List;
|
||||
SourceTable = "Campaign Member";
|
||||
Caption = 'Campaign Members';
|
||||
|
||||
layout
|
||||
{
|
||||
area(content)
|
||||
{
|
||||
repeater(Rows)
|
||||
{
|
||||
field("No."; Rec."No.") { }
|
||||
field(Name; Rec.Name) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
page 50101 "Campaign Member List"
|
||||
{
|
||||
PageType = List;
|
||||
SourceTable = "Campaign Member";
|
||||
|
||||
layout
|
||||
{
|
||||
area(content)
|
||||
{
|
||||
repeater(Rows)
|
||||
{
|
||||
field("No."; Rec."No.") { }
|
||||
field(Name; Rec.Name) { }
|
||||
field("Balance (LCY)"; Rec."Balance (LCY)") { }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: performance
|
||||
keywords: [lookuppageid, lookup-page, list-page, factbox, table-relation, dropdown]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Point lookups at a dedicated lookup page, not the full list
|
||||
|
||||
> Contributions welcome — open a PR to refine or extend this article.
|
||||
|
||||
## Description
|
||||
|
||||
A `TableRelation` lookup opens the table's `LookupPageId`. If that is the full list page, the lookup runs that page's triggers, FactBoxes, and calculated fields even though the dropdown never shows them. The base application added dedicated Customer, Vendor, and Item lookup pages for this reason. Agents set `LookupPageId` to the main list because it already exists.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Give master tables a slim lookup page (`PageType = List`, few columns, no FactBoxes, no heavy `OnAfterGetRecord`) and assign it to `LookupPageId`. Keep the full list for `DrillDownPageId` and the role-explorer entry.
|
||||
|
||||
See sample: `use-dedicated-lookup-pages-not-full-lists.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
`LookupPageId = Page::"... List"` on a table that already has (or should have) a lookup page. Opening a field lookup then pays list-page cost. The signal is `LookupPageId` pointing at a page that declares FactBoxes or a wide repeater.
|
||||
|
||||
See sample: `use-dedicated-lookup-pages-not-full-lists.bad.al`.
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
codeunit 50100 "Validate Partial Rec Bad"
|
||||
{
|
||||
procedure UppercaseUsCustomerNames()
|
||||
var
|
||||
Customer: Record Customer;
|
||||
begin
|
||||
Customer.SetLoadFields(Name);
|
||||
Customer.SetRange("Country/Region Code", 'US');
|
||||
if Customer.FindSet(true) then
|
||||
repeat
|
||||
// Validate touches other fields and TableRelation reads; JIT undoes the partial load.
|
||||
Customer.Validate(Name, UpperCase(Customer.Name));
|
||||
Customer.Modify(false);
|
||||
until Customer.Next() = 0;
|
||||
end;
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
codeunit 50100 "Validate Partial Rec Good"
|
||||
{
|
||||
procedure UppercaseUsCustomerNames()
|
||||
var
|
||||
Customer: Record Customer;
|
||||
begin
|
||||
// Include every field that Name.OnValidate reads so the runtime never JIT-loads.
|
||||
Customer.SetLoadFields(Name, "Search Name");
|
||||
Customer.SetRange("Country/Region Code", 'US');
|
||||
if Customer.FindSet(true) then
|
||||
repeat
|
||||
Customer.Validate(Name, UpperCase(Customer.Name));
|
||||
Customer.Modify(false);
|
||||
until Customer.Next() = 0;
|
||||
end;
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: performance
|
||||
keywords: [validate, setloadfields, jit-load, table-relation, onvalidate, partial-record]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Validate on a partial record forces JIT loads
|
||||
|
||||
> Contributions welcome — open a PR to refine or extend this article.
|
||||
|
||||
## Description
|
||||
|
||||
`Validate` runs the field's `OnValidate` trigger and TableRelation lookups. Those code paths routinely touch other fields on the same record. On a partial row those extra fields are not loaded, so the platform JIT-loads them — often the rest of the row — plus any related-table reads the trigger performs. Distinct from `skip-setloadfields-on-write-and-transferfields.md`: the write may be `Modify(false)`; `Validate` is what blows the partial load. Agents that combine `SetLoadFields` with `Validate` in a loop produce slower code than an unoptimized assignment.
|
||||
|
||||
## Best Practice
|
||||
|
||||
In a partial-record loop, assign fields directly when trigger side effects are not required. If `Validate` is required, do not use `SetLoadFields` on that iterator, or `AddLoadFields` every field the validate path can touch before the read.
|
||||
|
||||
See sample: `validate-on-partial-record-forces-jit.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
`SetLoadFields` on a handful of columns, then `Validate` inside the loop. The load list looks optimal; runtime JIT and TableRelation I/O dominate. The signal is `Validate(` on a record that still has a `SetLoadFields` in the same procedure.
|
||||
|
||||
See sample: `validate-on-partial-record-forces-jit.bad.al`.
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
codeunit 50100 "Order Buffer Helper"
|
||||
{
|
||||
procedure ResetStagingBuffer(var OrderBuffer: Record "Sales Header")
|
||||
begin
|
||||
// No IsTemporary check. A caller that accidentally passes the real
|
||||
// Sales Header table wipes every sales header in the company with
|
||||
// no prior warning.
|
||||
OrderBuffer.DeleteAll();
|
||||
end;
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
codeunit 50100 "Order Buffer Helper"
|
||||
{
|
||||
procedure ResetStagingBuffer(var OrderBuffer: Record "Sales Header")
|
||||
begin
|
||||
// The helper is designed for a temporary buffer only. Fail loudly
|
||||
// if a caller accidentally passes the real table.
|
||||
if not OrderBuffer.IsTemporary() then
|
||||
Error('ResetStagingBuffer requires a temporary Sales Header; a persistent record was passed.');
|
||||
|
||||
OrderBuffer.DeleteAll();
|
||||
end;
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: security
|
||||
keywords: [istemporary, deleteall, modifyall, safeguard, precondition]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Guard bulk operations with IsTemporary
|
||||
|
||||
> Contributions welcome — open a PR to refine or extend this article.
|
||||
|
||||
## Description
|
||||
|
||||
An AL helper that accepts a `var Rec: Record X` parameter and performs a bulk operation (`DeleteAll`, `ModifyAll`, or an unfiltered loop that mutates every record) cannot tell from the signature alone whether the caller passed a temporary buffer or the real table. A misuse that passes the real table wipes or rewrites live data at production scale with no earlier warning. A single `IsTemporary` check at the procedure entry turns a silent-corruption risk into an early, actionable failure.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Any helper designed to operate on a temporary record, and that performs `DeleteAll`, `ModifyAll`, or similar bulk writes on its parameter, should call `Rec.IsTemporary()` at the top and raise a descriptive error when the assumption is violated. The error message should name the parameter so the misuse is easy to locate.
|
||||
|
||||
See sample: `guard-bulk-operations-with-istemporary.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Trusting documentation or naming conventions alone to signal that a `var Rec` parameter is expected to be temporary. A future refactor or a copy-paste caller can pass the real table; the bulk operation then executes against production rows silently.
|
||||
|
||||
See sample: `guard-bulk-operations-with-istemporary.bad.al`.
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: ui
|
||||
keywords: [factbox, subpagelink, listpart, cardpart, page-part, related-information, flowfield-sift]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
# Filter ListPart FactBoxes With SubPageLink To The Parent Record
|
||||
|
||||
> Contributions welcome — open a PR to refine or extend this article.
|
||||
|
||||
## Description
|
||||
A FactBox is a page `part` that surfaces related data beside the main record so users avoid navigating away. Every FactBox runs a database query as its host page loads, so an unfiltered one is a hidden performance tax paid on every page open. The remedial trap: a `ListPart` FactBox with no `SubPageLink` does not show "the related rows" — it loads and pages through the entire source table, because nothing ties it to the host record. This makes correct `SubPageLink` linkage, not visual layout, the load-bearing design decision.
|
||||
|
||||
## Best Practice
|
||||
Give every `ListPart` FactBox a `SubPageLink` that maps a field on the part's source table to a `field()` of the host record (for example `SubPageLink = "Document No." = field("No.")`), so it returns only rows belonging to the current record. Prefer a `CardPart` when you only need summary figures (balance, availability, status) — it reads a single record and avoids list overhead entirely. When a FactBox shows FlowFields, ensure the calculated total is backed by a SIFT key (`MaintainSIFTIndex`) so the sum is read from the index rather than aggregated row-by-row on each load. Keep FactBox count modest and avoid heavy `OnAfterGetRecord` logic in the part.
|
||||
|
||||
## Anti Pattern
|
||||
Adding a `ListPart` FactBox without a `SubPageLink`, expecting it to "just show related lines." The consequence is a full-table scan on every page load that grows with the dataset and is felt worst on list pages, where the FactBox re-queries on each row selection. Reviewer signal: any `part(...)` referencing a list-type page part where the `SubPageLink` property is absent, or a FactBox FlowField filtered on non-indexed fields. A second smell is duplicating data already on the page or stacking many FactBoxes, which multiplies queries for little context gain.
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
table 50540 "Sample Shipping Agent Bad"
|
||||
{
|
||||
fields
|
||||
{
|
||||
field(1; "Code"; Code[20])
|
||||
{
|
||||
DataClassification = CustomerContent;
|
||||
NotBlank = true;
|
||||
}
|
||||
field(2; Description; Text[100])
|
||||
{
|
||||
DataClassification = CustomerContent;
|
||||
}
|
||||
}
|
||||
|
||||
keys
|
||||
{
|
||||
key(PK; "Code")
|
||||
{
|
||||
Clustered = true;
|
||||
}
|
||||
}
|
||||
|
||||
trigger OnInsert()
|
||||
begin
|
||||
TestField(Description);
|
||||
end;
|
||||
}
|
||||
|
||||
page 50541 "Sample Shipping Agents Bad"
|
||||
{
|
||||
PageType = List;
|
||||
ApplicationArea = All;
|
||||
UsageCategory = Lists;
|
||||
SourceTable = "Sample Shipping Agent Bad";
|
||||
DelayedInsert = true;
|
||||
|
||||
layout
|
||||
{
|
||||
area(content)
|
||||
{
|
||||
repeater(Agents)
|
||||
{
|
||||
field("Code"; Rec."Code")
|
||||
{
|
||||
ApplicationArea = All;
|
||||
ToolTip = 'Specifies the code of the shipping agent.';
|
||||
}
|
||||
field(Description; Rec.Description)
|
||||
{
|
||||
ApplicationArea = All;
|
||||
ToolTip = 'Specifies a description of the shipping agent.';
|
||||
// Required by OnInsert, but nothing marks it. The user types
|
||||
// the row, leaves it, and only then gets the error.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
table 50542 "Sample Shipping Agent"
|
||||
{
|
||||
fields
|
||||
{
|
||||
field(1; "Code"; Code[20])
|
||||
{
|
||||
DataClassification = CustomerContent;
|
||||
NotBlank = true;
|
||||
}
|
||||
field(2; Description; Text[100])
|
||||
{
|
||||
DataClassification = CustomerContent;
|
||||
}
|
||||
}
|
||||
|
||||
keys
|
||||
{
|
||||
key(PK; "Code")
|
||||
{
|
||||
Clustered = true;
|
||||
}
|
||||
}
|
||||
|
||||
trigger OnInsert()
|
||||
begin
|
||||
TestField(Description);
|
||||
end;
|
||||
}
|
||||
|
||||
page 50543 "Sample Shipping Agents"
|
||||
{
|
||||
PageType = List;
|
||||
ApplicationArea = All;
|
||||
UsageCategory = Lists;
|
||||
SourceTable = "Sample Shipping Agent";
|
||||
DelayedInsert = true;
|
||||
|
||||
layout
|
||||
{
|
||||
area(content)
|
||||
{
|
||||
repeater(Agents)
|
||||
{
|
||||
field("Code"; Rec."Code")
|
||||
{
|
||||
ApplicationArea = All;
|
||||
ToolTip = 'Specifies the code of the shipping agent.';
|
||||
ShowMandatory = true;
|
||||
}
|
||||
field(Description; Rec.Description)
|
||||
{
|
||||
ApplicationArea = All;
|
||||
ToolTip = 'Specifies a description of the shipping agent.';
|
||||
// Mirrors the TestField in OnInsert. ShowMandatory is what the
|
||||
// client reads for the marker, so it has to be set here.
|
||||
ShowMandatory = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: ui
|
||||
keywords: [showmandatory, notblank, mandatory-field, red-asterisk, delayedinsert, testfield, page-field]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Mark code-required page fields with ShowMandatory
|
||||
|
||||
> Contributions welcome — open a PR to refine or extend this article.
|
||||
|
||||
## Description
|
||||
|
||||
`ShowMandatory` draws the red asterisk on a page field and, per the platform documentation, enforces no validation. The reverse is not reliable: code that enforces a value — `TestField` in `OnInsert`/`OnModify`, a `NotBlank` table field, a mandatory setup value — does not guarantee that the page field renders as mandatory. Because the two halves are independent, it is easy to ship a field that the code requires but the UI presents as optional. Microsoft documents that `NotBlank` can mark primary-key fields, but current client behavior does not do so consistently; on non-primary-key fields, a value that was never entered is not validated at all. `ShowMandatory` also overrides any marking `NotBlank` would contribute, so set it explicitly when the page must communicate a requirement. The gap is widest on a list page with `DelayedInsert = true`, where the enforcing error surfaces only when the user leaves the row — after the rest of the line is typed, with nothing having indicated which field was missing.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Set `ShowMandatory = true` on every visible, editable page field whose value the user must supply before the record can be committed or an action can complete, and leave the enforcement in place: the property is presentation, `TestField`/`Error` is the guarantee, and the two belong together in the same change. When the requirement is conditional, bind `ShowMandatory` to a Boolean variable or field that mirrors the condition the enforcement checks — the base application drives `Vendor Invoice No.` on the Purchase Invoice page from an `Ext. Doc. No. Mandatory` setup flag this way. Two expression limits are worth knowing: the property cannot call an AL method, so compute the value into a variable first, and a numeric field that has a default value counts as filled, so it never shows the asterisk. See sample: `showmandatory-on-code-required-page-fields.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
A required field with no mandatory marker: the table's `OnInsert` or the page's `OnInsertRecord` calls `TestField` on a field, or `NotBlank` is expected to force entry, while the page field bound to it carries no `ShowMandatory`. On a `DelayedInsert = true` list page the user fills the row, leaves it, and gets an error naming a field that never looked different from the optional ones. Reviewer signal: code on the relevant commit or action path requires the user to supply a field, the corresponding page control is visible and editable, and its `ShowMandatory` property is missing or does not mirror the same condition. A `TestField` or `Error` elsewhere in `OnValidate` or `OnModify` is not sufficient evidence: the field may be populated by code, non-editable, or required only for another path. Setting `ShowMandatory = false` on a field that is unconditionally required on the current path is the same defect stated explicitly, and per the documentation it also overrides any marking `NotBlank` would otherwise contribute. See sample: `showmandatory-on-code-required-page-fields.bad.al`.
|
||||
|
||||
## See also
|
||||
|
||||
`ShowMandatory` property — https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/properties/devenv-showmandatory-property
|
||||
|
||||
`NotBlank` property — https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/properties/devenv-notblank-property
|
||||
|
||||
Review finding this article generalizes — https://github.com/microsoft/BCApps/pull/9315#discussion_r3568817946
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
report 50545 "Sample Statement Late Check"
|
||||
{
|
||||
ApplicationArea = All;
|
||||
UsageCategory = ReportsAndAnalysis;
|
||||
Caption = 'Sample Statement Late Check';
|
||||
|
||||
dataset
|
||||
{
|
||||
dataitem(CustLedgerEntry; "Cust. Ledger Entry")
|
||||
{
|
||||
column(CustomerNo; "Customer No.") { }
|
||||
column(Amount; Amount) { }
|
||||
}
|
||||
}
|
||||
|
||||
requestpage
|
||||
{
|
||||
layout
|
||||
{
|
||||
area(content)
|
||||
{
|
||||
group(Options)
|
||||
{
|
||||
field(StatementDateField; StatementDate)
|
||||
{
|
||||
ApplicationArea = All;
|
||||
Caption = 'Statement Date';
|
||||
ToolTip = 'Specifies the date the statement is printed for.';
|
||||
ShowMandatory = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// No OnQueryClosePage: nothing inspects the input while the page is open.
|
||||
}
|
||||
|
||||
var
|
||||
StatementDate: Date;
|
||||
StatementDateMissingErr: Label 'Enter a statement date.';
|
||||
|
||||
trigger OnPreReport()
|
||||
begin
|
||||
// The request page is already closed. The user cannot correct the date
|
||||
// here — the run is aborted and every entry on the page is lost.
|
||||
if StatementDate = 0D then
|
||||
Error(StatementDateMissingErr);
|
||||
end;
|
||||
}
|
||||
|
||||
report 50546 "Sample Statement Close Trap"
|
||||
{
|
||||
ApplicationArea = All;
|
||||
UsageCategory = ReportsAndAnalysis;
|
||||
Caption = 'Sample Statement Close Trap';
|
||||
|
||||
dataset
|
||||
{
|
||||
dataitem(CustLedgerEntry; "Cust. Ledger Entry")
|
||||
{
|
||||
column(CustomerNo; "Customer No.") { }
|
||||
column(Amount; Amount) { }
|
||||
}
|
||||
}
|
||||
|
||||
requestpage
|
||||
{
|
||||
layout
|
||||
{
|
||||
area(content)
|
||||
{
|
||||
group(Options)
|
||||
{
|
||||
field(StatementDateField; StatementDate)
|
||||
{
|
||||
ApplicationArea = All;
|
||||
Caption = 'Statement Date';
|
||||
ToolTip = 'Specifies the date the statement is printed for.';
|
||||
ShowMandatory = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
trigger OnQueryClosePage(CloseAction: Action): Boolean
|
||||
begin
|
||||
// No close-action guard. Cancel and Esc raise the error too, and an
|
||||
// error prevents the page from closing — the user cannot get out.
|
||||
if StatementDate = 0D then
|
||||
Error(StatementDateMissingErr);
|
||||
end;
|
||||
}
|
||||
|
||||
var
|
||||
StatementDate: Date;
|
||||
StatementDateMissingErr: Label 'Enter a statement date.';
|
||||
}
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
report 50547 "Sample Statement Good"
|
||||
{
|
||||
ApplicationArea = All;
|
||||
UsageCategory = ReportsAndAnalysis;
|
||||
Caption = 'Sample Statement Good';
|
||||
|
||||
dataset
|
||||
{
|
||||
dataitem(CustLedgerEntry; "Cust. Ledger Entry")
|
||||
{
|
||||
column(CustomerNo; "Customer No.") { }
|
||||
column(Amount; Amount) { }
|
||||
}
|
||||
}
|
||||
|
||||
requestpage
|
||||
{
|
||||
layout
|
||||
{
|
||||
area(content)
|
||||
{
|
||||
group(Options)
|
||||
{
|
||||
field(StatementDateField; StatementDate)
|
||||
{
|
||||
ApplicationArea = All;
|
||||
Caption = 'Statement Date';
|
||||
ToolTip = 'Specifies the date the statement is printed for.';
|
||||
ShowMandatory = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
trigger OnQueryClosePage(CloseAction: Action): Boolean
|
||||
begin
|
||||
// Only when the user confirmed the run. Erroring on Cancel or Esc
|
||||
// would trap the user in a page that refuses to close. The error
|
||||
// itself keeps the page open, so the date can be fixed in place.
|
||||
if CloseAction = Action::OK then
|
||||
CheckStatementDate();
|
||||
end;
|
||||
}
|
||||
|
||||
var
|
||||
StatementDate: Date;
|
||||
StatementDateMissingErr: Label 'Enter a statement date.';
|
||||
|
||||
trigger OnPreReport()
|
||||
begin
|
||||
// The same check for runs that have no request page: job queue entries,
|
||||
// Report.Run with the request window suppressed, scheduled and
|
||||
// web-service invocations.
|
||||
CheckStatementDate();
|
||||
end;
|
||||
|
||||
local procedure CheckStatementDate()
|
||||
begin
|
||||
if StatementDate = 0D then
|
||||
Error(StatementDateMissingErr);
|
||||
end;
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: ui
|
||||
keywords: [request-page, onqueryclosepage, onprereport, closeaction, mandatory-input, report-validation, job-queue]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Validate request-page input in OnQueryClosePage, not only in OnPreReport
|
||||
|
||||
> Contributions welcome — open a PR to refine or extend this article.
|
||||
|
||||
## Description
|
||||
|
||||
`OnPreReport` runs after the request page has closed and before the data items are processed. A validation error raised there aborts the run with the request page already gone: everything the user typed is lost, and the only way forward is to open the report again and retype it. The request page's own `OnQueryClosePage` trigger runs while the page is still open, and the platform does not close a page whose `OnQueryClosePage` raises an error or returns `false` — so the same check placed there leaves the user in front of their input, with the offending field still filled in and correctable. Moving the check rather than duplicating it fails the other way: a report can run with no request page at all — `Report.Run`/`Report.RunModal` with the request window suppressed, `UseRequestPage = false`, job queue entries, scheduled and web-service invocations — and `OnQueryClosePage` never fires on those paths.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Put the validation in one local procedure and call it from both places: from the request page's `OnQueryClosePage`, so an interactive user can correct the input where they entered it, and from `OnPreReport` (or the relevant `OnPreDataItem`), so a run without a request page is still refused. Guard the interactive call on the close action — validate only when the user confirmed the run, for example `if CloseAction = Action::OK then`. The base application uses this shape; report 292, `Copy Sales Document`, validates its request-page input in `OnQueryClosePage` behind a close-action check. Mark the control with `ShowMandatory` as well, so the requirement is visible before the user submits — see `showmandatory-on-code-required-page-fields.md`. See sample: `validate-request-page-input-in-onqueryclosepage.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Validating mandatory request-page input only in `OnPreReport`. The check is correct and the report is never run with bad input, but every interactive mistake costs the user the whole request page: the error arrives after the page is gone, and filters, dates, and options all have to be entered again. Reviewer signal: a `TestField`, `Error`, or blank/zero-value check in `OnPreReport` or `OnPreDataItem` against a variable that is bound to a request-page control, in a report whose request page declares no `OnQueryClosePage`.
|
||||
|
||||
The mirror defect is an `OnQueryClosePage` that validates without inspecting `CloseAction`: because an error prevents the page from closing, a user who presses Cancel or Esc to abandon the report is trapped in a request page that errors on every attempt to leave it. Validating only in `OnQueryClosePage` is the third variant — the interactive path behaves well, and a job queue entry runs the report with unchecked input. See sample: `validate-request-page-input-in-onqueryclosepage.bad.al`.
|
||||
|
||||
## See also
|
||||
|
||||
`OnQueryClosePage` (Request Page) trigger — https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/triggers-auto/requestpage/devenv-onqueryclosepage-requestpage-trigger
|
||||
|
||||
`OnPreReport` (Report) trigger — https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/triggers-auto/report/devenv-onprereport-report-trigger
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
// Ordinal 2 was renamed from CreditNote to CreditMemo with ordinal and caption kept. The compiler stays silent
|
||||
// and AS0082 fires only against a baseline; every schema 2.0 consumer that filters on or posts CreditNote fails.
|
||||
enum 50120 "Document Kind Bad"
|
||||
{
|
||||
Extensible = true;
|
||||
|
||||
value(0; Invoice) { Caption = 'Invoice'; }
|
||||
value(1; Order) { Caption = 'Order'; }
|
||||
value(2; CreditMemo) { Caption = 'Credit Memo'; }
|
||||
}
|
||||
|
||||
table 50121 "Document Header Bad"
|
||||
{
|
||||
DataClassification = CustomerContent;
|
||||
|
||||
fields
|
||||
{
|
||||
field(1; "No."; Code[20]) { DataClassification = CustomerContent; }
|
||||
field(2; Kind; Enum "Document Kind Bad") { DataClassification = CustomerContent; }
|
||||
}
|
||||
|
||||
keys
|
||||
{
|
||||
key(PK; "No.") { Clustered = true; }
|
||||
}
|
||||
}
|
||||
|
||||
page 50122 "Document API Bad"
|
||||
{
|
||||
PageType = API;
|
||||
APIPublisher = 'contoso';
|
||||
APIGroup = 'documents';
|
||||
APIVersion = 'v1.0';
|
||||
EntityName = 'document';
|
||||
EntitySetName = 'documents';
|
||||
ODataKeyFields = SystemId;
|
||||
SourceTable = "Document Header Bad";
|
||||
DelayedInsert = true;
|
||||
|
||||
layout
|
||||
{
|
||||
area(content)
|
||||
{
|
||||
repeater(records)
|
||||
{
|
||||
field(id; Rec.SystemId) { Caption = 'id'; Editable = false; }
|
||||
field(number; Rec."No.") { Caption = 'number'; }
|
||||
field(kind; Rec.Kind) { Caption = 'kind'; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
// Neither the name CreditNote nor its caption changes in place: schema 2.0 consumers bind to the name,
|
||||
// schema 1.0 consumers to the caption. A new kind is appended; a retired kind is obsoleted, never deleted.
|
||||
enum 50120 "Document Kind Good"
|
||||
{
|
||||
Extensible = true;
|
||||
|
||||
value(0; Invoice) { Caption = 'Invoice'; }
|
||||
value(1; Order) { Caption = 'Order'; }
|
||||
value(2; CreditNote) { Caption = 'Credit Note'; }
|
||||
value(3; ReturnOrder) { Caption = 'Return Order'; }
|
||||
value(4; Quote) { Caption = 'Quote'; ObsoleteState = Pending; ObsoleteReason = 'Quotes moved to the quotes API.'; ObsoleteTag = '3.0'; }
|
||||
}
|
||||
|
||||
table 50121 "Document Header Good"
|
||||
{
|
||||
DataClassification = CustomerContent;
|
||||
|
||||
fields
|
||||
{
|
||||
field(1; "No."; Code[20]) { DataClassification = CustomerContent; }
|
||||
field(2; Kind; Enum "Document Kind Good") { DataClassification = CustomerContent; }
|
||||
}
|
||||
|
||||
keys
|
||||
{
|
||||
key(PK; "No.") { Clustered = true; }
|
||||
}
|
||||
}
|
||||
|
||||
page 50122 "Document API Good"
|
||||
{
|
||||
PageType = API;
|
||||
APIPublisher = 'contoso';
|
||||
APIGroup = 'documents';
|
||||
APIVersion = 'v1.0';
|
||||
EntityName = 'document';
|
||||
EntitySetName = 'documents';
|
||||
ODataKeyFields = SystemId;
|
||||
SourceTable = "Document Header Good";
|
||||
DelayedInsert = true;
|
||||
|
||||
layout
|
||||
{
|
||||
area(content)
|
||||
{
|
||||
repeater(records)
|
||||
{
|
||||
field(id; Rec.SystemId) { Caption = 'id'; Editable = false; }
|
||||
field(number; Rec."No.") { Caption = 'number'; }
|
||||
field(kind; Rec.Kind) { Caption = 'kind'; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
---
|
||||
bc-version: [17..]
|
||||
domain: web-services
|
||||
keywords: [api-page, enum, enum-value-name, rename, ordinal, caption, schemaversion, breaking-change, dataverse, false-positive]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Under OData schema version 2.0 an API enum field is a contract by member name; under 1.0 it is the caption
|
||||
|
||||
> Contributions welcome — open a PR to refine or extend this article.
|
||||
|
||||
## Description
|
||||
|
||||
What an API page publishes for an enum field depends on the OData `$schemaversion` the caller receives, and never on the ordinal. Under schema 2.0 the field is a strongly typed enum: `$metadata`, every response and every `$filter` carry the AL member **names**, and captions are published separately through `entityDefinitions`. Under schema 1.0 the same field is `Edm.String` and responses carry the en-US **caption**. Microsoft's API v2.0 is always schema 2.0. Custom APIs defaulted to schema 1.0 through BC 23; BC 24 changed the default to 2.0, and a caller can still pin `?$schemaversion=1.0`. Dataverse virtual tables build on API v2.0 and match choices by the value's External Name, with the integer values documented as not stable.
|
||||
|
||||
LLMs treat one carrier as universal. Some assume the caption is serialised and report every caption change as an API break; others assume the name is serialised and wave a rename through when its ordinal and caption are kept. Each is right for one schema version and wrong for the other, and neither knows that the schema version decides. Page-shape changes are covered by `version-apis-by-adding-not-mutating-published-versions.md`; ordinal stability for persisted rows by `enum-values-additive-at-end.md`. This article is about the values inside one exposed field.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Establish which schema versions the field is served under before changing anything about its enum. Under schema 2.0 (Microsoft's API v2.0, an explicit `$schemaversion=2.0` in the consumer contract, or another reliable context signal) the member name is the contract: keep names stable, put wording changes in `Caption`, add a value by appending a new name with an ordinal above every existing one, and retire a value through `ObsoleteState` rather than by deleting it. For a custom API that clients may still call as schema 1.0, any install of BC 17 to 23 or a caller that pins 1.0, the caption is a contract as well: change neither name nor caption in place, or publish the change as a new `APIVersion` on a new page object. A rename is out in every case: AppSourceCop AS0082 rejects it against a baseline, and dependent extensions bind to the name.
|
||||
|
||||
See sample: `api-enum-values-are-a-contract-by-name-not-ordinal.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Renaming a value on an enum that an API page field exposes while keeping its ordinal and caption, or re-pointing an API page field at a source field whose enum carries different member names. Under schema 2.0 every consumer that filters on, posts, or maps the old name fails at runtime and Dataverse choices built on the old External Name stop matching; AS0082 reports the rename only when AppSourceCop runs against a baseline package, and nothing reports the re-pointed field.
|
||||
|
||||
Detection signal: a diff hunk that changes the name in a `value(...)` line while keeping its ordinal, on an enum used by a table field that a `PageType = API` page exposes; or an API page `field(...)` whose source expression moves to a field of another enum type.
|
||||
|
||||
The mirror image is a review defect: suppressing a caption-change finding because "the API serialises names". That holds only under schema 2.0. Do not flag a `Caption` change when the reviewer can establish schema 2.0 for every consumer; on a custom API where clients may select schema 1.0, report a caption change on an exposed value as a consumer-visible change and ask for versioning. A value appended at the end changes no contract under either schema and is never a finding.
|
||||
|
||||
See sample: `api-enum-values-are-a-contract-by-name-not-ordinal.bad.al`.
|
||||
|
||||
## See also
|
||||
|
||||
Deprecated features in the platform, Schema version for custom APIs (changed default in BC 24) — https://learn.microsoft.com/dynamics365/business-central/dev-itpro/upgrade/deprecated-features-platform#changes-in-2024-release-wave-1-version-240
|
||||
|
||||
Transitioning from API v1.0 to API v2.0, Enums and Schema version — https://learn.microsoft.com/dynamics365/business-central/dev-itpro/api-reference/v2.0/transition-to-api-v2.0#enums
|
||||
|
||||
Working with Virtual Tables, Table fields — https://learn.microsoft.com/dynamics365/business-central/dev-itpro/powerplatform/powerplat-entity-modeling#table-fields
|
||||
|
||||
AppSourceCop AS0082 (rename) — https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/analyzers/appsourcecop-as0082 and AS0083 (delete) — https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/analyzers/appsourcecop-as0083
|
||||
Loading…
Add table
Add a link
Reference in a new issue