physicalColumns held the same array the derive helper reads, so every later use in the file - cols, the search, the row loop, the empty-row fallback and vars - depended on that helper returning a new list rather than extending the one it was given. Taking a copy makes the invariant local to this file.
224 lines
10 KiB
JavaScript
224 lines
10 KiB
JavaScript
const nodePath = require('path')
|
|
|
|
let appLoc = nodePath.join(..._program.split('services')[0].split('/'))
|
|
const sasjsRoot = nodePath.resolve(weboutPath, '..', '..', '..')
|
|
const driveRoot = nodePath.resolve(sasjsRoot, 'drive')
|
|
const dcLibref = 'DC_JSLIB'
|
|
const mpeDataDir = nodePath.resolve(driveRoot, 'files', appLoc, 'data', dcLibref)
|
|
|
|
eval(fs.readFileSync(nodePath.resolve(driveRoot, 'files', appLoc, 'services', 'dcMockUtils.js'), 'utf8'))
|
|
|
|
// Row cap for the viewer - mirrors DC_MAXOBS_WEBVIEW in viewdata.sas, read from
|
|
// MPE_CONFIG the way the real service reads it from the settings program (an
|
|
// absent or switched-off option falls back to the shipped default). Also the
|
|
// MAXROWS value reported in sasparams.
|
|
const maxObsWebView = dcConfigNumber(
|
|
loadDcConfig(driveRoot, appLoc, mpeDataDir),
|
|
'DC_MAXOBS_WEBVIEW',
|
|
2000
|
|
)
|
|
|
|
// ─── Parse input ──────────────────────────────────────────────────────────────
|
|
|
|
const _sctRow = fetchTable('SASControlTable')[0] || {}
|
|
let libds = _sctRow.LIBDS || ''
|
|
const libref = libds.split('.')[0] || dcLibref
|
|
const dsn = libds.split('.')[1] || ''
|
|
|
|
// FILTER_RK (matching viewdata.sas: if filter_rk le 0 then filter_rk=-1)
|
|
const filterRk = Number(colVal(_sctRow, 'FILTER_RK')) > 0 ? Number(colVal(_sctRow, 'FILTER_RK')) : -1
|
|
|
|
// SEARCHTYPE / SEARCHVAL - only CHAR or NUM triggers a search, anything else
|
|
// (blank, NONE) is a normal view. Search values are stripped of the same
|
|
// characters viewdata.sas removes before handing them to mp_searchdata.
|
|
const rawSearchType = String(colVal(_sctRow, 'SEARCHTYPE') || '').toUpperCase()
|
|
const searchType = (rawSearchType === 'CHAR' || rawSearchType === 'NUM') ? rawSearchType : ''
|
|
const rawSearchVal = colVal(_sctRow, 'SEARCHVAL')
|
|
let searchVal = (rawSearchVal === undefined || rawSearchVal === null) ? '' : String(rawSearchVal)
|
|
if (searchType) searchVal = searchVal.replace(/[%&;"]/g, '')
|
|
|
|
// ─── Load the target table ───────────────────────────────────────────────────
|
|
|
|
const dataDir = libDataDir(libref)
|
|
const loadTableData = makeTableLoader(dataDir)
|
|
const loadMpeTable = makeTableLoader(mpeDataDir)
|
|
|
|
let tableData = loadTableData(dsn)
|
|
if (!tableData && dsn) {
|
|
const found = loadTableAnyLib(dsn)
|
|
if (found) tableData = found.data
|
|
}
|
|
|
|
// ─── Derived variables (mpe_derived.sas) ─────────────────────────────────────
|
|
// A stored filter can reference a derived column, so the derivation has to run
|
|
// before the filter. The columns themselves are dropped from the output - they
|
|
// belong to the EDIT screen, as viewdata.sas drops them.
|
|
// a copy: the derivation below returns a new column list, and every use here
|
|
// (cols, search, rows, vars) must keep looking at the physical columns only
|
|
const physicalColumns = [...(tableData && tableData.columns ? tableData.columns : [])]
|
|
const derived = mpeDerived({
|
|
libref: libref,
|
|
table: dsn,
|
|
dataDir: mpeDataDir,
|
|
columns: physicalColumns
|
|
})
|
|
if (tableData && derived.length > 0) {
|
|
tableData = mpeDeriveRows(tableData, derived)
|
|
}
|
|
|
|
// ─── Apply the stored filter (mpe_filtermaster, VIEW mode) ───────────────────
|
|
// Mirrors viewdata.sas: %mpe_filtermaster(VIEW,&libds,filter_rk=&filter_rk)
|
|
// Row Level Security and any saved filter are applied here, before the search.
|
|
|
|
const { predicate: filterPredicate, filterText } = mpeFilterMaster({
|
|
mode: 'VIEW',
|
|
libds: (libref + '.' + dsn).toUpperCase(),
|
|
filterRk: filterRk,
|
|
dataDir: mpeDataDir,
|
|
columns: tableData && tableData.columns ? tableData.columns : []
|
|
})
|
|
|
|
// ─── Search predicate (mp_searchdata.sas) ────────────────────────────────────
|
|
// mp_searchdata builds one WHERE clause covering every column of the table:
|
|
// or ("COL1"n ? "the string") /* character columns: case sensitive CONTAINS */
|
|
// or ("COL2"n = the number) /* numeric columns: exact equality */
|
|
|
|
function rowMatchesSearch(row, columns, type, value) {
|
|
for (const col of columns) {
|
|
const val = colVal(row, col.name)
|
|
if (val === undefined || val === null) continue
|
|
if (type === 'CHAR') {
|
|
if (col.type !== 'N' && String(val).includes(value)) return true
|
|
} else {
|
|
if (col.type === 'N' && Number(val) === Number(value)) return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ─── Build response ──────────────────────────────────────────────────────────
|
|
|
|
// Look up PK fields from MPE_TABLES (mirrors mp_getpk).
|
|
// MPE_AUDIT is a standard DC audit table whose PK is always the first 5 columns.
|
|
const mpeTables = loadMpeTable('MPE_TABLES')
|
|
let pkFields = ' '
|
|
if (mpeTables && mpeTables.rows) {
|
|
const reg = mpeTables.rows.find(r => r.libref === libref && r.dsn === dsn)
|
|
if (reg && reg.buskey) {
|
|
pkFields = reg.buskey
|
|
}
|
|
}
|
|
if (dsn.toUpperCase() === 'MPE_AUDIT' && pkFields.trim() === '') {
|
|
pkFields = 'LOAD_REF LIBREF DSN KEY_HASH TGTVAR_NM'
|
|
}
|
|
|
|
let cols = []
|
|
let viewdata = []
|
|
let vars = {}
|
|
let nobs = 0
|
|
|
|
if (tableData && tableData.columns) {
|
|
// mpGetcols, the JS counterpart of %mp_getcols.sas - the macro viewdata.sas
|
|
// calls for its own column list.
|
|
cols = mpGetcols(physicalColumns).map(c => {
|
|
const ddtype = c.DDTYPE === 'CHARACTER' ? 'C' : c.DDTYPE === 'NUMERIC' ? 'N' : c.DDTYPE
|
|
const formatStr = c.TYPE === 'N' ? (c.FORMAT || '8.') : ('$' + c.LENGTH + '.')
|
|
return {
|
|
NAME: c.NAME, TYPE: c.TYPE, LENGTH: c.LENGTH,
|
|
FORMAT: formatStr, FMTNAME: c.FMTNAME || ' ', DDTYPE: ddtype,
|
|
LABEL: c.LABEL
|
|
}
|
|
})
|
|
|
|
// Rows in scope: the stored filter is applied first, then the search
|
|
// (matching viewdata.sas, which searches a filtered view of the table).
|
|
const filteredRows = (tableData.rows || []).filter(r => filterPredicate(r))
|
|
|
|
let rows
|
|
if (searchType) {
|
|
// the derived columns are dropped before the search, so only the
|
|
// physical columns are searched (viewdata.sas drops them in the
|
|
// vwsearch data step mp_searchdata then reads)
|
|
const matches = filteredRows.filter(row => rowMatchesSearch(row, physicalColumns, searchType, searchVal))
|
|
// mp_searchdata caps its output at outobs=&DC_MAXOBS_WEBVIEW, and
|
|
// viewdata.sas then keeps only `if _n_<&DC_MAXOBS_WEBVIEW` of them.
|
|
nobs = Math.min(matches.length, maxObsWebView)
|
|
rows = matches.slice(0, maxObsWebView - 1)
|
|
} else {
|
|
// proc sql select count(*) ... where <filter> (uncapped)
|
|
nobs = filteredRows.length
|
|
// data work.viewdata; set &libds; where <filter>; if _n_>&DC_MAXOBS_WEBVIEW then stop;
|
|
rows = filteredRows.slice(0, maxObsWebView)
|
|
}
|
|
|
|
// viewdata rows: all values as strings (formatted, SAS proc-print style)
|
|
viewdata = rows.map(r => {
|
|
const row = {}
|
|
for (const col of physicalColumns) {
|
|
const lcName = col.name.toLowerCase()
|
|
let val = r[col.name]
|
|
if (val === undefined) val = r[lcName]
|
|
if (val === undefined) {
|
|
const key = Object.keys(r).find(k => k.toLowerCase() === lcName)
|
|
if (key) val = r[key]
|
|
}
|
|
const ddtype = getDdType(col)
|
|
if (val !== undefined && val !== null && val !== '') {
|
|
if (typeof val === 'number') {
|
|
val = formatSasValue(val, ddtype)
|
|
} else {
|
|
// Try converting ISO/old-format strings to SAS display format
|
|
if (ddtype === 'DATETIME') {
|
|
const m = String(val).match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})/)
|
|
if (m) {
|
|
const mn = ['JAN','FEB','MAR','APR','MAY','JUN','JUL','AUG','SEP','OCT','NOV','DEC']
|
|
val = `${parseInt(m[3])}${mn[parseInt(m[2])-1]}${m[1].slice(2)}:${m[4]}:${m[5]}:${m[6]}`
|
|
}
|
|
} else if (ddtype === 'DATE') {
|
|
const m = String(val).match(/^(\d{4})-(\d{2})-(\d{2})/)
|
|
if (m) {
|
|
const mn = ['JAN','FEB','MAR','APR','MAY','JUN','JUL','AUG','SEP','OCT','NOV','DEC']
|
|
val = `${parseInt(m[3])}${mn[parseInt(m[2])-1]}${m[1]}`
|
|
}
|
|
}
|
|
}
|
|
}
|
|
row[col.name] = String(val)
|
|
}
|
|
return row
|
|
})
|
|
|
|
// SAS normally sends a single empty row when the table has no data, so
|
|
// the client can derive the column set even when empty (the UI iterates
|
|
// viewdata[0] to build the grid headers). Numeric columns get "."
|
|
// (SAS missing), character columns get "".
|
|
// A search that matches nothing sends no rows at all (the client shows
|
|
// its "no data found" panel instead of an empty grid).
|
|
if (viewdata.length === 0 && !searchType && physicalColumns.length > 0) {
|
|
const emptyRow = {}
|
|
for (const col of physicalColumns) emptyRow[col.name] = col.type === 'N' ? '.' : ''
|
|
viewdata = [emptyRow]
|
|
}
|
|
|
|
// $viewdata vars: keep original SAS types (num stays num, char stays char)
|
|
vars = {}
|
|
for (const col of physicalColumns) {
|
|
vars[col.name] = sasVarsEntry(col)
|
|
}
|
|
}
|
|
|
|
webOutOpen()
|
|
webOutObj([], 'cls_rules')
|
|
webOutObj(cols, 'cols')
|
|
webOutObj([{ ODS_TABLE: 'ATTRIBUTES', NAME: 'Data Set Name', VALUE: libref + '.' + dsn },
|
|
{ ODS_TABLE: 'ATTRIBUTES', NAME: 'Observations', VALUE: String(nobs) },
|
|
{ ODS_TABLE: 'ATTRIBUTES', NAME: 'Member Type', VALUE: 'DATA' },
|
|
{ ODS_TABLE: 'ATTRIBUTES', NAME: 'Variables', VALUE: String(cols.length) },
|
|
{ ODS_TABLE: 'ATTRIBUTES', NAME: 'Engine', VALUE: 'V9' }], 'dsmeta')
|
|
webOutObj([], 'query')
|
|
webOutObj([{ TABLEURI: ' ', TABLENAME: dsn, FILTER_TEXT: filterText === '' ? ' ' : filterText,
|
|
PK_FIELDS: pkFields, NOBS: nobs, VARS: cols.length, MAXROWS: maxObsWebView }], 'sasparams')
|
|
webOutObj([], 'versions')
|
|
webOutObj(viewdata, 'viewdata', { vars })
|
|
webOutClose()
|