How to Use Semi-Structured Nodes with Flexible Schemas in neomodel
Use neomodel's SemiStructuredNode or AsyncSemiStructuredNode base classes to store arbitrary properties alongside declared schema fields, enabling flexible schemas while maintaining type safety for core attributes.
The neo4j-contrib/neomodel library provides a Pythonic way to interact with Neo4j, balancing the database's schemaless nature with structured modeling. When you need semi-structured nodes with flexible schemas, the SemiStructuredNode class allows you to define strict types for essential fields while accepting dynamic, undeclared properties at runtime.
Understanding SemiStructuredNode Architecture
The SemiStructuredNode implementation in neomodel/contrib/sync_/semi_structured.py extends StructuredNode to create a dual-layer property system. This architecture preserves type checking and indexing for declared fields while treating additional properties as dynamic attributes.
Core Components
| Component | Purpose | Implementation Location |
|---|---|---|
SemiStructuredNode |
Base class enabling flexible property storage | neomodel/contrib/sync_/semi_structured.py (line 5) |
inflate |
Deserializes nodes from Neo4j, separating declared properties from dynamic extras | neomodel/contrib/sync_/semi_structured.py (line 25) |
deflate |
Serializes nodes to Neo4j, merging declared and dynamic properties | neomodel/contrib/sync_/semi_structured.py (line 51) |
InflateConflict / DeflateConflict |
Exceptions raised when dynamic properties collide with class methods or attributes | neomodel/exceptions.py |
The inflate method first processes declared properties through the parent class, then identifies extra keys by computing node.keys() - registered_db_property_names. These extra properties attach directly to the instance via setattr. Similarly, deflate extracts undeclared attributes from __dict__ and merges them into the property dictionary sent to Neo4j.
Implementing Semi-Structured Nodes
Basic Model Definition
Define your model by inheriting from SemiStructuredNode and declaring only the fields that require type enforcement or indexing:
from neomodel import StringProperty, IntegerProperty
from neomodel.contrib import SemiStructuredNode
class Person(SemiStructuredNode):
name = StringProperty(required=True)
age = IntegerProperty()
This pattern appears in the test suite at test/sync_/test_contrib/test_semi_structured.py (lines 11-14), demonstrating identical syntax to StructuredNode but with flexible schema capabilities.
Storing Dynamic Properties
Instantiate nodes with arbitrary keyword arguments beyond your declared schema:
# Create with both declared and dynamic properties
p = Person(name="Alice", age=30, hobby="painting", height=165).save()
# Add ad-hoc attributes after creation
p.favorite_color = "blue"
p.save()
The test_save_to_model_with_extras test case (lines 31-38 in test_semi_structured.py) validates this exact workflow, confirming that dynamic properties persist through save and reload operations.
Retrieving Flexible Data
Access dynamic properties as normal Python attributes after retrieval:
alice = Person.nodes.get(name="Alice")
print(alice.hobby) # Output: painting
print(alice.favorite_color) # Output: blue
The test suite confirms these extra fields survive the round-trip from Neo4j through the inflate method and remain accessible as instance attributes.
Handling Schema Conflicts
The semi-structured implementation includes safety mechanisms to prevent dynamic properties from overwriting critical class methods or attributes.
InflateConflict Prevention
When loading a node from the database, InflateConflict raises if a stored property name matches an existing class attribute:
class Foo(SemiStructuredNode):
name = StringProperty()
# Manually create a node with a conflicting property
db.cypher_query(
"CREATE (n:Foo $props)",
{"props": {"name": "Bob", "hello": "world"}}
)
# This raises InflateConflict because 'hello' is a method on the class
try:
Foo.nodes.get(name="Bob")
except InflateConflict:
print("Conflict detected: database property collides with class method")
This protection appears in test_semi_structured.py (lines 63-68), demonstrating how the library prevents accidental API corruption.
DeflateConflict Protection
When saving, DeflateConflict prevents you from assigning values to reserved attribute names:
bob = Foo(name="Bob")
bob.hello = "override" # 'hello' is a method, not a free property
bob.save() # Raises DeflateConflict
The test_deflate_conflict test case (lines 71-82) validates this safety check, ensuring that dynamic properties cannot shadow class methods during serialization.
Async Support with AsyncSemiStructuredNode
For asynchronous applications, neomodel provides AsyncSemiStructuredNode with identical functionality:
from neomodel.contrib import AsyncSemiStructuredNode
class AsyncPerson(AsyncSemiStructuredNode):
email = StringProperty(unique_index=True)
The async implementation resides in neomodel/contrib/async_/semi_structured.py and mirrors the sync version's inflate and deflate logic, allowing flexible schemas in async/await contexts.
Summary
- SemiStructuredNode enables flexible schemas by allowing arbitrary properties alongside declared fields in
neo4j-contrib/neomodel. - The implementation in
neomodel/contrib/sync_/semi_structured.pyusesinflateto deserialize extra properties anddeflateto serialize them back to Neo4j. - InflateConflict and DeflateConflict exceptions protect against dynamic properties overwriting class methods or attributes.
- Both synchronous (
SemiStructuredNode) and asynchronous (AsyncSemiStructuredNode) variants support semi-structured data patterns. - Test coverage in
test/sync_/test_contrib/test_semi_structured.pydemonstrates creation, retrieval, and conflict handling workflows.
Frequently Asked Questions
What is the difference between StructuredNode and SemiStructuredNode?
StructuredNode requires all properties to be explicitly declared in the class definition, enforcing strict type checking and schema validation. SemiStructuredNode inherits from StructuredNode but adds the ability to store arbitrary, undeclared properties as dynamic attributes, making it ideal for evolving schemas or heterogeneous data while maintaining type safety for core fields.
Can I query dynamic properties using neomodel's filter methods?
Yes, dynamic properties stored on SemiStructuredNode instances are persisted to Neo4j as regular node properties and can be queried using Cypher. However, since these properties are not declared in the model definition, you cannot use the standard Person.nodes.filter(hobby="painting") syntax for dynamic fields unless you use raw Cypher queries via db.cypher_query() or the filter method with raw Cypher expressions.
How does neomodel prevent dynamic properties from breaking class methods?
The inflate and deflate methods in neomodel/contrib/sync_/semi_structured.py explicitly check for attribute collisions. During inflation (loading from Neo4j), if a stored property name matches an existing class method or attribute, InflateConflict is raised. During deflation (saving to Neo4j), if you attempt to assign a value to a reserved attribute name, DeflateConflict is raised. These exceptions protect the model's API integrity.
Is there a performance penalty for using SemiStructuredNode?
The performance impact is minimal. The inflate and deflate methods add only a simple set difference operation (node.keys() - registered_db_property_names) to separate declared from dynamic properties. This overhead is negligible compared to the network round-trip to Neo4j. However, since dynamic properties cannot be indexed through the model definition, querying large datasets by dynamic fields may require full database scans unless you manually create Neo4j indexes via Cypher.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →