Merge pull request 'fix(mocks): getrawdata reads the library it was asked for, and aborts when it cannot' (#518) from fix/mock-getrawdata-resolution into release/7.23
Sites / deploy (push) Successful in 3m4s
Build / Build-and-ng-test (pull_request) Successful in 6m13s
Build / lighthouse (pull_request) Successful in 6m43s
Build / Build-and-test-development (pull_request) Successful in 23m34s

Reviewed-on: #518
This commit was merged in pull request #518.
This commit is contained in:
allan committed 2026-11-08 00:44:55 +00:00
commit c407f85000
4 files changed
+247 -90

No files matched your search

+123 -80
View File
@@ -27,6 +27,17 @@ function loadTableData(tableName) {
} catch(err) { return null }
}
/* The same, from the folder of any library on the Drive: makedata and
createdemodata write each library's tables under data/<libref>, and this
service downloads from the library it was asked for rather than from the
control one. */
function loadTableFrom(libref, tableName) {
try {
const file = nodePath.resolve(dataDir, '..', libref, tableName.toLowerCase() + '.json')
return JSON.parse(fs.readFileSync(file, {encoding:'utf8'}).toString())
} catch(err) { return null }
}
// SAS epoch for date/datetime/time formatting
const SAS_EPOCH_MS = Date.UTC(1960, 0, 1)
const MONTHS = ['JAN','FEB','MAR','APR','MAY','JUN','JUL','AUG','SEP','OCT','NOV','DEC']
@@ -100,27 +111,45 @@ const DOWNLOAD_TYPES = {
helpers collide with it, so the envelope is written here the way mpAbort
writes it: the abort member, no CLOSE.
*/
function abortInput(message) {
function abortInput(message, mac) {
webout('OPEN')
webout('OBJ', 'sasjsAbort', [{ MSG: message, MAC: _program + '.sas' }])
webout('OBJ', 'sasjsAbort', [{ MSG: message, MAC: mac || _program + '.sas' }])
const stop = new Error(message)
stop.mockAbort = true
throw stop
}
const requestTable = String(typeof table === 'undefined' || table === null ? '' : table).trim()
const rawTable = String(typeof table === 'undefined' || table === null ? '' : table).trim()
const TABLE_SHAPE = /^[_a-z]\w{0,7}\.[_a-z]\w{0,31}$/i
/* %mpe_validatecol(...,LIBDS,...) strips a trailing -FC before validating, and
getrawdata.sas reads LIBREF.CAT-FC as a format catalog and then works with
the stripped pair (`table=cats("&lib..",ds)`), so the mock keeps ONE
canonical name from here on: the guard, the filename, the library it reads
and the name in an abort all use the stripped form rather than validating
one name and using another. */
const withoutCatalog =
requestTable.length > 3 && requestTable.slice(-3).toUpperCase() === '-FC'
? requestTable.slice(0, -3)
: requestTable
if (!requestTable || !TABLE_SHAPE.test(withoutCatalog)) abortInput('Invalid table')
rawTable.length > 3 && rawTable.slice(-3).toUpperCase() === '-FC'
? rawTable.slice(0, -3)
: rawTable
if (!rawTable || !TABLE_SHAPE.test(withoutCatalog)) abortInput('Invalid table')
const requestTable = withoutCatalog.toUpperCase()
const requestFilter = String(
typeof filter === 'undefined' || filter === null ? '' : filter
).trim() || '0'
/* ISINT is input(&filter,??best32.) with mod 1 = 0, and the estate's recorded
answers show the whole value has to read as a number: it rejects 1,000,
1abc and 1.5x, which a leading-numeric-prefix read would take, and accepts
7.0, 1e3, +5, 007 and -3. Number() alone is the wrong shape test - it reads
0x10 as 16, which the informat does not - so the number is matched first and
then asked whether it is whole. */
const FILTER_NUMBER = /^[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?$/
const filterValue = Number(requestFilter)
if (!isFinite(filterValue) || Math.floor(filterValue) !== filterValue) {
if (
!FILTER_NUMBER.test(requestFilter) ||
!isFinite(filterValue) ||
Math.floor(filterValue) !== filterValue
) {
abortInput('Invalid filter')
}
@@ -130,90 +159,104 @@ const writesWeboutDirectly = requestedType === 'WEBCSV' || requestedType === 'WE
if (!download && !writesWeboutDirectly) abortInput('Invalid type')
const filename = download ? requestTable.toUpperCase() + '.' + download.ext : ''
const filename = download ? requestTable + '.' + download.ext : ''
// Generate CSV from the table data (data-driven, not hardcoded)
let csvLines = []
// Determine the table name from the `table` param (format: LIBREF.TABLE)
const tableName = requestTable.split('.')[1]
const exportLibref = requestTable.split('.')[0]
const tableData = loadTableData(tableName)
if (tableData && tableData.columns && tableData.rows) {
/*
getrawdata.sas drops the registration's validity columns before it writes
the download (`data staged(drop=&txfrom &txto)`), so a control table never
carries its history columns into a user's file.
*/
const exportLibref = requestTable.split('.')[0]
const registry = loadTableData('MPE_TABLES')
const registration =
registry && registry.rows
? registry.rows.find(
(row) => String(row.libref).toUpperCase() === exportLibref.toUpperCase() &&
String(row.dsn).toUpperCase() === tableName.toUpperCase()
)
: null
const dropped = ['var_txfrom', 'var_txto']
.map((key) => (registration ? registration[key] : ''))
.filter((name) => name && String(name).trim() !== '')
.map((name) => String(name).trim().toUpperCase())
const exportColumns = tableData.columns.filter(
(col) => dropped.indexOf(col.name.toUpperCase()) < 0
/*
getrawdata.sas assigns the library it was asked for - %dc_assignlib(READ,&lib)
- and reads the data from there; the control library supplies only the
registration row. A table that cannot be resolved aborts before anything is
written, and the estate gives one message for both cases, a library it does
not have and a member missing from one it does:
NOLIB.MPE_TABLES -> Target table not found: NOLIB.MPE_TABLES
DC634301.NO_SUCH_TABLE -> Target table not found: DC634301.NO_SUCH_TABLE
The abort is mp_derivedvars', so the MAC is that macro's own default and not
the service, and the message carries the upcased LIBDS getrawdata.sas builds.
*/
const tableData = loadTableFrom(exportLibref, tableName)
if (!tableData || !tableData.columns || !tableData.rows) {
abortInput('Target table not found: ' + requestTable, 'MP_ABORT')
}
/*
getrawdata.sas drops the registration's validity columns before it writes
the download (`data staged(drop=&txfrom &txto)`), so a control table never
carries its history columns into a user's file.
*/
const registry = loadTableData('MPE_TABLES')
const registration =
registry && registry.rows
? registry.rows.find(
(row) => String(row.libref).toUpperCase() === exportLibref.toUpperCase() &&
String(row.dsn).toUpperCase() === tableName.toUpperCase()
)
: null
const dropped = ['var_txfrom', 'var_txto']
.map((key) => (registration ? registration[key] : ''))
.filter((name) => name && String(name).trim() !== '')
.map((name) => String(name).trim().toUpperCase())
const exportColumns = tableData.columns.filter(
(col) => dropped.indexOf(col.name.toUpperCase()) < 0
)
// a CSV download opens with a header row, as the SAS export writes one. The
// other types stream the same lines under their own content type, and none
// of them is a CSV, so they keep starting at the first row
if (requestedType === 'CSV') {
/* the header the SAS export writes is upper case, whatever case the
table was created with. This service carries its own helpers rather
than eval'ing dcMockUtils, so it upcases here rather than calling
payloadKey. */
csvLines.push(
exportColumns.map((col) => String(col.name).toUpperCase()).join(',')
)
}
// a CSV download opens with a header row, as the SAS export writes one. The
// other types stream the same lines under their own content type, and none
// of them is a CSV, so they keep starting at the first row
if (requestedType === 'CSV') {
/* the header the SAS export writes is upper case, whatever case the
table was created with. This service carries its own helpers rather
than eval'ing dcMockUtils, so it upcases here rather than calling
payloadKey. */
csvLines.push(
exportColumns.map((col) => String(col.name).toUpperCase()).join(',')
)
}
// Build column ddtype lookup
const colDdtypes = {}
for (const col of exportColumns) {
const fmt = (col.format || '').toLowerCase()
let ddtype = col.type === 'N' ? 'N' : 'C'
if (fmt.startsWith('datetime') || fmt.startsWith('e8601dt')) ddtype = 'DATETIME'
else if (fmt.startsWith('date')) ddtype = 'DATE'
else if (fmt.startsWith('time')) ddtype = 'TIME'
colDdtypes[col.name.toUpperCase()] = ddtype
}
// Build column ddtype lookup
const colDdtypes = {}
for (const row of tableData.rows) {
const values = []
for (const col of exportColumns) {
const fmt = (col.format || '').toLowerCase()
let ddtype = col.type === 'N' ? 'N' : 'C'
if (fmt.startsWith('datetime') || fmt.startsWith('e8601dt')) ddtype = 'DATETIME'
else if (fmt.startsWith('date')) ddtype = 'DATE'
else if (fmt.startsWith('time')) ddtype = 'TIME'
colDdtypes[col.name.toUpperCase()] = ddtype
}
for (const row of tableData.rows) {
const values = []
for (const col of exportColumns) {
let val = row[col.name]
if (val === undefined) val = row[col.name.toLowerCase()]
if (val === undefined) {
const key = Object.keys(row).find(k => k.toLowerCase() === col.name.toLowerCase())
if (key) val = row[key]
}
if (val === null || val === undefined) {
val = ''
} else {
const ddtype = colDdtypes[col.name.toUpperCase()]
if (ddtype === 'DATE' || ddtype === 'DATETIME' || ddtype === 'TIME') {
val = formatSasValue(val, ddtype)
} else {
val = String(val)
}
// Quote if it contains a delimiter, a quote or a line break -
// a description with a newline is still one CSV record
if (val.includes(',') || val.includes('"') || /[\r\n]/.test(val)) {
val = '"' + val.replace(/"/g, '""') + '"'
}
}
values.push(val)
let val = row[col.name]
if (val === undefined) val = row[col.name.toLowerCase()]
if (val === undefined) {
const key = Object.keys(row).find(k => k.toLowerCase() === col.name.toLowerCase())
if (key) val = row[key]
}
csvLines.push(values.join(','))
if (val === null || val === undefined) {
val = ''
} else {
const ddtype = colDdtypes[col.name.toUpperCase()]
if (ddtype === 'DATE' || ddtype === 'DATETIME' || ddtype === 'TIME') {
val = formatSasValue(val, ddtype)
} else {
val = String(val)
}
// Quote if it contains a delimiter, a quote or a line break -
// a description with a newline is still one CSV record
if (val.includes(',') || val.includes('"') || /[\r\n]/.test(val)) {
val = '"' + val.replace(/"/g, '""') + '"'
}
}
values.push(val)
}
csvLines.push(values.join(','))
}
_webout = csvLines.join('\n')
+76 -10
View File
@@ -209,11 +209,15 @@ test('getrawdata validates the table before it streams, as the estate does', ()
}
})
test('getrawdata validates the filter as an integer, as ISINT reads it', () => {
test('getrawdata reads the filter the way the estate does', () => {
const estate = seededEstate({ demo: false })
const rejected = ['abc', '1.5', '1,000']
for (const filter of rejected) {
/* Recorded from the synthetic estate: each of these is answered "Invalid
filter" and nothing is written. 1,000 and 1abc are the interesting ones -
ISINT's input(&filter,??best32.) reading would take a leading numeric
prefix, but the estate rejects them, so the whole value has to read as a
number - and 0x10 is 16 to Number() and invalid there. */
for (const filter of ['abc', '1.5', '1,000', '1abc', '1.5x', '0x10']) {
const body = runService(estate, 'public/getrawdata', {
params: { type: 'CSV', table: 'DC_JSLIB.MPE_SELECTBOX', filter }
})
@@ -225,20 +229,82 @@ test('getrawdata validates the filter as an integer, as ISINT reads it', () => {
assert.equal(headersOf(estate), '', filter)
}
/* %mp_validatecol(...,ISINT,...) is input(&filter,??best32.) with mod 1 = 0,
so a decimal written as one and an exponent are both integers to it. The
estate's own answer for these three is a downstream abort (the filter RK
does not exist), not "Invalid filter", which is what shows validation
passed; the mock does not resolve filter RKs, so it streams. */
for (const filter of ['7.0', '1e3', ' 0 ']) {
/* And these pass validation. The estate's own answer for them is a downstream
abort (the filter RK does not exist), not "Invalid filter"; the mock does
not resolve filter RKs, so it streams. */
for (const filter of ['7.0', '1e3', '+5', '007', ' 7 ', '-3']) {
const body = runService(estate, 'public/getrawdata', {
params: { type: 'CSV', table: 'DC_JSLIB.MPE_SELECTBOX', filter }
})
assert.doesNotMatch(body, /sasjsAbort/, filter)
assert.doesNotMatch(body, /Invalid filter/, filter)
assert.equal(headersOf(estate).split('\n')[0], 'Content-Type: application/csv', filter)
}
})
test('getrawdata resolves the catalog form to one canonical name', () => {
const estate = seededEstate({ demo: false })
/*
%mpe_validatecol(...,LIBDS,...) strips the -FC suffix before validating, and
getrawdata.sas then works with the stripped pair - so a request must not
validate one name and name the file after another. The estate answers a real
LIBREF.CAT-FC by running %mp_cntlout on the catalog; the mock has no catalog
support, so the body is the member's rows and its filename is the stripped
LIBREF.DS.
*/
const body = runService(estate, 'public/getrawdata', {
params: { type: 'CSV', table: 'DC_JSLIB.MPE_SELECTBOX-FC', filter: '0' }
})
assert.doesNotMatch(body, /sasjsAbort/)
assert.deepEqual(headersOf(estate).split('\n'), [
'Content-Type: application/csv',
'Content-disposition: attachment; filename=DC_JSLIB.MPE_SELECTBOX.csv'
])
})
test('getrawdata downloads from the library it was asked for', () => {
/* DC_JSLIB is the control library; the data comes from the library named in
the LIBDS, which is what %dc_assignlib(READ,&lib) in getrawdata.sas does
and what createdemodata writes TESTDATA for. */
const estate = seededEstate()
const body = runService(estate, 'public/getrawdata', {
params: { type: 'CSV', table: 'TESTDATA.DEMO_ORDERS', filter: '0' }
})
assert.doesNotMatch(body, /sasjsAbort/)
assert.ok(body.split('\n').length > 2, 'the export holds rows of the requested library')
assert.deepEqual(headersOf(estate).split('\n'), [
'Content-Type: application/csv',
'Content-disposition: attachment; filename=TESTDATA.DEMO_ORDERS.csv'
])
})
test('getrawdata aborts a table it cannot resolve, as the estate does', () => {
const estate = seededEstate({ demo: false })
const cases = [
// a library the estate does not have: recorded as Target table not found
['NOLIB.MPE_TABLES', 'Target table not found: NOLIB.MPE_TABLES'],
// a member missing from a library it does have: the same message
['DC_JSLIB.NO_SUCH_TABLE', 'Target table not found: DC_JSLIB.NO_SUCH_TABLE'],
// the message carries the upcased LIBDS getrawdata.sas builds
['nolib.mpe_tables', 'Target table not found: NOLIB.MPE_TABLES']
]
for (const [table, message] of cases) {
const body = runService(estate, 'public/getrawdata', {
params: { type: 'CSV', table, filter: '0' }
})
// the abort is mp_derivedvars', so the MAC is that macro's default
assert.deepEqual(
JSON.parse(body).sasjsAbort,
[{ MSG: message, MAC: 'MP_ABORT' }],
table
)
assert.equal(headersOf(estate), '', table)
}
})
// ─── admin/exportdb and the shared mpStreamfile ──────────────────────────────
test('exportdb streams the DDL as application/text, named <libref>.ddl', async () => {
@@ -0,0 +1,24 @@
{
"_meta": {
"scenario": "missing_table",
"service": "public/getrawdata",
"estate": "synthetic",
"targetType": "SASJS",
"sas": "9.4M9",
"dc": "7.22.0",
"captured": "2026-11-07",
"tls": "verified",
"members": [
"sasjsAbort"
]
},
"body": {
"sasjsAbort": [
{
"MSG": "Target table not found: <LIBREF>.NO_SUCH_TABLE",
"MAC": "MP_ABORT"
}
],
"SYSCC": "0"
}
}
+24
View File
@@ -272,6 +272,30 @@
"note": "the download path: a table as the client saves it, CSV with a header row and the registration's validity columns dropped. Parsed to rows on both sides, because SAS quotes and pads each cell to the variable's width",
"accepted": []
},
{
"id": "missing_table",
"service": "public/getrawdata",
"params": {
"type": "CSV",
"table": "{{LIBREF}}.NO_SUCH_TABLE",
"filter": "0"
},
"mockParams": {
"type": "CSV",
"table": "DC_JSLIB.NO_SUCH_TABLE",
"filter": "0"
},
"members": [
"sasjsAbort"
],
"note": "a download for a table that cannot be resolved: the abort names the full LIBDS and comes from mp_derivedvars, so the MAC is the macro's default rather than the service. The same answer covers a library the estate does not have",
"accepted": [
{
"path": ".SYSCC",
"reason": "the SAS estate's wrapper adds SYSCC to an aborted response; a JS program's envelope is finalised at CLOSE, and the abort stops before it, so the mock's abort carries none. The message and the macro name - what the client renders - do match"
}
]
},
{
"id": "control_library",
"service": "public/viewtables",