feat(makedata): optional sample data library under dcdemo
Build / Build-and-ng-test (pull_request) Successful in 3m51s
Build / lighthouse (pull_request) Successful in 5m45s
Build / Build-and-test-development (pull_request) Successful in 23m56s

The deploy screens offer a checkbox, off by default, that asks makedata to
build a sample data library beside the control library. It sits at the bottom
of each screen and explains itself only once it is ticked: the option is not
one most administrators will want, so it is not put in the path of the screen's
action.

Each makedata target resolves the flag itself - the URL parameter first, then
the adapter's SAMPLEDATA column when the input table carries one - so no extra
macro is deployed for it. mpe_makesampledata creates <dcloc>/dcdemo, assigns it
as DC_DEMO, writes three tables and registers them in MPE_TABLES, and takes
iftrue=0 by default so a call that omits the flag builds nothing.

mpe_makesampledata keeps what it already had for the 4GL demo estate (row level
security, excel config and security rows, gated on SYSSITE) and gains the
sample tables, so one macro owns the whole job. The mock makedata's
mpeMakesampledata is the same function with the same arguments, tables and rows.

The rows are generated from a fixed rule rather than copied from SASHELP, so a
SAS estate and the JS estate hold the same values; makedata-sampledata.test.js
compares both estates' registrations against the macro source.

The pre-commit prettier gate could not run here (no npm on PATH); the same
prettier check was run directly against the staged client files and passes.
This commit is contained in:
dc committed 2026-11-08 18:46:15 +00:00
1 parent 0fc4459f29
commit 52bb582cb7
13 files changed
+1071 -62

No files matched your search

