[{"data":1,"prerenderedAt":5637},["ShallowReactive",2],{"code-dubbele-klanten-opsporen":3,"code-related-dubbele-klanten-opsporen":971},{"id":4,"title":5,"body":6,"date":958,"description":959,"dialect":960,"extension":961,"language":962,"level":963,"meta":964,"navigation":965,"path":966,"questions":98,"seo":967,"stem":968,"topic":969,"__hash__":970},"practicelyCode\u002Fpracticely-code\u002Fdubbele-klanten-opsporen.md","Dubbele klanten opsporen — window-functies als ontdubbelaar",{"type":7,"value":8,"toc":956},"minimark",[9,13,21,518,578,667,753,798,904,952],[10,11,12],"p",{},"Vroeg of laat staan er dubbele klanten in je database: dezelfde persoon die twee keer een account\naanmaakte, één keer met een hoofdletter in het e-mailadres en één keer met een spatie erachter. Deze\nfunctie zoekt ze op — hij verwijdert niets, hij wijst alleen aan wat je zou verwijderen.",[10,14,15,16,20],{},"Dat onderscheid is belangrijk, want deze query wordt in de praktijk vrijwel altijd als basis voor een\n",[17,18,19],"code",{},"DELETE"," gebruikt. Twee van de vragen hieronder gaan precies over waarom dat gevaarlijk is.",[22,23,28],"pre",{"className":24,"code":25,"language":26,"meta":27,"style":27},"language-sql shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","CREATE OR REPLACE FUNCTION beheer.vind_dubbele_klanten(\n    p_max_resultaten int DEFAULT 500\n)\nRETURNS TABLE (\n    behouden_id   bigint,\n    dubbele_id    bigint,\n    email         text,\n    naam_verschil boolean,\n    orders_dubbel bigint\n)\nLANGUAGE sql\nSTABLE\nAS $$\n    WITH genormaliseerd AS (\n        SELECT k.id,\n               k.naam,\n               lower(btrim(k.email)) AS email,\n               k.aangemaakt_op\n        FROM   crm.klanten k\n        WHERE  k.email IS NOT NULL\n          AND  k.verwijderd_op IS NULL\n    ),\n    gerangschikt AS (\n        SELECT g.*,\n               ROW_NUMBER() OVER (PARTITION BY g.email\n                                  ORDER BY g.aangemaakt_op, g.id) AS rn,\n               FIRST_VALUE(g.id)   OVER (PARTITION BY g.email\n                                         ORDER BY g.aangemaakt_op, g.id) AS eerste_id,\n               FIRST_VALUE(g.naam) OVER (PARTITION BY g.email\n                                         ORDER BY g.aangemaakt_op, g.id) AS eerste_naam\n        FROM   genormaliseerd g\n    )\n    SELECT r.eerste_id,\n           r.id,\n           r.email,\n           r.naam IS DISTINCT FROM r.eerste_naam,\n           (SELECT COUNT(*) FROM verkoop.orders o WHERE o.klant_id = r.id)\n    FROM   gerangschikt r\n    WHERE  r.rn > 1\n    ORDER  BY r.email, r.rn\n    LIMIT  p_max_resultaten;\n$$;\n","sql","",[17,29,30,50,66,72,84,96,106,117,128,137,142,151,157,166,179,188,194,208,214,223,235,250,256,266,280,304,318,337,350,368,380,388,394,403,409,415,432,470,479,494,503,512],{"__ignoreMap":27},[31,32,35,39,42,46],"span",{"class":33,"line":34},"line",1,[31,36,38],{"class":37},"sbssI","CREATE OR REPLACE",[31,40,41],{"class":37}," FUNCTION",[31,43,45],{"class":44},"s2Zo4"," beheer",[31,47,49],{"class":48},"sTEyZ",".vind_dubbele_klanten(\n",[31,51,53,56,60,63],{"class":33,"line":52},2,[31,54,55],{"class":48},"    p_max_resultaten ",[31,57,59],{"class":58},"spNyl","int",[31,61,62],{"class":58}," DEFAULT",[31,64,65],{"class":37}," 500\n",[31,67,69],{"class":33,"line":68},3,[31,70,71],{"class":48},")\n",[31,73,75,78,81],{"class":33,"line":74},4,[31,76,77],{"class":37},"RETURNS",[31,79,80],{"class":37}," TABLE",[31,82,83],{"class":48}," (\n",[31,85,87,90,93],{"class":33,"line":86},5,[31,88,89],{"class":48},"    behouden_id   ",[31,91,92],{"class":58},"bigint",[31,94,95],{"class":48},",\n",[31,97,99,102,104],{"class":33,"line":98},6,[31,100,101],{"class":48},"    dubbele_id    ",[31,103,92],{"class":58},[31,105,95],{"class":48},[31,107,109,112,115],{"class":33,"line":108},7,[31,110,111],{"class":48},"    email         ",[31,113,114],{"class":58},"text",[31,116,95],{"class":48},[31,118,120,123,126],{"class":33,"line":119},8,[31,121,122],{"class":48},"    naam_verschil ",[31,124,125],{"class":58},"boolean",[31,127,95],{"class":48},[31,129,131,134],{"class":33,"line":130},9,[31,132,133],{"class":48},"    orders_dubbel ",[31,135,136],{"class":58},"bigint\n",[31,138,140],{"class":33,"line":139},10,[31,141,71],{"class":48},[31,143,145,148],{"class":33,"line":144},11,[31,146,147],{"class":37},"LANGUAGE",[31,149,150],{"class":37}," sql\n",[31,152,154],{"class":33,"line":153},12,[31,155,156],{"class":48},"STABLE\n",[31,158,160,163],{"class":33,"line":159},13,[31,161,162],{"class":37},"AS",[31,164,165],{"class":48}," $$\n",[31,167,169,172,175,177],{"class":33,"line":168},14,[31,170,171],{"class":37},"    WITH",[31,173,174],{"class":48}," genormaliseerd ",[31,176,162],{"class":37},[31,178,83],{"class":48},[31,180,182,185],{"class":33,"line":181},15,[31,183,184],{"class":37},"        SELECT",[31,186,187],{"class":48}," k.id,\n",[31,189,191],{"class":33,"line":190},16,[31,192,193],{"class":48},"               k.naam,\n",[31,195,197,200,203,205],{"class":33,"line":196},17,[31,198,199],{"class":44},"               lower",[31,201,202],{"class":48},"(btrim(k.email)) ",[31,204,162],{"class":37},[31,206,207],{"class":48}," email,\n",[31,209,211],{"class":33,"line":210},18,[31,212,213],{"class":48},"               k.aangemaakt_op\n",[31,215,217,220],{"class":33,"line":216},19,[31,218,219],{"class":37},"        FROM",[31,221,222],{"class":48},"   crm.klanten k\n",[31,224,226,229,232],{"class":33,"line":225},20,[31,227,228],{"class":37},"        WHERE",[31,230,231],{"class":48},"  k.email ",[31,233,234],{"class":37},"IS NOT NULL\n",[31,236,238,241,244,247],{"class":33,"line":237},21,[31,239,240],{"class":37},"          AND",[31,242,243],{"class":48},"  k.verwijderd_op ",[31,245,246],{"class":37},"IS",[31,248,249],{"class":37}," NULL\n",[31,251,253],{"class":33,"line":252},22,[31,254,255],{"class":48},"    ),\n",[31,257,259,262,264],{"class":33,"line":258},23,[31,260,261],{"class":48},"    gerangschikt ",[31,263,162],{"class":37},[31,265,83],{"class":48},[31,267,269,271,274,278],{"class":33,"line":268},24,[31,270,184],{"class":37},[31,272,273],{"class":48}," g.",[31,275,277],{"class":276},"sMK4o","*",[31,279,95],{"class":48},[31,281,283,286,289,292,295,298,301],{"class":33,"line":282},25,[31,284,285],{"class":44},"               ROW_NUMBER",[31,287,288],{"class":48},"() ",[31,290,291],{"class":37},"OVER",[31,293,294],{"class":48}," (",[31,296,297],{"class":37},"PARTITION",[31,299,300],{"class":37}," BY",[31,302,303],{"class":48}," g.email\n",[31,305,307,310,313,315],{"class":33,"line":306},26,[31,308,309],{"class":37},"                                  ORDER BY",[31,311,312],{"class":48}," g.aangemaakt_op, g.id) ",[31,314,162],{"class":37},[31,316,317],{"class":48}," rn,\n",[31,319,321,324,327,329,331,333,335],{"class":33,"line":320},27,[31,322,323],{"class":44},"               FIRST_VALUE",[31,325,326],{"class":48},"(g.id)   ",[31,328,291],{"class":37},[31,330,294],{"class":48},[31,332,297],{"class":37},[31,334,300],{"class":37},[31,336,303],{"class":48},[31,338,340,343,345,347],{"class":33,"line":339},28,[31,341,342],{"class":37},"                                         ORDER BY",[31,344,312],{"class":48},[31,346,162],{"class":37},[31,348,349],{"class":48}," eerste_id,\n",[31,351,353,355,358,360,362,364,366],{"class":33,"line":352},29,[31,354,323],{"class":44},[31,356,357],{"class":48},"(g.naam) ",[31,359,291],{"class":37},[31,361,294],{"class":48},[31,363,297],{"class":37},[31,365,300],{"class":37},[31,367,303],{"class":48},[31,369,371,373,375,377],{"class":33,"line":370},30,[31,372,342],{"class":37},[31,374,312],{"class":48},[31,376,162],{"class":37},[31,378,379],{"class":48}," eerste_naam\n",[31,381,383,385],{"class":33,"line":382},31,[31,384,219],{"class":37},[31,386,387],{"class":48},"   genormaliseerd g\n",[31,389,391],{"class":33,"line":390},32,[31,392,393],{"class":48},"    )\n",[31,395,397,400],{"class":33,"line":396},33,[31,398,399],{"class":37},"    SELECT",[31,401,402],{"class":48}," r.eerste_id,\n",[31,404,406],{"class":33,"line":405},34,[31,407,408],{"class":48},"           r.id,\n",[31,410,412],{"class":33,"line":411},35,[31,413,414],{"class":48},"           r.email,\n",[31,416,418,421,423,426,429],{"class":33,"line":417},36,[31,419,420],{"class":48},"           r.naam ",[31,422,246],{"class":37},[31,424,425],{"class":37}," DISTINCT",[31,427,428],{"class":37}," FROM",[31,430,431],{"class":48}," r.eerste_naam,\n",[31,433,435,438,441,444,447,449,452,455,458,461,464,467],{"class":33,"line":434},37,[31,436,437],{"class":48},"           (",[31,439,440],{"class":37},"SELECT",[31,442,443],{"class":44}," COUNT",[31,445,446],{"class":48},"(",[31,448,277],{"class":276},[31,450,451],{"class":48},") ",[31,453,454],{"class":37},"FROM",[31,456,457],{"class":48}," verkoop.orders o ",[31,459,460],{"class":37},"WHERE",[31,462,463],{"class":48}," o.klant_id ",[31,465,466],{"class":276},"=",[31,468,469],{"class":48}," r.id)\n",[31,471,473,476],{"class":33,"line":472},38,[31,474,475],{"class":37},"    FROM",[31,477,478],{"class":48},"   gerangschikt r\n",[31,480,482,485,488,491],{"class":33,"line":481},39,[31,483,484],{"class":37},"    WHERE",[31,486,487],{"class":48},"  r.rn ",[31,489,490],{"class":276},">",[31,492,493],{"class":37}," 1\n",[31,495,497,500],{"class":33,"line":496},40,[31,498,499],{"class":37},"    ORDER  BY",[31,501,502],{"class":48}," r.email, r.rn\n",[31,504,506,509],{"class":33,"line":505},41,[31,507,508],{"class":37},"    LIMIT",[31,510,511],{"class":48},"  p_max_resultaten;\n",[31,513,515],{"class":33,"line":514},42,[31,516,517],{"class":48},"$$;\n",[519,520,522,536],"code-question",{"level":521},"Opwarmer",[523,524,525],"template",{"v-slot:question":27},[10,526,527,528,531,532,535],{},"Waarom staat er ",[17,529,530],{},"lower(btrim(k.email))"," in plaats van gewoon ",[17,533,534],{},"k.email","?",[523,537,538,561,571],{"v-slot:answer":27},[10,539,540,541,544,545,548,549,552,553,556,557,560],{},"Omdat de duplicaten anders niet gevonden wórden. ",[17,542,543],{},"Jan@Voorbeeld.nl",", ",[17,546,547],{},"jan@voorbeeld.nl"," en\n",[17,550,551],{},"jan@voorbeeld.nl "," (met spatie) zijn voor ",[17,554,555],{},"PARTITION BY"," drie verschillende waarden en belanden in\ndrie aparte partities. Elk krijgt ",[17,558,559],{},"rn = 1"," en de query meldt: geen duplicaten.",[10,562,563,566,567,570],{},[17,564,565],{},"btrim"," haalt spaties aan begin en eind weg, ",[17,568,569],{},"lower"," maakt het geheel kleine letters. Pas daarna\nvallen de drie varianten in dezelfde partitie.",[10,572,573,574,577],{},"Een detail dat vaak vergeten wordt: de e-mailstandaard zegt dat alleen het domein\nhoofdletterongevoelig is — het deel vóór de ",[17,575,576],{},"@"," mag in theorie wél hoofdlettergevoelig zijn. In de\npraktijk behandelt vrijwel elke mailprovider het geheel als ongevoelig, dus dit is de juiste keuze.\nMaar het is een aanname, geen wet.",[519,579,581,596],{"level":580},"Semantiek",[523,582,583],{"v-slot:question":27},[10,584,585,586,588,589,592,593,595],{},"Er staan drie window-functies met exact dezelfde ",[17,587,555],{}," en ",[17,590,591],{},"ORDER BY",". Wat doet elk van de\ndrie, en waarom moet die ",[17,594,591],{}," identiek zijn?",[523,597,598,601,634],{"v-slot:answer":27},[10,599,600],{},"Ze verdelen de rijen op dezelfde manier maar halen er verschillende dingen uit:",[602,603,604,618,628],"ul",{},[605,606,607,610,611,613,614,617],"li",{},[17,608,609],{},"ROW_NUMBER()"," nummert binnen de partitie: 1, 2, 3… De rij met ",[17,612,559],{}," is de oudste, en die houd\nje. Alles met ",[17,615,616],{},"rn > 1"," is een duplicaat.",[605,619,620,623,624,627],{},[17,621,622],{},"FIRST_VALUE(g.id)"," haalt het ",[17,625,626],{},"id"," van diezelfde eerste rij op en plakt het naast élke rij in de\npartitie. Zo weet een duplicaatrij naar welke klant hij samengevoegd moet worden.",[605,629,630,633],{},[17,631,632],{},"FIRST_VALUE(g.naam)"," doet hetzelfde met de naam, puur om te kunnen vergelijken.",[10,635,636,637,639,640,642,643,646,647,642,650,652,653,655,656,659,660,663,664,666],{},"Die ",[17,638,591],{}," moet identiek zijn omdat \"de eerste rij\" anders per functie iets anders betekent. Zou\n",[17,641,609],{}," op ",[17,644,645],{},"aangemaakt_op"," sorteren en ",[17,648,649],{},"FIRST_VALUE",[17,651,626],{},", dan kan de rij met ",[17,654,559],{}," een\nandere zijn dan de rij waar ",[17,657,658],{},"eerste_id"," uit komt. Je krijgt dan een ",[17,661,662],{},"behouden_id"," dat zelf ook als\nduplicaat wordt aangemerkt — en als je daarop een ",[17,665,19],{}," baseert, verwijder je beide.",[519,668,670,682],{"level":669},"NULL-gedrag",[523,671,672],{"v-slot:question":27},[10,673,674,675,678,679,535],{},"Waarom ",[17,676,677],{},"r.naam IS DISTINCT FROM r.eerste_naam"," en niet gewoon ",[17,680,681],{},"r.naam \u003C> r.eerste_naam",[523,683,684,709,726],{"v-slot:answer":27},[10,685,686,687,690,691,694,695,698,699,701,702,704,705,708],{},"Omdat ",[17,688,689],{},"\u003C>"," bij ",[17,692,693],{},"NULL"," geen ",[17,696,697],{},"false"," teruggeeft maar ",[17,700,693],{},", en ",[17,703,693],{}," is niet ",[17,706,707],{},"true",".",[10,710,711,712,715,716,718,719,722,723,725],{},"Heeft de ene klant de naam \"Jan de Vries\" en de andere geen naam ingevuld, dan levert\n",[17,713,714],{},"'Jan de Vries' \u003C> NULL"," de waarde ",[17,717,693],{}," op. De kolom ",[17,720,721],{},"naam_verschil"," toont dan een leeg vakje in\nplaats van ",[17,724,707],{},", terwijl er wel degelijk een verschil is — precies het geval waar je op wilt letten\nbij het samenvoegen.",[10,727,728,731,732,734,735,737,738,740,741,743,744,746,747,749,750,752],{},[17,729,730],{},"IS DISTINCT FROM"," is de NULL-veilige variant: die geeft altijd ",[17,733,707],{}," of ",[17,736,697],{},". ",[17,739,693],{}," versus een\nwaarde is ",[17,742,707],{}," (ze verschillen), ",[17,745,693],{}," versus ",[17,748,693],{}," is ",[17,751,697],{}," (ze zijn gelijk). Dat is wat een\nmens bedoelt met \"verschillen deze twee\".",[519,754,756,769],{"level":755},"Randgeval",[523,757,758],{"v-slot:question":27},[10,759,760,761,764,765,768],{},"De sortering is ",[17,762,763],{},"ORDER BY g.aangemaakt_op, g.id",". Wat gebeurt er als je die ",[17,766,767],{},"g.id"," weglaat?",[523,770,771,774,783,792],{"v-slot:answer":27},[10,772,773],{},"Dan wordt de uitkomst niet-deterministisch, en dat is bij een opschoonquery gevaarlijker dan het klinkt.",[10,775,776,777,779,780,782],{},"Twee accounts die in dezelfde seconde zijn aangemaakt — bij een import of een dubbelklik op\n\"registreren\" gebeurt dat echt — hebben dezelfde ",[17,778,645],{},". Zonder tiebreaker mag PostgreSQL zelf\nkiezen welke van de twee ",[17,781,559],{}," krijgt, en die keuze kan per uitvoering verschillen, afhankelijk van\nhet queryplan en de fysieke volgorde op schijf.",[10,784,785,786,788,789,791],{},"Concreet: je draait de query, je bekijkt de uitkomst, je draait hem opnieuw als ",[17,787,19],{}," — en de\ntweede keer wijst hij de ándere rij aan als de bewaarder. Je verwijdert dan de klant die je wilde\nhouden, en houdt degene die je wilde verwijderen. ",[17,790,767],{}," is uniek, dus met die tiebreaker is de\nvolgorde altijd dezelfde.",[10,793,794,795,797],{},"Vuistregel: elke ",[17,796,591],{}," in een window-functie waarvan de uitkomst tot een wijziging leidt, moet\neindigen op een unieke kolom.",[519,799,801,812],{"level":800},"Performance",[523,802,803],{"v-slot:question":27},[10,804,805,808,809,811],{},[17,806,807],{},"(SELECT COUNT(*) FROM verkoop.orders o WHERE o.klant_id = r.id)"," staat in de ",[17,810,440],{},"-lijst. Wat is\ndaar het probleem mee?",[523,813,814,817,824,827,890,901],{"v-slot:answer":27},[10,815,816],{},"Het is een gecorreleerde subquery: hij wordt per resultaatrij opnieuw uitgevoerd. Bij 500 duplicaten\nzijn dat 500 losse tellingen over de orders-tabel.",[10,818,819,820,823],{},"Met een index op ",[17,821,822],{},"verkoop.orders(klant_id)"," valt dat mee — 500 index-scans is niet fataal. Zonder die\nindex is het 500 keer een sequential scan over de volledige orders-tabel, en dan praat je over\nminuten in plaats van milliseconden.",[10,825,826],{},"De schaalbare vorm is één keer groeperen en dan koppelen:",[22,828,830],{"className":24,"code":829,"language":26,"meta":27,"style":27},"LEFT JOIN (\n    SELECT klant_id, COUNT(*) AS aantal\n    FROM   verkoop.orders\n    GROUP  BY klant_id\n) o ON o.klant_id = r.id\n",[17,831,832,839,860,867,875],{"__ignoreMap":27},[31,833,834,837],{"class":33,"line":34},[31,835,836],{"class":37},"LEFT JOIN",[31,838,83],{"class":48},[31,840,841,843,846,849,851,853,855,857],{"class":33,"line":52},[31,842,399],{"class":37},[31,844,845],{"class":48}," klant_id, ",[31,847,848],{"class":44},"COUNT",[31,850,446],{"class":48},[31,852,277],{"class":276},[31,854,451],{"class":48},[31,856,162],{"class":37},[31,858,859],{"class":48}," aantal\n",[31,861,862,864],{"class":33,"line":68},[31,863,475],{"class":37},[31,865,866],{"class":48},"   verkoop.orders\n",[31,868,869,872],{"class":33,"line":74},[31,870,871],{"class":37},"    GROUP  BY",[31,873,874],{"class":48}," klant_id\n",[31,876,877,880,883,885,887],{"class":33,"line":86},[31,878,879],{"class":48},") o ",[31,881,882],{"class":37},"ON",[31,884,463],{"class":48},[31,886,466],{"class":276},[31,888,889],{"class":48}," r.id\n",[10,891,892,893,896,897,900],{},"Of, als je alleen de duplicaten nodig hebt, een ",[17,894,895],{},"LEFT JOIN LATERAL"," — die kan de planner combineren met\nde ",[17,898,899],{},"LIMIT",", zodat er niet meer geteld wordt dan er rijen teruggegeven worden.",[10,902,903],{},"Overigens is dit getal het belangrijkste veld van de hele query: een duplicaat met nul orders kun je\nweggooien, een duplicaat met orders moet je samenvoegen. Dat onderscheid bepaalt het opschoonwerk.",[519,905,907,915],{"level":906},"Valkuil",[523,908,909],{"v-slot:question":27},[10,910,911,912,914],{},"Deze functie verwijdert niets. Toch is hij gevaarlijk als je hem gebruikt om een ",[17,913,19],{}," op te\nbaseren. Waarom — twee redenen.",[523,916,917,931,944],{"v-slot:answer":27},[10,918,919,926,927,930],{},[920,921,922,923,925],"strong",{},"De ",[17,924,899],{}," maakt het beeld onvolledig."," De functie geeft maximaal 500 rijen terug, gesorteerd op\ne-mailadres. Draai je hem, kijk je \"dat zijn er niet zoveel\", en verwijder je die 500, dan lijkt het\nklaar — terwijl er misschien 5000 duplicaten waren en je er nu nog 4500 hebt. Erger: de sortering op\n",[17,928,929],{},"email"," betekent dat je systematisch alleen het begin van het alfabet opruimt. De uitkomst voelt als\neen volledig overzicht en is het niet.",[10,932,933,936,937,940,941,943],{},[920,934,935],{},"Klanten zonder e-mailadres bestaan niet voor deze query."," ",[17,938,939],{},"WHERE k.email IS NOT NULL"," sluit ze uit.\nEen klant die twee keer voorkomt zonder e-mailadres wordt nooit gevonden. Dat is een verdedigbare\nkeuze — op ",[17,942,693],{}," kun je niet ontdubbelen — maar het betekent dat \"geen resultaten\" niet hetzelfde is\nals \"geen duplicaten\".",[10,945,946,947,951],{},"Wat je hier eigenlijk mist is een aparte telling: hoeveel duplicaten zijn er ",[948,949,950],"em",{},"in totaal",", los van hoeveel\nje er nu toont. Zonder dat getal weet je nooit of je klaar bent.",[953,954,955],"style",{},"html pre.shiki code .sbssI, html code.shiki .sbssI{--shiki-light:#F76D47;--shiki-default:#F78C6C;--shiki-dark:#F78C6C}html pre.shiki code .s2Zo4, html code.shiki .s2Zo4{--shiki-light:#6182B8;--shiki-default:#82AAFF;--shiki-dark:#82AAFF}html pre.shiki code .sTEyZ, html code.shiki .sTEyZ{--shiki-light:#90A4AE;--shiki-default:#EEFFFF;--shiki-dark:#BABED8}html pre.shiki code .spNyl, html code.shiki .spNyl{--shiki-light:#9C3EDA;--shiki-default:#C792EA;--shiki-dark:#C792EA}html pre.shiki code .sMK4o, html code.shiki .sMK4o{--shiki-light:#39ADB5;--shiki-default:#89DDFF;--shiki-dark:#89DDFF}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":27,"searchDepth":52,"depth":52,"links":957},[],"2026-09-03","De klassieke opschoonquery — welke rij houd je, welke is de dubbele? Met ROW_NUMBER, FIRST_VALUE en een tiebreaker die belangrijker is dan hij lijkt.","PostgreSQL","md","SQL","Instap",{},true,"\u002Fpracticely-code\u002Fdubbele-klanten-opsporen",{"title":5,"description":959},"practicely-code\u002Fdubbele-klanten-opsporen","Datakwaliteit","0c-J6NEggH2_E_nIJ_XlV8dQVUOt31VQmEulAdM2iJ0",[972,2108,2927,4436],{"id":973,"title":974,"body":975,"date":2099,"description":2100,"dialect":960,"extension":961,"language":962,"level":2101,"meta":2102,"navigation":965,"path":2103,"questions":108,"seo":2104,"stem":2105,"topic":2106,"__hash__":2107},"practicelyCode\u002Fpracticely-code\u002Fmaandfacturatie-abonnementen.md","Maandfacturatie — een PL\u002FpgSQL-procedure onder de loep",{"type":7,"value":976,"toc":2097},[977,980,987,1712,1749,1806,1850,1904,1966,2028,2094],[10,978,979],{},"Deze procedure draait elke nacht en maakt facturen aan voor alle abonnementen waarvan de\nfactuurdatum verstreken is. Hij is geschreven om naast zichzelf te kunnen draaien: meerdere workers\ntegelijk, zonder dat iemand dubbel gefactureerd wordt.",[10,981,982,983,986],{},"Dat lukt grotendeels. Maar er zitten twee constructies in die er correct uitzien en dat niet zijn —\néén ervan merk je pas als een klant in februari belt. Kijk goed naar de datumberekening en naar wat\nde ",[17,984,985],{},"EXCEPTION","-tak allemaal opvangt.",[22,988,990],{"className":24,"code":989,"language":26,"meta":27,"style":27},"CREATE OR REPLACE PROCEDURE facturatie.verwerk_maandfacturen(\n    p_peildatum date DEFAULT current_date\n)\nLANGUAGE plpgsql\nAS $$\nDECLARE\n    r_abo          record;\n    v_factuur_id   bigint;\n    v_bedrag       numeric(12,2);\n    v_verwerkt     int := 0;\n    v_overgeslagen int := 0;\nBEGIN\n    FOR r_abo IN\n        SELECT a.id, a.klant_id, a.prijs_per_maand, a.korting_pct, a.valuta\n        FROM   facturatie.abonnementen a\n        WHERE  a.status = 'actief'\n          AND  a.volgende_factuurdatum \u003C= p_peildatum\n        ORDER  BY a.id\n        FOR UPDATE SKIP LOCKED\n    LOOP\n        BEGIN\n            v_bedrag := ROUND(\n                r_abo.prijs_per_maand * (1 - COALESCE(r_abo.korting_pct, 0) \u002F 100.0), 2\n            );\n\n            IF v_bedrag \u003C= 0 THEN\n                v_overgeslagen := v_overgeslagen + 1;\n                CONTINUE;\n            END IF;\n\n            INSERT INTO facturatie.facturen\n                   (abonnement_id, klant_id, bedrag, valuta, periode, status)\n            VALUES (r_abo.id, r_abo.klant_id, v_bedrag, r_abo.valuta,\n                    date_trunc('month', p_peildatum)::date, 'open')\n            ON CONFLICT (abonnement_id, periode) DO NOTHING\n            RETURNING id INTO v_factuur_id;\n\n            IF v_factuur_id IS NULL THEN\n                v_overgeslagen := v_overgeslagen + 1;\n                CONTINUE;\n            END IF;\n\n            UPDATE facturatie.abonnementen\n            SET    volgende_factuurdatum  = volgende_factuurdatum + interval '1 month',\n                   laatst_gefactureerd_op = p_peildatum\n            WHERE  id = r_abo.id;\n\n            INSERT INTO facturatie.factuurregels\n                   (factuur_id, omschrijving, aantal, stukprijs)\n            VALUES (v_factuur_id,\n                    'Abonnement ' || to_char(p_peildatum, 'YYYY-MM'),\n                    1, v_bedrag);\n\n            v_verwerkt := v_verwerkt + 1;\n\n        EXCEPTION WHEN OTHERS THEN\n            v_overgeslagen := v_overgeslagen + 1;\n            INSERT INTO facturatie.verwerkingsfouten\n                   (abonnement_id, foutmelding, opgetreden_op)\n            VALUES (r_abo.id, SQLERRM, clock_timestamp());\n        END;\n    END LOOP;\n\n    RAISE NOTICE 'Facturatie klaar: % verwerkt, % overgeslagen', v_verwerkt, v_overgeslagen;\nEND;\n$$;\n",[17,991,992,1009,1022,1026,1033,1039,1044,1049,1059,1081,1098,1113,1118,1129,1136,1143,1162,1175,1183,1197,1202,1207,1220,1262,1267,1272,1287,1305,1312,1322,1326,1334,1344,1352,1381,1389,1400,1404,1418,1432,1438,1446,1450,1459,1487,1497,1511,1516,1524,1530,1538,1565,1574,1579,1596,1601,1616,1632,1640,1646,1659,1667,1678,1683,1699,1707],{"__ignoreMap":27},[31,993,994,997,1000,1003,1006],{"class":33,"line":34},[31,995,996],{"class":37},"CREATE",[31,998,999],{"class":37}," OR",[31,1001,1002],{"class":37}," REPLACE",[31,1004,1005],{"class":37}," PROCEDURE",[31,1007,1008],{"class":48}," facturatie.verwerk_maandfacturen(\n",[31,1010,1011,1014,1017,1019],{"class":33,"line":52},[31,1012,1013],{"class":48},"    p_peildatum ",[31,1015,1016],{"class":58},"date",[31,1018,62],{"class":58},[31,1020,1021],{"class":48}," current_date\n",[31,1023,1024],{"class":33,"line":68},[31,1025,71],{"class":48},[31,1027,1028,1030],{"class":33,"line":74},[31,1029,147],{"class":37},[31,1031,1032],{"class":48}," plpgsql\n",[31,1034,1035,1037],{"class":33,"line":86},[31,1036,162],{"class":37},[31,1038,165],{"class":48},[31,1040,1041],{"class":33,"line":98},[31,1042,1043],{"class":37},"DECLARE\n",[31,1045,1046],{"class":33,"line":108},[31,1047,1048],{"class":48},"    r_abo          record;\n",[31,1050,1051,1054,1056],{"class":33,"line":119},[31,1052,1053],{"class":48},"    v_factuur_id   ",[31,1055,92],{"class":58},[31,1057,1058],{"class":48},";\n",[31,1060,1061,1064,1067,1069,1072,1075,1078],{"class":33,"line":130},[31,1062,1063],{"class":48},"    v_bedrag       ",[31,1065,1066],{"class":58},"numeric",[31,1068,446],{"class":48},[31,1070,1071],{"class":37},"12",[31,1073,1074],{"class":48},",",[31,1076,1077],{"class":37},"2",[31,1079,1080],{"class":48},");\n",[31,1082,1083,1086,1088,1091,1093,1096],{"class":33,"line":139},[31,1084,1085],{"class":48},"    v_verwerkt     ",[31,1087,59],{"class":58},[31,1089,1090],{"class":48}," :",[31,1092,466],{"class":276},[31,1094,1095],{"class":37}," 0",[31,1097,1058],{"class":48},[31,1099,1100,1103,1105,1107,1109,1111],{"class":33,"line":144},[31,1101,1102],{"class":48},"    v_overgeslagen ",[31,1104,59],{"class":58},[31,1106,1090],{"class":48},[31,1108,466],{"class":276},[31,1110,1095],{"class":37},[31,1112,1058],{"class":48},[31,1114,1115],{"class":33,"line":153},[31,1116,1117],{"class":37},"BEGIN\n",[31,1119,1120,1123,1126],{"class":33,"line":159},[31,1121,1122],{"class":37},"    FOR",[31,1124,1125],{"class":48}," r_abo ",[31,1127,1128],{"class":37},"IN\n",[31,1130,1131,1133],{"class":33,"line":168},[31,1132,184],{"class":37},[31,1134,1135],{"class":48}," a.id, a.klant_id, a.prijs_per_maand, a.korting_pct, a.valuta\n",[31,1137,1138,1140],{"class":33,"line":181},[31,1139,219],{"class":37},[31,1141,1142],{"class":48},"   facturatie.abonnementen a\n",[31,1144,1145,1147,1150,1152,1155,1159],{"class":33,"line":190},[31,1146,228],{"class":37},[31,1148,1149],{"class":48},"  a.status ",[31,1151,466],{"class":276},[31,1153,1154],{"class":276}," '",[31,1156,1158],{"class":1157},"sfazB","actief",[31,1160,1161],{"class":276},"'\n",[31,1163,1164,1166,1169,1172],{"class":33,"line":196},[31,1165,240],{"class":37},[31,1167,1168],{"class":48},"  a.volgende_factuurdatum ",[31,1170,1171],{"class":276},"\u003C=",[31,1173,1174],{"class":48}," p_peildatum\n",[31,1176,1177,1180],{"class":33,"line":210},[31,1178,1179],{"class":37},"        ORDER  BY",[31,1181,1182],{"class":48}," a.id\n",[31,1184,1185,1188,1191,1194],{"class":33,"line":216},[31,1186,1187],{"class":37},"        FOR",[31,1189,1190],{"class":37}," UPDATE",[31,1192,1193],{"class":37}," SKIP",[31,1195,1196],{"class":48}," LOCKED\n",[31,1198,1199],{"class":33,"line":225},[31,1200,1201],{"class":37},"    LOOP\n",[31,1203,1204],{"class":33,"line":237},[31,1205,1206],{"class":37},"        BEGIN\n",[31,1208,1209,1212,1214,1217],{"class":33,"line":252},[31,1210,1211],{"class":48},"            v_bedrag :",[31,1213,466],{"class":276},[31,1215,1216],{"class":44}," ROUND",[31,1218,1219],{"class":48},"(\n",[31,1221,1222,1225,1227,1229,1232,1235,1238,1241,1244,1246,1249,1252,1254,1256,1259],{"class":33,"line":258},[31,1223,1224],{"class":48},"                r_abo.prijs_per_maand ",[31,1226,277],{"class":276},[31,1228,294],{"class":48},[31,1230,1231],{"class":37},"1",[31,1233,1234],{"class":276}," -",[31,1236,1237],{"class":44}," COALESCE",[31,1239,1240],{"class":48},"(r_abo.korting_pct, ",[31,1242,1243],{"class":37},"0",[31,1245,451],{"class":48},[31,1247,1248],{"class":276},"\u002F",[31,1250,1251],{"class":37}," 100",[31,1253,708],{"class":48},[31,1255,1243],{"class":37},[31,1257,1258],{"class":48},"), ",[31,1260,1261],{"class":37},"2\n",[31,1263,1264],{"class":33,"line":268},[31,1265,1266],{"class":48},"            );\n",[31,1268,1269],{"class":33,"line":282},[31,1270,1271],{"emptyLinePlaceholder":965},"\n",[31,1273,1274,1277,1280,1282,1284],{"class":33,"line":306},[31,1275,1276],{"class":37},"            IF",[31,1278,1279],{"class":48}," v_bedrag ",[31,1281,1171],{"class":276},[31,1283,1095],{"class":37},[31,1285,1286],{"class":37}," THEN\n",[31,1288,1289,1292,1294,1297,1300,1303],{"class":33,"line":320},[31,1290,1291],{"class":48},"                v_overgeslagen :",[31,1293,466],{"class":276},[31,1295,1296],{"class":48}," v_overgeslagen ",[31,1298,1299],{"class":276},"+",[31,1301,1302],{"class":37}," 1",[31,1304,1058],{"class":48},[31,1306,1307,1310],{"class":33,"line":339},[31,1308,1309],{"class":37},"                CONTINUE",[31,1311,1058],{"class":48},[31,1313,1314,1317,1320],{"class":33,"line":352},[31,1315,1316],{"class":37},"            END",[31,1318,1319],{"class":37}," IF",[31,1321,1058],{"class":48},[31,1323,1324],{"class":33,"line":370},[31,1325,1271],{"emptyLinePlaceholder":965},[31,1327,1328,1331],{"class":33,"line":382},[31,1329,1330],{"class":37},"            INSERT INTO",[31,1332,1333],{"class":48}," facturatie.facturen\n",[31,1335,1336,1339,1342],{"class":33,"line":390},[31,1337,1338],{"class":48},"                   (abonnement_id, klant_id, bedrag, valuta, periode, ",[31,1340,1341],{"class":37},"status",[31,1343,71],{"class":48},[31,1345,1346,1349],{"class":33,"line":396},[31,1347,1348],{"class":37},"            VALUES",[31,1350,1351],{"class":48}," (r_abo.id, r_abo.klant_id, v_bedrag, r_abo.valuta,\n",[31,1353,1354,1357,1360,1363,1365,1368,1370,1372,1374,1377,1379],{"class":33,"line":405},[31,1355,1356],{"class":48},"                    date_trunc(",[31,1358,1359],{"class":276},"'",[31,1361,1362],{"class":1157},"month",[31,1364,1359],{"class":276},[31,1366,1367],{"class":48},", p_peildatum)::",[31,1369,1016],{"class":58},[31,1371,544],{"class":48},[31,1373,1359],{"class":276},[31,1375,1376],{"class":1157},"open",[31,1378,1359],{"class":276},[31,1380,71],{"class":48},[31,1382,1383,1386],{"class":33,"line":411},[31,1384,1385],{"class":37},"            ON",[31,1387,1388],{"class":48}," CONFLICT (abonnement_id, periode) DO NOTHING\n",[31,1390,1391,1394,1397],{"class":33,"line":417},[31,1392,1393],{"class":48},"            RETURNING id ",[31,1395,1396],{"class":37},"INTO",[31,1398,1399],{"class":48}," v_factuur_id;\n",[31,1401,1402],{"class":33,"line":434},[31,1403,1271],{"emptyLinePlaceholder":965},[31,1405,1406,1408,1411,1413,1416],{"class":33,"line":472},[31,1407,1276],{"class":37},[31,1409,1410],{"class":48}," v_factuur_id ",[31,1412,246],{"class":37},[31,1414,1415],{"class":37}," NULL",[31,1417,1286],{"class":37},[31,1419,1420,1422,1424,1426,1428,1430],{"class":33,"line":481},[31,1421,1291],{"class":48},[31,1423,466],{"class":276},[31,1425,1296],{"class":48},[31,1427,1299],{"class":276},[31,1429,1302],{"class":37},[31,1431,1058],{"class":48},[31,1433,1434,1436],{"class":33,"line":496},[31,1435,1309],{"class":37},[31,1437,1058],{"class":48},[31,1439,1440,1442,1444],{"class":33,"line":505},[31,1441,1316],{"class":37},[31,1443,1319],{"class":37},[31,1445,1058],{"class":48},[31,1447,1448],{"class":33,"line":514},[31,1449,1271],{"emptyLinePlaceholder":965},[31,1451,1453,1456],{"class":33,"line":1452},43,[31,1454,1455],{"class":37},"            UPDATE",[31,1457,1458],{"class":48}," facturatie.abonnementen\n",[31,1460,1462,1465,1468,1470,1473,1475,1478,1480,1483,1485],{"class":33,"line":1461},44,[31,1463,1464],{"class":37},"            SET",[31,1466,1467],{"class":48},"    volgende_factuurdatum  ",[31,1469,466],{"class":276},[31,1471,1472],{"class":48}," volgende_factuurdatum ",[31,1474,1299],{"class":276},[31,1476,1477],{"class":48}," interval ",[31,1479,1359],{"class":276},[31,1481,1482],{"class":1157},"1 month",[31,1484,1359],{"class":276},[31,1486,95],{"class":48},[31,1488,1490,1493,1495],{"class":33,"line":1489},45,[31,1491,1492],{"class":48},"                   laatst_gefactureerd_op ",[31,1494,466],{"class":276},[31,1496,1174],{"class":48},[31,1498,1500,1503,1506,1508],{"class":33,"line":1499},46,[31,1501,1502],{"class":37},"            WHERE",[31,1504,1505],{"class":48},"  id ",[31,1507,466],{"class":276},[31,1509,1510],{"class":48}," r_abo.id;\n",[31,1512,1514],{"class":33,"line":1513},47,[31,1515,1271],{"emptyLinePlaceholder":965},[31,1517,1519,1521],{"class":33,"line":1518},48,[31,1520,1330],{"class":37},[31,1522,1523],{"class":48}," facturatie.factuurregels\n",[31,1525,1527],{"class":33,"line":1526},49,[31,1528,1529],{"class":48},"                   (factuur_id, omschrijving, aantal, stukprijs)\n",[31,1531,1533,1535],{"class":33,"line":1532},50,[31,1534,1348],{"class":37},[31,1536,1537],{"class":48}," (v_factuur_id,\n",[31,1539,1541,1544,1547,1549,1552,1555,1557,1560,1562],{"class":33,"line":1540},51,[31,1542,1543],{"class":276},"                    '",[31,1545,1546],{"class":1157},"Abonnement ",[31,1548,1359],{"class":276},[31,1550,1551],{"class":276}," ||",[31,1553,1554],{"class":48}," to_char(p_peildatum, ",[31,1556,1359],{"class":276},[31,1558,1559],{"class":1157},"YYYY-MM",[31,1561,1359],{"class":276},[31,1563,1564],{"class":48},"),\n",[31,1566,1568,1571],{"class":33,"line":1567},52,[31,1569,1570],{"class":37},"                    1",[31,1572,1573],{"class":48},", v_bedrag);\n",[31,1575,1577],{"class":33,"line":1576},53,[31,1578,1271],{"emptyLinePlaceholder":965},[31,1580,1582,1585,1587,1590,1592,1594],{"class":33,"line":1581},54,[31,1583,1584],{"class":48},"            v_verwerkt :",[31,1586,466],{"class":276},[31,1588,1589],{"class":48}," v_verwerkt ",[31,1591,1299],{"class":276},[31,1593,1302],{"class":37},[31,1595,1058],{"class":48},[31,1597,1599],{"class":33,"line":1598},55,[31,1600,1271],{"emptyLinePlaceholder":965},[31,1602,1604,1607,1610,1613],{"class":33,"line":1603},56,[31,1605,1606],{"class":48},"        EXCEPTION ",[31,1608,1609],{"class":37},"WHEN",[31,1611,1612],{"class":48}," OTHERS ",[31,1614,1615],{"class":37},"THEN\n",[31,1617,1619,1622,1624,1626,1628,1630],{"class":33,"line":1618},57,[31,1620,1621],{"class":48},"            v_overgeslagen :",[31,1623,466],{"class":276},[31,1625,1296],{"class":48},[31,1627,1299],{"class":276},[31,1629,1302],{"class":37},[31,1631,1058],{"class":48},[31,1633,1635,1637],{"class":33,"line":1634},58,[31,1636,1330],{"class":37},[31,1638,1639],{"class":48}," facturatie.verwerkingsfouten\n",[31,1641,1643],{"class":33,"line":1642},59,[31,1644,1645],{"class":48},"                   (abonnement_id, foutmelding, opgetreden_op)\n",[31,1647,1649,1651,1654,1657],{"class":33,"line":1648},60,[31,1650,1348],{"class":37},[31,1652,1653],{"class":48}," (r_abo.id, SQLERRM, clock_timestamp",[31,1655,1656],{"class":276},"()",[31,1658,1080],{"class":48},[31,1660,1662,1665],{"class":33,"line":1661},61,[31,1663,1664],{"class":37},"        END",[31,1666,1058],{"class":48},[31,1668,1670,1673,1676],{"class":33,"line":1669},62,[31,1671,1672],{"class":37},"    END",[31,1674,1675],{"class":37}," LOOP",[31,1677,1058],{"class":48},[31,1679,1681],{"class":33,"line":1680},63,[31,1682,1271],{"emptyLinePlaceholder":965},[31,1684,1686,1689,1691,1694,1696],{"class":33,"line":1685},64,[31,1687,1688],{"class":48},"    RAISE NOTICE ",[31,1690,1359],{"class":276},[31,1692,1693],{"class":1157},"Facturatie klaar: % verwerkt, % overgeslagen",[31,1695,1359],{"class":276},[31,1697,1698],{"class":48},", v_verwerkt, v_overgeslagen;\n",[31,1700,1702,1705],{"class":33,"line":1701},65,[31,1703,1704],{"class":37},"END",[31,1706,1058],{"class":48},[31,1708,1710],{"class":33,"line":1709},66,[31,1711,517],{"class":48},[519,1713,1714,1723],{"level":521},[523,1715,1716],{"v-slot:question":27},[10,1717,1718,1719,1722],{},"Wat doet ",[17,1720,1721],{},"FOR UPDATE SKIP LOCKED",", en waarom staat het hier?",[523,1724,1725,1734,1740],{"v-slot:answer":27},[10,1726,1727,1730,1731,1733],{},[17,1728,1729],{},"FOR UPDATE"," zet een rijvergrendeling op elke rij die de ",[17,1732,440],{}," oplevert, en houdt die vast tot het\neinde van de transactie. Een andere transactie die dezelfde rij wil vergrendelen, moet wachten.",[10,1735,1736,1739],{},[17,1737,1738],{},"SKIP LOCKED"," verandert dat wachten in overslaan: rijen die al door iemand anders vergrendeld zijn,\nkomen simpelweg niet in het resultaat voor.",[10,1741,1742,1743,1745,1746,1748],{},"Samen maken ze van deze tabel een werkwachtrij. Start je vijf workers tegelijk, dan pakt elke worker\nandere abonnementen op, niemand staat te wachten, en niemand factureert hetzelfde abonnement dubbel.\nZonder ",[17,1744,1738],{}," zouden vier workers staan blokkeren op de eerste; zonder ",[17,1747,1729],{}," zouden ze\nallemaal dezelfde rijen oppakken.",[519,1750,1752,1764],{"level":1751},"Constructie",[523,1753,1754],{"v-slot:question":27},[10,1755,1756,1759,1760,1763],{},[17,1757,1758],{},"v_factuur_id"," wordt buiten de loop gedeclareerd en nooit expliciet leeggemaakt. Toch is de check\n",[17,1761,1762],{},"IF v_factuur_id IS NULL"," betrouwbaar. Waarom?",[523,1765,1766,1782,1799],{"v-slot:answer":27},[10,1767,686,1768,1770,1771,1774,1775,1778,1779,1781],{},[17,1769,1396],{}," altijd schrijft. Levert een ",[17,1772,1773],{},"INSERT ... RETURNING"," geen rij op — wat hier gebeurt zodra\n",[17,1776,1777],{},"ON CONFLICT DO NOTHING"," toeslaat — dan zet PL\u002FpgSQL de doelvariabele op ",[17,1780,693],{},". De oude waarde uit de\nvorige iteratie blijft dus niet hangen.",[10,1783,1784,1785,1787,1788,1787,1791,1794,1795,1798],{},"Dat is een bewuste taalregel van PL\u002FpgSQL, en precies waar deze constructie op leunt: de combinatie\n",[17,1786,1777],{}," + ",[17,1789,1790],{},"RETURNING ... INTO",[17,1792,1793],{},"IS NULL","-check is een idempotente insert. Draai je\nde procedure twee keer op dezelfde dag, dan maakt de tweede run geen dubbele facturen aan, want de\nunieke sleutel ",[17,1796,1797],{},"(abonnement_id, periode)"," blokkeert dat en de rest van de iteratie wordt overgeslagen.",[10,1800,1801,1802,1805],{},"Wel iets om te weten: leunen op dit gedrag is correct maar niet zelfverklarend. Een expliciete\n",[17,1803,1804],{},"v_factuur_id := NULL;"," bovenaan de iteratie kost niets en maakt de bedoeling voor de volgende lezer\nmeteen duidelijk.",[519,1807,1809,1822],{"level":1808},"Transacties",[523,1810,1811],{"v-slot:question":27},[10,1812,1813,1814,1817,1818,1821],{},"Wat doet het ",[17,1815,1816],{},"BEGIN ... EXCEPTION ... END","-blok binnen de loop met de transactie? En waarom overleeft\nde rij in ",[17,1819,1820],{},"verwerkingsfouten"," de fout wél?",[523,1823,1824,1838,1847],{"v-slot:answer":27},[10,1825,1826,1827,1830,1831,1833,1834,1837],{},"Elk ",[17,1828,1829],{},"BEGIN","-blok mét een ",[17,1832,985],{},"-tak opent in PostgreSQL intern een ",[920,1835,1836],{},"subtransactie"," (een\nsavepoint). Gaat er iets mis, dan wordt alles wat er in díé iteratie gebeurde teruggedraaid tot dat\nsavepoint — de halve factuur verdwijnt — en gaat de loop door met het volgende abonnement. Zonder die\ntak zou één kapot abonnement de hele nachtelijke batch laten terugdraaien.",[10,1839,922,1840,1843,1844,1846],{},[17,1841,1842],{},"INSERT"," in ",[17,1845,1820],{}," staat ín de handler, en die draait pas ná de rollback naar het\nsavepoint. Hij hoort dus bij de omliggende transactie en verdwijnt niet mee. Was je de fout vóór het\nsavepoint-herstel gaan wegschrijven, dan had je hem samen met de fout weggerold — de klassieke reden\ndat foutlogtabellen leeg blijven terwijl er wel degelijk fouten waren.",[10,1848,1849],{},"De prijs is niet gratis: een savepoint per iteratie kost werk. Bij tienduizenden abonnementen per nacht\nis dat merkbaar, en het houdt bovendien de transactie-ID-teller in beweging. Voor deze schaal prima,\nmaar het is een bewuste afweging, geen detail.",[519,1851,1852,1861],{"level":755},[523,1853,1854],{"v-slot:question":27},[10,1855,1856,1857,1860],{},"Een abonnement heeft ",[17,1858,1859],{},"volgende_factuurdatum = 2026-01-31",". Wat gebeurt er de komende maanden met die\ndatum?",[523,1862,1863,1866,1872,1890,1897],{"v-slot:answer":27},[10,1864,1865],{},"Dit is de eerste echte bug.",[10,1867,1868,1871],{},[17,1869,1870],{},"date + interval '1 month'"," in PostgreSQL houdt de dag gelijk, maar knijpt af op de laatste dag van de\ndoelmaand als die dag niet bestaat. Dus:",[602,1873,1874,1881,1887],{},[605,1875,1876,1877,1880],{},"31 januari → ",[920,1878,1879],{},"28 februari"," (of 29 in een schrikkeljaar)",[605,1882,1883,1884],{},"28 februari → ",[920,1885,1886],{},"28 maart",[605,1888,1889],{},"28 maart → 28 april",[10,1891,1892,1893,1896],{},"De datum kruipt naar voren en komt ",[920,1894,1895],{},"nooit meer terug op de 31e",". Een klant die op de 31e begon,\nwordt vanaf maart structureel drie dagen te vroeg gefactureerd, en dat blijft zo. Over een jaar\nverzamel je zo een extra factuurmoment.",[10,1898,1899,1900,1903],{},"De oplossing is de volgende datum niet uit de vórige berekenen, maar uit een vast ankerpunt: bewaar de\nstartdatum van het abonnement en bereken ",[17,1901,1902],{},"startdatum + (n maanden)",", of leg de factuurdag apart vast\nen construeer de datum opnieuw per periode. De regel is algemener dan SQL: bij herhalende data mag je\nnooit incrementeel doortellen, want elke afronding stapelt op.",[519,1905,1907,1916],{"level":1906},"Achterstand",[523,1908,1909],{"v-slot:question":27},[10,1910,1911,1912,1915],{},"Een abonnement staat drie maanden achter: ",[17,1913,1914],{},"volgende_factuurdatum"," is 15 mei, en de procedure draait op\n15 augustus. Wordt die achterstand ingehaald?",[523,1917,1918,1921,1936,1957],{"v-slot:answer":27},[10,1919,1920],{},"Nee, en dat is de tweede bug.",[10,1922,1923,1924,1927,1928,1931,1932,1935],{},"De iteratie doet precies één ding: één factuur aanmaken en de datum met één maand ophogen. Van 15 mei\nwordt 15 juni — nog steeds in het verleden. Je zou verwachten dat de volgende run de rest inhaalt,\nmaar de ",[17,1925,1926],{},"periode"," van de factuur is ",[17,1929,1930],{},"date_trunc('month', p_peildatum)",", dus ",[920,1933,1934],{},"augustus",", ongeacht\nhoever het abonnement achterloopt.",[10,1937,1938,1939,544,1941,1943,1944,749,1946,1948,1949,1952,1953,1956],{},"De volgende run botst daardoor op de unieke sleutel ",[17,1940,1797],{},[17,1942,1777],{},"\nslaat toe, ",[17,1945,1758],{},[17,1947,693],{}," en de iteratie stopt vóór de ",[17,1950,1951],{},"UPDATE",". De datum blijft op 15 juni\nstaan. Vanaf dan wordt dit abonnement elke nacht opgepakt, elke nacht overgeslagen, en telt het\neeuwig mee in ",[17,1954,1955],{},"v_overgeslagen"," — terwijl mei, juni en juli nooit gefactureerd worden.",[10,1958,1959,1960,1962,1963,1965],{},"Het venijn: er komt geen enkele fout uit. De procedure meldt netjes \"0 verwerkt, 1 overgeslagen\" en\ndat ziet er ordelijk uit. Wil je achterstanden echt inhalen, dan moet de ",[17,1961,1926],{}," uit\n",[17,1964,1914],{}," komen in plaats van uit de peildatum, en moet de loop per abonnement blijven\ndoorfactureren zolang de datum in het verleden ligt.",[519,1967,1969,1977],{"level":1968},"Foutafhandeling",[523,1970,1971],{"v-slot:question":27},[10,1972,1973,1976],{},[17,1974,1975],{},"EXCEPTION WHEN OTHERS THEN"," vangt alles op. Welk probleem levert dat op in een systeem dat met\nmeerdere workers tegelijk draait?",[523,1978,1979,1985,1988,2014],{"v-slot:answer":27},[10,1980,1981,1984],{},[17,1982,1983],{},"WHEN OTHERS"," maakt geen onderscheid tussen \"deze klant heeft rare data\" en \"de database vroeg me het\nopnieuw te proberen\".",[10,1986,1987],{},"Twee gevallen die hier stilletjes verkeerd aflopen:",[602,1989,1990,2005],{},[605,1991,1992,2000,2001,2004],{},[920,1993,1994,588,1997,708],{},[17,1995,1996],{},"serialization_failure",[17,1998,1999],{},"deadlock_detected"," Dat zijn tijdelijke condities: PostgreSQL zegt\nletterlijk ",[948,2002,2003],{},"probeer opnieuw",". Hier worden ze weggeschreven als verwerkingsfout en nooit opnieuw\ngeprobeerd, dus die klant mist zijn factuur voor die maand.",[605,2006,2007,2010,2011,2013],{},[920,2008,2009],{},"Programmeerfouten."," Een typefout in een kolomnaam of een type-mismatch geeft geen luide crash,\nmaar tienduizend nette regels in ",[17,2012,1820],{},". De batch \"slaagt\", de monitoring blijft groen,\nen niemand kijkt in die tabel tot de omzet niet klopt.",[10,2015,2016,2017,2020,2021,544,2024,2027],{},"Beter is per foutklasse handelen: ",[17,2018,2019],{},"WHEN serialization_failure OR deadlock_detected"," opnieuw proberen,\nde datafouten die je verwacht (",[17,2022,2023],{},"unique_violation",[17,2025,2026],{},"check_violation",") expliciet loggen, en al het\noverige laten doorslaan zodat de batch zichtbaar faalt. Een fout die je niet voorzag, hoort de nacht te\nverpesten — dat is precies waarvoor monitoring bestaat.",[519,2029,2031,2047],{"level":2030},"Expert",[523,2032,2033],{"v-slot:question":27},[10,2034,922,2035,2038,2039,2042,2043,2046],{},[17,2036,2037],{},"CONTINUE"," staat binnen het ",[17,2040,2041],{},"BEGIN ... EXCEPTION","-blok, dat zelf in de ",[17,2044,2045],{},"FOR","-loop zit. Waar gaat de\nuitvoering naartoe, en wat gebeurt er met de rijvergrendeling van het overgeslagen abonnement?",[523,2048,2049,2063,2073],{"v-slot:answer":27},[10,2050,2051,2053,2054,2056,2057,2059,2060,2062],{},[17,2052,2037],{}," hoort altijd bij de dichtstbijzijnde omsluitende loop, niet bij het blok waarin hij staat.\nHij verlaat dus het ",[17,2055,1829],{},"-blok en gaat naar de volgende iteratie van de ",[17,2058,2045],{},"-loop. Het savepoint van\ndat blok wordt daarbij netjes vrijgegeven — het is een normale uitgang, geen fout, dus er wordt niets\nteruggedraaid. De ophoging van ",[17,2061,1955],{}," van de regel ervoor blijft dus gewoon staan.",[10,2064,2065,2066,2068,2069,2072],{},"De vergrendeling is een ander verhaal. ",[17,2067,1729],{}," houdt de rijvergrendeling vast tot het einde van de\n",[920,2070,2071],{},"transactie",", niet tot het einde van de iteratie. Een overgeslagen abonnement blijft dus vergrendeld\nzolang de procedure loopt, ook al doen we er niets meer mee. Bij een batch van uren betekent dat dat\nandere workers al die rijen blijven overslaan en dat gewone applicatie-updates op die rijen staan te\nwachten.",[10,2074,2075,2076,2079,2080,2083,2084,2087,2088,2090,2091,2093],{},"Dat is meteen het bredere punt bij deze procedure: hij verwerkt álles in één transactie. Voor een paar\nduizend abonnementen prima. Wil je opschalen, dan moet je in blokken werken — bijvoorbeeld duizend\nabonnementen per transactie, met een ",[17,2077,2078],{},"COMMIT"," ertussen (in een ",[17,2081,2082],{},"PROCEDURE"," mag dat, in een ",[17,2085,2086],{},"FUNCTION","\nniet). Dat is precies de reden dat dit een ",[17,2089,2082],{}," is en geen ",[17,2092,2086],{},": de mogelijkheid om\ntransacties te beheren is hier nog niet gebruikt, maar wel de deur die openstaat.",[953,2095,2096],{},"html pre.shiki code .sbssI, html code.shiki .sbssI{--shiki-light:#F76D47;--shiki-default:#F78C6C;--shiki-dark:#F78C6C}html pre.shiki code .sTEyZ, html code.shiki .sTEyZ{--shiki-light:#90A4AE;--shiki-default:#EEFFFF;--shiki-dark:#BABED8}html pre.shiki code .spNyl, html code.shiki .spNyl{--shiki-light:#9C3EDA;--shiki-default:#C792EA;--shiki-dark:#C792EA}html pre.shiki code .sMK4o, html code.shiki .sMK4o{--shiki-light:#39ADB5;--shiki-default:#89DDFF;--shiki-dark:#89DDFF}html pre.shiki code .sfazB, html code.shiki .sfazB{--shiki-light:#91B859;--shiki-default:#C3E88D;--shiki-dark:#C3E88D}html pre.shiki code .s2Zo4, html code.shiki .s2Zo4{--shiki-light:#6182B8;--shiki-default:#82AAFF;--shiki-dark:#82AAFF}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":27,"searchDepth":52,"depth":52,"links":2098},[],"2026-08-27","Een batchprocedure met rijvergrendeling, idempotente inserts en een exception-handler per iteratie. Er zitten twee fouten in die pas na maanden zichtbaar worden.","Gevorderd",{},"\u002Fpracticely-code\u002Fmaandfacturatie-abonnementen",{"title":974,"description":2100},"practicely-code\u002Fmaandfacturatie-abonnementen","Batchverwerking","hsFNIRUyDtDr5LlmWaFHdF9IUjodlA8kJjkJDqqcDR0",{"id":2109,"title":2110,"body":2111,"date":2099,"description":2920,"dialect":960,"extension":961,"language":962,"level":963,"meta":2921,"navigation":965,"path":2922,"questions":98,"seo":2923,"stem":2924,"topic":2925,"__hash__":2926},"practicelyCode\u002Fpracticely-code\u002Fmaandomzet-per-regio.md","Maandomzet per regio — een rapportagefunctie lezen",{"type":7,"value":2112,"toc":2918},[2113,2120,2127,2586,2626,2695,2745,2807,2863,2915],[10,2114,2115,2116,2119],{},"Deze functie draait elke maandagochtend voor het management-dashboard. Hij is niet lang, maar er\nzitten vier dingen in die je in vrijwel elke rapportagequery terugziet: een filter op een\ndatumbereik, een aggregatie met een ",[17,2117,2118],{},"HAVING",", een berekend gemiddelde en een window-functie voor\neen percentage van het totaal.",[10,2121,2122,2123,2126],{},"Lees hem eerst helemaal door zonder naar de vragen te kijken. Probeer daarna per vraag eerst zelf\neen antwoord te formuleren voordat je op ",[920,2124,2125],{},"Toon antwoord"," klikt — anders leer je vooral hoe\novertuigend het antwoord klinkt.",[22,2128,2130],{"className":24,"code":2129,"language":26,"meta":27,"style":27},"CREATE OR REPLACE FUNCTION rapportage.maandomzet_per_regio(\n    p_jaar      int,\n    p_min_omzet numeric DEFAULT 0\n)\nRETURNS TABLE (\n    regio           text,\n    maand           date,\n    omzet           numeric,\n    orders          bigint,\n    gem_orderwaarde numeric,\n    aandeel_pct     numeric\n)\nLANGUAGE sql\nSTABLE\nAS $$\n    WITH betaalde_orders AS (\n        SELECT o.id,\n               o.klant_id,\n               date_trunc('month', o.besteld_op)::date AS maand,\n               o.totaal_excl_btw\n        FROM   verkoop.orders o\n        WHERE  o.status = 'betaald'\n          AND  o.besteld_op >= make_date(p_jaar, 1, 1)\n          AND  o.besteld_op \u003C  make_date(p_jaar + 1, 1, 1)\n    ),\n    per_regio AS (\n        SELECT k.regio,\n               b.maand,\n               SUM(b.totaal_excl_btw) AS omzet,\n               COUNT(*)               AS orders\n        FROM   betaalde_orders b\n        JOIN   verkoop.klanten k ON k.id = b.klant_id\n        GROUP  BY k.regio, b.maand\n        HAVING SUM(b.totaal_excl_btw) >= p_min_omzet\n    )\n    SELECT r.regio,\n           r.maand,\n           r.omzet,\n           r.orders,\n           ROUND(r.omzet \u002F r.orders, 2) AS gem_orderwaarde,\n           ROUND(100 * r.omzet \u002F SUM(r.omzet) OVER (PARTITION BY r.maand), 1) AS aandeel_pct\n    FROM   per_regio r\n    ORDER  BY r.maand, r.omzet DESC;\n$$;\n",[17,2131,2132,2144,2153,2165,2169,2177,2186,2195,2204,2213,2222,2230,2234,2240,2244,2250,2261,2268,2273,2295,2300,2307,2323,2344,2370,2374,2383,2390,2395,2408,2425,2432,2450,2458,2473,2477,2484,2489,2494,2499,2521,2563,2570,2582],{"__ignoreMap":27},[31,2133,2134,2136,2138,2141],{"class":33,"line":34},[31,2135,38],{"class":37},[31,2137,41],{"class":37},[31,2139,2140],{"class":44}," rapportage",[31,2142,2143],{"class":48},".maandomzet_per_regio(\n",[31,2145,2146,2149,2151],{"class":33,"line":52},[31,2147,2148],{"class":48},"    p_jaar      ",[31,2150,59],{"class":58},[31,2152,95],{"class":48},[31,2154,2155,2158,2160,2162],{"class":33,"line":68},[31,2156,2157],{"class":48},"    p_min_omzet ",[31,2159,1066],{"class":58},[31,2161,62],{"class":58},[31,2163,2164],{"class":37}," 0\n",[31,2166,2167],{"class":33,"line":74},[31,2168,71],{"class":48},[31,2170,2171,2173,2175],{"class":33,"line":86},[31,2172,77],{"class":37},[31,2174,80],{"class":37},[31,2176,83],{"class":48},[31,2178,2179,2182,2184],{"class":33,"line":98},[31,2180,2181],{"class":48},"    regio           ",[31,2183,114],{"class":58},[31,2185,95],{"class":48},[31,2187,2188,2191,2193],{"class":33,"line":108},[31,2189,2190],{"class":48},"    maand           ",[31,2192,1016],{"class":58},[31,2194,95],{"class":48},[31,2196,2197,2200,2202],{"class":33,"line":119},[31,2198,2199],{"class":48},"    omzet           ",[31,2201,1066],{"class":58},[31,2203,95],{"class":48},[31,2205,2206,2209,2211],{"class":33,"line":130},[31,2207,2208],{"class":48},"    orders          ",[31,2210,92],{"class":58},[31,2212,95],{"class":48},[31,2214,2215,2218,2220],{"class":33,"line":139},[31,2216,2217],{"class":48},"    gem_orderwaarde ",[31,2219,1066],{"class":58},[31,2221,95],{"class":48},[31,2223,2224,2227],{"class":33,"line":144},[31,2225,2226],{"class":48},"    aandeel_pct     ",[31,2228,2229],{"class":58},"numeric\n",[31,2231,2232],{"class":33,"line":153},[31,2233,71],{"class":48},[31,2235,2236,2238],{"class":33,"line":159},[31,2237,147],{"class":37},[31,2239,150],{"class":37},[31,2241,2242],{"class":33,"line":168},[31,2243,156],{"class":48},[31,2245,2246,2248],{"class":33,"line":181},[31,2247,162],{"class":37},[31,2249,165],{"class":48},[31,2251,2252,2254,2257,2259],{"class":33,"line":190},[31,2253,171],{"class":37},[31,2255,2256],{"class":48}," betaalde_orders ",[31,2258,162],{"class":37},[31,2260,83],{"class":48},[31,2262,2263,2265],{"class":33,"line":196},[31,2264,184],{"class":37},[31,2266,2267],{"class":48}," o.id,\n",[31,2269,2270],{"class":33,"line":210},[31,2271,2272],{"class":48},"               o.klant_id,\n",[31,2274,2275,2278,2280,2282,2284,2287,2289,2292],{"class":33,"line":216},[31,2276,2277],{"class":48},"               date_trunc(",[31,2279,1359],{"class":276},[31,2281,1362],{"class":1157},[31,2283,1359],{"class":276},[31,2285,2286],{"class":48},", o.besteld_op)::",[31,2288,1016],{"class":58},[31,2290,2291],{"class":37}," AS",[31,2293,2294],{"class":48}," maand,\n",[31,2296,2297],{"class":33,"line":225},[31,2298,2299],{"class":48},"               o.totaal_excl_btw\n",[31,2301,2302,2304],{"class":33,"line":237},[31,2303,219],{"class":37},[31,2305,2306],{"class":48},"   verkoop.orders o\n",[31,2308,2309,2311,2314,2316,2318,2321],{"class":33,"line":252},[31,2310,228],{"class":37},[31,2312,2313],{"class":48},"  o.status ",[31,2315,466],{"class":276},[31,2317,1154],{"class":276},[31,2319,2320],{"class":1157},"betaald",[31,2322,1161],{"class":276},[31,2324,2325,2327,2330,2333,2336,2338,2340,2342],{"class":33,"line":258},[31,2326,240],{"class":37},[31,2328,2329],{"class":48},"  o.besteld_op ",[31,2331,2332],{"class":276},">=",[31,2334,2335],{"class":48}," make_date(p_jaar, ",[31,2337,1231],{"class":37},[31,2339,544],{"class":48},[31,2341,1231],{"class":37},[31,2343,71],{"class":48},[31,2345,2346,2348,2350,2353,2356,2358,2360,2362,2364,2366,2368],{"class":33,"line":268},[31,2347,240],{"class":37},[31,2349,2329],{"class":48},[31,2351,2352],{"class":276},"\u003C",[31,2354,2355],{"class":48},"  make_date(p_jaar ",[31,2357,1299],{"class":276},[31,2359,1302],{"class":37},[31,2361,544],{"class":48},[31,2363,1231],{"class":37},[31,2365,544],{"class":48},[31,2367,1231],{"class":37},[31,2369,71],{"class":48},[31,2371,2372],{"class":33,"line":282},[31,2373,255],{"class":48},[31,2375,2376,2379,2381],{"class":33,"line":306},[31,2377,2378],{"class":48},"    per_regio ",[31,2380,162],{"class":37},[31,2382,83],{"class":48},[31,2384,2385,2387],{"class":33,"line":320},[31,2386,184],{"class":37},[31,2388,2389],{"class":48}," k.regio,\n",[31,2391,2392],{"class":33,"line":339},[31,2393,2394],{"class":48},"               b.maand,\n",[31,2396,2397,2400,2403,2405],{"class":33,"line":352},[31,2398,2399],{"class":44},"               SUM",[31,2401,2402],{"class":48},"(b.totaal_excl_btw) ",[31,2404,162],{"class":37},[31,2406,2407],{"class":48}," omzet,\n",[31,2409,2410,2413,2415,2417,2420,2422],{"class":33,"line":370},[31,2411,2412],{"class":44},"               COUNT",[31,2414,446],{"class":48},[31,2416,277],{"class":276},[31,2418,2419],{"class":48},")               ",[31,2421,162],{"class":37},[31,2423,2424],{"class":48}," orders\n",[31,2426,2427,2429],{"class":33,"line":382},[31,2428,219],{"class":37},[31,2430,2431],{"class":48},"   betaalde_orders b\n",[31,2433,2434,2437,2440,2442,2445,2447],{"class":33,"line":390},[31,2435,2436],{"class":37},"        JOIN",[31,2438,2439],{"class":48},"   verkoop.klanten k ",[31,2441,882],{"class":37},[31,2443,2444],{"class":48}," k.id ",[31,2446,466],{"class":276},[31,2448,2449],{"class":48}," b.klant_id\n",[31,2451,2452,2455],{"class":33,"line":396},[31,2453,2454],{"class":37},"        GROUP  BY",[31,2456,2457],{"class":48}," k.regio, b.maand\n",[31,2459,2460,2463,2466,2468,2470],{"class":33,"line":405},[31,2461,2462],{"class":37},"        HAVING",[31,2464,2465],{"class":44}," SUM",[31,2467,2402],{"class":48},[31,2469,2332],{"class":276},[31,2471,2472],{"class":48}," p_min_omzet\n",[31,2474,2475],{"class":33,"line":411},[31,2476,393],{"class":48},[31,2478,2479,2481],{"class":33,"line":417},[31,2480,399],{"class":37},[31,2482,2483],{"class":48}," r.regio,\n",[31,2485,2486],{"class":33,"line":434},[31,2487,2488],{"class":48},"           r.maand,\n",[31,2490,2491],{"class":33,"line":472},[31,2492,2493],{"class":48},"           r.omzet,\n",[31,2495,2496],{"class":33,"line":481},[31,2497,2498],{"class":48},"           r.orders,\n",[31,2500,2501,2504,2507,2509,2512,2514,2516,2518],{"class":33,"line":496},[31,2502,2503],{"class":44},"           ROUND",[31,2505,2506],{"class":48},"(r.omzet ",[31,2508,1248],{"class":276},[31,2510,2511],{"class":48}," r.orders, ",[31,2513,1077],{"class":37},[31,2515,451],{"class":48},[31,2517,162],{"class":37},[31,2519,2520],{"class":48}," gem_orderwaarde,\n",[31,2522,2523,2525,2527,2530,2533,2536,2538,2540,2543,2545,2547,2549,2551,2554,2556,2558,2560],{"class":33,"line":505},[31,2524,2503],{"class":44},[31,2526,446],{"class":48},[31,2528,2529],{"class":37},"100",[31,2531,2532],{"class":276}," *",[31,2534,2535],{"class":48}," r.omzet ",[31,2537,1248],{"class":276},[31,2539,2465],{"class":44},[31,2541,2542],{"class":48},"(r.omzet) ",[31,2544,291],{"class":37},[31,2546,294],{"class":48},[31,2548,297],{"class":37},[31,2550,300],{"class":37},[31,2552,2553],{"class":48}," r.maand), ",[31,2555,1231],{"class":37},[31,2557,451],{"class":48},[31,2559,162],{"class":37},[31,2561,2562],{"class":48}," aandeel_pct\n",[31,2564,2565,2567],{"class":33,"line":514},[31,2566,475],{"class":37},[31,2568,2569],{"class":48},"   per_regio r\n",[31,2571,2572,2574,2577,2580],{"class":33,"line":1452},[31,2573,499],{"class":37},[31,2575,2576],{"class":48}," r.maand, r.omzet ",[31,2578,2579],{"class":37},"DESC",[31,2581,1058],{"class":48},[31,2583,2584],{"class":33,"line":1461},[31,2585,517],{"class":48},[519,2587,2588,2601],{"level":521},[523,2589,2590],{"v-slot:question":27},[10,2591,2592,2593,2596,2597,2600],{},"Wat staat er precies in de kolom ",[17,2594,2595],{},"maand",", en waarom staat er ",[17,2598,2599],{},"::date"," achter?",[523,2602,2603,2620],{"v-slot:answer":27},[10,2604,2605,2608,2609,2612,2613,2615,2616,2619],{},[17,2606,2607],{},"date_trunc('month', o.besteld_op)"," kapt een timestamp af tot het begin van de maand: een order van\n17 maart 14:32 wordt ",[17,2610,2611],{},"2026-03-01 00:00:00",". De cast ",[17,2614,2599],{}," gooit het tijdsdeel weg, zodat je\n",[17,2617,2618],{},"2026-03-01"," overhoudt.",[10,2621,2622,2623,2625],{},"Zonder die cast werkt het ook, maar dan groepeer je op een type met een tijdcomponent die altijd nul\nis. Dat leest verwarrend, en het maakt vergelijken met een ",[17,2624,1016],{},"-kolom elders onnodig lastig.",[519,2627,2629,2641],{"level":2628},"Waarom zo",[523,2630,2631],{"v-slot:question":27},[10,2632,2633,2634,2637,2638,535],{},"Het jaarfilter staat er als ",[17,2635,2636],{},">= make_date(p_jaar, 1, 1) AND \u003C make_date(p_jaar + 1, 1, 1)",".\nWaarom niet gewoon ",[17,2639,2640],{},"WHERE EXTRACT(year FROM o.besteld_op) = p_jaar",[523,2642,2643,2646,2674],{"v-slot:answer":27},[10,2644,2645],{},"Twee redenen, en de tweede weegt het zwaarst.",[10,2647,2648,2651,2652,2655,2656,2659,2660,2663,2664,2667,2668,2670,2671,2673],{},[920,2649,2650],{},"Correctheid."," Als ",[17,2653,2654],{},"besteld_op"," een ",[17,2657,2658],{},"timestamp"," is, sluit ",[17,2661,2662],{},"BETWEEN '2026-01-01' AND '2026-12-31'","\nalles uit wat op 31 december ná middernacht binnenkwam — bijna een hele dag omzet. De vorm\n",[17,2665,2666],{},">= begin AND \u003C begin_volgend_jaar"," heeft dat probleem niet, of de kolom nu een ",[17,2669,1016],{}," of een\n",[17,2672,2658],{}," is.",[10,2675,2676,936,2679,2682,2683,2686,2687,2689,2690,588,2692,2694],{},[920,2677,2678],{},"Performance.",[17,2680,2681],{},"EXTRACT(year FROM o.besteld_op) = 2026"," past een functie toe op de kolom. Daarmee is\nde conditie niet meer ",[948,2684,2685],{},"sargable",": PostgreSQL kan een gewone index op ",[17,2688,2654],{}," niet gebruiken en\nvalt terug op een sequential scan over de hele tabel. De variant met ",[17,2691,2332],{},[17,2693,2352],{}," is een bereikconditie\nop de kale kolom en kan wél een index scan doen. Op een orders-tabel van enkele miljoenen rijen is dat\nhet verschil tussen milliseconden en seconden.",[519,2696,2697,2710],{"level":580},[523,2698,2699],{"v-slot:question":27},[10,2700,2701,2702,2705,2706,2709],{},"Wat berekent ",[17,2703,2704],{},"SUM(r.omzet) OVER (PARTITION BY r.maand)",", en tellen de waarden in ",[17,2707,2708],{},"aandeel_pct","\naltijd op tot 100?",[523,2711,2712,2721,2736],{"v-slot:answer":27},[10,2713,2714,2715,2717,2718,2720],{},"De window-functie telt de omzet op van alle rijen met dezelfde ",[17,2716,2595],{},", zonder te groeperen: elke rij\nhoudt zijn eigen regel maar krijgt het maandtotaal ernaast. ",[17,2719,2708],{}," is dus het percentage van\ndie maand dat deze regio leverde.",[10,2722,2723,2724,2727,2728,2731,2732,2735],{},"Binnen het resultaat tellen ze op tot 100. En daar zit precies de valkuil: ",[920,2725,2726],{},"het is 100% van wat\noverbleef",", niet van de werkelijke omzet. De ",[17,2729,2730],{},"HAVING SUM(...) >= p_min_omzet"," in de CTE eronder heeft\nal regio's weggegooid, en de window-functie draait pas daarna. Roep je de functie aan met\n",[17,2733,2734],{},"p_min_omzet = 50000",", dan oogt een regio met 40% van de resterende omzet groot, terwijl dat misschien\n12% van de echte maandomzet is.",[10,2737,2738,2739,2742,2743,708],{},"Wil je het aandeel in het échte totaal, dan moet de window-functie over de ongefilterde set draaien —\nbijvoorbeeld door het percentage al in ",[17,2740,2741],{},"per_regio"," te berekenen, vóór de ",[17,2744,2118],{},[519,2746,2747,2756],{"level":755},[523,2748,2749],{"v-slot:question":27},[10,2750,2751,2752,2755],{},"Kan deze functie een ",[17,2753,2754],{},"division by zero"," opleveren? Er wordt op twee plekken gedeeld.",[523,2757,2758,2777,2794],{"v-slot:answer":27},[10,2759,2760,2761,2764,2765,2768,2769,2772,2773,2776],{},"Bij ",[17,2762,2763],{},"r.omzet \u002F r.orders"," niet. ",[17,2766,2767],{},"orders"," komt uit ",[17,2770,2771],{},"COUNT(*)"," binnen een ",[17,2774,2775],{},"GROUP BY",", en een groep die\nbestaat heeft minstens één rij. Nul kan daar niet uitkomen.",[10,2778,2760,2779,2782,2783,2785,2786,2789,2790,2793],{},[17,2780,2781],{},"100 * r.omzet \u002F SUM(r.omzet) OVER (...)"," wél. Hebben alle regio's in een maand samen precies 0\nomzet — denkbaar bij volledig gecrediteerde orders of bij testdata — dan is de noemer 0 en breekt de\nquery af. Het ",[17,2784,2730],{}," houdt dat niet tegen zolang ",[17,2787,2788],{},"p_min_omzet"," op de\nstandaardwaarde 0 staat: ",[17,2791,2792],{},"0 >= 0"," is waar, dus die rijen blijven gewoon staan.",[10,2795,2796,2797,2800,2801,2803,2804,2806],{},"De gebruikelijke oplossing is ",[17,2798,2799],{},"NULLIF(SUM(...) OVER (...), 0)",". Delen door ",[17,2802,693],{}," geeft ",[17,2805,693],{}," in\nplaats van een fout, en een lege cel in een rapport is een stuk vriendelijker dan een gecrashte\nmaandrapportage.",[519,2808,2809,2817],{"level":906},[523,2810,2811],{"v-slot:question":27},[10,2812,2813,2816],{},[17,2814,2815],{},"JOIN verkoop.klanten k ON k.id = b.klant_id"," — welke twee dingen gaan hier stil mis met de cijfers?",[523,2818,2819,2847],{"v-slot:answer":27},[10,2820,2821,2828,2829,2832,2833,2836,2837,2839,2840,2842,2843,2846],{},[920,2822,2823,2824,2827],{},"Een ",[17,2825,2826],{},"INNER JOIN"," laat omzet verdwijnen."," Elke betaalde order waarvan de klant niet (meer) in\n",[17,2830,2831],{},"verkoop.klanten"," staat, valt geruisloos uit het rapport. Bij een verwijderde klant of een ",[17,2834,2835],{},"klant_id","\ndat ",[17,2838,693],{}," is, klopt het maandtotaal simpelweg niet — en niets in de uitvoer verraadt dat. De omzet in\ndit rapport telt dan niet op tot de omzet in de boekhouding, en dat soort verschillen kost bij een\ncontrole dagen uitzoekwerk. Een ",[17,2841,836],{}," met ",[17,2844,2845],{},"COALESCE(k.regio, 'Onbekend')"," maakt het probleem\nzichtbaar in plaats van onzichtbaar.",[10,2848,2849,936,2857,2859,2860,2862],{},[920,2850,2851,1843,2853,2856],{},[17,2852,693],{},[17,2854,2855],{},"regio"," wordt een eigen groep.",[17,2858,2775],{}," behandelt ",[17,2861,693],{}," als één waarde, dus alle\nklanten zonder regio belanden samen in één rij met een lege regiokolom. In het dashboard ziet dat\neruit als een naamloze balk, niet als een datakwaliteitsprobleem.",[519,2864,2866,2886],{"level":2865},"Uitvoering",[523,2867,2868],{"v-slot:question":27},[10,2869,2870,2871,2874,2875,2878,2879,2882,2883,535],{},"De functie is gemarkeerd als ",[17,2872,2873],{},"STABLE"," en geschreven in ",[17,2876,2877],{},"LANGUAGE sql"," in plaats van ",[17,2880,2881],{},"plpgsql",".\nWat levert die combinatie op, en wat verandert er bij ",[17,2884,2885],{},"VOLATILE",[523,2887,2888,2896,2909],{"v-slot:answer":27},[10,2889,2890,2892,2893,2895],{},[17,2891,2873],{}," belooft de planner: binnen één statement geeft deze functie bij dezelfde argumenten steeds\nhetzelfde resultaat, en hij wijzigt niets in de database. Dat is meer dan documentatie — het geeft\nPostgreSQL toestemming de functie één keer te evalueren in plaats van per rij, en hem te gebruiken op\nplekken waar dat bij ",[17,2894,2885],{}," niet mag.",[10,2897,2898,2899,2901,2902,2905,2906,2908],{},"De combinatie met ",[17,2900,2877],{}," is waar het echt loont: een SQL-functie die uit één query bestaat kan\nPostgreSQL ",[948,2903,2904],{},"inlinen"," — de body wordt in de aanroepende query geplakt, waarna de planner het geheel in\néén keer optimaliseert. Filters van buitenaf kunnen dan naar binnen worden geduwd. Bij ",[17,2907,2881],{},"\ngebeurt dat nooit: dat is een black box die zijn volledige resultaat opbouwt voordat de aanroeper er\niets mee doet.",[10,2910,2911,2912,2914],{},"Zet je hem op ",[17,2913,2885],{},", dan vervalt het inlinen en moet de planner aannemen dat elke aanroep een\nander antwoord kan geven. Wordt de functie per rij van een andere tabel aangeroepen, dan is dat het\nverschil tussen één scan en duizenden.",[953,2916,2917],{},"html pre.shiki code .sbssI, html code.shiki .sbssI{--shiki-light:#F76D47;--shiki-default:#F78C6C;--shiki-dark:#F78C6C}html pre.shiki code .s2Zo4, html code.shiki .s2Zo4{--shiki-light:#6182B8;--shiki-default:#82AAFF;--shiki-dark:#82AAFF}html pre.shiki code .sTEyZ, html code.shiki .sTEyZ{--shiki-light:#90A4AE;--shiki-default:#EEFFFF;--shiki-dark:#BABED8}html pre.shiki code .spNyl, html code.shiki .spNyl{--shiki-light:#9C3EDA;--shiki-default:#C792EA;--shiki-dark:#C792EA}html pre.shiki code .sMK4o, html code.shiki .sMK4o{--shiki-light:#39ADB5;--shiki-default:#89DDFF;--shiki-dark:#89DDFF}html pre.shiki code .sfazB, html code.shiki .sfazB{--shiki-light:#91B859;--shiki-default:#C3E88D;--shiki-dark:#C3E88D}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":27,"searchDepth":52,"depth":52,"links":2919},[],"Een SQL-functie met CTE's, een HAVING-filter en een window-functie. Begin hier als je stored procedures nog niet dagelijks leest.",{},"\u002Fpracticely-code\u002Fmaandomzet-per-regio",{"title":2110,"description":2920},"practicely-code\u002Fmaandomzet-per-regio","Rapportage","_4F1rUMkTMInlZ4vyNzkI4zptVUFsbrSjZ9Ou7DLMTg",{"id":2928,"title":2929,"body":2930,"date":2099,"description":4428,"dialect":4429,"extension":961,"language":962,"level":2030,"meta":4430,"navigation":965,"path":4431,"questions":119,"seo":4432,"stem":4433,"topic":4434,"__hash__":4435},"practicelyCode\u002Fpracticely-code\u002Fvoorraadsynchronisatie-merge.md","Voorraadsynchronisatie met MERGE — de gevaarlijkste tien regels T-SQL",{"type":7,"value":2931,"toc":4426},[2932,2941,2944,3821,3868,3921,3978,4029,4083,4184,4258,4423],[10,2933,2934,2937,2938,2940],{},[17,2935,2936],{},"MERGE"," is het statement waarmee je in één keer invoegt, bijwerkt en verwijdert. Het is ook het\nstatement met de meeste voetangels in SQL Server. Deze procedure synchroniseert de voorraad van één\nleverancier vanuit een stagingtabel en gebruikt vrijwel alles wat ",[17,2939,2936],{}," te bieden heeft.",[10,2942,2943],{},"Er zit één stuk dode code in, één constructie die dataverlies kan veroorzaken bij slechte invoer, en\néén hint die er niet voor de snelheid staat. Zoek ze voordat je de antwoorden opent.",[22,2945,2947],{"className":24,"code":2946,"language":26,"meta":27,"style":27},"CREATE OR ALTER PROCEDURE dbo.usp_SynchroniseerVoorraad\n    @LeverancierId INT,\n    @BatchId       UNIQUEIDENTIFIER,\n    @DryRun        BIT = 0\nAS\nBEGIN\n    SET NOCOUNT ON;\n    SET XACT_ABORT ON;\n\n    DECLARE @Toegevoegd INT = 0, @Gewijzigd INT = 0, @Verwijderd INT = 0;\n    DECLARE @Log TABLE (\n        Actie       NVARCHAR(10),\n        ProductId   INT,\n        OudAantal   INT,\n        NieuwAantal INT\n    );\n\n    BEGIN TRY\n        BEGIN TRANSACTION;\n\n        ;WITH Aangeleverd AS (\n            SELECT  s.ProductId,\n                    SUM(s.Aantal)        AS Aantal,\n                    MAX(s.AangeleverdOp) AS AangeleverdOp,\n                    ROW_NUMBER() OVER (PARTITION BY s.ProductId\n                                       ORDER BY MAX(s.AangeleverdOp) DESC) AS rn\n            FROM    staging.LeveranciersVoorraad s\n            WHERE   s.LeverancierId = @LeverancierId\n              AND   s.BatchId       = @BatchId\n              AND   s.Aantal       >= 0\n            GROUP BY s.ProductId\n        )\n        MERGE dbo.Voorraad WITH (HOLDLOCK) AS doel\n        USING (SELECT ProductId, Aantal, AangeleverdOp\n               FROM   Aangeleverd\n               WHERE  rn = 1) AS bron\n           ON  doel.ProductId     = bron.ProductId\n           AND doel.LeverancierId = @LeverancierId\n        WHEN MATCHED AND doel.Aantal \u003C> bron.Aantal THEN\n            UPDATE SET doel.Aantal       = bron.Aantal,\n                       doel.BijgewerktOp = bron.AangeleverdOp\n        WHEN NOT MATCHED BY TARGET THEN\n            INSERT (ProductId, LeverancierId, Aantal, BijgewerktOp)\n            VALUES (bron.ProductId, @LeverancierId, bron.Aantal, bron.AangeleverdOp)\n        WHEN NOT MATCHED BY SOURCE AND doel.LeverancierId = @LeverancierId THEN\n            DELETE\n        OUTPUT $action,\n               ISNULL(inserted.ProductId, deleted.ProductId),\n               deleted.Aantal,\n               inserted.Aantal\n        INTO @Log (Actie, ProductId, OudAantal, NieuwAantal);\n\n        SELECT @Toegevoegd = SUM(CASE WHEN Actie = 'INSERT' THEN 1 ELSE 0 END),\n               @Gewijzigd  = SUM(CASE WHEN Actie = 'UPDATE' THEN 1 ELSE 0 END),\n               @Verwijderd = SUM(CASE WHEN Actie = 'DELETE' THEN 1 ELSE 0 END)\n        FROM   @Log;\n\n        IF @DryRun = 1\n            ROLLBACK TRANSACTION;\n        ELSE\n            COMMIT TRANSACTION;\n\n        SELECT @Toegevoegd AS Toegevoegd,\n               @Gewijzigd  AS Gewijzigd,\n               @Verwijderd AS Verwijderd;\n    END TRY\n    BEGIN CATCH\n        IF XACT_STATE() \u003C> 0\n            ROLLBACK TRANSACTION;\n\n        INSERT INTO dbo.SyncFouten (LeverancierId, BatchId, Foutmelding, OpgetredenOp)\n        VALUES (@LeverancierId, @BatchId, ERROR_MESSAGE(), SYSUTCDATETIME());\n\n        THROW;\n    END CATCH\nEND;\n",[17,2948,2949,2963,2973,2983,2996,3001,3005,3018,3029,3033,3067,3079,3094,3103,3112,3120,3125,3129,3137,3147,3151,3166,3174,3187,3200,3218,3237,3245,3257,3270,3281,3288,3293,3311,3323,3331,3350,3363,3375,3396,3411,3421,3437,3445,3452,3477,3482,3495,3503,3508,3513,3521,3525,3569,3606,3643,3650,3654,3666,3675,3680,3689,3693,3704,3713,3722,3728,3736,3750,3759,3764,3773,3794,3799,3807,3814],{"__ignoreMap":27},[31,2950,2951,2953,2955,2958,2960],{"class":33,"line":34},[31,2952,996],{"class":37},[31,2954,999],{"class":37},[31,2956,2957],{"class":37}," ALTER",[31,2959,1005],{"class":37},[31,2961,2962],{"class":48}," dbo.usp_SynchroniseerVoorraad\n",[31,2964,2965,2968,2971],{"class":33,"line":52},[31,2966,2967],{"class":48},"    @LeverancierId ",[31,2969,2970],{"class":58},"INT",[31,2972,95],{"class":48},[31,2974,2975,2978,2981],{"class":33,"line":68},[31,2976,2977],{"class":48},"    @BatchId       ",[31,2979,2980],{"class":37},"UNIQUEIDENTIFIER",[31,2982,95],{"class":48},[31,2984,2985,2988,2991,2994],{"class":33,"line":74},[31,2986,2987],{"class":48},"    @DryRun        ",[31,2989,2990],{"class":58},"BIT",[31,2992,2993],{"class":276}," =",[31,2995,2164],{"class":37},[31,2997,2998],{"class":33,"line":86},[31,2999,3000],{"class":37},"AS\n",[31,3002,3003],{"class":33,"line":98},[31,3004,1117],{"class":37},[31,3006,3007,3010,3013,3016],{"class":33,"line":108},[31,3008,3009],{"class":37},"    SET",[31,3011,3012],{"class":37}," NOCOUNT",[31,3014,3015],{"class":37}," ON",[31,3017,1058],{"class":48},[31,3019,3020,3022,3025,3027],{"class":33,"line":119},[31,3021,3009],{"class":37},[31,3023,3024],{"class":37}," XACT_ABORT",[31,3026,3015],{"class":37},[31,3028,1058],{"class":48},[31,3030,3031],{"class":33,"line":130},[31,3032,1271],{"emptyLinePlaceholder":965},[31,3034,3035,3038,3041,3043,3045,3047,3050,3052,3054,3056,3059,3061,3063,3065],{"class":33,"line":139},[31,3036,3037],{"class":37},"    DECLARE",[31,3039,3040],{"class":48}," @Toegevoegd ",[31,3042,2970],{"class":58},[31,3044,2993],{"class":276},[31,3046,1095],{"class":37},[31,3048,3049],{"class":48},", @Gewijzigd ",[31,3051,2970],{"class":58},[31,3053,2993],{"class":276},[31,3055,1095],{"class":37},[31,3057,3058],{"class":48},", @Verwijderd ",[31,3060,2970],{"class":58},[31,3062,2993],{"class":276},[31,3064,1095],{"class":37},[31,3066,1058],{"class":48},[31,3068,3069,3071,3074,3077],{"class":33,"line":144},[31,3070,3037],{"class":37},[31,3072,3073],{"class":48}," @Log ",[31,3075,3076],{"class":37},"TABLE",[31,3078,83],{"class":48},[31,3080,3081,3084,3087,3089,3092],{"class":33,"line":153},[31,3082,3083],{"class":48},"        Actie       ",[31,3085,3086],{"class":37},"NVARCHAR",[31,3088,446],{"class":48},[31,3090,3091],{"class":37},"10",[31,3093,1564],{"class":48},[31,3095,3096,3099,3101],{"class":33,"line":159},[31,3097,3098],{"class":48},"        ProductId   ",[31,3100,2970],{"class":58},[31,3102,95],{"class":48},[31,3104,3105,3108,3110],{"class":33,"line":168},[31,3106,3107],{"class":48},"        OudAantal   ",[31,3109,2970],{"class":58},[31,3111,95],{"class":48},[31,3113,3114,3117],{"class":33,"line":181},[31,3115,3116],{"class":48},"        NieuwAantal ",[31,3118,3119],{"class":58},"INT\n",[31,3121,3122],{"class":33,"line":190},[31,3123,3124],{"class":48},"    );\n",[31,3126,3127],{"class":33,"line":196},[31,3128,1271],{"emptyLinePlaceholder":965},[31,3130,3131,3134],{"class":33,"line":210},[31,3132,3133],{"class":37},"    BEGIN",[31,3135,3136],{"class":37}," TRY\n",[31,3138,3139,3142,3145],{"class":33,"line":216},[31,3140,3141],{"class":37},"        BEGIN",[31,3143,3144],{"class":37}," TRANSACTION",[31,3146,1058],{"class":48},[31,3148,3149],{"class":33,"line":225},[31,3150,1271],{"emptyLinePlaceholder":965},[31,3152,3153,3156,3159,3162,3164],{"class":33,"line":237},[31,3154,3155],{"class":48},"        ;",[31,3157,3158],{"class":37},"WITH",[31,3160,3161],{"class":48}," Aangeleverd ",[31,3163,162],{"class":37},[31,3165,83],{"class":48},[31,3167,3168,3171],{"class":33,"line":252},[31,3169,3170],{"class":37},"            SELECT",[31,3172,3173],{"class":48},"  s.ProductId,\n",[31,3175,3176,3179,3182,3184],{"class":33,"line":258},[31,3177,3178],{"class":44},"                    SUM",[31,3180,3181],{"class":48},"(s.Aantal)        ",[31,3183,162],{"class":37},[31,3185,3186],{"class":48}," Aantal,\n",[31,3188,3189,3192,3195,3197],{"class":33,"line":268},[31,3190,3191],{"class":44},"                    MAX",[31,3193,3194],{"class":48},"(s.AangeleverdOp) ",[31,3196,162],{"class":37},[31,3198,3199],{"class":48}," AangeleverdOp,\n",[31,3201,3202,3205,3207,3209,3211,3213,3215],{"class":33,"line":282},[31,3203,3204],{"class":44},"                    ROW_NUMBER",[31,3206,288],{"class":48},[31,3208,291],{"class":37},[31,3210,294],{"class":48},[31,3212,297],{"class":37},[31,3214,300],{"class":37},[31,3216,3217],{"class":48}," s.ProductId\n",[31,3219,3220,3223,3226,3228,3230,3232,3234],{"class":33,"line":306},[31,3221,3222],{"class":37},"                                       ORDER BY",[31,3224,3225],{"class":44}," MAX",[31,3227,3194],{"class":48},[31,3229,2579],{"class":37},[31,3231,451],{"class":48},[31,3233,162],{"class":37},[31,3235,3236],{"class":48}," rn\n",[31,3238,3239,3242],{"class":33,"line":320},[31,3240,3241],{"class":37},"            FROM",[31,3243,3244],{"class":48},"    staging.LeveranciersVoorraad s\n",[31,3246,3247,3249,3252,3254],{"class":33,"line":339},[31,3248,1502],{"class":37},[31,3250,3251],{"class":48},"   s.LeverancierId ",[31,3253,466],{"class":276},[31,3255,3256],{"class":48}," @LeverancierId\n",[31,3258,3259,3262,3265,3267],{"class":33,"line":352},[31,3260,3261],{"class":37},"              AND",[31,3263,3264],{"class":48},"   s.BatchId       ",[31,3266,466],{"class":276},[31,3268,3269],{"class":48}," @BatchId\n",[31,3271,3272,3274,3277,3279],{"class":33,"line":370},[31,3273,3261],{"class":37},[31,3275,3276],{"class":48},"   s.Aantal       ",[31,3278,2332],{"class":276},[31,3280,2164],{"class":37},[31,3282,3283,3286],{"class":33,"line":382},[31,3284,3285],{"class":37},"            GROUP BY",[31,3287,3217],{"class":48},[31,3289,3290],{"class":33,"line":390},[31,3291,3292],{"class":48},"        )\n",[31,3294,3295,3298,3301,3303,3306,3308],{"class":33,"line":396},[31,3296,3297],{"class":37},"        MERGE",[31,3299,3300],{"class":48}," dbo.Voorraad ",[31,3302,3158],{"class":37},[31,3304,3305],{"class":48}," (HOLDLOCK) ",[31,3307,162],{"class":37},[31,3309,3310],{"class":48}," doel\n",[31,3312,3313,3316,3318,3320],{"class":33,"line":405},[31,3314,3315],{"class":37},"        USING",[31,3317,294],{"class":48},[31,3319,440],{"class":37},[31,3321,3322],{"class":48}," ProductId, Aantal, AangeleverdOp\n",[31,3324,3325,3328],{"class":33,"line":411},[31,3326,3327],{"class":37},"               FROM",[31,3329,3330],{"class":48},"   Aangeleverd\n",[31,3332,3333,3336,3339,3341,3343,3345,3347],{"class":33,"line":417},[31,3334,3335],{"class":37},"               WHERE",[31,3337,3338],{"class":48},"  rn ",[31,3340,466],{"class":276},[31,3342,1302],{"class":37},[31,3344,451],{"class":48},[31,3346,162],{"class":37},[31,3348,3349],{"class":48}," bron\n",[31,3351,3352,3355,3358,3360],{"class":33,"line":434},[31,3353,3354],{"class":37},"           ON",[31,3356,3357],{"class":48},"  doel.ProductId     ",[31,3359,466],{"class":276},[31,3361,3362],{"class":48}," bron.ProductId\n",[31,3364,3365,3368,3371,3373],{"class":33,"line":472},[31,3366,3367],{"class":37},"           AND",[31,3369,3370],{"class":48}," doel.LeverancierId ",[31,3372,466],{"class":276},[31,3374,3256],{"class":48},[31,3376,3377,3380,3383,3386,3389,3391,3394],{"class":33,"line":481},[31,3378,3379],{"class":37},"        WHEN",[31,3381,3382],{"class":37}," MATCHED",[31,3384,3385],{"class":37}," AND",[31,3387,3388],{"class":48}," doel.Aantal ",[31,3390,689],{"class":276},[31,3392,3393],{"class":48}," bron.Aantal ",[31,3395,1615],{"class":37},[31,3397,3398,3400,3403,3406,3408],{"class":33,"line":496},[31,3399,1455],{"class":37},[31,3401,3402],{"class":37}," SET",[31,3404,3405],{"class":48}," doel.Aantal       ",[31,3407,466],{"class":276},[31,3409,3410],{"class":48}," bron.Aantal,\n",[31,3412,3413,3416,3418],{"class":33,"line":505},[31,3414,3415],{"class":48},"                       doel.BijgewerktOp ",[31,3417,466],{"class":276},[31,3419,3420],{"class":48}," bron.AangeleverdOp\n",[31,3422,3423,3425,3428,3430,3432,3435],{"class":33,"line":514},[31,3424,3379],{"class":37},[31,3426,3427],{"class":37}," NOT",[31,3429,3382],{"class":37},[31,3431,300],{"class":37},[31,3433,3434],{"class":37}," TARGET",[31,3436,1286],{"class":37},[31,3438,3439,3442],{"class":33,"line":1452},[31,3440,3441],{"class":37},"            INSERT",[31,3443,3444],{"class":48}," (ProductId, LeverancierId, Aantal, BijgewerktOp)\n",[31,3446,3447,3449],{"class":33,"line":1461},[31,3448,1348],{"class":37},[31,3450,3451],{"class":48}," (bron.ProductId, @LeverancierId, bron.Aantal, bron.AangeleverdOp)\n",[31,3453,3454,3456,3458,3460,3462,3465,3468,3470,3472,3475],{"class":33,"line":1489},[31,3455,3379],{"class":37},[31,3457,3427],{"class":37},[31,3459,3382],{"class":37},[31,3461,300],{"class":37},[31,3463,3464],{"class":48}," SOURCE ",[31,3466,3467],{"class":37},"AND",[31,3469,3370],{"class":48},[31,3471,466],{"class":276},[31,3473,3474],{"class":48}," @LeverancierId ",[31,3476,1615],{"class":37},[31,3478,3479],{"class":33,"line":1499},[31,3480,3481],{"class":37},"            DELETE\n",[31,3483,3484,3487,3490,3493],{"class":33,"line":1513},[31,3485,3486],{"class":37},"        OUTPUT",[31,3488,3489],{"class":48}," $",[31,3491,3492],{"class":37},"action",[31,3494,95],{"class":48},[31,3496,3497,3500],{"class":33,"line":1518},[31,3498,3499],{"class":44},"               ISNULL",[31,3501,3502],{"class":48},"(inserted.ProductId, deleted.ProductId),\n",[31,3504,3505],{"class":33,"line":1526},[31,3506,3507],{"class":48},"               deleted.Aantal,\n",[31,3509,3510],{"class":33,"line":1532},[31,3511,3512],{"class":48},"               inserted.Aantal\n",[31,3514,3515,3518],{"class":33,"line":1540},[31,3516,3517],{"class":37},"        INTO",[31,3519,3520],{"class":48}," @Log (Actie, ProductId, OudAantal, NieuwAantal);\n",[31,3522,3523],{"class":33,"line":1567},[31,3524,1271],{"emptyLinePlaceholder":965},[31,3526,3527,3529,3531,3533,3535,3537,3540,3543,3546,3548,3550,3552,3554,3557,3559,3562,3564,3567],{"class":33,"line":1576},[31,3528,184],{"class":37},[31,3530,3040],{"class":48},[31,3532,466],{"class":276},[31,3534,2465],{"class":44},[31,3536,446],{"class":48},[31,3538,3539],{"class":37},"CASE",[31,3541,3542],{"class":37}," WHEN",[31,3544,3545],{"class":48}," Actie ",[31,3547,466],{"class":276},[31,3549,1154],{"class":276},[31,3551,1842],{"class":1157},[31,3553,1359],{"class":276},[31,3555,3556],{"class":37}," THEN",[31,3558,1302],{"class":37},[31,3560,3561],{"class":37}," ELSE",[31,3563,1095],{"class":37},[31,3565,3566],{"class":37}," END",[31,3568,1564],{"class":48},[31,3570,3571,3574,3576,3578,3580,3582,3584,3586,3588,3590,3592,3594,3596,3598,3600,3602,3604],{"class":33,"line":1581},[31,3572,3573],{"class":48},"               @Gewijzigd  ",[31,3575,466],{"class":276},[31,3577,2465],{"class":44},[31,3579,446],{"class":48},[31,3581,3539],{"class":37},[31,3583,3542],{"class":37},[31,3585,3545],{"class":48},[31,3587,466],{"class":276},[31,3589,1154],{"class":276},[31,3591,1951],{"class":1157},[31,3593,1359],{"class":276},[31,3595,3556],{"class":37},[31,3597,1302],{"class":37},[31,3599,3561],{"class":37},[31,3601,1095],{"class":37},[31,3603,3566],{"class":37},[31,3605,1564],{"class":48},[31,3607,3608,3611,3613,3615,3617,3619,3621,3623,3625,3627,3629,3631,3633,3635,3637,3639,3641],{"class":33,"line":1598},[31,3609,3610],{"class":48},"               @Verwijderd ",[31,3612,466],{"class":276},[31,3614,2465],{"class":44},[31,3616,446],{"class":48},[31,3618,3539],{"class":37},[31,3620,3542],{"class":37},[31,3622,3545],{"class":48},[31,3624,466],{"class":276},[31,3626,1154],{"class":276},[31,3628,19],{"class":1157},[31,3630,1359],{"class":276},[31,3632,3556],{"class":37},[31,3634,1302],{"class":37},[31,3636,3561],{"class":37},[31,3638,1095],{"class":37},[31,3640,3566],{"class":37},[31,3642,71],{"class":48},[31,3644,3645,3647],{"class":33,"line":1603},[31,3646,219],{"class":37},[31,3648,3649],{"class":48},"   @Log;\n",[31,3651,3652],{"class":33,"line":1618},[31,3653,1271],{"emptyLinePlaceholder":965},[31,3655,3656,3659,3662,3664],{"class":33,"line":1634},[31,3657,3658],{"class":37},"        IF",[31,3660,3661],{"class":48}," @DryRun ",[31,3663,466],{"class":276},[31,3665,493],{"class":37},[31,3667,3668,3671,3673],{"class":33,"line":1642},[31,3669,3670],{"class":37},"            ROLLBACK",[31,3672,3144],{"class":37},[31,3674,1058],{"class":48},[31,3676,3677],{"class":33,"line":1648},[31,3678,3679],{"class":37},"        ELSE\n",[31,3681,3682,3685,3687],{"class":33,"line":1661},[31,3683,3684],{"class":37},"            COMMIT",[31,3686,3144],{"class":37},[31,3688,1058],{"class":48},[31,3690,3691],{"class":33,"line":1669},[31,3692,1271],{"emptyLinePlaceholder":965},[31,3694,3695,3697,3699,3701],{"class":33,"line":1680},[31,3696,184],{"class":37},[31,3698,3040],{"class":48},[31,3700,162],{"class":37},[31,3702,3703],{"class":48}," Toegevoegd,\n",[31,3705,3706,3708,3710],{"class":33,"line":1685},[31,3707,3573],{"class":48},[31,3709,162],{"class":37},[31,3711,3712],{"class":48}," Gewijzigd,\n",[31,3714,3715,3717,3719],{"class":33,"line":1701},[31,3716,3610],{"class":48},[31,3718,162],{"class":37},[31,3720,3721],{"class":48}," Verwijderd;\n",[31,3723,3724,3726],{"class":33,"line":1709},[31,3725,1672],{"class":37},[31,3727,3136],{"class":37},[31,3729,3731,3733],{"class":33,"line":3730},67,[31,3732,3133],{"class":37},[31,3734,3735],{"class":37}," CATCH\n",[31,3737,3739,3741,3744,3746,3748],{"class":33,"line":3738},68,[31,3740,3658],{"class":37},[31,3742,3743],{"class":44}," XACT_STATE",[31,3745,288],{"class":48},[31,3747,689],{"class":276},[31,3749,2164],{"class":37},[31,3751,3753,3755,3757],{"class":33,"line":3752},69,[31,3754,3670],{"class":37},[31,3756,3144],{"class":37},[31,3758,1058],{"class":48},[31,3760,3762],{"class":33,"line":3761},70,[31,3763,1271],{"emptyLinePlaceholder":965},[31,3765,3767,3770],{"class":33,"line":3766},71,[31,3768,3769],{"class":37},"        INSERT INTO",[31,3771,3772],{"class":48}," dbo.SyncFouten (LeverancierId, BatchId, Foutmelding, OpgetredenOp)\n",[31,3774,3776,3779,3782,3785,3788,3791],{"class":33,"line":3775},72,[31,3777,3778],{"class":37},"        VALUES",[31,3780,3781],{"class":48}," (@LeverancierId, @BatchId, ",[31,3783,3784],{"class":44},"ERROR_MESSAGE",[31,3786,3787],{"class":48},"(), ",[31,3789,3790],{"class":44},"SYSUTCDATETIME",[31,3792,3793],{"class":48},"());\n",[31,3795,3797],{"class":33,"line":3796},73,[31,3798,1271],{"emptyLinePlaceholder":965},[31,3800,3802,3805],{"class":33,"line":3801},74,[31,3803,3804],{"class":37},"        THROW",[31,3806,1058],{"class":48},[31,3808,3810,3812],{"class":33,"line":3809},75,[31,3811,1672],{"class":37},[31,3813,3735],{"class":37},[31,3815,3817,3819],{"class":33,"line":3816},76,[31,3818,1704],{"class":37},[31,3820,1058],{"class":48},[519,3822,3823,3834],{"level":521},[523,3824,3825],{"v-slot:question":27},[10,3826,1718,3827,2596,3830,3833],{},[17,3828,3829],{},"WHEN NOT MATCHED BY SOURCE",[17,3831,3832],{},"AND doel.LeverancierId = @LeverancierId","\nachter?",[523,3835,3836,3841,3855],{"v-slot:answer":27},[10,3837,3838,3840],{},[17,3839,3829],{}," gaat over rijen die wél in de doeltabel staan maar niet in de bron\nvoorkomen. Hier betekent dat: dit product zat niet in de aangeleverde batch, dus de leverancier voert\nhet niet meer — verwijderen.",[10,3842,3843,3844,3846,3847,3850,3851,3854],{},"Die extra conditie is geen overbodige herhaling maar de belangrijkste regel van het hele statement.\nDe ",[17,3845,882],{},"-clausule bepaalt alleen hoe bron- en doelrijen aan elkaar gekoppeld worden; hij beperkt ",[920,3848,3849],{},"niet","\nwelke doelrijen ",[17,3852,3853],{},"NOT MATCHED BY SOURCE"," in overweging neemt. Dat is de hele tabel.",[10,3856,3857,3858,3860,3861,3864,3865,3867],{},"Zonder ",[17,3859,3832],{}," zou dit statement dus ",[920,3862,3863],{},"de voorraad van álle andere\nleveranciers verwijderen"," bij elke synchronisatie. Dat is een van de bekendste manieren om met ",[17,3866,2936],{},"\nin één commit een tabel leeg te trekken.",[519,3869,3871,3879],{"level":3870},"Concurrency",[523,3872,3873],{"v-slot:question":27},[10,3874,3875,3878],{},[17,3876,3877],{},"WITH (HOLDLOCK)"," staat op de doeltabel. Dat is geen performancehint. Waar dient hij voor?",[523,3880,3881,3891,3903,3912],{"v-slot:answer":27},[10,3882,3883,3886,3887,3890],{},[17,3884,3885],{},"HOLDLOCK"," is in dit verband synoniem met het isolatieniveau ",[17,3888,3889],{},"SERIALIZABLE"," voor deze ene tabel, en hij\nlost een echte race op.",[10,3892,3893,3895,3896,3899,3900,3902],{},[17,3894,2936],{}," bepaalt eerst welke rijen matchen en voert daarna de acties uit. Onder de standaard\n",[17,3897,3898],{},"READ COMMITTED"," zit er tussen die twee stappen een gaatje. Draaien twee synchronisaties tegelijk voor\nhetzelfde product, dan kunnen ze allebei concluderen \"bestaat nog niet, dus ",[17,3901,1842],{},"\", waarna de tweede\nknalt op een primary key-schending — of, erger, een duplicaat maakt als die sleutel er niet is.",[10,3904,3905,3907,3908,3911],{},[17,3906,3885],{}," laat SQL Server ",[948,3909,3910],{},"range locks"," nemen op de sleutelbereiken die het onderzoekt, inclusief de\ngaten waar nog niets staat. Daarmee kan een tweede transactie er niets tussen schuiven en is de\ncombinatie zoeken-plus-schrijven werkelijk atomair.",[10,3913,3914,3915,3917,3918,3920],{},"Dit is geen randgeval maar de standaardaanbeveling: een ",[17,3916,2936],{}," die kan invoegen hoort altijd\n",[17,3919,3885],{}," (of een strenger isolatieniveau) te hebben. Het is een van de weinige plekken waar een\nlocking-hint niet verdacht is maar verplicht.",[519,3922,3924,3936],{"level":3923},"Dode code",[523,3925,3926],{"v-slot:question":27},[10,3927,3928,3929,3932,3933,3935],{},"Er zit een ",[17,3930,3931],{},"ROW_NUMBER() OVER (PARTITION BY s.ProductId ...)"," in de CTE, en de bron filtert op ",[17,3934,559],{},".\nWat doet dat filter in de praktijk?",[523,3937,3938,3941,3962,3975],{"v-slot:answer":27},[10,3939,3940],{},"Niets. Dit is de dode code.",[10,3942,3943,3944,3947,3948,3951,3952,3954,3955,3957,3958,3961],{},"De CTE bevat ",[17,3945,3946],{},"GROUP BY s.ProductId",", dus er is per definitie al precies één rij per ",[17,3949,3950],{},"ProductId",". De\n",[17,3953,609],{}," partitioneert vervolgens óók op ",[17,3956,3950],{}," — in een partitie van één rij is het\nrijnummer altijd 1. Het filter ",[17,3959,3960],{},"WHERE rn = 1"," gooit dus nooit iets weg.",[10,3963,3964,3965,3967,3968,3970,3971,3974],{},"Dit patroon ontstaat vrijwel altijd op dezelfde manier: eerst was er geen ",[17,3966,2775],{}," en had je echt\nmeerdere regels per product, waarbij ",[17,3969,609],{}," de nieuwste koos. Later kwam de ",[17,3972,3973],{},"SUM()"," erbij om\nregels op te tellen, en bleef de oude ontdubbeling staan. Twee ontdubbelingsstrategieën over elkaar\nheen, waarvan er één stilletjes niets meer doet.",[10,3976,3977],{},"Waarom het uitmaakt: de volgende lezer denkt dat er iets ontdubbeld wordt en dat de nieuwste regel\nwint. In werkelijkheid worden alle regels bij elkáár opgeteld. Dat is een heel ander antwoord op de\nvraag \"wat is de voorraad\", en de code suggereert het verkeerde.",[519,3979,3981,3990],{"level":3980},"Dataverlies",[523,3982,3983],{"v-slot:question":27},[10,3984,3985,3986,3989],{},"De bronfilter bevat ",[17,3987,3988],{},"AND s.Aantal >= 0",". Wat gebeurt er met een product waarvan de leverancier in deze\nbatch uitsluitend negatieve aantallen aanlevert?",[523,3991,3992,3995,4006,4009,4016],{"v-slot:answer":27},[10,3993,3994],{},"Dat product wordt verwijderd.",[10,3996,3997,3998,4000,4001,4003,4004,708],{},"De redenering loopt zo: de filter gooit alle regels van dat product weg → het product komt niet meer\nvoor in de CTE → het staat dus niet in de bron van de ",[17,3999,2936],{}," → ",[17,4002,3829],{}," grijpt in\n→ ",[17,4005,19],{},[10,4007,4008],{},"Een filter die bedoeld was om rommel te negeren, verandert daardoor in een verwijderopdracht. De\nbedoeling was \"sla ongeldige regels over\"; het effect is \"voer dit product niet meer\".",[10,4010,4011,4012,4015],{},"De ernst zit in de stilte. Er komt geen foutmelding, geen waarschuwing, en de teruggegeven aantallen\nkloppen technisch gezien — er ",[948,4013,4014],{},"is"," immers een rij verwijderd. Pas als een verkoper meldt dat een artikel\nuit de webshop verdwenen is, gaat iemand zoeken.",[10,4017,4018,4019,4022,4023,4025,4026],{},"Ongeldige invoer hoort te leiden tot afwijzing van de batch of tot een expliciete melding, niet tot\nstilzwijgend overslaan in een statement dat ook mag verwijderen. Wie ",[17,4020,4021],{},"NOT MATCHED BY SOURCE ... DELETE","\ngebruikt, moet elke ",[17,4024,460],{}," in de bron lezen als: ",[948,4027,4028],{},"deze conditie kan rijen verwijderen.",[519,4030,4031,4042],{"level":1808},[523,4032,4033],{"v-slot:question":27},[10,4034,2760,4035,4038,4039,4041],{},[17,4036,4037],{},"@DryRun = 1"," wordt de transactie teruggedraaid. Toch bevat de daaropvolgende ",[17,4040,440],{}," nog de juiste\naantallen. Hoe kan dat?",[523,4043,4044,4056,4069,4080],{"v-slot:answer":27},[10,4045,686,4046,2655,4049,4052,4053,708],{},[17,4047,4048],{},"@Log",[920,4050,4051],{},"tabelvariabele"," is, en tabelvariabelen doen niet mee aan transacties. Wijzigingen\nerin worden niet teruggedraaid door een ",[17,4054,4055],{},"ROLLBACK",[10,4057,4058,4059,4061,4062,4065,4066,4068],{},"Daar leunt de dry-run precies op: de ",[17,4060,2936],{}," schrijft via ",[17,4063,4064],{},"OUTPUT ... INTO @Log"," weg wat hij zou doen,\nde ",[17,4067,4055],{}," maakt alle echte wijzigingen ongedaan, en de telling blijft staan. Zo krijg je een\nbetrouwbaar \"dit zou er gebeuren\" zonder iets te wijzigen.",[10,4070,4071,4072,4075,4076,4079],{},"Had hier een tijdelijke tabel (",[17,4073,4074],{},"#Log",") gestaan, dan was dit stukgegaan: ",[17,4077,4078],{},"#","-tabellen zijn wél\ntransactioneel, dus na de rollback was de inhoud weg en had je drie nullen gerapporteerd.",[10,4081,4082],{},"Hetzelfde gedrag is elders juist een bekende voetangel — mensen verwachten dat een rollback álles\nterugdraait — maar hier is het bewust en correct gebruikt.",[519,4084,4085,4105],{"level":1968},[523,4086,4087],{"v-slot:question":27},[10,4088,4089,4090,4093,4094,2878,4097,4100,4101,4104],{},"In het ",[17,4091,4092],{},"CATCH","-blok staat ",[17,4095,4096],{},"IF XACT_STATE() \u003C> 0",[17,4098,4099],{},"IF @@TRANCOUNT > 0",". Waarom, gegeven\n",[17,4102,4103],{},"SET XACT_ABORT ON"," bovenaan?",[523,4106,4107,4117,4139,4164,4177],{"v-slot:answer":27},[10,4108,4109,4112,4113,4116],{},[17,4110,4111],{},"@@TRANCOUNT"," telt alleen hoeveel transacties er open zijn. ",[17,4114,4115],{},"XACT_STATE()"," zegt of je er nog iets mee\nmág, en dat is hier de relevante vraag. Er zijn drie waarden:",[602,4118,4119,4124,4134],{},[605,4120,4121,4123],{},[17,4122,1231],{}," — actieve transactie, commit of rollback mag allebei",[605,4125,4126,4129,4130,4133],{},[17,4127,4128],{},"-1"," — transactie bestaat nog maar is ",[920,4131,4132],{},"niet meer te committen","; alleen rollback is toegestaan",[605,4135,4136,4138],{},[17,4137,1243],{}," — geen transactie",[10,4140,4141,4143,4144,4146,4147,4149,4150,4152,4153,4156,4157,4159,4160,4163],{},[17,4142,4103],{}," zorgt ervoor dat vrijwel elke runtime-fout de transactie in die ",[17,4145,4128],{},"-toestand brengt.\n",[17,4148,4111],{}," is dan nog steeds groter dan nul, dus die check zou hier ook rollbacken — maar hij vertelt\nje niet of committen nog kon. ",[17,4151,4115],{}," dekt bovendien het omgekeerde geval af: bij ",[17,4154,4155],{},"XACT_ABORT ON","\nkan de transactie al automatisch teruggedraaid zijn voordat het ",[17,4158,4092],{},"-blok begint, en dan levert een\n",[17,4161,4162],{},"ROLLBACK TRANSACTION"," zelf een fout op omdat er niets meer is om terug te draaien.",[10,4165,4166,4167,4170,4171,1843,4173,4176],{},"Even belangrijk is de ",[920,4168,4169],{},"volgorde"," in dit blok: eerst rollbacken, dán de fout wegschrijven. Zou de\n",[17,4172,1842],{},[17,4174,4175],{},"dbo.SyncFouten"," vóór de rollback staan, dan zou hij binnen de mislukte transactie vallen en\nmee teruggedraaid worden — of meteen falen, want in een niet-committeerbare transactie mag je niets meer\nschrijven. Je foutlogtabel zou leeg blijven precies wanneer je hem nodig hebt.",[10,4178,4179,4180,4183],{},"De afsluitende ",[17,4181,4182],{},"THROW"," gooit de originele fout door met nummer en regelnummer intact, zodat de aanroeper\nmerkt dat het misging. Dat is het verschil tussen loggen en verbergen.",[519,4185,4186,4191],{"level":755},[523,4187,4188],{"v-slot:question":27},[10,4189,4190],{},"De batch bevat geen enkele wijziging: alle aantallen zijn al gelijk, er is niets toe te voegen of te\nverwijderen. Wat retourneert de procedure?",[523,4192,4193,4199,4223,4236],{"v-slot:answer":27},[10,4194,4195,4196,4198],{},"Drie keer ",[17,4197,693],{},", niet drie keer 0.",[10,4200,4201,4203,4204,4207,4208,4210,4211,4214,4215,936,4217,4219,4220,708],{},[17,4202,4048],{}," blijft leeg, want de ",[17,4205,4206],{},"OUTPUT","-clausule schrijft alleen weg wat de ",[17,4209,2936],{}," daadwerkelijk deed.\nEen ",[17,4212,4213],{},"SELECT @Var = SUM(...) FROM lege_tabel"," levert één rij op waarin ",[17,4216,3973],{},[17,4218,693],{}," teruggeeft — de\ntoewijzing gebeurt dus wél, en overschrijft de initialisatie op 0 uit de ",[17,4221,4222],{},"DECLARE",[10,4224,4225,4226,4229,4230,4232,4233,4235],{},"Dat is precies het valse gevoel van veiligheid dat ",[17,4227,4228],{},"DECLARE @Toegevoegd INT = 0"," geeft: die 0 overleeft\nalleen als de ",[17,4231,440],{}," helemaal geen rij oplevert. Met een aggregatie zonder ",[17,4234,2775],{}," is er altijd een\nrij.",[10,4237,4238,4239,4241,4242,4245,4246,4248,4249,4252,4253,2878,4255,708],{},"Een aanroepende applicatie die deze waarden optelt of vergelijkt, krijgt hier ",[17,4240,693],{}," binnen, en in T-SQL\nis ",[17,4243,4244],{},"NULL + 1"," weer ",[17,4247,693],{},". Zo'n fout plant zich voort tot iemand een lege monitoringgrafiek ziet.\nOplossing: ",[17,4250,4251],{},"ISNULL(SUM(...), 0)",", of tellen met ",[17,4254,848],{},[17,4256,4257],{},"SUM(CASE ...)",[519,4259,4260,4278],{"level":2030},[523,4261,4262],{"v-slot:question":27},[10,4263,922,4264,4266,4267,4270,4271,588,4274,4277],{},[17,4265,4206],{},"-clausule gebruikt ",[17,4268,4269],{},"ISNULL(inserted.ProductId, deleted.ProductId)"," voor het product, maar\nschrijft ",[17,4272,4273],{},"deleted.Aantal",[17,4275,4276],{},"inserted.Aantal"," rechtstreeks weg. Waarom is dat verschil er, en is het\ncorrect?",[523,4279,4280,4283,4299,4363,4384,4409],{"v-slot:answer":27},[10,4281,4282],{},"Het is correct, en het verschil is doelbewust.",[10,4284,4285,4286,4288,4289,4291,4292,588,4295,4298],{},"In een ",[17,4287,4206],{}," bij een ",[17,4290,2936],{}," zijn ",[17,4293,4294],{},"inserted",[17,4296,4297],{},"deleted"," allebei beschikbaar, maar wat erin staat\nhangt af van de actie voor die rij:",[4300,4301,4302,4320],"table",{},[4303,4304,4305],"thead",{},[4306,4307,4308,4312,4316],"tr",{},[4309,4310,4311],"th",{},"Actie",[4309,4313,4314],{},[17,4315,4294],{},[4309,4317,4318],{},[17,4319,4297],{},[4321,4322,4323,4338,4350],"tbody",{},[4306,4324,4325,4330,4333],{},[4326,4327,4328],"td",{},[17,4329,1842],{},[4326,4331,4332],{},"de nieuwe rij",[4326,4334,4335,4336],{},"alles ",[17,4337,693],{},[4306,4339,4340,4344,4347],{},[4326,4341,4342],{},[17,4343,1951],{},[4326,4345,4346],{},"de nieuwe waarden",[4326,4348,4349],{},"de oude waarden",[4306,4351,4352,4356,4360],{},[4326,4353,4354],{},[17,4355,19],{},[4326,4357,4335,4358],{},[17,4359,693],{},[4326,4361,4362],{},"de verwijderde rij",[10,4364,4365,4366,4368,4369,4372,4373,4375,4376,4378,4379,4381,4382,708],{},"Voor ",[17,4367,3950],{}," wil je altijd een waarde hebben, ongeacht de actie — dat is de sleutel waarmee je de\nlogregel kunt terugvinden. Vandaar de ",[17,4370,4371],{},"ISNULL",": bij een ",[17,4374,1842],{}," komt hij uit ",[17,4377,4294],{},", bij een\n",[17,4380,19],{}," uit ",[17,4383,4297],{},[10,4385,4386,4387,737,4389,4392,4393,4395,4396,4392,4399,4401,4402,4404,4405,4408],{},"Voor de aantallen wil je juist géén ",[17,4388,4371],{},[17,4390,4391],{},"OudAantal"," moet leeg zijn bij een ",[17,4394,1842],{}," (er wás geen\noud aantal) en ",[17,4397,4398],{},"NieuwAantal",[17,4400,19],{}," (er ís geen nieuw aantal). De ",[17,4403,693],{}," is hier\nde informatie, niet een gebrek eraan. Zou je ze met ",[17,4406,4407],{},"ISNULL(..., 0)"," opvullen, dan lees je later in het\nlog dat een product naar 0 ging terwijl het in werkelijkheid verwijderd is — twee heel verschillende\ngebeurtenissen die er dan identiek uitzien.",[10,4410,4411,4412,4415,4416,4418,4419,4422],{},"Terzijde: ",[17,4413,4414],{},"$action"," is de enige betrouwbare manier om te weten wélke tak een rij raakte. Afleiden uit\n\"is ",[17,4417,4297],{}," leeg?\" werkt toevallig ook, maar breekt zodra iemand een ",[17,4420,4421],{},"WHEN MATCHED AND ... THEN DELETE","\ntoevoegt.",[953,4424,4425],{},"html pre.shiki code .sbssI, html code.shiki .sbssI{--shiki-light:#F76D47;--shiki-default:#F78C6C;--shiki-dark:#F78C6C}html pre.shiki code .sTEyZ, html code.shiki .sTEyZ{--shiki-light:#90A4AE;--shiki-default:#EEFFFF;--shiki-dark:#BABED8}html pre.shiki code .spNyl, html code.shiki .spNyl{--shiki-light:#9C3EDA;--shiki-default:#C792EA;--shiki-dark:#C792EA}html pre.shiki code .sMK4o, html code.shiki .sMK4o{--shiki-light:#39ADB5;--shiki-default:#89DDFF;--shiki-dark:#89DDFF}html pre.shiki code .s2Zo4, html code.shiki .s2Zo4{--shiki-light:#6182B8;--shiki-default:#82AAFF;--shiki-dark:#82AAFF}html pre.shiki code .sfazB, html code.shiki .sfazB{--shiki-light:#91B859;--shiki-default:#C3E88D;--shiki-dark:#C3E88D}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":27,"searchDepth":52,"depth":52,"links":4427},[],"Eén MERGE-statement dat invoegt, bijwerkt én verwijdert, met een dry-run en een CATCH-blok. Er zit dode code in, een dataverliesrisico en een subtiele reden waarom HOLDLOCK er staat.","T-SQL",{},"\u002Fpracticely-code\u002Fvoorraadsynchronisatie-merge",{"title":2929,"description":4428},"practicely-code\u002Fvoorraadsynchronisatie-merge","Datasynchronisatie","zAVBrG9vBORm_6lasr9KkmDt2-4RTqwkYTKfBqriI-U",{"id":4437,"title":4438,"body":4439,"date":958,"description":5630,"dialect":960,"extension":961,"language":962,"level":2030,"meta":5631,"navigation":965,"path":5632,"questions":119,"seo":5633,"stem":5634,"topic":5635,"__hash__":5636},"practicelyCode\u002Fpracticely-code\u002Faudit-trigger-valkuilen.md","Audit-trigger — vijf manieren waarop je logtabel liegt",{"type":7,"value":4440,"toc":5628},[4441,4444,4447,4873,5009,5131,5191,5308,5447,5497,5548,5625],[10,4442,4443],{},"\"We willen kunnen zien wie wat wanneer gewijzigd heeft.\" Vrijwel elk systeem krijgt vroeg of laat zo'n\ntrigger. Deze ziet er compleet uit: hij slaat oude en nieuwe waarde op als JSONB, berekent welke\nvelden veranderd zijn, en registreert de gebruiker.",[10,4445,4446],{},"Hij zit vol gaten. Eén ervan laat de trigger hard crashen; de andere zorgen ervoor dat de logtabel er\ngevuld uitziet terwijl hij het verkeerde verhaal vertelt. Dat tweede is erger — een crash merk je.",[22,4448,4450],{"className":24,"code":4449,"language":26,"meta":27,"style":27},"CREATE OR REPLACE FUNCTION audit.log_wijziging()\nRETURNS trigger\nLANGUAGE plpgsql\nSECURITY DEFINER\nAS $$\nDECLARE\n    v_oud       jsonb;\n    v_nieuw     jsonb;\n    v_gewijzigd text[];\nBEGIN\n    IF TG_OP = 'DELETE' THEN\n        v_oud   := to_jsonb(OLD);\n        v_nieuw := NULL;\n    ELSIF TG_OP = 'INSERT' THEN\n        v_oud   := NULL;\n        v_nieuw := to_jsonb(NEW);\n    ELSE\n        v_oud   := to_jsonb(OLD);\n        v_nieuw := to_jsonb(NEW);\n\n        SELECT array_agg(n.sleutel)\n        INTO   v_gewijzigd\n        FROM   jsonb_each(v_nieuw) AS n(sleutel, waarde)\n        WHERE  v_oud -> n.sleutel IS DISTINCT FROM n.waarde;\n\n        IF v_gewijzigd IS NULL THEN\n            RETURN NEW;\n        END IF;\n    END IF;\n\n    INSERT INTO audit.wijzigingen\n           (tabel, record_id, actie, oude_waarde, nieuwe_waarde,\n            gewijzigde_velden, gebruiker, tijdstip)\n    VALUES (TG_TABLE_NAME,\n            COALESCE(NEW.id, OLD.id),\n            TG_OP,\n            v_oud,\n            v_nieuw,\n            v_gewijzigd,\n            current_setting('app.gebruiker_id', true),\n            now());\n\n    RETURN COALESCE(NEW, OLD);\nEND;\n$$;\n\nCREATE TRIGGER klanten_audit\n    AFTER INSERT OR UPDATE OR DELETE ON crm.klanten\n    FOR EACH ROW EXECUTE FUNCTION audit.log_wijziging();\n",[17,4451,4452,4467,4474,4480,4488,4494,4498,4503,4508,4518,4522,4540,4550,4561,4576,4586,4595,4600,4608,4616,4620,4627,4634,4646,4668,4672,4685,4693,4701,4709,4713,4721,4726,4731,4739,4747,4752,4757,4762,4767,4782,4791,4795,4805,4811,4815,4819,4829,4851],{"__ignoreMap":27},[31,4453,4454,4456,4458,4461,4464],{"class":33,"line":34},[31,4455,38],{"class":37},[31,4457,41],{"class":37},[31,4459,4460],{"class":44}," audit",[31,4462,4463],{"class":48},".log_wijziging",[31,4465,4466],{"class":276},"()\n",[31,4468,4469,4471],{"class":33,"line":52},[31,4470,77],{"class":37},[31,4472,4473],{"class":48}," trigger\n",[31,4475,4476,4478],{"class":33,"line":68},[31,4477,147],{"class":37},[31,4479,1032],{"class":48},[31,4481,4482,4485],{"class":33,"line":74},[31,4483,4484],{"class":37},"SECURITY",[31,4486,4487],{"class":48}," DEFINER\n",[31,4489,4490,4492],{"class":33,"line":86},[31,4491,162],{"class":37},[31,4493,165],{"class":48},[31,4495,4496],{"class":33,"line":98},[31,4497,1043],{"class":37},[31,4499,4500],{"class":33,"line":108},[31,4501,4502],{"class":48},"    v_oud       jsonb;\n",[31,4504,4505],{"class":33,"line":119},[31,4506,4507],{"class":48},"    v_nieuw     jsonb;\n",[31,4509,4510,4513,4515],{"class":33,"line":130},[31,4511,4512],{"class":48},"    v_gewijzigd ",[31,4514,114],{"class":58},[31,4516,4517],{"class":48},"[];\n",[31,4519,4520],{"class":33,"line":139},[31,4521,1117],{"class":37},[31,4523,4524,4527,4530,4532,4534,4536,4538],{"class":33,"line":144},[31,4525,4526],{"class":37},"    IF",[31,4528,4529],{"class":48}," TG_OP ",[31,4531,466],{"class":276},[31,4533,1154],{"class":276},[31,4535,19],{"class":1157},[31,4537,1359],{"class":276},[31,4539,1286],{"class":37},[31,4541,4542,4545,4547],{"class":33,"line":153},[31,4543,4544],{"class":48},"        v_oud   :",[31,4546,466],{"class":276},[31,4548,4549],{"class":48}," to_jsonb(OLD);\n",[31,4551,4552,4555,4557,4559],{"class":33,"line":159},[31,4553,4554],{"class":48},"        v_nieuw :",[31,4556,466],{"class":276},[31,4558,1415],{"class":37},[31,4560,1058],{"class":48},[31,4562,4563,4566,4568,4570,4572,4574],{"class":33,"line":168},[31,4564,4565],{"class":48},"    ELSIF TG_OP ",[31,4567,466],{"class":276},[31,4569,1154],{"class":276},[31,4571,1842],{"class":1157},[31,4573,1359],{"class":276},[31,4575,1286],{"class":37},[31,4577,4578,4580,4582,4584],{"class":33,"line":181},[31,4579,4544],{"class":48},[31,4581,466],{"class":276},[31,4583,1415],{"class":37},[31,4585,1058],{"class":48},[31,4587,4588,4590,4592],{"class":33,"line":190},[31,4589,4554],{"class":48},[31,4591,466],{"class":276},[31,4593,4594],{"class":48}," to_jsonb(NEW);\n",[31,4596,4597],{"class":33,"line":196},[31,4598,4599],{"class":37},"    ELSE\n",[31,4601,4602,4604,4606],{"class":33,"line":210},[31,4603,4544],{"class":48},[31,4605,466],{"class":276},[31,4607,4549],{"class":48},[31,4609,4610,4612,4614],{"class":33,"line":216},[31,4611,4554],{"class":48},[31,4613,466],{"class":276},[31,4615,4594],{"class":48},[31,4617,4618],{"class":33,"line":225},[31,4619,1271],{"emptyLinePlaceholder":965},[31,4621,4622,4624],{"class":33,"line":237},[31,4623,184],{"class":37},[31,4625,4626],{"class":48}," array_agg(n.sleutel)\n",[31,4628,4629,4631],{"class":33,"line":252},[31,4630,3517],{"class":37},[31,4632,4633],{"class":48},"   v_gewijzigd\n",[31,4635,4636,4638,4641,4643],{"class":33,"line":258},[31,4637,219],{"class":37},[31,4639,4640],{"class":48},"   jsonb_each(v_nieuw) ",[31,4642,162],{"class":37},[31,4644,4645],{"class":48}," n(sleutel, waarde)\n",[31,4647,4648,4650,4653,4656,4659,4661,4663,4665],{"class":33,"line":268},[31,4649,228],{"class":37},[31,4651,4652],{"class":48},"  v_oud ",[31,4654,4655],{"class":276},"->",[31,4657,4658],{"class":48}," n.sleutel ",[31,4660,246],{"class":37},[31,4662,425],{"class":37},[31,4664,428],{"class":37},[31,4666,4667],{"class":48}," n.waarde;\n",[31,4669,4670],{"class":33,"line":282},[31,4671,1271],{"emptyLinePlaceholder":965},[31,4673,4674,4676,4679,4681,4683],{"class":33,"line":306},[31,4675,3658],{"class":37},[31,4677,4678],{"class":48}," v_gewijzigd ",[31,4680,246],{"class":37},[31,4682,1415],{"class":37},[31,4684,1286],{"class":37},[31,4686,4687,4690],{"class":33,"line":320},[31,4688,4689],{"class":37},"            RETURN",[31,4691,4692],{"class":48}," NEW;\n",[31,4694,4695,4697,4699],{"class":33,"line":339},[31,4696,1664],{"class":37},[31,4698,1319],{"class":37},[31,4700,1058],{"class":48},[31,4702,4703,4705,4707],{"class":33,"line":352},[31,4704,1672],{"class":37},[31,4706,1319],{"class":37},[31,4708,1058],{"class":48},[31,4710,4711],{"class":33,"line":370},[31,4712,1271],{"emptyLinePlaceholder":965},[31,4714,4715,4718],{"class":33,"line":382},[31,4716,4717],{"class":37},"    INSERT INTO",[31,4719,4720],{"class":48}," audit.wijzigingen\n",[31,4722,4723],{"class":33,"line":390},[31,4724,4725],{"class":48},"           (tabel, record_id, actie, oude_waarde, nieuwe_waarde,\n",[31,4727,4728],{"class":33,"line":396},[31,4729,4730],{"class":48},"            gewijzigde_velden, gebruiker, tijdstip)\n",[31,4732,4733,4736],{"class":33,"line":405},[31,4734,4735],{"class":37},"    VALUES",[31,4737,4738],{"class":48}," (TG_TABLE_NAME,\n",[31,4740,4741,4744],{"class":33,"line":411},[31,4742,4743],{"class":44},"            COALESCE",[31,4745,4746],{"class":48},"(NEW.id, OLD.id),\n",[31,4748,4749],{"class":33,"line":417},[31,4750,4751],{"class":48},"            TG_OP,\n",[31,4753,4754],{"class":33,"line":434},[31,4755,4756],{"class":48},"            v_oud,\n",[31,4758,4759],{"class":33,"line":472},[31,4760,4761],{"class":48},"            v_nieuw,\n",[31,4763,4764],{"class":33,"line":481},[31,4765,4766],{"class":48},"            v_gewijzigd,\n",[31,4768,4769,4772,4774,4777,4779],{"class":33,"line":496},[31,4770,4771],{"class":48},"            current_setting(",[31,4773,1359],{"class":276},[31,4775,4776],{"class":1157},"app.gebruiker_id",[31,4778,1359],{"class":276},[31,4780,4781],{"class":48},", true),\n",[31,4783,4784,4787,4789],{"class":33,"line":505},[31,4785,4786],{"class":37},"            now",[31,4788,1656],{"class":276},[31,4790,1080],{"class":48},[31,4792,4793],{"class":33,"line":514},[31,4794,1271],{"emptyLinePlaceholder":965},[31,4796,4797,4800,4802],{"class":33,"line":1452},[31,4798,4799],{"class":37},"    RETURN",[31,4801,1237],{"class":44},[31,4803,4804],{"class":48},"(NEW, OLD);\n",[31,4806,4807,4809],{"class":33,"line":1461},[31,4808,1704],{"class":37},[31,4810,1058],{"class":48},[31,4812,4813],{"class":33,"line":1489},[31,4814,517],{"class":48},[31,4816,4817],{"class":33,"line":1499},[31,4818,1271],{"emptyLinePlaceholder":965},[31,4820,4821,4823,4826],{"class":33,"line":1513},[31,4822,996],{"class":37},[31,4824,4825],{"class":37}," TRIGGER",[31,4827,4828],{"class":44}," klanten_audit\n",[31,4830,4831,4834,4837,4839,4841,4843,4846,4848],{"class":33,"line":1518},[31,4832,4833],{"class":37},"    AFTER",[31,4835,4836],{"class":37}," INSERT",[31,4838,999],{"class":37},[31,4840,1190],{"class":37},[31,4842,999],{"class":37},[31,4844,4845],{"class":37}," DELETE",[31,4847,3015],{"class":37},[31,4849,4850],{"class":48}," crm.klanten\n",[31,4852,4853,4855,4858,4861,4864,4866,4869,4871],{"class":33,"line":1526},[31,4854,1122],{"class":37},[31,4856,4857],{"class":48}," EACH ",[31,4859,4860],{"class":37},"ROW",[31,4862,4863],{"class":37}," EXECUTE",[31,4865,41],{"class":37},[31,4867,4868],{"class":48}," audit.log_wijziging",[31,4870,1656],{"class":276},[31,4872,1058],{"class":48},[519,4874,4875,4897],{"level":521},[523,4876,4877],{"v-slot:question":27},[10,4878,4879,544,4882,588,4885,4888,4889,4891,4892,4894,4895,535],{},[17,4880,4881],{},"TG_OP",[17,4883,4884],{},"OLD",[17,4886,4887],{},"NEW"," zijn automatisch beschikbaar in een triggerfunctie. Wat bevat elk van de drie\nbij een ",[17,4890,1842],{},", een ",[17,4893,1951],{}," en een ",[17,4896,19],{},[523,4898,4899,4916,4923,4975,5003],{"v-slot:answer":27},[10,4900,4901,4903,4904,544,4907,544,4910,734,4913,708],{},[17,4902,4881],{}," is een tekstwaarde met de operatie: ",[17,4905,4906],{},"'INSERT'",[17,4908,4909],{},"'UPDATE'",[17,4911,4912],{},"'DELETE'",[17,4914,4915],{},"'TRUNCATE'",[10,4917,4918,588,4920,4922],{},[17,4919,4884],{},[17,4921,4887],{}," zijn recordvariabelen met de rij vóór en ná de wijziging:",[4300,4924,4925,4940],{},[4303,4926,4927],{},[4306,4928,4929,4932,4936],{},[4309,4930,4931],{},"Operatie",[4309,4933,4934],{},[17,4935,4884],{},[4309,4937,4938],{},[17,4939,4887],{},[4321,4941,4942,4953,4965],{},[4306,4943,4944,4948,4951],{},[4326,4945,4946],{},[17,4947,1842],{},[4326,4949,4950],{},"niet toegekend",[4326,4952,4332],{},[4306,4954,4955,4959,4962],{},[4326,4956,4957],{},[17,4958,1951],{},[4326,4960,4961],{},"de rij vóór de wijziging",[4326,4963,4964],{},"de rij erna",[4306,4966,4967,4971,4973],{},[4326,4968,4969],{},[17,4970,19],{},[4326,4972,4362],{},[4326,4974,4950],{},[10,4976,4977,4978,544,4981,544,4984,294,4987,1248,4990,4993,4994,294,4997,1248,4999,5002],{},"Naast deze drie zijn er onder meer ",[17,4979,4980],{},"TG_TABLE_NAME",[17,4982,4983],{},"TG_TABLE_SCHEMA",[17,4985,4986],{},"TG_WHEN",[17,4988,4989],{},"BEFORE",[17,4991,4992],{},"AFTER",") en\n",[17,4995,4996],{},"TG_LEVEL",[17,4998,4860],{},[17,5000,5001],{},"STATEMENT",").",[10,5004,5005,5006,5008],{},"Het woord ",[920,5007,4950],{}," is hier belangrijker dan het lijkt, en de volgende vraag gaat daarover.\nHet is namelijk niet hetzelfde als \"leeg\".",[519,5010,5012,5031],{"level":5011},"Crash",[523,5013,5014],{"v-slot:question":27},[10,5015,5016,5017,5020,5021,5024,5025,5028,5029,535],{},"Er staat ",[17,5018,5019],{},"COALESCE(NEW.id, OLD.id)",". De bedoeling is duidelijk: pak ",[17,5022,5023],{},"NEW.id",", en als die er niet is,\n",[17,5026,5027],{},"OLD.id",". Werkt dat bij een ",[17,5030,19],{},[523,5032,5033,5036,5047,5054,5077,5086,5121],{"v-slot:answer":27},[10,5034,5035],{},"Nee. Dit is de bug die de trigger hard laat crashen.",[10,5037,5038,5039,749,5041,5043,5044,5046],{},"Bij een ",[17,5040,19],{},[17,5042,4887],{}," niet toegekend, en dat is iets anders dan ",[17,5045,693],{},". PostgreSQL geeft:",[22,5048,5052],{"className":5049,"code":5051,"language":114},[5050],"language-text","ERROR: record \"new\" is not assigned yet\nDETAIL: The tuple structure of a not-yet-assigned record is indeterminate.\n",[17,5053,5051],{"__ignoreMap":27},[10,5055,5056,5057,5060,5061,5063,5064,5066,5067,5069,5070,5073,5074,5076],{},"De reden dat ",[17,5058,5059],{},"COALESCE"," niet redt: die evalueert eerst zijn argumenten en kiest daarna. Om ",[17,5062,5023],{}," te\nkunnen evalueren moet PostgreSQL weten welke velden ",[17,5065,4887],{}," heeft, en dat weet het niet — de recordstructuur\nis onbepaald. De fout treedt dus op vóórdat ",[17,5068,5059],{}," iets te kiezen heeft. Datzelfde geldt voor\n",[17,5071,5072],{},"CASE WHEN NEW.id IS NOT NULL THEN ...",": ook daar wordt ",[17,5075,5023],{}," eerst geëvalueerd.",[10,5078,5079,5080,5082,5083,5085],{},"De betrouwbare vorm vertakt op ",[17,5081,4881],{},", zodat ",[17,5084,4887],{}," alleen wordt aangeraakt waar hij bestaat:",[22,5087,5089],{"className":24,"code":5088,"language":26,"meta":27,"style":27},"CASE WHEN TG_OP = 'DELETE' THEN OLD.id ELSE NEW.id END\n",[17,5090,5091],{"__ignoreMap":27},[31,5092,5093,5095,5097,5099,5101,5103,5105,5107,5109,5112,5115,5118],{"class":33,"line":34},[31,5094,3539],{"class":37},[31,5096,3542],{"class":37},[31,5098,4529],{"class":48},[31,5100,466],{"class":276},[31,5102,1154],{"class":276},[31,5104,19],{"class":1157},[31,5106,1359],{"class":276},[31,5108,3556],{"class":37},[31,5110,5111],{"class":48}," OLD.id ",[31,5113,5114],{"class":37},"ELSE",[31,5116,5117],{"class":48}," NEW.id ",[31,5119,5120],{"class":37},"END\n",[10,5122,5123,5124,5127,5128,5130],{},"Merk op dat ",[17,5125,5126],{},"RETURN COALESCE(NEW, OLD)"," onderaan géén fout geeft: daar wordt het record als geheel\ngebruikt, niet een veld ervan, en dan is een niet-toegekend record wel gewoon ",[17,5129,693],{},". Dat het ene\nwerkt en het andere niet, is precies waarom deze fout zo lang blijft zitten.",[519,5132,5133,5147],{"level":3923},[523,5134,5135],{"v-slot:question":27},[10,5136,5137,5138,5140,5141,588,5144,5146],{},"De trigger is ",[17,5139,4992],{},". Wat doen ",[17,5142,5143],{},"RETURN NEW",[17,5145,5126],{}," dan eigenlijk?",[523,5148,5149,5162,5175],{"v-slot:answer":27},[10,5150,5151,5152,5154,5155,5157,5158,5161],{},"Voor de database: niets. De retourwaarde van een ",[17,5153,4992],{},"-trigger wordt genegeerd. Alleen bij een\n",[17,5156,4989],{},"-trigger betekent de retourwaarde iets — daar bepaalt hij de rij die daadwerkelijk wordt\nweggeschreven, en ",[17,5159,5160],{},"RETURN NULL"," annuleert de bewerking.",[10,5163,5164,5165,5167,5168,5170,5171,5174],{},"Maar ",[17,5166,5143],{}," halverwege heeft hier wél effect, alleen niet het effect dat de naam suggereert: het\nbeëindigt de functie, waardoor de ",[17,5169,1842],{}," in de audittabel niet meer wordt uitgevoerd. Het is een\n",[17,5172,5173],{},"return"," als besturingsstructuur, niet als teruggave van een waarde.",[10,5176,5177,5178,5180,5181,5183,5184,4288,5186,642,5188,5190],{},"Dat is verwarrend om te lezen, en gevaarlijk bij verplaatsen: zet iemand deze functie ooit om naar een\n",[17,5179,4989],{},"-trigger — bijvoorbeeld om ook geweigerde wijzigingen te loggen — dan wordt die ",[17,5182,5143],{},"\nopeens betekenisvol, terwijl ",[17,5185,5126],{},[17,5187,19],{},[17,5189,4887],{}," stuit. De omzetting\nlijkt triviaal en is het niet.",[519,5192,5194,5203],{"level":5193},"Beveiliging",[523,5195,5196],{"v-slot:question":27},[10,5197,5198,5199,5202],{},"De functie is ",[17,5200,5201],{},"SECURITY DEFINER",". Wat betekent dat hier, en wat ontbreekt er?",[523,5204,5205,5214,5234,5237,5294],{"v-slot:answer":27},[10,5206,5207,5209,5210,5213],{},[17,5208,5201],{}," laat de functie draaien met de rechten van de eigenaar in plaats van de aanroeper.\nDat is hier verdedigbaar en zelfs nodig: gebruikers mogen wél klanten wijzigen, maar mogen niet zelf in\n",[17,5211,5212],{},"audit.wijzigingen"," kunnen schrijven — anders kunnen ze hun sporen uitwissen. De trigger schrijft dus\nmet verhoogde rechten.",[10,5215,5216,5217,5220,5221,5223,5224,5226,5227,734,5230,5233],{},"Wat ontbreekt is de ",[17,5218,5219],{},"search_path",". Zonder vaste ",[17,5222,5219],{}," bepaalt de aanroeper welke schema's\nworden doorzocht, en dan is dit een privilege-escalatie: iemand maakt een eigen tabel\n",[17,5225,5212],{}," in een schema dat vóór het echte komt, en de trigger schrijft met eigenaarsrechten\nnaar de tabel van de aanvaller. Erger nog, functies en operatoren worden op dezelfde manier opgezocht,\ndus ook ",[17,5228,5229],{},"to_jsonb",[17,5231,5232],{},"||"," kan gekaapt worden.",[10,5235,5236],{},"De juiste vorm:",[22,5238,5240],{"className":24,"code":5239,"language":26,"meta":27,"style":27},"CREATE OR REPLACE FUNCTION audit.log_wijziging()\nRETURNS trigger\nLANGUAGE plpgsql\nSECURITY DEFINER\nSET search_path = audit, pg_catalog\nAS $$ ... $$;\n",[17,5241,5242,5254,5260,5266,5272,5287],{"__ignoreMap":27},[31,5243,5244,5246,5248,5250,5252],{"class":33,"line":34},[31,5245,38],{"class":37},[31,5247,41],{"class":37},[31,5249,4460],{"class":44},[31,5251,4463],{"class":48},[31,5253,4466],{"class":276},[31,5255,5256,5258],{"class":33,"line":52},[31,5257,77],{"class":37},[31,5259,4473],{"class":48},[31,5261,5262,5264],{"class":33,"line":68},[31,5263,147],{"class":37},[31,5265,1032],{"class":48},[31,5267,5268,5270],{"class":33,"line":74},[31,5269,4484],{"class":37},[31,5271,4487],{"class":48},[31,5273,5274,5277,5280,5282,5284],{"class":33,"line":86},[31,5275,5276],{"class":37},"SET",[31,5278,5279],{"class":48}," search_path ",[31,5281,466],{"class":276},[31,5283,4460],{"class":37},[31,5285,5286],{"class":48},", pg_catalog\n",[31,5288,5289,5291],{"class":33,"line":98},[31,5290,162],{"class":37},[31,5292,5293],{"class":48}," $$ ... $$;\n",[10,5295,5296,5297,5299,5300,5303,5304,5307],{},"Vuistregel: ",[17,5298,5201],{}," zonder ",[17,5301,5302],{},"SET search_path"," is altijd een bevinding. Bij een Supabase-project\nkomt dit er nog vaker uit dan elders, omdat er standaard veel functies in ",[17,5305,5306],{},"public"," staan.",[519,5309,5311,5320],{"level":5310},"Volledigheid",[523,5312,5313],{"v-slot:question":27},[10,5314,5315,5316,5319],{},"De trigger vergelijkt de velden via ",[17,5317,5318],{},"jsonb_each(v_nieuw)",". Welk soort wijziging ontsnapt daaraan?",[523,5321,5322,5331,5352,5355,5427],{"v-slot:answer":27},[10,5323,5324,5325,5327,5328,5330],{},"Een veld dat uit ",[17,5326,4887],{}," verdwijnt maar in ",[17,5329,4884],{}," nog een waarde had.",[10,5332,5333,5335,5336,5339,5340,5343,5344,5346,5347,588,5349,5351],{},[17,5334,5318],{}," loopt alleen over de sleutels van de nieuwe rij. Elke sleutel die alleen in\n",[17,5337,5338],{},"v_oud"," bestaat, wordt nooit bekeken en komt dus niet in ",[17,5341,5342],{},"gewijzigde_velden",". Bij een gewone ",[17,5345,1951],{},"\nspeelt dat niet — ",[17,5348,4884],{},[17,5350,4887],{}," hebben dezelfde kolommen — maar wel zodra iemand tijdens een migratie\neen kolom hernoemt of verwijdert terwijl er nog verkeer op de tabel staat.",[10,5353,5354],{},"De symmetrische vorm loopt over de vereniging van beide sleutelverzamelingen:",[22,5356,5358],{"className":24,"code":5357,"language":26,"meta":27,"style":27},"SELECT array_agg(sleutel)\nINTO   v_gewijzigd\nFROM   (SELECT jsonb_object_keys(v_oud) AS sleutel\n        UNION\n        SELECT jsonb_object_keys(v_nieuw)) k\nWHERE  v_oud -> k.sleutel IS DISTINCT FROM v_nieuw -> k.sleutel;\n",[17,5359,5360,5367,5373,5390,5395,5402],{"__ignoreMap":27},[31,5361,5362,5364],{"class":33,"line":34},[31,5363,440],{"class":37},[31,5365,5366],{"class":48}," array_agg(sleutel)\n",[31,5368,5369,5371],{"class":33,"line":52},[31,5370,1396],{"class":37},[31,5372,4633],{"class":48},[31,5374,5375,5377,5380,5382,5385,5387],{"class":33,"line":68},[31,5376,454],{"class":37},[31,5378,5379],{"class":48},"   (",[31,5381,440],{"class":37},[31,5383,5384],{"class":48}," jsonb_object_keys(v_oud) ",[31,5386,162],{"class":37},[31,5388,5389],{"class":48}," sleutel\n",[31,5391,5392],{"class":33,"line":74},[31,5393,5394],{"class":37},"        UNION\n",[31,5396,5397,5399],{"class":33,"line":86},[31,5398,184],{"class":37},[31,5400,5401],{"class":48}," jsonb_object_keys(v_nieuw)) k\n",[31,5403,5404,5406,5408,5410,5413,5415,5417,5419,5422,5424],{"class":33,"line":98},[31,5405,460],{"class":37},[31,5407,4652],{"class":48},[31,5409,4655],{"class":276},[31,5411,5412],{"class":48}," k.sleutel ",[31,5414,246],{"class":37},[31,5416,425],{"class":37},[31,5418,428],{"class":37},[31,5420,5421],{"class":48}," v_nieuw ",[31,5423,4655],{"class":276},[31,5425,5426],{"class":48}," k.sleutel;\n",[10,5428,5429,5430,5432,5433,2803,5435,5437,5438,5440,5441,5443,5444,5446],{},"Het ",[17,5431,730],{}," in de originele versie is overigens wél goed gekozen: ",[17,5434,4655],{},[17,5436,693],{}," terug bij\neen ontbrekende sleutel, en met een gewone ",[17,5439,689],{}," zou elke vergelijking waarin ",[17,5442,693],{}," voorkomt zelf ",[17,5445,693],{},"\nworden — waardoor de wijziging niet in de lijst belandt.",[519,5448,5450,5458],{"level":5449},"Stille fout",[523,5451,5452],{"v-slot:question":27},[10,5453,5454,5457],{},[17,5455,5456],{},"current_setting('app.gebruiker_id', true)"," — wat doet die tweede parameter, en wat is het gevolg?",[523,5459,5460,5472,5482,5488],{"v-slot:answer":27},[10,5461,636,5462,5464,5465,5468,5469,5471],{},[17,5463,707],{}," betekent ",[948,5466,5467],{},"missing_ok",": bestaat de instelling niet, geef dan ",[17,5470,693],{}," terug in plaats van een\nfout te gooien.",[10,5473,5474,5475,5478,5479,5481],{},"Dat klinkt defensief en is het probleem. De applicatie hoort per verbinding\n",[17,5476,5477],{},"SET LOCAL app.gebruiker_id = '...'"," te doen. Gebeurt dat niet — een migratiescript, een cronjob, een\ncollega in psql, of simpelweg een codepad waar iemand het vergat — dan krijgt de auditrij stilzwijgend\n",[17,5480,693],{}," als gebruiker. De wijziging wordt keurig gelogd, alleen zonder de informatie waar het hele\nlogboek voor bestaat.",[10,5483,5484,5485,5487],{},"Zet je die ",[17,5486,707],{}," weg, dan crasht de wijziging als de gebruiker niet is gezet. Dat is luidruchtig, maar\nhet dwingt af dat elk schrijfpad zijn gebruiker meegeeft. Voor een audittabel die bij een geschil moet\nkunnen bewijzen wie wat deed, is die strengheid meestal de betere ruil.",[10,5489,5490,5491,2878,5494,5496],{},"Wil je het niet zo hard maken, log dan expliciet een waarde als ",[17,5492,5493],{},"'onbekend'",[17,5495,693],{},", zodat\n\"niemand heeft het gezet\" te onderscheiden is van \"de kolom bestond nog niet\".",[519,5498,5500,5508],{"level":5499},"Tijd",[523,5501,5502],{"v-slot:question":27},[10,5503,5504,5507],{},[17,5505,5506],{},"now()"," staat in de tijdstempel. Waarom zien alle auditrijen van één bulkwijziging er daardoor\nidentiek uit?",[523,5509,5510,5524,5527,5533],{"v-slot:answer":27},[10,5511,5512,5514,5515,5517,5518,5520,5521,708],{},[17,5513,5506],{}," geeft de starttijd van de ",[920,5516,2071],{},", niet het huidige moment. Alle aanroepen binnen\ndezelfde transactie krijgen exact dezelfde waarde — dat is per definitie zo, ",[17,5519,5506],{}," is een synoniem van\n",[17,5522,5523],{},"transaction_timestamp()",[10,5525,5526],{},"Werk je 10.000 klanten bij in één transactie, dan krijgen alle 10.000 auditrijen dezelfde tijdstempel op\nde microseconde. De volgorde waarin de wijzigingen plaatsvonden is daarmee onherstelbaar weg. Sorteren\nop tijdstip geeft een willekeurige volgorde, en \"wat gebeurde er vlak voor deze wijziging\" is niet meer\nte beantwoorden.",[10,5528,5529,5532],{},[17,5530,5531],{},"clock_timestamp()"," geeft wél de echte wandklok op het moment van aanroep en loopt dus door binnen een\ntransactie. Voor een audittabel is dat vrijwel altijd wat je wilt.",[10,5534,5535,5536,5538,5539,5541,5542,5545,5546,708],{},"Het beste is beide te bewaren: ",[17,5537,5531],{}," voor de volgorde, ",[17,5540,5506],{}," om te zien welke rijen bij\ndezelfde transactie hoorden. Dat laatste is bij het uitzoeken van een incident vaak precies de vraag —\n\"is dit één actie geweest of tien losse?\" — en met alleen een klokwaarde kun je dat niet meer zien.\nEen ",[17,5543,5544],{},"txid_current()","-kolom doet dat overigens explicieter dan ",[17,5547,5506],{},[519,5549,5551,5556],{"level":5550},"Fundamenteel",[523,5552,5553],{"v-slot:question":27},[10,5554,5555],{},"De grootste beperking staat niet in de functie zelf. Welke gebeurtenissen komen er nóóit in deze\naudittabel terecht?",[523,5557,5558,5564,5575,5580,5616],{"v-slot:answer":27},[10,5559,5560,5563],{},[920,5561,5562],{},"Alles wat teruggedraaid wordt."," De trigger schrijft binnen dezelfde transactie als de wijziging. Rolt\ndie terug, dan verdwijnt de auditrij mee. Precies de interessante gevallen — de mislukte poging, de\ngeweigerde wijziging, de actie die op een constraint stukliep — laten geen enkel spoor na. Je logboek\nbevat uitsluitend wat gelukt is.",[10,5565,5566,5567,5570,5571,5574],{},"Wil je pogingen vastleggen, dan moet dat buiten de transactie: via ",[17,5568,5569],{},"pg_notify"," naar een luisteraar, via\neen ",[17,5572,5573],{},"dblink","- of FDW-verbinding die een eigen transactie opent, of door het loggen in de applicatielaag\nte doen.",[10,5576,5577],{},[920,5578,5579],{},"En wat er verder ontbreekt:",[602,5581,5582,5594,5607],{},[605,5583,5584,5589,5590,5593],{},[920,5585,5586,708],{},[17,5587,5588],{},"TRUNCATE"," Die staat niet in de triggerdefinitie, en kan sowieso niet ",[17,5591,5592],{},"FOR EACH ROW",". Iemand kan\nde hele tabel legen zonder één auditrij. Dat vraagt om een aparte statement-trigger.",[605,5595,5596,5602,5603,5606],{},[920,5597,5598,5599,708],{},"Wijzigingen door een superuser met ",[17,5600,5601],{},"session_replication_role = replica"," Daarmee worden triggers\novergeslagen — dat is de stand waarin ook ",[17,5604,5605],{},"pg_restore"," draait.",[605,5608,5609,5615],{},[920,5610,5611,5614],{},[17,5612,5613],{},"DROP TABLE"," en schemawijzigingen."," Daar zijn event triggers voor nodig, geen gewone.",[10,5617,5618,5619,5621,5622,5624],{},"En de kostenkant, die net zo fundamenteel is: ",[17,5620,5592],{}," betekent één functie-aanroep en één\n",[17,5623,1842],{}," per gewijzigde rij, plus twee volledige JSONB-serialisaties van de rij. Bij een bulkupdate van\neen miljoen rijen levert dat een miljoen auditrijen op die vaak groter zijn dan de brondata zelf. Voor\ngrote batches wil je de trigger tijdelijk uitzetten en de wijziging op statement-niveau vastleggen — een\nafweging die je bewust moet maken, niet ontdekken als de schijf vol loopt.",[953,5626,5627],{},"html pre.shiki code .sbssI, html code.shiki .sbssI{--shiki-light:#F76D47;--shiki-default:#F78C6C;--shiki-dark:#F78C6C}html pre.shiki code .s2Zo4, html code.shiki .s2Zo4{--shiki-light:#6182B8;--shiki-default:#82AAFF;--shiki-dark:#82AAFF}html pre.shiki code .sTEyZ, html code.shiki .sTEyZ{--shiki-light:#90A4AE;--shiki-default:#EEFFFF;--shiki-dark:#BABED8}html pre.shiki code .sMK4o, html code.shiki .sMK4o{--shiki-light:#39ADB5;--shiki-default:#89DDFF;--shiki-dark:#89DDFF}html pre.shiki code .spNyl, html code.shiki .spNyl{--shiki-light:#9C3EDA;--shiki-default:#C792EA;--shiki-dark:#C792EA}html pre.shiki code .sfazB, html code.shiki .sfazB{--shiki-light:#91B859;--shiki-default:#C3E88D;--shiki-dark:#C3E88D}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":27,"searchDepth":52,"depth":52,"links":5629},[],"Een PL\u002FpgSQL-trigger die elke wijziging als JSONB wegschrijft. Hij crasht op DELETE, mist precies de gebeurtenissen die je wilde vastleggen, en zijn tijdstempels staan allemaal gelijk.",{},"\u002Fpracticely-code\u002Faudit-trigger-valkuilen",{"title":4438,"description":5630},"practicely-code\u002Faudit-trigger-valkuilen","Triggers & auditing","DwumNbfGS6JEYdBBsxpLaHN-WI_y7joCCtADt82BMko",1788471481767]