mcp-agensgraph-data-modeling 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mcp_agensgraph_data_modeling/__init__.py +41 -0
- mcp_agensgraph_data_modeling/data_model.py +619 -0
- mcp_agensgraph_data_modeling/models.py +12 -0
- mcp_agensgraph_data_modeling/server.py +429 -0
- mcp_agensgraph_data_modeling/static.py +3099 -0
- mcp_agensgraph_data_modeling/utils.py +66 -0
- mcp_agensgraph_data_modeling-0.2.0.dist-info/METADATA +58 -0
- mcp_agensgraph_data_modeling-0.2.0.dist-info/RECORD +12 -0
- mcp_agensgraph_data_modeling-0.2.0.dist-info/WHEEL +4 -0
- mcp_agensgraph_data_modeling-0.2.0.dist-info/entry_points.txt +2 -0
- mcp_agensgraph_data_modeling-0.2.0.dist-info/licenses/LICENSE +201 -0
- mcp_agensgraph_data_modeling-0.2.0.dist-info/licenses/NOTICE +35 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import asyncio
|
|
3
|
+
|
|
4
|
+
from . import server
|
|
5
|
+
from .utils import process_config
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def main():
|
|
9
|
+
"""Main entry point for the package."""
|
|
10
|
+
parser = argparse.ArgumentParser(description="AgensGraph Data Modeling MCP Server")
|
|
11
|
+
parser.add_argument(
|
|
12
|
+
"--transport", default=None, help="Transport type (stdio, sse, http)"
|
|
13
|
+
)
|
|
14
|
+
parser.add_argument(
|
|
15
|
+
"--server-host", default=None, help="HTTP host (default: 127.0.0.1)"
|
|
16
|
+
)
|
|
17
|
+
parser.add_argument(
|
|
18
|
+
"--server-port", type=int, default=None, help="HTTP port (default: 8000)"
|
|
19
|
+
)
|
|
20
|
+
parser.add_argument(
|
|
21
|
+
"--server-path", default=None, help="HTTP path (default: /mcp/)"
|
|
22
|
+
)
|
|
23
|
+
parser.add_argument(
|
|
24
|
+
"--allow-origins",
|
|
25
|
+
default=None,
|
|
26
|
+
help="Allow origins for remote servers (comma-separated list)",
|
|
27
|
+
)
|
|
28
|
+
parser.add_argument(
|
|
29
|
+
"--allowed-hosts",
|
|
30
|
+
default=None,
|
|
31
|
+
help="Allowed hosts for DNS rebinding protection on remote servers (comma-separated list)",
|
|
32
|
+
)
|
|
33
|
+
parser.add_argument("--namespace", default=None, help="Tool namespace prefix")
|
|
34
|
+
|
|
35
|
+
args = parser.parse_args()
|
|
36
|
+
|
|
37
|
+
config = process_config(args)
|
|
38
|
+
asyncio.run(server.main(**config))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
__all__ = ["main", "server"]
|
|
@@ -0,0 +1,619 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from collections import Counter
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field, ValidationInfo, field_validator
|
|
6
|
+
|
|
7
|
+
from .utils import _quote_identifiers
|
|
8
|
+
|
|
9
|
+
NODE_COLOR_PALETTE = [
|
|
10
|
+
("#e3f2fd", "#1976d2"), # Light Blue / Blue
|
|
11
|
+
("#f3e5f5", "#7b1fa2"), # Light Purple / Purple
|
|
12
|
+
("#e8f5e8", "#388e3c"), # Light Green / Green
|
|
13
|
+
("#fff3e0", "#f57c00"), # Light Orange / Orange
|
|
14
|
+
("#fce4ec", "#c2185b"), # Light Pink / Pink
|
|
15
|
+
("#e0f2f1", "#00695c"), # Light Teal / Teal
|
|
16
|
+
("#f1f8e9", "#689f38"), # Light Lime / Lime
|
|
17
|
+
("#fff8e1", "#ffa000"), # Light Amber / Amber
|
|
18
|
+
("#e8eaf6", "#3f51b5"), # Light Indigo / Indigo
|
|
19
|
+
("#efebe9", "#5d4037"), # Light Brown / Brown
|
|
20
|
+
("#fafafa", "#424242"), # Light Grey / Dark Grey
|
|
21
|
+
("#e1f5fe", "#0277bd"), # Light Cyan / Cyan
|
|
22
|
+
("#f9fbe7", "#827717"), # Light Yellow-Green / Olive
|
|
23
|
+
("#fff1f0", "#d32f2f"), # Light Red / Red
|
|
24
|
+
("#f4e6ff", "#6a1b9a"), # Light Violet / Violet
|
|
25
|
+
("#e6f7ff", "#1890ff"), # Very Light Blue / Bright Blue
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _generate_relationship_pattern(
|
|
30
|
+
start_node_label: str, relationship_type: str, end_node_label: str
|
|
31
|
+
) -> str:
|
|
32
|
+
"Helper function to generate a pattern for a relationship."
|
|
33
|
+
return f"(:{start_node_label})-[:{relationship_type}]->(:{end_node_label})"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class PropertySource(BaseModel):
|
|
37
|
+
"The source of a property."
|
|
38
|
+
|
|
39
|
+
column_name: str | None = Field(
|
|
40
|
+
default=None, description="The column name this property maps to, if known."
|
|
41
|
+
)
|
|
42
|
+
table_name: str | None = Field(
|
|
43
|
+
default=None,
|
|
44
|
+
description="The name of the table this property's column is in, if known. May also be the name of a file.",
|
|
45
|
+
)
|
|
46
|
+
location: str | None = Field(
|
|
47
|
+
default=None,
|
|
48
|
+
description="The location of the property, if known. May be a file path, URL, etc.",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Property(BaseModel):
|
|
53
|
+
"A AgensGraph Property."
|
|
54
|
+
|
|
55
|
+
name: str = Field(description="The name of the property. Should be in camelCase.")
|
|
56
|
+
type: str = Field(
|
|
57
|
+
default="STRING",
|
|
58
|
+
description="The AgensGraph type of the property. Should be all caps.",
|
|
59
|
+
)
|
|
60
|
+
source: PropertySource | None = Field(
|
|
61
|
+
default=None, description="The source of the property, if known."
|
|
62
|
+
)
|
|
63
|
+
description: str | None = Field(
|
|
64
|
+
default=None, description="The description of the property"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
@field_validator("type")
|
|
68
|
+
def validate_type(cls, v: str) -> str:
|
|
69
|
+
"Validate the type."
|
|
70
|
+
|
|
71
|
+
return v.upper()
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def from_arrows(cls, arrows_property: dict[str, str]) -> "Property":
|
|
75
|
+
"Convert an Arrows Property in dict format to a Property."
|
|
76
|
+
|
|
77
|
+
description = None
|
|
78
|
+
|
|
79
|
+
if "|" in list(arrows_property.values())[0]:
|
|
80
|
+
prop_props = [
|
|
81
|
+
x.strip() for x in list(arrows_property.values())[0].split("|")
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
prop_type = prop_props[0]
|
|
85
|
+
description = prop_props[1] if prop_props[1].lower() != "key" else None
|
|
86
|
+
else:
|
|
87
|
+
prop_type = list(arrows_property.values())[0]
|
|
88
|
+
|
|
89
|
+
return cls(
|
|
90
|
+
name=list(arrows_property.keys())[0],
|
|
91
|
+
type=prop_type,
|
|
92
|
+
description=description,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
def to_arrows(self, is_key: bool = False) -> dict[str, Any]:
|
|
96
|
+
"Convert a Property to an Arrows property dictionary. Final JSON string formatting is done at the data model level."
|
|
97
|
+
value = f"{self.type}"
|
|
98
|
+
if self.description:
|
|
99
|
+
value += f" | {self.description}"
|
|
100
|
+
if is_key:
|
|
101
|
+
value += " | KEY"
|
|
102
|
+
return {
|
|
103
|
+
self.name: value,
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class Node(BaseModel):
|
|
108
|
+
"A AgensGraph Node."
|
|
109
|
+
|
|
110
|
+
label: str = Field(
|
|
111
|
+
description="The label of the node. Should be in PascalCase.", min_length=1
|
|
112
|
+
)
|
|
113
|
+
key_property: Property = Field(description="The key property of the node")
|
|
114
|
+
properties: list[Property] = Field(
|
|
115
|
+
default_factory=list, description="The properties of the node"
|
|
116
|
+
)
|
|
117
|
+
metadata: dict[str, Any] = Field(
|
|
118
|
+
default_factory=dict,
|
|
119
|
+
description="The metadata of the node. This should only be used when converting data models.",
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
@field_validator("properties")
|
|
123
|
+
def validate_properties(
|
|
124
|
+
cls, properties: list[Property], info: ValidationInfo
|
|
125
|
+
) -> list[Property]:
|
|
126
|
+
"Validate the properties."
|
|
127
|
+
properties = [p for p in properties if p.name != info.data["key_property"].name]
|
|
128
|
+
|
|
129
|
+
counts = Counter([p.name for p in properties])
|
|
130
|
+
for name, count in counts.items():
|
|
131
|
+
if count > 1:
|
|
132
|
+
raise ValueError(
|
|
133
|
+
f"Property {name} appears {count} times in node {info.data['label']}"
|
|
134
|
+
)
|
|
135
|
+
return properties
|
|
136
|
+
|
|
137
|
+
def add_property(self, prop: Property) -> None:
|
|
138
|
+
"Add a new property to the node."
|
|
139
|
+
if prop.name in [p.name for p in self.properties]:
|
|
140
|
+
raise ValueError(
|
|
141
|
+
f"Property {prop.name} already exists in node {self.label}"
|
|
142
|
+
)
|
|
143
|
+
self.properties.append(prop)
|
|
144
|
+
|
|
145
|
+
def remove_property(self, prop: Property) -> None:
|
|
146
|
+
"Remove a property from the node."
|
|
147
|
+
try:
|
|
148
|
+
self.properties.remove(prop)
|
|
149
|
+
except ValueError:
|
|
150
|
+
pass
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def all_properties_dict(self) -> dict[str, str]:
|
|
154
|
+
"Return a dictionary of all properties of the node. {property_name: property_type}"
|
|
155
|
+
props = {p.name: p.type for p in self.properties} if self.properties else {}
|
|
156
|
+
if self.key_property:
|
|
157
|
+
props.update({self.key_property.name: f"{self.key_property.type} | KEY"})
|
|
158
|
+
return props
|
|
159
|
+
|
|
160
|
+
def get_mermaid_config_str(self) -> str:
|
|
161
|
+
"Get the Mermaid configuration string for the node."
|
|
162
|
+
props = [f"<br/>{self.key_property.name}: {self.key_property.type} | KEY"]
|
|
163
|
+
props.extend([f"<br/>{p.name}: {p.type}" for p in self.properties])
|
|
164
|
+
return f'{self.label}["{self.label}{"".join(props)}"]'
|
|
165
|
+
|
|
166
|
+
@classmethod
|
|
167
|
+
def from_arrows(cls, arrows_node_dict: dict[str, Any]) -> "Node":
|
|
168
|
+
"Convert an Arrows Node to a Node."
|
|
169
|
+
props = [
|
|
170
|
+
Property.from_arrows({k: v})
|
|
171
|
+
for k, v in arrows_node_dict["properties"].items()
|
|
172
|
+
if "KEY" not in v.upper()
|
|
173
|
+
]
|
|
174
|
+
keys = [
|
|
175
|
+
{k: v}
|
|
176
|
+
for k, v in arrows_node_dict["properties"].items()
|
|
177
|
+
if "KEY" in v.upper()
|
|
178
|
+
]
|
|
179
|
+
key_prop = Property.from_arrows(keys[0]) if keys else None
|
|
180
|
+
metadata = {
|
|
181
|
+
"position": arrows_node_dict["position"],
|
|
182
|
+
"caption": arrows_node_dict["caption"],
|
|
183
|
+
"style": arrows_node_dict["style"],
|
|
184
|
+
}
|
|
185
|
+
return cls(
|
|
186
|
+
label=arrows_node_dict["labels"][0],
|
|
187
|
+
key_property=key_prop,
|
|
188
|
+
properties=props,
|
|
189
|
+
metadata=metadata,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
def to_arrows(
|
|
193
|
+
self, default_position: dict[str, float] = {"x": 0.0, "y": 0.0}
|
|
194
|
+
) -> dict[str, Any]:
|
|
195
|
+
"Convert a Node to an Arrows Node dictionary. Final JSON string formatting is done at the data model level."
|
|
196
|
+
props = dict()
|
|
197
|
+
[props.update(p.to_arrows(is_key=False)) for p in self.properties]
|
|
198
|
+
props.update(self.key_property.to_arrows(is_key=True))
|
|
199
|
+
return {
|
|
200
|
+
"id": self.label,
|
|
201
|
+
"labels": [self.label],
|
|
202
|
+
"properties": props,
|
|
203
|
+
"style": self.metadata.get("style", {}),
|
|
204
|
+
"position": self.metadata.get("position", default_position),
|
|
205
|
+
"caption": self.metadata.get("caption", ""),
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
def get_cypher_ingest_query_for_many_records(self) -> str:
|
|
209
|
+
"""
|
|
210
|
+
Generate a Cypher query to ingest a list of Node records into a AgensGraph database.
|
|
211
|
+
This query takes a named parameter %(records)s that is a JSONB array of dictionaries.
|
|
212
|
+
Note: For AgensGraph with psycopg, use: cursor.execute(query, {"records": Jsonb(records)})
|
|
213
|
+
where Jsonb is imported from psycopg.types.json
|
|
214
|
+
"""
|
|
215
|
+
formatted_props = ", ".join(
|
|
216
|
+
[f"{p.name}: record.{p.name}" for p in self.properties]
|
|
217
|
+
)
|
|
218
|
+
query = f"""UNWIND %(records)s as record
|
|
219
|
+
MERGE (n: {self.label} {{{self.key_property.name}: record.{self.key_property.name}}})
|
|
220
|
+
SET n += {{{formatted_props}}}"""
|
|
221
|
+
return _quote_identifiers(query)
|
|
222
|
+
|
|
223
|
+
def get_cypher_constraint_query(self) -> str:
|
|
224
|
+
"""
|
|
225
|
+
Generate a Cypher query to create a UNIQUE constraint on the node's key property.
|
|
226
|
+
This enforces uniqueness on the key property of the node using AgensGraph syntax.
|
|
227
|
+
First creates the VLABEL if it doesn't exist, then creates the constraint.
|
|
228
|
+
"""
|
|
229
|
+
# Create VLABEL first, then constraint
|
|
230
|
+
vlabel_query = f"CREATE VLABEL IF NOT EXISTS {self.label}"
|
|
231
|
+
constraint_query = f"CREATE CONSTRAINT {self.label}_constraint ON {self.label} ASSERT {self.key_property.name} IS UNIQUE"
|
|
232
|
+
|
|
233
|
+
# Apply identifier quoting to both queries
|
|
234
|
+
vlabel_query = _quote_identifiers(vlabel_query)
|
|
235
|
+
constraint_query = _quote_identifiers(constraint_query)
|
|
236
|
+
|
|
237
|
+
# Return both queries separated by semicolon
|
|
238
|
+
return f"{vlabel_query}; {constraint_query}"
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
class Relationship(BaseModel):
|
|
242
|
+
"A AgensGraph Relationship."
|
|
243
|
+
|
|
244
|
+
type: str = Field(
|
|
245
|
+
description="The type of the relationship. Should be in SCREAMING_SNAKE_CASE.",
|
|
246
|
+
min_length=1,
|
|
247
|
+
)
|
|
248
|
+
start_node_label: str = Field(description="The label of the start node")
|
|
249
|
+
end_node_label: str = Field(description="The label of the end node")
|
|
250
|
+
key_property: Property | None = Field(
|
|
251
|
+
default=None, description="The key property of the relationship, if any."
|
|
252
|
+
)
|
|
253
|
+
properties: list[Property] = Field(
|
|
254
|
+
default_factory=list, description="The properties of the relationship, if any."
|
|
255
|
+
)
|
|
256
|
+
metadata: dict[str, Any] = Field(
|
|
257
|
+
default_factory=dict,
|
|
258
|
+
description="The metadata of the relationship. This should only be used when converting data models.",
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
@field_validator("properties")
|
|
262
|
+
def validate_properties(
|
|
263
|
+
cls, properties: list[Property], info: ValidationInfo
|
|
264
|
+
) -> list[Property]:
|
|
265
|
+
"Validate the properties."
|
|
266
|
+
if info.data.get("key_property"):
|
|
267
|
+
properties = [
|
|
268
|
+
p for p in properties if p.name != info.data["key_property"].name
|
|
269
|
+
]
|
|
270
|
+
|
|
271
|
+
counts = Counter([p.name for p in properties])
|
|
272
|
+
for name, count in counts.items():
|
|
273
|
+
if count > 1:
|
|
274
|
+
raise ValueError(
|
|
275
|
+
f"Property {name} appears {count} times in relationship {_generate_relationship_pattern(info.data['start_node_label'], info.data['type'], info.data['end_node_label'])}"
|
|
276
|
+
)
|
|
277
|
+
return properties
|
|
278
|
+
|
|
279
|
+
def add_property(self, prop: Property) -> None:
|
|
280
|
+
"Add a new property to the relationship."
|
|
281
|
+
if prop.name in [p.name for p in self.properties]:
|
|
282
|
+
raise ValueError(
|
|
283
|
+
f"Property {prop.name} already exists in relationship {self.pattern}"
|
|
284
|
+
)
|
|
285
|
+
self.properties.append(prop)
|
|
286
|
+
|
|
287
|
+
def remove_property(self, prop: Property) -> None:
|
|
288
|
+
"Remove a property from the relationship."
|
|
289
|
+
try:
|
|
290
|
+
self.properties.remove(prop)
|
|
291
|
+
except ValueError:
|
|
292
|
+
pass
|
|
293
|
+
|
|
294
|
+
@property
|
|
295
|
+
def pattern(self) -> str:
|
|
296
|
+
"Return the pattern of the relationship."
|
|
297
|
+
return _generate_relationship_pattern(
|
|
298
|
+
self.start_node_label, self.type, self.end_node_label
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
@property
|
|
302
|
+
def all_properties_dict(self) -> dict[str, str]:
|
|
303
|
+
"Return a dictionary of all properties of the relationship. {property_name: property_type}"
|
|
304
|
+
|
|
305
|
+
props = {p.name: p.type for p in self.properties} if self.properties else {}
|
|
306
|
+
if self.key_property:
|
|
307
|
+
props.update({self.key_property.name: f"{self.key_property.type} | KEY"})
|
|
308
|
+
return props
|
|
309
|
+
|
|
310
|
+
def get_mermaid_config_str(self) -> str:
|
|
311
|
+
"Get the Mermaid configuration string for the relationship."
|
|
312
|
+
props = (
|
|
313
|
+
[f"<br/>{self.key_property.name}: {self.key_property.type} | KEY"]
|
|
314
|
+
if self.key_property
|
|
315
|
+
else []
|
|
316
|
+
)
|
|
317
|
+
props.extend([f"<br/>{p.name}: {p.type}" for p in self.properties])
|
|
318
|
+
return f"{self.start_node_label} -->|{self.type}{''.join(props)}| {self.end_node_label}"
|
|
319
|
+
|
|
320
|
+
@classmethod
|
|
321
|
+
def from_arrows(
|
|
322
|
+
cls,
|
|
323
|
+
arrows_relationship_dict: dict[str, Any],
|
|
324
|
+
node_id_to_label_map: dict[str, str],
|
|
325
|
+
) -> "Relationship":
|
|
326
|
+
"Convert an Arrows Relationship to a Relationship."
|
|
327
|
+
props = [
|
|
328
|
+
Property.from_arrows({k: v})
|
|
329
|
+
for k, v in arrows_relationship_dict["properties"].items()
|
|
330
|
+
if "KEY" not in v.upper()
|
|
331
|
+
]
|
|
332
|
+
keys = [
|
|
333
|
+
{k: v}
|
|
334
|
+
for k, v in arrows_relationship_dict["properties"].items()
|
|
335
|
+
if "KEY" in v.upper()
|
|
336
|
+
]
|
|
337
|
+
key_prop = Property.from_arrows(keys[0]) if keys else None
|
|
338
|
+
metadata = {
|
|
339
|
+
"style": arrows_relationship_dict["style"],
|
|
340
|
+
}
|
|
341
|
+
return cls(
|
|
342
|
+
type=arrows_relationship_dict["type"],
|
|
343
|
+
start_node_label=node_id_to_label_map[arrows_relationship_dict["fromId"]],
|
|
344
|
+
end_node_label=node_id_to_label_map[arrows_relationship_dict["toId"]],
|
|
345
|
+
key_property=key_prop,
|
|
346
|
+
properties=props,
|
|
347
|
+
metadata=metadata,
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
def to_arrows(self) -> dict[str, Any]:
|
|
351
|
+
"Convert a Relationship to an Arrows Relationship dictionary. Final JSON string formatting is done at the data model level."
|
|
352
|
+
props = dict()
|
|
353
|
+
[props.update(p.to_arrows(is_key=False)) for p in self.properties]
|
|
354
|
+
if self.key_property:
|
|
355
|
+
props.update(self.key_property.to_arrows(is_key=True))
|
|
356
|
+
return {
|
|
357
|
+
"fromId": self.start_node_label,
|
|
358
|
+
"toId": self.end_node_label,
|
|
359
|
+
"type": self.type,
|
|
360
|
+
"properties": props,
|
|
361
|
+
"style": self.metadata.get("style", {}),
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
def get_cypher_ingest_query_for_many_records(
|
|
365
|
+
self, start_node_key_property_name: str, end_node_key_property_name: str
|
|
366
|
+
) -> str:
|
|
367
|
+
"""
|
|
368
|
+
Generate a Cypher query to ingest a list of Relationship records into a AgensGraph database.
|
|
369
|
+
The sourceId and targetId properties are used to match the start and end nodes.
|
|
370
|
+
This query takes a named parameter %(records)s that is a JSONB array of dictionaries.
|
|
371
|
+
Note: For AgensGraph with psycopg, use: cursor.execute(query, {"records": Jsonb(records)})
|
|
372
|
+
where Jsonb is imported from psycopg.types.json
|
|
373
|
+
"""
|
|
374
|
+
formatted_props = ", ".join(
|
|
375
|
+
[f"{p.name}: record.{p.name}" for p in self.properties]
|
|
376
|
+
)
|
|
377
|
+
key_prop = (
|
|
378
|
+
f" {{{self.key_property.name}: record.{self.key_property.name}}}"
|
|
379
|
+
if self.key_property
|
|
380
|
+
else ""
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
query = f"""UNWIND %(records)s as record
|
|
384
|
+
MATCH (startNode: {self.start_node_label} {{{start_node_key_property_name}: record.sourceId}})
|
|
385
|
+
MATCH (endNode: {self.end_node_label} {{{end_node_key_property_name}: record.targetId}})
|
|
386
|
+
MERGE (startNode)-[r:{self.type}{key_prop}]->(endNode)"""
|
|
387
|
+
if formatted_props:
|
|
388
|
+
# Relationship properties belong on the relationship (r), not the end node.
|
|
389
|
+
query += f"""
|
|
390
|
+
SET r += {{{formatted_props}}}"""
|
|
391
|
+
return _quote_identifiers(query)
|
|
392
|
+
|
|
393
|
+
def get_cypher_constraint_query(self) -> str | None:
|
|
394
|
+
"""
|
|
395
|
+
Generate a Cypher query to create a UNIQUE constraint on the relationship's key property.
|
|
396
|
+
This enforces uniqueness on the key property of the relationship using AgensGraph syntax.
|
|
397
|
+
First creates the ELABEL if it doesn't exist, then creates the constraint.
|
|
398
|
+
"""
|
|
399
|
+
if self.key_property:
|
|
400
|
+
# Create ELABEL first, then constraint
|
|
401
|
+
elabel_query = f"CREATE ELABEL IF NOT EXISTS {self.type}"
|
|
402
|
+
constraint_query = f"CREATE CONSTRAINT {self.type}_constraint ON {self.type} ASSERT {self.key_property.name} IS UNIQUE"
|
|
403
|
+
|
|
404
|
+
# Apply identifier quoting to both queries
|
|
405
|
+
elabel_query = _quote_identifiers(elabel_query)
|
|
406
|
+
constraint_query = _quote_identifiers(constraint_query)
|
|
407
|
+
|
|
408
|
+
# Return both queries separated by semicolon
|
|
409
|
+
return f"{elabel_query}; {constraint_query}"
|
|
410
|
+
else:
|
|
411
|
+
return None
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
class DataModel(BaseModel):
|
|
415
|
+
"A AgensGraph Graph Data Model."
|
|
416
|
+
|
|
417
|
+
nodes: list[Node] = Field(
|
|
418
|
+
default_factory=list, description="The nodes of the data model"
|
|
419
|
+
)
|
|
420
|
+
relationships: list[Relationship] = Field(
|
|
421
|
+
default_factory=list, description="The relationships of the data model"
|
|
422
|
+
)
|
|
423
|
+
metadata: dict[str, Any] = Field(
|
|
424
|
+
default_factory=dict,
|
|
425
|
+
description="The metadata of the data model. This should only be used when converting data models.",
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
@field_validator("nodes")
|
|
429
|
+
def validate_nodes(cls, nodes: list[Node]) -> list[Node]:
|
|
430
|
+
"Validate the nodes."
|
|
431
|
+
|
|
432
|
+
counts = Counter([n.label for n in nodes])
|
|
433
|
+
for label, count in counts.items():
|
|
434
|
+
if count > 1:
|
|
435
|
+
raise ValueError(
|
|
436
|
+
f"Node with label {label} appears {count} times in data model"
|
|
437
|
+
)
|
|
438
|
+
return nodes
|
|
439
|
+
|
|
440
|
+
@field_validator("relationships")
|
|
441
|
+
def validate_relationships(
|
|
442
|
+
cls, relationships: list[Relationship], info: ValidationInfo
|
|
443
|
+
) -> list[Relationship]:
|
|
444
|
+
"Validate the relationships."
|
|
445
|
+
|
|
446
|
+
# ensure source and target nodes exist
|
|
447
|
+
for relationship in relationships:
|
|
448
|
+
if relationship.start_node_label not in [
|
|
449
|
+
n.label for n in info.data["nodes"]
|
|
450
|
+
]:
|
|
451
|
+
raise ValueError(
|
|
452
|
+
f"Relationship {relationship.pattern} has a start node that does not exist in data model"
|
|
453
|
+
)
|
|
454
|
+
if relationship.end_node_label not in [n.label for n in info.data["nodes"]]:
|
|
455
|
+
raise ValueError(
|
|
456
|
+
f"Relationship {relationship.pattern} has an end node that does not exist in data model"
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
return relationships
|
|
460
|
+
|
|
461
|
+
@property
|
|
462
|
+
def nodes_dict(self) -> dict[str, Node]:
|
|
463
|
+
"Return a dictionary of the nodes of the data model. {node_label: node_dict}"
|
|
464
|
+
return {n.label: n for n in self.nodes}
|
|
465
|
+
|
|
466
|
+
@property
|
|
467
|
+
def relationships_dict(self) -> dict[str, Relationship]:
|
|
468
|
+
"Return a dictionary of the relationships of the data model. {relationship_pattern: relationship_dict}"
|
|
469
|
+
return {r.pattern: r for r in self.relationships}
|
|
470
|
+
|
|
471
|
+
def add_node(self, node: Node) -> None:
|
|
472
|
+
"Add a new node to the data model."
|
|
473
|
+
if node.label in [n.label for n in self.nodes]:
|
|
474
|
+
raise ValueError(
|
|
475
|
+
f"Node with label {node.label} already exists in data model"
|
|
476
|
+
)
|
|
477
|
+
self.nodes.append(node)
|
|
478
|
+
|
|
479
|
+
def add_relationship(self, relationship: Relationship) -> None:
|
|
480
|
+
"Add a new relationship to the data model."
|
|
481
|
+
if relationship.pattern in [r.pattern for r in self.relationships]:
|
|
482
|
+
raise ValueError(
|
|
483
|
+
f"Relationship {relationship.pattern} already exists in data model"
|
|
484
|
+
)
|
|
485
|
+
self.relationships.append(relationship)
|
|
486
|
+
|
|
487
|
+
def remove_node(self, node_label: str) -> None:
|
|
488
|
+
"Remove a node from the data model."
|
|
489
|
+
try:
|
|
490
|
+
[self.nodes.remove(x) for x in self.nodes if x.label == node_label]
|
|
491
|
+
except ValueError:
|
|
492
|
+
pass
|
|
493
|
+
|
|
494
|
+
def remove_relationship(
|
|
495
|
+
self,
|
|
496
|
+
relationship_type: str,
|
|
497
|
+
relationship_start_node_label: str,
|
|
498
|
+
relationship_end_node_label: str,
|
|
499
|
+
) -> None:
|
|
500
|
+
"Remove a relationship from the data model."
|
|
501
|
+
pattern = _generate_relationship_pattern(
|
|
502
|
+
relationship_start_node_label,
|
|
503
|
+
relationship_type,
|
|
504
|
+
relationship_end_node_label,
|
|
505
|
+
)
|
|
506
|
+
try:
|
|
507
|
+
[
|
|
508
|
+
self.relationships.remove(x)
|
|
509
|
+
for x in self.relationships
|
|
510
|
+
if x.pattern == pattern
|
|
511
|
+
]
|
|
512
|
+
except ValueError:
|
|
513
|
+
pass
|
|
514
|
+
|
|
515
|
+
def _generate_mermaid_config_styling_str(self) -> str:
|
|
516
|
+
"Generate the Mermaid configuration string for the data model."
|
|
517
|
+
node_color_config = ""
|
|
518
|
+
|
|
519
|
+
for idx, node in enumerate(self.nodes):
|
|
520
|
+
node_color_config += f"classDef node_{idx}_color fill:{NODE_COLOR_PALETTE[idx % len(NODE_COLOR_PALETTE)][0]},stroke:{NODE_COLOR_PALETTE[idx % len(NODE_COLOR_PALETTE)][1]},stroke-width:3px,color:#000,font-size:12px\nclass {node.label} node_{idx}_color\n\n"
|
|
521
|
+
|
|
522
|
+
return f"""
|
|
523
|
+
%% Styling
|
|
524
|
+
{node_color_config}
|
|
525
|
+
"""
|
|
526
|
+
|
|
527
|
+
def get_mermaid_config_str(self) -> str:
|
|
528
|
+
"Get the Mermaid configuration string for the data model."
|
|
529
|
+
mermaid_nodes = [n.get_mermaid_config_str() for n in self.nodes]
|
|
530
|
+
mermaid_relationships = [r.get_mermaid_config_str() for r in self.relationships]
|
|
531
|
+
mermaid_styling = self._generate_mermaid_config_styling_str()
|
|
532
|
+
nodes_formatted = "\n".join(mermaid_nodes)
|
|
533
|
+
relationships_formatted = "\n".join(mermaid_relationships)
|
|
534
|
+
return f"""graph TD
|
|
535
|
+
%% Nodes
|
|
536
|
+
{nodes_formatted}
|
|
537
|
+
|
|
538
|
+
%% Relationships
|
|
539
|
+
{relationships_formatted}
|
|
540
|
+
|
|
541
|
+
{mermaid_styling}
|
|
542
|
+
"""
|
|
543
|
+
|
|
544
|
+
@classmethod
|
|
545
|
+
def from_arrows(cls, arrows_data_model_dict: dict[str, Any]) -> "DataModel":
|
|
546
|
+
"Convert an Arrows Data Model to a Data Model."
|
|
547
|
+
nodes = [Node.from_arrows(n) for n in arrows_data_model_dict["nodes"]]
|
|
548
|
+
node_id_to_label_map = {
|
|
549
|
+
n["id"]: n["labels"][0] for n in arrows_data_model_dict["nodes"]
|
|
550
|
+
}
|
|
551
|
+
relationships = [
|
|
552
|
+
Relationship.from_arrows(r, node_id_to_label_map)
|
|
553
|
+
for r in arrows_data_model_dict["relationships"]
|
|
554
|
+
]
|
|
555
|
+
metadata = {
|
|
556
|
+
"style": arrows_data_model_dict["style"],
|
|
557
|
+
}
|
|
558
|
+
return cls(nodes=nodes, relationships=relationships, metadata=metadata)
|
|
559
|
+
|
|
560
|
+
def to_arrows_dict(self) -> dict[str, Any]:
|
|
561
|
+
"Convert the data model to an Arrows Data Model Python dictionary."
|
|
562
|
+
node_spacing: int = 200
|
|
563
|
+
y_current = 0
|
|
564
|
+
arrows_nodes = []
|
|
565
|
+
for idx, n in enumerate(self.nodes):
|
|
566
|
+
if (idx + 1) % 5 == 0:
|
|
567
|
+
y_current -= 200
|
|
568
|
+
arrows_nodes.append(
|
|
569
|
+
n.to_arrows(
|
|
570
|
+
default_position={"x": node_spacing * (idx % 5), "y": y_current}
|
|
571
|
+
)
|
|
572
|
+
)
|
|
573
|
+
arrows_relationships = [r.to_arrows() for r in self.relationships]
|
|
574
|
+
return {
|
|
575
|
+
"nodes": arrows_nodes,
|
|
576
|
+
"relationships": arrows_relationships,
|
|
577
|
+
"style": self.metadata.get("style", {}),
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
def to_arrows_json_str(self) -> str:
|
|
581
|
+
"Convert the data model to an Arrows Data Model JSON string."
|
|
582
|
+
return json.dumps(self.to_arrows_dict(), indent=2)
|
|
583
|
+
|
|
584
|
+
def get_node_cypher_ingest_query_for_many_records(self, node_label: str) -> str:
|
|
585
|
+
"Generate a Cypher query to ingest a list of Node records into a AgensGraph database."
|
|
586
|
+
node = self.nodes_dict[node_label]
|
|
587
|
+
return node.get_cypher_ingest_query_for_many_records()
|
|
588
|
+
|
|
589
|
+
def get_relationship_cypher_ingest_query_for_many_records(
|
|
590
|
+
self,
|
|
591
|
+
relationship_type: str,
|
|
592
|
+
relationship_start_node_label: str,
|
|
593
|
+
relationship_end_node_label: str,
|
|
594
|
+
) -> str:
|
|
595
|
+
"Generate a Cypher query to ingest a list of Relationship records into a AgensGraph database."
|
|
596
|
+
pattern = _generate_relationship_pattern(
|
|
597
|
+
relationship_start_node_label,
|
|
598
|
+
relationship_type,
|
|
599
|
+
relationship_end_node_label,
|
|
600
|
+
)
|
|
601
|
+
relationship = self.relationships_dict[pattern]
|
|
602
|
+
start_node = self.nodes_dict[relationship.start_node_label]
|
|
603
|
+
end_node = self.nodes_dict[relationship.end_node_label]
|
|
604
|
+
return relationship.get_cypher_ingest_query_for_many_records(
|
|
605
|
+
start_node.key_property.name, end_node.key_property.name
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
def get_cypher_constraints_query(self) -> list[str]:
|
|
609
|
+
"""
|
|
610
|
+
Generate a list of Cypher queries to create constraints on the data model.
|
|
611
|
+
This creates range indexes on the key properties of the nodes and relationships and enforces uniqueness and existence of the key properties.
|
|
612
|
+
"""
|
|
613
|
+
node_queries = [n.get_cypher_constraint_query() + ";" for n in self.nodes]
|
|
614
|
+
relationship_queries = [
|
|
615
|
+
r.get_cypher_constraint_query() + ";"
|
|
616
|
+
for r in self.relationships
|
|
617
|
+
if r.key_property is not None
|
|
618
|
+
]
|
|
619
|
+
return node_queries + relationship_queries
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from pydantic import BaseModel, Field
|
|
2
|
+
|
|
3
|
+
from .data_model import DataModel
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ExampleDataModelResponse(BaseModel):
|
|
7
|
+
"""Response model for the `get_example_data_model` tool."""
|
|
8
|
+
|
|
9
|
+
data_model: DataModel = Field(description="The example graph data model.")
|
|
10
|
+
mermaid_config: str = Field(
|
|
11
|
+
description="The Mermaid visualization configuration for the example graph data model."
|
|
12
|
+
)
|