Skip to content

Commit 604375d

Browse files
committed
incorporate feedback
1 parent c739145 commit 604375d

7 files changed

Lines changed: 148 additions & 33 deletions

File tree

dev/specs/ihs-138-store-merge/decisions.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -270,7 +270,7 @@ its results.
270270
## Summary table
271271

272272
| ID | Decision | Outcome |
273-
|----|----------|---------|
273+
| -- | -------- | ------- |
274274
| D1 | Returned vs stored object | Return per-query object; store holds the merged canonical copy |
275275
| D2 | In-place mutation model | Accept; one always-current merged object per node; protect local edits |
276276
| D3 | `store.set()` default | **Merge by default, uniformly** (override); replace is opt-in via `merge=False` |

infrahub_sdk/node/attribute.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,10 @@ def _merge(self, incoming: Attribute) -> None:
163163
fetched = incoming._fetched_fields
164164
if "value" in fetched or incoming.value_has_been_mutated:
165165
self._value = incoming._value
166+
# The pool-allocation intent travels with the value: a fetched copy carries
167+
# None, clearing any stale allocation request that would otherwise take
168+
# precedence over the value in the next mutation payload.
169+
self._from_pool = incoming._from_pool
166170
if incoming.value_has_been_mutated:
167171
self.value_has_been_mutated = True
168172
for field_name in (

infrahub_sdk/node/node.py

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242

4343
from ..client import InfrahubClient, InfrahubClientSync
4444
from ..context import RequestContext
45+
from ..protocols_base import CoreNodeBase
4546
from ..schema import MainSchemaTypesAPI
4647
from ..types import Order
4748

@@ -276,7 +277,7 @@ def get_node_metadata(self) -> NodeMetadata | None:
276277
return self._metadata
277278

278279
@staticmethod
279-
def _field_was_fetched(data: Any, name: str) -> bool:
280+
def _field_was_fetched(data: object, name: str) -> bool:
280281
"""Return whether a field was present in the response a node was built from.
281282
282283
Key-presence is the only unambiguous "was fetched" signal: an absent field and
@@ -316,7 +317,7 @@ def _get_request_context(self, request_context: RequestContext | None = None) ->
316317
def _init_relationships(self, data: dict | None = None) -> None:
317318
pass
318319

319-
def _merge(self, node: InfrahubNodeBase) -> None:
320+
def _merge(self, node: InfrahubNodeBase | CoreNodeBase) -> None:
320321
"""Merge a fresher copy of the same node into this one, field by field.
321322
322323
Used by the client store when a node with this UUID is fetched again: fields the
@@ -348,8 +349,8 @@ def _merge(self, node: InfrahubNodeBase) -> None:
348349
self._merge_raw_field(name=name, stored_data=stored_data, incoming_data=incoming_data)
349350

350351
for container_field in RELATIONSHIP_CONTAINER_FIELDS:
351-
incoming_bucket: dict[str, Any] = getattr(node, container_field, {})
352-
stored_bucket: dict[str, Any] = getattr(self, container_field, {})
352+
incoming_bucket: dict[str, RelatedNodeBase | RelationshipManagerBase] = getattr(node, container_field, {})
353+
stored_bucket: dict[str, RelatedNodeBase | RelationshipManagerBase] = getattr(self, container_field, {})
353354
for name, incoming_rel in incoming_bucket.items():
354355
if self._merge_relationship(stored_bucket, name, incoming_rel):
355356
self._merge_raw_field(name=name, stored_data=stored_data, incoming_data=incoming_data)
@@ -385,29 +386,32 @@ def _merge_attribute(self, name: str, incoming_attr: Attribute) -> bool:
385386
return True
386387

387388
@staticmethod
388-
def _merge_relationship(stored_bucket: dict[str, Any], name: str, incoming_rel: Any) -> bool:
389+
def _merge_relationship(
390+
stored_bucket: dict[str, RelatedNodeBase | RelationshipManagerBase],
391+
name: str,
392+
incoming_rel: RelatedNodeBase | RelationshipManagerBase,
393+
) -> bool:
389394
"""Merge one relationship entry into ``stored_bucket``; return whether it was taken."""
395+
stored_rel = stored_bucket.get(name)
390396
if isinstance(incoming_rel, RelatedNodeBase):
391397
if not incoming_rel.is_fetched and not incoming_rel._peer_has_been_mutated:
392398
return False
393-
stored_rel = stored_bucket.get(name)
394-
if stored_rel is None:
399+
if not isinstance(stored_rel, RelatedNodeBase):
395400
stored_bucket[name] = incoming_rel
396401
elif stored_rel._peer_has_been_mutated:
397402
return False
398403
else:
399404
stored_rel._merge(incoming_rel)
400405
return True
401406

402-
if not isinstance(incoming_rel, RelationshipManagerBase) or not incoming_rel.is_fetched:
407+
if not incoming_rel.is_fetched:
403408
return False
404-
stored_manager = stored_bucket.get(name)
405-
if stored_manager is None:
409+
if not isinstance(stored_rel, RelationshipManagerBase):
406410
stored_bucket[name] = incoming_rel
407-
elif stored_manager.has_update:
411+
elif stored_rel.has_update:
408412
return False
409413
else:
410-
stored_manager._merge(incoming_rel)
414+
stored_rel._merge(incoming_rel)
411415
return True
412416

413417
def _reset_mutation_tracking(self) -> None:
@@ -421,7 +425,8 @@ def _reset_mutation_tracking(self) -> None:
421425
for attr in self._attribute_data.values():
422426
attr.value_has_been_mutated = False
423427
for container_field in RELATIONSHIP_CONTAINER_FIELDS:
424-
for rel in getattr(self, container_field, {}).values():
428+
container: dict[str, RelatedNodeBase | RelationshipManagerBase] = getattr(self, container_field, {})
429+
for rel in container.values():
425430
if isinstance(rel, RelatedNodeBase):
426431
rel._peer_has_been_mutated = False
427432
elif isinstance(rel, RelationshipManagerBase):

infrahub_sdk/node/related_node.py

Lines changed: 35 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,14 @@ class RelatedNodeBase:
4444
4545
"""
4646

47+
# Relationship-edge properties, assigned dynamically in _init_properties from
48+
# PROPERTIES_FLAG / PROPERTIES_OBJECT; declared here so they are part of the
49+
# visible class surface.
50+
is_protected: bool | None
51+
updated_at: str | None
52+
source: str | None
53+
owner: str | None
54+
4755
def __init__(
4856
self,
4957
branch: str,
@@ -283,21 +291,32 @@ def _merge(self, incoming: RelatedNodeBase) -> None:
283291
"""Merge a fresher copy of this relationship into this one.
284292
285293
Which peer the relationship points at always refreshes (even to no peer, so a
286-
cleared relationship or a move-to-root is reflected). When the peer stays the
287-
same, its descriptive identity fields (hfid, display label, typename, kind) and
288-
the edge properties are only overwritten when the incoming fetch actually
289-
carried them, so a narrow payload does not null out previously fetched data.
290-
An incoming unsaved edit keeps its pending-mutation marker. Callers are
291-
responsible for the higher-level gates (``is_fetched`` on the incoming
292-
relationship, ``_peer_has_been_mutated`` on this one).
294+
cleared relationship or a move-to-root is reflected). A changed peer means a
295+
different relationship edge, so the incoming edge is taken wholesale,
296+
properties included. When the peer stays the same, its descriptive identity
297+
fields (hfid, display label, typename, kind) and the edge properties are only
298+
overwritten when the incoming fetch actually carried them, so a narrow payload
299+
does not null out previously fetched data. An incoming unsaved edit keeps its
300+
pending-mutation marker. Callers are responsible for the higher-level gates
301+
(``is_fetched`` on the incoming relationship, ``_peer_has_been_mutated`` on
302+
this one).
293303
"""
294-
if incoming._id != self._id:
304+
# An hfid-only relationship (no id on either side) changes peer when the hfid does.
305+
peer_changed = incoming._id != self._id or (incoming._id is None and incoming._hfid != self._hfid)
306+
if peer_changed:
295307
self._peer = incoming._peer
296308
self._id = incoming._id
297309
self._hfid = incoming._hfid
298310
self._display_label = incoming._display_label
299311
self._typename = incoming._typename
300312
self._kind = incoming._kind
313+
# The stored properties and metadata described the edge to the old peer;
314+
# they must not survive onto the new edge.
315+
for prop in self._properties:
316+
setattr(self, prop, getattr(incoming, prop))
317+
self._source_typename = incoming._source_typename
318+
self._relationship_metadata = incoming._relationship_metadata
319+
self._fetched_properties = incoming._fetched_properties
301320
else:
302321
if incoming._peer is not None:
303322
self._peer = incoming._peer
@@ -309,14 +328,14 @@ def _merge(self, incoming: RelatedNodeBase) -> None:
309328
self._typename = incoming._typename
310329
if incoming._kind is not None:
311330
self._kind = incoming._kind
312-
for prop in self._properties:
313-
if prop in incoming._fetched_properties:
314-
setattr(self, prop, getattr(incoming, prop))
315-
if prop == "source":
316-
self._source_typename = incoming._source_typename
317-
if incoming._relationship_metadata is not None:
318-
self._relationship_metadata = incoming._relationship_metadata
319-
self._fetched_properties = intern_frozenset(self._fetched_properties | incoming._fetched_properties)
331+
for prop in self._properties:
332+
if prop in incoming._fetched_properties:
333+
setattr(self, prop, getattr(incoming, prop))
334+
if prop == "source":
335+
self._source_typename = incoming._source_typename
336+
if incoming._relationship_metadata is not None:
337+
self._relationship_metadata = incoming._relationship_metadata
338+
self._fetched_properties = intern_frozenset(self._fetched_properties | incoming._fetched_properties)
320339
self.is_fetched = True
321340
self._peer_has_been_mutated = incoming._peer_has_been_mutated
322341

infrahub_sdk/protocols_base.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
import ipaddress
77

88
from .context import RequestContext
9+
from .node import InfrahubNodeBase
10+
from .node.attribute import Attribute as NodeAttribute
911
from .node.metadata import NodeMetadata
1012
from .schema import MainSchemaTypes
1113

@@ -175,6 +177,10 @@ class AnyAttributeOptional(Attribute):
175177
class CoreNodeBase:
176178
_schema: MainSchemaTypes
177179
_internal_id: str
180+
_data: dict | None
181+
_existing: bool
182+
_metadata: NodeMetadata | None
183+
_attribute_data: dict[str, NodeAttribute]
178184
id: str # NOTE this is incorrect, should be str | None
179185
display_label: str | None
180186
typename: str | None
@@ -211,7 +217,8 @@ def get_raw_graphql_data(self) -> dict | None: ...
211217

212218
def get_node_metadata(self) -> NodeMetadata | None: ...
213219

214-
def _merge(self, node: Any) -> None: ...
220+
def _merge(self, node: CoreNodeBase | InfrahubNodeBase) -> None:
221+
raise NotImplementedError
215222

216223

217224
class CoreNode(CoreNodeBase):

infrahub_sdk/store.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import inspect
44
import warnings
5-
from typing import TYPE_CHECKING, Any, Literal, cast, overload
5+
from typing import TYPE_CHECKING, Literal, overload
66

77
from infrahub_sdk.protocols_base import CoreNodeBase
88

@@ -68,7 +68,7 @@ def set(
6868
if merge and existing.get_kind() == node.get_kind():
6969
# Merge into the existing object and keep its internal id so every
7070
# reference already handed out by the store stays current.
71-
existing._merge(cast("Any", node))
71+
existing._merge(node)
7272
node = existing
7373
else:
7474
# Replace wholesale: explicit merge=False, or the node was converted

tests/unit/sdk/test_store_merge.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,86 @@ def test_merge_same_peer_keeps_identity_details_not_carried(
392392
assert stored.primary_tag.typename == "BuiltinTag"
393393

394394

395+
@pytest.mark.parametrize("client_type", client_types)
396+
def test_merge_clears_pool_allocation_intent_on_refetch(
397+
client_type: str, clients: BothClients, location_schema: NodeSchemaAPI
398+
) -> None:
399+
"""A fetched value clears a stale from_pool request.
400+
401+
A surviving from_pool would take precedence over the value in the next mutation
402+
payload and trigger a re-allocation.
403+
"""
404+
client, store, node_class = setup_store(client_type, clients)
405+
406+
pool_data = deep_location_data()
407+
pool_data["node"]["description"] = {"from_pool": {"id": "pppppppp-pppp-pppp-pppp-pppppppppppp"}}
408+
stored_node = node_class(client=client, schema=location_schema, data=pool_data)
409+
assert stored_node._attribute_data["description"]._from_pool is not None
410+
store.set(node=stored_node)
411+
412+
refetch = shallow_location_data()
413+
refetch["node"]["description"] = {"value": "allocated-value"}
414+
store.set(node=node_class(client=client, schema=location_schema, data=refetch))
415+
416+
assert get_location(store) is stored_node
417+
assert stored_node._attribute_data["description"].value == "allocated-value"
418+
assert stored_node._attribute_data["description"]._from_pool is None
419+
420+
421+
@pytest.mark.parametrize("client_type", client_types)
422+
def test_merge_peer_change_drops_old_edge_properties(
423+
client_type: str, clients: BothClients, location_schema: NodeSchemaAPI
424+
) -> None:
425+
"""A changed peer is a different edge: the old edge's properties must not survive."""
426+
client, store, node_class = setup_store(client_type, clients)
427+
428+
with_properties = deep_location_data()
429+
with_properties["node"]["primary_tag"] = {
430+
"properties": {
431+
"is_protected": True,
432+
"source": {
433+
"id": "ssssssss-ssss-ssss-ssss-ssssssssssss",
434+
"display_label": "crm",
435+
"__typename": "CoreAccount",
436+
},
437+
},
438+
"node": {"id": TAG_RED_ID, "display_label": "red", "__typename": "BuiltinTag"},
439+
}
440+
store.set(node=node_class(client=client, schema=location_schema, data=with_properties))
441+
442+
refetch = shallow_location_data()
443+
refetch["node"]["primary_tag"] = {
444+
"node": {"id": TAG_GREEN_ID, "display_label": "green", "__typename": "BuiltinTag"}
445+
}
446+
store.set(node=node_class(client=client, schema=location_schema, data=refetch))
447+
448+
stored = get_location(store)
449+
assert stored.primary_tag.id == TAG_GREEN_ID
450+
assert stored.primary_tag.is_protected is None
451+
assert stored.primary_tag.source is None
452+
453+
454+
@pytest.mark.parametrize("client_type", client_types)
455+
def test_merge_clears_hfid_only_relationship(
456+
client_type: str, clients: BothClients, location_schema: NodeSchemaAPI
457+
) -> None:
458+
"""A cleared relationship tracked only by hfid (no id on either side) is reflected."""
459+
client, store, node_class = setup_store(client_type, clients)
460+
461+
hfid_only = deep_location_data()
462+
hfid_only["node"]["primary_tag"] = {"node": {"hfid": ["red"], "__typename": "BuiltinTag"}}
463+
store.set(node=node_class(client=client, schema=location_schema, data=hfid_only))
464+
assert get_location(store).primary_tag.hfid == ["red"]
465+
466+
refetch = shallow_location_data()
467+
refetch["node"]["primary_tag"] = {"node": None}
468+
store.set(node=node_class(client=client, schema=location_schema, data=refetch))
469+
470+
stored = get_location(store)
471+
assert stored.primary_tag.hfid is None
472+
assert stored.primary_tag.initialized is False
473+
474+
395475
@pytest.mark.parametrize("client_type", client_types)
396476
def test_merge_propagates_unsaved_edits(client_type: str, clients: BothClients, location_schema: NodeSchemaAPI) -> None:
397477
"""Unsaved edits merged into the store keep their pending-mutation markers.

0 commit comments

Comments
 (0)