mirror of
https://github.com/microsoft/BCQuality.git
synced 2026-08-06 09:26:52 +01:00
Regenerate microsoft/knowledge from upstream BCApps instructions
The previous LLM-generated knowledge files contained factual
hallucinations. The most visible was the claim that `FindFirst` /
`FindLast` "forces a full-table scan" on an unfiltered record - it does
not; those APIs return a single row via the current key.
Other inaccuracies the audit found and fixed:
* `FindSet(true)` was described as "taking a LockTable". The correct
upstream phrasing is that `FindSet(true)` sets
`ReadIsolation::UpdLock` on the read. UpdLock and LockTable are
related but distinct mechanisms.
* The list of production-scale tables had been invented beyond the
upstream source (e.g. "Detailed Cust. Ledg. Entry") without a
citation. The regenerated list matches the ten tables upstream lists
with their P95 row counts.
* `SetLoadFields` guidance had been augmented with an extra mechanism
claim ("the database resolves the filter using the index without
hydrating the value") not present in upstream.
Approach: full regeneration of `microsoft/knowledge/` from the six
upstream BCApps Code Review instruction files, with Microsoft Learn /
the AL language reference as a secondary source. Every claim in every
regenerated file is anchored to a verbatim upstream quote (or a Learn
URL); the audit trail lives in artifacts/trace-<domain>.json on the
session workspace.
The PR #11 transaction/error-handling cluster is preserved verbatim:
* performance/understand-implicit-transaction-boundary.md
* performance/codeunit-run-as-atomic-sub-operation.{md,good.al,bad.al}
* performance/codeunit-run-requires-prior-commit-inside-transaction.{md,good.al,bad.al}
* performance/use-tryfunction-for-error-catching-not-rollback.{md,good.al,bad.al}
* performance/avoid-commit-inside-loops.{md,good.al,bad.al}
* security/commitbehavior-attribute-scopes-explicit-commits.{md,good.al,bad.al}
* testing/transactionmodel-attribute-governs-test-transactions.{md,good.al,bad.al}
These articles already cite Microsoft Learn and were carefully
cross-referenced; the regeneration skips their topics rather than
duplicating them.
File counts after regeneration:
performance 35 .md (5 preserved + 30 new)
privacy 17 .md
security 18 .md (1 preserved + 17 new)
style 33 .md
testing 1 .md (preserved)
ui 19 .md
upgrade 18 .md
Total 141 atomic knowledge files, each strictly one rule. All pass
.github/scripts/validate_frontmatter.py with 0 errors and 0 warnings.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
613c4b4019
commit
a9f3c50863
562 changed files with 6293 additions and 4869 deletions
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
}
|
||||
|
|
@ -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") { }
|
||||
}
|
||||
}
|
||||
|
|
@ -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`.
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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`.
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
30
microsoft/knowledge/upgrade/datatransfer-for-bulk-init.md
Normal file
30
microsoft/knowledge/upgrade/datatransfer-for-bulk-init.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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`.
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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`.
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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`.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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'; }
|
||||
}
|
||||
|
|
@ -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'; }
|
||||
}
|
||||
|
|
@ -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`.
|
||||
|
|
@ -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.
|
||||
}
|
||||
|
|
@ -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.
|
||||
}
|
||||
31
microsoft/knowledge/upgrade/enum-values-additive-at-end.md
Normal file
31
microsoft/knowledge/upgrade/enum-values-additive-at-end.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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`.
|
||||
20
microsoft/knowledge/upgrade/guard-database-reads.bad.al
Normal file
20
microsoft/knowledge/upgrade/guard-database-reads.bad.al
Normal file
|
|
@ -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;
|
||||
}
|
||||
22
microsoft/knowledge/upgrade/guard-database-reads.good.al
Normal file
22
microsoft/knowledge/upgrade/guard-database-reads.good.al
Normal file
|
|
@ -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;
|
||||
}
|
||||
26
microsoft/knowledge/upgrade/guard-database-reads.md
Normal file
26
microsoft/knowledge/upgrade/guard-database-reads.md
Normal file
|
|
@ -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`.
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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`.
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
codeunit 50831 "Upgrade Sample Trigger Bad"
|
||||
{
|
||||
Subtype = Upgrade;
|
||||
|
||||
trigger OnValidateUpgradePerCompany()
|
||||
begin
|
||||
ValidateAllCustomers();
|
||||
end;
|
||||
|
||||
local procedure ValidateAllCustomers()
|
||||
begin
|
||||
end;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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`.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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`.
|
||||
|
|
@ -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.
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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`.
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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`.
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
28
microsoft/knowledge/upgrade/no-external-calls-in-upgrade.md
Normal file
28
microsoft/knowledge/upgrade/no-external-calls-in-upgrade.md
Normal file
|
|
@ -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`.
|
||||
|
|
@ -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") { }
|
||||
}
|
||||
|
|
@ -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") { }
|
||||
}
|
||||
|
|
@ -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<version>` 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<version>` 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`.
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
codeunit 50228 "Old Method Holder"
|
||||
{
|
||||
// ObsoleteState set without ObsoleteReason or ObsoleteTag.
|
||||
[Obsolete('')]
|
||||
procedure OldMethod()
|
||||
begin
|
||||
end;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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`.
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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`.
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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`.
|
||||
10
microsoft/knowledge/upgrade/upgrade-codeunit-subtype.bad.al
Normal file
10
microsoft/knowledge/upgrade/upgrade-codeunit-subtype.bad.al
Normal file
|
|
@ -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;
|
||||
}
|
||||
17
microsoft/knowledge/upgrade/upgrade-codeunit-subtype.good.al
Normal file
17
microsoft/knowledge/upgrade/upgrade-codeunit-subtype.good.al
Normal file
|
|
@ -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;
|
||||
}
|
||||
26
microsoft/knowledge/upgrade/upgrade-codeunit-subtype.md
Normal file
26
microsoft/knowledge/upgrade/upgrade-codeunit-subtype.md
Normal file
|
|
@ -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`.
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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`.
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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`.
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue