`flags` was scoped to the entry loop and read by the central-directory loop, so mpZipPackage threw a ReferenceError on every call - the audit download was broken outright. The value now travels with each central entry.
1908 lines
75 KiB
JavaScript
1908 lines
75 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)
|
|
* - webOutOpen / webOutObj / webOutClose : emulate SAS %webout(OPEN/OBJ/CLOSE)
|
|
* - 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(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 : []
|
|
}
|
|
|
|
/**
|
|
* 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_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) {
|
|
const result = {
|
|
dcLibref: 'DC_JSLIB',
|
|
dataDir: nodePath.resolve(driveRoot, 'files', appLoc, 'data', 'DC_JSLIB'),
|
|
dcAdminGroup: null,
|
|
dcpath: null
|
|
}
|
|
try {
|
|
const settingsFile = nodePath.resolve(driveRoot, 'files', appLoc, 'services', 'settings.js')
|
|
const settingsRaw = fs.readFileSync(settingsFile, {encoding:'utf8'}).toString()
|
|
eval(settingsRaw)
|
|
if (typeof dcSettings !== 'undefined') {
|
|
if (dcSettings.dc_libref) result.dcLibref = dcSettings.dc_libref
|
|
if (dcSettings.dc_admin_group) result.dcAdminGroup = dcSettings.dc_admin_group
|
|
if (dcSettings.dcpath) result.dcpath = dcSettings.dcpath
|
|
result.dataDir = nodePath.resolve(driveRoot, 'files', appLoc, 'data', result.dcLibref)
|
|
}
|
|
} catch(err) { /* makedata hasn't run - keep defaults */ }
|
|
return result
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
}
|
|
|
|
// ─── %webout(OPEN/OBJ/CLOSE) emulation ───────────────────────────────────────
|
|
|
|
/**
|
|
* Internal buffer for the webOut stream.
|
|
* webOutOpen() starts it, webOutObj() appends tables, webOutClose()
|
|
* finalises and assigns to _webout.
|
|
*/
|
|
let _webOutBuf = ''
|
|
let _webOutFirst = true
|
|
|
|
/**
|
|
* Opens the JSON stream, writing the opening brace and SYSDATE/SYSTIME.
|
|
* Mirrors %webout(OPEN) / mv_webout(OPEN).
|
|
*
|
|
* Usage:
|
|
* webOutOpen()
|
|
* webOutObj(rows, 'sasdata', { vars })
|
|
* webOutObj(params, 'sasparams')
|
|
* webOutClose()
|
|
*/
|
|
function webOutOpen() {
|
|
const now = new Date()
|
|
const pad = n => String(n).padStart(2, '0')
|
|
const dateStr = now.getDate() + '' +
|
|
['JAN','FEB','MAR','APR','MAY','JUN','JUL','AUG','SEP','OCT','NOV','DEC'][now.getMonth()] +
|
|
String(now.getFullYear()).slice(2)
|
|
const timeStr = pad(now.getHours()) + ':' + pad(now.getMinutes())
|
|
_webOutBuf = '{"SYSDATE" : "' + dateStr + '"'
|
|
_webOutBuf += ',"SYSTIME" : "' + timeStr + '"'
|
|
_webOutFirst = false
|
|
}
|
|
|
|
/**
|
|
* Appends a table to the JSON stream.
|
|
* Mirrors %webout(OBJ,ds,dslabel=,showmeta=).
|
|
*
|
|
* @param {Array<Object>} rows - Array of row objects to send as a JSON array.
|
|
* @param {string} label - The key name in the JSON (e.g. 'sasdata', 'sasparams').
|
|
* @param {Object} [meta] - Optional metadata object. If provided, it is sent
|
|
* as `$label` (e.g. `$sasdata`), matching the showmeta=YES output from SAS.
|
|
* Typically { vars: { COL1: {format,label,length,type}, ... } }.
|
|
*/
|
|
function webOutObj(rows, label, meta) {
|
|
_webOutBuf += ',"' + label + '":' + JSON.stringify(rows)
|
|
if (meta) {
|
|
_webOutBuf += ',"$' + label + '":' + JSON.stringify(meta)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Closes the JSON stream, appends the standard SAS runtime variables
|
|
* (SYSDATE, SYSCC, _PROGRAM, etc.), and assigns the final JSON string to
|
|
* the `_webout` variable that the SASjs Server reads.
|
|
* Mirrors mv_webout(CLOSE).
|
|
*/
|
|
// 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'
|
|
}
|
|
|
|
function webOutClose() {
|
|
const now = new Date()
|
|
const pad = n => String(n).padStart(2, '0')
|
|
const endDttm = now.getFullYear() + '-' + pad(now.getMonth()+1) + '-' + pad(now.getDate()) +
|
|
'T' + pad(now.getHours()) + ':' + pad(now.getMinutes()) + ':' + pad(now.getSeconds()) +
|
|
'.' + String(now.getMilliseconds()).padStart(3,'0')
|
|
|
|
// Derive system vars from SASjs Server runtime variables where possible
|
|
const mfGetuser = getDcUser()
|
|
const metaperson = (typeof _METAPERSON !== 'undefined') ? _METAPERSON : 'sasdemo'
|
|
const sysprocmode = (typeof SASJSPROCESSMODE !== 'undefined') ? SASJSPROCESSMODE : 'Stored Program'
|
|
|
|
_webOutBuf += ',"_DEBUG" : ""'
|
|
_webOutBuf += ',"_METAUSER": "' + mfGetuser + '@SAS"'
|
|
_webOutBuf += ',"_METAPERSON": "' + metaperson + '"'
|
|
_webOutBuf += ',"_PROGRAM" : "' + (typeof _program !== 'undefined' ? _program : '') + '"'
|
|
_webOutBuf += ',"AUTOEXEC" : ""'
|
|
_webOutBuf += ',"MF_GETUSER" : "' + mfGetuser + '"'
|
|
_webOutBuf += ',"SYS_JES_JOB_URI" : " "'
|
|
_webOutBuf += ',"SYSCC" : "0"'
|
|
_webOutBuf += ',"SYSENCODING" : "utf-8"'
|
|
_webOutBuf += ',"SYSERRORTEXT" : ""'
|
|
_webOutBuf += ',"SYSHOSTNAME" : "sasjs-server"'
|
|
_webOutBuf += ',"SYSPROCESSID" : "0"'
|
|
_webOutBuf += ',"SYSPROCESSMODE" : "' + sysprocmode + '"'
|
|
_webOutBuf += ',"SYSPROCESSNAME" : ""'
|
|
_webOutBuf += ',"SYSJOBID" : "0"'
|
|
_webOutBuf += ',"SYSSCPL" : "LINUX"'
|
|
_webOutBuf += ',"SYSSITE" : "123"'
|
|
_webOutBuf += ',"SYSUSERID" : "sasdemo"'
|
|
_webOutBuf += ',"SYSVLONG" : "9.04.01M7P080520"'
|
|
_webOutBuf += ',"SYSWARNINGTEXT" : ""'
|
|
_webOutBuf += ',"END_DTTM" : "' + endDttm + '"'
|
|
_webOutBuf += ',"MEMSIZE" : "0KB"'
|
|
_webOutBuf += '}'
|
|
_webout = _webOutBuf
|
|
}
|
|
|
|
/**
|
|
* Convenience: builds a complete response in one call.
|
|
* Opens, adds each table, closes.
|
|
*
|
|
* @param {Object} tables - { label1: { rows: [...], meta: {...} }, label2: { rows: [...] } }
|
|
*/
|
|
function webOutSend(tables) {
|
|
webOutOpen()
|
|
for (const [label, entry] of Object.entries(tables)) {
|
|
webOutObj(entry.rows, label, entry.meta)
|
|
}
|
|
webOutClose()
|
|
}
|
|
|
|
// ─── %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()
|