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.
204 lines
9.2 KiB
JavaScript
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()
|