Skip to content

Commit 4bcb436

Browse files
docs: add deep merge design spec and implementation plan
1 parent 7d9b764 commit 4bcb436

1 file changed

Lines changed: 124 additions & 0 deletions

File tree

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
# Design: Deep Merge (`merge()`)
2+
3+
**Date:** 2026-05-22
4+
**Status:** Approved
5+
**Relates to:** `doc/todo/feature-deep-merge.md`
6+
7+
## Summary
8+
9+
Add a `merge()` method that recursively merges a value into an existing
10+
mapping without removing keys not present in the target. Lists replace
11+
entirely (list identity is ambiguous); only mapping keys get additive
12+
treatment.
13+
14+
## Semantics
15+
16+
| Current type | Target type | Behavior |
17+
|---|---|---|
18+
| mapping | mapping | Recurse: update matching keys, add new keys, never remove existing keys |
19+
| list | list | Replace the list entirely |
20+
| scalar | mapping | Replace scalar with mapping (promote) |
21+
| mapping | scalar | Replace mapping with scalar |
22+
| any | any (equal) | No-op |
23+
| missing | any | Create path + set value (delegate to `upsert()`) |
24+
25+
Full-depth recursion always. No configurable depth parameter.
26+
27+
## Public API
28+
29+
```python
30+
# Document (immutable, returns new Document)
31+
doc = doc.merge(*keys, value={"debug": False, "timeout": 30})
32+
33+
# Editor (mutable context manager)
34+
with edit("config.yaml") as ed:
35+
ed.merge("settings", value={"debug": False, "timeout": 30})
36+
```
37+
38+
Signature: `merge(self, *keys: KeyPart, value: Any) -> Document`
39+
40+
Matches `sync()` signature exactly.
41+
42+
## Error behavior
43+
44+
- `NodeTypeError` if path traverses through a scalar/list where a mapping
45+
is expected
46+
- `PatchError` on Rust-level failures (same fallback as `sync()`)
47+
- No new error types
48+
49+
## Scope
50+
51+
- No new Rust code — reuses existing patch primitives
52+
- No new error types
53+
- `sync()` behavior unchanged
54+
- Available on both `Document` and `Editor`
55+
56+
## Examples
57+
58+
```python
59+
from yamltrip import loads
60+
61+
doc = loads("""
62+
settings:
63+
debug: true
64+
log_level: info
65+
custom_setting: 42
66+
""")
67+
68+
# Merge ensures debug+timeout exist without removing log_level/custom_setting
69+
doc = doc.merge("settings", value={"debug": False, "timeout": 30})
70+
71+
# Result:
72+
# settings:
73+
# debug: false
74+
# log_level: info
75+
# custom_setting: 42
76+
# timeout: 30
77+
```
78+
79+
```python
80+
# Lists replace entirely
81+
doc = loads("""
82+
plugins:
83+
- eslint
84+
- prettier
85+
""")
86+
87+
doc = doc.merge("plugins", value=["stylelint"])
88+
89+
# Result:
90+
# plugins:
91+
# - stylelint
92+
```
93+
94+
```python
95+
# Nested merge
96+
doc = loads("""
97+
database:
98+
host: localhost
99+
credentials:
100+
user: admin
101+
password: secret
102+
""")
103+
104+
doc = doc.merge("database", value={"credentials": {"user": "deploy"}, "port": 5432})
105+
106+
# Result:
107+
# database:
108+
# host: localhost
109+
# credentials:
110+
# user: deploy
111+
# password: secret
112+
# port: 5432
113+
```
114+
115+
## Testing
116+
117+
- Mapping merge: keeps extra keys, updates matching, adds new
118+
- Nested mapping merge: recurses correctly
119+
- List replacement: lists in target replace entirely
120+
- Scalar-to-mapping promotion: works
121+
- Missing path creation: delegates to upsert
122+
- No-op when values equal: returns same Document instance
123+
- Flow sequence handling: same fallback as sync
124+
- Editor delegation: verify Editor.merge works

0 commit comments

Comments
 (0)