fix(mocks): derive the retained key wherever rows are staged
Build / Build-and-ng-test (pull_request) Successful in 4m51s
Lighthouse Checks / lighthouse (pull_request) Successful in 19m49s
Build / Build-and-test-development (pull_request) Successful in 29m37s

Review follow-ups on the rk_underlying handling:

- mpeAssignRetainedKey (dcMockUtils) mirrors %mp_retainedkey: any submitted RK
  column is dropped and every row's RK is re-derived from the business key, so a
  stale RK in a hand-built payload cannot survive into the diff. Business keys
  compare the way SAS does - case-sensitive, trailing blanks ignored.
- editors/loadfile excludes the RK from its expected-columns check for these
  tables, as loadfile.sas does, and runs the derivation before staging, so a
  real CSV upload (which legitimately omits the RK) is accepted and its diff
  matches instead of reporting every row as NEW.
- both staging paths add the derived column to the staged headers, so
  MPE_SUBMIT.INPUT_VARS counts the width mpe_loader would see.
This commit is contained in:
dc committed 2026-10-24 00:32:25 +00:00
1 parent 0ba7450b65
commit 74060c0585
3 files changed
+86 -33

No files matched your search

+54
View File
@@ -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/<tableId>/, copies the
* session audit items, and inserts the MPE_SUBMIT row. Shared by the
@@ -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,
+9 -33
View File
@@ -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 ──────────────────────────────────────────────────────────