Initial commit

This commit is contained in:
2025-07-11 13:52:19 +00:00
commit e0c489f625
362 changed files with 27286 additions and 0 deletions
@@ -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",
]
@@ -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__()
+114
View File
@@ -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
+116
View File
@@ -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__)