fix(mocks): one sasDatetimeNow, a documented TX_TO=0, and a shaped fallback export
Three review findings on this branch. - dcMockUtils defined sasDatetimeNow twice: once beside isCurrentRow with the epoch written inline and once in the mp_lockanytable section using SAS_EPOCH_MS. The second is the one the file already had; the duplicate is gone, so the epoch has one expression. - isCurrentRow treated TX_TO=0 as current without saying why, and reached that through Number(null) === 0. Both conventions are real: makedata writes 31DEC9999 for a row that was never superseded, and the fixtures in control-tables.test.js use 0 for the same thing. The clause stays and the comment now says which convention is which, plus that a missing, null or blank TX_TO (a table with no version column) is current too. Reading the column moved into isCurrentOn, because Array.filter passes the index as a second argument and would have handed a `column` parameter the row number. - admin/exportconfig used the schema only when it existed: with no columns in the table file it fell back to Object.keys(rows[0]), which exported the TX_/VER_/SELECTBOX_RK columns the spec drops and quoted every number. The fallback now applies the same drop prefixes and infers numeric typing from the rows, so a schema-less estate produces the same shape as a seeded one. A test writes a table file with rows and no columns and asserts both. All 46 mock tests pass (node --test sas/mocks/test/).
This commit is contained in:
1 parent
a1615ee8a7
commit
e53c50d4e2
3 files changed
+93
-26
No files matched your search
@@ -67,13 +67,6 @@ const EXPORTS = [
|
||||
|
||||
const upper = (value) => String(value === undefined || value === null ? '' : value).toUpperCase()
|
||||
|
||||
/** The version test of that table: a row is exported while it is current. */
|
||||
const isCurrent = (row, column) => {
|
||||
if (!column) return isCurrentRow(row)
|
||||
const txTo = Number(colVal(row, column))
|
||||
return isNaN(txTo) || txTo === 0 || txTo > sasDatetimeNow()
|
||||
}
|
||||
|
||||
/**
|
||||
* mp_ds2csv(headerformat=NAME) quotes every character value and leaves numbers
|
||||
* bare, which is why the schema decides the quoting rather than the value.
|
||||
@@ -105,7 +98,7 @@ try {
|
||||
const entries = []
|
||||
for (const spec of EXPORTS) {
|
||||
const rows = controlTable(spec.name)
|
||||
.filter((row) => isCurrent(row, spec.current))
|
||||
.filter((row) => isCurrentOn(row, spec.current || 'TX_TO'))
|
||||
.filter((row) => (spec.keep ? spec.keep(row) : true))
|
||||
.map((row) => {
|
||||
const out = Object.assign({}, row)
|
||||
@@ -131,19 +124,36 @@ try {
|
||||
|
||||
const schema = loadTableSchema(dcConf.dataDir, spec.name) || []
|
||||
const numeric = {}
|
||||
const columns = schema
|
||||
.map((column) => column.name)
|
||||
.filter(
|
||||
(name) =>
|
||||
!spec.drop.some((prefix) => upper(name).indexOf(prefix) === 0)
|
||||
)
|
||||
for (const column of schema) {
|
||||
numeric[column.name] = column.type === 'N'
|
||||
const dropped = (name) =>
|
||||
spec.drop.some((prefix) => upper(name).indexOf(prefix) === 0)
|
||||
|
||||
let columns
|
||||
if (schema.length) {
|
||||
columns = schema.map((column) => column.name).filter((name) => !dropped(name))
|
||||
for (const column of schema) {
|
||||
numeric[column.name] = column.type === 'N'
|
||||
}
|
||||
} else {
|
||||
// No schema on the estate: infer the shape from the rows rather than
|
||||
// exporting the dropped TX_/VER_/SELECTBOX_RK columns and quoting every
|
||||
// number, which would make the backup differ from the schema path.
|
||||
columns = Object.keys(rows[0]).filter((name) => !dropped(name))
|
||||
for (const name of columns) {
|
||||
numeric[name] = rows.every((row) => {
|
||||
const value = colVal(row, name)
|
||||
return (
|
||||
value === undefined ||
|
||||
value === null ||
|
||||
value === '' ||
|
||||
!isNaN(Number(value))
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
entries.push({
|
||||
name: newlib + '.' + spec.name + '.csv',
|
||||
content: configCsv(columns.length ? columns : Object.keys(rows[0]), rows, numeric)
|
||||
content: configCsv(columns, rows, numeric)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -446,19 +446,29 @@ function loadTableSchema(dataDir, tableName) {
|
||||
return (data && data.columns) ? data.columns : []
|
||||
}
|
||||
|
||||
/** SAS datetimes are seconds since 1960-01-01, which is what TX_TO holds. */
|
||||
function sasDatetimeNow() {
|
||||
return Math.floor((Date.now() - Date.UTC(1960, 0, 1)) / 1000)
|
||||
/**
|
||||
* The SAS test "&dc_dttmtfmt. lt tx_to": a row is current while TX_TO is ahead
|
||||
* of now. Two conventions mark a row that was never superseded: makedata
|
||||
* writes 31DEC9999, and the fixtures here use 0, so both count as open - and so
|
||||
* does a missing, null or blank TX_TO, which is what a table with no version
|
||||
* column has. Shared by every mock that reads a versioned MPE_* table: the
|
||||
* rule is the same one mpe_derived and the services apply, so it lives here
|
||||
* once.
|
||||
*/
|
||||
function isCurrentRow(row) {
|
||||
return isCurrentOn(row, 'TX_TO')
|
||||
}
|
||||
|
||||
/**
|
||||
* The SAS test "&dc_dttmtfmt. lt tx_to": a row is current while TX_TO is ahead
|
||||
* of now. A row with no TX_TO has never been superseded, so it is current.
|
||||
* Shared by every mock that reads a versioned MPE_* table - the rule is the
|
||||
* same one mpe_derived and the services apply, so it lives here once.
|
||||
* The same test against another version column, for the one table that does not
|
||||
* use TX_TO (MPE_SELECTBOX versions on VER_TO_DTTM). Separate from
|
||||
* isCurrentRow because Array.filter passes the index as a second argument,
|
||||
* which a `column` parameter would silently take as the column name.
|
||||
*/
|
||||
function isCurrentRow(row) {
|
||||
const txTo = Number(colVal(row, 'TX_TO'))
|
||||
function isCurrentOn(row, column) {
|
||||
const raw = colVal(row, column)
|
||||
if (raw === null || raw === undefined || raw === '') return true
|
||||
const txTo = Number(raw)
|
||||
return isNaN(txTo) || txTo === 0 || txTo > sasDatetimeNow()
|
||||
}
|
||||
|
||||
|
||||
@@ -274,3 +274,50 @@ test('exportconfig refuses a target that is not a libref', () => {
|
||||
assert.equal(body.sasjsAbort.length, 1)
|
||||
assert.equal(body.sasjsAbort[0].MSG, 'BAD LIBREF is not a valid libref')
|
||||
})
|
||||
|
||||
test('exportconfig keeps the dropped columns out when a table has no schema', () => {
|
||||
const estate = seededEstate()
|
||||
|
||||
for (const name of ['MPE_ALERTS', 'MPE_EMAILS', 'MPE_XLMAP_RULES', 'MPE_CONFIG']) {
|
||||
writeTable(estate, name, [])
|
||||
}
|
||||
writeTable(estate, 'MPE_XLMAP_INFO', [])
|
||||
|
||||
// A table file with rows but no column metadata: the estate was seeded by
|
||||
// something other than makedata, so loadTableSchema finds nothing.
|
||||
const file = path.join(estate.appDir, 'data', 'DC_JSLIB', 'mpe_tables.json')
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
JSON.stringify({
|
||||
rows: [
|
||||
{
|
||||
LIBREF: 'TESTDATA',
|
||||
TABLE: 'SOME_DATA',
|
||||
TX_TO: 0,
|
||||
TX_FROM: 0,
|
||||
NUM_OF_APPROVALS_REQUIRED: 1
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
const result = exportConfig(estate, {})
|
||||
|
||||
const start = result.text.indexOf('DC_JSLIB.MPE_TABLES.csv')
|
||||
assert.ok(start >= 0, 'the table with no schema is still exported')
|
||||
const member = result.text.slice(start, result.text.indexOf('PK', start))
|
||||
|
||||
assert.match(
|
||||
member,
|
||||
/"LIBREF","TABLE","NUM_OF_APPROVALS_REQUIRED"/,
|
||||
'the TX_ columns are dropped and the surviving ones keep their order'
|
||||
)
|
||||
assert.ok(
|
||||
!member.includes('TX_'),
|
||||
'a dropped column is not exported just because the schema is missing'
|
||||
)
|
||||
assert.ok(
|
||||
member.includes('"TESTDATA","SOME_DATA",1'),
|
||||
'a numeric value is left bare, as mp_ds2csv writes it'
|
||||
)
|
||||
})
|
||||
Reference in new issue
Block a user