diff --git a/microsoft/knowledge/performance/add-sift-keys-for-flowfields.good.al b/microsoft/knowledge/performance/add-sift-keys-for-flowfields.good.al deleted file mode 100644 index affc10d..0000000 --- a/microsoft/knowledge/performance/add-sift-keys-for-flowfields.good.al +++ /dev/null @@ -1,10 +0,0 @@ -tableextension 50118 "Perf Sample SIFTKey" extends "Cust. Ledger Entry" -{ - keys - { - key(PerfSampleOpenByCustomer; "Customer No.", Open, "Posting Date") - { - SumIndexFields = "Remaining Amt. (LCY)"; - } - } -} diff --git a/microsoft/knowledge/performance/add-sift-keys-for-flowfields.md b/microsoft/knowledge/performance/add-sift-keys-for-flowfields.md deleted file mode 100644 index 1a1843f..0000000 --- a/microsoft/knowledge/performance/add-sift-keys-for-flowfields.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [sift, sumindexfields, flowfield, key, aa0232] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Add SIFT keys for FlowField aggregations - -## Description - -CodeCop rule AA0232 checks that FlowFields backed by CalcSums or aggregation CalcFormula are supported by a key whose SumIndexFields include the summed field and whose key prefix matches the formula's filter fields. Without a SIFT key the platform falls back to a full aggregation on every read — typically invisible in development and catastrophic in production. - -## Best Practice - -For each Sum-style FlowField, ensure the source table has a key whose leading fields match the FlowField's CalcFormula WHERE clause and whose SumIndexFields list includes the summed field. Table extensions adding new FlowFields are responsible for adding the supporting key. - -See sample: `add-sift-keys-for-flowfields.good.al`. - -## Anti Pattern - -Declaring a FlowField on a hot table without checking whether a supporting SIFT key exists ships a latent scan into every list page and report that touches the field. - diff --git a/microsoft/knowledge/performance/addloadfields-in-report-onpredataitem.bad.al b/microsoft/knowledge/performance/addloadfields-in-report-onpredataitem.bad.al new file mode 100644 index 0000000..2fd95ac --- /dev/null +++ b/microsoft/knowledge/performance/addloadfields-in-report-onpredataitem.bad.al @@ -0,0 +1,14 @@ +report 50221 "Perf Sample AddLoadFields Bad" +{ + dataset + { + // No AddLoadFields: every Cust. Ledger Entry column ships per row, even though + // only three columns feed the layout. + dataitem(CustLedgerEntry; "Cust. Ledger Entry") + { + column(CustomerNo; "Customer No.") { } + column(PostingDate; "Posting Date") { } + column(Amount; Amount) { } + } + } +} diff --git a/microsoft/knowledge/performance/use-addloadfields-in-report-layouts.good.al b/microsoft/knowledge/performance/addloadfields-in-report-onpredataitem.good.al similarity index 76% rename from microsoft/knowledge/performance/use-addloadfields-in-report-layouts.good.al rename to microsoft/knowledge/performance/addloadfields-in-report-onpredataitem.good.al index 278957d..3267418 100644 --- a/microsoft/knowledge/performance/use-addloadfields-in-report-layouts.good.al +++ b/microsoft/knowledge/performance/addloadfields-in-report-onpredataitem.good.al @@ -1,8 +1,8 @@ -report 50112 "Perf Sample AddLoadFields Good" +report 50220 "Perf Sample AddLoadFields Good" { dataset { - dataitem(Cust; "Cust. Ledger Entry") + dataitem(CustLedgerEntry; "Cust. Ledger Entry") { column(CustomerNo; "Customer No.") { } column(PostingDate; "Posting Date") { } diff --git a/microsoft/knowledge/performance/addloadfields-in-report-onpredataitem.md b/microsoft/knowledge/performance/addloadfields-in-report-onpredataitem.md new file mode 100644 index 0000000..aa811ce --- /dev/null +++ b/microsoft/knowledge/performance/addloadfields-in-report-onpredataitem.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [report, addloadfields, onpredataitem, dataitem, partial-record, layout] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# In reports, declare the fields the layout needs with AddLoadFields + +## Description + +Reports iterate dataitems on potentially large source tables and pipe rows into a layout. The partial-record optimization is the same idea as `use-setloadfields-for-partial-records.md`, but the API is different: per the upstream guidance, "for reports, use `AddLoadFields()` in `OnPreDataItem` trigger to add fields needed by the layout." `AddLoadFields` is additive — call it for each field the layout consumes — and runs once per dataitem before iteration begins. + +## Best Practice + +In each dataitem's `OnPreDataItem` trigger, list the columns the layout binds to via `AddLoadFields(, , ...)`. The platform then materializes only those columns per row. Treat the layout column list as the spec: every column the layout uses must be added; columns the layout does not use should not be added. + +See sample: `addloadfields-in-report-onpredataitem.good.al`. + +## Anti Pattern + +Relying on the dataitem's default to load every field. On a report bound to a ledger-scale table this transfers an entire row per iteration, of which the layout reads a fraction. + +See sample: `addloadfields-in-report-onpredataitem.bad.al`. diff --git a/microsoft/knowledge/performance/admin-and-migration-pages-tolerate-lower-perf.md b/microsoft/knowledge/performance/admin-and-migration-pages-tolerate-lower-perf.md new file mode 100644 index 0000000..3e40ef3 --- /dev/null +++ b/microsoft/knowledge/performance/admin-and-migration-pages-tolerate-lower-perf.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: performance +keywords: [admin-page, migration, wizard, hybrid, permissions, lower-severity] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Admin and migration pages tolerate lower performance discipline + +## Description + +Some pages run rarely and against small datasets, and the upstream guidance explicitly calls for treating them as lower severity. Per the review checklist, "Admin/migration pages (`Admin`, `Setup`, `Wizard`, `Migration`, `HybridBC14`, `HybridSL`, `HybridGP` namespaces, `Permissions`/`PermissionSet` pages) are infrequently used with small datasets — apply lower severity." The same logic covers one-time wizards and tenant-bootstrap routines: the code path runs a handful of times in the lifetime of a tenant, against a bounded dataset, by an administrator. + +## Best Practice + +When triaging a finding on an admin, migration, or wizard page, downgrade severity relative to the same finding on a hot business path. A `FindSet` loop without `SetLoadFields` on a migration page that processes setup records once per tenant is a different finding than the same loop on a posting routine that runs thousands of times a day. Note this context explicitly in the review so the call site is not "fixed" twice with diminishing returns. + +## Anti Pattern + +Treating a migration wizard's per-row loop with the same urgency as the same loop in `Sales-Post`. The fix cost is the same; the production benefit is not. Bulk-rewriting an admin page to use `ModifyAll` and partial records buys nothing the user will perceive. diff --git a/microsoft/knowledge/performance/filter-before-find.bad.al b/microsoft/knowledge/performance/apply-filters-before-iterating.bad.al similarity index 60% rename from microsoft/knowledge/performance/filter-before-find.bad.al rename to microsoft/knowledge/performance/apply-filters-before-iterating.bad.al index 12d797e..6dc23df 100644 --- a/microsoft/knowledge/performance/filter-before-find.bad.al +++ b/microsoft/knowledge/performance/apply-filters-before-iterating.bad.al @@ -1,7 +1,10 @@ -codeunit 50101 "Perf Sample FilterBeforeFind Bad" +codeunit 50229 "Perf Sample FilterEarly Bad" { - procedure ProcessUsCustomers(var Customer: Record Customer) + procedure ProcessUSCustomers() + var + Customer: Record Customer; begin + // Reads every customer in the table, discards the non-US ones in AL. if Customer.FindSet() then repeat if Customer."Country/Region Code" = 'US' then @@ -11,6 +14,5 @@ codeunit 50101 "Perf Sample FilterBeforeFind Bad" local procedure ProcessCustomer(var Customer: Record Customer) begin - // per-customer work end; } diff --git a/microsoft/knowledge/performance/filter-before-find.good.al b/microsoft/knowledge/performance/apply-filters-before-iterating.good.al similarity index 67% rename from microsoft/knowledge/performance/filter-before-find.good.al rename to microsoft/knowledge/performance/apply-filters-before-iterating.good.al index a8dceda..820b102 100644 --- a/microsoft/knowledge/performance/filter-before-find.good.al +++ b/microsoft/knowledge/performance/apply-filters-before-iterating.good.al @@ -1,6 +1,8 @@ -codeunit 50100 "Perf Sample FilterBeforeFind Good" +codeunit 50228 "Perf Sample FilterEarly Good" { - procedure ProcessUsCustomers(var Customer: Record Customer) + procedure ProcessUSCustomers() + var + Customer: Record Customer; begin Customer.SetRange("Country/Region Code", 'US'); if Customer.FindSet() then @@ -11,6 +13,5 @@ codeunit 50100 "Perf Sample FilterBeforeFind Good" local procedure ProcessCustomer(var Customer: Record Customer) begin - // per-customer work end; } diff --git a/microsoft/knowledge/performance/apply-filters-before-iterating.md b/microsoft/knowledge/performance/apply-filters-before-iterating.md new file mode 100644 index 0000000..5f54c3b --- /dev/null +++ b/microsoft/knowledge/performance/apply-filters-before-iterating.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [setrange, setfilter, filter, loop, early, dataset] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Apply SetRange/SetFilter before iterating, not as an if-test inside the loop + +## Description + +A `SetRange` or `SetFilter` placed before `FindSet` narrows the result set at the database. The same condition expressed as an `if` inside the loop body filters in AL, after every row has crossed the boundary. Per the upstream guidance, "apply `SetRange`/`SetFilter` as early as possible to reduce dataset" and "more specific filters = better performance." On a production-scale table the difference is the difference between scanning a subset and scanning the whole table. + +## Best Practice + +Move every predicate that can be expressed as an equality or range filter into a `SetRange` or `SetFilter` ahead of the find. Combine with `SetCurrentKey` to choose a key whose first fields match the filter (see `setcurrentkey-aligns-key-with-filters.md`). The loop body should then contain only the work that depends on per-row state. + +See sample: `apply-filters-before-iterating.good.al`. + +## Anti Pattern + +`if Customer.FindSet() then repeat if Customer."Country/Region Code" = 'US' then ProcessCustomer(Customer); until Customer.Next() = 0;` — the loop pays for every row in the table and discards the non-matching ones in AL. The intent is the same as a `SetRange("Country/Region Code", 'US')` ahead of the find, but the cost is not. + +See sample: `apply-filters-before-iterating.bad.al`. diff --git a/microsoft/knowledge/performance/apply-guards-before-get.bad.al b/microsoft/knowledge/performance/apply-guards-before-get.bad.al new file mode 100644 index 0000000..2c0c710 --- /dev/null +++ b/microsoft/knowledge/performance/apply-guards-before-get.bad.al @@ -0,0 +1,14 @@ +codeunit 50215 "Perf Sample GuardBeforeGet Bad" +{ + procedure ResolveAllocation(var PurchaseLine: Record "Purchase Line") + var + PurchaseHeader: Record "Purchase Header"; + begin + // Wasted lookup when the line has no allocation account: the procedure + // exits below, but the header was already fetched. + PurchaseHeader.Get(PurchaseLine."Document Type", PurchaseLine."Document No."); + if PurchaseLine."Selected Alloc. Account No." = '' then + exit; + // ... + end; +} diff --git a/microsoft/knowledge/performance/guard-before-get-not-after.bad.al b/microsoft/knowledge/performance/apply-guards-before-get.good.al similarity index 50% rename from microsoft/knowledge/performance/guard-before-get-not-after.bad.al rename to microsoft/knowledge/performance/apply-guards-before-get.good.al index da0d391..52141f7 100644 --- a/microsoft/knowledge/performance/guard-before-get-not-after.bad.al +++ b/microsoft/knowledge/performance/apply-guards-before-get.good.al @@ -1,15 +1,12 @@ -codeunit 51201 "Perf Sample GuardBeforeGet Bad" +codeunit 50214 "Perf Sample GuardBeforeGet Good" { - procedure HandleLine(var PurchaseLine: Record "Purchase Line") + procedure ResolveAllocation(var PurchaseLine: Record "Purchase Line") var PurchaseHeader: Record "Purchase Header"; begin - // Get fires on every call — including the ones that exit immediately below. - PurchaseHeader.Get(PurchaseLine."Document Type", PurchaseLine."Document No."); - if PurchaseLine."Selected Alloc. Account No." = '' then exit; - - // Work with PurchaseHeader. + PurchaseHeader.Get(PurchaseLine."Document Type", PurchaseLine."Document No."); + // ... end; } diff --git a/microsoft/knowledge/performance/apply-guards-before-get.md b/microsoft/knowledge/performance/apply-guards-before-get.md new file mode 100644 index 0000000..9e77411 --- /dev/null +++ b/microsoft/knowledge/performance/apply-guards-before-get.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [get, guard, early-exit, conditional, lookup, wasted-query] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Apply early-exit guards before calling Get + +## Description + +A `Get` (or any other database call) executed before a guard that may exit the procedure does a round-trip the procedure never uses. Per the upstream guidance, "Flag `Get()` calls that execute before a guard condition that may exit early — the DB lookup is wasted." The fix is structural: order the procedure body so cheap checks (parameter validation, in-memory field comparisons, enum tests) run first, and the database call runs only after the guards pass. + +## Best Practice + +Read the procedure top-to-bottom and place every condition that can short-circuit ahead of every database call. The check `if SomeNo = '' then exit;` belongs above `Header.Get(...)`, not below. Each guard moved upward saves one wasted query on the path that exits. + +See sample: `apply-guards-before-get.good.al`. + +## Anti Pattern + +`Record.Get(...)` at the top of a procedure followed by `if SomeField = '' then exit;`. The code reads top-down as "load the record, then decide whether we needed it" — exactly the order that wastes the query. The pattern is easy to introduce when guards are added later, defensively, without re-checking call ordering. + +See sample: `apply-guards-before-get.bad.al`. diff --git a/microsoft/knowledge/performance/avoid-calcfields-in-loops.bad.al b/microsoft/knowledge/performance/avoid-calcfields-in-loops.bad.al deleted file mode 100644 index bc98ef1..0000000 --- a/microsoft/knowledge/performance/avoid-calcfields-in-loops.bad.al +++ /dev/null @@ -1,18 +0,0 @@ -codeunit 50117 "Perf Sample CalcFieldsInLoop Bad" -{ - procedure ProcessLargeLines(var SalesHeader: Record "Sales Header"; var SalesLine: Record "Sales Line") - begin - SalesLine.SetRange("Document Type", SalesHeader."Document Type"); - SalesLine.SetRange("Document No.", SalesHeader."No."); - if SalesLine.FindSet() then - repeat - SalesHeader.CalcFields(Amount); - if SalesHeader.Amount > 1000 then - ProcessLine(SalesLine); - until SalesLine.Next() = 0; - end; - - local procedure ProcessLine(var SalesLine: Record "Sales Line") - begin - end; -} diff --git a/microsoft/knowledge/performance/avoid-calcfields-in-loops.good.al b/microsoft/knowledge/performance/avoid-calcfields-in-loops.good.al deleted file mode 100644 index c02ca11..0000000 --- a/microsoft/knowledge/performance/avoid-calcfields-in-loops.good.al +++ /dev/null @@ -1,18 +0,0 @@ -codeunit 50116 "Perf Sample CalcFieldsInLoop Good" -{ - procedure ProcessLargeLines(var SalesHeader: Record "Sales Header"; var SalesLine: Record "Sales Line") - begin - SalesHeader.CalcFields(Amount); - SalesLine.SetRange("Document Type", SalesHeader."Document Type"); - SalesLine.SetRange("Document No.", SalesHeader."No."); - if SalesLine.FindSet() then - repeat - if SalesHeader.Amount > 1000 then - ProcessLine(SalesLine); - until SalesLine.Next() = 0; - end; - - local procedure ProcessLine(var SalesLine: Record "Sales Line") - begin - end; -} diff --git a/microsoft/knowledge/performance/avoid-calcfields-in-loops.md b/microsoft/knowledge/performance/avoid-calcfields-in-loops.md deleted file mode 100644 index 67fb793..0000000 --- a/microsoft/knowledge/performance/avoid-calcfields-in-loops.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [calcfields, flowfield, loop, n-plus-one] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Do not call CalcFields inside loops - -## Description - -CalcFields evaluates one or more FlowFields for the current record by issuing a separate SQL aggregation. Called inside a loop over a record set, it becomes an N+1 problem: one aggregate per row. For any non-trivial set on a ledger-entry-backed FlowField this is orders of magnitude slower than the equivalent batched query. - -## Best Practice - -Move CalcFields out of the iteration. If the total is what you need, use CalcSums on the filtered parent set. If row-by-row FlowField values are needed, reshape the computation so the aggregate runs once — for example by joining against a temporary table populated in a single batched query. - -**Acceptable exceptions:** CalcFields inside an `OnAfterGetRecord` page trigger is the standard pattern for displaying computed FlowField values — the platform calls this trigger once per row and it is not a developer-authored loop. Similarly, CalcFields inside an `OnValidate` field trigger fires at most once per user action and is acceptable. The concern is only developer-written `FindSet … repeat … until Next() = 0` loops. - -See sample: `avoid-calcfields-in-loops.good.al`. - -## Anti Pattern - -Calling CalcFields inside `repeat ... until Next() = 0` on a hot parent record is the textbook N+1 pattern. Even a modest parent set size (hundreds of rows) turns into thousands of round-trips. - -See sample: `avoid-calcfields-in-loops.bad.al`. - diff --git a/microsoft/knowledge/performance/avoid-findfirst-with-next.bad.al b/microsoft/knowledge/performance/avoid-findfirst-with-next.bad.al deleted file mode 100644 index 027ada6..0000000 --- a/microsoft/knowledge/performance/avoid-findfirst-with-next.bad.al +++ /dev/null @@ -1,14 +0,0 @@ -codeunit 50105 "Perf Sample AvoidFindFirstNext Bad" -{ - procedure EmitAllItems(var Item: Record Item) - begin - if Item.FindFirst() then - repeat - EmitItem(Item); - until Item.Next() = 0; - end; - - local procedure EmitItem(var Item: Record Item) - begin - end; -} diff --git a/microsoft/knowledge/performance/avoid-findfirst-with-next.md b/microsoft/knowledge/performance/avoid-findfirst-with-next.md deleted file mode 100644 index 267aaac..0000000 --- a/microsoft/knowledge/performance/avoid-findfirst-with-next.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [findfirst, findlast, get, next, aa0233] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Do not pair FindFirst, FindLast, or Get with Next - -## Description - -CodeCop rule AA0233 flags loops that start with FindFirst, FindLast, or Get and then call Next. FindFirst and FindLast retrieve a single row and reposition the cursor; calling Next after them forces the platform to re-seek and stream the rest of the set, which is slower than the correct FindSet pattern and signals intent incorrectly to reviewers and the optimizer. - -## Best Practice - -Choose the Find variant that matches the operation: FindSet for full iteration, FindFirst or FindLast when you want exactly one row, Get when the primary key is known. Never call Next after FindFirst, FindLast, or Get. - -## Anti Pattern - -Writing `if Rec.FindFirst() then repeat ... until Rec.Next() = 0` is the canonical AA0233 offender. The loop wastes bandwidth and obscures the author's intent. - -See sample: `avoid-findfirst-with-next.bad.al`. - diff --git a/microsoft/knowledge/performance/avoid-get-inside-loop-on-large-table.bad.al b/microsoft/knowledge/performance/avoid-get-inside-loop-on-large-table.bad.al new file mode 100644 index 0000000..7ff67be --- /dev/null +++ b/microsoft/knowledge/performance/avoid-get-inside-loop-on-large-table.bad.al @@ -0,0 +1,15 @@ +codeunit 50253 "Perf Sample NPlus1 Bad" +{ + procedure SumStdCost(var BOMLine: Record "BOM Component") TotalCost: Decimal + var + Item: Record Item; + begin + if BOMLine.FindSet() then + repeat + // Full-row Item.Get per BOM line — no partial loading, no caching. + Item.Get(BOMLine."No."); + if Item."Costing Method" = Item."Costing Method"::Standard then + TotalCost += Item."Standard Cost" * BOMLine."Quantity per"; + until BOMLine.Next() = 0; + end; +} diff --git a/microsoft/knowledge/performance/avoid-get-inside-loop-on-large-table.good.al b/microsoft/knowledge/performance/avoid-get-inside-loop-on-large-table.good.al new file mode 100644 index 0000000..2bdbf65 --- /dev/null +++ b/microsoft/knowledge/performance/avoid-get-inside-loop-on-large-table.good.al @@ -0,0 +1,15 @@ +codeunit 50252 "Perf Sample NPlus1 Good" +{ + procedure SumStdCost(var BOMLine: Record "BOM Component") TotalCost: Decimal + var + Item: Record Item; + begin + Item.SetLoadFields("Costing Method", "Standard Cost"); + if BOMLine.FindSet() then + repeat + if Item.Get(BOMLine."No.") then + if Item."Costing Method" = Item."Costing Method"::Standard then + TotalCost += Item."Standard Cost" * BOMLine."Quantity per"; + until BOMLine.Next() = 0; + end; +} diff --git a/microsoft/knowledge/performance/avoid-get-inside-loop-on-large-table.md b/microsoft/knowledge/performance/avoid-get-inside-loop-on-large-table.md new file mode 100644 index 0000000..2908d3f --- /dev/null +++ b/microsoft/knowledge/performance/avoid-get-inside-loop-on-large-table.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [n-plus-one, get, findfirst, loop, inner-lookup, large-table] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Avoid Get / FindFirst inside a loop on a large inner table + +## Description + +A `Get` or `FindFirst` against a different record inside a loop body produces one database round-trip per iteration — the classic N+1 pattern. Per the upstream guidance, "Flag when a `Get()`/`FindFirst()` is called inside a loop for each record — this creates N+1 database round-trips." The cost only matters when the inner table is meaningful: lookups against temporary tables, singleton setup tables, enum-mapping tables, permission objects, or Role IDs are bounded and safe. The pattern to catch is the inner lookup that hits a production-scale table for every outer row. + +## Best Practice + +When the loop needs values from another record, lift the lookup out of the loop if the rows can be collected up front, or apply `SetLoadFields` so each inner read transfers only the columns the loop actually uses (see `use-setloadfields-for-partial-records.md`). When the inner record is small or bounded, leave the call site alone — the rule targets large-table inner lookups specifically. + +See sample: `avoid-get-inside-loop-on-large-table.good.al`. + +## Anti Pattern + +Iterating BOM lines and calling `Item.Get(BOMLine."No.")` per row to read a costing method, with no `SetLoadFields` on `Item`. Each iteration issues one query against Item (~800k rows) and pulls the entire row to read two fields. The fix is `Item.SetLoadFields("Costing Method", "Standard Cost");` ahead of the loop — still N reads, but each one transfers only the needed columns. + +See sample: `avoid-get-inside-loop-on-large-table.bad.al`. diff --git a/microsoft/knowledge/performance/avoid-recordref-in-hot-loop.bad.al b/microsoft/knowledge/performance/avoid-recordref-in-hot-loop.bad.al new file mode 100644 index 0000000..f0cee4f --- /dev/null +++ b/microsoft/knowledge/performance/avoid-recordref-in-hot-loop.bad.al @@ -0,0 +1,22 @@ +codeunit 50255 "Perf Sample RecRef Bad" +{ + procedure ProcessAllCustomerNames() + var + Customer: Record Customer; + RecRef: RecordRef; + FldRef: FieldRef; + begin + RecRef.Open(Database::Customer); + if RecRef.FindSet() then + repeat + // Table and field are fixed at compile time, but every iteration + // pays dynamic resolution cost. + FldRef := RecRef.Field(Customer.FieldNo(Name)); + ProcessName(Format(FldRef.Value)); + until RecRef.Next() = 0; + end; + + local procedure ProcessName(Name: Text) + begin + end; +} diff --git a/microsoft/knowledge/performance/avoid-recordref-in-hot-loop.good.al b/microsoft/knowledge/performance/avoid-recordref-in-hot-loop.good.al new file mode 100644 index 0000000..86dcc3d --- /dev/null +++ b/microsoft/knowledge/performance/avoid-recordref-in-hot-loop.good.al @@ -0,0 +1,16 @@ +codeunit 50254 "Perf Sample RecRef Good" +{ + procedure ProcessAllCustomerNames() + var + Customer: Record Customer; + begin + if Customer.FindSet() then + repeat + ProcessName(Customer.Name); + until Customer.Next() = 0; + end; + + local procedure ProcessName(Name: Text[100]) + begin + end; +} diff --git a/microsoft/knowledge/performance/avoid-recordref-in-hot-loop.md b/microsoft/knowledge/performance/avoid-recordref-in-hot-loop.md new file mode 100644 index 0000000..b718449 --- /dev/null +++ b/microsoft/knowledge/performance/avoid-recordref-in-hot-loop.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [recordref, fieldref, hot-loop, typed-record, metadata] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Avoid RecordRef / FieldRef in hot loops when a typed record fits + +## Description + +`RecordRef` and `FieldRef` are slower than direct typed record access — the platform resolves the table and field at runtime instead of at compile time. The trade-off is intentional: per the upstream guidance, "RecordRef/FieldRef operations are slower than direct record access, but many features REQUIRE them for generic metadata iteration (permission checks, field copying, dynamic field access)." The rule, then, is not "never use them" but "only flag when used inside a clearly unbounded hot loop (10k+ iterations) where a typed alternative exists." + +## Best Practice + +Use `RecordRef`/`FieldRef` for genuinely generic code — permission checks, field copying, table-agnostic export. When the loop target is known at compile time and the loop iterates a large number of rows, declare the typed record and access fields directly; the saved per-iteration overhead is measurable at the volumes the rule targets. + +See sample: `avoid-recordref-in-hot-loop.good.al`. + +## Anti Pattern + +`RecRef.Open(Database::Customer); if RecRef.FindSet() then repeat FldRef := RecRef.Field(Customer.FieldNo(Name)); ProcessName(FldRef.Value); until RecRef.Next() = 0;` — the table is fixed at compile time, the field is fixed at compile time, and the loop pays the dynamic-resolution cost on every iteration. The direct `Customer.Name` form does the same work without the lookup. + +See sample: `avoid-recordref-in-hot-loop.bad.al`. diff --git a/microsoft/knowledge/performance/avoid-redundant-get-when-record-already-loaded.bad.al b/microsoft/knowledge/performance/avoid-redundant-get-when-record-already-loaded.bad.al new file mode 100644 index 0000000..e7358fb --- /dev/null +++ b/microsoft/knowledge/performance/avoid-redundant-get-when-record-already-loaded.bad.al @@ -0,0 +1,21 @@ +page 50217 "Perf Sample Redundant Bad" +{ + PageType = ListPart; + SourceTable = "Assembly Line"; + + var + AssemblyLineRec: Record "Assembly Line"; + ShowWarning: Boolean; + + trigger OnAfterGetRecord() + begin + // Redundant: the platform already fetched the row into Rec. + AssemblyLineRec.Get("Document Type", "Document No.", "Line No."); + ShowWarning := CheckAvailability(AssemblyLineRec); + end; + + local procedure CheckAvailability(var AssemblyLine: Record "Assembly Line"): Boolean + begin + exit(AssemblyLine.Quantity > 0); + end; +} diff --git a/microsoft/knowledge/performance/avoid-redundant-get-when-record-already-loaded.good.al b/microsoft/knowledge/performance/avoid-redundant-get-when-record-already-loaded.good.al new file mode 100644 index 0000000..6cb60ea --- /dev/null +++ b/microsoft/knowledge/performance/avoid-redundant-get-when-record-already-loaded.good.al @@ -0,0 +1,18 @@ +page 50216 "Perf Sample Redundant Good" +{ + PageType = ListPart; + SourceTable = "Assembly Line"; + + var + ShowWarning: Boolean; + + trigger OnAfterGetRecord() + begin + ShowWarning := CheckAvailability(Rec); + end; + + local procedure CheckAvailability(var AssemblyLine: Record "Assembly Line"): Boolean + begin + exit(AssemblyLine.Quantity > 0); + end; +} diff --git a/microsoft/knowledge/performance/avoid-redundant-get-when-record-already-loaded.md b/microsoft/knowledge/performance/avoid-redundant-get-when-record-already-loaded.md new file mode 100644 index 0000000..9684769 --- /dev/null +++ b/microsoft/knowledge/performance/avoid-redundant-get-when-record-already-loaded.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [get, onaftergetrecord, redundant, page-trigger, rec, already-loaded] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Do not Get the record the page already loaded + +## Description + +A list or card page's `OnAfterGetRecord` trigger fires *because* the platform has already fetched a row into `Rec`. Calling `Get` for that same row inside the trigger repeats the read the platform just did. Per the upstream guidance, this is "redundant — record already fetched by page runtime"; the correction is "use `Rec` directly — already loaded." The waste compounds on list pages, where the trigger runs once per row displayed. + +## Best Practice + +Inside page triggers — `OnAfterGetRecord`, `OnAfterGetCurrRecord`, validation triggers — read from `Rec` (or the trigger's record parameter). The platform exposes the freshly loaded record there for exactly this purpose. Reach for `Get` only when the trigger needs a *different* record than the one being displayed. + +See sample: `avoid-redundant-get-when-record-already-loaded.good.al`. + +## Anti Pattern + +`AssemblyLineRec.Get("Document Type", "Document No.", "Line No.");` at the top of `OnAfterGetRecord`, when the trigger is on the `Assembly Line` page itself and `Rec` already holds that row. The pattern often appears when a helper that expects a record parameter is invoked from a page trigger and the author writes a `Get` to "freshen" `Rec` rather than passing `Rec` through. + +See sample: `avoid-redundant-get-when-record-already-loaded.bad.al`. diff --git a/microsoft/knowledge/performance/avoid-user-interaction-in-transactions.bad.al b/microsoft/knowledge/performance/avoid-user-interaction-in-transactions.bad.al deleted file mode 100644 index 035b26b..0000000 --- a/microsoft/knowledge/performance/avoid-user-interaction-in-transactions.bad.al +++ /dev/null @@ -1,11 +0,0 @@ -codeunit 50127 "Perf Sample UserInTxn Bad" -{ - procedure ArchiveSalesHeader(var SalesHeader: Record "Sales Header") - begin - SalesHeader.Status := SalesHeader.Status::Released; - SalesHeader.Modify(); - if not Confirm('Archive document %1?', false, SalesHeader."No.") then - exit; - SalesHeader.Delete(true); - end; -} diff --git a/microsoft/knowledge/performance/avoid-user-interaction-in-transactions.good.al b/microsoft/knowledge/performance/avoid-user-interaction-in-transactions.good.al deleted file mode 100644 index f59e635..0000000 --- a/microsoft/knowledge/performance/avoid-user-interaction-in-transactions.good.al +++ /dev/null @@ -1,14 +0,0 @@ -codeunit 50126 "Perf Sample UserInTxn Good" -{ - procedure ArchiveSalesHeader(var SalesHeader: Record "Sales Header") - begin - if not Confirm('Archive document %1?', false, SalesHeader."No.") then - exit; - DoArchive(SalesHeader); - end; - - local procedure DoArchive(var SalesHeader: Record "Sales Header") - begin - // only Insert/Modify/Delete calls happen here; no prompts - end; -} diff --git a/microsoft/knowledge/performance/avoid-user-interaction-in-transactions.md b/microsoft/knowledge/performance/avoid-user-interaction-in-transactions.md deleted file mode 100644 index 548ee24..0000000 --- a/microsoft/knowledge/performance/avoid-user-interaction-in-transactions.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [confirm, strmenu, message, transaction, dialog] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Do not prompt the user inside a write transaction - -> Contributions welcome — open a PR to refine or extend this article. - -## Description - -Confirm, StrMenu, Message, and any other user-facing dialog pauses execution while the transaction is still open. During that pause every lock held by the transaction blocks other sessions. A user who walks away from the screen can suspend business-critical tables for an unbounded period. - -## Best Practice - -Gather every user decision before the writing phase begins. Once the decisions are known, run the transaction end-to-end without prompts. - -See sample: `avoid-user-interaction-in-transactions.good.al`. - -## Anti Pattern - -Calling Confirm or StrMenu from inside an OnInsert, OnModify, or OnDelete trigger — or from any code path that has already started modifying records — blocks on user input while holding locks. - -See sample: `avoid-user-interaction-in-transactions.bad.al`. - diff --git a/microsoft/knowledge/performance/avoid-user-prompts-inside-transactions.bad.al b/microsoft/knowledge/performance/avoid-user-prompts-inside-transactions.bad.al new file mode 100644 index 0000000..ce5cf31 --- /dev/null +++ b/microsoft/knowledge/performance/avoid-user-prompts-inside-transactions.bad.al @@ -0,0 +1,18 @@ +codeunit 50239 "Perf Sample PromptInTxn Bad" +{ + procedure PostOrder(DocNo: Code[20]) + var + SalesHeader: Record "Sales Header"; + PostConfirmQst: Label 'Post this order?'; + begin + SalesHeader.LockTable(); + SalesHeader.Get(SalesHeader."Document Type"::Order, DocNo); + // Lock held while the dialog is on screen — minutes or hours. + if Confirm(PostConfirmQst) then + PostSalesOrder(SalesHeader); + end; + + local procedure PostSalesOrder(var SalesHeader: Record "Sales Header") + begin + end; +} diff --git a/microsoft/knowledge/performance/avoid-user-prompts-inside-transactions.good.al b/microsoft/knowledge/performance/avoid-user-prompts-inside-transactions.good.al new file mode 100644 index 0000000..1407d3c --- /dev/null +++ b/microsoft/knowledge/performance/avoid-user-prompts-inside-transactions.good.al @@ -0,0 +1,18 @@ +codeunit 50238 "Perf Sample PromptInTxn Good" +{ + procedure PostOrder(DocNo: Code[20]) + var + SalesHeader: Record "Sales Header"; + PostConfirmQst: Label 'Post this order?'; + begin + if not Confirm(PostConfirmQst) then + exit; + SalesHeader.LockTable(); + SalesHeader.Get(SalesHeader."Document Type"::Order, DocNo); + PostSalesOrder(SalesHeader); + end; + + local procedure PostSalesOrder(var SalesHeader: Record "Sales Header") + begin + end; +} diff --git a/microsoft/knowledge/performance/avoid-user-prompts-inside-transactions.md b/microsoft/knowledge/performance/avoid-user-prompts-inside-transactions.md new file mode 100644 index 0000000..834641a --- /dev/null +++ b/microsoft/knowledge/performance/avoid-user-prompts-inside-transactions.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [confirm, strmenu, dialog, transaction, lock, user-interaction] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Do not hold locks while waiting for the user + +## Description + +A `Confirm`, `StrMenu`, modal page, or other user prompt issued from inside a write transaction stalls the transaction — and therefore every lock it holds — until the user responds. Per the upstream guidance, "Avoid user interactions (Confirm, StrMenu) inside transactions — they hold locks while waiting for user input." The wait is bounded only by the user; meanwhile other sessions block on whatever this transaction has acquired. + +## Best Practice + +Sequence the operation so user confirmation happens *before* any database write that takes a lock the prompt holds open. The shape is: ask the user → if confirmed, acquire locks and post. `if Confirm(...) then begin SalesHeader.LockTable(); SalesHeader.Get(DocNo); PostSalesOrder(SalesHeader); end;` keeps the lock window down to the work itself. + +See sample: `avoid-user-prompts-inside-transactions.good.al`. + +## Anti Pattern + +`SalesHeader.LockTable(); SalesHeader.Get(DocNo); if Confirm('Post this order?') then ...;` — the lock is held for as long as the dialog is up. A user who steps away to lunch holds the lock for an hour, and every other session that touches that row blocks for the duration. + +See sample: `avoid-user-prompts-inside-transactions.bad.al`. diff --git a/microsoft/knowledge/performance/blob-fields-are-not-cached-prefer-media.md b/microsoft/knowledge/performance/blob-fields-are-not-cached-prefer-media.md deleted file mode 100644 index 75b4347..0000000 --- a/microsoft/knowledge/performance/blob-fields-are-not-cached-prefer-media.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [blob, media, mediaset, cache, image, thumbnail] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Blob fields are never cached — prefer Media or MediaSet for images - -## Description - -`Blob` field contents are not cached by the Business Central server or the client. Every read re-fetches the full payload from the database, even when the same blob was read moments earlier in the same session. For images displayed on a page, this turns into a database round-trip per render. - -`Media` and `MediaSet` are purpose-built for this and behave differently in two ways that matter for performance. First, they are cached on the client, so subsequent renders of the same image do not re-hit the database. Second, the platform generates a thumbnail when the data is saved, so a list or card page can show the thumbnail immediately and lazy-load the full-resolution image — typically via a Page Background Task — only when needed. - -`Blob` remains appropriate for non-image binary data that is written once and rarely read, or for data the platform does not need to render. For any field that is displayed repeatedly — profile pictures, item images, logos on documents — `Media` or `MediaSet` is the default. - -## Best Practice - -Store images in `Media` or `MediaSet` fields. Bind the thumbnail to the page; load full-resolution data asynchronously when the user opens the full view. Reserve `Blob` for opaque payloads that are not rendered in the UI. - -## Anti Pattern - -An Item Image field defined as `Blob` and shown directly on a list page. Every scroll re-fetches every image from SQL, the list page load time scales with row count and image size, and no client-side caching mitigates the cost. diff --git a/microsoft/knowledge/performance/calcsums-instead-of-calcfields-in-loop.bad.al b/microsoft/knowledge/performance/calcsums-instead-of-calcfields-in-loop.bad.al new file mode 100644 index 0000000..48318a9 --- /dev/null +++ b/microsoft/knowledge/performance/calcsums-instead-of-calcfields-in-loop.bad.al @@ -0,0 +1,15 @@ +codeunit 50223 "Perf Sample CalcSums Bad" +{ + procedure TotalRemaining(CustomerNo: Code[20]) Total: Decimal + var + CustLedgerEntry: Record "Cust. Ledger Entry"; + begin + CustLedgerEntry.SetRange("Customer No.", CustomerNo); + // One SQL query per row over a 10M-row ledger. + if CustLedgerEntry.FindSet() then + repeat + CustLedgerEntry.CalcFields("Remaining Amount"); + Total += CustLedgerEntry."Remaining Amount"; + until CustLedgerEntry.Next() = 0; + end; +} diff --git a/microsoft/knowledge/performance/calcsums-instead-of-calcfields-in-loop.good.al b/microsoft/knowledge/performance/calcsums-instead-of-calcfields-in-loop.good.al new file mode 100644 index 0000000..2e1f367 --- /dev/null +++ b/microsoft/knowledge/performance/calcsums-instead-of-calcfields-in-loop.good.al @@ -0,0 +1,11 @@ +codeunit 50222 "Perf Sample CalcSums Good" +{ + procedure TotalRemaining(CustomerNo: Code[20]) Total: Decimal + var + CustLedgerEntry: Record "Cust. Ledger Entry"; + begin + CustLedgerEntry.SetRange("Customer No.", CustomerNo); + CustLedgerEntry.CalcSums("Remaining Amount"); + Total := CustLedgerEntry."Remaining Amount"; + end; +} diff --git a/microsoft/knowledge/performance/calcsums-instead-of-calcfields-in-loop.md b/microsoft/knowledge/performance/calcsums-instead-of-calcfields-in-loop.md new file mode 100644 index 0000000..7d0752f --- /dev/null +++ b/microsoft/knowledge/performance/calcsums-instead-of-calcfields-in-loop.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [calcfields, calcsums, loop, flowfield, n-plus-one, aggregation] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use CalcSums to aggregate, not CalcFields inside a loop + +## Description + +`CalcFields` materializes FlowField values for one record. Each call against a persistent table is "a separate SQL query"; running it inside a `repeat ... until Next() = 0` over a large table issues one query per row on top of the iteration itself. `CalcSums` answers the same aggregation question — "give me the sum of this FlowField over the filtered set" — as a single SQL statement. Per the upstream guidance, `CalcFields` inside loops on large persistent tables is "a performance problem"; the aggregation form is `CalcSums()`. + +## Best Practice + +When the procedure totals a FlowField (or several) across a filtered set, set the filters, then call `CalcSums("Field 1", "Field 2", ...)`. The platform issues one query; the result is read off the record's FlowField slot. Single `CalcFields` outside loops is fine, and `CalcFields` on the current row in a page's `OnAfterGetRecord` or in `OnValidate` is the standard pattern — those are per-action, not per-row over a large set. + +See sample: `calcsums-instead-of-calcfields-in-loop.good.al`. + +## Anti Pattern + +`if CustLedgerEntry.FindSet() then repeat CustLedgerEntry.CalcFields("Remaining Amount"); Total += CustLedgerEntry."Remaining Amount"; until CustLedgerEntry.Next() = 0;` — exactly the upstream-flagged shape. The iteration is the cheap part; the per-row `CalcFields` is what scales linearly with table size. + +See sample: `calcsums-instead-of-calcfields-in-loop.bad.al`. diff --git a/microsoft/knowledge/performance/combine-multiple-modifyall-calls.good.al b/microsoft/knowledge/performance/combine-multiple-modifyall-calls.good.al deleted file mode 100644 index e1ecaad..0000000 --- a/microsoft/knowledge/performance/combine-multiple-modifyall-calls.good.al +++ /dev/null @@ -1,16 +0,0 @@ -codeunit 51206 "Perf Sample CombineMA Good" -{ - procedure UpdateTolerance(DocumentNo: Code[20]; ToleranceAmount: Decimal) - var - CustLedgerEntry: Record "Cust. Ledger Entry"; - begin - CustLedgerEntry.SetRange("Document No.", DocumentNo); - CustLedgerEntry.SetRange(Open, true); - if CustLedgerEntry.FindSet(true) then - repeat - CustLedgerEntry."Accepted Payment Tolerance" := ToleranceAmount; - CustLedgerEntry."Accepted Pmt. Disc. Tolerance" := false; - CustLedgerEntry.Modify(false); - until CustLedgerEntry.Next() = 0; - end; -} diff --git a/microsoft/knowledge/performance/combine-multiple-modifyall-calls.md b/microsoft/knowledge/performance/combine-multiple-modifyall-calls.md deleted file mode 100644 index 21c32ff..0000000 --- a/microsoft/knowledge/performance/combine-multiple-modifyall-calls.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [modifyall, bulk-update, filter, scan, recordset] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Combine multiple ModifyAll calls on the same recordset into a single pass - -## Description - -`ModifyAll(Field, Value)` issues a SQL UPDATE against every row matching the record variable's current filters, setting one field. Calling it twice on the same filtered recordset — once per field to update — produces two separate UPDATE statements, each of which has to re-locate the matching rows through the index. On a ledger-entry-scale table with ten million rows and a filter that matches a thousand, the overhead is not a doubling of the update cost but a doubling of the more expensive row-location cost. A single `FindSet(true)` + set-by-set assignment + `Modify(false)` completes both field changes in one pass. - -## Best Practice - -When more than one field needs to change on the same filtered recordset, iterate once with `FindSet(true)` and assign all fields per row. Reserve ModifyAll for the case where a single field change covers the whole update. If the filter set is truly huge and the trigger behaviour differs between fields, consider splitting with concrete evidence — otherwise the single-pass loop wins. - -See sample: `combine-multiple-modifyall-calls.good.al`. - -## Anti Pattern - -Applying `SetRange` against `CustLedgerEntry` on `"Document No."` and then calling `ModifyAll("Accepted Payment Tolerance", ...)` followed by `ModifyAll("Accepted Pmt. Disc. Tolerance", false)` — two scans over the same filtered rows. On Cust. Ledger Entry with production-scale data the redundant second scan is the dominant cost. - -See sample: `combine-multiple-modifyall-calls.bad.al`. diff --git a/microsoft/knowledge/performance/do-not-flag-performance-on-bounded-tables.md b/microsoft/knowledge/performance/do-not-flag-performance-on-bounded-tables.md deleted file mode 100644 index 216e727..0000000 --- a/microsoft/knowledge/performance/do-not-flag-performance-on-bounded-tables.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [setup-table, temporary, bounded-table, metadata, migration, false-positive] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Do not flag performance on inherently bounded tables - -## Description - -Several categories of Business Central tables are so small, so rarely accessed, or so in-memory that performance heuristics that make sense on Item Ledger Entry produce noise when applied to them. Temporary records (`TableType = Temporary`, `SourceTableTemporary = true`) live in memory and any access pattern is fast. Singleton setup tables (`Sales & Receivables Setup`, `General Ledger Setup`, `*Setup` tables generally) hold one row per company. Small bounded tables — enum mappings, permission objects, Role IDs — count in the dozens. System metadata tables (`TableMetadata`, `Field`, `AllObjWithCaption`) are bounded by the object catalog. Admin, Migration, Setup, Wizard, and Hybrid* pages are used infrequently with small datasets. - -## Best Practice - -Skip performance findings on these categories unless the code is specifically pathological (unbounded loop that multiplies cost non-linearly). A missing SetLoadFields on a singleton Setup table is not a finding. A Count on a 30-row permission mapping is not a finding. An admin page that iterates a bounded list once per invocation is not a finding. Reserving reviewer attention for the tables where it matters is half the value of the heuristics — noise on bounded tables trains authors to ignore the signal. - -## Anti Pattern - -Flagging `SalesReceivablesSetup.Get()` followed by `SetLoadFields()` on a handful of fields as "missing partial record optimization". Flagging a `FindSet` + loop on `Role ID` mapping because the loop has no SetCurrentKey. Flagging a Migration codeunit for writing many records, when the entire migration runs once per customer. All three burn author attention on cases that are not regressions. diff --git a/microsoft/knowledge/performance/do-not-locktable-in-read-only-procedure.bad.al b/microsoft/knowledge/performance/do-not-locktable-in-read-only-procedure.bad.al new file mode 100644 index 0000000..79c524e --- /dev/null +++ b/microsoft/knowledge/performance/do-not-locktable-in-read-only-procedure.bad.al @@ -0,0 +1,10 @@ +codeunit 50235 "Perf Sample LockReadOnly Bad" +{ + procedure GetStatus(var AgentStatus: Record "Agent Status"): Boolean + begin + // Read-only path, yet every caller's transaction now acquires UPDLOCK + // on Agent Status for the remainder of the transaction. + AgentStatus.LockTable(); + exit(AgentStatus.Get()); + end; +} diff --git a/microsoft/knowledge/performance/do-not-locktable-in-read-only-procedure.good.al b/microsoft/knowledge/performance/do-not-locktable-in-read-only-procedure.good.al new file mode 100644 index 0000000..3f2a3a3 --- /dev/null +++ b/microsoft/knowledge/performance/do-not-locktable-in-read-only-procedure.good.al @@ -0,0 +1,14 @@ +codeunit 50234 "Perf Sample LockReadOnly Good" +{ + procedure GetStatus(var AgentStatus: Record "Agent Status"): Boolean + begin + if AgentStatus.Get() then + exit(true); + AgentStatus.LockTable(); + if not AgentStatus.Get() then begin + AgentStatus.Init(); + AgentStatus.Insert(); + end; + exit(true); + end; +} diff --git a/microsoft/knowledge/performance/do-not-locktable-in-read-only-procedure.md b/microsoft/knowledge/performance/do-not-locktable-in-read-only-procedure.md new file mode 100644 index 0000000..f25af2e --- /dev/null +++ b/microsoft/knowledge/performance/do-not-locktable-in-read-only-procedure.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [locktable, read-only, helper, contention, transaction] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Do not LockTable in a read-only procedure + +## Description + +`LockTable` is a transaction-wide signal: from the call onward, every read against that table in the same transaction acquires `UPDLOCK`. Per the upstream guidance, "`LockTable()` before Modify/Insert/Delete in the same procedure is the correct pattern" — locking the read against the write that follows is what the call exists for. The anti-pattern is "`LockTable()` in read-only procedures — unnecessary lock contention": the procedure never writes, but the lock cost is paid by everyone sharing the transaction. + +## Best Practice + +Reserve `LockTable` for the read directly before a `Modify`, `Insert`, or `Delete` that depends on the read value. If a helper is sometimes called for reading and sometimes for writing, split it into separate read and write paths and call `LockTable` only on the write path. For read-only existence checks or lookups, the right primitive is `ReadIsolation` (see `prefer-readisolation-over-locktable-for-reads.md`). + +See sample: `do-not-locktable-in-read-only-procedure.good.al`. + +## Anti Pattern + +A pure getter that opens with `Rec.LockTable();`. Every caller's transaction now acquires `UPDLOCK` on that table for every subsequent read until commit. The contention shows up as blocking on unrelated sessions whose own code path looks innocent — the locker is invisible to the blocked reader. + +See sample: `do-not-locktable-in-read-only-procedure.bad.al`. diff --git a/microsoft/knowledge/performance/do-not-modify-in-onaftergetrecord.bad.al b/microsoft/knowledge/performance/do-not-modify-in-onaftergetrecord.bad.al new file mode 100644 index 0000000..944ea99 --- /dev/null +++ b/microsoft/knowledge/performance/do-not-modify-in-onaftergetrecord.bad.al @@ -0,0 +1,17 @@ +page 50247 "Perf Sample WriteScroll Bad" +{ + PageType = List; + SourceTable = Customer; + + trigger OnAfterGetRecord() + begin + // One DB write per row displayed, every time the user scrolls. + Rec."Reminder Terms Code" := CalcReminderTerms(); + Rec.Modify(); + end; + + local procedure CalcReminderTerms(): Code[10] + begin + exit(''); + end; +} diff --git a/microsoft/knowledge/performance/do-not-modify-in-onaftergetrecord.good.al b/microsoft/knowledge/performance/do-not-modify-in-onaftergetrecord.good.al new file mode 100644 index 0000000..751875c --- /dev/null +++ b/microsoft/knowledge/performance/do-not-modify-in-onaftergetrecord.good.al @@ -0,0 +1,18 @@ +page 50246 "Perf Sample WriteScroll Good" +{ + PageType = List; + SourceTable = Customer; + + var + ShowWarning: Boolean; + + trigger OnAfterGetRecord() + begin + ShowWarning := CalcWarning(); + end; + + local procedure CalcWarning(): Boolean + begin + exit(false); + end; +} diff --git a/microsoft/knowledge/performance/do-not-modify-in-onaftergetrecord.md b/microsoft/knowledge/performance/do-not-modify-in-onaftergetrecord.md new file mode 100644 index 0000000..9de0903 --- /dev/null +++ b/microsoft/knowledge/performance/do-not-modify-in-onaftergetrecord.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [page-trigger, onaftergetrecord, modify, display, scroll, db-write] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Do not Modify inside OnAfterGetRecord + +## Description + +A list page's `OnAfterGetRecord` fires once per visible row, every time the user scrolls, sorts, or refreshes. A `Modify` inside that trigger means a database write per row displayed. Per the upstream guidance, "`Modify()` here means a DB write on every scroll. Use page variables for display-only state instead." `OnAfterGetCurrRecord` (single record on selection), `OnOpenPage`, and `OnInit` fire once or at much lower frequency and tolerate one-time setup logic. + +## Best Practice + +When the trigger needs to compute display-only state per row, write the result into a page variable (a global on the page object) rather than back to the database. Reserve `Modify` for triggers that fire on an explicit user action — `OnAction`, validation triggers, `OnQueryClosePage` — where one action maps to one write. + +See sample: `do-not-modify-in-onaftergetrecord.good.al`. + +## Anti Pattern + +`trigger OnAfterGetRecord() begin Rec."Warning Flag" := CalcWarning(); Rec.Modify(); end;` — on a list page over a moderately sized table, scrolling through fifty rows produces fifty writes. The page feels slow, the table accumulates churn, and the warning flag — which is recomputed on every refresh anyway — never needed persistence. + +See sample: `do-not-modify-in-onaftergetrecord.bad.al`. diff --git a/microsoft/knowledge/performance/do-not-modify-records-in-onaftergetrecord.bad.al b/microsoft/knowledge/performance/do-not-modify-records-in-onaftergetrecord.bad.al deleted file mode 100644 index fbf5047..0000000 --- a/microsoft/knowledge/performance/do-not-modify-records-in-onaftergetrecord.bad.al +++ /dev/null @@ -1,15 +0,0 @@ -pageextension 51209 "Perf Sample NoModifyOAGR Bad" extends "Customer List" -{ - trigger OnAfterGetRecord() - begin - // Every scroll writes to the database. Every OnModify subscriber on - // Customer fires alongside. Write volume scales with mouse-wheel speed. - Rec."Last Warning Flag" := CalcWarning(); - Rec.Modify(); - end; - - local procedure CalcWarning(): Boolean - begin - exit(false); - end; -} diff --git a/microsoft/knowledge/performance/do-not-modify-records-in-onaftergetrecord.good.al b/microsoft/knowledge/performance/do-not-modify-records-in-onaftergetrecord.good.al deleted file mode 100644 index 8e4d641..0000000 --- a/microsoft/knowledge/performance/do-not-modify-records-in-onaftergetrecord.good.al +++ /dev/null @@ -1,35 +0,0 @@ -page 51208 "Perf Sample NoModifyOAGR Good" -{ - PageType = List; - SourceTable = Customer; - - layout - { - area(Content) - { - repeater(Group) - { - field("No."; Rec."No.") { ApplicationArea = All; } - field(WarningFlag; ShowWarning) - { - ApplicationArea = All; - Caption = 'Warning'; - } - } - } - } - - trigger OnAfterGetRecord() - begin - // Page-local variable. No database write per row. - ShowWarning := CalcWarning(Rec); - end; - - var - ShowWarning: Boolean; - - local procedure CalcWarning(var Customer: Record Customer): Boolean - begin - exit(false); - end; -} diff --git a/microsoft/knowledge/performance/do-not-modify-records-in-onaftergetrecord.md b/microsoft/knowledge/performance/do-not-modify-records-in-onaftergetrecord.md deleted file mode 100644 index 8133229..0000000 --- a/microsoft/knowledge/performance/do-not-modify-records-in-onaftergetrecord.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [onaftergetrecord, modify, page, trigger, write-per-scroll] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Do not Modify records inside OnAfterGetRecord - -## Description - -`OnAfterGetRecord` fires for every row the page or repeater renders. On a list page the user scrolls through, the trigger runs hundreds of times per second. A `Modify()` call inside the trigger writes to the database for every row scrolled past — the user's mouse wheel generates the write storm, and the effect compounds with every other subscriber that reacts to the OnModify event. The database activity is usually invisible to the author in development, because the list page loads ten rows; on a production tenant scrolling through thousands of rows, the page becomes the top source of write volume. - -## Best Practice - -Derive display-only state into a page-level variable and bind that variable to the field control instead of writing to `Rec`. If the computed value is genuinely a stored attribute of the record, compute it once at the authoring site (OnValidate, OnInsert) and display the stored value on the list — do not recompute and rewrite on every render. - -See sample: `do-not-modify-records-in-onaftergetrecord.good.al`. - -## Anti Pattern - -An OnAfterGetRecord body that assigns a computed value to `Rec."Warning Flag"` and calls `Rec.Modify()` so the flag persists. The write fires per scroll, per user, per second — and every subscriber on the Rec's OnModify fires alongside. - -See sample: `do-not-modify-records-in-onaftergetrecord.bad.al`. diff --git a/microsoft/knowledge/performance/do-not-re-get-rec-inside-onaftergetrecord.bad.al b/microsoft/knowledge/performance/do-not-re-get-rec-inside-onaftergetrecord.bad.al deleted file mode 100644 index 0a5469a..0000000 --- a/microsoft/knowledge/performance/do-not-re-get-rec-inside-onaftergetrecord.bad.al +++ /dev/null @@ -1,34 +0,0 @@ -page 51203 "Perf Sample ReGetRec Bad" -{ - PageType = List; - SourceTable = "Assembly Line"; - - layout - { - area(Content) - { - repeater(Group) - { - field("No."; Rec."No.") { ApplicationArea = All; } - } - } - } - - trigger OnAfterGetRecord() - var - AssemblyLineRec: Record "Assembly Line"; - begin - // Redundant Get. The page runtime already loaded this row into Rec. - // At list-page scale this fires hundreds of times per scroll. - AssemblyLineRec.Get(Rec."Document Type", Rec."Document No.", Rec."Line No."); - ShowWarning := CheckAvailability(AssemblyLineRec); - end; - - var - ShowWarning: Boolean; - - local procedure CheckAvailability(var AssemblyLine: Record "Assembly Line"): Boolean - begin - exit(false); - end; -} diff --git a/microsoft/knowledge/performance/do-not-re-get-rec-inside-onaftergetrecord.good.al b/microsoft/knowledge/performance/do-not-re-get-rec-inside-onaftergetrecord.good.al deleted file mode 100644 index 2594f54..0000000 --- a/microsoft/knowledge/performance/do-not-re-get-rec-inside-onaftergetrecord.good.al +++ /dev/null @@ -1,30 +0,0 @@ -page 51202 "Perf Sample ReGetRec Good" -{ - PageType = List; - SourceTable = "Assembly Line"; - - layout - { - area(Content) - { - repeater(Group) - { - field("No."; Rec."No.") { ApplicationArea = All; } - } - } - } - - trigger OnAfterGetRecord() - begin - // Rec already holds the current row's values; no Get needed. - ShowWarning := CheckAvailability(Rec); - end; - - var - ShowWarning: Boolean; - - local procedure CheckAvailability(var AssemblyLine: Record "Assembly Line"): Boolean - begin - exit(false); - end; -} diff --git a/microsoft/knowledge/performance/do-not-re-get-rec-inside-onaftergetrecord.md b/microsoft/knowledge/performance/do-not-re-get-rec-inside-onaftergetrecord.md deleted file mode 100644 index 120b079..0000000 --- a/microsoft/knowledge/performance/do-not-re-get-rec-inside-onaftergetrecord.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [onaftergetrecord, get, rec, page-runtime, redundant-fetch] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Do not re-Get the current record inside OnAfterGetRecord - -## Description - -The page runtime loads the current record before firing `OnAfterGetRecord` — `Rec` already holds the row's values when the trigger body runs. Calling `Rec.Get(...)` (or any equivalent Get against the same key) inside the trigger issues a second database round-trip for data the runtime just fetched. On a list page that displays hundreds of rows during a scroll, this turns into hundreds of wasted round-trips per user interaction. The same concern applies to `OnAfterGetCurrRecord` on card and document pages, though the impact is smaller because the trigger fires per selection rather than per row. - -## Best Practice - -Read from `Rec` directly. When a helper method needs a different record, pass `Rec` as an argument or let the helper fetch its own lookup once; do not re-Get the current row. If the code truly needs a fresh value because it was modified by another session, design the refresh explicitly — document it in a comment — rather than paying the cost on every trigger fire. - -See sample: `do-not-re-get-rec-inside-onaftergetrecord.good.al`. - -## Anti Pattern - -An `OnAfterGetRecord` trigger body that starts with `AssemblyLineRec.Get("Document Type", "Document No.", "Line No.")` for the same keys the page runtime has already used — the Get restates what `Rec` already holds. Replace with a direct call against `Rec` (`CheckAvailability(Rec)`). - -See sample: `do-not-re-get-rec-inside-onaftergetrecord.bad.al`. diff --git a/microsoft/knowledge/performance/do-not-remove-sourcetabletemporary-from-api-page.bad.al b/microsoft/knowledge/performance/do-not-remove-sourcetabletemporary-from-api-page.bad.al new file mode 100644 index 0000000..1f99d7c --- /dev/null +++ b/microsoft/knowledge/performance/do-not-remove-sourcetabletemporary-from-api-page.bad.al @@ -0,0 +1,12 @@ +page 50249 "Perf Sample TempAPI Bad" +{ + PageType = API; + APIPublisher = 'perf'; + APIGroup = 'sample'; + APIVersion = 'v1.0'; + EntityName = 'outboxEmail'; + EntitySetName = 'outboxEmails'; + SourceTable = "Sent Email"; + // SourceTableTemporary removed — every request now hits SQL. + DelayedInsert = true; +} diff --git a/microsoft/knowledge/performance/do-not-remove-sourcetabletemporary-from-api-page.good.al b/microsoft/knowledge/performance/do-not-remove-sourcetabletemporary-from-api-page.good.al new file mode 100644 index 0000000..0ec95f7 --- /dev/null +++ b/microsoft/knowledge/performance/do-not-remove-sourcetabletemporary-from-api-page.good.al @@ -0,0 +1,12 @@ +page 50248 "Perf Sample TempAPI Good" +{ + PageType = API; + APIPublisher = 'perf'; + APIGroup = 'sample'; + APIVersion = 'v1.0'; + EntityName = 'outboxEmail'; + EntitySetName = 'outboxEmails'; + SourceTable = "Sent Email"; + SourceTableTemporary = true; + DelayedInsert = true; +} diff --git a/microsoft/knowledge/performance/do-not-remove-sourcetabletemporary-from-api-page.md b/microsoft/knowledge/performance/do-not-remove-sourcetabletemporary-from-api-page.md new file mode 100644 index 0000000..a7215be --- /dev/null +++ b/microsoft/knowledge/performance/do-not-remove-sourcetabletemporary-from-api-page.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [sourcetabletemporary, api-page, temporary, persistent, in-memory] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Removing SourceTableTemporary on an API page switches it from in-memory to persistent + +## Description + +`SourceTableTemporary = true` on a page makes the page's record buffer in-memory only — reads and writes do not touch SQL. The same applies to `TableType = Temporary` on a record. Removing either turns operations that were memory accesses into database round-trips. Per the upstream guidance, the change is "potentially increasing DB load for high-volume paths (API pages, background tasks)" — and on API pages especially, the change is invisible at the page definition but visible at production scale. + +## Best Practice + +If a page or record was declared temporary on purpose — to buffer payloads, accept synthetic rows, or expose computed data through an API surface without persisting it — keep it temporary. When removing the property looks necessary, audit the call sites first: a temporary API page is often consumed by integrations that issue many calls per minute, and the round-trip cost is paid per call. If persistence is genuinely required, weigh storage and lock cost against alternatives (a regular table the API page reads from, an event-driven write). + +See sample: `do-not-remove-sourcetabletemporary-from-api-page.good.al`. + +## Anti Pattern + +Dropping `SourceTableTemporary = true` from an API page to "simplify" it, without revisiting the access pattern. The page begins issuing real SQL on every request; locks now contend with other writers; bulk integrations slow proportionally. The same trap exists for a record that was `TableType = Temporary` and gets demoted to a persistent table to make a debugger view easier. + +See sample: `do-not-remove-sourcetabletemporary-from-api-page.bad.al`. diff --git a/microsoft/knowledge/performance/do-not-retarget-flowfield-calcformula-to-larger-tables.md b/microsoft/knowledge/performance/do-not-retarget-flowfield-calcformula-to-larger-tables.md deleted file mode 100644 index 6d1a572..0000000 --- a/microsoft/knowledge/performance/do-not-retarget-flowfield-calcformula-to-larger-tables.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [flowfield, calcformula, regression, source-table, sift] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Do not retarget a FlowField's CalcFormula to a larger source table - -## Description - -A FlowField's CalcFormula is evaluated every time the field is read — every time the page renders, every CalcFields call, every list page filter that references the field. Changing the CalcFormula's source table from a smaller, bounded, or already-filtered table to a larger unfiltered one multiplies the per-read cost. A common shape is the refactor from "Posted X" to "X" — the unposted line table is typically an order of magnitude larger and carries rows that the original FlowField never considered. The change compiles and may look like a simple scope widening; the performance impact is not visible until production load. - -## Best Practice - -When a FlowField CalcFormula changes source table, evaluate the before/after row counts, ensure a SIFT key exists on the new source that matches the formula's filters (see `add-sift-keys-for-flowfields`), and verify no existing callers rely on the tighter scope. If the widening is intentional, the corresponding SIFT keys on the new source must ship in the same PR. - -## Anti Pattern - -Changing a `sum("Posted Expense Report Line"."Amount" where(...))` formula to `sum("Expense Report Line"."Amount" where(...))` without touching the source table's keys. Every list page and dashboard that reads the FlowField now aggregates over the unposted table too, almost always without a supporting SIFT key. diff --git a/microsoft/knowledge/performance/filter-before-find.md b/microsoft/knowledge/performance/filter-before-find.md deleted file mode 100644 index f267761..0000000 --- a/microsoft/knowledge/performance/filter-before-find.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [filter, setrange, setfilter, findset, scan] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Filter before you find - -## Description - -Every call to FindSet, Find, or FindFirst on an unfiltered record variable scans the entire table. On hot tables (ledger entries, value entries, sales invoice lines) a production dataset can easily be millions of rows, so the cost of forgetting a filter is orders of magnitude worse than the cost of applying one. - -## Best Practice - -Apply SetRange or SetFilter to narrow the record set before calling FindSet or Find. The filters should match a key on the table (see set-current-key-to-match-filters). When iterating rows that belong to a parent record, set all key-field filters before the find call — never inside the repeat loop. - -See sample: `filter-before-find.good.al`. - -## Anti Pattern - -Calling FindSet with no filters and then discarding rows inside the loop with an if-statement forces the platform to read every row of the table before your code even runs. - -See sample: `filter-before-find.bad.al`. - diff --git a/microsoft/knowledge/performance/findset-true-applies-updlock-on-read.bad.al b/microsoft/knowledge/performance/findset-true-applies-updlock-on-read.bad.al new file mode 100644 index 0000000..d80786b --- /dev/null +++ b/microsoft/knowledge/performance/findset-true-applies-updlock-on-read.bad.al @@ -0,0 +1,25 @@ +codeunit 50237 "Perf Sample FindSetTrue Bad" +{ + procedure NormalizeNames() + var + Customer: Record Customer; + begin + // Read takes a shared lock; the Modify then needs to upgrade — that gap + // is the deadlock window FindSet(true) was designed to close. + if Customer.FindSet() then + repeat + Customer.Name := UpperCase(Customer.Name); + Customer.Modify(); + until Customer.Next() = 0; + end; + + procedure ReadOnlyOverlocked() + var + Customer: Record Customer; + begin + // No Modify in the loop, yet every row is read under UpdLock. + if Customer.FindSet(true) then + repeat + until Customer.Next() = 0; + end; +} diff --git a/microsoft/knowledge/performance/findset-true-applies-updlock-on-read.good.al b/microsoft/knowledge/performance/findset-true-applies-updlock-on-read.good.al new file mode 100644 index 0000000..bb6bf91 --- /dev/null +++ b/microsoft/knowledge/performance/findset-true-applies-updlock-on-read.good.al @@ -0,0 +1,23 @@ +codeunit 50236 "Perf Sample FindSetTrue Good" +{ + procedure NormalizeNames() + var + Customer: Record Customer; + begin + if Customer.FindSet(true) then + repeat + Customer.Name := UpperCase(Customer.Name); + Customer.Modify(); + until Customer.Next() = 0; + end; + + procedure SumBalances() Total: Decimal + var + Customer: Record Customer; + begin + if Customer.FindSet() then + repeat + Total += Customer."Balance (LCY)"; + until Customer.Next() = 0; + end; +} diff --git a/microsoft/knowledge/performance/findset-true-applies-updlock-on-read.md b/microsoft/knowledge/performance/findset-true-applies-updlock-on-read.md new file mode 100644 index 0000000..48a1deb --- /dev/null +++ b/microsoft/knowledge/performance/findset-true-applies-updlock-on-read.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [findset, updlock, readisolation, locking, modify, obsolete-syntax] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# FindSet(true) applies UpdLock on the read; the two-parameter form is obsolete + +## Description + +`FindSet()` and `FindSet(false)` are read-only — no locking. Per the upstream guidance, `FindSet(true)` "signifies the intent is to modify records" and "sets `ReadIsolation::UpdLock` on the record before finding rows." That is exactly the right shape when the loop body modifies each row: the read takes the same lock the modification will need, avoiding the deadlock window between an unlocked read and a later upgrade. The older two-parameter form `FindSet(ForUpdate, UpdateKey)` is obsolete — only the single-parameter signature should appear in new code. + +## Best Practice + +Use `FindSet(true)` only when the loop body genuinely modifies the iterated rows; use `FindSet()` (or `FindSet(false)`) when the loop only reads. Do not write `FindSet(true, true)` or `FindSet(true, false)` — the two-parameter form is the obsolete signature. + +See sample: `findset-true-applies-updlock-on-read.good.al`. + +## Anti Pattern + +`FindSet(true)` on a loop that does not modify the iterated rows takes an `UpdLock` the work does not need; competing readers and writers stall against a lock the loop never uses. The mirror anti-pattern is `FindSet()` (no parameter) on a loop that *does* modify each row — the read takes a shared lock, the `Modify` then needs to upgrade, and the gap between them is a deadlock candidate. + +See sample: `findset-true-applies-updlock-on-read.bad.al`. diff --git a/microsoft/knowledge/performance/flowfield-source-key-needs-sumindexfields.bad.al b/microsoft/knowledge/performance/flowfield-source-key-needs-sumindexfields.bad.al new file mode 100644 index 0000000..f784979 --- /dev/null +++ b/microsoft/knowledge/performance/flowfield-source-key-needs-sumindexfields.bad.al @@ -0,0 +1,15 @@ +tableextension 50226 "Perf Sample SIFT Bad Cust" extends Customer +{ + fields + { + // No SIFT key on Detailed Cust. Ledg. Entry for (Customer No.) with + // "Debit Amount" in SumIndexFields — the sum falls back to row-by-row + // aggregation over a ledger-scale table. + field(50226; "Perf Sample Total Debit"; Decimal) + { + FieldClass = FlowField; + CalcFormula = sum("Detailed Cust. Ledg. Entry"."Debit Amount" + where("Customer No." = field("No."))); + } + } +} diff --git a/microsoft/knowledge/performance/flowfield-source-key-needs-sumindexfields.good.al b/microsoft/knowledge/performance/flowfield-source-key-needs-sumindexfields.good.al new file mode 100644 index 0000000..420a84a --- /dev/null +++ b/microsoft/knowledge/performance/flowfield-source-key-needs-sumindexfields.good.al @@ -0,0 +1,23 @@ +tableextension 50224 "Perf Sample SIFT Good Ext" extends "Detailed Cust. Ledg. Entry" +{ + keys + { + key(PerfSampleByCustomer; "Customer No.", "Posting Date") + { + SumIndexFields = "Debit Amount"; + } + } +} + +tableextension 50225 "Perf Sample SIFT Good Cust" extends Customer +{ + fields + { + field(50225; "Perf Sample Total Debit"; Decimal) + { + FieldClass = FlowField; + CalcFormula = sum("Detailed Cust. Ledg. Entry"."Debit Amount" + where("Customer No." = field("No."))); + } + } +} diff --git a/microsoft/knowledge/performance/flowfield-source-key-needs-sumindexfields.md b/microsoft/knowledge/performance/flowfield-source-key-needs-sumindexfields.md new file mode 100644 index 0000000..3a63613 --- /dev/null +++ b/microsoft/knowledge/performance/flowfield-source-key-needs-sumindexfields.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [flowfield, sumindexfields, sift, key, calcformula, aa0232] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# A FlowField needs a source-table key that covers its CalcFormula + +## Description + +A FlowField is computed by SQL on demand. CodeCop AA0232 — "FlowFields should be indexed with SumIndexFields on corresponding keys" — captures the indexing requirement: the source table must declare a key that includes the fields the `CalcFormula` filters on, with the aggregated field listed in that key's `SumIndexFields`. When that alignment is in place, the platform answers `CalcFields`/`CalcSums` from SIFT; without it, the same query falls back to a row-by-row aggregation on what is often a ledger-scale table. Per the upstream guidance, "Missing SIFT indices cause performance issues on List pages." + +## Best Practice + +When introducing or changing a FlowField, walk the `CalcFormula`'s `WHERE` clause field by field and verify the source table has a key whose key fields cover those filters, with the aggregated field in `SumIndexFields`. The same applies when the destination side of the FlowField filter is a list-page column: the page filter triggers the FlowField on every visible row, and only SIFT keeps that affordable. + +See sample: `flowfield-source-key-needs-sumindexfields.good.al`. + +## Anti Pattern + +A `sum` FlowField against a large source table with no matching SIFT key. Each calculation aggregates rows directly; on a ledger-sized source the FlowField becomes the slowest column on every page that displays it. Pointing an existing FlowField's `CalcFormula` at a larger source table without verifying the new source's keys is the same trap a step removed — the upstream review guidance flags it as "CalcFormula changed to larger source table". + +See sample: `flowfield-source-key-needs-sumindexfields.bad.al`. diff --git a/microsoft/knowledge/performance/guard-before-get-not-after.good.al b/microsoft/knowledge/performance/guard-before-get-not-after.good.al deleted file mode 100644 index 5387f0f..0000000 --- a/microsoft/knowledge/performance/guard-before-get-not-after.good.al +++ /dev/null @@ -1,16 +0,0 @@ -codeunit 51200 "Perf Sample GuardBeforeGet Good" -{ - procedure HandleLine(var PurchaseLine: Record "Purchase Line") - var - PurchaseHeader: Record "Purchase Header"; - begin - // Cheap in-memory check first. Get only when the subsequent code needs the header. - if PurchaseLine."Selected Alloc. Account No." = '' then - exit; - - if not PurchaseHeader.Get(PurchaseLine."Document Type", PurchaseLine."Document No.") then - exit; - - // Work with PurchaseHeader. - end; -} diff --git a/microsoft/knowledge/performance/guard-before-get-not-after.md b/microsoft/knowledge/performance/guard-before-get-not-after.md deleted file mode 100644 index ea93e9d..0000000 --- a/microsoft/knowledge/performance/guard-before-get-not-after.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [get, guard, early-exit, wasted-fetch, conditional] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Place guard conditions before Get, not after - -## Description - -A `Record.Get(Key)` is a database round-trip. When the call site also contains an early-exit condition that may fire before the fetched record is used, the order of the two matters: `Get` first followed by a guard that may exit means every call pays the round-trip, including the calls that immediately return. Flipping the order — evaluate the guard first, `Get` only when needed — costs nothing in the happy path and turns the wasted round-trip into zero work on the exit path. The savings compound on hot tables and on code paths entered many times per user action. - -## Best Practice - -Evaluate cheap, in-memory conditions first. Only issue the `Get` (or `FindFirst`, `FindLast`) when the subsequent code actually needs the record's values. For complex procedures with multiple exit conditions, sort them cheapest-first: in-memory checks, then single-record lookups, then set iteration. - -See sample: `guard-before-get-not-after.good.al`. - -## Anti Pattern - -`PurchaseHeader.Get(PurchaseLine."Document Type", PurchaseLine."Document No."); if PurchaseLine."Selected Alloc. Account No." = '' then exit;` — the Get fires on every call; the exit discards the result for every call where `Selected Alloc. Account No.` is blank. - -See sample: `guard-before-get-not-after.bad.al`. diff --git a/microsoft/knowledge/performance/guard-event-subscribers-before-db-call.bad.al b/microsoft/knowledge/performance/guard-event-subscribers-before-db-call.bad.al new file mode 100644 index 0000000..15402a3 --- /dev/null +++ b/microsoft/knowledge/performance/guard-event-subscribers-before-db-call.bad.al @@ -0,0 +1,18 @@ +codeunit 50257 "Perf Sample EventGuard Bad" +{ + [EventSubscriber(ObjectType::Table, Database::"Sales Line", 'OnAfterValidateEvent', 'Quantity', false, false)] + local procedure OnAfterValidateQuantity(var Rec: Record "Sales Line") + var + Item: Record Item; + begin + // Item.Get fires on every Quantity edit — including lines whose Type is + // not Item. No cheap guard, no SetLoadFields. + Item.Get(Rec."No."); + if Item."Item Category Code" <> '' then + RecalculatePrice(Rec, Item); + end; + + local procedure RecalculatePrice(var SalesLine: Record "Sales Line"; var Item: Record Item) + begin + end; +} diff --git a/microsoft/knowledge/performance/guard-event-subscribers-before-db-call.good.al b/microsoft/knowledge/performance/guard-event-subscribers-before-db-call.good.al new file mode 100644 index 0000000..1530e97 --- /dev/null +++ b/microsoft/knowledge/performance/guard-event-subscribers-before-db-call.good.al @@ -0,0 +1,19 @@ +codeunit 50256 "Perf Sample EventGuard Good" +{ + [EventSubscriber(ObjectType::Table, Database::"Sales Line", 'OnAfterValidateEvent', 'Quantity', false, false)] + local procedure OnAfterValidateQuantity(var Rec: Record "Sales Line") + var + Item: Record Item; + begin + if Rec.Type <> Rec.Type::Item then + exit; + Item.SetLoadFields("Item Category Code"); + if Item.Get(Rec."No.") then + if Item."Item Category Code" <> '' then + RecalculatePrice(Rec, Item); + end; + + local procedure RecalculatePrice(var SalesLine: Record "Sales Line"; var Item: Record Item) + begin + end; +} diff --git a/microsoft/knowledge/performance/guard-event-subscribers-before-db-call.md b/microsoft/knowledge/performance/guard-event-subscribers-before-db-call.md new file mode 100644 index 0000000..c466ffe --- /dev/null +++ b/microsoft/knowledge/performance/guard-event-subscribers-before-db-call.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [event-subscriber, guard, db-call, frequently-fired, validate] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Guard event subscribers with cheap checks before any database call + +## Description + +Event subscribers fire on every event matching their signature — for `OnAfterValidateEvent` on a hot field like `Sales Line.Quantity`, that is every quantity edit by every user. Per the upstream guidance, "Keep event subscriber code lightweight" and "Avoid database operations in frequently-fired events — guard with cheap checks first." A `Get` or `FindFirst` at the top of such a subscriber pays a database round-trip on every fire, including the calls for which the subscriber's work was not needed. + +## Best Practice + +Open the subscriber with an in-memory predicate that filters out the calls the subscriber does not handle — record type, document type, status, parameter-passed flags. Only after the cheap guard passes should the body issue a database call, and only with `SetLoadFields` for the columns the body actually reads. + +See sample: `guard-event-subscribers-before-db-call.good.al`. + +## Anti Pattern + +`[EventSubscriber(...'OnAfterValidateEvent', 'Quantity', ...)] local procedure ... var Item: Record Item; begin Item.Get(Rec."No."); if Item.HasCustomPricing() then ...;` — `Item.Get` runs on every quantity change, including changes to lines whose `Type` is not `Item`. A pre-check `if Rec.Type <> Rec.Type::Item then exit;` ahead of the `Get` removes most of the calls. + +See sample: `guard-event-subscribers-before-db-call.bad.al`. diff --git a/microsoft/knowledge/performance/hidden-flowfields-still-calculate-on-pages.md b/microsoft/knowledge/performance/hidden-flowfields-still-calculate-on-pages.md deleted file mode 100644 index 184e363..0000000 --- a/microsoft/knowledge/performance/hidden-flowfields-still-calculate-on-pages.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [flowfield, visible, enabled, page, calcfields, feature-management] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Hidden FlowFields still calculate on pages - -## Description - -Setting `Visible = false` or `Enabled = false` on a FlowField hides the control but does not suppress the calculation. The server still runs the underlying CalcFields for every row the page renders. On a list page over a large table, the invisible column keeps consuming the same SQL as a visible one — the hiding is cosmetic only, and a diff that "turns off" an expensive FlowField by flipping `Visible` fixes nothing on the server. - -There are two correct remedies. The durable one is to remove the FlowField from the page or page-extension definition entirely — property toggles are not enough. The environment-level one, available where supported, is the **Calculate only visible FlowFields** feature in Feature Management; when enabled, the AL runtime skips calculation for non-visible FlowFields on pages. The feature is opt-in and administrator-controlled, so code cannot assume it is active. - -## Best Practice - -Remove unused or hidden FlowFields from the page or page extension. If the field is needed for some users but expensive for others, factor into a dedicated page variant rather than hiding it in place. Do not rely on `Visible = false` as a performance fix unless the tenant has enabled the Calculate only visible FlowFields feature and that assumption is acceptable. - -## Anti Pattern - -A performance PR that sets `Visible = false` on an expensive FlowField on a list page and claims the column no longer impacts load time. The control disappears from the UI, the CalcFields still runs for every row, and the list page stays slow. diff --git a/microsoft/knowledge/performance/keep-event-subscribers-lightweight.bad.al b/microsoft/knowledge/performance/keep-event-subscribers-lightweight.bad.al deleted file mode 100644 index ea3fd43..0000000 --- a/microsoft/knowledge/performance/keep-event-subscribers-lightweight.bad.al +++ /dev/null @@ -1,12 +0,0 @@ -codeunit 50140 "Perf Sample Subscriber Bad" -{ - [EventSubscriber(ObjectType::Table, Database::"Sales Line", 'OnAfterValidateEvent', 'No.', false, false)] - local procedure HeavyWorkOnSalesLineNo(var Rec: Record "Sales Line"; var xRec: Record "Sales Line") - var - HttpClient: HttpClient; - HttpResponse: HttpResponseMessage; - begin - // synchronous external call on a hot event - HttpClient.Get('https://example.com/validate?no=' + Rec."No.", HttpResponse); - end; -} diff --git a/microsoft/knowledge/performance/keep-event-subscribers-lightweight.good.al b/microsoft/knowledge/performance/keep-event-subscribers-lightweight.good.al deleted file mode 100644 index df25047..0000000 --- a/microsoft/knowledge/performance/keep-event-subscribers-lightweight.good.al +++ /dev/null @@ -1,20 +0,0 @@ -codeunit 50930 "Perf Sample Subscriber Good" -{ - [EventSubscriber(ObjectType::Table, Database::"Sales Line", 'OnAfterValidateEvent', 'No.', false, false)] - local procedure OnAfterValidateSalesLineNo(var Rec: Record "Sales Line") - var - Item: Record Item; - begin - if Rec.Type <> Rec.Type::Item then - exit; - - Item.SetLoadFields("Costing Method"); - if Item.Get(Rec."No.") then - if Item."Costing Method" = Item."Costing Method"::Specific then - UpdateSpecificCostingState(Rec); - end; - - local procedure UpdateSpecificCostingState(var SalesLine: Record "Sales Line") - begin - end; -} diff --git a/microsoft/knowledge/performance/keep-event-subscribers-lightweight.md b/microsoft/knowledge/performance/keep-event-subscribers-lightweight.md deleted file mode 100644 index 4aa59fb..0000000 --- a/microsoft/knowledge/performance/keep-event-subscribers-lightweight.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [event, subscriber, publisher, extension] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Keep event subscribers lightweight - -> Contributions welcome — open a PR to refine or extend this article. - -## Description - -Event subscribers run synchronously on the publisher's thread. If a subscriber does heavy work — a database query, a web service call, a layout render — every caller of the publisher pays that cost. Subscribers on hot events (OnAfterValidate on common fields, OnBeforeInsert on ledger-entry-like tables) can multiply a small per-call cost into a system-wide regression. - -## Best Practice - -Keep subscribers small: guard early with inexpensive checks on the publisher record before doing any database work, defer heavy work to a task queue or a background session, and cache results across invocations when the data is stable. In hot events, a cheap `Type`/`Status`/`IsTemporary` exit before a `Get` or `FindFirst` is often the difference between a rare lookup and an N+1 query across every posted line. - -See sample: `keep-event-subscribers-lightweight.good.al`. - -## Anti Pattern - -Calling an external web service, running a report, or iterating a large table from inside an event subscriber on a hot publisher makes every operation on that publisher as slow as the heaviest subscriber. - -See sample: `keep-event-subscribers-lightweight.bad.al`. - diff --git a/microsoft/knowledge/performance/keep-oncompanyopen-subscribers-lightweight.md b/microsoft/knowledge/performance/keep-oncompanyopen-subscribers-lightweight.md deleted file mode 100644 index 16a2061..0000000 --- a/microsoft/knowledge/performance/keep-oncompanyopen-subscribers-lightweight.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [oncompanyopen, oncompanyopencompleted, session, sign-in, subscriber, startup] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Keep OnCompanyOpen and OnCompanyOpenCompleted subscribers lightweight - -## Description - -`OnCompanyOpen` and `OnCompanyOpenCompleted` are raised every time a session is created — not only for interactive sign-ins, but also for every web service call, every job queue entry, every scheduled task, and every page background task. The session cannot run any AL code until every subscriber on these events has finished. Interactive users see a spinner; web service callers see elevated response times; background sessions sit idle waiting to start. - -Anything expensive in these subscribers is paid per session across the whole tenant. The two patterns that typically cause production incidents are outgoing HTTP calls to external services — which block AL execution until they complete (or time out) — and long-running SQL over large tables. An external service that is slow or unreachable turns into a tenant-wide sign-in outage, not a degraded feature. - -The code often looks harmless in review: a telemetry ping, a configuration refresh, a "just make sure the setup record exists" Get-or-Insert. Multiplied by session creations per minute, each of these becomes the critical path of sign-in. - -## Best Practice - -Keep `OnCompanyOpen` and `OnCompanyOpenCompleted` subscribers short and in-memory. Defer work that touches external services or large tables to a Page Background Task, a job queue entry, or a lazy first-use path. If an outgoing HTTP call in startup is truly unavoidable, set an aggressive timeout so a failing endpoint cannot stall session creation. - -## Anti Pattern - -An `OnCompanyOpen` subscriber that calls an external licensing API over HttpClient without a tight timeout. When the endpoint is slow, every new session in the tenant — UI, API, background — waits on the HTTP call before it can run any AL. diff --git a/microsoft/knowledge/performance/keep-sourcetabletemporary-on-api-and-background-pages.md b/microsoft/knowledge/performance/keep-sourcetabletemporary-on-api-and-background-pages.md deleted file mode 100644 index 9702960..0000000 --- a/microsoft/knowledge/performance/keep-sourcetabletemporary-on-api-and-background-pages.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [sourcetabletemporary, tabletype, temporary, api-page, persistence, regression] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Do not remove SourceTableTemporary or TableType = Temporary without understanding the impact - -## Description - -`SourceTableTemporary = true` on a page, and `TableType = Temporary` on a table, mean the underlying record operates in memory — Insert/Modify/Delete mutate the session buffer, not the database. Removing either property converts the same operations to real SQL writes. On an API page that external callers hit at high frequency, on a background task that processes thousands of records, or on a UI page that composes an in-memory list for display, the change from temporary to persistent can turn a lightweight operation into a major source of database load. The refactor is easy to propose ("why is this temporary?") and expensive to regret. - -## Best Practice - -When a diff removes `SourceTableTemporary = true` or `TableType = Temporary`, require justification explaining why persistence is now required and what paths still write. Review the callers for unexpected new writes, transaction scope, trigger fires, and contention. Keep the property unless the change genuinely needs persistence; an unused-looking temporary table on a bounded page is usually there for a reason. - -## Anti Pattern - -A cleanup PR that deletes `SourceTableTemporary = true` from an API page "because the source table already exists". The API now writes to the real table on every call, every consumer's requests reach the database, and the incidental side-effects in the source table's triggers start firing across tenants. diff --git a/microsoft/knowledge/performance/locktable-applies-to-whole-table-in-transaction.md b/microsoft/knowledge/performance/locktable-applies-to-whole-table-in-transaction.md deleted file mode 100644 index d6bf687..0000000 --- a/microsoft/knowledge/performance/locktable-applies-to-whole-table-in-transaction.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [locktable, updlock, transaction, contention, scope] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# LockTable applies to the whole table for the rest of the transaction - -## Description - -`Record.LockTable` is commonly read as "lock this record variable", but it does not work that way. The call applies `WITH (UPDLOCK)` to every subsequent read against the underlying table in the current transaction, regardless of which record variable issues the read. If `ItemA.LockTable` runs, then an unrelated `ItemB` variable on `Item`, a `FindSet` from a helper codeunit on `Item`, and any nested code that reads `Item` all acquire UPDLOCK until the transaction commits. - -The consequence is that calling LockTable early in a transaction — for example at the top of a routine "to be safe" — upgrades every read of that table for the remainder of the transaction to a writer-blocking lock. Contention scales with transaction length, not with how many writes the code actually performs. A LockTable deep in a call graph can silently serialize readers that never touch the LockTable-ing variable. - -## Best Practice - -Defer `LockTable` as late as possible and place it as close to the actual modification as you can. Keep transactions short so the UPDLOCK window is narrow. Do not add LockTable preemptively to "protect" a read that is not part of a read-modify-write sequence — the correct tool for read consistency is an isolation level (see Record.ReadIsolation), not a write lock. - -## Anti Pattern - -A procedure that calls `Rec.LockTable()` at the start "before doing anything" and then performs a long read-heavy validation before the eventual Modify. Every read in the validation now takes UPDLOCK on the whole table, and every other session that tries to read the same table waits on this transaction. diff --git a/microsoft/knowledge/performance/maintainsqlindex-false-breaks-flowfield-sift.bad.al b/microsoft/knowledge/performance/maintainsqlindex-false-breaks-flowfield-sift.bad.al new file mode 100644 index 0000000..edce742 --- /dev/null +++ b/microsoft/knowledge/performance/maintainsqlindex-false-breaks-flowfield-sift.bad.al @@ -0,0 +1,26 @@ +table 50227 "Perf Sample FA Journal Tmpl" +{ + fields + { + field(1; Name; Code[10]) { } + field(40; "No. of Lines"; Integer) + { + FieldClass = FlowField; + // Source key below has MaintainSQLIndex = false: SIFT cannot + // function, so this COUNT runs without a SQL index. + CalcFormula = count("FA Journal Line" + where("Journal Template Name" = field(Name))); + } + } +} + +tableextension 50228 "Perf Sample FA Jnl Line Ext" extends "FA Journal Line" +{ + keys + { + key(PerfSampleByTemplate; "Journal Template Name", "Journal Batch Name") + { + MaintainSQLIndex = false; + } + } +} diff --git a/microsoft/knowledge/performance/maintainsqlindex-false-breaks-flowfield-sift.md b/microsoft/knowledge/performance/maintainsqlindex-false-breaks-flowfield-sift.md new file mode 100644 index 0000000..e540859 --- /dev/null +++ b/microsoft/knowledge/performance/maintainsqlindex-false-breaks-flowfield-sift.md @@ -0,0 +1,24 @@ +--- +bc-version: [all] +domain: performance +keywords: [maintainsqlindex, key, sift, flowfield, sum, count, table-scan] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# MaintainSQLIndex = false on a key disables SIFT for FlowFields that depend on it + +## Description + +`MaintainSQLIndex = false` on a key tells the platform not to materialize that key as a SQL index. Per the upstream guidance, when a FlowField's source key carries that property, "SIFT cannot function, COUNT/SUM will table-scan." The flag is sometimes set to save write-path cost on a rarely-queried key, but if a `CalcFormula` aggregates through that exact key, the FlowField loses its index — every `CalcFields`/`CalcSums`/list-page filter that triggers it runs without one. + +## Best Practice + +When changing a key property to `MaintainSQLIndex = false`, find every FlowField whose `CalcFormula` filters on that key and verify another key covers the same fields. When adding a FlowField whose source table has only a `MaintainSQLIndex = false` key for its filter columns, add a fully-indexed key (or accept that the FlowField cannot ride SIFT and reshape the design — see `flowfield-source-key-needs-sumindexfields.md`). + +See sample: `maintainsqlindex-false-breaks-flowfield-sift.bad.al`. + +## Anti Pattern + +A FlowField whose `CalcFormula`'s `WHERE` columns line up with a key that has `MaintainSQLIndex = false`. The schema looks correct — the key exists, the SumIndexFields are listed — but at runtime the platform has no SQL index to use, and the aggregation table-scans on every invocation. diff --git a/microsoft/knowledge/performance/maintainsqlindex-false-disables-sift.md b/microsoft/knowledge/performance/maintainsqlindex-false-disables-sift.md deleted file mode 100644 index acd3eae..0000000 --- a/microsoft/knowledge/performance/maintainsqlindex-false-disables-sift.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [maintainsqlindex, sift, sumindexfields, flowfield, calcsums, key] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# MaintainSQLIndex = false on a key disables SIFT for the FlowFields that depend on it - -## Description - -SIFT relies on the underlying SQL index being maintained by the platform. Setting `MaintainSQLIndex = false` on a key drops the SQL index without dropping the AL key declaration — the key compiles, FlowFields that reference its SumIndexFields compile, and CalcSums calls against matching filters compile. At runtime, however, the SIFT optimization silently cannot engage, and every aggregate falls back to a table scan. The symptom is a FlowField whose read time degrades linearly with row count, with no code-level signal pointing at the key property as the cause. - -## Best Practice - -Keep `MaintainSQLIndex = true` (the default) on any key whose SumIndexFields back a FlowField or that callers use with CalcSums. When a key is genuinely unused and the SQL index cost is the concern, remove the key entirely rather than leaving it in place with `MaintainSQLIndex = false`. If the FlowField is still needed, pick a different key that is maintained. - -## Anti Pattern - -A source-table key declared with `SumIndexFields` and `MaintainSQLIndex = false`, with a FlowField referencing those sum fields. The FlowField appears to work in development against small datasets and becomes a full table scan on production-scale data, with no error message and no obvious culprit in the code under review. diff --git a/microsoft/knowledge/performance/only-fetch-records-you-use.bad.al b/microsoft/knowledge/performance/only-fetch-records-you-use.bad.al deleted file mode 100644 index ef3401f..0000000 --- a/microsoft/knowledge/performance/only-fetch-records-you-use.bad.al +++ /dev/null @@ -1,10 +0,0 @@ -codeunit 50107 "Perf Sample OnlyFetchUsed Bad" -{ - procedure CustomerHasEntries(CustomerNo: Code[20]): Boolean - var - CustLedgerEntry: Record "Cust. Ledger Entry"; - begin - CustLedgerEntry.SetRange("Customer No.", CustomerNo); - exit(CustLedgerEntry.FindSet()); - end; -} diff --git a/microsoft/knowledge/performance/only-fetch-records-you-use.good.al b/microsoft/knowledge/performance/only-fetch-records-you-use.good.al deleted file mode 100644 index a989e28..0000000 --- a/microsoft/knowledge/performance/only-fetch-records-you-use.good.al +++ /dev/null @@ -1,10 +0,0 @@ -codeunit 50106 "Perf Sample OnlyFetchUsed Good" -{ - procedure CustomerHasEntries(CustomerNo: Code[20]): Boolean - var - CustLedgerEntry: Record "Cust. Ledger Entry"; - begin - CustLedgerEntry.SetRange("Customer No.", CustomerNo); - exit(not CustLedgerEntry.IsEmpty()); - end; -} diff --git a/microsoft/knowledge/performance/only-fetch-records-you-use.md b/microsoft/knowledge/performance/only-fetch-records-you-use.md deleted file mode 100644 index 3a1e40e..0000000 --- a/microsoft/knowledge/performance/only-fetch-records-you-use.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [findset, get, aa0175, wasted-fetch, read] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Only fetch records you use - -> Contributions welcome — open a PR to refine or extend this article. - -## Description - -CodeCop rule AA0175 flags code that retrieves a record and then does not use it. Every Find, FindSet, FindFirst, FindLast, or Get has a cost: the platform reads rows from SQL, materializes them, and transports them to the AL runtime. A call whose result is never read is wasted work, and on hot tables that work is never free. - -## Best Practice - -Retrieve a record only when you need one or more of its field values. When you only need to know whether at least one row matches a filter, use IsEmpty (see use-isempty-for-existence-checks). When you only need a subset of fields, use SetLoadFields (see use-setloadfields-for-partial-records). - -See sample: `only-fetch-records-you-use.good.al`. - -## Anti Pattern - -Calling FindSet or Get and then ignoring the result, or using it only as a boolean existence test, performs the full fetch and throws the data away. - -See sample: `only-fetch-records-you-use.bad.al`. - diff --git a/microsoft/knowledge/performance/pair-findset-with-next-loop.bad.al b/microsoft/knowledge/performance/pair-findset-with-next-loop.bad.al new file mode 100644 index 0000000..ec5eec3 --- /dev/null +++ b/microsoft/knowledge/performance/pair-findset-with-next-loop.bad.al @@ -0,0 +1,13 @@ +codeunit 50209 "Perf Sample FindSetNext Bad" +{ + procedure SumCustomerBalances() Total: Decimal + var + Customer: Record Customer; + begin + // AA0233: FindFirst paired with Next — single-row API used to iterate. + if Customer.FindFirst() then + repeat + Total += Customer."Balance (LCY)"; + until Customer.Next() = 0; + end; +} diff --git a/microsoft/knowledge/performance/pair-findset-with-next-loop.good.al b/microsoft/knowledge/performance/pair-findset-with-next-loop.good.al new file mode 100644 index 0000000..955c05b --- /dev/null +++ b/microsoft/knowledge/performance/pair-findset-with-next-loop.good.al @@ -0,0 +1,18 @@ +codeunit 50208 "Perf Sample FindSetNext Good" +{ + procedure SumCustomerBalances() Total: Decimal + var + Customer: Record Customer; + begin + if Customer.FindSet() then + repeat + Total += Customer."Balance (LCY)"; + until Customer.Next() = 0; + end; + + procedure GetFirstUSCustomer(var Customer: Record Customer): Boolean + begin + Customer.SetRange("Country/Region Code", 'US'); + exit(Customer.FindFirst()); + end; +} diff --git a/microsoft/knowledge/performance/pair-findset-with-next-loop.md b/microsoft/knowledge/performance/pair-findset-with-next-loop.md new file mode 100644 index 0000000..80f855c --- /dev/null +++ b/microsoft/knowledge/performance/pair-findset-with-next-loop.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [findset, findfirst, findlast, get, next, repeat-until, aa0181, aa0233] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use FindSet with repeat..Next; do not pair FindFirst/FindLast/Get with Next + +## Description + +Two CodeCop rules carve out the loop pattern. AA0181 says `FindSet()`/`Find()` "must be used with `Next()` method" — these are the multi-row APIs that the runtime sets up for forward iteration. AA0233 says do "NOT use `FindFirst()`/`FindLast()`/`Get()` with `Next()`" — these are single-row APIs, and iterating from them "wastes CPU and bandwidth." Both rules together define one boundary: choose `FindSet` when the body iterates; choose `FindFirst`, `FindLast`, or `Get` when the body uses exactly one record. + +## Best Practice + +When the body executes `repeat ... until Next() = 0;`, open the iteration with `FindSet()`. When the body needs one record and does not call `Next`, use `FindFirst`, `FindLast`, or — if the full primary key is known — `Get` (see `use-get-instead-of-findfirst-on-full-primary-key.md`). The choice is per call site, not a global preference. + +See sample: `pair-findset-with-next-loop.good.al`. + +## Anti Pattern + +`if Customer.FindFirst() then repeat ... until Customer.Next() = 0;` — AA0233 flags this. The single-row API does not prepare the runtime for iteration, so the loop pays a cost the FindSet path does not. The mirror anti-pattern is calling `FindSet` to read a single record (see `use-isempty-for-existence-check.md` when only existence is required). + +See sample: `pair-findset-with-next-loop.bad.al`. diff --git a/microsoft/knowledge/performance/pass-false-to-insert-when-trigger-not-needed.good.al b/microsoft/knowledge/performance/pass-false-to-insert-when-trigger-not-needed.good.al new file mode 100644 index 0000000..3bcdd9a --- /dev/null +++ b/microsoft/knowledge/performance/pass-false-to-insert-when-trigger-not-needed.good.al @@ -0,0 +1,21 @@ +codeunit 50240 "Perf Sample Trigger Param Good" +{ + procedure BulkFlagOrders(var SalesHeader: Record "Sales Header") + begin + if SalesHeader.FindSet(true) then + repeat + SalesHeader."Job Queue Status" := SalesHeader."Job Queue Status"::"Scheduled for Posting"; + // Trigger has nothing to add for a status flip in this code path. + SalesHeader.Modify(false); + until SalesHeader.Next() = 0; + end; + + procedure CreateOrder(var SalesHeader: Record "Sales Header"; CustomerNo: Code[20]) + begin + SalesHeader.Init(); + SalesHeader."Document Type" := SalesHeader."Document Type"::Order; + SalesHeader."Sell-to Customer No." := CustomerNo; + // OnInsert allocates the No.-Series number — the trigger is required. + SalesHeader.Insert(true); + end; +} diff --git a/microsoft/knowledge/performance/pass-false-to-insert-when-trigger-not-needed.md b/microsoft/knowledge/performance/pass-false-to-insert-when-trigger-not-needed.md new file mode 100644 index 0000000..45214b8 --- /dev/null +++ b/microsoft/knowledge/performance/pass-false-to-insert-when-trigger-not-needed.md @@ -0,0 +1,24 @@ +--- +bc-version: [all] +domain: performance +keywords: [insert, modify, delete, trigger, run-trigger, write-parameters] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Pass false to Insert/Modify/Delete when the table triggers do not need to fire + +## Description + +`Insert(true)`, `Modify(true)`, and `Delete(true)` run the table's `OnInsert`/`OnModify`/`OnDelete` trigger; the `(false)` form skips it. Per the upstream guidance, the trigger form should be used "only when needed" — every row whose write fires a trigger pays that cost, even when the trigger has nothing useful to add for the current call site. For tight bulk write paths the difference compounds linearly with row count. + +## Best Practice + +Reach for the `(false)` form when the calling code already enforces the invariants the trigger would, or when the trigger is empty for the current table/extension. Use `(true)` when the trigger does work the caller depends on (number-series allocation, validation, cascading writes). Decide per call, not by code style: a default of "always `true`" makes bulk writes pay for triggers they did not need, and a default of "always `false`" silently skips validation the trigger was put there to enforce. + +See sample: `pass-false-to-insert-when-trigger-not-needed.good.al`. + +## Anti Pattern + +Looping over thousands of rows and calling `Modify(true)` on each, when the table's `OnModify` trigger does nothing relevant for the operation. The trigger cost is paid per row; the user-visible behavior is identical to the `(false)` form. The mirror is using `(false)` for an operation that depends on trigger-side defaulting and silently producing rows that fail downstream validation. diff --git a/microsoft/knowledge/performance/prefer-dictionary-over-temporary-table-for-lookups.md b/microsoft/knowledge/performance/prefer-dictionary-over-temporary-table-for-lookups.md new file mode 100644 index 0000000..458d098 --- /dev/null +++ b/microsoft/knowledge/performance/prefer-dictionary-over-temporary-table-for-lookups.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: performance +keywords: [dictionary, temporary-table, lookup, o-of-1, key-lookup] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Prefer a Dictionary over a temporary table for pure lookups + +## Description + +A temporary table supports a full record API — filters, iteration, multi-field keys — but a pure key→value lookup pays for plumbing it does not use. Per the upstream guidance, "if a temporary table record is ONLY used as a lookup table, it is faster to use a dictionary which supports O(1) lookups instead of O(lg n) for temporary tables." The Dictionary type has no record machinery to traverse; the key hash answers the lookup directly. + +## Best Practice + +When the use of a temp record is "set a key, see if the row exists, read a single value", switch to `Dictionary of [Key, Value]`. Use the temp-table form when the use genuinely needs filtering, iteration in a specific order, or a multi-field key. Compatibility with code that expects a `Record` parameter is a real reason to keep the temp table; performance alone, on a pure lookup, is not. + +## Anti Pattern + +A temp `Record` declared, populated row by row, then queried with `SetRange(KeyField, X); if Find('=') then Value := Rec.ValueField;`. The lookup hashes the key behind the scenes and does the same work a `Dictionary` would, plus the per-row record overhead. The pattern often appears because the author originally needed iteration and the iteration was later removed without revisiting the data structure. diff --git a/microsoft/knowledge/performance/prefer-direct-record-over-recordref.bad.al b/microsoft/knowledge/performance/prefer-direct-record-over-recordref.bad.al deleted file mode 100644 index cf0ed96..0000000 --- a/microsoft/knowledge/performance/prefer-direct-record-over-recordref.bad.al +++ /dev/null @@ -1,20 +0,0 @@ -codeunit 50135 "Perf Sample RecordRef Bad" -{ - procedure BlockCustomer(CustomerNo: Code[20]) - var - RecRef: RecordRef; - PkRef: KeyRef; - NoRef: FieldRef; - BlockedRef: FieldRef; - begin - RecRef.Open(Database::Customer); - PkRef := RecRef.KeyIndex(1); - NoRef := PkRef.FieldIndex(1); - NoRef.SetRange(CustomerNo); - if not RecRef.FindFirst() then - exit; - BlockedRef := RecRef.Field(54); - BlockedRef.Value(2); - RecRef.Modify(true); - end; -} diff --git a/microsoft/knowledge/performance/prefer-direct-record-over-recordref.good.al b/microsoft/knowledge/performance/prefer-direct-record-over-recordref.good.al deleted file mode 100644 index 44ed151..0000000 --- a/microsoft/knowledge/performance/prefer-direct-record-over-recordref.good.al +++ /dev/null @@ -1,12 +0,0 @@ -codeunit 50134 "Perf Sample RecordRef Good" -{ - procedure BlockCustomer(CustomerNo: Code[20]) - var - Customer: Record Customer; - begin - if not Customer.Get(CustomerNo) then - exit; - Customer.Blocked := Customer.Blocked::All; - Customer.Modify(true); - end; -} diff --git a/microsoft/knowledge/performance/prefer-direct-record-over-recordref.md b/microsoft/knowledge/performance/prefer-direct-record-over-recordref.md deleted file mode 100644 index 00ca392..0000000 --- a/microsoft/knowledge/performance/prefer-direct-record-over-recordref.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [recordref, fieldref, dynamic, reflection] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Prefer direct record access over RecordRef where possible - -> Contributions welcome — open a PR to refine or extend this article. - -## Description - -RecordRef and FieldRef are the platform's reflection API: they work across tables the compiler does not know at authoring time. That flexibility costs per-operation overhead — every field access goes through a lookup — and loses compile-time type checking. For operations where the table is known, a strongly-typed Record variable is simpler and faster. - -## Best Practice - -Use Record variables for code paths that target a known table. Reach for RecordRef and FieldRef only when the table is genuinely dynamic (generic export/import, field-agnostic utilities, cross-table integrations). - -Only flag RecordRef usage as a performance concern when it appears inside a **hot, unbounded loop** — typically iterating over ledger-entry-scale tables (10,000+ rows) — where a strongly-typed Record alternative exists. RecordRef in bounded contexts, one-off operations, admin tools, setup helpers, or wizard code is not a performance concern and should not be flagged. - -See sample: `prefer-direct-record-over-recordref.good.al`. - -## Anti Pattern - -Using RecordRef as a habit, even when the target table is hardcoded two lines earlier, costs performance and hides intent from reviewers. - -See sample: `prefer-direct-record-over-recordref.bad.al`. - diff --git a/microsoft/knowledge/performance/prefer-get-for-primary-key-lookups.good.al b/microsoft/knowledge/performance/prefer-get-for-primary-key-lookups.good.al deleted file mode 100644 index a370f7d..0000000 --- a/microsoft/knowledge/performance/prefer-get-for-primary-key-lookups.good.al +++ /dev/null @@ -1,10 +0,0 @@ -codeunit 50130 "Perf Sample GetVsFind Good" -{ - procedure CustomerName(CustomerNo: Code[20]): Text[100] - var - Customer: Record Customer; - begin - if Customer.Get(CustomerNo) then - exit(Customer.Name); - end; -} diff --git a/microsoft/knowledge/performance/prefer-get-for-primary-key-lookups.md b/microsoft/knowledge/performance/prefer-get-for-primary-key-lookups.md deleted file mode 100644 index e7d4d88..0000000 --- a/microsoft/knowledge/performance/prefer-get-for-primary-key-lookups.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [get, findfirst, primary-key, lookup] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Prefer Get for primary-key lookups - -> Contributions welcome — open a PR to refine or extend this article. - -## Description - -Get is a direct primary-key lookup: one index seek, one row, done. FindFirst with SetRange on the primary key fields reaches the same row through a more general code path and carries the overhead of filter setup and a broader optimizer decision. - -## Best Practice - -When the complete primary key is known, call Get. Use FindFirst only for non-primary-key lookups or when the filter is a partial prefix of the key. - -See sample: `prefer-get-for-primary-key-lookups.good.al`. - -## Anti Pattern - -Setting one SetRange per primary-key field and then calling FindFirst reproduces Get with more typing and slightly worse performance. - -See sample: `prefer-get-for-primary-key-lookups.bad.al`. - diff --git a/microsoft/knowledge/performance/prefer-modifyall-over-per-row-modify.bad.al b/microsoft/knowledge/performance/prefer-modifyall-over-per-row-modify.bad.al new file mode 100644 index 0000000..0b8c21d --- /dev/null +++ b/microsoft/knowledge/performance/prefer-modifyall-over-per-row-modify.bad.al @@ -0,0 +1,15 @@ +codeunit 50243 "Perf Sample ModifyAll Bad" +{ + procedure ApplyPriceUpdate(NewPrice: Decimal) + var + SalesLine: Record "Sales Line"; + begin + SalesLine.SetRange(Type, SalesLine.Type::Item); + // N writes when one ModifyAll would do. + if SalesLine.FindSet() then + repeat + SalesLine.Validate("Unit Price", NewPrice); + SalesLine.Modify(true); + until SalesLine.Next() = 0; + end; +} diff --git a/microsoft/knowledge/performance/combine-multiple-modifyall-calls.bad.al b/microsoft/knowledge/performance/prefer-modifyall-over-per-row-modify.good.al similarity index 50% rename from microsoft/knowledge/performance/combine-multiple-modifyall-calls.bad.al rename to microsoft/knowledge/performance/prefer-modifyall-over-per-row-modify.good.al index 3a9e190..c33d9c0 100644 --- a/microsoft/knowledge/performance/combine-multiple-modifyall-calls.bad.al +++ b/microsoft/knowledge/performance/prefer-modifyall-over-per-row-modify.good.al @@ -1,12 +1,19 @@ -codeunit 51207 "Perf Sample CombineMA Bad" +codeunit 50242 "Perf Sample ModifyAll Good" { - procedure UpdateTolerance(DocumentNo: Code[20]; ToleranceAmount: Decimal) + procedure ApplyPriceUpdate(NewPrice: Decimal) + var + SalesLine: Record "Sales Line"; + begin + SalesLine.SetRange(Type, SalesLine.Type::Item); + SalesLine.ModifyAll("Unit Price", NewPrice); + end; + + procedure ApplyTolerance(DocumentNo: Code[20]; ToleranceAmount: Decimal) var CustLedgerEntry: Record "Cust. Ledger Entry"; begin CustLedgerEntry.SetRange("Document No.", DocumentNo); CustLedgerEntry.SetRange(Open, true); - // Two scans over the same filtered rows on a 10M-row ledger table. CustLedgerEntry.ModifyAll("Accepted Payment Tolerance", ToleranceAmount); CustLedgerEntry.ModifyAll("Accepted Pmt. Disc. Tolerance", false); end; diff --git a/microsoft/knowledge/performance/prefer-modifyall-over-per-row-modify.md b/microsoft/knowledge/performance/prefer-modifyall-over-per-row-modify.md new file mode 100644 index 0000000..73a095c --- /dev/null +++ b/microsoft/knowledge/performance/prefer-modifyall-over-per-row-modify.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [modifyall, deleteall, bulk, loop, modify, set-based] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use ModifyAll / DeleteAll instead of per-row Modify / Delete in a loop + +## Description + +`ModifyAll` and `DeleteAll` are the bulk APIs. Per the upstream guidance, they "execute as single SQL statements" when the table supports it — one round-trip updates or deletes every row in the filtered set. The anti-pattern is the loop equivalent: `FindSet` followed by per-row `Modify`/`Delete`, where the runtime issues one write per row. On a production-scale table the difference is the difference between a single statement and N statements. + +## Best Practice + +When the loop body does nothing more than assign a constant value (or a value computed once) to one or more fields, replace the loop with `ModifyAll("Field 1", Value1)` — and chain additional `ModifyAll` calls for additional fields. The same shape applies to `DeleteAll`. Be aware that the bulk APIs can regress to row-by-row execution for tables with certain trigger or media-field configurations (see `triggers-and-media-field-regress-modifyall.md`); when that regression applies, multiple `ModifyAll` calls become more expensive than one manual loop, so the choice is conditional, not absolute. + +See sample: `prefer-modifyall-over-per-row-modify.good.al`. + +## Anti Pattern + +`if SalesLine.FindSet() then repeat SalesLine.Validate("Unit Price", NewPrice); SalesLine.Modify(true); until SalesLine.Next() = 0;` — N writes when one would do. The pattern is easy to introduce when the loop initially does per-row computation and is later simplified to assign a constant; the loop scaffolding survives the simplification. + +See sample: `prefer-modifyall-over-per-row-modify.bad.al`. diff --git a/microsoft/knowledge/performance/prefer-readisolation-over-locktable-for-reads.bad.al b/microsoft/knowledge/performance/prefer-readisolation-over-locktable-for-reads.bad.al new file mode 100644 index 0000000..c23886c --- /dev/null +++ b/microsoft/knowledge/performance/prefer-readisolation-over-locktable-for-reads.bad.al @@ -0,0 +1,13 @@ +codeunit 50233 "Perf Sample ReadIso Bad" +{ + procedure GetOrCreate(var AgentStatus: Record "Agent Status") + begin + // LockTable poisons every subsequent read of Agent Status in the + // surrounding transaction with UPDLOCK — even for callers that only read. + AgentStatus.LockTable(); + if not AgentStatus.Get() then begin + AgentStatus.Init(); + AgentStatus.Insert(); + end; + end; +} diff --git a/microsoft/knowledge/performance/prefer-readisolation-over-locktable-for-reads.good.al b/microsoft/knowledge/performance/prefer-readisolation-over-locktable-for-reads.good.al new file mode 100644 index 0000000..be5cd55 --- /dev/null +++ b/microsoft/knowledge/performance/prefer-readisolation-over-locktable-for-reads.good.al @@ -0,0 +1,11 @@ +codeunit 50232 "Perf Sample ReadIso Good" +{ + procedure GetOrCreate(var AgentStatus: Record "Agent Status") + begin + AgentStatus.ReadIsolation := IsolationLevel::ReadCommitted; + if not AgentStatus.Get() then begin + AgentStatus.Init(); + AgentStatus.Insert(); + end; + end; +} diff --git a/microsoft/knowledge/performance/prefer-readisolation-over-locktable-for-reads.md b/microsoft/knowledge/performance/prefer-readisolation-over-locktable-for-reads.md new file mode 100644 index 0000000..c2f888b --- /dev/null +++ b/microsoft/knowledge/performance/prefer-readisolation-over-locktable-for-reads.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [readisolation, locktable, updlock, read-only, transaction-scope, isolation-level] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Prefer ReadIsolation over LockTable for read-only scenarios + +## Description + +`LockTable` and `ReadIsolation` solve different problems with different blast radii. Per the upstream guidance, "`LockTable` ensures that all READS against that table will happen with UPDLOCK for the remainder of the transaction." `ReadIsolation` "only pertains to the current record instance, while `LockTable` affects the lockstate of the entire transaction." `ReadIsolation` is also more expressive: it can heighten or lower the isolation level inside an already-established transaction. Reaching for `LockTable` when only a single read needs guarding therefore poisons every later read on that table — including reads in other code paths that share the transaction. + +## Best Practice + +For a read-only operation, or a single read that needs a higher isolation level than the surrounding transaction, set `Rec.ReadIsolation := IsolationLevel::ReadCommitted;` (or the level the call requires) immediately before the read. The hint applies only to that record instance. Save `LockTable` for code that genuinely needs every subsequent read on the table to acquire an update lock (see `findset-true-applies-updlock-on-read.md` for the alternative narrower mechanism on iterated reads). + +See sample: `prefer-readisolation-over-locktable-for-reads.good.al`. + +## Anti Pattern + +`Rec.LockTable();` at the top of a helper that only reads, perhaps to "make sure the read is consistent". Every subsequent read on that table for the rest of the transaction acquires `UPDLOCK`, including reads from unrelated code paths fused into the same transaction. The contention surfaces in unrelated user sessions, not in the helper that introduced it. + +See sample: `prefer-readisolation-over-locktable-for-reads.bad.al`. diff --git a/microsoft/knowledge/performance/production-scale-tables-warrant-extra-analysis.md b/microsoft/knowledge/performance/production-scale-tables-warrant-extra-analysis.md new file mode 100644 index 0000000..f290448 --- /dev/null +++ b/microsoft/knowledge/performance/production-scale-tables-warrant-extra-analysis.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: performance +keywords: [table-size, hot-table, ledger-entry, item, customer, sales-line, scale] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Production-scale tables warrant concrete performance analysis + +## Description + +Some Business Central tables routinely reach sizes where access patterns matter much more than they do on a generic table. The upstream review guidance lists ten of them with P95 row counts: Item (~800k), Customer (~800k), Item Ledger Entry (~10M), Value Entry (~10M), G/L Entry (~10M), VAT Entry (~10M), Customer Ledger Entry (~10M), Vendor Ledger Entry (~10M), Sales Invoice Header (~300k), and Sales Invoice Line (~3M). These figures are not platform constants — they are the volumes a reviewer should assume when judging a change. + +## Best Practice + +For any code change that touches one of these tables, do not approve the pattern on intuition. Walk through the SQL the change implies (one query? one per row? one per chunk?), the memory it allocates (a `List` per row?), and the CPU work per row, against the row counts above. Smaller tables can tolerate a sub-optimal access pattern; these cannot. The rest of this domain — `apply-filters-before-iterating.md`, `use-setloadfields-for-partial-records.md`, `avoid-calcfields-in-loops.md`, `pair-findset-with-next-loop.md`, `avoid-get-inside-loop-on-persistent-tables.md` — exists primarily so that code touching these tables stays on the safe side of each rule. + +## Anti Pattern + +Generalizing from a unit test or a development tenant. A `FindSet` loop with a per-row `CalcFields` may execute in milliseconds against a few thousand rows on a developer's machine and become a multi-minute table scan against ten million Value Entry rows in production. Reasoning about performance from the dev-tenant timing instead of the production volume is the single most common way a regression ships. diff --git a/microsoft/knowledge/performance/query-objects-bypass-primary-key-cache.md b/microsoft/knowledge/performance/query-objects-bypass-primary-key-cache.md deleted file mode 100644 index 3c05629..0000000 --- a/microsoft/knowledge/performance/query-objects-bypass-primary-key-cache.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [query, cache, primary-key-cache, record-api, sql] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Query objects bypass the primary-key cache and always hit SQL - -## Description - -The Record API reuses a server-side primary-key cache: repeated reads of the same rows within a session or request can be served from memory without going to SQL. Query objects do not participate in that cache. Every execution of a query goes to the database, even when the same rows were just read through a Record variable in the same transaction. - -This inverts the usual intuition that queries are always faster than record loops. Queries win when they exploit a covering index, aggregate, or join multiple tables in SQL that AL would otherwise loop. They lose when the data is small, already cached, or read repeatedly in a short window — the per-call SQL round-trip dominates. - -Query objects also cannot write, cannot be backed by a page, and do not see the records a temp-table-backed AL flow has inserted but not committed. Choose them for set-based reads over indexed data, not as a generic replacement for the Record API. - -## Best Practice - -Use a query object when the shape of the work is genuinely set-based: aggregation, multi-table join, or a large read that benefits from a covering index. For hot single-record or small-result reads — especially lookups that will repeat in the same request — prefer the Record API so the primary-key cache does its job. - -## Anti Pattern - -Replacing a `Get` or a short filtered `FindSet` inside a frequently-called helper with a query object "for performance". Every caller now pays a SQL round-trip that the Record API cache had been absorbing, and the helper gets slower under load, not faster. diff --git a/microsoft/knowledge/performance/set-current-key-to-match-filters.good.al b/microsoft/knowledge/performance/set-current-key-to-match-filters.good.al deleted file mode 100644 index a891f40..0000000 --- a/microsoft/knowledge/performance/set-current-key-to-match-filters.good.al +++ /dev/null @@ -1,9 +0,0 @@ -codeunit 50122 "Perf Sample SetCurrentKey Good" -{ - procedure LinesForDocument(DocumentType: Enum "Sales Document Type"; DocumentNo: Code[20]; var SalesLine: Record "Sales Line") - begin - SalesLine.SetCurrentKey("Document Type", "Document No.", "Line No."); - SalesLine.SetRange("Document Type", DocumentType); - SalesLine.SetRange("Document No.", DocumentNo); - end; -} diff --git a/microsoft/knowledge/performance/set-current-key-to-match-filters.md b/microsoft/knowledge/performance/set-current-key-to-match-filters.md deleted file mode 100644 index 95effd7..0000000 --- a/microsoft/knowledge/performance/set-current-key-to-match-filters.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [setcurrentkey, key, index, sort, filter] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Set the current key to match your filters - -> Contributions welcome — open a PR to refine or extend this article. - -## Description - -AL chooses a key for a Find call based on the current SetCurrentKey selection. When filters do not align with any key, the platform either scans or falls back to a less selective index. On tables with production-scale row counts, this is the difference between an index seek and a table scan. - -## Best Practice - -Call SetCurrentKey with the fields you filter and sort on, in the order they appear in a table key. If no suitable key exists, add one via a table extension rather than relying on an unsupported filter pattern. - -See sample: `set-current-key-to-match-filters.good.al`. - -## Anti Pattern - -Setting many filters on fields that no key covers, and leaving the key selection to the platform's heuristics, produces non-deterministic performance that degrades as the table grows. - diff --git a/microsoft/knowledge/performance/setcurrentkey-aligns-key-with-filters.good.al b/microsoft/knowledge/performance/setcurrentkey-aligns-key-with-filters.good.al new file mode 100644 index 0000000..4ab3b0a --- /dev/null +++ b/microsoft/knowledge/performance/setcurrentkey-aligns-key-with-filters.good.al @@ -0,0 +1,15 @@ +codeunit 50230 "Perf Sample SetCurrentKey Good" +{ + procedure ProcessLines(var SalesHeader: Record "Sales Header") + var + SalesLine: Record "Sales Line"; + begin + SalesLine.SetCurrentKey("Document Type", "Document No.", "Line No."); + SalesLine.SetRange("Document Type", SalesHeader."Document Type"); + SalesLine.SetRange("Document No.", SalesHeader."No."); + if SalesLine.FindSet() then + repeat + // ... + until SalesLine.Next() = 0; + end; +} diff --git a/microsoft/knowledge/performance/setcurrentkey-aligns-key-with-filters.md b/microsoft/knowledge/performance/setcurrentkey-aligns-key-with-filters.md new file mode 100644 index 0000000..f7f8180 --- /dev/null +++ b/microsoft/knowledge/performance/setcurrentkey-aligns-key-with-filters.md @@ -0,0 +1,24 @@ +--- +bc-version: [all] +domain: performance +keywords: [setcurrentkey, key, index, filter, sort] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Pick a key whose fields cover the filter and sort with SetCurrentKey + +## Description + +The platform chooses a key for each record access. When the filters or required sort do not match the primary key — or any non-explicit choice — the query may run against a key that does not cover the filter columns. Per the upstream guidance, "Use `SetCurrentKey()` to select the most efficient key for your filters" and "match key fields to your filter/sort requirements." Filtering on fields that are not in any key is flagged as bad — there is no index to ride and the access ends up reading more than necessary. + +## Best Practice + +When the access pattern is anything other than primary-key lookup, look at the filters and the desired sort, then either pick an existing key whose leading fields cover them and call `SetCurrentKey(...)`, or declare a new key on the table for the pattern. Match leading fields first — a key starting with `"Document Type", "Document No.", "Line No."` serves a filter on those three; a key starting with `"Line No."` does not. + +See sample: `setcurrentkey-aligns-key-with-filters.good.al`. + +## Anti Pattern + +Applying filters on fields that no key indexes, leaving the platform to read more than it should. The query produces the right answer; the cost surfaces only at production volume. The mirror case is forgetting `SetCurrentKey` when the wanted sort differs from the primary key — the iteration may then be sorted in memory after a wider read than necessary. diff --git a/microsoft/knowledge/performance/singleton-setup-tables-need-no-access-optimization.md b/microsoft/knowledge/performance/singleton-setup-tables-need-no-access-optimization.md new file mode 100644 index 0000000..6b32de1 --- /dev/null +++ b/microsoft/knowledge/performance/singleton-setup-tables-need-no-access-optimization.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: performance +keywords: [singleton, setup-table, sales-receivables-setup, general-ledger-setup, setloadfields, bounded] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Singleton setup tables hold one row; access-pattern optimization is wasted + +## Description + +Business Central setup tables — `Sales & Receivables Setup`, `General Ledger Setup`, `FA Setup`, `Purchases & Payables Setup`, and the broader pattern of any `*Setup` table — hold at most one record per company. Per the upstream guidance, "any access pattern is fine, no `SetLoadFields` needed" on these tables. The same applies to other small bounded tables (enum mappings, permission objects, Role IDs) and system metadata tables (`TableMetadata`, `Field`, `AllObjWithCaption`) where iteration is safe. + +## Best Practice + +Skip access-pattern optimization on singleton-setup-style tables. `SalesReceivablesSetup.Get()` does not need `SetLoadFields` (see `use-setloadfields-for-partial-records.md`); a `repeat ... until` over a permission-object table does not need bulk operations. Spend the review attention on the production-scale tables instead (see `production-scale-tables-warrant-extra-analysis.md`). + +## Anti Pattern + +Mechanically applying the rules in this domain to every `Record` variable in the codebase. Flagging "missing `SetLoadFields`" on `GeneralLedgerSetup` or "use `IsEmpty` instead of `FindSet`" on a setup table adds noise without payoff — the optimization saves nothing measurable on a one-row table — and trains readers to ignore the review channel. diff --git a/microsoft/knowledge/performance/skip-setloadfields-on-narrow-tables-and-short-loops.md b/microsoft/knowledge/performance/skip-setloadfields-on-narrow-tables-and-short-loops.md deleted file mode 100644 index 33a299e..0000000 --- a/microsoft/knowledge/performance/skip-setloadfields-on-narrow-tables-and-short-loops.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [setloadfields, heuristics, narrow-table, short-loop, diminishing-returns] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# SetLoadFields pays off at scale; skip it on narrow tables and short loops - -## Description - -`SetLoadFields` reduces the number of columns the platform hydrates per record. It delivers real savings on wide tables with blob, media, or many text fields when the iteration touches a small subset. Below certain thresholds the accounting flips the other way: narrow tables (fewer than ~10 fields) save almost nothing per row, and short loops (fewer than ~10 iterations) amortize the narrowing over too few fetches to outweigh the extra code and the specification-and-access-set coupling that future edits have to maintain. Recommending SetLoadFields on every Find/Get call produces low-value churn and invites the opposite mistake — listing a field in SetLoadFields and then forgetting to access it, which triggers a second round-trip to load the missing field. - -## Best Practice - -Reach for SetLoadFields when the table is wide (10+ fields, especially with blobs) AND the code path reads a small subset AND the iteration or fetch count is material. When in doubt on a short loop over a narrow table, leave SetLoadFields out; the complexity cost is not earned. The filter-only-field rule from `omit-filter-only-fields-from-setloadfields` still applies: fields used only in filters stay out of the list. - -## Anti Pattern - -A 5-row loop over a 6-field setup table prefaced by `Rec.SetLoadFields(...)`. The author has added two lines of code, coupled the loop to a field specification that needs to be updated on every schema change, and saved nanoseconds. The same pattern applied mechanically to every Find call in a codebase produces hundreds of diffs that do not move the performance needle. diff --git a/microsoft/knowledge/performance/split-read-only-and-write-paths-to-avoid-locktable.bad.al b/microsoft/knowledge/performance/split-read-only-and-write-paths-to-avoid-locktable.bad.al deleted file mode 100644 index cb38dbf..0000000 --- a/microsoft/knowledge/performance/split-read-only-and-write-paths-to-avoid-locktable.bad.al +++ /dev/null @@ -1,14 +0,0 @@ -codeunit 51205 "Perf Sample LockTable Bad" -{ - procedure GetOrCreate(var AgentStatus: Record "Integer"): Boolean - begin - // Every caller takes an exclusive lock, even the ones that only read. - // Under load the helper becomes the dominant contention point. - AgentStatus.LockTable(); - if not AgentStatus.Get(1) then begin - AgentStatus.Number := 1; - AgentStatus.Insert(); - end; - exit(true); - end; -} diff --git a/microsoft/knowledge/performance/split-read-only-and-write-paths-to-avoid-locktable.good.al b/microsoft/knowledge/performance/split-read-only-and-write-paths-to-avoid-locktable.good.al deleted file mode 100644 index 1eca2d9..0000000 --- a/microsoft/knowledge/performance/split-read-only-and-write-paths-to-avoid-locktable.good.al +++ /dev/null @@ -1,18 +0,0 @@ -codeunit 51204 "Perf Sample LockTable Good" -{ - procedure GetOrCreate(var AgentStatus: Record "Integer"): Boolean - begin - // Read path: consistent read on this record instance only. - AgentStatus.ReadIsolation := IsolationLevel::ReadCommitted; - if AgentStatus.Get(1) then - exit(true); - - // Write path: lock only when we are about to insert. - AgentStatus.LockTable(); - if not AgentStatus.Get(1) then begin - AgentStatus.Number := 1; - AgentStatus.Insert(); - end; - exit(true); - end; -} diff --git a/microsoft/knowledge/performance/split-read-only-and-write-paths-to-avoid-locktable.md b/microsoft/knowledge/performance/split-read-only-and-write-paths-to-avoid-locktable.md deleted file mode 100644 index f9dae60..0000000 --- a/microsoft/knowledge/performance/split-read-only-and-write-paths-to-avoid-locktable.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [locktable, read-only, write-path, contention, helper] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Split read-only and write paths so LockTable runs only when needed - -## Description - -LockTable causes reads against the table to use update locks for the remainder of the transaction. In a helper that is called from many read-only sites and a few write sites, placing LockTable unconditionally at the top serializes every reader on every other reader's lock — the helper becomes a system-wide contention point. The correct shape is a conditional structure: try the read-only path first, and only fall through to LockTable when the code genuinely needs to modify the table. - -## Best Practice - -For paths that are read-only, prefer `ReadIsolation` over `LockTable`. Setting `Rec.ReadIsolation := IsolationLevel::ReadCommitted` on a record variable gives fine-grained, per-instance control over the isolation level without taking an update lock on the table for the rest of the transaction. Use `ReadCommitted` as the normal read-only choice; move to `RepeatableRead`, `Serializable`, or an update lock only when the code has a concrete consistency invariant that requires it. Use `LockTable` only for paths that genuinely write to the table. - -For helpers that may or may not modify records, factor the code so readers return immediately without a lock and only writers reach the LockTable call. A common pattern: attempt `Rec.Get()` first; if it returns the row, exit with the value; otherwise LockTable and proceed with the Insert. Document the pattern in a comment on the helper so callers understand why the LockTable is inside a branch. - -See sample: `split-read-only-and-write-paths-to-avoid-locktable.good.al`. - -## Anti Pattern - -A `GetOrCreate` helper that unconditionally calls `Rec.LockTable()` at the top, then Gets the row, then returns it. Every reader now blocks every other reader even though none of them intend to write. Under load the helper becomes the dominant bottleneck. - -See sample: `split-read-only-and-write-paths-to-avoid-locktable.bad.al`. diff --git a/microsoft/knowledge/performance/table-event-subscribers-disable-bulk-modifyall-and-deleteall.md b/microsoft/knowledge/performance/table-event-subscribers-disable-bulk-modifyall-and-deleteall.md deleted file mode 100644 index 21ce82d..0000000 --- a/microsoft/knowledge/performance/table-event-subscribers-disable-bulk-modifyall-and-deleteall.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [event, subscriber, modifyall, deleteall, bulk, row-by-row] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Table event subscribers force ModifyAll and DeleteAll to run row-by-row - -## Description - -`ModifyAll` and `DeleteAll` normally compile to a single set-based SQL UPDATE or DELETE. That optimization is conditional: the server falls back to row-by-row execution when it must invoke AL per affected row. Common causes are global table delete triggers, table modify/delete event subscribers, and Media or MediaSet fields added to the table or a table extension. - -The slowdown is invisible in the caller's source: the call site still reads as a bulk operation. It only shows up under load, and adding an apparently cheap subscriber (even an empty one, or one that guards on a condition and returns) is enough to trigger the fallback for every caller of ModifyAll/DeleteAll on that table across the system. Central tables — Item Ledger Entry, G/L Entry, Sales Line — are the worst places to attach such subscribers because every extension's bulk operation pays the cost. - -## Best Practice - -Before subscribing to a table's modify or delete events, consider whether the logic can live elsewhere — on the triggering action, on a specific OnValidate, or on a business-event publisher. If the subscriber, global trigger, or Media/MediaSet field is unavoidable, document that the table may no longer support set-based ModifyAll/DeleteAll. When a table has not regressed, prefer a small number of ModifyAll/DeleteAll calls; they are still commonly 10-50x faster than a manual loop. - -## Anti Pattern - -An empty or nearly-empty `OnAfterModifyEvent` subscriber on `Sales Line` added as a placeholder for future integration. Every `ModifyAll` on `Sales Line` — in the base app, in every extension, in every tenant — can now run one SQL UPDATE per row. The same regression can come from a global delete trigger or from adding a Media field to the table. diff --git a/microsoft/knowledge/performance/temporary-tables-have-no-database-cost.md b/microsoft/knowledge/performance/temporary-tables-have-no-database-cost.md new file mode 100644 index 0000000..d799b5f --- /dev/null +++ b/microsoft/knowledge/performance/temporary-tables-have-no-database-cost.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: performance +keywords: [temporary-table, in-memory, findset, findfirst, get, no-db-cost] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Temporary tables are in-memory; access-pattern rules do not apply + +## Description + +A record declared `Temporary` (or a page with `SourceTableTemporary = true`) lives entirely in memory; reads and writes never reach SQL. Per the upstream guidance, "any access pattern (FindSet, FindFirst, Get, loops) on temp tables is acceptable — they are in-memory and fast." The rules in the rest of this domain — partial loading, bulk operations, N+1 detection, `IsEmpty` over `Count` — exist to avoid database round-trips that a temporary table does not perform. + +## Best Practice + +Recognize the `Temporary` property (on a record variable, table declaration, or page's `SourceTableTemporary`) and exempt the code from access-pattern flags. The `SetLoadFields`/`FindSet` discipline that matters for `Customer` does not matter for a temporary `Customer` variable used as a working set. The interesting performance question on a temp table is volume in memory, not query plan. + +## Anti Pattern + +Flagging a temporary table's `FindFirst` inside a loop, or a temporary table without `SetLoadFields`, as a performance issue. The recommendation produces no measurable gain and obscures genuine issues elsewhere in the same review. diff --git a/microsoft/knowledge/performance/treat-ledger-entry-tables-as-production-scale.md b/microsoft/knowledge/performance/treat-ledger-entry-tables-as-production-scale.md deleted file mode 100644 index cd38037..0000000 --- a/microsoft/knowledge/performance/treat-ledger-entry-tables-as-production-scale.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [ledger-entry, production-scale, hot-table, item-ledger, gl-entry, sales-invoice-line] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Treat ledger-entry and line-type tables as production-scale when reviewing performance - -## Description - -A handful of Business Central tables grow to millions of rows in production tenants: Item Ledger Entry, Value Entry, G/L Entry, VAT Entry, Customer Ledger Entry, Vendor Ledger Entry, Sales Invoice Line, Purchase Invoice Line, Detailed Cust. Ledg. Entry, Detailed Vendor Ledg. Entry, and equivalent line-type tables. Master-data tables like Customer, Vendor, and Item typically reach the high hundreds of thousands. A performance review that treats these tables with the same latitude as setup tables or small reference lists under-reports real regressions; the same filter-or-key mistake that is invisible on a 50-row table is a full table scan over millions of rows on these. - -## Best Practice - -When a code change touches any of the above tables, demand concrete performance reasoning before accepting it: an appropriate key selection, a SetLoadFields narrowing, filters that use the key prefix, no N+1 inside the iteration. A finding on one of these tables should almost never be downgraded from High to Low on the grounds that "the operation looks small" — at production scale the operation is never small. - -## Anti Pattern - -Applying review heuristics uniformly to all tables. A missing SetCurrentKey on a Setup table changes nothing; the same mistake on Item Ledger Entry turns a list page into a multi-second load. The asymmetry is the whole point of the catalog — knowing which tables warrant the stricter read. diff --git a/microsoft/knowledge/performance/triggers-and-media-field-regress-modifyall.md b/microsoft/knowledge/performance/triggers-and-media-field-regress-modifyall.md new file mode 100644 index 0000000..c4890a0 --- /dev/null +++ b/microsoft/knowledge/performance/triggers-and-media-field-regress-modifyall.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: performance +keywords: [modifyall, deleteall, regression, triggers, media, getglobaltabletriggermask, subscriber] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Triggers, subscribers, and media fields can silently regress ModifyAll / DeleteAll + +## Description + +`ModifyAll` and `DeleteAll` usually execute as single SQL statements, but the platform falls back to a fetch-then-row-by-row loop under specific conditions. Per the upstream guidance, the regression is triggered by any of: global database triggers defined via `GetGlobalTableTriggerMask` or `GetDatabaseTableTriggerSetup` (so that `OnDatabaseDelete`/`OnGlobalDelete` must run); event subscribers on the table's `OnBeforeDelete`/`OnAfterDelete` (for `DeleteAll`) or `OnBeforeModify`/`OnAfterModify` (for `ModifyAll`); or "adding a Media or MediaSet table field to either the table or table extension." Each of these forces the platform to materialize each affected row in AL. + +## Best Practice + +Before introducing any of the above on a table — a global trigger registration, a `Modify`/`Delete` subscriber, a media or media-set field — note every `ModifyAll`/`DeleteAll` that targets the table and assess whether the regression cost is acceptable. The upstream guidance is explicit: "There should be a very good reason for doing any of the above since they will significantly regress performance of `ModifyAll` and/or `DeleteAll`." Once a table has regressed, multiple `ModifyAll` calls each iterate the rows themselves, so consolidating to one explicit `FindSet`+`Modify` loop becomes faster than chaining several `ModifyAll` calls. + +## Anti Pattern + +Adding a media field to a hot table — or subscribing to its modify/delete events from a generic logging codeunit — without auditing the bulk-write call sites. The schema change is mechanical; the performance change is invisible at the call site and only surfaces when a previously fast `ModifyAll` starts paying the per-row trigger cost in production. The mirror anti-pattern is chaining several `ModifyAll` calls on a table that has already regressed; each one re-iterates the same rows. diff --git a/microsoft/knowledge/performance/uninstall-test-framework-to-measure-insert-performance.md b/microsoft/knowledge/performance/uninstall-test-framework-to-measure-insert-performance.md deleted file mode 100644 index ca4d81c..0000000 --- a/microsoft/knowledge/performance/uninstall-test-framework-to-measure-insert-performance.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [test-framework, bulk-insert, performance-test, benchmark, insert] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Uninstall the test framework to measure insert performance - -## Description - -Business Central's server uses a bulk insert optimization that batches multiple row inserts into a single SQL round-trip when conditions allow. When the test framework is installed on the environment, that optimization is disabled — inserts fall back to one SQL statement per row. The behavior is a side-effect of how the test framework instruments AL execution and applies whether or not any test is actually running. - -For functional tests this is invisible; for performance measurement it is catastrophic. A benchmark that inserts ten thousand rows with the test framework present reports a number that has nothing to do with production, because production will not run in row-by-row mode. Treating the measurement as a real baseline produces conclusions that are wrong by a large constant factor. - -The same caveat applies to Update and Delete paths where bulk optimizations exist — the test framework's presence suppresses them. - -## Best Practice - -Before running any insert, update, or delete throughput benchmark — whether via the Performance Toolkit, a hand-rolled harness, or `SessionInformation` assertions — uninstall the test framework from the target environment. Re-install it only for functional test runs. Document this step in the benchmark procedure so future measurements are comparable. - -## Anti Pattern - -A performance regression report comparing two builds on a sandbox that has the test framework installed. Both numbers are row-by-row timings; the ratio between them may be meaningful, but neither number reflects production, and any absolute throughput claim derived from the run is wrong. diff --git a/microsoft/knowledge/performance/use-addloadfields-in-report-layouts.md b/microsoft/knowledge/performance/use-addloadfields-in-report-layouts.md deleted file mode 100644 index 3e14fdc..0000000 --- a/microsoft/knowledge/performance/use-addloadfields-in-report-layouts.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [report, addloadfields, ondatapreitem, layout, partial-record] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Use AddLoadFields in report dataitems - -> Contributions welcome — open a PR to refine or extend this article. - -## Description - -Reports iterate a dataitem's record automatically; the developer does not control the Find call directly. AddLoadFields, called in OnPreDataItem, tells the platform which fields the layout and the dataitem triggers will read. Without it the report streams every field of every row — for a ledger-entry dataitem on a production tenant, that is the dominant cost of the report. - -## Best Practice - -In each dataitem's OnPreDataItem trigger, call AddLoadFields for every field used by the layout, by the dataitem's triggers, and by any code that runs in the row-level event hooks. If the layout uses a FlowField, also ensure CalcFields is called and that the underlying key is loaded (see add-sift-keys-for-flowfields). - -See sample: `use-addloadfields-in-report-layouts.good.al`. - -## Anti Pattern - -Omitting AddLoadFields is the default for reports generated by the AL wizard. For a dataitem backed by a ledger-entry table, this silently turns the report into a full-column scan. - diff --git a/microsoft/knowledge/performance/use-calcsums-for-flowfield-totals.bad.al b/microsoft/knowledge/performance/use-calcsums-for-flowfield-totals.bad.al deleted file mode 100644 index efdbdb0..0000000 --- a/microsoft/knowledge/performance/use-calcsums-for-flowfield-totals.bad.al +++ /dev/null @@ -1,14 +0,0 @@ -codeunit 50115 "Perf Sample CalcSums Bad" -{ - procedure OutstandingForCustomer(CustomerNo: Code[20]) Total: Decimal - var - CustLedgerEntry: Record "Cust. Ledger Entry"; - begin - CustLedgerEntry.SetRange("Customer No.", CustomerNo); - CustLedgerEntry.SetRange(Open, true); - if CustLedgerEntry.FindSet() then - repeat - Total += CustLedgerEntry."Remaining Amt. (LCY)"; - until CustLedgerEntry.Next() = 0; - end; -} diff --git a/microsoft/knowledge/performance/use-calcsums-for-flowfield-totals.good.al b/microsoft/knowledge/performance/use-calcsums-for-flowfield-totals.good.al deleted file mode 100644 index f825b1f..0000000 --- a/microsoft/knowledge/performance/use-calcsums-for-flowfield-totals.good.al +++ /dev/null @@ -1,12 +0,0 @@ -codeunit 50114 "Perf Sample CalcSums Good" -{ - procedure OutstandingForCustomer(CustomerNo: Code[20]): Decimal - var - CustLedgerEntry: Record "Cust. Ledger Entry"; - begin - CustLedgerEntry.SetRange("Customer No.", CustomerNo); - CustLedgerEntry.SetRange(Open, true); - CustLedgerEntry.CalcSums("Remaining Amt. (LCY)"); - exit(CustLedgerEntry."Remaining Amt. (LCY)"); - end; -} diff --git a/microsoft/knowledge/performance/use-calcsums-for-flowfield-totals.md b/microsoft/knowledge/performance/use-calcsums-for-flowfield-totals.md deleted file mode 100644 index 6cb4a55..0000000 --- a/microsoft/knowledge/performance/use-calcsums-for-flowfield-totals.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [calcsums, sift, sum, aggregate, totals] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Use CalcSums to aggregate filtered sets - -> Contributions welcome — open a PR to refine or extend this article. - -## Description - -When the task is to compute a sum over a filtered set, CalcSums lets the platform push the aggregation down to SQL using SIFT indexes. Iterating rows in AL to accumulate a total transports every row's data to the runtime only to discard it after adding one field. On ledger-entry-scale tables this difference is dramatic. The same SIFT infrastructure backs Sum-style FlowFields; when the value you need is already declared as a FlowField, calling CalcSums on the underlying table with the correct filters produces the same aggregate. - -## Best Practice - -Set the required filters on the record, then call CalcSums on the field you want aggregated. Ensure the table has a key whose SumIndexFields includes the summed field and whose key prefix matches the filters (see add-sift-keys-for-flowfields). - -See sample: `use-calcsums-for-flowfield-totals.good.al`. - -## Anti Pattern - -Looping a filtered set with FindSet and adding a field to an accumulator on every iteration performs work in AL that SQL already knows how to do in one aggregate query. - -See sample: `use-calcsums-for-flowfield-totals.bad.al`. - diff --git a/microsoft/knowledge/performance/use-dictionary-for-temporary-identity-lookups.bad.al b/microsoft/knowledge/performance/use-dictionary-for-temporary-identity-lookups.bad.al deleted file mode 100644 index 1155668..0000000 --- a/microsoft/knowledge/performance/use-dictionary-for-temporary-identity-lookups.bad.al +++ /dev/null @@ -1,16 +0,0 @@ -codeunit 50934 "Perf Sample TempLookup Bad" -{ - procedure MarkSeenCustomers(var SalesLine: Record "Sales Line") - var - TempCustomer: Record Customer temporary; - begin - if SalesLine.FindSet() then - repeat - if not TempCustomer.Get(SalesLine."Sell-to Customer No.") then begin - TempCustomer.Init(); - TempCustomer."No." := SalesLine."Sell-to Customer No."; - TempCustomer.Insert(); - end; - until SalesLine.Next() = 0; - end; -} diff --git a/microsoft/knowledge/performance/use-dictionary-for-temporary-identity-lookups.good.al b/microsoft/knowledge/performance/use-dictionary-for-temporary-identity-lookups.good.al deleted file mode 100644 index ff6d499..0000000 --- a/microsoft/knowledge/performance/use-dictionary-for-temporary-identity-lookups.good.al +++ /dev/null @@ -1,13 +0,0 @@ -codeunit 50933 "Perf Sample Dictionary Good" -{ - procedure MarkSeenCustomers(var SalesLine: Record "Sales Line") - var - SeenCustomerNos: Dictionary of [Code[20], Boolean]; - begin - if SalesLine.FindSet() then - repeat - if not SeenCustomerNos.ContainsKey(SalesLine."Sell-to Customer No.") then - SeenCustomerNos.Add(SalesLine."Sell-to Customer No.", true); - until SalesLine.Next() = 0; - end; -} diff --git a/microsoft/knowledge/performance/use-dictionary-for-temporary-identity-lookups.md b/microsoft/knowledge/performance/use-dictionary-for-temporary-identity-lookups.md deleted file mode 100644 index 0e2843c..0000000 --- a/microsoft/knowledge/performance/use-dictionary-for-temporary-identity-lookups.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [dictionary, temporary-table, lookup, identity, o1] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Use Dictionary for temporary identity lookups - -## Description - -A temporary record is useful when code needs record semantics: filters, keys, FlowFields, or table-shaped buffers. When the only operation is "have I seen this key?" or "what value belongs to this key?", a `Dictionary` is the simpler and faster structure. Dictionary lookup is O(1) by key, while a temporary table still pays record and key-management overhead. - -## Best Practice - -Use `Dictionary` for in-memory lookup sets and maps whose keys fit in memory and whose access pattern is by identity. Keep temporary tables for data that needs table APIs, multiple keys, filter expressions, or later processing as records. - -See sample: `use-dictionary-for-temporary-identity-lookups.good.al`. - -## Anti Pattern - -Creating a temporary table solely to call `Get` or `FindFirst` by a single key in a loop. The code looks familiar to AL developers, but it is heavier than the lookup problem requires. - -See sample: `use-dictionary-for-temporary-identity-lookups.bad.al`. diff --git a/microsoft/knowledge/performance/use-findset-readonly-by-default.bad.al b/microsoft/knowledge/performance/use-findset-readonly-by-default.bad.al deleted file mode 100644 index efd0e49..0000000 --- a/microsoft/knowledge/performance/use-findset-readonly-by-default.bad.al +++ /dev/null @@ -1,10 +0,0 @@ -codeunit 50109 "Perf Sample FindSetReadonly Bad" -{ - procedure SumInvoiceLines(var SalesLine: Record "Sales Line") Total: Decimal - begin - if SalesLine.FindSet(true) then - repeat - Total += SalesLine."Line Amount"; - until SalesLine.Next() = 0; - end; -} diff --git a/microsoft/knowledge/performance/use-findset-readonly-by-default.good.al b/microsoft/knowledge/performance/use-findset-readonly-by-default.good.al deleted file mode 100644 index 371c5f6..0000000 --- a/microsoft/knowledge/performance/use-findset-readonly-by-default.good.al +++ /dev/null @@ -1,10 +0,0 @@ -codeunit 50108 "Perf Sample FindSetReadonly Good" -{ - procedure SumInvoiceLines(var SalesLine: Record "Sales Line") Total: Decimal - begin - if SalesLine.FindSet() then - repeat - Total += SalesLine."Line Amount"; - until SalesLine.Next() = 0; - end; -} diff --git a/microsoft/knowledge/performance/use-findset-readonly-by-default.md b/microsoft/knowledge/performance/use-findset-readonly-by-default.md deleted file mode 100644 index 6843c91..0000000 --- a/microsoft/knowledge/performance/use-findset-readonly-by-default.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [findset, lock, locktable, readonly, update] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Use FindSet in read-only mode by default - -## Description - -FindSet has two modes: FindSet() and FindSet(false) are read-only and take no update lock; FindSet(true) sets update-lock read isolation on the record before fetching. Update locks are expensive and hold for the lock scope, so passing `true` when you do not intend to modify the records increases contention under load. - -## Best Practice - -Call FindSet with no arguments when the loop only reads field values. Pass `true` only when the same loop is expected to call Modify, Delete, or Rename on the record, and the correctness of the operation depends on the matching rows being locked for the iteration. - -See sample: `use-findset-readonly-by-default.good.al`. - -## Anti Pattern - -Writing FindSet(true) reflexively for every iteration forces the platform to take a LockTable on every call, even when the loop only reads values. The older two-parameter signature `FindSet(ForUpdate, UpdateKey)` is obsolete and must not be used. - -See sample: `use-findset-readonly-by-default.bad.al`. - diff --git a/microsoft/knowledge/performance/use-findset-with-next.bad.al b/microsoft/knowledge/performance/use-findset-with-next.bad.al deleted file mode 100644 index 8467ce1..0000000 --- a/microsoft/knowledge/performance/use-findset-with-next.bad.al +++ /dev/null @@ -1,10 +0,0 @@ -codeunit 50103 "Perf Sample FindSetWithNext Bad" -{ - procedure SumLineAmounts(var SalesLine: Record "Sales Line") Total: Decimal - begin - if SalesLine.FindFirst() then - repeat - Total += SalesLine."Line Amount"; - until SalesLine.Next() = 0; - end; -} diff --git a/microsoft/knowledge/performance/use-findset-with-next.good.al b/microsoft/knowledge/performance/use-findset-with-next.good.al deleted file mode 100644 index 412c943..0000000 --- a/microsoft/knowledge/performance/use-findset-with-next.good.al +++ /dev/null @@ -1,10 +0,0 @@ -codeunit 50102 "Perf Sample FindSetWithNext Good" -{ - procedure SumLineAmounts(var SalesLine: Record "Sales Line") Total: Decimal - begin - if SalesLine.FindSet() then - repeat - Total += SalesLine."Line Amount"; - until SalesLine.Next() = 0; - end; -} diff --git a/microsoft/knowledge/performance/use-findset-with-next.md b/microsoft/knowledge/performance/use-findset-with-next.md deleted file mode 100644 index c78c1aa..0000000 --- a/microsoft/knowledge/performance/use-findset-with-next.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [findset, next, repeat, iteration, aa0181] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Use FindSet with Next for iteration - -> Contributions welcome — open a PR to refine or extend this article. - -## Description - -When iterating over a filtered set of records with repeat-until, use FindSet together with Next. CodeCop rule AA0181 requires FindSet or Find to be paired with Next; using FindFirst or FindLast as the loop starter misrepresents intent and leads to rule AA0233. - -## Best Practice - -Call FindSet to start the iteration and Next to advance. Guard the loop with the standard `if FindSet() then ... until Next() = 0` idiom so callers can still handle the empty-set case. - -See sample: `use-findset-with-next.good.al`. - -## Anti Pattern - -Starting a repeat-until loop with FindFirst or FindLast reads only one row and then calls Next on an iterator that was not intended for full-set traversal. The platform pays extra work to fetch the single row and the loop silhouette is misleading to reviewers. - -See sample: `use-findset-with-next.bad.al`. - diff --git a/microsoft/knowledge/performance/prefer-get-for-primary-key-lookups.bad.al b/microsoft/knowledge/performance/use-get-instead-of-findfirst-on-full-primary-key.bad.al similarity index 52% rename from microsoft/knowledge/performance/prefer-get-for-primary-key-lookups.bad.al rename to microsoft/knowledge/performance/use-get-instead-of-findfirst-on-full-primary-key.bad.al index 408c2e9..34f4ec3 100644 --- a/microsoft/knowledge/performance/prefer-get-for-primary-key-lookups.bad.al +++ b/microsoft/knowledge/performance/use-get-instead-of-findfirst-on-full-primary-key.bad.al @@ -1,11 +1,11 @@ -codeunit 50131 "Perf Sample GetVsFind Bad" +codeunit 50211 "Perf Sample GetByPK Bad" { - procedure CustomerName(CustomerNo: Code[20]): Text[100] + procedure ShowName(CustomerNo: Code[20]) var Customer: Record Customer; begin Customer.SetRange("No.", CustomerNo); if Customer.FindFirst() then - exit(Customer.Name); + Message(Customer.Name); end; } diff --git a/microsoft/knowledge/performance/use-get-instead-of-findfirst-on-full-primary-key.good.al b/microsoft/knowledge/performance/use-get-instead-of-findfirst-on-full-primary-key.good.al new file mode 100644 index 0000000..d625150 --- /dev/null +++ b/microsoft/knowledge/performance/use-get-instead-of-findfirst-on-full-primary-key.good.al @@ -0,0 +1,10 @@ +codeunit 50210 "Perf Sample GetByPK Good" +{ + procedure ShowName(CustomerNo: Code[20]) + var + Customer: Record Customer; + begin + if Customer.Get(CustomerNo) then + Message(Customer.Name); + end; +} diff --git a/microsoft/knowledge/performance/use-get-instead-of-findfirst-on-full-primary-key.md b/microsoft/knowledge/performance/use-get-instead-of-findfirst-on-full-primary-key.md new file mode 100644 index 0000000..06c0383 --- /dev/null +++ b/microsoft/knowledge/performance/use-get-instead-of-findfirst-on-full-primary-key.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [get, findfirst, primary-key, setrange, lookup] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use Get when the full primary key is known; FindFirst is the wrong tool + +## Description + +`Get(...)` is the direct primary-key lookup. `FindFirst()` walks an index — even when narrowed by `SetRange` on every primary-key field. The upstream review guidance treats `Customer.SetRange("No.", CustomerNo); if Customer.FindFirst() then ...` as a bad pattern and `if Customer.Get(CustomerNo) then ...` as the correction. The two reach the same record; only `Get` expresses the lookup as a primary-key seek. + +## Best Practice + +When all primary-key fields are available at the call site, call `Get` (or `GetBySystemId`) with them. Reserve `FindFirst` for cases where the filter is on something other than the full primary key — a unique secondary field, a partial composite key, a sort that the caller cares about. + +See sample: `use-get-instead-of-findfirst-on-full-primary-key.good.al`. + +## Anti Pattern + +Composing `SetRange` calls that exactly cover the primary key and then calling `FindFirst`. The result is correct but the call site reads as "search the table" rather than "look up by key", which obscures both the intent and the access pattern from later reviewers. + +See sample: `use-get-instead-of-findfirst-on-full-primary-key.bad.al`. diff --git a/microsoft/knowledge/performance/use-insert-false-when-skipping-triggers.good.al b/microsoft/knowledge/performance/use-insert-false-when-skipping-triggers.good.al deleted file mode 100644 index cc3377a..0000000 --- a/microsoft/knowledge/performance/use-insert-false-when-skipping-triggers.good.al +++ /dev/null @@ -1,12 +0,0 @@ -codeunit 50132 "Perf Sample InsertParam Good" -{ - procedure BulkLoadTempItems(var TempItem: Record Item temporary; Source: List of [Code[20]]) - var - ItemNo: Code[20]; - begin - foreach ItemNo in Source do begin - TempItem."No." := ItemNo; - TempItem.Insert(false); - end; - end; -} diff --git a/microsoft/knowledge/performance/use-insert-false-when-skipping-triggers.md b/microsoft/knowledge/performance/use-insert-false-when-skipping-triggers.md deleted file mode 100644 index 708f53e..0000000 --- a/microsoft/knowledge/performance/use-insert-false-when-skipping-triggers.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [insert, modify, delete, triggers, parameters] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Choose Insert, Modify, and Delete parameters deliberately - -> Contributions welcome — open a PR to refine or extend this article. - -## Description - -Insert, Modify, and Delete accept a boolean that controls whether the table's OnInsert / OnModify / OnDelete trigger fires. Running the trigger for scratch or migrated data is often unnecessary work — side effects, posting rules, validations — for rows that were already validated upstream. Running the trigger when application logic depends on it is non-negotiable. - -## Best Practice - -Call Insert(true), Modify(true), or Delete(true) when the table's trigger logic is part of the operation's semantics. Call Insert(false), Modify(false), or Delete(false) when the operation is bulk data movement or temporary-table manipulation and the trigger would duplicate work or fire invalid side effects. - -See sample: `use-insert-false-when-skipping-triggers.good.al`. - -## Anti Pattern - -Blindly passing `true` everywhere pays for triggers on rows that do not need them. Blindly passing `false` silently skips validations that the table's author intended to be mandatory. - diff --git a/microsoft/knowledge/performance/use-isempty-for-existence-check.bad.al b/microsoft/knowledge/performance/use-isempty-for-existence-check.bad.al new file mode 100644 index 0000000..1ab47e7 --- /dev/null +++ b/microsoft/knowledge/performance/use-isempty-for-existence-check.bad.al @@ -0,0 +1,17 @@ +codeunit 50213 "Perf Sample IsEmpty Bad" +{ + procedure HasOpenSalesOrders(CustomerNo: Code[20]): Boolean + var + SalesHeader: Record "Sales Header"; + begin + SalesHeader.SetRange("Document Type", SalesHeader."Document Type"::Order); + SalesHeader.SetRange("Sell-to Customer No.", CustomerNo); + // Count materializes a count the caller does not need. + if SalesHeader.Count() > 0 then + exit(true); + // FindFirst materializes a row the caller throws away. + if SalesHeader.FindFirst() then + exit(true); + exit(false); + end; +} diff --git a/microsoft/knowledge/performance/use-isempty-for-existence-check.good.al b/microsoft/knowledge/performance/use-isempty-for-existence-check.good.al new file mode 100644 index 0000000..b1ea8d8 --- /dev/null +++ b/microsoft/knowledge/performance/use-isempty-for-existence-check.good.al @@ -0,0 +1,11 @@ +codeunit 50212 "Perf Sample IsEmpty Good" +{ + procedure HasOpenSalesOrders(CustomerNo: Code[20]): Boolean + var + SalesHeader: Record "Sales Header"; + begin + SalesHeader.SetRange("Document Type", SalesHeader."Document Type"::Order); + SalesHeader.SetRange("Sell-to Customer No.", CustomerNo); + exit(not SalesHeader.IsEmpty()); + end; +} diff --git a/microsoft/knowledge/performance/use-isempty-for-existence-check.md b/microsoft/knowledge/performance/use-isempty-for-existence-check.md new file mode 100644 index 0000000..ab574ec --- /dev/null +++ b/microsoft/knowledge/performance/use-isempty-for-existence-check.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: performance +keywords: [isempty, count, findfirst, existence-check, exists] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use IsEmpty for existence checks, not Count() or FindFirst() + +## Description + +When the caller only needs to know whether any row matches a filter, `IsEmpty()` is the API designed for the question. Per the upstream guidance, "`IsEmpty()` is more efficient as it stops at first record found." `Count() > 0` materializes a count the caller does not need; `FindFirst()` materializes a row the caller does not need. Both do work that `IsEmpty` does not. + +## Best Practice + +Phrase existence checks as `if not Record.IsEmpty() then ...` (or `if Record.IsEmpty() then ...` for the negative). Apply filters via `SetRange`/`SetFilter` before the call so the existence check runs against the intended subset. Reserve `Count` for cases where the actual number matters and `FindFirst` for cases where the record fields are read. + +See sample: `use-isempty-for-existence-check.good.al`. + +## Anti Pattern + +`if Customer.Count() > 0 then ...` and `if Customer.FindFirst() then ...` (when the record is discarded) — both are flagged by the upstream guidance as the wrong tool. The first asks the database for the full count; the second asks for a row's fields. Both answers go unused. + +See sample: `use-isempty-for-existence-check.bad.al`. diff --git a/microsoft/knowledge/performance/use-isempty-for-existence-checks.bad.al b/microsoft/knowledge/performance/use-isempty-for-existence-checks.bad.al deleted file mode 100644 index 8674d63..0000000 --- a/microsoft/knowledge/performance/use-isempty-for-existence-checks.bad.al +++ /dev/null @@ -1,11 +0,0 @@ -codeunit 50121 "Perf Sample IsEmpty Bad" -{ - procedure HasOpenDocuments(CustomerNo: Code[20]): Boolean - var - SalesHeader: Record "Sales Header"; - begin - SalesHeader.SetRange("Sell-to Customer No.", CustomerNo); - SalesHeader.SetRange(Status, SalesHeader.Status::Open); - exit(SalesHeader.Count() > 0); - end; -} diff --git a/microsoft/knowledge/performance/use-isempty-for-existence-checks.good.al b/microsoft/knowledge/performance/use-isempty-for-existence-checks.good.al deleted file mode 100644 index 72cff56..0000000 --- a/microsoft/knowledge/performance/use-isempty-for-existence-checks.good.al +++ /dev/null @@ -1,11 +0,0 @@ -codeunit 50120 "Perf Sample IsEmpty Good" -{ - procedure HasOpenDocuments(CustomerNo: Code[20]): Boolean - var - SalesHeader: Record "Sales Header"; - begin - SalesHeader.SetRange("Sell-to Customer No.", CustomerNo); - SalesHeader.SetRange(Status, SalesHeader.Status::Open); - exit(not SalesHeader.IsEmpty()); - end; -} diff --git a/microsoft/knowledge/performance/use-isempty-for-existence-checks.md b/microsoft/knowledge/performance/use-isempty-for-existence-checks.md deleted file mode 100644 index 35955fc..0000000 --- a/microsoft/knowledge/performance/use-isempty-for-existence-checks.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [isempty, count, findfirst, existence] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Use IsEmpty for existence checks - -> Contributions welcome — open a PR to refine or extend this article. - -## Description - -IsEmpty is the cheapest way to answer whether at least one row matches the current filters. It short-circuits at the first match and never hydrates a record. Count() scans and counts the entire set; FindFirst fetches a full row just to be discarded. - -## Best Practice - -Use `if not Rec.IsEmpty() then ...` for existence checks. Reserve Count for cases where the exact number of rows is needed, and FindFirst for cases where you actually want the row's field values. - -See sample: `use-isempty-for-existence-checks.good.al`. - -## Anti Pattern - -`if Rec.Count() > 0` iterates the whole set just to answer a yes/no question. `if Rec.FindFirst() then` loads an entire row of data the caller never reads. - -See sample: `use-isempty-for-existence-checks.bad.al`. - diff --git a/microsoft/knowledge/performance/use-setloadfields-for-partial-records.bad.al b/microsoft/knowledge/performance/use-setloadfields-for-partial-records.bad.al index 7cf7fc9..c9b4ce2 100644 --- a/microsoft/knowledge/performance/use-setloadfields-for-partial-records.bad.al +++ b/microsoft/knowledge/performance/use-setloadfields-for-partial-records.bad.al @@ -1,14 +1,14 @@ -codeunit 50111 "Perf Sample SetLoadFields Bad" +codeunit 50219 "Perf Sample LoadFields Bad" { - procedure ExportItemNumbers(var Item: Record Item) + procedure ListUSCustomerNames() + var + Customer: Record Customer; begin - if Item.FindSet() then + // Loads every Customer column on every row, when only Name is read. + Customer.SetRange("Country/Region Code", 'US'); + if Customer.FindSet() then repeat - Export(Item."No.", Item.Description); - until Item.Next() = 0; - end; - - local procedure Export(ItemNo: Code[20]; Description: Text[100]) - begin + Message(Customer.Name); + until Customer.Next() = 0; end; } diff --git a/microsoft/knowledge/performance/use-setloadfields-for-partial-records.good.al b/microsoft/knowledge/performance/use-setloadfields-for-partial-records.good.al index 990e3ee..772023c 100644 --- a/microsoft/knowledge/performance/use-setloadfields-for-partial-records.good.al +++ b/microsoft/knowledge/performance/use-setloadfields-for-partial-records.good.al @@ -1,15 +1,23 @@ -codeunit 50110 "Perf Sample SetLoadFields Good" +codeunit 50218 "Perf Sample LoadFields Good" { - procedure ExportItemNumbers(var Item: Record Item) + procedure ListUSCustomerNames() + var + Customer: Record Customer; begin - Item.SetLoadFields("No.", Description); - if Item.FindSet() then + Customer.SetLoadFields(Name); + Customer.SetRange("Country/Region Code", 'US'); + if Customer.FindSet() then repeat - Export(Item."No.", Item.Description); - until Item.Next() = 0; + Message(Customer.Name); + until Customer.Next() = 0; end; - local procedure Export(ItemNo: Code[20]; Description: Text[100]) + procedure LookupSkuPolicy(LocationCode: Code[10]) Policy: Enum "SKU Creation Method" + var + Location: Record Location; begin + Location.SetLoadFields("SKU Creation Policy"); + if Location.Get(LocationCode) then + Policy := Location."SKU Creation Policy"; end; } diff --git a/microsoft/knowledge/performance/use-setloadfields-for-partial-records.md b/microsoft/knowledge/performance/use-setloadfields-for-partial-records.md index 3acf4f2..85d20b3 100644 --- a/microsoft/knowledge/performance/use-setloadfields-for-partial-records.md +++ b/microsoft/knowledge/performance/use-setloadfields-for-partial-records.md @@ -1,29 +1,26 @@ --- bc-version: [all] domain: performance -keywords: [setloadfields, partial-record, blob, bandwidth] +keywords: [setloadfields, partial-record, normal-field, flowfield, get, findset] technologies: [al] countries: [w1] application-area: [all] --- -# Use SetLoadFields for partial records +# Use SetLoadFields to load only the fields the code reads ## Description -SetLoadFields instructs the platform to hydrate only the listed fields on a record variable. On wide tables, or tables with BLOB or media fields, the difference is substantial: a Sales Invoice Line has dozens of fields and loading all of them for every row of a large set is wasted bandwidth. Primary key fields, SystemId, and system audit fields are always loaded automatically. SetLoadFields works only with FieldClass = Normal; FlowFields and FlowFilters cannot be partial-loaded. +`SetLoadFields(...)` declares the subset of normal fields the next read should materialize, "reducing data read and transfer thereby improving performance significantly." Per the upstream guidance, "the gains scale with the amount of rows read, so for loops that read many rows `SetLoadFields` is even more important." Primary-key fields, `SystemId`, and system audit fields are loaded automatically, "and fields that are filtered on are also automatically included" — those do not need to appear in the list. `SetLoadFields` only affects `FieldClass = Normal`; it does not narrow FlowFields or FlowFilters. ## Best Practice -Call SetLoadFields before FindSet, FindFirst, or Get when the table is wide enough to matter (roughly 10+ fields) and the code path reads a small subset (roughly under 60%) across a material number of rows. Short loops over narrow tables usually do not earn the extra coupling; see `skip-setloadfields-on-narrow-tables-and-short-loops` for that exception. List every field that is read or written during the operation, including fields used in calculations and downstream function calls. Omitting a field that is later accessed triggers a second round-trip. - -Fields that appear **only** in SetRange or SetFilter calls do not need to be included — the database resolves the filter using the index without hydrating the value into AL memory. Including filter-only fields wastes bandwidth and is not required. +Before a `Get`, `FindSet`, or `FindFirst` that the procedure follows by reading only a handful of the table's fields, call `SetLoadFields` listing exactly those fields. The pattern `SetLoadFields(...); if Record.Get(...) then ...` is the upstream-endorsed shape. Skip `SetLoadFields` when the table has few fields (under ten), when the code reads most of them (above 60 %), when the loop runs ten or fewer iterations, or when the table is exempt for other reasons (`singleton-setup-tables-need-no-access-optimization.md`, `temporary-tables-have-no-database-cost.md`). For report dataitems, use `AddLoadFields` in `OnPreDataItem` instead (see `addloadfields-in-report-onpredataitem.md`). See sample: `use-setloadfields-for-partial-records.good.al`. ## Anti Pattern -Iterating a large set and reading only two or three fields without SetLoadFields forces the platform to transport every column for every row, including BLOBs and unused text fields. +Loading a wide table and reading one field per row in a loop. The bytes transferred per row are dominated by the columns the procedure does not touch; the SQL query selects them anyway. The same applies to a single `Get` on a wide table — the platform reads the whole row when a single field would have sufficed. See sample: `use-setloadfields-for-partial-records.bad.al`. - diff --git a/microsoft/knowledge/performance/use-single-instance-codeunits-for-caching.good.al b/microsoft/knowledge/performance/use-single-instance-codeunits-for-caching.good.al deleted file mode 100644 index 036eccb..0000000 --- a/microsoft/knowledge/performance/use-single-instance-codeunits-for-caching.good.al +++ /dev/null @@ -1,17 +0,0 @@ -codeunit 50138 "Perf Sample SingleInstance Good" -{ - SingleInstance = true; - - var - Cached: Record "Sales & Receivables Setup"; - Loaded: Boolean; - - procedure GetSetup(): Record "Sales & Receivables Setup" - begin - if not Loaded then begin - Cached.Get(); - Loaded := true; - end; - exit(Cached); - end; -} diff --git a/microsoft/knowledge/performance/use-single-instance-codeunits-for-caching.md b/microsoft/knowledge/performance/use-single-instance-codeunits-for-caching.md deleted file mode 100644 index 9c8d6f9..0000000 --- a/microsoft/knowledge/performance/use-single-instance-codeunits-for-caching.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [singleinstance, cache, codeunit, session] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Use SingleInstance codeunits for session caching - -> Contributions welcome — open a PR to refine or extend this article. - -## Description - -A SingleInstance codeunit lives once per session. Variables on it survive across calls, which makes it the natural home for data that is expensive to compute, read often, and stable for the duration of the session — feature flags, configuration snapshots, setup records. Each cached value avoids a SQL read per subsequent call site. - -## Best Practice - -Store long-lived, read-often, rarely-changing data on a SingleInstance codeunit, populated lazily on first access. Keep the cached footprint small: a handful of booleans, a setup record, a few derived values. Be explicit about invalidation if the source can change during the session. - -See sample: `use-single-instance-codeunits-for-caching.good.al`. - -## Anti Pattern - -Reading the same setup record on every call from every caller, instead of caching it, repeats a SQL round-trip that has no business happening more than once per session. - diff --git a/microsoft/knowledge/performance/use-temporary-tables-for-intermediate-data.good.al b/microsoft/knowledge/performance/use-temporary-tables-for-intermediate-data.good.al deleted file mode 100644 index ecf550f..0000000 --- a/microsoft/knowledge/performance/use-temporary-tables-for-intermediate-data.good.al +++ /dev/null @@ -1,16 +0,0 @@ -codeunit 50124 "Perf Sample TempTable Good" -{ - procedure BuildAffectedItems(var TempItem: Record Item temporary) - var - SalesLine: Record "Sales Line"; - begin - TempItem.Reset(); - TempItem.DeleteAll(); - SalesLine.SetRange(Type, SalesLine.Type::Item); - if SalesLine.FindSet() then - repeat - TempItem."No." := SalesLine."No."; - if TempItem.Insert(false) then; - until SalesLine.Next() = 0; - end; -} diff --git a/microsoft/knowledge/performance/use-temporary-tables-for-intermediate-data.md b/microsoft/knowledge/performance/use-temporary-tables-for-intermediate-data.md deleted file mode 100644 index 0beb323..0000000 --- a/microsoft/knowledge/performance/use-temporary-tables-for-intermediate-data.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [temporary-table, in-memory, intermediate, working-set] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Use temporary tables for intermediate data - -> Contributions welcome — open a PR to refine or extend this article. - -## Description - -Temporary tables live in memory, not in SQL. They are the correct primary data structure for intermediate results, working sets, and lookup caches that do not need to outlive the current operation. Using a real persisted table for scratch data incurs database round-trips, transaction scope, and locking for data that has no business being persisted. - -## Best Practice - -Declare the record variable with `temporary` when the data is scratch. Populate it with Insert(false) to avoid firing triggers. Clear the table explicitly with DeleteAll when the variable's scope is long-lived (a SingleInstance codeunit or a reused session variable) and needs to be reset between uses. - -See sample: `use-temporary-tables-for-intermediate-data.good.al`. - -## Anti Pattern - -Writing intermediate results to a real table, processing them, and deleting them afterwards performs the full cost of INSERT and DELETE operations on data that never needed to be transactional. - diff --git a/microsoft/knowledge/performance/use-textbuilder-for-loop-string-assembly.bad.al b/microsoft/knowledge/performance/use-textbuilder-for-loop-string-assembly.bad.al deleted file mode 100644 index 97bf89a..0000000 --- a/microsoft/knowledge/performance/use-textbuilder-for-loop-string-assembly.bad.al +++ /dev/null @@ -1,14 +0,0 @@ -codeunit 50932 "Perf Sample TextConcat Bad" -{ - procedure BuildItemList(var Item: Record Item): Text - var - Result: Text; - begin - if Item.FindSet() then - repeat - Result += StrSubstNo('%1,%2', Item."No.", Item.Description); - until Item.Next() = 0; - - exit(Result); - end; -} diff --git a/microsoft/knowledge/performance/use-textbuilder-for-loop-string-assembly.good.al b/microsoft/knowledge/performance/use-textbuilder-for-loop-string-assembly.good.al deleted file mode 100644 index 417759c..0000000 --- a/microsoft/knowledge/performance/use-textbuilder-for-loop-string-assembly.good.al +++ /dev/null @@ -1,14 +0,0 @@ -codeunit 50931 "Perf Sample TextBuilder Good" -{ - procedure BuildItemList(var Item: Record Item): Text - var - Builder: TextBuilder; - begin - if Item.FindSet() then - repeat - Builder.AppendLine(StrSubstNo('%1,%2', Item."No.", Item.Description)); - until Item.Next() = 0; - - exit(Builder.ToText()); - end; -} diff --git a/microsoft/knowledge/performance/use-textbuilder-for-loop-string-assembly.md b/microsoft/knowledge/performance/use-textbuilder-for-loop-string-assembly.md deleted file mode 100644 index 32080b5..0000000 --- a/microsoft/knowledge/performance/use-textbuilder-for-loop-string-assembly.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: performance -keywords: [textbuilder, string-concatenation, loop, text, allocation] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Use TextBuilder for loop-based string assembly - -## Description - -Repeated `Text := Text + ...` concatenation inside a loop reallocates and copies the growing string on every iteration. In AL, `TextBuilder` is the platform type for constructing larger text payloads incrementally. `StrSubstNo` remains appropriate for formatting one message; TextBuilder is for many appends, especially inside loops. - -## Best Practice - -Use `TextBuilder.Append` or `AppendLine` when assembling CSV rows, log payloads, JSON-ish diagnostic text, or other multi-line strings from repeated loop iterations. Convert to Text once, after the loop, with `ToText()`. - -See sample: `use-textbuilder-for-loop-string-assembly.good.al`. - -## Anti Pattern - -Appending to the same Text variable on every iteration of a large loop. Each append copies the accumulated prefix again, so the cost grows with both row count and final string length. - -See sample: `use-textbuilder-for-loop-string-assembly.bad.al`. diff --git a/microsoft/knowledge/performance/use-textbuilder-for-string-concatenation-in-loops.md b/microsoft/knowledge/performance/use-textbuilder-for-string-concatenation-in-loops.md new file mode 100644 index 0000000..f19636a --- /dev/null +++ b/microsoft/knowledge/performance/use-textbuilder-for-string-concatenation-in-loops.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: performance +keywords: [textbuilder, string-concatenation, loop, append, immutable-text] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use TextBuilder for many string concatenations, especially inside loops + +## Description + +AL `Text` is immutable: each `Result += Piece;` allocates a new buffer and copies the previous content into it. Inside a loop the work is quadratic in the number of pieces. `TextBuilder` is the AL primitive designed for the pattern — per the upstream guidance, "Use `TextBuilder` when concatenating many strings together (for example inside loops)." Its `Append` mutates a growable internal buffer; `ToText()` materializes the final string once at the end. + +## Best Practice + +When a procedure assembles a string from many fragments — joining row data into a CSV, accumulating a log buffer, formatting a multi-line message inside a loop — declare a `TextBuilder` local, call `Append` per fragment, and call `ToText()` after the loop. For a fixed number of small fragments, `StrSubstNo` remains the right tool; the rule targets the loop case. + +## Anti Pattern + +`if Customer.FindSet() then repeat Csv += Customer."No." + ',' + Customer.Name + '\n'; until Customer.Next() = 0;` — every iteration reallocates and copies the entire string built so far. On a few hundred customers the cost is invisible; on the production-scale table list (`production-scale-tables-warrant-extra-analysis.md`) it dominates the loop. diff --git a/microsoft/knowledge/privacy/avoid-strsubstno-prebuild-before-error.bad.al b/microsoft/knowledge/privacy/avoid-strsubstno-prebuild-before-error.bad.al new file mode 100644 index 0000000..08f1702 --- /dev/null +++ b/microsoft/knowledge/privacy/avoid-strsubstno-prebuild-before-error.bad.al @@ -0,0 +1,11 @@ +codeunit 50207 "Privacy Sample StrSubstNo Bad" +{ + procedure ReportFailure(var Customer: Record Customer) + var + ErrorMsg: Text; + begin + ErrorMsg := StrSubstNo('Customer %1 (%2) at %3 has invalid data', + Customer.Name, Customer."E-Mail", Customer.Address); + Error(ErrorMsg); + end; +} diff --git a/microsoft/knowledge/privacy/avoid-strsubstno-prebuild-before-error.good.al b/microsoft/knowledge/privacy/avoid-strsubstno-prebuild-before-error.good.al new file mode 100644 index 0000000..6886e29 --- /dev/null +++ b/microsoft/knowledge/privacy/avoid-strsubstno-prebuild-before-error.good.al @@ -0,0 +1,9 @@ +codeunit 50206 "Privacy Sample StrSubstNo Good" +{ + procedure ReportFailure(var Customer: Record Customer) + var + CustomerInvalidErr: Label 'Customer %1 has invalid data (email: %2).', Comment = '%1 = Customer No., %2 = E-Mail'; + begin + Error(CustomerInvalidErr, Customer."No.", Customer."E-Mail"); + end; +} diff --git a/microsoft/knowledge/privacy/avoid-strsubstno-prebuild-before-error.md b/microsoft/knowledge/privacy/avoid-strsubstno-prebuild-before-error.md new file mode 100644 index 0000000..7d4c1e7 --- /dev/null +++ b/microsoft/knowledge/privacy/avoid-strsubstno-prebuild-before-error.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: privacy +keywords: [strsubstno, error, telemetry, pii, prebuild, text-variable] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Do not pre-build an error string with `StrSubstNo` before calling `Error()` + +## Description + +`StrSubstNo` returns a plain `Text` value with the substitutions already performed. When that result is then passed to `Error()`, the platform sees a single plain-text parameter with no field references left to inspect, so it cannot apply `DataClassification` to anything inside it. Whatever PII the `StrSubstNo` call interpolated — customer name, e-mail, address, error text — is logged verbatim to telemetry. This is the canonical way to accidentally leak customer data through error telemetry, and it is the only `Error()` shape that needs to be flagged. + +## Best Practice + +Call `Error()` directly with the format string and the substitution parameters. The platform classifies each parameter individually and handles telemetry correctly even when the parameters are PII fields (see `error-direct-substitution-safe-for-telemetry.md`). If the message text needs to be a `Label`, pass the `Label` and the parameters to `Error()` — do not pre-render via `StrSubstNo`. + +See sample: `avoid-strsubstno-prebuild-before-error.good.al`. + +## Anti Pattern + +Assigning `StrSubstNo('Customer %1 (%2) ...', Customer.Name, Customer."E-Mail")` to a `Text` variable and then calling `Error(ErrorMsg)`. The platform has nothing to classify by the time `Error` runs — the PII is baked into the string and goes straight to telemetry. Detection signal for a reviewer: any `Text` variable assigned from `StrSubstNo` and later passed as the *only* parameter to `Error()`. + +See sample: `avoid-strsubstno-prebuild-before-error.bad.al`. diff --git a/microsoft/knowledge/privacy/classify-data-at-migration-destination.bad.al b/microsoft/knowledge/privacy/classify-data-at-migration-destination.bad.al deleted file mode 100644 index 4300fd9..0000000 --- a/microsoft/knowledge/privacy/classify-data-at-migration-destination.bad.al +++ /dev/null @@ -1,14 +0,0 @@ -table 50936 "Migrated Employee" -{ - fields - { - field(1; "Employee No."; Code[20]) - { - DataClassification = ToBeClassified; - } - field(2; "Tax Identification No."; Text[30]) - { - DataClassification = SystemMetadata; - } - } -} diff --git a/microsoft/knowledge/privacy/classify-data-at-migration-destination.good.al b/microsoft/knowledge/privacy/classify-data-at-migration-destination.good.al deleted file mode 100644 index 44204a5..0000000 --- a/microsoft/knowledge/privacy/classify-data-at-migration-destination.good.al +++ /dev/null @@ -1,14 +0,0 @@ -table 50935 "Migrated Employee" -{ - fields - { - field(1; "Employee No."; Code[20]) - { - DataClassification = EndUserPseudonymousIdentifiers; - } - field(2; "Tax Identification No."; Text[30]) - { - DataClassification = EndUserIdentifiableInformation; - } - } -} diff --git a/microsoft/knowledge/privacy/classify-data-at-migration-destination.md b/microsoft/knowledge/privacy/classify-data-at-migration-destination.md deleted file mode 100644 index f582fcc..0000000 --- a/microsoft/knowledge/privacy/classify-data-at-migration-destination.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: privacy -keywords: [migration, dataclassification, hybrid, destination, pii] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Classify migrated data at the destination field - -## Description - -Hybrid migration codeunits such as HybridSL, HybridGP, and HybridBC legitimately process sensitive source data: tax IDs, employee identifiers, financial balances, and customer records. The privacy concern is not that the migration code touches the data. The concern is where the data lands: the destination table field must have a DataClassification value that matches the migrated content. - -## Best Practice - -When reviewing migration code, follow the assignment to the destination field and verify that the destination table declares an appropriate field-level or inherited DataClassification. Treat the migration procedure itself as expected business functionality; flag only missing or understated classification on the persistent destination. - -See sample: `classify-data-at-migration-destination.good.al`. - -## Anti Pattern - -Flagging a migration procedure merely because it copies tax IDs or names from a source system. That creates false positives and misses the real issue: a destination field with no classification, `ToBeClassified`, or `SystemMetadata` for customer or employee data. - -See sample: `classify-data-at-migration-destination.bad.al`. diff --git a/microsoft/knowledge/privacy/data-classification-is-table-field-property.md b/microsoft/knowledge/privacy/data-classification-is-table-field-property.md new file mode 100644 index 0000000..acfc0e7 --- /dev/null +++ b/microsoft/knowledge/privacy/data-classification-is-table-field-property.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: privacy +keywords: [data-classification, page-field, table-field, api-page, card-page, list-page] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# DataClassification is a table-field property, not a page-field property + +## Description + +`DataClassification` is defined on table fields. Pages — including `Card`, `List`, `API`, and `ListPart` — do not own a classification; they simply expose fields whose classification is inherited from the underlying table. A page-level `DataClassification` property does not exist, so neither a missing nor a "wrong" classification can be reported against a page. When the underlying table field is misclassified, the fix is on the table definition, not on every page that surfaces the field. + +## Best Practice + +When reviewing a page that exposes a field believed to be under-classified, follow the field back to its source table and inspect (or correct) the `DataClassification` there. A single corrected table field propagates to every page, report and API that uses it. + +## Anti Pattern + +Flagging a page (or trying to add a `DataClassification` property to a page field) because the page displays personal data. Pages display data that authenticated, permissioned users are already entitled to see; the classification belongs on the table field that stores the data, not on the UI that renders it. diff --git a/microsoft/knowledge/privacy/data-classification-required-on-pii-fields.bad.al b/microsoft/knowledge/privacy/data-classification-required-on-pii-fields.bad.al new file mode 100644 index 0000000..72b2174 --- /dev/null +++ b/microsoft/knowledge/privacy/data-classification-required-on-pii-fields.bad.al @@ -0,0 +1,11 @@ +tableextension 50201 "Customer Contact Ext Bad" extends Customer +{ + fields + { + field(50201; "Secondary Email"; Text[80]) + { + DataClassification = SystemMetadata; + Caption = 'Secondary Email'; + } + } +} diff --git a/microsoft/knowledge/privacy/data-classification-required-on-pii-fields.good.al b/microsoft/knowledge/privacy/data-classification-required-on-pii-fields.good.al new file mode 100644 index 0000000..aef9301 --- /dev/null +++ b/microsoft/knowledge/privacy/data-classification-required-on-pii-fields.good.al @@ -0,0 +1,11 @@ +tableextension 50200 "Customer Contact Ext" extends Customer +{ + fields + { + field(50200; "Secondary Email"; Text[80]) + { + DataClassification = CustomerContent; + Caption = 'Secondary Email'; + } + } +} diff --git a/microsoft/knowledge/privacy/data-classification-required-on-pii-fields.md b/microsoft/knowledge/privacy/data-classification-required-on-pii-fields.md new file mode 100644 index 0000000..808e103 --- /dev/null +++ b/microsoft/knowledge/privacy/data-classification-required-on-pii-fields.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: privacy +keywords: [data-classification, pii, gdpr, customer-content, table-field, under-classified] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# DataClassification is required on table fields containing sensitive data + +## Description + +`DataClassification` is the AL property that tells the platform what kind of data a table field stores so that telemetry, GDPR data-subject requests, and the platform's audit surfaces can treat it correctly. It is required on any field that holds personal or customer data. The default value `SystemMetadata` means "no user or customer data" — applying it to a field that actually holds PII (an email address, a customer name, an employee code) is an under-classification and a privacy bug, even though the code still compiles. + +## Best Practice + +Set `DataClassification` to the value that matches the data the field actually stores. A `Customer."E-Mail"`-style field is `CustomerContent` (data belonging to the tenant's customers); a personal identifier such as an employee number or user ID is `EndUserIdentifiableInformation` or `EndUserPseudonymousIdentifiers` depending on whether it is directly identifying. Choose the classification at field definition time — fixing it later is a schema change. + +See sample: `data-classification-required-on-pii-fields.good.al`. + +## Anti Pattern + +Declaring a field that stores PII with `DataClassification = SystemMetadata` to silence the compiler warning. The field compiles but the platform now treats customer data as system metadata in telemetry, GDPR exports and admin reports. + +See sample: `data-classification-required-on-pii-fields.bad.al`. diff --git a/microsoft/knowledge/privacy/dataclassification-is-a-table-field-property.md b/microsoft/knowledge/privacy/dataclassification-is-a-table-field-property.md deleted file mode 100644 index c47955f..0000000 --- a/microsoft/knowledge/privacy/dataclassification-is-a-table-field-property.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -bc-version: [all] -domain: privacy -keywords: [dataclassification, table-field, page, api-page, scope] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# DataClassification is a table-field property, not a page property - -## Description - -DataClassification governs how the platform handles a field's data in telemetry, data-subject requests, and retention tooling. It is declared on the table field, not on the page that displays the field. Pages — card pages, list pages, API pages — simply render fields sourced from a table. A privacy issue with classification is always an issue on the table definition; the page is a display surface. - -## Best Practice - -Flag missing or wrong DataClassification on the table field where the data lives. When a field is exposed through an API page or any other page type, the source table's classification governs. Do not report the same issue on every page that happens to include the field. - -## Anti Pattern - -Reporting a privacy finding on `page 50100 "Customer API"` because it exposes an email field, rather than on `table Customer`'s email field. Fix at the source; the page is not the offender and the same correction applied per-page produces churn without changing the data-classification story. diff --git a/microsoft/knowledge/privacy/do-not-move-pii-from-isolated-storage-to-plain-fields.bad.al b/microsoft/knowledge/privacy/do-not-move-pii-from-isolated-storage-to-plain-fields.bad.al deleted file mode 100644 index 718983a..0000000 --- a/microsoft/knowledge/privacy/do-not-move-pii-from-isolated-storage-to-plain-fields.bad.al +++ /dev/null @@ -1,13 +0,0 @@ -tableextension 50911 "Privacy Sample IS Bad" extends "Sales & Receivables Setup" -{ - fields - { - // Refactor moves the delta URL out of encrypted IsolatedStorage into a - // plain table field. Value is now plaintext in SQL, unscoped, indistinguishable - // from non-sensitive content. - field(50100; "Delta Url"; Text[250]) - { - DataClassification = EndUserPseudonymousIdentifiers; - } - } -} diff --git a/microsoft/knowledge/privacy/do-not-move-pii-from-isolated-storage-to-plain-fields.good.al b/microsoft/knowledge/privacy/do-not-move-pii-from-isolated-storage-to-plain-fields.good.al deleted file mode 100644 index b54ce3a..0000000 --- a/microsoft/knowledge/privacy/do-not-move-pii-from-isolated-storage-to-plain-fields.good.al +++ /dev/null @@ -1,10 +0,0 @@ -codeunit 50910 "Privacy Sample IS Good" -{ - procedure StoreDeltaUrl(DeltaUrl: Text) - var - DeltaKeyTok: Label 'SyncDeltaUrl', Locked = true; - begin - // Sensitive delta URL remains encrypted and scoped to the extension. - IsolatedStorage.SetEncrypted(DeltaKeyTok, DeltaUrl, DataScope::Company); - end; -} diff --git a/microsoft/knowledge/privacy/do-not-move-pii-from-isolated-storage-to-plain-fields.md b/microsoft/knowledge/privacy/do-not-move-pii-from-isolated-storage-to-plain-fields.md deleted file mode 100644 index 4e64bff..0000000 --- a/microsoft/knowledge/privacy/do-not-move-pii-from-isolated-storage-to-plain-fields.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: privacy -keywords: [isolatedstorage, encryption, tokens, refactor, regression] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Do not move PII or secrets from IsolatedStorage to plain table fields - -## Description - -IsolatedStorage with SetEncrypted keeps sensitive values — tokens, URLs carrying identifiers, delta cursors with embedded user context — encrypted at rest and scoped to the extension. Moving the same value to a normal table field is a refactor that looks structural but is a privacy and security regression: the value is now plaintext in SQL, visible to every reader of that table, backed up and replicated as ordinary business data. Reviews of existing integrations frequently see this change justified as "easier to query" — the concern is the storage model, not the ergonomics. - -## Best Practice - -Keep tokens, secrets, personal-context URLs, and similar sensitive values in IsolatedStorage (SetEncrypted) or Azure Key Vault. When a refactor moves the value, require an explicit justification and a mitigating control (restricted-read permission set, value-level encryption, redaction in the access path). Otherwise leave it where it was. - -See sample: `do-not-move-pii-from-isolated-storage-to-plain-fields.good.al`. - -## Anti Pattern - -A diff that deletes an `IsolatedStorage.SetEncrypted` call and writes the same value into a new `Text` column on a business table. The value is now unencrypted, unscoped, and indistinguishable from non-sensitive content to any caller reading the table. - -See sample: `do-not-move-pii-from-isolated-storage-to-plain-fields.bad.al`. diff --git a/microsoft/knowledge/privacy/error-direct-substitution-safe-for-telemetry.good.al b/microsoft/knowledge/privacy/error-direct-substitution-safe-for-telemetry.good.al new file mode 100644 index 0000000..1b8c886 --- /dev/null +++ b/microsoft/knowledge/privacy/error-direct-substitution-safe-for-telemetry.good.al @@ -0,0 +1,10 @@ +codeunit 50205 "Privacy Sample Direct Error" +{ + procedure ValidateCustomer(var Customer: Record Customer) + var + InvalidEmailErr: Label 'Customer %1 has an invalid e-mail address: %2.', Comment = '%1 = Customer No., %2 = E-Mail'; + begin + if not Customer."E-Mail".Contains('@') then + Error(InvalidEmailErr, Customer."No.", Customer."E-Mail"); + end; +} diff --git a/microsoft/knowledge/privacy/error-direct-substitution-safe-for-telemetry.md b/microsoft/knowledge/privacy/error-direct-substitution-safe-for-telemetry.md new file mode 100644 index 0000000..8653ecf --- /dev/null +++ b/microsoft/knowledge/privacy/error-direct-substitution-safe-for-telemetry.md @@ -0,0 +1,24 @@ +--- +bc-version: [all] +domain: privacy +keywords: [error, strsubstno, direct-substitution, telemetry, classification, label] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# `Error()` with direct substitution parameters is always safe for telemetry + +## Description + +When `Error()` is called with a format string and direct substitution parameters (`%1`, `%2`, …), the BC platform intercepts the call, inspects each parameter individually, and applies the `DataClassification` of the source field — stripping or masking sensitive data before writing the message to telemetry. This is true regardless of whether a parameter is a record field reference, a local variable, a function return value, or any other expression. Patterns such as `Error('Invalid email: %1', Customer."E-Mail")` are therefore safe even when the parameter is PII: the platform sees `Customer."E-Mail"` as a `CustomerContent` field reference and handles it correctly. + +## Best Practice + +Pass values to `Error()` as direct substitution parameters — either inline or via a `Label` with `Comment = '%1 = …'` placeholders. Let the platform do the per-parameter classification. This works equally well for record fields, local text variables, and document IDs. + +See sample: `error-direct-substitution-safe-for-telemetry.good.al`. + +## Anti Pattern + +Treating any `Error()` call that mentions PII as a leak. A review skill that flags `Error('Invalid email: %1', EmailAddress)` is wrong; the platform handles that pattern correctly. The only `Error()` shape that genuinely leaks PII to telemetry is the pre-built `StrSubstNo` form covered in `avoid-strsubstno-prebuild-before-error.md`. diff --git a/microsoft/knowledge/privacy/error-is-logged-to-telemetry-message-is-not.bad.al b/microsoft/knowledge/privacy/error-is-logged-to-telemetry-message-is-not.bad.al deleted file mode 100644 index f06f679..0000000 --- a/microsoft/knowledge/privacy/error-is-logged-to-telemetry-message-is-not.bad.al +++ /dev/null @@ -1,16 +0,0 @@ -codeunit 50903 "Privacy Sample ErrorVsMsg Bad" -{ - procedure ConfirmThenFail(var Customer: Record Customer) - var - ConfirmQst: Label 'Send welcome email to %1 at %2?', Comment = '%1 = name, %2 = email'; - FailureWithPiiErr: Text; - begin - if not Confirm(ConfirmQst, false, Customer.Name, Customer."E-Mail") then - exit; - - // Pre-built Text with PII, passed to Error: customer name and email reach telemetry. - FailureWithPiiErr := StrSubstNo( - 'Could not send welcome to %1 at %2.', Customer.Name, Customer."E-Mail"); - Error(FailureWithPiiErr); - end; -} diff --git a/microsoft/knowledge/privacy/error-is-logged-to-telemetry-message-is-not.good.al b/microsoft/knowledge/privacy/error-is-logged-to-telemetry-message-is-not.good.al deleted file mode 100644 index a3a4b25..0000000 --- a/microsoft/knowledge/privacy/error-is-logged-to-telemetry-message-is-not.good.al +++ /dev/null @@ -1,15 +0,0 @@ -codeunit 50902 "Privacy Sample ErrorVsMsg Good" -{ - procedure ConfirmThenFail(var Customer: Record Customer) - var - ConfirmQst: Label 'Send welcome email to %1 at %2?', Comment = '%1 = name, %2 = email'; - GenericFailureErr: Label 'The welcome email could not be sent.'; - begin - // Confirm is not logged to telemetry. PII in the prompt is fine. - if not Confirm(ConfirmQst, false, Customer.Name, Customer."E-Mail") then - exit; - - // Error is logged. Keep PII out of the message. - Error(GenericFailureErr); - end; -} diff --git a/microsoft/knowledge/privacy/error-is-logged-to-telemetry-message-is-not.md b/microsoft/knowledge/privacy/error-is-logged-to-telemetry-message-is-not.md deleted file mode 100644 index 694c640..0000000 --- a/microsoft/knowledge/privacy/error-is-logged-to-telemetry-message-is-not.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: privacy -keywords: [error, message, confirm, notification, telemetry, pii] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Error logs to telemetry; Message, Confirm, and Notification do not - -## Description - -The privacy concern with user-facing text is not what the authenticated user sees — it is what the platform exports to telemetry. Error is captured automatically; Message, Confirm, StrMenu, and Notification are not. Reviews that flag PII in any user-facing dialog over-report. Reviews that ignore PII in Error under-report. The distinction is the delivery surface, not the presence of a person's name on screen. - -## Best Practice - -Free-text business content — customer names, email addresses, document numbers — is acceptable in Message, Confirm, and Notification. Treat Error text as if it will be read by telemetry consumers, because it will be, but use direct Error substitution rather than pre-building the message. `Error(MyErr, EmailAddress)` is telemetry-safe; `Error(StrSubstNo(..., EmailAddress))` is not. - -See sample: `error-is-logged-to-telemetry-message-is-not.good.al`. - -## Anti Pattern - -Embedding customer emails, phone numbers, addresses, or names as literals in an Error label or baking them into a Text value with StrSubstNo before calling Error. The user also sees Message and Confirm, but those are not logged. Error is logged, so dynamic customer data must stay as direct substitution arguments. - -See sample: `error-is-logged-to-telemetry-message-is-not.bad.al`. diff --git a/microsoft/knowledge/privacy/error-vs-message-telemetry-logging.md b/microsoft/knowledge/privacy/error-vs-message-telemetry-logging.md new file mode 100644 index 0000000..f7372b7 --- /dev/null +++ b/microsoft/knowledge/privacy/error-vs-message-telemetry-logging.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: privacy +keywords: [error, message, confirm, notification, telemetry, logging, ui-dialog] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Only `Error()` is logged to telemetry — `Message`, `Confirm`, `Notification` are not + +## Description + +The privacy concern with dialog APIs is not what the signed-in user sees on the screen — it is what the platform writes to telemetry. The BC platform automatically captures `Error()` invocations in the telemetry stream; it does not capture `Message()`, `Confirm()` or `Notification` calls. That asymmetry is the reason privacy review focuses on `Error()` text and ignores the other dialog APIs: a `Message` that shows a customer's email to the signed-in user reveals nothing they were not already entitled to see, while an `Error` carrying the same email leaks it to a separate, longer-lived telemetry destination. + +## Best Practice + +Treat `Error()` as a telemetry surface, not just a UI surface — review the message text and parameters with the same scrutiny you apply to `Session.LogMessage`. Treat `Message()`, `Confirm()`, and `Notification` as pure UI: showing business data the user is permissioned for is normal functionality. + +## Anti Pattern + +Flagging `Message`/`Confirm`/`Notification` calls for "showing PII" — they are not logged to telemetry, and the user already has permission to the underlying data. The inverse anti-pattern is treating `Error()` as harmless because the user sees only a dialog: the message is also written verbatim to telemetry. diff --git a/microsoft/knowledge/privacy/featuretelemetry-customdimensions-no-pii.bad.al b/microsoft/knowledge/privacy/featuretelemetry-customdimensions-no-pii.bad.al new file mode 100644 index 0000000..8225269 --- /dev/null +++ b/microsoft/knowledge/privacy/featuretelemetry-customdimensions-no-pii.bad.al @@ -0,0 +1,12 @@ +codeunit 50215 "Privacy Sample FeatureTelemetry Bad" +{ + procedure LogDocumentReleased(ExpenseHeader: Record "Sales Header"; var User: Record User) + var + FeatureTelemetry: Codeunit "Feature Telemetry"; + CustomDimensions: Dictionary of [Text, Text]; + begin + CustomDimensions.Add('EmployeeNo', ExpenseHeader."Sell-to Customer No."); + CustomDimensions.Add('UserName', User."Full Name"); + FeatureTelemetry.LogUsage('0000EA1', 'Expense Agent', 'Document Released', CustomDimensions); + end; +} diff --git a/microsoft/knowledge/privacy/featuretelemetry-customdimensions-no-pii.good.al b/microsoft/knowledge/privacy/featuretelemetry-customdimensions-no-pii.good.al new file mode 100644 index 0000000..6172f9d --- /dev/null +++ b/microsoft/knowledge/privacy/featuretelemetry-customdimensions-no-pii.good.al @@ -0,0 +1,10 @@ +codeunit 50214 "Privacy Sample FeatureTelemetry Good" +{ + procedure LogUptake() + var + FeatureTelemetry: Codeunit "Feature Telemetry"; + begin + FeatureTelemetry.LogUptake('0000EA2', 'Expense Agent', + Enum::"Feature Uptake Status"::"Set up"); + end; +} diff --git a/microsoft/knowledge/privacy/featuretelemetry-customdimensions-no-pii.md b/microsoft/knowledge/privacy/featuretelemetry-customdimensions-no-pii.md new file mode 100644 index 0000000..6d8bfb5 --- /dev/null +++ b/microsoft/knowledge/privacy/featuretelemetry-customdimensions-no-pii.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: privacy +keywords: [feature-telemetry, customdimensions, logusage, loguptake, logerror, pii, euii, eupi] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# `FeatureTelemetry` `CustomDimensions` follow the same privacy rules as `Session.LogMessage` + +## Description + +`Codeunit "Feature Telemetry"` is the second telemetry surface in AL. Its methods — `LogUsage()`, `LogUptake()` and `LogError()` — each accept a `CustomDimensions` dictionary parameter whose contents are sent to telemetry as-is. The platform does not classify per-dimension values for you, so any customer or employee data placed into the dictionary is logged verbatim. The privacy rules that apply to `Session.LogMessage` message text apply to every value in `CustomDimensions`: no customer or employee names, email addresses, phone numbers (`CustomerContent`/EUII); no employee codes, user IDs or user security IDs (EUPI); no user-provided content (addresses, descriptions, notes); no `GetLastErrorText()` output. + +## Best Practice + +Pass only non-personal context through `CustomDimensions` — feature names, status enums, counts, error codes, durations. For uptake or usage signals that do not need per-call context, prefer the parameterless overload of `LogUptake`/`LogUsage` over a `CustomDimensions` dictionary that risks accreting PII over time. + +See sample: `featuretelemetry-customdimensions-no-pii.good.al`. + +## Anti Pattern + +`CustomDimensions.Add('EmployeeNo', ExpenseHeader."Employee No.")` followed by `FeatureTelemetry.LogUsage(...)` — the employee number is a pseudonymous user identifier (EUPI) and is now in telemetry. Same pattern with `'UserName'`, `'CustomerEmail'`, `'AttachmentName'` etc. + +See sample: `featuretelemetry-customdimensions-no-pii.bad.al`. diff --git a/microsoft/knowledge/privacy/flowfield-flowfilter-classification-systemmetadata.good.al b/microsoft/knowledge/privacy/flowfield-flowfilter-classification-systemmetadata.good.al new file mode 100644 index 0000000..c31ced8 --- /dev/null +++ b/microsoft/knowledge/privacy/flowfield-flowfilter-classification-systemmetadata.good.al @@ -0,0 +1,18 @@ +tableextension 50203 "Customer Order Stats" extends Customer +{ + fields + { + field(50203; "Open Order Count"; Integer) + { + FieldClass = FlowField; + CalcFormula = count("Sales Header" where("Sell-to Customer No." = field("No."))); + Caption = 'Open Order Count'; + } + + field(50204; "Date Filter"; Date) + { + FieldClass = FlowFilter; + Caption = 'Date Filter'; + } + } +} diff --git a/microsoft/knowledge/privacy/flowfield-flowfilter-classification-systemmetadata.md b/microsoft/knowledge/privacy/flowfield-flowfilter-classification-systemmetadata.md new file mode 100644 index 0000000..d3a1b7b --- /dev/null +++ b/microsoft/knowledge/privacy/flowfield-flowfilter-classification-systemmetadata.md @@ -0,0 +1,24 @@ +--- +bc-version: [all] +domain: privacy +keywords: [flowfield, flowfilter, data-classification, systemmetadata, calculated] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# FlowFields and FlowFilters are classified `SystemMetadata` automatically + +## Description + +`FlowField` and `FlowFilter` are not stored fields — a FlowField is computed from a CalcFormula at read time and a FlowFilter is a transient filter scoped to the record variable. Because nothing is ever written to the database for these fields, the platform automatically classifies them as `DataClassification = SystemMetadata` and AL does not require — or expect — the developer to set `DataClassification` on them. A FlowField that surfaces PII (e.g., a sum or lookup over a `CustomerContent` table) is still `SystemMetadata` at the FlowField level; the privacy classification lives on the underlying stored field that the CalcFormula references. + +## Best Practice + +Do not declare `DataClassification` on `FieldClass = FlowField` or `FieldClass = FlowFilter` fields — the inherited `SystemMetadata` is correct and the property is redundant. If a FlowField exposes sensitive data, ensure the underlying source field has the right `DataClassification`; that is where the platform reads classification from for GDPR and telemetry purposes. + +See sample: `flowfield-flowfilter-classification-systemmetadata.good.al`. + +## Anti Pattern + +Flagging a FlowField for "missing `DataClassification`" or trying to override it to `CustomerContent` because the formula references customer data. The platform's automatic `SystemMetadata` value is the documented, intentional behavior for non-stored fields; overriding it adds nothing and misrepresents the field as if it were stored. diff --git a/microsoft/knowledge/privacy/flowfields-auto-inherit-systemmetadata.md b/microsoft/knowledge/privacy/flowfields-auto-inherit-systemmetadata.md deleted file mode 100644 index 5b049d3..0000000 --- a/microsoft/knowledge/privacy/flowfields-auto-inherit-systemmetadata.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -bc-version: [all] -domain: privacy -keywords: [flowfield, flowfilter, dataclassification, systemmetadata, default] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# FlowFields and FlowFilters automatically inherit DataClassification SystemMetadata - -## Description - -FlowFields and FlowFilters are virtual — they carry no stored data of their own, and their values are computed on demand from the source table the CalcFormula references. The platform classifies them as SystemMetadata automatically and does not require (or respect) a per-field DataClassification declaration. Flagging a FlowField as missing DataClassification, or as under-classified because the computed value may be CustomerContent, is a false positive: the underlying source field carries the classification that matters, and that is what telemetry and compliance tooling inspects. - -## Best Practice - -Leave DataClassification off FlowFields and FlowFilters. If the computed value is sensitive, the fix is to ensure the source table's field has the correct classification. Verify source-field classification rather than trying to re-classify the computed view. - -## Anti Pattern - -Reporting "missing DataClassification" on a FlowField, or attempting to set a FlowField's DataClassification to CustomerContent because the SUM aggregates a sensitive amount. The declaration has no effect; the platform uses the source-field classification. diff --git a/microsoft/knowledge/privacy/getlasterrortext-customer-content-in-errors.bad.al b/microsoft/knowledge/privacy/getlasterrortext-customer-content-in-errors.bad.al new file mode 100644 index 0000000..b943031 --- /dev/null +++ b/microsoft/knowledge/privacy/getlasterrortext-customer-content-in-errors.bad.al @@ -0,0 +1,17 @@ +codeunit 50209 "Privacy Sample GetLastError Bad" +{ + procedure AddAttachment() + var + ErrorMsg: Text; + begin + if not TryAddAttachment() then begin + ErrorMsg := StrSubstNo('Attachment failed: %1', GetLastErrorText(true)); + Error(ErrorMsg); + end; + end; + + [TryFunction] + local procedure TryAddAttachment() + begin + end; +} diff --git a/microsoft/knowledge/privacy/getlasterrortext-customer-content-in-errors.good.al b/microsoft/knowledge/privacy/getlasterrortext-customer-content-in-errors.good.al new file mode 100644 index 0000000..4b07537 --- /dev/null +++ b/microsoft/knowledge/privacy/getlasterrortext-customer-content-in-errors.good.al @@ -0,0 +1,16 @@ +codeunit 50208 "Privacy Sample GetLastError Good" +{ + procedure AddAttachmentSafely() + var + AttachmentFailedErr: Label 'Failed to add email attachment. Please try again.'; + begin + if not TryAddAttachment() then + Error(AttachmentFailedErr); + end; + + [TryFunction] + local procedure TryAddAttachment() + begin + // ... attachment logic that may fail with a customer-data-bearing error ... + end; +} diff --git a/microsoft/knowledge/privacy/getlasterrortext-customer-content-in-errors.md b/microsoft/knowledge/privacy/getlasterrortext-customer-content-in-errors.md new file mode 100644 index 0000000..769a3f5 --- /dev/null +++ b/microsoft/knowledge/privacy/getlasterrortext-customer-content-in-errors.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: privacy +keywords: [getlasterrortext, error, strsubstno, telemetry, customer-data, attachment] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Treat `GetLastErrorText()` as potential customer content + +## Description + +`GetLastErrorText()` returns the text of the last error that occurred in the context where it is called. That text routinely contains customer content — field values that triggered the validation, record keys, customer names, file names from upload failures, and similar fragments lifted from the failing operation. Re-emitting it through `StrSubstNo` into `Error()` bakes that customer data into a single plain-text parameter that the platform can no longer classify, so it is logged verbatim to telemetry (the same problem as any other `StrSubstNo`-pre-built error — see `avoid-strsubstno-prebuild-before-error.md`). + +## Best Practice + +When the goal is to surface a recoverable failure to the user, raise a generic message that does not embed `GetLastErrorText()` content, and log technical detail separately via `Session.LogMessage` with the correct `DataClassification`. If you must propagate the inner error verbatim, re-raise it as a direct parameter of `Error()` (e.g., `Error('%1', GetLastErrorText())`) rather than concatenating with `StrSubstNo` so the platform can apply its own handling. + +See sample: `getlasterrortext-customer-content-in-errors.good.al`. + +## Anti Pattern + +`ErrorMsg := StrSubstNo('Attachment failed: %1', GetLastErrorText(true)); Error(ErrorMsg);` — the inner error text may carry filenames or record values, and `StrSubstNo` strips the platform's ability to filter them before they hit telemetry. + +See sample: `getlasterrortext-customer-content-in-errors.bad.al`. diff --git a/microsoft/knowledge/privacy/in-memory-data-is-not-a-privacy-concern.md b/microsoft/knowledge/privacy/in-memory-data-is-not-a-privacy-concern.md deleted file mode 100644 index 415525f..0000000 --- a/microsoft/knowledge/privacy/in-memory-data-is-not-a-privacy-concern.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -bc-version: [all] -domain: privacy -keywords: [memory, dictionary, list, temporary-record, scope, false-positive] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# In-memory variables are not a privacy concern in Business Central - -## Description - -Business Central runs in a managed server environment. Local variables, Dictionary, List, and temporary Record buffers exist only for the duration of the request or session; the runtime reclaims them when the scope exits. Memory dumps are not a realistic threat vector in this architecture, and flagging an in-memory collection of customer emails or names as a privacy issue misstates the product's security model. - -## Best Practice - -Focus privacy review on persistence, transit, and telemetry: what is written to tables, sent over the network, or logged. Treat in-memory handling of personal data as normal business functionality. When an in-memory buffer is copied into IsolatedStorage, a table, or a telemetry call, that downstream write is what gets reviewed. - -## Anti Pattern - -Flagging `Dictionary of [Code[20], Text]`, `List of [Text]`, or `Record Customer temporary` variables that hold customer data during a calculation as a privacy concern. The flag is a false positive that trains authors to avoid a normal pattern and distracts from the persistent storage that does matter. diff --git a/microsoft/knowledge/privacy/in-memory-data-not-a-privacy-concern.md b/microsoft/knowledge/privacy/in-memory-data-not-a-privacy-concern.md new file mode 100644 index 0000000..38fb6f2 --- /dev/null +++ b/microsoft/knowledge/privacy/in-memory-data-not-a-privacy-concern.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: privacy +keywords: [in-memory, dictionary, list, temporary-table, variable, memory-dump] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# In-memory variables, dictionaries, lists and temporary tables are not a privacy concern + +## Description + +AL runs in a managed server environment. Local variables, `Dictionary`, `List`, temporary `Record` variables, and other in-process data structures exist only for the duration of the request or session and are released by the runtime when it ends — they are not persisted, not visible across sessions, and not exposed outside the server process. Memory dumps are not a realistic threat vector against Business Central's hosted architecture, so holding business data (emails, names, addresses, document content) in these structures while processing a request is normal and expected. + +## Best Practice + +Use whatever in-memory shape (`Dictionary`, `List`, temporary tables, plain variables) the algorithm needs. The privacy review applies to *persistent* surfaces — table fields, telemetry, outgoing HTTP — not to per-request memory. + +## Anti Pattern + +Flagging a `Dictionary of [Text, Text]` populated with customer emails, or a temporary `Record Customer` holding rows mid-processing, as a privacy leak. These structures are scoped to the request and do not leave the server's memory. diff --git a/microsoft/knowledge/privacy/keep-customer-data-out-of-featuretelemetry-dimensions.bad.al b/microsoft/knowledge/privacy/keep-customer-data-out-of-featuretelemetry-dimensions.bad.al deleted file mode 100644 index 7411a0e..0000000 --- a/microsoft/knowledge/privacy/keep-customer-data-out-of-featuretelemetry-dimensions.bad.al +++ /dev/null @@ -1,14 +0,0 @@ -codeunit 50936 "Privacy FeatureTelemetry Bad" -{ - procedure LogExpenseReleased(EmployeeNo: Code[20]; UserName: Text) - var - FeatureTelemetry: Codeunit "Feature Telemetry"; - CustomDimensions: Dictionary of [Text, Text]; - begin - CustomDimensions.Add('EmployeeNo', EmployeeNo); - CustomDimensions.Add('UserName', UserName); - CustomDimensions.Add('LastError', GetLastErrorText()); - - FeatureTelemetry.LogUsage('0000EA1', 'Expense Agent', 'Document Released', CustomDimensions); - end; -} diff --git a/microsoft/knowledge/privacy/keep-customer-data-out-of-featuretelemetry-dimensions.good.al b/microsoft/knowledge/privacy/keep-customer-data-out-of-featuretelemetry-dimensions.good.al deleted file mode 100644 index 9300406..0000000 --- a/microsoft/knowledge/privacy/keep-customer-data-out-of-featuretelemetry-dimensions.good.al +++ /dev/null @@ -1,13 +0,0 @@ -codeunit 50935 "Privacy FeatureTelemetry Good" -{ - procedure LogExpenseReleased() - var - FeatureTelemetry: Codeunit "Feature Telemetry"; - CustomDimensions: Dictionary of [Text, Text]; - begin - CustomDimensions.Add('DocumentType', 'Expense'); - CustomDimensions.Add('LineCountBucket', '10-20'); - - FeatureTelemetry.LogUsage('0000EA1', 'Expense Agent', 'Document Released', CustomDimensions); - end; -} diff --git a/microsoft/knowledge/privacy/keep-customer-data-out-of-featuretelemetry-dimensions.md b/microsoft/knowledge/privacy/keep-customer-data-out-of-featuretelemetry-dimensions.md deleted file mode 100644 index d9c1bc7..0000000 --- a/microsoft/knowledge/privacy/keep-customer-data-out-of-featuretelemetry-dimensions.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: privacy -keywords: [featuretelemetry, customdimensions, telemetry, pii, customercontent] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Keep customer data out of FeatureTelemetry custom dimensions - -## Description - -`Codeunit "Feature Telemetry"` writes telemetry through methods such as `LogUsage`, `LogUptake`, and `LogError`. The `CustomDimensions` dictionary passed to those methods is exported to the telemetry pipeline, so it has the same privacy boundary as `Session.LogMessage` dimensions. Customer names, email addresses, employee numbers, user IDs, security IDs, notes, and `GetLastErrorText()` do not become safe merely because they are structured dimensions. - -## Best Practice - -Log feature state, event names, counts, enum values, and non-personal technical identifiers. Omit customer and employee identifiers from `CustomDimensions`; if diagnostics need correlation, use a non-personal event ID or aggregate count instead. - -See sample: `keep-customer-data-out-of-featuretelemetry-dimensions.good.al`. - -## Anti Pattern - -Adding employee numbers, user names, customer emails, free-text descriptions, or raw `GetLastErrorText()` to the `CustomDimensions` dictionary before calling `FeatureTelemetry.LogUsage`, `LogUptake`, or `LogError`. - -See sample: `keep-customer-data-out-of-featuretelemetry-dimensions.bad.al`. diff --git a/microsoft/knowledge/privacy/migration-destination-classification.md b/microsoft/knowledge/privacy/migration-destination-classification.md new file mode 100644 index 0000000..afb8e8d --- /dev/null +++ b/microsoft/knowledge/privacy/migration-destination-classification.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: privacy +keywords: [data-migration, hybridsl, hybridgp, hybridbc, destination-classification, ssn, tin] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# In data migration code, classify the destination — not the migration itself + +## Description + +Migration codeunits such as `HybridSL`, `HybridGP`, and `HybridBC` exist to copy sensitive data — TINs, Federal IDs, social security numbers, financial records — from a source system into Business Central. The fact that PII flows through these codeunits is the entire point of their existence, not a defect. The privacy concern is whether the destination field where the data lands carries the correct `DataClassification`. If it does, the migration is doing its job; if it doesn't, the right fix is on the destination table field, never on the migration code that writes to it. + +## Best Practice + +When reviewing a migration codeunit, trace each `Dest."" := Source.""` assignment to the destination field's `DataClassification`. Confirm that fields receiving PII (SSNs, Federal IDs, customer names, addresses) are classified `EndUserIdentifiableInformation` or `CustomerContent` as appropriate — and not left as `SystemMetadata` or `ToBeClassified`. + +## Anti Pattern + +Flagging the migration code itself for "processing sensitive data" or recommending that it filter, hash, or skip PII fields — these tables exist to migrate that data. The actionable finding is always on the destination field's classification, not on the migration's assignment statement. diff --git a/microsoft/knowledge/privacy/no-pii-in-telemetry-message-string.bad.al b/microsoft/knowledge/privacy/no-pii-in-telemetry-message-string.bad.al new file mode 100644 index 0000000..06970e2 --- /dev/null +++ b/microsoft/knowledge/privacy/no-pii-in-telemetry-message-string.bad.al @@ -0,0 +1,21 @@ +codeunit 50213 "Privacy Sample Telemetry Bad" +{ + procedure LogCustomerProcessed(var Customer: Record Customer) + begin + Session.LogMessage('0000', StrSubstNo('Processed %1', Customer.Name), Verbosity::Normal, + DataClassification::SystemMetadata, TelemetryScope::All, + 'Category', 'Privacy'); + end; + + procedure LogFileError(FileName: Text) + begin + Session.LogMessage('0001', StrSubstNo('Error processing file %1', FileName), Verbosity::Error, + DataClassification::SystemMetadata, TelemetryScope::All); + end; + + procedure LogEmployeeUpdate(EmployeeCode: Code[20]) + begin + Session.LogMessage('0002', StrSubstNo('Employee %1 updated record', EmployeeCode), Verbosity::Normal, + DataClassification::SystemMetadata, TelemetryScope::All); + end; +} diff --git a/microsoft/knowledge/privacy/no-pii-in-telemetry-message-string.good.al b/microsoft/knowledge/privacy/no-pii-in-telemetry-message-string.good.al new file mode 100644 index 0000000..96a553a --- /dev/null +++ b/microsoft/knowledge/privacy/no-pii-in-telemetry-message-string.good.al @@ -0,0 +1,15 @@ +codeunit 50212 "Privacy Sample Telemetry Good" +{ + procedure LogCustomerProcessed(var Customer: Record Customer) + begin + Session.LogMessage('0000', 'Customer record processed', Verbosity::Normal, + DataClassification::SystemMetadata, TelemetryScope::All, + 'Category', 'Privacy'); + end; + + procedure LogFileError() + begin + Session.LogMessage('0001', 'Error processing uploaded file', Verbosity::Error, + DataClassification::SystemMetadata, TelemetryScope::All); + end; +} diff --git a/microsoft/knowledge/privacy/no-pii-in-telemetry-message-string.md b/microsoft/knowledge/privacy/no-pii-in-telemetry-message-string.md new file mode 100644 index 0000000..ba95fbe --- /dev/null +++ b/microsoft/knowledge/privacy/no-pii-in-telemetry-message-string.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: privacy +keywords: [telemetry, session-logmessage, strsubstno, pii, customer-data, employee-code, filename] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Do not embed customer data in the telemetry message text + +## Description + +`Session.LogMessage`'s message argument is a plain `Text`. Unlike `Error()`, the platform does not inspect this string field-by-field — whatever is in the text is what telemetry receives. So a call that builds the message via `StrSubstNo` from customer-bearing fields ships those values to telemetry verbatim, regardless of the `DataClassification` argument on the same call. Flagged content includes customer names, email addresses, phone numbers, addresses, employee codes or IDs, attachment filenames, user-provided text that may carry PII, and dumps of `Record` content. + +## Best Practice + +Keep the telemetry message a static, non-personal string ("Customer record processed", "Error processing uploaded file"). When structured context is genuinely needed, attach it through custom dimensions, where individual values can be reviewed and classified at the dimension level rather than baked into a free-text message. + +See sample: `no-pii-in-telemetry-message-string.good.al`. + +## Anti Pattern + +`Session.LogMessage('0000', StrSubstNo('Processed %1', Customer.Name), ...)` — the customer name is in telemetry the moment the line runs. Detection signal: a `StrSubstNo` whose result is the second argument of `Session.LogMessage`. The same shape with `FileName`, `EmployeeCode`, or any record field is the same problem. + +See sample: `no-pii-in-telemetry-message-string.bad.al`. diff --git a/microsoft/knowledge/privacy/override-inherited-dataclassification-per-field.bad.al b/microsoft/knowledge/privacy/override-inherited-dataclassification-per-field.bad.al deleted file mode 100644 index cf1b8fa..0000000 --- a/microsoft/knowledge/privacy/override-inherited-dataclassification-per-field.bad.al +++ /dev/null @@ -1,18 +0,0 @@ -table 50909 "Privacy Sample Override Bad" -{ - DataClassification = SystemMetadata; - - fields - { - field(1; "Entry No."; Integer) { } - // Customer name inherits SystemMetadata from the table. Subject-access - // and retention tooling treats the value as system housekeeping. - field(2; "Customer Name"; Text[100]) { } - field(3; "E-Mail"; Text[80]) { } - field(4; "Logged At"; DateTime) { } - } - keys - { - key(PK; "Entry No.") { Clustered = true; } - } -} diff --git a/microsoft/knowledge/privacy/override-inherited-dataclassification-per-field.good.al b/microsoft/knowledge/privacy/override-inherited-dataclassification-per-field.good.al deleted file mode 100644 index 2670601..0000000 --- a/microsoft/knowledge/privacy/override-inherited-dataclassification-per-field.good.al +++ /dev/null @@ -1,23 +0,0 @@ -table 50908 "Privacy Sample Override Good" -{ - DataClassification = SystemMetadata; - - fields - { - field(1; "Entry No."; Integer) { } - field(2; "Customer Name"; Text[100]) - { - // Table default is SystemMetadata; this field is personal data. - DataClassification = CustomerContent; - } - field(3; "E-Mail"; Text[80]) - { - DataClassification = CustomerContent; - } - field(4; "Logged At"; DateTime) { } - } - keys - { - key(PK; "Entry No.") { Clustered = true; } - } -} diff --git a/microsoft/knowledge/privacy/override-inherited-dataclassification-per-field.md b/microsoft/knowledge/privacy/override-inherited-dataclassification-per-field.md deleted file mode 100644 index 677e9c4..0000000 --- a/microsoft/knowledge/privacy/override-inherited-dataclassification-per-field.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: privacy -keywords: [dataclassification, inheritance, table-level, field-level, override] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Override inherited DataClassification when a field doesn't fit the table default - -## Description - -When a table declares `DataClassification` at the table level, every field inherits that value unless the field declares its own. This is efficient for homogeneous tables — a SystemMetadata log table whose fields are all system-generated, a CustomerContent transaction table whose fields are all business data. It is a privacy regression when a table is classified SystemMetadata but contains a field that holds personal data: the field silently inherits the wrong classification, and telemetry tooling treats its content as safe to log when it is not. - -## Best Practice - -Review every field on a table with a table-level DataClassification. Fields whose content matches the table's default need no per-field declaration. Fields that carry a different kind of data — a customer name on an otherwise-system-metadata log table, a personal identifier on a mixed-content table — must declare their own DataClassification that overrides the table default. - -See sample: `override-inherited-dataclassification-per-field.good.al`. - -## Anti Pattern - -A table declared `DataClassification = SystemMetadata` with fields like `Customer Name`, `E-Mail`, `Phone No.` — the fields inherit SystemMetadata, which is wrong for CustomerContent. Subject-access-request and retention tooling treats the personal data as system housekeeping. - -See sample: `override-inherited-dataclassification-per-field.bad.al`. diff --git a/microsoft/knowledge/privacy/page-display-is-not-a-privacy-concern.md b/microsoft/knowledge/privacy/page-display-is-not-a-privacy-concern.md new file mode 100644 index 0000000..6e8d62d --- /dev/null +++ b/microsoft/knowledge/privacy/page-display-is-not-a-privacy-concern.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: privacy +keywords: [page, card, list, api, listpart, permission-system, display, ui-dialog] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Displaying fields on a page (or in a UI dialog) is not a privacy concern + +## Description + +Every page in Business Central — `Card`, `List`, `API`, `ListPart`, request pages — renders data to an authenticated user who has been granted permission to see it. The BC permission system, not the page definition, controls who sees what; once a user is permissioned to a table, displaying any field of that table is normal business functionality. The same logic extends to `Message`, `Notification` and `Confirm` dialogs: the signed-in user already has access to the data the dialog is showing them. Privacy review for pages and dialogs is therefore the wrong layer — the actionable findings live on the underlying data (table-field classification, telemetry message text, outbound HTTP consent), not on the UI. + +## Best Practice + +When asked "is it OK to show this email/name/employee code on this page?", the answer is yes — provided the user has permission to the underlying record. Drive privacy concerns to the data layer (classification, telemetry, external transfer) rather than the UI layer. + +## Anti Pattern + +Flagging an API page, list, card, or notification for surfacing customer-bearing fields (`E-Mail`, `Name`, `Phone No.`, audit fields, `User ID`). The permission system governs visibility; the page does not. diff --git a/microsoft/knowledge/privacy/pages-displaying-permitted-data-is-not-a-privacy-concern.md b/microsoft/knowledge/privacy/pages-displaying-permitted-data-is-not-a-privacy-concern.md deleted file mode 100644 index e28a0d6..0000000 --- a/microsoft/knowledge/privacy/pages-displaying-permitted-data-is-not-a-privacy-concern.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -bc-version: [all] -domain: privacy -keywords: [page, display, permission, authenticated, false-positive] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Pages displaying data to permitted users are not a privacy concern - -## Description - -Every page in Business Central displays data to an authenticated user who holds the permissions required to see it. The permission system — table permissions, entitlements, field-level restrictions where configured — is the access-control boundary. Flagging a page for showing customer emails, names, addresses, document numbers, or system audit fields treats display as a leak when it is the product's intended function. - -## Best Practice - -Privacy review of pages is about data classification on the source table and about consent on outgoing integrations reached through page actions. Displaying business data to a user with permission to view it is correct behaviour, including on API pages that are gated by the same permission model. - -## Anti Pattern - -Reporting "customer email is shown on the page" or "user ID visible in the list" as privacy findings. The finding does not reflect a privacy regression and redirects the author toward hiding data that the permitted user is entitled to see. The same logic produces noise on Confirm, Message, and Notification that surface business identifiers. diff --git a/microsoft/knowledge/privacy/privacy-notice-consent-for-external-data-transfer.bad.al b/microsoft/knowledge/privacy/privacy-notice-consent-for-external-data-transfer.bad.al new file mode 100644 index 0000000..6308674 --- /dev/null +++ b/microsoft/knowledge/privacy/privacy-notice-consent-for-external-data-transfer.bad.al @@ -0,0 +1,13 @@ +codeunit 50217 "Privacy Sample Consent Bad" +{ + procedure SendDataToExternalService(Customer: Record Customer) + var + HttpClient: HttpClient; + Content: HttpContent; + Response: HttpResponseMessage; + begin + Content.WriteFrom(StrSubstNo('{"email":"%1","name":"%2"}', + Customer."E-Mail", Customer.Name)); + HttpClient.Post('https://api.externalservice.com/sync', Content, Response); + end; +} diff --git a/microsoft/knowledge/privacy/privacy-notice-consent-for-external-data-transfer.good.al b/microsoft/knowledge/privacy/privacy-notice-consent-for-external-data-transfer.good.al new file mode 100644 index 0000000..0ac939c --- /dev/null +++ b/microsoft/knowledge/privacy/privacy-notice-consent-for-external-data-transfer.good.al @@ -0,0 +1,22 @@ +codeunit 50216 "Privacy Sample Consent Good" +{ + procedure SendDataToExternalService(Customer: Record Customer) + var + PrivacyNotice: Codeunit "Privacy Notice"; + PrivacyNoticeRegistrations: Codeunit "Privacy Notice Registrations"; + HttpClient: HttpClient; + Content: HttpContent; + Response: HttpResponseMessage; + PrivacyConsentRequiredErr: Label 'Privacy notice consent is required for this integration.'; + begin + if PrivacyNotice.GetPrivacyNoticeApprovalState( + PrivacyNoticeRegistrations.GetExchangePrivacyNoticeId()) + <> "Privacy Notice Approval State"::Agreed + then + Error(PrivacyConsentRequiredErr); + + Content.WriteFrom(StrSubstNo('{"email":"%1","name":"%2"}', + Customer."E-Mail", Customer.Name)); + HttpClient.Post('https://api.externalservice.com/sync', Content, Response); + end; +} diff --git a/microsoft/knowledge/privacy/privacy-notice-consent-for-external-data-transfer.md b/microsoft/knowledge/privacy/privacy-notice-consent-for-external-data-transfer.md new file mode 100644 index 0000000..a064792 --- /dev/null +++ b/microsoft/knowledge/privacy/privacy-notice-consent-for-external-data-transfer.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: privacy +keywords: [privacy-notice, consent, http-client, outgoing-request, external-service, getprivacynoticeapprovalstate] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Outgoing requests to external services require a Privacy Notice consent check + +## Description + +Business Central ships a built-in Privacy Notice framework that the admin uses to grant or withhold per-integration consent for sending data to external services. The relevant API surface is `Codeunit "Privacy Notice"` (consent checks via `GetPrivacyNoticeApprovalState()`), `Codeunit "Privacy Notice Registrations"` (well-known notice IDs for integrations such as Exchange, OneDrive, Teams), and the `Enum "Privacy Notice Approval State"` with values `Agreed`, `Disagreed`, and `Not Set`. The admin UI is the **Privacy Notices Status** page. The compliance concern in code review is therefore not that personal data is included in an outgoing HTTP body — that is normal business functionality — but that the code path issuing the request contains no `PrivacyNotice.GetPrivacyNoticeApprovalState(...)` check. + +## Best Practice + +Before issuing an outgoing HTTP request to an external service, verify `PrivacyNotice.GetPrivacyNoticeApprovalState() = "Privacy Notice Approval State"::Agreed`. The check does not have to live next to the `HttpClient.Post` call — it can sit anywhere upstream in the same code path (for example in the page's `OnOpenPage`, in a wizard step, or in a setup action) as long as no execution path reaches the request without passing through it. + +See sample: `privacy-notice-consent-for-external-data-transfer.good.al`. + +## Anti Pattern + +A `procedure SendDataToExternalService(...)` that posts customer data to an external endpoint with no `PrivacyNotice.GetPrivacyNoticeApprovalState` anywhere upstream. The same anti-pattern applies in reverse: removing an existing privacy-notice check from code that still issues the external call. + +See sample: `privacy-notice-consent-for-external-data-transfer.bad.al`. diff --git a/microsoft/knowledge/privacy/register-integration-in-privacy-notice-registrations.good.al b/microsoft/knowledge/privacy/register-integration-in-privacy-notice-registrations.good.al new file mode 100644 index 0000000..d8a20f5 --- /dev/null +++ b/microsoft/knowledge/privacy/register-integration-in-privacy-notice-registrations.good.al @@ -0,0 +1,11 @@ +codeunit 50218 "Privacy Sample Register Integration" +{ + [EventSubscriber(ObjectType::Codeunit, Codeunit::"Privacy Notice Registrations", 'OnRegisterPrivacyNotices', '', false, false)] + local procedure OnRegisterPrivacyNotices(var TempPrivacyNotice: Record "Privacy Notice" temporary) + var + PrivacyNotice: Codeunit "Privacy Notice"; + begin + PrivacyNotice.CreatePrivacyNoticeForIntegration( + 'My External Sync', 'External Customer Sync Service'); + end; +} diff --git a/microsoft/knowledge/privacy/register-integration-in-privacy-notice-registrations.md b/microsoft/knowledge/privacy/register-integration-in-privacy-notice-registrations.md new file mode 100644 index 0000000..40779e9 --- /dev/null +++ b/microsoft/knowledge/privacy/register-integration-in-privacy-notice-registrations.md @@ -0,0 +1,24 @@ +--- +bc-version: [all] +domain: privacy +keywords: [privacy-notice-registrations, integration, register, exchange, onedrive, teams, notice-id] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Register every new external integration with `Privacy Notice Registrations` + +## Description + +`Codeunit "Privacy Notice Registrations"` is the registry of integrations whose consent state the platform tracks. Built-in integrations such as Exchange, OneDrive and Teams already have notice IDs exposed via accessor methods on this codeunit (`GetExchangePrivacyNoticeId`, etc.); a new integration introduced by an extension must add itself to the registry so that the admin can grant or withhold consent on the **Privacy Notices Status** page. Without registration, there is nothing for `Codeunit "Privacy Notice"` to return an approval state for — the call cannot meaningfully gate the outbound request. + +## Best Practice + +When introducing a new outbound integration: pick a stable notice ID, register it via `Privacy Notice Registrations`, and then gate every outbound call with `PrivacyNotice.GetPrivacyNoticeApprovalState()` as described in `privacy-notice-consent-for-external-data-transfer.md`. + +See sample: `register-integration-in-privacy-notice-registrations.good.al`. + +## Anti Pattern + +Shipping a new outbound integration without registering it. Even if the code calls `GetPrivacyNoticeApprovalState`, the admin has no surface to express consent — the integration is effectively unmanaged from a privacy-notice standpoint. diff --git a/microsoft/knowledge/privacy/require-privacy-notice-consent-before-outgoing-requests.bad.al b/microsoft/knowledge/privacy/require-privacy-notice-consent-before-outgoing-requests.bad.al deleted file mode 100644 index 0349acf..0000000 --- a/microsoft/knowledge/privacy/require-privacy-notice-consent-before-outgoing-requests.bad.al +++ /dev/null @@ -1,14 +0,0 @@ -codeunit 50905 "Privacy Sample Consent Bad" -{ - procedure SyncToPartner(var Customer: Record Customer) - var - Client: HttpClient; - Content: HttpContent; - Response: HttpResponseMessage; - begin - // Customer email and name sent externally with no Privacy Notice check - // anywhere in the reachable code path. - Content.WriteFrom(Customer."E-Mail"); - Client.Post('https://partner.example.com/sync', Content, Response); - end; -} diff --git a/microsoft/knowledge/privacy/require-privacy-notice-consent-before-outgoing-requests.good.al b/microsoft/knowledge/privacy/require-privacy-notice-consent-before-outgoing-requests.good.al deleted file mode 100644 index c3aec56..0000000 --- a/microsoft/knowledge/privacy/require-privacy-notice-consent-before-outgoing-requests.good.al +++ /dev/null @@ -1,20 +0,0 @@ -codeunit 50904 "Privacy Sample Consent Good" -{ - procedure SyncToPartner(var Customer: Record Customer) - var - PrivacyNotice: Codeunit "Privacy Notice"; - Client: HttpClient; - Content: HttpContent; - Response: HttpResponseMessage; - PartnerNoticeIdTok: Label 'Contoso-PartnerSync', Locked = true; - ConsentRequiredErr: Label 'Consent is required before syncing to the external partner.'; - begin - if PrivacyNotice.GetPrivacyNoticeApprovalState(PartnerNoticeIdTok, false) <> - "Privacy Notice Approval State"::Agreed - then - Error(ConsentRequiredErr); - - Content.WriteFrom(Customer."No."); - Client.Post('https://partner.example.com/sync', Content, Response); - end; -} diff --git a/microsoft/knowledge/privacy/require-privacy-notice-consent-before-outgoing-requests.md b/microsoft/knowledge/privacy/require-privacy-notice-consent-before-outgoing-requests.md deleted file mode 100644 index 2ac058a..0000000 --- a/microsoft/knowledge/privacy/require-privacy-notice-consent-before-outgoing-requests.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: privacy -keywords: [privacy-notice, consent, gdpr, httpclient, outgoing-request] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Check Privacy Notice consent before outgoing requests with customer data - -## Description - -Business Central ships a Privacy Notice framework for user consent to third-party integrations. When code sends personal data (emails, names, addresses) to an external service, the concern is not whether the data itself is compliant — the product handles that — but whether the code path has verified the user has agreed to the integration. Missing consent checks on new or modified outgoing paths is the privacy issue to flag; the presence of PII in the payload is not. - -## Best Practice - -Before an outgoing HttpClient call that carries customer data, verify consent via `Codeunit "Privacy Notice".GetPrivacyNoticeApprovalState()` for the integration's registered notice id. The check may live upstream (page OnOpenPage, wizard step) as long as every path that reaches the external call passes through it. Register new integrations via `Codeunit "Privacy Notice Registrations"`. - -See sample: `require-privacy-notice-consent-before-outgoing-requests.good.al`. - -## Anti Pattern - -Adding or modifying an outgoing integration and sending customer data without any `Privacy Notice` check in the reachable code path. Removing an existing consent check from an integration that still sends data externally falls in the same category. - -See sample: `require-privacy-notice-consent-before-outgoing-requests.bad.al`. diff --git a/microsoft/knowledge/privacy/resolve-tobeclassified-before-release.md b/microsoft/knowledge/privacy/resolve-tobeclassified-before-release.md index 4e8c99c..6820704 100644 --- a/microsoft/knowledge/privacy/resolve-tobeclassified-before-release.md +++ b/microsoft/knowledge/privacy/resolve-tobeclassified-before-release.md @@ -1,22 +1,22 @@ --- bc-version: [all] domain: privacy -keywords: [tobeclassified, dataclassification, release, gdpr, placeholder] +keywords: [tobeclassified, data-classification, release, appsource, development] technologies: [al] countries: [w1] application-area: [all] --- -# Resolve ToBeClassified before release +# Resolve every `ToBeClassified` before release ## Description -`DataClassification = ToBeClassified` is a development marker, not a releasable privacy state. It tells reviewers and tooling that the field still needs classification work. Shipping it prevents data-subject, retention, and telemetry tooling from making a correct decision about the field. +`DataClassification = ToBeClassified` is the sentinel value the AL compiler accepts while a developer has not yet decided what a new field actually stores. It exists for the development phase only and must be resolved to a real classification (`CustomerContent`, `EndUserIdentifiableInformation`, `EndUserPseudonymousIdentifiers`, `AccountData`, `OrganizationIdentifiableInformation` or `SystemMetadata`) before the code ships. A released field left at `ToBeClassified` tells the platform "we have not classified this data" — which means GDPR data-subject requests, telemetry and audit reports cannot reason about it. ## Best Practice -Replace every `ToBeClassified` value with the narrowest accurate classification before the PR ships to customers. If the field inherits a correct table-level DataClassification, remove the placeholder rather than leaving a field-level `ToBeClassified` override. +Treat `ToBeClassified` as a TODO marker that fails release readiness. Sweep new table objects and table extensions for it before submitting a build for publication. If the right classification is genuinely unclear, decide between `CustomerContent` and `EndUserIdentifiableInformation` from the data's content, not from convenience. ## Anti Pattern -Treating ToBeClassified as a safe default because the field is new or because the final classification is uncertain. Uncertainty should bias toward a stronger classification, not toward an unresolved placeholder. +Leaving `ToBeClassified` in a shipped extension. Reviewers who treat the value as "I'll figure it out later" ship a field whose privacy posture is undefined for every customer that installs the app. diff --git a/microsoft/knowledge/privacy/sanitize-getlasterrortext-before-telemetry.bad.al b/microsoft/knowledge/privacy/sanitize-getlasterrortext-before-telemetry.bad.al deleted file mode 100644 index 4ff958b..0000000 --- a/microsoft/knowledge/privacy/sanitize-getlasterrortext-before-telemetry.bad.al +++ /dev/null @@ -1,16 +0,0 @@ -codeunit 50907 "Privacy Sample LastErr Bad" -{ - procedure LogFailure() - var - CategoryTok: Label 'Sync', Locked = true; - FailureTxt: Label 'Operation failed: %1', Comment = '%1 = last error text'; - begin - // GetLastErrorText(true) carries the call stack and field values from - // the failing context. Declared as SystemMetadata but the payload is CustomerContent. - Session.LogMessage( - '0000ABC', StrSubstNo(FailureTxt, GetLastErrorText(true)), - Verbosity::Error, - DataClassification::SystemMetadata, - TelemetryScope::ExtensionPublisher, 'Category', CategoryTok); - end; -} diff --git a/microsoft/knowledge/privacy/sanitize-getlasterrortext-before-telemetry.good.al b/microsoft/knowledge/privacy/sanitize-getlasterrortext-before-telemetry.good.al deleted file mode 100644 index 1c3d8fc..0000000 --- a/microsoft/knowledge/privacy/sanitize-getlasterrortext-before-telemetry.good.al +++ /dev/null @@ -1,15 +0,0 @@ -codeunit 50906 "Privacy Sample LastErr Good" -{ - procedure LogFailure() - var - CategoryTok: Label 'Sync', Locked = true; - GenericMsgTxt: Label 'Sync operation failed. See extended log for details.'; - begin - // Generic message, no GetLastErrorText. Detail goes to an internal log - // the telemetry pipeline does not receive. - Session.LogMessage( - '0000ABC', GenericMsgTxt, Verbosity::Error, - DataClassification::SystemMetadata, - TelemetryScope::ExtensionPublisher, 'Category', CategoryTok); - end; -} diff --git a/microsoft/knowledge/privacy/sanitize-getlasterrortext-before-telemetry.md b/microsoft/knowledge/privacy/sanitize-getlasterrortext-before-telemetry.md deleted file mode 100644 index 496c0ac..0000000 --- a/microsoft/knowledge/privacy/sanitize-getlasterrortext-before-telemetry.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: privacy -keywords: [getlasterrortext, telemetry, callstack, dataclassification, pii] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Sanitize GetLastErrorText before sending to telemetry - -## Description - -`GetLastErrorText` and `GetLastErrorCallStack` return strings built from the failing call site's data — field values, record keys, customer names, filenames. Logging either to telemetry with `DataClassification::SystemMetadata` misstates the content: the actual values are CustomerContent or worse. The true classification is not always SystemMetadata, and silently mislabelling a CustomerContent payload as system data is the specific privacy regression to avoid. - -## Best Practice - -Log a generic error message and either omit GetLastErrorText entirely or classify the telemetry call as `DataClassification::CustomerContent`. Prefer `GetLastErrorText(false)` to exclude the call stack when the text is needed but the stack is not. When in doubt, log a generic summary and persist the detailed error separately in a restricted-access log the telemetry pipeline does not receive. - -See sample: `sanitize-getlasterrortext-before-telemetry.good.al`. - -## Anti Pattern - -`Session.LogMessage(..., StrSubstNo('Operation failed: %1', GetLastErrorText(true)), ..., DataClassification::SystemMetadata, ...)` — the classification is wrong for the payload, and the call stack typically carries customer data from the failing operation into the telemetry stream. - -See sample: `sanitize-getlasterrortext-before-telemetry.bad.al`. diff --git a/microsoft/knowledge/privacy/session-logmessage-requires-dataclassification.bad.al b/microsoft/knowledge/privacy/session-logmessage-requires-dataclassification.bad.al new file mode 100644 index 0000000..e895d7c --- /dev/null +++ b/microsoft/knowledge/privacy/session-logmessage-requires-dataclassification.bad.al @@ -0,0 +1,7 @@ +codeunit 50211 "Privacy Sample LogMessage Bad" +{ + procedure LogCompleted() + begin + Session.LogMessage('0003', 'Operation completed', Verbosity::Normal); + end; +} diff --git a/microsoft/knowledge/privacy/session-logmessage-requires-dataclassification.good.al b/microsoft/knowledge/privacy/session-logmessage-requires-dataclassification.good.al new file mode 100644 index 0000000..d3353ec --- /dev/null +++ b/microsoft/knowledge/privacy/session-logmessage-requires-dataclassification.good.al @@ -0,0 +1,8 @@ +codeunit 50210 "Privacy Sample LogMessage Good" +{ + procedure LogCompleted() + begin + Session.LogMessage('0003', 'Operation completed', Verbosity::Normal, + DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher); + end; +} diff --git a/microsoft/knowledge/privacy/session-logmessage-requires-dataclassification.md b/microsoft/knowledge/privacy/session-logmessage-requires-dataclassification.md new file mode 100644 index 0000000..67381f7 --- /dev/null +++ b/microsoft/knowledge/privacy/session-logmessage-requires-dataclassification.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: privacy +keywords: [session-logmessage, telemetry, data-classification, verbosity, telemetry-scope] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Every `Session.LogMessage` call must specify `DataClassification` + +## Description + +`Session.LogMessage` writes a record to the telemetry pipeline. The platform requires the call to carry an explicit `DataClassification` argument so that the entry can be routed and retained correctly downstream — telemetry consumers, GDPR exports, and Application Insights dashboards all rely on it. The compiler accepts overloads without the parameter (the two-argument and three-argument shapes that omit it), but for any telemetry that ships to customers, the `DataClassification`-bearing overload is the correct one. + +## Best Practice + +Use the overload that takes `Verbosity`, `DataClassification`, and `TelemetryScope`. For payload-free operational telemetry that does not embed customer data, `DataClassification::SystemMetadata` is the right value. Choose `TelemetryScope::ExtensionPublisher` for telemetry meant for the publishing partner only; `TelemetryScope::All` also forwards to the customer's tenant telemetry. + +See sample: `session-logmessage-requires-dataclassification.good.al`. + +## Anti Pattern + +Calling `Session.LogMessage('0003', 'Operation completed', Verbosity::Normal)` — the overload omits `DataClassification` and leaves the platform without the information needed to classify the entry. Detection signal: a `Session.LogMessage` call whose argument list ends at `Verbosity`. + +See sample: `session-logmessage-requires-dataclassification.bad.al`. diff --git a/microsoft/knowledge/privacy/specify-dataclassification-on-every-telemetry-call.bad.al b/microsoft/knowledge/privacy/specify-dataclassification-on-every-telemetry-call.bad.al deleted file mode 100644 index f2eb191..0000000 --- a/microsoft/knowledge/privacy/specify-dataclassification-on-every-telemetry-call.bad.al +++ /dev/null @@ -1,16 +0,0 @@ -codeunit 50913 "Privacy Sample Telemetry Bad" -{ - procedure LogProcessed(var Customer: Record Customer) - var - CategoryTok: Label 'CustomerProcessing', Locked = true; - MsgTemplateTxt: Label 'Processed customer %1', Comment = '%1 = customer name'; - begin - // Declared SystemMetadata; payload is CustomerContent. The message is - // opaque text once built; the pipeline cannot redact. - Session.LogMessage( - '0000001', StrSubstNo(MsgTemplateTxt, Customer.Name), - Verbosity::Normal, - DataClassification::SystemMetadata, - TelemetryScope::All, 'Category', CategoryTok); - end; -} diff --git a/microsoft/knowledge/privacy/specify-dataclassification-on-every-telemetry-call.good.al b/microsoft/knowledge/privacy/specify-dataclassification-on-every-telemetry-call.good.al deleted file mode 100644 index 233e11d..0000000 --- a/microsoft/knowledge/privacy/specify-dataclassification-on-every-telemetry-call.good.al +++ /dev/null @@ -1,17 +0,0 @@ -codeunit 50912 "Privacy Sample Telemetry Good" -{ - procedure LogProcessed(var Customer: Record Customer) - var - CategoryTok: Label 'CustomerProcessing', Locked = true; - ProcessedMsgTxt: Label 'Customer record processed.'; - begin - // Generic message. Business identifier in a custom dimension, - // never a free-text personal name. - Session.LogMessage( - '0000001', ProcessedMsgTxt, Verbosity::Normal, - DataClassification::SystemMetadata, - TelemetryScope::ExtensionPublisher, - 'Category', CategoryTok, - 'CustomerNo', Customer."No."); - end; -} diff --git a/microsoft/knowledge/privacy/specify-dataclassification-on-every-telemetry-call.md b/microsoft/knowledge/privacy/specify-dataclassification-on-every-telemetry-call.md deleted file mode 100644 index cf8d257..0000000 --- a/microsoft/knowledge/privacy/specify-dataclassification-on-every-telemetry-call.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: privacy -keywords: [telemetry, session-logmessage, dataclassification, dimensions, pii] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Specify DataClassification on every telemetry call and keep PII out of the message - -## Description - -`Session.LogMessage` accepts a DataClassification parameter that governs how the platform handles the logged content in the telemetry pipeline. Omitting it is a schema violation the platform cannot repair later. Embedding personal data — emails, names, phone numbers, addresses, filenames of user uploads — in the message string also defeats classification, because the pipeline sees opaque text and cannot selectively redact. The same privacy boundary applies to other telemetry surfaces such as `Codeunit "Feature Telemetry"` custom dimensions. - -## Best Practice - -Pass DataClassification explicitly on every Session.LogMessage call. Keep the message a generic, non-identifying sentence and place structured values in custom dimensions where the classification applies per key. Business identifiers (Customer No., Document No., Vendor No.) are acceptable as dimensions; free-text personal data is not. - -See sample: `specify-dataclassification-on-every-telemetry-call.good.al`. - -## Anti Pattern - -`Session.LogMessage('0001', StrSubstNo('Customer %1 processed', Customer.Name), Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::All)` — the declared classification is SystemMetadata but the message carries CustomerContent. The payload is logged with the wrong tag; downstream consumers treat it as safe when it is not. - -See sample: `specify-dataclassification-on-every-telemetry-call.bad.al`. diff --git a/microsoft/knowledge/privacy/strsubstno-prebuild-breaks-error-telemetry-classification.bad.al b/microsoft/knowledge/privacy/strsubstno-prebuild-breaks-error-telemetry-classification.bad.al deleted file mode 100644 index f162f6a..0000000 --- a/microsoft/knowledge/privacy/strsubstno-prebuild-breaks-error-telemetry-classification.bad.al +++ /dev/null @@ -1,14 +0,0 @@ -codeunit 50901 "Privacy Sample StrSubstNo Bad" -{ - procedure FailCustomer(var Customer: Record Customer) - var - ErrorMsg: Text; - begin - // Platform receives a plain Text string. It cannot inspect fields, - // cannot classify, cannot strip. The email and address reach telemetry. - ErrorMsg := StrSubstNo( - 'Customer %1 (%2) at %3 has invalid data', - Customer.Name, Customer."E-Mail", Customer.Address); - Error(ErrorMsg); - end; -} diff --git a/microsoft/knowledge/privacy/strsubstno-prebuild-breaks-error-telemetry-classification.good.al b/microsoft/knowledge/privacy/strsubstno-prebuild-breaks-error-telemetry-classification.good.al deleted file mode 100644 index 3ced013..0000000 --- a/microsoft/knowledge/privacy/strsubstno-prebuild-breaks-error-telemetry-classification.good.al +++ /dev/null @@ -1,11 +0,0 @@ -codeunit 50900 "Privacy Sample StrSubstNo Good" -{ - procedure FailCustomer(var Customer: Record Customer) - var - CustomerDataInvalidErr: Label 'Customer %1 has invalid data.', Comment = '%1 = Customer No.'; - begin - // Platform sees the Label and the field reference. It inspects the - // field's DataClassification and handles telemetry correctly. - Error(CustomerDataInvalidErr, Customer."No."); - end; -} diff --git a/microsoft/knowledge/privacy/strsubstno-prebuild-breaks-error-telemetry-classification.md b/microsoft/knowledge/privacy/strsubstno-prebuild-breaks-error-telemetry-classification.md deleted file mode 100644 index f70c00e..0000000 --- a/microsoft/knowledge/privacy/strsubstno-prebuild-breaks-error-telemetry-classification.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: privacy -keywords: [strsubstno, error, telemetry, dataclassification, pii] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Pre-building Error text with StrSubstNo defeats platform PII stripping - -## Description - -Error messages are captured by platform telemetry. When Error receives a format template and substitution arguments directly (`Error('... %1 ...', Value)`), the platform can classify and strip sensitive values before telemetry is written. This is true whether the arguments are record fields, local variables, function results, or other expressions. When the caller pre-builds the message with StrSubstNo and then passes the resulting Text to Error, the platform sees a plain string with no argument context and logs the whole thing verbatim — any PII already baked in is exported to telemetry. - -## Best Practice - -Pass the template and substitution arguments directly to Error. Declare the template as a Label with a Comment describing each placeholder. Do not flag direct Error substitution merely because an argument may contain a customer name, email address, or phone number; the platform intercepts those arguments before telemetry. - -See sample: `strsubstno-prebuild-breaks-error-telemetry-classification.good.al`. - -## Anti Pattern - -Assigning the output of StrSubstNo to a Text variable and passing that variable to Error. Every substituted value is now part of an opaque string; the platform cannot classify it and logs everything. - -See sample: `strsubstno-prebuild-breaks-error-telemetry-classification.bad.al`. diff --git a/microsoft/knowledge/privacy/table-level-data-classification-cascades.good.al b/microsoft/knowledge/privacy/table-level-data-classification-cascades.good.al new file mode 100644 index 0000000..e1e5808 --- /dev/null +++ b/microsoft/knowledge/privacy/table-level-data-classification-cascades.good.al @@ -0,0 +1,16 @@ +table 50202 "System Configuration Log" +{ + DataClassification = SystemMetadata; + + fields + { + field(1; "Entry No."; Integer) { } + field(2; "Changed By"; Code[50]) { } + field(3; "Change Description"; Text[250]) { } + } + + keys + { + key(PK; "Entry No.") { Clustered = true; } + } +} diff --git a/microsoft/knowledge/privacy/table-level-data-classification-cascades.md b/microsoft/knowledge/privacy/table-level-data-classification-cascades.md new file mode 100644 index 0000000..bf457e9 --- /dev/null +++ b/microsoft/knowledge/privacy/table-level-data-classification-cascades.md @@ -0,0 +1,24 @@ +--- +bc-version: [all] +domain: privacy +keywords: [data-classification, table-level, inheritance, override, cascading] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Table-level DataClassification cascades to every field unless overridden + +## Description + +`DataClassification` may be set at the table level. When it is, every field in the table inherits that classification and individual fields do not need their own `DataClassification` property. The cascade is the platform's intended way of classifying tables whose fields are homogeneous — for example, a system configuration log whose every column is `SystemMetadata`. A field only needs its own classification when its content genuinely differs from the table's default and the inherited value would be wrong. + +## Best Practice + +Set `DataClassification` once at the table level whenever every field in the table shares the same classification. Omit field-level `DataClassification` properties in that case. Override only on the specific fields whose data class differs from the table's — for example, a `SystemMetadata` audit table that nonetheless captures a `CustomerContent` value somewhere. + +See sample: `table-level-data-classification-cascades.good.al`. + +## Anti Pattern + +Flagging individual fields for "missing `DataClassification`" when the table declares one — the inheritance is the correct, intentional pattern. The mirror anti-pattern is repeating the same `DataClassification` on every field of a table that already declares it at the table level; the property is redundant and adds nothing the platform did not already know. diff --git a/microsoft/knowledge/security/al-has-no-built-in-htmlencode.bad.al b/microsoft/knowledge/security/al-has-no-built-in-htmlencode.bad.al new file mode 100644 index 0000000..ab698a1 --- /dev/null +++ b/microsoft/knowledge/security/al-has-no-built-in-htmlencode.bad.al @@ -0,0 +1,7 @@ +codeunit 50227 "Sec Sample HtmlEncode Bad" +{ + procedure BuildWelcomeHtml(UserName: Text): Text + begin + exit('
Welcome ' + UserName + '!
'); + end; +} diff --git a/microsoft/knowledge/security/al-has-no-built-in-htmlencode.good.al b/microsoft/knowledge/security/al-has-no-built-in-htmlencode.good.al new file mode 100644 index 0000000..6da1911 --- /dev/null +++ b/microsoft/knowledge/security/al-has-no-built-in-htmlencode.good.al @@ -0,0 +1,19 @@ +codeunit 50226 "Sec Sample HtmlEncode Good" +{ + procedure BuildWelcomeHtml(UserName: Text): Text + var + SafeName: Text; + begin + SafeName := EncodeHtml(UserName); + exit('
Welcome ' + SafeName + '!
'); + end; + + local procedure EncodeHtml(Value: Text): Text + begin + Value := Value.Replace('&', '&'); + Value := Value.Replace('<', '<'); + Value := Value.Replace('>', '>'); + Value := Value.Replace('"', '"'); + exit(Value); + end; +} diff --git a/microsoft/knowledge/security/al-has-no-built-in-htmlencode.md b/microsoft/knowledge/security/al-has-no-built-in-htmlencode.md new file mode 100644 index 0000000..7441712 --- /dev/null +++ b/microsoft/knowledge/security/al-has-no-built-in-htmlencode.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: security +keywords: [html, xss, encoding, htmlencode, injection, email] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# AL has no built-in HtmlEncode — encode HTML output by hand or avoid it + +## Description + +AL does not ship a built-in `HtmlEncode` (or equivalent) function. Code that builds an HTML fragment — an email body, a report header, a chart label rendered as HTML — by concatenating record-field values into a string is therefore unencoded by default, and any `<`, `>`, `&`, or `"` in the user content is interpreted as markup by the receiving renderer. The result is cross-site scripting in the recipient's mail client, browser, or report viewer. The absence of a built-in encoder is non-obvious to anyone used to platforms where `HtmlEncode` is a one-liner. + +## Best Practice + +Replace the four characters by hand before concatenating user content into HTML: `&` → `&` first, then `<` → `<`, `>` → `>`, `"` → `"`. Centralize the substitution in one helper so every HTML producer in the extension uses the same encoder. Better still, do not build raw HTML at all — use a structured format (JSON for an API payload, a report layout for a printed document) and let the renderer do the encoding. See sample: `al-has-no-built-in-htmlencode.good.al`. + +## Anti Pattern + +`HtmlContent := '
Welcome ' + UserName + '!
'` — any record-field value or user input concatenated directly into an HTML string. Reviewers should flag any string concatenation whose right-hand operand is a field, a parameter, or any non-literal value, and whose surrounding context contains HTML tags (`<`, ` Contributions welcome — open a PR to refine or extend this article. - -## Description - -SecretStrSubstNo is the SecretText analogue of StrSubstNo. The template is a regular string literal; substitution arguments may be SecretText; the return value is SecretText. Intermediate results of the composition are never materialized as plaintext. - -## Best Practice - -Format SecretText templates with SecretStrSubstNo. This is the correct primitive for building authorization headers, secret URIs, and any other formatted string that embeds a SecretText. Provide the static parts of the template as a regular string literal; only the substitutions carry the secret value. - -See sample: `compose-secrets-with-secretstrsubstno.good.al`. - -## Anti Pattern - -Using StrSubstNo (or plain string concatenation) on a plain-Text token to build an authorization header. The result is a Text containing the secret in plaintext, visible in the debugger, inspectable in snapshot debug sessions, and captured by any logging the caller does not control. SecretText should have been used end-to-end. - -See sample: `compose-secrets-with-secretstrsubstno.bad.al`. - diff --git a/microsoft/knowledge/security/do-not-disable-validatetablerelation-on-user-input.bad.al b/microsoft/knowledge/security/do-not-disable-validatetablerelation-on-user-input.bad.al deleted file mode 100644 index 7659ea9..0000000 --- a/microsoft/knowledge/security/do-not-disable-validatetablerelation-on-user-input.bad.al +++ /dev/null @@ -1,16 +0,0 @@ -tableextension 51303 "Sec Sample VTR Bad" extends "Sales Header" -{ - fields - { - // Editable user input with validation suppressed and no fallback check. - // The user can type any string; downstream Get against Customer will fail - // or return a wrong row. - field(50102; "Customer No."; Code[20]) - { - Caption = 'Customer no.'; - DataClassification = CustomerContent; - TableRelation = Customer."No."; - ValidateTableRelation = false; - } - } -} diff --git a/microsoft/knowledge/security/do-not-disable-validatetablerelation-on-user-input.good.al b/microsoft/knowledge/security/do-not-disable-validatetablerelation-on-user-input.good.al deleted file mode 100644 index 9b76762..0000000 --- a/microsoft/knowledge/security/do-not-disable-validatetablerelation-on-user-input.good.al +++ /dev/null @@ -1,24 +0,0 @@ -tableextension 51302 "Sec Sample VTR Good" extends "Sales Header" -{ - fields - { - // User-editable field keeps ValidateTableRelation default (true). - field(50100; "External Customer Ref"; Code[50]) - { - Caption = 'External customer reference'; - DataClassification = CustomerContent; - TableRelation = Customer."No."; - } - - // System-controlled field: validation bypass is acceptable because - // the value is populated by controlled upstream code, not the user. - field(50101; "System Batch Id"; Code[20]) - { - Caption = 'System batch ID'; - DataClassification = SystemMetadata; - TableRelation = "Job Queue Entry".ID; - ValidateTableRelation = false; - Editable = false; - } - } -} diff --git a/microsoft/knowledge/security/do-not-disable-validatetablerelation-on-user-input.md b/microsoft/knowledge/security/do-not-disable-validatetablerelation-on-user-input.md deleted file mode 100644 index 0cec0fd..0000000 --- a/microsoft/knowledge/security/do-not-disable-validatetablerelation-on-user-input.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: security -keywords: [validatetablerelation, user-input, lookup, integrity, validation] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Do not set ValidateTableRelation = false on fields that accept user input - -## Description - -`TableRelation` on a field tells the platform that the value must exist as a primary key in the related table. `ValidateTableRelation = false` suppresses that check at validation time. On system-populated fields — values the code sets from a controlled source and never displays as editable — the suppression is acceptable because the integrity guarantee comes from the upstream writer. On a field the user types into (a page field, an import column, an API payload), disabling the validation means any value at all can be written: a non-existent customer number, a typo, a deliberate bad value. The table no longer enforces the relation, and downstream code that Gets the related row with an unguarded lookup breaks. - -## Best Practice - -Leave `ValidateTableRelation = true` (the default) on any field the user can set. When the default would produce unhelpful behaviour — a transient lookup that does not yet exist at validation time, a reference that uses a non-primary-key column — handle it with a targeted OnValidate trigger that performs the semantic check explicitly. Use `ValidateTableRelation = false` only when the field is genuinely system-controlled and the writer has already validated the reference. - -See sample: `do-not-disable-validatetablerelation-on-user-input.good.al`. - -## Anti Pattern - -A `Customer No.` field on an editable page with `TableRelation = Customer."No."` and `ValidateTableRelation = false` and no OnValidate fallback. The user can type any string; the platform accepts it; a later Get against Customer fails or returns the wrong row. - -See sample: `do-not-disable-validatetablerelation-on-user-input.bad.al`. diff --git a/microsoft/knowledge/security/do-not-expose-sensitive-data-in-event-publishers.bad.al b/microsoft/knowledge/security/do-not-expose-sensitive-data-in-event-publishers.bad.al deleted file mode 100644 index c111d18..0000000 --- a/microsoft/knowledge/security/do-not-expose-sensitive-data-in-event-publishers.bad.al +++ /dev/null @@ -1,19 +0,0 @@ -codeunit 50229 "Sec Sample EventPublisher Bad" -{ - [IntegrationEvent(false, false)] - local procedure OnBeforeExportCustomer(CustomerNo: Code[20]; ExportCredentials: SecretText; var AllowExport: Boolean) - begin - end; - - procedure ExportCustomer(CustomerNo: Code[20]; Credentials: SecretText) - var - AllowExport: Boolean; - begin - // Any subscriber on the tenant receives the credentials and - // can flip AllowExport := true to bypass the publisher's check. - OnBeforeExportCustomer(CustomerNo, Credentials, AllowExport); - if not AllowExport then - exit; - // ... perform export - end; -} diff --git a/microsoft/knowledge/security/do-not-expose-sensitive-data-in-event-publishers.good.al b/microsoft/knowledge/security/do-not-expose-sensitive-data-in-event-publishers.good.al deleted file mode 100644 index 8d7962c..0000000 --- a/microsoft/knowledge/security/do-not-expose-sensitive-data-in-event-publishers.good.al +++ /dev/null @@ -1,23 +0,0 @@ -codeunit 50228 "Sec Sample EventPublisher Good" -{ - [IntegrationEvent(false, false)] - local procedure OnBeforeExportCustomer(CustomerNo: Code[20]) - begin - end; - - procedure ExportCustomer(CustomerNo: Code[20]) - begin - if not CallerIsAuthorizedToExport(CustomerNo) then - Error('You are not authorized to export this customer.'); - - OnBeforeExportCustomer(CustomerNo); - // ... perform export using credentials owned by this codeunit - end; - - local procedure CallerIsAuthorizedToExport(CustomerNo: Code[20]): Boolean - begin - // Authorization decision stays inside the publisher. Subscribers - // receive only the customer number and cannot influence the - // decision. - end; -} diff --git a/microsoft/knowledge/security/do-not-expose-sensitive-data-in-event-publishers.md b/microsoft/knowledge/security/do-not-expose-sensitive-data-in-event-publishers.md deleted file mode 100644 index e5e3e08..0000000 --- a/microsoft/knowledge/security/do-not-expose-sensitive-data-in-event-publishers.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -bc-version: [all] -domain: security -keywords: [event, publisher, extensibility, var-parameter] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Do not expose sensitive data in event publishers - -> Contributions welcome — open a PR to refine or extend this article. - -## Description - -Events in AL are extensibility contracts. Every subscriber — third-party, internal, or installed after the fact — receives the full set of event parameters. Parameters that carry secrets, pre-authorization state, or variables the publisher relies on for access control effectively become public, and var-parameters can be mutated by a subscriber to alter publisher behaviour. - -## Best Practice - -Design event signatures to carry only the data a subscriber legitimately needs. Do not pass SecretText, credential material, or flags the publisher depends on for access control. Guard variables such as `HasAccess`, `SkipValidation`, or `CanExport` must not be `var` parameters on an OnBefore event; notify subscribers after the internal check with value parameters they cannot mutate. - -See sample: `do-not-expose-sensitive-data-in-event-publishers.good.al`. - -## Anti Pattern - -An OnBeforeElevateAccess publisher that exposes `var CanAccess: Boolean` or `var SkipValidation: Boolean` — any subscriber installed on the tenant can flip it to true and bypass the check. Or a publisher that passes a SecretText parameter it obtained internally, handing it to every subscriber. - -See sample: `do-not-expose-sensitive-data-in-event-publishers.bad.al`. - diff --git a/microsoft/knowledge/security/do-not-hardcode-environment-specific-guids.bad.al b/microsoft/knowledge/security/do-not-hardcode-environment-specific-guids.bad.al deleted file mode 100644 index 3ed2bff..0000000 --- a/microsoft/knowledge/security/do-not-hardcode-environment-specific-guids.bad.al +++ /dev/null @@ -1,14 +0,0 @@ -codeunit 51301 "Sec Sample EnvGuid Bad" -{ - procedure GetTenantId(): Text - begin - // Tenant GUID hardcoded. Extension works in one environment, fails in every other. - exit('{12345678-1234-1234-1234-123456789012}'); - end; - - procedure GetAadApplicationId(): Text - begin - // AAD application GUID hardcoded. Same problem, surfaces as an authentication error. - exit('{87654321-4321-4321-4321-210987654321}'); - end; -} diff --git a/microsoft/knowledge/security/do-not-hardcode-environment-specific-guids.good.al b/microsoft/knowledge/security/do-not-hardcode-environment-specific-guids.good.al deleted file mode 100644 index e19a771..0000000 --- a/microsoft/knowledge/security/do-not-hardcode-environment-specific-guids.good.al +++ /dev/null @@ -1,16 +0,0 @@ -codeunit 51300 "Sec Sample EnvGuid Good" -{ - procedure KnownSystemId(): Guid - begin - // Stable across tenants and versions — Base Application Id. - exit('{437dbf0e-84ff-417a-965d-ed2bb9650972}'); - end; - - procedure GetTenantId(): Text - var - EnvironmentInformation: Codeunit "Environment Information"; - begin - // Environment-specific values are retrieved at runtime. - exit(EnvironmentInformation.GetTenantId()); - end; -} diff --git a/microsoft/knowledge/security/do-not-hardcode-environment-specific-guids.md b/microsoft/knowledge/security/do-not-hardcode-environment-specific-guids.md deleted file mode 100644 index 7fb3775..0000000 --- a/microsoft/knowledge/security/do-not-hardcode-environment-specific-guids.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: security -keywords: [guid, tenant-id, aad, environment, hardcoded] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Hardcoded GUIDs are only safe for well-known system identifiers - -## Description - -AL code sometimes carries hardcoded GUIDs. Some are platform-defined, stable across tenants and versions, and legitimately constant — the Base Application's ApplicationId (`{437dbf0e-84ff-417a-965d-ed2bb9650972}`) is the canonical example. Others identify a specific tenant, a specific Azure Active Directory application, or a specific environment; these look identical at the source-code level but are environment-bound and break the moment the extension is deployed anywhere else. Shipping an environment-specific GUID as a constant effectively locks the extension to one environment, and the failure mode in other tenants is usually an authentication error with no code-level signal pointing at the literal. - -## Best Practice - -Hardcoded GUIDs are acceptable for well-known system identifiers that are stable across environments — document the identifier with a comment that names what it refers to. For tenant IDs, AAD application IDs, API subscription IDs, and any value that varies by deployment, retrieve at runtime from IsolatedStorage, configuration tables, or the platform APIs that expose the current tenant context. - -See sample: `do-not-hardcode-environment-specific-guids.good.al`. - -## Anti Pattern - -`TenantId := '{12345678-1234-1234-1234-123456789012}';` or `AadApplicationId := '{87654321-...}';` inline in a codeunit. The extension authenticates in one environment and fails in every other; debugging starts from an AAD error message that does not mention the literal. - -See sample: `do-not-hardcode-environment-specific-guids.bad.al`. diff --git a/microsoft/knowledge/security/follow-least-privilege-in-permission-sets.bad.al b/microsoft/knowledge/security/follow-least-privilege-in-permission-sets.bad.al deleted file mode 100644 index 3cdde58..0000000 --- a/microsoft/knowledge/security/follow-least-privilege-in-permission-sets.bad.al +++ /dev/null @@ -1,6 +0,0 @@ -permissionset 50201 "Sec Sample Full Access" -{ - Assignable = true; - Caption = 'Full Access (sample anti-pattern)'; - Permissions = tabledata * = RIMD; -} diff --git a/microsoft/knowledge/security/follow-least-privilege-in-permission-sets.good.al b/microsoft/knowledge/security/follow-least-privilege-in-permission-sets.good.al deleted file mode 100644 index d7ad6bd..0000000 --- a/microsoft/knowledge/security/follow-least-privilege-in-permission-sets.good.al +++ /dev/null @@ -1,9 +0,0 @@ -permissionset 50200 "Sec Sample Sales Order Entry" -{ - Assignable = true; - Caption = 'Sales Order Entry (sample)'; - Permissions = - tabledata "Sales Header" = RIM, - tabledata "Sales Line" = RIMD, - tabledata Customer = R; -} diff --git a/microsoft/knowledge/security/follow-least-privilege-in-permission-sets.md b/microsoft/knowledge/security/follow-least-privilege-in-permission-sets.md deleted file mode 100644 index 444ceed..0000000 --- a/microsoft/knowledge/security/follow-least-privilege-in-permission-sets.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -bc-version: [all] -domain: security -keywords: [permissionset, least-privilege, rimd, tabledata] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Follow least privilege in permission sets - -> Contributions welcome — open a PR to refine or extend this article. - -## Description - -Permission sets define the tabledata and object rights granted to every user or role assigned to them. A permission set that grants RIMD on tabledata * hands every caller full control over every table the extension exposes, which is never the shape of access any real role requires. Over-broad permission sets are a persistent source of privilege-escalation risk: once assigned, they are rarely audited. - -## Best Practice - -Enumerate the specific tabledata objects a role needs and grant only the letters (R, I, M, D) that role genuinely uses. A sales order-entry role typically needs RIM on Sales Header, RIMD on Sales Line, and R on Customer — not blanket RIMD. Permission sets SHOULD be granular and role-shaped; a single permission set that covers every role in an extension is a design smell. - -See sample: `follow-least-privilege-in-permission-sets.good.al`. - -## Anti Pattern - -Granting `tabledata * = RIMD` (or any wildcard with I, M, or D) in a permission set. This bypasses any meaningful separation of duties the extension could enforce and gives unreviewed code paths the ability to insert, modify, and delete on any table. - -See sample: `follow-least-privilege-in-permission-sets.bad.al`. - diff --git a/microsoft/knowledge/security/getlasterrortext-storage-is-privacy-not-security.bad.al b/microsoft/knowledge/security/getlasterrortext-storage-is-privacy-not-security.bad.al new file mode 100644 index 0000000..8a8c025 --- /dev/null +++ b/microsoft/knowledge/security/getlasterrortext-storage-is-privacy-not-security.bad.al @@ -0,0 +1,10 @@ +codeunit 50234 "Sec Sample LastErrText" +{ + procedure RunWithCapture(var ErrorLog: Record "Integration Log") + begin + if not Codeunit.Run(Codeunit::"My Worker") then begin + ErrorLog."Error Text" := CopyStr(GetLastErrorText(), 1, MaxStrLen(ErrorLog."Error Text")); + ErrorLog.Insert(true); + end; + end; +} diff --git a/microsoft/knowledge/security/getlasterrortext-storage-is-privacy-not-security.md b/microsoft/knowledge/security/getlasterrortext-storage-is-privacy-not-security.md new file mode 100644 index 0000000..4e9da1d --- /dev/null +++ b/microsoft/knowledge/security/getlasterrortext-storage-is-privacy-not-security.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: security +keywords: [getlasterrortext, error-text, classification, privacy, review-scope] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Storing GetLastErrorText() in table fields is a privacy finding, not a security finding + +## Description + +It is tempting to flag any code that calls `GetLastErrorText()` and writes the result into a table field (or displays it to end users) as a security issue, on the assumption that the error text might leak credentials or system internals. In Business Central, that pattern is treated as a **privacy** concern instead: AL `Error` text frequently contains customer content (record keys, field values, document numbers) rather than infrastructure details, and the appropriate review owner is the privacy/DataClassification reviewer. A security reviewer should not raise a finding for `GetLastErrorText()` storage on the grounds that it might expose secrets; that risk is covered elsewhere by the rules that prevent secrets from appearing in error messages in the first place (see `secrettext-for-credentials.md`). + +## Best Practice + +When auditing AL changes for security, ignore patterns where `GetLastErrorText()` is captured into a table or shown to users — leave those to the privacy review. Security findings on error text should be limited to the construction of the `Error()` call itself: secrets, paths, or technical internals being interpolated into the error before it is raised. See sample: `getlasterrortext-storage-is-privacy-not-security.bad.al` for the pattern that is *not* a security finding. + +## Anti Pattern + +Filing a security finding such as "GetLastErrorText() stored in field — potential information disclosure" against AL code that captures an error for later inspection. The finding is in the wrong domain and crowds out the actual security signal. The mirror anti-pattern is silencing genuine `Error('... %1 ...', SecretValue)` constructions on the grounds that "error text is privacy" — those *are* security findings because they create the leak, regardless of where the text ends up afterwards. diff --git a/microsoft/knowledge/security/indirect-permissions-for-elevated-access.bad.al b/microsoft/knowledge/security/indirect-permissions-for-elevated-access.bad.al new file mode 100644 index 0000000..37fe2a6 --- /dev/null +++ b/microsoft/knowledge/security/indirect-permissions-for-elevated-access.bad.al @@ -0,0 +1,4 @@ +permissionset 50204 "Sec Sample Report Runner Bad" +{ + Permissions = tabledata "G/L Entry" = RIMD; +} diff --git a/microsoft/knowledge/security/indirect-permissions-for-elevated-access.good.al b/microsoft/knowledge/security/indirect-permissions-for-elevated-access.good.al new file mode 100644 index 0000000..9fef5e4 --- /dev/null +++ b/microsoft/knowledge/security/indirect-permissions-for-elevated-access.good.al @@ -0,0 +1,4 @@ +permissionset 50203 "Sec Sample Report Runner" +{ + Permissions = tabledata "G/L Entry" = ri; +} diff --git a/microsoft/knowledge/security/indirect-permissions-for-elevated-access.md b/microsoft/knowledge/security/indirect-permissions-for-elevated-access.md new file mode 100644 index 0000000..206d211 --- /dev/null +++ b/microsoft/knowledge/security/indirect-permissions-for-elevated-access.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: security +keywords: [permissionset, indirect-permissions, ri, ii, mi, di, code-mediated] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use indirect permissions when access must be code-mediated + +## Description + +In a `permissionset`, uppercase letters (`R`, `I`, `M`, `D`) grant **direct** permissions: the assignee can read, insert, modify, or delete the table data through any UI or API surface. Lowercase letters (`r`, `i`, `m`, `d`) grant **indirect** permissions: the operation is allowed only when it is invoked from AL code that itself holds the corresponding direct permission. Indirect permissions let a role consume privileged tables through controlled procedures (a report, a posting routine) without giving users a way to read or change those tables outside the intended code path. + +## Best Practice + +Use indirect permissions (`ri`, `ii`, `mi`, `di`) when a role needs access to a sensitive table only through a specific codeunit or report — for example, a "Report Runner" role that reads `G/L Entry` only via published reports. Pair the indirect grant with the codeunit or report that mediates access; that object's own permissions (or InherentPermissions) supply the direct rights. Document why indirect permissions are required in the permission set or in the consuming object's comments. See sample: `indirect-permissions-for-elevated-access.good.al`. + +## Anti Pattern + +Granting `RIMD` on a sensitive table when the role only needs to view it through a report — for example `tabledata "G/L Entry" = RIMD` on a "Report Runner" role. Users assigned that role can now query and modify ledger entries directly through any client that respects the permission, bypassing the report entirely. Reviewers should look for uppercase grants on system-of-record tables (G/L Entry, ledger entries, posted documents) where the consuming code path is clearly read-through-report or read-through-API. See sample: `indirect-permissions-for-elevated-access.bad.al`. diff --git a/microsoft/knowledge/security/inherent-permissions-minimal-grant.bad.al b/microsoft/knowledge/security/inherent-permissions-minimal-grant.bad.al new file mode 100644 index 0000000..a75b336 --- /dev/null +++ b/microsoft/knowledge/security/inherent-permissions-minimal-grant.bad.al @@ -0,0 +1,20 @@ +codeunit 50206 "Sec Sample Inherent Bad" +{ + [InherentPermissions(PermissionObjectType::TableData, Database::"Sales Header", 'RIMD')] + [InherentEntitlements(Entitlement::"Dynamics 365 Business Central Premium")] + procedure GetCustomerName(CustomerNo: Code[20]): Text + var + Customer: Record Customer; + begin + if Customer.Get(CustomerNo) then + exit(Customer.Name); + end; + + [InherentEntitlements(Entitlement::"Dynamics 365 Business Central Premium")] + procedure CheckItemExists(ItemNo: Code[20]): Boolean + var + Item: Record Item; + begin + exit(Item.Get(ItemNo)); + end; +} diff --git a/microsoft/knowledge/security/inherent-permissions-minimal-grant.good.al b/microsoft/knowledge/security/inherent-permissions-minimal-grant.good.al new file mode 100644 index 0000000..e6d903e --- /dev/null +++ b/microsoft/knowledge/security/inherent-permissions-minimal-grant.good.al @@ -0,0 +1,19 @@ +codeunit 50205 "Sec Sample Inherent Good" +{ + [InherentPermissions(PermissionObjectType::TableData, Database::Customer, 'r')] + procedure GetCustomerName(CustomerNo: Code[20]): Text + var + Customer: Record Customer; + begin + if Customer.Get(CustomerNo) then + exit(Customer.Name); + end; + + [InherentPermissions(PermissionObjectType::TableData, Database::Item, 'r')] + procedure CheckItemExists(ItemNo: Code[20]): Boolean + var + Item: Record Item; + begin + exit(Item.Get(ItemNo)); + end; +} diff --git a/microsoft/knowledge/security/inherent-permissions-minimal-grant.md b/microsoft/knowledge/security/inherent-permissions-minimal-grant.md new file mode 100644 index 0000000..41ba2a5 --- /dev/null +++ b/microsoft/knowledge/security/inherent-permissions-minimal-grant.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: security +keywords: [inherentpermissions, inherententitlements, attribute, least-privilege, procedure] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Grant the minimum InherentPermissions a procedure needs + +## Description + +`[InherentPermissions(PermissionObjectType::..., ...)]` and `[InherentEntitlements(Entitlement::...)]` are method-level attributes that let a procedure perform an operation on the listed object even when the caller's permission set does not allow it. They effectively elevate the caller for the duration of the procedure. The grant therefore needs to be as narrow as the procedure's actual work — both in object scope (the specific table) and in operation (`'r'` versus `'RIMD'`). Overly broad inherent permissions silently expand the attack surface of every codeunit that calls the procedure. + +## Best Practice + +Match the inherent permission to the procedure's body: a procedure that only reads `Customer.Name` declares `[InherentPermissions(PermissionObjectType::TableData, Database::Customer, 'r')]`, not `'RIMD'`. Pick the inherent entitlement that matches the lowest tier the procedure should run under — do not require Premium for a procedure that performs an Essential-tier check. See sample: `inherent-permissions-minimal-grant.good.al`. + +## Anti Pattern + +Declaring `[InherentPermissions(..., 'RIMD')]` on a read-only procedure (`GetCustomerName`), or `[InherentEntitlements(Entitlement::"Dynamics 365 Business Central Premium")]` on a procedure that performs a simple existence check. Reviewers should compare the attribute's permission letters against what the procedure body actually does and flag any grant broader than the operations performed. See sample: `inherent-permissions-minimal-grant.bad.al`. diff --git a/microsoft/knowledge/security/integrationevent-must-not-expose-secrets.bad.al b/microsoft/knowledge/security/integrationevent-must-not-expose-secrets.bad.al new file mode 100644 index 0000000..79da066 --- /dev/null +++ b/microsoft/knowledge/security/integrationevent-must-not-expose-secrets.bad.al @@ -0,0 +1,7 @@ +codeunit 50229 "Sec Sample EventSecret Bad" +{ + [IntegrationEvent(false, false)] + local procedure OnBeforeSendRequest(var ApiKey: Text; var Password: Text; var RequestUrl: Text) + begin + end; +} diff --git a/microsoft/knowledge/security/integrationevent-must-not-expose-secrets.good.al b/microsoft/knowledge/security/integrationevent-must-not-expose-secrets.good.al new file mode 100644 index 0000000..b30372b --- /dev/null +++ b/microsoft/knowledge/security/integrationevent-must-not-expose-secrets.good.al @@ -0,0 +1,7 @@ +codeunit 50228 "Sec Sample EventSecret Good" +{ + [IntegrationEvent(false, false)] + local procedure OnBeforeSendRequest(var RequestPayload: JsonObject; var IsHandled: Boolean) + begin + end; +} diff --git a/microsoft/knowledge/security/integrationevent-must-not-expose-secrets.md b/microsoft/knowledge/security/integrationevent-must-not-expose-secrets.md new file mode 100644 index 0000000..1c51f8b --- /dev/null +++ b/microsoft/knowledge/security/integrationevent-must-not-expose-secrets.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: security +keywords: [integrationevent, eventsubscriber, secrets, credentials, publisher] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Do not pass credentials or secrets through IntegrationEvent parameters + +## Description + +`[IntegrationEvent]` publishes a hook that any extension can subscribe to. Every parameter of the event signature is visible to every subscriber — including `var` parameters, which subscribers can both read and modify. A publisher that includes an API key, password, bearer token, or other secret in the event signature hands that secret to every subscriber on the tenant, including subscribers in extensions the publisher has no relationship with. There is no permission or partner-only filter that limits who may subscribe. + +## Best Practice + +Restrict event payloads to the non-sensitive context a subscriber legitimately needs: the business record being processed (a `Customer`), the operation being performed, an `IsHandled` flag that lets a subscriber skip the default behaviour, and a mutable payload object whose contents the publisher controls. Authentication is handled by the publisher before or after the event, never inside the parameters. See sample: `integrationevent-must-not-expose-secrets.good.al`. + +## Anti Pattern + +`[IntegrationEvent(false, false)] procedure OnBeforeSendRequest(var ApiKey: Text; var Password: Text; var RequestUrl: Text)` — any extension on the tenant can subscribe, read `ApiKey` and `Password`, and persist them elsewhere. Reviewers should flag any event parameter whose name or type suggests a secret (`ApiKey`, `Token`, `Password`, `Secret`, `Credential`, `SecretText` — even `SecretText` should not flow through an event surface). See sample: `integrationevent-must-not-expose-secrets.bad.al`. diff --git a/microsoft/knowledge/security/integrationevent-var-parameter-bypasses-security-guards.bad.al b/microsoft/knowledge/security/integrationevent-var-parameter-bypasses-security-guards.bad.al new file mode 100644 index 0000000..ee1b8cf --- /dev/null +++ b/microsoft/knowledge/security/integrationevent-var-parameter-bypasses-security-guards.bad.al @@ -0,0 +1,19 @@ +codeunit 50231 "Sec Sample EventGuard Bad" +{ + procedure CheckPermissionsForTable(TableNo: Integer) + var + HasAccess: Boolean; + SkipValidation: Boolean; + begin + OnBeforeCheckPermissions(HasAccess, SkipValidation, TableNo); + if SkipValidation then + exit; + if not HasAccess then + Error('Access denied.'); + end; + + [IntegrationEvent(false, false)] + local procedure OnBeforeCheckPermissions(var HasAccess: Boolean; var SkipValidation: Boolean; TableNo: Integer) + begin + end; +} diff --git a/microsoft/knowledge/security/integrationevent-var-parameter-bypasses-security-guards.good.al b/microsoft/knowledge/security/integrationevent-var-parameter-bypasses-security-guards.good.al new file mode 100644 index 0000000..9d17d9e --- /dev/null +++ b/microsoft/knowledge/security/integrationevent-var-parameter-bypasses-security-guards.good.al @@ -0,0 +1,22 @@ +codeunit 50230 "Sec Sample EventGuard Good" +{ + procedure CheckPermissionsForTable(TableNo: Integer) + var + HasAccess: Boolean; + begin + HasAccess := PerformInternalCheck(TableNo); + if not HasAccess then + Error('Access denied.'); + OnAfterCheckPermissions(TableNo, HasAccess); + end; + + local procedure PerformInternalCheck(TableNo: Integer): Boolean + begin + exit(true); + end; + + [IntegrationEvent(false, false)] + local procedure OnAfterCheckPermissions(TableNo: Integer; HasAccess: Boolean) + begin + end; +} diff --git a/microsoft/knowledge/security/integrationevent-var-parameter-bypasses-security-guards.md b/microsoft/knowledge/security/integrationevent-var-parameter-bypasses-security-guards.md new file mode 100644 index 0000000..3429e80 --- /dev/null +++ b/microsoft/knowledge/security/integrationevent-var-parameter-bypasses-security-guards.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: security +keywords: [integrationevent, var, guard, ishandled, bypass, security-check] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Do not expose security guards as `var` parameters on IntegrationEvent + +## Description + +A `var` parameter on an `[IntegrationEvent]` is a mutable hook: any subscriber can overwrite the value and the publisher will see the new value when control returns. That is the right shape for "let an extension contribute to a payload"; it is the wrong shape for "let an extension confirm a security decision". A `var HasAccess: Boolean` or `var SkipValidation: Boolean` lets any subscriber on the tenant flip the result of the publisher's permission check to `true` (or set "skip" to `true`) before the publisher reads it. The publisher's check becomes advisory, which is the same as not having a check. + +## Best Practice + +Keep the security decision inside the publisher, where it is not bypassable. Fire an `OnAfter*` informational event after the check completes, with the result passed by value (not `var`) so subscribers can react — log, audit, surface a warning — but cannot rewrite the outcome. When subscribers legitimately need to add their own checks, expose an `OnAfterCheckPermissions(...)` that can only tighten access (e.g., a subscriber can `Error()`), never loosen it. See sample: `integrationevent-var-parameter-bypasses-security-guards.good.al`. + +## Anti Pattern + +`OnBeforeCheckPermissions(var HasAccess: Boolean; var SkipValidation: Boolean; TableNo: Integer)`, followed in the caller by `if SkipValidation then exit;`. Any subscriber sets `SkipValidation := true` and the check is gone. Reviewers should flag any `IntegrationEvent` whose signature contains a `var Boolean` whose name reads like a security decision (`HasAccess`, `IsAllowed`, `SkipValidation`, `BypassCheck`, `IsAuthorized`). See sample: `integrationevent-var-parameter-bypasses-security-guards.bad.al`. diff --git a/microsoft/knowledge/security/isolatedstorage-access-must-be-local-or-internal.bad.al b/microsoft/knowledge/security/isolatedstorage-access-must-be-local-or-internal.bad.al new file mode 100644 index 0000000..84297bb --- /dev/null +++ b/microsoft/knowledge/security/isolatedstorage-access-must-be-local-or-internal.bad.al @@ -0,0 +1,16 @@ +codeunit 50216 "Sec Sample IsoStorage Bad" +{ + procedure GetApiKey(): Text + var + ApiKey: Text; + begin + if IsolatedStorage.Contains('ApiKey', DataScope::Module) then + IsolatedStorage.Get('ApiKey', DataScope::Module, ApiKey); + exit(ApiKey); + end; + + procedure SetApiKey(NewKey: Text) + begin + IsolatedStorage.SetEncrypted('ApiKey', NewKey, DataScope::Module); + end; +} diff --git a/microsoft/knowledge/security/isolatedstorage-access-must-be-local-or-internal.good.al b/microsoft/knowledge/security/isolatedstorage-access-must-be-local-or-internal.good.al new file mode 100644 index 0000000..22e279b --- /dev/null +++ b/microsoft/knowledge/security/isolatedstorage-access-must-be-local-or-internal.good.al @@ -0,0 +1,15 @@ +codeunit 50215 "Sec Sample IsoStorage Good" +{ + local procedure GetApiKey(var ApiKey: SecretText): Boolean + begin + if not IsolatedStorage.Contains('ApiKey', DataScope::Module) then + exit(false); + IsolatedStorage.Get('ApiKey', DataScope::Module, ApiKey); + exit(true); + end; + + internal procedure SetApiKey(NewKey: Text) + begin + IsolatedStorage.SetEncrypted('ApiKey', NewKey, DataScope::Module); + end; +} diff --git a/microsoft/knowledge/security/isolatedstorage-access-must-be-local-or-internal.md b/microsoft/knowledge/security/isolatedstorage-access-must-be-local-or-internal.md new file mode 100644 index 0000000..cbf5d5d --- /dev/null +++ b/microsoft/knowledge/security/isolatedstorage-access-must-be-local-or-internal.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: security +keywords: [isolatedstorage, local, internal, public, getter, setter, encapsulation] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Procedures that read or write IsolatedStorage must not be public + +## Description + +`IsolatedStorage` partitions its data by extension: values written by one extension are unreadable to another. That guarantee assumes the owning extension does not voluntarily expose its storage through a public API. A `public` procedure on a codeunit that calls `IsolatedStorage.Get`, `IsolatedStorage.Set`, `IsolatedStorage.SetEncrypted`, `IsolatedStorage.Contains`, or `IsolatedStorage.Delete` defeats the isolation: any other extension on the same tenant can call that procedure and obtain (or overwrite) the secret. The platform's per-extension boundary becomes a per-procedure boundary, and there is no per-procedure boundary. + +## Best Practice + +Mark every procedure that touches `IsolatedStorage` as `local` (visible only inside its containing object) or `internal` (visible only inside the owning extension). Provide consumers with a narrow, intent-specific API — for example, "send notification to configured webhook" rather than "give me the webhook secret." See sample: `isolatedstorage-access-must-be-local-or-internal.good.al`. + +## Anti Pattern + +A public `GetApiKey()` returning the stored value, or a public `SetApiKey(NewKey: Text)` that calls `IsolatedStorage.SetEncrypted`. Both turn the extension into a confused deputy that hands out (or accepts overwrites of) its own secrets on behalf of any caller on the tenant. Reviewers should flag any procedure whose body references `IsolatedStorage` and whose declaration omits `local` or `internal`. See sample: `isolatedstorage-access-must-be-local-or-internal.bad.al`. diff --git a/microsoft/knowledge/security/isolatedstorage-datascope-module-vs-company.bad.al b/microsoft/knowledge/security/isolatedstorage-datascope-module-vs-company.bad.al new file mode 100644 index 0000000..60efe31 --- /dev/null +++ b/microsoft/knowledge/security/isolatedstorage-datascope-module-vs-company.bad.al @@ -0,0 +1,15 @@ +codeunit 50220 "Sec Sample DataScope Bad" +{ + internal procedure StoreCompanyWebhook(WebhookUrl: Text) + begin + IsolatedStorage.SetEncrypted('WebhookUrl', WebhookUrl, DataScope::Module); + end; + + local procedure ReadCompanyWebhook(var WebhookUrl: SecretText): Boolean + begin + if not IsolatedStorage.Contains('WebhookUrl', DataScope::Company) then + exit(false); + IsolatedStorage.Get('WebhookUrl', DataScope::Company, WebhookUrl); + exit(true); + end; +} diff --git a/microsoft/knowledge/security/isolatedstorage-datascope-module-vs-company.good.al b/microsoft/knowledge/security/isolatedstorage-datascope-module-vs-company.good.al new file mode 100644 index 0000000..397635f --- /dev/null +++ b/microsoft/knowledge/security/isolatedstorage-datascope-module-vs-company.good.al @@ -0,0 +1,20 @@ +codeunit 50219 "Sec Sample DataScope Good" +{ + internal procedure StoreTenantApiKey(ApiKey: Text) + begin + IsolatedStorage.SetEncrypted('TenantApiKey', ApiKey, DataScope::Module); + end; + + internal procedure StoreCompanyWebhook(WebhookUrl: Text) + begin + IsolatedStorage.SetEncrypted('WebhookUrl', WebhookUrl, DataScope::Company); + end; + + local procedure ReadCompanyWebhook(var WebhookUrl: SecretText): Boolean + begin + if not IsolatedStorage.Contains('WebhookUrl', DataScope::Company) then + exit(false); + IsolatedStorage.Get('WebhookUrl', DataScope::Company, WebhookUrl); + exit(true); + end; +} diff --git a/microsoft/knowledge/security/isolatedstorage-datascope-module-vs-company.md b/microsoft/knowledge/security/isolatedstorage-datascope-module-vs-company.md new file mode 100644 index 0000000..711895d --- /dev/null +++ b/microsoft/knowledge/security/isolatedstorage-datascope-module-vs-company.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: security +keywords: [isolatedstorage, datascope, module, company, user, scope] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Pick the right IsolatedStorage DataScope for the secret's lifetime + +## Description + +`IsolatedStorage` read and write methods take a `DataScope` parameter that decides which slice of storage the value belongs to. The choice is not a stylistic one — it changes which callers, in which company and under which user, can read the value back. Two scopes cover the common cases for app-level secrets: `DataScope::Module` stores the value once for the whole extension, isolated to that extension on the tenant — the right scope for app-specific secrets such as a global API key or service account. `DataScope::Company` stores the value per company, so each company on the tenant has its own slot — the right scope for company-specific secrets such as a per-company webhook URL or a per-company integration token. A per-user scope also exists for values that belong to an individual user. + +## Best Practice + +Choose `Module` when the secret is the same for every company and every user under the extension (a single tenant-wide API key). Choose `Company` when each company has its own integration credentials. Choose the user scope only when the secret is genuinely per-user. Use the same `DataScope` value on `Set`/`SetEncrypted`, `Get`, `Contains`, and `Delete` for the same key — mixing scopes for the same logical secret produces silent "not found" results. See sample: `isolatedstorage-datascope-module-vs-company.good.al`. + +## Anti Pattern + +Defaulting every call to `DataScope::Module` regardless of intent — storing a per-company webhook URL under `Module` means every company on the tenant shares the same URL. Or the inverse: storing a tenant-wide API key under `Company` means each company-switch effectively loses the key. Reviewers should look for cross-method inconsistency (`Set` under `Module`, `Get` under `Company`) and for scope choices that contradict the value's documented lifetime. See sample: `isolatedstorage-datascope-module-vs-company.bad.al`. diff --git a/microsoft/knowledge/security/isolatedstorage-setencrypted-for-sensitive-values.bad.al b/microsoft/knowledge/security/isolatedstorage-setencrypted-for-sensitive-values.bad.al new file mode 100644 index 0000000..dc75430 --- /dev/null +++ b/microsoft/knowledge/security/isolatedstorage-setencrypted-for-sensitive-values.bad.al @@ -0,0 +1,7 @@ +codeunit 50218 "Sec Sample SetEncrypted Bad" +{ + internal procedure StoreApiKey(ApiKeyValue: Text) + begin + IsolatedStorage.Set('ApiKey', ApiKeyValue, DataScope::Module); + end; +} diff --git a/microsoft/knowledge/security/isolatedstorage-setencrypted-for-sensitive-values.good.al b/microsoft/knowledge/security/isolatedstorage-setencrypted-for-sensitive-values.good.al new file mode 100644 index 0000000..215055c --- /dev/null +++ b/microsoft/knowledge/security/isolatedstorage-setencrypted-for-sensitive-values.good.al @@ -0,0 +1,17 @@ +codeunit 50217 "Sec Sample SetEncrypted Good" +{ + internal procedure StoreApiKey(ApiKeyValue: Text) + begin + if StrLen(ApiKeyValue) > 200 then + Error('API key too long for encrypted storage'); + IsolatedStorage.SetEncrypted('ApiKey', ApiKeyValue, DataScope::Module); + end; + + local procedure ReadApiKey(var ApiKey: SecretText): Boolean + begin + if not IsolatedStorage.Contains('ApiKey', DataScope::Module) then + exit(false); + IsolatedStorage.Get('ApiKey', DataScope::Module, ApiKey); + exit(true); + end; +} diff --git a/microsoft/knowledge/security/isolatedstorage-setencrypted-for-sensitive-values.md b/microsoft/knowledge/security/isolatedstorage-setencrypted-for-sensitive-values.md new file mode 100644 index 0000000..4e4f6b9 --- /dev/null +++ b/microsoft/knowledge/security/isolatedstorage-setencrypted-for-sensitive-values.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: security +keywords: [isolatedstorage, setencrypted, encryption, secret, storage] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Prefer IsolatedStorage.SetEncrypted over Set for sensitive values + +## Description + +`IsolatedStorage` exposes two write entry points: `Set` stores the value as-is, and `SetEncrypted` stores it encrypted at rest. Both are scoped per extension, but only `SetEncrypted` adds the additional protection that the value is not readable from the underlying storage by anything that bypasses the AL `IsolatedStorage` API. The choice between them is by intent: configuration that is not sensitive (a user preference, a default flag) can use `Set`; anything that would harm the tenant if leaked — API keys, tokens, connection strings, OAuth client secrets — uses `SetEncrypted`. + +## Best Practice + +Use `IsolatedStorage.SetEncrypted` for every value that meets the definition of a secret. Pair it with the matching retrieval pattern: `IsolatedStorage.Contains` to test for presence and `IsolatedStorage.Get` (preferably with a `SecretText` destination) to read. Constrain the input length before storing — long values can exceed the encrypted-storage size limit and the write will fail at runtime. See sample: `isolatedstorage-setencrypted-for-sensitive-values.good.al`. + +## Anti Pattern + +`IsolatedStorage.Set('ApiKey', ApiKeyValue, DataScope::Module)` — the key is now sitting in storage unencrypted, and any future incident that exposes the underlying storage exposes the key. Reviewers should flag any `IsolatedStorage.Set` whose key name or surrounding context suggests a secret (`ApiKey`, `Token`, `Password`, `Secret`, `ClientSecret`). See sample: `isolatedstorage-setencrypted-for-sensitive-values.bad.al`. diff --git a/microsoft/knowledge/security/keep-recordref-open-callers-non-public.good.al b/microsoft/knowledge/security/keep-recordref-open-callers-non-public.good.al deleted file mode 100644 index ec005d1..0000000 --- a/microsoft/knowledge/security/keep-recordref-open-callers-non-public.good.al +++ /dev/null @@ -1,12 +0,0 @@ -codeunit 50242 "Sec Sample RecordRef Good" -{ - internal procedure ArchiveRecord(RecId: RecordId) - var - RecRef: RecordRef; - begin - RecRef.Open(RecId.TableNo); - RecRef.Get(RecId); - RecRef.Delete(); - RecRef.Close(); - end; -} diff --git a/microsoft/knowledge/security/keep-recordref-open-callers-non-public.md b/microsoft/knowledge/security/keep-recordref-open-callers-non-public.md deleted file mode 100644 index 609374f..0000000 --- a/microsoft/knowledge/security/keep-recordref-open-callers-non-public.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: security -keywords: [recordref, recordid, table-no, scope, inherentpermissions] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Keep caller-driven RecordRef.Open procedures non-public - -## Description - -A codeunit can hold permissions or `InherentPermissions` that its callers do not have. If it exposes a public procedure that accepts a table number or RecordId and calls `RecordRef.Open`, another extension can call that procedure to make the privileged codeunit open tables on its behalf. That turns a generic helper into a permission-bypass surface, especially for system tables. - -## Best Practice - -Procedures that call `RecordRef.Open` with a caller-provided table number must be `local`, `internal`, or `[Scope('OnPrem')]`. If the procedure truly must be public in SaaS, validate the table number against a narrow allowlist before opening the RecordRef. - -See sample: `keep-recordref-open-callers-non-public.good.al`. - -## Anti Pattern - -A public helper such as `ArchiveRecord(RecId: RecordId)` that opens `RecId.TableNo` and then reads, modifies, or deletes through RecordRef. The helper compiles, but it lets untrusted callers choose which table the privileged code opens. - -See sample: `keep-recordref-open-callers-non-public.bad.al`. diff --git a/microsoft/knowledge/security/never-hardcode-secrets-in-al.bad.al b/microsoft/knowledge/security/never-hardcode-secrets-in-al.bad.al deleted file mode 100644 index 841a809..0000000 --- a/microsoft/knowledge/security/never-hardcode-secrets-in-al.bad.al +++ /dev/null @@ -1,10 +0,0 @@ -codeunit 50207 "Sec Sample HardcodedSecret Bad" -{ - var - HardcodedApiKeyLbl: Label 'sk-live-1234567890abcdef', Locked = true; - - procedure GetApiKey(): Text - begin - exit(HardcodedApiKeyLbl); - end; -} diff --git a/microsoft/knowledge/security/never-hardcode-secrets-in-al.good.al b/microsoft/knowledge/security/never-hardcode-secrets-in-al.good.al deleted file mode 100644 index 835bbbd..0000000 --- a/microsoft/knowledge/security/never-hardcode-secrets-in-al.good.al +++ /dev/null @@ -1,12 +0,0 @@ -codeunit 50206 "Sec Sample HardcodedSecret Good" -{ - procedure GetApiKey() ApiKey: SecretText - var - StoredValue: SecretText; - begin - if IsolatedStorage.Contains('ApiKey', DataScope::Module) then - if IsolatedStorage.Get('ApiKey', DataScope::Module, StoredValue) then - exit(StoredValue); - Error('API key is not configured.'); - end; -} diff --git a/microsoft/knowledge/security/never-hardcode-secrets-in-al.md b/microsoft/knowledge/security/never-hardcode-secrets-in-al.md deleted file mode 100644 index 6822b4a..0000000 --- a/microsoft/knowledge/security/never-hardcode-secrets-in-al.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -bc-version: [all] -domain: security -keywords: [secrets, credentials, hardcoded, label, apikey] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Never hardcode secrets in AL - -> Contributions welcome — open a PR to refine or extend this article. - -## Description - -A secret embedded in AL source — API key, password, connection string, token — lives forever: in the app package, in source control history, in every debugger session that sees the assignment, and in any log that captures the containing variable. Rotation is effectively impossible without a new release, and the blast radius covers every tenant the extension is installed in. - -## Best Practice - -Retrieve secrets at runtime from a protected store: Azure Key Vault for production workloads (see prefer-azure-key-vault-for-production-secrets) or IsolatedStorage for tenant-local encrypted values (see use-isolated-storage-for-module-and-company-secrets). Carry the retrieved value in a SecretText variable end-to-end (see use-secrettext-for-credentials). - -See sample: `never-hardcode-secrets-in-al.good.al`. - -## Anti Pattern - -Assigning a secret literal to a Text, Code, or Label variable (including labels marked as constants). The secret is now part of the compiled app and indistinguishable from non-sensitive content to callers and tools. - -See sample: `never-hardcode-secrets-in-al.bad.al`. - diff --git a/microsoft/knowledge/security/nondebuggable-required-when-unwrapping-secrettext.bad.al b/microsoft/knowledge/security/nondebuggable-required-when-unwrapping-secrettext.bad.al new file mode 100644 index 0000000..a22b60f --- /dev/null +++ b/microsoft/knowledge/security/nondebuggable-required-when-unwrapping-secrettext.bad.al @@ -0,0 +1,19 @@ +codeunit 50214 "Sec Sample NonDebug Bad" +{ + procedure BuildConnectionString(ApiKey: SecretText): Text + begin + exit('Server=db.example.com;Key=' + ApiKey.Unwrap()); + end; + + procedure ParseSessionToken(Response: HttpResponseMessage; var SessionToken: SecretText) + var + ResponseText: Text; + JsonObject: JsonObject; + JsonToken: JsonToken; + begin + Response.Content.ReadAs(ResponseText); + JsonObject.ReadFrom(ResponseText); + JsonObject.Get('access_token', JsonToken); + SessionToken := JsonToken.AsValue().AsText(); + end; +} diff --git a/microsoft/knowledge/security/nondebuggable-required-when-unwrapping-secrettext.good.al b/microsoft/knowledge/security/nondebuggable-required-when-unwrapping-secrettext.good.al new file mode 100644 index 0000000..421e9bd --- /dev/null +++ b/microsoft/knowledge/security/nondebuggable-required-when-unwrapping-secrettext.good.al @@ -0,0 +1,21 @@ +codeunit 50213 "Sec Sample NonDebug Good" +{ + [NonDebuggable] + procedure BuildConnectionString(ApiKey: SecretText): Text + begin + exit('Server=db.example.com;Key=' + ApiKey.Unwrap()); + end; + + [NonDebuggable] + procedure ParseSessionToken(Response: HttpResponseMessage; var SessionToken: SecretText) + var + ResponseText: Text; + JsonObject: JsonObject; + JsonToken: JsonToken; + begin + Response.Content.ReadAs(ResponseText); + JsonObject.ReadFrom(ResponseText); + JsonObject.Get('access_token', JsonToken); + SessionToken := JsonToken.AsValue().AsText(); + end; +} diff --git a/microsoft/knowledge/security/nondebuggable-required-when-unwrapping-secrettext.md b/microsoft/knowledge/security/nondebuggable-required-when-unwrapping-secrettext.md new file mode 100644 index 0000000..b214977 --- /dev/null +++ b/microsoft/knowledge/security/nondebuggable-required-when-unwrapping-secrettext.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: security +keywords: [nondebuggable, attribute, secrettext, unwrap, debugger] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Mark procedures that call SecretText.Unwrap() as [NonDebuggable] + +## Description + +`SecretText` transit — assignment, parameter passing, and return values — is auto-protected: the debugger sees a redacted placeholder, not the value. The protection ends the moment code calls `.Unwrap()`, which converts the `SecretText` back to plain `Text`. From that point on, the local variable holding the result is visible in the debugger like any other `Text`. The `[NonDebuggable]` attribute marks a procedure so that none of its locals or parameters are visible to the debugger during execution, which is exactly what is needed for any procedure that performs an `Unwrap()` or that otherwise materializes a secret as `Text` (for example, while parsing a JSON response to extract an access token). + +## Best Practice + +Apply `[NonDebuggable]` to any procedure whose body calls `.Unwrap()` on a `SecretText`, and to any procedure that constructs a `SecretText` from a `Text` source (such as a procedure that reads a JSON response body and converts the resulting `Text` into a `SecretText` for the caller). Keep the unwrap window as small as possible — ideally a single one-line helper that hands the unwrapped value straight to the consuming API. See sample: `nondebuggable-required-when-unwrapping-secrettext.good.al`. + +## Anti Pattern + +Calling `ApiKey.Unwrap()` inside a procedure that is not marked `[NonDebuggable]`. The unwrapped value is now an ordinary `Text` local and the debugger will display it, defeating the purpose of using `SecretText` in the first place. Reviewers should flag any `Unwrap()` call in a procedure that lacks the attribute, and any procedure that parses a credential out of a response (`access_token`, `id_token`, `client_secret`) without the attribute. See sample: `nondebuggable-required-when-unwrapping-secrettext.bad.al`. diff --git a/microsoft/knowledge/security/permission-set-avoid-wildcard-grants.bad.al b/microsoft/knowledge/security/permission-set-avoid-wildcard-grants.bad.al new file mode 100644 index 0000000..054892d --- /dev/null +++ b/microsoft/knowledge/security/permission-set-avoid-wildcard-grants.bad.al @@ -0,0 +1,10 @@ +permissionset 50201 "Sec Sample Full Access" +{ + Permissions = tabledata * = RIMD; +} + +permissionset 50202 "Sec Sample Basic User" +{ + Permissions = table * = X, + tabledata * = R; +} diff --git a/microsoft/knowledge/security/permission-set-avoid-wildcard-grants.good.al b/microsoft/knowledge/security/permission-set-avoid-wildcard-grants.good.al new file mode 100644 index 0000000..5a3c8a3 --- /dev/null +++ b/microsoft/knowledge/security/permission-set-avoid-wildcard-grants.good.al @@ -0,0 +1,8 @@ +permissionset 50200 "Sec Sample Sales Entry" +{ + Permissions = tabledata "Sales Header" = RIM, + tabledata "Sales Line" = RIMD, + tabledata Customer = R, + table "Sales Header" = X, + table "Sales Line" = X; +} diff --git a/microsoft/knowledge/security/permission-set-avoid-wildcard-grants.md b/microsoft/knowledge/security/permission-set-avoid-wildcard-grants.md new file mode 100644 index 0000000..20332b2 --- /dev/null +++ b/microsoft/knowledge/security/permission-set-avoid-wildcard-grants.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: security +keywords: [permissionset, wildcard, rimd, tabledata, least-privilege] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Avoid wildcard grants in permission sets + +## Description + +A `permissionset` object can grant access object-by-object or with the `*` wildcard. Wildcard grants — `tabledata * = RIMD` (Read/Insert/Modify/Delete on every table) and `table * = X` (Execute on every table object) — collapse the principle of least privilege into a single line and are almost never what the author intended. The grant binds for the lifetime of the permission set wherever it is assigned, including indirectly via role assignment. Permission sets should be granular and role-specific, enumerating only the objects the role actually needs. + +## Best Practice + +Enumerate each `tabledata` and each `table` entry explicitly. Grant only the letters required: `R` for read-only consumers, `RIM` for editors that do not delete, `RIMD` only for owners of the data. When a role needs Execute on objects, list those objects rather than using `table *`. See sample: `permission-set-avoid-wildcard-grants.good.al`. + +## Anti Pattern + +`Permissions = tabledata * = RIMD;` and `Permissions = table * = X, tabledata * = R;` — both grant access to objects the role's author never inspected, and the grant silently broadens every time a new table ships in the platform or in another extension. Reviewers should flag any `*` on the left-hand side of a `tabledata` or `table` entry. See sample: `permission-set-avoid-wildcard-grants.bad.al`. diff --git a/microsoft/knowledge/security/prefer-azure-key-vault-for-production-secrets.md b/microsoft/knowledge/security/prefer-azure-key-vault-for-production-secrets.md deleted file mode 100644 index 382f8e8..0000000 --- a/microsoft/knowledge/security/prefer-azure-key-vault-for-production-secrets.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -bc-version: [all] -domain: security -keywords: [keyvault, azure, secrets, rotation, audit] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Prefer Azure Key Vault for production secrets - -> Contributions welcome — open a PR to refine or extend this article. - -## Description - -Azure Key Vault is an external secret store that supports central management, rotation, and access auditing. The Business Central system application exposes integration APIs that retrieve Key Vault secrets at runtime. IsolatedStorage, by contrast, is a per-tenant local encrypted store with no central rotation or audit story. - -## Best Practice - -For production workloads that require secret rotation, access auditing, and separation between secret custodians and app developers, Azure Key Vault SHOULD be the store of record. Retrieve secrets into a SecretText variable on demand, cache only as long as the call requires, and never persist the retrieved plaintext anywhere the extension does not control. IsolatedStorage MAY be used when a per-tenant local encrypted store is all that is required. - -## Anti Pattern - -Treating IsolatedStorage as the long-term home for secrets in a multi-tenant production extension where secret rotation, central revocation, or access auditing are required. - diff --git a/microsoft/knowledge/security/keep-recordref-open-callers-non-public.bad.al b/microsoft/knowledge/security/recordref-open-with-caller-table-must-not-be-public.bad.al similarity index 83% rename from microsoft/knowledge/security/keep-recordref-open-callers-non-public.bad.al rename to microsoft/knowledge/security/recordref-open-with-caller-table-must-not-be-public.bad.al index 3081231..d0977e4 100644 --- a/microsoft/knowledge/security/keep-recordref-open-callers-non-public.bad.al +++ b/microsoft/knowledge/security/recordref-open-with-caller-table-must-not-be-public.bad.al @@ -1,4 +1,4 @@ -codeunit 50243 "Sec Sample RecordRef Bad" +codeunit 50233 "Sec Sample RecRef Bad" { procedure ArchiveRecord(RecId: RecordId) var diff --git a/microsoft/knowledge/security/recordref-open-with-caller-table-must-not-be-public.good.al b/microsoft/knowledge/security/recordref-open-with-caller-table-must-not-be-public.good.al new file mode 100644 index 0000000..9bd4bec --- /dev/null +++ b/microsoft/knowledge/security/recordref-open-with-caller-table-must-not-be-public.good.al @@ -0,0 +1,29 @@ +codeunit 50232 "Sec Sample RecRef Good" +{ + internal procedure ArchiveRecord(RecId: RecordId) + var + RecRef: RecordRef; + begin + RecRef.Open(RecId.TableNo); + RecRef.Get(RecId); + RecRef.Delete(); + RecRef.Close(); + end; + + procedure ArchiveAllowedRecord(RecId: RecordId) + var + RecRef: RecordRef; + begin + if not IsAllowedTable(RecId.TableNo) then + Error('Operation not permitted on this table.'); + RecRef.Open(RecId.TableNo); + RecRef.Get(RecId); + RecRef.Delete(); + RecRef.Close(); + end; + + local procedure IsAllowedTable(TableNo: Integer): Boolean + begin + exit(TableNo in [Database::Customer, Database::Vendor]); + end; +} diff --git a/microsoft/knowledge/security/recordref-open-with-caller-table-must-not-be-public.md b/microsoft/knowledge/security/recordref-open-with-caller-table-must-not-be-public.md new file mode 100644 index 0000000..c84e15a --- /dev/null +++ b/microsoft/knowledge/security/recordref-open-with-caller-table-must-not-be-public.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: security +keywords: [recordref, open, public, system-table, scope-onprem, confused-deputy, saas] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Procedures that RecordRef.Open a caller-provided table must not be public + +## Description + +When a codeunit holds permission to system tables — directly, via a permission set granted at install, or via `[InherentPermissions]` — and exposes a public procedure that accepts a table number (or a `RecordId`, from which the table number is derived) and calls `RecordRef.Open` on it, the procedure becomes a confused deputy. Any other extension on the same tenant can invoke the procedure with the table number of a system table the calling extension does not own permissions for and obtain access to its rows through the wrapper. This is especially acute in SaaS: an on-premises-style extension that holds broad permissions can be exploited by a co-tenant extension that calls its public surface. + +## Best Practice + +Mark such procedures `local` (callable only inside the containing object), `internal` (callable only inside the owning extension), or `[Scope('OnPrem')]` (not callable from SaaS extensions). If the procedure must be public, validate the table number against an allow-list before `RecordRef.Open` — `if not IsAllowedTable(RecId.TableNo) then Error(...)` — so the caller cannot specify an arbitrary table. See sample: `recordref-open-with-caller-table-must-not-be-public.good.al`. + +## Anti Pattern + +`procedure ArchiveRecord(RecId: RecordId)` (public by default) whose body calls `RecRef.Open(RecId.TableNo)` and then reads, modifies, or deletes the record. Reviewers should flag any procedure that is public (no `local`/`internal`/`[Scope('OnPrem')]`), takes a `RecordId`, `Integer` table number, or `Variant` as a parameter, and calls `RecordRef.Open` with that parameter — unless an allow-list check on the table number precedes the open. See sample: `recordref-open-with-caller-table-must-not-be-public.bad.al`. diff --git a/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.bad.al b/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.bad.al new file mode 100644 index 0000000..84dda45 --- /dev/null +++ b/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.bad.al @@ -0,0 +1,12 @@ +codeunit 50212 "Sec Sample SecretSubst Bad" +{ + procedure BuildAuthHeader(Token: SecretText): Text + begin + exit(StrSubstNo('Bearer %1', Token.Unwrap())); + end; + + procedure BuildSecretUri(BaseUrl: Text; ApiKey: SecretText): Text + begin + exit(BaseUrl + '?key=' + ApiKey.Unwrap()); + end; +} diff --git a/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.good.al b/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.good.al new file mode 100644 index 0000000..f550025 --- /dev/null +++ b/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.good.al @@ -0,0 +1,12 @@ +codeunit 50211 "Sec Sample SecretSubst Good" +{ + procedure BuildAuthHeader(Token: SecretText): SecretText + begin + exit(SecretStrSubstNo('Bearer %1', Token)); + end; + + procedure BuildSecretUri(BaseUrl: Text; ApiKey: SecretText): SecretText + begin + exit(SecretStrSubstNo('%1?key=%2', BaseUrl, ApiKey)); + end; +} diff --git a/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.md b/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.md new file mode 100644 index 0000000..6e315f7 --- /dev/null +++ b/microsoft/knowledge/security/secretstrsubstno-for-composing-secrets.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: security +keywords: [secretstrsubstno, secrettext, strsubstno, format, compose] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use SecretStrSubstNo to compose strings that contain secrets + +## Description + +`SecretStrSubstNo` is the secret-preserving counterpart of `StrSubstNo`. It accepts a format string and arguments (any of which may be `SecretText`) and returns a `SecretText` — the substitution happens without ever materializing the result as plain `Text`. It is the right tool whenever a secret needs to be embedded in a larger string: an `Authorization: Bearer ` header value, a URI that includes an API key as a query parameter, or any other interpolation that combines a `SecretText` with surrounding context. + +## Best Practice + +Compose every secret-bearing string through `SecretStrSubstNo` and keep the result as `SecretText` end-to-end. Pass the result to the `SecretText` overload of the consumer — `HttpClient.SetSecretRequestUri`, `HttpHeaders.Add`, or `HttpContent.WriteFrom`. See sample: `secretstrsubstno-for-composing-secrets.good.al`. + +## Anti Pattern + +Calling `StrSubstNo('Bearer %1', Token.Unwrap())` to build the header value, or concatenating `'Bearer ' + Token.Unwrap()`. Both produce a plain `Text` containing the secret, which is then visible in the debugger and in any subsequent log or trace. Reviewers should flag any `Unwrap()` whose result is fed into `StrSubstNo` or used in `+` concatenation — `SecretStrSubstNo` removes the need for either. See sample: `secretstrsubstno-for-composing-secrets.bad.al`. diff --git a/microsoft/knowledge/security/secrettext-for-credentials.bad.al b/microsoft/knowledge/security/secrettext-for-credentials.bad.al new file mode 100644 index 0000000..5b5ac23 --- /dev/null +++ b/microsoft/knowledge/security/secrettext-for-credentials.bad.al @@ -0,0 +1,22 @@ +codeunit 50208 "Sec Sample SecretText Bad" +{ + procedure CallExternalApi() + var + ApiKey: Text; + BearerToken: Text; + HttpClient: HttpClient; + Response: HttpResponseMessage; + Headers: HttpHeaders; + begin + ApiKey := GetApiKey(); + BearerToken := GetAccessToken(); + Headers := HttpClient.DefaultRequestHeaders(); + Headers.Add('Authorization', 'Bearer ' + BearerToken); + Headers.Add('X-Api-Key', ApiKey); + HttpClient.Get('https://api.example.com/data', Response); + end; + + local procedure GetApiKey(): Text begin end; + + local procedure GetAccessToken(): Text begin end; +} diff --git a/microsoft/knowledge/security/secrettext-for-credentials.good.al b/microsoft/knowledge/security/secrettext-for-credentials.good.al new file mode 100644 index 0000000..d98f127 --- /dev/null +++ b/microsoft/knowledge/security/secrettext-for-credentials.good.al @@ -0,0 +1,16 @@ +codeunit 50207 "Sec Sample SecretText Good" +{ + procedure CallExternalApi() + var + ApiKey: SecretText; + HttpClient: HttpClient; + Response: HttpResponseMessage; + Headers: HttpHeaders; + begin + if IsolatedStorage.Contains('ApiKey', DataScope::Module) then + IsolatedStorage.Get('ApiKey', DataScope::Module, ApiKey); + Headers := HttpClient.DefaultRequestHeaders(); + Headers.Add('X-Api-Key', ApiKey); + HttpClient.Get('https://api.example.com/data', Response); + end; +} diff --git a/microsoft/knowledge/security/secrettext-for-credentials.md b/microsoft/knowledge/security/secrettext-for-credentials.md new file mode 100644 index 0000000..17fec22 --- /dev/null +++ b/microsoft/knowledge/security/secrettext-for-credentials.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: security +keywords: [secrettext, credentials, api-key, token, debugger, unwrap] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use SecretText for credentials, API keys, and tokens + +## Description + +`SecretText` is the AL data type for values that should never appear in a debugger session, in a log, or in a variable watch. The compiler enforces two guarantees: a string literal cannot be assigned directly to a `SecretText` variable, and a `SecretText` cannot be assigned back to a `Text` or `Code` without an explicit `Unwrap` call. Together these prevent the two common accidents — embedding a secret in source code, and quietly converting a secret to plain text where the debugger can read it. Use `SecretText` for parameters, return values, and local variables that carry API keys, tokens, passwords, connection strings, or any other value an attacker with debugger access should not see. + +## Best Practice + +Declare credential-carrying parameters and variables as `SecretText` from the call site that retrieves the secret all the way to the call site that consumes it (typically an `HttpClient` header or URI). Never round-trip through `Text` — every conversion is a potential exposure point. Retrieve secrets from `IsolatedStorage` with the `SecretText` overload of `Get` rather than the `Text` overload. See sample: `secrettext-for-credentials.good.al`. + +## Anti Pattern + +Holding a credential in a `Text` variable (`BearerToken: Text`), concatenating it into a header, then passing it to `HttpClient`. The token is visible in the debugger and in any error that prints the variable, and the compiler offers no help because the type was wrong from the start. Reviewers should flag any local or parameter named like a secret (`ApiKey`, `Token`, `Password`, `ClientSecret`) whose type is `Text` or `Code`. See sample: `secrettext-for-credentials.bad.al`. diff --git a/microsoft/knowledge/security/secrettext-with-httpclient.bad.al b/microsoft/knowledge/security/secrettext-with-httpclient.bad.al new file mode 100644 index 0000000..6ddb883 --- /dev/null +++ b/microsoft/knowledge/security/secrettext-with-httpclient.bad.al @@ -0,0 +1,23 @@ +codeunit 50210 "Sec Sample SecretHttp Bad" +{ + procedure CallApiWithSecretInUri(ApiKey: SecretText) + var + HttpClient: HttpClient; + Response: HttpResponseMessage; + RequestUri: Text; + begin + RequestUri := 'https://api.example.com/data?key=' + ApiKey.Unwrap(); + HttpClient.Get(RequestUri, Response); + end; + + procedure CallApiWithBearer(BearerToken: SecretText) + var + HttpClient: HttpClient; + Response: HttpResponseMessage; + Headers: HttpHeaders; + begin + Headers := HttpClient.DefaultRequestHeaders(); + Headers.Add('Authorization', 'Bearer ' + BearerToken.Unwrap()); + HttpClient.Get('https://api.example.com/data', Response); + end; +} diff --git a/microsoft/knowledge/security/secrettext-with-httpclient.good.al b/microsoft/knowledge/security/secrettext-with-httpclient.good.al new file mode 100644 index 0000000..50f0e31 --- /dev/null +++ b/microsoft/knowledge/security/secrettext-with-httpclient.good.al @@ -0,0 +1,28 @@ +codeunit 50209 "Sec Sample SecretHttp Good" +{ + procedure CallApiWithSecretUri(ApiKey: SecretText) + var + HttpClient: HttpClient; + Response: HttpResponseMessage; + SecretUri: SecretText; + begin + SecretUri := SecretStrSubstNo('https://api.example.com/data?key=%1', ApiKey); + HttpClient.SetSecretRequestUri(SecretUri); + HttpClient.Get('', Response); + end; + + procedure CallApiWithBearer(BearerToken: SecretText) + var + HttpClient: HttpClient; + Response: HttpResponseMessage; + Headers: HttpHeaders; + AuthHeader: SecretText; + begin + AuthHeader := SecretStrSubstNo('Bearer %1', BearerToken); + Headers := HttpClient.DefaultRequestHeaders(); + Headers.Add('Authorization', AuthHeader); + if not Headers.ContainsSecret('Authorization') then + Error('Authorization header missing'); + HttpClient.Get('https://api.example.com/data', Response); + end; +} diff --git a/microsoft/knowledge/security/secrettext-with-httpclient.md b/microsoft/knowledge/security/secrettext-with-httpclient.md new file mode 100644 index 0000000..f8be895 --- /dev/null +++ b/microsoft/knowledge/security/secrettext-with-httpclient.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: security +keywords: [secrettext, httpclient, setsecretrequesturi, containssecret, headers, http] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use the SecretText-aware HttpClient surface for secrets in requests + +## Description + +`HttpClient` and its companion types expose a parallel surface that accepts `SecretText` instead of `Text`, so that secret URIs, secret headers, and secret request bodies never round-trip through plain text. The key entry points are: `HttpClient.SetSecretRequestUri()` for URIs that contain secrets (the subsequent `Get`/`Post` is then called with an empty string); `HttpHeaders.Add()` overload that accepts a `SecretText` value for authorization headers; `HttpHeaders.ContainsSecret()` to test whether a secret header is present (the plain `Contains()` returns false for secret headers); `HttpContent.WriteFrom()` and `HttpContent.ReadAs()` overloads that accept and produce `SecretText` for request and response bodies that carry credentials. + +## Best Practice + +When the URI contains a secret query parameter, compose it as `SecretText` (see `secretstrsubstno-for-composing-secrets.md`), pass it to `SetSecretRequestUri`, and call `Get('', Response)` with an empty string as the URI argument. When the credential is an authorization header, build the header value as `SecretText` and pass it to `Headers.Add`. Use `ContainsSecret` rather than `Contains` to check for the presence of a secret header. See sample: `secrettext-with-httpclient.good.al`. + +## Anti Pattern + +Calling `ApiKey.Unwrap()` to build a URI or header string and passing the resulting `Text` to `HttpClient.Get` or `Headers.Add`. The unwrapped secret is now visible in the debugger, in any HTTP trace that captures the request URI, and in any error that includes the URI. Reviewers should flag any `Unwrap()` call whose result flows into an `HttpClient` argument; the `SecretText` overload exists precisely so the unwrap is not needed. See sample: `secrettext-with-httpclient.bad.al`. diff --git a/microsoft/knowledge/security/use-indirect-permissions-for-elevated-access.bad.al b/microsoft/knowledge/security/use-indirect-permissions-for-elevated-access.bad.al deleted file mode 100644 index d22f177..0000000 --- a/microsoft/knowledge/security/use-indirect-permissions-for-elevated-access.bad.al +++ /dev/null @@ -1,7 +0,0 @@ -permissionset 50203 "Sec Sample Direct Write" -{ - Assignable = true; - Caption = 'Direct write granted to every caller (sample anti-pattern)'; - Permissions = - tabledata "Sales Header" = RM; -} diff --git a/microsoft/knowledge/security/use-indirect-permissions-for-elevated-access.good.al b/microsoft/knowledge/security/use-indirect-permissions-for-elevated-access.good.al deleted file mode 100644 index c2d3e20..0000000 --- a/microsoft/knowledge/security/use-indirect-permissions-for-elevated-access.good.al +++ /dev/null @@ -1,34 +0,0 @@ -permissionset 50202 "Sec Sample Elevated Write" -{ - Assignable = false; - Caption = 'Elevated write via helper (sample)'; - // Callers hold R directly; the helper codeunit assumes this set and - // performs the Modify via indirect permission. - Permissions = - tabledata "Sales Header" = Rmi; -} - -codeunit 50231 "Sec Sample Elevated Helper" -{ - Access = Public; - Permissions = tabledata "Sales Header" = Rmi; - - procedure SetExternalDocumentNo(SalesDocType: Enum "Sales Document Type"; SalesDocNo: Code[20]; NewExternalDocNo: Code[35]) - var - SalesHeader: Record "Sales Header"; - begin - ValidateCaller(); - if NewExternalDocNo = '' then - Error('External document number must be provided.'); - if not SalesHeader.Get(SalesDocType, SalesDocNo) then - Error('Sales document not found.'); - SalesHeader."External Document No." := NewExternalDocNo; - SalesHeader.Modify(true); - end; - - local procedure ValidateCaller() - begin - // Verify the caller is permitted to perform this elevated write - // (role check, setup flag, approvals, etc.). - end; -} diff --git a/microsoft/knowledge/security/use-indirect-permissions-for-elevated-access.md b/microsoft/knowledge/security/use-indirect-permissions-for-elevated-access.md deleted file mode 100644 index 58f69ac..0000000 --- a/microsoft/knowledge/security/use-indirect-permissions-for-elevated-access.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -bc-version: [all] -domain: security -keywords: [indirect-permission, elevation, permissionset] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Use indirect permissions for elevated access - -> Contributions welcome — open a PR to refine or extend this article. - -## Description - -Indirect permissions (ri, ii, mi, di) let a procedure perform an operation against tabledata the caller does not have direct rights to, provided the caller is authorized to invoke the procedure. They are the supported mechanism for elevation: instead of widening every caller's direct rights to M or D, the sensitive operation lives in a codeunit that holds the indirect right and validates its callers. - -## Best Practice - -Where a module exposes a controlled write or delete against a sensitive table, grant the codeunit (or the helper permission set it assumes) the indirect permission (mi, di) it requires, keep direct permissions minimal, and document why the elevation is justified. The helper MUST validate its inputs and the caller's identity before performing the elevated work. - -See sample: `use-indirect-permissions-for-elevated-access.good.al`. - -## Anti Pattern - -Granting direct M or D on a sensitive tabledata to every role that might invoke a helper, because authoring an indirect-permission codeunit was inconvenient. Every caller now has the elevated right for every code path, not just the one the helper implements. - -See sample: `use-indirect-permissions-for-elevated-access.bad.al`. - diff --git a/microsoft/knowledge/security/use-inherent-permissions-to-grant-minimal-access.bad.al b/microsoft/knowledge/security/use-inherent-permissions-to-grant-minimal-access.bad.al deleted file mode 100644 index 25a6740..0000000 --- a/microsoft/knowledge/security/use-inherent-permissions-to-grant-minimal-access.bad.al +++ /dev/null @@ -1,13 +0,0 @@ -codeunit 50205 "Sec Sample Inherent Bad" -{ - // No InherentPermissions attribute: every caller must hold - // tabledata "Sec Sample Lookup" = R just to look up a name. - procedure GetLookupName(LookupCode: Code[20]): Text[100] - var - Lookup: Record "Sec Sample Lookup"; - begin - if Lookup.Get(LookupCode) then - exit(Lookup.Name); - exit(''); - end; -} diff --git a/microsoft/knowledge/security/use-inherent-permissions-to-grant-minimal-access.good.al b/microsoft/knowledge/security/use-inherent-permissions-to-grant-minimal-access.good.al deleted file mode 100644 index a1e5898..0000000 --- a/microsoft/knowledge/security/use-inherent-permissions-to-grant-minimal-access.good.al +++ /dev/null @@ -1,28 +0,0 @@ -table 50230 "Sec Sample Lookup" -{ - DataClassification = SystemMetadata; - - fields - { - field(1; "Code"; Code[20]) { } - field(2; "Name"; Text[100]) { } - } - - keys - { - key(PK; "Code") { Clustered = true; } - } -} - -codeunit 50204 "Sec Sample Inherent Good" -{ - [InherentPermissions(PermissionObjectType::TableData, Database::"Sec Sample Lookup", 'r')] - procedure GetLookupName(LookupCode: Code[20]): Text[100] - var - Lookup: Record "Sec Sample Lookup"; - begin - if Lookup.Get(LookupCode) then - exit(Lookup.Name); - exit(''); - end; -} diff --git a/microsoft/knowledge/security/use-inherent-permissions-to-grant-minimal-access.md b/microsoft/knowledge/security/use-inherent-permissions-to-grant-minimal-access.md deleted file mode 100644 index 7056595..0000000 --- a/microsoft/knowledge/security/use-inherent-permissions-to-grant-minimal-access.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -bc-version: [all] -domain: security -keywords: [inherentpermissions, attribute, least-privilege] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Use InherentPermissions to grant minimal access - -> Contributions welcome — open a PR to refine or extend this article. - -## Description - -The InherentPermissions attribute attaches a minimum access grant to a procedure. Callers can invoke the procedure without holding the underlying tabledata right, because the attribute supplies exactly the right required by the procedure body and nothing more. InherentPermissions currently targets only objects owned by the same extension as the annotated procedure; it cannot be used to grant access to tables in other extensions or in the base application. - -## Best Practice - -Annotate read-only helper procedures with InherentPermissions specifying only the tables and access letters the body uses (typically 'r'). Callers do not need direct read rights on the underlying extension-owned table, so the calling role can be narrower. This is the narrowest of the elevation options and is appropriate for read-only lookup helpers. - -See sample: `use-inherent-permissions-to-grant-minimal-access.good.al`. - -## Anti Pattern - -A helper that reads a single lookup value but forces every calling role to hold tabledata read rights, because the helper does not declare its own inherent permissions. The broad read right then applies to every other code path that role can reach, not just the helper. - -See sample: `use-inherent-permissions-to-grant-minimal-access.bad.al`. - diff --git a/microsoft/knowledge/security/use-isolated-storage-for-module-and-company-secrets.bad.al b/microsoft/knowledge/security/use-isolated-storage-for-module-and-company-secrets.bad.al deleted file mode 100644 index e0572f6..0000000 --- a/microsoft/knowledge/security/use-isolated-storage-for-module-and-company-secrets.bad.al +++ /dev/null @@ -1,18 +0,0 @@ -codeunit 50209 "Sec Sample IsolatedStorage Bad" -{ - procedure StoreApiKey(NewKey: Text) - begin - // Plaintext write to IsolatedStorage is not encrypted at rest. - IsolatedStorage.Set('ApiKey', NewKey, DataScope::Module); - end; - - procedure GetApiKey(): Text - var - ApiKey: Text; - begin - // Public wrapper: another extension can call this to read the secret. - if IsolatedStorage.Get('ApiKey', DataScope::Module, ApiKey) then - exit(ApiKey); - exit(''); - end; -} diff --git a/microsoft/knowledge/security/use-isolated-storage-for-module-and-company-secrets.good.al b/microsoft/knowledge/security/use-isolated-storage-for-module-and-company-secrets.good.al deleted file mode 100644 index 5dafba8..0000000 --- a/microsoft/knowledge/security/use-isolated-storage-for-module-and-company-secrets.good.al +++ /dev/null @@ -1,14 +0,0 @@ -codeunit 50208 "Sec Sample IsolatedStorage Good" -{ - internal procedure StoreApiKey(NewKey: SecretText) - begin - IsolatedStorage.SetEncrypted('ApiKey', NewKey, DataScope::Module); - end; - - local procedure TryGetApiKey(var ApiKey: SecretText): Boolean - begin - if IsolatedStorage.Contains('ApiKey', DataScope::Module) then - exit(IsolatedStorage.Get('ApiKey', DataScope::Module, ApiKey)); - exit(false); - end; -} diff --git a/microsoft/knowledge/security/use-isolated-storage-for-module-and-company-secrets.md b/microsoft/knowledge/security/use-isolated-storage-for-module-and-company-secrets.md deleted file mode 100644 index 7e9d15b..0000000 --- a/microsoft/knowledge/security/use-isolated-storage-for-module-and-company-secrets.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -bc-version: [all] -domain: security -keywords: [isolatedstorage, encryption, datascope, secrets] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Use IsolatedStorage for module and company secrets - -> Contributions welcome — open a PR to refine or extend this article. - -## Description - -IsolatedStorage is a per-extension, per-tenant key-value store. DataScope::Module isolates values to the extension across the tenant; DataScope::Company scopes them to a single company within the tenant. The SetEncrypted method stores the value encrypted at rest; Set stores it in plaintext. SetEncrypted accepts inputs up to 215 characters (special characters may consume more space). - -## Best Practice - -Use IsolatedStorage.SetEncrypted to write secrets, IsolatedStorage.Contains to probe, and IsolatedStorage.Get into a SecretText destination to read. Choose DataScope::Company for per-company credentials (for example, a tenant-per-company service account) and DataScope::Module for extension-wide configuration. Procedures that call IsolatedStorage.Get, Set, SetEncrypted, Contains, or Delete must be `local` or `internal`; a public wrapper lets other extensions call into your storage boundary. - -See sample: `use-isolated-storage-for-module-and-company-secrets.good.al`. - -## Anti Pattern - -Storing secrets in a Setup table column as plain Text, using IsolatedStorage.Set (unencrypted) for values that authenticate the extension to an external service, or exposing a public Get/Set procedure around IsolatedStorage. The first two leave secrets readable; the public wrapper lets another extension exfiltrate or overwrite values through your codeunit. - -See sample: `use-isolated-storage-for-module-and-company-secrets.bad.al`. - diff --git a/microsoft/knowledge/security/use-nondebuggable-when-parsing-secrets.bad.al b/microsoft/knowledge/security/use-nondebuggable-when-parsing-secrets.bad.al deleted file mode 100644 index 4058959..0000000 --- a/microsoft/knowledge/security/use-nondebuggable-when-parsing-secrets.bad.al +++ /dev/null @@ -1,21 +0,0 @@ -codeunit 50217 "Sec Sample NonDebuggable Bad" -{ - // Missing [NonDebuggable]: ResponseText and the extracted token are - // inspectable in the debugger and in snapshot debug sessions. - procedure ParseSessionToken(Response: HttpResponseMessage; var SessionToken: SecretText) - var - ResponseText: Text; - JObject: JsonObject; - JToken: JsonToken; - begin - Response.Content.ReadAs(ResponseText); - JObject.ReadFrom(ResponseText); - JObject.Get('access_token', JToken); - SessionToken := JToken.AsValue().AsText(); - end; - - procedure BuildAuthorizationHeader(ApiKey: SecretText): Text - begin - exit('Bearer ' + ApiKey.Unwrap()); - end; -} diff --git a/microsoft/knowledge/security/use-nondebuggable-when-parsing-secrets.good.al b/microsoft/knowledge/security/use-nondebuggable-when-parsing-secrets.good.al deleted file mode 100644 index 23b00a9..0000000 --- a/microsoft/knowledge/security/use-nondebuggable-when-parsing-secrets.good.al +++ /dev/null @@ -1,21 +0,0 @@ -codeunit 50216 "Sec Sample NonDebuggable Good" -{ - [NonDebuggable] - procedure ParseSessionToken(Response: HttpResponseMessage; var SessionToken: SecretText) - var - ResponseText: Text; - JObject: JsonObject; - JToken: JsonToken; - begin - Response.Content.ReadAs(ResponseText); - JObject.ReadFrom(ResponseText); - JObject.Get('access_token', JToken); - SessionToken := JToken.AsValue().AsText(); - end; - - [NonDebuggable] - procedure BuildAuthorizationHeader(ApiKey: SecretText): Text - begin - exit('Bearer ' + ApiKey.Unwrap()); - end; -} diff --git a/microsoft/knowledge/security/use-nondebuggable-when-parsing-secrets.md b/microsoft/knowledge/security/use-nondebuggable-when-parsing-secrets.md deleted file mode 100644 index 19c776c..0000000 --- a/microsoft/knowledge/security/use-nondebuggable-when-parsing-secrets.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -bc-version: [all] -domain: security -keywords: [nondebuggable, secrettext, attribute, parse] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Use NonDebuggable when parsing secrets - -> Contributions welcome — open a PR to refine or extend this article. - -## Description - -SecretText transit (assignment between SecretText variables, parameters, and return values) is protected automatically. Extracting a secret from a Text source — for example, reading an access token out of a parsed JSON response — is a legitimate Text-to-SecretText conversion during which the plaintext exists. Calling `SecretText.Unwrap()` has the same exposure in the opposite direction: it materializes the secret as plain Text. The [NonDebuggable] attribute prevents debuggers (regular and snapshot) from inspecting the procedure's locals, parameters, and return at that moment. - -## Best Practice - -Apply [NonDebuggable] to any procedure that reads a response body, parses it, and assigns the extracted secret to a SecretText out-parameter or return. Also apply it to every procedure that calls `Unwrap()` because the secret becomes plain Text inside that procedure. Keep the procedure narrow: it SHOULD do the minimum work required to obtain or unwrap the secret, and nothing else. - -See sample: `use-nondebuggable-when-parsing-secrets.good.al`. - -## Anti Pattern - -Parsing a token response in a normal (debuggable) procedure, or calling `ApiKey.Unwrap()` there to build a legacy Text value. The plaintext token is visible in debug sessions and snapshots taken during the parse or unwrap. - -See sample: `use-nondebuggable-when-parsing-secrets.bad.al`. - diff --git a/microsoft/knowledge/security/use-secrettext-for-credentials.bad.al b/microsoft/knowledge/security/use-secrettext-for-credentials.bad.al deleted file mode 100644 index cef9e4a..0000000 --- a/microsoft/knowledge/security/use-secrettext-for-credentials.bad.al +++ /dev/null @@ -1,13 +0,0 @@ -codeunit 50211 "Sec Sample SecretText Bad" -{ - procedure SendAuthenticatedRequest(BearerToken: Text) - var - Client: HttpClient; - Response: HttpResponseMessage; - AuthValue: Text; - begin - AuthValue := 'Bearer ' + BearerToken; - Client.DefaultRequestHeaders.Add('Authorization', AuthValue); - Client.Get('https://api.example.com/data', Response); - end; -} diff --git a/microsoft/knowledge/security/use-secrettext-for-credentials.good.al b/microsoft/knowledge/security/use-secrettext-for-credentials.good.al deleted file mode 100644 index 269d50f..0000000 --- a/microsoft/knowledge/security/use-secrettext-for-credentials.good.al +++ /dev/null @@ -1,14 +0,0 @@ -codeunit 50210 "Sec Sample SecretText Good" -{ - procedure SendAuthenticatedRequest(BearerToken: SecretText) - var - Client: HttpClient; - Headers: HttpHeaders; - Response: HttpResponseMessage; - AuthValue: SecretText; - begin - AuthValue := SecretStrSubstNo('Bearer %1', BearerToken); - Client.DefaultRequestHeaders.Add('Authorization', AuthValue); - Client.Get('https://api.example.com/data', Response); - end; -} diff --git a/microsoft/knowledge/security/use-secrettext-for-credentials.md b/microsoft/knowledge/security/use-secrettext-for-credentials.md deleted file mode 100644 index 505d695..0000000 --- a/microsoft/knowledge/security/use-secrettext-for-credentials.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -bc-version: [all] -domain: security -keywords: [secrettext, credentials, debugger, type] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Use SecretText for credentials - -## Description - -SecretText is a compile-time-checked AL type for credentials, API keys, tokens, and similar sensitive values. The compiler rejects literal assignments to SecretText and blocks implicit conversion back to Text or Code, which prevents many accidental disclosures via logs, errors, and the debugger (regular and snapshot). A SecretText value remains opaque throughout its lifetime. - -## Best Practice - -Type every credential-carrying variable, procedure parameter, and return as SecretText. Compose values with SecretStrSubstNo (see compose-secrets-with-secretstrsubstno). For HttpClient integration, see use-secrettext-with-httpclient. When a secret must be extracted from a Text source, contain that conversion in a NonDebuggable procedure (see use-nondebuggable-when-parsing-secrets). - -See sample: `use-secrettext-for-credentials.good.al`. - -## Anti Pattern - -Passing credentials around as Text or Code parameters. Every such variable is visible in the debugger and may be captured by error handlers, logs, and telemetry that treat Text as non-sensitive. - -See sample: `use-secrettext-for-credentials.bad.al`. - diff --git a/microsoft/knowledge/security/use-secrettext-with-httpclient.bad.al b/microsoft/knowledge/security/use-secrettext-with-httpclient.bad.al deleted file mode 100644 index 4d8dd89..0000000 --- a/microsoft/knowledge/security/use-secrettext-with-httpclient.bad.al +++ /dev/null @@ -1,12 +0,0 @@ -codeunit 50213 "Sec Sample SecretHttpClient Bad" -{ - procedure Call(ApiKey: Text) - var - Client: HttpClient; - Response: HttpResponseMessage; - FullUrl: Text; - begin - FullUrl := 'https://api.example.com/v1?key=' + ApiKey; - Client.Get(FullUrl, Response); - end; -} diff --git a/microsoft/knowledge/security/use-secrettext-with-httpclient.good.al b/microsoft/knowledge/security/use-secrettext-with-httpclient.good.al deleted file mode 100644 index 2ea9a73..0000000 --- a/microsoft/knowledge/security/use-secrettext-with-httpclient.good.al +++ /dev/null @@ -1,15 +0,0 @@ -codeunit 50212 "Sec Sample SecretHttpClient Good" -{ - procedure Call(ApiKey: SecretText) - var - Client: HttpClient; - Request: HttpRequestMessage; - Response: HttpResponseMessage; - SecretUri: SecretText; - begin - SecretUri := SecretStrSubstNo('https://api.example.com/v1?key=%1', ApiKey); - Request.SetSecretRequestUri(SecretUri); - Request.Method('GET'); - Client.Send(Request, Response); - end; -} diff --git a/microsoft/knowledge/security/use-secrettext-with-httpclient.md b/microsoft/knowledge/security/use-secrettext-with-httpclient.md deleted file mode 100644 index 9b73fec..0000000 --- a/microsoft/knowledge/security/use-secrettext-with-httpclient.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -bc-version: [all] -domain: security -keywords: [httpclient, secrettext, headers, uri] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Use SecretText with HttpClient - -## Description - -HttpRequestMessage, HttpHeaders, and HttpContent expose SecretText overloads so credentials never have to be converted back to Text to be sent. Key APIs: HttpRequestMessage.SetSecretRequestUri (for URIs containing secrets), HttpHeaders.Add(name, SecretText) for authorization headers, HttpHeaders.ContainsSecret to probe secret-valued headers, HttpContent.WriteFrom(SecretText) for request bodies, and HttpContent.ReadAs(SecretText) to pull response bodies into a secret destination. - -## Best Practice - -Use HttpRequestMessage.SetSecretRequestUri when any URI component is sensitive (for example, a per-call API key in the path or query), and send the request with HttpClient.Send. Add Authorization headers as SecretText. Check for the presence of a secret header with ContainsSecret, not Contains. - -See sample: `use-secrettext-with-httpclient.good.al`. - -## Anti Pattern - -Materializing a URI or header value as Text to 'just get it to compile' — for example, StrSubstNo into a Text and then HttpClient.Get(FullUrl, Response). The resulting Text is visible in debuggers, and the URL is typically captured by platform-level logging the extension does not control. - -See sample: `use-secrettext-with-httpclient.bad.al`. - diff --git a/microsoft/knowledge/security/validate-user-configurable-urls-before-http-calls.bad.al b/microsoft/knowledge/security/validate-user-configurable-urls-before-http-calls.bad.al deleted file mode 100644 index 391f298..0000000 --- a/microsoft/knowledge/security/validate-user-configurable-urls-before-http-calls.bad.al +++ /dev/null @@ -1,10 +0,0 @@ -codeunit 50241 "Sec Sample Url Bad" -{ - procedure Sync(ServiceUrl: Text) - var - Client: HttpClient; - Response: HttpResponseMessage; - begin - Client.Get(ServiceUrl, Response); - end; -} diff --git a/microsoft/knowledge/security/validate-user-configurable-urls-before-http-calls.good.al b/microsoft/knowledge/security/validate-user-configurable-urls-before-http-calls.good.al deleted file mode 100644 index 5fb72fb..0000000 --- a/microsoft/knowledge/security/validate-user-configurable-urls-before-http-calls.good.al +++ /dev/null @@ -1,17 +0,0 @@ -codeunit 50240 "Sec Sample Url Good" -{ - procedure Sync(ServiceUrl: Text) - var - Client: HttpClient; - Response: HttpResponseMessage; - Uri: Codeunit Uri; - ExpectedBaseUrl: Text; - begin - ExpectedBaseUrl := 'https://api.contoso.com'; - - if not Uri.AreURIsHaveSameHost(ServiceUrl, ExpectedBaseUrl) then - Error('Service URL must point to api.contoso.com.'); - - Client.Get(ServiceUrl, Response); - end; -} diff --git a/microsoft/knowledge/security/validate-user-configurable-urls-before-http-calls.md b/microsoft/knowledge/security/validate-user-configurable-urls-before-http-calls.md deleted file mode 100644 index 7a26165..0000000 --- a/microsoft/knowledge/security/validate-user-configurable-urls-before-http-calls.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: security -keywords: [url, uri, httpclient, ssrf, validation, endpoint] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Validate user-configurable URLs before HTTP calls - -## Description - -URLs stored in setup tables or accepted from user input are user-configurable endpoints. Passing them directly to `HttpClient` lets a malicious or compromised setup value redirect the extension to internal services, metadata endpoints, or attacker-controlled hosts. Business Central's System Application `Uri` codeunit provides host and pattern validation helpers for this exact boundary. - -## Best Practice - -Before `HttpClient.Get`, `Post`, `Put`, or similar calls use a URL from a table field, validate it with `Uri.AreURIsHaveSameHost()` when the host must be fixed, or `Uri.IsValidURIPattern()` when a known URL pattern is allowed. Validate before writing the request body so sensitive payloads are never sent to an unexpected host. - -See sample: `validate-user-configurable-urls-before-http-calls.good.al`. - -## Anti Pattern - -Reading `Setup."Service URL"` or `WebhookSetup."Callback URL"` and passing it directly to HttpClient. The code looks configurable, but it creates an SSRF path and can exfiltrate data to whichever host the setup row names. - -See sample: `validate-user-configurable-urls-before-http-calls.bad.al`. diff --git a/microsoft/knowledge/security/validate-user-configurable-urls.bad.al b/microsoft/knowledge/security/validate-user-configurable-urls.bad.al new file mode 100644 index 0000000..a5b0658 --- /dev/null +++ b/microsoft/knowledge/security/validate-user-configurable-urls.bad.al @@ -0,0 +1,20 @@ +codeunit 50222 "Sec Sample UrlValidation Bad" +{ + procedure SyncWithExternalService(ServiceUrl: Text) + var + HttpClient: HttpClient; + Response: HttpResponseMessage; + begin + HttpClient.Get(ServiceUrl, Response); + end; + + procedure SendWebhookNotification(CallbackUrl: Text; Payload: Text) + var + HttpClient: HttpClient; + Content: HttpContent; + Response: HttpResponseMessage; + begin + Content.WriteFrom(Payload); + HttpClient.Post(CallbackUrl, Content, Response); + end; +} diff --git a/microsoft/knowledge/security/validate-user-configurable-urls.good.al b/microsoft/knowledge/security/validate-user-configurable-urls.good.al new file mode 100644 index 0000000..0925b14 --- /dev/null +++ b/microsoft/knowledge/security/validate-user-configurable-urls.good.al @@ -0,0 +1,24 @@ +codeunit 50221 "Sec Sample UrlValidation Good" +{ + procedure SyncWithExternalService(ServiceUrl: Text) + var + HttpClient: HttpClient; + Response: HttpResponseMessage; + Uri: Codeunit Uri; + begin + if not Uri.AreURIsHaveSameHost(ServiceUrl, 'https://api.contoso.com') then + Error('Service URL must point to api.contoso.com'); + HttpClient.Get(ServiceUrl, Response); + end; + + procedure SyncWithShopify(ShopUrl: Text) + var + HttpClient: HttpClient; + Response: HttpResponseMessage; + Uri: Codeunit Uri; + begin + if not Uri.IsValidURIPattern(ShopUrl, 'https://*.myshopify.com/*') then + Error('Shop URL must match the Shopify pattern'); + HttpClient.Get(ShopUrl + '/admin/api/2024-01/orders.json', Response); + end; +} diff --git a/microsoft/knowledge/security/validate-user-configurable-urls.md b/microsoft/knowledge/security/validate-user-configurable-urls.md new file mode 100644 index 0000000..06cc31d --- /dev/null +++ b/microsoft/knowledge/security/validate-user-configurable-urls.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: security +keywords: [ssrf, uri, url-validation, areurishavesamehost, isvaliduripattern, httpclient] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Validate URLs that come from table fields before calling them + +## Description + +A URL stored in a table field is user-configurable: anyone with write access to the row can change it. If that URL is then used as the target of an `HttpClient.Get`/`Post`, the extension becomes a server-side request forgery (SSRF) primitive — an attacker can redirect the call to an internal endpoint, to a metadata service, or to a malicious host that mirrors the legitimate API. The `Uri` codeunit from System Modules provides two validators built for this situation: `AreURIsHaveSameHost()` checks that two URLs share the same host (use when the hostname should not change — for example, the extension always talks to `api.contoso.com`). `IsValidURIPattern()` checks that a URL matches a wildcard pattern (use when the host varies but follows a predictable shape — for example `https://{store}.myshopify.com/...`). + +## Best Practice + +Before any `HttpClient` call whose URL came from a table field, call `Uri.AreURIsHaveSameHost(StoredUrl, ExpectedBaseUrl)` against a hard-coded expected base, or `Uri.IsValidURIPattern(StoredUrl, 'https://*.myshopify.com/*')` against a fixed pattern. Fail the call with an `Error` when the validator returns false. For webhook scenarios where the host is registered out-of-band, compare against the registered host stored alongside the URL. See sample: `validate-user-configurable-urls.good.al`. + +## Anti Pattern + +`HttpClient.Get(Setup."Service URL", Response)` or `HttpClient.Post(WebhookSetup."Callback URL", Content, Response)` with no validation step in between. The extension will dutifully send the request — and any sensitive payload — to whatever host the attacker put in the field. Reviewers should flag any `HttpClient` call whose first argument is a record field, an `OnValidate`-mutable field, or a value sourced from a table read, unless a `Uri.AreURIsHaveSameHost` or `Uri.IsValidURIPattern` check precedes it. See sample: `validate-user-configurable-urls.bad.al`. diff --git a/microsoft/knowledge/security/validatetablerelation-false-on-user-input.bad.al b/microsoft/knowledge/security/validatetablerelation-false-on-user-input.bad.al new file mode 100644 index 0000000..7029908 --- /dev/null +++ b/microsoft/knowledge/security/validatetablerelation-false-on-user-input.bad.al @@ -0,0 +1,11 @@ +tableextension 50225 "Sec Sample VTR Bad" extends Customer +{ + fields + { + field(50225; "Linked Customer No."; Code[20]) + { + TableRelation = Customer."No."; + ValidateTableRelation = false; + } + } +} diff --git a/microsoft/knowledge/security/validatetablerelation-false-on-user-input.good.al b/microsoft/knowledge/security/validatetablerelation-false-on-user-input.good.al new file mode 100644 index 0000000..9a49bc3 --- /dev/null +++ b/microsoft/knowledge/security/validatetablerelation-false-on-user-input.good.al @@ -0,0 +1,26 @@ +tableextension 50223 "Sec Sample VTR Good" extends Customer +{ + fields + { + field(50223; "System Batch ID"; Code[20]) + { + TableRelation = "Sales Header"."No."; + ValidateTableRelation = false; + Editable = false; + } + field(50224; "External Customer Ref"; Code[50]) + { + TableRelation = Customer."No."; + ValidateTableRelation = false; + trigger OnValidate() + var + Customer: Record Customer; + begin + if "External Customer Ref" = '' then + exit; + if not Customer.Get("External Customer Ref") then + Error('External customer reference %1 does not exist.', "External Customer Ref"); + end; + } + } +} diff --git a/microsoft/knowledge/security/validatetablerelation-false-on-user-input.md b/microsoft/knowledge/security/validatetablerelation-false-on-user-input.md new file mode 100644 index 0000000..275587c --- /dev/null +++ b/microsoft/knowledge/security/validatetablerelation-false-on-user-input.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: security +keywords: [validatetablerelation, tablerelation, field, validation, input] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Do not set ValidateTableRelation = false on user-editable fields + +## Description + +`TableRelation` on a field declares that the field's value must exist in another table; the platform validates the value on entry and on `Validate`. Setting `ValidateTableRelation = false` keeps the relation as metadata (used by lookups, by Edit-in-Excel, by APIs) but turns off the runtime check. On a system-controlled, non-editable field that is populated only by the platform or by a posting routine, that is acceptable. On a user-editable field, it is dangerous: users can type any value, and downstream code that assumes the relation holds will read a `Customer` record that does not exist, post to an account that was deleted, or join against missing rows. + +## Best Practice + +Leave `ValidateTableRelation` at its default (true) on any field a user can edit. If there is a legitimate reason to turn it off — typically because the relation is not on the primary key, or because the relation is computed — replace it with an `OnValidate` trigger that performs the equivalent check (`if FieldValue <> '' then VerifyExternalReferenceExists(FieldValue)`). Combine `ValidateTableRelation = false` with `Editable = false` for system-controlled fields, so the metadata is correct and the field is unreachable from the UI. See sample: `validatetablerelation-false-on-user-input.good.al`. + +## Anti Pattern + +`ValidateTableRelation = false` on a user-facing input field (a `Customer No.` typed by a sales user) with no alternative validation. Reviewers should flag the combination of `ValidateTableRelation = false` and any of: `Editable = true` (the default), an `OnValidate` trigger that does not perform the relation check, or a page that surfaces the field as input. See sample: `validatetablerelation-false-on-user-input.bad.al`. diff --git a/microsoft/knowledge/style/abouttitle-abouttext-teaching-tips.bad.al b/microsoft/knowledge/style/abouttitle-abouttext-teaching-tips.bad.al new file mode 100644 index 0000000..a32072f --- /dev/null +++ b/microsoft/knowledge/style/abouttitle-abouttext-teaching-tips.bad.al @@ -0,0 +1,5 @@ +page 50258 "Sample AboutTitle Bad" +{ + PageType = List; + SourceTable = Customer; +} diff --git a/microsoft/knowledge/style/abouttitle-abouttext-teaching-tips.good.al b/microsoft/knowledge/style/abouttitle-abouttext-teaching-tips.good.al new file mode 100644 index 0000000..b497974 --- /dev/null +++ b/microsoft/knowledge/style/abouttitle-abouttext-teaching-tips.good.al @@ -0,0 +1,15 @@ +page 50256 "Sample AboutTitle Good List" +{ + PageType = List; + SourceTable = Customer; + AboutTitle = 'About customers'; + AboutText = 'Manage your customer database and track customer interactions. You can create new customers, update contact information, and view customer statistics.'; +} + +page 50257 "Sample AboutTitle Good Card" +{ + PageType = Card; + SourceTable = Customer; + AboutTitle = 'About customer details'; + AboutText = 'View and edit detailed customer information including contact details, payment terms, and billing preferences.'; +} diff --git a/microsoft/knowledge/style/abouttitle-abouttext-teaching-tips.md b/microsoft/knowledge/style/abouttitle-abouttext-teaching-tips.md new file mode 100644 index 0000000..f72b959 --- /dev/null +++ b/microsoft/knowledge/style/abouttitle-abouttext-teaching-tips.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: style +keywords: [abouttitle, abouttext, teaching-tip, onboarding, page] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use `AboutTitle` and `AboutText` to surface teaching tips on top-level pages + +## Description + +The `AboutTitle` and `AboutText` properties on a page render a teaching tip — an onboarding callout that appears the first time a user opens the page. They are supported on pages, individual page controls, FactBoxes, and report request pages. They are NOT supported on Role Centers or modal dialogs. The conventions: `AboutTitle` answers "what is this page about?" and uses the plural for list pages (`'About sales invoices'`) and the `[entity] details` form for card and document pages (`'About sales invoice details'`); `AboutText` answers "what can I do with this page?" in two or three short sentences. Both are translation-aware and surface to the end user verbatim. + +The reviewer signal is "this is a new top-level card or list page in an app whose sibling pages already define teaching tips" — when the surrounding app sets the precedent, a new page without `AboutTitle`/`AboutText` is an inconsistency worth flagging. + +## Best Practice + +Set `AboutTitle` and `AboutText` on every new top-level card, list, and document page in an app that already uses them. Keep `AboutText` to two or three short sentences. Describe what the page does, not the navigation steps to use it — teaching tips explain WHAT, not HOW. + +See sample: `abouttitle-abouttext-teaching-tips.good.al`. + +## Anti Pattern + +A new top-level page in an app whose siblings have `AboutTitle`/`AboutText`, but with no teaching tips defined. Equally wrong is filling `AboutText` with step-by-step instructions ("Click New, then enter…") — the property is for orientation, not procedural help. + +See sample: `abouttitle-abouttext-teaching-tips.bad.al`. diff --git a/microsoft/knowledge/style/api-page-camelcase-properties.bad.al b/microsoft/knowledge/style/api-page-camelcase-properties.bad.al new file mode 100644 index 0000000..47f2f11 --- /dev/null +++ b/microsoft/knowledge/style/api-page-camelcase-properties.bad.al @@ -0,0 +1,10 @@ +page 50219 "Sample API Camel Bad" +{ + PageType = API; + APIPublisher = 'Contoso-App'; + APIGroup = 'app_1'; + APIVersion = 'v2.0'; + EntityName = 'sales_order'; + EntitySetName = 'sales_orders'; + SourceTable = "Sales Header"; +} diff --git a/microsoft/knowledge/style/follow-api-page-naming-rules.good.al b/microsoft/knowledge/style/api-page-camelcase-properties.good.al similarity index 61% rename from microsoft/knowledge/style/follow-api-page-naming-rules.good.al rename to microsoft/knowledge/style/api-page-camelcase-properties.good.al index 8e1bec5..f460bb9 100644 --- a/microsoft/knowledge/style/follow-api-page-naming-rules.good.al +++ b/microsoft/knowledge/style/api-page-camelcase-properties.good.al @@ -1,4 +1,4 @@ -page 51102 "Style Sample ApiPage Good" +page 50218 "Sample API Camel Good" { PageType = API; APIPublisher = 'contoso'; @@ -8,7 +8,6 @@ page 51102 "Style Sample ApiPage Good" EntitySetName = 'customers'; SourceTable = Customer; DelayedInsert = true; - ODataKeyFields = SystemId; layout { @@ -16,9 +15,7 @@ page 51102 "Style Sample ApiPage Good" { repeater(Group) { - field(systemId; Rec.SystemId) { } - field(number; Rec."No.") { } - field(displayName; Rec.Name) { } + field(displayName; Rec.Name) { Caption = 'displayName'; } } } } diff --git a/microsoft/knowledge/style/api-page-camelcase-properties.md b/microsoft/knowledge/style/api-page-camelcase-properties.md new file mode 100644 index 0000000..3baa009 --- /dev/null +++ b/microsoft/knowledge/style/api-page-camelcase-properties.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [api-page, camelcase, apipublisher, apigroup, entityname, entitysetname] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# API pages use camelCase, alphanumeric-only values for API properties + +## Description + +API pages — pages declared with `PageType = API` — surface as OData/JSON endpoints. The strings that appear in the URL (`APIPublisher`, `APIGroup`, `EntityName`, `EntitySetName`) and the JSON payload field names follow different naming rules from the rest of AL. They must be camelCase and use only alphanumeric characters: no hyphens, no underscores, no spaces, no punctuation. `'Contoso-App'`, `'contoso_app'`, and `'contoso.app'` are all rejected. The same rule applies to page field names exposed via `Name = '…'` on API page controls — those names appear verbatim in the JSON keys. + +## Best Practice + +Pick camelCase identifiers up front: `APIPublisher = 'contoso'`, `APIGroup = 'app1'`, `EntityName = 'customer'`, field `Name = 'displayName'`. Keep them short — they end up in URL paths and JSON keys that every consumer types. + +See sample: `api-page-camelcase-properties.good.al`. + +## Anti Pattern + +`APIPublisher = 'Contoso-App'` (hyphen rejected, capitalization wrong for camelCase), `EntityName = 'sales_order'` (underscore rejected), or fields exposed with `Name = 'Display Name'` (space rejected). The compiler usually catches these, but the failure mode is opaque and the rename cost on a deployed API is high. + +See sample: `api-page-camelcase-properties.bad.al`. diff --git a/microsoft/knowledge/style/api-page-delayedinsert-true.bad.al b/microsoft/knowledge/style/api-page-delayedinsert-true.bad.al new file mode 100644 index 0000000..4cf3fe2 --- /dev/null +++ b/microsoft/knowledge/style/api-page-delayedinsert-true.bad.al @@ -0,0 +1,10 @@ +page 50227 "Sample DelayedInsert Bad" +{ + PageType = API; + APIPublisher = 'contoso'; + APIGroup = 'app1'; + APIVersion = 'v1.0'; + EntityName = 'customer'; + EntitySetName = 'customers'; + SourceTable = Customer; +} diff --git a/microsoft/knowledge/style/api-page-delayedinsert-true.good.al b/microsoft/knowledge/style/api-page-delayedinsert-true.good.al new file mode 100644 index 0000000..532d3cd --- /dev/null +++ b/microsoft/knowledge/style/api-page-delayedinsert-true.good.al @@ -0,0 +1,11 @@ +page 50226 "Sample DelayedInsert Good" +{ + PageType = API; + APIPublisher = 'contoso'; + APIGroup = 'app1'; + APIVersion = 'v1.0'; + EntityName = 'customer'; + EntitySetName = 'customers'; + SourceTable = Customer; + DelayedInsert = true; +} diff --git a/microsoft/knowledge/style/api-page-delayedinsert-true.md b/microsoft/knowledge/style/api-page-delayedinsert-true.md new file mode 100644 index 0000000..6045c2f --- /dev/null +++ b/microsoft/knowledge/style/api-page-delayedinsert-true.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [api-page, delayedinsert, insert-trigger, validation] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Set `DelayedInsert = true` on API pages + +## Description + +On a normal page, `DelayedInsert = false` is the default: the record is inserted into the table as soon as the user enters the first field, and subsequent fields are written via `Modify` triggers. That model does not work for an API endpoint, where the consumer sends a complete JSON payload in a single request and expects exactly one `Insert` to fire with all fields already populated. `DelayedInsert = true` defers the insert until every field on the page has been assigned, so the `OnInsert` trigger runs once with the full record and `OnValidate` triggers on individual fields run in a predictable order. The convention is that API pages always set `DelayedInsert = true`. + +## Best Practice + +Declare `DelayedInsert = true` on every page with `PageType = API`. The setting plays well with `Modify(true)` and `Insert(true)` calls inside `OnInsert` and avoids the half-populated record states that otherwise reach validation logic. + +See sample: `api-page-delayedinsert-true.good.al`. + +## Anti Pattern + +Omitting `DelayedInsert` (which defaults to `false`) on an API page. Validation triggers fire on a partially populated record, mandatory-field errors come back to the caller for fields the JSON payload was about to supply, and the API surface produces failures that have no analogue in the UI page model. + +See sample: `api-page-delayedinsert-true.bad.al`. diff --git a/microsoft/knowledge/style/api-page-entity-naming-singular-plural.bad.al b/microsoft/knowledge/style/api-page-entity-naming-singular-plural.bad.al new file mode 100644 index 0000000..ecd1ec3 --- /dev/null +++ b/microsoft/knowledge/style/api-page-entity-naming-singular-plural.bad.al @@ -0,0 +1,10 @@ +page 50225 "Sample API Entity Bad" +{ + PageType = API; + APIPublisher = 'contoso'; + APIGroup = 'app1'; + APIVersion = 'v1.0'; + EntityName = 'customers'; + EntitySetName = 'customer'; + SourceTable = Customer; +} diff --git a/microsoft/knowledge/style/api-page-entity-naming-singular-plural.good.al b/microsoft/knowledge/style/api-page-entity-naming-singular-plural.good.al new file mode 100644 index 0000000..72981b7 --- /dev/null +++ b/microsoft/knowledge/style/api-page-entity-naming-singular-plural.good.al @@ -0,0 +1,23 @@ +page 50223 "Sample API Entity Good" +{ + PageType = API; + APIPublisher = 'contoso'; + APIGroup = 'app1'; + APIVersion = 'v1.0'; + EntityName = 'customer'; + EntitySetName = 'customers'; + SourceTable = Customer; + DelayedInsert = true; +} + +page 50224 "Sample API Compound Good" +{ + PageType = API; + APIPublisher = 'contoso'; + APIGroup = 'app1'; + APIVersion = 'v1.0'; + EntityName = 'salesOrder'; + EntitySetName = 'salesOrders'; + SourceTable = "Sales Header"; + DelayedInsert = true; +} diff --git a/microsoft/knowledge/style/api-page-entity-naming-singular-plural.md b/microsoft/knowledge/style/api-page-entity-naming-singular-plural.md new file mode 100644 index 0000000..6ba698e --- /dev/null +++ b/microsoft/knowledge/style/api-page-entity-naming-singular-plural.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [api-page, entityname, entitysetname, singular, plural] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# `EntityName` is singular; `EntitySetName` is plural + +## Description + +`EntityName` and `EntitySetName` on an API page are the two halves of the OData naming contract. `EntityName` names a single record — `'customer'`, `'salesOrder'`, `'item'`. `EntitySetName` names the collection — `'customers'`, `'salesOrders'`, `'items'`. Swapping them — `EntityName = 'customers'`, `EntitySetName = 'customer'` — produces URLs that lie to consumers: `GET /customers` returns one row, `GET /customers('id')` returns a collection. The OData conventions consumers rely on for client-side code generation depend on the singular/plural pairing being correct. + +## Best Practice + +Pick the singular noun for `EntityName` and its grammatical plural for `EntitySetName`, both in camelCase. For compound nouns, only the trailing noun is pluralized: `EntityName = 'salesOrder'`, `EntitySetName = 'salesOrders'`. For nouns whose plural is irregular, use the natural English form — `EntitySetName = 'people'` for `EntityName = 'person'`. + +See sample: `api-page-entity-naming-singular-plural.good.al`. + +## Anti Pattern + +`EntityName = 'customers'`, `EntitySetName = 'customer'` — singular and plural swapped. Equally wrong is reusing the same form for both — `EntityName = 'customer'`, `EntitySetName = 'customer'` — which breaks OData metadata parsers and client codegen. + +See sample: `api-page-entity-naming-singular-plural.bad.al`. diff --git a/microsoft/knowledge/style/api-page-version-format.bad.al b/microsoft/knowledge/style/api-page-version-format.bad.al new file mode 100644 index 0000000..2efe623 --- /dev/null +++ b/microsoft/knowledge/style/api-page-version-format.bad.al @@ -0,0 +1,10 @@ +page 50222 "Sample API Version Bad" +{ + PageType = API; + APIPublisher = 'contoso'; + APIGroup = 'app1'; + APIVersion = 'v2'; + EntityName = 'customer'; + EntitySetName = 'customers'; + SourceTable = Customer; +} diff --git a/microsoft/knowledge/style/api-page-version-format.good.al b/microsoft/knowledge/style/api-page-version-format.good.al new file mode 100644 index 0000000..dc115b4 --- /dev/null +++ b/microsoft/knowledge/style/api-page-version-format.good.al @@ -0,0 +1,23 @@ +page 50220 "Sample API Version Good" +{ + PageType = API; + APIPublisher = 'contoso'; + APIGroup = 'app1'; + APIVersion = 'v1.0'; + EntityName = 'customer'; + EntitySetName = 'customers'; + SourceTable = Customer; + DelayedInsert = true; +} + +page 50221 "Sample API Beta Good" +{ + PageType = API; + APIPublisher = 'contoso'; + APIGroup = 'app1'; + APIVersion = 'beta'; + EntityName = 'preview'; + EntitySetName = 'previews'; + SourceTable = Customer; + DelayedInsert = true; +} diff --git a/microsoft/knowledge/style/api-page-version-format.md b/microsoft/knowledge/style/api-page-version-format.md new file mode 100644 index 0000000..633c53b --- /dev/null +++ b/microsoft/knowledge/style/api-page-version-format.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [api-page, apiversion, version, format, beta] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# `APIVersion` must follow the pattern `vX.Y` (or `beta`) + +## Description + +The `APIVersion` property on an API page is part of the public URL path: `/api////`. The platform accepts only two value shapes for it: a `vMAJOR.MINOR` string such as `'v1.0'`, `'v2.0'`, or `'v2.1'`, or the literal string `'beta'` for pre-release endpoints. Anything else — `'v2'`, `'2.0'`, `'1'`, `'v2.0.0'` — is rejected. The major-minor pair lets consumers detect compatibility through URL inspection alone; the explicit `'beta'` channel signals "this contract may break without notice." + +## Best Practice + +Start a new public endpoint at `'v1.0'`. Bump the minor when adding fields or non-breaking changes; bump the major when changing field types, removing fields, or any breaking change. Use `'beta'` for endpoints that are still iterating and SHOULD NOT be consumed by external integrations. + +See sample: `api-page-version-format.good.al`. + +## Anti Pattern + +`APIVersion = 'v2'` (missing minor), `APIVersion = '2.0'` (missing `v` prefix), `APIVersion = 'v2.0.0'` (extra segment). All three either fail to compile or produce a URL that consumers cannot reach. + +See sample: `api-page-version-format.bad.al`. diff --git a/microsoft/knowledge/style/apply-approved-label-suffixes.bad.al b/microsoft/knowledge/style/apply-approved-label-suffixes.bad.al deleted file mode 100644 index 2893eb0..0000000 --- a/microsoft/knowledge/style/apply-approved-label-suffixes.bad.al +++ /dev/null @@ -1,17 +0,0 @@ -codeunit 51101 "Style Sample LabelSuffix Bad" -{ - procedure Example() - var - CannotDeleteLine: Label 'Cannot delete this line.'; - Text000: Label 'Update complete'; - UpdateLocation: Label 'Update location?'; - WrongSuffixTok: Label 'Customer %1 not found.', Comment = '%1 = Customer No.'; - CustomerNo: Code[20]; - begin - Error(CannotDeleteLine); - Message(Text000); - if Confirm(UpdateLocation) then - ; - Error(WrongSuffixTok, CustomerNo); - end; -} diff --git a/microsoft/knowledge/style/apply-approved-label-suffixes.good.al b/microsoft/knowledge/style/apply-approved-label-suffixes.good.al deleted file mode 100644 index 6557feb..0000000 --- a/microsoft/knowledge/style/apply-approved-label-suffixes.good.al +++ /dev/null @@ -1,20 +0,0 @@ -codeunit 51100 "Style Sample LabelSuffix Good" -{ - procedure Example() - var - UpdateCompleteMsg: Label 'Update complete.'; - CannotDeleteLineErr: Label 'Cannot delete this line.'; - UpdateLocationQst: Label 'Update location?'; - CustomerNameLbl: Label 'Customer Name'; - HttpsMethodTok: Label 'GET', Locked = true; - TelemetryCustomerUpdatedTxt: Label 'Customer updated.'; - begin - Message(UpdateCompleteMsg); - if Confirm(UpdateLocationQst) then - ; - Session.LogMessage('0001', TelemetryCustomerUpdatedTxt, - Verbosity::Normal, DataClassification::SystemMetadata, - TelemetryScope::ExtensionPublisher); - Error(CannotDeleteLineErr); - end; -} diff --git a/microsoft/knowledge/style/apply-approved-label-suffixes.md b/microsoft/knowledge/style/apply-approved-label-suffixes.md deleted file mode 100644 index 59a8f72..0000000 --- a/microsoft/knowledge/style/apply-approved-label-suffixes.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: style -keywords: [label, textconst, suffix, msg, err, qst, tok, lbl, txt, aa0074] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Suffix every Label and TextConst with its approved usage tag - -## Description - -CodeCop rule AA0074 requires every Label and TextConst to carry a suffix indicating how the value is consumed: `Msg` for Message calls, `Err` for Error calls, `Qst` for Confirm or StrMenu prompts, `Tok` for locked tokens (URLs, JSON keys, short literals with `Locked = true`), `Lbl` for captions and tooltips, and `Txt` for telemetry strings. The suffix is not decoration — it is how the compiler, linter, and reviewer detect misuse (a `Tok` value passed to `Error`, a `Msg` used as an error label). The cost of adopting the convention is one short suffix per declaration; the cost of ignoring it is that every reviewer has to inspect every call site to judge appropriateness. - -## Best Practice - -Name every Label and TextConst with one of `Msg`, `Err`, `Qst`, `Tok`, `Lbl`, or `Txt` at the end. Pick the suffix that matches the consuming call, not the look of the string. When multiple suffixes are grammatically valid (`Tok` vs `Lbl` for a short caption on a locked token) the choice is a judgment call; the violation is missing a suffix or using one inconsistent with the call site. - -See sample: `apply-approved-label-suffixes.good.al`. - -## Anti Pattern - -`CannotDeleteLine: Label 'Cannot delete this line.';` — no suffix, used with Error. `Text000: Label 'Update complete';` — generic name with no suffix at all. `WrongSuffixTok: Label 'Customer %1 not found.'` used with Error — a Tok suffix on an error label. - -See sample: `apply-approved-label-suffixes.bad.al`. diff --git a/microsoft/knowledge/style/begin-on-same-line-as-then-else-do.bad.al b/microsoft/knowledge/style/begin-on-same-line-as-then-else-do.bad.al new file mode 100644 index 0000000..fd3ac45 --- /dev/null +++ b/microsoft/knowledge/style/begin-on-same-line-as-then-else-do.bad.al @@ -0,0 +1,14 @@ +codeunit 50235 "Sample Begin Own Line Bad" +{ + procedure Run(Condition: Boolean) + begin + if Condition then + begin + DoSomething(); + DoSomethingElse(); + end; + end; + + local procedure DoSomething() begin end; + local procedure DoSomethingElse() begin end; +} diff --git a/microsoft/knowledge/style/begin-on-same-line-as-then-else-do.good.al b/microsoft/knowledge/style/begin-on-same-line-as-then-else-do.good.al new file mode 100644 index 0000000..6043c5b --- /dev/null +++ b/microsoft/knowledge/style/begin-on-same-line-as-then-else-do.good.al @@ -0,0 +1,24 @@ +codeunit 50234 "Sample Begin Same Line Good" +{ + procedure Run(Condition: Boolean) + var + i: Integer; + begin + if Condition then begin + DoSomething(); + DoSomethingElse(); + end else begin + Reset(); + Notify(); + end; + for i := 1 to 10 do begin + DoSomething(); + DoSomethingElse(); + end; + end; + + local procedure DoSomething() begin end; + local procedure DoSomethingElse() begin end; + local procedure Reset() begin end; + local procedure Notify() begin end; +} diff --git a/microsoft/knowledge/style/begin-on-same-line-as-then-else-do.md b/microsoft/knowledge/style/begin-on-same-line-as-then-else-do.md new file mode 100644 index 0000000..fbf7aec --- /dev/null +++ b/microsoft/knowledge/style/begin-on-same-line-as-then-else-do.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [begin, end, compound-statement, aa0005, codecop, formatting] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# `begin` goes on the same line as `then`, `else`, or `do` (CodeCop AA0005) + +## Description + +When a compound block follows `then`, `else`, or `do`, the `begin` keyword must sit on the same line as the preceding keyword, separated by exactly one space. `if Condition then begin` and `for i := 1 to N do begin` are correct. The form that puts `begin` on its own line — common in older AL and in languages like Pascal — is flagged by CodeCop AA0005. The rule does not change indentation of the block body; it only governs the placement of `begin` relative to `then`/`else`/`do`. + +## Best Practice + +`if Condition then begin … end;`, `else begin … end;`, `for i := 1 to N do begin … end;`. The block body is indented one level below the `if`/`for` line, and `end;` sits at the same indentation as the line that opened the block. + +See sample: `begin-on-same-line-as-then-else-do.good.al`. + +## Anti Pattern + +A line that ends with `then` (or `else`, or `do`) and is followed by a line whose only content is `begin`. The compiler accepts it but CodeCop AA0005 flags it; the visual cost is a wasted line per block and a layout that looks alien to readers used to current AL style. + +See sample: `begin-on-same-line-as-then-else-do.bad.al`. diff --git a/microsoft/knowledge/style/block-keywords-start-new-line.bad.al b/microsoft/knowledge/style/block-keywords-start-new-line.bad.al new file mode 100644 index 0000000..ef7f937 --- /dev/null +++ b/microsoft/knowledge/style/block-keywords-start-new-line.bad.al @@ -0,0 +1,15 @@ +codeunit 50239 "Sample Block Kw Bad" +{ + procedure Dispatch(IsContactName: Boolean; IsSalespersonCode: Boolean) + var + i: Integer; + begin + if IsContactName then ValidateContactName() else if IsSalespersonCode then ValidateSalespersonCode(); + for i := 1 to 10 do begin DoSomething(i); DoSomethingElse(i); end; + end; + + local procedure ValidateContactName() begin end; + local procedure ValidateSalespersonCode() begin end; + local procedure DoSomething(I: Integer) begin end; + local procedure DoSomethingElse(I: Integer) begin end; +} diff --git a/microsoft/knowledge/style/block-keywords-start-new-line.good.al b/microsoft/knowledge/style/block-keywords-start-new-line.good.al new file mode 100644 index 0000000..eb6c3d4 --- /dev/null +++ b/microsoft/knowledge/style/block-keywords-start-new-line.good.al @@ -0,0 +1,23 @@ +codeunit 50238 "Sample Block Kw Good" +{ + procedure Dispatch(IsContactName: Boolean; IsSalespersonCode: Boolean) + var + i: Integer; + begin + if IsContactName then + ValidateContactName() + else + if IsSalespersonCode then + ValidateSalespersonCode(); + + for i := 1 to 10 do begin + DoSomething(i); + DoSomethingElse(i); + end; + end; + + local procedure ValidateContactName() begin end; + local procedure ValidateSalespersonCode() begin end; + local procedure DoSomething(I: Integer) begin end; + local procedure DoSomethingElse(I: Integer) begin end; +} diff --git a/microsoft/knowledge/style/block-keywords-start-new-line.md b/microsoft/knowledge/style/block-keywords-start-new-line.md new file mode 100644 index 0000000..d40ea61 --- /dev/null +++ b/microsoft/knowledge/style/block-keywords-start-new-line.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [block-keyword, end, if, repeat, until, for, while, case, aa0018] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Block keywords (`end`, `if`, `repeat`, `until`, `for`, `while`, `case`) start a new line (CodeCop AA0018) + +## Description + +CodeCop AA0018 requires that the block-introducing keywords `if`, `repeat`, `until`, `for`, `while`, `case`, and the block-terminating keyword `end` always start a new line. Multiple statements packed onto one line — `if A then X() else if B then Y();` written inline, or `for i := 1 to 10 do begin X(i); Y(i); end;` — defeat code review tooling that operates line-by-line and obscure the control flow. The rule does not prohibit short single-statement constructs spread across two lines (`if Cond then X();`); it prohibits packing the entire control structure onto one line. + +## Best Practice + +Each `if`, `else if`, `repeat`, `for`, `while`, and `case` starts a line. Each `end;` (the closing of a `begin … end` block or a `case`) starts a line. Branch bodies are on their own line, indented. + +See sample: `block-keywords-start-new-line.good.al`. + +## Anti Pattern + +`if IsContactName then ValidateContactName() else if IsSalespersonCode then ValidateSalespersonCode();` collapses an `if/else if` chain onto a single line; AA0018 flags both the `else` and the second `if`. The same applies to `for i := 1 to 10 do begin DoX(i); DoY(i); end;` — `end` is not at the start of its line. + +See sample: `block-keywords-start-new-line.bad.al`. diff --git a/microsoft/knowledge/style/caption-required-on-page-fields.bad.al b/microsoft/knowledge/style/caption-required-on-page-fields.bad.al new file mode 100644 index 0000000..bd12f36 --- /dev/null +++ b/microsoft/knowledge/style/caption-required-on-page-fields.bad.al @@ -0,0 +1,13 @@ +table 50253 "Sample Caption Bad" +{ + fields + { + field(1; "Customer No."; Code[20]) + { + } + field(2; "Is Active"; Boolean) + { + Caption = ''; + } + } +} diff --git a/microsoft/knowledge/style/caption-required-on-page-fields.good.al b/microsoft/knowledge/style/caption-required-on-page-fields.good.al new file mode 100644 index 0000000..7de715b --- /dev/null +++ b/microsoft/knowledge/style/caption-required-on-page-fields.good.al @@ -0,0 +1,17 @@ +table 50252 "Sample Caption Good" +{ + fields + { + field(1; "Customer No."; Code[20]) + { + Caption = 'Customer No.'; + } + field(2; "Enabled"; Boolean) + { + } + field(3; Amount; Decimal) + { + CaptionClass = '3,5,' + 'USD'; + } + } +} diff --git a/microsoft/knowledge/style/caption-required-on-page-fields.md b/microsoft/knowledge/style/caption-required-on-page-fields.md new file mode 100644 index 0000000..e3a69c3 --- /dev/null +++ b/microsoft/knowledge/style/caption-required-on-page-fields.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: style +keywords: [caption, page-field, aa0225, aa0226, codecop, captionclass] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Every page field needs a `Caption` (CodeCop AA0225/AA0226) + +## Description + +CodeCop AA0225 and AA0226 require every field control to expose a `Caption` property, separately from the field's source name. The caption is what the user sees as the column header or label; the source name is what the code uses to reference the field. Without an explicit `Caption`, AL falls back to the source field's caption — which may be wrong for the page's context — or to the field name itself in code casing, which surfaces internal naming to users and to translators. + +Acceptable exceptions: a field whose caption is inherited via `CaptionClass = '3,5,' + CurrencyCode` (or another CaptionClass formula) does not need a literal `Caption`; the formula provides it. API pages and test pages may omit captions because their consumers are not human users. Boolean fields whose name already reads as a sentence — `Enabled`, `Posted`, `Released` — do not need a redundant Caption that repeats the name. + +## Best Practice + +`Caption = 'Customer No.';` paired with `ToolTip = 'Specifies …';`. Captions are short, noun-phrase, title-case for primary labels; sentence-case is allowed for descriptive labels that read as a sentence fragment. + +See sample: `caption-required-on-page-fields.good.al`. + +## Anti Pattern + +A field control with no `Caption` and no `CaptionClass`, or `Caption = '';`. The user sees the internal identifier as the column header and the translation pipeline has nothing to translate. + +See sample: `caption-required-on-page-fields.bad.al`. diff --git a/microsoft/knowledge/style/case-action-on-line-after-possibility.bad.al b/microsoft/knowledge/style/case-action-on-line-after-possibility.bad.al new file mode 100644 index 0000000..e4fbe58 --- /dev/null +++ b/microsoft/knowledge/style/case-action-on-line-after-possibility.bad.al @@ -0,0 +1,16 @@ +codeunit 50241 "Sample Case Format Bad" +{ + procedure Translate(Letter: Char): Code[10] + var + Letter2: Code[10]; + begin + case Letter of + 'A': Letter2 := '10'; + 'B': Letter2 := '11'; + 'C': begin Letter2 := '12'; DoSomething(); end; + end; + exit(Letter2); + end; + + local procedure DoSomething() begin end; +} diff --git a/microsoft/knowledge/style/case-action-on-line-after-possibility.good.al b/microsoft/knowledge/style/case-action-on-line-after-possibility.good.al new file mode 100644 index 0000000..a7ff731 --- /dev/null +++ b/microsoft/knowledge/style/case-action-on-line-after-possibility.good.al @@ -0,0 +1,21 @@ +codeunit 50240 "Sample Case Format Good" +{ + procedure Translate(Letter: Char): Code[10] + var + Letter2: Code[10]; + begin + case Letter of + 'A': + Letter2 := '10'; + 'B': + Letter2 := '11'; + 'C': begin + Letter2 := '12'; + DoSomething(); + end; + end; + exit(Letter2); + end; + + local procedure DoSomething() begin end; +} diff --git a/microsoft/knowledge/style/case-action-on-line-after-possibility.md b/microsoft/knowledge/style/case-action-on-line-after-possibility.md new file mode 100644 index 0000000..c928375 --- /dev/null +++ b/microsoft/knowledge/style/case-action-on-line-after-possibility.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [case, statement, formatting, possibility, action, line-break] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# `case` action goes on the line after the possibility + +## Description + +In an AL `case` statement, the action for each label is written on the line that follows the label, not on the same line. `'A': Letter2 := '10';` on a single line is the discouraged form; the convention is `'A':` on one line and `Letter2 := '10';` on the next, indented one level deeper. The exception is when the action is a `begin … end` block — there the `begin` follows the colon on the same line, consistent with the rule for `then begin` / `else begin` / `do begin`. + +## Best Practice + +Each case label sits on its own line, terminated by `:`. The action below it is indented; multi-statement actions open with `begin` on the label line and close with `end;` on its own line. + +See sample: `case-action-on-line-after-possibility.good.al`. + +## Anti Pattern + +`'A': Letter2 := '10';` (single-line label and action), and `'C': begin Letter2 := '12'; DoSomething(); end;` (everything on one line including the block body). Both defeat per-line diff review and crowd the control flow. + +See sample: `case-action-on-line-after-possibility.bad.al`. diff --git a/microsoft/knowledge/style/error-passes-parameters-directly-not-strsubstno.bad.al b/microsoft/knowledge/style/error-passes-parameters-directly-not-strsubstno.bad.al new file mode 100644 index 0000000..4d47c31 --- /dev/null +++ b/microsoft/knowledge/style/error-passes-parameters-directly-not-strsubstno.bad.al @@ -0,0 +1,15 @@ +codeunit 50207 "Sample Error Params Bad" +{ + var + CustomerNotFoundErr: Label 'Customer %1 does not exist.'; + + procedure CheckCustomer(CustomerNo: Code[20]) + var + Customer: Record Customer; + begin + if not Customer.Get(CustomerNo) then + Error(StrSubstNo(CustomerNotFoundErr, CustomerNo)); + if not Customer.Get(CustomerNo) then + Error('Customer ' + CustomerNo + ' not found'); + end; +} diff --git a/microsoft/knowledge/style/error-passes-parameters-directly-not-strsubstno.good.al b/microsoft/knowledge/style/error-passes-parameters-directly-not-strsubstno.good.al new file mode 100644 index 0000000..96e3d9c --- /dev/null +++ b/microsoft/knowledge/style/error-passes-parameters-directly-not-strsubstno.good.al @@ -0,0 +1,13 @@ +codeunit 50206 "Sample Error Params Good" +{ + var + CustomerNotFoundErr: Label 'Customer %1 does not exist.'; + + procedure CheckCustomer(CustomerNo: Code[20]) + var + Customer: Record Customer; + begin + if not Customer.Get(CustomerNo) then + Error(CustomerNotFoundErr, CustomerNo); + end; +} diff --git a/microsoft/knowledge/style/error-passes-parameters-directly-not-strsubstno.md b/microsoft/knowledge/style/error-passes-parameters-directly-not-strsubstno.md new file mode 100644 index 0000000..f8ee5bb --- /dev/null +++ b/microsoft/knowledge/style/error-passes-parameters-directly-not-strsubstno.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [error, strsubstno, label, parameters, concatenation, aa0231] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Pass parameters directly to `Error()`, do not wrap with `StrSubstNo` + +## Description + +`Error()` accepts a format string and a variable number of arguments — `Error(SomeLabelErr, Arg1, Arg2)`. The platform performs the substitution itself, which is the path the translation pipeline understands. Wrapping the same call as `Error(StrSubstNo(SomeLabelErr, Arg1, Arg2))` hides the placeholders from the platform and removes the format-string identity from the call-site, so analyzers cannot match the call to its label and translators lose the link between the formatted message and its template. The corresponding anti-pattern for hardcoded strings — `Error('Customer ' + CustomerNo + ' not found')` — is even worse: it builds an untranslatable, unanalyzable string at runtime. + +## Best Practice + +Declare a `Label` with the `Err` suffix and the appropriate `Comment` for placeholders, then call `Error(YourErr, arg1, arg2)`. The same rule applies to `Message`, `Confirm`, and other UI primitives: format string in, parameters as separate arguments, no `StrSubstNo` wrapper at the call site, no string concatenation. An `Error('')` (empty message) is acceptable when the calling code expects another layer to emit the actual diagnostic. + +See sample: `error-passes-parameters-directly-not-strsubstno.good.al`. + +## Anti Pattern + +`Error(StrSubstNo(CustomerNotFoundErr, CustomerNo))` and `Error(CustomerNotFoundErr + ': ' + CustomerNo)` both defeat the translation and analysis machinery. Reviewers should treat `StrSubstNo` appearing as an argument to `Error`, `Message`, `Confirm`, or `StrMenu` as an unconditional signal to rewrite. + +See sample: `error-passes-parameters-directly-not-strsubstno.bad.al`. diff --git a/microsoft/knowledge/style/event-subscriber-param-names-match-publisher.md b/microsoft/knowledge/style/event-subscriber-param-names-match-publisher.md new file mode 100644 index 0000000..aaf3729 --- /dev/null +++ b/microsoft/knowledge/style/event-subscriber-param-names-match-publisher.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: style +keywords: [event-subscriber, parameter-name, publisher, signature, eventsubscriber] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Event subscriber parameter names must match the publisher signature + +## Description + +In AL, an `[EventSubscriber]` procedure is bound to its publisher by event name and parameter list. The parameter names on the subscriber are not a style choice — they must match the names the publisher declared. The compiler validates the match at build time and emits an error if the subscriber renames a parameter. This means a reviewer cannot apply a generic "use better names" pass to subscriber parameters: `Sender`, `Rec`, `xRec`, `RunTrigger`, the table-and-field-specific parameter names a publisher emits — all are dictated by the publisher and must be reproduced verbatim. + +## Best Practice + +Copy the publisher signature exactly when declaring the subscriber. When in doubt, navigate to the publisher (`OnAfterValidateEvent`, `OnBeforePostSalesDoc`, etc.) and copy its parameter list. Style rules that apply to other locals — descriptive names, no spaces — do not apply to subscriber parameters. + +## Anti Pattern + +Renaming a publisher parameter to look prettier in the subscriber. The build breaks immediately. More insidiously, a parameter name that happens to match by coincidence in one event publisher but not in a similar one will compile in some versions of BC and fail in others when the publisher signature evolves. diff --git a/microsoft/knowledge/style/fieldcaption-not-fieldname-in-user-messages.bad.al b/microsoft/knowledge/style/fieldcaption-not-fieldname-in-user-messages.bad.al new file mode 100644 index 0000000..4328ce2 --- /dev/null +++ b/microsoft/knowledge/style/fieldcaption-not-fieldname-in-user-messages.bad.al @@ -0,0 +1,13 @@ +tableextension 50211 "Sample FieldCaption Bad" extends Customer +{ + procedure ConfirmAndAnnounce(): Boolean + var + UpdateLocationQst: Label 'Update %1?'; + UpdatedMsg: Label 'Updated %1.'; + begin + if not Confirm(UpdateLocationQst, true, FieldName("Location Code")) then + exit(false); + Message(UpdatedMsg, TableName()); + exit(true); + end; +} diff --git a/microsoft/knowledge/style/fieldcaption-not-fieldname-in-user-messages.good.al b/microsoft/knowledge/style/fieldcaption-not-fieldname-in-user-messages.good.al new file mode 100644 index 0000000..449b98d --- /dev/null +++ b/microsoft/knowledge/style/fieldcaption-not-fieldname-in-user-messages.good.al @@ -0,0 +1,13 @@ +tableextension 50210 "Sample FieldCaption Good" extends Customer +{ + procedure ConfirmAndAnnounce(): Boolean + var + UpdateLocationQst: Label 'Update %1?'; + UpdatedMsg: Label 'Updated %1.'; + begin + if not Confirm(UpdateLocationQst, true, FieldCaption("Location Code")) then + exit(false); + Message(UpdatedMsg, TableCaption()); + exit(true); + end; +} diff --git a/microsoft/knowledge/style/fieldcaption-not-fieldname-in-user-messages.md b/microsoft/knowledge/style/fieldcaption-not-fieldname-in-user-messages.md new file mode 100644 index 0000000..a74f1aa --- /dev/null +++ b/microsoft/knowledge/style/fieldcaption-not-fieldname-in-user-messages.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [fieldcaption, fieldname, tablecaption, tablename, translation, message, error] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use FieldCaption/TableCaption (not FieldName/TableName) in user-facing text + +## Description + +`FieldName` and `TableName` return the developer-facing identifier of a field or table — a fixed English string used in metadata and in code. `FieldCaption` and `TableCaption` return the translated, user-facing label declared by the field's or table's `Caption` property. When the value is embedded in a `Message`, `Error`, `Confirm`, or any other string shown to a user, the caption is the correct source. Otherwise the user sees the English internal name regardless of locale, and any caption change must be re-applied at every call site instead of being picked up from the single point of definition. + +## Best Practice + +Reach for `FieldCaption("Location Code")` and `TableCaption()` whenever the value flows into a UI primitive. The same rule applies to format parameters: `Error(SomeErr, FieldCaption("Status"), TableCaption(), "Status")` rather than `Error(SomeErr, FieldName("Status"), TableName(), "Status")`. The captions follow the user's language; the names do not. + +See sample: `fieldcaption-not-fieldname-in-user-messages.good.al`. + +## Anti Pattern + +`Message('Updated %1', TableName())` or `Confirm(UpdateLocationQst, true, FieldName("Location Code"))`. The user sees the English internal name in every locale, and any future rename of the caption fails to reach the message. + +See sample: `fieldcaption-not-fieldname-in-user-messages.bad.al`. diff --git a/microsoft/knowledge/style/file-name-object-type-pattern.md b/microsoft/knowledge/style/file-name-object-type-pattern.md new file mode 100644 index 0000000..dd8e0f1 --- /dev/null +++ b/microsoft/knowledge/style/file-name-object-type-pattern.md @@ -0,0 +1,24 @@ +--- +bc-version: [all] +domain: style +keywords: [file-name, object-type, suffix, naming-convention] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Name AL source files `..al` + +## Description + +Each AL source file holds a single object, and the file name is expected to be of the form `..al` — `CustomerCard.Page.al`, `PostSalesInvoice.Codeunit.al`, `NoSeriesTests.Codeunit.al`, `SalesHeader.TableExt.al`. The pattern makes object types greppable from a file listing and lets tooling — symbol search, project explorers, code generators — locate objects without parsing the AL source. Snake-case, lowercase-only, or type-less file names (`customer_page.al`, `tests_noSeries.al`, `PostSalesInvoiceLogic.al`) all break that contract. + +## Best Practice + +Use PascalCase for the object portion, no spaces, no underscores; the type segment is one of the AL object-type names — `Page`, `Codeunit`, `Table`, `TableExt`, `Report`, `Query`, `XmlPort`, `Enum`, `EnumExt`, `Interface`, `PermissionSet`, `PageExt`, `ReportExt`. The object portion should echo the object's name as it appears in AL. + +See sample (file-naming pattern is structural; no AL sample shipped here). + +## Anti Pattern + +`customer_page.al`, `PostSalesInvoiceLogic.al`, `tests_noSeries.al`. The first uses snake_case and lower-case; the second omits the type segment entirely; the third inverts the order and uses mixed casing. All three break grep, symbol search, and the implicit map between file system and AL object table. diff --git a/microsoft/knowledge/style/follow-api-page-naming-rules.bad.al b/microsoft/knowledge/style/follow-api-page-naming-rules.bad.al deleted file mode 100644 index 155d263..0000000 --- a/microsoft/knowledge/style/follow-api-page-naming-rules.bad.al +++ /dev/null @@ -1,22 +0,0 @@ -page 51103 "Style Sample ApiPage Bad" -{ - PageType = API; - APIPublisher = 'Contoso-App'; // hyphen not allowed - APIGroup = 'app_1'; // underscore not allowed - APIVersion = 'v2'; // missing minor version - EntityName = 'customers'; // should be singular - EntitySetName = 'customer'; // should be plural - SourceTable = Customer; - // DelayedInsert omitted; composite-key inserts misbehave - - layout - { - area(Content) - { - repeater(Group) - { - field(number; Rec."No.") { } - } - } - } -} diff --git a/microsoft/knowledge/style/follow-api-page-naming-rules.md b/microsoft/knowledge/style/follow-api-page-naming-rules.md deleted file mode 100644 index a203d38..0000000 --- a/microsoft/knowledge/style/follow-api-page-naming-rules.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: style -keywords: [api-page, apiversion, entityname, entitysetname, apipublisher, apigroup, delayedinsert] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# API pages follow strict naming and property rules that differ from regular pages - -## Description - -Pages declared `PageType = API` are exposed through the OData API surface. The platform enforces a set of conventions that regular pages do not share: `APIPublisher`, `APIGroup`, `EntityName`, and `EntitySetName` must be camelCase alphanumeric only — no spaces, hyphens, or underscores. `APIVersion` must match the pattern `vX.Y` (for example `v2.0`) or the literal `beta`. `EntityName` is the singular form (`customer`); `EntitySetName` is the plural (`customers`). `DelayedInsert = true` is effectively required for the OData insert workflow to behave correctly on composite keys. These rules are platform-enforced and tooling-enforced; violations produce runtime errors or consumer-visible inconsistencies rather than soft warnings. - -## Best Practice - -For every API page: camelCase alphanumeric API properties; `APIVersion` as `vX.Y` or `beta`; singular `EntityName` and plural `EntitySetName`; `DelayedInsert = true`. Keep these properties together near the top of the page definition so reviewers can check the set at a glance. - -See sample: `follow-api-page-naming-rules.good.al`. - -## Anti Pattern - -`APIPublisher = 'Contoso-App'` (hyphen rejected), `EntityName = 'customers'` and `EntitySetName = 'customer'` (swapped), `APIVersion = 'v2'` (missing minor version), `DelayedInsert` omitted. Each violation surfaces only when a consumer exercises the endpoint. - -See sample: `follow-api-page-naming-rules.bad.al`. diff --git a/microsoft/knowledge/style/function-call-parentheses-required.bad.al b/microsoft/knowledge/style/function-call-parentheses-required.bad.al new file mode 100644 index 0000000..2677716 --- /dev/null +++ b/microsoft/knowledge/style/function-call-parentheses-required.bad.al @@ -0,0 +1,11 @@ +codeunit 50213 "Sample Parens Bad" +{ + procedure Run() + var + Customer: Record Customer; + begin + Customer.Init; + if Customer.FindFirst then + Customer.Modify; + end; +} diff --git a/microsoft/knowledge/style/function-call-parentheses-required.good.al b/microsoft/knowledge/style/function-call-parentheses-required.good.al new file mode 100644 index 0000000..53f6f85 --- /dev/null +++ b/microsoft/knowledge/style/function-call-parentheses-required.good.al @@ -0,0 +1,11 @@ +codeunit 50212 "Sample Parens Good" +{ + procedure Run() + var + Customer: Record Customer; + begin + Customer.Init(); + if Customer.FindFirst() then + Customer.Modify(); + end; +} diff --git a/microsoft/knowledge/style/function-call-parentheses-required.md b/microsoft/knowledge/style/function-call-parentheses-required.md new file mode 100644 index 0000000..31fbaf8 --- /dev/null +++ b/microsoft/knowledge/style/function-call-parentheses-required.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [parentheses, function-call, method-call, aa0008, codecop] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Always write parentheses on procedure calls (CodeCop AA0008) + +## Description + +AL allows a parameterless procedure to be called without parentheses — `Customer.Init` instead of `Customer.Init()` — and the result is syntactically identical at runtime. CodeCop AA0008 still flags the parenthesis-less form. The reason is twofold: written without parentheses, a procedure call is visually indistinguishable from a property read, which makes BC code harder to scan; and the same identifier may exist as both a property and a procedure on different objects, so the parentheses are the only local signal that this is a call. The rule applies to every parameterless invocation, including `Init`, `Insert`, `Modify`, `Delete`, `DeleteAll`, `FindFirst`, `FindSet`, `Next`, `Get`, `CalcFields`, and user-defined procedures. + +## Best Practice + +Always write `()` on a procedure call, even when it takes no arguments: `Customer.Init();`, `TempBuffer.DeleteAll();`, `if Customer.FindFirst() then …`. The same applies inside expressions and as a condition. + +See sample: `function-call-parentheses-required.good.al`. + +## Anti Pattern + +`Customer.Init;`, `TempBuffer.DeleteAll;`, `if Customer.FindFirst then …`. Every one of those is an AA0008 violation. Reviewers should treat a parameterless procedure name appearing without parentheses as a defect, even though the compiler accepts it. + +See sample: `function-call-parentheses-required.bad.al`. diff --git a/microsoft/knowledge/style/include-comment-on-labels-with-placeholders.bad.al b/microsoft/knowledge/style/include-comment-on-labels-with-placeholders.bad.al deleted file mode 100644 index b114696..0000000 --- a/microsoft/knowledge/style/include-comment-on-labels-with-placeholders.bad.al +++ /dev/null @@ -1,15 +0,0 @@ -codeunit 51107 "Style Sample LabelProps Bad" -{ - procedure Example() - var - // Two placeholders, no Comment. The translator has to guess which - // identifier maps to %1 and which to %2. - CustomerLocationErr: Label 'Customer %1 not found in %2.'; - // URL without Locked: enters the localization pipeline, may be translated. - HttpsUrlLbl: Label 'https://example.com'; - CustomerNo: Code[20]; - LocationCode: Code[10]; - begin - Error(CustomerLocationErr, CustomerNo, LocationCode); - end; -} diff --git a/microsoft/knowledge/style/include-comment-on-labels-with-placeholders.good.al b/microsoft/knowledge/style/include-comment-on-labels-with-placeholders.good.al deleted file mode 100644 index d462a3f..0000000 --- a/microsoft/knowledge/style/include-comment-on-labels-with-placeholders.good.al +++ /dev/null @@ -1,14 +0,0 @@ -codeunit 51106 "Style Sample LabelProps Good" -{ - procedure Example() - var - CustomerNotFoundErr: Label 'Customer %1 does not exist for document %2.', - Comment = '%1 = Customer No., %2 = Document No.'; - HttpsProtocolTok: Label 'HTTPS', Locked = true; - ShortDescLbl: Label 'Description text', MaxLength = 50; - CustomerNo: Code[20]; - DocumentNo: Code[20]; - begin - Error(CustomerNotFoundErr, CustomerNo, DocumentNo); - end; -} diff --git a/microsoft/knowledge/style/include-comment-on-labels-with-placeholders.md b/microsoft/knowledge/style/include-comment-on-labels-with-placeholders.md deleted file mode 100644 index 9064608..0000000 --- a/microsoft/knowledge/style/include-comment-on-labels-with-placeholders.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: style -keywords: [label, placeholder, comment, locked, maxlength, localization] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Label placeholders need a Comment; locked strings need Locked = true - -## Description - -AL Labels accept optional properties — `Comment`, `Locked`, `MaxLength` — that travel with the string to localization. The Comment is the translator's only signal for what `%1` and `%2` mean; without it, `'Document %1 has errors in %2.'` translates unpredictably because the translator has to guess whether %1 is a document number, document type, or document name. `Locked = true` marks a string as non-translatable — URLs, JSON keys, short command tokens — and keeps the localization pipeline from translating literals that must stay verbatim. `MaxLength` limits how much of the label survives truncation. The Comment is required whenever placeholders are not self-evident; Locked is required on any non-text value. - -## Best Practice - -For placeholders, write `Comment = '%1 = Customer No., %2 = Document Type'` alongside the Label. For URLs, HTTP methods, JSON keys, and similar literals, set `Locked = true` and use the `Tok` suffix (see `apply-approved-label-suffixes`). For captions with a tight visual budget, set `MaxLength` to the enforceable length. When the placeholder meaning is obvious (`'Customer %1 not found.'`) the Comment is optional. - -See sample: `include-comment-on-labels-with-placeholders.good.al`. - -## Anti Pattern - -`CustomerLocationErr: Label 'Customer %1 not found in %2.';` with no Comment — translators will not know which identifier maps to which placeholder. `HttpsUrl: Label 'https://example.com';` with no Locked — the URL enters the localization pipeline and may be translated into a broken address. - -See sample: `include-comment-on-labels-with-placeholders.bad.al`. diff --git a/microsoft/knowledge/style/label-comment-explains-placeholders.bad.al b/microsoft/knowledge/style/label-comment-explains-placeholders.bad.al new file mode 100644 index 0000000..7f5893b --- /dev/null +++ b/microsoft/knowledge/style/label-comment-explains-placeholders.bad.al @@ -0,0 +1,11 @@ +codeunit 50203 "Sample Label Comment Bad" +{ + var + DocumentErrorErr: Label 'Document %1 has errors in %2.'; + ValidationErr: Label 'Field %1 in table %2 contains invalid value %3.'; + + procedure Validate(DocNo: Code[20]; Loc: Code[10]) + begin + Error(DocumentErrorErr, DocNo, Loc); + end; +} diff --git a/microsoft/knowledge/style/label-comment-explains-placeholders.good.al b/microsoft/knowledge/style/label-comment-explains-placeholders.good.al new file mode 100644 index 0000000..7318333 --- /dev/null +++ b/microsoft/knowledge/style/label-comment-explains-placeholders.good.al @@ -0,0 +1,12 @@ +codeunit 50202 "Sample Label Comment Good" +{ + var + CustomerNotFoundErr: Label 'Customer %1 does not exist for sales document %2.', Comment = '%1 = Customer No., %2 = Sales Header No.'; + ValidationErr: Label 'Field %1 in table %2 contains invalid value %3.', Comment = '%1 = Field Name, %2 = Table Caption, %3 = Field Value'; + CustomerSimpleLbl: Label 'Customer %1'; + + procedure Validate(CustNo: Code[20]; DocNo: Code[20]) + begin + Error(CustomerNotFoundErr, CustNo, DocNo); + end; +} diff --git a/microsoft/knowledge/style/label-comment-explains-placeholders.md b/microsoft/knowledge/style/label-comment-explains-placeholders.md new file mode 100644 index 0000000..43d9c1f --- /dev/null +++ b/microsoft/knowledge/style/label-comment-explains-placeholders.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [label, comment, placeholder, strsubstno, translation, aa0470] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Document each Label placeholder with the Comment parameter + +## Description + +`Label` and `TextConst` strings that contain placeholders (`%1`, `%2`, …) need a `Comment` parameter that names what each placeholder is. Translators do not see the call site, so without the Comment they cannot disambiguate `'Customer %1 not found in %2.'` — is `%2` a location code, a posting date, a company name? The pattern is `Comment = '%1 = , %2 = '`. The Comment is not required when the placeholder meaning is obvious from the surrounding text — `'Customer %1'` is unambiguously a Customer No. — but for any non-trivial label the Comment is a hard requirement. + +## Best Practice + +Write the Comment in the form `'%1 = Customer No., %2 = Sales Header No.'` — one entry per placeholder, matched by ordinal, named in the vocabulary of the BC domain. When the label is reused across multiple call sites, the Comment names the canonical meaning all call sites must conform to. + +See sample: `label-comment-explains-placeholders.good.al`. + +## Anti Pattern + +A label with two or more placeholders and no Comment, leaving the translator to guess. Equally bad is a Comment that only restates the placeholders (`'%1 and %2 are values'`) without naming what they are. Both fail in translation: the localized string ends up grammatically or semantically wrong, and the bug surfaces only in a non-English tenant. + +See sample: `label-comment-explains-placeholders.bad.al`. diff --git a/microsoft/knowledge/style/label-locked-for-non-translatable.bad.al b/microsoft/knowledge/style/label-locked-for-non-translatable.bad.al new file mode 100644 index 0000000..0ec72e8 --- /dev/null +++ b/microsoft/knowledge/style/label-locked-for-non-translatable.bad.al @@ -0,0 +1,7 @@ +codeunit 50205 "Sample Locked Label Bad" +{ + var + HttpsUrl: Label 'https://example.com'; + GetVerbTok: Label 'GET'; + JsonTypeLbl: Label 'application/json'; +} diff --git a/microsoft/knowledge/style/label-locked-for-non-translatable.good.al b/microsoft/knowledge/style/label-locked-for-non-translatable.good.al new file mode 100644 index 0000000..02b35d9 --- /dev/null +++ b/microsoft/knowledge/style/label-locked-for-non-translatable.good.al @@ -0,0 +1,8 @@ +codeunit 50204 "Sample Locked Label Good" +{ + var + GetMethodTok: Label 'GET', Locked = true; + ContentTypeJsonTok: Label 'application/json', Locked = true; + ApiBaseUrlTok: Label 'https://api.contoso.com/v1', Locked = true; + TelemetryStartTxt: Label 'Operation started for %1.', Locked = true; +} diff --git a/microsoft/knowledge/style/label-locked-for-non-translatable.md b/microsoft/knowledge/style/label-locked-for-non-translatable.md new file mode 100644 index 0000000..77f054b --- /dev/null +++ b/microsoft/knowledge/style/label-locked-for-non-translatable.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [label, locked, translation, token, url, json, xml] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Set `Locked = true` on Labels that must not be translated + +## Description + +A `Label` is by default surfaced to translators and rewritten per locale. That is wrong for strings that are not natural language: HTTP verbs (`GET`, `PUT`), URL fragments, JSON/XML snippets, content-type strings, GUIDs, application keys, and field tokens used by integrations. Translating these breaks the integration the moment a non-English tenant runs the code. The `Locked = true` parameter on the Label declaration tells the translation pipeline to keep the string verbatim, and signals to reviewers that the value is part of a wire-level contract rather than display text. + +## Best Practice + +Pair `Locked = true` with the `Tok` suffix for short tokens (`GetMethodTok: Label 'GET', Locked = true;`) and with the `Txt` suffix for telemetry strings that contain format placeholders but should not be localized. The `Locked` parameter and the `Tok` / `Txt` suffix together make the intent unambiguous. + +See sample: `label-locked-for-non-translatable.good.al`. + +## Anti Pattern + +`HttpsUrl: Label 'https://example.com';` or `ContentTypeTok: Label 'application/json';` declared without `Locked = true`. The translator localizes them, the integration fails in production for the affected tenant, and the failure is invisible in the developer's English-locale tests. + +See sample: `label-locked-for-non-translatable.bad.al`. diff --git a/microsoft/knowledge/style/label-suffix-approved-list.bad.al b/microsoft/knowledge/style/label-suffix-approved-list.bad.al new file mode 100644 index 0000000..6b227de --- /dev/null +++ b/microsoft/knowledge/style/label-suffix-approved-list.bad.al @@ -0,0 +1,14 @@ +codeunit 50201 "Sample Label Suffix Bad" +{ + var + CannotDeleteLine: Label 'Cannot delete this line.'; + Text000: Label 'Update complete'; + UpdateLocation: Label 'Update location?'; + WrongSuffixTok: Label 'Customer %1 not found.'; + + procedure ShowMessages() + begin + Error(WrongSuffixTok, '10000'); + Message(Text000); + end; +} diff --git a/microsoft/knowledge/style/label-suffix-approved-list.good.al b/microsoft/knowledge/style/label-suffix-approved-list.good.al new file mode 100644 index 0000000..f3ec561 --- /dev/null +++ b/microsoft/knowledge/style/label-suffix-approved-list.good.al @@ -0,0 +1,15 @@ +codeunit 50200 "Sample Label Suffix Good" +{ + var + UpdateCompleteMsg: Label 'Update complete.'; + CustomerNotFoundErr: Label 'Customer %1 does not exist.'; + DeleteRecordQst: Label 'Delete this record?'; + CustomerNameLbl: Label 'Customer Name'; + GetMethodTok: Label 'GET', Locked = true; + TelemetryStartedTxt: Label 'Operation started for customer %1.', Locked = true; + + procedure ShowMessage() + begin + Message(UpdateCompleteMsg); + end; +} diff --git a/microsoft/knowledge/style/label-suffix-approved-list.md b/microsoft/knowledge/style/label-suffix-approved-list.md new file mode 100644 index 0000000..8e937f3 --- /dev/null +++ b/microsoft/knowledge/style/label-suffix-approved-list.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [label, textconst, suffix, aa0074, codecop, msg, err, qst, lbl, tok] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use approved suffixes on Label and TextConst names (CodeCop AA0074) + +## Description + +CodeCop AA0074 flags `Label` and `TextConst` identifiers that do not end with an approved usage suffix. The suffix signals at the call site how the text is consumed and what translation behaviour it should get. The approved suffixes and their intended usage are: `Msg` for text shown via `Message()`; `Err` for text passed to `Error()`; `Qst` for text used with `Confirm` or `StrMenu`; `Lbl` for captions and tooltips; `Tok` for short tokens such as `'GET'`, `'PUT'`, `'HTTPS'`, GUIDs, or JSON/XML snippets that are not translated (typically with `Locked = true`); and `Txt` for general text including telemetry messages. A `Label` named `Text000` or `CannotDeleteLine` without a suffix violates the rule, regardless of how readable the prose is. + +## Best Practice + +Pick the suffix that matches the call where the label is consumed: `UpdateCompleteMsg` for `Message(...)`, `CustomerNotFoundErr` for `Error(...)`, `DeleteRecordQst` for `Confirm(...)`, `CustomerNameLbl` for tooltips and captions, `GetMethodTok` for locked tokens, `TelemetryDataTxt` for telemetry payloads. Suffix choices between `Tok`, `Lbl`, `Txt`, and `Msg` are judgment calls when the suffix is valid for the usage — what matters is that the suffix is on the approved list and matches the actual call. + +See sample: `label-suffix-approved-list.good.al`. + +## Anti Pattern + +A `Label` declared with no suffix (`CannotDeleteLine: Label '…';`), a generic name (`Text000: Label '…';`), or a suffix that contradicts the usage (`WrongSuffixTok: Label 'Customer %1 not found.'` then passed to `Error()`). All three trip AA0074 or its reviewers and obscure the call-site contract. + +See sample: `label-suffix-approved-list.bad.al`. diff --git a/microsoft/knowledge/style/lowercase-reserved-keywords.bad.al b/microsoft/knowledge/style/lowercase-reserved-keywords.bad.al new file mode 100644 index 0000000..83d5994 --- /dev/null +++ b/microsoft/knowledge/style/lowercase-reserved-keywords.bad.al @@ -0,0 +1,14 @@ +codeunit 50245 "Sample Upper Keywords Bad" +{ + procedure Walk(VAR Customer: Record Customer) + VAR + Found: Boolean; + BEGIN + IF Customer.FindSet() THEN + REPEAT + Found := TRUE; + UNTIL Customer.Next() = 0; + IF Found THEN + EXIT; + END; +} diff --git a/microsoft/knowledge/style/lowercase-reserved-keywords.good.al b/microsoft/knowledge/style/lowercase-reserved-keywords.good.al new file mode 100644 index 0000000..25fb20e --- /dev/null +++ b/microsoft/knowledge/style/lowercase-reserved-keywords.good.al @@ -0,0 +1,14 @@ +codeunit 50244 "Sample Lower Keywords Good" +{ + procedure Walk(var Customer: Record Customer) + var + Found: Boolean; + begin + if Customer.FindSet() then + repeat + Found := true; + until Customer.Next() = 0; + if Found then + exit; + end; +} diff --git a/microsoft/knowledge/style/lowercase-reserved-keywords.md b/microsoft/knowledge/style/lowercase-reserved-keywords.md new file mode 100644 index 0000000..f14b973 --- /dev/null +++ b/microsoft/knowledge/style/lowercase-reserved-keywords.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: style +keywords: [reserved-keyword, lowercase, aa0241, codecop, if, then, begin] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Reserved keywords are written in lowercase (CodeCop AA0241) + +## Description + +CodeCop AA0241 requires reserved AL keywords — `if`, `then`, `else`, `begin`, `end`, `var`, `procedure`, `local`, `internal`, `for`, `while`, `repeat`, `until`, `case`, `of`, `do`, `not`, `and`, `or`, `exit`, `break`, `skip`, `quit`, and the rest — to be lowercase. Old Navision and C/AL code used `IF…THEN…BEGIN…END` in uppercase, and that style still lingers in training data and legacy modules. New AL code is lowercase. The rule applies to keywords only — type names (`Record`, `Codeunit`, `Integer`), property names (`Caption`, `ToolTip`), and identifiers are unaffected. + +Test codeunits that retain legacy uppercase forms (`OPENEDIT`, `ASSERTERROR`, `VALUE`) are an accepted exception: the test framework historically uses those identifiers and rewriting them brings no benefit. The rule applies to new code in modified lines, not to long-standing test patterns. + +## Best Practice + +Write keywords lowercase: `if Condition then begin … end;`, `repeat … until Found;`, `for i := 1 to N do …`. The standard AL formatter normalizes casing automatically. + +See sample: `lowercase-reserved-keywords.good.al`. + +## Anti Pattern + +`IF Condition THEN BEGIN DoSomething(); END;`, `REPEAT GetNext(); UNTIL Found;`. Uppercase keywords trip AA0241 and signal C/AL-era code that has not been modernized. + +See sample: `lowercase-reserved-keywords.bad.al`. diff --git a/microsoft/knowledge/style/match-optioncaption-count-to-optionmembers.bad.al b/microsoft/knowledge/style/match-optioncaption-count-to-optionmembers.bad.al deleted file mode 100644 index 4931672..0000000 --- a/microsoft/knowledge/style/match-optioncaption-count-to-optionmembers.bad.al +++ /dev/null @@ -1,22 +0,0 @@ -table 51113 "Style Sample Option Bad" -{ - fields - { - field(1; "Entry No."; Integer) { } - field(10; Priority; Option) - { - // Four members, three captions. Critical renders with no caption. - OptionMembers = Low,Medium,High,Critical; - OptionCaption = 'Low,Medium,High'; - } - field(20; Status; Option) - { - // Missing OptionCaption entirely. - OptionMembers = Open,Released,Pending; - } - } - keys - { - key(PK; "Entry No.") { Clustered = true; } - } -} diff --git a/microsoft/knowledge/style/match-optioncaption-count-to-optionmembers.md b/microsoft/knowledge/style/match-optioncaption-count-to-optionmembers.md deleted file mode 100644 index 6f81865..0000000 --- a/microsoft/knowledge/style/match-optioncaption-count-to-optionmembers.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: style -keywords: [option, optionmembers, optioncaption, aa0221, aa0223, aa0224] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# OptionCaption must list exactly as many captions as OptionMembers - -## Description - -Option fields declare their values in `OptionMembers` and their localized display text in `OptionCaption`. The two lists are positionally paired — the Nth caption maps to the Nth member — and a mismatch either in count or in intent produces a field that renders blank for some values or shows the wrong caption for others. CodeCop rules AA0221, AA0223, and AA0224 flag the variants of this mistake: missing OptionCaption entirely on non-table-sourced option fields, OptionCaption with a different element count than OptionMembers, and OptionCaption content that does not correspond to the member names. - -## Best Practice - -Whenever OptionMembers is declared, declare OptionCaption with the same number of entries in the same order. For table-sourced option fields, the base table's caption applies and a per-page override is usually unnecessary — the rule applies to option fields defined in pages, reports, and non-table sources. - -See sample: `match-optioncaption-count-to-optionmembers.good.al`. - -## Anti Pattern - -`OptionMembers = Low,Medium,High,Critical;` paired with `OptionCaption = 'Low,Medium,High';` — three captions for four members. `Critical` rows render with the empty caption, or fall back to the member name, depending on where the option is displayed. - -See sample: `match-optioncaption-count-to-optionmembers.bad.al`. diff --git a/microsoft/knowledge/style/name-files-as-object-dot-type-dot-al.md b/microsoft/knowledge/style/name-files-as-object-dot-type-dot-al.md deleted file mode 100644 index 8dd4f56..0000000 --- a/microsoft/knowledge/style/name-files-as-object-dot-type-dot-al.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -bc-version: [all] -domain: style -keywords: [file-name, convention, object-type, al-project] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Name AL files as `..al` - -## Description - -Business Central AL projects follow a consistent file-naming convention: the file name is the object's name, followed by a dot, followed by the object type (`Page`, `Codeunit`, `Table`, `Report`, `Enum`, etc.), followed by `.al`. `CustomerCard.Page.al`, `PostSalesInvoice.Codeunit.al`, `SalesLine.Table.al`. The convention produces an alphabetically-ordered folder that groups all of an entity's objects (`SalesLine.Table.al`, `SalesLine.TableExt.al`, `SalesLineCard.Page.al`) next to each other, and makes navigation by file name in large repos predictable. - -## Best Practice - -Match the file name to the object declaration: PascalCase name, type segment, `.al`. Use `TableExt`, `PageExt`, `EnumExt` for the corresponding extension types. When multiple objects share a file (generally discouraged), name the file after the primary object. - -## Anti Pattern - -`customer_page.al`, `PostSalesInvoiceLogic.al`, `tests_noSeries.al` — all three violate the convention. The first uses snake_case, the second adds a descriptive suffix after the object name, the third prefixes the type instead of suffixing it. Tooling that expects the convention (AL-Go scaffolding, navigation helpers, diff conventions) then misbehaves on these files. diff --git a/microsoft/knowledge/style/named-invocations-not-object-ids.bad.al b/microsoft/knowledge/style/named-invocations-not-object-ids.bad.al new file mode 100644 index 0000000..47e25d8 --- /dev/null +++ b/microsoft/knowledge/style/named-invocations-not-object-ids.bad.al @@ -0,0 +1,12 @@ +codeunit 50209 "Sample Named Invocations Bad" +{ + procedure ShowShipmentLines(var SalesShptLine: Record "Sales Shipment Line") + begin + Page.RunModal(525, SalesShptLine); + end; + + procedure RunInvoiceReport() + begin + Report.Run(206, true); + end; +} diff --git a/microsoft/knowledge/style/named-invocations-not-object-ids.good.al b/microsoft/knowledge/style/named-invocations-not-object-ids.good.al new file mode 100644 index 0000000..8586984 --- /dev/null +++ b/microsoft/knowledge/style/named-invocations-not-object-ids.good.al @@ -0,0 +1,12 @@ +codeunit 50208 "Sample Named Invocations Good" +{ + procedure ShowShipmentLines(var SalesShptLine: Record "Sales Shipment Line") + begin + Page.RunModal(Page::"Posted Sales Shipment Lines", SalesShptLine); + end; + + procedure RunInvoiceReport() + begin + Report.Run(Report::"Sales - Invoice", true); + end; +} diff --git a/microsoft/knowledge/style/named-invocations-not-object-ids.md b/microsoft/knowledge/style/named-invocations-not-object-ids.md new file mode 100644 index 0000000..413dfc2 --- /dev/null +++ b/microsoft/knowledge/style/named-invocations-not-object-ids.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [page, report, codeunit, runmodal, run, object-id, named-invocation] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Call objects by name, not by numeric ID + +## Description + +`Page.RunModal`, `Report.Run`, `Codeunit.Run`, and the `Page::`, `Report::`, `Codeunit::`, `Table::`, `XmlPort::` selectors accept either a numeric ID or a named alias. The named form — `Page::"Posted Sales Shipment Lines"`, `Report::"Sales - Invoice"` — is the one to use. Numeric IDs are an implementation detail that change with renumbering, do not survive a rename, and carry no signal to a reader about what the call actually does. The compiler resolves named aliases at build time, so the named form is no slower than the numeric form. + +## Best Practice + +When invoking an object whose named alias is available in the same app (or in a dependency the current app already references), use the named form: `Page.RunModal(Page::"Posted Sales Shipment Lines", SalesShptLine)`, `Report.Run(Report::"Sales - Invoice", true)`. The same applies to `Codeunit.Run`, `XmlPort.Run`, `Query.Open`, and any platform method that takes an object reference. The named form makes diffs reviewable — a rename is visible — and makes log output and stack traces interpretable. + +See sample: `named-invocations-not-object-ids.good.al`. + +## Anti Pattern + +`Page.RunModal(525, …)` or `Report.Run(206, true)`. The numeric form is unreadable, fragile across renumbering, and breaks every search that looks for callers of a named object. + +See sample: `named-invocations-not-object-ids.bad.al`. diff --git a/microsoft/knowledge/style/no-begin-end-around-single-statement.bad.al b/microsoft/knowledge/style/no-begin-end-around-single-statement.bad.al new file mode 100644 index 0000000..1803e1c --- /dev/null +++ b/microsoft/knowledge/style/no-begin-end-around-single-statement.bad.al @@ -0,0 +1,11 @@ +codeunit 50237 "Sample Single Stmt Bad" +{ + procedure Validate(IsAssemblyOutputLine: Boolean) + var + SalesLine: Record "Sales Line"; + begin + if IsAssemblyOutputLine then begin + SalesLine.TestField("Order Line No.", 0); + end; + end; +} diff --git a/microsoft/knowledge/style/no-begin-end-around-single-statement.good.al b/microsoft/knowledge/style/no-begin-end-around-single-statement.good.al new file mode 100644 index 0000000..684a86c --- /dev/null +++ b/microsoft/knowledge/style/no-begin-end-around-single-statement.good.al @@ -0,0 +1,10 @@ +codeunit 50236 "Sample Single Stmt Good" +{ + procedure Validate(IsAssemblyOutputLine: Boolean) + var + SalesLine: Record "Sales Line"; + begin + if IsAssemblyOutputLine then + SalesLine.TestField("Order Line No.", 0); + end; +} diff --git a/microsoft/knowledge/style/no-begin-end-around-single-statement.md b/microsoft/knowledge/style/no-begin-end-around-single-statement.md new file mode 100644 index 0000000..d7665f7 --- /dev/null +++ b/microsoft/knowledge/style/no-begin-end-around-single-statement.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [begin, end, single-statement, aa0013, codecop, compound] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Do not wrap a single statement in `begin … end` (CodeCop AA0013) + +## Description + +CodeCop AA0013 flags `begin … end` blocks that contain exactly one statement. The compound-block syntax exists to group multiple statements as a unit; using it for a single statement adds two lines and a level of nesting without adding meaning. `if IsAssemblyOutputLine then begin TestField("Order Line No.", 0); end;` should be `if IsAssemblyOutputLine then TestField("Order Line No.", 0);` — one statement, no block. The same logic applies after `else`, `for`, `while`, and `repeat`. + +## Best Practice + +A single statement following `then`, `else`, `do`, or a case label is written on its own line, indented one level, with no `begin … end`. Use `begin … end` only when there are two or more statements to group. + +See sample: `no-begin-end-around-single-statement.good.al`. + +## Anti Pattern + +`if Cond then begin OneCall(); end;` — single statement wrapped in a block. AA0013 flags it. The reviewer signal is "a `begin` followed by exactly one statement before its `end`." + +See sample: `no-begin-end-around-single-statement.bad.al`. diff --git a/microsoft/knowledge/style/no-else-after-terminating-statement.bad.al b/microsoft/knowledge/style/no-else-after-terminating-statement.bad.al new file mode 100644 index 0000000..eefa888 --- /dev/null +++ b/microsoft/knowledge/style/no-else-after-terminating-statement.bad.al @@ -0,0 +1,13 @@ +codeunit 50243 "Sample Redundant Else Bad" +{ + procedure Validate(IsAdjmtBinCodeChanged: Boolean) + var + AdjmtBinErr: Label 'Adjustment bin code change not allowed.'; + BinCodeErr: Label 'Bin code change not allowed.'; + begin + if IsAdjmtBinCodeChanged then + Error(AdjmtBinErr) + else + Error(BinCodeErr); + end; +} diff --git a/microsoft/knowledge/style/no-else-after-terminating-statement.good.al b/microsoft/knowledge/style/no-else-after-terminating-statement.good.al new file mode 100644 index 0000000..15a0941 --- /dev/null +++ b/microsoft/knowledge/style/no-else-after-terminating-statement.good.al @@ -0,0 +1,12 @@ +codeunit 50242 "Sample No Else Good" +{ + procedure Validate(IsAdjmtBinCodeChanged: Boolean) + var + AdjmtBinErr: Label 'Adjustment bin code change not allowed.'; + BinCodeErr: Label 'Bin code change not allowed.'; + begin + if IsAdjmtBinCodeChanged then + Error(AdjmtBinErr); + Error(BinCodeErr); + end; +} diff --git a/microsoft/knowledge/style/no-else-after-terminating-statement.md b/microsoft/knowledge/style/no-else-after-terminating-statement.md new file mode 100644 index 0000000..76d7ede --- /dev/null +++ b/microsoft/knowledge/style/no-else-after-terminating-statement.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [else, exit, break, skip, quit, error, terminating, control-flow] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Omit `else` when the `then` branch ends with `exit`, `break`, `skip`, `quit`, or `error` + +## Description + +When the `then` branch of an `if` ends in a terminating statement — `exit`, `break`, `skip`, `quit`, or `error` — the `else` branch becomes the natural fall-through. `if Cond then exit; DoX();` and `if Cond then exit else DoX();` are equivalent, and the second form adds a layer of nesting that the reader has to mentally flatten. The same applies to `Error(...)`: `if IsAdjmtBinCodeChanged() then Error(AdjmtErr) else Error(BinErr);` is better written as `if IsAdjmtBinCodeChanged() then Error(AdjmtErr); Error(BinErr);` — the second `Error` is always reached when the first branch is not taken. + +## Best Practice + +Drop the `else` when the `then` branch unconditionally exits the procedure or the enclosing loop. The body that would have been inside `else` becomes the unindented continuation. + +See sample: `no-else-after-terminating-statement.good.al`. + +## Anti Pattern + +An `if … then Error(…) else Error(…)` pair where both branches terminate. The `else` is structural noise — the reader cannot tell at a glance whether it exists to handle an actual continuation or simply mirrors the `then`. The fix is to drop `else` and let the second `Error` fall through naturally. + +See sample: `no-else-after-terminating-statement.bad.al`. diff --git a/microsoft/knowledge/style/no-space-before-method-parenthesis.bad.al b/microsoft/knowledge/style/no-space-before-method-parenthesis.bad.al new file mode 100644 index 0000000..b2f8295 --- /dev/null +++ b/microsoft/knowledge/style/no-space-before-method-parenthesis.bad.al @@ -0,0 +1,11 @@ +codeunit 50231 "Sample No Space Paren Bad" +{ + procedure Lookup(CustomerNo: Code[20]) + var + Customer: Record Customer; + GreetingMsg: Label 'Hello %1'; + begin + if Customer.Get ( CustomerNo ) then + Message ( GreetingMsg, Customer.Name ); + end; +} diff --git a/microsoft/knowledge/style/no-space-before-method-parenthesis.good.al b/microsoft/knowledge/style/no-space-before-method-parenthesis.good.al new file mode 100644 index 0000000..eb16dc3 --- /dev/null +++ b/microsoft/knowledge/style/no-space-before-method-parenthesis.good.al @@ -0,0 +1,11 @@ +codeunit 50230 "Sample No Space Paren Good" +{ + procedure Lookup(CustomerNo: Code[20]) + var + Customer: Record Customer; + GreetingMsg: Label 'Hello %1'; + begin + if Customer.Get(CustomerNo) then + Message(GreetingMsg, Customer.Name); + end; +} diff --git a/microsoft/knowledge/style/no-space-before-method-parenthesis.md b/microsoft/knowledge/style/no-space-before-method-parenthesis.md new file mode 100644 index 0000000..d7a2f69 --- /dev/null +++ b/microsoft/knowledge/style/no-space-before-method-parenthesis.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [spacing, parenthesis, method-call, aa0002, codecop] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# No space between a method name and its opening parenthesis (CodeCop AA0002) + +## Description + +CodeCop AA0002 forbids whitespace between a procedure/method name and its `(`. `Customer.Get(CustomerNo)` is correct; `Customer.Get (CustomerNo)` is not. The rule applies to user-defined procedures, system methods (`Insert`, `FindFirst`, `CalcFields`), trigger-style invocations, and the parenthesised cast/conversion forms (`Format(Value)`, `CopyStr(Source, 1, 10)`). The whitespace between `(` and the first argument, and between the last argument and `)`, is also forbidden by the same rule. + +## Best Practice + +`Customer.Get(CustomerNo)`, `Customer.SetFilter("No.", '%1', '*A*')`, `Message(GreetingMsg, UserName)`. The standard AL formatter enforces this automatically. + +See sample: `no-space-before-method-parenthesis.good.al`. + +## Anti Pattern + +`Customer.Get ( CustomerNo )`, `Message ( GreetingMsg, UserName )`. Both trip AA0002 and read as if the call had an extra unnamed parameter — a small but persistent friction every reader pays. + +See sample: `no-space-before-method-parenthesis.bad.al`. diff --git a/microsoft/knowledge/style/object-name-30-char-limit.md b/microsoft/knowledge/style/object-name-30-char-limit.md new file mode 100644 index 0000000..f0e96ee --- /dev/null +++ b/microsoft/knowledge/style/object-name-30-char-limit.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: style +keywords: [object-name, length, prefix, affix, 30-characters, appsource] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Keep object names within the 30-character platform limit + +## Description + +Business Central object names — for tables, pages, codeunits, reports, queries, XML ports, enums, and permission sets — are limited to 30 characters in total. AppSource and per-tenant extensions also have to carry a mandatory prefix or affix (typically 3–4 characters), which leaves roughly 26 characters for the descriptive part of the name. Names hitting the 30-character ceiling are routinely rejected at publish time, and over-aggressive abbreviation to fit (`CustLE`, `SIPoster`, `SalesInv`) makes the object name opaque to reviewers and to anyone reading dependency lists. The right move is to plan name length around the budget — descriptive base + prefix — not to discover the limit during AppSource validation. + +## Best Practice + +Choose a clear, descriptive name in the 20–26-character range and reserve the remaining characters for the mandatory app prefix. `"Customer Ledger Entry"`, `"Sales Invoice Posting"`, `"Sales Invoice"` are descriptive and well under the budget. When you genuinely need to abbreviate, prefer abbreviations that are already established in BC (`Cust.`, `Vend.`, `Gen. Jnl.`, `WHSE`) over ad-hoc shortenings. + +## Anti Pattern + +Names like `"CustLE"` or `"SIPoster"` that abbreviate beyond comprehensibility, or names like `"Customer Ledger Entry Posting Helper Codeunit"` that breach 30 characters and force a rename during publish. diff --git a/microsoft/knowledge/style/optioncaption-required-and-matches-membercount.bad.al b/microsoft/knowledge/style/optioncaption-required-and-matches-membercount.bad.al new file mode 100644 index 0000000..9d5269c --- /dev/null +++ b/microsoft/knowledge/style/optioncaption-required-and-matches-membercount.bad.al @@ -0,0 +1,17 @@ +table 50255 "Sample OptionCaption Bad" +{ + fields + { + field(1; Status; Option) + { + Caption = 'Status'; + OptionMembers = Open,Released,Pending; + } + field(2; Priority; Option) + { + Caption = 'Priority'; + OptionMembers = Low,Medium,High,Critical; + OptionCaption = 'Low,Medium,High'; + } + } +} diff --git a/microsoft/knowledge/style/match-optioncaption-count-to-optionmembers.good.al b/microsoft/knowledge/style/optioncaption-required-and-matches-membercount.good.al similarity index 55% rename from microsoft/knowledge/style/match-optioncaption-count-to-optionmembers.good.al rename to microsoft/knowledge/style/optioncaption-required-and-matches-membercount.good.al index fda6fa3..d0e9866 100644 --- a/microsoft/knowledge/style/match-optioncaption-count-to-optionmembers.good.al +++ b/microsoft/knowledge/style/optioncaption-required-and-matches-membercount.good.al @@ -1,21 +1,18 @@ -table 51112 "Style Sample Option Good" +table 50254 "Sample OptionCaption Good" { fields { - field(1; "Entry No."; Integer) { } - field(10; Priority; Option) - { - OptionMembers = Low,Medium,High,Critical; - OptionCaption = 'Low,Medium,High,Critical'; - } - field(20; Status; Option) + field(1; Status; Option) { + Caption = 'Status'; OptionMembers = Open,Released,Pending; OptionCaption = 'Open,Released,Pending'; } - } - keys - { - key(PK; "Entry No.") { Clustered = true; } + field(2; Priority; Option) + { + Caption = 'Priority'; + OptionMembers = Low,Medium,High,Critical; + OptionCaption = 'Low,Medium,High,Critical'; + } } } diff --git a/microsoft/knowledge/style/optioncaption-required-and-matches-membercount.md b/microsoft/knowledge/style/optioncaption-required-and-matches-membercount.md new file mode 100644 index 0000000..d961040 --- /dev/null +++ b/microsoft/knowledge/style/optioncaption-required-and-matches-membercount.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [optioncaption, option, member-count, aa0221, aa0223, aa0224] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Option fields need `OptionCaption`, and its element count must match `OptionMembers` (CodeCop AA0221/AA0223/AA0224) + +## Description + +CodeCop AA0221 requires an `OptionCaption` on every option-type field that is not sourced from a table column (table-sourced option fields inherit the captions of the underlying field). AA0223 and AA0224 add two integrity checks: the number of comma-separated entries in `OptionCaption` must equal the number of entries in `OptionMembers`, and each caption must align by position with its member. The position alignment is what the platform uses to translate option values — the `OptionMembers` list never changes per locale, the `OptionCaption` list does. A mismatch in count or order produces silent corruption: the option `Released` shows the caption that belongs to `Pending`, and the bug is locale-dependent. + +## Best Practice + +`OptionMembers = Open,Released,Pending;` and `OptionCaption = 'Open,Released,Pending';` — same count, same order. When adding a new member, update both lines in the same commit. + +See sample: `optioncaption-required-and-matches-membercount.good.al`. + +## Anti Pattern + +`OptionMembers = Open,Released,Pending;` with no `OptionCaption` at all (the user sees the raw English members and translation is impossible), or `OptionMembers = Low,Medium,High,Critical;` paired with `OptionCaption = 'Low,Medium,High';` — count mismatch, `Critical` displays as blank or carries the wrong caption depending on platform version. + +See sample: `optioncaption-required-and-matches-membercount.bad.al`. diff --git a/microsoft/knowledge/style/page-name-must-match-source-table.md b/microsoft/knowledge/style/page-name-must-match-source-table.md new file mode 100644 index 0000000..b8706cb --- /dev/null +++ b/microsoft/knowledge/style/page-name-must-match-source-table.md @@ -0,0 +1,24 @@ +--- +bc-version: [all] +domain: style +keywords: [page-name, source-table, misleading, naming] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# A page or view name must describe the table it shows + +## Description + +A page (or filtered page View) whose name references one entity but whose `SourceTable` is a different entity misleads every consumer of the object's metadata. A page named `"Items with Negative Inventory"` that sources `"Stockkeeping Unit"` looks like a list of items in the search bar and in role explorer, but presents stockkeeping-unit fields and behaviour. The fix is either to rename the page to match the source table — `"Stockkeeping Units with Negative Inventory"` — or to change the source table to the entity the name promises. The choice depends on which the actual users are asking for; the constraint is that the two MUST agree. + +The rule extends to filtered Views declared inside a page: the `View` name should describe the filter applied to the page's existing source, not introduce a different entity. + +## Best Practice + +Read the page name out loud and ask: "If a user typed this into the search bar, would they expect to see rows from ``?" If the answer is no, rename one side or the other. The same check applies whenever the source table changes — the name has to follow. + +## Anti Pattern + +`page "Items with Negative Inventory" { SourceTable = "Stockkeeping Unit"; … }`. The Tell-Don't-Ask name asserts items; the source contradicts it. Reviewers should flag every mismatch they spot, even when both sides "make sense individually" — they have to agree. diff --git a/microsoft/knowledge/style/pass-parameters-directly-to-error-no-strsubstno.bad.al b/microsoft/knowledge/style/pass-parameters-directly-to-error-no-strsubstno.bad.al deleted file mode 100644 index 947caa1..0000000 --- a/microsoft/knowledge/style/pass-parameters-directly-to-error-no-strsubstno.bad.al +++ /dev/null @@ -1,13 +0,0 @@ -codeunit 51115 "Style Sample ErrorParams Bad" -{ - procedure Fail(CustomerNo: Code[20]) - var - CustomerNotFoundErr: Label 'Customer %1 does not exist.', Comment = '%1 = Customer No.'; - begin - // Pre-built Text to Error: translation skipped, telemetry opaque. - Error(StrSubstNo(CustomerNotFoundErr, CustomerNo)); - - // Concatenation: translation skipped, hard-coded delimiters baked in. - Error('Customer ' + CustomerNo + ' not found'); - end; -} diff --git a/microsoft/knowledge/style/pass-parameters-directly-to-error-no-strsubstno.good.al b/microsoft/knowledge/style/pass-parameters-directly-to-error-no-strsubstno.good.al deleted file mode 100644 index 57a1775..0000000 --- a/microsoft/knowledge/style/pass-parameters-directly-to-error-no-strsubstno.good.al +++ /dev/null @@ -1,11 +0,0 @@ -codeunit 51114 "Style Sample ErrorParams Good" -{ - procedure Fail(CustomerNo: Code[20]; DocumentNo: Code[20]) - var - CustomerNotFoundErr: Label 'Customer %1 does not exist for document %2.', - Comment = '%1 = Customer No., %2 = Document No.'; - begin - // Label + arguments passed directly. Translations apply; telemetry classifies per field. - Error(CustomerNotFoundErr, CustomerNo, DocumentNo); - end; -} diff --git a/microsoft/knowledge/style/pass-parameters-directly-to-error-no-strsubstno.md b/microsoft/knowledge/style/pass-parameters-directly-to-error-no-strsubstno.md deleted file mode 100644 index b7d758b..0000000 --- a/microsoft/knowledge/style/pass-parameters-directly-to-error-no-strsubstno.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: style -keywords: [error, label, strsubstno, concatenation, telemetry, aa0216, aa0217] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Pass Error parameters directly to the Label; do not pre-build with StrSubstNo or concatenation - -## Description - -`Error` accepts a Label and its substitution parameters directly (`Error(CustomerNotFoundErr, CustomerNo, DocumentNo)`). Pre-building the message via `StrSubstNo` and passing the resulting Text, or concatenating parts with `+` and passing the result, compiles but produces two distinct regressions. The localization pipeline can only translate the Label; a pre-built Text is passed through untouched, so non-English users see the English template. Platform telemetry inspects the Label's placeholder arguments for DataClassification; a pre-built Text is opaque, so PII in the arguments is logged verbatim (see `strsubstno-prebuild-breaks-error-telemetry-classification` in the privacy domain). - -## Best Practice - -Declare the Label with placeholders and pass arguments directly to Error: `Error(CustomerNotFoundErr, CustomerNo, DocumentNo)`. Use `Comment` on the Label to document each placeholder (see `include-comment-on-labels-with-placeholders`). `Error('')` is acceptable when the caller is responsible for the surfaced error. - -See sample: `pass-parameters-directly-to-error-no-strsubstno.good.al`. - -## Anti Pattern - -`Error(StrSubstNo(CustomerNotFoundErr, CustomerNo))` — loses translation. `Error(CustomerNotFoundErr + ': ' + CustomerNo)` — loses translation, concatenates hard-coded delimiters. `Error('Customer ' + CustomerNo + ' not found')` — uses no Label at all. - -See sample: `pass-parameters-directly-to-error-no-strsubstno.bad.al`. diff --git a/microsoft/knowledge/style/prefix-temporary-record-variables-with-temp.bad.al b/microsoft/knowledge/style/prefix-temporary-record-variables-with-temp.bad.al deleted file mode 100644 index ba94df9..0000000 --- a/microsoft/knowledge/style/prefix-temporary-record-variables-with-temp.bad.al +++ /dev/null @@ -1,17 +0,0 @@ -codeunit 51105 "Style Sample TempPrefix Bad" -{ - procedure BuildWorkingSet() - var - WIPBuffer: Record "Job WIP Buffer" temporary; - Customer: Record Customer; - begin - // Call sites read as persistent. A reviewer cannot tell at a glance - // whether DeleteAll hits the database or the in-memory buffer. - WIPBuffer.DeleteAll(); - if Customer.FindSet() then - repeat - WIPBuffer.Init(); - WIPBuffer.Insert(); - until Customer.Next() = 0; - end; -} diff --git a/microsoft/knowledge/style/prefix-temporary-record-variables-with-temp.good.al b/microsoft/knowledge/style/prefix-temporary-record-variables-with-temp.good.al deleted file mode 100644 index 1f7d090..0000000 --- a/microsoft/knowledge/style/prefix-temporary-record-variables-with-temp.good.al +++ /dev/null @@ -1,16 +0,0 @@ -codeunit 51104 "Style Sample TempPrefix Good" -{ - procedure BuildWorkingSet() - var - TempJobWIPBuffer: Record "Job WIP Buffer" temporary; - Customer: Record Customer; - begin - // Every read site shows whether the variable is temporary. - TempJobWIPBuffer.DeleteAll(); - if Customer.FindSet() then - repeat - TempJobWIPBuffer.Init(); - TempJobWIPBuffer.Insert(); - until Customer.Next() = 0; - end; -} diff --git a/microsoft/knowledge/style/prefix-temporary-record-variables-with-temp.md b/microsoft/knowledge/style/prefix-temporary-record-variables-with-temp.md deleted file mode 100644 index 36d8a6e..0000000 --- a/microsoft/knowledge/style/prefix-temporary-record-variables-with-temp.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: style -keywords: [temporary, record, variable, prefix, naming, temp] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Prefix temporary record variables with "Temp" - -## Description - -A `Record X temporary` variable behaves differently from a persistent Record variable of the same type: Insert/Modify/Delete mutate an in-memory buffer, not the underlying table. Code that mixes persistent and temporary variables of the same type is a recurring source of data-loss bugs — a helper that does `DeleteAll` on what the caller believed was a temporary buffer wipes the real table. The convention across Business Central is to prefix every temporary record variable with `Temp` (`TempJobWIPBuffer`, `TempSalesLine`, `TempCustomer`) so the distinction is visible at every read site, not only at the declaration. - -## Best Practice - -Prefix every temporary-record variable with `Temp`. The prefix goes on the variable name, not the type; the `temporary` keyword remains on the declaration. Matching the prefix against the declaration makes it a one-line check in code review: if the name starts with `Temp`, the declaration ends in `temporary`, and vice versa. - -See sample: `prefix-temporary-record-variables-with-temp.good.al`. - -## Anti Pattern - -`WIPBuffer: Record "Job WIP Buffer" temporary` — the variable reads like a persistent record in every call site below the declaration. A reviewer scanning a mutation call (`WIPBuffer.DeleteAll()`) cannot tell from the call site whether the effect is in-memory or production. - -See sample: `prefix-temporary-record-variables-with-temp.bad.al`. diff --git a/microsoft/knowledge/style/require-parentheses-on-function-calls.bad.al b/microsoft/knowledge/style/require-parentheses-on-function-calls.bad.al deleted file mode 100644 index 8a0dba6..0000000 --- a/microsoft/knowledge/style/require-parentheses-on-function-calls.bad.al +++ /dev/null @@ -1,13 +0,0 @@ -codeunit 51119 "Style Sample Parentheses Bad" -{ - procedure Example(var Customer: Record Customer) - var - TempBuffer: Record "Integer" temporary; - begin - // Parentheses omitted. The call site reads like a field access. - Customer.Init; - TempBuffer.DeleteAll; - if Customer.FindFirst then - ; - end; -} diff --git a/microsoft/knowledge/style/require-parentheses-on-function-calls.good.al b/microsoft/knowledge/style/require-parentheses-on-function-calls.good.al deleted file mode 100644 index 358ffe5..0000000 --- a/microsoft/knowledge/style/require-parentheses-on-function-calls.good.al +++ /dev/null @@ -1,12 +0,0 @@ -codeunit 51118 "Style Sample Parentheses Good" -{ - procedure Example(var Customer: Record Customer) - var - TempBuffer: Record "Integer" temporary; - begin - Customer.Init(); - TempBuffer.DeleteAll(); - if Customer.FindFirst() then - ; - end; -} diff --git a/microsoft/knowledge/style/require-parentheses-on-function-calls.md b/microsoft/knowledge/style/require-parentheses-on-function-calls.md deleted file mode 100644 index 3c1916b..0000000 --- a/microsoft/knowledge/style/require-parentheses-on-function-calls.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: style -keywords: [parentheses, function-call, aa0008, invocation] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Every function call carries parentheses, even with no arguments - -## Description - -AL allows `Customer.Init`, `TempBuffer.DeleteAll`, and `Customer.FindFirst` without trailing parentheses when the method takes no parameters. CodeCop rule AA0008 requires the parentheses anyway. The reason is readability: without `()`, the reader has to know the member is a method and not a property — an ambiguity that resolves differently for the platform's own APIs (FindFirst is a method; `Name` is a field). With `()`, the call site is visibly a method invocation and a simple grep for `Init(` or `DeleteAll(` finds every usage. - -## Best Practice - -Always write parentheses on method calls, even when empty: `Customer.Init()`, `TempBuffer.DeleteAll()`, `if Customer.FindFirst() then`. Apply the rule to platform methods and to user-defined procedures alike. - -See sample: `require-parentheses-on-function-calls.good.al`. - -## Anti Pattern - -`Customer.Init;`, `TempBuffer.DeleteAll;`, `if Customer.FindFirst then` — all three compile but obscure what is a call and what is a field access. The inconsistency compounds when the same codebase has both conventions. - -See sample: `require-parentheses-on-function-calls.bad.al`. diff --git a/microsoft/knowledge/style/single-space-after-not-operator.bad.al b/microsoft/knowledge/style/single-space-after-not-operator.bad.al new file mode 100644 index 0000000..c7143e7 --- /dev/null +++ b/microsoft/knowledge/style/single-space-after-not-operator.bad.al @@ -0,0 +1,11 @@ +codeunit 50233 "Sample Not Spacing Bad" +{ + procedure Check(): Boolean + var + Customer: Record Customer; + begin + if NOT Customer.IsEmpty() then + exit(true); + exit(false); + end; +} diff --git a/microsoft/knowledge/style/single-space-after-not-operator.good.al b/microsoft/knowledge/style/single-space-after-not-operator.good.al new file mode 100644 index 0000000..92d8f33 --- /dev/null +++ b/microsoft/knowledge/style/single-space-after-not-operator.good.al @@ -0,0 +1,11 @@ +codeunit 50232 "Sample Not Spacing Good" +{ + procedure Check(): Boolean + var + Customer: Record Customer; + begin + if not Customer.IsEmpty() then + exit(true); + exit(false); + end; +} diff --git a/microsoft/knowledge/style/single-space-after-not-operator.md b/microsoft/knowledge/style/single-space-after-not-operator.md new file mode 100644 index 0000000..c5d2077 --- /dev/null +++ b/microsoft/knowledge/style/single-space-after-not-operator.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [spacing, not, operator, aa0003, codecop] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Exactly one space between `not` and its argument (CodeCop AA0003) + +## Description + +CodeCop AA0003 requires exactly one space between the `not` operator and the expression it negates. `if not Customer.FindFirst() then …` is correct; `if not Customer.FindFirst() then …` (two spaces) and `if notCustomer.FindFirst() then …` (zero — which fails parsing anyway) are not. The rule is also the place where uppercase `NOT` is flagged in combination with CodeCop AA0241 (reserved keywords must be lowercase): `if NOT Condition then` is doubly wrong. + +## Best Practice + +`if not Condition then`, `if not Customer.IsEmpty() then`, `exit(not Result)`. One space, lowercase keyword, no parentheses around the bare boolean. + +See sample: `single-space-after-not-operator.good.al`. + +## Anti Pattern + +`if NOT condition then`, `if not condition then`, `if !condition then` (which is not even AL — `!` is not a negation operator in AL). All three either trip AA0003 / AA0241 or fail to compile. + +See sample: `single-space-after-not-operator.bad.al`. diff --git a/microsoft/knowledge/style/single-space-around-binary-operators.bad.al b/microsoft/knowledge/style/single-space-around-binary-operators.bad.al new file mode 100644 index 0000000..2515d33 --- /dev/null +++ b/microsoft/knowledge/style/single-space-around-binary-operators.bad.al @@ -0,0 +1,12 @@ +codeunit 50229 "Sample Spaces Op Bad" +{ + procedure Compute(Amount: Decimal; Quantity: Decimal): Decimal + var + Price: Decimal; + begin + Price:=Amount*Quantity; + if (Amount>0)and(Quantity>0) then + exit(Price); + exit(0); + end; +} diff --git a/microsoft/knowledge/style/single-space-around-binary-operators.good.al b/microsoft/knowledge/style/single-space-around-binary-operators.good.al new file mode 100644 index 0000000..55793d3 --- /dev/null +++ b/microsoft/knowledge/style/single-space-around-binary-operators.good.al @@ -0,0 +1,12 @@ +codeunit 50228 "Sample Spaces Op Good" +{ + procedure Compute(Amount: Decimal; Quantity: Decimal): Decimal + var + Price: Decimal; + begin + Price := Amount * Quantity; + if (Amount > 0) and (Quantity > 0) then + exit(Price); + exit(0); + end; +} diff --git a/microsoft/knowledge/style/single-space-around-binary-operators.md b/microsoft/knowledge/style/single-space-around-binary-operators.md new file mode 100644 index 0000000..a09fef9 --- /dev/null +++ b/microsoft/knowledge/style/single-space-around-binary-operators.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [spacing, binary-operator, aa0001, codecop, formatting] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# One space on each side of every binary operator (CodeCop AA0001) + +## Description + +CodeCop AA0001 requires exactly one space on each side of every binary operator: assignment (`:=`), arithmetic (`+`, `-`, `*`, `/`, `mod`, `div`), comparison (`=`, `<>`, `<`, `<=`, `>`, `>=`), logical (`and`, `or`, `xor`), and string concatenation. `x:=1+2`, `Price:=Amount*Quantity`, `if a=b then`, and `if a and b then` all violate the rule. The rule applies to the binary use of `-` (subtraction); the unary minus (`-Profit`) takes no leading space. + +## Best Practice + +Write `x := 1 + 2`, `Price := Amount * Quantity`, `if a = b then`, `if a and b then`. The standard AL formatter inserts these spaces automatically; running `Alt+Shift+F` (Format Document) in the AL extension is the simplest way to bring an entire file into compliance. + +See sample: `single-space-around-binary-operators.good.al`. + +## Anti Pattern + +`x:=1+2;`, `Price:=Amount*Quantity;`, `if a=b then`, `if a and b then`. All trip AA0001. + +See sample: `single-space-around-binary-operators.bad.al`. diff --git a/microsoft/knowledge/style/temporary-variable-temp-prefix.bad.al b/microsoft/knowledge/style/temporary-variable-temp-prefix.bad.al new file mode 100644 index 0000000..62a1dbe --- /dev/null +++ b/microsoft/knowledge/style/temporary-variable-temp-prefix.bad.al @@ -0,0 +1,12 @@ +codeunit 50217 "Sample Temp Prefix Bad" +{ + procedure BuildBuffer(var SalesLine: Record "Sales Line" temporary) + var + WIPBuffer: Record "Job WIP Buffer" temporary; + begin + WIPBuffer.Init(); + WIPBuffer.Insert(); + SalesLine.Init(); + SalesLine.Insert(); + end; +} diff --git a/microsoft/knowledge/style/temporary-variable-temp-prefix.good.al b/microsoft/knowledge/style/temporary-variable-temp-prefix.good.al new file mode 100644 index 0000000..09123a9 --- /dev/null +++ b/microsoft/knowledge/style/temporary-variable-temp-prefix.good.al @@ -0,0 +1,12 @@ +codeunit 50216 "Sample Temp Prefix Good" +{ + procedure BuildBuffer(var TempSalesLine: Record "Sales Line" temporary) + var + TempJobWIPBuffer: Record "Job WIP Buffer" temporary; + begin + TempJobWIPBuffer.Init(); + TempJobWIPBuffer.Insert(); + TempSalesLine.Init(); + TempSalesLine.Insert(); + end; +} diff --git a/microsoft/knowledge/style/temporary-variable-temp-prefix.md b/microsoft/knowledge/style/temporary-variable-temp-prefix.md new file mode 100644 index 0000000..2211b4c --- /dev/null +++ b/microsoft/knowledge/style/temporary-variable-temp-prefix.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [temporary, temp, prefix, record-variable, naming] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Prefix temporary record variables with `Temp` + +## Description + +A `Record` variable declared with the `temporary` modifier behaves nothing like a normal record variable: it never touches the database, holds rows only for the lifetime of the variable, and is not visible to filters or queries on the underlying table. The BC convention is to make that difference visible at every call site by prefixing the variable name with `Temp` — `TempJobWIPBuffer`, `TempSalesLine`, `TempIntegerBuffer`. The convention is load-bearing for code review: when a reader sees `SalesLine.Insert()`, they expect a database write; when they see `TempSalesLine.Insert()`, they know it is an in-memory buffer. + +## Best Practice + +Every variable of type `Record X temporary` must start with `Temp`. The same applies to parameters: a procedure that receives a temporary record as a buffer names the parameter `TempBuffer`, `TempSalesLine`, and so on. The convention extends naturally to derived names — `TempJobWIPBufferCopy`, `TempSourceSalesLine` — anything that starts with `Temp` is in-memory. + +See sample: `temporary-variable-temp-prefix.good.al`. + +## Anti Pattern + +`WIPBuffer: Record "Job WIP Buffer" temporary;` reads at the call site as if it were a database operation: `WIPBuffer.Insert()` looks identical to a write to the underlying table. The reader has to scroll back to the declaration to discover that this is in-memory, every time. + +See sample: `temporary-variable-temp-prefix.bad.al`. diff --git a/microsoft/knowledge/style/this-keyword-in-codeunits.bad.al b/microsoft/knowledge/style/this-keyword-in-codeunits.bad.al new file mode 100644 index 0000000..7fd03a1 --- /dev/null +++ b/microsoft/knowledge/style/this-keyword-in-codeunits.bad.al @@ -0,0 +1,14 @@ +codeunit 50215 "Sample This Bad" +{ + procedure ProcessRecord(Customer: Record Customer) + var + Helper: Codeunit "Sample This Helper"; + begin + ValidateCustomer(Customer); + Helper.DoWork(); + end; + + local procedure ValidateCustomer(Customer: Record Customer) + begin + end; +} diff --git a/microsoft/knowledge/style/this-keyword-in-codeunits.good.al b/microsoft/knowledge/style/this-keyword-in-codeunits.good.al new file mode 100644 index 0000000..392c042 --- /dev/null +++ b/microsoft/knowledge/style/this-keyword-in-codeunits.good.al @@ -0,0 +1,14 @@ +codeunit 50214 "Sample This Good" +{ + procedure ProcessRecord(Customer: Record Customer) + var + Helper: Codeunit "Sample This Helper"; + begin + this.ValidateCustomer(Customer); + Helper.DoWork(this); + end; + + local procedure ValidateCustomer(Customer: Record Customer) + begin + end; +} diff --git a/microsoft/knowledge/style/this-keyword-in-codeunits.md b/microsoft/knowledge/style/this-keyword-in-codeunits.md new file mode 100644 index 0000000..ffcf5a1 --- /dev/null +++ b/microsoft/knowledge/style/this-keyword-in-codeunits.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [this, codeunit, self-reference, aa0248, scope] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use the `this` keyword for self-reference inside codeunits (CodeCop AA0248) + +## Description + +CodeCop AA0248 recommends prefixing self-references inside a codeunit with `this`. `this.ValidateCustomer(Customer)` is unambiguous: the call resolves to a procedure on the current codeunit, not to a local variable or a procedure on a passed-in object. Without the prefix, a reader of a 200-line procedure has to scan the whole codeunit to confirm whether `ValidateCustomer` is local. `this` also makes it possible to pass the current codeunit as an argument — `SomeOtherCodeunit.DoWork(this)` — which is the only way to expose the running codeunit instance to a collaborator. The rule applies only to codeunits, not to pages, reports, queries, or tables — those object types do not have a `this` reference in AL. + +## Best Practice + +Inside a codeunit, prefix calls to procedures and accesses to global variables on the same codeunit with `this.`, and pass `this` when an external codeunit needs a reference to the running instance. + +See sample: `this-keyword-in-codeunits.good.al`. + +## Anti Pattern + +Calling a codeunit-local procedure as a bare identifier (`ValidateCustomer(Customer)`) when other readings are possible. The ambiguity costs reading time on every encounter and grows with codeunit size. + +See sample: `this-keyword-in-codeunits.bad.al`. diff --git a/microsoft/knowledge/ui/field-tooltips-start-with-specifies-and-end-with-period.good.al b/microsoft/knowledge/style/tooltip-required-on-page-fields.bad.al similarity index 53% rename from microsoft/knowledge/ui/field-tooltips-start-with-specifies-and-end-with-period.good.al rename to microsoft/knowledge/style/tooltip-required-on-page-fields.bad.al index 100dfcf..e6b356c 100644 --- a/microsoft/knowledge/ui/field-tooltips-start-with-specifies-and-end-with-period.good.al +++ b/microsoft/knowledge/style/tooltip-required-on-page-fields.bad.al @@ -1,23 +1,21 @@ -page 51002 "UI Sample FieldTooltip Good" +page 50251 "Sample Tooltip Bad" { PageType = Card; SourceTable = Customer; - layout { area(Content) { group(General) { - field("Name"; Rec.Name) + field("No."; Rec."No.") { ApplicationArea = All; - ToolTip = 'Specifies the name of the customer.'; } - field("Balance (LCY)"; Rec."Balance (LCY)") + field(Amount; Rec."Balance (LCY)") { ApplicationArea = All; - ToolTip = 'Shows the current balance in the local currency.'; + ToolTip = ''; } } } diff --git a/microsoft/knowledge/ui/field-tooltips-start-with-specifies-and-end-with-period.bad.al b/microsoft/knowledge/style/tooltip-required-on-page-fields.good.al similarity index 51% rename from microsoft/knowledge/ui/field-tooltips-start-with-specifies-and-end-with-period.bad.al rename to microsoft/knowledge/style/tooltip-required-on-page-fields.good.al index 3f7eec1..1816de5 100644 --- a/microsoft/knowledge/ui/field-tooltips-start-with-specifies-and-end-with-period.bad.al +++ b/microsoft/knowledge/style/tooltip-required-on-page-fields.good.al @@ -1,24 +1,22 @@ -page 51003 "UI Sample FieldTooltip Bad" +page 50250 "Sample Tooltip Good" { PageType = Card; SourceTable = Customer; - layout { area(Content) { group(General) { - field("Name"; Rec.Name) + field("No."; Rec."No.") { ApplicationArea = All; - // No "Specifies" opener, no period, a bare fragment. - ToolTip = 'The name of the customer'; + ToolTip = 'Specifies the number that identifies the customer.'; } - field("Balance (LCY)"; Rec."Balance (LCY)") + field(Amount; Rec."Balance (LCY)") { ApplicationArea = All; - ToolTip = 'Balance'; + ToolTip = 'Shows the total balance in local currency.'; } } } diff --git a/microsoft/knowledge/style/tooltip-required-on-page-fields.md b/microsoft/knowledge/style/tooltip-required-on-page-fields.md new file mode 100644 index 0000000..fc5a3cb --- /dev/null +++ b/microsoft/knowledge/style/tooltip-required-on-page-fields.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: style +keywords: [tooltip, page-field, aa0218, codecop, accessibility, specifies] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Every page field needs a `ToolTip` (CodeCop AA0218) + +## Description + +CodeCop AA0218 requires a non-empty `ToolTip` property on every field control on a page. The tooltip is what users see on hover and is what screen readers announce; an empty or missing tooltip removes a piece of UI affordance that is part of BC's accessibility baseline. AppSource technical validation rejects pages with missing tooltips. The companion rules AA0219 and AA0220 push the wording further — tooltips should describe what the field shows, conventionally starting with `'Specifies …'`, though `'Shows …'` and similar variants are acceptable when they clearly describe the field's purpose. + +Acceptable exceptions: table fields inside `Upgrade`, `Migration`, `HybridBC14`, `HybridSL`, and `HybridGP` codeunits and tables are allowed to omit the tooltip — those types are not surfaced to users. + +## Best Practice + +Every field control on a regular page carries `ToolTip = 'Specifies …';` (or a clear alternative phrasing). Compose the text in the form "what this value shows" rather than "what the user does with it". + +See sample: `tooltip-required-on-page-fields.good.al`. + +## Anti Pattern + +A field control with no `ToolTip` property at all, or `ToolTip = '';`. AA0218 flags both; the hover state is blank and the screen reader has nothing to announce. + +See sample: `tooltip-required-on-page-fields.bad.al`. diff --git a/microsoft/knowledge/style/use-fieldcaption-and-tablecaption-in-user-messages.bad.al b/microsoft/knowledge/style/use-fieldcaption-and-tablecaption-in-user-messages.bad.al deleted file mode 100644 index 5492368..0000000 --- a/microsoft/knowledge/style/use-fieldcaption-and-tablecaption-in-user-messages.bad.al +++ /dev/null @@ -1,12 +0,0 @@ -codeunit 51111 "Style Sample FieldCaption Bad" -{ - procedure Example(var SalesLine: Record "Sales Line") - var - UpdateLocationQst: Label 'Update the %1?', Comment = '%1 = field'; - begin - // FieldName/TableName return English identifiers. User with a non-English - // locale sees the English "Location Code" inside an otherwise translated dialog. - if not Confirm(UpdateLocationQst, true, SalesLine.FieldName("Location Code")) then - exit; - end; -} diff --git a/microsoft/knowledge/style/use-fieldcaption-and-tablecaption-in-user-messages.good.al b/microsoft/knowledge/style/use-fieldcaption-and-tablecaption-in-user-messages.good.al deleted file mode 100644 index cf7693e..0000000 --- a/microsoft/knowledge/style/use-fieldcaption-and-tablecaption-in-user-messages.good.al +++ /dev/null @@ -1,13 +0,0 @@ -codeunit 51110 "Style Sample FieldCaption Good" -{ - procedure Example(var SalesLine: Record "Sales Line") - var - UpdateLocationQst: Label 'Update the %1?', Comment = '%1 = field caption'; - TableUpdatedMsg: Label 'Updated %1.', Comment = '%1 = table caption'; - begin - // Captions are localized for the current user's language. - if not Confirm(UpdateLocationQst, true, SalesLine.FieldCaption("Location Code")) then - exit; - Message(TableUpdatedMsg, SalesLine.TableCaption()); - end; -} diff --git a/microsoft/knowledge/style/use-fieldcaption-and-tablecaption-in-user-messages.md b/microsoft/knowledge/style/use-fieldcaption-and-tablecaption-in-user-messages.md deleted file mode 100644 index 2bd8d6f..0000000 --- a/microsoft/knowledge/style/use-fieldcaption-and-tablecaption-in-user-messages.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: style -keywords: [fieldcaption, tablecaption, fieldname, tablename, localization, user-message] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Use FieldCaption and TableCaption in user messages, not FieldName and TableName - -## Description - -`FieldName` and `TableName` return the object's internal identifier in English — the name the developer typed into the declaration. `FieldCaption` and `TableCaption` return the translated caption for the current user's language. In user-facing messages, errors, confirmations, and notifications, the two pairs diverge the moment the user is running a non-English locale: `FieldName("Location Code")` reads `Location Code` in every language, while `FieldCaption("Location Code")` reads the translated equivalent. Using the wrong one leaks the English identifier into a localized UI and defeats the product's translation work. - -## Best Practice - -In any string the user will read, use `FieldCaption()` and `TableCaption`. Reserve `FieldName` and `TableName` for diagnostic and telemetry contexts where the stable English identifier is preferable. The same rule applies to `XmlPort`, `Query`, and other objects with a caption/name pair. - -See sample: `use-fieldcaption-and-tablecaption-in-user-messages.good.al`. - -## Anti Pattern - -`Confirm(UpdateLocationQst, true, FieldName("Location Code"))`, `Message('Updated %1', TableName())` — both surface English identifiers to a user whose entire UI is in a different language. - -See sample: `use-fieldcaption-and-tablecaption-in-user-messages.bad.al`. diff --git a/microsoft/knowledge/style/use-named-invocations-instead-of-object-ids.bad.al b/microsoft/knowledge/style/use-named-invocations-instead-of-object-ids.bad.al deleted file mode 100644 index 1065a25..0000000 --- a/microsoft/knowledge/style/use-named-invocations-instead-of-object-ids.bad.al +++ /dev/null @@ -1,10 +0,0 @@ -codeunit 51109 "Style Sample NamedInvoke Bad" -{ - procedure Example(var SalesShptLine: Record "Sales Shipment Line") - begin - // Numeric ID. The reader has to look up 525 and 206 to know what is called. - // If either object is renumbered in a future release, this call silently retargets. - Page.RunModal(525, SalesShptLine); - Report.Run(206, true); - end; -} diff --git a/microsoft/knowledge/style/use-named-invocations-instead-of-object-ids.good.al b/microsoft/knowledge/style/use-named-invocations-instead-of-object-ids.good.al deleted file mode 100644 index 634c202..0000000 --- a/microsoft/knowledge/style/use-named-invocations-instead-of-object-ids.good.al +++ /dev/null @@ -1,9 +0,0 @@ -codeunit 51108 "Style Sample NamedInvoke Good" -{ - procedure Example(var SalesShptLine: Record "Sales Shipment Line") - begin - // Named invocation: reviewer sees the object, rename of 525 cannot retarget. - Page.RunModal(Page::"Posted Sales Shipment Lines", SalesShptLine); - Report.Run(Report::"Sales - Invoice", true); - end; -} diff --git a/microsoft/knowledge/style/use-named-invocations-instead-of-object-ids.md b/microsoft/knowledge/style/use-named-invocations-instead-of-object-ids.md deleted file mode 100644 index db62cff..0000000 --- a/microsoft/knowledge/style/use-named-invocations-instead-of-object-ids.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: style -keywords: [object-id, page-run, report-run, codeunit-run, named-invocation] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Invoke objects by name, not by numeric ID - -## Description - -AL supports calling `Page.RunModal(525, ...)` or `Report.Run(206, ...)` with a bare numeric ID. The platform accepts the number, but the call site loses every signal that makes the code reviewable and refactor-safe: the reader cannot tell which object is being invoked without looking up 525 in the object catalog, and the renumbering of an object in a future release (legal in AL — IDs are not a stable contract) silently retargets the call to a different object. The `Page::"..."` / `Report::"..."` syntax compiles to the same runtime call but makes the target explicit and binds by name, which is the stable identity. - -## Best Practice - -Write `Page.RunModal(Page::"Posted Sales Shipment Lines", SalesShptLine)` and `Report.Run(Report::"Sales - Invoice", true)`. Apply the same rule to `Codeunit.Run`, `XmlPort.Run`, and similar runtime invocations. Reserve numeric IDs for diagnostic tooling that genuinely needs them. - -See sample: `use-named-invocations-instead-of-object-ids.good.al`. - -## Anti Pattern - -`Page.RunModal(525, SalesShptLine);` — the reader has no idea what page 525 is without a lookup, and a future rename of page 525 or renumber of "Posted Sales Shipment Lines" produces a silent mismatch. - -See sample: `use-named-invocations-instead-of-object-ids.bad.al`. diff --git a/microsoft/knowledge/style/use-this-keyword-in-codeunits.bad.al b/microsoft/knowledge/style/use-this-keyword-in-codeunits.bad.al deleted file mode 100644 index b35982d..0000000 --- a/microsoft/knowledge/style/use-this-keyword-in-codeunits.bad.al +++ /dev/null @@ -1,15 +0,0 @@ -codeunit 51117 "Style Sample ThisKeyword Bad" -{ - procedure ProcessRecord(var Customer: Record Customer) - begin - // Ambiguous: is ValidateCustomer a local, a global, or a method on - // another codeunit in scope? - ValidateCustomer(Customer); - - // No way to pass the current codeunit without `this`. - end; - - local procedure ValidateCustomer(var Customer: Record Customer) - begin - end; -} diff --git a/microsoft/knowledge/style/use-this-keyword-in-codeunits.good.al b/microsoft/knowledge/style/use-this-keyword-in-codeunits.good.al deleted file mode 100644 index 6a6bf94..0000000 --- a/microsoft/knowledge/style/use-this-keyword-in-codeunits.good.al +++ /dev/null @@ -1,21 +0,0 @@ -codeunit 51116 "Style Sample ThisKeyword Good" -{ - procedure ProcessRecord(var Customer: Record Customer) - var - Other: Codeunit "Style Sample ThisKeyword Good"; - begin - // Clearly this codeunit's method. - this.ValidateCustomer(Customer); - - // Only way to pass the current codeunit as an argument. - Other.DoWith(this); - end; - - local procedure ValidateCustomer(var Customer: Record Customer) - begin - end; - - procedure DoWith(var Helper: Codeunit "Style Sample ThisKeyword Good") - begin - end; -} diff --git a/microsoft/knowledge/style/use-this-keyword-in-codeunits.md b/microsoft/knowledge/style/use-this-keyword-in-codeunits.md deleted file mode 100644 index 08a26f8..0000000 --- a/microsoft/knowledge/style/use-this-keyword-in-codeunits.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: style -keywords: [this, codeunit, self-reference, aa0248, readability] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Use the `this` keyword for codeunit self-reference - -## Description - -CodeCop rule AA0248 recommends the `this` keyword inside codeunit procedures when referring to the codeunit's own members or passing the codeunit itself to another procedure. AL's scope resolution otherwise blurs global-variable access, local-variable access, and same-codeunit method calls into the same unqualified syntax — a reader of `ValidateCustomer(Customer)` cannot tell at the call site whether `ValidateCustomer` is a local, a global, or a method on a different codeunit in scope. `this.ValidateCustomer(Customer)` removes the ambiguity, and `OtherCodeunit.DoWork(this)` is the only way to pass the current codeunit as a parameter. - -## Best Practice - -In codeunits, prefix same-codeunit method calls with `this.` when the call is ambiguous or when the scope spans more than a few lines. When the current codeunit needs to be passed as an argument, write `this` — there is no alternative syntax. The rule applies to codeunits; pages, reports, and tables have their own scoping. - -See sample: `use-this-keyword-in-codeunits.good.al`. - -## Anti Pattern - -`ValidateCustomer(Customer); SomeOtherCodeunit.DoWork(/* this codeunit? */);` — the first call has ambiguous origin, and the second cannot pass the current codeunit without `this`. The style becomes load-bearing as the codeunit grows past a few small procedures. - -See sample: `use-this-keyword-in-codeunits.bad.al`. diff --git a/microsoft/knowledge/style/variable-declaration-order-by-type.bad.al b/microsoft/knowledge/style/variable-declaration-order-by-type.bad.al new file mode 100644 index 0000000..3131fa6 --- /dev/null +++ b/microsoft/knowledge/style/variable-declaration-order-by-type.bad.al @@ -0,0 +1,13 @@ +codeunit 50247 "Sample Var Order Bad" +{ + procedure Run() + var + CustomerNo: Code[20]; + TempBuffer: Record "Integer" temporary; + Amount: Decimal; + Customer: Record Customer; + IsValid: Boolean; + begin + IsValid := Customer.Get(CustomerNo); + end; +} diff --git a/microsoft/knowledge/style/variable-declaration-order-by-type.good.al b/microsoft/knowledge/style/variable-declaration-order-by-type.good.al new file mode 100644 index 0000000..590ed25 --- /dev/null +++ b/microsoft/knowledge/style/variable-declaration-order-by-type.good.al @@ -0,0 +1,13 @@ +codeunit 50246 "Sample Var Order Good" +{ + procedure Run() + var + Customer: Record Customer; + TempBuffer: Record "Integer" temporary; + CustomerNo: Code[20]; + Amount: Decimal; + IsValid: Boolean; + begin + IsValid := Customer.Get(CustomerNo); + end; +} diff --git a/microsoft/knowledge/style/variable-declaration-order-by-type.md b/microsoft/knowledge/style/variable-declaration-order-by-type.md new file mode 100644 index 0000000..3435726 --- /dev/null +++ b/microsoft/knowledge/style/variable-declaration-order-by-type.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [variable-declaration, order, var, complex-types, aa0021] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Order variable declarations by type, complex types first (CodeCop AA0021) + +## Description + +CodeCop AA0021 requires that variable declarations inside a `var` block follow a fixed ordering by type, with complex (composite) types appearing before primitive types. The canonical order is `Record`, then `Report`, `Codeunit`, `XmlPort`, `Page`, `Query`, `Notification`, `BigText`, `DateFormula`, `RecordId`, `RecordRef`, `FieldRef`, `FilterPageBuilder`, then the simple types `Text`, `Code`, `Integer`, `Decimal`, `Boolean`, `Date`, `Time`, `DateTime`, `Char`, `Byte`. Inside each type group the variables can be alphabetical or in usage order. Temporary records still sort under `Record`. + +## Best Practice + +Declare all `Record` variables first, then other complex types, then primitives. A consistent order makes diffs review-friendly and matches the convention enforced by the AL formatter and CodeCop. + +See sample: `variable-declaration-order-by-type.good.al`. + +## Anti Pattern + +A `var` block where records and primitives are interleaved — `CustomerNo: Code[20];` between two `Record` variables, or `Amount: Decimal;` declared above the `Customer: Record Customer;` it is computed from. AA0021 flags it and the block is harder to scan; readers expect composite types at the top. + +See sample: `variable-declaration-order-by-type.bad.al`. diff --git a/microsoft/knowledge/style/variable-name-must-not-shadow.bad.al b/microsoft/knowledge/style/variable-name-must-not-shadow.bad.al new file mode 100644 index 0000000..65c8223 --- /dev/null +++ b/microsoft/knowledge/style/variable-name-must-not-shadow.bad.al @@ -0,0 +1,19 @@ +codeunit 50249 "Sample Shadow Bad" +{ + var + Customer: Record Customer; + + procedure ProcessSales() + var + Customer: Text; + Amount: Decimal; + begin + Customer := 'C-100'; + Amount := 0; + end; + + procedure Amount(): Decimal + begin + exit(0); + end; +} diff --git a/microsoft/knowledge/style/variable-name-must-not-shadow.good.al b/microsoft/knowledge/style/variable-name-must-not-shadow.good.al new file mode 100644 index 0000000..f5391ef --- /dev/null +++ b/microsoft/knowledge/style/variable-name-must-not-shadow.good.al @@ -0,0 +1,19 @@ +codeunit 50248 "Sample No Shadow Good" +{ + var + CustomerRec: Record Customer; + + procedure ProcessSales() + var + CustomerName: Text; + SalesAmount: Decimal; + begin + CustomerName := CustomerRec.Name; + SalesAmount := GetAmount(); + end; + + procedure GetAmount(): Decimal + begin + exit(0); + end; +} diff --git a/microsoft/knowledge/style/variable-name-must-not-shadow.md b/microsoft/knowledge/style/variable-name-must-not-shadow.md new file mode 100644 index 0000000..4fee2da --- /dev/null +++ b/microsoft/knowledge/style/variable-name-must-not-shadow.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: style +keywords: [variable-name, shadow, conflict, aa0198, aa0202, aa0204, codecop] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Local variable names must not shadow globals, fields, methods, or actions (CodeCop AA0198/AA0202/AA0204) + +## Description + +Three CodeCop rules — AA0198, AA0202, AA0204 — together forbid a local variable from sharing a name with a global variable on the same object, with a field on the same table or page source, with a procedure on the same object, or with an action on the same page. The compiler resolves the conflict by binding the closer scope, so a local `Customer: Text` will silently override a global `Customer: Record Customer` for the duration of a procedure — every call site reading `Customer.Name` from inside that procedure refers to the text, and the breakage is invisible to a reader who has both declarations on screen. + +## Best Practice + +Differentiate every local declaration from globals, fields, procedures, and actions on the same object. `Customer` global plus `CustomerName` local; method `GetAmount` plus local `SalesAmount`. The standard pattern is to attach a noun suffix to the local (`CustomerName`, `CustomerRec`, `CustomerNo`) rather than to the global. + +See sample: `variable-name-must-not-shadow.good.al`. + +## Anti Pattern + +A procedure that declares a local `Customer: Text` inside a codeunit that already has a global `Customer: Record Customer`. The local wins and the global becomes unreachable inside the procedure. AA0198/AA0202/AA0204 flag this category of conflict whether the colliding entity is a global, a field, a method, or an action. + +See sample: `variable-name-must-not-shadow.bad.al`. diff --git a/microsoft/knowledge/style/xmldoc-for-public-library-procedures.md b/microsoft/knowledge/style/xmldoc-for-public-library-procedures.md new file mode 100644 index 0000000..bffa5e8 --- /dev/null +++ b/microsoft/knowledge/style/xmldoc-for-public-library-procedures.md @@ -0,0 +1,24 @@ +--- +bc-version: [all] +domain: style +keywords: [xmldoc, summary, param, returns, public-procedure, documentation] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Add XML documentation to public procedures on library/API codeunits + +## Description + +XML documentation comments (`/// `, `/// …`, `/// `) are expected on procedures that form the public surface of a library — codeunits intended to be called from outside the current app: System App modules, AppSource library codeunits, `Access = Public` codeunits exposed for extension. The supported tags are ``, ``, ``, ``, ``, and ``. Active wording is preferred — `'Sets…'`, `'Gets…'`, `'Specifies…'` — and the docs should list parameter preconditions and any exceptions the procedure may raise. + +XML docs are NOT required on internal procedures, event subscribers, trigger implementations, page-part procedures, test procedures, or the object declarations themselves (tables, pages, codeunits). The reviewer signal is a `procedure` (not `local procedure`, not `internal procedure`) declared inside a codeunit whose role is "library" — those need XML docs; everything else is optional. + +## Best Practice + +For every public procedure on a library codeunit, write a `` describing what the procedure does, one `` per parameter naming its role and preconditions, and `` describing the return when applicable. Avoid placeholder text — `Validates discount` is no better than no doc at all. + +## Anti Pattern + +A public procedure on a library codeunit with no XML doc, or a `` that restates the procedure name in three words. The first leaves consumers guessing at intent; the second wastes the slot a meaningful description should occupy. diff --git a/microsoft/knowledge/ui/action-tooltips-are-imperative-and-end-with-period.bad.al b/microsoft/knowledge/ui/action-tooltips-are-imperative-and-end-with-period.bad.al deleted file mode 100644 index a519d8d..0000000 --- a/microsoft/knowledge/ui/action-tooltips-are-imperative-and-end-with-period.bad.al +++ /dev/null @@ -1,26 +0,0 @@ -page 51005 "UI Sample ActionTooltip Bad" -{ - PageType = Card; - SourceTable = "Sales Header"; - - actions - { - area(Processing) - { - action(Post) - { - Caption = 'Post'; - ApplicationArea = All; - // Declarative, not imperative. No period. - ToolTip = 'This will post the invoice'; - } - action(SendForApproval) - { - Caption = 'Send for approval'; - ApplicationArea = All; - // Fragment that repeats the caption and says nothing new. - ToolTip = 'Send for approval'; - } - } - } -} diff --git a/microsoft/knowledge/ui/action-tooltips-are-imperative-and-end-with-period.good.al b/microsoft/knowledge/ui/action-tooltips-are-imperative-and-end-with-period.good.al deleted file mode 100644 index 2dcab7e..0000000 --- a/microsoft/knowledge/ui/action-tooltips-are-imperative-and-end-with-period.good.al +++ /dev/null @@ -1,25 +0,0 @@ -page 51004 "UI Sample ActionTooltip Good" -{ - PageType = Card; - SourceTable = "Sales Header"; - - actions - { - area(Processing) - { - action(Post) - { - Caption = 'Post'; - ApplicationArea = All; - // Imperative verb-first sentence, Sentence case, terminating period. - ToolTip = 'Post the current sales invoice and finalize the transaction.'; - } - action(SendForApproval) - { - Caption = 'Send for approval'; - ApplicationArea = All; - ToolTip = 'Send the document to the approval workflow.'; - } - } - } -} diff --git a/microsoft/knowledge/ui/action-tooltips-are-imperative-and-end-with-period.md b/microsoft/knowledge/ui/action-tooltips-are-imperative-and-end-with-period.md deleted file mode 100644 index bc2d457..0000000 --- a/microsoft/knowledge/ui/action-tooltips-are-imperative-and-end-with-period.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: ui -keywords: [tooltip, action, imperative, voice, period] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Action tooltips are imperative, verb-first sentences ending with a period - -## Description - -Action tooltips describe what the user will cause by invoking the action. The house style is an imperative verb-first sentence — `Post the current sales invoice and finalize the transaction.` — not a declarative one ("This will post …") and not a fragment ("Post invoice"). The imperative voice matches how the user reads the action bar: each tooltip completes the sentence "If I click this, the system will …" in the same grammatical form. Shortcut-key hints, when present, belong at the end of the tooltip and are retained verbatim. - -## Best Practice - -Start the tooltip with the verb. Use Sentence case, end with a period, stay within the ~250-character budget. Keep one sentence unless the action genuinely needs two; avoid editorializing ("Easily post …") or narrating ("This action posts …"). Preserve any existing shortcut annotation. - -See sample: `action-tooltips-are-imperative-and-end-with-period.good.al`. - -## Anti Pattern - -`ToolTip = 'This will post the invoice'` — declarative rather than imperative, no period. `ToolTip = 'Post'` — one-word fragment that duplicates the Caption and says nothing new. Both fail the scan-the-action-bar comprehension test. - -See sample: `action-tooltips-are-imperative-and-end-with-period.bad.al`. diff --git a/microsoft/knowledge/ui/avoid-banned-ui-terms.md b/microsoft/knowledge/ui/avoid-banned-ui-terms.md deleted file mode 100644 index efb5c79..0000000 --- a/microsoft/knowledge/ui/avoid-banned-ui-terms.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -bc-version: [all] -domain: ui -keywords: [terminology, disabled, invalid, whitelist, blacklist, voice] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Avoid banned UI terms; prefer the inclusive and direct replacements - -## Description - -Business Central's UI voice guidelines exclude four terms that carry connotations the product does not want to push onto users: "Disabled" (clinical/negative), "Invalid" (pejorative), "Whitelist" and "Blacklist" (terms with racial associations the industry has moved away from). The replacements read naturally, match the product's warm-and-direct voice, and align with Microsoft's cross-product terminology. The concern applies to user-visible text — captions, tooltips, error messages, notifications — not to variable names or code comments. - -## Best Practice - -Replace "Disabled" with "Turned off" or "Not available". Replace "Invalid" with "Not valid" or "Incorrect". Replace "Whitelist" with "Allow list". Replace "Blacklist" with "Block list". Apply the substitution in all UI text surfaces: Caption, ToolTip, AboutTitle, AboutText, Label values, Message/Confirm/Error strings. - -## Anti Pattern - -`ErrorLbl: Label 'Invalid input.'`, `Caption = 'Disabled Users'`, `ToolTip = 'Specifies the blacklist of blocked senders.'` — all three terms in places the user will read. The fix is literal substitution with the approved alternative. diff --git a/microsoft/knowledge/ui/caption-capitalization-noun-phrase-vs-sentence-phrase.bad.al b/microsoft/knowledge/ui/caption-capitalization-noun-phrase-vs-sentence-phrase.bad.al deleted file mode 100644 index 8c82cf1..0000000 --- a/microsoft/knowledge/ui/caption-capitalization-noun-phrase-vs-sentence-phrase.bad.al +++ /dev/null @@ -1,21 +0,0 @@ -page 51001 "UI Sample Caption Bad" -{ - PageType = List; - SourceTable = Customer; - - // Noun phrase in Sentence case. Every other list page in the product is Title Case. - Caption = 'Sales orders'; - - actions - { - area(Processing) - { - // Sentence phrase in Title Case. Reads as a typo. - action(PostAndPrint) - { - Caption = 'Post And Print'; - ApplicationArea = All; - } - } - } -} diff --git a/microsoft/knowledge/ui/caption-capitalization-noun-phrase-vs-sentence-phrase.good.al b/microsoft/knowledge/ui/caption-capitalization-noun-phrase-vs-sentence-phrase.good.al deleted file mode 100644 index e3bec15..0000000 --- a/microsoft/knowledge/ui/caption-capitalization-noun-phrase-vs-sentence-phrase.good.al +++ /dev/null @@ -1,27 +0,0 @@ -page 51000 "UI Sample Caption Good" -{ - PageType = List; - SourceTable = Customer; - - // Noun-phrase page caption: Title Case. - Caption = 'Sales Orders'; - - actions - { - area(Processing) - { - // Sentence-phrase action caption: Sentence case. - action(PostAndPrint) - { - Caption = 'Post and print'; - ApplicationArea = All; - } - - action(SendEmail) - { - Caption = 'Send email'; - ApplicationArea = All; - } - } - } -} diff --git a/microsoft/knowledge/ui/caption-capitalization-noun-phrase-vs-sentence-phrase.md b/microsoft/knowledge/ui/caption-capitalization-noun-phrase-vs-sentence-phrase.md deleted file mode 100644 index 595010f..0000000 --- a/microsoft/knowledge/ui/caption-capitalization-noun-phrase-vs-sentence-phrase.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: ui -keywords: [caption, capitalization, title-case, sentence-case, noun-phrase] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Capitalize captions by phrase type: noun phrase is Title Case, sentence phrase is Sentence case - -## Description - -Business Central UI captions follow a simple capitalization rule that depends on the grammatical shape of the caption, not its location. A caption that is a pure noun phrase — no verb — uses Title Case: each major word capitalized (`Sales Orders`, `Chart of Accounts`, `Payment Terms`). A caption that is an imperative or declarative sentence phrase — contains a verb — uses Sentence case: only the first word and proper nouns capitalized (`Post and print`, `Send email`, `Create flow`). Following the rule makes unrelated captions feel consistent; ignoring it is visibly inconsistent in the user's navigation. - -## Best Practice - -Decide by parsing the caption as a phrase. "Sales Orders" is a thing; Title Case. "Post and print" tells the user to do something; Sentence case. For captions that are literally a single noun (`Save`, `Close`), treat them as sentence phrases — the imperative verb is implied. - -See sample: `caption-capitalization-noun-phrase-vs-sentence-phrase.good.al`. - -## Anti Pattern - -Writing `Caption = 'Sales orders'` on a list page (noun phrase styled as a sentence) or `Caption = 'Post And Print'` on an action (sentence phrase styled as title case). Both read as typos to a native English reader and inconsistent to a translator. - -See sample: `caption-capitalization-noun-phrase-vs-sentence-phrase.bad.al`. diff --git a/microsoft/knowledge/ui/control-add-in-accessibility-is-developer-responsibility.md b/microsoft/knowledge/ui/control-add-in-accessibility-is-developer-responsibility.md new file mode 100644 index 0000000..cc74054 --- /dev/null +++ b/microsoft/knowledge/ui/control-add-in-accessibility-is-developer-responsibility.md @@ -0,0 +1,20 @@ +--- +bc-version: [all] +domain: ui +keywords: [control-add-in, javascript, accessibility, framework, wcag] +technologies: [al, javascript] +countries: [w1] +application-area: [all] +--- + +# Control add-in accessibility is the developer's responsibility + +## Description + +When a developer builds a JavaScript control add-in, they bypass the Business Central framework's built-in accessibility support and take full responsibility for the accessibility of the rendered HTML, JavaScript, and CSS. Unlike standard AL page controls, an add-in receives no automatic ARIA semantics, no automatic keyboard handling, and no automatic high-contrast support from the BC client. + +Control add-in code must be reviewed for WCAG 2.1 AA compliance and general accessibility best practices. Automated review is inherently non-exhaustive — many accessibility issues (keyboard flow, screen reader announcements, dynamic behavior) require manual testing. + +## Best Practice + +Treat every UI-rendering change to a control add-in as something the platform will not catch for you: accessible names, semantic HTML, keyboard reachability, focus management, contrast, and reflow are all yours to verify. When reporting issues in control add-in code, include a recommendation that a manual accessibility review accompany any control add-in that renders a UI. diff --git a/microsoft/knowledge/ui/control-add-in-has-no-bc-color-tokens.md b/microsoft/knowledge/ui/control-add-in-has-no-bc-color-tokens.md new file mode 100644 index 0000000..ac396e2 --- /dev/null +++ b/microsoft/knowledge/ui/control-add-in-has-no-bc-color-tokens.md @@ -0,0 +1,18 @@ +--- +bc-version: [all] +domain: ui +keywords: [control-add-in, color-tokens, theming, high-contrast, forced-colors, accessibility] +technologies: [al, javascript] +countries: [w1] +application-area: [all] +--- + +# Control add-ins cannot use BC color tokens or theming + +## Description + +A JavaScript control add-in has no access to Business Central's color tokens or theming system. The BC client will not push theme variables, accent colors, or high-contrast palettes into the add-in's iframe. As a result, the add-in must handle Windows contrast themes independently — for example by responding to the `forced-colors` CSS media query or an equivalent mechanism, and by ensuring its own contrast ratios meet WCAG AA (4.5:1 for normal text, 3:1 for large text and UI components) against the backgrounds it draws. + +## Best Practice + +Style control add-ins with explicit colors that are known to meet contrast requirements, and add a `forced-colors` (or equivalent) branch so that Windows high-contrast users see a usable rendering. Do not assume that the add-in inherits BC's theme — verify the rendered output in default, dark, and high-contrast themes. diff --git a/microsoft/knowledge/ui/cosmetic-styles-need-no-textual-context.md b/microsoft/knowledge/ui/cosmetic-styles-need-no-textual-context.md new file mode 100644 index 0000000..02d2f13 --- /dev/null +++ b/microsoft/knowledge/ui/cosmetic-styles-need-no-textual-context.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: ui +keywords: [style, cosmetic, attention, strong, subordinate, accessibility] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Cosmetic styles need no textual context + +## Description + +A field's `Style` property controls text formatting. Some style values are purely **cosmetic** — they change visual appearance but do not convey semantic meaning. Cosmetic styles never require additional context and must not be reported as accessibility findings: + +- `None`, `Standard` +- `StandardAccent` (Blue) +- `Strong` (Bold), `StrongAccent` (Blue + Bold) +- `Attention` (Red + Italic), `AttentionAccent` (Blue + Italic) +- `Subordinate` (Grey) + +This list is exhaustive — every other named style on the platform either falls outside the cosmetic set or is one of the three semantic styles documented in `semantic-styles-need-independent-textual-meaning.md`. + +The same rule applies whether the cosmetic style is set via `Style` directly or via a `StyleExpr` Text variable. If the resolved value at runtime is one of the cosmetic styles above, the field is safe. + +## Best Practice + +Use cosmetic styles freely for visual emphasis. Do not treat the use of `Attention`, `Strong`, or any other cosmetic value as an accessibility issue — the colors and weights are purely presentational and carry no meaning a screen reader needs to convey. diff --git a/microsoft/knowledge/ui/field-tooltips-start-with-specifies-and-end-with-period.md b/microsoft/knowledge/ui/field-tooltips-start-with-specifies-and-end-with-period.md deleted file mode 100644 index e00842c..0000000 --- a/microsoft/knowledge/ui/field-tooltips-start-with-specifies-and-end-with-period.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: ui -keywords: [tooltip, field, specifies, voice, period] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Field tooltips start with "Specifies" and end with a period - -## Description - -Field tooltips describe what a value means, and the Business Central house style for them is a declarative sentence that starts with "Specifies" and ends with a period. The convention is not cosmetic: it yields a consistent voice across thousands of fields so a user scanning several tooltips in quick succession can compare them without re-parsing each opening clause. Alternative phrasings ("Shows …", "The …") are accepted when they describe the field clearly, but "Specifies …" is the default and the easiest to translate consistently. - -## Best Practice - -Write field tooltips as `Specifies .` — a single sentence, Sentence case, terminating period. Keep under the ~250-character tooltip budget (see `respect-ui-text-character-limits`). When the field's meaning is genuinely not a "specifies" sentence, use "Shows …" or a clearly descriptive alternative; avoid bare fragments. - -See sample: `field-tooltips-start-with-specifies-and-end-with-period.good.al`. - -## Anti Pattern - -`ToolTip = 'The name of the customer'` — missing "Specifies" opener, missing period. `ToolTip = 'Customer name'` — a fragment rather than a sentence. Both sit inconsistently next to adjacent "Specifies …" tooltips on the same page. - -See sample: `field-tooltips-start-with-specifies-and-end-with-period.bad.al`. diff --git a/microsoft/knowledge/ui/use-grid-data-table-pattern-consistently.good.al b/microsoft/knowledge/ui/grid-data-table-heuristic.good.al similarity index 56% rename from microsoft/knowledge/ui/use-grid-data-table-pattern-consistently.good.al rename to microsoft/knowledge/ui/grid-data-table-heuristic.good.al index c290629..21a3851 100644 --- a/microsoft/knowledge/ui/use-grid-data-table-pattern-consistently.good.al +++ b/microsoft/knowledge/ui/grid-data-table-heuristic.good.al @@ -1,25 +1,30 @@ -page 50732 "UI Grid Good" +page 50207 "UI Sample Data Table" { + PageType = Card; + SourceTable = Customer; + layout { area(Content) { - grid(BalanceGrid) + grid(DataGrid) { GridLayout = Columns; - group(CustomerColumn) + group(Column1) { ShowCaption = false; - field(CustomerName; Rec."Customer Name") + field(Name; Rec.Name) { + ApplicationArea = All; ShowCaption = false; } } - group(BalanceColumn) + group(Column2) { ShowCaption = false; - field(Balance; Rec.Balance) + field(Balance; Rec."Balance (LCY)") { + ApplicationArea = All; ShowCaption = false; } } diff --git a/microsoft/knowledge/ui/grid-data-table-heuristic.md b/microsoft/knowledge/ui/grid-data-table-heuristic.md new file mode 100644 index 0000000..2cd6b63 --- /dev/null +++ b/microsoft/knowledge/ui/grid-data-table-heuristic.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: ui +keywords: [grid, fixed, data-table, heuristic, show-caption, accessibility] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Grid and fixed-layout data-table heuristic + +## Description + +Business Central renders `grid()` and `fixed()` layouts in two modes. The mode is chosen automatically by a client heuristic. A grid renders as a **data table** (HTML `` with row/column semantics) only when **all** of the following are true: + +- All direct children of the grid/fixed are groups (no loose fields). +- Every child of every group is a field (no nested groups or other controls). +- All fields have `ShowCaption = false`. + +The heuristic checks field captions only — group `ShowCaption` is not part of the check. A group with a visible caption inside a data-table grid does **not** break the heuristic and is not a violation. However, groups in a data table should also have `ShowCaption = false` for correct visual presentation. + +Any grid or fixed layout that does not meet all three conditions renders as a layout table (visual column arrangement, no table semantics). + +## Best Practice + +If you intend a grid or fixed layout to render as a data table, satisfy all three conditions and verify the resulting markup matches your intent. If you do not need tabular semantics, prefer simple groups over grid or fixed layouts — they reflow better and produce correct semantic markup automatically. + +See sample: `grid-data-table-heuristic.good.al`. diff --git a/microsoft/knowledge/ui/group-caption-quality-is-not-an-accessibility-issue.md b/microsoft/knowledge/ui/group-caption-quality-is-not-an-accessibility-issue.md new file mode 100644 index 0000000..7c546b8 --- /dev/null +++ b/microsoft/knowledge/ui/group-caption-quality-is-not-an-accessibility-issue.md @@ -0,0 +1,20 @@ +--- +bc-version: [all] +domain: ui +keywords: [group, caption, missing, duplicate, generic, accessibility, false-positive] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Group caption quality is not an accessibility issue + +## Description + +Group captions affect page organization, but missing, generic, or duplicate group captions are **not** accessibility violations per the BC accessibility rules. Do not flag groups for missing, generic, or duplicate captions during an accessibility review. + +This rule prevents a common false positive: LLM-driven reviewers tend to flag "GroupName" or duplicated `Caption = 'General'` as accessibility issues, but the BC client does not depend on group captions for screen-reader announcements of the fields within. Caption quality belongs to other review domains (UI text / style), not accessibility. + +## Best Practice + +Treat group caption quality as a UI-text concern reviewed elsewhere. Accessibility findings on groups should be limited to the specific patterns documented in the `grid-data-table-heuristic.md`, `tabular-intent-requires-data-table-conditions.md`, and `group-labeled-first-child-exception.md` files. diff --git a/microsoft/knowledge/ui/group-labeled-first-child-exception.bad.al b/microsoft/knowledge/ui/group-labeled-first-child-exception.bad.al new file mode 100644 index 0000000..648e729 --- /dev/null +++ b/microsoft/knowledge/ui/group-labeled-first-child-exception.bad.al @@ -0,0 +1,22 @@ +page 50204 "UI Sample First Child Bad" +{ + PageType = Card; + SourceTable = Customer; + + layout + { + area(Content) + { + group(SomeGroup) + { + ShowCaption = false; + field(DescriptionField; Rec.Address) + { + ApplicationArea = All; + ShowCaption = false; + MultiLine = true; + } + } + } + } +} diff --git a/microsoft/knowledge/ui/keep-captions-on-editable-fields.good.al b/microsoft/knowledge/ui/group-labeled-first-child-exception.good.al similarity index 60% rename from microsoft/knowledge/ui/keep-captions-on-editable-fields.good.al rename to microsoft/knowledge/ui/group-labeled-first-child-exception.good.al index adb3ccd..3852237 100644 --- a/microsoft/knowledge/ui/keep-captions-on-editable-fields.good.al +++ b/microsoft/knowledge/ui/group-labeled-first-child-exception.good.al @@ -1,5 +1,8 @@ -page 50730 "UI Caption Good" +page 50203 "UI Sample First Child Good" { + PageType = Card; + SourceTable = Customer; + layout { area(Content) @@ -7,15 +10,13 @@ page 50730 "UI Caption Good" group(Description) { Caption = 'Description'; - field(DescriptionField; Rec.Description) + field(DescriptionField; Rec.Address) { - MultiLine = true; + ApplicationArea = All; ShowCaption = false; + MultiLine = true; } } - field(CustomerName; Rec."Customer Name") - { - } } } } diff --git a/microsoft/knowledge/ui/group-labeled-first-child-exception.md b/microsoft/knowledge/ui/group-labeled-first-child-exception.md new file mode 100644 index 0000000..2f47ae3 --- /dev/null +++ b/microsoft/knowledge/ui/group-labeled-first-child-exception.md @@ -0,0 +1,32 @@ +--- +bc-version: [all] +domain: ui +keywords: [show-caption, group, first-child, multiline, accessibility] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Group-labeled first child exception + +## Description + +`ShowCaption = false` is acceptable on an editable field only when **all** of the following conditions are met: + +1. The control is the **first visible field** in its parent group. +2. The field has `ShowCaption = false`. +3. The parent group has a visible caption: `ShowCaption` is true (the default) **and** the group has a non-empty `Caption` value. + +When these three conditions hold, the group caption becomes the accessible label for the field. This works regardless of whether the field is multiline. The presence of `InstructionalText` on the field is irrelevant to this check. + +## Best Practice + +Do not second-guess this exception. If the three conditions are met, the pattern is acceptable — even if the group caption seems generic (e.g. "General Information") or does not exactly match the field name. + +See sample: `group-labeled-first-child-exception.good.al`. + +## Anti Pattern + +If the parent group has `ShowCaption = false` or no `Caption`, the first-child exception does not apply: the field has no accessible label anywhere. + +See sample: `group-labeled-first-child-exception.bad.al`. diff --git a/microsoft/knowledge/ui/group-show-caption-false-outside-grid-is-not-a-violation.md b/microsoft/knowledge/ui/group-show-caption-false-outside-grid-is-not-a-violation.md new file mode 100644 index 0000000..fba4bfd --- /dev/null +++ b/microsoft/knowledge/ui/group-show-caption-false-outside-grid-is-not-a-violation.md @@ -0,0 +1,20 @@ +--- +bc-version: [all] +domain: ui +keywords: [group, show-caption, card, document, layout, accessibility, false-positive] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Group ShowCaption = false outside grid/fixed is a layout choice + +## Description + +In a standard Card or Document page, a group with `ShowCaption = false` is a layout choice, not an accessibility violation. Only flag `ShowCaption` issues as documented in the grid/fixed-layout and field-level `ShowCaption` rules — `show-caption-on-editable-fields.md`, `grid-data-table-heuristic.md`, `tabular-intent-requires-data-table-conditions.md`. + +The heuristic in BC's client uses **field** captions to decide between data-table and layout-table rendering. A captionless group (outside a grid or fixed layout) does not strip labels from its child fields — each field retains its own caption. + +## Best Practice + +Reserve accessibility findings for hidden **field** labels and grid-semantics problems. Do not raise a finding merely because a `group` block has `ShowCaption = false` in an ordinary Card or Document page layout. diff --git a/microsoft/knowledge/ui/keep-captions-on-editable-fields.bad.al b/microsoft/knowledge/ui/keep-captions-on-editable-fields.bad.al deleted file mode 100644 index 0d206a1..0000000 --- a/microsoft/knowledge/ui/keep-captions-on-editable-fields.bad.al +++ /dev/null @@ -1,14 +0,0 @@ -page 50731 "UI Caption Bad" -{ - layout - { - area(Content) - { - field(CustomerName; Rec."Customer Name") - { - InstructionalText = 'Enter the customer name.'; - ShowCaption = false; - } - } - } -} diff --git a/microsoft/knowledge/ui/keep-captions-on-editable-fields.md b/microsoft/knowledge/ui/keep-captions-on-editable-fields.md deleted file mode 100644 index aeea903..0000000 --- a/microsoft/knowledge/ui/keep-captions-on-editable-fields.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: ui -keywords: [showcaption, editable, accessibility, screen-reader, label] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Keep captions on editable fields - -## Description - -`ShowCaption = false` on an editable page field removes the visible and accessible label that identifies the input. `InstructionalText` is not a replacement: it behaves like placeholder text, disappears after entry, and is not reliably announced as the field label. The default `ShowCaption = true` is the safe form-field pattern. - -## Best Practice - -Leave captions visible on editable fields. `ShowCaption = false` is acceptable for non-editable content fields, for fields inside a valid data-table grid pattern, and for the first visible field in a parent group with a visible non-empty caption; in that last pattern, the group caption becomes the accessible label. - -See sample: `keep-captions-on-editable-fields.good.al`. - -## Anti Pattern - -Hiding the caption on an editable field because the page layout looks cleaner, or because `InstructionalText` appears to describe the input. Screen reader users lose the field label, and sighted users lose the persistent visual cue. - -See sample: `keep-captions-on-editable-fields.bad.al`. diff --git a/microsoft/knowledge/ui/layout-table-with-captions-is-valid.md b/microsoft/knowledge/ui/layout-table-with-captions-is-valid.md new file mode 100644 index 0000000..53caaec --- /dev/null +++ b/microsoft/knowledge/ui/layout-table-with-captions-is-valid.md @@ -0,0 +1,20 @@ +--- +bc-version: [all] +domain: ui +keywords: [grid, fixed, layout-table, show-caption, false-positive, accessibility] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Layout-table grids with visible captions are valid + +## Description + +A grid or fixed layout that does not meet all three data-table conditions renders as a **layout table**. A layout table where editable fields keep their visible captions is not an accessibility violation. Each field is labeled by its own caption — this is a valid, accessible pattern. + +Do not flag a grid or fixed layout as an accessibility issue merely because it does not meet the data-table heuristic. The violation is hidden labels in a non-data-table grid, not the layout choice itself. + +## Best Practice + +When reviewing a grid or fixed layout, first check whether it meets all data-table conditions. If yes, `ShowCaption = false` on fields is correct. If no, allow editable fields to keep their captions and only flag the cases enumerated in `tabular-intent-requires-data-table-conditions.md`. diff --git a/microsoft/knowledge/ui/manually-review-control-addin-ui-accessibility.md b/microsoft/knowledge/ui/manually-review-control-addin-ui-accessibility.md deleted file mode 100644 index 03123aa..0000000 --- a/microsoft/knowledge/ui/manually-review-control-addin-ui-accessibility.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -bc-version: [all] -domain: ui -keywords: [control-addin, javascript, accessibility, wcag, keyboard, aria] -technologies: [al, javascript] -countries: [w1] -application-area: [all] ---- - -# Manually review UI-rendering control add-in changes for accessibility - -## Description - -JavaScript control add-ins bypass much of the Business Central client's built-in accessibility support. Once the add-in renders its own HTML, JavaScript, or CSS, the extension owns WCAG 2.1 AA concerns such as accessible names, semantic HTML, keyboard navigation, color contrast, focus management, and 200% zoom/reflow. Automated review cannot exhaustively verify those behaviours. - -## Best Practice - -When a control add-in change touches DOM creation, templates, CSS, interaction handlers, ARIA attributes, dynamic visibility, or focus flow, include a manual accessibility review finding even if no specific defect is obvious. Do not require manual accessibility review for pure data processing or API changes that do not render UI. - -## Anti Pattern - -Treating a control add-in diff as clean because no AL page properties changed. A new `div`-based button without an accessible name, a keyboard trap, or a color-only status indicator lives in JavaScript and still affects Business Central users. diff --git a/microsoft/knowledge/ui/no-nested-grids.bad.al b/microsoft/knowledge/ui/no-nested-grids.bad.al new file mode 100644 index 0000000..03c4135 --- /dev/null +++ b/microsoft/knowledge/ui/no-nested-grids.bad.al @@ -0,0 +1,33 @@ +page 50210 "UI Sample Nested Grid Bad" +{ + PageType = Card; + SourceTable = Customer; + + layout + { + area(Content) + { + grid(OuterGrid) + { + GridLayout = Columns; + group(Left) + { + ShowCaption = false; + grid(InnerGrid) + { + GridLayout = Rows; + group(Row1) + { + ShowCaption = false; + field(Name; Rec.Name) + { + ApplicationArea = All; + ShowCaption = false; + } + } + } + } + } + } + } +} diff --git a/microsoft/knowledge/ui/no-nested-grids.md b/microsoft/knowledge/ui/no-nested-grids.md new file mode 100644 index 0000000..9de7ba4 --- /dev/null +++ b/microsoft/knowledge/ui/no-nested-grids.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: ui +keywords: [grid, nested-grid, fixed, data-table, accessibility] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Nested grids are not supported + +## Description + +A grid nested inside another grid is not a supported pattern in Business Central. Even if an inner grid independently meets the data-table heuristic, the outer grid fails because its groups contain non-field children (the inner grids). The result is broken table semantics for both layers. + +Always flag a nested grid as a violation. The fix is to restructure the page so there is at most one grid in any branch of the layout tree, choosing either a data-table or a layout-table arrangement. + +## Anti Pattern + +Wrapping a working data-table grid inside another grid in an attempt to compose two tabular regions side by side. The outer grid silently degrades to layout-table rendering, the inner grid's headers are no longer associated with the outer structure, and editable fields with `ShowCaption = false` lose their labels. + +See sample: `no-nested-grids.bad.al`. diff --git a/microsoft/knowledge/ui/on-drill-down-on-non-editable-fields-renders-as-link.md b/microsoft/knowledge/ui/on-drill-down-on-non-editable-fields-renders-as-link.md new file mode 100644 index 0000000..c27503d --- /dev/null +++ b/microsoft/knowledge/ui/on-drill-down-on-non-editable-fields-renders-as-link.md @@ -0,0 +1,20 @@ +--- +bc-version: [all] +domain: ui +keywords: [on-drill-down, link, non-editable, accessibility, false-positive] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# OnDrillDown on non-editable fields renders as a link + +## Description + +The Business Central client renders non-editable fields that have an `OnDrillDown` trigger as HTML `` (anchor) elements. Screen readers correctly announce these as links. `OnDrillDown` on a non-editable field is therefore **not** an accessibility issue — the platform handles the semantics. + +Do not flag `OnDrillDown` usage as an accessibility issue. The combination of `Editable = false` and `OnDrillDown` is the standard BC pattern for navigable, screen-reader-friendly value cells in list and card pages. + +## Best Practice + +Use `OnDrillDown` freely on non-editable fields when you want users to navigate from a value to a related record or detail page. No additional ARIA attributes or accessible-name workarounds are required. diff --git a/microsoft/knowledge/ui/provide-text-meaning-for-semantic-styles.bad.al b/microsoft/knowledge/ui/provide-text-meaning-for-semantic-styles.bad.al deleted file mode 100644 index 5af36ca..0000000 --- a/microsoft/knowledge/ui/provide-text-meaning-for-semantic-styles.bad.al +++ /dev/null @@ -1,19 +0,0 @@ -page 50735 "UI Style Bad" -{ - layout - { - area(Content) - { - field(Score; Score) - { - Caption = 'Score'; - Style = Favorable; - StyleExpr = IsGood; - } - } - } - - var - Score: Integer; - IsGood: Boolean; -} diff --git a/microsoft/knowledge/ui/provide-text-meaning-for-semantic-styles.good.al b/microsoft/knowledge/ui/provide-text-meaning-for-semantic-styles.good.al deleted file mode 100644 index 496d892..0000000 --- a/microsoft/knowledge/ui/provide-text-meaning-for-semantic-styles.good.al +++ /dev/null @@ -1,19 +0,0 @@ -page 50734 "UI Style Good" -{ - layout - { - area(Content) - { - field(ValidationStatus; ValidationStatus) - { - Caption = 'Validation status'; - Style = Unfavorable; - StyleExpr = HasValidationErrors; - } - } - } - - var - ValidationStatus: Text; - HasValidationErrors: Boolean; -} diff --git a/microsoft/knowledge/ui/provide-text-meaning-for-semantic-styles.md b/microsoft/knowledge/ui/provide-text-meaning-for-semantic-styles.md deleted file mode 100644 index f266288..0000000 --- a/microsoft/knowledge/ui/provide-text-meaning-for-semantic-styles.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: ui -keywords: [style, styleexpr, favorable, unfavorable, ambiguous, accessibility] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Provide text meaning for semantic styles - -## Description - -Most Business Central page styles are cosmetic, but `Favorable`, `Unfavorable`, and `Ambiguous` communicate meaning through color. Color-only meaning is not accessible. A user who cannot perceive the style must still be able to determine whether the value is positive, negative, or uncertain from the caption, value, or nearby text. - -## Best Practice - -Use semantic styles only when the meaning is independently available: a caption such as "Error", a value such as "Failed", a signed number whose sign carries the meaning, or an adjacent status field. Cosmetic styles such as `Strong`, `Attention`, and `Subordinate` do not need this extra check. Cue tiles inside `cuegroup` are exempt because the client supplies accessible semantic labels. - -See sample: `provide-text-meaning-for-semantic-styles.good.al`. - -## Anti Pattern - -Applying `Style = Favorable`, `Unfavorable`, or `Ambiguous` to a value whose text is neutral, such as "42" or "Open", without any caption or adjacent field explaining what the color means. - -See sample: `provide-text-meaning-for-semantic-styles.bad.al`. diff --git a/microsoft/knowledge/ui/respect-ui-text-character-limits.md b/microsoft/knowledge/ui/respect-ui-text-character-limits.md deleted file mode 100644 index 7577656..0000000 --- a/microsoft/knowledge/ui/respect-ui-text-character-limits.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -bc-version: [all] -domain: ui -keywords: [caption, tooltip, character-limit, truncation, localization] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Respect Business Central's UI text character limits to avoid truncation - -## Description - -Business Central UI surfaces have practical character limits before the platform truncates or the translator's localization overflows the available space. Authoring captions and tooltips close to the English limit almost guarantees truncation in languages whose translations are longer (German, French, Spanish average 20–40% longer than English). The limits are not hard compiler errors — they are product-quality thresholds that agents should flag at author time so the string reaches localization with room to grow. - -## Best Practice - -Author within these approximate limits (English): action and field captions ~40 chars; field-group, menu-item, page, and dialog titles ~40 chars; button captions ~20 chars; action and field tooltips ~250 chars; dialog text and error messages ~250 chars; notifications ~100 chars; checklist ShortTitleChecklist 34, LongerTitleCard 53, CardDescription 180. Leave headroom for longer translations; at 40/40 in English, German is likely to truncate. - -## Anti Pattern - -`action(RecalculateAndReapplyAllOutstandingCustomerDiscounts) { Caption = 'Recalculate and reapply all outstanding customer discounts'; }` — 58 characters in English, essentially guaranteed to truncate once translated. The fix is to shorten the English caption (`Recalculate customer discounts`, 30 chars) and move the full sentence into the tooltip where the budget is larger. diff --git a/microsoft/knowledge/ui/semantic-style-in-cuegroup-exception.good.al b/microsoft/knowledge/ui/semantic-style-in-cuegroup-exception.good.al new file mode 100644 index 0000000..5471632 --- /dev/null +++ b/microsoft/knowledge/ui/semantic-style-in-cuegroup-exception.good.al @@ -0,0 +1,31 @@ +page 50213 "UI Sample CueGroup Style" +{ + PageType = RoleCenter; + + layout + { + area(RoleCenter) + { + cuegroup(Activities) + { + Caption = 'Activities'; + field(OverdueInvoices; OverdueInvoiceCount) + { + ApplicationArea = All; + Caption = 'Overdue Invoices'; + Style = Unfavorable; + } + field(PaidInvoices; PaidInvoiceCount) + { + ApplicationArea = All; + Caption = 'Paid Invoices'; + Style = Favorable; + } + } + } + } + + var + OverdueInvoiceCount: Integer; + PaidInvoiceCount: Integer; +} diff --git a/microsoft/knowledge/ui/semantic-style-in-cuegroup-exception.md b/microsoft/knowledge/ui/semantic-style-in-cuegroup-exception.md new file mode 100644 index 0000000..5f26333 --- /dev/null +++ b/microsoft/knowledge/ui/semantic-style-in-cuegroup-exception.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: ui +keywords: [style, cuegroup, cue-tile, favorable, unfavorable, accessibility] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Semantic styles in a cuegroup are auto-labeled + +## Description + +Fields inside a `cuegroup` render as cue tiles. The Business Central client automatically provides an accessible label for semantic styles on cue tiles (for example, "Favorable", "Unfavorable"). Semantic styles in a `cuegroup` therefore do **not** need additional context and should be ignored when checking that semantic colors are backed by text. + +This is a narrow platform exception to `semantic-styles-need-independent-textual-meaning.md`. Outside a `cuegroup`, the normal rule applies. + +## Best Practice + +You may apply `Favorable`, `Unfavorable`, or `Ambiguous` to fields inside a `cuegroup` without supplying a redundant textual indicator — the platform supplies the screen-reader text. Reserve this shortcut for cue tiles only; do not extend it to other layout containers. + +See sample: `semantic-style-in-cuegroup-exception.good.al`. diff --git a/microsoft/knowledge/ui/semantic-styles-need-independent-textual-meaning.bad.al b/microsoft/knowledge/ui/semantic-styles-need-independent-textual-meaning.bad.al new file mode 100644 index 0000000..4741a7c --- /dev/null +++ b/microsoft/knowledge/ui/semantic-styles-need-independent-textual-meaning.bad.al @@ -0,0 +1,27 @@ +page 50212 "UI Sample Semantic Style Bad" +{ + PageType = Card; + SourceTable = Customer; + + layout + { + area(Content) + { + field(CompanyName; Rec.Name) + { + ApplicationArea = All; + Style = Favorable; + } + field(Confidence; ConfidencePercent) + { + ApplicationArea = All; + Caption = 'Confidence'; + StyleExpr = ConfidenceStyle; + } + } + } + + var + ConfidencePercent: Decimal; + ConfidenceStyle: Text; +} diff --git a/microsoft/knowledge/ui/semantic-styles-need-independent-textual-meaning.good.al b/microsoft/knowledge/ui/semantic-styles-need-independent-textual-meaning.good.al new file mode 100644 index 0000000..a42cc08 --- /dev/null +++ b/microsoft/knowledge/ui/semantic-styles-need-independent-textual-meaning.good.al @@ -0,0 +1,28 @@ +page 50211 "UI Sample Semantic Style Good" +{ + PageType = Card; + SourceTable = "Cust. Ledger Entry"; + + layout + { + area(Content) + { + field(OverdueAmount; Rec."Remaining Amount") + { + ApplicationArea = All; + Caption = 'Overdue Amount'; + Style = Unfavorable; + } + field(ProfitMargin; Rec.Amount) + { + ApplicationArea = All; + Caption = 'Profit Margin'; + Style = Favorable; + StyleExpr = IsProfitable; + } + } + } + + var + IsProfitable: Boolean; +} diff --git a/microsoft/knowledge/ui/semantic-styles-need-independent-textual-meaning.md b/microsoft/knowledge/ui/semantic-styles-need-independent-textual-meaning.md new file mode 100644 index 0000000..9d58d41 --- /dev/null +++ b/microsoft/knowledge/ui/semantic-styles-need-independent-textual-meaning.md @@ -0,0 +1,34 @@ +--- +bc-version: [all] +domain: ui +keywords: [style, favorable, unfavorable, ambiguous, color, accessibility] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Semantic styles need independent textual meaning + +## Description + +Three `Style` values carry semantic meaning through color and must be backed by text that conveys the same meaning: + +- `Favorable` (Bold + Green) — implies a positive outcome. +- `Unfavorable` (Bold + Italic + Red) — implies a negative outcome. +- `Ambiguous` (Yellow) — implies an uncertain or mixed outcome. + +For accessibility, assume the style is completely invisible to the user. The semantic meaning must be independently determinable from at least one of: + +1. The **field caption** matches the semantic meaning (e.g. caption "Error" with `Style = Unfavorable`, or "Profit" with `Style = Favorable`). +2. The **field value** communicates the meaning (e.g. value "Success!" with Favorable, a negative number with Unfavorable). +3. An **adjacent field** provides a textual representation of the semantic meaning (e.g. a "Status" column reads "High" / "Medium" / "Low" alongside a percentage field). + +The rule applies equally whether `Style` is set to a literal value or to a variable that evaluates to a semantic style at runtime. + +## Best Practice + +When you reach for `Favorable`, `Unfavorable`, or `Ambiguous`, verify that the caption, value, or an adjacent column already conveys the same meaning. See sample: `semantic-styles-need-independent-textual-meaning.good.al`. + +## Anti Pattern + +Applying a semantic style for purely cosmetic emphasis (e.g. green company name for aesthetics), or using semantic colors where only the color reveals the threshold (e.g. confidence percentages with no qualitative label). See sample: `semantic-styles-need-independent-textual-meaning.bad.al`. diff --git a/microsoft/knowledge/ui/show-caption-false-allowed-on-non-editable-fields.good.al b/microsoft/knowledge/ui/show-caption-false-allowed-on-non-editable-fields.good.al new file mode 100644 index 0000000..16b5300 --- /dev/null +++ b/microsoft/knowledge/ui/show-caption-false-allowed-on-non-editable-fields.good.al @@ -0,0 +1,18 @@ +page 50202 "UI Sample NonEditable Caption" +{ + PageType = Card; + SourceTable = Customer; + + layout + { + area(Content) + { + field("Customer Name"; Rec.Name) + { + ApplicationArea = All; + Editable = false; + ShowCaption = false; + } + } + } +} diff --git a/microsoft/knowledge/ui/show-caption-false-allowed-on-non-editable-fields.md b/microsoft/knowledge/ui/show-caption-false-allowed-on-non-editable-fields.md new file mode 100644 index 0000000..dcf4d95 --- /dev/null +++ b/microsoft/knowledge/ui/show-caption-false-allowed-on-non-editable-fields.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: ui +keywords: [show-caption, non-editable, content, accessibility] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# ShowCaption = false on non-editable fields + +## Description + +When a field is explicitly non-editable (`Editable = false`), it serves as content rather than as a form field. In that case, `ShowCaption = false` is acceptable: there is no input control whose label could be lost. The combination signals to a reviewer (and to the platform) that the field displays a value standalone — for example a status message or a description that is meaningful on its own. + +This exception does **not** extend to dynamically editable fields. A field with `Editable = SomeBooleanExpression` may be editable at runtime and must keep its caption. + +## Best Practice + +If you want to hide a field's caption, pair `ShowCaption = false` with a literal `Editable = false`. Use this pattern only for content fields that do not act as labels for other fields in the same layout container. + +See sample: `show-caption-false-allowed-on-non-editable-fields.good.al`. diff --git a/microsoft/knowledge/ui/show-caption-in-promptdialog-prompt-area.good.al b/microsoft/knowledge/ui/show-caption-in-promptdialog-prompt-area.good.al new file mode 100644 index 0000000..082ed47 --- /dev/null +++ b/microsoft/knowledge/ui/show-caption-in-promptdialog-prompt-area.good.al @@ -0,0 +1,31 @@ +page 50206 "UI Sample PromptDialog" +{ + PageType = PromptDialog; + Caption = 'Draft new project with Copilot'; + + layout + { + area(Prompt) + { + field(ProjectDescription; InputProjectDescription) + { + ApplicationArea = All; + ShowCaption = false; + MultiLine = true; + InstructionalText = 'Describe the project'; + } + } + area(Content) + { + field("Job Description"; JobDescription) + { + ApplicationArea = All; + Caption = 'Project Description'; + } + } + } + + var + InputProjectDescription: Text; + JobDescription: Text; +} diff --git a/microsoft/knowledge/ui/show-caption-in-promptdialog-prompt-area.md b/microsoft/knowledge/ui/show-caption-in-promptdialog-prompt-area.md new file mode 100644 index 0000000..9b2e43b --- /dev/null +++ b/microsoft/knowledge/ui/show-caption-in-promptdialog-prompt-area.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: ui +keywords: [show-caption, promptdialog, copilot, prompt, accessibility] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# ShowCaption in a PromptDialog prompt area + +## Description + +On `PageType = PromptDialog` pages, input fields inside `area(Prompt)` are labeled by the dialog's heading — the page `Caption`. Setting `ShowCaption = false` on such an input field is the standard pattern and should not be flagged, provided the page has a `Caption`. + +Fields in the `area(Content)` section of the same PromptDialog page are **not** labeled by the dialog heading and follow the normal `ShowCaption` rules. + +## Best Practice + +In a PromptDialog, give the page a meaningful `Caption` (the dialog heading) and let prompt-area input fields hide their own captions. Treat content-area fields like any other editable field — keep their captions. + +See sample: `show-caption-in-promptdialog-prompt-area.good.al`. diff --git a/microsoft/knowledge/ui/show-caption-in-repeater-allowed.good.al b/microsoft/knowledge/ui/show-caption-in-repeater-allowed.good.al new file mode 100644 index 0000000..c463e71 --- /dev/null +++ b/microsoft/knowledge/ui/show-caption-in-repeater-allowed.good.al @@ -0,0 +1,25 @@ +page 50205 "UI Sample Repeater" +{ + PageType = List; + SourceTable = "Sales Line"; + + layout + { + area(Content) + { + repeater(Lines) + { + field(Description; Rec.Description) + { + ApplicationArea = All; + ShowCaption = false; + } + field(Amount; Rec.Amount) + { + ApplicationArea = All; + ShowCaption = false; + } + } + } + } +} diff --git a/microsoft/knowledge/ui/show-caption-in-repeater-allowed.md b/microsoft/knowledge/ui/show-caption-in-repeater-allowed.md new file mode 100644 index 0000000..b161149 --- /dev/null +++ b/microsoft/knowledge/ui/show-caption-in-repeater-allowed.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: ui +keywords: [show-caption, repeater, column-header, accessibility] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# ShowCaption inside a repeater is harmless + +## Description + +Fields inside a `repeater()` control are labeled by their **column headers**, not by their own captions. `ShowCaption = false` on a field inside a repeater is harmless and should not be flagged. + +This is the explicit behaviour of the Business Central client: a repeater renders as a tabular list whose column headings come from each field's `Caption` (or source-table caption), and individual row cells do not announce a per-cell caption. + +## Best Practice + +Inside a repeater, you may set `ShowCaption = false` on fields without losing accessibility. The column header still provides the label for every cell in that column. Outside a repeater, the rules in `show-caption-on-editable-fields.md` apply. + +See sample: `show-caption-in-repeater-allowed.good.al`. diff --git a/microsoft/knowledge/ui/show-caption-on-editable-fields.bad.al b/microsoft/knowledge/ui/show-caption-on-editable-fields.bad.al new file mode 100644 index 0000000..ea39356 --- /dev/null +++ b/microsoft/knowledge/ui/show-caption-on-editable-fields.bad.al @@ -0,0 +1,27 @@ +page 50201 "UI Sample Editable Caption Bad" +{ + PageType = Card; + SourceTable = Customer; + + layout + { + area(Content) + { + field("Customer Name"; Rec.Name) + { + ApplicationArea = All; + ShowCaption = false; + InstructionalText = 'Enter the customer name'; + } + field("Dynamic Editable"; Rec."No.") + { + ApplicationArea = All; + Editable = IsEditable; + ShowCaption = false; + } + } + } + + var + IsEditable: Boolean; +} diff --git a/microsoft/knowledge/ui/show-caption-on-editable-fields.good.al b/microsoft/knowledge/ui/show-caption-on-editable-fields.good.al new file mode 100644 index 0000000..9030dd4 --- /dev/null +++ b/microsoft/knowledge/ui/show-caption-on-editable-fields.good.al @@ -0,0 +1,16 @@ +page 50200 "UI Sample Editable Caption Good" +{ + PageType = Card; + SourceTable = Customer; + + layout + { + area(Content) + { + field("Customer Name"; Rec.Name) + { + ApplicationArea = All; + } + } + } +} diff --git a/microsoft/knowledge/ui/show-caption-on-editable-fields.md b/microsoft/knowledge/ui/show-caption-on-editable-fields.md new file mode 100644 index 0000000..23075fd --- /dev/null +++ b/microsoft/knowledge/ui/show-caption-on-editable-fields.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: ui +keywords: [show-caption, editable, accessibility, label, instructional-text] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# ShowCaption on editable fields + +## Description + +`ShowCaption` must remain true (the default) on editable fields unless the field matches one of the officially supported "magic patterns". Fields are editable by default. Setting `ShowCaption = false` on an editable field is almost always an accessibility bug: without a visible caption, screen reader users lose the label that identifies the field, and sighted users lose a visual cue. + +A field whose `Editable` property is a Boolean expression (e.g. `Editable = IsEditable`) is dynamically editable and must be treated as a form field — `ShowCaption = false` on such a field is also a violation. + +## Best Practice + +Leave `ShowCaption` at its default on editable fields. If a caption would be visually redundant, rely on one of the documented magic patterns (group-labeled first child, repeater column, PromptDialog prompt input) rather than removing the caption. + +See sample: `show-caption-on-editable-fields.good.al`. + +## Anti Pattern + +The `InstructionalText` property on a field renders as HTML placeholder text and is **not** a substitute for a caption — it disappears once the user types and is not reliably announced by screen readers. + +See sample: `show-caption-on-editable-fields.bad.al`. diff --git a/microsoft/knowledge/ui/standalone-content-in-layout-table.good.al b/microsoft/knowledge/ui/standalone-content-in-layout-table.good.al new file mode 100644 index 0000000..70f5213 --- /dev/null +++ b/microsoft/knowledge/ui/standalone-content-in-layout-table.good.al @@ -0,0 +1,39 @@ +page 50208 "UI Sample Standalone Content" +{ + PageType = Card; + SourceTable = Customer; + + layout + { + area(Content) + { + grid(InfoGrid) + { + GridLayout = Columns; + group(LeftColumn) + { + field(Address; Rec.Address) + { + ApplicationArea = All; + } + field(City; Rec.City) + { + ApplicationArea = All; + } + } + group(RightColumn) + { + field(StatusMessage; StatusText) + { + ApplicationArea = All; + Editable = false; + ShowCaption = false; + } + } + } + } + } + + var + StatusText: Text; +} diff --git a/microsoft/knowledge/ui/standalone-content-in-layout-table.md b/microsoft/knowledge/ui/standalone-content-in-layout-table.md new file mode 100644 index 0000000..74a69b2 --- /dev/null +++ b/microsoft/knowledge/ui/standalone-content-in-layout-table.md @@ -0,0 +1,22 @@ +--- +bc-version: [all] +domain: ui +keywords: [grid, fixed, layout-table, standalone-content, show-caption, accessibility] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Standalone content in a layout-table grid + +## Description + +A non-editable field with `ShowCaption = false` is acceptable inside a layout-table grid **only when** the field is **standalone content** — it displays a value that is meaningful on its own (for example a status message or a description) and is **not** intended to label or be labeled by another field in the grid. + +Layout tables have no `
` column headers, so a captionless field that is meant to participate in a tabular relationship with a neighbour has no accessible label at all. + +## Best Practice + +Reserve `ShowCaption = false` in a layout-table grid for non-editable, free-standing content cells. If a field's role is to label or annotate another field in the same grid, restructure the grid to meet the data-table conditions (see `grid-data-table-heuristic.md`) instead of hiding the caption. + +See sample: `standalone-content-in-layout-table.good.al`. diff --git a/microsoft/knowledge/ui/style-expr-text-vs-boolean.good.al b/microsoft/knowledge/ui/style-expr-text-vs-boolean.good.al new file mode 100644 index 0000000..3d09c2a --- /dev/null +++ b/microsoft/knowledge/ui/style-expr-text-vs-boolean.good.al @@ -0,0 +1,41 @@ +page 50214 "UI Sample StyleExpr" +{ + PageType = List; + SourceTable = "Sales Header"; + + layout + { + area(Content) + { + repeater(Lines) + { + field(Status; Rec.Status) + { + ApplicationArea = All; + StyleExpr = StatusStyle; + } + field(Amount; Rec.Amount) + { + ApplicationArea = All; + Style = Favorable; + StyleExpr = IsProfitable; + } + } + } + } + + trigger OnAfterGetRecord() + begin + case Rec.Status of + Rec.Status::Open: + StatusStyle := 'Standard'; + Rec.Status::Released: + StatusStyle := 'Favorable'; + end; + IsProfitable := Rec.Amount > 0; + end; + + var + StatusStyle: Text; + IsProfitable: Boolean; +} diff --git a/microsoft/knowledge/ui/style-expr-text-vs-boolean.md b/microsoft/knowledge/ui/style-expr-text-vs-boolean.md new file mode 100644 index 0000000..5040099 --- /dev/null +++ b/microsoft/knowledge/ui/style-expr-text-vs-boolean.md @@ -0,0 +1,25 @@ +--- +bc-version: [all] +domain: ui +keywords: [style-expr, style, boolean, text-variable, accessibility] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# StyleExpr: Boolean toggle vs Text variable + +## Description + +`StyleExpr` on a page field serves two distinct purposes depending on its type: + +- **Boolean** — When `StyleExpr` is a Boolean expression, it controls whether the `Style` property is applied. In this case the `Style` property carries the style name; analyze `Style` and ignore `StyleExpr` itself. +- **Text** — When `StyleExpr` is a Text variable (e.g. `StyleExpr = StatusStyle` where `StatusStyle: Text` and is assigned literals such as `'Favorable'`), the variable contains the style name at runtime. There may be no `Style` property at all — the `StyleExpr` variable **is** the style. + +When `StyleExpr` is Text, you must trace the variable's assignments — typically in `OnAfterGetRecord` or `OnAfterGetCurrRecord` — to determine which styles can be applied, then apply the same accessibility rules as for a literal `Style` value. + +## Best Practice + +Inspect the declared type of the symbol referenced by `StyleExpr` before drawing conclusions. If it is Boolean, evaluate the `Style` property. If it is Text, follow every assignment to the variable and check the full set of possible style values against `cosmetic-styles-need-no-textual-context.md` and `semantic-styles-need-independent-textual-meaning.md`. + +See sample: `style-expr-text-vs-boolean.good.al`. diff --git a/microsoft/knowledge/ui/tabular-intent-requires-data-table-conditions.bad.al b/microsoft/knowledge/ui/tabular-intent-requires-data-table-conditions.bad.al new file mode 100644 index 0000000..3fc52ec --- /dev/null +++ b/microsoft/knowledge/ui/tabular-intent-requires-data-table-conditions.bad.al @@ -0,0 +1,40 @@ +page 50209 "UI Sample Tabular Mix Bad" +{ + PageType = Card; + SourceTable = "Cust. Ledger Entry"; + + layout + { + area(Content) + { + grid(StatementGrid) + { + GridLayout = Columns; + group(Periods) + { + ShowCaption = false; + field(StatementPeriod; Rec."Posting Date") + { + ApplicationArea = All; + Editable = false; + ShowCaption = false; + } + } + group(Balances) + { + ShowCaption = false; + field(StatementBalance; Rec.Amount) + { + ApplicationArea = All; + Editable = false; + ShowCaption = false; + } + field(DueDate; Rec."Due Date") + { + ApplicationArea = All; + } + } + } + } + } +} diff --git a/microsoft/knowledge/ui/tabular-intent-requires-data-table-conditions.md b/microsoft/knowledge/ui/tabular-intent-requires-data-table-conditions.md new file mode 100644 index 0000000..b56aadb --- /dev/null +++ b/microsoft/knowledge/ui/tabular-intent-requires-data-table-conditions.md @@ -0,0 +1,27 @@ +--- +bc-version: [all] +domain: ui +keywords: [grid, fixed, tabular-intent, data-table, accidental-mix, accessibility] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Tabular intent requires data-table conditions + +## Description + +The most common accessibility bug in grid layouts is partially following the data-table conventions. A developer arranges fields with **tabular intent** — one field acts as a label or row header for another — but the grid does not satisfy all the data-table heuristic conditions. The client falls back to layout-table rendering, and the tabular relationships between fields are lost: a screen reader announces each field independently with no programmatic association. + +Flag a grid as an accessibility issue when any of these are true: + +- An editable field has `ShowCaption = false` and the grid does not meet all data-table conditions. +- Fields are arranged so that one field is clearly intended to label or describe another field (tabular data intent), but the grid does not meet all data-table conditions. + +Both manifestations have the same root cause: tabular semantics were intended but the heuristic ultimately rendered the grid as a layout table. + +## Anti Pattern + +A single field that keeps its visible caption is enough to demote an entire would-be data-table grid into a layout table — and silently strip the labels off its sibling captionless fields. Either restructure to meet all three conditions, or restore captions on every editable field. + +See sample: `tabular-intent-requires-data-table-conditions.bad.al`. diff --git a/microsoft/knowledge/ui/titles-have-no-trailing-punctuation.md b/microsoft/knowledge/ui/titles-have-no-trailing-punctuation.md deleted file mode 100644 index a076a76..0000000 --- a/microsoft/knowledge/ui/titles-have-no-trailing-punctuation.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -bc-version: [all] -domain: ui -keywords: [title, caption, page, dialog, punctuation, ellipsis] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Titles carry no trailing punctuation and no trailing ellipsis - -## Description - -Page titles, section titles, FastTab titles, and dialog titles in Business Central are labels, not sentences — they have no trailing period, question mark, or exclamation. Trailing ellipsis ("…" or "...") on a title is specifically a long-standing Windows convention for action buttons that open a dialog, and AL handles that via the action's runtime behaviour rather than the caption text. Adding the ellipsis literally into a page caption or action caption is wrong in both directions: the platform also displays its own ellipsis when appropriate, and the static three dots corrupt translations that adjust punctuation for the locale. - -## Best Practice - -End titles with the last word of the title. Sentence case per the capitalization rule for the phrase type (see `caption-capitalization-noun-phrase-vs-sentence-phrase`). If a dialog needs "…" behaviour, rely on the platform; do not type the characters into the caption string. - -## Anti Pattern - -`Caption = 'Setup wizard...'`, `Caption = 'Sales orders.'`, `page Caption = 'Customer list:'` — all three decorate the title with terminal punctuation that is noise to the reader and a translation headache. diff --git a/microsoft/knowledge/ui/tooltips-describe-teaching-tips-guide.md b/microsoft/knowledge/ui/tooltips-describe-teaching-tips-guide.md deleted file mode 100644 index da920e3..0000000 --- a/microsoft/knowledge/ui/tooltips-describe-teaching-tips-guide.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -bc-version: [all] -domain: ui -keywords: [tooltip, teaching-tip, abouttitle, abouttext, onboarding] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Tooltips describe what a thing is; teaching tips guide what the user can do with it - -## Description - -Business Central exposes two distinct affordances for explaining the UI: ToolTip and the AboutTitle/AboutText teaching tip. They answer different questions and are complementary, not alternatives. ToolTip answers "What is this field/action?" and is expected on every field and action. The teaching tip answers "What can I do with this page or this important element?" and is reserved for the few entry points where an onboarding hint is worth the user's attention. Authors who put teaching-tip content in tooltips make tooltips noisy; authors who put tooltip content in teaching tips make teaching tips useless. - -## Best Practice - -Write ToolTip as a concise descriptive sentence following the "Specifies …" or imperative voice rules. Reserve AboutTitle/AboutText for the top-level card and list pages where first-time users benefit from discovering the page's purpose and outcome. On list pages, title uses the plural form ("About sales invoices"). On card or document pages, title uses the entity name plus "details" ("About sales invoice details"). - -## Anti Pattern - -A field ToolTip that tells the user "You can create new customers from here and update their payment terms, and the list also shows…" — that is teaching-tip content. Conversely, an AboutText that simply repeats the page Caption tells the user nothing they did not already read in the title bar. diff --git a/microsoft/knowledge/ui/tour-tips-do-not-use-action-language.md b/microsoft/knowledge/ui/tour-tips-do-not-use-action-language.md deleted file mode 100644 index 026a005..0000000 --- a/microsoft/knowledge/ui/tour-tips-do-not-use-action-language.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -bc-version: [all] -domain: ui -keywords: [tour-tip, abouttext, teaching-tip, imperative, onboarding] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Tour tips describe outcomes, not instructions — never tell the user to perform an action during the tour - -## Description - -A tour is a guided sequence of teaching tips that runs over the page while the user is passively watching. The tour framework does not expose the page's actions during the tip — so an `AboutText` that tells the user `Enter the customer name here.` or `Now post the invoice.` asks the user to do something that is not possible in the moment. The result is a confusing first-run experience. Tour content should describe what the element represents and what the user will be able to do with it after the tour completes, in descriptive rather than imperative voice. - -## Best Practice - -Write tour AboutTitle as a short noun-phrase label for the element ("Who you are selling to", "When all is set, you post"). Write AboutText as one or two sentences that describe the outcome or meaning, not steps. Keep the tour itself short — one to four tips total — and let the regular ToolTip carry the per-element detail. - -## Anti Pattern - -`AboutText = 'Enter the customer name here.'` on a tour tip — the action is not active. `AboutText = 'Now post the invoice.'` during a tour — the user cannot, and would not want to mid-tour. Both teach nothing and confuse the reader. diff --git a/microsoft/knowledge/ui/use-and-not-ampersand-in-ui-captions.bad.al b/microsoft/knowledge/ui/use-and-not-ampersand-in-ui-captions.bad.al deleted file mode 100644 index 8142b2f..0000000 --- a/microsoft/knowledge/ui/use-and-not-ampersand-in-ui-captions.bad.al +++ /dev/null @@ -1,20 +0,0 @@ -page 51007 "UI Sample Ampersand Bad" -{ - PageType = Card; - SourceTable = "Sales Header"; - - actions - { - area(Processing) - { - action(PostAndSend) - { - // '&' is being used as "and", not as an accelerator prefix. The - // parser cannot tell; translators re-evaluate every occurrence. - Caption = 'Post & Send'; - ApplicationArea = All; - ToolTip = 'Post and send the document.'; - } - } - } -} diff --git a/microsoft/knowledge/ui/use-and-not-ampersand-in-ui-captions.good.al b/microsoft/knowledge/ui/use-and-not-ampersand-in-ui-captions.good.al deleted file mode 100644 index 723a70b..0000000 --- a/microsoft/knowledge/ui/use-and-not-ampersand-in-ui-captions.good.al +++ /dev/null @@ -1,19 +0,0 @@ -page 51006 "UI Sample Ampersand Good" -{ - PageType = Card; - SourceTable = "Sales Header"; - - actions - { - area(Processing) - { - action(PostAndSend) - { - // "and" written out. Ampersand-s marks 's' as the accelerator key. - Caption = 'Post and &send'; - ApplicationArea = All; - ToolTip = 'Post the document and send it to the customer.'; - } - } - } -} diff --git a/microsoft/knowledge/ui/use-and-not-ampersand-in-ui-captions.md b/microsoft/knowledge/ui/use-and-not-ampersand-in-ui-captions.md deleted file mode 100644 index 02746fd..0000000 --- a/microsoft/knowledge/ui/use-and-not-ampersand-in-ui-captions.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: ui -keywords: [ampersand, caption, accelerator, translation, voice] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Write "and" in UI captions; keep the ampersand only as an accelerator-key prefix - -## Description - -AL Caption strings use the ampersand character in two distinct ways. Inside a caption, `&` is the accelerator-key prefix — `Caption = '&Post'` underlines the P and makes Alt+P activate the action. Outside that role, `&` is sometimes used as a shortening for the word "and" (`Post & Send`). The first usage is platform-defined and must be preserved. The second is a style choice that the Business Central voice guidelines reject: `Post and send` reads naturally in all supported locales and translates cleanly, while `Post & Send` conveys nothing extra and adds a character that localizers have to re-evaluate. - -## Best Practice - -Use the word "and" in caption text. Keep `&` only when it is immediately followed by a letter chosen as the keyboard accelerator. If both meanings apply, write them explicitly: `Post and &send` uses `s` as the accelerator and spells the conjunction out. - -See sample: `use-and-not-ampersand-in-ui-captions.good.al`. - -## Anti Pattern - -`Caption = 'Post & Send'` as the full caption — the ampersand is meant as "and" but the AL parser cannot tell, and the result is inconsistent with every other "X and Y" caption in the product. - -See sample: `use-and-not-ampersand-in-ui-captions.bad.al`. diff --git a/microsoft/knowledge/ui/use-grid-data-table-pattern-consistently.bad.al b/microsoft/knowledge/ui/use-grid-data-table-pattern-consistently.bad.al deleted file mode 100644 index 2b34f6c..0000000 --- a/microsoft/knowledge/ui/use-grid-data-table-pattern-consistently.bad.al +++ /dev/null @@ -1,24 +0,0 @@ -page 50733 "UI Grid Bad" -{ - layout - { - area(Content) - { - grid(BalanceGrid) - { - GridLayout = Columns; - field(CustomerName; Rec."Customer Name") - { - ShowCaption = false; - } - group(BalanceColumn) - { - field(Balance; Rec.Balance) - { - ShowCaption = false; - } - } - } - } - } -} diff --git a/microsoft/knowledge/ui/use-grid-data-table-pattern-consistently.md b/microsoft/knowledge/ui/use-grid-data-table-pattern-consistently.md deleted file mode 100644 index b30dcd1..0000000 --- a/microsoft/knowledge/ui/use-grid-data-table-pattern-consistently.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: ui -keywords: [grid, fixed, showcaption, accessibility, table-semantics] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Use the grid data-table pattern consistently - -## Description - -Business Central `grid` and `fixed` layouts render either as data tables or layout tables based on a structural heuristic. A data table requires all direct children to be groups, every group child to be a field, and all fields to have `ShowCaption = false`. If the structure fails that heuristic, the client renders a layout table; hidden captions on editable fields then remove the only accessible labels. - -## Best Practice - -Use one pattern consistently. For a data-table grid, make every direct child a group and every field `ShowCaption = false`. For a layout grid, keep captions visible on editable or tabular fields and hide captions only on standalone non-editable content where the missing label is not a form-field problem. - -See sample: `use-grid-data-table-pattern-consistently.good.al`. - -## Anti Pattern - -Mixing the patterns: one loose field, nested group, or visible field caption prevents data-table rendering, while other editable fields still hide captions. The result looks like a table visually but has layout-table semantics and missing labels for assistive technology. - -See sample: `use-grid-data-table-pattern-consistently.bad.al`. diff --git a/microsoft/knowledge/upgrade/assess-existing-data-before-key-or-type-changes.md b/microsoft/knowledge/upgrade/assess-existing-data-before-key-or-type-changes.md deleted file mode 100644 index 63ffed1..0000000 --- a/microsoft/knowledge/upgrade/assess-existing-data-before-key-or-type-changes.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -bc-version: [all] -domain: upgrade -keywords: [primary-key, field-type, existing-data, schema, breaking-change] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Assess existing data before primary-key or field-type changes - -## Description - -Primary-key and field-type changes are upgrade concerns because existing rows may no longer map safely to the new schema. The risk depends on whether the table already has tenant data and whether the old values can be converted without loss. New feature tables with no production rows do not have the same migration burden as ledger, document, or base application tables. - -## Best Practice - -For existing tables with data, require a concrete migration or compatibility assessment before changing keys or field types. For new tables, new feature tables, or Integer-to-BigInteger changes with evidence that existing values fit, avoid flagging a breaking-change finding without data-impact evidence. - -## Anti Pattern - -Treating every primary-key edit in a new feature table as a blocker while missing a key or type change on an established ledger-like table. Reviewers need to tie the finding to existing tenant data, not just to the syntactic shape of the schema edit. diff --git a/microsoft/knowledge/upgrade/breaking-changes-only-on-tables-without-data.bad.al b/microsoft/knowledge/upgrade/breaking-changes-only-on-tables-without-data.bad.al new file mode 100644 index 0000000..00ed5e3 --- /dev/null +++ b/microsoft/knowledge/upgrade/breaking-changes-only-on-tables-without-data.bad.al @@ -0,0 +1,15 @@ +// A pre-existing table with millions of rows. Changing the primary key or +// widening a field type without an upgrade plan can fail at deployment. +tableextension 50233 "Cust Ledger Entry Ext" extends "Cust. Ledger Entry" +{ + fields + { + // Widening Integer to BigInteger on an existing column with persisted data + // requires an upgrade plan and value-range evidence; not safe as a bare edit. + modify("Entry No.") + { + // (hypothetical: field type change goes here) + } + } + // No accompanying upgrade codeunit, no upgrade tag, no overflow verification. +} diff --git a/microsoft/knowledge/upgrade/breaking-changes-only-on-tables-without-data.good.al b/microsoft/knowledge/upgrade/breaking-changes-only-on-tables-without-data.good.al new file mode 100644 index 0000000..455477a --- /dev/null +++ b/microsoft/knowledge/upgrade/breaking-changes-only-on-tables-without-data.good.al @@ -0,0 +1,16 @@ +// New feature table introduced in the same change as the keys / field types. +// No existing data, so the layout is free to choose. +table 50232 "New Feature Table" +{ + fields + { + field(1; "Entry No."; BigInteger) { } + field(2; "Customer No."; Code[20]) { } + field(3; "Posting Date"; Date) { } + } + keys + { + key(PK; "Entry No.") { Clustered = true; } + key(ByCustomer; "Customer No.", "Posting Date") { } + } +} diff --git a/microsoft/knowledge/upgrade/breaking-changes-only-on-tables-without-data.md b/microsoft/knowledge/upgrade/breaking-changes-only-on-tables-without-data.md new file mode 100644 index 0000000..9ba7e8a --- /dev/null +++ b/microsoft/knowledge/upgrade/breaking-changes-only-on-tables-without-data.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [primary-key, field-type, breaking-change, integer-to-biginteger, existing-data] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Primary-key and field-type changes are safe only on tables without existing data + +## Description + +Primary-key changes and field-type changes (for example widening `Integer` to `BigInteger`) rewrite the on-disk layout of every row in the table. On a new feature table that ships in the same change as the modification, no rows exist and the change is free. On an existing table that already holds tenant data — base-app tables, ledger entries, anything that has been live across releases — the same change can fail outright (key uniqueness violations, value overflow on conversion) or require a full table rewrite during the upgrade window. Either way, the change needs an explicit migration design, not just a metadata edit. + +## Best Practice + +Treat primary-key and field-type changes as restricted to tables introduced in the same change. For changes on tables with existing data, design and ship the corresponding upgrade procedure (typically backed by `DataTransfer` and an upgrade tag) that guarantees the new layout is achievable for every row, and verify with concrete evidence that the existing values fit the new constraint (no PK collisions, no value-range overflow). + +See sample: `breaking-changes-only-on-tables-without-data.good.al`. + +## Anti Pattern + +Changing the primary key on a base-app table, or widening / narrowing a field type on a table that has been shipping for releases, with no accompanying upgrade plan. The change compiles cleanly and may even deploy on an empty-ish tenant, then fails on customers who actually have data. + +See sample: `breaking-changes-only-on-tables-without-data.bad.al`. diff --git a/microsoft/knowledge/upgrade/call-methods-from-onupgrade-triggers-not-inline-code.bad.al b/microsoft/knowledge/upgrade/call-methods-from-onupgrade-triggers-not-inline-code.bad.al deleted file mode 100644 index 1d83a48..0000000 --- a/microsoft/knowledge/upgrade/call-methods-from-onupgrade-triggers-not-inline-code.bad.al +++ /dev/null @@ -1,14 +0,0 @@ -codeunit 50801 "Upgrade Sample CallMethods Bad" -{ - Subtype = Upgrade; - - trigger OnUpgradePerCompany() - var - Customer: Record Customer; - begin - // Inline logic in the trigger body: no tag guard, not testable in isolation, - // re-runs on every upgrade. - Customer.SetRange(Blocked, Customer.Blocked::" "); - Customer.ModifyAll("Some Field", true); - end; -} diff --git a/microsoft/knowledge/upgrade/call-methods-from-onupgrade-triggers-not-inline-code.good.al b/microsoft/knowledge/upgrade/call-methods-from-onupgrade-triggers-not-inline-code.good.al deleted file mode 100644 index e5c7218..0000000 --- a/microsoft/knowledge/upgrade/call-methods-from-onupgrade-triggers-not-inline-code.good.al +++ /dev/null @@ -1,31 +0,0 @@ -codeunit 50800 "Upgrade Sample CallMethods Good" -{ - Subtype = Upgrade; - - trigger OnUpgradePerCompany() - begin - UpgradeCustomerDefaults(); - UpgradeSalesDocumentDefaults(); - end; - - local procedure UpgradeCustomerDefaults() - var - UpgradeTag: Codeunit "Upgrade Tag"; - begin - if UpgradeTag.HasUpgradeTag(CustomerDefaultsUpgradeTag()) then - exit; - - // Step body omitted - - UpgradeTag.SetUpgradeTag(CustomerDefaultsUpgradeTag()); - end; - - local procedure UpgradeSalesDocumentDefaults() - begin - end; - - local procedure CustomerDefaultsUpgradeTag(): Code[250] - begin - exit('MS-000001-CustomerDefaults-20260501'); - end; -} diff --git a/microsoft/knowledge/upgrade/call-methods-from-onupgrade-triggers-not-inline-code.md b/microsoft/knowledge/upgrade/call-methods-from-onupgrade-triggers-not-inline-code.md deleted file mode 100644 index cf04b13..0000000 --- a/microsoft/knowledge/upgrade/call-methods-from-onupgrade-triggers-not-inline-code.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: upgrade -keywords: [upgrade-codeunit, onupgradepercompany, onupgradeperdatabase, structure] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Call named methods from OnUpgrade triggers; keep the triggers empty of logic - -## Description - -An upgrade codeunit (`Subtype = Upgrade`) runs its triggers once per upgrade scope. Inlining upgrade logic inside the trigger body mixes the entry point with the work, makes individual steps untestable in isolation, and prevents the standard upgrade-tag guard pattern from being applied cleanly. The convention across Business Central's own upgrade codeunits is that `OnUpgradePerCompany` and `OnUpgradePerDatabase` are a list of calls to named local procedures, each implementing one step behind its own upgrade-tag check. - -## Best Practice - -Keep `OnUpgradePerCompany` and `OnUpgradePerDatabase` to a list of `UpgradeXxx();` statements. Put every data migration, default, or correction in a named local procedure whose first action is the upgrade-tag guard. Empty trigger bodies are also acceptable as placeholders on a new codeunit with no current steps. - -See sample: `call-methods-from-onupgrade-triggers-not-inline-code.good.al`. - -## Anti Pattern - -Writing `Customer.ModifyAll(...)`, `TableX.SetRange(...)` + loops, or `DataTransfer.CopyFields()` directly inside the trigger body. The step is untagged, untestable, and re-runs on every upgrade. - -See sample: `call-methods-from-onupgrade-triggers-not-inline-code.bad.al`. diff --git a/microsoft/knowledge/upgrade/datatransfer-for-bulk-init.bad.al b/microsoft/knowledge/upgrade/datatransfer-for-bulk-init.bad.al new file mode 100644 index 0000000..30c601a --- /dev/null +++ b/microsoft/knowledge/upgrade/datatransfer-for-bulk-init.bad.al @@ -0,0 +1,23 @@ +codeunit 50219 "Upgrade Price List Source" +{ + Subtype = Upgrade; + + local procedure UpdatePriceSourceGroupInPriceListLines() + var + PriceListLine: Record "Price List Line"; + begin + // One round-trip per row across a potentially large table. + PriceListLine.SetRange("Source Group", "Price Source Group"::All); + if PriceListLine.FindSet(true) then + repeat + if PriceListLine."Source Type" in + ["Price Source Type"::"All Jobs", + "Price Source Type"::Job, + "Price Source Type"::"Job Task"] + then begin + PriceListLine."Source Group" := "Price Source Group"::Job; + PriceListLine.Modify(); + end; + until PriceListLine.Next() = 0; + end; +} diff --git a/microsoft/knowledge/upgrade/datatransfer-for-bulk-init.good.al b/microsoft/knowledge/upgrade/datatransfer-for-bulk-init.good.al new file mode 100644 index 0000000..119e9f0 --- /dev/null +++ b/microsoft/knowledge/upgrade/datatransfer-for-bulk-init.good.al @@ -0,0 +1,23 @@ +codeunit 50218 "Upgrade Price List Source" +{ + Subtype = Upgrade; + + local procedure UpdatePriceSourceGroupInPriceListLines() + var + PriceListLine: Record "Price List Line"; + PriceListLineDataTransfer: DataTransfer; + begin + PriceListLineDataTransfer.SetTables(Database::"Price List Line", Database::"Price List Line"); + PriceListLineDataTransfer.AddSourceFilter( + PriceListLine.FieldNo("Source Group"), '=%1', "Price Source Group"::All); + PriceListLineDataTransfer.AddSourceFilter( + PriceListLine.FieldNo("Source Type"), '%1|%2|%3', + "Price Source Type"::"All Jobs", + "Price Source Type"::Job, + "Price Source Type"::"Job Task"); + PriceListLineDataTransfer.AddConstantValue( + "Price Source Group"::Job, PriceListLine.FieldNo("Source Group")); + PriceListLineDataTransfer.CopyFields(); + Clear(PriceListLineDataTransfer); + end; +} diff --git a/microsoft/knowledge/upgrade/datatransfer-for-bulk-init.md b/microsoft/knowledge/upgrade/datatransfer-for-bulk-init.md new file mode 100644 index 0000000..3eeaa46 --- /dev/null +++ b/microsoft/knowledge/upgrade/datatransfer-for-bulk-init.md @@ -0,0 +1,30 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [datatransfer, large-dataset, bulk-update, modifyall, copyfields, new-field] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Use `DataTransfer` for bulk updates on large tables + +## Description + +Tables that can contain more than 300,000 records, and any newly added field on an existing table, should be initialized with `DataTransfer` rather than a `repeat ... Modify ... until Next() = 0` loop. `DataTransfer` issues a single set-based statement to the database; the loop/modify pattern issues one round-trip per row and accumulates write locks for the duration of the upgrade. On the volumes that drive upgrade pain — ledger entries, item ledger entries, price list lines — the difference is the upgrade running for minutes instead of hours. + +## Best Practice + +For a bulk update use a `DataTransfer` variable: call `SetTables(Database::"...", Database::"...")` (source and destination may be the same table), add filters with `AddSourceFilter`, set the target value with `AddConstantValue` (or copy a source field with `AddFieldValue`), and execute with `CopyFields()`. To express multiple distinct updates against the same table, `Clear` the `DataTransfer` between executions and configure the next one. + +See sample: `datatransfer-for-bulk-init.good.al`. + +## Anti Pattern + +Iterating with `FindSet(true) ... repeat ... Modify() ... until Next() = 0` to set a single field across an entire large table. On 300k+ rows this is the canonical slow-upgrade footgun. + +See sample: `datatransfer-for-bulk-init.bad.al`. + +## See also + +- `datatransfer-skips-triggers-and-subscribers.md` — `DataTransfer` does not raise field validation triggers or event subscribers; if a row needs validation logic, `DataTransfer` is the wrong tool. diff --git a/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.bad.al b/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.bad.al new file mode 100644 index 0000000..800c828 --- /dev/null +++ b/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.bad.al @@ -0,0 +1,16 @@ +codeunit 50221 "Upgrade Existing Field" +{ + Subtype = Upgrade; + + local procedure UpdateCustomerCreditLimit() + var + Customer: Record Customer; + DT: DataTransfer; + begin + // "Credit Limit (LCY)" has OnValidate logic that recalculates risk fields + // and notifies subscribers. DataTransfer skips both — derived data drifts. + DT.SetTables(Database::Customer, Database::Customer); + DT.AddConstantValue(50000, Customer.FieldNo("Credit Limit (LCY)")); + DT.CopyFields(); + end; +} diff --git a/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.good.al b/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.good.al new file mode 100644 index 0000000..0475079 --- /dev/null +++ b/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.good.al @@ -0,0 +1,16 @@ +codeunit 50220 "Upgrade New Field Init" +{ + Subtype = Upgrade; + + local procedure InitializeNewFlagOnMyTable() + var + MyTable: Record "My Table"; + DT: DataTransfer; + begin + // "New Flag" is added in the same change as this upgrade procedure. + // No existing validation logic depends on it, so DataTransfer is safe. + DT.SetTables(Database::"My Table", Database::"My Table"); + DT.AddConstantValue(true, MyTable.FieldNo("New Flag")); + DT.CopyFields(); + end; +} diff --git a/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.md b/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.md new file mode 100644 index 0000000..785684f --- /dev/null +++ b/microsoft/knowledge/upgrade/datatransfer-skips-triggers-and-subscribers.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [datatransfer, validate-trigger, event-subscriber, side-effects, business-logic] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# `DataTransfer` does not fire validation triggers or event subscribers + +## Description + +`DataTransfer` writes directly at the database layer. It does not invoke field `OnValidate` triggers, table `OnModify` triggers, or any `OnAfterModifyEvent` / `OnBeforeValidate...` event subscribers that a normal `Record.Modify(true)` would. This is precisely what makes it fast — and precisely what makes it a footgun when the field being updated has validation logic that other code relies on. The receiving code never gets the signal that a row changed, derived fields stay stale, audit hooks do not run. + +For *new fields and tables added in the same change* this is fine: nothing yet depends on the validation. For *pre-existing fields with validation logic*, `DataTransfer` quietly bypasses business logic that may be load-bearing for posting, calculation, or integration scenarios. + +## Best Practice + +Use `DataTransfer` only when the field or table is new in the same change — initial population is the canonical safe case. When updating a pre-existing field that has validation logic, either use `Modify(true)` to honour the triggers, or, if `DataTransfer` is still required for performance reasons, leave a comment that explicitly states "validation triggers and event subscribers are intentionally not raised" and verify with the field's owner that this is safe. + +See sample: `datatransfer-skips-triggers-and-subscribers.good.al`. + +## Anti Pattern + +Reaching for `DataTransfer` to update an existing field with non-trivial `OnValidate` logic, without a comment and without confirming that subscribers can be skipped. The upgrade succeeds; runtime behaviour drifts silently. + +See sample: `datatransfer-skips-triggers-and-subscribers.bad.al`. diff --git a/microsoft/knowledge/upgrade/detect-first-install-via-dataversion-zero.bad.al b/microsoft/knowledge/upgrade/detect-first-install-via-dataversion-zero.bad.al deleted file mode 100644 index e895201..0000000 --- a/microsoft/knowledge/upgrade/detect-first-install-via-dataversion-zero.bad.al +++ /dev/null @@ -1,15 +0,0 @@ -codeunit 50822 "Upgrade Sample FirstInstall Bad" -{ - Subtype = Install; - - trigger OnInstallAppPerCompany() - begin - // Unconditional initialization. Re-install after uninstall either throws - // on primary-key collisions or overwrites existing rows. - InsertDefaultSetup(); - end; - - local procedure InsertDefaultSetup() - begin - end; -} diff --git a/microsoft/knowledge/upgrade/detect-first-install-via-dataversion-zero.md b/microsoft/knowledge/upgrade/detect-first-install-via-dataversion-zero.md deleted file mode 100644 index 84d72fb..0000000 --- a/microsoft/knowledge/upgrade/detect-first-install-via-dataversion-zero.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: upgrade -keywords: [oninstall, dataversion, appinfo, first-install, upgrade-tag] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Detect first install via DataVersion equal to 0.0.0.0 in OnInstall triggers - -## Description - -`OnInstallAppPerCompany` fires on first install and on subsequent re-installs after an uninstall. Code that should only run on the very first install needs to distinguish the two — and the supported way is checking `AppInfo.DataVersion() = Version.Create('0.0.0.0')`, which is the sentinel for "no prior data exists for this app in this tenant". This is the one case where a DataVersion check is correct; steady-state upgrade steps should use upgrade tags instead. - -## Best Practice - -In `OnInstallAppPerCompany`, call `NavApp.GetCurrentModuleInfo(AppInfo)` and exit early when `AppInfo.DataVersion()` is non-zero. The remainder of the trigger body then runs exclusively on first install. For all other version-sensitive upgrade logic, use upgrade tags (see `use-upgrade-tags-not-version-checks`). - -See sample: `detect-first-install-via-dataversion-zero.good.al`. - -## Anti Pattern - -Running initialization unconditionally in `OnInstallAppPerCompany` and relying on primary-key collisions to avoid double-inserts. Re-install scenarios either throw or overwrite existing rows; the install path becomes brittle as the app grows. - -See sample: `detect-first-install-via-dataversion-zero.bad.al`. diff --git a/microsoft/knowledge/upgrade/do-not-block-upgrade-on-data-errors.bad.al b/microsoft/knowledge/upgrade/do-not-block-upgrade-on-data-errors.bad.al new file mode 100644 index 0000000..2c200c6 --- /dev/null +++ b/microsoft/knowledge/upgrade/do-not-block-upgrade-on-data-errors.bad.al @@ -0,0 +1,17 @@ +codeunit 50207 "Upgrade Graceful" +{ + Subtype = Upgrade; + + trigger OnUpgradePerCompany() + begin + UpgradeCustomerLink('C00010'); + end; + + local procedure UpgradeCustomerLink(CustomerNo: Code[20]) + var + Customer: Record Customer; + begin + // Throws if the record is missing — aborts the upgrade. + Customer.Get(CustomerNo); + end; +} diff --git a/microsoft/knowledge/upgrade/do-not-block-upgrade-on-data-errors.good.al b/microsoft/knowledge/upgrade/do-not-block-upgrade-on-data-errors.good.al new file mode 100644 index 0000000..7a83df2 --- /dev/null +++ b/microsoft/knowledge/upgrade/do-not-block-upgrade-on-data-errors.good.al @@ -0,0 +1,26 @@ +codeunit 50206 "Upgrade Graceful" +{ + Subtype = Upgrade; + + trigger OnUpgradePerCompany() + begin + UpgradeCustomerLink('C00010'); + end; + + local procedure UpgradeCustomerLink(CustomerNo: Code[20]) + var + Customer: Record Customer; + begin + if not Customer.Get(CustomerNo) then begin + Session.LogMessage( + '0000ABC', + 'Customer not found during upgrade', + Verbosity::Warning, + DataClassification::SystemMetadata, + TelemetryScope::ExtensionPublisher, + 'CustomerNo', CustomerNo); + exit; + end; + // Continue upgrade work using Customer ... + end; +} diff --git a/microsoft/knowledge/upgrade/do-not-block-upgrade-on-data-errors.md b/microsoft/knowledge/upgrade/do-not-block-upgrade-on-data-errors.md new file mode 100644 index 0000000..cf585eb --- /dev/null +++ b/microsoft/knowledge/upgrade/do-not-block-upgrade-on-data-errors.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [error-handling, telemetry, session-logmessage, blocking, graceful] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Log telemetry; do not raise errors that block the upgrade + +## Description + +When upgrade code encounters unexpected data — a record it expected to find, a relationship it assumed to be intact — the response is to log telemetry and continue, not to raise an error. A runtime error inside an upgrade codeunit aborts the upgrade for the company or database, leaving the customer stuck on the old version. Customers should not be blocked from upgrading because of a data inconsistency that an upgrade routine could not have anticipated. + +## Best Practice + +When an upgrade procedure detects something missing, call `Session.LogMessage` with a stable event ID, classify the message verbosity (typically `Warning`), and `exit` the procedure so the rest of the upgrade can proceed. The platform telemetry then surfaces the situation to the partner without breaking the customer. + +See sample: `do-not-block-upgrade-on-data-errors.good.al`. + +## Anti Pattern + +Calling `Record.Get(Key)` (or any other erroring API) and letting the error propagate out of the upgrade trigger. The first tenant with imperfect data fails to upgrade, and the failure surfaces as a hard upgrade error rather than as a telemetry signal. + +See sample: `do-not-block-upgrade-on-data-errors.bad.al`. diff --git a/microsoft/knowledge/upgrade/do-not-make-external-calls-in-upgrade-codeunits.md b/microsoft/knowledge/upgrade/do-not-make-external-calls-in-upgrade-codeunits.md deleted file mode 100644 index e7bf1e6..0000000 --- a/microsoft/knowledge/upgrade/do-not-make-external-calls-in-upgrade-codeunits.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -bc-version: [all] -domain: upgrade -keywords: [upgrade, httpclient, external-service, dotnet, availability] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Do not make external service calls inside upgrade codeunits - -## Description - -The upgrade scope has to complete for the tenant to reach the new version. Any call in the upgrade path that depends on an external service — HttpClient to a partner API, a DotNet interop call, a codeunit that fetches remote configuration — fails closed when the service is unreachable, misconfigured, or slow. The failure blocks the upgrade for every customer whose environment cannot reach the dependency at the moment the upgrade runs, and there is no user present to retry. The scope is specifically code inside codeunits with `Subtype = Upgrade` or reachable from their triggers. - -## Best Practice - -Defer external calls to runtime code that executes after the upgrade — install-triggered tasks, background job queue entries scheduled by the upgrade, or lazy initialization on first use. The upgrade step should compute a local result or mark work to be done, not perform the remote call itself. Do not apply this rule to ordinary runtime codeunits, pages, tables, install procedures, or background jobs unless they are directly invoked from an upgrade trigger. - -## Anti Pattern - -`HttpClient.Get(...)` or `DotNetType.CallStaticMethod(...)` directly in `OnUpgradePerCompany`, or in a local procedure called from it. The upgrade now depends on network availability to a service the platform does not control. diff --git a/microsoft/knowledge/upgrade/enum-changes-must-be-additive-at-the-end.bad.al b/microsoft/knowledge/upgrade/enum-changes-must-be-additive-at-the-end.bad.al deleted file mode 100644 index 0eb1590..0000000 --- a/microsoft/knowledge/upgrade/enum-changes-must-be-additive-at-the-end.bad.al +++ /dev/null @@ -1,23 +0,0 @@ -enum 50815 "Upgrade Sample EnumInsert Bad" -{ - Extensible = true; - - value(0; First) { Caption = 'First'; } - - // Inserting at ordinal 1 shifts everything below. Every row that stored - // ordinal 1 before now resolves to NewMiddleValue. - value(1; NewMiddleValue) { Caption = 'New middle value'; } - - value(2; Second) { Caption = 'Second'; } - value(3; Third) { Caption = 'Third'; } -} - -enum 50816 "Upgrade Sample EnumRemove Bad" -{ - Extensible = true; - - value(0; First) { Caption = 'First'; } - // value(1; Second) removed without obsoletion. - // Existing rows storing ordinal 1 no longer resolve to any declared value. - value(2; Third) { Caption = 'Third'; } -} diff --git a/microsoft/knowledge/upgrade/enum-changes-must-be-additive-at-the-end.good.al b/microsoft/knowledge/upgrade/enum-changes-must-be-additive-at-the-end.good.al deleted file mode 100644 index 073a9c6..0000000 --- a/microsoft/knowledge/upgrade/enum-changes-must-be-additive-at-the-end.good.al +++ /dev/null @@ -1,29 +0,0 @@ -enum 50813 "Upgrade Sample EnumAdditive Good" -{ - Extensible = true; - - value(0; First) { Caption = 'First'; } - value(1; Second) { Caption = 'Second'; } - value(2; Third) { Caption = 'Third'; } - - // New value appended at the next free ordinal. Existing stored ordinals - // (0, 1, 2) keep their meaning. - value(3; NewValue) { Caption = 'New value'; } -} - -enum 50814 "Upgrade Sample EnumRetire Good" -{ - Extensible = true; - - value(0; First) { Caption = 'First'; } - - value(1; Second) - { - Caption = 'Second'; - ObsoleteState = Removed; - ObsoleteReason = 'Replaced by NewValue.'; - ObsoleteTag = '28.0'; - } - - value(2; Third) { Caption = 'Third'; } -} diff --git a/microsoft/knowledge/upgrade/enum-changes-must-be-additive-at-the-end.md b/microsoft/knowledge/upgrade/enum-changes-must-be-additive-at-the-end.md deleted file mode 100644 index 5f2ef7e..0000000 --- a/microsoft/knowledge/upgrade/enum-changes-must-be-additive-at-the-end.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -bc-version: [all] -domain: upgrade -keywords: [enum, ordinal, obsolete, backward-compatibility, breaking-change] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Enum changes must be additive at the end; never insert or remove values - -## Description - -AL enums store their ordinal on disk. Inserting a new value in the middle of an existing enum shifts every following ordinal by one: every row whose field holds the old ordinal N now resolves to the value that used to be N+1. Removing a value without obsoletion has the same effect. Both changes are data corruption disguised as a code edit and are effectively irreversible once a tenant has upgraded. Adding values at the end is safe — existing ordinals keep their meaning. - -## Best Practice - -Append new enum values at the end, taking the next free ordinal. Renaming the caption on an existing ordinal is fine. - -When a value must be retired, follow the two-stage obsoletion workflow: - -1. **First release:** Mark the value with `ObsoleteState = Pending`, `ObsoleteReason`, and `ObsoleteTag`. This gives callers at least one release cycle to migrate. -2. **Later release:** Advance to `ObsoleteState = Removed` once all callers have been updated. - -Never skip straight to `ObsoleteState = Removed` without first going through `Pending` — doing so removes the warning cycle that callers depend on. Do not reclaim the ordinal in either stage. See also: `use-obsolete-pending-before-removed.md`. - -See sample: `enum-changes-must-be-additive-at-the-end.good.al`. - -## Anti Pattern - -Inserting `value(1; "NewMiddleValue")` between existing `value(0; "First")` and the original `value(1; "Second")`. Every row that stored ordinal 1 before the change now reads as `NewMiddleValue`. The same applies to removing a value outright without obsoletion. - -See sample: `enum-changes-must-be-additive-at-the-end.bad.al`. diff --git a/microsoft/knowledge/upgrade/enum-values-additive-at-end.bad.al b/microsoft/knowledge/upgrade/enum-values-additive-at-end.bad.al new file mode 100644 index 0000000..ad16066 --- /dev/null +++ b/microsoft/knowledge/upgrade/enum-values-additive-at-end.bad.al @@ -0,0 +1,11 @@ +enum 50226 "My Enum" +{ + Extensible = true; + + value(0; "First") { } + value(1; "NewMiddleValue") { } // Inserted in the middle — shifts ordinals. + value(2; "Second") { } + value(3; "Third") { } + // Or: a previously declared value(1; "Second") removed without obsoletion — + // any persisted "1" now maps to whatever currently occupies ordinal 1. +} diff --git a/microsoft/knowledge/upgrade/enum-values-additive-at-end.good.al b/microsoft/knowledge/upgrade/enum-values-additive-at-end.good.al new file mode 100644 index 0000000..a585a2c --- /dev/null +++ b/microsoft/knowledge/upgrade/enum-values-additive-at-end.good.al @@ -0,0 +1,9 @@ +enum 50225 "My Enum" +{ + Extensible = true; + + value(0; "First") { } + value(1; "Second") { } + value(2; "Third") { } + value(3; "NewValue") { } // Appended at the end — no existing ordinal shifts. +} diff --git a/microsoft/knowledge/upgrade/enum-values-additive-at-end.md b/microsoft/knowledge/upgrade/enum-values-additive-at-end.md new file mode 100644 index 0000000..4de929b --- /dev/null +++ b/microsoft/knowledge/upgrade/enum-values-additive-at-end.md @@ -0,0 +1,31 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [enum, ordinal, additive, append, backward-compatible, breaking-change] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Add new enum values only at the end + +## Description + +An AL `enum` is a fixed list of ordinal-named values. Persisted rows reference enum members by ordinal, not by name. The only enum mutation that preserves the meaning of every existing row is **appending a new value at the end** — every previously valid ordinal still maps to the same member. Inserting a new value in the middle, renumbering existing values, or removing a value without obsoletion all shift ordinals: rows written with the old layout silently take on the new member at their saved ordinal. + +## Best Practice + +When adding an enum value, place it after the last existing `value(N; ...)` entry, with an ordinal strictly greater than every existing one. Never renumber existing entries. To retire a value, do not delete it: mark it `ObsoleteState = Pending` (and later `Removed`) with `ObsoleteReason` and `ObsoleteTag` so the ordinal remains taken. + +See sample: `enum-values-additive-at-end.good.al`. + +## Anti Pattern + +Inserting a value between existing entries ("just put `NewMiddleValue` between `First` and `Second`"), or removing a value from the enum without first going through `ObsoleteState = Pending` → `Removed`. Every row whose persisted ordinal matched the removed or shifted value now reads as a different member. + +See sample: `enum-values-additive-at-end.bad.al`. + +## See also + +- `obsoletion-requires-reason-and-tag.md` — how to retire an enum member correctly. +- `obsolete-pending-to-removed-staging.md` — the `Pending → Removed` lifecycle. diff --git a/microsoft/knowledge/upgrade/exclude-hybrid-migration-codeunits-from-standard-upgrade-rules.md b/microsoft/knowledge/upgrade/exclude-hybrid-migration-codeunits-from-standard-upgrade-rules.md deleted file mode 100644 index 66bad0a..0000000 --- a/microsoft/knowledge/upgrade/exclude-hybrid-migration-codeunits-from-standard-upgrade-rules.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -bc-version: [all] -domain: upgrade -keywords: [hybrid, migration, upgrade-tag, false-positive, datamigration] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Exclude Hybrid migration codeunits from standard upgrade rules - -## Description - -Hybrid migration codeunits such as `HybridBC14`, `HybridSL`, `HybridGP`, and `HybridBaseDeployment` are one-time migration paths with established migration-specific patterns. They are not ordinary `Subtype = Upgrade` steps, and forcing standard upgrade-tag, trigger-shape, or missing-upgrade-code rules onto them creates false positives. - -## Best Practice - -When a change is clearly in a Hybrid migration codeunit or migration namespace, review it against migration-specific data handling and destination classification rules. Do not flag it merely because it lacks ordinary upgrade tags or because its control flow differs from standard upgrade codeunits. - -## Anti Pattern - -Reporting "missing upgrade tag" or "missing standard upgrade code" on a `HybridSL`, `HybridGP`, `HybridBC`, or `HybridBaseDeployment` codeunit solely because it does not look like a normal upgrade step. The name and migration context are the signal that different rules apply. diff --git a/microsoft/knowledge/upgrade/first-install-dataversion-zero-check.bad.al b/microsoft/knowledge/upgrade/first-install-dataversion-zero-check.bad.al new file mode 100644 index 0000000..e3381ad --- /dev/null +++ b/microsoft/knowledge/upgrade/first-install-dataversion-zero-check.bad.al @@ -0,0 +1,13 @@ +codeunit 50211 "Install My Extension" +{ + Subtype = Install; + + trigger OnInstallAppPerCompany() + begin + // No DataVersion() guard — this runs on every reinstall and upgrade + // path, duplicating seed rows. + SeedDefaultRows(); + end; + + local procedure SeedDefaultRows() begin end; +} diff --git a/microsoft/knowledge/upgrade/detect-first-install-via-dataversion-zero.good.al b/microsoft/knowledge/upgrade/first-install-dataversion-zero-check.good.al similarity index 55% rename from microsoft/knowledge/upgrade/detect-first-install-via-dataversion-zero.good.al rename to microsoft/knowledge/upgrade/first-install-dataversion-zero-check.good.al index 174e2d9..9d6e92e 100644 --- a/microsoft/knowledge/upgrade/detect-first-install-via-dataversion-zero.good.al +++ b/microsoft/knowledge/upgrade/first-install-dataversion-zero-check.good.al @@ -1,4 +1,4 @@ -codeunit 50821 "Upgrade Sample FirstInstall Good" +codeunit 50210 "Install My Extension" { Subtype = Install; @@ -10,11 +10,6 @@ codeunit 50821 "Upgrade Sample FirstInstall Good" if AppInfo.DataVersion() <> Version.Create('0.0.0.0') then exit; - // First-install-only initialization follows here. - InsertDefaultSetup(); - end; - - local procedure InsertDefaultSetup() - begin + // Install-only seed code goes here. end; } diff --git a/microsoft/knowledge/upgrade/first-install-dataversion-zero-check.md b/microsoft/knowledge/upgrade/first-install-dataversion-zero-check.md new file mode 100644 index 0000000..260b15b --- /dev/null +++ b/microsoft/knowledge/upgrade/first-install-dataversion-zero-check.md @@ -0,0 +1,30 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [dataversion, first-install, on-install-app-per-company, moduleinfo, zero-version] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Detect first install with `DataVersion() = Version.Create('0.0.0.0')` + +## Description + +On the first install of an extension on a tenant the platform records a zero data version: `AppInfo.DataVersion()` returns `Version.Create('0.0.0.0')`. Subsequent upgrades record the actual previous version. The `OnInstallAppPerCompany` trigger uses this distinction to detect a brand-new install — for example, to seed default rows that should not be re-inserted on a normal upgrade. This is the one place where reading `DataVersion()` is the right tool; for everything else, use an upgrade tag. + +## Best Practice + +In `OnInstallAppPerCompany`, fetch the current `ModuleInfo` via `NavApp.GetCurrentModuleInfo`, compare `AppInfo.DataVersion()` to `Version.Create('0.0.0.0')`, and run install-only seed logic only when they match. On any non-zero data version, exit immediately — that path is an upgrade, not an install. + +See sample: `first-install-dataversion-zero-check.good.al`. + +## Anti Pattern + +Treating `OnInstallAppPerCompany` as if it always implies "fresh tenant". The trigger also fires when reinstalling over an existing data set; without the `0.0.0.0` guard, install-only seed code re-runs on every upgrade and duplicates rows. + +See sample: `first-install-dataversion-zero-check.bad.al`. + +## See also + +- `use-upgrade-tags-not-version-checks.md` — for upgrade steps after first install, use upgrade tags rather than `DataVersion`. diff --git a/microsoft/knowledge/upgrade/guard-database-reads.bad.al b/microsoft/knowledge/upgrade/guard-database-reads.bad.al new file mode 100644 index 0000000..0c28fa6 --- /dev/null +++ b/microsoft/knowledge/upgrade/guard-database-reads.bad.al @@ -0,0 +1,20 @@ +codeunit 50205 "Upgrade Guarded Reads" +{ + Subtype = Upgrade; + + trigger OnUpgradePerCompany() + begin + UpgradeMyFeature(); + end; + + local procedure UpgradeMyFeature() + var + Item: Record Item; + Customer: Record Customer; + Vendor: Record Vendor; + begin + Item.Get('1000'); // Throws if missing; aborts upgrade. + Customer.FindSet(); // Throws if empty. + Vendor.FindLast(); // Throws if empty. + end; +} diff --git a/microsoft/knowledge/upgrade/guard-database-reads.good.al b/microsoft/knowledge/upgrade/guard-database-reads.good.al new file mode 100644 index 0000000..868f2ae --- /dev/null +++ b/microsoft/knowledge/upgrade/guard-database-reads.good.al @@ -0,0 +1,22 @@ +codeunit 50204 "Upgrade Guarded Reads" +{ + Subtype = Upgrade; + + trigger OnUpgradePerCompany() + begin + UpgradeMyFeature(); + end; + + local procedure UpgradeMyFeature() + var + Item: Record Item; + Customer: Record Customer; + Vendor: Record Vendor; + begin + if Item.Get('1000') then + Item.Modify(); + if Customer.FindSet() then; + if not Vendor.FindLast() then + exit; + end; +} diff --git a/microsoft/knowledge/upgrade/guard-database-reads.md b/microsoft/knowledge/upgrade/guard-database-reads.md new file mode 100644 index 0000000..c5bc206 --- /dev/null +++ b/microsoft/knowledge/upgrade/guard-database-reads.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [get, findset, findlast, guard, if-then, runtime-error] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Guard every database read in upgrade code with `if` + +## Description + +Inside an upgrade codeunit (or any procedure transitively invoked from `OnUpgradePerCompany` / `OnUpgradePerDatabase`), an unguarded `Record.Get`, `Record.FindSet`, or `Record.FindLast` raises a runtime error when the row or set is missing. In upgrade context that error aborts the entire upgrade for the company or database — a far worse outcome than the missing data itself. Records the upgrade reasons about may legitimately not exist on every customer's tenant. + +## Best Practice + +Wrap every read in an `if`. `if Item.Get(No) then ...`, `if Customer.FindSet() then;`, `if not Vendor.FindLast() then exit;`. The empty-then form `if Customer.FindSet() then;` is the idiomatic way to attempt a read whose only purpose is to position a record, while swallowing the "not found" case. + +See sample: `guard-database-reads.good.al`. + +## Anti Pattern + +Calling `Item.Get()`, `Customer.FindSet()`, or `Vendor.FindLast()` bare in upgrade code. The first tenant whose data does not match the upgrade's assumptions will fail to upgrade. + +See sample: `guard-database-reads.bad.al`. diff --git a/microsoft/knowledge/upgrade/guard-every-database-read-in-upgrade-codeunits.bad.al b/microsoft/knowledge/upgrade/guard-every-database-read-in-upgrade-codeunits.bad.al deleted file mode 100644 index 2003dab..0000000 --- a/microsoft/knowledge/upgrade/guard-every-database-read-in-upgrade-codeunits.bad.al +++ /dev/null @@ -1,19 +0,0 @@ -codeunit 50807 "Upgrade Sample GuardReads Bad" -{ - Subtype = Upgrade; - - trigger OnUpgradePerCompany() - var - Setup: Record "Sales & Receivables Setup"; - Customer: Record Customer; - begin - // Unguarded Get. One tenant whose Setup row is missing blocks the upgrade. - Setup.Get(); - - // Unguarded FindSet. Raises when the table is empty for this tenant. - Customer.FindSet(); - repeat - // per-row work - until Customer.Next() = 0; - end; -} diff --git a/microsoft/knowledge/upgrade/guard-every-database-read-in-upgrade-codeunits.good.al b/microsoft/knowledge/upgrade/guard-every-database-read-in-upgrade-codeunits.good.al deleted file mode 100644 index f98f45d..0000000 --- a/microsoft/knowledge/upgrade/guard-every-database-read-in-upgrade-codeunits.good.al +++ /dev/null @@ -1,23 +0,0 @@ -codeunit 50806 "Upgrade Sample GuardReads Good" -{ - Subtype = Upgrade; - - trigger OnUpgradePerCompany() - begin - UpgradeDefaults(); - end; - - local procedure UpgradeDefaults() - var - Setup: Record "Sales & Receivables Setup"; - Customer: Record Customer; - begin - if not Setup.Get() then - exit; - - if Customer.FindSet() then - repeat - // per-row work - until Customer.Next() = 0; - end; -} diff --git a/microsoft/knowledge/upgrade/guard-every-database-read-in-upgrade-codeunits.md b/microsoft/knowledge/upgrade/guard-every-database-read-in-upgrade-codeunits.md deleted file mode 100644 index f3c6f99..0000000 --- a/microsoft/knowledge/upgrade/guard-every-database-read-in-upgrade-codeunits.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: upgrade -keywords: [upgrade, get, findset, findlast, guard, unblocking] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Guard every database read in upgrade codeunits; never let a missing row block the upgrade - -## Description - -An unguarded `Record.Get()` raises when the row does not exist; an unguarded `FindSet()` or `FindLast()` raises when the result set is empty. In ordinary runtime code those errors surface to a user who can retry. In an upgrade codeunit they abort the upgrade of the tenant and the customer is blocked from getting to the new version. Real-world data is inconsistent enough — missing lookup rows, empty setup tables, skipped modules — that an unguarded read reliably blocks at least one customer per release. - -## Best Practice - -Wrap every Get, FindSet, FindFirst, FindLast, and related call in an `if … then` guard. On the not-found path, either exit the current step or log telemetry and continue; never let the upgrade scope raise. `if Customer.FindSet() then;` (statement terminator as the entire body) is an acceptable pattern when only the side effect of positioning matters. - -See sample: `guard-every-database-read-in-upgrade-codeunits.good.al`. - -## Anti Pattern - -`Customer.Get(CustomerNo);` or `SalesHeader.FindLast();` inside an upgrade procedure. One missing row in one tenant turns every future upgrade into a support ticket. - -See sample: `guard-every-database-read-in-upgrade-codeunits.bad.al`. diff --git a/microsoft/knowledge/upgrade/guard-performance-impacting-upgrade-triggers.bad.al b/microsoft/knowledge/upgrade/guard-performance-impacting-upgrade-triggers.bad.al deleted file mode 100644 index 08f88b1..0000000 --- a/microsoft/knowledge/upgrade/guard-performance-impacting-upgrade-triggers.bad.al +++ /dev/null @@ -1,13 +0,0 @@ -codeunit 50831 "Upgrade Sample Trigger Bad" -{ - Subtype = Upgrade; - - trigger OnValidateUpgradePerCompany() - begin - ValidateAllCustomers(); - end; - - local procedure ValidateAllCustomers() - begin - end; -} diff --git a/microsoft/knowledge/upgrade/guard-performance-impacting-upgrade-triggers.good.al b/microsoft/knowledge/upgrade/guard-performance-impacting-upgrade-triggers.good.al deleted file mode 100644 index 7e29e43..0000000 --- a/microsoft/knowledge/upgrade/guard-performance-impacting-upgrade-triggers.good.al +++ /dev/null @@ -1,25 +0,0 @@ -codeunit 50830 "Upgrade Sample Trigger Good" -{ - Subtype = Upgrade; - - trigger OnValidateUpgradePerCompany() - var - UpgradeTag: Codeunit "Upgrade Tag"; - begin - // Required for regulatory data validation before this release can run. - if UpgradeTag.HasUpgradeTag(ValidationTag()) then - exit; - - ValidateAllCustomers(); - UpgradeTag.SetUpgradeTag(ValidationTag()); - end; - - local procedure ValidateAllCustomers() - begin - end; - - local procedure ValidationTag(): Code[250] - begin - exit('MS-000010-ValidateCustomers-20260501'); - end; -} diff --git a/microsoft/knowledge/upgrade/guard-performance-impacting-upgrade-triggers.md b/microsoft/knowledge/upgrade/guard-performance-impacting-upgrade-triggers.md deleted file mode 100644 index 0bfd375..0000000 --- a/microsoft/knowledge/upgrade/guard-performance-impacting-upgrade-triggers.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: upgrade -keywords: [onvalidateupgrade, trigger, upgrade-tag, performance, justification] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Guard performance-impacting upgrade triggers - -## Description - -Upgrade validation triggers such as `OnValidateUpgradePerCompany` can run during upgrade for every tenant and company. Expensive validation, full-table scans, or repair logic in those triggers becomes part of the upgrade's critical path. The trigger is acceptable only when the work is necessary and when re-execution is prevented. - -## Best Practice - -Add written justification for the trigger's work and guard it with an upgrade tag just like a data-migration step. Check `HasUpgradeTag` before the expensive work and call `SetUpgradeTag` only after the work succeeds, so retries do not re-run completed validation. - -See sample: `guard-performance-impacting-upgrade-triggers.good.al`. - -## Anti Pattern - -Putting `ValidateAllCustomers()`, table scans, or external-style setup validation directly in `OnValidateUpgradePerCompany` without a skip tag. The work runs on every upgrade attempt, including retries after unrelated failures. - -See sample: `guard-performance-impacting-upgrade-triggers.bad.al`. diff --git a/microsoft/knowledge/upgrade/hybrid-migration-codeunits-not-standard-upgrade.md b/microsoft/knowledge/upgrade/hybrid-migration-codeunits-not-standard-upgrade.md new file mode 100644 index 0000000..2d047ac --- /dev/null +++ b/microsoft/knowledge/upgrade/hybrid-migration-codeunits-not-standard-upgrade.md @@ -0,0 +1,24 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [hybrid-migration, hybrid-bc14, hybrid-sl, hybrid-gp, hybrid-base-deployment, one-time-migration] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Hybrid migration codeunits are not standard upgrade codeunits + +## Description + +Codeunits like `HybridBC14`, `HybridSL`, `HybridGP`, and `HybridBaseDeployment` implement one-time migration paths from a specific source system into Business Central. They run in a different pipeline from the standard per-company / per-database upgrade triggers and follow patterns shaped by that source — staging tables, schema-mapped imports, and per-source post-processing. The rules that apply to standard upgrade codeunits — guarded reads, no external calls, `DataTransfer` for bulk init, `Subtype = Upgrade`, upgrade tags — are not the right yardstick for these migration codeunits. + +## Best Practice + +Treat a hybrid migration codeunit as a domain of its own. If you need to add or modify migration logic, follow the conventions of the surrounding hybrid migration codebase (which has its own dispatcher, its own way of recording progress, and its own error handling) rather than imposing standard upgrade conventions on it. Conversely, do not borrow hybrid-migration patterns into standard upgrade codeunits — the platform contract is different. + +When reviewing changes inside a hybrid migration codeunit, do not flag missing upgrade tags, missing `Subtype = Upgrade`, or missing `OnUpgradePerCompany` wiring. None of those apply. + +## Anti Pattern + +Reviewing a change inside `HybridBC14` / `HybridSL` / `HybridGP` / `HybridBaseDeployment` against standard upgrade rules and flagging the absence of `Subtype = Upgrade` or upgrade-tag plumbing. diff --git a/microsoft/knowledge/upgrade/initvalue-does-not-populate-existing-records.bad.al b/microsoft/knowledge/upgrade/initvalue-does-not-populate-existing-records.bad.al deleted file mode 100644 index dd54bbd..0000000 --- a/microsoft/knowledge/upgrade/initvalue-does-not-populate-existing-records.bad.al +++ /dev/null @@ -1,14 +0,0 @@ -tableextension 50812 "Upgrade Sample InitValue Bad" extends Customer -{ - fields - { - field(50101; "Is Active"; Boolean) - { - DataClassification = CustomerContent; - Caption = 'Is active'; - // InitValue applies to new records only. - // Every existing customer remains Is Active = false after the upgrade. - InitValue = true; - } - } -} diff --git a/microsoft/knowledge/upgrade/initvalue-does-not-populate-existing-records.good.al b/microsoft/knowledge/upgrade/initvalue-does-not-populate-existing-records.good.al deleted file mode 100644 index e26cfa3..0000000 --- a/microsoft/knowledge/upgrade/initvalue-does-not-populate-existing-records.good.al +++ /dev/null @@ -1,43 +0,0 @@ -tableextension 50810 "Upgrade Sample InitValue Good" extends Customer -{ - fields - { - field(50100; "Is Active"; Boolean) - { - DataClassification = CustomerContent; - Caption = 'Is active'; - InitValue = true; - } - } -} - -codeunit 50811 "Upgrade Sample InitValue Good Upg" -{ - Subtype = Upgrade; - - trigger OnUpgradePerCompany() - begin - UpgradeExistingCustomersIsActive(); - end; - - local procedure UpgradeExistingCustomersIsActive() - var - Customer: Record Customer; - CustomerDataTransfer: DataTransfer; - UpgradeTag: Codeunit "Upgrade Tag"; - begin - if UpgradeTag.HasUpgradeTag(UpgradeCustomerIsActiveTag()) then - exit; - - CustomerDataTransfer.SetTables(Database::Customer, Database::Customer); - CustomerDataTransfer.AddConstantValue(true, Customer.FieldNo("Is Active")); - CustomerDataTransfer.CopyFields(); - - UpgradeTag.SetUpgradeTag(UpgradeCustomerIsActiveTag()); - end; - - local procedure UpgradeCustomerIsActiveTag(): Code[250] - begin - exit('MS-000006-CustomerIsActive-20260501'); - end; -} diff --git a/microsoft/knowledge/upgrade/initvalue-does-not-populate-existing-records.md b/microsoft/knowledge/upgrade/initvalue-does-not-populate-existing-records.md deleted file mode 100644 index 66669ac..0000000 --- a/microsoft/knowledge/upgrade/initvalue-does-not-populate-existing-records.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: upgrade -keywords: [initvalue, field, upgrade, existing-records, migration] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# InitValue on a new field does not populate existing rows - -## Description - -The `InitValue` property sets a field's default for rows created after the field exists. Rows that already exist when the field is added keep the data-type default (empty text, zero, false, epoch date) — InitValue does not retroactively apply. Shipping a new field with `InitValue = true` on an existing table produces a silently inconsistent dataset: new rows match the intended default, existing rows do not, and callers that do not distinguish the two read the wrong state for existing data. - -## Best Practice - -When adding a field to an existing table with a meaningful default, write an upgrade step that populates existing rows with the same value, guarded by its own upgrade tag. Use `DataTransfer` with `AddConstantValue` for set-based initialization (see `use-datatransfer-for-large-dataset-initialization`). Exceptions: brand-new tables; new Boolean fields without InitValue where `false` is the intended existing-row value; new extensions, new feature tables, or setup tables with no meaningful existing data to migrate; and informational fields where empty is an acceptable state. - -See sample: `initvalue-does-not-populate-existing-records.good.al`. - -## Anti Pattern - -Adding `field(100; "Is Active"; Boolean) { InitValue = true; }` to an existing business table without upgrade code. New records are Active; every existing record is silently inactive. The bug surfaces later as "why is this data missing from the default report?" - -See sample: `initvalue-does-not-populate-existing-records.bad.al`. diff --git a/microsoft/knowledge/upgrade/initvalue-does-not-update-existing-rows.bad.al b/microsoft/knowledge/upgrade/initvalue-does-not-update-existing-rows.bad.al new file mode 100644 index 0000000..3ca0ab7 --- /dev/null +++ b/microsoft/knowledge/upgrade/initvalue-does-not-update-existing-rows.bad.al @@ -0,0 +1,15 @@ +tableextension 50224 "MyTable Ext" extends "My Table" +{ + fields + { + // InitValue only applies to rows inserted after deployment. + // Pre-existing rows silently carry the datatype default (false). + field(50200; "New Flag"; Boolean) + { + DataClassification = CustomerContent; + Caption = 'New Flag'; + InitValue = true; + } + } + // No accompanying upgrade codeunit to back-fill existing rows. +} diff --git a/microsoft/knowledge/upgrade/initvalue-does-not-update-existing-rows.good.al b/microsoft/knowledge/upgrade/initvalue-does-not-update-existing-rows.good.al new file mode 100644 index 0000000..c61284b --- /dev/null +++ b/microsoft/knowledge/upgrade/initvalue-does-not-update-existing-rows.good.al @@ -0,0 +1,43 @@ +tableextension 50222 "MyTable Ext" extends "My Table" +{ + fields + { + field(50200; "New Flag"; Boolean) + { + DataClassification = CustomerContent; + Caption = 'New Flag'; + InitValue = true; + } + } +} + +codeunit 50223 "Upgrade MyTable NewFlag" +{ + Subtype = Upgrade; + + trigger OnUpgradePerCompany() + begin + UpgradeMyTableNewFlag(); + end; + + local procedure UpgradeMyTableNewFlag() + var + MyTable: Record "My Table"; + UpgradeTag: Codeunit "Upgrade Tag"; + DT: DataTransfer; + begin + if UpgradeTag.HasUpgradeTag(MyTableNewFlagTag()) then + exit; + + DT.SetTables(Database::"My Table", Database::"My Table"); + DT.AddConstantValue(true, MyTable.FieldNo("New Flag")); + DT.CopyFields(); + + UpgradeTag.SetUpgradeTag(MyTableNewFlagTag()); + end; + + local procedure MyTableNewFlagTag(): Code[250] + begin + exit('MS-123456-MyTable-NewFlag-20240101'); + end; +} diff --git a/microsoft/knowledge/upgrade/initvalue-does-not-update-existing-rows.md b/microsoft/knowledge/upgrade/initvalue-does-not-update-existing-rows.md new file mode 100644 index 0000000..4733ef2 --- /dev/null +++ b/microsoft/knowledge/upgrade/initvalue-does-not-update-existing-rows.md @@ -0,0 +1,32 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [initvalue, new-field, existing-rows, default-value, table-extension] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# `InitValue` does not back-fill existing rows + +## Description + +`InitValue` on a field defines the value the platform assigns when a *new* record is inserted. It does not touch rows that already exist when the field is added. When a new field is added to an existing table — directly or via a table extension — every pre-existing row receives the datatype default (`false` for Boolean, `0` for numeric, empty for text), not the `InitValue`. If the intended semantics require existing rows to carry the `InitValue`, the change is incomplete without an upgrade routine that sets the field on those rows. + +Several legitimate cases do NOT need upgrade code: +- New fields on brand-new tables (no existing rows). +- New `Boolean` fields without `InitValue` where the datatype default `false` is the intended value. +- New fields on configuration / setup tables that have no meaningful "existing data". +- Informational or optional fields (logging, preferences, tracking) where `false` / empty is a valid state. + +## Best Practice + +When a new field on an existing table has an `InitValue` that matters, ship an upgrade procedure that walks the existing rows and sets the field to the same value — typically via `DataTransfer.AddConstantValue` for performance — guarded by an upgrade tag. + +See sample: `initvalue-does-not-update-existing-rows.good.al`. + +## Anti Pattern + +Adding a field with `InitValue = true;` (or any non-default `InitValue`) and shipping no upgrade code. Existing rows silently carry the datatype default, leaving the table in two states: rows created before the upgrade with the wrong value, and rows created after with the right one. + +See sample: `initvalue-does-not-update-existing-rows.bad.al`. diff --git a/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.bad.al b/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.bad.al new file mode 100644 index 0000000..69994e6 --- /dev/null +++ b/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.bad.al @@ -0,0 +1,13 @@ +codeunit 50235 "Upgrade With Validation" +{ + Subtype = Upgrade; + + trigger OnValidateUpgradePerCompany() + begin + // No skip logic and no written justification — full-table validation + // runs on every single upgrade pass. + ValidateAllCustomers(); + end; + + local procedure ValidateAllCustomers() begin end; +} diff --git a/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.good.al b/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.good.al new file mode 100644 index 0000000..9a5a83b --- /dev/null +++ b/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.good.al @@ -0,0 +1,25 @@ +codeunit 50234 "Upgrade With Validation" +{ + Subtype = Upgrade; + + trigger OnValidateUpgradePerCompany() + var + UpgradeTag: Codeunit "Upgrade Tag"; + begin + // Justification: regulatory compliance requires a full-table scan once + // per tenant after this release. Tag prevents re-runs. + if UpgradeTag.HasUpgradeTag(MyValidationUpgradeTag()) then + exit; + + ValidateAllCustomers(); + + UpgradeTag.SetUpgradeTag(MyValidationUpgradeTag()); + end; + + local procedure ValidateAllCustomers() begin end; + + local procedure MyValidationUpgradeTag(): Code[250] + begin + exit('MS-123456-CustomerValidation-20240101'); + end; +} diff --git a/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.md b/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.md new file mode 100644 index 0000000..2c02def --- /dev/null +++ b/microsoft/knowledge/upgrade/minimize-onvalidate-upgrade-triggers.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [on-validate-upgrade-per-company, performance-impact, skip-logic, justification, upgrade-tag] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Performance-impacting upgrade triggers need justification and skip logic + +## Description + +Triggers such as `OnValidateUpgradePerCompany` run on every upgrade pass. When their body performs non-trivial work — full-table scans, cross-table validations — the cost is paid on every upgrade of every tenant, even when there is nothing to validate. That cost is acceptable only when the validation is critical (regulatory compliance, data-integrity guarantees the platform depends on) AND the trigger short-circuits once it has done its work. + +## Best Practice + +A performance-impacting upgrade trigger carries two things: a written comment that names the reason the work has to happen on every upgrade pass, and an early-exit guard backed by an upgrade tag so the work runs at most once per tenant. The `HasUpgradeTag` check at the top exits when the validation has already been recorded; the `SetUpgradeTag` call at the bottom records completion. + +See sample: `minimize-onvalidate-upgrade-triggers.good.al`. + +## Anti Pattern + +Doing real work in `OnValidateUpgradePerCompany` with no upgrade-tag guard. The same scan runs every upgrade, multiplying upgrade time by the number of releases the customer takes. + +See sample: `minimize-onvalidate-upgrade-triggers.bad.al`. diff --git a/microsoft/knowledge/upgrade/no-external-calls-in-upgrade.bad.al b/microsoft/knowledge/upgrade/no-external-calls-in-upgrade.bad.al new file mode 100644 index 0000000..2827b96 --- /dev/null +++ b/microsoft/knowledge/upgrade/no-external-calls-in-upgrade.bad.al @@ -0,0 +1,13 @@ +codeunit 50215 "Upgrade No External" +{ + Subtype = Upgrade; + + trigger OnUpgradePerCompany() + var + Client: HttpClient; + Response: HttpResponseMessage; + begin + // External call inside upgrade code — can hang or fail and abort the upgrade. + Client.Get('https://external-service.contoso.com/api/sync', Response); + end; +} diff --git a/microsoft/knowledge/upgrade/no-external-calls-in-upgrade.good.al b/microsoft/knowledge/upgrade/no-external-calls-in-upgrade.good.al new file mode 100644 index 0000000..48f62b1 --- /dev/null +++ b/microsoft/knowledge/upgrade/no-external-calls-in-upgrade.good.al @@ -0,0 +1,17 @@ +codeunit 50214 "Upgrade No External" +{ + Subtype = Upgrade; + + trigger OnUpgradePerCompany() + var + ExternalSyncSetup: Record "External Sync Setup"; + begin + // Defer the external call: just set a flag the runtime path will pick up. + if not ExternalSyncSetup.Get() then begin + ExternalSyncSetup.Init(); + ExternalSyncSetup.Insert(); + end; + ExternalSyncSetup."Resync Required" := true; + ExternalSyncSetup.Modify(); + end; +} diff --git a/microsoft/knowledge/upgrade/no-external-calls-in-upgrade.md b/microsoft/knowledge/upgrade/no-external-calls-in-upgrade.md new file mode 100644 index 0000000..eb644b6 --- /dev/null +++ b/microsoft/knowledge/upgrade/no-external-calls-in-upgrade.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [httpclient, dotnet, external-service, network-call, blocking, upgrade-rollback] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# No external calls inside upgrade codeunits + +## Description + +Upgrade code runs in a constrained execution window: the tenant is mid-upgrade, no users are signed in, and a failure aborts the entire transaction. An external HTTP call, DotNet interop call, or any other I/O to a system outside Business Central can hang or fail for reasons completely unrelated to the upgrade — DNS, expired credentials, a service that is itself being upgraded — and the upgrade fails with it. Rolling back from such a failure is hard because the upgrade pipeline assumes its work is deterministic. + +The rule applies inside any codeunit with `Subtype = Upgrade` and to any procedure transitively invoked from `OnUpgrade...` triggers. The same calls in regular runtime code — pages, table triggers, normal codeunits, background jobs — are fine. + +## Best Practice + +Defer external calls to runtime code. If a piece of upgrade work conceptually needs data from an external service, set a flag or write a queue row during upgrade and have the runtime code make the call later (for example on first user sign-in or via job queue), where retries and degraded modes are tractable. + +See sample: `no-external-calls-in-upgrade.good.al`. + +## Anti Pattern + +Calling `HttpClient.Get`, `HttpClient.Post`, or DotNet interop methods from `OnUpgradePerCompany`, `OnUpgradePerDatabase`, or any procedure they invoke. + +See sample: `no-external-calls-in-upgrade.bad.al`. diff --git a/microsoft/knowledge/upgrade/obsolete-pending-to-removed-staging.bad.al b/microsoft/knowledge/upgrade/obsolete-pending-to-removed-staging.bad.al new file mode 100644 index 0000000..e28ae94 --- /dev/null +++ b/microsoft/knowledge/upgrade/obsolete-pending-to-removed-staging.bad.al @@ -0,0 +1,14 @@ +// Skipping the Pending stage and going straight to Removed leaves callers +// and persisted rows with no migration window. +enum 50231 "My Enum" +{ + Extensible = true; + value(0; "First") { } + value(1; "Second") + { + ObsoleteState = Removed; + ObsoleteReason = 'Replaced by NewValue'; + ObsoleteTag = '22.0'; + } + value(2; "Third") { } +} diff --git a/microsoft/knowledge/upgrade/obsolete-pending-to-removed-staging.good.al b/microsoft/knowledge/upgrade/obsolete-pending-to-removed-staging.good.al new file mode 100644 index 0000000..be94807 --- /dev/null +++ b/microsoft/knowledge/upgrade/obsolete-pending-to-removed-staging.good.al @@ -0,0 +1,29 @@ +// Release N: deprecation announced. +enum 50229 "My Enum N" +{ + Extensible = true; + value(0; "First") { } + value(1; "Second") + { + ObsoleteState = Pending; + ObsoleteReason = 'Replaced by NewValue'; + ObsoleteTag = '22.0'; + } + value(2; "Third") { } + value(3; "NewValue") { } +} + +// Release N+1 (or later): removal staged; upgrade code now migrates persisted rows. +enum 50230 "My Enum NPlus1" +{ + Extensible = true; + value(0; "First") { } + value(1; "Second") + { + ObsoleteState = Removed; + ObsoleteReason = 'Replaced by NewValue'; + ObsoleteTag = '22.0'; + } + value(2; "Third") { } + value(3; "NewValue") { } +} diff --git a/microsoft/knowledge/upgrade/obsolete-pending-to-removed-staging.md b/microsoft/knowledge/upgrade/obsolete-pending-to-removed-staging.md new file mode 100644 index 0000000..cb008ac --- /dev/null +++ b/microsoft/knowledge/upgrade/obsolete-pending-to-removed-staging.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [obsolete-state, pending, removed, lifecycle, clean-flag, upgrade-code-timing] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Stage obsoletion `Pending → Removed`; write upgrade code on removal + +## Description + +`ObsoleteState` has a deliberate two-step lifecycle. `Pending` keeps the element compilable and present — callers still find it but receive a deprecation warning. `Removed` marks the element as gone from the contract; the body may be empty or wrapped in `#if not CLEAN` so the symbol survives only for binary compatibility. Upgrade code that migrates persisted data away from the obsolete element is normally written when the element moves to `Removed`, not when it goes `Pending`. `ObsoleteState = Pending` without accompanying upgrade code is the expected steady state during the deprecation window; reviewers should not flag that combination as missing migration. + +## Best Practice + +Stage the deprecation across releases. Step 1: mark `Pending` with reason and tag; consumers are warned but data and code keep working. Step 2: in a later release, transition to `Removed` and (if persisted data references the element) ship an upgrade procedure that migrates that data — gated by an upgrade tag. The standard mechanic for retiring the actual implementation body is to remove the `#if not CLEAN` block in the same release that flips the state to `Removed`. + +See sample: `obsolete-pending-to-removed-staging.good.al`. + +## Anti Pattern + +Jumping straight to `ObsoleteState = Removed` without a prior `Pending` release. Consumers have no deprecation window to migrate and any data still referencing the element is stranded. Equally wrong: leaving an element `Pending` indefinitely and never staging its removal — the deprecation never completes. + +See sample: `obsolete-pending-to-removed-staging.bad.al`. diff --git a/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.bad.al b/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.bad.al new file mode 100644 index 0000000..22290a4 --- /dev/null +++ b/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.bad.al @@ -0,0 +1,8 @@ +codeunit 50228 "Old Method Holder" +{ + // ObsoleteState set without ObsoleteReason or ObsoleteTag. + [Obsolete('')] + procedure OldMethod() + begin + end; +} diff --git a/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.good.al b/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.good.al new file mode 100644 index 0000000..8562b0c --- /dev/null +++ b/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.good.al @@ -0,0 +1,12 @@ +codeunit 50227 "Old Method Holder" +{ + [Obsolete('Use NewMethod instead for better performance', '22.0')] + procedure OldMethod() + begin + // Body kept while ObsoleteState = Pending; warns at call sites. + end; + + procedure NewMethod() + begin + end; +} diff --git a/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.md b/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.md new file mode 100644 index 0000000..0f2e11e --- /dev/null +++ b/microsoft/knowledge/upgrade/obsoletion-requires-reason-and-tag.md @@ -0,0 +1,36 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [obsolete-state, obsolete-reason, obsolete-tag, deprecation, metadata] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Mark obsolete elements with `ObsoleteState`, `ObsoleteReason`, and `ObsoleteTag` + +## Description + +When a procedure, field, table, page, or enum value is being retired, AL requires three pieces of metadata to declare the deprecation: + +- `ObsoleteState` — `Pending` while the element still exists but is being phased out, `Removed` once it should no longer be used. +- `ObsoleteReason` — a short human-readable string explaining what to use instead. Tooling and downstream consumers surface this when warning callers. +- `ObsoleteTag` — a stable version-like marker (typically the release version in which the deprecation was introduced, e.g. `'22.0'`). + +Omitting `ObsoleteReason` or `ObsoleteTag` leaves consumers with `ObsoleteState = Pending` but no guidance and no traceability. Declaring `ObsoleteState = Removed` without a reason or tag is the same failure with a stronger blast radius. + +## Best Practice + +Every obsoleted element carries all three properties together. The reason names the replacement explicitly; the tag is the version in which the deprecation was introduced and stays stable for the life of the deprecation. + +See sample: `obsoletion-requires-reason-and-tag.good.al`. + +## Anti Pattern + +Setting only `ObsoleteState = Pending;` (or `Removed`) without `ObsoleteReason` and `ObsoleteTag`. Callers see a warning with no explanation, and the deprecation cannot be tracked by version. + +See sample: `obsoletion-requires-reason-and-tag.bad.al`. + +## See also + +- `obsolete-pending-to-removed-staging.md` — when to advance `Pending` to `Removed` and write upgrade code. diff --git a/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.bad.al b/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.bad.al index d2ebe49..adf5cf5 100644 --- a/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.bad.al +++ b/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.bad.al @@ -1,4 +1,4 @@ -codeunit 50805 "Upgrade Sample TagRegister Bad" +codeunit 50213 "Upgrade Tag Registration" { Subtype = Upgrade; @@ -6,17 +6,15 @@ codeunit 50805 "Upgrade Sample TagRegister Bad" var UpgradeTag: Codeunit "Upgrade Tag"; begin - if UpgradeTag.HasUpgradeTag(FeatureXUpgradeTag()) then + if UpgradeTag.HasUpgradeTag(MyUpgradeTag()) then exit; - - UpgradeTag.SetUpgradeTag(FeatureXUpgradeTag()); + UpgradeTag.SetUpgradeTag(MyUpgradeTag()); end; - // Missing OnGetPerCompanyUpgradeTags subscriber. - // The tag is set but the platform's upgrade-tag machinery does not know about it. - - local procedure FeatureXUpgradeTag(): Code[250] + local procedure MyUpgradeTag(): Code[250] begin - exit('MS-000004-FeatureX-20260501'); + exit('MS-123456-MyFeature-20240101'); end; + + // No OnGetPerCompanyUpgradeTags subscriber — the tag is unknown to the platform. } diff --git a/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.good.al b/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.good.al index 0fb5118..02362c9 100644 --- a/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.good.al +++ b/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.good.al @@ -1,4 +1,4 @@ -codeunit 50804 "Upgrade Sample TagRegister Good" +codeunit 50212 "Upgrade Tag Registration" { Subtype = Upgrade; @@ -6,20 +6,20 @@ codeunit 50804 "Upgrade Sample TagRegister Good" var UpgradeTag: Codeunit "Upgrade Tag"; begin - if UpgradeTag.HasUpgradeTag(FeatureXUpgradeTag()) then + if UpgradeTag.HasUpgradeTag(MyUpgradeTag()) then exit; + // Upgrade work ... + UpgradeTag.SetUpgradeTag(MyUpgradeTag()); + end; - UpgradeTag.SetUpgradeTag(FeatureXUpgradeTag()); + local procedure MyUpgradeTag(): Code[250] + begin + exit('MS-123456-MyFeature-20240101'); end; [EventSubscriber(ObjectType::Codeunit, Codeunit::"Upgrade Tag", 'OnGetPerCompanyUpgradeTags', '', false, false)] local procedure RegisterPerCompanyTags(var PerCompanyUpgradeTags: List of [Code[250]]) begin - PerCompanyUpgradeTags.Add(FeatureXUpgradeTag()); - end; - - local procedure FeatureXUpgradeTag(): Code[250] - begin - exit('MS-000003-FeatureX-20260501'); + PerCompanyUpgradeTags.Add(MyUpgradeTag()); end; } diff --git a/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.md b/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.md index 9f34007..a413520 100644 --- a/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.md +++ b/microsoft/knowledge/upgrade/register-upgrade-tags-with-subscribers.md @@ -1,26 +1,28 @@ --- bc-version: [all] domain: upgrade -keywords: [upgrade-tag, ongetpercompanyupgradetags, ongetperdatabaseupgradetags, registration] +keywords: [upgrade-tag, event-subscriber, on-get-per-company-upgrade-tags, on-get-per-database-upgrade-tags, registration] technologies: [al] countries: [w1] application-area: [all] --- -# Register every upgrade tag with the matching PerCompany or PerDatabase subscriber +# Register every upgrade tag with the platform via an event subscriber ## Description -An upgrade tag set via `UpgradeTag.SetUpgradeTag` only participates in the platform's upgrade-tag machinery when it is also registered through `OnGetPerCompanyUpgradeTags` or `OnGetPerDatabaseUpgradeTags` event subscribers on `Codeunit "Upgrade Tag"`. Without registration, the platform cannot enumerate the tag for diagnostic reporting, skipped-step detection, or cross-app coordination. The step still runs and sets the tag, but the tag is effectively invisible to the rest of the upgrade infrastructure. +The `Upgrade Tag` codeunit only recognizes a tag if the tag was published to the platform through one of two events on that codeunit: `OnGetPerCompanyUpgradeTags` for tags set inside `OnUpgradePerCompany`, and `OnGetPerDatabaseUpgradeTags` for tags set inside `OnUpgradePerDatabase`. A tag that is `Set` and `Has`-checked in code but never added to one of these lists is unknown to the platform — its semantics around skip-on-reinstall, telemetry, and operator queries do not apply. + +The registration scope must match where the tag is set: a tag used from `OnUpgradePerCompany` registers in `OnGetPerCompanyUpgradeTags`; a tag used from `OnUpgradePerDatabase` registers in `OnGetPerDatabaseUpgradeTags`. Crossing the scopes silently breaks the tag. ## Best Practice -For every upgrade-tag constant referenced in `HasUpgradeTag`/`SetUpgradeTag`, register it in the subscriber that matches its trigger scope: tags used from `OnUpgradePerCompany` go in `OnGetPerCompanyUpgradeTags`; tags used from `OnUpgradePerDatabase` go in `OnGetPerDatabaseUpgradeTags`. Treat this mapping as a review point, not just a naming convention. Keep the tag string in a single source (Label or function) and reference it at the guard, the setter, and the registration. +For every new upgrade tag, add one line to the matching subscriber: `PerCompanyUpgradeTags.Add(MyUpgradeTag());` or `PerDatabaseUpgradeTags.Add(MyUpgradeTag());`. Place the subscribers in the same codeunit (or a dedicated "Upgrade Tag Definitions" codeunit) so the tag string and its registration stay together. See sample: `register-upgrade-tags-with-subscribers.good.al`. ## Anti Pattern -Adding a new `UpgradeTag.SetUpgradeTag(MyTag())` without the matching `PerCompanyUpgradeTags.Add(MyTag())` in the registration subscriber, or registering a tag used from `OnUpgradePerCompany` in `OnGetPerDatabaseUpgradeTags`. The code compiles and the step completes, but the tag is invisible or registered at the wrong scope. +Calling `UpgradeTag.SetUpgradeTag(MyUpgradeTag())` without ever adding `MyUpgradeTag()` to the corresponding `OnGetPerCompany...` / `OnGetPerDatabase...` subscriber. See sample: `register-upgrade-tags-with-subscribers.bad.al`. diff --git a/microsoft/knowledge/upgrade/skip-non-essential-work-during-upgrade-context.bad.al b/microsoft/knowledge/upgrade/skip-non-essential-work-during-upgrade-context.bad.al deleted file mode 100644 index 300b5d7..0000000 --- a/microsoft/knowledge/upgrade/skip-non-essential-work-during-upgrade-context.bad.al +++ /dev/null @@ -1,14 +0,0 @@ -codeunit 50820 "Upgrade Sample SkipContext Bad" -{ - procedure AddReportSelectionEntries() - begin - // No execution-context check. On upgrade, this either throws on - // primary-key conflict or silently overwrites the tenant's - // customized report selections. - InsertDefaultReportSelections(); - end; - - local procedure InsertDefaultReportSelections() - begin - end; -} diff --git a/microsoft/knowledge/upgrade/skip-non-essential-work-during-upgrade-context.good.al b/microsoft/knowledge/upgrade/skip-non-essential-work-during-upgrade-context.good.al deleted file mode 100644 index 04ade74..0000000 --- a/microsoft/knowledge/upgrade/skip-non-essential-work-during-upgrade-context.good.al +++ /dev/null @@ -1,15 +0,0 @@ -codeunit 50819 "Upgrade Sample SkipContext Good" -{ - procedure AddReportSelectionEntries() - begin - // Existing tenants already have the selections, possibly customized. - if GetExecutionContext() = ExecutionContext::Upgrade then - exit; - - InsertDefaultReportSelections(); - end; - - local procedure InsertDefaultReportSelections() - begin - end; -} diff --git a/microsoft/knowledge/upgrade/skip-non-essential-work-during-upgrade-context.md b/microsoft/knowledge/upgrade/skip-non-essential-work-during-upgrade-context.md deleted file mode 100644 index 39a53df..0000000 --- a/microsoft/knowledge/upgrade/skip-non-essential-work-during-upgrade-context.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: upgrade -keywords: [executioncontext, upgrade, reportselections, initialization, install] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Skip non-essential initialization when ExecutionContext is Upgrade - -## Description - -Initialization code that inserts default rows — report selections, number-series, setup-table defaults — is correct on first install and harmful during upgrade. Existing tenants already have these rows, possibly customized; re-running the initialization either fails on primary-key conflicts or silently overwrites customer configuration. The platform exposes `GetExecutionContext()` so the same procedure can be safely called from install and upgrade paths without duplicating the insert logic. - -## Best Practice - -Check `if GetExecutionContext() = ExecutionContext::Upgrade then exit;` at the top of idempotent-on-install-only procedures. Keep the early exit narrow and document the reason. The check should be additive to existing guards, not a replacement for proper primary-key handling in the insert itself. - -See sample: `skip-non-essential-work-during-upgrade-context.good.al`. - -## Anti Pattern - -A procedure that unconditionally inserts a default report-selection, number-series, or setup row, called from both install and upgrade paths. On upgrade it either throws on the conflicting key or overwrites the tenant's existing configuration. - -See sample: `skip-non-essential-work-during-upgrade-context.bad.al`. diff --git a/microsoft/knowledge/upgrade/skip-nonessential-work-via-execution-context.bad.al b/microsoft/knowledge/upgrade/skip-nonessential-work-via-execution-context.bad.al new file mode 100644 index 0000000..db0df6f --- /dev/null +++ b/microsoft/knowledge/upgrade/skip-nonessential-work-via-execution-context.bad.al @@ -0,0 +1,12 @@ +codeunit 50217 "Report Selection Seeder" +{ + procedure AddReportSelectionEntries() + var + ReportSelections: Record "Report Selections"; + begin + // No context check — fires during upgrade and silently inserts rows + // the upgrade pipeline never asked for. + ReportSelections.Init(); + ReportSelections.Insert(); + end; +} diff --git a/microsoft/knowledge/upgrade/skip-nonessential-work-via-execution-context.good.al b/microsoft/knowledge/upgrade/skip-nonessential-work-via-execution-context.good.al new file mode 100644 index 0000000..61dfeda --- /dev/null +++ b/microsoft/knowledge/upgrade/skip-nonessential-work-via-execution-context.good.al @@ -0,0 +1,15 @@ +codeunit 50216 "Report Selection Seeder" +{ + procedure AddReportSelectionEntries() + var + ReportSelections: Record "Report Selections"; + begin + // Do not add report-selection entries during upgrade; the upgrade pipeline + // does not need them and re-running this on every upgrade is wasteful. + if GetExecutionContext() = ExecutionContext::Upgrade then + exit; + + ReportSelections.Init(); + ReportSelections.Insert(); + end; +} diff --git a/microsoft/knowledge/upgrade/skip-nonessential-work-via-execution-context.md b/microsoft/knowledge/upgrade/skip-nonessential-work-via-execution-context.md new file mode 100644 index 0000000..0b441e6 --- /dev/null +++ b/microsoft/knowledge/upgrade/skip-nonessential-work-via-execution-context.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [get-execution-context, execution-context-upgrade, skip, report-selection, runtime-trigger] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Skip non-essential runtime work when `GetExecutionContext() = ExecutionContext::Upgrade` + +## Description + +Runtime procedures (table triggers, install routines, helpers called from many places) sometimes fire during the upgrade window because the upgrade itself touches the data they react to. When the work those procedures do is not strictly required for the upgrade to succeed — inserting report-selection entries, seeding optional configuration, sending welcome notifications — they should detect upgrade context with `GetExecutionContext() = ExecutionContext::Upgrade` and exit. This keeps upgrade transactions tight and avoids side effects that the upgrade pipeline did not ask for. + +This is the opposite of a load-bearing concern: code that MUST run during the upgrade does not consult execution context. The check is for *optional* side effects that happen to be wired into runtime code paths. + +## Best Practice + +In a runtime procedure that performs non-essential side effects, guard the side-effect block with `if GetExecutionContext() = ExecutionContext::Upgrade then exit;` and include a brief comment explaining what is being skipped and why. + +See sample: `skip-nonessential-work-via-execution-context.good.al`. + +## Anti Pattern + +Using `GetExecutionContext()` to *enable* upgrade behaviour from outside an upgrade codeunit. Upgrade behaviour belongs in a codeunit with `Subtype = Upgrade`; runtime code should only use the check to *suppress* optional work. + +See sample: `skip-nonessential-work-via-execution-context.bad.al`. diff --git a/microsoft/knowledge/upgrade/triggers-call-helpers-not-implementations.bad.al b/microsoft/knowledge/upgrade/triggers-call-helpers-not-implementations.bad.al new file mode 100644 index 0000000..e409ea8 --- /dev/null +++ b/microsoft/knowledge/upgrade/triggers-call-helpers-not-implementations.bad.al @@ -0,0 +1,12 @@ +codeunit 50203 "Upgrade Orchestrator" +{ + Subtype = Upgrade; + + trigger OnUpgradePerCompany() + var + Customer: Record Customer; + begin + // Direct implementation in the trigger body — wrong. + Customer.ModifyAll("Some Field", true); + end; +} diff --git a/microsoft/knowledge/upgrade/triggers-call-helpers-not-implementations.good.al b/microsoft/knowledge/upgrade/triggers-call-helpers-not-implementations.good.al new file mode 100644 index 0000000..d03fa4a --- /dev/null +++ b/microsoft/knowledge/upgrade/triggers-call-helpers-not-implementations.good.al @@ -0,0 +1,19 @@ +codeunit 50202 "Upgrade Orchestrator" +{ + Subtype = Upgrade; + + trigger OnUpgradePerCompany() + begin + UpgradeMyFeature(); + UpgradeSecondFeature(); + end; + + local procedure UpgradeMyFeature() + var + Customer: Record Customer; + begin + Customer.ModifyAll("Some Field", true); + end; + + local procedure UpgradeSecondFeature() begin end; +} diff --git a/microsoft/knowledge/upgrade/triggers-call-helpers-not-implementations.md b/microsoft/knowledge/upgrade/triggers-call-helpers-not-implementations.md new file mode 100644 index 0000000..dcc21e3 --- /dev/null +++ b/microsoft/knowledge/upgrade/triggers-call-helpers-not-implementations.md @@ -0,0 +1,28 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [on-upgrade-per-company, on-upgrade-per-database, trigger-body, helper-procedure, structure] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# `OnUpgradePerCompany` / `OnUpgradePerDatabase` should call helpers, not inline logic + +## Description + +The `OnUpgradePerCompany` and `OnUpgradePerDatabase` triggers on an upgrade codeunit are dispatch points, not implementation slots. They should contain only calls to named local procedures — one call per feature being upgraded. Putting `ModifyAll`, record loops, or any business logic directly inside the trigger body makes the upgrade impossible to read, impossible to selectively skip via upgrade tags per feature, and impossible to extend without touching the trigger itself. + +Empty `OnUpgradePerCompany` / `OnUpgradePerDatabase` triggers are acceptable — they may be placeholders for future use or artifacts from cleanup. + +## Best Practice + +Each upgrade trigger contains an ordered list of procedure calls, one per feature: `UpgradeFeatureA();` `UpgradeFeatureB();`. Each procedure handles its own upgrade tag, its own data work, and can be added or removed independently. + +See sample: `triggers-call-helpers-not-implementations.good.al`. + +## Anti Pattern + +Implementing record loops, `ModifyAll`, or other data work directly in the trigger body. The trigger then mixes orchestration with implementation, and adding a second feature requires editing the trigger rather than appending one line. + +See sample: `triggers-call-helpers-not-implementations.bad.al`. diff --git a/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.bad.al b/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.bad.al new file mode 100644 index 0000000..024443c --- /dev/null +++ b/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.bad.al @@ -0,0 +1,10 @@ +codeunit 50201 "Upgrade My Feature" +{ + // Missing Subtype = Upgrade; the OnUpgrade trigger is never dispatched. + trigger OnUpgradePerCompany() + begin + UpgradeMyFeature(); + end; + + local procedure UpgradeMyFeature() begin end; +} diff --git a/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.good.al b/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.good.al new file mode 100644 index 0000000..5ef4e88 --- /dev/null +++ b/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.good.al @@ -0,0 +1,17 @@ +codeunit 50200 "Upgrade My Feature" +{ + Subtype = Upgrade; + + trigger OnUpgradePerCompany() + begin + UpgradeMyFeature(); + end; + + trigger OnUpgradePerDatabase() + begin + UpgradeMyGlobalSetup(); + end; + + local procedure UpgradeMyFeature() begin end; + local procedure UpgradeMyGlobalSetup() begin end; +} diff --git a/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.md b/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.md new file mode 100644 index 0000000..2dba21a --- /dev/null +++ b/microsoft/knowledge/upgrade/upgrade-codeunit-subtype.md @@ -0,0 +1,26 @@ +--- +bc-version: [all] +domain: upgrade +keywords: [upgrade-codeunit, subtype, on-upgrade-per-company, on-upgrade-per-database, trigger] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Upgrade logic must live in a codeunit with `Subtype = Upgrade` + +## Description + +A codeunit only participates in the upgrade pipeline when it sets `Subtype = Upgrade`. The platform then dispatches the `OnUpgradePerCompany` and `OnUpgradePerDatabase` triggers on that codeunit during upgrade. A codeunit without `Subtype = Upgrade` — even one that declares an `OnUpgradePerCompany` trigger — is not an upgrade codeunit, and reviewers ignore it for upgrade concerns. Conversely, any procedure invoked transitively from an `OnUpgrade...` trigger of an upgrade codeunit IS upgrade code regardless of where it lives, and the upgrade rules apply to it. + +## Best Practice + +Place every piece of upgrade logic in a codeunit declared with `Subtype = Upgrade;` and expose entry points via the two triggers `OnUpgradePerCompany` and `OnUpgradePerDatabase`. Helper procedures may live in normal codeunits, but they inherit the upgrade-context rules (guarded reads, no external calls, upgrade tags, etc.) when called from an upgrade trigger. + +See sample: `upgrade-codeunit-subtype.good.al`. + +## Anti Pattern + +Putting upgrade-style logic in a regular codeunit that the platform never invokes during upgrade — for example a normal codeunit with a manually invented "RunUpgrade" procedure that nothing wires to the upgrade pipeline. The migration code will simply not run. + +See sample: `upgrade-codeunit-subtype.bad.al`. diff --git a/microsoft/knowledge/upgrade/use-datatransfer-for-large-dataset-initialization.bad.al b/microsoft/knowledge/upgrade/use-datatransfer-for-large-dataset-initialization.bad.al deleted file mode 100644 index c232adb..0000000 --- a/microsoft/knowledge/upgrade/use-datatransfer-for-large-dataset-initialization.bad.al +++ /dev/null @@ -1,22 +0,0 @@ -codeunit 50809 "Upgrade Sample DataTransfer Bad" -{ - Subtype = Upgrade; - - trigger OnUpgradePerCompany() - begin - InitializeNewFlag(); - end; - - local procedure InitializeNewFlag() - var - CustLedgerEntry: Record "Cust. Ledger Entry"; - begin - // Row-at-a-time update over a 10M-row ledger table. Multi-hour upgrade. - CustLedgerEntry.SetRange(Open, true); - if CustLedgerEntry.FindSet(true) then - repeat - CustLedgerEntry."New Flag" := false; - CustLedgerEntry.Modify(); - until CustLedgerEntry.Next() = 0; - end; -} diff --git a/microsoft/knowledge/upgrade/use-datatransfer-for-large-dataset-initialization.good.al b/microsoft/knowledge/upgrade/use-datatransfer-for-large-dataset-initialization.good.al deleted file mode 100644 index b45a9b9..0000000 --- a/microsoft/knowledge/upgrade/use-datatransfer-for-large-dataset-initialization.good.al +++ /dev/null @@ -1,31 +0,0 @@ -codeunit 50808 "Upgrade Sample DataTransfer Good" -{ - Subtype = Upgrade; - - trigger OnUpgradePerCompany() - begin - InitializeNewFlag(); - end; - - local procedure InitializeNewFlag() - var - CustLedgerEntry: Record "Cust. Ledger Entry"; - CLEDataTransfer: DataTransfer; - UpgradeTag: Codeunit "Upgrade Tag"; - begin - if UpgradeTag.HasUpgradeTag(InitializeNewFlagTag()) then - exit; - - CLEDataTransfer.SetTables(Database::"Cust. Ledger Entry", Database::"Cust. Ledger Entry"); - CLEDataTransfer.AddSourceFilter(CustLedgerEntry.FieldNo(Open), '=%1', true); - CLEDataTransfer.AddConstantValue(false, CustLedgerEntry.FieldNo("New Flag")); - CLEDataTransfer.CopyFields(); - - UpgradeTag.SetUpgradeTag(InitializeNewFlagTag()); - end; - - local procedure InitializeNewFlagTag(): Code[250] - begin - exit('MS-000005-CLEInitializeNewFlag-20260501'); - end; -} diff --git a/microsoft/knowledge/upgrade/use-datatransfer-for-large-dataset-initialization.md b/microsoft/knowledge/upgrade/use-datatransfer-for-large-dataset-initialization.md deleted file mode 100644 index 96d7526..0000000 --- a/microsoft/knowledge/upgrade/use-datatransfer-for-large-dataset-initialization.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -bc-version: [all] -domain: upgrade -keywords: [datatransfer, initvalue, large-dataset, bulk-update, upgrade] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Use DataTransfer to initialize large tables in upgrade; not FindSet plus Modify - -## Description - -An upgrade that populates a new field on existing rows with a FindSet+Modify loop pays a round-trip and a per-row trigger invocation for every row — turning a multi-hour upgrade into a multi-day one on ledger-entry-scale tables. `DataTransfer` pushes the update to SQL as a single set-based operation using source filters and constant values, which is the supported platform mechanism for this scenario. The tradeoff: DataTransfer bypasses validation triggers and event subscribers — if the step depends on trigger logic, that has to be reconstructed explicitly. - -## Best Practice - -Use DataTransfer when a new field added to an existing table needs initialization across existing rows, and for any table that can contain more than 300,000 records. Tables in the ledger-entry and document-line category reliably exceed this threshold; treat them as requiring DataTransfer by default. - -Set tables, add source filters, add constant values, call CopyFields, clear, and repeat for additional slices. Use the pattern for new fields and tables added in the same change. If no new field or table is involved, document why validation triggers and event subscribers are safe to bypass, or keep the explicit loop that invokes the business logic. - -See sample: `use-datatransfer-for-large-dataset-initialization.good.al`. - -## Anti Pattern - -`FindSet(true)` + `Modify()` in a loop as the initialization path for a new field across an entire existing table. The resulting upgrade time is proportional to the row count; for a ten-million-row ledger-entry table it is the single largest step in the release. - -See sample: `use-datatransfer-for-large-dataset-initialization.bad.al`. diff --git a/microsoft/knowledge/upgrade/use-obsolete-pending-before-removed.bad.al b/microsoft/knowledge/upgrade/use-obsolete-pending-before-removed.bad.al deleted file mode 100644 index 3ec4385..0000000 --- a/microsoft/knowledge/upgrade/use-obsolete-pending-before-removed.bad.al +++ /dev/null @@ -1,11 +0,0 @@ -codeunit 50818 "Upgrade Sample Obsolete Bad" -{ - // Straight to Removed with no preceding Pending phase, no ObsoleteReason, - // no ObsoleteTag. Dependents compiled against the previous release hit - // a hard compile error with no migration signal. - [Obsolete('', '')] - procedure CalculateNetAmount(Amount: Decimal): Decimal - begin - Error('Removed.'); - end; -} diff --git a/microsoft/knowledge/upgrade/use-obsolete-pending-before-removed.good.al b/microsoft/knowledge/upgrade/use-obsolete-pending-before-removed.good.al deleted file mode 100644 index 2dc1f9c..0000000 --- a/microsoft/knowledge/upgrade/use-obsolete-pending-before-removed.good.al +++ /dev/null @@ -1,13 +0,0 @@ -codeunit 50817 "Upgrade Sample Obsolete Good" -{ - [Obsolete('Use CalculateNetAmountV2 for the updated rounding semantics.', '28.0')] - procedure CalculateNetAmount(Amount: Decimal): Decimal - begin - exit(Amount); - end; - - procedure CalculateNetAmountV2(Amount: Decimal): Decimal - begin - exit(Amount); - end; -} diff --git a/microsoft/knowledge/upgrade/use-obsolete-pending-before-removed.md b/microsoft/knowledge/upgrade/use-obsolete-pending-before-removed.md deleted file mode 100644 index 0c5a4e8..0000000 --- a/microsoft/knowledge/upgrade/use-obsolete-pending-before-removed.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -bc-version: [all] -domain: upgrade -keywords: [obsolete, obsoletestate, obsoletereason, obsoletetag, deprecation] -technologies: [al] -countries: [w1] -application-area: [all] ---- - -# Deprecate via ObsoleteState Pending first; move to Removed only after the grace window - -## Description - -AL's obsolete workflow is two-stage by design. `ObsoleteState = Pending` keeps the object or member compilable and callable but emits warnings and records the deprecation in metadata. `ObsoleteState = Removed` makes it a compile error for callers. Jumping straight to Removed — or marking Pending without `ObsoleteReason` and `ObsoleteTag` — breaks dependents who had no signal to migrate, and loses the tooling's ability to surface the planned removal in sandbox builds before the production tenant upgrades. - -## Best Practice - -Mark the element `ObsoleteState = Pending` with a concrete `ObsoleteReason` naming the replacement and an `ObsoleteTag` identifying the version the deprecation started. Keep it Pending through at least one major release so dependents have a cycle to migrate. Move to `ObsoleteState = Removed` only in a later release, with the same Reason and Tag retained or updated. - -See sample: `use-obsolete-pending-before-removed.good.al`. - -## Anti Pattern - -`[Obsolete('', '')]` or `ObsoleteState = Removed` applied directly on an element that was public and callable in the previous release, with no preceding Pending phase. Dependents get a hard compile error with no migration signal in the previous version. - -See sample: `use-obsolete-pending-before-removed.bad.al`. diff --git a/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.bad.al b/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.bad.al index bccfb66..f16946e 100644 --- a/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.bad.al +++ b/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.bad.al @@ -1,4 +1,4 @@ -codeunit 50803 "Upgrade Sample TagGuard Bad" +codeunit 50209 "Upgrade Tag Driven" { Subtype = Upgrade; @@ -8,20 +8,18 @@ codeunit 50803 "Upgrade Sample TagGuard Bad" begin NavApp.GetCurrentModuleInfo(AppInfo); - // Version check: fragile across skipped versions, and every nested branch - // is another place a customer can be stuck if the matching step fails. - if AppInfo.DataVersion().Major < 18 then + // Version-coupled branching — breaks when a tenant skips a version. + if AppInfo.DataVersion().Major > 14 then + exit; + + if AppInfo.DataVersion().Major < 14 then UpgradeFeatureA() + else if AppInfo.DataVersion().Major < 17 then + UpgradeFeatureB() else - if AppInfo.DataVersion().Major < 21 then - UpgradeFeatureB(); + exit; end; - local procedure UpgradeFeatureA() - begin - end; - - local procedure UpgradeFeatureB() - begin - end; + local procedure UpgradeFeatureA() begin end; + local procedure UpgradeFeatureB() begin end; } diff --git a/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.good.al b/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.good.al index 4c90219..958e3b6 100644 --- a/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.good.al +++ b/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.good.al @@ -1,26 +1,26 @@ -codeunit 50802 "Upgrade Sample TagGuard Good" +codeunit 50208 "Upgrade Tag Driven" { Subtype = Upgrade; trigger OnUpgradePerCompany() begin - UpgradeFeatureX(); + UpgradeMyFeature(); end; - local procedure UpgradeFeatureX() + local procedure UpgradeMyFeature() var UpgradeTag: Codeunit "Upgrade Tag"; begin - if UpgradeTag.HasUpgradeTag(FeatureXUpgradeTag()) then + if UpgradeTag.HasUpgradeTag(MyUpgradeTag()) then exit; - // Idempotent, retries cleanly after failure, runs exactly once. + // Upgrade work goes here. - UpgradeTag.SetUpgradeTag(FeatureXUpgradeTag()); + UpgradeTag.SetUpgradeTag(MyUpgradeTag()); end; - local procedure FeatureXUpgradeTag(): Code[250] + local procedure MyUpgradeTag(): Code[250] begin - exit('MS-000002-FeatureX-20260501'); + exit('MS-123456-MyFeatureUpgrade-20240101'); end; } diff --git a/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.md b/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.md index 86f82bb..62347d1 100644 --- a/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.md +++ b/microsoft/knowledge/upgrade/use-upgrade-tags-not-version-checks.md @@ -1,26 +1,31 @@ --- bc-version: [all] domain: upgrade -keywords: [upgrade-tag, dataversion, version-check, idempotent, guard] +keywords: [upgrade-tag, version-check, dataversion, has-upgrade-tag, set-upgrade-tag, control-flow] technologies: [al] countries: [w1] application-area: [all] --- -# Guard upgrade steps with upgrade tags, not version checks +# Control upgrade execution with upgrade tags, not version checks ## Description -`DataVersion()` comparisons tie an upgrade step to a specific release cadence: if the step is skipped or fails on one version and the tenant upgrades past the check before the step succeeds, the step never runs. Upgrade tags, managed by `Codeunit "Upgrade Tag"`, record per-step completion in the tenant database. A tag-guarded step runs once, retries cleanly after failure, and remains idempotent across future versions regardless of the version the customer is upgrading from. +Each piece of upgrade logic must run exactly once per company (or database) across the lifetime of an extension. The platform mechanism for that is the `Upgrade Tag` codeunit: a procedure asks `HasUpgradeTag(MyTag())` at entry, performs its work, then calls `SetUpgradeTag(MyTag())` to record completion. Subsequent upgrades on the same tenant see the tag and skip the work. Hand-rolled `if MyApp.DataVersion().Major < N then ...` chains are the wrong tool: they are version-coupled, accumulate stale branches over time, and break when a tenant skips a version. ## Best Practice -Guard each standard upgrade step with `if UpgradeTag.HasUpgradeTag(MyTag()) then exit;` at the top of the procedure. After the step completes, call `UpgradeTag.SetUpgradeTag(MyTag())`. Define the tag string in a `Tok`-suffixed Label or returning function so the same constant is referenced at both the guard and the registration (see `register-upgrade-tags-with-getpercompany-getperdatabase-subscribers`). The supported DataVersion exception is first-install detection in `OnInstallAppPerCompany` with the `0.0.0.0` sentinel; one-time Hybrid migration codeunits follow separate migration patterns and should not be forced into ordinary upgrade-tag structure. +Every upgrade procedure starts with a `HasUpgradeTag` guard and ends with `SetUpgradeTag` once the work is committed. Each feature gets its own tag string so features can be re-run independently if needed. See sample: `use-upgrade-tags-not-version-checks.good.al`. ## Anti Pattern -`if MyApp.DataVersion().Major < 18 then UpgradeFeatureA();` inside a standard upgrade step — the step runs on every upgrade from a pre-18 version, may fail on partial data, and the next retry re-runs work that already succeeded. Nesting version-check branches (`< 14` → step A, `< 17` → step B) compounds the fragility. +Branching on `MyApp.DataVersion().Major > N`, or chains of `< N` / `< M` to decide which upgrade step to run. Such code becomes unmaintainable after a few releases and silently does the wrong thing on tenants that skip versions. See sample: `use-upgrade-tags-not-version-checks.bad.al`. + +## See also + +- `first-install-dataversion-zero-check.md` — the one situation where reading `DataVersion()` is the right call. +- `register-upgrade-tags-with-subscribers.md` — how to make a tag known to the platform.