Macros for SAS Application Developers
https://github.com/sasjs/core
Loading...
Searching...
No Matches
mp_derivedvars.sas
Go to the documentation of this file.
1/**
2 @file
3 @brief Generates the SAS statements that create derived variables
4 @details Derived (computed) variables are configured in a control table, one
5 row per variable. The macro writes the statements that create them to a
6 fileref, so that callers can compose them into a view:
7
8 %mp_derivedvars(libds=&libds,control=perm.derived_vars,outref=dercode)
9 data work.vw_derived / view=work.vw_derived;
10 set &libds;
11 %include dercode;
12 run;
13
14 Alternatively pass outview= and the macro will build the view itself.
15
16 Building a view is what makes a derived variable filterable. A WHERE
17 statement applies to the data set named on the SET statement, so it cannot
18 reference a variable created in the same data step:
19
20 data out;
21 set work.vw_derived;
22 where <filter>;
23 run;
24
25 Each variable produces two statements:
26
27 attrib REGION length=$21 label='REGION=<expression>' format=$21.;
28 REGION = <expression>;
29
30 Type and length are declared in the control table, so the result is
31 deterministic.
32
33 VAR_DEPS lists every input the expression uses - physical columns
34 and derived variables alike. The order the statements are generated in is
35 the dependency order (variables with no unsatisfied dependency first, ties
36 broken by name), so a derived variable may build on another one without the
37 administrator maintaining a sequence number. A cycle is rejected.
38
39 The declaration is checked against the expression: a derived variable that
40 is referenced must be declared, a declared name that is not referenced is an
41 error, and a declared name must be a column of the target table or a derived
42 variable defined for it. Semicolons, macro triggers and comments are
43 rejected, so a row can only ever be an expression. A derived variable may
44 not reuse the name of a physical column, and may not reference itself: an
45 intra-row calculation would imply a retained value, which depends on the
46 order rows happen to arrive in.
47
48 VAR_LABEL is optional. When it is missing or blank, the label is the
49 variable name, an equals sign and the expression - so a reader can see both
50 what the column is and where it comes from. A configured label replaces
51 that, and is used as given.
52
53 The control table is created by mddl_derivedvars.sas, and must carry LIBREF,
54 DSN, VAR_NM, VAR_DEPS, VAR_TYPE, VAR_LEN, VAR_FMT and VAR_LOGIC, with
55 VAR_LABEL optional. Extra columns are ignored, but a control table that
56 holds several versions of the same VAR_NM - for example a bitemporal
57 TX_FROM / TX_TO pair - must be narrowed with where= (typically to the
58 current versions), because two rows with the same name are rejected as a
59 configuration error.
60
61 <h4> SAS Macros </h4>
62 @li mf_existds.sas
63 @li mf_existvar.sas
64 @li mf_getuniquename.sas
65 @li mf_getvarlist.sas
66 @li mf_nobs.sas
67 @li mp_abort.sas
68
69 <h4> Related Macros </h4>
70 @li mddl_derivedvars.sas
71
72 @param [in] libds= The target table (libref.dataset, lib must be assigned)
73 @param [in] control= The control table holding the variable definitions
74 @param [in] where= (0) Optional additional filter for the control table
75 @param [out] outref= (0) Fileref to receive the generated statements
76 @param [out] outvars= (work.derived_varlist) Dataset of derived variable names
77 @param [in] outview= (0) Optional two-level name for the derived view
78 @param [in] reserved= (0) Space separated list of names that may not be used
79
80 @version 5.6
81 @author 4GL Apps Ltd
82
83**/
84
85%macro mp_derivedvars(libds=,
86 control=,
87 where=0,
88 outref=0,
89 outvars=work.derived_varlist,
90 outview=0,
91 reserved=0
92);
93
94%local physlist derived_err resv_list i w left nready nplaced
95 cyc_start cyc_cur cyc_next cyc_path cyc_seen cyc_i
96 ds_cfg ds_byname ds_bad ds_baddep ds_dup ds_dep ds_ref ds_stale
97 ds_undecl ds_self ds_edge ds_todo ds_ready ds_placed ds_srt;
98
99/* every working table gets its own name, so a caller with tables of the same
100 name in WORK cannot collide with this macro's intermediates */
101%let ds_cfg=%mf_getuniquename(prefix=dvcfg);
102%let ds_byname=%mf_getuniquename(prefix=dvbyn);
103%let ds_bad=%mf_getuniquename(prefix=dvbad);
104%let ds_baddep=%mf_getuniquename(prefix=dvbdp);
105%let ds_dup=%mf_getuniquename(prefix=dvdup);
106%let ds_dep=%mf_getuniquename(prefix=dvdep);
107%let ds_ref=%mf_getuniquename(prefix=dvref);
108%let ds_stale=%mf_getuniquename(prefix=dvstl);
109%let ds_undecl=%mf_getuniquename(prefix=dvund);
110%let ds_self=%mf_getuniquename(prefix=dvsfl);
111%let ds_edge=%mf_getuniquename(prefix=dvedg);
112%let ds_todo=%mf_getuniquename(prefix=dvtd);
113%let ds_ready=%mf_getuniquename(prefix=dvrdy);
114%let ds_placed=%mf_getuniquename(prefix=dvpld);
115%let ds_srt=%mf_getuniquename(prefix=dvsrt);
116
117%mp_abort(iftrue= (%length(&libds)=0 or %length(&control)=0)
118 ,mac=&sysmacroname
119 ,msg=%str(Missing: libds control)
120)
121%mp_abort(iftrue= (&outref = 0)
122 ,mac=&sysmacroname
123 ,msg=%str(Please provide an outref for the generated statements)
124)
125%mp_abort(iftrue= (%mf_existds(libds=&libds) ne 1)
126 ,mac=&sysmacroname
127 ,msg=%str(Target table not found: &libds)
128)
129
130/* a long label can push a statement past the default record length, which
131 would wrap it and break the generated code */
132filename &outref temp lrecl=32767;
133
134/* ensure the outputs exist, and are empty */
135data _null_;
136 file &outref;
137run;
138data &outvars(keep=name);
139 length name $32;
140 name='';
141 if 0;
142run;
143
144%if %mf_existds(libds=&control) ne 1 %then %do;
145 %put &sysmacroname: &control not found - no derived variables;
146 %return;
147%end;
148
149data work.&ds_cfg.;
150 set &control;
151 /* %superq, because a filter can legitimately contain quotes - a datetime
152 literal such as "21OCT2026:09:00:00"dt is the usual case, and quoting it
153 again inside a %if condition breaks the evaluation */
154 %if %superq(where) ne 0 %then %do;
155 where &where;
156 %end;
157 where also upcase(libref)="%upcase(%scan(&libds,1,.))"
158 and upcase(dsn)="%upcase(%scan(&libds,2,.))";
159 length name $32 logic $4000 fmt $32 deps $4000 lbl $256;
160 name=upcase(strip(var_nm));
161 logic=strip(var_logic);
162 type=upcase(strip(var_type));
163 len=var_len;
164 fmt=strip(upcase(var_fmt));
165 deps=upcase(strip(var_deps));
166 /* VAR_LABEL is optional - older control tables may not have the column */
167 /* mf_existvar returns the variable position, so 0 means absent */
168 %if %mf_existvar(&control,VAR_LABEL) gt 0 %then %do;
169 lbl=translate(strip(var_label),' ','0D0A'x);
170 %end;
171 %else %do;
172 lbl='';
173 %end;
174run;
175
176%if %mf_nobs(work.&ds_cfg.)=0 %then %return;
177
178proc sort data=work.&ds_cfg. out=work.&ds_byname.;
179 by name;
180run;
181
182%let physlist=%upcase(%mf_getvarlist(&libds,dlm=%str(,),quote=SINGLE));
183
184/* build a quoted list of reserved names, or an empty list */
185%let resv_list=;
186%if "&reserved" ne "0" %then %do i=1 %to %sysfunc(countw(&reserved,%str( )));
187 %let w=%scan(&reserved,&i,%str( ));
188 %let resv_list=&resv_list.%str(,)%str(%")%upcase(&w)%str(%");
189%end;
190%if %length(&resv_list)>0 %then %let resv_list=%substr(&resv_list,2);
191
192/**
193 * Reject anything that could break the generated data step, or that would
194 * produce a variable we cannot describe to the caller
195 */
196data work.&ds_bad.;
197 set work.&ds_byname.;
198 length reason $200;
199 call missing(reason);
200
201 if name='' then reason='VAR_NM is required';
202 else if verify(strip(name),'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_')>0
203 then reason='VAR_NM must be a valid SAS name';
204 else if verify(substr(name,1,1),'0123456789')=0
205 then reason='VAR_NM must not start with a digit';
206 %if %length(&physlist)>0 %then %do;
207 else if name in (%unquote(&physlist))
208 then reason='VAR_NM collides with a column in the target table';
209 %end;
210 %if %length(&resv_list)>0 %then %do;
211 else if name in (%unquote(&resv_list))
212 then reason='VAR_NM is reserved';
213 %end;
214 else if type not in ('C','N')
215 then reason='VAR_TYPE must be C or N';
216 else if missing(len) or len ne int(len) or len<1
217 then reason='VAR_LEN must be a positive integer';
218 else if type='C' and len>32767
219 then reason='VAR_LEN exceeds 32767 for a character variable';
220 else if type='N' and len>8
221 then reason='VAR_LEN exceeds 8 for a numeric variable';
222 else if index(fmt,';')>0 or indexc(fmt,'&%')>0
223 or index(fmt,'/*')>0 or index(fmt,'*/')>0
224 then reason='VAR_FMT must be a format name, without ; & % or //*';
225 else if verify(strip(fmt),'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_$.')>0
226 then reason='VAR_FMT must be a format name';
227 else if missing(logic)
228 then reason='VAR_LOGIC is required';
229 else if index(logic,';')>0
230 then reason='VAR_LOGIC must be a single expression - remove the ;';
231 else if indexc(logic,'&%')>0
232 then reason='VAR_LOGIC must not contain macro triggers';
233 else if index(logic,'/*')>0 or index(logic,'*/')>0
234 then reason='VAR_LOGIC must not contain comments';
235
236 if missing(reason)=0 then output;
237run;
238
239data work.&ds_dup.;
240 set work.&ds_byname.;
241 by name;
242 length reason $200;
243 if first.name and not last.name;
244 reason='VAR_NM is defined more than once';
245run;
246
247proc append base=work.&ds_bad. data=work.&ds_dup.;
248run;
249
250/**
251 * The declared dependencies, one row per word
252 */
253data work.&ds_dep.;
254 set work.&ds_byname.;
255 length dep_nm $32;
256 do i=1 to countw(deps,' ');
257 dep_nm=scan(deps,i,' ');
258 if not missing(dep_nm) then output;
259 end;
260 keep name dep_nm;
261run;
262
263/**
264 * The identifiers the expression actually references - quoted strings are
265 * removed first, so a name inside a literal is not mistaken for a reference
266 */
267data work.&ds_ref.;
268 set work.&ds_byname.;
269 length l $4000 tok $32;
270 l=prxchange("s/'([^']|'')*'//",-1,logic);
271 l=prxchange('s/"([^"]|"")*"//',-1,l);
272 l=prxchange('s/[^A-Za-z0-9_]/ /',-1,upcase(l));
273 do i=1 to countw(l,' ');
274 tok=scan(l,i,' ');
275 if not missing(tok) then output;
276 end;
277 keep name tok;
278run;
279
280/* a declared name must resolve - to a column of the target, or to a variable
281 in this configuration */
282proc sql noprint;
283create table work.&ds_baddep. as
284 select d.name, d.dep_nm
285 from work.&ds_dep. d
286 where d.dep_nm not in (select name from work.&ds_byname.)
287 /* a table with no columns leaves physlist empty - fall back to the derived
288 names alone rather than compiling an empty in-list */
289 %if %length(&physlist)>0 %then %do;
290 and d.dep_nm not in (%unquote(&physlist))
291 %end;
292 ;
293
294/* every declared name must actually appear in the expression */
295create table work.&ds_stale. as
296 select d.name, d.dep_nm
297 from work.&ds_dep. d
298 where not exists (
299 select 1 from work.&ds_ref. r
300 where r.name=d.name and r.tok=d.dep_nm
301 );
302
303/* a derived variable referenced by the expression must be declared, otherwise
304 the statement order would be wrong and the value would be silently missing */
305create table work.&ds_undecl. as
306 select distinct r.name, r.tok as dep_nm
307 from work.&ds_ref. r
308 where r.tok in (select name from work.&ds_byname.)
309 and r.tok ne r.name
310 and not exists (
311 select 1 from work.&ds_dep. d
312 where d.name=r.name and d.dep_nm=r.tok
313 );
314
315/* a variable may not depend on itself */
316create table work.&ds_self. as
317 select name, dep_nm from work.&ds_dep.
318 where name=dep_nm;
319quit;
320
321data _null_;
322 set work.&ds_baddep.(obs=1);
323 call symputx('derived_err'
324 ,cats(name,': VAR_DEPS lists ',dep_nm
325 ,', which is not a column of the table or a derived variable'));
326run;
327
328%mp_abort(iftrue= (0 < %mf_nobs(work.&ds_baddep.))
329 ,mac=&sysmacroname
330 ,msg=%superq(derived_err)
331)
332
333data _null_;
334 set work.&ds_self.(obs=1);
335 call symputx('derived_err',cats(name,': VAR_DEPS may not list itself'));
336run;
337
338%mp_abort(iftrue= (0 < %mf_nobs(work.&ds_self.))
339 ,mac=&sysmacroname
340 ,msg=%superq(derived_err)
341)
342
343data _null_;
344 set work.&ds_undecl.(obs=1);
345 call symputx('derived_err'
346 ,cats(name,': VAR_LOGIC references ',dep_nm
347 ,', which is not in VAR_DEPS'));
348run;
349
350%mp_abort(iftrue= (0 < %mf_nobs(work.&ds_undecl.))
351 ,mac=&sysmacroname
352 ,msg=%superq(derived_err)
353)
354
355data _null_;
356 set work.&ds_stale.(obs=1);
357 call symputx('derived_err'
358 ,cats(name,': VAR_DEPS lists ',dep_nm
359 ,', which VAR_LOGIC does not reference'));
360run;
361
362%mp_abort(iftrue= (0 < %mf_nobs(work.&ds_stale.))
363 ,mac=&sysmacroname
364 ,msg=%superq(derived_err)
365)
366
367data _null_;
368 set work.&ds_bad.;
369 putlog 'ERR' 'OR: derived variable config: ' name= reason=;
370run;
371data _null_;
372 set work.&ds_bad.(obs=1);
373 call symputx('derived_err',cats(name,': ',reason));
374run;
375%mp_abort(iftrue= (0 < %mf_nobs(work.&ds_bad.))
376 ,mac=&sysmacroname
377 ,msg=%str(Invalid derived variable configuration - &derived_err)
378)
379
380/**
381 * Dependency order. The edges are between derived variables only - a
382 * physical column is available from the start. Repeatedly take the variables
383 * whose derived inputs have all been placed, alphabetically within a round;
384 * if a round places nothing, what is left is a cycle.
385 */
386proc sql;
387create table work.&ds_edge. as
388 select d.name as child, d.dep_nm as parent
389 from work.&ds_dep. d
390 where d.dep_nm in (select name from work.&ds_byname.);
391quit;
392
393data work.&ds_todo.(keep=name);
394 set work.&ds_byname.;
395run;
396
397data work.&ds_placed.(keep=name seq);
398 length name $32;
399 name='';
400 seq=0;
401 if 0;
402run;
403
404%let left=%mf_nobs(work.&ds_todo.);
405%let nplaced=0;
406%let cyc_found=0;
407
408%do %while (&left>0);
409 proc sql;
410 create table work.&ds_ready. as
411 select t.name
412 from work.&ds_todo. t
413 where not exists (
414 select 1 from work.&ds_edge. e
415 inner join work.&ds_todo. t2 on e.parent=t2.name
416 where e.child=t.name
417 )
418 order by t.name;
419 quit;
420
421 %let nready=%mf_nobs(work.&ds_ready.);
422 %if &nready=0 %then %do;
423 /* walk the edges, so the message names the cycle rather than just the
424 names that could not be placed */
425 data _null_;
426 set work.&ds_todo.(obs=1);
427 call symputx('cyc_start',name);
428 run;
429 %let cyc_path=&cyc_start;
430 %let cyc_cur=&cyc_start;
431 %let cyc_seen=%str(.&cyc_start..);
432 %do cyc_i=1 %to &left;
433 %let cyc_next=;
434 proc sql noprint;
435 select e.parent into: cyc_next
436 from work.&ds_edge. e
437 where e.child="&cyc_cur"
438 and e.parent in (select name from work.&ds_todo.);
439 quit;
440 %if %length(&cyc_next)=0 %then %let cyc_i=&left;
441 %else %do;
442 %let cyc_path=&cyc_path. -> &cyc_next;
443 %if %index(&cyc_seen,%str(.&cyc_next..))>0 %then %let cyc_i=&left;
444 %else %do;
445 %let cyc_seen=&cyc_seen.&cyc_next..;
446 %let cyc_cur=&cyc_next;
447 %end;
448 %end;
449 %end;
450 /* leave the loop before aborting - an abort cancels the step, and the
451 loop would re-enter with the ready count still zero */
452 %let cyc_found=1;
453 %let left=0;
454 %end;
455
456 %if &cyc_found=0 %then %do;
457 /* seq carries the dependency order - the placement rounds are not name
458 ordered, so the final merge must not rely on by name alone */
459 data work.&ds_ready.;
460 set work.&ds_ready.;
461 seq=_n_+&nplaced;
462 run;
463 proc append base=work.&ds_placed. data=work.&ds_ready. force;
464 run;
465 %let nplaced=%eval(&nplaced+&nready);
466
467 proc sql;
468 delete from work.&ds_todo.
469 where name in (select name from work.&ds_ready.);
470 quit;
471
472 %let left=%mf_nobs(work.&ds_todo.);
473 %end;
474%end;
475
476%mp_abort(iftrue= (&cyc_found=1)
477 ,mac=&sysmacroname
478 ,msg=%str(VAR_DEPS contains a cycle: &cyc_path)
479)
480
481proc sort data=work.&ds_placed.;
482 by name;
483run;
484
485data work.&ds_srt.;
486 merge work.&ds_placed.(in=in1) work.&ds_byname.(in=in2);
487 by name;
488 if in1 and in2;
489run;
490
491proc sort data=work.&ds_srt.;
492 by seq;
493run;
494
495/**
496 * Generate the attrib + assignment statements
497 */
498data _null_;
499 file &outref;
500 set work.&ds_srt.;
501 /* 512 so the doubled quotes fit before the 256 boundary is applied */
502 length stmt $4600 label $512 fmt2 $49;
503 /* the configured label wins when there is one, otherwise the label is the
504 name, an equals sign and the expression. Labels are limited to 256 chars:
505 the quotes are doubled first, then the cut is made on an even quote
506 boundary - truncating between the two halves of an escape would leave the
507 generated literal unterminated */
508 if not missing(lbl) then label=lbl;
509 else label=cats(name,'=',translate(logic,' ','0D0A'x));
510 label=tranwrd(label,"'","''");
511 if length(label)>256 then do;
512 label=substr(label,1,256);
513 if mod(count(label,"'"),2) ne 0 then label=substr(label,1,255);
514 end;
515 if not missing(fmt) and index(fmt,'.')=0 then fmt=cats(fmt,'.');
516 if type='C' then do;
517 if missing(fmt) then fmt2=cats('$',len,'.');
518 else fmt2=fmt;
519 stmt=catx(' ','attrib',name,cats('length=$',len)
520 ,cats("label='",label,"'"),cats('format=',fmt2,';'));
521 end;
522 else do;
523 if missing(fmt) then fmt2=cats(len,'.');
524 else fmt2=fmt;
525 stmt=catx(' ','attrib',name,cats('length=',len)
526 ,cats("label='",label,"'"),cats('format=',fmt2,';'));
527 end;
528 put stmt;
529 stmt=cats(name,' = ',logic,';');
530 put stmt;
531run;
532
533%mp_abort(iftrue= (&syscc ne 0)
534 ,mac=&sysmacroname
535 ,msg=%str(Unable to generate derived variables for &libds)
536)
537
538data &outvars(keep=name);
539 set work.&ds_srt.;
540run;
541
542%if %length(&outview)>0 and &outview ne 0 %then %do;
543 data &outview / view=&outview;
544 set &libds;
545 %include &outref;
546 run;
547 %mp_abort(iftrue= (&syscc ne 0)
548 ,mac=&sysmacroname
549 ,msg=%str(Unable to build derived view &outview for &libds)
550 )
551%end;
552
553%mend mp_derivedvars;