fix(mocks): key rk_underlying tables on their business key
Build / Build-and-ng-test (pull_request) Failing after 1m55s
Build / Build-and-test-development (pull_request) Skipped
Lighthouse Checks / lighthouse (pull_request) Successful in 19m24s

getdata.sas exposes coalesce(rk_underlying, buskey) as the PK and drops the
RK column, so the editor shows the business key as read-only and never sends
the RK. The JS mock still published the RK as the PK and left it in the grid,
and stagedata had no loader to derive it back, so an edit to such a table
(MPE_ROW_LEVEL_SECURITY, MPE_SELECTBOX) could not be diffed.

The mock now mirrors that contract: PK/PKCNT come from rk_underlying, the RK
column is withheld and the PK variables lead COLHEADERS, and stagedata assigns
the RK from the business key - keeping the one the base row already holds and
minting the next value for a new key.
This commit is contained in:
dc committed 2026-10-23 23:30:57 +00:00
1 parent 779006b0bf
commit 347dbb53b0
2 files changed
+78 -7

No files matched your search

+34 -7
View File
@@ -77,6 +77,27 @@ if (mpeTablesData && mpeTablesData.rows) {
tableReg = mpeTablesData.rows.find(r => r.libref === requestedLibref && r.dsn === requestedTable)
}
// The variables exposed to the editor as the primary key, matching getdata.sas:
// pk = coalesce(rk_underlying, buskey). A table registered with rk_underlying
// is keyed on its business key in the editor - the RK itself is assigned by the
// backend loader, so it is neither shown nor sent by the user.
const rkUnderlying = tableReg && tableReg.rk_underlying ? tableReg.rk_underlying.trim() : ''
const buskey = tableReg && tableReg.buskey ? tableReg.buskey.trim() : ''
const pkFields = rkUnderlying || buskey
const pkColumns = pkFields ? pkFields.split(/\s+/) : []
// Order columns with the PK variables first, in PK order, then the remaining
// columns in schema order. getdata.sas does the same ("order delete var and pk
// fields at start of table") and the client reads the first PKCNT columns
// positionally when it detects duplicate keys.
function pkFirst(columns, keys) {
const rank = new Map(keys.map((name, i) => [name.toUpperCase(), i]))
const keyColumns = columns
.filter(col => rank.has(col.name.toUpperCase()))
.sort((a, b) => rank.get(a.name.toUpperCase()) - rank.get(b.name.toUpperCase()))
return keyColumns.concat(columns.filter(col => !rank.has(col.name.toUpperCase())))
}
// ─── DQ rules (from MPE_VALIDATIONS + NOTNULL from schema) ──────────────────
// Mirrors getdata.sas: dqrules = MPE_VALIDATIONS rows UNION dictionary.columns
// NOTNULL constraints. MPE_VALIDATIONS lives in the control library.
@@ -85,10 +106,7 @@ const validationsData = mpeLoadTableData('MPE_VALIDATIONS')
let dqrules = []
let dqdata = []
if (validationsData && validationsData.rows) {
const pkCols = new Set()
if (tableReg && tableReg.buskey) {
tableReg.buskey.trim().split(/\s+/).forEach(c => pkCols.add(c.toUpperCase()))
}
const pkCols = new Set(pkColumns.map(c => c.toUpperCase()))
dqrules = validationsData.rows
.filter(r => r.base_lib === requestedLibref && r.base_ds === requestedTable)
.map(r => ({
@@ -206,8 +224,17 @@ if (tableData && tableData.columns && tableData.rows) {
if (tableReg.var_busfrom) excludeCols.add(tableReg.var_busfrom.toUpperCase())
if (tableReg.var_busto) excludeCols.add(tableReg.var_busto.toUpperCase())
}
// getdata.sas drops the RK field from the editor view when the table is
// registered with rk_underlying (drop_rk=&buskey), and exposes the business
// key instead.
if (rkUnderlying && buskey) {
buskey.split(/\s+/).forEach(c => excludeCols.add(c.toUpperCase()))
}
let visibleColumns = tableData.columns.filter(c => !excludeCols.has(c.name.toUpperCase()))
let visibleColumns = pkFirst(
tableData.columns.filter(c => !excludeCols.has(c.name.toUpperCase())),
pkColumns
)
let visibleRows = tableData.rows
.filter(r => filterPredicate(r))
.map(r => {
@@ -363,8 +390,8 @@ if (tableData && tableData.columns && tableData.rows) {
sasparams: [{
COLHEADERS: colHeaders.join(','),
FILTER_TEXT: filterText === '' ? ' ' : filterText,
PKCNT: tableReg && tableReg.buskey ? tableReg.buskey.trim().split(/\s+/).length : 0,
PK: tableReg ? tableReg.buskey || '' : '',
PKCNT: pkColumns.length,
PK: pkFields,
DTVARS: dtVars.length ? ' ' + dtVars.join(' ') : '',
DTTMVARS: dttmVars.length ? ' ' + dttmVars.join(' ') : '',
TMVARS: tmVars.length ? ' ' + tmVars.join(' ') : '',
@@ -81,6 +81,50 @@ 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]
}
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
}
}
// ─── Stage the data ──────────────────────────────────────────────────────────
// One subfolder per submission (DSID) under the staging directory, so that the
// staged data can sit alongside related artifacts (submit/approval logs,