mirror of
https://github.com/microsoft/BCQuality.git
synced 2026-08-06 09:26:52 +01:00
Add data-modeling and appsource knowledge articles (MICROSOFT layer)
Author 7 remedial BCQuality knowledge articles plus good/bad AL samples (21 files) covering AL master-table and data-model design: - data-modeling: master No. from number series in OnInsert; use codeunit "No. Series" not obsolete NoSeriesManagement; setup table is a singleton; set Last Date Modified in OnModify and OnRename; enforce Blocked in referencing code not in the master. - style: ApplicationArea required on page controls (AS0062). - appsource: object affixes prevent collisions (AS0011). Clean-room authored from own BC knowledge; specifics verified against public sources only (learn.microsoft.com, microsoft/BCApps). Introduces two new domains (data-modeling, appsource). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
24e62f5ec8
commit
18cb503540
21 changed files with 885 additions and 0 deletions
|
|
@ -0,0 +1,43 @@
|
||||||
|
// Anti-pattern: an own object with no affix. Another app that also defines a
|
||||||
|
// "Loyalty Tier" table cannot be installed alongside this one.
|
||||||
|
table 50379 "Loyalty Tier"
|
||||||
|
{
|
||||||
|
Caption = 'Loyalty Tier';
|
||||||
|
DataClassification = CustomerContent;
|
||||||
|
|
||||||
|
fields
|
||||||
|
{
|
||||||
|
field(1; "Code"; Code[20])
|
||||||
|
{
|
||||||
|
Caption = 'Code';
|
||||||
|
}
|
||||||
|
field(10; Description; Text[100])
|
||||||
|
{
|
||||||
|
Caption = 'Description';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
keys
|
||||||
|
{
|
||||||
|
key(PK; "Code")
|
||||||
|
{
|
||||||
|
Clustered = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anti-pattern (the common half-measure): the extension object carries the
|
||||||
|
// affix, but the field it adds to the standard Customer table does not. That
|
||||||
|
// unaffixed field still collides with any other app that adds "Loyalty Points"
|
||||||
|
// to Customer, and AS0011 flags it.
|
||||||
|
tableextension 50378 "ABC Customer Ext" extends Customer
|
||||||
|
{
|
||||||
|
fields
|
||||||
|
{
|
||||||
|
field(50378; "Loyalty Points"; Integer)
|
||||||
|
{
|
||||||
|
Caption = 'Loyalty Points';
|
||||||
|
DataClassification = CustomerContent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
// Own object: the affix "ABC" is carried at object-name level.
|
||||||
|
table 50377 "ABC Loyalty Tier"
|
||||||
|
{
|
||||||
|
Caption = 'Loyalty Tier';
|
||||||
|
DataClassification = CustomerContent;
|
||||||
|
|
||||||
|
fields
|
||||||
|
{
|
||||||
|
field(1; "Code"; Code[20])
|
||||||
|
{
|
||||||
|
Caption = 'Code';
|
||||||
|
}
|
||||||
|
field(10; Description; Text[100])
|
||||||
|
{
|
||||||
|
Caption = 'Description';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
keys
|
||||||
|
{
|
||||||
|
key(PK; "Code")
|
||||||
|
{
|
||||||
|
Clustered = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extension of a standard object: the added field is individually affixed,
|
||||||
|
// because the object name (Customer) belongs to the base application.
|
||||||
|
tableextension 50376 "ABC Customer Ext" extends Customer
|
||||||
|
{
|
||||||
|
fields
|
||||||
|
{
|
||||||
|
field(50376; "Loyalty Points ABC"; Integer)
|
||||||
|
{
|
||||||
|
Caption = 'Loyalty Points';
|
||||||
|
DataClassification = CustomerContent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
---
|
||||||
|
bc-version: [all]
|
||||||
|
domain: appsource
|
||||||
|
keywords: [object-affix, prefix, suffix, as0011, appsourcecop, collision, tableextension]
|
||||||
|
technologies: [al]
|
||||||
|
countries: [w1]
|
||||||
|
application-area: [all]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Apply a reserved affix to objects and to members added to base objects
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
An AppSource extension must carry a reserved affix — a prefix or a suffix of at least three characters — on the names of the objects it owns **and** on any field, key, control, or action it adds to a base-application object. The affix is registered with Microsoft; when two coexisting extensions would otherwise collide, the registrant of the affix wins. Without it, two apps that both add a `Loyalty Points` field to `Customer`, or both define a `Loyalty Tier` table, cannot be installed side by side.
|
||||||
|
|
||||||
|
AppSourceCop enforces this. The primary rule is AS0011 ("An affix is required"); the affixes are configured through `mandatoryAffixes` (and `mandatoryPrefix`) in `AppSourceCop.json`. Two placements matter and are easy to get half-right: an object you define carries the affix at **object-name** level, while a member you add to a **standard** object carries the affix on that **member's** name. Adding an affixed object is not enough — an unaffixed field bolted onto `Customer` still collides and still fails validation.
|
||||||
|
|
||||||
|
## Best Practice
|
||||||
|
|
||||||
|
Own objects are named with the affix (e.g. a table `ABC Loyalty Tier`), and every field or action added to a standard object is individually affixed (e.g. `Loyalty Points ABC` on a `Customer` tableextension).
|
||||||
|
|
||||||
|
See sample: `object-affixes-prevent-collisions.good.al`.
|
||||||
|
|
||||||
|
## Anti Pattern
|
||||||
|
|
||||||
|
Unaffixed object or member names, or the common half-measure: the extension object carries the affix but a field it adds to a standard table does not. AS0011 flags the missing affix and the field can still collide with another app.
|
||||||
|
|
||||||
|
See sample: `object-affixes-prevent-collisions.bad.al`.
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
table 50372 "Loyalty Member"
|
||||||
|
{
|
||||||
|
Caption = 'Loyalty Member';
|
||||||
|
DataClassification = CustomerContent;
|
||||||
|
|
||||||
|
fields
|
||||||
|
{
|
||||||
|
field(1; "No."; Code[20])
|
||||||
|
{
|
||||||
|
Caption = 'No.';
|
||||||
|
}
|
||||||
|
field(10; Name; Text[100])
|
||||||
|
{
|
||||||
|
Caption = 'Name';
|
||||||
|
}
|
||||||
|
field(20; Blocked; Boolean)
|
||||||
|
{
|
||||||
|
Caption = 'Blocked';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
keys
|
||||||
|
{
|
||||||
|
key(PK; "No.")
|
||||||
|
{
|
||||||
|
Clustered = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anti-pattern: the block check sits in the master's own trigger. Editing a
|
||||||
|
// blocked member is rare; referencing it is constant, and references never
|
||||||
|
// fire OnModify. So this stops nothing that matters.
|
||||||
|
trigger OnModify()
|
||||||
|
begin
|
||||||
|
TestField(Blocked, false);
|
||||||
|
end;
|
||||||
|
}
|
||||||
|
|
||||||
|
table 50373 "Loyalty Point Entry"
|
||||||
|
{
|
||||||
|
Caption = 'Loyalty Point Entry';
|
||||||
|
DataClassification = CustomerContent;
|
||||||
|
|
||||||
|
fields
|
||||||
|
{
|
||||||
|
field(1; "Entry No."; Integer)
|
||||||
|
{
|
||||||
|
Caption = 'Entry No.';
|
||||||
|
AutoIncrement = true;
|
||||||
|
}
|
||||||
|
field(10; "Member No."; Code[20])
|
||||||
|
{
|
||||||
|
Caption = 'Member No.';
|
||||||
|
TableRelation = "Loyalty Member"."No.";
|
||||||
|
// No block check on the referencing side: a line can freely
|
||||||
|
// reference a blocked member, and posting proceeds unchecked.
|
||||||
|
}
|
||||||
|
field(20; Points; Integer)
|
||||||
|
{
|
||||||
|
Caption = 'Points';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
keys
|
||||||
|
{
|
||||||
|
key(PK; "Entry No.")
|
||||||
|
{
|
||||||
|
Clustered = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
table 50370 "Loyalty Member"
|
||||||
|
{
|
||||||
|
Caption = 'Loyalty Member';
|
||||||
|
DataClassification = CustomerContent;
|
||||||
|
|
||||||
|
fields
|
||||||
|
{
|
||||||
|
field(1; "No."; Code[20])
|
||||||
|
{
|
||||||
|
Caption = 'No.';
|
||||||
|
}
|
||||||
|
field(10; Name; Text[100])
|
||||||
|
{
|
||||||
|
Caption = 'Name';
|
||||||
|
}
|
||||||
|
// Blocked is inert data here: the master carries the flag but no logic.
|
||||||
|
field(20; Blocked; Boolean)
|
||||||
|
{
|
||||||
|
Caption = 'Blocked';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
keys
|
||||||
|
{
|
||||||
|
key(PK; "No.")
|
||||||
|
{
|
||||||
|
Clustered = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
table 50371 "Loyalty Point Entry"
|
||||||
|
{
|
||||||
|
Caption = 'Loyalty Point Entry';
|
||||||
|
DataClassification = CustomerContent;
|
||||||
|
|
||||||
|
fields
|
||||||
|
{
|
||||||
|
field(1; "Entry No."; Integer)
|
||||||
|
{
|
||||||
|
Caption = 'Entry No.';
|
||||||
|
AutoIncrement = true;
|
||||||
|
}
|
||||||
|
field(10; "Member No."; Code[20])
|
||||||
|
{
|
||||||
|
Caption = 'Member No.';
|
||||||
|
TableRelation = "Loyalty Member"."No.";
|
||||||
|
|
||||||
|
trigger OnValidate()
|
||||||
|
var
|
||||||
|
LoyaltyMember: Record "Loyalty Member";
|
||||||
|
begin
|
||||||
|
if "Member No." = '' then
|
||||||
|
exit;
|
||||||
|
// Enforcement lives at the point of use: reject a blocked master
|
||||||
|
// as soon as a line references it.
|
||||||
|
LoyaltyMember.Get("Member No.");
|
||||||
|
LoyaltyMember.TestField(Blocked, false);
|
||||||
|
end;
|
||||||
|
}
|
||||||
|
field(20; Points; Integer)
|
||||||
|
{
|
||||||
|
Caption = 'Points';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
keys
|
||||||
|
{
|
||||||
|
key(PK; "Entry No.")
|
||||||
|
{
|
||||||
|
Clustered = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
procedure Post()
|
||||||
|
var
|
||||||
|
LoyaltyMember: Record "Loyalty Member";
|
||||||
|
begin
|
||||||
|
// Re-check before committing the transaction, in case the member was
|
||||||
|
// blocked after the line was created.
|
||||||
|
LoyaltyMember.Get("Member No.");
|
||||||
|
LoyaltyMember.TestField(Blocked, false);
|
||||||
|
end;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
---
|
||||||
|
bc-version: [all]
|
||||||
|
domain: data-modeling
|
||||||
|
keywords: [blocked-field, testfield, referencing-code, point-of-use, enforcement, journal-line]
|
||||||
|
technologies: [al]
|
||||||
|
countries: [w1]
|
||||||
|
application-area: [all]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Enforce `Blocked` where the master is used, not in the master itself
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
The `Blocked` field on a master record (`Item`, `Customer`, `Resource`, or a custom master) is inert data. The master table holds **no** logic that acts on it. Enforcement belongs in the **consuming** code: when a journal line, document line, or posting routine references the master by its `No.`, that referencing object tests the flag at the point of use, e.g. `LoyaltyMember.Get("Member No."); LoyaltyMember.TestField(Blocked, false);` in the line's `OnValidate` and again before posting.
|
||||||
|
|
||||||
|
Putting the block check inside the master's own `OnInsert`/`OnModify` does nothing to stop transactional use: a blocked master is edited rarely, but it is *referenced* constantly, and those references never touch the master's own triggers. Base BC follows this split — `Item.Blocked` is checked by sales/purchase/journal code, not by the `Item` table. A boolean `Blocked` uses `TestField(Blocked, false)`; an option-style block (e.g. `Sales`/`All`) needs the specific option compared at each relevant path.
|
||||||
|
|
||||||
|
## Best Practice
|
||||||
|
|
||||||
|
The referencing line validates `Master.TestField(Blocked, false)` in `OnValidate` of the reference field and re-checks before posting. The master table stays logic-free on `Blocked`.
|
||||||
|
|
||||||
|
See sample: `check-blocked-in-referencing-code-not-in-master.good.al`.
|
||||||
|
|
||||||
|
## Anti Pattern
|
||||||
|
|
||||||
|
The block check sits in the master's own `OnModify`/`OnInsert` (so referencing and posting proceed unchecked), or there is no check at all on the referencing side.
|
||||||
|
|
||||||
|
See sample: `check-blocked-in-referencing-code-not-in-master.bad.al`.
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
table 50361 "Loyalty Member"
|
||||||
|
{
|
||||||
|
Caption = 'Loyalty Member';
|
||||||
|
DataClassification = CustomerContent;
|
||||||
|
|
||||||
|
fields
|
||||||
|
{
|
||||||
|
// Anti-pattern: an autoincrement Integer surrogate used as the business key.
|
||||||
|
field(1; "Entry No."; Integer)
|
||||||
|
{
|
||||||
|
Caption = 'Entry No.';
|
||||||
|
AutoIncrement = true;
|
||||||
|
}
|
||||||
|
field(10; Name; Text[100])
|
||||||
|
{
|
||||||
|
Caption = 'Name';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
keys
|
||||||
|
{
|
||||||
|
key(PK; "Entry No.")
|
||||||
|
{
|
||||||
|
Clustered = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No OnInsert, no number series, no "No." code, and no "No. Series" field.
|
||||||
|
// Records get an opaque integer users never see and cannot quote on the phone,
|
||||||
|
// and the master is cut off from BC's standard numbering and manual-entry flow.
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
table 50360 "Loyalty Member"
|
||||||
|
{
|
||||||
|
Caption = 'Loyalty Member';
|
||||||
|
DataClassification = CustomerContent;
|
||||||
|
|
||||||
|
fields
|
||||||
|
{
|
||||||
|
field(1; "No."; Code[20])
|
||||||
|
{
|
||||||
|
Caption = 'No.';
|
||||||
|
NotBlank = true;
|
||||||
|
}
|
||||||
|
field(2; "No. Series"; Code[20])
|
||||||
|
{
|
||||||
|
Caption = 'No. Series';
|
||||||
|
Editable = false;
|
||||||
|
TableRelation = "No. Series";
|
||||||
|
}
|
||||||
|
field(10; Name; Text[100])
|
||||||
|
{
|
||||||
|
Caption = 'Name';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
keys
|
||||||
|
{
|
||||||
|
key(PK; "No.")
|
||||||
|
{
|
||||||
|
Clustered = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
trigger OnInsert()
|
||||||
|
var
|
||||||
|
LoyaltySetup: Record "Loyalty Setup";
|
||||||
|
NoSeries: Codeunit "No. Series";
|
||||||
|
begin
|
||||||
|
if "No." = '' then begin
|
||||||
|
LoyaltySetup.Get();
|
||||||
|
LoyaltySetup.TestField("Member Nos.");
|
||||||
|
"No. Series" := LoyaltySetup."Member Nos.";
|
||||||
|
"No." := NoSeries.GetNextNo("No. Series");
|
||||||
|
end;
|
||||||
|
end;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
---
|
||||||
|
bc-version: [all]
|
||||||
|
domain: data-modeling
|
||||||
|
keywords: [no-series, primary-key, code20, oninsert, autoincrement, number-assignment]
|
||||||
|
technologies: [al]
|
||||||
|
countries: [w1]
|
||||||
|
application-area: [all]
|
||||||
|
---
|
||||||
|
|
||||||
|
# A master table's `No.` primary key comes from a number series in `OnInsert`
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
In Business Central, a master table (Customer, Vendor, Item, and any custom equivalent) uses a single primary-key field named `No.` of type `Code[20]`. It is populated from a number series — configured on the feature's application-area setup table — inside the table's `OnInsert` trigger, but only when `No.` is still blank (so a user may still type a manual number when the series allows it). The record also keeps a non-editable `No. Series` `Code[20]` field recording which series produced the number.
|
||||||
|
|
||||||
|
This is not an `Integer` `AutoIncrement` key, a GUID, or the `SystemId`. Those are surrogate/system identifiers that users never see and cannot quote; BC's whole document flow — lookups, filtering, printed references, telephone support — depends on a short, human-readable, business-controlled `No.`. Use the modern assignment API described in `use-no-series-codeunit-not-noseriesmanagement.md`.
|
||||||
|
|
||||||
|
## Best Practice
|
||||||
|
|
||||||
|
`No.` `Code[20]` is the sole primary key; a non-editable `No. Series` `Code[20]` field records the source series. `OnInsert` checks `if "No." = ''`, reads the setup table, `TestField`s the configured series, stores it in `No. Series`, and assigns `No.` from the series.
|
||||||
|
|
||||||
|
See sample: `master-table-no-from-number-series-in-oninsert.good.al`.
|
||||||
|
|
||||||
|
## Anti Pattern
|
||||||
|
|
||||||
|
An `Integer` `AutoIncrement` (or GUID / `SystemId`) primary key used as the business key, with no `OnInsert` number assignment. Records get an opaque identifier no user can reference, and the master no longer participates in the standard numbering and manual-entry behavior every other BC master follows.
|
||||||
|
|
||||||
|
See sample: `master-table-no-from-number-series-in-oninsert.bad.al`.
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
table 50369 "Loyalty Member"
|
||||||
|
{
|
||||||
|
Caption = 'Loyalty Member';
|
||||||
|
DataClassification = CustomerContent;
|
||||||
|
|
||||||
|
fields
|
||||||
|
{
|
||||||
|
field(1; "No."; Code[20])
|
||||||
|
{
|
||||||
|
Caption = 'No.';
|
||||||
|
}
|
||||||
|
field(10; Name; Text[100])
|
||||||
|
{
|
||||||
|
Caption = 'Name';
|
||||||
|
}
|
||||||
|
field(20; "Last Date Modified"; Date)
|
||||||
|
{
|
||||||
|
Caption = 'Last Date Modified';
|
||||||
|
Editable = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
keys
|
||||||
|
{
|
||||||
|
key(PK; "No.")
|
||||||
|
{
|
||||||
|
Clustered = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
trigger OnModify()
|
||||||
|
begin
|
||||||
|
"Last Date Modified" := Today();
|
||||||
|
end;
|
||||||
|
|
||||||
|
// Missing OnRename: renaming the member changes the primary key without
|
||||||
|
// firing OnModify, so "Last Date Modified" keeps its old, stale value and
|
||||||
|
// change-detection logic downstream skips the renamed record.
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
table 50368 "Loyalty Member"
|
||||||
|
{
|
||||||
|
Caption = 'Loyalty Member';
|
||||||
|
DataClassification = CustomerContent;
|
||||||
|
|
||||||
|
fields
|
||||||
|
{
|
||||||
|
field(1; "No."; Code[20])
|
||||||
|
{
|
||||||
|
Caption = 'No.';
|
||||||
|
}
|
||||||
|
field(10; Name; Text[100])
|
||||||
|
{
|
||||||
|
Caption = 'Name';
|
||||||
|
}
|
||||||
|
field(20; "Last Date Modified"; Date)
|
||||||
|
{
|
||||||
|
Caption = 'Last Date Modified';
|
||||||
|
Editable = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
keys
|
||||||
|
{
|
||||||
|
key(PK; "No.")
|
||||||
|
{
|
||||||
|
Clustered = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
trigger OnModify()
|
||||||
|
begin
|
||||||
|
"Last Date Modified" := Today();
|
||||||
|
end;
|
||||||
|
|
||||||
|
trigger OnRename()
|
||||||
|
begin
|
||||||
|
"Last Date Modified" := Today();
|
||||||
|
end;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
---
|
||||||
|
bc-version: [all]
|
||||||
|
domain: data-modeling
|
||||||
|
keywords: [last-date-modified, onmodify, onrename, audit-field, non-editable, stale-value]
|
||||||
|
technologies: [al]
|
||||||
|
countries: [w1]
|
||||||
|
application-area: [all]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Refresh `Last Date Modified` in both `OnModify` and `OnRename`
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
Master tables carry a non-editable `Last Date Modified` field of type `Date`. It records when the record last changed and is refreshed by table triggers, not by the user. The refresh must happen in **both** `OnModify` and `OnRename`.
|
||||||
|
|
||||||
|
The reason is a BC-specific trap: renaming a record changes its primary key and fires `OnRename` — it does **not** fire `OnModify`. A table that updates `Last Date Modified` only in `OnModify` therefore leaves a stale date behind every rename. Downstream logic that keys on this field (incremental sync, integration deltas, "changed since" reports) then silently skips the renamed record. Assign `Today` (the system date), not `WorkDate`, because the field reflects the real modification moment.
|
||||||
|
|
||||||
|
## Best Practice
|
||||||
|
|
||||||
|
Both `OnModify` and `OnRename` set `"Last Date Modified" := Today();`, and the field is declared `Editable = false` so only the triggers maintain it.
|
||||||
|
|
||||||
|
See sample: `set-last-date-modified-in-onmodify-and-onrename.good.al`.
|
||||||
|
|
||||||
|
## Anti Pattern
|
||||||
|
|
||||||
|
Only `OnModify` assigns `Last Date Modified`. After a rename the value is stale, and any process that trusts it to detect changes misses the record.
|
||||||
|
|
||||||
|
See sample: `set-last-date-modified-in-onmodify-and-onrename.bad.al`.
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
table 50366 "Loyalty Setup"
|
||||||
|
{
|
||||||
|
Caption = 'Loyalty Setup';
|
||||||
|
DataClassification = CustomerContent;
|
||||||
|
|
||||||
|
fields
|
||||||
|
{
|
||||||
|
// Anti-pattern: an autoincrement key lets the table hold many rows,
|
||||||
|
// so "the setup" is no longer a single, well-known record.
|
||||||
|
field(1; "Entry No."; Integer)
|
||||||
|
{
|
||||||
|
Caption = 'Entry No.';
|
||||||
|
AutoIncrement = true;
|
||||||
|
}
|
||||||
|
field(10; "Member Nos."; Code[20])
|
||||||
|
{
|
||||||
|
Caption = 'Member Nos.';
|
||||||
|
TableRelation = "No. Series";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
keys
|
||||||
|
{
|
||||||
|
key(PK; "Entry No.")
|
||||||
|
{
|
||||||
|
Clustered = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
page 50367 "Loyalty Setup List"
|
||||||
|
{
|
||||||
|
// Anti-pattern: a List page over a setup table invites multiple rows and
|
||||||
|
// never guarantees that a row exists to read.
|
||||||
|
Caption = 'Loyalty Setup List';
|
||||||
|
PageType = List;
|
||||||
|
SourceTable = "Loyalty Setup";
|
||||||
|
UsageCategory = Administration;
|
||||||
|
ApplicationArea = All;
|
||||||
|
|
||||||
|
layout
|
||||||
|
{
|
||||||
|
area(Content)
|
||||||
|
{
|
||||||
|
repeater(Group)
|
||||||
|
{
|
||||||
|
field("Member Nos."; Rec."Member Nos.")
|
||||||
|
{
|
||||||
|
ApplicationArea = All;
|
||||||
|
ToolTip = 'Specifies the number series used to assign member numbers.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
table 50364 "Loyalty Setup"
|
||||||
|
{
|
||||||
|
Caption = 'Loyalty Setup';
|
||||||
|
DataClassification = CustomerContent;
|
||||||
|
|
||||||
|
fields
|
||||||
|
{
|
||||||
|
field(1; "Primary Key"; Code[10])
|
||||||
|
{
|
||||||
|
Caption = 'Primary Key';
|
||||||
|
}
|
||||||
|
field(10; "Member Nos."; Code[20])
|
||||||
|
{
|
||||||
|
Caption = 'Member Nos.';
|
||||||
|
TableRelation = "No. Series";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
keys
|
||||||
|
{
|
||||||
|
key(PK; "Primary Key")
|
||||||
|
{
|
||||||
|
Clustered = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
procedure GetRecordOnce()
|
||||||
|
begin
|
||||||
|
if Rec.Get() then
|
||||||
|
exit;
|
||||||
|
Rec.Init();
|
||||||
|
Rec.Insert();
|
||||||
|
end;
|
||||||
|
}
|
||||||
|
|
||||||
|
page 50365 "Loyalty Setup"
|
||||||
|
{
|
||||||
|
Caption = 'Loyalty Setup';
|
||||||
|
PageType = Card;
|
||||||
|
SourceTable = "Loyalty Setup";
|
||||||
|
UsageCategory = Administration;
|
||||||
|
ApplicationArea = All;
|
||||||
|
InsertAllowed = false;
|
||||||
|
DeleteAllowed = false;
|
||||||
|
|
||||||
|
layout
|
||||||
|
{
|
||||||
|
area(Content)
|
||||||
|
{
|
||||||
|
group(Numbering)
|
||||||
|
{
|
||||||
|
Caption = 'Numbering';
|
||||||
|
field("Member Nos."; Rec."Member Nos.")
|
||||||
|
{
|
||||||
|
ApplicationArea = All;
|
||||||
|
ToolTip = 'Specifies the number series used to assign member numbers.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
trigger OnOpenPage()
|
||||||
|
begin
|
||||||
|
Rec.Reset();
|
||||||
|
if not Rec.Get() then begin
|
||||||
|
Rec.Init();
|
||||||
|
Rec.Insert();
|
||||||
|
end;
|
||||||
|
end;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
---
|
||||||
|
bc-version: [all]
|
||||||
|
domain: data-modeling
|
||||||
|
keywords: [setup-table, insertallowed, deleteallowed, getrecordonce, primary-key, card-page]
|
||||||
|
technologies: [al]
|
||||||
|
countries: [w1]
|
||||||
|
application-area: [all]
|
||||||
|
---
|
||||||
|
|
||||||
|
# A setup table is a singleton: one blank-keyed row, no insert or delete
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
An application-area setup table (`Sales & Receivables Setup`, `Inventory Setup`, and any custom `* Setup`) holds exactly one record per company. Its primary key is a single `Code[10]` field named `Primary Key`, and the row's value is left blank. Nothing else identifies the row — there is only ever one.
|
||||||
|
|
||||||
|
The setup **card** page enforces the singleton: `InsertAllowed = false` and `DeleteAllowed = false` stop a second row or an empty table, and the page guarantees the row exists on first open — typically `OnOpenPage` with `if not Rec.Get() then begin Rec.Init(); Rec.Insert(); end;`, or a `GetRecordOnce` helper on the table. Consuming code then reads it with a plain `Get()`. The read side needs no access optimization — see `singleton-setup-tables-need-no-access-optimization.md`.
|
||||||
|
|
||||||
|
## Best Practice
|
||||||
|
|
||||||
|
`Primary Key` `Code[10]` is the sole key; the setup is surfaced through a Card page with `InsertAllowed = false`, `DeleteAllowed = false`, and an open-time guard that inserts the blank row if it is missing.
|
||||||
|
|
||||||
|
See sample: `setup-table-is-a-singleton.good.al`.
|
||||||
|
|
||||||
|
## Anti Pattern
|
||||||
|
|
||||||
|
An `Integer` / `AutoIncrement` key, a page that allows insert or delete, or a List page over the setup table. Any of these lets the table hold zero or many rows, so "the setup" becomes ambiguous and `Get()` may fail or read the wrong record.
|
||||||
|
|
||||||
|
See sample: `setup-table-is-a-singleton.bad.al`.
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
table 50363 "Loyalty Member"
|
||||||
|
{
|
||||||
|
Caption = 'Loyalty Member';
|
||||||
|
DataClassification = CustomerContent;
|
||||||
|
|
||||||
|
fields
|
||||||
|
{
|
||||||
|
field(1; "No."; Code[20])
|
||||||
|
{
|
||||||
|
Caption = 'No.';
|
||||||
|
|
||||||
|
trigger OnValidate()
|
||||||
|
begin
|
||||||
|
if "No." = xRec."No." then
|
||||||
|
exit;
|
||||||
|
LoyaltySetup.Get();
|
||||||
|
// Obsolete-pending: NoSeriesManagement.TestManual raises a
|
||||||
|
// deprecation warning and is scheduled for removal.
|
||||||
|
NoSeriesMgt.TestManual(LoyaltySetup."Member Nos.");
|
||||||
|
"No. Series" := '';
|
||||||
|
end;
|
||||||
|
}
|
||||||
|
field(2; "No. Series"; Code[20])
|
||||||
|
{
|
||||||
|
Caption = 'No. Series';
|
||||||
|
Editable = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
keys
|
||||||
|
{
|
||||||
|
key(PK; "No.")
|
||||||
|
{
|
||||||
|
Clustered = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var
|
||||||
|
LoyaltySetup: Record "Loyalty Setup";
|
||||||
|
NoSeriesMgt: Codeunit NoSeriesManagement;
|
||||||
|
|
||||||
|
trigger OnInsert()
|
||||||
|
begin
|
||||||
|
if "No." = '' then begin
|
||||||
|
LoyaltySetup.Get();
|
||||||
|
LoyaltySetup.TestField("Member Nos.");
|
||||||
|
// Obsolete-pending legacy assignment call; use codeunit "No. Series".
|
||||||
|
NoSeriesMgt.InitSeries(LoyaltySetup."Member Nos.", xRec."No. Series", 0D, "No.", "No. Series");
|
||||||
|
end;
|
||||||
|
end;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
table 50362 "Loyalty Member"
|
||||||
|
{
|
||||||
|
Caption = 'Loyalty Member';
|
||||||
|
DataClassification = CustomerContent;
|
||||||
|
|
||||||
|
fields
|
||||||
|
{
|
||||||
|
field(1; "No."; Code[20])
|
||||||
|
{
|
||||||
|
Caption = 'No.';
|
||||||
|
|
||||||
|
trigger OnValidate()
|
||||||
|
var
|
||||||
|
NoSeries: Codeunit "No. Series";
|
||||||
|
begin
|
||||||
|
if "No." = xRec."No." then
|
||||||
|
exit;
|
||||||
|
LoyaltySetup.Get();
|
||||||
|
if not NoSeries.IsManual(LoyaltySetup."Member Nos.") then
|
||||||
|
Error(ManualNosNotAllowedErr);
|
||||||
|
"No. Series" := '';
|
||||||
|
end;
|
||||||
|
}
|
||||||
|
field(2; "No. Series"; Code[20])
|
||||||
|
{
|
||||||
|
Caption = 'No. Series';
|
||||||
|
Editable = false;
|
||||||
|
TableRelation = "No. Series";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
keys
|
||||||
|
{
|
||||||
|
key(PK; "No.")
|
||||||
|
{
|
||||||
|
Clustered = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var
|
||||||
|
LoyaltySetup: Record "Loyalty Setup";
|
||||||
|
ManualNosNotAllowedErr: Label 'Numbers are assigned automatically. Allow manual numbers on the No. Series to enter one by hand.';
|
||||||
|
|
||||||
|
trigger OnInsert()
|
||||||
|
var
|
||||||
|
NoSeries: Codeunit "No. Series";
|
||||||
|
begin
|
||||||
|
if "No." = '' then begin
|
||||||
|
LoyaltySetup.Get();
|
||||||
|
LoyaltySetup.TestField("Member Nos.");
|
||||||
|
"No. Series" := LoyaltySetup."Member Nos.";
|
||||||
|
"No." := NoSeries.GetNextNo("No. Series");
|
||||||
|
end;
|
||||||
|
end;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
---
|
||||||
|
bc-version: [22..]
|
||||||
|
domain: data-modeling
|
||||||
|
keywords: [no-series, getnextno, ismanual, noseriesmanagement, obsolete-pending, testmanual]
|
||||||
|
technologies: [al]
|
||||||
|
countries: [w1]
|
||||||
|
application-area: [all]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Assign numbers with codeunit `"No. Series"`, not the obsolete `NoSeriesManagement`
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
Since 2023 release wave 1 (v22) the number-series API is codeunit **310** `"No. Series"`, called by name in AL. Its methods include `GetNextNo`, `PeekNextNo`, `IsManual`, `TestManual`, and `LookupRelatedNoSeries`. The older codeunit **396** `NoSeriesManagement` and its `InitSeries` / `SelectSeries` / `SetSeries` / `TestManual` methods are marked obsolete-pending: they still compile but raise a deprecation warning and are scheduled for removal, so they must not appear in new code.
|
||||||
|
|
||||||
|
LLMs reproduce the legacy `NoSeriesManagement` pattern because it dominates pre-2023 training data. Prefer the new codeunit: it has a cleaner surface and is the only version that survives the deprecation. (The numbers matter — `310` is the current codeunit; `396` is the legacy one being retired.) Verify signatures on learn.microsoft.com or in the `microsoft/BCApps` source before use.
|
||||||
|
|
||||||
|
## Best Practice
|
||||||
|
|
||||||
|
`OnInsert` assigns the number with `NoSeries.GetNextNo("No. Series")` where `NoSeries` is `Codeunit "No. Series"`. The `No.` field's `OnValidate` guards manual entry by calling `NoSeries.IsManual(...)` (or `TestManual`) before clearing `No. Series`.
|
||||||
|
|
||||||
|
See sample: `use-no-series-codeunit-not-noseriesmanagement.good.al`.
|
||||||
|
|
||||||
|
## Anti Pattern
|
||||||
|
|
||||||
|
`NoSeriesMgt.InitSeries(...)` for assignment and `NoSeriesMgt.TestManual(...)` for the manual check, where `NoSeriesMgt` is `Codeunit NoSeriesManagement`. Both are obsolete-pending and emit compiler warnings.
|
||||||
|
|
||||||
|
See sample: `use-no-series-codeunit-not-noseriesmanagement.bad.al`.
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
page 50375 "Sample App Area Bad"
|
||||||
|
{
|
||||||
|
PageType = Card;
|
||||||
|
SourceTable = Customer;
|
||||||
|
layout
|
||||||
|
{
|
||||||
|
area(Content)
|
||||||
|
{
|
||||||
|
group(General)
|
||||||
|
{
|
||||||
|
// Anti-pattern: no ApplicationArea. AS0062 flags this control,
|
||||||
|
// and it is silently hidden in the Web client for profiles whose
|
||||||
|
// enabled areas do not already cover it.
|
||||||
|
field("No."; Rec."No.")
|
||||||
|
{
|
||||||
|
ToolTip = 'Specifies the number that identifies the customer.';
|
||||||
|
}
|
||||||
|
field(Name; Rec.Name)
|
||||||
|
{
|
||||||
|
ToolTip = 'Specifies the customer''s name.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
page 50374 "Sample App Area Good"
|
||||||
|
{
|
||||||
|
PageType = Card;
|
||||||
|
SourceTable = Customer;
|
||||||
|
layout
|
||||||
|
{
|
||||||
|
area(Content)
|
||||||
|
{
|
||||||
|
group(General)
|
||||||
|
{
|
||||||
|
field("No."; Rec."No.")
|
||||||
|
{
|
||||||
|
ApplicationArea = All;
|
||||||
|
ToolTip = 'Specifies the number that identifies the customer.';
|
||||||
|
}
|
||||||
|
field(Name; Rec.Name)
|
||||||
|
{
|
||||||
|
ApplicationArea = All;
|
||||||
|
ToolTip = 'Specifies the customer''s name.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
actions
|
||||||
|
{
|
||||||
|
area(Processing)
|
||||||
|
{
|
||||||
|
action(Refresh)
|
||||||
|
{
|
||||||
|
ApplicationArea = All;
|
||||||
|
ToolTip = 'Reloads the current record.';
|
||||||
|
|
||||||
|
trigger OnAction()
|
||||||
|
begin
|
||||||
|
CurrPage.Update(false);
|
||||||
|
end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
---
|
||||||
|
bc-version: [all]
|
||||||
|
domain: style
|
||||||
|
keywords: [application-area, page-control, as0062, appsourcecop, hidden-control, web-client]
|
||||||
|
technologies: [al]
|
||||||
|
countries: [w1]
|
||||||
|
application-area: [all]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Every page control needs an `ApplicationArea` (AppSourceCop AS0062)
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
A field control on a page or pageextension that has no `ApplicationArea` property is silently hidden in the Web client for every profile whose enabled application areas do not cover it. There is no error and no warning at runtime — the field simply does not appear, which reads as data loss to the user. AppSourceCop AS0062 flags any page control or action that is missing the `ApplicationArea` property, and AppSource technical validation rejects the app until it is set.
|
||||||
|
|
||||||
|
Set the property to an area the app actually enables. `All` makes the control visible under every profile and is the common default; if the app declares narrower areas in `app.json`, use one of those. The property applies to field controls and to actions. This is a sibling concern to `caption-required-on-page-fields.md` and `tooltip-required-on-page-fields.md`; note that the ToolTip requirement is the separate CodeCop rule AA0218, not AS0062.
|
||||||
|
|
||||||
|
## Best Practice
|
||||||
|
|
||||||
|
Every field control and action carries `ApplicationArea = All;` (or a declared area of the app). The value is set once per control and keeps the control visible in the Web client.
|
||||||
|
|
||||||
|
See sample: `applicationarea-required-on-page-controls.good.al`.
|
||||||
|
|
||||||
|
## Anti Pattern
|
||||||
|
|
||||||
|
A field control with no `ApplicationArea`. AS0062 flags it, and the control is invisible in the Web client for any profile that does not already enable a matching area.
|
||||||
|
|
||||||
|
See sample: `applicationarea-required-on-page-controls.bad.al`.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue