Repository: omry/omegaconf

Issue:
[bug] OmegaConf.merge should respect interpolations
```py
from omegaconf import OmegaConf

first = OmegaConf.create({
    "a": {
        "aa": 1,
        "ab": 2
    },
    "b": "${a}"
})

second = OmegaConf.create({
    "b": {
        "ba": 1,
        "bb": 2
    }
})

third = OmegaConf.merge(first, second)


expected = OmegaConf.create({
   "a": {
        "aa": 1,
        "ab": 2
   },
   "b": { 
        "aa": 1,
        "ab": 2,
        "ba": 1,
        "bb": 2
    }       
})
assert third == expected
# throws AssertionError
```

Version: 2.0.0rc16

Discussion:
deepcode-ci-bot[bot]: <img src="https://www.deepcode.ai/icons/green_check.svg" width= "50px" align= "left"/> Congratulations :tada:. DeepCode [analyzed](https://www.deepcode.ai/app/gh/omry/omegaconf/b6a35f255e8ff20f992f05e96fc73686044c67d9/omry/omegaconf/8d1121ef9c5cffee7149be8c36613931d3810a46/_/%2F/code/?utm_source=gh_review) your code in 1.654 seconds and we found no issues. Enjoy a moment of no bugs :sunny:.

##### 👉 View analysis in [**DeepCode’s Dashboard**](https://www.deepcode.ai/app/gh/omry/omegaconf/b6a35f255e8ff20f992f05e96fc73686044c67d9/omry/omegaconf/8d1121ef9c5cffee7149be8c36613931d3810a46/_/%2F/code/?utm_source=gh_review)
---


Patch:
diff --git a/news/184.feature b/news/184.feature
new file mode 100644
index 0000000..bf4c914
--- /dev/null
+++ b/news/184.feature
@@ -0,0 +1,1 @@
+Merge into node interpolation is now by value (copying target node)
diff --git a/omegaconf/base.py b/omegaconf/base.py
index 52177ce..ad8fd44 100644
--- a/omegaconf/base.py
+++ b/omegaconf/base.py
@@ -101,7 +101,9 @@ class Node(ABC):
     def _get_full_key(self, key: Union[str, Enum, int, None]) -> str:
         ...  # pragma: no cover
 
-    def _dereference_node(self, throw_on_missing: bool = False) -> "Node":
+    def _dereference_node(
+        self, throw_on_missing: bool = False, throw_on_resolution_failure: bool = True
+    ) -> Optional["Node"]:
         from .nodes import StringNode
 
         if self._is_interpolation():
@@ -118,12 +120,18 @@ class Node(ABC):
                     inter_type=match.group(1),
                     inter_key=match.group(2),
                     throw_on_missing=throw_on_missing,
+                    throw_on_resolution_failure=throw_on_resolution_failure,
                 )
                 return v
             elif value_kind == ValueKind.STR_INTERPOLATION:
                 ret = parent._resolve_str_interpolation(
-                    key=key, value=self, throw_on_missing=throw_on_missing
+                    key=key,
+                    value=self,
+                    throw_on_missing=throw_on_missing,
+                    throw_on_resolution_failure=throw_on_resolution_failure,
                 )
