From 170ce6a04a56a604aeb95aeca5f8e2a22fa5966b Mon Sep 17 00:00:00 2001 From: dextmorgn <64375473+dextmorgn@users.noreply.github.com> Date: Sun, 20 Sep 2026 19:05:48 +0200 Subject: [PATCH 1/2] test(core): repro partial-write clobber on Website/Domain nodes --- .../tests/core/graph/test_graph_serializer.py | 43 ++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/flowsint-core/tests/core/graph/test_graph_serializer.py b/flowsint-core/tests/core/graph/test_graph_serializer.py index 292f3500..dc000c18 100644 --- a/flowsint-core/tests/core/graph/test_graph_serializer.py +++ b/flowsint-core/tests/core/graph/test_graph_serializer.py @@ -8,7 +8,7 @@ GraphSerializer, NodeMetadata, ) -from flowsint_types import Domain, Ip +from flowsint_types import Domain, Ip, Website def test_serializer(): @@ -112,6 +112,47 @@ def test_serialize_from_flowsint_type(): assert "T" in neo4j_dict["nodeMetadata.created_at"] +class TestNodePropertiesExcludeUnset: + def test_thin_website_only_sends_fields_it_set(self): + website = Website(url="https://example.com/") + node = GraphNode( + id="id", + nodeLabel="https://example.com/", + nodeType="website", + nodeProperties=website, + nodeMetadata=NodeMetadata(), + ) + neo4j_dict = GraphSerializer.graph_node_to_neo4j_dict(node) + + assert neo4j_dict["nodeProperties.url"] == "https://example.com/" + for unset_field in ( + "active", + "status_code", + "headers", + "title", + "description", + "content", + "technologies", + "redirects", + "domain", + ): + assert f"nodeProperties.{unset_field}" not in neo4j_dict + + def test_richer_website_keeps_explicitly_set_fields(self): + website = Website(url="https://example.com/", active=False, status_code=404) + node = GraphNode( + id="id", + nodeLabel="https://example.com/", + nodeType="website", + nodeProperties=website, + nodeMetadata=NodeMetadata(), + ) + neo4j_dict = GraphSerializer.graph_node_to_neo4j_dict(node) + + assert neo4j_dict["nodeProperties.active"] is False + assert neo4j_dict["nodeProperties.status_code"] == 404 + + class TestCleanEmptyValues: def test_removes_empty_strings(self): data = {"key1": "value", "key2": "", "key3": "another"} From aa97124df058f4f297a51f6f352e3784cf5c8c97 Mon Sep 17 00:00:00 2001 From: dextmorgn <64375473+dextmorgn@users.noreply.github.com> Date: Sun, 20 Sep 2026 19:05:50 +0200 Subject: [PATCH 2/2] fix(core): stop node upsert from nulling unset FlowsintType fields --- .../src/flowsint_core/core/graph/serializer.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/flowsint-core/src/flowsint_core/core/graph/serializer.py b/flowsint-core/src/flowsint_core/core/graph/serializer.py index 2054c095..3b15f8e4 100644 --- a/flowsint-core/src/flowsint_core/core/graph/serializer.py +++ b/flowsint-core/src/flowsint_core/core/graph/serializer.py @@ -7,7 +7,7 @@ from typing import Any, Callable, Dict, List, Optional, Type, Union -from pydantic import ValidationError +from pydantic import BaseModel, ValidationError from flowsint_core.utils import flatten, unflatten from flowsint_types import FlowsintType @@ -160,8 +160,20 @@ def graph_node_to_neo4j_dict(node: GraphNode) -> Dict[str, Any]: Serializes the model to JSON-compatible types and flattens nested structures into dot-notation keys for Neo4j property storage. + + nodeProperties uses exclude_unset: repository._build_node_query does + `SET n += $props`, so a full dump of a partial FlowsintType would + null out fields a prior enricher already set on the same node. """ - neo4j_dict = node.model_dump(mode="json", serialize_as_any=True) + neo4j_dict = node.model_dump( + mode="json", serialize_as_any=True, exclude={"nodeProperties"} + ) + node_properties = node.nodeProperties + neo4j_dict["nodeProperties"] = ( + node_properties.model_dump(mode="json", exclude_unset=True) + if isinstance(node_properties, BaseModel) + else node_properties + ) neo4j_dict_flatten = flatten(neo4j_dict, remove_empty=False) neo4j_dict_flatten.pop( "nodeProperties.nodeLabel", None