Initial commit
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
# flake8: noqa
|
||||
|
||||
from .basenode import BaseNode
|
||||
from .baserelationship import BaseRelationship
|
||||
from .graphconnection import GraphConnection, init_neontology
|
||||
from .utils import auto_constrain
|
||||
|
||||
__all__ = [
|
||||
# BaseNode
|
||||
"BaseNode",
|
||||
# BaseRelationship
|
||||
"BaseRelationship",
|
||||
# GraphConnection
|
||||
"init_neontology",
|
||||
"GraphConnection",
|
||||
# utils
|
||||
"auto_constrain",
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,315 @@
|
||||
from typing import Any, ClassVar, Dict, List, Optional, Type, TypeVar, Union
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from .commonmodel import CommonModel
|
||||
from .graphconnection import GraphConnection
|
||||
|
||||
B = TypeVar("B", bound="BaseNode")
|
||||
|
||||
class BaseNode(CommonModel): # pyre-ignore[13]
|
||||
__primaryproperty__: ClassVar[str]
|
||||
__primarylabel__: ClassVar[Optional[str]]
|
||||
__secondarylabels__: ClassVar[Optional[list]] = []
|
||||
|
||||
def __init__(self, **data: dict):
|
||||
super().__init__(**data)
|
||||
|
||||
# we can define 'abstract' nodes which don't have a label
|
||||
# these are to provide common properties to be used by subclassed nodes
|
||||
# but shouldn't be put in the graph or even instantiated
|
||||
if self.__primarylabel__ is None:
|
||||
raise NotImplementedError(
|
||||
"Nodes to be used in the graph must define a primary label."
|
||||
)
|
||||
|
||||
def _get_merge_parameters(self) -> Dict[str, Any]:
|
||||
"""
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: a dictionary of key/value pairs.
|
||||
"""
|
||||
|
||||
params = {
|
||||
"pp": self.neo4j_dict()[self.__primaryproperty__],
|
||||
"always_set": self._get_prop_values(self._always_set),
|
||||
"set_on_match": self._get_prop_values(self._set_on_match),
|
||||
"set_on_create": self._get_prop_values(self._set_on_create),
|
||||
}
|
||||
|
||||
return params
|
||||
|
||||
def get_primary_property_value(self) -> Union[str, int]:
|
||||
return self._get_merge_parameters()["pp"]
|
||||
|
||||
def create(self, database: str = 'neo4j') -> None:
|
||||
"""Create this node in the graph."""
|
||||
|
||||
params = self.neo4j_dict()
|
||||
|
||||
all_props = self.neo4j_dict()
|
||||
|
||||
pp_value = all_props.pop(self.__primaryproperty__)
|
||||
|
||||
params = {"pp": pp_value, "all_props": all_props}
|
||||
|
||||
all_labels = [self.__primarylabel__] + self.__secondarylabels__
|
||||
|
||||
cypher = f"""
|
||||
CREATE (n:{":".join(all_labels)} {{ {self.__primaryproperty__}: $pp }})
|
||||
SET n += $all_props
|
||||
RETURN n
|
||||
"""
|
||||
graph = GraphConnection()
|
||||
with graph.driver.session(database=database) as session:
|
||||
result = session.run(cypher, params).single()
|
||||
if result:
|
||||
return self.__class__(**dict(result["n"]))
|
||||
return None
|
||||
|
||||
def merge(self, database: str = 'neo4j') -> None:
|
||||
"""Merge this node into the graph."""
|
||||
|
||||
params = self._get_merge_parameters()
|
||||
|
||||
all_labels = [self.__primarylabel__] + self.__secondarylabels__
|
||||
|
||||
cypher = f"""
|
||||
MERGE (n:{":".join(all_labels)} {{ {self.__primaryproperty__}: $pp }})
|
||||
ON MATCH SET n += $set_on_match
|
||||
ON CREATE SET n += $set_on_create
|
||||
SET n += $always_set
|
||||
RETURN n
|
||||
"""
|
||||
|
||||
graph = GraphConnection()
|
||||
with graph.driver.session(database=database) as session:
|
||||
result = session.run(cypher, params).single()
|
||||
if result:
|
||||
return self.__class__(**dict(result["n"]))
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def create_nodes(cls: Type[B], nodes: List[B]) -> List[Union[str, int]]:
|
||||
"""Create the given nodes in the database.
|
||||
|
||||
Args:
|
||||
nodes (List[B]): A list of nodes to create.
|
||||
|
||||
Returns:
|
||||
list: A list of the primary property values
|
||||
|
||||
Raises:
|
||||
TypeError: Raised if one of the nodes isn't of this type.
|
||||
"""
|
||||
|
||||
for node in nodes:
|
||||
if isinstance(node, cls) is False:
|
||||
raise TypeError("Node was incorrect type.")
|
||||
|
||||
node_list = [
|
||||
{"props": x.neo4j_dict(), "pp": x.neo4j_dict()[cls.__primaryproperty__]}
|
||||
for x in nodes
|
||||
]
|
||||
|
||||
all_labels = [cls.__primarylabel__] + cls.__secondarylabels__
|
||||
|
||||
cypher = f"""
|
||||
UNWIND $node_list AS node
|
||||
create (n:{":".join(all_labels)} {{{cls.__primaryproperty__}: node.pp}})
|
||||
SET n = node.props
|
||||
RETURN n
|
||||
"""
|
||||
|
||||
graph = GraphConnection()
|
||||
results = graph.cypher_write_many(
|
||||
cypher=cypher, params={"node_list": node_list}
|
||||
)
|
||||
|
||||
matched_nodes = [cls(**dict(x["n"])) for x in results]
|
||||
|
||||
return matched_nodes
|
||||
|
||||
@classmethod
|
||||
def merge_nodes(cls: Type[B], nodes: List[B]) -> List[B]:
|
||||
"""Merge multiple nodes into the database.
|
||||
|
||||
Args:
|
||||
nodes (List[B]): A list of nodes to merge.
|
||||
|
||||
Returns:
|
||||
list: A list of the primary property values
|
||||
|
||||
Raises:
|
||||
TypeError: Raised if any of the nodes provided don't match this class.
|
||||
"""
|
||||
|
||||
for node in nodes:
|
||||
if isinstance(node, cls) is False:
|
||||
raise TypeError("Node was incorrect type.")
|
||||
|
||||
node_list = [x._get_merge_parameters() for x in nodes]
|
||||
|
||||
all_labels = [cls.__primarylabel__] + cls.__secondarylabels__
|
||||
|
||||
cypher = f"""
|
||||
UNWIND $node_list AS node
|
||||
MERGE (n:{":".join(all_labels)} {{{cls.__primaryproperty__}: node.pp}})
|
||||
ON MATCH SET n += node.set_on_match
|
||||
ON CREATE SET n += node.set_on_create
|
||||
SET n += node.always_set
|
||||
RETURN n
|
||||
"""
|
||||
|
||||
graph = GraphConnection()
|
||||
results = graph.cypher_write_many(
|
||||
cypher=cypher, params={"node_list": node_list}
|
||||
)
|
||||
|
||||
matched_nodes = [cls(**dict(x["n"])) for x in results]
|
||||
|
||||
return matched_nodes
|
||||
|
||||
@classmethod
|
||||
def merge_records(cls: Type[B], records: dict) -> List[B]:
|
||||
"""Take a list of dictionaries and use them to merge in nodes in the graph.
|
||||
|
||||
Each dictionary will be used to merge a node where dictionary key/value pairs
|
||||
represent properties to be applied.
|
||||
|
||||
Returns:
|
||||
list: A list of the primary property values
|
||||
|
||||
Args:
|
||||
records (List[Dict[str, Any]]): a list of dictionaries of node properties
|
||||
"""
|
||||
|
||||
nodes = [cls(**x) for x in records]
|
||||
|
||||
return cls.merge_nodes(nodes)
|
||||
|
||||
@classmethod
|
||||
def merge_df(cls: Type[B], df: pd.DataFrame, deduplicate: bool = True) -> pd.Series:
|
||||
"""Merge in new nodes based on data in a dataframe.
|
||||
|
||||
The dataframe columns must correspond to the Node properties.
|
||||
|
||||
Returns:
|
||||
pd.Series: A list of the primary property values
|
||||
|
||||
Args:
|
||||
df (pd.DataFrame): A pandas dataframe of node properties
|
||||
|
||||
"""
|
||||
|
||||
if df.empty is True:
|
||||
return pd.Series(dtype=object)
|
||||
|
||||
input_df = df.replace([np.nan], None).copy()
|
||||
|
||||
if deduplicate is True:
|
||||
# we don't wan't to waste time attempting to merge identical records
|
||||
unique_df = input_df.drop_duplicates(ignore_index=True).copy()
|
||||
else:
|
||||
unique_df = input_df
|
||||
|
||||
records = unique_df.to_dict(orient="records")
|
||||
|
||||
unique_df["generated_nodes"] = pd.Series(cls.merge_records(records))
|
||||
|
||||
# now we need to get the mapping from unique id to primary property
|
||||
# so that we can return the data in the same shape it was received
|
||||
input_df.insert(0, "ontolocy_merging_order", range(0, len(input_df)))
|
||||
merge_cols = list(input_df.columns)
|
||||
merge_cols.remove("ontolocy_merging_order")
|
||||
output_df = input_df.merge(
|
||||
unique_df,
|
||||
how="inner",
|
||||
on=merge_cols,
|
||||
).sort_values("ontolocy_merging_order", ignore_index=True)
|
||||
|
||||
return output_df.generated_nodes
|
||||
|
||||
@classmethod
|
||||
def match(cls: Type[B], pp: str) -> Optional[B]:
|
||||
"""MATCH a single node of this type with the given primary property.
|
||||
|
||||
Args:
|
||||
pp (str): The value of the primary property (pp) to match on.
|
||||
|
||||
Returns:
|
||||
Optional[B]: If the node exists, return it as an instance.
|
||||
"""
|
||||
|
||||
cypher = f"""
|
||||
MATCH (n:{cls.__primarylabel__})
|
||||
WHERE n.{cls.__primaryproperty__} = $pp
|
||||
RETURN n
|
||||
"""
|
||||
|
||||
params = {"pp": pp}
|
||||
|
||||
graph = GraphConnection()
|
||||
|
||||
result = graph.cypher_read(cypher, params)
|
||||
|
||||
if result:
|
||||
return cls(**dict(result["n"]))
|
||||
|
||||
else:
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def delete(cls, pp: str) -> None:
|
||||
"""Delete a node from the graph.
|
||||
|
||||
Match on label and the pp value provided.
|
||||
If the node exists, delete it and any relationships it has.
|
||||
|
||||
Args:
|
||||
pp (str): Primary property value to match on.
|
||||
"""
|
||||
|
||||
cypher = f"""
|
||||
MATCH (n:{cls.__primarylabel__})
|
||||
WHERE n.{cls.__primaryproperty__} = $pp
|
||||
DETACH DELETE n
|
||||
"""
|
||||
|
||||
params = {"pp": pp}
|
||||
|
||||
graph = GraphConnection()
|
||||
|
||||
graph.cypher_write(cypher, params)
|
||||
|
||||
@classmethod
|
||||
def match_nodes(cls: Type[B], limit: int = 100, skip: int = 0) -> List[B]:
|
||||
"""Get nodes of this type from the database.
|
||||
|
||||
Run a MATCH cypher query to retrieve any Nodes with the label of this class.
|
||||
|
||||
Args:
|
||||
limit (int, optional): Maximum number of results to return. Defaults to 100.
|
||||
skip (int, optional): Skip through this many results (for pagination). Defaults to 0.
|
||||
|
||||
Returns:
|
||||
Optional[List[B]]: A list of node instances.
|
||||
"""
|
||||
|
||||
cypher = f"""
|
||||
MATCH(n:{cls.__primarylabel__})
|
||||
RETURN n{{.*}}
|
||||
ORDER BY n.created DESC
|
||||
SKIP $skip
|
||||
LIMIT $limit
|
||||
"""
|
||||
|
||||
params = {"skip": skip, "limit": limit}
|
||||
|
||||
graph = GraphConnection()
|
||||
records = graph.cypher_read_many(cypher, params)
|
||||
|
||||
nodes = [cls(**dict(x["n"])) for x in records]
|
||||
|
||||
return nodes
|
||||
@@ -0,0 +1,305 @@
|
||||
"""Defines the BaseRelationship class.
|
||||
|
||||
The BaseRelationship class is used for creating and matching on relationships in the graph.
|
||||
|
||||
Typical usage example:
|
||||
|
||||
class MyRel(BaseRelationship):
|
||||
|
||||
__relationshiptype__: ClassVar[Optional[str]] = "MY_REL"
|
||||
|
||||
source: SourceNode
|
||||
target: TargetNode
|
||||
|
||||
my_rel = MyRel(source=source_node, target=target_node)
|
||||
my_rel.merge()
|
||||
|
||||
"""
|
||||
|
||||
from typing import Any, ClassVar, Dict, List, Optional, Type, TypeVar
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from pydantic import PrivateAttr
|
||||
|
||||
from modules.database.tools.neontology.graphconnection import GraphConnection
|
||||
|
||||
from .basenode import BaseNode
|
||||
from .commonmodel import CommonModel
|
||||
|
||||
R = TypeVar("R", bound="BaseRelationship")
|
||||
|
||||
|
||||
class BaseRelationship(CommonModel): # pyre-ignore[13]
|
||||
source: BaseNode
|
||||
target: BaseNode
|
||||
|
||||
__relationshiptype__: ClassVar[Optional[str]] = None
|
||||
|
||||
_merge_on: List[
|
||||
str
|
||||
] = PrivateAttr() # what relationship properties should we merge on
|
||||
|
||||
def __init__(self, **data: dict):
|
||||
super().__init__(**data)
|
||||
|
||||
self._merge_on = self._get_prop_usage("merge_on")
|
||||
|
||||
# we can define 'abstract' relationships which don't have a label
|
||||
# these are to provide common properties to be used by subclassed relationships
|
||||
# but shouldn't be put in the graph or even instantiated
|
||||
if self.__relationshiptype__ is None:
|
||||
raise NotImplementedError(
|
||||
"Nodes to be used in the graph must define a primary label."
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_relationship_type(cls) -> str:
|
||||
"""Get the relationship type to use for creating and matching this relationship.
|
||||
|
||||
If __relationship__ has been specified, use that.
|
||||
|
||||
Otherwise use the class name in uppercase
|
||||
|
||||
Returns:
|
||||
str: the string to use for creating and matching this relationship
|
||||
"""
|
||||
return cls.__relationshiptype__ # pyre-ignore[7]
|
||||
|
||||
def _get_merge_parameters(
|
||||
self, source_prop: str, target_prop: str
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: a dictionary of key/value pairs.
|
||||
"""
|
||||
|
||||
exclusions = {"source", "target"}
|
||||
|
||||
# these properties will be referenced individually
|
||||
merge_props = self._get_prop_values(self._merge_on, exclude=exclusions)
|
||||
|
||||
params = {
|
||||
"source_prop": self.source.neo4j_dict()[source_prop],
|
||||
"target_prop": self.target.neo4j_dict()[target_prop],
|
||||
"always_set": self._get_prop_values(self._always_set, exclude=exclusions),
|
||||
"set_on_match": self._get_prop_values(
|
||||
self._set_on_match, exclude=exclusions
|
||||
),
|
||||
"set_on_create": self._get_prop_values(
|
||||
self._set_on_create, exclude=exclusions
|
||||
),
|
||||
**merge_props,
|
||||
}
|
||||
|
||||
return params
|
||||
|
||||
def merge(
|
||||
self,
|
||||
database: Optional[str] = 'neo4j' # default to 'neo4j' if not specified
|
||||
) -> None:
|
||||
"""Merge this relationship into the database."""
|
||||
source_label = self.source.__primarylabel__
|
||||
target_label = self.target.__primarylabel__
|
||||
|
||||
source_pp = self.source.__primaryproperty__
|
||||
target_pp = self.target.__primaryproperty__
|
||||
|
||||
params = self._get_merge_parameters(
|
||||
source_prop=source_pp, target_prop=target_pp
|
||||
)
|
||||
|
||||
rel_type = self.get_relationship_type()
|
||||
|
||||
# build a string of properties to merge on "prop_name: $prop_name"
|
||||
merge_props = ", ".join([f"{x}: ${x}" for x in self._merge_on])
|
||||
|
||||
cypher = f"""
|
||||
MATCH (source:{source_label} {{ {source_pp}: $source_prop }}),
|
||||
(target:{target_label} {{ {target_pp}: $target_prop }})
|
||||
MERGE (source)-[r:{rel_type} {{ {merge_props} }}]->(target)
|
||||
ON MATCH SET r += $set_on_match
|
||||
ON CREATE SET r += $set_on_create
|
||||
SET r += $always_set
|
||||
"""
|
||||
|
||||
graph = GraphConnection()
|
||||
# Use session with database instead of USE statement
|
||||
with graph.driver.session(database=database) as session:
|
||||
session.run(cypher, params)
|
||||
|
||||
@classmethod
|
||||
def merge_relationships(
|
||||
cls: Type[R],
|
||||
rels: List[R],
|
||||
source_type: Optional[Type[BaseNode]] = None,
|
||||
target_type: Optional[Type[BaseNode]] = None,
|
||||
source_prop: Optional[str] = None,
|
||||
target_prop: Optional[str] = None,
|
||||
database: Optional[str] = 'neo4j' # Add database parameter
|
||||
) -> None:
|
||||
"""Merge multiple relationships (of this type) into the database.
|
||||
|
||||
Sometimes the source and target label may be ambiguous (e.g. where we have subclassed nodes)
|
||||
In this case you can explicitly pass in the relevant types
|
||||
|
||||
Sometimes we want to match nodes on a property which isn't the primary property,
|
||||
so we can specify what property to use.
|
||||
|
||||
Args:
|
||||
cls (Type[R]): this class
|
||||
rels (List[R]): a list of relationships which are instances of this class
|
||||
database (Optional[str]): database to use for the operation
|
||||
|
||||
Raises:
|
||||
TypeError: If relationships are provided which aren't of this class
|
||||
"""
|
||||
|
||||
if source_type is None:
|
||||
source_type = cls.model_fields["source"].annotation
|
||||
|
||||
if target_type is None:
|
||||
target_type = cls.model_fields["target"].annotation
|
||||
|
||||
for rel in rels:
|
||||
if isinstance(rel, cls) is False:
|
||||
raise TypeError("Relationship was incorrect type.")
|
||||
if type(rel.source) is not source_type:
|
||||
raise TypeError("Received an inappropriate kind of source node.")
|
||||
if type(rel.target) is not target_type:
|
||||
raise TypeError("Received an inappropriate kind of target node.")
|
||||
|
||||
if source_prop is None:
|
||||
source_prop = source_type.__primaryproperty__
|
||||
|
||||
if target_prop is None:
|
||||
target_prop = target_type.__primaryproperty__
|
||||
|
||||
source_label = source_type.__primarylabel__
|
||||
target_label = target_type.__primarylabel__
|
||||
|
||||
# build a string of properties to merge on "prop_name: $prop_name"
|
||||
# we need to instantiate the class so that _merge_on is generated as part of __init__
|
||||
merge_props = ", ".join([f"{x}: ${x}" for x in cls._get_prop_usage("merge_on")])
|
||||
|
||||
rel_list: List[Dict[str, Any]] = [
|
||||
x._get_merge_parameters(source_prop, target_prop) for x in rels
|
||||
]
|
||||
|
||||
rel_type = cls.get_relationship_type()
|
||||
|
||||
cypher = f"""
|
||||
UNWIND $rel_list AS rel
|
||||
MATCH (source:{source_label})
|
||||
WHERE source.{source_prop} = rel.source_prop
|
||||
MATCH (target:{target_label})
|
||||
WHERE target.{target_prop} = rel.target_prop
|
||||
MERGE (source)-[r:{rel_type} {{ {merge_props} }}]->(target)
|
||||
ON MATCH SET r += rel.set_on_match
|
||||
ON CREATE SET r += rel.set_on_create
|
||||
SET r += rel.always_set
|
||||
"""
|
||||
|
||||
graph = GraphConnection()
|
||||
# Use session with database instead of USE statement
|
||||
with graph.driver.session(database=database) as session:
|
||||
session.run(cypher=cypher, parameters={"rel_list": rel_list})
|
||||
|
||||
@classmethod
|
||||
def merge_records(
|
||||
cls: Type[R],
|
||||
records: List[Dict[str, Any]],
|
||||
source_type: Optional[Type[BaseNode]] = None,
|
||||
target_type: Optional[Type[BaseNode]] = None,
|
||||
source_prop: Optional[str] = None,
|
||||
target_prop: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Take a list of dictionaries and use them to merge in relationships in the graph.
|
||||
|
||||
Sometimes, a relationship can accept nodes which subclass a particular node type.
|
||||
In these instances, it may be necessary to explicitly state what type of node should be used.
|
||||
|
||||
Each record should have a source and target key where the value is the primary property
|
||||
value of the respective nodes.
|
||||
|
||||
Args:
|
||||
records (List[Dict[str, Any]]): a list of dictionaries used to populate relationships
|
||||
source_type: explicitly state the class to use for source node
|
||||
target_type: explicitly state the class to use for target node
|
||||
"""
|
||||
|
||||
hydrated_list = []
|
||||
|
||||
if source_type is None:
|
||||
source_type = cls.model_fields["source"].annotation
|
||||
|
||||
if target_type is None:
|
||||
target_type = cls.model_fields["target"].annotation
|
||||
|
||||
if source_prop is None:
|
||||
source_prop = source_type.__primaryproperty__
|
||||
|
||||
if target_prop is None:
|
||||
target_prop = target_type.__primaryproperty__
|
||||
|
||||
for record in records:
|
||||
hydrated = dict(record)
|
||||
|
||||
hydrated["source"] = source_type.model_construct(
|
||||
**{source_prop: record["source"]}
|
||||
)
|
||||
hydrated["target"] = target_type.model_construct(
|
||||
**{target_prop: record["target"]}
|
||||
)
|
||||
|
||||
hydrated_list.append(hydrated)
|
||||
|
||||
rels = [cls(**x) for x in hydrated_list]
|
||||
|
||||
cls.merge_relationships(
|
||||
rels,
|
||||
source_type=source_type,
|
||||
source_prop=source_prop,
|
||||
target_type=target_type,
|
||||
target_prop=target_prop,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def merge_df(
|
||||
cls: Type[R],
|
||||
df: pd.DataFrame,
|
||||
source_type: Optional[Type[BaseNode]] = None,
|
||||
target_type: Optional[Type[BaseNode]] = None,
|
||||
source_prop: Optional[str] = None,
|
||||
target_prop: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Merge in relationships based on data in a pandas data frame
|
||||
|
||||
Expects columns named 'source' and 'target' with the primary property value
|
||||
for the source and target nodes.
|
||||
|
||||
Then additional fields should have a corresponding column.
|
||||
|
||||
Args:
|
||||
df (pd.DataFrame): pandas dataframe where each row represents a relationship to merge
|
||||
"""
|
||||
|
||||
if df.empty is False:
|
||||
records = df.replace([np.nan], None).to_dict(orient="records")
|
||||
cls.merge_records(
|
||||
records,
|
||||
source_type=source_type,
|
||||
source_prop=source_prop,
|
||||
target_type=target_type,
|
||||
target_prop=target_prop,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def to_dict(cls):
|
||||
return {
|
||||
"source": cls.source.to_dict(),
|
||||
"target": cls.target.to_dict(),
|
||||
"relationship_type": cls.__relationshiptype__
|
||||
}
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import date, datetime, time, timedelta
|
||||
from typing import Any, ClassVar, Dict, List, Optional, Set
|
||||
|
||||
from neo4j.time import Date as Neo4jDate
|
||||
from neo4j.time import DateTime as Neo4jDateTime
|
||||
from neo4j.time import Time as Neo4jTime
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
ConfigDict,
|
||||
Field,
|
||||
PrivateAttr,
|
||||
field_validator,
|
||||
model_validator,
|
||||
)
|
||||
|
||||
|
||||
class CommonModel(BaseModel, ABC):
|
||||
model_config = ConfigDict(
|
||||
validate_assignment=True,
|
||||
extra="forbid",
|
||||
arbitrary_types_allowed=True,
|
||||
)
|
||||
|
||||
created: datetime = Field(
|
||||
default_factory=datetime.now, json_schema_extra={"set_on_create": True}
|
||||
)
|
||||
merged: Optional[datetime] = Field(default=None, validate_default=True)
|
||||
|
||||
_set_on_match: List[str] = PrivateAttr()
|
||||
_set_on_create: List[str] = PrivateAttr()
|
||||
_always_set: List[str] = PrivateAttr()
|
||||
|
||||
_neo4j_supported_types: ClassVar[Any] = (
|
||||
list,
|
||||
bool,
|
||||
int,
|
||||
bytearray,
|
||||
float,
|
||||
str,
|
||||
bytes,
|
||||
date,
|
||||
time,
|
||||
datetime,
|
||||
timedelta,
|
||||
)
|
||||
|
||||
def __init__(self, **data: dict):
|
||||
super().__init__(**data)
|
||||
|
||||
self._set_on_match = self._get_prop_usage("set_on_match")
|
||||
self._set_on_create = self._get_prop_usage("set_on_create")
|
||||
self._always_set = [
|
||||
x
|
||||
for x in self.model_dump().keys()
|
||||
if x not in self._set_on_match + self._set_on_create + ["source", "target"]
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def _get_prop_usage(cls, usage_type: str) -> List[str]:
|
||||
all_props = cls.model_json_schema()["properties"]
|
||||
|
||||
selected_props = []
|
||||
|
||||
for prop, entry in all_props.items():
|
||||
if entry.get(usage_type) is True:
|
||||
selected_props.append(prop)
|
||||
|
||||
return selected_props
|
||||
|
||||
def _get_prop_values(
|
||||
self, props: List[str], exclude: Set[str] = set()
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: a dictionary of key/value pairs.
|
||||
"""
|
||||
|
||||
prop_values = {
|
||||
k: v for k, v in self.neo4j_dict(exclude=exclude).items() if k in props
|
||||
}
|
||||
|
||||
return prop_values
|
||||
|
||||
@abstractmethod
|
||||
def _get_merge_parameters(self) -> Dict[str, Any]:
|
||||
raise NotImplementedError
|
||||
|
||||
@classmethod
|
||||
def export_type_converter(cls, value: Any) -> Any:
|
||||
if isinstance(value, dict):
|
||||
raise TypeError("Neo4j doesn't support dict types for properties.")
|
||||
|
||||
elif isinstance(value, (tuple, set)):
|
||||
new_value = list(value)
|
||||
return cls.export_type_converter(new_value)
|
||||
|
||||
elif isinstance(value, list):
|
||||
# items in a list must all be the same type
|
||||
item_type = type(value[0])
|
||||
for item in value:
|
||||
if isinstance(item, item_type) is False:
|
||||
raise TypeError(
|
||||
"For neo4j, all items in a list must be of the same type."
|
||||
)
|
||||
|
||||
return [cls.export_type_converter(x) for x in value]
|
||||
|
||||
elif isinstance(value, cls._neo4j_supported_types) is False:
|
||||
return str(value)
|
||||
|
||||
else:
|
||||
return value
|
||||
|
||||
@classmethod
|
||||
def _export_dict_converter(cls, original_dict: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""_summary_
|
||||
|
||||
Args:
|
||||
export_dict (Dict[str, Any]): _description_
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: _description_
|
||||
"""
|
||||
|
||||
export_dict = original_dict.copy()
|
||||
|
||||
for k, v in export_dict.items():
|
||||
export_dict[k] = cls.export_type_converter(v)
|
||||
|
||||
return export_dict
|
||||
|
||||
def neo4j_dict(self, **kwargs: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Return a dict made up of only types compatible with neo4j
|
||||
|
||||
Returns:
|
||||
dict: a dictionary export of this model instance
|
||||
"""
|
||||
|
||||
export_dict = self.model_dump(exclude_none=True, **kwargs)
|
||||
|
||||
export_dict = self._export_dict_converter(export_dict)
|
||||
|
||||
return export_dict
|
||||
|
||||
#
|
||||
# validators
|
||||
#
|
||||
|
||||
@field_validator("merged")
|
||||
def set_merged_to_created(
|
||||
cls, value: Optional[datetime], values: Dict[str, Any]
|
||||
) -> datetime:
|
||||
"""By default, set the 'merged' time equal to the 'created' time.
|
||||
|
||||
If the 'merged' value has been explicitly set, this is preserved.
|
||||
|
||||
Args:
|
||||
value (Optional[datetime]): the value of the field.
|
||||
values (Dict[str, Any]): a dictionary of field/value pairs set so far.
|
||||
|
||||
Returns:
|
||||
datetime: The merged datetime value.
|
||||
"""
|
||||
|
||||
if value is None:
|
||||
return values.data["created"]
|
||||
else:
|
||||
return value
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def neo4j_datetime_to_native(cls, values: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Datetimes come back from Neo4j as a non standard DateTime type.
|
||||
|
||||
We check for any values where that is the case and convert them to
|
||||
native Python datetimes.
|
||||
|
||||
See https://neo4j.com/docs/api/python-driver/4.4/temporal_types.html for further info.
|
||||
|
||||
Args:
|
||||
values (Dict[str, Any]): Dictionary of field/value pairs from pydantic.
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: Returns the dictionary, with any Neo4jDateTimes updated.
|
||||
"""
|
||||
|
||||
if not isinstance(values, dict):
|
||||
raise ValueError
|
||||
|
||||
for key in values:
|
||||
if isinstance(values[key], (Neo4jDateTime, Neo4jDate, Neo4jTime)):
|
||||
values[key] = values[key].to_native()
|
||||
|
||||
return values
|
||||
@@ -0,0 +1,253 @@
|
||||
from dotenv import load_dotenv, find_dotenv
|
||||
load_dotenv(find_dotenv())
|
||||
import os
|
||||
import modules.logger_tool as logger
|
||||
log_name = 'api_modules_database_tools_neontology_graphconnection'
|
||||
log_dir = os.getenv("LOG_PATH", "/logs") # Default path as fallback
|
||||
logging = logger.get_logger(
|
||||
name=log_name,
|
||||
log_level=os.getenv("LOG_LEVEL", "DEBUG"),
|
||||
log_path=log_dir,
|
||||
log_file=log_name,
|
||||
runtime=True,
|
||||
log_format='default'
|
||||
)
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from neo4j import GraphDatabase, Neo4jDriver
|
||||
from neo4j import Record as Neo4jRecord
|
||||
from neo4j import Result as Neo4jResult
|
||||
from neo4j import Transaction as Neo4jTransaction
|
||||
|
||||
from .result import NeontologyResult, neo4j_records_to_neontology_records
|
||||
|
||||
|
||||
class GraphConnection(object):
|
||||
"""Class for managing connections to Neo4j."""
|
||||
|
||||
_instance = None
|
||||
|
||||
def __new__(
|
||||
cls,
|
||||
neo4j_uri: Optional[str] = None,
|
||||
neo4j_username: Optional[str] = None,
|
||||
neo4j_password: Optional[str] = None,
|
||||
) -> "GraphConnection":
|
||||
"""Make sure we only have a single connection to the GraphDatabase.
|
||||
|
||||
This connection then gets used by all instances.
|
||||
|
||||
Args:
|
||||
neo4j_uri (Optional[str], optional): Neo4j URI to connect to. Defaults to None.
|
||||
neo4j_username (Optional[str], optional): Neo4j username. Defaults to None.
|
||||
neo4j_password (Optional[str], optional): Neo4j password. Defaults to None.
|
||||
|
||||
Returns:
|
||||
GraphConnection: Instance of the connection
|
||||
"""
|
||||
|
||||
if cls._instance is None:
|
||||
cls._instance = object.__new__(cls)
|
||||
|
||||
if GraphConnection._instance:
|
||||
try:
|
||||
driver = GraphConnection._instance.driver = GraphDatabase.driver( # type: ignore
|
||||
neo4j_uri, auth=(neo4j_username, neo4j_password)
|
||||
)
|
||||
driver.verify_connectivity()
|
||||
|
||||
from .utils import get_node_types, get_rels_by_type
|
||||
|
||||
# capture all possible types of node and relationship
|
||||
cls.global_nodes = get_node_types()
|
||||
cls.global_rels = get_rels_by_type()
|
||||
|
||||
except Exception as error:
|
||||
logging.error(
|
||||
"Error: connection not established. Have you run init_neontology? {}".format(
|
||||
error
|
||||
)
|
||||
)
|
||||
GraphConnection._instance = None
|
||||
|
||||
else:
|
||||
GraphConnection._instance = None
|
||||
|
||||
return cls._instance
|
||||
|
||||
def __del__(self) -> None:
|
||||
"""Close the driver gracefully when the class gets deleted."""
|
||||
|
||||
self.driver.close()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
neo4j_uri: Optional[str] = None,
|
||||
neo4j_username: Optional[str] = None,
|
||||
neo4j_password: Optional[str] = None,
|
||||
) -> None:
|
||||
if self._instance:
|
||||
self.driver: Neo4jDriver = self._instance.driver
|
||||
|
||||
def run_transaction_single(
|
||||
self, tx: Neo4jTransaction, query: str, params: Dict[str, Any]
|
||||
) -> Optional[Neo4jRecord]:
|
||||
"""Run a transaction which is expected to return a single result.
|
||||
|
||||
Args:
|
||||
tx (Neo4jTransaction): Neo4j Transaction object
|
||||
query (str): cypher query to run
|
||||
params (Dict[str, Any]): Parameters to pass to the query
|
||||
|
||||
Returns:
|
||||
Optional[Neo4jRecord]: The result
|
||||
"""
|
||||
|
||||
return tx.run(query, **params).single()
|
||||
|
||||
def run_transaction_many(
|
||||
self, tx: Neo4jTransaction, query: str, params: Dict[str, Any]
|
||||
) -> List[Neo4jRecord]:
|
||||
"""Run a transation which is expected to return multiple nodes.
|
||||
|
||||
Args:
|
||||
tx (Neo4jTransaction): Neo4j Transaction object
|
||||
query (str): cypher query to run
|
||||
params (Dict[str, Any]): parameters to pass the query
|
||||
|
||||
Returns:
|
||||
List[Neo4jRecord]: a list of the results
|
||||
"""
|
||||
|
||||
return [record for record in tx.run(query, **params)]
|
||||
|
||||
def cypher_write(self, cypher: str, params: Dict[str, Any] = {}) -> None:
|
||||
"""Execute a write transaction.
|
||||
|
||||
Args:
|
||||
cypher (str): cypher query
|
||||
params (Dict[str, Any]): parameters to pass to the query
|
||||
"""
|
||||
|
||||
with self.driver.session() as session:
|
||||
session.execute_write(self.run_transaction_single, cypher, params)
|
||||
|
||||
def cypher_write_single(self, cypher: str, params: Dict[str, Any] = {}) -> None:
|
||||
"""Execute a write transaction.
|
||||
|
||||
Args:
|
||||
cypher (str): cypher query
|
||||
params (Dict[str, Any]): parameters to pass to the query
|
||||
"""
|
||||
|
||||
with self.driver.session() as session:
|
||||
return session.execute_write(self.run_transaction_single, cypher, params)
|
||||
|
||||
def cypher_write_many(self, cypher: str, params: Dict[str, Any] = {}) -> None:
|
||||
"""Execute a write transaction.
|
||||
|
||||
Args:
|
||||
cypher (str): cypher query
|
||||
params (Dict[str, Any]): parameters to pass to the query
|
||||
"""
|
||||
|
||||
with self.driver.session() as session:
|
||||
return session.execute_write(self.run_transaction_many, cypher, params)
|
||||
|
||||
def cypher_read(
|
||||
self, cypher: str, params: Dict[str, Any] = {}
|
||||
) -> Optional[Neo4jRecord]:
|
||||
"""Run a cypher read only query which is expected to return a single result.
|
||||
|
||||
Args:
|
||||
cypher (str): cypher query string
|
||||
params (Dict[str, Any]): parameters to pass to the query
|
||||
|
||||
Returns:
|
||||
Neo4jRecord: the resulting Neo4j 'Record', or None
|
||||
"""
|
||||
|
||||
with self.driver.session() as session:
|
||||
return session.execute_read(self.run_transaction_single, cypher, params)
|
||||
|
||||
def cypher_read_many(
|
||||
self, cypher: str, params: Dict[str, Any] = {}
|
||||
) -> List[Neo4jRecord]:
|
||||
"""Run a cypher read query which will return multiple records.
|
||||
|
||||
Args:
|
||||
cypher (str): cypher string to run
|
||||
params (Dict[str, Any]): parameters to pass to the query
|
||||
|
||||
Returns:
|
||||
List[Neo4jRecord]: A list of Neo4j 'Records' returned by the query.
|
||||
"""
|
||||
|
||||
with self.driver.session() as session:
|
||||
return session.execute_read(self.run_transaction_many, cypher, params)
|
||||
|
||||
def apply_constraint(self, label: str, property: str) -> None:
|
||||
cypher = f"""
|
||||
CREATE CONSTRAINT IF NOT EXISTS
|
||||
FOR (n:{label})
|
||||
REQUIRE n.{property} IS UNIQUE
|
||||
"""
|
||||
|
||||
self.cypher_write(cypher)
|
||||
|
||||
def evaluate_query_single(self, cypher, params={}):
|
||||
result = self.driver.execute_query(
|
||||
cypher, parameters_=params, result_transformer_=Neo4jResult.single
|
||||
)
|
||||
|
||||
if result:
|
||||
return result.value()
|
||||
|
||||
else:
|
||||
return None
|
||||
|
||||
def evaluate_query(self, cypher, params={}):
|
||||
result = self.driver.execute_query(cypher, parameters_=params)
|
||||
|
||||
neo4j_records = result.records
|
||||
neontology_records = neo4j_records_to_neontology_records(
|
||||
neo4j_records, self.global_nodes, self.global_rels
|
||||
)
|
||||
|
||||
return NeontologyResult(
|
||||
records=neo4j_records, neontology_records=neontology_records
|
||||
)
|
||||
|
||||
|
||||
def init_neontology(
|
||||
neo4j_uri: Optional[str] = None,
|
||||
neo4j_username: Optional[str] = None,
|
||||
neo4j_password: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Initialise neontology.
|
||||
|
||||
If connection properties are explicitly passed in, use these.
|
||||
If not, attempt to load from enviornment variables (optionally in a .env file.)
|
||||
|
||||
Args:
|
||||
neo4j_uri (Optional[str], optional): Neo4j URI to connect to. Defaults to None.
|
||||
neo4j_username (Optional[str], optional): Neo4j username. Defaults to None.
|
||||
neo4j_password (Optional[str], optional): Neo4j password. Defaults to None.
|
||||
"""
|
||||
|
||||
# try to load environment variables from .env file
|
||||
load_dotenv()
|
||||
|
||||
if neo4j_uri is None:
|
||||
neo4j_uri = os.getenv("NEO4J_URI")
|
||||
|
||||
if neo4j_password is None:
|
||||
neo4j_password = os.getenv("PASSWORD_NEO4J")
|
||||
|
||||
if neo4j_username is None:
|
||||
neo4j_username = os.getenv("USER_NEO4J")
|
||||
|
||||
GraphConnection(neo4j_uri, neo4j_username, neo4j_password)
|
||||
|
||||
def close_neontology():
|
||||
GraphConnection().__del__()
|
||||
@@ -0,0 +1,114 @@
|
||||
import itertools
|
||||
import warnings
|
||||
from typing import List
|
||||
|
||||
from neo4j import Record as Neo4jRecord
|
||||
from neo4j.graph import Node as Neo4jNode
|
||||
from neo4j.graph import Relationship as Neo4jRelationship
|
||||
from pydantic import BaseModel, computed_field
|
||||
|
||||
|
||||
def neo4j_records_to_neontology_records(
|
||||
records: List[Neo4jRecord], node_classes: list, rel_classes: list
|
||||
) -> list:
|
||||
new_records = []
|
||||
|
||||
for record in records:
|
||||
new_record = {"nodes": {}, "relationships": {}}
|
||||
for key, entry in record.items():
|
||||
if isinstance(entry, Neo4jNode):
|
||||
node_label = list(entry.labels)[0]
|
||||
|
||||
# gracefully handle cases where we don't have a class defined
|
||||
# for the identified label
|
||||
try:
|
||||
node = node_classes[node_label](**dict(entry))
|
||||
new_record["nodes"][key] = node
|
||||
except KeyError:
|
||||
warnings.warn(
|
||||
(
|
||||
f"Could not find a class for {node_label} label."
|
||||
" Did you define the class before initializing Neontology?"
|
||||
)
|
||||
)
|
||||
pass
|
||||
|
||||
elif isinstance(entry, Neo4jRelationship):
|
||||
rel_type = entry.type
|
||||
|
||||
rel_dict = rel_classes[rel_type]
|
||||
|
||||
if not rel_dict:
|
||||
warnings.warn(
|
||||
(
|
||||
f"Could not find a class for {rel_type} relationship type."
|
||||
" Did you define the class before initializing Neontology?"
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
src_label = list(entry.nodes[0].labels)[0]
|
||||
tgt_label = list(entry.nodes[1].labels)[0]
|
||||
|
||||
src_node = node_classes[src_label](**dict(entry.nodes[0]))
|
||||
tgt_node = node_classes[tgt_label](**dict(entry.nodes[1]))
|
||||
|
||||
rel_props = dict(entry)
|
||||
rel_props["source"] = src_node
|
||||
rel_props["target"] = tgt_node
|
||||
|
||||
rel = rel_dict["rel_class"](**rel_props)
|
||||
|
||||
new_record["relationships"][key] = rel
|
||||
|
||||
new_records.append(new_record)
|
||||
|
||||
return new_records
|
||||
|
||||
|
||||
class NeontologyResult(BaseModel):
|
||||
records: list
|
||||
neontology_records: list
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def nodes(self) -> list:
|
||||
nodes_list_of_lists = [x["nodes"].values() for x in self.neontology_records]
|
||||
return list(itertools.chain.from_iterable(nodes_list_of_lists))
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def relationships(self) -> list:
|
||||
nodes_list_of_lists = [
|
||||
x["relationships"].values() for x in self.neontology_records
|
||||
]
|
||||
return list(itertools.chain.from_iterable(nodes_list_of_lists))
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def node_link_data(self) -> dict:
|
||||
nodes = [
|
||||
{
|
||||
"id": x.get_primary_property_value(),
|
||||
"label": x.__primarylabel__,
|
||||
"name": str(x),
|
||||
}
|
||||
for x in self.nodes
|
||||
]
|
||||
|
||||
links = [
|
||||
{
|
||||
"source": x.source.get_primary_property_value(),
|
||||
"target": x.target.get_primary_property_value(),
|
||||
}
|
||||
for x in self.relationships
|
||||
]
|
||||
|
||||
unique_nodes = list({frozenset(item.items()): item for item in nodes}.values())
|
||||
unique_links = list({frozenset(item.items()): item for item in links}.values())
|
||||
data = {
|
||||
"nodes": unique_nodes,
|
||||
"links": unique_links,
|
||||
}
|
||||
|
||||
return data
|
||||
@@ -0,0 +1,116 @@
|
||||
from collections import defaultdict
|
||||
from typing import Dict, Set, Type
|
||||
|
||||
from .basenode import BaseNode
|
||||
from .baserelationship import BaseRelationship
|
||||
from .graphconnection import GraphConnection
|
||||
|
||||
|
||||
def get_node_types(base_type: Type[BaseNode] = BaseNode) -> Dict[str, Type[BaseNode]]:
|
||||
node_types = {}
|
||||
|
||||
for subclass in base_type.__subclasses__():
|
||||
# we can define 'abstract' nodes which don't have a label
|
||||
# these are to provide common properties to be used by subclassed nodes
|
||||
# but shouldn't be put in the graph
|
||||
if (
|
||||
hasattr(subclass, "__primarylabel__")
|
||||
and subclass.__primarylabel__ is not None
|
||||
):
|
||||
node_types[subclass.__primarylabel__] = subclass
|
||||
|
||||
if subclass.__subclasses__():
|
||||
subclass_node_types = get_node_types(subclass)
|
||||
|
||||
node_types.update(subclass_node_types)
|
||||
|
||||
return node_types
|
||||
|
||||
|
||||
def get_rels_by_type(
|
||||
base_type: Type[BaseRelationship] = BaseRelationship,
|
||||
) -> Dict[str, dict]:
|
||||
rel_types: dict = defaultdict(dict)
|
||||
|
||||
for rel_subclass in base_type.__subclasses__():
|
||||
# we can define 'abstract' relationships which don't have a label
|
||||
# these are to provide common properties to be used by subclassed relationships
|
||||
# but shouldn't be put in the graph
|
||||
if (
|
||||
hasattr(rel_subclass, "__relationshiptype__")
|
||||
and rel_subclass.__relationshiptype__ is not None
|
||||
):
|
||||
rel_types[rel_subclass.__relationshiptype__] = {
|
||||
"rel_class": rel_subclass,
|
||||
"source_class": rel_subclass.model_fields["source"].annotation,
|
||||
"target_class": rel_subclass.model_fields["target"].annotation,
|
||||
}
|
||||
|
||||
if rel_subclass.__subclasses__():
|
||||
subclass_rel_types = get_rels_by_type(rel_subclass)
|
||||
|
||||
rel_types.update(subclass_rel_types)
|
||||
|
||||
return rel_types
|
||||
|
||||
|
||||
def all_subclasses(cls: type) -> set:
|
||||
return set(cls.__subclasses__()).union(
|
||||
[s for c in cls.__subclasses__() for s in all_subclasses(c)]
|
||||
)
|
||||
|
||||
|
||||
def get_rels_by_node(
|
||||
base_type: Type[BaseRelationship] = BaseRelationship, by_source: bool = True
|
||||
) -> Dict[str, Set[str]]:
|
||||
if by_source is True:
|
||||
node_dir = "source_class"
|
||||
|
||||
else:
|
||||
node_dir = "target_class"
|
||||
|
||||
all_rels = get_rels_by_type(base_type)
|
||||
|
||||
by_node: Dict[str, Set[str]] = defaultdict(set)
|
||||
|
||||
for rel_type, entry in all_rels.items():
|
||||
try:
|
||||
node_label = entry[node_dir].__primarylabel__
|
||||
except AttributeError:
|
||||
node_label = None
|
||||
|
||||
if node_label is not None:
|
||||
by_node[node_label].add(rel_type)
|
||||
|
||||
for node_subclass in all_subclasses(entry[node_dir]):
|
||||
subclass_label = node_subclass.__primarylabel__
|
||||
if subclass_label is not None:
|
||||
by_node[subclass_label].add(rel_type)
|
||||
|
||||
return by_node
|
||||
|
||||
|
||||
def get_rels_by_source(
|
||||
base_type: Type[BaseRelationship] = BaseRelationship,
|
||||
) -> Dict[str, Set[str]]:
|
||||
return get_rels_by_node(by_source=True)
|
||||
|
||||
|
||||
def get_rels_by_target(
|
||||
base_type: Type[BaseRelationship] = BaseRelationship,
|
||||
) -> Dict[str, Set[str]]:
|
||||
return get_rels_by_node(by_source=False)
|
||||
|
||||
|
||||
def auto_constrain() -> None:
|
||||
"""Automatically apply constraints
|
||||
|
||||
Get information about all the defined nodes in the current environment.
|
||||
|
||||
Apply constraints based on the primary label and primary property for each node.
|
||||
"""
|
||||
|
||||
graph = GraphConnection()
|
||||
|
||||
for node_label, node_type in get_node_types().items():
|
||||
graph.apply_constraint(node_label, node_type.__primaryproperty__)
|
||||
Reference in New Issue
Block a user