Skip to content

Commit 116077b

Browse files
authored
Merge pull request #22 from alex-feel/alex-feel-dev
Add metadata_patch parameter for partial metadata updates
2 parents f277d3a + 1133505 commit 116077b

9 files changed

Lines changed: 2223 additions & 81 deletions

File tree

CLAUDE.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,18 @@
22

33
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
44

5+
## Quick Reference
6+
7+
```bash
8+
# Essential commands
9+
uv sync # Install dependencies
10+
uv run pytest # Run all tests
11+
uv run pytest tests/test_server.py -v # Run specific test file
12+
uv run pytest tests/test_server.py::TestStoreContext::test_store_text_context -v # Single test
13+
uv run pre-commit run --all-files # Lint + type check
14+
uv run mcp-context-server # Start server
15+
```
16+
517
## Common Development Commands
618

719
### Building and Running

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -596,9 +596,29 @@ Update specific fields of an existing context entry.
596596
- `context_id` (int, required): ID of the context entry to update
597597
- `text` (str, optional): New text content
598598
- `metadata` (dict, optional): New metadata (full replacement)
599+
- `metadata_patch` (dict, optional): Partial metadata update using RFC 7396 JSON Merge Patch
599600
- `tags` (list, optional): New tags (full replacement)
600601
- `images` (list, optional): New images (full replacement)
601602

