Files
dc/sas/mocks/sasjs/services/dcMockUtils.js
T
dc ab2d9131df docs(mocks): say how a golden was obtained, and stop trimming CSV cells
Three findings from review.

- the abort envelope: no service depended on the CLOSE-time assignment. Members
  are on the session's webout file as they are added, and the server serves that
  file whether or not the program finished (api/src/controllers/session.ts
  sessionOutput returns webout.txt, catching to ''), so a caller receives what
  was flushed; every abort writes sasjsAbort first, so an abort is never an
  empty body. The comment now says that, says that dropping CLOSE is estate
  parity (a SAS soft abort stops before the envelope is finalised), and keeps
  the note that the estate's wrapper is what adds SYSCC to its own aborts.
- parseCsvTable trimmed every cell, quoted content included, so the download
  comparison could not see a leading or trailing blank - the very class of
  difference the viewer work just established. Cells are kept as they arrive,
  a quote opens a field only at its start, and a test pins all three cases.
  Removing the trim changed no scenario: all seven still ok.
- provenance: parity/README.md now has a section saying where each _meta field
  comes from. sas is the envelope's SYSVLONG, which only a SAS runtime emits, so
  a golden carrying it cannot have come from the mock; a scenario that answers
  with something other than a SAS JSON envelope (the getrawdata CSV) records
  unknown, and that is why that one golden says so while its accepted
  differences still come from a live response. estate is the target's declared
  flag, which record.js refuses to do without; dc is the repository version at
  record time and is named as the one field not read from the response.
2026-11-07 10:50:06 +00:00

2133 lines
85 KiB
JavaScript

