Files
dc/sas/mocks/sasjs/services/public/viewdata.js
T
dc 55fd16d53c
Build / Build-and-ng-test (pull_request) Successful in 5m27s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m24s
Build / Build-and-test-development (pull_request) Successful in 32m19s
refactor(mocks): a JS mpGetcols mirroring %mp_getcols.sas
The mock typed columns from its own format list while the services typed them
from the macro's, so the two could disagree - the review of the getcols.sas
change found exactly that, with the mock covering YYMMDD/MMDDYY/DDMMYY/E8601DT
and the service only the DATETIME/DATE/TIME prefixes.

mpGetcols() now mirrors the macro: NAME (upcased), TYPE (C/N), LENGTH, LABEL,
VARNUM, FORMAT, FMTNAME and DDTYPE (CHARACTER/NUMERIC/DATETIME/DATE/TIME), with
the macro's date list.  editors/getdata.js, public/viewdata.js and
public/getcols.js use it, the last mapping CHARACTER/NUMERIC to C/N the way the
service does.  getDdType() delegates to it, so there is one inference, not two.

Verified by diffing the column list every fixture table returns from all three
services before and after: 49 tables x 3 services, 9 differences, all of them
FMTNAME on E8601DT columns - the old code rewrote it to DATETIME, the new code
leaves E8601DT, which is what %mp_getcols puts in fmtname.  FMTNAME is not read
at runtime.  Every DDTYPE, TYPE, LENGTH, FORMAT and LABEL is identical.
2026-10-04 12:46:53 +00:00

204 lines
9.2 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
}
// ─── 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(tableData.columns).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) {
const matches = filteredRows.filter(row => rowMatchesSearch(row, tableData.columns, 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 tableData.columns) {
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 && tableData.columns.length > 0) {
const emptyRow = {}
for (const col of tableData.columns) 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 tableData.columns) {
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()