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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion evaluation/review-fixtures.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,16 @@
"selection": "first-paired-al-article",
"minimumExpectedRecall": 1.0,
"minimumCleanRate": 1.0,
"coverageWaivers": [],
"coverageWaivers": [
{
"path": "microsoft/knowledge/reporting/do-not-rename-or-remove-shipped-report-dataset-columns.md",
"reason": "The defect is a change relative to a released version; a single neutralized sample cannot show which column names shipped before."
},
{
"path": "microsoft/knowledge/reporting/reportextension-layouts-are-not-selected-automatically.md",
"reason": "The defect is the expectation that the layout becomes the default; the code alone is identical to a legitimate optional layout once comments are removed."
}
],
"overrides": {
"agents": {
"article": "wire-all-three-agent-interfaces"
Expand Down Expand Up @@ -104,6 +113,8 @@
"query": {
"articles": [
"dataitemtablefilter-cannot-be-overwritten-at-runtime",
"every-non-aggregated-query-column-is-a-group-key",
"query-dataitems-default-to-left-outer-join",
"reopening-query-resets-cursor-but-keeps-filters",
"set-query-filters-before-open",
"setfilter-overwrites-query-columnfilter"
Expand All @@ -115,6 +126,7 @@
"currreport-break-ends-the-current-trigger",
"currreport-quit-rolls-back-and-skips-onpostreport",
"currreport-skip-does-not-stop-trigger-code",
"declare-report-layouts-in-the-rendering-section",
"report-output-in-a-loop-needs-one-client-download",
"reportextension-dataitem-trigger-order-is-explicit",
"reportextension-report-triggers-run-after-base-triggers",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// Demonstration only. Shows the wrong pattern: a display column changes the grouping of a totals query.

query 50231 "Customer Sales Totals Bad"
{
QueryType = Normal;

elements
{
dataitem(CustLedgerEntry; "Cust. Ledger Entry")
{
DataItemTableFilter = "Document Type" = const(Invoice);
column(CustomerNo; "Customer No.") { }
// BAD: Document No. varies within each customer, so the query now returns one row per
// customer per invoice instead of one total per customer.
column(DocumentNo; "Document No.") { }
column(SalesLCY; "Sales (LCY)")
{
Method = Sum;
}
}
}
}

codeunit 50232 "Customer Sales Lookup Bad"
{
procedure GetSalesForCustomer(CustomerNo: Code[20]): Decimal
var
CustomerSalesTotals: Query "Customer Sales Totals Bad";
begin
CustomerSalesTotals.SetRange(CustomerNo, CustomerNo);
CustomerSalesTotals.Open();
// Reads only the first invoice's partial total.
if CustomerSalesTotals.Read() then
exit(CustomerSalesTotals.SalesLCY);
end;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Demonstration only. Shows the recommended pattern: only the grouping column is a non-aggregated column.

query 50233 "Customer Sales Totals Good"
{
QueryType = Normal;

elements
{
dataitem(CustLedgerEntry; "Cust. Ledger Entry")
{
DataItemTableFilter = "Document Type" = const(Invoice);
column(CustomerNo; "Customer No.") { }
column(SalesLCY; "Sales (LCY)")
{
Method = Sum;
}
}
}
}

codeunit 50234 "Customer Sales Lookup Good"
{
procedure GetSalesForCustomer(CustomerNo: Code[20]): Decimal
var
CustomerSalesTotals: Query "Customer Sales Totals Good";
begin
CustomerSalesTotals.SetRange(CustomerNo, CustomerNo);
CustomerSalesTotals.Open();
if CustomerSalesTotals.Read() then
exit(CustomerSalesTotals.SalesLCY);
end;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
bc-version: [all]
domain: query
keywords: [query-totals, method, sum, count, average, group-by, grouping, aggregate, query-column]
technologies: [al]
countries: [w1]
application-area: [all]
---

# Every non-aggregated query column is a group key

## Description

As soon as one query column sets `Method` to `Sum`, `Count`, `Average`, `Min`, or `Max`, the query groups its result by all other columns, including columns of other data items and columns that use a date method (`Day`, `Month`, `Year`). There is no explicit GROUP BY; the grouping is implied by the column list. Adding a column "just to display it", such as a document number, a description, or a posting date, therefore silently changes the grain of the result: one total per customer becomes one total per customer per document, and the code that reads one row per customer reads the first partial total or adds duplicates.

Integer `Average` also uses integer division, so averages of `Integer` fields lose their fractional part.

## Best Practice

Treat the non-aggregated column list as the GROUP BY clause. Include only the columns that define the grain you want, and get descriptive values (names, descriptions) either from a column that is constant within the group or from a separate lookup after reading the totals. When a column is needed only for filtering, use `filter` elements or `ColumnFilter`/`DataItemTableFilter` instead of a `column`. Average `Decimal` fields, or compute the average in AL from `Sum` and `Count`.

See sample: [`every-non-aggregated-query-column-is-a-group-key.good.al`](every-non-aggregated-query-column-is-a-group-key.good.al).

## Anti Pattern

A totals query, meaning one with any aggregate `Method`, that also contains a `column` whose value varies within the intended group (document number, line number, entry number, posting date without a date method), while the query's name or consuming code expects one row per group, for example via a single `Read()` or a dictionary keyed by the group field.

See sample: [`every-non-aggregated-query-column-is-a-group-key.bad.al`](every-non-aggregated-query-column-is-a-group-key.bad.al).

## References

[Aggregating data in query objects](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-query-totals-grouping) (an aggregate method groups the dataset by the other columns; integer Average uses integer division) and [Method property](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/properties/devenv-method-property) (date-method columns remain part of the group).
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// Demonstration only. A sales-document count includes customers without any document.

query 50227 "Cust With Sales Docs Bad"
{
QueryType = Normal;

elements
{
dataitem(Customer; Customer)
{
column(CustomerNo; "No.") { }
column(CustomerName; Name) { }
// BAD: no child filter rejects nulls, so unmatched customers survive the default join.
dataitem(SalesHeader; "Sales Header")
{
DataItemLink = "Sell-to Customer No." = Customer."No.";
column(OrderNo; "No.") { }
}
}
}
}

codeunit 50228 "Sales Doc Reminder Bad"
{
procedure CountSalesDocsToRemind(): Integer
var
CustomersWithSalesDocs: Query "Cust With Sales Docs Bad";
Total: Integer;
begin
CustomersWithSalesDocs.Open();
while CustomersWithSalesDocs.Read() do
Total += 1;
CustomersWithSalesDocs.Close();
exit(Total);
end;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// Demonstration only. InnerJoin excludes unmatched rows; a null-rejecting child filter is also valid.

query 50229 "Cust With Sales Docs Good"
{
QueryType = Normal;

elements
{
dataitem(Customer; Customer)
{
column(CustomerNo; "No.") { }
column(CustomerName; Name) { }
dataitem(SalesHeader; "Sales Header")
{
DataItemLink = "Sell-to Customer No." = Customer."No.";
SqlJoinType = InnerJoin;
column(OrderNo; "No.") { }
}
}
}
}

codeunit 50230 "Sales Doc Reminder Good"
{
procedure CountSalesDocsToRemind(): Integer
var
CustomersWithSalesDocs: Query "Cust With Sales Docs Good";
Total: Integer;
begin
CustomersWithSalesDocs.Open();
while CustomersWithSalesDocs.Read() do
Total += 1;
CustomersWithSalesDocs.Close();
exit(Total);
end;

procedure CountOpenOrdersToRemind(): Integer
var
CustomersWithOpenOrders: Query "Cust With Open Orders";
Total: Integer;
begin
CustomersWithOpenOrders.Open();
while CustomersWithOpenOrders.Read() do
Total += 1;
CustomersWithOpenOrders.Close();
exit(Total);
end;
}

query 50235 "Cust With Open Orders"
{
QueryType = Normal;

elements
{
dataitem(Customer; Customer)
{
column(CustomerNo; "No.") { }
column(CustomerName; Name) { }
dataitem(SalesHeader; "Sales Header")
{
DataItemLink = "Sell-to Customer No." = Customer."No.";
DataItemTableFilter = "Document Type" = const(Order), Status = const(Open);
column(OrderNo; "No.") { }
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
bc-version: [all]
domain: query
keywords: [sqljointype, left-outer-join, inner-join, dataitemlink, query-dataitem, null-columns, join]
technologies: [al]
countries: [w1]
application-area: [all]
---

# A lower query data item without SqlJoinType is a left outer join

## Description

When a lower `dataitem` in a query sets `DataItemLink` but omits `SqlJoinType`, the join is `LeftOuterJoin`. Before subsequent filtering, it keeps every row of the upper data item, including rows with no matching record below; for those rows the lower data item's columns are null and are read in AL as blank or zero. Developers coming from SQL, where a plain `JOIN` is an inner join, can incorrectly count these unmatched rows as sales documents or ledger entries.

The final dataset also depends on filters. A null-rejecting child filter applied in the SQL `WHERE` clause removes unmatched rows: for example, `DataItemTableFilter = "Document Type" = const(Order), Status = const(Open)` on Sales Header rejects the null child rows of customers without matching orders. In that case, changing only the join to `InnerJoin` does not fix a row-selection defect; both joins return the same rows.

## Best Practice

Prefer an explicit `SqlJoinType` to communicate intent. Use `InnerJoin` when only matched rows are meaningful. Keep `LeftOuterJoin` when unmatched upper rows are intended, for example to list customers with zero sales, and make the consuming code handle the blank lower columns. A null-rejecting child filter is also a valid way to exclude unmatched rows; evaluate the effective filters and consuming code before reporting a missing join type as a defect.

See sample: [`query-dataitems-default-to-left-outer-join.good.al`](query-dataitems-default-to-left-outer-join.good.al).

## Anti Pattern

A query whose lower data item has a `DataItemLink` and no `SqlJoinType`, where unmatched rows survive the effective filters and are consumed as matches, counted as documents or entries, or read without handling blank lower values. Require evidence of a wrong result, not just a missing property or a suggestive query name.

Do not flag an explicit `SqlJoinType = LeftOuterJoin`, an implicit one where the consumer clearly expects and handles unmatched rows, or a query whose null-rejecting child filters already eliminate unmatched rows. The clean companion includes both an explicit inner join and an implicit outer join constrained by such a filter.

See sample: [`query-dataitems-default-to-left-outer-join.bad.al`](query-dataitems-default-to-left-outer-join.bad.al).

## References

[Linking and joining data items to define the query dataset](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-query-links-joins#how-to-link-and-join-data-items) (by default, SqlJoinType is LeftOuterJoin; unmatched rows get null values), [SqlJoinType property](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/properties/devenv-sqljointype-property), and [Filtering in Query objects](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-query-filters) (data-item filters restrict the resulting dataset).
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// Demonstration only. Shows the wrong pattern: a new report declares its layouts with the legacy properties.

report 50223 "Orders By Salesperson Bad"
{
UsageCategory = ReportsAndAnalysis;
ApplicationArea = All;
// BAD: legacy, type-based layout properties on a new report.
DefaultLayout = Word;
WordLayout = './layouts/OpenOrdersBySalesperson.docx';
ExcelLayout = './layouts/OpenOrdersBySalesperson.xlsx';

dataset
{
dataitem(SalesHeader; "Sales Header")
{
DataItemTableView = where("Document Type" = const(Order));
column(No; "No.") { }
column(SalespersonCode; "Salesperson Code") { }
column(Amount; Amount) { }
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Demonstration only. Shows the recommended pattern: named layouts in the rendering section and a default chosen by name.

report 50224 "Orders By Salesperson Good"
{
UsageCategory = ReportsAndAnalysis;
ApplicationArea = All;
DefaultRenderingLayout = OpenOrdersWord;

dataset
{
dataitem(SalesHeader; "Sales Header")
{
DataItemTableView = where("Document Type" = const(Order));
column(No; "No.") { }
column(SalespersonCode; "Salesperson Code") { }
column(Amount; Amount) { }
}
}

rendering
{
layout(OpenOrdersWord)
{
Type = Word;
LayoutFile = './layouts/OpenOrdersBySalesperson.docx';
Caption = 'Open orders (Word)';
Summary = 'Printable list of open sales orders per salesperson.';
}
layout(OpenOrdersExcel)
{
Type = Excel;
LayoutFile = './layouts/OpenOrdersBySalesperson.xlsx';
Caption = 'Open orders (Excel)';
Summary = 'Open sales orders for analysis in Excel.';
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
bc-version: [20..]
domain: reporting
keywords: [rendering, defaultrenderinglayout, defaultlayout, rdlclayout, wordlayout, excellayout, layoutfile, legacy-layout]
technologies: [al]
countries: [w1]
application-area: [all]
---

# Declare report layouts in the rendering section, not with the legacy layout properties

## Description

Since runtime 9.0 (BC20), reports declare layouts in a `rendering` section of named `layout` entries, each with `Type`, `LayoutFile`, `Caption`, and `Summary`, and choose the default by name with `DefaultRenderingLayout`. The older properties `DefaultLayout`, `RDLCLayout`, `WordLayout`, and `ExcelLayout` allow only one layout per type, select the default by type, and Microsoft documents that they should be avoided because they will be deprecated. Code that reads them also loads the full layout into memory even when it only checks whether a layout of that type exists.

Agents still produce the legacy properties because most published samples and older Base Application code use them.

## Best Practice

In new or changed reports, declare every layout in `rendering`, set `DefaultRenderingLayout` on the report, and give each layout a `Caption` and `Summary` so users can choose between them on the **Report Layouts** page. When touching a report that still uses the legacy properties, convert it with the AL code action **Convert to 'Rendering'**.

See sample: [`declare-report-layouts-in-the-rendering-section.good.al`](declare-report-layouts-in-the-rendering-section.good.al).

## Anti Pattern

A new report, or a changed report's layout declaration, that uses `DefaultLayout` together with `RDLCLayout`, `WordLayout`, or `ExcelLayout`.

Do not flag unchanged legacy declarations in code outside the review scope, or projects whose target runtime is below 9.0.

See sample: [`declare-report-layouts-in-the-rendering-section.bad.al`](declare-report-layouts-in-the-rendering-section.bad.al).

## References

[DefaultLayout property](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/properties/devenv-defaultlayout-property) (avoid the legacy properties; they will be deprecated; reading them loads the full layout), [Declare report layouts in AL](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-report-layout-declaration), and [Defining multiple report layouts](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-multiple-report-layouts).
Loading
Loading