Compare commits

..
7 Commits
Author SHA1 Message Date
4gl 28104f83e1 chore(test): test for mpe_targetloader update
Build / Build-and-ng-test (pull_request) Successful in 5m9s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m27s
Build / Build-and-test-development (pull_request) Successful in 22m4s
2026-07-31 15:01:00 +01:00
4gl ea05f07180 fix: CAS support for REPLACE type plus docs
Build / Build-and-test-development (pull_request) Successful in 22m24s
Build / Build-and-ng-test (pull_request) Successful in 5m50s
Lighthouse Checks / lighthouse (pull_request) Successful in 20m39s
2026-07-31 13:04:19 +01:00
YuryShkoda acb97f4bfb fix(formulas): avoid EDIT_STATUS colliding with a real column of that name
Build / Build-and-ng-test (pull_request) Successful in 5m15s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m48s
Build / Build-and-test-development (pull_request) Successful in 21m38s
2026-07-31 10:26:35 +03:00
YuryShkoda bb808617f4 chore: merge remote-tracking branch 'origin/main' into additional-validations-formulae
Build / Build-and-ng-test (pull_request) Successful in 5m7s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m44s
Build / Build-and-test-development (pull_request) Successful in 21m54s
2026-07-30 18:00:48 +03:00
YuryShkoda 4a8c39b4c0 feat(formulas): live DC.ROW_STATUS, insert-row formula fixes, UserService singleton fix
Build / Build-and-ng-test (pull_request) Successful in 5m13s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m31s
Build / Build-and-test-development (pull_request) Successful in 21m56s
- addRow()/insertRowAtPosition() use hot.alter() so HARDFORMULA/SOFTFORMULA
  values realign on insert instead of going stale
- New hidden EDIT_STATUS column gives DC.ROW_STATUS a real, live cell
  reference; row-header +/-/~ indicator now translates visual->physical
  row index so it stays correct when the grid is sorted
- UserService is now providedIn: 'root' instead of module-scoped, fixing
  DC.USER_NAME (and any other lazy-module reader) always seeing an unset user
- Column-info dropdown shows the applied formula ("√x=<formula>")
2026-07-30 16:03:11 +03:00
YuryShkoda 69cfccd565 chore: merge additional-validations-regex branch 2026-07-27 15:30:50 +03:00
YuryShkoda cbea04c8e1 feat(formulas): wire HyperFormula into HARDFORMULA/SOFTFORMULA rules
Adds HARDFORMULA/SOFTFORMULA DQ rule types, backed by Handsontable's
formulas plugin (gated per-table via hasFormulaRules). parseFormulaRule
substitutes column names with row-relative cell references (quote-aware,
blank-boundary matching per spec) and resolves DC.USER_NAME/DC.ORIG_VALUE
to literal values; applyFormulaRules injects the computed formula per row,
reusing the existing READONLY mechanism for HARDFORMULA.

