Files
dc/sas/mocks/sasjs/services/public/getddl.js
T
dc ad732f42b6
Build / Build-and-ng-test (pull_request) Successful in 7m46s
Build / lighthouse (pull_request) Successful in 6m32s
Build / Build-and-test-development (pull_request) Successful in 29m16s
fix(mocks): the other two getrawdata aborts, and synchronous headers
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.
2026-11-07 23:23:09 +00:00

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}`
)