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 } } 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 // SAS epoch for date/datetime/time formatting
const SAS_EPOCH_MS = Date.UTC(1960, 0, 1) const SAS_EPOCH_MS = Date.UTC(1960, 0, 1)
const MONTHS = ['JAN','FEB','MAR','APR','MAY','JUN','JUL','AUG','SEP','OCT','NOV','DEC'] 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 helpers collide with it, so the envelope is written here the way mpAbort
writes it: the abort member, no CLOSE. writes it: the abort member, no CLOSE.
*/ */
function abortInput(message) { function abortInput(message, mac) {
webout('OPEN') webout('OPEN')
webout('OBJ', 'sasjsAbort', [{ MSG: message, MAC: _program + '.sas' }]) webout('OBJ', 'sasjsAbort', [{ MSG: message, MAC: mac || _program + '.sas' }])
const stop = new Error(message) const stop = new Error(message)
stop.mockAbort = true stop.mockAbort = true
throw stop 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 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 = const withoutCatalog =
requestTable.length > 3 && requestTable.slice(-3).toUpperCase() === '-FC' rawTable.length > 3 && rawTable.slice(-3).toUpperCase() === '-FC'
? requestTable.slice(0, -3) ? rawTable.slice(0, -3)
: requestTable : rawTable
if (!requestTable || !TABLE_SHAPE.test(withoutCatalog)) abortInput('Invalid table') if (!rawTable || !TABLE_SHAPE.test(withoutCatalog)) abortInput('Invalid table')
const requestTable = withoutCatalog.toUpperCase()
const requestFilter = String( const requestFilter = String(
typeof filter === 'undefined' || filter === null ? '' : filter typeof filter === 'undefined' || filter === null ? '' : filter
).trim() || '0' ).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) 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') abortInput('Invalid filter')
} }
@@ -130,90 +159,104 @@ const writesWeboutDirectly = requestedType === 'WEBCSV' || requestedType === 'WE
if (!download && !writesWeboutDirectly) abortInput('Invalid type') 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) // Generate CSV from the table data (data-driven, not hardcoded)
let csvLines = [] let csvLines = []
// Determine the table name from the `table` param (format: LIBREF.TABLE)
const tableName = requestTable.split('.')[1] const tableName = requestTable.split('.')[1]
const exportLibref = requestTable.split('.')[0]
const tableData = loadTableData(tableName) /*
if (tableData && tableData.columns && tableData.rows) { 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
getrawdata.sas drops the registration's validity columns before it writes registration row. A table that cannot be resolved aborts before anything is
the download (`data staged(drop=&txfrom &txto)`), so a control table never written, and the estate gives one message for both cases, a library it does
carries its history columns into a user's file. not have and a member missing from one it does:
*/
const exportLibref = requestTable.split('.')[0] NOLIB.MPE_TABLES -> Target table not found: NOLIB.MPE_TABLES
const registry = loadTableData('MPE_TABLES') DC634301.NO_SUCH_TABLE -> Target table not found: DC634301.NO_SUCH_TABLE
const registration =
registry && registry.rows The abort is mp_derivedvars', so the MAC is that macro's own default and not
? registry.rows.find( the service, and the message carries the upcased LIBDS getrawdata.sas builds.
(row) => String(row.libref).toUpperCase() === exportLibref.toUpperCase() && */
String(row.dsn).toUpperCase() === tableName.toUpperCase() const tableData = loadTableFrom(exportLibref, tableName)
) if (!tableData || !tableData.columns || !tableData.rows) {
: null abortInput('Target table not found: ' + requestTable, 'MP_ABORT')
const dropped = ['var_txfrom', 'var_txto'] }
.map((key) => (registration ? registration[key] : ''))
.filter((name) => name && String(name).trim() !== '') /*
.map((name) => String(name).trim().toUpperCase()) getrawdata.sas drops the registration's validity columns before it writes
const exportColumns = tableData.columns.filter( the download (`data staged(drop=&txfrom &txto)`), so a control table never
(col) => dropped.indexOf(col.name.toUpperCase()) < 0 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 // Build column ddtype lookup
// other types stream the same lines under their own content type, and none const colDdtypes = {}
// of them is a CSV, so they keep starting at the first row for (const col of exportColumns) {
if (requestedType === 'CSV') { const fmt = (col.format || '').toLowerCase()
/* the header the SAS export writes is upper case, whatever case the let ddtype = col.type === 'N' ? 'N' : 'C'
table was created with. This service carries its own helpers rather if (fmt.startsWith('datetime') || fmt.startsWith('e8601dt')) ddtype = 'DATETIME'
than eval'ing dcMockUtils, so it upcases here rather than calling else if (fmt.startsWith('date')) ddtype = 'DATE'
payloadKey. */ else if (fmt.startsWith('time')) ddtype = 'TIME'
csvLines.push( colDdtypes[col.name.toUpperCase()] = ddtype
exportColumns.map((col) => String(col.name).toUpperCase()).join(',') }
)
}
// Build column ddtype lookup for (const row of tableData.rows) {
const colDdtypes = {} const values = []
for (const col of exportColumns) { for (const col of exportColumns) {
const fmt = (col.format || '').toLowerCase() let val = row[col.name]
let ddtype = col.type === 'N' ? 'N' : 'C' if (val === undefined) val = row[col.name.toLowerCase()]
if (fmt.startsWith('datetime') || fmt.startsWith('e8601dt')) ddtype = 'DATETIME' if (val === undefined) {
else if (fmt.startsWith('date')) ddtype = 'DATE' const key = Object.keys(row).find(k => k.toLowerCase() === col.name.toLowerCase())
else if (fmt.startsWith('time')) ddtype = 'TIME' if (key) val = row[key]
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)
} }
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') _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 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', { const body = runService(estate, 'public/getrawdata', {
params: { type: 'CSV', table: 'DC_JSLIB.MPE_SELECTBOX', filter } 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) assert.equal(headersOf(estate), '', filter)
} }
/* %mp_validatecol(...,ISINT,...) is input(&filter,??best32.) with mod 1 = 0, /* And these pass validation. The estate's own answer for them is a downstream
so a decimal written as one and an exponent are both integers to it. The abort (the filter RK does not exist), not "Invalid filter"; the mock does
estate's own answer for these three is a downstream abort (the filter RK not resolve filter RKs, so it streams. */
does not exist), not "Invalid filter", which is what shows validation for (const filter of ['7.0', '1e3', '+5', '007', ' 7 ', '-3']) {
passed; the mock does not resolve filter RKs, so it streams. */
for (const filter of ['7.0', '1e3', ' 0 ']) {
const body = runService(estate, 'public/getrawdata', { const body = runService(estate, 'public/getrawdata', {
params: { type: 'CSV', table: 'DC_JSLIB.MPE_SELECTBOX', filter } 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) 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 ────────────────────────────── // ─── admin/exportdb and the shared mpStreamfile ──────────────────────────────
test('exportdb streams the DDL as application/text, named <libref>.ddl', async () => { 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", "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": [] "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", "id": "control_library",
"service": "public/viewtables", "service": "public/viewtables",