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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions microsoft/knowledge/appsource/file-datatype-saas.bad.al
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
codeunit 50104 "Import File Reader"
{
procedure ImportFile()
var
ImportFile: File;
InStream: InStream;
begin
ImportFile.WriteMode(false);
ImportFile.TextMode(true);
ImportFile.Open('C:\Import\data.txt'); // fails in SaaS — no local filesystem
ImportFile.CreateInStream(InStream);
end;
}
24 changes: 24 additions & 0 deletions microsoft/knowledge/appsource/file-datatype-saas.good.al
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
codeunit 50104 "Import File Reader"
{
procedure ImportFile()
var
TempBlob: Codeunit "Temp Blob";
FromInStream: InStream;
ToOutStream: OutStream;
InStream: InStream;
begin
if not UploadIntoStream('All Files (*.*)|*.*', FromInStream) then
exit;

TempBlob.CreateOutStream(ToOutStream);
CopyStream(ToOutStream, FromInStream);

TempBlob.CreateInStream(InStream);
ParseStream(InStream);
end;

local procedure ParseStream(var InStream: InStream)
begin
// parse InStream content here
end;
}
28 changes: 28 additions & 0 deletions microsoft/knowledge/appsource/file-datatype-saas.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
bc-version: [all]
domain: appsource
keywords: [file-datatype, saas, onprem, uploadintostream, downloadfromstream, instream, outstream, streaming]
technologies: [al]
countries: [w1]
application-area: [all]
---

# The File data type's direct I/O methods are OnPrem-only

> Contributions welcome — open a PR to refine or extend this article.

## Description

The classic `File` variable type — `Open`/`Create`/`Read`/`Write`/`Close` against a path on the local or server filesystem — is scoped OnPrem-only. Code targeting Business Central Online that calls `File.Open`, `File.Create`, `File.Read`, or `File.Write` fails to compile against a Cloud-scoped project; it does not compile successfully and fail or get silently skipped at runtime. Separately, and regardless of the compile-time scoping, no server/local filesystem path is available to an extension actually running in Business Central Online.

## Best Practice

Use the stream-based equivalents: `UploadIntoStream` to read user-selected file content into an `InStream`, and `DownloadFromStream` to write an `OutStream`'s content to a file the user saves. Stage the content in a `TempBlob` between the stream and the rest of the parsing/formatting code.

See sample: [`file-datatype-saas.good.al`](file-datatype-saas.good.al).

## Anti Pattern

Opening a hardcoded or user-supplied filesystem path with the `File` variable type. This is a strong signal the code was written for on-premises only, or copied from material that predates the cloud-first streaming APIs.

See sample: [`file-datatype-saas.bad.al`](file-datatype-saas.bad.al).
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
codeunit 50103 "Order Confirmation Notifier"
{
procedure Send(ToAddress: Text; Subject: Text; Body: Text)
var
Mail: Codeunit Mail;
begin
Mail.CreateMessage(ToAddress, '', '', Subject, Body, false, false);
end;
}
11 changes: 11 additions & 0 deletions microsoft/knowledge/breaking-changes/prefer-email-module.good.al
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
codeunit 50103 "Order Confirmation Notifier"
{
procedure Send(ToAddress: Text; Subject: Text; Body: Text)
var
Email: Codeunit Email;
EmailMessage: Codeunit "Email Message";
begin
EmailMessage.Create(ToAddress, Subject, Body, true);
Email.Send(EmailMessage, Enum::"Email Scenario"::Default);
end;
}
28 changes: 28 additions & 0 deletions microsoft/knowledge/breaking-changes/prefer-email-module.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
bc-version: [all]
domain: breaking-changes
keywords: [email, codeunit-mail, email-message, email-scenario, email-account, smtp, sending-email]
technologies: [al]
countries: [w1]
application-area: [all]
---

# Send email through the Email module, not Codeunit Mail (397)

> Contributions welcome — open a PR to refine or extend this article.

## Description

Older AL code sends email by calling `Codeunit Mail (397)`. Business Central's current extensibility model is a different, richer object set — `Codeunit Email`, `Codeunit "Email Message"`, `enum "Email Scenario"`, and the `Email Account`/`Email Connector` interface (Microsoft 365, Current User, SMTP, or a custom connector). `Codeunit "Email Message"` is the in-memory object you build the message on; it is not itself the persisted Sent/Outbox/Draft record — that storage is managed separately once the message is queued or sent. New code built on `Codeunit Mail` inherits its SMTP-era, single-connector assumptions and leaves no Sent/Outbox trail behind.

