1179 lines
47 KiB
JavaScript
1179 lines
47 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)
|
|
* - 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 ──────────────────────────────────────────
|
|
|
|
/**
|
|
* Derives a DDTYPE (DATE, DATETIME, TIME, N, C) from a SAS column format string.
|
|
*/
|
|
function getDdType(col) {
|
|
const fmt = (col.format || '').toLowerCase()
|
|
if (fmt.startsWith('datetime') || fmt.startsWith('e8601dt')) return 'DATETIME'
|
|
if (fmt.startsWith('date') || fmt.startsWith('yymmdd') || fmt.startsWith('ddmmyy') || fmt.startsWith('mmddyy')) return 'DATE'
|
|
if (fmt.startsWith('time')) return 'TIME'
|
|
return col.type === 'N' ? 'N' : 'C'
|
|
}
|
|
|
|
/**
|
|
* 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 : []
|
|
}
|
|
|
|
/**
|
|
* 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' }
|
|
}
|
|
|
|
/**
|
|
* 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')
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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)
|
|
*/
|
|
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)
|
|
}
|
|
|
|
function parseFilterScalar(s) {
|
|
s = s.trim()
|
|
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')
|
|
}
|
|
}
|
|
|
|
// ─── 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
|
|
}
|
|
|
|
// ─── %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()
|
|
}
|