DC.ROW_STATUS/EDIT_STATUS deliberately deferred - would require prepending
COLHEADERS, which headerColumns has undocumented positional coupling to
elsewhere in editor.component.ts.
2026-07-23 17:35:07 +03:00
28 changed files with 2272 additions and 89 deletions
+147
View File
@@ -0,0 +1,147 @@
# Bitemporal Dataloader — Technical Deep Dive
This document explains how the Data Controller backend loads staged data into target tables: the load-type dispatch, the internals of the `%bitemporal_dataloader` macro, and a detailed description of the REPLACE load type. For user-facing load type documentation, see [docs.datacontroller.io](https://docs.datacontroller.io/).
## Overview
Every table registered for loading has a current record in `&mpelib..MPE_TABLES` with a `LOADTYPE` (selectbox values are seeded in `mpe_makedata.sas`):
| LOADTYPE | Loader used | History kept |
|---|---|---|
| `UPDATE` | `%bitemporal_dataloader` (no temporal vars) | None — changed records are deleted and re-appended |
| `REPLACE` | Inline code in `%mpe_targetloader` (does **not** use `%bitemporal_dataloader`) | None — entire table wiped and reloaded |
| `TXTEMPORAL` | `%bitemporal_dataloader` (technical time only) | SCD2-style, technical (transaction) time |
| `BITEMPORAL` | `%bitemporal_dataloader` (business + technical time) | Full two-dimensional history |
| `FORMAT_CAT` | `%mp_loadformat` | Format catalog load (table suffix `-FC`) |
The relevant `MPE_TABLES` columns read by the loader are: `LOADTYPE`, `BUSKEY` (primary key, space-separated, excluding temporal columns), `VAR_TXFROM` / `VAR_TXTO` (technical validity), `VAR_BUSFROM` / `VAR_BUSTO` (business validity), `VAR_PROCESSED` (processed timestamp column), `RK_UNDERLYING` (retained-key generation), `CLOSE_VARS`, and `AUDIT_LIBDS` (defaults to `&dclib..MPE_AUDIT`).
## Request Flow
```mermaid
flowchart TD
A[User submits changeset\neditors/stagedata.sas] --> B[Staging package written to\n&mpelocapprovals/&LOAD_REF\nCSV + jsdata]
B --> C[Approval workflow\nMPE_SUBMIT / MPE_REVIEW]
C --> D{auditors/postdata.sas}
D -->|action=SHOW_DIFFS| E["%mpe_targetloader(LOADTARGET=NO)\nbuilds work.outds_add / outds_mod / outds_del\nfor the diff screen only"]
D -->|action=APPROVE_TABLE| F["%mpe_targetloader(LOADTARGET=YES)\nactual load"]
E --> G[Diff CSV + TEMPDIFFS stored\nin approval package]
F --> H{LOADTYPE from\nMPE_TABLES}
H -->|UPDATE / TXTEMPORAL / BITEMPORAL| I["%bitemporal_dataloader"]
H -->|FORMAT_CAT| J["%mp_loadformat"]
H -->|REPLACE| K[Inline delete-all + append\nin %mpe_targetloader]
```
`%mpe_targetloader` (`sas/sasjs/macros/mpe_targetloader.sas`) is the single dispatch point. It reads the current `MPE_TABLES` record (`&dc_dttmtfmt. lt tx_to`), aborts if the table is not registered (or has duplicate config records), and routes to the loader. Note the two-phase design: `LOADTARGET=NO` prepares the intermediate `outds_*` tables so the approver can review diffs; `LOADTARGET=YES` performs the destructive load. Both phases run the same preparation logic, so the reviewed diffs correspond to what is actually applied.
## The Temporal Model
Bitemporal tables carry two independent time dimensions:
* **Business time** (`bus_from` / `bus_to`) — when the fact was true in the real world. Present on **both** staging and base tables.
* **Technical time** (`tech_from` / `tech_to`, a.k.a. transaction time) — when the record was known to the database. Present on the **base table only**; the loader stamps these itself.
All validity is expressed with **half-open intervals** (`from <= t < to`). Queries against bitemporal tables need two conditions and must not use `BETWEEN` or `from LE t LE to` (the latter excludes boundary records — see the macro header for background):
```sas
where &bus_from le [tstamp] lt &bus_to
and &tx_from le [tstamp] lt &tx_to
```
"Current" records have `tech_to` set to the high date (`'31DEC9999:23:59:59'dt` when called from `%mpe_targetloader`). Closing out a record means setting `tech_to = now` — records are never physically deleted from a temporal table, they are superseded.
## Key Components
| Component | Location | Role |
|---|---|---|
| `%mpe_targetloader` | `sas/sasjs/macros/mpe_targetloader.sas` | Reads `MPE_TABLES` config and dispatches per LOADTYPE; implements REPLACE inline |
| `%bitemporal_dataloader` | `sas/sasjs/macros/bitemporal_dataloader.sas` | Generic loader for UPDATE / TXTEMPORAL / BITEMPORAL |
| `%bitemporal_closeouts` | `sas/sasjs/macros/bitemporal_closeouts.sas` | Closes out (sets `tech_to=now`) live records matching a key |
| `%mp_retainedkey` | SASjs core | Generates retained (surrogate) keys when `RK_UNDERLYING` is configured |
| `%mp_rowhash` | SASjs core | MD5 hash of non-temporal columns, used for change detection |
| `%mp_storediffs` | SASjs core | Writes row-level audit records to `AUDIT_LIBDS` |
| `MPE_DATALOADS` | `&dclib` | Load log (counts, duration, macro version, user) — written only by `%bitemporal_dataloader` |
| `MPE_LOCKANYTABLE` | `&dclib` | Lock control table used by `%mp_lockanytable` |
## `%bitemporal_dataloader` Execution Flow
The macro builds a series of `work.bitemp*` intermediate tables, then applies the changes in two target-table operations (closeout, then append) under a single lock.
### 1. Pre-checks and setup
* Early return if the staging table is empty; hard abort if `&syscc > 0`.
* `CLOSE_VARS` is not supported on REDSHIFT / POSTGRES / SNOWFLAKE engines (returns with a NOTE).
* A zero-row snapshot of the base table (`&basecopy`) is taken with `data ... set base; stop;` — this doubles as a lock check, since metadata functions fail against a locked table.
* `proc contents` on the base table feeds column lists. Columns are split into character and numeric lists for hashing (`%mp_rowhash` hashes the two types via separate arrays). Temporal columns, the processed column, and the delete-flag column are excluded from the hash.
* Table names containing `___TMP___` or `_____` are rejected (they would collide with generated temp columns).
### 2. Locking
For `LOADTARGET=YES`, the base table (and audit table, if configured) is locked via `%mp_lockanytable` before any staging prep, because the load is a two-part update (closeouts + append) and must not interleave with another load.
### 3. Staging preparation (`work.bitemp0_append`)
* If `RK_UNDERLYING` is configured, `%mp_retainedkey` maps business keys to retained keys (filtering the base lookup to live records, `&now < tech_to`, for temporal types); otherwise the staging table is used as-is.
* `bus_from` / `bus_to` overrides are applied if provided; the processed column is stamped with the load timestamp (`now`). Even with `processed=0`, a column literally named `PROCESSED_DTTM` on the base table is used if present.
* The MD5 change-detection hash is computed for every staged record.
* If the staging table contains the delete-flag column (`_____DELETE__THIS__RECORD_____`), rows flagged `"Yes"` are diverted to `&outds_del` and closed out via `%bitemporal_closeouts` (PK is taken as `bus_from` + business key). The remaining rows continue as `bitemp0_append`.
### 4. CLOSE_VARS closeout
When `CLOSE_VARS` (a subset of the PK) is supplied, live base records whose CLOSE_VARS values appear in staging but whose full PK does **not** are closed out. This handles "this group was fully reloaded, so anything missing was removed" semantics without reloading the whole table.
### 5. Uniqueness check
If `CHECK_UNIQUENESS=YES` (the default) or business-date overrides are in play, the staging table is sorted `nodupkey` by the PK; a row-count mismatch aborts the load and releases the locks. The staging table must be a unique snapshot of the business key at one point in business time.
### 6. Base extract (`work.bitemp0_base`)
Only base records matching staged PKs are extracted — for temporal load types, only **currently live** records (`now < tech_to`). A left join from the staged keys to the base produces `___TMP___NEW_FLG` to identify brand-new keys. This is engine-specific:
* **OLEDB (SQL Server)** — staged keys are pushed to a `##global` temp table and joined via explicit pass-through.
* **REDSHIFT / POSTGRES / SNOWFLAKE** — an in-database temp table is created `like` the base, stripped to PK + an added `md5 varchar(32)` column, loaded with staged keys, and joined via pass-through. Snowflake uses transient tables; Redshift gets `alter sortkey none` plus any `DCBL_REDSH` config options from `MPE_CONFIG`.
* **CAS** — a FedSQL join against a CASUSER copy.
* **BASE/other** — plain PROC SQL in SAS.
### 7. Change classification
* **`&outds_add`** — staged records flagged as new (no base match). They get `tech_from=now`, `tech_to=high_date`.
* **`work.bitemp1_current`** — matched base records, re-hashed so hashes are comparable.
* **Inserts (BITEMPORAL only)** — a staged record whose business range falls strictly *inside* an existing record's range splits the existing record into a "before" and "after" segment (`bitemp3_inserts` / `bitemp3a_inserts`). The split segments replace the original in the comparison set (`bitemp3b_newbase`).
* **Updates (`bitemp4*`)** — staged records matching a base PK but with a different hash or different business dates. Base and staged versions are stacked, deduplicated, then aligned in **two passes** over the business timeline per key: a forward pass (carry `bus_from` forward across identical hashes; a staged record trims the preceding base record's range) and a reverse pass (carry `bus_to` back; records fully subsumed by the new version are deleted). Records that end up byte-identical to what is already stored are dropped via a hash lookup that includes the business dates (`bitemp5a_lkp` / `bitemp5b_updates`, BITEMPORAL only).
### 8. Closeout application
Changed records are closed out in the target before the new versions are appended. SAS SQL has no UPDATE-with-join, so a correlated `EXISTS` subquery against `work.bitemp5d_subquery` is used (pushed in-database as a temp table for OLEDB / REDSHIFT / POSTGRES / SNOWFLAKE):
* **BITEMPORAL** — per key, the closeout range is `min(bus_from)` / `max(bus_to)` of the changed records: `update base set tech_to=now (, processed=now) where tech_from <= now < tech_to and exists (key match and base.bus_from >= min and base.bus_to <= max)`.
* **TXTEMPORAL** — same, on key only.
* **UPDATE** — changed records are physically `delete`d (they are re-appended in the next step). On CAS this uses `table.deleteRows` with a whereTable; temporal types are not supported on CAS and abort.
* **BUSTEMPORAL** — closeouts are not implemented; the macro aborts at this point ("BUSTEMPORAL NOT YET SUPPORTED").
### 9. Append, unlock, audit, log
* The union of modified + new records, deduplicated on all columns (`bitemp6_unique`), is appended to the base table (`proc append ... force nowarn`; CAS appends via a varchar-casting step; Redshift applies `DCBL_REDSH` options). Locks are then released.
* If `outds_audit` is set (always, from `%mpe_targetloader``AUDIT_LIBDS` defaulting to `&dclib..MPE_AUDIT`), `%mp_storediffs` compares the pre-load snapshot with the applied changes and appends row-level audit records; modified-row entries with no actual value change are removed (`MOVE_TYPE="M" and IS_PK=0 and IS_DIFF=0`).
* A summary row is inserted into `&dclib..MPE_DATALOADS` (libref, dsn, etlsource, loadtype, changed/new/deleted counts, duration, macro version, user, timestamp) unless `LOG=0`.
## REPLACE Load Type
REPLACE is the simplest load type and is deliberately **not** routed through `%bitemporal_dataloader` — it is implemented inline in `%mpe_targetloader` and simply wipes the target table (`delete * from`) and re-appends the staging table verbatim, with no history, change detection, key matching, audit rows, or `MPE_DATALOADS` logging. See [replace-load-type.md](replace-load-type.md) for the full deep dive.
## Supporting Tables
| Table | Role |
|---|---|
| `MPE_TABLES` | Per-table load configuration (loadtype, keys, temporal vars, audit target) |
| `MPE_SELECTBOX` | Dropdown values, including the LOADTYPE list (seeded in `mpe_makedata.sas`) |
| `MPE_SUBMIT` / `MPE_REVIEW` | Approval workflow state |
| `MPE_LOADS` | Submission-level log of CSV package loads |
| `MPE_DATALOADS` | Per-load statistics (temporal/UPDATE loads only) |
| `MPE_AUDIT` (or `AUDIT_LIBDS`) | Row-level change audit (temporal/UPDATE/FORMAT_CAT loads only) |
| `MPE_LOCKANYTABLE` | Lock control table |
| `MPE_CONFIG` | Engine-specific config (e.g. `DCBL_REDSH` scope for Redshift bulk options) |
## Testing
Unit tests for the loaders live next to the macros: `sas/sasjs/macros/bitemporal_dataloader.test.[1-4].sas` and `sas/sasjs/macros/mpe_targetloader.test.sas` (REPLACE loadtype), executed as sasjs tests. See [testing.md](testing.md) for how to run them (`npm run 4gl` then `sasjs test -t 4gl` from the `sas/` directory), and remember `sasjs lint` after touching any `.sas` files.
+97
View File
@@ -0,0 +1,97 @@
# REPLACE Load Type — Technical Deep Dive
This document describes the REPLACE load type in detail. For the overall loader architecture, the temporal model, and the other load types, see [bitemporal-dataloader.md](bitemporal-dataloader.md).
## Overview
REPLACE is the simplest load type and is deliberately **not** routed through `%bitemporal_dataloader`. It is implemented inline in `%mpe_targetloader` (`sas/sasjs/macros/mpe_targetloader.sas`, search for `&loadtype=REPLACE`). There is no history, no change detection, no key matching — the target table is wiped and reloaded from the staging table verbatim.
## Actual load (`LOADTARGET=YES`)
```sas
%mp_lockanytable(LOCK, lib=&lib, ds=&ds, ...)
data WORK.&STAGING_DS;
set WORK.&STAGING_DS;
/* only if the target contains the MPE_TABLES.VAR_PROCESSED variable: */
&VAR_PROCESSED = &now;
drop _____DELETE__THIS__RECORD_____;
run;
%if &engine_type=CAS %then %do;
/* prep first: cast varchar columns in a CASUSER copy of staging */
proc contents noprint data=&libds out=work.rpl_base_cols(keep=name type);
proc contents noprint data=WORK.&STAGING_DS out=work.rpl_stag_cols(keep=name type);
/* work.rpl_vchars = columns that are varchar in target AND in staging */
data casuser.&tmpds; /* temp copy with varchars casted */
length <varchar col> varchar(*); ...
set WORK.&STAGING_DS (rename=(<varchar col>=<tmp> ...));
<varchar col>=<tmp>; ... drop <tmp> ...;
run;
/* unlock + abort if any error so far - nothing destructive done yet */
%mp_abort(iftrue= (&syscc>0) ...)
/* destructive step, deliberately last before the append */
proc cas;
table.deleteRows / table={caslib="&lib",name="&ds",where="1=1"};
quit;
data &libds (append=yes) / sessref=dcsession;
set casuser.&tmpds;
run;
proc sql; drop table CASUSER.&tmpds; quit;
%end;
%else %do;
/* unlock + abort if any error so far - nothing destructive done yet */
%mp_abort(iftrue= (&syscc>0) ...)
proc sql;
delete * from &libds;
quit;
proc append base=&libds data=WORK.&STAGING_DS force nowarn; run;
%end;
%mp_lockanytable(UNLOCK, lib=&lib, ds=&ds, ...)
```
Step by step:
1. **Lock** — the target is locked via `%mp_lockanytable` (control table `&dclib..MPE_LOCKANYTABLE`). This is the only concurrency guard for the whole operation.
2. **Staging recopy** — the staging dataset is rewritten in place: the processed-timestamp column is set to the approval timestamp **only if** the target table contains the variable named by `MPE_TABLES.VAR_PROCESSED` (unlike `%bitemporal_dataloader`, there is no `PROCESSED_DTTM` fallback). The delete-flag column `_____DELETE__THIS__RECORD_____` is unconditionally dropped — per-record delete semantics do not exist in REPLACE (everything is deleted anyway), so a delete flag submitted by the user is silently discarded (a `drop` of a non-existent variable just produces a warning).
3. **Prepare the load (CAS only)** — fixed char variables cannot be appended to CAS varchar columns, so the staging table is first copied to a CASUSER temp table with every column that is varchar in the target (and present in staging) redeclared as `varchar(*)` via generated `length` / `rename` / assignment statements. This mirrors the CAS append in `%bitemporal_dataloader`.
4. **Pre-destructive abort check** — if `&syscc > 0` after all preparation, the target is unlocked and the macro aborts via `%mp_abort`. Nothing destructive has happened at this point, so a failed prep leaves the target intact (and without a stale lock).
5. **Delete all rows** — every row is removed from the target while preserving structure, indexes and metadata, deliberately as the last step before the append (to minimise the time the target sits empty). On most engines this is `proc sql; delete * from &libds;` (passed through as a `DELETE` on database libraries). CAS tables do not support SQL deletes, so on the CAS engine the table is truncated instead via the `table.deleteRows` action with `where="1=1"` (same approach as `%bitemporal_closeouts`), with the libref passed as the caslib.
6. **Append staged rows** — the entire staging table is appended. On most engines this is `proc append ... force nowarn` (`force` allows the append to proceed despite attribute mismatches, so lengths/formats may be coerced or values truncated, and columns present in only one side are handled; `nowarn` suppresses the associated warnings). On CAS the pre-cast CASUSER temp table is appended with `data &libds (append=yes) / sessref=dcsession` and then dropped.
7. **Unlock**.
## Diff screen (`LOADTARGET=NO`)
When the approver reviews a REPLACE submission, no real comparison is performed:
```sas
/* is full replace so treat all staged records as new in diff screen */
data work.outds_mod work.outds_add;
set work.&staging_ds;
output work.outds_add; /* every staged record is "NEW" */
run; /* outds_mod stays empty */
/* previous table will be considered fully deleted */
data work.outds_del;
set &lib..&ds; /* every existing record is "DELETED" */
run;
```
The approval screen therefore always shows full-table turnover: the entire current table as deleted and the entire staging table as added, with no "modified" records and no original/current comparison.
## What REPLACE does not do
Because it bypasses `%bitemporal_dataloader`, none of the following apply to REPLACE:
* **No row-level audit** — nothing is written to `AUDIT_LIBDS` / `MPE_AUDIT`. The record of the change is the approval package itself (staged CSV and the `TEMPDIFFS` CSV stored under `&mpelocapprovals/&LOAD_REF`).
* **No `MPE_DATALOADS` log entry** — the load-log insert lives inside `%bitemporal_dataloader`, so REPLACE loads do not appear in load history. (Consequently the SHOW_DIFFS timestamp lookup in `postdata.sas` finds no `MPE_DATALOADS` row for a REPLACE load and falls back to the current datetime.)
* **No PK usage** — `BUSKEY` is ignored: no uniqueness check, no dedup, no join to existing data. Staged rows are loaded exactly as submitted, duplicates included.
* **No temporal handling** — `VAR_TXFROM`/`VAR_TXTO`/`VAR_BUSFROM`/`VAR_BUSTO` and `CLOSE_VARS` are ignored. A REPLACE table should not be treated as temporal; querying it with the usual validity filters makes no sense.
* **No retained-key handling** — `RK_UNDERLYING` is ignored.
* **Minimal engine-specific handling** — unlike the temporal loaders, there is no pass-through/temp-table optimisation for OLEDB, Redshift, Postgres or Snowflake. The only engine conditional is CAS, where rows are removed via the `deleteRows` action and appended via a varchar-casting CASUSER temp table and a data-step append (SQL deletes and fixed-char-to-varchar appends are not possible on CAS).
* **Not atomic** — the delete/truncate and append are separate steps with no transaction or rollback, so a session failure between them still leaves the target empty or partially loaded (recovery is a re-approval of the same or a previous staging package). The risk is mitigated by performing all preparation first, aborting on any error before the destructive step, and executing the delete/truncate immediately before the append.
## Interaction with Row Level Security
REPLACE is incompatible with `EDIT`-scope RLS rules (a full-table wipe cannot honour row-level write restrictions); this is enforced at edit time in both directions — see [row-level-security.md](row-level-security.md#incompatibility-with-replace-load-type) and [issue #211](https://git.datacontroller.io/dc/dc/issues/211). `VIEW`-scope rules remain compatible.
+73
View File
@@ -0,0 +1,73 @@
---
name: sas
description: >
Use this skill whenever writing, modifying, or debugging SAS code in this repository — SAS
macros, sasjs services, hooks, or test files (*.sas). Triggers include: SAS macro compilation
errors, parameter parsing issues, writing sasjs tests with mp_assert / mp_assertdsobs /
mp_assertscope, macro-quoting questions (%str, %nrstr, %superq), %mp_abort usage, or unexpected
behaviour when passing free-text values (descriptions, messages) to macros. Also covers repo
conventions such as running `sasjs lint` after touching .sas files and the maximum line length
rule.
---
# SAS Development
Conventions and pitfalls for SAS code in this repository (macros in `sas/sasjs/macros/`, services
in `sas/sasjs/services/`, tests alongside them as `*.test.sas`).
## Always `%str()` free-text macro parameters
A comma inside a macro parameter value is parsed as a **parameter delimiter**. Free-text
parameters such as `desc=` (in `%mp_assert`, `%mp_assertdsobs`, `%mp_assertscope`) and `msg=` (in
`%mp_abort`) must be wrapped in `%str()` — even when they currently contain no comma, so later
edits cannot silently break the call.
Wrong — the text after the comma becomes an unexpected positional parameter and the compilation
fails (or worse, is misparsed):
```sas
%mp_assert(iftrue=(&oldrows=0),
desc=Test 2 - all staged records loaded, delete flags ignored,
outds=work.test_results
)
```
Right:
```sas
%mp_assert(iftrue=(&oldrows=0),
desc=%str(Test 2 - all staged records loaded, delete flags ignored),
outds=work.test_results
)
```
The same applies to any macro parameter that carries user-facing text: `%mp_abort(msg=%str(...))`,
`etlsource=` values containing punctuation (use `%superq()` when passing macro variables that may
contain special characters), etc.
Related quoting rules of thumb:
- `%str()` masks commas, parentheses, semicolons and quotes at compile time — sufficient for
static text like `desc=`.
- Use `%nrstr()` if the text must also mask `%` and `&` (rare in descriptions).
- Use `%superq(var)` when forwarding a macro **variable** whose value may contain special
characters (e.g. `etlsource=` in `mpe_targetloader.sas`).
## Tests must be idempotent
A test file must pass when run repeatedly (including after a run that failed partway). Conventions:
- Start the file with `%let syscc=0;` — many macros (e.g. `%mp_lockanytable(LOCK)`) abort on entry if `&syscc>0`, and any `WARNING` in a previous test bumps `syscc` to 4.
- Clean up **all** persistent state, not just the obvious one: `MPE_TABLES` registrations, `MPE_LOCKANYTABLE` lock records (a run dying between LOCK and UNLOCK leaves a stale `LOCKED` row), physical tables in `dctest` (`proc datasets ... delete`), and global macro variables created via `select ... into:` (`%symdel`).
- Make prep defensive: delete-then-insert config records (handles leftovers from an aborted run), and recreate physical tables rather than assuming they are absent.
## Repo conventions for .sas files
- Run `npx sasjs lint` from the `sas/` directory after creating or modifying any `.sas` file; the
files you touched must have zero warnings (pre-existing warnings in other files can be ignored).
- Maximum line length is 80 characters (lint-enforced) — this is why long `msg=%str(...)` values
sometimes need shortening.
- Test files are named `<thing>.test.sas` (or `<thing>.test.N.sas`) and run via
`npm run 4gl && sasjs test -t 4gl` from `sas/` — see `.agent/docs/testing.md`.
- `sas/sasjsbuild/` is generated build output — never hand-edit it; edit sources under
`sas/sasjs/` only.
+425 -9
View File
@@ -274,7 +274,81 @@ context('editor tests: ', function () {
})
})
it('9 | Info dropdown shows the applied HARDREGEX/SOFTREGEX pattern', () => {
// MPE_X_FORMULA_TEST is a small, dedicated fixture for formula testing
// (kept separate from MPE_X_NEW, which carries no formula columns at
// all) - row index 1 (0-indexed, i=1 in the mock's row generator) has
// A_COL=2, B_COL=10: HARDFORMULA (A_COL * B_COL) computes to 20,
// SOFTFORMULA (A_COL + B_COL) computes to 12.
it('9 | HARDFORMULA shows the computed value and blocks direct edits', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
getCellByHeaderAndRow(1, 'FORMULA_HARD_COL').should('have.text', '20')
getCellByHeaderAndRow(1, 'FORMULA_HARD_COL')
.dblclick({ force: true })
.then(() => {
// readOnly cells don't open an editor - nothing is focused to
// type into, so this is a no-op if the column is truly readonly.
cy.focused().type('9999{enter}')
getCellByHeaderAndRow(1, 'FORMULA_HARD_COL').should(
'have.text',
'20'
)
})
})
})
})
it('10 | SOFTFORMULA shows the computed default but accepts an override', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
getCellByHeaderAndRow(1, 'FORMULA_SOFT_COL').should('have.text', '12')
getCellByHeaderAndRow(1, 'FORMULA_SOFT_COL')
.dblclick({ force: true })
.then(() => {
cy.focused().clear().type('override{enter}')
getCellByHeaderAndRow(1, 'FORMULA_SOFT_COL').should(
'have.text',
'override'
)
})
})
})
})
it('11 | Info dropdown shows the applied formula as √x=<formula>', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
openColumnDropdown('FORMULA_HARD_COL')
cy.get('.htDropdownMenu').should(($menu) => {
expect($menu.text()).to.include('√x=A_COL * B_COL')
})
cy.get('body').click(0, 0) // close menu
openColumnDropdown('FORMULA_SOFT_COL')
cy.get('.htDropdownMenu').should(($menu) => {
expect($menu.text()).to.include('√x=A_COL + B_COL')
})
})
})
})
it('12 | Info dropdown shows the applied HARDREGEX/SOFTREGEX pattern', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_new')
clickOnEdit(() => {
@@ -301,7 +375,7 @@ context('editor tests: ', function () {
})
})
it('10 | Info dropdown shows only the applied HARDREGEX pattern when a column has both', () => {
it('13 | Info dropdown shows only the applied HARDREGEX pattern when a column has both', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_new')
clickOnEdit(() => {
@@ -321,7 +395,7 @@ context('editor tests: ', function () {
})
})
it('11 | REGEX_BOTH_COL: a HARDREGEX failure blocks submission and sets its own tooltip, not yellow', (done) => {
it('14 | REGEX_BOTH_COL: a HARDREGEX failure blocks submission and sets its own tooltip, not yellow', (done) => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_new')
clickOnEdit(() => {
@@ -360,7 +434,7 @@ context('editor tests: ', function () {
})
})
it('12 | REGEX_BOTH_COL: SOFTREGEX is ignored entirely when HARDREGEX is present', (done) => {
it('15 | REGEX_BOTH_COL: SOFTREGEX is ignored entirely when HARDREGEX is present', (done) => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_new')
clickOnEdit(() => {
@@ -393,14 +467,315 @@ context('editor tests: ', function () {
})
})
})
it("16 | Insert Row above keeps existing rows' formulas aligned with their own shifted data", () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
// Row index 2 (0-indexed): PRIMARY_KEY_FIELD=3, A_COL=3, B_COL=10 ->
// HARDFORMULA (A_COL*B_COL) = 30, SOFTFORMULA (A_COL+B_COL) = 13.
getCellByHeaderAndRow(2, 'PRIMARY_KEY_FIELD').should('have.text', '3')
insertRowViaContextMenu(2, 'Insert Row above')
// The new blank row lands at index 2 - its formula columns are
// seeded with their own row-relative formula (A_COL/B_COL are both
// still empty, so HyperFormula evaluates the arithmetic as 0).
getCellByHeaderAndRow(2, 'FORMULA_HARD_COL').should('have.text', '0')
getCellByHeaderAndRow(2, 'FORMULA_SOFT_COL').should('have.text', '0')
// The row that WAS at index 2 (PK=3) is now shifted down to index
// 3 - its formulas must recompute from ITS OWN data, not read
// whatever the engine had cached at index 3 before the insert
// (that would be PK=4's values: 40/14).
getCellByHeaderAndRow(3, 'PRIMARY_KEY_FIELD').should('have.text', '3')
getCellByHeaderAndRow(3, 'FORMULA_HARD_COL').should('have.text', '30')
getCellByHeaderAndRow(3, 'FORMULA_SOFT_COL').should('have.text', '13')
})
})
})
it("17 | Insert Row below keeps existing rows' formulas aligned with their own shifted data", () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
// Row index 3 (0-indexed): PRIMARY_KEY_FIELD=4, A_COL=4, B_COL=10 ->
// HARDFORMULA = 40, SOFTFORMULA = 14.
getCellByHeaderAndRow(3, 'PRIMARY_KEY_FIELD').should('have.text', '4')
// 'Insert Row below' on row index 2 (PK=3) inserts the new blank
// row at index 3, pushing the old index-3 row (PK=4) to index 4.
insertRowViaContextMenu(2, 'Insert Row below')
// The new blank row lands at index 3 - seeded the same way as
// above, evaluating to 0 while A_COL/B_COL are still empty.
getCellByHeaderAndRow(3, 'FORMULA_HARD_COL').should('have.text', '0')
getCellByHeaderAndRow(3, 'FORMULA_SOFT_COL').should('have.text', '0')
getCellByHeaderAndRow(4, 'PRIMARY_KEY_FIELD').should('have.text', '4')
getCellByHeaderAndRow(4, 'FORMULA_HARD_COL').should('have.text', '40')
getCellByHeaderAndRow(4, 'FORMULA_SOFT_COL').should('have.text', '14')
})
})
})
it('18 | Insert Row seeds formulas that live-recalculate once A_COL/B_COL are filled in', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
insertRowViaContextMenu(0, 'Insert Row below')
// New row at index 1 - both formula columns evaluate the seeded
// formula (=C2*D2 / =C2+D2 in spreadsheet notation) against
// still-empty A_COL/B_COL, so HyperFormula treats them as 0.
getCellByHeaderAndRow(1, 'FORMULA_HARD_COL').should('have.text', '0')
getCellByHeaderAndRow(1, 'FORMULA_SOFT_COL').should('have.text', '0')
getCellByHeaderAndRow(1, 'A_COL')
.dblclick({ force: true })
.then(() => {
cy.focused().type('5{enter}')
})
getCellByHeaderAndRow(1, 'B_COL')
.dblclick({ force: true })
.then(() => {
cy.focused().type('3{enter}')
})
// Both formula columns must recompute live from THIS row's own
// A_COL/B_COL - proving the seeded formula is genuinely wired to
// HyperFormula, not a one-time static snapshot.
getCellByHeaderAndRow(1, 'FORMULA_HARD_COL').should('have.text', '15')
getCellByHeaderAndRow(1, 'FORMULA_SOFT_COL').should('have.text', '8')
})
})
})
it('19 | Row header shows blank for unchanged, ~ for a modified row', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
getRowHeaderSymbol(0).should('have.text', ' ')
getCellByHeaderAndRow(0, 'A_COL')
.dblclick({ force: true })
.then(() => {
cy.focused().clear().type('999{enter}')
})
getRowHeaderSymbol(0).should('have.text', '~')
})
})
})
it('20 | Row header shows - for a delete-marked row and + for a newly inserted row', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
getCellByHeaderAndRow(2, 'Delete?').then(($cell) => {
setDeleteFlag($cell[0], 'Yes', () => {
getRowHeaderSymbol(2).should('have.text', '-')
insertRowViaContextMenu(0, 'Insert Row below')
// New row lands at index 1, shifting the delete-marked row
// (was index 2) down to index 3 - its own status is unaffected
// by the shift, only its position.
getRowHeaderSymbol(1).should('have.text', '+')
getRowHeaderSymbol(3).should('have.text', '-')
})
})
})
})
})
it('21 | Inserting a row does not falsely mark unrelated rows as modified or revert an existing override', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
getCellByHeaderAndRow(0, 'FORMULA_SOFT_COL')
.dblclick({ force: true })
.then(() => {
cy.focused().clear().type('999{enter}')
})
getRowHeaderSymbol(0).should('have.text', '~')
// Untouched row, well away from the insert point below.
getRowHeaderSymbol(5).should('have.text', ' ')
insertRowViaContextMenu(2, 'Insert Row below')
// The override and its '~' mark must survive the insert...
getCellByHeaderAndRow(0, 'FORMULA_SOFT_COL').should('have.text', '999')
getRowHeaderSymbol(0).should('have.text', '~')
// ...and a row that was never touched must stay unmarked, even
// though its position shifted (row 5 is now row 6).
getRowHeaderSymbol(6).should('have.text', ' ')
})
})
})
it("22 | DC.ROW_STATUS resolves to a live cell reference, reacting to this row's own edit status", () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
// _____EDIT_STATUS_____ itself is a purely client-synthesized
// column (see editStatusColumnRule.ts) - it must never render as a
// visible header, only exist as something DC.ROW_STATUS can
// point a cell reference at.
cy.get('.ht_clone_top .htCore thead tr th').should(($ths) => {
const texts = [...$ths].map((th) => th.innerText.trim())
expect(texts).not.to.include('_____EDIT_STATUS_____')
})
getCellByHeaderAndRow(0, 'ROW_STATUS_COL').should('have.text', 'U')
getCellByHeaderAndRow(0, 'A_COL')
.dblclick({ force: true })
.then(() => {
cy.focused().clear().type('999{enter}')
})
getCellByHeaderAndRow(0, 'ROW_STATUS_COL').should('have.text', 'M')
})
})
})
it('23 | DC.USER_NAME resolves to the current logged-in user', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
getCellByHeaderAndRow(0, 'USER_NAME_COL').should('have.text', 'sasdemo')
})
})
})
it("24 | DC.ORIG_VALUE echoes this row's pre-edit value, and is blank for a newly-inserted row", () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
getCellByHeaderAndRow(0, 'ORIG_VALUE_COL').should('have.text', 'orig-1')
insertRowViaContextMenu(0, 'Insert Row below')
// No dataSourceUnchanged match exists for a brand-new row, so
// DC.ORIG_VALUE falls back to blank rather than echoing anything.
getCellByHeaderAndRow(1, 'ORIG_VALUE_COL').should('have.text', '')
})
})
})
it('25 | Row header symbols stay aligned with their own row after sorting by another column', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
// Mark row 2 (PRIMARY_KEY_FIELD=3) for delete before sorting, so
// there's a distinctive symbol ('-') to track across the reorder.
getCellByHeaderAndRow(2, 'Delete?').then(($cell) => {
setDeleteFlag($cell[0], 'Yes', () => {
getRowHeaderSymbol(2).should('have.text', '-')
sortByColumn('FORMULA_SOFT_COL')
// Wherever PRIMARY_KEY_FIELD=3 lands visually after sorting,
// its OWN row header must show '-' - not whatever symbol
// previously belonged to that visual position.
cy.get('.ht_clone_top .htCore thead tr th')
.should(($ths) => {
const texts = [...$ths].map((th) => th.innerText.trim())
expect(texts).to.include('PRIMARY_KEY_FIELD')
})
.then(($ths) => {
const pkColIndex = [...$ths].findIndex(
(th) => th.innerText.trim() === 'PRIMARY_KEY_FIELD'
)
cy.get('.ht_master tbody tr').then((rows: any) => {
const sortedRowIndex = [...rows].findIndex(
(row: any) =>
row.childNodes[pkColIndex].innerText.trim() === '3'
)
getRowHeaderSymbol(sortedRowIndex).should('have.text', '-')
})
})
})
})
})
})
})
// CHANGE_SUMMARY_COL combines all three DC.* variables in one formula:
// IF( DC.ROW_STATUS ="U","unedited", DC.USER_NAME &" changed from "& DC.ORIG_VALUE ).
// DC.USER_NAME/DC.ORIG_VALUE are frozen literals baked in at load time,
// while DC.ROW_STATUS is a live cell reference - so editing the row
// doesn't recompute the "changed from" text, it just flips which
// already-computed branch IF() reveals.
it("26 | Combining DC.ROW_STATUS/DC.USER_NAME/DC.ORIG_VALUE in one formula reveals the frozen 'changed from' text once the row is edited", () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
getCellByHeaderAndRow(0, 'CHANGE_SUMMARY_COL').should(
'have.text',
'unedited'
)
getCellByHeaderAndRow(0, 'A_COL')
.dblclick({ force: true })
.then(() => {
cy.focused().clear().type('999{enter}')
})
getCellByHeaderAndRow(0, 'CHANGE_SUMMARY_COL').should(
'have.text',
'sasdemo changed from orig-1'
)
})
})
})
})
// Handsontable virtualizes columns — with 17 columns on MPE_X_NEW, only
// Handsontable virtualizes columns — with 18 columns on MPE_X_NEW, only
// the ones in the current viewport actually exist in the DOM. Scroll all
// the way right so REGEX_HARD_COL/REGEX_SOFT_COL (the last two) render at
// all. Same technique already used in excel.cy.ts. Must be called (and
// re-settle) before any header/body query below, since scrolling replaces
// the previously-rendered column nodes.
// the way right so REGEX_HARD_COL/REGEX_SOFT_COL/REGEX_BOTH_COL (the last
// three) render at all. Same technique already used in excel.cy.ts. Must
// be called (and re-settle) before any header/body query below, since
// scrolling replaces the previously-rendered column nodes.
const scrollGridRight = () => {
return cy
.get('#hotTable')
@@ -409,6 +784,19 @@ const scrollGridRight = () => {
.scrollTo('right')
}
// Clicks a column header's sort indicator (Handsontable's multiColumnSorting
// plugin decorates every sortable header with a `.columnSorting` element -
// HEADER_SORT_CLASS in Handsontable's own source) to sort ascending by that
// column. force: true for the same reason as openColumnDropdown - an
// overlay clone layer can sit over the real target.
const sortByColumn = (headerText: string) => {
cy.get('.ht_clone_top .htCore thead tr th')
.filter((_, th) => Cypress.$(th).text().includes(headerText))
.last()
.find('.columnSorting')
.click({ force: true })
}
// Opens a column header's dropdown menu to reach its `info` item, which
// has a custom renderer showing NAME/LABEL/TYPE/LENGTH/FORMAT and, when the
// column has a HARDREGEX/SOFTREGEX rule, the applied pattern. Clicking the
@@ -460,6 +848,34 @@ const getCellByHeaderAndRow = (rowIndex: number, headerText: string) => {
})
}
// Reads the row-header gutter's text for the given row - this is the
// left-hand "select entire row" bar (rowHeaders callback in
// editor.component.ts), doubling as the edit-status indicator
// (+/-/~/blank). .ht_clone_left mirrors .ht_clone_top's frozen-clone role,
// just for the row axis instead of the column axis.
const getRowHeaderSymbol = (rowIndex: number) => {
return cy
.get('.ht_clone_left .htCore tbody tr')
.eq(rowIndex)
.find('th .rowHeader')
}
// Right-clicks the given row's PRIMARY_KEY_FIELD cell to open the row's
// context menu, then clicks the given item. 'Insert Row above'/'below' are
// hidden when hotTable.readOnly is true, so this only works after
// clickOnEdit(). force: true - same reasoning as toggleColumnLabelsFromContextMenu
// in viewer-labels.cy.ts, the right-clicked cell can sit under an overlay
// clone layer that fails Cypress's actionability check.
const insertRowViaContextMenu = (
rowIndex: number,
menuItemText: 'Insert Row above' | 'Insert Row below'
) => {
getCellByHeaderAndRow(rowIndex, 'PRIMARY_KEY_FIELD').rightclick({
force: true
})
cy.get('.htContextMenu').contains(menuItemText).click()
}
// Opens the _____DELETE__THIS__RECORD_____ dropdown editor on the given cell
// and picks the given choice ('Yes'/'No') — same technique as
// coltype-delete-record.cy.ts (arrow -> autocompleteEditor choice list).
+229 -60
View File
@@ -39,6 +39,9 @@ import { EventService } from '../services/event.service'
import { HelperService } from '../services/helper.service'
import { LoggerService } from '../services/logger.service'
import { SasService } from '../services/sas.service'
import { UserService } from '../shared/user.service'
import { applyFormulaRules } from '../shared/dc-validator/utils/applyFormulaRules'
import { parseFormulaRule } from '../shared/dc-validator/utils/parseFormulaRule'
import { DcValidator } from '../shared/dc-validator/dc-validator'
import { Col } from '../shared/dc-validator/models/col.model'
import { DcValidation } from '../shared/dc-validator/models/dc-validation.model'
@@ -46,6 +49,8 @@ import { DQRule } from '../shared/dc-validator/models/dq-rules.model'
import { getHotDataSchema } from '../shared/dc-validator/utils/getHotDataSchema'
import { excelRound } from '../shared/dc-validator/utils/excelRound'
import { isEmpty } from '../shared/dc-validator/utils/isEmpty'
import { hasFormulaRules } from '../shared/dc-validator/utils/hasFormulaRules'
import { HyperFormula } from 'hyperformula'
import { parseLabelsParam } from '../shared/utils/parse-labels-param'
import { getDisplayColHeaders } from '../shared/utils/display-col-headers'
import { buildColInfoHtml } from '../shared/utils/col-info-html'
@@ -58,6 +63,9 @@ import {
import { EditRecordInputFocusedEvent } from './models/edit-record/edit-record-events'
import { EditorRestrictions } from './models/editor-restrictions.model'
import { parseTableColumns } from './utils/grid.utils'
import { classifyRow } from './utils/classifyRow'
import { getEditStatusSymbol } from './utils/getEditStatusSymbol'
import { EDIT_STATUS_COLUMN_NAME } from '../shared/dc-validator/utils/editStatusColumnRule'
import {
errorRenderer,
noSpinnerRenderer,
@@ -486,7 +494,8 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
private cdf: ChangeDetectorRef,
private spreadsheetService: SpreadsheetService,
private vaMessaging: VaMessagingService,
private vaFilter: VaFilterService
private vaFilter: VaFilterService,
private userService: UserService
) {
this.parseRestrictions()
this.setRestrictions()
@@ -760,6 +769,10 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
if (!itemObject['_____DELETE__THIS__RECORD_____'])
itemObject['_____DELETE__THIS__RECORD_____'] = 'No'
// EDIT_STATUS is never part of the uploaded file (client-only, see
// editStatusColumnRule.ts) - default it the same as a fresh load.
itemObject[EDIT_STATUS_COLUMN_NAME] = 'U'
previewDatasource.push(itemObject)
})
@@ -1301,18 +1314,20 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
setTimeout(() => {
const hot = this.hotInstance
const newIndex = this.dataSource.length
// Create a new empty row object with proper structure
const newRow = this.createEmptyRow()
// Add the new row to the data source
this.dataSource.push(newRow)
// Update the hot table with the new data
hot.updateSettings({ data: this.dataSource }, false)
// hot.alter() (rather than splicing dataSource and calling
// updateSettings) is what triggers the formulas plugin's own
// insert-row hooks - without it, HyperFormula's sheet never learns
// about the new row and HARDFORMULA/SOFTFORMULA columns silently
// fall out of sync with the grid's own data.
hot.alter('insert_row_below', newIndex - 1, 1)
this.dataSource[newIndex].noLinkOption = true
this.seedFormulaValuesForRow(newIndex)
this.updateEditStatusForRow(newIndex)
// Select the newly added row
hot.selectCell(this.dataSource.length - 1, 0)
hot.selectCell(newIndex, 0)
hot.render()
this.addingNewRow = false
@@ -1321,39 +1336,102 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
})
}
/**
* Creates a new empty row object with proper structure.
* Columns with NOTNULL DQ rules are pre-populated with their RULE_VALUE.
*/
private createEmptyRow(): any {
const newRow: any = {}
this.cellValidation.forEach((rule: any) => {
const dataKey = rule.data
newRow[dataKey] = this.hotDataSchema.hasOwnProperty(dataKey)
? this.hotDataSchema[dataKey]
: ''
})
newRow['noLinkOption'] = true
return newRow
}
/**
* Inserts a new row at the specified position and updates the table
*/
private insertRowAtPosition(targetRow: number): void {
const newRow = this.createEmptyRow()
// Insert the new row at the target position
this.dataSource.splice(targetRow, 0, newRow)
// Update the hot table
const hot = this.hotInstance
hot.updateSettings({ data: this.dataSource }, false)
hot.render()
// See addRow()'s comment - hot.alter() is required for HyperFormula to
// learn about the new row. beforeCreateRow only allows this while
// addingNewRow is set (see its own hook registration).
this.addingNewRow = true
hot.alter('insert_row_above', targetRow, 1)
this.addingNewRow = false
// alter() builds the new row from the grid's configured dataSchema
// (NOTNULL defaults included), which doesn't know about noLinkOption -
// set it directly on the row alter() just spliced into dataSource.
this.dataSource[targetRow].noLinkOption = true
this.seedFormulaValuesForRow(targetRow)
this.updateEditStatusForRow(targetRow)
this.reSetCellValidationValues()
}
/**
* Seeds HARDFORMULA/SOFTFORMULA columns on a newly-inserted row with
* their computed formula string, the same way applyFormulaRules seeds
* every row on initial load - a new row otherwise sits with a blank
* formula cell until the next full reload. DC.ORIG_VALUE naturally
* resolves to blank for this row (applyFormulaRules can't find a
* dataSourceUnchanged match for a row that never existed before).
*
* Written via hot.setDataAtRowProp (not a bare dataSource mutation, which
* is what applyFormulaRules itself does) so HyperFormula's engine
* actually learns about the new cell content - the same class of fix as
* hot.alter() above, just for a single cell instead of a row structure
* change.
*/
private seedFormulaValuesForRow(rowIndex: number): void {
const dqRules = this.dcValidator?.getDqDetails()
if (!dqRules || !hasFormulaRules(dqRules)) return
const hot = this.hotInstance
const userName = this.userService.user?.username ?? ''
const formulaRules = dqRules.filter(
(rule) =>
rule.RULE_TYPE === 'HARDFORMULA' || rule.RULE_TYPE === 'SOFTFORMULA'
)
// Computes only THIS row's formula string (parseFormulaRule directly,
// not applyFormulaRules on the full dataSource) - applyFormulaRules
// would overwrite every other row's formula column too, silently
// reverting any SOFTFORMULA override a user already typed elsewhere
// and rewriting every shifted row's cell-reference string (its
// spreadsheet position changed), both of which make classifyRow see a
// false diff against dataSourceUnchanged and misreport those untouched
// rows as modified.
for (const rule of formulaRules) {
const value = parseFormulaRule(rule.RULE_VALUE, {
columnNames: this.headerColumns,
rowIndex,
userName,
origValue: undefined
})
hot.setDataAtRowProp(rowIndex, rule.BASE_COL, value)
}
}
/**
* Recomputes this row's classification (M/A/D/U) and writes it into the
* EDIT_STATUS cell - the same classification the row-header symbol shows,
* but written into a real Handsontable cell so DC.ROW_STATUS-based
* formulas can actually react to it. Written via hot.setDataAtRowProp
* with a distinct source string (not a bare dataSource mutation) so
* HyperFormula is notified and dependents recalculate - the source string
* also lets the afterChange hook below recognise and ignore its own
* writes, avoiding infinite recursion.
*/
private updateEditStatusForRow(rowIndex: number): void {
const dataRow = this.dataSource[rowIndex]
if (!dataRow) return
const status = classifyRow(
dataRow,
this.dataSourceUnchanged ?? this.dataSource,
this.headerPks
)
this.hotInstance.setDataAtRowProp(
rowIndex,
EDIT_STATUS_COLUMN_NAME,
status,
'editStatus'
)
}
public cancelSubmit() {
this.dataSource = this.helperService.deepClone(this.dataSourceBeforeSubmit)
this.dataSourceBeforeSubmit = []
@@ -1402,31 +1480,23 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
for (let i = 0; i < this.dataSource.length; i++) {
const dataRow = this.helperService.deepClone(this.dataSource[i])
if (dataRow._____DELETE__THIS__RECORD_____ === 'Yes') {
this.dataModified.push(dataRow)
rowsDeleted++
} else {
const dataRowUnchanged = this.dataSourceUnchanged.find((row: any) => {
for (const pkCol of this.headerPks) {
if (row[pkCol] !== dataRow[pkCol]) {
return false
}
}
return true
})
if (dataRowUnchanged) {
if (JSON.stringify(dataRow) !== JSON.stringify(dataRowUnchanged)) {
this.dataModified.push(dataRow)
this.modifedRowsIndexes.push(i)
rowsUpdated++
}
} else {
switch (classifyRow(dataRow, this.dataSourceUnchanged, this.headerPks)) {
case 'D':
this.dataModified.push(dataRow)
rowsDeleted++
break
case 'A':
this.dataModified.push(dataRow)
this.modifedRowsIndexes.push(i)
rowsAdded++
}
break
case 'M':
this.dataModified.push(dataRow)
this.modifedRowsIndexes.push(i)
rowsUpdated++
break
case 'U':
break
}
}
@@ -1819,7 +1889,11 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
while (this.dataSource.length > 0) {
const lastRow = this.dataSource[this.dataSource.length - 1]
const isEmpty = Object.keys(lastRow).every((key) => {
if (key === '_____DELETE__THIS__RECORD_____') return true
if (
key === '_____DELETE__THIS__RECORD_____' ||
key === EDIT_STATUS_COLUMN_NAME
)
return true
return !lastRow[key] || lastRow[key] === ''
})
@@ -1906,6 +1980,10 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
delete row['_____DELETE__THIS__RECORD_____']
row['_____DELETE__THIS__RECORD_____'] = deleteColValue
// EDIT_STATUS is client-only (see editStatusColumnRule.ts) - it must
// never reach the backend.
delete row[EDIT_STATUS_COLUMN_NAME]
// If cell is numeric and value is dot `.` we change it to `null`
Object.keys(row).map((key: string) => {
const colRule = this.dcValidator?.getRule(key)
@@ -3125,6 +3203,13 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
this.headerArray = this.headerColumns.slice(1)
// EDIT_STATUS is never part of COLHEADERS (it's client-synthesized, see
// editStatusColumnRule.ts) - appended after headerArray is derived so it
// stays out of whatever headerArray drives, but before headerColumns is
// used below for cell-reference math (applyFormulaRules/DcValidator's
// rules must stay index-aligned with headerColumns).
this.headerColumns.push(EDIT_STATUS_COLUMN_NAME)
if (response.data.sasparams[0].DTVARS !== '') {
this.dateHeaders = response.data.sasparams[0].DTVARS.split(' ')
}
@@ -3146,12 +3231,43 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
response.data.dqdata
)
// Only turn on Handsontable's formulas plugin (HyperFormula engine) for
// tables that actually use HARDFORMULA/SOFTFORMULA - it's not free to
// run for every grid. gpl-v3: this app embeds HyperFormula under its
// GPLv3 free-tier terms, not a purchased commercial key.
this.hotTable.formulas = hasFormulaRules(response.data.dqrules)
? { engine: HyperFormula, licenseKey: 'gpl-v3' }
: false
this.cellValidation = this.dcValidator.getRules()
// to take datasource
this.dataSource = response.data.sasdata
this.$dataFormats = response.data.$sasdata
// Seed every row's EDIT_STATUS to 'U' before anything (HyperFormula
// included) ever sees this data - nothing has been edited yet, and this
// is also the baseline dataSourceUnchanged will later be cloned from, so
// future diffs never see a spurious EDIT_STATUS mismatch (see
// classifyRow's withoutEditStatus for why that would matter).
for (const row of this.dataSource) {
row[EDIT_STATUS_COLUMN_NAME] = 'U'
}
// Seed HARDFORMULA/SOFTFORMULA columns with their computed formula
// string per row. dataSourceUnchanged isn't populated yet this early in
// a fresh load (only the excel-preview/add-row flows set it) - at this
// point, before any edits exist, dataSource IS the unchanged baseline,
// so DC.ORIG_VALUE resolves correctly either way.
applyFormulaRules(
this.dataSource,
response.data.dqrules,
this.headerColumns,
this.dataSourceUnchanged ?? this.dataSource,
this.headerPks,
this.userService.user?.username ?? ''
)
// Note: this.headerColumns and this.columnHeader contains same data
// need to resolve redundancy
@@ -3204,9 +3320,35 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
filters: false,
manualRowResize: true,
viewportRowRenderingOffset: 100,
// show a bar on the left to enable users to select an entire row
// Doubles as the edit-status indicator: +/-/~ for
// added/deleted/modified rows. Handsontable re-invokes this on
// every render, so it stays live as rows are edited/added/deleted
// without any extra wiring. Falls back to a plain space (not an
// empty string) for unchanged rows and before dataSource is
// populated, keeping the original row-selection bar's click target
// intact - a space and '' render identically, so this changes
// nothing visually.
//
// `index` is the VISUAL row - once multiColumnSorting reorders the
// grid, that no longer matches dataSource's physical order, so it
// must be translated via toPhysicalRow() before indexing into
// dataSource. Without this, a sorted grid shows every row's symbol
// shifted to the wrong row.
rowHeaders: (index: number) => {
return ' '
const physicalRow = hot.toPhysicalRow(index)
const dataRow =
physicalRow === null ? undefined : this.dataSource[physicalRow]
if (!dataRow) return ' '
return (
getEditStatusSymbol(
classifyRow(
dataRow,
this.dataSourceUnchanged ?? this.dataSource,
this.headerPks
)
) || ' '
)
},
rowHeaderWidth: 15,
rowHeights: 24,
@@ -3256,12 +3398,15 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
const { hardRegexValue, softRegexValue } =
this.dcValidator?.getRegexRuleValues(colName) || {}
const formulaValue =
this.dcValidator?.getFormulaRuleValue(colName)
textInfo = buildColInfoHtml(
colName,
colInfo,
hardRegexValue,
softRegexValue
softRegexValue,
formulaValue
)
}
@@ -3475,6 +3620,30 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
}
})
// Keeps each edited row's EDIT_STATUS cell in sync live, so
// DC.ROW_STATUS-based formulas recalculate immediately - not just on
// insert/delete (already handled at their own call sites) but on any
// direct edit, paste or autofill too. 'loadData' fires on every
// hot.updateSettings({data: ...}) call this component already makes
// (initial load, cancelSubmit, ...) where dataSource is already
// consistent, so it's skipped; 'editStatus' is this same hook's own
// writes (via updateEditStatusForRow), skipped to avoid recursion.
hot.addHook('afterChange', (changes: any[], source: any) => {
if (!changes || source === 'loadData' || source === 'editStatus') return
const changedRows = new Set<number>()
for (const change of changes) {
if (!change) continue
const [row, prop] = change
if (prop === EDIT_STATUS_COLUMN_NAME) continue
changedRows.add(row)
}
for (const row of changedRows) this.updateEditStatusForRow(row)
})
hot.addHook('afterPaste', async (_data: any, coords: any) => {
// In read-only mode HOT discards the paste itself, so nothing to validate.
if (this.hotTable.readOnly) return
@@ -0,0 +1,77 @@
import { classifyRow } from './classifyRow'
import { EDIT_STATUS_COLUMN_NAME } from '../../shared/dc-validator/utils/editStatusColumnRule'
describe('classifyRow', () => {
const headerPks = ['PRIMARY_KEY_FIELD']
it('is D when the row is marked for delete, regardless of other field changes', () => {
const dataRow = {
PRIMARY_KEY_FIELD: 1,
SOME_CHAR: 'changed',
_____DELETE__THIS__RECORD_____: 'Yes'
}
const dataSourceUnchanged = [
{ PRIMARY_KEY_FIELD: 1, SOME_CHAR: 'original' }
]
expect(classifyRow(dataRow, dataSourceUnchanged, headerPks)).toEqual('D')
})
it('is A when no row in dataSourceUnchanged matches the primary key(s)', () => {
const dataRow = { PRIMARY_KEY_FIELD: 99, SOME_CHAR: 'new row' }
const dataSourceUnchanged = [
{ PRIMARY_KEY_FIELD: 1, SOME_CHAR: 'original' }
]
expect(classifyRow(dataRow, dataSourceUnchanged, headerPks)).toEqual('A')
})
it('is M when a matching row exists but a field differs', () => {
const dataRow = { PRIMARY_KEY_FIELD: 1, SOME_CHAR: 'changed' }
const dataSourceUnchanged = [
{ PRIMARY_KEY_FIELD: 1, SOME_CHAR: 'original' }
]
expect(classifyRow(dataRow, dataSourceUnchanged, headerPks)).toEqual('M')
})
it('is U when a matching row exists and nothing differs', () => {
const dataRow = { PRIMARY_KEY_FIELD: 1, SOME_CHAR: 'original' }
const dataSourceUnchanged = [
{ PRIMARY_KEY_FIELD: 1, SOME_CHAR: 'original' }
]
expect(classifyRow(dataRow, dataSourceUnchanged, headerPks)).toEqual('U')
})
it('matches on all primary key columns for a composite key', () => {
const headerPksComposite = ['LIB', 'ID']
const dataRow = { LIB: 'WORK', ID: 1, SOME_CHAR: 'original' }
const dataSourceUnchanged = [
{ LIB: 'OTHER', ID: 1, SOME_CHAR: 'original' },
{ LIB: 'WORK', ID: 1, SOME_CHAR: 'original' }
]
// Same ID=1 exists under a different LIB - must not match that one.
expect(
classifyRow(dataRow, dataSourceUnchanged, headerPksComposite)
).toEqual('U')
})
it('ignores its own EDIT_STATUS column value when diffing, so writing the classification back never self-perpetuates a stale M', () => {
const dataRow = {
PRIMARY_KEY_FIELD: 1,
SOME_CHAR: 'original',
[EDIT_STATUS_COLUMN_NAME]: 'M'
}
const dataSourceUnchanged = [
{
PRIMARY_KEY_FIELD: 1,
SOME_CHAR: 'original',
[EDIT_STATUS_COLUMN_NAME]: 'U'
}
]
expect(classifyRow(dataRow, dataSourceUnchanged, headerPks)).toEqual('U')
})
})
@@ -0,0 +1,44 @@
import { EDIT_STATUS_COLUMN_NAME } from '../../shared/dc-validator/utils/editStatusColumnRule'
export type RowEditStatus = 'M' | 'A' | 'D' | 'U'
// EDIT_STATUS holds this very function's own prior output - comparing it
// like any other field would self-perpetuate: once a row is written as 'M',
// its EDIT_STATUS would forever differ from the unchanged snapshot's 'U'
// even after the real edit is reverted, permanently stuck on 'M'.
const withoutEditStatus = (row: any): any => {
const { [EDIT_STATUS_COLUMN_NAME]: _editStatus, ...rest } = row
return rest
}
/**
* Classifies a single row as Modified/Added/Deleted/Unchanged against the
* pre-edit snapshot (`dataSourceUnchanged`), PK-matched via `headerPks`.
*
* Extracted from editor.component.ts's getRowsSubmittingCount(), which used
* this same logic inline, only at submit time. Kept here as a pure function
* so it can run live (e.g. on every afterChange, for the EDIT_STATUS column)
* without duplicating the diffing rules.
*/
export const classifyRow = (
dataRow: any,
dataSourceUnchanged: any[],
headerPks: string[]
): RowEditStatus => {
if (dataRow._____DELETE__THIS__RECORD_____ === 'Yes') return 'D'
const dataRowUnchanged = dataSourceUnchanged.find((row: any) => {
for (const pkCol of headerPks) {
if (row[pkCol] !== dataRow[pkCol]) return false
}
return true
})
if (!dataRowUnchanged) return 'A'
return JSON.stringify(withoutEditStatus(dataRow)) !==
JSON.stringify(withoutEditStatus(dataRowUnchanged))
? 'M'
: 'U'
}
@@ -0,0 +1,19 @@
import { getEditStatusSymbol } from './getEditStatusSymbol'
describe('getEditStatusSymbol', () => {
it("returns '+' for Added", () => {
expect(getEditStatusSymbol('A')).toEqual('+')
})
it("returns '-' for Deleted", () => {
expect(getEditStatusSymbol('D')).toEqual('-')
})
it("returns '~' for Modified", () => {
expect(getEditStatusSymbol('M')).toEqual('~')
})
it("returns '' for Unchanged", () => {
expect(getEditStatusSymbol('U')).toEqual('')
})
})
@@ -0,0 +1,20 @@
import { RowEditStatus } from './classifyRow'
/**
* Maps a row's edit status to the symbol shown in the row-header gutter
* (see editor.component.ts's rowHeaders callback). Unchanged renders as an
* empty string rather than a letter, since it's the common case and would
* otherwise clutter every untouched row.
*/
export const getEditStatusSymbol = (status: RowEditStatus): string => {
switch (status) {
case 'A':
return '+'
case 'D':
return '-'
case 'M':
return '~'
case 'U':
return ''
}
}
@@ -19,6 +19,7 @@ import { getNotNullDefault } from './utils/getNotNullDefault'
import { mergeColsRules } from './utils/mergeColsRules'
import { parseColTypeRow } from './utils/parseColTypeRow'
import { DELETE_RECORD_COLUMN_RULE } from './utils/deleteRecordColumnRule'
import { EDIT_STATUS_COLUMN_RULE } from './utils/editStatusColumnRule'
import { dqValidate } from './validations/dq-validation'
import {
datetimeValidator,
@@ -84,6 +85,13 @@ export class DcValidator {
this.rules = mergeColsRules(cols, this.rules, $dataFormats)
this.rules = applyNumericFormats(this.rules)
this.rules = mapIntlCellTypes(this.rules)
// EDIT_STATUS is appended last, always hidden - it's a purely
// client-synthesized column (never in COLHEADERS) that gives
// DC.ROW_STATUS a real cell to reference. See its own doc comment.
this.rules.push({ ...EDIT_STATUS_COLUMN_RULE })
this.hiddenColumns.push(this.rules.length - 1)
this.dqrules = dqRules
this.dqdata = dqData
this.primaryKeys = sasparams.PK.split(' ')
@@ -229,6 +237,25 @@ export class DcValidator {
}
}
/**
* Returns the RULE_VALUE of a HARDFORMULA/SOFTFORMULA rule on the given
* column, for display in the column-header info dropdown. A column only
* ever carries one of the two in practice (readonly-computed vs.
* overridable-default aren't a meaningful combination), so unlike
* getRegexRuleValues there's no need to distinguish which one this is.
*
* @param col column name
*/
getFormulaRuleValue(col: string): string | undefined {
const formulaRule = this.dqrules.find(
(rule: DQRule) =>
rule.BASE_COL === col &&
(rule.RULE_TYPE === 'HARDFORMULA' || rule.RULE_TYPE === 'SOFTFORMULA')
)
return formulaRule?.RULE_VALUE
}
/**
* Retrieves dropdown source for given dc validation rule
* The values comes from MPE_SELECTBOX table
@@ -439,6 +466,14 @@ export class DcValidator {
this.rules[i].readOnly = true
}
// HARDFORMULA: same read-only treatment as an explicit READONLY rule
// - its value is always computed (see applyFormulaRules), never
// user-editable. SOFTFORMULA deliberately does not set this: its
// computed value is only a default, the user can overwrite it.
if (this.hasDqRules(ruleColName, ['HARDFORMULA'])) {
this.rules[i].readOnly = true
}
// HIDDEN: hide column in HOT but keep its data (still submitted via hot.getData())
if (this.hasDqRules(ruleColName, ['HIDDEN'])) {
this.hiddenColumns.push(i)
@@ -20,3 +20,5 @@ export type DQRuleTypes =
| 'NUMBER_FORMAT'
| 'HARDREGEX'
| 'SOFTREGEX'
| 'HARDFORMULA'
| 'SOFTFORMULA'
@@ -3,6 +3,7 @@ import { DQData, SASParam } from 'src/app/models/TableData'
import { DcValidator } from '../dc-validator'
import { Col } from '../models/col.model'
import { DQRule } from '../models/dq-rules.model'
import { EDIT_STATUS_COLUMN_NAME } from '../utils/editStatusColumnRule'
describe('DC Validator', () => {
it('should create an instance of validator with correct rules', () => {
@@ -24,10 +25,10 @@ describe('DC Validator', () => {
expect(cols[0].TYPE).toEqual('char')
// Get all — one rule per cols[] entry, plus the injected
// DELETE_RECORD_COLUMN_RULE (never present in cols[], see its own
// doc comment)
// DELETE_RECORD_COLUMN_RULE and EDIT_STATUS_COLUMN_RULE (neither is ever
// present in cols[], see their own doc comments)
const validationRules = dcValidator.getRules()
expect(validationRules).toHaveSize(example_cols.length + 1)
expect(validationRules).toHaveSize(example_cols.length + 2)
// Get col with notnull validation
const someNumRule = dcValidator.getRule('SOME_NUM')
@@ -75,6 +76,17 @@ describe('DC Validator', () => {
)
expect(deleteRecordRule?.type).toEqual('dropdown')
expect(deleteRecordRule?.source).toEqual(['No', 'Yes'])
// EDIT_STATUS is likewise never a cols[] entry - it's a purely
// client-synthesized column (see editStatusColumnRule.ts) so
// DC.ROW_STATUS has a real cell to reference. Always the last rule,
// always read-only, always hidden.
const editStatusRule = dcValidator.getRule(EDIT_STATUS_COLUMN_NAME)
expect(editStatusRule?.readOnly).toBeTrue()
expect(validationRules[validationRules.length - 1].data).toEqual(
EDIT_STATUS_COLUMN_NAME
)
expect(dcValidator.getHiddenColumns()).toContain(validationRules.length - 1)
})
it('should create an instance of validator and execute its functions', () => {
@@ -305,9 +317,10 @@ describe('DC Validator', () => {
example_dqData
)
expect(dcValidator.getRules().map((rule) => rule.data)).toEqual(
example_sasparams.COLHEADERS.split(',')
)
expect(dcValidator.getRules().map((rule) => rule.data)).toEqual([
...example_sasparams.COLHEADERS.split(','),
EDIT_STATUS_COLUMN_NAME
])
})
it("6 | keeps rules aligned with COLHEADERS when the PK is not the source table's first column", () => {
@@ -344,9 +357,10 @@ describe('DC Validator', () => {
[]
)
expect(dcValidator.getRules().map((rule) => rule.data)).toEqual(
sasparams.COLHEADERS.split(',')
)
expect(dcValidator.getRules().map((rule) => rule.data)).toEqual([
...sasparams.COLHEADERS.split(','),
EDIT_STATUS_COLUMN_NAME
])
})
it('7 | falls back to a text rule rather than dropping a column with an unparseable COLTYPE', () => {
@@ -373,9 +387,10 @@ describe('DC Validator', () => {
[]
)
expect(dcValidator.getRules().map((rule) => rule.data)).toEqual(
sasparams.COLHEADERS.split(',')
)
expect(dcValidator.getRules().map((rule) => rule.data)).toEqual([
...sasparams.COLHEADERS.split(','),
EDIT_STATUS_COLUMN_NAME
])
})
it('8 | does not un-hide an unrelated column when a CLS EDIT column was never hidden', () => {
@@ -872,6 +887,53 @@ describe('DC Validator', () => {
})
})
})
describe('16 | getFormulaRuleValue (column-header info display)', () => {
const buildValidator = (dqRules: DQRule[]) =>
new DcValidator(
example_sasparams,
example_dataformats,
example_cols,
dqRules,
example_dqData
)
it('returns the RULE_VALUE for a HARDFORMULA rule', () => {
const dcValidator = buildValidator([
{
BASE_COL: 'SOME_CHAR_ANY',
RULE_TYPE: 'HARDFORMULA',
RULE_VALUE: 'A_COL * B_COL',
X: 0
}
])
expect(dcValidator.getFormulaRuleValue('SOME_CHAR_ANY')).toEqual(
'A_COL * B_COL'
)
})
it('returns the RULE_VALUE for a SOFTFORMULA rule', () => {
const dcValidator = buildValidator([
{
BASE_COL: 'SOME_CHAR_ANY',
RULE_TYPE: 'SOFTFORMULA',
RULE_VALUE: 'A_COL + B_COL',
X: 0
}
])
expect(dcValidator.getFormulaRuleValue('SOME_CHAR_ANY')).toEqual(
'A_COL + B_COL'
)
})
it('returns undefined for a column with no formula rule', () => {
const dcValidator = buildValidator([])
expect(dcValidator.getFormulaRuleValue('SOME_CHAR_ANY')).toBeUndefined()
})
})
})
/** Minimal cols[] entry — only the fields rule ordering depends on. */
@@ -0,0 +1,116 @@
import { DQRule } from '../models/dq-rules.model'
import { applyFormulaRules } from './applyFormulaRules'
const rule = (overrides: Partial<DQRule>): DQRule => ({
BASE_COL: 'REVENUE',
RULE_TYPE: 'HARDFORMULA',
RULE_VALUE: 'PRICE * VOLUME',
X: 0,
...overrides
})
const columnNames = ['ITEM', 'PRICE', 'VOLUME', 'REVENUE']
const headerPks = ['ITEM']
describe('applyFormulaRules', () => {
it('leaves the dataset untouched when there are no formula rules', () => {
const dataSource = [{ ITEM: 'PAPER', PRICE: 4.2, VOLUME: 100, REVENUE: '' }]
const result = applyFormulaRules(
dataSource,
[],
columnNames,
dataSource,
headerPks,
'sasdemo'
)
expect(result).toEqual(dataSource)
})
it("injects the row-relative formula string for a HARDFORMULA column (issue's own PRICE*VOLUME example)", () => {
const dataSource = [
{ ITEM: 'PAPER', PRICE: 4.2, VOLUME: 100, REVENUE: '' },
{ ITEM: 'PEN', PRICE: 61.02, VOLUME: 1971, REVENUE: '' }
]
const result = applyFormulaRules(
dataSource,
[rule({})],
columnNames,
dataSource,
headerPks,
'sasdemo'
)
expect(result[0].REVENUE).toEqual('=B1 * C1')
expect(result[1].REVENUE).toEqual('=B2 * C2')
})
it('injects the formula for a SOFTFORMULA column too (only readOnly-ness differs, handled elsewhere)', () => {
const dataSource = [{ ITEM: 'PAPER', PRICE: 4.2, VOLUME: 100, REVENUE: '' }]
const result = applyFormulaRules(
dataSource,
[rule({ RULE_TYPE: 'SOFTFORMULA' })],
columnNames,
dataSource,
headerPks,
'sasdemo'
)
expect(result[0].REVENUE).toEqual('=B1 * C1')
})
it('resolves DC.ORIG_VALUE via the original (pre-edit) row, PK-matched', () => {
const dataSourceUnchanged = [
{ ITEM: 'PAPER', PRICE: 4.2, VOLUME: 100, NOTE: 'original note' }
]
const dataSource = [{ ITEM: 'PAPER', PRICE: 4.2, VOLUME: 999, NOTE: '' }]
const result = applyFormulaRules(
dataSource,
[rule({ BASE_COL: 'NOTE', RULE_VALUE: 'DC.ORIG_VALUE' })],
['ITEM', 'PRICE', 'VOLUME', 'NOTE'],
dataSourceUnchanged,
headerPks,
'sasdemo'
)
expect(result[0].NOTE).toEqual('="original note"')
})
it('resolves DC.ORIG_VALUE to an empty literal for a newly-added row with no PK match', () => {
const dataSource = [{ ITEM: 'NEWITEM', PRICE: 1, VOLUME: 1, NOTE: '' }]
const result = applyFormulaRules(
dataSource,
[rule({ BASE_COL: 'NOTE', RULE_VALUE: 'DC.ORIG_VALUE' })],
['ITEM', 'PRICE', 'VOLUME', 'NOTE'],
[],
headerPks,
'sasdemo'
)
expect(result[0].NOTE).toEqual('=""')
})
it('resolves DC.USER_NAME to the current username for every row', () => {
const dataSource = [
{ ITEM: 'PAPER', PRICE: 4.2, VOLUME: 100, NOTE: '' },
{ ITEM: 'PEN', PRICE: 61.02, VOLUME: 1971, NOTE: '' }
]
const result = applyFormulaRules(
dataSource,
[rule({ BASE_COL: 'NOTE', RULE_VALUE: 'DC.USER_NAME' })],
['ITEM', 'PRICE', 'VOLUME', 'NOTE'],
dataSource,
headerPks,
'sasdemo'
)
expect(result[0].NOTE).toEqual('="sasdemo"')
expect(result[1].NOTE).toEqual('="sasdemo"')
})
})
@@ -0,0 +1,44 @@
import { DQRule } from '../models/dq-rules.model'
import { parseFormulaRule } from './parseFormulaRule'
/**
* Injects the computed `=...` formula string into each row's data for every
* HARDFORMULA/SOFTFORMULA column, so Handsontable/HyperFormula displays the
* evaluated result. HARDFORMULA's readOnly-ness is handled separately in
* dc-validator.ts, alongside the existing READONLY rule - this only deals
* with the actual computed value.
*
* Mutates and returns `dataSource` (same in-place-mutate convention as
* applyNumericFormats).
*/
export const applyFormulaRules = (
dataSource: any[],
dqRules: DQRule[],
columnNames: string[],
dataSourceUnchanged: any[],
headerPks: string[],
userName: string
): any[] => {
const formulaRules = dqRules.filter(
(rule) =>
rule.RULE_TYPE === 'HARDFORMULA' || rule.RULE_TYPE === 'SOFTFORMULA'
)
if (formulaRules.length === 0) return dataSource
dataSource.forEach((row, rowIndex) => {
for (const formulaRule of formulaRules) {
const origRow = dataSourceUnchanged.find((candidate) =>
headerPks.every((pk) => candidate[pk] === row[pk])
)
row[formulaRule.BASE_COL] = parseFormulaRule(formulaRule.RULE_VALUE, {
columnNames,
rowIndex,
userName,
origValue: origRow ? origRow[formulaRule.BASE_COL] : undefined
})
}
})
return dataSource
}
@@ -0,0 +1,22 @@
import { DcValidation } from '../models/dc-validation.model'
/**
* EDIT_STATUS is a purely client-synthesized column (M/A/D/U from
* classifyRow), added so DC.ROW_STATUS has a real, HyperFormula-addressable
* cell to point at. It never comes from COLHEADERS, is always hidden (see
* DcValidator's constructor) and is stripped before the submit payload
* leaves the browser (see editor.component.ts's saveTable()).
*
* Heavily decorated with underscores, same convention as
* DELETE_RECORD_COLUMN_RULE - a plain 'EDIT_STATUS' could collide with a
* real column of that name in the actual dataset, which would silently
* overwrite that column's real data (see editor.component.ts's seeding
* loop) and drop it from the submit payload entirely.
*/
export const EDIT_STATUS_COLUMN_NAME = '_____EDIT_STATUS_____'
export const EDIT_STATUS_COLUMN_RULE: DcValidation = {
data: EDIT_STATUS_COLUMN_NAME,
type: 'text',
readOnly: true
}
@@ -0,0 +1,48 @@
import { DQRule } from '../models/dq-rules.model'
import { hasFormulaRules } from './hasFormulaRules'
const rule = (overrides: Partial<DQRule>): DQRule => ({
BASE_COL: 'SOME_COL',
RULE_TYPE: 'NOTNULL',
RULE_VALUE: '',
X: 0,
...overrides
})
describe('hasFormulaRules', () => {
it('is false for an empty rule set', () => {
expect(hasFormulaRules([])).toBeFalse()
})
it('is false when no rule is HARDFORMULA/SOFTFORMULA', () => {
const rules = [
rule({ RULE_TYPE: 'NOTNULL' }),
rule({ RULE_TYPE: 'HARDREGEX' }),
rule({ RULE_TYPE: 'SOFTREGEX' })
]
expect(hasFormulaRules(rules)).toBeFalse()
})
it('is true when a HARDFORMULA rule is present', () => {
const rules = [rule({ RULE_TYPE: 'HARDFORMULA' })]
expect(hasFormulaRules(rules)).toBeTrue()
})
it('is true when a SOFTFORMULA rule is present', () => {
const rules = [rule({ RULE_TYPE: 'SOFTFORMULA' })]
expect(hasFormulaRules(rules)).toBeTrue()
})
it('is true when a formula rule is mixed in with unrelated rules', () => {
const rules = [
rule({ RULE_TYPE: 'NOTNULL' }),
rule({ RULE_TYPE: 'SOFTFORMULA' }),
rule({ RULE_TYPE: 'HARDREGEX' })
]
expect(hasFormulaRules(rules)).toBeTrue()
})
})
@@ -0,0 +1,12 @@
import { DQRule } from '../models/dq-rules.model'
/**
* Whether any rule in the set is HARDFORMULA/SOFTFORMULA - used to gate
* turning on Handsontable's `formulas` plugin (HyperFormula engine) only
* when a table actually uses it, rather than for every grid.
*/
export const hasFormulaRules = (dqRules: DQRule[]): boolean =>
dqRules.some(
(rule) =>
rule.RULE_TYPE === 'HARDFORMULA' || rule.RULE_TYPE === 'SOFTFORMULA'
)
@@ -0,0 +1,96 @@
import { parseFormulaRule, FormulaVariableContext } from './parseFormulaRule'
import { EDIT_STATUS_COLUMN_NAME } from './editStatusColumnRule'
const context = (overrides: Partial<FormulaVariableContext> = {}) => ({
columnNames: ['ITEM', 'PRICE', 'VOLUME', 'REVENUE'],
rowIndex: 0,
userName: 'sasdemo',
origValue: 'sasinstaller',
...overrides
})
describe('parseFormulaRule', () => {
it("substitutes column-name variables with this row's cell references (issue's own PRICE/VOLUME example)", () => {
expect(parseFormulaRule('PRICE * VOLUME', context())).toEqual('=B1 * C1')
})
it('uses the row-relative reference for a later row', () => {
expect(
parseFormulaRule('PRICE * VOLUME', context({ rowIndex: 1 }))
).toEqual('=B2 * C2')
})
it('requires a leading/trailing blank (or string edge) around a variable - no match without it', () => {
// No spaces around '*' - PRICE/VOLUME here should NOT be recognized as
// variables (the issue's own rule: a named variable must have a
// leading and trailing blank to avoid clashing with function names).
expect(parseFormulaRule('PRICE*VOLUME', context())).toEqual('=PRICE*VOLUME')
})
it("does not substitute a variable name matched inside a function call with no surrounding blanks (the issue's MATCH() clash example)", () => {
// MATCH is not one of our column names here, but this proves adjacency
// to parens alone doesn't trigger substitution - only literal
// surrounding whitespace (or string start/end) does.
expect(parseFormulaRule('MATCH(PRICE)', context())).toEqual('=MATCH(PRICE)')
})
it("leaves variable occurrences inside quoted strings untouched (the issue's own ITEM example)", () => {
expect(parseFormulaRule('ITEM & " string ITEM "', context())).toEqual(
'=A1 & " string ITEM "'
)
})
it('substitutes DC.USER_NAME with the quoted, literal current username', () => {
expect(
parseFormulaRule('DC.USER_NAME', context({ userName: 'sasdemo' }))
).toEqual('=\"sasdemo\"')
})
it('substitutes DC.ORIG_VALUE with the quoted, literal original cell value', () => {
expect(
parseFormulaRule('DC.ORIG_VALUE', context({ origValue: 'sasinstaller' }))
).toEqual('=\"sasinstaller\"')
})
it('does not substitute DC.USER_NAME/DC.ORIG_VALUE inside quoted strings either', () => {
expect(parseFormulaRule('"DC.USER_NAME"', context())).toEqual(
'=\"DC.USER_NAME\"'
)
})
it('substitutes DC.ROW_STATUS with a cell reference to the EDIT_STATUS column, not a quoted literal', () => {
expect(
parseFormulaRule(
'IF( DC.ROW_STATUS ="U","unchanged","changed")',
context({
columnNames: ['ITEM', 'PRICE', 'VOLUME', EDIT_STATUS_COLUMN_NAME],
rowIndex: 0
})
)
).toEqual('=IF( D1 ="U","unchanged","changed")')
})
it('uses the row-relative reference for DC.ROW_STATUS on a later row', () => {
expect(
parseFormulaRule(
'DC.ROW_STATUS',
context({
columnNames: ['ITEM', EDIT_STATUS_COLUMN_NAME],
rowIndex: 4
})
)
).toEqual('=B5')
})
it('leaves DC.ROW_STATUS untouched when the EDIT_STATUS column is not present', () => {
expect(parseFormulaRule('DC.ROW_STATUS', context())).toEqual(
'=DC.ROW_STATUS'
)
})
it('does not substitute DC.ROW_STATUS inside quoted strings either', () => {
expect(parseFormulaRule('"DC.ROW_STATUS"', context())).toEqual(
'=\"DC.ROW_STATUS\"'
)
})
})
@@ -0,0 +1,94 @@
import Handsontable from 'handsontable'
import { EDIT_STATUS_COLUMN_NAME } from './editStatusColumnRule'
export interface FormulaVariableContext {
/** Column names in grid order - index drives the cell-reference letter. */
columnNames: string[]
/** 0-based physical row index - drives the cell-reference row number. */
rowIndex: number
/** Current logged-in user - DC.USER_NAME resolves to this, quoted. */
userName: string
/** Original (pre-edit) value of the cell this rule applies to - DC.ORIG_VALUE resolves to this, quoted. */
origValue: string | number | undefined
}
const escapeRegExpMetacharacters = (text: string): string =>
text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
/**
* Replaces `token` with `replacement` wherever it has a leading and
* trailing blank (a literal space, or the start/end of this span). This
* lets `PRICE * VOLUME` substitute correctly while `MATCH(PRICE)` (no
* surrounding blanks) does not, without needing to know anything about
* function names.
*/
const substituteBoundedToken = (
text: string,
token: string,
replacement: string
): string => {
const pattern = new RegExp(
`(?<=^|\\s)${escapeRegExpMetacharacters(token)}(?=$|\\s)`,
'g'
)
return text.replace(pattern, replacement)
}
const quoteLiteral = (value: string | number | undefined): string =>
`"${value ?? ''}"`
export const parseFormulaRule = (
ruleValue: string,
context: FormulaVariableContext
): string => {
const quotedSpanPattern = /"[^"]*"|'[^']*'/g
let result = ''
let lastIndex = 0
let match: RegExpExecArray | null
const substituteUnquotedSpan = (span: string): string => {
let substituted = substituteBoundedToken(
span,
'DC.USER_NAME',
quoteLiteral(context.userName)
)
substituted = substituteBoundedToken(
substituted,
'DC.ORIG_VALUE',
quoteLiteral(context.origValue)
)
// Unlike DC.USER_NAME/DC.ORIG_VALUE, DC.ROW_STATUS resolves to a real
// cell reference (not a quoted literal) - it points at the EDIT_STATUS
// column, which holds this row's live M/A/D/U classification, so other
// formulas can react to it, e.g. `=if(A1!='U',"sasdemo","sasinstaller")`.
const rowStatusColIndex = context.columnNames.indexOf(
EDIT_STATUS_COLUMN_NAME
)
if (rowStatusColIndex !== -1) {
const rowStatusCellRef = `${Handsontable.helper.spreadsheetColumnLabel(rowStatusColIndex)}${context.rowIndex + 1}`
substituted = substituteBoundedToken(
substituted,
'DC.ROW_STATUS',
rowStatusCellRef
)
}
context.columnNames.forEach((columnName, columnIndex) => {
const cellRef = `${Handsontable.helper.spreadsheetColumnLabel(columnIndex)}${context.rowIndex + 1}`
substituted = substituteBoundedToken(substituted, columnName, cellRef)
})
return substituted
}
while ((match = quotedSpanPattern.exec(ruleValue))) {
result += substituteUnquotedSpan(ruleValue.slice(lastIndex, match.index))
result += match[0]
lastIndex = match.index + match[0].length
}
result += substituteUnquotedSpan(ruleValue.slice(lastIndex))
return `=${result}`
}
+1 -2
View File
@@ -6,7 +6,6 @@ import { RouterModule } from '@angular/router'
import { LoadingIndicatorComponent } from './loading-indicator/loading-indicator.component'
import { LoginComponent } from './login/login.component'
import { UserService } from './user.service'
import { AlertsService } from './alerts/alerts.service'
import { HeaderActions } from './user-nav-dropdown/header-actions.component'
import { AlertsComponent } from './alerts/alerts.component'
@@ -50,7 +49,7 @@ import { BulkValidationModalComponent } from './bulk-validation-modal/bulk-valid
ConfirmModalComponent,
BulkValidationModalComponent
],
providers: [UserService, AlertsService]
providers: [AlertsService]
})
export class SharedModule implements OnInit {
ngOnInit(): void {}
@@ -0,0 +1,43 @@
import { Injector } from '@angular/core'
import { TestBed } from '@angular/core/testing'
import { UserService } from './user.service'
describe('UserService', () => {
// UserService must be reachable without any consuming module declaring it
// as a local provider - that's what `providedIn: 'root'` gives you.
it('resolves to the exact same instance from an injector that never declares it as a provider', () => {
const rootInstance = TestBed.inject(UserService)
// Simulates a lazy-loaded feature module's own child injector (e.g.
// EditorModule) - it provides nothing of its own, so it can only
// resolve UserService by walking up to the root tree-shakable provider.
const lazyModuleInjector = Injector.create({
providers: [],
parent: TestBed.inject(Injector)
})
expect(lazyModuleInjector.get(UserService)).toBe(rootInstance)
})
it('shares live state (e.g. the logged-in user) across every injected reference', () => {
const fromRootModule = TestBed.inject(UserService)
const lazyModuleAInjector = Injector.create({
providers: [],
parent: TestBed.inject(Injector)
})
const lazyModuleBInjector = Injector.create({
providers: [],
parent: TestBed.inject(Injector)
})
const fromModuleA = lazyModuleAInjector.get(UserService)
const fromModuleB = lazyModuleBInjector.get(UserService)
// sas.service.ts sets .user through whichever reference it was injected
// with - here, simulated via the "root module"'s instance.
fromRootModule.user = { username: 'sasdemo' }
expect(fromModuleA.user?.username).toEqual('sasdemo')
expect(fromModuleB.user?.username).toEqual('sasdemo')
})
})
+1 -1
View File
@@ -2,7 +2,7 @@ import { Injectable } from '@angular/core'
import { Subject } from 'rxjs'
import { User } from './user.interface'
@Injectable()
@Injectable({ providedIn: 'root' })
export class UserService {
private _user!: User
public userChange: Subject<User> = new Subject<User>()
@@ -74,4 +74,57 @@ describe('buildColInfoHtml', () => {
expect(buildColInfoHtml('SOME_CHAR', colInfo)).not.toContain('REGEX:')
})
it('appends a √x=<formula> line when a formula value is provided', () => {
const colInfo: DataFormat = {
label: 'Some Character Column',
type: 'char',
length: '1024',
format: '$1024.'
}
expect(
buildColInfoHtml(
'SOME_CHAR',
colInfo,
undefined,
undefined,
'SOME_SHORTNUM * SOME_BESTNUM'
)
).toBe(
'NAME: SOME_CHAR<br>LABEL: Some Character Column<br>TYPE: char<br>LENGTH: 1024<br>FORMAT: $1024.<br>√x=SOME_SHORTNUM * SOME_BESTNUM'
)
})
it('omits the formula line when no formula value is provided', () => {
const colInfo: DataFormat = {
label: 'Some Character Column',
type: 'char',
length: '1024',
format: '$1024.'
}
expect(buildColInfoHtml('SOME_CHAR', colInfo)).not.toContain('√x=')
})
it('appends both the HARDREGEX line and the formula line when both are present', () => {
const colInfo: DataFormat = {
label: 'Some Character Column',
type: 'char',
length: '1024',
format: '$1024.'
}
expect(
buildColInfoHtml(
'SOME_CHAR',
colInfo,
'/^[A-Z]+$/i',
undefined,
'A_COL * B_COL'
)
).toBe(
'NAME: SOME_CHAR<br>LABEL: Some Character Column<br>TYPE: char<br>LENGTH: 1024<br>FORMAT: $1024.<br>HARDREGEX: /^[A-Z]+$/i<br>√x=A_COL * B_COL'
)
})
})
+9 -1
View File
@@ -9,7 +9,8 @@ export function buildColInfoHtml(
colName: string,
colInfo?: DataFormat,
hardRegexValue?: string,
softRegexValue?: string
softRegexValue?: string,
formulaValue?: string
): string {
if (!colInfo) return 'No info found'
@@ -25,5 +26,12 @@ export function buildColInfoHtml(
html += `<br>SOFTREGEX: ${softRegexValue}`
}
// '√x=' stands in for a text label here - HARDFORMULA vs SOFTFORMULA is
// already conveyed by the column's readOnly state, so there's no need to
// spell out which one this is.
if (formulaValue) {
html += `<br>√x=${formulaValue}`
}
return html
}
+213 -2
View File
@@ -53,7 +53,7 @@ function makeRows(n) {
NUMFMT_COL: 1000 + i * 12.5, // shown as EUR
REGEX_HARD_COL: "user@example.com", // HARDREGEX: email — starts valid
REGEX_SOFT_COL: "SW1A 1AA", // SOFTREGEX: UK postcode — starts valid
REGEX_BOTH_COL: "ABC-123" // HARDREGEX + SOFTREGEX together — starts valid against both
REGEX_BOTH_COL: "ABC-123" // HARDREGEX + SOFTREGEX together — starts valid against both
})
}
return rows
@@ -336,7 +336,9 @@ let webouts = {
{ NAME: "numfmt_col", MAXLEN: 8 },
{ NAME: "regex_hard_col", MAXLEN: 128 },
{ NAME: "regex_soft_col", MAXLEN: 128 },
{ NAME: "regex_both_col", MAXLEN: 128 }
{ NAME: "regex_both_col", MAXLEN: 128 },
{ NAME: "formula_hard_col", MAXLEN: 128 },
{ NAME: "formula_soft_col", MAXLEN: 128 }
],
query: [],
sasdata: makeRows(100),
@@ -1511,6 +1513,213 @@ let webouts = {
SYSWARNINGTEXT: "ENCODING option ignored for files opened with RECFM=N.",
END_DTTM: "2023-03-10T12:39:16.070656",
MEMSIZE: "2GB"
},
// Single-row, HARDFORMULA-only table - isolates whether HyperFormula
// computes at all when the row count is nowhere near the license's
// editor_rows_allowed cap (see maxRows investigation: Handsontable's
// formulas plugin forwards its own maxRows setting straight into the
// HyperFormula engine's sheet-size limit).
MPE_X_FORMULA_TEST: {
SYSDATE: "28JUL26",
SYSTIME: "12:00",
approvers: [],
cols: [
{
NAME: "PRIMARY_KEY_FIELD",
LABEL: "PRIMARY_KEY_FIELD",
FMTNAME: "",
DDTYPE: "N",
CLS_RULE: "READ",
MEMLABEL: "",
DESC: "",
LONGDESC: "",
COLTYPE: "{\"data\":\"PRIMARY_KEY_FIELD\",\"type\":\"numeric\",\"format\":\"0\"}"
},
{
NAME: "A_COL",
LABEL: "A_COL",
FMTNAME: "",
DDTYPE: "N",
CLS_RULE: "READ",
MEMLABEL: "",
DESC: "",
LONGDESC: "",
COLTYPE: "{\"data\":\"A_COL\",\"type\":\"numeric\",\"format\":\"0\"}"
},
{
NAME: "B_COL",
LABEL: "B_COL",
FMTNAME: "",
DDTYPE: "N",
CLS_RULE: "READ",
MEMLABEL: "",
DESC: "",
LONGDESC: "",
COLTYPE: "{\"data\":\"B_COL\",\"type\":\"numeric\",\"format\":\"0\"}"
},
{
NAME: "FORMULA_HARD_COL",
LABEL: "FORMULA_HARD_COL",
FMTNAME: "",
DDTYPE: "C",
CLS_RULE: "READ",
MEMLABEL: "",
DESC: "HARDFORMULA: computed A_COL * B_COL, readonly",
LONGDESC: "",
COLTYPE: "{\"data\":\"FORMULA_HARD_COL\"}"
},
{
NAME: "FORMULA_SOFT_COL",
LABEL: "FORMULA_SOFT_COL",
FMTNAME: "",
DDTYPE: "C",
CLS_RULE: "READ",
MEMLABEL: "",
DESC: "SOFTFORMULA: computed A_COL + B_COL as a default, user can overwrite",
LONGDESC: "",
COLTYPE: "{\"data\":\"FORMULA_SOFT_COL\"}"
},
{
NAME: "ROW_STATUS_COL",
LABEL: "ROW_STATUS_COL",
FMTNAME: "",
DDTYPE: "C",
CLS_RULE: "READ",
MEMLABEL: "",
DESC: "SOFTFORMULA: DC.ROW_STATUS demo, reacts live to this row's edit status",
LONGDESC: "",
COLTYPE: "{\"data\":\"ROW_STATUS_COL\"}"
},
{
NAME: "USER_NAME_COL",
LABEL: "USER_NAME_COL",
FMTNAME: "",
DDTYPE: "C",
CLS_RULE: "READ",
MEMLABEL: "",
DESC: "SOFTFORMULA: DC.USER_NAME demo, shows the current logged-in user",
LONGDESC: "",
COLTYPE: "{\"data\":\"USER_NAME_COL\"}"
},
{
NAME: "ORIG_VALUE_COL",
LABEL: "ORIG_VALUE_COL",
FMTNAME: "",
DDTYPE: "C",
CLS_RULE: "READ",
MEMLABEL: "",
DESC: "SOFTFORMULA: DC.ORIG_VALUE demo, echoes this row's pre-edit value (blank for a newly-inserted row)",
LONGDESC: "",
COLTYPE: "{\"data\":\"ORIG_VALUE_COL\"}"
},
{
NAME: "CHANGE_SUMMARY_COL",
LABEL: "CHANGE_SUMMARY_COL",
FMTNAME: "",
DDTYPE: "C",
CLS_RULE: "READ",
MEMLABEL: "",
DESC: "SOFTFORMULA: combines DC.ROW_STATUS/DC.USER_NAME/DC.ORIG_VALUE - 'unedited' while unchanged, else '<user> changed from <original value>'",
LONGDESC: "",
COLTYPE: "{\"data\":\"CHANGE_SUMMARY_COL\"}"
}
],
dqdata: [],
dqrules: [
{ BASE_COL: "PRIMARY_KEY_FIELD", RULE_TYPE: "NOTNULL", RULE_VALUE: "" },
{ BASE_COL: "FORMULA_HARD_COL", RULE_TYPE: "HARDFORMULA", RULE_VALUE: "A_COL * B_COL" },
{ BASE_COL: "FORMULA_SOFT_COL", RULE_TYPE: "SOFTFORMULA", RULE_VALUE: "A_COL + B_COL" },
{ BASE_COL: "ROW_STATUS_COL", RULE_TYPE: "SOFTFORMULA", RULE_VALUE: "DC.ROW_STATUS" },
{ BASE_COL: "USER_NAME_COL", RULE_TYPE: "SOFTFORMULA", RULE_VALUE: "DC.USER_NAME" },
{ BASE_COL: "ORIG_VALUE_COL", RULE_TYPE: "SOFTFORMULA", RULE_VALUE: "DC.ORIG_VALUE" },
{ BASE_COL: "CHANGE_SUMMARY_COL", RULE_TYPE: "SOFTFORMULA", RULE_VALUE: "IF( DC.ROW_STATUS =\"U\",\"unedited\", DC.USER_NAME &\" changed from \"& DC.ORIG_VALUE )" }
],
dsmeta: [
{ ODS_TABLE: "ATTRIBUTES", NAME: "Data Set Name", VALUE: "MPE_X_FORMULA_TEST" },
{ ODS_TABLE: "ATTRIBUTES", NAME: "Member Type", VALUE: "DATA" },
{ ODS_TABLE: "ATTRIBUTES", NAME: "Engine", VALUE: "V9" },
{ ODS_TABLE: "ATTRIBUTES", NAME: "Observations", VALUE: "10" },
{ ODS_TABLE: "ATTRIBUTES", NAME: "Variables", VALUE: "9" }
],
maxvarlengths: [
{ NAME: "_____DELETE__THIS__RECORD_____", MAXLEN: 3 },
{ NAME: "primary_key_field", MAXLEN: 8 },
{ NAME: "a_col", MAXLEN: 8 },
{ NAME: "b_col", MAXLEN: 8 },
{ NAME: "formula_hard_col", MAXLEN: 128 },
{ NAME: "formula_soft_col", MAXLEN: 128 },
{ NAME: "row_status_col", MAXLEN: 128 },
{ NAME: "user_name_col", MAXLEN: 128 },
{ NAME: "orig_value_col", MAXLEN: 128 },
{ NAME: "change_summary_col", MAXLEN: 128 }
],
query: [],
// 10 rows, well under the editor_rows_allowed=15 cap, with both
// HARDFORMULA and SOFTFORMULA rules present together. ORIG_VALUE_COL
// and CHANGE_SUMMARY_COL are seeded with a distinctive raw value
// (not blank, unlike the other formula columns) so DC.ORIG_VALUE -
// which always echoes THIS SAME column's own pre-edit value, never
// another column's - has something meaningful to echo back.
sasdata: Array.from({ length: 10 }, (_, i) => ({
_____DELETE__THIS__RECORD_____: "No",
PRIMARY_KEY_FIELD: i + 1,
A_COL: i + 1,
B_COL: 10,
FORMULA_HARD_COL: "",
FORMULA_SOFT_COL: "",
ROW_STATUS_COL: "",
USER_NAME_COL: "",
ORIG_VALUE_COL: `orig-${i + 1}`,
CHANGE_SUMMARY_COL: `orig-${i + 1}`
})),
$sasdata: {
vars: {
_____DELETE__THIS__RECORD_____: { format: "$3.", label: "_____DELETE__THIS__RECORD_____", length: "3", type: "char" },
PRIMARY_KEY_FIELD: { format: "best.", label: "PRIMARY_KEY_FIELD", length: "8", type: "num" },
A_COL: { format: "best.", label: "A_COL", length: "8", type: "num" },
B_COL: { format: "best.", label: "B_COL", length: "8", type: "num" },
FORMULA_HARD_COL: { format: "$128.", label: "FORMULA_HARD_COL", length: "128", type: "char" },
FORMULA_SOFT_COL: { format: "$128.", label: "FORMULA_SOFT_COL", length: "128", type: "char" },
ROW_STATUS_COL: { format: "$128.", label: "ROW_STATUS_COL", length: "128", type: "char" },
USER_NAME_COL: { format: "$128.", label: "USER_NAME_COL", length: "128", type: "char" },
ORIG_VALUE_COL: { format: "$128.", label: "ORIG_VALUE_COL", length: "128", type: "char" },
CHANGE_SUMMARY_COL: { format: "$128.", label: "CHANGE_SUMMARY_COL", length: "128", type: "char" }
}
},
sasparams: [
{
COLHEADERS: "_____DELETE__THIS__RECORD_____,PRIMARY_KEY_FIELD,A_COL,B_COL,FORMULA_HARD_COL,FORMULA_SOFT_COL,ROW_STATUS_COL,USER_NAME_COL,ORIG_VALUE_COL,CHANGE_SUMMARY_COL",
FILTER_TEXT: "",
PKCNT: 1,
PK: "PRIMARY_KEY_FIELD",
DTVARS: "",
DTTMVARS: "",
TMVARS: "",
LOADTYPE: "UPDATE",
RK_FLAG: 0,
CLS_FLAG: 0
}
],
xl_rules: [],
_DEBUG: "",
_PROGRAM: "/Public/app/dc/services/editors/getdata",
AUTOEXEC: "",
MF_GETUSER: "sasdemo",
SYSCC: "0",
SYSENCODING: "utf-8",
SYSERRORTEXT: "",
SYSHOSTNAME: "SAS",
SYSPROCESSID: "0",
SYSPROCESSMODE: "SAS Batch Mode",
SYSPROCESSNAME: "",
SYSJOBID: "1",
SYSSCPL: "Linux",
SYSSITE: "123",
SYSUSERID: "sasjssrv",
SYSVLONG: "9.04.01M7P080520",
SYSWARNINGTEXT: "",
END_DTTM: "2026-07-28T12:00:00.000000",
MEMSIZE: "1MB"
}
}
@@ -1549,6 +1758,8 @@ if (_WEBIN_FILEREF1) {
if (file1.includes('MPE_X_NEW')) {
table = 'MPE_X_NEW'
} else if (file1.includes('MPE_X_FORMULA_TEST')) {
table = 'MPE_X_FORMULA_TEST'
} else if (file1.includes('MPE_X_TEST')) {
table = 'MPE_X_TEST'
} else if (file1.includes('MPE_DATADICTIONARY')) {
@@ -55,6 +55,10 @@ _webout = `{"SYSDATE" : "26SEP22"
"LIBREF": "DC996664",
"DSN": "MPE_X_NEW"
},
{
"LIBREF": "DC996664",
"DSN": "MPE_X_FORMULA_TEST"
},
{
"LIBREF": "DC996664",
"DSN": "MPE_DATADICTIONARY"
+96 -2
View File
@@ -12,6 +12,8 @@
<h4> SAS Macros </h4>
@li bitemporal_dataloader.sas
@li mf_existvar.sas
@li mf_getengine.sas
@li mf_getuniquename.sas
@li mp_abort.sas
@li mp_loadformat.sas
@li mp_lockanytable.sas
@@ -136,8 +138,100 @@ run;
%end;
drop _____DELETE__THIS__RECORD_____;
run;
proc sql; delete * from &libds;
proc append base=&libds data=WORK.&STAGING_DS force nowarn;run;
%local engine_type;
%let engine_type=%mf_getengine(&lib);
%if &engine_type=CAS %then %do;
/* Fixed char variables cannot be appended to CAS varchar columns, so
cast them (per the target table structure) in a CASUSER copy of the
staging table, then append via data step. Approach mirrors the CAS
append in bitemporal_dataloader.sas */
proc contents noprint data=&libds
out=work.rpl_base_cols(keep=name type);
run;
proc contents noprint data=WORK.&STAGING_DS
out=work.rpl_stag_cols(keep=name type);
run;
proc sql noprint;
create table work.rpl_vchars as
select a.name
from work.rpl_base_cols a
inner join work.rpl_stag_cols b
on upcase(a.name)=upcase(b.name)
where a.type=6; /* varchar in target */
quit;
/* get varchar variables ready for casting */
%local vcfmt vcrename vcassign vcdrop tmpds;
%let vcfmt=;
%let vcrename=;
%let vcassign=;
%let vcdrop=;
data _null_;
set work.rpl_vchars end=last;
length vcrename vcassign vcdrop vcfmt $32767 rancol $32;
retain vcrename vcassign vcdrop vcfmt;
if _n_=1 then vcrename='(rename=(';
rancol=resolve('%mf_getuniquename()');
vcfmt=trim(vcfmt)!!'length '!!cats(name)!!' varchar(*);';
vcrename=trim(vcrename)!!' '!!cats(name,'=',rancol);
vcassign=cats(vcassign,name,'=',rancol,';');
vcdrop=cats(vcdrop,'drop '!!rancol,';');
if last then do;
vcrename=cats(vcrename,'))');
call symputx('vcfmt',vcfmt);
call symputx('vcrename',vcrename);
call symputx('vcassign',vcassign);
call symputx('vcdrop',vcdrop);
end;
run;
/* prepare a temp cas table with varchars casted */
%let tmpds=%mf_getuniquename();
data casuser.&tmpds;
&vcfmt
set WORK.&STAGING_DS &vcrename;
&vcassign
&vcdrop
run;
/* exit on err condition before the destructive truncate below */
%if &syscc>0 %then %do;
%mp_lockanytable(UNLOCK,lib=&lib,ds=&ds,ref=&ETLSOURCE (aborted),
ctl_ds=&dclib..mpe_lockanytable
)
%end;
%mp_abort(iftrue= (&syscc>0)
,mac=&sysmacroname
,msg=%str(syscc=&syscc - aborting before REPLACE truncate of &libds.)
)
/* CAS tables do not support SQL deletes, so truncate with deleteRows.
This is deliberately the last step before the append, to minimise
the time in which the target table is empty. */
proc cas;
table.deleteRows / table={caslib="&lib",name="&ds",where="1=1"};
quit;
/* load the target with varchars applied */
data &libds (append=yes) / sessref=dcsession;
set casuser.&tmpds;
run;
/* drop temp table */
proc sql;
drop table CASUSER.&tmpds;
quit;
%end;
%else %do;
/* exit on err condition before the destructive delete below */
%if &syscc>0 %then %do;
%mp_lockanytable(UNLOCK,lib=&lib,ds=&ds,ref=&ETLSOURCE (aborted),
ctl_ds=&dclib..mpe_lockanytable
)
%end;
%mp_abort(iftrue= (&syscc>0)
,mac=&sysmacroname
,msg=%str(syscc=&syscc - aborting before REPLACE delete of &libds.)
)
proc sql;
delete * from &libds;
quit;
proc append base=&libds data=WORK.&STAGING_DS force nowarn;run;
%end;
%mp_lockanytable(UNLOCK,lib=&lib,ds=&ds,ctl_ds=&dclib..mpe_lockanytable)
%end;
+178
View File
@@ -0,0 +1,178 @@
/**
@file
@brief Testing mpe_targetloader macro - REPLACE loadtype
@details Covers the REPLACE branch of mpe_targetloader.sas:
* LOADTARGET=NO (diff screen preparation) - all staged records classed as
new, all existing records classed as deleted, target table unchanged
* LOADTARGET=YES (actual load) - target table fully replaced by the
staging table, delete flag column dropped, processed column stamped,
per-record delete flags ignored (everything is loaded)
A dedicated DCTEST.DC_REPLACE table is registered in MPE_TABLES (and the
registration removed again in cleanup). The DCTEST library is BASE engine,
so the CAS-specific branch (deleteRows truncation / varchar casting) is not
exercised here.
<h4> SAS Macros </h4>
@li mp_assert.sas
@li mp_assertdsobs.sas
@li mp_assertscope.sas
@li mpe_targetloader.sas
@author 4GL Apps Ltd
@copyright 4GL Apps Ltd. This code may only be used within Data Controller
and may not be re-distributed or re-sold without the express permission of
4GL Apps Ltd.
**/
%let syscc=0;
/**
* Prep - physical target table and MPE_TABLES registration
*/
data dctest.dc_replace;
length pk $8 val $20;
pk='OLD1'; val='oldvalue1'; processed_dttm=0; output;
pk='OLD2'; val='oldvalue2'; processed_dttm=0; output;
format processed_dttm datetime19.;
run;
proc sql noprint;
delete from &dc_libref..mpe_tables where libref="DCTEST" and dsn='DC_REPLACE';
insert into &dc_libref..mpe_tables
set tx_from=0
,tx_to='31DEC5999:23:59:59'dt
,libref="DCTEST"
,dsn='DC_REPLACE'
,buskey='PK'
,loadtype='REPLACE'
,var_processed='PROCESSED_DTTM'
,num_of_approvals_required=1;
quit;
/* staging table, as it would arrive from the approval package */
data work.staging_ds;
length pk $8 val $20 _____DELETE__THIS__RECORD_____ $3;
pk='NEW1'; val='newvalue1'; _____DELETE__THIS__RECORD_____='No'; output;
pk='NEW2'; val='newvalue2'; _____DELETE__THIS__RECORD_____='Yes'; output;
pk='NEW3'; val='newvalue3'; _____DELETE__THIS__RECORD_____='No'; output;
run;
/**
* Test 1 - LOADTARGET=NO builds the diff tables without touching the target
*/
%mp_assertscope(SNAPSHOT)
%mpe_targetloader(libds=DCTEST.DC_REPLACE
,etlsource=mpe_targetloader.test
,STAGING_DS=STAGING_DS
,LOADTARGET=NO
,dclib=&dc_libref
,dc_dttmtfmt=&dc_dttmtfmt.
)
%mp_assertscope(COMPARE,
desc=%str(Test 1 - checking macro variables against previous snapshot)
)
%mp_assert(iftrue=(&syscc=0),
desc=%str(Test 1 - REPLACE LOADTARGET=NO completed without errors),
outds=work.test_results
)
%mp_assertdsobs(work.outds_add,
desc=%str(Test 1 - all staged records classed as new),
test=EQUALS 3,
outds=work.test_results
)
%mp_assertdsobs(work.outds_del,
desc=%str(Test 1 - all existing records classed as deleted),
test=EQUALS 2,
outds=work.test_results
)
%mp_assertdsobs(work.outds_mod,
desc=%str(Test 1 - no records classed as modified),
test=EQUALS 0,
outds=work.test_results
)
%mp_assertdsobs(dctest.dc_replace,
desc=%str(Test 1 - target table not modified),
test=EQUALS 2,
outds=work.test_results
)
/**
* Test 2 - LOADTARGET=YES replaces the target table with the staged data
*/
%mp_assertscope(SNAPSHOT)
%mpe_targetloader(libds=DCTEST.DC_REPLACE
,etlsource=mpe_targetloader.test
,STAGING_DS=STAGING_DS
,LOADTARGET=YES
,dclib=&dc_libref
,dc_dttmtfmt=&dc_dttmtfmt.
)
%mp_assertscope(COMPARE,
desc=%str(Test 2 - checking macro variables against previous snapshot)
)
%mp_assert(iftrue=(&syscc=0),
desc=%str(Test 2 - REPLACE LOADTARGET=YES completed without errors),
outds=work.test_results
)
%mp_assertdsobs(dctest.dc_replace,
desc=%str(Test 2 - target row count matches staging table),
test=EQUALS 3,
outds=work.test_results
)
proc sql noprint;
select count(*) into: oldrows
from dctest.dc_replace where pk in ('OLD1','OLD2');
select count(*) into: newrows
from dctest.dc_replace where pk in ('NEW1','NEW2','NEW3');
select count(*) into: delcol from dictionary.columns
where libname='DCTEST' and memname='DC_REPLACE'
and upcase(name)='_____DELETE__THIS__RECORD_____';
select count(*) into: notstamped
from dctest.dc_replace where missing(processed_dttm) or processed_dttm=0;
quit;
%mp_assert(iftrue=(&oldrows=0),
desc=%str(Test 2 - pre-existing records removed from target),
outds=work.test_results
)
%mp_assert(iftrue=(&newrows=3),
desc=%str(Test 2 - all staged records loaded, delete flags ignored),
outds=work.test_results
)
%mp_assert(iftrue=(&delcol=0),
desc=%str(Test 2 - delete flag column not loaded to target),
outds=work.test_results
)
%mp_assert(iftrue=(&notstamped=0),
desc=%str(Test 2 - processed_dttm stamped on every loaded record),
outds=work.test_results
)
/**
* Cleanup - remove all persistent state created by this test, so that a
* subsequent run starts from the same position (including a run that
* previously failed partway through)
*/
proc sql noprint;
/* REPLACE table registration */
delete from &dc_libref..mpe_tables where libref="DCTEST" and dsn='DC_REPLACE';
/* lock record (may be left as LOCKED after an aborted run) */
delete from &dc_libref..mpe_lockanytable
where lock_lib="DCTEST" and lock_ds="DC_REPLACE";
quit;
/* physical target table */
proc datasets lib=dctest nolist;
delete dc_replace;
run;
quit;
/* assertion macro variables */
%symdel oldrows newrows delcol notstamped;