一键导入
xppai-init
Use when working with any X++ AX 2009 code, analysis, review, or fix task — loads foundational AX 2009 and X++ knowledge required by all xppai skills.
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
菜单
Use when working with any X++ AX 2009 code, analysis, review, or fix task — loads foundational AX 2009 and X++ knowledge required by all xppai skills.
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
基于 SOC 职业分类
Use when analyzing X++ AX 2009 profiler traces, code stack traces, or execution paths to identify performance bottlenecks — repeated call chains, quadratic loops, expensive constructor+calc combos, excessive tax/totals recalculation, or deep framework cascades.
Use when reviewing X++ or general ERP source code for architectural weaknesses, design gaps, fragile sequencing, responsibility mismatches, technical debt, or structural risks — not just syntax or performance issues.
Use when given any X++ AX 2009 artifact — stack trace, method, class, form XPO, posting code, or table — and you need a full structured multi-skill analysis applied automatically based on artifact type.
Use when proposing X++ AX 2009 code fixes for identified performance bottlenecks or behavioral bugs — minimal, production-safe changes that preserve business logic and fit ERP team review standards.
Use for all XppAI AX 2009 tasks as the canonical core baseline (architecture, language, lifecycle, hard constraints, and evidence labels).
Use when deep AX 2009 analysis is required (performance, posting, totals/tax, framework patterns, customization safety, and architecture judgment).
| name | xppai-init |
| description | Use when working with any X++ AX 2009 code, analysis, review, or fix task — loads foundational AX 2009 and X++ knowledge required by all xppai skills. |
BASE layer + REAL WORK layer. No D365, no cloud, no modern AX. AX 2009 and X++ as used in real production projects.
The AOT is the central repository for all application objects. Everything — tables, classes, forms, queries, enums, EDTs, jobs — lives in the AOT. Customizations are layered on top of standard objects using the layer system (SYS → GLS → ISP → ISV → VAR → CUS → USR). Higher layers override lower layers per method.
| Object | Role | Where code lives |
|---|---|---|
| Table | Data structure + business logic close to data | Table methods: find, exist, insert, update, delete, validateWrite, modifiedField, initValue |
| Class | Reusable logic, services, helpers, RunBase jobs | Methods on the class; new, run, main, construct |
| Form | UI layer — displays and interacts with data | Form methods + datasource methods + control methods |
| Query | Reusable query definitions | Used by forms, reports, classes |
| Enum | Fixed value sets (e.g. NoYes, PurchStatus) | Referenced in conditions and field types |
| EDT | Extended Data Type — typed field definitions with labels, relations | Used as field types on tables and method signatures |
| Map | Abstract field mapping across multiple tables | Allows shared method logic across tables with same field structure |
| View | Read-only SQL view defined in AOT | Used for reporting and display datasources |
The AX 2009 IDE. Code is written and compiled inside MorphX. All objects are stored as metadata in the AOT, not as flat files. .xpo files are export snapshots of AOT objects.
// Variable declarations at TOP of method — always, no exceptions in AX 2009
void myMethod()
{
SalesTable salesTable;
int counter;
boolean found;
; // semicolon separator between declarations and statements (AX 2009 style)
// code here
}
find() — standard table lookup
// Returns one record by primary key. Static method on the table.
salesTable = SalesTable::find(salesId);
salesTable = SalesTable::find(salesId, true); // true = forupdate
exist() — boolean check without fetching full record
if (SalesTable::exist(salesId))
{
// ...
}
while select — standard iteration
while select salesLine
where salesLine.SalesId == salesTable.SalesId
{
// process salesLine
}
display method — computed field shown on form, not stored
// On the table, used as a datasource field on forms
display Amount totalAmount()
{
return this.SalesQty * this.SalesPrice;
}
modifiedField — fires when a field changes on a form
// On the table — called by the form datasource
public void modifiedField(FieldId _fieldId)
{
switch (_fieldId)
{
case fieldNum(SalesLine, ItemId):
this.initFromInventTable(InventTable::find(this.ItemId));
break;
}
super(_fieldId);
}
validateWrite — called before insert/update
public boolean validateWrite()
{
boolean ret = super();
if (!this.ItemId)
ret = checkFailed("Item is required");
return ret;
}
form.init() → sets up datasources, queries, controls
form.run() → executes query, renders UI
datasource.active() → fires when current record changes (navigation, filter)
control.modified() → fires when a field value changes in the UI
datasource.write() → fires on save (calls table.validateWrite + insert/update)
| Method | When it fires | Typical use |
|---|---|---|
init() | Once on form open | Set up query ranges, field visibility, caching |
executeQuery() | On query run/re-run | Rarely overridden; use research() to re-run |
active() | On every record navigation | Update field states, button enables, dependent UI |
refresh() | UI repaint — no reread | Use when buffer already updated |
reread() | Re-fetches current record from DB | Use after external update |
research() | Re-runs query, returns to current record | Use after data change affecting query |
write() | Before save | Rarely overridden on form; prefer table methods |
| Logic type | Correct location |
|---|---|
| Field defaulting | table.initValue(), table.modifiedField() |
| Validation | table.validateWrite(), table.validateField() |
| Calculations | Table methods or separate class |
| UI state (enable/disable) | Form active() or form-level methods |
| Complex processes | RunBase class or service class |
Do NOT put business logic in form control modified() methods — it will not run in non-UI contexts (batch, integration).
// Basic
select firstOnly salesTable
where salesTable.SalesId == 'SO-001';
// With index hint (performance)
select firstOnly salesTable
index hint SalesIdx
where salesTable.SalesId == 'SO-001';
// forupdate — required before modifying
select forupdate salesTable
where salesTable.SalesId == 'SO-001';
// join
select salesLine
join salesTable
where salesTable.SalesId == salesLine.SalesId
&& salesTable.CustAccount == 'C-001';
// count
select count(RecId) from salesLine
where salesLine.SalesId == salesTable.SalesId;
// result is in salesLine.RecId (AX 2009 aggregate behavior)
ttsBegin;
// select forupdate + modify within this scope
salesTable.SalesName = 'Updated';
salesTable.update();
ttsCommit;
RecordInsertList ril = new RecordInsertList(tableNum(SalesLine));
// add records in loop
ril.add(salesLine);
// single DB operation
ril.insertDatabase();
| Keyword | Effect |
|---|---|
firstOnly | Returns at most one record — stops after first match |
firstFast | Hint: client starts processing before full result set arrives — use only when iterating, not when total count matters |
forupdate | Acquires update lock on fetched records — required before .update() or .delete() |
exists join | Returns left-side records that have a matching right-side record — no right-side fields available |
notexists join | Returns left-side records with NO matching right-side record |
outer join | Returns all left-side records; right-side fields are blank if no match |
group by | Aggregates — use with sum(), count(), avg(), min(), max() |
order by | Sort — asc (default) or desc |
index hint | Forces a specific index — critical for performance when default optimizer choice is wrong |
nofetch | Declares cursor without fetching — combined with next to iterate manually |
// exists join — find purchLines that have at least one receipt
while select purchLine
exists join inventTrans
where inventTrans.InventTransId == purchLine.InventTransId
{
// process purchLine
}
// notexists join — find orders with no lines
while select purchTable
notexists join purchLine
where purchLine.PurchId == purchTable.PurchId
{
// purchTable has no lines
}
// group by + sum
select sum(LineAmount) from salesLine
where salesLine.SalesId == salesTable.SalesId;
// result in salesLine.LineAmount
// nofetch + next (manual cursor)
select nofetch forupdate salesLine
where salesLine.SalesId == salesTable.SalesId;
while (salesLine)
{
salesLine.SalesStatus = SalesStatus::Invoiced;
salesLine.update();
next salesLine;
}
| Pattern | Why it's expensive | Fix |
|---|---|---|
find() inside while select loop | O(n) reads — one per iteration | Cache before loop if key doesn't change |
select with header-level filter inside line loop | Loop-invariant query | Move before loop |
select without firstOnly when one record expected | Fetches full result set | Add firstOnly |
select * with no index hint on large table | Full table scan risk | Add index hint |
Repeated find() on same key in same method | Redundant reads | Store in local variable |
display method calling find() per row | Called per visible row on form | Cache or restructure |
ttsBegin; // opens transaction (or increments nesting level)
ttsCommit; // commits if at level 1; decrements level if nested
ttsAbort; // rolls back ALL levels immediately — no partial commit possible
ttsLevel (system variable) returns the current nesting depth. AX 2009 supports nested ttsBegin blocks but only the outermost ttsCommit performs the actual commit. ttsAbort at any level rolls back everything.
ttsBegin; // ttsLevel = 1
ttsBegin; // ttsLevel = 2
ttsCommit; // ttsLevel back to 1 — NOT committed yet
ttsCommit; // ttsLevel = 0 — actual DB commit happens here
| Pattern | Risk |
|---|---|
ttsBegin far from ttsCommit | Wide lock scope — blocks other users |
ttsAbort after partial work | Full rollback — caller may not expect it |
insert/update outside ttsBegin | AX 2009 auto-wraps in implicit transaction — side effects may commit separately |
Calling external methods inside tts | Hidden commits or aborts inside the called method |
error() or throw inside tts without abort | Transaction left open — must always ttsAbort before throwing |
validateWrite() → must return true or save is blocked
write() (form ds) → calls insert() or update() on table
insert() / update() → fires table-level insert/update logic
modifiedField() → fires during UI field change, before save
AX 2009 is a two-tier architecture: AOS (server) and client. Every method runs on one side. Crossing the boundary has cost.
| RunOn setting | Effect |
|---|---|
Server | Runs on AOS — correct for data access and business logic |
Client | Runs on client — correct for UI-only logic |
Called from | Runs wherever the caller runs — can cause unintended client execution |
Crossing the boundary per iteration is the most common hidden cost — a method marked Client called inside a server-side loop causes a network round-trip per call.
active() on a form datasource — fires on every record navigationdisplay methods — re-execute for every visible row on every refreshrefresh() or reread() inside a loop — multiplies DB readsnew ClassName() inside a loop — object construction cost per iterationfind() inside while select — read per row| Source | Symptom |
|---|---|
| Tax/totals recalc per line | Posting slow on orders with many lines |
FormDataSource.refresh() inside update | UI freeze when saving multi-line order |
| Constructor + calc called per line | Profiler shows same constructor 100× |
display method with DB access | Form scroll is slow |
Wide ttsBegin scope with many updates | Blocking and timeout under concurrent use |
find() or select appearing inside a loop with a fixed key = loop-invariant query| Symptom | Likely root cause |
|---|---|
| Posting takes 10 minutes | Tax/totals recalc per line, not per order |
| Form navigation is slow | active() doing DB work per record |
| Form scroll is slow | display method doing find() per row |
| Deadlock on concurrent posting | Wide ttsBegin scope or missing firstOnly |
| Infolog flood during import | Validation or warning inside loop, no batch guard |
ttsBegin wider than needed?| Level | What it covers | Triggered by |
|---|---|---|
| Line-level | Unit price, line amount, line discount, line tax | modifiedField on ItemId, Qty, Price; line write |
| Total-level | Sum of lines, header charges, total tax, net amount | PurchTotals/SalesTotals recalc; posting flow |
Line-level recalc is cheap. Total-level recalc reads all lines and recomputes everything — calling it per line inside a loop is a confirmed performance anti-pattern.
// Instantiated with a header record — recalculates all totals on demand
purchTotals = PurchTotals::newPurchTable(purchTable);
purchTotals.calc();
// Access results via purchTotals.purchTotalTaxAmount(), .purchTotalAmount(), etc.
calc() reads all PurchLine records for the order and recomputes everythingcalc() once after all lines are processedfield change (e.g. TaxGroup)
→ modifiedField on table
→ Tax::newTrans() or TaxPurch::newTrans() ← constructor per trigger
→ Tax::calc() ← reads TaxTable, applies rates
→ updates TaxAmount on line
→ triggers totals recalc ← re-reads all lines
One field change can trigger: constructor + calc + totals recalc + currency conversion. If called inside a loop, this cascade multiplies per iteration.
MarkupTable / MarkupTrans — charges applied at header or line levelinitCursorMarkup() is called per line in posting flows — expensive if markup exists on every line| Problem | Where it appears | Why it's wrong |
|---|---|---|
calcTax() called per line in posting loop | Class posting method | Should be called once after all lines written |
PurchTotals.calc() in active() | Form datasource | Recalculates all lines on every navigation |
Tax constructor inside while select | Import or migration class | O(n) constructor + calc cost |
class MyJob extends RunBase
{
// pack/unpack for dialog persistence
public container pack() { return conNull(); }
public boolean unpack(container _pack) { return true; }
// construct — always use instead of new()
public static MyJob construct() { return new MyJob(); }
// main — entry point from menu item
public static void main(Args _args)
{
MyJob job = MyJob::construct();
if (job.prompt()) // shows dialog
job.run();
}
public void run()
{
// actual work here
}
}
RunBaseBatch extends RunBase — adds batch scheduling. Structure is identical; override runsImpersonated() and batch-related methods.
The FormLetter classes manage the full document posting flow: validation → picking/packing → journal creation → tax → inventory → voucher.
PurchFormLetter::construct(DocumentStatus::Invoice)
→ PurchFormLetter_Invoice
→ run()
→ PurchParmTable / PurchParmLine population
→ PurchCalcTax_Invoice (tax per line)
→ InventMovement (inventory)
→ Ledger posting
Key constraint: never insert custom logic directly into FormLetter.run() — override specific hook methods or use pre/post patterns to avoid breaking the framework sequence.
Query q = new Query(queryStr(PurchTable));
QueryRun qr = new QueryRun(q);
// Add range programmatically
q.dataSourceTable(tableNum(PurchTable))
.addRange(fieldNum(PurchTable, PurchStatus))
.value(queryValue(PurchStatus::Backorder));
while (qr.next())
{
purchTable = qr.get(tableNum(PurchTable));
// process
}
// Never call directly — use the table's number sequence reference
purchTable.PurchId = NumberSeq::newGetNum(PurchParameters::numRefPurchId()).num();
Allocating outside a ttsBegin scope can cause gaps. Allocating inside a loop wastes sequence numbers on rollback.
// Never construct manually — always use findOrCreate
inventDim.InventSiteId = 'SITE1';
inventDim.InventLocationId = 'WH01';
inventDim.inventSerialId = serialId;
inventDim = InventDim::findOrCreate(inventDim);
// use inventDim.inventDimId on the line
InventDim is a shared dimension table — each unique combination of dimension values has one record. findOrCreate ensures no duplicates. Writing a new InventDim directly is wrong and breaks dimension integrity.
| Area | Why it's risky |
|---|---|
modifiedField on core tables (SalesLine, PurchLine) | Called from many paths — UI, posting, integration |
validateWrite | Blocks all saves if it returns false unexpectedly |
FormLetter.run() sequence | Any ordering change breaks posting |
initFrom* chains | Field overwrite order is sensitive — last call wins |
active() on major forms | Fires constantly — side effects multiply |
modified() instead of table modifiedField() — won't run in batchDataAreaId or company — breaks multi-companyttsAbort inside a helper method called mid-transaction — surprise rollback for callerselect forupdate without a following update() or delete() — unnecessary lockinfo() / warning() inside a batch loop — floods infolog, no user sees it| Logic type | Should live in |
|---|---|
| Field defaulting, validation | Table |
| Complex calculation, totals | Separate class |
| UI state, control visibility | Form (active/init) |
| Document posting, workflow | FormLetter or RunBase |
| Integration, import/export | RunBase or service class |
SalesTable::find() when it already holds the buffer → unnecessary coupling to DBelement.refresh() → table knows about form — wrong directionGlobal or form-level variables → hidden dependencyThis gate is mandatory for XppAI analysis skills when XPO input is present and intake has not already been completed for the current request. If XPO input is detected, perform direct XPO intake immediately before analysis text. Do not delay intake until after classification, summaries, or clarifying discussion.
Detect XPO from any of these:
.xpo.xpo fileCLASS #, TABLE #, FORM #, QUERY #, MAP #, VIEW #, JOB #, PROJECT #Intake policy:
.xpo file path: open the local file directly and read only the object text needed for analysis.XPO intake already completed for this request, do not perform intake again.<GBR>, <GIN>, <GJP>, <GSA>, <GTH>TextIo has no .eof() — use IO_Status::Ok after read()select count(RecId) stores result in the RecId field of the buffer