mirror of
https://github.com/microsoft/BCQuality.git
synced 2026-08-06 17:36:53 +01:00
Merge remote-tracking branch 'origin/main' into pr49-review-fixes
This commit is contained in:
commit
28041e80e1
111 changed files with 2331 additions and 65 deletions
|
|
@ -0,0 +1,24 @@
|
|||
---
|
||||
bc-version: [24..]
|
||||
domain: appsource
|
||||
keywords: [app-json, help-url, copilot, grounding, documentation, url-depth, contexturl]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Keep the Copilot help URL to two path levels
|
||||
|
||||
> Contributions welcome — open a PR to refine or extend this article.
|
||||
|
||||
## Description
|
||||
|
||||
The `help` URL declared in `app.json` is what Copilot uses to ground answers about your app. That URL may be at most **two path levels** deep (for example `https://contoso.com/docs/myapp`). If you point it at a deeper path (three or more segments), Copilot does not use the URL as given: it truncates to the first two levels, drops any fragments and query strings, and then grounds on **all** content beneath that two-level path. The failure is silent — there is no build error — and the practical effect is worse answers, because Copilot may ingest sibling apps' documentation that lives under the same two-level parent.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Organize per-app documentation so the canonical help page sits no deeper than two path levels, and confirm during testing that Copilot citations resolve to your app's content rather than a broader parent. If your docs naturally nest deeper, give each app a dedicated two-level path it owns.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Setting `help` to a deep, tidy-looking docs path such as `https://contoso.com/docs/products/erp/myapp/setup`. Copilot truncates it to `…/docs/products`, then grounds on everything under that node — pulling in unrelated content and degrading answer quality for your users.
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
table 50120 "FieldError Default Bad"
|
||||
{
|
||||
fields
|
||||
{
|
||||
field(1; "No."; Code[20]) { }
|
||||
field(2; "Discount %"; Decimal) { }
|
||||
field(3; "Currency Code"; Code[10]) { }
|
||||
}
|
||||
|
||||
procedure ValidateForRelease()
|
||||
begin
|
||||
// Re-testing a field and handing FieldError a fully-formed sentence.
|
||||
// The framework already prepends the caption and appends the value,
|
||||
// so this renders as "Currency Code The Currency Code field must have
|
||||
// a value. in ..." — caption repeated, capital letter mid-sentence,
|
||||
// stray trailing clause.
|
||||
if "Currency Code" = '' then
|
||||
FieldError("Currency Code", 'The Currency Code field must have a value.');
|
||||
|
||||
if "Discount %" > 100 then
|
||||
FieldError("Discount %", 'The Discount % must not be greater than 100 percent.');
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
table 50120 "FieldError Default Good"
|
||||
{
|
||||
fields
|
||||
{
|
||||
field(1; "No."; Code[20]) { }
|
||||
field(2; "Discount %"; Decimal) { }
|
||||
field(3; "Currency Code"; Code[10]) { }
|
||||
}
|
||||
|
||||
procedure ValidateForRelease()
|
||||
begin
|
||||
// Plain required-field gate: TestField checks the condition and raises
|
||||
// the error in one call, with caption and record context supplied by
|
||||
// the framework.
|
||||
TestField("Currency Code");
|
||||
|
||||
// Condition already evaluated: pass only a lowercase predicate so it
|
||||
// reads as one sentence after the auto-inserted caption and value.
|
||||
if "Discount %" > 100 then
|
||||
FieldError("Discount %", 'cannot exceed 100');
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: error-handling
|
||||
keywords: [fielderror, testfield, error-message, field-caption, lowercase-convention, record-context, validation]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
# Rely On FieldError's Auto-Generated Context And Pass Only A Lowercase Predicate
|
||||
|
||||
> Contributions welcome — open a PR to refine or extend this article.
|
||||
|
||||
## Description
|
||||
`Rec.FieldError(FieldNo)` does not just print the text you give it. Business Central automatically prepends the field caption, appends the current field value (when non-blank), and suffixes the table name and primary-key values for record identification. The optional second argument is only the middle predicate of that sentence — e.g. `"must be unique"`, not a whole self-contained message. Misunderstanding this leads to messages that duplicate the caption and value or read as broken grammar, because the framework's surrounding text is built to join a lowercase fragment.
|
||||
|
||||
## Best Practice
|
||||
For a plain required-field check, prefer `TestField`, which tests the condition and raises the error in one call. When the condition is non-trivial and has already been evaluated, call `FieldError(FieldNo)` with no message to get the localized default (`must have a value`, `is not valid`, etc.), or pass a short lowercase predicate such as `FieldError(FieldNo, 'must be a positive number')`. Start the custom text with a lowercase letter so it reads as one sentence with the auto-inserted caption, and use a field-number reference (or the field token) rather than a hard-coded field name so captions and translations stay correct. Let the framework supply the caption, value, table, and key context for you.
|
||||
|
||||
## Anti Pattern
|
||||
Re-testing a condition you already evaluated, or passing a fully formed sentence like `'The Amount field must be positive.'` to `FieldError`. The result reads as `Amount The Amount field must be positive. in Gen. Journal Line ...` — capital letter mid-sentence, caption and value repeated, and a stray trailing clause. Reviewer signals: a `FieldError` argument that names the field, restates the current value, starts with a capital letter, or ends with a period. Each is a sign the author treated `FieldError` like `Error` instead of as a predicate slotted into framework-generated context.
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
table 50122 "FieldError vs TestField Bad"
|
||||
{
|
||||
fields
|
||||
{
|
||||
field(1; "No."; Code[20]) { }
|
||||
field(2; "Posting Date"; Date) { }
|
||||
field(3; "Amount"; Decimal) { }
|
||||
}
|
||||
|
||||
procedure PostDocument()
|
||||
begin
|
||||
// FieldError performs no comparison and always raises the moment it is
|
||||
// reached, so this "check" terminates PostDocument every time — the
|
||||
// Posting Date is never actually tested, and the amount rule below is
|
||||
// dead code.
|
||||
FieldError("Posting Date", 'must be filled in');
|
||||
|
||||
if IsAmountOutsideAllowedRange("Amount") then
|
||||
Error('Amount is out of range.');
|
||||
end;
|
||||
|
||||
local procedure IsAmountOutsideAllowedRange(Value: Decimal): Boolean
|
||||
begin
|
||||
exit((Value < 0) or (Value > 1000000));
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
table 50122 "FieldError vs TestField Good"
|
||||
{
|
||||
fields
|
||||
{
|
||||
field(1; "No."; Code[20]) { }
|
||||
field(2; "Posting Date"; Date) { }
|
||||
field(3; "Amount"; Decimal) { }
|
||||
}
|
||||
|
||||
procedure PostDocument()
|
||||
begin
|
||||
// Simple presence gate: TestField performs the check itself and raises
|
||||
// only when the field is empty. Self-documenting prerequisite.
|
||||
TestField("Posting Date");
|
||||
|
||||
// Business logic has already determined the value is invalid;
|
||||
// FieldError raises a tailored, record-aware message with no
|
||||
// condition of its own.
|
||||
if IsAmountOutsideAllowedRange("Amount") then
|
||||
FieldError("Amount", 'is outside the approved posting range');
|
||||
end;
|
||||
|
||||
local procedure IsAmountOutsideAllowedRange(Value: Decimal): Boolean
|
||||
begin
|
||||
exit((Value < 0) or (Value > 1000000));
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: error-handling
|
||||
keywords: [fielderror, testfield, field-validation, onvalidate, error-message, mandatory-field, record-context]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
# Choose `TestField` For Conditional Checks And `FieldError` For Already-Failed Validation
|
||||
|
||||
> Contributions welcome — open a PR to refine or extend this article.
|
||||
|
||||
## Description
|
||||
`TestField` and `FieldError` look interchangeable but behave differently, and choosing the wrong one produces either dead code or a check that never fires. `TestField` performs the comparison itself and throws only when the field is empty or does not match the supplied value; `FieldError` performs no comparison and always raises an error the moment it is reached. Both attach the field caption and the record's primary-key context to the message automatically, which is why neither should be replaced by a hand-built `Error` call that interpolates the field name as a literal.
|
||||
|
||||
## Best Practice
|
||||
Use `TestField` when the condition is a simple presence-or-equality check on a single field — mandatory-field gates and prerequisite checks at the top of a procedure read clearly and self-document intent. Use `FieldError` inside an `OnValidate` trigger or a validation procedure where surrounding business logic has already determined the value is invalid and you want a specific, custom message. Rely on the built-in field-and-record context both methods add rather than re-stating the field name in the text.
|
||||
|
||||
## Anti Pattern
|
||||
Calling `FieldError` to "test" a field — placing it on a path that is reached unconditionally and expecting it to validate — terminates execution every time because `FieldError` never evaluates a condition. The inverse smell is reaching for `TestField` when the rule needs a tailored message, then bolting a vague generic string onto a check that cannot express the real business reason. A reviewer can spot the first by a `FieldError` that is not guarded by a preceding `if`, and the second by a `TestField` whose intent comment describes a condition more complex than presence or equality.
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
codeunit 50124 "Sales Line Guard Bad Sample"
|
||||
{
|
||||
// A throw here executes synchronously inside the transaction of the write
|
||||
// that fired the event. With no per-record savepoint, it rolls back ALL
|
||||
// uncommitted work since the last COMMIT — the entire batch, not just this
|
||||
// line. One bad row discards every row imported before it.
|
||||
[EventSubscriber(ObjectType::Table, Database::"Sales Line", 'OnAfterInsertEvent', '', false, false)]
|
||||
local procedure OnAfterInsertSalesLine(var Rec: Record "Sales Line")
|
||||
begin
|
||||
if Rec.Quantity <= 0 then
|
||||
Rec.FieldError(Quantity, 'must be greater than zero');
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
codeunit 50124 "Batch Import Good Sample"
|
||||
{
|
||||
procedure ImportAll(var StagingLine: Record "Sales Line")
|
||||
var
|
||||
FailedCount: Integer;
|
||||
begin
|
||||
if StagingLine.FindSet() then
|
||||
repeat
|
||||
// Isolate each record behind a Codeunit.Run boundary: a failure
|
||||
// inside the run rolls back only that record's work, and the
|
||||
// batch continues instead of discarding everything.
|
||||
if not Codeunit.Run(Codeunit::"Batch Import One Line", StagingLine) then
|
||||
FailedCount += 1;
|
||||
until StagingLine.Next() = 0;
|
||||
|
||||
if FailedCount > 0 then
|
||||
Message('%1 line(s) were skipped; the rest were imported.', FailedCount);
|
||||
end;
|
||||
}
|
||||
|
||||
codeunit 50125 "Batch Import One Line"
|
||||
{
|
||||
TableNo = "Sales Line";
|
||||
|
||||
trigger OnRun()
|
||||
begin
|
||||
// Validation lives here. If it throws, only this line rolls back,
|
||||
// because the caller wrapped the call in Codeunit.Run.
|
||||
Rec.TestField("No.");
|
||||
Rec.TestField(Quantity);
|
||||
Rec.Insert(true);
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: error-handling
|
||||
keywords: [table-events, oninsert, onmodify, ondelete, transaction, rollback, commit, batch, subscriber]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# A throw in a table-event subscriber rolls back the whole batch
|
||||
|
||||
> Contributions welcome — open a PR to refine or extend this article.
|
||||
|
||||
## Description
|
||||
|
||||
Table-trigger event subscribers (`OnAfterInsertEvent`, `OnAfterModifyEvent`, `OnAfterDeleteEvent`, and their `OnBefore` counterparts) execute synchronously inside the transaction of the write that fired them. Because AL runs on a single implicit transaction with no per-record savepoint, an error raised in such a subscriber rolls back **all work since the last `COMMIT`** — not just the record that triggered it. In a batch loop with no intermediate `COMMIT`s, a single failing record discards the entire batch. The intuition that subscriber validation fails only the current record is wrong on the BC platform.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Decide the failure granularity deliberately. If a batch must continue past individual failures, do not throw from the table-event subscriber — collect the error (for example via `ErrorInfo`/collectible errors) and let the loop continue, or isolate each record's work behind a `Codeunit.Run` / `if Codeunit.Run() then` boundary so its failure rolls back only that record. Insert intermediate `COMMIT`s only with full awareness of the durability trade-off.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Putting `Error`/`TestField`/`FieldError` validation inside a table-event subscriber and assuming it rejects just the offending record during bulk processing. The first failure unwinds every uncommitted record in the run, turning a one-row data problem into a whole-batch rollback.
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
// Demonstration only. Shows the wrong pattern: raising the integration event inside a TryFunction body.
|
||||
|
||||
codeunit 50116 "Payment Processor Bad"
|
||||
{
|
||||
[IntegrationEvent(false, false)]
|
||||
procedure OnBeforeSubmitPayment(var PaymentAmount: Decimal; var Cancel: Boolean)
|
||||
begin
|
||||
end;
|
||||
|
||||
procedure SubmitPayment(PaymentAmount: Decimal)
|
||||
var
|
||||
Success: Boolean;
|
||||
begin
|
||||
// TryFunction wraps both the event raise and the gateway call.
|
||||
Success := TrySubmitPaymentInternal(PaymentAmount);
|
||||
if not Success then
|
||||
Error('Payment gateway call failed. Check connectivity and retry.');
|
||||
end;
|
||||
|
||||
[TryFunction]
|
||||
local procedure TrySubmitPaymentInternal(PaymentAmount: Decimal)
|
||||
var
|
||||
Cancel: Boolean;
|
||||
Client: HttpClient;
|
||||
Response: HttpResponseMessage;
|
||||
begin
|
||||
Cancel := false;
|
||||
// BAD: event raised inside TryFunction. Any Error() thrown by a subscriber is caught here
|
||||
// and silently swallowed - the subscriber's error never reaches the caller.
|
||||
// A subscriber setting Cancel := true is also lost when TryFunction returns false.
|
||||
OnBeforeSubmitPayment(PaymentAmount, Cancel);
|
||||
if Cancel then
|
||||
exit;
|
||||
Client.Get('https://payments.example.com/submit?amount=' + Format(PaymentAmount), Response);
|
||||
if not Response.IsSuccessStatusCode() then
|
||||
Error('HTTP %1', Response.HttpStatusCode());
|
||||
end;
|
||||
}
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
// Demonstration only. Shows the correct pattern: raise the integration event before entering TryFunction.
|
||||
|
||||
codeunit 50114 "Payment Processor"
|
||||
{
|
||||
[IntegrationEvent(false, false)]
|
||||
procedure OnBeforeSubmitPayment(var PaymentAmount: Decimal; var Cancel: Boolean)
|
||||
begin
|
||||
end;
|
||||
|
||||
procedure SubmitPayment(PaymentAmount: Decimal)
|
||||
var
|
||||
Cancel: Boolean;
|
||||
Success: Boolean;
|
||||
begin
|
||||
Cancel := false;
|
||||
// Event raised outside the try scope - subscriber errors propagate normally to the caller.
|
||||
OnBeforeSubmitPayment(PaymentAmount, Cancel);
|
||||
if Cancel then
|
||||
exit;
|
||||
|
||||
// Only the operation that can fail transiently lives inside TryFunction.
|
||||
Success := TryCallPaymentGateway(PaymentAmount);
|
||||
if not Success then
|
||||
Error('Payment gateway call failed. Check connectivity and retry.');
|
||||
end;
|
||||
|
||||
[TryFunction]
|
||||
local procedure TryCallPaymentGateway(PaymentAmount: Decimal)
|
||||
var
|
||||
Client: HttpClient;
|
||||
Response: HttpResponseMessage;
|
||||
begin
|
||||
// ... build request, set headers ...
|
||||
Client.Get('https://payments.example.com/submit?amount=' + Format(PaymentAmount), Response);
|
||||
if not Response.IsSuccessStatusCode() then
|
||||
Error('HTTP %1', Response.HttpStatusCode());
|
||||
end;
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: events
|
||||
keywords: [tryfunction, integration-event, subscriber, error-handling, silent-failure, event-publisher]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Do not raise integration events inside a TryFunction
|
||||
|
||||
## Description
|
||||
|
||||
A `TryFunction` catches all errors — including errors thrown by event subscribers. When an `[IntegrationEvent]` is raised inside a `TryFunction` body, any error a subscriber raises is silently swallowed by the TryFunction's error boundary. The subscriber's logic fails, the caller sees no error, and the calling code continues as if nothing happened. Subscribers have no way to signal failure to the caller.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Raise the integration event before entering the TryFunction scope. The event and its subscribers execute outside the error boundary, so subscriber errors propagate normally to the caller. Move only the operation that genuinely needs error isolation (such as an HTTP call or a posting step) inside the TryFunction.
|
||||
|
||||
See sample: `avoid-raising-events-inside-try-functions.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Raising an integration event inside a TryFunction body. Subscriber failures are caught and discarded by the TryFunction. The subscriber contract — that a subscriber can signal failure to the caller — is silently broken.
|
||||
|
||||
See sample: `avoid-raising-events-inside-try-functions.bad.al`.
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
codeunit 50100 "Event Audit Buffer"
|
||||
{
|
||||
SingleInstance = true;
|
||||
|
||||
// Unbounded global: every event fires adds an entry for the lifetime of the session.
|
||||
var
|
||||
AllEventIds: List of [Guid];
|
||||
|
||||
[EventSubscriber(ObjectType::Table, Database::"Sales Header", OnAfterInsertEvent, '', false, false)]
|
||||
local procedure OnAfterInsertSalesHeader(var Rec: Record "Sales Header")
|
||||
begin
|
||||
// No cap. No eviction. No reset. A session that sees ten thousand inserts
|
||||
// keeps ten thousand GUIDs in memory until the user signs out.
|
||||
AllEventIds.Add(Rec.SystemId);
|
||||
end;
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
codeunit 50100 "Event Audit Buffer"
|
||||
{
|
||||
SingleInstance = true;
|
||||
|
||||
var
|
||||
RecentEventIds: List of [Guid];
|
||||
MaxBuffered: Integer;
|
||||
|
||||
trigger OnRun()
|
||||
begin
|
||||
MaxBuffered := 50;
|
||||
end;
|
||||
|
||||
[EventSubscriber(ObjectType::Table, Database::"Sales Header", OnAfterInsertEvent, '', false, false)]
|
||||
local procedure OnAfterInsertSalesHeader(var Rec: Record "Sales Header")
|
||||
begin
|
||||
// Bounded cache: drop the oldest entry when the cap is reached.
|
||||
RecentEventIds.Add(Rec.SystemId);
|
||||
if RecentEventIds.Count() > MaxBuffered then
|
||||
RecentEventIds.RemoveAt(1);
|
||||
end;
|
||||
|
||||
procedure ResetAtBusinessProcessBoundary()
|
||||
begin
|
||||
// Explicit reset point at a natural boundary in the workflow.
|
||||
Clear(RecentEventIds);
|
||||
end;
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: performance
|
||||
keywords: [singleinstance, subscriber, event, memory, session]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Avoid growing globals in SingleInstance subscribers
|
||||
|
||||
> Contributions welcome — open a PR to refine or extend this article.
|
||||
|
||||
## Description
|
||||
|
||||
A codeunit with `SingleInstance = true` is allocated once per session and lives until the session ends. Global variables on it are never collected between event fires. A subscriber that accumulates data into a global — buffering payloads, appending to a list, caching without a cap — steadily grows its session footprint for the entire user session. The symptom is memory that only recovers on sign-out, and it surfaces only on long-running sessions.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Keep the global footprint on a SingleInstance subscriber bounded and intentional: a handful of flags, a setup record, a bounded cache with a maximum size. When cross-event state is genuinely needed, define an explicit reset point — end of a business process, arrival of a specific terminal event — that clears the growing collection.
|
||||
|
||||
See sample: `avoid-growing-globals-in-singleinstance-subscribers.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
A SingleInstance subscriber that appends each event's payload to a global list, dictionary, or temporary record without a cap or cleanup trigger. The list grows for hours, memory pressure builds quietly, and debugging the root cause on a live environment is substantially harder than noticing the unbounded append in code review.
|
||||
|
||||
See sample: `avoid-growing-globals-in-singleinstance-subscribers.bad.al`.
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
table 50100 "Item Ledger Entry (Demo)"
|
||||
{
|
||||
fields
|
||||
{
|
||||
field(1; "Entry No."; Integer) { DataClassification = SystemMetadata; }
|
||||
field(2; "Item No."; Code[20]) { DataClassification = CustomerContent; }
|
||||
field(3; "Posting Date"; Date) { DataClassification = CustomerContent; }
|
||||
field(4; Quantity; Decimal) { DataClassification = CustomerContent; }
|
||||
field(5; "Cost Amount"; Decimal) { DataClassification = CustomerContent; }
|
||||
}
|
||||
|
||||
keys
|
||||
{
|
||||
key(PK; "Entry No.") { Clustered = true; }
|
||||
|
||||
// Write-heavy ledger key: aggregates on this key are read rarely relative
|
||||
// to INSERT frequency. Keeping SIFT live on every write is net-negative.
|
||||
key(ByItemAndDate; "Item No.", "Posting Date")
|
||||
{
|
||||
SumIndexFields = Quantity, "Cost Amount";
|
||||
MaintainSIFTIndex = false;
|
||||
}
|
||||
|
||||
// Dashboard-facing key: aggregates read on every session load, underlying
|
||||
// rows updated infrequently. Keeping SIFT live pays for itself.
|
||||
key(ByItem; "Item No.")
|
||||
{
|
||||
SumIndexFields = Quantity;
|
||||
MaintainSIFTIndex = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: performance
|
||||
keywords: [maintainsiftindex, sift, calcsums, flowfield, write-cost]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Choose MaintainSIFTIndex by read-write ratio
|
||||
|
||||
> Contributions welcome — open a PR to refine or extend this article.
|
||||
|
||||
## Description
|
||||
|
||||
`MaintainSIFTIndex` on a key decides whether the SIFT aggregate structure is updated on every `INSERT`, `MODIFY`, and `DELETE` that touches the key's fields. With `Yes`, `CalcSums` and FlowField reads are immediate — but every write pays the cost of updating the aggregate. With `No`, writes are cheaper but the first aggregate read after a change has to rebuild. Neither value is universally correct; the right choice depends on how often the aggregate is read versus how often the underlying rows are written.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Measure read-to-write ratios for the key's SIFT fields under realistic workloads. Set `MaintainSIFTIndex = Yes` only on keys whose aggregates are read far more often than the rows are written (reporting keys on reference tables, dashboards). Set `No` on keys whose rows are written heavily and whose aggregates are read rarely (transactional ledger entries, import-staging tables).
|
||||
|
||||
See sample: `choose-maintainsiftindex-by-read-write-ratio.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Leaving `MaintainSIFTIndex = Yes` on every key by reflex or convenience. On write-heavy tables the cumulative cost turns every INSERT or MODIFY into several additional aggregate updates, and the impact compounds in batch imports and posting routines — often without any code-review signal that the property is the cause.
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
codeunit 50130 "Purge Orders Bad Sample"
|
||||
{
|
||||
procedure PurgeCancelledLines(var SalesLine: Record "Sales Line")
|
||||
begin
|
||||
// Assumes DeleteAll fires OnDelete and cascades to reservation entries
|
||||
// and item applications. It does not: parameterless DeleteAll() is
|
||||
// DeleteAll(false) and skips OnDelete, so the rows vanish but their
|
||||
// dependent records are orphaned.
|
||||
SalesLine.DeleteAll();
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
codeunit 50130 "Purge Orders Good Sample"
|
||||
{
|
||||
procedure PurgeCancelledLines(var SalesLine: Record "Sales Line")
|
||||
begin
|
||||
// These lines have OnDelete cleanup (reservation entries, item
|
||||
// application). Pass true so DeleteAll runs OnDelete per record and the
|
||||
// cleanup actually happens — the row-by-row cost is accepted on purpose.
|
||||
SalesLine.DeleteAll(true);
|
||||
end;
|
||||
|
||||
procedure PurgeStagingBuffer(var TempBuffer: Record "Name/Value Buffer" temporary)
|
||||
begin
|
||||
// No OnDelete logic to run: the fast, set-based form is correct here.
|
||||
TempBuffer.DeleteAll();
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: performance
|
||||
keywords: [deleteall, ondelete, run-trigger, set-based-delete, bulk-delete, triggers, validation]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# DeleteAll skips OnDelete unless you pass RunTrigger
|
||||
|
||||
> Contributions welcome — open a PR to refine or extend this article.
|
||||
|
||||
## Description
|
||||
|
||||
`Record.DeleteAll()` — equivalently `DeleteAll(false)` — translates to a single set-based SQL `DELETE` and **does not** run AL `OnDelete` triggers or field/table validations. Only database-level referential constraints still apply. To run `OnDelete` logic you must call `DeleteAll(true)`, which then deletes record-by-record and forfeits the set-based performance, making it equivalent to a `FindSet` loop calling `Delete(true)`. The common misconception, which training data reproduces, is that `DeleteAll` iterates and fires `OnDelete` per record; it does not. (Parameterless `Delete()` likewise defaults to `Delete(false)` and skips `OnDelete`.)
|
||||
|
||||
## Best Practice
|
||||
|
||||
Use `DeleteAll()` / `DeleteAll(false)` for bulk deletion only when no AL `OnDelete` cleanup is required — it is the fast, set-based form. When `OnDelete` logic must run (cascading deletes, ledger cleanup, integration events), pass `DeleteAll(true)` and accept the row-by-row cost, or refactor the cleanup to run explicitly before the bulk delete.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Calling `DeleteAll()` and assuming dependent records, integration events, or validation side effects are handled by `OnDelete`. The deletion succeeds but the AL-side cleanup never runs, leaving orphaned data — and adding a manual `FindSet`/`Delete` loop "for safety" reintroduces the per-record cost the set-based form was chosen to avoid.
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
codeunit 50100 "Sales Document Processor"
|
||||
{
|
||||
procedure ProcessDocument(var SalesHeader: Record "Sales Header")
|
||||
begin
|
||||
// Single top-level load pulls every field any branch might touch.
|
||||
// Order records pay for Posting Date and Amount Including VAT that
|
||||
// only the Invoice branch reads, and vice versa.
|
||||
SalesHeader.SetLoadFields(
|
||||
"Document Type", "No.", "Sell-to Customer No.",
|
||||
"Order Date", "Shipment Date", "Completely Shipped",
|
||||
"Posting Date", "Amount Including VAT");
|
||||
|
||||
case SalesHeader."Document Type" of
|
||||
SalesHeader."Document Type"::Order:
|
||||
ProcessOrder(SalesHeader);
|
||||
SalesHeader."Document Type"::Invoice:
|
||||
ProcessInvoice(SalesHeader);
|
||||
end;
|
||||
end;
|
||||
|
||||
local procedure ProcessOrder(var SalesHeader: Record "Sales Header") begin end;
|
||||
local procedure ProcessInvoice(var SalesHeader: Record "Sales Header") begin end;
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
codeunit 50100 "Sales Document Processor"
|
||||
{
|
||||
procedure ProcessDocument(var SalesHeader: Record "Sales Header")
|
||||
begin
|
||||
// Tier 1: the discriminator and any fields every branch reads.
|
||||
SalesHeader.SetLoadFields("Document Type", "No.", "Sell-to Customer No.");
|
||||
|
||||
case SalesHeader."Document Type" of
|
||||
SalesHeader."Document Type"::Order:
|
||||
begin
|
||||
// Tier 2: extend the load only on the branch that needs these fields.
|
||||
SalesHeader.SetLoadFields("Order Date", "Shipment Date", "Completely Shipped");
|
||||
ProcessOrder(SalesHeader);
|
||||
end;
|
||||
SalesHeader."Document Type"::Invoice:
|
||||
begin
|
||||
SalesHeader.SetLoadFields("Posting Date", "Amount Including VAT");
|
||||
ProcessInvoice(SalesHeader);
|
||||
end;
|
||||
end;
|
||||
end;
|
||||
|
||||
local procedure ProcessOrder(var SalesHeader: Record "Sales Header") begin end;
|
||||
local procedure ProcessInvoice(var SalesHeader: Record "Sales Header") begin end;
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: performance
|
||||
keywords: [setloadfields, case, conditional, branch, field-loading]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Load common fields before branching on case
|
||||
|
||||
> Contributions welcome — open a PR to refine or extend this article.
|
||||
|
||||
## Description
|
||||
|
||||
When record processing branches on state, different branches typically read different fields. A single `SetLoadFields` at the top listing every field any branch might touch pulls more data than any individual execution path needs — on the hot path, the rest is loaded for nothing. A two-tier approach matches loading to actual usage: load the fields the `case` expression evaluates plus any fields every branch uses, then add a branch-local `SetLoadFields` inside each branch for that branch's extra fields.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Before the `case`, call `SetLoadFields` with the minimal set — the discriminator field and fields common to every branch. Inside each branch, before the first access to a branch-specific field, add a second `SetLoadFields` covering those fields. The platform honors the in-branch call for the next record operation, so the extra data is fetched only when the branch runs.
|
||||
|
||||
See sample: `load-common-fields-before-branching-on-case.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
A single top-level `SetLoadFields` enumerating every field any branch might read. On records whose state routes them to the fast common branch, the rarely-needed fields are still loaded — the optimization becomes a net-neutral or net-negative change on the hot path.
|
||||
|
||||
See sample: `load-common-fields-before-branching-on-case.bad.al`.
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
codeunit 50100 "Item Reindex Queue"
|
||||
{
|
||||
procedure QueueItemsForReindex(CategoryCode: Code[20])
|
||||
var
|
||||
Item: Record Item;
|
||||
ReindexQueue: Codeunit "Reindex Queue";
|
||||
begin
|
||||
// Default full-record load. Description, Unit Price, Inventory, and
|
||||
// every other column are fetched across the wire and held in memory
|
||||
// for the whole loop - the body only ever reads "No.".
|
||||
Item.SetRange("Item Category Code", CategoryCode);
|
||||
|
||||
if Item.FindSet() then
|
||||
repeat
|
||||
ReindexQueue.Enqueue(Item."No.");
|
||||
until Item.Next() = 0;
|
||||
end;
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
codeunit 50100 "Item Reindex Queue"
|
||||
{
|
||||
procedure QueueItemsForReindex(CategoryCode: Code[20])
|
||||
var
|
||||
Item: Record Item;
|
||||
ReindexQueue: Codeunit "Reindex Queue";
|
||||
begin
|
||||
// Only the primary key is used in the loop body; load nothing else.
|
||||
Item.SetLoadFields("No.");
|
||||
Item.SetRange("Item Category Code", CategoryCode);
|
||||
|
||||
if Item.FindSet() then
|
||||
repeat
|
||||
ReindexQueue.Enqueue(Item."No.");
|
||||
until Item.Next() = 0;
|
||||
end;
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: performance
|
||||
keywords: [setloadfields, primary-key, reference, existence-check, memory]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Load only primary key fields for reference work
|
||||
|
||||
> Contributions welcome — open a PR to refine or extend this article.
|
||||
|
||||
## Description
|
||||
|
||||
Work that uses a record only for its identity — passing it to another procedure that will re-fetch what it needs, queueing a key for later processing, running existence checks, or building a reference collection — does not need non-key payload fields. `SetLoadFields` with only the primary key fields loads the minimum that preserves record identity while skipping everything else. On wide tables with large text, BLOB, or media fields the difference in memory and transfer is substantial.
|
||||
|
||||
## Best Practice
|
||||
|
||||
When the iterating code's body touches only primary key fields (or passes the record to another procedure that will apply its own `SetLoadFields`), declare `SetLoadFields` with just the primary key fields before applying filters and calling `FindSet`. Callers downstream that need more fields issue their own `Get` or extend the load explicitly.
|
||||
|
||||
See sample: `load-only-primary-key-fields-for-reference-work.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Using the default full-record load in loops whose body only reads the primary key, or forwards the record to another codeunit that immediately re-queries. The non-key payload is fetched across the wire and held in memory for the duration of the loop, then discarded unread.
|
||||
|
||||
See sample: `load-only-primary-key-fields-for-reference-work.bad.al`.
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
codeunit 50100 "Recent Orders Summary"
|
||||
{
|
||||
procedure SummarizeRecentOrders(StartDate: Date; EndDate: Date)
|
||||
var
|
||||
SalesHeader: Record "Sales Header";
|
||||
begin
|
||||
// "Document Type" and "Document Date" are listed in SetLoadFields even
|
||||
// though they appear only in filters. Per-row values are transferred
|
||||
// for columns the processing body never reads.
|
||||
SalesHeader.SetLoadFields(
|
||||
"Document Type", "Document Date",
|
||||
"No.", "Sell-to Customer No.", "Amount Including VAT");
|
||||
|
||||
SalesHeader.SetRange("Document Type", SalesHeader."Document Type"::Order);
|
||||
SalesHeader.SetRange("Document Date", StartDate, EndDate);
|
||||
|
||||
if SalesHeader.FindSet() then
|
||||
repeat
|
||||
Emit(SalesHeader."No.", SalesHeader."Sell-to Customer No.", SalesHeader."Amount Including VAT");
|
||||
until SalesHeader.Next() = 0;
|
||||
end;
|
||||
|
||||
local procedure Emit(No: Code[20]; CustNo: Code[20]; Amount: Decimal) begin end;
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
codeunit 50100 "Recent Orders Summary"
|
||||
{
|
||||
procedure SummarizeRecentOrders(StartDate: Date; EndDate: Date)
|
||||
var
|
||||
SalesHeader: Record "Sales Header";
|
||||
begin
|
||||
// "Document Type" and "Document Date" are used only in the filters below.
|
||||
// The database index handles them; there is no need to load their values
|
||||
// into AL memory for every row.
|
||||
SalesHeader.SetLoadFields("No.", "Sell-to Customer No.", "Amount Including VAT");
|
||||
|
||||
SalesHeader.SetRange("Document Type", SalesHeader."Document Type"::Order);
|
||||
SalesHeader.SetRange("Document Date", StartDate, EndDate);
|
||||
|
||||
if SalesHeader.FindSet() then
|
||||
repeat
|
||||
Emit(SalesHeader."No.", SalesHeader."Sell-to Customer No.", SalesHeader."Amount Including VAT");
|
||||
until SalesHeader.Next() = 0;
|
||||
end;
|
||||
|
||||
local procedure Emit(No: Code[20]; CustNo: Code[20]; Amount: Decimal) begin end;
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: performance
|
||||
keywords: [setloadfields, filter, field-exclusion, index]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Omit filter-only fields from SetLoadFields
|
||||
|
||||
> Contributions welcome — open a PR to refine or extend this article.
|
||||
|
||||
## Description
|
||||
|
||||
Fields used only in `SetRange` and `SetFilter` do their work at the database level using indexes; their values never need to be loaded into AL memory for the filter to apply. Listing such fields in `SetLoadFields` costs the transfer and memory footprint of every row's value for no functional benefit. Distinguishing filter-only fields from processing fields keeps the loaded column set as narrow as the iterating code actually reads.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Include in `SetLoadFields` exactly the fields the iterating code reads. Fields referenced only in `SetRange`/`SetFilter` stay out of the list — filtering continues to work correctly because the database uses the index. Treat the audit as "what does the `repeat…until` block touch?" rather than "what does this procedure mention?".
|
||||
|
||||
See sample: `omit-filter-only-fields-from-setloadfields.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Listing every field the procedure mentions in `SetLoadFields`, including date-range or status fields that appear only in filters. The loaded record now carries per-row values for columns the processing body never reads, inflating memory and network cost without changing any behavior.
|
||||
|
||||
See sample: `omit-filter-only-fields-from-setloadfields.bad.al`.
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
codeunit 50100 "Document Router"
|
||||
{
|
||||
procedure Route(SalesHeader: Record "Sales Header")
|
||||
begin
|
||||
// Alphabetical ordering. Every Order (the ~85% common case) evaluates
|
||||
// "Credit Memo", "Invoice", and "Quote" before matching.
|
||||
case SalesHeader."Document Type" of
|
||||
SalesHeader."Document Type"::"Credit Memo":
|
||||
RouteCreditMemo(SalesHeader);
|
||||
SalesHeader."Document Type"::Invoice:
|
||||
RouteInvoice(SalesHeader);
|
||||
SalesHeader."Document Type"::Quote:
|
||||
RouteQuote(SalesHeader);
|
||||
SalesHeader."Document Type"::Order:
|
||||
RouteOrder(SalesHeader);
|
||||
SalesHeader."Document Type"::"Return Order":
|
||||
RouteReturnOrder(SalesHeader);
|
||||
end;
|
||||
end;
|
||||
|
||||
local procedure RouteOrder(SalesHeader: Record "Sales Header") begin end;
|
||||
local procedure RouteInvoice(SalesHeader: Record "Sales Header") begin end;
|
||||
local procedure RouteQuote(SalesHeader: Record "Sales Header") begin end;
|
||||
local procedure RouteCreditMemo(SalesHeader: Record "Sales Header") begin end;
|
||||
local procedure RouteReturnOrder(SalesHeader: Record "Sales Header") begin end;
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
codeunit 50100 "Document Router"
|
||||
{
|
||||
procedure Route(SalesHeader: Record "Sales Header")
|
||||
begin
|
||||
// In this deployment Orders are ~85% of posting calls, Invoices ~12%,
|
||||
// and the rest are edge cases. The hot branch goes first.
|
||||
case SalesHeader."Document Type" of
|
||||
SalesHeader."Document Type"::Order:
|
||||
RouteOrder(SalesHeader);
|
||||
SalesHeader."Document Type"::Invoice:
|
||||
RouteInvoice(SalesHeader);
|
||||
SalesHeader."Document Type"::"Credit Memo":
|
||||
RouteCreditMemo(SalesHeader);
|
||||
SalesHeader."Document Type"::"Return Order":
|
||||
RouteReturnOrder(SalesHeader);
|
||||
else
|
||||
Error('Unexpected document type %1', SalesHeader."Document Type");
|
||||
end;
|
||||
end;
|
||||
|
||||
local procedure RouteOrder(SalesHeader: Record "Sales Header") begin end;
|
||||
local procedure RouteInvoice(SalesHeader: Record "Sales Header") begin end;
|
||||
local procedure RouteCreditMemo(SalesHeader: Record "Sales Header") begin end;
|
||||
local procedure RouteReturnOrder(SalesHeader: Record "Sales Header") begin end;
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: performance
|
||||
keywords: [case, branch, frequency, control-flow, hot-path]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Order case branches by frequency
|
||||
|
||||
> Contributions welcome — open a PR to refine or extend this article.
|
||||
|
||||
## Description
|
||||
|
||||
The AL `case` statement evaluates branches in the order they appear. When the distribution of the discriminator is heavily skewed — one or two values handle the vast majority of records, and the rest handle edge cases — the average cost of the statement is dominated by how many branches precede the common one. For evenly distributed discriminators the order does not matter; for skewed distributions it changes the hot-path cost of every call site.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Where the runtime frequency of values is known or measurable, list the common branches first. An `else` arm that handles unexpected values belongs last. When the common branch is also the simplest to evaluate, the placement compounds: the hot path is both short and cheap, and the uncommon branches are never touched on typical records.
|
||||
|
||||
See sample: `order-case-branches-by-frequency.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Ordering branches alphabetically, by enum declaration order, or by "logical grouping" when the runtime distribution is heavily skewed. Every common record pays the cost of evaluating every uncommon branch first; on a posting routine processing thousands of rows the overhead is measurable.
|
||||
|
||||
See sample: `order-case-branches-by-frequency.bad.al`.
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
codeunit 50132 "LoadFields Bad Sample"
|
||||
{
|
||||
procedure TotalReleasedAmount(): Decimal
|
||||
var
|
||||
SalesHeader: Record "Sales Header";
|
||||
Total: Decimal;
|
||||
begin
|
||||
// "Currency Code" is not listed. The helper takes SalesHeader BY VALUE,
|
||||
// so the copy neither shares the load set nor updates the enumerator:
|
||||
// reading the unlisted field triggers a fresh JIT load (an extra Get)
|
||||
// on EVERY iteration, quietly reversing the saving.
|
||||
SalesHeader.SetLoadFields("Amount Including VAT", Status);
|
||||
if SalesHeader.FindSet() then
|
||||
repeat
|
||||
if IsLocalReleased(SalesHeader) then
|
||||
Total += SalesHeader."Amount Including VAT";
|
||||
until SalesHeader.Next() = 0;
|
||||
exit(Total);
|
||||
end;
|
||||
|
||||
local procedure IsLocalReleased(SalesHeader: Record "Sales Header"): Boolean
|
||||
begin
|
||||
exit((SalesHeader.Status = SalesHeader.Status::Released) and
|
||||
(SalesHeader."Currency Code" = ''));
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
codeunit 50132 "LoadFields Good Sample"
|
||||
{
|
||||
procedure TotalReleasedAmount(): Decimal
|
||||
var
|
||||
SalesHeader: Record "Sales Header";
|
||||
Total: Decimal;
|
||||
begin
|
||||
// Every field read anywhere downstream is listed — including the one
|
||||
// the by-var helper reads — so no JIT load is ever triggered.
|
||||
SalesHeader.SetLoadFields("Amount Including VAT", Status, "Currency Code");
|
||||
if SalesHeader.FindSet() then
|
||||
repeat
|
||||
if IsLocalReleased(SalesHeader) then
|
||||
Total += SalesHeader."Amount Including VAT";
|
||||
until SalesHeader.Next() = 0;
|
||||
exit(Total);
|
||||
end;
|
||||
|
||||
local procedure IsLocalReleased(var SalesHeader: Record "Sales Header"): Boolean
|
||||
begin
|
||||
exit((SalesHeader.Status = SalesHeader.Status::Released) and
|
||||
(SalesHeader."Currency Code" = ''));
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: performance
|
||||
keywords: [setloadfields, partial-records, just-in-time-load, jit-load, round-trip, pass-by-value, enumerator]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Reading an unlisted field after SetLoadFields triggers a JIT load
|
||||
|
||||
> Contributions welcome — open a PR to refine or extend this article.
|
||||
|
||||
## Description
|
||||
|
||||
`SetLoadFields` loads only the named fields, but the trap is what happens when code later reads a field that was *not* listed: the platform silently issues a **just-in-time (JIT) load** — an implicit `Get` that fetches the missing field(s) in a second database round-trip. A single JIT load can erase the saving; the real danger is a JIT that repeats per record. The optimization is only a win if the listed set covers every field touched anywhere downstream, not just in the immediate code block.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Before adding `SetLoadFields`, audit the *whole* access lifecycle of the record variable — every field read in the loop body, in called procedures, in `OnValidate`/`OnAfterGetRecord`, and in anything that receives the record — and list all of them via `SetLoadFields`/`AddLoadFields`. Be especially careful when passing a partial record **by value**: the copy does not share the load set and its enumerator is not updated, so a helper that reads an unlisted field re-triggers the JIT on *every* iteration. Pass by `var` where you can (a JIT then updates the enumerator, so later iterations don't re-load), or call `AddLoadFields` before passing by value. If you cannot enumerate the fields confidently, prefer not to call `SetLoadFields` at all. See the existing guidance on when partial records pay off (`use-setloadfields-for-partial-records`).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Adding `SetLoadFields(Field1, Field2)` at the top of a loop, then reading `Field3` deeper in the body or inside a by-value helper. The code compiles and returns correct data, but pays a hidden JIT round-trip — and in the by-value case it repeats once per row, quietly reversing the gain. JIT loads also introduce `Inconsistent read` / record-modified race errors that a full non-partial load avoids. Reviewer signal: a `SetLoadFields` list that omits a field later read through that record variable, especially a record passed by value to a procedure that reads a field the caller never listed.
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
codeunit 50100 "Stale Quote Cleanup"
|
||||
{
|
||||
procedure ClearExpiredQuotes(CutoffDate: Date)
|
||||
var
|
||||
SalesHeader: Record "Sales Header";
|
||||
begin
|
||||
SalesHeader.SetRange("Document Type", SalesHeader."Document Type"::Quote);
|
||||
SalesHeader.SetFilter("Document Date", '<%1', CutoffDate);
|
||||
SalesHeader.SetRange(Status, SalesHeader.Status::Open);
|
||||
|
||||
// One SQL DELETE per row. On a 10k-row cleanup, minutes instead of
|
||||
// under a second - and the OnDelete trigger has no logic this call
|
||||
// needs to run.
|
||||
if SalesHeader.FindSet() then
|
||||
repeat
|
||||
SalesHeader.Delete();
|
||||
until SalesHeader.Next() = 0;
|
||||
end;
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
codeunit 50100 "Stale Quote Cleanup"
|
||||
{
|
||||
procedure ClearExpiredQuotes(CutoffDate: Date)
|
||||
var
|
||||
SalesHeader: Record "Sales Header";
|
||||
begin
|
||||
// OnDelete on Sales Header carries no logic this call depends on:
|
||||
// expired quotes have no ledger entries, shipments, or downstream state.
|
||||
SalesHeader.SetRange("Document Type", SalesHeader."Document Type"::Quote);
|
||||
SalesHeader.SetFilter("Document Date", '<%1', CutoffDate);
|
||||
SalesHeader.SetRange(Status, SalesHeader.Status::Open);
|
||||
|
||||
// Single SQL DELETE. Orders of magnitude faster than FindSet + Delete
|
||||
// once the filtered set exceeds a handful of rows.
|
||||
SalesHeader.DeleteAll();
|
||||
end;
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: performance
|
||||
keywords: [deleteall, bulk-delete, sql, ondelete, trigger-bypass]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Use DeleteAll for filtered bulk deletion
|
||||
|
||||
> Contributions welcome — open a PR to refine or extend this article.
|
||||
|
||||
## Description
|
||||
|
||||
`DeleteAll` translates to a single SQL `DELETE` with the record variable's current filters applied as the WHERE clause. A loop of `FindSet` + `Delete` instead issues one SQL statement per row. On any dataset larger than a handful of records, the gap is an order of magnitude or more. The tradeoff is that `DeleteAll` bypasses the `OnDelete` table trigger, so the decision hinges on whether that trigger's logic is required for this specific deletion.
|
||||
|
||||
## Best Practice
|
||||
|
||||
After narrowing the record set with `SetRange`/`SetFilter`, use `DeleteAll` whenever the `OnDelete` trigger has no logic that this call depends on — typically the case for housekeeping routines, staging-table cleanup, and deletions already validated upstream. When the trigger IS required, either keep the explicit loop-plus-`Delete` pattern and comment why, or pre-run the trigger logic against a temporary buffer and then `DeleteAll` the primary table.
|
||||
|
||||
See sample: `use-deleteall-for-filtered-bulk-deletion.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Iterating with `FindSet` + `Delete` to clear a filtered set of records that carry no meaningful `OnDelete` logic. Every row pays a full AL round-trip; on a ten-thousand-row cleanup the loop can take minutes where `DeleteAll` takes under a second.
|
||||
|
||||
See sample: `use-deleteall-for-filtered-bulk-deletion.bad.al`.
|
||||
21
community/knowledge/security/secrets-isolated-storage.bad.al
Normal file
21
community/knowledge/security/secrets-isolated-storage.bad.al
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
table 50134 "Api Setup Bad Sample"
|
||||
{
|
||||
fields
|
||||
{
|
||||
field(1; "Primary Key"; Code[10]) { }
|
||||
|
||||
// A secret in an ordinary Text field is readable by anyone with table
|
||||
// permission, ships in RapidStart packages and Excel exports, and
|
||||
// appears in record snapshots. No DataClassification tag makes it safe;
|
||||
// it belongs in IsolatedStorage instead.
|
||||
field(10; "API Key"; Text[250])
|
||||
{
|
||||
DataClassification = CustomerContent;
|
||||
}
|
||||
}
|
||||
|
||||
keys
|
||||
{
|
||||
key(PK; "Primary Key") { Clustered = true; }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
codeunit 50134 "Api Credential Good Sample"
|
||||
{
|
||||
procedure StoreApiKey(ApiKey: SecretText)
|
||||
begin
|
||||
// Credentials live in IsolatedStorage, invisible to record reads, API
|
||||
// pages, RapidStart packages, and Excel export.
|
||||
IsolatedStorage.Set('ExternalApiKey', ApiKey, DataScope::Module);
|
||||
end;
|
||||
|
||||
procedure GetApiKey() ApiKey: SecretText
|
||||
begin
|
||||
if not IsolatedStorage.Get('ExternalApiKey', DataScope::Module, ApiKey) then
|
||||
Error('The external API key has not been configured.');
|
||||
end;
|
||||
}
|
||||
24
community/knowledge/security/secrets-isolated-storage.md
Normal file
24
community/knowledge/security/secrets-isolated-storage.md
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: security
|
||||
keywords: [isolatedstorage, secrets, api-key, oauth-token, connection-string, table-field, credentials]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# A secret belongs in IsolatedStorage, never in a table field
|
||||
|
||||
> Contributions welcome — open a PR to refine or extend this article.
|
||||
|
||||
## Description
|
||||
|
||||
API keys, OAuth tokens, client secrets, and connection strings must not be stored in an ordinary table `Text` field — not even on a hidden setup table. A regular field is exposed through record reads, page display, RapidStart and Excel export, report datasets, and surfaces in `DataClassification` review; anyone with table permission can read it. The correct home is `IsolatedStorage`, which is invisible to database queries, API pages, and configuration packages. The storage-*location* decision is the rule here; how to scope and encrypt the value once it is in IsolatedStorage is covered separately.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Persist every credential with `IsolatedStorage`, write it at the point of capture, and read it only when needed. For the per-secret details — choosing the right `DataScope`, encrypting at rest, and typing the value as `SecretText` so it cannot leak into logs — follow `isolatedstorage-datascope-module-vs-company`, `isolatedstorage-setencrypted-for-sensitive-values`, and `secrettext-for-credentials`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
A "Setup" or "Connection" table carrying a `Text` field named `API Key`, `Password`, or `Client Secret`. The value is now readable by any object with table permission, ships in RapidStart packages and Excel exports, and appears in record snapshots — a credential disclosure that no amount of encryption-in-transit elsewhere makes up for. Reviewer signal: a secret-shaped field declared on a table instead of an `IsolatedStorage` call.
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
codeunit 50136 "Telemetry Bad Sample"
|
||||
{
|
||||
procedure LogSyncDiagnostic(RecordsProcessed: Integer)
|
||||
var
|
||||
Dimensions: Dictionary of [Text, Text];
|
||||
begin
|
||||
Dimensions.Add('recordsProcessed', Format(RecordsProcessed));
|
||||
|
||||
// TelemetryScope::All pushes this internal diagnostic into every
|
||||
// customer's Application Insights too, inflating their ingestion cost
|
||||
// and burying their own signals in noise. ExtensionPublisher is the
|
||||
// correct scope for publisher-only diagnostics.
|
||||
Session.LogMessage(
|
||||
'SYNC001', 'Nightly sync completed.', Verbosity::Normal,
|
||||
DataClassification::SystemMetadata, TelemetryScope::All, Dimensions);
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
codeunit 50136 "Telemetry Good Sample"
|
||||
{
|
||||
procedure LogSyncDiagnostic(RecordsProcessed: Integer)
|
||||
var
|
||||
Dimensions: Dictionary of [Text, Text];
|
||||
begin
|
||||
Dimensions.Add('recordsProcessed', Format(RecordsProcessed));
|
||||
|
||||
// A diagnostic only the publisher acts on: route it to the publisher's
|
||||
// own Application Insights, not the customer's environment resource.
|
||||
Session.LogMessage(
|
||||
'SYNC001', 'Nightly sync completed.', Verbosity::Normal,
|
||||
DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, Dimensions);
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: telemetry
|
||||
keywords: [telemetry, session-logmessage, telemetryscope, application-insights, extensionpublisher, ingestion-cost]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Default TelemetryScope to ExtensionPublisher, not All
|
||||
|
||||
> Contributions welcome — open a PR to refine or extend this article.
|
||||
|
||||
## Description
|
||||
|
||||
The `TelemetryScope` parameter of `Session.LogMessage` (and `LogError`) controls *where* a custom telemetry signal is routed, not just whether it is emitted. `TelemetryScope::ExtensionPublisher` sends the signal only to the extension publisher's own Application Insights resource. `TelemetryScope::All` sends it to **both** the publisher's resource **and** the customer's environment-level Application Insights resource. The distinction is easy to get wrong because both values compile and both "emit telemetry" — but `All` silently adds to the customer's ingestion volume and cost.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Default to `TelemetryScope::ExtensionPublisher` for diagnostic telemetry that only the publisher acts on. Reserve `TelemetryScope::All` for signals the customer's own administrators are expected to monitor and act on (for example, a business event surfaced to their environment telemetry). Treat the choice as a deliberate routing decision per signal, not a copy-paste default.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Emitting all custom telemetry with `TelemetryScope::All` "to be safe." This pushes the publisher's internal diagnostics into every customer's Application Insights, inflating their ingestion cost and burying their own signals in noise — a footgun a code reviewer can catch by flagging `All` on any signal the customer would not act on.
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
page 50100 "Integration Log Entries"
|
||||
{
|
||||
PageType = List;
|
||||
SourceTable = "Integration Log Entry";
|
||||
ApplicationArea = All;
|
||||
UsageCategory = History;
|
||||
Caption = 'Integration Log Entries';
|
||||
|
||||
// No descending default sort: the page opens oldest-first.
|
||||
|
||||
layout
|
||||
{
|
||||
area(Content)
|
||||
{
|
||||
repeater(General)
|
||||
{
|
||||
field("Entry No."; Rec."Entry No.")
|
||||
{
|
||||
}
|
||||
field(Status; Rec.Status)
|
||||
{
|
||||
}
|
||||
field(Message; Rec.Message)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
page 50100 "Integration Log Entries"
|
||||
{
|
||||
PageType = List;
|
||||
SourceTable = "Integration Log Entry";
|
||||
ApplicationArea = All;
|
||||
UsageCategory = History;
|
||||
Caption = 'Integration Log Entries';
|
||||
|
||||
// Historical pages should open with the newest records first.
|
||||
SourceTableView = order(descending);
|
||||
|
||||
layout
|
||||
{
|
||||
area(Content)
|
||||
{
|
||||
repeater(General)
|
||||
{
|
||||
field("Entry No."; Rec."Entry No.")
|
||||
{
|
||||
}
|
||||
field(Status; Rec.Status)
|
||||
{
|
||||
}
|
||||
field(Message; Rec.Message)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: ui
|
||||
keywords: [historical-table, list-page, descending-sort, log-entry, ledger-entry, archive]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Default descending sort on historical pages
|
||||
|
||||
## Description
|
||||
Historical list pages should default to showing the newest records first. On pages such as log entries, ledger entries, archives, and other history lists, an oldest-first default order does not align with the primary use of the page, which is typically to review recent activity.
|
||||
|
||||
## Best Practice
|
||||
Set descending sort as the default on list pages whose primary purpose is to present historical records. This is the expected default for entry, log, archive, and posted-history pages unless there is a specific requirement to begin with the oldest record.
|
||||
|
||||
See sample: `default-descending-sort-on-historical-pages.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
Using an oldest-first default order on a historical list page where users are primarily interested in recent activity. Typical signs include history, log, or entry pages that regularly need to be re-sorted to descending during normal use.
|
||||
|
||||
See sample: `default-descending-sort-on-historical-pages.bad.al`.
|
||||
20
community/knowledge/ui/factbox-design.md
Normal file
20
community/knowledge/ui/factbox-design.md
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
---
|
||||
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.
|
||||
20
community/knowledge/ui/fasttab-field-importance.md
Normal file
20
community/knowledge/ui/fasttab-field-importance.md
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: ui
|
||||
keywords: [importance, promoted, additional, fasttab, show-more, summary-line, progressive-disclosure, field-visibility]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
# Set Field Importance To Drive FastTab Progressive Disclosure
|
||||
|
||||
> Contributions welcome — open a PR to refine or extend this article.
|
||||
|
||||
## Description
|
||||
A FastTab field's `Importance` property controls whether the field is visible immediately, hidden behind "Show more", or surfaced on the collapsed FastTab header summary line. The three values are `Standard` (the default, shown in the expanded FastTab), `Promoted` (also rendered on the FastTab header when the tab is collapsed), and `Additional` (hidden until the user clicks "Show more"). Misusing these values either clutters the summary line or buries fields users need on every transaction, so reviewers should treat `Importance` as a deliberate layout decision rather than an afterthought.
|
||||
|
||||
## Best Practice
|
||||
Promote only the two to four identifying fields per FastTab that users must read at a glance without expanding — name, status, key amount — so the collapsed header summary line stays scannable. Leave the everyday working fields at `Standard`, and push rarely-touched fields (legacy compatibility fields, system timestamps, seldom-changed configuration) to `Additional`. Note that field-level `Importance = Promoted` is unrelated to action promotion on the page action bar; it governs FastTab field visibility only. Do not rely on initial expand or collapse state, which you cannot set programmatically and which the platform may personalize per user — design assuming any FastTab may be collapsed.
|
||||
|
||||
## Anti Pattern
|
||||
Setting `Importance = Promoted` on most fields of a FastTab so "everything is important" defeats progressive disclosure: the collapsed summary line overflows and conveys nothing at a glance. The opposite failure is marking frequently edited fields `Additional`, forcing users to click "Show more" on every record. A detectable signal is a FastTab whose fields are nearly all `Promoted`, or a FastTab containing only `Additional` fields, which renders as an empty tab until expanded.
|
||||
20
community/knowledge/ui/page-background-tasks.md
Normal file
20
community/knowledge/ui/page-background-tasks.md
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: ui
|
||||
keywords: [enqueuebackgroundtask, async-calculation, child-session, factbox, cue-tile, onaftergetcurrrecord, responsive-page, read-only]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
# Offload Slow Read-Only Page Calculations To Background Tasks
|
||||
|
||||
> Contributions welcome — open a PR to refine or extend this article.
|
||||
|
||||
## Description
|
||||
Pages that compute statistics, aggregates, or external lookups inline block the page from rendering until the calculation finishes, producing a visible freeze on FactBoxes, cue tiles, and calculated fields. Business Central provides page background tasks: `CurrPage.EnqueueBackgroundTask` runs a dedicated codeunit in a read-only child session and returns values via `OnPageBackgroundTaskCompleted`, so the page opens immediately and fills in computed values as they arrive. This matters because users should never wait on a calculation they may not need. The mechanism has specific rules that are easy to get wrong, which is why it warrants an explicit pattern.
|
||||
|
||||
## Best Practice
|
||||
Move any noticeable read-only computation off the synchronous render path into a background task. Enqueue from `OnAfterGetCurrRecord` so the task is tied to the currently focused record, and pass small payloads through the `Dictionary of [Text, Text]` input/output, converting types with `Format` and `Evaluate`. Keep each task focused on one value or a small related set rather than one large task, and show a placeholder until results land. Because tasks auto-cancel when the page closes, the record changes, or a same-ID task is re-enqueued, always supply sensible defaults and handle the timeout path in `OnPageBackgroundTaskError` — never let critical functionality depend on completion. For tests, drive the task synchronously with `RunPageBackgroundTask`.
|
||||
|
||||
## Anti Pattern
|
||||
Enqueuing from `OnAfterGetRecord` on a list page fires the task for every row, and each cancels the instant the selection moves to the next row — pure wasted child-session churn; a reviewer spots `EnqueueBackgroundTask` called from `OnAfterGetRecord` (or from `OnOpenPage`, where the record context is not yet stable). The other tell is a task codeunit attempting a database write or `Modify`: background tasks run read-only and the write fails at runtime. Inline heavy calculation directly in `OnAfterGetCurrRecord` with no task at all is the baseline smell — it reintroduces the page freeze the feature exists to remove.
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
---
|
||||
bc-version: [21..]
|
||||
domain: ui
|
||||
keywords: [actionref, promoted-actions, area-promoted, promotedcategory, promotedonly, action-bar, legacy-syntax]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
# Promote Actions With The Modern `actionref` Syntax, Never The Legacy `Promoted` Properties
|
||||
|
||||
> Contributions welcome — open a PR to refine or extend this article.
|
||||
|
||||
## Description
|
||||
Business Central 2022 release wave 2 (v21) introduced the `area(Promoted)` block with `actionref` as the way to promote page actions, separating an action's definition from its promotion. The older approach set `Promoted`, `PromotedCategory`, `PromotedOnly`, and `PromotedIsBig` directly on each action. The two syntaxes cannot be mixed within a single page or page extension, and choosing the legacy one entangles definition with presentation, making the action bar harder to maintain and to extend.
|
||||
|
||||
## Best Practice
|
||||
For new pages and page extensions, define actions in their normal `area`, then promote selected ones with `actionref` inside `area(Promoted)`, grouping them under explicit categories such as `Category_Process` and entity-named groups. This keeps each action defined once and referenced where it should appear, supports split buttons via `ShowAs`, and lets an extension promote a base action without redefining it. When extending a page, you may use modern syntax even if the base page used legacy properties (and vice versa) — the no-mixing rule is per-object, not per-dependency-tree.
|
||||
|
||||
## Anti Pattern
|
||||
Setting `Promoted = true` (with `PromotedCategory`, `PromotedOnly`, or `PromotedIsBig`) on actions in new code, or attempting to combine those properties with an `area(Promoted)` block in the same object — the latter fails to compile. The reviewer signal is any `Promoted`-prefixed property on an action in a newly authored page or page extension; flag it and convert to `actionref` (VS Code offers an automated conversion). Note separately that once an action is promoted in a published app, removing the promotion is a breaking change (AS0031/AW0013), so promote conservatively rather than walking it back later.
|
||||
20
community/knowledge/ui/promoted-action-groups.md
Normal file
20
community/knowledge/ui/promoted-action-groups.md
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
---
|
||||
bc-version: [21..]
|
||||
domain: ui
|
||||
keywords: [action-groups, area-promoted, actionref, showas, split-button, group-caption, navigate-group, entity-group]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
# Use Standard Promoted Action Group Names And Placements
|
||||
|
||||
> Contributions welcome — open a PR to refine or extend this article.
|
||||
|
||||
## Description
|
||||
Business Central ships a fixed vocabulary of promoted action groups, and users build muscle memory around where each kind of action lives. When you define `area(Promoted)` groups, reusing the standard caption and placement for a given action class makes the page feel native; inventing your own caption or putting an action in the wrong group forces every user to relearn your page. Frontier models tend to emit plausible-but-nonstandard captions (`Go To`, `Vendor Actions`, `Related`) instead of the established BC names, which is exactly what breaks cross-page consistency.
|
||||
|
||||
## Best Practice
|
||||
Map each action to its conventional group and use the exact standard caption: `Home`/`Process` for data-modifying and workflow actions (entity/card/document pages use `Home`, lists and worksheets use `Process`); an entity-named group (`Customer`, `Item`, `Order`) for navigation tied to the current record (statistics, ledger entries, dimensions); `Navigate` for related pages that are useful regardless of the selected record; `Report` for printing and analysis; and the workflow groups `Posting`, `Release`, `Approve`, `Request Approval`, and `Prepare` for their respective document lifecycle actions. Only `Posting` (Post / Post and Print / Preview) and `Release` (Release / Reopen) should render as split buttons via `ShowAs = SplitButton`; everything else is a normal dropdown. Within a common group keep the same action sequence you see on the matching base-app page (e.g. mirror Sales Order for a sales document) so order stays predictable.
|
||||
|
||||
## Anti Pattern
|
||||
Custom captions for what is really a standard group (`Vendor Actions` instead of the `Vendor` entity group, `Go To` instead of `Navigate`), posting or statistics actions dropped into the wrong group, or many tiny one-action groups that fragment the ribbon. The reviewer signal is an `area(Promoted)` block whose `group` captions do not match the base-application names for the same page type, or a `ShowAs = SplitButton` on anything other than `Posting`/`Release`.
|
||||
20
community/knowledge/ui/split-button-standard-groups.md
Normal file
20
community/knowledge/ui/split-button-standard-groups.md
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
---
|
||||
bc-version: [21..]
|
||||
domain: ui
|
||||
keywords: [showas, splitbutton, promoted-actions, actionref, posting-actions, release-action, action-bar]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
# Reserve `ShowAs = SplitButton` For Standard Posting And Release Groups
|
||||
|
||||
> Contributions welcome — open a PR to refine or extend this article.
|
||||
|
||||
## Description
|
||||
Setting `ShowAs = SplitButton` on a `group` inside `area(Promoted)` renders a primary one-click button with a dropdown of related alternatives, where the FIRST `actionref` in the group becomes the primary (left) button. Business Central users have learned this pattern from the two standard groups it ships with — Posting (`Post`, `Post and Print`, `Post and Send`, `Preview Posting`) and Release (`Release`, `Reopen`). Inventing new split-button groups for unrelated actions, or ordering the dropdown so the most common action is not first, breaks that learned muscle memory and makes users guess what the left button will do.
|
||||
|
||||
## Best Practice
|
||||
Use `ShowAs = SplitButton` only when all hold: the actions are genuinely variations of one operation, there is an obvious most-frequent primary, and the dropdown stays at roughly two to four items. Place that primary action as the first `actionref` so it occupies the left button; order the remaining refs by descending frequency. Outside the Posting and Release conventions, treat a new split-button group as something to justify, not a default — a plain promoted group or category is usually the safer choice and keeps the action bar predictable.
|
||||
|
||||
## Anti Pattern
|
||||
Grouping unrelated actions under one split button to save toolbar space — for example pairing `Post` with `Delete`, or `Release` with `Print` — so the left button performs whatever happens to be listed first. The reviewer signal is a group with `ShowAs = SplitButton` whose member `actionref`s do not share a verb or workflow, a primary that is not the most common action, or a dropdown padded well beyond four items. Each makes the immediate left-click unpredictable and costs the user the very click the split button was meant to save.
|
||||
20
community/knowledge/upgrade/no-series-bc24-migration.md
Normal file
20
community/knowledge/upgrade/no-series-bc24-migration.md
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
---
|
||||
bc-version: [24..]
|
||||
domain: upgrade
|
||||
keywords: [no-series, noseriesmanagement, codeunit-310, getnextno, peeknextno, testmanual, arerelated, no-series-batch, business-foundation, obsolete-codeunit]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
# Migrate No. Series Calls From NoSeriesManagement To The BC24 No. Series Module
|
||||
|
||||
> Contributions welcome — open a PR to refine or extend this article.
|
||||
|
||||
## Description
|
||||
In BC24 (2024 Wave 1) Microsoft moved number generation into the Business Foundation `No. Series` codeunit (310) and obsoleted the legacy `NoSeriesManagement` codeunit (396). Code that still declares `Codeunit NoSeriesManagement` or calls its methods compiles only against the temporary obsolete shim and will break once Microsoft removes it. The new API is not a drop-in rename: the facade exposes a small, specific set of real methods, parameter shapes changed, and the old single method that both previewed and consumed a number was split into two. Getting the mapping wrong silently consumes numbers when you only meant to preview, leaving gaps in the sequence.
|
||||
|
||||
## Best Practice
|
||||
Replace the `NoSeriesManagement` variable with `Codeunit "No. Series"` and map each call deliberately using the facade's actual methods — `GetNextNo`, `PeekNextNo`, `GetLastNoUsed`, `TestManual`, `IsManual`, and `AreRelated`. Use `GetNextNo(SeriesCode, RefDate)` only when you intend to consume and advance the series for a committed document, and `PeekNextNo(SeriesCode, RefDate)` for any display, validation, or preview-posting path where you must not consume. Replace `InitSeries` with a guarded `if "No." = '' then "No." := NoSeries.GetNextNo(...)`. Map `SelectSeries` to `LookupRelatedNoSeries`, relationship checks the old code did by hand to `AreRelated`, and both `TestManual` and `ManualNoAllowed` to `TestManual` (which now raises its own error). For multi-document allocation use `Codeunit "No. Series - Batch"` and persist its state once with `SaveState` instead of committing per iteration. Treat the migration as an opportunity to add preview-posting support, since `PeekNextNo` now makes that trivial.
|
||||
|
||||
## Anti Pattern
|
||||
Mechanically swapping the codeunit reference while keeping the old boolean call shape. The legacy `GetNextNo(Series, Date, false)` meant "peek" and `GetNextNo(Series, Date, true)` meant "consume"; the new `GetNextNo` always consumes and takes no boolean. Equally common is inventing validation helpers such as `IsValidNo`, `VerifySeriesExists`, `IsValidForDate`, or `TryGetNextNo` — these names are not on the `No. Series` or `No. Series - Batch` codeunits and will not compile, a frequent LLM hallucination for this migration. A reviewer can detect the defect by the residual third boolean argument, by any lingering `NoSeriesMgt`/`NoSeriesManagement` identifier, by a fabricated method name, or by an `OnBeforeGetNextNo`/`OnAfterGetNextNo` subscriber — those events were removed without replacement, so that logic must be rewritten as inline pre/post procedures, not re-subscribed. A subtler signal is `GetNextNo` used merely to display a preview, which silently advances the series and creates number gaps; that should be `PeekNextNo`.
|
||||
Loading…
Add table
Add a link
Reference in a new issue