Code Examples
Practical X++ code patterns for working with tables — from basic CRUD to method overrides, set-based operations, and concurrency handling.
Table Method Overrides
Every table inherits virtual methods from the Common class. Override these on individual tables to inject business logic at specific points in the record lifecycle.
insert() / update() / delete()
public void insert()
{
this.SAMONumberSequence = NumberSeq::newGetNum(samoNumSeqRef).num();
super();
this.samoCreateDefaultDimensions();
}
public void update()
{
SAMOTable orig = this.orig();
if (this.Status != orig.Status)
{
this.StatusChangedDateTime = DateTimeUtil::utcNow();
}
super();
}
public void delete()
{
SAMOChildTable::deleteForParent(this.RecId);
super();
}
If super() is omitted in insert(), update(), or delete(), the database operation will not execute.
doInsert() / doUpdate() / doDelete()
These execute the database operation directly, bypassing all overridden logic, events, and database logging. Use only for intentional bypasses such as data migration.
// Bypasses all custom insert() logic and events
samoTable.doInsert();
doInsert(), doUpdate(), and doDelete() skip table-level business logic, events, database logging, and alert rules. Use with extreme caution.
initValue()
Called by the runtime when a new record buffer is created in a form context (user clicks "New"). Not called during direct X++ inserts unless explicitly invoked.
public void initValue()
{
super();
this.TransDate = DateTimeUtil::getSystemDate(DateTimeUtil::getUserPreferredTimeZone());
this.CurrencyCode = CompanyInfo::standardCurrency();
this.Status = SAMOStatus::Draft;
}
clear()
Resets every field in the buffer to its default value. Useful when reusing a buffer to insert multiple records:
SAMOTable samoTable;
for (int i = 1; i <= 10; i++)
{
samoTable.clear();
samoTable.Name = strFmt("Record %1", i);
samoTable.insert();
}
validateWrite()
Called before insert() and update(). If it returns false, the write is cancelled.
public boolean validateWrite()
{
boolean ret = super();
if (ret && this.StartDate > this.EndDate)
{
ret = checkFailed("Start date cannot be after end date.");
}
return ret;
}
validateField(FieldId _fieldId)
Called when a field value changes on a form. Validates individual field values immediately.
public boolean validateField(FieldId _fieldId)
{
boolean ret = super(_fieldId);
switch (_fieldId)
{
case fieldNum(SAMOTable, Quantity):
if (this.Quantity < 0)
{
ret = checkFailed("Quantity cannot be negative.");
}
break;
}
return ret;
}
validateDelete()
Called before a record is deleted. If it returns false, the delete is cancelled.
public boolean validateDelete()
{
boolean ret = super();
if (ret && this.Status == SAMOStatus::Posted)
{
ret = checkFailed("Cannot delete a posted record.");
}
return ret;
}
modifiedField(FieldId _fieldId)
Called after a field value has changed on a form. Use for cascading field updates. Unlike validateField(), it does not return a boolean — it cannot cancel the change.
public void modifiedField(FieldId _fieldId)
{
super(_fieldId);
switch (_fieldId)
{
case fieldNum(SAMOTable, CustAccount):
this.CustName = CustTable::find(this.CustAccount).Name;
break;
case fieldNum(SAMOTable, ItemId):
this.ItemName = InventTable::find(this.ItemId).itemName();
this.DefaultPrice = InventTable::find(this.ItemId).salesPrice();
break;
}
}
reread()
Re-reads the current record from the database, discarding in-memory changes. Important in concurrency scenarios.
samoTable.reread();
// Buffer now reflects the latest database state
Valid Time State Queries
When querying a valid time state table, the runtime automatically filters to records valid at the current date/time unless overridden:
// Default: only records valid right now
select samoVTSTable where samoVTSTable.Key == someKey;
// Query as of a specific date
select validTimeState(asOfDate) samoVTSTable
where samoVTSTable.Key == someKey;
// Query a date range — returns all records overlapping the range
select validTimeState(fromDate, toDate) samoVTSTable
where samoVTSTable.Key == someKey;
Basic CRUD
Insert
static void insertExample()
{
CustTable custTable;
ttsbegin;
custTable.AccountNum = 'CUST-001';
custTable.CustGroup = 'DOM';
custTable.Currency = 'USD';
custTable.insert();
ttscommit;
}
Read (select)
static void selectExample()
{
CustTable custTable;
// Single record by primary key
select firstonly custTable
where custTable.AccountNum == 'CUST-001';
if (custTable)
{
info(strFmt("Found: %1", custTable.AccountNum));
}
}
Update
static void updateExample()
{
CustTable custTable;
ttsbegin;
select forupdate custTable
where custTable.AccountNum == 'CUST-001';
if (custTable)
{
custTable.CustGroup = 'INT';
custTable.update();
}
ttscommit;
}
Always use the forupdate keyword when selecting records for update. Without it, the record buffer is read-only and calling update() will throw an error.
Delete
static void deleteExample()
{
CustTable custTable;
ttsbegin;
select forupdate custTable
where custTable.AccountNum == 'CUST-001';
if (custTable)
{
custTable.delete();
}
ttscommit;
}
While-Select Loop
Process multiple records using a while select loop:
static void whileSelectExample()
{
SalesLine salesLine;
while select salesLine
where salesLine.SalesId == 'SO-001'
{
info(strFmt("Item: %1, Qty: %2",
salesLine.ItemId, salesLine.SalesQty));
}
}
With ordering and field selection
static void orderedSelectExample()
{
VendTable vendTable;
while select AccountNum, VendGroup from vendTable
order by AccountNum asc
where vendTable.VendGroup == 'SERVICES'
{
info(vendTable.AccountNum);
}
}
Find and Exist Patterns
Most tables implement static find and exist methods. This is a standard D365 pattern:
// find — returns the record buffer or an empty buffer
public static CustTable find(
CustAccount _accountNum,
boolean _forUpdate = false)
{
CustTable custTable;
if (_accountNum)
{
custTable.selectForUpdate(_forUpdate);
select firstonly custTable
where custTable.AccountNum == _accountNum;
}
return custTable;
}
// exist — returns true if a matching record exists
public static boolean exist(CustAccount _accountNum)
{
return _accountNum
&& (select firstonly RecId from custTable
where custTable.AccountNum == _accountNum
).RecId != 0;
}
Usage:
// Find a customer record, optionally for update
CustTable cust = CustTable::find('CUST-001', true);
// Check existence without loading the full record
if (CustTable::exist('CUST-001'))
{
// ...
}
Set-Based Operations
Row-by-row operations (select → modify → update in a loop) execute one SQL statement per record. On large datasets, this is extremely slow. D365 provides three set-based operations that execute a single SQL statement affecting all matching rows at once.
update_recordset
Updates multiple records in a single SQL UPDATE statement:
static void updateRecordsetExample()
{
CustTable custTable;
ttsbegin;
update_recordset custTable
setting CustGroup = 'KEY'
where custTable.CustGroup == 'DOM'
&& custTable.Currency == 'USD';
ttscommit;
info(strFmt("Updated %1 records", custTable.RowCount()));
}
This executes as:
UPDATE CustTable SET CustGroup = 'KEY'
WHERE CustGroup = 'DOM' AND Currency = 'USD'
delete_from
Deletes multiple records in a single SQL DELETE statement:
static void deleteFromExample()
{
SalesLine salesLine;
ttsbegin;
delete_from salesLine
where salesLine.SalesId == 'SO-CANCELLED'
&& salesLine.SalesStatus == SalesStatus::None;
ttscommit;
}
insert_recordset
Inserts records from a query result in a single SQL INSERT … SELECT statement:
static void insertRecordsetExample()
{
SalesLineArchive archive;
SalesLine salesLine;
ttsbegin;
insert_recordset archive (SalesId, ItemId, SalesQty)
select SalesId, ItemId, SalesQty from salesLine
where salesLine.SalesStatus == SalesStatus::Invoiced;
ttscommit;
}
Row-by-Row vs Set-Based Comparison
| Aspect | Row-by-Row | Set-Based |
|---|---|---|
| SQL round trips | One per record | One total |
| Performance on 1,000 rows | Seconds | Milliseconds |
| Performance on 100,000 rows | Minutes | Seconds |
Triggers insert()/update()/delete() overrides | Yes | No |
| Triggers database events | Per record | Bulk |
Set-based operations bypass the table's insert(), update(), and delete() method overrides. If your business logic depends on these overrides (e.g., number sequences, validation, event publishing), you must either use row-by-row operations or replicate the logic before/after the set-based call.
Forcing Set-Based with skipDataMethods
If you explicitly want to bypass the table methods and ensure the operation runs as set-based:
ttsbegin;
update_recordset custTable
setting CustGroup = 'NEW'
where custTable.CustGroup == 'OLD';
// No need to call skipDataMethods — update_recordset is already set-based.
// skipDataMethods is used on delete_from and insert_recordset if the
// kernel detects overridden methods and would otherwise fall back to row-by-row.
ttscommit;
Optimistic Concurrency
D365 uses optimistic concurrency control (OCC) to handle simultaneous updates. Every record has a RecVersion field that acts as a version stamp. When you update a record, the system checks that RecVersion has not changed since you read it. If another process modified the record in the meantime, the update fails with an UpdateConflict exception.
Handling Update Conflicts
static void occExample()
{
CustTable custTable;
int retryCount = 0;
const int maxRetries = 3;
try
{
ttsbegin;
select forupdate custTable
where custTable.AccountNum == 'CUST-001';
if (custTable)
{
custTable.CreditMax = custTable.CreditMax + 1000;
custTable.update();
}
ttscommit;
}
catch (Exception::UpdateConflict)
{
if (retryCount < maxRetries)
{
retryCount++;
retry; // Re-executes the try block from ttsbegin
}
else
{
throw Exception::UpdateConflictNotRecovered;
}
}
}
Using reread()
If you read a record early in a long process and want to ensure you have the latest version before updating, call reread():
ttsbegin;
// custTable was read earlier in the process
custTable.reread(); // Refreshes from database with current RecVersion
custTable.CreditMax = newCreditLimit;
custTable.update();
ttscommit;
Transaction Scope
All data modifications must be enclosed in ttsbegin / ttscommit blocks. These are nestable — each ttsbegin increments a counter and each ttscommit decrements it. The database transaction only commits when the outermost ttscommit executes.
ttsbegin; // ttsLevel = 1
// Insert order header
salesTable.insert();
ttsbegin; // ttsLevel = 2
// Insert order lines
salesLine.SalesId = salesTable.SalesId;
salesLine.insert();
ttscommit; // ttsLevel = 1
ttscommit; // ttsLevel = 0 → COMMIT
If an exception occurs or ttsabort is called at any level, the entire transaction — including all nested levels — is rolled back.
ttsabort rolls back the entire transaction, not just the current nesting level. Use it only when the entire operation must be abandoned.
Query Objects
For dynamic queries where the filter conditions are not known at compile time, use the Query and QueryRun classes:
static void queryObjectExample()
{
Query query;
QueryBuildDataSource qbds;
QueryBuildRange qbr;
QueryRun queryRun;
CustTable custTable;
query = new Query();
qbds = query.addDataSource(tableNum(CustTable));
qbr = qbds.addRange(fieldNum(CustTable, CustGroup));
qbr.value('DOM');
queryRun = new QueryRun(query);
while (queryRun.next())
{
custTable = queryRun.get(tableNum(CustTable));
info(custTable.AccountNum);
}
}
Cross-Company Queries
By default, all queries are filtered to the current company (legal entity). To query across companies:
static void crossCompanyExample()
{
CustTable custTable;
// Query all companies
while select crosscompany custTable
where custTable.CustGroup == 'VIP'
{
info(strFmt("%1 / %2",
custTable.DataAreaId, custTable.AccountNum));
}
// Query specific companies
container companies = ['DAT', 'USMF', 'DEMF'];
while select crosscompany : companies custTable
where custTable.CustGroup == 'VIP'
{
info(strFmt("%1 / %2",
custTable.DataAreaId, custTable.AccountNum));
}
}