## Best Practice

Build on `Codeunit Email` and `Codeunit "Email Message"`. Route the message through an `Email Scenario` so different document types can use different accounts without the calling code needing to know which account that is, and get a tracked Sent/Outbox/Draft record for free.

See sample: [`prefer-email-module.good.al`](prefer-email-module.good.al).

## Anti Pattern

Calling `Codeunit Mail`'s `CreateMessage`. It still compiles and runs, but current `Codeunit Mail`'s own implementation of `CreateMessage` no longer sends anything by itself — it only raises integration events for a legacy subscriber to act on — so building new code on it means depending on whatever compatibility shim happens to still be wired up, with no first-class connector selection and no queryable Sent/Outbox/Draft record. `Send` and `GetErrorDesc` are not current members of `Codeunit Mail` at all; do not reference them.

See sample: [`prefer-email-module.bad.al`](prefer-email-module.bad.al).
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
codeunit 50101 "Meter Jnl.-Post"
{
procedure Post(var MeterJnlLine: Record "Meter Journal Line")
var
MeterLedgEntry: Record "Meter Ledger Entry";
begin
// Validation, Journal access, and posting all mixed in one routine.
if not Confirm('Post journal lines?') then
exit;

if MeterJnlLine.FindSet() then
repeat
if MeterJnlLine.Quantity = 0 then
Error('Quantity must not be zero.');

MeterLedgEntry.Init();
MeterLedgEntry.TransferFields(MeterJnlLine);
MeterLedgEntry.Insert();
MeterJnlLine.Delete();
until MeterJnlLine.Next() = 0;
end;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
codeunit 50100 "Meter Jnl.-Check Line"
{
procedure CheckLine(var MeterJnlLine: Record "Meter Journal Line")
begin
// Reads setup/dimension data only, shows no UI beyond errors.
if MeterJnlLine.Quantity = 0 then
Error('Quantity must not be zero.');
end;
}

codeunit 50101 "Meter Jnl.-Post Line"
{
procedure PostLine(var MeterJnlLine: Record "Meter Journal Line")
var
MeterLedgEntry: Record "Meter Ledger Entry";
begin
// Posts exactly one journal line; never touches the Journal table.
MeterLedgEntry.Init();
MeterLedgEntry.TransferFields(MeterJnlLine);
MeterLedgEntry.Insert();
end;
}

codeunit 50102 "Meter Jnl.-Post Batch"
{
procedure PostBatch(var MeterJnlLine: Record "Meter Journal Line")
var
CheckLine: Codeunit "Meter Jnl.-Check Line";
PostLine: Codeunit "Meter Jnl.-Post Line";
begin
if MeterJnlLine.FindSet() then
repeat
CheckLine.CheckLine(MeterJnlLine);
until MeterJnlLine.Next() = 0;

if MeterJnlLine.FindSet() then
repeat
PostLine.PostLine(MeterJnlLine);
MeterJnlLine.Delete();
until MeterJnlLine.Next() = 0;
end;
}
28 changes: 28 additions & 0 deletions microsoft/knowledge/data-modeling/check-post-line-batch-pattern.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
bc-version: [all]
domain: data-modeling
keywords: [posting-routine, check-line, post-line, post-batch, companion-codeunit, yes-no-wrapper, journal]
technologies: [al]
countries: [w1]
application-area: [all]
---

# Split posting routines into Check Line / Post Line / Post Batch

> Contributions welcome — open a PR to refine or extend this article.

## Description

Business Central's own journal-based posting routines consistently follow a three-codeunit split, each with one primary responsibility — `Codeunit "Gen. Jnl.-Check Line"` / `"Gen. Jnl.-Post Line"` / `"Gen. Jnl.-Post Batch"` for the general journal, and the same `<Journal>-Check Line` / `<Journal>-Post Line` / `<Journal>-Post Batch` shape repeated for Item, Resource, Job, Fixed Asset, Insurance, and Cost Accounting journals: `Check Line` validates one line, `Post Line` posts exactly one journal line — `Gen. Jnl.-Post Line` itself can write more than one G/L Entry per call (a balancing entry, VAT, currency rounding, deferrals), so "exactly one line" describes its input, not a one-entry-out guarantee — and `Post Batch` loops both across the journal. A document posting routine (posting one document at a time) calls `Post Line` directly and skips `Post Batch`. This is the standard shape to evaluate a new journal-based posting routine against, not a platform-enforced constraint — a routine with a genuinely different transaction/reuse shape may legitimately organize itself differently, and the three codeunits' responsibilities are a useful default split, not a guarantee that every implementation keeps them non-overlapping. But a new routine that blurs this split without a specific reason either misses functionality other code expects to call directly, or exposes an interaction surface it shouldn't.

## Best Practice

`Check Line` reads setup/dimension data only on its first call and shows no UI beyond errors. `Post Line` only operates on the record passed to it — never the Journal table — so it can be called directly by other posting code, including a document posting routine. `Post Batch` is the only one of the three that reads and updates the Journal table, and it is the only one invoked from the Post action on a journal page. A `-Post` document codeunit is never called directly from a page; a page calls a `-Post (Yes/No)` confirmation wrapper instead, so the same `-Post` codeunit can also run unattended from a batch-posting report.

See sample: [`check-post-line-batch-pattern.good.al`](check-post-line-batch-pattern.good.al).

## Anti Pattern

A single monolithic posting codeunit that reads the Journal table, validates lines, writes ledger entries, and shows confirmation dialogs all in one procedure. It cannot be reused by another posting routine without fabricating journal records, and it cannot run unattended because it insists on user interaction.

See sample: [`check-post-line-batch-pattern.bad.al`](check-post-line-batch-pattern.bad.al).
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
table 50100 "Course"
{
fields
{
field(1; "No."; Code[20]) { }
field(10; "Global Dimension 1 Code"; Code[20])
{
// No CaptionClass, no OnValidate call into DimensionManagement.
// Accepts any value; never becomes a Default Dimension record.
TableRelation = "Dimension Value".Code;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// Master data: Default Dimension records, no Dimension Set ID field.
table 50100 "Course"
{
fields
{
field(1; "No."; Code[20]) { }
field(10; "Global Dimension 1 Code"; Code[20])
{
CaptionClass = '1,1,1';
TableRelation = "Dimension Value".Code where(
"Global Dimension No." = const(1), Blocked = const(false));

trigger OnValidate()
var
DimMgt: Codeunit DimensionManagement;
begin
DimMgt.ValidateDimValueCode(1, "Global Dimension 1 Code");
DimMgt.SaveDefaultDim(Database::Course, "No.", 1, "Global Dimension 1 Code");
end;
}
}

trigger OnDelete()
var
DimMgt: Codeunit DimensionManagement;
begin
DimMgt.DeleteDefaultDim(Database::Course, "No.");
end;
}

// Transactional/document data: a single Dimension Set ID, inherited from the
// related master record and overridable via shortcut dimension fields.
table 50101 "Course Registration Header"
{
fields
{
field(1; "No."; Code[20]) { }
field(2; "Customer No."; Code[20])
{
TableRelation = Customer;

trigger OnValidate()
begin
UpdateDimensionSetID();
end;
}
field(10; "Shortcut Dimension 1 Code"; Code[20])
{
CaptionClass = '1,1,1';
TableRelation = "Dimension Value".Code where(
"Global Dimension No." = const(1), Blocked = const(false));

trigger OnValidate()
var
DimMgt: Codeunit DimensionManagement;
begin
DimMgt.ValidateShortcutDimValues(1, "Shortcut Dimension 1 Code", "Dimension Set ID");
end;
}
field(480; "Dimension Set ID"; Integer)
{
Editable = false;
TableRelation = "Dimension Set Entry"."Dimension Set ID";
}
}

local procedure UpdateDimensionSetID()
var
Customer: Record Customer;
DimMgt: Codeunit DimensionManagement;
DefaultDimSource: List of [Dictionary of [Integer, Code[20]]];
GlobalDim2Code: Code[20];
begin
// Recompute from scratch (InheritFromDimSetID = 0) whether or not the
// customer lookup succeeds. Passing the existing "Dimension Set ID"
// here would inherit dimensions from whichever record the document
// was previously linked to, and exiting early on a failed Get would
// leave that same stale data in place — both defeat the point of
// this procedure. Clearing the shortcut field and recomputing with
// an empty source list (when the customer doesn't exist) correctly
// clears the document's dimensions instead of leaving old ones.
"Shortcut Dimension 1 Code" := '';
if Customer.Get("Customer No.") then
DimMgt.AddDimSource(DefaultDimSource, Database::Customer, "Customer No.");
"Dimension Set ID" :=
DimMgt.GetDefaultDimID(
DefaultDimSource, '', "Shortcut Dimension 1 Code", GlobalDim2Code, 0, 0);
end;
}
35 changes: 35 additions & 0 deletions microsoft/knowledge/data-modeling/dimension-management-wiring.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
bc-version: [all]
domain: data-modeling
keywords: [dimensions, dimensionmanagement, global-dimension, shortcut-dimension, default-dimension, validatedimvaluecode, getdefaultdimid]
technologies: [al]
countries: [w1]
application-area: [all]
---

# Wire dimension support through DimensionManagement, not ad hoc fields

> Contributions welcome — open a PR to refine or extend this article.

## Description

Adding dimension support to a custom table is not just a matter of adding a `Code[20]` field, and master tables and document/transactional tables wire into `Codeunit "Dimension Management"` through two different models — treating them as one mechanism is itself the mistake this article corrects:

- **Master data** (a custom master table, e.g. "Course") persists **Default Dimension** records: each shortcut dimension field validates through `ValidateDimValueCode`, then the result is saved via `SaveDefaultDim`, and `DeleteDefaultDim` removes them again in `OnDelete`. Both `ValidateDimValueCode` and `SaveDefaultDim` take the shortcut dimension *number* (1-8, matching `General Ledger Setup`'s "Shortcut Dimension N Code" fields) as their first/third argument respectively — not the AL field ID of the table field being validated. The master record itself carries no `Dimension Set ID` field.
- **Transactional/document data** (a custom document or journal-line table) carries a single **`Dimension Set ID`** field — a pointer to a shared, deduplicated set of dimension values in `Dimension Set Entry`, assembled from whatever the document inherited plus whatever the user overrode. A document does not acquire that ID by calling `SaveDefaultDim`; it builds a source list with `AddDimSource` (naming the related master table and its key, e.g. `Database::Customer`), then calls `GetDefaultDimID` to compute a new `Dimension Set ID` that inherits the master's Default Dimension records. Editing a shortcut dimension field directly on the document validates through `ValidateShortcutDimValues`, which updates the same `Dimension Set ID` in place rather than writing a separate Default Dimension record.

Skipping the model that actually matches the table's kind produces a field that looks correct in the designer but silently fails to save, validate, or carry through to postings — or, for a document, one that never picks up the customer's/vendor's own dimensions at all.

## Best Practice

For a master table, validate each shortcut dimension field through `ValidateDimValueCode`, save the result with `SaveDefaultDim`, and delete the matching Default Dimension records in `OnDelete`.

For a document table, when the field that attaches the document to a master record changes (e.g. `Customer No.`), call `AddDimSource` naming that master table and key, then `GetDefaultDimID` to compute the document's new `Dimension Set ID`, inheriting the master's Default Dimension records. Pass `0` for `GetDefaultDimID`'s `InheritFromDimSetID` argument in this case — passing the document's *existing* `Dimension Set ID` instead inherits whatever dimensions were already in it, so a value the previous linked record supplied can survive into the new one even where the new record has no default for that dimension. Run this same recompute — clear the shortcut field, call `GetDefaultDimID` with no source added — when the lookup on the new key fails (blank or an invalid value), too: exiting early instead leaves the previous record's dimensions in place, which is the same staleness bug the `InheritFromDimSetID = 0` rule exists to prevent. Validate the document's own Shortcut Dimension fields through `ValidateShortcutDimValues`, which updates that same `Dimension Set ID` in place rather than persisting a separate Default Dimension record.

See sample: [`dimension-management-wiring.good.al`](dimension-management-wiring.good.al).

## Anti Pattern

Adding a dimension-looking field with only a `TableRelation` to Dimension Value, and no call into `DimensionManagement` at all. The field accepts input but never becomes a real Default Dimension record, so it does not validate against blocked values and does not flow into postings.

See sample: [`dimension-management-wiring.bad.al`](dimension-management-wiring.bad.al).
14 changes: 14 additions & 0 deletions microsoft/knowledge/data-modeling/do-not-change-primary-key.bad.al
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
table 50100 "Period Stats"
{
fields
{
field(1; "Period Start"; Date) { }
field(2; Flow; Enum "Some Flow") { }
}
keys
{
// Table already shipped with key(PK; "Period Start").
// Adding Flow here breaks every upgrade with AS0009.
key(PK; Flow, "Period Start") { Clustered = true; }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
table 50100 "Period Stats"
{
fields
{
field(1; "Period Start"; Date) { }
}
keys
{
key(PK; "Period Start") { Clustered = true; }
}
}

table 50101 "Period Stats By Flow"
{
fields
{
field(1; "Period Start"; Date) { }
field(2; Flow; Enum "Some Flow") { }
}
keys
{
key(PK; Flow, "Period Start") { Clustered = true; }
}
}
Loading