TWItys Blog

All Around Microsoft Dynamics Business Central

BusinessCraft: Triggers, xRec, and Rename Cascades

The last BusinessCraft post was about images. Since then, BusinessCraft got a lot of things you never see, but always use: almost all AL triggers, xRec, and the rename cascade over table relations. And it got its own test app, so I can finally check the platform with AL instead of clicking through pages.

Triggers

Until now, BusinessCraft ran the triggers the mini base app needed. Now it runs almost all of them, on every object type:

Object Triggers
Table OnInsert, OnModify, OnDelete, OnRename, field OnValidate and OnLookup, from AL and from the UI
TableExtension OnBefore… / On… / OnAfter… for insert, modify, delete, and rename, plus OnBeforeValidate and OnAfterValidate on modify fields
Page OnInit, OnOpenPage, OnClosePage, OnQueryClosePage, OnNewRecord, OnInsertRecord, OnModifyRecord, OnDeleteRecord, OnAfterGetRecord, OnAfterGetCurrRecord, field OnValidate, OnLookup, OnDrillDown, OnAssistEdit, and OnAction
PageExtension The same object triggers, plus field and action triggers. Host page and extension both run
Report OnInitReport, OnPreReport, OnPostReport, and per dataitem OnPreDataItem, OnAfterGetRecord, OnPostDataItem
ReportExtension OnPreReport and OnPostReport after the base report, and the OnBefore… / OnAfter… wrappers on modify dataitems
XmlPort Object, table element, and field element triggers on import and export
Query OnBeforeOpen
Codeunit OnRun

In every trigger, the object's global variables are visible, like in BC. The table extension triggers stack: every OnBeforeInsert of every app, then every OnInsert, then every OnAfterInsert.

Still missing: OnFindRecord and OnNextRecord on pages, the page background task triggers, and OnControlAddIn.

xRec

Every AL developer knows xRec. Most of us think of it as "the record as it was before". That is not quite right, and BusinessCraft had to get the difference right.

The test app has a small table, Rename Demo Line, with a Type and a No. field. Both OnValidate triggers remember what they see in xRec:

field(10; Type; Enum "Rename Demo Line Type")
{
    trigger OnValidate()
    begin
        XRecTypeOnTypeValidate := xRec.Type;
    end;
}
field(20; "No."; Code[20])
{
    TableRelation = if (Type = const(Item)) "Rename Demo Item"
    else
    if (Type = const(Resource)) "Rename Demo Resource";

    trigger OnValidate()
    begin
        XRecTypeOnNoValidate := xRec.Type;
        XRecNoOnNoValidate := xRec."No.";
    end;
}

Line 20000 is stored as Resource 1000. The test validates two fields in a row, without a Modify in between:

RenameDemoLine.Get(20000);
RenameDemoLine.Validate(Type, RenameDemoLine.Type::Item);
RenameDemoLine.Validate("No.", '1100');

This is what the triggers see:

Step Rec xRec Database
Get(20000) Resource 1000 Resource 1000 Resource 1000
Validate(Type, Item) Item 1000 Resource 1000 Resource 1000
Validate("No.", '1100') Item 1100 Item 1000 Resource 1000

In the second OnValidate, xRec.Type is Item, although the database still says Resource. xRec is not the database row. It is Rec right before this one change. A runtime that reads xRec from the database gets Resource in both triggers, and every "has the type changed?" check in the base app goes wrong.

So BusinessCraft now keeps xRec beside Rec, equal by default. Validate and the page field OnValidate take a snapshot right before the new value is assigned. Modify, ModifyAll, and Rename hand the row before the change to their triggers.

The test also needed something else: table globals that survive. XRecTypeOnTypeValidate is a global variable of the table, set in a trigger and read later by a procedure. In BC, these globals belong to the record variable and live as long as it does. In BusinessCraft, they were gone after the trigger. Worse, the procedure call swallowed the error, so the test just failed without a message. Both are fixed.

Rename cascades

Here is something no AL code in the base app does: rename a customer, and every sales header, every ledger entry, every document with that customer number follows. There is no OnRename code for that. The platform does it, based on the TableRelation of every field in every table of every installed app.

BusinessCraft does this now. The flow:

  1. Confirm (UI only). You type a new key on a card. If at least one row refers to the old key, the client asks first: Your change may update related records, which can take a while. Do you want to continue? No throws the typed value away.
  2. OnRename. The trigger runs with the new key in Rec and the old record in xRec.
  3. Primary key. The row gets its new key.
  4. Find the relations. The platform looks for every field whose TableRelation points to the renamed key field. Only normal fields, no FlowFields.
  5. Check the conditions. A relation can have arms, like if (Type = const(Item)). A referring row is only updated when the arm's condition matches that row.
  6. Update. The field gets the new value. No OnValidate, no OnModify on the referring rows.

Two details are easy to miss.

The conditions matter. In the demo data, item 1000 and resource 1000 have the same code:

Line Type No. before No. after renaming item 1000 to 1001
10000 Item 1000 1001
20000 Resource 1000 1000
30000 Item 1100 1100

Line 20000 also says 1000, but its relation arm points to the resources. It stays. A naive cascade that only compares values would have renamed it too.

where filters do not matter. A relation like "Rename Demo Item" where(Blocked = const(false)) only restricts the lookup. The cascade ignores it. If you rename a blocked item, its lines still follow.

Blank keys

