mirror of
https://github.com/microsoft/BCQuality.git
synced 2026-10-05 22:56:55 +01:00
Merge current main into development guidance
Reconcile the read-only guidance output with the machine-readable skill index, adopt linked sample references required by bounded retrieval, and update the guidance regression fixture for the retrieval helper dependency. Permit only the known endpoint-DLP metadata stream during read-only evidence capture. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 638b66d2-9f06-4f60-8781-808709e1485c
This commit is contained in:
commit
8f025ac679
127 changed files with 5251 additions and 136 deletions
|
|
@ -17,13 +17,13 @@ Business Central document headers assign their number series first and then call
|
|||
|
||||
In the document table's insert path, assign the document number and then call `InitRecord`. Keep the default assignments in that procedure and expose narrow before/after events when other extensions must participate.
|
||||
|
||||
See sample: `initialize-document-defaults-in-initrecord.good.al`.
|
||||
See sample: [`initialize-document-defaults-in-initrecord.good.al`](initialize-document-defaults-in-initrecord.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Assigning document defaults in a page trigger, or scattering them directly through `OnInsert` with no `InitRecord` boundary. Non-page creation paths can then miss the defaults, and extensions have no stable initialization hook.
|
||||
|
||||
See sample: `initialize-document-defaults-in-initrecord.bad.al`.
|
||||
See sample: [`initialize-document-defaults-in-initrecord.bad.al`](initialize-document-defaults-in-initrecord.bad.al).
|
||||
|
||||
## Reference
|
||||
|
||||
|
|
|
|||
|
|
@ -17,13 +17,13 @@ AL's `Round(Number, Precision, Direction)` uses `'>'` to round away from zero an
|
|||
|
||||
Choose the direction from the business meaning: `'>'` increases absolute magnitude and `'<'` decreases absolute magnitude for both positive and negative values. Include positive and negative cases whenever a directed rounding rule is tested.
|
||||
|
||||
See sample: `round-direction-symbols-use-magnitude.good.al`.
|
||||
See sample: [`round-direction-symbols-use-magnitude.good.al`](round-direction-symbols-use-magnitude.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Using `'<'` as a mathematical floor or `'>'` as a mathematical ceiling. The result looks correct for positive amounts but moves in the opposite mathematical direction for negative amounts.
|
||||
|
||||
See sample: `round-direction-symbols-use-magnitude.bad.al`.
|
||||
See sample: [`round-direction-symbols-use-magnitude.bad.al`](round-direction-symbols-use-magnitude.bad.al).
|
||||
|
||||
## Reference
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,91 @@
|
|||
codeunit 50100 "Transfer Request Bad"
|
||||
{
|
||||
// Self-contained demonstration of the anti pattern. Not derived from base-app source.
|
||||
procedure RequestFromCompany(TargetCompany: Text[30]; ItemNo: Code[20]; Quantity: Decimal)
|
||||
var
|
||||
TransferRequest: Record "Transfer Request Bad";
|
||||
TransferSetup: Record "Transfer Setup Bad";
|
||||
begin
|
||||
TransferRequest.ChangeCompany(TargetCompany);
|
||||
TransferSetup.ChangeCompany(TargetCompany);
|
||||
TransferSetup.Get();
|
||||
|
||||
TransferRequest.Init();
|
||||
TransferRequest."Entry No." := NextEntryNo(TargetCompany);
|
||||
TransferRequest."Item No." := ItemNo;
|
||||
TransferRequest.Quantity := Quantity;
|
||||
// OnInsert is skipped below, so the default is copied by hand from the target company's setup.
|
||||
TransferRequest."Location Code" := TransferSetup."Default Location Code";
|
||||
// The OnAfterInsertEvent subscriber still fires, in the calling company, and grows the caller's counter.
|
||||
TransferRequest.Insert(false);
|
||||
|
||||
TransferSetup."Open Requests" += 1;
|
||||
TransferSetup.Modify();
|
||||
end;
|
||||
|
||||
local procedure NextEntryNo(TargetCompany: Text[30]): Integer
|
||||
var
|
||||
LastRequest: Record "Transfer Request Bad";
|
||||
begin
|
||||
LastRequest.ChangeCompany(TargetCompany);
|
||||
if LastRequest.FindLast() then
|
||||
exit(LastRequest."Entry No." + 1);
|
||||
exit(1);
|
||||
end;
|
||||
}
|
||||
|
||||
table 50100 "Transfer Request Bad"
|
||||
{
|
||||
DataClassification = CustomerContent;
|
||||
|
||||
fields
|
||||
{
|
||||
field(1; "Entry No."; Integer) { }
|
||||
field(2; "Item No."; Code[20]) { }
|
||||
field(3; Quantity; Decimal) { }
|
||||
field(4; "Location Code"; Code[10]) { }
|
||||
}
|
||||
|
||||
keys
|
||||
{
|
||||
key(PK; "Entry No.") { Clustered = true; }
|
||||
}
|
||||
|
||||
trigger OnInsert()
|
||||
var
|
||||
TransferSetup: Record "Transfer Setup Bad";
|
||||
begin
|
||||
TransferSetup.Get();
|
||||
"Location Code" := TransferSetup."Default Location Code";
|
||||
end;
|
||||
}
|
||||
|
||||
table 50101 "Transfer Setup Bad"
|
||||
{
|
||||
DataClassification = CustomerContent;
|
||||
|
||||
fields
|
||||
{
|
||||
field(1; "Primary Key"; Code[10]) { }
|
||||
field(2; "Default Location Code"; Code[10]) { }
|
||||
field(3; "Open Requests"; Integer) { }
|
||||
}
|
||||
|
||||
keys
|
||||
{
|
||||
key(PK; "Primary Key") { Clustered = true; }
|
||||
}
|
||||
}
|
||||
|
||||
codeunit 50101 "Transfer Request Count Bad"
|
||||
{
|
||||
[EventSubscriber(ObjectType::Table, Database::"Transfer Request Bad", OnAfterInsertEvent, '', false, false)]
|
||||
local procedure CountOpenRequest(var Rec: Record "Transfer Request Bad"; RunTrigger: Boolean)
|
||||
var
|
||||
TransferSetup: Record "Transfer Setup Bad";
|
||||
begin
|
||||
TransferSetup.Get();
|
||||
TransferSetup."Open Requests" += 1;
|
||||
TransferSetup.Modify();
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
codeunit 50100 "Transfer Request Good"
|
||||
{
|
||||
// Self-contained demonstration of the best practice. Not derived from base-app source.
|
||||
procedure RequestFromCompany(TargetCompany: Text[30]; ItemNo: Code[20]; Quantity: Decimal)
|
||||
var
|
||||
TransferRequest: Record "Transfer Request Good";
|
||||
SessionId: Integer;
|
||||
begin
|
||||
TransferRequest.Init();
|
||||
TransferRequest."Item No." := ItemNo;
|
||||
TransferRequest.Quantity := Quantity;
|
||||
// The insert runs inside TargetCompany, so OnInsert and the subscriber read that company's setup.
|
||||
StartSession(SessionId, Codeunit::"Transfer Request Create Good", TargetCompany, TransferRequest);
|
||||
end;
|
||||
}
|
||||
|
||||
codeunit 50102 "Transfer Request Create Good"
|
||||
{
|
||||
TableNo = "Transfer Request Good";
|
||||
|
||||
trigger OnRun()
|
||||
begin
|
||||
// "Entry No." is AutoIncrement, so concurrent background sessions in TargetCompany never race on the same value.
|
||||
Rec.Insert(true);
|
||||
end;
|
||||
}
|
||||
|
||||
table 50100 "Transfer Request Good"
|
||||
{
|
||||
DataClassification = CustomerContent;
|
||||
|
||||
fields
|
||||
{
|
||||
field(1; "Entry No."; Integer) { AutoIncrement = true; }
|
||||
field(2; "Item No."; Code[20]) { }
|
||||
field(3; Quantity; Decimal) { }
|
||||
field(4; "Location Code"; Code[10]) { }
|
||||
}
|
||||
|
||||
keys
|
||||
{
|
||||
key(PK; "Entry No.") { Clustered = true; }
|
||||
}
|
||||
|
||||
trigger OnInsert()
|
||||
var
|
||||
TransferSetup: Record "Transfer Setup Good";
|
||||
begin
|
||||
TransferSetup.Get();
|
||||
"Location Code" := TransferSetup."Default Location Code";
|
||||
end;
|
||||
}
|
||||
|
||||
table 50101 "Transfer Setup Good"
|
||||
{
|
||||
DataClassification = CustomerContent;
|
||||
|
||||
fields
|
||||
{
|
||||
field(1; "Primary Key"; Code[10]) { }
|
||||
field(2; "Default Location Code"; Code[10]) { }
|
||||
field(3; "Open Requests"; Integer) { }
|
||||
}
|
||||
|
||||
keys
|
||||
{
|
||||
key(PK; "Primary Key") { Clustered = true; }
|
||||
}
|
||||
}
|
||||
|
||||
codeunit 50101 "Transfer Request Count Good"
|
||||
{
|
||||
[EventSubscriber(ObjectType::Table, Database::"Transfer Request Good", OnAfterInsertEvent, '', false, false)]
|
||||
local procedure CountOpenRequest(var Rec: Record "Transfer Request Good"; RunTrigger: Boolean)
|
||||
var
|
||||
TransferSetup: Record "Transfer Setup Good";
|
||||
begin
|
||||
// Serializes the read-modify-write so concurrent background sessions don't lose an increment.
|
||||
TransferSetup.LockTable();
|
||||
TransferSetup.Get();
|
||||
TransferSetup."Open Requests" += 1;
|
||||
TransferSetup.Modify();
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: events
|
||||
keywords: [changecompany, cross-company, runtrigger, trigger-event, subscriber, onafterinsertevent, insert, startsession, multi-company]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# ChangeCompany leaves triggers and trigger-event subscribers running in the calling company
|
||||
|
||||
> Contributions welcome — open a PR to refine or extend this article.
|
||||
|
||||
## Description
|
||||
|
||||
`ChangeCompany` redirects the data access of one record variable to another company's table. Execution context does not move with it: Microsoft Learn states that triggers still run in the current company, not in the company passed to `ChangeCompany`. Code that knows this usually reaches for `Insert(false)` and copies the trigger's work by hand from the target company's setup. That closes only half of the gap. The runtime raises the database trigger events (`OnBeforeInsertEvent`, `OnAfterInsertEvent`, and their modify, delete, and rename counterparts) on every database operation and only passes the `RunTrigger` flag to the subscriber, so every subscriber that does not exit on `RunTrigger = false` still runs, in the calling company, against the calling company's setup, number series, and companion tables. The row lands in the target company, the side effects land in the caller, and nothing reports an error. The per-row cost of the call is a separate concern, see `changecompany-in-loop-drops-caches`.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Use `ChangeCompany` to read. Access rights in the target company are still enforced, so reads are safe. When the goal is business data in another company, run the code in that company: `StartSession` takes a company name and runs a codeunit there, so triggers, validation, and subscribers all execute with the target company as their context. `StartSession` is a background session, not a synchronous call: the `Ok` return value reports only whether the session started, not whether the codeunit's work inside it succeeded, the caller's transaction does not extend into it, and an error raised there does not come back to the caller — it has to be logged or telemetered from inside that session. Reach for `StartSession` only for work the caller does not need to confirm before it continues; a write whose success the caller must know synchronously needs a durable status or error channel (a field the caller polls, a job queue with retry) rather than a bare `StartSession` call. Learn notes that a background session costs as much as a user session to start, so batch the work rather than starting one session per row, or let the target company process a hand-off row on its own schedule. A direct cross-company write is acceptable only as such a hand-off into a table the writing extension owns, whose triggers do not read company data and whose trigger-event subscribers exit when `RunTrigger` is false, using `Insert(false)`, `Modify(false)`, or `Delete(false)`, and never `Validate`.
|
||||
|
||||
See sample: [`changecompany-runs-triggers-in-the-calling-company.good.al`](changecompany-runs-triggers-in-the-calling-company.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
An `Insert`, `Modify`, `Delete`, or `Validate` on a record variable after `ChangeCompany(<name>)`, on a table whose triggers or trigger-event subscribers read setup, consume a number series, or write companion rows. With `RunTrigger = true` the trigger code fills the row from the caller's setup. With `RunTrigger = false` the trigger code is skipped, but the subscribers still fire in the caller, so a counter, log, or companion row maintained by a subscriber is written in the wrong company, and a caller that also updates the target by hand counts twice.
|
||||
|
||||
Detection signal: a record variable that has had `ChangeCompany` called on it with a company name and is later used with `Insert`, `Modify`, `Delete`, or `Validate`, where the table is not owned by the extension, or has triggers that read company data, or has trigger-event subscribers that do not exit on `RunTrigger = false`. Do not flag reads after `ChangeCompany`; writes with `RunTrigger = false` into an owned table whose triggers do not read company data and whose subscribers exit on `RunTrigger = false`; or `ChangeCompany()` without an argument, which points the variable back at the current company.
|
||||
|
||||
See sample: [`changecompany-runs-triggers-in-the-calling-company.bad.al`](changecompany-runs-triggers-in-the-calling-company.bad.al).
|
||||
|
||||
## See also
|
||||
|
||||
- Record.ChangeCompany method, Remarks — https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/record/record-changecompany-method
|
||||
- Record.Insert(Boolean) method, RunTrigger — https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/record/record-insert-boolean-method
|
||||
- Record.Delete method, RunTrigger defaults to false — https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/record/record-delete-method
|
||||
- OnInsert (Table) trigger, Remarks — https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/triggers-auto/table/devenv-oninsert-table-trigger
|
||||
- Event types, Database trigger events and order of event execution — https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-event-types
|
||||
- OnAfterInsertEvent trigger event, RunTrigger parameter — https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/triggers-auto/events/table/devenv-onafterinsertevent-table-trigger
|
||||
- Session.StartSession method, Company parameter, Remarks (background session, no UI), and Return Value (`Ok` reports whether the session started, not whether the codeunit's work succeeded) — https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/session/session-startsession-integer-integer-string-table-method
|
||||
- AL error handling, error handling strategies: an error inside a rolled-back transaction is logged from a background session or telemetry, it does not return to the caller — https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-al-error-handling
|
||||
- AutoIncrement property, Remarks: "if several transactions are performed at the same time, they will each be assigned a different number" — https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/properties/devenv-autoincrement-property
|
||||
|
|
@ -17,13 +17,13 @@ From runtime 14.0, AL can type-test an interface or `Variant` with `is` and cast
|
|||
|
||||
Use `is` to establish that the value supports the target interface before using `as`. Cast directly only where the target implementation is an invariant guaranteed by the surrounding contract.
|
||||
|
||||
See sample: `guard-interface-casts-with-is.good.al`.
|
||||
See sample: [`guard-interface-casts-with-is.good.al`](guard-interface-casts-with-is.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Using `as` unconditionally for an optional extended interface. An otherwise valid implementation of the base interface then fails at runtime merely because it does not implement the additional contract.
|
||||
|
||||
See sample: `guard-interface-casts-with-is.bad.al`.
|
||||
See sample: [`guard-interface-casts-with-is.bad.al`](guard-interface-casts-with-is.bad.al).
|
||||
|
||||
## Reference
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ The first database write opens an AL write transaction that the runtime holds un
|
|||
|
||||
## Best Practice
|
||||
|
||||
Defer the HTTP call to a separate session. When the external operation must correspond to a committed database change, insert an outbox work item in the same transaction as that change and process committed outbox rows with a recurring job queue entry. The change and work item then commit or roll back together, and the worker performs HTTP before deleting the item so it holds no write lock during the call. Make the external operation idempotent because a failure after a successful HTTP response can cause the work item to be retried.
|
||||
Defer the HTTP call to a separate session. When the external operation must correspond to a committed database change, insert an outbox work item in the same transaction as that change and process committed outbox rows with a recurring job queue entry. The change and work item then commit or roll back together, and the worker performs HTTP before deleting the item so it holds no write lock during the call. The separate retry-safety requirement is covered by `job-queue-external-effects-must-be-idempotent.md`.
|
||||
|
||||
A directly created scheduled task is suitable only when its work is independent of the caller's commit. An immediately ready task can run concurrently with the caller, so it must not assume that the caller's writes are already committed. Do **not** use `Commit()` as a general remedy: it irrevocably commits all prior writes in the current transaction, so any subsequent failure cannot roll them back. `Commit()` is appropriate only at top-level entry points where partial persistence is intentional and understood.
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
codeunit 50113 "Job Queue Category Bad"
|
||||
{
|
||||
procedure ConfigureJobsForSharedExclusiveResource(var SalesPostingJob: Record "Job Queue Entry"; var PurchasePostingJob: Record "Job Queue Entry"; ExclusiveResourceId: Text[250])
|
||||
begin
|
||||
// Both jobs update the same posting resources, but nothing prevents overlap.
|
||||
SalesPostingJob.Validate("Parameter String", ExclusiveResourceId);
|
||||
PurchasePostingJob.Validate("Parameter String", ExclusiveResourceId);
|
||||
SalesPostingJob.Validate("Job Queue Category Code", '');
|
||||
PurchasePostingJob.Validate("Job Queue Category Code", '');
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
codeunit 50113 "Job Queue Category Good"
|
||||
{
|
||||
procedure ConfigureJobsForSharedExclusiveResource(var SalesPostingJob: Record "Job Queue Entry"; var PurchasePostingJob: Record "Job Queue Entry"; ExclusiveResourceId: Text[250])
|
||||
var
|
||||
JobQueueCategory: Record "Job Queue Category";
|
||||
begin
|
||||
if not JobQueueCategory.Get('POSTING') then begin
|
||||
JobQueueCategory.Code := 'POSTING';
|
||||
JobQueueCategory.Insert();
|
||||
end;
|
||||
|
||||
// The shared category lets only one conflicting posting job run at a time.
|
||||
SalesPostingJob.Validate("Parameter String", ExclusiveResourceId);
|
||||
PurchasePostingJob.Validate("Parameter String", ExclusiveResourceId);
|
||||
SalesPostingJob.Validate("Job Queue Category Code", 'POSTING');
|
||||
PurchasePostingJob.Validate("Job Queue Category Code", 'POSTING');
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: performance
|
||||
keywords: [job-queue, category-code, concurrency, waiting, serialization, locking]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Use a job queue category to serialize conflicting jobs
|
||||
|
||||
> Contributions welcome — open a PR to refine or extend this article.
|
||||
|
||||
## Description
|
||||
|
||||
Different job queue entries can run at the same time. When two jobs update the same exclusive resource, concurrent execution can cause lock contention, deadlocks, or conflicting results. Within one company, entries with the same Job Queue Category Code are serialized: while one runs, another entry in that category waits.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Assign the same non-empty Job Queue Category Code to job queue entries in the same company that must not overlap, regardless of which codeunit they run. Define categories around the shared resource or exclusivity requirement, not merely around object names. Leave independent jobs in different categories so they can still run concurrently. A category does not serialize work across companies or environments, or coordinate workers outside the job queue dispatcher. Protect shared external or cross-company resources with a separate application-level locking mechanism.
|
||||
|
||||
See sample: [`job-queue-category-code-serializes-conflicting-jobs.good.al`](job-queue-category-code-serializes-conflicting-jobs.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Creating or configuring multiple job queue entries that update the same exclusive resource while leaving their Job Queue Category Code empty or different. Do not flag jobs merely because they touch the same tables; the rule applies when their operation requires mutual exclusion.
|
||||
|
||||
See sample: [`job-queue-category-code-serializes-conflicting-jobs.bad.al`](job-queue-category-code-serializes-conflicting-jobs.bad.al).
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
table 50112 "Queued Export Bad"
|
||||
{
|
||||
DataClassification = CustomerContent;
|
||||
|
||||
fields
|
||||
{
|
||||
field(1; "Entry No."; Integer)
|
||||
{
|
||||
AutoIncrement = true;
|
||||
}
|
||||
field(2; Payload; Text[250])
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
keys
|
||||
{
|
||||
key(PK; "Entry No.")
|
||||
{
|
||||
Clustered = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
codeunit 50112 "Queued Export Worker Bad"
|
||||
{
|
||||
TableNo = "Job Queue Entry";
|
||||
|
||||
trigger OnRun()
|
||||
var
|
||||
QueuedExport: Record "Queued Export Bad";
|
||||
Client: HttpClient;
|
||||
Content: HttpContent;
|
||||
Response: HttpResponseMessage;
|
||||
begin
|
||||
if not QueuedExport.FindFirst() then
|
||||
exit;
|
||||
|
||||
Content.WriteFrom(QueuedExport.Payload);
|
||||
Client.Post('https://example.local/exports', Content, Response);
|
||||
if not Response.IsSuccessStatusCode() then
|
||||
Error('Export failed with HTTP status %1.', Response.HttpStatusCode());
|
||||
|
||||
// If this local step fails, the external export exists but this row is retried.
|
||||
UpdateLocalStatus();
|
||||
FinalizeExport(QueuedExport);
|
||||
end;
|
||||
|
||||
local procedure UpdateLocalStatus()
|
||||
begin
|
||||
end;
|
||||
|
||||
local procedure FinalizeExport(var QueuedExport: Record "Queued Export Bad")
|
||||
begin
|
||||
QueuedExport.Delete();
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
table 50112 "Queued Export Good"
|
||||
{
|
||||
DataClassification = CustomerContent;
|
||||
|
||||
fields
|
||||
{
|
||||
field(1; "Entry No."; Integer)
|
||||
{
|
||||
AutoIncrement = true;
|
||||
}
|
||||
field(2; Payload; Text[250])
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
keys
|
||||
{
|
||||
key(PK; "Entry No.")
|
||||
{
|
||||
Clustered = true;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
codeunit 50112 "Queued Export Worker Good"
|
||||
{
|
||||
TableNo = "Job Queue Entry";
|
||||
|
||||
trigger OnRun()
|
||||
var
|
||||
QueuedExport: Record "Queued Export Good";
|
||||
Client: HttpClient;
|
||||
Content: HttpContent;
|
||||
ContentHeaders: HttpHeaders;
|
||||
JsonPayload: JsonObject;
|
||||
RequestBody: Text;
|
||||
Response: HttpResponseMessage;
|
||||
begin
|
||||
if not QueuedExport.FindFirst() then
|
||||
exit;
|
||||
|
||||
JsonPayload.Add('idempotencyKey', Format(QueuedExport.SystemId));
|
||||
JsonPayload.Add('payload', QueuedExport.Payload);
|
||||
JsonPayload.WriteTo(RequestBody);
|
||||
|
||||
Content.WriteFrom(RequestBody);
|
||||
Content.GetHeaders(ContentHeaders);
|
||||
ContentHeaders.Clear();
|
||||
ContentHeaders.Add('Content-Type', 'application/json');
|
||||
Client.Post('https://example.local/exports', Content, Response);
|
||||
if not Response.IsSuccessStatusCode() then
|
||||
Error('Export failed with HTTP status %1.', Response.HttpStatusCode());
|
||||
|
||||
// The external service must atomically create a record only when idempotencyKey
|
||||
// does not exist. When the key already exists, it must return the existing record
|
||||
// without repeating the side effect.
|
||||
UpdateLocalStatus();
|
||||
QueuedExport.Delete();
|
||||
end;
|
||||
|
||||
local procedure UpdateLocalStatus()
|
||||
begin
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: performance
|
||||
keywords: [job-queue, idempotency, retry, outbox, httpclient, external-effect]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Job queue external effects must be idempotent
|
||||
|
||||
> Contributions welcome — open a PR to refine or extend this article.
|
||||
|
||||
## Description
|
||||
|
||||
A job queue handler can successfully create something in an external system and then fail while updating Business Central. Business Central rolls back its database changes, but it cannot roll back the external request. The same work can later run again through configured retries, recurrence, rescheduling, or manual restart. Without a way for the external system to recognize the repeated request, a later run can create a duplicate shipment, payment, notification, or other side effect.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Use a stable request ID that exists before the job queue processes the outbox row. For example, include the outbox record's `SystemId` as an `idempotencyKey` value in the JSON body of every POST attempt. The external service must enforce uniqueness on that value: when it receives the key again, it returns the existing record instead of creating another one. Delete the outbox row only after the external call and all required local updates succeed.
|
||||
|
||||
A `Processed` flag set after the external call does not solve this failure window. If a later AL error rolls back that flag, the outbox row again looks unprocessed even though the external operation already happened.
|
||||
|
||||
See sample: [`job-queue-external-effects-must-be-idempotent.good.al`](job-queue-external-effects-must-be-idempotent.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Sending a state-changing request from a job queue handler with no stable request ID understood by the external API. Specifically, look for this sequence: read an outbox row, call `HttpClient.Post` or another side-effecting API, update or delete local data, and propagate an error after which the same outbox row can be processed again. The key may be part of the request body, URI, headers, or an existing business key; a naturally idempotent remote operation is already safe and should not be flagged.
|
||||
|
||||
See sample: [`job-queue-external-effects-must-be-idempotent.bad.al`](job-queue-external-effects-must-be-idempotent.bad.al).
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
codeunit 50110 "Job Queue UI Bad"
|
||||
{
|
||||
TableNo = "Job Queue Entry";
|
||||
|
||||
trigger OnRun()
|
||||
begin
|
||||
if not Confirm('Process the queued export now?') then
|
||||
exit;
|
||||
|
||||
ProcessExport(Rec."Parameter String");
|
||||
Message('The queued export completed.');
|
||||
end;
|
||||
|
||||
local procedure ProcessExport(ParameterString: Text)
|
||||
begin
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
codeunit 50110 "Job Queue UI Good"
|
||||
{
|
||||
TableNo = "Job Queue Entry";
|
||||
|
||||
trigger OnRun()
|
||||
begin
|
||||
Rec.TestField("Parameter String");
|
||||
ProcessExport(Rec."Parameter String");
|
||||
end;
|
||||
|
||||
local procedure ProcessExport(ParameterString: Text)
|
||||
begin
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: performance
|
||||
keywords: [job-queue, background-session, guiallowed, confirm, runmodal, client-callback]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Job queue handlers must not require user interaction
|
||||
|
||||
> Contributions welcome — open a PR to refine or extend this article.
|
||||
|
||||
## Description
|
||||
|
||||
A job queue handler runs in a background session with no client UI. Calls that require a client callback, such as `Confirm`, `Page.RunModal`, `Report.RunModal`, upload, or download, can stop the job with a non-retriable callback error. `Message` is suppressed and logged by the server, so it cannot communicate a result to the user who scheduled the job.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Make a dedicated job queue entry point non-interactive. Validate parameters and data in AL, persist business-visible status when needed, and let failures propagate to the job queue log. If one procedure genuinely serves both foreground and background callers, isolate optional UI-only behavior behind `GuiAllowed`; do not use the guard to silently skip a decision that the operation requires.
|
||||
|
||||
See sample: [`job-queue-handlers-must-not-require-ui.good.al`](job-queue-handlers-must-not-require-ui.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Calling `Confirm`, `Page.Run`, `Page.RunModal`, `Report.Run`, `Report.RunModal`, `Hyperlink`, `File.Upload`, or `File.Download` from a codeunit run by the job queue. Another signal is using `Message` as the only success or failure notification: no user is attached to receive it.
|
||||
|
||||
See sample: [`job-queue-handlers-must-not-require-ui.bad.al`](job-queue-handlers-must-not-require-ui.bad.al).
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
codeunit 50111 "Job Queue Failure Bad"
|
||||
{
|
||||
TableNo = "Job Queue Entry";
|
||||
|
||||
trigger OnRun()
|
||||
begin
|
||||
if not TryProcessCustomer(Rec."Parameter String") then
|
||||
exit;
|
||||
end;
|
||||
|
||||
[TryFunction]
|
||||
local procedure TryProcessCustomer(CustomerNo: Code[20])
|
||||
var
|
||||
Customer: Record Customer;
|
||||
begin
|
||||
Customer.Get(CustomerNo);
|
||||
ProcessCustomer(Customer);
|
||||
end;
|
||||
|
||||
local procedure ProcessCustomer(Customer: Record Customer)
|
||||
begin
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
codeunit 50111 "Job Queue Failure Good"
|
||||
{
|
||||
TableNo = "Job Queue Entry";
|
||||
|
||||
trigger OnRun()
|
||||
var
|
||||
Customer: Record Customer;
|
||||
begin
|
||||
Customer.Get(Rec."Parameter String");
|
||||
ProcessCustomer(Customer);
|
||||
end;
|
||||
|
||||
local procedure ProcessCustomer(Customer: Record Customer)
|
||||
begin
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: performance
|
||||
keywords: [job-queue, error-propagation, tryfunction, retry, dispatcher, job-queue-log]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Job queue handlers must propagate execution failures
|
||||
|
||||
> Contributions welcome — open a PR to refine or extend this article.
|
||||
|
||||
## Description
|
||||
|
||||
The job queue dispatcher can mark an entry as failed, record the error, and apply its configured retry behavior only when the handler terminates with an error. A handler that catches a failed `TryFunction` or Boolean-returning operation and then returns normally reports success to the dispatcher, even though its work did not complete.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Let an error that invalidates the whole run propagate out of the job queue entry point. Add context only when it helps an operator diagnose the failure and does not expose sensitive data. Per-item failures may be collected deliberately, but the batch must persist or emit an observable aggregate outcome instead of silently treating incomplete work as success.
|
||||
|
||||
See sample: [`job-queue-handlers-must-propagate-failures.good.al`](job-queue-handlers-must-propagate-failures.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Calling a `TryFunction`, `Codeunit.Run`, or another Boolean-returning operation from a job queue handler and using `exit` or normal fall-through on failure without recording an intentional partial-success outcome. The dispatcher sees a successful return, so the entry's status and log do not represent the failed work and configured retries are not applied.
|
||||
|
||||
See sample: [`job-queue-handlers-must-propagate-failures.bad.al`](job-queue-handlers-must-propagate-failures.bad.al).
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
codeunit 50114 "Job Queue On Hold Bad"
|
||||
{
|
||||
TableNo = "Job Queue Entry";
|
||||
|
||||
trigger OnRun()
|
||||
begin
|
||||
repeat
|
||||
if not ProcessNextBatch() then
|
||||
exit;
|
||||
Rec.Get(Rec.ID);
|
||||
until Rec.Status = Rec.Status::"On Hold";
|
||||
end;
|
||||
|
||||
local procedure ProcessNextBatch(): Boolean
|
||||
begin
|
||||
exit(false);
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
table 50114 "Job Cancellation Control"
|
||||
{
|
||||
DataClassification = SystemMetadata;
|
||||
|
||||
fields
|
||||
{
|
||||
field(1; "Job Queue Entry ID"; Guid)
|
||||
{
|
||||
}
|
||||
field(2; "Stop Requested"; Boolean)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
keys
|
||||
{
|
||||
key(PK; "Job Queue Entry ID")
|
||||
{
|
||||
Clustered = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
codeunit 50114 "Job Queue On Hold Good"
|
||||
{
|
||||
TableNo = "Job Queue Entry";
|
||||
|
||||
trigger OnRun()
|
||||
begin
|
||||
while not IsStopRequested(Rec.ID) do
|
||||
if not ProcessNextBatch() then
|
||||
exit;
|
||||
end;
|
||||
|
||||
local procedure IsStopRequested(JobQueueEntryId: Guid): Boolean
|
||||
var
|
||||
JobCancellationControl: Record "Job Cancellation Control";
|
||||
begin
|
||||
if not JobCancellationControl.Get(JobQueueEntryId) then
|
||||
exit(false);
|
||||
|
||||
exit(JobCancellationControl."Stop Requested");
|
||||
end;
|
||||
|
||||
local procedure ProcessNextBatch(): Boolean
|
||||
begin
|
||||
exit(false);
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: performance
|
||||
keywords: [job-queue, on-hold, cancellation, in-process, long-running, stop-request]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Putting a job queue entry on hold does not stop its current run
|
||||
|
||||
> Contributions welcome — open a PR to refine or extend this article.
|
||||
|
||||
## Description
|
||||
|
||||
The On Hold status prevents a job queue entry from starting again, but it does not cancel a run that is already in process. A long-running handler continues until it completes, fails, reaches a cancellation point implemented by the application, or its session is stopped externally.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Use On Hold to pause future scheduling. When a long-running operation must support graceful cancellation, store a separate application-owned stop request and check it before every bounded unit of work, including the first. Exit only at a point where completed work and the checkpoint are consistent. The code that resumes scheduling must clear the stop request before restarting the job. Use administrative session termination only when graceful cancellation is impossible.
|
||||
|
||||
See sample: [`job-queue-on-hold-does-not-stop-running-work.good.al`](job-queue-on-hold-does-not-stop-running-work.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Polling the job queue entry's Status field from inside its handler and expecting a change to On Hold to cancel the active run. The status controls scheduling, not cooperative cancellation, so the handler can continue processing despite the operator's action.
|
||||
|
||||
See sample: [`job-queue-on-hold-does-not-stop-running-work.bad.al`](job-queue-on-hold-does-not-stop-running-work.bad.al).
|
||||
|
|
@ -17,7 +17,7 @@ application-area: [all]
|
|||
|
||||
## Best Practice
|
||||
|
||||
Keep company-open subscribers to cheap in-memory work: set a flag, enqueue a job-queue entry, or `TaskScheduler.CreateTask`. Perform HTTP and large SQL after the session is running, in that background work.
|
||||
Keep company-open subscribers to cheap in-memory work: set a flag, enqueue a job-queue entry, or `TaskScheduler.CreateTask`. Perform HTTP and large SQL after the session is running, in that background work. When the subscriber can run repeatedly, use `store-scheduled-task-id-to-avoid-duplicate-tasks.md` to avoid creating the same logical task more than once.
|
||||
|
||||
See sample: [`oncompanyopen-subscribers-must-not-do-io.good.al`](oncompanyopen-subscribers-must-not-do-io.good.al).
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
codeunit 50115 "Scheduled Task Duplicate Bad"
|
||||
{
|
||||
procedure EnsureCleanupTask()
|
||||
begin
|
||||
// Every call creates another task for the same cleanup work.
|
||||
TaskScheduler.CreateTask(Codeunit::"Scheduled Cleanup Work Bad", 0, true, CompanyName());
|
||||
end;
|
||||
}
|
||||
|
||||
codeunit 50116 "Scheduled Cleanup Work Bad"
|
||||
{
|
||||
trigger OnRun()
|
||||
begin
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
codeunit 50115 "Scheduled Task Duplicate Good"
|
||||
{
|
||||
internal procedure EnsureCleanupTask()
|
||||
var
|
||||
TaskId: Guid;
|
||||
StoredTaskId: Text;
|
||||
begin
|
||||
if IsolatedStorage.Get('CleanupTaskId', DataScope::Company, StoredTaskId) then
|
||||
if Evaluate(TaskId, StoredTaskId) then
|
||||
if TaskScheduler.TaskExists(TaskId) then
|
||||
exit;
|
||||
|
||||
TaskId := TaskScheduler.CreateTask(Codeunit::"Scheduled Cleanup Work Good", 0, true, CompanyName());
|
||||
IsolatedStorage.Set('CleanupTaskId', Format(TaskId), DataScope::Company);
|
||||
end;
|
||||
}
|
||||
|
||||
codeunit 50116 "Scheduled Cleanup Work Good"
|
||||
{
|
||||
trigger OnRun()
|
||||
begin
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: performance
|
||||
keywords: [task-scheduler, scheduled-task, taskexists, duplicate-task, createtask, guid]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Store the scheduled task ID to avoid duplicate tasks
|
||||
|
||||
> Contributions welcome — open a PR to refine or extend this article.
|
||||
|
||||
## Description
|
||||
|
||||
Every call to `TaskScheduler.CreateTask` creates a new scheduled task and returns its unique GUID. Repeating setup or lifecycle code without retaining that GUID can create multiple tasks for the same logical work, consuming scheduler capacity and running the work more than once.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Persist the GUID returned by `CreateTask` at the same scope as the logical task. Before creating a replacement, parse the stored GUID and call `TaskScheduler.TaskExists`; create and store a new task only when the previous task no longer exists. `TaskExists` checks one GUID, not whether an equivalent codeunit is already scheduled, so callers that can schedule concurrently still need serialization around this check-and-create sequence.
|
||||
|
||||
See sample: [`store-scheduled-task-id-to-avoid-duplicate-tasks.good.al`](store-scheduled-task-id-to-avoid-duplicate-tasks.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Calling `TaskScheduler.CreateTask` every time initialization, login, setup, or another repeatable path runs while ignoring its return value. Each invocation creates another independent task even when an equivalent task is already pending.
|
||||
|
||||
See sample: [`store-scheduled-task-id-to-avoid-duplicate-tasks.bad.al`](store-scheduled-task-id-to-avoid-duplicate-tasks.bad.al).
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
query 50428 "Static Query Filter Bad"
|
||||
{
|
||||
QueryType = Normal;
|
||||
|
||||
elements
|
||||
{
|
||||
dataitem(SalesHeader; "Sales Header")
|
||||
{
|
||||
DataItemTableFilter = Status = const(Open);
|
||||
|
||||
column(DocumentNo; "No.")
|
||||
{
|
||||
}
|
||||
filter(StatusFilter; Status)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
codeunit 50429 "Static Query Filter Bad"
|
||||
{
|
||||
procedure ReadReleasedOrders()
|
||||
var
|
||||
SalesHeader: Record "Sales Header";
|
||||
SalesHeaderQuery: Query "Static Query Filter Bad";
|
||||
begin
|
||||
// This is combined with Status = Open and returns no rows.
|
||||
SalesHeaderQuery.SetRange(StatusFilter, SalesHeader.Status::Released);
|
||||
SalesHeaderQuery.Open();
|
||||
while SalesHeaderQuery.Read() do
|
||||
ProcessOrder(SalesHeaderQuery.DocumentNo);
|
||||
SalesHeaderQuery.Close();
|
||||
end;
|
||||
|
||||
local procedure ProcessOrder(DocumentNo: Code[20])
|
||||
begin
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
query 50430 "Static Query Filter Good"
|
||||
{
|
||||
QueryType = Normal;
|
||||
|
||||
elements
|
||||
{
|
||||
dataitem(SalesHeader; "Sales Header")
|
||||
{
|
||||
DataItemTableFilter = "Document Type" = const(Order);
|
||||
|
||||
column(DocumentNo; "No.")
|
||||
{
|
||||
}
|
||||
filter(StatusFilter; Status)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
codeunit 50431 "Static Query Filter Good"
|
||||
{
|
||||
procedure ReadReleasedOrders()
|
||||
var
|
||||
SalesHeader: Record "Sales Header";
|
||||
SalesHeaderQuery: Query "Static Query Filter Good";
|
||||
begin
|
||||
SalesHeaderQuery.SetRange(StatusFilter, SalesHeader.Status::Released);
|
||||
SalesHeaderQuery.Open();
|
||||
while SalesHeaderQuery.Read() do
|
||||
ProcessOrder(SalesHeaderQuery.DocumentNo);
|
||||
SalesHeaderQuery.Close();
|
||||
end;
|
||||
|
||||
local procedure ProcessOrder(DocumentNo: Code[20])
|
||||
begin
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: query
|
||||
keywords: [query, dataitemtablefilter, setfilter, setrange, static-filter, filter-precedence]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# DataItemTableFilter cannot be overwritten at runtime
|
||||
|
||||
## Description
|
||||
|
||||
`DataItemTableFilter` defines a static filter on a Query dataitem. A runtime `SetFilter` or `SetRange` on the same source field does not replace that filter. The static and runtime filters are combined with AND, so contradictory values produce an empty dataset instead of broadening or replacing the query definition.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Keep only invariant restrictions in `DataItemTableFilter`. Expose caller-selectable fields through a column or filter row and apply their values with `SetFilter` or `SetRange` before `Open()`. When both filter types intentionally target the same field, ensure their intersection represents the required dataset.
|
||||
|
||||
See sample: [`dataitemtablefilter-cannot-be-overwritten-at-runtime.good.al`](dataitemtablefilter-cannot-be-overwritten-at-runtime.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Define a static filter in `DataItemTableFilter`, then apply a contradictory runtime filter to the same source field while expecting the runtime filter to replace the static one. Both filters remain effective and the query returns no rows.
|
||||
|
||||
See sample: [`dataitemtablefilter-cannot-be-overwritten-at-runtime.bad.al`](dataitemtablefilter-cannot-be-overwritten-at-runtime.bad.al).
|
||||
|
||||
## References
|
||||
|
||||
Filtering in Query objects — https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-query-filters
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
query 50432 "Column Query Filter Bad"
|
||||
{
|
||||
QueryType = Normal;
|
||||
|
||||
elements
|
||||
{
|
||||
dataitem(SalesLine; "Sales Line")
|
||||
{
|
||||
column(DocumentNo; "Document No.")
|
||||
{
|
||||
}
|
||||
column(LineQuantity; Quantity)
|
||||
{
|
||||
ColumnFilter = LineQuantity = filter(> 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
codeunit 50433 "Column Query Filter Bad"
|
||||
{
|
||||
procedure ReadSmallPositiveLines()
|
||||
var
|
||||
SalesLineQuery: Query "Column Query Filter Bad";
|
||||
begin
|
||||
// This replaces > 0, so negative quantities are also returned.
|
||||
SalesLineQuery.SetFilter(LineQuantity, '<100');
|
||||
SalesLineQuery.Open();
|
||||
while SalesLineQuery.Read() do
|
||||
ProcessLine(SalesLineQuery.DocumentNo, SalesLineQuery.LineQuantity);
|
||||
SalesLineQuery.Close();
|
||||
end;
|
||||
|
||||
local procedure ProcessLine(DocumentNo: Code[20]; Quantity: Decimal)
|
||||
begin
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
query 50434 "Column Query Filter Good"
|
||||
{
|
||||
QueryType = Normal;
|
||||
|
||||
elements
|
||||
{
|
||||
dataitem(SalesLine; "Sales Line")
|
||||
{
|
||||
DataItemTableFilter = Quantity = filter(> 0);
|
||||
|
||||
column(DocumentNo; "Document No.")
|
||||
{
|
||||
}
|
||||
column(LineQuantity; Quantity)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
codeunit 50435 "Column Query Filter Good"
|
||||
{
|
||||
procedure ReadSmallPositiveLines()
|
||||
var
|
||||
SalesLineQuery: Query "Column Query Filter Good";
|
||||
begin
|
||||
// This combines with the invariant Quantity > 0 dataitem filter.
|
||||
SalesLineQuery.SetFilter(LineQuantity, '<100');
|
||||
SalesLineQuery.Open();
|
||||
while SalesLineQuery.Read() do
|
||||
ProcessLine(SalesLineQuery.DocumentNo, SalesLineQuery.LineQuantity);
|
||||
SalesLineQuery.Close();
|
||||
end;
|
||||
|
||||
local procedure ProcessLine(DocumentNo: Code[20]; Quantity: Decimal)
|
||||
begin
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: query
|
||||
keywords: [query, columnfilter, setfilter, setrange, filter-precedence, runtime-filter]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# SetFilter and SetRange overwrite Query ColumnFilter
|
||||
|
||||
## Description
|
||||
|
||||
`ColumnFilter` on a Query column or filter row defines a dynamic filter. A runtime `SetFilter` or `SetRange` on that same column or filter row replaces the `ColumnFilter`; it does not combine the two conditions. Rows excluded by the declarative filter can therefore reappear when the runtime filter omits that restriction.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Place invariant restrictions in `DataItemTableFilter`, which runtime filters cannot overwrite. When a `ColumnFilter` is intentionally replaceable, make each runtime `SetFilter` or `SetRange` express the complete required condition before `Open()`.
|
||||
|
||||
See sample: [`setfilter-overwrites-query-columnfilter.good.al`](setfilter-overwrites-query-columnfilter.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Apply `SetFilter` or `SetRange` to a column or filter row and rely on its existing `ColumnFilter` to remain effective. The runtime call replaces that filter and can admit rows that the query definition appeared to exclude.
|
||||
|
||||
See sample: [`setfilter-overwrites-query-columnfilter.bad.al`](setfilter-overwrites-query-columnfilter.bad.al).
|
||||
|
||||
## References
|
||||
|
||||
Filtering in Query objects — https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-query-filters
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
codeunit 50102 "Run Customer Reports"
|
||||
{
|
||||
procedure RunBlockedAndUnblockedCustomers()
|
||||
var
|
||||
Customer: Record Customer;
|
||||
CustomerList: Report "Customer - List";
|
||||
begin
|
||||
Customer.SetRange(Blocked, Customer.Blocked::All);
|
||||
CustomerList.SetTableView(Customer);
|
||||
CustomerList.RunModal();
|
||||
|
||||
Customer.SetRange(Blocked, Customer.Blocked::" ");
|
||||
CustomerList.SetTableView(Customer);
|
||||
CustomerList.RunModal();
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
codeunit 50102 "Run Customer Reports"
|
||||
{
|
||||
procedure RunBlockedAndUnblockedCustomers()
|
||||
var
|
||||
Customer: Record Customer;
|
||||
CustomerList: Report "Customer - List";
|
||||
begin
|
||||
Customer.SetRange(Blocked, Customer.Blocked::All);
|
||||
CustomerList.SetTableView(Customer);
|
||||
CustomerList.RunModal();
|
||||
|
||||
Clear(CustomerList);
|
||||
Customer.SetRange(Blocked, Customer.Blocked::" ");
|
||||
CustomerList.SetTableView(Customer);
|
||||
CustomerList.RunModal();
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: reporting
|
||||
keywords: [report, runmodal, clear, settableview, instance, state, filters]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Clear a Report variable before an independent RunModal execution
|
||||
|
||||
## Description
|
||||
|
||||
`Report.Run()` automatically clears the report variable after execution, but `Report.RunModal()` does not. Reconfiguring and running the same variable for an independent operation can therefore retain filters and other instance state from the previous run.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Call `Clear(ReportVariable)` before configuring a new, logically independent `RunModal()` execution on a reused report variable. No clear is required after a single execution, and retaining state is valid when the subsequent run intentionally continues with the same configuration.
|
||||
|
||||
See sample: [`clear-report-variable-before-independent-runmodal.good.al`](clear-report-variable-before-independent-runmodal.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Run the same report variable modally for two independent views without clearing it between runs. The second `SetTableView` can only narrow the existing report view, so filters retained by the instance can make the second result incomplete or empty.
|
||||
|
||||
See sample: [`clear-report-variable-before-independent-runmodal.bad.al`](clear-report-variable-before-independent-runmodal.bad.al).
|
||||
|
||||
## References
|
||||
|
||||
`Report.RunModal()` method — https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/methods-auto/report/reportinstance-runmodal-method
|
||||
|
||||
`Report.Run()` method — https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/methods-auto/report/reportinstance-run-method
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
report 50105 "Customer Entry Review"
|
||||
{
|
||||
ProcessingOnly = true;
|
||||
|
||||
dataset
|
||||
{
|
||||
dataitem(Customer; Customer)
|
||||
{
|
||||
trigger OnAfterGetRecord()
|
||||
var
|
||||
EntryNo: Integer;
|
||||
begin
|
||||
repeat
|
||||
EntryNo += 1;
|
||||
if EntryNo = 5 then
|
||||
CurrReport.Break();
|
||||
until EntryNo = 10;
|
||||
|
||||
MarkCustomerReviewed();
|
||||
end;
|
||||
}
|
||||
}
|
||||
|
||||
local procedure MarkCustomerReviewed()
|
||||
begin
|
||||
ReviewedCustomerCount += 1;
|
||||
end;
|
||||
|
||||
var
|
||||
ReviewedCustomerCount: Integer;
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
report 50105 "Customer Entry Review"
|
||||
{
|
||||
ProcessingOnly = true;
|
||||
|
||||
dataset
|
||||
{
|
||||
dataitem(Customer; Customer)
|
||||
{
|
||||
trigger OnAfterGetRecord()
|
||||
var
|
||||
EntryNo: Integer;
|
||||
StopReview: Boolean;
|
||||
begin
|
||||
repeat
|
||||
EntryNo += 1;
|
||||
StopReview := EntryNo = 5;
|
||||
until StopReview or (EntryNo = 10);
|
||||
|
||||
MarkCustomerReviewed();
|
||||
end;
|
||||
}
|
||||
}
|
||||
|
||||
local procedure MarkCustomerReviewed()
|
||||
begin
|
||||
ReviewedCustomerCount += 1;
|
||||
end;
|
||||
|
||||
var
|
||||
ReviewedCustomerCount: Integer;
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: reporting
|
||||
keywords: [report, currreport, break, loop, trigger, control-flow]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# CurrReport.Break ends the current trigger
|
||||
|
||||
## Description
|
||||
|
||||
`CurrReport.Break()` inside a report dataitem trigger does more than leave an AL loop. It terminates the current trigger and omits the current record from the dataset. The report runtime still invokes the remaining triggers for that record. Consequently, statements after the loop in the current trigger do not run, while later report triggers can still produce side effects.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Use an explicit loop condition or the AL `break` statement when only the loop must end and the current trigger must continue. Use `CurrReport.Break()` only when ending the trigger and omitting the current record are both intended, and keep subsequent report triggers safe for that omitted record.
|
||||
|
||||
See sample: [`currreport-break-ends-the-current-trigger.good.al`](currreport-break-ends-the-current-trigger.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Call `CurrReport.Break()` inside a loop and rely on statements after the loop to finish processing the current record. Those statements are unreachable when the call executes, the record is omitted, and remaining report triggers still run.
|
||||
|
||||
See sample: [`currreport-break-ends-the-current-trigger.bad.al`](currreport-break-ends-the-current-trigger.bad.al).
|
||||
|
||||
## References
|
||||
|
||||
`Report.Break()` method — https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/methods-auto/report/reportinstance-break-method
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
report 50101 "Update Customer Review"
|
||||
{
|
||||
ProcessingOnly = true;
|
||||
|
||||
dataset
|
||||
{
|
||||
dataitem(Customer; Customer)
|
||||
{
|
||||
trigger OnAfterGetRecord()
|
||||
begin
|
||||
"Last Date Modified" := Today();
|
||||
Modify();
|
||||
|
||||
if Blocked <> Blocked::" " then
|
||||
CurrReport.Quit();
|
||||
end;
|
||||
}
|
||||
}
|
||||
|
||||
trigger OnPostReport()
|
||||
begin
|
||||
Message(CompletedMsg);
|
||||
end;
|
||||
|
||||
var
|
||||
CompletedMsg: Label 'Customer review completed.';
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
report 50101 "Update Customer Review"
|
||||
{
|
||||
ProcessingOnly = true;
|
||||
|
||||
dataset
|
||||
{
|
||||
dataitem(Customer; Customer)
|
||||
{
|
||||
trigger OnAfterGetRecord()
|
||||
begin
|
||||
if Blocked <> Blocked::" " then
|
||||
Error(BlockedCustomerErr, "No.");
|
||||
|
||||
"Last Date Modified" := Today();
|
||||
Modify();
|
||||
end;
|
||||
}
|
||||
}
|
||||
|
||||
trigger OnPostReport()
|
||||
begin
|
||||
Message(CompletedMsg);
|
||||
end;
|
||||
|
||||
var
|
||||
BlockedCustomerErr: Label 'Customer %1 is blocked.', Comment = '%1 = customer number';
|
||||
CompletedMsg: Label 'Customer review completed.';
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: reporting
|
||||
keywords: [report, currreport, quit, rollback, onpostreport, transaction, control-flow]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# CurrReport.Quit rolls back report changes and skips OnPostReport
|
||||
|
||||
## Description
|
||||
|
||||
`CurrReport.Quit()` aborts the report without committing database changes made during its execution. It also prevents `OnPostReport` from running. It is therefore not a normal early-return mechanism for a processing report that expects earlier writes or finalization in `OnPostReport` to survive.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Use `CurrReport.Quit()` only when silently aborting the report, rolling back its database changes, and skipping `OnPostReport` are all intentional. When processing must stop with a failure, raise an error. When completed work and `OnPostReport` must be preserved, structure the dataitem control flow without `Quit()`.
|
||||
|
||||
See sample: [`currreport-quit-rolls-back-and-skips-onpostreport.good.al`](currreport-quit-rolls-back-and-skips-onpostreport.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Modify data and then call `CurrReport.Quit()` while relying on those writes or on `OnPostReport` finalization. The report exits without committing its changes and never invokes `OnPostReport`.
|
||||
|
||||
See sample: [`currreport-quit-rolls-back-and-skips-onpostreport.bad.al`](currreport-quit-rolls-back-and-skips-onpostreport.bad.al).
|
||||
|
||||
## References
|
||||
|
||||
`Report.Quit()` method — https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/methods-auto/report/reportinstance-quit-method
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
report 50100 "Released Customer List"
|
||||
{
|
||||
ProcessingOnly = true;
|
||||
|
||||
dataset
|
||||
{
|
||||
dataitem(Customer; Customer)
|
||||
{
|
||||
trigger OnAfterGetRecord()
|
||||
begin
|
||||
if Blocked <> Blocked::" " then
|
||||
CurrReport.Skip();
|
||||
|
||||
CountIncludedCustomer();
|
||||
end;
|
||||
}
|
||||
}
|
||||
|
||||
local procedure CountIncludedCustomer()
|
||||
begin
|
||||
IncludedCustomerCount += 1;
|
||||
end;
|
||||
|
||||
var
|
||||
IncludedCustomerCount: Integer;
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
report 50100 "Released Customer List"
|
||||
{
|
||||
ProcessingOnly = true;
|
||||
|
||||
dataset
|
||||
{
|
||||
dataitem(Customer; Customer)
|
||||
{
|
||||
trigger OnAfterGetRecord()
|
||||
begin
|
||||
if Blocked <> Blocked::" " then begin
|
||||
CurrReport.Skip();
|
||||
exit;
|
||||
end;
|
||||
|
||||
CountIncludedCustomer();
|
||||
end;
|
||||
}
|
||||
}
|
||||
|
||||
local procedure CountIncludedCustomer()
|
||||
begin
|
||||
IncludedCustomerCount += 1;
|
||||
end;
|
||||
|
||||
var
|
||||
IncludedCustomerCount: Integer;
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: reporting
|
||||
keywords: [report, currreport, skip, trigger, onaftergetrecord, control-flow]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# CurrReport.Skip omits the record but does not stop trigger code
|
||||
|
||||
## Description
|
||||
|
||||
`CurrReport.Skip()` omits the current record from the report dataset and continues processing with the next record. It does not terminate the current trigger, and the remaining triggers for the current record still run. Code placed after `Skip()` can therefore produce side effects for a record that never appears in the output.
|
||||
|
||||
## Best Practice
|
||||
|
||||
When no further code in the current trigger should run for a skipped record, call `CurrReport.Skip()` and then exit the trigger explicitly. Keep later record triggers safe for skipped records because the report runtime still invokes them.
|
||||
|
||||
See sample: [`currreport-skip-does-not-stop-trigger-code.good.al`](currreport-skip-does-not-stop-trigger-code.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Call `CurrReport.Skip()` and rely on it to bypass subsequent statements or later record triggers. The record is removed from the dataset, but those statements and triggers can still update state, write data, or perform expensive work.
|
||||
|
||||
See sample: [`currreport-skip-does-not-stop-trigger-code.bad.al`](currreport-skip-does-not-stop-trigger-code.bad.al).
|
||||
|
||||
## References
|
||||
|
||||
`Report.Skip()` method — https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/methods-auto/report/reportinstance-skip-method
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
codeunit 50106 "Download Customer Reports"
|
||||
{
|
||||
procedure DownloadReports(var Customer: Record Customer)
|
||||
var
|
||||
CustomerView: Record Customer;
|
||||
begin
|
||||
if Customer.FindSet() then
|
||||
repeat
|
||||
CustomerView := Customer;
|
||||
CustomerView.SetRecFilter();
|
||||
Report.Run(Report::"Customer - List", false, false, CustomerView);
|
||||
until Customer.Next() = 0;
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
codeunit 50106 "Download Customer Reports"
|
||||
{
|
||||
procedure DownloadReports(var Customer: Record Customer)
|
||||
var
|
||||
CustomerView: Record Customer;
|
||||
CustomerList: Report "Customer - List";
|
||||
DataCompression: Codeunit "Data Compression";
|
||||
ReportTempBlob: Codeunit "Temp Blob";
|
||||
ZipTempBlob: Codeunit "Temp Blob";
|
||||
ReportInStream: InStream;
|
||||
ZipInStream: InStream;
|
||||
ReportOutStream: OutStream;
|
||||
ZipOutStream: OutStream;
|
||||
ZipFileName: Text;
|
||||
begin
|
||||
DataCompression.CreateZipArchive();
|
||||
if Customer.FindSet() then
|
||||
repeat
|
||||
Clear(CustomerList);
|
||||
Clear(ReportTempBlob);
|
||||
CustomerView := Customer;
|
||||
CustomerView.SetRecFilter();
|
||||
CustomerList.SetTableView(CustomerView);
|
||||
ReportTempBlob.CreateOutStream(ReportOutStream);
|
||||
CustomerList.SaveAs('', ReportFormat::Pdf, ReportOutStream);
|
||||
ReportTempBlob.CreateInStream(ReportInStream);
|
||||
DataCompression.AddEntry(ReportInStream, Customer."No." + '.pdf');
|
||||
until Customer.Next() = 0;
|
||||
|
||||
ZipTempBlob.CreateOutStream(ZipOutStream);
|
||||
DataCompression.SaveZipArchive(ZipOutStream);
|
||||
DataCompression.CloseZipArchive();
|
||||
ZipTempBlob.CreateInStream(ZipInStream);
|
||||
ZipFileName := 'CustomerReports.zip';
|
||||
DownloadFromStream(ZipInStream, '', '', '*.zip', ZipFileName);
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: reporting
|
||||
keywords: [report, run, saveas, downloadfromstream, web-client, loop, zip, data-compression]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Report output in a loop needs one client download
|
||||
|
||||
## Description
|
||||
|
||||
The Business Central Web client can deliver only one file per request. When AL generates or downloads a report file repeatedly in the same request, only the last file is delivered to the browser. Earlier report output is silently unavailable to the user even though every iteration ran.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Generate each report into a stream, add the streams to one archive, and call `DownloadFromStream` once after the loop. A direct report run or download inside a loop is valid only when the execution context does not use the Web client or the loop is guaranteed to execute at most once.
|
||||
|
||||
See sample: [`report-output-in-a-loop-needs-one-client-download.good.al`](report-output-in-a-loop-needs-one-client-download.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Call `Report.Run`, `Report.RunModal`, or `DownloadFromStream` repeatedly in a loop initiated by one Web client action and expect every generated file to reach the browser. The client receives only the last download.
|
||||
|
||||
See sample: [`report-output-in-a-loop-needs-one-client-download.bad.al`](report-output-in-a-loop-needs-one-client-download.bad.al).
|
||||
|
||||
## References
|
||||
|
||||
`File.DownloadFromStream` method — https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/file/file-downloadfromstream-method
|
||||
|
||||
`Data Compression` codeunit — https://learn.microsoft.com/dynamics365/business-central/application/system-application/codeunit/system.io.data-compression
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
report 50103 "Base Customer Export"
|
||||
{
|
||||
ProcessingOnly = true;
|
||||
|
||||
dataset
|
||||
{
|
||||
dataitem(Customer; Customer)
|
||||
{
|
||||
trigger OnPreDataItem()
|
||||
begin
|
||||
SetRange(Blocked, Blocked::" ");
|
||||
SetRange("Country/Region Code");
|
||||
end;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
reportextension 50104 "Local Customer Export" extends "Base Customer Export"
|
||||
{
|
||||
dataset
|
||||
{
|
||||
modify(Customer)
|
||||
{
|
||||
trigger OnBeforePreDataItem()
|
||||
begin
|
||||
SetFilter("Country/Region Code", '<>%1', '');
|
||||
end;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
report 50103 "Base Customer Export"
|
||||
{
|
||||
ProcessingOnly = true;
|
||||
|
||||
dataset
|
||||
{
|
||||
dataitem(Customer; Customer)
|
||||
{
|
||||
trigger OnPreDataItem()
|
||||
begin
|
||||
SetRange(Blocked, Blocked::" ");
|
||||
SetRange("Country/Region Code");
|
||||
end;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
reportextension 50104 "Local Customer Export" extends "Base Customer Export"
|
||||
{
|
||||
dataset
|
||||
{
|
||||
modify(Customer)
|
||||
{
|
||||
trigger OnAfterPreDataItem()
|
||||
begin
|
||||
SetFilter("Country/Region Code", '<>%1', '');
|
||||
end;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
---
|
||||
bc-version: [19..]
|
||||
domain: reporting
|
||||
keywords: [reportextension, report, dataitem, trigger-order, onbeforepredataitem, onafterpredataitem, onbeforeaftergetrecord, onafteraftergetrecord]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Choose ReportExtension dataitem triggers by their order around the base trigger
|
||||
|
||||
## Description
|
||||
|
||||
ReportExtension dataitem triggers run at defined points around the corresponding base-report trigger. `OnBeforePreDataItem` and `OnBeforeAfterGetRecord` run before the base trigger; `OnAfterPreDataItem` and `OnAfterAfterGetRecord` run after it. A filter or calculated value can be overwritten when an extension uses a before-trigger even though its result must be final after base processing.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Choose the before or after trigger from the required ordering relative to base behavior. Use an after-trigger when the extension must observe or refine the final view or value produced by the base trigger. A before-trigger is valid when the base report must consume the extension's state.
|
||||
|
||||
See sample: [`reportextension-dataitem-trigger-order-is-explicit.good.al`](reportextension-dataitem-trigger-order-is-explicit.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Place extension logic in a before-trigger while relying on its filter or value to survive a base trigger that can replace it. Do not report a before-trigger merely because an after-trigger exists; the defect requires visible base behavior or another reliable source showing that ordering changes the result.
|
||||
|
||||
See sample: [`reportextension-dataitem-trigger-order-is-explicit.bad.al`](reportextension-dataitem-trigger-order-is-explicit.bad.al).
|
||||
|
||||
## References
|
||||
|
||||
`OnBeforePreDataItem` report-extension trigger — https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/triggers-auto/reportextensiondatasetmodify/devenv-onbeforepredataitem-reportextensiondatasetmodify-trigger
|
||||
|
||||
`OnAfterPreDataItem` report-extension trigger — https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/triggers-auto/reportextensiondatasetmodify/devenv-onafterpredataitem-reportextensiondatasetmodify-trigger
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
report 50110 "Customer Export"
|
||||
{
|
||||
ProcessingOnly = true;
|
||||
|
||||
dataset
|
||||
{
|
||||
dataitem(Customer; Customer)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
trigger OnPreReport()
|
||||
var
|
||||
ExportSetup: Record "Customer Export Setup";
|
||||
begin
|
||||
ExportSetup.Get();
|
||||
ExportSetup.TestField("Export Date");
|
||||
end;
|
||||
}
|
||||
|
||||
reportextension 50111 "Customer Export Extension" extends "Customer Export"
|
||||
{
|
||||
trigger OnPreReport()
|
||||
var
|
||||
ExportSetup: Record "Customer Export Setup";
|
||||
begin
|
||||
ExportSetup.Get();
|
||||
ExportSetup.Validate("Export Date", Today());
|
||||
ExportSetup.Modify(true);
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
report 50110 "Customer Export"
|
||||
{
|
||||
ProcessingOnly = true;
|
||||
|
||||
dataset
|
||||
{
|
||||
dataitem(Customer; Customer)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
trigger OnPreReport()
|
||||
var
|
||||
ExportDate: Date;
|
||||
begin
|
||||
OnBeforeResolveExportDate(ExportDate);
|
||||
if ExportDate = 0D then
|
||||
Error(ExportDateRequiredErr);
|
||||
end;
|
||||
|
||||
[IntegrationEvent(false, false)]
|
||||
local procedure OnBeforeResolveExportDate(var ExportDate: Date)
|
||||
begin
|
||||
end;
|
||||
|
||||
var
|
||||
ExportDateRequiredErr: Label 'An export date is required.';
|
||||
}
|
||||
|
||||
codeunit 50111 "Customer Export Extension"
|
||||
{
|
||||
[EventSubscriber(ObjectType::Report, Report::"Customer Export", 'OnBeforeResolveExportDate', '', false, false)]
|
||||
local procedure SetExportDate(var ExportDate: Date)
|
||||
begin
|
||||
ExportDate := Today();
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
---
|
||||
bc-version: [18..]
|
||||
domain: reporting
|
||||
keywords: [reportextension, report, trigger-order, onprereport, onpostreport, base-report, integration-event]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# ReportExtension report triggers run after base report triggers
|
||||
|
||||
## Description
|
||||
|
||||
`OnPreReport` and `OnPostReport` on a ReportExtension run after the corresponding triggers on the base report. An extension `OnPreReport` cannot prepare state that the base `OnPreReport` must consume, and an extension `OnPostReport` cannot affect finalization that the base `OnPostReport` has already completed.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Use a base-report event at the required execution point when extension logic must run before or within a base trigger. Use ReportExtension `OnPreReport` and `OnPostReport` only for work that is correct after the corresponding base trigger. Report a violation only when the base trigger and extension dependency are both visible or otherwise established.
|
||||
|
||||
See sample: [`reportextension-report-triggers-run-after-base-triggers.good.al`](reportextension-report-triggers-run-after-base-triggers.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Initialize data in a ReportExtension `OnPreReport` and rely on the base report's `OnPreReport` to consume it, or perform extension `OnPostReport` work that the base `OnPostReport` needed beforehand. The base trigger has already run.
|
||||
|
||||
See sample: [`reportextension-report-triggers-run-after-base-triggers.bad.al`](reportextension-report-triggers-run-after-base-triggers.bad.al).
|
||||
|
||||
## References
|
||||
|
||||
Report extension object — https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/devenv-report-ext-object
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
report 50107 "Selected Sales Orders"
|
||||
{
|
||||
ProcessingOnly = true;
|
||||
|
||||
dataset
|
||||
{
|
||||
dataitem(SalesHeader; "Sales Header")
|
||||
{
|
||||
DataItemTableView = where("Document Type" = const(Order), Status = const(Open));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
codeunit 50108 "Run Selected Sales Orders"
|
||||
{
|
||||
procedure RunReleasedOrders()
|
||||
var
|
||||
SalesHeader: Record "Sales Header";
|
||||
SelectedSalesOrders: Report "Selected Sales Orders";
|
||||
begin
|
||||
SalesHeader.SetRange("Document Type", SalesHeader."Document Type"::Order);
|
||||
SalesHeader.SetRange(Status, SalesHeader.Status::Released);
|
||||
SelectedSalesOrders.SetTableView(SalesHeader);
|
||||
SelectedSalesOrders.RunModal();
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
report 50107 "Selected Sales Orders"
|
||||
{
|
||||
ProcessingOnly = true;
|
||||
|
||||
dataset
|
||||
{
|
||||
dataitem(SalesHeader; "Sales Header")
|
||||
{
|
||||
DataItemTableView = where("Document Type" = const(Order));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
codeunit 50108 "Run Selected Sales Orders"
|
||||
{
|
||||
procedure RunReleasedOrders()
|
||||
var
|
||||
SalesHeader: Record "Sales Header";
|
||||
SelectedSalesOrders: Report "Selected Sales Orders";
|
||||
begin
|
||||
SalesHeader.SetRange("Document Type", SalesHeader."Document Type"::Order);
|
||||
SalesHeader.SetRange(Status, SalesHeader.Status::Released);
|
||||
SelectedSalesOrders.SetTableView(SalesHeader);
|
||||
SelectedSalesOrders.RunModal();
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: reporting
|
||||
keywords: [report, settableview, dataitemtableview, filter, view, narrowing]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# SetTableView cannot broaden DataItemTableView
|
||||
|
||||
## Description
|
||||
|
||||
`Report.SetTableView()` applies the supplied record view by narrowing the view already defined by the report dataitem's `DataItemTableView`. It cannot remove or broaden a static dataitem filter. A caller that requests records excluded by `DataItemTableView` therefore produces an empty dataset rather than overriding the report filter.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Keep only invariant restrictions in `DataItemTableView`. When callers must select among values, leave that dimension open in the static view and pass the required filter through `SetTableView`. Review this as a defect only when the report definition and caller together show a contradictory filter.
|
||||
|
||||
See sample: [`settableview-cannot-broaden-dataitemtableview.good.al`](settableview-cannot-broaden-dataitemtableview.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Define a static filter in `DataItemTableView` and call `SetTableView` with a mutually exclusive filter while expecting the runtime view to replace the static one. The filters are intersected and no records are selected.
|
||||
|
||||
See sample: [`settableview-cannot-broaden-dataitemtableview.bad.al`](settableview-cannot-broaden-dataitemtableview.bad.al).
|
||||
|
||||
## References
|
||||
|
||||
`Report.SetTableView()` method — https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/methods-auto/report/reportinstance-settableview-method
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
codeunit 50109 "Export Customer Report"
|
||||
{
|
||||
procedure ExportReport()
|
||||
var
|
||||
TempBlob: Codeunit "Temp Blob";
|
||||
ReportOutStream: OutStream;
|
||||
RequestPageParameters: Text;
|
||||
begin
|
||||
RequestPageParameters := Report.RunRequestPage(Report::"Customer - List");
|
||||
TempBlob.CreateOutStream(ReportOutStream);
|
||||
Report.SaveAs(
|
||||
Report::"Customer - List",
|
||||
RequestPageParameters,
|
||||
ReportFormat::Pdf,
|
||||
ReportOutStream);
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
codeunit 50109 "Export Customer Report"
|
||||
{
|
||||
procedure ExportReport()
|
||||
var
|
||||
TempBlob: Codeunit "Temp Blob";
|
||||
ReportOutStream: OutStream;
|
||||
RequestPageParameters: Text;
|
||||
begin
|
||||
RequestPageParameters := Report.RunRequestPage(Report::"Customer - List");
|
||||
if RequestPageParameters = '' then
|
||||
exit;
|
||||
|
||||
TempBlob.CreateOutStream(ReportOutStream);
|
||||
Report.SaveAs(
|
||||
Report::"Customer - List",
|
||||
RequestPageParameters,
|
||||
ReportFormat::Pdf,
|
||||
ReportOutStream);
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: reporting
|
||||
keywords: [report, runrequestpage, cancel, parameters, saveas, execute, print]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Stop when RunRequestPage returns empty parameters
|
||||
|
||||
## Description
|
||||
|
||||
`Report.RunRequestPage()` returns an empty string when the user chooses **Cancel**. Passing that value to `Report.Execute`, `Report.Print`, or `Report.SaveAs` ignores the cancellation and can run the report with default parameters instead.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Test the returned parameter string immediately after `RunRequestPage()` and exit when it is empty. Pass the value to `Execute`, `Print`, or `SaveAs` only after the user has confirmed the request page.
|
||||
|
||||
See sample: [`stop-when-runrequestpage-returns-empty-parameters.good.al`](stop-when-runrequestpage-returns-empty-parameters.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Call `RunRequestPage()` and unconditionally pass its return value to a report execution method. Choosing **Cancel** can still execute, print, or save the report.
|
||||
|
||||
See sample: [`stop-when-runrequestpage-returns-empty-parameters.bad.al`](stop-when-runrequestpage-returns-empty-parameters.bad.al).
|
||||
|
||||
## References
|
||||
|
||||
`Report.RunRequestPage()` method — https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/methods-auto/report/report-runrequestpage-method
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
codeunit 50541 "Sec Sample UnauthResp Bad"
|
||||
{
|
||||
procedure IsVatNumberValid(RequestedCountryCode: Text; RequestedVatNumber: Text): Boolean
|
||||
var
|
||||
HttpClient: HttpClient;
|
||||
Response: HttpResponseMessage;
|
||||
JsonResponse: JsonObject;
|
||||
JsonToken: JsonToken;
|
||||
Content: Text;
|
||||
begin
|
||||
// Anti-pattern: the endpoint is unauthenticated, yet the response is trusted with no
|
||||
// size cap, no schema check, and no request-to-response integrity check.
|
||||
HttpClient.Get('http://vat-service.example/check?cc=' + RequestedCountryCode + '&vat=' + RequestedVatNumber, Response);
|
||||
Response.Content().ReadAs(Content);
|
||||
JsonResponse.ReadFrom(Content);
|
||||
|
||||
// Trusts valid=true for ANY input: a spoofed or MITM response that omits the echoed
|
||||
// countryCode/vatNumber is accepted as valid for whatever number was requested.
|
||||
if JsonResponse.Get('valid', JsonToken) then
|
||||
exit(JsonToken.AsValue().AsBoolean());
|
||||
exit(false);
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
codeunit 50540 "Sec Sample UnauthResp Good"
|
||||
{
|
||||
// The public VAT validation service does not authenticate itself to us (no OAuth, no
|
||||
// certificate, plain HTTP), so its response must be validated before it is trusted.
|
||||
procedure IsVatNumberValid(RequestedCountryCode: Text; RequestedVatNumber: Text): Boolean
|
||||
var
|
||||
HttpClient: HttpClient;
|
||||
Response: HttpResponseMessage;
|
||||
JsonResponse: JsonObject;
|
||||
JsonToken: JsonToken;
|
||||
Content: Text;
|
||||
ResponseCountryCode: Text;
|
||||
ResponseVatNumber: Text;
|
||||
begin
|
||||
HttpClient.Get('http://vat-service.example/check?cc=' + RequestedCountryCode + '&vat=' + RequestedVatNumber, Response);
|
||||
if not Response.IsSuccessStatusCode() then
|
||||
exit(false);
|
||||
|
||||
Response.Content().ReadAs(Content);
|
||||
|
||||
// 1) Size cap - the platform already buffered the whole body; reject abnormally large payloads.
|
||||
if StrLen(Content) > 4096 then
|
||||
Error('The VAT validation response exceeded the maximum allowed size and was rejected.');
|
||||
|
||||
// 2) Schema - require the expected scalar fields, not just a truthy flag.
|
||||
if not JsonResponse.ReadFrom(Content) then
|
||||
Error('The VAT validation response was not in the expected format and was rejected.');
|
||||
if not JsonResponse.Get('countryCode', JsonToken) then
|
||||
Error('The VAT validation response did not include the requested identifiers and was rejected.');
|
||||
ResponseCountryCode := JsonToken.AsValue().AsText();
|
||||
if not JsonResponse.Get('vatNumber', JsonToken) then
|
||||
Error('The VAT validation response did not include the requested identifiers and was rejected.');
|
||||
ResponseVatNumber := JsonToken.AsValue().AsText();
|
||||
|
||||
// 3) Integrity - the echoed identifiers must match the request, so a valid=true payload
|
||||
// with the identifiers stripped cannot be accepted for an arbitrary VAT number.
|
||||
if (UpperCase(ResponseCountryCode) <> UpperCase(RequestedCountryCode)) or
|
||||
(UpperCase(ResponseVatNumber) <> UpperCase(RequestedVatNumber))
|
||||
then
|
||||
Error('The VAT validation response did not match the requested identifiers and was rejected.');
|
||||
|
||||
if not JsonResponse.Get('valid', JsonToken) then
|
||||
exit(false);
|
||||
exit(JsonToken.AsValue().AsBoolean());
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: security
|
||||
keywords: [unauthenticated, ssrf, httpclient, soap, response-validation, integrity, size-limit, disablehttpscheck, temp-blob, vies]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Validate responses from unauthenticated endpoints before trusting them
|
||||
|
||||
## Description
|
||||
|
||||
When AL calls an external endpoint that does not authenticate *itself* to the client, the response is fully attacker-influenceable — cleartext MITM, a spoofed or compromised host, DNS/redirect games, or simply a misbehaving public service. Recognizing that a call is unauthenticated is the first review step, and the signals are BC-specific: a bare `HttpClient.Get`/`Post` with no `Authorization` header, no acquired OAuth token, and no client certificate; a SOAP request whose credentials are blank, such as `SOAP Web Service Request Mgt.SetGlobals(..., '', BlankSecretText)`; or any request issued after `DisableHttpsCheck()` over plain HTTP (for example the EU VIES VAT service, whose default endpoint is `http://`). Because the BC platform HTTP stack buffers the entire response body before AL is handed the stream or `Temp Blob`, the whole payload is already in memory by the time AL parses it — so the response must pass three checks in AL — **response size**, **schema compliance**, and **content integrity** — *before* any of it is written to tax, VAT, customer, or vendor tables.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Before parsing or trusting a response from an unauthenticated endpoint, apply all three of these checks before the payload reaches business logic: (1) **Response size** — reject when the buffered `Temp Blob` length or `Content-Length` exceeds a small cap sized to the expected payload; (2) **Schema compliance** — require the specific scalar nodes/fields you expect in the expected shape, not merely "the body contains a truthy flag"; (3) **Content integrity** — when the protocol echoes the identifiers you queried (VIES echoes `countryCode`/`vatNumber`; a public-IP service echoes an IP string), require them to be present and to match the request, so a response carrying only `valid=true` cannot be accepted for an arbitrary input. On any failing check, raise an `Error` and record a security audit via `Audit Log.LogAuditMessage(...)` plus telemetry. See sample: [`validate-unauthenticated-response-before-use.good.al`](validate-unauthenticated-response-before-use.good.al). For validating the outbound target/host, see `validate-user-configurable-urls.md`; for authenticating outbound calls, see `prefer-oauth2-over-api-keys-for-external-http-calls.md`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Feeding the parsed response straight into business logic — load the XML/JSON, read a `valid` flag or an IP-shaped substring, then `Customer.Modify()` — trusting it purely because the HTTP call returned 2xx, with no size, shape, or echoed-identifier check. Reviewers should flag an unauthenticated outbound call (no `Authorization`/OAuth/cert, blank SOAP `SecretText`, or a request after `DisableHttpsCheck`) whose response is parsed and persisted without a preceding size cap, schema check, and request-to-response integrity check. Do NOT, however, demand a streaming or bounded read that aborts the transfer mid-download, nor a resolved-IP/DNS-rebinding check: the platform buffers the full body before AL sees it and AL has no connection-time or DNS hook, so an in-AL size check necessarily runs after buffering and host-rebinding defense belongs to the platform egress layer — raising those is a false positive. HTTPS is likewise not always enforceable (VIES is HTTP by design); the mitigation there is response validation, not scheme enforcement. See sample: [`validate-unauthenticated-response-before-use.bad.al`](validate-unauthenticated-response-before-use.bad.al).
|
||||
|
|
@ -17,13 +17,13 @@ A tableextension can append a field to the `DropDown` field group with `addlast`
|
|||
|
||||
When adding a hidden field to a `DropDown` field group, also extend the page used for the lookup and make that field control visible. Verify the actual lookup page rather than assuming the table definition alone controls the drop-down.
|
||||
|
||||
See sample: `dropdown-fieldgroup-respects-lookup-page-visibility.good.al`.
|
||||
See sample: [`dropdown-fieldgroup-respects-lookup-page-visibility.good.al`](dropdown-fieldgroup-respects-lookup-page-visibility.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Adding the field with `addlast(DropDown; ...)` while leaving its lookup-page control hidden, then expecting the field to appear in the drop-down.
|
||||
|
||||
See sample: `dropdown-fieldgroup-respects-lookup-page-visibility.bad.al`.
|
||||
See sample: [`dropdown-fieldgroup-respects-lookup-page-visibility.bad.al`](dropdown-fieldgroup-respects-lookup-page-visibility.bad.al).
|
||||
|
||||
## Reference
|
||||
|
||||
|
|
|
|||
|
|
@ -17,13 +17,13 @@ A page part does not automatically refresh its parent page when the subpage chan
|
|||
|
||||
Set `UpdatePropagation = Both` on a part when edits in that subpage must immediately update values rendered by the main page. Leave propagation at `Subpage` when the parent has no dependent presentation to avoid unnecessary refreshes.
|
||||
|
||||
See sample: `updatepropagation-both-refreshes-main-page.good.al`.
|
||||
See sample: [`updatepropagation-both-refreshes-main-page.good.al`](updatepropagation-both-refreshes-main-page.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Displaying a line-dependent total on the main page while the editable lines part updates only itself. The persisted values can be correct while the parent page continues to show an old total.
|
||||
|
||||
See sample: `updatepropagation-both-refreshes-main-page.bad.al`.
|
||||
See sample: [`updatepropagation-both-refreshes-main-page.bad.al`](updatepropagation-both-refreshes-main-page.bad.al).
|
||||
|
||||
## Reference
|
||||
|
||||
|
|
|
|||
|
|
@ -17,13 +17,13 @@ An extension can contain multiple `Install` or `Upgrade` codeunits, but Business
|
|||
|
||||
Keep separate install or upgrade codeunits independent. When two steps have a real dependency, coordinate them from one owning trigger in the required order; use upgrade tags to make each completed step idempotent.
|
||||
|
||||
See sample: `install-and-upgrade-codeunits-have-no-order.good.al`.
|
||||
See sample: [`install-and-upgrade-codeunits-have-no-order.good.al`](install-and-upgrade-codeunits-have-no-order.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Splitting dependent steps into separate codeunits and relying on names, object IDs, or declaration order. The dependent codeunit can run first and fail or observe partially migrated data.
|
||||
|
||||
See sample: `install-and-upgrade-codeunits-have-no-order.bad.al`.
|
||||
See sample: [`install-and-upgrade-codeunits-have-no-order.bad.al`](install-and-upgrade-codeunits-have-no-order.bad.al).
|
||||
|
||||
## Reference
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue