Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion evaluation/review-fixtures.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@
"events": {
"articles": [
"reset-ishandled-only-when-the-value-can-carry-over",
"changecompany-runs-triggers-in-the-calling-company"
"changecompany-runs-triggers-in-the-calling-company",
"database-trigger-setup-flags-may-only-be-set-to-true"
]
},
"finance": {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
codeunit 50110 "Change Tracking Subscr. Bad"
{
// Self-contained demonstration of the anti pattern. Not derived from base-app source.
[EventSubscriber(ObjectType::Codeunit, Codeunit::GlobalTriggerManagement, OnAfterGetDatabaseTableTriggerSetup, '', false, false)]
local procedure OptInTrackedTables(TableId: Integer; var OnDatabaseInsert: Boolean; var OnDatabaseModify: Boolean; var OnDatabaseDelete: Boolean; var OnDatabaseRename: Boolean)
var
TrackedTable: Record "Tracked Table Bad";
IsTracked: Boolean;
begin
IsTracked := TrackedTable.Get(TableId);
// Stores false for every table this feature does not track, clearing flags that
// Dataverse integration, API webhooks, or another app already set for that table.
OnDatabaseModify := IsTracked;
OnDatabaseDelete := IsTracked;
// Opting out of operations this feature does not need clears them for everyone else too.
OnDatabaseInsert := false;
OnDatabaseRename := false;
end;

[EventSubscriber(ObjectType::Codeunit, Codeunit::GlobalTriggerManagement, OnAfterOnDatabaseModify, '', false, false)]
local procedure LogModify(RecRef: RecordRef)
var
TrackedChange: Record "Tracked Change Bad";
begin
TrackedChange.Init();
TrackedChange."Table No." := RecRef.Number();
TrackedChange."Record ID" := RecRef.RecordId();
TrackedChange.Insert();
end;
}

table 50110 "Tracked Table Bad"
{
DataClassification = SystemMetadata;

fields
{
field(1; "Table No."; Integer) { }
}

keys
{
key(PK; "Table No.") { Clustered = true; }
}
}

table 50111 "Tracked Change Bad"
{
DataClassification = SystemMetadata;

fields
{
field(1; "Entry No."; Integer) { AutoIncrement = true; }
field(2; "Table No."; Integer) { }
field(3; "Record ID"; RecordId) { }
}

keys
{
key(PK; "Entry No.") { Clustered = true; }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
codeunit 50112 "Change Tracking Subscr. Good"
{
// Self-contained demonstration of the best practice. Not derived from base-app source.
[EventSubscriber(ObjectType::Codeunit, Codeunit::GlobalTriggerManagement, OnAfterGetDatabaseTableTriggerSetup, '', false, false)]
local procedure OptInTrackedTables(TableId: Integer; var OnDatabaseInsert: Boolean; var OnDatabaseModify: Boolean; var OnDatabaseDelete: Boolean; var OnDatabaseRename: Boolean)
var
TrackedTable: Record "Tracked Table Good";
begin
// Only ever turn flags on; flags set by other features stay untouched.
if not TrackedTable.Get(TableId) then
exit;
OnDatabaseModify := true;
OnDatabaseDelete := true;
end;

[EventSubscriber(ObjectType::Codeunit, Codeunit::GlobalTriggerManagement, OnAfterOnDatabaseModify, '', false, false)]
local procedure LogModify(RecRef: RecordRef)
var
TrackedTable: Record "Tracked Table Good";
TrackedChange: Record "Tracked Change Good";
begin
// The event also fires for tables other features opted in.
if RecRef.IsTemporary() then
exit;
if not TrackedTable.Get(RecRef.Number()) then
exit;

TrackedChange.Init();
TrackedChange."Table No." := RecRef.Number();
TrackedChange."Record ID" := RecRef.RecordId();
TrackedChange.Insert();
end;
}

table 50112 "Tracked Table Good"
{
DataClassification = SystemMetadata;

fields
{
field(1; "Table No."; Integer) { }
}

keys
{
key(PK; "Table No.") { Clustered = true; }
}
}

table 50113 "Tracked Change Good"
{
DataClassification = SystemMetadata;

fields
{
field(1; "Entry No."; Integer) { AutoIncrement = true; }
field(2; "Table No."; Integer) { }
field(3; "Record ID"; RecordId) { }
}

keys
{
key(PK; "Entry No.") { Clustered = true; }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
---
bc-version: [15..]
domain: events
keywords: [getdatabasetabletriggersetup, onaftergetdatabasetabletriggersetup, globaltriggermanagement, global-triggers, ondatabaseinsert, ondatabasemodify, ondatabasedelete, ondatabaserename, change-log, var-parameter]
technologies: [al]
countries: [w1]
application-area: [all]
---

# Database trigger setup flags may only be set to true

## Description

The `OnDatabaseInsert`, `OnDatabaseModify`, `OnDatabaseDelete`, and `OnDatabaseRename` events let one subscriber react to writes on any table, receiving the record as a `RecordRef`. They are opt-in per table through `GetDatabaseTableTriggerSetup(TableId; var OnDatabaseInsert; var OnDatabaseModify; var OnDatabaseDelete; var OnDatabaseRename)`, raised by the system codeunit `Global Triggers` (in the 2000000001..2000000010 range). Codeunit 49 `GlobalTriggerManagement` subscribes to it, collects the Dataverse integration and API webhook flags, raises its own integration event `OnAfterGetDatabaseTableTriggerSetup` with the same four `var` Booleans, and then, in the normal execution context only, adds the change log flags.

That the platform raises the database events for a table only when the matching flag ends up `true` is not stated on Microsoft Learn; it is inferred from the sources below. Learn states that subscribing to global triggers disables bulk SQL insert, modify, and delete for that table, so the answer is per table. A BCApps change log test notes that global trigger management may have cached the setup for a table. The `No Transactions Subscriber` test library sets all four flags for every table so that it sees every write.

The four Booleans are shared by every subscriber in the chain, and subscribers run in no particular order. A subscriber that assigns `false`, or assigns an expression that can be `false` such as `OnDatabaseModify := MySetup.Get(TableId)`, overwrites whatever another feature already set for that table if it runs after that feature. The table may then stop raising the database events, and features that rely on them (the change log, Dataverse synchronization, API webhook notifications, data archiving) stop working for it with no error. `GlobalTriggerManagement` asks the change log last, in the normal execution context only, and its comment says it does not want anyone to disable change log management. That ordering protects only the change log flags, and only against `OnAfterGetDatabaseTableTriggerSetup` subscribers. It does not protect the other features' flags, and it does not protect anything against another direct subscriber to `Global Triggers`.

## Best Practice

Prefer table-specific mechanisms when the set of tables is known. Learn advises avoiding global trigger subscribers in general because every opted-in table loses bulk SQL operations. Use the database events only when the set of tables is open-ended or configurable, and turn on only the operations and tables the feature needs.

Subscribe to `GlobalTriggerManagement`'s integration events, `OnAfterGetDatabaseTableTriggerSetup` to opt in and `OnAfterOnDatabaseInsert`, `OnAfterOnDatabaseModify`, `OnAfterOnDatabaseDelete`, or `OnAfterOnDatabaseRename` to react. Learn does not recommend subscribing directly to the events of system codeunits 2000000001..2000000010. Some Microsoft apps do, for example `Data Archive Db Subscriber`, `GP Collect All Modifications`, and the `No Transactions Subscriber` test library, and those subscriptions still compile and run.

In the setup subscriber, only turn flags on: `if IsTracked(TableId) then OnDatabaseModify := true;`, or `OnDatabaseModify := OnDatabaseModify or IsTracked(TableId);` as `Change Log Management` does. A statement such as `if not OnDatabaseDelete then OnDatabaseDelete := false;`, which appears in BCApps, cannot clear a flag and is not this anti-pattern. In the handler, check `RecRef.Number` against the feature's own setup and skip temporary records, because the events also fire for every table another feature opted in.

See sample: [`database-trigger-setup-flags-may-only-be-set-to-true.good.al`](database-trigger-setup-flags-may-only-be-set-to-true.good.al).

## Anti Pattern

In a subscriber to `GetDatabaseTableTriggerSetup` (`Global Triggers`) or `OnAfterGetDatabaseTableTriggerSetup` (`GlobalTriggerManagement`), any assignment to one of the four `var` flags that can store `false` when the flag was already `true`. This includes a literal `false`, an assignment from a lookup or Boolean expression without `or` on the flag's current value, and `Clear` on the parameter.

BCApps has this shape in two places, which shows the mechanism rather than an exception to it. The demo data generator assigns `OnDatabaseInsert := IsTableIDIncludedIntoFullPack(TableId)` while it collects table IDs. The test library `Backup Management` assigns all four flags from its own lookup. Both clear flags set by other features, and both run only in demo-data generation or test sessions where that is accepted. Production and extension code has no such session boundary.

See sample: [`database-trigger-setup-flags-may-only-be-set-to-true.bad.al`](database-trigger-setup-flags-may-only-be-set-to-true.bad.al).

## References

- [Transitioning from codeunit 1 to system codeunits](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/upgrade/transition-from-codeunit1): v14-era upgrade guidance. `GetDatabaseTableTriggerSetup` and `OnDatabase*` moved to codeunit 49 `GlobalTriggerManagement`, and Learn advises against subscribing directly to system codeunits 2000000001..2000000010.
- [How to refactor use of integration records to system fields](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/devenv-integration-record-refactoring): subscribers to codeunit 49 hurt performance, and subscribing to global triggers disables bulk SQL insert, modify, and delete for that table.
- [Event types, global events](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/devenv-event-types#global-events): the codeunit 49 integration events. [Subscribing to events](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/devenv-subscribing-to-events): subscribers run one at a time in no particular order.
- [GlobalTriggerManagement.Codeunit.al](https://github.com/microsoft/BCApps/blob/main/src/Layers/W1/BaseApp/GlobalTriggerManagement.Codeunit.al): the `Global Triggers` setup subscriber and its signature (lines 51-52), the change log ordering and comment in the normal execution context (62-64), `OnAfterGetDatabaseTableTriggerSetup` (173-174), and `OnAfterOnDatabase*` (178-194).
- Inference sources: [ChangeLog.Codeunit.al](https://github.com/microsoft/BCApps/blob/main/src/Layers/W1/Tests/Misc/ChangeLog.Codeunit.al) line 2087 (setup cached per table) and [NoTransactionsSubscriber.Codeunit.al](https://github.com/microsoft/BCApps/blob/main/src/Apps/W1/LibraryNoTransactions/app/NoTransactionsSubscriber.Codeunit.al) lines 4-11 (all four flags on).
- Only-true assignments in BCApps: `ChangeLogManagement.Codeunit.al` lines 85-88 (`or`), `APIWebhookNotificationMgt.Codeunit.al` 224-227, `CRMIntegrationManagement.Codeunit.al` 3748-3753, `MasterDataManagement.Codeunit.al` 1511-1516, `DataArchiveDbSubscriber.Codeunit.al` 27-32, and `GPCollectAllModifications.codeunit.al` 11-21.
- Flag-clearing assignments in demo and test code: [CreateDemonstrationData.Codeunit.al](https://github.com/microsoft/BCApps/blob/main/src/Layers/W1/DemoTool/CreateDemonstrationData.Codeunit.al) lines 343-344 (also the CZ layer line 353 and the IN layer line 357) and [BackupManagement.Codeunit.al](https://github.com/microsoft/BCApps/blob/main/src/Layers/W1/Tests/TestLibraries/BackupManagement.Codeunit.al) lines 470-476.
3 changes: 2 additions & 1 deletion microsoft/skills/review/al-events-review.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ Narrow the relevant files to the subset that applies to the changes under review

- The changed AL object names and types — especially codeunits that publish events or host event subscribers, posting/release/validation routines that should expose extension points, and test codeunits that bind subscribers.
- The changed procedures and triggers, weighted toward event publisher methods, methods carrying the `[EventSubscriber(...)]` attribute, routines that raise `OnBefore`/`OnAfter` events, and any procedure that calls `BindSubscription`/`UnbindSubscription`.
- Tokens extracted from the diff that relate to events and the publish/subscribe model (`IntegrationEvent`, `BusinessEvent`, `InternalEvent`, `EventSubscriber`, `IsHandled`, `BindSubscription`, `UnbindSubscription`, `EventSubscriberInstance`, `OnBefore`, `OnAfter`, `Manual`, `IncludeSender`, `GlobalVarAccess`, `Isolated`, `local`, `internal`, `Sender`, `this`, `RecordRef`, `xRec`, `temporary`, `Temp`, `repeat`, `ChangeCompany`, `StartSession`, `RunTrigger`).
- Tokens extracted from the diff that relate to events and the publish/subscribe model (`IntegrationEvent`, `BusinessEvent`, `InternalEvent`, `EventSubscriber`, `IsHandled`, `BindSubscription`, `UnbindSubscription`, `EventSubscriberInstance`, `OnBefore`, `OnAfter`, `Manual`, `IncludeSender`, `GlobalVarAccess`, `Isolated`, `local`, `internal`, `Sender`, `this`, `RecordRef`, `xRec`, `temporary`, `Temp`, `repeat`, `ChangeCompany`, `StartSession`, `RunTrigger`, `GetDatabaseTableTriggerSetup`, `OnAfterGetDatabaseTableTriggerSetup`, `GlobalTriggerManagement`, `Global Triggers`, `OnDatabaseInsert`, `OnDatabaseModify`, `OnDatabaseDelete`, `OnDatabaseRename`).

A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Read an article's full file — its `## Best Practice` / `## Anti Pattern` bodies — only after it makes the worklist; candidate selection uses the index alone.

Expand All @@ -66,6 +66,7 @@ The following targeted checks map diff signals to specific `events` articles. Tr
- A `var IsHandled` added to a pre-existing event rather than introduced through a new `OnBefore` publisher — `do-not-add-ishandled-to-an-existing-event`.
- An `if IsHandled then exit;` whose skipped body performs posting, ledger-entry creation, number-series consumption, or integrity/permission validation — `do-not-bypass-critical-operations-with-ishandled`.
- A record variable that had `ChangeCompany(<name>)` called on it and is later used with `Insert`, `Modify`, `Delete`, or `Validate`, where the table is not owned by the extension, has triggers that read company data, or has trigger-event subscribers that do not exit on `RunTrigger = false` — `changecompany-runs-triggers-in-the-calling-company`. Do not match a read-only use after `ChangeCompany`, a write with `RunTrigger = false` into an extension-owned table whose triggers do not read company data and whose trigger-event subscribers exit on `RunTrigger = false`, or the parameterless `ChangeCompany()` reset.
- A subscriber to `GetDatabaseTableTriggerSetup` (`Global Triggers`) or `OnAfterGetDatabaseTableTriggerSetup` (`GlobalTriggerManagement`) that assigns one of its `var` flags (`OnDatabaseInsert`, `OnDatabaseModify`, `OnDatabaseDelete`, `OnDatabaseRename`) a literal `false` or a lookup/Boolean expression that does not `or` in the flag's current value, or that calls `Clear` on one — `database-trigger-setup-flags-may-only-be-set-to-true`. Do not match `Flag := true` under a condition, `Flag := Flag or <condition>`, or `if not Flag then Flag := false;`, none of which can clear a flag. Do not match code that runs only in demo-data generation or test-library sessions (for example the base application's demo data tool or a test library's backup/restore subscriber), where clearing other features' flags is accepted.

## Action

Expand Down
Loading