Files
dc/sas/mocks/sasjs/services/public/viewdata.js
T
dc f411b7d269 fix(mocks): copy the physical column list before deriving
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.
2026-10-25 20:30:09 +00:00

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()