fix(mocks): derive the retained key wherever rows are staged
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:
1 parent
0ba7450b65
commit
74060c0585
3 files changed
+86
-33
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────────────
|
||||
|
||||
Reference in new issue
Block a user