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