Macros for SAS Application Developers
https://github.com/sasjs/core
Loading...
Searching...
No Matches
mp_stackdiffs.sas
Go to the documentation of this file.
1/**
2 @file
3 @brief Prepares an audit table for stacking (re-applying) the changes.
4 @details When the underlying data from a Base Table is refreshed, it can be
5 helpful to have any previously-applied changes, re-applied.
6
7 Such situation might arise if you are applying those changes using a tool
8 like [Data Controller for SASĀ®](https://datacontroller.io) - which records
9 all such changes in an audit table.
10 It may also apply if you are preparing a series of specific cell-level
11 transactions, that you would like to apply to multiple sets of (similarly
12 structured) Base Tables.
13
14 In both cases, it is necessary that the transactions are stored using
15 the mp_storediffs.sas macro, or at least that the underlying table is
16 structured as per the definition in mddl_dc_difftable.sas
17
18 <b>This</b> macro is used to convert the stored changes (tall format) into
19 staged changes (wide format), with base table values incorporated (in the
20 case of modified rows), ready for the subsequent load process.
21
22 Essentially then, what this macro does, is turn a table like this:
23
24|KEY_HASH:$32.|MOVE_TYPE:$1.|TGTVAR_NM:$32.|IS_PK:best.|IS_DIFF:best.|TGTVAR_TYPE:$1.|OLDVAL_NUM:best32.|NEWVAL_NUM:best32.|OLDVAL_CHAR:$32765.|NEWVAL_CHAR:$32765.|
25|---|---|---|---|---|---|---|---|---|---|
26|`27AA6F7581052E7FF48E1BCA901313FB `|`A `|`NAME `|`1 `|`-1 `|`C `|`. `|`. `|` `|`Newbie `|
27|`27AA6F7581052E7FF48E1BCA901313FB `|`A `|`AGE `|`0 `|`-1 `|`N `|`. `|`13 `|` `|` `|
28|`27AA6F7581052E7FF48E1BCA901313FB `|`A `|`HEIGHT `|`0 `|`-1 `|`N `|`. `|`65.3 `|` `|` `|
29|`27AA6F7581052E7FF48E1BCA901313FB `|`A `|`SEX `|`0 `|`-1 `|`C `|`. `|`. `|` `|`F `|
30|`27AA6F7581052E7FF48E1BCA901313FB `|`A `|`WEIGHT `|`0 `|`-1 `|`N `|`. `|`98 `|` `|` `|
31|`86703FDE9E87DD5C0F8E1072545D0128 `|`D `|`NAME `|`1 `|`-1 `|`C `|`. `|`. `|`Alfred `|` `|
32|`86703FDE9E87DD5C0F8E1072545D0128 `|`D `|`AGE `|`0 `|`-1 `|`N `|`14 `|`. `|` `|` `|
33|`86703FDE9E87DD5C0F8E1072545D0128 `|`D `|`HEIGHT `|`0 `|`-1 `|`N `|`69 `|`. `|` `|` `|
34|`86703FDE9E87DD5C0F8E1072545D0128 `|`D `|`SEX `|`0 `|`-1 `|`C `|`. `|`. `|`M `|` `|
35|`86703FDE9E87DD5C0F8E1072545D0128 `|`D `|`WEIGHT `|`0 `|`-1 `|`N `|`112.5 `|`. `|` `|` `|
36|`64489C85DC2FE0787B85CD87214B3810 `|`M `|`NAME `|`1 `|`0 `|`C `|`. `|`. `|`Alice `|`Alice `|
37|`64489C85DC2FE0787B85CD87214B3810 `|`M `|`AGE `|`0 `|`1 `|`N `|`13 `|`99 `|` `|` `|
38|`64489C85DC2FE0787B85CD87214B3810 `|`M `|`HEIGHT `|`0 `|`0 `|`N `|`56.5 `|`56.5 `|` `|` `|
39|`64489C85DC2FE0787B85CD87214B3810 `|`M `|`SEX `|`0 `|`0 `|`C `|`. `|`. `|`F `|`F `|
40|`64489C85DC2FE0787B85CD87214B3810 `|`M `|`WEIGHT `|`0 `|`0 `|`N `|`84 `|`84 `|` `|` `|
41
42 Into three tables like this:
43
44 <b> `work.outmod`: </b>
45 |NAME:$8.|SEX:$1.|AGE:best.|HEIGHT:best.|WEIGHT:best.|
46 |---|---|---|---|---|
47 |`Alice `|`F `|`99 `|`56.5 `|`84 `|
48
49 <b> `work.outadd`: </b>
50 |NAME:$8.|SEX:$1.|AGE:best.|HEIGHT:best.|WEIGHT:best.|
51 |---|---|---|---|---|
52 |`Newbie `|`F `|`13 `|`65.3 `|`98 `|
53
54 <b> `work.outdel`: </b>
55 |NAME:$8.|SEX:$1.|AGE:best.|HEIGHT:best.|WEIGHT:best.|
56 |---|---|---|---|---|
57 |`Alfred `|`M `|`14 `|`69 `|`112.5 `|
58
59 As you might expect, there are a bunch of extra features and checks.
60
61 The macro supports both SCD2 (TXTEMPORAL) and UPDATE loadtypes. If the
62 base table contains a PROCESSED_DTTM column (or similar), this can be
63 ignored by declaring it in the `processed_dttm_var` parameter.
64
65 The macro is also flexible where columns have been added or removed from
66 the base table UNLESS there is a change to the primary key.
67
68 Changes to the primary key fields are NOT supported, and are likely to cause
69 unexpected results.
70
71 The following pre-flight checks are made:
72
73 @li All primary key columns exist on the base table
74 @li There is no change in variable TYPE for any of the columns
75 @li There is no reduction in variable LENGTH below the max-length of the
76 supplied values
77
78 Rules for stacking changes are as follows:
79
80 <table>
81 <tr>
82 <th>Transaction Type</th><th>Key Behaviour</th><th>Column Behaviour</th>
83 </tr>
84 <tr>
85 <td>Deletes</td>
86 <td>
87 The row is added to `&outDEL.` UNLESS it no longer exists
88 in the base table, in which case it is added to `&errDS.` instead.
89 </td>
90 <td>
91 Deletes are unaffected by the addition or removal of non Primary-Key
92 columns.
93 </td>
94 </tr>
95 <tr>
96 <td>Inserts</td>
97 <td>
98 Previously newly added rows are added to the `outADD` table UNLESS they
99 are present in the Base table.<br>In this case they are added to the
100 `&errDS.` table instead.
101 </td>
102 <td>
103 Inserts are unaffected by the addition of columns in the Base Table
104 (they are padded with blanks). Deleted columns are only a problem if
105 they appear on the previous insert - in which case the record is added
106 to `&errDS.`.
107 </td>
108 </tr>
109 <tr>
110 <td>Updates</td>
111 <td>
112 Previously modified rows are merged with base table values such that
113 only the individual cells that were _previously_ changed are re-applied.
114 Where the row contains cells that were not marked as having changed in
115 the prior transaction, the 'blanks' are filled with base table values in
116 the `outMOD` table.<br>
117 If the row no longer exists on the base table, then the row is added to
118 the `errDS` table instead.
119 </td>
120 <td>
121 Updates are unaffected by the addition of columns in the Base Table -
122 the new cells are simply populated with Base Table values. Deleted
123 columns are only a problem if they relate to a modified cell
124 (`is_diff=1`) - in which case the record is added to `&errDS.`.
125 </td>
126 </tr>
127 </table>
128
129 To illustrate the above with a diagram:
130
131 @dot
132 digraph {
133 rankdir="TB"
134 start[label="Transaction Type?" shape=Mdiamond]
135 del[label="Does Base Row exist?" shape=rectangle]
136 add [label="Does Base Row exist?" shape=rectangle]
137 mod [label="Does Base Row exist?" shape=rectangle]
138 chkmod [label="Do all modified\n(is_diff=1) cells exist?" shape=rectangle]
139 chkadd [label="Do all inserted cells exist?" shape=rectangle]
140 outmod [label="outMOD\nTable" shape=Msquare style=filled]
141 outadd [label="outADD\nTable" shape=Msquare style=filled]
142 outdel [label="outDEL\nTable" shape=Msquare style=filled]
143 outerr [label="ErrDS Table" shape=Msquare fillcolor=Orange style=filled]
144 start -> del [label="Delete"]
145 start -> add [label="Insert"]
146 start -> mod [label="Update"]
147
148 del -> outdel [label="Yes"]
149 del -> outerr [label="No" color="Red" fontcolor="Red"]
150 add -> chkadd [label="No"]
151 add -> outerr [label="Yes" color="Red" fontcolor="Red"]
152 mod -> outerr [label="No" color="Red" fontcolor="Red"]
153 mod -> chkmod [label="Yes"]
154 chkmod -> outerr [label="No" color="Red" fontcolor="Red"]
155 chkmod -> outmod [label="Yes"]
156 chkadd -> outerr [label="No" color="Red" fontcolor="Red"]
157 chkadd -> outadd [label="Yes"]
158
159 }
160 @enddot
161
162 For examples of usage, check out the mp_stackdiffs.test.sas program.
163
164
165 @param [in] baselibds Base Table against which the changes will be applied,
166 in libref.dataset format.
167 @param [in] auditlibds Dataset with previously applied transactions, to be
168 re-applied. Use libref.dataset format.
169 DDL as follows: %mddl_dc_difftable()
170 @param [in] key Space seperated list of key variables
171 @param [in] mdebug= Set to 1 to enable DEBUG messages and preserve outputs
172 @param [in] processed_dttm_var= (0) If a variable is being used to mark
173 the processed datetime, put the name of the variable here. It will NOT
174 be included in the staged dataset (the load process is expected to
175 provide this)
176 @param [out] errds= (work.errds) Output table containing problematic records.
177 The columns of this table are:
178 @li PK_VARS - Space separated list of primary key variable names
179 @li PK_VALS - Slash separted list of PK variable values
180 @li ERR_MSG - Explanation of why this record is problematic
181 @param [out] outmod= (work.outmod) Output table containing modified records
182 @param [out] outadd= (work.outadd) Output table containing additional records
183 @param [out] outdel= (work.outdel) Output table containing deleted records
184
185 <h4> SAS Macros </h4>
186 @li mf_existvarlist.sas
187 @li mf_getquotedstr.sas
188 @li mf_getuniquefileref.sas
189 @li mf_getuniquename.sas
190 @li mf_islibds.sas
191 @li mf_nobs.sas
192 @li mf_wordsinstr1butnotstr2.sas
193 @li mp_abort.sas
194 @li mp_ds2squeeze.sas
195 @li mf_getvarlist.sas
196
197 <h4> Related Macros </h4>
198 @li mddl_dc_difftable.sas
199 @li mp_stackdiffs.test.sas
200 @li mp_storediffs.sas
201 @li mp_stripdiffs.sas
202
203 @todo The current approach assumes that a variable called KEY_HASH is not on
204 the base table. This part will need to be refactored (eg using
205 mf_getuniquename.sas) when such a use case arises.
206
207 @version 9.2
208 @author Allan Bowe
209 @sasjslint {"maxLineLength": 164}
210**/
211/** @cond */
212
213%macro mp_stackdiffs(baselibds
214 ,auditlibds
215 ,key
216 ,mdebug=0
217 ,processed_dttm_var=0
218 ,errds=work.errds
219 ,outmod=work.outmod
220 ,outadd=work.outadd
221 ,outdel=work.outdel
222)/*/STORE SOURCE*/;
223%local dbg;
224%if &mdebug=1 %then %do;
225 %put &sysmacroname entry vars:;
226 %put _local_;
227%end;
228%else %let dbg=*;
229
230/* input parameter validations */
231%mp_abort(iftrue= (%mf_islibds(&baselibds) ne 1)
232 ,mac=&sysmacroname
233 ,msg=%str(Invalid baselibds: &baselibds)
234)
235%mp_abort(iftrue= (%mf_islibds(&auditlibds) ne 1)
236 ,mac=&sysmacroname
237 ,msg=%str(Invalid auditlibds: &auditlibds)
238)
239%mp_abort(iftrue= (%length(&key)=0)
240 ,mac=&sysmacroname
241 ,msg=%str(Missing key variables!)
242)
243%mp_abort(iftrue= (
244 %mf_existVarList(&auditlibds,LIBREF DSN MOVE_TYPE KEY_HASH TGTVAR_NM IS_PK
245 IS_DIFF TGTVAR_TYPE OLDVAL_NUM NEWVAL_NUM OLDVAL_CHAR NEWVAL_CHAR)=0
246 )
247 ,mac=&sysmacroname
248 ,msg=%str(Input &auditlibds is missing required columns!)
249)
250
251
252/* set up macro vars */
253%local prefix dslist x var keyjoin commakey keepvars missvars fref;
254%let prefix=%substr(%mf_getuniquename(),1,25);
255%let dslist=ds1d ds2d ds3d ds1a ds2a ds3a ds1m ds2m ds3m pks dups base
256 delrec delerr addrec adderr modrec moderr;
257%do x=1 %to %sysfunc(countw(&dslist));
258 %let var=%scan(&dslist,&x);
259 %local &var;
260 %let &var=%upcase(&prefix._&var);
261%end;
262
263%let key=%upcase(&key);
264%let commakey=%mf_getquotedstr(&key,quote=N);
265
266%let keyjoin=1=1;
267%do x=1 %to %sysfunc(countw(&key));
268 %let var=%scan(&key,&x);
269 %let keyjoin=&keyjoin and a.&var=b.&var;
270%end;
271
272data &errds;
273 length pk_vars $256 pk_vals $4098 err_msg $512;
274 call missing (of _all_);
275 stop;
276run;
277
278/**
279 * Prepare raw DELETE table
280 * Records are in the OLDVAL_xxx columns
281 */
282%let keepvars=MOVE_TYPE KEY_HASH TGTVAR_NM TGTVAR_TYPE IS_PK
283 OLDVAL_NUM OLDVAL_CHAR
284 NEWVAL_NUM NEWVAL_CHAR;
285proc sort data=&auditlibds(where=(move_type='D') keep=&keepvars)
286 out=&ds1d(drop=move_type);
287by KEY_HASH TGTVAR_NM;
288run;
289proc transpose data=&ds1d(where=(tgtvar_type='N'))
290 out=&ds2d(drop=_name_);
291 by KEY_HASH;
292 id TGTVAR_NM;
293 var OLDVAL_NUM;
294run;
295proc transpose data=&ds1d(where=(tgtvar_type='C'))
296 out=&ds3d(drop=_name_);
297 by KEY_HASH;
298 id TGTVAR_NM;
299 var OLDVAL_CHAR;
300run;
301%mp_ds2squeeze(&ds2d,outds=&ds2d)
302%mp_ds2squeeze(&ds3d,outds=&ds3d)
303data &outdel;
304 if 0 then set &baselibds;
305 set &ds2d;
306 set &ds3d;
307 drop key_hash;
308 if not missing(%scan(&key,1));
309run;
310proc sort;
311 by &key;
312run;
313
314/**
315 * Prepare raw APPEND table
316 * Records are in the NEWVAL_xxx columns
317 */
318proc sort data=&auditlibds(where=(move_type='A') keep=&keepvars)
319 out=&ds1a(drop=move_type);
320 by KEY_HASH TGTVAR_NM;
321run;
322proc transpose data=&ds1a(where=(tgtvar_type='N'))
323 out=&ds2a(drop=_name_);
324 by KEY_HASH;
325 id TGTVAR_NM;
326 var NEWVAL_NUM;
327run;
328proc transpose data=&ds1a(where=(tgtvar_type='C'))
329 out=&ds3a(drop=_name_);
330 by KEY_HASH;
331 id TGTVAR_NM;
332 var NEWVAL_CHAR;
333run;
334%mp_ds2squeeze(&ds2a,outds=&ds2a)
335%mp_ds2squeeze(&ds3a,outds=&ds3a)
336data &outadd;
337 if 0 then set &baselibds;
338 set &ds2a;
339 set &ds3a;
340 drop key_hash;
341 if not missing(%scan(&key,1));
342run;
343proc sort;
344 by &key;
345run;
346
347/**
348 * Prepare raw MODIFY table
349 * Keep only primary key - will add modified values later
350 */
351proc sort data=&auditlibds(
352 where=(move_type='M' and is_pk=1) keep=&keepvars
353 ) out=&ds1m(drop=move_type);
354 by KEY_HASH TGTVAR_NM;
355run;
356proc transpose data=&ds1m(where=(tgtvar_type='N'))
357 out=&ds2m(drop=_name_);
358 by KEY_HASH ;
359 id TGTVAR_NM;
360 var NEWVAL_NUM;
361run;
362proc transpose data=&ds1m(where=(tgtvar_type='C'))
363 out=&ds3m(drop=_name_);
364 by KEY_HASH;
365 id TGTVAR_NM;
366 var NEWVAL_CHAR;
367run;
368%mp_ds2squeeze(&ds2m,outds=&ds2m)
369%mp_ds2squeeze(&ds3m,outds=&ds3m)
370data &outmod;
371 if 0 then set &baselibds;
372 set &ds2m;
373 set &ds3m;
374 if not missing(%scan(&key,1));
375run;
376proc sort;
377 by &key;
378run;
379
380/**
381 * Extract matching records from the base table
382 * Do this in one join for efficiency.
383 * At a later date, this should be optimised for large database tables by using
384 * passthrough and a temporary table.
385 */
386data &pks;
387 if 0 then set &baselibds;
388 set &outadd &outmod &outdel;
389 keep &key;
390run;
391
392proc sort noduprec dupout=&dups;
393by &key;
394run;
395data _null_;
396 set &dups;
397 putlog (_all_)(=);
398run;
399%mp_abort(iftrue= (%mf_nobs(&dups) ne 0)
400 ,mac=&sysmacroname
401 ,msg=%str(duplicates (%mf_nobs(&dups)) found on &auditlibds!)
402)
403
404proc sql;
405create table &base as
406 select a.*
407 from &baselibds a, &pks b
408 where &keyjoin;
409
410/**
411 * delete check
412 * This is straightforward as it relates to records only
413 */
414proc sql;
415create table &delrec as
416 select a.*
417 from &outdel a
418 left join &base b
419 on &keyjoin
420 where b.%scan(&key,1) is null
421 order by &commakey;
422
423data &delerr;
424 if 0 then set &errds;
425 set &delrec;
426 PK_VARS="&key";
427 PK_VALS=catx('/',&commakey);
428 ERR_MSG="Rows cannot be deleted as they do not exist on the Base dataset";
429 keep PK_VARS PK_VALS ERR_MSG;
430run;
431proc append base=&errds data=&delerr;
432run;
433
434data &outdel;
435 merge &outdel (in=a) &delrec (in=b);
436 by &key;
437 if not b;
438run;
439
440/**
441 * add check
442 * Problems - where record already exists, or base table has columns missing
443 */
444%let missvars=%mf_wordsinstr1butnotstr2(
445 Str1=%upcase(%mf_getvarlist(&outadd)),
446 Str2=%upcase(%mf_getvarlist(&baselibds))
447);
448%if %length(&missvars)>0 %then %do;
449 /* add them to the err table */
450 data &adderr;
451 if 0 then set &errds;
452 set &outadd;
453 PK_VARS="&key";
454 PK_VALS=catx('/',&commakey);
455 ERR_MSG="Rows cannot be added due to missing base vars: &missvars";
456 keep PK_VARS PK_VALS ERR_MSG;
457 run;
458 proc append base=&errds data=&adderr;
459 run;
460 proc sql;
461 delete * from &outadd;
462%end;
463%else %do;
464 proc sql;
465 /* find records that already exist on base table */
466 create table &addrec as
467 select a.*
468 from &outadd a
469 inner join &base b
470 on &keyjoin
471 order by &commakey;
472
473 /* add them to the err table */
474 data &adderr;
475 if 0 then set &errds;
476 set &addrec;
477 PK_VARS="&key";
478 PK_VALS=catx('/',&commakey);
479 ERR_MSG="Rows cannot be added as they already exist on the Base dataset";
480 keep PK_VARS PK_VALS ERR_MSG;
481 run;
482 proc append base=&errds data=&adderr;
483 run;
484
485 /* remove invalid rows from the outadd table */
486 data &outadd;
487 merge &outadd (in=a) &addrec (in=b);
488 by &key;
489 if not b;
490 run;
491%end;
492
493/**
494 * mod check
495 * Problems - where record does not exist or baseds has modified cols missing
496 */
497proc sql noprint;
498select distinct tgtvar_nm into: missvars separated by ' '
499 from &auditlibds
500 where move_type='M' and is_diff=1;
501%let missvars=%mf_wordsinstr1butnotstr2(
502 Str1=&missvars,
503 Str2=%upcase(%mf_getvarlist(&baselibds))
504);
505%if %length(&missvars)>0 %then %do;
506 /* add them to the err table */
507 data &moderr;
508 if 0 then set &errds;
509 set &outmod;
510 PK_VARS="&key";
511 PK_VALS=catx('/',&commakey);
512 ERR_MSG="Rows cannot be modified due to missing base vars: &missvars";
513 keep PK_VARS PK_VALS ERR_MSG;
514 run;
515 proc append base=&errds data=&moderr;
516 run;
517 proc sql;
518 delete * from &outmod;
519%end;
520%else %do;
521 /* now check for records that do not exist (therefore cannot be modified) */
522 proc sql;
523 create table &modrec as
524 select a.*
525 from &outmod a
526 left join &base b
527 on &keyjoin
528 where b.%scan(&key,1) is null
529 order by &commakey;
530 data &moderr;
531 if 0 then set &errds;
532 set &modrec;
533 PK_VARS="&key";
534 PK_VALS=catx('/',&commakey);
535 ERR_MSG="Rows cannot be modified as they do not exist on the Base dataset";
536 keep PK_VARS PK_VALS ERR_MSG;
537 run;
538 proc append base=&errds data=&moderr;
539 run;
540 /* delete the above records from the outmod table */
541 data &outmod;
542 merge &outmod (in=a) &modrec (in=b);
543 by &key;
544 if not b;
545 run;
546 /* now - we can prepare the final MOD table (which is currently PK only) */
547 proc sql undo_policy=none;
548 create table &outmod as
549 select a.key_hash
550 ,b.*
551 from &outmod a
552 inner join &base b
553 on &keyjoin
554 order by &commakey;
555 /* now - to update outmod with modified (is_diff=1) values */
556 %let fref=%mf_getuniquefileref();
557 data _null_;
558 file &fref;
559 set &auditlibds(where=(move_type='M')) end=lastobs;
560 by key_hash;
561 retain comma 'N';
562 if _n_=1 then put 'proc sql;';
563 if first.key_hash then do;
564 comma='N';
565 put "update &outmod set " @@;
566 end;
567 if is_diff=1 then do;
568 if comma='N' then do;
569 put ' '@@;
570 comma='Y';
571 end;
572 else put ' ,'@@;
573 if tgtvar_type='C' then do;
574 length qstr $32767;
575 qstr=quote(trim(NEWVAL_CHAR));
576 put tgtvar_nm '=' qstr;
577 end;
578 else do;
579 /* the value becomes a SQL literal, so a special missing (.A-.Z, ._)
580 needs its leading period - PUT writes only the letter, which PROC SQL
581 reads as a column name */
582 put tgtvar_nm '=' @@;
583 if missing(newval_num) and newval_num ne . then put "." newval_num;
584 else put newval_num;
585 end;
586 if comma=' ' then comma=' ,';
587 end;
588 if last.key_hash then put ' where key_hash=trim("' key_hash '");';
589 if lastobs then put "alter table &outmod drop key_hash;";
590 run;
591 %inc &fref/source2;
592%end;
593
594%if &mdebug=0 %then %do;
595 proc datasets lib=work;
596 delete &prefix:;
597 run;
598 %put &sysmacroname exit vars:;
599 %put _local_;
600%end;
601%mend mp_stackdiffs;
602/** @endcond */