Merge pull request #228 from reconurge/fix/node-upsert-clobber-exclude-unset

fix(core): stop node upsert from nulling unset FlowsintType fields
This commit is contained in:
dextmorgn
2026-09-20 19:23:28 +02:00
committed by GitHub
2 changed files with 56 additions and 3 deletions
@@ -7,7 +7,7 @@ into Neo4j-compatible primitive types, following the Single Responsibility Princ
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 @@ class GraphSerializer:
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
@@ -8,7 +8,7 @@ from flowsint_core.core.graph import (
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"}