From 741fe29451cf65cf55e1b924dba64860b9dc1991 Mon Sep 17 00:00:00 2001 From: MarcAntoineGmd Date: Sat, 7 Nov 2026 22:45:43 -0500 Subject: [PATCH 1/2] docs(database): add full schema diagram and documentation --- docs/database/images/schema.png | Bin 0 -> 88003 bytes docs/database/schema.dbml | 170 +++++++++++ docs/database/schema.md | 159 ++++++++++ .../sql/0001_create_users_and_roles.sql | 2 +- .../Configurations/ReceiptConfiguration.cs | 4 +- .../Data/MoissonDbContext.cs | 9 + .../Data/UtcDateTimeOffsetConverter.cs | 9 + .../Api/UtcDateTimeTests.cs | 132 +++++++++ .../Docs/DatabaseDocsTests.cs | 101 +++++++ .../Docs/DatabaseSchema.cs | 154 ++++++++++ .../MoissonMontreal.Tests/Docs/DbmlReader.cs | 273 ++++++++++++++++++ .../Docs/PostgresSchemaReader.cs | 131 +++++++++ .../Docs/RepositoryPaths.cs | 28 ++ .../Docs/SchemaDiagramTests.cs | 75 +++++ .../Docs/SchemaDriftTests.cs | 114 ++++++++ .../MoissonMontreal.Tests/Docs/SchemaText.cs | 49 ++++ 16 files changed, 1406 insertions(+), 4 deletions(-) create mode 100644 docs/database/images/schema.png create mode 100644 docs/database/schema.dbml create mode 100644 docs/database/schema.md create mode 100644 src/MoissonMontreal.Api/Data/UtcDateTimeOffsetConverter.cs create mode 100644 tests/MoissonMontreal.Tests/Api/UtcDateTimeTests.cs create mode 100644 tests/MoissonMontreal.Tests/Docs/DatabaseDocsTests.cs create mode 100644 tests/MoissonMontreal.Tests/Docs/DatabaseSchema.cs create mode 100644 tests/MoissonMontreal.Tests/Docs/DbmlReader.cs create mode 100644 tests/MoissonMontreal.Tests/Docs/PostgresSchemaReader.cs create mode 100644 tests/MoissonMontreal.Tests/Docs/RepositoryPaths.cs create mode 100644 tests/MoissonMontreal.Tests/Docs/SchemaDiagramTests.cs create mode 100644 tests/MoissonMontreal.Tests/Docs/SchemaDriftTests.cs create mode 100644 tests/MoissonMontreal.Tests/Docs/SchemaText.cs diff --git a/docs/database/images/schema.png b/docs/database/images/schema.png new file mode 100644 index 0000000000000000000000000000000000000000..2efe4f36c6892221c91564960d0900915ecfb032 GIT binary patch literal 88003 zcma&O1yogQ*FU-urCS7~OF*PSKpNSSiV{jU(kUsew3Minq#y#)-5`ykgmjmnbSRB* z=Q{6u{`VXAj&Iz%hvOV#$6D)|&z!$ngg;PKAjG4_L!nTFN{VQ86zXCG3WX_(g9Sgi z>uxFz|GVgNTS*fK9zHncVetJ`*E`y-8jcpO9wtv7qbwa9?H_Zym_2#?*ullh(RB;6 zSqh%SjXX*2$zu~&Yexr0O>6tdC=C-=Mm`ZnRY!M50X_j?Mm_;C0YNdrn~bve81HCk z8WK_jqEL(|CA6%jXUh8YQ~jagGwJP7yH5lWWh`a60byJha5rnod1@25we%XQ^>mEZ zC^x2ylI*MXCN`>cEFR6+Wf(j$?e@=AfMmJNHhaHRTuY?s5R{he`jpMR8$<7>I z3!aT?7NeI#kE|r_U5V&u-Rd;5^SSJHzbnLX(sQ_!|7+qMb4d-Q%&MJ_8~o^@bM4hZ zx4TsQQe+0cclWp__pjGaRWu7I2+w>X3%^Jfj^+@Y z8A1u|WYfFpMM0$ zUO!dkV9!hw?99xSxj%JFvcN!RvW19wRa&f<^ZVMKm9@1=dbAaaW??n#@t}fPZoNuQ zS4Uxz0qb|0N(#Mibg$9e+}!;(m5H^hH#Sr-IXB6|a<`J!2B-fn zpF7D8_m2*{UzLLM#7A?rF6#)& zkE`B~G?>ej??k;Nbj+mZtH!4$-Ak*ASQ4-x-7}&S@t^yv>}qdya+tScY3u53LE5Yt zRz#p7n{vRQHYx^7lj~6$rRci%&(j1d2TinQF(0uI(lpAV@#wwGxu3&Z$FFX4AiFPZvnOFj#@ zy{1U8q0Vp^GMv{XB~|mdMCg1cBW7jG1*P~!)U3)*Gk`s<(G0Kkf`r;&e~?a}4^|_g z#lnZi-zb6m>0OxT$L}_Tag^$(*^QsQOs zefd4A_cGc6?0#bEPQkX&Q>L9gZVOp-1t)SRD77cjFudrJKK*QdL^sGxa%+la%P&2o zoHF13700S_VI6LLGut9u+|{S*H|wQV2II6wOcZpb-rQW}iAlXo`CR&5Oj#n=s%K-iVCSa+S@(|D zcb?4z&fijereYzLz#rQlM7sCEP=(@N6m@jOFd;{yrrCZXqmc+2`y`RJ&`oV@@kl(! zL_d)sI*l__n~Us=xjMgLjW^bU+}}CYg!w;DuNcnk-hkt3r8($(UY&nAIz9iBy#|ZZ z@^d_fE#DRFU%2=ywS{=Bf*5E;=dFV3*O!l9$sc|ExBc%pSA3V#GvBYQQAY5juBx zYw$Bs*1Vkly(Yh&Hv_jbt(RJz$6mdO(#TeB@H`+0I;_pQV=2>{6rsJh!kcSt6_lP6 zF<$mSn)uXyWshoEl1h9~Ro33*Q-{mT3q2O-7FEuc;E!SrwWh+TxyI0+?^LIQ@?soY z+qY2??mg(kr*#=+kD`7QlzW;@VlXW?p7>Iq2}zPY@xH45`-Sr#&0Fz(kC}Bh zClXFdjaxaUJO;J9-*mvX0s5GfFHI#rIYwX{9i&jkSIrh)4#k~^?)3cAC|VFALQpcye;O;KQoBT1>!I4 zG*@UGa-VQHG)i3%>4<6Xg5%KCxrTL1ERmMm!OEs7rJ0kNdDX0|&!9Ep{9cvk*Dt|~ zcbn2&mYK`$8?}$cHXTlWXyo9^Z+}~F$!HTqe0HKj5ipXZa(`OtYM=dtZRz@s9y^mn zKYsdV?u2uvEiWb*lqQ$y1RDy_*(;LS}NiQ0L~i=M>tz2gyVYsS|7VY$_N zt8eclu$b0}{PDs}JMtucTPcQ)+HQd>-<Qn6T_P8eykytI<^aO zeg1czKBnsoJ!0Y+B1Vy1mbi#2IqPB>OK`*TYQ*q2621c4$a!7!7RuQ1*jnOThYV$W zoH*aVMyTy2N8Y6PWuDv%mi>pObvEDL+1w7_XNegjROY2z zpDuUxJ)E1~8bqO9w_+B23@nq6+Wn%7`n0p4BI?<2aZ&!LWuS#9TFPD9rH^1<+rL=$ zcG)i(L;fnZpSLG7722H~Vfmu^UYGGtS@Kcbr3!vW-`mZrCIDk<;PH`h7pYGzFE*-V zJ%k^nYe~nQN_r{7t!o2L)(Lv@@Ar0gCwnnnO=pg>5&o(#T|c%kB1h#}^LAE&FS$YtRk#yd`Z6qxC*k8aFL|s$46i_p`+kSz+qzj* zmzXu!FZ7?&Q%R3GU%ZqZ*SdCPn2=(2aQh^DUtrShX<=T($D&GUYd%!eH*OT_p2pf% zr)Tp*A%pYJD6ya=aQ{*hLTQ>q=Lb>_pend(B<8MS-) z3(=3XKxp?G)%RLXciy!!59atA{TNJvWoR|FO5K%~^b*}nqVss;JHrT(|;>ml>WvU6k9IJN0DM zrwn2_%I2dAxV6KyP4zwi&Q8&vSV-ut-|17EA*U&FX)?eh_^8&m(r@Lt0S(0WhP77k zFMD{{s!YGX6EJE#Mb7ZNl*PB+fYOCtM}j`5C%?pblpht4|2pvVBF}Ke{jjG*PuksB zd`W*vCdTBd6|YqbMx2$4;Io9(m?)bwhMd$aDEeJpP$0a7I`wd*m@*)*dHRIPAxVH* zVYg?ycq7liQ1HO+(#WYL!)CfT#TtELl*^T2WWaOOY6cn`RjYP+*NZK*92E}I+fq~S z%ty1NI%sEAW#y#>(gt5yjk)sX;MecVqyuqgYjpZ&I~{%%zy5fx$A|Z?5RN6e<3VRE z=_zwF%ATvu9$lOnA7#2$OclKJ@KN*6`wDnYa(|q-Qbh4!iqCh+9Vwp9u}d`DQPU?< zVxziwADaG+39aALu_MJl_?fac;tkvRQ>C+(7@UPRp9%Sb!qGdO{5BdRI+=TNm($L< zaQn`z@eCU*u`0N|8cJ^AD%_7-WH34l30>}59G5mAMjZ+-1&yy!#%V2lo@6t5nT6@j zaHEwN`~C%#)NDea8b+Gu!idsXx(m_gM-tKdJ4LPt@Z~)}l5hCC!Q@jXo~7r;Z+Uk3 z;MCfS6~&mfEpb*a((}U^E`@W^P7GZifa*E^ZqijYZjakphU^GkIyksVMbN*t*T3Ql zjLFKzrZxV@<-@+FMGD^aWv(tjEfh3$Elm!sbp%@ZI_F}rdl#mOD-V}UrNvp=4!o6wRB4kQ>=AX=-?!#A6+TukrDVPxyZc6WDw`PrN# zqT}{g=FGEqX6Z$>wO6Hc(p!>e>B>E4hKF(lH~-XbjOsV+FP$|N^*+Y^Lr)MRi^pL* z!P{<68#I6XY>3X|9z)dQ!B(+7ZVc_n;YQ)|To_HzkVz$mrq}uZ&Z{gv`5W&-0jD%+ zZ;EqMSd!q~QB?rTov*>I1y^E)**YJbvBYyF09tL65g@lC*@N{&af`}o#x@Hu-UrZ zWY@(27s`Bb!d{ZSctc-;Lf}o#$sNyRrgN7FRnS7HKR**6Ph}T0o5H519y_-6X`uDj zIk7j=YTbIJlOPT3V3OC>+T*XrFnZ_%!E`+dwdL-;#`GgIu-tiofkX_g=Pe16r=|Eb*q7I z#j$0o`?74ihRJiT^+}(f;*V&4GUk&h9+k@(lx2>U;o9`7%?hRW!U-j2==`%XC?0n& zRqV>sr%w}axqKM^9T5=`o01YaF`@h8=g+LFDhjy}0&9;*^}P1^woo-ay@)SgzNj`U zD=YI^^)t?t44HW~8rI<6q$X5N6}x0v5JRx(92gBintSc%i7)3-VbqlLBcG+22_GbPIRR2?mkh>R_})Q?!l1ZqL5>bbv;UBj>Y*$vI^RHk(<6cmQ%ib)(bEeeMT4FZEFjYs$_xKMzyY(C8YrDUHmpaYg zMimm;EpEY?Zbbh+I8cz03HW@#$-^VBq=c(riz~Bsp0KwzYId}{w0&@}rP<`XDDUa* zZFY8gWKpd4=n)n+cBng@Se)`D6l@Rl!-o&+cRM@fb2T{+kB=t~2T5WJ4-XILmXjSr z^7CzDdi%P&18%?j^j_(8#-n_ugr0W;RvDU{lN}7%>fuVS*)aGdR}){o`kobgmpV~E zK%m6?z(Hbv%FDTi^6&^%&l7s;y+jGV{Og0 zbm|cit>W77+GPV3)fCZZ_mYL@*T>6I0YgKYTzXXmD)C&hy1JtWZ%dz9ow_`JoIReH z{V4xir$A=a1x!r-r@KE?!!xVuUg*fm%69kk2_%e%z%-rM~;nq6coIMkx7DU&)AIk~E|R85g;Fa9_r#IRm?pt2}057&02K=$4}!p+*B z^d_1EDbZ0a0RbqJ{g-)LIKj7DVmobL){dETMq_+}mKQl{@v_H&COj9y*)vAhN-e%o ze@*1aO!qs!URGYd1J5y?tg;V_h>$ZiWtNbTV2o4V+3n$F;pHWck=VtdfBJ)z_1ZO) zwUI)L*{%HIrq=YcKkQ*>=7!VVp4{T%E~p_46g~ylr~0iX+u;IVy+5)FQPhw3Jr_~2 zp`qB&P+?of#>Qe)>V7+0d1y8K@nQFuFBkfc{Vivke8;LC8Ab~A09``Se0-RX`eZk| zTRrR71{)y^uKeKYUXZ?l!M9G6#e&SHut6;@oj0#v2mbnHHRU#H;CZ}~!|%CmT;4@Z zvrI=b2)n|9W-LVqZGcNe)TzSTIJ9}Q(J+QvugZ9Tecbcx=qFsb!n*kU{2R2iv|nc` zpq8Q8m@g*(;&k1dx-~RBY_hY^ktppiMen`NU)}{$dAY`*in8)uRi$O{M>-31%ANWo z4=TL5Z{ED}voF01InK4B`rcLBOP4O`r0h`B)BD}AXeS#O?in4WJU{&@?Yz}^keQo{ z``IRfUN;F1BM(v%Qx_dQtj{CA2>gq4yBK!OMoBV$9>J=F| zB_)2KypU7Us$P72yy?N_wBp^n?Q|YfCVTQq1|2I!U%XY)H$p5NNZHKTMmz(r$9-`Z zNw2lMBW~dkHHbC7U0dZA_pLBLKTtmF8Ehl41*+vw0-rxG@jpLv$A49M)3hDu$I{ZC zRUtFj9Sr`bSJm_GY6pE44OU|%d)FjO$gXgy>Fl`6!6>=l=a2P^@NG&8?fj4Rpt%ea zUxRC_nDG>ZU%R_ytgLdkr`2Hhw%>3O47=M{q{$iVseoy8--9PFH#eZVTBO~yw4=k| zUa8PxC9eW^_^;J|cVBX|WLUA}?WDIa8x@oG)YFquNQnH}wQDHG0uL|(G$JHDEXzY)yucF`7jGLMzpARL zI_~9M!ES<~fTn0m;b7 zP!+;;zkmM@j@4OQ`v4GwMMy|9Q8+O@o%gG-sfEYf6DKDb*ysTxbox+x6Wrg&AC~pQ z=ivokNJ>gd6m#R$GjFXl<5JNEjgb>e^!m;2H$d3$@q&{q}D92OB` z6l9@%%^tJ1U-{We^bpq&PPR2aZAv{UzhNKnq&beCr5$C@v#|5ymI;wQH9E*Wlpd z%DTG?!?l9G_bd>bcwwV@R{ri?0@XB$aBK!25@%;;j?E!E>?bUh48jA-;QXpm*-v z(Jr?j1d9w{F*Hg(uRm4|C!6($(1=X{od>q`Z(bcFq!FI$e-q#1Nc zrzcNZq2InJDd8*6N}`UE_arAonVOpN*$i=%6B?UE0?49Vh$|i3nxAiJiNg4*Zzpi2 zD_~?VLh58)@YkM~#P6?yP^4ioF`+p*?1tVOH<@+@QxmVy(>pkmTw`W_E?5?xt0Is@ zfJ|mMp>3EZG&i)0&^9)lKuR_neydJ-E=G+t`HH7`Z{GUx;|B_LwqMBUlsbPFl&-Ma@Ynj&Y( zh-i^q2DR~FKiQ1--OZbBvu}Tg*JTddyc=8zN{^Z(svh32Ec$|>O?wMVcU0ZR&hE$8 zOuyBf^aCcZyP}p0<3UlMXS%AVSCFW0cIt88bRtlS& zn-rA&tYz`p7z7z7JIc(l+EY>A4iR+zA5PzzHQ4ApORCoAx;_Y0NzcvS$Qr>q$SS+7 zjhM6s;cBh>)=y$zI@8|27*K*wt)>N>ZnE&P`8}>GO*XP{E;hqwBiB^_L-eA4CJ$QL zQY4u<++%hj$GcPA$Fv$9E%Gvtrh3EHAa3&c>nxVbcYLjOe|Vl&+`v_d|GKsI@c{#J z7HPOYplD;xX8i3 zJUZy1Sw&Vdpetj4yjj#IX$CF?t@YVY&Xp2`A>Uz}(WwsX?J<4C#1?_E&z=%o(;=#bXcx zhA{0M$qZ<`yowKUuh{cZdHu0Ko72shp{w4FuKlAFq(Piujgv%;xTI0Z)&AE?V=t4| z|F`O}hDQQzY)4;&l8lu(bH*ayO%b9GVPRff!~NCfWnsDaP}|?WeG{%vg8`}eMK2~S z%YshbvaxsDIAn%=!b z#0u7uhnYB5bbD~&nIZO}*Eu{BJqXv%?X`SX>7lOH($oi6s+1BjlyJQmEtAsaW|k(bBHT}{UR$a@Yv%mMUs zbaVtq{wTkr$@iq-%NH9V>h;;?=0U=ggam99P|egY4>_->rc2qEAFH3MYkPhu&X;MN zgWj)Q?O>2TVggtwx1c~sCq6MT44;xmTcR15)aTEiId0sLd-(9m_wV1mvfd8GT)A>( z{?|a(hN-%m8Yw6z7$^_`Zl4MO{kr@mJS<@3Vkg3?+*UQY^lSRrFw<*mYZFDCnXBx^ zP0REFEVn$r1P!l#d&2+hH-mnI=T6yd{S6vnyHBsKsq_yG4drq1^OFi%d@b>8;#YeA zfMafRGy2OHWpi_L*Q2KE*ROX2Ers?&CNESHmynRWE*_tdfWJhy2(0XuCg<>#JcieI zT<QhGqB@2Fyl?UHd@@J zz~Flk1#PtBMsdS)gh~HB-c!7PAE2|j00E6K{Ie_JH2JZ-9MaGrQGV6H>=vPqYBV0| zQ!xG&U;WDh*|LT54x38)g|CkgP+wDDe{gtOQ6Y4AbYwD7VIB73#RU|w7sxKSUIKXl zBi(mDX?*u!*1x&OwtMNxy8&xettAlfCt)5SbM5?W_Ph-sdr|ko0nk(J5@U=IVg@3V zsLL|JNU>22u;0C7@6h026ToTEizUURrKK5gadBZXkYLpe+f;V^h~ZbN0K#?mtq?9f zJw3nj5%gK=4!oIBrDc$5BCdjT)1Tc_OrDLS2>zs|~WoG3)c25KxgtZ%VkH zL+vG0iQ}vkoc6-$(Qjnv0As`sM?Y_GN8e2rCIq2K5Tr4OrXvelNiXu7H*Z23#&f#` zdwy$+IItxEXccXaYU*<+XJ}b`mc1!6r=qS`?Mg*IsOO=fqR+O@&+dB`mX(q1?Cc=# zk5v8Jw=ZtFEX!mK%oY3py#W{`*o=mnn&irrU?8#{b^F`%ZTtWafFkiC>QjdoTtHD# z(Ts)$TA&=MUknTkFe;*F-g4$VyC%kVL3%^Eb^Vs$+>c+-=a^7{=@g~oqsq#DgHjS) zX+Lpa>gXbPk3IIA)Am7MWhB-DKPP_}rKg@=czCxI8+_*O4c{eZSYG&%2p zXtP4PwHnK^1 z^r&lS?96y?z01qX>+wrg&Tjp1FS&neis1hJ`>bqi7f_qc=Y9m#LOAvA8@E+eiGVAH z0;+56>A@G7@nk`M`z9g5?DMN@2)`|P`rATCNXU3~D340qolENM&V!+3Tjx`@xk5 zMn*AFG$NTGH{N(uAancn1$}*eKHp>4yXoL+Z$o)34&`a!;NwFCEfDYyz|k1!wGzj_ zvJMZL&!gt%jA=!kP{8@yUtGDd4GIYhE9=Fccy2kM5+I%M!&^aJ{aIF^?gx?U0U+Vi zdPFZenD$`kO}Q1=7D|k7G$JzPgt@-Hj$EF9#6y9h(SL+E09s>>-qqWl?Fh&Q?FgfyI1dj?)O^66)L#3t?gU<;HQNjzrubcD#DeZlA1{aX!vW5)1X{H z&_zw#6RdA~(pVGFFNALePrbp!CYn3kS@isw>H)=uAX+^Rq+ywwnf)VCenOh(Wp-5@ z{vQ35les|R?QWJBM&LBCN$yMRT!a%rRY!R7Ed?JR378-|z|a#V zieT_S={f#12{h=#-6cHnJj6*E>hXiFj_~Bj+w0T5ye)*CFp{2k& zMW6h@L3$BP7E&NLvQRZh4bM{}^$l+atx8u{x4_F0&~A#9FE!L+>KEXPbE~U1?x>)! zBKs_bm`;#BLZJ*zVT(}-+ff<%9X&>x8%zpb)Ld4K)bsLk!8E^P>+;8NI2@pJVkX@U z!KU{_K{qTPcH?vJZa&!*mpD6I#33S**U-2OJ{v9~#(sNyQ`C9kqSW6%8j%dr(30s} zy`^`%S;lHz*w3IKnb_H}q59fF2x+$WCmqe^TZ2o=%gYaEjQs!J^gCW*|C(~^G8{9= ztBoCtpqtEXZSkH;0AoQFmPbvn#YRGv2EBg(k}&6u8;EMF$w_+ovf17&>3Ap1k`nXJ zWRX_|vCcuDL#hP@1Fj1wMl?tVc9T_vyp4az@bK|VK|4kjUM7~0q{2WQLdScVl0v}F z&YsUOPRnn4ad&q&NC}%RicXvYra#-Fr{n70-+Fe{6daV3v$L$CA`ZY5*oaQsbF$`^ zmTfaL^w2q57v)gUo$yh>U|TX|1B28wd3kwFjty;WKK37B2bi>l$Y!gXbiZV3{2Pl^ zz;D($H|;UoQDHrpp`HgYfGAe+BCu?v+n@r>x}uqQdG8p z0_D4Nxi48btf**HX$QW4e?DmWVqj`2a(m#NfguYR7jPu4BUuQocl||z@J~a}`AhR- zaBTA+NkenPG#2YsVkLudg`4VFs~-(r4ag)TGxG%yTN1@RirmtRo9GD%34y9&_a9H2 zer&<5kn*CSr6mB!fJ`}_gshAV6i`7bG1uIOTMB;zK^FROKYhkQGNzz_i-2B&3M5FG zJ9mP&6Wz4HIC+_nVCha>pN&BRGl`H+OjFSU>Et{KEH`f8gM{Cbetv2RH5n64FNue; z=;=)r53Q>coATajMAScMJkHQUbRS}a^TQMp91;R+b)hSULBYU)4h)s;_3}QvE)dr6 zK4)4FelUSn3kU}(Rw#+6T9=;-&_WT8?6mSdo5#!yCKvR>M}@kV0GWX9Oe^8p|DzP( zA`Ey(%B-mJ5L5-tdmx~~0&KxxrvNyKiHszGLpJ{YUMV#?=j<9RGgAHV9@iQk0nLW4 zyttB+E^lCvI;>I(_`+naQ**Y|{wR+2aBa_AZL%vMU}U(Nm{v3Z$laWy{|OD0ycl;s zYpmkkv#S9wMWF#JDk{nt8yoWupH8q)MDQb$-p`+lKR&-kIz1{QJNuIK>2~1DA#3q( zy@@G;z5CaBc`3z&P;)CQxEXr`T#J;|x8KP?1A!I=dytEEQrP6z($Y0nyU0=ZCr@sJ z=9v?e(PI1w<~He-E7#0-yCSc$yg4~KdIx|C1gF+II*^GrD835XI5<=t-?G!v(gH2% ztwF=Y*x?7;Kp=9NSuD^Bo*jo^4UOK+Xp)UOt~+ijnh4M`n)OqtgKuUn4Xg&H?EVTb zbfVR|q#;!83M@UP11B-s1u@l5ZM+RVFnzn+E>Dc~b=T0VK?6WBa&jW{0XfgtBYy+9 zM-HuMFdG{h#Qx5fw~N|XZ=R<)54{TOrlxBioaoIc(X;>+-Yt}$r!Q5!dYZ-l*|TS@ z1*VpkKi&8ErJk9dZVK(+sBhD6j1mlr49|Scejjgb%+u2oS-y>Et^DEczCKEp^sMc7*B2KTcXU>k?tD{_F5*^=j@EDf zg%=SWF4K}XSmU%qM*akwfM%Ikq`KvibciqjWLha7?Xh`QadAD4h^VNJcXFXlgZD%* ztWq1zQaY@9p?!eP!&_jGF<|8c*wsYW&jVCKS+>`>ePvn?A10kPz3Ev3yRtPh6FQ3O z8im?ytRgGqI&SCW!I+j=ixgTkR?@<+J_hTsH5eai(v|ifz%z4ptIDP^TFZi~0a_+{aCEaUf*T+}c z6@-L^@h^rq2&jgxlZeCu?c)6wbn`BS{NJg#-b<_}2!qJHWkCm&7ozMRsELM&hwrSj#yaeM2)=y%m{c_`jN!tkUJl?BY*Z)YZ zuNQaUoWg}h1YS5(xcQeL+{=hN>T@_B3L}C_+OJ`|z*6Dt>L; zY|yT8x3}j87iRFWLho3j!$~>RN{QfG=Z(YDSJ&!w`mn6{P~B0oPl zK0EDE7FUfAcV&T}AVUQb1yHYud5>~}_PhX4rxiI>;Bhc*DwAccd*R#Q80j-Az;mF1 z`2Srqng{K$pn00KDV0zpjJwkBm=nvui;m4goHo(~+f^A3je{|N);^wm#!}(e)TwHgl z{yZJfJ{yLPeTYQV`XclJAQP}2Bzi*;5uA7j{a3C_ z%D}~?{NaI!PM4@0&Y{`J1CQRNWAteB^71mhk^k67FQuO@nC8)6eV1o`xkJUL|k5#OTtVX_5DX{0d4D#sBSjF~U2DleR+U@2Zx3^<2 zsD}S}7`Xi}H;nyHb@YGFQwx>F?-uy?A zbghqscg$^u0GFbQ3TTpX--?pr;*e-fel#~P@0KfscxwBPRzAYSdDFC+zVzb<@J}f| zohrx`tj{#o_t*l|Wx93iD)b|Gi?u(ET8|!ufI|0=^!j*h%e_t*Qbu6@R(jvd8j#6W z#|2>#5;|mGylG&`-QK->hXL`bw6rwIrV#Z!Bw_|3C3|f_ka#O+{Z0r$rmNYkTjN<1 zgUh8?WhZNH{+`b!6(*RVWiO~%h&74~79?cRE53!(6-gsPR9adJqNa?xIvErj${76d z?VTNXCF|MA>^UUC|0(>*Ms1M#5%L3#oVAO6Px~{_4@LVH&V&c%| zX8)MJ;*TG1>*`XUo}QA>(nhYY+r5F7%F6nFnxBb@35GeFB|TglzEV|a9RD;u@R^c* z%)KF|fY_uBjg9xx93uxoK?3Oo6#$zau3O9S@D=F!0B=$kL{R`C_rK>H%R$xy$+{rt zjwEK>-8&l(X7FJwl2cGD0Gki`$7^(|cljD;EkxaLxDbIuNZ0`~1|k+93^?8Y>=qIL zgHX}a-^&QjM-ro*Q8bWP3IVT?m6g>P`1K*gl3u2!g1UhT{kA~AwoF7?ijV{%0U%np z6N*grFWR00VGfImdJIy2x)?Ae&`_P1zg`6?rVX+i;JdZIWRiQJqZ6Jg?y&&*H8^`D z`GSawkmNyBZBdU+TChduS60GT26KR7(gB1A<)f|aaSsXw(&kvhQ$dKa^^c8(UAghd zcqmuR_cZS_ZRz1IyCDMz1hD>8f))e;BUstGg`My!MzZkjvy&~e)59GrD=QgIO>$tM zvzwgs}U?00r# ziN15k1lUsQste@PXvJJ{5bXk~L_{9U%)9`A1YWrP`*-=_Q&3&zfBs}=otmDSdhqDc z2zXQGgt3Z2Ao@mhfkzdw1Trf?62}CRRqeEdgiz3_Zb-cx%>SGTc4|;?Fb1mW{LCA8 z^gOIeSF&SM_)<>-iKE2P$SxhkU!$75ox#3k$J;4Xr?7@Qv z0pNy0b_S7JK%+!zL(9lF82CRi_#=>KY6T4&8PE`{cvnzB040tfn3F>d(%9>uka_~X z132>>LH3Bq`lvt~!^6V^2}z*>U=*5x=7i|@{l|1Wa1yuKUL(EXka*

^iPRyH5OQJPB~OeSc#T8<6MN_&5^4LlP`bU%`}uXj1H(H$f~4 z(Q_bM);kd{axEa#P)tmBuL+5EVLL>gW1pBB=V-mY1D!=tNl8{k1)s;T0S9y{Xd_wB z{=uU_p+Gx?2pcM(LjZ+@2q5kloUWmv5iC!ELX0A4%!pXyxy^{!G?0~K0xk?ggG$Or z6sZabS3yFojg2K#sHCH_^EQkc=2&BB5hz`cx8nfb=)Hb1K^6Oe;U5+r{tO5OBCpy{ z3PUJk;TbkP3iUNvgcw3~iIU#J5G**}T_lOU1Y-i!H;jI-E&sh?EmQ#bLngpAA)x?> z4}7gFy;mi@gdh`*Vg!vFmk>1!1R(|J zDaEZ@x8NcY0orqD*pUY%1kexy^lz5|g~EkW41#h8?F?cYRSwfHklhAF-@2$JvvzP= zSUvLs*gC{jFd-29EG8!A<>@W=bt=#hNiJW;%-B0u{YSoQeA#bpV?%Z(?dA%RUC0VS zrlW0eko`va{=Zp(%OHfo?z;`~iC3>)@j1>&1c7eE#YF&PYOK!f1~Q3|-7`uhV2%TV zljZR2L&zmUAK|_0yu~3Qp-)xG%52l4e$E9+uPv9t{D7c1XPfd8$&V|m^1jTsSih2IaNI#&LB53zE{uNZpG)F z16mmfN1nVJlff#!P1nYm!z)_PRVvvg5n5;&`HjimH zN_xLTlN50eA`rP-H>c}&`uxu{tcFOFvIbWs{?2M}#=#tGU3dz_80Q64FhZ)M zK371ajZgMBQ2h=ib})NuTvycV*FYu#$wKGv!4iaU-A(KuUmR&eM(So>>t{V0E~V{!(Dn7A8S%&>wGr2#Agi;H+rpmk>q z+t{DxSu?(ZY`6fj#o(jC4fzC;C%^3o7leuLYM+X6a}&Z+LY&d2`j6(`*4q{q7S(+W5}tgtbab()soV_~ky=A8K(7#Ut;&9q54^FTub6J@}odE7y1gk#HCS6`2xO78;hCS>zQHY;JgnZY6;)=O|@$jAKr z;Fb!=L%xgE(bf(Fi5DdU@VY)J4~hi<)Y?d$R_9I9uNxVrTtA-|qKD$M&vanJ!|dZ> z^@=QKtJlpE6c(lkC88tqQw3aaXJ;3)DuPH?;_d`8YbZwthoW&a_%-KgEl{xOzU2x! zMuCyx8Z0ogFk?W|heN=SN%Xa_<3$zPKB&59=MK|77&<#Dz_6I|%r2@3%K>zxd5HI0 zA5_c?*$PG=zJ|a5`FY<=s=`gT<#SB`SuPow=K&edt*$-K$oLhOTS!qDR#;Uv;a54V znp!&HE>@YZotkIgS3&Rh(t^8NLJ)WYoB^Wo9bm?jT)i3&=IH`-3qh3e zpI-w=2pcgZ0iTPfP~JnQ_Q7aF^f$nC5Dlxe5(*tg{5`-h3NOTAkhX(7Gi5NO*PE-B zwGGNV6Xe}|;ksL|10356GBk(xyY7fg)YVH+@Ir{~W=*#*P|*?a|ns^$2((ZGQi zeE*PwCy}ceR6D%#6cPfwC?rlvEU}G3WdKA(XamTuVAk7_$w9qQ zi8^Jx(S9-v7{m$CZRn(#scDLEMcA*AAGyBEAohc0@J~+r4k;*q+6|u0MEEKM+?0F@Vwh+Rft8mgJ zt$aIRRdF?SU75?h>K;ju#b~Qg@;5%}sCyIfBctN2k;I@vsDZ<|4|K6kOGxNuy_&Qq0Rym5j95*jLWrW;GR5K_@gg_k z0S7r>(p?Afw7pF~G@1@!ICKUVnX6y*EPX5T9{avndF4NUx~C5E{_|BYSaM?F!)vwL zt{aPdEL;@&&mZkEz$b(Dv$o+|Wk$KmHuhKf=p@ zWEG|Vq3$TA5Nsopz@2I(Y+zQLZ zFf>DNnv`mb)#0EG(mtE7{$eIkyHV1lgzhcriThWkVf`P{P|*K4GN(JY;dnE7+6~rT zI_>DemsRu8+S4P>Z~u8xCQqxKa8^-KFLxK&MGWQaR~}d3rrsx)BRVc*4g88X!+nmb z)t|BOxc2xTb!L@$DvsmAs<-4fi}J~67`m?cE|&WCYogy`VR_MLgExl7p~~4$4)t9o z!{PLRS^=&hs)lDWGBRe+si22kk991g2SvVhX99*y*!SKaw**2z!uA-701QcoWZGmg z5jApWFo<(7x?*8$08$TaEJs5E3rHJ8S>AUcmNyZtC<-MCX|nt#%iKi(R|}x9+WVz} zNuOC$GnVa(kB=V}yhD}~S`8I2G zO-)lXC@c&o^c-BkXM zj78=_r7h(X7Z(FR>E2f=UX3$LKQ5E1>Di7g|K8ng1y!2>m7AL@qpSP2r>iu&p+vY0 z6ME{I^PeBz92YyIME%biR8G>Q{po>3c0%e3Lo|9Y+O${_Tqy){BLoK+-6G_O0kQT! z0A<<-I5bb(y=?WHG@;_0s?{q(6-XZGV9JF!n)cRnpbN%d-dsn7CY15d-c)2>LG+37 z1v8R?2Ib%#$m|fclK}*V7{TqA?=ylO5F8u~U7H_^kUFS~-kSjK1#1E9>2$RF_|L{9 zB|^FNc^s?muB@z#0d3Ueyn--C;E^Clkih*R^dPMPJwXsTyzBxBLD}|`RV{se#L!lR zZ$zI%iX42Gc7S9g5UaOLd-}oNFLuagQ~S+tIBihmS|D|fF(kqat(?z$-wx+_0MK|8 zu!!dzFA!sT@V;~u@MBW|JrKpgLS?{x98^M9BuKvn4mji zxSgka#s0xy1B1$7B19bvQ6bB8UkZ337;kSS9GC36vlLXpZ4(OjCy zP^?8rqA1GPppr@&l}sr@XjUmh(j*xwWO_fBwVwZep1t?~eUI0%j$>~g`rW_#zV7on z&+l{zeM*#J>|QoPIH3FwU<#s<19)^9Fc18Y=LjXgv2y>uH)>3Q2C=46G|fmP+eqko zC_{$?gTqhgu;hB5`?MeL=|UwQpM~a#?pXN_;~G87v& z#;tgVmj3x-_f|h?$A(Oc8xC(0!@2eJICGhyg{}_;>T~*|4iaQ@>&jDw*XS>;5C@}c zh>zf8`0w-Auc{@Odk($avdS`l{gKRd!g&KTz?h77y1C%mIc9dMj1&Gy6DL5qLlz(_a zg0#@B^6uH}%oQC}A4TI%Af!050!-b0XE_Oqvo!G+)_fLI3H)pLQIiKa!B{zQOi%PZ zVG9FZQfZ zE7Zs9H-7Ow+=0W8__d{;;7L&~;Kr@VQ3rI%y%!#!FdH-&f@m{dDE7z}RM#EH4- zcHc(Wa}}XsVPY*pm;?C;&=rx@yMXK8e#7=bk(xDgrlfP1E`%5edQ-f3#W5xs>~-gs zD7-^=1%fCLPaf_(+yQau4Mb^>*5pkR%2H$opV1^b)I3Hv+2|f{&!M4IS#*&6ls|`b z5@UGob$53(R+Wd;-(IRc`L4NH)5%Gx+n{L*8r2FeQzr2>g6>E)o_q|oqVgVs_Hiobx$^O!OD(ZaY=&sY!2dXF@*fqrH|5AwQUWFWwvQj6gJ^H zc=urPBOw7?k?yAava<4hO^8ov!k3F7yTOtsQW&jEF8_vvt=*DmC$=@!=rK2(W1*VI zU(pWk6miB4xFbbHZ@{W6n}^fz;|uQsnx=!1oMJ_rGn3g6#BCpl{4Dqq)&X-QZ`+^Q z>u<440U3lE8A&Qc~l-RB1aE3hq8bU&i>Wip5iWovPE?6P`Z6rJq>hQ3CP<7>n9Ti&e%DS2L&><+sCK7WXQB>0}W%0XTipg zSbX}R&_9E2-Y{`3-&2vktxV&spo8f&wz#f=a=CgtsUkt(T#Cmn4APzAP2Ao zq&1VF`REZ@q>I3K?IeP>0A0q%EqwUE9J8VgGS)_Rrz~w{Wp&OVs=@y|Z5PXL z4?l#e2ON)Ga#u}F&4~J$OgJp)eZe*Q!;bDNU2I^`2QoKol@I;EA58X^EzW#}C-53* zK!GD|_D;H!m9^lRET4S`YQSjOd3G&kAk_0id82vAnP^Q`IUS4&~4yBm4yBy7AFzO_Y%Ae2;lcgc5-wHpk6PZP502vm`U|YzjdKlh?vwm|p!H zzI}EGs@#dRSID+lhzGTXvD-@uROcEQZMEvB9_aYJ)mhYRFt>o5kLlY!xorKM4q9Yt zdOBn68@4z44@N(S+AVXP>LV^(xJ7Rimbw|;An=c|TXaZ^5w3kD8`sBYXQ{;HFGmye z8G|qn-*d;;7H#dwtSOfxb};hloSxR7y;UHNWBYhz?8wN-=-IEIFlGo+&f&6xK$?pu&DdfYfo)&xx(_&=+U<@BPVVO+y7s%CTDa+(9} zN6HUmKGo)T1VtxV?#gkU5!TPH$?`j)%#a#gxpb6XGd@XGfQbRG}R62Fe-&B(A*JyhOulfK-G}VPaxpw@-;h z8+bJ(1@2j>T9C7&;dPOCD3$k&h;6rGhSfv=exno>ld=b}Kl7GrF4Nb4`C*$wpKSx; zyBPti&-atRU=XzjY0>@aM3<*i4&+OLRg76%@VblfV8Dc#JLY@`3*Lt)*V=ll0QrWm zzo=miKo7OZsE$r0>-(o?opO|p6Vp*4(Qgb{QhcN*Ek`6i|N>&&fQ?=W*ZpzH+=?$JGQgs zbh|Add z-A(7@lsN4-Jv4tQ-Ipyxs+Zhiq?THC+IgGf#H0Ic$FVK-Xg|x+di9mfzftS|9~UE!e?$qcYnfRi*}ir)GsraF#*=-mbE$3$l8wiC=$ zC;`MgimMEY$BencHv8za16tlPdm68OT~wFY+|sg_O$Q~MLiCQReM357LmC0F-r>)i z5{xa#2h^X=V6UR9L2*3k1S^t68 zUQx<3<0A#BEt>sl3_mJmh`JBKsr-SC#r1n%hn*yd(HoIOdt*`BT%) zvT5_)Q>iQI8rf~;ty>qIkY#L!5Q2d>z-w_Z2hsEYs>pc(>@Q&JEPccA^d|o5)>Gu`J0OMcil!NU@Gg)DC zABwh*d08Ywu%3k#X3D9m7h_wl;g&*CdO&H z@_nbFbmu|Jhh*%EH!11hm1aY z)|fAcb`#s+tGh?Wo&IRV?~pn@-CBLh#V{rL&bm0loo+C?Aa6nj!ML(}h`r3Jk(Zt8 zKVje7rk>ib>F54nh0L*j+47lV?d%%+tX8F2yM@2>-Ak|WNqQD0$%2wLG)%q_;9cse zaNr4HNW1_1b5;81SK()N3Im@~S*;UrJlpj_8RdI#TlR*8m-9ZUp4h#v@g%P@kUk#| zQ+5FL+*Bpod|Hfv7`tt!-G6Kqn%2OqGCj6e7WaE5$x@qXtLF7o&XUiKMke1K2r7C| zk_Hzgl+2Nw5@;eB3;_HqrZ2S!8qOLyqY;hvJcEBAJj7QOA2dlK9um~ zuG~hA9X1y5u_`{xM<2VNd~>+v1Jo9uD#}tr3B*X&eblMvktm%Jy=Wo+q-?l2>D~Q( zR*!*-F?9#XG6IIl3d_xI*_)~=@0b15=yw!|7ZEP>96Y$u{;1}*LpRPov}0t5e*1W0 zP!8CUg^FH#2`_|W>X|fk{gd8LQuRz+!gLHS?%!8qo`l&3L}wk(NpbvX9o2AfwB+bh zJkcPN_D`4Z9`f|E*M(ks)yqvy4+*sjTp?vodtP5p+crEobP={w{hPH0zU{YcSf|L+DwC^}l;kXB7tKkVYA<6{{qmv3rn#@w7R?z}_wDIajNP`1(Wmwny>^{5>~El3 zE;y*;zs1+D{V!$U{|$2fZ(z9E&j=$SYUEIC(mNU*`0?ecOP1tK1-s4Ja$ zNU^P6fVoPLItX_`=Yu|TllK;x)(cDY?5-68Rt9GdF;YKp_}_5t|NIcQb90UVr3LsG zB0fL3rlu{3UC$%i_62u}FL~pi*a#QW=!As#mH7z2>~Ri8J=))``k;~)KlC50e=e^K zP4TKj8+6r1Nncb%%6MfCHjh<}a+Yx`pgUe9RVv@ESE| zn_ZHBYu`S>(PCeze_EyGs;t@|^Bh3#2iz1S_e-bu}3+p07R ztv)tv>fn@?g;rxmk3I~ETUWfa8ZTSpTI^-2+fPy*LqbE_W4e||NLw1Bp&<*t&`u&e zlyndR=OtwbnJ;>v3GP3(OGFgW7^!w$yL8C_ux6n;l-VS|5XNdsYRZpxdVMDW*+Xiz zleoNjvTM}R^OyQ2c(o$rv8RHDaMh|#CmA~{DHFGT8HEm29%MH~!{Z5>Ee17ES&0WM z>2|8Du;3wpIdys=8fNfZglZVt^BieZ_z!6W$ssCb&T6L;PVI|Gq`eis&vy0|eWD^F z4qtorW=A7;Z%J!~+-7sIJOSkn{_)OpfZ8JFRDqQfm^Hgcdq63Htk8;GS}|7GRv`H# zXRp0=rK?na^ z9^iH+W>fz9+$`bO#7j&vMWy#}(tBsfL)k$F;< z807xVfu6o}Uln3)zXHFUIH+ci4%xUH2fD$(`AT)Szw?RjPeb*gJJba`5+mY1GpSE zDFqI2vzuGs3|8BnK2Q-vv4dnwZCHTd@o&(>L0gZeFB3XAM6e(t0@SB!2qUh597Qg{ zqOx^G7Msu#%f4@k=;AOh$R0U`Sa8_texar{3<7V{KbaO375x&|0DB^Blu!`B=RDmF5b=V0D~YZFe;_>zObuLALbmEQs{CA4xxB1r!q82V2C89S$C`3xR^sv6h< z;4Nv}LvUm>79Db-6Zs!bqdw#}(BA@tzl6+E;Ce!ME3||LQAZFG65k_qP!bOUlElaV zMN|#2EgCJKcXP9_&CKor4S1S`Ds9K6%x*C;z)kV+UjjjwI* zkC1sBRFPX#%`TyNxN~P1{h1g_asnP;Sq(iCePYAe=@B;Nsl6GyK^8QPjR(%vo4ayl zf4KgAWJ(CFsQ%u565H$FU4-L@4X*qzv_c}QPI6<$kN4e0DJOv~Enx>;s%U^;KRk6` z%eCWX>5VZaX+n=qPc@9$K`6eub+gqw*`_Ys!vwjAR!C1!aWHew@+s{kB1s6)7@clM zugu*z+XoZ%H1GP(@78YZWHV3Px;2~muqR!T5SPGqhE?dZCWSx|JaSV~$3nHd9KAit zz?DKWI&76*n9wseDG6fWmtNyJGkl8ui?%VwS9Xhg4{RoqOc3aG1oLNT z5ZJY3&5T7sIp*RS`~v<5_UPduK-P%GS}QAAZs8M24W&dI6P%%TYSDr|ZG>Qc(&Wh| z7y7n-{i^;ZB@TB-uvvJHucl$F>7LAWaoVMl8$hD(O08nGeJ}>TL!&DP5F`ohe1cUs zcW!&oo`w;Nc7AU*MC2xV;jpl<6u0IJ8IGy7)aw(VL8hFIw+gXq~L{sC#B9U0oGty^>5aFfHr zlWuA{2)|PY30Os%(~EmmoAIwn`gm!|oLEjw=h+`0`}FGdCray!Mv0wSWvI4bcK=!s z2GJ;d-^=!^%6M<7iwyrY=|&Lwf{jns|0jSRn@8IvKUD_;9n&_${7kfeK9d<%Bp-oa z-4RGRGMyLW-M@G>xk0*+c&jH}_NHa;+yWKA{4n*|2vSj;U!_H9nQ~w6Zs z8ont_>>{+wlH$s)61MkBFh4ESn8Mr(E3I~LG8mJnEDT}N8%K{G{WyN(V$UkOejDk( zA#;TUM*VF{I|fLVuTZc9Sg@|OpU}bVCQ`-Is;76qiL>j3Msq)$X4ipJdd%GMAr0g@ z^A0r>x!)gjt4~d))vafu7yU?n?PI1ufeWzJ{U!-0vthXl8n@TtX?(Z6z)O)f8-PV0 z&*}aG*R2TugTNAidSrS!X$|P}0H4ad^C@}=@SIls094mp&}n7(Jk0w($FU%?*P6xK z&7Z&TezPXw3II+)yE=?3fd?47@>Rqv>q#d9yLOK^ua+pCC^ECti=>;Xq_*6^RLZsz z_SjI>*tY9wVzRDmK2r8aIhFG3kd$@_u)iZw9`hR-b8>sv*Pfw9s zF=gwAP^B3q>nE*chtMBBeae&-Zn}W+l9@);;VCIA7Sy#lytgCEpLIG&Qa9!!x1S9W zd6y;yiQtEv*=tCQ%X?PVnp|5HJpgPqH_g>9XtP)FJ_G{7YA@X5OP&phtIH4XE*@C; z;=#2V*s)7Z9+z%Xnxr?k;CJO~in=5Gaw@Z)<-fgFDSgE=WQv*W4ohdC+1ibUtc1v+ zl?@G203VWWl?R!g3f`M}i|1Kic}fbZ+t53V zlImfxecXz9+E?zCI#enQmG0H6(rMeOP4Q{xR`&I&b{Ul@qbT0HZcg!_ELd=%!*Z~K` z=0Hh)2fsKwJpUXr^TNg}V^V1Y1h5hUGX_Z8Mk07f$N=C`tu<@T=4-8By(M(&C#hX{ z<1~F2%XBTG32+2!l_x@o4;EfMpvCCZ53ft;x)M}*-n7)kTE^`_peP@dVXA`4p6=r zVS>61yRq{m+AB?3X3%(VUNCKK`LolnIKiq#78mv|Qtml0eQ>>t%7c9o6NwS9xP2Ik zo<4U~Q|s%}b->R^rd1v>uNYdoJ2=$G42O|ngb~n@*0%)t(|h;s6>1*+pxl)wul*0O zHo6_!W5JO{MBIJ7wLT$vuT>Yaj&2f6!yui3G{WYk(h~QMGwXHa43&x=m^N$TB2=)FkhIv(Of=9 zuZ|2t#VfQ#eIxgxCl0FN3;oyN(dgEApbxn`f?B64GZ+aCrbH5Z^+V^1t<|y?6YsHM zm74sO%0p%1OFjBdltM^CqwlBFNMYb!K!5VpE6e zVi+E==%j)yWwf)1xq-no{=U4Yp1KTE6{%jLb3L1sl(anmMEHL~gQaJWzUsWqVFUtu zA-7>t4Kn$5d0gc|IQu_(^@`hYzt1$HOM0}wx%Q+s2p1crJu8Q)dH%4ok|F4m!PA}8 zmQ|b(rJ|Mv=VZon@gtO`zli;CKsz45O2hQo#$8I~RxTa{u;z9!kjowPr*b(}{)s zc@wWsSnhmi6-{O{0m_LjEAGyq3_3ZXO4yFJq^2l3NOzI2gQ+ZiW4z&%?dwfav#jnc z+SZf(>g2<%e`xk(XAV?f4N)DYU#lNhV}2P`*Cr(yo#PYEg?P=DoiD9P@MmA@tnI2a zLFNmd8DieVbXvjxJIFSxI{9{J%P_G0c%2b`((y=^ww5mm_hssUmM|!sipsyMU-qj@ zmG@V6A0r7`wWKUf*158p;qmB#uzWL_t{pnqGA z0L&J3RQ@-;=H~muy@X(jwHH(^2opqT@7~#qfMD5WlY)O{tk2Eal^y(WPqtj}Yd_6# znd^J%6+d1H+L9cYl^r~w&_6pkXmX`vJ626Yw~Y@TKD7OLOJFuvj?6x{>GI64-j@`= z>9THA7g3v(R~Gm?wE8dXGj)>tk8hwx{KZ@;Jq2mos41J*^UscKQyy687*eVxG7JSq zvu8r~9RAPN^05x38l%SjgJ{Xs{qb*V%-{e2p<4-~S2}S*3L*ctHstm+L}q%jUtZNxvaDwOW-p zv724WENZlHP)0_^rD+TqBH>0i7>}-duw{UqT-|5}cH!h8_|sM0?0Jq4(U7SZ*nH8g zXTNAcLWu<`!EAqxf&Jvn!l2FT_0T&DOW4a|S-gcmNi$mI{1EkL+N0`MujU_HyX_~+ zhKvPD<=gqm6dtm&ZBDCi|2#ng*`Qn!lV8i!T>N&!@Zh4fBU^G$W=(ukXt)|^M>FW; z{di-&ppHJw(($BilZ5fUOGy-L}Lm74uU`b}uS~CpWnEC|-jQn>L&0%5X z!i9&*jj%Fxt2CtbCxGqg5~I4YT+^x5>jNWXR0c>+i3}2%jdixz&enc!wMqRbAAdc?&@Y z|ID_gAA|;}?(tVP4bYEVrqiwGkvZOGPne9DNT2K)exZ77(bQhdJlx>4iI4B!zyHFu zD%uul15K1es!!UwbMXtp7KK|SFBsXgTN*QOoY9jcy0gF&6sj|@GNwpl$U%2Hds ziOMH5A0jUB?9TGD;;kS2^RsXmhJF_=DG{43fCCkq(pPy3Ll_?!z8BUF>>?$#hj{`q zSEGIjamn!xDIqVNf`E~?yq5zo)Q~jyeId-803c#QWrv5|`ixKckeH8{it%(}P`Cx$ zK=bSH^7ilOU%TT_^g!>9IMEY~!MfsVfA%LrnYSPd77eM0FcY#i<}r~R`M2Ft$VwTL z=bV|o<8iXx>hP*)bgE+5I61YQo;2xDUB!T4cm2Bx`i@J0V*+dd6tnqI8uMe%WmG~U zJ%u(@>#SWUy9F^ff<5F6n;kGjwOp6EUqI{ z7RZh0G=LWwW46Hi_3YC}ghmnFka2!E#3iveXyj z?dvUUXDx8OJ)03kWM9e4&$aBQTw%ImMJGCoG~`?2?gF&+rSokkx!RPsPzW7Sd^S!{ zL7P9;D52Bf?eBIiw27n(xzf?6i zVP#ECO_2!RWEIUbPIv1q1iEO%kkp>u{y`f&QF&_Yg0Q*|4Jk4A3SA=p4ROO5ELk$c zV+gAS$V=rb0KtoEU*C#+shw=uBFKBvq`cbq`T6-lPH+B8TbWR1YOMf`2)xUGK)$+3 zt11&hrd0TsDy?i1haq5;N8OsdbtfRmmXA5AV!jAzilHy*wI_WA0lQN#@wHq zn=A1UbxBC-CW7?||2bb89O@Oni&;$P?dvp9;itp07#JT@Y!hevC2{|(;HE`z?$q;0e%nLW+ zY(uZVM=2QE-3bfmR*D#cLJMnZ3g6l}5x1mHXhfuhX(e(5Io zyNpmVwW{ZCQlc?W4(dw0f(Y?J(Nx~$^ggvS)DHE7eMKaw;MrkM+Xxf3?#U?*ZMmTn z*q35rvOpU2mM`BU5L>30B~gWncP{On-dZzkEMb`7l58|sn0$}+5Sm}HP|;U+Ani+N zS7k?dolGBWXl8=T28D$eYjU=y)oUjI8}?Xc<7qUu4+_)&05ksu@|A9&wh8&;%|AQZRNP5%sI(rW>RNmm z0)-(6TkOT`q|i`#n8j4-Rm_VADL9OdPu{jbm(qTp*ES7{PF#7phGzid;qvS;0159< zn*u{E!bZix6>TFLZzdX?1v_+jexnU6u3(3_$8WEou4MlfhwDggaAraW0$oH^8Z}Bg zRd?(5?N{@+9{6vPzR;^lYm->YRo`-QoAm}H+y{wZ&{pW z;b_4<2}4!xCbV}lKRyIppNSrEWqb#2dj?wrT@DJAuCscCdgWGpLCXP-%^rx2Kv)t$ zbJMEZ)fKnjih>I0xG0nMgc+C}yVt0BPbcp;YrU=g+e z`Qt6{`okPU#BHWUT7#fL;(`5js<+<53ZJMrMT&q?w84Q#h-^@=C8XII9@_ObnD&R= z34$g#CT5Y*oO@Jg5jTmbRu5~cNt(+GPGw0L(B%=KqkOEmKj2A1PGY>E4=Q^~&D4Qe zZ!fm{Yv0`y{u-BB zG&CAcFBRZ!*nt1gA0EqBx7h3HDRz6d@tLe=UeYdb;aRGv+QrctyNArblUUr1J$y9= zrqJAHMPc*)#{M4lNk79w6mh3H54B48C~*vn$Xe1(+Vj_6r1d@x`>0&(()8(*C({ZF z>a?AvWn)*$9|+FTETbI^Xo^SS0OCb%@W7xQV5uUKOPu$#cI{f>Y!Q;IPg~66`%`vm ziep#%*%TevTzRHpE1RqkBTs93JVz*3(8K0`k49lgzn3#12|{(*vSm*mc&49#FtB)X zdUDaTI0BgTBik>z{(5(bwg2nU#6*f9EIud&fchQ1=uISROH~J+8c!x8s~e;(?`2TS z!;TUSwUOJYZ_Qkb?OnQc^Z$|MU2i7UyKmoxzt59& z>fE{Ql4qmxpUUi#qWch&w$Rm|+7JRsPIW`4}NIC-6Mp>bKM6MG zX2yFhlA^J@{_}@A&J#rqLni+XlIS_9G6v=xM3fU<7ZMV3?b5<*Zz}27s_`5A$7`CPy5{P zd)KZ)J0vh5G;N{}5DgqDUu)J3`9ZHNT0E65&y=UHW|N|ep0MGbG_8tC+L{Z_Ha*Y& zn7pZBT;iwcJbR%|7mIG3+v=G+s>(`C%j(#R-KfF^OY>!7 zH_jOs0;1jR@N3%%PQ%{kjHW$fefbR<(6xhP=HI|!qhzplJ80V0sM zZ4E%8%EW0xR-b!aD)aVjr7H_9=KRtC>K2c6gXfQ1m)Y6k?sLe(z&H6Y2jYAULO71} z(cSjy$UG5j4JUWCrPK=Js1|BaF`k~?srW*KI@2}0EZKM_69K{{ehi*__6vlEFGMmn z960$F9>Q5f&BUeQsHU`;&~%aTLFx!pYUqb1m}oE@At)pv+kEn#QB*v3e-|Is^>+^| zejGj-Px^LR0g({TPB{?*TEvelj2=B3Jqb%G(GdWKWM*P?(=;v&M1Q?O(oT;g}+OTuH`zSrVcLk;Kco#ZhpJdM)^Q;2q%= zlz5kxgccCXKz*|S6MB|0~FW|i%aaCGc5{(qvJ)%Q+=)dZNWm_r(>IM{=L zT-YeM`1d2~CqJ$E>Y!YtdwfD0F%CFB(&$K+M6=7daQA$`I6wjR2;FtO`I%hq1x zP_M2X{%kU?)QlcrH&U)`UFCB`49Gm&q2YH`@tpjyR9|Xknf-qutbrOW1SpElQFNWA!3o1)`Q7E3({<#8kZg z7Nu1hpPmj&kD)GOl=M3Uj&A`I6;_g4;_Y{gfknk?pmIe_?xs@bx=_sA)lEC?gB8Fm zZ~XG&c7v;7HrmwEZBTM&?5oKEig;%i9#lx}4|XrhfOUhY#VY?wY*Zy(lbtZTpyNF{kC_ zkVeIPhl?7KZ}YYUCWkn7{=cJ!_XCx!*uO+%yNh~wh>Njd?VFLS(4h-5r+MR8WSht) zaz-qC^xxpa8YkUw|3BKFE2%xpcFZ9c+GTmIZu3f=*6VF_#`^@rk^A}zETKxaO4{r& z{r6qMyv#NP>{A=#niXHPZr~f^^^;Dl8gp*$+_$e3Y};HqaVvjNzw5T+2&{0~k{tIM zVLitarYK!tQ`k9f&<@vOzhra+5e|0MlU}P5RIdM}Py3rA{OUW+>r*}uABE$KyK=#; z^*8X&hUE_T%Qg;d6$a*0&vGVQCJg3kN!sF`6HcS4ro=B<+O)h@3mOAxtw{ zE+$}h!Ssv8D>JMP6%YFRFDG^k@YA%u^he@&Q>(xKWiy z{I4&t%1f({@<8*a_P5*lf%?WctlPK$AJn$4lj2A(kMrIJ80??k&sBz|7rzQjjgF4J zZ282aDtPbQx0&^vMq{*4TC+ALQ1!wKZ(V-yT@5hwRfqD698#BO2k(7yi36BOD-eB+ z^6+wE6cnUYjZLt<%N5+QaCNSec$8Sv*Ls#6D((?7gBvvRd~v<3?b66)4J-ag?VmDP zuf2U_8#y^wGvn)tSJY#YHk|ZNEIGml+v*AEik$$2;omIk(zKTM@8nu0ir^>U#Wz37 zfVR92%{vvKaDYEs6Yu^Ap7xh*-K@?jb;Q>?C$-3Pc~4#V5VVC`zP-QGM#B3|oz6=_ zQFv&y|0?)Q4uk6Dk_?SjyjJH?cJ?fChSUREie6j3vDr_P$0(YT5NjH1_lIY*I1l&K z!m=`%dE!Lg#B)FIRGjkEbRP8W=g$XS)jRE$JK)h_$)Opj?W&XTEWx z6{4dLEiEov6W_zhZjZB@#|~E-XXYuwImeXxWW0FHJ|4^NSLvQ%nP^EQ4VKOV5@;>0 z2Mibxg>-=CzIOLFcYrm43K`_x8plfoh&m!-z|lE>*O`m_lK1b{=qJ zIbNDD;H}xdLL6-KG)0eEZB=b#a)r^@B*lIfhF(_|cS#m5| z;6#!=a!yPRFHr+q9;K|j+*evgW_0bkeRbABr|RpI@*kd6m@wgz^5X2?L&vt2IBo6b zIVU!~C6LMbRYOBG_+|~V=hn>eJAYjTey&^=!f@@Mc>bmT_#+rVu@;!uh%_l-v*BX# zGm4ws+>%ZMq^YHKue{s@#&4OH)?Qd)P9=*$qO%D(gKsF}t4(0bZnP8w3L6>EWYE(U zy35&TKX^T+F*Kbf_DgGP9VnZUKcNejNR!n#2_vL(oKeDjPBZfl4h}xptMoj;)2MOd zbXnFM14l~(yp9+(_2r#5XB`*l>K3lDOCJtWgJO2RuQ-Nb<=8$NXdGE1$?_&A)i=Ps zw!+kn(q5pWvy5W^80Vq@;y}7~G`CpAmoz;w^Qm?JQSIL3Ipf=`{{8#6R*iR$B)#pc z5sEV;_2v(JzM}1@B?WOgM50V#Ba)P&ami1GPq!X+IZ$I;k_fWW)+f_JaDc28aQKDS zzg3N|YHW%HqdQ2a=(CSS{92c@ASjxF{Md1(voJy#}lD!ZS@%Yis zmdU~_Dhw3T{rW|qrO1YVBS^0MjBl4nxr-)7fH7eKa& z<3=}Ylo%bAgw5U0}acZ{VFtdU8#R z8$NPmxap7r`yFI9(B8P^jDOXmPa8?aW@9<$9cj3b^IL*q*>0{KN@ zc|?Pphl_T3>_cX9-RXl6v!QpaWu}r*wp~@CG-9@_p{NbJSv8ky5`T}+C_l1w9f4vy$ zwfYt2P;ne5IOKiXbDCS>7$t3X?eM1oe4paEb zHAFLvS+NBKaHI#YR4F2=^YZrm7f{CEVK$HIaFP$V6(hS85UC13xp`}4~gUMZtg zr${2ELm5422=Rc zFnUItlFOQiCktbDHjfDzgpFRj9_!;vIN$CZXw?PbS}ZdS$~jLZ9X))wCf8dGP8T?B z@W4sy_q|WcUaya~D!mzX`D3QcvI|}l0lsuiQzFYgellA9VtkmN#8tn^k_osaX^F1WesYsZ%=|4o@40MKxC?zth;#e$O)-!ksKCtT~5U& zuA{gJEYOGLSsWIS+rFXVWeg3SOG|#?;>7}fFfYEHPQUNWixzM)GGs^b6xSyj;joCo zMpT1>z#nIpdgIj)Bpu=_1`;9(6zBZn)^9t2mWWi{B_GLk3L(#i~Q7C>t4@$^i-@CM<;QD0v< z)-ww%DkUYQ@Xo1;W_v}*s}RKT@yfHq&m%@%=!=>Jokuvr)9h!@Rs%vAUE6*|K9U6h z7_4=IE6%TxtrP*`lB(hJD zK$T*uP;eCFeG>#lvp*YmQmeb%=+O(gB53$*z8Tqzh z(HY!%%MwZrUG=O_@7~#u9>p#%bKAC!XFh(T zhDXf(7xs6{%hM|+Rc~DG*icDvYlecVCuU>Pwb0cEFpAZ(G!))_#hN#%bQl6FTQd+%J|o6k7Dm8>;h~xBrHX8}C~t zCSCZ}e7oc|oA*8Qc;zyyvYZ^Wd)b{F2X^yhXFj<9(W!LjQ>{(4!y>=Dcuwu@+^N%q z(x%kDy2gp=^o&`JsPXf7^#XTne>lOCcEWph0pvQTXGWKNaGpB;sZ#GwRFsP)&4%q< z=d`15A=Q(V5a0KoSjGpRksfp~o6#;K=Om(MC2)P_7g^r0%*qvG{oO{2!>I}}T_lqA zYaW}VeDYo1R!_pgUVjMf1`fQ) zxIaI2%jOj;SC+g=g0;-$If=gkZg?rPtTgNPZR5Pq^E6bP5mC$nHZsYYI&wCTKED3M z1lgG;bZL0b;E4R>t6smZ1I_VOY>|m0vU#Jix%qiLpdm*YrA>!9tE|d?3hCKJZyY!_ zqAt+~+!mSdV{#g4(oDw7+iw8X@>T4hB0h<$=-sPVh%kuqx@5WVdMe&0G(utO)GHMm z*Aq8DMCGc+hX&4{3Xkr!*)fN9qItU2Dc48$?%u6wvPsQcr|0aP$fFwAZJw9w(E8jE zPaw%q{3&7%)q3>2=)6Tf%9+cIr-z(`fo58ZiOq{64 znrMFQyYlk-{lcz6fwIE7JFcbaxT@W0Vrmq5MKrK^h?j=~r_D zwp7b5f2_vyDIlCVlFS=zU&5W1>FJ%r?0)NbWAId=eCPRxqmVOVvq>d^JMbwXw#IlF z7A#t1TiE^y*+jX*6+{o4ROcnaz`3pYF9X8&BK)#>u=v%zOKUR{TpLYCq;**GX#CFi zUA{Nixs$(nBGt9g_JKN7NEkgsJ%kBCAptx^@6*!ahmu_wECLgGiYT9KTl#Ts+n?yN z!yqp8ao`j}qA4LP5dDE0aJA7zGi~kt;#>>rxFwBFRzMS{e9Z(nO39u6jK;8egpvz= zPcBeU2%_H=tSsKB#~?RhP@GzJSKsf={VLC%hTF+5N%7Y=<9K(C=7ObeXi{n9_3hH* z=hDNuR(snm-VB+QMRR@xLGcci5t*7qVxXiAB{9Qt`XYG|fQ*AdpCe>8c zs3O_!*`mSH&U2$}whgxcc|2fK8W1`z*QRmN}*J8!HA>%iW`P9-< z1V$a}5dC-%uZz;gMfYFCN5#C=b!e`-@b>rfQDXvhO>)|MSRpvEk%7K@zmoG*C!L~W zs!(}2wZ=&1ZJA!zeiJ;FM6|Q8DYHs0qtKMCTOzNGRMx7{#kbxi_3dT<()Ur`mpVy2 zT;4Gx)s@*@Uk+0{QG1wu7{zVb(xv(4U!&A}s!EKzuteNwbC&bo@@nL*czdCvqWw7J zmBZ!b6H2ZbsrPrY`sA88p{tZcu~)`pHUFf6dpj6EI@!2qOkv;`fBC)V+7$mO{_OcZ z<9e6CwVNao323EUL>tN3KT}OhYl}4X_#=MR{LzW3Bz~fI z-+%liCnKJ5YKrnGdkyy81`~w;yIOkAbQFK-lmY!L<#U^1yPASGF59*lwz;w4?2h)2 z2mbFrOZ@bI`@rOu81W~id&_MNp$dZD++RN_Tdb?e#mrbWmYcq}kx10`Cv|27a9u#t zV-wGxc+__kwY5f$xu1KM`>K_QZ@|~p88YWWDvL@b5+~gzzTNZDYHm#t02bq~JjEW6 zNPx78`so~VF_&PMtN(C^$rSh}VEZ&DV*rj)TEvhI>|4JE0w%zsRcHMD%`bM8XpbI0 zzL?y&2*%vi&1SU7H4q(nyu3z<-TK<3jyV_gkw3 z$&XS{h^Sah`nqydWuYib?CQ!!bSJ;&bnG3)ueR7AU_Pt6==>>n|zY*I2)H;0bx zvoO9)^a#9)`K`_Mv3fa=SBf<#(Ps2IEygT>Y70`KM4M{5{k&=^_y*VoqsnGob%TW%Cw%Rm0OOOMx3em} z%o&bqw=XOzQW@}kAO~*Iuon{PR1m#N^?OtJj6!gQ0kW~;b}Z5;cmTFVxfUN(H;!sA zz<`*N_ocVk6qPr>nopMle^=2F&zfvqf2XL!F3 z8ld5pfEF^8#`j)EM#yrX0iqZ=Z53TUVam$y>nj3KWv>S5UJ>s>E7_ z|G1}#h55zeaOyfjO~g1;K?6v*^CCnQ7h+hM;gP*}>? zK~UL6mhcW}dO!-+fPfp&K^~=1>CH$vLHNh}WeUg_be_fb62WB<1H(8Mp;u zpv81W(JSI+3v`24SRh+q2GHBLH+VQA;9#TGV%-?wshR%u&E)T|M$XXF(K(1FK8lbF z1tq0~%scut8vG-2uB?Dlz^W_@KPKb(6ukoa*lSe%<)%Lx2dzZhS-_$>*y1kc=`@#U zg7${gzFeY8l1e#wCar{PG*O1FY95~h7A+ueLmK=WHIaanM4=N6sdnEvsWdD)tYHH* za$e3)fzsjpg?ovnT~#KFRxOejR$M#2=xDd6lC?c0XYZ>!)f^t7b9_yE%|*Ba1U&lw z6IkQ&6)Pfu*cbW&9;|(E?2=WnBFpkF(gYwy#k5S4tVU^Qm@sS|adOynG(0?99#FgR z`05)xZeVh=c;`in7Ztk+?fGS1pNhR;nne0vR#5jL+$)ZJvM7s_`$<{#S5D=}R zPcelSsC^!tR;vAr-Pc;{#z^&>aHHzId?ZmSfz!5L=5aL9^Na?eMd%#GsDBpxJQ7gR zHx%_uEg19_Ubh4sDO_Ug@<$6nMH$nJU)kTK8Rp}om#)}*)go;#Jb8B1} z!Jt+5StrbN*axzfBw?MWVYZ@e&uYoZX;gK6YYYsbFnRJ)A+KPBameNHxQlwns}}6s zd5sMigeHbFWqHu`KJMSlys9`tDGcCT_snz?-@3%%hzvV-?_P}HQ{Loi4|46^yHz!E zS~;7GE|SnfHd^n^lv)8$J}{3bewgA=;r4NS`R4oMWHj8IyqcrZuBW|CUC3V|d#eB# z_+T^Z#aHpPrzBc)MSDrj2fD0~v-9;smSox{3*sRaOtXF*^KhDY@5PA2v}*TZ;Z_Qa z&WNRc>9c4vjek@up*o3xmvs1hah1d-Z)o3roWsMFueMtQ*HBfDf9yl=pOEU6 z{&A{Hw^8;}2C8jqxyVyG8Wc1pty~daK|2p6EtZ$n{nR9PjI= zZk~&()b5)hr_II0s?O6}`t=8TVDIsZqLo>ER43*B(wtG7S`jeq?6_mc0_tb#Uzjv$ z@x4dI^LCbhD^QnDNslV5Wyl(%s`~7+Iu98{msG%IyzjQ<-;AzqdEV8JMl-#NUM8#Y zVT&U}RaW&0r-^4b)g3y(uEFpVQ+ztf{BAS=S-Gv9(u2CqO2rRT)nb-r3|cAmXs}FC z&X*IbRgYWMZH-L3`qpuKq)bs9&qPnw!~bt+AAw@MXj~}CUSR+*z)M*Ebx2 zp+E$|JlLRQIAT)1e}?}ri1I^o^YR4?4otj#5w=0}TfnmTOXc`8XpN^+u*r^`0n+5bR$ydk5J5da-Fe%zaL<_tPPZo(n8E zXSzQ-xdn@~bGK^=<4bv`v+O4uYV50v=~$Y#t6k*1yHn0-`(-hJ$2FLoQVhR%)%ePl z>Z7N38eiF3Ev;^Sc=ztqWVe2SALCyG+~~GUKX~YnzdVeoAw^s4aP)hGJoN6_b8Se08V$li(r3XVBP9fZJ$PPKvaGBjPDldRoqd?%Vp$cQF)db8Cm|d4tShX zg#*Q&fdv5;Y7_4Nanqvuw)@7sDR$nvXOI9(m-Yq|h?Po_W^~I3eNNb1wSbpG%$vd zGkMwf@7$RO^sDCh`r!S_sZ$&O@;`D!&u+Cr)N&r7%=GPB!jIpH5clfcyT_}4wi>dN z)KdMA07Zb+iw*!JaT5@|uKyYO4My5v1iDdY&x$53tM5_KD1ac;VUwrV44P)TWlIW+ z>lo)C$6u)$@Mr{W3}8R1Epf4N+gU*(V`kaPm64*lv)qwA7YH6F_+JQqKmM>dB#6*@9d|2*-5XP*qAXor<|P4U z0Bsil=`<02p)hplEb6dQ(O@;F`6&E0EwcrrY(+_%h&qh8fdtb{;v6})m3o56f#UU< z?QNkD%Vn&fEu#6Xj6?ZHsC~8-`jxuv9LcLToiJWlx z^4a73aW*rPPrKSllV%#x&9lMOVHR#Qq>~(PoQ>-~(j;Ld8vd{z;g1)xI`t^?D%&FD)PAEGX zZ8QAMH+OnzgNuoRV$qR|S}1g%*>kofuTY^nk&k^&K#|;c<3CG(l#xjiim|jt_4=2b z;Zg(AmVsi9zxrwb|bSg_}Pz^WHGoPzRayCoTYxLtN|3rm$n5GZA09O zgI+6h5(~vlezqOwG@i({x?9WqnD%Pzd|$PkC6kqMtW}b;jR)O0rgr$``JSpSG2_cm z?TPjAS!dQU*|J}tJS$fR>Q>*>v_iSN*U7-Z%J3MZw`0bSfAqPGLQF!eVN^>aOg?s? zs>}7R%Ayft64FfH!0HC$TN8D!y1Kgle2}f2!mZ)MeqGAfT%Vl<70YidFDO+HOcY%b zJB?9u=7r%P9v4ir=T*P1t`Dh5KQTBgK0^I!Y6z39?KZ;~7m(oPBe!!esX`oEcJb5b z)IJXdkfHXwU-s>C+sAL&(vn9kWZ*I4x()O!jro zSIK#GI>&13Fv(0$t-t?(_1v1^+FK$q^!WdPX2fUyf>Rzx0@t_1fau5`%6PG6{%Ltrg_Sda|jWj zWI`v7{57NkdhgIZB+D3qz1rB%%w!=~cAhX`@gYUHcc3|9^co)bOi6V8O(NN3N8W@V z;N{mlXNjA~$t8rF@KraC?#O1PSwU#&T z3@18V7ewS3Fc&iuYM({81|#8P#~54`4L$uQAu6#@mrH5jTVsE=j;Ee217H?hw0Jtv_Yk>;Tc7Wb6^ez!n5DK)(AaN3F9maINS_pQY9msS9a7g-XL}_Vh zpEHgA_uyUT2yqW&5VXINAis##i1>sgsF{U+udHkpN~e50VL>}=PgVaxQ6^XH()a7MR@z`9Gi7$sVsy0_qdQ+nfKJ$w5{eeQ$X3(TF~$xmLWOL)nbB7kvXY`#DzqMKxq zljzEG6xVanAWAUZ7m^AiVNhXlIItm?#~#&xvbn;TF$=|d09R@vXL$*ukN77v+o4|P zaQUVwk%I@bDMoTO`#?443*Z+`m_!omo`@F}C`3Fim}3K}@pdsfAh8_%I0j5B=gf@N zyzg!#qM*W9U+@*mI+wn6gG!LVJQ1axTVDOg8TTJoAg+{me*4^~{z+baE>JMNlZmpP?a#;_W4w7e1bU(R0uVZ73(CmA*oDWn`H7V6@bTl z6y-QT$7)a7yBlm`6#c${)K~}wNbY54?0l-wm!_7?iyF2j%fia(#QE}cmC%2UTXAV7 z4cY(0-J8c#-L~t)t9hgukwz&MO=gKEZVjXinHyv#88d655{jY>5h8PC3ZZhRBvYm` zOXj3vk@-E&)${CkKYKrWe}3=#*ZXRH?-0-P2SxZCG0mySk@*D(xvmUD3M14eXkbfnT7gp5Bo09WkUXESb0 zxJ8iiwoP0F6O&YRq};?%Dd&I=Z3dN2NVRH{Zq zTd0tWz5V<5MkAekE8FZ8T#ocCi=@GtA5Xz`S#HY+@(T`5-skBHkU$6a5h$Y3g#X>N zX|v#QjZD>pil!YwUo=}p`oyiQ``0VLjO2Z>R7^;U1CU8&)O2#b+)1}?wR?I>*11R5 zzH6^dtxmO9w6IVgOsjObNpvXS8U??y`hQmPnh5HIyj9Bj&6TLubC!3=s$JCV$o;HR zW+)}gZ$(5Xm)t(}$lirOsO zcwiX9md!5>`gYr`?^v9aI^g)pOcb1+8Yk_}G0Jds8c`>uxlOv_jCyQXI$|vjJ_>PL zYJrSU=Noj7L!WBA#sAp{L3hVPhSS943+80Bo8iZd5beZ^4jP)<>!Ox8@0x*UQIqyD;; zo$VC)bINNYn#SQ8kR*sfK_j6++}h?aX6-!VnUu z^(CjlgXS@!5=cXv#KhE*zgJ@0tye&ki_!*wj^*7uFeC_Y%2Nn9lL1Vr6Ay5K>yK$5 z4~c2{Zk7nNd;=2A=bCkbf{BgZvSlJvp=3_-GPI%O(2buluRYaXA*FMg?tFnH?Qg~SeM>(!lCW^<6vCl8W&_|lIOo%Az8`&;=|{!MI$u7 zt2}%n)n(M;fPB--hO(@My8S+HKkw!Uywllvdp((dp8E<;=Z#8IKH6?1q!(Ov|84Mthp_ldqWt;bp)-n1=G znawC8x<8R@k#yYwR>WK}J#fdGj)^`m9q(v^$Q94uA4yIj=l9tK`Lf?0&=Vva=J98JI*1x=vAWg%83jhu&+ zG?EhE#%FMFS37cAPyr(l%Bmi#1wZ%eNPYbHFTnWaNR5)!m}PtD(+Mq+_L2&2tk>yil z96yITy&frRY$h!0?3=qyf9Fms+g{Z>Eu1zq zn*xTc-nen?wyBZX*Lw`Gm09<NNR~t@0pL*0uQPmu<%40@3|Z{<;TVG9R|gSk^I`-g0$mt$g!{YKEXiTw=5#x&Vi8 zsS(eZ{6B1j{Ke^GNx&0dw)sBIbOS8aA{ z8I9{VPEA^n^nK-~a=EFY3Rtv7>sHEKYG%i+Uj{Ni=8k_!baNKsz6k|_aF8xf%SNSp z=0008g62t_Rd&9L?NZ2|+rXHS`UPM~PF=@fBu!<#_i?e+CcpRftqpIrUbYl_6o26f zt@f9I03@-qo}!#lLsVcwvR4_NRI%4$@cQqZZ8h@%GN9kF*|{x!3`X;N)+f4~m2=|{ z2tLpq#BxBKQYsWPSHiMJ}u-mY^4qE<8I0VSBS4V|!ZT$3Dw!Iew5eqQR$lM#Sm&ndp1`tW$kq`$V#CeW zccncfY_@F2p1-CuURM3N1l%*_7?PZgb)YJ^{0Suvhuur!ElSrmw&G{J|9)}F&KfG+ z(B88^CNER9t|VA^zrQCl`@IN0s;psqbt9mT@SFq8IhB_L@+;%BlZ%+cobQ=Gt5wcE z5X>qN$?gpP>ifI@}xuE3Fhn{`0@L;kGFJ{>GgHT;rzrb!&=MX3zY!2m&p5sxE*mpTdCrn)>I&AWMBA@ZM@NL764(~EDJavEnv^f*} zLwCyD?sh>1NBeQt^()06s@5$~@H>5O_HCP|HRaM3o`;U5WXzb{w*5f7{Yqu`0yfOB{-MW!{~20EJD{bD!_{*~?E8BC?_jTP3WcYHPjn zE;u}oeOAzDlpU7yR88jhgm;jX>n+_0-@eWKFJdO1o2W|!GEh8I72 z@In2&!_@-zxfL&;tn`a&QO>c={X6<=Yo=dRSPA!|`;n2%cVlz7mo7@^`)I8%dOvmJ zse8Mm>%aE&K5V?`dSvAp<=5Me_uZvZWr#uYWzUkn&lYPx) z`kdr+jHU7g%tUJ$Da^KZQqAppjw;G3kNily6m7BND5^z0wFD|ZkebbKM ze{umFnR<1>yX170@4MPJ9l^)yEH2GeEg3%67P7UfqiJQ;x2M}3yEZh{OgfzLZolbO zj(xRWL1DRJ+E~1P;OIQ|AF66^@}@<)u4xc4k$uc(?6$;TkF}rw^k1$%O6SNZd?#RL z)$n0MfQY8u>ehtzoF5O1dYd$Jw9X|qHW&>Tt+J@X-{w^lHGRcpk#4$8M#CwSpCSw= zpT7C+^ggmrRHHAeWLaR%Xq`l??Vj_Q&wh2<;)pD}`)4&u%cn3Wi+St5iS`VM$V?eb zGuV+f+F?43feLiY56yU%L zA1_1N%3uUO61=pw>r3c7UUpBMaKYae;FB$9t+|S3pOPlmq^CsOJJl!7K7C zvXYhmYAH_GIH-FMDRC;Q1I@xBvyB5DcaO`nK+%GBeor4XMgDDxYVn}&ujx8{)CN*; z$g5(@?I17k6Y|)bZqdukgrui)pEz!~;Pe&pwm<;v7*p zYNdq%Ry3#)$ykZErK{&I)CYzwNrF)J2q+LfNzFZ!xkjL+3kJZC;3$Yw-gy`_M;vz0 zmBnhSP136CI65~1UXDi5Dw5e~W-*+KfmkY-YIGzCR%#}d*|@x{56-BBPfaYb$^r9? zp!hMH&ue-xKYSzfY|s>#JoCH-z!O$&wW%et`-uA5n^yA&T;5r_YaL6jgznM$uG2~p zFmpKQM+BxEw9abN&4Zu%0wo=X9IA?#)o8e?@z#L-@$lipBQy7+g-mR8Ma7b{GNKHI zC6khrqw+x905g(c$_NB7N5C4v_33~jNNOa0{&Y?o`jpiDy>Q_|a92Qr>7Mzkk<_As`Xq(6}lb19}DISyQzHNUgH6 zGCnHEmG&t;Qy8*uS)rd7;KXt0>B{53VD7C|W+>~fLv=>NJ+Qj)YfSAXlRRQ1HoW$^ zem$0R?UX>G#GoAp5DE+_=vpD5@WW(7;N!{sL*?=6ThUxRicuh0UsqR1)F(jp3Tlvd zwWo*2CUEb79t5W9;R>1)snT5%JxQw(;000&Ik{N4R zKzA4#)+B1x_yNmxQ35bY+EkE|jLhopc;sAzpsz>J^tmfIQCLC{m|{!TK8VfA${G$> z#c2Ncc2#oKr5V<>+1$U01A)K503kFDdJ0%LpjBDHzt4)>ik-)LaH+|WvWh1+k7K>x ze>=N|7sn_-XVHe3;9xEPeKMg42q?IqZ(AcKCWhV%8d>qbcE4?de^Ew6mC_26b+`%jy|q#^ZP%4 z{w!5ke?G!c*uCa|gt`CoCK!c9Rum2e3k0rNCVeLKqnc%`R36P7R@mZZ)GnN|4P85m zvqr1P+n#?xV{-Z-VViLg@6O9e+|JOxSYJOzO~2H6p84hN_Wbc6NLLU+x@ceNTE_FkIr}cv zWzUsO0kV5HG*nQ5w9;VWmX(O?HB?hk;X@w>wCQZ^>dFzYT?Zcvp%*n1)avFP2Npcv zQW1 z19uu4?IREyoCxsuuP}1S-+|Mi72-qpqOTo2@jwOz*7fV75zPi-YESd4ePHJ8b5d`6 zMlwUN9N(V3iZ`59?a#u(XxSV7ip$vDdJ8l%+jz(Nv}aHh$t^ylXmq&Hza;V>uHI~KhC2A3b>Bv+5AWgy!w^?NU|`a)ucS3AZppZBUf#TSoC`Hn z7G7O!xu>#H`(ycBp11LWYUy=%hQu6VqR*OGXnYp_92%>&Q?TN2g-u03ozanR)tkJg{+f|nG(7%l{K1LbEo*1L?is%DgHh69y}P4-{Jn%_qwDUl zB|KV&S@)H|v4M8G%t!+K`xSsgn&)5fxMK9{>(BRvB^9H6ZV}KLZ{&|YCvP8LCm>^c z(75Tl%AQx-*YkzMm?qs92rPO8#D}9d52!Q|G-z7%SqRUe!&;&t#g?^JYEUS6>KF*yDTen5JK z9e(NWv@t26D-P(|1PD8cfuEf%8mq3uaf}0oPWiL?`uFxIqb(}uv*L5anTMvB&z?ba zEi|S6^3!!4KHGpbPpVH$Er}vt7~B*nfF|V(YSPC8S4SOvR+g|?(sk@P#*#cn@FgwP z?@#E?sN-D39qh^21+*587<|-S{hSm|36KeSSaxe6DE8V4muGI07Chc1^-M)qEvf9m zL*9loi-#`~wA*4f>2rCUK0OlG+=Vx`Mu7W-w zAA?$PTP4M{96qrB<*LmmM8uqQpQX zY716$#y2L2c|anG*cKJU(_6bP%sE6cMuPdF#nOn(3DC7im@j_GFglFR8*pLUZ=Z&g zE@;CYf#j2q-6khS(DcXNu8Bwo3L6w+5rB3jdLHqxiaIjFj_%92x?@=C4Zm_Vw+$Dx?VtWlJIggJU zaCvA81)7O!T|V=BP^zIz3?RDqU{weD0XJ^mOxQU!>iP3~iw-p0d2?E#Rg~~&gq=fv z=z(di;818%5;Of~D7}9DqgSUtAvpB|G;p_D%)R(yxlX4jHgWhoxknpE^+r>z-bk{o z;7b1bbS+cmaB5$z0yI+K7Dc4DLlkTquL!sy(pm|FC{Y-5k(szJq=NAR<2vxA$Kcb_3 zmdk@E%ZhVM-&21Z#Ll8FoA;-mc4C^y(Gw?5#LFEU`@)&W!uT=r1L13wS5U>N`#$y{ zyrUFNLzmkK5>pKjKPl^wiOiTe^Pr#5^X92!lTe%ImKW8>nOO4f-BBDJ#4&|-0x2(X z_*oeZ(CkCB^2j zVjpoyUS`cK)I+?NN$ACHgV9vo2xYyU7L3Hj3m29doVuNNpoMZwmfA`-9-6MbC=#+( z7mT1cMJPs0h>Y zY&aRI+kM|wlB)Ls;upK@x>RZ3x9hS)Wt5=HG8z*_0aYyN{QC)R9=G7CZ71DVu)o##wn#GQ z6c=v;ip?^`6I2Q+U|$$3=MZIu3Lld`$fhJKEJ9U5Z%_r9qUjl2?ja)?9shB+^&9%P zRoJ6-L`nsP zV+RggCD)Q1jA<|kMMI0YAS5~>2W04FVhTmYpj$z=p2RyNs8@=6$tnh$=Mj~Fqiyr9 znoQ0|8?0JjtAN1vo~lM%MjN5S)Z8{j3g-yo>0N(+mUvZN`}|d6q##JEQgV;E+Qhbp zuS^e^Nh5Vl6VdntWC$TMIb2GVeL=lkZK4XD;tGP0fR3zh=TW{UqFgvekp?L?lPp8v zjfRis1z*}|`Xh@BEkq7=+*lOMr_yCGy+Yw5^a4!c=tKSDajFwK71(txR@ zekN0iVMnZa%~Ye?2;xMhLF$9^q6<%_Es+La0=NKC%?a|T{^i{`PNIX-rlGF`u~+y3 z+j;ZmfwUEk`Gs}$YTDWfWV3?6C6NTN*t9XNuaNH!fH1F#zV*jsQKOn-UaE1;Im*;EK+mNoTl9RMIqDb!X!5% zgYXGD^hjJA5Gfb}6}y_#?D`2h|@=DYlNVTLt?#j!bU+`Hvg}g^Jat z#N0d*Ekju{0Of-QhKSpwye$FoCJfqSg^zGPF~x*4Rb-4$)sl4+k*;jozP-H9HCK7H zj>I;y9Ku1+W|vKV8SzP32T?@QEpZ)834{5`g;ur(xMKfk#l`^g(8F1i6 zFXQ6cm|PPNJ@zrA$h6>b03fYE=_QmmW<||>Ok*aNFbQ@)etU~PbSj9h5}}F++$LBR z-QY0LCP7xEk=V3=eMs!tW^5@et3W=~$Y{rjjRXmDc#)T*>p3pD&4yc7Y81p&%CZUw+_d+oEVFYW;anMh{GJA}xIoWy>#vCw&Y?N(*e+ zau`dzeN-3Q0E@7~QJQpm7(P=Kk%c`?9eV9h+;V3^E z^I{@0-_)(oY3kj)-6JE$Pfu{acu-5mB^^%5HFXiLhfJO{ejuL+$o{M!_9S6mqnsb) z@@r!%J~xji6kB9Kly#wo5%0s`fvZUav~RQ!BINuGbQx!%#QN}!b?dYzrQ6O@$p zQdwX)Eb3Yf1{|wFFDoo5n2?Tq*c78Nv%Y8baNohJ_re&B!5H z&Ti1{c=`j8HMZX-6+<51uULLM<#Tr><$WN}Y(Rh+7#QH-O-9!oJSJc>{(R7o_NR{S zf^QHCi=z2Yo!9NfQ1V#ZDfqQ~NXW)A;Yib%Osi6Ni~0*k4_?$}BUyzJ>J_eR9dm<= z`2&mA=Wo;Jl8aQj*?%3n1%M|apkWn1kj{P+>F5kU5*vu4LU%bFJJv9S%<%wtmgdS* zP=J737iduWpuwyGUJRY6L&^|3KCS+}Sx`_FYmT^nW8BUa#oSKGn5>Fl8|<=`vVxP` z+&IH{;6MO9ay+QkDnv>I`HJL|#9TX89JCE6qd%Vx4yzGNm3Rycv6J*uR8-=qAa5HW zOvj9jj8fhn!V*Umr@PP&ot_Y7qT$ zKuQl<;Y~Zh6w&0DV-A#y)ljRNDRL$xqcA3GDuhF|B<)ztjwdA;Y0c;Wii2+fepwds zLI5c?80{ng#1lRwqMVXLG2pEN?ajqSW6DhS%YX)3joDv9u?F2=Q4ZQRxeVPOCzutH zEI>$SlR_4vIqVma48}wLsHCoAtD4exd_2|^Byii;;UjGT2tf>TO6i3wKuYO<$|1$N z^Lv=@d+mzatYSfFM1_7taoye`@leIQi`DK^re(d6lncFnii=ccK;=VOMSWy~Wsw zQbAAx{(Z*D`m_HjEp@l{pb0#{j>_8p_2u}Je`JGMyZ(FLNlhJkxED5-<5MtRW)^On z?~v0dYRuBVwoPtFra7OLrj~6&)%PbiU40R!L?|QA8!4b=?fiVB{tfM&PxPz z4ExfCpW7uZ8sN_Qeq78l{iN1*>4M_`SY9OOMMixRE zKLpyp{K(FS+9a^MVXvdIBqJX}cqNTE9{yr#@iM~DnH7#M= zWXzZJ{L9=U_jeqi)B+b(1yDR>b6vO|Os53=p44$3Z}u}!PXs0QlR-u<`~)IfF;;>O zJLu(^Qg?x$DI;J~yXX{TX!qlN4$m3l1z z#=GOWL5ConXWuA}8XHy^7^ZI+4(a4ZU}j5qM`T^MiT*H@qNK@33X5|lSb7khC!~LmpdlR*^!4^ug@zUE z`MLoSl+Ee;P;R zLm2}RwZS}=BvwDoI6*2Ql;TZt0U|lh;MAt+OGMG6BqK=Lj#`Wr#m?M%tO)ooooD#nGvGwN#nogPFYOZG5@Mx;JXfS{+t$KOAqbidv-Qua)jBezCm&wL7i-;Bv~iPg1kR20jDgaKojQ^kdV;8)E(8(5uy%a^_y1THj(43vPY`i+mwgwVO>530EnD? z$xsjx`50g#iuuE?)ue75IR0{w>NUM<9V3AR?d=6DRpy0mSHeW&H;R%$i zm(QS_9pTmBQ^0rC-~Ud6azjAWekRqazpnZrLRFxoL!zW2`*QG~TmTz3j6NCDxTP;- z(K~wonvaiY56Y3_bX5KPiF}XNkG6Kbz62%xp-XTmF@CgyTw4P$-iZYkk%0)gN&$0X z`8%QTf_}CK(}k;mF_FT6Ou9gty~{@`>a6BV^SVSYpzp8!Ad9fR;I837fM z0QAj$?T?ozz|PejMzRBNIINP$1^X5CyO!`-cHXT>5&`-W%@&|v@oj6oH$^bi;pHcz z>QJdX2CKBN&B_?*89BHjkxX+MvOpt%a3oa_z$4$nZy+J1;BKE&Q~HSM(yC;fGLj3t zirRJrC1Vai2Vl_o`P<|>pKA-@Zo!Z2`2nR%Cg&7mj^pS_MkuT=*O^j#?x@NnE4u9T#e^480I&h=Hg#?{J%&>I-ez|xfV~+@rI!NPe$@n zT^K+Z5>i5?%s;gLv;jm0!OefbU;mMY{2Pqy8*c*va3R6FR{GXk*axSgskn6ELJV;G z$lr@LTQv({lZ}6_og`{ngzRP4%Fn%H4s#i@@~s)$8H|Q2KtvG@#saXhsa|IHY~3<6 zw_{WOHp#4cNIpWJ7-0SP8qonPB(%GCoT~Xm&9t~9z8MhPDTur}I!+_h(546({K*J| za{+p_Ri6W($&aZp4(Moa7yU#C({4H(QzQQYeC7KB%t^pBb_7;||BQU4RKHF7yNB5g z*%MnjCq!LtlS6K$wXnlMF5mX^f@V(M)}?Xd{V*ieh`~^gzZ2A*Rw1#=+=l);LxHS_ zbKwhu-ET||1W13GdXr2&mc||UHX1invy;mPsf!WQ$hMc3As_j95nh9EN0fS`xbSQ_ zQv1Ro1yBSsb~3&?M%D+=-6xVP8D#;~eUwqh!Bq!_18yI&so}=C0R^kRdkS`m3wjkK zTB9B(3J zH5uY1$#p=mr$!2?e1n2E{js`rL%NcMDQ{^cMuRm9$QXW69S@uo|4rtCA|?)cJL!?m zNWyNWE!Y)~D<)ctnU4CfkvZf?=p^YD$ODAFD^KB`lQ!*W(hmTWLvv+6WdycB&5XhE zC%}T_73egKzjYhK@eYFvP#=z;J^Kjp2#}HpoQaVzvov_9p$Li(3ZhRytOufN01%}k z`OPwQVaFGQi2?jjLzf@M0+EC;z~zdHuYZM|$kJ$d;2DA>>2|>Yp`)C%F6aquf;%6~ z3NyjSNySK81r&b|VOJctYOHzIk=zY9nvwgFWmHqxD>O9FJ3w+x(hToOEOA*amVvDk zrDN!6aqoI2g=&%BZ+IMlq$-v@z}dh2kgJf5s=`O=fB|AAC=lPBx+y#>VxT}pa!j7q zQ9&~G5K}@sV>#7dJPs2v4g$?f>3Y=$A1+~x4UOt|7M>{?0c zJpj@NLVJ0{EGjYjBMyriGZhocMQ-2{z?Fe)z4O}ARxNL=Y|TG(@E|pi$io0QN<{~J zfS`Mj>#1=&q8L-*Y>v|heqL6Rp}6ZN$~?ax)b*;JJbBWqdt~CsbSR5)SfI zkXX>ugq#pwOGpt2Q)j|SvNOU~8 zOH?U9*({IYRE#dkd)xoE%cgqyPi3q{^dEqsl4r-~A%^YeUOW6E1;628oBi_gRKbv? zAsP6B=Wx&uP)!tVsl4BBwx%My?Q|coe?iI|1WHnt;>=!;*!Ok2%etFX4Ptj#XP-N7 zs+TPkc!+dNNqG~FknM+5_x$qaHu+ZQ3@8q_Akp#IYLujItj3lTtm9OHpMK9BwSe83 z38t|SY&c$Bhs8-|-H7dl1}R++Jy(8w!>N}*Yz310qa_sj#^t}DLjy2G=o!kJ%#o1P zM}wW35>x@f=+P8;B{YZdmN8Dv1NK6T1YPN6aFeA96PT>4Z$~Q++|X6{6xt1&D&hcB zhND#9>AWF340~o1K!1{*BTU)CJA__Ag)neC=sM$J_a>Ub-3gNYajytUEyjO08yD=GWKoSkCE=4Jw3JrwrN{q(?xK~&2iqV}W=$N1uBkwv) z6@5s?ZXm#k`08Z4x-jJzk!cv}Qr9A0cKORzI1(LBp!r;0j+CeQFuITV+rBa#5l}HQ z3muI|&CSgmN?~2ysLPjcsuD#4UcJ4WpSl6G(GU?0>jO@4C2}9BS_+it>(axq?bj_K zo&!9O{v@s(`X3=3q3;U3weH>#KDvFopxz-iI)|U3qf7?{PvA7m(kR*yt)I)r=R zCseBGOsidS5&2;#OUM!W39|yL%XMTqtw8=u*Pwa_ze6{r zkF#2iMt9qzZz8R+UZRA46+Qmf#^L{#IsHp|Wn;|Y^@uu^NTpaRBl@qC|KrQeH>Dc> zC693*mNWmnP3rwim(EcB_Hy~37nMct-A7p%Lp99`uS^MAD(b2k2Ra)gX_7u@}DYh6{vf+Rx z43=Ccbx=`LTm|k-T~2cP!Xl${gqjmb4)CU-ZWF^f@BFI4N?ds9D&2kX*Q2~CB z+k%E#_{z110A@J2RL=2kknBZx80Sl z8C-DDa{ah^msRYD(q2ZLLRN)|1_CNF|v|>9go2Uh+P(zSoL_` zv8Al4Xgb5_ADSV_j-=J??@=Ouj7df<^NmYt&>gQ))6p>>oYJ`knIT%lTgf@uBBRhGt?=~l?k$^SR3F}REuYIa z=X>)5JM){Xr@AMR4{lj6`AOmy|Io5SnT}Kz-hMi0@KQfgLk->`c@xZJaD^jSO)(d; z3`O&XWigMNf_TkWsJ%5-0E93yEg(xH;3kOE^d2#zR!0Dfy$68+7CzL31u+FfLQA)7 z-Lgdm6?A;vtlEA1_ix2s1-t+xp~xVfhr?Jt0Mx3Dsmodf7>viSf`FY{P|c26x$0<2 zTz&@EH-ffMM-c}MmO;hOP@ME2|1w?pSUAlWI~1`v38fZ|m>{2fgAexzL=~>lJ2bE3JeVd%)BXH4th@zd` zwLuukTW_-x8N?BvEt@uq7c9m1Em=qv&LMICCx69hTyO*j48988#)*e2HC)hzZ9iV= zjMKa#Vkp1$rx6w%+_>(+I;2TgXS(L2OajtEBq)yzw2VHr$laQCk`U#s^{ zt9w3EZOlVjQKfEWz36Xle%YF@QppmS!ek_a^T%B%-@dGE)*a%p_kCal|xx`tQM)5_Fd=q96MyqrTJK@a^=rzb2< zi;*cf=N_l-og!K?{#e6;OK#R6yX>JV_DjfVQMN#waQXIu>2ml~f~-&VCEGYbdPZ^U z&v$4B;jeA;U$1Rmq3jeDzLk$o<YPKhi>`{9626nM9Tx;3ZgktqS zR3TaWJ^zmhMXdYvKec!05C4y;N&g>tC;f9p1!xTwwYw`~+ZJ#)hTz zBJ<4HWr$FB?qmyY=g=?MVo>i zfVYgSjEa>SU|YP;FKXr(8{bi|x% zd}<#m8)nj{WE`9KI-*w0KbPkStF4fzYCNiX)H!aCPI&yUh7WJ{du-%iyFYSq1gM}N zdYSU`Y=oN}v_|WtwNJ#ARxDV!bc&%uuj$^y`q!d;m!F8aiG_(sbR=Gm130ntKs)Eb zQt8^p@oAEY_pTisHyRku8u!eXVs^UM?m4Gvad9O)73A&bbXM;eTvfBL#^>BNm$aR_ zGI)jG)nNq)* zK9#sws@4gcHwX4ru6vpEB67WVwSrGi;=EaJx`V%s%{-fNm^XHofl}qU0N00FZWk`v zedXxiXn!eX>&WLKpjlcDZK}AFIL}cIWI}x~c)~7oF-4 zKQA+r^RIdb%&IjX`ME%Tn9a*0tV#Wx%r31%H3pr-dFMXf+w)X&%uB=3`fSj|?{W?u zk@Js}?uR5S>X48B*j9b~P+pl~-7_9(J-4A%70Y-R#T9GJvKe15;5gA2Z1em{LZS8V z7o9&iLi6boFeY)aCAC$eYt)_Ng=O~KDy=M^BdMYM*(kHvq-t8q2IDvBW-e(x;oo1J zeqnHvCyGrfuOgsfmTk@5R%V@p%*VUFTN6{7{yud+YpB~RRiMY+b@G*Z)`VY+<3PKB z=D2y%_bjWBg84V9UT4$@Y%EJ!_vWmtuZwO|5RampSg@@6U~wLM4|7?QsOHK=+)>M0 zXJy3odY%hPc4Ph_RF3iRTeDNZl9e*a zc=9+Q;-H_3cDq@sVrEL#XFJ_-o8XBoix|GHfFil~Jt6~T5qIP49PU`T>J_h$InnX< zO3i1(!^dP4J}x-+g?ahT1#73n`ybQ${(d;krmt$rKwf;sU6<$Y`p#z-?zeACuoH7= zd~n^3chkoibG8RbxuiDgWE_{j@m|FA%Ta}iQuQ!BIF|mJYa%CL_2*NKb`Q+c4y?>F zPJ6dDw&i$e0w99EoJsYu?wGneE}f%1g=aP>b`7m^eME1{7C65K->65PlfP?HddPR? z+p{WDEk|BV+FOj26zaZ`?!MDBJgQ{VvtVmk!u5U3fyvQ_9)B~{(&7!w`P@3<^O7b0 z>>U>ksjO)+XU??SKhqQpa?4+;<50d(XV({J7yE%LgS@fXLLbv}JoULg-u!vFGF$yg zf|}RNx6JC;_a+V7TlZ$xuN?1-6Xun1IM}jgkE?H6AAkRgPRnY^DM6Os{uXe^@hD*H zO0JwYv%6LxA0NrS_&{x-Uc#F&mvylQ2epoO#LI-RmQ`7<%Hg zu*TI_)lKE~)yrpWv0l0?cny(8)SYe|$s7xvj7~EiRZ(WToV0E` zu+PT2d$z(c&5}ie!_J;ozr3&y#*KR=LRK`%7yfkNJOAhI9B1W^ahFbUq4@y?1+Wc} zd_MZwW~b?oC-L(rcCU(!`n0j_>!_a6{y2vh%%WB2%ymlZ6vA}Z9Q5f+>}7JaEBf?B z_K$d&=9l!@ytplI73nlG@KXbo-Z5}VVM%}t3c%nNX0-f+BgnHll7RC4QGnULNtUs& zYtoALsjP8YAfmY&(PUYmMA~UBJ1x7+VqrV>uo^LKeB6N}cDt2!?)P-E`@Ym$@uo7~ z7#(9IRK=#~YcR^nYt8kQOIl8&e;MGd@ieEu!R$d?T=h@?W!zF5)AuajvncEGvbtv? zuAPhngEqef`#$WsT{$3e7(NDg6r0u<8%cP26_<5tD=T+>{66h%)o(toaMPY|r;b|x z3C7JILXHzGNRM!re`T=H&;E~+pX2ebBV@A^6Gy@7$~cws%S{BY?0oTW?*UyrGtsH* zWc^UbfRYmR@}(@w5)|QJ*CLl-@1f&8vj>DvbofE?V8SFle`I>I6Ec54dNvhYlqs;& zM~+PE&J$&eEOcfaLkvd8#)4OWzRkbV#_78-4&#twlm$3@NoLI82%eEOtLf4Y^QSAr zP%qxy5Q?*@d4>FmD?)FEt~^Y~NZAiDOQ&>ZX$z?bU;W^R&ezK&`|un~#a1CM9XYMW zZwe+Uw`VdWGtzVIQf-~^{x3gQ$I5dMhnHGfTejR`jfDex$JT9zjsgl0*E%?!Rs+zk zf)Sow-&V;$nvwv}vBmOtL-h#r3Xn;IZ|un9t7sz0+b!u}QCO)ikkXRj8k5v7^{5YZUNu?{ zKxV(o4JkoCiryY3?+L>l7UdyzvwAe!l-@o|}wbAZQter%Ij~hc4_*m2)OmqOe zF0prE!y$ks|3JeGdh7yvWGvah>ZDWU)LTHS90M#n%hr(kDG0;y zp#KwrUA1s#GCE+O7)JHQ_`&b)kYCf$YsNyo=cpS9&b*e&5coyqgT2uS&W8Ww0@T%I z$`>EP#8J5Vg(@@BP~(zxu>4p0Er0(S3-b(56zZk~zh*c1naC|6H%m~aeSwEpe$53x z*EZT2Ef`HQ=)^4YQ_5ntUC;?K(7v3VY~61|~K&((V^=vq2~fMWX~Hh%E|^7)a^hq9YuK|2LFMZm>Gzs%%u*sWDl3j_mC&@wq3sBLu}7{((o>1m<{ z=0T9BA}9|u^=~s8j+jZHf)*Uvz+v5Bq6Olw{n*RKnnoLAYHE_ zPcMa!MA=3E@v5h1JlL?i^2T+<9X%ruQxLk2!ELhX7wm^&PR!!o_Ya{-40VX1;1*K6(aAZC z=sB;?UEEOlzsE}fVrw(ttP}wd|ep8Nam1VZ6ukm=WYF+ zm~C9aPq$}uR#qY!Ii=DgwF;`%mrtGFPVCp!e2p^#oWd`Pr9SGF1yWi9! zcc$hNmKXUs5}BL)S!o~{?$a+VF}PIwfwFXF!tlT$j)){)zR}is89c%5C|MXGMUvl) zjqI`~_tmi9a5h~=;2UDgk=<2hpqkr00P-DR&=@7kv909;^{+tH2PLq5>JvY}-#C@@ zUBj?#5W#5bYI{16$6@n+@LG^cjKHGvq=l5juPAih1r*xf-L05ok7&dlEV={pFre~F zR;@TVll`W;@5+lXXdBCW@@|t?+vtVQ0P&?b8!h0E+hLfvgxls%^diHD8z^y+n8#2P1Is6OV!2L6hx{!d^C?C zt3Ak+R;+NgZ(pHdZW*Uh>`0AwLDCFF7a|SO5FyOxh=a%nh`npigIH`gQ`ptI?&Al4 z2F?a@oH&rfizG}>l@nQj+I7h+kw$ySK22U((9X>>u3RIh^cljcgG$4N}2Dje9hXsFY;TuIz`p%*{) zQWv~EvR6kaa031lG|2#sJ|Yhi6=5@^9YkBig>$NPNy?k4jPQmwmBTcR$@Wt9#`3+v z8e`*T6j8~en4BkQS%s5EE*_upW!Ri;QmQMj>xVuDGKsxX{(J4!b>E-R6xHC%t0wpvgcV<9uS)Hj^nU{n` z!qB<+rp;>k5!G!hY!zI-KiDc3tdR}(@-iBgOeEa_9Kr7~jt3)ykV&&N7F7>wddLJj zcyRmba0A|mX!zucNIb*x{$!lIC-roj|(~*r3}hkZS%4V?=pqg0F%f3`w98T^(O#qAP_@>+U1>(_kwLfA7tR{WFp-vDp_>8N>+V4c3}<}Cx9!v(ITf-j9f4BOJY}d zhX%3T!Myb!Ol`Bxhl%_~eFJ?!9t_Eyi&xnQ zaClvvteR!1$wJ8(wxPo-c<2&>d9<9>I(*5}q{qkgjrrz%5BTb_g>TKAIak)N`F1LP zVd~yF*EY(%T(+g;FVpkJ@pp#>($js!j~m{LJ;T}ay7`jY(Jdd}ujaV*$n4qN=~EBR zKKWs8i^mndA^yywK3`#ZA&uD!-@P7%BK?A8>Cl!YC+pH9C8MsnP3VrSoq6>D{>gfr zK6--hpa1FR(`%Q} znG2V03yG~CUm@|x_>}9sdli=Fo)r2$siwT$$r>>gWMY2V95RLda&o6L>XNOJznWw& z&hR*Q{ou4rab%p{=^&j|pkEjNiRZRgOeuNAE;8<`wJH1?b6b$7m<^7A>tU8!++um_ z_U-biIetZOi6{nR##W)uqBiw9j#Gog;QRL#?Ck7v&ML+%aYw7&KP;@TCKo&NPGh{|Tmx_aus^_GuyAqy{<-M3GhtX8o5iWPCv7FzaE2AlF`58*39P6Cz z=z}5E9uX`mErrA*s(cWsQmZvx=KWFYI=9_D!0ve&Jr%HcFiC*9=^EPTuOo`k?ijm> zZsgdrp%hSu(CmDUY=HrP3Sxq65Etiim-_zvGL%=xjvjpp4$F3?J9uM%m7B62uM;7$7 zoI$ivZ~!HrnEd(?Wejo5p#wcNzM!ie zQ&THIQf~G_9RYdy_S4f9WBehx!8yDKq8TnJyDba|QeGfN&R?}V1T94V(PpbmcxB>0 z7cN>frFm#MBr1%t+MWQ`db3%oF1%#~UkRDb1fz1|={Qmh)M>vlQvGs%%gY|-4!AbM zg>=SCuz@t5oSb5iavyWQe)l3r^MZdkNGXSp9$g5MyfFG(OrOt3kMad3uW#=a5E3## z$p#jk%=Q4}W|kmbY=c2Uo~?S4HuX1W&!4aV>e7r|I2{=9FWqL_t%97g0AxDnS7-Rw zufKi&ejzky>dDZG;gnG_Ffhnz)hoi%g?mN|5(k`){tq6MBxW?ehbhzN949f1dlLb> z30xTefPex_3;TIt*Uc3Yeqh5)wfz0#6k6u{6&2Z$mh3{j$J0{4-Ur3w{$YfU}qf39`OI@tj{ z=U&{#$(eU578eJ(ML$%ix9;4rc{PN=c#Dyc>Z6m-mJUN9vCl#jQ9d^6lt1aL$(sjwH>!81@3J<;W(tH98`&@uz9*bnYgrd@yl{GNy@2NYSDb{&Qxdko9LZyFd`@;4gV zjF-?XA)8WqPSXf^Q<8Rk)trA5S5eNyWE0L#5J#Ts7cM3T1xwT&cHiq2;3EwZ!Qp$` zF1oujNK2ofwpldT8^nCvl*~j8#6HSTob{^YioT(7oKSY10jX(;H8xH+FQ&aDEaFtX{uql&Jts2))m zmStKS62ooR$^|QZJ>gCd3xjuXuV+!znrAQuQJgoQR|uh9CT}OJN!Y+LN9TjrY1K-&-o{)Wm{evr2MS6E`5rTx)Y;$lRRE; zUXv%YMc%j_C|F$EPBNh7WHcjDF@ROe;jrB~pIx4Fls3h;K)%}!-R>KcG%Hl7hyq+< z7AsIv{oP`@kemBGK73bGo(uy;$67Gy@ljiuj`u~)xuMrFYkaXBp%a3(PDU5Iyl>(}d4mr{ho zQId7~l(Mn00J`d1H%|SOX37bT-_DgW(b2!aUh=_nL9bW zX1F6cLPvbgym{*ow{V8Rfk0r>rnUV1?GYp4E$-?C;Dgbct-+2gy zPi55o9CoJpAaW0skJ{nD=P>R0Z)kJ{ZM2r z=H#?j4wFV!c7$C}+U^?{A`dKxp{fB{V=W{Wr*Qc&t?B_LNa1X}fRWYLu3v9~)bk|f zS{95hSuG^wgF)L_8p*2r_fJRuSl~42Y^!?RF&XvywZG(T-n?ELR%-Y`;(St8-R-dW z@9A%BIUmY(Oq}mI<@dxfP1^c_rs78Zq(68=o1c&}S?c ze2JMRdi{tmTn|e!Iln_z@G@iHUzkee{gQo~$7L+CvPXwF!|kpeRaIRE*vQrYMqoey z_oVOj>)Tx?Y)9GW%xQsLNx}9h>lRM$#CE}el?Z4ZFmniDr!dvH;zX*^B6?vd6VJQi z;UU^%E0J+9{OMrUrYW(~O)wZY`mjW`19addd#NA&t45u71C7Gi@|7@%+OTHLMcCxs zMBSyQvZ}j1BDU31{N4NauTgS|2i_Jg#YyoNMn3yMbHQmT_sJ3198T4%RVK`N@|P}O z_O?X^q}-yO@vYwEMb^W77@+NF|B-pG^Z{m|m4LH%GOz#FuM?0`Z2tg~_TT3p?bE79P=5lJZ0k=zFz(pm7B2YTy*GK6Qp(Ob}_L)MCg-gn%nOm#tG~JHwbKs zaiN1n@82VT<0IUwH8kF0wBUOx5Jcaiwqti0+rTx%Xdbg&yN6zw`ap>ef0#qqokw~0 zYbW%(DRwlj@6{CdO}+yp0$=(Al)&%U#OfK9Y8C-^@1DoBs_mE*%wKvDc37Z_d!PKh zx1;Ed^B}H5WW*0`AhpiGv=EOQ?wNLCX~;2#p$z2Gn?8N!%wrg^kzj2h5`lvWAcI_$ ziZ%8R6vI3@{UJ_+^MHw=f<@UO#(hiJSI!OB4UF=*7kP^%-|x_0xXu+Ud<1`EJe!N4Rrb~?v&T-7;R2yj80Y96e12tIn$h{wYLFhEP| z=pTRdp!4!XoE?DeC3{*8E+xmr{E}3VL6lZ6OEB6@On8rB#4uKlv~~FUviJS8Q-1%j ziC5&>V*$>Vs>_kty+BwV5a~54c!GrPhF7#iE$3S9HYB)JTRvPp2Dq{PasWhnhJ;+M zO;@0OxpP|r!=9qNlLHhT74K@D#{#KqI2V5FGd+-?7! z+hUIS%JDlhi$#R3b~|_e{HY5UXr^~b_1m5E=$Ty{D1W3yTf6Nn2}*Fxe3oYQy^r&x zA=5C*(|x4le*3*(-`&Hs_cy6)DLRrZFJWZD{dSjL%Q! z=_=YfT-aLk`y#c&8*g36#7|E;+ZT^DS@fBkTv{u*%U3%0?DZfny|}iw-16au7W<9v zosBjUYxZ{VFUqAODcBZ<_e3hE(m3Wsx?HE-lf#;0O(X6W6q?@j*{afdN3P7W*zYt1 z-O&OE700=sj`bG_;F*8GUjvo=Sg7&H4U303Z%QOwff{S zzx5qrM4`K0t!DVLPrL5l$*PvqWZixmIgDJSQnx6yQ7 z(Cu1bujh=8%h@zpT!M;%&Zs%pv(|0`ny{Dn` zCcWT~e^=tUCp#}&3}Q!mJ~gz$B`J{h&}G*;KUhF#+eGRRm!xZ{s{-QiC%8Pt_0Mf< zGm2*;>Mm-W-TK+WhL=1#WC#@2srTsZR9SJUUM@-aM?TIg_z(>gNcdmox3E}LWZz5G zz`&WJ>{#X8SxMK3wpa5;0KyR|2Xlq_u7x!Rjo;u));2C7 z1ES*|Tc~_^RAl68R(;)J^O9P!=4rwDt(TZJ$hjo?-1p50BCcTDrE}-&e@(D%q)NFB z{-o|9Ojp#FUIkS1l|MLHS#Q%(B-vfVd?wYgD0hlYfB(|79U(^0O5 zep1D>ss8e*^!F+xuW?|xZ8tY?Qco&iUa%f1#F3gF@A@!fjeLKs$-6}XGqsg^^tes< zteu`$3^>*x1f)xg>7C@mmYx1w@T_?6<=JGl$v|oWS5gr7n_L>#dI=_Xd-nC!w94qi z;eEg6UD~BZYA-g`ri&SM65D6dTj0YeoDCS@baaA;CXf%)mPg`%f+0 z*fP%4okPIb$mm9KtteS&N0_*N{V0YWy}&I=ww{FZ-88-Q&SwV>ax&wb!4KUpx@N^Qv%e~zgy zTbp<8-8*>9nC@pWC0Q3t?Z1cCV`0qq%An=ZvxGPf?(1J(cf{Ou7Ig|3eiL3n12K^m z^9lP**!TySz;Ti{TTMkMI>siSq(x2eVNlx7F0buAe*Abcof_uQeZ8iivB1nM!@;vw zu}|j?gm&7n6rSo(F+xP_2Nq}jeR7(%YRZ;1i+Edn#(oEC^SZRPjR^!Pza{cSclut&}r!%%i z)|)Y)xuSBR*ZgI*Tl90=WR}CqRjV*Nm|ew~>>#j#XrL6mhPT}Xt}44sgt z{0EPJ-{Hek=FFLM%@fLJ8^`LfhWSyVA5Y|2f?jDGO^Y2pdehw8v|z~4o1Ua+9k7Ec z`4r1MrRm*>MG*@Vol2qS+KL8ZvZ6lPPCK~)TwubRJvx&64<7U%Ja`B65Yp_4Fnna$ zZ=h<(I1or*<>K17VVgxK(?DI_K2f=8WT#bbx<3lF6wfX|Ct%tQ!_zV{-ov1rK6h>> z3G#tMhwhLbfGO}yW2lGd#u_?&xOfe3CR(!wNc5SvG`7JM_MJP5@$TTELrYKRM;QhL zE|G{&!GN)W6bqk=yn^@Ye>+O=z;?h1!4w|zYR>hNw%!p2TeL-VQJy7xmNHD%X!98wKoTUShzn1IF%q{}3|Ar6-tZbuj-&WN8(UlG~AKIif z$7jccZvN(^$`B_Jj|2xNNk*Pb(iX_#I&kvj4D#*u>E(ogcgjDPO?=PF%DCoq8 zT$y&}<5*?t2GNG8Sda-C5D+5M?>`l>XCB+RR^;7*X!XB`6kro__)ld|{m2Pvb8BPZ z}K{d5jOe!!_7X zckR_{p+kl|EzU1nUghV9dgv-~To{qgk9f9&)$_HWPHn(|TSpgNQQv9pz)pXD#ZD1l zrjfr|@+1J1HFCy}^Y)|8yvh8^8Nk6+BRvoo^phA*{%t;K>sa5DfjWK8eH>eweZIT; zk&fPcs2C{hH|myF87KR*-3XKebkF584O#}|)-I=((Jf6yEzQdD1ulwoeyV6`Jv-{Q z3Bme|6Pf{lx=+L9bvC^3M7f^sd9BR%#8{X8dTv!OHy*Sw59g@}x6YK>1a&vthY9+z z+&?-+?~#gge!awc{0*Id0rtpk%4K(4jErn=J#2i2uv@MiFY*qKi~o^ z{BhU6mJNGjnWEmq+6eRret!k0Bb6mnXHiOESTVP^Q-2z*DH4>VI&`QdeC-FNduk0f zGHUfhdHgKEdFzt`H!oh?n-qZx*=${lv$$iuda)`ro_h{YIJzNx&r|C^@z8x-{~zV! zx>v@*q$#glUE^p^k?*;KXZ4Cc$WhhtT|h36@t>oG#=NrG9&?uu zEeUuU2}e2qS5r83Z5*3xi`B|xg7y3Q+_5}$=1lwG%pIA>ncR0ywW~AGyxs$NcP2yIemB2PZ&)LkvB-xZ1R1Tco^HeF{Xl6ckOIp&(<>6n7C66uwLZ z;-fmEQ1O8b$}z0NXb=#(3z?D2LAl$MGH=Av0b^DfKmHh$L)(#dc^V>9$}Jx_VM4(< zN!7+{YReM&wKGejyA)^s2Bg2ez<%7<4d0?;6YFi3ZODkIg_J)*Ke1=EpSgT(r@&#O zM(yWlVg*cHw{9e1kg-;pi7AbhDZ%85qw%KzPA6daD~$d5?2`xJwu@6dC*bk*8iklH z)6h6FQxYt(&d3D16xMJuKmUBw@=_Hgb~i=EG+H$`2f4leiIexIE?tU`Sp`{et|!Ck zGDCMv(+4Yko6$QzWc}HZ=Wd0&#>ojDhsk?1wfe+hb;cdtcj(Z9sEBJ`-<*0-ae_M6 zaESb(q_pRbWh&eQ-O3`xJ%ZRqQnYa9`}gl-9E$XaU(hpFG>CRm(AdctBTNoR2NX_@ zYt8e+u8n${ovmS}GO;3hR8+3b!{J0{pY+wWioX4`_R(I8m%vS`)5j{Vi`p%Uv+jw* zi-2*=PVi)^9qQ~fms~rwuUN;UUJbxXN=ix~CMVL5&N!2%@VX}csp8dDzvm5fFgF?c zs&3k>1MH%{6OI15<6kewMeJX}$`ch|09+PBCJg8R0n%n>tp?Oi&G zH6oJa;fa|vi;c@$DECNX?BtJ@r8g`7LIX5_Ords(CYcv@Q|^x&FI>DR94}_p8Go;0 zCj3s_-;XXn#x`W?hCA32ADg3Ky2tXe;kisM@KjVisD$@{eytvh!*Ej^-99^H0;*e3khzo+yx zWZAV_2aTqN)lU5WirNQO`xrXQ-AWwN>wJ{Gify3c_t(nmrjZnL?xQ6OC;nN%naSE) zH{4S3neyYCh08vT)F_y>^z9O}C^^47zgH$d8vp%UDiw#~#t#czvS8(in9uu=Qk)nrmSdXG)!6cm2Y-k4t;*&#~g?-E<=gTG`0`?U#*IW5(8( zO?m^=F5thzp5djSF{s1GzIt(LwR2Cy;)T89-bEYTnrfKWS-O?&%{n|bJVxev--Yw! zovdIe1E-GhG+h&xyzQ%|Lir#Ks7hAww=m9zST`bM{TRa2;aNz5gA$)uns4Na$=w&)_%&^0tTExO=X(loFk&0)(y~Ph@>}s@4}m@${{*&uX?41S zgyj4tjr}9m>NDH0J<;FhL8Ng9fpPzXT-C>HCf#%#WG01C$}0YksH2e=6c`vlhIK4M zyzI`)Z7v*?+rN7H?AcJJSL2fU+dTxzD&aIPYRGU^w-I&hk94s>zAzd8MhdDuPfEkg zQ#}3q>h$ua=dYH!wY!|$)Quap2?SJ8dd;8aaYfWXV-QF1LDC8M<_^nCv;I^a zG-$NNO{v2p%tfY2UdFdQzdi|$Az!9LeEj;?Qm{LpDvN?e4tOH{y{kV}eJsi==vx_C z*>`9|-vUC2Di@!=VuK3luL6$)g5LK0gSk|xleRnNt&IQfH)f1T`a~x@yLlJMCk*FX zG6c%r(Hx;zf7sdrsiwj1jy#e6f(}{=*I%M?r>|V8iV3bN>>Meupz25**Ci~~t+_vd z57tuFB*{Bp>ow(w<(=Iy>UEU1}c0Fcttp-OP%mqJK(YjMB0&l8x zt0pzH#6z*+&Ir#fY40pycSvn+I|Pcq|QzfMoQxA6_IJtVCp)jDAJ8E_)so8ijl+s?;>E zrh8sX>uvJ$gfG9+=1+4WLpl6Jy$7x&ZV^WfNg#C6BbmF{I~#J%9CY_MnmC*RR-Y%k zk74z|rD8eqvjOxUB|nKapluIoO)WSddac8tg?KtR5*Qd5NZ@MKf&G}B3#NcQ=gsmu zAuj-s_y2Z$#vnn2=tiE<{mN;~8;i=GCpcv2Pz(GWFxFssqGc9kIxC2yva`W|CJJE& z1$mAeT3K8NY3Xjgdn35eq_#NHyg|foB7PQi0_f6v zY#LUQ(D*Cs7s@yzx|Gf~D}h?C6U(RWCML3K=n{74SDfKKk!*lsqAnI0KT-vuqhPIn zo6?9~hcQ5}L|$uMKlY(P#mR9P_r0SIhe4e;Z{CWBgS+?bJCiD2Lg^>;4diQ~5m@X) zMw2Wq0_K^cI0qKMEYI)x-A;YA{~+04k1y?dxE*00!W)k(aOw_(2i#66Yq#ByEPFn`yh%ui>xG92mEgQqh}BaBd7HvaRKBH4nrOb4xgwqkEJF|d~5?=b}DZ{;c9($<9I zW(Wdaoqr`PY&#Z5qrARtLQVXIrzQ$|2`%ZCXeh$YoN>fRmTqFjiWMu`WB_fF?u_wG z0ZTabxEN2ZCMAz+W-si=4LW%KLCA$`!pqJLHF8OQ{0Qt-Wd35v`whz6XwwMKS4M&= z*n=C{ug+zMpQb-=_Xt}H37xzvAHI6ylC+rTB1&^AM;`u)=Brn=R-{OvwMg!+qqZUmrzr)Y;%^Jg%k+RoJtkx{&xRV@o*=eO0;VO z*SL5@6?#bxQmiRHnE`~NMIIyk-+=SY_4?7TWpITooZZox?C?8q0 zcVkchOfQ?hq7^>(`jViODa2)9T9x?!oZZNn9D*nyxF%7~CO zjd(Lh=VUom%0|Q-&p3KRSW;s5>t@`X z0^IA4&BwEtRUNSJRKK%u`O7tQ%;KknE{~Wg({~-YM4i8~q@bGxzy15mbq^|36D??O$$F+VP0wg`3m z>+ixSy|Jm7wR306Ka)P9MD$%iJRo)FK(|XZ%Zcak=j@Z$_iP$i`38muCeOR5>HWCj z&&I4B!Kx6q0@3tTzZVv=%R#QM7TD7dQn1{kAUfeNyR59BP1PnskVMoBg3d@kn!Y;4 zd%8%O!{W}x7)>_)Bh%Hrp%#`e0k|ySUU8AnL;HBnYt+@gmzS|dVkEy^pc6=aCWSqi>$rAAI`^oPwl$=TZOay@tqob z`EL?iX1jMsGtUL z7OJ@p*x%$WJKz#Y#KbQw<#Sd-ntJwk*;P032-KM>@^-5J*T;qq#f}L_bzD(Bf&snu z`c~|v?)CyOV$R~l#(>t~m4n8O`+T~wzvFd^Or}JU^J<96yuzp_=-9E++@RoKucwWu zfnl=-4psXlp?x2%bw_0tuUv5%*NNJ(si{~$YLy#Q&rdB zrbgsSQZK&(kFMLkFJHd2-*K4D7$f?c^1p{F9Tu_Oe2Z7ci7o*N zB^6~8PZKi^9({ID=i|{CH2973KQkWhtQ%f~Hef;I`tD$8Y4~mbn`a^zj*7+Qas&P41e%(uO^| zVkc+sDvN#fjH6Z8R#y}sUSdCL>uH+QNL9IA1{GaT!ojQ~MM~+n1|R_e4hG2+BuHPi z@SBz1E1clTF?T*SZCtN`xD(pQ%;DOL7fVmh8Z4l`n3w~c3Am%|+`W5rTh+Bo$a#&Y zPhU~}`i^H>_=O8@glm?Un(5`8)DinpU~&XqMWtT?&NIhjdL#F+3jLQnib+Anp%??D zd&9Az4u{hOI%0`(kE&ebs(nGD7IhsOg=Diz>1`_1t1@gQg}(N^5PULrz&G?C$x@hX zS>9OR$Ym>rM4bz1vNep29~N~ktjTsfSsp^4Zrz_qqj;J*bF8|_g4oa9={IRg0Sg3V za1e7>QtpvCK&0*q3M%~YCi{HH-5D({2~<=@vu3sL2ja%QUrrL@@1I=q#<+a?J z*?mK3Mwg?a~_W6sF z#3RG>^n!?Zf=G<}L1krR*k5`h9>&hg(lGtnA$rEwjHZ1se*Qdm)1|@NM$~@@T@tSq znd14@XPo_w7YQ5OX(dC61#08@DO9;zuU_w68>=V~Hqi7TAVWkMqyeHJlk4P1k$HnY1W)vyr)CH1aT9_BQTQE260D7udyT645y3}M7f|C2BCAhz9}ZAFuYy+UY`ztKa-Y z-{R6DXq|WHOWy~#Gz5=u_c~(goD1)<6|;QDz{H;~P$dEcu8!@^T|T{FZtIDCWZEG^ zm0a&xsi~cmUZgD8)2!T5t?aqC!Ug`};o3vjjqZu;ThOS$0n`~YPHI)hh;fvtgobA) z`JIq!1qH)~|GsL!Q3of_Ran!E?rqao%+2J-vKb18n{63ybEJ- zoxI!*bWXlvrq3U(53^!_D^&9jjeA>8J4=e!Dfep}TIwChud?&$9I3`E-w}gv#Cgai zI)SIOUrzHZ^FidKq@@MT0l9hy$(q*2PeXW8x)|ZL{?qs&R#Xt?-)fE^+-+Bd{_P3w zT~JK7Y2(mxd@Dou9a6Rp%)8Qj){^MAajl~I;2~|VqLlkdMuuYQqR&Pr3r;POIFPGo zJX7k{?eWSVr=b8V6~wSOp{MWKqeu4Jjo(#i?c!Vyix#J&y6&Ul2bns$mx)ZDJzaNT z6g(V6MP=yF2M+p_4l7aK3GqJo03}fd-PMc!8wt7Tw&vgK=H$oVcf}#f6~Y3_VlEAm zg|_|1o9cCl7#~BPfx~=Zp3&Clwu8`s&`28fID{Ft$LYYZ&^&?0O?CM20BBO~)m``! z;+*ywSU=4XzG*vwnL16W>;&6l9F zP0f{F#hi2PtsSQ7L8s7N`uuIZthA7Tkry%3J0j`SvQ?}qj_DB1rF0YNbJB|{!dv7N z3JTK@AxXGYgNl*SfESvHML^T+Jh()lxD|UM1mFpf1iXQ?3QVN^!3Qgot#G7WmsF5m z)|l|rP*!@Pa|OJv&HM0H?Ov8=iTxUttRdv4oYx6}+P#tK@`+?4UGYN%g4#nD#JO`j zA*v*J*4?1Fr98p`An4*n+jZuQ0T6mpVh%XCO*^0tk-Rx&N8hgl(`8R7;O{g4?&h+0;W z2?`($&tHqt$J85p)w3;t*b#&)+A;?mN7^!VkJixoN)%NJb(HlWQ(F<+_V1r6tXh&cjK+%RGANUUvSHnMfUu|(J6IefW?3d}F%wt5HdOd8#_dIC<$;0uq#Y^6ooF)>dBLvLwszCsLjL3PgC5c~-;frsZv`EgEx#nKeKN$CnDA z%ln@)hfQ;DX|@(skv+hFjAEILaS}OEkps+avuIy4JwLUGZjqmTTJeL)rry7bbj{{c zjM3ZQ6AC`Ap1o;v|3L>IoalPzGkAAkNu&&j6z zfBy1Y1s0QOQb(1VE`R3-l>CkX5Pm{#+R!0T%mE=qp_KlS5_J3D~@6@*nPO}sQ zl#m;S9_6)@wxVrpeudC#0QrT#7`E2Vxso{tg)d_|#3w-*>Fu0tJry`+MI z=GK$h`ZH;`7-5~ne6ignB_$hA&8BPwm7pO}<7(ioSN$*%wRk*M%wr?^h@m3`njWAL z8$B99cH#0y7$Bn=Gi=lCTi@jJ5ZOuH_lK{Btum=|qv`rgYh$Ar;TG-}IS>Wm$&)8H zrft?!PHC=i;8-zv=Qdm@C=xzcEGM1CH^kiW@1s;CA%dGGGgTh2vNh<8UMWKo&@zQ( zBQ#CNw4HzDVpcymFi;5msCD3K5)gUO{IUs&QyzuuCpKjHSb~A!>k9mDP>-Cza`z{4g zUZRC6fU&l(8MQI8{xi7hIvPN$>1ZECg%4R`h{_gw3H;t6MF=2ms>;ndxBzP2{2+x`3RdT(0iR1$`+zr509t;Muk>)L5A^x<XF8*^FY7-~- zj#b00WoXZMpnA9=!F!Ono~T=@ctH)1oZRe3ml4l6Lh?jIL!<;JrLSH=M+_3t`wq<< z^Hje=A4*rrX%YVl&{d%av}`^5rW!4Q8ATu0gU&oqQ3p!+&CK(z@0WX z0P5~Pe0bYdbDW#5H~AA=ViNKv?h3%mCkxiJK%a`~a2s2%h{zI7a(CLh*BBSP0qwKR zDJQmVp})ZC>S78{G2mfOD`(yJfJoQh)Ha|_t2-R5Tu+AaLn+sMI0Yt?IHu(J#ey1A z%&@A`0@2`7bHZ!5MfX<6HVlt7ZSnH>tdu{$;mi7_CoTm65&i*o-Y2|9wpWzuHAlVO z;xj+>e)WsW_M;(le=&TgEHkC0tItdQ+xq^NJ9lRosdoKkz`T9SeK)+kI%H&@ZEarr zs`=+H|Ijw+k2sqYuMfGkHun-c`bR7&iVCyPnNX}ddwOQZ2EENe#a~+H39DJ*6^kwh zSM+knJ*W3**Q@^5FWhGhA=jzV517wyHoe{sn&ViK+tt2i%_b>&0Hd{cJ!r!_gv=rUBuWdjBn#F1!gPtfHT6KSYd*uwT&3U z`z~GyS7jO%k7Ejp5p3q2B&#p;rm`Wd?d?6+$Q)Upy#Jgx8;IiKXG1aAAl z%BFlpFflO|ougk2^b#XK4hS1!%%_DdHx4@58{YdD)qp-nh^e202Tw(Liuc;G!=(*N z3zFUE0l|(u>@g_5-m@=BBx5J$L)J%`=Mx_0FIkdbx`B@|rHd+8y?n?e=JBk5Vqntt z#=%#u*7Dr+LEPBr=xF=+W4i8a$h<#^=fX%w^TbR(n;3@z`e1qS2;;lB|E0h!VgjZ5 z(yJj5a$-sW6}1=_#@Jo*FmbPC8+9YzB(o*U0uGc@x$OASL3HFcQG*&I=R8tEz5R&E-1u3fWFXxLOVDh7T?{`XP=Zld2+vY!^DYOFFmx?Bp-BY2=bUX z9FJiBQfBD#NWHhLcGT+gaZ99>_jQH^1ccl#b-EB2w*dZtHt%-J;=CU}PoA8r`?zlT z0p`k}9sE=>%YDMD(z!V%YQ54XO&Y3G^J$Y9dW8+_bKApIwoEUS7#J+iP?2oAOA)u( zbMMZbZf}<)b?=mlj?Go)LqvR|u!CzqW@E25le$ju^A4)*Gc4#qtn!YtJLb5Sh?$Ar z22(OsBnETF9Gg*)F^Vrpq`H6o>xYS)!QXzutp?A0PtvK?@`L{v+4b)M<^TLcT~!~h z?(Euc4*In%k$gJIOF5Ih2Kx}Tv|A=EX}MS7(#5H#c%2ksVO(O>`f4&O|K}GKKmX6~ zyuFi?w4`5Gz?E}XuH;nK@)$^jOYSLPZ6djGBgCqk_h3t^B7xByDPPKn&@}J6!Du;V z(cu4~&V`_q2z_}rQw)_$PfrijBR1f0v&-y=@Ew$F>xQRw8_X`E5)%_Whea`22OMQ@ zQ88s4s7)BX^uhPYte&E=cBt#K8(@~r`9Ahxg-bcYP|V>zJc)sLiZr{Hcee2ps({DH z7K86!7uG8VsUtNcdSXNpX_gIYoC9GTM`rBbElYBlNm?QXkmHEB zVD8*gl=Y^oV;57~)IKj}&IV2mVdR>^9!fMGtD7aCAgAB1X$CUNv^yIAQh^zA&P!cC%d1p2Fa3_nti;Yd4Dp zv}J3wj6qZrNr-SuBiLSVs+rD2AK~}*m5zrvR6C?blg8PQ6DG1Q|#kqdbu8gKfABWm+V+@j5|6%)epte2EB6 zsS$HA330*?$1{H5kTMSfpa^uJK4*wrrXA(f|G4#P4r7SG5dNe&XU+lPACFWGEM1%& zrB2pjI-9Yu=G)Ko)s^x2V^JwHY(U&+Xvf10(A%&f7WO=nyWPxC6w1W?D8(raAAm#R0N@r@BS!j}T8S3lY{MDJFv4mv!5uIys z-0a~K#q8_= zI}!cJoA9zCjt=YJ|JA8xO-;=U$;mHdj`2Jw<5fk3&WpC#7FO@~+27w^z5Ln1nx<1$ zbBmV5SsxNEP&g{)Em|FCDn{3HzrI|gO?eTQQcAinMjSx9tsH#}<~|e!&tHtIg~<{_ zw7DimMutFij@8f*4pAI#!bsn%Wf#{04?mc_-ke7Qa0y@7^WY#;V*|{kp zH0W^g?0X^Sm=r~i;=TOP4Sn@ZzPWvKxOMB+z_=a0Y$p>Q2>8lCW0LVF^z5wF<|$uU60`sRpUAs z1Uc%qe9VuZP3$q5HERz7bcgK|zabEue!#vk`8@4F@@YExt_(wkU87aKs61ELb4#gk zCRJTR$6;5Pycj887F>_gaU*RyrU})NJQMJ>$XqjznFm^p{X~x9)gs2328{Qi7a*MH zCrmS`pO2Fck#7t#7UQ7N6%A#66P|zuu2;&`!a`2KkotUVNY1|qDJ1KQ81qgFm&I|RG{7TXT6oGXB%tPoc zrShD^auJiCDAtdq7kJIavfu*UYhp|qDcG39Rv54WhUXxo(m+l|22{$vNZe4ohu=~) z78a0Ph0>b>v3A-Y#zxhtx$al*-#BK|_{$`uzf+9nOO-D%b_|EVy#_41dH$0dbC)c! z**>{Y^aJePO0P~39e6d9Jq{B2j?Eav+QsNjoGLj5*6evuZ9Zi^{stxJ@W2f zVk|qU-J@~xzN`dqgS!Qfy!)jZ4iUc>GFT)A@)CgFt_*9Gv|%)(OxvaV+HU#t&8P>X zgdw}Z#6?n_)(82`i9L3!uZ*hHWPVI${?Z9;CmnU2GkKAeRIkjD8WRWbRVjr?r7ivc z+R&yfL4KfG+qNBD4LFw!LSC13li!-WO(j$MJm$BzUdg{a-UeMVbC>w5ckfqzwd;_H z?GxJ)Pi8Mi=B{eYnTSQHUKh#i!>(P(>P`!lQ|~RpC9SQjMYoZY-Ob%L`i8h~9dIPg zaFNuf?v)9*h$&bkKYp{6X@H$efXX& z-Q>;~v{bpCbPt}b72mJxWAOvO<;R|j4^Yl*oE-Yow*8-rb9A)YKKh)fWA5bIBwg;_ zyLnr^m{h$hq+)UQvK24PLw8Q;-d;y9>Bhmw1r1wX1~Qhxk^)mBZlvJ308tKGed&OBb;R^sHMRB`p<;NraH6O==}oO8cb zo9z&9l{#$5O%<;qf9E()tD=j}Ia;Svr6j50Ng*Y3Cx+fztXx$;q(ZsUc})vN>M2Eg{W3K&1t9wi^~I#l6a|u7A^4 z(MaQ{q@UX-)gfhT-QGI%esn~kSEYAx}?fo}Hd?<;u74A&;hui}$|QA~{#|Iz;x{u1P!_iLv3V$&nM6Z~b4g C$>gm7 literal 0 HcmV?d00001 diff --git a/docs/database/schema.dbml b/docs/database/schema.dbml new file mode 100644 index 0000000..1df5bd0 --- /dev/null +++ b/docs/database/schema.dbml @@ -0,0 +1,170 @@ +// Full database schema of the Moisson Montreal receipt app +// Paste this file into https://dbdiagram.io to render the diagram. +// Matches the migrations 20261103000127_CreateUsersAndRoles, 20261105163138_CreateDonorsAndFoodItems +// and 20261107041912_CreateReceipts. + +Project moisson_montreal { + database_type: 'PostgreSQL' + Note: 'Accounts of the employees, donors and their donated food, and the tax receipts with the prices used to value them' +} + +Table roles { + id integer [not null, note: 'Fixed values, inserted by the migration'] + code varchar(50) [not null, note: 'Stable identifier used by the permission checks'] + name varchar(100) [not null, note: 'Label shown in the app'] + + indexes { + id [pk, name: 'pk_roles'] + code [unique, name: 'ix_roles_code'] + name [unique, name: 'ix_roles_name'] + } + + Note: 'Department-based roles that determine what a user can see and do' +} + +Table users { + id integer [not null, increment] + email varchar(256) [not null, note: 'Work email, always lowercase'] + entra_object_id uuid [null, note: 'Microsoft Entra id, filled on the first sign-in'] + display_name varchar(200) [not null] + role_id integer [not null] + is_active boolean [not null, default: true, note: 'False once the admin removes access'] + created_at timestamptz [not null, default: `now()`, note: 'Stored in UTC'] + updated_at timestamptz [null, note: 'Stored in UTC, empty if never changed'] + + indexes { + id [pk, name: 'pk_users'] + email [unique, name: 'ix_users_email'] + entra_object_id [unique, name: 'ix_users_entra_object_id'] + role_id [name: 'ix_users_role_id'] + } + + checks { + `email = lower(email)` [name: 'ck_users_email_lowercase'] + } + + Note: 'Employees allowed to use the app' +} + +Table donors { + id uuid [not null, default: `gen_random_uuid()`] + name varchar(255) [not null] + address text [null] + postal_code varchar(20) [null] + contact_person varchar(255) [null] + created_at timestamptz [not null, default: `now()`, note: 'Stored in UTC'] + + indexes { + id [pk, name: 'pk_donors'] + } + + checks { + `length(name) > 0` [name: 'ck_donors_name_not_empty'] + } + + Note: 'Organizations or people who give food' +} + +Table food_items { + id uuid [not null, default: `gen_random_uuid()`] + donor_id uuid [not null] + name varchar(255) [not null] + quantity numeric(12,2) [not null] + unit varchar(20) [not null] + donation_date date [not null, note: 'Date the food was received'] + quality varchar(1) [not null, note: 'A, B or C'] + created_at timestamptz [not null, default: `now()`, note: 'Stored in UTC'] + + indexes { + id [pk, name: 'pk_food_items'] + (id, donor_id) [unique, name: 'ak_food_items_id_donor_id', note: 'Lets receipt_items check the donor'] + donor_id [name: 'ix_food_items_donor_id'] + } + + checks { + `length(name) > 0` [name: 'ck_food_items_name_not_empty'] + `quality IN ('A', 'B', 'C')` [name: 'ck_food_items_quality'] + `quantity > 0` [name: 'ck_food_items_quantity_positive'] + `length(unit) > 0` [name: 'ck_food_items_unit_not_empty'] + } + + Note: 'Food donated by a donor, with its date and quality' +} + +Table receipts { + id uuid [not null, default: `gen_random_uuid()`] + donor_id uuid [not null] + total_amount numeric(12,2) [not null, note: 'Sum of the rounded line values, in dollars'] + receipt_date date [not null, default: `(now() AT TIME ZONE 'America/Montreal')::date`, note: 'Date in Montreal time, used for the tax year'] + created_at timestamptz [not null, default: `now()`, note: 'Stored in UTC'] + + indexes { + id [pk, name: 'pk_receipts'] + (id, donor_id) [unique, name: 'ak_receipts_id_donor_id', note: 'Lets receipt_items check the donor'] + donor_id [name: 'ix_receipts_donor_id'] + } + + checks { + `total_amount >= 0` [name: 'ck_receipts_total_amount_not_negative'] + } + + Note: 'Tax receipts given to donors' +} + +Table receipt_items { + receipt_id uuid [not null] + food_item_id uuid [not null] + donor_id uuid [not null, note: 'Donor of both the receipt and the food item'] + quantity numeric(12,2) [not null, note: 'Copied from the food item when the receipt was made'] + unit varchar(20) [not null, note: 'Copied from the food item when the receipt was made'] + unit_price numeric(12,4) [not null, note: 'Price of one unit when the receipt was made'] + quality_percent numeric(5,2) [not null, note: 'A = 100, B = 50 to 75, C = 0'] + + indexes { + (receipt_id, food_item_id) [pk, name: 'pk_receipt_items'] + food_item_id [unique, name: 'ix_receipt_items_food_item_id', note: 'A food item can only be on one receipt'] + (food_item_id, donor_id) [name: 'ix_receipt_items_food_item_id_donor_id'] + (receipt_id, donor_id) [name: 'ix_receipt_items_receipt_id_donor_id'] + } + + checks { + `quantity > 0` [name: 'ck_receipt_items_quantity_positive'] + `length(unit) > 0` [name: 'ck_receipt_items_unit_not_empty'] + `unit_price > 0` [name: 'ck_receipt_items_unit_price_positive'] + `quality_percent BETWEEN 0 AND 100` [name: 'ck_receipt_items_quality_percent_range'] + } + + Note: 'Food items on a receipt. Line value = round(quantity x unit_price x quality_percent / 100, 2), half away from zero' +} + +Ref fk_users_roles_role_id: users.role_id > roles.id [delete: restrict] + +Ref fk_food_items_donors_donor_id: food_items.donor_id > donors.id [delete: restrict] + +Ref fk_receipts_donors_donor_id: receipts.donor_id > donors.id [delete: restrict] + +Ref fk_receipt_items_receipts_receipt_id_donor_id: receipt_items.(receipt_id, donor_id) > receipts.(id, donor_id) [delete: cascade] + +Ref fk_receipt_items_food_items_food_item_id_donor_id: receipt_items.(food_item_id, donor_id) > food_items.(id, donor_id) [delete: restrict] + +TableGroup accounts { + roles + users +} + +TableGroup donations { + donors + food_items +} + +TableGroup tax_receipts { + receipts + receipt_items +} + +Records roles(id, code, name) { + 1, 'Supply', 'Supply' + 2, 'SupplyDirector', 'Supply Director' + 3, 'Finance', 'Finance' + 4, 'Admin', 'Admin' +} diff --git a/docs/database/schema.md b/docs/database/schema.md new file mode 100644 index 0000000..2d257c4 --- /dev/null +++ b/docs/database/schema.md @@ -0,0 +1,159 @@ +# Database overview + +This page explains what the receipt app stores and how the information fits together. It is written for everyone on the project, including people who do not write code. + +The database has three parts: + +| Part | Tables | What it holds | +| --- | --- | --- | +| Accounts | roles, users | Who can use the app and what each person is allowed to do | +| Donations | donors, food_items | Who gave food, what they gave, when, and in what condition | +| Tax receipts | receipts, receipt_items | The receipts given to donors and the value of each food item on them | + +## Diagram + +![Database diagram](images/schema.png) + +How to read it: + +- Each box is a table, which works like a spreadsheet: one row per record, one column per piece of information. +- A line between two boxes is a link. For example, the line between donors and receipts means that every receipt points to the donor who receives it. +- The boxes are grouped by part: Accounts, Donations and Tax receipts. + +The diagram is generated from [schema.dbml](schema.dbml). To update it, paste the content of that file into [dbdiagram.io](https://dbdiagram.io) and export the image. + +## Tables + +### roles + +The four roles of the app, one per department: Supply, Supply Director, Finance and Admin. A role decides what a person can see and do. For example, Finance gives the second approval on a receipt. The four roles are created automatically and do not need to be entered by hand. + +### users + +One row per employee who has access to the app: their work email, their name, their role, and whether their access is still active. Employees sign in with their Microsoft work account, so no password is stored in the app. When someone leaves, the admin deactivates their access instead of deleting them, so the history of who did what is kept. + +More detail: [Users and roles](users-roles.md). + +### donors + +The organizations and people who give food: name, address, postal code and contact person. This is the information printed on the tax receipt. Two donors can have the same name, for example two branches of the same grocery chain. + +### food_items + +The food given by a donor. Each row holds what the food is, the quantity and its unit (for example 12.50 kg), the date it was received, and its quality grade: + +| Grade | Meaning | Share of the price kept | +| --- | --- | --- | +| A | Comparable to what a grocery store sells | 100 % | +| B | Has imperfections | Between 50 % and 75 %, a range set by the warehouse director | +| C | Close to its expiry date | 0 %, no value | + +Every food item belongs to one donor. + +### receipts + +One row per tax receipt. It holds the donor who receives it, the total value in dollars and cents, and the date of the receipt. + +More detail: [Receipts and their prices](receipts.md). + +### receipt_items + +The lines of a receipt, one per food item. Each line keeps a copy of everything used to value the item at the time: the quantity, the unit, the price of one unit and the quality percentage. If a food item or a price is changed later, receipts that already exist do not change. + +More detail: [Receipts and their prices](receipts.md). + +## How a receipt is valued + +The value of each line is: + +quantity × price of one unit × quality percentage + +The total of the receipt is the sum of its lines. For example, a bakery gives 12.50 kg of bread in grade B and 3 cases of apples in grade A: + +| Item | Quantity | Price of one unit | Quality | Value | +| --- | --- | --- | --- | --- | +| Bread | 12.50 kg | $4.00 | 75 % | $37.50 | +| Apples | 3 cases | $2.49 | 100 % | $7.47 | +| Total | | | | $44.97 | + +Each line is rounded to the cent, and an amount exactly halfway, such as $0.225, is rounded up to $0.23. The total is the sum of the rounded lines, so it always matches the detail shown on screen. + +## How the information is protected + +The database itself refuses anything that would break these rules, even if there were a mistake in the app. + +**No duplicate receipts** +- A food item can be on only one receipt, so the same donation cannot get two tax receipts. +- The same food item cannot appear twice on one receipt. + +**The right donor** +- A receipt can only list food given by its own donor. +- A receipt and a food item always belong to a donor that exists. + +**Nothing in use can be deleted** +- A donor cannot be deleted while they have food items or receipts. +- A food item cannot be deleted while it is on a receipt. +- A role cannot be deleted while someone has it. + +**Exact amounts** +- All amounts are stored as exact decimal numbers, so no cent is lost along the way. +- Prices keep up to 4 decimal places, so a price such as $0.125 per kg is stored exactly. +- Totals and line values are in dollars and cents. + +**Valid values** +- A quantity and a price must be greater than zero. +- A quality percentage must be between 0 and 100. +- A quality grade must be A, B or C. +- A receipt total cannot be negative. +- Donor names, food names and units cannot be empty. + +**Accounts** +- Two employees cannot have the same email or the same Microsoft account. +- Emails are stored in lowercase, so the same address cannot be entered twice with different capital letters. + +## Dates and times + +- **Dates without a time** are stored as plain calendar dates, with no time zone involved. This covers the date a donation was received and the date of a receipt. +- **The date of a receipt** is set automatically to the current date in Montreal. It decides the tax year. A receipt made on December 31 at 8 p.m. is dated December 31, even though it is already January 1 in universal time. +- **Moments with a time**, such as when an account or a receipt was created, are stored in universal time (UTC) and shown in Montreal time. + +## Not built yet + +These parts of the project are planned in their own issues and will add tables later: + +| Feature | Issue | +| --- | --- | +| Price grid with effective dates, and price lookup by date | #24, #25 | +| Quality grades with their percentages | #26 | +| Food catalog with Business Central codes | #27 | +| Calculation of a donation's value | #30 | +| Receipt statuses: prepared, approved at two levels, issued, cancelled and replaced | #31 | +| Receipt numbering, unique and with no gaps | #32 | +| PDF receipt | #40 | + +When cancelled and replacement receipts are added, the rule "a food item can be on only one receipt" will be adjusted so that a replacement receipt can list the same food as the receipt it replaces. + +## Words used on this page + +| Word | Meaning | +| --- | --- | +| Table | A list of records of the same kind, like a spreadsheet tab | +| Column | One piece of information kept for every record, like a spreadsheet column | +| Link | A column that points to a record in another table, such as the donor of a receipt | +| UTC | Universal time, the same everywhere in the world. Montreal is 4 or 5 hours behind it. | + +## For developers + +Each table comes from one of these migrations: + +| Tables | Migration | SQL script | Detailed page | +| --- | --- | --- | --- | +| roles, users | [20261103000127_CreateUsersAndRoles](../../src/MoissonMontreal.Api/Data/Migrations/20261103000127_CreateUsersAndRoles.cs) | [0001_create_users_and_roles.sql](sql/0001_create_users_and_roles.sql) | [users-roles.md](users-roles.md) | +| donors, food_items | [20261105163138_CreateDonorsAndFoodItems](../../src/MoissonMontreal.Api/Data/Migrations/20261105163138_CreateDonorsAndFoodItems.cs) | [0002_create_donors_and_food_items.sql](sql/0002_create_donors_and_food_items.sql) | This page | +| receipts, receipt_items | [20261107041912_CreateReceipts](../../src/MoissonMontreal.Api/Data/Migrations/20261107041912_CreateReceipts.cs) | [0003_create_receipts.sql](sql/0003_create_receipts.sql) | [receipts.md](receipts.md) | + +The SQL scripts must be run in order on an empty database. + +Every date and time is converted to UTC before it is written, by [UtcDateTimeOffsetConverter.cs](../../src/MoissonMontreal.Api/Data/UtcDateTimeOffsetConverter.cs), which [MoissonDbContext.cs](../../src/MoissonMontreal.Api/Data/MoissonDbContext.cs) applies to every DateTimeOffset column. + +The tests in [SchemaDiagramTests.cs](../../tests/MoissonMontreal.Tests/Docs/SchemaDiagramTests.cs) build an empty database from the migrations and check that every .dbml file in this folder matches it exactly: tables, columns, types, defaults, keys, indexes, checks, links and the roles data. Run them with `dotnet test --filter "FullyQualifiedName~MoissonMontreal.Tests.Docs"`. When a migration changes the schema, update [schema.dbml](schema.dbml), the .dbml of that part, and the images. diff --git a/docs/database/sql/0001_create_users_and_roles.sql b/docs/database/sql/0001_create_users_and_roles.sql index e0406f0..019cfba 100644 --- a/docs/database/sql/0001_create_users_and_roles.sql +++ b/docs/database/sql/0001_create_users_and_roles.sql @@ -1,4 +1,4 @@ -CREATE TABLE IF NOT EXISTS "__EFMigrationsHistory" ( +CREATE TABLE IF NOT EXISTS "__EFMigrationsHistory" ( migration_id character varying(150) NOT NULL, product_version character varying(32) NOT NULL, CONSTRAINT pk___ef_migrations_history PRIMARY KEY (migration_id) diff --git a/src/MoissonMontreal.Api/Data/Configurations/ReceiptConfiguration.cs b/src/MoissonMontreal.Api/Data/Configurations/ReceiptConfiguration.cs index 1f7b4d2..f3950de 100644 --- a/src/MoissonMontreal.Api/Data/Configurations/ReceiptConfiguration.cs +++ b/src/MoissonMontreal.Api/Data/Configurations/ReceiptConfiguration.cs @@ -23,9 +23,7 @@ public void Configure(EntityTypeBuilder builder) builder.Property(r => r.ReceiptDate).HasDefaultValueSql("(now() AT TIME ZONE 'America/Montreal')::date"); - builder.Property(r => r.CreatedAt) - .HasDefaultValueSql("now()") - .HasConversion(v => v.ToUniversalTime(), v => v); + builder.Property(r => r.CreatedAt).HasDefaultValueSql("now()"); builder.HasOne(r => r.Donor) .WithMany() diff --git a/src/MoissonMontreal.Api/Data/MoissonDbContext.cs b/src/MoissonMontreal.Api/Data/MoissonDbContext.cs index 0c53bd8..d667c40 100644 --- a/src/MoissonMontreal.Api/Data/MoissonDbContext.cs +++ b/src/MoissonMontreal.Api/Data/MoissonDbContext.cs @@ -48,6 +48,15 @@ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) optionsBuilder.UseSnakeCaseNamingConvention(); } + ///

+ /// Converts every date and time to UTC before it is written, so a value such as DateTimeOffset.Now can be saved. + /// + /// Builder used to configure the conventions of the model. + protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) + { + configurationBuilder.Properties().HaveConversion(); + } + /// /// Applies every table configuration found in the Configurations folder. /// diff --git a/src/MoissonMontreal.Api/Data/UtcDateTimeOffsetConverter.cs b/src/MoissonMontreal.Api/Data/UtcDateTimeOffsetConverter.cs new file mode 100644 index 0000000..cccbf1e --- /dev/null +++ b/src/MoissonMontreal.Api/Data/UtcDateTimeOffsetConverter.cs @@ -0,0 +1,9 @@ +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace MoissonMontreal.Api.Data; + +/// +/// Converts a date and time to UTC before it is written, because Npgsql only accepts UTC values for timestamptz columns. +/// Values are read back unchanged, in UTC. +/// +public class UtcDateTimeOffsetConverter() : ValueConverter(v => v.ToUniversalTime(), v => v); diff --git a/tests/MoissonMontreal.Tests/Api/UtcDateTimeTests.cs b/tests/MoissonMontreal.Tests/Api/UtcDateTimeTests.cs new file mode 100644 index 0000000..83590f4 --- /dev/null +++ b/tests/MoissonMontreal.Tests/Api/UtcDateTimeTests.cs @@ -0,0 +1,132 @@ +using Microsoft.EntityFrameworkCore; +using MoissonMontreal.Api.Data; +using MoissonMontreal.Core.Entities; +using MoissonMontreal.Tests.Fixtures; + +namespace MoissonMontreal.Tests.Api; + +[Trait("Category", "Integration")] +public class UtcDateTimeTests(PostgresFixture postgres) : IClassFixture +{ + [Fact] + public void Model_ConvertsEveryDateTimeOffsetToUtc() + { + using var context = CreateContext(); + + var properties = context.Model.GetEntityTypes() + .SelectMany(e => e.GetProperties()) + .Where(p => p.ClrType == typeof(DateTimeOffset) || p.ClrType == typeof(DateTimeOffset?)) + .ToList(); + + Assert.NotEmpty(properties); + Assert.All(properties, p => Assert.IsType(p.GetValueConverter())); + } + + [Theory] + [InlineData(-5)] + [InlineData(-12)] + [InlineData(14)] + public async Task User_SavesCreatedAtAndUpdatedAt_WhenGivenWithAnOffset(int offsetHours) + { + var moment = NewYearsEve(offsetHours); + int userId; + + await using (var saveContext = await MigratedContextAsync()) + { + var user = new User + { + Email = $"offset{offsetHours + 100}@moissonmontreal.org", + DisplayName = "Offset", + RoleId = 1, + CreatedAt = moment, + UpdatedAt = moment.AddHours(1) + }; + saveContext.Users.Add(user); + await saveContext.SaveChangesAsync(); + userId = user.Id; + } + + await using var context = CreateContext(); + var saved = await context.Users.SingleAsync(u => u.Id == userId); + + AssertSameMomentInUtc(moment, saved.CreatedAt); + Assert.NotNull(saved.UpdatedAt); + AssertSameMomentInUtc(moment.AddHours(1), saved.UpdatedAt.Value); + } + + [Fact] + public async Task User_KeepsUpdatedAtEmpty_WhenNeverChanged() + { + int userId; + + await using (var saveContext = await MigratedContextAsync()) + { + var user = new User { Email = "never.changed@moissonmontreal.org", DisplayName = "Never changed", RoleId = 1 }; + saveContext.Users.Add(user); + await saveContext.SaveChangesAsync(); + userId = user.Id; + } + + await using var context = CreateContext(); + var saved = await context.Users.SingleAsync(u => u.Id == userId); + + Assert.Null(saved.UpdatedAt); + Assert.Equal(TimeSpan.Zero, saved.CreatedAt.Offset); + } + + [Theory] + [InlineData(-5)] + [InlineData(14)] + public async Task DonorAndFoodItem_SaveCreatedAt_WhenGivenWithAnOffset(int offsetHours) + { + var moment = NewYearsEve(offsetHours); + Guid donorId; + Guid foodItemId; + + await using (var saveContext = await MigratedContextAsync()) + { + var donor = new Donor { Name = "Fruiterie Mont-Royal", CreatedAt = moment }; + var foodItem = new FoodItem + { + Donor = donor, + Name = "Oranges", + Quantity = 4m, + Unit = "caisse", + DonationDate = new DateOnly(2026, 12, 31), + Quality = "A", + CreatedAt = moment + }; + saveContext.FoodItems.Add(foodItem); + await saveContext.SaveChangesAsync(); + donorId = donor.Id; + foodItemId = foodItem.Id; + } + + await using var context = CreateContext(); + var savedDonor = await context.Donors.SingleAsync(d => d.Id == donorId); + var savedFoodItem = await context.FoodItems.SingleAsync(f => f.Id == foodItemId); + + AssertSameMomentInUtc(moment, savedDonor.CreatedAt); + AssertSameMomentInUtc(moment, savedFoodItem.CreatedAt); + Assert.Equal(new DateOnly(2026, 12, 31), savedFoodItem.DonationDate); + } + + private static DateTimeOffset NewYearsEve(int offsetHours) => + new(2026, 12, 31, 20, 0, 0, TimeSpan.FromHours(offsetHours)); + + private static void AssertSameMomentInUtc(DateTimeOffset expected, DateTimeOffset actual) + { + Assert.Equal(expected.UtcDateTime, actual.UtcDateTime); + Assert.Equal(TimeSpan.Zero, actual.Offset); + } + + private async Task MigratedContextAsync() + { + var context = CreateContext(); + await context.Database.MigrateAsync(); + return context; + } + + private MoissonDbContext CreateContext() => + new(new DbContextOptionsBuilder().UseNpgsql(postgres.ConnectionString).Options); +} diff --git a/tests/MoissonMontreal.Tests/Docs/DatabaseDocsTests.cs b/tests/MoissonMontreal.Tests/Docs/DatabaseDocsTests.cs new file mode 100644 index 0000000..00fdd08 --- /dev/null +++ b/tests/MoissonMontreal.Tests/Docs/DatabaseDocsTests.cs @@ -0,0 +1,101 @@ +using System.Text.RegularExpressions; + +namespace MoissonMontreal.Tests.Docs; + +public partial class DatabaseDocsTests +{ + public static TheoryData MarkdownFiles => + [.. Directory.EnumerateFiles(RepositoryPaths.DatabaseDocs, "*.md").Order().Select(f => Path.GetFileName(f))]; + + public static TheoryData DocsFiles => + [.. Directory.EnumerateFiles(Path.Combine(RepositoryPaths.Root, "docs"), "*", SearchOption.AllDirectories) + .Where(f => Path.GetExtension(f) is ".md" or ".dbml" or ".sql") + .Order() + .Select(f => Path.GetRelativePath(RepositoryPaths.Root, f))]; + + [Theory] + [MemberData(nameof(MarkdownFiles))] + public void EveryLinkAndImage_PointsToAFileThatExists(string fileName) + { + var markdown = File.ReadAllText(Path.Combine(RepositoryPaths.DatabaseDocs, fileName)); + + var missing = MarkdownLink().Matches(markdown) + .Select(m => m.Groups[1].Value) + .Where(target => !target.StartsWith("http", StringComparison.Ordinal) && !target.StartsWith('#')) + .Select(target => target.Split('#')[0]) + .Where(target => !Path.Exists(Path.Combine(RepositoryPaths.DatabaseDocs, target))) + .ToList(); + + Assert.True(missing.Count == 0, $"{fileName} links to missing files: {string.Join(", ", missing)}"); + } + + [Fact] + public void SchemaPage_DescribesEveryTableOfTheDiagram() + { + var schema = DbmlReader.Read(File.ReadAllText(Path.Combine(RepositoryPaths.DatabaseDocs, "schema.dbml"))); + var page = File.ReadAllText(Path.Combine(RepositoryPaths.DatabaseDocs, "schema.md")); + + Assert.All(schema.Tables.Keys, table => Assert.Contains($"\n### {table}\n", page)); + } + + [Theory] + [MemberData(nameof(DocsFiles))] + public void DocsFile_HasNoEmDash(string relativePath) + { + Assert.DoesNotContain('—', File.ReadAllText(Path.Combine(RepositoryPaths.Root, relativePath))); + } + + [Fact] + public void SqlScripts_StartWithoutByteOrderMark() + { + var scripts = Directory.EnumerateFiles(Path.Combine(RepositoryPaths.DatabaseDocs, "sql"), "*.sql").ToList(); + + Assert.NotEmpty(scripts); + Assert.All(scripts, script => Assert.False(File.ReadAllBytes(script).AsSpan().StartsWith((byte[])[0xEF, 0xBB, 0xBF]), $"{Path.GetFileName(script)} starts with a BOM")); + } + + [Fact] + public void SqlScripts_CoverEveryMigrationInOrder() + { + var migrations = Directory.EnumerateFiles(RepositoryPaths.Migrations, "*.cs") + .Select(Path.GetFileNameWithoutExtension) + .Where(name => MigrationId().IsMatch(name!)) + .Order() + .ToList(); + var scripts = Directory.EnumerateFiles(Path.Combine(RepositoryPaths.DatabaseDocs, "sql"), "*.sql").Order().ToList(); + + Assert.Equal(migrations.Count, scripts.Count); + for (var i = 0; i < scripts.Count; i++) + { + Assert.StartsWith($"{i + 1:0000}_", Path.GetFileName(scripts[i])); + Assert.Contains($"VALUES ('{migrations[i]}'", File.ReadAllText(scripts[i])); + } + } + + [Theory] + [MemberData(nameof(MarkdownFiles))] + [MemberData(nameof(DbmlFileNames))] + public void MigrationNamesInTheDocs_Exist(string fileName) + { + var text = File.ReadAllText(Path.Combine(RepositoryPaths.DatabaseDocs, fileName)); + + var missing = MigrationReference().Matches(text) + .Select(m => m.Value) + .Distinct() + .Where(id => !File.Exists(Path.Combine(RepositoryPaths.Migrations, $"{id}.cs"))) + .ToList(); + + Assert.True(missing.Count == 0, $"{fileName} names migrations that do not exist: {string.Join(", ", missing)}"); + } + + public static TheoryData DbmlFileNames => [.. RepositoryPaths.DbmlFiles.Select(f => Path.GetFileName(f))]; + + [GeneratedRegex(@"\]\(([^)\s]+)\)")] + private static partial Regex MarkdownLink(); + + [GeneratedRegex(@"^\d{14}_\w+$")] + private static partial Regex MigrationId(); + + [GeneratedRegex(@"\b\d{14}_[A-Za-z]\w*")] + private static partial Regex MigrationReference(); +} diff --git a/tests/MoissonMontreal.Tests/Docs/DatabaseSchema.cs b/tests/MoissonMontreal.Tests/Docs/DatabaseSchema.cs new file mode 100644 index 0000000..5a3a5b0 --- /dev/null +++ b/tests/MoissonMontreal.Tests/Docs/DatabaseSchema.cs @@ -0,0 +1,154 @@ +namespace MoissonMontreal.Tests.Docs; + +/// +/// Structure of a database, read either from a .dbml diagram or from a migrated PostgreSQL database, so the two can be compared. +/// +internal sealed record DatabaseSchema( + IReadOnlyDictionary Tables, + IReadOnlyDictionary ForeignKeys, + IReadOnlyDictionary Records) +{ + /// + /// Lists every difference between the diagram and the database. An empty list means they match. + /// + public static List Compare(DatabaseSchema diagram, DatabaseSchema database, bool diagramMustHaveEveryTable) + { + var differences = new List(); + + foreach (var name in diagram.Tables.Keys.Except(database.Tables.Keys)) + { + differences.Add($"Table {name} is in the diagram but not in the database"); + } + + if (diagramMustHaveEveryTable) + { + foreach (var name in database.Tables.Keys.Except(diagram.Tables.Keys)) + { + differences.Add($"Table {name} is missing from the diagram"); + } + } + + foreach (var (name, table) in diagram.Tables) + { + if (database.Tables.TryGetValue(name, out var actual)) + { + CompareTable(name, table, actual, differences); + } + } + + foreach (var (name, key) in diagram.ForeignKeys) + { + if (!diagram.Tables.ContainsKey(key.Table) || !diagram.Tables.ContainsKey(key.ReferencedTable)) + { + differences.Add($"Ref {name} links a table that is not in the diagram"); + } + } + + var diagramKeys = diagram.ForeignKeys.Where(k => diagram.Tables.ContainsKey(k.Value.Table)).ToDictionary(); + var databaseKeys = database.ForeignKeys.Where(k => diagram.Tables.ContainsKey(k.Value.Table)).ToDictionary(); + CompareNamed("Ref ", diagramKeys, databaseKeys, differences, (name, expected, actual) => + { + Check(differences, $"Ref {name}: table", expected.Table, actual.Table); + Check(differences, $"Ref {name}: columns", Join(expected.Columns), Join(actual.Columns)); + Check(differences, $"Ref {name}: referenced table", expected.ReferencedTable, actual.ReferencedTable); + Check(differences, $"Ref {name}: referenced columns", Join(expected.ReferencedColumns), Join(actual.ReferencedColumns)); + Check(differences, $"Ref {name}: delete rule", expected.OnDelete, actual.OnDelete); + }); + + return differences; + } + + private static void CompareTable(string table, SchemaTable diagram, SchemaTable database, List differences) + { + CompareNamed($"Column {table}.", diagram.Columns, database.Columns, differences, (name, expected, actual) => + { + Check(differences, $"{table}.{name}: type", expected.Type, actual.Type); + Check(differences, $"{table}.{name}: not null", expected.NotNull, actual.NotNull); + Check(differences, $"{table}.{name}: default", expected.Default, actual.Default); + Check(differences, $"{table}.{name}: increment", expected.Increment, actual.Increment); + }); + + CompareNamed("Index ", diagram.Indexes, database.Indexes, differences, (name, expected, actual) => + { + Check(differences, $"Index {name}: columns", Join(expected.Columns), Join(actual.Columns)); + Check(differences, $"Index {name}: primary key", expected.Primary, actual.Primary); + Check(differences, $"Index {name}: unique", expected.Unique, actual.Unique); + }); + + foreach (var name in diagram.Checks.Except(database.Checks)) + { + differences.Add($"Check {name} is in the diagram but not in the database"); + } + + foreach (var name in database.Checks.Except(diagram.Checks)) + { + differences.Add($"Check {name} is missing from the diagram"); + } + } + + private static void CompareNamed( + string label, + IReadOnlyDictionary diagram, + IReadOnlyDictionary database, + List differences, + Action compare) + { + foreach (var name in diagram.Keys.Except(database.Keys)) + { + differences.Add($"{label}{name} is in the diagram but not in the database"); + } + + foreach (var name in database.Keys.Except(diagram.Keys)) + { + differences.Add($"{label}{name} is missing from the diagram"); + } + + foreach (var name in diagram.Keys.Intersect(database.Keys)) + { + compare(name, diagram[name], database[name]); + } + } + + private static void Check(List differences, string what, T diagram, T database) + { + if (!EqualityComparer.Default.Equals(diagram, database)) + { + differences.Add($"{what} is {diagram} in the diagram but {database} in the database"); + } + } + + private static string Join(IEnumerable values) => $"({string.Join(", ", values)})"; +} + +/// +/// One table: its columns, its indexes (including the primary key and unique constraints) and the names of its checks. +/// +internal sealed record SchemaTable( + IReadOnlyDictionary Columns, + IReadOnlyDictionary Indexes, + IReadOnlySet Checks); + +/// +/// One column. Type and default are normalized so the diagram and PostgreSQL spellings compare equal. +/// +internal sealed record SchemaColumn(string Type, bool NotNull, string? Default, bool Increment); + +/// +/// One index, primary key or unique constraint, with its columns in order. +/// +internal sealed record SchemaIndex(IReadOnlyList Columns, bool Primary, bool Unique); + +/// +/// One foreign key, with its delete rule in lowercase words such as restrict or cascade. +/// +internal sealed record SchemaForeignKey( + string Table, + IReadOnlyList Columns, + string ReferencedTable, + IReadOnlyList ReferencedColumns, + string OnDelete); + +/// +/// Rows that the diagram says a table always contains, such as the four roles. +/// +internal sealed record SchemaRecords(IReadOnlyList Columns, IReadOnlyList> Rows); diff --git a/tests/MoissonMontreal.Tests/Docs/DbmlReader.cs b/tests/MoissonMontreal.Tests/Docs/DbmlReader.cs new file mode 100644 index 0000000..315bcff --- /dev/null +++ b/tests/MoissonMontreal.Tests/Docs/DbmlReader.cs @@ -0,0 +1,273 @@ +using System.Text; +using System.Text.RegularExpressions; + +namespace MoissonMontreal.Tests.Docs; + +/// +/// Reads the subset of DBML used in docs/database into a . +/// Anything it does not understand throws, so a diagram cannot silently skip part of the check. +/// +internal static partial class DbmlReader +{ + public static DatabaseSchema Read(string dbml) + { + var lines = dbml.Replace("\r\n", "\n").Split('\n').Select(RemoveComment).ToList(); + var tables = new Dictionary(); + var foreignKeys = new Dictionary(); + var records = new Dictionary(); + + for (var i = 0; i < lines.Count; i++) + { + var line = lines[i].TrimEnd(); + if (line.Length == 0) + { + continue; + } + + Match match; + if ((match = TableStart().Match(line)).Success) + { + var body = TakeBlock(lines, ref i); + Add(tables, match.Groups[1].Value, ReadTable(match.Groups[1].Value, body), "Table"); + } + else if ((match = RefLine().Match(line)).Success) + { + var settings = Settings(match.Groups[6].Value); + var onDelete = settings.GetValueOrDefault("delete") ?? "no action"; + Add(foreignKeys, match.Groups[1].Value, new SchemaForeignKey( + match.Groups[2].Value, + Columns(match.Groups[3].Value), + match.Groups[4].Value, + Columns(match.Groups[5].Value), + onDelete.ToLowerInvariant()), "Ref"); + } + else if ((match = RecordsStart().Match(line)).Success) + { + var body = TakeBlock(lines, ref i); + var rows = body.Where(l => l.Trim().Length > 0).Select(l => (IReadOnlyList)SplitTopLevel(l.Trim()).Select(Unquote).ToList()).ToList(); + Add(records, match.Groups[1].Value, new SchemaRecords(Columns($"({match.Groups[2].Value})"), rows), "Records"); + } + else if (SkippedBlockStart().IsMatch(line)) + { + TakeBlock(lines, ref i); + } + else + { + throw new FormatException($"Unsupported DBML on line {i + 1}: {line}"); + } + } + + return new DatabaseSchema(tables, foreignKeys, records); + } + + private static SchemaTable ReadTable(string table, List body) + { + var columns = new Dictionary(); + var indexes = new Dictionary(); + var checks = new HashSet(); + + for (var i = 0; i < body.Count; i++) + { + var line = body[i].Trim(); + if (line.Length == 0 || line.StartsWith("Note:", StringComparison.Ordinal)) + { + continue; + } + + if (line == "indexes {") + { + foreach (var indexLine in TakeBlock(body, ref i).Select(l => l.Trim()).Where(l => l.Length > 0)) + { + var match = IndexLine().Match(indexLine); + if (!match.Success) + { + throw new FormatException($"Unsupported index in {table}: {indexLine}"); + } + + var settings = Settings(match.Groups[2].Value); + var name = settings.GetValueOrDefault("name") ?? throw new FormatException($"Index without a name in {table}: {indexLine}"); + var primary = settings.ContainsKey("pk"); + Add(indexes, name, new SchemaIndex(Columns(match.Groups[1].Value), primary, primary || settings.ContainsKey("unique")), "Index"); + } + } + else if (line == "checks {") + { + foreach (var checkLine in TakeBlock(body, ref i).Select(l => l.Trim()).Where(l => l.Length > 0)) + { + var match = CheckLine().Match(checkLine); + var name = match.Success ? Settings(match.Groups[1].Value).GetValueOrDefault("name") : null; + if (name is null || !checks.Add(name)) + { + throw new FormatException($"Check without a unique name in {table}: {checkLine}"); + } + } + } + else + { + var match = ColumnLine().Match(line); + if (!match.Success) + { + throw new FormatException($"Unsupported column in {table}: {line}"); + } + + var settings = Settings(match.Groups[3].Value); + var unknown = settings.Keys.Except(["not null", "null", "increment", "default", "note"]).ToList(); + if (unknown.Count > 0) + { + throw new FormatException($"Unsupported column setting in {table}: {string.Join(", ", unknown)}"); + } + + Add(columns, match.Groups[1].Value, new SchemaColumn( + SchemaText.NormalizeType(match.Groups[2].Value), + settings.ContainsKey("not null"), + SchemaText.NormalizeDefault(settings.GetValueOrDefault("default")), + settings.ContainsKey("increment")), "Column"); + } + } + + return new SchemaTable(columns, indexes, checks); + } + + private static List TakeBlock(List lines, ref int start) + { + var depth = 0; + var body = new List(); + for (var i = start; i < lines.Count; i++) + { + depth += Count(lines[i], '{') - Count(lines[i], '}'); + if (i > start && depth == 0) + { + start = i; + return body; + } + + if (i > start) + { + body.Add(lines[i]); + } + } + + throw new FormatException($"Block starting on line {start + 1} is never closed"); + } + + private static Dictionary Settings(string text) + { + var settings = new Dictionary(); + foreach (var part in SplitTopLevel(text).Where(p => p.Length > 0)) + { + var colon = part.IndexOf(':'); + if (colon < 0) + { + settings[part.ToLowerInvariant()] = null; + } + else + { + settings[part[..colon].Trim().ToLowerInvariant()] = Unquote(part[(colon + 1)..]); + } + } + + return settings; + } + + private static List SplitTopLevel(string text) + { + var parts = new List(); + var current = new StringBuilder(); + var depth = 0; + char? quote = null; + + foreach (var c in text) + { + if (quote is not null) + { + quote = c == quote ? null : quote; + } + else if (c is '\'' or '`' or '"') + { + quote = c; + } + else if (c == '(') + { + depth++; + } + else if (c == ')') + { + depth--; + } + else if (c == ',' && depth == 0) + { + parts.Add(current.ToString().Trim()); + current.Clear(); + continue; + } + + current.Append(c); + } + + parts.Add(current.ToString().Trim()); + return parts; + } + + private static string RemoveComment(string line) + { + char? quote = null; + for (var i = 0; i < line.Length; i++) + { + var c = line[i]; + if (quote is not null) + { + quote = c == quote ? null : quote; + } + else if (c is '\'' or '`' or '"') + { + quote = c; + } + else if (c == '/' && i + 1 < line.Length && line[i + 1] == '/') + { + return line[..i]; + } + } + + return line; + } + + private static IReadOnlyList Columns(string text) => + text.Trim().TrimStart('(').TrimEnd(')').Split(',').Select(c => c.Trim()).ToList(); + + private static string Unquote(string value) + { + var text = value.Trim(); + return text.Length >= 2 && text[0] == text[^1] && text[0] is '\'' or '"' ? text[1..^1] : text; + } + + private static int Count(string line, char c) => line.Count(x => x == c); + + private static void Add(Dictionary items, string name, T item, string kind) + { + if (!items.TryAdd(name, item)) + { + throw new FormatException($"{kind} {name} is defined twice"); + } + } + + [GeneratedRegex(@"^Table\s+(\w+)\s*\{$")] + private static partial Regex TableStart(); + + [GeneratedRegex(@"^Ref\s+(\w+)\s*:\s*(\w+)\.(\(.*?\)|\w+)\s*>\s*(\w+)\.(\(.*?\)|\w+)\s*(?:\[(.*)\])?$")] + private static partial Regex RefLine(); + + [GeneratedRegex(@"^Records\s+(\w+)\((.*)\)\s*\{$")] + private static partial Regex RecordsStart(); + + [GeneratedRegex(@"^(Project|TableGroup)\s+\w+\s*\{$")] + private static partial Regex SkippedBlockStart(); + + [GeneratedRegex(@"^(\w+)\s+(\w+(?:\(\s*\d+\s*(?:,\s*\d+\s*)?\))?)\s*(?:\[(.*)\])?$")] + private static partial Regex ColumnLine(); + + [GeneratedRegex(@"^(\(.*?\)|\w+)\s*\[(.*)\]$")] + private static partial Regex IndexLine(); + + [GeneratedRegex(@"^`.*`\s*\[(.*)\]$")] + private static partial Regex CheckLine(); +} diff --git a/tests/MoissonMontreal.Tests/Docs/PostgresSchemaReader.cs b/tests/MoissonMontreal.Tests/Docs/PostgresSchemaReader.cs new file mode 100644 index 0000000..ac4f158 --- /dev/null +++ b/tests/MoissonMontreal.Tests/Docs/PostgresSchemaReader.cs @@ -0,0 +1,131 @@ +using System.Globalization; +using Npgsql; + +namespace MoissonMontreal.Tests.Docs; + +/// +/// Reads the real structure of the public schema of a PostgreSQL database into a . +/// The Entity Framework history table is left out, since it is not part of the app's data. +/// +internal static class PostgresSchemaReader +{ + private const string HistoryTable = "__EFMigrationsHistory"; + + public static async Task ReadAsync(NpgsqlConnection connection) + { + var columns = new Dictionary>(); + var indexes = new Dictionary>(); + var checks = new Dictionary>(); + var foreignKeys = new Dictionary(); + + await ForEachRowAsync(connection, """ + SELECT c.relname, a.attname, format_type(a.atttypid, a.atttypmod), a.attnotnull, + pg_get_expr(d.adbin, d.adrelid), a.attidentity <> '' + FROM pg_attribute a + JOIN pg_class c ON c.oid = a.attrelid + LEFT JOIN pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum + WHERE c.relnamespace = 'public'::regnamespace AND c.relkind = 'r' AND a.attnum > 0 AND NOT a.attisdropped + """, r => + { + Table(columns, r.GetString(0))[r.GetString(1)] = new SchemaColumn( + SchemaText.NormalizeType(r.GetString(2)), + r.GetBoolean(3), + SchemaText.NormalizeDefault(r.IsDBNull(4) ? null : r.GetString(4)), + r.GetBoolean(5)); + }); + + await ForEachRowAsync(connection, """ + SELECT t.relname, i.relname, ix.indisprimary, ix.indisunique, + array(SELECT a.attname::text FROM unnest(ix.indkey) WITH ORDINALITY k(num, ord) + JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.num ORDER BY k.ord) + FROM pg_index ix + JOIN pg_class t ON t.oid = ix.indrelid + JOIN pg_class i ON i.oid = ix.indexrelid + WHERE t.relnamespace = 'public'::regnamespace + """, r => + { + Table(indexes, r.GetString(0))[r.GetString(1)] = new SchemaIndex(r.GetFieldValue(4), r.GetBoolean(2), r.GetBoolean(3)); + }); + + await ForEachRowAsync(connection, """ + SELECT c.conrelid::regclass::text, c.conname, c.contype::text, c.confrelid::regclass::text, c.confdeltype::text, + array(SELECT a.attname::text FROM unnest(c.conkey) WITH ORDINALITY k(num, ord) + JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.num ORDER BY k.ord), + array(SELECT a.attname::text FROM unnest(c.confkey) WITH ORDINALITY k(num, ord) + JOIN pg_attribute a ON a.attrelid = c.confrelid AND a.attnum = k.num ORDER BY k.ord) + FROM pg_constraint c + WHERE c.connamespace = 'public'::regnamespace AND c.contype IN ('c', 'f') + """, r => + { + var table = r.GetString(0).Trim('"'); + if (r.GetString(2) == "c") + { + Table(checks, table).Add(r.GetString(1)); + return; + } + + foreignKeys[r.GetString(1)] = new SchemaForeignKey( + table, + r.GetFieldValue(5), + r.GetString(3).Trim('"'), + r.GetFieldValue(6), + DeleteRule(r.GetString(4))); + }); + + var tables = columns.Keys + .Where(t => t != HistoryTable) + .ToDictionary( + t => t, + t => new SchemaTable( + columns[t], + indexes.GetValueOrDefault(t) ?? new Dictionary(), + checks.GetValueOrDefault(t) ?? new HashSet())); + + return new DatabaseSchema(tables, foreignKeys, new Dictionary()); + } + + public static async Task>> ReadRowsAsync(NpgsqlConnection connection, string table, IReadOnlyList columns) + { + var rows = new List>(); + var columnList = string.Join(", ", columns.Select(c => $"\"{c}\"")); + await ForEachRowAsync(connection, $"SELECT {columnList} FROM \"{table}\" ORDER BY 1", r => + { + rows.Add(Enumerable.Range(0, r.FieldCount) + .Select(i => Convert.ToString(r.GetValue(i), CultureInfo.InvariantCulture) ?? "") + .ToList()); + }); + + return rows; + } + + private static string DeleteRule(string code) => code switch + { + "a" => "no action", + "r" => "restrict", + "c" => "cascade", + "n" => "set null", + "d" => "set default", + _ => throw new InvalidOperationException($"Unknown delete rule {code}") + }; + + private static TValue Table(Dictionary items, string table) where TValue : new() + { + if (!items.TryGetValue(table, out var value)) + { + value = new TValue(); + items[table] = value; + } + + return value; + } + + private static async Task ForEachRowAsync(NpgsqlConnection connection, string sql, Action read) + { + await using var command = new NpgsqlCommand(sql, connection); + await using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + read(reader); + } + } +} diff --git a/tests/MoissonMontreal.Tests/Docs/RepositoryPaths.cs b/tests/MoissonMontreal.Tests/Docs/RepositoryPaths.cs new file mode 100644 index 0000000..224b1ad --- /dev/null +++ b/tests/MoissonMontreal.Tests/Docs/RepositoryPaths.cs @@ -0,0 +1,28 @@ +namespace MoissonMontreal.Tests.Docs; + +/// +/// Locates the repository folders from the test output folder, by walking up to the folder that holds the solution file. +/// +internal static class RepositoryPaths +{ + public static string Root { get; } = FindRoot(); + + public static string DatabaseDocs => Path.Combine(Root, "docs", "database"); + + public static string Migrations => Path.Combine(Root, "src", "MoissonMontreal.Api", "Data", "Migrations"); + + public static IEnumerable DbmlFiles => Directory.EnumerateFiles(DatabaseDocs, "*.dbml").Order(); + + private static string FindRoot() + { + for (var folder = new DirectoryInfo(AppContext.BaseDirectory); folder is not null; folder = folder.Parent) + { + if (File.Exists(Path.Combine(folder.FullName, "MoissonMontreal.slnx"))) + { + return folder.FullName; + } + } + + throw new DirectoryNotFoundException("MoissonMontreal.slnx was not found above the test output folder"); + } +} diff --git a/tests/MoissonMontreal.Tests/Docs/SchemaDiagramTests.cs b/tests/MoissonMontreal.Tests/Docs/SchemaDiagramTests.cs new file mode 100644 index 0000000..de09332 --- /dev/null +++ b/tests/MoissonMontreal.Tests/Docs/SchemaDiagramTests.cs @@ -0,0 +1,75 @@ +using Microsoft.EntityFrameworkCore; +using MoissonMontreal.Api.Data; +using MoissonMontreal.Tests.Fixtures; +using Npgsql; + +namespace MoissonMontreal.Tests.Docs; + +[Trait("Category", "Integration")] +public class SchemaDiagramTests(PostgresFixture postgres) : IClassFixture +{ + public static TheoryData DbmlFiles => [.. RepositoryPaths.DbmlFiles.Select(f => Path.GetFileName(f))]; + + [Theory] + [MemberData(nameof(DbmlFiles))] + public async Task Diagram_MatchesTheMigratedDatabase(string fileName) + { + var diagram = ReadDiagram(fileName); + var database = await ReadDatabaseAsync(); + + var differences = DatabaseSchema.Compare(diagram, database, diagramMustHaveEveryTable: fileName == "schema.dbml"); + + Assert.True(differences.Count == 0, $"{fileName} does not match the migrations:\n{string.Join("\n", differences)}"); + } + + [Fact] + public async Task FullSchemaDiagram_HasEveryTableOfTheDatabase() + { + var diagram = ReadDiagram("schema.dbml"); + var database = await ReadDatabaseAsync(); + + Assert.Equal(database.Tables.Keys.Order(), diagram.Tables.Keys.Order()); + Assert.Equal(database.ForeignKeys.Keys.Order(), diagram.ForeignKeys.Keys.Order()); + } + + [Theory] + [MemberData(nameof(DbmlFiles))] + public async Task DiagramRecords_MatchTheRowsCreatedByTheMigrations(string fileName) + { + var diagram = ReadDiagram(fileName); + await ReadDatabaseAsync(); + + await using var connection = new NpgsqlConnection(postgres.ConnectionString); + await connection.OpenAsync(); + foreach (var (table, records) in diagram.Records) + { + var rows = await PostgresSchemaReader.ReadRowsAsync(connection, table, records.Columns); + + Assert.Equal(records.Rows, rows); + } + } + + [Fact] + public void FullSchemaDiagram_ListsTheFourRoles() + { + var diagram = ReadDiagram("schema.dbml"); + + var roles = Assert.Single(diagram.Records, r => r.Key == "roles").Value; + Assert.Equal(["Supply", "SupplyDirector", "Finance", "Admin"], roles.Rows.Select(r => r[1])); + } + + private static DatabaseSchema ReadDiagram(string fileName) => + DbmlReader.Read(File.ReadAllText(Path.Combine(RepositoryPaths.DatabaseDocs, fileName))); + + private async Task ReadDatabaseAsync() + { + await using (var context = new MoissonDbContext(new DbContextOptionsBuilder().UseNpgsql(postgres.ConnectionString).Options)) + { + await context.Database.MigrateAsync(); + } + + await using var connection = new NpgsqlConnection(postgres.ConnectionString); + await connection.OpenAsync(); + return await PostgresSchemaReader.ReadAsync(connection); + } +} diff --git a/tests/MoissonMontreal.Tests/Docs/SchemaDriftTests.cs b/tests/MoissonMontreal.Tests/Docs/SchemaDriftTests.cs new file mode 100644 index 0000000..faefaab --- /dev/null +++ b/tests/MoissonMontreal.Tests/Docs/SchemaDriftTests.cs @@ -0,0 +1,114 @@ +namespace MoissonMontreal.Tests.Docs; + +public class SchemaDriftTests +{ + private static readonly string FullSchema = File.ReadAllText(Path.Combine(RepositoryPaths.DatabaseDocs, "schema.dbml")); + + public static TheoryData Drifts => new() + { + { "lower price precision", "unit_price numeric(12,4)", "unit_price numeric(12,2)", "receipt_items.unit_price: type" }, + { "longer text", "quality varchar(1)", "quality varchar(2)", "food_items.quality: type" }, + { "missing column", " updated_at timestamptz [null, note: 'Stored in UTC, empty if never changed']\n", "", "Column users.updated_at is missing from the diagram" }, + { "extra column", " display_name varchar(200) [not null]\n", " display_name varchar(200) [not null]\n nickname varchar(50) [null]\n", "Column users.nickname is in the diagram but not in the database" }, + { "wrong nullability", "entra_object_id uuid [null", "entra_object_id uuid [not null", "users.entra_object_id: not null" }, + { "wrong default", "default: true", "default: false", "users.is_active: default" }, + { "wrong time zone default", "'America/Montreal'", "'UTC'", "receipts.receipt_date: default" }, + { "missing default", "created_at timestamptz [not null, default: `now()`, note: 'Stored in UTC']\n\n indexes {\n id [pk, name: 'pk_donors']", "created_at timestamptz [not null, note: 'Stored in UTC']\n\n indexes {\n id [pk, name: 'pk_donors']", "donors.created_at: default" }, + { "missing increment", "id integer [not null, increment]", "id integer [not null]", "users.id: increment" }, + { "renamed index", "name: 'ix_receipt_items_food_item_id',", "name: 'ix_receipt_items_food_item',", "Index ix_receipt_items_food_item_id is missing from the diagram" }, + { "duplicates allowed", "food_item_id [unique, name: 'ix_receipt_items_food_item_id'", "food_item_id [name: 'ix_receipt_items_food_item_id'", "Index ix_receipt_items_food_item_id: unique" }, + { "index columns in another order", "(receipt_id, food_item_id) [pk", "(food_item_id, receipt_id) [pk", "Index pk_receipt_items: columns" }, + { "missing check", " `quality IN ('A', 'B', 'C')` [name: 'ck_food_items_quality']\n", "", "Check ck_food_items_quality is missing from the diagram" }, + { "wrong delete rule", "receipts.(id, donor_id) [delete: cascade]", "receipts.(id, donor_id) [delete: restrict]", "Ref fk_receipt_items_receipts_receipt_id_donor_id: delete rule" }, + { "donor left out of a link", "receipt_items.(food_item_id, donor_id) > food_items.(id, donor_id)", "receipt_items.food_item_id > food_items.id", "Ref fk_receipt_items_food_items_food_item_id_donor_id: columns" }, + { "missing link", "Ref fk_users_roles_role_id: users.role_id > roles.id [delete: restrict]\n", "", "Ref fk_users_roles_role_id is missing from the diagram" }, + { "renamed table", "Table donors {", "Table donor {", "Table donors is missing from the diagram" } + }; + + [Fact] + public void FullSchemaDiagram_MatchesItself() + { + var schema = DbmlReader.Read(FullSchema); + + Assert.Empty(DatabaseSchema.Compare(schema, schema, diagramMustHaveEveryTable: true)); + } + + [Theory] + [MemberData(nameof(Drifts))] + public void Compare_ReportsTheDifference_WhenTheDiagramDrifts(string drift, string find, string replace, string expected) + { + Assert.Contains(find, FullSchema); + var database = DbmlReader.Read(FullSchema); + var diagram = DbmlReader.Read(FullSchema.Replace(find, replace)); + + var differences = DatabaseSchema.Compare(diagram, database, diagramMustHaveEveryTable: true); + + Assert.True(differences.Any(d => d.StartsWith(expected, StringComparison.Ordinal)), $"{drift}: expected \"{expected}\" in:\n{string.Join("\n", differences)}"); + } + + [Fact] + public void Compare_ReportsALinkToATableThatIsNotInTheDiagram() + { + var database = DbmlReader.Read(FullSchema); + var diagram = DbmlReader.Read(""" + Table users { + id integer [not null] + role_id integer [not null] + } + + Ref fk_users_roles_role_id: users.role_id > roles.id [delete: restrict] + """); + + var differences = DatabaseSchema.Compare(diagram, database, diagramMustHaveEveryTable: false); + + Assert.Contains("Ref fk_users_roles_role_id links a table that is not in the diagram", differences); + } + + [Theory] + [InlineData("Ref: users.role_id < roles.id")] + [InlineData("Enum quality { A B C }")] + [InlineData("Table users {\n id integer [pk]\n}")] + [InlineData("Table users {\n id integer\n indexes {\n id [pk]\n }\n}")] + [InlineData("Table users {\n id integer\n checks {\n `id > 0`\n }\n}")] + [InlineData("Table users {\n id integer\n id integer\n}")] + [InlineData("Table users {\n id integer\n")] + public void Read_Throws_OnDbmlItCannotCheck(string dbml) + { + Assert.Throws(() => DbmlReader.Read(dbml)); + } + + [Fact] + public void Read_KeepsCommasAndColonsInsideNotes() + { + var schema = DbmlReader.Read(""" + Table food_items { + quality varchar(1) [not null, note: 'A, B or C: the grade'] + } + """); + + var column = schema.Tables["food_items"].Columns["quality"]; + Assert.Equal(new SchemaColumn("character varying(1)", true, null, false), column); + } + + [Theory] + [InlineData("varchar(256)", "character varying(256)")] + [InlineData("character varying(256)", "character varying(256)")] + [InlineData("timestamptz", "timestamp with time zone")] + [InlineData("timestamp with time zone", "timestamp with time zone")] + [InlineData("numeric(12, 4)", "numeric(12,4)")] + [InlineData("int", "integer")] + public void NormalizeType_GivesTheSameSpellingForTheSameType(string type, string expected) + { + Assert.Equal(expected, SchemaText.NormalizeType(type)); + } + + [Theory] + [InlineData("`(now() AT TIME ZONE 'America/Montreal')::date`", "((now() AT TIME ZONE 'America/Montreal'::text))::date")] + [InlineData("`now()`", "now()")] + [InlineData("`gen_random_uuid()`", "gen_random_uuid()")] + [InlineData("true", "true")] + public void NormalizeDefault_MatchesThePostgresSpelling(string diagram, string postgres) + { + Assert.Equal(SchemaText.NormalizeDefault(postgres), SchemaText.NormalizeDefault(diagram)); + } +} diff --git a/tests/MoissonMontreal.Tests/Docs/SchemaText.cs b/tests/MoissonMontreal.Tests/Docs/SchemaText.cs new file mode 100644 index 0000000..af63d2c --- /dev/null +++ b/tests/MoissonMontreal.Tests/Docs/SchemaText.cs @@ -0,0 +1,49 @@ +using System.Text.RegularExpressions; + +namespace MoissonMontreal.Tests.Docs; + +/// +/// Normalizes types and default values, so that the spelling used in a diagram and the one returned by PostgreSQL compare equal. +/// +internal static partial class SchemaText +{ + public static string NormalizeType(string type) + { + var value = WhiteSpace().Replace(type.Trim().ToLowerInvariant(), ""); + value = Varchar().Replace(value, "character varying$1"); + + return value switch + { + "timestamptz" => "timestamp with time zone", + "timestampwithtimezone" => "timestamp with time zone", + "int" or "int4" => "integer", + "bool" => "boolean", + _ => value.StartsWith("charactervarying", StringComparison.Ordinal) + ? "character varying" + value["charactervarying".Length..] + : value + }; + } + + public static string? NormalizeDefault(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + var text = value.Trim().Trim('`'); + text = text.Replace("::text", "", StringComparison.OrdinalIgnoreCase) + .Replace("::character varying", "", StringComparison.OrdinalIgnoreCase); + + return WhiteSpaceOrParentheses().Replace(text, "").ToLowerInvariant(); + } + + [GeneratedRegex(@"\s+")] + private static partial Regex WhiteSpace(); + + [GeneratedRegex(@"^varchar(\(\d+\))?$")] + private static partial Regex Varchar(); + + [GeneratedRegex(@"[\s()]+")] + private static partial Regex WhiteSpaceOrParentheses(); +} From 8397ea05f599ad5d835d932e6b4cafe9083fb603 Mon Sep 17 00:00:00 2001 From: MarcAntoineGmd Date: Sat, 7 Nov 2026 23:42:17 -0500 Subject: [PATCH 2/2] test(docs): address review feedback on schema diagram tests --- docs/database/schema.md | 2 +- .../Docs/DatabaseDocsTests.cs | 4 +- .../Docs/DatabaseSchema.cs | 24 ++--- .../MoissonMontreal.Tests/Docs/DbmlReader.cs | 16 +-- .../Docs/PostgresSchemaReader.cs | 99 +++++++++++++++-- .../Docs/SchemaDiagramTests.cs | 100 ++++++++++++++++-- .../Docs/SchemaDriftTests.cs | 35 ++++-- .../MoissonMontreal.Tests/Docs/SchemaText.cs | 21 +--- 8 files changed, 234 insertions(+), 67 deletions(-) diff --git a/docs/database/schema.md b/docs/database/schema.md index 2d257c4..efcc9fe 100644 --- a/docs/database/schema.md +++ b/docs/database/schema.md @@ -156,4 +156,4 @@ The SQL scripts must be run in order on an empty database. Every date and time is converted to UTC before it is written, by [UtcDateTimeOffsetConverter.cs](../../src/MoissonMontreal.Api/Data/UtcDateTimeOffsetConverter.cs), which [MoissonDbContext.cs](../../src/MoissonMontreal.Api/Data/MoissonDbContext.cs) applies to every DateTimeOffset column. -The tests in [SchemaDiagramTests.cs](../../tests/MoissonMontreal.Tests/Docs/SchemaDiagramTests.cs) build an empty database from the migrations and check that every .dbml file in this folder matches it exactly: tables, columns, types, defaults, keys, indexes, checks, links and the roles data. Run them with `dotnet test --filter "FullyQualifiedName~MoissonMontreal.Tests.Docs"`. When a migration changes the schema, update [schema.dbml](schema.dbml), the .dbml of that part, and the images. +The tests in [SchemaDiagramTests.cs](../../tests/MoissonMontreal.Tests/Docs/SchemaDiagramTests.cs) build an empty database from the migrations and check that every .dbml file in this folder matches it exactly: tables, columns, types, defaults, keys, indexes, checks, links and the roles data. Defaults and checks are compared by their full expression, after PostgreSQL rewrites the diagram's version in its own spelling. dbdiagram.io cannot draw a partial index (an index with a WHERE condition) or an index on an expression, so the tests fail if a migration adds one. When that happens, for example for replacement receipts (#31), describe the condition in the index note and extend the tests. Run them with `dotnet test --filter "FullyQualifiedName~MoissonMontreal.Tests.Docs"`. When a migration changes the schema, update [schema.dbml](schema.dbml), the .dbml of that part, and the images. diff --git a/tests/MoissonMontreal.Tests/Docs/DatabaseDocsTests.cs b/tests/MoissonMontreal.Tests/Docs/DatabaseDocsTests.cs index 00fdd08..147ab02 100644 --- a/tests/MoissonMontreal.Tests/Docs/DatabaseDocsTests.cs +++ b/tests/MoissonMontreal.Tests/Docs/DatabaseDocsTests.cs @@ -33,7 +33,7 @@ public void EveryLinkAndImage_PointsToAFileThatExists(string fileName) public void SchemaPage_DescribesEveryTableOfTheDiagram() { var schema = DbmlReader.Read(File.ReadAllText(Path.Combine(RepositoryPaths.DatabaseDocs, "schema.dbml"))); - var page = File.ReadAllText(Path.Combine(RepositoryPaths.DatabaseDocs, "schema.md")); + var page = File.ReadAllText(Path.Combine(RepositoryPaths.DatabaseDocs, "schema.md")).ReplaceLineEndings("\n"); Assert.All(schema.Tables.Keys, table => Assert.Contains($"\n### {table}\n", page)); } @@ -42,7 +42,7 @@ public void SchemaPage_DescribesEveryTableOfTheDiagram() [MemberData(nameof(DocsFiles))] public void DocsFile_HasNoEmDash(string relativePath) { - Assert.DoesNotContain('—', File.ReadAllText(Path.Combine(RepositoryPaths.Root, relativePath))); + Assert.DoesNotContain('—',File.ReadAllText(Path.Combine(RepositoryPaths.Root, relativePath))); } [Fact] diff --git a/tests/MoissonMontreal.Tests/Docs/DatabaseSchema.cs b/tests/MoissonMontreal.Tests/Docs/DatabaseSchema.cs index 5a3a5b0..4cd8d68 100644 --- a/tests/MoissonMontreal.Tests/Docs/DatabaseSchema.cs +++ b/tests/MoissonMontreal.Tests/Docs/DatabaseSchema.cs @@ -73,17 +73,13 @@ private static void CompareTable(string table, SchemaTable diagram, SchemaTable Check(differences, $"Index {name}: columns", Join(expected.Columns), Join(actual.Columns)); Check(differences, $"Index {name}: primary key", expected.Primary, actual.Primary); Check(differences, $"Index {name}: unique", expected.Unique, actual.Unique); + Check(differences, $"Index {name}: condition", expected.Where ?? "none", actual.Where ?? "none"); }); - foreach (var name in diagram.Checks.Except(database.Checks)) + CompareNamed("Check ", diagram.Checks, database.Checks, differences, (name, expected, actual) => { - differences.Add($"Check {name} is in the diagram but not in the database"); - } - - foreach (var name in database.Checks.Except(diagram.Checks)) - { - differences.Add($"Check {name} is missing from the diagram"); - } + Check(differences, $"Check {name}: expression", expected, actual); + }); } private static void CompareNamed( @@ -121,22 +117,24 @@ private static void Check(List differences, string what, T diagram, T } /// -/// One table: its columns, its indexes (including the primary key and unique constraints) and the names of its checks. +/// One table: its columns, its indexes (including the primary key and unique constraints) and its checks, by name. /// internal sealed record SchemaTable( IReadOnlyDictionary Columns, IReadOnlyDictionary Indexes, - IReadOnlySet Checks); + IReadOnlyDictionary Checks); /// -/// One column. Type and default are normalized so the diagram and PostgreSQL spellings compare equal. +/// One column. The type is normalized so the diagram and PostgreSQL spellings compare equal. +/// The default is the expression as written, until PostgreSQL rewrites it in its own spelling. /// internal sealed record SchemaColumn(string Type, bool NotNull, string? Default, bool Increment); /// -/// One index, primary key or unique constraint, with its columns in order. +/// One index, primary key or unique constraint, with its columns or expressions in order, +/// and the condition of a partial index. A diagram cannot show a condition, so it is always null there. /// -internal sealed record SchemaIndex(IReadOnlyList Columns, bool Primary, bool Unique); +internal sealed record SchemaIndex(IReadOnlyList Columns, bool Primary, bool Unique, string? Where = null); /// /// One foreign key, with its delete rule in lowercase words such as restrict or cascade. diff --git a/tests/MoissonMontreal.Tests/Docs/DbmlReader.cs b/tests/MoissonMontreal.Tests/Docs/DbmlReader.cs index 315bcff..211948c 100644 --- a/tests/MoissonMontreal.Tests/Docs/DbmlReader.cs +++ b/tests/MoissonMontreal.Tests/Docs/DbmlReader.cs @@ -33,7 +33,7 @@ public static DatabaseSchema Read(string dbml) else if ((match = RefLine().Match(line)).Success) { var settings = Settings(match.Groups[6].Value); - var onDelete = settings.GetValueOrDefault("delete") ?? "no action"; + var onDelete = Unquote(settings.GetValueOrDefault("delete") ?? "no action"); Add(foreignKeys, match.Groups[1].Value, new SchemaForeignKey( match.Groups[2].Value, Columns(match.Groups[3].Value), @@ -64,7 +64,7 @@ private static SchemaTable ReadTable(string table, List body) { var columns = new Dictionary(); var indexes = new Dictionary(); - var checks = new HashSet(); + var checks = new Dictionary(); for (var i = 0; i < body.Count; i++) { @@ -85,7 +85,9 @@ private static SchemaTable ReadTable(string table, List body) } var settings = Settings(match.Groups[2].Value); - var name = settings.GetValueOrDefault("name") ?? throw new FormatException($"Index without a name in {table}: {indexLine}"); + var name = settings.GetValueOrDefault("name") is { } quoted + ? Unquote(quoted) + : throw new FormatException($"Index without a name in {table}: {indexLine}"); var primary = settings.ContainsKey("pk"); Add(indexes, name, new SchemaIndex(Columns(match.Groups[1].Value), primary, primary || settings.ContainsKey("unique")), "Index"); } @@ -95,8 +97,8 @@ private static SchemaTable ReadTable(string table, List body) foreach (var checkLine in TakeBlock(body, ref i).Select(l => l.Trim()).Where(l => l.Length > 0)) { var match = CheckLine().Match(checkLine); - var name = match.Success ? Settings(match.Groups[1].Value).GetValueOrDefault("name") : null; - if (name is null || !checks.Add(name)) + var name = match.Success ? Settings(match.Groups[2].Value).GetValueOrDefault("name") : null; + if (name is null || !checks.TryAdd(Unquote(name), match.Groups[1].Value)) { throw new FormatException($"Check without a unique name in {table}: {checkLine}"); } @@ -162,7 +164,7 @@ private static List TakeBlock(List lines, ref int start) } else { - settings[part[..colon].Trim().ToLowerInvariant()] = Unquote(part[(colon + 1)..]); + settings[part[..colon].Trim().ToLowerInvariant()] = part[(colon + 1)..].Trim(); } } @@ -268,6 +270,6 @@ private static void Add(Dictionary items, string name, T item, str [GeneratedRegex(@"^(\(.*?\)|\w+)\s*\[(.*)\]$")] private static partial Regex IndexLine(); - [GeneratedRegex(@"^`.*`\s*\[(.*)\]$")] + [GeneratedRegex(@"^`(.*)`\s*\[(.*)\]$")] private static partial Regex CheckLine(); } diff --git a/tests/MoissonMontreal.Tests/Docs/PostgresSchemaReader.cs b/tests/MoissonMontreal.Tests/Docs/PostgresSchemaReader.cs index ac4f158..93dcb0f 100644 --- a/tests/MoissonMontreal.Tests/Docs/PostgresSchemaReader.cs +++ b/tests/MoissonMontreal.Tests/Docs/PostgresSchemaReader.cs @@ -15,7 +15,7 @@ public static async Task ReadAsync(NpgsqlConnection connection) { var columns = new Dictionary>(); var indexes = new Dictionary>(); - var checks = new Dictionary>(); + var checks = new Dictionary>(); var foreignKeys = new Dictionary(); await ForEachRowAsync(connection, """ @@ -30,21 +30,26 @@ FROM pg_attribute a Table(columns, r.GetString(0))[r.GetString(1)] = new SchemaColumn( SchemaText.NormalizeType(r.GetString(2)), r.GetBoolean(3), - SchemaText.NormalizeDefault(r.IsDBNull(4) ? null : r.GetString(4)), + r.IsDBNull(4) ? null : r.GetString(4), r.GetBoolean(5)); }); + // pg_get_indexdef returns the column name, or the full expression of an expression column, so none is skipped. await ForEachRowAsync(connection, """ SELECT t.relname, i.relname, ix.indisprimary, ix.indisunique, - array(SELECT a.attname::text FROM unnest(ix.indkey) WITH ORDINALITY k(num, ord) - JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.num ORDER BY k.ord) + array(SELECT pg_get_indexdef(ix.indexrelid, n, true) FROM generate_series(1, ix.indnkeyatts) n ORDER BY n), + pg_get_expr(ix.indpred, ix.indrelid, true) FROM pg_index ix JOIN pg_class t ON t.oid = ix.indrelid JOIN pg_class i ON i.oid = ix.indexrelid WHERE t.relnamespace = 'public'::regnamespace """, r => { - Table(indexes, r.GetString(0))[r.GetString(1)] = new SchemaIndex(r.GetFieldValue(4), r.GetBoolean(2), r.GetBoolean(3)); + Table(indexes, r.GetString(0))[r.GetString(1)] = new SchemaIndex( + r.GetFieldValue(4), + r.GetBoolean(2), + r.GetBoolean(3), + r.IsDBNull(5) ? null : r.GetString(5)); }); await ForEachRowAsync(connection, """ @@ -52,7 +57,8 @@ await ForEachRowAsync(connection, """ array(SELECT a.attname::text FROM unnest(c.conkey) WITH ORDINALITY k(num, ord) JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.num ORDER BY k.ord), array(SELECT a.attname::text FROM unnest(c.confkey) WITH ORDINALITY k(num, ord) - JOIN pg_attribute a ON a.attrelid = c.confrelid AND a.attnum = k.num ORDER BY k.ord) + JOIN pg_attribute a ON a.attrelid = c.confrelid AND a.attnum = k.num ORDER BY k.ord), + pg_get_constraintdef(c.oid) FROM pg_constraint c WHERE c.connamespace = 'public'::regnamespace AND c.contype IN ('c', 'f') """, r => @@ -60,7 +66,7 @@ FROM pg_constraint c var table = r.GetString(0).Trim('"'); if (r.GetString(2) == "c") { - Table(checks, table).Add(r.GetString(1)); + Table(checks, table)[r.GetString(1)] = r.GetString(7); return; } @@ -79,11 +85,64 @@ FROM pg_constraint c t => new SchemaTable( columns[t], indexes.GetValueOrDefault(t) ?? new Dictionary(), - checks.GetValueOrDefault(t) ?? new HashSet())); + checks.GetValueOrDefault(t) ?? new Dictionary())); return new DatabaseSchema(tables, foreignKeys, new Dictionary()); } + /// + /// Has PostgreSQL rewrite the defaults and checks of the diagram in its own spelling, by adding them to empty copies of the real tables + /// and reading them back. The two sides can then be compared exactly. Nothing is kept: the transaction is rolled back. + /// An expression PostgreSQL rejects is replaced by a message, so the comparison reports it. + /// + public static async Task CanonicalizeAsync(NpgsqlConnection connection, DatabaseSchema diagram) + { + var existing = (await ReadAsync(connection)).Tables; + var tables = new Dictionary(); + + await using var transaction = await connection.BeginTransactionAsync(); + foreach (var (name, table) in diagram.Tables) + { + if (!existing.TryGetValue(name, out var real)) + { + tables[name] = table; + continue; + } + + var probe = $"probe_{name}"; + await ExecuteAsync(connection, $"CREATE TEMP TABLE \"{probe}\" (LIKE public.\"{name}\")"); + + var columns = new Dictionary(); + foreach (var (columnName, column) in table.Columns) + { + columns[columnName] = column.Default is null || !real.Columns.ContainsKey(columnName) + ? column + : column with + { + Default = await RewriteAsync(connection, $"ALTER TABLE \"{probe}\" ALTER COLUMN \"{columnName}\" SET DEFAULT {column.Default}", $""" + SELECT pg_get_expr(d.adbin, d.adrelid) FROM pg_attrdef d + JOIN pg_attribute a ON a.attrelid = d.adrelid AND a.attnum = d.adnum + WHERE d.adrelid = '"{probe}"'::regclass AND a.attname = '{columnName}' + """) + }; + } + + var checks = new Dictionary(); + foreach (var (checkName, expression) in table.Checks) + { + checks[checkName] = await RewriteAsync(connection, $"ALTER TABLE \"{probe}\" ADD CONSTRAINT \"{checkName}\" CHECK ({expression})", $""" + SELECT pg_get_constraintdef(oid) FROM pg_constraint + WHERE conrelid = '"{probe}"'::regclass AND conname = '{checkName}' + """); + } + + tables[name] = table with { Columns = columns, Checks = checks }; + } + + await transaction.RollbackAsync(); + return diagram with { Tables = tables }; + } + public static async Task>> ReadRowsAsync(NpgsqlConnection connection, string table, IReadOnlyList columns) { var rows = new List>(); @@ -98,6 +157,24 @@ await ForEachRowAsync(connection, $"SELECT {columnList} FROM \"{table}\" ORDER B return rows; } + private static async Task RewriteAsync(NpgsqlConnection connection, string change, string read) + { + await ExecuteAsync(connection, "SAVEPOINT rewrite"); + try + { + await ExecuteAsync(connection, change); + await using var command = new NpgsqlCommand(read, connection); + var rewritten = (string)(await command.ExecuteScalarAsync())!; + await ExecuteAsync(connection, "RELEASE SAVEPOINT rewrite"); + return rewritten; + } + catch (PostgresException exception) + { + await ExecuteAsync(connection, "ROLLBACK TO SAVEPOINT rewrite"); + return $"invalid ({exception.MessageText})"; + } + } + private static string DeleteRule(string code) => code switch { "a" => "no action", @@ -119,6 +196,12 @@ await ForEachRowAsync(connection, $"SELECT {columnList} FROM \"{table}\" ORDER B return value; } + private static async Task ExecuteAsync(NpgsqlConnection connection, string sql) + { + await using var command = new NpgsqlCommand(sql, connection); + await command.ExecuteNonQueryAsync(); + } + private static async Task ForEachRowAsync(NpgsqlConnection connection, string sql, Action read) { await using var command = new NpgsqlCommand(sql, connection); diff --git a/tests/MoissonMontreal.Tests/Docs/SchemaDiagramTests.cs b/tests/MoissonMontreal.Tests/Docs/SchemaDiagramTests.cs index de09332..d10f984 100644 --- a/tests/MoissonMontreal.Tests/Docs/SchemaDiagramTests.cs +++ b/tests/MoissonMontreal.Tests/Docs/SchemaDiagramTests.cs @@ -8,13 +8,37 @@ namespace MoissonMontreal.Tests.Docs; [Trait("Category", "Integration")] public class SchemaDiagramTests(PostgresFixture postgres) : IClassFixture { + private static readonly string FullSchema = File.ReadAllText(Path.Combine(RepositoryPaths.DatabaseDocs, "schema.dbml")).ReplaceLineEndings("\n"); + public static TheoryData DbmlFiles => [.. RepositoryPaths.DbmlFiles.Select(f => Path.GetFileName(f))]; + public static TheoryData ExpressionDrifts => new() + { + { "`quality IN ('A', 'B', 'C')`", "`quality IN ('A', 'B')`", "Check ck_food_items_quality: expression" }, + { "`quantity > 0` [name: 'ck_food_items_quantity_positive']", "`quantity >= 0` [name: 'ck_food_items_quantity_positive']", "Check ck_food_items_quantity_positive: expression" }, + { "`quality_percent BETWEEN 0 AND 100`", "`quality_percent BETWEEN 0 AND 75`", "Check ck_receipt_items_quality_percent_range: expression" }, + { "`quality_percent BETWEEN 0 AND 100`", "`quality_percent BETWEEN 0 AND 100 OR unknown_column > 0`", "Check ck_receipt_items_quality_percent_range: expression is invalid" }, + { "'America/Montreal'", "'America/Toronto'", "receipts.receipt_date: default" }, + { "default: true", "default: false", "users.is_active: default" } + }; + + public static TheoryData IndexesADiagramCannotShow => new() + { + { + "DROP INDEX ix_receipt_items_food_item_id; CREATE UNIQUE INDEX ix_receipt_items_food_item_id ON receipt_items (food_item_id) WHERE quantity > 0", + "Index ix_receipt_items_food_item_id: condition is none in the diagram but quantity > 0::numeric in the database" + }, + { + "DROP INDEX ix_users_email; CREATE UNIQUE INDEX ix_users_email ON users (lower(email::text))", + "Index ix_users_email: columns is (email) in the diagram but (lower(email::text)) in the database" + } + }; + [Theory] [MemberData(nameof(DbmlFiles))] public async Task Diagram_MatchesTheMigratedDatabase(string fileName) { - var diagram = ReadDiagram(fileName); + var diagram = await ReadDiagramAsync(File.ReadAllText(Path.Combine(RepositoryPaths.DatabaseDocs, fileName))); var database = await ReadDatabaseAsync(); var differences = DatabaseSchema.Compare(diagram, database, diagramMustHaveEveryTable: fileName == "schema.dbml"); @@ -25,19 +49,64 @@ public async Task Diagram_MatchesTheMigratedDatabase(string fileName) [Fact] public async Task FullSchemaDiagram_HasEveryTableOfTheDatabase() { - var diagram = ReadDiagram("schema.dbml"); + var diagram = DbmlReader.Read(FullSchema); var database = await ReadDatabaseAsync(); Assert.Equal(database.Tables.Keys.Order(), diagram.Tables.Keys.Order()); Assert.Equal(database.ForeignKeys.Keys.Order(), diagram.ForeignKeys.Keys.Order()); } + [Theory] + [MemberData(nameof(ExpressionDrifts))] + public async Task Diagram_ReportsADifference_WhenAnExpressionDiffers(string find, string replace, string expected) + { + Assert.Contains(find, FullSchema); + var diagram = await ReadDiagramAsync(FullSchema.Replace(find, replace)); + var database = await ReadDatabaseAsync(); + + var differences = DatabaseSchema.Compare(diagram, database, diagramMustHaveEveryTable: true); + + Assert.True(differences.Any(d => d.StartsWith(expected, StringComparison.Ordinal)), $"Expected \"{expected}\" in:\n{string.Join("\n", differences)}"); + } + + [Fact] + public async Task Diagram_KeepsParenthesesThatChangeTheMeaning() + { + var grouped = await ReadDiagramAsync(FullSchema.Replace("`total_amount >= 0`", "`(total_amount + 1) * 2 >= 0`")); + var ungrouped = await ReadDiagramAsync(FullSchema.Replace("`total_amount >= 0`", "`total_amount + 1 * 2 >= 0`")); + + var differences = DatabaseSchema.Compare(grouped, ungrouped, diagramMustHaveEveryTable: true); + + Assert.Single(differences, d => d.StartsWith("Check ck_receipts_total_amount_not_negative: expression", StringComparison.Ordinal)); + } + + [Theory] + [MemberData(nameof(IndexesADiagramCannotShow))] + public async Task Diagram_ReportsAPartialOrExpressionIndex(string change, string expected) + { + var diagram = await ReadDiagramAsync(FullSchema); + await MigrateAsync(); + + await using var connection = new NpgsqlConnection(postgres.ConnectionString); + await connection.OpenAsync(); + await using var transaction = await connection.BeginTransactionAsync(); + await using (var command = new NpgsqlCommand(change, connection)) + { + await command.ExecuteNonQueryAsync(); + } + + var database = await PostgresSchemaReader.ReadAsync(connection); + await transaction.RollbackAsync(); + + Assert.Contains(expected, DatabaseSchema.Compare(diagram, database, diagramMustHaveEveryTable: true)); + } + [Theory] [MemberData(nameof(DbmlFiles))] public async Task DiagramRecords_MatchTheRowsCreatedByTheMigrations(string fileName) { - var diagram = ReadDiagram(fileName); - await ReadDatabaseAsync(); + var diagram = DbmlReader.Read(File.ReadAllText(Path.Combine(RepositoryPaths.DatabaseDocs, fileName))); + await MigrateAsync(); await using var connection = new NpgsqlConnection(postgres.ConnectionString); await connection.OpenAsync(); @@ -52,24 +121,33 @@ public async Task DiagramRecords_MatchTheRowsCreatedByTheMigrations(string fileN [Fact] public void FullSchemaDiagram_ListsTheFourRoles() { - var diagram = ReadDiagram("schema.dbml"); + var diagram = DbmlReader.Read(FullSchema); var roles = Assert.Single(diagram.Records, r => r.Key == "roles").Value; Assert.Equal(["Supply", "SupplyDirector", "Finance", "Admin"], roles.Rows.Select(r => r[1])); } - private static DatabaseSchema ReadDiagram(string fileName) => - DbmlReader.Read(File.ReadAllText(Path.Combine(RepositoryPaths.DatabaseDocs, fileName))); + private async Task MigrateAsync() + { + await using var context = new MoissonDbContext(new DbContextOptionsBuilder().UseNpgsql(postgres.ConnectionString).Options); + await context.Database.MigrateAsync(); + } private async Task ReadDatabaseAsync() { - await using (var context = new MoissonDbContext(new DbContextOptionsBuilder().UseNpgsql(postgres.ConnectionString).Options)) - { - await context.Database.MigrateAsync(); - } + await MigrateAsync(); await using var connection = new NpgsqlConnection(postgres.ConnectionString); await connection.OpenAsync(); return await PostgresSchemaReader.ReadAsync(connection); } + + private async Task ReadDiagramAsync(string dbml) + { + await MigrateAsync(); + + await using var connection = new NpgsqlConnection(postgres.ConnectionString); + await connection.OpenAsync(); + return await PostgresSchemaReader.CanonicalizeAsync(connection, DbmlReader.Read(dbml)); + } } diff --git a/tests/MoissonMontreal.Tests/Docs/SchemaDriftTests.cs b/tests/MoissonMontreal.Tests/Docs/SchemaDriftTests.cs index faefaab..f6c1931 100644 --- a/tests/MoissonMontreal.Tests/Docs/SchemaDriftTests.cs +++ b/tests/MoissonMontreal.Tests/Docs/SchemaDriftTests.cs @@ -2,7 +2,7 @@ namespace MoissonMontreal.Tests.Docs; public class SchemaDriftTests { - private static readonly string FullSchema = File.ReadAllText(Path.Combine(RepositoryPaths.DatabaseDocs, "schema.dbml")); + private static readonly string FullSchema = File.ReadAllText(Path.Combine(RepositoryPaths.DatabaseDocs, "schema.dbml")).ReplaceLineEndings("\n"); public static TheoryData Drifts => new() { @@ -19,6 +19,7 @@ public class SchemaDriftTests { "duplicates allowed", "food_item_id [unique, name: 'ix_receipt_items_food_item_id'", "food_item_id [name: 'ix_receipt_items_food_item_id'", "Index ix_receipt_items_food_item_id: unique" }, { "index columns in another order", "(receipt_id, food_item_id) [pk", "(food_item_id, receipt_id) [pk", "Index pk_receipt_items: columns" }, { "missing check", " `quality IN ('A', 'B', 'C')` [name: 'ck_food_items_quality']\n", "", "Check ck_food_items_quality is missing from the diagram" }, + { "wrong check expression", "`quantity > 0` [name: 'ck_food_items_quantity_positive']", "`quantity >= 0` [name: 'ck_food_items_quantity_positive']", "Check ck_food_items_quantity_positive: expression" }, { "wrong delete rule", "receipts.(id, donor_id) [delete: cascade]", "receipts.(id, donor_id) [delete: restrict]", "Ref fk_receipt_items_receipts_receipt_id_donor_id: delete rule" }, { "donor left out of a link", "receipt_items.(food_item_id, donor_id) > food_items.(id, donor_id)", "receipt_items.food_item_id > food_items.id", "Ref fk_receipt_items_food_items_food_item_id_donor_id: columns" }, { "missing link", "Ref fk_users_roles_role_id: users.role_id > roles.id [delete: restrict]\n", "", "Ref fk_users_roles_role_id is missing from the diagram" }, @@ -102,13 +103,31 @@ public void NormalizeType_GivesTheSameSpellingForTheSameType(string type, string Assert.Equal(expected, SchemaText.NormalizeType(type)); } - [Theory] - [InlineData("`(now() AT TIME ZONE 'America/Montreal')::date`", "((now() AT TIME ZONE 'America/Montreal'::text))::date")] - [InlineData("`now()`", "now()")] - [InlineData("`gen_random_uuid()`", "gen_random_uuid()")] - [InlineData("true", "true")] - public void NormalizeDefault_MatchesThePostgresSpelling(string diagram, string postgres) + [Fact] + public void Read_GivesTheSameSchema_WithWindowsLineEndings() + { + var unix = DbmlReader.Read(FullSchema); + var windows = DbmlReader.Read(FullSchema.Replace("\n", "\r\n")); + + Assert.Empty(DatabaseSchema.Compare(windows, unix, diagramMustHaveEveryTable: true)); + Assert.Equal(unix.Tables.Keys, windows.Tables.Keys); + } + + [Fact] + public void Read_KeepsTheDefaultAndCheckExactlyAsWritten() { - Assert.Equal(SchemaText.NormalizeDefault(postgres), SchemaText.NormalizeDefault(diagram)); + var schema = DbmlReader.Read(""" + Table receipts { + receipt_date date [not null, default: `(now() AT TIME ZONE 'America/Montreal')::date`] + total_amount numeric(12,2) [not null] + + checks { + `(total_amount + 1) * 2 >= 0` [name: 'ck_total'] + } + } + """); + + Assert.Equal("(now() AT TIME ZONE 'America/Montreal')::date", schema.Tables["receipts"].Columns["receipt_date"].Default); + Assert.Equal("(total_amount + 1) * 2 >= 0", schema.Tables["receipts"].Checks["ck_total"]); } } diff --git a/tests/MoissonMontreal.Tests/Docs/SchemaText.cs b/tests/MoissonMontreal.Tests/Docs/SchemaText.cs index af63d2c..ddd9496 100644 --- a/tests/MoissonMontreal.Tests/Docs/SchemaText.cs +++ b/tests/MoissonMontreal.Tests/Docs/SchemaText.cs @@ -3,7 +3,8 @@ namespace MoissonMontreal.Tests.Docs; /// -/// Normalizes types and default values, so that the spelling used in a diagram and the one returned by PostgreSQL compare equal. +/// Normalizes type names, so that the spelling used in a diagram and the one returned by PostgreSQL compare equal. +/// Expressions (defaults and checks) are not rewritten here: PostgreSQL rewrites them itself, see PostgresSchemaReader.CanonicalizeAsync. /// internal static partial class SchemaText { @@ -24,26 +25,12 @@ public static string NormalizeType(string type) }; } - public static string? NormalizeDefault(string? value) - { - if (string.IsNullOrWhiteSpace(value)) - { - return null; - } - - var text = value.Trim().Trim('`'); - text = text.Replace("::text", "", StringComparison.OrdinalIgnoreCase) - .Replace("::character varying", "", StringComparison.OrdinalIgnoreCase); - - return WhiteSpaceOrParentheses().Replace(text, "").ToLowerInvariant(); - } + public static string? NormalizeDefault(string? value) => + string.IsNullOrWhiteSpace(value) ? null : value.Trim().Trim('`'); [GeneratedRegex(@"\s+")] private static partial Regex WhiteSpace(); [GeneratedRegex(@"^varchar(\(\d+\))?$")] private static partial Regex Varchar(); - - [GeneratedRegex(@"[\s()]+")] - private static partial Regex WhiteSpaceOrParentheses(); }