fix(mocks): one sasDatetimeNow, a documented TX_TO=0, and a shaped fallback export
Build / Build-and-ng-test (pull_request) Successful in 5m56s
Lighthouse Checks / lighthouse (pull_request) Successful in 8m3s
Build / Build-and-test-development (pull_request) Successful in 29m43s

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:
dc committed 2026-11-05 09:39:25 +00:00
1 parent a1615ee8a7
commit e53c50d4e2
3 files changed
+93 -26

No files matched your search

+27 -17
View File
@@ -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)
})
}
+19 -9
View File
@@ -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()
}
+47
View File
@@ -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'
)
})