Renaming a record from or to a blank key is rejected. Think about a blank item: every line with a blank No. and a matching type would suddenly point to the renamed item. And the other way round, renaming an item to blank would turn all its lines into lines without a number. Both cases are in the test app.

Field and Table Relations Metadata

To find the relations, the runtime needs them as data. And once it has them, AL can have them too. Two more virtual tables:

Table ID Content
Field 2000000041 Every field of every table, with type, length, class, and RelationTableNo / RelationFieldNo
Table Relations Metadata 2000000141 Every relation arm, with its condition type, condition field, and condition value

Table Relations Metadata is the interesting one. Field only knows one related table per field. A conditional relation like the one on No. has two arms, and only Table Relations Metadata shows both, with their CONST and FILTER conditions.

The test app

So far, I tested BusinessCraft by clicking through pages, with Rust tests below. That does not scale. So there is now a test app, BusinessCraft Test, written in AL, next to the mini base app.

It does not use the BC test framework. That one needs test codeunits, test pages, and a test runner, and I did not want to build all of that first. Instead, it is a plain worksheet page, and every test is an enum value with an interface implementation:

interface ITest
{
    procedure GetCategory(): Enum "Test Category";
    procedure Run(TestToRun: Enum "Test");
}
enum 50171 "Test" implements ITest
{
    value(1; "Boolean")
    {
        Caption = 'Boolean';
        Implementation = ITest = "Scalar Type Tests";
    }
    ...
}

The worksheet runs each test with Codeunit.Run. A test that fails rolls back, and the error text ends up in the line:

if Codeunit.Run(Codeunit::"Test Runner", TempTestLine) then
    TempTestLine.Result := TempTestLine.Result::Passed
else begin
    TempTestLine.Result := TempTestLine.Result::Failed;
    TempTestLine."Error Message" := CopyStr(GetLastErrorText(), 1, MaxStrLen(TempTestLine."Error Message"));
end;

The nice part: the test app is a test by itself. To publish and run it, BusinessCraft needed a few things it did not have:

  • Interfaces. interface objects, implements, and enum Implementation mappings, including DefaultImplementation.
  • ShowAsTree. The categories are collapsible tree nodes, with the tests indented below.
  • StyleExpr. Category lines are bold, passed tests green, failed tests red.
  • Enabled on actions. Run Test is disabled on category lines. The expression is evaluated after OnAfterGetCurrRecord, so the ribbon follows the selected row.
  • Runtime errors. Codeunit.Run has to catch everything, also an overflow on assignment or a division by zero. Otherwise the whole worksheet stops at the first failing test.

There are 68 tests in four categories:

Category Tests Examples
Scalar Types 25 Integer, Decimal, Text, TextBuilder, Duration, DateFormula, overflows, division by zero
Data Types 27 Guid, Blob, streams, RecordRef, Variant, ErrorInfo, Notification, IsolatedStorage, TaskScheduler
Relation Rename 4 The rename cascade, blank keys, and xRec from above
System Functions 12 Format with length and format strings, IncStr, GetUrl, GetLastErrorCode, collected errors

Indirect permissions: put aside

The last two posts promised the indirect permission test. It did not work. The test app has a BCRAFT POST INDIRECT permission set for it, but the test did not get green.

I put it aside on purpose. The goal right now is to import the real Business Central base app. For that, it is enough that indirect permissions count as direct ones. A user with indirect permission can do a bit more than in BC, and nothing breaks. The real indirect check will come back once the base app runs.

The rest

Lots of small things, mostly AL language:

  • Data types. JSON and XML, the HttpClient family, Guid, RecordId, TableFilter, InStream and OutStream, RecordRef, FieldRef, KeyRef, Variant, and variables for Report, Query, Xmlport, and FilterPageBuilder. The test app has a small weather forecast demo that calls a REST API with HttpClient and reads the JSON response.
  • Statements. break, continue, asserterror, with, multi-dimensional arrays, and [TryFunction].
  • System functions. Most of the commonly used ones, like Format with length and format strings, WorkDate as a setter, Hyperlink, and GetUrl. A few, like ApplicationPath or WindowsLanguage, are mocks that return something plausible.
  • IsolatedStorage. Stored in the BC system table 2000000107. The encryption is a mock for now.
  • Reports. ProcessingOnly reports with procedures on the report instance.
  • Action menus. Nested action groups open as flyouts, like in BC.
  • Action scope. Page actions default to Scope = Page and run without a selected row. Before, Create Demo Data was disabled on an empty list.
  • Role center. The role center at login comes from User Personalization.
  • Lookups. FactBoxes are hidden on lookup pages, like in BC.

Numbers

  • 327 commits, up from 241
  • About 97,100 lines of Rust, up from about 72,800
  • 68 platform tests in AL, in four categories
  • Two new virtual tables: Field and Table Relations Metadata
  • Still a Cursor Pro subscription for $20 a month

Next: Generic events and global triggers

Two things are next. Both are needed by the base app.

Generic table and page events. OnAfterInsertEvent, OnBeforeModifyEvent, OnAfterValidateEvent, OnAfterGetCurrRecordEvent, and friends. Every table and every page publishes them, without a line of AL. A large share of all event subscribers out there use them.

Global triggers. The database triggers for insert, modify, and rename that run for every table. The change log is built on them.

And some system events and system tables are still missing. Those will show up one by one while the base app import gets closer.

I'll keep you updated. :)


BusinessCraft is not affiliated with or endorsed by Microsoft. "Microsoft", "Dynamics 365" and "Business Central" are trademarks of Microsoft Corporation.