+                if ret is None:
+                    return ret
                 return StringNode(
                     value=ret,
                     key=key,
@@ -231,7 +239,7 @@ class Container(Node):
         return root
 
     def _select_impl(
-        self, key: str, throw_on_missing: bool
+        self, key: str, throw_on_missing: bool, throw_on_resolution_failure: bool
     ) -> Tuple[Optional["Container"], Optional[str], Optional[Node]]:
         """
         Select a value using dot separated key sequence
@@ -249,7 +257,12 @@ class Container(Node):
             if root is None:
                 break
             k = split[i]
-            ret, _ = _select_one(c=root, key=k, throw_on_missing=throw_on_missing)
+            ret, _ = _select_one(
+                c=root,
+                key=k,
+                throw_on_missing=throw_on_missing,
+                throw_on_type_error=throw_on_resolution_failure,
+            )
             assert ret is None or isinstance(ret, Container)
             root = ret
 
@@ -257,17 +270,30 @@ class Container(Node):
             return None, None, None
 
         last_key = split[-1]
-        value, _ = _select_one(c=root, key=last_key, throw_on_missing=throw_on_missing)
+        value, _ = _select_one(
+            c=root,
+            key=last_key,
+            throw_on_missing=throw_on_missing,
+            throw_on_type_error=throw_on_resolution_failure,
+        )
         if value is None:
             return root, last_key, value
         value = root._resolve_str_interpolation(
-            key=last_key, value=value, throw_on_missing=False
+            key=last_key,
+            value=value,
+            throw_on_missing=False,
+            throw_on_resolution_failure=True,
         )
         return root, last_key, value
 
     def _resolve_interpolation(
-        self, key: Any, inter_type: str, inter_key: str, throw_on_missing: bool,
-    ) -> "Node":
+        self,
+        key: Any,
+        inter_type: str,
+        inter_key: str,
+        throw_on_missing: bool,
+        throw_on_resolution_failure: bool,
+    ) -> Optional["Node"]:
         from omegaconf import OmegaConf
 
         from .nodes import ValueNode
@@ -277,13 +303,18 @@ class Container(Node):
         inter_type = ("str:" if inter_type is None else inter_type)[0:-1]
         if inter_type == "str":
             parent, last_key, value = root_node._select_impl(
-                inter_key, throw_on_missing=throw_on_missing
+                inter_key,
+                throw_on_missing=throw_on_missing,
+                throw_on_resolution_failure=throw_on_resolution_failure,
             )
 
             if parent is None or (value is None and last_key not in parent):  # type: ignore
-                raise KeyError(
-                    f"{inter_type} interpolation key '{inter_key}' not found"
-                )
+                if throw_on_resolution_failure:
+                    raise KeyError(
+                        f"{inter_type} interpolation key '{inter_key}' not found"
+                    )
+                else:
+                    return None
             assert isinstance(value, Node)
             return value
         else:
@@ -302,12 +333,19 @@ class Container(Node):
                     self._format_and_raise(key=inter_key, value=None, cause=e)
                     assert False
             else:
-                raise UnsupportedInterpolationType(
-                    f"Unsupported interpolation type {inter_type}"
-                )
+                if throw_on_resolution_failure:
+                    raise UnsupportedInterpolationType(
+                        f"Unsupported interpolation type {inter_type}"
+                    )
+                else:
+                    return None
 
     def _resolve_str_interpolation(
-        self, key: Any, value: "Node", throw_on_missing: bool
+        self,
+        key: Any,
+        value: "Node",
+        throw_on_missing: bool,
+        throw_on_resolution_failure: bool,
     ) -> Any:
         from .nodes import StringNode
 
@@ -323,6 +361,7 @@ class Container(Node):
                 inter_type=match.group(1),
                 inter_key=match.group(2),
                 throw_on_missing=throw_on_missing,
+                throw_on_resolution_failure=throw_on_resolution_failure,
             )
         elif value_kind == ValueKind.STR_INTERPOLATION:
             value = _get_value(value)
@@ -336,7 +375,11 @@ class Container(Node):
                     inter_type=match.group(1),
                     inter_key=match.group(2),
                     throw_on_missing=throw_on_missing,
+                    throw_on_resolution_failure=throw_on_resolution_failure,
                 )
+                # if failed to resolve, return None for the whole thing.
+                if new_val is None:
+                    return None
                 new += orig[last_index : match.start(0)] + str(new_val)
                 last_index = match.end(0)
 
diff --git a/omegaconf/basecontainer.py b/omegaconf/basecontainer.py
index 07e7d7e..6dbf1c8 100644
--- a/omegaconf/basecontainer.py
+++ b/omegaconf/basecontainer.py
@@ -54,7 +54,10 @@ class BaseContainer(Container, ABC):
             value = default_value
 
         resolved = self._resolve_str_interpolation(
-            key=key, value=value, throw_on_missing=True
+            key=key,
+            value=value,
+            throw_on_missing=True,
+            throw_on_resolution_failure=True,
         )
         if is_mandatory_missing(resolved):
             raise MissingMandatoryValue("Missing mandatory value: $FULL_KEY")
@@ -222,8 +225,17 @@ class BaseContainer(Container, ABC):
                     if OmegaConf.is_missing(dest, key):
                         dest[key] = src_value
 
-            if (dest._get_node(key) is not None) or element_typed:
-                dest_node = dest._get_node(key)
+            dest_node = dest._get_node(key)
+            if dest_node is not None and dest_node._is_interpolation():
+                target_node = dest_node._dereference_node(
+                    throw_on_resolution_failure=False
+                )
+                if isinstance(target_node, Container):
+                    dest_node = copy.deepcopy(target_node)
+                    dest[key] = dest_node
+
+            if dest_node is not None or element_typed:
+
                 if dest_node is None and element_typed:
                     dest[key] = DictConfig(content=dest_element_type, parent=dest)
                     dest_node = dest._get_node(key)
@@ -360,53 +372,51 @@ class BaseContainer(Container, ABC):
 
     @staticmethod
     def _item_eq(
-        c1: "BaseContainer",
-        k1: Union[str, int],
-        c2: "BaseContainer",
-        k2: Union[str, int],
+        c1: Container, k1: Union[str, int], c2: Container, k2: Union[str, int],
     ) -> bool:
         v1 = c1._get_node(k1)
         v2 = c2._get_node(k2)
-        v1_kind = get_value_kind(v1)
-        v2_kind = get_value_kind(v2)
+        assert v1 is not None and v2 is not None
 
-        if v1_kind == v2_kind and v1_kind == ValueKind.MANDATORY_MISSING:
+        if v1._is_none() and v2._is_none():
             return True
 
-        v1 = _get_value(v1)
-        v2 = _get_value(v2)
-
-        # special case for two interpolations. just compare them literally.
-        # This is helping in cases where the two interpolations are not resolvable
-        # but the objects are still considered equal.
-        if v1_kind in (
-            ValueKind.STR_INTERPOLATION,
-            ValueKind.INTERPOLATION,
-        ) and v2_kind in (ValueKind.STR_INTERPOLATION, ValueKind.INTERPOLATION):
+        if v1._is_missing() and v2._is_missing():
             return True
-        if isinstance(v1, str):
-            v1 = c1._resolve_str_interpolation(key=k1, value=v1, throw_on_missing=False)
-        if isinstance(v2, str):
-            v2 = c2._resolve_str_interpolation(key=k2, value=v2, throw_on_missing=False)
 
-        if isinstance(v1, BaseContainer) and isinstance(v2, BaseContainer):
-            if not BaseContainer._config_eq(v1, v2):
-                return False
-        return v1 == v2
+        v1_inter = v1._is_interpolation()
+        v2_inter = v2._is_interpolation()
+        dv1: Optional[Node] = v1
+        dv2: Optional[Node] = v2
 
-    @staticmethod
-    def _config_eq(c1: "BaseContainer", c2: "BaseContainer") -> bool:
-        from .dictconfig import DictConfig
-        from .listconfig import ListConfig
+        if v1_inter:
+            dv1 = v1._dereference_node(
+                throw_on_missing=False, throw_on_resolution_failure=False
+            )
+        if v2_inter:
+            dv2 = v2._dereference_node(
+                throw_on_missing=False, throw_on_resolution_failure=False
+            )
 
-        assert isinstance(c1, Container)
-        assert isinstance(c2, Container)
-        if isinstance(c1, DictConfig) and isinstance(c2, DictConfig):
-            return DictConfig._dict_conf_eq(c1, c2)
-        if isinstance(c1, ListConfig) and isinstance(c2, ListConfig):
-            return ListConfig._list_eq(c1, c2)
-        # if type does not match objects are different
-        return False
+        if v1_inter and v2_inter:
+            if dv1 is None or dv2 is None:
+                return v1 == v2
+            else:
+                # both are not none, if both are containers compare as container
+                if isinstance(dv1, Container) and isinstance(dv2, Container):
+                    if dv1 != dv2:
+                        return False
+                dv1 = _get_value(dv1)
+                dv2 = _get_value(dv2)
+                return dv1 == dv2
+        elif not v1_inter and not v2_inter:
+            v1 = _get_value(v1)
+            v2 = _get_value(v2)
+            return v1 == v2
+        else:
+            dv1 = _get_value(dv1)
+            dv2 = _get_value(dv2)
+            return dv1 == dv2
 
     def _re_parent(self) -> None:
         from .dictconfig import DictConfig
diff --git a/omegaconf/dictconfig.py b/omegaconf/dictconfig.py
index f23a4b1..3d041af 100644
--- a/omegaconf/dictconfig.py
+++ b/omegaconf/dictconfig.py
@@ -446,7 +446,8 @@ class DictConfig(BaseContainer, MutableMapping[str, Any]):  # type: ignore
         if other is None:
             return self.__dict__["_content"] is None
         if is_primitive_dict(other) or is_structured_config(other):
-            return DictConfig._dict_conf_eq(self, DictConfig(other))
+            other = DictConfig(other)
+            return DictConfig._dict_conf_eq(self, other)
         if isinstance(other, DictConfig):
             return DictConfig._dict_conf_eq(self, other)
         return NotImplemented
diff --git a/omegaconf/listconfig.py b/omegaconf/listconfig.py
index fed3f70..59a01d8 100644
--- a/omegaconf/listconfig.py
+++ b/omegaconf/listconfig.py
@@ -328,7 +328,8 @@ class ListConfig(BaseContainer, MutableSequence[Any]):
 
     def __eq__(self, other: Any) -> bool:
         if isinstance(other, (list, tuple)) or other is None:
-            return ListConfig._list_eq(self, ListConfig(other))
+            other = ListConfig(other)
+            return ListConfig._list_eq(self, other)
         if other is None or isinstance(other, ListConfig):
             return ListConfig._list_eq(self, other)
         return NotImplemented
diff --git a/omegaconf/nodes.py b/omegaconf/nodes.py
index fde6d65..50b78fa 100644
--- a/omegaconf/nodes.py
+++ b/omegaconf/nodes.py
@@ -70,16 +70,35 @@ class ValueNode(Node):
         res.__dict__ = copy.deepcopy(self.__dict__, memo=memo)
 
     def _is_none(self) -> bool:
-        node = self._dereference_node()
+        if self._is_interpolation():
+            node = self._dereference_node(
+                throw_on_resolution_failure=False, throw_on_missing=False
+            )
+            if node is None:
+                # missing or resolution failure
+                return False
+        else:
+            node = self
         return node._value() is None
 
     def _is_optional(self) -> bool:
         node = self._dereference_node()
+        assert node is not None
         return node._metadata.optional
 
     def _is_missing(self) -> bool:
         try:
-            node = self._dereference_node(throw_on_missing=True)
+            if self._is_interpolation():
+                node = self._dereference_node(
+                    throw_on_resolution_failure=False, throw_on_missing=True
+                )
+                if node is None:
+                    # resolution failure
+                    return False
+            else:
+                node = self
+
+            assert node is not None
             if isinstance(node, Container):
                 ret = node._is_missing()
             else:
diff --git a/omegaconf/omegaconf.py b/omegaconf/omegaconf.py
index eb7cf5f..f95b9d6 100644
--- a/omegaconf/omegaconf.py
+++ b/omegaconf/omegaconf.py
@@ -491,7 +491,10 @@ class OmegaConf:
     @staticmethod
     def select(cfg: Container, key: str, throw_on_missing: bool = False) -> Any:
         try:
-            _root, _last_key, value = cfg._select_impl(key, throw_on_missing=False)
+            # TODO: should be throw_on_missing=throw_on_missing ?
+            _root, _last_key, value = cfg._select_impl(
+                key, throw_on_missing=False, throw_on_resolution_failure=True
+            )
             if value is not None and value._is_missing():
                 if throw_on_missing:
                     raise MissingMandatoryValue("missing node selected")


Test patch:
diff --git a/tests/test_base_config.py b/tests/test_base_config.py
index 39a42d1..fd1a58b 100644
--- a/tests/test_base_config.py
+++ b/tests/test_base_config.py
@@ -434,7 +434,10 @@ def test_resolve_str_interpolation(query: str, result: Any) -> None:
     cfg = OmegaConf.create({"foo": 10, "bar": "${foo}"})
     assert (
         cfg._resolve_str_interpolation(
-            key=None, value=StringNode(value=query), throw_on_missing=False
+            key=None,
+            value=StringNode(value=query),
+            throw_on_missing=False,
+            throw_on_resolution_failure=True,
         )
         == result
     )
diff --git a/tests/test_config_eq.py b/tests/test_config_eq.py
index bbb1d05..1ed5f09 100644
--- a/tests/test_config_eq.py
+++ b/tests/test_config_eq.py
@@ -1,38 +1,37 @@
-from typing import Any, List
+from typing import Any
 
 import pytest
 
 from omegaconf import AnyNode, DictConfig, OmegaConf
-from omegaconf.basecontainer import BaseContainer
 
 from . import Group, User
 
 
 @pytest.mark.parametrize(  # type: ignore
-    "l1,l2",
+    "i1,i2",
     [
         # === LISTS ===
         # empty list
-        ([], []),
+        pytest.param([], [], id="empty"),
         # simple list
-        (["a", 12, "15"], ["a", 12, "15"]),
+        pytest.param(["a", 12, "15"], ["a", 12, "15"], id="simple_list"),
         # raw vs any
         ([1, 2, 12], [1, 2, AnyNode(12)]),
         # nested empty dict
-        ([12, dict()], [12, dict()]),
+        ([12, {}], [12, {}]),
         # nested dict
-        ([12, dict(c=10)], [12, dict(c=10)]),
+        ([12, {"c": 10}], [12, {"c": 10}]),
         # nested list
         ([1, 2, 3, [10, 20, 30]], [1, 2, 3, [10, 20, 30]]),
         # nested list with any
         ([1, 2, 3, [1, 2, AnyNode(3)]], [1, 2, 3, [1, 2, AnyNode(3)]]),
-        # === DICTS ==
+        # === DICTS ===
         # empty
-        (dict(), dict()),
+        ({}, {}),
         # simple
-        (dict(a=12), dict(a=12)),
+        ({"a": 12}, {"a": 12}),
         # any vs raw
-        (dict(a=12), dict(a=AnyNode(12))),
+        ({"a": 12}, {"a": AnyNode(12)}),
         # nested dict empty
         (dict(a=12, b=dict()), dict(a=12, b=dict())),
         # nested dict
@@ -44,21 +43,32 @@ from . import Group, User
         # In python 3.6+ insert order changes iteration order. this ensures that equality is preserved.
         (dict(a=1, b=2, c=3, d=4, e=5), dict(e=5, b=2, c=3, d=4, a=1)),
         (DictConfig(content=None), DictConfig(content=None)),
+        pytest.param({"a": [1, 2]}, {"a": [1, 2]}, id="list_in_dict"),
         # With interpolations
         ([10, "${0}"], [10, 10]),
         (dict(a=12, b="${a}"), dict(a=12, b=12)),
         # With missing interpolation
-        ([10, "${0}"], [10, 10]),
-        (dict(a="${missing}"), dict(a="${missing}")),
-        (User, User),
-        ({"name": "poo", "age": 7}, User(name="poo", age=7)),
-        (Group, Group),
-        ({"group": {"admin": None}}, {"group": Group}),
+        pytest.param([10, "${0}"], [10, 10], id="list_simple_interpolation"),
+        pytest.param(
+            {"a": "${ref_error}"}, {"a": "${ref_error}"}, id="dict==dict,ref_error"
+        ),
+        pytest.param({"a": "???"}, {"a": "???"}, id="dict==dict,missing"),
+        pytest.param(User, User, id="User==User"),
+        pytest.param(
+            {"name": "poo", "age": 7}, User(name="poo", age=7), id="dict==User"
+        ),
+        pytest.param(Group, Group, id="Group==Group"),
+        pytest.param({"group": {"admin": None}}, {"group": Group}, id="dict==Group"),
+        pytest.param(
+            {"i1": "${n1}", "n1": {"a": 10}},
+            {"i1": "${n1}", "n1": {"a": 10}},
+            id="node_interpolation",
+        ),
     ],
 )
-def test_eq(l1: List[Any], l2: List[Any]) -> None:
-    c1 = OmegaConf.create(l1)
-    c2 = OmegaConf.create(l2)
+def test_eq(i1: Any, i2: Any) -> None:
+    c1 = OmegaConf.create(i1)
+    c2 = OmegaConf.create(i2)
 
     def eq(a: Any, b: Any) -> None:
         assert a == b
@@ -67,32 +77,41 @@ def test_eq(l1: List[Any], l2: List[Any]) -> None:
         assert not b != a
 
     eq(c1, c2)
-    eq(c1, l1)
-    eq(c2, l2)
+    eq(c1, i1)
+    eq(c2, i2)
 
 
 @pytest.mark.parametrize(  # type: ignore
     "input1, input2",
     [
         # Dicts
-        ({}, {"a": 10}),
-        ({}, []),
-        ({}, None),
-        (dict(a=12), dict(a=13)),
-        (dict(a=0), dict(b=0)),
-        (dict(a=12), dict(a=AnyNode(13))),
-        (dict(a=12, b=dict()), dict(a=13, b=dict())),
-        (dict(a=12, b=dict(c=10)), dict(a=13, b=dict(c=10))),
-        (dict(a=12, b=[1, 2, 3]), dict(a=12, b=[10, 2, 3])),
-        (dict(a=12, b=[1, 2, AnyNode(3)]), dict(a=12, b=[1, 2, AnyNode(30)])),
+        pytest.param({}, {"a": 10}, id="empty_dict_neq_dict"),
+        pytest.param({}, [], id="empty_dict_vs_list"),
+        pytest.param({}, None, id="dict_neq_none"),
+        pytest.param({"foo": None}, {"foo": "bar"}, id="dict_none_neq_dict_not_none"),
+        pytest.param({"a": 12}, {"a": 13}, id="simple_dict_neq"),
+        pytest.param({"a": 0}, {"b": 0}, id="different_key_same_value"),
+        pytest.param(dict(a=12), dict(a=AnyNode(13))),
+        pytest.param(dict(a=12, b=dict()), dict(a=13, b=dict())),
+        pytest.param(dict(a=12, b=dict(c=10)), dict(a=13, b=dict(c=10))),
+        pytest.param(dict(a=12, b=[1, 2, 3]), dict(a=12, b=[10, 2, 3])),
+        pytest.param(
+            dict(a=12, b=[1, 2, AnyNode(3)]), dict(a=12, b=[1, 2, AnyNode(30)])
+        ),
         # Lists
-        ([], [10]),
-        ([10], [11]),
+        pytest.param([], [10], id="list:empty_vs_full"),
+        pytest.param([10], [11], id="list:different_value"),
         ([12], [AnyNode(13)]),
         ([12, dict()], [13, dict()]),
         ([12, dict(c=10)], [13, dict(c=10)]),
         ([12, [1, 2, 3]], [12, [10, 2, 3]]),
         ([12, [1, 2, AnyNode(3)]], [12, [1, 2, AnyNode(30)]]),
+        (dict(a="${foo1}"), dict(a="${foo2}")),
+        pytest.param(
+            {"i1": "${n1}", "n1": {"a": 10}},
+            {"i1": "${n1}", "n1": {"a": 20}},
+            id="node_interpolation",
+        ),
     ],
 )
 def test_not_eq(input1: Any, input2: Any) -> None:
@@ -113,8 +132,7 @@ def test_not_eq(input1: Any, input2: Any) -> None:
 def test_config_eq_mismatch_types() -> None:
     c1 = OmegaConf.create({})
     c2 = OmegaConf.create([])
-    assert not BaseContainer._config_eq(c1, c2)
-    assert not BaseContainer._config_eq(c2, c1)
+    assert c1 != c2
 
 
 def test_dict_not_eq_with_another_class() -> None:
diff --git a/tests/test_merge.py b/tests/test_merge.py
index 5d6eaee..4f38fe7 100644
--- a/tests/test_merge.py
+++ b/tests/test_merge.py
@@ -36,14 +36,62 @@ from . import ConcretePlugin, ConfWithMissingDict, Group, Plugin, User, Users
         (([[1, 2, 3]], [[4, 5, 6]]), [[4, 5, 6]]),
         (([1, 2, {"a": 10}], [4, 5, {"b": 20}]), [4, 5, {"b": 20}]),
         # Interpolations
-        (
-            ({"data": 123, "reference": "${data}"}, {"data": 456}),
-            {"data": 456, "reference": 456},
+        # value interpolation
+        pytest.param(
+            ({"d1": 1, "inter": "${d1}"}, {"d1": 2}),
+            {"d1": 2, "inter": 2},
+            id="inter:updating_data",
         ),
-        (({"missing": "${data}"}, {"missing": 123}), {"missing": 123}),
-        (
-            ({"missing": 123}, {"missing": "${data}"}, {"missing": 456}),
-            {"missing": 456},
+        pytest.param(
+            ({"d1": 1, "d2": 2, "inter": "${d1}"}, {"inter": "${d2}"}),
+            {"d1": 1, "d2": 2, "inter": 2},
+            id="inter:value_inter_over_value_inter",
+        ),
+        pytest.param(
+            ({"inter": "${d1}"}, {"inter": 123}),
+            {"inter": 123},
+            id="inter:data_over_value_inter",
+        ),
+        pytest.param(
+            ({"inter": "${d1}", "d1": 1, "n1": {"foo": "bar"}}, {"inter": "${n1}"}),
+            {"inter": {"foo": "bar"}, "d1": 1, "n1": {"foo": "bar"}},
+            id="inter:node_inter_over_value_inter",
+        ),
+        pytest.param(
+            ({"inter": 123}, {"inter": "${data}"}),
+            {"inter": "${data}"},
+            id="inter:inter_over_data",
+        ),
+        # node interpolation
+        pytest.param(
+            ({"n": {"a": 10}, "i": "${n}"}, {"n": {"a": 20}}),
+            {"n": {"a": 20}, "i": {"a": 20}},
+            id="node_inter:node_update",
+        ),
+        pytest.param(
+            ({"d": 20, "n": {"a": 10}, "i": "${n}"}, {"i": "${d}"}),
+            {"d": 20, "n": {"a": 10}, "i": 20},
+            id="node_inter:value_inter_over_node_inter",
+        ),
+        pytest.param(
+            ({"n": {"a": 10}, "i": "${n}"}, {"i": 30}),
+            {"n": {"a": 10}, "i": 30},
+            id="node_inter:data_over_node_inter",
+        ),
+        pytest.param(
+            ({"n1": {"a": 10}, "n2": {"b": 20}, "i": "${n1}"}, {"i": "${n2}"}),
+            {"n1": {"a": 10}, "n2": {"b": 20}, "i": {"b": 20}},
+            id="node_inter:node_inter_over_node_inter",
+        ),
+        pytest.param(
+            ({"v": 10, "n": {"a": 20}}, {"v": "${n}"}),
+            {"v": {"a": 20}, "n": {"a": 20}},
+            id="inter:node_inter_over_data",
+        ),
+        pytest.param(
+            ({"n": {"a": 10}, "i": "${n}"}, {"i": {"b": 20}}),
+            {"n": {"a": 10}, "i": {"a": 10, "b": 20}},
+            id="inter:node_over_node_interpolation",
         ),
         # Structured configs
         (({"user": User}, {}), {"user": User(name=MISSING, age=MISSING)}),
@@ -72,7 +120,11 @@ from . import ConcretePlugin, ConfWithMissingDict, Group, Plugin, User, Users
             {"name2user": {"joe": {"name": "joe", "age": MISSING}}},
         ),
         ([ConfWithMissingDict, {"dict": {"foo": "bar"}}], {"dict": {"foo": "bar"}}),
-        ([{}, ConfWithMissingDict], {"dict": "???"}),
+        pytest.param(
+            [{}, ConfWithMissingDict],
+            {"dict": "???"},
+            id="merge_missing_dict_into_missing_dict",
+        ),
         ([{"user": User}, {"user": Group}], pytest.raises(ValidationError)),
         (
             [{"user": DictConfig(ref_type=User, content=User)}, {"user": Group}],