Compare commits

...
5 Commits
Author SHA1 Message Date
allan ac97252b02 Merge pull request 'fix(mocks): key rk_underlying tables on their business key' (#357) from fix/mock-rk-underlying-pk into feat/derived-vars
Build / Build-and-ng-test (pull_request) Successful in 4m55s
Lighthouse Checks / lighthouse (pull_request) Successful in 19m30s
Build / Build-and-test-development (pull_request) Successful in 30m57s
Reviewed-on: #357
2026-10-24 09:24:39 +00:00
dc 821bf9d1ca Merge branch 'feat/derived-vars' into fix/mock-rk-underlying-pk
Build / Build-and-ng-test (pull_request) Successful in 5m8s
Lighthouse Checks / lighthouse (pull_request) Successful in 23m2s
Build / Build-and-test-development (pull_request) Successful in 31m10s
Two conflicts, both resolved by keeping the intent of each side:

- sas/mocks/sasjs/services/editors/getdata.js: the PK derivation and the
  pkFirst() ordering from this branch, then the derived-variable block from the
  release branch. The derived columns are added to the schema before the
  filter, so the PK-first ordering still applies to the full column set.
- client/package-lock.json: taken from this branch. The merged package.json
  differs from the release branch only in the fast-uri override (4.2.3 here,
  3.1.8 there); the release branch already carries the same @angular 20.3.34
  and @sasjs/adapter 5.1.2 pins, so the lock is unchanged from 74060c0.

Gates on the merged tree: npm audit --omit=dev 0 vulnerabilities, prettier
clean, 603/603 unit tests, ng build, license-checker. Mock services re-probed
against a local SASjs Server: MPE_ROW_LEVEL_SECURITY publishes PKCNT 10 with
the business keys first and no RLS_RK, an edit diffs as UPDATED 1 with the
base RK, and the derived table TESTDATA.DERIVED_DEMO still serves its computed
columns.
2026-10-24 08:40:44 +00:00
dc 74060c0585 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.
2026-10-24 00:32:25 +00:00
dc 0ba7450b65 chore(deps): clear the client audit advisories
Build / Build-and-ng-test (pull_request) Successful in 4m52s
Lighthouse Checks / lighthouse (pull_request) Successful in 19m38s
Build / Build-and-test-development (pull_request) Successful in 29m19s
The npm audit gate started failing on advisories that landed upstream, so
nothing can merge until the client tree is patched:

- @angular/* 20.3.33 -> 20.3.34 (XSRF token leak in HttpClient, SSR DoS in
  the router)
- @sasjs/adapter 5.1.0 -> 5.1.2 (brings a patched axios)
- fast-uri override 3.1.8 -> 4.2.3 (the override was pinning the vulnerable
  version; ajv only needs it for URI resolution)
- lockfile refresh takes moment, undici, follow-redirects and brace-expansion
  to their patched versions, and re-resolves the @angular-devkit build
  toolchain within its existing ^ range

Verified in client/: npm audit --omit=dev reports 0 vulnerabilities, npm run
test:headless is 589/589, npm run build succeeds, lint:check and
license-checker pass.
2026-10-23 23:53:15 +00:00
dc 347dbb53b0 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.
2026-10-23 23:30:57 +00:00
6 changed files with 976 additions and 1150 deletions

No files matched your search

+844 -1142
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -147,7 +147,7 @@
"ajv": "8.18.0",
"uuid": "11.1.1",
"lighthouse": "13.4.0",
"fast-uri": "3.1.8",
"fast-uri": "4.2.3",
"readdir-glob": {
"brace-expansion": "^5.0.9"
},
+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
+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())))
}
// ─── Derived (computed) variables (mpe_derived.sas) ─────────────────────────
// Applied before the filter, so a filter can reference them. The derived
// columns are added to the schema too, which is what puts them in COLS and in
@@ -100,10 +121,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 => ({
@@ -220,8 +238,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 => {
@@ -389,8 +416,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(' ') : '',
@@ -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,
@@ -81,6 +81,26 @@ 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, 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 rkName = mpeAssignRetainedKey({
rows: jsDataRows,
tableReg: finalReg,
baseData: loadTableData(dsn)
})
if (rkName && !jsDataHeaders.some((h) => h.toUpperCase() === rkName.toUpperCase())) {
jsDataHeaders.push(rkName)
}
// ─── 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,