Skip to content

Commit c13efa1

Browse files
authored
data modeling - add turtle owl import / export tools (#210)
* create and test * remove rel prop conversion, update readme, update doc strings * update docstrings
1 parent 16da806 commit c13efa1

File tree

8 files changed

+504
-0
lines changed

8 files changed

+504
-0
lines changed

servers/mcp-neo4j-data-modeling/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
### Changed
66

77
### Added
8+
* Add import and export methods to `DataModel` for turtle OWL strings
9+
* Add MCP tools for loading and exporting turtle OWL files
810

911
## v0.5.1
1012

servers/mcp-neo4j-data-modeling/README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,20 @@ These tools provide integration with **[Arrows](https://arrows.app/)** - a graph
125125
- `data_model` (DataModel): The data model to export
126126
- Returns: JSON string compatible with Arrows app
127127

128+
- `load_from_owl_turtle`
129+
- Load a data model from OWL Turtle format
130+
- Input:
131+
- `owl_turtle_str` (str): OWL Turtle string representation of an ontology
132+
- Returns: DataModel object with nodes and relationships extracted from the ontology
133+
- Note: **This conversion is lossy** - OWL Classes become Nodes, ObjectProperties become Relationships, and DatatypeProperties become Node properties.
134+
135+
- `export_to_owl_turtle`
136+
- Export a data model to OWL Turtle format
137+
- Input:
138+
- `data_model` (DataModel): The data model to export
139+
- Returns: String representation of the data model in OWL Turtle format
140+
- Note: **This conversion is lossy** - Relationship properties are not preserved since OWL does not support properties on ObjectProperties
141+
128142
#### 📚 Example Data Model Tools
129143

130144
These tools provide access to pre-built example data models for common use cases and domains.

servers/mcp-neo4j-data-modeling/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ dependencies = [
88
"fastmcp>=2.0.0",
99
"pydantic>=2.10.1",
1010
"starlette>=0.47.0",
11+
"rdflib>=7.0.0",
1112
]
1213

1314

servers/mcp-neo4j-data-modeling/src/mcp_neo4j_data_modeling/data_model.py

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from typing import Any
44

55
from pydantic import BaseModel, Field, ValidationInfo, field_validator
6+
from rdflib import Graph, Namespace, RDF, RDFS, OWL, XSD, Literal, URIRef
67

78
NODE_COLOR_PALETTE = [
89
("#e3f2fd", "#1976d2"), # Light Blue / Blue
@@ -551,6 +552,189 @@ def to_arrows_dict(self) -> dict[str, Any]:
551552
def to_arrows_json_str(self) -> str:
552553
"Convert the data model to an Arrows Data Model JSON string."
553554
return json.dumps(self.to_arrows_dict(), indent=2)
555+
556+
def to_owl_turtle_str(self) -> str:
557+
"""
558+
Convert the data model to an OWL Turtle string.
559+
560+
This process is lossy since OWL does not support properties on ObjectProperties.
561+
562+
This method creates an OWL ontology from the Neo4j data model:
563+
- Node labels become OWL Classes
564+
- Node properties become OWL DatatypeProperties with the node class as domain
565+
- Relationship types become OWL ObjectProperties with start/end nodes as domain/range
566+
- Relationship properties become OWL DatatypeProperties with the relationship as domain
567+
"""
568+
# Create a new RDF graph
569+
g = Graph()
570+
571+
# Define namespaces
572+
# Use a generic namespace for the ontology
573+
base_ns = Namespace("http://voc.neo4j.com/datamodel#")
574+
g.bind("", base_ns)
575+
g.bind("owl", OWL)
576+
g.bind("rdfs", RDFS)
577+
g.bind("xsd", XSD)
578+
579+
# Create the ontology declaration
580+
ontology_uri = URIRef("http://voc.neo4j.com/datamodel")
581+
g.add((ontology_uri, RDF.type, OWL.Ontology))
582+
583+
# Map Neo4j types to XSD types
584+
type_mapping = {
585+
"STRING": XSD.string,
586+
"INTEGER": XSD.integer,
587+
"FLOAT": XSD.float,
588+
"BOOLEAN": XSD.boolean,
589+
"DATE": XSD.date,
590+
"DATETIME": XSD.dateTime,
591+
"TIME": XSD.time,
592+
"DURATION": XSD.duration,
593+
"LONG": XSD.long,
594+
"DOUBLE": XSD.double,
595+
}
596+
597+
# Process nodes -> OWL Classes
598+
for node in self.nodes:
599+
class_uri = base_ns[node.label]
600+
g.add((class_uri, RDF.type, OWL.Class))
601+
602+
# Add key property as a datatype property
603+
if node.key_property:
604+
prop_uri = base_ns[node.key_property.name]
605+
g.add((prop_uri, RDF.type, OWL.DatatypeProperty))
606+
g.add((prop_uri, RDFS.domain, class_uri))
607+
xsd_type = type_mapping.get(node.key_property.type.upper(), XSD.string)
608+
g.add((prop_uri, RDFS.range, xsd_type))
609+
610+
# Add other properties as datatype properties
611+
for prop in node.properties:
612+
prop_uri = base_ns[prop.name]
613+
g.add((prop_uri, RDF.type, OWL.DatatypeProperty))
614+
g.add((prop_uri, RDFS.domain, class_uri))
615+
xsd_type = type_mapping.get(prop.type.upper(), XSD.string)
616+
g.add((prop_uri, RDFS.range, xsd_type))
617+
618+
# Process relationships -> OWL ObjectProperties
619+
for rel in self.relationships:
620+
rel_uri = base_ns[rel.type]
621+
g.add((rel_uri, RDF.type, OWL.ObjectProperty))
622+
g.add((rel_uri, RDFS.domain, base_ns[rel.start_node_label]))
623+
g.add((rel_uri, RDFS.range, base_ns[rel.end_node_label]))
624+
625+
# relationships don't have properties in the OWL format.
626+
# This means translation to OWL is lossy.
627+
628+
# Serialize to Turtle format
629+
return g.serialize(format="turtle")
630+
631+
@classmethod
632+
def from_owl_turtle_str(cls, owl_turtle_str: str) -> "DataModel":
633+
"""
634+
Convert an OWL Turtle string to a Neo4j Data Model.
635+
636+
This process is lossy and some components of the ontology may be lost in the data model schema.
637+
638+
This method parses an OWL ontology and creates a Neo4j data model:
639+
- OWL Classes become Node labels
640+
- OWL DatatypeProperties with Class domains become Node properties
641+
- OWL ObjectProperties become Relationships
642+
- Property domains and ranges are used to infer Node labels and types
643+
"""
644+
# Parse the Turtle string
645+
g = Graph()
646+
g.parse(data=owl_turtle_str, format="turtle")
647+
648+
# Map XSD types back to Neo4j types
649+
xsd_to_neo4j = {
650+
str(XSD.string): "STRING",
651+
str(XSD.integer): "INTEGER",
652+
str(XSD.float): "FLOAT",
653+
str(XSD.boolean): "BOOLEAN",
654+
str(XSD.date): "DATE",
655+
str(XSD.dateTime): "DATETIME",
656+
str(XSD.time): "TIME",
657+
str(XSD.duration): "DURATION",
658+
str(XSD.long): "LONG",
659+
str(XSD.double): "DOUBLE",
660+
}
661+
662+
# Extract OWL Classes -> Nodes
663+
classes = set()
664+
for s in g.subjects(RDF.type, OWL.Class):
665+
classes.add(str(s).split("#")[-1].split("/")[-1])
666+
667+
# Extract DatatypeProperties
668+
datatype_props = {}
669+
for prop in g.subjects(RDF.type, OWL.DatatypeProperty):
670+
prop_name = str(prop).split("#")[-1].split("/")[-1]
671+
domains = list(g.objects(prop, RDFS.domain))
672+
ranges = list(g.objects(prop, RDFS.range))
673+
674+
domain_name = str(domains[0]).split("#")[-1].split("/")[-1] if domains else None
675+
range_type = xsd_to_neo4j.get(str(ranges[0]), "STRING") if ranges else "STRING"
676+
677+
if domain_name:
678+
if domain_name not in datatype_props:
679+
datatype_props[domain_name] = []
680+
datatype_props[domain_name].append({
681+
"name": prop_name,
682+
"type": range_type
683+
})
684+
685+
# Extract ObjectProperties -> Relationships
686+
object_props = []
687+
for prop in g.subjects(RDF.type, OWL.ObjectProperty):
688+
prop_name = str(prop).split("#")[-1].split("/")[-1]
689+
domains = list(g.objects(prop, RDFS.domain))
690+
ranges = list(g.objects(prop, RDFS.range))
691+
692+
if domains and ranges:
693+
domain_name = str(domains[0]).split("#")[-1].split("/")[-1]
694+
range_name = str(ranges[0]).split("#")[-1].split("/")[-1]
695+
696+
object_props.append({
697+
"type": prop_name,
698+
"start_node_label": domain_name,
699+
"end_node_label": range_name
700+
})
701+
702+
# Create Nodes
703+
nodes = []
704+
for class_name in classes:
705+
props_for_class = datatype_props.get(class_name, [])
706+
707+
# Use the first property as key property, or create a default one
708+
if props_for_class:
709+
key_prop = Property(
710+
name=props_for_class[0]["name"],
711+
type=props_for_class[0]["type"]
712+
)
713+
other_props = [
714+
Property(name=p["name"], type=p["type"])
715+
for p in props_for_class[1:]
716+
]
717+
else:
718+
# Create a default key property
719+
key_prop = Property(name=f"{class_name.lower()}Id", type="STRING")
720+
other_props = []
721+
722+
nodes.append(Node(
723+
label=class_name,
724+
key_property=key_prop,
725+
properties=other_props
726+
))
727+
728+
# Create Relationships
729+
relationships = []
730+
for obj_prop in object_props:
731+
relationships.append(Relationship(
732+
type=obj_prop["type"],
733+
start_node_label=obj_prop["start_node_label"],
734+
end_node_label=obj_prop["end_node_label"]
735+
))
736+
737+
return cls(nodes=nodes, relationships=relationships)
554738

555739
def get_node_cypher_ingest_query_for_many_records(self, node_label: str) -> str:
556740
"Generate a Cypher query to ingest a list of Node records into a Neo4j database."

servers/mcp-neo4j-data-modeling/src/mcp_neo4j_data_modeling/server.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,26 @@ def list_example_data_models() -> dict[str, Any]:
328328
"total_examples": len(examples),
329329
"usage": "Use the get_example_data_model tool with any of the example names above to get a specific data model",
330330
}
331+
332+
@mcp.tool(name=namespace_prefix + "load_from_owl_turtle")
333+
def load_from_owl_turtle(owl_turtle_str: str) -> DataModel:
334+
"""
335+
Load a data model from an OWL Turtle string.
336+
This process is lossy and some components of the ontology may be lost in the data model schema.
337+
Returns a DataModel object.
338+
"""
339+
logger.info("Loading a data model from an OWL Turtle string.")
340+
return DataModel.from_owl_turtle_str(owl_turtle_str)
341+
342+
@mcp.tool(name=namespace_prefix + "export_to_owl_turtle")
343+
def export_to_owl_turtle(data_model: DataModel) -> str:
344+
"""
345+
Export a data model to an OWL Turtle string.
346+
This process is lossy since OWL does not support properties on relationships.
347+
Returns a string representation of the data model in OWL Turtle format.
348+
"""
349+
logger.info("Exporting a data model to an OWL Turtle string.")
350+
return data_model.to_owl_turtle_str()
331351

332352
@mcp.prompt(title="Create New Data Model")
333353
def create_new_data_model(
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
@prefix : <http://voc.neo4j.com/blueplaques#> .
2+
@prefix owl: <http://www.w3.org/2002/07/owl#> .
3+
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
4+
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
5+
6+
<http://voc.neo4j.com/blueplaques> a owl:Ontology .
7+
8+
:COMPOSED a owl:ObjectProperty ;
9+
rdfs:domain :Person ;
10+
rdfs:range :MusicalComposition .
11+
12+
:HONORED_BY a owl:ObjectProperty ;
13+
rdfs:domain :Person ;
14+
rdfs:range :Plaque .
15+
16+
:LOCATED_AT a owl:ObjectProperty ;
17+
rdfs:domain :Plaque ;
18+
rdfs:range :Address .
19+
20+
:addressId a owl:DatatypeProperty ;
21+
rdfs:domain :Address ;
22+
rdfs:range xsd:string .
23+
24+
:area a owl:DatatypeProperty ;
25+
rdfs:domain :Address ;
26+
rdfs:range xsd:string .
27+
28+
:birthYear a owl:DatatypeProperty ;
29+
rdfs:domain :Person ;
30+
rdfs:range xsd:integer .
31+
32+
:borough a owl:DatatypeProperty ;
33+
rdfs:domain :Address ;
34+
rdfs:range xsd:string .
35+
36+
:compositionId a owl:DatatypeProperty ;
37+
rdfs:domain :MusicalComposition ;
38+
rdfs:range xsd:string .
39+
40+
:deathYear a owl:DatatypeProperty ;
41+
rdfs:domain :Person ;
42+
rdfs:range xsd:integer .
43+
44+
:erectionYear a owl:DatatypeProperty ;
45+
rdfs:domain :Plaque ;
46+
rdfs:range xsd:integer .
47+
48+
:genre a owl:DatatypeProperty ;
49+
rdfs:domain :MusicalComposition ;
50+
rdfs:range xsd:string .
51+
52+
:inscription a owl:DatatypeProperty ;
53+
rdfs:domain :Plaque ;
54+
rdfs:range xsd:string .
55+
56+
:material a owl:DatatypeProperty ;
57+
rdfs:domain :Plaque ;
58+
rdfs:range xsd:string .
59+
60+
:name a owl:DatatypeProperty ;
61+
rdfs:domain :Organization,
62+
:Person ;
63+
rdfs:range xsd:string .
64+
65+
:nationality a owl:DatatypeProperty ;
66+
rdfs:domain :Person ;
67+
rdfs:range xsd:string .
68+
69+
:organizationType a owl:DatatypeProperty ;
70+
rdfs:domain :Organization ;
71+
rdfs:range xsd:string .
72+
73+
:personId a owl:DatatypeProperty ;
74+
rdfs:domain :Person ;
75+
rdfs:range xsd:string .
76+
77+
:plaqueId a owl:DatatypeProperty ;
78+
rdfs:domain :Plaque ;
79+
rdfs:range xsd:string .
80+
81+
:postcode a owl:DatatypeProperty ;
82+
rdfs:domain :Address ;
83+
rdfs:range xsd:string .
84+
85+
:profession a owl:DatatypeProperty ;
86+
rdfs:domain :Person ;
87+
rdfs:range xsd:string .
88+
89+
:professionCategory a owl:DatatypeProperty ;
90+
rdfs:domain :Person ;
91+
rdfs:range xsd:string .
92+
93+
:streetAddress a owl:DatatypeProperty ;
94+
rdfs:domain :Address ;
95+
rdfs:range xsd:string .
96+
97+
:title a owl:DatatypeProperty ;
98+
rdfs:domain :MusicalComposition ;
99+
rdfs:range xsd:string .
100+
101+
:yearComposed a owl:DatatypeProperty ;
102+
rdfs:domain :MusicalComposition ;
103+
rdfs:range xsd:integer .
104+
105+
:Organization a owl:Class .
106+
107+
:MusicalComposition a owl:Class .
108+
109+
:Address a owl:Class .
110+
111+
:Plaque a owl:Class .
112+
113+
:Person a owl:Class .

0 commit comments

Comments
 (0)