mirror of
https://github.com/microsoft/BCQuality.git
synced 2026-08-06 09:26:52 +01:00
Integrate current review domains
Merge current main because it added four active AL review leaves, then extend the findings domain contract across all 15 leaves, generic composition guidance, plugin documentation, and the self-review knowledge-upgrade path. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 77d0a40e-8bf5-40ac-a450-40eb0255db03
This commit is contained in:
commit
55aa40eb9e
269 changed files with 4806 additions and 827 deletions
15
.claude-plugin/marketplace.json
Normal file
15
.claude-plugin/marketplace.json
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"name": "bcquality",
|
||||||
|
"owner": {
|
||||||
|
"name": "microsoft/BCQuality",
|
||||||
|
"url": "https://github.com/microsoft/BCQuality"
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "bcquality",
|
||||||
|
"source": "./",
|
||||||
|
"description": "Business Central AL quality knowledge base and review skills, packaged as an installable plugin. Ships the entire BCQuality tree (skills, knowledge, tools) so the Entry routing protocol runs against the installed clone.",
|
||||||
|
"version": "0.1.0"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
9
.claude-plugin/plugin.json
Normal file
9
.claude-plugin/plugin.json
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"name": "bcquality",
|
||||||
|
"description": "Quality skills and knowledge for Business Central development. Exposes a review bridge skill that drives the BCQuality Entry protocol over the installed knowledge base.",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"author": {
|
||||||
|
"name": "microsoft/BCQuality",
|
||||||
|
"url": "https://github.com/microsoft/BCQuality"
|
||||||
|
}
|
||||||
|
}
|
||||||
31
.github/custom-layer-autoclose.md
vendored
Normal file
31
.github/custom-layer-autoclose.md
vendored
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
Hey @{{AUTHOR}} 👋
|
||||||
|
|
||||||
|
First off — thank you for jumping in and experimenting! It's awesome to see people pushing on the framework. 🎉
|
||||||
|
|
||||||
|
That said, let me gently redirect you, because I think there's a small but important misunderstanding about how the `custom` layer is meant to work:
|
||||||
|
|
||||||
|
The `custom` layer in *this* repo isn't a destination for PRs — it's the designated sandbox inside **your own fork**. Think of it as the "your timeline" branch of the multiverse 🌌: this repo is canon, your fork is where you get to remix the lore without needing anyone's approval. That's the whole point of the layer existing — so you *don't* have to upstream your team-specific or experimental work.
|
||||||
|
|
||||||
|
The intended workflow is:
|
||||||
|
|
||||||
|
1. 🍴 **Fork** BCQuality to your own GitHub account
|
||||||
|
2. Clone *your fork* locally
|
||||||
|
3. Drop your custom agents and knowledge into the `custom` layer **there**
|
||||||
|
4. Commit and push to your fork — no PR back to upstream needed for custom stuff
|
||||||
|
|
||||||
|
That way you get full control, your changes survive upstream updates cleanly, and you can pull in new core releases from this repo whenever you want. ✨
|
||||||
|
|
||||||
|
**Now — here's the fun part:** if while building out your fork you discover knowledge, patterns, or agents that you think would genuinely benefit *everyone* using BCQuality (not just your team), that's exactly what the `/community` layer is for! 🌟 PRs to `/community` here in the upstream repo are absolutely welcome and encouraged — it's how the collective hive mind 🧠 levels up. So please: tinker in your fork, and when you strike gold that's worth sharing, send it our way via `/community`.
|
||||||
|
|
||||||
|
Going to close this PR for now (since it's targeting `custom` rather than `/community`), but please don't read it as a "no" — it's a "yes, but let's route it correctly." 🙏 Happy to help if you hit any snags spinning up your fork, and genuinely looking forward to seeing what you contribute to `/community` down the line.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Files in this PR that triggered the auto-close</summary>
|
||||||
|
|
||||||
|
{{FILES}}
|
||||||
|
</details>
|
||||||
|
|
||||||
|
May your merges be conflict-free. 🚀
|
||||||
|
|
||||||
|
---
|
||||||
|
<sub>🤖 This PR was closed automatically by the `Guard custom layer` workflow because it adds or changes content under `/custom/`. If you were only updating the template (`custom/README.md` or a `.gitkeep`), a maintainer can re-open it. If you think this was closed in error, just comment here.</sub>
|
||||||
10
.github/new-top-level-flag.md
vendored
Normal file
10
.github/new-top-level-flag.md
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
<!-- guard:new-top-level -->
|
||||||
|
👋 Heads up @{{AUTHOR}} — and cc maintainers — this PR introduces **new top-level entries** that aren't part of BCQuality's known repository structure:
|
||||||
|
|
||||||
|
{{ENTRIES}}
|
||||||
|
|
||||||
|
This isn't a block — just a flag. 🚩 New top-level folders and files are *usually* unintended (a stray export, a tool's scratch dir, or content that meant to land inside an existing layer like `/community/knowledge/`). BCQuality keeps a deliberately small root: `.github/`, `community/`, `custom/`, `microsoft/`, `skills/`, and `tools/`, plus a handful of root docs.
|
||||||
|
|
||||||
|
**If this was intentional** and the new entry genuinely belongs at the repo root, a maintainer can review and merge as normal — no action needed beyond a quick sanity check. **If it wasn't**, please move the content into the right existing layer (or drop it) and push an update. 🙏
|
||||||
|
|
||||||
|
A maintainer will take a look before merging.
|
||||||
108
.github/workflows/flag-new-top-level.yml
vendored
Normal file
108
.github/workflows/flag-new-top-level.yml
vendored
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
name: Flag new top-level entries
|
||||||
|
|
||||||
|
# BCQuality keeps a deliberately small repository root. New top-level folders
|
||||||
|
# or files are almost always unintended — a stray export, a tool's scratch
|
||||||
|
# directory, or content that meant to land inside an existing layer (e.g.
|
||||||
|
# /community/knowledge/). PR #55 leaked exactly this kind of stray folder.
|
||||||
|
#
|
||||||
|
# Unlike the custom-layer guard, this workflow does NOT close the PR. It only
|
||||||
|
# posts a single advisory comment so a maintainer (and the author) can eyeball
|
||||||
|
# the addition. It reads the PR's file LIST via the API and never checks out or
|
||||||
|
# runs PR code.
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request_target:
|
||||||
|
types: [opened, reopened, synchronize]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
|
issues: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
flag:
|
||||||
|
if: github.repository == 'microsoft/BCQuality'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
sparse-checkout: |
|
||||||
|
.github/new-top-level-flag.md
|
||||||
|
sparse-checkout-cone-mode: false
|
||||||
|
|
||||||
|
- name: Flag unexpected new top-level entries
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
// Known, intended repository root. Anything else added at the root
|
||||||
|
// is flagged for a human to eyeball.
|
||||||
|
const ALLOWED_DIRS = new Set([
|
||||||
|
'.claude-plugin', '.github', 'community', 'custom', 'microsoft', 'skills', 'tools',
|
||||||
|
]);
|
||||||
|
const ALLOWED_FILES = new Set([
|
||||||
|
'.gitignore', 'CODEOWNERS', 'LICENSE', 'README.md',
|
||||||
|
'SECURITY.md', 'agent-consumption.md',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const MARKER = '<!-- guard:new-top-level -->';
|
||||||
|
const { owner, repo } = context.repo;
|
||||||
|
const prNumber = context.payload.pull_request.number;
|
||||||
|
|
||||||
|
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||||
|
owner, repo, pull_number: prNumber, per_page: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only consider newly-added paths — a new top-level entry can only
|
||||||
|
// appear via an added file.
|
||||||
|
const added = files
|
||||||
|
.filter((f) => f.status === 'added')
|
||||||
|
.map((f) => f.filename);
|
||||||
|
|
||||||
|
const newDirs = new Set();
|
||||||
|
const newFiles = new Set();
|
||||||
|
for (const p of added) {
|
||||||
|
const slash = p.indexOf('/');
|
||||||
|
if (slash === -1) {
|
||||||
|
// Top-level file.
|
||||||
|
if (!ALLOWED_FILES.has(p)) newFiles.add(p);
|
||||||
|
} else {
|
||||||
|
// Top-level directory.
|
||||||
|
const dir = p.slice(0, slash);
|
||||||
|
if (!ALLOWED_DIRS.has(dir)) newDirs.add(dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newDirs.size === 0 && newFiles.size === 0) {
|
||||||
|
core.info('No unexpected new top-level entries. Nothing to flag.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idempotency: don't re-flag on every synchronize.
|
||||||
|
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||||
|
owner, repo, issue_number: prNumber, per_page: 100,
|
||||||
|
});
|
||||||
|
if (comments.some((c) => c.body && c.body.includes(MARKER))) {
|
||||||
|
core.info('Already flagged on this PR. Skipping duplicate comment.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = [];
|
||||||
|
for (const d of [...newDirs].sort()) lines.push(`- 📁 \`${d}/\` (new top-level folder)`);
|
||||||
|
for (const f of [...newFiles].sort()) lines.push(`- 📄 \`${f}\` (new top-level file)`);
|
||||||
|
const entries = lines.join('\n');
|
||||||
|
|
||||||
|
core.warning(`Unexpected new top-level entries: ${[...newDirs, ...newFiles].join(', ')}`);
|
||||||
|
|
||||||
|
let body = fs.readFileSync('.github/new-top-level-flag.md', 'utf8');
|
||||||
|
body = body
|
||||||
|
.replace(/{{AUTHOR}}/g, context.payload.pull_request.user.login)
|
||||||
|
.replace(/{{ENTRIES}}/g, entries);
|
||||||
|
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner, repo, issue_number: prNumber, body,
|
||||||
|
});
|
||||||
|
|
||||||
|
core.info(`Flagged PR #${prNumber}.`);
|
||||||
88
.github/workflows/guard-custom-layer.yml
vendored
Normal file
88
.github/workflows/guard-custom-layer.yml
vendored
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
name: Guard custom layer
|
||||||
|
|
||||||
|
# The /custom/ layer is a template: in upstream microsoft/BCQuality it stays
|
||||||
|
# empty by default (README.md + .gitkeep placeholders only). Custom knowledge
|
||||||
|
# and skills are partner/customer-specific and belong in a fork, never upstream.
|
||||||
|
#
|
||||||
|
# This workflow auto-closes any PR that adds or changes content under /custom/
|
||||||
|
# (anything beyond the allowed template files). It runs only on the upstream
|
||||||
|
# repo, so forks that legitimately populate /custom/ are unaffected.
|
||||||
|
#
|
||||||
|
# pull_request_target is required so the workflow runs with a token that can
|
||||||
|
# comment on and close the PR (including PRs opened from forks). It only reads
|
||||||
|
# the PR's file LIST via the API and never checks out or executes PR code, so
|
||||||
|
# the elevated token is not exposed to untrusted content.
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request_target:
|
||||||
|
types: [opened, reopened, synchronize]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
|
issues: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
guard:
|
||||||
|
# Never run on forks — a fork's /custom/ content is exactly what's supposed
|
||||||
|
# to live there.
|
||||||
|
if: github.repository == 'microsoft/BCQuality'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
sparse-checkout: |
|
||||||
|
.github/custom-layer-autoclose.md
|
||||||
|
sparse-checkout-cone-mode: false
|
||||||
|
|
||||||
|
- name: Close PR if it touches the custom layer
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
// Files under custom/ that ARE allowed to change (the template seed).
|
||||||
|
const ALLOWED = new Set([
|
||||||
|
'custom/README.md',
|
||||||
|
]);
|
||||||
|
// Any .gitkeep under custom/ is also allowed.
|
||||||
|
const isAllowed = (p) =>
|
||||||
|
ALLOWED.has(p) || /^custom\/.*\.gitkeep$/.test(p) || p === 'custom/.gitkeep';
|
||||||
|
|
||||||
|
const { owner, repo } = context.repo;
|
||||||
|
const prNumber = context.payload.pull_request.number;
|
||||||
|
|
||||||
|
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||||
|
owner, repo, pull_number: prNumber, per_page: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Offending = added/modified/renamed/copied/changed paths under custom/
|
||||||
|
// that are not template files. (We ignore pure deletions.)
|
||||||
|
const offending = files
|
||||||
|
.filter((f) => f.status !== 'removed')
|
||||||
|
.map((f) => f.filename)
|
||||||
|
.filter((p) => p.startsWith('custom/') && !isAllowed(p));
|
||||||
|
|
||||||
|
if (offending.length === 0) {
|
||||||
|
core.info('No disallowed /custom/ changes found. Nothing to do.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
core.warning(`PR #${prNumber} touches the custom layer: ${offending.join(', ')}`);
|
||||||
|
|
||||||
|
const fileList = offending.map((p) => `- \`${p}\``).join('\n');
|
||||||
|
let body = fs.readFileSync('.github/custom-layer-autoclose.md', 'utf8');
|
||||||
|
body = body
|
||||||
|
.replace(/{{AUTHOR}}/g, context.payload.pull_request.user.login)
|
||||||
|
.replace(/{{FILES}}/g, fileList);
|
||||||
|
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner, repo, issue_number: prNumber, body,
|
||||||
|
});
|
||||||
|
|
||||||
|
await github.rest.pulls.update({
|
||||||
|
owner, repo, pull_number: prNumber, state: 'closed',
|
||||||
|
});
|
||||||
|
|
||||||
|
core.info(`Closed PR #${prNumber}.`);
|
||||||
69
.github/workflows/release-version.yml
vendored
Normal file
69
.github/workflows/release-version.yml
vendored
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
# Cuts a BCQuality content release on demand (roughly monthly), NOT on every
|
||||||
|
# commit. Run this workflow manually once the `main` content is ready, and choose
|
||||||
|
# whether to bump the minor (usual periodic content update) or the major
|
||||||
|
# (breaking change).
|
||||||
|
#
|
||||||
|
# The version is a `major.minor` value derived from existing git tags — there is
|
||||||
|
# no VERSION file. The minor is a monotonic counter: it only ever increments and
|
||||||
|
# never resets, even across a major bump, so it uniquely identifies a release.
|
||||||
|
# This workflow computes the next version and tags the current commit as
|
||||||
|
# `v{major}.{minor}`.
|
||||||
|
|
||||||
|
name: Release version
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
bump:
|
||||||
|
description: Which part to bump
|
||||||
|
type: choice
|
||||||
|
options:
|
||||||
|
- minor
|
||||||
|
- major
|
||||||
|
default: minor
|
||||||
|
|
||||||
|
# Only tag creation needs write.
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: release-version
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Compute and tag release
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
git fetch --tags --force --quiet
|
||||||
|
tags="$(git tag -l | grep -E '^v[0-9]+\.[0-9]+$' || true)"
|
||||||
|
|
||||||
|
if [[ -z "$tags" ]]; then
|
||||||
|
# First release.
|
||||||
|
major=1
|
||||||
|
minor=0
|
||||||
|
else
|
||||||
|
latest_major="$(printf '%s\n' "$tags" | sed -E 's/^v([0-9]+)\..*/\1/' | sort -n | tail -1)"
|
||||||
|
latest_minor="$(printf '%s\n' "$tags" | sed -E 's/^v[0-9]+\.([0-9]+)$/\1/' | sort -n | tail -1)"
|
||||||
|
minor=$(( latest_minor + 1 )) # monotonic, never resets
|
||||||
|
if [[ "${{ inputs.bump }}" == "major" ]]; then
|
||||||
|
major=$(( latest_major + 1 ))
|
||||||
|
else
|
||||||
|
major="$latest_major"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
tag="v${major}.${minor}"
|
||||||
|
if git rev-parse -q --verify "refs/tags/${tag}" >/dev/null; then
|
||||||
|
echo "::error::Tag ${tag} already exists"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
git tag "$tag" "${{ github.sha }}"
|
||||||
|
git push origin "$tag"
|
||||||
|
echo "Released BCQuality ${tag} at ${{ github.sha }}"
|
||||||
12
README.md
12
README.md
|
|
@ -136,6 +136,18 @@ For the end-to-end flow — from orchestrator trigger through to how output reac
|
||||||
│ └── /skills/
|
│ └── /skills/
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Versioning
|
||||||
|
|
||||||
|
BCQuality content is released on demand — roughly monthly, not on every commit. A
|
||||||
|
release is a `major.minor` value derived from git tags, cut manually via the
|
||||||
|
`Release version` workflow: pick whether to bump the minor or the major, and it
|
||||||
|
computes the next version and tags the current `main` as `v{major}.{minor}`.
|
||||||
|
|
||||||
|
- Bump the **minor** for the usual periodic content update; bump the **major**
|
||||||
|
only for a breaking change.
|
||||||
|
- The minor is a **monotonic counter** — it only ever increments and never
|
||||||
|
resets, even across a major bump — so it uniquely identifies a release.
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
Contributions are welcome. Before submitting a PR:
|
Contributions are welcome. Before submitting a PR:
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ flowchart LR
|
||||||
E -->|3 dispatch record| A
|
E -->|3 dispatch record| A
|
||||||
A -->|4 invoke dispatched skill| S[Action skill<br/>e.g. al-code-review]
|
A -->|4 invoke dispatched skill| S[Action skill<br/>e.g. al-code-review]
|
||||||
S -->|5 execute| P[Source → Relevance<br/>→ Worklist → Action<br/>reading READ · DO on demand]
|
S -->|5 execute| P[Source → Relevance<br/>→ Worklist → Action<br/>reading READ · DO on demand]
|
||||||
P -->|6 emit| R[Findings · References<br/>· Confidence]
|
P -->|6 emit| R[Findings · Domain labels<br/>· References · Confidence]
|
||||||
R -->|7 integrate| O
|
R -->|7 integrate| O
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -65,6 +65,7 @@ The output contract is defined in the DO meta-skill so that every action skill
|
||||||
|
|
||||||
- **Outcome** — `completed`, `not-applicable`, `no-knowledge`, `partial`, or `failed`. An orchestrator can distinguish a clean run from a no-op from a failure without guessing.
|
- **Outcome** — `completed`, `not-applicable`, `no-knowledge`, `partial`, or `failed`. An orchestrator can distinguish a clean run from a no-op from a failure without guessing.
|
||||||
- **Findings** — what the skill observed (severity, message, optional location).
|
- **Findings** — what the skill observed (severity, message, optional location).
|
||||||
|
- **Domain** — the producer-owned, human-readable display label on each review finding.
|
||||||
- **References** — structured objects (`path` plus optional commit `sha`) pointing to the knowledge files that informed each finding.
|
- **References** — structured objects (`path` plus optional commit `sha`) pointing to the knowledge files that informed each finding.
|
||||||
- **Confidence** — per-finding evidence strength.
|
- **Confidence** — per-finding evidence strength.
|
||||||
- **Suppressed** — knowledge files that were discarded by layer precedence or configuration, so reviewers can see what was overridden.
|
- **Suppressed** — knowledge files that were discarded by layer precedence or configuration, so reviewers can see what was overridden.
|
||||||
|
|
@ -78,12 +79,12 @@ The orchestrator turns findings into PR comments, build gates, or IDE diagnostic
|
||||||
|
|
||||||
BCQuality is an **additive** knowledge layer. The agent surfaces two kinds of findings, both shaped to the same DO output contract:
|
BCQuality is an **additive** knowledge layer. The agent surfaces two kinds of findings, both shaped to the same DO output contract:
|
||||||
|
|
||||||
- **Knowledge-backed findings** carry one or more entries in `references[]` pointing at BCQuality knowledge files. Their `id` is the primary file's repo-relative path. These are produced by leaf sub-skills and rolled up by super-skills.
|
- **Knowledge-backed findings** carry one or more entries in `references[]` pointing at BCQuality knowledge files. Their `id` is the primary file's repo-relative path. Leaf sub-skills set `domain` to their human-readable display label, and super-skills preserve it verbatim during rollup.
|
||||||
- **Agent findings** are surfaced by a super-skill from its own self-review pass when no BCQuality knowledge file backs the concern. They are tagged with `from-sub-skill: "agent"`, carry an empty `references: []`, use a slug `id` prefixed `agent:`, and have `confidence` capped at `medium`. Their `message` is self-contained because there is no knowledge-file footer to fall back on.
|
- **Agent findings** carry an empty `references: []`, use a slug `id` prefixed `agent:`, and have `confidence` capped at `medium`. A leaf can emit one strictly within its own domain and uses that leaf's display label. A super-skill can emit a cross-cutting agent finding with `from-sub-skill: "agent"` and `domain: "Agent"`. Their `message` is self-contained because there is no knowledge-file footer to fall back on.
|
||||||
|
|
||||||
Before a super-skill emits an agent finding, it validates the candidate against the BCQuality knowledge already loaded for the task: a matching file upgrades the candidate to a knowledge-backed finding (and merges or deduplicates against the relevant sub-skill output); a contradicting file suppresses the candidate. Only candidates with no BCQuality coverage become agent findings.
|
Before a skill emits an agent finding, it validates the candidate against the BCQuality knowledge already loaded for the task: a matching file upgrades the candidate to a knowledge-backed finding (and merges or deduplicates against relevant existing output); a contradicting file suppresses the candidate. Only candidates with no BCQuality coverage become agent findings.
|
||||||
|
|
||||||
Orchestrators MAY render the two kinds differently — for example, by labelling agent findings or routing them to a separate review domain — and MAY apply independent severity floors. The `from-sub-skill: "agent"` marker is the contract.
|
Orchestrators SHOULD render a non-empty `domain` value verbatim and MUST tolerate its absence for reports from older producers. They MAY render knowledge-backed and agent findings differently and MAY apply independent severity floors; `references: []` and the `agent:` id prefix distinguish agent findings, while `from-sub-skill: "agent"` identifies those emitted by the super-skill itself.
|
||||||
|
|
||||||
## Why this architecture
|
## Why this architecture
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
---
|
|
||||||
bc-version: [all]
|
|
||||||
domain: performance
|
|
||||||
keywords: [maintainsiftindex, sift, calcsums, flowfield, write-cost]
|
|
||||||
technologies: [al]
|
|
||||||
countries: [w1]
|
|
||||||
application-area: [all]
|
|
||||||
---
|
|
||||||
|
|
||||||
# Choose MaintainSIFTIndex by read-write ratio
|
|
||||||
|
|
||||||
> Contributions welcome — open a PR to refine or extend this article.
|
|
||||||
|
|
||||||
## Description
|
|
||||||
|
|
||||||
`MaintainSIFTIndex` on a key decides whether the SIFT aggregate structure is updated on every `INSERT`, `MODIFY`, and `DELETE` that touches the key's fields. With `Yes`, `CalcSums` and FlowField reads are immediate — but every write pays the cost of updating the aggregate. With `No`, writes are cheaper but the first aggregate read after a change has to rebuild. Neither value is universally correct; the right choice depends on how often the aggregate is read versus how often the underlying rows are written.
|
|
||||||
|
|
||||||
## Best Practice
|
|
||||||
|
|
||||||
Measure read-to-write ratios for the key's SIFT fields under realistic workloads. Set `MaintainSIFTIndex = Yes` only on keys whose aggregates are read far more often than the rows are written (reporting keys on reference tables, dashboards). Set `No` on keys whose rows are written heavily and whose aggregates are read rarely (transactional ledger entries, import-staging tables).
|
|
||||||
|
|
||||||
See sample: `choose-maintainsiftindex-by-read-write-ratio.good.al`.
|
|
||||||
|
|
||||||
## Anti Pattern
|
|
||||||
|
|
||||||
Leaving `MaintainSIFTIndex = Yes` on every key by reflex or convenience. On write-heavy tables the cumulative cost turns every INSERT or MODIFY into several additional aggregate updates, and the impact compounds in batch imports and posting routines — often without any code-review signal that the property is the cause.
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
codeunit 50100 "Sales Document Processor"
|
|
||||||
{
|
|
||||||
procedure ProcessDocument(var SalesHeader: Record "Sales Header")
|
|
||||||
begin
|
|
||||||
// Single top-level load pulls every field any branch might touch.
|
|
||||||
// Order records pay for Posting Date and Amount Including VAT that
|
|
||||||
// only the Invoice branch reads, and vice versa.
|
|
||||||
SalesHeader.SetLoadFields(
|
|
||||||
"Document Type", "No.", "Sell-to Customer No.",
|
|
||||||
"Order Date", "Shipment Date", "Completely Shipped",
|
|
||||||
"Posting Date", "Amount Including VAT");
|
|
||||||
|
|
||||||
case SalesHeader."Document Type" of
|
|
||||||
SalesHeader."Document Type"::Order:
|
|
||||||
ProcessOrder(SalesHeader);
|
|
||||||
SalesHeader."Document Type"::Invoice:
|
|
||||||
ProcessInvoice(SalesHeader);
|
|
||||||
end;
|
|
||||||
end;
|
|
||||||
|
|
||||||
local procedure ProcessOrder(var SalesHeader: Record "Sales Header") begin end;
|
|
||||||
local procedure ProcessInvoice(var SalesHeader: Record "Sales Header") begin end;
|
|
||||||
}
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
codeunit 50100 "Sales Document Processor"
|
|
||||||
{
|
|
||||||
procedure ProcessDocument(var SalesHeader: Record "Sales Header")
|
|
||||||
begin
|
|
||||||
// Tier 1: the discriminator and any fields every branch reads.
|
|
||||||
SalesHeader.SetLoadFields("Document Type", "No.", "Sell-to Customer No.");
|
|
||||||
|
|
||||||
case SalesHeader."Document Type" of
|
|
||||||
SalesHeader."Document Type"::Order:
|
|
||||||
begin
|
|
||||||
// Tier 2: extend the load only on the branch that needs these fields.
|
|
||||||
SalesHeader.SetLoadFields("Order Date", "Shipment Date", "Completely Shipped");
|
|
||||||
ProcessOrder(SalesHeader);
|
|
||||||
end;
|
|
||||||
SalesHeader."Document Type"::Invoice:
|
|
||||||
begin
|
|
||||||
SalesHeader.SetLoadFields("Posting Date", "Amount Including VAT");
|
|
||||||
ProcessInvoice(SalesHeader);
|
|
||||||
end;
|
|
||||||
end;
|
|
||||||
end;
|
|
||||||
|
|
||||||
local procedure ProcessOrder(var SalesHeader: Record "Sales Header") begin end;
|
|
||||||
local procedure ProcessInvoice(var SalesHeader: Record "Sales Header") begin end;
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
---
|
|
||||||
bc-version: [all]
|
|
||||||
domain: performance
|
|
||||||
keywords: [setloadfields, case, conditional, branch, field-loading]
|
|
||||||
technologies: [al]
|
|
||||||
countries: [w1]
|
|
||||||
application-area: [all]
|
|
||||||
---
|
|
||||||
|
|
||||||
# Load common fields before branching on case
|
|
||||||
|
|
||||||
> Contributions welcome — open a PR to refine or extend this article.
|
|
||||||
|
|
||||||
## Description
|
|
||||||
|
|
||||||
When record processing branches on state, different branches typically read different fields. A single `SetLoadFields` at the top listing every field any branch might touch pulls more data than any individual execution path needs — on the hot path, the rest is loaded for nothing. A two-tier approach matches loading to actual usage: load the fields the `case` expression evaluates plus any fields every branch uses, then add a branch-local `SetLoadFields` inside each branch for that branch's extra fields.
|
|
||||||
|
|
||||||
## Best Practice
|
|
||||||
|
|
||||||
Before the `case`, call `SetLoadFields` with the minimal set — the discriminator field and fields common to every branch. Inside each branch, before the first access to a branch-specific field, add a second `SetLoadFields` covering those fields. The platform honors the in-branch call for the next record operation, so the extra data is fetched only when the branch runs.
|
|
||||||
|
|
||||||
See sample: `load-common-fields-before-branching-on-case.good.al`.
|
|
||||||
|
|
||||||
## Anti Pattern
|
|
||||||
|
|
||||||
A single top-level `SetLoadFields` enumerating every field any branch might read. On records whose state routes them to the fast common branch, the rarely-needed fields are still loaded — the optimization becomes a net-neutral or net-negative change on the hot path.
|
|
||||||
|
|
||||||
See sample: `load-common-fields-before-branching-on-case.bad.al`.
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
codeunit 50100 "Recent Orders Summary"
|
|
||||||
{
|
|
||||||
procedure SummarizeRecentOrders(StartDate: Date; EndDate: Date)
|
|
||||||
var
|
|
||||||
SalesHeader: Record "Sales Header";
|
|
||||||
begin
|
|
||||||
// "Document Type" and "Document Date" are listed in SetLoadFields even
|
|
||||||
// though they appear only in filters. Per-row values are transferred
|
|
||||||
// for columns the processing body never reads.
|
|
||||||
SalesHeader.SetLoadFields(
|
|
||||||
"Document Type", "Document Date",
|
|
||||||
"No.", "Sell-to Customer No.", "Amount Including VAT");
|
|
||||||
|
|
||||||
SalesHeader.SetRange("Document Type", SalesHeader."Document Type"::Order);
|
|
||||||
SalesHeader.SetRange("Document Date", StartDate, EndDate);
|
|
||||||
|
|
||||||
if SalesHeader.FindSet() then
|
|
||||||
repeat
|
|
||||||
Emit(SalesHeader."No.", SalesHeader."Sell-to Customer No.", SalesHeader."Amount Including VAT");
|
|
||||||
until SalesHeader.Next() = 0;
|
|
||||||
end;
|
|
||||||
|
|
||||||
local procedure Emit(No: Code[20]; CustNo: Code[20]; Amount: Decimal) begin end;
|
|
||||||
}
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
codeunit 50100 "Recent Orders Summary"
|
|
||||||
{
|
|
||||||
procedure SummarizeRecentOrders(StartDate: Date; EndDate: Date)
|
|
||||||
var
|
|
||||||
SalesHeader: Record "Sales Header";
|
|
||||||
begin
|
|
||||||
// "Document Type" and "Document Date" are used only in the filters below.
|
|
||||||
// The database index handles them; there is no need to load their values
|
|
||||||
// into AL memory for every row.
|
|
||||||
SalesHeader.SetLoadFields("No.", "Sell-to Customer No.", "Amount Including VAT");
|
|
||||||
|
|
||||||
SalesHeader.SetRange("Document Type", SalesHeader."Document Type"::Order);
|
|
||||||
SalesHeader.SetRange("Document Date", StartDate, EndDate);
|
|
||||||
|
|
||||||
if SalesHeader.FindSet() then
|
|
||||||
repeat
|
|
||||||
Emit(SalesHeader."No.", SalesHeader."Sell-to Customer No.", SalesHeader."Amount Including VAT");
|
|
||||||
until SalesHeader.Next() = 0;
|
|
||||||
end;
|
|
||||||
|
|
||||||
local procedure Emit(No: Code[20]; CustNo: Code[20]; Amount: Decimal) begin end;
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
---
|
|
||||||
bc-version: [all]
|
|
||||||
domain: performance
|
|
||||||
keywords: [setloadfields, filter, field-exclusion, index]
|
|
||||||
technologies: [al]
|
|
||||||
countries: [w1]
|
|
||||||
application-area: [all]
|
|
||||||
---
|
|
||||||
|
|
||||||
# Omit filter-only fields from SetLoadFields
|
|
||||||
|
|
||||||
> Contributions welcome — open a PR to refine or extend this article.
|
|
||||||
|
|
||||||
## Description
|
|
||||||
|
|
||||||
Fields used only in `SetRange` and `SetFilter` do their work at the database level using indexes; their values never need to be loaded into AL memory for the filter to apply. Listing such fields in `SetLoadFields` costs the transfer and memory footprint of every row's value for no functional benefit. Distinguishing filter-only fields from processing fields keeps the loaded column set as narrow as the iterating code actually reads.
|
|
||||||
|
|
||||||
## Best Practice
|
|
||||||
|
|
||||||
Include in `SetLoadFields` exactly the fields the iterating code reads. Fields referenced only in `SetRange`/`SetFilter` stay out of the list — filtering continues to work correctly because the database uses the index. Treat the audit as "what does the `repeat…until` block touch?" rather than "what does this procedure mention?".
|
|
||||||
|
|
||||||
See sample: `omit-filter-only-fields-from-setloadfields.good.al`.
|
|
||||||
|
|
||||||
## Anti Pattern
|
|
||||||
|
|
||||||
Listing every field the procedure mentions in `SetLoadFields`, including date-range or status fields that appear only in filters. The loaded record now carries per-row values for columns the processing body never reads, inflating memory and network cost without changing any behavior.
|
|
||||||
|
|
||||||
See sample: `omit-filter-only-fields-from-setloadfields.bad.al`.
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
---
|
|
||||||
bc-version: [all]
|
|
||||||
domain: performance
|
|
||||||
keywords: [case, branch, frequency, control-flow, hot-path]
|
|
||||||
technologies: [al]
|
|
||||||
countries: [w1]
|
|
||||||
application-area: [all]
|
|
||||||
---
|
|
||||||
|
|
||||||
# Order case branches by frequency
|
|
||||||
|
|
||||||
> Contributions welcome — open a PR to refine or extend this article.
|
|
||||||
|
|
||||||
## Description
|
|
||||||
|
|
||||||
The AL `case` statement evaluates branches in the order they appear. When the distribution of the discriminator is heavily skewed — one or two values handle the vast majority of records, and the rest handle edge cases — the average cost of the statement is dominated by how many branches precede the common one. For evenly distributed discriminators the order does not matter; for skewed distributions it changes the hot-path cost of every call site.
|
|
||||||
|
|
||||||
## Best Practice
|
|
||||||
|
|
||||||
Where the runtime frequency of values is known or measurable, list the common branches first. An `else` arm that handles unexpected values belongs last. When the common branch is also the simplest to evaluate, the placement compounds: the hot path is both short and cheap, and the uncommon branches are never touched on typical records.
|
|
||||||
|
|
||||||
See sample: `order-case-branches-by-frequency.good.al`.
|
|
||||||
|
|
||||||
## Anti Pattern
|
|
||||||
|
|
||||||
Ordering branches alphabetically, by enum declaration order, or by "logical grouping" when the runtime distribution is heavily skewed. Every common record pays the cost of evaluating every uncommon branch first; on a posting routine processing thousands of rows the overhead is measurable.
|
|
||||||
|
|
||||||
See sample: `order-case-branches-by-frequency.bad.al`.
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
codeunit 50100 "Stale Quote Cleanup"
|
|
||||||
{
|
|
||||||
procedure ClearExpiredQuotes(CutoffDate: Date)
|
|
||||||
var
|
|
||||||
SalesHeader: Record "Sales Header";
|
|
||||||
begin
|
|
||||||
SalesHeader.SetRange("Document Type", SalesHeader."Document Type"::Quote);
|
|
||||||
SalesHeader.SetFilter("Document Date", '<%1', CutoffDate);
|
|
||||||
SalesHeader.SetRange(Status, SalesHeader.Status::Open);
|
|
||||||
|
|
||||||
// One SQL DELETE per row. On a 10k-row cleanup, minutes instead of
|
|
||||||
// under a second - and the OnDelete trigger has no logic this call
|
|
||||||
// needs to run.
|
|
||||||
if SalesHeader.FindSet() then
|
|
||||||
repeat
|
|
||||||
SalesHeader.Delete();
|
|
||||||
until SalesHeader.Next() = 0;
|
|
||||||
end;
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
codeunit 50100 "Stale Quote Cleanup"
|
|
||||||
{
|
|
||||||
procedure ClearExpiredQuotes(CutoffDate: Date)
|
|
||||||
var
|
|
||||||
SalesHeader: Record "Sales Header";
|
|
||||||
begin
|
|
||||||
// OnDelete on Sales Header carries no logic this call depends on:
|
|
||||||
// expired quotes have no ledger entries, shipments, or downstream state.
|
|
||||||
SalesHeader.SetRange("Document Type", SalesHeader."Document Type"::Quote);
|
|
||||||
SalesHeader.SetFilter("Document Date", '<%1', CutoffDate);
|
|
||||||
SalesHeader.SetRange(Status, SalesHeader.Status::Open);
|
|
||||||
|
|
||||||
// Single SQL DELETE. Orders of magnitude faster than FindSet + Delete
|
|
||||||
// once the filtered set exceeds a handful of rows.
|
|
||||||
SalesHeader.DeleteAll();
|
|
||||||
end;
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
---
|
|
||||||
bc-version: [all]
|
|
||||||
domain: performance
|
|
||||||
keywords: [deleteall, bulk-delete, sql, ondelete, trigger-bypass]
|
|
||||||
technologies: [al]
|
|
||||||
countries: [w1]
|
|
||||||
application-area: [all]
|
|
||||||
---
|
|
||||||
|
|
||||||
# Use DeleteAll for filtered bulk deletion
|
|
||||||
|
|
||||||
> Contributions welcome — open a PR to refine or extend this article.
|
|
||||||
|
|
||||||
## Description
|
|
||||||
|
|
||||||
`DeleteAll` translates to a single SQL `DELETE` with the record variable's current filters applied as the WHERE clause. A loop of `FindSet` + `Delete` instead issues one SQL statement per row. On any dataset larger than a handful of records, the gap is an order of magnitude or more. The tradeoff is that `DeleteAll` bypasses the `OnDelete` table trigger, so the decision hinges on whether that trigger's logic is required for this specific deletion.
|
|
||||||
|
|
||||||
## Best Practice
|
|
||||||
|
|
||||||
After narrowing the record set with `SetRange`/`SetFilter`, use `DeleteAll` whenever the `OnDelete` trigger has no logic that this call depends on — typically the case for housekeeping routines, staging-table cleanup, and deletions already validated upstream. When the trigger IS required, either keep the explicit loop-plus-`Delete` pattern and comment why, or pre-run the trigger logic against a temporary buffer and then `DeleteAll` the primary table.
|
|
||||||
|
|
||||||
See sample: `use-deleteall-for-filtered-bulk-deletion.good.al`.
|
|
||||||
|
|
||||||
## Anti Pattern
|
|
||||||
|
|
||||||
Iterating with `FindSet` + `Delete` to clear a filtered set of records that carry no meaningful `OnDelete` logic. Every row pays a full AL round-trip; on a ten-thousand-row cleanup the loop can take minutes where `DeleteAll` takes under a second.
|
|
||||||
|
|
||||||
See sample: `use-deleteall-for-filtered-bulk-deletion.bad.al`.
|
|
||||||
20
community/knowledge/ui/factbox-design.md
Normal file
20
community/knowledge/ui/factbox-design.md
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
---
|
||||||
|
bc-version: [all]
|
||||||
|
domain: ui
|
||||||
|
keywords: [factbox, subpagelink, listpart, cardpart, page-part, related-information, flowfield-sift]
|
||||||
|
technologies: [al]
|
||||||
|
countries: [w1]
|
||||||
|
application-area: [all]
|
||||||
|
---
|
||||||
|
# Filter ListPart FactBoxes With SubPageLink To The Parent Record
|
||||||
|
|
||||||
|
> Contributions welcome — open a PR to refine or extend this article.
|
||||||
|
|
||||||
|
## Description
|
||||||
|
A FactBox is a page `part` that surfaces related data beside the main record so users avoid navigating away. Every FactBox runs a database query as its host page loads, so an unfiltered one is a hidden performance tax paid on every page open. The remedial trap: a `ListPart` FactBox with no `SubPageLink` does not show "the related rows" — it loads and pages through the entire source table, because nothing ties it to the host record. This makes correct `SubPageLink` linkage, not visual layout, the load-bearing design decision.
|
||||||
|
|
||||||
|
## Best Practice
|
||||||
|
Give every `ListPart` FactBox a `SubPageLink` that maps a field on the part's source table to a `field()` of the host record (for example `SubPageLink = "Document No." = field("No.")`), so it returns only rows belonging to the current record. Prefer a `CardPart` when you only need summary figures (balance, availability, status) — it reads a single record and avoids list overhead entirely. When a FactBox shows FlowFields, ensure the calculated total is backed by a SIFT key (`MaintainSIFTIndex`) so the sum is read from the index rather than aggregated row-by-row on each load. Keep FactBox count modest and avoid heavy `OnAfterGetRecord` logic in the part.
|
||||||
|
|
||||||
|
## Anti Pattern
|
||||||
|
Adding a `ListPart` FactBox without a `SubPageLink`, expecting it to "just show related lines." The consequence is a full-table scan on every page load that grows with the dataset and is felt worst on list pages, where the FactBox re-queries on each row selection. Reviewer signal: any `part(...)` referencing a list-type page part where the `SubPageLink` property is absent, or a FactBox FlowField filtered on non-indexed fields. A second smell is duplicating data already on the page or stacking many FactBoxes, which multiplies queries for little context gain.
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
---
|
||||||
|
bc-version: [27..]
|
||||||
|
domain: appsource
|
||||||
|
keywords: [app-json, help-url, copilot, grounding, documentation, url-depth, contexturl]
|
||||||
|
technologies: [al]
|
||||||
|
countries: [w1]
|
||||||
|
application-area: [all]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Keep the Copilot help URL to two path levels
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
The `help` URL declared in `app.json` is what Copilot uses to ground answers about your app. That URL may be at most **two path levels** deep (for example `https://contoso.com/docs/myapp`). If you point it at a deeper path (three or more segments), Copilot does not use the URL as given: it truncates to the first two levels, drops any fragments and query strings, and then grounds on **all** content beneath that two-level path. The failure is silent — there is no build error — and the practical effect is worse answers, because Copilot may ingest sibling apps' documentation that lives under the same two-level parent.
|
||||||
|
|
||||||
|
## Best Practice
|
||||||
|
|
||||||
|
Organize per-app documentation so the canonical help page sits no deeper than two path levels, and confirm during testing that Copilot citations resolve to your app's content rather than a broader parent. If your docs naturally nest deeper, give each app a dedicated two-level path it owns.
|
||||||
|
|
||||||
|
## Anti Pattern
|
||||||
|
|
||||||
|
Setting `help` to a deep, tidy-looking docs path such as `https://contoso.com/docs/products/erp/myapp/setup`. Copilot truncates it to `…/docs/products`, then grounds on everything under that node — pulling in unrelated content and degrading answer quality for your users.
|
||||||
|
|
@ -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`.
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
codeunit 50305 "Net Amount Api Good"
|
codeunit 50305 "Net Amount Api Good"
|
||||||
{
|
{
|
||||||
// Old name kept and marked obsolete: callers still compile but get a warning
|
// Old name kept during the warning window. The tag records when obsoletion
|
||||||
// pointing at the replacement, with a tag recording the removal target version.
|
// began; a later release deletes the method after consumers have migrated.
|
||||||
[Obsolete('Use CalculateNetAmount instead.', '25.0')]
|
[Obsolete('Use CalculateNetAmount instead.', '25.0')]
|
||||||
procedure CalcNet(GrossAmount: Decimal; TaxRate: Decimal): Decimal
|
procedure CalcNet(GrossAmount: Decimal; TaxRate: Decimal): Decimal
|
||||||
begin
|
begin
|
||||||
|
|
|
||||||
|
|
@ -11,16 +11,16 @@ application-area: [all]
|
||||||
|
|
||||||
## Description
|
## Description
|
||||||
|
|
||||||
Deleting or renaming a published procedure (or object) in a single release is a hard break: dependent extensions that reference it stop compiling the moment they pick up the new version, with no warning window to migrate. AL provides a staged deprecation lifecycle precisely so consumers get advance notice. For a procedure, apply the `[Obsolete('reason', 'tag')]` attribute: the member keeps working but every caller gets a compiler warning naming the replacement and the target version. The member stays through a deprecation window — at least one major release — before it is finally removed. Object- and field-level members use the matching `ObsoleteState = Pending` → `Removed` property progression. LLMs trained to "clean up" code often delete or rename the old member immediately, skipping the window entirely.
|
Deleting or renaming a published procedure (or object) in a single release is a hard break: dependent extensions that reference it stop compiling the moment they pick up the new version, with no warning window to migrate. AL provides staged deprecation so consumers get advance notice. A procedure uses `[Obsolete('reason', 'tag')]`: it remains callable but callers receive a compiler warning naming the replacement and the version in which obsoletion began. Methods do not have `ObsoleteState`; after the deprecation window, the method is deleted, commonly through versioned preprocessor cleanup. Objects and fields instead use the `ObsoleteState = Pending` to `Removed` property progression.
|
||||||
|
|
||||||
## Best Practice
|
## Best Practice
|
||||||
|
|
||||||
When a published procedure is superseded, keep it in place and mark it `[Obsolete('Use CalculateNetAmount instead.', '25.0')]`, where the message names the replacement and the tag records the target version for removal. Have the obsolete member forward to the new one so behavior is preserved during the window. Only after the deprecation window has elapsed — a later release — change its state to removed. This gives every dependent app a compile-time signal and time to migrate before anything actually disappears.
|
When a published procedure is superseded, keep it in place and mark it `[Obsolete('Use CalculateNetAmount instead.', '25.0')]`, where the message names the replacement and the tag records when the method became obsolete. Have the obsolete member forward to the new one so behavior is preserved during the window. Only after the deprecation window has elapsed should a later release delete the method. For an object or field, use `Pending` during the warning window and `Removed` afterward.
|
||||||
|
|
||||||
See sample: `deprecate-public-members-with-the-obsolete-lifecycle.good.al`.
|
See sample: `deprecate-public-members-with-the-obsolete-lifecycle.good.al`.
|
||||||
|
|
||||||
## Anti Pattern
|
## Anti Pattern
|
||||||
|
|
||||||
Renaming or deleting the published `CalcNet` procedure in place — replacing it with `CalculateNetAmount` and nothing else — so consumers calling `CalcNet` break immediately with no deprecation notice. Detection: a previously shipped non-`local` procedure that vanished or was renamed between versions with no `[Obsolete]` marker left behind on a kept member. Mark it obsolete and keep it for a window instead.
|
Renaming or deleting the published `CalcNet` procedure in place — replacing it with `CalculateNetAmount` and nothing else — so consumers calling `CalcNet` break immediately with no deprecation notice. Detection: a previously shipped non-`local` procedure that vanished or was renamed between versions with no `[Obsolete]` marker left behind during a prior warning window. Do not suggest `ObsoleteState = Removed` for a method; that property belongs to supported object and element types.
|
||||||
|
|
||||||
See sample: `deprecate-public-members-with-the-obsolete-lifecycle.bad.al`.
|
See sample: `deprecate-public-members-with-the-obsolete-lifecycle.bad.al`.
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
codeunit 50320 "Payment Client Good"
|
codeunit 50320 "Payment Client Good"
|
||||||
{
|
{
|
||||||
var
|
var
|
||||||
AccessToken: Text;
|
AccessToken: SecretText;
|
||||||
|
|
||||||
// Credential flows inward through an internal setter and never leaves the object.
|
// Credential remains SecretText as it flows inward and is stored.
|
||||||
internal procedure SetAccessToken(NewToken: Text)
|
internal procedure SetAccessToken(NewToken: SecretText)
|
||||||
begin
|
begin
|
||||||
AccessToken := NewToken;
|
AccessToken := NewToken;
|
||||||
end;
|
end;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
---
|
---
|
||||||
bc-version: [all]
|
bc-version: [23..]
|
||||||
domain: breaking-changes
|
domain: breaking-changes
|
||||||
keywords: [sensitive-data, secrettext, token, credential, public-api, access-boundary]
|
keywords: [sensitive-data, secrettext, token, credential, public-api, access-boundary]
|
||||||
technologies: [al]
|
technologies: [al]
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,9 @@ table 50311 "Customer Profile Bad"
|
||||||
fields
|
fields
|
||||||
{
|
{
|
||||||
field(1; "No."; Code[20]) { }
|
field(1; "No."; Code[20]) { }
|
||||||
// Breaking: the published "Email" field was renamed in place. Dependent
|
// Breaking: the published field was renamed while retaining ID 2.
|
||||||
// extensions that reference "Email" stop compiling, and the data stored in
|
// AppSourceCop AS0005 rejects the compatibility change; retaining the ID
|
||||||
// the old column is orphaned on upgrade.
|
// does not by itself mean the stored column was dropped and re-created.
|
||||||
field(2; "Contact Email"; Text[80]) { }
|
field(2; "Contact Email"; Text[80]) { }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,20 +7,20 @@ countries: [w1]
|
||||||
application-area: [all]
|
application-area: [all]
|
||||||
---
|
---
|
||||||
|
|
||||||
# Obsolete published table fields instead of deleting or renaming them
|
# Obsolete published table fields instead of deleting or renumbering them
|
||||||
|
|
||||||
## Description
|
## Description
|
||||||
|
|
||||||
A table field that has shipped carries two contracts at once: extensions reference it by name, and the database holds data in its column. Deleting the field, or renaming it (which the platform treats as drop-plus-add), breaks dependent code at compile time and discards the stored data — a silent data-loss event on upgrade. The fix is the same staged lifecycle used for objects: set `ObsoleteState = Pending` together with `ObsoleteReason` and an `ObsoleteTag` naming the target version, ship the new field alongside, migrate data during the window, and only switch the old field to `ObsoleteState = Removed` in a later release once nothing depends on it. LLMs often "tidy" a schema by renaming a field in place, not realizing this is both a breaking change and a data-loss risk.
|
A shipped table field carries both a source-level contract and persisted data. Renaming a field while retaining its ID is prohibited by AppSourceCop AS0005 and can break dependent extensions, but it is not inherently a drop-and-readd operation and should not be described as automatic data loss. Deleting the field or replacing it under a different ID is the data-loss risk: the old field storage is no longer represented unless data is migrated. The supported path is to keep the old field and obsolete it, add a replacement under a new ID, and migrate values before later removal.
|
||||||
|
|
||||||
## Best Practice
|
## Best Practice
|
||||||
|
|
||||||
Add the replacement field, then mark the old field `ObsoleteState = Pending` with an `ObsoleteReason` that names the replacement and an `ObsoleteTag` carrying the target version (for example `'25.0'`). Keep the obsolete field readable so an upgrade codeunit can copy its data into the new field during the deprecation window. Move it to `ObsoleteState = Removed` only in a later major version, after the window has passed and data has migrated.
|
Add the replacement field under a new ID, then mark the old field `ObsoleteState = Pending` with an `ObsoleteReason` that names the replacement and an `ObsoleteTag` recording the obsoletion version. Keep the old field readable so an upgrade codeunit can copy its data during the deprecation window. Move it to `ObsoleteState = Removed` only in a later release, after the window has passed and data has migrated.
|
||||||
|
|
||||||
See sample: `obsolete-table-fields-instead-of-deleting-them.good.al`.
|
See sample: `obsolete-table-fields-instead-of-deleting-them.good.al`.
|
||||||
|
|
||||||
## Anti Pattern
|
## Anti Pattern
|
||||||
|
|
||||||
Renaming the published `Email` field to `Contact Email` directly in the table — or deleting it — so dependent extensions that reference `Email` break and the column's stored values are orphaned on upgrade. Detection: a previously shipped field removed or renamed in a table or table extension with no `ObsoleteState = Pending` step preserving the original. Obsolete the field through the lifecycle instead.
|
Renaming published `Email` to `Contact Email` with the same ID violates the compatibility contract and AS0005, even though the retained ID does not itself imply a fresh empty column. Deleting `Email` or moving the replacement to another ID without migration additionally risks losing its stored values. Detection: a previously shipped field removed, renumbered, or renamed with no retained `Pending` field and migration path.
|
||||||
|
|
||||||
See sample: `obsolete-table-fields-instead-of-deleting-them.bad.al`.
|
See sample: `obsolete-table-fields-instead-of-deleting-them.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`.
|
||||||
|
|
@ -15,10 +15,13 @@ codeunit 50185 "Collect Errors Good Sample"
|
||||||
until Item.Next() = 0;
|
until Item.Next() = 0;
|
||||||
|
|
||||||
if HasCollectedErrors() then begin
|
if HasCollectedErrors() then begin
|
||||||
CollectedErrors := GetCollectedErrors();
|
// The default is false; true retrieves and clears the collection.
|
||||||
|
CollectedErrors := GetCollectedErrors(true);
|
||||||
|
// This blocking aggregate intentionally retains messages only.
|
||||||
foreach CollectedError in CollectedErrors do
|
foreach CollectedError in CollectedErrors do
|
||||||
ErrorText += CollectedError.Message() + '\';
|
ErrorText += CollectedError.Message() + '\';
|
||||||
Message('The following must be fixed before posting:\%1', ErrorText);
|
Error(ErrorInfo.Create(
|
||||||
|
StrSubstNo('The following must be fixed before posting:\%1', ErrorText), false));
|
||||||
end;
|
end;
|
||||||
end;
|
end;
|
||||||
}
|
}
|
||||||
|
|
@ -30,8 +33,10 @@ codeunit 50186 "Collect Errors Item Check"
|
||||||
trigger OnRun()
|
trigger OnRun()
|
||||||
begin
|
begin
|
||||||
if Rec.Description = '' then
|
if Rec.Description = '' then
|
||||||
Error('Item %1 has no description.', Rec."No.");
|
Error(ErrorInfo.Create(
|
||||||
|
StrSubstNo('Item %1 has no description.', Rec."No."), true));
|
||||||
if Rec."Unit Cost" <= 0 then
|
if Rec."Unit Cost" <= 0 then
|
||||||
Error('Item %1 must have a positive unit cost.', Rec."No.");
|
Error(ErrorInfo.Create(
|
||||||
|
StrSubstNo('Item %1 must have a positive unit cost.', Rec."No."), true));
|
||||||
end;
|
end;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
---
|
---
|
||||||
bc-version: [all]
|
bc-version: [19..]
|
||||||
domain: error-handling
|
domain: error-handling
|
||||||
keywords: [collectible-errors, errorbehavior, collect, getcollectederrors, hascollectederrors, validation, batch]
|
keywords: [collectible-errors, errorbehavior, collect, getcollectederrors, hascollectederrors, validation, batch]
|
||||||
technologies: [al]
|
technologies: [al]
|
||||||
|
|
@ -11,16 +11,16 @@ application-area: [all]
|
||||||
|
|
||||||
## Description
|
## Description
|
||||||
|
|
||||||
By default a procedure stops on the first `Error`, so a user fixing ten bad rows must rerun the operation ten times. The collectible-errors feature postpones error handling to the end of the call: a procedure attributed `[ErrorBehavior(ErrorBehavior::Collect)]` keeps running as errors occur and gathers them, so all failures can be presented together. The collected errors are read with `HasCollectedErrors()` and `GetCollectedErrors()` (which returns a `List of [ErrorInfo]`); `ClearCollectedErrors()` empties the buffer. This is a platform mechanism most LLMs are unaware of — they reach for a manually concatenated `Text` buffer or a temporary error table instead.
|
By default a procedure stops on the first `Error`, so a user fixing ten bad rows must rerun the operation ten times. The collectible-errors feature postpones error handling to the end of the call: a procedure attributed `[ErrorBehavior(ErrorBehavior::Collect)]` keeps running as collectible errors occur and gathers them, so all failures can be presented together. `GetCollectedErrors()` returns a `List of [ErrorInfo]` for the handler to inspect, but does not clear the collection by default; pass `true` to retrieve and clear in one call, or call `ClearCollectedErrors()` explicitly after retrieving. A handler can copy record information into a custom error page as Microsoft Learn demonstrates, or deliberately format only the messages into a final blocking error as this article's sample does.
|
||||||
|
|
||||||
## Best Practice
|
## Best Practice
|
||||||
|
|
||||||
Mark the orchestrating procedure `[ErrorBehavior(ErrorBehavior::Collect)]` and run each item's validation so one failure doesn't abandon the rest — typically by calling the per-item routine through `Codeunit.Run`. When the run finishes, inspect `HasCollectedErrors()` and surface `GetCollectedErrors()` to the user as a single list. Always handle the collected errors yourself: the platform's own guidance is that any errors still in the collected list when the procedure ends are concatenated into one dialog, which is hard for users to read.
|
Mark the orchestrating procedure `[ErrorBehavior(ErrorBehavior::Collect)]` and run each item's validation so one failure doesn't abandon the rest — typically by calling the per-item routine through `Codeunit.Run`. When the run finishes, inspect `HasCollectedErrors()`, retrieve and clear the list with `GetCollectedErrors(true)`, and fail the operation with the collected messages. The sample intentionally produces a text aggregate and does not claim to retain record/field metadata in the final error. If that metadata is needed, map each `ErrorInfo` to a custom error UI before clearing, following the Microsoft Learn pattern. Do not replace validation failure with `Message`: clearing collected errors suppresses the platform failure, so the custom handler must still block the invalid operation.
|
||||||
|
|
||||||
See sample: `collect-validation-errors-with-errorbehavior.good.al`.
|
See sample: `collect-validation-errors-with-errorbehavior.good.al`.
|
||||||
|
|
||||||
## Anti Pattern
|
## Anti Pattern
|
||||||
|
|
||||||
Two shapes signal trouble. The first is hand-rolled accumulation — appending messages to a `Text` variable and showing them at the end — which reimplements the platform feature, loses each error's `ErrorInfo` structure, and skips telemetry classification. The second is applying `[ErrorBehavior(ErrorBehavior::Collect)]` but never calling `HasCollectedErrors`/`GetCollectedErrors`, so every collected error spills into the platform's concatenated end-of-procedure dialog. Detection: a `Collect` attribute with no matching `GetCollectedErrors` call, or a per-row loop that builds an error string by concatenation.
|
Three shapes signal trouble. Hand-rolled accumulation reimplements collection and prevents the handler from receiving individual `ErrorInfo` values. A `Collect` procedure that never handles the collection falls back to the concatenated platform dialog. Finally, code that calls parameterless `GetCollectedErrors()`, assumes it cleared the list, and only shows a `Message` can both leave the errors collected and allow invalid processing to continue.
|
||||||
|
|
||||||
See sample: `collect-validation-errors-with-errorbehavior.bad.al`.
|
See sample: `collect-validation-errors-with-errorbehavior.bad.al`.
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ codeunit 50190 "Error Type Good Sample"
|
||||||
if not BucketInitialized(BucketId) then begin
|
if not BucketInitialized(BucketId) then begin
|
||||||
InternalErr.ErrorType := ErrorType::Internal;
|
InternalErr.ErrorType := ErrorType::Internal;
|
||||||
InternalErr.Message := StrSubstNo('Ledger bucket %1 was not initialized before posting.', BucketId);
|
InternalErr.Message := StrSubstNo('Ledger bucket %1 was not initialized before posting.', BucketId);
|
||||||
InternalErr.DetailedMessage := 'Internal invariant violated. Inspect the call stack captured in telemetry.';
|
|
||||||
Error(InternalErr);
|
Error(InternalErr);
|
||||||
end;
|
end;
|
||||||
end;
|
end;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
---
|
---
|
||||||
bc-version: [all]
|
bc-version: [14..]
|
||||||
domain: error-handling
|
domain: error-handling
|
||||||
keywords: [errorinfo, errortype, internal, client, telemetry, diagnostics, generic-message]
|
keywords: [errorinfo, errortype, internal, client, telemetry, diagnostics, generic-message]
|
||||||
technologies: [al]
|
technologies: [al]
|
||||||
|
|
@ -15,7 +15,7 @@ application-area: [all]
|
||||||
|
|
||||||
## Best Practice
|
## Best Practice
|
||||||
|
|
||||||
Reserve `ErrorType::Internal` for errors the user cannot act on: corrupted internal state, an unreachable branch, a contract a caller violated. Set a precise, detail-rich `Message` and `DetailedMessage` for telemetry, raise it via `Error(ErrorInfo)`, and let the platform show the user a generic dialog. Keep `ErrorType::Client` (or a plain `Error`) for failures the user is expected to read and resolve — validation messages, missing setup, business-rule violations. The test is simple: if the message only makes sense to a developer, mark it `Internal`.
|
Reserve `ErrorType::Internal` for errors the user cannot act on: corrupted internal state, an unreachable branch, a contract a caller violated. Set a precise, detail-rich `Message` for telemetry, raise it via `Error(ErrorInfo)`, and let the platform show the user a generic dialog. Keep `ErrorType::Client` (or a plain `Error`) for failures the user is expected to read and resolve — validation messages, missing setup, business-rule violations. The test is simple: if the message only makes sense to a developer, mark it `Internal`.
|
||||||
|
|
||||||
See sample: `errortype-internal-vs-client-for-diagnostics.good.al`.
|
See sample: `errortype-internal-vs-client-for-diagnostics.good.al`.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
table 50120 "FieldError Default Bad"
|
||||||
|
{
|
||||||
|
fields
|
||||||
|
{
|
||||||
|
field(1; "No."; Code[20]) { }
|
||||||
|
field(2; "Discount %"; Decimal) { }
|
||||||
|
field(3; "Currency Code"; Code[10]) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
procedure ValidateForRelease()
|
||||||
|
begin
|
||||||
|
// This re-tests a field and gives FieldError a fully formed sentence.
|
||||||
|
// The framework already prepends the caption and appends the value,
|
||||||
|
// so this renders as "Currency Code The Currency Code field must have
|
||||||
|
// a value. in ..." — caption repeated, capital letter mid-sentence,
|
||||||
|
// stray trailing clause.
|
||||||
|
if "Currency Code" = '' then
|
||||||
|
FieldError("Currency Code", 'The Currency Code field must have a value.');
|
||||||
|
|
||||||
|
if "Discount %" > 100 then
|
||||||
|
FieldError("Discount %", 'The Discount % must not be greater than 100 percent.');
|
||||||
|
end;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
table 50120 "FieldError Default Good"
|
||||||
|
{
|
||||||
|
fields
|
||||||
|
{
|
||||||
|
field(1; "No."; Code[20]) { }
|
||||||
|
field(2; "Discount %"; Decimal) { }
|
||||||
|
field(3; "Currency Code"; Code[10]) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
procedure ValidateForRelease()
|
||||||
|
begin
|
||||||
|
// TestField checks this required-field condition and raises the error
|
||||||
|
// with caption and record context supplied by the framework.
|
||||||
|
TestField("Currency Code");
|
||||||
|
|
||||||
|
// Condition already evaluated: pass only a lowercase predicate so it
|
||||||
|
// reads as one sentence after the auto-inserted caption and value.
|
||||||
|
if "Discount %" > 100 then
|
||||||
|
FieldError("Discount %", 'cannot exceed 100');
|
||||||
|
end;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
---
|
||||||
|
bc-version: [all]
|
||||||
|
domain: error-handling
|
||||||
|
keywords: [fielderror, testfield, error-message, field-caption, lowercase-convention, record-context, validation]
|
||||||
|
technologies: [al]
|
||||||
|
countries: [w1]
|
||||||
|
application-area: [all]
|
||||||
|
---
|
||||||
|
# Rely On FieldError's Auto-Generated Context And Pass Only A Lowercase Predicate
|
||||||
|
|
||||||
|
## Description
|
||||||
|
`Rec.FieldError(FieldNo)` does not just print the text you give it. Business Central automatically prepends the field caption, appends the current field value (when non-blank), and suffixes the table name and primary-key values for record identification. The optional second argument is only the middle predicate of that sentence — e.g. `"must be unique"`, not a whole self-contained message. Misunderstanding this leads to messages that duplicate the caption and value or read as broken grammar, because the framework's surrounding text is built to join a lowercase fragment.
|
||||||
|
|
||||||
|
## Best Practice
|
||||||
|
For a plain required-field check, prefer `TestField`, which tests the condition and raises the error in one call. When the condition is non-trivial and has already been evaluated, call `FieldError(FieldNo)` with no message to get the localized default (`must have a value`, `is not valid`, etc.), or pass a short lowercase predicate such as `FieldError(FieldNo, 'must be a positive number')`. Start the custom text with a lowercase letter so it reads as one sentence with the auto-inserted caption, and use a field-number reference (or the field token) rather than a hard-coded field name so captions and translations stay correct. Let the framework supply the caption, value, table, and key context for you.
|
||||||
|
|
||||||
|
## Anti Pattern
|
||||||
|
Re-testing a condition you already evaluated, or passing a fully formed sentence like `'The Amount field must be positive.'` to `FieldError`. The result reads as `Amount The Amount field must be positive. in Gen. Journal Line ...` — capital letter mid-sentence, caption and value repeated, and a stray trailing clause. Reviewer signals: a `FieldError` argument that names the field, restates the current value, starts with a capital letter, or ends with a period. Each is a sign the author treated `FieldError` like `Error` instead of as a predicate slotted into framework-generated context.
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
table 50122 "FieldError vs TestField Bad"
|
||||||
|
{
|
||||||
|
fields
|
||||||
|
{
|
||||||
|
field(1; "No."; Code[20]) { }
|
||||||
|
field(2; "Posting Date"; Date) { }
|
||||||
|
field(3; "Amount"; Decimal) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
procedure PostDocument()
|
||||||
|
begin
|
||||||
|
// FieldError performs no comparison and raises as soon as it is
|
||||||
|
// reached, so this "check" terminates PostDocument every time — the
|
||||||
|
// Posting Date is never actually tested, and the amount rule below is
|
||||||
|
// dead code.
|
||||||
|
FieldError("Posting Date", 'must be filled in');
|
||||||
|
|
||||||
|
if IsAmountOutsideAllowedRange("Amount") then
|
||||||
|
Error('Amount is out of range.');
|
||||||
|
end;
|
||||||
|
|
||||||
|
local procedure IsAmountOutsideAllowedRange(Value: Decimal): Boolean
|
||||||
|
begin
|
||||||
|
exit((Value < 0) or (Value > 1000000));
|
||||||
|
end;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
table 50122 "FieldError vs TestField Good"
|
||||||
|
{
|
||||||
|
fields
|
||||||
|
{
|
||||||
|
field(1; "No."; Code[20]) { }
|
||||||
|
field(2; "Posting Date"; Date) { }
|
||||||
|
field(3; "Amount"; Decimal) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
procedure PostDocument()
|
||||||
|
begin
|
||||||
|
// TestField performs this simple presence check and raises only when
|
||||||
|
// the field is empty. Self-documenting prerequisite.
|
||||||
|
TestField("Posting Date");
|
||||||
|
|
||||||
|
// Business logic has already determined the value is invalid;
|
||||||
|
// FieldError raises a tailored, record-aware message with no
|
||||||
|
// condition of its own.
|
||||||
|
if IsAmountOutsideAllowedRange("Amount") then
|
||||||
|
FieldError("Amount", 'is outside the approved posting range');
|
||||||
|
end;
|
||||||
|
|
||||||
|
local procedure IsAmountOutsideAllowedRange(Value: Decimal): Boolean
|
||||||
|
begin
|
||||||
|
exit((Value < 0) or (Value > 1000000));
|
||||||
|
end;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
---
|
||||||
|
bc-version: [all]
|
||||||
|
domain: error-handling
|
||||||
|
keywords: [fielderror, testfield, field-validation, onvalidate, error-message, mandatory-field, record-context]
|
||||||
|
technologies: [al]
|
||||||
|
countries: [w1]
|
||||||
|
application-area: [all]
|
||||||
|
---
|
||||||
|
# Choose `TestField` For Conditional Checks And `FieldError` For Already-Failed Validation
|
||||||
|
|
||||||
|
## Description
|
||||||
|
`TestField` and `FieldError` look interchangeable but behave differently, and choosing the wrong one produces either dead code or a check that never fires. `TestField` performs the comparison itself and throws only when the field is empty or does not match the supplied value; `FieldError` performs no comparison and always raises an error the moment it is reached. Both attach the field caption and the record's primary-key context to the message automatically, which is why neither should be replaced by a hand-built `Error` call that interpolates the field name as a literal.
|
||||||
|
|
||||||
|
## Best Practice
|
||||||
|
Use `TestField` when the condition is a simple presence-or-equality check on a single field — mandatory-field gates and prerequisite checks at the top of a procedure read clearly and self-document intent. Use `FieldError` inside an `OnValidate` trigger or a validation procedure where surrounding business logic has already determined the value is invalid and you want a specific, custom message. Rely on the built-in field-and-record context both methods add rather than re-stating the field name in the text.
|
||||||
|
|
||||||
|
## Anti Pattern
|
||||||
|
Calling `FieldError` to "test" a field — placing it on a path that is reached unconditionally and expecting it to validate — terminates execution every time because `FieldError` never evaluates a condition. The inverse smell is reaching for `TestField` when the rule needs a tailored message, then bolting a vague generic string onto a check that cannot express the real business reason. A reviewer can spot the first by a `FieldError` that is not guarded by a preceding `if`, and the second by a `TestField` whose intent comment describes a condition more complex than presence or equality.
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
codeunit 50301 "Try Return Bad"
|
||||||
|
{
|
||||||
|
procedure ImportDocument()
|
||||||
|
begin
|
||||||
|
// Ignoring the Boolean result makes this an ordinary, throwing call.
|
||||||
|
TryImportDocument();
|
||||||
|
end;
|
||||||
|
|
||||||
|
[TryFunction]
|
||||||
|
local procedure TryImportDocument()
|
||||||
|
begin
|
||||||
|
Error(SourceRejectedErr);
|
||||||
|
end;
|
||||||
|
|
||||||
|
var
|
||||||
|
SourceRejectedErr: Label 'The source document was rejected.';
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
codeunit 50300 "Try Return Good"
|
||||||
|
{
|
||||||
|
procedure ImportDocument()
|
||||||
|
begin
|
||||||
|
if not TryImportDocument() then
|
||||||
|
Error(ImportFailedErr);
|
||||||
|
end;
|
||||||
|
|
||||||
|
[TryFunction]
|
||||||
|
local procedure TryImportDocument()
|
||||||
|
begin
|
||||||
|
Error(SourceRejectedErr);
|
||||||
|
end;
|
||||||
|
|
||||||
|
var
|
||||||
|
ImportFailedErr: Label 'The document could not be imported.';
|
||||||
|
SourceRejectedErr: Label 'The source document was rejected.';
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
---
|
||||||
|
bc-version: [13..]
|
||||||
|
domain: error-handling
|
||||||
|
keywords: [tryfunction, try-method, boolean-return, ignored-return-value, error-propagation]
|
||||||
|
technologies: [al]
|
||||||
|
countries: [w1]
|
||||||
|
application-area: [all]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Consume a TryFunction return value to enable try semantics
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
A procedure marked `[TryFunction]` catches errors only when the caller uses its Boolean return value. An assignment or conditional makes the invocation a try-method call; a bare call is treated as an ordinary procedure call and exposes errors as usual. The attribute alone does not make every invocation non-throwing.
|
||||||
|
|
||||||
|
## Best Practice
|
||||||
|
|
||||||
|
Consume the result directly: assign it to a Boolean or use the call in an `if` condition. Handle `false` immediately while the last-error state still describes that failure.
|
||||||
|
|
||||||
|
See sample: `ignored-tryfunction-return-disables-try-semantics.good.al`.
|
||||||
|
|
||||||
|
## Anti Pattern
|
||||||
|
|
||||||
|
Calling a `[TryFunction]` procedure as a standalone statement and assuming the attribute suppresses its errors. The call has ordinary error semantics because its Boolean result is ignored.
|
||||||
|
|
||||||
|
See sample: `ignored-tryfunction-return-disables-try-semantics.bad.al`.
|
||||||
|
|
@ -1,21 +1,14 @@
|
||||||
// Demonstration-only AL. Not compiled by CI; illustrates the article.
|
// Demonstration-only AL. Version 1 exposed PostDocument(SalesHeader).
|
||||||
codeunit 50251 "Param Append Bad Sample"
|
codeunit 50251 "Param Append Bad Sample"
|
||||||
{
|
{
|
||||||
procedure PostDocument(var SalesHeader: Record "Sales Header"; CalledFromBatch: Boolean)
|
procedure PostDocument(var SalesHeader: Record "Sales Header")
|
||||||
var
|
|
||||||
IsHandled: Boolean;
|
|
||||||
begin
|
begin
|
||||||
IsHandled := false;
|
// Existing callers cannot supply the newly required argument.
|
||||||
// Anti-pattern: 'CalledFromBatch' was inserted before the existing
|
OnBeforePostDocument(SalesHeader);
|
||||||
// IsHandled parameter, shifting it and breaking the argument positions
|
|
||||||
// every existing subscriber relied on.
|
|
||||||
OnBeforePostDocument(SalesHeader, CalledFromBatch, IsHandled);
|
|
||||||
if IsHandled then
|
|
||||||
exit;
|
|
||||||
end;
|
end;
|
||||||
|
|
||||||
[IntegrationEvent(false, false)]
|
[IntegrationEvent(false, false)]
|
||||||
local procedure OnBeforePostDocument(var SalesHeader: Record "Sales Header"; CalledFromBatch: Boolean; var IsHandled: Boolean)
|
procedure OnBeforePostDocument(var SalesHeader: Record "Sales Header"; CalledFromBatch: Boolean)
|
||||||
begin
|
begin
|
||||||
end;
|
end;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
// Demonstration-only AL. Not compiled by CI; illustrates the article.
|
// Demonstration-only AL. Version 1 had SalesHeader and IsHandled parameters.
|
||||||
codeunit 50250 "Param Append Good Sample"
|
codeunit 50250 "Param Append Good Sample"
|
||||||
{
|
{
|
||||||
procedure PostDocument(var SalesHeader: Record "Sales Header"; CalledFromBatch: Boolean)
|
procedure PostDocument(var SalesHeader: Record "Sales Header"; CalledFromBatch: Boolean)
|
||||||
|
|
@ -6,15 +6,24 @@ codeunit 50250 "Param Append Good Sample"
|
||||||
IsHandled: Boolean;
|
IsHandled: Boolean;
|
||||||
begin
|
begin
|
||||||
IsHandled := false;
|
IsHandled := false;
|
||||||
// The new 'CalledFromBatch' parameter was appended at the end of the
|
// Subscribers bind by name, so the new parameter can sit between the
|
||||||
// existing signature, so existing subscribers needed no re-mapping.
|
// existing parameters without breaking subscribers that omit it.
|
||||||
OnBeforePostDocument(SalesHeader, IsHandled, CalledFromBatch);
|
OnBeforePostDocument(SalesHeader, CalledFromBatch, IsHandled);
|
||||||
if IsHandled then
|
if IsHandled then
|
||||||
exit;
|
exit;
|
||||||
end;
|
end;
|
||||||
|
|
||||||
[IntegrationEvent(false, false)]
|
[IntegrationEvent(false, false)]
|
||||||
local procedure OnBeforePostDocument(var SalesHeader: Record "Sales Header"; var IsHandled: Boolean; CalledFromBatch: Boolean)
|
local procedure OnBeforePostDocument(var SalesHeader: Record "Sales Header"; CalledFromBatch: Boolean; var IsHandled: Boolean)
|
||||||
begin
|
begin
|
||||||
end;
|
end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
codeunit 50252 "Existing Param Subscriber"
|
||||||
|
{
|
||||||
|
[EventSubscriber(ObjectType::Codeunit, Codeunit::"Param Append Good Sample", 'OnBeforePostDocument', '', false, false)]
|
||||||
|
local procedure OnBeforePostDocument(var SalesHeader: Record "Sales Header"; var IsHandled: Boolean)
|
||||||
|
begin
|
||||||
|
IsHandled := SalesHeader."No." = '';
|
||||||
|
end;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,26 @@
|
||||||
---
|
---
|
||||||
bc-version: [all]
|
bc-version: [all]
|
||||||
domain: events
|
domain: events
|
||||||
keywords: [event-parameters, signature, backward-compatibility, append, onbefore, integration-event, versioning]
|
keywords: [event-parameters, signature, backward-compatibility, public-event, local-event, internal-event, appsourcecop, as0024, as0025]
|
||||||
technologies: [al]
|
technologies: [al]
|
||||||
countries: [w1]
|
countries: [w1]
|
||||||
application-area: [all]
|
application-area: [all]
|
||||||
---
|
---
|
||||||
|
|
||||||
# Add new event parameters at the end
|
# Event parameter additions depend on publisher access, not position
|
||||||
|
|
||||||
## Description
|
## Description
|
||||||
|
|
||||||
Adding a parameter to an existing event publisher changes its signature. Appending the new parameter at the end of the parameter list keeps the change easy to review and track: existing subscribers still bind to the leading parameters, and the diff is a single clean addition. Inserting a parameter in the middle makes diffs noisy and harder to review, and obscures the history of how the signature evolved. New parameters belong after the existing ones.
|
Event subscribers bind publisher parameters by name and can omit parameters they do not use. A `local` or `internal` Business or Integration event can therefore gain a parameter at any position without breaking subscriber-only consumers; appending is not a compatibility requirement. A public event is also a public procedure that dependent extensions can raise, so adding a required parameter anywhere breaks callers under AppSourceCop AS0024.
|
||||||
|
|
||||||
## Best Practice
|
## Best Practice
|
||||||
|
|
||||||
When extending an existing publisher, append the new parameter after all existing ones, including after a trailing `var IsHandled: Boolean` when present. Subscribers that already match keep working against the leading parameters, and the change stays a one-line addition that is trivial to review.
|
Add a parameter directly only when the shipped event publisher is `local` or `internal`. Place it where the signature is clearest; existing subscribers continue binding the parameters they name. For a public event, keep the original publisher unchanged and introduce a new event with the expanded contract.
|
||||||
|
|
||||||
See sample: `add-new-event-parameters-at-the-end.good.al`.
|
See sample: `add-new-event-parameters-at-the-end.good.al`.
|
||||||
|
|
||||||
## Anti Pattern
|
## Anti Pattern
|
||||||
|
|
||||||
Inserting a new parameter in the middle of an existing event's signature, shifting every subsequent parameter and making the change noisy and harder to review. Detection: a changed event signature where an added parameter appears before existing parameters rather than at the tail of the list.
|
Appending a parameter to a public event and assuming its position makes the change compatible. Existing external callers still lack the new required argument. Conversely, do not flag a parameter inserted among existing parameters on a `local` or `internal` Business or Integration event merely because it was not appended.
|
||||||
|
|
||||||
See sample: `add-new-event-parameters-at-the-end.bad.al`.
|
See sample: `add-new-event-parameters-at-the-end.bad.al`.
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,9 @@ codeunit 50291 "New OnBefore Bad Sample"
|
||||||
begin
|
begin
|
||||||
Total := 100;
|
Total := 100;
|
||||||
|
|
||||||
// Anti-pattern: IsHandled was bolted onto the existing
|
// Anti-pattern: IsHandled was bolted onto the existing OnAfter event.
|
||||||
// OnAfterCalculateTotal, changing its contract and breaking every
|
// Regardless of compiler compatibility, this changes a notification
|
||||||
// subscriber that matched the original signature.
|
// into an override contract that existing subscribers did not expect.
|
||||||
OnAfterCalculateTotal(SalesHeader, Total, IsHandled);
|
OnAfterCalculateTotal(SalesHeader, Total, IsHandled);
|
||||||
end;
|
end;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
// Demonstration-only AL. Version 1 used [IntegrationEvent(true, true, false)].
|
||||||
|
codeunit 50531 "Shipment Events Bad"
|
||||||
|
{
|
||||||
|
procedure NotifyShipment(ShipmentNo: Code[20])
|
||||||
|
begin
|
||||||
|
OnShipmentCreated(ShipmentNo);
|
||||||
|
end;
|
||||||
|
|
||||||
|
// Version 2 mutates all three contract-significant arguments in place.
|
||||||
|
[IntegrationEvent(false, false, true)]
|
||||||
|
local procedure OnShipmentCreated(ShipmentNo: Code[20])
|
||||||
|
begin
|
||||||
|
end;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
// Demonstration-only AL. The Isolated argument requires runtime 9.0 / BC20.
|
||||||
|
codeunit 50530 "Shipment Events"
|
||||||
|
{
|
||||||
|
procedure NotifyShipment(ShipmentNo: Code[20])
|
||||||
|
begin
|
||||||
|
OnShipmentCreated(ShipmentNo);
|
||||||
|
OnShipmentCreatedIsolated(ShipmentNo);
|
||||||
|
end;
|
||||||
|
|
||||||
|
// Preserve the shipped attribute contract.
|
||||||
|
[IntegrationEvent(true, true, false)]
|
||||||
|
local procedure OnShipmentCreated(ShipmentNo: Code[20])
|
||||||
|
begin
|
||||||
|
end;
|
||||||
|
|
||||||
|
// Publish a new event for different isolation and sender semantics.
|
||||||
|
[IntegrationEvent(false, false, true)]
|
||||||
|
local procedure OnShipmentCreatedIsolated(ShipmentNo: Code[20])
|
||||||
|
begin
|
||||||
|
end;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
---
|
||||||
|
bc-version: [all]
|
||||||
|
domain: events
|
||||||
|
keywords: [event-attribute, includesender, globalvaraccess, isolated-event, compatibility, integration-event, business-event, appsourcecop, as0021, as0101]
|
||||||
|
technologies: [al]
|
||||||
|
countries: [w1]
|
||||||
|
application-area: [all]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Do not change shipped event attribute flags
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
`IncludeSender` and, on Integration events, `GlobalVarAccess` have been event-contract flags since runtime 1.0. Removing sender or global access breaks subscribers, so AppSourceCop AS0021 prevents changing those flags from `true` to `false`. On runtime 9.0 and later (Business Central 2022 release wave 1, BC20), `Isolated` also controls transaction, error, and rollback behavior; AS0101 prevents adding, removing, or changing that argument.
|
||||||
|
|
||||||
|
## Best Practice
|
||||||
|
|
||||||
|
Keep every available attribute argument exactly as shipped. If new subscribers need different sender/global exposure, publish a new event with the desired flags. Apply the same rule to `Isolated` only on BC20 or later, where that argument exists. Raise both events while the original contract is supported, and choose preferred flags only when designing a new event.
|
||||||
|
|
||||||
|
See sample: `do-not-change-shipped-event-attribute-flags.good.al`.
|
||||||
|
|
||||||
|
## Anti Pattern
|
||||||
|
|
||||||
|
Changing a shipped event's `IncludeSender` or `GlobalVarAccess` to modernize its design, including replacing `IncludeSender` with an explicit parameter. On BC20 or later, adding, removing, or toggling `Isolated` is equally contract-significant. Even a change that leaves old subscribers compiling can alter observable execution or exposure; version the event instead.
|
||||||
|
|
||||||
|
See sample: `do-not-change-shipped-event-attribute-flags.bad.al`.
|
||||||
|
|
@ -8,13 +8,13 @@ codeunit 50260 "Reuse Event Good Sample"
|
||||||
IsHandled := false;
|
IsHandled := false;
|
||||||
// A single event, extended with CustomerNo appended at the end, covers
|
// A single event, extended with CustomerNo appended at the end, covers
|
||||||
// the need; no second event is raised beside it.
|
// the need; no second event is raised beside it.
|
||||||
OnBeforeProcessOrder(SalesHeader, CustomerNo, IsHandled);
|
OnBeforeProcessOrder(SalesHeader, IsHandled, CustomerNo);
|
||||||
if IsHandled then
|
if IsHandled then
|
||||||
exit;
|
exit;
|
||||||
end;
|
end;
|
||||||
|
|
||||||
[IntegrationEvent(false, false)]
|
[IntegrationEvent(false, false)]
|
||||||
local procedure OnBeforeProcessOrder(var SalesHeader: Record "Sales Header"; CustomerNo: Code[20]; var IsHandled: Boolean)
|
local procedure OnBeforeProcessOrder(var SalesHeader: Record "Sales Header"; var IsHandled: Boolean; CustomerNo: Code[20])
|
||||||
begin
|
begin
|
||||||
end;
|
end;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,20 +7,20 @@ countries: [w1]
|
||||||
application-area: [all]
|
application-area: [all]
|
||||||
---
|
---
|
||||||
|
|
||||||
# Prefer this over IncludeSender in codeunit events
|
# Prefer this over IncludeSender in new codeunit events
|
||||||
|
|
||||||
## Description
|
## Description
|
||||||
|
|
||||||
Some publishers set `IncludeSender` to `true` on `[IntegrationEvent]` or `[BusinessEvent]` so subscribers receive the publishing object as an implicit sender parameter. From Business Central 2024 release wave 2, a codeunit can instead pass itself explicitly with the `this` keyword as a normal, strongly-typed `Sender` parameter. Explicit passing is clearer at both the publisher and the subscriber: the sender appears in the signature, it is concretely typed to the publishing codeunit, and it avoids the implicit-parameter mechanics of `IncludeSender`. Reserve `IncludeSender = true` for cases where the sender genuinely cannot be passed explicitly. This guidance applies to code targeting Business Central 2024 release wave 2 or later, where the `this` keyword is available.
|
When designing a new publisher, setting `IncludeSender` to `true` on `[IntegrationEvent]` or `[BusinessEvent]` gives subscribers the publishing object as an implicit sender parameter. From Business Central 2024 release wave 2, a codeunit can instead pass itself explicitly with the `this` keyword as a normal, strongly-typed `Sender` parameter. Explicit passing makes the sender visible and typed in the signature. This is new-event design guidance only: never change `IncludeSender` on an event that has already shipped.
|
||||||
|
|
||||||
## Best Practice
|
## Best Practice
|
||||||
|
|
||||||
Declare the publisher `[IntegrationEvent(false, false)]` with an explicit `Sender: Codeunit "…"` parameter and raise it with `this`, for example `OnBeforeProcessOrder(OrderNo, this);`. Subscribers then receive a typed sender they can call directly.
|
For a new event, declare the publisher `[IntegrationEvent(false, false)]` with an explicit `Sender: Codeunit "…"` parameter and raise it with `this`, for example `OnBeforeProcessOrder(OrderNo, this);`. Subscribers then receive a typed sender they can call directly.
|
||||||
|
|
||||||
See sample: `prefer-this-over-includesender-in-codeunit-events.good.al`.
|
See sample: `prefer-this-over-includesender-in-codeunit-events.good.al`.
|
||||||
|
|
||||||
## Anti Pattern
|
## Anti Pattern
|
||||||
|
|
||||||
Relying on `[IntegrationEvent(true, …)]` solely to hand subscribers the publisher instance, where a codeunit could pass `this` explicitly as a typed parameter. Detection: `IncludeSender = true` on a codeunit event whose only purpose is to expose the sender, in code targeting Business Central 2024 release wave 2 or later.
|
Designing a new codeunit event with `[IntegrationEvent(true, …)]` solely to hand subscribers the publisher instance, where `this` could be passed explicitly as a typed parameter. Do not apply this rule by mutating a shipped event's attribute flags.
|
||||||
|
|
||||||
See sample: `prefer-this-over-includesender-in-codeunit-events.bad.al`.
|
See sample: `prefer-this-over-includesender-in-codeunit-events.bad.al`.
|
||||||
|
|
|
||||||
|
|
@ -20,13 +20,14 @@ codeunit 50225 "Reservation Post Good Sample"
|
||||||
var
|
var
|
||||||
IsHandled: Boolean;
|
IsHandled: Boolean;
|
||||||
begin
|
begin
|
||||||
|
IsHandled := false;
|
||||||
OnBeforeReserve(ReservationEntry, IsHandled);
|
OnBeforeReserve(ReservationEntry, IsHandled);
|
||||||
if IsHandled then
|
if not IsHandled then begin
|
||||||
exit;
|
|
||||||
|
|
||||||
ReservationEntry.Reserved := true;
|
ReservationEntry.Reserved := true;
|
||||||
ReservationEntry.Modify(true);
|
ReservationEntry.Modify(true);
|
||||||
|
end;
|
||||||
|
|
||||||
|
// OnAfter reports completion whether a subscriber or the base body handled it.
|
||||||
OnAfterReserve(ReservationEntry);
|
OnAfterReserve(ReservationEntry);
|
||||||
end;
|
end;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
// Demonstration-only AL. Version 1 exposed var Score as an Integer.
|
||||||
|
codeunit 50521 "Customer Scoring Events Bad"
|
||||||
|
{
|
||||||
|
procedure ScoreCustomer(CustomerNo: Code[20]; ScoreText: Text)
|
||||||
|
begin
|
||||||
|
OnCustomerScored(CustomerNo, ScoreText);
|
||||||
|
end;
|
||||||
|
|
||||||
|
// 'local' limits raising, not subscription. Renaming Score to ScoreText,
|
||||||
|
// changing its type, and removing var all break existing subscribers.
|
||||||
|
[IntegrationEvent(false, false)]
|
||||||
|
local procedure OnCustomerScored(CustomerNo: Code[20]; ScoreText: Text)
|
||||||
|
begin
|
||||||
|
end;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
// Demonstration-only AL. Version 1 had CustomerNo and var Score parameters.
|
||||||
|
codeunit 50520 "Customer Scoring Events"
|
||||||
|
{
|
||||||
|
procedure ScoreCustomer(CustomerNo: Code[20]; Reason: Text; var Score: Integer)
|
||||||
|
begin
|
||||||
|
OnCustomerScored(CustomerNo, Reason, Score);
|
||||||
|
end;
|
||||||
|
|
||||||
|
// Adding Reason between existing parameters preserves subscriber bindings.
|
||||||
|
[IntegrationEvent(false, false)]
|
||||||
|
local procedure OnCustomerScored(CustomerNo: Code[20]; Reason: Text; var Score: Integer)
|
||||||
|
begin
|
||||||
|
end;
|
||||||
|
}
|
||||||
|
|
||||||
|
codeunit 50522 "Existing Scoring Subscriber"
|
||||||
|
{
|
||||||
|
[EventSubscriber(ObjectType::Codeunit, Codeunit::"Customer Scoring Events", 'OnCustomerScored', '', false, false)]
|
||||||
|
local procedure OnCustomerScored(CustomerNo: Code[20]; var Score: Integer)
|
||||||
|
begin
|
||||||
|
if CustomerNo = '' then
|
||||||
|
Score := 0;
|
||||||
|
end;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
---
|
||||||
|
bc-version: [all]
|
||||||
|
domain: events
|
||||||
|
keywords: [local-event, internal-event, event-subscriber, compatibility, access-modifier, integration-event, business-event, parameter-name, var-parameter, appsourcecop]
|
||||||
|
technologies: [al]
|
||||||
|
countries: [w1]
|
||||||
|
application-area: [all]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Treat local and internal events as subscriber contracts
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
The `local` and `internal` access modifiers on Business and Integration event publishers restrict who can raise the procedure; they do not prevent dependent extensions from subscribing. Once shipped, the event name and each existing parameter's name, type/subtype, and value-versus-`var` passing mode are compatibility contracts even when the publisher is not public. Parameter order is not a subscriber contract because subscribers bind the parameters they use by name. This differs from `[InternalEvent]`, which is module-only except for modules named by `internalsVisibleTo`.
|
||||||
|
|
||||||
|
## Best Practice
|
||||||
|
|
||||||
|
Preserve a shipped Business or Integration event's identity and every existing parameter's name, type/subtype, and passing mode regardless of the procedure access modifier. AS0025 protects names and types, while AS0063 and AS0077 protect removal and addition of `var`. New parameters may be added at any position on a `local` or `internal` event because subscribers can omit them; public event procedures follow the stricter caller contract described by `add-new-event-parameters-at-the-end`.
|
||||||
|
|
||||||
|
See sample: `treat-local-and-internal-events-as-subscriber-contracts.good.al`.
|
||||||
|
|
||||||
|
## Anti Pattern
|
||||||
|
|
||||||
|
Renaming or removing an existing parameter, changing its type/subtype, or adding/removing its `var` modifier because the event publisher procedure is `local` or `internal`. AppSourceCop checks these subscriber-breaking changes because dependent event subscribers can still bind to the event. Reordering unchanged parameters, or inserting a new parameter among them, is not this anti-pattern.
|
||||||
|
|
||||||
|
See sample: `treat-local-and-internal-events-as-subscriber-contracts.bad.al`.
|
||||||
|
|
@ -7,6 +7,7 @@ codeunit 50220 "Shipping Charge Good Sample"
|
||||||
begin
|
begin
|
||||||
// Give extensions a sanctioned seam to replace the calculation, then
|
// Give extensions a sanctioned seam to replace the calculation, then
|
||||||
// skip the default logic when a subscriber has handled it.
|
// skip the default logic when a subscriber has handled it.
|
||||||
|
IsHandled := false;
|
||||||
OnBeforeCalculateShippingCharge(OrderAmount, Charge, IsHandled);
|
OnBeforeCalculateShippingCharge(OrderAmount, Charge, IsHandled);
|
||||||
if IsHandled then
|
if IsHandled then
|
||||||
exit(Charge);
|
exit(Charge);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
// Demonstration-only AL. Version 1 shipped with only CalculateAmount().
|
||||||
|
interface "I Shipping Quote Bad"
|
||||||
|
{
|
||||||
|
procedure CalculateAmount(): Decimal;
|
||||||
|
|
||||||
|
// Added in version 2: every existing implementer now fails to compile.
|
||||||
|
procedure CalculateDeliveryDate(): Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
codeunit 50511 "Existing Shipping Quote" implements "I Shipping Quote Bad"
|
||||||
|
{
|
||||||
|
procedure CalculateAmount(): Decimal
|
||||||
|
begin
|
||||||
|
exit(10);
|
||||||
|
end;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
// Demonstration-only AL. Interface inheritance requires runtime 14.0 / BC25.
|
||||||
|
interface "I Shipping Quote"
|
||||||
|
{
|
||||||
|
procedure CalculateAmount(): Decimal;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface "I Shipping Quote V2" extends "I Shipping Quote"
|
||||||
|
{
|
||||||
|
procedure CalculateDeliveryDate(): Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
codeunit 50510 "Shipping Quote V2" implements "I Shipping Quote V2"
|
||||||
|
{
|
||||||
|
procedure CalculateAmount(): Decimal
|
||||||
|
begin
|
||||||
|
exit(10);
|
||||||
|
end;
|
||||||
|
|
||||||
|
procedure CalculateDeliveryDate(): Date
|
||||||
|
begin
|
||||||
|
exit(Today() + 1);
|
||||||
|
end;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
---
|
||||||
|
bc-version: [16..]
|
||||||
|
domain: interfaces
|
||||||
|
keywords: [published-interface, interface-method, breaking-change, interface-extends, versioned-interface, appsourcecop, as0066]
|
||||||
|
technologies: [al]
|
||||||
|
countries: [w1]
|
||||||
|
application-area: [all]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Extend published interfaces; do not edit them
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
Adding a method to a shipped interface changes the contract every implementing codeunit must satisfy. Implementers can live in dependent extensions, so the addition breaks code the interface publisher cannot update; AppSourceCop reports AS0066. Interface inheritance is available from runtime 14.0 (Business Central 2024 release wave 2, BC25), but the original interface must remain unchanged.
|
||||||
|
|
||||||
|
## Best Practice
|
||||||
|
|
||||||
|
On BC25 or later, declare a new interface that `extends` the published interface and add the new method there. Existing implementers remain valid for the original contract, while new implementers opt in to the extended contract. For targets BC16 through BC24, where interface inheritance is unavailable, publish a new or versioned sibling interface instead.
|
||||||
|
|
||||||
|
See sample: `extend-published-interfaces-dont-edit-them.good.al`.
|
||||||
|
|
||||||
|
## Anti Pattern
|
||||||
|
|
||||||
|
Adding a procedure directly to an interface that has already shipped. Every dependent implementation must immediately add that procedure, so an otherwise compatible app update breaks its implementers.
|
||||||
|
|
||||||
|
See sample: `extend-published-interfaces-dont-edit-them.bad.al`.
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
// Demonstration-only AL. A removed enum-extension value left ordinal 700 in data.
|
||||||
|
enum 50503 "Delivery Method Bad" implements "I Delivery Method Bad"
|
||||||
|
{
|
||||||
|
Extensible = true;
|
||||||
|
DefaultImplementation = "I Delivery Method Bad" = "Default Delivery Method Bad";
|
||||||
|
|
||||||
|
value(0; Default)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface "I Delivery Method Bad"
|
||||||
|
{
|
||||||
|
procedure Deliver();
|
||||||
|
}
|
||||||
|
|
||||||
|
codeunit 50504 "Default Delivery Method Bad" implements "I Delivery Method Bad"
|
||||||
|
{
|
||||||
|
procedure Deliver()
|
||||||
|
begin
|
||||||
|
end;
|
||||||
|
}
|
||||||
|
|
||||||
|
codeunit 50505 "Delivery Dispatch Bad"
|
||||||
|
{
|
||||||
|
procedure DeliverPersistedValue()
|
||||||
|
var
|
||||||
|
DeliveryMethod: Enum "Delivery Method Bad";
|
||||||
|
Delivery: Interface "I Delivery Method Bad";
|
||||||
|
begin
|
||||||
|
DeliveryMethod := 700;
|
||||||
|
// DefaultImplementation does not handle an ordinal that is not declared.
|
||||||
|
Delivery := DeliveryMethod;
|
||||||
|
Delivery.Deliver();
|
||||||
|
end;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
// Demonstration-only AL. UnknownValueImplementation requires runtime 7.0 / BC18.
|
||||||
|
interface "I Delivery Method"
|
||||||
|
{
|
||||||
|
procedure Deliver();
|
||||||
|
}
|
||||||
|
|
||||||
|
codeunit 50500 "Unknown Delivery Method" implements "I Delivery Method"
|
||||||
|
{
|
||||||
|
procedure Deliver()
|
||||||
|
begin
|
||||||
|
Error(UnknownMethodErr);
|
||||||
|
end;
|
||||||
|
|
||||||
|
var
|
||||||
|
UnknownMethodErr: Label 'The saved delivery method is no longer installed. Select another method.';
|
||||||
|
}
|
||||||
|
|
||||||
|
codeunit 50501 "Default Delivery Method" implements "I Delivery Method"
|
||||||
|
{
|
||||||
|
procedure Deliver()
|
||||||
|
begin
|
||||||
|
end;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum 50502 "Delivery Method" implements "I Delivery Method"
|
||||||
|
{
|
||||||
|
Extensible = true;
|
||||||
|
DefaultImplementation = "I Delivery Method" = "Default Delivery Method";
|
||||||
|
UnknownValueImplementation = "I Delivery Method" = "Unknown Delivery Method";
|
||||||
|
|
||||||
|
value(0; Default)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
---
|
||||||
|
bc-version: [18..]
|
||||||
|
domain: interfaces
|
||||||
|
keywords: [unknownvalueimplementation, unknown-enum-value, persisted-ordinal, enum-extension, extension-uninstall, interface-fallback]
|
||||||
|
technologies: [al]
|
||||||
|
countries: [w1]
|
||||||
|
application-area: [all]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Handle unknown enum ordinals with UnknownValueImplementation
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
An enum ordinal can remain in persisted data after the enum extension that declared it is uninstalled. The ordinal is then unknown: it matches no currently declared enum value. `DefaultImplementation` does not cover this case; it covers declared values that have no explicit interface implementation. `UnknownValueImplementation`, available from runtime 7.0 (Business Central 2021 release wave 1, BC18), provides the distinct interface implementation for an unknown ordinal.
|
||||||
|
|
||||||
|
## Best Practice
|
||||||
|
|
||||||
|
On BC18 or later, set `UnknownValueImplementation = <Interface> = <Codeunit>;` on an enum that implements an interface and can be persisted. Use an implementation that reports a clear domain error or safely contains the unknown state. Keep `DefaultImplementation` separately when declared but unmapped values also need a fallback.
|
||||||
|
|
||||||
|
See sample: `handle-unknown-enum-ordinals-with-unknownvalueimplementation.good.al`.
|
||||||
|
|
||||||
|
## Anti Pattern
|
||||||
|
|
||||||
|
Defining only `DefaultImplementation` and assuming it also handles a stored ordinal whose enum value has disappeared. After an enum extension is uninstalled, converting that unknown ordinal to the interface can produce a technical runtime error instead of controlled handling.
|
||||||
|
|
||||||
|
See sample: `handle-unknown-enum-ordinals-with-unknownvalueimplementation.bad.al`.
|
||||||
|
|
@ -11,11 +11,11 @@ application-area: [all]
|
||||||
|
|
||||||
## Description
|
## Description
|
||||||
|
|
||||||
An `enum` that `implements` an interface maps each value to a codeunit through the `Implementation` property. But an extensible enum can carry values that set no `Implementation` — values added later by an extension, or a value left intentionally blank. Assigning such a value to an interface variable and calling a method on it fails at runtime unless the enum provides a fallback. The enum-level `DefaultImplementation` property names the codeunit used whenever a value has no explicit `Implementation`, so resolution always yields a usable object. LLMs are generally unaware this property exists and leave the gap open.
|
An `enum` that `implements` an interface maps each declared value to a codeunit through the `Implementation` property. A declared value, including one supplied by an enum extension, can omit that mapping. Assigning that value to an interface variable then fails at runtime unless the enum provides `DefaultImplementation`. This property is for declared but unmapped values; an ordinal that is no longer declared is a different case covered by `handle-unknown-enum-ordinals-with-unknownvalueimplementation`.
|
||||||
|
|
||||||
## Best Practice
|
## Best Practice
|
||||||
|
|
||||||
On any extensible enum that implements an interface, set `DefaultImplementation = <Interface> = <Codeunit>;` at the enum level, pointing at a safe implementation that does nothing harmful. Values with their own `Implementation` keep using it; every other value — including ones added later by extensions — resolves to the default instead of failing. For the distinct case of an out-of-range integer that matches no declared value, pair it with `UnknownValueImplementation`. The result is that a consumer can assign any enum value to the interface variable and call through it without a runtime guard.
|
On any extensible enum that implements an interface, set `DefaultImplementation = <Interface> = <Codeunit>;` at the enum level, pointing at a safe implementation. Values with their own `Implementation` keep using it; declared values without one resolve to the default. Do not rely on this property for persisted ordinals that match no declared enum value.
|
||||||
|
|
||||||
See sample: `set-defaultimplementation-on-enum.good.al`.
|
See sample: `set-defaultimplementation-on-enum.good.al`.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,22 @@ report 50221 "Perf Sample AddLoadFields Bad"
|
||||||
{
|
{
|
||||||
dataset
|
dataset
|
||||||
{
|
{
|
||||||
// No AddLoadFields: every Cust. Ledger Entry column ships per row, even though
|
|
||||||
// only three columns feed the layout.
|
|
||||||
dataitem(CustLedgerEntry; "Cust. Ledger Entry")
|
dataitem(CustLedgerEntry; "Cust. Ledger Entry")
|
||||||
{
|
{
|
||||||
column(CustomerNo; "Customer No.") { }
|
column(CustomerNo; "Customer No.") { }
|
||||||
column(PostingDate; "Posting Date") { }
|
column(PostingDate; "Posting Date") { }
|
||||||
column(Amount; Amount) { }
|
column(Amount; Amount) { }
|
||||||
|
|
||||||
|
trigger OnAfterGetRecord()
|
||||||
|
begin
|
||||||
|
// Source Code is not a dataset column, so its first access causes a
|
||||||
|
// just-in-time load and updates the dataitem enumerator.
|
||||||
|
RegisterSourceCode("Source Code");
|
||||||
|
end;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
local procedure RegisterSourceCode(SourceCode: Code[10])
|
||||||
|
begin
|
||||||
|
end;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,19 @@ report 50220 "Perf Sample AddLoadFields Good"
|
||||||
|
|
||||||
trigger OnPreDataItem()
|
trigger OnPreDataItem()
|
||||||
begin
|
begin
|
||||||
AddLoadFields("Customer No.", "Posting Date", Amount);
|
// Dataset columns are selected by the report compiler. Source Code is
|
||||||
|
// extra because only trigger code reads it.
|
||||||
|
CustLedgerEntry.AddLoadFields("Source Code");
|
||||||
|
end;
|
||||||
|
|
||||||
|
trigger OnAfterGetRecord()
|
||||||
|
begin
|
||||||
|
RegisterSourceCode("Source Code");
|
||||||
end;
|
end;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
local procedure RegisterSourceCode(SourceCode: Code[10])
|
||||||
|
begin
|
||||||
|
end;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,20 +7,20 @@ countries: [w1]
|
||||||
application-area: [all]
|
application-area: [all]
|
||||||
---
|
---
|
||||||
|
|
||||||
# In reports, declare the fields the layout needs with AddLoadFields
|
# Add trigger-only report fields in OnPreDataItem
|
||||||
|
|
||||||
## Description
|
## Description
|
||||||
|
|
||||||
Reports iterate dataitems on potentially large source tables and pipe rows into a layout. The partial-record optimization is the same idea as `use-setloadfields-for-partial-records.md`, but the API is different: per the upstream guidance, "for reports, use `AddLoadFields()` in `OnPreDataItem` trigger to add fields needed by the layout." `AddLoadFields` is additive — call it for each field the layout consumes — and runs once per dataitem before iteration begins.
|
Report dataitem field selection is calculated at compile time and once per dataitem type during execution. Fields referenced by dataset columns are selected automatically; fields used only in triggers are not. Use `AddLoadFields` in `OnPreDataItem` to supplement the automatic selection with normal fields that trigger code needs.
|
||||||
|
|
||||||
## Best Practice
|
## Best Practice
|
||||||
|
|
||||||
In each dataitem's `OnPreDataItem` trigger, list the columns the layout binds to via `AddLoadFields(<field>, <field>, ...)`. The platform then materializes only those columns per row. Treat the layout column list as the spec: every column the layout uses must be added; columns the layout does not use should not be added.
|
When a dataitem trigger needs an extra field, add that field in `OnPreDataItem` before iteration starts. This supplements the compiler-selected fields and avoids the first just-in-time load and enumerator update when the trigger reads the extra field.
|
||||||
|
|
||||||
See sample: `addloadfields-in-report-onpredataitem.good.al`.
|
See sample: `addloadfields-in-report-onpredataitem.good.al`.
|
||||||
|
|
||||||
## Anti Pattern
|
## Anti Pattern
|
||||||
|
|
||||||
Relying on the dataitem's default to load every field. On a report bound to a ledger-scale table this transfers an entire row per iteration, of which the layout reads a fraction.
|
Listing every dataset column in `AddLoadFields`, or omitting a known trigger-only field because the dataset already uses other fields. The former is redundant; the latter causes a just-in-time load on first access and can cause repeated loads when the record is copied or passed by value.
|
||||||
|
|
||||||
See sample: `addloadfields-in-report-onpredataitem.bad.al`.
|
See sample: `addloadfields-in-report-onpredataitem.bad.al`.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
codeunit 50493 "Perf Record Clone Bad"
|
||||||
|
{
|
||||||
|
procedure IncreaseCustomerCreditLimits(Percent: Decimal)
|
||||||
|
var
|
||||||
|
Customer: Record Customer;
|
||||||
|
CustomerCopy: Record Customer;
|
||||||
|
begin
|
||||||
|
Customer.SetLoadFields("Credit Limit (LCY)");
|
||||||
|
Customer.SetFilter("Credit Limit (LCY)", '>0');
|
||||||
|
if Customer.FindSet(true) then
|
||||||
|
repeat
|
||||||
|
CustomerCopy.Copy(Customer);
|
||||||
|
CustomerCopy.Validate(
|
||||||
|
"Credit Limit (LCY)",
|
||||||
|
Round(CustomerCopy."Credit Limit (LCY)" * (1 + Percent / 100)));
|
||||||
|
CustomerCopy.Modify(true);
|
||||||
|
until Customer.Next() = 0;
|
||||||
|
end;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
codeunit 50492 "Perf Record Clone Good"
|
||||||
|
{
|
||||||
|
procedure IncreaseCustomerCreditLimits(Percent: Decimal)
|
||||||
|
var
|
||||||
|
Customer: Record Customer;
|
||||||
|
begin
|
||||||
|
Customer.SetLoadFields("Credit Limit (LCY)");
|
||||||
|
Customer.SetFilter("Credit Limit (LCY)", '>0');
|
||||||
|
if Customer.FindSet(true) then
|
||||||
|
repeat
|
||||||
|
Customer.Validate(
|
||||||
|
"Credit Limit (LCY)",
|
||||||
|
Round(Customer."Credit Limit (LCY)" * (1 + Percent / 100)));
|
||||||
|
Customer.Modify(true);
|
||||||
|
until Customer.Next() = 0;
|
||||||
|
end;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
---
|
||||||
|
bc-version: [all]
|
||||||
|
domain: performance
|
||||||
|
keywords: [clone, clone-before-write, copy, gettable, by-value, copied-record, writing-helper]
|
||||||
|
technologies: [al]
|
||||||
|
countries: [w1]
|
||||||
|
application-area: [all]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Avoid cloning records before Modify or Delete in loops
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
Microsoft's [AL database-method performance guidance](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/administration/optimize-sql-al-database-methods-and-performance-on-server#insert-modify-delete-and-locktable) states that cloning an iterated record before `Modify` or `Delete` restarts the SQL `SELECT` and issues an extra SQL statement for every row. The runtime treats `Record.Copy`, `RecordRef.GetTable`, and passing a record by value to a writing helper as clones in this situation.
|
||||||
|
|
||||||
|
## Best Practice
|
||||||
|
|
||||||
|
Use `FindSet(true)` when the loop writes the traversed rows, and call `Modify` or `Delete` on that iterating record variable. If generic code is required, open and iterate the `RecordRef` directly instead of calling `GetTable` for each typed record. Keep a per-row loop when validation or row-specific behavior is required; this rule does not imply that `ModifyAll` or `DeleteAll` is equivalent.
|
||||||
|
|
||||||
|
See sample: `avoid-cloning-records-before-modify-delete-in-loops.good.al`.
|
||||||
|
|
||||||
|
## Anti Pattern
|
||||||
|
|
||||||
|
Inside an active traversal, copy the current row, convert it with `RecordRef.GetTable`, or pass it without `var` to a helper, then call `Modify` or `Delete` on that clone. Do not flag read-only snapshots, temporary records, or copies used to write a different target table; the documented extra-statement concern is clone-before-write on the traversed table.
|
||||||
|
|
||||||
|
See sample: `avoid-cloning-records-before-modify-delete-in-loops.bad.al`.
|
||||||
|
|
@ -1,21 +1,60 @@
|
||||||
|
query 50127 "Perf Customer Chunk"
|
||||||
|
{
|
||||||
|
QueryType = Normal;
|
||||||
|
OrderBy = ascending(CustomerNo);
|
||||||
|
|
||||||
|
elements
|
||||||
|
{
|
||||||
|
dataitem(Customer; Customer)
|
||||||
|
{
|
||||||
|
column(CustomerNo; "No.") { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
codeunit 50128 "Perf Sample CommitInLoop Good"
|
codeunit 50128 "Perf Sample CommitInLoop Good"
|
||||||
{
|
{
|
||||||
procedure NormalizeCustomerNames()
|
procedure NormalizeCustomerNames()
|
||||||
var
|
var
|
||||||
Customer: Record Customer;
|
LastCustomerNo: Code[20];
|
||||||
RowsInChunk: Integer;
|
|
||||||
ChunkSize: Integer;
|
|
||||||
begin
|
begin
|
||||||
ChunkSize := 500;
|
// The outer loop owns checkpoints; the per-row loop contains no Commit.
|
||||||
if Customer.FindSet(true) then
|
while NormalizeNextChunk(LastCustomerNo) do
|
||||||
|
Commit();
|
||||||
|
end;
|
||||||
|
|
||||||
|
local procedure NormalizeNextChunk(var LastCustomerNo: Code[20]): Boolean
|
||||||
|
var
|
||||||
|
Customer: Record Customer;
|
||||||
|
TempCustomer: Record Customer temporary;
|
||||||
|
CustomerChunk: Query "Perf Customer Chunk";
|
||||||
|
LastChunkCustomerNo: Code[20];
|
||||||
|
begin
|
||||||
|
CustomerChunk.TopNumberOfRows(500);
|
||||||
|
if LastCustomerNo <> '' then
|
||||||
|
CustomerChunk.SetFilter(CustomerNo, '>%1', LastCustomerNo);
|
||||||
|
CustomerChunk.Open();
|
||||||
|
while CustomerChunk.Read() do begin
|
||||||
|
TempCustomer.Init();
|
||||||
|
TempCustomer."No." := CustomerChunk.CustomerNo;
|
||||||
|
TempCustomer.Insert();
|
||||||
|
LastChunkCustomerNo := CustomerChunk.CustomerNo;
|
||||||
|
end;
|
||||||
|
CustomerChunk.Close();
|
||||||
|
|
||||||
|
if TempCustomer.IsEmpty() then
|
||||||
|
exit(false);
|
||||||
|
|
||||||
|
Customer.LockTable();
|
||||||
|
if TempCustomer.FindSet() then
|
||||||
repeat
|
repeat
|
||||||
|
if Customer.Get(TempCustomer."No.") then begin
|
||||||
Customer.Name := UpperCase(Customer.Name);
|
Customer.Name := UpperCase(Customer.Name);
|
||||||
Customer.Modify();
|
Customer.Modify();
|
||||||
RowsInChunk += 1;
|
|
||||||
if RowsInChunk >= ChunkSize then begin
|
|
||||||
Commit();
|
|
||||||
RowsInChunk := 0;
|
|
||||||
end;
|
end;
|
||||||
until Customer.Next() = 0;
|
until TempCustomer.Next() = 0;
|
||||||
|
|
||||||
|
LastCustomerNo := LastChunkCustomerNo;
|
||||||
|
exit(true);
|
||||||
end;
|
end;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
---
|
---
|
||||||
bc-version: [all]
|
bc-version: [all]
|
||||||
domain: performance
|
domain: performance
|
||||||
keywords: [commit, loop, transaction, lock, checkpoint, codeunit-run]
|
keywords: [commit, commit-in-loop, per-row-commit, checkpoint, bounded-checkpoint, watermark, topnumberofrows]
|
||||||
technologies: [al]
|
technologies: [al]
|
||||||
countries: [w1]
|
countries: [w1]
|
||||||
application-area: [all]
|
application-area: [all]
|
||||||
|
|
@ -13,17 +13,16 @@ application-area: [all]
|
||||||
|
|
||||||
## Description
|
## Description
|
||||||
|
|
||||||
Commit ends the current write transaction. Calling it inside a per-row loop produces one transaction per iteration and loses the ability to roll back the whole operation atomically; it also interferes with the platform's ability to batch write operations. Most loops need no explicit Commit at all — AL auto-commits the enclosing code module on successful completion (see `understand-implicit-transaction-boundary.md`). When the batch is too large for one transaction, the fix is not a per-row Commit but bounded checkpoints that each process N rows.
|
Commit ends the current write transaction. Calling it inside a per-row loop produces one transaction per iteration and loses the ability to roll back the whole operation atomically; it also interferes with the platform's ability to batch write operations. Most loops need no explicit Commit at all — AL auto-commits the enclosing code module on successful completion (see `understand-implicit-transaction-boundary.md`). When the batch is too large for one transaction, the fix is not a per-row Commit but bounded checkpoints that select an exact list of at most N keys and process only those rows.
|
||||||
|
|
||||||
## Best Practice
|
## Best Practice
|
||||||
|
|
||||||
If the batch is large enough that a single transaction is untenable, process it in checkpoints driven by an outer loop that each time picks up the next N rows. Commit once per checkpoint at a clearly defined safe boundary, not inside the per-row loop. Wrapping each chunk in `Codeunit.Run` gives the same effect with native rollback on failure — see `codeunit-run-as-atomic-sub-operation.md`.
|
If the batch is large enough that a single transaction is untenable, use an ordered primary-key watermark and retrieve a bounded next-N key list. `FindSet` is optimized for reading the complete filtered set and isn't implemented as `TOP X`, so calling it over the remaining tail and breaking after N rows does not bound retrieval. The sample uses a query capped by [`TopNumberOfRows`](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/query/queryinstance-topnumberofrows-method) to fill a temporary key buffer, then takes update locks and modifies only those exact keys. It does not reconstruct an inclusive first-to-last range that concurrent inserts could expand. Commit after the bounded inner loop returns and persist its last selected key as the next watermark. Use a stable key and define how a later run handles records inserted at or below an already committed watermark. A `Codeunit.Run` boundary can also own a chunk when its implicit commit and error behavior fit the caller — see `codeunit-run-as-atomic-sub-operation.md`.
|
||||||
|
|
||||||
See sample: `avoid-commit-inside-loops.good.al`.
|
See sample: `avoid-commit-inside-loops.good.al`.
|
||||||
|
|
||||||
## Anti Pattern
|
## Anti Pattern
|
||||||
|
|
||||||
Placing Commit inside `repeat ... until Next() = 0` is almost always a mistake: it is unusual for the correctness of the operation to depend on per-row commits, and the cost of starting a new transaction on every row dominates the work.
|
Placing Commit inside `repeat ... until Next() = 0` is almost always a mistake: it is unusual for the correctness of the operation to depend on per-row commits, and the cost of starting a new transaction on every row dominates the work. A capped query that discovers only an upper key and then re-reads an inclusive key range is not exact batching either; concurrent inserts inside that range can enlarge the checkpoint.
|
||||||
|
|
||||||
See sample: `avoid-commit-inside-loops.bad.al`.
|
See sample: `avoid-commit-inside-loops.bad.al`.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,15 @@
|
||||||
codeunit 50253 "Perf Sample NPlus1 Bad"
|
codeunit 50253 "Perf Sample NPlus1 Bad"
|
||||||
{
|
{
|
||||||
procedure SumStdCost(var BOMLine: Record "BOM Component") TotalCost: Decimal
|
procedure SumStdCost(BOMNo: Code[20]; BOMVersionCode: Code[20]) TotalCost: Decimal
|
||||||
var
|
var
|
||||||
|
BOMLine: Record "Production BOM Line";
|
||||||
Item: Record Item;
|
Item: Record Item;
|
||||||
begin
|
begin
|
||||||
|
BOMLine.SetRange("Production BOM No.", BOMNo);
|
||||||
|
BOMLine.SetRange("Version Code", BOMVersionCode);
|
||||||
if BOMLine.FindSet() then
|
if BOMLine.FindSet() then
|
||||||
repeat
|
repeat
|
||||||
// Full-row Item.Get per BOM line — no partial loading, no caching.
|
if Item.Get(BOMLine."No.") then
|
||||||
Item.Get(BOMLine."No.");
|
|
||||||
if Item."Costing Method" = Item."Costing Method"::Standard then
|
if Item."Costing Method" = Item."Costing Method"::Standard then
|
||||||
TotalCost += Item."Standard Cost" * BOMLine."Quantity per";
|
TotalCost += Item."Standard Cost" * BOMLine."Quantity per";
|
||||||
until BOMLine.Next() = 0;
|
until BOMLine.Next() = 0;
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,38 @@
|
||||||
codeunit 50252 "Perf Sample NPlus1 Good"
|
query 50252 "Perf Sample BOM Cost"
|
||||||
{
|
{
|
||||||
procedure SumStdCost(var BOMLine: Record "BOM Component") TotalCost: Decimal
|
QueryType = Normal;
|
||||||
|
|
||||||
|
elements
|
||||||
|
{
|
||||||
|
dataitem(ProductionBOMLine; "Production BOM Line")
|
||||||
|
{
|
||||||
|
column(ProductionBOMNo; "Production BOM No.") { }
|
||||||
|
column(VersionCode; "Version Code") { }
|
||||||
|
column(QuantityPer; "Quantity per") { }
|
||||||
|
|
||||||
|
dataitem(Item; Item)
|
||||||
|
{
|
||||||
|
DataItemLink = "No." = ProductionBOMLine."No.";
|
||||||
|
DataItemTableFilter = "Costing Method" = const(Standard);
|
||||||
|
SqlJoinType = InnerJoin;
|
||||||
|
|
||||||
|
column(StandardCost; "Standard Cost") { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
codeunit 50254 "Perf Sample NPlus1 Good"
|
||||||
|
{
|
||||||
|
procedure SumStdCost(BOMNo: Code[20]; BOMVersionCode: Code[20]) TotalCost: Decimal
|
||||||
var
|
var
|
||||||
Item: Record Item;
|
BOMCost: Query "Perf Sample BOM Cost";
|
||||||
begin
|
begin
|
||||||
Item.SetLoadFields("Costing Method", "Standard Cost");
|
BOMCost.SetRange(ProductionBOMNo, BOMNo);
|
||||||
if BOMLine.FindSet() then
|
BOMCost.SetRange(VersionCode, BOMVersionCode);
|
||||||
repeat
|
BOMCost.Open();
|
||||||
if Item.Get(BOMLine."No.") then
|
while BOMCost.Read() do
|
||||||
if Item."Costing Method" = Item."Costing Method"::Standard then
|
TotalCost += BOMCost.StandardCost * BOMCost.QuantityPer;
|
||||||
TotalCost += Item."Standard Cost" * BOMLine."Quantity per";
|
BOMCost.Close();
|
||||||
until BOMLine.Next() = 0;
|
|
||||||
end;
|
end;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,16 +11,16 @@ application-area: [all]
|
||||||
|
|
||||||
## Description
|
## Description
|
||||||
|
|
||||||
A `Get` or `FindFirst` against a different record inside a loop body produces one database round-trip per iteration — the classic N+1 pattern. Per the upstream guidance, "Flag when a `Get()`/`FindFirst()` is called inside a loop for each record — this creates N+1 database round-trips." The cost only matters when the inner table is meaningful: lookups against temporary tables, singleton setup tables, enum-mapping tables, permission objects, or Role IDs are bounded and safe. The pattern to catch is the inner lookup that hits a production-scale table for every outer row.
|
A `Get` or `FindFirst` against another persistent table inside a loop can produce an N+1 access pattern: one outer query followed by repeated inner lookups. Server and primary-key caches can satisfy some `Get` calls, so a source-level `Get` is not proof of one SQL round-trip. The concern is an unbounded loop whose lookup keys are not known to repeat or remain cached.
|
||||||
|
|
||||||
## Best Practice
|
## Best Practice
|
||||||
|
|
||||||
When the loop needs values from another record, lift the lookup out of the loop if the rows can be collected up front, or apply `SetLoadFields` so each inner read transfers only the columns the loop actually uses (see `use-setloadfields-for-partial-records.md`). When the inner record is small or bounded, leave the call site alone — the rule targets large-table inner lookups specifically.
|
Use a query object to join the outer and inner tables when the relationship and filters can be expressed as one query. If keys repeat, a dictionary cache can reduce lookups to one per distinct key. `SetLoadFields` can reduce the columns transferred by unavoidable inner reads, but it does not eliminate the N+1 shape and must not be presented as doing so.
|
||||||
|
|
||||||
See sample: `avoid-get-inside-loop-on-large-table.good.al`.
|
See sample: `avoid-get-inside-loop-on-large-table.good.al`.
|
||||||
|
|
||||||
## Anti Pattern
|
## Anti Pattern
|
||||||
|
|
||||||
Iterating BOM lines and calling `Item.Get(BOMLine."No.")` per row to read a costing method, with no `SetLoadFields` on `Item`. Each iteration issues one query against Item (~800k rows) and pulls the entire row to read two fields. The fix is `Item.SetLoadFields("Costing Method", "Standard Cost");` ahead of the loop — still N reads, but each one transfers only the needed columns.
|
Iterating production BOM lines and calling `Item.Get(BOMLine."No.")` for each line when the same result can be produced by a query joining Production BOM Line to Item. Partial loading alone is only a payload mitigation for this pattern.
|
||||||
|
|
||||||
See sample: `avoid-get-inside-loop-on-large-table.bad.al`.
|
See sample: `avoid-get-inside-loop-on-large-table.bad.al`.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
---
|
||||||
|
bc-version: [all]
|
||||||
|
domain: performance
|
||||||
|
keywords: [maintainsiftindex, sift, calcsums, flowfield, write-cost]
|
||||||
|
technologies: [al]
|
||||||
|
countries: [w1]
|
||||||
|
application-area: [all]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Choose MaintainSIFTIndex by read-write ratio
|
||||||
|
|
||||||
|
> Contributions welcome — open a PR to refine or extend this article.
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
`MaintainSIFTIndex` on a key decides whether SQL Server maintains the SIFT indexed view as underlying rows change. With `Yes`, writes that affect the key or sum fields also maintain the indexed aggregate. With `No`, that SIFT indexed view is not maintained, so a compatible `CalcSums` or FlowField calculation is computed from the base table instead and may require scanning many rows. There is no deferred "first read rebuild" of the SIFT structure.
|
||||||
|
|
||||||
|
## Best Practice
|
||||||
|
|
||||||
|
Measure aggregate-read latency and write cost under realistic filters and volumes. Keep `MaintainSIFTIndex = true` when the maintained aggregate materially benefits frequent `CalcSums` or FlowField reads. Consider `false` when writes dominate and the less-frequent aggregate reads can tolerate calculation from the base table.
|
||||||
|
|
||||||
|
See sample: `choose-maintainsiftindex-by-read-write-ratio.good.al`.
|
||||||
|
|
||||||
|
## Anti Pattern
|
||||||
|
|
||||||
|
Leaving `MaintainSIFTIndex = Yes` on every key by reflex or convenience. On write-heavy tables the cumulative cost turns every INSERT or MODIFY into several additional aggregate updates, and the impact compounds in batch imports and posting routines — often without any code-review signal that the property is the cause.
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
codeunit 50100 "Sales Document Processor"
|
||||||
|
{
|
||||||
|
procedure DescribeDocument(DocumentType: Enum "Sales Document Type"; DocumentNo: Code[20]): Text
|
||||||
|
var
|
||||||
|
SalesHeader: Record "Sales Header";
|
||||||
|
begin
|
||||||
|
SalesHeader.SetLoadFields(
|
||||||
|
"Sell-to Customer No.",
|
||||||
|
"Order Date", "Shipment Date", "Completely Shipped",
|
||||||
|
"Posting Date", "Due Date", "Payment Terms Code");
|
||||||
|
SalesHeader.Get(DocumentType, DocumentNo);
|
||||||
|
|
||||||
|
case DocumentType of
|
||||||
|
DocumentType::Order:
|
||||||
|
exit(DescribeOrder(SalesHeader));
|
||||||
|
DocumentType::Invoice:
|
||||||
|
exit(DescribeInvoice(SalesHeader));
|
||||||
|
end;
|
||||||
|
end;
|
||||||
|
|
||||||
|
local procedure DescribeOrder(SalesHeader: Record "Sales Header"): Text
|
||||||
|
begin
|
||||||
|
exit(StrSubstNo('%1|%2|%3|%4', SalesHeader."Sell-to Customer No.", SalesHeader."Order Date", SalesHeader."Shipment Date", SalesHeader."Completely Shipped"));
|
||||||
|
end;
|
||||||
|
|
||||||
|
local procedure DescribeInvoice(SalesHeader: Record "Sales Header"): Text
|
||||||
|
begin
|
||||||
|
exit(StrSubstNo('%1|%2|%3|%4', SalesHeader."Sell-to Customer No.", SalesHeader."Posting Date", SalesHeader."Due Date", SalesHeader."Payment Terms Code"));
|
||||||
|
end;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue