MPE_DERIVED is maintained through the EDIT screen, but LIBREF and DSN were free text - a typo in either silently produced a rule that never fired. LIBREF now offers the editable libraries and DSN the tables in the LIBREF on the same row, through the same SOFTSELECT_HOOK services the other configuration tables use (libraries_editable, tables_editable). VAR_NM stays typed: a derived variable names a column the table does not have yet, so a list of existing columns would be the wrong control. ACTIVE becomes the fixed pair 1 and 0 from MPE_SELECTBOX, with HARDSELECT so it cannot be typed freehand, and its NOTNULL rule carries '1' - the value the EDIT screen pre-fills on Add Row (getColumnDefault), because a derived variable only takes effect when active. The rows are written by mpe_makedata.sas for a fresh install and by the v7.19 migration for an upgrade, and each names the other so the pair cannot drift unnoticed; the ACTIVE guard itself lives once, in mpe_derived_check.sas. The mock hook carries the ACTIVE rule, as it carries every rule mpe_derived_check.sas enforces.
374 lines
16 KiB
JavaScript
374 lines
16 KiB
JavaScript
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Mock of services/hooks/mpe_derived_postedit
|
|
//
|
|
// Registered against DC_JSLIB.MPE_DERIVED (MPE_TABLES.post_edit_hook). This
|
|
// file is eval'd by editors/stagedata.js and editors/loadfile.js, mirroring how
|
|
// mpe_loader.sas %includes the hook program via %mpe_runhook, so it shares the
|
|
// caller's scope: jsDataRows (the submitted rows - work.staging_ds on the
|
|
// server), libref, dsn, mpeDataDir and the dcMockUtils helpers.
|
|
//
|
|
// A hook rejects a submission by setting hookErrmsg, which the caller turns
|
|
// into a sasjsAbort - the same outcome as the SAS hook's %mp_abort.
|
|
//
|
|
// The rules mirror services/hooks/mpe_derived_postedit.sas, in its order:
|
|
// normalise the incoming rows, reject a duplicate name and a bad field, then
|
|
// check the dependency graph that the resulting configuration would have (the
|
|
// incoming rows plus the current rows they do not replace). As on the server,
|
|
// the collision between VAR_NM and a physical column is left to the read-time
|
|
// engine, which runs with the target library assigned - the hook does not have
|
|
// it here either.
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
;(function mpeDerivedPostedit() {
|
|
const DELETE_MARK = '_____DELETE__THIS__RECORD_____'
|
|
const NAME_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_'
|
|
const FMT_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_$.'
|
|
|
|
const text = (v) => (v === undefined || v === null ? '' : String(v))
|
|
const up = (v) => text(v).trim().toUpperCase()
|
|
const strip = (v) => text(v).trim()
|
|
|
|
/** Reads a field as the hook normalised it. */
|
|
const field = (row, name) => up(colVal(row, name))
|
|
|
|
/** Writes a field back under the key the row already carries. */
|
|
const setField = (row, name, value) => {
|
|
const key = Object.keys(row).find((k) => k.toUpperCase() === name)
|
|
if (key) row[key] = value
|
|
else row[name] = value
|
|
}
|
|
|
|
const rows = Array.isArray(jsDataRows) ? jsDataRows : []
|
|
const isDeleted = (row) => field(row, DELETE_MARK) === 'YES'
|
|
const isLive = (row) =>
|
|
!isDeleted(row) && Number(colVal(row, 'ACTIVE')) === 1
|
|
const keyOf = (row) =>
|
|
[field(row, 'LIBREF'), field(row, 'DSN'), field(row, 'VAR_NM')].join(
|
|
'.'
|
|
)
|
|
|
|
const reject = (msg) => {
|
|
hookErrmsg = msg
|
|
}
|
|
|
|
// ─── Normalise the incoming values ─────────────────────────────────────────
|
|
for (const row of rows) {
|
|
if (isDeleted(row)) continue
|
|
for (const name of [
|
|
'LIBREF',
|
|
'DSN',
|
|
'VAR_NM',
|
|
'VAR_TYPE',
|
|
'VAR_FMT',
|
|
'VAR_DEPS'
|
|
]) {
|
|
setField(row, name, up(colVal(row, name)))
|
|
}
|
|
for (const name of ['VAR_LABEL', 'VAR_LOGIC']) {
|
|
setField(row, name, strip(colVal(row, name)))
|
|
}
|
|
}
|
|
|
|
// ─── Duplicate names after normalisation ───────────────────────────────────
|
|
// mpe_loader compares the raw values, so a case variant reaches this point
|
|
const seen = new Set()
|
|
for (const row of rows) {
|
|
if (isDeleted(row)) continue
|
|
const key = keyOf(row)
|
|
if (seen.has(key)) {
|
|
return reject(
|
|
`MPE_DERIVED: ${field(row, 'VAR_NM')} is declared more than once for ${field(row, 'LIBREF')}.${field(row, 'DSN')}`
|
|
)
|
|
}
|
|
seen.add(key)
|
|
}
|
|
|
|
// ─── Field level checks, the rules the engine applies at read time ─────────
|
|
// C0 controls and DEL: what a single-line label cannot carry
|
|
const CONTROL_CHARS = /[\u0000-\u001F\u007F]/
|
|
|
|
// true when the value carries a character outside the allowed set
|
|
const hasInvalidChar = (value, chars) =>
|
|
[...value].some((c) => !chars.includes(c))
|
|
|
|
for (const row of rows) {
|
|
if (isDeleted(row)) continue
|
|
const libref = field(row, 'LIBREF')
|
|
const dsn = field(row, 'DSN')
|
|
const name = field(row, 'VAR_NM')
|
|
const type = field(row, 'VAR_TYPE')
|
|
const len = Number(colVal(row, 'VAR_LEN'))
|
|
const fmt = field(row, 'VAR_FMT')
|
|
const label = strip(colVal(row, 'VAR_LABEL'))
|
|
const logic = strip(colVal(row, 'VAR_LOGIC'))
|
|
|
|
let reason = ''
|
|
if (!libref || !dsn) reason = 'LIBREF and DSN are required'
|
|
else if (!name) reason = 'VAR_NM is required'
|
|
else if (hasInvalidChar(name, NAME_CHARS))
|
|
reason = 'VAR_NM must be a valid SAS name'
|
|
else if ('0123456789'.includes(name[0]))
|
|
reason = 'VAR_NM must not start with a digit'
|
|
else if (name === DELETE_MARK) reason = 'VAR_NM is reserved'
|
|
else if (type !== 'C' && type !== 'N')
|
|
reason = 'VAR_TYPE must be C or N'
|
|
// ACTIVE is a picker in the EDIT screen (HARDSELECT over the
|
|
// MPE_SELECTBOX pair) - the same pair mpe_derived_check.sas enforces
|
|
else if (!['0', '1'].includes(String(colVal(row, 'ACTIVE')).trim()))
|
|
reason = 'ACTIVE must be 1 or 0'
|
|
else if (!Number.isInteger(len) || len < 1)
|
|
reason = 'VAR_LEN must be a positive integer'
|
|
else if (type === 'C' && len > 32767)
|
|
reason = 'VAR_LEN exceeds 32767 for a character variable'
|
|
else if (type === 'N' && len > 8)
|
|
reason = 'VAR_LEN exceeds 8 for a numeric variable'
|
|
else if (
|
|
fmt.includes(';') ||
|
|
fmt.includes('&') ||
|
|
fmt.includes('%') ||
|
|
fmt.includes('/*') ||
|
|
fmt.includes('*/')
|
|
)
|
|
reason = 'VAR_FMT must be a format name, without ; & % or /* */'
|
|
else if (hasInvalidChar(fmt, FMT_CHARS))
|
|
reason = 'VAR_FMT must be a format name'
|
|
// free text, in any script - what is refused is anything that cannot be
|
|
// a single-line column label, and a value the engine would cut to 256
|
|
else if (CONTROL_CHARS.test(label))
|
|
reason =
|
|
'VAR_LABEL must be plain text - no line breaks or control characters'
|
|
else if ([...label].length > 256)
|
|
reason = 'VAR_LABEL exceeds 256 characters'
|
|
else if (!logic) reason = 'VAR_LOGIC is required'
|
|
else if (logic.includes(';'))
|
|
reason = 'VAR_LOGIC must be a single expression - remove the ;'
|
|
else if (logic.includes('&') || logic.includes('%'))
|
|
reason = 'VAR_LOGIC must not contain macro triggers'
|
|
else if (logic.includes('/*') || logic.includes('*/'))
|
|
reason = 'VAR_LOGIC must not contain comments'
|
|
|
|
if (reason) return reject(`MPE_DERIVED: ${name}: ${reason}`)
|
|
}
|
|
|
|
// ─── The graph of the resulting configuration ──────────────────────────────
|
|
// The incoming live rows plus the current rows they do not replace. Only
|
|
// active rows are in it, because the engine reads nothing else - an inactive
|
|
// row can neither resolve a dependency nor be resolved by one.
|
|
const controlDir = mpeDataDir
|
|
const loadControl = makeTableLoader(controlDir)
|
|
const current = loadControl('MPE_DERIVED')
|
|
const nowSeconds = Date.now() / 1000
|
|
|
|
// mpeDerived reads a row as current when TX_TO is missing, zero or in the
|
|
// future (dcMockUtils.js, the read-time engine), so the same test is used
|
|
// here - a seeded row with no TX_TO is live for the engine, and a dependency
|
|
// on it has to validate
|
|
const isCurrent = (row) =>
|
|
!Number(colVal(row, 'TX_TO')) || Number(colVal(row, 'TX_TO')) > nowSeconds
|
|
|
|
const stagedKeys = new Set(rows.map(keyOf))
|
|
const stagedLive = rows.filter(isLive)
|
|
|
|
// the control-table arm of the SAS hook is limited to the tables this
|
|
// submission touches, so another table's configuration cannot block it
|
|
const stagedTables = new Set(
|
|
rows.map((row) => [field(row, 'LIBREF'), field(row, 'DSN')].join('.'))
|
|
)
|
|
|
|
/** libref.dsn.name - the key every graph lookup uses. */
|
|
const nodeKey = (n) => [n.libref, n.dsn, n.name].join('.')
|
|
|
|
const graph = new Map()
|
|
for (const row of stagedLive) {
|
|
graph.set(keyOf(row), {
|
|
libref: field(row, 'LIBREF'),
|
|
dsn: field(row, 'DSN'),
|
|
name: field(row, 'VAR_NM'),
|
|
deps: field(row, 'VAR_DEPS').split(/\s+/).filter(Boolean),
|
|
logic: strip(colVal(row, 'VAR_LOGIC')).toUpperCase()
|
|
})
|
|
}
|
|
const currentRows =
|
|
current && Array.isArray(current.rows)
|
|
? current.rows.filter(
|
|
(r) =>
|
|
isCurrent(r) &&
|
|
Number(colVal(r, 'ACTIVE')) === 1 &&
|
|
stagedTables.has(
|
|
[up(colVal(r, 'LIBREF')), up(colVal(r, 'DSN'))].join(
|
|
'.'
|
|
)
|
|
) &&
|
|
!stagedKeys.has(
|
|
[
|
|
up(colVal(r, 'LIBREF')),
|
|
up(colVal(r, 'DSN')),
|
|
up(colVal(r, 'VAR_NM'))
|
|
].join('.')
|
|
)
|
|
)
|
|
: []
|
|
for (const row of currentRows) {
|
|
graph.set(
|
|
[
|
|
up(colVal(row, 'LIBREF')),
|
|
up(colVal(row, 'DSN')),
|
|
up(colVal(row, 'VAR_NM'))
|
|
].join('.'),
|
|
{
|
|
libref: up(colVal(row, 'LIBREF')),
|
|
dsn: up(colVal(row, 'DSN')),
|
|
name: up(colVal(row, 'VAR_NM')),
|
|
deps: up(colVal(row, 'VAR_DEPS')).split(/\s+/).filter(Boolean),
|
|
logic: strip(colVal(row, 'VAR_LOGIC')).toUpperCase()
|
|
}
|
|
)
|
|
}
|
|
|
|
/** Names this load leaves inactive, which therefore cannot resolve a dep. */
|
|
const inactive = new Set()
|
|
const liveKeys = new Set(stagedLive.map(keyOf))
|
|
for (const row of rows) {
|
|
const key = keyOf(row)
|
|
if (!liveKeys.has(key)) inactive.add(key)
|
|
}
|
|
if (current && Array.isArray(current.rows)) {
|
|
for (const row of current.rows) {
|
|
const key = [
|
|
up(colVal(row, 'LIBREF')),
|
|
up(colVal(row, 'DSN')),
|
|
up(colVal(row, 'VAR_NM'))
|
|
].join('.')
|
|
if (
|
|
isCurrent(row) &&
|
|
Number(colVal(row, 'ACTIVE')) !== 1 &&
|
|
!liveKeys.has(key)
|
|
) {
|
|
inactive.add(key)
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const node of graph.values()) {
|
|
for (const dep of node.deps) {
|
|
const key = [node.libref, node.dsn, dep].join('.')
|
|
if (inactive.has(key) && !graph.has(key)) {
|
|
return reject(
|
|
`MPE_DERIVED: ${node.name}: VAR_DEPS lists ${dep}, which is not active after this load - the engine cannot resolve it`
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── Self reference, undeclared and stale dependencies ─────────────────────
|
|
/** The identifiers an expression uses, with quoted runs removed. */
|
|
const identifiers = (logic) =>
|
|
logic
|
|
.replace(/'([^']|'')*'/g, ' ')
|
|
.replace(/"([^"]|"")*"/g, ' ')
|
|
.replace(/[^A-Za-z0-9_]/g, ' ')
|
|
.toUpperCase()
|
|
.split(/\s+/)
|
|
.filter(Boolean)
|
|
|
|
// scoped per table, as the server does: a name that is a derived variable
|
|
// on another table says nothing about this one
|
|
const graphKeys = new Set([...graph.values()].map((n) => nodeKey(n)))
|
|
|
|
// one check at a time, in the order the server runs them, so the message a
|
|
// row gets is the one it would get there
|
|
for (const node of graph.values()) {
|
|
if (node.deps.includes(node.name)) {
|
|
return reject(
|
|
`MPE_DERIVED: ${node.name}: VAR_DEPS may not list itself`
|
|
)
|
|
}
|
|
}
|
|
|
|
for (const node of graph.values()) {
|
|
for (const token of identifiers(node.logic)) {
|
|
if (
|
|
graphKeys.has([node.libref, node.dsn, token].join('.')) &&
|
|
token !== node.name &&
|
|
!node.deps.includes(token)
|
|
) {
|
|
return reject(
|
|
`MPE_DERIVED: ${node.name}: VAR_LOGIC references ${token}, which is not in VAR_DEPS`
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const node of graph.values()) {
|
|
const tokens = identifiers(node.logic)
|
|
for (const dep of node.deps) {
|
|
if (!tokens.includes(dep)) {
|
|
return reject(
|
|
`MPE_DERIVED: ${node.name}: VAR_DEPS lists ${dep}, which VAR_LOGIC does not reference`
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── A derived variable may not be named by a row level security rule ──────
|
|
const rls = loadControl('MPE_ROW_LEVEL_SECURITY')
|
|
if (rls && Array.isArray(rls.rows)) {
|
|
for (const node of graph.values()) {
|
|
const match = rls.rows.find(
|
|
(r) =>
|
|
up(colVal(r, 'RLS_LIBREF')) === node.libref &&
|
|
up(colVal(r, 'RLS_TABLE')) === node.dsn &&
|
|
up(colVal(r, 'RLS_VARIABLE_NM')) === node.name &&
|
|
Number(colVal(r, 'RLS_ACTIVE')) === 1 &&
|
|
isCurrent(r)
|
|
)
|
|
if (match) {
|
|
return reject(
|
|
`MPE_DERIVED: ${node.name} is named by a row level security rule on ${node.libref}.${node.dsn} - derived variables cannot be used there`
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── Cycles ────────────────────────────────────────────────────────────────
|
|
// Repeatedly place the variables whose derived inputs are already placed; a
|
|
// round that places nothing leaves a cycle. The path is walked so the
|
|
// message names it, as the SAS hook does.
|
|
const nodeKeys = new Map(
|
|
[...graph.values()].map((n) => [n, [n.libref, n.dsn, n.name].join('.')])
|
|
)
|
|
const placed = new Set()
|
|
let remaining = [...graph.values()]
|
|
|
|
while (remaining.length > 0) {
|
|
const ready = remaining.filter((n) =>
|
|
n.deps.every((d) => {
|
|
const key = [n.libref, n.dsn, d].join('.')
|
|
return !graph.has(key) || placed.has(key)
|
|
})
|
|
)
|
|
if (ready.length === 0) {
|
|
// walk the unplaced edges so the message names the cycle
|
|
const start = remaining[0]
|
|
const path = [start.name]
|
|
const visited = new Set([nodeKeys.get(start)])
|
|
let cursor = start
|
|
for (let i = 0; i < remaining.length; i++) {
|
|
const parentKey = cursor.deps
|
|
.map((d) => [cursor.libref, cursor.dsn, d].join('.'))
|
|
.find((key) => graph.has(key) && !placed.has(key))
|
|
if (!parentKey) break
|
|
path.push(graph.get(parentKey).name)
|
|
if (visited.has(parentKey)) break
|
|
visited.add(parentKey)
|
|
cursor = graph.get(parentKey)
|
|
}
|
|
return reject(
|
|
`MPE_DERIVED: VAR_DEPS contains a cycle: ${path.join(' -> ')}`
|
|
)
|
|
}
|
|
for (const node of ready) placed.add(nodeKeys.get(node))
|
|
remaining = remaining.filter((n) => !placed.has(nodeKeys.get(n)))
|
|
}
|
|
})()
|