@@ -249,3 +249,29 @@
>
Deploy
</button>
<!--
Optional sample data library. Deliberately the last thing on the screen: it
is a convenience for a new deployment that most administrators will not
want, so it sits below the screen's action rather than in the path to it.
The explanation is shown only once the box is ticked, and the box is what
makedata reads as SAMPLEDATA.
-->
<div class="d-flex flex-column mt-10">
<clr-checkbox-wrapper>
<input
clrCheckbox
type="checkbox"
id="createsampledata"
name="createsampledata"
[(ngModel)]="createSampleData"
/>
<label for="createsampledata">Create sample data library</label>
</clr-checkbox-wrapper>
@if (createSampleData) {
<clr-control-helper class="mt-5">
Will create a <code>dcdemo</code> subfolder in the Data Controller
directory above, and configure it with a <code>DC_DEMO</code> libref.
</clr-control-helper>
}
</div>
@@ -76,11 +76,55 @@ describe('AutomaticComponent (Viya setup)', () => {
expect(requests[0][0]).toBe('services/admin/makedata')
expect(requests[0][1]).toEqual({
fromjs: [
{ ACTION: 'MAKEDATA', ADMIN: 'viyagroup18', DCPATH: '/export/dc' }
{
ACTION: 'MAKEDATA',
ADMIN: 'viyagroup18',
DCPATH: '/export/dc',
SAMPLEDATA: 'N'
}
]
})
})
it('leaves the sample data library off unless it is asked for', async () => {
// The option is opt in: the deploy screen's checkbox starts unticked, and
// the payload has to say so rather than leave makedata to guess.
const { component, requests } = build()
component.deployInNewWindow = false
await component.createDatabase()
expect(requests[0][1].fromjs[0].SAMPLEDATA).toBe('N')
})
it('asks for the sample data library when the box is ticked', async () => {
const { component, requests } = build()
component.deployInNewWindow = false
component.createSampleData = true
await component.createDatabase()
expect(requests[0][1].fromjs[0].SAMPLEDATA).toBe('Y')
})
it('carries the sample data choice into the new-window URL', () => {
// The new-window route does not post a table, so the flag has to be on the
// URL or the box would be ignored on that path.
const { component } = build()
const opened: string[] = []
spyOn(window, 'open').and.callFake((url?: any) => {
opened.push(String(url))
return null
})
spyOn(component as any, 'pollMakedataCompletion')
component.deployInNewWindow = true
component.createSampleData = true
component.createDatabase()
expect(opened[0]).toContain('&sampledata=Y')
})
it('runs the job in the configured context, on the web method', async () => {
const { component, requests } = build()
component.deployInNewWindow = false
@@ -164,7 +208,8 @@ describe('AutomaticComponent (Viya setup)', () => {
component.runMakedataInNewWindow({
contextName: 'SAS Job Execution & co',
admin: 'group 1+2',
dcPath: '/export/dc data'
dcPath: '/export/dc data',
createSampleData: false
})
expect(opened[0]).toContain('_contextname=SAS%20Job%20Execution%20%26%20co')
@@ -184,7 +229,8 @@ describe('AutomaticComponent (Viya setup)', () => {
component.runMakedataInNewWindow({
contextName: 'Compute Reusable',
admin: 'viyagroup18',
dcPath: '/export/dc'
dcPath: '/export/dc',
createSampleData: false
})
expect((component as any).makedataPollInterval).not.toBeNull()
@@ -323,3 +369,77 @@ describe('AutomaticComponent (Viya setup) field order', () => {
expect(picker().computeContextName).toBe('Compute Reusable')
})
})
/**
* The Viya screen's sample data option: at the bottom, and explained only once
* it is ticked. Rendered rather than asserted on the flag, because both are
* template behaviour.
*/
describe('AutomaticComponent sample data option', () => {
let fixture: ComponentFixture<AutomaticComponent>
const render = () => {
const sasService: any = {
getSasjsConfig: () => new SASjsConfig(),
getExecutionPath: () => '/SASJobExecution/',
shouldLogin: { next: () => undefined },
viyaMakedataSuccessfull: () => Promise.resolve(true),
updateFileContent: () => Promise.resolve()
}
TestBed.configureTestingModule({
declarations: [AutomaticComponent],
imports: [FormsModule],
providers: [
{
provide: EventService,
useValue: { showAbortModal: () => undefined }
},
{ provide: DeployService, useValue: {} },
{ provide: SasService, useValue: sasService },
{ provide: SasViyaService, useValue: {} },
{ provide: LoggerService, useValue: { error: () => undefined } },
{ provide: HelperService, useValue: {} }
],
schemas: [NO_ERRORS_SCHEMA]
})
fixture = TestBed.createComponent(AutomaticComponent)
// the screen loads its groups and contexts on init; this spec is about the
// template, so those calls are stubbed out
fixture.componentInstance.loadData = () => Promise.resolve()
fixture.detectChanges()
return fixture.componentInstance
}
afterEach(() => TestBed.resetTestingModule())
it('does not explain the option until it is ticked', () => {
const component = render()
expect(fixture.nativeElement.textContent).not.toContain('dcdemo subfolder')
component.createSampleData = true
fixture.detectChanges()
const text = fixture.nativeElement.textContent
expect(text).toContain('dcdemo subfolder')
expect(text).toContain('a DC_DEMO libref')
expect(text).not.toContain('the DC_DEMO libref')
})
it('puts the option below the Deploy button', () => {
render()
// the screen's own action, not the first button the template renders
const button = fixture.nativeElement.querySelector('.btn-autodeploy')
const checkbox = fixture.nativeElement.querySelector('#createsampledata')
expect(button).toBeTruthy()
expect(checkbox).toBeTruthy()
const follows =
button.compareDocumentPosition(checkbox) &
Node.DOCUMENT_POSITION_FOLLOWING
expect(follows).toBeTruthy()
})
})
@@ -426,6 +426,13 @@ export class AutomaticComponent implements OnInit, OnDestroy {
* services arrived with the sasjs deploy, so there is nothing else to deploy
* from here.
*/
/**
* The deploy screen's sample data option, off by default. When ticked the
* makedata service also builds the DC_DEMO library beside the control
* library, which gives a new deployment something to browse and edit.
*/
public createSampleData = false
public runAutoDeploy() {
if (!this.deployInNewWindow) this.autodeploying = true
@@ -441,7 +448,8 @@ export class AutomaticComponent implements OnInit, OnDestroy {
{
ACTION: 'MAKEDATA',
ADMIN: this.selectedAdminGroup,
DCPATH: this.dcPath
DCPATH: this.dcPath,
SAMPLEDATA: this.createSampleData ? 'Y' : 'N'
}
]
}
@@ -474,7 +482,8 @@ export class AutomaticComponent implements OnInit, OnDestroy {
this.runMakedataInNewWindow({
contextName: selectedComputeContextName,
admin: this.selectedAdminGroup,
dcPath: this.dcPath
dcPath: this.dcPath,
createSampleData: this.createSampleData
})
} else {
this.sasJs
@@ -530,6 +539,7 @@ export class AutomaticComponent implements OnInit, OnDestroy {
contextName: string
admin: string
dcPath: string
createSampleData: boolean
}) {
let serverUrl = this.sasjsConfig.serverUrl
let appLoc = this.sasjsConfig.appLoc
@@ -539,6 +549,7 @@ export class AutomaticComponent implements OnInit, OnDestroy {
let dcPath = `&dcpath=${encodeURIComponent(params.dcPath)}`
// makedata requires ACTION; MAKEDATA is the seeding run
let action = '&action=MAKEDATA'
let sampleData = `&sampledata=${params.createSampleData ? 'Y' : 'N'}`
// Debug is ALWAYS enabled for makedata — it runs only once during
// deployment and the full log is needed to diagnose any issues.
let debug = '&_debug=131'
@@ -553,6 +564,7 @@ export class AutomaticComponent implements OnInit, OnDestroy {
admin +
dcPath +
action +
sampleData +
debug
window.open(programUrl)
@@ -67,3 +67,29 @@
<li>SYSVLONG: {{ SYSVLONG }}</li>
</ul>
</div>
<!--
Optional sample data library. Deliberately the last thing on the screen: it
is a convenience for a new deployment that most administrators will not
want, so it sits below the screen's action rather than in the path to it.
The explanation is shown only once the box is ticked, and the box is what
makedata reads as SAMPLEDATA.
-->
<div class="d-flex flex-column mt-10">
<clr-checkbox-wrapper>
<input
clrCheckbox
type="checkbox"
id="createsampledata"
name="createsampledata"
[(ngModel)]="createSampleData"
/>
<label for="createsampledata">Create sample data library</label>
</clr-checkbox-wrapper>
@if (createSampleData) {
<clr-control-helper class="mt-5">
Will create a <code>dcdemo</code> subfolder in the Data Controller
directory above, and configure it with a <code>DC_DEMO</code> libref.
</clr-control-helper>
}
</div>
@@ -1,3 +1,4 @@
import { Location } from '@angular/common'
import { Component, NO_ERRORS_SCHEMA } from '@angular/core'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { FormsModule } from '@angular/forms'
@@ -58,6 +59,17 @@ describe('SasjsConfiguratorComponent (SAS9 / SASjs setup)', () => {
expect(opened[0]).toContain('path=%2Ftmp%2FDataController')
expect(opened[0]).toContain('action=MAKEDATA')
expect(opened[0]).toContain('_debug=131')
// the sample data library is opt in, so the URL says no until asked
expect(opened[0]).toContain('sampledata=N')
})
it('asks for the sample data library when the box is ticked', () => {
const { component, opened } = build(ServerType.Sas9)
component.createSampleData = true
component.makeData()
expect(opened[0]).toContain('sampledata=Y')
})
it('stays on the page origin for SAS9 rather than the configured server', () => {
@@ -229,3 +241,86 @@ describe('SasjsConfiguratorComponent field order', () => {
expect(admin).toBeLessThan(directory)
})
})
/**
* The sample data library option sits at the bottom of the setup screen, and
* the explanation appears only once the box is ticked. Both are template
* behaviour, so they are checked on a rendered component: a spec over the
* `createSampleData` flag would pass even with the `@if` deleted.
*/
describe('SasjsConfiguratorComponent sample data option', () => {
let fixture: ComponentFixture<SasjsConfiguratorComponent>
const render = () => {
const sasService: any = {
getExecutionPath: () => '/SASStoredProcess/do',
sasjsMakedataChecking: () => Promise.resolve(false),
logout: () => undefined,
request: () =>
Promise.resolve({
adapterResponse: {
MF_GETUSER: 'demo',
SYSUSERID: '0',
SYSHOSTNAME: 'host',
SYSVLONG: 'browser',
SYSSCPL: 'LINUX',
groups: [{ GROUPNAME: 'AllUsers', GROUPDESC: 'everyone' }]
}
})
}
const sasjsService: any = {
getServerInfo: () => ({ subscribe: () => undefined })
}
TestBed.configureTestingModule({
declarations: [SasjsConfiguratorComponent],
imports: [FormsModule],
providers: [
{ provide: SasService, useValue: sasService },
{ provide: SasjsService, useValue: sasjsService },
{ provide: Location, useValue: { replaceState: () => undefined } }
],
schemas: [NO_ERRORS_SCHEMA]
})
fixture = TestBed.createComponent(SasjsConfiguratorComponent)
fixture.detectChanges()
return fixture.componentInstance
}
afterEach(() => TestBed.resetTestingModule())
it('does not explain the option until it is ticked', () => {
const component = render()
expect(fixture.nativeElement.textContent).not.toContain('dcdemo subfolder')
component.createSampleData = true
fixture.detectChanges()
const text = fixture.nativeElement.textContent
expect(text).toContain('dcdemo subfolder')
expect(text).toContain('a DC_DEMO libref')
expect(text).not.toContain('the DC_DEMO libref')
})
it('puts the option below the screen action', () => {
render()
// the screen's SUBMIT button, not the first button the template renders
const buttons = Array.from(
fixture.nativeElement.querySelectorAll('button')
) as any[]
const button = buttons.find((b: any) => b.textContent.trim() === 'SUBMIT')
const checkbox = fixture.nativeElement.querySelector('#createsampledata')
expect(button).toBeTruthy()
expect(checkbox).toBeTruthy()
// the box is the last thing on the screen, not something in the path of
// the SUBMIT button
const follows =
button.compareDocumentPosition(checkbox) &
Node.DOCUMENT_POSITION_FOLLOWING
expect(follows).toBeTruthy()
})
})
@@ -33,6 +33,12 @@ export class SasjsConfiguratorComponent implements OnInit {
dcDirectory: string = ''
dcAdminGroup: string = 'DCDEFAULT'
/**
* The sample data option, off by default. makedata builds the DC_DEMO
* library beside the control library when this is on.
*/
createSampleData: boolean = false
METAPERSON: string = 'n/a'
METAUSER: string = 'n/a'
SYSUSERID: string = 'n/a'
@@ -145,11 +151,13 @@ export class SasjsConfiguratorComponent implements OnInit {
dcDirectory = encodeURIComponent(this.dcDirectory)
}
const sampledata = `&sampledata=${this.createSampleData ? 'Y' : 'N'}`
const url = `${
serverUrl ? serverUrl : ''
}${executor}?_program=${root}/services/admin/makedata&action=MAKEDATA&admin=${
this.dcAdminGroup
}&path=${dcDirectory}${_debug}`
}&path=${dcDirectory}${sampledata}${_debug}`
window.open(url, '_blank')
}
+158
View File
@@ -113,6 +113,23 @@ let brAction = brValue(brRows[0], 'ACTION')
if (!brAction && typeof action !== 'undefined') brAction = action
const ACTION = String(brAction || '').trim().toUpperCase()
/*
SAMPLEDATA is the deploy screen's "create a sample data library" checkbox.
It is off unless asked for, and arrives the two ways ACTION does - a column
of the input table, or a URL parameter. The SAS makedata services resolve it
inline the same way, from the same list of values.
*/
const brSampleValue = brValue(brRows[0], 'SAMPLEDATA')
const SAMPLE_ON =
['1', 'Y', 'YES', 'TRUE'].indexOf(
String(
brSampleValue ||
(typeof sampledata !== 'undefined' ? sampledata : '')
)
.trim()
.toUpperCase()
) >= 0
try {
if (!ACTION) {
@@ -5837,6 +5854,137 @@ const tableIndexes = {
MPE_X_TEST: { name: 'primary_key_field', columns: ['primary_key_field'] },
}
/*
The sample data library (DC_DEMO) is built by mpeMakesampledata, which is
defined below the control-library write: like the SAS macro of the same name
it appends its registrations to mpe_tables, so the table has to be on disk
first.
*/
/*
---- mpe_makesampledata ------------------------------------------------
The SAS macro of the same name, transcribed: the same three tables, the same
rows, the same registrations in MPE_TABLES, and the same three arguments
(lib, path, iftrue). The rows are generated from the rule the macro's
docblock records rather than copied from SASHELP - that is what lets the two
estates be compared row for row, which makedata-sampledata.test.js does.
*/
function mpeMakesampledata(lib, path, iftrue) {
if (!iftrue) return
const demoDir = nodePath.resolve(path, 'DC_DEMO')
fs.mkdirSync(demoDir, { recursive: true })
/* STUDENTS - the small all-purpose table */
const students = []
for (let n = 1; n <= 20; n++) {
students.push({
id: n,
name: 'student ' + String(n).padStart(2, '0'),
sex: n % 2 ? 'M' : 'F',
age: 12 + (n % 7),
height: 50 + n,
weight: 80 + n
})
}
/* VEHICLES - a list of values, for filters and dropdowns */
const makes = ['Audi', 'BMW', 'Ford', 'Honda', 'Toyota']
const types = ['Sedan', 'SUV', 'Wagon']
const vehicles = []
for (let n = 1; n <= 30; n++) {
const make = makes[(n - 1) % 5]
vehicles.push({
id: n,
make: make,
model: 'Model ' + String(n).padStart(2, '0'),
type: types[(n - 1) % 3],
origin: ['Audi', 'BMW'].indexOf(make) >= 0 ? 'Europe' : 'Asia',
drivetrain: n % 2 ? 'Front' : 'Rear',
msrp: 20000 + n * 750
})
}
/* FORMATS - 01JAN2026 and that date at 13:45:00, as SAS values */
const formats = [{ pk: 1, e8601da: 24107, dtm: 2082894300 }]
const demoSchema = {
STUDENTS: [
{ name: 'id', type: 'N', length: 8, format: '', label: '', notnull: true },
{ name: 'name', type: 'C', length: 20, format: '', label: '', notnull: false },
{ name: 'sex', type: 'C', length: 1, format: '', label: '', notnull: false },
{ name: 'age', type: 'N', length: 8, format: '', label: '', notnull: false },
{ name: 'height', type: 'N', length: 8, format: '', label: '', notnull: false },
{ name: 'weight', type: 'N', length: 8, format: '', label: '', notnull: false }
],
VEHICLES: [
{ name: 'id', type: 'N', length: 8, format: '', label: '', notnull: true },
{ name: 'make', type: 'C', length: 10, format: '', label: '', notnull: false },
{ name: 'model', type: 'C', length: 12, format: '', label: '', notnull: false },
{ name: 'type', type: 'C', length: 10, format: '', label: '', notnull: false },
{ name: 'origin', type: 'C', length: 6, format: '', label: '', notnull: false },
{ name: 'drivetrain', type: 'C', length: 5, format: '', label: '', notnull: false },
{ name: 'msrp', type: 'N', length: 8, format: '', label: '', notnull: false }
],
FORMATS: [
{ name: 'pk', type: 'N', length: 8, format: '', label: '', notnull: true },
{ name: 'e8601da', type: 'N', length: 8, format: 'E8601DA10.', label: '', notnull: false },
{ name: 'dtm', type: 'N', length: 8, format: 'E8601DT19.', label: '', notnull: false }
]
}
const demo = { STUDENTS: students, VEHICLES: vehicles, FORMATS: formats }
for (const [tableName, rows] of Object.entries(demo)) {
fs.writeFileSync(
nodePath.resolve(demoDir, tableName.toLowerCase() + '.json'),
JSON.stringify(
{
name: tableName,
columns: demoSchema[tableName],
uniqueIndex: null,
rows: rows
},
null,
2
)
)
}
/* proc append base=&lib..mpe_tables data=append */
const controlFile = nodePath.resolve(path, lib, 'mpe_tables.json')
const control = JSON.parse(fs.readFileSync(controlFile, 'utf8'))
control.rows.push(
{
tx_from: 0,
tx_to: 253717919999,
libref: 'DC_DEMO',
dsn: 'STUDENTS',
num_of_approvals_required: 1,
loadtype: 'UPDATE',
buskey: 'ID'
},
{
tx_from: 0,
tx_to: 253717919999,
libref: 'DC_DEMO',
dsn: 'VEHICLES',
num_of_approvals_required: 1,
loadtype: 'UPDATE',
buskey: 'MODEL'
},
{
tx_from: 0,
tx_to: 253717919999,
libref: 'DC_DEMO',
dsn: 'FORMATS',
num_of_approvals_required: 1,
loadtype: 'UPDATE',
buskey: 'PK'
}
)
fs.writeFileSync(controlFile, JSON.stringify(control, null, 2))
}
// Write one JSON file per table with metadata + rows
for (const [tableName, rows] of Object.entries(tables)) {
const tableFile = nodePath.resolve(dataDir, tableName.toLowerCase() + '.json')
@@ -5848,6 +5996,16 @@ for (const [tableName, rows] of Object.entries(tables)) {
}
fs.writeFileSync(tableFile, JSON.stringify(tableData, null, 2))
}
/*
makedata calls the sample library where the SAS service does - after the
control tables exist - and gated on the same flag (iftrue=&sampledata=1).
*/
mpeMakesampledata(
dcLibref,
nodePath.resolve(dataDir, '..'),
SAMPLE_ON
)
// Write settings (what the real settings.sas would contain) as a JS module at
// services/settings.js, mirroring how makedata.sas writes
// services/public/settings.sas on the real backend. Services eval() it to
+214
View File
@@ -0,0 +1,214 @@
/**
* The mock makedata's sample data library (SAMPLEDATA).
*
* The deploy screens offer a checkbox - off by default - that asks makedata
* for a sample data library beside the control library. mpe_makesampledata creates
* it on a SAS estate; the mock has to create the same thing, from the same
* rule, or a Cypress run against the JS estate would see a different library
* from the one a customer's deployment gets.
*
* The cases below run the shipping service file against a temporary estate
* (see harness.js) and then read the files it wrote, so this is the mock that
* is tested, not a copy of its logic.
*
* 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 { makeEstate, runService, readTable } = require('./harness')
const runMakedata = (estate, options) =>
runService(estate, 'admin/makedata', options)
/** The folder mpe_makesampledata creates, where a libref is a folder on this estate. */
const demoDir = (estate) => path.join(estate.appDir, 'data', 'DC_DEMO')
function demoTable(estate, name) {
const file = path.join(demoDir(estate), name.toLowerCase() + '.json')
return JSON.parse(fs.readFileSync(file, 'utf8'))
}
function demoRegistrations(estate) {
return readTable(estate, 'MPE_TABLES').filter(
(row) => String(row.libref).toUpperCase() === 'DC_DEMO'
)
}
const seed = (estate, rows) =>
runMakedata(estate, { rows: rows || [{ ACTION: 'MAKEDATA' }] })
test('the sample data library is not created unless it is asked for', () => {
const estate = makeEstate()
seed(estate)
assert.ok(
!fs.existsSync(demoDir(estate)),
'a deployment that did not ask for sample data must not get a DC_DEMO folder'
)
assert.deepEqual(demoRegistrations(estate), [], 'and no DC_DEMO registrations')
})
test('an explicit N leaves the option off', () => {
const estate = makeEstate()
seed(estate, [{ ACTION: 'MAKEDATA', SAMPLEDATA: 'N' }])
assert.ok(!fs.existsSync(demoDir(estate)))
assert.deepEqual(demoRegistrations(estate), [])
})
test('a value nobody documents leaves the option off', () => {
// A stray parameter must not build a library the deployment did not ask for,
// which is also what the SAS makedata services decide.
const estate = makeEstate()
seed(estate, [{ ACTION: 'MAKEDATA', SAMPLEDATA: 'BANANA' }])
assert.ok(!fs.existsSync(demoDir(estate)))
})
test('the URL parameter route turns the option on too', () => {
// SAS9 and SASjs Server open a URL rather than posting a table, so the flag
// arrives as a parameter. The SAS service reads both routes.
const estate = makeEstate()
runMakedata(estate, {
params: { action: 'MAKEDATA', admin: 'AllUsers', sampledata: 'Y' }
})
assert.ok(
fs.existsSync(path.join(demoDir(estate), 'students.json')),
'the parameter must be enough to create the library'
)
})
test('ticking the box creates the three demo tables', () => {
const estate = makeEstate()
seed(estate, [{ ACTION: 'MAKEDATA', SAMPLEDATA: 'Y' }])
const classTable = demoTable(estate, 'STUDENTS')
const carsTable = demoTable(estate, 'VEHICLES')
const fmtTable = demoTable(estate, 'FORMATS')
assert.equal(classTable.rows.length, 20)
assert.equal(carsTable.rows.length, 30)
assert.equal(fmtTable.rows.length, 1)
assert.deepEqual(Object.keys(classTable.rows[0]), [
'id',
'name',
'sex',
'age',
'height',
'weight'
])
assert.deepEqual(classTable.rows[0], {
id: 1,
name: 'student 01',
sex: 'M',
age: 13,
height: 51,
weight: 81
})
assert.deepEqual(classTable.rows[1], {
id: 2,
name: 'student 02',
sex: 'F',
age: 14,
height: 52,
weight: 82
})
// MAKE and MODEL cycle independently of TYPE, so the last row is the fifth
// make and the third type
assert.deepEqual(carsTable.rows[4], {
id: 5,
make: 'Toyota',
model: 'Model 05',
type: 'SUV',
origin: 'Asia',
drivetrain: 'Front',
msrp: 23750
})
assert.equal(carsTable.rows[0].origin, 'Europe', 'Audi is a European make')
assert.equal(carsTable.rows[5].drivetrain, 'Rear', 'even rows are rear wheel')
// 01JAN2026 and 01JAN2026:13:45:00 as the SAS day and second counts, which is
// what the tables hold - the formats only change how they are shown
assert.deepEqual(fmtTable.rows[0], {
pk: 1,
e8601da: 24107,
dtm: 2082894300
})
assert.equal(fmtTable.columns[1].format, 'E8601DA10.')
assert.equal(fmtTable.columns[2].format, 'E8601DT19.')
})
test('the demo tables are registered so DC can find them', () => {
const estate = makeEstate()
seed(estate, [{ ACTION: 'MAKEDATA', SAMPLEDATA: 'Y' }])
const registrations = demoRegistrations(estate)
assert.equal(registrations.length, 3)
for (const row of registrations) {
assert.equal(row.loadtype, 'UPDATE')
assert.equal(row.num_of_approvals_required, 1)
assert.equal(row.tx_from, 0)
assert.equal(row.tx_to, 253717919999, '31DEC9999:23:59:59 as a SAS datetime')
}
assert.deepEqual(
registrations.map((row) => [row.dsn, row.buskey]),
[
['STUDENTS', 'ID'],
['VEHICLES', 'MODEL'],
['FORMATS', 'PK']
]
)
})
test('the registration rows are the ones mpe_makesampledata appends', () => {
// The mock estate is meant to be the same estate the SAS service builds, so
// the registrations are read out of the macro rather than restated here: a
// change to either side that is not made on the other fails this test.
const macro = fs.readFileSync(
path.join(__dirname, '..', '..', 'sasjs', 'macros', 'mpe_makesampledata.sas'),
'utf8'
)
const fromMacro = []
const re = /dsn='([A-Z0-9_]+)'; buskey='([^']+)';/g
let match
while ((match = re.exec(macro)) !== null) {
fromMacro.push([match[1], match[2]])
}
assert.equal(fromMacro.length, 3, 'three registrations in the macro')
const estate = makeEstate()
seed(estate, [{ ACTION: 'MAKEDATA', SAMPLEDATA: 'Y' }])
const fromMock = demoRegistrations(estate).map((row) => [
row.dsn,
row.buskey
])
assert.deepEqual(fromMock, fromMacro)
// and the macro must build the same three tables the mock writes
const macroTables = []
const tableRe = /data dcdemo\.([a-z0-9_]+);/g
while ((match = tableRe.exec(macro)) !== null) {
macroTables.push(match[1].toUpperCase())
}
assert.deepEqual(macroTables.sort(), [
'FORMATS',
'STUDENTS',
'VEHICLES'
])
})
+130 -46
View File
@@ -1,68 +1,159 @@
/**
@file mpe_makesampledata.sas
@brief Creates sample data for DC and updates MPE_TABLES
@details Creates sample data for DC.
@brief Creates the sample data library DC_DEMO
@details
Called by makedata when the deployment asked for sample data - the
SAMPLEDATA flag makedata resolves from the deploy screen's checkbox, off by
default. It creates `<path>/dcdemo`,
assigns it as DC_DEMO, writes three tables there and registers them in
MPE_TABLES, so a new deployment has something to browse, filter and edit
without pointing DC at a site's own data.
Usage:
The rows are generated from a fixed rule, not copied from SASHELP, for two
reasons: SASHELP is not the same on every platform, and the JS estate
(sas/mocks) has no SASHELP at all. The mock's mpeMakesampledata writes the
same tables from the same rule, and sas/mocks/test/makedata-sampledata.test.js
compares the two - a change here has to be made there.
%mpe_makesampledata(outlib=DCxxxx)
The rule, for the tables below (n is the row number, 1-based):
@li STUDENTS - 20 rows: NAME=student nn, SEX=M on odd n, AGE=12+mod(n,7),
HEIGHT=50+n, WEIGHT=80+n
@li VEHICLES - 30 rows: MAKE cycles Audi/BMW/Ford/Honda/Toyota,
MODEL=Model nn, TYPE cycles Sedan/SUV/Wagon, ORIGIN=Europe for Audi and
BMW else Asia, DRIVETRAIN=Front on odd n, MSRP=20000+n*750
@li FORMATS - 1 row: PK=1, E8601DA=01JAN2026, DTM=01JAN2026:13:45:00
The dates are recorded as their SAS values (24107 and 2082894300), which is
what the tables hold, so the two estates can be compared row for row.
On the 4GL demo estate (tested below with SYSSITE) the macro also adds that
estate's row level security, excel config and security rows. Those name
metadata groups that exist only there, so every other deployment gets the
three tables and nothing else.
@param lib= The control library - mpe_tables is updated here.
@param path= The DC location. The library is created below it, as dcdemo.
@param iftrue= The caller's flag, defaulting to 0: the macro builds nothing
unless it is passed a true value. Fails closed, so a call that forgets the
argument - or a site that has not opted in - cannot create the library.
<h4> SAS Macros </h4>
@li mf_mkdir.sas
@li mf_trimstr.sas
@li mp_abort.sas
@version 9.2
@author 4GL Apps Ltd
@copyright 4GL Apps Ltd. This code may only be used within Data Controller
and may not be re-distributed or re-sold without the express permission of
4GL Apps Ltd.
@sasjslint {"maxLineLength": 80}
**/
%macro mpe_makesampledata(outlib=);
%if &syscc ne 0 %then %do;
%put syscc=&syscc exiting &sysmacroname;
%macro mpe_makesampledata(lib=,path=,iftrue=0);
%if not (&iftrue) %then %return;
%if &syscc ne 0 %then %return;
%if %superq(lib)=%str() %then %do;
%put NOTE: &sysmacroname needs LIB - no sample data created;
%return;
%end;
%if &syssite ne 70221618 and &syssite ne 70253615 %then %do;
%put syssite=&syssite, exiting &sysmacroname;
%if %superq(path)=%str() %then %do;
%put NOTE: &sysmacroname needs PATH - no sample data created;
%return;
%end;
data &outlib..class(index=(name /unique));
set sashelp.class;
%local demo_path;
%let demo_path=%mf_trimstr(&path,/)/dcdemo;
%mf_mkdir(&demo_path)
%mp_abort(iftrue= (&syscc ne 0)
,mac=&_program
,msg=%str(Unable to create &demo_path using &sysuserid)
)
libname dcdemo "&demo_path";
%mp_abort(iftrue= (&syscc ne 0)
,mac=&_program
,msg=%str(Unable to assign DC_DEMO to &demo_path)
)
/* STUDENTS - the small all-purpose table */
data dcdemo.students;
length name $20 sex $1;
do id=1 to 20;
name='student '!!put(id,z2.);
if mod(id,2) then sex='M';
else sex='F';
age=12+mod(id,7);
height=50+id;
weight=80+id;
output;
end;
run;
data &outlib..cars(index=(carspk=(make model drivetrain) /unique));
set sashelp.cars;
/* VEHICLES - a list of values, for filters and dropdowns */
data dcdemo.vehicles;
length make $10 model $12 type $10 origin $6 drivetrain $5;
array _makes[5] $10 _temporary_
('Audi' 'BMW' 'Ford' 'Honda' 'Toyota');
array _types[3] $10 _temporary_ ('Sedan' 'SUV' 'Wagon');
do id=1 to 30;
make=_makes[1+mod(id-1,5)];
model='Model '!!put(id,z2.);
type=_types[1+mod(id-1,3)];
if make in ('Audi','BMW') then origin='Europe';
else origin='Asia';
if mod(id,2) then drivetrain='Front';
else drivetrain='Rear';
msrp=20000+(id*750);
output;
end;
run;
data &outlib..springs(index=(springspk=(name area latitude) /unique));
set sashelp.springs;
/* FORMATS - one row carrying the date formats the viewer renders */
data dcdemo.formats;
pk=1;
e8601da=24107;
dtm=2082894300;
format e8601da e8601da10. dtm e8601dt19.;
run;
data &outlib..fmt_checks;;
pk=1; E8601DA=date();
format E8601DA E8601DA10.;
run;
%mp_abort(iftrue= (&syscc ne 0)
,mac=&_program
,msg=%str(Problem creating the DC_DEMO tables in &demo_path)
)
/* register the tables so the EDIT and VIEW screens can find them */
data append;
if 0 then set &dc_libref..mpe_tables;
TX_FROM=0;
TX_TO='31DEC9999:23:59:59'dt;
LIBREF=%upcase("&outlib");
LOADTYPE='UPDATE';
NUM_OF_APPROVALS_REQUIRED=1;
DSN='SPRINGS'; BUSKEY='NAME AREA LATITUDE'; output;
DSN='CARS'; BUSKEY='MAKE MODEL DRIVETRAIN'; output;
DSN='CLASS'; BUSKEY='NAME'; output;
DSN='FMT_CHECKS'; BUSKEY='PK'; output;
if 0 then set &lib..mpe_tables;
tx_from=0;
tx_to=253717919999;
libref='DC_DEMO';
loadtype='UPDATE';
num_of_approvals_required=1;
dsn='STUDENTS'; buskey='ID'; output;
dsn='VEHICLES'; buskey='MODEL'; output;
dsn='FORMATS'; buskey='PK'; output;
run;
proc append base=&dc_libref..MPE_TABLES data=&syslast;
proc append base=&lib..mpe_tables data=append;
run;
/**
* DC data extra
*/
%local lib;
%let lib=&dc_libref;
%mp_abort(iftrue= (&syscc ne 0)
,mac=&_program
,msg=%str(Problem registering the DC_DEMO tables in &lib..mpe_tables)
)
/*
The 4GL demo estate's own configuration. The body below is not indented: it
is long SQL that already runs to the 80 column limit, and re-indenting it
would break the lint gate for no gain.
*/
%if &syssite=70221618 or &syssite=70253615 %then %do;
proc sql;
insert into &lib..mpe_row_level_security set
tx_from=0
@@ -347,16 +438,9 @@ insert into &lib..mpe_security set
,sas_group="303001.DataController"
,tx_to='31DEC5999:23:59:59'dt;
data append;
if 0 then set &dc_libref..mpe_tables;
TX_FROM=0;
TX_TO='31DEC9999:23:59:59'dt;
LIBREF=%upcase("&dc_libref");
LOADTYPE='UPDATE';
NUM_OF_APPROVALS_REQUIRED=1;
DSN='MPE_USERS'; BUSKEY='USER_ID'; output;
run;
proc append base=&dc_libref..MPE_TABLES data=&syslast;
run;
%end;
/* leave no libref behind - makedata writes the assignment into settings */
libname dcdemo clear;
%mend mpe_makesampledata;
@@ -0,0 +1,175 @@
/**
@file
@brief Testing mpe_makesampledata macro
<h4> SAS Macros </h4>
@li mf_mkdir.sas
@li mp_assert.sas
@li mpe_makesampledata.sas
@author 4GL Apps Ltd
@copyright 4GL Apps Ltd. This code may only be used within Data Controller
and may not be re-distributed or re-sold without the express permission of
4GL Apps Ltd.
@sasjslint {"maxLineLength": 80}
**/
%let dc_sample_root=%sysfunc(pathname(work))/mpe_makesampledata_test;
%mf_mkdir(&dc_sample_root)
libname dcsample "&dc_sample_root";
/* a control library holding only the table mpe_makesampledata appends to */
data dcsample.mpe_tables;
length libref $8 dsn $32 buskey $200 loadtype $10;
tx_from=0;
tx_to=0;
num_of_approvals_required=0;
libref='';
dsn='';
buskey='';
loadtype='';
delete;
run;
/**
* Test 1 - the option is off unless it is asked for: iftrue defaults to 0, so
* a call that omits the argument builds nothing. This is what makes the flag
* safe to add to a deployment that knows nothing about it.
*/
%mpe_makesampledata(lib=dcsample,path=%str(&dc_sample_root))
%let dc_sample_dir=&dc_sample_root/dcdemo;
%let dc_exists=0;
data _null_;
if fileexist("&dc_sample_dir") then call symputx('dc_exists',1);
run;
%mp_assert(
iftrue=("&dc_exists"="0"),
desc=The library is not created when the flag is off,
outds=work.test_results
)
/**
* Test 2 - asking for it creates the library and the three tables
*/
%mpe_makesampledata(lib=dcsample,path=%str(&dc_sample_root),iftrue=1)
%let dc_exists=0;
data _null_;
if fileexist("&dc_sample_dir") then call symputx('dc_exists',1);
run;
%mp_assert(
iftrue=("&dc_exists"="1"),
desc=The library is created when the flag is on,
outds=work.test_results
)
libname dcdemo2 "&dc_sample_dir";
%let dc_students=0;
%let dc_vehicles=0;
%let dc_formats=0;
proc sql noprint;
select count(*) into :dc_students from dcdemo2.students;
select count(*) into :dc_vehicles from dcdemo2.vehicles;
select count(*) into :dc_formats from dcdemo2.formats;
quit;
%mp_assert(
iftrue=(&dc_students=20),
desc=The student table holds twenty rows,
outds=work.test_results
)
%mp_assert(
iftrue=(&dc_vehicles=30),
desc=The vehicle table holds thirty rows,
outds=work.test_results
)
%mp_assert(
iftrue=(&dc_formats=1),
desc=The format checks table holds one row,
outds=work.test_results
)
/**
* Test 3 - and the rows are the ones the rule documents, which is what the
* mock transcribes. A change to either side has to be made on the other.
*/
%let dc_ok=0;
data _null_;
set dcdemo2.students(where=(id=2));
if name='student 02' and sex='F' and age=14 and height=52 and weight=82
then call symputx('dc_ok',1);
run;
%mp_assert(
iftrue=(&dc_ok=1),
desc=The generated student row is the documented one,
outds=work.test_results
)
%let dc_ok=0;
data _null_;
set dcdemo2.vehicles(where=(id=5));
if make='Toyota' and model='Model 05' and type='SUV'
and origin='Asia' and drivetrain='Front' and msrp=23750
then call symputx('dc_ok',1);
run;
%mp_assert(
iftrue=(&dc_ok=1),
desc=The generated vehicle row is the documented one,
outds=work.test_results
)
%let dc_ok=0;
data _null_;
set dcdemo2.formats;
/* 01JAN2026 and 01JAN2026:13:45:00 as SAS values */
if pk=1 and e8601da=24107 and dtm=2082894300
then call symputx('dc_ok',1);
run;
%mp_assert(
iftrue=(&dc_ok=1),
desc=The format checks row carries the documented dates,
outds=work.test_results
)
/**
* Test 4 - the tables are registered in MPE_TABLES, or DC cannot browse them
*/
%let dc_regs=0;
%let dc_bad=0;
proc sql noprint;
select count(*) into :dc_regs
from dcsample.mpe_tables
where libref='DC_DEMO';
select count(*) into :dc_bad
from dcsample.mpe_tables
where libref='DC_DEMO'
and not ((dsn='STUDENTS' and buskey='ID')
or (dsn='VEHICLES' and buskey='MODEL')
or (dsn='FORMATS' and buskey='PK'));
quit;
%mp_assert(
iftrue=(&dc_regs=3),
desc=The three demo tables are registered,
outds=work.test_results
)
%mp_assert(
iftrue=(&dc_bad=0),
desc=Each registration carries its business key,
outds=work.test_results
)
/**
* Test 5 - a caller with no control library is told, not left with a half
* built library: the macro does what it can and reports through syscc
*/
%let dc_cc=0;
%mpe_makesampledata(lib=,path=%str(&dc_sample_root))
%let dc_cc=&syscc;
%mp_assert(
iftrue=(&dc_cc=0),
desc=A missing control library is skipped rather than failing the deploy,
outds=work.test_results
)
libname dcdemo2 clear;
libname dcsample clear;
@@ -24,6 +24,7 @@
@li mm_getstpinfo.sas
@li mp_abort.sas
@li mp_init.sas
@li mpe_makesampledata.sas
@li mpe_makedata.sas
@li mpe_makedatamodel.sas
@@ -37,7 +38,7 @@
%mp_init()
%global admin repo path; /* URL params from configurator */
%global admin repo path sampledata; /* URL params from configurator */
%let repo=%sysfunc(coalescec(&repo,Foundation));
%let admin=%sysfunc(coalescec(&admin,dc-admin));
%let dcpath=%sysfunc(coalescec(&path,NOTFOUND));
@@ -47,6 +48,25 @@
,msg=%str(PATH variable was not provided)
)
/**
* Resolves the optional SAMPLEDATA parameter of this STP
*
* This target is reached by opening a URL, so the value arrives as a macro
* variable: the flag is settled with macro logic alone - no data step, and
* nothing opened on a run that does not ask for the library. A value outside
* the list is off, so an absent or misspelt parameter builds nothing.
*/
%macro resolve_sampledata();
%global sampledata;
%let sample_in=N;
%if %symexist(sampledata) %then %let sample_in=%upcase(&sampledata);
%let sampledata=0;
%if &sample_in=1 or &sample_in=Y or &sample_in=YES or &sample_in=TRUE
%then %let sampledata=1;
%mend resolve_sampledata;
%resolve_sampledata()
%let root=%mf_getapploc();
%let work=%sysfunc(pathname(work));
%let dc_libref=%upcase(DC%substr(%sysevalf(%sysfunc(datetime())/60),3,6));
@@ -136,6 +156,13 @@ libname &dc_libref "&dcpath";
SYSWARNINGTEXT=&SYSWARNINGTEXT)
)
/* sample data library - only when the deployment asked for it */
%mpe_makesampledata(lib=&dc_libref,path=%str(&dcpath),iftrue=(&sampledata=1))
%mp_abort(iftrue= (&syscc ne 0)
,mac=&_program..sas
,msg=%str(Problem creating the DC_DEMO library)
)
/* register tables in metadata */
proc metalib;
omr (library="&DC_LIBNAME");
@@ -169,6 +196,12 @@ data _null_;
put ' ';
put 'libname &DC_LIBREF "&dc_libloc";';
put ' ';
/* the sample data library, when this deployment asked for one */
if symget('sampledata')='1' then do;
put '/* sample data library, created by this deployment */';
put "libname DC_DEMO '&dcpath/dcdemo';";
put ' ';
end;
put '/* This metadata group has unrestricted access to Data Controller */';
put '%let dc_admin_group=' "&admin;";
put ' ';
@@ -32,6 +32,7 @@
@li mpe_action.sas
@li mpe_dirlist.sas
@li mpe_writecheck.sas
@li mpe_makesampledata.sas
@li mpe_makedata.sas
@li mpe_makedatamodel.sas
@li ms_createfile.sas
@@ -45,7 +46,7 @@
**/
%global path ADMIN OUTFMT keepme action;
%global path ADMIN OUTFMT keepme action sampledata;
/* response format - HTML (default) or JSON */
%let OUTFMT=HTML;
@@ -162,6 +163,28 @@ data _null_;
stop;
run;
/* optional SAMPLEDATA flag - build the DC_DEMO library as well. Off unless
the deployment asks for it. The parameter is read first and macro logic
settles it; the adapter's column is read only when the input table carries
one, so a route that does not send it compiles no step for it. Anything
outside the list below is off. */
%let sample_in=N;
%if %symexist(sampledata) %then %let sample_in=%upcase(&sampledata);
%let sampledata=0;
%if &sample_in=1 or &sample_in=Y or &sample_in=YES or &sample_in=TRUE
%then %let sampledata=1;
%if &exist=1 %then %do;
%let _dsid=%sysfunc(open(work.fromjs));
%if %sysfunc(varnum(&_dsid,SAMPLEDATA)) ne 0 %then %do;
data _null_;
set work.fromjs(obs=1);
if upcase(strip(cats(sampledata))) in ('1','Y','YES','TRUE')
then call symputx('sampledata',1);
run;
%end;
%let _rc=%sysfunc(close(&_dsid));
%end;
%mp_abort(iftrue= (&syscc ne 0)
,mac=&_program
,msg=%str(Issue on makedata entry)
@@ -214,6 +237,13 @@ run;
,msg=%str(Problem creating tables in &DC_LIBREF library)
)
/* sample data library - only when the deployment asked for it */
%mpe_makesampledata(lib=&dc_libref,path=%str(&dcpath),iftrue=(&sampledata=1))
%mp_abort(iftrue= (&syscc ne 0)
,mac=&_program..sas
,msg=%str(Problem creating the DC_DEMO library)
)
/* finally, update the app component. The user will need WM perms for this. */
filename stngs temp;
data _null_;
@@ -228,6 +258,11 @@ data _null_;
put ' ';
put "libname &dc_libref '&dcpath' ;";
put ' ';
%if &sampledata=1 %then %do;
put '/* sample data library, created by this deployment */';
put "libname DC_DEMO '&dcpath/dcdemo' ;";
put ' ';
%end;
put '/* This physical location is used for staging data and audit history */';
put '%let dc_staging_area=' "&dcpath/dc_staging;";
put ' ';
@@ -43,7 +43,7 @@
**/
%global dcpath ADMIN _contextname keepme action;
%global dcpath ADMIN _contextname keepme action sampledata;
%webout(FETCH)
@@ -113,6 +113,28 @@ data _null_;
stop;
run;
/* optional SAMPLEDATA flag - build the DC_DEMO library as well. Off unless
the deployment asks for it. The parameter is read first and macro logic
settles it; the adapter's column is read only when the input table carries
one, so a route that does not send it compiles no step for it. Anything
outside the list below is off. */
%let sample_in=N;
%if %symexist(sampledata) %then %let sample_in=%upcase(&sampledata);
%let sampledata=0;
%if &sample_in=1 or &sample_in=Y or &sample_in=YES or &sample_in=TRUE
%then %let sampledata=1;
%if &exist=1 %then %do;
%let _dsid=%sysfunc(open(work.fromjs));
%if %sysfunc(varnum(&_dsid,SAMPLEDATA)) ne 0 %then %do;
data _null_;
set work.fromjs(obs=1);
if upcase(strip(cats(sampledata))) in ('1','Y','YES','TRUE')
then call symputx('sampledata',1);
run;
%end;
%let _rc=%sysfunc(close(&_dsid));
%end;
data _null_;
set &inds;
call symputx('dcpath',dcpath);
@@ -175,10 +197,9 @@ libname &dclib "&dcpath";
)
/* sample data library */
%mf_mkdir(&dcpath/dc_demo)
libname dcdemo "&dcpath/dc_demo";
%mpe_makesampledata(outlib=DCDEMO)
/* sample data library - only when the deployment asked for it */
%mpe_makesampledata(lib=&dclib,path=%str(&dcpath),iftrue=(&sampledata=1))
%mp_abort(iftrue=(&syscc ne 0)
,mac=&sysmacroname
@@ -211,9 +232,11 @@ data _null_;
put 'cas dcsession sessopts=(caslib=casuser);';
put 'caslib _all_ assign;';
put ' ';
if &syssite in (70221618,70253615) then do;
put "libname dcdemo '&dcpath/dc_demo';";
end;
%if &sampledata=1 %then %do;
put '/* sample data library */';
put "libname DC_DEMO '&dcpath/dcdemo';";
put ' ';
%end;
run;