|
| 1 | +-- RFC 7396 JSON Merge Patch Implementation for PostgreSQL |
| 2 | +-- This migration adds a production-grade jsonb_merge_patch() function that implements |
| 3 | +-- TRUE recursive deep merge semantics as specified in RFC 7396. |
| 4 | +-- |
| 5 | +-- RFC 7396 Specification: https://datatracker.ietf.org/doc/html/rfc7396 |
| 6 | +-- |
| 7 | +-- Key Semantics: |
| 8 | +-- 1. If patch is not an object, return patch directly (replaces target entirely) |
| 9 | +-- 2. If target is not an object, start with empty object for merging |
| 10 | +-- 3. For each key in patch: |
| 11 | +-- - If value is null, DELETE that key from target |
| 12 | +-- - Otherwise, RECURSIVELY merge the value into target's corresponding key |
| 13 | +-- 4. Keys in target but not in patch are PRESERVED (unchanged) |
| 14 | +-- |
| 15 | +-- This function replaces the shallow || - pattern which only handles top-level keys. |
| 16 | +-- The new implementation correctly handles deeply nested structures. |
| 17 | + |
| 18 | +CREATE OR REPLACE FUNCTION jsonb_merge_patch( |
| 19 | + target jsonb, |
| 20 | + patch jsonb |
| 21 | +) |
| 22 | +RETURNS jsonb |
| 23 | +LANGUAGE plpgsql |
| 24 | +IMMUTABLE |
| 25 | +PARALLEL SAFE |
| 26 | +AS $$ |
| 27 | +BEGIN |
| 28 | + -- RFC 7396 Section 2, Step 1: |
| 29 | + -- "If the provided Merge Patch is not a JSON object, then the result is to |
| 30 | + -- replace the entire target with the entire patch." |
| 31 | + -- This covers: null, arrays, strings, numbers, booleans - all non-object types |
| 32 | + IF patch IS NULL OR jsonb_typeof(patch) != 'object' THEN |
| 33 | + RETURN patch; |
| 34 | + END IF; |
| 35 | + |
| 36 | + -- RFC 7396 Section 2, Step 2: |
| 37 | + -- "If the original document is not an object, its value is replaced |
| 38 | + -- entirely by the object provided in the patch document." |
| 39 | + -- This means: start with empty object if target is null or non-object |
| 40 | + IF target IS NULL OR jsonb_typeof(target) != 'object' THEN |
| 41 | + target := '{}'::jsonb; |
| 42 | + END IF; |
| 43 | + |
| 44 | + -- RFC 7396 Section 2, Step 3 (recursive merge): |
| 45 | + -- Use FULL OUTER JOIN to iterate over all keys from both target and patch. |
| 46 | + -- This ensures we handle: |
| 47 | + -- - Keys only in target (preserved) |
| 48 | + -- - Keys only in patch (added) |
| 49 | + -- - Keys in both (merged/updated/deleted) |
| 50 | + -- |
| 51 | + -- The WHERE clause implements RFC 7396 null-deletion semantics: |
| 52 | + -- - Keys with null values in patch are DELETED (filtered out) |
| 53 | + -- - Keys only in target (patch_key IS NULL) are PRESERVED |
| 54 | + RETURN COALESCE( |
| 55 | + ( |
| 56 | + SELECT jsonb_object_agg( |
| 57 | + COALESCE(target_key, patch_key), |
| 58 | + CASE |
| 59 | + -- Key only in target (not in patch): preserve target value unchanged |
| 60 | + WHEN patch_key IS NULL THEN target_value |
| 61 | + -- Key in both or only in patch: recursively merge |
| 62 | + -- This handles nested objects correctly via recursive call |
| 63 | + ELSE jsonb_merge_patch(target_value, patch_value) |
| 64 | + END |
| 65 | + ) |
| 66 | + FROM jsonb_each(target) AS t(target_key, target_value) |
| 67 | + FULL OUTER JOIN jsonb_each(patch) AS p(patch_key, patch_value) |
| 68 | + ON t.target_key = p.patch_key |
| 69 | + -- RFC 7396 null-deletion: Filter out keys where patch has null value |
| 70 | + -- Condition: (target-only keys) OR (patch value is not JSON null) |
| 71 | + -- This correctly handles RFC 7396 test case #13: {"e":null} + {"a":1} = {"e":null,"a":1} |
| 72 | + -- The existing null in target is preserved because it's not being patched with null |
| 73 | + WHERE patch_key IS NULL OR jsonb_typeof(patch_value) != 'null' |
| 74 | + ), |
| 75 | + '{}'::jsonb |
| 76 | + ); |
| 77 | +END; |
| 78 | +$$; |
| 79 | + |
| 80 | +-- ============================================================================ |
| 81 | +-- RFC 7396 Appendix A: Test Cases Verification |
| 82 | +-- ============================================================================ |
| 83 | +-- All 15 test cases from RFC 7396 are documented below with expected results. |
| 84 | +-- These can be used to verify the function works correctly: |
| 85 | +-- |
| 86 | +-- Test Case 1: Simple value replacement |
| 87 | +-- SELECT jsonb_merge_patch('{"a":"b"}'::jsonb, '{"a":"c"}'::jsonb); |
| 88 | +-- Expected: {"a":"c"} |
| 89 | +-- |
| 90 | +-- Test Case 2: Add new key |
| 91 | +-- SELECT jsonb_merge_patch('{"a":"b"}'::jsonb, '{"b":"c"}'::jsonb); |
| 92 | +-- Expected: {"a":"b","b":"c"} |
| 93 | +-- |
| 94 | +-- Test Case 3: Delete key with null (RFC 7396 core semantic) |
| 95 | +-- SELECT jsonb_merge_patch('{"a":"b"}'::jsonb, '{"a":null}'::jsonb); |
| 96 | +-- Expected: {} |
| 97 | +-- |
| 98 | +-- Test Case 4: Delete one key, preserve others |
| 99 | +-- SELECT jsonb_merge_patch('{"a":"b","b":"c"}'::jsonb, '{"a":null}'::jsonb); |
| 100 | +-- Expected: {"b":"c"} |
| 101 | +-- |
| 102 | +-- Test Case 5: Array replacement (arrays are NOT merged) |
| 103 | +-- SELECT jsonb_merge_patch('{"a":["b"]}'::jsonb, '{"a":"c"}'::jsonb); |
| 104 | +-- Expected: {"a":"c"} |
| 105 | +-- |
| 106 | +-- Test Case 6: Replace value with array |
| 107 | +-- SELECT jsonb_merge_patch('{"a":"c"}'::jsonb, '{"a":["b"]}'::jsonb); |
| 108 | +-- Expected: {"a":["b"]} |
| 109 | +-- |
| 110 | +-- Test Case 7: CRITICAL - Nested object merge with deletion |
| 111 | +-- SELECT jsonb_merge_patch('{"a":{"b":"c"}}'::jsonb, '{"a":{"b":"d","c":null}}'::jsonb); |
| 112 | +-- Expected: {"a":{"b":"d"}} |
| 113 | +-- Note: This is where the old || - pattern failed (shallow merge replaced entire nested object) |
| 114 | +-- |
| 115 | +-- Test Case 8: Array of objects replacement |
| 116 | +-- SELECT jsonb_merge_patch('{"a":[{"b":"c"}]}'::jsonb, '{"a":[1]}'::jsonb); |
| 117 | +-- Expected: {"a":[1]} |
| 118 | +-- |
| 119 | +-- Test Case 9: Array replacement (top-level arrays) |
| 120 | +-- SELECT jsonb_merge_patch('["a","b"]'::jsonb, '["c","d"]'::jsonb); |
| 121 | +-- Expected: ["c","d"] |
| 122 | +-- |
| 123 | +-- Test Case 10: Object replaced by array |
| 124 | +-- SELECT jsonb_merge_patch('{"a":"b"}'::jsonb, '["c"]'::jsonb); |
| 125 | +-- Expected: ["c"] |
| 126 | +-- |
| 127 | +-- Test Case 11: Null patch replaces everything |
| 128 | +-- SELECT jsonb_merge_patch('{"a":"foo"}'::jsonb, 'null'::jsonb); |
| 129 | +-- Expected: null |
| 130 | +-- |
| 131 | +-- Test Case 12: String patch replaces everything |
| 132 | +-- SELECT jsonb_merge_patch('{"a":"foo"}'::jsonb, '"bar"'::jsonb); |
| 133 | +-- Expected: "bar" |
| 134 | +-- |
| 135 | +-- Test Case 13: CRITICAL - Existing null value preserved (NOT deleted) |
| 136 | +-- SELECT jsonb_merge_patch('{"e":null}'::jsonb, '{"a":1}'::jsonb); |
| 137 | +-- Expected: {"a":1,"e":null} |
| 138 | +-- Note: The null value in TARGET is preserved because patch doesn't modify it |
| 139 | +-- |
| 140 | +-- Test Case 14: Array becomes object after patch |
| 141 | +-- SELECT jsonb_merge_patch('[1,2]'::jsonb, '{"a":"b","c":null}'::jsonb); |
| 142 | +-- Expected: {"a":"b"} |
| 143 | +-- |
| 144 | +-- Test Case 15: CRITICAL - Deeply nested null deletion |
| 145 | +-- SELECT jsonb_merge_patch('{}'::jsonb, '{"a":{"bb":{"ccc":null}}}'::jsonb); |
| 146 | +-- Expected: {"a":{"bb":{}}} |
| 147 | +-- Note: This is where recursive merge is essential - the deeply nested null causes |
| 148 | +-- deletion at the deepest level, but the containing objects are preserved |
| 149 | +-- |
| 150 | +-- ============================================================================ |
| 151 | +-- Verification Query (run all test cases at once): |
| 152 | +-- ============================================================================ |
| 153 | +-- DO $$ |
| 154 | +-- DECLARE |
| 155 | +-- test_results boolean[]; |
| 156 | +-- BEGIN |
| 157 | +-- test_results := ARRAY[ |
| 158 | +-- jsonb_merge_patch('{"a":"b"}', '{"a":"c"}') = '{"a":"c"}', |
| 159 | +-- jsonb_merge_patch('{"a":"b"}', '{"b":"c"}') = '{"a":"b","b":"c"}', |
| 160 | +-- jsonb_merge_patch('{"a":"b"}', '{"a":null}') = '{}', |
| 161 | +-- jsonb_merge_patch('{"a":"b","b":"c"}', '{"a":null}') = '{"b":"c"}', |
| 162 | +-- jsonb_merge_patch('{"a":["b"]}', '{"a":"c"}') = '{"a":"c"}', |
| 163 | +-- jsonb_merge_patch('{"a":"c"}', '{"a":["b"]}') = '{"a":["b"]}', |
| 164 | +-- jsonb_merge_patch('{"a":{"b":"c"}}', '{"a":{"b":"d","c":null}}') = '{"a":{"b":"d"}}', |
| 165 | +-- jsonb_merge_patch('{"a":[{"b":"c"}]}', '{"a":[1]}') = '{"a":[1]}', |
| 166 | +-- jsonb_merge_patch('["a","b"]', '["c","d"]') = '["c","d"]', |
| 167 | +-- jsonb_merge_patch('{"a":"b"}', '["c"]') = '["c"]', |
| 168 | +-- jsonb_merge_patch('{"a":"foo"}', 'null') IS NULL, |
| 169 | +-- jsonb_merge_patch('{"a":"foo"}', '"bar"') = '"bar"', |
| 170 | +-- jsonb_merge_patch('{"e":null}', '{"a":1}') = '{"a":1,"e":null}', |
| 171 | +-- jsonb_merge_patch('[1,2]', '{"a":"b","c":null}') = '{"a":"b"}', |
| 172 | +-- jsonb_merge_patch('{}', '{"a":{"bb":{"ccc":null}}}') = '{"a":{"bb":{}}}' |
| 173 | +-- ]; |
| 174 | +-- |
| 175 | +-- FOR i IN 1..array_length(test_results, 1) LOOP |
| 176 | +-- IF NOT test_results[i] THEN |
| 177 | +-- RAISE EXCEPTION 'RFC 7396 Test Case % failed', i; |
| 178 | +-- END IF; |
| 179 | +-- END LOOP; |
| 180 | +-- |
| 181 | +-- RAISE NOTICE 'All 15 RFC 7396 test cases passed!'; |
| 182 | +-- END; |
| 183 | +-- $$; |
0 commit comments