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**/
210/** @cond */
211
212%macro mp_stackdiffs(baselibds
213 ,auditlibds
214 ,key
215 ,mdebug=0
216 ,processed_dttm_var=0
217 ,errds=work.errds
218 ,outmod=work.outmod
219 ,outadd=work.outadd
220 ,outdel=work.outdel
221)/*/STORE SOURCE*/;
222%local dbg;
223%if &mdebug=1 %then %do;
224 %put &sysmacroname entry vars:;
225 %put _local_;
226%end;
227%else %let dbg=*;
228
229/* input parameter validations */
230%mp_abort(iftrue= (%mf_islibds(&baselibds) ne 1)
231 ,mac=&sysmacroname
232 ,msg=%str(Invalid baselibds: &baselibds)
233)
234%mp_abort(iftrue= (%mf_islibds(&auditlibds) ne 1)
235 ,mac=&sysmacroname
236 ,msg=%str(Invalid auditlibds: &auditlibds)
237)
238%mp_abort(iftrue= (%length(&key)=0)
239 ,mac=&sysmacroname
240 ,msg=%str(Missing key variables!)
241)
242%mp_abort(iftrue= (
243 %mf_existVarList(&auditlibds,LIBREF DSN MOVE_TYPE KEY_HASH TGTVAR_NM IS_PK
244 IS_DIFF TGTVAR_TYPE OLDVAL_NUM NEWVAL_NUM OLDVAL_CHAR NEWVAL_CHAR)=0
245 )
246 ,mac=&sysmacroname
247 ,msg=%str(Input &auditlibds is missing required columns!)
248)
249
250
251/* set up macro vars */
252%local prefix dslist x var keyjoin commakey keepvars missvars fref;
253%let prefix=%substr(%mf_getuniquename(),1,25);
254%let dslist=ds1d ds2d ds3d ds1a ds2a ds3a ds1m ds2m ds3m pks dups base
255 delrec delerr addrec adderr modrec moderr;
256%do x=1 %to %sysfunc(countw(&dslist));
257 %let var=%scan(&dslist,&x);
258 %local &var;
259 %let &var=%upcase(&prefix._&var);
260%end;
261
262%let key=%upcase(&key);
263%let commakey=%mf_getquotedstr(&key,quote=N);
264
265%let keyjoin=1=1;
266%do x=1 %to %sysfunc(countw(&key));
267 %let var=%scan(&key,&x);
268 %let keyjoin=&keyjoin and a.&var=b.&var;
269%end;
270
271data &errds;
272 length pk_vars $256 pk_vals $4098 err_msg $512;
273 call missing (of _all_);
274 stop;
275run;
276
277/**
278 * Prepare raw DELETE table
279 * Records are in the OLDVAL_xxx columns
280 */
281%let keepvars=MOVE_TYPE KEY_HASH TGTVAR_NM TGTVAR_TYPE IS_PK
282 OLDVAL_NUM OLDVAL_CHAR
283 NEWVAL_NUM NEWVAL_CHAR;
284proc sort data=&auditlibds(where=(move_type='D') keep=&keepvars)
285 out=&ds1d(drop=move_type);
286by KEY_HASH TGTVAR_NM;
287run;
288proc transpose data=&ds1d(where=(tgtvar_type='N'))
289 out=&ds2d(drop=_name_);
290 by KEY_HASH;
291 id TGTVAR_NM;
292 var OLDVAL_NUM;
293run;
294proc transpose data=&ds1d(where=(tgtvar_type='C'))
295 out=&ds3d(drop=_name_);
296 by KEY_HASH;
297 id TGTVAR_NM;
298 var OLDVAL_CHAR;
299run;
300%mp_ds2squeeze(&ds2d,outds=&ds2d)
301%mp_ds2squeeze(&ds3d,outds=&ds3d)
302data &outdel;
303 if 0 then set &baselibds;
304 set &ds2d;
305 set &ds3d;
306 drop key_hash;
307 if not missing(%scan(&key,1));
308run;
309proc sort;
310 by &key;
311run;
312
313/**
314 * Prepare raw APPEND table
315 * Records are in the NEWVAL_xxx columns
316 */
317proc sort data=&auditlibds(where=(move_type='A') keep=&keepvars)
318 out=&ds1a(drop=move_type);
319 by KEY_HASH TGTVAR_NM;
320run;
321proc transpose data=&ds1a(where=(tgtvar_type='N'))
322 out=&ds2a(drop=_name_);
323 by KEY_HASH;
324 id TGTVAR_NM;
325 var NEWVAL_NUM;
326run;
327proc transpose data=&ds1a(where=(tgtvar_type='C'))
328 out=&ds3a(drop=_name_);
329 by KEY_HASH;
330 id TGTVAR_NM;
331 var NEWVAL_CHAR;
332run;
333%mp_ds2squeeze(&ds2a,outds=&ds2a)
334%mp_ds2squeeze(&ds3a,outds=&ds3a)
335data &outadd;
336 if 0 then set &baselibds;
337 set &ds2a;
338 set &ds3a;
339 drop key_hash;
340 if not missing(%scan(&key,1));
341run;
342proc sort;
343 by &key;
344run;
345
346/**
347 * Prepare raw MODIFY table
348 * Keep only primary key - will add modified values later
349 */
350proc sort data=&auditlibds(
351 where=(move_type='M' and is_pk=1) keep=&keepvars
352 ) out=&ds1m(drop=move_type);
353 by KEY_HASH TGTVAR_NM;
354run;
355proc transpose data=&ds1m(where=(tgtvar_type='N'))
356 out=&ds2m(drop=_name_);
357 by KEY_HASH ;
358 id TGTVAR_NM;
359 var NEWVAL_NUM;
360run;
361proc transpose data=&ds1m(where=(tgtvar_type='C'))
362 out=&ds3m(drop=_name_);
363 by KEY_HASH;
364 id TGTVAR_NM;
365 var NEWVAL_CHAR;
366run;
367%mp_ds2squeeze(&ds2m,outds=&ds2m)
368%mp_ds2squeeze(&ds3m,outds=&ds3m)
369data &outmod;
370 if 0 then set &baselibds;
371 set &ds2m;
372 set &ds3m;
373 if not missing(%scan(&key,1));
374run;
375proc sort;
376 by &key;
377run;
378
379/**
380 * Extract matching records from the base table
381 * Do this in one join for efficiency.
382 * At a later date, this should be optimised for large database tables by using
383 * passthrough and a temporary table.
384 */
385data &pks;
386 if 0 then set &baselibds;
387 set &outadd &outmod &outdel;
388 keep &key;
389run;
390
391proc sort noduprec dupout=&dups;
392by &key;
393run;
394data _null_;
395 set &dups;
396 putlog (_all_)(=);
397run;
398%mp_abort(iftrue= (%mf_nobs(&dups) ne 0)
399 ,mac=&sysmacroname
400 ,msg=%str(duplicates (%mf_nobs(&dups)) found on &auditlibds!)
401)
402
403proc sql;
404create table &base as
405 select a.*
406 from &baselibds a, &pks b
407 where &keyjoin;
408
409/**
410 * delete check
411 * This is straightforward as it relates to records only
412 */
413proc sql;
414create table &delrec as
415 select a.*
416 from &outdel a
417 left join &base b
418 on &keyjoin
419 where b.%scan(&key,1) is null
420 order by &commakey;
421
422data &delerr;
423 if 0 then set &errds;
424 set &delrec;
425 PK_VARS="&key";
426 PK_VALS=catx('/',&commakey);
427 ERR_MSG="Rows cannot be deleted as they do not exist on the Base dataset";
428 keep PK_VARS PK_VALS ERR_MSG;
429run;
430proc append base=&errds data=&delerr;
431run;
432
433data &outdel;
434 merge &outdel (in=a) &delrec (in=b);
435 by &key;
436 if not b;
437run;
438
439/**
440 * add check
441 * Problems - where record already exists, or base table has columns missing
442 */
443%let missvars=%mf_wordsinstr1butnotstr2(
444 Str1=%upcase(%mf_getvarlist(&outadd)),
445 Str2=%upcase(%mf_getvarlist(&baselibds))
446);
447%if %length(&missvars)>0 %then %do;
448 /* add them to the err table */
449 data &adderr;
450 if 0 then set &errds;
451 set &outadd;
452 PK_VARS="&key";
453 PK_VALS=catx('/',&commakey);
454 ERR_MSG="Rows cannot be added due to missing base vars: &missvars";
455 keep PK_VARS PK_VALS ERR_MSG;
456 run;
457 proc append base=&errds data=&adderr;
458 run;
459 proc sql;
460 delete * from &outadd;
461%end;
462%else %do;
463 proc sql;
464 /* find records that already exist on base table */
465 create table &addrec as
466 select a.*
467 from &outadd a
468 inner join &base b
469 on &keyjoin
470 order by &commakey;
471
472 /* add them to the err table */
473 data &adderr;
474 if 0 then set &errds;
475 set &addrec;
476 PK_VARS="&key";
477 PK_VALS=catx('/',&commakey);
478 ERR_MSG="Rows cannot be added as they already exist on the Base dataset";
479 keep PK_VARS PK_VALS ERR_MSG;
480 run;
481 proc append base=&errds data=&adderr;
482 run;
483
484 /* remove invalid rows from the outadd table */
485 data &outadd;
486 merge &outadd (in=a) &addrec (in=b);
487 by &key;
488 if not b;
489 run;
490%end;
491
492/**
493 * mod check
494 * Problems - where record does not exist or baseds has modified cols missing
495 */
496proc sql noprint;
497select distinct tgtvar_nm into: missvars separated by ' '
498 from &auditlibds
499 where move_type='M' and is_diff=1;
500%let missvars=%mf_wordsinstr1butnotstr2(
501 Str1=&missvars,
502 Str2=%upcase(%mf_getvarlist(&baselibds))
503);
504%if %length(&missvars)>0 %then %do;
505 /* add them to the err table */
506 data &moderr;
507 if 0 then set &errds;
508 set &outmod;
509 PK_VARS="&key";
510 PK_VALS=catx('/',&commakey);
511 ERR_MSG="Rows cannot be modified due to missing base vars: &missvars";
512 keep PK_VARS PK_VALS ERR_MSG;
513 run;
514 proc append base=&errds data=&moderr;
515 run;
516 proc sql;
517 delete * from &outmod;
518%end;
519%else %do;
520 /* now check for records that do not exist (therefore cannot be modified) */
521 proc sql;
522 create table &modrec as
523 select a.*
524 from &outmod a
525 left join &base b
526 on &keyjoin
527 where b.%scan(&key,1) is null
528 order by &commakey;
529 data &moderr;
530 if 0 then set &errds;
531 set &modrec;
532 PK_VARS="&key";
533 PK_VALS=catx('/',&commakey);
534 ERR_MSG="Rows cannot be modified as they do not exist on the Base dataset";
535 keep PK_VARS PK_VALS ERR_MSG;
536 run;
537 proc append base=&errds data=&moderr;
538 run;
539 /* delete the above records from the outmod table */
540 data &outmod;
541 merge &outmod (in=a) &modrec (in=b);
542 by &key;
543 if not b;
544 run;
545 /* now - we can prepare the final MOD table (which is currently PK only) */
546 proc sql undo_policy=none;
547 create table &outmod as
548 select a.key_hash
549 ,b.*
550 from &outmod a
551 inner join &base b
552 on &keyjoin
553 order by &commakey;
554 /* now - to update outmod with modified (is_diff=1) values */
555 %let fref=%mf_getuniquefileref();
556 data _null_;
557 file &fref;
558 set &auditlibds(where=(move_type='M')) end=lastobs;
559 by key_hash;
560 retain comma 'N';
561 if _n_=1 then put 'proc sql;';
562 if first.key_hash then do;
563 comma='N';
564 put "update &outmod set " @@;
565 end;
566 if is_diff=1 then do;
567 if comma='N' then do;
568 put ' '@@;
569 comma='Y';
570 end;
571 else put ' ,'@@;
572 if tgtvar_type='C' then do;
573 length qstr $32767;
574 qstr=quote(trim(NEWVAL_CHAR));
575 put tgtvar_nm '=' qstr;
576 end;
577 else put tgtvar_nm '=' newval_num;
578 if comma=' ' then comma=' ,';
579 end;
580 if last.key_hash then put ' where key_hash=trim("' key_hash '");';
581 if lastobs then put "alter table &outmod drop key_hash;";
582 run;
583 %inc &fref/source2;
584%end;
585
586%if &mdebug=0 %then %do;
587 proc datasets lib=work;
588 delete &prefix:;
589 run;
590 %put &sysmacroname exit vars:;
591 %put _local_;
592%end;
593%mend mp_stackdiffs;
594/** @endcond */