Compare commits
175
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4ec4ec629 | ||
|
|
619fe9f9b9 | ||
|
|
7830b7bcfa | ||
|
|
f8f65681e1 | ||
|
|
7fb981daa9 | ||
|
|
469229aecb | ||
|
|
6c7c8ebf74 | ||
|
|
468bdf5ab3 | ||
|
|
e0da9f1098 | ||
|
|
cfe6647ca8 | ||
|
|
cdfb3d01aa | ||
|
|
e80ca2af4d | ||
|
|
81ccf758e9 | ||
|
|
7efbf31ea5 | ||
|
|
9b3cf50b9c | ||
|
|
e929ca047f | ||
|
|
d95ffdfb97 | ||
|
|
87e794f769 | ||
|
|
f182179403 | ||
|
|
803bb1ad76 | ||
|
|
1863076bc9 | ||
|
|
36e4e88eb1 | ||
|
|
1e224763ba | ||
|
|
059cdf176a | ||
|
|
45dce7b11b | ||
|
|
ddb1671e37 | ||
|
|
da30266278 | ||
|
|
fdcd8fa4b3 | ||
|
|
220408538b | ||
|
|
4bcee706ff | ||
|
|
ded8bd13b3 | ||
|
|
f2b1428ec3 | ||
|
|
ea300f73e7 | ||
|
|
359b570b85 | ||
|
|
d626cb8f2d | ||
|
|
0a52d20c5f | ||
|
|
a03638667f | ||
|
|
638c34a070 | ||
|
|
9aef2f4f03 | ||
|
|
9aea59dfef | ||
|
|
6513d09f05 | ||
|
|
356308e13f | ||
|
|
46bacdd6d9 | ||
|
|
582523dc87 | ||
|
|
105052b948 | ||
|
|
d0bc5ae297 | ||
|
|
31aa859153 | ||
|
|
a141e38da4 | ||
|
|
e3ec1da55c | ||
|
|
9c294c0ab5 | ||
|
|
fed43cb07e | ||
|
|
a20a5a627c | ||
|
|
f5aade5976 | ||
|
|
867faf8dd8 | ||
|
|
609f2a743c | ||
|
|
16e9b31e65 | ||
|
|
8096772119 | ||
|
|
36e168971c | ||
|
|
0d58be975d | ||
|
|
fe2388718a | ||
|
|
05b91cb43e | ||
|
|
072a133702 | ||
|
|
116c37b379 | ||
|
|
1fb4818d21 | ||
|
|
78e88a4112 | ||
|
|
bca1b9363f | ||
|
|
1b13120839 | ||
|
|
6cad23fce9 | ||
|
|
5fc0ff1ee3 | ||
|
|
5b70c000d7 | ||
|
|
d7cadc0b7b | ||
|
|
9914790777 | ||
|
|
e0ee99c178 | ||
|
|
5d183d1350 | ||
|
|
fa7a468fd3 | ||
|
|
d14395a0f8 | ||
|
|
f411b7d269 | ||
|
|
be8f445ae1 | ||
|
|
293022d46b | ||
|
|
60ec470404 | ||
|
|
ee3c8787ef | ||
|
|
5f443b0b16 | ||
|
|
3e8f200a1a | ||
|
|
03b67c44c7 | ||
|
|
4d462d310f | ||
|
|
1f1028a2a5 | ||
|
|
a07728be2b | ||
|
|
7d9df71fdb | ||
|
|
75b4acf1b1 | ||
|
|
73d25b1220 | ||
|
|
c4021a42ff | ||
|
|
698dea6d6b | ||
|
|
489819b051 | ||
|
|
0b7cf6a724 | ||
|
|
62f55db170 | ||
|
|
752d944c20 | ||
|
|
50f09c8db8 | ||
|
|
fb36379520 | ||
|
|
6e70d203d1 | ||
|
|
3ec53076e7 | ||
|
|
bb8d007e2d | ||
|
|
a128b609f8 | ||
|
|
37abc3adb7 | ||
|
|
ab36ff8671 | ||
|
|
56ed429183 | ||
|
|
6b5bf3e6be | ||
|
|
033ffd3025 | ||
|
|
ac97252b02 | ||
|
|
821bf9d1ca | ||
|
|
74060c0585 | ||
|
|
0ba7450b65 | ||
|
|
347dbb53b0 | ||
|
|
2dc2885c3f | ||
|
|
8806504967 | ||
|
|
aa07401766 | ||
|
|
4166679dbf | ||
|
|
688550a28d | ||
|
|
bd4ac50a69 | ||
|
|
7799a300df | ||
|
|
85c89ca2a4 | ||
|
|
96914f1cbd | ||
|
|
3445107d9a | ||
|
|
ad2da45389 | ||
|
|
de71b4456e | ||
|
|
40f61331aa | ||
|
|
0dd55c9796 | ||
|
|
04ce1861e7 | ||
|
|
7ac620cc22 | ||
|
|
f38d0f80ca | ||
|
|
9de56f8a0b | ||
|
|
7df4cb29cc | ||
|
|
66c233602f | ||
|
|
5585ae481e | ||
|
|
34691dceec | ||
|
|
422f766cd4 | ||
|
|
af31c36b67 | ||
|
|
c5a8de38b3 | ||
|
|
ccd468b335 | ||
|
|
88a38db877 | ||
|
|
4c198c6e31 | ||
|
|
561d7fc3ed | ||
|
|
9a047ad42e | ||
|
|
616a2928e7 | ||
|
|
ee31782fbf | ||
|
|
ace4a66701 | ||
|
|
7c9ce9a712 | ||
|
|
1b5c4b3ecd | ||
|
|
5f5d8681b9 | ||
|
|
9f70c56b58 | ||
|
|
5af8e6fc35 | ||
|
|
6a1128a463 | ||
|
|
2d5de9a324 | ||
|
|
12beeff695 | ||
|
|
1055a0bc4a | ||
|
|
d2de3ce88a | ||
|
|
be50cb5d08 | ||
|
|
ee5d7c678e | ||
|
|
cee1a954ee | ||
|
|
5f6569b9e5 | ||
|
|
813bce6856 | ||
|
|
4d9ad38df1 | ||
|
|
8bae21230e | ||
|
|
27ddcfb575 | ||
|
|
aa6d50e1ea | ||
|
|
0b010f2c65 | ||
|
|
52803225df | ||
|
|
3ede98fb18 | ||
|
|
9ef813d439 | ||
|
|
33d043ea92 | ||
|
|
d3dec682aa | ||
|
|
05ad5544e2 | ||
|
|
8e82a7c857 | ||
|
|
11ae41d626 | ||
|
|
b4fae88ae1 | ||
|
|
b0fd354ecd |
No files matched your search
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
@@ -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')
|
||||
})
|
||||
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Generated
+907
-1168
File diff suppressed because it is too large.
Load diff
+13
-13
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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: ',' }}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"fromjs": [
|
||||
{
|
||||
"ADMIN": "all-users",
|
||||
"DCPATH": "/tmp/dcdata",
|
||||
"OUTFMT": "JSON",
|
||||
"KEEPME": "Y"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"licenceKey": "invalid",
|
||||
"activationKey": "invalid"
|
||||
}
|
||||
"licenceKey": "",
|
||||
"activationKey": ""
|
||||
}
|
||||
@@ -16,7 +16,9 @@
|
||||
"streamWebFolder": "web9",
|
||||
"webSourcePath": "../../client/dist",
|
||||
"assetPaths": [],
|
||||
"streamServiceName": "clickme"
|
||||
"streamServiceName": "clickme",
|
||||
"mockWeb": true,
|
||||
"mockWebFolder": "sasjsbuild/mock"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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) ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -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()
|
||||
@@ -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 }]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
})()
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(', ')}`
|
||||
: '')
|
||||
)
|
||||
Generated
+136
-227
@@ -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
@@ -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"
|
||||
|
||||
@@ -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));
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
)
|
||||
@@ -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;
|
||||
@@ -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
|
||||
)
|
||||
@@ -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;
|
||||
@@ -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=(¬checked=0),
|
||||
desc=%str(Checking a valid row, a free text label and a delete marked row are not reported),
|
||||
outds=work.test_results
|
||||
)
|
||||
@@ -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;
|
||||
@@ -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
|
||||
)
|
||||
@@ -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"
|
||||
|
||||
@@ -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 ¬null 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 ¬null 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 ¬null,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user