The index map and the schema are two lists of the same column names, and nothing else joins them: a later rename in one would leave getddl emitting CREATE UNIQUE INDEX on a column the table does not have, while the test that exists to check every table has a key kept passing. Each uniqueIndex column must now resolve to a column of its own table, case-insensitively, so a rename on either side fails here instead of in a download. Merges release/7.23, which carries the 4.14.0 pinned estate and the lazy-route revert.
283 lines
10 KiB
JavaScript
283 lines
10 KiB
JavaScript
/**
|
|
* The mock makedata must build the estate the SAS service builds.
|
|
*
|
|
* makedata.sas (the `server` target) runs `%mpe_makedatamodel` for the empty
|
|
* tables and `%mpe_makedata` for the rows. The JS mock in
|
|
* sas/mocks/sasjs/services/admin/makedata.js is a second implementation of
|
|
* those two macros, and the two used to disagree: two model tables
|
|
* (MPE_MAXKEYVALUES, MPE_SIGNOFFS) were never created, and a handful of seed
|
|
* rows were missing. Nothing failed, because the client only asks for the
|
|
* tables it opens.
|
|
*
|
|
* So the macros are read here, and the estate the mock seeds is compared with
|
|
* what they declare. A change to either side that is not made on the other
|
|
* fails this test.
|
|
*
|
|
* Run with: node --test sas/mocks/test/
|
|
*/
|
|
const test = require('node:test')
|
|
const assert = require('node:assert')
|
|
const fs = require('node:fs')
|
|
const path = require('node:path')
|
|
|
|
const { seededEstate, readTable } = require('./harness')
|
|
|
|
const MACROS = path.join(__dirname, '..', '..', 'sasjs', 'macros')
|
|
const MODEL = fs.readFileSync(
|
|
path.join(MACROS, 'mpe_makedatamodel.sas'),
|
|
'utf8'
|
|
)
|
|
const SEED = fs.readFileSync(path.join(MACROS, 'mpe_makedata.sas'), 'utf8')
|
|
|
|
/** `create table &lib..name(` blocks, with their column definitions. */
|
|
function modelTables(source) {
|
|
const tables = {}
|
|
const re = /create table &lib\.\.\s*([A-Za-z0-9_-]+)\s*\(([\s\S]*?)\n\s*\);/gi
|
|
let match
|
|
while ((match = re.exec(source)) !== null) {
|
|
const name = match[1].toUpperCase()
|
|
const columns = []
|
|
for (const line of match[2].split('\n')) {
|
|
const col = /^\s*,?\s*([A-Za-z0-9_]+)\s+(num|char|varchar|float|int)\b/i.exec(
|
|
line
|
|
)
|
|
if (col) columns.push(col[1].toUpperCase())
|
|
}
|
|
tables[name] = columns
|
|
}
|
|
return tables
|
|
}
|
|
|
|
/** `insert into &lib..name set` statements, grouped by table. */
|
|
function seedInserts(source) {
|
|
const body = source.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
const rows = {}
|
|
const re = /insert into &lib\.\.\s*([A-Za-z0-9_-]+)\s+set\s+([\s\S]*?);/gi
|
|
let match
|
|
while ((match = re.exec(body)) !== null) {
|
|
const name = match[1].toUpperCase()
|
|
const columns = []
|
|
for (const part of match[2].split(/[\n,]/)) {
|
|
const assignment = /^\s*([A-Za-z0-9_]+)\s*=/.exec(part)
|
|
if (assignment) columns.push(assignment[1].toUpperCase())
|
|
}
|
|
rows[name] = rows[name] || []
|
|
rows[name].push(columns.sort())
|
|
}
|
|
return rows
|
|
}
|
|
|
|
const model = modelTables(MODEL)
|
|
const seeds = seedInserts(SEED)
|
|
|
|
/*
|
|
These four carry rows the mock cannot copy verbatim:
|
|
- MPE_GROUPS, MPE_ROW_LEVEL_SECURITY and MPE_SECURITY name SAS metadata
|
|
groups, which a JS-only estate does not have (the mock uses its own).
|
|
- MPE_X_TEST is seeded with ~490 rows from a ranuni() loop, and the mock
|
|
keeps the seven deterministic rows only.
|
|
*/
|
|
const ADAPTED = new Set([
|
|
'MPE_GROUPS',
|
|
'MPE_ROW_LEVEL_SECURITY',
|
|
'MPE_SECURITY',
|
|
'MPE_X_TEST'
|
|
])
|
|
|
|
test('mpe_makedatamodel declares tables, and the mock creates all of them', () => {
|
|
const estate = seededEstate({ demo: false })
|
|
const created = {}
|
|
const dir = path.join(estate.appDir, 'data', 'DC_JSLIB')
|
|
for (const file of fs.readdirSync(dir)) {
|
|
const table = JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8'))
|
|
created[table.name] = table
|
|
}
|
|
|
|
assert.ok(Object.keys(model).length > 30, 'the model was parsed')
|
|
const missing = Object.keys(model).filter((name) => !created[name])
|
|
assert.deepEqual(
|
|
missing,
|
|
[],
|
|
'every table the model creates must exist in the mock estate'
|
|
)
|
|
|
|
const known = new Set(Object.keys(model))
|
|
// the mock writes a marker for the format catalog, which is a SAS catalog
|
|
// rather than a dataset, so the model does not declare it
|
|
known.add('MPE_X_CATALOG')
|
|
const extra = Object.keys(created).filter((name) => !known.has(name))
|
|
assert.deepEqual(
|
|
extra,
|
|
[],
|
|
'a new table in the mock estate is a table the SAS service does not build'
|
|
)
|
|
})
|
|
|
|
test('each table has the model columns, in the model order', () => {
|
|
const estate = seededEstate({ demo: false })
|
|
for (const [name, expected] of Object.entries(model)) {
|
|
const actual = readTable(estate, name)
|
|
// readTable returns rows; the schema is read from the file instead
|
|
const file = path.join(
|
|
estate.appDir,
|
|
'data',
|
|
'DC_JSLIB',
|
|
name.toLowerCase() + '.json'
|
|
)
|
|
assert.ok(fs.existsSync(file), name + ' is missing')
|
|
const table = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
// the parser upper-cases what it reads out of the macro, and the mock
|
|
// keeps the case SAS creates the table with - the comparison is on names
|
|
assert.deepEqual(
|
|
(table.columns || []).map((col) => col.name.toUpperCase()),
|
|
expected,
|
|
name + ' columns differ from mpe_makedatamodel.sas'
|
|
)
|
|
assert.ok(Array.isArray(actual), name + ' rows')
|
|
}
|
|
})
|
|
|
|
test('each seeded table carries the rows mpe_makedata inserts', () => {
|
|
const estate = seededEstate({ demo: false })
|
|
for (const [name, inserts] of Object.entries(seeds)) {
|
|
if (ADAPTED.has(name)) continue
|
|
const rows = readTable(estate, name)
|
|
assert.equal(
|
|
rows.length,
|
|
inserts.length,
|
|
name + ' row count differs from mpe_makedata.sas'
|
|
)
|
|
// a table whose inserts name optional columns (MPE_XLMAP_RULES sets
|
|
// XLMAP_FINISH on some ranges only) has a union; every row must sit
|
|
// inside it, and the union must be the one the macro writes
|
|
const union = new Set()
|
|
for (const insert of inserts) for (const field of insert) union.add(field)
|
|
const expected = Array.from(union).sort()
|
|
for (const row of rows) {
|
|
const fields = Object.keys(row)
|
|
.map((field) => field.toUpperCase())
|
|
.sort()
|
|
for (const field of fields) {
|
|
assert.ok(
|
|
union.has(field),
|
|
name + ' row carries ' + field + ', which mpe_makedata never sets'
|
|
)
|
|
}
|
|
assert.deepEqual(
|
|
fields,
|
|
expected.filter((field) => fields.includes(field)),
|
|
name + ' row fields differ from mpe_makedata.sas'
|
|
)
|
|
}
|
|
}
|
|
})
|
|
|
|
test('the adapted tables are seeded, and document why they differ', () => {
|
|
const estate = seededEstate({ demo: false })
|
|
for (const name of ADAPTED) {
|
|
const rows = readTable(estate, name)
|
|
assert.ok(rows.length > 0, name + ' is empty')
|
|
}
|
|
// the mock's own groups are the ones its services report
|
|
const groups = readTable(estate, 'MPE_GROUPS')
|
|
assert.ok(
|
|
groups.some((row) => row.group_name === 'dc-admin'),
|
|
'the mock grants its own group, not a SAS metadata one'
|
|
)
|
|
})
|
|
|
|
test('every seeded value fits its column type', () => {
|
|
// The rows are transcribed from SAS source, where a value can be a macro
|
|
// expression. The mock has no macro engine, so a transcription that leaves
|
|
// one behind (a %mf_increment(rk) surrogate key, a quoted '0A'x newline)
|
|
// writes a string where the client expects a number or a line break.
|
|
const estate = seededEstate({ demo: false })
|
|
for (const name of Object.keys(model)) {
|
|
const file = path.join(
|
|
estate.appDir,
|
|
'data',
|
|
'DC_JSLIB',
|
|
name.toLowerCase() + '.json'
|
|
)
|
|
const table = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
const types = {}
|
|
for (const col of table.columns) types[col.name.toUpperCase()] = col.type
|
|
for (const row of table.rows) {
|
|
for (const [field, value] of Object.entries(row)) {
|
|
const type = types[field.toUpperCase()]
|
|
if (value === null || value === undefined) continue
|
|
if (type === 'N') {
|
|
assert.equal(
|
|
typeof value,
|
|
'number',
|
|
name + '.' + field + ' is numeric but carries ' + JSON.stringify(value)
|
|
)
|
|
} else {
|
|
assert.equal(
|
|
typeof value,
|
|
'string',
|
|
name + '.' + field + ' is character but carries ' + JSON.stringify(value)
|
|
)
|
|
assert.ok(
|
|
value.indexOf('\\u0001') < 0,
|
|
name + '.' + field + ' still holds an escaped newline'
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
test('makedata leaves no Cypress fixture behind', () => {
|
|
const estate = seededEstate({ demo: false })
|
|
const dir = path.join(estate.appDir, 'data')
|
|
assert.deepEqual(
|
|
fs.readdirSync(dir).sort(),
|
|
['DC_JSLIB'],
|
|
'the demo libraries belong to createdemodata, not makedata'
|
|
)
|
|
const tables = readTable(estate, 'MPE_TABLES')
|
|
const fixture = tables.filter(
|
|
(row) => String(row.libref).toUpperCase() !== 'DC_JSLIB'
|
|
)
|
|
assert.deepEqual(fixture, [], 'a fixture table is registered by makedata')
|
|
})
|
|
|
|
test('every control table carries the unique key the estate creates it with', () => {
|
|
// mp_ds2ddl builds CREATE UNIQUE INDEX from dictionary.indexes, so the key is
|
|
// part of the table's definition here too - and the one table without one on
|
|
// the estate is named rather than left to drift.
|
|
const estate = seededEstate({ demo: false })
|
|
// MPE_REQUESTS is the one control table the estate creates without a unique
|
|
// index; MPE_X_CATALOG is the format catalog marker, which is a catalog
|
|
// rather than a dataset and has no columns to key
|
|
const withoutKey = ['MPE_REQUESTS', 'MPE_X_CATALOG']
|
|
const files = fs
|
|
.readdirSync(path.join(estate.appDir, 'data', 'DC_JSLIB'))
|
|
.filter((f) => f.endsWith('.json'))
|
|
|
|
for (const file of files) {
|
|
const table = JSON.parse(
|
|
fs.readFileSync(path.join(estate.appDir, 'data', 'DC_JSLIB', file), 'utf8')
|
|
)
|
|
const expected = withoutKey.indexOf(table.name) < 0
|
|
assert.equal(
|
|
!!(table.uniqueIndex && table.uniqueIndex.name && table.uniqueIndex.columns.length),
|
|
expected,
|
|
table.name + (expected ? ' has no unique key' : ' should have no unique key')
|
|
)
|
|
/* The index map and the schema are two lists of the same column names and
|
|
nothing else ties them together: a rename in one would otherwise leave
|
|
getddl emitting CREATE UNIQUE INDEX on a column the table does not have,
|
|
and the test above would still pass. */
|
|
if (!expected) continue
|
|
const names = (table.columns || []).map((col) => col.name.toUpperCase())
|
|
for (const keyed of table.uniqueIndex.columns) {
|
|
assert.ok(
|
|
names.indexOf(keyed.toUpperCase()) >= 0,
|
|
table.name + ' indexes ' + keyed + ', which is not one of its columns'
|
|
)
|
|
}
|
|
}
|
|
})
|