Compare commits

...
56 Commits
Author SHA1 Message Date
semantic-release-bot 402ec346b9 chore(release): 7.17.0 [skip ci]
# [7.17.0](https://git.datacontroller.io/dc/dc/compare/v7.16.0...v7.17.0) (2026-10-15)

### Bug Fixes

* **client:** do not publish an empty getcols fallback to the picker ([9f8fc6f](9f8fc6f2eb))
* **client:** map the getcols fallback onto the picker vocabulary ([7cecf79](7cecf79515))
* **client:** pin the dependency entries this release changes ([b034fe5](b034fe5335))
* **deploy:** apply the same identity-call fix to the automatic deploy ([5693b44](5693b4488b))
* **deploy:** disable the manual deploy actions without an admin group ([6ffbdba](6ffbdbadf9))
* **deploy:** disable the manual deploy file inputs while admin groups load ([7094bfd](7094bfdf7c))
* **deploy:** guard the manual deploy form and Viya identity calls ([6522fcb](6522fcb28b))
* **deploy:** never bake an empty admin group into the deploy script ([b0e47fc](b0e47fc4ed))
* **deps:** pin shelljs to clear the braces advisory in the sas audit ([6e50938](6e5093858b))
* **editor:** check the EDIT limits once, and skip switched-off options ([f08c4c2](f08c4c231f))
* **filters:** carry TYPE on the fallback list, and drop the temporal exclusion ([bcb8fde](bcb8fde986))
* **filters:** source the picker variables from public/getcols when the table is not returned ([3a6a216](3a6a216b9d))
* **getcols:** use %global and %mp_getcols, and merge the base branch ([1385741](1385741f2d))
* **getcols:** widen TYPE before assigning the client vocabulary ([bcad05e](bcad05e032))
* **getdata:** apply row level security to selectbox source values ([768d33c](768d33c618)), closes [#340](#340)
* **mocks:** read MPE_CONFIG correctly, and pin the limits to it ([05126ae](05126ae73a))
* repair the SAS suite for the row/cell limits and default the manual deploy admin group ([99ff732](99ff732d67))
* **requests-modal:** widen the dialog so log lines need less scrolling ([3c10d91](3c10d915a5))
* **sas:** keep format and label in the public getcols response ([9c32b1b](9c32b1b1da))
* **sas:** keep the SAS C/N vocabulary in the public getcols response ([e90bcde](e90bcde512))
* **sas:** run the row/cell limit migration as a Viya job ([90af6b8](90af6b8515))
* **startup:** stack the SASjs tag detail so the dump is readable ([1f4c4a4](1f4c4a4d82))

### Features

* **debug:** keep the debug toggle state across a page refresh ([3ce006d](3ce006d6b4))
* **editor:** raise the row limits and add a cell limit to the EDIT screen ([3de6655](3de6655f26))
* **mocks:** read the DC_MAX* options from MPE_CONFIG, and mock the DDL export ([edac9a9](edac9a987a))
* **startup:** call out a missing SASjs tag on the boot screen ([b24eb73](b24eb731ce))
* **startup:** dump the SASjs tag contents in the boot screen checks ([75c1dcc](75c1dccfd2))
* **system:** export the DC library DDL from the system screen ([05f3f49](05f3f49e0c)), closes [#179](#179)
2026-10-15 20:21:53 +00:00
allan e48bf80ab4 Merge pull request 'feat: release 7.17 - DDL export, Handsontable 18.1.1, EDIT row and cell limits' (#333) from feat/export-dc-library-ddl into main
Release / Build-production-and-ng-test (push) Successful in 4m47s
Release / Build-and-test-development (push) Successful in 23m40s
Release / release (push) Successful in 7m49s
Reviewed-on: #333
2026-10-15 19:49:27 +00:00
dc dd958ac4e7 chore(styles): drop the dead manual-deploy CSS block
Build / Build-and-ng-test (pull_request) Successful in 5m47s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m10s
Build / Build-and-test-development (pull_request) Successful in 31m19s
8fdb468 removed the only component matching app-manual-deploy and the
.clear-memory-button rule inside it, leaving the block in styles.scss with no
consumer anywhere in the tree.
2026-10-15 16:48:35 +00:00
dc 8fdb468ad3 refactor(deploy): remove the manual deploy section
Build / Build-and-ng-test (pull_request) Successful in 6m21s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m13s
Build / Build-and-test-development (pull_request) Successful in 33m8s
It is the old Viya deployment form - the one built around uploading a JSON build
file - and it is not coming back. It was reachable only through the manualdeploy
route, which goes with it, so the Viya deploy screen is now the automatic flow
only.

That takes the manualdeploy-only plumbing in deploy.component with it (autodeploy
and its dead commented-out toggle, adminGroups/getAdminGroups, jsonFile) along
with the now-unused Router and LoggerService injections, and the section's two
spec files.
2026-10-15 15:13:00 +00:00
dc af52e4a1bf docs(contributing): drop the tag-based Handsontable key instructions
Build / Build-and-ng-test (pull_request) Successful in 6m30s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m23s
Build / Build-and-test-development (pull_request) Failing after 33m50s
The key now comes from the backend licence (hot_license_key on the decrypted
licence), falling back to DEFAULT_HOT_LICENCE_KEY, so the hotLicenceKey tag
attribute the docs told you to set has no reader. Also reword the missing-tag
comment in parseDcAdapterSettings, which claimed there was no case to handle
while the boot screen reports one.
2026-10-15 15:07:58 +00:00
dc b24eb731ce feat(startup): call out a missing SASjs tag on the boot screen
Build / Build-and-ng-test (pull_request) Successful in 6m34s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m32s
Build / Build-and-test-development (pull_request) Failing after 34m53s
The tag is a mandatory part of index.html - it carries serverUrl, serverType and
the rest of the adapter config - so the missing-tag branch (deactivateApp plus an
abort modal) goes. A missing tag is reported on the tag step itself instead,
which is where the rest of the boot diagnostics already live, and the settings
parse is null-safe so nothing throws on the way there.
2026-10-15 14:54:11 +00:00
dc d81967e6a1 refactor(sasjs-tag): unwire adminGroup and hotLicenceKey from the tag
Build / Build-and-ng-test (pull_request) Successful in 6m29s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m9s
Build / Build-and-test-development (pull_request) Successful in 33m25s
Both were hangovers from the earlier deployment flow.

adminGroup is a DC setting (DC_ADMIN_GROUP, shown on the System screen), not a
tag attribute. Its only readers were the deploy form's default in
deploy.component.ts and manual.component.ts, and both already fall back to
SASAdministrators when it is absent, so the fallback is now the whole logic.

hotLicenceKey was the Handsontable licence key. The customer's own key arrives
in the decrypted licence (hot_license_key), so the client carries the free-tier
default itself and no longer reads it from index.html.

With neither attribute on the tag, the boot-screen dump has nothing to hide:
HIDDEN_TAG_ATTRIBUTES is gone and every attribute is shown, so a wrong or
misspelt one is visible. That closes the review finding on hiding adminGroup.
The missing-tag branch also records why it carries no error state, closing the
other carried finding.
2026-10-15 14:43:42 +00:00
dc c81db17588 chore(index): drop the adminGroup attribute from the SASjs tag
Build / Build-and-ng-test (pull_request) Successful in 5m43s
Lighthouse Checks / lighthouse (pull_request) Successful in 20m48s
Build / Build-and-test-development (pull_request) Successful in 31m50s
It is a hangover from the earlier deployment flow: its only consumers are the
deploy form's default (deploy.component.ts setDeployDefaults and
manual.component.ts), both of which already fall back to SASAdministrators when
it is absent. The attribute stays readable, so an install that still sets it is
unaffected.

hotLicenceKey is left in place - it is not a deployment setting. It is the
Handsontable licence key, seeded into freeTierLicenceData.hot_license_key and
pushed to hotTable.licenseKey in six components, and nothing on the SAS side
supplies it.
2026-10-15 14:19:54 +00:00
dc 3c10d915a5 fix(requests-modal): widen the dialog so log lines need less scrolling
Build / Build-and-ng-test (pull_request) Successful in 5m48s
Lighthouse Checks / lighthouse (pull_request) Successful in 20m50s
Build / Build-and-test-development (pull_request) Successful in 31m8s
Clarity's `xl` modal size is a fixed 57.6rem (1152px), so the SAS log pane -
which is `white-space: pre` and deliberately never wraps - needed left/right
scrolling to read a line. Give the requests modal a 90vw dialog instead; the
log pane itself is untouched, so lines still do not wrap.
2026-10-15 13:43:43 +00:00
dc 3ce006d6b4 feat(debug): keep the debug toggle state across a page refresh
Build / Build-and-ng-test (pull_request) Successful in 6m35s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m55s
Build / Build-and-test-development (pull_request) Successful in 32m38s
The <sasjs> tag in index.html is re-read on every load, so the debug switch fell
back to the tag's value whenever the page was refreshed. Store the state the user
picks from the UI in localStorage, and reinstate it when the adapter is built, so
a refresh keeps the state from before.
2026-10-15 12:38:54 +00:00
dc cc4cbd52e4 refactor(startup): show the SASjs tag as a name/value grid
Build / Build-and-ng-test (pull_request) Successful in 6m30s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m46s
Build / Build-and-test-development (pull_request) Failing after 33m17s
Render the tag attributes as a two-column grid under the label instead of a
single comma-joined line, and leave the admin group and the licence key off the
boot screen.

The missing-tag error state goes too: the tag ships in index.html, so an absent
tag is not a case the boot screen needs to report.
2026-10-15 12:22:27 +00:00
dc 1f4c4a4d82 fix(startup): stack the SASjs tag detail so the dump is readable
Build / Build-and-ng-test (pull_request) Successful in 6m36s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m26s
Build / Build-and-test-development (pull_request) Failing after 34m3s
The check row renders the detail in a narrow right-hand column, which squeezed
the tag dump into a clipped block next to the label. Long details now wrap onto
their own full-width line, left aligned, via a `stacked` flag on the step.
2026-10-15 12:11:57 +00:00
dc 75c1dccfd2 feat(startup): dump the SASjs tag contents in the boot screen checks
Build / Build-and-ng-test (pull_request) Successful in 5m56s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m38s
Build / Build-and-test-development (pull_request) Successful in 32m22s
The boot screen lists the startup checks (app location, Viya deploy, startup
service). Add a fourth step that shows the contents of the <sasjs> tag, so the
effective configuration (serverType, debug, useComputeApi, ...) is visible
without having to read the page source. The step is marked as errored when the
tag is missing, which is the state that triggers the abort modal.

The step is appended after the existing three so the indices the startup flow
drives (sas.service / app.service) are unchanged. HTML lower-cases attribute
names in the DOM, so known attributes are mapped back to their canonical
spelling for display; unknown ones are kept as-is so a misspelt attribute is
still visible.
2026-10-15 11:56:07 +00:00
dc 7094bfdf7c fix(deploy): disable the manual deploy file inputs while admin groups load
Build / Build-and-ng-test (pull_request) Successful in 6m31s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m38s
Build / Build-and-test-development (pull_request) Successful in 33m0s
A file chosen before the identity calls settle runs onSasFileChange ->
addPrecodeLines while selectedAdminGroup is still '', baking '%let admin=;'
into the generated script; the action buttons then enable against that stale
header once the groups load. Disable both file inputs while
adminGroupsLoading, and cover it with a template spec that asserts the
binding (verified red without the guard).
2026-10-14 21:28:48 +00:00
dc fead3d5b26 test(getdata): wrap the RLS skip logic in a macro for sas9 portability
Build / Build-and-ng-test (pull_request) Successful in 5m41s
Lighthouse Checks / lighthouse (pull_request) Successful in 20m46s
Build / Build-and-test-development (pull_request) Successful in 32m11s
The admin-skip conditional used open-code %if/%then/%do, which needs SAS
9.4M5+. The suite also runs on the sas9 target, where an older maintenance
release would fail to compile the test. Wrap the body in
%macro getdata_rls_test() ... %mend and invoke it, so the conditional sits
inside a macro definition (as testsetup.sas's update_settings does).
2026-10-14 21:13:56 +00:00
allan cd5bae7518 Merge pull request 'fix(getdata): apply row level security to selectbox source values' (#341) from fix/rls-selectbox-source into feat/export-dc-library-ddl
Build / Build-and-ng-test (pull_request) Successful in 5m50s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m4s
Build / Build-and-test-development (pull_request) Successful in 30m57s
Reviewed-on: #341
2026-10-14 15:57:47 +00:00
dc 78d436f847 test(getdata): derive RLS value from data and guard open-code return
Build / Build-and-ng-test (pull_request) Successful in 5m46s
Lighthouse Checks / lighthouse (pull_request) Successful in 20m45s
Build / Build-and-test-development (pull_request) Successful in 30m47s
- The startupservice test leaves trailing %let dc_admin_group lines in the
  deployed settings.sas, so the skip guard compares against whatever the
  file resolves to; keep using &dc_admin_group (not &mpeadmins, which is
  never set in a test session).
- Selectbox RLS test now derives the restricted SOME_BESTNUM value from the
  data (testsetup reseeds MPE_X_TEST with random values) and asserts the
  user filter matches at least one value so the grid cannot be empty.
- Replace the open-code %return (invalid outside a macro definition) with
  an %if/%else %do guard around the demote + assert section.
2026-10-14 14:52:47 +00:00
dc 0d3d47500c test(getdata): make selectbox RLS test runnable on admin estates
Build / Build-and-ng-test (pull_request) Successful in 5m45s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m16s
Build / Build-and-test-development (pull_request) Successful in 30m50s
RLS is skipped for admins, and test sessions never resolve &mpeadmins
(testinit.sas runs without mpeinit), so the test checks the runner's
identity groups against &dc_admin_group instead. Admin runners skip
the assertions with an explicit PASS row; everyone else gets the real
RLS selectbox checks. Also documents the filter (row limit) and distinct
value preconditions.
2026-10-14 13:46:55 +00:00
dc 768d33c618 fix(getdata): apply row level security to selectbox source values
Build / Build-and-ng-test (pull_request) Successful in 5m55s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m12s
Build / Build-and-test-development (pull_request) Successful in 31m1s
The cell dropdown (selectbox) values sourced from a library.member.column
reference are read directly from the source table in editors/getdata.sas
(%dq_selects), without applying Row Level Security.  The grid rows are
filtered by %mpe_filtermaster, but the dropdown was not, so values from
restricted rows leaked into the dropdown.

This runs %mpe_filtermaster over the source table and reads the distinct
values through the filtered view.  EDIT mode mirrors the grid on this
(editor) page.  A data step view is used because a fileref cannot be %inc'd
inside a proc sql where clause.  The per-source view, filter table and
fileref are dropped at the end of each loop iteration.

getdata.test.2.sas restricts SOME_BESTNUM in MPE_X_TEST with a Row Level
Security rule (only the value 44 is visible) and asserts the selectbox
values returned by getdata are restricted to 44.  Without the fix the
dropdown returns the other distinct values and the test fails.  It is named
.test.2 because the row/cell limits test added on the target branch already
occupies .test.1.

Closes #340
2026-10-14 11:12:31 +00:00
dc 6ffbdbadf9 fix(deploy): disable the manual deploy actions without an admin group
Build / Build-and-ng-test (pull_request) Successful in 6m38s
Lighthouse Checks / lighthouse (pull_request) Successful in 23m22s
Build / Build-and-test-development (pull_request) Successful in 31m29s
The fallback added in b0e47fc names a group when the identity calls fail, but
the deploy should not proceed on a group the user never saw or chose.  The
three actions that consume it - the precode download, SUBMIT (executeSAS) and
Create Database - are now disabled while the group list is empty, so a failed
or empty identity call cannot produce a deploy script or a makedata run.  A
note next to the group select says why they are disabled.

The template spec asserts both states by rendering the real template.
2026-10-05 22:16:35 +00:00
dc b0e47fc4ed fix(deploy): never bake an empty admin group into the deploy script
Build / Build-and-ng-test (pull_request) Successful in 5m47s
Lighthouse Checks / lighthouse (pull_request) Successful in 23m8s
Build / Build-and-test-development (pull_request) Successful in 32m16s
When the identity calls fail the group lists stay empty and selectedAdminGroup
stayed '' - the manual section, unlike the automatic one, set no default - so
addPrecodeLines wrote `%let admin=;` and createDatabase carried an empty
ADMIN to makedata.

Settle the selection on both paths: applyAdminGroupFilter now also runs when
the calls fail, and falls back the way the automatic section does
(dcAdapterSettings.adminGroup, else SASAdministrators).  The filter previously
sat inside the try, so the failure path skipped it entirely.
2026-10-05 21:50:19 +00:00
dc 5693b4488b fix(deploy): apply the same identity-call fix to the automatic deploy
Build / Build-and-ng-test (pull_request) Successful in 5m54s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m18s
Build / Build-and-test-development (pull_request) Successful in 31m23s
The automatic deploy's getAdminGroups has the identical raw fetch() - no
credentials, no res.ok check, and no catch, so a 401 surfaced as an unhandled
rejection while the list stayed empty.  Same fix as the manual section; this
path predates the release branch.
2026-10-05 19:39:22 +00:00
dc 6522fcb28b fix(deploy): guard the manual deploy form and Viya identity calls
The "Show all groups" toggle was the only ngModel control in the form without
a name or ngModelOptions.standalone, so Angular threw NG01352 on render -
dev-mode only, so a production build hid it, but ng serve and the Cypress job
did not, and the form renders on every visit (needsLogin defaults to false).
Added both, matching the neighbouring controls.

Both Viya identity calls used raw fetch() with no credentials and no res.ok
check.  The deploy app can be served from a different origin to the Viya
server, where the default same-origin credential mode sends no session cookie
and Viya answers 401 - fetch still resolves, the body parses to no items, and
the screen silently rendered an empty group list that the generated deploy
script then carried as an empty admin value.  Passed credentials: 'include'
(matching withCredentials on the app's other Viya calls) and threw on a
non-ok response so the existing error path reports it.

Adds a spec that renders the real template through its module - the other
specs construct the class directly, so nothing ran the template directives,
which is why CI could not catch the ngModel guard.
2026-10-05 19:39:12 +00:00
dc 7cecf79515 fix(client): map the getcols fallback onto the picker vocabulary
Build / Build-and-ng-test (pull_request) Successful in 5m36s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m16s
Build / Build-and-test-development (pull_request) Successful in 31m26s
public/getcols publishes the SAS vocabulary (TYPE C/N, DDTYPE separating
DATE/DATETIME/TIME) but the picker compares TYPE exactly against num/char, so
the fallback list has to be mapped before it is published.  getCols does that
with the rule mergeColsRules uses on the normal path: the date and time types
are numeric.

Adds the missing spec for the store's fallback, covering the mapping, the
pass-through, and the empty-payload guard.
2026-10-05 15:04:15 +00:00
dc e90bcde512 fix(sas): keep the SAS C/N vocabulary in the public getcols response
The fallback rewrite changed the published TYPE from C/N to the client's
num/char, which is a breaking change to a public service.  TYPE goes back to
the SAS vocabulary it has always used, and the client maps it to its own
num/char at the store boundary instead (sas-store.getCols).  ddtype, fmtname,
format and label are unchanged, so the payload is a superset of the old one.

getcols.test now asserts the SAS contract (TYPE is C/N and agrees with ddtype)
rather than the client vocabulary, and the mock mirrors the service.
2026-10-05 15:04:04 +00:00
dc 9c32b1b1da fix(sas): keep format and label in the public getcols response
Build / Build-and-ng-test (pull_request) Successful in 6m20s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m42s
Build / Build-and-test-development (pull_request) Successful in 31m29s
The rewrite dropped format and label from the payload of this public service.
They are restored alongside the new fmtname/ddtype so an existing caller still
finds them, and the mock carries format too so it mirrors the service. TYPE
keeps the picker's num/char vocabulary - the picker compares it exactly, so the
old C/N cannot be kept - and that vocabulary change is now documented in the
service.
2026-10-05 12:59:00 +00:00
dc 9f8fc6f2eb fix(client): do not publish an empty getcols fallback to the picker
setQueryVariablesFromTable fell back to public/getcols, which can return an
empty cols list when the dataset has no metadata. Publishing that pushes []
into the filter picker, whose subscriber reads cols[0].NAME - the throw is
caught, but it leaves the picker half-updated (this.cols assigned, the
filter.cols assignment below the throwing line never run). Skip the store
update when the fallback is empty.
2026-10-05 12:58:47 +00:00
dc b034fe5335 fix(client): pin the dependency entries this release changes
The entries this PR bumps (@angular/animations, common, compiler, core,
forms, platform-browser, platform-browser-dynamic, router at 20.3.33;
@sasjs/adapter 4.19.1; @angular/compiler-cli 20.3.33) carried ^ ranges even
though .npmrc sets save-exact=true, so the manifest drifted on every manual
edit. Pinned them to the versions the lockfile resolves. @angular/cli is
pinned too (to 20.3.37) as the reviewer listed it. npm ci verified against
the lockfile afterwards.
2026-10-05 12:58:41 +00:00
dc 90af6b8515 fix(sas): run the row/cell limit migration as a Viya job
Build / Build-and-ng-test (pull_request) Successful in 6m26s
Lighthouse Checks / lighthouse (pull_request) Successful in 23m33s
Build / Build-and-test-development (pull_request) Successful in 32m38s
Each of the three blocks ended with a single-statement %else %put, which the
Viya compute engine rejects in open code (outside a %macro) with
'Expected %DO not found' - so the migration aborted whenever it was run as a
job. Both branches now use %do/%end. Verified by executing the script as a
real Viya job: all three options updated and inserted, no errors.
2026-10-05 12:25:50 +00:00
dc 99ff732d67 fix: repair the SAS suite for the row/cell limits and default the manual deploy admin group
Build / Build-and-ng-test (pull_request) Successful in 5m50s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m37s
Build / Build-and-test-development (pull_request) Successful in 32m29s
SAS tests:
- getcols.test: the new TYPE-vocabulary assertion had an unquoted comma in
  desc=, so the argument split and the whole test job aborted with ERROR 180.
  Quoted it with %str() - the test now runs (shown RED/GREEN against the
  length type $4 line it guards).
- getdata.test.1 (new): covers the EDIT row and cell limits. dctest.wideboy
  filtered to 300 rows trips DC_MAXOBS_WEBEDIT; filtered to 200 rows (200 x
  1005 = 201,000 cells) trips DC_MAXCELLS_WEBEDIT.
- getdata.test: test 1 fetched MPE_TABLES unfiltered, which now exceeds the
  250 row limit on a populated estate. Filtered it to one libref and moved
  the query assertion from test=NONE to test=ALL.
- exportdb.test: TSQL test now also asserts the export contains no INSERT.

Client:
- manual (Viya) deploy: the SAS Admin group dropdown was never populated
  (ngOnInit was empty). It now fetches the groups and defaults the list to
  the ones the user is a member of, with a 'Show all groups' toggle -
  mirroring the automatic deploy section. Adds three unit tests.
2026-10-05 12:05:34 +00:00
allan d5d28e92c8 Merge pull request 'feat(mocks): read the DC_MAX* options from MPE_CONFIG, and mock the DDL export' (#337) from feat/mock-config-and-ddl into feat/export-dc-library-ddl
Build / Build-and-ng-test (pull_request) Successful in 5m41s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m41s
Build / Build-and-test-development (pull_request) Successful in 31m28s
Reviewed-on: #337
2026-10-05 08:15:31 +00:00
dc 93ef9228aa test(getcols): assert the TYPE vocabulary in the SAS suite
Build / Build-and-ng-test (pull_request) Successful in 5m35s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m10s
Build / Build-and-test-development (pull_request) Successful in 31m32s
Review feedback on this PR.  The cypress cases exercise the mock, which derives
TYPE in JS and never passes through the $1 variable the length statement
widens, so dropping `length type $4;` again would pass everything CI runs.

getcols.test.sas already runs the service end to end, so its second case now
also counts the rows whose TYPE does not match their DDTYPE - 'char' for C,
'num' for N/DATE/DATETIME/TIME.  That covers the vocabulary and the mapping it
is derived from, so a truncated 'n'/'c' and a swapped mapping both fail it.
The suite runs via `sasjs test` against a Viya target, not in CI.

sasjs lint reports no warning on the file.
2026-10-05 01:08:39 +00:00
dc bcad05e032 fix(getcols): widen TYPE before assigning the client vocabulary
Build / Build-and-ng-test (pull_request) Successful in 5m30s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m53s
Build / Build-and-test-development (pull_request) Successful in 31m18s
Review feedback on this PR.  type comes from %mp_getcols as a $1 holding 'C' or
'N' - the macro assigns those with no length statement - and this step sets
cols1, so type inherited that $1.  Assigning 'num' and 'char' to it truncated
them to 'n' and 'c', and the picker compares the vocabulary exactly, so against
the real backend every filter built from the fallback list gave numeric columns
the character operator set and submitted character values unquoted, which
%mp_filtercheck rejects.

length type $4; now precedes the SET, which is the documented way to change an
input variable's length.  ddtype needs nothing: the macro creates it with
'CHARACTER', so it is already $9.

The sibling data steps are not affected - getdata.sas (vars3) and mpe_loader.sas
rename the incoming type to type2 first, so type is a new variable there and its
length comes from the first assignment ('char', 4), which holds both values.

sasjs lint reports no warning on this file.  The cypress case cannot cover this:
the mock sets TYPE in full in JS, and CI has no SAS engine to run the service.
2026-10-04 19:08:31 +00:00
dc dbc3044a0f ci(hooks): scan for secrets in CI, and add the pre-commit framework config
Build / Build-and-ng-test (pull_request) Successful in 5m44s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m18s
Build / Build-and-test-development (pull_request) Successful in 31m18s
Review feedback on this PR: with .npmrc setting ignore-scripts=true the prepare
script never runs, so the native hooks in .git-hooks/ do not activate on a fresh
clone and a commit can go unchecked.

Both standard routes are added, the way sasjs/cli and sasjs/lint carry them:

- .pre-commit-config.yaml pins the gitleaks hook to v8.30.1, so `pre-commit
  install` wires the secret scan without the one-time core.hooksPath command.
- Build-and-ng-test runs the same pinned @nogoo9/gitleaks binary as a step,
  because `pre-commit install` is itself a manual command: CI is the enforcement
  point.  --no-git scans the working tree, so the gate is what the pull request
  adds rather than the repo's history.

The scan runs before the job writes client/.npmrc from the NPMRC secret - that
file carries the private registry token, so scanning after it would flag the
token CI injects itself.

.npmrc is unchanged - ignore-scripts=true stays - and the native hooks stay for
anyone who prefers them.  CONTRIBUTING.md names the framework route next to the
existing one-time command.

Verified: npm ci followed by the scan reports no leaks on a fresh checkout, the
client lint check passes, and pre-commit validate-config plus pre-commit run
--all-files both pass.
2026-10-04 17:47:35 +00:00
allan 1fc7b55e3b Merge pull request 'fix(filters): source the picker variables from public/getcols when the table is not returned' (#338) from feat/filter-vars-fallback into feat/mock-config-and-ddl
Build / Build-and-ng-test (pull_request) Successful in 5m23s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m22s
Build / Build-and-test-development (pull_request) Successful in 31m35s
Reviewed-on: #338
2026-10-04 17:12:17 +00:00
dc bcb8fde986 fix(filters): carry TYPE on the fallback list, and drop the temporal exclusion
Build / Build-and-ng-test (pull_request) Successful in 5m29s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m59s
Build / Build-and-test-development (pull_request) Successful in 31m20s
Review feedback on this PR.

- The fallback list bypassed mergeColsRules, so col.TYPE was never set and every
  column reached the picker as char: char operators for numerics, and unquoted
  character values, which %mp_filtercheck rejects on the value list and on the
  submit alike.  getcols.sas and the mock now emit TYPE in the client's
  vocabulary - char for C, num for N/DATE/DATETIME/TIME - and the cypress case
  no longer stops at selecting the variable: it sets a value, submits, and
  asserts the applied clause reads SITE_NAME = 'Bristol', so the quoting is the
  assertion.
- The temporal exclusion is dropped, matching getdata.sas and viewdata.sas,
  whose cols payloads include those columns - the drop is on the data, not on
  the cols.  The mock's getdata.js cols had the same divergence and is aligned;
  it keeps the exclusion for the rows.  That also removes the SAS/mock
  disagreement on the excluded set, since there is no set.
- client/.npmrc added with ignore-scripts and save-exact (plus legacy-peer-deps
  and fund, matching the root), so a local `cd client && npm i` is guarded the
  way the CI's is.

Verified against the running mock: for MPE_CONFIG (TXTEMPORAL) getcols returns
TX_FROM/TX_TO with TYPE=num and DDTYPE=DATETIME, the char columns as TYPE=char,
and editors/getdata returns cols including both temporal columns while the rows
still exclude them.  row-cell-limits.cy.ts is 5/5.
2026-10-04 16:26:51 +00:00
allan a20ce96744 Merge pull request 'refactor(mocks): a JS mpGetcols mirroring %mp_getcols.sas' (#339) from refactor/mock-mp-getcols into feat/filter-vars-fallback
Build / Build-and-ng-test (pull_request) Successful in 5m54s
Lighthouse Checks / lighthouse (pull_request) Successful in 23m34s
Build / Build-and-test-development (pull_request) Successful in 32m19s
Reviewed-on: #339
2026-10-04 12:56:44 +00:00
dc 55fd16d53c refactor(mocks): a JS mpGetcols mirroring %mp_getcols.sas
Build / Build-and-ng-test (pull_request) Successful in 5m27s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m24s
Build / Build-and-test-development (pull_request) Successful in 32m19s
The mock typed columns from its own format list while the services typed them
from the macro's, so the two could disagree - the review of the getcols.sas
change found exactly that, with the mock covering YYMMDD/MMDDYY/DDMMYY/E8601DT
and the service only the DATETIME/DATE/TIME prefixes.

mpGetcols() now mirrors the macro: NAME (upcased), TYPE (C/N), LENGTH, LABEL,
VARNUM, FORMAT, FMTNAME and DDTYPE (CHARACTER/NUMERIC/DATETIME/DATE/TIME), with
the macro's date list.  editors/getdata.js, public/viewdata.js and
public/getcols.js use it, the last mapping CHARACTER/NUMERIC to C/N the way the
service does.  getDdType() delegates to it, so there is one inference, not two.

Verified by diffing the column list every fixture table returns from all three
services before and after: 49 tables x 3 services, 9 differences, all of them
FMTNAME on E8601DT columns - the old code rewrote it to DATETIME, the new code
leaves E8601DT, which is what %mp_getcols puts in fmtname.  FMTNAME is not read
at runtime.  Every DDTYPE, TYPE, LENGTH, FORMAT and LABEL is identical.
2026-10-04 12:46:53 +00:00
dc 1385741f2d fix(getcols): use %global and %mp_getcols, and merge the base branch
Build / Build-and-ng-test (pull_request) Successful in 5m30s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m21s
Build / Build-and-test-development (pull_request) Successful in 31m14s
Review feedback on this PR, plus the conflict with the base.

Conflict: row-cell-limits.cy.ts, where both branches added a case.  Both are
kept - the config-driven limits case is 4, the filter-picker-after-abort case
is 5 - with the helpers from each side.

- %local in open code is a macro error ("not valid in open code"), logged on
  every call, and the variables are never created - so a table with no
  mpe_tables row left the INTO variables unresolved.  It is %global now, the
  way getdata.sas declares the same three variables.
- The hand-rolled format-prefix test typed only DATETIME/DATE/TIME, so YYMMDD,
  MMDDYY, DDMMYY, MONYY, E8601DA, B8601DA, E8601DT and NLDATM columns reached
  the picker as numeric.  The proc contents step is replaced with
  %mp_getcols(&ds, outds=cols1) - the call getdata.sas already makes - so the
  fallback list is typed exactly as the normal path types it, with the
  temporal-column exclusion applied on top.
2026-10-04 12:00:41 +00:00
dc 05126ae73a fix(mocks): read MPE_CONFIG correctly, and pin the limits to it
Build / Build-and-ng-test (pull_request) Successful in 5m27s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m3s
Build / Build-and-test-development (pull_request) Successful in 31m9s
Review feedback on this PR.

- getdata.js read MPE_CONFIG from the requested table's own library, so the
  read failed for every non-control libref and both limits silently fell back
  to the shipped defaults.  It now reads mpeDataDir, as viewdata.js already
  did.  Reproduced first: with DC_MAXOBS_WEBEDIT=5, DC_JSLIB.MPE_X_TEST
  aborted while TESTDATA.DEMO_01 loaded untouched.
- exportdb.js mapped DATETIME to TIMESTAMP for TSQL as well, and T-SQL's
  TIMESTAMP is a deprecated synonym for rowversion, so datetime columns were
  declared as rowversions.  TSQL now gets DATETIME2; PGSQL still gets
  TIMESTAMP.  DOUBLE PRECISION is left alone - it is a T-SQL synonym for
  float, not an invalid type.
- loadDcConfig read the config row as direct properties, but a row that has
  been through the app's own edit-and-approve flow comes back from the adapter
  with upper-case keys, so the approved option was skipped and fell back to
  its default - the same silent-default failure by another route.  It now
  reads every field through colVal, like the rest of the mocks.
- row-cell-limits.cy.ts gains a case that moves DC_MAXCELLS_WEBEDIT to a
  non-default value through the editor, asserts that a non-control-libref
  table is then refused, and puts the option back.  It fails if the read is
  dropped: verified by reinstating the getdata.js bug and watching it fail.
2026-10-04 11:18:49 +00:00
dc 3a6a216b9d fix(filters): source the picker variables from public/getcols when the table is not returned
Build / Build-and-ng-test (pull_request) Successful in 5m28s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m29s
Build / Build-and-test-development (pull_request) Successful in 30m40s
A limit aborting the table request left the filter picker with no variables,
so the "please filter and try again" advice could not be acted on: the
picker's list came from the columns that arrived with the table, and there
was no table.

The list now falls back to public/getcols, which reads the column metadata
without the rows. The normal path is unchanged - the service is only called
when the caller has no columns to hand - so nothing extra is requested for a
table that loaded.

- SasStoreService.getCols() calls public/getcols and returns cols.
- SasStoreService.setQueryVariablesFromTable() takes the columns the caller
  already has and reaches for the service only when they are empty.
- editor openQb() and viewboxes openFilter() use it (the viewer's filter is
  reachable after a VIEW abort, the editor's after an EDIT abort).
- public/getcols.sas keeps ddtype (already computed, dropped by the keep) and
  excludes the temporal columns exactly as the editor does, so the fallback
  list matches the table's own.
- New mock public/getcols.js mirrors the service.

Verified: the mock's list matches editors/getdata for every fixture except the
aborting one (MPE_X_SEARCH: getdata 0 cols, getcols 9), and a new Cypress case
in row-cell-limits.cy.ts aborts a too-big EDIT, opens the filter and asserts
all nine columns are offered. 89 Cypress tests green across the six affected
specs.
2026-10-04 09:30:33 +00:00
dc edac9a987a feat(mocks): read the DC_MAX* options from MPE_CONFIG, and mock the DDL export
Build / Build-and-ng-test (pull_request) Successful in 5m22s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m5s
Build / Build-and-test-development (pull_request) Successful in 30m22s
The mocked estate could not exercise what 7.17 changed.

- getdata.js and viewdata.js hard-coded their limits, so editing
  DC_MAXOBS_WEBEDIT / DC_MAXCELLS_WEBEDIT / DC_MAXOBS_WEBVIEW in DC made
  no difference.  They now read them from MPE_CONFIG via a new
  loadDcConfig/dcConfigNumber pair in dcMockUtils, which mirrors the loop
  in the generated settings.sas: DC-scope rows only, var_active=1 only,
  current rows only (now < tx_to), latest tx_from wins.  An option that is
  absent or switched off falls back to the service's own default, which is
  what %symexist does in the real service.

- There was no mock for services/admin/exportdb, so the System screen's
  Export DC Library DDL action hit a service that did not exist.  The new
  mock renders every table in the DC libref as basic DDL - SAS, PGSQL and
  TSQL flavours, schema defaulting to the libref, a unique index from the
  MPE_TABLES buskey, and the download headers mp_streamfile produces.  DDL
  only: the real service appends inserts for SAS and PGSQL, but
  serialising the library's rows in a mock buys no test value.

ddl-export.cy.ts gains two cases that fetch the URL the action opens and
assert the DDL, so the spec covers the service and not just the URL.
2026-10-03 22:34:57 +00:00
allan 8169ef253d Merge pull request 'chore(deps): upgrade Handsontable to 18.1.1' (#334) from chore/handsontable-18.1.1 into feat/export-dc-library-ddl
Build / Build-and-ng-test (pull_request) Successful in 5m46s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m27s
Build / Build-and-test-development (pull_request) Successful in 33m2s
Reviewed-on: #334
2026-10-03 18:49:35 +00:00
allan f2535ce335 Merge branch 'feat/export-dc-library-ddl' into chore/handsontable-18.1.1
Build / Build-and-ng-test (pull_request) Successful in 5m52s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m31s
Build / Build-and-test-development (pull_request) Successful in 33m5s
2026-10-03 18:48:48 +00:00
allan 831f0b27a9 Merge pull request 'feat(editor): raise the row limits and add a cell limit to the EDIT screen' (#335) from feat/row-and-cell-limits into chore/handsontable-18.1.1
Build / Build-and-ng-test (pull_request) Successful in 5m53s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m43s
Build / Build-and-test-development (pull_request) Successful in 32m23s
Reviewed-on: #335
2026-10-03 18:47:54 +00:00
dc f08c4c231f fix(editor): check the EDIT limits once, and skip switched-off options
Build / Build-and-ng-test (pull_request) Successful in 5m35s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m41s
Build / Build-and-test-development (pull_request) Successful in 30m42s
Review follow-ups on the row/cell limits:

- getdata.sas checked the limits twice - once before the sort that caps
  work.out, and again after PRE_EDIT_HOOK.  It is now a single check,
  after both, so the counts cover everything that could reach the
  browser: the rows the selection matched, plus anything a hook has
  added.  The payload cap moves to where work.outdata is built, which is
  the only place that needs it.  Each mp_abort also names its option once
  - the limit was repeated in the message text as well as the condition.

- The migration closed only var_active=1 rows but inserted active rows
  unconditionally, so an option a site had switched off was silently
  switched back on.  It now leaves those alone and logs a note.

- row-cell-limits.cy.ts test 1 matched a regex against the grid's text,
  which the fixture's own columns satisfy (DEPTH_M reaches 4293,
  SAMPLE_COUNT 4210), so it did not pin the raised VIEW cap.  It now
  reads the PK column - the first td of a .ht_master row - and requires
  values in the PK range 1001-2000 with a maximum above 1500, which the
  old 500 row cap could never render.
2026-10-03 18:00:46 +00:00
allan b505e45c6f Merge pull request 'fix(deps): pin shelljs to clear the braces advisory in the sas audit' (#336) from fix/audit-braces into feat/export-dc-library-ddl
Build / Build-and-ng-test (pull_request) Successful in 5m19s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m38s
Build / Build-and-test-development (pull_request) Successful in 30m4s
Reviewed-on: #336
2026-10-03 17:26:05 +00:00
dc 3de6655f26 feat(editor): raise the row limits and add a cell limit to the EDIT screen
Build / Build-and-ng-test (pull_request) Successful in 5m22s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m58s
Build / Build-and-test-development (pull_request) Successful in 30m41s
VIEW default 500 -> 2000. EDIT default 100 -> 250, plus a new
DC_MAXCELLS_WEBEDIT (200000) so a wide selection is refused on cells as
well as rows, whichever is reached first. Measured end-to-end: 2000 rows
of a 9 column table is ~1s in VIEW / ~6s in EDIT, but 100 rows of a 1001
column table is already ~17s in EDIT - a row cap cannot bound that.

The EDIT guard also moves: it now checks the filtered row count before
the sort that caps work.out, and again after PRE_EDIT_HOOK, which can
replace work.out with a larger table.
2026-10-03 13:15:58 +00:00
dc 6e5093858b fix(deps): pin shelljs to clear the braces advisory in the sas audit
Build / Build-and-ng-test (pull_request) Successful in 5m19s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m58s
Build / Build-and-test-development (pull_request) Successful in 29m37s
braces (GHSA-vfj7-8cjw-p6xm) arrives via @sasjs/cli > shelljs > fast-glob
> micromatch > braces and has no fixed version - braces 3.0.3 is the
latest release, and npm's only remedy is downgrading @sasjs/cli to
4.13.1.  shelljs 0.9.0 is what introduced fast-glob, so pinning shelljs
to 0.8.5 removes braces, micromatch and fast-glob from the tree
entirely.  The CLI only uses shelljs for ls/cp/rm/exec, unchanged
between the two versions - sasjs lint and sasjs cb -t server both still
pass.
2026-10-03 12:40:30 +00:00
dc b6b00ab96e chore(deps): upgrade Handsontable to 18.1.1
Build / Build-and-ng-test (pull_request) Successful in 5m23s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m45s
Build / Build-and-test-development (pull_request) Successful in 29m34s
handsontable and @handsontable/angular-wrapper 18.0.0 -> 18.1.1, plus the
two repo-owned places that pin the version.

18.1 fixes two bugs this repo's specs had pinned as failure modes:

- "Sheet size limit exceeded" (upstream #10672): the formulas plugin used
  to push the grid's maxRows into the HyperFormula engine as its sheet
  size limit, so a licensing cap below an existing table's row count made
  the engine reject the whole table. initSetup works around it with
  Math.max(dataSource.length, editor_rows_allowed).
- Formula corruption on updateSettings while a sort is active: a bare {}
  settings object was enough to turn formula cells into #REF!.
  updateSettingsSortSafe clears and restores the sort around every call.

Both specs now assert the fixed behaviour instead, so a future regression
shows up. DC's own workarounds are untouched.

18.1.1 also fixes a filters plugin crash that lands on DC's usage exactly
(upstream #13480): editing a cell in a filtered column, or replacing the
data with a filter active, after updateSettings() with the filters option.
The Angular wrapper calls updateSettings on every update, and DC's viewer
and viewboxes run filters: true.

18.1.0 is mostly a performance release (viewport cell-meta release, index
translation, single-pass layout, bulk operations).

Also:
- licenseChecker.js: add the 18.1.1 handsontable packages to
  excludePackages, or the build stops at the license-checker step (the
  library's licence string is non-standard).
- _hot-icons.scss: regenerated by scripts/gen-hot-icons.mjs; drops the
  Pikaday rules because 18.1 removed the leftover Pikaday theme styles.
- dc-validator.spec.ts: 18.1 tightened ColumnSettings['validator'] to its
  real union (RemoveIndexSignature), so narrow it the way the production
  code already does.
2026-10-02 17:54:42 +00:00
dc e49e8e6710 ci: apply the RUN_AS=root fix to the release workflow too
Build / Build-and-ng-test (pull_request) Successful in 5m15s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m49s
Build / Build-and-test-development (pull_request) Successful in 30m1s
release.yaml starts the SASjs Server the same way as build.yaml and
lighthouse.yaml, so the same root guard (sasjs/server v1.9.0) breaks it.
It runs on pushes to main and has not run since the guard shipped, so
the next release would have failed at the mock deploy.
2026-10-02 16:16:30 +00:00
dc 1354fced46 ci: set RUN_AS=root so the SASjs Server starts in the runner
Build / Build-and-ng-test (pull_request) Successful in 5m51s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m23s
Build / Build-and-test-development (pull_request) Successful in 30m21s
Recent sasjs/server releases refuse to run as root unless RUN_AS names
an account (security hardening, "refuse to run as root unless RUN_AS
names an account"). The CI runners are root, so the server exited at
startup and pm2 restart-looped (103 restarts), leaving :5000
unreachable - which is what failed the mocked-estate deploy and the
Lighthouse job.

The server's own message offers RUN_AS=root as the deliberate override;
opt in explicitly for the throwaway CI container.
2026-10-02 15:31:24 +00:00
dc a0c9349e08 ci: wait for the SASjs Server to answer before deploying mocks
Lighthouse Checks / lighthouse (pull_request) Failing after 2m59s
Build / Build-and-ng-test (pull_request) Successful in 5m3s
Build / Build-and-test-development (pull_request) Failing after 6m51s
Both workflows start api-linux with `pm2 start api-linux --wait-ready`
and then hit http://localhost:5000 in the next step. pm2 can return
while the app is still 'launching' (it does not always block on the
ready signal), so the deploy step races the server and fails with
"Couldn't connect to server".

Wait up to 120s for the server to answer, and print `pm2 list` plus the
api-linux log if it never does, so a genuine startup failure is visible
in the job output instead of a bare ECONNREFUSED.
2026-10-02 15:26:04 +00:00
dc 644a7eb33d chore(client): clear newly-published npm audit advisories
Lighthouse Checks / lighthouse (pull_request) Failing after 4m45s
Build / Build-and-ng-test (pull_request) Successful in 4m55s
Build / Build-and-test-development (pull_request) Failing after 4m18s
The CI "Check audit" step (`npm audit --omit=dev`) now fails on the
client tree because of advisories published after the last green build:

- @angular/router 20.0.0 - 20.3.31 (SSR DoS) - bump @angular/* to 20.3.33
- fast-uri 3.0.0 - 3.1.7 - bump the override to 3.1.8
- brace-expansion, moment, undici - updated by `npm audit fix`

`npm audit --omit=dev` is clean after this. Verified with the production
build, the 548 unit tests and the Cypress suite.
2026-10-02 14:33:28 +00:00
dc c019224e96 chore(sas): bump @sasjs/cli to 4.20.5
Build / Build-and-ng-test (pull_request) Failing after 1m42s
Build / Build-and-test-development (pull_request) Skipped
Lighthouse Checks / lighthouse (pull_request) Failing after 4m36s
4.20.5 brings @sasjs/adapter 4.19.1, whose axios dependency is 1.20.0.
The previous CLI resolved axios 1.18.1, which the current npm audit
advisories flag (axios 1.0.0 - 1.19.0), failing the client-side
`npm audit --omit=dev` check in CI.
2026-10-02 14:17:54 +00:00
dc 05f3f49e0c feat(system): export the DC library DDL from the system screen
Build / Build-and-ng-test (pull_request) Failing after 1m47s
Build / Build-and-test-development (pull_request) Skipped
Lighthouse Checks / lighthouse (pull_request) Failing after 4m46s
Adds a fourth admin action to the System information screen - Export DC
Library DDL - with a secondary flavour chooser (SAS, PGSQL, TSQL) and an
optional schema box for the DB flavours. The selection is passed to the
existing services/admin/exportdb service in a new window as URL params.

Also bumps @sasjs/adapter to ^4.19.1, adds a Cypress spec for the three
flavour states, and adds a TSQL case to the exportdb SAS test.

Closes #179
2026-10-02 14:03:53 +00:00
59 changed files with 3089 additions and 1807 deletions

No files matched your search

+36 -1
View File
@@ -15,6 +15,22 @@ jobs:
with:
node-version: ${{ env.NODE_VERSION }}
- name: Scan for secrets (gitleaks)
# The local pre-commit hook is suppressed entirely when .npmrc sets
# ignore-scripts=true (the prepare script that would activate the
# hooks never runs), so CI is the enforcement point: a leaked
# credential must fail the build, not just a commit.
# Uses the same @nogoo9/gitleaks binary the pre-commit hook pins - the
# gitleaks-action requires a license key for organization repos.
# Runs before client/.npmrc is written below, because that file is
# created from the NPMRC secret and a later scan would flag the
# registry token this job injects itself.
# --no-git scans the working tree, which is what a pull request gate
# is about.
run: |
npm ci
node_modules/.bin/gitleaks detect --no-git --source . --redact --no-banner -v
- name: Install Google Chrome
run: |
apt-get update
@@ -126,8 +142,27 @@ jobs:
echo NODE_PATH=node >> .env
echo CORS=enable >> .env
echo WHITELIST=http://localhost:4200 >> .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.
echo RUN_AS=root >> .env
cat .env
pm2 start api-linux --wait-ready
# `pm2 start --wait-ready` can return while the app is still
# launching (it does not always wait for the ready signal), and the
# next step hits :5000 immediately. Wait for the server to answer,
# and dump its log if it never does.
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"
pm2 list
pm2 logs api-linux --lines 100 --nostream
exit 1
- name: Deploy mocked services
run: |
@@ -163,7 +198,7 @@ jobs:
# Start frontend and run cypress
# timeout 1800: SIGTERM after 30 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"
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"
- name: Zip Cypress videos
if: always()
+19
View File
@@ -33,10 +33,29 @@ jobs:
echo NODE_PATH=node >> .env
echo CORS=enable >> .env
echo WHITELIST=http://localhost:4200 >> .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.
echo RUN_AS=root >> .env
cat .env
curl -L https://github.com/sasjs/server/releases/latest/download/linux.zip > linux.zip
unzip linux.zip
pm2 start api-linux --wait-ready
# `pm2 start --wait-ready` can return while the app is still
# launching (it does not always wait for the ready signal), and the
# next step hits :5000 immediately. Wait for the server to answer,
# and dump its log if it never does.
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"
pm2 list
pm2 logs api-linux --lines 100 --nostream
exit 1
- name: Write .npmrc file
run: echo "$NPMRC" > client/.npmrc
+19
View File
@@ -117,8 +117,27 @@ jobs:
echo NODE_PATH=node >> .env
echo CORS=enable >> .env
echo WHITELIST=http://localhost:4200 >> .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.
echo RUN_AS=root >> .env
cat .env
pm2 start api-linux --wait-ready
# `pm2 start --wait-ready` can return while the app is still
# launching (it does not always wait for the ready signal), and the
# next step hits :5000 immediately. Wait for the server to answer,
# and dump its log if it never does.
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"
pm2 list
pm2 logs api-linux --lines 100 --nostream
exit 1
- name: Deploy mocked services
run: |
+16
View File
@@ -0,0 +1,16 @@
# Hooks for the pre-commit framework - https://pre-commit.com
#
# The repo also ships native hooks in .git-hooks/, covering the secret scan, a
# 2MB commit size limit, a prettier check and Conventional Commits. But .npmrc
# sets ignore-scripts=true, so the `prepare` script that points git at them
# never runs on `npm i` - a fresh clone commits unchecked until the one-time
# command in CONTRIBUTING.md is run by hand.
#
# This file is the standard route instead: install the framework once with
# `pre-commit install`, and the secret scan runs on every commit. The hook is
# pinned to an exact gitleaks release tag.
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.30.1
hooks:
- id: gitleaks
+38
View File
@@ -1,3 +1,41 @@
# [7.17.0](https://git.datacontroller.io/dc/dc/compare/v7.16.0...v7.17.0) (2026-10-15)
### Bug Fixes
* **client:** do not publish an empty getcols fallback to the picker ([9f8fc6f](https://git.datacontroller.io/dc/dc/commit/9f8fc6f2eb83c78ea9cb220bb74059bab39e66ad))
* **client:** map the getcols fallback onto the picker vocabulary ([7cecf79](https://git.datacontroller.io/dc/dc/commit/7cecf795153138d6f2ad1f87f7bdd12b5d861341))
* **client:** pin the dependency entries this release changes ([b034fe5](https://git.datacontroller.io/dc/dc/commit/b034fe5335c41ec0289e459270839ee7caa8e126))
* **deploy:** apply the same identity-call fix to the automatic deploy ([5693b44](https://git.datacontroller.io/dc/dc/commit/5693b4488b0328022c69dd34f98662d660612578))
* **deploy:** disable the manual deploy actions without an admin group ([6ffbdba](https://git.datacontroller.io/dc/dc/commit/6ffbdbadf9804e0128694ed1c2bdb8f957743ad9))
* **deploy:** disable the manual deploy file inputs while admin groups load ([7094bfd](https://git.datacontroller.io/dc/dc/commit/7094bfdf7cdab4c70e5f1a7763a8a8aeb8cc61a7))
* **deploy:** guard the manual deploy form and Viya identity calls ([6522fcb](https://git.datacontroller.io/dc/dc/commit/6522fcb28b67b910b2784b93251f591bbc79d294))
* **deploy:** never bake an empty admin group into the deploy script ([b0e47fc](https://git.datacontroller.io/dc/dc/commit/b0e47fc4ed4e36878de6505e678667b14d19c5e6))
* **deps:** pin shelljs to clear the braces advisory in the sas audit ([6e50938](https://git.datacontroller.io/dc/dc/commit/6e5093858be106a1c3545a16119d246f69aac3da))
* **editor:** check the EDIT limits once, and skip switched-off options ([f08c4c2](https://git.datacontroller.io/dc/dc/commit/f08c4c231f019979a27fe71fe73798752c015710))
* **filters:** carry TYPE on the fallback list, and drop the temporal exclusion ([bcb8fde](https://git.datacontroller.io/dc/dc/commit/bcb8fde9868b8e1ded1590e204c34054f62d8250))
* **filters:** source the picker variables from public/getcols when the table is not returned ([3a6a216](https://git.datacontroller.io/dc/dc/commit/3a6a216b9d4dfc462e05031039c47002a9156130))
* **getcols:** use %global and %mp_getcols, and merge the base branch ([1385741](https://git.datacontroller.io/dc/dc/commit/1385741f2d303c1432f1f0ba637dd3cb39500a71))
* **getcols:** widen TYPE before assigning the client vocabulary ([bcad05e](https://git.datacontroller.io/dc/dc/commit/bcad05e03291fc3e2b72a4465629e1e330d70c8a))
* **getdata:** apply row level security to selectbox source values ([768d33c](https://git.datacontroller.io/dc/dc/commit/768d33c618a7ea453c3c19723f342a170ec576ea)), closes [#340](https://git.datacontroller.io/dc/dc/issues/340)
* **mocks:** read MPE_CONFIG correctly, and pin the limits to it ([05126ae](https://git.datacontroller.io/dc/dc/commit/05126ae73a1ab2461066eb701b7475674b96f1ec))
* repair the SAS suite for the row/cell limits and default the manual deploy admin group ([99ff732](https://git.datacontroller.io/dc/dc/commit/99ff732d67425b01a8a8f5e3e7c98b3e6d604323))
* **requests-modal:** widen the dialog so log lines need less scrolling ([3c10d91](https://git.datacontroller.io/dc/dc/commit/3c10d915a5134247aff0e987dd4d5c83c197e4c5))
* **sas:** keep format and label in the public getcols response ([9c32b1b](https://git.datacontroller.io/dc/dc/commit/9c32b1b1dae00e4b7bfd4acad94cbed831819d84))
* **sas:** keep the SAS C/N vocabulary in the public getcols response ([e90bcde](https://git.datacontroller.io/dc/dc/commit/e90bcde51218c9e5f562f7aaa65506761b21d6bd))
* **sas:** run the row/cell limit migration as a Viya job ([90af6b8](https://git.datacontroller.io/dc/dc/commit/90af6b851542a97f79172b655070cda559386a5c))
* **startup:** stack the SASjs tag detail so the dump is readable ([1f4c4a4](https://git.datacontroller.io/dc/dc/commit/1f4c4a4d8297568f46bcc16ea208673fedad345b))
### Features
* **debug:** keep the debug toggle state across a page refresh ([3ce006d](https://git.datacontroller.io/dc/dc/commit/3ce006d6b469b4ece2bc769f0a32a1792eb1e774))
* **editor:** raise the row limits and add a cell limit to the EDIT screen ([3de6655](https://git.datacontroller.io/dc/dc/commit/3de6655f26b7e895f60266e9931b46cfc0053975))
* **mocks:** read the DC_MAX* options from MPE_CONFIG, and mock the DDL export ([edac9a9](https://git.datacontroller.io/dc/dc/commit/edac9a987aad3e4217641eed43f697f61c4bf739))
* **startup:** call out a missing SASjs tag on the boot screen ([b24eb73](https://git.datacontroller.io/dc/dc/commit/b24eb731ce089acf496bed1e11a5e34208c1bd11))
* **startup:** dump the SASjs tag contents in the boot screen checks ([75c1dcc](https://git.datacontroller.io/dc/dc/commit/75c1dccfd23557ab321cd7abd1508f20d817c7b8))
* **system:** export the DC library DDL from the system screen ([05f3f49](https://git.datacontroller.io/dc/dc/commit/05f3f49e0c4d7005fa1f93852d73f2368395ab28)), closes [#179](https://git.datacontroller.io/dc/dc/issues/179)
# [7.16.0](https://git.datacontroller.io/dc/dc/compare/v7.15.0...v7.16.0) (2026-09-28)
+7 -8
View File
@@ -19,6 +19,8 @@ git config core.hooksPath ./.git-hooks
The pre-commit hook requires the gitleaks binary provided by the root `@nogoo9/gitleaks` devDependency, so make sure `npm i` has run in the repo root before your first commit.
The standard [pre-commit](https://pre-commit.com) route is available as well, and does not depend on the lifecycle scripts above: install it once with `pre-commit install` and the secret scan in [`.pre-commit-config.yaml`](./.pre-commit-config.yaml) runs on every commit. The same scan also runs in CI, so a leaked credential fails the build regardless of which local hooks are active.
## Dependencies that requires licences
[SheetJS Pro Version](https://www.npmjs.com/package/sheetjs)
@@ -48,15 +50,12 @@ npm i
[Handsontable](https://www.npmjs.com/package/handsontable)
Licence should be inserted in the `client/src/index.html` file:
The licence key comes from the backend licence: the decrypted licence carries `hot_license_key`,
which is published on the `hot_license_key` subject and pushed into every Handsontable instance.
Until a licence is loaded the client falls back to the non-commercial key
(`DEFAULT_HOT_LICENCE_KEY` in `client/src/app/services/licence.service.ts`), so there is nothing to
configure in `index.html` for this.
```
<sasjs
...
hotLicenceKey="LICENCE-GOES-HERE"
>
</sasjs>
```
## Development
Update `client/src/index.html` so that it points to your SAS9, SASVIYA or SASJS backend.
Be aware that VIYA can be configured in such way that it would not work with cross origin frontend.
+164
View File
@@ -0,0 +1,164 @@
export {}
const hostUrl = Cypress.env('hosturl')
const appLocation = Cypress.env('appLocation')
const longerCommandTimeout = Cypress.env('longerCommandTimeout')
context('Export DC Library DDL: ', function () {
this.beforeAll(() => {
cy.loginAndUpdateValidKey(true)
})
this.beforeEach(() => {
cy.visit(hostUrl + appLocation)
visitPage('home')
})
it('1 | the admin action opens the flavour chooser and exports the SAS flavour by default', () => {
visitPage('system')
cy.get('.admin-action', { timeout: longerCommandTimeout })
.contains('Export DC Library DDL')
.should('exist')
cy.get('.admin-action').contains('button', 'EXPORT').should('exist')
cy.window().then((win) => {
cy.stub(win, 'open').as('open')
})
cy.get('.admin-action').contains('button', 'EXPORT').click()
const ddlModal = () => cy.contains('clr-modal', 'Export DC Library DDL')
ddlModal().should('be.visible')
ddlModal().within(() => {
cy.get('select').should('have.value', 'SAS')
// the schema box only applies to the DB flavours
cy.contains('label', 'Schema (optional)').should('not.exist')
})
ddlModal().contains('button', 'Export').click()
cy.get('@open').should(
'have.been.calledWithMatch',
/\/services\/admin\/exportdb&flavour=SAS$/
)
})
it('2 | the PGSQL flavour reveals the optional schema box and passes it to the service', () => {
visitPage('system')
cy.window().then((win) => {
cy.stub(win, 'open').as('open')
})
cy.get('.admin-action').contains('button', 'EXPORT').click()
const ddlModal = () => cy.contains('clr-modal', 'Export DC Library DDL')
ddlModal().within(() => {
cy.get('select').select('PGSQL')
cy.contains('label', 'Schema (optional)').should('be.visible')
cy.get('input[type="text"]').clear().type('DC')
})
ddlModal().contains('button', 'Export').click()
cy.get('@open').should(
'have.been.calledWithMatch',
/\/services\/admin\/exportdb&flavour=PGSQL&schema=DC$/
)
})
it('3 | the TSQL flavour exports without a schema when the box is left empty', () => {
visitPage('system')
cy.window().then((win) => {
cy.stub(win, 'open').as('open')
})
cy.get('.admin-action').contains('button', 'EXPORT').click()
const ddlModal = () => cy.contains('clr-modal', 'Export DC Library DDL')
ddlModal().within(() => {
cy.get('select').select('TSQL')
cy.contains('label', 'Schema (optional)').should('be.visible')
})
ddlModal().contains('button', 'Export').click()
cy.get('@open').should(
'have.been.calledWithMatch',
/\/services\/admin\/exportdb&flavour=TSQL$/
)
})
it('4 | the SAS export streams the DC library DDL', () => {
visitPage('system')
cy.window().then((win) => {
cy.stub(win, 'open').as('open')
})
cy.get('.admin-action').contains('button', 'EXPORT').click()
const ddlModal = () => cy.contains('clr-modal', 'Export DC Library DDL')
ddlModal().contains('button', 'Export').click()
// the URL the action opens is served by the backend - against the mocks
// that is services/admin/exportdb.js, which renders the library as basic
// DDL per flavour
cy.get('@open').then((open: any) => {
const url = open.getCall(0).args[0]
expect(url).to.match(/\/services\/admin\/exportdb&flavour=SAS$/)
cy.request(url).then((res) => {
expect(res.status).to.eq(200)
expect(res.headers['content-disposition']).to.contain('DC_JSLIB.ddl')
expect(res.body).to.contain('create table DC_JSLIB.MPE_CONFIG(')
expect(res.body).to.contain('VAR_NAME char(32)')
expect(res.body).to.contain('create table DC_JSLIB.MPE_X_TEST(')
// the SAS flavour has no schema statement and no unique index
expect(res.body).not.to.contain('CREATE SCHEMA')
expect(res.body).not.to.contain('UNIQUE INDEX')
})
})
})
it('5 | the PGSQL export honours the schema and quotes its identifiers', () => {
visitPage('system')
cy.window().then((win) => {
cy.stub(win, 'open').as('open')
})
cy.get('.admin-action').contains('button', 'EXPORT').click()
const ddlModal = () => cy.contains('clr-modal', 'Export DC Library DDL')
ddlModal().within(() => {
cy.get('select').select('PGSQL')
cy.get('input[type="text"]').clear().type('DC')
})
ddlModal().contains('button', 'Export').click()
cy.get('@open').then((open: any) => {
const url = open.getCall(0).args[0]
expect(url).to.match(/&flavour=PGSQL&schema=DC$/)
cy.request(url).then((res) => {
expect(res.status).to.eq(200)
expect(res.body).to.contain('CREATE SCHEMA DC;')
expect(res.body).to.contain('CREATE TABLE DC.MPE_CONFIG (')
expect(res.body).to.contain('"VAR_NAME" VARCHAR(32)')
expect(res.body).to.contain('CREATE UNIQUE INDEX "MPE_CONFIG_pk"')
})
})
})
})
const visitPage = (url: string) => {
cy.visit(`${hostUrl}${appLocation}/#/${url}`)
}
+415
View File
@@ -0,0 +1,415 @@
// 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.
//
// 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
export {}
const hostUrl = Cypress.env('hosturl')
const appLocation = Cypress.env('appLocation')
const longerCommandTimeout = Cypress.env('longerCommandTimeout')
const serverType = Cypress.env('serverType')
const libraryToOpenIncludes = Cypress.env(`libraryToOpenIncludes_${serverType}`)
context('EDIT row and cell limits, VIEW row limit: ', function () {
this.beforeAll(() => {
cy.visit(`${hostUrl}/SASLogon/logout`)
// forceLicenceKey=true: applies a valid licence rather than relying on an
// earlier spec in the run having done so (see commands.ts).
cy.loginAndUpdateValidKey(true)
})
this.beforeEach(() => {
cy.visit(hostUrl + appLocation)
visitPage('home')
visitPage('view/data')
})
it('1 | VIEW renders up to the raised row cap, past the old 500', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_search')
// Neither the header nor the cell text can pin the cap: the header reports
// the table's true row count whether or not the grid is capped, and the
// fixture's own numeric columns carry values above 1500 (DEPTH_M reaches
// 4293, SAMPLE_COUNT 4210), so a text match can hit fixture data.
//
// The PK can pin it. The first td of a .ht_master row is the PK - the row
// header, where present, is a th - and MPE_X_SEARCH holds PK 1001-2000, so
// the range check below is what stops a mis-selected column passing. With
// the old 500 row cap the grid stopped at PK 1500, so a rendered PK above
// 1500 can only appear if the cap was raised.
scrollGridToBottom()
cy.get('#hotTable .ht_master .htCore tbody tr', {
timeout: longerCommandTimeout
}).should(($rows) => {
const pks = $rows
.toArray()
.map((row) => row.querySelectorAll('td')[0])
.filter((td) => !!td)
.map((td) => parseInt((td.textContent || '').trim(), 10))
.filter((pk) => !isNaN(pk))
expect(pks.length).to.be.greaterThan(0)
pks.forEach((pk) => expect(pk).to.be.within(1001, 2000))
expect(Math.max(...pks)).to.be.greaterThan(1500)
})
})
it('2 | EDIT refuses a table over the row limit', () => {
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!'
)
})
it('3 | EDIT refuses a table over the cell limit', () => {
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(
'contain.text',
'Selection is too wide (200 rows x 1002 cols) - please filter and try again!'
)
})
it('4 | the EDIT limits follow MPE_CONFIG, not the shipped defaults', () => {
// Two things at once. The seeded config carries the shipped defaults (250
// rows / 200000 cells), which are the numbers the mock used to hard-code -
// so nothing above would notice if the mock stopped reading MPE_CONFIG at
// 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.
setConfigValue('DC_MAXCELLS_WEBEDIT', '110', () => {
visitPage('view/data/TESTDATA.MPE_X_NEW')
closeAbortModalIfPresent()
openEdit()
cy.get('.abortMsg', { timeout: longerCommandTimeout }).should(
'contain.text',
'Selection is too wide (7 rows x 18 cols) - please filter and try again!'
)
})
// Put the option back, so the rest of the estate sees the shipped default.
setConfigValue('DC_MAXCELLS_WEBEDIT', '200000', () => {
visitPage('view/data/TESTDATA.MPE_X_NEW')
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')
})
})
it('5 | the filter picker still lists the variables after the abort', () => {
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!'
)
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.
openFilterPopup()
filterVariables().should(($options) => {
const names = $options
.toArray()
.map((o) => (o.textContent || '').trim())
.filter((n) => n.length > 0)
expect(names, 'the picker offers the table columns').to.include.members([
'PRIMARY_KEY_FIELD',
'SITE_NAME',
'VESSEL',
'SAMPLE_COUNT',
'DEPTH_M',
'SPECIES',
'CRUISE_DATE',
'EXPEDITION_ID',
'NOTES'
])
})
// 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')
submitFilter()
cy.get('.infoBar-text', { timeout: longerCommandTimeout }).should(
'contain.text',
"SITE_NAME = 'Bristol'"
)
})
})
const visitPage = (url: string) => {
cy.visit(`${hostUrl}${appLocation}/#/${url}`)
}
// The viewer's options dropdown holds the Edit item; it only renders for a
// table that is registered in MPE_TABLES (tableEditExists()).
const openEdit = () => {
cy.get('.filterSide', { timeout: longerCommandTimeout }).click()
cy.contains('[clrDropdownItem]', 'Edit', {
timeout: longerCommandTimeout
}).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,
* which is where a failed EDIT load leaves it.
*/
const openFilterPopup = () => {
cy.contains('button.btnView', 'Filter', {
timeout: longerCommandTimeout
}).click()
cy.get('.filter-modal', { timeout: longerCommandTimeout }).should(
'be.visible'
)
}
/** The picker's variable suggestion list. */
const filterVariables = () =>
cy.get('#datalist_vals_var_id0_0 option', { timeout: longerCommandTimeout })
/** The soft-select drops a transparent overlay over the modal, so it needs 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 })
}
/** The value input of the same soft-select row. */
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 submitFilter = () => {
cy.contains('.filter-modal button', 'Ok').click()
cy.get('.app-loading', { timeout: longerCommandTimeout }).should('not.exist')
cy.wait(2500)
}
/**
* Sets a DC option in MPE_CONFIG through the editor and approves it, then hands
* back to the callback. The same route licensing.cy.ts uses for the licence
* keys: the config table needs an approval like any other.
*/
const setConfigValue = (
varName: string,
varValue: string,
callback: () => void
) => {
reloadApp()
// straight to the config table's editor, so the flow does not depend on
// whichever view mode the previous step left behind
visitPage('editor/DC_JSLIB.MPE_CONFIG')
closeAbortModalIfPresent()
clickOnEdit()
editTableField([{ varName, varValue }], () => {
submitTable(() => {
cy.wait(2000)
approveTable(() => {
reloadApp()
callback()
})
})
})
}
/**
* A full page load. A visit that only changes the hash does not reload the
* app, so an open modal - the abort message from the step just gone, say -
* would still be covering the page.
*/
const reloadApp = () => {
cy.visit(hostUrl + appLocation)
cy.get('.app-loading', { timeout: longerCommandTimeout }).should('not.exist')
}
/**
* DC puts abort messages up as modals over whatever page is showing, and they
* outlive a hash-only navigation, so clear one before driving the page.
*/
const closeAbortModalIfPresent = () => {
cy.get('body').then(($body) => {
if ($body.find('app-info-modal').length > 0) {
cy.get('app-info-modal')
.contains('button', 'Close')
.click({ force: true })
}
})
}
/**
* The viewer's Edit button. The options dropdown (.filterSide) that openEdit()
* uses is not rendered for every table, so the config table is opened the way
* licensing.cy.ts opens it.
*/
const clickOnEdit = (callback?: () => void) => {
cy.get('.btnCtrl button.btn-primary', { timeout: longerCommandTimeout })
.click()
.then(() => {
if (callback) callback()
})
}
/** Types a new value into the row whose cell carries varName. */
const editTableField = (
edits: { varName: string; varValue: string }[],
callback: () => void
) => {
cy.get('#hotTable .ht_master .htCore tbody td', {
timeout: longerCommandTimeout
}).then(($tds: any) => {
const nodes = $tds.toArray()
for (const edit of edits) {
for (let i = 0; i < nodes.length; i++) {
// exact match on the cell that holds the option name; the cell after it
// is the value
if ((nodes[i].textContent || '').trim() === edit.varName) {
cy.wrap(nodes[i + 1])
.dblclick()
.then(() => {
cy.focused()
.type('{selectall}')
.type(edit.varValue)
.type('{enter}')
})
break
}
}
}
callback()
})
}
const submitTable = (callback: () => void) => {
cy.get('.btnCtrl button.btn-primary', { timeout: longerCommandTimeout })
.click()
.then(() => {
cy.get(".modal.ng-star-inserted button[type='submit']").click()
callback()
})
}
const approveTable = (callback: () => void) => {
cy.get('button', { timeout: longerCommandTimeout })
.should('contain', 'Approve')
.then((allButtons: any) => {
for (const approvalButton of allButtons) {
if (approvalButton.innerText.toLowerCase().includes('approve')) {
approvalButton.click()
break
}
}
cy.get('button#acceptBtn', { timeout: longerCommandTimeout })
.should('exist')
.should('not.be.disabled')
.click()
.then(() => {
cy.get('app-history', { timeout: longerCommandTimeout }).should(
'exist'
)
callback()
})
})
}
// Handsontable virtualises rows, so the last row is only in the DOM once the
// grid has been scrolled to the bottom. The scroller is the .wtHolder inside
// the .ht_master pane.
const scrollGridToBottom = () => {
cy.get('#hotTable .ht_master .wtHolder', { timeout: longerCommandTimeout })
.scrollTo('bottom', { duration: 0 })
.trigger('scroll')
}
const openTableFromTree = (libNameIncludes: string, tablename: string) => {
cy.get('.app-loading', { timeout: longerCommandTimeout })
.should('not.exist')
.then(() => {
cy.get('.nav-tree clr-tree > clr-tree-node', {
timeout: longerCommandTimeout
}).then((treeNodes: any) => {
let targetLib
for (let node of treeNodes) {
if (node.innerText.toLowerCase().includes(libNameIncludes)) {
targetLib = node
break
}
}
cy.get(targetLib).within(() => {
// Small settle wait: right after a fresh visit the tree component can
// still be re-rendering, causing this node to be found then swapped
// out mid-click.
cy.wait(300)
cy.get(
'.clr-tree-node-content-container .clr-treenode-content p'
).click()
cy.get('.clr-treenode-link').then((innerNodes: any) => {
for (let innerNode of innerNodes) {
if (innerNode.innerText.toLowerCase().includes(tablename)) {
innerNode.click()
break
}
}
})
})
})
})
cy.get('#hotTable .ht_clone_top .htCore thead button.changeType', {
timeout: longerCommandTimeout
}).should('exist')
}
+1 -1
View File
@@ -10,7 +10,7 @@ const check = (cwd) => {
onlyAllow:
'AFLv2.1;Apache 2.0;Apache-2.0;Apache*;Artistic-2.0;BlueOak-1.0.0;0BSD;BSD*;BSD-2-Clause;BSD-3-Clause;CC0-1.0;CC-BY-3.0;CC-BY-4.0;ISC;MIT;MPL-2.0;ODC-By-1.0;Python-2.0;Unlicense;',
excludePackages:
'@cds/city@1.1.0;@handsontable/angular-wrapper@16.0.1;@handsontable/angular-wrapper@17.1.0;@handsontable/angular-wrapper@18.0.0;handsontable@^16.0.1;handsontable@16.2.0;handsontable@17.1.0;handsontable@18.0.0;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@^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'
},
(error, json) => {
if (error) {
+271 -469
View File
File diff suppressed because it is too large. Load diff
+14 -14
View File
@@ -41,21 +41,21 @@
},
"private": true,
"dependencies": {
"@angular/animations": "^20.3.27",
"@angular/animations": "20.3.33",
"@angular/cdk": "^20.2.14",
"@angular/common": "^20.3.27",
"@angular/compiler": "^20.3.27",
"@angular/core": "^20.3.27",
"@angular/forms": "^20.3.27",
"@angular/platform-browser": "^20.3.27",
"@angular/platform-browser-dynamic": "^20.3.27",
"@angular/router": "^20.3.27",
"@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",
"@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.0.0",
"@sasjs/adapter": "^4.18.0",
"@handsontable/angular-wrapper": "18.1.1",
"@sasjs/adapter": "4.19.1",
"@sasjs/utils": "^3.6.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.0.0",
"handsontable": "18.1.1",
"https-browserify": "1.0.0",
"hyperformula": "^2.5.0",
"iconv-lite": "^0.5.0",
@@ -99,8 +99,8 @@
"@angular-eslint/eslint-plugin-template": "19.8.1",
"@angular-eslint/schematics": "19.8.1",
"@angular-eslint/template-parser": "19.8.1",
"@angular/cli": "^20.3.32",
"@angular/compiler-cli": "^20.3.27",
"@angular/cli": "20.3.37",
"@angular/compiler-cli": "20.3.33",
"@babel/plugin-proposal-private-methods": "^7.18.6",
"@compodoc/compodoc": "^2.0.0",
"@cypress/webpack-preprocessor": "^5.17.1",
@@ -148,7 +148,7 @@
"ajv": "8.18.0",
"uuid": "11.1.1",
"lighthouse": "13.4.0",
"fast-uri": "3.1.7",
"fast-uri": "3.1.8",
"readdir-glob": {
"brace-expansion": "^5.0.9"
},
+1 -10
View File
@@ -3,8 +3,7 @@
[class*=ht-theme-classic] .htDropdownMenu table tbody tr td.htSubmenu .htItemWrapper::after,
[class*=ht-theme-classic] .htContextMenu table tbody tr td.htSubmenu .htItemWrapper::after,
[class*=ht-theme-classic] .htFiltersConditionsMenu table tbody tr td.htSubmenu .htItemWrapper::after,
[class*=ht-theme-classic] .pika-single .pika-next {
[class*=ht-theme-classic] .htFiltersConditionsMenu table tbody tr td.htSubmenu .htItemWrapper::after {
width: var(--ht-icon-size);
height: var(--ht-icon-size);
-webkit-mask-size: contain;
@@ -12,14 +11,6 @@
background-color: currentColor;
}
[class*=ht-theme-classic] .pika-single .pika-prev {
width: var(--ht-icon-size);
height: var(--ht-icon-size);
-webkit-mask-size: contain;
-webkit-mask-image: url("./assets/hot-icons/arrow-left.svg");
background-color: currentColor;
}
[class*=ht-theme-classic] .ht-page-size-section__select-wrapper::after {
width: var(--ht-icon-size);
height: var(--ht-icon-size);
+13
View File
@@ -318,6 +318,7 @@
class="startup-check"
[class.startup-check--error]="step.status === 'error'"
[class.startup-check--done]="step.status === 'done'"
[class.startup-check--grid]="step.details?.length"
>
@switch (step.status) {
@case ('pending') {
@@ -346,6 +347,18 @@
@if (step.detail) {
<span class="startup-check__detail">{{ step.detail }}</span>
}
@if (step.details?.length) {
<div class="startup-check__grid">
@for (detail of step.details; track detail.name) {
<span class="startup-check__grid-name">{{
detail.name
}}</span>
<span class="startup-check__grid-value">{{
detail.value
}}</span>
}
</div>
}
</div>
}
</div>
+24 -17
View File
@@ -20,6 +20,8 @@ import { DcAdapterSettings } from './models/DcAdapterSettings'
import { AppStoreService } from './services/app-store.service'
import { LicenceService } from './services/licence.service'
import {
getSasjsTagDetails,
SASJS_TAG_STEP,
StartupCheckService,
StartupStep
} from './services/startup-check.service'
@@ -190,40 +192,45 @@ export class AppComponent {
* Parses adapter settings that are found in the <sasjs> tag inside index.html
*/
private parseDcAdapterSettings() {
/* The <sasjs> tag is a mandatory part of index.html - it carries serverUrl,
serverType and the rest of the adapter config - so there is no config to
fall back on when it is absent. A missing one is reported on the boot
screen below rather than handled with an abort modal. */
const sasjsElement = document.querySelector('sasjs')
if (!sasjsElement) {
this.licenceService.deactivateApp()
setTimeout(() => {
this.eventService.showAbortModal(
null,
"Please make sure 'SASJS' tag with config attributes is added to index.html",
null,
'SASjs Config not found'
)
})
return
}
const getAppAttribute = (attribute: string) =>
sasjsElement.getAttribute(attribute) || undefined
sasjsElement?.getAttribute(attribute) || undefined
const dcAdapterSettings = {
serverUrl: getAppAttribute('serverUrl') || '',
appLoc: getAppAttribute('appLoc') || '',
serverType: getAppAttribute('serverType'),
loginMechanism: getAppAttribute('loginMechanism') || '',
adminGroup: getAppAttribute('adminGroup') || '',
dcPath: getAppAttribute('dcPath') || '',
debug: getAppAttribute('debug') === 'true' || false,
useComputeApi: this.parseComputeApi(getAppAttribute('useComputeApi')),
runAsTask: getAppAttribute('runAsTask') === 'true' || false,
contextName: getAppAttribute('contextName') || '',
hotLicenceKey: getAppAttribute('hotLicenceKey') || ''
contextName: getAppAttribute('contextName') || ''
}
this.dcAdapterSettings = dcAdapterSettings as any
this.appStoreService.setDcAdapterSettings(dcAdapterSettings as any)
/* Show the <sasjs> tag contents on the boot screen, so the effective
configuration (serverType, debug, useComputeApi, ...) is visible without
having to read the page source. The tag is mandatory - without it the
adapter has no serverUrl or serverType - so a missing one is called out
plainly rather than silently leaving the step pending. */
if (sasjsElement) {
this.startupCheckService.setTagDetails(getSasjsTagDetails(sasjsElement))
} else {
this.startupCheckService.updateStep(
SASJS_TAG_STEP,
'error',
'SASJS TAG MISSING!!'
)
}
this.appService.sasServiceInit()
}
@@ -2,10 +2,7 @@ import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
import { DeployComponent } from './deploy.component'
const routes: Routes = [
{ path: '', component: DeployComponent },
{ path: 'manualdeploy', component: DeployComponent }
]
const routes: Routes = [{ path: '', component: DeployComponent }]
@NgModule({
imports: [RouterModule.forChild(routes)],
+11 -37
View File
@@ -29,20 +29,6 @@
></label
>
</clr-checkbox-wrapper>
<!-- <hr />
<div class="clr-checkbox-wrapper">
<input
[(ngModel)]="autodeploy"
type="checkbox"
id="checkbox2"
class="clr-checkbox"
checked
/>
<label for="checkbox2"
>Autodeploy
{{ !jsonFile ? '(json file is not available)' : '' }}</label
>
</div> -->
</div>
</div>
}
@@ -52,29 +38,17 @@
@if (step > 0) {
@switch (true) {
@case (sasJsConfig.serverType === ServerType.SasViya) {
@if (autodeploy) {
<div class="autodeploy-section card">
<app-automatic-deploy
[sasJs]="sasJs"
[sasJsConfig]="sasJsConfig"
[dcAdapterSettings]="dcAdapterSettings"
[appLoc]="appLoc"
[dcPath]="dcPath"
[selectedAdminGroup]="selectedAdminGroup"
(onNavigateToHome)="onNavigateToHome()"
></app-automatic-deploy>
</div>
}
@if (!autodeploy) {
<div id="mainbody" class="card">
<app-manual-deploy
[sasJs]="sasJs"
[sasJsConfig]="sasJsConfig"
[dcAdapterSettings]="dcAdapterSettings"
(onNavigateToHome)="onNavigateToHome()"
></app-manual-deploy>
</div>
}
<div class="autodeploy-section card">
<app-automatic-deploy
[sasJs]="sasJs"
[sasJsConfig]="sasJsConfig"
[dcAdapterSettings]="dcAdapterSettings"
[appLoc]="appLoc"
[dcPath]="dcPath"
[selectedAdminGroup]="selectedAdminGroup"
(onNavigateToHome)="onNavigateToHome()"
></app-automatic-deploy>
</div>
}
@case (sasJsConfig.serverType === ServerType.Sasjs) {
<div class="autodeploy-section card">
+2 -52
View File
@@ -1,8 +1,6 @@
import { Component, OnInit, ViewEncapsulation } from '@angular/core'
import { SasService } from '../services/sas.service'
import { SASjsConfig } from '@sasjs/adapter'
import { Router } from '@angular/router'
import { LoggerService } from '../services/logger.service'
import { ServerType } from '@sasjs/utils/types/serverType'
import { AppStoreService } from '../services/app-store.service'
import { DcAdapterSettings } from '../models/DcAdapterSettings'
@@ -19,15 +17,11 @@ import { DcAdapterSettings } from '../models/DcAdapterSettings'
})
export class DeployComponent implements OnInit {
public step: number = 0
public adminGroups: any = []
public appLoc: string = ''
public dcPath: string = ''
public selectedAdminGroup: string = ''
public autodeploy: boolean = true
public jsonFile: any = null
public sasJs: any
public sasJsConfig: SASjsConfig = new SASjsConfig()
@@ -37,16 +31,10 @@ export class DeployComponent implements OnInit {
constructor(
private appStoreService: AppStoreService,
private sasService: SasService,
private loggerService: LoggerService,
private router: Router
private sasService: SasService
) {
this.dcAdapterSettings = this.appStoreService.getDcAdapterSettings()
if (this.router.url.includes('manualdeploy')) {
this.autodeploy = false
}
this.sasJs = this.sasService.getSasjsInstance()
this.sasJsConfig = this.sasService.getSasjsConfig()
this.appLoc = this.dcAdapterSettings?.appLoc || ''
@@ -61,54 +49,16 @@ export class DeployComponent implements OnInit {
*/
public setDeployDefaults() {
this.dcPath = this.dcAdapterSettings?.dcPath || ''
this.selectedAdminGroup = this.dcAdapterSettings?.adminGroup || ''
if (!this.selectedAdminGroup) {
this.selectedAdminGroup = 'SASAdministrators'
}
this.selectedAdminGroup = 'SASAdministrators'
}
/**
* Accepting terms of service shows next screen
*/
public termsAgreeChange() {
if (!this.autodeploy) {
this.getAdminGroups()
}
this.step++
}
/**
* Fetches admin groups from VIYA to be selected for a backend deploy
*/
public getAdminGroups() {
fetch(
this.sasJsConfig.serverUrl + '/identities/groups?sortBy=name&limit=5000',
{
headers: {
Accept: 'application/json'
}
}
)
.then((res: any) => {
return res.text()
})
.then((res: any) => {
let jsonRes
try {
jsonRes = JSON.parse(res)
} catch (err: any) {
console.error(err)
}
if (jsonRes) {
this.adminGroups = jsonRes.items
this.selectedAdminGroup = this.adminGroups[0].id
}
})
}
public onNavigateToHome() {
window.open(location.href.split('#')[0], '_blank')
}
-2
View File
@@ -2,7 +2,6 @@ import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { DeployComponent } from './deploy.component'
import { AutomaticComponent } from './sections/automatic/automatic.component'
import { ManualComponent } from './sections/manual/manual.component'
import { SasjsConfiguratorComponent } from './sections/sasjs-configurator/sasjs-configurator.component'
import { ClarityModule } from '@clr/angular'
import { FormsModule } from '@angular/forms'
@@ -12,7 +11,6 @@ import { DeployRoutingModule } from './deploy-routing.module'
declarations: [
DeployComponent,
AutomaticComponent,
ManualComponent,
SasjsConfiguratorComponent
],
imports: [CommonModule, FormsModule, ClarityModule, DeployRoutingModule]
@@ -1,229 +0,0 @@
<div class="card-header position-relative">
Configurator
<p class="d-inline-block ml-10 mb-10 mt-0">App Location: {{ appLoc }}</p>
<button
(click)="deleteKeys()"
class="btn btn-primary clear-memory-button position-absolute"
>
Clear memory
</button>
</div>
<div class="card-block">
<!-- <div class="card-title">
Client Details
</div> -->
@if (needsLogin) {
<div id="loginForm" class="d-none">
<p class="mb-10">Please log in first</p>
<label for="username" class="clr-control-label">Username</label>
<div class="mb-10 clr-control-container">
<div class="clr-input-wrapper">
<input type="text" id="username" class="clr-input" />
</div>
</div>
<label for="password" class="clr-control-label">Password</label>
<div class="mb-10 clr-control-container">
<div class="clr-input-wrapper">
<input type="password" id="password" class="clr-input" />
</div>
</div>
<button class="btn btn-primary d-none" id="loginBtn">Log in</button>
</div>
}
@if (!needsLogin) {
<form>
<div class="clr-form-control">
<label for="select-full" class="clr-control-label">Admin group</label>
<div class="clr-control-container">
<div class="clr-select-wrapper">
<select
[(ngModel)]="selectedAdminGroup"
[ngModelOptions]="{ standalone: true }"
name="selectedAdminGroup"
id="adminGroupsSelect"
class="clr-select"
>
@for (adminGroup of adminGroups; track adminGroup) {
<option [value]="adminGroup.id">
{{ adminGroup.name }}
</option>
}
</select>
</div>
</div>
</div>
<div class="clr-form-control">
<div [class.hidden]="contextsLoading">
<label for="select-full" class="clr-control-label">Context</label>
<div class="clr-control-container">
<div class="clr-select-wrapper">
<select
[(ngModel)]="selectedContext"
[ngModelOptions]="{ standalone: true }"
name="selectedContext"
class="clr-select"
>
@for (context of allContexts; track context) {
<option [value]="context.name">
{{ context.name }}
@if ((context.attributes | json) != '{}') {
<span>( {{ context.attributes.sysUserId }} )</span>
}
</option>
}
</select>
</div>
</div>
<button
(click)="executableContext()"
type="button"
class="btn btn-icon"
id="contexts-btn"
>
<clr-icon shape="play"></clr-icon>
</button>
</div>
<div [class.hidden]="!contextsLoading" class="d-flex">
<span class="spinner spinner-inline mr-10">
Loading contexts...
</span>
<span> Loading contexts... </span>
</div>
<label for="dcloc" class="mt-20 clr-control-label">DC Loc</label>
<div class="mb-10 clr-control-container">
<div class="clr-input-wrapper">
<input
[(ngModel)]="dcPath"
[ngModelOptions]="{ standalone: true }"
(focusout)="saveDcPath()"
name="dcPath"
type="text"
id="dcloc"
class="clr-input"
/>
</div>
</div>
<p class="mt-10">
Select JSON file to upload (json build file preloaded):
</p>
<div class="d-flex flex-column">
<input
type="file"
(click)="clearUploadInput($event)"
(change)="onJsonFileChange($event)"
/>
<!-- <button *ngIf="downloadFileBtn" (click)="downloadSasPrecodeFile()" style="width: 40px; min-width: 0;" class="btn btn-sm btn-icon">
<clr-icon shape="download"></clr-icon>
</button> -->
</div>
<div class="mt-20 d-flex align-items-center">
<button
(click)="executeJson()"
class="btn btn-primary"
[clrLoading]="isSubmittingJson"
id=""
class="mt-0"
>
SUBMIT JSON
</button>
@if (isJsonSubmitted) {
<span>JSON Submitted Successfully</span>
}
<!-- <span *ngIf="executingScript" class="spinner spinner-inline ml-3">
Loading...
</span> -->
</div>
<p class="mt-10">Select SAS file to upload:</p>
<div class="d-flex flex-column">
<input
type="file"
(click)="clearUploadInput($event)"
(change)="onSasFileChange($event); downloadFileBtn = true"
/>
@if (downloadFileBtn) {
<button
(click)="downloadSasPrecodeFile()"
class="btn btn-sm btn-icon min-w-0 w-40"
>
<clr-icon shape="download"></clr-icon>
</button>
}
</div>
<div class="mt-20 d-flex align-items-center">
<button
(click)="executeSAS()"
class="btn btn-primary"
[clrLoading]="executingScript"
id="deploy"
class="mt-0"
>
SUBMIT
</button>
<!-- <span *ngIf="executingScript" class="spinner spinner-inline ml-3">
Loading...
</span> -->
</div>
</div>
@if (jobLog.length > 0) {
<p class="mb-0 mt-10">File execute completed</p>
<hr />
<div>
<button
(click)="downloadFile(jobLog, 'execute-script-log', 'txt')"
class="btn btn-primary mt-0 mr-20"
>
Download log
</button>
</div>
}
<button
[clrLoading]="createDatabaseLoading"
(click)="createDatabase()"
class="btn btn-primary mt-10"
>
Create Database
</button>
@if (makeDataResponse.length > 0) {
<p class="mb-0 mt-10">Create Database Completed</p>
<hr />
@if (makeDataResponse.length > 0) {
<div class="log-wrapper">
{{ makeDataResponse }}
</div>
}
<button (click)="navigateToHome()" class="btn btn-primary mt-15">
Let's get started
</button>
<button
(click)="downloadFile(makeDataResponse, 'create-database-log', 'txt')"
class="btn btn-primary mt-15"
>
Download log
</button>
<button (click)="validateDeploy()" class="btn btn-primary mt-15">
Validate
</button>
@if (validationState !== 'none' || isValidating) {
<div class="validation-bar">
@if (isValidating) {
<span class="spinner spinner-inline mr-10">
Validating deploy...
</span>
<span> Validating deploy... </span>
}
@if (!isValidating && validationState === 'error') {
<clr-icon shape="exclamation-circle" class="is-error"></clr-icon>
<span> Validation failed </span>
}
@if (!isValidating && validationState === 'success') {
<clr-icon shape="check-circle" class="is-success"></clr-icon>
<span> Validation succeeded </span>
}
</div>
}
}
</form>
}
</div>
Whitespace-only changes.
@@ -1,90 +0,0 @@
import { ManualComponent } from './manual.component'
// validateDeploy() is fire-and-forget (doesn't return its own promise
// chain), so tests need to wait for it to settle some other way. A
// macrotask boundary guarantees every already-queued microtask (regardless
// of how many .then() hops validateDeploy's chain has) has run first.
const flushPromiseChain = () => new Promise((resolve) => setTimeout(resolve, 0))
/**
* validateDeploy() only touches sasService.request, eventService.showInfoModal
* and loggerService.log - these stubs cover exactly that surface, not the
* full real services (no TestBed/DI needed, same plain-instantiation-with-
* stubs precedent as app.service.spec.ts).
*/
const buildDeps = () => {
const sasService: any = {
request: jasmine.createSpy('request')
}
const eventService: any = {
showInfoModal: jasmine.createSpy('showInfoModal')
}
const loggerService: any = {
log: jasmine.createSpy('log')
}
const deployService: any = {}
return { sasService, eventService, loggerService, deployService }
}
const buildManualComponent = (deps: ReturnType<typeof buildDeps>) =>
new ManualComponent(
deps.sasService,
deps.eventService,
deps.loggerService,
deps.deployService
)
describe('ManualComponent - validateDeploy', () => {
it('sets validationState to success when saslibs is present on a normal object response', async () => {
const deps = buildDeps()
deps.sasService.request.and.resolveTo({
adapterResponse: { saslibs: { SOME_LIB: ['SOME_TABLE'] } }
})
const component = buildManualComponent(deps)
component.validateDeploy()
await flushPromiseChain()
expect(component.validationState).toBe('success')
expect(deps.eventService.showInfoModal).not.toHaveBeenCalled()
})
it('sets validationState to error with no modal when saslibs is missing from an otherwise normal object response', async () => {
const deps = buildDeps()
deps.sasService.request.and.resolveTo({
adapterResponse: { SYSSITE: 'SITE1' }
// saslibs deliberately omitted
})
const component = buildManualComponent(deps)
component.validateDeploy()
await flushPromiseChain()
expect(component.validationState).toBe('error')
expect(deps.eventService.showInfoModal).not.toHaveBeenCalled()
})
// Same underlying bug as app.service.spec.ts's equivalent test: a
// misconfigured Viya computeTasks deployment returns a raw "Job error"
// text body, which the adapter resolves as adapterResponse verbatim.
it('sets validationState to error and shows the real response text when adapterResponse is a raw string, not an object', async () => {
const deps = buildDeps()
const rawJobError = [
'Job error',
'The Compute service could not execute the task c74a707a-8b88-46a5-8f28-9a7694ad5e13 because the context 340cd3eb-72ae is not reusable. Please provide a reusable context or supply the ID of an existing session in the task request.',
'path: /compute/tasks',
'correlator: dc1a9fda-8f65-4f32-b4d4-2a70e02179b1;1bcb6a4c-8a16-4053-a790-292544c25a03'
].join('\n')
deps.sasService.request.and.resolveTo({ adapterResponse: rawJobError })
const component = buildManualComponent(deps)
component.validateDeploy()
await flushPromiseChain()
expect(component.validationState).toBe('error')
expect(deps.eventService.showInfoModal).toHaveBeenCalledTimes(1)
const [, message] = deps.eventService.showInfoModal.calls.mostRecent().args
expect(message).toContain(rawJobError)
})
})
@@ -1,346 +0,0 @@
import {
Component,
Input,
OnInit,
Output,
EventEmitter,
ViewEncapsulation
} from '@angular/core'
import SASjs, { SASjsConfig } from '@sasjs/adapter'
import { DcAdapterSettings } from 'src/app/models/DcAdapterSettings'
import { RequestWrapperResponse } from 'src/app/models/request-wrapper/RequestWrapperResponse'
import { DeployService } from 'src/app/services/deploy.service'
import { EventService } from 'src/app/services/event.service'
import { LoggerService } from 'src/app/services/logger.service'
import { SasService } from 'src/app/services/sas.service'
import { getMalformedAdapterResponseMessage } from 'src/app/shared/utils/get-malformed-adapter-response-message'
@Component({
selector: 'app-manual-deploy',
templateUrl: './manual.component.html',
styleUrls: ['./manual.component.scss'],
encapsulation: ViewEncapsulation.None,
standalone: false
})
export class ManualComponent implements OnInit {
@Input() sasJs!: SASjs
@Input() sasJsConfig: SASjsConfig = new SASjsConfig()
@Input() dcAdapterSettings: DcAdapterSettings | undefined
@Output() onNavigateToHome: EventEmitter<any> = new EventEmitter<any>()
public needsLogin: boolean = false
public adminGroups: any = []
public allContexts: any = []
public appLoc: string = ''
public dcPath: string = ''
public selectedAdminGroup: string = ''
public selectedContext: string = ''
public jobLog: string = ''
public makeDataResponse: string = ''
public linesOfCode: any = []
public fileName: string = ''
public preloadedFile: boolean = true
public executeSASEnabled: boolean = false
public contextsLoading: boolean = false
public createDatabaseLoading: boolean = false
public executingScript: boolean = false
public downloadFileBtn: boolean = false
public isValidating: boolean = false
public jsonFile: any = null
public isSubmittingJson: boolean = false
public isJsonSubmitted: boolean = false
public validationState: string = 'none'
constructor(
private sasService: SasService,
private eventService: EventService,
private loggerService: LoggerService,
private deployService: DeployService
) {}
ngOnInit(): void {}
/**
* FIXME: Remove
*/
public async executableContext() {
// getExecutableContexts now need AuthConfig parameter which we don't have on web
// this.contextsLoading = true
// let contexts = await this.sasJs.getExecutableContexts()
// this.allContexts = contexts
// if (contexts.length === 0) {
// this.selectedContext = ''
// } else {
// this.selectedContext = this.allContexts[0].name
// }
// this.contextsLoading = false
}
/**
* Removes sas.json file attached to the input
*/
public clearUploadInput(event: Event) {
this.deployService.clearUploadInput(event)
}
/**
* Reads attached SAS file to be sent to sas for execution (backend deploy)
*/
public onSasFileChange(event: any) {
this.preloadedFile = false
let file = event.target.files[0]
this.fileName = file.name
let fileReader = new FileReader()
fileReader.onload = () => {
if (fileReader.result) {
this.linesOfCode = (fileReader.result as string).split('\n')
this.linesOfCode = this.linesOfCode.filter((el: string) => {
return el !== '' && el !== null
})
this.executeSASEnabled = true
this.addPrecodeLines()
}
}
fileReader.readAsText(file)
}
/**
* Reads attached JSON file to be sent to sas for execution (backend deploy)
*/
public async onJsonFileChange(event: any) {
let file = event.target.files[0]
this.jsonFile = await this.deployService.readFile(file)
}
/**
* Appending precode lines to the attached sas or json file for backend deploy
*/
public addPrecodeLines() {
let headerLines = [
`%let context=${this.selectedContext};`,
`%let appLoc=${this.appLoc};`,
`%let admin=${this.selectedAdminGroup};`,
`%let dcpath=${this.dcPath};`
]
this.linesOfCode.unshift(...headerLines)
}
/**
* Downloadng file with precode included
*/
public downloadSasPrecodeFile() {
let linesAsText = this.linesOfCode.join('\n')
let filename = this.fileName.split('.')[0]
this.downloadFile(linesAsText, filename, 'sas')
}
/**
* Used for downloading log and repsonse as a file
*/
public downloadFile(
content: any,
filename: string,
extension: string = 'txt'
) {
this.deployService.downloadFile(content, filename, extension)
}
/**
* Saving dcpath to localstorage
* FIXME: maybe it'snot necessary
*/
public saveDcPath() {
localStorage.setItem('deploy_dc_loc', this.dcPath)
}
/**
* Send sas.json to be executed (deploying backend)
*/
public async executeJson() {
this.isSubmittingJson = true
try {
let uploadJsonFile = await this.sasJs.deployServicePack(
this.jsonFile,
this.dcAdapterSettings?.appLoc || '',
undefined,
undefined,
true
)
this.isJsonSubmitted = true
} catch (ex: any) {
let textEx = ''
if (typeof ex.message !== 'string') {
textEx = JSON.stringify(ex).replace(/\\/gm, '')
} else {
textEx = ex.message
}
this.eventService.showInfoModal(
'Deploy error',
`Exception: \n ${textEx !== '' ? textEx : ex}`
)
return
}
this.isSubmittingJson = false
}
/**
* Send sas file to be executed (deploying backend)
*/
public async executeSAS() {
this.executingScript = true
this.jobLog = ''
this.makeDataResponse = ''
try {
let executeResponse = await this.sasJs.executeScript({
fileName: this.fileName,
linesOfCode: this.linesOfCode,
contextName: this.selectedContext
})
this.loggerService.log(executeResponse)
if (typeof executeResponse.log === 'string') {
executeResponse.log = JSON.parse(executeResponse.log)
}
if (executeResponse.jobStatus === 'error') {
alert('Error!')
} else {
this.jobLog = executeResponse.log.items
? executeResponse.log.items.map((i: any) => i.line).join('\n')
: JSON.stringify(executeResponse.log)
}
this.executingScript = false
} catch (err) {
this.executingScript = false
}
}
/**
* Running makedata service
* @param newTab open and run in new tab
*/
public createDatabase(newTab: boolean = true) {
if (newTab) {
// Debug is ALWAYS enabled for makedata — it runs only once during
// deployment and the full log is needed to diagnose any issues.
const _debug = '&_debug=131'
let url =
this.sasService.getSasjsConfig().serverUrl +
'/SASJobExecution/?_program=' +
this.dcAdapterSettings?.appLoc ||
'' +
'/admin/makedata' +
'&ADMIN=' +
this.selectedAdminGroup +
'&DCPATH=' +
this.dcPath +
_debug
window.open(url, '_blank')
return
}
this.createDatabaseLoading = true
let data = {
fromjs: [
{
ADMIN: this.selectedAdminGroup,
DCPATH: this.dcPath
}
]
}
/**
* We are overriding default `sasjsConfig` object fields with this object fields.
* Here we want to run this request using original WEB method.
* contextName: null is the MUST field for it.
*/
let overrideConfig = {
useComputeApi: null,
contextName: this.sasJsConfig.contextName,
debug: true
}
this.sasJs
.request(`services/admin/makedata`, data, overrideConfig, () => {
this.sasService.shouldLogin.next(true)
})
.then((res: any) => {
try {
this.makeDataResponse = JSON.stringify(res)
} catch {
this.makeDataResponse = res
}
this.createDatabaseLoading = false
})
.catch((err: any) => {
this.createDatabaseLoading = false
try {
this.makeDataResponse = JSON.stringify(err)
} catch {
this.makeDataResponse = err
}
})
}
public navigateToHome() {
this.onNavigateToHome.emit()
}
public validateDeploy() {
this.isValidating = true
this.sasService
.request('public/startupservice', null)
.then((res: RequestWrapperResponse) => {
this.loggerService.log(res.adapterResponse)
const malformedMessage = getMalformedAdapterResponseMessage(
res.adapterResponse
)
if (malformedMessage) {
this.validationState = 'error'
this.eventService.showInfoModal('Error', malformedMessage)
} else if (res.adapterResponse.saslibs) {
this.validationState = 'success'
} else {
this.validationState = 'error'
}
this.isValidating = false
})
.catch((err: any) => {
this.isValidating = false
this.validationState = 'error'
})
}
public deleteKeys() {
localStorage.removeItem('deploy_dc_loc')
}
}
+10 -1
View File
@@ -1206,7 +1206,16 @@ export class EditorComponent
this.cdf.detectChanges()
this.submitLoading = false
this.sasStoreService.setQueryVariables(this.libds, this.cols)
// The columns that came with the table are the cheapest source for the
// picker. When the table was not returned - the row/cell limits abort the
// request before the data arrives - that list is empty, so the store
// falls back to public/getcols rather than leaving the picker with
// nothing to pick from.
this.sasStoreService
.setQueryVariablesFromTable(this.libds, this.cols)
.catch((err: any) =>
this.eventService.catchResponseError('public/getcols', err)
)
}
}
@@ -417,9 +417,14 @@ describe('formulas plugin must not be disabled/re-enabled around a table load',
* capping maxRows at editor_rows_allowed alone makes HyperFormula reject
* any table bigger than that cap outright. See initSetup's own
* Math.max(dataSource.length, editor_rows_allowed) fix.
*
* Handsontable 18.1 fixed the engine coupling upstream (#10672): the grid's
* maxRows/maxCols no longer limit the HyperFormula engine, so a capped
* maxRows no longer rejects a larger existing dataset. DC's own guard is
* still in place; the first test below now pins the fixed behaviour.
*/
describe('maxRows must never be smaller than the actual loaded row count', () => {
it('throws when maxRows is capped below the real row count (proves the mechanism)', () => {
it('no longer throws when maxRows is capped below the real row count (HOT 18.1 #10672)', () => {
const container = document.createElement('div')
document.body.appendChild(container)
const colNames = ['PK', 'A', 'B']
@@ -440,7 +445,7 @@ describe('maxRows must never be smaller than the actual loaded row count', () =>
error = e
}
expect(error?.message).toEqual('Sheet size limit exceeded')
expect(error).toBeNull()
})
it('does not throw when maxRows is never let below the actual row count (the fix)', () => {
@@ -287,9 +287,13 @@ describe('syncOverwrittenCommentForCell resolves the correct visual cell on a so
* trigger it, independent of what it actually changes. editTable(),
* cancelEdit(), and every other place editor.component.ts calls
* updateSettings() after the grid could already be sorted (e.g. the user
* sorted while still read-only, then clicked Edit) hits this. The fix
* (updateSettingsSortSafe) clears the sort before the call and restores it
* immediately afterward.
* sorted while still read-only, then clicked Edit) hits this.
*
* Fixed upstream in Handsontable 18.1 (the formulas plugin now keeps its
* index translations across updateSettings), so the corruption no longer
* occurs. DC's own guard (updateSettingsSortSafe - clear the sort, call
* updateSettings, restore the sort) is still in place and still correct;
* this test now pins the fixed behaviour so a future regression shows up.
*/
describe('updateSettings must not run while a sort is active - it corrupts formula cell references', () => {
const buildSortedGrid = (): Handsontable => {
@@ -340,13 +344,13 @@ describe('updateSettings must not run while a sort is active - it corrupts formu
hot.getDataAtRowProp(r, 'FORMULA_HARD_COL')
)
it('demonstrates the failure mode: updateSettings while sorted corrupts formula cells', () => {
it('no longer corrupts formula cells when updateSettings runs while sorted (HOT 18.1)', () => {
const hot = buildSortedGrid()
hot.updateSettings({}, false)
hot.render()
expect(formulaHardColValues(hot)).toContain('#REF!')
expect(formulaHardColValues(hot)).not.toContain('#REF!')
hot.destroy()
})
@@ -1,7 +1,5 @@
import { SASjsConfig } from '@sasjs/adapter'
export interface DcAdapterSettings extends Partial<SASjsConfig> {
adminGroup: string
dcPath: string
hotLicenceKey: string
}
+11 -7
View File
@@ -8,13 +8,20 @@ import * as encoding from 'text-encoding'
import { Router } from '@angular/router'
import { Globvar } from '../models/sas/public-startupservice.model'
import { freeTierConfig } from '../free-tier.config'
import { AppStoreService } from './app-store.service'
import { HelperService } from './helper.service'
import { LicenceState } from '../models/LicenceState'
import { decodeLicenceFeatures as decodeFeatures } from './utils/decodeLicenceFeatures'
import { LoggerService } from './logger.service'
import { EventService } from './event.service'
/**
* Handsontable licence key used until a licence is loaded from the backend.
* The decrypted licence carries the customer's own key (`hot_license_key`), so
* this is only the pre-licence / free-tier default - it used to be supplied by
* the `<sasjs>` tag.
*/
export const DEFAULT_HOT_LICENCE_KEY = 'non-commercial-and-evaluation'
@Injectable({
providedIn: 'root'
})
@@ -43,7 +50,7 @@ export class LicenceService {
private freeTierLicenceData: LicenseKeyData = {
demo: true,
hot_license_key: this.appStoreService.getDcAdapterSettings()?.hotLicenceKey,
hot_license_key: DEFAULT_HOT_LICENCE_KEY,
users_allowed: this._licenceState.users_allowed || 1,
valid_until: moment().add(1, 'year').format('YYYY-MM-DD'),
site_id: '',
@@ -63,7 +70,6 @@ export class LicenceService {
constructor(
private loggerService: LoggerService,
private appStoreService: AppStoreService,
private eventService: EventService,
private sasService: SasService,
private helperService: HelperService,
@@ -79,8 +85,7 @@ export class LicenceService {
}
public async activation(startupservice: any) {
this.freeTierLicenceData.hot_license_key =
this.appStoreService.getDcAdapterSettings()?.hotLicenceKey
this.freeTierLicenceData.hot_license_key = DEFAULT_HOT_LICENCE_KEY
await this.setStartupserviceRules(startupservice)
@@ -225,8 +230,7 @@ export class LicenceService {
? licenseData.hot_license_key
: this.hot_license_key.value
if (!hotLicenceKey)
hotLicenceKey = this.appStoreService.getDcAdapterSettings()?.hotLicenceKey
if (!hotLicenceKey) hotLicenceKey = DEFAULT_HOT_LICENCE_KEY
this.hot_license_key.next(hotLicenceKey)
@@ -0,0 +1,118 @@
import { SasStoreService } from './sas-store.service'
/**
* The filter picker keys its operator set, its variable highlight and its value
* quoting on Col.TYPE being exactly 'num' or 'char'. On the normal path
* mergeColsRules derives that from $dataFormats; on the public/getcols fallback
* (used when the EDIT/VIEW limits abort the load) the service speaks the SAS
* vocabulary (TYPE C/N, DDTYPE separating DATE/DATETIME/TIME), so the store has
* to map it. These tests pin both the mapping and the empty-payload guard.
*/
describe('SasStoreService', () => {
const buildStore = (cols: any[]) =>
new SasStoreService(
{
request: () => Promise.resolve({ adapterResponse: { cols } })
} as any,
{} as any,
{} as any
)
const collectColumns = (store: SasStoreService) => {
const seen: any[] = []
store.columns.subscribe((value: any) => seen.push(value))
return seen
}
describe('getCols', () => {
it('maps the SAS C/N TYPE onto the picker vocabulary', async () => {
const store = buildStore([
{ NAME: 'CHAR_COL', TYPE: 'C', DDTYPE: 'C' },
{ NAME: 'NUM_COL', TYPE: 'N', DDTYPE: 'N' },
{ NAME: 'DATE_COL', TYPE: 'N', DDTYPE: 'DATE' },
{ NAME: 'DATETIME_COL', TYPE: 'N', DDTYPE: 'DATETIME' },
{ NAME: 'TIME_COL', TYPE: 'N', DDTYPE: 'TIME' }
])
const cols = await store.getCols('DC.MY_TABLE')
expect(cols.map((col: any) => col.TYPE)).toEqual([
'char',
'num',
'num',
'num',
'num'
])
})
it('leaves the rest of the column payload untouched', async () => {
const store = buildStore([
{
NAME: 'CHAR_COL',
TYPE: 'C',
DDTYPE: 'C',
LENGTH: 8,
LABEL: 'A label'
}
])
const cols = await store.getCols('DC.MY_TABLE')
expect(cols[0].NAME).toBe('CHAR_COL')
expect(cols[0].DDTYPE).toBe('C')
expect(cols[0].LENGTH).toBe(8)
expect(cols[0].LABEL).toBe('A label')
})
it('returns an empty list rather than throwing when the payload is empty', async () => {
const store = buildStore([])
const cols = await store.getCols('DC.MY_TABLE')
expect(cols).toEqual([])
})
})
describe('setQueryVariablesFromTable', () => {
it('publishes the table columns without calling the service', async () => {
let called = false
const store = new SasStoreService(
{
request: () => {
called = true
return Promise.resolve({})
}
} as any,
{} as any,
{} as any
)
const seen = collectColumns(store)
await store.setQueryVariablesFromTable('DC.MY_TABLE', [{ NAME: 'A' }])
expect(called).toBe(false)
expect(seen.length).toBe(1)
expect(seen[0].data.cols.length).toBe(1)
})
it('falls back to getcols and publishes the mapped columns', async () => {
const store = buildStore([{ NAME: 'A', TYPE: 'C', DDTYPE: 'C' }])
const seen = collectColumns(store)
await store.setQueryVariablesFromTable('DC.MY_TABLE', [])
expect(seen.length).toBe(1)
expect(seen[0].libds).toBe('DC.MY_TABLE')
expect(seen[0].data.cols[0].TYPE).toBe('char')
})
it('does not publish an empty fallback, which the picker cannot render', async () => {
const store = buildStore([])
const seen = collectColumns(store)
await store.setQueryVariablesFromTable('DC.MY_TABLE', [])
expect(seen.length).toBe(0)
})
})
})
@@ -295,6 +295,68 @@ export class SasStoreService {
this.columns.next(columnsData)
}
/**
* Column metadata for the filter picker, read from the service.
*
* The picker's variable list normally comes from the columns that arrived
* with the table (see setQueryVariablesFromTable). This is the fallback for
* when there is no table - public/getcols reads the metadata only.
*
* public/getcols speaks the SAS vocabulary: TYPE is C/N and DDTYPE separates
* DATE, DATETIME and TIME. The picker keys its operator set (query.component),
* its variable highlight and its value quoting (sas-store) on the client
* vocabulary instead - TYPE is exactly 'num' or 'char' - which
* mergeColsRules derives from $dataFormats on the normal path. That pass
* never runs for this fallback, so map TYPE here, with the same rule: the
* date and time types are numeric.
*/
public async getCols(libds: string) {
const tables = { iwant: [{ libds: libds }] }
const res = await this.sasService.request('public/getcols', tables)
const cols = res.adapterResponse.cols || []
return cols.map((col: any) => ({
...col,
TYPE:
['DATE', 'DATETIME', 'TIME'].includes(col.DDTYPE) || col.TYPE === 'N'
? 'num'
: 'char'
}))
}
/**
* Feeds the filter picker its variable list.
*
* `cols` is the column metadata that came back with the table, which costs
* nothing extra and is the normal path. When the table was not returned - the
* EDIT/VIEW row and cell limits abort the request before the data arrives -
* that list is empty, which is what makes "please filter and try again"
* unactionable: the picker has nothing to offer. Fall back to
* public/getcols, which reads the column metadata without the rows.
*/
public async setQueryVariablesFromTable(libds: string, cols: any[]) {
if (cols && cols.length > 0) {
this.setQueryVariables(libds, cols)
return
}
const fallback = await this.getCols(libds)
/*
public/getcols emits an empty cols table when the dataset has no
metadata. Publishing that would push an empty array into the filter
picker, whose subscriber reads cols[0].NAME - the throw is caught, but
the picker is left half-updated (this.cols assigned, the filter.cols
assignment below the throwing line never run). There is nothing to
offer, so leave the picker as it was.
*/
if (!fallback || fallback.length === 0) return
this.setQueryVariables(libds, fallback)
}
public async getChangeInfo(tableId: any) {
let obj = { TABLE: tableId }
let table = { SASControlTable: [obj] }
+78 -2
View File
@@ -1,4 +1,4 @@
import { SasService } from './sas.service'
import { DEBUG_STATE_KEY, SasService } from './sas.service'
/**
* SasService's constructor is a plain 8-argument constructor with an empty
@@ -13,7 +13,8 @@ import { SasService } from './sas.service'
const buildService = () => {
const sasjsAdapter: any = {
request: jasmine.createSpy('request').and.resolveTo({}),
getSasRequests: () => []
getSasRequests: () => [],
setDebugState: jasmine.createSpy('setDebugState')
}
const loggerService: any = {
logRequestData: jasmine.createSpy('logRequestData')
@@ -87,3 +88,78 @@ describe('SasService diagnostics payload', () => {
expect(sentBody(sasjsAdapter).browser_info).toBe(supplied)
})
})
/**
* The `<sasjs>` tag is re-read on every load, so the debug toggle has to survive
* a refresh on its own.
*/
describe('SasService debug state', () => {
/** Builds a service whose init can actually run (real adapter, stub deps). */
const initService = (tagDebug = false) => {
const appStoreService: any = {
getDcAdapterSettings: () => ({
serverUrl: '',
appLoc: '/Public/app/dc',
serverType: 'SAS9',
loginMechanism: '',
dcPath: '',
debug: tagDebug,
useComputeApi: null,
runAsTask: true,
contextName: ''
})
}
const service = new SasService(
appStoreService,
{ user: undefined } as any,
{ showInfoModal: jasmine.createSpy('showInfoModal') } as any,
{ setup: jasmine.createSpy('setup') } as any,
{ setup: jasmine.createSpy('setup') } as any,
{ logRequestData: jasmine.createSpy('logRequestData') } as any,
{ updateStep: jasmine.createSpy('updateStep') } as any,
{ navigateByUrl: jasmine.createSpy('navigateByUrl') } as any
)
service.sasServiceInit()
return service
}
beforeEach(() => localStorage.removeItem(DEBUG_STATE_KEY))
afterEach(() => localStorage.removeItem(DEBUG_STATE_KEY))
it('stores the state when the toggle changes', () => {
const { service, sasjsAdapter } = buildService()
service.setDebugState(true)
expect(localStorage.getItem(DEBUG_STATE_KEY)).toBe('true')
expect(sasjsAdapter.setDebugState).toHaveBeenCalledWith(true)
})
it('stores a switch back off, rather than clearing it', () => {
const { service } = buildService()
service.setDebugState(true)
service.setDebugState(false)
expect(localStorage.getItem(DEBUG_STATE_KEY)).toBe('false')
})
it('reinstates the stored state on the next load', () => {
localStorage.setItem(DEBUG_STATE_KEY, 'true')
expect(initService().getSasjsConfig().debug).toBe(true)
})
it('leaves the tag value alone when nothing is stored', () => {
expect(initService(true).getSasjsConfig().debug).toBe(true)
})
it('reinstates an off state over a tag that turns debug on', () => {
localStorage.setItem(DEBUG_STATE_KEY, 'false')
expect(initService(true).getSasjsConfig().debug).toBe(false)
})
})
+15
View File
@@ -41,6 +41,13 @@ const DIAGNOSTICS_SERVICES = [
'services/auditors/postdata'
]
/**
* Where the debug toggle state is kept. The `<sasjs>` tag in index.html is
* re-read on every load, so without this a refresh would drop the state the user
* set from the UI.
*/
export const DEBUG_STATE_KEY = 'dc-debug-state'
@Injectable({
providedIn: 'root'
})
@@ -89,6 +96,13 @@ export class SasService {
this.sasjsAdapter = new SASjs(this.dcAdapterSettings)
/* The tag resets debug on every load, so reinstate the state the user last
chose from the UI. */
const storedDebugState = localStorage.getItem(DEBUG_STATE_KEY)
if (storedDebugState !== null) {
this.sasjsAdapter.setDebugState(storedDebugState === 'true')
}
switch (this.dcAdapterSettings.serverType) {
case ServerType.SasViya: {
this.checkViyaDeploy(this.dcAdapterSettings.appLoc || '')
@@ -803,6 +817,7 @@ export class SasService {
}
public setDebugState(state: boolean) {
localStorage.setItem(DEBUG_STATE_KEY, String(state))
this.sasjsAdapter.setDebugState(state)
}
@@ -0,0 +1,93 @@
import {
getSasjsTagDetails,
SASJS_TAG_STEP,
StartupCheckService
} from './startup-check.service'
describe('StartupCheckService', () => {
let service: StartupCheckService
beforeEach(() => {
service = new StartupCheckService()
})
it('includes a SASjs tag step after the startup checks', () => {
const steps = service.steps$.value
expect(steps.length).toBe(SASJS_TAG_STEP + 1)
expect(steps[SASJS_TAG_STEP].label).toBe('SASjs tag')
expect(steps[SASJS_TAG_STEP].status).toBe('pending')
})
it('shows the tag contents against the SASjs tag step', () => {
service.setTagDetails([{ name: 'serverType', value: 'SASVIYA' }])
expect(service.steps$.value[SASJS_TAG_STEP]).toEqual({
label: 'SASjs tag',
status: 'done',
details: [{ name: 'serverType', value: 'SASVIYA' }]
})
})
it('flags a missing tag on the SASjs tag step', () => {
service.updateStep(SASJS_TAG_STEP, 'error', 'SASJS TAG MISSING!!')
expect(service.steps$.value[SASJS_TAG_STEP]).toEqual({
label: 'SASjs tag',
status: 'error',
detail: 'SASJS TAG MISSING!!'
})
})
})
describe('getSasjsTagDetails', () => {
const tagWith = (attributes: Record<string, string>) => {
const element = document.createElement('sasjs')
Object.entries(attributes).forEach(([name, value]) =>
element.setAttribute(name, value)
)
return element
}
it('reads the tag attributes as name/value pairs', () => {
const details = getSasjsTagDetails(
tagWith({ serverType: 'SASVIYA', debug: 'false', useComputeApi: 'true' })
)
expect(details).toEqual([
{ name: 'serverType', value: 'SASVIYA' },
{ name: 'debug', value: 'false' },
{ name: 'useComputeApi', value: 'true' }
])
})
it('keeps attributes the parser does not know about', () => {
expect(getSasjsTagDetails(tagWith({ serverTyp: 'SASJS' }))).toEqual([
{ name: 'servertyp', value: 'SASJS' }
])
})
it('shows every attribute, including the admin group and the licence key', () => {
const details = getSasjsTagDetails(
tagWith({
serverType: 'SASJS',
adminGroup: 'SASAdministrators',
hotLicenceKey: 'non-commercial-and-evaluation'
})
)
expect(details).toEqual([
{ name: 'serverType', value: 'SASJS' },
{ name: 'adminGroup', value: 'SASAdministrators' },
{ name: 'hotLicenceKey', value: 'non-commercial-and-evaluation' }
])
})
it('flags attributes that carry no value', () => {
expect(getSasjsTagDetails(tagWith({ runAsTask: '' }))).toEqual([
{ name: 'runAsTask', value: '(empty)' }
])
})
})
@@ -3,18 +3,64 @@ import { BehaviorSubject } from 'rxjs'
export type StartupStepStatus = 'pending' | 'in_progress' | 'done' | 'error'
/** A name/value pair shown under a startup step. */
export interface StartupStepDetail {
name: string
value: string
}
export interface StartupStep {
label: string
status: StartupStepStatus
detail?: string
/** Rendered as a grid under the label. */
details?: StartupStepDetail[]
}
/**
* Index of the `<sasjs>` tag dump within the startup steps. It is appended
* after the three startup checks so the indices the startup flow already drives
* (sas.service / app.service) are unchanged.
*/
export const SASJS_TAG_STEP = 3
/**
* Canonical spelling of the `<sasjs>` tag attributes. HTML lower-cases attribute
* names in the DOM, so they are mapped back for display.
*/
const TAG_ATTRIBUTE_NAMES: Record<string, string> = {
serverurl: 'serverUrl',
apploc: 'appLoc',
servertype: 'serverType',
loginmechanism: 'loginMechanism',
admingroup: 'adminGroup',
dcpath: 'dcPath',
debug: 'debug',
usecomputeapi: 'useComputeApi',
runastask: 'runAsTask',
contextname: 'contextName',
hotlicencekey: 'hotLicenceKey'
}
/**
* Reads the attributes of the `<sasjs>` tag for display in the startup checks.
* Every attribute is shown - including ones the parser does not know about, so a
* misspelt one is visible and a wrong value is exactly the misconfiguration this
* dump exists to expose.
*/
export const getSasjsTagDetails = (element: Element): StartupStepDetail[] =>
Array.from(element.attributes).map((attribute) => ({
name: TAG_ATTRIBUTE_NAMES[attribute.name.toLowerCase()] || attribute.name,
value: attribute.value || '(empty)'
}))
@Injectable({ providedIn: 'root' })
export class StartupCheckService {
private steps: StartupStep[] = [
{ label: 'Checking app location', status: 'pending' },
{ label: 'Checking Viya deploy', status: 'pending' },
{ label: 'Loading startup service', status: 'pending' }
{ label: 'Loading startup service', status: 'pending' },
{ label: 'SASjs tag', status: 'pending' }
]
public steps$ = new BehaviorSubject<StartupStep[]>([...this.steps])
@@ -30,6 +76,16 @@ export class StartupCheckService {
}
}
/** Shows the `<sasjs>` tag contents on the boot screen. */
public setTagDetails(details: StartupStepDetail[]) {
this.steps[SASJS_TAG_STEP] = {
...this.steps[SASJS_TAG_STEP],
status: 'done',
details
}
this.steps$.next([...this.steps])
}
public setSteps(steps: StartupStep[]) {
this.steps = steps
this.steps$.next([...this.steps])
@@ -553,11 +553,27 @@ describe('DC Validator', () => {
example_dqData
)
// HOT 18.1 tightened `ColumnSettings['validator']` to the real
// `string | RegExp | function` union (18.0 erased it behind an index
// signature), so narrow it the way the production code does before
// invoking it.
const validatorOf = (rule: ReturnType<DcValidator['getRule']>) => {
const validator = rule?.validator
if (typeof validator !== 'function') {
throw new Error('expected the rule to carry a function validator')
}
return validator as (
this: any,
value: any,
callback: (valid: boolean) => void
) => void
}
it('exempts an invalid non-PK cell on a delete-marked row', () => {
const dcValidator = buildValidator()
const someNumRule = dcValidator.getRule('SOME_NUM')
someNumRule!.validator!.call(
validatorOf(someNumRule).call(
fakeCellProps(true),
'not a number',
(valid: boolean) => {
@@ -570,7 +586,7 @@ describe('DC Validator', () => {
const dcValidator = buildValidator()
const someNumRule = dcValidator.getRule('SOME_NUM')
someNumRule!.validator!.call(
validatorOf(someNumRule).call(
fakeCellProps(true),
null,
(valid: boolean) => {
@@ -583,7 +599,7 @@ describe('DC Validator', () => {
const dcValidator = buildValidator()
const someNumRule = dcValidator.getRule('SOME_NUM')
someNumRule!.validator!.call(
validatorOf(someNumRule).call(
fakeCellProps(false),
'not a number',
(valid: boolean) => {
@@ -596,7 +612,7 @@ describe('DC Validator', () => {
const dcValidator = buildValidator()
const someNumRule = dcValidator.getRule('SOME_NUM')
someNumRule!.validator!.call(
validatorOf(someNumRule).call(
fakeCellProps(false),
2,
(valid: boolean) => {
@@ -609,7 +625,7 @@ describe('DC Validator', () => {
const dcValidator = buildValidator()
const pkRule = dcValidator.getRule('PRIMARY_KEY_FIELD')
pkRule!.validator!.call(
validatorOf(pkRule).call(
fakeCellProps(true),
'not a number',
(valid: boolean) => {
@@ -1404,10 +1404,17 @@ export class ViewboxesComponent implements OnInit, AfterViewInit, OnDestroy {
this.filter = true
this.cdf.detectChanges()
this.sasStoreService.setQueryVariables(
this.filterLibds,
viewboxTable.hotTable.cols
)
// Table columns when we have them (the normal, cheapest source); when the
// view was aborted by the row limit there is no loaded table to read them
// from, so the store falls back to public/getcols.
const cols =
viewboxTable && viewboxTable.hotTable ? viewboxTable.hotTable.cols : []
this.sasStoreService
.setQueryVariablesFromTable(this.filterLibds, cols)
.catch((err: any) =>
this.eventService.catchResponseError('public/getcols', err)
)
}
/**
@@ -286,6 +286,12 @@
UPDATE
</button>
</div>
<div class="admin-action">
Export DC Library DDL
<button (click)="openDdlExport()" class="btn btn-info btn-sm">
EXPORT
</button>
</div>
}
</div>
</div>
@@ -306,3 +312,40 @@
</button>
</div>
</clr-modal>
<clr-modal [(clrModalOpen)]="ddlModal">
<h3 class="modal-title">Export DC Library DDL</h3>
<div class="modal-body">
<clr-select-container>
<label>Flavour</label>
<select [(ngModel)]="ddlFlavour" clrSelect>
<option value="SAS">SAS</option>
<option value="PGSQL">PGSQL</option>
<option value="TSQL">TSQL</option>
</select>
</clr-select-container>
@if (ddlFlavour !== 'SAS') {
<clr-input-container>
<label>Schema (optional)</label>
<input
clrInput
type="text"
[(ngModel)]="ddlSchema"
placeholder="e.g. DC"
/>
</clr-input-container>
}
</div>
<div class="modal-footer">
<button
type="button"
class="btn btn-sm btn-outline"
(click)="ddlModal = false"
>
Cancel
</button>
<button type="button" class="btn btn-sm btn-primary" (click)="exportDdl()">
Export
</button>
</div>
</clr-modal>
+29
View File
@@ -42,6 +42,9 @@ export class SystemComponent implements OnInit {
Infinity = Infinity
dcLib: string = globals.dcLib
targetLibref: string = globals.dcLib
ddlModal: boolean = false
ddlFlavour: string = 'SAS'
ddlSchema: string = ''
licenceState = this.licenceService.licenceState
settings: AppSettings
@@ -92,6 +95,32 @@ export class SystemComponent implements OnInit {
window.open(downUrl)
}
openDdlExport() {
this.ddlFlavour = 'SAS'
this.ddlSchema = ''
this.ddlModal = true
}
exportDdl() {
let sasjsConfig = this.sasService.getSasjsConfig()
let storage = sasjsConfig.serverUrl
let metaData = sasjsConfig.appLoc
let path = this.sasService.getExecutionPath()
let downUrl =
storage +
path +
'/?_program=' +
metaData +
'/services/admin/exportdb&flavour=' +
encodeURIComponent(this.ddlFlavour)
let schema = this.ddlSchema.trim()
if (this.ddlFlavour !== 'SAS' && schema) {
downUrl += '&schema=' + encodeURIComponent(schema)
}
window.open(downUrl)
this.ddlModal = false
}
refreshDataCatalog() {
this.refreshingDataCatalog = true
+3 -3
View File
@@ -42,11 +42,13 @@
* debug: Whether to enable debug on startup
## Viya only settings ##
* adminGroup: This group will have unrestricted access (Viya Only)
* dcPath: The physical location in which to deploy the staging area (Viya only)
* useComputeApi: Connection method (Viya only)
* contextName: Compute Context (Viya only)
The admin group is not configured here - it is a DC setting (DC_ADMIN_GROUP),
shown on the System screen.
-->
<sasjs
@@ -58,8 +60,6 @@
useComputeApi="null"
runAsTask="true"
contextName="SAS Job Execution compute context"
adminGroup="SASAdministrators"
hotLicenceKey="non-commercial-and-evaluation"
>
</sasjs>
+31 -9
View File
@@ -1664,6 +1664,13 @@ app-requests-modal {
padding: 20px 10px 5px 10px;
}
/* The SAS log pane is `white-space: pre`, so it never wraps. The stock `xl`
width is a fixed 57.6rem, which forced left/right scrolling to read a log
line. */
.requests-modal .modal-dialog {
width: 90vw;
}
.work-tables-dropdown button {
color: var(--clr-nav-link-color, #8c8c8c) !important;
}
@@ -2289,15 +2296,6 @@ app-alerts {
}
// ALERTS.COMPONENT END
// MANUAL-DEPLOY.COMPONENT
app-manual-deploy {
.clear-memory-button {
right: 10px;
top: 2px;
}
}
// MANUAL-DEPLOY.COMPONENT END
// LINEAGE.COMPONENT
app-lineage {
.toggle-switch input[type='checkbox']:checked + label:before {
@@ -4255,6 +4253,30 @@ body[cds-theme='light'] {
color: #c21d00;
}
/* The SASjs tag dump drops onto its own line and reads as a name/value grid. */
&--grid {
flex-wrap: wrap;
align-items: baseline;
}
&__grid {
flex-basis: 100%;
display: grid;
grid-template-columns: auto 1fr;
gap: 1px 12px;
margin-top: 4px;
font-size: 12px;
}
&__grid-name {
color: #999;
}
&__grid-value {
color: #ccc;
word-break: break-word;
}
&__icon--pending {
color: #999;
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "dcfrontend",
"version": "7.16.0",
"version": "7.17.0",
"description": "Data Controller",
"devDependencies": {
"@nogoo9/gitleaks": "8.30.1-post.2",
+160
View File
@@ -0,0 +1,160 @@
const nodePath = require('path')
let appLoc = nodePath.join(..._program.split('services')[0].split('/'))
const sasjsRoot = nodePath.resolve(weboutPath, '..', '..', '..')
const driveRoot = nodePath.resolve(sasjsRoot, 'drive')
eval(fs.readFileSync(nodePath.resolve(driveRoot, 'files', appLoc, 'services', 'dcMockUtils.js'), 'utf8'))
/**
* Mock of services/admin/exportdb.sas - exports the DC library as DDL.
*
* The real service streams mp_ds2ddl output for every table in &DC_LIBREF, in
* the requested flavour, and mp_lib2inserts appends the rows for the SAS and
* PGSQL flavours. This mock emits the DDL only - a basic, readable rendering
* per flavour, enough to exercise the System screen's Export DC Library DDL
* action end to end (the download headers, the filename, the flavour and the
* optional schema). It deliberately does not append INSERTs: the real service
* does, but for a mock that would mean serialising the whole library's rows
* (MPE_X_WIDE alone is 200,000 cells) for no extra test value.
*
* Params arrive from the URL the client opens:
* <serverUrl>/SASjsApi/stp/execute/?_program=<appLoc>/services/admin/exportdb
* &flavour=SAS|PGSQL|TSQL[&schema=<name>]
* The schema box only applies to the DB flavours, and the client omits the
* param entirely when it is blank, so both must be read defensively - an
* unset URL param is an undeclared const here, not an empty string.
*/
const ddlFlavour = (typeof flavour === 'undefined' || !flavour)
? 'SAS'
: String(flavour).toUpperCase()
const dcConf = loadDcSettings(driveRoot, appLoc)
const libref = dcConf.dcLibref
const dataDir = dcConf.dataDir
// if no schema param, the DC libref is used (as in exportdb.sas)
const ddlSchema = (typeof schema === 'undefined' || !schema)
? libref
: String(schema)
// ─── Column types, per flavour ───────────────────────────────────────────────
function colType(col, flavour) {
const isChar = String(col.type).toUpperCase() === 'C'
const fmt = String(col.format || '').toUpperCase()
const len = Number(col.length) || (isChar ? 1 : 8)
if (flavour === 'SAS') {
return isChar ? `char(${len})` : 'num'
}
// PGSQL and TSQL share the mapping; only the quoting differs. The one type
// that must not be shared is the datetime: T-SQL's TIMESTAMP is a deprecated
// synonym for rowversion - a binary row version, not a datetime - so it maps
// to DATETIME2 there. DOUBLE PRECISION is a T-SQL synonym for float, so it
// stays valid on both.
if (isChar) return `VARCHAR(${len})`
if (fmt.startsWith('DATETIME'))
return flavour === 'TSQL' ? 'DATETIME2' : 'TIMESTAMP'
if (fmt.startsWith('DATE')) return 'DATE'
if (fmt.startsWith('TIME')) return 'TIME'
return 'DOUBLE PRECISION'
}
function colRef(name, flavour) {
if (flavour === 'PGSQL') return `"${name}"`
if (flavour === 'TSQL') return `[${name}]`
return name
}
function tableRef(table, flavour) {
if (flavour === 'TSQL') return `[${ddlSchema}].[${table}]`
return `${ddlSchema}.${table}`
}
// ─── Buskeys (for the unique index), from MPE_TABLES ─────────────────────────
const buskeys = {}
try {
const reg = makeTableLoader(dataDir)('MPE_TABLES')
for (const row of ((reg && reg.rows) || [])) {
const dsn = String(colVal(row, 'DSN') || '').trim().toUpperCase()
const buskey = String(colVal(row, 'BUSKEY') || '').trim()
if (dsn && buskey) buskeys[dsn] = buskey
}
} catch(err) { /* no MPE_TABLES - the DDL is still emitted, without indexes */ }
// ─── Build the DDL ───────────────────────────────────────────────────────────
const lines = []
const stamp = new Date().toISOString().replace('T', ':').substring(0, 19)
lines.push(`/* DDL generated by the Data Controller mock on ${stamp} */`)
if (ddlFlavour === 'PGSQL') {
lines.push(`CREATE SCHEMA ${ddlSchema};`)
}
let tables = []
try {
tables = fs.readdirSync(dataDir)
.filter(f => f.endsWith('.json'))
.map(f => {
try {
return JSON.parse(fs.readFileSync(nodePath.resolve(dataDir, f), {encoding:'utf8'}).toString())
} catch(e) { return null }
})
.filter(t => t && t.name && Array.isArray(t.columns))
.sort((a, b) => String(a.name).localeCompare(String(b.name)))
} catch(err) { /* nothing to export */ }
for (const table of tables) {
const tableName = String(table.name).toUpperCase()
const cols = table.columns.filter(c => String(c.name || '').trim() !== '')
if (cols.length === 0) continue
if (ddlFlavour === 'SAS') {
lines.push(`/* SAS flavour DDL for ${libref}.${tableName} */`)
lines.push(`create table ${tableRef(tableName, ddlFlavour)}(`)
} else if (ddlFlavour === 'PGSQL') {
lines.push(`/* Postgres Flavour DDL for ${libref}.${tableName} */`)
lines.push(`CREATE TABLE ${tableRef(tableName, ddlFlavour)} (`)
} else {
lines.push(`/* TSQL Flavour DDL for ${libref}.${tableName} */`)
lines.push(`create table ${tableRef(tableName, ddlFlavour)}(`)
}
cols.forEach((col, idx) => {
const name = colRef(String(col.name).toUpperCase(), ddlFlavour)
const prefix = idx === 0 ? ' ' : ' ,'
lines.push(`${prefix}${name} ${colType(col, ddlFlavour)}`)
})
lines.push(');')
// a unique index on the buskey, where MPE_TABLES declares one - SAS has no
// equivalent in mp_ds2ddl output, so it is skipped for that flavour
const buskey = buskeys[tableName]
if (buskey && ddlFlavour !== 'SAS') {
const keys = buskey.split(/[\s,]+/).filter(Boolean)
.map(k => colRef(k.toUpperCase(), ddlFlavour))
lines.push(
`CREATE UNIQUE INDEX ${colRef(tableName + '_pk', ddlFlavour)} ON ` +
`${tableRef(tableName, ddlFlavour)}(\n ${keys.join('\n ,')}\n);`
)
}
lines.push('')
}
// ─── Stream it back as a download, as mp_streamfile does ─────────────────────
const filename = `${libref}.ddl`
fs.writeFile(
_SASJS_WEBOUT_HEADERS,
`Content-type: text/plain \n Content-Disposition: attachment;filename="${filename}"`,
function (err) {
if (err) throw err
}
)
_webout = lines.join('\n') + '\n'
+81 -2
View File
@@ -76,9 +76,32 @@ const tables = {
tx_to: 253717919999,
var_scope: 'DC',
var_name: 'DC_MAXOBS_WEBEDIT',
var_value: '100',
var_value: '250',
var_active: 1,
var_desc: 'Maximum observations for editing in the EDIT screen.'
var_desc:
'Maximum observations for editing in the EDIT screen. The ' +
'DC_MAXCELLS_WEBEDIT limit applies at the same time, whichever is ' +
'reached first.'
},
{
tx_from: 0,
tx_to: 253717919999,
var_scope: 'DC',
var_name: 'DC_MAXCELLS_WEBEDIT',
var_value: '200000',
var_active: 1,
var_desc:
'Maximum CELLS (rows x columns) for editing in the EDIT screen - a ' +
'wide table is expensive long before it is tall.'
},
{
tx_from: 0,
tx_to: 253717919999,
var_scope: 'DC',
var_name: 'DC_MAXOBS_WEBVIEW',
var_value: '2000',
var_active: 1,
var_desc: 'Maximum observations for the VIEW screen.'
},
{
tx_from: 0,
@@ -2201,6 +2224,37 @@ tables.MPE_TABLES.push({
notes: 'Full table search demo table'
})
// ─── 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
// 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
// the seeded JSON near 1MB rather than the 25MB the real WIDEBOY fixture (20
// character fields) would take.
tables.MPE_X_WIDE = []
for (let n = 0; n < 200; n++) {
const wideRow = { ROW_ID: n + 1 }
for (let c = 1; c <= 500; c++) {
wideRow['CHAR' + c] = String.fromCharCode(65 + ((n + c) % 26))
}
for (let c = 1; c <= 500; c++) {
wideRow['NUM' + c] = (n + c) % 10
}
tables.MPE_X_WIDE.push(wideRow)
}
tables.MPE_TABLES.push({
tx_from: 0,
tx_to: 127490111999,
libref: dcLibref,
dsn: 'MPE_X_WIDE',
num_of_approvals_required: 1,
loadtype: 'UPDATE',
buskey: 'ROW_ID CHAR1 NUM1',
notes: 'Wide table fixture for the EDIT cell limit'
})
// Column metadata for each table, extracted from mpe_makedatamodel.sas
// Each entry: { name, type (N/C), length, format, label, notnull (bool) }
const schema = {
@@ -4976,6 +5030,31 @@ const schema = {
MPE_MAXKEYVALUES: []
}
// Columns for MPE_X_WIDE - built rather than spelled out, there are 1001.
schema.MPE_X_WIDE = [
{ name: 'ROW_ID', type: 'N', length: 8, format: '', label: '', notnull: true }
]
for (let c = 1; c <= 500; c++) {
schema.MPE_X_WIDE.push({
name: 'CHAR' + c,
type: 'C',
length: 1,
format: '',
label: '',
notnull: false
})
}
for (let c = 1; c <= 500; c++) {
schema.MPE_X_WIDE.push({
name: 'NUM' + c,
type: 'N',
length: 8,
format: '',
label: '',
notnull: false
})
}
// ─── TESTDATA library: demo user tables for the Cypress E2E suite ───────────
// These tables hold the DQ-rule demo columns (HARDSELECT, READONLY, HIDDEN,
// ROUND, NUMBER_FORMAT, HARDREGEX, SOFTREGEX) and the formula demo columns
+110 -6
View File
@@ -148,14 +148,63 @@ function _parseCsvLine(line) {
// ─── SAS format <-> ISO conversions ──────────────────────────────────────────
/**
* Derives a DDTYPE (DATE, DATETIME, TIME, N, C) from a SAS column format string.
* Column metadata, mirroring %mp_getcols.sas.
*
* The SAS macro runs proc contents and derives, per column: NAME (upcased),
* TYPE (C/N), LENGTH, LABEL (defaulting to the name), VARNUM, FMTNAME, FORMAT
* and DDTYPE - CHARACTER, NUMERIC, DATETIME, DATE or TIME. The date list is
* the macro's (YYMMDD, MMDDYY, DDMMYY, MONYY, E8601DA, B8601DA, E8601DT,
* NLDATM, NLDATE), so a column types the same way here as it does in the
* service that shares this code.
*
* @param {Array} schemaCols the table's columns as the mock data stores them
* ({name, type, length, format, label}), the equivalent of the proc contents
* output the macro reads
* @returns {Array} one entry per column, in the macro's shape and order
*/
function mpGetcols(schemaCols) {
return (schemaCols || []).map((col, index) => {
const name = String(col.name || '').toUpperCase()
const isChar = String(col.type || '').toUpperCase() === 'C'
const fmtname = String(col.format || '')
.replace(/[\d.]+$/, '')
.toUpperCase()
let ddtype
if (isChar) ddtype = 'CHARACTER'
else if (fmtname.startsWith('DATETIME') || fmtname.startsWith('E8601DT')
|| fmtname.startsWith('NLDATM')) ddtype = 'DATETIME'
else if (fmtname.startsWith('DATE') || fmtname.startsWith('DDMMYY')
|| fmtname.startsWith('MMDDYY') || fmtname.startsWith('YYMMDD')
|| fmtname.startsWith('E8601DA') || fmtname.startsWith('B8601DA')
|| fmtname.startsWith('MONYY') || fmtname.startsWith('NLDATE')) ddtype = 'DATE'
else if (fmtname.startsWith('TIME')) ddtype = 'TIME'
else ddtype = 'NUMERIC'
return {
NAME: name,
TYPE: isChar ? 'C' : 'N',
LENGTH: Number(col.length) || 0,
LABEL: col.label || col.name,
VARNUM: index + 1,
FORMAT: String(col.format || ''),
FMTNAME: fmtname,
DDTYPE: ddtype
}
})
}
/**
* Derives a DDTYPE (DATE, DATETIME, TIME, N, C) from a SAS column format
* string, in the shape the client expects. Delegates to mpGetcols so the
* inference is the macro's, rather than a second format list that can drift
* away from the service's.
*/
function getDdType(col) {
const fmt = (col.format || '').toLowerCase()
if (fmt.startsWith('datetime') || fmt.startsWith('e8601dt')) return 'DATETIME'
if (fmt.startsWith('date') || fmt.startsWith('yymmdd') || fmt.startsWith('ddmmyy') || fmt.startsWith('mmddyy')) return 'DATE'
if (fmt.startsWith('time')) return 'TIME'
return col.type === 'N' ? 'N' : 'C'
const ddtype = mpGetcols([col])[0].DDTYPE
if (ddtype === 'CHARACTER') return 'C'
if (ddtype === 'NUMERIC') return 'N'
return ddtype
}
/**
@@ -1102,6 +1151,61 @@ function loadDcSettings(driveRoot, appLoc) {
return result
}
/**
* Reads the DC-scope options out of MPE_CONFIG, mirroring the loop in the real
* backend's generated settings.sas: only rows that are active (var_active=1)
* and current (now < tx_to) count, and the latest tx_from wins. Returns a map
* of var_name -> trimmed var_value.
*
* An option that is absent, or switched off, is simply missing from the map so
* the caller's own default applies - which is what the real service's
* %symexist fallback does (a switched-off option means "use the code default",
* not "no limit").
*
* Requires nodePath, fs and the caller's driveRoot/appLoc/dataDir.
*/
function loadDcConfig(driveRoot, appLoc, dataDir) {
const out = {}
try {
const configFile = nodePath.resolve(
dataDir || nodePath.resolve(driveRoot, 'files', appLoc, 'data', 'DC_JSLIB'),
'mpe_config.json'
)
const config = JSON.parse(fs.readFileSync(configFile, {encoding:'utf8'}).toString())
const now = Math.floor(Date.now() / 1000)
const latest = {}
for (const row of (config.rows || [])) {
// colVal, not row.var_name: a row that has been through the app's
// own edit and approve flow comes back from the adapter with
// upper-case keys, and a direct property read would skip it - which
// silently drops the option back to its default.
if (colVal(row, 'VAR_SCOPE') !== 'DC') continue
if (Number(colVal(row, 'VAR_ACTIVE')) !== 1) continue
if (Number(colVal(row, 'TX_TO')) <= now) continue
const name = String(colVal(row, 'VAR_NAME') || '').trim()
if (!name) continue
const from = Number(colVal(row, 'TX_FROM')) || 0
if (latest[name] === undefined || from > latest[name]) {
const val = colVal(row, 'VAR_VALUE')
latest[name] = from
out[name] = String(val == null ? '' : val).trim()
}
}
} catch(err) { /* no MPE_CONFIG - every option falls back to its default */ }
return out
}
/**
* A numeric DC option out of a loadDcConfig() map, or the service's own default
* when the option is absent, blank, or not a number.
*/
function dcConfigNumber(cfg, name, fallback) {
const raw = cfg ? cfg[name] : undefined
if (raw === undefined || raw === '') return fallback
const num = Number(raw)
return isNaN(num) ? fallback : num
}
// ─── %webout(OPEN/OBJ/CLOSE) emulation ───────────────────────────────────────
/**
+69 -25
View File
@@ -191,6 +191,9 @@ 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 = ''
if (tableData && tableData.columns && tableData.rows) {
// Exclude temporal columns (TX_FROM/TX_TO etc.) from the editor view
@@ -232,6 +235,39 @@ 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.
// 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
// non-control libref, and the catch then fell back to the shipped defaults.
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!'
}
// Augment rows: add delete flag, normalise keys, convert temporal values to ISO
const colLookup = {}
for (const col of visibleColumns) {
@@ -264,22 +300,25 @@ if (tableData && tableData.columns && tableData.rows) {
else if (ddtype === 'TIME') tmVars.push(col.name)
}
// Build cols array
const cols = visibleColumns.map(col => {
const ddtype = getDdType(col)
const fmtname = col.format
? col.format.replace(/[\d.]+$/, '').replace('datetime', 'DATETIME').replace('date', 'DATE').replace('time', 'TIME').replace('best', 'BEST').replace('E8601DT', 'DATETIME')
: ' '
// Build cols array. Every column of the table, as getdata.sas emits them:
// the temporal columns are dropped from the data (the rows below) but not
// from the cols payload, so a filter can still be built on them. mpGetcols
// is the JS counterpart of %mp_getcols.sas - the macro getdata.sas itself
// calls to type these columns - so the editor sees the same DDTYPE the
// service would give it.
const cols = mpGetcols(tableData.columns).map((c, i) => {
const raw = tableData.columns[i]
const ddtype = c.DDTYPE === 'CHARACTER' ? 'C' : c.DDTYPE === 'NUMERIC' ? 'N' : c.DDTYPE
let coltype
if (ddtype === 'DATE') coltype = `{"data":"${col.name}","type":"date"}`
else if (ddtype === 'DATETIME') coltype = `{"data":"${col.name}","type":"datetime"}`
else if (ddtype === 'TIME') coltype = `{"data":"${col.name}","type":"time"}`
else if (ddtype === 'N') coltype = `{"data":"${col.name}","type":"numeric","format":"0"}`
else coltype = `{"data":"${col.name}"}`
if (ddtype === 'DATE') coltype = `{"data":"${raw.name}","type":"date"}`
else if (ddtype === 'DATETIME') coltype = `{"data":"${raw.name}","type":"datetime"}`
else if (ddtype === 'TIME') coltype = `{"data":"${raw.name}","type":"time"}`
else if (ddtype === 'N') coltype = `{"data":"${raw.name}","type":"numeric","format":"0"}`
else coltype = `{"data":"${raw.name}"}`
return {
NAME: col.name,
LABEL: col.label || col.name,
FMTNAME: fmtname || ' ',
NAME: c.NAME,
LABEL: c.LABEL,
FMTNAME: c.FMTNAME || ' ',
DDTYPE: ddtype,
CLS_RULE: 'READ',
MEMLABEL: ' ',
@@ -354,15 +393,20 @@ if (tableData && tableData.columns && tableData.rows) {
}
webOutOpen()
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')
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')
}
webOutClose()
@@ -0,0 +1,80 @@
const nodePath = require('path')
let appLoc = nodePath.join(..._program.split('services')[0].split('/'))
const sasjsRoot = nodePath.resolve(weboutPath, '..', '..', '..')
const driveRoot = nodePath.resolve(sasjsRoot, 'drive')
const dcLibref = 'DC_JSLIB'
// Load shared DC mock utilities
eval(fs.readFileSync(nodePath.resolve(driveRoot, 'files', appLoc, 'services', 'dcMockUtils.js'), 'utf8'))
// ─── Parse input ──────────────────────────────────────────────────────────────
// getcols.sas reads %mf_getvalue(work.iwant,libds). The adapter serialises the
// input table as CSV, which fetchTable parses - so the row is `_iwantRow` (the
// runtime already owns the name `iwant`).
const _iwantRow = fetchTable('iwant')[0] || {}
const libds = String(_iwantRow.libds || _iwantRow.LIBDS || '').trim()
let libref = dcLibref
let table = ''
if (libds) {
const parts = libds.split('.')
if (parts.length >= 2) {
libref = parts[0].trim()
table = parts[1].trim()
}
}
// ─── Load the table schema ────────────────────────────────────────────────────
// Same resolution as getdata.js: the requested libref first, then any libref
// (a LIBDS that doesn't match the data folder still resolves).
let tableData = table ? makeTableLoader(libDataDir(libref))(table) : null
if (!tableData) {
// Fall back to searching every libref if the table isn't in the requested
// libref (e.g. a LIBDS that doesn't match the actual data folder), and take
// the libref that actually held it for the registry lookup below.
const found = loadTableAnyLib(table)
if (found) {
tableData = found.data
libref = found.libref
}
}
// ─── Build the column list ────────────────────────────────────────────────────
// Every column, as getdata.sas emits them: the temporal columns are dropped from
// the data, not from the cols payload, so a filter can still be built on them -
// which is exactly what "please filter and try again" wants to do.
let cols = []
if (tableData && tableData.columns) {
// mpGetcols, then the mapping getcols.sas applies on top of %mp_getcols:
// DDTYPE in the client's vocabulary, and TYPE as the SAS C/N it has always
// been. The client maps TYPE to its own num/char vocabulary in
// sas-store.getCols, because the picker keys its operator set and its value
// quoting on it and this list does not pass through mergeColsRules.
cols = mpGetcols(tableData.columns)
.map(c => {
const ddtype = c.DDTYPE === 'CHARACTER' ? 'C' : c.DDTYPE === 'NUMERIC' ? 'N' : c.DDTYPE
return {
NAME: c.NAME,
LABEL: c.LABEL,
FORMAT: c.FORMAT || ' ',
FMTNAME: c.FMTNAME || ' ',
DDTYPE: ddtype,
TYPE: ddtype === 'C' ? 'C' : 'N',
CLS_RULE: 'READ',
MEMLABEL: ' ',
DESC: ' ',
LONGDESC: ' ',
COLTYPE: ' '
}
})
.sort((a, b) => a.NAME.localeCompare(b.NAME))
}
webOutOpen()
webOutObj(cols, 'cols')
webOutClose()
+16 -11
View File
@@ -8,9 +8,15 @@ const mpeDataDir = nodePath.resolve(driveRoot, 'files', appLoc, 'data', dcLibref
eval(fs.readFileSync(nodePath.resolve(driveRoot, 'files', appLoc, 'services', 'dcMockUtils.js'), 'utf8'))
// Row cap for the viewer - mirrors DC_MAXOBS_WEBVIEW in viewdata.sas, and the
// Row cap for the viewer - mirrors DC_MAXOBS_WEBVIEW in viewdata.sas, read from
// MPE_CONFIG the way the real service reads it from the settings program (an
// absent or switched-off option falls back to the shipped default). Also the
// MAXROWS value reported in sasparams.
const maxObsWebView = 500
const maxObsWebView = dcConfigNumber(
loadDcConfig(driveRoot, appLoc, mpeDataDir),
'DC_MAXOBS_WEBVIEW',
2000
)
// ─── Parse input ──────────────────────────────────────────────────────────────
@@ -95,16 +101,15 @@ let vars = {}
let nobs = 0
if (tableData && tableData.columns) {
cols = tableData.columns.map(col => {
const ddtype = getDdType(col)
const fmtname = col.format
? col.format.replace(/[\d.]+$/, '').replace('datetime', 'DATETIME').replace('date', 'DATE').replace('time', 'TIME').replace('best', 'BEST').replace('E8601DT', 'DATETIME')
: ' '
const formatStr = col.type === 'N' ? (col.format || '8.') : ('$' + col.length + '.')
// mpGetcols, the JS counterpart of %mp_getcols.sas - the macro viewdata.sas
// calls for its own column list.
cols = mpGetcols(tableData.columns).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 {
NAME: col.name, TYPE: col.type === 'N' ? 'N' : 'C', LENGTH: col.length,
FORMAT: formatStr, FMTNAME: fmtname || ' ', DDTYPE: ddtype,
LABEL: col.label || col.name
NAME: c.NAME, TYPE: c.TYPE, LENGTH: c.LENGTH,
FORMAT: formatStr, FMTNAME: c.FMTNAME || ' ', DDTYPE: ddtype,
LABEL: c.LABEL
}
})
+201 -400
View File
@@ -6,7 +6,7 @@
"": {
"name": "dc-sas",
"dependencies": {
"@sasjs/cli": "4.20.4",
"@sasjs/cli": "4.20.5",
"@sasjs/core": "5.2.8"
}
},
@@ -153,49 +153,14 @@
"lodash.isnil": "^4.0.0"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "2.0.5",
"run-parallel": "^1.1.9"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/@nodelib/fs.stat": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/@nodelib/fs.walk": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
"license": "MIT",
"dependencies": {
"@nodelib/fs.scandir": "2.1.5",
"fastq": "^1.6.0"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/@sasjs/adapter": {
"version": "4.19.0",
"resolved": "https://registry.npmjs.org/@sasjs/adapter/-/adapter-4.19.0.tgz",
"integrity": "sha512-AhSldduDFPAuhRsXdzeiwMqEm0lm6dnh2yVxDSUmxxw8V9YShmeGPmEzvU8n2Cl4VAyrhc+Ietc5YWFYC03G+Q==",
"version": "4.19.1",
"resolved": "https://registry.npmjs.org/@sasjs/adapter/-/adapter-4.19.1.tgz",
"integrity": "sha512-IpN6UPSdqVb5cAWEBt+jG/i0rhgCubdyp5kxdouuX3ifL313oJW2ZPVUNUvs0+ZGzsXWcGDTNoEzzTrGKiqJlQ==",
"license": "ISC",
"dependencies": {
"@sasjs/utils": "^3.6.0",
"axios": "1.18.1",
"axios": "1.20.0",
"axios-cookiejar-support": "5.0.5",
"form-data": "4.0.6",
"https": "1.0.0",
@@ -203,14 +168,14 @@
}
},
"node_modules/@sasjs/cli": {
"version": "4.20.4",
"resolved": "https://registry.npmjs.org/@sasjs/cli/-/cli-4.20.4.tgz",
"integrity": "sha512-hKE002Dm9AzWU62qpRyYpjQv8BIXG951Z0ToNdG9J1wvvJNYH7h6rGv7kdQMbgvXff/KOQvVdnGJZ023SrG+Jw==",
"version": "4.20.5",
"resolved": "https://registry.npmjs.org/@sasjs/cli/-/cli-4.20.5.tgz",
"integrity": "sha512-/HSpueCFS+SvXas9FwfP8qoD9skhdTDE5r7dT9Xlrs7HGCBjxDYvSiw6tW0GIjfw3/49Yf8TCVDFBsISang1hg==",
"license": "ISC",
"dependencies": {
"@sasjs/adapter": "^4.19.0",
"@sasjs/core": "5.2.9",
"@sasjs/lint": "2.5.0",
"@sasjs/adapter": "4.19.1",
"@sasjs/core": "5.2.10",
"@sasjs/lint": "4.1.0",
"@sasjs/utils": "3.6.2",
"chalk": "4.1.2",
"dotenv": "17.4.2",
@@ -236,11 +201,28 @@
}
},
"node_modules/@sasjs/cli/node_modules/@sasjs/core": {
"version": "5.2.9",
"resolved": "https://registry.npmjs.org/@sasjs/core/-/core-5.2.9.tgz",
"integrity": "sha512-R5wtJF0ANHchaURqTF2t1vRXrhUJ+uDfj21ewzdOq4Lp14CWW22+yYeRQooY4QOpyHUyFDjReA5q6SoJt2Tz1g==",
"version": "5.2.10",
"resolved": "https://registry.npmjs.org/@sasjs/core/-/core-5.2.10.tgz",
"integrity": "sha512-Lk/ENHVuXaeAl8M0+Mjh1rcBSdFt4tIM6OPxPPmk78ZD0gYFqAOUZreqIoV9EN+VwPDqYsUPDqXzh0crW6j8OA==",
"license": "MIT"
},
"node_modules/@sasjs/cli/node_modules/shelljs": {
"version": "0.8.5",
"resolved": "https://registry.npmjs.org/shelljs/-/shelljs-0.8.5.tgz",
"integrity": "sha512-TiwcRcrkhHvbrZbnRcFYMLl30Dfov3HKqzp5tO5b4pt6G/SezKcYhmDg15zXVBswHmctSAQKznqNW2LO5tTDow==",
"license": "BSD-3-Clause",
"dependencies": {
"glob": "^7.0.0",
"interpret": "^1.0.0",
"rechoir": "^0.6.2"
},
"bin": {
"shjs": "bin/shjs"
},
"engines": {
"node": ">=4"
}
},
"node_modules/@sasjs/core": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@sasjs/core/-/core-5.2.8.tgz",
@@ -248,14 +230,14 @@
"license": "MIT"
},
"node_modules/@sasjs/lint": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@sasjs/lint/-/lint-2.5.0.tgz",
"integrity": "sha512-bVUtYnKhCigXylCCvQ6dsAIywpPKkX3BXatMqOWjDVw9R30jfj4M3/qn05fcR45L42Y7e5Tuzlt+LYs+9k95Yg==",
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@sasjs/lint/-/lint-4.1.0.tgz",
"integrity": "sha512-Vpg1s0/zdPMFJ0hRXNXk53HhE3kfRr5gFRS0kvrse+bxgcbqih1qqVW7yZ2krhSCYylp9z13eBXLhXbRrj70VQ==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"@sasjs/utils": "3.6.2",
"ignore": "7.0.8"
"ignore": "7.0.11"
}
},
"node_modules/@sasjs/utils": {
@@ -390,13 +372,13 @@
"license": "MIT"
},
"node_modules/axios": {
"version": "1.18.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz",
"integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==",
"version": "1.20.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz",
"integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.16.0",
"form-data": "^4.0.5",
"form-data": "^4.0.6",
"https-proxy-agent": "^5.0.1",
"proxy-from-env": "^2.1.0"
}
@@ -420,6 +402,12 @@
"tough-cookie": ">=4.0.0"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT"
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -451,16 +439,14 @@
"readable-stream": "^3.4.0"
}
},
"node_modules/braces": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"node_modules/brace-expansion": {
"version": "1.1.21",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
"integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==",
"license": "MIT",
"dependencies": {
"fill-range": "^7.1.1"
},
"engines": {
"node": ">=8"
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/buffer": {
@@ -646,26 +632,18 @@
"node": ">= 0.8"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"license": "MIT"
},
"node_modules/consola": {
"version": "2.15.0",
"resolved": "https://registry.npmjs.org/consola/-/consola-2.15.0.tgz",
"integrity": "sha512-vlcSGgdYS26mPf7qNi+dCisbhiyDnrN1zaRbw3CSuc2wGOMEGGPsp46PdRG5gqXwgtJfjxDkxRNAgRPr1B77vQ==",
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/cssstyle": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
@@ -840,66 +818,6 @@
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
"license": "MIT"
},
"node_modules/execa": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
"integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==",
"license": "MIT",
"dependencies": {
"cross-spawn": "^7.0.3",
"get-stream": "^6.0.0",
"human-signals": "^2.1.0",
"is-stream": "^2.0.0",
"merge-stream": "^2.0.0",
"npm-run-path": "^4.0.1",
"onetime": "^5.1.2",
"signal-exit": "^3.0.3",
"strip-final-newline": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sindresorhus/execa?sponsor=1"
}
},
"node_modules/fast-glob": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
"integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "^2.0.2",
"@nodelib/fs.walk": "^1.2.3",
"glob-parent": "^5.1.2",
"merge2": "^1.3.0",
"micromatch": "^4.0.8"
},
"engines": {
"node": ">=8.6.0"
}
},
"node_modules/fastq": {
"version": "1.20.3",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz",
"integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==",
"license": "ISC",
"dependencies": {
"reusify": "^1.0.4"
}
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"license": "MIT",
"dependencies": {
"to-regex-range": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/find": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/find/-/find-0.3.0.tgz",
@@ -910,9 +828,9 @@
}
},
"node_modules/follow-redirects": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"version": "1.16.1",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz",
"integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==",
"funding": [
{
"type": "individual",
@@ -959,6 +877,12 @@
"node": ">=14.14"
}
},
"node_modules/fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
"license": "ISC"
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -1014,28 +938,25 @@
"node": ">= 0.4"
}
},
"node_modules/get-stream": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
"integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": ">= 6"
"node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/gopd": {
@@ -1184,15 +1105,6 @@
"node": ">= 6.0.0"
}
},
"node_modules/human-signals": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
"integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==",
"license": "Apache-2.0",
"engines": {
"node": ">=10.17.0"
}
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
@@ -1226,20 +1138,55 @@
"license": "BSD-3-Clause"
},
"node_modules/ignore": {
"version": "7.0.8",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.8.tgz",
"integrity": "sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q==",
"version": "7.0.11",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.11.tgz",
"integrity": "sha512-YChdK5txDjwGUvgR7oCJcLGkwi3LDh8Zx8tS7ndYciLEg/oOCL26VUBSUEtgS7EjiybcBqFxh5j0rAdDX7/bbg==",
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/inflight": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
"deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
"license": "ISC",
"dependencies": {
"once": "^1.3.0",
"wrappy": "1"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/interpret": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/interpret/-/interpret-1.4.0.tgz",
"integrity": "sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/is-core-module": {
"version": "2.17.0",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.17.0.tgz",
"integrity": "sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA==",
"license": "MIT",
"dependencies": {
"hasown": "^2.0.4"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-docker": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz",
@@ -1255,15 +1202,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
@@ -1273,18 +1211,6 @@
"node": ">=8"
}
},
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-interactive": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz",
@@ -1294,33 +1220,12 @@
"node": ">=8"
}
},
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"license": "MIT",
"engines": {
"node": ">=0.12.0"
}
},
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"license": "MIT"
},
"node_modules/is-stream": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
"integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
"license": "MIT",
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-unicode-supported": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz",
@@ -1345,12 +1250,6 @@
"node": ">=8"
}
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"license": "ISC"
},
"node_modules/js-base64": {
"version": "3.9.3",
"resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.9.3.tgz",
@@ -1532,34 +1431,6 @@
"node": ">= 0.4"
}
},
"node_modules/merge-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
"integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==",
"license": "MIT"
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/micromatch": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"license": "MIT",
"dependencies": {
"braces": "^3.0.3",
"picomatch": "^2.3.1"
},
"engines": {
"node": ">=8.6"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
@@ -1590,6 +1461,18 @@
"node": ">=6"
}
},
"node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -1630,24 +1513,21 @@
"is-wsl": "^2.2.0"
}
},
"node_modules/npm-run-path": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
"integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==",
"license": "MIT",
"dependencies": {
"path-key": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/nwsapi": {
"version": "2.2.27",
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.27.tgz",
"integrity": "sha512-gQPNF78qebCQ6tvVFBYrvJdBNOrYZm90ZlXgpIFm06p6qHDHq/XC4TnJftN6OMbxVE0UTBAoRgcsDeJBBooITw==",
"license": "MIT"
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/onetime": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz",
@@ -1744,33 +1624,27 @@
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"node_modules/path-is-absolute": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
"license": "MIT",
"engines": {
"node": ">=8"
"node": ">=0.10.0"
}
},
"node_modules/path-parse": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
"license": "MIT"
},
"node_modules/pend": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
"integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
"license": "MIT"
},
"node_modules/picomatch": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"license": "MIT",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/prompts": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
@@ -1820,26 +1694,6 @@
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
"license": "MIT"
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
@@ -1854,6 +1708,17 @@
"node": ">= 6"
}
},
"node_modules/rechoir": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz",
"integrity": "sha512-HFM8rkZ+i3zrV+4LQjwQ0W+ez98pApMGM3HUrN04j3CqzPOzl9nmP15Y8YXNm8QHGv/eacOVEjqhmWpkRV0NAw==",
"dependencies": {
"resolve": "^1.1.6"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -1869,6 +1734,27 @@
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
"license": "MIT"
},
"node_modules/resolve": {
"version": "1.22.12",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
"integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"is-core-module": "^2.16.1",
"path-parse": "^1.0.7",
"supports-preserve-symlinks-flag": "^1.0.0"
},
"bin": {
"resolve": "bin/resolve"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/restore-cursor": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz",
@@ -1882,45 +1768,12 @@
"node": ">=8"
}
},
"node_modules/reusify": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
"license": "MIT",
"engines": {
"iojs": ">=1.0.0",
"node": ">=0.10.0"
}
},
"node_modules/rrweb-cssom": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
"integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==",
"license": "MIT"
},
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"queue-microtask": "^1.2.2"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -1959,40 +1812,6 @@
"node": ">=v12.22.7"
}
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/shebang-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/shelljs": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/shelljs/-/shelljs-0.10.0.tgz",
"integrity": "sha512-Jex+xw5Mg2qMZL3qnzXIfaxEtBaC4n7xifqaqtrZDdlheR70OGkydrPJWT0V1cA1k3nanC86x9FwAmQl6w3Klw==",
"license": "BSD-3-Clause",
"dependencies": {
"execa": "^5.1.1",
"fast-glob": "^3.3.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/signal-exit": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
@@ -2049,15 +1868,6 @@
"node": ">=8"
}
},
"node_modules/strip-final-newline": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz",
"integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@ -2070,6 +1880,18 @@
"node": ">=8"
}
},
"node_modules/supports-preserve-symlinks-flag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
@@ -2094,18 +1916,6 @@
"integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
"license": "MIT"
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"license": "MIT",
"dependencies": {
"is-number": "^7.0.0"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/tough-cookie": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.3.tgz",
@@ -2249,21 +2059,6 @@
"node": ">=18"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"node-which": "bin/node-which"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
@@ -2281,6 +2076,12 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/ws": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
+3 -2
View File
@@ -30,10 +30,11 @@
},
"private": true,
"dependencies": {
"@sasjs/cli": "4.20.4",
"@sasjs/cli": "4.20.5",
"@sasjs/core": "5.2.8"
},
"overrides": {
"nanoid": "3.3.18"
"nanoid": "3.3.18",
"shelljs": "0.8.5"
}
}
@@ -0,0 +1,117 @@
/**
@file
@brief migration script - raise the VIEW row limit, add the EDIT cell limit
OPTIONAL CHANGE - updates three options in MPE_CONFIG:
|option|old|new|
|---|---|---|
|DC_MAXOBS_WEBVIEW|500|2000|
|DC_MAXOBS_WEBEDIT|100|250|
|DC_MAXCELLS_WEBEDIT|(new)|200000|
MPE_CONFIG is bitemporal, so the current row for each option is closed and a
new row written with the new value. A site that has already chosen its own
values for these options will have them replaced - edit the values below
first if that is not what you want.
An option that is switched off - a current row with var_active=0 - is left
alone, and a note is written to the log. Writing an active row for it would
silently switch it back on.
Without this script the code defaults still apply (the same numbers), but
only for options that are not present at all - an existing install carries
its own rows, so it keeps 500 / 100 until this is run.
Each block below uses %do/%end on BOTH branches. A single-statement
%then/%else in open code (outside a %macro) is rejected by the Viya compute
engine with "Expected %DO not found", so it must not be shortened back.
**/
%let dclib=YOURDCLIB;
%let now=%sysfunc(datetime());
libname &dclib "/YOUR/DATACONTROLLER/LIBRARY/PATH";
/* which of the three options the site has switched off */
proc sql noprint;
select count(*) into :off_view trimmed
from &dclib..mpe_config
where var_name='DC_MAXOBS_WEBVIEW' and var_active=0 and &now lt tx_to;
select count(*) into :off_edit trimmed
from &dclib..mpe_config
where var_name='DC_MAXOBS_WEBEDIT' and var_active=0 and &now lt tx_to;
select count(*) into :off_cells trimmed
from &dclib..mpe_config
where var_name='DC_MAXCELLS_WEBEDIT' and var_active=0 and &now lt tx_to;
quit;
%if &off_view=0 %then %do;
proc sql noprint;
update &dclib..mpe_config
set tx_to=&now
where var_name='DC_MAXOBS_WEBVIEW' and var_active=1 and &now lt tx_to;
insert into &dclib..mpe_config
set tx_from=&now
,tx_to='31DEC9999:23:59:59'dt
,var_scope="DC"
,var_name="DC_MAXOBS_WEBVIEW"
,var_value='2000'
,var_active=1
,var_desc='This sets the maximum number of observations that can be'
!!' loaded into the browser in the VIEW screen.';
quit;
%end;
%else %do;
%put NOTE: DC_MAXOBS_WEBVIEW is switched off (var_active=0) - not changed;
%end;
%if &off_edit=0 %then %do;
proc sql noprint;
update &dclib..mpe_config
set tx_to=&now
where var_name='DC_MAXOBS_WEBEDIT' and var_active=1 and &now lt tx_to;
insert into &dclib..mpe_config
set tx_from=&now
,tx_to='31DEC9999:23:59:59'dt
,var_scope="DC"
,var_name="DC_MAXOBS_WEBEDIT"
,var_value='250'
,var_active=1
,var_desc='This sets the maximum number of observations that can be'
!!' loaded into the browser for editing in the EDIT screen. The'
!!' DC_MAXCELLS_WEBEDIT limit is applied at the same time, whichever'
!!' is reached first.';
quit;
%end;
%else %do;
%put NOTE: DC_MAXOBS_WEBEDIT is switched off (var_active=0) - not changed;
%end;
%if &off_cells=0 %then %do;
proc sql noprint;
update &dclib..mpe_config
set tx_to=&now
where var_name='DC_MAXCELLS_WEBEDIT' and var_active=1 and &now lt tx_to;
insert into &dclib..mpe_config
set tx_from=&now
,tx_to='31DEC9999:23:59:59'dt
,var_scope="DC"
,var_name="DC_MAXCELLS_WEBEDIT"
,var_value='200000'
,var_active=1
,var_desc='This sets the maximum number of CELLS (rows multiplied by'
!!' columns) that can be loaded into the browser for editing in the'
!!' EDIT screen. It exists because a wide table is expensive long'
!!' before it is tall - 250 rows of a 1000 column table is a quarter'
!!' of a million cells. Whichever of this and DC_MAXOBS_WEBEDIT is'
!!' reached first applies.';
quit;
%end;
%else %do;
%put NOTE: DC_MAXCELLS_WEBEDIT is switched off (var_active=0) - not changed;
%end;
+25 -2
View File
@@ -72,12 +72,35 @@ insert into &lib..mpe_config set
,tx_to='31DEC9999:23:59:59'dt
,var_scope="DC"
,var_name="DC_MAXOBS_WEBEDIT"
,var_value="100"
,var_value="250"
,var_active=1
,var_desc='This sets the maximum number of observations that can be loaded'
!!' into the browser for editing in the EDIT screen. A higher number'
!!' will require a decent browser (ie, not IE) and more memory on the'
!!' client side.';
!!' client side. The DC_MAXCELLS_WEBEDIT limit is applied at the same'
!!' time, whichever is reached first.';
insert into &lib..mpe_config set
tx_from=0
,tx_to='31DEC9999:23:59:59'dt
,var_scope="DC"
,var_name="DC_MAXCELLS_WEBEDIT"
,var_value="200000"
,var_active=1
,var_desc='This sets the maximum number of CELLS (rows multiplied by'
!!' columns) that can be loaded into the browser for editing in the EDIT'
!!' screen. It exists because a wide table is expensive long before it'
!!' is tall - 250 rows of a 1000 column table is a quarter of a million'
!!' cells. Whichever of this and DC_MAXOBS_WEBEDIT is reached first'
!!' applies.';
insert into &lib..mpe_config set
tx_from=0
,tx_to='31DEC9999:23:59:59'dt
,var_scope="DC"
,var_name="DC_MAXOBS_WEBVIEW"
,var_value="2000"
,var_active=1
,var_desc='This sets the maximum number of observations that can be'
!!' loaded into the browser in the VIEW screen.';
insert into &lib..mpe_config set
tx_from=0
,tx_to='31DEC9999:23:59:59'dt
+4 -1
View File
@@ -4,9 +4,12 @@
@details If user is in the administrator group, they can call this
service directly adding the following URL params:
@li &flavour= (only PGSQL supported at this time)
@li &flavour= (SAS, PGSQL or TSQL - defaults to SAS)
@li &schema= (optional, if target schema is needed)
Data inserts are generated for the SAS and PGSQL flavours only; the TSQL
export contains DDL without inserts.
<h4> SAS Macros </h4>
@li mf_getuser.sas
@li mp_abort.sas
@@ -72,3 +72,51 @@ run;
test=EQUALS 1,
outds=work.test_results
)
/* test 3 - TSQL flavour (DDL only, no inserts) */
data work.params;
length name $32 value $1000;
name='flavour';value='TSQL';output;
name='schema';value='public';output;
run;
%mx_execute(&_program,
viyacontext=&defaultcontext,
inputparams=work.params,
outref=web3,
viyaresult=WEBOUT_TXT,
mdebug=&sasjs_mdebug
)
data work.results3;
infile web3;
input;
putlog _infile_;
if index(upcase(_infile_),'CREATE TABLE') then do;
putlog 'test passed';
output;
stop;
end;
run;
/* scan the whole file for any INSERT - TSQL is DDL only */
data work.results4;
infile web3;
input;
if index(upcase(_infile_),'INSERT INTO') then do;
putlog 'unexpected INSERT found';
output;
stop;
end;
run;
%mp_assertdsobs(work.results3,
desc=TSQL flavour DDL file is successfully returned,
test=EQUALS 1,
outds=work.test_results
)
%mp_assertdsobs(work.results4,
desc=TSQL flavour export contains no INSERTs (DDL only),
test=EQUALS 0,
outds=work.test_results
)
+55 -5
View File
@@ -55,6 +55,8 @@
@li mf_getvarlist.sas
@li mf_existds.sas
@li mf_getquotedstr.sas
@li mf_getuniquefileref.sas
@li mf_getuniquename.sas
@li mf_getuser.sas
@li mf_nobs.sas
@li mf_verifymacvars.sas
@@ -205,7 +207,14 @@ select upcase(loadtype)
%put NOTE- Please add to &mpelib..MPE_CONFIG table;
%put NOTE-;%put NOTE-;
%global DC_MAXOBS_WEBEDIT;
%let DC_MAXOBS_WEBEDIT=500;
%let DC_MAXOBS_WEBEDIT=250;
%end;
%if not %symexist(DC_MAXCELLS_WEBEDIT) %then %do;
%put NOTE:;%put NOTE- DC_MAXCELLS_WEBEDIT not found!;
%put NOTE- Please add to &mpelib..MPE_CONFIG table;
%put NOTE-;%put NOTE-;
%global DC_MAXCELLS_WEBEDIT;
%let DC_MAXCELLS_WEBEDIT=200000;
%end;
/* for tables which use RKs/SKs then we just expose the business key to
users - this lets uploads be sent to multiple environments (with
@@ -254,18 +263,31 @@ select upcase(loadtype)
,msg=%str(Issue with filtering (line 165) )
)
options obs=&DC_MAXOBS_WEBEDIT;
/* 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. */
%let sortpk=%sysfunc(coalescec(&sortpk &var_busfrom,_ALL_));
proc sort data=work.out; by &sortPK; run;
options obs=max;
%mpe_runhook(PRE_EDIT_HOOK)
%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!
)
/* order delete var and pk fields at start of table */
@@ -274,6 +296,9 @@ select upcase(loadtype)
,Str2= _____DELETE__THIS__RECORD_____ &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;
data outdata;
/* delete & pk fields come first */
attrib _____DELETE__THIS__RECORD_____ &pk label='';
@@ -288,6 +313,7 @@ select upcase(loadtype)
%end;
set work.out ;
run;
options obs=max;
@@ -624,7 +650,7 @@ create table dqdata as
/* extract selectbox data */
%macro dq_selects();
%local x source lib ds col;
%local x source lib ds col filtref filterds srcview;
%do x=1 %to &sourcecnt;
%let source=&&source&x;
%let lib=%scan(&source,1,.);
@@ -634,13 +660,31 @@ create table dqdata as
%put &=lib;
%dc_assignlib(READ,&lib)
%dc_casload(&lib..&ds)
/* Selectbox values are read straight from the source table, so the same
Row Level Security that filters the grid must be applied here too -
otherwise values from rows the user is not permitted to see leak into
the cell dropdown. %mpe_filtermaster builds the filter; a data step
view is used because a fileref cannot be %inc'd inside a proc sql where
clause. EDIT mode mirrors the grid on this (editor) page. */
%let filtref=%mf_getuniquefileref();
%let filterds=%mf_getuniquename();
%mpe_filtermaster(EDIT,&lib..&ds,
dclib=&mpelib,
outref=&filtref,
outds=work.&filterds
)
%let srcview=%mf_getuniquename();
data work.&srcview / view=work.&srcview;
set &lib..&ds;
where %inc &filtref;;
run;
proc sql;
create table dqdata&x as
select distinct "&&base_col&x" as base_col length=32
,"&source" as rule_value length=74
,cats(&col) as rule_data length=1000
,&col as tmp_order
from &lib..&ds
from work.&srcview
order by tmp_order;
/* ensure both numerics and char vals are ordered correctly */
data work.dqdata&x (drop=tmp_order);
@@ -653,6 +697,12 @@ create table dqdata as
)
proc append base=dqdata data=dqdata&x;run;
proc sql; drop table dqdata&x;
/* drop the per-source filter artifacts - WORK is session scoped, but the
loop should not leave a view and a filter table behind per source */
proc datasets lib=work nolist;
delete &srcview &filterds;
quit;
filename &filtref clear;
%end;
%mend dq_selects;
%dq_selects()
@@ -0,0 +1,124 @@
/**
@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).
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.
<h4> SAS Macros </h4>
@li mp_assert.sas
@li mp_filterstore.sas
@li mf_getuniquefileref.sas
@li mx_execute.sas
**/
%let _program=&appLoc/services/editors/getdata;
/**
* build a ROW_ID <= &maxrow filter on dctest.wideboy, leaving the filter_rk
* in &filter_rk
*/
%macro wideboy_filter(maxrow);
data work.inquery;
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='ROW_ID';
OPERATOR_NM='LE';
RAW_VALUE="&maxrow";
output;
run;
%mp_filterstore(
libds=dctest.wideboy,
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.inquery,
outresult=work.result,
outquery=work.query
)
data _null_;
set work.result;
call symputx('filter_rk',filter_rk,'g');
run;
%mend wideboy_filter;
/**
* Test 1 - row limit (300 rows > 250)
*/
%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;
%mx_execute(&_program,
viyacontext=&defaultcontext,
inputfiles=&f1:sascontroltable,
outref=web1
)
%let abort1=0;
data _null_;
set work.results;
if state='canceled' then call symputx('abort1',1);
run;
%let syscc=0;
%mp_assert(
iftrue=(&abort1=1),
desc=Test 1 - a selection over the row limit is refused,
outds=work.test_results
)
/**
* 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;
%mx_execute(&_program,
viyacontext=&defaultcontext,
inputfiles=&f2:sascontroltable,
outref=web2
)
%let abort2=0;
data _null_;
set work.results;
if state='canceled' then call symputx('abort2',1);
run;
%mp_assert(
iftrue=(&abort2=1),
desc=Test 2 - a selection over the cell limit is refused,
outds=work.test_results
)
/**
* dump results to the log for offline inspection
*/
data _null_;
set work.test_results;
putlog 'TEST_RESULT_LINE: ' test_result ' - ' test_description;
run;
@@ -0,0 +1,271 @@
/**
@file
@brief testing that selectbox (cell dropdown) values respect Row Level Security
@details A HARDSELECT / SOFTSELECT rule whose RULE_VALUE is a
library.member.column reference has its distinct values read straight from
that source table by getdata.sas. If the source table carries Row Level
Security rules, the restricted rows must be filtered out of the dropdown too -
otherwise values the user is not permitted to see appear in the cell dropdown
while the grid itself is correctly filtered.
This test makes the runner a 'regular' (non admin) user and restricts
SOME_BESTNUM in MPE_X_TEST to one value selected from the data (the demo
data holds random SOME_BESTNUM values, refreshed by testsetup, so the
value cannot be hardcoded). The demo data must hold more than one
distinct SOME_BESTNUM - true of the SAS-seeded estate, but NOT of the
SASjs server mock, which seeds the seven 44 rows only (see the
'distinct_bestnum' precondition assertion below).
Runners whose SAS identity group is the admin group cannot be demoted by
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.
<h4> SAS Macros </h4>
@li mf_getuniquefileref.sas
@li mf_getuniquename.sas
@li mf_getuser.sas
@li mpe_getgroups.sas
@li mp_assert.sas
@li mp_assertcolvals.sas
@li mp_assertdsobs.sas
@li mp_filterstore.sas
@li mx_execute.sas
**/
%let _program=&appLoc/services/editors/getdata;
/**
* The body is wrapped in a macro so the conditional skip below uses
* %if/%then/%do inside a macro definition. Open-code %if needs SAS 9.4M5+,
* and the suite also runs on the sas9 target, so the wrapper keeps the test
* portable across the supported estates (as testsetup.sas's update_settings
* does for its own conditionals).
*/
%macro getdata_rls_test();
/**
* Row Level Security is skipped for admins, so only a non admin runner can
* verify it. The group membership is checked against &dc_admin_group (the
* mpeinit-resolved &mpeadmins is not available in a test session, see
* testinit.sas) and taken from the SAS identity groups, NOT the MPE_GROUPS
* table this test goes on to edit. Admin runners (eg a shared test account
* that is a member of the admin group) simply skip the RLS assertions.
*/
%mpe_getgroups(user=%mf_getuser(),outds=work.testgroups)
proc sql noprint;
select count(*) into: admin_check
from work.testgroups
where groupname="&dc_admin_group";
quit;
%if &admin_check>0 %then %do;
%put WARNING: %mf_getuser() is in the &dc_admin_group group;
%put WARNING: Row Level Security is skipped for admins - test skipped;
data work.test_results;
length test_result $4 test_description $256 test_comments $256;
test_result='PASS';
test_description="GETDATA_TEST_2 skipped - runner is an admin";
test_comments="RLS is skipped for admins so the assertions cannot run";
run;
%end;
%else %do;
/**
* Make the runner a 'regular' user and grant the temp group EDIT access so
* the getdata access check still passes.
*/
data work.groups_backup;
set &dc_libref..MPE_GROUPS;
where upcase(user_name)="%upcase(%mf_getuser())";
run;
%let tempgroup=%mf_getuniquename();
proc sql;
delete from &dc_libref..MPE_GROUPS
where upcase(user_name)="%upcase(%mf_getuser())";
insert into &dc_libref..MPE_GROUPS
set user_name="%mf_getuser()"
,group_name="&tempgroup"
,group_desc="temp group"
,tx_from='01Jan1960:00:00:00'dt
,tx_to='31Dec9999:23:59:59'dt;
insert into &dc_libref..MPE_SECURITY
set tx_from=0
,libref="*ALL*"
,dsn="*ALL*"
,access_level="EDIT"
,sas_group="&tempgroup"
,tx_to='31DEC9999:23:59:59'dt;
quit;
/**
* Row Level Security on MPE_X_TEST - only one SOME_BESTNUM value (selected
* from the data, see header) is visible. The value is taken from the rows
* that match the user filter below, so the filtered grid cannot be empty.
*/
proc sql noprint;
delete from &dc_libref..MPE_ROW_LEVEL_SECURITY
where RLS_TABLE="MPE_X_TEST" and rls_group="&tempgroup";
select distinct some_bestnum into :rls_value trimmed
from &dc_libref..MPE_X_TEST
where some_bestnum is not null and primary_key_field < 100;
select coalesce(max(rls_rk),0) into: max_rk
from &dc_libref..MPE_ROW_LEVEL_SECURITY;
quit;
proc sql;
insert into &dc_libref..mpe_row_level_security set
tx_from=0
,tx_to='31DEC5999:23:59:59'dt
,RLS_RK=&max_rk+1
,RLS_SCOPE='ALL'
,RLS_GROUP="&tempgroup"
,RLS_LIBREF="&dc_libref"
,RLS_TABLE="MPE_X_TEST"
,RLS_GROUP_LOGIC='AND'
,RLS_SUBGROUP_LOGIC='OR'
,RLS_SUBGROUP_ID=0
,RLS_VARIABLE_NM='SOME_BESTNUM'
,RLS_OPERATOR_NM='IN'
,RLS_RAW_VALUE="(&rls_value)"
,RLS_ACTIVE=1;
quit;
/**
* Preconditions - the source column really does hold more than one distinct
* value (so the assertions are meaningful) and the user filter matches at
* least one of them (so the grid cannot be empty).
*/
proc sql noprint;
select count(distinct some_bestnum) into: distinct_bestnum
from &dc_libref..MPE_X_TEST;
quit;
%mp_assert(
iftrue=(&distinct_bestnum>1),
desc=Precondition - MPE_X_TEST.SOME_BESTNUM has more than one distinct value,
outds=work.test_results
)
%mp_assert(
iftrue=(%length(&rls_value)>0),
desc=Precondition - the user filter matches at least one SOME_BESTNUM value,
outds=work.test_results
)
/**
* Store a filter (PRIMARY_KEY_FIELD < 100) so the selection stays under the
* DC_MAXOBS_WEBEDIT row limit.
*/
data work.inquery;
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='PRIMARY_KEY_FIELD';
OPERATOR_NM='<';
RAW_VALUE='100';
run;
%mp_filterstore(
libds=&dclib..MPE_X_TEST,
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.inquery,
outresult=work.filter_result,
outquery=work.filter_query
)
data _null_;
set work.filter_result;
call symputx('f1_rk',filter_rk,'l');
run;
/**
* Call the getdata service - filtered to keep the row count under
* DC_MAXOBS_WEBEDIT (see header).
*/
%let f1=%mf_getuniquefileref();
data _null_;
file &f1 termstr=crlf;
put 'LIBDS:$43. FILTER_RK:8.';
put "&dclib..MPE_X_TEST,&f1_rk";
run;
%mx_execute(&_program,
viyacontext=&defaultcontext,
inputfiles=&f1:sascontroltable,
outlib=web1,
mdebug=&sasjs_mdebug
)
data work.dqdata;
set web1.dqdata;
run;
data work.sasdata;
set web1.sasdata;
run;
/* the selectbox source rows only (exclude MPE_SELECTBOX-derived values) */
data work.dqtest;
set work.dqdata;
where upcase(base_col)='SOME_BESTNUM';
run;
data work.checkchar;
val="&rls_value"; output;
run;
data work.checknum;
val=&rls_value; output;
run;
/**
* Test 1 - control: the grid itself is filtered by RLS. If this fails the
* runner is an admin and the dropdown assertion below cannot be trusted.
*/
%mp_assertcolvals(work.sasdata.SOME_BESTNUM,
checkvals=work.checknum.val,
desc=Test 1 - grid rows respect Row Level Security (control),
test=ALLVALS,
outds=work.test_results
)
/**
* Test 2 - the selectbox values sourced from MPE_X_TEST.SOME_BESTNUM must
* also be restricted to the RLS-visible value (44).
*/
%mp_assertdsobs(work.dqtest,
desc=Test 2 precondition - selectbox values were returned for SOME_BESTNUM,
test=HASOBS,
outds=work.test_results
)
%mp_assertcolvals(work.dqtest.RULE_DATA,
checkvals=work.checkchar.val,
desc=Test 2 - selectbox values respect Row Level Security,
test=ALLVALS,
outds=work.test_results
)
/**
* Cleanup - restore the group / security / RLS rows
*/
proc sql;
delete from &dc_libref..MPE_ROW_LEVEL_SECURITY where rls_group="&tempgroup";
delete from &dc_libref..MPE_SECURITY where sas_group="&tempgroup";
delete from &dc_libref..MPE_GROUPS where group_name="&tempgroup";
quit;
proc append base=&dc_libref..MPE_GROUPS data=work.groups_backup;
run;
%end;
%mend getdata_rls_test;
%getdata_rls_test()
+34 -4
View File
@@ -17,13 +17,43 @@
/**
* Test 1 - basic fetch
* The whole of MPE_TABLES is over the EDIT row limit on a populated estate,
* so fetch a single library's rows - the filter is what keeps the selection
* under the limit. The assertion is simply that data still comes back.
*/
data work.inquery1;
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="'&dclib'";
output;
run;
%mp_filterstore(
libds=&dclib..MPE_TABLES,
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.inquery1,
outresult=work.result1,
outquery=work.query1
)
data _null_;
set work.result1;
call symputx('filter_rk1',filter_rk,'g');
run;
%let f1=%mf_getuniquefileref();
data _null_;
file &f1 termstr=crlf;
put 'LIBDS:$43.';
put "&dclib..MPE_TABLES";
put 'LIBDS:$43. FILTER_RK:8.';
put "&dclib..MPE_TABLES,&filter_rk1";
run;
%mx_execute(&_program,
viyacontext=&defaultcontext,
@@ -58,9 +88,9 @@ data query;
run;
%mp_assertcols(work.query,
desc=Query dataset is empty (test cols not obs as table has one row),
desc=Query dataset carries the filter columns,
cols=VARIABLE_NM RAW_VALUE SUBGROUP_ID,
test=NONE,
test=ALL,
outds=work.test_results
)
%mp_assertdsobs(work.sasdata,
+21 -26
View File
@@ -6,6 +6,7 @@
<h4> SAS Macros </h4>
@li dc_assignlib.sas
@li mp_abort.sas
@li mp_getcols.sas
@li mp_validatecol.sas
@version 9.2
@@ -43,32 +44,26 @@ run;
%dc_assignlib(READ,%scan(&ds,1,.))
proc contents noprint data=&ds
out=droplist1 (keep=name type length label varnum format:);
run;
data cols(keep=name type length varnum format label);
set droplist1(rename=(format=format2 type=type2));
name=upcase(name);
if type2=2 then do;
length format $49.;
if format2='' then format=cats('$',length,'.');
else if formatl=0 then format=cats(format2,'.');
else format=cats(format2,formatl,'.');
type='C';
ddtype='C';
end;
else do;
if format2='' then format=cats(length,'.');
else if formatl=0 then format=cats(format2,'.');
else if formatd=0 then format=cats(format2,formatl,'.');
else format=cats(format2,formatl,'.',formatd);
type='N';
if format=:'DATETIME' then ddtype='DATETIME';
else if format=:'DATE' then ddtype='DATE';
else if format=:'TIME' then ddtype='TIME';
else ddtype='N';
end;
if label='' then label=name;
/* Column metadata, typed the way the editor's own load types it: %mp_getcols
infers DATE and DATETIME across the whole format list (YYMMDD, MMDDYY,
E8601DA, B8601DA, NLDATE, NLDATM, E8601DT, ...), which a test against the
format prefix does not - a column formatted YYMMDD would otherwise reach the
picker as numeric. getdata.sas makes the same call for the same reason. */
%mp_getcols(&ds, outds=cols1)
/* Response contract. `type` keeps the SAS vocabulary this public service has
always used (C/N), and `format` and `label` are carried over from the
previous shape, so an existing caller still finds them unchanged. `fmtname`
(the bare format name) and `ddtype` (which now distinguishes DATE, DATETIME
and TIME) are additions. The client derives its own num/char vocabulary from
ddtype at the store boundary - see sas-store.getCols. */
data cols(keep=name type length varnum fmtname format label ddtype);
set cols1;
/* DDTYPE in the client's vocabulary, as getdata.sas maps it. ddtype needs
no length statement - the macro creates it with 'CHARACTER', so it is
already $9. */
if ddtype='CHARACTER' then ddtype='C';
else if ddtype='NUMERIC' then ddtype='N';
run;
%mp_abort(iftrue= (&syscc ne 0)
@@ -66,8 +66,20 @@ run;
)
%let nobs=0;
%let nbad=0;
proc sql noprint;
select count(*) into: nobs from web2.cols;
/**
* The service keeps the SAS vocabulary this public contract has always used:
* TYPE is C/N, and DDTYPE separates the date and time types. The client maps
* TYPE to its own num/char vocabulary in sas-store.getCols, so a change here
* that silently switched vocabulary would not be caught by the client suite.
* Every column must be C or N, and the two must agree with ddtype.
*/
select count(*) into: nbad from web2.cols
where type not in ('C','N')
or (ddtype='C' and type ne 'C')
or (ddtype in ('N','DATE','DATETIME','TIME') and type ne 'N');
quit;
%mp_assert(
@@ -76,6 +88,12 @@ quit;
outds=work.test_results
)
%mp_assert(
iftrue=(&nbad=0),
desc=%str(TYPE stays in the SAS C/N vocabulary and agrees with ddtype),
outds=work.test_results
)
/**
* dump results to the log for offline inspection
*/
+1 -1
View File
@@ -181,7 +181,7 @@ run;
%put NOTE- Please add to &mpelib..MPE_CONFIG table;
%put NOTE-;%put NOTE-;
%global DC_MAXOBS_WEBVIEW;
%let DC_MAXOBS_WEBVIEW=500;
%let DC_MAXOBS_WEBVIEW=2000;
%end;
%if &existds>0 %then %do;