Skip to main content

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();
}
warning

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();
danger

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;
}
warning

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

AspectRow-by-RowSet-Based
SQL round tripsOne per recordOne total
Performance on 1,000 rowsSecondsMilliseconds
Performance on 100,000 rowsMinutesSeconds
Triggers insert()/update()/delete() overridesYesNo
Triggers database eventsPer recordBulk
danger

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.

warning

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));
}
}