From 7ef58a0f540905cf9466fb4a8b80b4fafc512588 Mon Sep 17 00:00:00 2001 From: 4gl <@> Date: Tue, 28 Apr 2026 17:36:12 +0100 Subject: [PATCH] feat: mv_castabsave macro and tests --- base/mp_init.sas | 3 +- tests/viyaonly/mv_castabsave.test.sas | 158 +++++++++++++++++++++ viya/mv_castabsave.sas | 192 ++++++++++++++++++++++++++ 3 files changed, 352 insertions(+), 1 deletion(-) create mode 100644 tests/viyaonly/mv_castabsave.test.sas create mode 100644 viya/mv_castabsave.sas diff --git a/base/mp_init.sas b/base/mp_init.sas index ee2231b..68b4e19 100644 --- a/base/mp_init.sas +++ b/base/mp_init.sas @@ -41,8 +41,9 @@ &prefix._INIT_NUM /* initialisation time as numeric */ &prefix._INIT_DTTM /* initialisation time in E8601DT26.6 format */ &prefix.WORK /* avoid typing %sysfunc(pathname(work)) every time */ + &prefix.PROCESSMODE + &prefix._STPSRV_HEADER_LOC ; - %let sasjs_prefix=&prefix; data _null_; diff --git a/tests/viyaonly/mv_castabsave.test.sas b/tests/viyaonly/mv_castabsave.test.sas new file mode 100644 index 0000000..20bb0f6 --- /dev/null +++ b/tests/viyaonly/mv_castabsave.test.sas @@ -0,0 +1,158 @@ +/** + @file + @brief Testing mv_castabsave macro + +

SAS Macros

+ @li mf_uid.sas + @li mp_assert.sas + @li mp_assertscope.sas + @li mv_castabsave.sas + +**/ + +options mprint; + +/* -------------------------------------------------------------------- */ +/* Setup: start a CAS session and load a table that has a tracked */ +/* source file so mv_castabsave can discover it via the REST API */ +/* -------------------------------------------------------------------- */ +cas mysess; +caslib _all_ assign; + +%let testcaslib=Public; + +proc cas; + table.caslibInfo result=r / ; + found=0; + do row over r.CASLibInfo; + if upcase(row.Name)=upcase("&testcaslib") then found=1; + end; + if found=0 then do; + print "ERROR: caslib &testcaslib not available"; + exit; + end; +quit; +%put NOTE: Using testcaslib=&testcaslib; + +%let tab1=T%mf_uid(); + +/* Load sashelp.class into CAS, save as sashdat, reload from that file + so the table has a tracked source path (needed for REST discovery) */ +proc casutil; + load data=sashelp.class + outcaslib="&testcaslib" casout="&tab1" replace; + save casdata="&tab1" incaslib="&testcaslib" + casout="&tab1..sashdat" outcaslib="&testcaslib" replace; + /* Drop any existing global-scope version before promoting */ + /* runs twice (with quiet) as first would drop local scope if exists */ + droptable casdata="&tab1" incaslib="&testcaslib" quiet; + droptable casdata="&tab1" incaslib="&testcaslib" quiet; + + load casdata="&tab1..sashdat" incaslib="&testcaslib" + casout="&tab1" outcaslib="&testcaslib" promote; +quit; + +libname mylib cas caslib="&testcaslib"; + + +/* -------------------------------------------------------------------- */ +%put TEST 1 - save in-memory table back to disk + no scope leakage; +/* -------------------------------------------------------------------- */ + +/* Source file is removed so that the reload proves mv_castabsave + created the file from scratch, not that a prior version existed */ +proc casutil; + deletesource casdata="&tab1..sashdat" + incaslib="&testcaslib" quiet; +quit; + +/* Insert a sentinel row - it must survive the full save/drop/reload */ +data work.appendme; + set mylib.&tab1; + name='TESTROW'; + output; + stop; +proc casutil; + load data=work.appendme casout="&tab1" outcaslib="&testcaslib" append; +quit; + +%mp_assertscope(SNAPSHOT) + +%mv_castabsave(lib=mylib, table=&tab1, mdebug=1) + +%mp_assertscope(COMPARE, + desc=Check mv_castabsave does not leak macro variables into GLOBAL scope, + ignorelist=MC0_JADP1LEN MC0_JADP2LEN MC0_JADP3LEN MC0_JADPNUM MC0_JADVLEN +) + +proc casutil; + droptable casdata="&tab1" incaslib="&testcaslib" quiet; + droptable casdata="&tab1" incaslib="&testcaslib" quiet; + load casdata="&tab1..sashdat" incaslib="&testcaslib" + casout="&tab1" outcaslib="&testcaslib" promote; +quit; + +%let _rowcount=0; +proc sql noprint; + select count(*) into :_rowcount + from mylib.&tab1 + where name='TESTROW'; +quit; + +%mp_assert( + iftrue=(&_rowcount=1), + desc=Check inserted row survives mv_castabsave round-trip to disk +) + + +/* -------------------------------------------------------------------- */ +%put TEST 2 - save overwrites an existing source file; +/* -------------------------------------------------------------------- */ + +/* Source file already exists from the TEST 1 save - append a new row */ +data work.appendme; + set mylib.&tab1; + name='TESTROW2'; + output; + stop; +proc casutil; + load data=work.appendme casout="&tab1" + outcaslib="&testcaslib" append; +quit; + +%mv_castabsave(lib=mylib, table=&tab1, mdebug=1) + +proc casutil; + droptable casdata="&tab1" incaslib="&testcaslib" quiet; + droptable casdata="&tab1" incaslib="&testcaslib" quiet; + load casdata="&tab1..sashdat" incaslib="&testcaslib" + casout="&tab1" outcaslib="&testcaslib" promote; +quit; + +%let _rowcount=0; +proc sql noprint; + select count(*) into :_rowcount + from mylib.&tab1 + where name='TESTROW2'; +quit; + +%mp_assert( + iftrue=(&_rowcount=1), + desc=Check inserted row survives save over an existing source file +) + + +/* -------------------------------------------------------------------- */ +/* Teardown */ +/* -------------------------------------------------------------------- */ +libname mylib clear; + +proc casutil; + droptable casdata="&tab1" incaslib="&testcaslib" quiet; + deletesource casdata="&tab1..sashdat" + incaslib="&testcaslib" quiet; +quit; + +cas mysess terminate; + +%let syscc=0; diff --git a/viya/mv_castabsave.sas b/viya/mv_castabsave.sas new file mode 100644 index 0000000..87d6aa1 --- /dev/null +++ b/viya/mv_castabsave.sas @@ -0,0 +1,192 @@ +/** + @file mv_castabsave.sas + @brief Saves an in-memory CAS table back to persistent storage + @details Runs in SPRE against an active CAS session. Accepts a + SAS libref, derives the CAS caslib and session UUID from + sashelp.vlibnam, locates the owning CAS server via the + casManagement REST API, then queries the table endpoint to + discover the original source file and saves back to that path. + CASUTIL infers the file type from the output file extension. + + A CAS session must already be established by the caller, eg: + + cas mysess; + libname mylib cas caslib=Public; + %mv_castabsave(lib=mylib, table=BASEBALL) + + @param [in] lib= SAS libref for the CAS caslib + @param [in] table= Name of the in-memory CAS table to save + @param [in] mdebug= (0) Set to 1 to enable verbose logging: + - echoes resolved parameters + - prints HTTP response body + - enables mprint/notes during PROC calls + +

SAS Macros

+ @li mf_getplatform.sas + @li mf_getuniquefileref.sas + @li mf_getuniquelibref.sas + @li mp_abort.sas + +**/ + +%macro mv_castabsave( + lib= + ,table= + ,mdebug=0 +); + +%local _sysopts base_uri caslib uuid server + srcfile srccaslib fname1 libref1 ftmp i _svcount; +%let _sysopts=%sysfunc(getoption(mprint)) %sysfunc(getoption(notes)); + +/* ---- input validation -------------------------------------------------- */ +%mp_abort( + iftrue=("&lib"="" or "&table"=""), + msg=%str(lib= and table= are required) +) + +%if &mdebug=1 %then %do; + %put &=lib; + %put &=table; + options mprint notes; +%end; + +/* ---- derive caslib and session UUID from sashelp.vlibnam --------------- */ +data _null_; + set sashelp.vlibnam( + where=(libname="%upcase(&lib)" + and sysname in ("Caslib","Session UUID")) + ); + if sysname="Caslib" then call symputx('caslib',sysvalue,'L'); + else call symputx('uuid',sysvalue,'L'); +run; + +%mp_abort( + iftrue=("&caslib"=""), + msg=%str(&lib is not an assigned CAS libref) +) + +%mp_abort( + iftrue=("&uuid"=""), + msg=%str(No session UUID found for libref &lib) +) + +%if &mdebug=1 %then %do; + %put &=caslib; + %put &=uuid; +%end; + +%let base_uri=%mf_getplatform(VIYARESTAPI); + +/* ---- get list of CAS servers ------------------------------------------- */ +%let fname1=%mf_getuniquefileref(); +%let libref1=%mf_getuniquelibref(); + +proc http method='GET' out=&fname1 oauth_bearer=sas_services + url="&base_uri/casManagement/servers"; +run; + +%mp_abort( + iftrue=(&SYS_PROCHTTP_STATUS_CODE ne 200), + msg=%str(&SYS_PROCHTTP_STATUS_CODE &SYS_PROCHTTP_STATUS_PHRASE) +) + +libname &libref1 JSON fileref=&fname1; + +data _null_; + set &libref1..items; + call symputx(cats('_sv_', _n_), name, 'L'); + call symputx('_svcount', _n_, 'L'); +run; + +libname &libref1 clear; +filename &fname1 clear; + +/* ---- find which server owns this session ------------------------------- */ +%do i=1 %to &_svcount; + %if "&server"="" %then %do; + %if &mdebug=1 %then %put checking server: &&_sv_&i; + %let ftmp=%mf_getuniquefileref(); + proc http method='GET' out=&ftmp oauth_bearer=sas_services + url="&base_uri/casManagement/servers/&&_sv_&i/sessions/&uuid"; + run; + %if &SYS_PROCHTTP_STATUS_CODE=200 + %then %let server=&&_sv_&i; + filename &ftmp clear; + %end; +%end; + +%mp_abort( + iftrue=("&server"=""), + msg=%str(Could not find owning server for CAS session &uuid) +) + +%if &mdebug=1 %then %put &=server; + +/* ---- discover srcfile from REST endpoint ------------------------------- */ +%let fname1=%mf_getuniquefileref(); +%let libref1=%mf_getuniquelibref(); + +proc http method='GET' out=&fname1 oauth_bearer=sas_services + url="&base_uri/casManagement/servers/&server/caslibs/&caslib/tables/&table"; +run; + +%if &mdebug=1 %then %do; + %put &=SYS_PROCHTTP_STATUS_CODE &=SYS_PROCHTTP_STATUS_PHRASE; + data _null_; + infile &fname1; + input; + putlog _infile_; + run; +%end; + +%mp_abort( + iftrue=(&SYS_PROCHTTP_STATUS_CODE=404), + msg=%str(&caslib..&table not found - is it loaded in memory?) +) +%mp_abort( + iftrue=(&SYS_PROCHTTP_STATUS_CODE ne 200), + msg=%str(&SYS_PROCHTTP_STATUS_CODE &SYS_PROCHTTP_STATUS_PHRASE) +) + +libname &libref1 JSON fileref=&fname1; + +data _null_; + set &libref1..tablereference; + call symputx('srcfile', sourceTableName, 'L'); + call symputx('srccaslib', sourceCaslibName, 'L'); + stop; +run; + +libname &libref1 clear; +filename &fname1 clear; + +%mp_abort( + iftrue=("&srcfile"="" or "&srccaslib"=""), + msg=%str(No sourceTableName/sourceCaslibName for &caslib..&table) +) + +%if &mdebug=1 %then %put &=srcfile; + +/* ---- save to disk ------------------------------------------------------- */ +proc casutil; + save casdata="&table" + incaslib="&caslib" + casout="&srcfile" + outcaslib="&srccaslib" + replace; +quit; + +%mp_abort( + iftrue=(&syscc ne 0), + msg=%str(Save failed for &caslib..&table) +) + +%put NOTE: Table &caslib..&table saved to &srcfile; + +/* ---- restore options --------------------------------------------------- */ +%if &mdebug=1 %then %do; + options &_sysopts; +%end; + +%mend mv_castabsave;