diff --git a/sas/mocks/sasjs/services/dcMockUtils.js b/sas/mocks/sasjs/services/dcMockUtils.js index 15aed55..a286297 100644 --- a/sas/mocks/sasjs/services/dcMockUtils.js +++ b/sas/mocks/sasjs/services/dcMockUtils.js @@ -460,6 +460,60 @@ function sasVarsEntry(col) { return { format: fmt, label: col.label || col.name, length: String(col.length), type: col.type === 'N' ? 'num' : 'char' } } +/** + * Assigns the retained key (RK) to staged rows for a table registered with + * rk_underlying, mirroring %mp_retainedkey as %mpe_loader invokes it: any RK + * column in the input is dropped, and every row's RK is derived from its + * business key - the value that key already holds on the base table, or the + * next key value for a new one. Business keys are matched the way SAS compares + * them: case-sensitive, trailing blanks ignored. + * + * @param {Object} opts - { rows, tableReg, baseData } + * tableReg - the table's MPE_TABLES row (needs rk_underlying and buskey) + * baseData - { columns, rows } of the base table, for the key lookup + * @returns {String} the RK column name when the assignment ran, else '' + */ +function mpeAssignRetainedKey(opts) { + const { rows, tableReg, baseData } = opts + const rkUnderlying = tableReg && tableReg.rk_underlying + ? String(tableReg.rk_underlying).trim() + : '' + const rkCol = tableReg && tableReg.buskey ? String(tableReg.buskey).trim() : '' + if (!rkUnderlying || !rkCol || !rows || rows.length === 0) return '' + + const base = baseData || { columns: [], rows: [] } + const submitted = Object.keys(rows[0] || {}).find( + (k) => k.toUpperCase() === rkCol.toUpperCase() + ) + const rkName = submitted || + (base.columns.find((c) => c.name.toUpperCase() === rkCol.toUpperCase()) || {}).name || + rkCol + const keys = rkUnderlying.split(/\s+/) + const keyValue = (value) => { + if (value === null || value === undefined) return '' + return typeof value === 'number' ? String(value) : String(value).replace(/\s+$/, '') + } + const businessKey = (row) => keys.map((k) => keyValue(colVal(row, k))).join('|') + + const baseByKey = new Map() + let nextRk = 0 + for (const row of base.rows) { + const rk = colVal(row, rkCol) + baseByKey.set(businessKey(row), rk) + const num = Number(rk) + if (isFinite(num) && num > nextRk) nextRk = num + } + + for (const row of rows) { + Object.keys(row).forEach((k) => { + if (k.toUpperCase() === rkCol.toUpperCase()) delete row[k] + }) + const existing = baseByKey.get(businessKey(row)) + row[rkName] = existing === undefined || existing === null ? ++nextRk : existing + } + return rkName +} + /** * Stages a submission: writes staged.json under staging//, copies the * session audit items, and inserts the MPE_SUBMIT row. Shared by the diff --git a/sas/mocks/sasjs/services/editors/loadfile.js b/sas/mocks/sasjs/services/editors/loadfile.js index 0c03468..5449672 100644 --- a/sas/mocks/sasjs/services/editors/loadfile.js +++ b/sas/mocks/sasjs/services/editors/loadfile.js @@ -147,6 +147,12 @@ if (!msg) { if (tableEntry.var_txfrom) computedCols.push(tableEntry.var_txfrom.toUpperCase()) if (tableEntry.var_txto) computedCols.push(tableEntry.var_txto.toUpperCase()) if (tableEntry.var_processed) computedCols.push(tableEntry.var_processed.toUpperCase()) + // loadfile.sas drops the RK from the comparison for a table + // with rk_underlying (the loader derives it), so an upload + // for one legitimately omits it. + if (tableEntry.rk_underlying && tableEntry.buskey) { + computedCols.push(String(tableEntry.buskey).toUpperCase()) + } } } const baseCols = schema.map((c) => c.name.toUpperCase()) @@ -191,6 +197,23 @@ if (!msg) { } tableId = makeTableId() + // ─── RK assignment (mpe_loader) ────────────────────────────────────── + // The uploaded file carries the business key and no RK (see the + // expected-columns check above), so the loader derives it before the + // rows are staged. + const finalTables = mpeLoadTableData('MPE_TABLES') + const finalReg = + finalTables && finalTables.rows + ? finalTables.rows.find((r) => r.libref === libref && r.dsn === dsn) + : null + const rkName = mpeAssignRetainedKey({ + rows, + tableReg: finalReg, + baseData: makeTableLoader(libDataDir(libref))(dsn) + }) + if (rkName && !headers.some((h) => h.toUpperCase() === rkName.toUpperCase())) { + headers.push(rkName) + } stageSubmission({ dataDir: libDataDir(libref), stagingDir, diff --git a/sas/mocks/sasjs/services/editors/stagedata.js b/sas/mocks/sasjs/services/editors/stagedata.js index 0b0eb81..0566bf5 100644 --- a/sas/mocks/sasjs/services/editors/stagedata.js +++ b/sas/mocks/sasjs/services/editors/stagedata.js @@ -84,45 +84,21 @@ if (hookReg && hookReg.post_edit_hook) { // ─── RK assignment (mpe_loader) ────────────────────────────────────────────── // A table registered with rk_underlying is edited on its business key and the // RK column is withheld from the editor (see getdata.js), so the submitted rows -// carry no RK. The loader derives it: %mpe_loader hands RK_UNDERLYING to -// %bitemporal_dataloader, which keeps the RK the business key already holds and -// mints the next value for a new key. - -const valueOf = (row, name) => { - if (row[name] !== undefined) return row[name] - const key = Object.keys(row).find((k) => k.toUpperCase() === name.toUpperCase()) - return key === undefined ? undefined : row[key] -} +// carry no RK. The loader derives it, and MPE_SUBMIT records the staged width +// - which includes the derived column. const finalTables = makeTableLoader(mpeDataDir)('MPE_TABLES') const finalReg = finalTables && finalTables.rows ? finalTables.rows.find((r) => r.libref === libref && r.dsn === dsn) : null -const rkCols = - finalReg && finalReg.rk_underlying ? finalReg.rk_underlying.trim().split(/\s+/) : [] -const rkCol = finalReg && finalReg.buskey ? finalReg.buskey.trim() : '' -const sendsRk = - rkCol !== '' && jsDataHeaders.some((h) => h.toUpperCase() === rkCol.toUpperCase()) - -if (rkCols.length > 0 && rkCol !== '' && !sendsRk) { - const baseData = loadTableData(dsn) || { columns: [], rows: [] } - const rkName = - (baseData.columns.find((c) => c.name.toUpperCase() === rkCol.toUpperCase()) || {}).name || - rkCol - const businessKey = (row) => - rkCols.map((c) => String(valueOf(row, c) ?? '').trim().toUpperCase()).join('|') - const baseByKey = new Map() - let nextRk = 0 - for (const row of baseData.rows) { - baseByKey.set(businessKey(row), valueOf(row, rkName)) - const rk = Number(valueOf(row, rkName)) - if (isFinite(rk) && rk > nextRk) nextRk = rk - } - for (const row of jsDataRows) { - const existing = baseByKey.get(businessKey(row)) - row[rkName] = existing === undefined || existing === null ? ++nextRk : existing - } +const rkName = mpeAssignRetainedKey({ + rows: jsDataRows, + tableReg: finalReg, + baseData: loadTableData(dsn) +}) +if (rkName && !jsDataHeaders.some((h) => h.toUpperCase() === rkName.toUpperCase())) { + jsDataHeaders.push(rkName) } // ─── Stage the data ──────────────────────────────────────────────────────────