13.1 Record Operations: Get, FindSet, ModifyAll & Filter Functions
Key Takeaways
- Get() executes an immediate primary key lookup directly against SQL Server, bypassing and clearing all active record filters, returning a Boolean indicating whether the exact record exists.
- FindSet(ForUpdate, UpdateKey) is the standard AL method for traversing filtered recordsets using fast-forward cursor caching, where ForUpdate applies UPDLOCK SQL hints and UpdateKey prevents index-skipping anomalies.
- Set-based operations ModifyAll() and DeleteAll() execute high-performance bulk database updates as single direct T-SQL statements when RunTrigger is false, but iterate row-by-row when RunTrigger is true.
- FilterGroup(6..255) establishes developer-protected filter scopes that are completely hidden from and unchangeable by end-users in the web client Filter Pane, while FilterGroup(0) represents the standard user filter layer.
- FlowFields require explicit evaluation via CalcFields() or declarative pre-fetching via SetAutoCalcFields(), while CalcSums() executes a single optimized SQL aggregate query across SumIndexFields (SIFT) without loading table rows into memory.
13.1 Record Operations: Get, FindSet, ModifyAll & Filter Functions
In Microsoft Dynamics 365 Business Central, the Record data type is the core abstraction through which AL code interacts with persistent SQL database tables. Understanding how AL record methods translate into underlying Transact-SQL (T-SQL) queries, how locking behaviors impact multi-user concurrency, how filter groups protect business rules, and how virtual FlowFields are evaluated is essential for designing scalable enterprise extensions and achieving success on the MB-820 certification exam.
1. Finding & Navigating Records in AL
AL provides a rich set of retrieval and navigation functions. Selecting the optimal method directly dictates SQL Server query execution plans, middle-tier Navision Server Tier (NST) data caching, network payload size across Tabular Data Stream (TDS) connections, and multi-user concurrency.
local procedure DemonstrateRecordRetrievalPatterns()
var
Customer: Record Customer;
SalesLine: Record "Sales Line";
ItemLedgerEntry: Record "Item Ledger Entry";
begin
// 1. Direct Primary Key Lookup with Get()
// Bypasses any existing filters on the record variable
if Customer.Get('10000') then
Message('Customer Name: %1', Customer.Name);
// 2. Traversing Filtered Recordsets with FindSet()
Customer.Reset();
Customer.SetRange("Customer Posting Group", 'DOMESTIC');
Customer.SetRange("Blocked", Customer.Blocked::" ");
if Customer.FindSet(false, false) then
repeat
// Read-only processing of each customer record
until Customer.Next() = 0;
// 3. Single Boundary Retrieval with FindFirst() and FindLast()
SalesLine.SetRange("Document Type", SalesLine."Document Type"::Order);
SalesLine.SetRange("Document No.", 'SO-1001');
if SalesLine.FindFirst() then
Message('First Line Item: %1', SalesLine."No.");
if SalesLine.FindLast() then
Message('Last Line No.: %1', SalesLine."Line No.");
// 4. High-Performance Existence Check with IsEmpty()
ItemLedgerEntry.SetRange("Item No.", 'ITEM-001');
ItemLedgerEntry.SetRange(Open, true);
if not ItemLedgerEntry.IsEmpty() then
Message('Active open ledger entries exist for this item.');
end;
Record Retrieval Methods Reference
| Method | Underlying SQL Translation | Locking Applied | Best Practice & Performance Scenario |
|---|---|---|---|
Get(PK1, [PK2...]) | SELECT ... WHERE PK = @PK | None (Shared Read) | Retrieving a single record when the complete primary key is known. Clears all active filters on the record variable. |
FindSet([ForUpdate], [UpdateKey]) | SELECT ... WHERE ... ORDER BY Key | Shared Read, or UPDLOCK if ForUpdate = true | Looping through a filtered set of records using a repeat...until Rec.Next() = 0 loop. Utilizes fast-forward cursor caching on the NST. |
FindFirst() | SELECT TOP 1 ... WHERE ... ORDER BY Key ASC | Shared Read | Retrieving exclusively the first matching record in the filtered set. Does not cache subsequent rows. |
FindLast() | SELECT TOP 1 ... WHERE ... ORDER BY Key DESC | Shared Read | Retrieving exclusively the last matching record in the filtered set (e.g., finding the highest line number). |
Find('-') / Find('+') | SELECT ... (Dynamic Cursor) | Shared Read | Legacy C/SIDE cursor navigation. Obsolete for looping; replaced by FindSet() for sets and FindFirst() / FindLast() for boundaries. |
IsEmpty() | SELECT TOP 1 1 WHERE ... | None | Checking if any matching records exist without loading field columns or companion tables into NST memory. |
Count() | SELECT COUNT(*) WHERE ... | Shared Read | Calculating the exact total number of matching rows. Expensive on large tables without covering indexes. |
Deep Dive: FindSet(ForUpdate, UpdateKey)
The FindSet() method is specifically optimized for looping through recordsets. It takes two optional Boolean arguments that control database locking and cursor stabilization:
-
ForUpdate(Boolean, defaultfalse):- When set to
true, the SQL query generated by the NST includes theUPDLOCK(and potentiallyROWLOCK) table hint. - This instructs SQL Server to acquire update locks immediately during data read, signaling an intention to modify or delete the records later in the transaction.
- Why this matters: Placing
UPDLOCKduringFindSet(true)prevents deadlocks when two concurrent sessions read the same records with shared locks and subsequently attempt to elevate their shared locks to exclusive write locks (Xlocks).
- When set to
-
UpdateKey(Boolean, defaultfalse):- When set to
true, it informs the NST and SQL Server that the AL loop intends to modify one or more fields that comprise the currently active sorting key (CurrentKey). - Why this matters: When a key field value is modified inside a loop, SQL Server may reposition the record within the index B-tree. Without
UpdateKey = true, the cursor might encounter the modified record a second time or skip adjacent records entirely (index cursor oscillation). SettingUpdateKey = truecauses the runtime to buffer the keys safely to maintain consistent traversal.
- When set to
Exam Performance Rule:
- Never use
FindSet()if you only need a single record; useFindFirst().FindSet()reads a block of records (default 50 rows) into the NST cache, creating unnecessary database I/O when only one record is inspected.- Never use
if Rec.Count() > 0 thenorif Rec.FindSet() thenmerely to check for record existence. Always useif not Rec.IsEmpty() then, which generatesSELECT TOP 1 1and stops scanning immediately upon finding the first match without hydrating record buffers.
2. Record Modification & Set-Based Bulk Operations
AL provides both row-level manipulation statements (Insert, Modify, Delete, Rename) and high-throughput set-based operations (ModifyAll, DeleteAll).
local procedure DemonstrateDataModifications()
var
Customer: Record Customer;
SalesHeader: Record "Sales Header";
SalesLine: Record "Sales Line";
begin
// 1. Row-Level Insert with Trigger Execution
Customer.Init();
Customer."No." := 'CUST-88001';
Customer.Name := 'Fabrikam Logistics Corp';
Customer."Gen. Bus. Posting Group" := 'DOMESTIC';
Customer."Customer Posting Group" := 'DOMESTIC';
Customer.Insert(true); // Runs OnInsert table trigger and subscriber events
// 2. Row-Level Modify without Trigger Execution
Customer."Credit Limit (LCY)" := 150000;
Customer.Modify(false); // Bypasses OnModify trigger logic
// 3. Primary Key Renaming with Cascading Updates
Customer.Get('CUST-88001');
Customer.Rename('CUST-88999'); // Automatically updates all foreign key relations
// 4. Set-Based Bulk Update with ModifyAll
SalesLine.SetRange("Document Type", SalesLine."Document Type"::Order);
SalesLine.SetRange("Document No.", 'SO-1001');
SalesLine.SetRange(Type, SalesLine.Type::Item);
// Direct T-SQL UPDATE statement executed on SQL Server (RunTrigger = false)
SalesLine.ModifyAll("Location Code", 'MAIN', false);
// 5. Set-Based Bulk Delete with DeleteAll
SalesHeader.SetRange("Document Type", SalesHeader."Document Type"::Quote);
SalesHeader.SetRange(Status, SalesHeader.Status::Draft);
// Direct T-SQL DELETE statement executed on SQL Server (RunTrigger = false)
SalesHeader.DeleteAll(false);
end;
Row-Level Manipulation & The RunTrigger Parameter
All row-level mutation methods (Insert, Modify, Delete) accept an optional RunTrigger: Boolean parameter (defaulting to false if omitted):
Insert([RunTrigger]): WhenRunTrigger = true, executes the table'sOnInserttrigger, initializes No. Series logic, sets default timestamps, and firesOnBeforeInsert/OnAfterInsertevent subscribers.Modify([RunTrigger]): WhenRunTrigger = true, executes the table'sOnModifytrigger, validating state transitions and firing modification subscribers.Delete([RunTrigger]): WhenRunTrigger = true, executes the table'sOnDeletetrigger (frequently used to cascade deletions to document lines, comments, or archive tables).Rename(NewPK1, [NewPK2...]): Changes the primary key of an existing record. In addition to executing theOnRenametrigger, Business Central inspects the entire data dictionary and automatically cascades the new primary key value to all related foreign key fields across all tables where aTableRelationis defined.
Set-Based Bulk Operations: ModifyAll & DeleteAll
ModifyAll(Field, NewValue [, RunTrigger]) and DeleteAll([RunTrigger]) execute mass updates or deletions across all records matching current filters:
ModifyAll / DeleteAll (RunTrigger = false) ──► Single Direct SQL UPDATE/DELETE ──► Minimal I/O
ModifyAll / DeleteAll (RunTrigger = true) ──► Iterates Each Record in NST ──► Runs OnModify/OnDelete Triggers
- When
RunTrigger = false(Default):- The NST constructs a single direct T-SQL
UPDATEorDELETEstatement with aWHEREclause matching the active filters and sends it directly to SQL Server. - Individual records are never fetched into NST memory, companion tables are updated via set-based joins, and execution completes in milliseconds regardless of row count.
- The NST constructs a single direct T-SQL
- When
RunTrigger = true:- The NST must fetch every matching record over the network into memory one by one, execute the table's
OnModifyorOnDeletetrigger along with all subscribed event handlers in AL, and issue individual SQL statements for each record. - Performance trade-off: Use
RunTrigger = trueonly when business validation or audit logging on each record is strictly required.
- The NST must fetch every matching record over the network into memory one by one, execute the table's
3. Filtering Records & Scoping with FilterGroups
Filters define the subset of records returned by database queries. AL provides several methods to construct, inspect, copy, and isolate filters across different execution scopes.
local procedure ApplyAdvancedFilters(var Item: Record Item)
var
FilterString: Text;
begin
// 1. Simple Range Filter with SetRange
Item.SetRange("Unit Price", 50, 250); // Unit Price BETWEEN 50 AND 250
Item.SetRange(Blocked); // Clearing filter on Blocked by omitting parameters
// 2. Complex Wildcard Expression with SetFilter
// Matches items starting with 'CHAIR' or 'DESK', excluding 'CHAIR-09'
Item.SetFilter("No.", 'CHAIR*|DESK*&<>CHAIR-09');
// Case-insensitive match containing 'wood'
Item.SetFilter(Description, '@*wood*');
// 3. Inspecting and Copying Filters
FilterString := Item.GetFilter("No.");
Message('Active Filter on No.: %1', FilterString);
Message('All Active Filters: %1', Item.GetFilters());
// 4. Resetting Filters and Key Sorting
Item.Reset(); // Removes all filters, marks, and resets sorting to Primary Key
end;
Filter Expression Syntax Reference
| Expression | Meaning | Example | Result |
|---|---|---|---|
.. | Value Range (Inclusive) | '1000..2000' | Matches values between 1000 and 2000 inclusive. |
| ` | ` | Disjunction (OR) | `'1000 |
& | Conjunction (AND) | '>100&<500' | Matches values strictly greater than 100 AND less than 500. |
<> | Inequality (NOT) | '<>0' | Matches all values not equal to 0. |
@ | Case-Insensitive | '@*chair*' | Matches 'Chair', 'CHAIR', 'armchair', 'Desk Chair'. |
* | Multi-Character Wildcard | 'A*' | Matches any string starting with 'A' ('Apple', 'Account'). |
? | Single-Character Wildcard | '?001' | Matches 'A001', 'B001', '1001'. |
'' | Empty / Blank Value | '''' or '' | Matches blank/empty text or zero date (0D). |
Scoping Filters with FilterGroup
Every record buffer maintains multiple distinct filter layers called FilterGroups (indexed from -1 to 255). When Business Central builds the final SQL WHERE clause, it applies an AND operation across all active filter groups.
| FilterGroup Index | Name / Purpose | UI Behavior & Operational Characteristics |
|---|---|---|
0 | Standard User Filter | Default filter group. Filters applied here appear in the web client Filter Pane and can be inspected, altered, or cleared by users. |
-1 | Cross-Column QuickFilter | Used by the UI search box to execute cross-column OR searches across visible fields. |
1 | Internal Subform Link | Applied automatically by page parts and subforms to bind child lines to parent header primary keys. |
2 | Form/Page Link Filter | Used by pages when opened via RunPageLink properties or action drilldowns. |
3 | Page Search Filter | Internal filter group used for page search indexing. |
4 | TableView Filter | Applied when a page is initialized with a static SourceTableView property. |
6 to 255 | Developer / Security Groups | Hidden and protected. Filters applied in groups 6..255 do not appear in the web client Filter Pane and cannot be viewed, modified, or removed by users. |
local procedure ApplyProtectedDepartmentFilter(var CustLedgEntry: Record "Cust. Ledger Entry"; DeptCode: Code[20])
begin
// Switch to developer protected FilterGroup 6
CustLedgEntry.FilterGroup(6);
CustLedgEntry.SetRange("Global Dimension 1 Code", DeptCode);
// Switch back to default user FilterGroup 0
CustLedgEntry.FilterGroup(0);
CustLedgEntry.SetRange("Posting Date", 20260101D, 20261231D);
// The user can freely change the Posting Date filter in the Web Client,
// but the Global Dimension 1 Code filter remains strictly enforced and invisible.
end;
4. Calculating FlowFields, SIFT Aggregations & Partial Records
FlowFields are dynamic calculation fields defined on tables (such as Customer."Balance (LCY)" or Item."Inventory"). Because FlowField values are not stored physically in table columns, they are not populated by standard record retrieval methods and must be evaluated on demand.
local procedure DemonstrateFlowFieldCalculations()
var
Customer: Record Customer;
CustLedgerEntry: Record "Cust. Ledger Entry";
TotalCustomerBalance: Decimal;
begin
// 1. Explicit On-Demand Calculation with CalcFields()
if Customer.Get('10000') then begin
Customer.CalcFields("Balance (LCY)", "Net Change (LCY)");
Message('Customer Balance: %1, Net Change: %2',
Customer."Balance (LCY)", Customer."Net Change (LCY)");
end;
// 2. Declarative Auto-Calculation for Recordsets with SetAutoCalcFields()
Customer.Reset();
Customer.SetRange("Customer Posting Group", 'DOMESTIC');
// Automatically evaluates Balance (LCY) in the main SELECT query for each row
Customer.SetAutoCalcFields("Balance (LCY)");
if Customer.FindSet() then
repeat
TotalCustomerBalance += Customer."Balance (LCY)";
until Customer.Next() = 0;
// 3. SIFT Aggregate Calculation with CalcSums()
CustLedgerEntry.SetRange("Customer No.", '10000');
CustLedgerEntry.SetRange(Open, true);
// Executes a single optimized SQL SUM query against SumIndexFields (SIFT)
CustLedgerEntry.CalcSums("Remaining Amount", "Amount (LCY)");
Message('Total Open Amount: %1', CustLedgerEntry."Remaining Amount");
end;
CalcFields() vs. SetAutoCalcFields() vs. CalcSums()
| Method | Target Scope | Execution Pattern | SQL Mechanics |
|---|---|---|---|
CalcFields(Field1, [Field2...]) | Single active record buffer | On-demand explicit call | Executes a subquery or join for the specified FlowFields for that specific row. Calling CalcFields() inside a loop causes $N$ separate subqueries ($N+1$ query anti-pattern). |
SetAutoCalcFields(Field1, [Field2...]) | Recordset iterator / queries | Declarative pre-configuration | Injects FlowField subqueries directly into the primary SELECT statement generated by subsequent FindSet(), FindFirst(), or Get() calls, eliminating roundtrips. |
CalcSums(DecimalField1, [DecimalField2...]) | Filtered recordset | Aggregate calculation | Executes a single SELECT SUM(Field) FROM SIFT_View WHERE ... against SQL Server SumIndexField Technology (SIFT) tables without loading any data rows into memory. |
Modern Performance Optimization: Partial Records (SetLoadFields)
By default, AL fetches all fields declared on a table and all its active companion tables. In high-volume routines, loading dozens of unneeded columns causes high memory usage and unneeded SQL joins. Partial Records allow developers to explicitly designate which fields to load:
local procedure ProcessCustomerNamesOnly()
var
Customer: Record Customer;
begin
Customer.SetLoadFields("No.", Name, "Customer Posting Group");
if Customer.FindSet() then
repeat
// Only No., Name, and Customer Posting Group are fetched over TDS
ProcessCustomerName(Customer."No.", Customer.Name);
until Customer.Next() = 0;
end;
- If AL code subsequently accesses an unloaded field (e.g.,
Customer."Credit Limit (LCY)"), the NST automatically executes an implicit JIT (Just-In-Time) read to fetch the remaining fields, ensuring backward compatibility at the expense of an extra SQL query.
A developer needs to iterate through 10,000 Sales Line records to inspect and update the 'Special Discount %' field. The sorting key is set to 'Document Type, Document No., Line No.', and the code will NOT modify any of these key fields. Which AL record retrieval statement provides the most optimal performance and lock management?
A developer is creating an extension that restricts sales representatives to viewing only sales invoices associated with their assigned Responsibility Center. The developer wants to ensure that users cannot see or clear this filter in the web client Filter Pane. How should this filter be applied in AL?
An AL routine processes 50,000 Vendor Ledger Entry records to compute the grand total of all open vendor ledger balances for a given posting date range. Which approach executes with the fastest performance and lowest database overhead?
A developer executes SalesHeader.ModifyAll(Status, SalesHeader.Status::Released, false) on a filtered set of 1,000 sales orders. How does the Business Central Server (NST) execute this operation against SQL Server?