Break the process into steps to figure out which one is causing the issue.
Expand the XLSX file into actual files (using UNZIP).
Rebuild it into a new file (using ZIP).
Does it still work? If not then perhaps the changes are not compatible with what Excel wants.
Then introduce making the changes.
Try making the changes without SAS.
Try making the changes with SAS, but only one file at a time (perhaps it is the MEMNAME= file option that is confused).
Then try making the changes in the original XLSX file. Perhaps it is the ZIP engine.
Break the process into steps to figure out which one is causing the issue.
That was the first thing I tried. No luck. No combination of run order of the steps separated out individually works. Each combination of steps all produce one kind of error or another, typically the CRC check failure but sometimes "file is not a zip file" also occurs. In fact, the reason I combined all the update steps and used a Memvar was to see if somehow the CRC error might be resolved if all updates/adds were in one Data step. No luck; neither individual Data steps nor a combined all-in-one Data step worked.
Expand the XLSX file into actual files (using UNZIP).
Rebuild it into a new file (using ZIP).
Does it still work? If not then perhaps the changes are not compatible with what Excel wants.
I can't expand the xlsx file if updates have been applied. Well, I can open it, but many of the component XML files inside get a CRC error and cannot be opened.
However, I created a xlsx file using the SASHELP.Class data set and Proc Export. I unzipped everything and manually applied updates to the XML using ordinary Windows Notepad. After the manual updates, I zipped everything up. Everything worked fine. The resultant xlsx file opens in Excel, displays the Class data correctly, and has the "External" label applied correctly. This would seem to confirm that the SAS zip engine is having some kind of problem.
I've also, using SAS, made one change at a time. If I do only one of the four changes, it works fine at least in the sense that I can see the expected XML in the zip archive. However, if I try to do multiple updates, no matter what order I do them in, I get CRC errors. Individually, they work fine. Taken together, Excel cannot open the file, and I can't unzip the XML components inside the xlsx due to CRC errors.
Everything seems to be pointing to a SAS zip engine error.
Jim
Edit: I have a sneaking suspicion that SAS may be just updating the files inside the Zip archive without re-calculating the checksums, possibly causing the apparent out-of-sync type messages I'm getting that state that the central and local directory have CRC-32 check sums that are different. I haven't found it yet, but if there were a way to force the SAS Zip engine to recalculate all the CRC-32 check sums, that might correct the problem. I'm sure that WinZip calculates all the CRC-32 check sums afresh when I zip everything up.
I suppose, since it at least appears to be a SAS Zip engine issue, that I ought to open a new thread. I can't really address the ODS issue -- which I think is essentially solved -- until the Zip issue is resolved.
Jim
@jimbarbour wrote:
I suppose, since it at least appears to be a SAS Zip engine issue, that I ought to open a new thread. I can't really address the ODS issue -- which I think is essentially solved -- until the Zip issue is resolved.
Jim
If you have an reproducible issue with the ZIP engine then open a ticket with SAS support and see if they can figure out waht is happening. They might know of a work around, or at least it gets on their list of known issues and so might get fixed in the future.
@Tom wrote:If you have an reproducible issue with the ZIP engine then open a ticket with SAS support and see if they can figure out waht is happening. They might know of a work around, or at least it gets on their list of known issues and so might get fixed in the future.
I just created a thread specific to the Zip engine issue. I'm hoping that someone may have already encountered this and already have a work-around. If not, then opening a support ticket is definitely the next step.
Jim
I was able to create a work-around for the SAS Zip engine CRC-32 check sum problems. See https://communities.sas.com/t5/SAS-Programming/SAS-Zip-Engine-Problem-CRC32-Checksum-Error/m-p/84291...
Things are now working. I'll post code once I get things cleaned up and organized.
Jim
@Tom, @ballardw, @ChrisHemedinger, and @Kurt_Bremser
If it is of continued interest, I wrote a macro to set the custom properties in Excel. I was able to get around the CRC-32 checksum issue that I was having by extracting all the embedded xml files inside the xlsx file, changing the xml as needed, and then zipping all the updated xml into a replacement xlsx file.
My macro sets the "Sensitivity" custom property to either Internal or External. It requires a file_name, path, and label value (Internal or External). Example: %set_sensitivity_label(&Path, &File_Name, Label=External);
Code is below. There are also two subordinate macros, one to extract the xml members from the original xlsx and another to add the updated xml members to the new xlsx.
I hope it is of interest. The custom property itself may not be interesting, but an example of how to extract, update, and replace xlsx components might be useful.
Jim
/*------------------------------------------------------------------------------*/
/* Macro: set_sensitivity_label
/* Author: Jim Barbour
/* Date: 08 November 2022
/* Info: This macro will set the sensitivity label on an Excel xlsx file to
/* either "Internal" or "External".
/*
/* The sensitivity label is an Excel custom property that is required
/* for all Excel xlsx files. This macro will set the label to either
/* "Internal" or "External" based on the arguments passed in when the
/* macro is called. If the Excel xlsx file does not exist, this macro
/* will create a skeleton file for you. When you populate the file
/* with actual data, make sure that you do not delete or overlay the
/* skeleton. For example, on Proc Export, code the REPLACE option.
/* If you cannot run without deleting or overlaying the skeleton, then
/* run this macro *after* you populate your Excel xlsx file.
/* If this macro creates the Excel xlsx file, a sheet named "Placehoder"
/* will be created. You may delete this sheet at your convenience.
/*------------------------------------------------------------------------------*/
/* CHANGE LOG
/*------------------------------------------------------------------------------*/
/* Name: Jim Barbour Date: 08 November 2022
/* Info: Original implementation.
/*------------------------------------------------------------------------------*/
%MACRO set_sensitivity_label(Path /* The Path to your Excel xlsx file. */,
File_Name /* The name of your Excel xlsx file. */,
Label=Internal /* Either Internal or External. */,
Debug=NO /* YES=on NO=off for debug logging. */,
Width=42 /* Sets the display width for logging. */
);
%LOCAL Slash;
%LOCAL Path_File;
%LOCAL lCmnt;
%LOCAL i;
%LOCAL Nte1;
%LOCAL Nte2;
%LOCAL Warn1;
%LOCAL Warn2;
%LOCAL Err1;
%LOCAL Err2;
%LET Nte1 = NOTE:;
%LET Nte2 = NOTE-;
%LET Warn1 = WARNING:;
%LET Warn2 = WARNING-;
%LET Err1 = ERROR:;
%LET Err2 = ERROR-;
OPTION DLCREATEDIR;
%IF %QUPCASE(&Debug) = YES %THEN
%DO;
%PUT &Nte1 Debug is ON in macro &SYSMACRONAME;
%LET lCmnt = ;
%END;
%ELSE
%DO;
%LET lCmnt = *;
%END;
%IF %BQUOTE(&Label) ^= %BQUOTE() %THEN
%DO;
%IF %QUPCASE(&Label) = INTERNAL OR
%QUPCASE(&Label) = EXTERNAL %THEN
%DO;
%&lCmnt.PUT &Nte1 Setting Sensitivity Label to &Label;
%END;
%ELSE
%DO;
%IF &SYSCC < 4 %THEN
%DO;
%LET SYSCC = 4;
%END;
%PUT &Warn1 Invalid value for Sensitivity Label: &Label;
%PUT &Warn2 Defaulting to Internal;
%LET Label = Internal;
%END;
%END;
%ELSE
%DO;
%PUT &Nte1 Sensitivity Label is blank.;
%PUT &Nte2 Defaulting Sensitivity Label to Internal;
%LET Label = Internal;
%END;
%** Set slash to a back slash for Windows or a forward slash for LINUX/UNIX **;
%LET Slash = %SYSFUNC(IFC(%EVAL(&SYSSCP = WIN),\,/));
%LET Path_File = &Path.&Slash&File_Name;
%** If the file does not exist, create the file. **;
%IF %SYSFUNC(FILEEXIST(&Path_File)) %THEN
%DO;
%PUT &Nte1 File &Path_File already exists, so a file was not created.;
%END;
%ELSE
%DO;
%PUT &Nte1 Creating an XLSX skeleton file as follows: &Path_File;
DATA WORK.Skeleton;
Placeholder = ' ';
RUN;
PROC EXPORT DATA=WORK.Skeleton FILE="&Path_File" REPLACE DBMS=xlsx;
SHEET = 'Placeholder';
RUN;
PROC DELETE DATA=WORK.Skeleton;
%END;
%IF NOT %SYSFUNC(LIBREF(XLSX_In)) %THEN
%DO;
FILENAME XLSX_In CLEAR;
%END;
%&lCmnt.PUT &Nte1 Assigning filename &Path_File;
FILENAME XLSX_In ZIP "&Path_File";
%** Read the internal contents of the XLSX file and create a macro array. **;
DATA _NULL_;
LENGTH MemName $200;
FID = DOPEN("XLSX_In");
IF NOT FID THEN
STOP;
MemCount = DNUM(FID);
DO _i = 1 TO MemCount;
MemName = DREAD(FID, _i);
CALL SYMPUTX(CATS("Member", _i), STRIP(MemName), 'G');
END;
CALL SYMPUTX('MemCount', STRIP(PUT(MemCount, 3.)), 'G');
_RC = DCLOSE(FID);
RUN;
%** Iterate through the macro array and extract the internal XML components **;
%DO i = 1 %TO &MemCount;
%&lCmnt.PUT &Nte1 &=i Extracting &&Member&i;
%Extract_Member(&&Member&i, Debug=&Debug);
%END;
%**-------------------------------------------------------------------------**;
%** Relationships **;
%** Read in the .rels relationships file, add a reference to custom.xml, **;
%** and write out a file containing the modified .rels xml **;
%**-------------------------------------------------------------------------**;
%&lCmnt.PUT &Nte1 Creating %SYSFUNC(GETOPTION(WORK))&Slash._rels&Slash..rels;
FILENAME XML_Out "%SYSFUNC(GETOPTION(WORK))&Slash._rels&Slash..rels";
DATA _NULL_;
RETAIN _No_Custom_XML 1;
FILE XML_Out;
INFILE XLSX_In(_rels/.rels) LENGTH=SAS_Length;
INPUT Line $VARYING32767. SAS_Length;
IF INDEX(UPCASE(Line),'CUSTOM.XML') THEN
DO;
&lCmnt.PUTLOG "&Nte1 Custom.xml already exists _rels/.rels";
_No_Custom_XML = 0;
END;
_Position = FIND(Line, '</relationships>', 'i');
IF _Position AND
_No_Custom_XML THEN
DO;
&lCmnt.PUTLOG "&Nte1 Adding custom.xml entry to _rels&Slash..rels";
SUBSTR(Line,_Position) = '<Relationship Id="rId4" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom-properties" Target="docProps/custom.xml"/></Relationships>';
END;
PUT Line;
RUN;
%**-------------------------------------------------------------------------**;
%** Content Types **;
%** Read in the Content Types XML file, add a reference to custom.xml, and **;
%** write out a file containing the modified Content Types XML. **;
%**-------------------------------------------------------------------------**;
%&lCmnt.PUT &Nte1 Creating %SYSFUNC(GETOPTION(WORK))&Slash.[Content_Types].xml;
FILENAME XML_Out "%SYSFUNC(GETOPTION(WORK))&Slash.[Content_Types].xml";
DATA _NULL_;
RETAIN _No_Custom_XML 1;
FILE XML_Out;
INFILE XLSX_In([Content_Types].xml) LENGTH=SAS_Length;
INPUT Line $VARYING32767. SAS_Length;
IF INDEX(UPCASE(Line),'CUSTOM.XML') THEN
DO;
&lCmnt.PUTLOG "&Nte1 Custom.xml already exists in Content Types.";
_No_Custom_XML = 0;
END;
_Position = FIND(Line, '</Types>', 'i');
IF _Position AND
_No_Custom_XML THEN
DO;
&lCmnt.PUTLOG "&Nte1 Adding custom.xml entry to [Content_Types].xml";
SUBSTR(Line,_Position) = '<Override PartName="/docProps/custom.xml" ContentType="application/vnd.openxmlformats-officedocument.custom-properties+xml"/></Types>';
END;
PUT Line;
RUN;
%**-------------------------------------------------------------------------**;
%** Custom.XML **;
%** Create a file that contains the custom XML that will set the **;
%** label to either External or Internal **;
%**-------------------------------------------------------------------------**;
OPTIONS NOQUOTELENMAX;
%&lCmnt.PUT &Nte1 Creating %SYSFUNC(GETOPTION(WORK))&Slash.docProps&Slash.custom.xml;
FILENAME XML_Out "%SYSFUNC(GETOPTION(WORK))&Slash.docProps&Slash.custom.xml";
DATA _NULL_;
FILE XML_Out;
PUT '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
%IF %QUPCASE(&Label) = EXTERNAL %THEN
%DO;
&lCmnt.PUTLOG "&Nte1 Setting Label to External in custom.xml";
PUT '<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/custom-properties" xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes"><property fmtid="{D5CDD505-2E9C-101B-9397-08002B2CF9AE}" pid="2" name="MSIP_Label_320f21ee-9bdc-4991-8abe-58f53448e302_Enabled"><vt:lpwstr>true</vt:lpwstr></property><property fmtid="{D5CDD505-2E9C-101B-9397-08002B2CF9AE}" pid="3" name="MSIP_Label_320f21ee-9bdc-4991-8abe-58f53448e302_SetDate"><vt:lpwstr>2022-11-05T21:48:26Z</vt:lpwstr></property><property fmtid="{D5CDD505-2E9C-101B-9397-08002B2CF9AE}" pid="4" name="MSIP_Label_320f21ee-9bdc-4991-8abe-58f53448e302_Method"><vt:lpwstr>Privileged</vt:lpwstr></property><property fmtid="{D5CDD505-2E9C-101B-9397-08002B2CF9AE}" pid="5" name="MSIP_Label_320f21ee-9bdc-4991-8abe-58f53448e302_Name"><vt:lpwstr>External Label</vt:lpwstr></property><property fmtid="{D5CDD505-2E9C-101B-9397-08002B2CF9AE}" pid="6" name="MSIP_Label_320f21ee-9bdc-4991-8abe-58f53448e302_SiteId"><vt:lpwstr>db05faca-c82a-4b9d-b9c5-0f64b6755421</vt:lpwstr></property><property fmtid="{D5CDD505-2E9C-101B-9397-08002B2CF9AE}" pid="7" name="MSIP_Label_320f21ee-9bdc-4991-8abe-58f53448e302_ActionId"><vt:lpwstr>6d6ffa67-7505-4457-b8c1-4592fb83e3c9</vt:lpwstr></property><property fmtid="{D5CDD505-2E9C-101B-9397-08002B2CF9AE}" pid="8" name="MSIP_Label_320f21ee-9bdc-4991-8abe-58f53448e302_ContentBits"><vt:lpwstr>0</vt:lpwstr></property></Properties>';
%END;
%ELSE
%DO;
&lCmnt.PUTLOG "&Nte1 Setting Label to Internal in custom.xml";
PUT '<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/custom-properties" xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes"><property fmtid="{D5CDD505-2E9C-101B-9397-08002B2CF9AE}" pid="2" name="MSIP_Label_a8a73c85-e524-44a6-bd58-7df7ef87be8f_Enabled"><vt:lpwstr>true</vt:lpwstr></property><property fmtid="{D5CDD505-2E9C-101B-9397-08002B2CF9AE}" pid="3" name="MSIP_Label_a8a73c85-e524-44a6-bd58-7df7ef87be8f_SetDate"><vt:lpwstr>2022-11-09T06:26:58Z</vt:lpwstr></property><property fmtid="{D5CDD505-2E9C-101B-9397-08002B2CF9AE}" pid="4" name="MSIP_Label_a8a73c85-e524-44a6-bd58-7df7ef87be8f_Method"><vt:lpwstr>Privileged</vt:lpwstr></property><property fmtid="{D5CDD505-2E9C-101B-9397-08002B2CF9AE}" pid="5" name="MSIP_Label_a8a73c85-e524-44a6-bd58-7df7ef87be8f_Name"><vt:lpwstr>Internal Label</vt:lpwstr></property><property fmtid="{D5CDD505-2E9C-101B-9397-08002B2CF9AE}" pid="6" name="MSIP_Label_a8a73c85-e524-44a6-bd58-7df7ef87be8f_SiteId"><vt:lpwstr>db05faca-c82a-4b9d-b9c5-0f64b6755421</vt:lpwstr></property><property fmtid="{D5CDD505-2E9C-101B-9397-08002B2CF9AE}" pid="7" name="MSIP_Label_a8a73c85-e524-44a6-bd58-7df7ef87be8f_ActionId"><vt:lpwstr>70034016-ca32-40c6-a2a6-65d62c0d22d6</vt:lpwstr></property><property fmtid="{D5CDD505-2E9C-101B-9397-08002B2CF9AE}" pid="8" name="MSIP_Label_a8a73c85-e524-44a6-bd58-7df7ef87be8f_ContentBits"><vt:lpwstr>0</vt:lpwstr></property></Properties>';
%END;
RUN;
%LET No_Custom_XML = 1;
%** Open an ODS Package that will be used to create a new XLSX file that will replace the original XLSX file. **;
ODS PACKAGE(New_XLSX) OPEN NOPF;
%DO i = 1 %TO &MemCount;
%IF %SYSFUNC(INDEX(%UPCASE(&&Member&i),CUSTOM.XML)) %THEN
%DO;
%&lCmnt.PUT &Nte1 Custom.xml already exists in the macro array.;
%LET No_Custom_XML = 0;
%END;
%Add_Member(&&Member&i);
%END;
%** Add new XML file for custom properties if it does not already exist. **;
%IF &No_Custom_XML %THEN
%DO;
%&lCmnt.PUT &Nte1 Adding new Custom.xml to XLSX file.;
%Add_Member(docProps/custom.xml);
%END;
%** Create the new XLSX file that will replace the original XLSX file. **;
ODS PACKAGE(New_XLSX) PUBLISH ARCHIVE
PROPERTIES(
ARCHIVE_NAME = "&File_Name"
ARCHIVE_PATH = "&Path"
);
ODS PACKAGE(New_XLSX) CLOSE;
%PUT &Nte2;
%PUT &Nte2 +---------------------------------------------------------------------------------+;
%PUT &Nte1 | Label has been set to &Label for &File_Name;
%PUT &Nte2 | --- End of macro &SYSMACRONAME ---;
%PUT &Nte2 +---------------------------------------------------------------------------------+;
%PUT &Nte2;
%MEND set_sensitivity_label;
/*------------------------------------------------------------------------------*/
/* Macro: extract_member
/* Author: Jim Barbour
/* Date: 08 November 2022
/* Info: This macro extracts the internal XML compoents from an xlsx file.
/* This macro should be called from macro set_sensitivity_level.
/*------------------------------------------------------------------------------*/
/* CHANGE LOG
/*------------------------------------------------------------------------------*/
/* Name: Jim Barbour Date: 08 November 2022
/* Info: Original implementation.
/*------------------------------------------------------------------------------*/
%MACRO extract_member(Member, Debug=NO);
%LOCAL j;
%LOCAL lCmnt;
%IF %QUPCASE(&Debug) = YES %THEN
%DO;
%PUT &Nte1 Debug is ON in macro &SYSMACRONAME;
%LET lCmnt = ;
%END;
%ELSE
%DO;
%LET lCmnt = *;
%END;
%** Parse out the directory portion of each Member. **;
%LET Slash_Count = %SYSFUNC(COUNT(&Member,/));
%LET Last_Slash = %SYSFUNC(FINDC(%SYSFUNC(REVERSE(&Member)),/));
%LET Member_Length = %SYSFUNC(LENGTHN(&Member));
%LET Dir_End = %EVAL(&Member_Length - &Last_Slash);
%LET Save_Dir = ;
%&lCmnt.PUT &Nte1 &=Member &=Member_Length &=Slash_Count &=Last_Slash &=Dir_End;
%** Allocate any needed directories using "dummy" Libnames with the DLCREATEDIR option. **;
%IF &Slash_Count > 0 %THEN
%DO j = 1 %TO &Slash_Count;
%&lCmnt.PUT &Nte1 &=j Allocating any needed directories;
%IF NOT %SYSFUNC(LIBREF(Temp_Lib)) %THEN
%DO;
LIBNAME Temp_Lib CLEAR;
%END;
%LET Mem_Dir = %SYSFUNC(SCAN(&Member,&j,/));
%&lCmnt.PUT &Nte1 &=Mem_Dir resulting from scan of &Member for &=j;
%IF &j = 1 %THEN
%DO;
%&lCmnt.PUT &Nte1 &=j (j should be 1 here);
%LET Lib_Path = %SYSFUNC(GETOPTION(WORK))&Slash%SYSFUNC(TRANWRD(&Mem_Dir,/,&Slash));
%END;
%ELSE
%DO;
%&lCmnt.PUT &Nte1 &=j;
%LET Lib_Path = %SYSFUNC(GETOPTION(WORK))&Save_Dir&Slash%SYSFUNC(TRANWRD(&Mem_Dir,/,&Slash));
%END;
%&lCmnt.PUT &Nte1 &=Mem_Dir &=Lib_Path;
LIBNAME Temp_Lib "&Lib_Path";
%LET Save_Dir = &Save_Dir&Slash&Mem_Dir;
%PUT &Nte1 &=Save_Dir;
%END;
%ELSE
%DO;
%LET Mem_Dir = ;
%END;
%&lCmnt.PUT &Nte1 All directories allocated for &=Member;
%** Allocate the output XML file. **;
FILENAME XML_Out "%SYSFUNC(GETOPTION(WORK))&Slash%SYSFUNC(TRANWRD(&Member,/,&Slash))" ;
%&lCmnt.PUT &Nte1 Extracting %SYSFUNC(GETOPTION(WORK))&Slash%SYSFUNC(TRANWRD(&Member,/,&Slash));
%** Extract and write out each XML file embedded in the XLSX file. **;
DATA _NULL_;
INFILE XLSX_In(&Member) LRECL = 256 LENGTH = SAS_Length
RECFM = F EOF = EOF UNBUF;
FILE XML_Out LRECL = 256 RECFM = N;
INPUT;
PUT _INFILE_ $VARYING256. SAS_Length;
RETURN;
EOF:
STOP;
RUN;
%MEND extract_member;
/*------------------------------------------------------------------------------*/
/* Macro: add_member
/* Author: Jim Barbour
/* Date: 08 November 2022
/* Info: This macro adds internal XML components to an xlsx file.
/* This macro should be called from macro set_sensitivity_level.
/*------------------------------------------------------------------------------*/
/* CHANGE LOG
/*------------------------------------------------------------------------------*/
/* Name: Jim Barbour Date: 08 November 2022
/* Info: Original implementation.
/*------------------------------------------------------------------------------*/
%MACRO add_member(Member, Debug=NO);
%LOCAL Slash_Count;
%LOCAL Last_Slash;
%LOCAL Dir_End;
%LOCAL Dir_Path;
%LOCAL lCmnt;
%IF %QUPCASE(&Debug) = YES %THEN
%DO;
%PUT &Nte1 Debug is ON in macro &SYSMACRONAME;
%LET lCmnt = ;
%END;
%ELSE
%DO;
%LET lCmnt = *;
%END;
%LET Slash_Count = %SYSFUNC(COUNT(&Member,/));
%LET Member_Length = %SYSFUNC(LENGTHN(&Member));
%IF &Slash_Count > 0 %THEN
%DO;
%LET Last_Slash = %SYSFUNC(FINDC(%SYSFUNC(REVERSE(&Member)),/));
%LET Dir_End = %EVAL(&Member_Length - &Last_Slash);
%LET Dir_Path = %SYSFUNC(SUBSTR(&Member,1,&Dir_End));
%END;
%ELSE
%DO;
%LET Last_Slash = 0;
%LET Dir_End = 0;
%LET Dir_Path = ;
%END;
%&lCmnt.PUT &Nte1 &=Member &=Member_Length &=Last_Slash &=Dir_End &=Dir_Path;
%DO;
ODS PACKAGE(New_XLSX) ADD FILE="%SYSFUNC(GETOPTION(WORK))&Slash&Member" PATH="&Dir_Path";
%END;
%MEND add_member;
One advantage of having a macro to make changes to a Zip archive is that one can apply bulk changes to a Windows or UNIX/Linux directory. One would iterate through the directory, set the arguments, and call the macro for each Zip file in the directory.
If one needed to apply a mass change to all Excel (or other Zip archives) in a directory, this could be quite handy.
Jim
SAS Innovate 2025 is scheduled for May 6-9 in Orlando, FL. Sign up to be first to learn about the agenda and registration!
Learn how use the CAT functions in SAS to join values from multiple variables into a single value.
Find more tutorials on the SAS Users YouTube channel.
Ready to level-up your skills? Choose your own adventure.