diff --git a/.gitignore b/.gitignore index 288005b..e958365 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ mc_* ~ +.claude \ No newline at end of file diff --git a/README.md b/README.md index ef1b40b..898c450 100644 --- a/README.md +++ b/README.md @@ -201,7 +201,7 @@ When contributing to this library, it is therefore important to ensure that all - All dataset references must be 2 level (eg `work.blah`, not `blah`). This is to avoid contention when options [DATASTMTCHK](https://support.sas.com/documentation/cdl/en/lrdict/64316/HTML/default/viewer.htm#a000279064.htm)=ALLKEYWORDS is in effect, or the [USER](https://documentation.sas.com/doc/en/pgmsascdc/9.4_3.5/lrcon/n18m1vkqmeo4esn1moikt23zhp8s.htm) library is active. - Avoid naming collisions! All macro variables should be local scope. Use system generated work tables where possible - eg `data ; set sashelp.class; run; data &output; set &syslast; run;` - Where global macro variables are absolutely necessary, they should make use of `&sasjs_prefix` - see mp_init.sas -- The use of `quit;` for `proc sql` is optional unless you are looking to benefit from the timing statistics. +- The use of `quit;` for `proc sql` is essential, to avoid `WARNING: You cannot disconnect or terminate session XXXX until the procedure completes.` when terminating CAS sessions in Viya. - Use [sasjs lint](https://github.com/sasjs/lint)! ## General Notes diff --git a/all.sas b/all.sas index 447822e..27c485e 100644 --- a/all.sas +++ b/all.sas @@ -3512,6 +3512,7 @@ run; run; proc sql; drop table &ds; + quit; %mend mp_assert;/** @file @@ -4042,6 +4043,7 @@ run; from dictionary.macros where scope="&scope" and upcase(name) not in (%mf_getquotedstr(&ilist)) order by name,offset; + quit; %end; %else %if &action=COMPARE %then %do; @@ -4079,7 +4081,6 @@ run; %let test_comments=%str(Mod:(&mod) Add:(&add) Del:(&del)); %end; - data ; length test_description $256 test_result $4 test_comments $256; test_description=symget('desc'); @@ -4092,6 +4093,7 @@ run; run; proc sql; drop table &ds; + quit; %end; %mend mp_assertscope; @@ -10103,8 +10105,9 @@ filename &tempref clear; &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_; @@ -24211,12 +24214,12 @@ run; %if %mfv_existsashdat(libds=casuser.sometable) %then %put yes it does!; The function uses `dosubl()` to run the `table.fileinfo` action, for the - specified library, filtering for `*.sashdat` tables. The results are stored - in a WORK table (&outprefix._&lib). If that table already exists, it is - queried instead, to avoid the dosubl() performance hit. + specified library, filtering for `*.sashdat` tables. - To force a rescan, just use a new `&outprefix` value, or delete the table(s) - before running the function. + Results are cached in a WORK table (&outprefix._&lib). If that table + already exists it is queried directly to avoid the dosubl() overhead. + To force a rescan, use a new `&outprefix` value or delete the cache + table before calling. @param [in] libds library.dataset @param [out] outprefix= (work.mfv_existsashdat) @@ -24229,13 +24232,12 @@ run; @author Mathieu Blauw **/ -%macro mfv_existsashdat(libds,outprefix=work.mfv_existsashdat -); +%macro mfv_existsashdat(libds,outprefix=work.mfv_existsashdat); %local rc dsid name lib ds; %let lib=%upcase(%scan(&libds,1,'.')); %let ds=%upcase(%scan(&libds,-1,'.')); -/* if table does not exist, create it */ +/* if cache table does not exist, build it */ %if %sysfunc(exist(&outprefix._&lib)) ne 1 %then %do; %let rc=%sysfunc(dosubl(%nrstr( /* Read in table list (once per &lib per session) */ @@ -24246,7 +24248,7 @@ run; quit; /* Only keep name, without file extension */ data &outprefix._&lib; - set &outprefix._&lib(where=(Name like '%.sashdat') keep=Name); + set &outprefix._&lib(where=(upcase(Name) like '%.SASHDAT') keep=Name); Name=upcase(scan(Name,1,'.')); run; ))); @@ -24263,6 +24265,43 @@ run; %else 0; %mend mfv_existsashdat; +/** + @file mfv_getcaslib.sas + @brief Returns the CAS caslib name for a given SAS libref + @details Pure macro function. Reads sashelp.vlibnam and returns + the sysvalue where sysname='Caslib' for the given libref. This + is useful when the caslib name and libref name may differ. + + Usage: + + %put %mfv_getcaslib(lib=PUBLIC); + + @param [in] lib SAS libref for which to return the CAS caslib name + + @return Returns the CAS caslib name, or empty string if not found + +**/ + +%macro mfv_getcaslib(lib); + +%local dsid rc result; + +%let dsid=%sysfunc(open(sashelp.vlibnam( + where=(libname="%upcase(&lib)" and sysname="Caslib") +))); + +%if &dsid %then %do; + %let rc=%sysfunc(fetch(&dsid)); + %if &rc=0 %then + %let result=%sysfunc( + getvarc(&dsid,%sysfunc(varnum(&dsid,SYSVALUE))) + ); + %let rc=%sysfunc(close(&dsid)); +%end; + +&result + +%mend mfv_getcaslib; /** @file @brief Returns the path of a folder from the URI @@ -24371,6 +24410,405 @@ run; msg=Cannot leave &sysmacroname with syscc=&syscc ) %mend mfv_getpathuri;/** + @file mv_castabload.sas + @brief Checks if a CAS table is loaded; if not, loads and promotes it + @details Runs in SPRE against an active CAS session. Accepts a + SAS libref, derives the CAS caslib and session UUID from + sashelp.vlibnam, then checks whether the table is already + in-memory. If not, locates the owning CAS server via the + casManagement REST API, queries the table endpoint to discover + the source file and caslib, then loads and promotes the table. + + A CAS session must already be established by the caller, eg: + + cas mysess; + libname mylib cas caslib=Public; + %mv_castabload(lib=mylib, table=BASEBALL) + + @param [in] lib= SAS libref for the CAS caslib + @param [in] table= Name of the CAS table to load + @param [in] mdebug= (0) Set to 1 to enable verbose logging: + - echoes resolved parameters + - prints tableExists result + - 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_castabload( + lib= + ,table= + ,mdebug=0 +); + +%local _sysopts base_uri caslib uuid server + srcfile srccaslib fname1 libref1 ftmp i _svcount _exists; +%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'); + %if &mdebug=1 %then %do; + putlog sysname sysvalue; + %end; +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) +) + +/* ---- existence check --------------------------------------------------- */ +proc cas; + table.tableExists result=r / + caslib="&caslib" + name="&table"; + %if &mdebug=1 %then %do; + print r; + %end; + if r.exists > 0 then call symputx('_exists', '1', 'L'); + else call symputx('_exists', '0', 'L'); +quit; + +/* ---- already loaded: skip ---------------------------------------------- */ +%if &_exists=1 %then %do; + %put NOTE: Table &caslib..&table already loaded - skipping; + %return; +%end; + +/* ---- get list of CAS servers ----------------------------------------- */ +%let base_uri=%mf_getplatform(VIYARESTAPI); +%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 source file 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 a source file registered?) +) +%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 &=srccaslib; + +/* ---- load from discovered source -------------------------------------- */ +proc casutil; + load casdata="&srcfile" + incaslib="&srccaslib" + casout="&table" + outcaslib="&caslib" + promote; +quit; + +%mp_abort( + iftrue=(&syscc ne 0), + msg=%str(Load failed for &caslib..&table) +) + +%put NOTE: Table &caslib..&table loaded and promoted from &srcfile; + +/* ---- restore options --------------------------------------------------- */ +%if &mdebug=1 %then %do; + options &_sysopts; +%end; + +%mend mv_castabload; +/** + @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; +/** @file @brief Creates a file in SAS Drive using the API method @details Creates a file in SAS Drive using the API interface. diff --git a/base/mp_assert.sas b/base/mp_assert.sas index 86fdf56..e30e387 100644 --- a/base/mp_assert.sas +++ b/base/mp_assert.sas @@ -52,5 +52,6 @@ run; proc sql; drop table &ds; + quit; %mend mp_assert; \ No newline at end of file diff --git a/base/mp_assertscope.sas b/base/mp_assertscope.sas index 213b146..5e24c93 100644 --- a/base/mp_assertscope.sas +++ b/base/mp_assertscope.sas @@ -92,6 +92,7 @@ from dictionary.macros where scope="&scope" and upcase(name) not in (%mf_getquotedstr(&ilist)) order by name,offset; + quit; %end; %else %if &action=COMPARE %then %do; @@ -129,7 +130,6 @@ %let test_comments=%str(Mod:(&mod) Add:(&add) Del:(&del)); %end; - data ; length test_description $256 test_result $4 test_comments $256; test_description=symget('desc'); @@ -142,6 +142,7 @@ run; proc sql; drop table &ds; + quit; %end; %mend mp_assertscope; 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/mfv_existsashdat.test.sas b/tests/viyaonly/mfv_existsashdat.test.sas new file mode 100644 index 0000000..1cf5dde --- /dev/null +++ b/tests/viyaonly/mfv_existsashdat.test.sas @@ -0,0 +1,74 @@ +/** + @file + @brief Testing mfv_existsashdat macro function + +

SAS Macros

+ @li mf_uid.sas + @li mfv_existsashdat.sas + @li mp_assert.sas + @li mp_assertscope.sas + +**/ + +options mprint; + +/* ------------------------------------------------------------------------ */ +/* Setup: start a CAS session and stage a sashdat file in the Public caslib */ +/* ------------------------------------------------------------------------ */ +cas mysess; +caslib _all_ assign; + +%let testcaslib = Public; /* change this if Public isn't available */ +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(); + +proc casutil; + load data=sashelp.baseball outcaslib="&testcaslib" casout="&tab1" replace; + save casdata="&tab1" incaslib="&testcaslib" + casout="&tab1..sashdat" outcaslib="&testcaslib" replace; + droptable casdata="&tab1" incaslib="&testcaslib" quiet; +quit; + + +/* ------------------------------------------------------------------------ */ +%put TEST 1 - returns 1 when the sashdat file exists in the caslib; +/* ------------------------------------------------------------------------ */ +%mp_assert( + iftrue=(%mfv_existsashdat(&testcaslib..&tab1)=1), + desc=Test 1 - Check returns 1 for a sashdat that exists +) + +/* ------------------------------------------------------------------------ */ +%put TEST 2 - returns 0 when the file does not exist in the caslib; +/* ------------------------------------------------------------------------ */ +%mp_assertscope(SNAPSHOT) +%mp_assert( + iftrue=(%mfv_existsashdat(&testcaslib..DOESNOTEXIST_%mf_uid())=0), + desc=Check returns 0 for a sashdat that does not exist +) +%mp_assertscope(COMPARE, + desc=Check mfv_existsashdat does not leak macro variables into GLOBAL scope +) + +/* ------------------------------------------------------------------------ */ +/* Teardown */ +/* ------------------------------------------------------------------------ */ +proc casutil; + deletesource casdata="&tab1..sashdat" incaslib="&testcaslib" quiet; +quit; + +cas mysess terminate; + +%let syscc=0; diff --git a/tests/viyaonly/mfv_getcaslib.test.sas b/tests/viyaonly/mfv_getcaslib.test.sas new file mode 100644 index 0000000..da70f0e --- /dev/null +++ b/tests/viyaonly/mfv_getcaslib.test.sas @@ -0,0 +1,69 @@ +/** + @file + @brief Testing mfv_getcaslib macro function + +

SAS Macros

+ @li mfv_getcaslib.sas + @li mp_assert.sas + @li mp_assertscope.sas + +**/ + +options mprint; + +/* ------------------------------------------------------------------------ */ +/* Setup: start a CAS session and assign caslibs */ +/* ------------------------------------------------------------------------ */ +cas mysess; +caslib _all_ assign; + +%let testcaslib=Public; + +libname castest cas caslib=&testcaslib; + +/* ------------------------------------------------------------------------ */ +%put TEST 1 - returns the caslib name for a valid CAS libref; +/* ------------------------------------------------------------------------ */ +%mp_assert( + iftrue=(%mfv_getcaslib(castest)=%upcase(&testcaslib)), + desc=Check correct caslib name returned for a valid CAS libref +) + + +/* ------------------------------------------------------------------------ */ +%put TEST 2 - returns empty for a non-CAS libref (WORK); +/* ------------------------------------------------------------------------ */ +%mp_assert( + iftrue=(%mfv_getcaslib(WORK)=), + desc=Check empty string returned for a non-CAS libref +) + + +/* ------------------------------------------------------------------------ */ +%put TEST 3 - returns empty for a libref that does not exist; +/* ------------------------------------------------------------------------ */ +%mp_assert( + iftrue=(%mfv_getcaslib(DOESNOTEXIST)=), + desc=Check empty string returned for a non-existent libref +) + + +/* ------------------------------------------------------------------------ */ +%put TEST 5 - no scope leakage into global macro variables; +/* ------------------------------------------------------------------------ */ +%mp_assertscope(SNAPSHOT) + +%let _rc=%mfv_getcaslib(castest); + +%mp_assertscope(COMPARE, + desc=Check mfv_getcaslib does not leak macro variables into GLOBAL scope, + ignorelist=_RC +) + + +/* ------------------------------------------------------------------------ */ +/* Teardown */ +/* ------------------------------------------------------------------------ */ +cas mysess terminate; + + diff --git a/tests/viyaonly/mv_castabload.test.sas b/tests/viyaonly/mv_castabload.test.sas new file mode 100644 index 0000000..6c6d3f9 --- /dev/null +++ b/tests/viyaonly/mv_castabload.test.sas @@ -0,0 +1,152 @@ +/** + @file + @brief Testing mv_castabload macro + +

SAS Macros

+ @li mf_uid.sas + @li mp_assert.sas + @li mp_assertscope.sas + @li mv_castabload.sas + +**/ + +options mprint; + +/* -------------------------------------------------------------------- */ +/* Setup: start a CAS session and stage a source file in the caslib */ +/* -------------------------------------------------------------------- */ +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(); + +/* Save a sashdat source file then drop the in-memory copy so the first + mv_castabload call has something to load */ +proc casutil; + load data=sashelp.baseball + outcaslib="&testcaslib" casout="&tab1" replace; + save casdata="&tab1" incaslib="&testcaslib" + casout="&tab1..sashdat" outcaslib="&testcaslib" replace; + droptable casdata="&tab1" incaslib="&testcaslib" quiet; +quit; + +libname mylib cas caslib="&testcaslib"; + + +/* -------------------------------------------------------------------- */ +%put TEST 1 - load a table that is not in memory; +/* -------------------------------------------------------------------- */ + +/* Confirm table is absent before the call */ +%let _tabexists=0; +proc cas; + table.tableExists result=r / + caslib="&testcaslib" name="&tab1"; + if r.exists > 0 then call symputx('_tabexists','1'); +quit; + +%mp_assert( + iftrue=(&_tabexists=0), + desc=Check table is not in memory before mv_castabload +) + +%mv_castabload(lib=mylib, table=&tab1, mdebug=1) + +%let _tabexists=0; +proc cas; + table.tableExists result=r / + caslib="&testcaslib" name="&tab1"; + if r.exists > 0 then call symputx('_tabexists','1'); +quit; + +%mp_assert( + iftrue=(&_tabexists=1), + desc=Check table is in memory after mv_castabload +) + + +/* -------------------------------------------------------------------- */ +%put TEST 2 - reload fetches a fresh copy and discards in-memory changes; +/* -------------------------------------------------------------------- */ + +/* Append a sentinel row to the in-memory table */ +data work.extra; + set mylib.&tab1; + name='TESTROW'; + output; + stop; +run; +proc casutil; + load data=work.extra casout="&tab1" + outcaslib="&testcaslib" append; +quit; + +%let _modified=0; +proc sql noprint; + select count(*) into :_modified + from mylib.&tab1 + where name='TESTROW'; +quit; + +%mp_assert( + iftrue=(&_modified=1), + desc=Check sentinel row is present in memory before reload +) + +/* Drop the table and reload - source file does not have the sentinel */ +proc casutil; + droptable casdata="&tab1" incaslib="&testcaslib" quiet; + droptable casdata="&tab1" incaslib="&testcaslib" quiet; +quit; + +%mp_assertscope(SNAPSHOT) + +%mv_castabload(lib=mylib, table=&tab1, mdebug=1) + +%mp_assertscope(COMPARE, + desc=Check mv_castabload does not leak macro variables into GLOBAL scope +) + +%let _after=0; +proc sql noprint; + select count(*) into :_after + from mylib.&tab1 + where name='TESTROW'; +quit; + +%mp_assert( + iftrue=(&_after=0), + desc=Check sentinel row is absent after reload from source +) + + +/* -------------------------------------------------------------------- */ +/* Teardown */ +/* -------------------------------------------------------------------- */ +libname mylib clear; + +proc casutil; + droptable casdata="&tab1" incaslib="&testcaslib" quiet; + droptable casdata="&tab1" incaslib="&testcaslib" quiet; + deletesource casdata="&tab1..sashdat" + incaslib="&testcaslib" quiet; +quit; + +cas mysess terminate; + +%let syscc=0; 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/mfv_existsashdat.sas b/viya/mfv_existsashdat.sas index 5a256c9..65be9e3 100644 --- a/viya/mfv_existsashdat.sas +++ b/viya/mfv_existsashdat.sas @@ -6,12 +6,12 @@ %if %mfv_existsashdat(libds=casuser.sometable) %then %put yes it does!; The function uses `dosubl()` to run the `table.fileinfo` action, for the - specified library, filtering for `*.sashdat` tables. The results are stored - in a WORK table (&outprefix._&lib). If that table already exists, it is - queried instead, to avoid the dosubl() performance hit. + specified library, filtering for `*.sashdat` tables. - To force a rescan, just use a new `&outprefix` value, or delete the table(s) - before running the function. + Results are cached in a WORK table (&outprefix._&lib). If that table + already exists it is queried directly to avoid the dosubl() overhead. + To force a rescan, use a new `&outprefix` value or delete the cache + table before calling. @param [in] libds library.dataset @param [out] outprefix= (work.mfv_existsashdat) @@ -24,13 +24,12 @@ @author Mathieu Blauw **/ -%macro mfv_existsashdat(libds,outprefix=work.mfv_existsashdat -); +%macro mfv_existsashdat(libds,outprefix=work.mfv_existsashdat); %local rc dsid name lib ds; %let lib=%upcase(%scan(&libds,1,'.')); %let ds=%upcase(%scan(&libds,-1,'.')); -/* if table does not exist, create it */ +/* if cache table does not exist, build it */ %if %sysfunc(exist(&outprefix._&lib)) ne 1 %then %do; %let rc=%sysfunc(dosubl(%nrstr( /* Read in table list (once per &lib per session) */ @@ -41,7 +40,7 @@ quit; /* Only keep name, without file extension */ data &outprefix._&lib; - set &outprefix._&lib(where=(Name like '%.sashdat') keep=Name); + set &outprefix._&lib(where=(upcase(Name) like '%.SASHDAT') keep=Name); Name=upcase(scan(Name,1,'.')); run; ))); diff --git a/viya/mfv_getcaslib.sas b/viya/mfv_getcaslib.sas new file mode 100644 index 0000000..e95693f --- /dev/null +++ b/viya/mfv_getcaslib.sas @@ -0,0 +1,37 @@ +/** + @file mfv_getcaslib.sas + @brief Returns the CAS caslib name for a given SAS libref + @details Pure macro function. Reads sashelp.vlibnam and returns + the sysvalue where sysname='Caslib' for the given libref. This + is useful when the caslib name and libref name may differ. + + Usage: + + %put %mfv_getcaslib(lib=PUBLIC); + + @param [in] lib SAS libref for which to return the CAS caslib name + + @return Returns the CAS caslib name, or empty string if not found + +**/ + +%macro mfv_getcaslib(lib); + +%local dsid rc result; + +%let dsid=%sysfunc(open(sashelp.vlibnam( + where=(libname="%upcase(&lib)" and sysname="Caslib") +))); + +%if &dsid %then %do; + %let rc=%sysfunc(fetch(&dsid)); + %if &rc=0 %then + %let result=%sysfunc( + getvarc(&dsid,%sysfunc(varnum(&dsid,SYSVALUE))) + ); + %let rc=%sysfunc(close(&dsid)); +%end; + +&result + +%mend mfv_getcaslib; diff --git a/viya/mv_castabload.sas b/viya/mv_castabload.sas new file mode 100644 index 0000000..4bdb13a --- /dev/null +++ b/viya/mv_castabload.sas @@ -0,0 +1,207 @@ +/** + @file mv_castabload.sas + @brief Checks if a CAS table is loaded; if not, loads and promotes it + @details Runs in SPRE against an active CAS session. Accepts a + SAS libref, derives the CAS caslib and session UUID from + sashelp.vlibnam, then checks whether the table is already + in-memory. If not, locates the owning CAS server via the + casManagement REST API, queries the table endpoint to discover + the source file and caslib, then loads and promotes the table. + + A CAS session must already be established by the caller, eg: + + cas mysess; + libname mylib cas caslib=Public; + %mv_castabload(lib=mylib, table=BASEBALL) + + @param [in] lib= SAS libref for the CAS caslib + @param [in] table= Name of the CAS table to load + @param [in] mdebug= (0) Set to 1 to enable verbose logging: + - echoes resolved parameters + - prints tableExists result + - 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_castabload( + lib= + ,table= + ,mdebug=0 +); + +%local _sysopts base_uri caslib uuid server + srcfile srccaslib fname1 libref1 ftmp i _svcount _exists; +%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'); + %if &mdebug=1 %then %do; + putlog sysname sysvalue; + %end; +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) +) + +/* ---- existence check --------------------------------------------------- */ +proc cas; + table.tableExists result=r / + caslib="&caslib" + name="&table"; + %if &mdebug=1 %then %do; + print r; + %end; + if r.exists > 0 then call symputx('_exists', '1', 'L'); + else call symputx('_exists', '0', 'L'); +quit; + +/* ---- already loaded: skip ---------------------------------------------- */ +%if &_exists=1 %then %do; + %put NOTE: Table &caslib..&table already loaded - skipping; + %return; +%end; + +/* ---- get list of CAS servers ----------------------------------------- */ +%let base_uri=%mf_getplatform(VIYARESTAPI); +%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 source file 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 a source file registered?) +) +%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 &=srccaslib; + +/* ---- load from discovered source -------------------------------------- */ +proc casutil; + load casdata="&srcfile" + incaslib="&srccaslib" + casout="&table" + outcaslib="&caslib" + promote; +quit; + +%mp_abort( + iftrue=(&syscc ne 0), + msg=%str(Load failed for &caslib..&table) +) + +%put NOTE: Table &caslib..&table loaded and promoted from &srcfile; + +/* ---- restore options --------------------------------------------------- */ +%if &mdebug=1 %then %do; + options &_sysopts; +%end; + +%mend mv_castabload; 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;