I've written a converter between pydantic models of arbitrary complexity (thus, with submodels, nulls, lists) and neomodel models (library for neo4j). In simplified terms, it covers following transitions (in red) and if target is OGM model - saves result in database:
The converter's main trick is pretty simple: it looks at your complex data (nested dictionaries, lists of objects, etc.) and figures out how to map the whole thing to Neo4j's graph structure. It doesn't just handle flat data - it deals with the entire object hierarchy and builds all the right nodes and relationships in the database. Works both ways too - you can pull a complex graph from Neo4j and get back your nested Python objects. It's especially handy when your data has objects that reference each other in circles, which would normally cause endless loops and stack overflows.
Limitation: OGM models are needed.
Whole implementation with docker-compose file and tests is available here ( >1KLOC in total).
There's a ton of code below, so I'll write my Qs here:
- Assuming code separation is not a problem and that 1+ KLOC chunk (
converter.py) is pasted for better readability of this post, how can I improve my code in general? - Some functions, like
_get_property_type()or_convert_value()are bounded directly to the internals ofneomodel. What bothers me are these endless if-elses: I haven't found simple solution of acquiring python datatype fromneomodelproperty, thus created such a spaghetti. Maybe better way of solving problem exists? - There is like 1000 and 1 place with generic
try - except Exception: comments in internal methods ofneomodelare either non-existing or do not mention which errors are raised and when. Although defensive programming in this case is rather good, is there a better option of specifying which errors to be catched when no sufficient data about errors to be thrown exists?
Example:
from datetime import date
from typing import List, Optional
from neomodel import (
StructuredNode, StringProperty, IntegerProperty, FloatProperty, BooleanProperty,
RelationshipTo, config
)
from pydantic import BaseModel, Field
from converter import Converter
import json
# Configure Neo4j connection
config.DATABASE_URL = 'bolt://neo4j:password@localhost:7687'
# ===== Models for complex nested structure =====
class ItemPydantic(BaseModel):
name: str
price: float
class OrderPydantic(BaseModel):
uid: str
items: List[ItemPydantic] = Field(default_factory=list)
class CustomerPydantic(BaseModel):
name: str
email: str
orders: List[OrderPydantic] = Field(default_factory=list)
class ItemOGM(StructuredNode):
name = StringProperty()
price = FloatProperty()
class OrderOGM(StructuredNode):
uid = StringProperty()
items = RelationshipTo(ItemOGM, 'CONTAINS')
class CustomerOGM(StructuredNode):
name = StringProperty()
email = StringProperty()
orders = RelationshipTo(OrderOGM, 'PLACED')
# ===== Models for cyclic references =====
class CyclicPydantic(BaseModel):
name: str
links: List['CyclicPydantic'] = Field(default_factory=list)
CyclicPydantic.model_rebuild() # Resolve forward references
class CyclicOGM(StructuredNode):
name = StringProperty()
links = RelationshipTo('CyclicOGM', 'LINKS_TO')
# Register models
def register_models():
# Register nested structure models
Converter.register_models(ItemPydantic, ItemOGM)
Converter.register_models(OrderPydantic, OrderOGM)
Converter.register_models(CustomerPydantic, CustomerOGM)
# Register cyclic models
Converter.register_models(CyclicPydantic, CyclicOGM)
# Register custom type converters
Converter.register_type_converter(
date, str, lambda d: d.isoformat()
)
Converter.register_type_converter(
str, date, lambda s: date.fromisoformat(s)
)
# ===== Example 1: Complex Nested Structure =====
def example_complex_structure():
print("\n=== EXAMPLE 1: COMPLEX NESTED STRUCTURE ===")
# Create sample data
item1 = ItemPydantic(name="Laptop", price=999.99)
item2 = ItemPydantic(name="Headphones", price=89.99)
item3 = ItemPydantic(name="Mouse", price=24.99)
order1 = OrderPydantic(uid="ORD-001", items=[item1, item2])
order2 = OrderPydantic(uid="ORD-002", items=[item3])
customer = CustomerPydantic(
name="John Doe",
email="[email protected]",
orders=[order1, order2]
)
# Print original structure
print("Original Pydantic Structure:")
print(json.dumps({
"name": customer.name,
"email": customer.email,
"orders": [
{
"uid": order.uid,
"items": [
{"name": item.name, "price": item.price}
for item in order.items
]
}
for order in customer.orders
]
}, indent=2))
# Convert to OGM
customer_ogm = Converter.to_ogm(customer)
# Extract data from OGM
ogm_orders = list(customer_ogm.orders.all())
ogm_items = []
for order in ogm_orders:
ogm_items.extend(list(order.items.all()))
print("\nConverted to OGM:")
print(json.dumps({
"name": customer_ogm.name,
"email": customer_ogm.email,
"orders_count": len(ogm_orders),
"order_uids": [order.uid for order in ogm_orders],
"items_count": len(ogm_items),
"items": [{"name": item.name, "price": item.price} for item in ogm_items]
}, indent=2))
# Convert to dict
customer_dict = Converter.ogm_to_dict(customer_ogm)
print("\nConverted to Dict:")
print(json.dumps(customer_dict, default=str, indent=2))
# Convert back to Pydantic
customer_py = Converter.to_pydantic(customer_ogm)
print("\nRound-trip to Pydantic:")
print(json.dumps({
"name": customer_py.name,
"email": customer_py.email,
"orders_count": len(customer_py.orders),
"order_uids": [order.uid for order in customer_py.orders],
"items": [
{"name": item.name, "price": item.price}
for order in customer_py.orders
for item in order.items
]
}, indent=2))
# ===== Example 2: Cyclic References =====
def example_cyclic_references():
print("\n=== EXAMPLE 2: CYCLIC REFERENCES ===")
# Create cycle: A -> B -> C -> A
node_a = CyclicPydantic(name="NodeA")
node_b = CyclicPydantic(name="NodeB")
node_c = CyclicPydantic(name="NodeC")
node_a.links = [node_b]
node_b.links = [node_c]
node_c.links = [node_a] # Creates cycle
print("Original Cyclic Structure:")
print(json.dumps({
"node": node_a.name,
"links_to": node_a.links[0].name,
"links_to_links_to": node_a.links[0].links[0].name,
"links_to_links_to_links_to": node_a.links[0].links[0].links[0].name,
"cycle_detected": node_a.links[0].links[0].links[0].name == node_a.name
}, indent=2))
# Convert to OGM
node_a_ogm = Converter.to_ogm(node_a)
# Extract data from OGM
ogm_bs = list(node_a_ogm.links.all())
ogm_cs = list(ogm_bs[0].links.all())
ogm_as = list(ogm_cs[0].links.all())
print("\nConverted to OGM:")
print(json.dumps({
"node": node_a_ogm.name,
"links_to": ogm_bs[0].name,
"links_to_links_to": ogm_cs[0].name,
"links_to_links_to_links_to": ogm_as[0].name,
"cycle_detected": ogm_as[0].name == node_a_ogm.name
}, indent=2))
# Convert to dict
node_dict = Converter.ogm_to_dict(node_a_ogm, max_depth=4)
print("\nConverted to Dict (trimmed for readability):")
print(json.dumps({
"name": node_dict["name"],
"links": [
{
"name": node_dict["links"][0]["name"],
"links": [
{
"name": node_dict["links"][0]["links"][0]["name"],
"has_links_back": "links" in node_dict["links"][0]["links"][0]
}
]
}
]
}, indent=2))
# Convert back to Pydantic
node_py = Converter.to_pydantic(node_a_ogm)
print("\nRound-trip to Pydantic:")
print(json.dumps({
"node": node_py.name,
"links_to": node_py.links[0].name,
"links_to_links_to": node_py.links[0].links[0].name,
"links_to_links_to_links_to": node_py.links[0].links[0].links[0].name,
"cycle_detected": node_py.links[0].links[0].links[0].name == node_py.name
}, indent=2))
if __name__ == "__main__":
# Register all models
register_models()
# Run examples
example_complex_structure()
example_cyclic_references()
Example output:
# python3 example.py
=== EXAMPLE 1: COMPLEX NESTED STRUCTURE ===
Original Pydantic Structure:
{
"name": "John Doe",
"email": "[email protected]",
"orders": [
{
"uid": "ORD-001",
"items": [
{
"name": "Laptop",
"price": 999.99
},
{
"name": "Headphones",
"price": 89.99
}
]
},
{
"uid": "ORD-002",
"items": [
{
"name": "Mouse",
"price": 24.99
}
]
}
]
}
Converted to OGM:
{
"name": "John Doe",
"email": "[email protected]",
"orders_count": 2,
"order_uids": [
"ORD-002",
"ORD-001"
],
"items_count": 3,
"items": [
{
"name": "Mouse",
"price": 24.99
},
{
"name": "Headphones",
"price": 89.99
},
{
"name": "Laptop",
"price": 999.99
}
]
}
Converted to Dict:
{
"name": "John Doe",
"email": "[email protected]",
"orders": [
{
"uid": "ORD-002",
"items": [
{
"name": "Mouse",
"price": 24.99
}
]
},
{
"uid": "ORD-001",
"items": [
{
"name": "Headphones",
"price": 89.99
},
{
"name": "Laptop",
"price": 999.99
}
]
}
]
}
Round-trip to Pydantic:
{
"name": "John Doe",
"email": "[email protected]",
"orders_count": 2,
"order_uids": [
"ORD-002",
"ORD-001"
],
"items": [
{
"name": "Mouse",
"price": 24.99
},
{
"name": "Headphones",
"price": 89.99
},
{
"name": "Laptop",
"price": 999.99
}
]
}
=== EXAMPLE 2: CYCLIC REFERENCES ===
Original Cyclic Structure:
{
"node": "NodeA",
"links_to": "NodeB",
"links_to_links_to": "NodeC",
"links_to_links_to_links_to": "NodeA",
"cycle_detected": true
}
Converted to OGM:
{
"node": "NodeA",
"links_to": "NodeB",
"links_to_links_to": "NodeC",
"links_to_links_to_links_to": "NodeA",
"cycle_detected": true
}
Converted to Dict (trimmed for readability):
{
"name": "NodeA",
"links": [
{
"name": "NodeB",
"links": [
{
"name": "NodeC",
"has_links_back": true
}
]
}
]
}
Round-trip to Pydantic:
{
"node": "NodeA",
"links_to": "NodeB",
"links_to_links_to": "NodeC",
"links_to_links_to_links_to": "NodeA",
"cycle_detected": true
}