603+
**Metadata Update Options:**
604+
605+
Use `metadata` for full replacement or `metadata_patch` for partial updates. These parameters are mutually exclusive.
606+
607+
RFC 7396 JSON Merge Patch semantics (`metadata_patch`):
608+
- New keys are ADDED to existing metadata
609+
- Existing keys are REPLACED with new values
610+
- Null values DELETE keys
611+
612+
```python
613+
# Update single field while preserving others
614+
update_context(context_id=123, metadata_patch={"status": "completed"})
615+
616+
# Add new field and delete another
617+
update_context(context_id=123, metadata_patch={"reviewer": "alice", "draft": None})
618+
```
619+
620+
**Limitations (RFC 7396):** Null values cannot be stored (null means delete key - use full replacement if needed), arrays are replaced entirely (not merged). See [Metadata Filtering Guide](docs/metadata-filtering.md#partial-metadata-updates-metadata_patch) for details.
621+
602622
**Field Update Rules:**
603623
- **Updatable fields**: text_content, metadata, tags, images
604624
- **Immutable fields**: id, thread_id, source, created_at (preserved for data integrity)
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
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+
-- $$;

app/repositories/context_repository.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -766,6 +766,129 @@ async def _update_content_type_postgresql(conn: asyncpg.Connection) -> bool:
766766

767767
return await self.backend.execute_write(_update_content_type_postgresql)
768768

769+
async def patch_metadata(
770+
self,
771+
context_id: int,
772+
patch: dict[str, Any],
773+
) -> tuple[bool, list[str]]:
774+
"""Apply RFC 7396 JSON Merge Patch to metadata atomically.
775+
776+
This method performs a partial update of the metadata field using database-native
777+
JSON patching functions for atomic, race-condition-free operations.
778+
779+
RFC 7396 JSON Merge Patch Semantics:
780+
- New keys in patch are ADDED to existing metadata
781+
- Existing keys are REPLACED with new values
782+
- Keys with null values are DELETED from metadata
783+
784+
IMPORTANT LIMITATIONS (RFC 7396):
785+
- Cannot set a value to null: null always means DELETE. If you need to store
786+
null values, use the full metadata replacement (metadata parameter) instead.
787+
- Array operations are replace-only: Arrays are replaced entirely, not merged.
788+
Individual array elements cannot be added, removed, or modified - the entire
789+
array is replaced with the new value.
790+
- Empty patch {} is a no-op for data but still updates the updated_at timestamp.
791+
792+
Backend-specific implementation:
793+
- SQLite: Uses json_patch() function (available in SQLite 3.38.0+)
794+
- PostgreSQL: Uses custom jsonb_merge_patch() function for TRUE recursive deep merge.
795+
The function is created by migration app/migrations/add_jsonb_merge_patch_postgresql.sql
796+
and provides identical RFC 7396 semantics to SQLite's json_patch().
797+
798+
Args:
799+
context_id: ID of the context entry to update
800+
patch: Dictionary containing the merge patch to apply
801+
802+
Returns:
803+
Tuple of (success, list_of_updated_fields).
804+
Updated fields will include 'metadata' if successful.
805+
"""
806+
# Convert patch dict to JSON string for database operations
807+
patch_json = json.dumps(patch, ensure_ascii=False)
808+
809+
if self.backend.backend_type == 'sqlite':
810+
811+
def _patch_metadata_sqlite(conn: sqlite3.Connection) -> tuple[bool, list[str]]:
812+
cursor = conn.cursor()
813+
814+
# Verify entry exists before attempting update
815+
cursor.execute(
816+
f'SELECT id FROM context_entries WHERE id = {self._placeholder(1)}',
817+
(context_id,),
818+
)
819+
if not cursor.fetchone():
820+
return False, []
821+
822+
# Apply JSON Merge Patch using SQLite's json_patch() function
823+
# json_patch() implements RFC 7396 semantics:
824+
# - COALESCE ensures null metadata is treated as empty object '{}'
825+
# - json_patch(target, patch) merges patch into target
826+
# - null values in patch DELETE keys from result
827+
cursor.execute(
828+
f'''
829+
UPDATE context_entries
830+
SET metadata = json_patch(COALESCE(metadata, '{{}}'), {self._placeholder(1)}),
831+
updated_at = CURRENT_TIMESTAMP
832+
WHERE id = {self._placeholder(2)}
833+
''',
834+
(patch_json, context_id),
835+
)
836+
837+
if cursor.rowcount > 0:
838+
logger.debug(f'Patched metadata for context entry {context_id}')
839+
return True, ['metadata']
840+
841+
return False, []
842+
843+
return await self.backend.execute_write(_patch_metadata_sqlite)
844+
845+
# PostgreSQL implementation - RFC 7396 compliant using jsonb_merge_patch() function
846+
async def _patch_metadata_postgresql(conn: asyncpg.Connection) -> tuple[bool, list[str]]:
847+
# Verify entry exists before attempting update
848+
row = await conn.fetchrow(
849+
f'SELECT id FROM context_entries WHERE id = {self._placeholder(1)}',
850+
context_id,
851+
)
852+
if not row:
853+
return False, []
854+
855+
# RFC 7396 JSON Merge Patch Implementation for PostgreSQL
856+
#
857+
# Uses the custom jsonb_merge_patch() function that implements TRUE recursive
858+
# deep merge semantics as specified in RFC 7396:
859+
# - New keys in patch are ADDED to existing metadata
860+
# - Existing keys are REPLACED with new values from patch
861+
# - Keys with null values are DELETED from metadata
862+
# - Nested objects are RECURSIVELY merged (not replaced like || operator)
863+
#
864+
# The jsonb_merge_patch() function is created by the migration file:
865+
# app/migrations/add_jsonb_merge_patch_postgresql.sql
866+
#
867+
# This approach provides identical behavior to SQLite's json_patch() function,
868+
# ensuring consistent RFC 7396 semantics across both backends.
869+
p1 = self._placeholder(1)
870+
p2 = self._placeholder(2)
871+
result = await conn.execute(
872+
f'''
873+
UPDATE context_entries
874+
SET metadata = jsonb_merge_patch(COALESCE(metadata, '{{}}'::jsonb), {p1}::jsonb),
875+
updated_at = CURRENT_TIMESTAMP
876+
WHERE id = {p2}
877+
''',
878+
patch_json,
879+
context_id,
880+
)
881+
882+
# asyncpg returns "UPDATE N" where N is the count
883+
rows_affected = int(result.split()[-1]) if result else 0
884+
if rows_affected > 0:
885+
logger.debug(f'Patched metadata for context entry {context_id}')
886+
return True, ['metadata']
887+
888+
return False, []
889+
890+
return await self.backend.execute_write(_patch_metadata_postgresql)
891+
769892
@staticmethod
770893
def row_to_dict(row: sqlite3.Row) -> dict[str, Any]:
771894
"""Convert a database row to a dictionary.

0 commit comments

Comments
 (0)