mirror of
https://github.com/microsoft/BCQuality.git
synced 2026-08-06 09:26:52 +01:00
Extract 55 knowledge articles from BC review-agent prompt
Adds 55 articles (plus 76 code samples) spanning four new domains and two existing domains, extracted from the internal Business Central review-agent prompt. Content was filtered against BCQuality's remedial-knowledge premise: each article encodes BC-specific behaviour, a CodeCop rule, a platform API semantic, or an anti-false-positive guideline that a capable LLM would otherwise get wrong. New domains: - privacy (11 articles): DataClassification inheritance semantics, the StrSubstNo-defeats-Error-telemetry-classification pitfall, Privacy Notice consent for outgoing requests, anti-false-positives for pages and in-memory data. - upgrade (11 articles): upgrade-codeunit structure, upgrade-tag lifecycle and registration, protected DB reads, DataTransfer for large datasets, InitValue semantics, enum-ordinal preservation, obsolete-workflow, first-install detection. - ui (9 articles): caption capitalization by phrase type, tooltip voice, teaching-tip vs tooltip, tour-tip conventions, character limits, banned terms, ampersand handling, title punctuation. - style (11 articles): label-suffix convention, API page naming, temporary-variable prefix, label properties (Comment/Locked), named invocations, FieldCaption in user messages, OptionCaption pairing, Error-parameter passing, `this` keyword, required parentheses, file naming. Gaps in existing domains: - performance (11 articles): production-scale table catalog (no row counts, per internal-data concern), anti-false-positive for bounded tables, guard-before-Get ordering, redundant-Get-in-OnAfterGetRecord, LockTable in read-only helpers, combined ModifyAll passes, writes in OnAfterGetRecord, SetLoadFields heuristics, temporary-table regressions, FlowField source-table widening, MaintainSQLIndex disabling SIFT. - security (2 articles): environment-specific hardcoded GUIDs, ValidateTableRelation=false on user input. Intentionally excluded: specific production P95 row-count numbers (aggregated internal telemetry); rewritten as categorical guidance on which tables to treat as production-scale without publishing sizes. All articles use `bc-version: [all]` (applies to every BC version, per the new schema sentinel). Validator passes with 0 errors / 0 warnings.
This commit is contained in:
parent
9a4198eb28
commit
e570d6113f
131 changed files with 2799 additions and 0 deletions
|
|
@ -0,0 +1,14 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
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`.
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
codeunit 50821 "Upgrade Sample FirstInstall Good"
|
||||
{
|
||||
Subtype = Install;
|
||||
|
||||
trigger OnInstallAppPerCompany()
|
||||
var
|
||||
AppInfo: ModuleInfo;
|
||||
begin
|
||||
NavApp.GetCurrentModuleInfo(AppInfo);
|
||||
if AppInfo.DataVersion() <> Version.Create('0.0.0.0') then
|
||||
exit;
|
||||
|
||||
// First-install-only initialization follows here.
|
||||
InsertDefaultSetup();
|
||||
end;
|
||||
|
||||
local procedure InsertDefaultSetup()
|
||||
begin
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
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`.
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
---
|
||||
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.
|
||||
|
||||
## 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.
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
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'; }
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
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'; }
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
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. When a value must be retired, mark it with `ObsoleteState = Removed`, `ObsoleteReason`, and `ObsoleteTag` so tooling and downstream code can detect the deprecation; do not reclaim the ordinal. Renaming the caption on an existing ordinal is fine.
|
||||
|
||||
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`.
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
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`.
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
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 where `false` is the correct value for existing rows, 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`.
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
codeunit 50805 "Upgrade Sample TagRegister Bad"
|
||||
{
|
||||
Subtype = Upgrade;
|
||||
|
||||
trigger OnUpgradePerCompany()
|
||||
var
|
||||
UpgradeTag: Codeunit "Upgrade Tag";
|
||||
begin
|
||||
if UpgradeTag.HasUpgradeTag(FeatureXUpgradeTag()) then
|
||||
exit;
|
||||
|
||||
UpgradeTag.SetUpgradeTag(FeatureXUpgradeTag());
|
||||
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]
|
||||
begin
|
||||
exit('MS-000004-FeatureX-20260501');
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
codeunit 50804 "Upgrade Sample TagRegister Good"
|
||||
{
|
||||
Subtype = Upgrade;
|
||||
|
||||
trigger OnUpgradePerCompany()
|
||||
var
|
||||
UpgradeTag: Codeunit "Upgrade Tag";
|
||||
begin
|
||||
if UpgradeTag.HasUpgradeTag(FeatureXUpgradeTag()) then
|
||||
exit;
|
||||
|
||||
UpgradeTag.SetUpgradeTag(FeatureXUpgradeTag());
|
||||
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');
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: upgrade
|
||||
keywords: [upgrade-tag, ongetpercompanyupgradetags, ongetperdatabaseupgradetags, registration]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Register every upgrade tag with the matching PerCompany or PerDatabase 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.
|
||||
|
||||
## 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`. Keep the tag string in a single source (Label or function) and reference it at the guard, the setter, and the registration.
|
||||
|
||||
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. The code compiles and the step completes, but the tag is unregistered and the infrastructure is partially disabled.
|
||||
|
||||
See sample: `register-upgrade-tags-with-subscribers.bad.al`.
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
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`.
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
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 millions of 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 for field-default initialization on existing tables, especially when the target is a ledger-entry or document-line table. Set tables, add source filters, add constant values, call CopyFields, clear, and repeat for additional slices. When trigger or subscriber behaviour is required, do that work separately against a filtered result set so the bulk update remains set-based.
|
||||
|
||||
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`.
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
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`.
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
codeunit 50803 "Upgrade Sample TagGuard Bad"
|
||||
{
|
||||
Subtype = Upgrade;
|
||||
|
||||
trigger OnUpgradePerCompany()
|
||||
var
|
||||
AppInfo: ModuleInfo;
|
||||
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
|
||||
UpgradeFeatureA()
|
||||
else
|
||||
if AppInfo.DataVersion().Major < 21 then
|
||||
UpgradeFeatureB();
|
||||
end;
|
||||
|
||||
local procedure UpgradeFeatureA()
|
||||
begin
|
||||
end;
|
||||
|
||||
local procedure UpgradeFeatureB()
|
||||
begin
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
codeunit 50802 "Upgrade Sample TagGuard Good"
|
||||
{
|
||||
Subtype = Upgrade;
|
||||
|
||||
trigger OnUpgradePerCompany()
|
||||
begin
|
||||
UpgradeFeatureX();
|
||||
end;
|
||||
|
||||
local procedure UpgradeFeatureX()
|
||||
var
|
||||
UpgradeTag: Codeunit "Upgrade Tag";
|
||||
begin
|
||||
if UpgradeTag.HasUpgradeTag(FeatureXUpgradeTag()) then
|
||||
exit;
|
||||
|
||||
// Idempotent, retries cleanly after failure, runs exactly once.
|
||||
|
||||
UpgradeTag.SetUpgradeTag(FeatureXUpgradeTag());
|
||||
end;
|
||||
|
||||
local procedure FeatureXUpgradeTag(): Code[250]
|
||||
begin
|
||||
exit('MS-000002-FeatureX-20260501');
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: upgrade
|
||||
keywords: [upgrade-tag, dataversion, version-check, idempotent, guard]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Guard upgrade steps 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.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Guard each 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`).
|
||||
|
||||
See sample: `use-upgrade-tags-not-version-checks.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
`if MyApp.DataVersion().Major < 18 then UpgradeFeatureA();` — 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.
|
||||
|
||||
See sample: `use-upgrade-tags-not-version-checks.bad.al`.
|
||||
Loading…
Add table
Add a link
Reference in a new issue