Review 886 on #517, both findings. Finding 1 - getrawdata validates three inputs, and the mock only had the third. getrawdata.sas aborts, in this order and all with mac=&_program..sas, before it streams anything: table shape -> "Invalid table" filter integer -> "Invalid filter" type list -> "Invalid type" The mock now reproduces all three, from the rules of the macros the service calls: %mpe_validatecol(...,LIBDS,...) strips a trailing -FC and applies %mp_validatecol's /^[_a-z]\w{0,7}\.[_a-z]\w{0,31}$/i, and %mp_validatecol(...,ISINT,...) is input(??best32.) with mod 1 = 0, so 7.0 and 1e3 are integers and abc and 1.5 are not. Each answer was recorded from the synthetic estate first: {table:'NOT_A_LIBDS'} -> Invalid table {filter:'abc'} -> Invalid filter no table at all -> Invalid table (was a ReferenceError before) {type:'FOOBAR',table:'NOT_A_LIBDS',filter:'abc'} -> Invalid table Finding 2 - the tests waited 100ms for a fire-and-forget write. The cause was the writers, not the tests: getddl, getrawdata and exportdb used the async fs.writeFile for the headers file, while %mfs_httpheader (fopen/fput/fclose) and dcMockUtils.mpStreamfile are synchronous. Those three now use fs.writeFileSync, so the file is complete when the program returns; the waits are gone, the positive assertions read the file directly, and the absence assertions mean "never written" rather than "not written yet". No polling remains in the file. Also: mock-settings.test.js renamed the control library to DC7QATEST to prove getrawdata follows the settings file - nine characters, which the estate rejects as "Invalid table" for a LIBREF. The fixture is now DC7QATST. That was a test defect the new validation exposed, not a product one. Tests: download-headers.test.js is 11 tests, and the two new ones were run against the old code first - dropping either check fails its own test. Whole mock suite: 144 tests, 144 pass. parity/check.js unchanged.
226 lines
9.6 KiB
JavaScript
226 lines
9.6 KiB
JavaScript
/* mp_streamfile(contenttype=TEXT) sends an application/text attachment named
|
|
after the member, unquoted - confirmed against the estate's own response for
|
|
public/getddl, whose headers are `content-type: application/text` and
|
|
`content-disposition: attachment; filename=<libref>_<ds>.ddl`. */
|
|
let mimeType = `application/text`
|
|
let filename = `dc-download`
|
|
|
|
const nodePath = require('path')
|
|
let appLoc = nodePath.join(..._program.split('services')[0].split('/'))
|
|
const sasjsRoot = nodePath.resolve(weboutPath, '..', '..', '..')
|
|
const driveRoot = nodePath.resolve(sasjsRoot, 'drive')
|
|
eval(
|
|
fs.readFileSync(
|
|
nodePath.resolve(driveRoot, 'files', appLoc, 'services', 'dcMockUtils.js'),
|
|
'utf8'
|
|
)
|
|
)
|
|
|
|
const reqLibref =
|
|
typeof libref === 'undefined' ? '' : String(libref || '').trim()
|
|
const reqDs = typeof ds === 'undefined' ? '' : String(ds || '').trim()
|
|
|
|
/**
|
|
* getddl.sas assigns the library, checks the member is there and aborts with
|
|
* "Dataset <libref>.<ds> was not found" - the same abort answers an unknown
|
|
* library and a missing member, which is why the message names both parts.
|
|
* The mock used to answer its fixed sample body for any request at all,
|
|
* including a table it has never heard of.
|
|
*/
|
|
const requested = reqDs ? makeTableLoader(libDataDir(reqLibref))(reqDs) : null
|
|
if (!reqLibref || !reqDs || !requested) {
|
|
mpAbort(`Dataset ${reqLibref}.${reqDs} was not found`, _program)
|
|
}
|
|
|
|
filename = `${reqLibref}_${reqDs}.ddl`
|
|
|
|
/* ─── the DDL, generated from the table's own metadata ─────────────────────
|
|
mp_ds2ddl reads dictionary.columns, dictionary.indexes and the table's
|
|
constraints; the mock reads the table file makedata wrote, which carries the
|
|
same things - the columns in order at the case the estate reports, and the
|
|
unique key. Verified byte-for-byte against the synthetic estate's own DDL for
|
|
MPE_TABLES in all three flavours, which is the table the parity gated
|
|
scenario records (public/getddl/mpe_tables); no golden pins the other
|
|
control tables yet.
|
|
*/
|
|
const DDL_MONTHS = ['JAN','FEB','MAR','APR','MAY','JUN','JUL','AUG','SEP','OCT','NOV','DEC']
|
|
|
|
/**
|
|
* The estate's header comes from `%sysfunc(datetime(),datetime19.)`, a SAS
|
|
* datetime, so it carries the session's own local time rather than UTC. The
|
|
* mock reads the local components for the same reason: the synthetic estate
|
|
* answers 07NOV2025:22:27 for a moment whose UTC getters give 21:27, and the
|
|
* projection masks the whole comment, so only this choice keeps the two equal.
|
|
*/
|
|
function ddlDatetime(now) {
|
|
const d = new Date(now)
|
|
const p = (n) => String(n).padStart(2, '0')
|
|
return (
|
|
p(d.getDate()) + DDL_MONTHS[d.getMonth()] + d.getFullYear() + ':' +
|
|
p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds())
|
|
)
|
|
}
|
|
|
|
/** The name as created: the mock's metadata carries the estate's own casing. */
|
|
function ddlType(col) {
|
|
const isChar = col.type === 'C' || col.type === 'char'
|
|
if (isChar) return 'char(' + col.length + ')'
|
|
if (Number(col.length) !== 8) return 'num length=' + col.length
|
|
return 'num'
|
|
}
|
|
|
|
/**
|
|
* One column line, as `put name typ fmt notnul lab` writes it: list output puts
|
|
* one space between parts, and a blank part costs a space of its own - which is
|
|
* why the trailing blanks differ between a column that is not null and one that
|
|
* carries a label.
|
|
*/
|
|
function ddlColumnLine(col, first) {
|
|
const indent = first ? ' ' : ' ,'
|
|
const fmt =
|
|
col.format && String(col.format).length > 1
|
|
? 'format=' + String(col.format).toUpperCase()
|
|
: ' '
|
|
const notnul = col.notnull ? 'not null' : ' '
|
|
const lab =
|
|
col.label && String(col.label).length > 1
|
|
? "label='" + String(col.label).replace(/'/g, "''") + "'"
|
|
: ' '
|
|
return indent + [col.name, ddlType(col), fmt, notnul, lab].join(' ')
|
|
}
|
|
|
|
/** Is this numeric column a datetime the way mp_ds2ddl reads the format? */
|
|
function ddlIsDatetime(col) {
|
|
return String(col.format || '').toUpperCase().indexOf('DATETIME') === 0
|
|
}
|
|
|
|
function ddlPostgresType(col) {
|
|
if (col.type === 'C' || col.type === 'char') return 'VARCHAR(' + col.length + ')'
|
|
return ddlIsDatetime(col) ? 'TIMESTAMP' : 'DOUBLE PRECISION'
|
|
}
|
|
|
|
function ddlTsqlType(col) {
|
|
if (col.type === 'C' || col.type === 'char') {
|
|
return '[varchar](' + (Number(col.length) <= 8000 ? col.length : 'max') + ')'
|
|
}
|
|
return ddlIsDatetime(col) ? '[datetime2](7)' : '[decimal](18,2)'
|
|
}
|
|
|
|
/** The line each dialect writes for a column, blanks and all. */
|
|
function ddlDialectColumn(col, first, flavour) {
|
|
const indent = first ? ' ' : ' ,'
|
|
const notnul = col.notnull ? 'NOT NULL' : ' '
|
|
if (flavour === 'PGSQL') {
|
|
return indent + ['"' + col.name + '"', ddlPostgresType(col), notnul].join(' ')
|
|
}
|
|
// the type follows the name with no separator, as mp_ds2ddl writes it
|
|
return indent + '[' + col.name + ']' + ddlTsqlType(col) + ' ' + notnul
|
|
}
|
|
|
|
/**
|
|
* The DDL mp_ds2ddl writes, from the table file makedata wrote. SAS, TSQL and
|
|
* PGSQL each have their own shape, and each was checked against the estate's
|
|
* own output for MPE_TABLES - the table the parity gate records.
|
|
*/
|
|
function ddlText(table, libref, ds, flavour, user, now) {
|
|
const lines = []
|
|
lines.push('/* DDL generated by ' + user + ' on ' + ddlDatetime(now) + ' */')
|
|
|
|
/* mp_ds2ddl writes that comment before it branches, and has branches only
|
|
for SAS, TSQL and PGSQL: any other flavour leaves the file holding the
|
|
comment alone, which mp_streamfile then sends as a normal download. The
|
|
estate answers exactly that for an unknown flavour - a 200 with a
|
|
55-byte body - so the mock does too rather than inventing a refusal. */
|
|
if (['SAS', 'TSQL', 'PGSQL'].indexOf(flavour) < 0) {
|
|
return lines.join('\n') + '\n'
|
|
}
|
|
|
|
if (flavour === 'PGSQL') {
|
|
lines.push('CREATE SCHEMA ' + libref + ';')
|
|
lines.push('/* Postgres Flavour DDL for ' + libref + '.' + ds + ' */')
|
|
lines.push('CREATE TABLE ' + libref + '.' + ds + ' (')
|
|
table.columns.forEach(function (col, i) {
|
|
lines.push(ddlDialectColumn(col, i === 0, 'PGSQL'))
|
|
})
|
|
lines.push(');')
|
|
if (table.uniqueIndex) {
|
|
// the index is a second statement in Postgres, after the table
|
|
lines.push('CREATE UNIQUE INDEX "' + table.uniqueIndex.name + '" ON ' + libref + '.' + ds + '(')
|
|
table.uniqueIndex.columns.forEach(function (name, i) {
|
|
lines.push((i === 0 ? ' ' : ' ,') + '"' + name + '"')
|
|
})
|
|
lines.push(');')
|
|
}
|
|
return lines.join('\n') + '\n'
|
|
}
|
|
|
|
if (flavour === 'TSQL') {
|
|
lines.push('/* TSQL Flavour DDL for ' + libref + '.' + ds + ' */')
|
|
lines.push('create table [' + libref + '].[' + ds + '](')
|
|
table.columns.forEach(function (col, i) {
|
|
lines.push(ddlDialectColumn(col, i === 0, 'TSQL'))
|
|
})
|
|
if (table.uniqueIndex) {
|
|
// TSQL declares the key inside the create table, not after it
|
|
lines.push(' ,index [' + table.uniqueIndex.name + '] UNIQUE NONCLUSTERED (')
|
|
table.uniqueIndex.columns.forEach(function (name, i) {
|
|
lines.push((i === 0 ? ' ' : ' ,') + '[' + name + ']')
|
|
})
|
|
lines.push(' )')
|
|
}
|
|
lines.push(')')
|
|
lines.push('GO')
|
|
/* A label becomes an extended property, one statement per column, and
|
|
the statement block is separated from the create by a line holding a
|
|
single space - which is what put ' ' / writes. */
|
|
table.columns.forEach(function (col) {
|
|
if (!col.label || String(col.label).length < 2) return
|
|
lines.push(' ')
|
|
lines.push('EXEC sys.sp_addextendedproperty ')
|
|
lines.push(" @name=N'MS_Description',@value=N'" + String(col.label).replace(/'/g, "''") + "'")
|
|
lines.push(" ,@level0type=N'SCHEMA',@level0name=N'" + libref + "' ")
|
|
lines.push(" ,@level1type=N'TABLE',@level1name=N'" + ds + "'")
|
|
lines.push(" ,@level2type=N'COLUMN',@level2name=N'" + col.name + "'")
|
|
})
|
|
if (table.columns.some(function (col) { return col.label && String(col.label).length > 1 })) {
|
|
lines.push('GO')
|
|
}
|
|
return lines.join('\n') + '\n'
|
|
}
|
|
|
|
lines.push('/* SAS Flavour DDL for ' + libref + '.' + ds + ' */')
|
|
lines.push('proc sql;')
|
|
lines.push('create table ' + libref + '.' + ds + '(')
|
|
table.columns.forEach(function (col, i) {
|
|
lines.push(ddlColumnLine(col, i === 0))
|
|
})
|
|
lines.push(');')
|
|
if (table.uniqueIndex) {
|
|
lines.push('CREATE UNIQUE INDEX ' + table.uniqueIndex.name + ' ON ' + libref + '.' + ds + ' (')
|
|
table.uniqueIndex.columns.forEach(function (name, i) {
|
|
lines.push((i === 0 ? ' ' : ' ,') + name)
|
|
})
|
|
lines.push(');')
|
|
}
|
|
return lines.join('\n') + '\n'
|
|
}
|
|
|
|
_webout = ddlText(
|
|
requested,
|
|
reqLibref,
|
|
reqDs,
|
|
String(typeof flavour === 'undefined' ? 'SAS' : flavour || 'SAS').toUpperCase(),
|
|
typeof _SASJS_USERNAME === 'undefined' ? 'sasjs' : _SASJS_USERNAME,
|
|
Date.now()
|
|
)
|
|
|
|
/* The headers go on last because the estate writes none until mp_streamfile,
|
|
which runs after mp_ds2ddl has produced the file - so a request that cannot
|
|
produce one has already aborted as a JSON envelope by now. One header per
|
|
line, as %mfs_httpheader writes them, and synchronously as that macro does,
|
|
so they are on disk before the program returns. */
|
|
fs.writeFileSync(
|
|
_SASJS_WEBOUT_HEADERS,
|
|
`Content-Type: ${mimeType}\nContent-disposition: attachment; filename=${filename}`
|
|
)
|