/**
* @file dcMockUtils.js
* @brief Shared utilities for Data Controller JS mock services on SASjs Server.
*
* Provides:
* - fetchTable / fetchRaw / parseCsv : emulate SAS %webout(FETCH)
* - mpeinit : emulate the %mpeinit debug dump
* - formatSasValue : apply a SAS format to a numeric value
* - parseFormattedToSas : convert an ISO string back to a SAS numeric
* - getDdType : derive DATE/DATETIME/TIME/N/C from a column format
* - loadTableData / loadTableSchema : read a DC data JSON file from the Drive
* - sasVarsRow : build a $sasdata/$viewdata vars entry for a column
* - sasVarsRow : build a $sasdata/$viewdata vars entry for a column
*
* Usage (from a mock service that is eval'd by the SASjs Server):
* const _utilPath = nodePath.resolve(driveRoot, 'files', appLoc, 'services', 'dcMockUtils.js')
* eval(fs.readFileSync(_utilPath, 'utf8'))
* const rows = fetchTable('SASControlTable')
* const libds = rows[0]?.LIBDS
*/
// ─── SAS epoch ────────────────────────────────────────────────────────────────
const SAS_EPOCH_MS = Date.UTC(1960, 0, 1)
const MONTHS_ABBR = ['JAN','FEB','MAR','APR','MAY','JUN','JUL','AUG','SEP','OCT','NOV','DEC']
const MONTHS_NUM = {JAN:'01',FEB:'02',MAR:'03',APR:'04',MAY:'05',JUN:'06',JUL:'07',AUG:'08',SEP:'09',OCT:'10',NOV:'11',DEC:'12'}
// ─── webout() options ────────────────────────────────────────────────────────
/**
* Refuse a member whose column metadata did not reach the response.
*
* The three services that pass `{ showmeta: { vars } }` expect `$<member>` beside
* their rows - `$sasdata`, `$viewdata`, `$vals` - which is what the client reads
* for column formats and labels. Whether the runtime honoured the option is a
* fact about the response, so it is read from the response rather than inferred
* from the function.
*
* Call it immediately after the `webout('OBJ', ...)` that carried the option, and
* before the envelope is closed: a throw after `webout('CLOSE')` leaves a
* complete body behind, and the server serves it - the caller then sees a normal
* response with SYSCC 0 while the metadata is missing, which is the failure this
* exists to prevent. Before CLOSE the body cannot be closed, so the run fails.
*
* A runtime without the option - an `@sasjs/utils` below 4.6.0, which
* `@sasjs/server`'s `^4.4.0` range can resolve on releases before 3.12.3 -
* writes the rows and drops the metadata silently. The response still looks
* complete, so the estate quietly tests less than it claims. Refuse it instead,
* and name the cause. This function is the source of truth for the rule; the
* sas9 tree's `sas/mocks/sas9/sasjs/services/weboutGuard.js` is a copy of it and
* the two must say one thing, because an operator reads whichever tree threw.
*
* Testing the output rather than the function is deliberate: an arity check
* (`webout.length`) stops counting at the first default or rest parameter, so a
* runtime that does carry the option can report three and be refused wrongly.
*
* @param {string} bodyPath the response body the program is writing
* @param {string} member the member the caller wrote
*/
function requireMemberMetadata(bodyPath, member) {
let body
try {
body = fs.readFileSync(bodyPath, 'utf8')
} catch (err) {
// A body that cannot be read is a path or filesystem problem, and
// reporting it as missing metadata would send the reader to a
// dependency reinstall that cannot fix it.
throw new Error(
"webout() could not read the response body at '" + bodyPath + "' (" +
(err && err.message ? err.message : err) + "), so the column " +
"metadata for '" + member + "' cannot be checked. This is a " +
'path or filesystem problem, not a @sasjs/utils version ' +
'problem - check the weboutPath of the program that called it.'
)
}
if (body.indexOf('"$' + String(member).toLowerCase() + '"') >= 0) return
throw new Error(
"webout() did not emit the column metadata for '" + member + "', so the " +
'response would carry rows without their formats and labels. The ' +
'options argument arrived in @sasjs/utils 4.6.0, and @sasjs/server ' +
'declares that floor from 3.12.3 - an older server accepts it and ' +
'drops it. Install @sasjs/server 3.12.3 or later, or @sasjs/utils ' +
'4.6.0 or later, on the host running this program.'
)
}
// ─── %webout(FETCH) emulation ────────────────────────────────────────────────
/**
* Fetches the raw CSV text of a named input table.
* Uses eval() to access variables in the CALLER's scope.
*/
function fetchRaw(tableName) {
// 1. Direct variable (SASjs Server may set form fields as top-level JS vars)
try {
const val = eval(tableName)
if (val !== undefined && val !== null && val !== '') return val.toString()
} catch(e) { /* not defined */ }
// 2. _WEBIN_NAME / _WEBIN_FILEREF loop (SASjs Server format)
// Variable names are UPPERCASE: _WEBIN_NAME1, _WEBIN_FILEREF1, etc.
// _WEBIN_FILEREF may be a Buffer (from fs.readFileSync) or a string.
for (let i = 1; i <= 10; i++) {
try {
const nameVal = eval('_WEBIN_NAME' + i)
if (nameVal && nameVal.toLowerCase() === tableName.toLowerCase()) {
const refVal = eval('_WEBIN_FILEREF' + i)
if (refVal) return refVal.toString()
}
} catch(e) {}
// Also try lowercase variant
try {
const nameVal = eval('_webin_name' + i)
if (nameVal && nameVal.toLowerCase() === tableName.toLowerCase()) {
const refVal = eval('_webin_fileref' + i)
if (refVal) return refVal.toString()
}
} catch(e) {}
}
// 3. _WEBIN_FILENAME / _WEBIN_FILEREF fallback
for (let i = 1; i <= 10; i++) {
try {
const nameVal = eval('_WEBIN_FILENAME' + i)
if (nameVal && nameVal.toLowerCase().includes(tableName.toLowerCase())) {
const refVal = eval('_WEBIN_FILEREF' + i)
if (refVal) return refVal.toString()
}
} catch(e) {}
try {
const nameVal = eval('_webin_filename' + i)
if (nameVal && nameVal.toLowerCase().includes(tableName.toLowerCase())) {
const refVal = eval('_webin_fileref' + i)
if (refVal) return refVal.toString()
}
} catch(e) {}
}
return null
}
/**
* Fetches a named input table as parsed row objects.
*/
function fetchTable(tableName) {
const raw = fetchRaw(tableName)
if (!raw) return []
return parseCsv(raw)
}
/**
* Parses just the header line of a SASjs adapter CSV string
* ("COL1:$char10. COL2:$char8." -> ['COL1', 'COL2']).
*/
function parseCsvHeader(csvText) {
if (!csvText) return []
const lines = csvText.split(/\r?\n/)
if (lines.length < 1) return []
return lines[0].trim().split(/\s+/).map((h) => h.split(':')[0])
}
/**
* Parses a SASjs adapter CSV string into an array of row objects.
* Header line: "COL1:$char10. COL2:$char8." (space-separated, name before colon)
* Data lines: comma-separated with double-quote escaping.
*/
function parseCsv(csvText) {
if (!csvText) return []
const lines = csvText.split(/\r?\n/)
if (lines.length < 2) return []
const headers = lines[0].trim().split(/\s+/).map(h => h.split(':')[0])
if (headers.length === 0) return []
const rows = []
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim()
if (!line) continue
const values = _parseCsvLine(line)
const row = {}
for (let j = 0; j < headers.length && j < values.length; j++) {
row[headers[j]] = values[j]
}
rows.push(row)
}
return rows
}
function _parseCsvLine(line) {
const values = []
let cur = ''
let inQuote = false
for (let j = 0; j < line.length; j++) {
const ch = line[j]
if (ch === '"') {
if (inQuote && line[j + 1] === '"') { cur += '"'; j++ }
else inQuote = !inQuote
} else if (ch === ',' && !inQuote) {
values.push(cur); cur = ''
} else { cur += ch }
}
values.push(cur)
return values
}
// ─── SAS format <-> ISO conversions ──────────────────────────────────────────
/**
* Column metadata, mirroring %mp_getcols.sas.
*
* The SAS macro runs proc contents and derives, per column: NAME (upcased),
* TYPE (C/N), LENGTH, LABEL (defaulting to the name), VARNUM, FMTNAME, FORMAT
* and DDTYPE - CHARACTER, NUMERIC, DATETIME, DATE or TIME. The date list is
* the macro's (YYMMDD, MMDDYY, DDMMYY, MONYY, E8601DA, B8601DA, E8601DT,
* NLDATM, NLDATE), so a column types the same way here as it does in the
* service that shares this code.
*
* @param {Array} schemaCols the table's columns as the mock data stores them
* ({name, type, length, format, label}), the equivalent of the proc contents
* output the macro reads
* @returns {Array} one entry per column, in the macro's shape and order
*/
function mpGetcols(schemaCols) {
return (schemaCols || []).map((col, index) => {
const name = String(col.name || '').toUpperCase()
const isChar = String(col.type || '').toUpperCase() === 'C'
const fmtname = String(col.format || '')
.replace(/[\d.]+$/, '')
.toUpperCase()
let ddtype
if (isChar) ddtype = 'CHARACTER'
else if (fmtname.startsWith('DATETIME') || fmtname.startsWith('E8601DT')
|| fmtname.startsWith('NLDATM')) ddtype = 'DATETIME'
else if (fmtname.startsWith('DATE') || fmtname.startsWith('DDMMYY')
|| fmtname.startsWith('MMDDYY') || fmtname.startsWith('YYMMDD')
|| fmtname.startsWith('E8601DA') || fmtname.startsWith('B8601DA')
|| fmtname.startsWith('MONYY') || fmtname.startsWith('NLDATE')) ddtype = 'DATE'
else if (fmtname.startsWith('TIME')) ddtype = 'TIME'
else ddtype = 'NUMERIC'
return {
NAME: name,
TYPE: isChar ? 'C' : 'N',
LENGTH: Number(col.length) || 0,
LABEL: col.label || col.name,
VARNUM: index + 1,
FORMAT: String(col.format || ''),
FMTNAME: fmtname,
DDTYPE: ddtype
}
})
}
/**
* Derives a DDTYPE (DATE, DATETIME, TIME, N, C) from a SAS column format
* string, in the shape the client expects. Delegates to mpGetcols so the
* inference is the macro's, rather than a second format list that can drift
* away from the service's.
*/
function getDdType(col) {
const ddtype = mpGetcols([col])[0].DDTYPE
if (ddtype === 'CHARACTER') return 'C'
if (ddtype === 'NUMERIC') return 'N'
return ddtype
}
/**
* Applies a SAS format to a raw numeric value, producing the display string.
* DATE: 42 -> "12FEB1960"
* DATETIME: 42 -> "01JAN1960:00:00:42"
* TIME: 42 -> "0:00:42"
*/
function formatSasValue(value, ddtype) {
if (value === null || value === undefined || value === '') return String(value)
if (typeof value !== 'number') return String(value)
if (ddtype === 'DATE') {
const d = new Date(SAS_EPOCH_MS + value * 86400000)
return `${String(d.getUTCDate()).padStart(2,'0')}${MONTHS_ABBR[d.getUTCMonth()]}${d.getUTCFullYear()}`
}
if (ddtype === 'DATETIME') {
const d = new Date(SAS_EPOCH_MS + value * 1000)
return `${String(d.getUTCDate()).padStart(2,'0')}${MONTHS_ABBR[d.getUTCMonth()]}${d.getUTCFullYear()}:${String(d.getUTCHours()).padStart(2,'0')}:${String(d.getUTCMinutes()).padStart(2,'0')}:${String(d.getUTCSeconds()).padStart(2,'0')}`
}
if (ddtype === 'TIME') {
const h = Math.floor(value / 3600)
const m = Math.floor((value % 3600) / 60)
const s = value % 60
return `${h}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`
}
return String(value)
}
/**
* Converts a numeric SAS value to the ISO string Handsontable expects.
* DATE: 42 -> "1960-02-12"
* DATETIME: 42 -> "1960-01-01 00:00:42"
* TIME: 42 -> "00:00:42"
*/
function sasToIso(value, ddtype) {
if (value === null || value === undefined || value === '') return value
if (typeof value !== 'number') return value
if (ddtype === 'DATE') {
const d = new Date(SAS_EPOCH_MS + value * 86400000)
return `${d.getUTCFullYear()}-${String(d.getUTCMonth()+1).padStart(2,'0')}-${String(d.getUTCDate()).padStart(2,'0')}`
}
if (ddtype === 'DATETIME') {
const d = new Date(SAS_EPOCH_MS + value * 1000)
return `${d.getUTCFullYear()}-${String(d.getUTCMonth()+1).padStart(2,'0')}-${String(d.getUTCDate()).padStart(2,'0')} ${String(d.getUTCHours()).padStart(2,'0')}:${String(d.getUTCMinutes()).padStart(2,'0')}:${String(d.getUTCSeconds()).padStart(2,'0')}`
}
if (ddtype === 'TIME') {
const h = Math.floor(value / 3600)
const m = Math.floor((value % 3600) / 60)
const s = value % 60
return `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`
}
return value
}
/**
* Converts an ISO-formatted string (as sent by the editor) back to a SAS numeric.
* DATE: "1960-02-12" -> 42
* DATETIME: "1960-01-01 00:00:42" -> 42
* TIME: "00:00:42" -> 42
* Non-temporal values pass through unchanged.
*/
function parseFormattedToSas(value, ddtype) {
if (value === null || value === undefined || value === '') return value
if (ddtype !== 'DATE' && ddtype !== 'DATETIME' && ddtype !== 'TIME') return value
if (typeof value === 'number') return value
const str = String(value)
if (ddtype === 'DATE') {
const m = str.match(/^(\d{4})-(\d{2})-(\d{2})$/)
if (m) return Math.round((Date.UTC(+m[1], +m[2]-1, +m[3]) - SAS_EPOCH_MS) / 86400000)
return value
}
if (ddtype === 'DATETIME') {
const m = str.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})/)
if (m) return Math.round((Date.UTC(+m[1], +m[2]-1, +m[3], +m[4], +m[5], +m[6]) - SAS_EPOCH_MS) / 1000)
return value
}
if (ddtype === 'TIME') {
const parts = str.split(':')
if (parts.length >= 3) return (+parts[0]) * 3600 + (+parts[1]) * 60 + (+parts[2])
return value
}
return value
}
/**
* Handles either numeric or string temporal values, converting to ISO for
* Handsontable. Numeric values use sasToIso; string values use the legacy
* regex parsing (for backwards compatibility with old data files).
*/
function formatCellValue(value, ddtype) {
if (value === null || value === undefined || value === '') return value
if (typeof value === 'number') return sasToIso(value, ddtype)
const str = String(value)
if (ddtype === 'DATE') {
const m = str.match(/^(\d{1,2})([A-Z]{3})(\d{4})$/)
if (m) return `${m[3]}-${MONTHS_NUM[m[2]]}-${m[1].padStart(2,'0')}`
return str
}
if (ddtype === 'DATETIME') {
const m = str.match(/^(\d{1,2})([A-Z]{3})(\d{4}):(\d{1,2}):(\d{2}):(\d{2})$/)
if (m) return `${m[3]}-${MONTHS_NUM[m[2]]}-${m[1].padStart(2,'0')} ${m[4].padStart(2,'0')}:${m[5]}:${m[6]}`
return str
}
if (ddtype === 'TIME') {
const parts = str.split(':')
if (parts.length >= 3) return `${parts[0].padStart(2,'0')}:${parts[1].padStart(2,'0')}:${parts[2].padStart(2,'0')}`
return str
}
return value
}
// ─── DC data file helpers ────────────────────────────────────────────────────
/**
* Resolves the data directory for a given libref.
* Each libref has its own subfolder under <appLoc>/data/ (e.g. DC_JSLIB,
* TESTDATA). DC_JSLIB is the default control-library (mpelib) and is used
* for MPE_* config tables; other librefs hold user/demo data tables.
* Requires nodePath, driveRoot and appLoc in the caller's scope.
*/
function libDataDir(libref) {
return nodePath.resolve(driveRoot, 'files', appLoc, 'data', libref)
}
/**
* Lists all librefs that have a data folder on the Drive.
* Requires nodePath, driveRoot and appLoc in the caller's scope.
*/
function allLibrefs() {
const base = nodePath.resolve(driveRoot, 'files', appLoc, 'data')
try {
return fs.readdirSync(base)
.filter(f => {
const p = nodePath.resolve(base, f)
return fs.existsSync(p) && fs.statSync(p).isDirectory()
})
.sort()
} catch (err) { return [] }
}
/**
* Creates a loadTableData function bound to a specific data directory.
* const loadTableData = makeTableLoader(dataDir)
* const mpeXTest = loadTableData('MPE_X_TEST')
*/
function makeTableLoader(dataDir) {
return function loadTableData(tableName) {
try {
const file = nodePath.resolve(dataDir, tableName.toLowerCase() + '.json')
return JSON.parse(fs.readFileSync(file, {encoding:'utf8'}).toString())
} catch(err) { return null }
}
}
/**
* Loads a table from any libref, searching every data folder until a JSON
* file named <table>.json is found. Returns { data, libref } or null.
* Requires nodePath, driveRoot and appLoc in the caller's scope.
*/
function loadTableAnyLib(tableName) {
for (const lib of allLibrefs()) {
const dir = libDataDir(lib)
const loader = makeTableLoader(dir)
const data = loader(tableName)
if (data) return { data, libref: lib }
}
return null
}
/**
* Loads just the column schema (array of column metadata) for a table.
*/
function loadTableSchema(dataDir, tableName) {
const data = makeTableLoader(dataDir)(tableName)
return (data && data.columns) ? data.columns : []
}
/**
* The SAS test "&dc_dttmtfmt. lt tx_to": a row is current while TX_TO is ahead
* of now. An open row carries 31DEC9999, which is what makedata writes, so a
* missing, null or blank TX_TO is the only other thing treated as open - that
* is a table with no version column at all. Shared by every mock that reads a
* versioned MPE_* table, so the rule is applied in one place.
*/
function isCurrentRow(row) {
return isCurrentOn(row, 'TX_TO')
}
/**
* The same test against another version column, for the one table that does not
* use TX_TO (MPE_SELECTBOX versions on VER_TO_DTTM). Separate from
* isCurrentRow because Array.filter passes the index as a second argument,
* which a `column` parameter would silently take as the column name.
*/
function isCurrentOn(row, column) {
const raw = colVal(row, column)
if (raw === null || raw === undefined || raw === '') return true
const txTo = Number(raw)
// 0 is 01JAN1960 in SAS, a closed window like any other past timestamp, so
// it is deliberately not special-cased here.
return isNaN(txTo) || txTo > sasDatetimeNow()
}
/**
* The audit table a table's diffs belong to: MPE_TABLES.AUDIT_LIBDS when the
* row sets it, otherwise the control library's MPE_AUDIT. mpe_targetloader.sas
* defaults it the same way, and both the approval that writes the diffs and the
* restore that reads them resolve it here, so they cannot disagree about where
* a load's history lives.
*/
function auditLibdsFor(tableRow, dcLibref) {
const configured = tableRow ? colVal(tableRow, 'AUDIT_LIBDS') : null
const value =
configured === null || configured === undefined
? ''
: String(configured).trim()
return value || String(dcLibref || 'DC_JSLIB') + '.MPE_AUDIT'
}
/**
* Binds controlTable() to a data directory: the rows of an MPE_* table, or []
* when the deployment has not written it yet (a mock estate before makedata).
* Rows, not the whole {columns, rows} structure - every caller here only wants
* the rows, the way a PROC SQL query over the table would.
*/
function makeControlTableLoader(dataDir) {
return function controlTable(name) {
if (!dataDir) return []
try {
const file = nodePath.resolve(dataDir, name.toLowerCase() + '.json')
const raw = fs.readFileSync(file, { encoding: 'utf8' }).toString()
return JSON.parse(raw).rows || []
} catch (err) {
return []
}
}
}
// ─── mpe_runhook.sas ─────────────────────────────────────────────────────────
/**
* mpe_runhook: the source of the hook named by `hookvar` on an MPE_TABLES row.
*
* The macro reads the macro variable named by hookvar (PRE_EDIT_HOOK,
* POST_APPROVE_HOOK, ...), resolves the program it names and %includes it - so
* the hook runs in the service's own scope and shares its macro variables. The
* mock keeps its hooks as .js files beside the services; this returns the source
* for the caller to eval() in its own scope, which is the same contract.
*
* @param {object} tableReg the table's MPE_TABLES row
* @param {string} hookvar the column name, case insensitive
* @returns {string|null} the hook source, or null when none is registered
*/
function mpeRunhook(tableReg, hookvar) {
if (!tableReg) return null
const key = String(hookvar || '').toLowerCase()
// an MPE_TABLES row written through the editor carries the schema's
// upper-case names, while the seeded rows are lower case - read both, as
// the buskey/loadtype reads beside this one do
const value =
tableReg[key] !== undefined ? tableReg[key] : tableReg[key.toUpperCase()]
return mockHookSource(value)
}
/**
* Resolves a hook program name to the source of its mock implementation.
*
* MPE_TABLES.pre_edit_hook / post_edit_hook (and the SOFTSELECT_HOOK /
* HARDSELECT_HOOK rule_value in MPE_VALIDATIONS) hold a hook program: either a
* physical .sas path, or a path relative to the appLoc (e.g.
* 'services/hooks/mytable_postedit'). The real backend %includes the program
* into the running service, so the hook shares the service's macro variables
* and work datasets and can modify them.
*
* The mock mirrors that: this returns the source of the hook's .js counterpart,
* which the calling service eval()s in its OWN scope so the hook can read and
* modify the service's variables. Returns null when the hook has no mock
* implementation, so callers can fall back to an inline emulation or ignore it.
*
* Requires nodePath, driveRoot and appLoc in the caller's scope.
*/
function mockHookSource(hookValue) {
if (!hookValue) return null
let hook = String(hookValue).trim()
if (!hook) return null
// Reduce to the services-relative form, dropping any drive path or appLoc
// prefix the config may carry.
const idx = hook.indexOf('services/')
if (idx > -1) hook = hook.slice(idx)
// A physical hook is a .sas program on the server; its mock is a .js file
// sitting in the same place on the Drive.
hook = hook.replace(/\.sas$/i, '')
const hookFile = nodePath.resolve(driveRoot, 'files', appLoc, hook + '.js')
// The hook value is hand-edited mock config, and a value carrying '..'
// segments would otherwise resolve outside the Drive and be eval()'d
// in-service. Keep it inside the Drive's files tree.
const filesRoot = nodePath.resolve(driveRoot, 'files')
if (!hookFile.startsWith(filesRoot + nodePath.sep)) return null
if (!fs.existsSync(hookFile)) return null
return fs.readFileSync(hookFile, 'utf8')
}
/**
* Synthesises NOTNULL dqrules from the table schema, mirroring the
* dictionary.columns union in getdata.sas:
* select upcase(name) as base_col, 'NOTNULL' as rule_type, '' as rule_value
* from dictionary.columns where ... and notnull='yes'
* Returns an array of { BASE_COL, RULE_TYPE, RULE_VALUE } entries for every
* schema column whose `notnull` flag is true.
*/
function notNullRules(schema) {
if (!schema) return []
const rules = []
for (const col of schema) {
if (col.notnull) {
rules.push({
BASE_COL: String(col.name).toUpperCase(),
RULE_TYPE: 'NOTNULL',
RULE_VALUE: ''
})
}
}
return rules
}
/**
* Builds a $sasdata/$viewdata vars entry for a column.
* Temporal columns are sent as char ($200.) by the real SAS backend.
*/
function sasVarsEntry(col) {
const ddtype = getDdType(col)
if (ddtype === 'DATE' || ddtype === 'DATETIME' || ddtype === 'TIME') {
return { format: '$200.', label: col.label || col.name, length: '200', type: 'char' }
}
const fmt = col.type === 'N' ? (col.format || 'best.') : ('$' + col.length + '.')
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
* editors/stagedata (grid submit) and editors/loadfile (CSV upload) services.
*
* @param {Object} opts - { dataDir, stagingDir, tableId, libds, libref, dsn,
* message, headers, rows, weboutPath }
* @returns {Object} the stageData object written to staged.json
*/
function stageSubmission(opts) {
const { dataDir, stagingDir, tableId, libds, libref, dsn, message, headers, rows } = opts
const stageFolder = nodePath.resolve(stagingDir, tableId)
fs.mkdirSync(stageFolder, { recursive: true })
const stageData = {
table_id: tableId,
libds,
libref,
dsn,
submitted_by_nm: getDcUser(),
submitted_on_dttm: Date.now() / 1000,
headers,
rows
}
fs.writeFileSync(
nodePath.resolve(stageFolder, 'staged.json'),
JSON.stringify(stageData, null, 2)
)
// keep audit items from this Stored Program run alongside the staged data
copySessionAuditItems(stageFolder, 'submit')
// Files the SAS loader leaves in the approval folder, so the audit pack
// holds the same artefacts: the cleaned CSV and the uploaded file itself.
for (const file of opts.auditFiles || []) {
fs.writeFileSync(nodePath.resolve(stageFolder, file.name), file.content)
}
// Insert a record into MPE_SUBMIT (always in the control library /
// mpelib, regardless of which library the base table lives in)
const mpeDataDir = libDataDir('DC_JSLIB')
const loadTableData = makeTableLoader(mpeDataDir)
const submitTable = loadTableData('MPE_SUBMIT') || {
name: 'MPE_SUBMIT',
columns: [],
rows: []
}
submitTable.rows.push({
TABLE_ID: tableId,
SUBMIT_STATUS_CD: 'SUBMITTED',
BASE_LIB: libref,
BASE_DS: dsn,
SUBMITTED_BY_NM: stageData.submitted_by_nm,
SUBMITTED_ON_DTTM: stageData.submitted_on_dttm,
SUBMITTED_REASON_TXT: message || '',
INPUT_OBS: rows.length,
INPUT_VARS: headers.filter((h) => h !== '_____DELETE__THIS__RECORD_____').length,
NUM_OF_APPROVALS_REQUIRED: 1,
NUM_OF_APPROVALS_REMAINING: 1,
REVIEWED_BY_NM: '',
REVIEWED_ON_DTTM: null
})
const submitFile = nodePath.resolve(mpeDataDir, 'mpe_submit.json')
fs.writeFileSync(submitFile, JSON.stringify(submitTable, null, 2))
return stageData
}
/**
* Copies audit items from the Stored Program's session directory (its
* current working directory at runtime) into the staging subfolder:
* - log.log -> <prefix>.log (runtime log of the submission)
* - code.js -> <prefix>_code.js (program source as executed)
* - reqHeaders.txt -> <prefix>_reqHeaders.txt
*/
function copySessionAuditItems(stageFolder, prefix) {
try {
const sessionDir = nodePath.dirname(weboutPath)
const copies = [
['log.log', prefix + '.log'],
['code.js', prefix + '_code.js'],
['reqHeaders.txt', prefix + '_reqHeaders.txt']
]
for (const [src, dest] of copies) {
const srcPath = nodePath.resolve(sessionDir, src)
if (fs.existsSync(srcPath)) {
fs.copyFileSync(srcPath, nodePath.resolve(stageFolder, dest))
}
}
} catch (err) {
console.log('Error copying session audit items: ' + err.message)
}
}
// ─── %mp_streamfile + ODS package (zip) ───────────────────────────────────────
const ZIP_CRC_TABLE = (() => {
const table = new Array(256)
for (let i = 0; i < 256; i++) {
let c = i
for (let k = 0; k < 8; k++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1)
table[i] = c >>> 0
}
return table
})()
function zipCrc32(bytes) {
let crc = -1
for (let i = 0; i < bytes.length; i++)
crc = (crc >>> 8) ^ ZIP_CRC_TABLE[(crc ^ bytes[i]) & 0xff]
return (crc ^ -1) >>> 0
}
function zipUtf8(value) {
const text = String(value)
if (typeof TextEncoder !== 'undefined') return new TextEncoder().encode(text)
const out = []
for (let i = 0; i < text.length; i++) {
const code = text.charCodeAt(i)
if (code < 0x80) out.push(code)
else if (code < 0x800) out.push(0xc0 | (code >> 6), 0x80 | (code & 0x3f))
else out.push(0xe0 | (code >> 12), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f))
}
return Uint8Array.from(out)
}
/**
* Renders rows as CSV, the counterpart of %mp_ds2csv: the header row, then one
* line per row, with values quoted only when they carry a delimiter, quote or
* line break.
*
* @param {string[]} headers column names, in order
* @param {Array<Object>} rows objects keyed by column name
* @returns {string} CSV text with a trailing newline
*/
function toCsv(headers, rows) {
const cell = (value) => {
const text = value === undefined || value === null ? '' : String(value)
return /[",\n\r]/.test(text) ? '"' + text.replace(/"/g, '""') + '"' : text
}
const lines = [headers.map(cell).join(',')]
for (const row of rows) {
lines.push(headers.map((header) => cell(row[header])).join(','))
}
return lines.join('\n') + '\n'
}
/**
* Builds a zip archive in memory, entries stored rather than deflated - the JS
* counterpart of the ODS package getauditfile.sas publishes. The zip format
* leaves compression to the writer, so a stored archive opens anywhere.
*
* @param {Array<{name: string, content: string}>} entries archive entries
* @returns {Uint8Array} the archive
*/
function mpZipPackage(entries) {
const u16 = (n) => [n & 0xff, (n >>> 8) & 0xff]
const u32 = (n) => [n & 0xff, (n >>> 8) & 0xff, (n >>> 16) & 0xff, (n >>> 24) & 0xff]
const local = []
const central = []
let offset = 0
for (const entry of entries) {
const name = zipUtf8(entry.name)
const data = zipUtf8(entry.content)
const crc = zipCrc32(data)
// Bit 11 marks the name as UTF-8. Without it an extractor decodes a
// non-ASCII name as CP437, so the entry cannot be read back by name.
const flags = name.some((byte) => byte >= 0x80) ? 0x0800 : 0
// 1980-01-01 00:00, the earliest DOS timestamp: a stable value keeps
// the archive byte-identical for identical inputs.
const header = [].concat(
u32(0x04034b50), u16(20), u16(flags), u16(0), u16(0), u16(0x21),
u32(crc), u32(data.length), u32(data.length), u16(name.length), u16(0)
)
local.push(Uint8Array.from(header), name, data)
central.push({ name, crc, size: data.length, offset, flags })
offset += header.length + name.length + data.length
}
const directory = []
let directorySize = 0
for (const entry of central) {
const header = [].concat(
u32(0x02014b50), u16(20), u16(20), u16(entry.flags), u16(0), u16(0), u16(0x21),
u32(entry.crc), u32(entry.size), u32(entry.size),
u16(entry.name.length), u16(0), u16(0), u16(0), u16(0), u32(0),
u32(entry.offset)
)
directory.push(Uint8Array.from(header), entry.name)
directorySize += header.length + entry.name.length
}
const end = Uint8Array.from([].concat(
u32(0x06054b50), u16(0), u16(0),
u16(central.length), u16(central.length),
u32(directorySize), u32(offset), u16(0)
))
const chunks = local.concat(directory, [end])
const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0)
const archive = new Uint8Array(total)
let at = 0
for (const chunk of chunks) {
archive.set(chunk, at)
at += chunk.length
}
return archive
}
/**
* Streams a file back as an attachment, the JS counterpart of %mp_streamfile on
* the SASJS platform: the headers name the content type and the body is the file
* itself. Returns false where that is not possible - the browser mock host has
* a text-only webout - so the caller can fall back to something textual.
*
* @param {{filename: string, contentType: string, bytes: Uint8Array}} opts
* @returns {boolean} whether the file was streamed
*/
function mpStreamfile(opts) {
if (typeof Buffer === 'undefined') return false
// A content-type header is what tells the SASjs Server to send webout.txt as
// binary rather than a string (api/src/controllers/internal/Execution.ts).
fs.writeFileSync(
_SASJS_WEBOUT_HEADERS,
`Content-type: ${opts.contentType} \n Content-Disposition: attachment;filename="${opts.filename}"`
)
fs.writeFileSync(weboutPath, Buffer.from(opts.bytes))
return true
}
/**
* Generates a DC table_id in the same format as the real backend
* (DC<yyyymmdd-hhmmss.sss>_<rand6>_<pid4>).
*/
function makeTableId() {
const now = new Date()
const pad = (n, w) => String(n).padStart(w, '0')
const dtStr =
now.getFullYear() +
pad(now.getMonth() + 1, 2) +
pad(now.getDate(), 2) +
'-' +
pad(now.getHours(), 2) +
pad(now.getMinutes(), 2) +
pad(now.getSeconds(), 2) +
'.' +
pad(now.getMilliseconds(), 3)
const rand6 = pad(Math.floor(Math.random() * 1000000), 6)
const pid4 = pad(process.pid % 10000, 4)
return `DC${dtStr}_${rand6}_${pid4}`
}
// ─── mp_abort.sas ────────────────────────────────────────────────────────────
/**
* mp_abort: refuse a request the way the SAS macro does.
*
* The response carries a sasjsAbort table (MSG + MAC), which the client turns
* into the abort modal and a rejected promise. That is the difference between
* a refusal a user can read and a generic HTTP 500 - or, on a route that expects
* JSON, an "invalid Json string" in the adapter's log.
*
* Execution does not continue: the SAS macro is a soft abort, and the closest
* JS equivalent is to stop the program. The body already written to the webout
* is still returned - the browser host and the server both read the webout file
* even when the program throws - so the abort response is what reaches the
* client.
*
* @param {string} message the MSG the user sees
* @param {string} mac the calling macro/service, for the log
* @param {{tables?: Array<[string, Array]>}} [options] extra members to send
* with the abort (eg the sasparams STATUS row a service emits beside it)
*/
function mpAbort(message, mac, options) {
const opts = options || {}
webout('OPEN')
webout('OBJ', 'sasjsAbort', [
{
MSG: String(message === undefined || message === null ? '' : message),
MAC: String(mac === undefined || mac === null ? '' : mac)
}
])
for (const table of opts.tables || []) {
webout('OBJ', table[0], table[1])
}
/*
No CLOSE, and that is estate parity rather than a mock limitation: a SAS
soft abort writes its message and stops, so the body never reaches the
state that carries SYSCC and SYSENCODING - those are written when the
response is finalised. The SAS estate's own abort does carry SYSCC,
because its wrapper adds it; that is the difference the abort scenarios
declare rather than the harness faking.
The members above are already on the session's webout file when the abort
throws - @sasjs/utils writes each as it is added, and the server serves
that file (api/src/controllers/session.ts sessionOutput), so a caller
receives what was flushed. Every abort writes sasjsAbort first, so no
caller is handed an empty body.
*/
const stop = new Error(String(message))
stop.mockAbort = true
throw stop
}
// ─── mp_lockanytable.sas ─────────────────────────────────────────────────────
/** The SAS datetime form TX_FROM/TX_TO and LOCK_START_DTTM carry. */
function sasDatetimeNow() {
return Math.floor((Date.now() - SAS_EPOCH_MS) / 1000)
}
/**
* The session's job id, as &sysjobid is. The browser host runs every program
* with the same process id, so a per-run value is what distinguishes this
* session's lock from another's.
*/
let _mockSysJobId = null
function mockSysJobId() {
if (!_mockSysJobId) {
const pid =
typeof process !== 'undefined' && process.pid ? String(process.pid) : '0'
_mockSysJobId = pid + String(Math.floor(Math.random() * 1e9))
}
return _mockSysJobId
}
/**
* mp_lockanytable: lock or unlock one table through MPE_LOCKANYTABLE.
*
* Same control table, same columns and the same failure message as the macro:
* a lock is a row marked LOCKED carrying the holder's job id and reference, so
* two sessions cannot write MPE_REVIEW / MPE_SUBMIT over each other.
*
* The macro retries (loops x loop_secs) because another session may release the
* lock between attempts. Here nothing else can run while this request is inside
* the loop - the runtime is single threaded - so the retries could only ever
* fail, and the wait is skipped: a lock held elsewhere aborts at once with the
* macro's message.
*
* @param {string} action LOCK or UNLOCK
* @param {object} opts lib, ds, ref; loadTable(name), saveTable(name, data);
* optional loops, pid, mac
*/
function mpLockAnyTable(action, opts) {
const options = opts || {}
const verb = String(action === undefined ? '' : action).toUpperCase()
const lib = String(options.lib || '').toUpperCase()
const ds = String(options.ds || '').toUpperCase()
const ref = String(options.ref || '')
const mac = options.mac || 'mp_lockanytable'
const loops = Math.max(1, Number(options.loops || 25))
const pid = String(options.pid || mockSysJobId())
if (!options.loadTable || !options.saveTable) {
throw new Error('mp_lockanytable: no control table accessor was provided')
}
if (!ds) throw new Error('dataset was not provided')
if (verb !== 'LOCK' && verb !== 'UNLOCK' && verb !== 'MAKETABLE') {
throw new Error('Invalid action (' + action + ') provided')
}
const ctl = options.loadTable('MPE_LOCKANYTABLE') || { rows: [] }
ctl.rows = ctl.rows || []
const row = ctl.rows.find(
(entry) =>
String(entry.LOCK_LIB || '').toUpperCase() === lib &&
String(entry.LOCK_DS || '').toUpperCase() === ds
)
if (verb === 'UNLOCK') {
if (row && String(row.LOCK_PID || '') === pid) {
row.LOCK_STATUS_CD = 'UNLOCKED'
options.saveTable('MPE_LOCKANYTABLE', ctl)
}
return
}
if (verb === 'MAKETABLE') return
if (!row) {
ctl.rows.push({
LOCK_LIB: lib,
LOCK_DS: ds,
LOCK_STATUS_CD: 'LOCKED',
LOCK_START_DTTM: sasDatetimeNow(),
LOCK_USER_NM: getDcUser(),
LOCK_PID: pid,
LOCK_REF: ref
})
options.saveTable('MPE_LOCKANYTABLE', ctl)
return
}
const heldByOther =
String(row.LOCK_STATUS_CD || '').toUpperCase() === 'LOCKED' &&
String(row.LOCK_PID || '') !== pid
for (let attempt = 1; heldByOther && attempt <= loops; attempt++) {
/* nothing else can release it while this request runs - see above */
if (attempt < loops) continue
mpAbort(
'Unable to lock ' +
lib +
'..' +
ds +
' via MPE_LOCKANYTABLE after ' +
loops +
' attempts.\nPlease ask your administrator to investigate!',
mac
)
}
row.LOCK_STATUS_CD = 'LOCKED'
row.LOCK_START_DTTM = sasDatetimeNow()
row.LOCK_USER_NM = getDcUser()
row.LOCK_PID = pid
row.LOCK_REF = ref
options.saveTable('MPE_LOCKANYTABLE', ctl)
}
// ─── mp_filtergenerate.sas ────────────────────────────────────────────────────
/**
* Parses a SAS literal RAW_VALUE into a JS value or array.
* 'O''Brien' -> "O'Brien" (char, doubled quotes)
* 42 -> 42 (numeric)
* ('A','B') -> ["A","B"] (IN list)
* (1,2,3) -> [1,2,3] (IN list, numeric)
* 1 AND 5 -> [1, 5] (BETWEEN)
* '20DEC2053'd-> 34322 (date / datetime / time literal)
*/
function parseFilterRawValue(raw, operator) {
if (raw === undefined || raw === null || raw === '') return null
const s = String(raw).trim()
if (operator === 'BETWEEN') {
const parts = s.split(/\s+AND\s+/i)
return parts.map(p => parseFilterScalar(p))
}
if (operator === 'IN' || operator === 'NOT IN') {
const inner = s.replace(/^\(/, '').replace(/\)$/, '')
return inner.split(',').map(p => parseFilterScalar(p.trim()))
}
return parseFilterScalar(s)
}
/**
* SAS date / datetime / time literal -> the SAS internal value, or null.
* 34322 <- '20DEC2053'd
* 42 <- '01JAN1960:00:00:42'dt
* 42 <- '00:00:42't
* The client builds these in canonical ddMMMyyyy form (HelperService
* .sasTemporalLiteral), so only that shape needs parsing here.
*/
const TEMPORAL_LITERAL_RE = /^'(\d{2})([A-Z]{3})(\d{4})(?::(\d{2}):(\d{2}):(\d{2})(\.\d+)?)?'(dt|d|t)$/i
/* A time literal carries no date part, so it is matched separately */
const TIME_LITERAL_RE = /^'(\d{2}):(\d{2}):(\d{2})(\.\d+)?'t$/i
function parseTemporalLiteral(s) {
const t = TIME_LITERAL_RE.exec(s)
if (t) {
return Number(t[1]) * 3600 + Number(t[2]) * 60 + Number(t[3]) +
(t[4] ? Number(t[4]) : 0)
}
const m = TEMPORAL_LITERAL_RE.exec(s)
if (!m) return null
const month = MONTHS_ABBR.indexOf(m[2].toUpperCase())
if (month < 0) return null
const days = (Date.UTC(Number(m[3]), month, Number(m[1])) - SAS_EPOCH_MS) / 86400000
const suffix = m[8].toLowerCase()
if (suffix === 'd') return days
const secs = Number(m[4]) * 3600 + Number(m[5]) * 60 + Number(m[6]) +
(m[7] ? Number(m[7]) : 0)
return suffix === 'dt' ? days * 86400 + secs : secs
}
function parseFilterScalar(s) {
s = s.trim()
const temporal = parseTemporalLiteral(s)
if (temporal !== null) return temporal
if (s.startsWith("'") && s.endsWith("'")) {
return s.slice(1, -1).replace(/''/g, "'")
}
if (s.startsWith('"') && s.endsWith('"')) {
return s.slice(1, -1).replace(/""/g, '"')
}
const num = Number(s)
return Number.isFinite(num) && s !== '' ? num : s
}
/**
* Compares a row value against a SAS-literal RAW_VALUE for the given operator.
*/
function compareFilterValue(val, op, rawVal, isNumCol) {
if (val === undefined || val === null) return false
if (op === '=' || op === 'EQ') {
const target = parseFilterRawValue(rawVal, '=')
return isNumCol ? Number(val) === target : String(val) === String(target)
}
if (op === 'NE') {
const target = parseFilterRawValue(rawVal, 'NE')
return isNumCol ? Number(val) !== target : String(val) !== String(target)
}
if (op === '<') {
const target = parseFilterRawValue(rawVal, '<')
return isNumCol ? Number(val) < target : String(val) < String(target)
}
if (op === '>') {
const target = parseFilterRawValue(rawVal, '>')
return isNumCol ? Number(val) > target : String(val) > String(target)
}
if (op === '<=') {
const target = parseFilterRawValue(rawVal, '<=')
return isNumCol ? Number(val) <= target : String(val) <= String(target)
}
if (op === '>=') {
const target = parseFilterRawValue(rawVal, '>=')
return isNumCol ? Number(val) >= target : String(val) >= String(target)
}
if (op === 'BETWEEN') {
const parts = parseFilterRawValue(rawVal, 'BETWEEN')
if (Array.isArray(parts) && parts.length === 2) {
return isNumCol
? Number(val) >= parts[0] && Number(val) <= parts[1]
: String(val) >= String(parts[0]) && String(val) <= String(parts[1])
}
return true
}
if (op === 'IN') {
const list = parseFilterRawValue(rawVal, 'IN')
if (Array.isArray(list)) {
return isNumCol
? list.includes(Number(val))
: list.map(String).includes(String(val))
}
return false
}
if (op === 'NOT IN') {
const list = parseFilterRawValue(rawVal, 'NOT IN')
if (Array.isArray(list)) {
return isNumCol
? !list.includes(Number(val))
: !list.map(String).includes(String(val))
}
return true
}
if (op === 'CONTAINS') {
const target = parseFilterRawValue(rawVal, 'CONTAINS')
return String(val).toLowerCase().includes(String(target).toLowerCase())
}
// Unknown operator: don't filter
return true
}
/**
* Renders the WHERE clause text that mp_filtergenerate.sas writes to the
* fileref, joined the way getdata.sas/viewdata.sas collapse the lines into
* FILTER_TEXT (catx(' ')). Single clause: (( A = 1 )). Empty query: 1=1.
*/
function mpFilterGenerateText(fqRows) {
const sorted = [...fqRows].sort((a, b) =>
Number(colVal(a, 'SUBGROUP_ID')) - Number(colVal(b, 'SUBGROUP_ID')))
const lines = []
let prevSg = null
for (let i = 0; i < sorted.length; i++) {
const r = sorted[i]
const sg = colVal(r, 'SUBGROUP_ID')
if (i === 0) lines.push('((')
else if (sg !== prevSg) lines.push((colVal(r, 'GROUP_LOGIC') || 'AND') + ' (')
else lines.push(colVal(r, 'SUBGROUP_LOGIC') || 'AND')
lines.push(
colVal(r, 'VARIABLE_NM') + ' ' +
colVal(r, 'OPERATOR_NM') + ' ' +
colVal(r, 'RAW_VALUE'))
const nextSg = i + 1 < sorted.length ? colVal(sorted[i + 1], 'SUBGROUP_ID') : null
if (nextSg !== sg) lines.push(i === sorted.length - 1 ? '))' : ')')
prevSg = sg
}
return lines.map(l => String(l).trim()).filter(l => l !== '').join(' ')
}
/**
* Builds a JS filter predicate from FILTERQUERY rows, mirroring the SAS WHERE
* clause that mp_filtergenerate.sas would produce.
*
* Groups rows by SUBGROUP_ID; each subgroup is AND-ed within (SUBGROUP_LOGIC),
* then the subgroups are combined by GROUP_LOGIC (AND/OR).
*
* @param {Array<Object>} fqRows - Filter query rows (GROUP_LOGIC, SUBGROUP_LOGIC,
* SUBGROUP_ID, VARIABLE_NM, OPERATOR_NM, RAW_VALUE)
* @param {Array<Object>} columns - Column metadata [{ name, type, format, ... }]
* used to determine numeric vs char comparison
* @returns {function(Object): boolean} predicate function
*/
function mpFilterGenerate(fqRows, columns) {
if (!fqRows || fqRows.length === 0) return () => true
// Build a column lookup: UPPER(name) -> { type, ddtype }
const colMap = {}
if (columns) {
for (const c of columns) {
const ddtype = getDdType(c)
colMap[c.name.toUpperCase()] = { type: c.type === 'N' ? 'num' : 'char', ddtype }
}
}
// Group rows by SUBGROUP_ID (matching: proc sort; by SUBGROUP_ID)
const subgroups = {}
for (const r of fqRows) {
const sgId = colVal(r, 'SUBGROUP_ID') || 0
if (!subgroups[sgId]) subgroups[sgId] = []
subgroups[sgId].push(r)
}
const subgroupIds = Object.keys(subgroups).sort((a, b) => Number(a) - Number(b))
return function matches(row) {
let result = true
let firstGroup = true
for (const sgId of subgroupIds) {
const rows = subgroups[sgId]
let sgResult = true
for (const r of rows) {
const varNm = (colVal(r, 'VARIABLE_NM') || '').toString()
const val = colVal(row, varNm)
const op = (colVal(r, 'OPERATOR_NM') || '').toString().toUpperCase()
const rawVal = colVal(r, 'RAW_VALUE')
const colInfo = colMap[varNm.toUpperCase()]
const isNumCol = colInfo && colInfo.type === 'num'
if (!compareFilterValue(val, op, rawVal, isNumCol)) sgResult = false
}
const groupLogic = (colVal(rows[0], 'GROUP_LOGIC') || 'AND').toString().toUpperCase()
if (firstGroup) {
result = sgResult
firstGroup = false
} else if (groupLogic === 'OR') {
result = result || sgResult
} else {
result = result && sgResult
}
}
return result
}
}
// ─── mpe_filtermaster.sas ────────────────────────────────────────────────────
/**
* Retrieves and applies a stored filter, mirroring mpe_filtermaster.sas.
*
* If filter_rk > 0, looks up the FILTER_HASH from mpe_filteranytable, then
* fetches the filter rows from mpe_filtersource (sorted by filter_line),
* dropping filter_hash/filter_line/processed_dttm to produce the query dataset.
* Then calls mpFilterGenerate to build the predicate.
*
* Also applies the current-record filter for EDIT/DLOAD mode (tx_from/tx_to)
* when the table is TXTEMPORAL/BITEMPORAL — matching the SAS macro.
*
* @param {Object} opts
* @param {string} opts.mode - EDIT, VIEW, DLOAD, ULOAD
* @param {string} opts.libds - Target table (libref.dataset), uppercased
* @param {number} opts.filterRk - The filter_rk (-1 if none)
* @param {string} opts.dataDir - Data directory for the DC control library
* @param {Array<Object>} [opts.columns] - Column metadata for the target table
* (needed by mpFilterGenerate for numeric/char comparison)
* @param {Object} [opts.tableReg] - MPE_TABLES row for the target table
* (needed for temporal current-record filter)
* @returns {{ query: Array<Object>, predicate: function(Object): boolean }}
*/
function mpeFilterMaster(opts) {
const { mode, libds, filterRk, dataDir, columns, tableReg } = opts
const loadTableData = makeTableLoader(dataDir)
let query = []
// ── Deal with FILTER_RK first ──────────────────────────────────────
// data _null_; set &dclib..mpe_filteranytable(where=(filter_rk=&filter_rk))
// call symputx('filter_hash',filter_hash,'l')
if (filterRk && filterRk > 0) {
const summaryTable = loadTableData('MPE_FILTERANYTABLE')
if (summaryTable && summaryTable.rows) {
const summaryRow = summaryTable.rows.find(r =>
Number(colVal(r, 'FILTER_RK')) === Number(filterRk)
)
if (summaryRow) {
const filterHash = colVal(summaryRow, 'FILTER_HASH')
// proc sort data=&dclib..mpe_filtersource(where=(filter_hash=...))
// out=&outds(drop=filter_hash filter_line processed_dttm); by filter_line
const detailTable = loadTableData('MPE_FILTERSOURCE')
if (detailTable && detailTable.rows) {
query = detailTable.rows
.filter(r => colVal(r, 'FILTER_HASH') === filterHash)
.sort((a, b) => Number(colVal(a, 'FILTER_LINE')) - Number(colVal(b, 'FILTER_LINE')))
.map(r => ({
GROUP_LOGIC: colVal(r, 'GROUP_LOGIC'),
SUBGROUP_LOGIC: colVal(r, 'SUBGROUP_LOGIC'),
SUBGROUP_ID: Number(colVal(r, 'SUBGROUP_ID')),
VARIABLE_NM: colVal(r, 'VARIABLE_NM'),
OPERATOR_NM: colVal(r, 'OPERATOR_NM'),
RAW_VALUE: colVal(r, 'RAW_VALUE')
}))
}
}
}
}
// ── Build the predicate from the query (mp_filtergenerate) ────────
let predicate = mpFilterGenerate(query, columns)
let filterText = mpFilterGenerateText(query)
// data _null_ (getdata.sas/viewdata.sas): if cats(filter_text)='1=1'
// then filter_text='' — an empty filter hides the client info bar
if (filterText === '1=1') filterText = ''
// ── Current-record filter for EDIT/DLOAD mode ──────────────────────
// Mirrors: if &varfrom ne 0 and (&mode=EDIT or &mode=DLOAD)
// ("&datetime"dt < varto)
if (tableReg && (mode === 'EDIT' || mode === 'DLOAD')) {
const varFrom = tableReg.var_txfrom
const varTo = tableReg.var_txto
if (varFrom && varTo) {
// Check if the validity vars are mentioned in the query
const hasValidityVar = query.some(r => {
const vn = (colVal(r, 'VARIABLE_NM') || '').toUpperCase()
return vn === varFrom.toUpperCase() || vn === varTo.toUpperCase()
})
if (!hasValidityVar) {
const now = Date.now() / 1000
const inner = predicate
predicate = (row) => {
if (!inner(row)) return false
// (&dc_dttmtfmt. lt varto) — current record has tx_to > now
const txTo = colVal(row, varTo)
return txTo === undefined || txTo === null || Number(txTo) > now
}
}
}
}
return { query, predicate, filterText }
}
// ─── Column value lookup / table writer ──────────────────────────────────────
/**
* Gets a value from a row object by column name, case-insensitive.
* Centralises the lookup pattern used throughout the mock services.
*/
function colVal(row, name) {
if (!row || !name) return undefined
if (row[name] !== undefined) return row[name]
const lc = name.toLowerCase()
if (row[lc] !== undefined) return row[lc]
const key = Object.keys(row).find(k => k.toLowerCase() === lc)
return key ? row[key] : undefined
}
/**
* Saves a table's JSON data to the data directory.
* Complement to makeTableLoader().
*/
function saveTableData(dataDir, tableName, tableData) {
const file = nodePath.resolve(dataDir, tableName.toLowerCase() + '.json')
fs.writeFileSync(file, JSON.stringify(tableData, null, 2))
}
// ─── mp_hashdataset.sas ───────────────────────────────────────────────────────
/**
* Computes an MD5 hash of a query dataset, mirroring mp_hashdataset.sas.
*
* The hash is chained row-to-row: each row's hash includes the previous row's
* hash (prevkey), and the salt seeds the initial prevkey. Char and num
* variables are hashed separately — matching mf_getvarlist(typefilter=C/N) —
* and prevkey is appended to the char var list (cvars = charVars & prevkeyvar).
*
* Each value is inner-md5'd (put(md5(trim(var)),$hex32.)), the results are
* concatenated (!! operator), and the outer md5 produces the row hash.
*
* @param {Array<Object>} queryds - Array of row objects (e.g. filterquery rows)
* @param {string} salt - The salt value (typically the libds, uppercased)
* @param {string[]} charVars - Char variable names in column order
* @param {string[]} numVars - Numeric variable names in column order
* @returns {string} 32-char uppercase hex hash ($hex32. representation)
*/
function mpHashDataset(queryds, salt, charVars, numVars) {
const crypto = require('crypto')
const md5hex = (s) => crypto.createHash('md5').update(String(s)).digest('hex').toUpperCase()
// Empty dataset: hash is just md5(salt), matching the NLOBS=0 branch
if (!queryds || queryds.length === 0) {
return md5hex(salt)
}
// Chain seed: prevkey = md5(salt)
let prevkey = md5hex(salt)
for (const row of queryds) {
const parts = []
// Char vars (matching cvars=%mf_getvarlist(typefilter=C) &prevkeyvar)
for (const v of charVars) {
parts.push(md5hex(String(colVal(row, v) ?? '').trim()))
}
// prevkey is included as a char var
parts.push(md5hex(prevkey.trim()))
// Num vars (matching nvars=%mf_getvarlist(typefilter=N))
// SAS: put(md5(trim(put(ifn(missing(var),var,var*1),binary64.))),$hex32.)
for (const v of numVars) {
const val = colVal(row, v)
const num = Number(val)
parts.push(md5hex(String(Number.isFinite(num) ? num : '').trim()))
}
// Outer md5 of the concatenated inner hashes (!! operator)
prevkey = md5hex(parts.join(''))
}
return prevkey
}
// ─── mp_retainedkey.sas ───────────────────────────────────────────────────────
/**
* Allocates a retained key value for a new record, mirroring
* mp_retainedkey.sas.
*
* Gets max(retained_key) from the base table (or 0 if empty), increments by 1
* for the new record, and updates the maxkeytable with the new max.
*
* @param {Object} baseTable - The base (target) table JSON { name, columns, rows }
* @param {string} retainedKey - Name of the RK column (e.g. 'FILTER_RK')
* @param {Object} maxkeyTable - The maxkeyvalues table JSON (modified in place)
* @param {string} keyTableLibds - Uppercased libref.dsn of the base table
* @returns {number} The new retained key value
*/
function mpRetainedKey(baseTable, retainedKey, maxkeyTable, keyTableLibds) {
// select sum(max(&retained_key),0) into: maxkey from &base_libds
const rows = (baseTable && baseTable.rows) || []
let maxKey = 0
for (const row of rows) {
const val = Number(colVal(row, retainedKey))
if (Number.isFinite(val) && val > maxKey) maxKey = val
}
const newKey = maxKey + 1
// Update maxkeytable (matching the maxkeytable update in mp_retainedkey.sas)
if (maxkeyTable) {
const existing = maxkeyTable.rows.find(r =>
colVal(r, 'KEYTABLE') &&
colVal(r, 'KEYTABLE').toString().toUpperCase() === keyTableLibds.toUpperCase()
)
const now = Date.now() / 1000
if (existing) {
existing.MAX_KEY = newKey
existing.PROCESSED_DTTM = now
} else {
maxkeyTable.rows.push({
KEYTABLE: keyTableLibds,
KEYCOLUMN: retainedKey,
MAX_KEY: newKey,
PROCESSED_DTTM: now
})
}
}
return newKey
}
// ─── mp_filterstore.sas ───────────────────────────────────────────────────────
/**
* Validates and stores a filter query, mirroring mp_filterstore.sas.
*
* Hashes the query (mp_hashdataset with salt=libds), checks for an existing
* entry in the summary table, and if not found:
* - allocates a FILTER_RK via mp_retainedkey
* - inserts into filter_summary (mpe_filteranytable)
* - inserts into filter_detail (mpe_filtersource)
* - updates the maxkeytable (mpe_maxkeyvalues)
*
* @param {Object} opts
* @param {string} opts.libds - The target table being filtered (libref.dataset)
* @param {Array<Object>} opts.queryds - The filterquery rows
* @param {string} opts.dataDir - Data directory for the control library
* @returns {Object} { FILTER_RK, FILTER_HASH, FILTER_TABLE } (PROCESSED_DTTM
* dropped, matching: alter table work.result drop PROCESSED_DTTM)
*/
function mpFilterStore(opts) {
const { libds, queryds, dataDir } = opts
// Filterquery column types (matching the SAS dataset definition).
// mf_getvarlist(typefilter=C) returns these in column order.
const CHAR_VARS = ['GROUP_LOGIC', 'SUBGROUP_LOGIC', 'VARIABLE_NM', 'OPERATOR_NM', 'RAW_VALUE']
const NUM_VARS = ['SUBGROUP_ID']
// ── Hash the query (salt=uppercased libds, matching salt=&orig_libds) ──
const filterHash = mpHashDataset(queryds, libds.toUpperCase(), CHAR_VARS, NUM_VARS)
// ── Load summary table (mpe_filteranytable) ──────────────────────────
const loadTableData = makeTableLoader(dataDir)
const summaryTable = loadTableData('MPE_FILTERANYTABLE') || {
name: 'MPE_FILTERANYTABLE',
columns: [
{ name: 'FILTER_RK', type: 'N', length: 8, format: '', label: '', notnull: true },
{ name: 'FILTER_HASH', type: 'C', length: 32, format: '', label: '', notnull: true },
{ name: 'FILTER_TABLE', type: 'C', length: 41, format: '', label: '', notnull: true },
{ name: 'PROCESSED_DTTM', type: 'N', length: 8, format: 'datetime19.', label: '', notnull: true }
],
rows: []
}
// ── Check if hash already exists (set &filter_summary; where filter_hash=) ──
let existing = summaryTable.rows.find(r =>
colVal(r, 'FILTER_HASH') === filterHash
)
if (!existing) {
// ── Allocate FILTER_RK via mp_retainedkey ────────────────────────
const maxkeyTable = loadTableData('MPE_MAXKEYVALUES') || {
name: 'MPE_MAXKEYVALUES',
columns: [
{ name: 'KEYTABLE', type: 'C', length: 41, format: '', label: '', notnull: false },
{ name: 'KEYCOLUMN', type: 'C', length: 32, format: '$32.', label: '', notnull: false },
{ name: 'MAX_KEY', type: 'N', length: 8, format: '', label: '', notnull: false },
{ name: 'PROCESSED_DTTM', type: 'N', length: 8, format: 'E8601DT26.6', label: '', notnull: false }
],
rows: []
}
const filterRk = mpRetainedKey(
summaryTable, 'FILTER_RK', maxkeyTable,
dataDir.split('/').pop() === 'DC_JSLIB'
? 'DC_JSLIB.MPE_FILTERANYTABLE'
: libds.split('.')[0] + '.MPE_FILTERANYTABLE'
)
// ── Insert into summary table (proc append base=&filter_summary) ──
const now = Date.now() / 1000
const newSummaryRow = {
FILTER_RK: filterRk,
FILTER_HASH: filterHash,
FILTER_TABLE: libds.toUpperCase(),
PROCESSED_DTTM: now
}
summaryTable.rows.push(newSummaryRow)
saveTableData(dataDir, 'MPE_FILTERANYTABLE', summaryTable)
saveTableData(dataDir, 'MPE_MAXKEYVALUES', maxkeyTable)
// ── Insert into detail table (proc append base=&filter_detail) ──
const detailTable = loadTableData('MPE_FILTERSOURCE') || {
name: 'MPE_FILTERSOURCE',
columns: [
{ name: 'FILTER_HASH', type: 'C', length: 32, format: '', label: '', notnull: true },
{ name: 'FILTER_LINE', type: 'N', length: 8, format: '', label: '', notnull: true },
{ name: 'GROUP_LOGIC', type: 'C', length: 3, format: '', label: '', notnull: true },
{ name: 'SUBGROUP_LOGIC', type: 'C', length: 3, format: '', label: '', notnull: true },
{ name: 'SUBGROUP_ID', type: 'N', length: 8, format: '', label: '', notnull: true },
{ name: 'VARIABLE_NM', type: 'C', length: 32, format: '', label: '', notnull: true },
{ name: 'OPERATOR_NM', type: 'C', length: 12, format: '', label: '', notnull: true },
{ name: 'RAW_VALUE', type: 'C', length: 4000, format: '', label: '', notnull: true },
{ name: 'PROCESSED_DTTM', type: 'N', length: 8, format: 'datetime19.', label: '', notnull: true }
],
rows: []
}
let lineNum = 0
for (const row of queryds) {
lineNum++
detailTable.rows.push({
FILTER_HASH: filterHash,
FILTER_LINE: lineNum,
GROUP_LOGIC: colVal(row, 'GROUP_LOGIC'),
SUBGROUP_LOGIC: colVal(row, 'SUBGROUP_LOGIC'),
SUBGROUP_ID: Number(colVal(row, 'SUBGROUP_ID')),
VARIABLE_NM: colVal(row, 'VARIABLE_NM'),
OPERATOR_NM: colVal(row, 'OPERATOR_NM'),
RAW_VALUE: colVal(row, 'RAW_VALUE'),
PROCESSED_DTTM: now
})
}
saveTableData(dataDir, 'MPE_FILTERSOURCE', detailTable)
existing = newSummaryRow
}
// Return result (PROCESSED_DTTM dropped)
return {
FILTER_RK: colVal(existing, 'FILTER_RK'),
FILTER_HASH: colVal(existing, 'FILTER_HASH'),
FILTER_TABLE: colVal(existing, 'FILTER_TABLE')
}
}
// ─── mpe_derived.sas ────────────────────────────────────────────────────────
/**
* Compiles a SAS expression into a JS function - for the mock only.
*
* The real evaluation is done by SAS. This covers the subset used by the mock
* and demo configs, so the EDIT screen can be exercised (display, filtering,
* submission) without a SAS server:
*
* - column names, numbers, quoted strings
* - + - * / and comparisons (= ne < > <= >=)
* - AND OR, and `X IN (a, b, c)`
* - ifc(), cats(), catx(), upcase(), lowcase(), strip(), trim(), length(),
* substr(), index()
*
* Anything outside that subset throws when a row is derived, and the column is
* left missing for that row - the same way a failing SAS expression would.
*/
function compileSasExpression(expr, columnNames) {
// Tokenise first, so operators are only rewritten outside quoted strings
// (a literal such as 'OR' must never be read as an operator).
const tokens = []
const s = String(expr)
let i = 0
while (i < s.length) {
const c = s[i]
if (/\s/.test(c)) { i++; continue }
if (c === "'" || c === '"') {
const q = c
let j = i + 1
let val = ''
while (j < s.length) {
if (s[j] === q) {
if (s[j + 1] === q) { val += q; j += 2; continue }
break
}
val += s[j]
j++
}
tokens.push({ type: 'str', value: val })
i = j + 1
continue
}
if (/[0-9]/.test(c)) {
let j = i
while (j < s.length && /[0-9.]/.test(s[j])) j++
tokens.push({ type: 'num', value: s.slice(i, j) })
i = j
continue
}
if (/[A-Za-z_]/.test(c)) {
let j = i
while (j < s.length && /[A-Za-z0-9_]/.test(s[j])) j++
tokens.push({ type: 'word', value: s.slice(i, j) })
i = j
continue
}
const two = s.slice(i, i + 2)
if (['<=', '>=', '!=', '<>'].includes(two)) {
tokens.push({ type: 'punct', value: two })
i += 2
continue
}
// only the operators in the documented subset may reach the generated
// function - anything else is rejected rather than executed
if (!'+-*/(),<>='.includes(c)) {
throw new Error('Unsupported character in derived expression: ' + c)
}
tokens.push({ type: 'punct', value: c })
i++
}
// X IN (a, b, c) -> __in([a, b, c], X)
const out = []
for (let t = 0; t < tokens.length; t++) {
const tok = tokens[t]
if (
tok.type === 'word' &&
tok.value.toLowerCase() === 'in' &&
out.length > 0 &&
tokens[t + 1] &&
tokens[t + 1].value === '('
) {
let depth = 0
let j = t + 1
for (; j < tokens.length; j++) {
if (tokens[j].value === '(') depth++
else if (tokens[j].value === ')') {
depth--
if (depth === 0) break
}
}
if (j < tokens.length) {
const left = out.pop()
const inner = tokens.slice(t + 2, j)
out.push(
{ type: 'word', value: '__in' },
{ type: 'punct', value: '(' },
{ type: 'punct', value: '[' },
...inner,
{ type: 'punct', value: ']' },
{ type: 'punct', value: ',' },
left,
{ type: 'punct', value: ')' }
)
t = j
continue
}
}
out.push(tok)
}
const OPS = {
and: '&&', or: '||',
eq: '==', ne: '!=', lt: '<', gt: '>', le: '<=', ge: '>='
}
const js = out
.map(tok => {
if (tok.type === 'str') return JSON.stringify(tok.value)
if (tok.type === 'num') return tok.value
if (tok.type === 'word') return OPS[tok.value.toLowerCase()] || tok.value
if (tok.value === '=') return '=='
if (tok.value === '<>') return '!='
return tok.value
})
.join(' ')
const sasTrue = v => {
if (typeof v === 'boolean') return v
if (typeof v === 'number') return v !== 0 && !isNaN(v)
if (v === null || v === undefined) return false
const str = String(v).trim()
return str !== '' && str !== '0'
}
const toStr = v => {
if (v === null || v === undefined) return ''
if (typeof v === 'number') return isNaN(v) ? '' : String(v)
return String(v).trim()
}
const fns = {
ifc: (c, a, b) => (sasTrue(c) ? a : b),
cats: (...args) => args.map(toStr).join(''),
catx: (sep, ...args) => args.map(toStr).filter(v => v !== '').join(toStr(sep)),
upcase: v => toStr(v).toUpperCase(),
lowcase: v => toStr(v).toLowerCase(),
strip: v => toStr(v),
trim: v => toStr(v),
length: v => toStr(v).length,
substr: (v, p, n) => {
const str = toStr(v)
const start = Number(p) - 1
return n === undefined
? str.substring(start)
: str.substring(start, start + Number(n))
},
index: (v, sub) => toStr(v).indexOf(toStr(sub)) + 1,
__in: (list, v) => list.some(item => String(item) === String(v) || Number(item) === Number(v))
}
const names = Object.keys(fns)
const fn = new Function(...columnNames, ...names, 'return (' + js + ');')
return row => fn(...columnNames.map(n => row[n]), ...names.map(n => fns[n]))
}
/**
* Reads MPE_DERIVED for a table, mirroring %mpe_derived. Returns the active
* variables with a compiled row function, in dependency order (ties by name),
* the same sequence the real macro emits.
*
* @param {Object} opts
* @param {string} opts.libref - target libref
* @param {string} opts.table - target dataset
* @param {string} opts.dataDir - data directory of the DC control library
* @param {Array<Object>} opts.columns - physical columns of the target table
*/
function mpeDerived(opts) {
const { libref, table, dataDir, columns } = opts
const loadTableData = makeTableLoader(dataDir)
const cfg = loadTableData('MPE_DERIVED')
if (!cfg || !Array.isArray(cfg.rows) || cfg.rows.length === 0) return []
const now = Date.now() / 1000
const lib = String(libref || '').toUpperCase()
const dsn = String(table || '').toUpperCase()
const columnNames = (columns || []).map(c => c.name)
const vars = cfg.rows
.filter(r =>
String(colVal(r, 'LIBREF') || '').toUpperCase() === lib &&
String(colVal(r, 'DSN') || '').toUpperCase() === dsn &&
Number(colVal(r, 'ACTIVE')) === 1 &&
(!Number(colVal(r, 'TX_TO')) || Number(colVal(r, 'TX_TO')) > now)
)
.map(r => {
const logic = String(colVal(r, 'VAR_LOGIC') || '').trim()
const nm = String(colVal(r, 'VAR_NM') || '').toUpperCase()
return {
name: nm,
// every input the expression uses - columns and derived vars
deps: String(colVal(r, 'VAR_DEPS') || '')
.toUpperCase()
.split(/\s+/)
.filter(Boolean),
type: String(colVal(r, 'VAR_TYPE') || '').toUpperCase(),
length: Number(colVal(r, 'VAR_LEN')) || 8,
format: String(colVal(r, 'VAR_FMT') || ''),
logic,
label: String(colVal(r, 'VAR_LABEL') || '').trim() || nm + '=' + logic
}
})
.filter(d => d.name && (d.type === 'C' || d.type === 'N') && d.logic)
// dependency order: repeatedly take the variables whose derived inputs are
// already placed, alphabetically within a round
const byName = new Map(vars.map(v => [v.name, v]))
const placed = []
const done = new Set()
while (placed.length < vars.length) {
const ready = vars
.filter(v => !done.has(v.name))
.filter(v => v.deps.every(d => !byName.has(d) || done.has(d)))
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
if (ready.length === 0) {
console.log('derived variables for ' + lib + '.' + dsn + ' contain a cycle')
return []
}
for (const v of ready) {
placed.push(v)
done.add(v.name)
}
}
vars.length = 0
vars.push.apply(vars, placed)
// compile each against the physical columns plus the variables defined
// earlier, so a derived variable can build on another one
const compileCols = columnNames.slice()
for (const d of vars) {
try {
// slice(): the compiled function captures the column list it was
// built with, not the array as it grows
d.fn = compileSasExpression(d.logic, compileCols.slice())
} catch (err) {
console.log('derived variable ' + d.name + ' could not be compiled: ' + err.message)
d.fn = () => (d.type === 'N' ? null : '')
}
compileCols.push(d.name)
}
return vars
}
/**
* Adds the derived variables to every row and to the column list, so that the
* filter (which runs afterwards) and the grid both see them.
*/
function mpeDeriveRows(tableData, derived) {
if (!derived || derived.length === 0) return tableData
const rows = tableData.rows.map(row => {
const copy = Object.assign({}, row)
for (const d of derived) {
try {
copy[d.name] = d.fn(copy)
} catch (err) {
copy[d.name] = d.type === 'N' ? null : ''
}
}
return copy
})
const derivedCols = derived.map(d => ({
name: d.name,
type: d.type,
length: d.length,
format: d.format,
label: d.label,
notnull: false
}))
return {
name: tableData.name,
columns: (tableData.columns || []).concat(derivedCols),
rows
}
}
// ─── DC settings (services/settings.js) ──────────────────────────────────────
/**
* Loads the DC settings written by makedata to services/settings.js
* (the JS equivalent of the real backend's settings.sas).
* Returns { dcLibref, dataDir, dcAdminGroup, dcpath } - with defaults when
* makedata has not run yet.
* Requires nodePath, fs, driveRoot and appLoc in the caller's scope.
*/
function loadDcSettings(driveRoot, appLoc) {
// one reader for the settings program, shared with the services that cannot
// eval this file (see dcSettings.js)
eval(
fs.readFileSync(
nodePath.resolve(driveRoot, 'files', appLoc, 'services', 'dcSettings.js'),
'utf8'
)
)
const settings = dcReadSettings(driveRoot, appLoc)
return {
dcLibref: settings.dcLibref,
dcAdminGroup: settings.dcAdminGroup,
dcpath: settings.dcpath,
dataDir: nodePath.resolve(driveRoot, 'files', appLoc, 'data', settings.dcLibref)
}
}
/**
* The mock's %mpe_init(): the values every service needs before it does
* anything, read from the settings program makedata writes.
*
* The real backend generates settings.sas in makedata (dc_libref, dcpath,
* dc_admin_group, dc_staging_area) and %mpe_init reads it into macro variables
* for every service call. makedata here writes the same values to
* services/settings.js, and this is the one function services call to get them -
* so a deployment that puts the control library somewhere else, or names a
* different admin group, changes one file rather than every service.
*
* @returns {{dcLibref: string, dataDir: string, dcAdminGroup: string|null, dcpath: string|null}}
*/
function dcInit(driveRoot, appLoc) {
return loadDcSettings(driveRoot, appLoc)
}
/**
* Reads the DC-scope options out of MPE_CONFIG, mirroring the loop in the real
* backend's generated settings.sas: only rows that are active (var_active=1)
* and current (now < tx_to) count, and the latest tx_from wins. Returns a map
* of var_name -> trimmed var_value.
*
* An option that is absent, or switched off, is simply missing from the map so
* the caller's own default applies - which is what the real service's
* %symexist fallback does (a switched-off option means "use the code default",
* not "no limit").
*
* Requires nodePath, fs and the caller's driveRoot/appLoc/dataDir.
*/
function loadDcConfig(driveRoot, appLoc, dataDir) {
const out = {}
try {
const configFile = nodePath.resolve(
dataDir || nodePath.resolve(driveRoot, 'files', appLoc, 'data', 'DC_JSLIB'),
'mpe_config.json'
)
const config = JSON.parse(fs.readFileSync(configFile, {encoding:'utf8'}).toString())
const now = Math.floor(Date.now() / 1000)
const latest = {}
for (const row of (config.rows || [])) {
// colVal, not row.var_name: a row that has been through the app's
// own edit and approve flow comes back from the adapter with
// upper-case keys, and a direct property read would skip it - which
// silently drops the option back to its default.
if (colVal(row, 'VAR_SCOPE') !== 'DC') continue
if (Number(colVal(row, 'VAR_ACTIVE')) !== 1) continue
if (Number(colVal(row, 'TX_TO')) <= now) continue
const name = String(colVal(row, 'VAR_NAME') || '').trim()
if (!name) continue
const from = Number(colVal(row, 'TX_FROM')) || 0
if (latest[name] === undefined || from > latest[name]) {
const val = colVal(row, 'VAR_VALUE')
latest[name] = from
out[name] = String(val == null ? '' : val).trim()
}
}
} catch(err) { /* no MPE_CONFIG - every option falls back to its default */ }
return out
}
/**
* A numeric DC option out of a loadDcConfig() map, or the service's own default
* when the option is absent, blank, or not a number.
*/
function dcConfigNumber(cfg, name, fallback) {
const raw = cfg ? cfg[name] : undefined
if (raw === undefined || raw === '') return fallback
const num = Number(raw)
return isNaN(num) ? fallback : num
}
// Current user for MPE_SUBMIT writes/reads - mock equivalent of %mf_getuser().
// SASjs Server predeclares _METAUSER for authenticated requests; the desktop
// mode (no auth) leaves it undefined.
function getDcUser() {
return typeof _METAUSER !== 'undefined' && _METAUSER ? _METAUSER : 'sasdemo'
}
// ─── %mpeinit() emulation ────────────────────────────────────────────────────
/**
* The `_debug` values that mean "debug on", mirroring the test in mpeinit.sas.
*
* 131 is what the adapter sends - SASjsApiClient.executeJob() puts it on every
* server-mode request, and on the multipart form when the payload goes out as
* FormData. 128 is what arrives on the Viya WEB JES path when runAsTask is
* enabled, and 2477 / 'fields,log,trace' are the SASjs debug values.
*/
const MPEINIT_DEBUG_VALUES = ['2477', 'fields,log,trace', '131', '128']
/* Mirrors the &mpeinit=1 guard in the SAS macro: dump once per execution. */
let _mpeinitDone = false
/**
* Dumps the browser_url_vars and browser_info input tables, mirroring the
* debug block of %mpeinit (sas/sasjs/macros/mpeinit.sas).
*
* The SAS macro reads work.browser_url_vars (name/value pairs taken from the
* query string) and work.browser_info (the browser fingerprint). Both arrive
* here as input tables - see fetchRaw() - and are dumped with console.log,
* which SASjs Server returns in the request's log (after its
* SASJS_LOGS_SEPARATOR marker), so the dump shows up in Data Controller's
* SAS Log tab exactly as the SAS version does.
*
* Called at the bottom of this file, so the services that eval() it dump the
* tables at startup - the same place %mpeinit() runs in the SAS services.
* That covers every diagnostics-carrying service (startup, editors, auditors);
* the mocks that do not load this file (lineage, metanav, usernav, a few
* public ones) have no equivalent startup step in the SAS app either.
*
* Difference from the SAS version: the header line is printed once per table,
* where SAS repeats it for every row (its PUTLOG sits inside the SET loop).
*/
function mpeinit() {
if (_mpeinitDone) return
const debugValue = typeof _debug === 'undefined' ? '' : _debug
if (MPEINIT_DEBUG_VALUES.indexOf(String(debugValue)) < 0) return
_mpeinitDone = true
const urlVars = fetchRaw('browser_url_vars')
if (urlVars === null) {
console.log('NOTE: mpeinit: no work.browser_url_vars on this request')
} else {
console.log('NOTE: mpeinit: work.browser_url_vars:')
parseCsv(urlVars).forEach(row => {
console.log(
'name=' + colVal(row, 'name') + ' value=' + colVal(row, 'value')
)
})
}
const browserInfo = fetchRaw('browser_info')
if (browserInfo === null) {
console.log('NOTE: mpeinit: no work.browser_info on this request')
} else {
console.log('NOTE: mpeinit: work.browser_info:')
parseCsv(browserInfo).forEach(row => {
console.log(Object.keys(row).map(col => col + '=' + row[col]).join(' '))
})
}
}
/* Every service that eval()s this file runs %mpeinit() at startup, the same
place the SAS services call it. */
mpeinit()