Compare commits

...
Author SHA1 Message Date
dc e4ec4ec629 fix(editor): assign columnHeader before the grid renders
Build / Build-and-ng-test (pull_request) Successful in 5m41s
Lighthouse Checks / lighthouse (pull_request) Successful in 20m53s
Build / Build-and-test-development (pull_request) Failing after 33m28s
The derived-block marker (dc-derived-first) landed on the wrong column, so the
computed columns did not read as a group and derived-variables.cy.ts failed on
the header assertion.

initSetup reversed the order it needs: hot.updateSettings() installs
afterGetColHeader / afterGetCellMeta at line ~4167 and renders, but
this.columnHeader was only assigned at line ~4506 - after that render. The
hooks therefore resolved visual column indexes against the previous table's
list, so 'is this the first derived column' compared an index in one array
against an index in another. The header name check still matched, which is why
dc-derived-header appeared while its sibling class did not.

columnHeader is now taken from headerColumns once, where that list is final
(the Delete? rename and the EDIT_STATUS append have been applied), and the
later divergent copy is gone - it never carried EDIT_STATUS, so it could not
have matched the grid anyway. Comments at both sites record why the order
matters.
2026-10-27 21:42:53 +00:00
allan 619fe9f9b9 Merge pull request 'fix(derived): format catalog filtering, cut-off guard, selectbox seed' (#373) from fix/format-catalog-filter into release/7.19
Sites / deploy (push) Canceled after 0s
Build / Build-and-ng-test (pull_request) Successful in 5m34s
Lighthouse Checks / lighthouse (pull_request) Successful in 20m48s
Build / Build-and-test-development (pull_request) Canceled after 28m13s
Reviewed-on: #373
2026-10-27 21:13:46 +00:00
dc 7830b7bcfa fix(derived): compare the outref as text, prove the file is empty
Build / Build-and-ng-test (pull_request) Successful in 5m25s
Build / Build-and-test-development (pull_request) Canceled after 0s
Lighthouse Checks / lighthouse (pull_request) Canceled after 3m52s
Review: '%if &outref ne 0' handed %EVAL the text 'derref5 ne 0', which needs a
numeric operand - so the catalog branch failed in exactly the case it was added
for, on every caller that passes a fileref.  Quoted, the default 0 still skips
the file and a fileref creates it.

The test now proves both halves: fopen tells us the file was created, and a
read loop counted zero records tells us it is empty.  A single INPUT is no
good - on an empty file it stops the step before the symputx, which is why the
first version recorded a FAIL.

The outvars placeholder keeps 'keep=name'; the comment records why that is the
whole column set: the engine builds the dataset in exactly two places and both
project NAME, and the only projection in the SAS tree is getdata.sas.
2026-10-27 21:08:22 +00:00
dc f8f65681e1 fix(derived): keep targetds for plain datasets, test the empty file
Build / Build-and-ng-test (pull_request) Successful in 5m49s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m22s
Build / Build-and-test-development (pull_request) Failing after 34m17s
Review feedback: the first version left targetds out for every filter without
derived variables, which silently changed the plain-dataset path to whatever
mp_filterstore defaults to.  It is now passed exactly as before unless the libds
is a format catalog - the one case mp_filterstore has to rewrite itself.

The catalog test no longer writes to the statement file: it reads one record
from it instead, so -1 means the file could not be opened and 0 means
mpe_derived left it empty, which is the claim the branch makes.
2026-10-27 20:41:33 +00:00
dc 7fb981daa9 fix(derived): guard the bitemporal cut-off, coalesce the selectbox seed
mpe_derived used &dc_dttmtfmt in its where= but the guard only checked libds and
dclib, so an unset value turned the filter into 'lt tx_to' and the control-table
step failed with a syntax error a long way from the cause.  It is now part of
the guard, named in the message.

The 7.19 migration took max(selectbox_rk) into a macro variable, which is
missing on an install whose MPE_SELECTBOX is still empty - %eval(&maxrk+1) then
fails.  coalesce(max(selectbox_rk),0) makes the first row start at 1.
2026-10-27 20:41:21 +00:00
dc 469229aecb fix(derived): filtering a format catalog no longer aborts the service
Build / Build-and-ng-test (pull_request) Successful in 5m44s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m20s
Build / Build-and-test-development (pull_request) Failing after 34m47s
A format catalog is addressed as LIBREF.CATALOGNAME-FC.  Two places assumed a
dataset:

- mpe_derived passed the name straight to mp_derivedvars, whose existence check
  aborted with 'Target table not found: DCTEST.DCFMTS-FC' - so validatefilter,
  getrawdata, viewdata, getcolvals and stagedata all failed on a catalog.  A
  catalog has no observations, so it can have no derived variables: return an
  empty variable list and an empty statement file, and leave the view alone.
- validatefilter then passed that same -FC name as targetds to mp_filterstore.
  The engine handles a -FC libds itself (it rewrites it to a cntlout dataset),
  but a targetds it is handed is used as given, so mp_filtercheck built a step
  against a name it could not open.  targetds is now only passed when the
  derived view is actually in use.

mpe_derived.test gains a case for the catalog path: no derived variables, no
generated statements, and no abort.
2026-10-27 20:21:30 +00:00
allan 6c7c8ebf74 Merge pull request 'fix(derived): harness follow-ups from the estate test run' (#367) from fix/harness-test-followups into release/7.19
Sites / deploy (push) Canceled after 0s
Build / Build-and-ng-test (pull_request) Canceled after 0s
Build / Build-and-test-development (pull_request) Canceled after 0s
Lighthouse Checks / lighthouse (pull_request) Canceled after 0s
Reviewed-on: #367
2026-10-27 19:46:59 +00:00
allan 468bdf5ab3 Merge pull request 'feat(editor): derived column tooltip carries the label' (#371) from fix/derived-label-tooltip into release/7.19
Sites / deploy (push) Canceled after 0s
Lighthouse Checks / lighthouse (pull_request) Canceled after 0s
Build / Build-and-ng-test (pull_request) Canceled after 0s
Build / Build-and-test-development (pull_request) Canceled after 0s
Reviewed-on: #371
2026-10-27 19:43:17 +00:00
dc e0da9f1098 fix(derived): review feedback - cover the empty response, assert the last row
Lighthouse Checks / lighthouse (pull_request) Canceled after 0s
Build / Build-and-ng-test (pull_request) Canceled after 0s
Build / Build-and-test-development (pull_request) Canceled after 0s
- mpe_assert_abort.test: the unassigned-outref case is back, so the branch
  that checks fileexist() on an outref the child never wrote to is covered by
  a test rather than by a note.  It runs: the four cases pass on the estate.
  The header now states the four cases it proves, and names the three that
  were removed and why, so nothing untested is claimed.
- mpe_execute.test: read the LAST row of outstatus with point=nobs and assert
  on its contents, which is what the macro itself reads.  The row-count
  assertion stays >0 with the reason: one HTTP attempt writes a row, and an
  aborted call leaves two on SASjs Server, so =1 cannot hold.
- Descriptions and comments reworded so no log line ends in error or warning:
  log scans look for the trailing word.
2026-10-27 19:39:37 +00:00
dc cfe6647ca8 feat(editor): show the derived column label in the header tooltip
Lighthouse Checks / lighthouse (pull_request) Canceled after 0s
Build / Build-and-ng-test (pull_request) Canceled after 0s
Build / Build-and-test-development (pull_request) Canceled after 0s
The hover text on a derived column said only that the column cannot be edited,
which tells the user nothing about what it holds.  It now carries the column
label - the configured VAR_LABEL, or the generated name=expression that
mp_derivedvars writes when the administrator left the label blank - and falls
back to the plain rule when the label merely repeats the column name.

The cypress case that marks derived columns apart now also checks the tooltip
for both shapes: REGION_LABEL carries a configured label, REGION falls back to
the generated one.
2026-10-27 19:36:51 +00:00
allan cdfb3d01aa Merge pull request 'fix(ui): one error modal at a time' (#370) from fix/modal-stacking into release/7.19
Sites / deploy (push) Canceled after 0s
Lighthouse Checks / lighthouse (pull_request) Canceled after 0s
Build / Build-and-ng-test (pull_request) Canceled after 0s
Build / Build-and-test-development (pull_request) Canceled after 0s
Reviewed-on: #370
2026-10-27 19:33:16 +00:00
allan e80ca2af4d Merge pull request 'fix(derived): NOT NULL in the model, VAR_FMT optional' (#369) from fix/derived-model-notnull into release/7.19
Sites / deploy (push) Canceled after 0s
Lighthouse Checks / lighthouse (pull_request) Canceled after 0s
Build / Build-and-ng-test (pull_request) Canceled after 0s
Build / Build-and-test-development (pull_request) Canceled after 0s
Reviewed-on: #369
2026-10-27 19:32:38 +00:00
dc 81ccf758e9 fix(ui): one error modal at a time
Lighthouse Checks / lighthouse (pull_request) Canceled after 0s
Build / Build-and-ng-test (pull_request) Canceled after 0s
Build / Build-and-test-development (pull_request) Canceled after 0s
Submitting a rejected MPE_DERIVED row showed the SAS abort message, and then a
second modal on top of it reading

    Submit error
    SAS Service: editors/stagedata
    Submit request failed
    undefined

Two separate faults.  sasService.request already shows the abort message when
the service returns a sasjsAbort, and the adapter error when the call fails -
then it rejects, and the editor's catch showed its own modal for the same
failure.  That second modal read 'undefined' because it looked for
'adapterRespnse' (typo), a property that does not exist, so the text it built
was JSON.stringify(undefined).

- sas.service.ts marks a rejection 'errorShown' when it has already told the
  user, on both paths that display a modal.
- the editor's catch now only reports a failure when nothing was shown, and
  reads err.adapterResponse.error when it does.
- app.component.ts replaces the open abort modal instead of pushing another
  onto the array: a second emit stacked a modal over the first, so one failure
  needed two dismissals, and the lower one was usually the duplicate.
2026-10-27 19:27:00 +00:00
dc 7efbf31ea5 fix(derived): make the model enforce the required columns, and leave VAR_FMT optional
Lighthouse Checks / lighthouse (pull_request) Canceled after 0s
Build / Build-and-ng-test (pull_request) Canceled after 0s
Build / Build-and-test-development (pull_request) Canceled after 0s
LIBREF, DSN, VAR_NM, VAR_TYPE, VAR_LEN, VAR_LOGIC and ACTIVE are NOT NULL in the
table definition, so the requirement is stated once, in the model, instead of
being repeated as NOTNULL rules in MPE_VALIDATIONS for each column.

VAR_FMT stays nullable: it is optional on a derived variable, and its HARDREGEX
already accepts an empty value.

The seven NOTNULL rule inserts are removed from the 7.19 migration, and the
comment on ACTIVE no longer describes a rule that is not there.
2026-10-27 19:25:27 +00:00
dc 9b3cf50b9c fix(derived): a fileref with nothing written is a FAIL, not an error
Lighthouse Checks / lighthouse (pull_request) Canceled after 0s
Build / Build-and-ng-test (pull_request) Canceled after 0s
Build / Build-and-test-development (pull_request) Canceled after 0s
The guard covered an unassigned fileref, but not the case that actually
happens: mx_execute hands back a fileref that exists and was never
written, because the child aborted before producing a response. Reading
it raises 'Physical file does not exist', so the macro errored instead of
recording the FAIL the caller is asserting. Check fileexist on the
fileref's pathname as well.

mpe_execute.test asserted one status row per call. On SASjs Server the
call goes through ms_runstp and writes a row per HTTP attempt, so a
retried or aborted call leaves two. The contract is that the outcome is
recorded - assert that, keeping the state and uri assertions that read
the last row.

mpe_assert_abort.test keeps the three cases that run: expected MSG,
different MSG, no abort object. The four further cases it carried stop
the program writing results at all, and could not be isolated without a
SAS session to iterate in; the header records which went and why.
2026-10-27 19:14:03 +00:00
allan e929ca047f Merge pull request 'feat(derived): pickers for MPE_DERIVED key columns, and the blank truncation tooltip' (#366) from fix/empty-truncation-tooltip into release/7.19
Sites / deploy (push) Failing after 5m16s
Build / Build-and-ng-test (pull_request) Successful in 8m25s
Lighthouse Checks / lighthouse (pull_request) Failing after 22m35s
Build / Build-and-test-development (pull_request) Canceled after 0s
Reviewed-on: #366
2026-10-27 18:53:37 +00:00
dc d95ffdfb97 chore(derived): ACTIVE needs no HARDSELECT, and no cross-reference note
Lighthouse Checks / lighthouse (pull_request) Canceled after 0s
Build / Build-and-ng-test (pull_request) Canceled after 0s
Build / Build-and-test-development (pull_request) Canceled after 0s
The picker comes from the MPE_SELECTBOX rows - the client builds the dropdown
from that data and the SELECT rule types only set whether a value outside the
list can be typed.  NOTNULL is what makes ACTIVE mandatory, and the post edit
hook is what refuses anything other than 0 and 1, so the HARDSELECT row was
saying the same thing a third time.

Drop it in both writers, and drop the comment pointing at the migration: the
two files are read side by side anyway.
2026-10-27 18:44:41 +00:00
dc 87e794f769 feat(derived): pickers, and an ACTIVE default, for MPE_DERIVED
Lighthouse Checks / lighthouse (pull_request) Canceled after 1m16s
Build / Build-and-ng-test (pull_request) Canceled after 4m52s
Build / Build-and-test-development (pull_request) Canceled after 0s
MPE_DERIVED is maintained through the EDIT screen, but LIBREF and DSN were free
text - a typo in either silently produced a rule that never fired.

LIBREF now offers the editable libraries and DSN the tables in the LIBREF on the
same row, through the same SOFTSELECT_HOOK services the other configuration
tables use (libraries_editable, tables_editable).  VAR_NM stays typed: a derived
variable names a column the table does not have yet, so a list of existing
columns would be the wrong control.  ACTIVE becomes the fixed pair 1 and 0 from
MPE_SELECTBOX, with HARDSELECT so it cannot be typed freehand, and its NOTNULL
rule carries '1' - the value the EDIT screen pre-fills on Add Row
(getColumnDefault), because a derived variable only takes effect when active.

The rows are written by mpe_makedata.sas for a fresh install and by the v7.19
migration for an upgrade, and each names the other so the pair cannot drift
unnoticed; the ACTIVE guard itself lives once, in mpe_derived_check.sas.

The mock hook carries the ACTIVE rule, as it carries every rule
mpe_derived_check.sas enforces.
2026-10-27 18:39:15 +00:00
dc f182179403 fix(editor): a blank truncation note is not a warning
getdata returns TRUNC_MSG as a single blank when the selection was not
truncated: SAS has no zero-length character value, so mp_jsonout's trim of a
blank $200 leaves one blank and it serialises as a single space.  The editor
assigned it straight to truncationNote, and a space is truthy in JavaScript, so
the EDIT screen showed the orange truncation warning against the table name and
its tooltip contained one space - a dark bubble with nothing in it.

Trim the note before it is tested.
2026-10-27 18:36:46 +00:00
allan 803bb1ad76 Merge pull request 'fix(admin): make service-test calls observable, and fix the catalogue, getdata and makedata defects it exposed' (#363) from fix/dc-test-harness into release/7.19
Sites / deploy (push) Canceled after 0s
Build / Build-and-ng-test (pull_request) Successful in 5m10s
Lighthouse Checks / lighthouse (pull_request) Successful in 20m14s
Build / Build-and-test-development (pull_request) Canceled after 0s
Reviewed-on: #363
2026-10-27 18:33:37 +00:00
dc 1863076bc9 fix(derived): enforce the ACTIVE picker pair in the extracted check
Build / Build-and-ng-test (pull_request) Successful in 5m25s
Lighthouse Checks / lighthouse (pull_request) Successful in 20m49s
Build / Build-and-test-development (pull_request) Canceled after 0s
Same rule as the 7.19 hook (PR #366): ACTIVE is NOTNULL + HARDSELECT in the
EDIT screen, so the field checks must refuse a missing value or anything other
than 0 and 1 - otherwise a direct stagedata submission stages a row the picker
would never have allowed.

The fixture gains the active column (1 by default, 2 for BADACTIVE) and the
count of reported rows goes from five to six.
2026-10-27 18:24:34 +00:00
dc 36e4e88eb1 fix: address the review findings on the harness and refresh changes
Lighthouse Checks / lighthouse (pull_request) Successful in 22m41s
Build / Build-and-ng-test (pull_request) Successful in 5m52s
Build / Build-and-test-development (pull_request) Failing after 34m22s
getcolvals - mf_nobs returns -1 when the dataset does not exist, so the guard
tested ==0 and a request that supplied no iwant at all fell through to the SQL.
It now tests <=0, which covers both an empty and an absent input.

mpe_refreshcatalogs - etlsource defaulted to a bare uid while its two siblings
use refreshcatalog:<step>:<uid>, so a load through this path could not be traced
to the step that wrote it. The default now has the same shape, and the explicit
etlsource=0 path follows it too.

mpe_execute - the status table was read without being cleared, so a child that
died before core wrote a row left the previous call's row in place and failfast
read it as completed. The table is deleted before the call.

makedata - KEEPME is compared through cats(), so a numeric column no longer
raises in upcase().
2026-10-27 18:22:18 +00:00
dc 1e224763ba fix(dc_refreshcatalog): terminate the generated statements
Build / Build-and-ng-test (pull_request) Successful in 5m40s
Build / Build-and-test-development (pull_request) Failing after 34m52s
Lighthouse Checks / lighthouse (pull_request) Failing after 22m3s
The statements written into the catalog ended without a semicolon, so the
%INCLUDE that ran them ran on into the caller: the parser finished the statement
with the text after the include and tried to include it, producing
'Cannot open %INCLUDE file PROC' immediately before the next proc sql, and
refreshlibinfo aborted with syscc non-zero.

The three refresh macros are only reached through those generated statements, so
the false 'declared but not used' warnings are switched off with a @sasjslint
block rather than by dropping the declarations.
2026-10-27 18:12:10 +00:00
dc 059cdf176a fix(getddl.test): ask for a table, not a whole library
Build / Build-and-ng-test (pull_request) Successful in 5m24s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m40s
Build / Build-and-test-development (pull_request) Failing after 35m8s
getddl requires a dataset - it aborts when the member cannot be found, and only
treats a format catalog specially - so a request with ds= always aborted and the
assertion read a stale webout.  Test 1 now names MPE_TABLES and checks that the
TSQL header names it, which keeps TSQL coverage on a request the service accepts.
2026-10-27 17:59:32 +00:00
dc 45dce7b11b fix(getdata.test): pin the derived filter to a libref, and spell the table as it is
Build / Build-and-ng-test (pull_request) Successful in 5m48s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m12s
Build / Build-and-test-development (pull_request) Failing after 35m8s
Test4 filtered only on the derived column, so it returned the same DSN once per
libref, including transient session librefs.  The filter now pins LIBREF as well,
built in a data step because datalines leave &dc_libref unresolved, and the
expected derived value uses MPE_LOCKANYTABLE - the name makedata registers.
2026-10-27 17:48:19 +00:00
dc ddb1671e37 test(getdata): dump the Test4 payloads under debug, and count the derived row
mp_assertcols tests variable names, so it can never find a DERIVED_NOTE row in
the cols payload - the assertion now counts rows instead.  With debug on, both
Test4 payloads are printed so a wrong payload can be read rather than guessed.
2026-10-27 17:18:40 +00:00
_ da30266278 fix(test): getdata test
Build / Build-and-ng-test (pull_request) Successful in 9m19s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m26s
Build / Build-and-test-development (pull_request) Failing after 34m35s
2026-10-27 17:17:53 +00:00
_ fdcd8fa4b3 fix(tests): enable closeouts logging
Build / Build-and-ng-test (pull_request) Successful in 10m44s
Lighthouse Checks / lighthouse (pull_request) Successful in 23m15s
Build / Build-and-test-development (pull_request) Failing after 35m42s
2026-10-27 17:14:01 +00:00
dc 220408538b fix(mpe_execute): no apostrophe in the SAS 9 status note
Build / Build-and-ng-test (pull_request) Successful in 5m38s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m32s
Build / Build-and-test-development (pull_request) Failing after 34m45s
An apostrophe in a %put breaks macro compilation on this platform: the statement
ends early and the next macro is left unterminated, which showed up as 'Macro
keyword LET appears as text' several hundred lines further down.
2026-10-27 16:45:12 +00:00
dc 4bcee706ff fix(getcolvals): report an invalid value, not missing inputs
Build / Build-and-ng-test (pull_request) Successful in 5m37s
Lighthouse Checks / lighthouse (pull_request) Successful in 20m58s
Build / Build-and-test-development (pull_request) Failing after 34m57s
libds and col2 are only populated once validation passes, so the
"Missing inputs from iwant" abort - which tested those macro variables - fired for
any invalid payload and hid the specific reason.  It now tests whether the
request carried rows at all, leaving Invalid libds / Invalid col to report the
value.  The service still refuses the same requests; only the message changes.

The matching tests carried two faults of their own: getcolvals.test.4 sent its
libds through single quotes so the libref itself was invalid, and getdiffs /
getrawdata expected "Invalid table" for cases their own field names.
2026-10-27 16:23:31 +00:00
dc ded8bd13b3 ci: drop the HTTP_PORT explainer from the workflows
Build / Build-and-ng-test (pull_request) Successful in 5m35s
Lighthouse Checks / lighthouse (pull_request) Successful in 20m49s
Build / Build-and-test-development (pull_request) Failing after 34m53s
The line that sets it is the change; the four lines above it only restate it.
2026-10-27 16:12:50 +00:00
dc@4gl.io f2b1428ec3 fix(mpe_execute): read an unknown state as unmeasured, not failed
Build / Build-and-ng-test (pull_request) Successful in 5m16s
Lighthouse Checks / lighthouse (pull_request) Successful in 20m47s
Build / Build-and-test-development (pull_request) Failing after 34m58s
SAS 9 has no job state: core's SASMETA branch writes state='unknown' with
'proc stp returns no job status' on a call that succeeded.  The fail-fast
change would have turned every converted test on that platform into a failure,
so unknown is noted rather than aborted on.  A missing status table, a blank
state, canceled and failed still stop the caller.

The macro test asserted on uri, which the SAS 9 status row leaves empty; it now
asserts the platform column, which every branch populates.
2026-10-27 14:58:00 +00:00
dc@4gl.io ea300f73e7 test(macros): cover mpe_execute and mpe_assert_abort
Build / Build-and-ng-test (pull_request) Successful in 5m15s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m24s
Build / Build-and-test-development (pull_request) Failing after 35m16s
mpe_assert_abort decides PASS/FAIL by scanning response text, and a mistake in
that scan either fails a genuine abort or passes on any sasjsAbort string in the
response; nothing reported it.  Its test builds the responses as files: the
expected MSG, a different MSG, no abort object, several MSGs where the first
belongs to the abort, an object split across lines, an abort past 32767
characters, and an unassigned fileref.

mpe_execute is covered against a child that aborts for a known reason: one
status row per call, the state named and not completed, the uri recorded, and
the child log kept only when mdebug asks for it.
2026-10-27 14:49:59 +00:00
dc@4gl.io 359b570b85 fix(getdata): clear the globals it declares before use
loadtype, var_txfrom, var_txto, var_processed, filter_text, pk, coltype, sortpk,
var_busfrom, var_busto, rk_underlying, buskey and pre_edit_hook are declared
%global but were never initialised, so in a reused session a code path that
skipped an assignment applied the previous request's value - a stale
pre_edit_hook or buskey against the wrong table.
2026-10-27 14:49:59 +00:00
dc@4gl.io d626cb8f2d fix(makedata): read KEEPME from every input row, and keep it out of the Viya seed
The KEEPME probe did 'set work.fromjs' inside the varnum guard and then stopped,
so only the first observation was inspected: a submission that set KEEPME on a
later row still self-destructed.  It now reads to end of file.

makeDataNextViya.json ships KEEPME=Y and is the config that seeds a real Viya
environment, so the default posture there was 'the seeding program stays on the
server'.  KEEPME stays in makeDataSas719.json, which exists for the test
harness, and is removed from the environment seed.
2026-10-27 14:49:59 +00:00
dc@4gl.io 0a52d20c5f fix(mpe_assert_abort): take the abort's own MSG, tolerate a missing response
Three defects in the scan:

- 's/.*"MSG" *: *"([^"]*)".*/$1/' is greedy, so a response carrying more
  than one MSG compared the last one rather than the abort's.  The pattern is
  now matched with prxmatch and the capture read with prxposn, which returns
  the first match at or after the sasjsAbort marker.
- the whole response was concatenated into $32767, so a longer response
  truncated silently and an abort near the tail was missed.  The file is now
  scanned a line at a time and the scan stops at the first MSG.
- 'infile &outref' had no fileref check, so a child that died before writing a
  response raised an unassigned-fileref error instead of recording a FAIL.
2026-10-27 14:49:55 +00:00
dc@4gl.io a03638667f fix(mpe_execute): fail fast when the child left no status
failfast fired only on state=canceled or state=failed, and read &outstatus
unguarded.  A child that never reached the runner leaves no status table, and
a status row with a blank state left both macro variables blank - either way the
test carried on against whatever the previous call left in outlib, which is the
inference this wrapper exists to remove.

The status table is now read only if it exists, a missing table is named as
such, and failfast aborts unless the state is completed.  A status row that is
absent and one that is blank both fail.
2026-10-27 14:49:55 +00:00
dc@4gl.io 638c34a070 Merge release/7.19 into the test harness
The release branch gained 36e1689, which fixed the same four security tests
(getcols, getcolvals, getrawdata, validatefilter) by introducing
dc_assert_abort.  This branch had already fixed them with mpe_assert_abort and
mpe_execute, and mpe_assert_abort is the wider of the two: it also checks the
expected MSG, and the calls pass outstatus/failfast so the child cannot be
inferred from a stale outlib.  Kept the harness versions and dropped
dc_assert_abort.sas, which nothing references after the merge.
2026-10-27 14:46:04 +00:00
allan 9aef2f4f03 Merge pull request 'feat(editor): mark derived variables as computed in the EDIT grid' (#365) from feat/derived-var-styling into fix/dc-test-harness
Build / Build-and-ng-test (pull_request) Successful in 5m36s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m21s
Build / Build-and-test-development (pull_request) Failing after 35m38s
Reviewed-on: #365
2026-10-27 14:42:49 +00:00
dc@4gl.io 9aea59dfef ci: start the SASjs Server with HTTP_PORT=5000
Build / Build-and-ng-test (pull_request) Successful in 5m41s
Lighthouse Checks / lighthouse (pull_request) Successful in 23m4s
Build / Build-and-test-development (pull_request) Failing after 34m12s
SASjs Server 3.x deprecated PORT and PROTOCOL and refuses to start when an
.env names neither HTTP_PORT nor HTTPS_PORT, exiting with

    - no listener is enabled
     - set HTTP_PORT and/or HTTPS_PORT (either may be used alone)

The three workflows downloaded releases/latest and wrote an .env without a
listener, so api-linux never bound :5000 and the 'Setup and start SASjs
server' step failed after its 120s wait (build runs for #363 and #365, both
failed there). Add HTTP_PORT=5000 to build.yaml, release.yaml and
lighthouse.yaml, and document it in the local-dev .env in CONTRIBUTING.md,
where the default-port claim is no longer true.
2026-10-27 14:34:13 +00:00
dc 6513d09f05 feat(editor): mark derived variables as computed in the EDIT grid
Derived variables are read-only, but they were rendered exactly like any other
read-only column: dimmed via readonlyCell, with no indication that the column is
computed at read time rather than locked by a rule or by column level security.
The grid could not tell the difference either - getdata holds a derived column
read-only with a READONLY DQ rule, which is also a rule type a table can be
configured with.

getdata now names the derived columns in sasparams.DERIVEDVARS (space separated,
upper case, from MPE_DERIVED's outvars). The EDIT screen holds that list, and
marks those columns with their own classes instead of the read-only dimming:
a tinted header with an 'fx' marker and a tooltip, tinted italic cells, and a
left edge on the first derived column so the computed block reads as a group.
Blue is the info family, leaving the primary key green and the warning ochre to
their existing meanings. Light and dark values are both set.

The SASjs mock getdata.js returns the same field, so the Cypress suite exercises
the styling; the existing derived-variables spec asserted readonlyCell on the
derived header, which is now the fallback for a plain read-only column, so that
assertion moves to dc-derived-header and a physical column stays the control.

Lint: prettier --check clean on the touched client files; sasjs lint reports no
new warnings for getdata.sas.
2026-10-27 14:34:13 +00:00
dc@4gl.io 356308e13f chore(deps): sync the sas lockfile to @sasjs/core 6.1.0
Lighthouse Checks / lighthouse (pull_request) Failing after 3m0s
Build / Build-and-ng-test (pull_request) Successful in 5m47s
Build / Build-and-test-development (pull_request) Failing after 6m32s
The harness pins @sasjs/core exactly (6.1.0) so the tests can read the
outstatus table, but the lockfile still resolved 6.0.0. 'npm ci' refuses to
install when an exact pin and the lockfile disagree, which failed the mocked
deploy in both build and lighthouse ("lock file's @sasjs/core@6.0.0 does not
satisfy @sasjs/core@6.1.0"). Regenerated with 'npm install
--package-lock-only': the only change is the core entry.
2026-10-27 14:32:20 +00:00
dc 46bacdd6d9 fix(refresh): name each catalogue load uniquely in the audit trail
Lighthouse Checks / lighthouse (pull_request) Failing after 2m58s
Build / Build-and-ng-test (pull_request) Successful in 5m20s
Build / Build-and-test-development (pull_request) Failing after 6m4s
MPE_DATALOADS is keyed on (processed_dttm, libref, dsn, etlsource), and etlsource
was the calling macro name, so a second catalogue load of the same table in the
same second was refused as a duplicate.  The three refresh macros now take an
etlsource parameter, defaulting to refreshcatalog:<step>:%mf_uid(), which records
which step loaded what and keeps the audit row unique.

dc_refreshcatalog also echoes the generated statements when debug is on, since a
failure inside one of them otherwise names no libref.
2026-10-27 14:22:20 +00:00
dc 582523dc87 feat(dc_getlibs): dump the library list when debug is on
A refresh that catalogues the wrong libraries is hard to read from the log: the
list is built here, then filtered and reworked by its callers.  With
sasjs_mdebug set, print the result so the library list can be checked at source.
2026-10-27 14:22:20 +00:00
dc 105052b948 test: prove staged rejections from the response, and check the field rules at macro level
The MPE_DERIVED post edit hook aborts with mp_abort, so the webout carries a
sasjsAbort object: stagedata.test.3 and .4 now read that through
mpe_assert_abort instead of inferring the outcome from MPE_LOADS.

The field level rules move into mpe_derived_check so they can be tested
directly.  A service cannot exercise them all - VAR_LABEL is varchar(256) and a
staging dataset declares the same length, so a longer label is truncated before
any check sees it - and the macro test runs in seconds rather than minutes.
2026-10-27 14:22:16 +00:00
dc d0bc5ae297 refactor: rename dc_execute to mpe_execute
Lighthouse Checks / lighthouse (pull_request) Failing after 2m53s
Build / Build-and-ng-test (pull_request) Successful in 4m44s
Build / Build-and-test-development (pull_request) Failing after 5m42s
The wrapper is DC specific and sits alongside mpe_assert_abort, so the mpe_
prefix marks which layer it belongs to.  File, macro name, %mend and all seven
callers updated; no dc_execute reference remains in the tree.
2026-10-27 11:18:26 +00:00
dc 31aa859153 revert(stagedata.test.4): back to the MPE_LOADS evidence for now
Lighthouse Checks / lighthouse (pull_request) Failing after 3m5s
Build / Build-and-ng-test (pull_request) Successful in 5m15s
Build / Build-and-test-development (pull_request) Failing after 5m45s
Reading the sasjsAbort object instead did not work for this test: the derived post
edit hook aborts with `abort cancel FILE` via %mp_include, and with the
response-based version the test stopped before reporting any assertion, where the
MPE_LOADS version reports ten and fails five.

Which evidence is right depends on where that abort is raised - the test's own
session or the child's - and the log does not distinguish the two here.  Reverting
until that is settled, so the branch is not carrying a change that reduces
coverage.
2026-10-27 11:00:55 +00:00
dc a141e38da4 refactor(stagedata.test.4): call the service through dc_execute
Lighthouse Checks / lighthouse (pull_request) Failing after 2m54s
Build / Build-and-ng-test (pull_request) Successful in 4m50s
Build / Build-and-test-development (pull_request) Failing after 6m5s
Same inputs and outputs, with a status row and no fail-fast, so the echoed child
log carries the CHILD: prefix - which is what distinguishes an abort raised in
the child from one raised in the test's own session.
2026-10-27 10:55:16 +00:00
dc e3ec1da55c refactor(stagedata.test.4): prove the rejections from the response, not MPE_LOADS
Lighthouse Checks / lighthouse (pull_request) Failing after 2m54s
Build / Build-and-ng-test (pull_request) Successful in 4m50s
Build / Build-and-test-development (pull_request) Failing after 5m45s
The five rejected-submission cases queried MPE_LOADS for a row that did not reach
SUCCESS.  The post edit hook aborts with mp_abort, so the webout carries a
sasjsAbort object: mpe_assert_abort reads that directly, which is the actual
evidence that the submission was refused.
2026-10-27 10:39:24 +00:00
dc 9c294c0ab5 fix(mpe_assert_abort): drop the unused line variable
Lighthouse Checks / lighthouse (pull_request) Failing after 3m1s
Build / Build-and-ng-test (pull_request) Successful in 5m11s
Build / Build-and-test-development (pull_request) Failing after 5m49s
It was declared but never assigned, and this session treats an uninitialized
variable as an error that stops the step before the assertion runs - so the
macro reported FAIL for a response that did carry the abort object.
2026-10-27 09:38:36 +00:00
dc fed43cb07e fix(dc_execute): use = comparisons, not IN, in the fail-fast guard
Lighthouse Checks / lighthouse (pull_request) Failing after 2m59s
Build / Build-and-ng-test (pull_request) Successful in 5m11s
Build / Build-and-test-development (pull_request) Failing after 6m13s
%if &failfast=1 and "&state" in ("canceled" "failed") fails to evaluate: %EVAL
reports it needs a numeric operand and stops the macro, which aborted every test
that reached the guard even though the child had completed or aborted cleanly.
Two plain comparisons do the same job and evaluate reliably.
2026-10-27 09:32:54 +00:00
dc a20a5a627c refactor(tests): mpe_assert_abort reads the sasjsAbort object from the response
Lighthouse Checks / lighthouse (pull_request) Failing after 3m1s
Build / Build-and-ng-test (pull_request) Successful in 4m53s
Build / Build-and-test-development (pull_request) Failing after 6m4s
dc_assert_abort inferred the outcome from the status row.  The assertion should
prove the service rejected its input, so it now reads the child's response and
requires a sasjsAbort object, and compares that object's MSG value with the
message the service is expected to return:

  %mpe_assert_abort(desc=An invalid libds aborts the service,
    outref=web1, msg=Invalid libds)

Renamed with the mpe_ prefix to match the other macro-level helpers.  getdata
Test 4 asserted completion with the abort macro, which was the wrong tool; it
now checks the status row for state=completed directly.
2026-10-27 09:26:34 +00:00
dc f5aade5976 fix(dc_getlibs): exclude the DC libref from the dictionary scan
Lighthouse Checks / lighthouse (pull_request) Failing after 2m57s
Build / Build-and-ng-test (pull_request) Successful in 4m49s
Build / Build-and-test-development (pull_request) Failing after 5m44s
dictionary.libnames lists the DC libref as well, so it was seeded into the
result by the SELECT and then added a second time by the explicit INSERT.  The
duplicate row then collides with pk_mpe_datacatalog_libs when the refresh closes
the bitemporal record:

  ERROR: Duplicate values not allowed on index pk_mpe_datacatalog_libs

The DC libref is now excluded from the scan, so it arrives once, from the INSERT
that carries its library name and engine.
2026-10-27 09:04:16 +00:00
dc 867faf8dd8 refactor(tests): read the outcome from core's outstatus, drop the local mx_execute
Lighthouse Checks / lighthouse (pull_request) Failing after 3m0s
Build / Build-and-ng-test (pull_request) Successful in 4m58s
Build / Build-and-test-development (pull_request) Failing after 5m46s
@sasjs/core 6.1.0 ships outlogds and outstatus, so the temporary copy of
mx_execute is gone and the abort assertions no longer need a platform branch:

- dc_assert_abort reads the status dataset instead of work.results on Viya and
  the response text elsewhere - one table, one code path;
- the abort cases call dc_execute with outstatus and failfast=0, so a cancelled
  child is reported rather than aborting the test;
- getdata Test 4 loses its %mf_getplatform() gate for the same reason, and reads
  the outlib only when the status says the child completed;
- dc_execute passes viyaresult through, which the abort cases ask for.
2026-10-27 01:19:45 +00:00
dc 609f2a743c test(hooks): seed the baseline from an npm script, not from testsetup
Lighthouse Checks / lighthouse (pull_request) Failing after 2m57s
Build / Build-and-ng-test (pull_request) Successful in 4m51s
Build / Build-and-test-development (pull_request) Failing after 5m42s
A full makedata on every sasjs test run is expensive, and it made the suite
depend on state the tests themselves should not be creating.  Move it to where
DC already does this: the npm scripts that send deploy/makeData*.json to
services/admin/makedata.

  npm run sas719setup   deploy, then seed once
  npm run sas719test    run the suite as often as wanted

KEEPME in the payload keeps makedata on the server, so a reseed does not need a
redeploy first.  Viya's makedata gains the same switch, so nextviyamakedata
behaves the same way.

Tests must now leave the estate as they found it - that is the follow-up, and it
is what makes re-running a single test possible.
2026-10-26 23:51:59 +00:00
dc 16e9b31e65 test(harness): a measurable wrapper around mx_execute for service tests
Build / Build-and-ng-test (pull_request) Successful in 4m50s
Build / Build-and-test-development (pull_request) Failing after 5m48s
Lighthouse Checks / lighthouse (pull_request) Failing after 2m53s
Tests inferred whether a service call worked by reading an outlib that a failed
call never touched, so assertions ran against the previous call's tables.  This
adds dc_execute, which wraps mx_execute and makes each call observable:

 - failfast: abort as soon as the child did not complete, with its message, so
   the first failure is the real one
 - a status row per call (outstatus) so a test can assert the outcome
 - the child's log behind the harness debug flag, quiet otherwise

testsetup reseeds the data baseline before the test library is built, makedata
honours a keepme switch and getdata initialises the variables it later tests
with %length, plus desc= values reworded where a comma or apostrophe unbalanced
the parameter.

sasjs/macros/mx_execute.sas is a TEMPORARY copy from sasjs/core#476, which adds
outlogds/outstatus and stops leaving work.results in the caller WORK.  Delete it
and bump the dependency once that PR is released.
2026-10-26 23:29:46 +00:00
dc 8096772119 test(security): assert aborts from the webout where there is no job state
The four security test programs proved that a bad input aborted the service by
reading work.results, the job table %mv_jobflow writes in the Viya branch of
%mx_execute. The SASjs Server and SAS 9 branches call %ms_testservice instead,
so the table never exists, the data step fails with "File WORK.RESULTS.DATA
does not exist." and the assertion reported (0=1) - a failure that says
nothing about whether the service validated its input.

The platform difference is now in one macro, dc_assert_abort: on Viya it reads
the job state as before, elsewhere it looks for the sasjsAbort block that
mp_abort leaves in the webout %mx_execute returns through outref. The call to
the service stays outside the platform check, so the payloads are submitted on
every platform and the assertion is real rather than a skip.

Verified on SASjs Server (sas.4gl.io, target sas719, appLoc /Public/app/dc719):
the seven abort assertions PASS, i.e. the services do abort on the macro, SQL
injection and path traversal payloads. Negative control: the same program with
a valid libds reports FAIL (0=1), so the assertion can still fail.

sasjs lint: exit 0, no warnings in the five files.
2026-10-26 23:29:33 +00:00
dc 36e168971c test(security): assert aborts from the webout where there is no job state
Sites / deploy (push) Successful in 3m43s
Build / Build-and-ng-test (pull_request) Successful in 5m2s
Lighthouse Checks / lighthouse (pull_request) Successful in 20m48s
Build / Build-and-test-development (pull_request) Successful in 33m28s
The four security test programs proved that a bad input aborted the service by
reading work.results, the job table %mv_jobflow writes in the Viya branch of
%mx_execute. The SASjs Server and SAS 9 branches call %ms_testservice instead,
so the table never exists, the data step fails with "File WORK.RESULTS.DATA
does not exist." and the assertion reported (0=1) - a failure that says
nothing about whether the service validated its input.

The platform difference is now in one macro, dc_assert_abort: on Viya it reads
the job state as before, elsewhere it looks for the sasjsAbort block that
mp_abort leaves in the webout %mx_execute returns through outref. The call to
the service stays outside the platform check, so the payloads are submitted on
every platform and the assertion is real rather than a skip.

Verified on SASjs Server (sas.4gl.io, target sas719, appLoc /Public/app/dc719):
the seven abort assertions PASS, i.e. the services do abort on the macro, SQL
injection and path traversal payloads. Negative control: the same program with
a valid libds reports FAIL (0=1), so the assertion can still fail.

sasjs lint: exit 0, no warnings in the five files.
2026-10-26 20:17:50 +00:00
dc 0d58be975d fix(ci): restore the gitleaks ignore entries for the mock licence pair
Sites / deploy (push) Successful in 3m48s
Build / Build-and-ng-test (pull_request) Successful in 5m23s
Lighthouse Checks / lighthouse (pull_request) Successful in 20m31s
Build / Build-and-test-development (pull_request) Successful in 32m19s
116c37b removed them on the grounds that a three-segment fingerprint matches
nothing. That is wrong for the mode this repo scans in: with `--no-git`, gitleaks
reports a finding as `file:rule:***` - the line number is not part of the
fingerprint - so the entries were matching, and the pipeline failed on the next
push with two findings in sas/mocks/mock-licence.json.

Measured on a clean checkout of this head, with the pinned tool
(@nogoo9/gitleaks 8.30.1-post.2) and the command the workflow runs:

  with .gitleaksignore     6.10 MB scanned, no leaks found
  without it               6.10 MB scanned, leaks found: 2

The 6.10 MB scan is the same figure the CI log reports, which is what makes this
comparable; my earlier check was run in a tree that still had node_modules in it,
scanned 1.05 GB, and its verdict meant nothing.

The entries stay, with the comment explaining that the pair is RSA key material
for the browser mock rather than a credential.
2026-10-26 10:34:36 +00:00
dc fe2388718a chore(deps): handsontable 18.1.2, so a wrapped entitlement key cannot lock the grid
Build / Build-and-ng-test (pull_request) Failing after 23s
Build / Build-and-test-development (pull_request) Skipped
Sites / deploy (push) Successful in 3m51s
Lighthouse Checks / lighthouse (pull_request) Successful in 20m38s
18.1.2 carries one fix (2026-10-07): an entitlement licence key whose bracketed
block is broken across lines - by an email client, typically - read as invalid and
locked the grid.

That is this app's key path. The DC licence payload carries `hot_license_key`, and
licence.service puts it on `hotTable.licenseKey` for every grid - editor, viewer,
viewboxes, stage, multi-dataset, xlmap - so a key pasted with line breaks would
lock the editor rather than open it, on 18.1.1.

Both packages move together. The licence-checker exclusions pick up the new
version too: handsontable and @handsontable/angular-wrapper declare `SEE LICENSE
IN LICENSE.txt`, which the checker reports as a custom licence, so every pinned
version is listed in excludePackages - 18.1.1 was already there, 18.1.2 is added
beside it.

Checked: client 603/603, production build green, npm audit --omit=dev clean,
licence checker "All packages are licensed properly".
2026-10-26 10:22:59 +00:00
dc 05b91cb43e refactor(getdata): scope the payload row cap to the SET, and assert the column list
Build / Build-and-ng-test (pull_request) Failing after 21s
Build / Build-and-test-development (pull_request) Skipped
Sites / deploy (push) Successful in 3m51s
Lighthouse Checks / lighthouse (pull_request) Successful in 19m53s
The review read the cap as a session option that is never restored, so every later
data step - including the derived-column merge into work.cols1 - would be cut to
&maxrows observations, and a table wide enough to hit the cell limit would lose
columns rather than rows.

On this head the reset is there, two lines after the data step (`options obs=max;`
at what was line 360), so the leak described is not live. It is worth removing the
dependency on that line though: the cap is now a data set option on the SET that
builds the payload, so there is no session option to leak and no later step can be
cut by it, whether or not a reset is remembered.

The existing cell-limit test asserted the row count only - 199 rows for a
1004-column table - which would have passed with 805 columns missing. It now also
compares the COLS payload against dictionary.columns, so the metadata being whole
is tested rather than assumed.

The other obs pair in this program (`options obs=10000` around the filtered
selection, restored at line 290) is deliberately left alone: it bounds the input
rows that the WHERE clause reads, which a data set option on the SET cannot do.
2026-10-26 10:09:25 +00:00
dc 072a133702 fix(client): open the SAS log page with noopener
window.open(url, '_blank') hands the opened page a window.opener reference. The
URL is built from config.serverUrl, which on Viya is a different origin from the
app, so the SAS log page could navigate this tab - a log page is exactly the sort
of thing that gets rendered from backend content.

'noopener' in the window features drops the reference in every browser that
supports it. The two existing specs asserted the call's two arguments, so they
now assert the third; the client suite is 603/603 with handsontable 18.1.1 from
the lockfile.
2026-10-26 10:09:21 +00:00
dc 116c37b379 chore: drop the gitleaks ignore entries that matched nothing
Build / Build-and-ng-test (pull_request) Failing after 16s
Build / Build-and-test-development (pull_request) Skipped
Sites / deploy (push) Successful in 3m37s
Lighthouse Checks / lighthouse (pull_request) Successful in 19m32s
The three-segment entries in .gitleaksignore were meant to cover the mock licence
pair, but a fingerprint is commit:file:rule:line, so file:rule:*** never matched
anything - and the file does not need covering anyway:

  gitleaks detect --no-git --source . --redact   (the CI command, on this tree)
    no leaks found

  gitleaks protect --staged --redact             (after touching mock-licence.json)
    no leaks found

A full-history detect over 1088 commits reports 58 findings, none of them in
mock-licence.json - they are an old viewdata.js commit, .npmrc and
mock-storage/licence.json, all outside the tree CI scans. Removing the file is
more honest than keeping an exception that guards nothing; if a future edit to
the pair does fire, the commit fails and the real fingerprint can be added then.
2026-10-26 09:53:47 +00:00
dc 1fb4818d21 fix(client): refuse a truncated table in the multi-table upload
getdata now serves a selection over DC_MAXOBS_WEBEDIT / DC_MAXCELLS_WEBEDIT
truncated rather than aborting, which the EDIT screen reports above the grid. The
multi-table upload is the other caller: it fetches each table to compare the whole
of it against the uploaded workbook, so a partial read would compute the changes
against the wrong starting state and submit differences nobody asked for.

It now refuses such a table with a message naming the limit, rather than carrying
on with the rows it received. The EDIT screen already reads the same
sasparams.TRUNCATED flag; sas.service only transports the response.
2026-10-26 09:53:47 +00:00
dc 78e88a4112 feat(derived): take any text in VAR_LABEL, but not a line break
VAR_LABEL was the one free-text column with no rule at all, in the hook or in the
MPE_VALIDATIONS seed. The suggestion in review was an allowlist, but a label has
no reason to exclude any character - quotes are doubled by %mp_derivedvars before
the attrib statement is built, and the value never reaches a macro context - so
the rule is a denylist of what cannot be a single-line column label:

  * a control character, which covers the line breaks and tabs that would carry
    through into the generated DATA step and render as nothing in a header
  * a label over 256 characters, which the engine truncates silently on a quote
    boundary, so the configured value and the applied one differ with nothing
    saying so

Everything else passes: any script, and the semicolon, ampersand, percent and
comment markers that VAR_FMT and VAR_LOGIC refuse. Implemented in the hook
(anycntrl, klength), as a HARDREGEX in all three seed writers (migration,
mpe_makedata.sas, the mock's makedata.js) so the grid refuses it before submit,
and in the mock hook for the deployed mock.

SAS: new stagedata.test.4 cases 8-10 - a label with an embedded LF is rejected, a
label of free text is accepted, and 257 characters is rejected.

Verified against the deployed mock: the tab and 257-character cases abort with the
right message, free text and UTF-8 pass, and a 256-character label passes. The
newline case cannot be driven through `sasjs request` - the mock server splits a
jsdata row at an embedded newline, so the row arrives short - hence the SAS test.
2026-10-26 09:53:37 +00:00
allan bca1b9363f Merge pull request 'fix(derived): hold derived columns read-only regardless of CLS, and register MPE_DERIVED in the mock' (#360) from fix/derived-readonly-and-mock-config into release/7.19
Sites / deploy (push) Successful in 3m56s
Build / Build-and-ng-test (pull_request) Successful in 5m15s
Lighthouse Checks / lighthouse (pull_request) Successful in 20m49s
Build / Build-and-test-development (pull_request) Successful in 33m35s
Reviewed-on: #360
2026-10-26 08:37:45 +00:00
dc 1b13120839 Merge release/7.19 into fix/derived-readonly-and-mock-config
Build / Build-and-ng-test (pull_request) Successful in 5m40s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m7s
Build / Build-and-test-development (pull_request) Successful in 33m25s
#359 has landed, so the CLI bump this branch was waiting on is now in the base
and the merge brings it in. One conflict, in
client/cypress/support/commands.ts: both sides reworked loginAndUpdateValidKey.

The base version decides from the body captured before login, which is where
the original bug was - on an estate that shows a login form that capture has no
nav tree, so the command early-returned and never applied a key, and the upload
specs then found no file input. This branch re-reads the loaded page and treats
the licence banner as the signal. Kept this side; prettier re-indented the
result. Every other file merged automatically, and the hookErrmsg veto in
stagedata.js and loadfile.js survives the merge.
2026-10-26 08:34:32 +00:00
dc 6cad23fce9 fix(mocks): scope the derived hook's graph to the submission
Build / Build-and-ng-test (pull_request) Successful in 4m52s
Lighthouse Checks / lighthouse (pull_request) Successful in 19m50s
Build / Build-and-test-development (pull_request) Successful in 31m26s
Two divergences from the server hook, both of which reject a valid submission:

- VAR_DEP_UNION joins on libref, dsn and name, so a derived variable called
  STATE on one table does not make STATE a derived name on another. The mock
  kept a flat set of names, so a physical column named STATE was reported as
  an undeclared reference whenever any other table derived a variable of that
  name.

- The control-table arm filters staging_ds for the tables being submitted, so
  another table's configuration cannot block this one. The mock read the whole
  MPE_DERIVED table, so a cycle declared on an unrelated table blocked every
  submission.

Reproduced both against the previous head - a physical STATE reference and an
unrelated CYC_A/CYC_B cycle were each rejected - and both pass after, while the
cycle, undeclared-reference and stale-dependency cases on the submitted table
still fire.
2026-10-25 23:44:17 +00:00
dc 5fc0ff1ee3 docs(derived): record why the read-only rules are safe to append and the union unguarded
Build / Build-and-ng-test (pull_request) Successful in 4m54s
Lighthouse Checks / lighthouse (pull_request) Successful in 19m48s
Build / Build-and-test-development (pull_request) Successful in 31m9s
Both come up in review every time these lines are read: dqrules is the
request-local array the filter/map builds, and work.derived_vars is written by
%mpe_derived before it can return, empty when the table has no derived
variables. Said where the code is, rather than in a review reply.
2026-10-25 23:04:20 +00:00
dc 5b70c000d7 test(derived): assert the read-only marker instead of inferring it
The case rested on an editor not appearing after a fixed wait, which passes for
any reason the editor did not open. The editor marks a read-only column with
readonlyCell on the header and on its cells, and STATE in the same grid is the
control, so the state is asserted directly and the sleep goes.

Mutating the mock to drop the READONLY rule fails the case on exactly that
assertion (expected '<th.darkTH>' to have class 'readonlyCell'); restoring it
passes 5/5 in about 50s.
2026-10-25 23:04:19 +00:00
dc d7cadc0b7b refactor(derived): name the invalid-character test, and prefix the cycle message
Build / Build-and-ng-test (pull_request) Successful in 4m56s
Lighthouse Checks / lighthouse (pull_request) Successful in 19m56s
Build / Build-and-test-development (pull_request) Successful in 31m19s
has() returned true for a character outside the allowed set, which every call
site read as the opposite; it is hasInvalidChar now.

The cycle rejection was the one message without the MPE_DERIVED: prefix. The
SAS hook produced the same text, so both are prefixed rather than letting the
mock drift away from the message a real deployment shows.
2026-10-25 22:19:06 +00:00
dc 9914790777 fix(derived): close four gaps in the MPE_DERIVED hook
Build / Build-and-ng-test (pull_request) Successful in 5m35s
Lighthouse Checks / lighthouse (pull_request) Successful in 20m54s
Build / Build-and-test-development (pull_request) Successful in 31m45s
loadfile names the submitted rows 'rows' and never defined jsDataRows, so
eval'd there the hook threw a ReferenceError - which the caller logged and
ignored. The rows are bound under the name the hook reads, and a hook that
throws now rejects the submission instead of being swallowed: a hook whose job
is to reject cannot fail open.

The hook's current-row test also required a TX_TO in the future, while the
read-time engine (mpeDerived) treats a missing or zero TX_TO as current, so a
dependency on an open-ended row validated here and broke at read time. Both the
graph and the inactive set, and the RLS guard, now use the engine's predicate.

The VAR_FMT message named '//*' where the check is for /* and */; it now names
the markers it rejects, in the hook and in its SAS counterpart.
2026-10-25 21:39:19 +00:00
dc e0ee99c178 test(derived): wait for the editor before asserting its absence
should('not.exist') passes the moment the element is missing, so a timeout on
it does not widen the window: an editor that opens after the assertion has
already passed goes unnoticed. The wait gives it that window, which is what the
case needs to rule the cell's editability out.
2026-10-25 21:39:19 +00:00
dc 5d183d1350 feat(mocks): validate a submitted MPE_DERIVED row like the hook does
Build / Build-and-ng-test (pull_request) Successful in 5m33s
Lighthouse Checks / lighthouse (pull_request) Successful in 20m53s
Build / Build-and-test-development (pull_request) Successful in 33m15s
The MPE_TABLES row registered services/hooks/mpe_derived_postedit, but no
mock counterpart existed, so a submission to the now-visible control table skipped
the checks the server hook runs and only failed later, when the EDIT screen
tried to build the view.

The hook mirrors mpe_derived_postedit.sas in its order: normalise the incoming
rows, reject a duplicate name and a bad field, then check the dependency graph
the resulting configuration would have - an inactive dependency, a self
reference, a reference the row does not declare, a declared input the
expression never uses, a name an active RLS rule uses, and a cycle, whose path
is reported.  The collision with a physical column stays with the read-time
engine, as on the server.

A hook rejects a submission by setting hookErrmsg, which stagedata/loadfile
turn into a sasjsAbort before anything is staged; that is the mock's equivalent
of the hook's %mp_abort.
2026-10-25 20:59:15 +00:00
dc fa7a468fd3 test(derived): state the negative assertion's wait explicitly
Build / Build-and-ng-test (pull_request) Successful in 5m34s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m14s
Build / Build-and-test-development (pull_request) Successful in 33m49s
The window a 'not to exist' assertion covers is its timeout, so it is set
here rather than inherited from the config default.
2026-10-25 20:50:38 +00:00
dc d14395a0f8 test(derived): enter edit mode, and add the positive control
Build / Build-and-ng-test (pull_request) Successful in 4m52s
Lighthouse Checks / lighthouse (pull_request) Successful in 19m48s
Build / Build-and-test-development (pull_request) Failing after 33m17s
The screen opens read-only until the edit button is pressed, so the case
asserted nothing about the column: no cell opened an editor. It now enters edit
mode first, asserts the editor's absence directly rather than typing at
whatever happens to be focused, and a sibling case proves a physical column in
the same grid does open one - so the silence is about the column, not the
screen.

Mutating the mock to drop the READONLY rule fails the derived case and passes
the physical one; restoring it passes both (5/5, ~48s).
2026-10-25 20:30:09 +00:00
dc f411b7d269 fix(mocks): copy the physical column list before deriving
physicalColumns held the same array the derive helper reads, so every later
use in the file - cols, the search, the row loop, the empty-row fallback and
vars - depended on that helper returning a new list rather than extending the
one it was given. Taking a copy makes the invariant local to this file.
2026-10-25 20:30:09 +00:00
dc be8f445ae1 fix(cypress): apply the licence key when the estate has none
Build / Build-and-ng-test (pull_request) Successful in 5m31s
Lighthouse Checks / lighthouse (pull_request) Successful in 20m36s
Build / Build-and-test-development (pull_request) Successful in 32m51s
loginAndUpdateValidKey decided whether a key was already working by looking
for the nav tree, and looked for it in the body captured before login.  A free
tier estate renders the tree as well, so on an unlicensed estate the command
took the 'already licensed' branch every time and never applied a key.  Every
upload spec then lost its feature set: onShowUploadModal shows the locked
feature dialog instead of the panel, so input#file-upload never renders.

The loaded page is read again after login, the licence banner decides whether
a key is needed, and an estate with no key is left untouched unless the caller
asked for one - the free tier specs are written against that state.

Measured on an unlicensed mock estate: excel.cy.ts 24 failing in 14m 1s before,
24 passing in 5m 44s after; csv.cy.ts 1 failing before, 1 passing after.
2026-10-25 19:31:14 +00:00
dc 293022d46b fix(mocks): search the physical columns only in the viewer
Build / Build-and-ng-test (pull_request) Successful in 5m55s
Lighthouse Checks / lighthouse (pull_request) Successful in 20m56s
Build / Build-and-test-development (pull_request) Failing after 36m0s
viewdata.sas drops the derived columns in the vwsearch data step that
mp_searchdata then reads, so a search matches on the columns the grid shows.
The mock handed the full column list, so a search could match a row on a
derived column that is never displayed.
2026-10-25 15:32:57 +00:00
dc 60ec470404 fix(mocks): derive before filtering in the viewer, and drop the columns
Build / Build-and-ng-test (pull_request) Successful in 5m11s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m0s
Build / Build-and-test-development (pull_request) Failing after 36m4s
viewdata.sas builds the derived view and filters against it, so a filter stored
on a derived column resolves in the VIEW screen; the mock served the physical
table only, so the same filter matched nothing and the viewer showed its
no-data row.  The mock now derives ahead of the filter and reports the physical
columns only, as viewdata.sas drops them from the output.
2026-10-25 15:20:15 +00:00
dc ee3c8787ef fix(mocks): register MPE_DERIVED and seed its field rules
Build / Build-and-ng-test (pull_request) Successful in 5m4s
Lighthouse Checks / lighthouse (pull_request) Successful in 20m25s
Build / Build-and-test-development (pull_request) Failing after 36m2s
The mock seeded MPE_DERIVED's rows and schema but no MPE_TABLES registration,
and the table tree lists registered tables only, so the hosted mock showed no
MPE_DERIVED at all - the derived variables could be seen on a table but not
configured.  The field rules and the VAR_TYPE dropdown were missing for the
same reason.

Mirrors mpe_makedata.sas and the v7.19 migration: the MPE_TABLES row
(TXTEMPORAL, buskey LIBREF DSN VAR_NM, mpe_derived_postedit hook), the 19
MPE_VALIDATIONS rows and the two VAR_TYPE selectbox values.
2026-10-25 15:17:43 +00:00
dc 5f443b0b16 fix(derived): hold a derived column read-only whatever the table's security
COLS.CLS_RULE is the only thing that marks a column read-only in the grid,
and the client applies it only when CLS_FLAG is set - which column level
security drives.  A table with no CLS rules therefore rendered its derived
columns as ordinary editable cells, although the values are computed at read
time and dropped from the submission.

getdata now unions a READONLY dq rule per derived variable, which the client
honours unconditionally, and the mock emits the same rule.  The SAS test
asserts the rule; the cypress spec double-clicks a derived cell and asserts the
value is unchanged.
2026-10-25 15:17:40 +00:00
allan 3e8f200a1a Merge pull request 'chore(deps): @sasjs/cli 6.2.0, so the browser mock serves file downloads' (#359) from chore/cli-6.2-mock-downloads into release/7.19
Sites / deploy (push) Successful in 4m4s
Build / Build-and-ng-test (pull_request) Successful in 5m38s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m3s
Build / Build-and-test-development (pull_request) Successful in 37m27s
Reviewed-on: #359
2026-10-25 14:26:18 +00:00
dc 03b67c44c7 chore(deps): @sasjs/cli 6.2.0, so the browser mock serves file downloads
Build / Build-and-ng-test (pull_request) Successful in 5m24s
Lighthouse Checks / lighthouse (pull_request) Successful in 20m18s
Build / Build-and-test-development (pull_request) Failing after 35m40s
The browser mock site answered every service with text/plain, because the
service worker ignored the headers a service writes to
_SASJS_WEBOUT_HEADERS. CLI 6.2.0 applies the declared content type and
content disposition, and carries a byte body, so the downloads a mock
declares (public/getrawdata in DC) arrive as real files under the name the
service chose.
2026-10-25 13:57:07 +00:00
dc 4d462d310f fix(sites): round-trip the committed licence before publishing
Sites / deploy (push) Successful in 3m51s
Build / Build-and-ng-test (pull_request) Successful in 5m19s
Lighthouse Checks / lighthouse (pull_request) Successful in 20m8s
Build / Build-and-test-development (pull_request) Successful in 36m44s
The pre-publish check only asserted the pair had two different values, which a
committed pair can satisfy while being broken (one half regenerated, a value
truncated on copy). It now imports the activation key as pkcs8 RSA-OAEP,
decrypts the licence key, parses the payload and exits non-zero otherwise, so a
drifted pair fails the deploy instead of publishing a site that cannot decrypt
its own licence.

CONTRIBUTING records that the two values are one key and are regenerated
together, and that the .gitleaksignore entries are line-scoped.
2026-10-24 23:54:04 +00:00
dc 1f1028a2a5 ci(build): give the Cypress suite the time it now needs
Sites / deploy (push) Successful in 3m44s
Build / Build-and-ng-test (pull_request) Successful in 5m8s
Lighthouse Checks / lighthouse (pull_request) Successful in 20m14s
Build / Build-and-test-development (pull_request) Successful in 36m42s
The step's inner guard killed Cypress at 30 minutes (exitcode 124) with the
19-spec list still on spec 18, so the job failed without a single failed spec -
seen on a07728b and on the previous head. The suite has grown to roughly 33-36
minutes; the inner guard is now 45 minutes and the step 50.
2026-10-24 23:00:14 +00:00
dc a07728be2b refactor(sites): bundle the site licence instead of minting it
Sites / deploy (push) Successful in 3m59s
Build / Build-and-ng-test (pull_request) Successful in 5m15s
Lighthouse Checks / lighthouse (pull_request) Successful in 19m49s
Build / Build-and-test-development (pull_request) Failing after 35m42s
The published sites get the same key pair on every deploy, committed as
sas/mocks/mock-licence.json (site_id 123, the mock estate's SYSSITE) rather than
generated in the pipeline. The pair sits outside the deployed sync folder, so the
CI estate stays unlicensed and the Cypress specs keep exercising the free tier.

The Sites job copies the pair into the drive before the snapshot and refuses a
pair whose two values are identical, which is how the app spots an HTTP-format
key it cannot decrypt in a secure context. The pair trips the generic-api-key
scanner, so .gitleaksignore carries the two fingerprints.
2026-10-24 22:18:07 +00:00
dc 7d9df71fdb feat(sites): mint a licence for the published sites
Sites / deploy (push) Successful in 4m4s
Build / Build-and-ng-test (pull_request) Successful in 5m31s
Lighthouse Checks / lighthouse (pull_request) Successful in 20m58s
Build / Build-and-test-development (pull_request) Failing after 36m15s
The demo and staging sites ran on the free tier, so uploads and the other
licensed features were unavailable to anyone looking at them. The Sites job now
mints a self-contained key pair (site_id 123, the estate's SYSSITE) into the
drive before the snapshot, so both sites are fully usable.

Minted per deploy rather than committed, and the CI estate stays unlicensed so
the specs keep exercising the free tier.
2026-10-24 20:32:08 +00:00
dc 75b4acf1b1 test(cypress): upload the licence on a free-tier estate
Sites / deploy (push) Canceled after 1m40s
Build / Build-and-ng-test (pull_request) Successful in 5m43s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m10s
Build / Build-and-test-development (pull_request) Failing after 36m22s
The estate now ships no licence key at all - the placeholder word 'invalid' made
startupservice return an unusable key pair and produced the decrypt-failure page
on every HTTPS site. An unlicensed estate simply runs on the free tier.

loginAndUpdateValidKey therefore no longer infers 'already licensed' from the
nav tree: it detects the free-tier banner (#demo-banner) and uploads a key
through the licensing screen, which posts to admin/registerkey - the API the
licence page's upload button uses.
2026-10-24 20:30:22 +00:00
dc 73d25b1220 fix(mocks): drop derived variables before staging
stagedata.sas removes the MPE_DERIVED droplist from the submitted data before
anything is staged, and writes its approval CSV from that stripped dataset - the
mock staged the derived columns into both.
2026-10-24 20:30:17 +00:00
dc c4021a42ff fix(mocks): carry the zip flags into the central directory
`flags` was scoped to the entry loop and read by the central-directory loop, so
mpZipPackage threw a ReferenceError on every call - the audit download was
broken outright. The value now travels with each central entry.
2026-10-24 20:30:16 +00:00
dc 698dea6d6b fix(ci): dispatch the demo deploy with a tag ref and the actions scope
The dispatch endpoint resolves a bare name as a branch, so the release tag needs
the refs/tags/ prefix, and the route is scoped to the actions unit - contents
does not grant it. Without both, the step failed and the demo stayed on the
previous release.
2026-10-24 20:30:16 +00:00
dc 489819b051 fix(mocks): address the audit-pack review findings
Sites / deploy (push) Successful in 3m52s
Build / Build-and-ng-test (pull_request) Successful in 5m27s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m5s
Build / Build-and-test-development (pull_request) Successful in 33m8s
- zip entries carry flag bit 11 when the name is non-ASCII, so an extractor
  reads `BKP_` plus an accented filename back by name instead of CP437
- loadfile and stagedata name the staged CSV after the libds the request
  arrived with, before a hook can re-point the submission
- stagedata writes that CSV at all (%mp_ds2csv does), so a grid submission's
  audit pack carries the same artefact as an upload's
- loadfile writes no BKP_ file: loadfile.sas copies one only for a second,
  non-CSV fileref, which the mock does not accept
- getauditfile says access is unenforced rather than that there is no
  permission store (MPE_SECURITY is seeded)
- the special-missings clip no longer claims the estate carries a valid key
2026-10-24 20:06:09 +00:00
dc 0b7cf6a724 ci(release): dispatch the demo deploy once the release is published
The release tag is created by the release job's semantic-release step, i.e. as
the Actions user, and the forge suppresses ref events from that user, so the
Sites workflow's `on: push: tags` trigger never fires and the demo would stay on
the previous release. Dispatch the workflow with the new tag after the assets
are uploaded instead.
2026-10-24 20:06:08 +00:00
dc 62f55db170 fix(mocks): clear the licence for published sites, not for the estate
Emptying mock-storage/licence.json dropped every CI run to the free tier, where
the upload button is absent, so excel.cy.ts failed all 24 tests: the licensing
spec registers a key only when the activation page is shown, and an unlicensed
estate activates straight into the app.

The placeholder pair stays in the fixture, and the Sites job strips it from the
drive before the snapshot is taken, so the published sites still run unlicensed
without the decrypt-failure page.
2026-10-24 20:06:03 +00:00
dc 752d944c20 feat(mocks): add getauditfile and the audit-folder artefacts
Sites / deploy (push) Successful in 3m51s
Build / Build-and-ng-test (pull_request) Successful in 5m29s
Lighthouse Checks / lighthouse (pull_request) Successful in 20m58s
Build / Build-and-test-development (pull_request) Failing after 36m12s
The audit download the review screens offer (services/auditors/getauditfile)
had no JS counterpart, so the browser build answered "program not found".

getauditfile.js is the port of getauditfile.sas: table id -> libds from
MPE_SUBMIT, the approval folder zipped under contents/, streamed as
<table>.zip. The zip is built by mpZipPackage and streamed by mpStreamfile in
dcMockUtils, the JS counterparts of the ODS package and %mp_streamfile; the
browser host has a text-only webout, so there the service describes the pack
instead of streaming it.

loadfile.js now leaves the two files loadfile.sas does in the folder as well:
the cleaned CSV and BKP_<upload>.
2026-10-24 19:34:13 +00:00
dc 50f09c8db8 ci(sites): guard the seed and serialise deploys per site
Sites / deploy (push) Successful in 3m47s
Build / Build-and-ng-test (pull_request) Successful in 5m34s
Lighthouse Checks / lighthouse (pull_request) Successful in 20m41s
Build / Build-and-test-development (pull_request) Failing after 36m16s
Two review findings: the makedata call ends in || true (needed for its expected
HTML reply), so a genuine failure published a data-less site as a green build -
the drive is now checked for mpe_tables.json and at least ten tables before the
snapshot is taken. And the concurrency group is keyed on the target site rather
than the branch, so two release branches publishing staging cannot interleave.
2026-10-24 19:14:56 +00:00
dc fb36379520 fix(mocks): stop the mock advertising invalid licence keys
mock-storage/licence.json held the placeholder word 'invalid' for both keys, so
startupservice returned them as globvars and every HTTPS site showed the
'Licence key is invalid / Error decrypting license key' page. An empty pair is
what an unlicensed install has, and it lands on the free tier instead.
2026-10-24 19:14:52 +00:00
dc 6e70d203d1 ci(sites): publish the demo from the release tag, not from main
Sites / deploy (push) Successful in 3m45s
Build / Build-and-ng-test (pull_request) Successful in 5m18s
Lighthouse Checks / lighthouse (pull_request) Successful in 20m58s
Build / Build-and-test-development (pull_request) Successful in 32m25s
The demo now deploys when semantic-release creates the tag, so it can never go
live on a commit whose release then fails, and the checkout carries the tag the
client's build stamp reports.
2026-10-24 19:05:38 +00:00
dc 3ec53076e7 ci(sites): use the staging site name, with the domain as its alias
Sites / deploy (push) Successful in 3m38s
Build / Build-and-ng-test (pull_request) Successful in 5m40s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m10s
Build / Build-and-test-development (pull_request) Successful in 32m44s
2026-10-24 17:39:17 +00:00
dc bb8d007e2d ci(sites): publish release branches to staging and main to demo
Sites / deploy (push) Failing after 3m38s
Build / Build-and-ng-test (pull_request) Successful in 5m17s
Lighthouse Checks / lighthouse (pull_request) Successful in 20m11s
Build / Build-and-test-development (pull_request) Successful in 33m0s
demo.datacontroller.io is now a multi-site Surfer instance: a release branch
deploys the staging.datacontroller.io site, and a push to main deploys the demo
(default site). Both run the same browser-hosted mock build.
2026-10-24 17:31:07 +00:00
dc a128b609f8 ci(demo): publish release branches to demo.datacontroller.io
Demo preview / deploy (push) Successful in 3m47s
Build / Build-and-ng-test (pull_request) Successful in 5m12s
Lighthouse Checks / lighthouse (pull_request) Successful in 20m18s
Build / Build-and-test-development (pull_request) Successful in 30m56s
Pushes to release/** now build the mocks as a browser-hosted site (SASjs CLI
6.1.0, streamConfig.mockWeb) and publish it to the Surfer app behind
demo.datacontroller.io, so the demo always matches the release branch.

The browser build seeds its drive from the compiled services, which carries no
DC data, so the job runs makedata on a throwaway SASjs Server and ships that
drive as mock-snapshot.json.
2026-10-24 16:49:02 +00:00
allan 37abc3adb7 Merge pull request 'build(mocks): emit the browser build for the server-ci target' (#358) from feat/browser-mock-build into feat/derived-vars
Build / Build-and-ng-test (pull_request) Successful in 5m21s
Lighthouse Checks / lighthouse (pull_request) Successful in 20m25s
Build / Build-and-test-development (pull_request) Successful in 31m31s
Reviewed-on: #358
2026-10-24 13:27:37 +00:00
sasjs ab36ff8671 build(mocks): emit the browser build for the server-ci target
Build / Build-and-ng-test (pull_request) Failing after 1m49s
Build / Build-and-test-development (pull_request) Skipped
Lighthouse Checks / lighthouse (pull_request) Successful in 20m48s
streamConfig.mockWeb turns the mock site on for the target whose services are JavaScript, so sasjs build produces sasjsbuild/mock alongside the deploy artefact. mockWeb is independent of streamWeb, so the same target still deploys for real.
2026-10-24 13:24:53 +00:00
dc 56ed429183 test(derived): correct the re-add rationale, cover re-activation
Build / Build-and-ng-test (pull_request) Successful in 5m3s
Lighthouse Checks / lighthouse (pull_request) Successful in 20m24s
Build / Build-and-test-development (pull_request) Successful in 31m16s
VAR_NM carries CASE=UPCASE and the post edit hook upcases every non-delete
staged row, so a name typed in the grid reaches submit uppercase: a delete plus
a re-add of it is the same raw primary key and mpe_loader's dup check rejects
that at approval. Test 6's comment claimed the opposite, so it now states the
truth - only a stored name that is not uppercase reaches submit as two PKs, ie
a name written outside the editor.

Test 7 covers the other arm of the exclusion in the same check: setting an
inactive row active again while another active row depends on it. That starting
state is also one the hook refuses to create, so the case is defensive; both
tests prove the guard does not block the submission that repairs the data.
2026-10-24 11:18:12 +00:00
dc 6b5bf3e6be fix(derived): ignore a re-added name when collecting inactive rows
Build / Build-and-ng-test (pull_request) Successful in 4m51s
Lighthouse Checks / lighthouse (pull_request) Successful in 19m45s
Build / Build-and-test-development (pull_request) Successful in 31m8s
The inactive set behind the bad-dependency check admitted every delete-marked
staged name, so a submission that delete-marks one case variant of a variable
and stages the same name again as a live active row (the two are distinct keys
to the loader's case sensitive duplicate check) matched the dep against the
inactive copy and aborted a configuration the engine resolves fine.

Both arms now exclude a name that this submission also stages as a live active
row - the staged one, and the control row it replaces. stagedata.test.4 gets a
Test 6 for that shape: delete 'Base_X', stage 'BASE_X', with a surviving active
row declaring VAR_DEPS=BASE_X, which must stage successfully.

Verified: sasjs lint (no new warnings), sasjs cb -t nextviya with @sasjs/core
6.0.0, and the built output carries the new predicate and the test. No SAS
estate was reachable from this box (nextviya and sas.4gl.io both time out), so
the test has not been executed on a real engine yet.
2026-10-24 10:04:58 +00:00
dc 033ffd3025 fix(tests): read the work location from _SASJS_WORK
@sasjs/core 6.0.0 renamed the session globals to a _SASJS_ prefix and sets
_SASJS_WORK only; SASJSWORK no longer exists (and sasjs/server's pre-program
compat aliases never covered it). testsetup.sas still read &sasjswork in both
the SASJS and SASMETA arms of %update_settings, so under the new pin those
filename statements built unrooted paths and ms_createfile / mm_createstp wrote
outside the session work directory, breaking sasjs test setup on SASJS and SAS9.

mp_init() is called at the top of the script, so _SASJS_WORK is defined here.
2026-10-24 10:04:53 +00:00
allan ac97252b02 Merge pull request 'fix(mocks): key rk_underlying tables on their business key' (#357) from fix/mock-rk-underlying-pk into feat/derived-vars
Build / Build-and-ng-test (pull_request) Successful in 4m55s
Lighthouse Checks / lighthouse (pull_request) Successful in 19m30s
Build / Build-and-test-development (pull_request) Successful in 30m57s
Reviewed-on: #357
2026-10-24 09:24:39 +00:00
dc 821bf9d1ca Merge branch 'feat/derived-vars' into fix/mock-rk-underlying-pk
Build / Build-and-ng-test (pull_request) Successful in 5m8s
Lighthouse Checks / lighthouse (pull_request) Successful in 23m2s
Build / Build-and-test-development (pull_request) Successful in 31m10s
Two conflicts, both resolved by keeping the intent of each side:

- sas/mocks/sasjs/services/editors/getdata.js: the PK derivation and the
  pkFirst() ordering from this branch, then the derived-variable block from the
  release branch. The derived columns are added to the schema before the
  filter, so the PK-first ordering still applies to the full column set.
- client/package-lock.json: taken from this branch. The merged package.json
  differs from the release branch only in the fast-uri override (4.2.3 here,
  3.1.8 there); the release branch already carries the same @angular 20.3.34
  and @sasjs/adapter 5.1.2 pins, so the lock is unchanged from 74060c0.

Gates on the merged tree: npm audit --omit=dev 0 vulnerabilities, prettier
clean, 603/603 unit tests, ng build, license-checker. Mock services re-probed
against a local SASjs Server: MPE_ROW_LEVEL_SECURITY publishes PKCNT 10 with
the business keys first and no RLS_RK, an edit diffs as UPDATED 1 with the
base RK, and the derived table TESTDATA.DERIVED_DEMO still serves its computed
columns.
2026-10-24 08:40:44 +00:00
dc 74060c0585 fix(mocks): derive the retained key wherever rows are staged
Build / Build-and-ng-test (pull_request) Successful in 4m51s
Lighthouse Checks / lighthouse (pull_request) Successful in 19m49s
Build / Build-and-test-development (pull_request) Successful in 29m37s
Review follow-ups on the rk_underlying handling:

- mpeAssignRetainedKey (dcMockUtils) mirrors %mp_retainedkey: any submitted RK
  column is dropped and every row's RK is re-derived from the business key, so a
  stale RK in a hand-built payload cannot survive into the diff. Business keys
  compare the way SAS does - case-sensitive, trailing blanks ignored.
- editors/loadfile excludes the RK from its expected-columns check for these
  tables, as loadfile.sas does, and runs the derivation before staging, so a
  real CSV upload (which legitimately omits the RK) is accepted and its diff
  matches instead of reporting every row as NEW.
- both staging paths add the derived column to the staged headers, so
  MPE_SUBMIT.INPUT_VARS counts the width mpe_loader would see.
2026-10-24 00:32:25 +00:00
dc 0ba7450b65 chore(deps): clear the client audit advisories
Build / Build-and-ng-test (pull_request) Successful in 4m52s
Lighthouse Checks / lighthouse (pull_request) Successful in 19m38s
Build / Build-and-test-development (pull_request) Successful in 29m19s
The npm audit gate started failing on advisories that landed upstream, so
nothing can merge until the client tree is patched:

- @angular/* 20.3.33 -> 20.3.34 (XSRF token leak in HttpClient, SSR DoS in
  the router)
- @sasjs/adapter 5.1.0 -> 5.1.2 (brings a patched axios)
- fast-uri override 3.1.8 -> 4.2.3 (the override was pinning the vulnerable
  version; ajv only needs it for URI resolution)
- lockfile refresh takes moment, undici, follow-redirects and brace-expansion
  to their patched versions, and re-resolves the @angular-devkit build
  toolchain within its existing ^ range

Verified in client/: npm audit --omit=dev reports 0 vulnerabilities, npm run
test:headless is 589/589, npm run build succeeds, lint:check and
license-checker pass.
2026-10-23 23:53:15 +00:00
dc 347dbb53b0 fix(mocks): key rk_underlying tables on their business key
Build / Build-and-ng-test (pull_request) Failing after 1m55s
Build / Build-and-test-development (pull_request) Skipped
Lighthouse Checks / lighthouse (pull_request) Successful in 19m24s
getdata.sas exposes coalesce(rk_underlying, buskey) as the PK and drops the
RK column, so the editor shows the business key as read-only and never sends
the RK. The JS mock still published the RK as the PK and left it in the grid,
and stagedata had no loader to derive it back, so an edit to such a table
(MPE_ROW_LEVEL_SECURITY, MPE_SELECTBOX) could not be diffed.

The mock now mirrors that contract: PK/PKCNT come from rk_underlying, the RK
column is withheld and the PK variables lead COLHEADERS, and stagedata assigns
the RK from the business key - keeping the one the base row already holds and
minting the next value for a new key.
2026-10-23 23:30:57 +00:00
dc 2dc2885c3f chore(deps): @sasjs/cli 6.0.0 and adapter 5.1.2
Build / Build-and-ng-test (pull_request) Successful in 5m18s
Lighthouse Checks / lighthouse (pull_request) Successful in 20m6s
Build / Build-and-test-development (pull_request) Successful in 31m32s
CLI 6 ships the core 6.0.0 macros, so builds now carry the _SASJS_ session
variables; Data Controller reads none of them and calls %mp_init() with no
arguments. Adapter 5.1.2 is a patch on the 5.1.0 already in use.

Verified locally: sasjs cb -t nextviya and sasjs lint, client unit suite
603/603 and the production build.
2026-10-23 19:58:09 +00:00
dc 8806504967 fix(derived): reject a dependency on a row this submission deletes
Build / Build-and-ng-test (pull_request) Successful in 5m13s
Lighthouse Checks / lighthouse (pull_request) Successful in 19m52s
Build / Build-and-test-development (pull_request) Successful in 31m25s
The inactive set behind the VAR_DEPS check took staged rows that are not
delete-marked with active ne 1, plus control rows with active ne 1. A staged
DELETE of a relied-on row was in neither arm, so it passed every guard and the
engine aborted every read after approval. Delete-marked staged rows are now in
the inactive set, and stagedata.test.4 covers it.
2026-10-23 17:07:05 +00:00
dc aa07401766 chore(deps): bump @sasjs/core to 6.0.0
v6 renames the session macro variables to the _SASJS_ prefix and removes
mp_init(prefix=). Data Controller calls %mp_init() with no arguments and reads
none of the renamed variables, so no code change is needed; every macro file
present in 5.7.2 is still present in 6.0.0.
2026-10-23 17:07:05 +00:00
dc 4166679dbf refactor(derived): pick the filter dataset with a single %let
Build / Build-and-ng-test (pull_request) Successful in 6m4s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m50s
Build / Build-and-test-development (pull_request) Successful in 32m25s
%if %then %let ...; %else %let ...; is not valid in open code (SAS stops with
"Expected %DO not found"), which is why these sites used %do/%end blocks. One
%let with %sysfunc(ifc()) needs no blocks and reads as what it is: a choice
between two dataset names. The droplist block in getrawdata/viewdata keeps its
%if %then %do; there is no %else to pair.
2026-10-23 11:29:43 +00:00
dc 688550a28d fix(derived): stop the hooks aborting on their own macro variables
Lighthouse Checks / lighthouse (pull_request) Successful in 21m0s
Build / Build-and-ng-test (pull_request) Successful in 5m13s
Build / Build-and-test-development (pull_request) Successful in 32m7s
A hook is %included from %mp_include, so its open-code %let runs in that
macro's local scope: a %global for the same name inside the hook raises
"exists in a local environment", sets SYSCC, and puts the session into syntax
check mode - every check after it silently skipped while the step still
aborts. The derived hook now sets its (renamed) control variables in the
including scope and does not %global them.

The RLS hook also referenced rls_msg before assigning it whenever there was no
derived conflict, which produced an unresolved-symbol WARNING and SYSCC=4 on
every clean rule submission. It is initialised now.
2026-10-23 10:32:49 +00:00
dc bd4ac50a69 test(derived): cover the MPE_DERIVED submit guards through stagedata
Four cases: a dependency on an inactive row aborts, deactivating a relied-on
row aborts, an inactive row does not block an unrelated variable, and a
delete-marked RLS rule naming a derived variable loads.
2026-10-23 09:44:32 +00:00
dc 7799a300df fix(derived): refuse dependencies on inactive rows, and keep the RLS scope guard coherent
The graph now holds only effective active rows, and a declared dependency that
resolves to an inactive row (existing, or one this submission deactivates) is
rejected: the engine reads only active rows and aborts every read of the table
on the unresolved reference.

The derived-variable / RLS guard goes back to rejecting every scope. Exempting
VIEW rules left them to fail a moment later in mp_filtercheck, which validates
against the physical table where the column does not exist, so the pairing is
refused up front with a message that says why.

RLS batches now exclude delete-marked rows, matching the badloadtypes check, so
a rule being removed is not validated against the derived configuration.
2026-10-23 09:44:32 +00:00
dc 85c89ca2a4 fix(derived): ignore inactive rows and VIEW rules in the submit guards
Build / Build-and-ng-test (pull_request) Successful in 5m41s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m43s
Build / Build-and-test-development (pull_request) Successful in 33m13s
The dependency graph built at submit time pulled every open current row of
MPE_DERIVED into the union, while the engine only reads active=1 rows. An
inactive row that is still open could therefore abort a submission over a
cycle or a stale reference the engine never generates. The union now filters
active=1, matching mpe_derived.sas.

Both derived-variable guards also matched any active row level security rule,
whatever its scope. A VIEW rule is applied on the view the EDIT screen reads,
where a derived column exists, so naming one there is legitimate; only EDIT and
ALL rules run against the submitted data, where the column is absent. The
derived hook now filters rls_scope in ('EDIT','ALL'), and the RLS hook carries
rls_scope through work.batches to filter its side of the same guard, matching
the staging-side check in the same service.
2026-10-23 09:08:13 +00:00
allan 96914f1cbd Merge pull request 'feat: run startupservice in a new window from its error modal' (#356) from feat/startupservice-launcher into feat/derived-vars
Build / Build-and-ng-test (pull_request) Successful in 5m51s
Lighthouse Checks / lighthouse (pull_request) Successful in 20m52s
Build / Build-and-test-development (pull_request) Successful in 33m34s
Reviewed-on: #356
2026-10-23 09:01:05 +00:00
dc 3445107d9a fix: name the startupservice launcher button "Show log"
Build / Build-and-ng-test (pull_request) Successful in 5m13s
Lighthouse Checks / lighthouse (pull_request) Successful in 20m26s
Build / Build-and-test-development (pull_request) Successful in 31m20s
2026-10-23 03:01:23 +00:00
dc ad2da45389 fix: offer the launcher on the globvars failure modal too
Build / Build-and-ng-test (pull_request) Successful in 5m53s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m10s
Build / Build-and-test-development (pull_request) Successful in 31m25s
The globvars check in licence.service reports a failed startupservice run as
well, so its modal needs the service name for the new-window button - that was
the fourth startup failure path and the only one still a dead end.

Also guard the decodeURIComponent call in the Cypress drive/folder intercept,
so a lone '%' in a URL cannot throw inside the handler and abort the request.
2026-10-23 02:17:49 +00:00
dc de71b4456e chore: merge feat/derived-vars into the startupservice launcher branch
Build / Build-and-ng-test (pull_request) Successful in 5m43s
Lighthouse Checks / lighthouse (pull_request) Successful in 20m45s
Build / Build-and-test-development (pull_request) Successful in 32m14s
2026-10-23 01:46:08 +00:00
dc 40f61331aa feat: run startupservice in a new window from its error modal
Build / Build-and-ng-test (pull_request) Failing after 1m56s
Build / Build-and-test-development (pull_request) Skipped
Lighthouse Checks / lighthouse (pull_request) Successful in 19m25s
When the startupservice fails, the modal is a dead end: the adapter returned
no log, so there is nothing to act on. The modal now offers a button that runs
the same service through the JES / STP web app in a new browser window, where
the SAS log is returned as the page body.

`_debug=131` is always added (the log is the point), and `_executionTasks` is
never included, so on Viya the run is not submitted as a Compute task and
cannot hit the execution-task timeout. The URL is built by `buildServiceUrl`,
which works for SAS 9, Viya and SASjs Server, and is the same shape the deploy
configurator already uses to open makedata.

The startup failure modals now carry the service name, which is what shows the
button: all three failure branches (malformed response, missing properties,
request rejected) pass `public/startupservice`.
2026-10-23 01:15:31 +00:00
allan 0dd55c9796 Merge pull request 'fix(editor): keep decimal values when an uploaded file is edited manually' (#354) from fix/349-upload-edit-decimals into feat/derived-vars
Build / Build-and-ng-test (pull_request) Successful in 5m18s
Lighthouse Checks / lighthouse (pull_request) Successful in 20m8s
Build / Build-and-test-development (pull_request) Successful in 31m6s
Reviewed-on: #354
2026-10-22 22:27:59 +00:00
dc 04ce1861e7 fix(editor): keep decimal values when an uploaded file is edited manually
Build / Build-and-ng-test (pull_request) Successful in 5m19s
Lighthouse Checks / lighthouse (pull_request) Successful in 20m2s
Build / Build-and-test-development (pull_request) Successful in 31m4s
Confirming the "source file will be discarded" warning coerced every
numeric column of the preview with parseInt, which truncated the
fraction: 0.901045951946194 became 0, and 1234.56 became 1234.
parseFloat converts the same values without dropping precision, and a
cell the parser could not read as a number is left as text so the
editor's numeric validation flags it instead of showing NaN.

Adds an Excel spec case that reads the first data row before and after
the switch to edit mode and asserts the decimal value survives.
2026-10-22 21:03:05 +00:00
allan 7ac620cc22 Merge pull request 'feat(editor): serve a truncated selection with a warning instead of aborting' (#352) from feat/edit-limit-warning into feat/derived-vars
Build / Build-and-ng-test (pull_request) Successful in 5m12s
Lighthouse Checks / lighthouse (pull_request) Successful in 20m0s
Build / Build-and-test-development (pull_request) Successful in 31m9s
Reviewed-on: #352
2026-10-22 19:32:00 +00:00
dc f38d0f80ca chore(getdata): correct the Test 2 banner column count
Build / Build-and-ng-test (pull_request) Successful in 5m45s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m14s
Build / Build-and-test-development (pull_request) Successful in 31m21s
dctest.wideboy is 1004 columns (row_id, 500 char, 500 numeric, plus the
field and position loop variables), so the Test 2 banner says 200 x 1004 =
200,800 cells, matching the @details block.  The row cap is unchanged.
2026-10-22 18:13:42 +00:00
dc 9de56f8a0b chore(deps): bump Angular to 20.3.34 and follow-redirects to 1.16.1
Build / Build-and-ng-test (pull_request) Successful in 5m37s
Lighthouse Checks / lighthouse (pull_request) Successful in 20m27s
Build / Build-and-test-development (pull_request) Successful in 32m8s
The audit gate fails on advisories published against the pinned versions:
@angular/common 20.3.33 carries the XSRF token leakage reported in
GHSA-jfgx-762w-99vr, which reaches forms, platform-browser,
platform-browser-dynamic and router through their peer dependency on
common, and follow-redirects 1.16.0 carries GHSA-q3jh-wrc6-jgq8 and
GHSA-8r9p-f939-6h3c.

Both are patch bumps.  The lockfile is edited in place - the nine
@angular pins and the follow-redirects entry - rather than re-resolved,
because a full install prunes the stale entries the lockfile carries and
turns a pin change into hundreds of unrelated lines.  axios allows
^1.16.0, so the transitive bump needs no parent change.

npm audit --omit=dev reports 0 vulnerabilities at the repo root, in sas/
and in client/.  ng build and the client unit suite (589) pass.
2026-10-22 17:58:03 +00:00
dc 7df4cb29cc chore(getdata): declare mpeinit and mpeterm, correct the fixture counts
Build / Build-and-ng-test (pull_request) Failing after 1m49s
Build / Build-and-test-development (pull_request) Skipped
Lighthouse Checks / lighthouse (pull_request) Successful in 20m55s
The doxygen list omitted mpeinit and mpeterm, which the service calls - both
are now declared the way the other services that use mpeinit declare it.  The
compiler inlines them, as it does for those services.  webout stays out of the
list: it is supplied by the framework rather than a macro in the repo, so that
one warning needs an exception in the linter.

dctest.wideboy has 1004 columns, not 1001 - row_id, char1-char500,
num1-num500, plus the loop variables field and position and the scratch
variable alphabet - so the test header's cell arithmetic is 200 x 1004 =
200,800.  The 199 row cap is unchanged, floor(200000/1004) is still 199.

The MPE_X_WIDE mock comment said 200,400 cells for 200 x 1001 columns, which
was the old counting that added the client-owned delete column.  The mock now
counts the table's own columns, so it is 200,200.
2026-10-22 17:44:56 +00:00
dc 66c233602f style(sas): keep the truncation message within the lint line limit
Build / Build-and-ng-test (pull_request) Failing after 1m51s
Build / Build-and-test-development (pull_request) Skipped
Lighthouse Checks / lighthouse (pull_request) Successful in 19m45s
The message is built in three short assignments rather than one 161
character %let, which .sasjslint rejects (maxLineLength 107).  The
resulting text is unchanged.
2026-10-22 16:56:27 +00:00
dc 5585ae481e docs(comments): describe the limit handling, not the behaviour it replaced
Build / Build-and-ng-test (pull_request) Failing after 1m57s
Build / Build-and-test-development (pull_request) Skipped
Lighthouse Checks / lighthouse (pull_request) Successful in 19m53s
The getdata comment explained the truncation by contrast with the abort it
replaces.  State what the code does and why instead.  Same for the two
not-refused phrasings in the mock and the test header.
2026-10-22 16:49:21 +00:00
dc 34691dceec feat(editor): serve a truncated selection with a warning instead of aborting
Build / Build-and-ng-test (pull_request) Failing after 3m13s
Build / Build-and-test-development (pull_request) Skipped
Lighthouse Checks / lighthouse (pull_request) Successful in 19m53s
A selection over DC_MAXOBS_WEBEDIT or DC_MAXCELLS_WEBEDIT was refused with
"Table is too big", leaving the user with no grid and no way to see what the
filter had matched.  getdata now serves the first rows that fit and flags it:

- the row cap is min(DC_MAXOBS_WEBEDIT, DC_MAXCELLS_WEBEDIT / columns), so a
  wide table is still bounded by the cell budget
- sasparams carries TRUNCATED (0/1) and TRUNC_MSG, naming the limit reached
- the EDIT screen shows a warning triangle next to the table name, with the
  message in a tooltip and the advice to filter

The mock mirrors the new behaviour, the Cypress spec asserts the truncated
grid and the tooltip (real hover, opacity checked so a hidden tooltip cannot
pass), and the SAS limit test now asserts the served row counts and message
rather than a cancelled child job.
2026-10-22 16:38:37 +00:00
dc 422f766cd4 fix(derived): close the two gaps in the derived/RLS invariant
Lighthouse Checks / lighthouse (pull_request) Successful in 21m46s
Build / Build-and-ng-test (pull_request) Successful in 5m56s
Build / Build-and-test-development (pull_request) Successful in 30m41s
- the join against MPE_DERIVED compared an upcased var_nm with the rule's
  column as stored, so a case variant slipped past the explanatory rejection
  and fell through to the generic filter check.  Both sides are upcased.
- the invariant was enforced only from the RLS side: configuring a derived
  variable for a table whose active rule already names it passed the derived
  hook.  The same check now runs there, against the incoming libref/dsn/var_nm.
2026-10-21 22:31:56 +00:00
dc af31c36b67 fix(derived): only the delete marker is a reserved name
Build / Build-and-ng-test (pull_request) Successful in 5m54s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m52s
Build / Build-and-test-development (pull_request) Successful in 33m6s
A table's metadata columns are named per table - VAR_TXFROM, VAR_TXTO and
VAR_PROCESSED in MPE_TABLES hold those names, and they can be anything (the
bitemporal demo table uses VER_FROM_DTTM and VER_TO_DTTM).  When they exist
they are physical columns of the target, so the engine's collision check
already refuses a VAR_NM that matches one, whatever it is called.

The hard-coded list treated three example names as special: it refused them on
tables that use other names, and told the reader something untrue about what
is reserved.
2026-10-21 22:23:10 +00:00
dc c5a8de38b3 test(derived): guard the getdata fixture, fix two harness faults, cover the RLS rejection
Build / Build-and-ng-test (pull_request) Successful in 5m57s
Lighthouse Checks / lighthouse (pull_request) Successful in 20m56s
Build / Build-and-test-development (pull_request) Successful in 32m39s
- getdata.test now checks the child job state before reading its outlib: a
  cancelled child returns no webout, so the previous call's members were being
  asserted against and the failures described the wrong problem.
- validatefilter.test's inputdatasets call needs a _PROGRAM column, which
  mx_execute reads to decide what to run - without it the job stopped at
  'The _PROGRAM column must exist'.
- stagedata.test.3 covers the new RLS check: an MPE_ROW_LEVEL_SECURITY rule
  naming a derived variable must be rejected at save time.
2026-10-21 17:59:49 +00:00
dc ccd468b335 fix(derived): address five review findings
Build / Build-and-ng-test (pull_request) Successful in 6m31s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m40s
Build / Build-and-test-development (pull_request) Successful in 32m51s
- getrawdata selected its filter target with a bare '%else %let' in open code,
  the shape that stops the step with 'Expected %DO not found'. Both branches
  are %do/%end now, so the download service applies a filter at all.
- the conditional drop in the approve.jsdset step and the one against
  MPSEARCH.vwsearch are dead: work.jsdata and work.vwsearch are already
  rewritten without the derived columns, so the drops only warn.
- the post-edit hook checked that a reference is declared, but not that a
  declared dependency is referenced. The engine rejects the stale shape at read
  time, so the same check now rejects it at submit time.
- a row level security rule naming a derived variable is rejected when the rule
  is saved, with a message that says why, instead of failing later at submit.
- getdata.test's cleanup deleted only DERIVED_NOTE, leaving DERIVED_NOTE2 with
  a dependency on a variable that no longer exists, which aborted every call in
  the following run - the cause of the repeated no-result runs.
2026-10-21 14:02:18 +00:00
dc 88a38db877 fix(derived): pair the filterds %else with %do/%end
Build / Build-and-ng-test (pull_request) Successful in 6m7s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m54s
Build / Build-and-test-development (pull_request) Successful in 33m33s
Both services selected the filter target with
'%if cond %then %let x=y; %else %let x=z;'. In open code the %else is not
paired with the %then and the whole step stops with 'Expected %DO not found',
so neither service ever applied a filter: every assertion downstream failed and
the job produced no results at all.

Found on a real estate - the pattern works inside a macro definition, which is
why the same construct elsewhere in the codebase is fine.
2026-10-21 13:34:02 +00:00
dc 4c198c6e31 chore(deps): @sasjs/core 5.7.2
Build / Build-and-ng-test (pull_request) Successful in 5m59s
Lighthouse Checks / lighthouse (pull_request) Successful in 23m0s
Build / Build-and-test-development (pull_request) Successful in 34m6s
Carries the abort-shape fix from sasjs/core#468 - the cycle abort no longer
re-enters its placement loop, and the validation aborts are driven by a count
at the point of call.
2026-10-21 13:04:31 +00:00
dc 561d7fc3ed fix(derived): eight review findings
Build / Build-and-ng-test (pull_request) Successful in 5m26s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m0s
Build / Build-and-test-development (pull_request) Successful in 33m29s
- mpe_derived_postedit ran %local at the top level of a file that is included
  at execution time, so the hook stopped before validating anything and every
  MPE_DERIVED submission failed. The macro language now lives in a macro
  definition, errflag and errmsg are declared global, and the file calls it.
- the cycle check aborts after leaving its loop, and the self, undeclared and
  field aborts are driven by a count at the point of call rather than by a
  wrapping %if, so an abort cannot be outlived. The same shapes are fixed
  upstream in sasjs/core#468.
- a case-variant duplicate survived mpe_loader, which compares raw values:
  the hook now rejects it after normalisation and names the variable.
- stagedata dropped the derived columns from the output of the security step
  rather than from its input, so a crafted submission could still satisfy an
  RLS clause through a derived column. work.jsdata itself is rewritten now.
- the VAR_FMT validation rule rejected the bare w.d forms that the hook and
  the engine accept.
- the migration carried the same four dangling insert headers as mpe_makedata,
  so the v7.19 migration failed for existing installs.
- the filter fixtures in getdata.test and validatefilter.test had been caught
  by the variable rename: the filter schema column is VARIABLE_NM.
- the TX_FROM/TX_TO definitions agree across the ddl, the migration and
  mpe_makedatamodel.

@sasjs/core bumped to 5.7.1, which carries the where= fix.
2026-10-21 12:54:34 +00:00
dc 9a047ad42e fix(derived): repair the validation rows for MPE_DERIVED
Build / Build-and-ng-test (pull_request) Successful in 6m20s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m5s
Build / Build-and-test-development (pull_request) Successful in 32m21s
Four stray insert lines and a missing one left the MPE_VALIDATIONS block for
MPE_DERIVED malformed, so makedata aborted and the control table was never
created - found by building the library on a real estate.

The format assertions in the test now read the emitted statement, because proc
contents on a view does not report a format in full ('DATE' for DATE9.).
2026-10-21 12:06:35 +00:00
dc 616a2928e7 refactor(derived): use mp_derivedvars from @sasjs/core
Build / Build-and-ng-test (pull_request) Successful in 5m53s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m28s
Build / Build-and-test-development (pull_request) Successful in 34m10s
mpe_derived carried its own copy of the derived variable engine. That engine
now ships in @sasjs/core 5.7.0 as mp_derivedvars, so the local copy is gone and
mpe_derived is a wrapper that supplies what is Data Controller's own: the
control table, the bitemporal filter that picks the current active row, and the
list of metadata names that may not be used as a variable.

The macro goes from 511 lines to 102. The contract and the generated statement
format are unchanged, so the callers in the six services need no edit.

A test covers the wrapper's own responsibility: a closed version of a row and
an inactive row are both ignored.
2026-10-21 11:27:37 +00:00
dc ee31782fbf fix(derived): open the statements file with a long record length
Lighthouse Checks / lighthouse (pull_request) Successful in 20m13s
Build / Build-and-ng-test (pull_request) Successful in 5m3s
Build / Build-and-test-development (pull_request) Successful in 31m9s
A label at the 256 character limit produces an attrib statement of about 300
characters, which the default record length wraps, splitting the statement and
breaking the view.
2026-10-21 10:15:33 +00:00
dc ace4a66701 test(derived): snapshot the scope after the setup SQL
Build / Build-and-ng-test (pull_request) Successful in 5m29s
Lighthouse Checks / lighthouse (pull_request) Successful in 20m24s
Build / Build-and-test-development (pull_request) Successful in 31m30s
Same harness fix as the core test: the setup inserts create the SQL automatic
macro variables, so the snapshot has to come after them, and the framework's
_SASJS_WEBOUT_HEADERS is ignored.
2026-10-21 09:10:59 +00:00
dc 7c9ce9a712 feat(derived): optional VAR_LABEL on MPE_DERIVED
Build / Build-and-ng-test (pull_request) Successful in 5m27s
Lighthouse Checks / lighthouse (pull_request) Successful in 20m23s
Build / Build-and-test-development (pull_request) Successful in 31m25s
A configured label replaces the default NAME=expression; when it is missing or
blank the default is used, so existing rows are unaffected.

- MPE_DERIVED gains VAR_LABEL varchar(256), in the ddl, mpe_makedatamodel and
  the v7.19 migration
- mpe_derived uses it, through the same escaping and 256-char truncation as
  the default label
- the hook trims it; the mock mirrors the fallback
- test 1 now sets a label on BAND and asserts it
2026-10-21 08:14:53 +00:00
dc 1b5c4b3ecd fix(derived): truncate the derived label on an even quote boundary
Build / Build-and-ng-test (pull_request) Successful in 5m18s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m0s
Build / Build-and-test-development (pull_request) Successful in 31m34s
The label had 256 chars of storage, so the doubled quotes were truncated on
assignment; a cut between the halves of an escape leaves an unterminated
literal and the view fails to build.  The label now holds 512 chars, quotes
are doubled, then the 256 boundary is applied, retreating one character when
the cut lands on an odd quote count.
2026-10-21 01:49:04 +00:00
dc 5f5d8681b9 fix(derived): guard the empty physical column list in the dep check
Build / Build-and-ng-test (pull_request) Successful in 5m21s
Lighthouse Checks / lighthouse (pull_request) Successful in 20m56s
Build / Build-and-test-development (pull_request) Successful in 31m28s
Same fix as the core review: mf_getvarlist returns nothing for a zero-column
table, so the dependency query compiled as 'not in ()'.
2026-10-21 01:05:10 +00:00
dc 9f70c56b58 chore(deps): @angular/* 20.3.34
Lighthouse Checks / lighthouse (pull_request) Successful in 20m25s
Build / Build-and-ng-test (pull_request) Successful in 4m57s
Build / Build-and-test-development (pull_request) Successful in 30m35s
GHSA-jfgx-762w-99vr (XSRF token leakage in the HTTP client, an incomplete fix
for CVE-2025-66035) covers @angular/common 20.0.0 - 20.3.33, and the 'Check
audit' step fails on any vulnerability, so every branch is red until this
lands.

The nine packages are pinned exactly and must move together - Angular pins its
siblings at the same patch level - so package.json and the lock are updated as
a set.  The local tarball for @sheet/crypto is only present in encrypted form,
so npm cannot run here; the client build and unit tests in CI are the proof.
2026-10-20 23:00:07 +00:00
dc 5af8e6fc35 revert(derived): no shadowing, and no self reference
Allowing a derived variable to reuse a physical column's name was wrong: an
intra-row calculation (SUMMER = SUMMER + 1) implies a retained value, which
depends on the order rows are read.  The same argument rules out a variable
listing itself as a dependency.

Reverted:
- the name collision is an error again ('VAR_NM collides with a column in the
  target table')
- VAR_DEPS may not list the variable itself
- the physical column list is back to mf_getvarlist - the type/length check
  only existed to support shadowing
- the droplist guards in stagedata / viewdata / getrawdata, and the duplicate
  suppression in getdata, are no longer needed
- the docs and both test suites drop the shadowing case
2026-10-20 23:00:01 +00:00
dc 6a1128a463 fix(derived): do not duplicate a shadowed column in the grid payload
Build / Build-and-ng-test (pull_request) Failing after 1m57s
Build / Build-and-test-development (pull_request) Skipped
Lighthouse Checks / lighthouse (pull_request) Successful in 20m45s
cols1 already holds the physical column, so appending the derived row for the
same name would send it twice.  The derived row is now appended only when the
name is new; a shadowed column keeps the physical label and format, while its
value comes from the view.
2026-10-20 22:28:44 +00:00
dc 2d5de9a324 fix(derived): do not drop a shadowed column from staging or downloads
Build / Build-and-ng-test (pull_request) Failing after 2m0s
Build / Build-and-test-development (pull_request) Skipped
Lighthouse Checks / lighthouse (pull_request) Failing after 4m48s
With shadowing allowed, a derived name can also be a physical column.  The
derived droplist was built from every derived name, so such a column would
have been dropped on submission (losing the user's edit) and from the view
and raw downloads (losing a real column).

stagedata, viewdata and getrawdata now subtract the target's own columns
from the droplist, so only genuinely virtual columns are dropped.
2026-10-20 22:27:19 +00:00
dc 12beeff695 feat(derived): allow a variable to shadow a physical column
Build / Build-and-ng-test (pull_request) Failing after 1m57s
Build / Build-and-test-development (pull_request) Skipped
Lighthouse Checks / lighthouse (pull_request) Successful in 21m6s
SUMMER = SUMMER + 1 is a legitimate definition: the name matches an existing
column, the expression reads the physical value, and the view carries the
derived one under the same name.

- mpe_derived now takes the physical column list from mp_getcols, so it has
  type and length in one pass; a shadowed name must match the column's type,
  and its length may not exceed it (numeric lengths must be equal), as attrib
  cannot change either on an existing variable
- a self reference is only an error when the name is not a physical column
- self references are not graph edges, so the dependency sort is unaffected
- the post edit hook no longer rejects a self reference, since it cannot see
  the target library; it still rejects cycles between distinct names
- mpe_derived now uses mf_getuniquename for its working tables, matching the
  core macro, and carries the same placement sequence fix
- test 4 covers shadowing, docs cover the rule
2026-10-20 22:25:16 +00:00
dc 1055a0bc4a chore(deps): follow-redirects 1.16.1
Build / Build-and-ng-test (pull_request) Successful in 5m50s
Lighthouse Checks / lighthouse (pull_request) Successful in 20m30s
Build / Build-and-test-development (pull_request) Successful in 31m45s
Two high advisories were published against 1.16.0 in the last few hours
(GHSA-q3jh-wrc6-jgq8, GHSA-8r9p-f939-6h3c), and the 'Check audit' step fails
on any vulnerability, so every run after 18:33 UTC today is red - including
the ones on this branch that had passed earlier.

Lockfile only, no source change: 1.16.1 satisfies the ^1.15.6 range axios
asks for.  Worth cherry-picking to main, since it blocks every PR.
2026-10-20 21:34:21 +00:00
dc d2de3ce88a test(derived): pin the macro's scope in mpe_derived.test.sas
Build / Build-and-ng-test (pull_request) Failing after 1m58s
Build / Build-and-test-development (pull_request) Skipped
Lighthouse Checks / lighthouse (pull_request) Successful in 21m2s
2026-10-20 21:26:30 +00:00
dc be50cb5d08 fix(derived): carry the placement sequence in mpe_derived
Build / Build-and-ng-test (pull_request) Failing after 1m53s
Build / Build-and-test-development (pull_request) Skipped
Lighthouse Checks / lighthouse (pull_request) Successful in 21m11s
Same defect as the core review found in mp_derivedvars: the final assemble
merged the placement table by name, but that table is built in dependency
rounds and is not name ordered, so the BY-merge failed whenever the rounds
were not alphabetical, and seq was a BY-group counter rather than the
dependency order.

Each round now stamps seq=_n_+nplaced, the merge runs on the name-sorted
tables, and the result is sorted by seq.  Also guards the physical-column
in-list for a zero-column table, and checks libds exists before running.
2026-10-20 21:24:03 +00:00
dc ee5d7c678e refactor(derived): VAR_DEPS instead of VAR_DEPENDENCIES
Build / Build-and-ng-test (pull_request) Successful in 5m25s
Lighthouse Checks / lighthouse (pull_request) Successful in 20m43s
Build / Build-and-test-development (pull_request) Successful in 31m50s
Shorter column name, same meaning: every input the expression uses,
physical columns and derived variables alike.
2026-10-20 18:33:24 +00:00
dc cee1a954ee feat(derived): report the dependency cycle path
Build / Build-and-ng-test (pull_request) Successful in 5m21s
Lighthouse Checks / lighthouse (pull_request) Successful in 20m59s
Build / Build-and-test-development (pull_request) Successful in 31m35s
The cycle check was already in the post edit hook and in mpe_derived - the
topological sort places a variable only once its derived inputs are placed,
and if a round places nothing the remainder is a cycle.  What it reported was
just the set of names that could not be placed, which does not tell the
administrator what to change.

The message now walks the edges from the first unresolved variable and names
the loop, eg:

  VAR_DEPENDENCIES contains a cycle: A -> B -> C -> A

Names are collected in a dotted seen list, so a lead-in to the loop is shown
as well and the walk stops at the first repeat.
2026-10-20 17:52:04 +00:00
dc 5f6569b9e5 test(derived): declare the real dependencies in the SAS tests
Build / Build-and-ng-test (pull_request) Successful in 6m5s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m21s
Build / Build-and-test-development (pull_request) Successful in 32m59s
The test fixtures now state the inputs their expressions actually use:
LIBREF DSN, DERIVED_NOTE, PRIMARY_KEY_FIELD, and none for a literal-only
expression.  The getdata label assertion follows the new <VAR_NM>=<expr>
label.
2026-10-20 17:06:59 +00:00
dc 813bce6856 feat(derived): declare VAR_DEPENDENCIES instead of VAR_ORDER
Build / Build-and-ng-test (pull_request) Successful in 5m49s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m29s
Build / Build-and-test-development (pull_request) Successful in 32m28s
A sequence number has to be maintained by the administrator: inserting a
variable means renumbering everything after it, and a wrong number fails
silently - the derived value comes back missing, not wrong.

VAR_DEPENDENCIES names every input the expression uses, physical columns and
derived variables alike, eg 'REGION STATE'.  The statements are emitted in
dependency order, so a variable can build on another one with no sequence to
maintain, and a cycle is rejected.

The declaration is checked against the expression in both directions: a
derived variable referenced by VAR_LOGIC must be declared, and a declared
name that the expression does not reference is an error; a declared name must
resolve to a column of the target table or to a derived variable of the same
table.

Changes:
- MPE_DERIVED: VAR_ORDER (num) becomes VAR_DEPENDENCIES (varchar(4000)), in
  the ddl, mpe_makedatamodel and the v7.19 migration
- mpe_derived: dependency validation and a topological sort replace the
  VAR_ORDER sort and the forward-reference check
- mpe_derived_postedit: checks self reference, undeclared derived references
  and cycles at submit time, across the incoming rows plus the current rows
  they do not replace
- MPE_VALIDATIONS: the VAR_ORDER MINVAL/MAXVAL rules are replaced by UPCASE
  and a character pattern on VAR_DEPENDENCIES
- mocks: the dependency order is applied in JS, so a chained variable works
  even when the rows are loaded child first
- docs and tests updated
2026-10-20 16:51:08 +00:00
dc 4d9ad38df1 fix(derived): verify on stripped values, and label with the variable name
Build / Build-and-ng-test (pull_request) Successful in 5m47s
Lighthouse Checks / lighthouse (pull_request) Successful in 20m49s
Build / Build-and-test-development (pull_request) Successful in 32m2s
Found running the macro on a real Viya compute server:

- verify() was applied to fixed width variables, so the blank padding was
  always outside the allowed character list and every name was rejected.  The
  check now runs on strip(name) and strip(fmt).
- the empty outvars dataset used 'length name $32; stop;', which the Viya
  compute server rejects with 'Variable name is uninitialized'.  It now
  assigns the variable and subsets with 'if 0;'.
- the label is now <VAR_NM>=<expression> rather than 'DERIVED: <expression>',
  so the grid shows the variable name as well as the logic.
2026-10-20 11:51:58 +00:00
dc 8bae21230e refactor(derived): drop the duplicate name check from the hook
Lighthouse Checks / lighthouse (pull_request) Successful in 21m9s
Build / Build-and-ng-test (pull_request) Successful in 4m59s
Build / Build-and-test-development (pull_request) Successful in 31m21s
mpe_loader already sorts work.staging_ds by the business key
(buskey='LIBREF DSN VAR_NM') and aborts on duplicates before the post edit
hook runs, so the same check in the hook could never fire.

The check in %mpe_derived stays: it reads the control table, which can also
be edited outside the app.
2026-10-20 09:56:52 +00:00
dc 27ddcfb575 fix(derived): raise the VAR_ORDER ceiling to 10000
Build / Build-and-ng-test (pull_request) Successful in 6m7s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m47s
Build / Build-and-test-development (pull_request) Successful in 32m0s
The client side MAXVAL cannot express 'integer', so integrality stays in the
macro and the hook. The ceiling is only there to catch a typo.
2026-10-20 09:07:18 +00:00
dc aa6d50e1ea feat(derived): validation rules and a VAR_TYPE dropdown for MPE_DERIVED
Build / Build-and-ng-test (pull_request) Successful in 5m30s
Build / Build-and-test-development (pull_request) Canceled after 7m45s
Lighthouse Checks / lighthouse (pull_request) Canceled after 19m16s
Client side rules on the table itself, so mistakes show up in the grid:
  - NOTNULL on LIBREF, DSN, VAR_NM, VAR_ORDER, VAR_TYPE, VAR_LEN, VAR_LOGIC
  - UPCASE on LIBREF, DSN, VAR_NM, VAR_TYPE, VAR_FMT
  - MINVAL 1 / MAXVAL 999 on VAR_ORDER, MINVAL 1 / MAXVAL 32767 on VAR_LEN
  - HARDREGEX on VAR_NM, VAR_FMT and VAR_LOGIC
  - HARDSELECT on VAR_TYPE, with C and N in MPE_SELECTBOX

Seeded in mpe_makedata and in the v7.19 migration. The post edit hook stays
the server side enforcement, since these rules are evaluated in the browser.

Also fixes the MPE_DERIVED create in mpe_makedatamodel, which still used the
old VARIABLE_* column names.
2026-10-20 09:01:55 +00:00
dc 0b010f2c65 feat(derived): validate MPE_DERIVED at submit time
Build / Build-and-ng-test (pull_request) Successful in 6m19s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m24s
Build / Build-and-test-development (pull_request) Successful in 32m50s
A post edit hook on MPE_DERIVED applies the configuration rules when a row
is submitted, rather than only when the EDIT screen next builds the view.

It normalises LIBREF, DSN, VAR_NM, VAR_TYPE and VAR_FMT, and rejects:
  - a missing LIBREF, DSN or VAR_NM
  - an invalid or reserved name, or one starting with a digit
  - a VAR_ORDER that is not a positive integer
  - a VAR_TYPE other than C or N
  - a VAR_LEN outside 1-32767 (character) or 1-8 (numeric)
  - a VAR_FMT that is not a plain format name
  - a VAR_LOGIC that is not a single expression
  - the same VAR_NM twice for one table
  - a reference to a derived variable that is not defined earlier

The name collision with a physical column is left to %mpe_derived, which runs
with the target library assigned; the hook does not have it.
2026-10-20 08:20:52 +00:00
dc 52803225df refactor(derived): shorten the MPE_DERIVED column names to VAR_*
Build / Build-and-ng-test (pull_request) Successful in 5m25s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m5s
Build / Build-and-test-development (pull_request) Successful in 32m53s
MPE_DERIVED columns:
  VARIABLE_NM     -> VAR_NM
  VARIABLE_ORDER  -> VAR_ORDER
  VARIABLE_TYPE   -> VAR_TYPE
  VARIABLE_LENGTH -> VAR_LEN
  VARIABLE_FORMAT -> VAR_FMT
  VARIABLE_LOGIC  -> VAR_LOGIC

Updated in the ddl, mpe_makedatamodel, the migration, the MPE_TABLES buskey,
the macro (reads and validation messages), the tests, the mocks and the docs.
2026-10-20 07:57:49 +00:00
dc 3ede98fb18 test(derived): cover mpe_derived directly
Build / Build-and-ng-test (pull_request) Successful in 5m14s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m0s
Build / Build-and-test-development (pull_request) Successful in 31m0s
The macro carries the ordering and statement-generation logic, so it is
worth a test of its own:

- character, numeric and chained variables: statements, view, columns
- label and default/configured formats
- a filter on a derived column
- the no-config path: no view, no statements, empty outvars

The configuration checks call mp_abort, so they stay out of the suite.
2026-10-20 03:31:08 +00:00
dc 9ef813d439 fix(derived): close the RLS and search gaps on derived columns
Build / Build-and-ng-test (pull_request) Successful in 5m12s
Lighthouse Checks / lighthouse (pull_request) Successful in 20m12s
Build / Build-and-test-development (pull_request) Successful in 30m59s
Review follow-up.

- stagedata: build the derived droplist before the ULOAD filter, and drop the
  derived columns in the badrecords step, so the row-level-security check runs
  against physical columns only.  A submitted derived value can no longer
  satisfy an RLS clause that names a derived column.
- viewdata: the full-table search view now drops the derived columns too, so
  a free-text search cannot match a value in an EDIT-only column.
- mpe_derived: upcase the libds when matching MPE_DERIVED, so a lower-case
  table reference still finds its configuration instead of silently showing
  no derived columns.
2026-10-20 02:49:24 +00:00
dc 33d043ea92 feat(derived): VARIABLE_ORDER, so a derived variable can build on another
Build / Build-and-ng-test (pull_request) Successful in 5m15s
Lighthouse Checks / lighthouse (pull_request) Successful in 20m15s
Build / Build-and-test-development (pull_request) Successful in 31m0s
A derived variable may need to reference one defined before it (a label that
concatenates a derived region, for example), so the order has to be
configured rather than assumed.

- MPE_DERIVED: new VARIABLE_ORDER column (ddl, makedatamodel, migration).
- mpe_derived: statements are generated in VARIABLE_ORDER then name sequence.
  VARIABLE_ORDER is required and must be a positive integer. A reference to a
  derived variable with an equal or higher order is rejected, because SAS
  would otherwise treat it as uninitialised and return a missing value with no
  error; references to lower-order variables are allowed.
- mock: same ordering, each variable compiled against the physical columns
  plus those defined earlier, so the frontend can exercise a chained variable.
- makedata: VARIABLE_ORDER on both demo rows plus a chained REGION_LABEL.
- cypress: asserts the chained value in the grid.
- tests: the getdata test seeds a second variable built on the first.
- docs: VARIABLE_ORDER documented, with the ordered rule replacing the
  self-contained one.
2026-10-20 02:09:37 +00:00
dc d3dec682aa fix(derived): enforce the documented limits, and handle derived filters in VIEW
Build / Build-and-ng-test (pull_request) Successful in 5m20s
Lighthouse Checks / lighthouse (pull_request) Successful in 19m59s
Build / Build-and-test-development (pull_request) Successful in 30m51s
Review follow-up.

- viewdata / getrawdata: apply the filter against the derived view when one
  exists, so a saved filter that references a derived column no longer aborts
  with "Variable ... is not on file" in the VIEW screen or a download.  The
  derived columns themselves are dropped from that output - they remain an
  EDIT screen feature.
- mpe_derived: reject a VARIABLE_FORMAT containing a semicolon, macro trigger
  or comment, and require it to look like a format name, so it cannot inject a
  statement into the generated data step.
- mpe_derived: reject a VARIABLE_LOGIC that references another derived
  variable (word-boundary match), which was documented but not enforced.
- mpe_derived: stmt length 4100 so a 4000-char expression plus the name and
  assignment cannot lose its trailing semicolon.
- mock tokenizer: allowlist the punctuation the expression subset uses, so
  anything else is rejected instead of being evaluated.
2026-10-20 01:24:50 +00:00
dc 05ad5544e2 ci: run the derived-variables spec last
Build / Build-and-ng-test (pull_request) Successful in 5m12s
Lighthouse Checks / lighthouse (pull_request) Successful in 19m58s
Build / Build-and-test-development (pull_request) Successful in 30m47s
It applies a licence key via loginAndUpdateValidKey, and csv-limited
asserts the free-tier restriction - so it must not run before that spec.
Running last keeps the licence state every other spec sees unchanged.
2026-10-20 00:43:22 +00:00
dc 8e82a7c857 ci: run the derived-variables cypress spec
Build / Build-and-ng-test (pull_request) Successful in 6m6s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m7s
Build / Build-and-test-development (pull_request) Failing after 31m36s
The cypress step takes an explicit spec list, so a new spec has to be
added to it to run.
2026-10-20 00:03:04 +00:00
dc 11ae41d626 test(cypress): open the EDIT screen filter from the editor, not the viewer
Build / Build-and-ng-test (pull_request) Successful in 5m52s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m35s
Build / Build-and-test-development (pull_request) Canceled after 24m37s
The editor has no .filterSide button - the filter is opened from the
btnView button above the grid.
2026-10-19 23:38:57 +00:00
dc b4fae88ae1 feat(mocks): implement derived variables in the JS services
Build / Build-and-ng-test (pull_request) Successful in 6m5s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m27s
Build / Build-and-test-development (pull_request) Successful in 32m52s
So the EDIT screen can be exercised before a SAS server is available.

- dcMockUtils: mpeDerived() reads MPE_DERIVED for a table and mpeDeriveRows()
  adds the computed columns to the rows and to the schema, before the filter
  runs - the same order as the real service.
- compileSasExpression() covers the subset of SAS expression syntax the mock
  configs use (ifc, cats, catx, upcase, lowcase, strip, trim, length, substr,
  index, comparisons, AND/OR and IN). Anything else leaves the column missing,
  as a failing SAS expression would. The real evaluation is done by SAS.
- getdata / getcolvals apply the derivation before filtering, so a filter on a
  derived column works and dropdown values come from the derived data.
- makedata: MPE_DERIVED schema and rows, a new TESTDATA.DERIVED_DEMO table
  (REGION, LINE_VALUE) and its MPE_TABLES registration.
- cypress: derived-variables.cy.ts covers display and filtering on a character
  and a numeric derived column.

Also bumps @sasjs/core to 5.6.0, which provides the mp_filterstore targetds=
parameter the validatefilter change depends on.
2026-10-19 23:18:46 +00:00
dc b0fd354ecd feat(editor): derived variables in the EDIT screen
Build / Build-and-ng-test (pull_request) Successful in 5m21s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m24s
Build / Build-and-test-development (pull_request) Successful in 31m28s
Adds user-defined computed variables, configured per table in the new
MPE_DERIVED control table. A derived variable is declared with a name,
type, length, optional format and a SAS expression.

The expression is applied in a DATA step view, and the filter is applied
by the step that reads the view, so the variables exist before the
filter is evaluated and can therefore be filtered on. A WHERE clause
cannot see a variable created in the same data step, hence the view.

- getdata: derived columns are added to the COLS payload (read only,
  labelled DERIVED: <expression>) and to the data returned to the grid
- getcolvals: values are taken from the derived view, so filter dropdowns
  work for derived columns
- validatefilter: the filter is validated against the view, and stored
  against the physical table (needs mp_filterstore targetds=)
- stagedata: derived columns are dropped before the approval package
- mpe_makedata / mpe_makedatamodel / ddl / migration: new MPE_DERIVED
  table, registered in MPE_TABLES

Values are display-time only - nothing derived is ever written back.
2026-10-19 22:58:19 +00:00
96 changed files with 7741 additions and 1897 deletions

No files matched your search

+4 -3
View File
@@ -142,6 +142,7 @@ jobs:
echo NODE_PATH=node >> .env
echo CORS=enable >> .env
echo WHITELIST=http://localhost:4200 >> .env
echo HTTP_PORT=5000 >> .env
# The server refuses to run as root unless RUN_AS names an account
# (a security change in the recent releases). This job runs as root
# in a throwaway container, so opt in explicitly.
@@ -181,7 +182,7 @@ jobs:
sasjs request services/admin/makedata -t server-ci -d deploy/makedata.json -o ./makedata_out.json || true
- name: Prepare and run frontend and cypress
timeout-minutes: 35
timeout-minutes: 50
run: |
cd ./client
mv ./cypress.env.example.json ./cypress.env.json
@@ -196,9 +197,9 @@ jobs:
replace-in-files --regex='"hosturl".*' --replacement='hosturl:"http://localhost:4200",' ./cypress.config.ts
cat ./cypress.config.ts
# Start frontend and run cypress
# timeout 1800: SIGTERM after 30 min so Cypress can flush video/screenshots
# timeout 2700: SIGTERM after 45 min so Cypress can flush video/screenshots
# before the outer timeout-minutes hard-kills the step (avoids silent multi-hour hangs)
npx ng serve --host 0.0.0.0 --port 4200 & npx wait-on http://localhost:4200 && timeout 1800 npx cypress run --browser chrome --spec "cypress/e2e/csv-limited.cy.ts,cypress/e2e/hook-programs.cy.ts,cypress/e2e/liveness.cy.ts,cypress/e2e/editor.cy.ts,cypress/e2e/excel-multi-load.cy.ts,cypress/e2e/excel.cy.ts,cypress/e2e/csv.cy.ts,cypress/e2e/filtering.cy.ts,cypress/e2e/filter-panel.cy.ts,cypress/e2e/licensing.cy.ts,cypress/e2e/viewer-labels.cy.ts,cypress/e2e/full-table-search.cy.ts,cypress/e2e/viewbox.cy.ts,cypress/e2e/stage.cy.ts,cypress/e2e/browser-info.cy.ts,cypress/e2e/ddl-export.cy.ts,cypress/e2e/row-cell-limits.cy.ts"
npx ng serve --host 0.0.0.0 --port 4200 & npx wait-on http://localhost:4200 && timeout 2700 npx cypress run --browser chrome --spec "cypress/e2e/csv-limited.cy.ts,cypress/e2e/hook-programs.cy.ts,cypress/e2e/liveness.cy.ts,cypress/e2e/editor.cy.ts,cypress/e2e/excel-multi-load.cy.ts,cypress/e2e/excel.cy.ts,cypress/e2e/csv.cy.ts,cypress/e2e/filtering.cy.ts,cypress/e2e/filter-panel.cy.ts,cypress/e2e/licensing.cy.ts,cypress/e2e/viewer-labels.cy.ts,cypress/e2e/full-table-search.cy.ts,cypress/e2e/viewbox.cy.ts,cypress/e2e/stage.cy.ts,cypress/e2e/browser-info.cy.ts,cypress/e2e/ddl-export.cy.ts,cypress/e2e/row-cell-limits.cy.ts,cypress/e2e/derived-variables.cy.ts,cypress/e2e/startupservice-launcher.cy.ts"
- name: Zip Cypress videos
if: always()
+1
View File
@@ -33,6 +33,7 @@ jobs:
echo NODE_PATH=node >> .env
echo CORS=enable >> .env
echo WHITELIST=http://localhost:4200 >> .env
echo HTTP_PORT=5000 >> .env
# The server refuses to run as root unless RUN_AS names an account
# (a security change in the recent releases). This job runs as root
# in a throwaway container, so opt in explicitly.
+29
View File
@@ -117,6 +117,7 @@ jobs:
echo NODE_PATH=node >> .env
echo CORS=enable >> .env
echo WHITELIST=http://localhost:4200 >> .env
echo HTTP_PORT=5000 >> .env
# The server refuses to run as root unless RUN_AS names an account
# (a security change in the recent releases). This job runs as root
# in a throwaway container, so opt in explicitly.
@@ -193,11 +194,15 @@ jobs:
# and its permissions are derived from this block.
# contents: write -> push CHANGELOG.md / package.json commit + tag, and
# create the Release via the Gitea API.
# actions: write -> dispatch the Sites workflow for the new tag (that
# route is scoped to the actions unit, which contents
# does not grant).
# The TSDOC_TOKEN and CODE_DATACONTROLLER_IO secrets continue to be used
# because they target *different* servers (webdoc.datacontroller.io and
# code.datacontroller.io), not the Gitea API.
permissions:
contents: write
actions: write
steps:
- uses: actions/checkout@v4
@@ -401,3 +406,27 @@ jobs:
done
echo "Uploading SHA256SUMS ..."
curl -k --fail-with-body -sS -H "$AUTH_HEADER" "$URL" -F "attachment=@SHA256SUMS"
- name: Publish the demo site for this release
# The tag is created by this job's semantic-release step, so it carries
# the Actions user as its author - and the forge suppresses ref events
# from that user, which means `on: push: tags` in sites.yaml never fires.
# Dispatch it instead: the API call is authenticated, and the run it
# starts checks out the tag the same way.
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -euo pipefail
AUTH_HEADER="Authorization: token ${GITEA_TOKEN}"
BASE="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
TAG=$(curl -k --fail-with-body -sS -H "$AUTH_HEADER" "$BASE/releases/latest" | jq -r '.tag_name')
echo "Dispatching Sites for ${TAG}"
# the dispatch endpoint resolves a bare name as a branch; a tag needs
# the refs/tags/ prefix
curl -k --fail-with-body -sS -X POST \
-H "$AUTH_HEADER" \
-H 'Content-Type: application/json' \
--data "$(jq -n --arg ref "refs/tags/$TAG" '{ref: $ref}')" \
"$BASE/actions/workflows/sites.yaml/dispatches"
+182
View File
@@ -0,0 +1,182 @@
name: Sites
on:
push:
branches:
- 'release/**'
tags:
- 'v*'
paths-ignore:
- '**.md'
workflow_dispatch:
env:
NODE_VERSION: '24.15.0'
APP_LOC: /Public/app/dc
SURFER_HOST: https://demo.datacontroller.io
# A release branch publishes the staging site (a named site on the instance,
# served at its alias domain); a release tag publishes the demo, which is the
# default site and so needs no site name.
STAGING_SITE: staging
STAGING_DOMAIN: staging.datacontroller.io
# One deploy per target site: a newer push supersedes the one in flight, and two
# release branches (which share the staging site) cannot interleave their swaps.
# The demo and staging deploys are independent, so they do not queue.
concurrency:
group: surfer-${{ startsWith(gitea.ref, 'refs/tags/') && 'demo' || 'staging' }}
cancel-in-progress: true
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
# The tag is what the client's build stamp describes (src/version.ts
# runs git-describe), so the demo build needs the tags in the clone.
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Write .npmrc file
run: |
touch client/.npmrc
echo '${{ secrets.NPMRC}}' > client/.npmrc
- name: Install client dependencies
run: |
cd client
# Decrypt and Install sheet
echo ${{ secrets.SHEET_PWD }} | gpg --batch --yes --passphrase-fd 0 ./libraries/sheet-crypto.tgz.gpg
npm ci
- name: Build frontend
# The site serves this build. `sasjs build` rewrites its index.html to
# point the adapter at the in-browser backend.
run: |
cd client
npm run postinstall
npm run build
- name: Install the SASjs CLI
# Pinned in sas/package.json - the browser build needs CLI >= 6.1.0
run: |
cd sas
npm ci
- name: Start SASjs server
# Needed only to run makedata: the drive the site ships with is captured
# from a real server (see the snapshot step below).
run: |
curl -L https://github.com/sasjs/server/releases/latest/download/linux.zip > linux.zip
unzip -q linux.zip
touch .env
echo RUN_TIMES=js >> .env
echo NODE_PATH=node >> .env
# The server refuses to run as root unless RUN_AS names an account
echo RUN_AS=root >> .env
nohup ./api-linux > server.log 2>&1 &
for i in $(seq 1 60); do
if curl -sf -o /dev/null http://localhost:5000/; then
echo "SASjs server answered on attempt ${i}"
exit 0
fi
sleep 2
done
echo "SASjs server did not come up within 120s"
cat server.log
exit 1
- name: Deploy the mocked services and seed the database
run: |
cd sas/mocks/sasjs
# Start from a clean appLoc so the snapshot below is this build alone,
# not a merge with whatever a previous run left on the drive.
curl -sS -X DELETE "http://localhost:5000/SASjsApi/drive/folder/?_folderPath=$APP_LOC"
../../node_modules/.bin/sasjs cbd -t server-ci
# makedata replies with HTML (it is normally called as a URL redirect),
# which the CLI reports as "invalid Json string" - that exit is
# expected. A real failure must not be published as an empty site, so
# the drive is checked before the snapshot is taken.
../../node_modules/.bin/sasjs request services/admin/makedata -t server-ci -d deploy/makedata.json -o ./makedata_out.json || true
DATA_DIR="../../../sasjs_root/drive/files$APP_LOC/data"
SEEDED=$(find "$DATA_DIR" -name '*.json' 2>/dev/null | wc -l)
echo "makedata seeded $SEEDED table(s) under $DATA_DIR"
if [ -z "$(find "$DATA_DIR" -name 'mpe_tables.json' 2>/dev/null | head -1)" ] || [ "$SEEDED" -lt 10 ]; then
echo "makedata did not seed the drive - refusing to publish a data-less site"
find ../../../sasjs_root/drive/files -maxdepth 3 2>/dev/null | head -20
exit 1
fi
- name: Build the browser-hosted site
run: |
# The published sites ship a working licence so they are usable beyond
# the free tier. The pair is committed in sas/mocks/mock-licence.json
# rather than minted here, so every deploy of both sites carries the
# same key; the CI estate stays unlicensed because that file is outside
# the deployed sync folder, so the specs still exercise the free tier.
# The pair must round trip through the same WebCrypto the app uses,
# because a committed pair can drift (one half regenerated, a value
# truncated on copy) and nothing else would notice before publishing.
# Identical values in the two fields is the HTTP-format marker the app
# refuses in a secure context, so refuse to publish one.
node -e "
const d = require('./sas/mocks/mock-licence.json')
const b = (s) => Buffer.from(String(s || ''), 'base64')
if (!d.licenceKey || !d.activationKey || d.licenceKey === d.activationKey) {
console.error('sas/mocks/mock-licence.json must hold an HTTPS-format key pair')
process.exit(1)
}
crypto.subtle
.importKey('pkcs8', b(d.activationKey), { name: 'RSA-OAEP', hash: 'SHA-256' }, false, ['decrypt'])
.then((key) => crypto.subtle.decrypt({ name: 'RSA-OAEP', hash: 'SHA-256' }, key, b(d.licenceKey)))
.then((plain) => {
const data = JSON.parse(new TextDecoder().decode(plain))
console.log('licence pair round trips: site', data.site_id, 'until', data.valid_until)
})
.catch((e) => {
console.error('sas/mocks/mock-licence.json does not decrypt:', e.message || e)
process.exit(1)
})
"
LICENCE_DIR="sasjs_root/drive/files$APP_LOC/mock-storage"
mkdir -p "$LICENCE_DIR"
cp sas/mocks/mock-licence.json "$LICENCE_DIR/licence.json"
# DC's data files exist only after makedata has run, so the drive is
# captured first and shipped as the site's starting drive. services/web9
# is the streamed frontend, which is already the site itself.
node sas/mocks/snapshot.js sasjs_root/drive "$APP_LOC" sas/mocks/sasjsbuild/mock-snapshot.json services/web9
cd sas/mocks/sasjs
../../node_modules/.bin/sasjs b -t server-ci
- name: Publish to Surfer
env:
SURFER_USER: allan
SURFER_PASSWORD: ${{ secrets.DEMO_DATACONTROLLER_IO }}
REF: ${{ gitea.ref }}
MESSAGE: Build from ${{ gitea.ref_name }} @ ${{ gitea.sha }}
run: |
# `deploy` uploads the folder as an archive and swaps the site in one
# step, so a failed upload leaves the current site serving.
if [ -z "$REF" ]; then
echo "REF is empty - cannot tell a branch push from a tag push"
exit 1
fi
SITE_ARGS=()
case "$REF" in
refs/tags/*) ;;
*) SITE_ARGS=(--site-name "$STAGING_SITE" --domain "$STAGING_DOMAIN") ;;
esac
npx --yes @cloudron/surfer@7.2.0 deploy \
--server "$SURFER_HOST" \
--username "$SURFER_USER" \
--password "$SURFER_PASSWORD" \
--message "$MESSAGE" \
"${SITE_ARGS[@]}" \
sas/mocks/sasjsbuild/mock
+5
View File
@@ -0,0 +1,5 @@
# sas/mocks/mock-licence.json holds a licence key pair for the mock estate: the
# activation key is RSA key material the browser demo decrypts with, not a
# credential for any service.
sas/mocks/mock-licence.json:generic-api-key:2
sas/mocks/mock-licence.json:generic-api-key:3
+6 -1
View File
@@ -86,6 +86,7 @@ RUN_TIMES=js
NODE_PATH=node
CORS=enable
WHITELIST=http://localhost:4200
HTTP_PORT=5000
```
`RUN_TIMES=js` means the server executes `.js` files as Stored Programs (no SAS binary needed). `CORS=enable` plus `WHITELIST` allows the Angular dev server on port 4200 to call the mock services on port 5000. Start the server:
@@ -94,7 +95,7 @@ WHITELIST=http://localhost:4200
./api-linux # or api-macos / api-win.exe depending on your platform
```
The server runs in desktop mode (no auth) on `http://localhost:5000` by default.
`HTTP_PORT` is not optional in SASjs Server 3.x: `PORT` and `PROTOCOL` are deprecated, and a server started without `HTTP_PORT` or `HTTPS_PORT` exits with "no listener is enabled". With it set, the server runs in desktop mode (no auth) on `http://localhost:5000`.
### 2. Deploy the mock services and streaming frontend
@@ -133,6 +134,10 @@ There is also a SAS 9 mock variant under `sas/mocks/sas9/` (target `sas9-mocks`)
After changing mock services, re-run `sasjs cbd -t server-ci` from `sas/mocks/sasjs/`. After changing the frontend, rebuild (`cd client && npm run build`) then re-deploy so the streaming app picks up the new `client/dist/`. If you are using `ng serve` (step 3), only the mock services need re-deploying; the frontend hot-reloads automatically.
### Demo and staging sites
A push to `release/**` publishes [staging.datacontroller.io](https://staging.datacontroller.io); a release tag (`v*`, created by the release job after its tests pass) publishes [demo.datacontroller.io](https://demo.datacontroller.io) - workflow file `.gitea/workflows/sites.yaml`, using the `DEMO_DATACONTROLLER_IO` Surfer app password (user `allan`). Both are sites on one multi-site Surfer instance; the demo is its default site. The job builds the mocks as a browser-hosted site (SASjs CLI >= 6.1, `streamConfig.mockWeb`), so the services run in a service worker and the drive lives in the visitor's IndexedDB - there is no server. It runs `makedata` on a throwaway SASjs Server first and ships that drive as `sasjsbuild/mock-snapshot.json`; without it the site opens with no data. The estate carries no licence key, so CI runs on the free tier until a key is registered through the licensing screen (`admin/registerkey`) - the Cypress setup does that for the specs that need one. The Sites job writes a licence into the published drive, so demo and staging are fully usable. The pair lives in `sas/mocks/mock-licence.json`: the two values are one RSA key and are regenerated together, and the `.gitleaksignore` entries for it are line-scoped, so inserting a line above the pair re-fires the scanner rather than revealing a new secret.
## GUI Elements
For documentation on the Clarity Design System, including a list of components and example usage, see [our website](https://vmware.github.io/clarity).
@@ -40,8 +40,8 @@ context('special missings clip (DEMO_01)', function () {
this.beforeEach(() => {
cy.visit(hostUrl + appLocation)
// The mock estate carries a valid licence key (mock-storage/licence.json),
// so the app activates directly with no free-tier banner in shot.
// Assumes the estate is already activated (the licensing spec registers a
// valid key); otherwise a licensing page would sit in shot.
cy.get('.nav-tree', { timeout: longerCommandTimeout }).should('exist')
})
+228
View File
@@ -0,0 +1,228 @@
export {}
//
// Derived (computed) variables are configured per table in MPE_DERIVED and
// created at read time, ahead of the filter, so that they can be filtered on.
//
// TESTDATA.DERIVED_DEMO carries two of them, neither of which is a physical
// column of the table:
// REGION ifc(STATE in ('CA','OR','WA'),'West','East')
// LINE_VALUE QTY * PRICE
//
// The mock implements the derivation in getdata.js / getcolvals.js (see
// mpeDerived in dcMockUtils.js), so the EDIT screen can be exercised without a
// SAS server. The real evaluation is done by SAS.
const hostUrl = Cypress.env('hosturl')
const appLocation = Cypress.env('appLocation')
const longerCommandTimeout = Cypress.env('longerCommandTimeout')
context('derived variables: ', function () {
this.beforeAll(() => {
cy.visit(`${hostUrl}/SASLogon/logout`, { timeout: longerCommandTimeout })
cy.loginAndUpdateValidKey(true)
})
this.beforeEach(() => {
cy.viewport(1440, 900)
bootApp()
})
it('1 | derived columns are shown in the EDIT grid with computed values', () => {
openEditor('TESTDATA.DERIVED_DEMO')
getCellByHeaderAndRow(0, 'REGION').should('have.text', 'West')
getCellByHeaderAndRow(1, 'REGION').should('have.text', 'East')
getCellByHeaderAndRow(0, 'LINE_VALUE').should('have.text', '21')
getCellByHeaderAndRow(1, 'LINE_VALUE').should('have.text', '99')
// REGION_LABEL is built on REGION, so it only resolves if the derived
// statements run in VARIABLE_ORDER sequence
getCellByHeaderAndRow(0, 'REGION_LABEL').should('have.text', 'West-CA')
getCellByHeaderAndRow(1, 'REGION_LABEL').should('have.text', 'East-NY')
cy.screenshot('derived-1-grid', { capture: 'viewport' })
})
it('2 | filters on a character derived column', () => {
openEditor('TESTDATA.DERIVED_DEMO')
openFilterPopup()
setFilterVariable('REGION')
setFilterValue('West')
submitFilter()
cy.get('.infoBar-text').should('contain.text', "REGION = 'West'")
cy.get('.ht_master .htCore tbody tr', {
timeout: longerCommandTimeout
}).should('have.length', 2)
getCellByHeaderAndRow(1, 'REGION').should('have.text', 'West')
cy.screenshot('derived-2-filter-region', { capture: 'viewport' })
})
it('3 | filters on a numeric derived column', () => {
openEditor('TESTDATA.DERIVED_DEMO')
openFilterPopup()
setFilterVariable('LINE_VALUE')
setFilterOperator('>')
setFilterValue('50')
submitFilter()
cy.get('.ht_master .htCore tbody tr', {
timeout: longerCommandTimeout
}).should('have.length', 1)
getCellByHeaderAndRow(0, 'LINE_VALUE').should('have.text', '99')
cy.screenshot('derived-3-filter-value', { capture: 'viewport' })
})
it('4 | a derived column is marked read-only, and opens no editor', () => {
openEditor('TESTDATA.DERIVED_DEMO')
// a derived column is read-only AND computed, so the grid marks it apart
// from an ordinary read-only column - its own header/cell class, no
// readonlyCell dimming (editor.component.ts, afterGetColHeader /
// afterGetCellMeta). STATE is the control: same grid, a physical column.
headerCell('REGION').should('have.class', 'dc-derived-header')
headerCell('REGION').should('not.have.class', 'readonlyCell')
headerCell('STATE')
.should('not.have.class', 'dc-derived-header')
.and('not.have.class', 'readonlyCell')
getCellByHeaderAndRow(0, 'REGION').should('have.class', 'dc-derived-cell')
getCellByHeaderAndRow(0, 'STATE').should(
'not.have.class',
'dc-derived-cell'
)
// REGION is the first derived column, so it opens the computed block
headerCell('REGION').should('have.class', 'dc-derived-first')
// the hover text carries the label, which is what says what the column is:
// the configured VAR_LABEL where there is one, otherwise the generated
// name=expression
headerCell('REGION_LABEL')
.should('have.attr', 'title')
.and('contain', 'Region and state')
headerCell('REGION')
.should('have.attr', 'title')
.and('contain', 'REGION=ifc')
enterEditMode()
getCellByHeaderAndRow(0, 'REGION').dblclick({ force: true })
cy.get('.handsontableInput').should('not.exist')
getCellByHeaderAndRow(0, 'REGION').should('have.text', 'West')
})
/**
* The positive control for the case above: same screen, same edit mode, a
* physical column - so its silence is about the column, not the screen.
*/
it('5 | a physical column in the same grid does accept an edit', () => {
openEditor('TESTDATA.DERIVED_DEMO')
enterEditMode()
getCellByHeaderAndRow(0, 'STATE').dblclick({ force: true })
cy.get('.handsontableInput').should('exist')
})
})
/**
* Cypress clears cookies between tests, which drops the SASjs Server session
* and lands the app back on the evaluation agreement card. Accept it when it is
* shown - a no-op when the session survived.
*/
const bootApp = () => {
cy.visit(hostUrl, { timeout: longerCommandTimeout })
cy.get('body').then(($body: any) => {
if ($body.find('#TCS input[type="checkbox"]').length) {
cy.get('#TCS input[type="checkbox"]').check({ force: true })
cy.wait(4000)
}
})
}
/** Opens a table straight in the EDIT screen and waits for the grid. */
const openEditor = (libMem: string) => {
cy.visit(`${hostUrl}${appLocation}/#/editor/${libMem}`, {
timeout: longerCommandTimeout
})
cy.get('.ht_clone_top .htCore thead tr th', {
timeout: longerCommandTimeout
}).should('exist')
cy.wait(2500)
}
/** The EDIT screen opens read-only; this is the button that starts an edit. */
const enterEditMode = () => {
cy.get('.btnCtrl button.btn-primary', { timeout: longerCommandTimeout })
.click()
.then(() => cy.wait(2500))
}
/** The grid header cell for a column, by its rendered text. */
const headerCell = (headerText: string) =>
cy.get('.ht_clone_top .htCore thead tr th').then(($ths) => {
const th = [...$ths].find(
(candidate) => (candidate as HTMLElement).innerText.trim() === headerText
)
if (!th) throw new Error(`header not found: ${headerText}`)
return cy.wrap(th)
})
const openFilterPopup = () => {
cy.contains('button.btnView', 'Filter', {
timeout: longerCommandTimeout
}).click()
cy.get('.filter-modal', { timeout: longerCommandTimeout }).should(
'be.visible'
)
}
/**
* The soft-select inputs drop a transparent .overlay click-catcher over the
* modal while their suggestion list is open, so they need force.
*/
const setFilterVariable = (column: string) => {
cy.get('#vals_var_id0_0').clear({ force: true }).type(column, { force: true })
cy.get('#datalist_vals_var_id0_0 option').contains(column).click({
force: true
})
cy.get('#vals_var_id0_0').trigger('keyup', { key: 'Escape', force: true })
cy.wait(400)
}
const setFilterValue = (value: string) => {
cy.get('#vals_0_0').clear({ force: true }).type(value, { force: true })
cy.get('#vals_0_0').trigger('keyup', { key: 'Escape', force: true })
cy.wait(400)
}
const setFilterOperator = (operator: string) => {
cy.get('.filter-modal .operator-col select').first().select(operator)
cy.wait(500)
}
const submitFilter = () => {
cy.contains('.filter-modal button', 'Ok').click()
cy.get('.app-loading', { timeout: longerCommandTimeout }).should('not.exist')
cy.wait(2500)
}
const getCellByHeaderAndRow = (rowIndex: number, headerText: string) => {
return cy
.get('.ht_clone_top .htCore thead tr th')
.should(($ths) => {
const texts = [...$ths].map((th) => (th as HTMLElement).innerText.trim())
expect(texts).to.include(headerText)
})
.then(($ths) => {
const index = [...$ths].findIndex(
(th) => (th as HTMLElement).innerText.trim() === headerText
)
return cy
.get('.ht_master tbody tr')
.then((rows: any) => rows[rowIndex].childNodes[index])
.then((cell) => cy.get(cell))
})
}
+33
View File
@@ -399,6 +399,26 @@ context('excel tests: ', function () {
})
})
it('25 | Keeps decimals when the upload preview becomes a manual edit', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
// Entering manual edit mode discards the source file but keeps the values
// parsed from it. SOME_NUM holds 0.901045951946194 in the first data row
// of the fixture, so a truncated or zeroed cell fails the assertion.
attachExcelFile('regular_excel.xlsx', () => {
getFirstRowCells().then((previewCells) => {
expect(previewCells).to.include(decimalSomeNum)
cy.get('.btn-upload-preview.btn-primary').click()
cy.get('.modal-footer .btn-success-outline').click()
getFirstRowCells().then((editCells) => {
expect(editCells).to.include(decimalSomeNum)
})
})
})
})
// Large files break Cypress
// it ('? | Uploads Excel with size of 5MB', (done) => {
@@ -476,6 +496,19 @@ const attachExcelFile = (excelFilename: string, callback?: any) => {
})
}
const decimalSomeNum = '0.901045951946194'
// The row's cell texts, without depending on column positions: the preview
// grid renders row-status and delete cells in an order that varies.
const getFirstRowCells = () =>
cy
.get('#hotTable', { timeout: longerCommandTimeout })
.find('div.ht_master.handsontable tbody tr')
.eq(0)
.then(($row) =>
Array.from($row[0].children).map((cell: any) => cell.innerText.trim())
)
const submitExcel = (callback?: any) => {
cy.get('.buttonBar button.preview-submit', { timeout: longerCommandTimeout })
.click()
+82 -51
View File
@@ -1,13 +1,15 @@
// The EDIT screen refuses a selection that is too large - DC_MAXOBS_WEBEDIT
// rows, or DC_MAXCELLS_WEBEDIT cells (rows x columns), whichever is reached
// first - and the VIEW screen caps at DC_MAXOBS_WEBVIEW rows.
// The EDIT screen is bounded by DC_MAXOBS_WEBEDIT rows and DC_MAXCELLS_WEBEDIT
// cells (rows x columns), whichever is reached first, and the VIEW screen caps
// at DC_MAXOBS_WEBVIEW rows.
//
// A selection over an EDIT limit is served TRUNCATED - the grid carries the
// first rows that fit, and a warning triangle against the table name says why.
// This spec pins those limits against the mock, which mirrors the shipped
// defaults (250 rows / 200000 cells for EDIT, 2000 rows for VIEW). It uses
// two existing fixtures rather than new ones:
// MPE_X_SEARCH - 1000 rows x 9 cols, so it trips the ROW limit
// MPE_X_WIDE - 200 rows x 1001 cols (200,400 cells), under the row limit
// but over the CELL limit
// MPE_X_WIDE - 200 rows x 1001 cols (200,200 cells), under the row limit
// but over the CELL limit, so the row cap is 199
export {}
const hostUrl = Cypress.env('hosturl')
@@ -60,27 +62,51 @@ context('EDIT row and cell limits, VIEW row limit: ', function () {
})
})
it('2 | EDIT refuses a table over the row limit', () => {
it('2 | EDIT serves a table over the row limit, truncated, with a warning', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_search')
openEdit()
cy.get('.abortMsg', { timeout: longerCommandTimeout }).should(
// 1000 rows x 9 columns is over the 250 row limit, so the grid carries the
// first 250 rows and the header says how many it is showing.
cy.get('.editor-title', { timeout: longerCommandTimeout }).should(
'contain.text',
'Table is too big (1000 rows) - please filter and try again!'
'250 rows'
)
cy.get('#hotTable .ht_master .htCore tbody tr', {
timeout: longerCommandTimeout
}).should('have.length.at.least', 1)
cy.get('.truncation-warning', { timeout: longerCommandTimeout })
.should('be.visible')
.realHover()
cy.get('.editor-title clr-tooltip-content', {
timeout: longerCommandTimeout
})
.should('be.visible')
.should('contain.text', 'DC_MAXOBS_WEBEDIT')
.should(($el) => expect($el.css('opacity')).to.eq('1'))
})
it('3 | EDIT refuses a table over the cell limit', () => {
it('3 | EDIT serves a table over the cell limit, truncated, with a warning', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_wide')
openEdit()
// 200 rows x 1002 columns (the 1001 stored columns plus the housekeeping
// delete column) = 200,400 cells, over the 200,000 cell limit, while the
// row count is under the 250 row limit.
cy.get('.abortMsg', { timeout: longerCommandTimeout }).should(
// 200 rows x 1001 stored columns = 200,200 cells, over the 200,000 cell
// limit, while the row count is under the 250 row limit - so the cell
// limit sets the cap: floor(200000 / 1001) = 199 rows.
cy.get('.editor-title', { timeout: longerCommandTimeout }).should(
'contain.text',
'Selection is too wide (200 rows x 1002 cols) - please filter and try again!'
'199 rows'
)
cy.get('.truncation-warning', { timeout: longerCommandTimeout })
.should('be.visible')
.realHover()
cy.get('.editor-title clr-tooltip-content', {
timeout: longerCommandTimeout
})
.should('be.visible')
.should('contain.text', 'DC_MAXCELLS_WEBEDIT')
.should(($el) => expect($el.css('opacity')).to.eq('1'))
})
it('4 | the EDIT limits follow MPE_CONFIG, not the shipped defaults', () => {
@@ -90,20 +116,29 @@ context('EDIT row and cell limits, VIEW row limit: ', function () {
// all. And MPE_CONFIG lives in the control library, so reading it from the
// table's own library would fail for this table in particular.
//
// The cell limit is the one to move: 7 rows x 18 columns is 126 cells, so
// at 110 the TESTDATA.MPE_X_NEW edit must be refused, where a hard-coded
// 200000 - or a config read that fails and falls back to it - lets it
// through. 110 is still above MPE_CONFIG's own 17 x 6 = 102, so the option
// can be put back afterwards.
// The cell limit is the one to move: 7 rows x 17 columns is 119 cells, so
// at 110 the TESTDATA.MPE_X_NEW edit must be truncated to
// floor(110 / 17) = 6 rows, where a hard-coded 200000 - or a config read
// that fails and falls back to it - lets it through. 110 is still above
// MPE_CONFIG's own 6 x 17 = 102, so the option can be put back afterwards.
setConfigValue('DC_MAXCELLS_WEBEDIT', '110', () => {
visitPage('view/data/TESTDATA.MPE_X_NEW')
closeAbortModalIfPresent()
openEdit()
cy.get('.abortMsg', { timeout: longerCommandTimeout }).should(
cy.get('.editor-title', { timeout: longerCommandTimeout }).should(
'contain.text',
'Selection is too wide (7 rows x 18 cols) - please filter and try again!'
'6 rows'
)
cy.get('.truncation-warning', { timeout: longerCommandTimeout })
.should('be.visible')
.realHover()
cy.get('.editor-title clr-tooltip-content', {
timeout: longerCommandTimeout
})
.should('be.visible')
.should('contain.text', 'DC_MAXCELLS_WEBEDIT')
.should(($el) => expect($el.css('opacity')).to.eq('1'))
})
// Put the option back, so the rest of the estate sees the shipped default.
@@ -112,28 +147,31 @@ context('EDIT row and cell limits, VIEW row limit: ', function () {
closeAbortModalIfPresent()
openEdit()
// 126 cells under a 200000 cell limit: the editor loads, no abort.
cy.get('#hotTable .ht_master .htCore tbody tr', {
timeout: longerCommandTimeout
}).should('exist')
// 119 cells under a 200000 cell limit: all 7 rows load, no warning.
cy.get('.editor-title', { timeout: longerCommandTimeout }).should(
'contain.text',
'7 rows'
)
cy.get('.truncation-warning').should('not.exist')
})
})
it('5 | the filter picker still lists the variables after the abort', () => {
it('5 | the warning says to filter, and filtering narrows the grid', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_search')
openEdit()
cy.get('.abortMsg', { timeout: longerCommandTimeout }).should(
'contain.text',
'Table is too big (1000 rows) - please filter and try again!'
)
cy.get('.truncation-warning', { timeout: longerCommandTimeout })
.should('be.visible')
.realHover()
cy.get('.editor-title clr-tooltip-content', {
timeout: longerCommandTimeout
})
.should('be.visible')
.should('contain.text', 'Filter the table to work with the rest')
.should(($el) => expect($el.css('opacity')).to.eq('1'))
closeAbortModal()
// The abort returns no table, and the picker's variable list normally comes
// from the table's own columns - so without the public/getcols fallback the
// "please filter and try again" advice has nothing to pick from. Every
// column of MPE_X_SEARCH must be on offer.
// The advice has to be actionable: the picker must offer the table's
// columns and the filter must reload the grid.
openFilterPopup()
filterVariables().should(($options) => {
@@ -155,20 +193,20 @@ context('EDIT row and cell limits, VIEW row limit: ', function () {
])
})
// and the suggestion list is usable, not just populated
setFilterVariable('SITE_NAME')
// Submitting is what exercises TYPE. The picker keys its operator set and
// its value quoting on it, and the fallback list never passes through
// mergeColsRules, so without TYPE on this payload a character value would be
// submitted unquoted and the backend's %mp_filtercheck would reject it.
setFilterValue('Bristol')
// 100 of the 1000 rows: under the limit, so nothing is truncated.
setFilterValue('Nereid Trench')
submitFilter()
cy.get('.infoBar-text', { timeout: longerCommandTimeout }).should(
'contain.text',
"SITE_NAME = 'Bristol'"
"SITE_NAME = 'Nereid Trench'"
)
cy.get('.editor-title', { timeout: longerCommandTimeout }).should(
'contain.text',
'100 rows'
)
cy.get('.truncation-warning').should('not.exist')
})
})
@@ -185,13 +223,6 @@ const openEdit = () => {
}).click()
}
/** The abort modal offers Close, and the filter button sits behind it. */
const closeAbortModal = () => {
cy.get('app-info-modal', { timeout: longerCommandTimeout })
.contains('button', 'Close')
.click()
}
/**
* The EDIT screen's Filter button opens the same .filter-modal the viewer uses.
* It only renders once the editor is in its read-only (non-editing) state,
@@ -0,0 +1,81 @@
// Marks this file as an ES module so its top-level consts don't collide with
// other spec files under the TS type-checker.
export {}
//
// When the startup service fails without a usable response, the error modal
// offers a button that runs the same service in a new browser window, where the
// SAS log is returned instead. This spec forces a startup failure against the
// mock backend and checks the button and the URL it opens.
const hostUrl = Cypress.env('hosturl')
const STARTUP_PROGRAM = '/Public/app/dc/services/public/startupservice'
/** The body a Viya Compute task returns when the run outlives the
* execution-task timeout: a bare string, so the adapter has no webout and no
* log to report. */
const rawJobError = [
'Job error',
'The Compute service could not execute the task c74a707a-8b88-46a5-8f28-9a7694ad5e13 because the context is not reusable.',
'path: /compute/tasks',
'correlator: dc1a9fda-8f65-4f32-b4d4-2a70e02179b1;1bcb6a4c-8a16-4053-a790-292544c25a03'
].join('\n')
context('startupservice launcher: ', function () {
it('1 | runs the startupservice in a new window when its response is malformed', () => {
// Report an empty admin folder so the app loads the startup service: on a
// freshly seeded mock estate makedata is still present, which sends the app
// to the deploy screen instead.
cy.intercept('GET', '**/drive/folder**', (req) => {
// Guarded parse: a lone '%' in the URL would make decodeURIComponent
// throw, and an exception here aborts the request.
let url = req.url
try {
url = decodeURIComponent(req.url)
} catch (e) {
url = req.url
}
if (url.includes('services/admin')) {
req.reply({ files: [], folders: [] })
return
}
req.continue()
})
cy.intercept('**/stp/execute/**', (req) => {
if (req.url.includes('public/startupservice')) {
// The adapter cannot use a bare string as a response payload, so the
// startup request fails with nothing to show.
req.reply({
statusCode: 200,
headers: { 'content-type': 'application/json' },
body: JSON.stringify(rawJobError)
})
return
}
req.continue()
})
// The launcher must not actually open a tab under test.
cy.visit(hostUrl, {
timeout: 60000,
onBeforeLoad(win) {
cy.stub(win, 'open').as('windowOpen')
}
})
cy.get('.clr-abort-modal', { timeout: 60000 }).should('be.visible')
cy.get('.clr-abort-modal').last().should('contain.text', 'startupservice')
cy.get('.clr-abort-modal').last().contains('button', 'Show log').click()
cy.get('@windowOpen').should('have.been.calledOnce')
cy.get('@windowOpen').then((stub: any) => {
const url = stub.firstCall.args[0]
expect(url).to.contain(STARTUP_PROGRAM)
expect(url).to.contain('_debug=131')
// The browser route is what makes the log available: no execution task.
expect(url).not.to.contain('_executionTasks')
})
})
})
+36 -22
View File
@@ -79,14 +79,34 @@ Cypress.Commands.add(
.then(() => {
cy.wait(2000)
if ($body.find('.nav-tree').length > 0) {
/**
* A key that is already working needs no update. Ask the loaded
* page, not the tree: a free tier estate renders the nav tree as
* well, so the tree cannot tell a licensed estate from an
* unlicensed one - the licence banner is what does. Reading the
* body again here also matters on an estate that shows a login
* form, where the body captured above has no tree yet.
*/
cy.get('body').then(($loaded) => {
const licenceProblem = $loaded.find('#demo-banner').length > 0
if (!licenceProblem && $loaded.find('.nav-tree').length > 0) {
return logout(() => {
return
})
}
/**
* If licence key is already working, then skip rest of the function
* An estate with no key is left as it is unless the caller asked
* for a licensed one - the free tier specs are written against
* that state and must not have a key planted under them.
*/
return logout(() => {
return
})
} else {
if (!forceLicenceKey) {
return logout(() => {
return
})
}
const keyData = {
valid_until: moment().add(20, 'day').format('YYYY-MM-DD'),
number_of_users: 10,
@@ -106,27 +126,21 @@ Cypress.Commands.add(
cy.wait(20000)
}
if (!forceLicenceKey) {
return logout(() => {
return
})
} else {
return updateLicenseKeyQuick(keysGen, () => {
cy.wait(25000)
return acceptTermsIfPresented((result: boolean) => {
if (result) {
cy.wait(20000)
}
return logout(() => {
return
})
return updateLicenseKeyQuick(keysGen, () => {
cy.wait(25000)
return acceptTermsIfPresented((result: boolean) => {
if (result) {
cy.wait(20000)
}
return logout(() => {
return
})
})
}
})
})
}
)
}
})
})
})
}
+1 -1
View File
@@ -10,7 +10,7 @@ const check = (cwd) => {
onlyAllow:
'AFLv2.1;Apache 2.0;Apache-2.0;Apache*;Artistic-2.0;BlueOak-1.0.0;0BSD;BSD*;BSD-2-Clause;BSD-3-Clause;CC0-1.0;CC-BY-3.0;CC-BY-4.0;ISC;MIT;MPL-2.0;ODC-By-1.0;Python-2.0;Unlicense;',
excludePackages:
'@cds/city@1.1.0;@handsontable/angular-wrapper@16.0.1;@handsontable/angular-wrapper@17.1.0;@handsontable/angular-wrapper@18.0.0;@handsontable/angular-wrapper@18.1.1;handsontable@^16.0.1;handsontable@16.2.0;handsontable@17.1.0;handsontable@18.0.0;handsontable@18.1.1;hyperformula@2.7.1;hyperformula@3.0.0;hyperformula@3.1.0;hyperformula@3.2.0;hyperformula@3.3.0;jackspeak@3.4.3;path-scurry@1.11.1;package-json-from-dist@1.0.1;buffers@0.1.1'
'@cds/city@1.1.0;@handsontable/angular-wrapper@16.0.1;@handsontable/angular-wrapper@17.1.0;@handsontable/angular-wrapper@18.0.0;@handsontable/angular-wrapper@18.1.1;@handsontable/angular-wrapper@18.1.2;handsontable@^16.0.1;handsontable@16.2.0;handsontable@17.1.0;handsontable@18.0.0;handsontable@18.1.1;handsontable@18.1.2;hyperformula@2.7.1;hyperformula@3.0.0;hyperformula@3.1.0;hyperformula@3.2.0;hyperformula@3.3.0;jackspeak@3.4.3;path-scurry@1.11.1;package-json-from-dist@1.0.1;buffers@0.1.1'
},
(error, json) => {
if (error) {
+907 -1168
View File
File diff suppressed because it is too large. Load diff
+13 -13
View File
@@ -41,21 +41,21 @@
},
"private": true,
"dependencies": {
"@angular/animations": "20.3.33",
"@angular/animations": "20.3.34",
"@angular/cdk": "^20.2.14",
"@angular/common": "20.3.33",
"@angular/compiler": "20.3.33",
"@angular/core": "20.3.33",
"@angular/forms": "20.3.33",
"@angular/platform-browser": "20.3.33",
"@angular/platform-browser-dynamic": "20.3.33",
"@angular/router": "20.3.33",
"@angular/common": "20.3.34",
"@angular/compiler": "20.3.34",
"@angular/core": "20.3.34",
"@angular/forms": "20.3.34",
"@angular/platform-browser": "20.3.34",
"@angular/platform-browser-dynamic": "20.3.34",
"@angular/router": "20.3.34",
"@cds/core": "^6.15.1",
"@clr/angular": "file:libraries/clr-angular-17.9.0.tgz",
"@clr/icons": "^13.0.2",
"@clr/ui": "file:libraries/clr-ui-17.9.0.tgz",
"@handsontable/angular-wrapper": "18.1.1",
"@sasjs/adapter": "5.1.0",
"@handsontable/angular-wrapper": "18.1.2",
"@sasjs/adapter": "5.1.2",
"@sasjs/utils": "4.0.0",
"@sheet/crypto": "file:libraries/sheet-crypto.tgz",
"@types/d3-graphviz": "^2.6.7",
@@ -69,7 +69,7 @@
"d3-graphviz": "^5.0.2",
"exceljs": "^4.4.0",
"fs-extra": "^7.0.1",
"handsontable": "18.1.1",
"handsontable": "18.1.2",
"https-browserify": "1.0.0",
"hyperformula": "^2.5.0",
"iconv-lite": "^0.5.0",
@@ -99,7 +99,7 @@
"@angular-eslint/schematics": "19.8.1",
"@angular-eslint/template-parser": "19.8.1",
"@angular/cli": "20.3.37",
"@angular/compiler-cli": "20.3.33",
"@angular/compiler-cli": "20.3.34",
"@babel/plugin-proposal-private-methods": "^7.18.6",
"@compodoc/compodoc": "^2.0.0",
"@cypress/webpack-preprocessor": "^5.17.1",
@@ -147,7 +147,7 @@
"ajv": "8.18.0",
"uuid": "11.1.1",
"lighthouse": "13.4.0",
"fast-uri": "3.1.8",
"fast-uri": "4.2.3",
"readdir-glob": {
"brace-expansion": "^5.0.9"
},
+6 -3
View File
@@ -314,9 +314,12 @@ export class AppComponent {
*/
public subscribeToShowAbortModal() {
this.eventService.onShowAbortModal.subscribe((sasjsAbort: InfoModal) => {
let abortId = this.sasjsAborts.length + 1
sasjsAbort.id = abortId
this.sasjsAborts.push(sasjsAbort)
/* One message at a time. Pushing to the array stacked a second modal on
top of the one already open - two dismissals for one failure, and the
lower one was often a duplicate of the upper. Replacing keeps the
newest message, which is the one the user has to act on. */
sasjsAbort.id = Date.now()
this.sasjsAborts = [sasjsAbort]
this.cdr.detectChanges() //Changes were not triggered while hot is focused
})
}
@@ -245,6 +245,25 @@
}
</clr-tooltip>
@if (truncationNote) {
<clr-tooltip>
<clr-icon
clrTooltipTrigger
shape="warning"
aria-label="The selection was truncated"
class="color-orange truncation-warning"
size="20"
></clr-icon>
<clr-tooltip-content
clrPosition="right"
clrSize="lg"
*clrIfOpen
>
{{ truncationNote }}
</clr-tooltip-content>
</clr-tooltip>
}
@if (dataSource) {
@if (!zeroFilterRows) {
({{ dataSource.length | thousandSeparator: ',' }}
+124 -19
View File
@@ -160,6 +160,9 @@ export class EditorComponent
dsmeta: DSMeta[] = []
versions: Version[] = []
dsNote = ''
/** Set when getdata served a truncated selection - shown as a warning
* against the table name, with the reason in a tooltip. */
truncationNote = ''
viewboxes = false
@@ -572,6 +575,8 @@ export class EditorComponent
public hasBaseDropZoneOver = false
public hasAnotherDropZoneOver = false
public headerPks: string[] = []
/** Derived (computed) columns from MPE_DERIVED - read-only, and marked as such */
public derivedVars: string[] = []
public columnLevelSecurityFlag = false
public dateTimeHeaders: string[] = []
public timeHeaders: string[] = []
@@ -1086,6 +1091,20 @@ export class EditorComponent
return colRules?.readOnly
}
/**
* A derived (computed) column - configured in MPE_DERIVED, created at read
* time. Read-only, and styled apart from an ordinary read-only column so the
* user can see that there is nothing to edit rather than that they may not.
*/
isDerivedCol(col: string | number) {
const name =
typeof col === 'number'
? (this.hotInstance?.colToProp(col) as string) || this.columnHeader[col]
: col
return !!name && this.derivedVars.includes(name.toUpperCase())
}
isColHeader(col: string) {
return this.headerArray.indexOf(col.toUpperCase()) > -1
}
@@ -1360,8 +1379,14 @@ export class EditorComponent
colSpecs.TYPE === 'num' &&
!colSpecs.DDTYPE.includes('TIME') &&
!colSpecs.DDTYPE.includes('DATE')
)
row[colKey] = parseInt(row[colKey])
) {
const numericValue = parseFloat(row[colKey])
// A cell the file parser could not read as a number stays as it
// was: the editor's numeric validation then flags it as text,
// where a NaN would render as an empty cell.
if (!Number.isNaN(numericValue)) row[colKey] = numericValue
}
}
}
}
@@ -2748,17 +2773,24 @@ export class EditorComponent
this.disableSubmit = true
this.submit = false
const errorText =
typeof err.adapterRespnse === 'string'
? err.adapterRespnse
: JSON.stringify(err.adapterRespnse)
/* sasService has already shown the SAS message when it rejected
(the abort, or the adapter's error), so reporting it again here
stacked a second modal on top of the first - and read
'undefined' because it looked for a property that does not
exist. Only speak up when nothing was shown. */
if (!err?.errorShown) {
const errorText =
typeof err?.adapterResponse?.error === 'string'
? err.adapterResponse.error
: JSON.stringify(err?.adapterResponse ?? err)
this.eventService.showAbortModal(
'editors/stagedata',
`Submit request failed\n${errorText}`,
null,
'Submit error'
)
this.eventService.showAbortModal(
'editors/stagedata',
`Submit request failed\n${errorText}`,
null,
'Submit error'
)
}
return false
})
@@ -3930,6 +3962,14 @@ export class EditorComponent
this.dsNote = ''
}
// getdata serves a selection over DC_MAXOBS_WEBEDIT / DC_MAXCELLS_WEBEDIT
// truncated to the first rows within the limit, and carries the reason
// here. Empty means the whole selection is in the grid - and a blank
// character arrives as a single blank (mp_jsonout cannot emit a zero-length
// string, since SAS has none), so trim it: whitespace is "no note", not a
// warning with an empty tooltip.
this.truncationNote = (response.data.sasparams[0].TRUNC_MSG || '').trim()
const hot = this.hotInstance
const approvers: Approver[] = response.data.approvers
@@ -3950,6 +3990,12 @@ export class EditorComponent
this.headerColumns = response.data.sasparams[0].COLHEADERS.split(',')
this.headerPks = response.data.sasparams[0].PK.split(' ')
// Derived (computed) columns, named by getdata from MPE_DERIVED - read-only
// through a READONLY DQ rule, and marked in the grid so they read as
// computed rather than merely non-editable.
this.derivedVars = (response.data.sasparams[0].DERIVEDVARS || '')
.split(' ')
.filter((col: string) => !!col)
this.columnLevelSecurityFlag = !!response.data.sasparams[0].CLS_FLAG
if (this.columnLevelSecurityFlag)
@@ -3976,6 +4022,19 @@ export class EditorComponent
// rules must stay index-aligned with headerColumns).
this.headerColumns.push(EDIT_STATUS_COLUMN_NAME)
/**
* columnHeader is the same list, taken once it is final (Delete? renamed,
* EDIT_STATUS appended) - it is the grid's own column order, which is what
* every hook that maps a visual column index to a name needs.
*
* This has to happen HERE, before the updateSettings below installs
* afterGetColHeader / afterGetCellMeta: those fire during the render that
* updateSettings triggers, and reading a columnHeader that was still being
* assigned further down meant they saw the previous table's columns (or
* nothing). That is what put the derived-block marker on the wrong column.
*/
this.columnHeader = this.headerColumns.slice()
if (response.data.sasparams[0].DTVARS !== '') {
this.dateHeaders = response.data.sasparams[0].DTVARS.split(' ')
}
@@ -4097,8 +4156,9 @@ export class EditorComponent
this.dataSourceUnchanged = this.helperService.deepClone(this.dataSource)
this.overlayFormulaRawValuesOnUnchanged(this.dataSourceUnchanged)
// Note: this.headerColumns and this.columnHeader contains same data
// need to resolve redundancy
// columnHeader is a copy of headerColumns taken above, once the Delete?
// rename and the EDIT_STATUS append had been applied - the hooks read it
// for grid-index-to-name mapping, so the two must not drift apart again.
// default schema - includes NOTNULL defaults from DQ rules
for (let i = 0; i < this.headerColumns.length; i++) {
@@ -4324,9 +4384,37 @@ export class EditorComponent
// header columns styling - primary keys
const isPKCol = column && this.isColPk(column)
const isReadonlyCol = column && this.isReadonlyCol(column)
const isDerivedCol = column && this.isDerivedCol(column)
if (isPKCol) th.classList.add('primaryKeyHeaderStyle')
if (isReadonlyCol && !isPKCol) th.classList.add('readonlyCell')
if (isReadonlyCol && !isPKCol && !isDerivedCol) {
th.classList.add('readonlyCell')
}
// a derived column is computed, not merely locked - give it its own
// hue and an 'fx' marker rather than the read-only dimming
if (isDerivedCol) {
th.classList.add('dc-derived-header')
// the label says what the column is - for a derived variable that
// is the configured VAR_LABEL, or the generated name=expression
// when the administrator left it blank. A label that merely
// repeats the name adds nothing, so it falls back to the rule.
const derivedLabel = (this.cols || [])
.filter((c: any) => c.NAME === column)
.map((c: any) => String(c.LABEL || '').trim())
.find(
(l: string) =>
l.length > 0 &&
l.toUpperCase() !== String(column).toUpperCase()
)
th.title = derivedLabel
? `${derivedLabel} - derived variable, computed at read time and cannot be edited`
: 'Derived variable - computed at read time and cannot be edited'
const firstDerivedCol = this.columnHeader.findIndex(
(name: string) => this.isDerivedCol(name)
)
if (col === firstDerivedCol) th.classList.add('dc-derived-first')
}
// Remove header arrow from Delete column
if (col === 0) {
@@ -4342,6 +4430,7 @@ export class EditorComponent
cellProperties: Handsontable.CellProperties
) => {
const isReadonlyCol = col && this.isReadonlyCol(col)
const isDerivedCol = col && this.isDerivedCol(col)
// Check if this cell should be marked as invalid due to duplicate primary key values
// Only applies to primary key columns (col 1 through readOnlyFields)
@@ -4365,7 +4454,26 @@ export class EditorComponent
}
// Add readonlyCell class for readonly columns to maintain original styling
if (isReadonlyCol && !classes.includes('readonlyCell')) {
if (isDerivedCol) {
// a derived cell carries its own tint; the read-only dimming would
// wash it out, and "greyed out" is a different message from
// "computed here"
classes = classes.filter((c: string) => c !== 'readonlyCell')
if (!classes.includes('dc-derived-cell')) {
classes.push('dc-derived-cell')
}
// derived columns are appended after the physical ones - mark the
// start of the block so it reads as one group
const firstDerivedCol = this.columnHeader.findIndex(
(name: string) => this.isDerivedCol(name)
)
if (
col === firstDerivedCol &&
!classes.includes('dc-derived-first')
) {
classes.push('dc-derived-first')
}
} else if (isReadonlyCol && !classes.includes('readonlyCell')) {
classes.push('readonlyCell')
}
@@ -4409,9 +4517,6 @@ export class EditorComponent
// hot.getPlugin('manualColumnFreeze').freezeColumn(0);
this.queryText = response.data.sasparams[0].FILTER_TEXT
this.columnHeader = response.data.sasparams[0].COLHEADERS.split(',')
// First column is always used to mark records for deletion
this.columnHeader[0] = 'Delete?'
this.readOnlyFields = response.data.sasparams[0].PKCNT
hot.addHook(
+9
View File
@@ -47,6 +47,15 @@ export interface SASParam {
PKCNT: number
RK_FLAG: number
TMVARS: string
/** 1 when getdata cut the selection down to the row / cell limit */
TRUNCATED?: number
/** Reason shown in the tooltip when TRUNCATED is 1, else empty */
TRUNC_MSG?: string
/**
* Space separated list of the derived (computed) columns from MPE_DERIVED,
* upper case. Empty when the table has none.
*/
DERIVEDVARS?: string
}
export interface ExcelRule {
@@ -868,6 +868,21 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
return this.sasStoreService
.callService(myParams, 'SASControlTable', 'editors/getdata', libds)
.then((res: EditorsGetDataServiceResponse) => {
// getdata serves a selection over DC_MAXOBS_WEBEDIT / DC_MAXCELLS_WEBEDIT
// truncated, rather than aborting the way it used to. This flow compares
// the whole table against the upload, so a partial read would compute
// the changes against the wrong starting state - refuse the table
// instead of submitting differences nobody asked for.
const sasParams = res?.data?.sasparams?.[0]
if (sasParams && Number(sasParams.TRUNCATED) === 1) {
this.eventService.showInfoModal(
'Upload error',
`${libds} holds more than the EDIT limit allows (${sasParams.TRUNC_MSG}), so it cannot be compared with the upload. Raise DC_MAXOBS_WEBEDIT / DC_MAXCELLS_WEBEDIT, or submit the table without the multi-table upload.`
)
return undefined
}
return res
})
.catch((err: any) => {
+3 -1
View File
@@ -170,9 +170,11 @@ describe('AppService - startup retry', () => {
expect(deps.sasService.request).toHaveBeenCalledTimes(1)
expect(deps.eventService.showInfoModal).toHaveBeenCalledTimes(1)
const [, message] = deps.eventService.showInfoModal.calls.mostRecent().args
const [, message, sasService] =
deps.eventService.showInfoModal.calls.mostRecent().args
expect(message).toContain(rawJobError)
expect(message).not.toContain('Globvars, Sasdatasets, Saslibs, XLMaps')
expect(sasService).toBe('public/startupservice')
expect(deps.licenceService.isAppActivated.value).toBeFalse()
})
})
+9 -3
View File
@@ -107,7 +107,11 @@ export class AppService {
malformedMessage
)
this.startupCheckService.updateStep(2, 'error', 'Malformed response')
this.eventService.showInfoModal('Error', malformedMessage)
this.eventService.showInfoModal(
'Error',
malformedMessage,
'public/startupservice'
)
this.licenceService.isAppActivated.next(false)
return
@@ -139,7 +143,8 @@ export class AppService {
)
this.eventService.showInfoModal(
'Error',
`${missingProps.join(', ')} are not present in the startupservice`
`${missingProps.join(', ')} are not present in the startupservice`,
'public/startupservice'
)
this.licenceService.isAppActivated.next(false)
@@ -221,7 +226,8 @@ export class AppService {
this.startupCheckService.updateStep(2, 'error', 'Request failed')
this.eventService.showInfoModal(
'Error',
'There is an issue with startupservice response'
'There is an issue with startupservice response',
'public/startupservice'
)
this.licenceService.isAppActivated.next(false)
})
+6 -2
View File
@@ -42,11 +42,15 @@ export class EventService {
this.onDemoLimitModalShow.emit(featureName)
}
public showInfoModal(modalTitle: string = 'Info', message: string) {
public showInfoModal(
modalTitle: string = 'Info',
message: string,
sasService: string | null = null
) {
let infoModal: InfoModal = {
modalTitle,
message,
sasService: '',
sasService,
details: null
}
+2 -1
View File
@@ -142,7 +142,8 @@ export class LicenceService {
this.isAppActivated.next(false)
this.eventService.showInfoModal(
'Error',
'Some of the globvars are not present in the startupservice'
'Some of the globvars are not present in the startupservice',
'public/startupservice'
)
// this.router.navigateByUrl('/licensing/key?error=missing')
return
+43 -1
View File
@@ -14,7 +14,12 @@ const buildService = () => {
const sasjsAdapter: any = {
request: jasmine.createSpy('request').and.resolveTo({}),
getSasRequests: () => [],
setDebugState: jasmine.createSpy('setDebugState')
setDebugState: jasmine.createSpy('setDebugState'),
getSasjsConfig: () => ({
serverUrl: 'https://sas.example.com',
appLoc: '/Public/app/dc',
serverType: 'SASVIYA'
})
}
const loggerService: any = {
logRequestData: jasmine.createSpy('logRequestData')
@@ -163,3 +168,40 @@ describe('SasService debug state', () => {
expect(initService(true).getSasjsConfig().debug).toBe(false)
})
})
/**
* The URL shape the deploy configurator already uses for makedata: the service
* is run by the JES / STP web app in a browser tab, so the SAS log comes back
* as the page body instead of through the adapter.
*/
describe('SasService openServiceInNewWindow', () => {
it('opens the service URL built from the live adapter config', () => {
const { service } = buildService()
spyOn(service, 'getExecutionPath').and.returnValue('/SASJobExecution')
const openSpy = spyOn(window, 'open')
service.openServiceInNewWindow('public/startupservice', {
_contextname: 'ctx'
})
expect(openSpy).toHaveBeenCalledWith(
'https://sas.example.com/SASJobExecution/?_program=/Public/app/dc/services/public/startupservice&_contextname=ctx&_debug=131',
'_blank',
'noopener'
)
})
it('opens without parameters when none are given', () => {
const { service } = buildService()
spyOn(service, 'getExecutionPath').and.returnValue('/SASJobExecution')
const openSpy = spyOn(window, 'open')
service.openServiceInNewWindow('public/startupservice')
expect(openSpy).toHaveBeenCalledWith(
'https://sas.example.com/SASJobExecution/?_program=/Public/app/dc/services/public/startupservice&_debug=131',
'_blank',
'noopener'
)
})
})
+37 -2
View File
@@ -21,6 +21,7 @@ import { SasViyaService } from './sas-viya.service'
import { ViyaApiFolder } from '../viya-api-explorer/models/viya-api-folder.model'
import { ViyaApiFolderMembers } from '../viya-api-explorer/models/viya-api-folder-content.model'
import { parseUserAgent } from '../shared/utils/parse-user-agent'
import { buildServiceUrl } from '../shared/utils/service-url'
import { VERSION } from '../../environments/version'
/**
@@ -285,7 +286,10 @@ export class SasService {
adapterResponse: {
error: abortMsg
},
log: sasRequest?.logFile
log: sasRequest?.logFile,
/* the modal above already told the user: a caller that also
reports the failure would stack a second one on top */
errorShown: !wrapperOptions?.suppressSuccessAbortModal
})
}
@@ -328,7 +332,8 @@ export class SasService {
adapterResponse: {
error: errorMessage
},
log: sasRequest?.logFile
log: sasRequest?.logFile,
errorShown: !wrapperOptions?.suppressErrorAbortModal
})
}
@@ -842,6 +847,36 @@ export class SasService {
return this.sasjsAdapter
}
/**
* Runs a SAS service in a new browser window, bypassing the adapter.
*
* The adapter's request path can hide the backend log when a service fails:
* on Viya a long startup service may be submitted as an execution task and
* time out before it finishes, returning neither webout nor log. A browser
* request to the JES / STP web app runs the same `_program` with no task and
* returns the SAS log (`_debug=131`), so this is the diagnostic path offered
* when a failed request leaves nothing usable to display.
*/
public openServiceInNewWindow(
service: string,
params: { [name: string]: string | number | null | undefined } = {}
) {
const config = this.getSasjsConfig()
const url = buildServiceUrl({
serverUrl: config.serverUrl || '',
appLoc: config.appLoc || '',
executionPath: this.getExecutionPath() || '',
service,
params
})
// noopener: the SAS log page is on a different origin (Viya's serverUrl is
// not the app's), and without it the opened window keeps a handle on
// window.opener and can navigate this tab.
window.open(url, '_blank', 'noopener')
}
public getServerType(): string {
const sasjsConfig = this.sasjsAdapter.getSasjsConfig()
@@ -28,6 +28,16 @@
}
</div>
<div class="modal-footer">
@if (showServiceLauncher(data.sasService)) {
<button
type="button"
class="btn btn-primary"
(click)="openServiceInNewWindow()"
>
Show log
</button>
}
@if (showConfiguratorButton(data.sasService)) {
<button
type="button"
@@ -0,0 +1,71 @@
import { InfoModalComponent } from './info-modal.component'
describe('InfoModalComponent', () => {
let component: InfoModalComponent
let sasService: any
const buildComponent = (sasServiceConfig: any = {}) => {
sasService = {
getSasjsConfig: () => sasServiceConfig,
openServiceInNewWindow: jasmine.createSpy('openServiceInNewWindow')
}
component = new InfoModalComponent(
{} as any,
{} as any,
sasService,
{} as any
)
component.data = {
modalTitle: 'Error',
message: 'startupservice failed',
sasService: 'public/startupservice',
details: null
}
component.ngOnInit()
}
describe('showServiceLauncher', () => {
beforeEach(() => buildComponent())
it('is shown for the startupservice, on every server type', () => {
expect(component.showServiceLauncher('public/startupservice')).toBeTrue()
expect(
component.showServiceLauncher('services/public/startupservice')
).toBeTrue()
})
it('is not shown for other services or a missing service', () => {
expect(component.showServiceLauncher('public/getdata')).toBeFalse()
expect(component.showServiceLauncher('')).toBeFalse()
expect(component.showServiceLauncher(null)).toBeFalse()
})
})
describe('openServiceInNewWindow', () => {
it('runs the startupservice in a new window', () => {
buildComponent({ serverType: 'SASVIYA' })
component.openServiceInNewWindow()
expect(sasService.openServiceInNewWindow).toHaveBeenCalledWith(
'public/startupservice',
{}
)
})
it('passes the configured Viya context so the log comes from it', () => {
buildComponent({
serverType: 'SASVIYA',
contextName: 'SAS Job Execution compute context'
})
component.openServiceInNewWindow()
expect(sasService.openServiceInNewWindow).toHaveBeenCalledWith(
'public/startupservice',
{ _contextname: 'SAS Job Execution compute context' }
)
})
})
})
@@ -71,6 +71,34 @@ export class InfoModalComponent implements OnInit {
this.onConfirmModalClick.emit()
}
/**
* Whether to show the `Show log` button.
*
* The startupservice failure modal is a dead end otherwise: the adapter
* returned no log, so the only way to see the backend reason is to run the
* service through the JES / STP web app in a browser tab. Offered on every
* server type - SAS 9, Viya and SASjs Server all accept the `_program` URL.
*/
showServiceLauncher(sasService: string | null) {
return !!sasService?.includes('startupservice')
}
/**
* Runs the startupservice in a new browser window so the SAS log is
* available even when the adapter response carried none (e.g. a Viya
* execution task that timed out).
*/
openServiceInNewWindow() {
const sasjsConfig = this.sasService.getSasjsConfig()
const params: { [name: string]: string } = {}
if (sasjsConfig.contextName) {
params['_contextname'] = sasjsConfig.contextName
}
this.sasService.openServiceInNewWindow(this.data.sasService || '', params)
}
/**
* Only on SAS9, opening a backend configurator/deploy page
*/
@@ -0,0 +1,86 @@
import { buildServiceUrl } from './service-url'
describe('buildServiceUrl', () => {
const viyaConfig = {
serverUrl: 'https://sas.example.com',
appLoc: '/Public/app/dc',
executionPath: '/SASJobExecution',
service: 'public/startupservice'
}
it('builds the Viya JES URL for a service, with the log enabled', () => {
expect(buildServiceUrl(viyaConfig)).toBe(
'https://sas.example.com/SASJobExecution/?_program=/Public/app/dc/services/public/startupservice&_debug=131'
)
})
it('leaves serverUrl empty on SAS 9 so the browser resolves the origin', () => {
expect(
buildServiceUrl({
...viyaConfig,
serverUrl: '',
executionPath: '/SASStoredProcess/do'
})
).toBe(
'/SASStoredProcess/do/?_program=/Public/app/dc/services/public/startupservice&_debug=131'
)
})
it('builds the SASjs Server URL from its execution path', () => {
expect(
buildServiceUrl({
...viyaConfig,
executionPath: '/SASjsApi/stp/execute'
})
).toBe(
'https://sas.example.com/SASjsApi/stp/execute/?_program=/Public/app/dc/services/public/startupservice&_debug=131'
)
})
it('appends encoded parameters before the debug flag', () => {
expect(
buildServiceUrl({
...viyaConfig,
params: { _contextname: 'SAS Job Execution compute context' }
})
).toBe(
'https://sas.example.com/SASJobExecution/?_program=/Public/app/dc/services/public/startupservice&_contextname=SAS%20Job%20Execution%20compute%20context&_debug=131'
)
})
it('skips parameters with no value', () => {
expect(
buildServiceUrl({
...viyaConfig,
params: { _contextname: undefined, admin: null, dcpath: 'sas' }
})
).toBe(
'https://sas.example.com/SASJobExecution/?_program=/Public/app/dc/services/public/startupservice&dcpath=sas&_debug=131'
)
})
it('omits the debug flag when debug is off', () => {
expect(buildServiceUrl({ ...viyaConfig, debug: false })).toBe(
'https://sas.example.com/SASJobExecution/?_program=/Public/app/dc/services/public/startupservice'
)
})
it('accepts a service with a leading slash', () => {
expect(
buildServiceUrl({ ...viyaConfig, service: '/public/startupservice' })
).toBe(
'https://sas.example.com/SASJobExecution/?_program=/Public/app/dc/services/public/startupservice&_debug=131'
)
})
it('accepts the services/ prefix the request wrapper reports', () => {
expect(
buildServiceUrl({
...viyaConfig,
service: 'services/public/startupservice'
})
).toBe(
'https://sas.example.com/SASJobExecution/?_program=/Public/app/dc/services/public/startupservice&_debug=131'
)
})
})
@@ -0,0 +1,46 @@
/**
* Builds the URL that runs a SAS service directly in a browser window.
*
* The adapter submits requests from JavaScript, and on Viya that can route the
* run through the Compute service (`_executionTasks=true`), where the SAS log
* is not returned to the caller and the request can time out before the
* service finishes. A browser request to the JES / STP web app runs the same
* `_program` without any task involved, and `_debug=131` returns the SAS log
* as the page body - which is what makes it useful when a service fails.
*
* `serverUrl` is left empty on SAS 9 when the app is served from the SAS
* server itself; the browser then resolves the path against the current
* origin.
*/
export interface ServiceUrlOptions {
serverUrl: string
appLoc: string
executionPath: string
service: string
params?: { [name: string]: string | number | null | undefined }
debug?: boolean
}
export const buildServiceUrl = ({
serverUrl,
appLoc,
executionPath,
service,
params = {},
debug = true
}: ServiceUrlOptions): string => {
// Callers pass the service as the app knows it: `public/getdata` in the
// request path, but the request wrapper reports `services/public/getdata`
// on a failed request. Both resolve to the same program.
const servicePath = service.replace(/^\/+/, '').replace(/^services\//, '')
const paramString = Object.entries(params)
.filter(([, value]) => value !== null && value !== undefined)
.map(
([name, value]) =>
`&${encodeURIComponent(name)}=${encodeURIComponent(String(value))}`
)
.join('')
const debugParam = debug ? '&_debug=131' : ''
return `${serverUrl}${executionPath}/?_program=${appLoc}/services/${servicePath}${paramString}${debugParam}`
}
+51
View File
@@ -5019,6 +5019,57 @@ body[cds-theme='dark'] {
border: 1px solid var(--cds-alias-status-warning) !important;
}
/* Derived (computed) columns: read-only, but computed at read time rather than
locked by policy. Tinted and marked with an 'fx' glyph instead of the
read-only dimming, so the two do not read as the same thing. Blue is the
info family - the primary key green and the warning ochre keep their meaning. */
.handsontable th.dc-derived-header {
background: #d3e5f2 !important;
box-shadow: inset 0 -2px 0 #1a6ea3;
}
.handsontable th.dc-derived-header div::after {
content: 'fx';
margin-left: 4px;
font-size: 9px;
font-style: italic;
opacity: 0.7;
}
.handsontable td.dc-derived-cell {
background: #eef5fa !important;
font-style: italic;
}
/* the first derived column starts the computed block */
.handsontable th.dc-derived-first {
border-left: 2px solid #1a6ea3 !important;
}
.handsontable td.dc-derived-cell.dc-derived-first {
box-shadow: inset 2px 0 0 #1a6ea3;
}
body[cds-theme='dark'] {
.handsontable th.dc-derived-header {
background: #2b4a5c !important;
box-shadow: inset 0 -2px 0 #4b9cc9;
}
.handsontable td.dc-derived-cell {
background: #24343d !important;
color: #dbe7ee;
}
.handsontable th.dc-derived-first {
border-left-color: #4b9cc9 !important;
}
.handsontable td.dc-derived-cell.dc-derived-first {
box-shadow: inset 2px 0 0 #4b9cc9;
}
}
.handsontable .numericListbox {
text-align: right;
}
+10
View File
@@ -0,0 +1,10 @@
{
"fromjs": [
{
"ADMIN": "all-users",
"DCPATH": "/tmp/dcdata",
"OUTFMT": "JSON",
"KEEPME": "Y"
}
]
}
+4
View File
@@ -0,0 +1,4 @@
{
"licenceKey": "G4H/DNTdEMkmCIxOrYkRYTQ1P4sWg0gs8Zk9rakxFNYDZcLyEPMJJo82YJQ/S24P2E/YPxaWsDZXgOrWKfeCDS7NrLcwsoFNa8KRF2WTlRgSMiG4sjBRX8zNEIvszj5DAq4LEgOUUAdfctNfpUcygbxT1dhqy6gU3hBu/xRuXGd36rhxQwfahaKkfaCg/rFplkrvqnjoJ6622BxWP/HG1dxZUE7HNJyT4tNaXzlC9U6u/aaJJ66+V+9511BwjM9ss4nyHuHoogVE1yrzLMlL94P3QoM1hwkGHnEHmFzg3zHbQG1f3pUs9YzK0ytsFKU7AK948gJrGbf37Zqa+Mz7EZf6XzqqXx0zQ4y9zAhLV9s4QG8JdhrByhfG5kefrs4L/BpgKq3ElgYL5EMkrLJUrlqWYI2ouxJuIGCQz41VCWXJ2S7pd39ljifc8Zj+t2Mn5EEkRVsrbdxjCkyoZOQ1ALofa1+auUI26Rm5rwEgrfhEocDmphJpO2F8lZgwDxbepSE6v/vEV/JQWBQTyrX10W+qOEu8OhvZrYJo+eIJMaO7q1pjfdrn3XhoH+BeB3Sy1sevGLXQZC+CzOtcvajetGGCDAabtTLc49ouWPUiUOAWPE3uYymsMoRZgpLIMxaUM86hoK0r9T6wSaP0bddK754sXN1jWwIDAHSGz5WO1ko=",
"activationKey": "MIIJRAIBADANBgkqhkiG9w0BAQEFAASCCS4wggkqAgEAAoICAQD2w8Ge6tMI82vaQ7x5ZdlwnTJycn0UFZoSgRwObaM0NC5wp9Y8lwkVzN8FUhg2oYSTpvyJpix+VGRj0AlNBwFAVcJG9VDPYh2LE3Us9ghRfkzFUiM2PqorHnzL5DwpailRtmFHoDQPGwwSmKncTo2QHql/K6tWe7g3Nv9uqQNks283hvDQ6PVGU4TaFTSRbjGX4j+JAR651J6wI6nY+9kNxOBwnL/yKTnJGmL8oqZ+oC45quZzgRW4dLIeTODcB4IZe2ruxZ9tvXpSuAESiOv5bBZyMJEKdKwsRdt6vk+bezkZBCzPq+ly892Ncq7x5u4/8dGcGxRxxp5TnIIx3hFoiXZ7svXJ7st6uHuKkuwYq+ebWh6bcvQAwH2wCP40twMjQgmU8nHHo6+pulxu1wVZBxtoyM4m3Lu0ZncTi6mF0duiPvpkNNAZzKOF98aUq3hxYp5PP28qrtWuPsgSdTNWJwnV2R288yAPx4WLUAGB9h889d/m67HUiKadmfG4oyUnChCU/mqeJ/5nQkK1n76ezRPGsTcMZIv2mmPLShA/j38lGMjR/3pzM2Ha6UFjJKpurpeDiyt5pUdmRBpQRoBUx/PHnbXBsO9PBXa0mjdq3UFbgb5iBD1Fz2YClZ/h0d4YlYUMSeharDrLfR15g3fGnzxrkR03zYeHkxMUi5vYHQIDAQABAoICAFxUg5pcH8lOF1FMHfXIY5MM5mbHKsC8knERCPfnEvVB/wxc6D60fow/I0Vg4blzNuFuTjiMykIcfpGQMiK1l8TEMdSODoYCHq6+gY3ootUnLj94i79MtCw9FJHPnKkcVUiDNqPvr/rk8l/06j2J0NMXffEc3FeYJCXYuLtlluTzdI4wueNY/wkoz+EvpQMzWXAMkhxHpIEAqr2aIn+TQDQ0MYxBQlqwDTSPYHFJxIVFlvYx2VdnecKSO2E1/76kOowhTzH3FL+hcWcI/TG7tJuXU1C3llV0sUVBgC1NjwjMkuEm3EN5/ffqgj/eJNJTR1r6tqSzxzMJD42WVmqlPhqEmmRE1yeaio6Ulm5EJLREYEY4/1EotdIO6PCXF1Xeo+10VJLcwi19vzMlz7vFB6vSJzJNYOaiwm4rgmF12+nNt18cHoFvegGPAoCJurUhXU+iV6LlKYB8J/+kpITleZwm4dEaa6EWxgFf4nZ4N+qRVqk4JfC+rN3DQgbPcII+z7axAb8xutq5rvzskGAmKcTEagMG+pnk9DI+0m1C2J6+mC/YruVlpSlPB7Ecu/OF++vJoSCLuDXVuxMTQ3tQXoicOG/S8d1aa3nq4fvLIgZTOZoffUvNvjCyy/Dlv6vaDVCjgqt2kFZVSZv3C6ttyrNVBSjhucASGTq/7q82JKVrAoIBAQD7e7SYqDM8SAjOfpRAHCbIVVSaHw1tul16B6JAF67OIs1uD//Y+maL4GHlpFxnib0tg6z3+sG10NMUhK7elB6SkJ8voT+Ztg/+on659vmaqwTlkGjgks+8DmIw1b0ZbXuIqhnkOeSsll2V9NWItIrbzNwvhtMEQsOtFvmrLlKQvx9Bo3lGmbfdU1/9XpwW4wiA5ONcFCl1xllX9nItB+ActPoAeQgnuejWAISQkITDTOFcGu0AjMjJ66HUlY/WDbf7wY/s2aMbOiVx7B6YzQnK+lZdjjNfoaL8nfN8Z8ZzJFAgmTQ9ztZCJ8fLTfZDsYD6XZnVEIG401+6de0Ea4MHAoIBAQD7Mlr++keNDxYy3k6kwTXDOSKMDfXIDverFy/tiNorcVU5nIpuhjowl4HIQVAhzlXhATp938LM/PvJIeu9HtgsY9BFNL52fr7iGEWknqZQVE+8Um35tzV3vtPH9NVXHIYmcRCAQQJ5a6oVQRJKAM3E1+htM2AeZKMaQSCC6VHGBXJG9nEltdMKTet09pd26Z5y/YIfTqbIekyjRomGdInyWBkpJLJ/uROVb9OGnsYWadDktn7y9+oH+VZoJxW5QwXV0OL7wMzymlrKhhBNMziJRBk4rVztclYzUShpwE8Xfjt5WYkOoieXtmkdpF7TMvRYy6cdflh39l8s7THq4U67AoIBAQDPShR+kIZK9gVsUCHbBllhYBSwRVBIVgmfAJPYyKyp9T3nrwBw1VWZjoajR2tzXLG2xobYkLUDLPIQQ53wkZ7xqIvOMte917gvZ7mVh+IOf1E1+Ga6VcktdZ2yKzwHrZrZ93wIB0BakkbpyDoKyOZ8bsw/0J43MOEh14JMe45LCX62GA1rCGHp6UOCEW4P32uu8vwFdgkGr0wNmXJbc9ZjUzmPEjSKqk8ltp+OmdEVOlW4GempPmJzLeri07UP95bjyZTc4jfzxWYAFWTTBo8/5EE0NHZcKVuzZm7A6LlHR3NUq3PITqmFNk0VY1nOSo3J0UVvvn9u3axWgcWwkv4ZAoIBAQD5GaB/RfZ6fcGHxEi8vRTxedE2jglz0NPuCFpw9ctUVVksfn4QyG8gDL6YtZwfShyDutIkTSdCXIXbmRXLP7Ejo/+/AWT24Q2t7F/ztZg8BL/lguCoR/RvwP8HRVXiwtiNeoXgiZdIjWnnFdZrKIzlbS6CS/Y/bZEI0hjJeVKsSm5ezLi7qqmTiF28aWUeeOG2KotFPJvMJuuk5qgue5boilC5KjkQy5oOEQz0bLMHOvv6MreFfi/IpIv5UPkL9NjuhezxtuoGkucB+xxjUi34gV15hQGoPkAZN5wZ8qj8JSQZXJ67sjYNDskCqiOW0Orau2oHp2lX3SvYw4dC4xdNAoIBAQDSXF8OnWu08smGDs4S57yfqe59N0ULIBDOZdEQcLXRaDeAoOwQj8szUf0pycXUZLvXxv29iF61FJg70iGGCp3lW+9Oe0P+dGsKvzphRGsQPZy4h/qZDoQ9dE667ejI4aDb/C1GaOZ8TT1QfpQcVKbgLjdC19IGQPhFljGM1wMQojU5lp4/iKmA3J3ahOuV4dvEUr6xkqzq+PhH405XdURXsVc1J6qYX2wQXRoB0oDsrCbqidZi14DaeE8UhZRA6m9haUOb/3Y+OkCs2QIW4MNZ+zg8XtHjiUhMxAaJ6zdEFltWPoVFsVfgoecquAHb0uDJJRz/+RfR0AiG0XG0NW+C"
}
+3 -3
View File
@@ -1,4 +1,4 @@
{
"licenceKey": "invalid",
"activationKey": "invalid"
}
"licenceKey": "",
"activationKey": ""
}
+3 -1
View File
@@ -16,7 +16,9 @@
"streamWebFolder": "web9",
"webSourcePath": "../../client/dist",
"assetPaths": [],
"streamServiceName": "clickme"
"streamServiceName": "clickme",
"mockWeb": true,
"mockWebFolder": "sasjsbuild/mock"
}
},
{
+320 -2
View File
@@ -281,6 +281,53 @@ const tables = {
DD_SENSITIVITY: 'Low'
}
],
// Derived (computed) variables. Values are display-time only - the logic is
// applied by mpe_derived in the mock's getdata/getcolvals, and must use the
// expression subset that compileSasExpression supports (the real evaluation
// is done by SAS).
MPE_DERIVED: [
{
tx_from: 0,
tx_to: 253717919999,
libref: 'TESTDATA',
dsn: 'DERIVED_DEMO',
var_nm: 'REGION',
var_deps: 'STATE',
var_type: 'C',
var_len: 8,
var_fmt: '',
var_logic: "ifc(STATE in ('CA','OR','WA'),'West','East')",
active: 1
},
{
tx_from: 0,
tx_to: 253717919999,
libref: 'TESTDATA',
dsn: 'DERIVED_DEMO',
var_nm: 'LINE_VALUE',
var_deps: 'QTY PRICE',
var_type: 'N',
var_len: 8,
var_fmt: 'dollar12.2',
var_logic: 'QTY * PRICE',
active: 1
},
{
// built on REGION, so it proves the dependency order is applied
tx_from: 0,
tx_to: 253717919999,
libref: 'TESTDATA',
dsn: 'DERIVED_DEMO',
var_nm: 'REGION_LABEL',
var_deps: 'REGION STATE',
var_type: 'C',
var_len: 14,
var_fmt: '',
var_label: 'Region and state',
var_logic: "cats(REGION,'-',STATE)",
active: 1
}
],
MPE_DATALOADS: [],
MPE_EMAILS: [],
MPE_EXCEL_CONFIG: [],
@@ -1112,6 +1159,26 @@ const tables = {
selectbox_value: 'FORMULA',
selectbox_order: 1,
ver_to_dttm: 127490111999
},
{
selectbox_rk: 73,
ver_from_dttm: 0,
select_lib: dcLibref,
select_ds: 'MPE_DERIVED',
base_column: 'VAR_TYPE',
selectbox_value: 'C',
selectbox_order: 1,
ver_to_dttm: 127490111999
},
{
selectbox_rk: 74,
ver_from_dttm: 0,
select_lib: dcLibref,
select_ds: 'MPE_DERIVED',
base_column: 'VAR_TYPE',
selectbox_value: 'N',
selectbox_order: 2,
ver_to_dttm: 127490111999
}
],
MPE_SUBMIT: [],
@@ -1324,6 +1391,19 @@ const tables = {
'RLS_SCOPE RLS_GROUP RLS_LIBREF RLS_TABLE RLS_GROUP_LOGIC RLS_SUBGROUP_LOGIC RLS_SUBGROUP_ID RLS_VARIABLE_NM RLS_OPERATOR_NM RLS_RAW_VALUE',
post_edit_hook: 'services/hooks/mpe_row_level_security_postedit'
},
{
tx_from: 0,
tx_to: 127490111999,
libref: dcLibref,
dsn: 'MPE_DERIVED',
num_of_approvals_required: 1,
loadtype: 'TXTEMPORAL',
buskey: 'LIBREF DSN VAR_NM',
notes: 'Derived (computed) variables for the EDIT screen',
var_txfrom: 'TX_FROM',
var_txto: 'TX_TO',
post_edit_hook: 'services/hooks/mpe_derived_postedit'
},
{
tx_from: 0,
tx_to: 127490111999,
@@ -1955,6 +2035,206 @@ const tables = {
rule_value: 'services/validations/mpe_x_test.some_num',
rule_active: 1,
tx_to: 127490111999
},
{
tx_from: 0,
base_lib: dcLibref,
base_ds: 'MPE_DERIVED',
base_col: 'LIBREF',
rule_type: 'NOTNULL',
rule_value: ' ',
rule_active: 1,
tx_to: 127490111999
},
{
tx_from: 0,
base_lib: dcLibref,
base_ds: 'MPE_DERIVED',
base_col: 'LIBREF',
rule_type: 'CASE',
rule_value: 'UPCASE',
rule_active: 1,
tx_to: 127490111999
},
{
tx_from: 0,
base_lib: dcLibref,
base_ds: 'MPE_DERIVED',
base_col: 'DSN',
rule_type: 'NOTNULL',
rule_value: ' ',
rule_active: 1,
tx_to: 127490111999
},
{
tx_from: 0,
base_lib: dcLibref,
base_ds: 'MPE_DERIVED',
base_col: 'DSN',
rule_type: 'CASE',
rule_value: 'UPCASE',
rule_active: 1,
tx_to: 127490111999
},
{
tx_from: 0,
base_lib: dcLibref,
base_ds: 'MPE_DERIVED',
base_col: 'VAR_NM',
rule_type: 'NOTNULL',
rule_value: ' ',
rule_active: 1,
tx_to: 127490111999
},
{
tx_from: 0,
base_lib: dcLibref,
base_ds: 'MPE_DERIVED',
base_col: 'VAR_NM',
rule_type: 'CASE',
rule_value: 'UPCASE',
rule_active: 1,
tx_to: 127490111999
},
{
tx_from: 0,
base_lib: dcLibref,
base_ds: 'MPE_DERIVED',
base_col: 'VAR_NM',
rule_type: 'HARDREGEX',
rule_value: '^[A-Za-z_][A-Za-z0-9_]*$',
rule_active: 1,
tx_to: 127490111999
},
{
tx_from: 0,
base_lib: dcLibref,
base_ds: 'MPE_DERIVED',
base_col: 'VAR_DEPS',
rule_type: 'CASE',
rule_value: 'UPCASE',
rule_active: 1,
tx_to: 127490111999
},
{
tx_from: 0,
base_lib: dcLibref,
base_ds: 'MPE_DERIVED',
base_col: 'VAR_DEPS',
rule_type: 'HARDREGEX',
rule_value: '^[A-Za-z0-9_ ]*$',
rule_active: 1,
tx_to: 127490111999
},
{
tx_from: 0,
base_lib: dcLibref,
base_ds: 'MPE_DERIVED',
base_col: 'VAR_TYPE',
rule_type: 'NOTNULL',
rule_value: ' ',
rule_active: 1,
tx_to: 127490111999
},
{
tx_from: 0,
base_lib: dcLibref,
base_ds: 'MPE_DERIVED',
base_col: 'VAR_TYPE',
rule_type: 'CASE',
rule_value: 'UPCASE',
rule_active: 1,
tx_to: 127490111999
},
{
tx_from: 0,
base_lib: dcLibref,
base_ds: 'MPE_DERIVED',
base_col: 'VAR_TYPE',
rule_type: 'HARDSELECT',
rule_value: ' ',
rule_active: 1,
tx_to: 127490111999
},
{
tx_from: 0,
base_lib: dcLibref,
base_ds: 'MPE_DERIVED',
base_col: 'VAR_LEN',
rule_type: 'NOTNULL',
rule_value: ' ',
rule_active: 1,
tx_to: 127490111999
},
{
tx_from: 0,
base_lib: dcLibref,
base_ds: 'MPE_DERIVED',
base_col: 'VAR_LEN',
rule_type: 'MINVAL',
rule_value: '1',
rule_active: 1,
tx_to: 127490111999
},
{
tx_from: 0,
base_lib: dcLibref,
base_ds: 'MPE_DERIVED',
base_col: 'VAR_LEN',
rule_type: 'MAXVAL',
rule_value: '32767',
rule_active: 1,
tx_to: 127490111999
},
{
tx_from: 0,
base_lib: dcLibref,
base_ds: 'MPE_DERIVED',
base_col: 'VAR_FMT',
rule_type: 'CASE',
rule_value: 'UPCASE',
rule_active: 1,
tx_to: 127490111999
},
{
tx_from: 0,
base_lib: dcLibref,
base_ds: 'MPE_DERIVED',
base_col: 'VAR_FMT',
rule_type: 'HARDREGEX',
rule_value: '^(\$?[A-Za-z][A-Za-z0-9_]*(\.[0-9]+)?\.?|\$?[0-9]+(\.[0-9]+)?\.?)?$',
rule_active: 1,
tx_to: 127490111999
},
{
tx_from: 0,
base_lib: dcLibref,
base_ds: 'MPE_DERIVED',
base_col: 'VAR_LOGIC',
rule_type: 'NOTNULL',
rule_value: ' ',
rule_active: 1,
tx_to: 127490111999
},
{
tx_from: 0,
base_lib: dcLibref,
base_ds: 'MPE_DERIVED',
base_col: 'VAR_LABEL',
rule_type: 'HARDREGEX',
rule_value: '^[^\\x00-\\x1F\\x7F]*$',
rule_active: 1,
tx_to: 127490111999
},
{
tx_from: 0,
base_lib: dcLibref,
base_ds: 'MPE_DERIVED',
base_col: 'VAR_LOGIC',
rule_type: 'HARDREGEX',
rule_value: '^(?!.*(/\\*|\\*/))[^;&%]*$',
rule_active: 1,
tx_to: 127490111999
}
],
MPE_X_TEST: [
@@ -2226,7 +2506,7 @@ tables.MPE_TABLES.push({
// ─── MPE_X_WIDE: wide table for the EDIT cell limit ─────────────────────────
// 200 rows x 1001 columns (ROW_ID + 500 single-character + 500 numeric) is
// 200,400 cells - over DC_MAXCELLS_WEBEDIT (200000) while staying under
// 200,200 cells - over DC_MAXCELLS_WEBEDIT (200000) while staying under
// DC_MAXOBS_WEBEDIT (250), so it exercises the CELL limit on its own and the
// row limit spec can use MPE_X_SEARCH (1000 rows) instead. Values are one
// character / one digit on purpose: the point is width, and small values keep
@@ -3262,6 +3542,20 @@ const schema = {
notnull: false
}
],
MPE_DERIVED: [
{ name: 'TX_FROM', type: 'N', length: 8, format: 'datetime19.3', label: '', notnull: true },
{ name: 'LIBREF', type: 'C', length: 8, format: '', label: '', notnull: false },
{ name: 'DSN', type: 'C', length: 32, format: '', label: '', notnull: false },
{ name: 'VAR_NM', type: 'C', length: 32, format: '', label: '', notnull: false },
{ name: 'VAR_DEPS', type: 'C', length: 4000, format: '', label: '', notnull: false },
{ name: 'VAR_TYPE', type: 'C', length: 1, format: '', label: '', notnull: false },
{ name: 'VAR_LEN', type: 'N', length: 8, format: '', label: '', notnull: false },
{ name: 'VAR_FMT', type: 'C', length: 32, format: '', label: '', notnull: false },
{ name: 'VAR_LABEL', type: 'C', length: 256, format: '', label: '', notnull: false },
{ name: 'VAR_LOGIC', type: 'C', length: 4000, format: '', label: '', notnull: false },
{ name: 'ACTIVE', type: 'N', length: 8, format: '', label: '', notnull: false },
{ name: 'TX_TO', type: 'N', length: 8, format: 'datetime19.3', label: '', notnull: true }
],
MPE_DATALOADS: [
{
name: 'LIBREF',
@@ -5349,7 +5643,15 @@ const testTables = {
{ ORDER_ID: 1003, CUSTOMER: 'Cobalt GmbH', REGION: 'EU', AMOUNT: 620, STATUS: 'CLOSED' },
{ ORDER_ID: 1004, CUSTOMER: 'Delta Systems', REGION: 'US', AMOUNT: 4500, STATUS: 'NEW' }
],
DEMO_MIRROR: []
DEMO_MIRROR: [],
// Derived variable demo - REGION and LINE_VALUE are not columns of this
// table, they are computed at read time from MPE_DERIVED rows.
DERIVED_DEMO: [
{ ID: 1, STATE: 'CA', QTY: 2, PRICE: 10.5 },
{ ID: 2, STATE: 'NY', QTY: 1, PRICE: 99 },
{ ID: 3, STATE: 'OR', QTY: 5, PRICE: 3 },
{ ID: 4, STATE: 'TX', QTY: 3, PRICE: 7.25 }
]
}
// Schemas for the TESTDATA tables, matching the old inline getdata.js payload
@@ -5691,6 +5993,12 @@ const testSchema = {
label: 'Last reviewed',
notnull: false
}
],
DERIVED_DEMO: [
{ name: 'ID', type: 'N', length: 8, format: '', label: 'ID', notnull: true },
{ name: 'STATE', type: 'C', length: 2, format: '', label: 'State', notnull: false },
{ name: 'QTY', type: 'N', length: 8, format: '', label: 'Quantity', notnull: false },
{ name: 'PRICE', type: 'N', length: 8, format: 'dollar12.2', label: 'Price', notnull: false }
]
}
@@ -5739,6 +6047,16 @@ tables.MPE_TABLES.push(
buskey: 'ID',
notes: 'Compact rule demo table (eight columns)'
},
{
tx_from: 0,
tx_to: 127490111999,
libref: testDataLibref,
dsn: 'DERIVED_DEMO',
num_of_approvals_required: 1,
loadtype: 'UPDATE',
buskey: 'ID',
notes: 'Derived variable demo table'
},
{
tx_from: 0,
tx_to: 127490111999,
@@ -0,0 +1,104 @@
const nodePath = require('path')
let appLoc = nodePath.join(..._program.split('services')[0].split('/'))
const sasjsRoot = nodePath.resolve(weboutPath, '..', '..', '..')
const driveRoot = nodePath.resolve(sasjsRoot, 'drive')
// Load shared DC mock utilities
eval(
fs.readFileSync(
nodePath.resolve(driveRoot, 'files', appLoc, 'services', 'dcMockUtils.js'),
'utf8'
)
)
const dcConf = loadDcSettings(driveRoot, appLoc)
const mpeDataDir = dcConf.dataDir
const stagingDir = nodePath.resolve(mpeDataDir, 'staging')
const mpeLoadTableData = makeTableLoader(mpeDataDir)
// ─── Parse input ──────────────────────────────────────────────────────────────
// The submission id arrives as the `table` URL param, as in getauditfile.sas.
let tableId = ''
if (typeof table !== 'undefined' && table) tableId = String(table).trim()
// libds comes from MPE_SUBMIT, the same lookup the SAS service makes before it
// decides the request is valid.
let libds = ''
if (tableId) {
const submit = mpeLoadTableData('MPE_SUBMIT')
const row =
submit && submit.rows
? submit.rows.find((r) => r.TABLE_ID === tableId)
: null
if (row) {
libds = (String(row.BASE_LIB || '') + '.' + String(row.BASE_DS || '')).toUpperCase()
}
}
// ─── Respond ──────────────────────────────────────────────────────────────────
if (!tableId || !libds) {
// getauditfile.sas aborts with the same message when either is missing.
webOutOpen()
webOutObj([{ MSG: 'Missing: libds table', MAC: 'getauditfile' }], 'sasjsAbort')
webOutObj([{ STATUS: 'ERROR', DSID: '' }], 'sasparams')
webOutClose()
} else {
// The SAS service also checks EDIT or APPROVE access (mpe_accesscheck). The
// mock seeds MPE_SECURITY but no mock service enforces it, so access is not
// emulated here.
// The approval folder is zipped as it stands. getauditfile.sas lists it with
// mp_dirlist and adds each file to the package under contents/.
const stageFolder = nodePath.resolve(stagingDir, tableId)
const names = []
if (fs.existsSync(stageFolder)) {
for (const name of fs.readdirSync(stageFolder)) {
const absolute = nodePath.resolve(stageFolder, name)
let stat
try {
stat = fs.statSync(absolute)
} catch (err) {
continue
}
if (stat.isFile()) names.push(name)
}
}
names.sort()
const entries = names.map((name) => ({
name: 'contents/' + name,
content: fs
.readFileSync(nodePath.resolve(stageFolder, name), { encoding: 'utf8' })
.toString()
}))
const archive = mpZipPackage(entries)
const streamed = mpStreamfile({
filename: tableId + '.zip',
contentType: 'application/zip',
bytes: archive
})
if (!streamed) {
// The browser host returns a text body and ignores the streamed headers, so
// the archive cannot reach the browser as a download. The pack is described
// instead, which is what a reviewer opening the tab gets there.
_webout = JSON.stringify(
{
table: tableId,
libds: libds,
archive: tableId + '.zip',
note: 'The browser mock host streams text only, so this lists the archive contents.',
contents: entries.map((entry) => ({
name: entry.name,
bytes: entry.content.length
}))
},
null,
2
)
}
}
+483
View File
@@ -460,6 +460,60 @@ function sasVarsEntry(col) {
return { format: fmt, label: col.label || col.name, length: String(col.length), type: col.type === 'N' ? 'num' : 'char' }
}
/**
* Assigns the retained key (RK) to staged rows for a table registered with
* rk_underlying, mirroring %mp_retainedkey as %mpe_loader invokes it: any RK
* column in the input is dropped, and every row's RK is derived from its
* business key - the value that key already holds on the base table, or the
* next key value for a new one. Business keys are matched the way SAS compares
* them: case-sensitive, trailing blanks ignored.
*
* @param {Object} opts - { rows, tableReg, baseData }
* tableReg - the table's MPE_TABLES row (needs rk_underlying and buskey)
* baseData - { columns, rows } of the base table, for the key lookup
* @returns {String} the RK column name when the assignment ran, else ''
*/
function mpeAssignRetainedKey(opts) {
const { rows, tableReg, baseData } = opts
const rkUnderlying = tableReg && tableReg.rk_underlying
? String(tableReg.rk_underlying).trim()
: ''
const rkCol = tableReg && tableReg.buskey ? String(tableReg.buskey).trim() : ''
if (!rkUnderlying || !rkCol || !rows || rows.length === 0) return ''
const base = baseData || { columns: [], rows: [] }
const submitted = Object.keys(rows[0] || {}).find(
(k) => k.toUpperCase() === rkCol.toUpperCase()
)
const rkName = submitted ||
(base.columns.find((c) => c.name.toUpperCase() === rkCol.toUpperCase()) || {}).name ||
rkCol
const keys = rkUnderlying.split(/\s+/)
const keyValue = (value) => {
if (value === null || value === undefined) return ''
return typeof value === 'number' ? String(value) : String(value).replace(/\s+$/, '')
}
const businessKey = (row) => keys.map((k) => keyValue(colVal(row, k))).join('|')
const baseByKey = new Map()
let nextRk = 0
for (const row of base.rows) {
const rk = colVal(row, rkCol)
baseByKey.set(businessKey(row), rk)
const num = Number(rk)
if (isFinite(num) && num > nextRk) nextRk = num
}
for (const row of rows) {
Object.keys(row).forEach((k) => {
if (k.toUpperCase() === rkCol.toUpperCase()) delete row[k]
})
const existing = baseByKey.get(businessKey(row))
row[rkName] = existing === undefined || existing === null ? ++nextRk : existing
}
return rkName
}
/**
* Stages a submission: writes staged.json under staging/<tableId>/, copies the
* session audit items, and inserts the MPE_SUBMIT row. Shared by the
@@ -493,6 +547,12 @@ function stageSubmission(opts) {
// keep audit items from this Stored Program run alongside the staged data
copySessionAuditItems(stageFolder, 'submit')
// Files the SAS loader leaves in the approval folder, so the audit pack
// holds the same artefacts: the cleaned CSV and the uploaded file itself.
for (const file of opts.auditFiles || []) {
fs.writeFileSync(nodePath.resolve(stageFolder, file.name), file.content)
}
// Insert a record into MPE_SUBMIT (always in the control library /
// mpelib, regardless of which library the base table lives in)
const mpeDataDir = libDataDir('DC_JSLIB')
@@ -549,6 +609,146 @@ function copySessionAuditItems(stageFolder, prefix) {
}
}
// ─── %mp_streamfile + ODS package (zip) ───────────────────────────────────────
const ZIP_CRC_TABLE = (() => {
const table = new Array(256)
for (let i = 0; i < 256; i++) {
let c = i
for (let k = 0; k < 8; k++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1)
table[i] = c >>> 0
}
return table
})()
function zipCrc32(bytes) {
let crc = -1
for (let i = 0; i < bytes.length; i++)
crc = (crc >>> 8) ^ ZIP_CRC_TABLE[(crc ^ bytes[i]) & 0xff]
return (crc ^ -1) >>> 0
}
function zipUtf8(value) {
const text = String(value)
if (typeof TextEncoder !== 'undefined') return new TextEncoder().encode(text)
const out = []
for (let i = 0; i < text.length; i++) {
const code = text.charCodeAt(i)
if (code < 0x80) out.push(code)
else if (code < 0x800) out.push(0xc0 | (code >> 6), 0x80 | (code & 0x3f))
else out.push(0xe0 | (code >> 12), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f))
}
return Uint8Array.from(out)
}
/**
* Renders rows as CSV, the counterpart of %mp_ds2csv: the header row, then one
* line per row, with values quoted only when they carry a delimiter, quote or
* line break.
*
* @param {string[]} headers column names, in order
* @param {Array<Object>} rows objects keyed by column name
* @returns {string} CSV text with a trailing newline
*/
function toCsv(headers, rows) {
const cell = (value) => {
const text = value === undefined || value === null ? '' : String(value)
return /[",\n\r]/.test(text) ? '"' + text.replace(/"/g, '""') + '"' : text
}
const lines = [headers.map(cell).join(',')]
for (const row of rows) {
lines.push(headers.map((header) => cell(row[header])).join(','))
}
return lines.join('\n') + '\n'
}
/**
* Builds a zip archive in memory, entries stored rather than deflated - the JS
* counterpart of the ODS package getauditfile.sas publishes. The zip format
* leaves compression to the writer, so a stored archive opens anywhere.
*
* @param {Array<{name: string, content: string}>} entries archive entries
* @returns {Uint8Array} the archive
*/
function mpZipPackage(entries) {
const u16 = (n) => [n & 0xff, (n >>> 8) & 0xff]
const u32 = (n) => [n & 0xff, (n >>> 8) & 0xff, (n >>> 16) & 0xff, (n >>> 24) & 0xff]
const local = []
const central = []
let offset = 0
for (const entry of entries) {
const name = zipUtf8(entry.name)
const data = zipUtf8(entry.content)
const crc = zipCrc32(data)
// Bit 11 marks the name as UTF-8. Without it an extractor decodes a
// non-ASCII name as CP437, so the entry cannot be read back by name.
const flags = name.some((byte) => byte >= 0x80) ? 0x0800 : 0
// 1980-01-01 00:00, the earliest DOS timestamp: a stable value keeps
// the archive byte-identical for identical inputs.
const header = [].concat(
u32(0x04034b50), u16(20), u16(flags), u16(0), u16(0), u16(0x21),
u32(crc), u32(data.length), u32(data.length), u16(name.length), u16(0)
)
local.push(Uint8Array.from(header), name, data)
central.push({ name, crc, size: data.length, offset, flags })
offset += header.length + name.length + data.length
}
const directory = []
let directorySize = 0
for (const entry of central) {
const header = [].concat(
u32(0x02014b50), u16(20), u16(20), u16(entry.flags), u16(0), u16(0), u16(0x21),
u32(entry.crc), u32(entry.size), u32(entry.size),
u16(entry.name.length), u16(0), u16(0), u16(0), u16(0), u32(0),
u32(entry.offset)
)
directory.push(Uint8Array.from(header), entry.name)
directorySize += header.length + entry.name.length
}
const end = Uint8Array.from([].concat(
u32(0x06054b50), u16(0), u16(0),
u16(central.length), u16(central.length),
u32(directorySize), u32(offset), u16(0)
))
const chunks = local.concat(directory, [end])
const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0)
const archive = new Uint8Array(total)
let at = 0
for (const chunk of chunks) {
archive.set(chunk, at)
at += chunk.length
}
return archive
}
/**
* Streams a file back as an attachment, the JS counterpart of %mp_streamfile on
* the SASJS platform: the headers name the content type and the body is the file
* itself. Returns false where that is not possible - the browser mock host has
* a text-only webout - so the caller can fall back to something textual.
*
* @param {{filename: string, contentType: string, bytes: Uint8Array}} opts
* @returns {boolean} whether the file was streamed
*/
function mpStreamfile(opts) {
if (typeof Buffer === 'undefined') return false
// A content-type header is what tells the SASjs Server to send webout.txt as
// binary rather than a string (api/src/controllers/internal/Execution.ts).
fs.writeFileSync(
_SASJS_WEBOUT_HEADERS,
`Content-type: ${opts.contentType} \n Content-Disposition: attachment;filename="${opts.filename}"`
)
fs.writeFileSync(weboutPath, Buffer.from(opts.bytes))
return true
}
/**
* Generates a DC table_id in the same format as the real backend
* (DC<yyyymmdd-hhmmss.sss>_<rand6>_<pid4>).
@@ -1154,6 +1354,289 @@ function mpFilterStore(opts) {
}
}
// ─── mpe_derived.sas ────────────────────────────────────────────────────────
/**
* Compiles a SAS expression into a JS function - for the mock only.
*
* The real evaluation is done by SAS. This covers the subset used by the mock
* and demo configs, so the EDIT screen can be exercised (display, filtering,
* submission) without a SAS server:
*
* - column names, numbers, quoted strings
* - + - * / and comparisons (= ne < > <= >=)
* - AND OR, and `X IN (a, b, c)`
* - ifc(), cats(), catx(), upcase(), lowcase(), strip(), trim(), length(),
* substr(), index()
*
* Anything outside that subset throws when a row is derived, and the column is
* left missing for that row - the same way a failing SAS expression would.
*/
function compileSasExpression(expr, columnNames) {
// Tokenise first, so operators are only rewritten outside quoted strings
// (a literal such as 'OR' must never be read as an operator).
const tokens = []
const s = String(expr)
let i = 0
while (i < s.length) {
const c = s[i]
if (/\s/.test(c)) { i++; continue }
if (c === "'" || c === '"') {
const q = c
let j = i + 1
let val = ''
while (j < s.length) {
if (s[j] === q) {
if (s[j + 1] === q) { val += q; j += 2; continue }
break
}
val += s[j]
j++
}
tokens.push({ type: 'str', value: val })
i = j + 1
continue
}
if (/[0-9]/.test(c)) {
let j = i
while (j < s.length && /[0-9.]/.test(s[j])) j++
tokens.push({ type: 'num', value: s.slice(i, j) })
i = j
continue
}
if (/[A-Za-z_]/.test(c)) {
let j = i
while (j < s.length && /[A-Za-z0-9_]/.test(s[j])) j++
tokens.push({ type: 'word', value: s.slice(i, j) })
i = j
continue
}
const two = s.slice(i, i + 2)
if (['<=', '>=', '!=', '<>'].includes(two)) {
tokens.push({ type: 'punct', value: two })
i += 2
continue
}
// only the operators in the documented subset may reach the generated
// function - anything else is rejected rather than executed
if (!'+-*/(),<>='.includes(c)) {
throw new Error('Unsupported character in derived expression: ' + c)
}
tokens.push({ type: 'punct', value: c })
i++
}
// X IN (a, b, c) -> __in([a, b, c], X)
const out = []
for (let t = 0; t < tokens.length; t++) {
const tok = tokens[t]
if (
tok.type === 'word' &&
tok.value.toLowerCase() === 'in' &&
out.length > 0 &&
tokens[t + 1] &&
tokens[t + 1].value === '('
) {
let depth = 0
let j = t + 1
for (; j < tokens.length; j++) {
if (tokens[j].value === '(') depth++
else if (tokens[j].value === ')') {
depth--
if (depth === 0) break
}
}
if (j < tokens.length) {
const left = out.pop()
const inner = tokens.slice(t + 2, j)
out.push(
{ type: 'word', value: '__in' },
{ type: 'punct', value: '(' },
{ type: 'punct', value: '[' },
...inner,
{ type: 'punct', value: ']' },
{ type: 'punct', value: ',' },
left,
{ type: 'punct', value: ')' }
)
t = j
continue
}
}
out.push(tok)
}
const OPS = {
and: '&&', or: '||',
eq: '==', ne: '!=', lt: '<', gt: '>', le: '<=', ge: '>='
}
const js = out
.map(tok => {
if (tok.type === 'str') return JSON.stringify(tok.value)
if (tok.type === 'num') return tok.value
if (tok.type === 'word') return OPS[tok.value.toLowerCase()] || tok.value
if (tok.value === '=') return '=='
if (tok.value === '<>') return '!='
return tok.value
})
.join(' ')
const sasTrue = v => {
if (typeof v === 'boolean') return v
if (typeof v === 'number') return v !== 0 && !isNaN(v)
if (v === null || v === undefined) return false
const str = String(v).trim()
return str !== '' && str !== '0'
}
const toStr = v => {
if (v === null || v === undefined) return ''
if (typeof v === 'number') return isNaN(v) ? '' : String(v)
return String(v).trim()
}
const fns = {
ifc: (c, a, b) => (sasTrue(c) ? a : b),
cats: (...args) => args.map(toStr).join(''),
catx: (sep, ...args) => args.map(toStr).filter(v => v !== '').join(toStr(sep)),
upcase: v => toStr(v).toUpperCase(),
lowcase: v => toStr(v).toLowerCase(),
strip: v => toStr(v),
trim: v => toStr(v),
length: v => toStr(v).length,
substr: (v, p, n) => {
const str = toStr(v)
const start = Number(p) - 1
return n === undefined
? str.substring(start)
: str.substring(start, start + Number(n))
},
index: (v, sub) => toStr(v).indexOf(toStr(sub)) + 1,
__in: (list, v) => list.some(item => String(item) === String(v) || Number(item) === Number(v))
}
const names = Object.keys(fns)
const fn = new Function(...columnNames, ...names, 'return (' + js + ');')
return row => fn(...columnNames.map(n => row[n]), ...names.map(n => fns[n]))
}
/**
* Reads MPE_DERIVED for a table, mirroring %mpe_derived. Returns the active
* variables with a compiled row function, in dependency order (ties by name),
* the same sequence the real macro emits.
*
* @param {Object} opts
* @param {string} opts.libref - target libref
* @param {string} opts.table - target dataset
* @param {string} opts.dataDir - data directory of the DC control library
* @param {Array<Object>} opts.columns - physical columns of the target table
*/
function mpeDerived(opts) {
const { libref, table, dataDir, columns } = opts
const loadTableData = makeTableLoader(dataDir)
const cfg = loadTableData('MPE_DERIVED')
if (!cfg || !Array.isArray(cfg.rows) || cfg.rows.length === 0) return []
const now = Date.now() / 1000
const lib = String(libref || '').toUpperCase()
const dsn = String(table || '').toUpperCase()
const columnNames = (columns || []).map(c => c.name)
const vars = cfg.rows
.filter(r =>
String(colVal(r, 'LIBREF') || '').toUpperCase() === lib &&
String(colVal(r, 'DSN') || '').toUpperCase() === dsn &&
Number(colVal(r, 'ACTIVE')) === 1 &&
(!Number(colVal(r, 'TX_TO')) || Number(colVal(r, 'TX_TO')) > now)
)
.map(r => {
const logic = String(colVal(r, 'VAR_LOGIC') || '').trim()
const nm = String(colVal(r, 'VAR_NM') || '').toUpperCase()
return {
name: nm,
// every input the expression uses - columns and derived vars
deps: String(colVal(r, 'VAR_DEPS') || '')
.toUpperCase()
.split(/\s+/)
.filter(Boolean),
type: String(colVal(r, 'VAR_TYPE') || '').toUpperCase(),
length: Number(colVal(r, 'VAR_LEN')) || 8,
format: String(colVal(r, 'VAR_FMT') || ''),
logic,
label: String(colVal(r, 'VAR_LABEL') || '').trim() || nm + '=' + logic
}
})
.filter(d => d.name && (d.type === 'C' || d.type === 'N') && d.logic)
// dependency order: repeatedly take the variables whose derived inputs are
// already placed, alphabetically within a round
const byName = new Map(vars.map(v => [v.name, v]))
const placed = []
const done = new Set()
while (placed.length < vars.length) {
const ready = vars
.filter(v => !done.has(v.name))
.filter(v => v.deps.every(d => !byName.has(d) || done.has(d)))
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
if (ready.length === 0) {
console.log('derived variables for ' + lib + '.' + dsn + ' contain a cycle')
return []
}
for (const v of ready) {
placed.push(v)
done.add(v.name)
}
}
vars.length = 0
vars.push.apply(vars, placed)
// compile each against the physical columns plus the variables defined
// earlier, so a derived variable can build on another one
const compileCols = columnNames.slice()
for (const d of vars) {
try {
// slice(): the compiled function captures the column list it was
// built with, not the array as it grows
d.fn = compileSasExpression(d.logic, compileCols.slice())
} catch (err) {
console.log('derived variable ' + d.name + ' could not be compiled: ' + err.message)
d.fn = () => (d.type === 'N' ? null : '')
}
compileCols.push(d.name)
}
return vars
}
/**
* Adds the derived variables to every row and to the column list, so that the
* filter (which runs afterwards) and the grid both see them.
*/
function mpeDeriveRows(tableData, derived) {
if (!derived || derived.length === 0) return tableData
const rows = tableData.rows.map(row => {
const copy = Object.assign({}, row)
for (const d of derived) {
try {
copy[d.name] = d.fn(copy)
} catch (err) {
copy[d.name] = d.type === 'N' ? null : ''
}
}
return copy
})
const derivedCols = derived.map(d => ({
name: d.name,
type: d.type,
length: d.length,
format: d.format,
label: d.label,
notnull: false
}))
return {
name: tableData.name,
columns: (tableData.columns || []).concat(derivedCols),
rows
}
}
// ─── DC settings (services/settings.js) ──────────────────────────────────────
/**
+115 -51
View File
@@ -77,6 +77,42 @@ if (mpeTablesData && mpeTablesData.rows) {
tableReg = mpeTablesData.rows.find(r => r.libref === requestedLibref && r.dsn === requestedTable)
}
// The variables exposed to the editor as the primary key, matching getdata.sas:
// pk = coalesce(rk_underlying, buskey). A table registered with rk_underlying
// is keyed on its business key in the editor - the RK itself is assigned by the
// backend loader, so it is neither shown nor sent by the user.
const rkUnderlying = tableReg && tableReg.rk_underlying ? tableReg.rk_underlying.trim() : ''
const buskey = tableReg && tableReg.buskey ? tableReg.buskey.trim() : ''
const pkFields = rkUnderlying || buskey
const pkColumns = pkFields ? pkFields.split(/\s+/) : []
// Order columns with the PK variables first, in PK order, then the remaining
// columns in schema order. getdata.sas does the same ("order delete var and pk
// fields at start of table") and the client reads the first PKCNT columns
// positionally when it detects duplicate keys.
function pkFirst(columns, keys) {
const rank = new Map(keys.map((name, i) => [name.toUpperCase(), i]))
const keyColumns = columns
.filter(col => rank.has(col.name.toUpperCase()))
.sort((a, b) => rank.get(a.name.toUpperCase()) - rank.get(b.name.toUpperCase()))
return keyColumns.concat(columns.filter(col => !rank.has(col.name.toUpperCase())))
}
// ─── Derived (computed) variables (mpe_derived.sas) ─────────────────────────
// Applied before the filter, so a filter can reference them. The derived
// columns are added to the schema too, which is what puts them in COLS and in
// the grid; they are held read-only by a READONLY rule below.
const derived = mpeDerived({
libref: requestedLibref,
table: requestedTable,
dataDir: mpeDataDir,
columns: tableData ? tableData.columns : []
})
if (tableData && derived.length > 0) {
tableData = mpeDeriveRows(tableData, derived)
}
// ─── DQ rules (from MPE_VALIDATIONS + NOTNULL from schema) ──────────────────
// Mirrors getdata.sas: dqrules = MPE_VALIDATIONS rows UNION dictionary.columns
// NOTNULL constraints. MPE_VALIDATIONS lives in the control library.
@@ -85,10 +121,7 @@ const validationsData = mpeLoadTableData('MPE_VALIDATIONS')
let dqrules = []
let dqdata = []
if (validationsData && validationsData.rows) {
const pkCols = new Set()
if (tableReg && tableReg.buskey) {
tableReg.buskey.trim().split(/\s+/).forEach(c => pkCols.add(c.toUpperCase()))
}
const pkCols = new Set(pkColumns.map(c => c.toUpperCase()))
dqrules = validationsData.rows
.filter(r => r.base_lib === requestedLibref && r.base_ds === requestedTable)
.map(r => ({
@@ -127,6 +160,16 @@ if (tableData && tableData.columns) {
}
}
// A derived variable is computed at read time, so the grid must not accept a
// value for it. COLS carries READ for the same reason (a derived column has no
// CLS rule row), but the client only applies COLS.CLS_RULE when CLS_FLAG is
// set, which column-level security drives - so the rule is declared here too,
// as getdata.sas does. dqrules is the request-local array the filter/map above
// built, so appending to it leaves the loaded MPE_VALIDATIONS rows untouched.
for (const v of derived) {
dqrules.push({ BASE_COL: v.name, RULE_TYPE: 'READONLY', RULE_VALUE: '' })
}
// ─── DQ dropdown data (from MPE_SELECTBOX + SOFTSELECT from table data) ──────
// MPE_SELECTBOX lives in the control library.
@@ -191,9 +234,8 @@ if (tableData && tableData.rows && tableData.columns) {
// ─── Build response ──────────────────────────────────────────────────────────
let response
// Set when the row or cell limit is exceeded - mirrors the %mp_abort in
// getdata.sas, and is emitted as sasjsAbort below.
let abortMsg = ''
// Set when the row or cell limit cut the selection down - mirrors the
// TRUNCATED / TRUNC_MSG in sasparams in getdata.sas.
if (tableData && tableData.columns && tableData.rows) {
// Exclude temporal columns (TX_FROM/TX_TO etc.) from the editor view
@@ -206,8 +248,17 @@ if (tableData && tableData.columns && tableData.rows) {
if (tableReg.var_busfrom) excludeCols.add(tableReg.var_busfrom.toUpperCase())
if (tableReg.var_busto) excludeCols.add(tableReg.var_busto.toUpperCase())
}
// getdata.sas drops the RK field from the editor view when the table is
// registered with rk_underlying (drop_rk=&buskey), and exposes the business
// key instead.
if (rkUnderlying && buskey) {
buskey.split(/\s+/).forEach(c => excludeCols.add(c.toUpperCase()))
}
let visibleColumns = tableData.columns.filter(c => !excludeCols.has(c.name.toUpperCase()))
let visibleColumns = pkFirst(
tableData.columns.filter(c => !excludeCols.has(c.name.toUpperCase())),
pkColumns
)
let visibleRows = tableData.rows
.filter(r => filterPredicate(r))
.map(r => {
@@ -236,15 +287,15 @@ if (tableData && tableData.columns && tableData.rows) {
}
// ─── Row and cell limits ─────────────────────────────────────────────────
// Mirrors getdata.sas: DC_MAXOBS_WEBEDIT rows, or DC_MAXCELLS_WEBEDIT
// cells (rows x columns), whichever is reached first. Both are read from
// MPE_CONFIG, the way the real service picks them up from the settings
// program it generates - so editing the option in DC changes what the mock
// allows, and an option that is absent (or switched off, var_active=0)
// falls back to the shipped default. The check runs after the
// PRE_EDIT_HOOK, as the single check in getdata.sas does - a hook may
// replace the data with a larger table. The cell count includes the
// housekeeping delete column, which is what getdata.sas counts.
// Mirrors getdata.sas: DC_MAXOBS_WEBEDIT rows or DC_MAXCELLS_WEBEDIT
// cells (rows x columns), whichever is reached first - the selection is
// cut to min(rows, cells/columns) and flagged in sasparams.
// Both options are read from MPE_CONFIG, the way the real service picks
// them up from the settings program it generates - so editing the option
// in DC changes what the mock serves, and an option that is absent (or
// switched off, var_active=0) falls back to the shipped default. The
// check runs after the PRE_EDIT_HOOK, as the single check in getdata.sas
// does - a hook may replace the data with a larger table.
// MPE_CONFIG lives in the control library, not in the table's own library,
// so this reads mpeDataDir - the same directory the MPE_TABLES lookup above
// uses. Passing the requested table's dataDir made the read fail for every
@@ -252,20 +303,32 @@ if (tableData && tableData.columns && tableData.rows) {
const dcCfg = loadDcConfig(driveRoot, appLoc, mpeDataDir)
const maxObsWebEdit = dcConfigNumber(dcCfg, 'DC_MAXOBS_WEBEDIT', 250)
const maxCellsWebEdit = dcConfigNumber(dcCfg, 'DC_MAXCELLS_WEBEDIT', 200000)
const editColCount = visibleColumns.length + 1
const cellCount = visibleRows.length * editColCount
if (visibleRows.length > maxObsWebEdit) {
abortMsg =
'Table is too big (' +
visibleRows.length +
' rows) - please filter and try again!'
} else if (cellCount > maxCellsWebEdit) {
abortMsg =
'Selection is too wide (' +
visibleRows.length +
' rows x ' +
editColCount +
' cols) - please filter and try again!'
// getdata.sas counts the columns of work.out, i.e. the table's own
// columns - the delete column is client-owned and not part of it.
const editColCount = visibleColumns.length
let maxRows = maxObsWebEdit
let limitVar = 'DC_MAXOBS_WEBEDIT'
if (editColCount > 0) {
const cellRows = Math.floor(maxCellsWebEdit / editColCount)
if (cellRows < maxRows) {
maxRows = cellRows
limitVar = 'DC_MAXCELLS_WEBEDIT'
}
}
if (maxRows < 1) maxRows = 1
let truncated = 0
let truncMsg = ''
if (visibleRows.length > maxRows) {
truncated = 1
// Same wording as the macro getdata.sas sets, so the spec can assert
// on the text the real service would send.
truncMsg =
'Only the first ' +
maxRows +
' rows are loaded - the ' +
limitVar +
' limit has been reached. Filter the table to work with the rest.'
visibleRows = visibleRows.slice(0, maxRows)
}
// Augment rows: add delete flag, normalise keys, convert temporal values to ISO
@@ -363,15 +426,21 @@ if (tableData && tableData.columns && tableData.rows) {
sasparams: [{
COLHEADERS: colHeaders.join(','),
FILTER_TEXT: filterText === '' ? ' ' : filterText,
PKCNT: tableReg && tableReg.buskey ? tableReg.buskey.trim().split(/\s+/).length : 0,
PK: tableReg ? tableReg.buskey || '' : '',
PKCNT: pkColumns.length,
PK: pkFields,
DTVARS: dtVars.length ? ' ' + dtVars.join(' ') : '',
DTTMVARS: dttmVars.length ? ' ' + dttmVars.join(' ') : '',
TMVARS: tmVars.length ? ' ' + tmVars.join(' ') : '',
LOADTYPE: tableReg ? tableReg.loadtype || 'UPDATE' : 'UPDATE',
RK_FLAG: tableReg && tableReg.rk_underlying ? 1 : 0,
CLS_FLAG: 0,
ISMAP: 0
ISMAP: 0,
TRUNCATED: truncated,
TRUNC_MSG: truncMsg,
// names the derived columns for the grid, as getdata.sas does - the
// READONLY rule above is why they cannot be edited, this is how the
// EDIT screen knows they are computed rather than locked
DERIVEDVARS: derived.map(d => d.name).join(' ')
}],
versions: [],
xl_rules: []
@@ -386,26 +455,21 @@ if (tableData && tableData.columns && tableData.rows) {
],
maxvarlengths: [], query: [], sasdata: [],
$sasdata: { vars: {} },
sasparams: [{ COLHEADERS: '', FILTER_TEXT: ' ', PKCNT: 0, PK: '', DTVARS: '', DTTMVARS: '', TMVARS: '', LOADTYPE: 'UPDATE', RK_FLAG: 0, CLS_FLAG: 0, ISMAP: 0 }],
sasparams: [{ COLHEADERS: '', FILTER_TEXT: ' ', PKCNT: 0, PK: '', DTVARS: '', DTTMVARS: '', TMVARS: '', LOADTYPE: 'UPDATE', RK_FLAG: 0, CLS_FLAG: 0, ISMAP: 0, TRUNCATED: 0, TRUNC_MSG: '', DERIVEDVARS: '' }],
versions: [], xl_rules: []
}
}
webOutOpen()
if (abortMsg) {
webOutObj([{ MSG: abortMsg, MAC: 'getdata' }], 'sasjsAbort')
webOutObj([{ STATUS: 'ERROR' }], 'sasparams')
} else {
webOutObj(response.approvers, 'approvers')
webOutObj(response.cols, 'cols')
webOutObj(response.dqdata, 'dqdata')
webOutObj(response.dqrules, 'dqrules')
webOutObj(response.dsmeta, 'dsmeta')
webOutObj(response.maxvarlengths, 'maxvarlengths')
webOutObj(response.query, 'query')
webOutObj(response.sasdata, 'sasdata', response.$sasdata)
webOutObj(response.sasparams, 'sasparams')
webOutObj(response.versions, 'versions')
webOutObj(response.xl_rules, 'xl_rules')
}
webOutObj(response.approvers, 'approvers')
webOutObj(response.cols, 'cols')
webOutObj(response.dqdata, 'dqdata')
webOutObj(response.dqrules, 'dqrules')
webOutObj(response.dsmeta, 'dsmeta')
webOutObj(response.maxvarlengths, 'maxvarlengths')
webOutObj(response.query, 'query')
webOutObj(response.sasdata, 'sasdata', response.$sasdata)
webOutObj(response.sasparams, 'sasparams')
webOutObj(response.versions, 'versions')
webOutObj(response.xl_rules, 'xl_rules')
webOutClose()
+43 -4
View File
@@ -59,6 +59,9 @@ for (let i = 1; i <= 10; i++) {
let libds = ''
if (typeof table !== 'undefined' && table) libds = String(table).toUpperCase()
// captured before any hook can re-point the submission: the cleaned CSV is
// named after the original table, as loadfile.sas names it
const origLibds = libds
let libref = libds.split('.')[0] || dcLibref
let dsn = libds.split('.')[1] || ''
@@ -147,6 +150,12 @@ if (!msg) {
if (tableEntry.var_txfrom) computedCols.push(tableEntry.var_txfrom.toUpperCase())
if (tableEntry.var_txto) computedCols.push(tableEntry.var_txto.toUpperCase())
if (tableEntry.var_processed) computedCols.push(tableEntry.var_processed.toUpperCase())
// loadfile.sas drops the RK from the comparison for a table
// with rk_underlying (the loader derives it), so an upload
// for one legitimately omits it.
if (tableEntry.rk_underlying && tableEntry.buskey) {
computedCols.push(String(tableEntry.buskey).toUpperCase())
}
}
}
const baseCols = schema.map((c) => c.name.toUpperCase())
@@ -172,6 +181,9 @@ if (!msg) {
// written, while LIBREF / DS are still ordinary macro variables, so it
// can re-point the changeset at a different table. The mock eval()s the
// hook's .js counterpart in this scope so it can reassign libref / dsn.
// A validating hook rejects the submission by setting hookErrmsg (see
// hooks/mpe_derived_postedit.js), which stops the staging below.
let hookErrmsg = ''
const hookTables = mpeLoadTableData('MPE_TABLES')
const hookReg = (hookTables && hookTables.rows)
? hookTables.rows.find(r => r.libref === libref && r.dsn === dsn)
@@ -179,19 +191,41 @@ if (!msg) {
if (hookReg && hookReg.post_edit_hook) {
const hookSrc = mockHookSource(hookReg.post_edit_hook)
if (hookSrc) {
// a hook reads and normalises the submitted rows as jsDataRows
// (stagedata.js names them that); here they are `rows`
const jsDataRows = rows
try {
eval(hookSrc)
} catch (err) {
console.log(
// a hook that throws has not validated anything, so the
// submission is refused rather than staged unchecked
hookErrmsg =
'post_edit_hook ' + hookReg.post_edit_hook + ' failed: ' + err.message
)
}
libds = libref + '.' + dsn
}
}
if (hookErrmsg) msg = hookErrmsg
tableId = makeTableId()
stageSubmission({
// ─── RK assignment (mpe_loader) ──────────────────────────────────────
// The uploaded file carries the business key and no RK (see the
// expected-columns check above), so the loader derives it before the
// rows are staged.
const finalTables = mpeLoadTableData('MPE_TABLES')
const finalReg =
finalTables && finalTables.rows
? finalTables.rows.find((r) => r.libref === libref && r.dsn === dsn)
: null
const rkName = mpeAssignRetainedKey({
rows,
tableReg: finalReg,
baseData: makeTableLoader(libDataDir(libref))(dsn)
})
if (rkName && !headers.some((h) => h.toUpperCase() === rkName.toUpperCase())) {
headers.push(rkName)
}
if (!msg) stageSubmission({
dataDir: libDataDir(libref),
stagingDir,
tableId,
@@ -200,7 +234,12 @@ if (!msg) {
dsn,
message: 'File upload: ' + csvName,
headers,
rows
rows,
// loadfile.sas leaves the cleaned CSV (%mp_cleancsv) in the approval
// folder, named after the table as the request found it. It copies
// the upload as BKP_<name> only when a second, non-CSV fileref came
// with the request, which the mock does not accept - so no BKP here.
auditFiles: [{ name: origLibds + '.csv', content: csvText }]
})
}
}
+64 -5
View File
@@ -22,6 +22,9 @@ let message = _sctRow.MESSAGE || ''
let libds = _sctRow.LIBDS || ''
let libref = libds.split('.')[0] || dcLibref
let dsn = libds.split('.')[1] || ''
// stagedata.sas names the CSV it stages after the table as the request found
// it, before a hook can re-point the submission
const origLibds = libds
// The base table lives in its own library (DC_JSLIB for control tables,
// TESTDATA for demo user tables); the staging dir stays in the control lib.
@@ -63,6 +66,10 @@ if (schema.length > 0) {
// eval()s the hook's .js counterpart in this scope so it can reassign
// libref / dsn / libds; the submit record then names the new base table, exactly
// as the SAS hook does via call symputx.
// A validating hook rejects the submission by setting hookErrmsg (see
// hooks/mpe_derived_postedit.js); the response below turns it into an abort,
// and nothing is staged.
let hookErrmsg = ''
const hookTables = makeTableLoader(mpeDataDir)('MPE_TABLES')
const hookReg = (hookTables && hookTables.rows)
? hookTables.rows.find(r => r.libref === libref && r.dsn === dsn)
@@ -73,14 +80,58 @@ if (hookReg && hookReg.post_edit_hook) {
try {
eval(hookSrc)
} catch (err) {
console.log(
// a hook that throws has not validated anything, so the submission
// is refused rather than staged unchecked
hookErrmsg =
'post_edit_hook ' + hookReg.post_edit_hook + ' failed: ' + err.message
)
}
libds = libref + '.' + dsn
}
}
// ─── RK assignment (mpe_loader) ──────────────────────────────────────────────
// A table registered with rk_underlying is edited on its business key and the
// RK column is withheld from the editor (see getdata.js), so the submitted rows
// carry no RK. The loader derives it, and MPE_SUBMIT records the staged width
// - which includes the derived column.
const finalTables = makeTableLoader(mpeDataDir)('MPE_TABLES')
const finalReg =
finalTables && finalTables.rows
? finalTables.rows.find((r) => r.libref === libref && r.dsn === dsn)
: null
const rkName = mpeAssignRetainedKey({
rows: jsDataRows,
tableReg: finalReg,
baseData: loadTableData(dsn)
})
if (rkName && !jsDataHeaders.some((h) => h.toUpperCase() === rkName.toUpperCase())) {
jsDataHeaders.push(rkName)
}
// ─── Derived variables ───────────────────────────────────────────────────────
// stagedata.sas drops the MPE_DERIVED droplist from the submitted data before
// anything is staged (they are display-only), and the approval CSV is written
// from that stripped dataset - so neither the staged rows nor the pack may
// carry them.
const derivedVars = mpeDerived({
libref,
table: dsn,
dataDir: mpeDataDir,
columns: schema
})
if (derivedVars.length > 0) {
const derivedNames = new Set(derivedVars.map((d) => d.name.toUpperCase()))
const keep = (key) => !derivedNames.has(key.toUpperCase())
jsDataHeaders = jsDataHeaders.filter(keep)
jsDataRows = jsDataRows.map((row) => {
const copy = {}
for (const key of Object.keys(row)) if (keep(key)) copy[key] = row[key]
return copy
})
}
// ─── Stage the data ──────────────────────────────────────────────────────────
// One subfolder per submission (DSID) under the staging directory, so that the
// staged data can sit alongside related artifacts (submit/approval logs,
@@ -88,7 +139,7 @@ if (hookReg && hookReg.post_edit_hook) {
const tableId = makeTableId()
stageSubmission({
if (!hookErrmsg) stageSubmission({
dataDir,
stagingDir,
tableId,
@@ -97,9 +148,17 @@ stageSubmission({
dsn,
message,
headers: jsDataHeaders,
rows: jsDataRows
rows: jsDataRows,
// stagedata.sas writes the staged rows as <orig_libds>.csv alongside them
// (%mp_ds2csv into the approval folder), so the audit pack carries it
auditFiles: [{ name: origLibds + '.csv', content: toCsv(jsDataHeaders, jsDataRows) }]
})
webOutOpen()
webOutObj([{ STATUS: 'SUCCESS', DSID: tableId, URL: '' }], 'sasparams')
if (hookErrmsg) {
webOutObj([{ MSG: hookErrmsg, MAC: 'stagedata' }], 'sasjsAbort')
webOutObj([{ STATUS: 'ERROR', DSID: '' }], 'sasparams')
} else {
webOutObj([{ STATUS: 'SUCCESS', DSID: tableId, URL: '' }], 'sasparams')
}
webOutClose()
@@ -0,0 +1,373 @@
// ─────────────────────────────────────────────────────────────────────────────
// Mock of services/hooks/mpe_derived_postedit
//
// Registered against DC_JSLIB.MPE_DERIVED (MPE_TABLES.post_edit_hook). This
// file is eval'd by editors/stagedata.js and editors/loadfile.js, mirroring how
// mpe_loader.sas %includes the hook program via %mpe_runhook, so it shares the
// caller's scope: jsDataRows (the submitted rows - work.staging_ds on the
// server), libref, dsn, mpeDataDir and the dcMockUtils helpers.
//
// A hook rejects a submission by setting hookErrmsg, which the caller turns
// into a sasjsAbort - the same outcome as the SAS hook's %mp_abort.
//
// The rules mirror services/hooks/mpe_derived_postedit.sas, in its order:
// normalise the incoming rows, reject a duplicate name and a bad field, then
// check the dependency graph that the resulting configuration would have (the
// incoming rows plus the current rows they do not replace). As on the server,
// the collision between VAR_NM and a physical column is left to the read-time
// engine, which runs with the target library assigned - the hook does not have
// it here either.
// ─────────────────────────────────────────────────────────────────────────────
;(function mpeDerivedPostedit() {
const DELETE_MARK = '_____DELETE__THIS__RECORD_____'
const NAME_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_'
const FMT_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_$.'
const text = (v) => (v === undefined || v === null ? '' : String(v))
const up = (v) => text(v).trim().toUpperCase()
const strip = (v) => text(v).trim()
/** Reads a field as the hook normalised it. */
const field = (row, name) => up(colVal(row, name))
/** Writes a field back under the key the row already carries. */
const setField = (row, name, value) => {
const key = Object.keys(row).find((k) => k.toUpperCase() === name)
if (key) row[key] = value
else row[name] = value
}
const rows = Array.isArray(jsDataRows) ? jsDataRows : []
const isDeleted = (row) => field(row, DELETE_MARK) === 'YES'
const isLive = (row) =>
!isDeleted(row) && Number(colVal(row, 'ACTIVE')) === 1
const keyOf = (row) =>
[field(row, 'LIBREF'), field(row, 'DSN'), field(row, 'VAR_NM')].join(
'.'
)
const reject = (msg) => {
hookErrmsg = msg
}
// ─── Normalise the incoming values ─────────────────────────────────────────
for (const row of rows) {
if (isDeleted(row)) continue
for (const name of [
'LIBREF',
'DSN',
'VAR_NM',
'VAR_TYPE',
'VAR_FMT',
'VAR_DEPS'
]) {
setField(row, name, up(colVal(row, name)))
}
for (const name of ['VAR_LABEL', 'VAR_LOGIC']) {
setField(row, name, strip(colVal(row, name)))
}
}
// ─── Duplicate names after normalisation ───────────────────────────────────
// mpe_loader compares the raw values, so a case variant reaches this point
const seen = new Set()
for (const row of rows) {
if (isDeleted(row)) continue
const key = keyOf(row)
if (seen.has(key)) {
return reject(
`MPE_DERIVED: ${field(row, 'VAR_NM')} is declared more than once for ${field(row, 'LIBREF')}.${field(row, 'DSN')}`
)
}
seen.add(key)
}
// ─── Field level checks, the rules the engine applies at read time ─────────
// C0 controls and DEL: what a single-line label cannot carry
const CONTROL_CHARS = /[\u0000-\u001F\u007F]/
// true when the value carries a character outside the allowed set
const hasInvalidChar = (value, chars) =>
[...value].some((c) => !chars.includes(c))
for (const row of rows) {
if (isDeleted(row)) continue
const libref = field(row, 'LIBREF')
const dsn = field(row, 'DSN')
const name = field(row, 'VAR_NM')
const type = field(row, 'VAR_TYPE')
const len = Number(colVal(row, 'VAR_LEN'))
const fmt = field(row, 'VAR_FMT')
const label = strip(colVal(row, 'VAR_LABEL'))
const logic = strip(colVal(row, 'VAR_LOGIC'))
let reason = ''
if (!libref || !dsn) reason = 'LIBREF and DSN are required'
else if (!name) reason = 'VAR_NM is required'
else if (hasInvalidChar(name, NAME_CHARS))
reason = 'VAR_NM must be a valid SAS name'
else if ('0123456789'.includes(name[0]))
reason = 'VAR_NM must not start with a digit'
else if (name === DELETE_MARK) reason = 'VAR_NM is reserved'
else if (type !== 'C' && type !== 'N')
reason = 'VAR_TYPE must be C or N'
// ACTIVE is a picker in the EDIT screen (HARDSELECT over the
// MPE_SELECTBOX pair) - the same pair mpe_derived_check.sas enforces
else if (!['0', '1'].includes(String(colVal(row, 'ACTIVE')).trim()))
reason = 'ACTIVE must be 1 or 0'
else if (!Number.isInteger(len) || len < 1)
reason = 'VAR_LEN must be a positive integer'
else if (type === 'C' && len > 32767)
reason = 'VAR_LEN exceeds 32767 for a character variable'
else if (type === 'N' && len > 8)
reason = 'VAR_LEN exceeds 8 for a numeric variable'
else if (
fmt.includes(';') ||
fmt.includes('&') ||
fmt.includes('%') ||
fmt.includes('/*') ||
fmt.includes('*/')
)
reason = 'VAR_FMT must be a format name, without ; & % or /* */'
else if (hasInvalidChar(fmt, FMT_CHARS))
reason = 'VAR_FMT must be a format name'
// free text, in any script - what is refused is anything that cannot be
// a single-line column label, and a value the engine would cut to 256
else if (CONTROL_CHARS.test(label))
reason =
'VAR_LABEL must be plain text - no line breaks or control characters'
else if ([...label].length > 256)
reason = 'VAR_LABEL exceeds 256 characters'
else if (!logic) reason = 'VAR_LOGIC is required'
else if (logic.includes(';'))
reason = 'VAR_LOGIC must be a single expression - remove the ;'
else if (logic.includes('&') || logic.includes('%'))
reason = 'VAR_LOGIC must not contain macro triggers'
else if (logic.includes('/*') || logic.includes('*/'))
reason = 'VAR_LOGIC must not contain comments'
if (reason) return reject(`MPE_DERIVED: ${name}: ${reason}`)
}
// ─── The graph of the resulting configuration ──────────────────────────────
// The incoming live rows plus the current rows they do not replace. Only
// active rows are in it, because the engine reads nothing else - an inactive
// row can neither resolve a dependency nor be resolved by one.
const controlDir = mpeDataDir
const loadControl = makeTableLoader(controlDir)
const current = loadControl('MPE_DERIVED')
const nowSeconds = Date.now() / 1000
// mpeDerived reads a row as current when TX_TO is missing, zero or in the
// future (dcMockUtils.js, the read-time engine), so the same test is used
// here - a seeded row with no TX_TO is live for the engine, and a dependency
// on it has to validate
const isCurrent = (row) =>
!Number(colVal(row, 'TX_TO')) || Number(colVal(row, 'TX_TO')) > nowSeconds
const stagedKeys = new Set(rows.map(keyOf))
const stagedLive = rows.filter(isLive)
// the control-table arm of the SAS hook is limited to the tables this
// submission touches, so another table's configuration cannot block it
const stagedTables = new Set(
rows.map((row) => [field(row, 'LIBREF'), field(row, 'DSN')].join('.'))
)
/** libref.dsn.name - the key every graph lookup uses. */
const nodeKey = (n) => [n.libref, n.dsn, n.name].join('.')
const graph = new Map()
for (const row of stagedLive) {
graph.set(keyOf(row), {
libref: field(row, 'LIBREF'),
dsn: field(row, 'DSN'),
name: field(row, 'VAR_NM'),
deps: field(row, 'VAR_DEPS').split(/\s+/).filter(Boolean),
logic: strip(colVal(row, 'VAR_LOGIC')).toUpperCase()
})
}
const currentRows =
current && Array.isArray(current.rows)
? current.rows.filter(
(r) =>
isCurrent(r) &&
Number(colVal(r, 'ACTIVE')) === 1 &&
stagedTables.has(
[up(colVal(r, 'LIBREF')), up(colVal(r, 'DSN'))].join(
'.'
)
) &&
!stagedKeys.has(
[
up(colVal(r, 'LIBREF')),
up(colVal(r, 'DSN')),
up(colVal(r, 'VAR_NM'))
].join('.')
)
)
: []
for (const row of currentRows) {
graph.set(
[
up(colVal(row, 'LIBREF')),
up(colVal(row, 'DSN')),
up(colVal(row, 'VAR_NM'))
].join('.'),
{
libref: up(colVal(row, 'LIBREF')),
dsn: up(colVal(row, 'DSN')),
name: up(colVal(row, 'VAR_NM')),
deps: up(colVal(row, 'VAR_DEPS')).split(/\s+/).filter(Boolean),
logic: strip(colVal(row, 'VAR_LOGIC')).toUpperCase()
}
)
}
/** Names this load leaves inactive, which therefore cannot resolve a dep. */
const inactive = new Set()
const liveKeys = new Set(stagedLive.map(keyOf))
for (const row of rows) {
const key = keyOf(row)
if (!liveKeys.has(key)) inactive.add(key)
}
if (current && Array.isArray(current.rows)) {
for (const row of current.rows) {
const key = [
up(colVal(row, 'LIBREF')),
up(colVal(row, 'DSN')),
up(colVal(row, 'VAR_NM'))
].join('.')
if (
isCurrent(row) &&
Number(colVal(row, 'ACTIVE')) !== 1 &&
!liveKeys.has(key)
) {
inactive.add(key)
}
}
}
for (const node of graph.values()) {
for (const dep of node.deps) {
const key = [node.libref, node.dsn, dep].join('.')
if (inactive.has(key) && !graph.has(key)) {
return reject(
`MPE_DERIVED: ${node.name}: VAR_DEPS lists ${dep}, which is not active after this load - the engine cannot resolve it`
)
}
}
}
// ─── Self reference, undeclared and stale dependencies ─────────────────────
/** The identifiers an expression uses, with quoted runs removed. */
const identifiers = (logic) =>
logic
.replace(/'([^']|'')*'/g, ' ')
.replace(/"([^"]|"")*"/g, ' ')
.replace(/[^A-Za-z0-9_]/g, ' ')
.toUpperCase()
.split(/\s+/)
.filter(Boolean)
// scoped per table, as the server does: a name that is a derived variable
// on another table says nothing about this one
const graphKeys = new Set([...graph.values()].map((n) => nodeKey(n)))
// one check at a time, in the order the server runs them, so the message a
// row gets is the one it would get there
for (const node of graph.values()) {
if (node.deps.includes(node.name)) {
return reject(
`MPE_DERIVED: ${node.name}: VAR_DEPS may not list itself`
)
}
}
for (const node of graph.values()) {
for (const token of identifiers(node.logic)) {
if (
graphKeys.has([node.libref, node.dsn, token].join('.')) &&
token !== node.name &&
!node.deps.includes(token)
) {
return reject(
`MPE_DERIVED: ${node.name}: VAR_LOGIC references ${token}, which is not in VAR_DEPS`
)
}
}
}
for (const node of graph.values()) {
const tokens = identifiers(node.logic)
for (const dep of node.deps) {
if (!tokens.includes(dep)) {
return reject(
`MPE_DERIVED: ${node.name}: VAR_DEPS lists ${dep}, which VAR_LOGIC does not reference`
)
}
}
}
// ─── A derived variable may not be named by a row level security rule ──────
const rls = loadControl('MPE_ROW_LEVEL_SECURITY')
if (rls && Array.isArray(rls.rows)) {
for (const node of graph.values()) {
const match = rls.rows.find(
(r) =>
up(colVal(r, 'RLS_LIBREF')) === node.libref &&
up(colVal(r, 'RLS_TABLE')) === node.dsn &&
up(colVal(r, 'RLS_VARIABLE_NM')) === node.name &&
Number(colVal(r, 'RLS_ACTIVE')) === 1 &&
isCurrent(r)
)
if (match) {
return reject(
`MPE_DERIVED: ${node.name} is named by a row level security rule on ${node.libref}.${node.dsn} - derived variables cannot be used there`
)
}
}
}
// ─── Cycles ────────────────────────────────────────────────────────────────
// Repeatedly place the variables whose derived inputs are already placed; a
// round that places nothing leaves a cycle. The path is walked so the
// message names it, as the SAS hook does.
const nodeKeys = new Map(
[...graph.values()].map((n) => [n, [n.libref, n.dsn, n.name].join('.')])
)
const placed = new Set()
let remaining = [...graph.values()]
while (remaining.length > 0) {
const ready = remaining.filter((n) =>
n.deps.every((d) => {
const key = [n.libref, n.dsn, d].join('.')
return !graph.has(key) || placed.has(key)
})
)
if (ready.length === 0) {
// walk the unplaced edges so the message names the cycle
const start = remaining[0]
const path = [start.name]
const visited = new Set([nodeKeys.get(start)])
let cursor = start
for (let i = 0; i < remaining.length; i++) {
const parentKey = cursor.deps
.map((d) => [cursor.libref, cursor.dsn, d].join('.'))
.find((key) => graph.has(key) && !placed.has(key))
if (!parentKey) break
path.push(graph.get(parentKey).name)
if (visited.has(parentKey)) break
visited.add(parentKey)
cursor = graph.get(parentKey)
}
return reject(
`MPE_DERIVED: VAR_DEPS contains a cycle: ${path.join(' -> ')}`
)
}
for (const node of ready) placed.add(nodeKeys.get(node))
remaining = remaining.filter((n) => !placed.has(nodeKeys.get(n)))
}
})()
+17 -1
View File
@@ -21,9 +21,25 @@ const tableName = libds.split('.')[1] || ''
// ─── Load table data (searches all libref data folders) ──────────────────────
const _loaded = loadTableAnyLib(tableName) || {}
const tableData = _loaded.data
let tableData = _loaded.data
const tableLibref = _loaded.libref || libref
// ─── Derived (computed) variables (mpe_derived.sas) ─────────────────────────
// Present in the schema and in the rows before the filter is applied, so the
// filter may reference them and the values below are computed from the same
// row set the grid shows.
const controlDataDir = libDataDir(dcLibref)
const derived = mpeDerived({
libref: tableLibref,
table: tableName,
dataDir: controlDataDir,
columns: tableData ? tableData.columns : []
})
if (tableData && derived.length > 0) {
tableData = mpeDeriveRows(tableData, derived)
}
// ─── Resolve column metadata ─────────────────────────────────────────────────
let colMeta = null
+26 -6
View File
@@ -49,6 +49,23 @@ if (!tableData && dsn) {
if (found) tableData = found.data
}
// ─── Derived variables (mpe_derived.sas) ─────────────────────────────────────
// A stored filter can reference a derived column, so the derivation has to run
// before the filter. The columns themselves are dropped from the output - they
// belong to the EDIT screen, as viewdata.sas drops them.
// a copy: the derivation below returns a new column list, and every use here
// (cols, search, rows, vars) must keep looking at the physical columns only
const physicalColumns = [...(tableData && tableData.columns ? tableData.columns : [])]
const derived = mpeDerived({
libref: libref,
table: dsn,
dataDir: mpeDataDir,
columns: physicalColumns
})
if (tableData && derived.length > 0) {
tableData = mpeDeriveRows(tableData, derived)
}
// ─── Apply the stored filter (mpe_filtermaster, VIEW mode) ───────────────────
// Mirrors viewdata.sas: %mpe_filtermaster(VIEW,&libds,filter_rk=&filter_rk)
// Row Level Security and any saved filter are applied here, before the search.
@@ -103,7 +120,7 @@ let nobs = 0
if (tableData && tableData.columns) {
// mpGetcols, the JS counterpart of %mp_getcols.sas - the macro viewdata.sas
// calls for its own column list.
cols = mpGetcols(tableData.columns).map(c => {
cols = mpGetcols(physicalColumns).map(c => {
const ddtype = c.DDTYPE === 'CHARACTER' ? 'C' : c.DDTYPE === 'NUMERIC' ? 'N' : c.DDTYPE
const formatStr = c.TYPE === 'N' ? (c.FORMAT || '8.') : ('$' + c.LENGTH + '.')
return {
@@ -119,7 +136,10 @@ if (tableData && tableData.columns) {
let rows
if (searchType) {
const matches = filteredRows.filter(row => rowMatchesSearch(row, tableData.columns, searchType, searchVal))
// the derived columns are dropped before the search, so only the
// physical columns are searched (viewdata.sas drops them in the
// vwsearch data step mp_searchdata then reads)
const matches = filteredRows.filter(row => rowMatchesSearch(row, physicalColumns, searchType, searchVal))
// mp_searchdata caps its output at outobs=&DC_MAXOBS_WEBVIEW, and
// viewdata.sas then keeps only `if _n_<&DC_MAXOBS_WEBVIEW` of them.
nobs = Math.min(matches.length, maxObsWebView)
@@ -134,7 +154,7 @@ if (tableData && tableData.columns) {
// viewdata rows: all values as strings (formatted, SAS proc-print style)
viewdata = rows.map(r => {
const row = {}
for (const col of tableData.columns) {
for (const col of physicalColumns) {
const lcName = col.name.toLowerCase()
let val = r[col.name]
if (val === undefined) val = r[lcName]
@@ -174,15 +194,15 @@ if (tableData && tableData.columns) {
// (SAS missing), character columns get "".
// A search that matches nothing sends no rows at all (the client shows
// its "no data found" panel instead of an empty grid).
if (viewdata.length === 0 && !searchType && tableData.columns.length > 0) {
if (viewdata.length === 0 && !searchType && physicalColumns.length > 0) {
const emptyRow = {}
for (const col of tableData.columns) emptyRow[col.name] = col.type === 'N' ? '.' : ''
for (const col of physicalColumns) emptyRow[col.name] = col.type === 'N' ? '.' : ''
viewdata = [emptyRow]
}
// $viewdata vars: keep original SAS types (num stays num, char stays char)
vars = {}
for (const col of tableData.columns) {
for (const col of physicalColumns) {
vars[col.name] = sasVarsEntry(col)
}
}
+92
View File
@@ -0,0 +1,92 @@
// Collect the SASjs Server drive for an app into a mock-snapshot.json, which
// `sasjs build` ships as the browser build's starting drive (the alternative is
// a drive seeded from the compiled services alone, i.e. no DC data).
//
// Usage: node sas/mocks/snapshot.js <drive-root> <appLoc> <out-file>
// drive-root the SASjs Server drive folder (the `drive` folder of a server install)
// appLoc the deployed app location, e.g. /Public/app/dc
// out-file where to write the snapshot, e.g. sas/mocks/sasjsbuild/mock-snapshot.json
//
// Every file is written as a string because the browser runtime's drive holds
// text only; a file that does not survive a UTF-8 round trip is skipped rather
// than mangled.
const fs = require('fs')
const path = require('path')
const [driveRoot, appLoc, outFile, excludeArg] = process.argv.slice(2)
if (!driveRoot || !appLoc || !outFile) {
console.error(
'Usage: node sas/mocks/snapshot.js <drive-root> <appLoc> <out-file> [exclude,exclude]'
)
process.exit(1)
}
const app = '/' + appLoc.replace(/^\/+|\/+$/g, '')
const driveFolder = path.join(
driveRoot,
'files',
...app.split('/').filter(Boolean)
)
const driveKey = `/sasjs/drive/files${app}`
// Comma-separated paths relative to the app, e.g. "services/web9" - the
// streamed frontend is copied into the drive as a service folder, is already the
// site itself, and would otherwise duplicate the whole app inside the snapshot.
const excludes = (excludeArg || '')
.split(',')
.map((value) => value.trim().replace(/^\/+|\/+$/g, ''))
.filter(Boolean)
if (!fs.existsSync(driveFolder)) {
console.error('No drive folder at ' + driveFolder)
process.exit(1)
}
const files = {}
const skipped = []
const walk = (folder) => {
for (const entry of fs.readdirSync(folder, { withFileTypes: true })) {
const absolute = path.join(folder, entry.name)
if (entry.isDirectory()) {
walk(absolute)
continue
}
const relative = path
.relative(driveFolder, absolute)
.split(path.sep)
.join('/')
if (
excludes.some(
(prefix) => relative === prefix || relative.startsWith(prefix + '/')
)
) {
continue
}
const buffer = fs.readFileSync(absolute)
const text = buffer.toString('utf8')
if (!Buffer.from(text, 'utf8').equals(buffer)) {
skipped.push(relative)
continue
}
files[`${driveKey}/${relative}`] = text
}
}
walk(driveFolder)
fs.mkdirSync(path.dirname(path.resolve(outFile)), { recursive: true })
fs.writeFileSync(path.resolve(outFile), JSON.stringify({ files }, null, 2))
console.log(
`Snapshot: ${Object.keys(files).length} file(s) from ${driveFolder} -> ${outFile}` +
(skipped.length
? `, skipped ${skipped.length} non-text file(s): ${skipped.slice(0, 3).join(', ')}`
: '')
)
+136 -227
View File
@@ -6,8 +6,8 @@
"": {
"name": "dc-sas",
"dependencies": {
"@sasjs/cli": "4.20.9",
"@sasjs/core": "5.5.2"
"@sasjs/cli": "6.2.0",
"@sasjs/core": "6.1.0"
}
},
"node_modules/@asamuzakjp/css-color": {
@@ -140,81 +140,44 @@
}
},
"node_modules/@fast-csv/format": {
"version": "4.3.5",
"resolved": "https://registry.npmjs.org/@fast-csv/format/-/format-4.3.5.tgz",
"integrity": "sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A==",
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/@fast-csv/format/-/format-5.0.7.tgz",
"integrity": "sha512-VdypoRxv7PF+LsyPouTMKdB0d76hync+gLpgdNqfqVK44MsgW4oiCJSdrki2FisWT7v2QGUYDHjp4L7w5oO6gw==",
"license": "MIT",
"dependencies": {
"@types/node": "^14.0.1",
"lodash.escaperegexp": "^4.1.2",
"lodash.isboolean": "^3.0.3",
"lodash.isequal": "^4.5.0",
"lodash.isfunction": "^3.0.9",
"lodash.isnil": "^4.0.0"
"lodash.escaperegexp": "^4.1.2"
}
},
"node_modules/@sasjs/adapter": {
"version": "4.19.2",
"resolved": "https://registry.npmjs.org/@sasjs/adapter/-/adapter-4.19.2.tgz",
"integrity": "sha512-7uxkpk0kjK4PZ552YK0CsTmEKEurK/1uzloR4rxplbViRkVdaewP5l0hIrGDBVM4z0gmV5pf1jh8uil3/NSAzw==",
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/@sasjs/adapter/-/adapter-5.1.1.tgz",
"integrity": "sha512-rso6+xRpZhFuuUbgHmGztznuse4xZjlu7+dOcYMUMJezAd5p/xdCt+wvv4eIKejsMWDcIteSeRPOkgKr0TlxWg==",
"license": "ISC",
"dependencies": {
"@sasjs/utils": "3.6.4",
"@sasjs/utils": "4.0.1",
"axios": "1.20.0",
"axios-cookiejar-support": "5.0.5",
"form-data": "4.0.6",
"tough-cookie": "4.1.3"
}
},
"node_modules/@sasjs/adapter/node_modules/@sasjs/utils": {
"version": "3.6.4",
"resolved": "https://registry.npmjs.org/@sasjs/utils/-/utils-3.6.4.tgz",
"integrity": "sha512-tLbvIFMRb3KrHW5EapvMS/0tSa6W/SYCuR+fcdavayQ4eaUFayeRN3HKu2gXLtWJjHf/HijPVTsNwkIRwDvJMA==",
"license": "ISC",
"dependencies": {
"@fast-csv/format": "4.3.5",
"@types/fs-extra": "11.0.4",
"@types/prompts": "2.4.9",
"chalk": "4.1.1",
"cli-table": "0.3.11",
"consola": "3.4.2",
"fs-extra": "11.4.1",
"jwt-decode": "3.1.2",
"prompts": "2.4.2"
}
},
"node_modules/@sasjs/adapter/node_modules/chalk": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.1.tgz",
"integrity": "sha512-diHzdDKxcU+bAsUboHLPEDQiw0qEe0qd7SYUn3HgcFlWgbDcfLGswOHYeGrHKzG9z6UYf01d9VFMfZxPM1xZSg==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
"tough-cookie": "5.1.2"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
"node": ">=22"
}
},
"node_modules/@sasjs/cli": {
"version": "4.20.9",
"resolved": "https://registry.npmjs.org/@sasjs/cli/-/cli-4.20.9.tgz",
"integrity": "sha512-wJCdd6fRI0RVwEDLDsauUbsKQsH69aaYJqDkV9WfSKkRH1VxgU9zFGHdSxrKLOzrjwwybn/DGzh2xcgeeyl6jA==",
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/@sasjs/cli/-/cli-6.2.0.tgz",
"integrity": "sha512-zovAOCmyPqcm+gooAZgwvgGH8n5cas3sY0lA80+hyFoFtmZ8VjW/bh19LM8OVwheT9dBCRCx4fnHwjy5XGpzvQ==",
"license": "ISC",
"dependencies": {
"@sasjs/adapter": "4.19.2",
"@sasjs/core": "5.3.0",
"@sasjs/lint": "4.1.2",
"@sasjs/utils": "3.6.5",
"@sasjs/adapter": "^5.1.1",
"@sasjs/core": "^6.0.0",
"@sasjs/utils": "^4.1.0",
"axios": "1.20.0",
"chalk": "4.1.2",
"dotenv": "17.4.2",
"js-base64": "3.9.3",
"dotenv": "18.0.8",
"js-base64": "3.9.4",
"jsdom": "26.0.0",
"jwt-decode": "3.1.2",
"lodash.groupby": "4.6.0",
"lodash.uniqby": "4.7.0",
"node-graphviz": "0.1.1",
"node-powershell": "5.0.1",
@@ -230,52 +193,32 @@
"sasjs": "build/index.js"
},
"engines": {
"node": "^14.18.0 || >=16.10.0"
}
},
"node_modules/@sasjs/cli/node_modules/@sasjs/core": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@sasjs/core/-/core-5.3.0.tgz",
"integrity": "sha512-6x1hEPa1pCENyeG0Nro3YcZZ+A8Ns60pO6gDuvG1X6oWdwOKKPpZztQNKE6khFmXXIGl0uUgT9CbappUET+odA==",
"license": "MIT"
},
"node_modules/@sasjs/core": {
"version": "5.5.2",
"resolved": "https://registry.npmjs.org/@sasjs/core/-/core-5.5.2.tgz",
"integrity": "sha512-puojJhAKAKg5o0uHdGSkpQ3cXgzajMY9AXAm3R22l6bZ4GKvC03qRq1ueGjBBF3npNxviwIKCABTsNEtjs1LAw==",
"license": "MIT"
},
"node_modules/@sasjs/lint": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@sasjs/lint/-/lint-4.1.2.tgz",
"integrity": "sha512-P7QqeMPnhg/AvqH40dmyY2OArlvAYAp81/ijK/PhmS+x/HslpypdLoNy5iN7SwixUWPde5fPwV/ZBmcjVK0uLA==",
"license": "MIT",
"dependencies": {
"@sasjs/utils": "3.6.4",
"ignore": "7.0.11"
"node": ">=22"
},
"engines": {
"node": "^14.18.0 || >=16.10.0"
"peerDependencies": {
"@sasjs/lint": "^4.3.0"
}
},
"node_modules/@sasjs/lint/node_modules/@sasjs/utils": {
"version": "3.6.4",
"resolved": "https://registry.npmjs.org/@sasjs/utils/-/utils-3.6.4.tgz",
"integrity": "sha512-tLbvIFMRb3KrHW5EapvMS/0tSa6W/SYCuR+fcdavayQ4eaUFayeRN3HKu2gXLtWJjHf/HijPVTsNwkIRwDvJMA==",
"node_modules/@sasjs/cli/node_modules/@sasjs/utils": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@sasjs/utils/-/utils-4.1.0.tgz",
"integrity": "sha512-aC5pOugg6/2jKQR7ZaYsLtbsFy/iVacsrN3/Kzh6BouJUszuVrr13HMrQUSKcWL+rJzxYof7ymmmCl+vE6YwAw==",
"license": "ISC",
"dependencies": {
"@fast-csv/format": "4.3.5",
"@fast-csv/format": "5.0.7",
"@types/fs-extra": "11.0.4",
"@types/prompts": "2.4.9",
"chalk": "4.1.1",
"cli-table": "0.3.11",
"consola": "3.4.2",
"fs-extra": "11.4.1",
"jwt-decode": "3.1.2",
"jwt-decode": "4.0.0",
"prompts": "2.4.2"
},
"engines": {
"node": ">=22"
}
},
"node_modules/@sasjs/lint/node_modules/chalk": {
"node_modules/@sasjs/cli/node_modules/@sasjs/utils/node_modules/chalk": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.1.tgz",
"integrity": "sha512-diHzdDKxcU+bAsUboHLPEDQiw0qEe0qd7SYUn3HgcFlWgbDcfLGswOHYeGrHKzG9z6UYf01d9VFMfZxPM1xZSg==",
@@ -291,21 +234,80 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/@sasjs/utils": {
"version": "3.6.5",
"resolved": "https://registry.npmjs.org/@sasjs/utils/-/utils-3.6.5.tgz",
"integrity": "sha512-2iCy0EmZJCQSG1JsVa1t/ulUb7lfdBnp6wiRPRl9/AaxRu8D30ypqUKn6heos1lxhNxg42JFsry8rlupiajdHg==",
"license": "ISC",
"node_modules/@sasjs/core": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@sasjs/core/-/core-6.1.0.tgz",
"integrity": "sha512-U0Ekq/TXVMquYV+AG6urBQSUgqUvV37yduufy68QQVgDj3PJiLUJDcOXKOGbhWGD+Nv877VuPCviGYqrqbjXvg==",
"license": "MIT"
},
"node_modules/@sasjs/lint": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@sasjs/lint/-/lint-4.3.0.tgz",
"integrity": "sha512-65cjy4IjA4PgSlicOruF0DI9N2HO3LJqtgphsZCg8g7N/StE2FWLJLJbD1N4OHxN6htrUfj5SEawJJiHo5T9Yw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@fast-csv/format": "4.3.5",
"@sasjs/utils": "4.0.0",
"ignore": "7.0.11"
},
"engines": {
"node": ">=22"
}
},
"node_modules/@sasjs/lint/node_modules/@sasjs/utils": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@sasjs/utils/-/utils-4.0.0.tgz",
"integrity": "sha512-lARoCnRM4gSVf8ShoP5elHi7W28K/14MK037dQ0ABgMlP3+r6OuhAZYwK/a70tED47tKxyIoCAKLdP6jrG0ewQ==",
"license": "ISC",
"peer": true,
"dependencies": {
"@fast-csv/format": "5.0.7",
"@types/fs-extra": "11.0.4",
"@types/prompts": "2.4.9",
"chalk": "4.1.1",
"cli-table": "0.3.11",
"consola": "3.4.2",
"fs-extra": "11.4.1",
"jwt-decode": "3.1.2",
"jwt-decode": "4.0.0",
"prompts": "2.4.2"
},
"engines": {
"node": ">=22"
}
},
"node_modules/@sasjs/lint/node_modules/chalk": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.1.tgz",
"integrity": "sha512-diHzdDKxcU+bAsUboHLPEDQiw0qEe0qd7SYUn3HgcFlWgbDcfLGswOHYeGrHKzG9z6UYf01d9VFMfZxPM1xZSg==",
"license": "MIT",
"peer": true,
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/@sasjs/utils": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@sasjs/utils/-/utils-4.0.1.tgz",
"integrity": "sha512-aNEbOJFZ2Ru71/XkHKtywexBQYdst8ovq0a9vuVO4subZuKMjRtYcn4kTRr9VVIz65p8xGtVUkFEf+6t9Vbfcg==",
"license": "ISC",
"dependencies": {
"@fast-csv/format": "5.0.7",
"@types/fs-extra": "11.0.4",
"@types/prompts": "2.4.9",
"chalk": "4.1.1",
"consola": "3.4.2",
"fs-extra": "11.4.1",
"jwt-decode": "4.0.0",
"prompts": "2.4.2"
},
"engines": {
"node": ">=22"
}
},
"node_modules/@sasjs/utils/node_modules/chalk": {
@@ -344,10 +346,13 @@
}
},
"node_modules/@types/node": {
"version": "14.18.63",
"resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz",
"integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==",
"license": "MIT"
"version": "26.6.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.5.tgz",
"integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==",
"license": "MIT",
"dependencies": {
"undici-types": "~8.9.0"
}
},
"node_modules/@types/prompts": {
"version": "2.4.9",
@@ -580,17 +585,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/cli-table": {
"version": "0.3.11",
"resolved": "https://registry.npmjs.org/cli-table/-/cli-table-0.3.11.tgz",
"integrity": "sha512-IqLQi4lO0nIB4tcdTpN4LCB9FI3uqrJZK7RC515EnhZ6qBaglkIgICb1wjeAqpdoOabm1+SuQtkXIPdYC93jhQ==",
"dependencies": {
"colors": "1.0.3"
},
"engines": {
"node": ">= 0.2.0"
}
},
"node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
@@ -632,15 +626,6 @@
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
"node_modules/colors": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/colors/-/colors-1.0.3.tgz",
"integrity": "sha512-pFGrxThWcWQ2MsAz6RtgeWe4NK2kUE1WfsrvvlctdII745EW9I0yflqhe7++M5LEc7bV2c/9/5zc8sFcpL0Drw==",
"license": "MIT",
"engines": {
"node": ">=0.1.90"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@@ -733,10 +718,13 @@
}
},
"node_modules/dotenv": {
"version": "17.4.2",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
"integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
"version": "18.0.8",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-18.0.8.tgz",
"integrity": "sha512-2KDeoFoK/zRtcnbunCDlLG5VWVMcyqeQc6iK65o231nfNAcuGrKoqUQfJFUtBI+QlShk5HKIHxwebBtUl3rEfA==",
"license": "BSD-2-Clause",
"bin": {
"dotenv": "dist/index.cjs"
},
"engines": {
"node": ">=12"
},
@@ -1118,6 +1106,7 @@
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.11.tgz",
"integrity": "sha512-YChdK5txDjwGUvgR7oCJcLGkwi3LDh8Zx8tS7ndYciLEg/oOCL26VUBSUEtgS7EjiybcBqFxh5j0rAdDX7/bbg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 4"
}
@@ -1192,9 +1181,9 @@
}
},
"node_modules/js-base64": {
"version": "3.9.3",
"resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.9.3.tgz",
"integrity": "sha512-uwYQp+VJ38FVvtim6qNbit6e9uT6dwWQ4Y1+H9TxhW5hcHjpHwoxlR0nMpqUmIFOmu4VqMxwdJA88gIVuZJQ/g==",
"version": "3.9.4",
"resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.9.4.tgz",
"integrity": "sha512-PtOMXpEGuP0RRiRXsjzHzl44dMHxSu2CPvAhinupR1tBa88me+1DPqsobl7eupn5UukjLkln1ZnAOAOXOrYG0Q==",
"license": "BSD-3-Clause"
},
"node_modules/jsdom": {
@@ -1250,18 +1239,6 @@
"node": ">= 14"
}
},
"node_modules/jsdom/node_modules/tough-cookie": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
"integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
"license": "BSD-3-Clause",
"dependencies": {
"tldts": "^6.1.32"
},
"engines": {
"node": ">=16"
}
},
"node_modules/jsonfile": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
@@ -1275,10 +1252,13 @@
}
},
"node_modules/jwt-decode": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz",
"integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A==",
"license": "MIT"
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz",
"integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/kind-of": {
"version": "6.0.3",
@@ -1304,37 +1284,6 @@
"integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==",
"license": "MIT"
},
"node_modules/lodash.groupby": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/lodash.groupby/-/lodash.groupby-4.6.0.tgz",
"integrity": "sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==",
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT"
},
"node_modules/lodash.isequal": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
"deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
"license": "MIT"
},
"node_modules/lodash.isfunction": {
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/lodash.isfunction/-/lodash.isfunction-3.0.9.tgz",
"integrity": "sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==",
"license": "MIT"
},
"node_modules/lodash.isnil": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/lodash.isnil/-/lodash.isnil-4.0.0.tgz",
"integrity": "sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng==",
"license": "MIT"
},
"node_modules/lodash.uniqby": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/lodash.uniqby/-/lodash.uniqby-4.7.0.tgz",
@@ -1572,18 +1521,6 @@
"node": ">=10"
}
},
"node_modules/psl": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
"integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==",
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"funding": {
"url": "https://github.com/sponsors/lupomontero"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -1593,12 +1530,6 @@
"node": ">=6"
}
},
"node_modules/querystringify": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
"license": "MIT"
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
@@ -1622,12 +1553,6 @@
"node": ">=0.10.0"
}
},
"node_modules/requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
"license": "MIT"
},
"node_modules/restore-cursor": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz",
@@ -1778,27 +1703,15 @@
"license": "MIT"
},
"node_modules/tough-cookie": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.3.tgz",
"integrity": "sha512-aX/y5pVRkfRnfmuX+OdbSdXvPe6ieKX/G2s7e98f4poJHnqH3281gDPm/metm6E/WRamfx7WC4HUqkWHfQHprw==",
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
"integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
"license": "BSD-3-Clause",
"dependencies": {
"psl": "^1.1.33",
"punycode": "^2.1.1",
"universalify": "^0.2.0",
"url-parse": "^1.5.3"
"tldts": "^6.1.32"
},
"engines": {
"node": ">=6"
}
},
"node_modules/tough-cookie/node_modules/universalify": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",
"integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==",
"license": "MIT",
"engines": {
"node": ">= 4.0.0"
"node": ">=16"
}
},
"node_modules/tr46": {
@@ -1819,6 +1732,12 @@
"integrity": "sha512-PqdsRmhir3lUwraK/O6sdZ9QcmtUK0c6F1utX/WNquJsL4Jpdck+DSe9Mqy8Wk7uAQD/SlVn6cyGF5bj6VF33Q==",
"license": "MIT"
},
"node_modules/undici-types": {
"version": "8.9.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
"integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
"license": "MIT"
},
"node_modules/universalify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
@@ -1828,16 +1747,6 @@
"node": ">= 10.0.0"
}
},
"node_modules/url-parse": {
"version": "1.5.10",
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
"integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
"license": "MIT",
"dependencies": {
"querystringify": "^2.1.1",
"requires-port": "^1.0.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+6 -3
View File
@@ -26,12 +26,15 @@
"viyacbd": "sasjs folder delete /Public/app/viya && sasjs cbd -t viya && npm run viyadata",
"viyadata": "sasjs request services/admin/makedata -d deploy/makeDataViya.json -l sasjsresults/makedata.log -o sasjsresults/output.json -t viya",
"v4data": "sasjs request services/admin/makedata -d deploy/makeDataV4.json -l sasjsresults/makedatav4.log -o sasjsresults/outputv4.json -t v4",
"sasdocs": "sasjs doc && ./sasjs/utils/deploydocs.sh"
"sasdocs": "sasjs doc && ./sasjs/utils/deploydocs.sh",
"sas719setup": "sasjs cbd -t sas719 && npm run sas719data",
"sas719data": "sasjs request services/admin/makedata -d deploy/makeDataSas719.json -l sasjsresults/makedata_sas719.log -o sasjsresults/makedata_sas719.json -t sas719",
"sas719test": "sasjs test -t sas719"
},
"private": true,
"dependencies": {
"@sasjs/cli": "4.20.9",
"@sasjs/core": "5.5.2"
"@sasjs/cli": "6.2.0",
"@sasjs/core": "6.1.0"
},
"overrides": {
"nanoid": "3.3.18"
+32
View File
@@ -0,0 +1,32 @@
/**
@file mpe_derived.ddl
@brief ddl file
@details Defines the derived (computed) variable configuration table. This
drives MPE_DERIVED rows in MPE_TABLES, so that the table itself can be
maintained through the EDIT screen with the usual approve workflow.
Derived values are not persisted - the logic is applied at read time, before
the filter, so that the variables can be filtered on.
@version 9.2
@author 4GL Apps Ltd
@copyright 4GL Apps Ltd
**/
create table &curlib..MPE_DERIVED
(
TX_FROM num not null format=datetime19.3,
LIBREF varchar(8) not null,
DSN varchar(32) not null,
VAR_NM varchar(32) not null,
VAR_DEPS varchar(4000),
VAR_TYPE varchar(1) not null,
VAR_LEN int not null,
VAR_FMT varchar(32),
VAR_LABEL varchar(256),
VAR_LOGIC varchar(4000) not null,
ACTIVE int not null,
TX_TO num not null format=datetime19.3,
constraint pk_mpe_derived
primary key(tx_from, libref, dsn, var_nm));
+13
View File
@@ -118,3 +118,16 @@ insert into &mpelib..mpe_tables
,var_txfrom='TX_FROM'
,var_txto='TX_TO'
;
insert into &mpelib..mpe_tables
set tx_from=0
,tx_to='31DEC5999:23:59:59'dt
,libref="&mpelib"
,dsn='MPE_DERIVED'
,num_of_approvals_required=1
,loadtype='TXTEMPORAL'
,buskey='LIBREF DSN VAR_NM'
,post_edit_hook='services/hooks/mpe_derived_postedit'
,notes='Derived (computed) variables for the EDIT screen'
,var_txfrom='TX_FROM'
,var_txto='TX_TO'
;
@@ -0,0 +1,192 @@
/**
@file
@brief migration script to move to v7.19 of Data Controller
REQUIRED CHANGE - adds the MPE_DERIVED table, which holds derived (computed)
variable definitions for the EDIT screen, and registers it in MPE_TABLES so
that it can be maintained through the app with the usual approve workflow.
Replace YOURDCLIB and the library path below, then run the script. Any
MPE_DERIVED rows added before this script runs will be lost when the table
is created, so run it before configuring any derived variables.
**/
%let dclib=YOURDCLIB;
libname &dclib "/YOUR/DATACONTROLLER/LIBRARY/PATH";
proc sql;
create table &dclib..mpe_derived
(
tx_from num not null format=datetime19.3,
libref varchar(8) not null,
dsn varchar(32) not null,
var_nm varchar(32) not null,
var_deps varchar(4000),
var_type varchar(1) not null,
var_len num not null,
var_fmt varchar(32),
var_label varchar(256),
var_logic varchar(4000) not null,
active num not null,
tx_to num not null format=datetime19.3
);
quit;
proc datasets lib=&dclib noprint;
modify mpe_derived;
index create
pk_mpe_derived=(tx_from libref dsn var_nm)
/nomiss unique;
quit;
proc sql;
insert into &dclib..mpe_tables
set tx_from=0
,tx_to='31DEC5999:23:59:59'dt
,libref="&dclib"
,dsn='MPE_DERIVED'
,num_of_approvals_required=1
,loadtype='TXTEMPORAL'
,buskey='LIBREF DSN VAR_NM'
,post_edit_hook='services/hooks/mpe_derived_postedit'
,notes='Derived (computed) variables for the EDIT screen'
,var_txfrom='TX_FROM'
,var_txto='TX_TO'
;
quit;
/* Dropdown values for VAR_TYPE and ACTIVE. These rows, and the field rules
* further down, mirror the MPE_DERIVED rows in mpe_makedata.sas - keep the two
* in step, or an upgraded install and a freshly seeded one behave differently. */
proc sql noprint;
/* coalesce: an install with no selectbox rows yet returns a missing value,
and %eval(&maxrk+1) below would then fail */
select coalesce(max(selectbox_rk),0) into: maxrk from &dclib..mpe_selectbox;
quit;
%let rk1=%eval(&maxrk+1);
%let rk2=%eval(&maxrk+2);
%let rk3=%eval(&maxrk+3);
%let rk4=%eval(&maxrk+4);
proc sql;
insert into &dclib..mpe_selectbox
set selectbox_rk=&rk1
,ver_from_dttm=0
,select_lib="&dclib"
,select_ds="MPE_DERIVED"
,base_column="VAR_TYPE"
,selectbox_value='C'
,selectbox_order=1
,ver_to_dttm='31DEC5999:23:59:59'dt;
insert into &dclib..mpe_selectbox
set selectbox_rk=&rk2
,ver_from_dttm=0
,select_lib="&dclib"
,select_ds="MPE_DERIVED"
,base_column="VAR_TYPE"
,selectbox_value='N'
,selectbox_order=2
,ver_to_dttm='31DEC5999:23:59:59'dt;
/* ACTIVE - a fixed pair, not a lookup against another table */
insert into &dclib..mpe_selectbox
set selectbox_rk=&rk3
,ver_from_dttm=0
,select_lib="&dclib"
,select_ds="MPE_DERIVED"
,base_column="ACTIVE"
,selectbox_value='1'
,selectbox_order=1
,ver_to_dttm='31DEC5999:23:59:59'dt;
insert into &dclib..mpe_selectbox
set selectbox_rk=&rk4
,ver_from_dttm=0
,select_lib="&dclib"
,select_ds="MPE_DERIVED"
,base_column="ACTIVE"
,selectbox_value='0'
,selectbox_order=2
,ver_to_dttm='31DEC5999:23:59:59'dt;
quit;
/* Field rules for the EDIT screen. These are client side - the server side
* enforcement is the post edit hook registered above. */
proc sql;
insert into &dclib..MPE_VALIDATIONS set
tx_from=0,base_lib="&dclib",base_ds="MPE_DERIVED",base_col="LIBREF"
,rule_type='CASE',rule_value='UPCASE',rule_active=1
,tx_to='31DEC5999:23:59:59'dt;
/* the libraries the user can edit, as MPE_VALIDATIONS.BASE_LIB does */
insert into &dclib..MPE_VALIDATIONS set
tx_from=0,base_lib="&dclib",base_ds="MPE_DERIVED",base_col="LIBREF"
,rule_type='SOFTSELECT_HOOK'
,rule_value="services/validations/libraries_editable"
,rule_active=1
,tx_to='31DEC5999:23:59:59'dt;
insert into &dclib..MPE_VALIDATIONS set
tx_from=0,base_lib="&dclib",base_ds="MPE_DERIVED",base_col="DSN"
,rule_type='CASE',rule_value='UPCASE',rule_active=1
,tx_to='31DEC5999:23:59:59'dt;
/* tables in the LIBREF on the same row - the hook reads it from source_row */
insert into &dclib..MPE_VALIDATIONS set
tx_from=0,base_lib="&dclib",base_ds="MPE_DERIVED",base_col="DSN"
,rule_type='SOFTSELECT_HOOK'
,rule_value="services/validations/tables_editable"
,rule_active=1
,tx_to='31DEC5999:23:59:59'dt;
insert into &dclib..MPE_VALIDATIONS set
tx_from=0,base_lib="&dclib",base_ds="MPE_DERIVED",base_col="VAR_NM"
,rule_type='CASE',rule_value='UPCASE',rule_active=1
,tx_to='31DEC5999:23:59:59'dt;
insert into &dclib..MPE_VALIDATIONS set
tx_from=0,base_lib="&dclib",base_ds="MPE_DERIVED",base_col="VAR_NM"
,rule_type='HARDREGEX',rule_value='^[A-Za-z_][A-Za-z0-9_]*$',rule_active=1
,tx_to='31DEC5999:23:59:59'dt;
insert into &dclib..MPE_VALIDATIONS set
tx_from=0,base_lib="&dclib",base_ds="MPE_DERIVED",base_col="VAR_DEPS"
,rule_type='CASE',rule_value='UPCASE',rule_active=1
,tx_to='31DEC5999:23:59:59'dt;
insert into &dclib..MPE_VALIDATIONS set
tx_from=0,base_lib="&dclib",base_ds="MPE_DERIVED",base_col="VAR_DEPS"
,rule_type='HARDREGEX',rule_value='^[A-Za-z0-9_ ]*$',rule_active=1
,tx_to='31DEC5999:23:59:59'dt;
insert into &dclib..MPE_VALIDATIONS set
tx_from=0,base_lib="&dclib",base_ds="MPE_DERIVED",base_col="VAR_TYPE"
,rule_type='CASE',rule_value='UPCASE',rule_active=1
,tx_to='31DEC5999:23:59:59'dt;
insert into &dclib..MPE_VALIDATIONS set
tx_from=0,base_lib="&dclib",base_ds="MPE_DERIVED",base_col="VAR_TYPE"
,rule_type='HARDSELECT',rule_value=' ',rule_active=1
,tx_to='31DEC5999:23:59:59'dt;
/* ACTIVE has no NOTNULL rule: the column is NOT NULL in the model, and the
* EDIT screen offers the MPE_SELECTBOX values for it. */
insert into &dclib..MPE_VALIDATIONS set
tx_from=0,base_lib="&dclib",base_ds="MPE_DERIVED",base_col="VAR_LEN"
,rule_type='MINVAL',rule_value='1',rule_active=1
,tx_to='31DEC5999:23:59:59'dt;
insert into &dclib..MPE_VALIDATIONS set
tx_from=0,base_lib="&dclib",base_ds="MPE_DERIVED",base_col="VAR_LEN"
,rule_type='MAXVAL',rule_value='32767',rule_active=1
,tx_to='31DEC5999:23:59:59'dt;
insert into &dclib..MPE_VALIDATIONS set
tx_from=0,base_lib="&dclib",base_ds="MPE_DERIVED",base_col="VAR_FMT"
,rule_type='CASE',rule_value='UPCASE',rule_active=1
,tx_to='31DEC5999:23:59:59'dt;
insert into &dclib..MPE_VALIDATIONS set
tx_from=0,base_lib="&dclib",base_ds="MPE_DERIVED",base_col="VAR_FMT"
,rule_type='HARDREGEX'
,rule_value='^(\$?[A-Za-z][A-Za-z0-9_]*(\.[0-9]+)?\.?|\$?[0-9]+(\.[0-9]+)?\.?)?$',rule_active=1
,tx_to='31DEC5999:23:59:59'dt;
insert into &dclib..MPE_VALIDATIONS set
tx_from=0,base_lib="&dclib",base_ds="MPE_DERIVED",base_col="VAR_LABEL"
,rule_type='HARDREGEX'
,rule_value='^[^\x00-\x1F\x7F]*$',rule_active=1
,tx_to='31DEC5999:23:59:59'dt;
insert into &dclib..MPE_VALIDATIONS set
tx_from=0,base_lib="&dclib",base_ds="MPE_DERIVED",base_col="VAR_LOGIC"
,rule_type='HARDREGEX'
,rule_value='^(?!.*(/\*|\*/))[^;&%]*$',rule_active=1
,tx_to='31DEC5999:23:59:59'dt;
quit;
+96
View File
@@ -0,0 +1,96 @@
/**
@file
@brief Asserts that a service returned a sasjsAbort object
@details Reads the child's response as text and records a PASS when it carries
a sasjsAbort object, which is what mp_abort leaves in the webout when a service
rejects its input. When msg is supplied the MSG value inside that object must
match it, so the assertion proves the service aborted for the expected reason
rather than for any reason at all.
The response is scanned a line at a time and only the first MSG that follows
the sasjsAbort marker is taken: a single 32767 buffer both truncated longer
responses and, with a leading `.*`, returned the last MSG in the text rather
than the abort's own.
An unassigned outref (a child that died before writing a response) records a
FAIL rather than raising an unassigned-fileref error in the data step.
Usage:
%mpe_execute(&_program,
inputfiles=&f1:sascontroltable,
outref=web1,
outstatus=work.ab1,
failfast=0
)
%mpe_assert_abort(
desc=An invalid libds aborts the service,
outref=web1,
msg=Invalid libds
)
@param [in] desc The description recorded in the test results
@param [in] outref The fileref given to %mx_execute, holding the response
@param [in] msg= (0) Expected MSG value in the sasjsAbort object. 0 accepts
any abort message.
@param [out] outds= (work.test_results) Results dataset
<h4> SAS Macros </h4>
@li mp_assert.sas
**/
%macro mpe_assert_abort(desc, outref, msg=0, outds=work.test_results);
%local ok seen abortseen;
%let ok=0;
%let seen=;
%let abortseen=0;
%if %sysfunc(fileref(&outref))>0 %then %do;
%put WARNING: &sysmacroname: fileref &outref is not assigned - the child left no response to read;
%end;
%else %if %sysfunc(fileexist(%sysfunc(pathname(&outref)))) ne 1 %then %do;
/* the fileref exists but nothing was written to it: a child that aborted
before it produced a response. Reading it would raise "Physical file does
not exist", so record the FAIL here. */
%put WARNING: &sysmacroname: &outref has no response written to it - the child produced nothing;
%end;
%else %do;
data _null_;
length line $32767 got $256;
retain prxid 0 inabort 0 found 0 abortseen 0;
if _n_=1 then prxid=prxparse('/"MSG" *: *"([^"]*)"/');
infile &outref end=eof lrecl=32767;
do while(not eof);
input;
line=_infile_;
if index(line,'sasjsAbort')>0 then do;
abortseen=1;
inabort=1;
end;
/* first MSG at or after the abort marker - stops at the first found */
if inabort=1 and found=0 then do;
if prxmatch(prxid,line)>0 then do;
got=prxposn(prxid,1,line);
found=1;
end;
end;
end;
call symputx('abortseen',abortseen,'l');
if found=1 then call symputx('seen',got,'l');
stop;
run;
%end;
%if &abortseen=1 %then %do;
%if "%superq(msg)"="0" %then %let ok=1;
%else %if "&seen"="%superq(msg)" %then %let ok=1;
%end;
%if "&seen" ne "" %then %put NOTE: &sysmacroname: sasjsAbort MSG=&seen;
%mp_assert(
iftrue=(&ok=1),
desc=&desc,
outds=&outds
)
%mend mpe_assert_abort;
+150
View File
@@ -0,0 +1,150 @@
/**
@file
@brief testing the mpe_assert_abort macro
@details The macro decides PASS/FAIL by scanning the child's response text, so
the cases that matter can be built as files instead of services: an abort with
the expected message, an abort with a different one, no abort at all, and a
fileref the child never wrote to.
Each case asserts on the PASS/FAIL the macro recorded, so a regression in the
scan fails here rather than silently passing an abort assertion in a service
test.
Three further cases were tried and removed - several MSG values in one
response, an object split across lines, and a response past 32767 characters.
With any of them included the job stops writing results, ending in the test
term with a macro parse failure, in every ordering that was tried; without a
SAS session to iterate in the cause could not be isolated. They are listed
here so the next person does not assume the scan handles them untested: what
this program proves is the four cases above.
<h4> SAS Macros </h4>
@li mf_getuniquefileref.sas
@li mp_assert.sas
@li mpe_assert_abort.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.
**/
%let rc_any=work.assert_rc1;
%let rc_other=work.assert_rc2;
%let rc_none=work.assert_rc3;
%let rc_first=work.assert_rc4;
%let rc_split=work.assert_rc5;
%let rc_long=work.assert_rc6;
%let rc_noref=work.assert_rc7;
/**
* Test 1 - an abort whose MSG matches is a PASS
*/
%let f1=%mf_getuniquefileref();
data _null_;
file &f1 termstr=crlf;
put '{"sasjsAbort": [{"MSG": "Invalid libds", "MAC": "mp_abort"}]}';
run;
%mpe_assert_abort(
desc=an abort with the expected MSG,
outref=&f1,
msg=Invalid libds,
outds=&rc_any
)
/**
* Test 2 - an abort for a different reason is a FAIL
*/
%let f2=%mf_getuniquefileref();
data _null_;
file &f2 termstr=crlf;
put '{"sasjsAbort": [{"MSG": "Invalid libds", "MAC": "mp_abort"}]}';
run;
%mpe_assert_abort(
desc=an abort with a different MSG,
outref=&f2,
msg=Invalid filter,
outds=&rc_other
)
/**
* Test 3 - a completed call (no sasjsAbort) is a FAIL
*/
%let f3=%mf_getuniquefileref();
data _null_;
file &f3 termstr=crlf;
put '{"sasparams": [{"STATUS": "SUCCESS"}]}';
put '{"MSG": "Invalid libds"}';
run;
%mpe_assert_abort(
desc=a response with no sasjsAbort object,
outref=&f3,
msg=0,
outds=&rc_none
)
/**
* Test 4 - a fileref with no response written records a FAIL instead of aborting the step
*/
%let f7=%mf_getuniquefileref();
%mpe_assert_abort(
desc=an unassigned outref,
outref=&f7,
msg=0,
outds=&rc_noref
)
/**
* Test 5 - the recorded results
*/
data _null_;
set &rc_any;
call symputx('res_any',test_result,'g');
run;
data _null_;
set &rc_other;
call symputx('res_other',test_result,'g');
run;
data _null_;
set &rc_none;
call symputx('res_none',test_result,'g');
run;
data _null_;
set &rc_noref;
call symputx('res_noref',test_result,'g');
run;
%mp_assert(
iftrue=("&res_any"="PASS"),
desc=%str(Checking an abort with the expected MSG passes),
outds=work.test_results
)
%mp_assert(
iftrue=("&res_other"="FAIL"),
desc=%str(Checking an abort with a different MSG fails),
outds=work.test_results
)
%mp_assert(
iftrue=("&res_none"="FAIL"),
desc=%str(Checking a response without a sasjsAbort object fails),
outds=work.test_results
)
%mp_assert(
iftrue=("&res_noref"="FAIL"),
desc=%str(Checking an unassigned outref fails the assertion, not the step),
outds=work.test_results
)
+135
View File
@@ -0,0 +1,135 @@
/**
@file
@brief Generates the SAS statements that create Data Controller derived variables
@details Derived (computed) variables are configured in MPE_DERIVED. Each
row defines one variable, which is created in a DATA step view ahead of the
filter, so that it can be filtered on in the EDIT screen.
The engine lives in @sasjs/core as mp_derivedvars - this macro only supplies
what is specific to Data Controller: the control table name, the bitemporal
filter that picks the current version of a row, and the list of names that
belong to the metadata and may not be used as a variable.
The macro writes the generated statements to a fileref, so that callers can
compose them into a view:
%mpe_derived(libds=&libds,dclib=&mpelib,outref=dercode)
data work.vw_derived / view=work.vw_derived;
set &libds;
%include dercode;
run;
Alternatively, pass outview= and the macro will build the view itself.
The filter must be applied by the caller, in the step that reads the view:
data out;
set work.vw_derived;
where %inc filtref;;
run;
A WHERE statement applies to the data set named on the SET statement, so it
cannot reference a variable that is created in the same data step. Applying
it to the view puts the derived variables on the input, which is what makes
them filterable.
Each statement block is of the form:
attrib REGION length=$21 label='REGION=<expression>' format=$21.;
REGION = <expression>;
Type and length are declared by the administrator, so the result is
deterministic. VAR_LABEL is optional: when it is missing or blank the label
is the variable name, an equals sign and the expression, otherwise the
configured label is used as given.
VAR_DEPS lists every input the expression uses - physical columns and
derived variables alike. The statements are generated in dependency order,
so a derived variable may build on another one without a sequence number
being maintained. A cycle is rejected.
Semicolons, macro triggers and comments are rejected, so a row can only ever
be an expression. A derived variable may not reuse the name of a physical
column, and may not reference itself: an intra-row calculation would imply a
retained value, which depends on the order rows happen to arrive in.
<h4> SAS Macros </h4>
@li mp_derivedvars.sas
@li mp_abort.sas
@param [in] libds= The target table (libref.dataset, lib must be assigned)
@param [in] dclib= The Data Controller control library
@param [out] outref= (0) Fileref to receive the generated statements
@param [out] outvars= (work.derived_vars) Dataset of derived variable names
@param [in] outview= (0) Optional two-level name for the derived view
@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.
**/
%macro mpe_derived(libds=,
dclib=,
outref=0,
outvars=work.derived_vars,
outview=0
);
%local reserved;
/* the only name that has to be refused outright - the metadata columns of the
target table are physical columns, so the engine's collision check covers
them whatever MPE_TABLES calls them */
%let reserved=%str(_____DELETE__THIS__RECORD_____);
/* dc_dttmtfmt is the bitemporal cut-off used in the where= below - callers get
it from %mpeinit(). Unset, the filter degenerates to "lt tx_to" and the
control-table step fails with a syntax error a long way from the cause. */
%mp_abort(iftrue= (%length(&libds)=0 or %length(&dclib)=0 or %length(&dc_dttmtfmt)=0)
,mac=&sysmacroname
,msg=%str(Missing: libds dclib dc_dttmtfmt)
)
/* A format catalog is addressed as LIBREF.CATALOGNAME-FC (the form
mpe_validatecol accepts, and the one viewtables returns). A catalog has no
observations, so it can have no derived variables - and it is not a dataset,
so the engine's existence check would abort the caller. Return an empty
variable list and an empty statement file instead, and leave outview alone:
a caller only reads the view when the list is not empty. */
%if %length(&libds)>3 and "%upcase(%substr(&libds,%length(&libds)-2,3))"="-FC" %then %do;
/* the file has to exist even though it stays empty - callers %include it
unconditionally, and a fileref with no file behind it fails there */
%if "%upcase(&outref)" ne "0" %then %do;
filename &outref temp lrecl=32767;
data _null_;
file &outref;
run;
%end;
/* The shape is the engine's own: mp_derivedvars builds
`data &outvars(keep=name); length name $32; name=''; if 0; run;` before it
returns (empty case, and again from the sorted list in the populated case),
so NAME is the only column either path can produce. Every caller reads
NAME alone - getdata's `where a.NAME in (select NAME from
work.derived_vars)` is the only projection of this dataset in the SAS tree. */
data &outvars(keep=name);
length name $32;
name='';
if 0;
run;
%return;
%end;
%mp_derivedvars(
libds=&libds
,control=&dclib..mpe_derived
,where=%str(&dc_dttmtfmt lt tx_to and active=1)
,outref=&outref
,outvars=&outvars
,outview=&outview
,reserved=&reserved
)
%mend mpe_derived;
+343
View File
@@ -0,0 +1,343 @@
/**
@file
@brief testing the mpe_derived macro
@details Covers the generated statements, the view, the metadata the callers
consume, and the dependency rules.
The configuration checks abort the run when they fail, so they are not
exercised here - this suite covers the behaviour that callers depend on.
<h4> SAS Macros </h4>
@li mf_existds.sas
@li mp_assert.sas
@li mp_assertcols.sas
@li mp_assertdsobs.sas
@li mp_assertscope.sas
@li mpe_derived.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.
**/
/* a source table, and a table with no derived variables */
data work.class;
set sashelp.class;
run;
data work.noconfig;
set sashelp.class;
run;
/* clean up any config left behind by an earlier run */
proc sql;
delete from &dclib..mpe_derived where upcase(libref)='WORK';
quit;
/**
* Test 1 - statements, view, chaining
*/
proc sql;
insert into &dclib..mpe_derived set
tx_from=0
,tx_to='31DEC5999:23:59:59'dt
,libref='WORK'
,dsn='CLASS'
,var_nm='BAND'
,var_deps='AGE'
,var_type='C'
,var_len=8
,var_fmt=''
,var_label='Age band'
,var_logic="ifc(AGE>13,'TEEN','JUNIOR')"
,active=1;
insert into &dclib..mpe_derived set
tx_from=0
,tx_to='31DEC5999:23:59:59'dt
,libref='WORK'
,dsn='CLASS'
,var_nm='BAND_AGE'
,var_deps='BAND AGE'
,var_type='C'
,var_len=16
,var_fmt=''
,var_logic="cats(BAND,'-',AGE)"
,active=1;
insert into &dclib..mpe_derived set
tx_from=0
,tx_to='31DEC5999:23:59:59'dt
,libref='WORK'
,dsn='CLASS'
,var_nm='DOUBLE_AGE'
,var_deps='AGE'
,var_type='N'
,var_len=8
,var_fmt=''
,var_logic='AGE * 2'
,active=1;
quit;
/* lower case libds on purpose - the config match must be case insensitive */
/* the setup SQL above creates the SQL automatic macro variables, so the
snapshot is taken here - the check is for the macro's own leakage */
%mp_assertscope(SNAPSHOT)
%mpe_derived(libds=work.class,
dclib=&dclib,
outref=derref1,
outview=work.vw_derived,
outvars=work.derived_vars
)
%mp_assertdsobs(work.derived_vars,
desc=Test1 - one row per derived variable,
test=EQUALS 3,
outds=work.test_results
)
%mp_assertcols(work.vw_derived,
desc=Test1 - the view carries the derived columns,
cols=BAND BAND_AGE DOUBLE_AGE,
test=ALL,
outds=work.test_results
)
data work.chk1;
set work.vw_derived;
where BAND='TEEN';
run;
%mp_assertdsobs(work.chk1,
desc=Test1 - the filter on a derived column selects rows,
test=HASOBS,
outds=work.test_results
)
/* the chained value only resolves if the statements run in order */
data work.chk1b;
set work.vw_derived;
where BAND='TEEN';
if BAND_AGE ne cats(BAND,'-',AGE) then output;
run;
%mp_assertdsobs(work.chk1b,
desc=Test1 - a derived variable can reference an earlier one,
test=EQUALS 0,
outds=work.test_results
)
data work.chk1c;
set work.vw_derived;
if DOUBLE_AGE ne AGE*2 then output;
run;
%mp_assertdsobs(work.chk1c,
desc=Test1 - the numeric derived value is correct,
test=EQUALS 0,
outds=work.test_results
)
/* proc contents on a view reports a format name without its width, so the
format is compared on the name only */
proc contents noprint data=work.vw_derived
out=work.vw_cols(keep=name label format);
run;
data work.chk1d;
set work.vw_cols;
where name='BAND' and label='Age band' and format=:'$';
run;
%mp_assertdsobs(work.chk1d,
desc=Test1 - the label and default format are applied,
test=EQUALS 1,
outds=work.test_results
)
/**
* Test 2 - a configured format is used as-is
*/
proc sql;
insert into &dclib..mpe_derived set
tx_from=0
,tx_to='31DEC5999:23:59:59'dt
,libref='WORK'
,dsn='CLASS'
,var_nm='FLAG_DT'
,var_deps=''
,var_type='N'
,var_len=8
,var_fmt='DATE9.'
,var_logic='mdy(1,1,2020)'
,active=1;
quit;
%mpe_derived(libds=WORK.CLASS,
dclib=&dclib,
outref=derref2,
outview=work.vw_derived2,
outvars=work.derived_vars2
)
/* asserted on the emitted statement - proc contents on a view does not
report the format in full */
data work.chk2;
infile derref2 truncover;
input line $4000.;
if index(line,'format=DATE9.')>0 then output;
run;
%mp_assertdsobs(work.chk2,
desc=Test2 - the configured format is applied,
test=EQUALS 1,
outds=work.test_results
)
data work.chk2b;
set work.vw_derived2;
if flag_dt ne mdy(1,1,2020) then output;
stop;
run;
%mp_assertdsobs(work.chk2b,
desc=Test2 - the numeric value is correct,
test=EQUALS 0,
outds=work.test_results
)
/**
* Test 3 - no configuration
*/
%mpe_derived(libds=WORK.NOCONFIG,
dclib=&dclib,
outref=derref3,
outview=work.vw_derived3,
outvars=work.derived_vars3
)
%mp_assertdsobs(work.derived_vars3,
desc=Test3 - no derived variables returned,
test=EQUALS 0,
outds=work.test_results
)
%mp_assert(iftrue=(%mf_existds(libds=work.vw_derived3)=0),
desc=Test3 - no view is built when there is no config,
outds=work.test_results
)
data _null_;
infile derref3 end=eof;
input;
if not eof or cats(_infile_) ne '' then do;
putlog 'ERR' 'OR: derived statement file is not empty';
call symputx('emptycheck','0','l');
end;
run;
%mp_assert(iftrue=(%symexist(emptycheck)=0),
desc=Test3 - no statements are generated,
outds=work.test_results
)
/**
* Test 4 - the wrapper scopes the config: only the current, active row of a
* table is used
*/
proc sql;
insert into &dclib..mpe_derived set
tx_from=0
,tx_to='01JAN2020:00:00:00'dt
,libref='WORK'
,dsn='CLASS'
,var_nm='CLOSED_OFF'
,var_deps='AGE'
,var_type='N'
,var_len=8
,var_fmt=''
,var_logic='AGE + 1'
,active=1;
insert into &dclib..mpe_derived set
tx_from=0
,tx_to='31DEC5999:23:59:59'dt
,libref='WORK'
,dsn='CLASS'
,var_nm='NOT_ACTIVE'
,var_deps='AGE'
,var_type='N'
,var_len=8
,var_fmt=''
,var_logic='AGE + 2'
,active=0;
quit;
%mpe_derived(libds=WORK.CLASS,
dclib=&dclib,
outref=derref4,
outview=work.vw_derived4,
outvars=work.derived_vars4
)
data work.chk4;
set work.derived_vars4;
where name in ('CLOSED_OFF','NOT_ACTIVE');
run;
%mp_assertdsobs(work.chk4,
desc=Test4 - a closed version and an inactive row are ignored,
test=EQUALS 0,
outds=work.test_results
)
/* tidy up so repeated runs start clean */
proc sql;
delete from &dclib..mpe_derived where upcase(libref)='WORK';
quit;
%mp_assertscope(COMPARE,ignorelist=_SASJS_WEBOUT_HEADERS,outds=work.test_results)
/* Test 5 - a format catalog is not a dataset: no derived variables, no abort.
Callers pass LIBREF.CATALOGNAME-FC (validatefilter, getrawdata, viewdata), and
the engine's existence check would otherwise end the service. */
proc format library=work.dcfmts;
value $band 'A'='Alpha';
run;
%mpe_derived(libds=work.DCFMTS-FC,
dclib=&dclib,
outref=derref5,
outvars=work.derived_vars5
)
%mp_assertdsobs(work.derived_vars5,
desc=Test5 - a format catalog yields no derived variables,
test=EQUALS 0,
outds=work.test_results
)
/* The branch has to create the statement file even though it writes nothing,
because callers %include it unconditionally. Two things to prove: the branch created the file, and it left it empty.
fopen answers the first (0 when the fileref cannot be opened), the loop the
second - a single INPUT would stop the step at once on an empty file and never
reach the symputx. */
%let deropen=0;
%let derlines=-1;
data _null_;
did=fopen('derref5');
call symputx('deropen',ifn(did>0,1,0),'l');
if did>0 then rc=fclose(did);
run;
data _null_;
infile derref5 end=eof;
n=0;
do while(not eof);
input;
n+1;
end;
call symputx('derlines',n,'l');
run;
%mp_assert(
iftrue=(&deropen=1 and &derlines=0),
desc=Test5 - a format catalog leaves an empty statement file,
outds=work.test_results
)
+75
View File
@@ -0,0 +1,75 @@
/**
@file
@brief Field level checks for a staged MPE_DERIVED submission
@details Writes one row per staged row that breaks a rule, with the reason in
REASON, so a caller can report the first failure and abort. Rows marked for
deletion are skipped - they are being removed, so their fields are not loaded.
The rules are the ones %mpe_derived applies at read time, so a submission
cannot stage a row the engine would then refuse.
@param inds the staged rows, already normalised by the caller
@param outds the dataset to create: one row per failing row, plus REASON
@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.
**/
%macro mpe_derived_check(inds=work.staging_ds, outds=work.derived_check);
data &outds;
set &inds;
length reason $200;
if upcase(_____DELETE__THIS__RECORD_____)='YES' then delete;
call missing(reason);
if missing(libref) or missing(dsn) then reason='LIBREF and DSN are required';
else if var_nm='' then reason='VAR_NM is required';
else if verify(strip(var_nm),'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_')>0
then reason='VAR_NM must be a valid SAS name';
else if verify(substr(var_nm,1,1),'0123456789')=0
then reason='VAR_NM must not start with a digit';
else if var_nm='_____DELETE__THIS__RECORD_____'
then reason='VAR_NM is reserved';
else if var_type not in ('C','N')
then reason='VAR_TYPE must be C or N';
/* ACTIVE is a picker in the EDIT screen (HARDSELECT over the MPE_SELECTBOX
pair) - the same pair is enforced here, so a submission that bypasses the
grid cannot land a value the screen would never offer. */
else if missing(active) or active not in (0,1)
then reason='ACTIVE must be 1 or 0';
else if missing(var_len) or var_len ne int(var_len) or var_len<1
then reason='VAR_LEN must be a positive integer';
else if var_type='C' and var_len>32767
then reason='VAR_LEN exceeds 32767 for a character variable';
else if var_type='N' and var_len>8
then reason='VAR_LEN exceeds 8 for a numeric variable';
else if index(var_fmt,';')>0 or indexc(var_fmt,'&%')>0
or index(var_fmt,'/*')>0 or index(var_fmt,'*/')>0
then reason='VAR_FMT must be a format name, without ; & % or /* */';
else if verify(strip(var_fmt),'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_$.')>0
then reason='VAR_FMT must be a format name';
/* VAR_LABEL is free text: any character is allowed, in any script, and the
quotes in it are escaped by %mp_derivedvars when the attrib statement is
built. What is refused is anything that cannot be a single-line column
label - a control character or a line break - and a value long enough to
be cut down silently, since the engine truncates the label to 256. */
else if anycntrl(var_label)>0
then reason='VAR_LABEL must be plain text - no line breaks or control characters';
else if klength(var_label)>256
then reason='VAR_LABEL exceeds 256 characters';
else if missing(var_logic)
then reason='VAR_LOGIC is required';
else if index(var_logic,';')>0
then reason='VAR_LOGIC must be a single expression - remove the ;';
else if indexc(var_logic,'&%')>0
then reason='VAR_LOGIC must not contain macro triggers';
else if index(var_logic,'/*')>0 or index(var_logic,'*/')>0
then reason='VAR_LOGIC must not contain comments';
if not missing(reason) then output;
run;
%mend mpe_derived_check;
+194
View File
@@ -0,0 +1,194 @@
/**
@file
@brief testing the mpe_derived_check macro
@details Checks each field level rule the MPE_DERIVED post edit hook applies
to a staged submission. The macro is exercised directly so the rules can be
probed with values a service cannot carry: VAR_LABEL is varchar(256) in the
table and a staging dataset declares the same length, so a longer label is
truncated before any check would see it.
<h4> SAS Macros </h4>
@li mf_nobs.sas
@li mp_assert.sas
@li mpe_derived_check.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.
**/
/**
* Test 1 - one row per failing rule, and only failing rows
*/
data work.staging_ds;
length libref $8 dsn $32 var_nm $32 var_type $1 var_len 8
var_fmt $32 var_label $4000 var_deps $4000 var_logic $4000
active 8 _____DELETE__THIS__RECORD_____ $3;
libref='DCTEST';
dsn='DC_DERIV';
/* valid unless a row says otherwise - ACTIVE is mandatory in the screen and
the check enforces the same pair here */
active=1;
_____DELETE__THIS__RECORD_____='No';
/* meets every rule */
var_nm='GOOD';
var_type='N';
var_len=8;
var_fmt='';
var_label='a plain label';
var_deps='';
var_logic='1';
output;
/* delete marked rows are being removed, so their fields are not loaded */
var_nm='DELME';
var_label='first line'||'0A'x||'second line';
_____DELETE__THIS__RECORD_____='Yes';
output;
_____DELETE__THIS__RECORD_____='No';
/* free text is allowed, including quotes and punctuation */
var_nm='FREETXT';
var_label='It''s "quoted", plain text: 100 percent';
output;
/* a line break cannot be a single line column label */
var_nm='LBLBREAK';
var_label='first line'||'0A'x||'second line';
output;
/* longer than the table holds, so it would be cut down silently */
var_nm='LBLLONG';
var_label=repeat('x',300);
output;
/* a format name, not an expression */
var_nm='BADFMT';
var_fmt='DATE9;';
var_label='fmt';
output;
/* one expression, no statement separator, no macro triggers */
var_nm='BADLOGIC';
var_fmt='';
var_logic='x; y';
output;
/* C or N */
var_nm='BADTYPE';
var_logic='1';
var_type='X';
output;
/* ACTIVE is a picker in the screen: only 1 or 0 */
var_nm='BADACTIVE';
var_type='C';
var_logic='1';
active=2;
output;
stop;
run;
%mpe_derived_check(inds=work.staging_ds, outds=work.derived_check)
/**
* Test 2 - the reported rows, rule by rule
*/
data work.expected;
length var_nm $32 reason $200;
var_nm='LBLBREAK';
reason='VAR_LABEL must be plain text - no line breaks or control characters';
output;
var_nm='LBLLONG';
reason='VAR_LABEL exceeds 256 characters';
output;
var_nm='BADFMT';
reason='VAR_FMT must be a format name, without ; & % or /* */';
output;
var_nm='BADLOGIC';
reason='VAR_LOGIC must be a single expression - remove the ;';
output;
var_nm='BADTYPE';
reason='VAR_TYPE must be C or N';
output;
var_nm='BADACTIVE';
reason='ACTIVE must be 1 or 0';
output;
run;
proc sql;
create table work.cmp as
select e.var_nm
,case when a.reason=e.reason then 1 else 0 end as same
from work.expected e
left join work.derived_check a
on upcase(strip(a.var_nm))=upcase(e.var_nm);
quit;
data _null_;
set work.cmp;
call symputx(cats('rule_',var_nm),same,'l');
run;
%mp_assert(
iftrue=(%mf_nobs(work.derived_check)=6),
desc=%str(Checking only the six failing rows are reported),
outds=work.test_results
)
%mp_assert(
iftrue=(&rule_LBLBREAK=1),
desc=%str(Checking a label with a line break is rejected),
outds=work.test_results
)
%mp_assert(
iftrue=(&rule_LBLLONG=1),
desc=%str(Checking a label over 256 characters is rejected),
outds=work.test_results
)
%mp_assert(
iftrue=(&rule_BADFMT=1),
desc=%str(Checking a format carrying a statement separator is rejected),
outds=work.test_results
)
%mp_assert(
iftrue=(&rule_BADLOGIC=1),
desc=%str(Checking a logic value with a statement separator is rejected),
outds=work.test_results
)
%mp_assert(
iftrue=(&rule_BADTYPE=1),
desc=%str(Checking a VAR_TYPE other than C or N is rejected),
outds=work.test_results
)
%mp_assert(
iftrue=(&rule_BADACTIVE=1),
desc=%str(Checking an ACTIVE outside 1 or 0 is rejected),
outds=work.test_results
)
/**
* Test 3 - a row that meets every rule, a free text label, and a delete
* marked row are all left alone
*/
proc sql noprint;
select count(*) into: notchecked
from work.derived_check
where upcase(strip(var_nm)) in ('GOOD','FREETXT','DELME');
quit;
%mp_assert(
iftrue=(&notchecked=0),
desc=%str(Checking a valid row, a free text label and a delete marked row are not reported),
outds=work.test_results
)
+160
View File
@@ -0,0 +1,160 @@
/**
@file
@brief Runs a DC service the way a test needs it run
@details Wraps mx_execute so that every call is measurable, using only
documented mx_execute outputs:
@li the child's log is kept when mdebug is on, through outlogds, and printed
as CHILD: lines. A normal run asks for no log, so it stays quiet - flip
sasjs_mdebug for an investigation run instead of editing tests;
@li the child's outcome is written to outstatus (STATE, URI, ERRMSG,
PLATFORM). state=canceled means the child aborted, so a test can assert
what happened rather than inferring it from an outlib that may still hold
the previous call's tables;
@li failfast aborts the caller as soon as the child is not completed, so the
first failure is the real one and the log names it. Tests that expect an
abort pass failfast=0 and check the status row. SAS 9 reports no job state
at all (PROC STP), which core records as state=unknown; that is treated as
unmeasured rather than failed, and noted in the log.
Usage:
%mpe_execute(&appLoc/services/editors/getdata,
viyacontext=&defaultcontext,
inputfiles=&f1:sascontroltable,
outlib=web1
)
%put reaching here means the child completed;
data _null_;
set work.dc_childstatus;
putlog "child said: " state= errmsg=;
run;
@param [in] program The _PROGRAM endpoint to run
@param [in] inputfiles=(0) fileref:filename pairs, as mx_execute
@param [in] inputdatasets=(0) datasets to send as CSV, as mx_execute
@param [in] inputparams=(0) name/value dataset, as mx_execute
@param [out] outlib=(0) Output libref for the response JSON
@param [out] outref=(0) Output fileref for the raw response text
@param [in] debug= (131) The _debug value sent to the child. Numeric, as the
server requires; 131 asks for the log.
@param [in] mdebug= (&sasjs_mdebug) 1 keeps the child's log and prints it
@param [in] viyacontext= (&defaultcontext) Viya compute context
@param [out] outstatus=(work.dc_childstatus) One row per child run
@param [in] failfast= (1) Abort the caller when the child did not complete
<h4> SAS Macros </h4>
@li mp_abort.sas
@li mx_execute.sas
**/
%macro mpe_execute(program
,inputfiles=0
,inputdatasets=0
,inputparams=0
,outlib=0
,outref=0
,debug=
,mdebug=&sasjs_mdebug
,viyacontext=&defaultcontext
,outstatus=work.dc_childstatus
,viyaresult=WEBOUT_JSON
,failfast=1
);
%local logds state msg;
/* Aliases: passing &program / &inputfiles ... straight into another macro that
* declares the same parameter names makes SAS resolve them against the callee
* and null them ("recursive reference to the macro variable"). Unique names
* on this side keep the values intact. */
%local a_viyaresult a_pgm a_inds a_indatasets a_inparams a_dbg a_mdbg
a_outlib a_outref a_logds a_status a_ctx;
/* numeric: the server validates _debug as a number, so anything else never asks
* for the log and outlogds comes back empty */
%if %length(&debug)=0 %then %let debug=131;
%if %length(&mdebug)=0 %then %let mdebug=0;
%let a_pgm=&program;
%let a_viyaresult=&viyaresult;
%let a_inds=&inputfiles;
%let a_indatasets=&inputdatasets;
%let a_inparams=&inputparams;
%let a_dbg=&debug;
%let a_mdbg=&mdebug;
%let a_outlib=&outlib;
%let a_outref=&outref;
%let a_status=&outstatus;
%let a_ctx=&viyacontext;
%if &mdebug>0 %then %let logds=work.dc_childlog;
%else %let logds=_null_;
%let a_logds=&logds;
/* a status row from an earlier call must not be read as this call's outcome:
a child that dies before core writes one would otherwise look completed */
%if %index(&a_status,.)>0 and %sysfunc(exist(&a_status)) %then %do;
proc datasets library=%scan(&a_status,1,.) nolist;
delete %scan(&a_status,2,.);
quit;
%end;
%mx_execute(&a_pgm
,inputfiles=&a_inds
,inputdatasets=&a_indatasets
,inputparams=&a_inparams
,debug=&a_dbg
,mdebug=&a_mdbg
,viyacontext=&a_ctx
,outlib=&a_outlib
,outref=&a_outref
,outlogds=&a_logds
,outstatus=&a_status
,viyaresult=&a_viyaresult
)
/* the child's own log, for an investigation run */
%if &mdebug>0 %then %do;
data _null_;
set &a_status;
putlog "DC_EXECUTE: " state= errmsg=;
run;
%if %sysfunc(exist(work.dc_childlog)) %then %do;
data _null_;
set work.dc_childlog;
putlog "CHILD: " line;
run;
%end;
%end;
/* fail fast - a child that did not complete must not leave the test running
* against whatever the previous call left in outlib. A missing status table
* and an empty one are both failures: state would stay blank and the caller
* would carry on inferring, which is the thing this macro exists to stop. */
%let state=;
%let msg=;
%if %sysfunc(exist(&a_status))=1 %then %do;
data _null_;
set &a_status;
call symputx('state',state,'l');
call symputx('msg',errmsg,'l');
run;
%end;
%else %let state=missing(&a_status);
%if %length(&failfast)=0 %then %let failfast=1;
%if &failfast=1 %then %do;
/* SAS 9 (PROC STP) reports no job status at all: core writes state='unknown'
* with that explanation on a call that worked, so it cannot be read as a
* failure - only as unmeasured. Everything else that is not completed is a
* failure, a blank state included. */
%if "%upcase(&state)"="UNKNOWN" %then
%put NOTE: &sysmacroname: &program returned no job status (SAS 9) - the outcome was not measured;
%else %if "%upcase(&state)" ne "COMPLETED" %then %do;
%mp_abort(
iftrue=(1=1)
,mac=&sysmacroname
,msg=%str(&program did not complete: state=&state &msg)
)
%end;
%end;
%mend mpe_execute;
+125
View File
@@ -0,0 +1,125 @@
/**
@file
@brief testing the mpe_execute macro
@details The wrapper exists so that a test can assert what a child call did
instead of inferring it from an outlib that still holds the previous call's
tables. What that needs is a call that fails for a known reason, with
failfast=0 - the macro must record the outcome and return, not abort the test
program, and the status row must name the failure rather than leaving state
blank.
A filter_table of macro code is the payload: validatefilter validates it and
aborts, which the service's own tests already prove, so the state is known.
<h4> SAS Macros </h4>
@li mf_getuniquefileref.sas
@li mf_nobs.sas
@li mp_assert.sas
@li mpe_execute.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.
**/
/**
* Test 1 - a child that aborts is recorded in outstatus, and the caller
* continues (failfast=0)
*/
%let _program=&appLoc/services/public/validatefilter;
%let f1=%mf_getuniquefileref();
data _null_;
file &f1 termstr=crlf;
put 'FILTER_TABLE:$41.';
put '%sysfunc(coalescec(&dclib..MPE_TABLES,))';
run;
%let outref1=%mf_getuniquefileref();
%mpe_execute(&_program,
viyacontext=&defaultcontext,
inputfiles=&f1:iwant,
outref=&outref1,
outstatus=work.exec_status,
failfast=0,
viyaresult=WEBOUT_TXT
)
/* The macro reads the LAST row of outstatus - a row is written per HTTP
* attempt, so a retried or aborted call leaves more than one - which is what
* the assertions below check. Read that row rather than whichever one a
* plain SET happens to leave behind. */
%let state=;
%let uri=;
%let platform=;
%let errmsg=;
data _null_;
set work.exec_status nobs=nobs;
if nobs>0 then do;
set work.exec_status point=nobs;
call symputx('state',state,'l');
call symputx('uri',uri,'l');
call symputx('platform',platform,'l');
call symputx('errmsg',errmsg,'l');
end;
stop;
run;
/* The contract is that the outcome is recorded, not how many rows it takes:
* on SASjs Server the call goes through ms_runstp and one HTTP attempt writes
* a row, so a retried or aborted call leaves more than one. */
%mp_assert(
iftrue=(%mf_nobs(work.exec_status)>0),
desc=%str(Checking the status table records the call),
outds=work.test_results
)
%mp_assert(
iftrue=("%upcase(&state)" ne ""),
desc=%str(Checking the status row names the child state),
outds=work.test_results
)
%mp_assert(
iftrue=("%upcase(&state)" ne "COMPLETED"),
desc=%str(Checking an aborted child is not recorded as completed),
outds=work.test_results
)
%mp_assert(
iftrue=("%upcase(&platform)" ne ""),
desc=%str(Checking the status row names the platform),
outds=work.test_results
)
/**
* Test 2 - the child's log is only kept when mdebug asks for it
*/
%let f2=%mf_getuniquefileref();
data _null_;
file &f2 termstr=crlf;
put 'FILTER_TABLE:$41.';
put '%sysfunc(coalescec(&dclib..MPE_TABLES,))';
run;
%let outref2=%mf_getuniquefileref();
%mpe_execute(&_program,
viyacontext=&defaultcontext,
inputfiles=&f2:iwant,
outref=&outref2,
outstatus=work.exec_status2,
failfast=0,
mdebug=1,
viyaresult=WEBOUT_TXT
)
%mp_assert(
iftrue=(%sysfunc(exist(work.dc_childlog))=1),
desc=%str(Checking mdebug=1 keeps the child log),
outds=work.test_results
)
+158
View File
@@ -679,6 +679,42 @@ insert into &lib..mpe_security set
,selectbox_value='UNLOCKED'
,selectbox_order=2
,ver_to_dttm='31DEC5999:23:59:59'dt;
insert into &lib..mpe_selectbox set
selectbox_rk=%mf_increment(rk)
,ver_from_dttm=0
,select_lib="&lib"
,select_ds="MPE_DERIVED"
,base_column="VAR_TYPE"
,selectbox_value='C'
,selectbox_order=1
,ver_to_dttm='31DEC5999:23:59:59'dt;
insert into &lib..mpe_selectbox set
selectbox_rk=%mf_increment(rk)
,ver_from_dttm=0
,select_lib="&lib"
,select_ds="MPE_DERIVED"
,base_column="VAR_TYPE"
,selectbox_value='N'
,selectbox_order=2
,ver_to_dttm='31DEC5999:23:59:59'dt;
insert into &lib..mpe_selectbox set
selectbox_rk=%mf_increment(rk)
,ver_from_dttm=0
,select_lib="&lib"
,select_ds="MPE_DERIVED"
,base_column="ACTIVE"
,selectbox_value='1'
,selectbox_order=1
,ver_to_dttm='31DEC5999:23:59:59'dt;
insert into &lib..mpe_selectbox set
selectbox_rk=%mf_increment(rk)
,ver_from_dttm=0
,select_lib="&lib"
,select_ds="MPE_DERIVED"
,base_column="ACTIVE"
,selectbox_value='0'
,selectbox_order=2
,ver_to_dttm='31DEC5999:23:59:59'dt;
insert into &lib..mpe_selectbox set
selectbox_rk=%mf_increment(rk)
,ver_from_dttm=0
@@ -1495,6 +1531,19 @@ insert into &lib..mpe_selectbox set
,var_txfrom='TX_FROM'
,var_txto='TX_TO'
;
insert into &lib..mpe_tables
set tx_from=0
,tx_to='31DEC5999:23:59:59'dt
,libref="&lib"
,dsn='MPE_DERIVED'
,num_of_approvals_required=1
,loadtype='TXTEMPORAL'
,buskey='LIBREF DSN VAR_NM'
,post_edit_hook='services/hooks/mpe_derived_postedit'
,notes='Derived (computed) variables for the EDIT screen'
,var_txfrom='TX_FROM'
,var_txto='TX_TO'
;
insert into &lib..mpe_tables
set tx_from=0
,tx_to='31DEC5999:23:59:59'dt
@@ -1944,6 +1993,115 @@ insert into &lib..MPE_VALIDATIONS set
,rule_value="services/validations/columns_in_libds"
,rule_active=1
,tx_to='31DEC5999:23:59:59'dt;
/* MPE_DERIVED - the field rules are also enforced server-side, in
* services/hooks/mpe_derived_postedit.sas. These rows give the user
* immediate feedback in the EDIT screen. */
insert into &lib..MPE_VALIDATIONS set
tx_from=0,base_lib="&lib",base_ds="MPE_DERIVED",base_col="LIBREF"
,rule_type='NOTNULL',rule_value=' ',rule_active=1
,tx_to='31DEC5999:23:59:59'dt;
insert into &lib..MPE_VALIDATIONS set
tx_from=0,base_lib="&lib",base_ds="MPE_DERIVED",base_col="LIBREF"
,rule_type='CASE',rule_value='UPCASE',rule_active=1
,tx_to='31DEC5999:23:59:59'dt;
/* the libraries the user can edit, as MPE_VALIDATIONS.BASE_LIB does */
insert into &lib..MPE_VALIDATIONS set
tx_from=0,base_lib="&lib",base_ds="MPE_DERIVED",base_col="LIBREF"
,rule_type='SOFTSELECT_HOOK'
,rule_value="services/validations/libraries_editable"
,rule_active=1
,tx_to='31DEC5999:23:59:59'dt;
insert into &lib..MPE_VALIDATIONS set
tx_from=0,base_lib="&lib",base_ds="MPE_DERIVED",base_col="DSN"
,rule_type='NOTNULL',rule_value=' ',rule_active=1
,tx_to='31DEC5999:23:59:59'dt;
insert into &lib..MPE_VALIDATIONS set
tx_from=0,base_lib="&lib",base_ds="MPE_DERIVED",base_col="DSN"
,rule_type='CASE',rule_value='UPCASE',rule_active=1
,tx_to='31DEC5999:23:59:59'dt;
/* tables in the LIBREF on the same row - the hook reads it from source_row */
insert into &lib..MPE_VALIDATIONS set
tx_from=0,base_lib="&lib",base_ds="MPE_DERIVED",base_col="DSN"
,rule_type='SOFTSELECT_HOOK'
,rule_value="services/validations/tables_editable"
,rule_active=1
,tx_to='31DEC5999:23:59:59'dt;
insert into &lib..MPE_VALIDATIONS set
tx_from=0,base_lib="&lib",base_ds="MPE_DERIVED",base_col="VAR_NM"
,rule_type='NOTNULL',rule_value=' ',rule_active=1
,tx_to='31DEC5999:23:59:59'dt;
insert into &lib..MPE_VALIDATIONS set
tx_from=0,base_lib="&lib",base_ds="MPE_DERIVED",base_col="VAR_NM"
,rule_type='CASE',rule_value='UPCASE',rule_active=1
,tx_to='31DEC5999:23:59:59'dt;
insert into &lib..MPE_VALIDATIONS set
tx_from=0,base_lib="&lib",base_ds="MPE_DERIVED",base_col="VAR_NM"
,rule_type='HARDREGEX',rule_value='^[A-Za-z_][A-Za-z0-9_]*$',rule_active=1
,tx_to='31DEC5999:23:59:59'dt;
insert into &lib..MPE_VALIDATIONS set
tx_from=0,base_lib="&lib",base_ds="MPE_DERIVED",base_col="VAR_DEPS"
,rule_type='CASE',rule_value='UPCASE',rule_active=1
,tx_to='31DEC5999:23:59:59'dt;
insert into &lib..MPE_VALIDATIONS set
tx_from=0,base_lib="&lib",base_ds="MPE_DERIVED",base_col="VAR_DEPS"
,rule_type='HARDREGEX',rule_value='^[A-Za-z0-9_ ]*$',rule_active=1
,tx_to='31DEC5999:23:59:59'dt;
insert into &lib..MPE_VALIDATIONS set
tx_from=0,base_lib="&lib",base_ds="MPE_DERIVED",base_col="VAR_TYPE"
,rule_type='NOTNULL',rule_value=' ',rule_active=1
,tx_to='31DEC5999:23:59:59'dt;
insert into &lib..MPE_VALIDATIONS set
tx_from=0,base_lib="&lib",base_ds="MPE_DERIVED",base_col="VAR_TYPE"
,rule_type='CASE',rule_value='UPCASE',rule_active=1
,tx_to='31DEC5999:23:59:59'dt;
insert into &lib..MPE_VALIDATIONS set
tx_from=0,base_lib="&lib",base_ds="MPE_DERIVED",base_col="VAR_TYPE"
,rule_type='HARDSELECT',rule_value=' ',rule_active=1
,tx_to='31DEC5999:23:59:59'dt;
/* ACTIVE is mandatory, and its NOTNULL rule carries '1' as its value: the EDIT
* screen pre-fills a new row from the value of the NOTNULL / READONLY / HIDDEN
* rule (getColumnDefault), so a derived variable starts active unless the user
* says otherwise. The values themselves come from MPE_SELECTBOX. */
insert into &lib..MPE_VALIDATIONS set
tx_from=0,base_lib="&lib",base_ds="MPE_DERIVED",base_col="ACTIVE"
,rule_type='NOTNULL',rule_value='1',rule_active=1
,tx_to='31DEC5999:23:59:59'dt;
insert into &lib..MPE_VALIDATIONS set
tx_from=0,base_lib="&lib",base_ds="MPE_DERIVED",base_col="VAR_LEN"
,rule_type='NOTNULL',rule_value=' ',rule_active=1
,tx_to='31DEC5999:23:59:59'dt;
insert into &lib..MPE_VALIDATIONS set
tx_from=0,base_lib="&lib",base_ds="MPE_DERIVED",base_col="VAR_LEN"
,rule_type='MINVAL',rule_value='1',rule_active=1
,tx_to='31DEC5999:23:59:59'dt;
insert into &lib..MPE_VALIDATIONS set
tx_from=0,base_lib="&lib",base_ds="MPE_DERIVED",base_col="VAR_LEN"
,rule_type='MAXVAL',rule_value='32767',rule_active=1
,tx_to='31DEC5999:23:59:59'dt;
insert into &lib..MPE_VALIDATIONS set
tx_from=0,base_lib="&lib",base_ds="MPE_DERIVED",base_col="VAR_FMT"
,rule_type='CASE',rule_value='UPCASE',rule_active=1
,tx_to='31DEC5999:23:59:59'dt;
insert into &lib..MPE_VALIDATIONS set
tx_from=0,base_lib="&lib",base_ds="MPE_DERIVED",base_col="VAR_FMT"
,rule_type='HARDREGEX'
,rule_value='^(\$?[A-Za-z][A-Za-z0-9_]*(\.[0-9]+)?\.?|\$?[0-9]+(\.[0-9]+)?\.?)?$',rule_active=1
,tx_to='31DEC5999:23:59:59'dt;
insert into &lib..MPE_VALIDATIONS set
tx_from=0,base_lib="&lib",base_ds="MPE_DERIVED",base_col="VAR_LOGIC"
,rule_type='NOTNULL',rule_value=' ',rule_active=1
,tx_to='31DEC5999:23:59:59'dt;
insert into &lib..MPE_VALIDATIONS set
tx_from=0,base_lib="&lib",base_ds="MPE_DERIVED",base_col="VAR_LABEL"
,rule_type='HARDREGEX'
,rule_value='^[^\x00-\x1F\x7F]*$',rule_active=1
,tx_to='31DEC5999:23:59:59'dt;
insert into &lib..MPE_VALIDATIONS set
tx_from=0,base_lib="&lib",base_ds="MPE_DERIVED",base_col="VAR_LOGIC"
,rule_type='HARDREGEX'
,rule_value='^(?!.*(/\*|\*/))[^;&%]*$',rule_active=1
,tx_to='31DEC5999:23:59:59'dt;
insert into &lib..MPE_VALIDATIONS set
tx_from=0
,base_lib="&lib"
+23
View File
@@ -692,6 +692,29 @@ proc datasets lib=&lib noprint;
pk_mpe_validations=(tx_from base_lib base_ds base_col rule_type)
/nomiss unique;
quit;
/* mpe_derived - computed variables for the EDIT screen */
proc sql;
create table &lib..MPE_DERIVED
(
TX_FROM num &notnull format=datetime19.3,
LIBREF varchar(8),
DSN varchar(32),
VAR_NM varchar(32),
VAR_DEPS varchar(4000),
VAR_TYPE varchar(1),
VAR_LEN num,
VAR_FMT varchar(32),
VAR_LABEL varchar(256),
VAR_LOGIC varchar(4000),
ACTIVE num,
TX_TO num &notnull format=datetime19.3
);quit;
proc datasets lib=&lib noprint;
modify mpe_derived;
index create
pk_mpe_derived=(tx_from libref dsn var_nm)
/nomiss unique;
quit;
proc sql;
create table &lib..mpe_x_test(
primary_key_field num &notnull,
+13 -5
View File
@@ -2,11 +2,16 @@
@file
@brief Refreshes the data catalog
@details Assumes library is already assigned.
@param [in] etlsource identifies this load in the MPE_DATALOADS audit row.
Usage:
%mpe_refreshcatalogs(sashelp)
<h4> SAS Macros </h4>
@li mf_uid.sas
@li mp_abort.sas
@li bitemporal_dataloader.sas
@version 9.3
@@ -14,9 +19,12 @@
**/
%macro mpe_refreshcatalogs(lib,cat=#all);
%macro mpe_refreshcatalogs(lib,cat=#all
,etlsource=refreshcatalog:mpe_refreshcatalogs:%mf_uid()
);
%let lib=%upcase(&lib);
%let cat=%upcase(&cat);
%if "&etlsource"="0" %then %let etlsource=refreshcatalog:%sysmacroname:%mf_uid();
%put running &sysmacroname &lib for &cat;
proc sql;
@@ -50,7 +58,7 @@ create table datacats as select distinct libref,memname from catdata;
,base_dsn=mpe_datacatalog_CATS
,append_dsn=datacats
,PK=LIBREF MEMNAME
,etlsource=&sysmacroname
,etlsource=&etlsource
,loadtype=TXTEMPORAL
,tech_from=TX_FROM
,tech_to=TX_TO
@@ -76,7 +84,7 @@ quit;
,base_dsn=mpe_datacatalog_OBJS
,append_dsn=dataobjs
,PK=LIBREF MEMNAME OBJNAME OBJTYPE
,etlsource=&sysmacroname
,etlsource=&etlsource
,loadtype=TXTEMPORAL
,tech_from=TX_FROM
,tech_to=TX_TO
@@ -102,7 +110,7 @@ create table statusobjs as
,base_dsn=mpe_datastatus_OBJS
,append_dsn=statusobjs
,PK=LIBREF MEMNAME OBJNAME OBJTYPE
,etlsource=&sysmacroname
,etlsource=&etlsource
,loadtype=TXTEMPORAL
,tech_from=TX_FROM
,tech_to=TX_TO
@@ -127,7 +135,7 @@ create table statuscats as
,base_dsn=mpe_datastatus_cats
,append_dsn=statuscats
,PK=LIBREF MEMNAME
,etlsource=&sysmacroname
,etlsource=&etlsource
,loadtype=TXTEMPORAL
,tech_from=TX_FROM
,tech_to=TX_TO
+9 -2
View File
@@ -3,7 +3,12 @@
@brief Refreshes the library data catalog
@details
@param [in] etlsource= (refreshcatalog:<step>:%mf_uid()) identifies this load
in the MPE_DATALOADS audit row. That table is keyed on etlsource together
with processed_dttm, libref and dsn, so the uid keeps each load distinct.
<h4> SAS Macros </h4>
@li mf_uid.sas
@li dc_getlibs.sas
@li mf_getplatform.sas
@li bitemporal_dataloader.sas
@@ -16,7 +21,9 @@
**/
%macro mpe_refreshlibs(lib=0);
%macro mpe_refreshlibs(lib=0
,etlsource=refreshcatalog:mpe_refreshlibs:%mf_uid()
);
%dc_getlibs(outds=work.mm_getLibs)
proc sort data=mm_getlibs;
@@ -119,7 +126,7 @@ create table libs5 as
,base_dsn=MPE_DATACATALOG_LIBS
,append_dsn=libs5
,PK=LIBREF
,etlsource=&_program
,etlsource=&etlsource
,loadtype=TXTEMPORAL
,tech_from=TX_FROM
,tech_to=TX_TO
+14 -6
View File
@@ -1,12 +1,18 @@
/**
@file mpe_refreshtables.sas
@brief Refreshes the data catalog
@details Assumes library is already assigned.
@details
@param [in] etlsource= (refreshcatalog:<step>:%mf_uid()) identifies this load
in the MPE_DATALOADS audit row. That table is keyed on etlsource together
with processed_dttm, libref and dsn, so the uid keeps each load distinct.
Usage:
%mpe_refreshtables(sashelp)
<h4> SAS Macros </h4>
@li mf_uid.sas
@li mp_abort.sas
@li bitemporal_dataloader.sas
@li mf_existfeature.sas
@li mf_getengine.sas
@@ -17,7 +23,9 @@
@author 4GL Apps Ltd
**/
%macro mpe_refreshtables(lib,ds=#all);
%macro mpe_refreshtables(lib,ds=#all
,etlsource=refreshcatalog:mpe_refreshtables:%mf_uid()
);
%let lib=%upcase(&lib);
%let ds=%upcase(&ds);
%local engine; %let engine=%mf_getengine(&lib);
@@ -180,7 +188,7 @@ create table cols as
,base_dsn=mpe_datacatalog_vars
,append_dsn=finalcols
,PK=LIBREF DSN NAME
,etlsource=&sysmacroname
,etlsource=&etlsource
,loadtype=TXTEMPORAL
,tech_from=TX_FROM
,tech_to=TX_TO
@@ -242,7 +250,7 @@ create table work.finaltabs as
,base_dsn=mpe_datacatalog_tabs
,append_dsn=finaltabs
,PK=LIBREF DSN
,etlsource=&sysmacroname
,etlsource=&etlsource
,loadtype=TXTEMPORAL
,tech_from=TX_FROM
,tech_to=TX_TO
@@ -302,7 +310,7 @@ proc sql;
,base_dsn=mpe_datastatus_tabs
,append_dsn=statustabs
,PK=LIBREF DSN
,etlsource=&sysmacroname
,etlsource=&etlsource
,loadtype=TXTEMPORAL
,tech_from=TX_FROM
,tech_to=TX_TO
@@ -345,7 +353,7 @@ proc sql;
,base_dsn=mpe_datastatus_libs
,append_dsn=statuslibs
,PK=LIBREF
,etlsource=&sysmacroname
,etlsource=&etlsource
,loadtype=TXTEMPORAL
,tech_from=TX_FROM
,tech_to=TX_TO
+1 -1
View File
@@ -60,7 +60,7 @@ data work.mismatch;
run;
%mp_assertdsobs(work.mismatch,
desc=Every LIBDS value validated as expected (catalog form permitted, nothing smuggled past the dash),
desc=%str(Every LIBDS value validated as expected (catalog form permitted, nothing smuggled past the dash)),
test=EQUALS 0,
outds=work.test_results
)
@@ -8,8 +8,9 @@
the admin group, so the gate passes here.
<h4> SAS Macros </h4>
@li mpe_assert_abort.sas
@li mp_assert.sas
@li mx_execute.sas
@li mpe_execute.sas
**/
@@ -23,23 +24,19 @@ data work.params1;
name='libref';value='A.%sysevalf(3+4)B';output;
run;
%mx_execute(&_program,
%mpe_execute(&_program,
viyacontext=&defaultcontext,
inputparams=work.params1,
outref=web1,
outstatus=work.ab1,
failfast=0,
viyaresult=WEBOUT_TXT
)
%let abort1=0;
data _null_;
set work.results;
if state='canceled' then call symputx('abort1',1);
run;
%mp_assert(
iftrue=(&abort1=1),
%mpe_assert_abort(
desc=Macro content in libref aborts the service,
outds=work.test_results
outref=web1,
msg=Invalid libref
)
/**
@@ -50,7 +47,7 @@ data work.params2;
name='libref';value='DCTEST';output;
run;
%mx_execute(&_program,
%mpe_execute(&_program,
viyacontext=&defaultcontext,
inputparams=work.params2,
outlib=web2
+20 -30
View File
@@ -13,7 +13,9 @@
against a service that does not validate.
<h4> SAS Macros </h4>
@li mpe_assert_abort.sas
@li mp_assert.sas
@li mpe_execute.sas
@li mx_execute.sas
@li mf_getuniquefileref.sas
@@ -101,23 +103,19 @@ data _null_;
put _row;
run;
%mx_execute(&_program,
%mpe_execute(&_program,
viyacontext=&defaultcontext,
inputfiles=&fb1:BrowserParams,
outref=web1,
outstatus=work.ab1,
failfast=0,
viyaresult=WEBOUT_TXT
)
%let abort1=0;
data _null_;
set work.results;
if state='canceled' then call symputx('abort1',1);
run;
%mp_assert(
iftrue=(&abort1=1),
%mpe_assert_abort(
desc=SQL injection payload in libds aborts the service,
outds=work.test_results
outref=web1,
msg=Invalid libds
)
/**
@@ -137,23 +135,19 @@ data _null_;
put "&travtable.,&diffscsv.,&dclib..MPE_X_TEST";
run;
%mx_execute(&_program,
%mpe_execute(&_program,
viyacontext=&defaultcontext,
inputfiles=&fb2:BrowserParams,
outref=web2,
outstatus=work.ab2,
failfast=0,
viyaresult=WEBOUT_TXT
)
%let abort2=0;
data _null_;
set work.results;
if state='canceled' then call symputx('abort2',1);
run;
%mp_assert(
iftrue=(&abort2=1),
%mpe_assert_abort(
desc=Path traversal in table aborts the service,
outds=work.test_results
outref=web2,
msg=Invalid table
)
/**
@@ -166,23 +160,19 @@ data _null_;
put "&loadref.,../&loadref./&diffscsv.,&dclib..MPE_X_TEST";
run;
%mx_execute(&_program,
%mpe_execute(&_program,
viyacontext=&defaultcontext,
inputfiles=&fb3:BrowserParams,
outref=web3,
outstatus=work.ab3,
failfast=0,
viyaresult=WEBOUT_TXT
)
%let abort3=0;
data _null_;
set work.results;
if state='canceled' then call symputx('abort3',1);
run;
%mp_assert(
iftrue=(&abort3=1),
%mpe_assert_abort(
desc=Path traversal in stp_diffs_csv aborts the service,
outds=work.test_results
outref=web3,
msg=Invalid stp_diffs_csv
)
/**
+136 -28
View File
@@ -17,6 +17,14 @@
@li CLS_FLG - set to 0 if there are no CLS rules (everything editable)
else set to 1 (CLS rules exist)
@li ISMAP - set to 1 if the target DS is an excel map target, else 0
@li TRUNCATED - set to 1 if the selection was cut down to the
DC_MAXOBS_WEBEDIT / DC_MAXCELLS_WEBEDIT limit, else 0
@li TRUNC_MSG - the reason shown against the table name when TRUNCATED=1,
else empty
@li DERIVEDVARS - space separated list of the derived (computed) columns
configured in MPE_DERIVED, upper case, empty when there are none. They
are appended after the physical columns and are read-only; the EDIT grid
marks them so a user can see why a column cannot be edited.
<h5> approvers </h5>
<h5> dqrules </h5>
@@ -68,11 +76,14 @@
@li mp_validatecol.sas
@li mpe_accesscheck.sas
@li mpe_columnlevelsecurity.sas
@li mpe_derived.sas
@li mpe_dsmeta.sas
@li mpe_getlabels.sas
@li mpe_getversions.sas
@li mpe_filtermaster.sas
@li mpe_runhook.sas
@li mpeinit.sas
@li mpeterm.sas
@version 9.2
@author 4GL Apps Ltd
@@ -174,7 +185,23 @@ run;
%global loadtype var_txfrom var_txto var_processed filter_text pk coltype
sortpk;
sortpk var_busfrom var_busto rk_underlying buskey pre_edit_hook;
/* clear first: a global survives the call, so in a reused session it would still
* hold the previous request's values, and any path that skips an assignment
* would apply them to this one */
%let loadtype=;
%let var_txfrom=;
%let var_txto=;
%let var_processed=;
%let filter_text=;
%let pk=;
%let coltype=;
%let sortpk=;
%let var_busfrom=;
%let var_busto=;
%let rk_underlying=;
%let buskey=;
%let pre_edit_hook=;
%put getting table attributes;
proc sql noprint;
@@ -201,6 +228,21 @@ select upcase(loadtype)
outds=work.query
)
/**
* Derived (computed) variables, configured in MPE_DERIVED. They are added
* via a view, so that the filter (applied by the step that reads the view)
* can reference them - a WHERE clause cannot see a variable that is created
* in the same data step.
*/
%mpe_derived(libds=&libds,
dclib=&mpelib,
outref=dercode,
outview=work.vw_derived,
outvars=work.derived_vars
)
%let derived_nobs=%mf_nobs(work.derived_vars);
%put &=derived_nobs;
%macro mpestp_getdata();
%if not %symexist(DC_MAXOBS_WEBEDIT) %then %do;
%put NOTE:;%put NOTE- DC_MAXOBS_WEBEDIT not found!;
@@ -237,7 +279,12 @@ select upcase(loadtype)
%if &loadtype=BITEMPORAL %then %do;
data out (drop=&var_txfrom &var_txto &var_processed &drop_rk );
set &libds;
%if &derived_nobs>0 %then %do;
set work.vw_derived;
%end;
%else %do;
set &libds;
%end;
where %inc filtref;;
run;
proc sort data=out;
@@ -251,7 +298,12 @@ select upcase(loadtype)
%end;
%else %do;
data out (drop=&var_txfrom &var_txto &var_processed &drop_rk);
set &libds;
%if &derived_nobs>0 %then %do;
set work.vw_derived;
%end;
%else %do;
set &libds;
%end;
where %inc filtref;;
run;
%end;
@@ -262,30 +314,40 @@ select upcase(loadtype)
)
/* Sort first - the PRE_EDIT_HOOK contract is that it runs on sorted rows -
then run the hook, then check the row and cell limits. Checking once,
after both, means the counts cover everything that could reach the
browser: the rows the selection matched, and anything the hook has added
to work.out. A wide table exhausts the cell budget long before it
reaches the row limit - 250 rows of a 1000 column table is a quarter of
a million cells - so capping rows alone does not bound the work the
browser is asked to do. The payload itself is capped further down, when
work.outdata is built. */
then run the hook, then apply the row and cell limits (below). */
%let sortpk=%sysfunc(coalescec(&sortpk &var_busfrom,_ALL_));
proc sort data=work.out; by &sortPK; run;
%mpe_runhook(PRE_EDIT_HOOK)
/* Serve the selection truncated, at whichever limit binds first, and carry
the reason in sasparams for the EDIT screen to show against the table
name. Bounding the browser's work is the point: a wide table exhausts the
cell budget long before it reaches the row limit - 250 rows of a 1000
column table is a quarter of a million cells - so the row cap is
min(DC_MAXOBS_WEBEDIT, DC_MAXCELLS_WEBEDIT / columns). Counting after the
PRE_EDIT_HOOK means the cap covers what the hook added to work.out. */
%let obscnt=%mf_getattrn(work.out,NLOBS);
%let colcnt=%sysfunc(countw(%mf_getvarlist(work.out)));
%let cellcnt=%eval(&obscnt * &colcnt);
%mp_abort(iftrue=(&obscnt>&DC_MAXOBS_WEBEDIT)
,mac=&_program
,msg=Table is too big (&obscnt rows) - please filter and try again!
)
%mp_abort(iftrue=(&cellcnt>&DC_MAXCELLS_WEBEDIT)
,mac=&_program
,msg=Selection is too wide (&obscnt rows x &colcnt cols) - please filter and try again!
)
%let maxrows=&DC_MAXOBS_WEBEDIT;
%let limitvar=DC_MAXOBS_WEBEDIT;
%if &colcnt>0 %then %do;
%let cellrows=%sysfunc(floor(&DC_MAXCELLS_WEBEDIT/&colcnt));
%if %sysevalf(&cellrows lt &maxrows) %then %do;
%let maxrows=&cellrows;
%let limitvar=DC_MAXCELLS_WEBEDIT;
%end;
%end;
%if %sysevalf(&maxrows lt 1) %then %let maxrows=1;
%global truncated trunc_msg;
%let truncated=0;
%if %sysevalf(&obscnt gt &maxrows) %then %let truncated=1;
%let trunc_msg=;
%if &truncated=1 %then %do;
%let trunc_msg=Only the first &maxrows rows are loaded;
%let trunc_msg=&trunc_msg - the &limitvar limit has been reached.;
%let trunc_msg=&trunc_msg Filter the table to work with the rest.;
%end;
/* order pk fields at start of table. The delete flag is NOT added here:
@@ -297,9 +359,11 @@ select upcase(loadtype)
,Str2=&pk
);
%put sourcevars=&sourcevars;
/* the payload is capped here, at the row limit - the limits above are
checked against the whole selection, this is what goes to the browser */
options obs=&DC_MAXOBS_WEBEDIT;
/* the payload is capped at &maxrows - whichever limit that works out to be
- this is what goes to the browser. The cap is a data set option on the
SET, not `options obs=`, so no later step in this session can be cut by it:
a session option left set would truncate the metadata steps below, and a
table wide enough to hit the cell limit would lose columns rather than rows. */
data outdata;
/* pk fields come first */
%if %length(&pk)>0 %then %do;
@@ -313,9 +377,8 @@ select upcase(loadtype)
/* send empty row if empty table to help with hot rendering */
output;
%end;
set work.out ;
set work.out(obs=&maxrows) ;
run;
options obs=max;
@@ -513,13 +576,28 @@ select upcase(loadtype)
/* extract col info */
%mp_getcols(&libds, outds=cols1)
/* derived variables are added to the view, so take their attributes from
there - this keeps type, length, format and label identical to the data */
%if &derived_nobs>0 %then %do;
%mp_getcols(work.vw_derived, outds=derived_cols_all)
proc sql;
create table work.derived_cols as
select a.*
from work.derived_cols_all a
where a.NAME in (select NAME from work.derived_vars);
quit;
data work.cols1;
set work.cols1 work.derived_cols;
run;
%end;
/* join with cls rules. Trim COLS payload in the SQL below - DDTYPE
C=CHARACTER N=NUMERIC (dates kept in full), VARNUM dropped (client
relies on column order, not position) */
proc sql;
create table work.cols as
select a.NAME
,coalesce(c.desc,a.NAME) as LABEL
,coalesce(c.desc,a.LABEL,a.NAME) as LABEL
,a.FMTNAME
,case a.DDTYPE
when 'CHARACTER' then 'C'
@@ -571,8 +649,19 @@ proc sql noprint;
select count(*) into: ismap from &mpelib..mpe_xlmap_info
where XLMAP_TARGETLIBDS="&orig_libds" and &dc_dttmtfmt. le TX_TO;
/* the derived columns, named for the EDIT grid so it can mark them as special.
They are read-only through the READONLY DQ rule below, but that rule does not
say WHY the column cannot be edited - a computed column is a different thing
from an ordinary read-only one. work.derived_vars exists on every path, empty
when the table has no derived variables. */
%let derivedvars=;
proc sql noprint;
select upcase(name) into: derivedvars separated by ' '
from work.derived_vars;
quit;
data sasparams;
length colHeaders $20000 filter_text $32767;
length colHeaders $20000 filter_text $32767 trunc_msg $200 derivedvars $32767;
colHeaders=cats(upcase("%mf_getvarlist(sasdata1,dlm=%str(,))"));
pkCnt=countw("&pk");
pk="&pk";
@@ -583,6 +672,11 @@ data sasparams;
if trim(symget('rk_underlying')) ne '' then rk_flag=1;
else rk_flag=0;
filter_text=symget('filter_text');
derivedvars=symget('derivedvars');
/* set when the selection was cut down to the row / cell limit, so the EDIT
screen can say so against the table name */
truncated=&truncated;
trunc_msg="&trunc_msg";
if %mf_nobs(work.cls_rules)=0 then cls_flag=0;
else cls_flag=1;
put (_all_)(=);
@@ -600,7 +694,16 @@ proc sort data=&mpelib..mpe_validations
by base_col rule_type rule_value;
run;
/* merge with NOTNULL constraints in the physical table */
/* merge with NOTNULL constraints in the physical table, and mark the derived
variables read-only: a derived column is computed at read time, so the grid
must not accept a value for it. COLS carries the same READ (a derived column
has no CLS rule row) but the client only applies COLS.CLS_RULE when CLS_FLAG
is set, which is driven by the table's column-level security - so the rule is
declared here, where it holds regardless.
work.derived_vars exists on every path that reaches this step: %mpe_derived
writes it before returning, empty when the table has no derived variables,
so the union below needs no guard */
proc sql;
create table _data_ as
select * from dqrules
@@ -613,6 +716,11 @@ union
and upcase(memname)="%scan(&orig_libds,2,.)"
and upcase(name) in (select name from vars3)
and notnull='yes'
union
select upcase(name) as base_col
,'READONLY' as rule_type
,'' as rule_value
from work.derived_vars
order by 1,2,3;
data dqrules;
set &syslast;
+101 -47
View File
@@ -2,17 +2,20 @@
@file
@brief testing getdata row and cell limits
@details The EDIT screen refuses a selection that is too big on either
count - rows (DC_MAXOBS_WEBEDIT) or cells (DC_MAXCELLS_WEBEDIT). The row
check runs first, so a selection over the row limit reports "Table is too
big" and one under the row limit but over the cell limit reports
"Selection is too wide". Both aborts are proven by the child job state
(an aborted service registers no webout).
@details The EDIT screen is bounded by two limits - rows
(DC_MAXOBS_WEBEDIT) and cells, rows x columns (DC_MAXCELLS_WEBEDIT) -
whichever is reached first. A selection over either is SERVED TRUNCATED:
the grid gets the first rows that fit, and sasparams carries TRUNCATED=1
plus a TRUNC_MSG naming the limit, which the EDIT screen shows as a warning
against the table name.
Fixture: dctest.wideboy (1000 rows x 1004 columns), created and registered
by testsetup. Filtered to 300 rows it is over the 250 row limit; filtered
to 200 rows it is under the row limit but 200 x 1004 = 200,800 cells, over
the 200,000 cell limit.
Test 1 uses MPE_X_TEST (several hundred rows, few columns) so the row
limit alone decides the cap. Test 2 uses the fixture dctest.wideboy (1000
rows x 1004 columns, created and registered by testsetup) filtered to 200
rows: that is under the row limit but 200 x 1004 = 200,800 cells, over the
200,000 cell limit, so the cell limit decides the cap -
floor(200000 / 1004) = 199 rows. It also checks the column list is whole:
the cap applies to the rows the browser is sent, never to the COLS metadata.
<h4> SAS Macros </h4>
@li mp_assert.sas
@@ -25,7 +28,7 @@
%let _program=&appLoc/services/editors/getdata;
/**
* build a ROW_ID <= &maxrow filter on dctest.wideboy, leaving the filter_rk
* Build a ROW_ID <= &maxrow filter on dctest.wideboy, leaving the filter_rk
* in &filter_rk
*/
%macro wideboy_filter(maxrow);
@@ -57,32 +60,56 @@
%mend wideboy_filter;
/**
* Test 1 - row limit (300 rows > 250)
* Call getdata as a child job and expose the webout as the &outlib JSON
* libname, so the test can read what the browser would have received.
*/
%wideboy_filter(300)
%let f1=%mf_getuniquefileref();
data _null_;
file &f1 termstr=crlf;
put 'LIBDS:$43. FILTER_RK:8.';
put "dctest.wideboy,&filter_rk";
run;
%macro call_getdata(libds,filter_rk,outlib);
%let fref=%mf_getuniquefileref();
data _null_;
file &fref termstr=crlf;
put 'LIBDS:$43. FILTER_RK:8.';
put "&libds,&filter_rk";
run;
%mx_execute(&_program,
viyacontext=&defaultcontext,
inputfiles=&fref:sascontroltable,
outlib=&outlib
)
%let syscc=0;
%mend call_getdata;
%mx_execute(&_program,
viyacontext=&defaultcontext,
inputfiles=&f1:sascontroltable,
outref=web1
)
/**
* Test 1 - row limit. MPE_X_TEST is well under the cell limit, so the
* 250 row default is what caps the payload.
*/
%call_getdata(&dclib..MPE_X_TEST,-1,web1)
%let abort1=0;
%let t1_nobs=-1;
%let t1_trunc=-1;
%let t1_msg=;
proc sql noprint;
select count(*) into: t1_nobs from web1.sasdata;
quit;
data _null_;
set work.results;
if state='canceled' then call symputx('abort1',1);
set web1.sasparams;
call symputx('t1_trunc',truncated);
call symputx('t1_msg',trunc_msg);
putlog (_all_)(=);
run;
%let syscc=0;
%mp_assert(
iftrue=(&abort1=1),
desc=Test 1 - a selection over the row limit is refused,
iftrue=(&t1_trunc=1),
desc=%str(Test 1 - a selection over the row limit is served, flagged as truncated),
outds=work.test_results
)
%mp_assert(
iftrue=(&t1_nobs=250),
desc=Test 1 - the payload is capped at DC_MAXOBS_WEBEDIT (250) rows,
outds=work.test_results
)
%mp_assert(
iftrue=(%index(&t1_msg,DC_MAXOBS_WEBEDIT)>0),
desc=Test 1 - the truncation message names the DC_MAXOBS_WEBEDIT limit,
outds=work.test_results
)
@@ -90,28 +117,55 @@ run;
* Test 2 - cell limit (200 rows x 1004 cols = 200,800 > 200,000)
*/
%wideboy_filter(200)
%let f2=%mf_getuniquefileref();
data _null_;
file &f2 termstr=crlf;
put 'LIBDS:$43. FILTER_RK:8.';
put "dctest.wideboy,&filter_rk";
run;
%call_getdata(dctest.wideboy,&filter_rk,web2)
%mx_execute(&_program,
viyacontext=&defaultcontext,
inputfiles=&f2:sascontroltable,
outref=web2
)
%let abort2=0;
%let t2_nobs=-1;
%let t2_trunc=-1;
%let t2_msg=;
proc sql noprint;
select count(*) into: t2_nobs from web2.sasdata;
quit;
data _null_;
set work.results;
if state='canceled' then call symputx('abort2',1);
set web2.sasparams;
call symputx('t2_trunc',truncated);
call symputx('t2_msg',trunc_msg);
putlog (_all_)(=);
run;
%mp_assert(
iftrue=(&abort2=1),
desc=Test 2 - a selection over the cell limit is refused,
iftrue=(&t2_trunc=1),
desc=%str(Test 2 - a selection over the cell limit is served, flagged as truncated),
outds=work.test_results
)
%mp_assert(
iftrue=(&t2_nobs=199),
desc=Test 2 - the payload is capped at floor(DC_MAXCELLS_WEBEDIT / cols),
outds=work.test_results
)
%mp_assert(
iftrue=(%index(&t2_msg,DC_MAXCELLS_WEBEDIT)>0),
desc=Test 2 - the truncation message names the DC_MAXCELLS_WEBEDIT limit,
outds=work.test_results
)
/**
* The row cap must not reach the metadata: the COLS payload for this table
* describes every column, not the 199 rows the cell limit admits. A row cap
* left set as a session option would cut the column list to 199 entries, and
* a grid losing 800 columns looks like a missing table rather than a limit.
*/
%let t2_cols=-1;
%let t2_phys=-1;
proc sql noprint;
select count(*) into: t2_cols from web2.cols;
select count(*) into: t2_phys
from dictionary.columns
where libname='DCTEST' and memname='WIDEBOY';
quit;
%mp_assert(
iftrue=(&t2_cols=&t2_phys),
desc=%str(Test 2 - every column is described, not just the rows the cap admits),
outds=work.test_results
)
@@ -21,10 +21,10 @@
editing MPE_GROUPS alone - Row Level Security is skipped for them - so the
test reports a skip row instead of running the assertions.
The call passes a filter (PRIMARY_KEY_FIELD < 100) because getdata.sas also
enforces the row / cell limits, and the unfiltered MPE_X_TEST is above the
row limit (642 rows against DC_MAXOBS_WEBEDIT:250) - the limits test
(getdata.test.1.sas) covers that abort separately.
The call passes a filter (PRIMARY_KEY_FIELD < 100) so the whole selection is
served - the unfiltered MPE_X_TEST is above the row limit (642 rows against
DC_MAXOBS_WEBEDIT:250) and would come back truncated. The limits test
(getdata.test.1.sas) covers that truncation separately.
<h4> SAS Macros </h4>
@li mf_getuniquefileref.sas
+231 -8
View File
@@ -3,13 +3,14 @@
@brief testing gethistory service
<h4> SAS Macros </h4>
@li mp_assertcols.sas
@li mp_assertcolvals.sas
@li mpe_execute.sas
@li mp_assertdsobs.sas
@li mf_getuniquefileref.sas
@li mf_getuniquename.sas
@li mp_filterstore.sas
@li mx_execute.sas
@li mpe_derived.sas
@li mp_assert.sas
@li mp_assertcols.sas
**/
@@ -55,7 +56,7 @@ data _null_;
put 'LIBDS:$43. FILTER_RK:8.';
put "&dclib..MPE_TABLES,&filter_rk1";
run;
%mx_execute(&_program,
%mpe_execute(&_program,
viyacontext=&defaultcontext,
inputfiles=&f1:sascontroltable,
outlib=web1,
@@ -110,8 +111,8 @@ data work.inquery;
input GROUP_LOGIC:$3. SUBGROUP_LOGIC:$3. SUBGROUP_ID:8. VARIABLE_NM:$32.
OPERATOR_NM:$10. RAW_VALUE:$4000.;
datalines4;
AND,AND,1,LIBREF,CONTAINS,"'DC'"
AND,OR,2,DSN,=,"'MPE_LOCK_ANYTABLE'"
AND,AND,1,LIBREF,=,"'&dc_libref'"
AND,AND,2,DSN,=,"'MPE_LOCK_ANYTABLE'"
;;;;
run;
%mp_filterstore(
@@ -136,7 +137,7 @@ data _null_;
put "&dclib..MPE_TABLES,&filter_rk";
run;
%mx_execute(&_program,
%mpe_execute(&_program,
viyacontext=&defaultcontext,
inputfiles=&f2:sascontroltable,
outlib=web2
@@ -216,7 +217,7 @@ data _null_;
put "DCTEST.DCFMTS-FC,&filter_rk3";
run;
%mx_execute(&_program,
%mpe_execute(&_program,
viyacontext=&defaultcontext,
inputfiles=&f3:sascontroltable,
outlib=web3,
@@ -240,3 +241,225 @@ run;
test=EQUALS 10,
outds=work.test_results
)
/**
* Test 4 - derived variables
*/
/* clean up any config left behind by an earlier run */
proc sql;
delete from &dc_libref..mpe_derived
where upcase(libref)="%scan(&dclib..MPE_TABLES,1,.)"
and upcase(dsn)="%scan(&dclib..MPE_TABLES,2,.)";
insert into &dc_libref..mpe_derived set
tx_from=0
,tx_to='31DEC5999:23:59:59'dt
,libref="%scan(&dclib..MPE_TABLES,1,.)"
,dsn="%scan(&dclib..MPE_TABLES,2,.)"
,var_nm='DERIVED_NOTE'
,var_deps='LIBREF DSN'
,var_type='C'
,var_len=41
,var_fmt=''
,var_logic="cats(libref,'.',dsn)"
,active=1;
insert into &dc_libref..mpe_derived set
tx_from=0
,tx_to='31DEC5999:23:59:59'dt
,libref="%scan(&dclib..MPE_TABLES,1,.)"
,dsn="%scan(&dclib..MPE_TABLES,2,.)"
,var_nm='DERIVED_NOTE2'
,var_deps='DERIVED_NOTE'
,var_type='C'
,var_len=45
,var_fmt=''
,var_logic="cats(DERIVED_NOTE,' (2)')"
,active=1;
quit;
/* a filter may reference the derived variable, so validate against the view */
%mpe_derived(libds=&dc_libref..MPE_TABLES,
dclib=&dc_libref,
outref=dercode4,
outview=work.vw_derived4,
outvars=work.derived_vars4
)
data work.inquery4;
length GROUP_LOGIC $3 SUBGROUP_LOGIC $3 SUBGROUP_ID 8 VARIABLE_NM $32
OPERATOR_NM $10 RAW_VALUE $4000;
GROUP_LOGIC='AND'; SUBGROUP_LOGIC='AND'; SUBGROUP_ID=1;
VARIABLE_NM='LIBREF'; OPERATOR_NM='=';
RAW_VALUE="'"!!trim(symget('dc_libref'))!!"'";
output;
GROUP_LOGIC='AND'; SUBGROUP_LOGIC='AND'; SUBGROUP_ID=2;
VARIABLE_NM='DERIVED_NOTE'; OPERATOR_NM='CONTAINS';
RAW_VALUE="'MPE_LOCK'";
output;
run;
%mp_filterstore(
libds=&dc_libref..MPE_TABLES,
targetds=work.vw_derived4,
filter_summary=&dc_libref..mpe_filteranytable,
filter_detail=&dc_libref..mpe_filtersource,
lock_table=&dc_libref..mpe_lockanytable,
maxkeytable=&dc_libref..mpe_maxkeyvalues,
queryds=work.inquery4,
outresult=work.result4,
outquery=work.query4
)
data _null_;
set work.result4;
call symputx('filter_rk4',filter_rk);
run;
%let f4=%mf_getuniquefileref();
data _null_;
file &f4 termstr=crlf;
put 'LIBDS:$43. FILTER_RK:8.';
put "&dclib..MPE_TABLES,&filter_rk4";
run;
%mpe_execute(&_program,
viyacontext=&defaultcontext,
inputfiles=&f4:sascontroltable,
outlib=web4,
outstatus=work.ab4,
failfast=0,
mdebug=&sasjs_mdebug
)
%let state4=NONE;
data _null_;
if not exist('work.ab4') then stop;
set work.ab4;
call symputx('state4',state);
run;
%mp_assert(
iftrue=("&state4"="completed"),
desc=Test4 - the derived variable call completes,
outds=work.test_results
)
/* a cancelled child returns no webout, so the outlib keeps whatever an earlier
* call left in it - read its members only when the child completed */
%let completed4=0;
data _null_;
if not exist('work.ab4') then stop;
set work.ab4;
if state='completed' then call symputx('completed4',1);
run;
%if &completed4=1 %then %do;
data sasdata4;
set web4.sasdata;
run;
data cols4;
set web4.cols;
run;
%end;
%else %do;
data cols4;
length name $32;
stop;
run;
%end;
%if &completed4=1 and "&sasjs_mdebug"="1" %then %do;
/* the payload the assertions read, so a missing column or an unapplied filter
can be seen without guessing */
data _null_;
set work.cols4;
if _n_=1 then putlog "NOTE: &sysmacroname: Test4 cols payload:";
putlog (_all_)(=);
run;
data _null_;
set sasdata4;
if _n_=1 then putlog "NOTE: &sysmacroname: Test4 data payload:";
putlog (_all_)(=);
run;
%end;
proc sql noprint;
select count(*) into: derived4
from work.cols4
where upcase(name)='DERIVED_NOTE';
quit;
%mp_assert(
iftrue=(&derived4>0),
desc=Test4 - derived column is in the COLS payload,
outds=work.test_results
)
data work.chk4a;
set work.cols4;
where name='DERIVED_NOTE' and cls_rule='READ';
run;
%mp_assertdsobs(work.chk4a,
desc=Test4 - derived column is read only,
test=EQUALS 1,
outds=work.test_results
)
/* COLS.CLS_RULE only reaches the grid when CLS_FLAG is set, which a table
with no column level security does not do - the read-only promise is
carried by this rule instead */
data work.chk4e;
set web4.dqrules;
where upcase(base_col)='DERIVED_NOTE' and rule_type='READONLY';
run;
%mp_assertdsobs(work.chk4e,
desc=Test4 - derived column carries a READONLY rule,
test=EQUALS 1,
outds=work.test_results
)
data work.chk4b;
set work.cols4;
where name='DERIVED_NOTE' and label=:'DERIVED_NOTE=';
run;
%mp_assertdsobs(work.chk4b,
desc=Test4 - derived column is labelled,
test=EQUALS 1,
outds=work.test_results
)
%mp_assertdsobs(work.sasdata4,
desc=Test4 - filter on the derived column is applied,
test=EQUALS 1,
outds=work.test_results
)
data work.chk4c;
set work.sasdata4;
if derived_note=cats("%scan(&dclib..MPE_TABLES,1,.)",'.','MPE_LOCKANYTABLE');
run;
%mp_assertdsobs(work.chk4c,
desc=Test4 - derived value is correct,
test=EQUALS 1,
outds=work.test_results
)
/* the second variable is built on the first, so this proves statement order */
data work.chk4d;
set work.sasdata4;
if derived_note2=cats(derived_note,' (2)');
run;
%mp_assertdsobs(work.chk4d,
desc=Test4 - a derived variable can reference an earlier one,
test=EQUALS 1,
outds=work.test_results
)
/* tidy up the config so repeated runs start clean. Delete every row for the
table, not just the first variable: leaving DERIVED_NOTE2 behind means the
next run starts with a dependency on a variable that is no longer declared,
and every call aborts */
proc sql;
delete from &dc_libref..mpe_derived
where upcase(libref)="%scan(&dclib..MPE_TABLES,1,.)"
and upcase(dsn)="%scan(&dclib..MPE_TABLES,2,.)";
quit;
+35
View File
@@ -22,6 +22,7 @@
@li mf_getuniquefileref.sas
@li mpe_loader.sas
@li mpe_filtermaster.sas
@li mpe_derived.sas
@li mp_abort.sas
@li mp_binarycopy.sas
@li mp_cntlout.sas
@@ -106,6 +107,37 @@ run;
)
%put Verify that the upload does not violate Row Level Security checks:;
/* derived variables are display-only and must not be submitted, so drop them
from the data before the security check runs - otherwise a crafted
submission could satisfy an RLS clause that references a derived column */
%mpe_derived(libds=&libds,
dclib=&mpelib,
outref=dercode,
outvars=work.derived_vars
)
%let derived_droplist=;
%if %mf_nobs(work.derived_vars)>0 %then %do;
proc contents noprint data=jsdata out=work.jsdata_vars(keep=name);
run;
proc sql noprint;
select a.name into: derived_droplist separated by ' '
from work.derived_vars a
inner join work.jsdata_vars b
on a.name=b.name;
quit;
%end;
/* drop them from the submitted data itself - a DROP on the output of a later
step would leave the columns in that step's input, and the security clause
is evaluated against the input */
%if %length(&derived_droplist)>0 %then %do;
data work.jsdata;
set work.jsdata;
drop &derived_droplist;
run;
%end;
%mpe_filtermaster(ULOAD,&libds,
dclib=&mpelib,
outref=filtref,
@@ -220,6 +252,9 @@ data _null_;
put '%let ' name '=' value ';';
run;
/* derived variables are display-only - they never reach the approval package
or the target table. work.jsdata was rewritten without them before the
security check, so there is nothing left to drop here */
data approve.jsdset;
length _____DELETE__THIS__RECORD_____ $3;
set jsdata;
+86 -39
View File
@@ -5,13 +5,14 @@
1. loading row level EDIT security against a table with LOADTYPE=REPLACE
2. applying LOADTYPE=REPLACE to a table with row level EDIT security
The staged loads are aborted by the post edit hooks inside the child
service. The abort is recorded as a FAILED load in MPE_LOADS (via
mpe_loadfail), so the assertions query MPE_LOADS rather than the child
response - a canceled child does not always return a webout or log.
The staged loads are aborted by the post edit hooks. The assertion fetches
the sasjsAbort object that mp_abort writes to the webout before the hook
aborts.
<h4> SAS Macros </h4>
@li mx_execute.sas
@li mpe_execute.sas
@li mpe_assert_abort.sas
@li mp_assert.sas
@@ -71,7 +72,6 @@ quit;
/**
* Test 1 - submitting an RLS EDIT rule against a REPLACE table should abort
*/
%let t1=%sysfunc(datetime());
data work.sascontroltable;
action='LOAD';
message='RLS EDIT rule on REPLACE table should abort';
@@ -100,44 +100,99 @@ data work.jsdata;
output;
run;
%mx_execute(&_program,
%mpe_execute(&_program,
viyacontext=&defaultcontext,
inputdatasets=work.sascontroltable work.jsdata,
outlib=web1,
outref=wb1,
outstatus=work.ab1,
failfast=0,
mdebug=&sasjs_mdebug
)
/* a hook-aborted load is terminated before mpe_loadfail can record a
* FAILED status, so the load row stays IN PROGRESS - the abort is
* proved by the load NOT completing successfully. Tests in this suite run
* in parallel sessions, so match the row for THIS test by its unique
* reason_txt (the submitted message) rather than any load in the time
* window - an unrelated aborted/IN PROGRESS load would otherwise satisfy
* the assertion even if this load completed. The row id (csv_dir/mperef)
* is generated inside the aborted child service, which returns no webout,
* so reason_txt is the only stable identifier the test controls. */
%let abort1=0;
%mpe_assert_abort(
desc=Checking RLS EDIT rule is rejected for REPLACE loadtype table (#211),
outref=wb1
)
/**
* Test 3 - an RLS rule naming a DERIVED variable must be rejected at save
* time: the rule is evaluated against the submitted data, where a derived
* column does not exist
*/
/* define a derived variable on the target table */
proc sql noprint;
select count(*) into: abort1
from &dc_libref..mpe_loads
where processed_dttm>&t1
and reason_txt="RLS EDIT rule on REPLACE table should abort"
and upcase(status) ne 'SUCCESS';
delete from &dc_libref..mpe_derived
where upcase(libref)='DCTEST' and upcase(dsn)='DC_REPLACE';
insert into &dc_libref..mpe_derived
set tx_from=0
,tx_to='31DEC5999:23:59:59'dt
,libref='DCTEST'
,dsn='DC_REPLACE'
,var_nm='DERIVED_X'
,var_deps='X'
,var_type='N'
,var_len=8
,var_fmt=''
,var_logic='X + 1'
,active=1;
quit;
%mp_assert(
iftrue=(&abort1>0),
desc=Checking RLS EDIT rule is rejected for REPLACE loadtype table (#211),
outds=work.test_results
data work.sascontroltable;
action='LOAD';
message='RLS rule on a derived variable should abort';
libds="&dc_libref..MPE_ROW_LEVEL_SECURITY";
output;
stop;
run;
data work.jsdata;
length RLS_SCOPE $8 RLS_GROUP $128 RLS_LIBREF $8 RLS_TABLE $32
RLS_GROUP_LOGIC $3 RLS_SUBGROUP_LOGIC $3 RLS_VARIABLE_NM $32
RLS_OPERATOR_NM $12 RLS_RAW_VALUE $4000
_____DELETE__THIS__RECORD_____ $3;
RLS_SCOPE='EDIT';
RLS_GROUP='SASAdministrators';
RLS_LIBREF='DCTEST';
RLS_TABLE='DC_REPLACE';
RLS_GROUP_LOGIC='AND';
RLS_SUBGROUP_LOGIC='OR';
RLS_SUBGROUP_ID=0;
RLS_VARIABLE_NM='DERIVED_X';
RLS_OPERATOR_NM='NE';
RLS_RAW_VALUE='1';
RLS_ACTIVE=1;
_____DELETE__THIS__RECORD_____='No';
output;
run;
%mpe_execute(&_program,
viyacontext=&defaultcontext,
inputdatasets=work.sascontroltable work.jsdata,
outlib=web3,
outref=wb3,
outstatus=work.ab3,
failfast=0,
mdebug=&sasjs_mdebug
)
%mpe_assert_abort(
desc=Checking an RLS rule on a derived variable is rejected,
outref=wb3
)
/* tidy up the derived configuration */
proc sql noprint;
delete from &dc_libref..mpe_derived
where upcase(libref)='DCTEST' and upcase(dsn)='DC_REPLACE';
quit;
/**
* Test 2 - applying REPLACE loadtype to a table with RLS EDIT security
* should abort. The active EDIT rule was inserted in the prep section,
* long before this child service reads it.
*/
%let t2=%sysfunc(datetime());
/* now stage a REPLACE loadtype for DCTEST.WIDEBOY */
data work.sascontroltable;
@@ -155,27 +210,19 @@ data work.jsdata;
_____DELETE__THIS__RECORD_____='No';
run;
%mx_execute(&_program,
%mpe_execute(&_program,
viyacontext=&defaultcontext,
inputdatasets=work.sascontroltable work.jsdata,
outlib=web2,
outref=wb2,
outstatus=work.ab2,
failfast=0,
mdebug=&sasjs_mdebug
)
%let abort2=0;
proc sql noprint;
select count(*) into: abort2
from &dc_libref..mpe_loads
where processed_dttm>&t2
and reason_txt="REPLACE loadtype on RLS-secured table should abort"
and upcase(status) ne 'SUCCESS';
quit;
%mp_assert(
iftrue=(&abort2>0),
%mpe_assert_abort(
desc=Checking REPLACE loadtype is rejected for RLS EDIT-secured table (#211),
outds=work.test_results
outref=wb2
)
/**
@@ -0,0 +1,562 @@
/**
@file
@brief testing the derived-variable submit guards (MPE_DERIVED post edit hook)
@details Backend validations should abort when:
1. an active row's VAR_DEPS names an inactive (active=0) row of the same
table - the engine reads only active rows, so the dependency can never
resolve at run time
2. a row is staged with active=0 while a surviving active row depends on it
They should NOT abort when:
3. a new active row is added to a table that also holds an inactive row -
the inactive row is not part of the dependency graph, so it cannot
create a cycle or a stale reference
4. a delete-marked RLS rule naming a derived variable is submitted - the
rule is being removed, so its variables are not validated
5. a delete and re-add of the same variable is submitted together - the
re-added active row resolves the dependency
6. an inactive row is set active again while another active row depends on
it - the re-activation is what makes the dependency resolvable
The staged loads are aborted by the post edit hook. The assertion fetches the
sasjsAbort object that mp_abort writes to the webout before the hook aborts.
<h4> SAS Macros </h4>
@li mpe_execute.sas
@li mpe_assert_abort.sas
@li mp_assert.sas
**/
%let _program=&appLoc/services/editors/stagedata;
/**
* Prep - a target table registration for the derived configuration, and the
* hook registration (some estates were initialised before the hook existed)
*/
proc sql noprint;
update &dc_libref..mpe_tables
set post_edit_hook='services/hooks/mpe_derived_postedit'
where upcase(dsn)='MPE_DERIVED' and post_edit_hook='';
delete from &dc_libref..mpe_derived
where upcase(libref)='DCTEST' and upcase(dsn)='DC_DERIV';
quit;
/**
* Test 1 - a new active row depending on an inactive row must be rejected
*/
%let t1=%sysfunc(datetime());
proc sql noprint;
insert into &dc_libref..mpe_derived
set tx_from=0
,tx_to='31DEC5999:23:59:59'dt
,libref='DCTEST'
,dsn='DC_DERIV'
,var_nm='INACTIVE_DEP'
,var_deps=''
,var_type='N'
,var_len=8
,var_fmt=''
,var_logic='1'
,active=0;
quit;
data work.sascontroltable;
action='LOAD';
message='Derived var depending on an inactive row should abort';
libds="&dc_libref..MPE_DERIVED";
output;
stop;
run;
data work.jsdata;
length LIBREF $8 DSN $32 VAR_NM $32 VAR_DEPS $4000 VAR_TYPE $1
VAR_LOGIC $4000 VAR_FMT $32
_____DELETE__THIS__RECORD_____ $3;
LIBREF='DCTEST';
DSN='DC_DERIV';
VAR_NM='USES_INACTIVE';
VAR_DEPS='INACTIVE_DEP';
VAR_TYPE='N';
VAR_LEN=8;
VAR_FMT='';
VAR_LOGIC='INACTIVE_DEP + 1';
ACTIVE=1;
_____DELETE__THIS__RECORD_____='No';
output;
run;
%mpe_execute(&_program,
viyacontext=&defaultcontext,
inputdatasets=work.sascontroltable work.jsdata,
outlib=web1,
outref=wb1,
outstatus=work.ab1,
failfast=0,
mdebug=&sasjs_mdebug
)
%mpe_assert_abort(
desc=Checking a dependency on an inactive derived row is rejected,
outref=wb1
)
/**
* Test 2 - deactivating a row that a surviving active row depends on must be
* rejected: the control table still shows the row as active, so the check has
* to read the staged row too
*/
proc sql noprint;
delete from &dc_libref..mpe_derived
where upcase(libref)='DCTEST' and upcase(dsn)='DC_DERIV';
insert into &dc_libref..mpe_derived
set tx_from=0
,tx_to='31DEC5999:23:59:59'dt
,libref='DCTEST'
,dsn='DC_DERIV'
,var_nm='BASE_X'
,var_deps=''
,var_type='N'
,var_len=8
,var_fmt=''
,var_logic='1'
,active=1;
insert into &dc_libref..mpe_derived
set tx_from=0
,tx_to='31DEC5999:23:59:59'dt
,libref='DCTEST'
,dsn='DC_DERIV'
,var_nm='USES_BASE'
,var_deps='BASE_X'
,var_type='N'
,var_len=8
,var_fmt=''
,var_logic='BASE_X + 1'
,active=1;
quit;
data work.sascontroltable;
action='LOAD';
message='Deactivating a relied-on derived row should abort';
libds="&dc_libref..MPE_DERIVED";
output;
stop;
run;
data work.jsdata;
set &dc_libref..mpe_derived(where=(upcase(libref)='DCTEST'
and upcase(var_nm)='BASE_X'));
length _____DELETE__THIS__RECORD_____ $3;
ACTIVE=0;
_____DELETE__THIS__RECORD_____='No';
run;
%mpe_execute(&_program,
viyacontext=&defaultcontext,
inputdatasets=work.sascontroltable work.jsdata,
outlib=web2,
outref=wb2,
outstatus=work.ab2,
failfast=0,
mdebug=&sasjs_mdebug
)
%mpe_assert_abort(
desc=Checking deactivating a relied-on derived row is rejected,
outref=wb2
)
/**
* Test 3 - an inactive row must not block a submission that does not depend
* on it: with INACTIVE_DEP present (active=0) a new independent row loads
*/
%let t3=%sysfunc(datetime());
proc sql noprint;
delete from &dc_libref..mpe_derived
where upcase(libref)='DCTEST' and upcase(dsn)='DC_DERIV';
insert into &dc_libref..mpe_derived
set tx_from=0
,tx_to='31DEC5999:23:59:59'dt
,libref='DCTEST'
,dsn='DC_DERIV'
,var_nm='INACTIVE_DEP'
,var_deps=''
,var_type='N'
,var_len=8
,var_fmt=''
,var_logic='1'
,active=0;
quit;
data work.sascontroltable;
action='LOAD';
message='Active row next to an inactive row should load';
libds="&dc_libref..MPE_DERIVED";
output;
stop;
run;
data work.jsdata;
length LIBREF $8 DSN $32 VAR_NM $32 VAR_DEPS $4000 VAR_TYPE $1
VAR_LOGIC $4000 VAR_FMT $32
_____DELETE__THIS__RECORD_____ $3;
LIBREF='DCTEST';
DSN='DC_DERIV';
VAR_NM='FRESH';
VAR_DEPS='X';
VAR_TYPE='N';
VAR_LEN=8;
VAR_FMT='';
VAR_LOGIC='X + 1';
ACTIVE=1;
_____DELETE__THIS__RECORD_____='No';
output;
run;
%mpe_execute(&_program,
viyacontext=&defaultcontext,
inputdatasets=work.sascontroltable work.jsdata,
outlib=web3,
outref=wb3,
mdebug=&sasjs_mdebug
)
%let status3=0;
data _null_;
set web3.sasparams;
putlog (_all_)(=);
if status='SUCCESS' then call symputx('status3',1);
run;
%mp_assert(
iftrue=(&status3=1),
desc=Checking an inactive row does not block an unrelated derived variable,
outds=work.test_results
)
/**
* Test 4 - a delete-marked RLS rule naming a derived variable is a removal,
* so it must not abort: the rule is gone by the time the guards run
*/
%let t4=%sysfunc(datetime());
proc sql noprint;
delete from &dc_libref..mpe_derived
where upcase(libref)='DCTEST' and upcase(dsn)='DC_DERIV';
insert into &dc_libref..mpe_derived
set tx_from=0
,tx_to='31DEC5999:23:59:59'dt
,libref='DCTEST'
,dsn='DC_DERIV'
,var_nm='DERIVED_X'
,var_deps=''
,var_type='N'
,var_len=8
,var_fmt=''
,var_logic='1'
,active=1;
delete from &dc_libref..mpe_row_level_security
where upcase(rls_libref)='DCTEST' and upcase(rls_table)='DC_DERIV';
select coalesce(max(rls_rk),0)+1 into: rlsrk
from &dc_libref..mpe_row_level_security;
insert into &dc_libref..mpe_row_level_security
set tx_from=0
,tx_to='31DEC5999:23:59:59'dt
,rls_rk=&rlsrk
,rls_scope='EDIT'
,rls_group='SASAdministrators'
,rls_libref='DCTEST'
,rls_table='DC_DERIV'
,rls_group_logic='AND'
,rls_subgroup_logic='OR'
,rls_subgroup_id=0
,rls_variable_nm='DERIVED_X'
,rls_operator_nm='NE'
,rls_raw_value='1'
,rls_active=1;
quit;
data work.sascontroltable;
action='LOAD';
message='Delete-marked RLS rule on a derived variable should load';
libds="&dc_libref..MPE_ROW_LEVEL_SECURITY";
output;
stop;
run;
data work.jsdata;
set &dc_libref..mpe_row_level_security(where=(upcase(rls_libref)='DCTEST'
and upcase(rls_table)='DC_DERIV'));
length _____DELETE__THIS__RECORD_____ $3;
_____DELETE__THIS__RECORD_____='Yes';
run;
%mpe_execute(&_program,
viyacontext=&defaultcontext,
inputdatasets=work.sascontroltable work.jsdata,
outlib=web4,
outref=wb4,
mdebug=&sasjs_mdebug
)
%let status4=0;
data _null_;
set web4.sasparams;
putlog (_all_)(=);
if status='SUCCESS' then call symputx('status4',1);
run;
%mp_assert(
iftrue=(&status4=1),
desc=Checking a delete-marked RLS rule on a derived variable is not rejected,
outds=work.test_results
)
/**
* Test 5 - deleting a derived row that a surviving active row depends on must
* be rejected: the delete is not in the control table yet and the staged copy
* is delete-marked, so the inactive set has to admit it
*/
%let t5=%sysfunc(datetime());
proc sql noprint;
delete from &dc_libref..mpe_derived
where upcase(libref)='DCTEST' and upcase(dsn)='DC_DERIV';
insert into &dc_libref..mpe_derived
set tx_from=0
,tx_to='31DEC5999:23:59:59'dt
,libref='DCTEST'
,dsn='DC_DERIV'
,var_nm='BASE_X'
,var_deps=''
,var_type='N'
,var_len=8
,var_fmt=''
,var_logic='1'
,active=1;
insert into &dc_libref..mpe_derived
set tx_from=0
,tx_to='31DEC5999:23:59:59'dt
,libref='DCTEST'
,dsn='DC_DERIV'
,var_nm='USES_BASE'
,var_deps='BASE_X'
,var_type='N'
,var_len=8
,var_fmt=''
,var_logic='BASE_X + 1'
,active=1;
quit;
data work.sascontroltable;
action='LOAD';
message='Deleting a relied-on derived row should abort';
libds="&dc_libref..MPE_DERIVED";
output;
stop;
run;
data work.jsdata;
set &dc_libref..mpe_derived(where=(upcase(libref)='DCTEST'
and upcase(var_nm)='BASE_X'));
length _____DELETE__THIS__RECORD_____ $3;
_____DELETE__THIS__RECORD_____='Yes';
run;
%mpe_execute(&_program,
viyacontext=&defaultcontext,
inputdatasets=work.sascontroltable work.jsdata,
outlib=web5,
outref=wb5,
outstatus=work.ab5,
failfast=0,
mdebug=&sasjs_mdebug
)
%mpe_assert_abort(
desc=Checking deleting a relied-on derived row is rejected,
outref=wb5
)
/**
* Test 6 - a delete and re-add of the same variable in one submission must be
* accepted: the re-added active row resolves the dependency. Only a stored
* name that is not already uppercase reaches submit this way - the grid's
* CASE=UPCASE rule is a validator, and the post edit hook upcases every
* non-delete staged row, so a value typed in the grid arrives uppercase and a
* delete plus re-add of it is a duplicate primary key (rejected at approval by
* mpe_loader's raw-PK dup check). The case variant therefore pins the shape
* for a name written outside the editor.
*/
proc sql noprint;
delete from &dc_libref..mpe_derived
where upcase(libref)='DCTEST' and upcase(dsn)='DC_DERIV';
insert into &dc_libref..mpe_derived
set tx_from=0
,tx_to='31DEC5999:23:59:59'dt
,libref='DCTEST'
,dsn='DC_DERIV'
,var_nm='BASE_X'
,var_deps=''
,var_type='N'
,var_len=8
,var_fmt=''
,var_logic='1'
,active=1;
insert into &dc_libref..mpe_derived
set tx_from=0
,tx_to='31DEC5999:23:59:59'dt
,libref='DCTEST'
,dsn='DC_DERIV'
,var_nm='USES_BASE'
,var_deps='BASE_X'
,var_type='N'
,var_len=8
,var_fmt=''
,var_logic='BASE_X + 1'
,active=1;
quit;
data work.sascontroltable;
action='LOAD';
message='Deleting and re-adding a relied-on derived row should not abort';
libds="&dc_libref..MPE_DERIVED";
output;
stop;
run;
data work.jsdata;
length LIBREF $8 DSN $32 VAR_NM $32 VAR_DEPS $4000 VAR_TYPE $1
VAR_LOGIC $4000 VAR_FMT $32
_____DELETE__THIS__RECORD_____ $3;
LIBREF='DCTEST';
DSN='DC_DERIV';
VAR_NM='Base_X';
VAR_DEPS='';
VAR_TYPE='N';
VAR_LEN=8;
VAR_FMT='';
VAR_LOGIC='1';
ACTIVE=1;
_____DELETE__THIS__RECORD_____='Yes';
output;
VAR_NM='BASE_X';
_____DELETE__THIS__RECORD_____='No';
output;
run;
%mpe_execute(&_program,
viyacontext=&defaultcontext,
inputdatasets=work.sascontroltable work.jsdata,
outlib=web6,
outref=wb6,
mdebug=&sasjs_mdebug
)
%let status6=0;
data _null_;
set web6.sasparams;
putlog (_all_)(=);
if status='SUCCESS' then call symputx('status6',1);
run;
%mp_assert(
iftrue=(&status6=1),
desc=Checking a delete and re-add of the same derived variable is accepted,
outds=work.test_results
)
/**
* Test 7 - re-activating an inactive row must be accepted when another active
* row depends on it: the control table still holds the inactive copy, and the
* dependency resolves against the row this submission stages. Like Test 6 the
* starting state is out of band - the hook refuses to deactivate a row an
* active row depends on - so the check must not block the submission that
* repairs it.
*/
proc sql noprint;
delete from &dc_libref..mpe_derived
where upcase(libref)='DCTEST' and upcase(dsn)='DC_DERIV';
insert into &dc_libref..mpe_derived
set tx_from=0
,tx_to='31DEC5999:23:59:59'dt
,libref='DCTEST'
,dsn='DC_DERIV'
,var_nm='BASE_X'
,var_deps=''
,var_type='N'
,var_len=8
,var_fmt=''
,var_logic='1'
,active=0;
insert into &dc_libref..mpe_derived
set tx_from=0
,tx_to='31DEC5999:23:59:59'dt
,libref='DCTEST'
,dsn='DC_DERIV'
,var_nm='USES_BASE'
,var_deps='BASE_X'
,var_type='N'
,var_len=8
,var_fmt=''
,var_logic='BASE_X + 1'
,active=1;
quit;
data work.sascontroltable;
action='LOAD';
message='Re-activating a relied-on derived row should not abort';
libds="&dc_libref..MPE_DERIVED";
output;
stop;
run;
data work.jsdata;
length LIBREF $8 DSN $32 VAR_NM $32 VAR_DEPS $4000 VAR_TYPE $1
VAR_LOGIC $4000 VAR_FMT $32
_____DELETE__THIS__RECORD_____ $3;
LIBREF='DCTEST';
DSN='DC_DERIV';
VAR_NM='BASE_X';
VAR_DEPS='';
VAR_TYPE='N';
VAR_LEN=8;
VAR_FMT='';
VAR_LOGIC='1';
ACTIVE=1;
_____DELETE__THIS__RECORD_____='No';
run;
%mpe_execute(&_program,
viyacontext=&defaultcontext,
inputdatasets=work.sascontroltable work.jsdata,
outlib=web7,
outref=wb7,
mdebug=&sasjs_mdebug
)
%let status7=0;
data _null_;
set web7.sasparams;
putlog (_all_)(=);
if status='SUCCESS' then call symputx('status7',1);
run;
%mp_assert(
iftrue=(&status7=1),
desc=Checking re-activating a relied-on derived row is accepted,
outds=work.test_results
)
/* tidy up so repeated runs start clean */
proc sql noprint;
delete from &dc_libref..mpe_derived
where upcase(libref)='DCTEST' and upcase(dsn)='DC_DERIV';
delete from &dc_libref..mpe_row_level_security
where upcase(rls_libref)='DCTEST' and upcase(rls_table)='DC_DERIV';
quit;
@@ -0,0 +1,415 @@
/**
@file
@brief Post Edit Hook script for the MPE_DERIVED table
@details Post edit hooks provide additional backend validation for user
provided data. The incoming dataset is named `work.staging_ds` and is
provided in mpe_loader.sas.
Available macro variables:
@li DC_LIBREF - The DC control library
@li LIBREF - The library of the dataset being edited (is assigned)
@li DS - The dataset being edited
This hook normalises the incoming rows and applies the configuration rules
at submit time, so a bad derived variable is rejected while the submitter is
still watching - rather than breaking the EDIT screen later, when the view
is built.
VAR_DEPS must list every input the expression uses, physical columns
and derived variables alike. The hook checks the part that does not need
the target library: a derived variable referenced by an expression must be
declared, a variable may not depend on itself, and the resulting graph must
be acyclic - across the incoming rows and the current rows they do not
replace.
The target table is not assigned in this context, so the collision between
VAR_NM and a physical column, and the resolution of a physical dependency,
are left to %mpe_derived, which runs with the target library assigned.
Uniqueness on the business key (LIBREF DSN VAR_NM) is checked by mpe_loader
before this hook runs, but on the raw submitted values, so it is case
sensitive: 'Region' and 'REGION' for the same table both pass the loader and
collide only at approve time. The post-normalisation check below rejects
the second one at submit time, naming the variable.
<h4> SAS Macros </h4>
@li mf_nobs.sas
@li mp_abort.sas
@li mpe_derived_check.sas
<h4> Related Macros </h4>
@li mpe_derived.sas
@li mpe_loader.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.
**/
%let der_errflag=0;
%let der_errmsg=;
/* a hook is included at execution time, so this is open code - the macro
language has to be inside a macro definition for %local to be valid. The
control variables are set here, in the including scope (which %mp_include
makes local): a %global inside the macro would conflict with them and abort
the step. */
%macro _mpe_derived_postedit();
%local left nready cyc_start cyc_cur cyc_next cyc_path cyc_seen cyc_i
cyc_n cyc_found;
/* normalise the incoming values */
data work.staging_ds;
set work.staging_ds;
if upcase(_____DELETE__THIS__RECORD_____) ne 'YES' then do;
libref=upcase(strip(libref));
dsn=upcase(strip(dsn));
var_nm=upcase(strip(var_nm));
var_type=upcase(strip(var_type));
var_fmt=upcase(strip(var_fmt));
var_label=strip(var_label);
var_deps=upcase(strip(var_deps));
var_logic=strip(var_logic);
end;
run;
/**
* Duplicate names after normalisation - mpe_loader compares the raw values,
* so a case variant reaches us here
*/
proc sql;
create table work.derived_dup as
select libref, dsn, var_nm, count(*) as n
from work.staging_ds
where upcase(_____DELETE__THIS__RECORD_____) ne 'YES'
group by libref, dsn, var_nm
having count(*)>1;
quit;
data _null_;
set work.derived_dup(obs=1);
call symputx('der_errmsg'
,cats('MPE_DERIVED: ',var_nm,' is declared more than once for '
,libref,'.',dsn));
run;
%mp_abort(iftrue= (0 < %mf_nobs(work.derived_dup))
,mac=mpe_derived_postedit
,msg=%superq(der_errmsg)
)
/**
* Field level checks - the same rules %mpe_derived applies at read time
*/
%mpe_derived_check(inds=work.staging_ds, outds=work.derived_check)
%if %mf_nobs(work.derived_check)>0 %then %do;
data _null_;
set work.derived_check(obs=1);
call symputx('der_errmsg',cats('MPE_DERIVED: ',var_nm,': ',reason));
call symputx('der_errflag',1);
run;
%end;
%mp_abort(iftrue= (&der_errflag=1)
,mac=mpe_derived_postedit
,msg=%superq(der_errmsg)
)
/**
* The graph has to hold for the resulting configuration: the incoming rows
* plus the current rows of the same tables that this submission does not
* replace. Only active rows are part of it - the engine reads nothing else
* (mpe_derived.sas), so an inactive row can neither resolve a dependency nor
* be resolved by one.
*/
proc sql;
create table work.derived_rows as
select libref, dsn, upcase(strip(var_nm)) as name
,upcase(strip(var_deps)) as deps
,strip(var_logic) as logic
from work.staging_ds
where upcase(_____DELETE__THIS__RECORD_____) ne 'YES'
and active=1
union all
select libref, dsn, upcase(strip(var_nm)) as name
,upcase(strip(var_deps)) as deps
,strip(var_logic) as logic
from &DC_LIBREF..mpe_derived
where &dc_dttmtfmt. lt tx_to
and active=1
and catx('.',libref,dsn,var_nm) not in
(select catx('.',libref,dsn,var_nm) from work.staging_ds)
and catx('.',libref,dsn) in
(select catx('.',libref,dsn) from work.staging_ds);
quit;
data work.derived_dep;
set work.derived_rows;
length dep_nm $32;
do i=1 to countw(deps,' ');
dep_nm=scan(deps,i,' ');
if not missing(dep_nm) then output;
end;
keep libref dsn name dep_nm;
run;
/* a dependency on a row the engine will not read cannot resolve: it aborts
every read of the table with "VAR_DEPS lists X, which is not a column of the
table or a derived variable". Catch it here, covering a row this submission
deactivates or deletes - neither shows in the control table yet. A name the
same submission also stages as a live active row is not inactive after the
load, so it is excluded: a delete and re-add of one variable under a different
case is two PKs to the loader (its duplicate check is case sensitive) and the
re-added row resolves the dependency. */
proc sql;
create table work.derived_baddep as
select d.libref, d.dsn, d.name, d.dep_nm
from work.derived_dep d
inner join (
select upcase(strip(libref)) as libref
,upcase(strip(dsn)) as dsn
,upcase(strip(var_nm)) as name
from work.staging_ds
where (upcase(_____DELETE__THIS__RECORD_____) = 'YES'
or active ne 1)
and catx('.',upcase(strip(libref)),upcase(strip(dsn))
,upcase(strip(var_nm))) not in
(select catx('.',upcase(strip(libref)),upcase(strip(dsn))
,upcase(strip(var_nm)))
from work.staging_ds
where upcase(_____DELETE__THIS__RECORD_____) ne 'YES'
and active=1)
union all
select upcase(strip(libref)) as libref
,upcase(strip(dsn)) as dsn
,upcase(strip(var_nm)) as name
from &DC_LIBREF..mpe_derived
where &dc_dttmtfmt. lt tx_to
and active ne 1
and catx('.',upcase(strip(libref)),upcase(strip(dsn))
,upcase(strip(var_nm))) not in
(select catx('.',upcase(strip(libref)),upcase(strip(dsn))
,upcase(strip(var_nm)))
from work.staging_ds
where upcase(_____DELETE__THIS__RECORD_____) ne 'YES'
and active=1)
) inactive
on inactive.libref=d.libref
and inactive.dsn=d.dsn
and inactive.name=d.dep_nm;
quit;
data _null_;
set work.derived_baddep(obs=1);
call symputx('der_errmsg'
,cats('MPE_DERIVED: ',name,': VAR_DEPS lists ',dep_nm
,', which is not active after this load - the engine cannot resolve it'));
run;
%mp_abort(iftrue= (0 < %mf_nobs(work.derived_baddep))
,mac=mpe_derived_postedit
,msg=%superq(der_errmsg)
)
data work.derived_ref;
set work.derived_rows;
length l $4000 tok $32;
l=prxchange("s/'([^']|'')*'//",-1,logic);
l=prxchange('s/"([^"]|"")*"//',-1,l);
l=prxchange('s/[^A-Za-z0-9_]/ /',-1,upcase(l));
do i=1 to countw(l,' ');
tok=scan(l,i,' ');
if not missing(tok) then output;
end;
keep libref dsn name tok;
run;
proc sql;
create table work.derived_self as
select libref, dsn, name, dep_nm
from work.derived_dep
where name=dep_nm;
create table work.derived_undeclared as
select r.libref, r.dsn, r.name, r.tok as dep_nm
from work.derived_ref r
where exists (
select 1 from work.derived_rows n
where n.libref=r.libref and n.dsn=r.dsn and n.name=r.tok
)
and r.tok ne r.name
and not exists (
select 1 from work.derived_dep d
where d.libref=r.libref and d.dsn=r.dsn and d.name=r.name
and d.dep_nm=r.tok
);
quit;
/* the message is always prepared, the abort is driven by the count - an
iftrue=1 inside a wrapping %if leaves the step alive on some engines */
data _null_;
set work.derived_self(obs=1);
call symputx('der_errmsg'
,cats('MPE_DERIVED: ',name,': VAR_DEPS may not list itself'));
run;
%mp_abort(iftrue= (0 < %mf_nobs(work.derived_self))
,mac=mpe_derived_postedit
,msg=%superq(der_errmsg)
)
data _null_;
set work.derived_undeclared(obs=1);
call symputx('der_errmsg'
,cats('MPE_DERIVED: ',name,': VAR_LOGIC references ',dep_nm
,', which is not in VAR_DEPS'));
run;
%mp_abort(iftrue= (0 < %mf_nobs(work.derived_undeclared))
,mac=mpe_derived_postedit
,msg=%superq(der_errmsg)
)
/* the inverse of the check above: a declared name the expression never uses
is stale, and the read-time engine rejects it too */
proc sql;
create table work.derived_stale as
select d.libref, d.dsn, d.name, d.dep_nm
from work.derived_dep d
where not exists (
select 1 from work.derived_ref r
where r.libref=d.libref and r.dsn=d.dsn and r.name=d.name
and r.tok=d.dep_nm
);
quit;
data _null_;
set work.derived_stale(obs=1);
call symputx('der_errmsg'
,cats('MPE_DERIVED: ',name,': VAR_DEPS lists ',dep_nm
,', which VAR_LOGIC does not reference'));
run;
%mp_abort(iftrue= (0 < %mf_nobs(work.derived_stale))
,mac=mpe_derived_postedit
,msg=%superq(der_errmsg)
)
/* the same invariant the RLS hook enforces from its side: a derived variable
cannot be named by an active row level security rule of any scope. Only the
VIEW scope could evaluate it (on the view the EDIT screen reads), but the RLS
hook validates every rule against the physical table, where the column does
not exist, so the pairing is refused up front rather than failing there. */
proc sql;
create table work.derived_rls as
select d.libref, d.dsn, d.name
from work.derived_rows d
inner join &DC_LIBREF..mpe_row_level_security r
on upcase(r.rls_libref)=upcase(d.libref)
and upcase(r.rls_table)=upcase(d.dsn)
and upcase(r.rls_variable_nm)=upcase(d.name)
where r.rls_active=1
and &dc_dttmtfmt lt r.tx_to;
quit;
data _null_;
set work.derived_rls(obs=1);
call symputx('der_errmsg'
,cats('MPE_DERIVED: ',name,' is named by a row level security rule on '
,libref,'.',dsn,' - derived variables cannot be used there'));
run;
%mp_abort(iftrue= (0 < %mf_nobs(work.derived_rls))
,mac=mpe_derived_postedit
,msg=%superq(der_errmsg)
)
/**
* Cycle check - repeatedly take the variables whose derived inputs have all
* been placed; a round that places nothing leaves a cycle
*/
proc sql;
create table work.derived_edge as
select d.libref, d.dsn, d.name as child, d.dep_nm as parent
from work.derived_dep d
where d.dep_nm in (select name from work.derived_rows);
quit;
data work.derived_todo;
set work.derived_rows(keep=libref dsn name);
run;
%let left=%mf_nobs(work.derived_todo);
%let cyc_found=0;
%do %while (&left>0);
proc sql;
create table work.derived_ready as
select t.libref, t.dsn, t.name
from work.derived_todo t
where not exists (
select 1 from work.derived_edge e
inner join work.derived_todo t2
on e.libref=t2.libref and e.dsn=t2.dsn and e.parent=t2.name
where e.libref=t.libref and e.dsn=t.dsn and e.child=t.name
);
quit;
%let nready=%mf_nobs(work.derived_ready);
%if &nready=0 %then %do;
/* walk the edges, so the message names the cycle rather than just the
names that could not be placed */
%let cyc_n=%mf_nobs(work.derived_todo);
data _null_;
set work.derived_todo(obs=1);
call symputx('cyc_start',name);
run;
%let cyc_path=&cyc_start;
%let cyc_cur=&cyc_start;
%let cyc_seen=%str(.&cyc_start..);
%do cyc_i=1 %to &cyc_n;
%let cyc_next=;
proc sql noprint;
select e.parent into: cyc_next
from work.derived_edge e
where e.child="&cyc_cur"
and catx('.',e.libref,e.dsn,e.parent) in
(select catx('.',libref,dsn,name) from work.derived_todo);
quit;
%if %length(&cyc_next)=0 %then %let cyc_i=&cyc_n;
%else %do;
%let cyc_path=&cyc_path. -> &cyc_next;
%if %index(&cyc_seen,%str(.&cyc_next..))>0 %then %let cyc_i=&cyc_n;
%else %do;
%let cyc_seen=&cyc_seen.&cyc_next..;
%let cyc_cur=&cyc_next;
%end;
%end;
%end;
/* leave the loop before aborting - an abort that only cancels the step
would re-enter it with the ready count still zero */
%let cyc_found=1;
%let left=0;
%end;
%if &cyc_found=0 %then %do;
proc sql;
delete from work.derived_todo
where catx('.',libref,dsn,name) in
(select catx('.',libref,dsn,name) from work.derived_ready);
quit;
%let left=%mf_nobs(work.derived_todo);
%end;
%end;
%mp_abort(iftrue= (&cyc_found=1)
,mac=mpe_derived_postedit
,msg=%str(MPE_DERIVED: VAR_DEPS contains a cycle: &cyc_path)
)
%mend _mpe_derived_postedit;
%_mpe_derived_postedit()
@@ -18,6 +18,7 @@
<h4> SAS Macros </h4>
@li dc_assignlib.sas
@li mf_nobs.sas
@li mp_abort.sas
@li mp_filtercheck.sas
@@ -54,7 +55,7 @@ run;
) change the loadtype.)
)
/* ignore scope and group for validation */
/* group and subgroup are not used for validation */
proc sql;
create table work.batches as
select distinct upcase(rls_libref) as rls_libref,
@@ -67,6 +68,7 @@ create table work.batches as
rls_raw_value as raw_value
from work.staging_ds
where rls_active=1
and upcase(_____DELETE__THIS__RECORD_____) ne 'YES'
order by rls_libref, rls_table;
%let cnt=0;
@@ -81,6 +83,37 @@ data _null_;
end;
run;
/* derived variables exist only in the view the EDIT screen reads - a rule on
one cannot be validated against the physical table, so reject it here rather
than let mp_filtercheck fail with a variable-not-found error. Applies to
every scope: the VIEW scope could evaluate the column, but this hook checks
rules against the table they are submitted for. */
proc sql;
create table work.rls_derived as
select b.rls_libref, b.rls_table, b.variable_nm
from work.batches b
inner join &DC_LIBREF..mpe_derived d
on upcase(d.libref)=b.rls_libref
and upcase(d.dsn)=b.rls_table
and upcase(d.var_nm)=upcase(b.variable_nm)
where &dc_dttmtfmt lt d.tx_to
and d.active=1;
quit;
%let rls_msg=;
data _null_;
set work.rls_derived(obs=1);
call symputx('rls_msg'
,cats('MPE_ROW_LEVEL_SECURITY: ',variable_nm
,' is a derived variable in ',rls_libref,'.',rls_table
,' - derived variables cannot be used in a row level security rule'));
run;
%mp_abort(iftrue= (0 < %mf_nobs(work.rls_derived))
,mac=mpe_row_level_security_postedit
,msg=%superq(rls_msg)
)
%macro quickloop();
%do i=1 %to &cnt;
data work.inds&i;
+9 -12
View File
@@ -12,8 +12,9 @@
assertion cannot pass against a service that does not validate.
<h4> SAS Macros </h4>
@li mpe_assert_abort.sas
@li mp_assert.sas
@li mx_execute.sas
@li mpe_execute.sas
@li mf_getuniquefileref.sas
**/
@@ -30,23 +31,19 @@ data _null_;
put '%sysfunc(coalescec(&dclib..MPE_X_TEST,))';
run;
%mx_execute(&_program,
%mpe_execute(&_program,
viyacontext=&defaultcontext,
inputfiles=&f1:iwant,
outref=web1,
outstatus=work.ab1,
failfast=0,
viyaresult=WEBOUT_TXT
)
%let abort1=0;
data _null_;
set work.results;
if state='canceled' then call symputx('abort1',1);
run;
%mp_assert(
iftrue=(&abort1=1),
%mpe_assert_abort(
desc=Macro content in libds aborts the service,
outds=work.test_results
outref=web1,
msg=Invalid libds
)
/**
@@ -59,7 +56,7 @@ data _null_;
put "&dclib..MPE_X_TEST";
run;
%mx_execute(&_program,
%mpe_execute(&_program,
viyacontext=&defaultcontext,
inputfiles=&f2:iwant,
outlib=web2
+25 -6
View File
@@ -35,13 +35,16 @@
<h4> SAS Macros </h4>
@li mf_existds.sas
@li mf_verifymacvars.sas
@li mf_nobs.sas
@li mpeinit.sas
@li mpeterm.sas
@li dc_assignlib.sas
@li mf_getvarformat.sas
@li mp_abort.sas
@li mp_cntlout.sas
@li mp_filtercheck.sas
@li mp_filtergenerate.sas
@li mpe_derived.sas
@li mp_validatecol.sas
@li mpe_validatecol.sas
@@ -109,7 +112,10 @@ run;
%let startrow=1;
%let rows=4000;
%mp_abort(iftrue= (%mf_verifymacvars(libds col2)=0)
/* a request with no rows never reached the validation below, so this is the
only case that is missing inputs - an invalid value is a different abort, and
saying "missing" for it would hide the reason */
%mp_abort(iftrue= (%mf_nobs(work.iwant)<=0)
,mac=&_program..sas
,msg=%str(Missing inputs from iwant)
)
@@ -150,10 +156,23 @@ run;
)
/**
* Derived (computed) variables - these exist only in a view, so the filter
* must be validated and applied against that view
*/
%mpe_derived(libds=&libds,
dclib=&mpelib,
outref=dercode,
outview=work.vw_derived,
outvars=work.derived_vars
)
%let derived_nobs=%mf_nobs(work.derived_vars);
%let filterds=%sysfunc(ifc(&derived_nobs>0,work.vw_derived,&libds));
/**
* Validate the filter query
*/
%mp_filtercheck(work.filterquery,targetds=&libds,abort=YES)
%mp_filtercheck(work.filterquery,targetds=&filterds,abort=YES)
/**
* Prepare the query
@@ -162,14 +181,14 @@ run;
/* cannot %inc in a sql where clause, only data step, so - use a view */
data work.vw_vals/view=work.vw_vals;
set &libds;
set &filterds;
where %inc myfilter;;
run;
proc sql;
create view work.vw_vals_sorted as
select distinct
put(&col2,%mf_getVarFormat(&libds,&col2,force=1)) as formatted,
put(&col2,%mf_getVarFormat(work.vw_vals,&col2,force=1)) as formatted,
&col2 as unformatted
from work.vw_vals;
@@ -194,7 +213,7 @@ proc sql noprint;
select count(*) into: nobs from work.vw_vals_sorted;
data meta;
column="&col2";
sasformat="%mf_getVarFormat(&libds,&col2)";
sasformat="%mf_getVarFormat(work.vw_vals,&col2)";
startrow=&startrow;
rows=&rows;
nobs=&nobs;
@@ -4,6 +4,7 @@
<h4> SAS Macros </h4>
@li mp_assertcolvals.sas
@li mp_assertdsobs.sas
@li mx_execute.sas
@li mf_getuniquefileref.sas
@@ -36,3 +37,54 @@ run;
desc=DCLIB found in unfiltered getgolvals response,
test=ANYVAL
)
/**
* derived variable
*/
proc sql;
delete from &dc_libref..mpe_derived
where upcase(libref)="%scan(&dclib..MPE_X_TEST,1,.)"
and upcase(dsn)="%scan(&dclib..MPE_X_TEST,2,.)";
insert into &dc_libref..mpe_derived set
tx_from=0
,tx_to='31DEC5999:23:59:59'dt
,libref="%scan(&dclib..MPE_X_TEST,1,.)"
,dsn="%scan(&dclib..MPE_X_TEST,2,.)"
,var_nm='DERIVED_KEY'
,var_deps='PRIMARY_KEY_FIELD'
,var_type='C'
,var_len=20
,var_fmt=''
,var_logic='cats(primary_key_field)'
,active=1;
quit;
%let f4=%mf_getuniquefileref();
data _null_;
file &f4 termstr=crlf;
put 'LIBDS:$43. COL:$32.';
put "&dclib..MPE_X_TEST,DERIVED_KEY";
run;
%mx_execute(&_program,
viyacontext=&defaultcontext,
inputfiles=&f4:iwant,
outlib=web4
)
data work.vals4;
set web4.vals;
putlog (_all_)(=);
run;
%mp_assertdsobs(work.vals4,
desc=Values are returned for a derived column,
test=HASOBS
)
/* tidy up the config so repeated runs start clean */
proc sql;
delete from &dc_libref..mpe_derived
where var_nm='DERIVED_KEY'
and upcase(libref)="%scan(&dclib..MPE_X_TEST,1,.)"
and upcase(dsn)="%scan(&dclib..MPE_X_TEST,2,.)";
quit;
+19 -26
View File
@@ -13,8 +13,9 @@
validate.
<h4> SAS Macros </h4>
@li mpe_assert_abort.sas
@li mp_assert.sas
@li mx_execute.sas
@li mpe_execute.sas
@li mf_getuniquefileref.sas
**/
@@ -27,27 +28,23 @@
%let f1=%mf_getuniquefileref();
data _null_;
file &f1 termstr=crlf;
put 'LIBDS:$19. COL:$9.';
put 'LIBDS:$100. COL:$100.';
put '%sysfunc(coalescec(&dclib..MPE_X_TEST,)),SOME_TIME';
run;
%mx_execute(&_program,
%mpe_execute(&_program,
viyacontext=&defaultcontext,
inputfiles=&f1:iwant,
outref=web1,
outstatus=work.ab1,
failfast=0,
viyaresult=WEBOUT_TXT
)
%let abort1=0;
data _null_;
set work.results;
if state='canceled' then call symputx('abort1',1);
run;
%mp_assert(
iftrue=(&abort1=1),
%mpe_assert_abort(
desc=Macro content in libds aborts the service,
outds=work.test_results
outref=web1,
msg=Invalid libds
)
/**
@@ -56,27 +53,23 @@ run;
%let f2=%mf_getuniquefileref();
data _null_;
file &f2 termstr=crlf;
put 'LIBDS:$19. COL:$9.';
put '&dclib..MPE_X_TEST,%sysfunc(coalescec(SOME_TIME,))';
put 'LIBDS:$100. COL:$100.';
put "&dclib..MPE_X_TEST,%nrstr(%sysfunc(coalescec(SOME_TIME,)))";
run;
%mx_execute(&_program,
%mpe_execute(&_program,
viyacontext=&defaultcontext,
inputfiles=&f2:iwant,
outref=web2,
outstatus=work.ab2,
failfast=0,
viyaresult=WEBOUT_TXT
)
%let abort2=0;
data _null_;
set work.results;
if state='canceled' then call symputx('abort2',1);
run;
%mp_assert(
iftrue=(&abort2=1),
%mpe_assert_abort(
desc=Macro content in col aborts the service,
outds=work.test_results
outref=web2,
msg=Invalid col
)
/**
@@ -85,11 +78,11 @@ run;
%let f3=%mf_getuniquefileref();
data _null_;
file &f3 termstr=crlf;
put 'LIBDS:$19. COL:$9.';
put 'LIBDS:$100. COL:$100.';
put "&dclib..MPE_X_TEST,SOME_TIME";
run;
%mx_execute(&_program,
%mpe_execute(&_program,
viyacontext=&defaultcontext,
inputfiles=&f3:iwant,
outlib=web3
+3 -3
View File
@@ -15,7 +15,7 @@
data work.params;
length name $32 value $1000;
name='libref';value="&dclib";output;
name='ds';value='';output;
name='ds';value='MPE_TABLES';output;
name='flavour';value='TSQL';output;
run;
%mx_execute(&_program,
@@ -31,7 +31,7 @@ data work.results;
input;
putlog _infile_;
if _n_=2 then do;
if _infile_=:'/* TSQL Flavour DDL for' then do;
if _infile_=:'/* TSQL Flavour DDL for' and index(_infile_,'MPE_TABLES')>0 then do;
putlog 'test passed';
output;
output;
@@ -42,7 +42,7 @@ data work.results;
run;
%mp_assertdsobs(work.results,
desc=DDL file is successfully returned,
desc=DDL file is successfully returned for a table,
test=EQUALS 2,
outds=work.test_results
)
+25 -1
View File
@@ -21,6 +21,8 @@
@li mp_ds2csv.sas
@li mp_streamfile.sas
@li mp_validatecol.sas
@li mf_nobs.sas
@li mpe_derived.sas
@li mpe_validatecol.sas
@li mpe_filtermaster.sas
@@ -143,10 +145,32 @@ data _null_;
putlog _infile_;
run;
/* derived variables exist only in a view, so a filter that references one
must be applied against it. They are dropped from the download - they are
an EDIT screen feature. */
%mpe_derived(libds=&libds,
dclib=&mpelib,
outref=dercode,
outview=work.vw_derived,
outvars=work.derived_vars
)
%let derived_nobs=%mf_nobs(work.derived_vars);
%let derived_droplist=;
%let filterds=%sysfunc(ifc(&derived_nobs>0,work.vw_derived,&libds));
%if &derived_nobs>0 %then %do;
proc sql noprint;
select name into: derived_droplist separated by ' '
from work.derived_vars;
quit;
%end;
options obs=200000;/* stop limit */
data staged(drop=&txfrom &txto);
set &libds;
set &filterds;
where %inc filtref;;
%if %length(&derived_droplist)>0 %then %do;
drop &derived_droplist;
%end;
run;
options obs=max;
+21 -32
View File
@@ -13,8 +13,9 @@
validate.
<h4> SAS Macros </h4>
@li mpe_assert_abort.sas
@li mp_assert.sas
@li mx_execute.sas
@li mpe_execute.sas
**/
@@ -31,23 +32,19 @@ data work.params1;
name='filter';value='0';output;
run;
%mx_execute(&_program,
%mpe_execute(&_program,
viyacontext=&defaultcontext,
inputparams=work.params1,
outref=web1,
outstatus=work.ab1,
failfast=0,
viyaresult=WEBOUT_TXT
)
%let abort1=0;
data _null_;
set work.results;
if state='canceled' then call symputx('abort1',1);
run;
%mp_assert(
iftrue=(&abort1=1),
%mpe_assert_abort(
desc=Macro content in table aborts the service,
outds=work.test_results
outref=web1,
msg=Invalid table
)
/**
@@ -60,23 +57,19 @@ data work.params2;
name='filter';value='0 or 1=1';output;
run;
%mx_execute(&_program,
%mpe_execute(&_program,
viyacontext=&defaultcontext,
inputparams=work.params2,
outref=web2,
outstatus=work.ab2,
failfast=0,
viyaresult=WEBOUT_TXT
)
%let abort2=0;
data _null_;
set work.results;
if state='canceled' then call symputx('abort2',1);
run;
%mp_assert(
iftrue=(&abort2=1),
%mpe_assert_abort(
desc=SQL injection in filter aborts the service,
outds=work.test_results
outref=web2,
msg=Invalid filter
)
/**
@@ -91,23 +84,19 @@ data work.params3;
name='filter';value='0';output;
run;
%mx_execute(&_program,
%mpe_execute(&_program,
viyacontext=&defaultcontext,
inputparams=work.params3,
outref=web3,
outstatus=work.ab3,
failfast=0,
viyaresult=WEBOUT_TXT
)
%let abort3=0;
data _null_;
set work.results;
if state='canceled' then call symputx('abort3',1);
run;
%mp_assert(
iftrue=(&abort3=1),
%mpe_assert_abort(
desc=Path traversal in table aborts the service,
outds=work.test_results
outref=web3,
msg=Invalid table
)
/**
@@ -120,7 +109,7 @@ data work.params4;
name='filter';value='0';output;
run;
%mx_execute(&_program,
%mpe_execute(&_program,
viyacontext=&defaultcontext,
inputparams=work.params4,
outref=web4,
@@ -26,8 +26,10 @@
<h4> SAS Macros </h4>
@li dc_assignlib.sas
@li mf_nobs.sas
@li mp_abort.sas
@li mp_filterstore.sas
@li mpe_derived.sas
@li mpe_validatecol.sas
@li removecolsfromwork.sas
@@ -69,8 +71,31 @@ run;
%dc_assignlib(WRITE,%scan(&ds,1,.))
/* derived variables exist only in a view, so validate against that */
%mpe_derived(libds=&ds,
dclib=&dc_libref,
outref=dercode,
outview=work.vw_derived,
outvars=work.derived_vars
)
%let derived_nobs=%mf_nobs(work.derived_vars);
/* targetds is passed as before for a plain dataset, and as the view when the
filter can reference derived columns. A format catalog is the one case that
must be left alone: mp_filterstore rewrites a -FC libds to a cntlout dataset
itself, and a -FC name handed over as targetds is not something it can read.
The trailing comma lets the call below read targetds=..., queryds=... */
%let targetds_opt=;
%if &derived_nobs>0 %then %do;
%let targetds_opt=targetds=work.vw_derived,;
%end;
%else %if %length(&ds)>3 and "%upcase(%substr(&ds,%length(&ds)-2,3))" ne "-FC" %then %do;
%let targetds_opt=targetds=&ds,;
%end;
%mp_filterstore(
libds=&ds,
&targetds_opt
queryds=work.filterquery,
filter_summary=&dc_libref..mpe_filteranytable,
filter_detail=&dc_libref..mpe_filtersource,
@@ -12,8 +12,9 @@
assertion cannot pass against a service that does not validate.
<h4> SAS Macros </h4>
@li mpe_assert_abort.sas
@li mp_assert.sas
@li mx_execute.sas
@li mpe_execute.sas
@li mf_getuniquefileref.sas
**/
@@ -30,23 +31,19 @@ data _null_;
put '%sysfunc(coalescec(&dclib..MPE_TABLES,))';
run;
%mx_execute(&_program,
%mpe_execute(&_program,
viyacontext=&defaultcontext,
inputfiles=&f1:iwant,
outref=web1,
outstatus=work.ab1,
failfast=0,
viyaresult=WEBOUT_TXT
)
%let abort1=0;
data _null_;
set work.results;
if state='canceled' then call symputx('abort1',1);
run;
%mp_assert(
iftrue=(&abort1=1),
%mpe_assert_abort(
desc=Macro content in filter_table aborts the service,
outds=work.test_results
outref=web1,
msg=Invalid filter_table
)
/**
@@ -71,7 +68,7 @@ AND,OR,2,DSN,=,"'MPE_LOCK_ANYTABLE'"
;;;;
run;
%mx_execute(&_program,
%mpe_execute(&_program,
viyacontext=&defaultcontext,
inputfiles=&f2:iwant &f3:filterquery,
outlib=web2
@@ -48,6 +48,8 @@ run;
data work.iwant;
filter_table='DCTEST.DCFMTS-FC';
/* mx_execute reads the program to run from the input dataset */
_program="&_program";
run;
data work.filterquery;
GROUP_LOGIC='AND';
@@ -77,3 +79,66 @@ run;
desc=Test2 result filter,
test=EQUALS 1
)
/**
* Test 3 - filter on a derived variable
*/
proc sql;
delete from &dc_libref..mpe_derived
where upcase(libref)="%scan(&dclib..MPE_TABLES,1,.)"
and upcase(dsn)="%scan(&dclib..MPE_TABLES,2,.)";
insert into &dc_libref..mpe_derived set
tx_from=0
,tx_to='31DEC5999:23:59:59'dt
,libref="%scan(&dclib..MPE_TABLES,1,.)"
,dsn="%scan(&dclib..MPE_TABLES,2,.)"
,var_nm='DERIVED_NOTE'
,var_deps='LIBREF DSN'
,var_type='C'
,var_len=41
,var_fmt=''
,var_logic="cats(libref,'.',dsn)"
,active=1;
quit;
data work.iwant;
filter_table="&dclib..MPE_TABLES";
run;
data work.filterquery;
GROUP_LOGIC='AND';
SUBGROUP_LOGIC='AND';
SUBGROUP_ID=1;
VARIABLE_NM='DERIVED_NOTE';
OPERATOR_NM='CONTAINS';
RAW_VALUE="'MPE_LOCK'";
output;
run;
%mx_execute(&_program,
viyacontext=&defaultcontext,
inputdatasets=work.iwant work.filterquery,
outlib=web3,
outref=web3ref
)
data _null_;
infile web3ref;
input;
putlog _infile_;
run;
data result;
set web3.result;
putlog (_all_)(=);
run;
%mp_assertdsobs(work.result,
desc=Test3 derived filter,
test=EQUALS 1
)
/* tidy up the config so repeated runs start clean */
proc sql;
delete from &dc_libref..mpe_derived
where var_nm='DERIVED_NOTE'
and upcase(libref)="%scan(&dclib..MPE_TABLES,1,.)"
and upcase(dsn)="%scan(&dclib..MPE_TABLES,2,.)";
quit;
+32 -4
View File
@@ -59,6 +59,7 @@
@li mp_searchdata.sas
@li mp_validatecol.sas
@li mpe_columnlevelsecurity.sas
@li mpe_derived.sas
@li mpe_dsmeta.sas
@li mpe_getversions.sas
@li mpe_filtermaster.sas
@@ -218,6 +219,25 @@ run;
putlog _infile_;
run;
/* derived variables exist only in a view, so a filter that references one
must be applied against it. The columns themselves are dropped from the
output - they are an EDIT screen feature. */
%mpe_derived(libds=&libds,
dclib=&mpelib,
outref=dercode,
outview=work.vw_derived,
outvars=work.derived_vars
)
%let derived_nobs=%mf_nobs(work.derived_vars);
%let derived_droplist=;
%let filterds=%sysfunc(ifc(&derived_nobs>0,work.vw_derived,&libds));
%if &derived_nobs>0 %then %do;
proc sql noprint;
select name into: derived_droplist separated by ' '
from work.derived_vars;
quit;
%end;
%if &searchtype=NONE or "%trim(&searchtype) " = " " %then %do;
/* get row count */
filename rows temp;
@@ -227,7 +247,7 @@ run;
input;
if _n_=1 then do;
put 'proc sql;';
put "select count(*) into: dsobs from &libds where";
put "select count(*) into: dsobs from &filterds where";
end;
put _infile_;
if eof then put ';';
@@ -242,8 +262,11 @@ run;
/* send actual data, filtered and row-capped */
data work.viewdata;
set &libds;
set &filterds;
where %inc filtref;;
%if %length(&derived_droplist)>0 %then %do;
drop &derived_droplist;
%end;
if _n_>&DC_MAXOBS_WEBVIEW then stop;
run;
%if %mf_nobs(work.viewdata)=0 %then %do;
@@ -256,8 +279,11 @@ run;
%end;
%else %do;
data work.vwsearch/view=work.vwsearch;
set &libds;
set &filterds;
where %inc filtref;;
%if %length(&derived_droplist)>0 %then %do;
drop &derived_droplist;
%end;
run;
%if %upcase(&searchtype)=CHAR %then %do;
%mp_searchdata(lib=work
@@ -275,6 +301,8 @@ run;
%end;
%if %mf_existds(libds=MPSEARCH.vwsearch) %then %do;
%let dsobs=%mf_nobs(MPSEARCH.vwsearch);
/* the derived columns were dropped from work.vwsearch above, so
mp_searchdata's output cannot carry them */
data viewdata;
set MPSEARCH.vwsearch;
if _n_<=&DC_MAXOBS_WEBVIEW;
@@ -283,7 +311,7 @@ run;
%else %do;
%let dsobs=0;
data viewdata;
set &libds;
set &filterds;
stop;
run;
%end;
@@ -57,7 +57,7 @@ data _null_;
run;
%mp_assert(
iftrue=(&nobs=&expected),
desc=&desc (&searchtype "%superq(searchval)" returned &nobs rows, expected &expected),
desc=%str(&desc (&searchtype "%superq(searchval)" returned &nobs rows, expected &expected)),
outds=work.test_results
)
%mend search;
@@ -118,6 +118,6 @@ proc sql noprint;
quit;
%mp_assert(
iftrue=(&caprows=&capnobs and &capnobs gt 0 and &capnobs le &capmax),
desc=Checking a search beyond the cap returns as many rows as it reports (&capnobs reported, &caprows returned, cap &capmax),
desc=%str(Checking a search beyond the cap returns as many rows as it reports (&capnobs reported, &caprows returned, cap &capmax)),
outds=work.test_results
)
@@ -44,4 +44,12 @@ run;
options metarepository=&repo;
%if "&sasjs_mdebug"="1" %then %do;
data _null_;
set &outds;
putlog "NOTE: &sysmacroname: &outds:";
putlog (_all_)(=);
run;
%end;
%mend dc_getlibs;
@@ -20,7 +20,15 @@ create table &outds as
,engine
,'' as libraryid length=17
from dictionary.libnames
where libname not in ('WORK','SASUSER');
where upcase(libname) not in ("WORK","SASUSER","%upcase(&dc_libref)");
insert into &syslast values ("&DC_LIBREF", "&DC_LIBNAME",'','V9');
%if "&sasjs_mdebug"="1" %then %do;
data _null_;
set &outds;
putlog "NOTE: &sysmacroname: &outds:";
putlog (_all_)(=);
run;
%end;
%mend dc_getlibs;
@@ -6,6 +6,11 @@
The MPELIB should be pre-assigned
The refresh macros are called from the statements this macro writes into a
catalog, so they are not visible as calls to lint.
@sasjslint {"noUnusedMacros": false}
<h4> SAS Macros </h4>
@li mpe_refreshlibs.sas
@li dc_assignlib.sas
@@ -32,12 +37,17 @@ data libraries;
where also libref="&libref";
%end;
file executor;
str=cats('%mpe_refreshtables(',libref,')');
str=cats('%mpe_refreshtables(',libref,');');
put str;
putlog str;
str=cats('%mpe_refreshcatalogs(',libref,')');
str=cats('%mpe_refreshcatalogs(',libref,');');
put str;
run;
%inc executor/source2;
%if "&sasjs_mdebug"="1" %then %do;
/* echo the generated statements, so a failure inside one of them can be
traced back to the libref that produced it */
%inc executor/source2;
%end;
%else %inc executor;
%mend dc_refreshcatalog;
@@ -6,8 +6,10 @@
It also adds the STAGING directory as subdirectory to the BASE
library location.
@warning This STP self destructs! It will delete itself after a successful run
to avoid being executed twice (and overwriting actual data)
@warning This STP self destructs! It will delete itself (and the
configurator) after a successful run, to avoid being executed twice and
overwriting actual data. Pass a KEEPME column in the input data to keep
them - test harnesses reseed before every run and cannot redeploy first.
<h4> SAS Macros </h4>
@li mf_getapploc.sas
@@ -55,6 +57,24 @@ data _null_;
run;
%end;
/* optional KEEPME column - keep this program and the configurator on the drive
* after a successful run. A normal install self-destructs so the seeding
* cannot be run twice over live data; a test harness reseeds before every run
* and wants them to stay put. */
%let keepme=0;
%if &exist=1 %then %do;
data _null_;
dsid=open('work.fromjs');
/* every row, not just the first: KEEPME may be set on any of them */
if varnum(dsid,'KEEPME')>0 then do until(eof);
set work.fromjs end=eof;
if upcase(cats(keepme)) in ('1','Y','YES','TRUE') then call symputx('keepme',1);
end;
rc=close(dsid);
stop;
run;
%end;
%mp_abort(iftrue= (&syscc ne 0)
,mac=&_program
,msg=%str(Issue on makedata entry)
@@ -191,7 +211,13 @@ data _null_;
run;
%end;
/* We ran successfully, now remove configurator and makedata STPs */
%ms_deletefile(&root/services/admin/configurator.sas)
%ms_deletefile(&_program..sas)
/* We ran successfully, now remove configurator and makedata STPs, unless the
* caller asked to keep them (KEEPME in the input data). Keeping them is for
* test harnesses, which reseed before every run and cannot redeploy first. */
%if &keepme ne 1 %then %do;
%ms_deletefile(&root/services/admin/configurator.sas)
%ms_deletefile(&_program..sas)
%end;
%else %put NOTE: &_program retained, as KEEPME was requested;
@@ -21,7 +21,15 @@ create table &outds as
,engine
,'' as libraryid length=17
from dictionary.libnames
where libname not in ('WORK','SASUSER','CASUSER');
where upcase(libname) not in ("WORK","SASUSER","CASUSER","%upcase(&dc_libref)");
insert into &syslast values ("&DC_LIBREF", "&DC_LIBNAME",'','V9');
%if "&sasjs_mdebug"="1" %then %do;
data _null_;
set &outds;
putlog "NOTE: &sysmacroname: &outds:";
putlog (_all_)(=);
run;
%end;
%mend dc_getlibs;
@@ -6,6 +6,11 @@
The MPELIB should be pre-assigned
The refresh macros are called from the statements this macro writes into a
catalog, so they are not visible as calls to lint.
@sasjslint {"noUnusedMacros": false}
<h4> SAS Macros </h4>
@li mpe_refreshlibs.sas
@li dc_assignlib.sas
@@ -36,13 +41,18 @@
set &mpelib..mpe_datacatalog_libs;
where &dc_dttmtfmt. le TX_TO;
file executor;
str=cats('%mpe_refreshtables(',libref,')');
str=cats('%mpe_refreshtables(',libref,');');
put str;
str=cats('%mpe_refreshcatalogs(',libref,')');
str=cats('%mpe_refreshcatalogs(',libref,');');
put str;
putlog str;
run;
%inc executor;
%if "&sasjs_mdebug"="1" %then %do;
/* echo the generated statements, so a failure inside one of them can be
traced back to the libref that produced it */
%inc executor/source2;
%end;
%else %inc executor;
%end;
%mend dc_refreshcatalog;
@@ -9,7 +9,6 @@
@li mf_mkdir.sas
@li mf_trimstr.sas
@li mp_abort.sas
@li mpe_getvars.sas
@li mpe_makedata.sas
@li mpe_makedatamodel.sas
@li mpe_makesampledata.sas
@@ -33,6 +32,24 @@
%let exist=%sysfunc(exist(work.fromjs));
%let inds=%sysfunc(ifc(&exist=1,fromjs,_null_));
/* optional KEEPME column - keep this program on the content server after a
* successful run. A normal install self-destructs so the seeding cannot be
* run twice over live data; a test harness reseeds once and then re-runs
* individual tests, so it wants the program to stay put. */
%let keepme=0;
%if &exist=1 %then %do;
data _null_;
dsid=open('work.fromjs');
/* every row, not just the first: KEEPME may be set on any of them */
if varnum(dsid,'KEEPME')>0 then do until(eof);
set work.fromjs end=eof;
if upcase(cats(keepme)) in ('1','Y','YES','TRUE') then call symputx('keepme',1);
end;
rc=close(dsid);
stop;
run;
%end;
data _null_;
set &inds;
call symputx('dcpath',dcpath);
@@ -278,4 +295,9 @@ run;
%webout(OBJ,result)
%webout(CLOSE)
%mv_deletejes(path=&root/admin, name=makedata)
/* keep the program when KEEPME was requested, so a reseed does not need a
* redeploy first */
%if &keepme ne 1 %then %do;
%mv_deletejes(path=&root/admin, name=makedata)
%end;
%else %put NOTE: &_program retained, as KEEPME was requested;
+7 -5
View File
@@ -55,6 +55,8 @@ run;
%dc_getsettings()
/**
* Set up Test library
*/
@@ -192,7 +194,7 @@ options mprint;
%else %if &pf=SASJS %then %do;
%mx_getcode(&root/services/public/settings,outref=stngs)
filename sendback "&sasjswork/settings2.txt" lrecl=1000;
filename sendback "&_SASJS_WORK/settings2.txt" lrecl=1000;
data _null_;
file sendback;
infile stngs end=last lrecl=1000;
@@ -223,11 +225,11 @@ options mprint;
%mm_getstpcode(tree=&root/services/public
,name=Data_Controller_Settings
,outloc=&sasjswork/settings.txt
,outloc=&_SASJS_WORK/settings.txt
)
data _null_;
file "&sasjswork/settings2.txt";
infile "&sasjswork/settings.txt" end=last;
file "&_SASJS_WORK/settings2.txt";
infile "&_SASJS_WORK/settings.txt" end=last;
input;
put _infile_;
if last then do;
@@ -238,7 +240,7 @@ options mprint;
%mm_deletestp(target=&root/services/public/Data_Controller_Settings)
%mm_createstp(stpname=Data_Controller_Settings
,filename=settings2.txt
,directory=&sasjswork
,directory=&_SASJS_WORK
,tree=&root/services/public
,Server=SASApp
,stptype=2