diff --git a/CHANGELOG.md b/CHANGELOG.md index 572cec4..f02cc13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,9 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - + +### Added + +- `algorithms` module +- `bfs()`, `shortest_path()`, `all_shortest_paths()`, `connected_components()`, `neighborhood()` algorithms with engine wrapper ## [0.5] - 2026-06-10 diff --git a/docs/query-engine.md b/docs/query-engine.md index deff435..19e68d3 100644 --- a/docs/query-engine.md +++ b/docs/query-engine.md @@ -97,7 +97,8 @@ engine.query.Person.outgoing("WORKS_AT") Equivalent to: ```python -engine.query.Person.traverse("WORKS_AT", direction="outgoing") +from graphite import Direction +engine.query.Person.traverse("WORKS_AT", direction=Direction.OUTGOING) ``` --- diff --git a/docs/references/query.md b/docs/references/query.md index 46c6527..347f9b0 100644 --- a/docs/references/query.md +++ b/docs/references/query.md @@ -37,7 +37,7 @@ Filter nodes based on type Filter nodes with given fields -## `traverse(relation_type: Optional[str] = None, direction: str = 'outgoing') -> QueryResult` +## `traverse(relation_type: Optional[str] = None, direction: Direction = Direction.OUTGOING) -> QueryResult` Traverse relations from current nodes Returns a new query result with connected nodes and traversed relations diff --git a/examples/basic_algorithms.py b/examples/basic_algorithms.py new file mode 100644 index 0000000..82d20ec --- /dev/null +++ b/examples/basic_algorithms.py @@ -0,0 +1,42 @@ +""" +Example of usage of basic graph algorithms including BFS, etc. in Graphite with internal +`algorithms` module. +""" + +from random import randint + +from src.graphite import Direction, GraphiteEngine +from src.graphite.algorithms import bfs, neighborhood +# Use this in your code instead: +#from graphite import GraphiteEngine +#from graphite.algorithms import bfs, neighborhood + +engine = GraphiteEngine() +engine.define_node(""" +node Object +""") +for i in range(30): + engine.create_node("Object", f"node{i}") + +engine.define_relation(""" +relation Edge +Object -> Object +""") + +for i in range(100): + engine.create_relation(f"node{i % 30}", f"node{(i + randint(0, 100)) % 30}", "Edge") + +# Find ALL possible paths to node10 with less than 6 steps +print(bfs( + engine, + "node0", + "node10", + stop_at_first=False, + max_depth=5, + direction=Direction.BOTH, + allow_direction_switch=True +)) + +print(neighborhood(engine, "node0", 4)) +# Important algorithms are available directly in engine, so this has same result: +#print(engine.neighborhood("node0", 4)) diff --git a/examples/example_suite.py b/examples/example_suite.py index d22d09c..6f56d27 100644 --- a/examples/example_suite.py +++ b/examples/example_suite.py @@ -1,9 +1,10 @@ """ Example usages of Graphite in separate functions. You can run a function to see its result. """ -# noinspection PyPackageRequirements -import graphite -# import src.graphite as graphite # Just for test dev version + +from src import graphite +# Use this in your code instead: +#import graphite # =============== EXAMPLE COMPLETE DSL LOADING =============== diff --git a/examples/generic_example.py b/examples/generic_example.py index 592f159..02217e4 100644 --- a/examples/generic_example.py +++ b/examples/generic_example.py @@ -2,9 +2,10 @@ Runs a generic example for Graphite module. """ from datetime import date -# noinspection PyPackageRequirements -import graphite -# import src.graphite as graphite # Just for test dev version + +from src import graphite +# Use this in your code instead: +#import graphite engine = graphite.engine() diff --git a/src/graphite/__init__.py b/src/graphite/__init__.py index 5262ca8..e07dcc7 100644 --- a/src/graphite/__init__.py +++ b/src/graphite/__init__.py @@ -10,7 +10,7 @@ from .instances import Node, Relation from .migration import Migration from .parser import GraphiteParser -from .query import QueryBuilder, QueryResult +from .query import QueryBuilder, QueryResult, Direction from .serialization import GraphiteJSONEncoder from .types import DataType, Field, NodeType, RelationType from .utils import SecurityWarning, engine, node, relation @@ -18,9 +18,12 @@ simplefilter('always', SecurityWarning) __all__ = [ + 'GraphiteEngine', + 'Node', 'Relation', + 'Migration', + 'GraphiteParser', + 'QueryBuilder', 'QueryResult', 'Direction', + 'GraphiteJSONEncoder', 'DataType', 'Field', 'NodeType', 'RelationType', - 'Node', 'Relation', 'GraphiteJSONEncoder', - 'GraphiteParser', 'QueryResult', 'QueryBuilder', - 'GraphiteEngine', 'Migration', 'SecurityWarning', - 'node', 'relation', 'engine' + 'SecurityWarning', 'engine', 'node', 'relation', ] diff --git a/src/graphite/algorithms.py b/src/graphite/algorithms.py new file mode 100644 index 0000000..fea9da2 --- /dev/null +++ b/src/graphite/algorithms.py @@ -0,0 +1,339 @@ +""" +Implemented standard graph algorithms for Graphite graph database. +""" +from collections import deque +from typing import Callable, cast, TYPE_CHECKING + +from .instances import Node, Relation +from .query import Direction + +if TYPE_CHECKING: + from .engine import GraphiteEngine + +# pylint: disable=too-many-locals, too-many-positional-arguments, too-many-arguments +# pylint: disable=too-many-branches +def bfs( + engine: 'GraphiteEngine', + start: Node | str, + end: Node | str | Callable[[list[tuple[Relation, Node]]], bool] | None = None, + stop_at_first: bool = True, + direction: Direction = Direction.OUTGOING, + relation_type: str | None = None, + max_depth: int | None = None, + include_start:bool = False, + allow_direction_switch: bool = False, + visited: set[str] | None = None, + max_results: int | None = None +) -> list[tuple[str, int, list[tuple[Relation, Node]]]]: + """ + Highly customizable Breadth-First Search in graph + + Note: Steps are sorted by distance (it's logical order too). When using INCOMING or BOTH + directions, results contain founded paths with a pattern like this: + ``'end', depth_int, [(Relation(Type:other->start), Node(Type:other)), ..., + (Relation(Type:end->another), Node(Type:end))]`` + + :param engine: Engine to use + :param start: Starting node + :param end: End target, node, node ID, or a callable ((path) -> bool) to match on path + :param stop_at_first: If True and ``end`` is provided, stops at first match + :param direction: Direction to traverse + :param relation_type: Type of relations to traverse + :param max_depth: Maximum depth to traverse + :param include_start: If True check starting node on result + :param allow_direction_switch: If True allow direction switch in a path when ``direction = + Direction.BOTH`` + :param visited: Optional visited set to ignore + :param max_results: Maximum number of results to return + :return: An empty list or a list of found paths with each item as: (node_id, path_depth, + list(path_steps)), where ``path_steps`` is (relation, node) + """ + if direction == Direction.BOTH and not allow_direction_switch: + result_out = bfs( + engine, start, end, stop_at_first, Direction.OUTGOING, relation_type, max_depth, + include_start, False, visited, max_results + ) + result_in = bfs( + engine, start, end, stop_at_first, Direction.INCOMING, relation_type, max_depth, + include_start, False, visited, max_results + ) + result = sorted(result_in + result_out, key=lambda x: x[1]) + if stop_at_first and end is not None: + return result[:1] + return result[:max_results] if max_results is not None else result + + start: str = start.id if isinstance(start, Node) else start + end: str | Callable[[list[tuple[Relation, Node]]], bool] | None = ( + end.id if isinstance(end, Node) else end + ) + queue: deque[tuple[str, int, list[tuple[Relation, Node]], set[str]]] = deque() + queue.append((start, 0, [], {start} | (visited if visited is not None else set()))) + result: list[tuple[str, int, list[tuple[Relation, Node]]]] = [] + if callable(end): + end = cast(Callable[[list[tuple[Relation, Node]]], bool], end) + + while queue: + current, depth, path, visited_nodes = queue.popleft() + + if include_start or depth > 0: + if isinstance(end, str) and end == current: + result.append((current, depth, path)) + if stop_at_first: + break + continue # Exact match on node ID, this branch hasn't anything else to find + if callable(end) and end(path): + result.append((current, depth, path)) + if stop_at_first: + break + elif end is None: + result.append((current, depth, path)) + + if max_results is not None and len(result) >= max_results: + break + + if max_depth is not None and depth >= max_depth: + continue + + neighbors = _resolve_neighbors(engine, current, direction, relation_type) + for relation, node in neighbors: + if node.id not in visited_nodes: + new_visited_nodes = visited_nodes.copy() + new_visited_nodes.add(node.id) + queue.append((node.id, depth + 1, [*path, (relation, node)], new_visited_nodes)) + + return result[:max_results] if max_results is not None else result + +def shortest_path( # pylint: disable=too-many-positional-arguments, too-many-arguments + engine: 'GraphiteEngine', + from_node: Node | str, + to_end: Node | str | Callable[[list[tuple[Relation, Node]]], bool] | None = None, + direction: Direction = Direction.OUTGOING, + relation_type: str | None = None, + max_depth: int | None = None, + allow_direction_switch: bool = False, + ignore_nodes: set[str] | None = None, + weight: str | None = None +) -> tuple[str, int, list[tuple[Relation, Node]]] | None: + """ + Shortest path from ``from_node`` to ``to_end`` + + Non-weighted mode uses BFS. Weighted mode is not implemented yet. + + :param engine: Engine to use + :param from_node: Starting node + :param to_end: End node, node ID, or function to call (list(steps) -> bool) or None to get one + of nearest neighbors + :param direction: Direction to traverse + :param relation_type: Type of relations to traverse + :param max_depth: Maximum depth to traverse + :param allow_direction_switch: If True allow direction switch in a path when ``direction = + Direction.BOTH`` + :param ignore_nodes: Nodes to ignore + :param weight: Optional field name to weighted pathfinding (Not implemented yet) + :return: Target node ID, Distance, List of steps where each step is a (Relation, Node) pair + """ + if weight is None: + result = bfs( + engine, + from_node, + to_end, + True, + direction, + relation_type, + max_depth, + False, + allow_direction_switch, + ignore_nodes + ) + return None if len(result) == 0 else result[0] + raise NotImplementedError("Weighted shortest path is not implemented yet.") + +def all_shortest_paths( # pylint: disable=too-many-positional-arguments, too-many-arguments + engine: 'GraphiteEngine', + from_node: Node | str, + to_end: Node | str | Callable[[list[tuple[Relation, Node]]], bool] | None = None, + direction: Direction = Direction.OUTGOING, + relation_type: str | None = None, + max_depth: int | None = None, + allow_direction_switch: bool = False, + ignore_nodes: set[str] | None = None, + weight: str | None = None +) -> list[tuple[str, int, list[tuple[Relation, Node]]]]: + """ + All shortest paths from ``from_node`` to ``to_end`` + + Non-weighted mode uses BFS. Weighted mode is not implemented yet. + + :param engine: Engine to use + :param from_node: Starting node + :param to_end: End node, node ID, or function to call (list(steps) -> bool) or None to match + :param direction: Direction to traverse + :param relation_type: Relation types to traverse + :param max_depth: Maximum depth to traverse + :param allow_direction_switch: If True allow direction switch in a path when ``direction = + Direction.BOTH`` + :param ignore_nodes: Nodes to ignore + :param weight: Optional weight field name to weighted pathfinding (Not implemented yet) + :return: List of result paths like shortest_path(), where items are sorted by distance / cost + and cycles are trimmed + """ + if weight is None: + paths = bfs( + engine, + from_node, + to_end, + False, + direction, + relation_type, + max_depth, + False, + allow_direction_switch, + ignore_nodes + ) + if not paths: + return [] + min_depth = paths[0][1] + return [p for p in paths if p[1] == min_depth] + raise NotImplementedError("Weighted all shortest paths is not implemented yet.") + +def connected_components( # pylint: disable=too-many-positional-arguments, too-many-arguments + engine: 'GraphiteEngine', + nodes: Node | str | set[Node | str] | None = None, + return_all_nodes: bool = False, + direction: Direction = Direction.OUTGOING, + relation_type: str | None = None, + allow_direction_switch: bool = False, + ignore_nodes: set[str] | None = None +) -> list[set[str]]: + """ + Split given nodes to connected components + + :param engine: Engine to use + :param nodes: One or a set of nodes / node IDs to group, or None to get all nodes from engine + :param return_all_nodes: If True return all nodes in component, not just intersection with + ``nodes`` + :param direction: Direction to traverse + :param relation_type: Type of relations to traverse + :param allow_direction_switch: If True allow direction switch in a path when ``direction = + Direction.BOTH`` + :param ignore_nodes: Nodes to ignore + :return: List of component nodes + """ + if nodes is None: + _nodes = set(engine.nodes.keys()) + elif not isinstance(nodes, set): + _nodes = {nodes if isinstance(nodes, str) else nodes.id} + else: + _nodes = {node.id if isinstance(node, Node) else node for node in nodes} + + if ignore_nodes is not None: + _nodes -= ignore_nodes + + result: list[set[str]] = [] + visited = set() + for node in _nodes: + # Skip currently found nodes when max depth not provided + if node in visited: + continue + + step_result = {r[0] for r in bfs( + engine, + str(node), + direction=direction, + relation_type=relation_type, + include_start=True, + allow_direction_switch=allow_direction_switch, + visited=ignore_nodes + )} + + if not return_all_nodes: + step_result &= _nodes + + result.append(step_result) + visited |= step_result + + merged_result: list[set[str]] = [] + + for component in result: + sets_to_merge = [] + for merged in merged_result: + if merged.intersection(component): + sets_to_merge.append(merged) + new_component: set[str] = component.copy() + if sets_to_merge: + for merged in sets_to_merge: + merged_result.remove(merged) + new_component.update(merged) + merged_result.append(new_component) + + return merged_result + +def neighborhood( # pylint: disable=too-many-positional-arguments, too-many-arguments + engine: 'GraphiteEngine', + start: Node | str, + max_distance: int | None = None, + filter_method: Callable[[list[tuple[Relation, Node]]], bool] | None = None, + max_results: int | None = None, + direction: Direction = Direction.OUTGOING, + relation_type: str | None = None, + allow_direction_switch: bool = False, + ignore_nodes: set[str] | None = None +) -> tuple[set[tuple[Node, int]], set[Relation]]: + """ + Get neighbors of ``start`` in given ``max_distance`` + + :param engine: Engine to use + :param start: Starting node object or ID + :param max_distance: Maximum distance to traverse + :param filter_method: Optional callable to filter neighbors + :param max_results: Maximum number of results to return + :param direction: Direction to traverse + :param relation_type: Type of relations to traverse + :param allow_direction_switch: If True allow direction switch in a path when ``direction = + Direction.BOTH`` + :param ignore_nodes: Nodes to ignore + :return: Set of neighbors (including ``start``) with their distance to ``start``, and set of + relations in neighborhood + """ + paths = bfs( + engine, + start, + filter_method, + False, + direction, + relation_type, + max_distance, + False, + allow_direction_switch, + ignore_nodes, + max_results + ) + + result: tuple[set[tuple[Node, int]], set[Relation]] = (set(), set()) + result[0].add((start if isinstance(start, Node) else engine.get_node(start), 0)) + added_nodes: set[Node] = set() + + for _, distance, path in paths: + node = path[-1][1] + if node not in added_nodes: + result[0].add((node, distance)) + added_nodes.add(node) + for relation, _ in path: + result[1].add(relation) + + return result + +def _resolve_neighbors( + engine: 'GraphiteEngine', + current: str, + direction: Direction, + relation_type: str | None = None, +) -> list[tuple[Relation, Node]]: + result: list[tuple[Relation, Node]] = [] + if direction in (Direction.OUTGOING, Direction.BOTH): + for relation in engine.get_relations_from(current, relation_type): + result.append((relation, engine.get_node(relation.to_node))) + if direction in (Direction.INCOMING, Direction.BOTH): + for relation in engine.get_relations_to(current, relation_type): + result.append((relation, engine.get_node(relation.from_node))) + return result diff --git a/src/graphite/engine.py b/src/graphite/engine.py index 708b4a5..10ae0b4 100644 --- a/src/graphite/engine.py +++ b/src/graphite/engine.py @@ -5,22 +5,24 @@ import os import warnings from collections import defaultdict -from typing import Any, cast +from typing import Any, Callable, cast from typing_extensions import deprecated +from . import algorithms as _algorithms from .exceptions import ( FileSizeError, InvalidJSONError, InvalidPropertiesError, InvalidRelationError, - NotFoundError, SafeLoadExtensionError, TooNestedJSONError, ValidationError, + NotFoundError, SafeLoadExtensionError, TooNestedJSONError, ) from .instances import Node, Relation from .parser import GraphiteParser -from .query import QueryBuilder -from .serialization import GraphiteJSONEncoder, graphite_object_hook +from .query import Direction, QueryBuilder +from .serialization import ( + GraphiteJSONEncoder, SAVE_FILE_VERSION, _validate_loaded_data, + graphite_object_hook, +) from .types import Field, NodeType, RelationType -SAVE_FILE_VERSION = "1.0" - # pylint: disable=too-many-instance-attributes, too-many-public-methods class GraphiteEngine: """Main graph database engine""" @@ -503,15 +505,6 @@ def remove_relations(self, relations: Relation | set[Relation] | list[Relation]) def remove_node(self, node: Node | str | list[Node | str]) -> None: """ Remove given nodes and all their relations - - **Note:** When removing multiple nodes, this method is significantly faster than - calling it repeatedly because indexes are rebuilt only once. - - :param node: List of node ID strings or node objects - - :return: None - - :except NotFoundError: if any node is not found """ return self.remove_nodes(node) @@ -519,12 +512,6 @@ def remove_node(self, node: Node | str | list[Node | str]) -> None: def remove_relation(self, relation: Relation | set[Relation] | list[Relation]) -> None: """ Removes given relations - - :param relation: Set, list, or one of relation objects - - :return: None - - :except NotFoundError: if any relation is not found """ return self.remove_relations(relation) @@ -542,58 +529,7 @@ def parse(self, data: str) -> None: :except NotFoundError: using any undefined object (node type, relation type, node, relation) :except ValueError: if a used data type not fount """ - lines = data.strip().split('\n') - - i = 0 - while i < len(lines): - line = lines[i].strip() - if not line or line.startswith('#'): - i += 1 - continue - - if line.startswith('node '): - # Collect multiline node definition - node_def = [line] - i += 1 - while ( - i < len(lines) - and lines[i].strip() - and not lines[i].strip().startswith(('node ', 'relation ')) - ): - if lines[i].strip().startswith('#'): - i += 1 - continue - node_def.append(lines[i]) - i += 1 - self.define_node('\n'.join(node_def)) - - elif line.startswith('relation '): - # Collect multiline relation definition - rel_def = [line] - i += 1 - while ( - i < len(lines) - and lines[i].strip() - and not lines[i].strip().startswith(('node ', 'relation ')) - ): - if lines[i].strip().startswith('#'): - i += 1 - continue - rel_def.append(lines[i]) - i += 1 - self.define_relation('\n'.join(rel_def)) - - elif '-[' in line and (']->' in line or ']-' in line): - # Relation instance - from_id, to_id, rel_type, values, _ = self.parser.parse_relation_instance(line) - self.create_relation(from_id, to_id, rel_type, *values, parse_fields=True) - i += 1 - - else: - # Node instance - node_type, node_id, values = self.parser.parse_node_instance(line) - self.create_node(node_type, node_id, *values, parse_fields=True) - i += 1 + self.parser.parse(self, data) @deprecated("Use parse() instead") def load_dsl(self, dsl: str) -> None: @@ -666,95 +602,12 @@ def load_safe( # Validate structure if validate_schema: - self._validate_loaded_data(data) + _validate_loaded_data(data) # Load normally self._load_from_dict(data) - # pylint: disable=too-many-branches - @staticmethod - def _validate_loaded_data(data: dict[str, Any]) -> None: - """ - Validate loaded data for consistency - - :param data: Dictionary of loaded data - - :return: None - - :except ValidationError: for any fail at validation - """ - if not isinstance(data, dict): - raise ValidationError( - "Loaded data must be a dictionary", - "data", - str(type(data)) - ) - - required_keys = ('version', 'node_types', 'relation_types', 'nodes') - for key in required_keys: - if key not in data: - raise ValidationError( - f"Missing required key {key}", - key, - "'Missing'" - ) - - if data.get("version") != SAVE_FILE_VERSION: - raise ValidationError( - f"Save file version must be {SAVE_FILE_VERSION} not {data.get('version')}", - "version", - data.get("version") - ) - - if not isinstance(data.get('node_types'), list): - raise ValidationError( - "node_types must be a list", - "node_types", - str(type(data.get('node_types'))) - ) - if not isinstance(data.get('relation_types'), list): - raise ValidationError( - "relation_types must be a list", - "relation_types", - str(type(data.get('relation_types'))) - ) - if not isinstance(data.get('nodes'), list): - raise ValidationError( - "nodes must be a list", - "nodes", - str(type(data.get('nodes'))) - ) - if 'relations' in data and not isinstance(data.get('relations'), list): - raise ValidationError( - "relations must be a list", - "relations", - str(type(data.get('relations'))) - ) - - # Check for unexpected keys - allowed_keys = ('version', 'node_types', 'relation_types', 'nodes', 'relations', 'node_by_type', - 'relations_by_type', 'relations_by_from', 'relations_by_to') - for key in data.keys(): - if key not in allowed_keys: - warnings.warn(f"Unexpected key in data: {key}", UserWarning) - - # Validate nodes reference existing types - node_type_names = set() - for node_type in data.get('node_types', []): - if isinstance(node_type, NodeType): - node_type_names.add(node_type.name) - elif isinstance(node_type, dict) and 'name' in node_type: - node_type_names.add(node_type['name']) - - for check_node in data.get('nodes', []): - type_name = check_node.type_name if isinstance(check_node, Node) else check_node.get('type_name') - if type_name not in node_type_names: - raise NotFoundError( - "Node type", - type_name, - ) - - # pylint: disable=too-many-locals + # pylint: disable=too-many-locals, too-many-branches def _load_from_dict(self, data: dict[str, Any]) -> None: """ Internal method to load from dictionary (used by both load and load_safe) @@ -960,3 +813,169 @@ def stats(self) -> dict[str, Any]: 'nodes' : len(self.nodes), 'relations' : len(self.relations), } + + # ================== ALGORITHMS ================= + + def bfs( # pylint: disable=too-many-arguments, too-many-positional-arguments + self, + start: Node | str, + end: Node | str | Callable[[list[tuple[Relation, Node]]], bool] | None = None, + stop_at_first: bool = True, + direction: Direction = Direction.OUTGOING, + relation_type: str | None = None, + max_depth: int | None = None, + include_start: bool = False, + allow_direction_switch: bool = False, + visited: set[str] | None = None, + max_results: int | None = None + ) -> list[tuple[str, int, list[tuple[Relation, Node]]]]: + """ + Highly customizable Breadth-First Search in graph + + Note: Steps are sorted by distance (it's logical order too). When using INCOMING or BOTH + directions, results contain founded paths with a pattern like this: + ``'end', depth_int, [(Relation(Type:other->start), Node(Type:other)), ..., + (Relation(Type:end->another), Node(Type:end))]`` + + :param start: Starting node + :param end: End target, node, node ID, or a callable ((path) -> bool) to match on path + :param stop_at_first: If True and ``end`` is provided, stops at first match + :param direction: Direction to traverse + :param relation_type: Type of relations to traverse + :param max_depth: Maximum depth to traverse + :param include_start: If True check starting node on result + :param allow_direction_switch: If True allow direction switch in a path when ``direction = + Direction.BOTH`` + :param visited: Optional visited set to ignore + :param max_results: Maximum number of results to return + :return: An empty list or a list of found paths with each item as: (node_id, path_depth, + list(path_steps)), where ``path_steps`` is (relation, node) + """ + return _algorithms.bfs( + self, start, end, stop_at_first, direction, relation_type, max_depth, include_start, + allow_direction_switch, visited, max_results + ) + + def shortest_path( # pylint: disable=too-many-positional-arguments, too-many-arguments + self, + from_node: Node | str, + to_end: Node | str | Callable[[list[tuple[Relation, Node]]], bool] | None = None, + direction: Direction = Direction.OUTGOING, + relation_type: str | None = None, + max_depth: int | None = None, + allow_direction_switch: bool = False, + ignore_nodes: set[str] | None = None, + weight: str | None = None + ) -> tuple[str, int, list[tuple[Relation, Node]]] | None: + """ + Shortest path from ``from_node`` to ``to_end`` + + Non-weighted mode uses BFS. Weighted mode is not implemented yet. + + :param from_node: Starting node + :param to_end: End node, node ID, or function to call (list(steps) -> bool) or None to get one + of nearest neighbors + :param direction: Direction to traverse + :param relation_type: Type of relations to traverse + :param max_depth: Maximum depth to traverse + :param allow_direction_switch: If True allow direction switch in a path when ``direction = + Direction.BOTH`` + :param ignore_nodes: Nodes to ignore + :param weight: Optional field name to weighted pathfinding (Not implemented yet) + :return: Target node ID, Distance, List of steps where each step is a (Relation, Node) pair + """ + return _algorithms.shortest_path( + self, from_node, to_end, direction, relation_type, max_depth, allow_direction_switch, + ignore_nodes, weight + ) + + def all_shortest_paths( # pylint: disable=too-many-positional-arguments, too-many-arguments + self, + from_node: Node | str, + to_end: Node | str | Callable[[list[tuple[Relation, Node]]], bool] | None = None, + direction: Direction = Direction.OUTGOING, + relation_type: str | None = None, + max_depth: int | None = None, + allow_direction_switch: bool = False, + ignore_nodes: set[str] | None = None, + weight: str | None = None + ) -> list[tuple[str, int, list[tuple[Relation, Node]]]]: + """ + All shortest paths from ``from_node`` to ``to_end`` + + Non-weighted mode uses BFS. Weighted mode is not implemented yet. + + :param from_node: Starting node + :param to_end: End node, node ID, or function to call (list(steps) -> bool) or None to match + :param direction: Direction to traverse + :param relation_type: Relation types to traverse + :param max_depth: Maximum depth to traverse + :param allow_direction_switch: If True allow direction switch in a path when ``direction = + Direction.BOTH`` + :param ignore_nodes: Nodes to ignore + :param weight: Optional weight field name to weighted pathfinding (Not implemented yet) + :return: List of result paths like shortest_path(), where items are sorted by distance / cost + and cycles are trimmed + """ + return _algorithms.all_shortest_paths( + self, from_node, to_end, direction, relation_type, max_depth, allow_direction_switch, + ignore_nodes, weight + ) + + def connected_components( # pylint: disable=too-many-positional-arguments, too-many-arguments + self, + nodes: Node | str | set[Node | str] | None = None, + return_all_nodes: bool = False, + direction: Direction = Direction.OUTGOING, + relation_type: str | None = None, + allow_direction_switch: bool = False, + ignore_nodes: set[str] | None = None + ) -> list[set[str]]: + """ + Split given nodes to connected components + + :param nodes: One or a set of nodes / node IDs to group, or None to get all nodes from engine + :param return_all_nodes: If True return all nodes in component, not just intersection with + ``nodes`` + :param direction: Direction to traverse + :param relation_type: Type of relations to traverse + :param allow_direction_switch: If True allow direction switch in a path when ``direction = + Direction.BOTH`` + :param ignore_nodes: Nodes to ignore + :return: List of component nodes + """ + return _algorithms.connected_components( + self, nodes, return_all_nodes, direction, relation_type, allow_direction_switch, + ignore_nodes + ) + + def neighborhood( # pylint: disable=too-many-positional-arguments, too-many-arguments + self, + start: Node | str, + max_distance: int | None = None, + filter_method: Callable[[list[tuple[Relation, Node]]], bool] | None = None, + max_results: int | None = None, + direction: Direction = Direction.OUTGOING, + relation_type: str | None = None, + allow_direction_switch: bool = False, + ignore_nodes: set[str] | None = None + ) -> tuple[set[tuple[Node, int]], set[Relation]]: + """ + Get neighbors of ``start`` in given ``max_distance`` + + :param start: Starting node object or ID + :param max_distance: Maximum distance to traverse + :param filter_method: Optional callable to filter neighbors + :param max_results: Maximum number of results to return + :param direction: Direction to traverse + :param relation_type: Type of relations to traverse + :param allow_direction_switch: If True allow direction switch in a path when ``direction = + Direction.BOTH`` + :param ignore_nodes: Nodes to ignore + :return: Set of neighbors (including ``start``) with their distance to ``start``, and set of + relations in neighborhood + """ + return _algorithms.neighborhood( + self, start, max_distance, filter_method, max_results, direction, relation_type, + allow_direction_switch, ignore_nodes + ) diff --git a/src/graphite/parser.py b/src/graphite/parser.py index ead534a..0cafa30 100644 --- a/src/graphite/parser.py +++ b/src/graphite/parser.py @@ -4,7 +4,7 @@ import re import warnings from datetime import datetime -from typing import Any +from typing import Any, TYPE_CHECKING from .exceptions import ( DateParseError, FieldError, GraphiteError, NotFoundError, ParseError, @@ -12,6 +12,9 @@ ) from .types import DataType, DataTypePython, Field +if TYPE_CHECKING: + from .engine import GraphiteEngine + class GraphiteParser: """Parser for Graphite DSL""" @@ -383,3 +386,69 @@ def parse_relation_instance( direction = 'forward' return from_node, to_node, rel_type, attributes, direction + + def parse(self, engine: 'GraphiteEngine', data: str) -> None: + """ + Parse and load data from Graphite DSL to engine + + :param engine: engine instance + :param data: data as Graphite DSL string + + :return: None + + :except ParseError: if parsing fails + :except NotFoundError: using any undefined object (node type, relation type, node, relation) + :except ValueError: if a used data type not fount + """ + lines = data.strip().split('\n') + + i = 0 + while i < len(lines): + line = lines[i].strip() + if not line or line.startswith('#'): + i += 1 + continue + + if line.startswith('node '): + # Collect multiline node definition + node_def = [line] + i += 1 + while ( + i < len(lines) + and lines[i].strip() + and not lines[i].strip().startswith(('node ', 'relation ')) + ): + if lines[i].strip().startswith('#'): + i += 1 + continue + node_def.append(lines[i]) + i += 1 + engine.define_node('\n'.join(node_def)) + + elif line.startswith('relation '): + # Collect multiline relation definition + rel_def = [line] + i += 1 + while ( + i < len(lines) + and lines[i].strip() + and not lines[i].strip().startswith(('node ', 'relation ')) + ): + if lines[i].strip().startswith('#'): + i += 1 + continue + rel_def.append(lines[i]) + i += 1 + engine.define_relation('\n'.join(rel_def)) + + elif '-[' in line and (']->' in line or ']-' in line): + # Relation instance + from_id, to_id, rel_type, values, _ = self.parse_relation_instance(line) + engine.create_relation(from_id, to_id, rel_type, *values, parse_fields=True) + i += 1 + + else: + # Node instance + node_type, node_id, values = self.parse_node_instance(line) + engine.create_node(node_type, node_id, *values, parse_fields=True) + i += 1 diff --git a/src/graphite/query.py b/src/graphite/query.py index 9fa1393..7ad2786 100644 --- a/src/graphite/query.py +++ b/src/graphite/query.py @@ -3,6 +3,7 @@ """ from collections import defaultdict from collections.abc import Callable +from enum import Enum from functools import reduce from typing import Any, TYPE_CHECKING, cast @@ -16,6 +17,12 @@ if TYPE_CHECKING: from .engine import GraphiteEngine +class Direction(Enum): + """Traverse direction types""" + OUTGOING = "outgoing" + INCOMING = "incoming" + BOTH = "both" + # pylint: disable=too-many-public-methods class QueryResult: """ @@ -33,7 +40,6 @@ def __init__( self.nodes = nodes self.edges: set[Relation] = edges or set() self.current_relation: RelationType | None = None - self.direction: str = 'outgoing' def set_val(self, **values: Any) -> 'QueryResult': """ @@ -205,7 +211,7 @@ def with_fields(self, *fields: str) -> 'QueryResult': ) def traverse( - self, relation_type: str | None = None, direction: str = 'outgoing' + self, relation_type: str | None = None, direction: Direction = Direction.OUTGOING ) -> 'QueryResult': """ Traverse relations from current nodes @@ -229,20 +235,22 @@ def traverse( ) for processing_node in self.nodes: - if direction == 'outgoing': + if direction in (Direction.OUTGOING, Direction.OUTGOING.value): edges = self.engine.get_relations_from(processing_node.id, relation_type) - elif direction == 'incoming': + elif direction in (Direction.INCOMING, Direction.INCOMING.value): edges = self.engine.get_relations_to(processing_node.id, relation_type) - else: # both + elif direction in (Direction.BOTH, Direction.BOTH.value): edges = (self.engine.get_relations_from(processing_node.id, relation_type).union( self.engine.get_relations_to(processing_node.id, relation_type) )) + else: + raise NotImplementedError(direction) result_edges.update(edges) for edge in edges: - if direction == 'outgoing': + if direction == Direction.OUTGOING: target_id = edge.to_node - elif direction == 'incoming': + elif direction == Direction.INCOMING: target_id = edge.from_node else: target_id = ( @@ -265,7 +273,7 @@ def outgoing(self, relation_type: str | None = None) -> 'QueryResult': :return: a new query with result nodes and traversed relations """ - return self.traverse(relation_type, 'outgoing') + return self.traverse(relation_type, Direction.OUTGOING) def incoming(self, relation_type: str | None = None) -> 'QueryResult': """ @@ -275,7 +283,7 @@ def incoming(self, relation_type: str | None = None) -> 'QueryResult': :return: a new query with result nodes and traversed relations """ - return self.traverse(relation_type, 'incoming') + return self.traverse(relation_type, Direction.INCOMING) def both(self, relation_type: str | None = None) -> 'QueryResult': """ @@ -285,7 +293,7 @@ def both(self, relation_type: str | None = None) -> 'QueryResult': :return: a new query with result nodes and traversed relations """ - return self.traverse(relation_type, 'both') + return self.traverse(relation_type, Direction.BOTH) def limit( self, diff --git a/src/graphite/serialization.py b/src/graphite/serialization.py index 4a95431..0ee1b4e 100644 --- a/src/graphite/serialization.py +++ b/src/graphite/serialization.py @@ -2,15 +2,18 @@ Serialization utils for Graphite databases """ import json +import warnings from collections import defaultdict from dataclasses import asdict, is_dataclass from datetime import date, datetime from enum import Enum from typing import Any, Callable +from .exceptions import NotFoundError, ValidationError from .instances import Node, Relation from .types import DataType, Field, NodeType, RelationType +SAVE_FILE_VERSION = "1.0" GRAPHITE_TYPE_FIELD = "__graphite_type__" DEFAULT_FACTORY_FIELD = "__default_factory" @@ -193,3 +196,93 @@ def graphite_object_hook(dct: dict[str, Any]) -> Any: ) return dct + +def _validate_loaded_data(data: dict[str, Any]) -> None: + """ + Validate loaded data for consistency + + :param data: Dictionary of loaded data + + :return: None + + :except ValidationError: for any fail at validation + """ + if not isinstance(data, dict): + raise ValidationError( + "Loaded data must be a dictionary", + "data", + str(type(data)) + ) + + required_keys = ('version', 'node_types', 'relation_types', 'nodes') + for key in required_keys: + if key not in data: + raise ValidationError( + f"Missing required key {key}", + key, + "'Missing'" + ) + + if data.get("version") != SAVE_FILE_VERSION: + raise ValidationError( + f"Save file version must be {SAVE_FILE_VERSION} not {data.get('version')}", + "version", + data.get("version") + ) + + if not isinstance(data.get('node_types'), list): + raise ValidationError( + "node_types must be a list", + "node_types", + str(type(data.get('node_types'))) + ) + if not isinstance(data.get('relation_types'), list): + raise ValidationError( + "relation_types must be a list", + "relation_types", + str(type(data.get('relation_types'))) + ) + if not isinstance(data.get('nodes'), list): + raise ValidationError( + "nodes must be a list", + "nodes", + str(type(data.get('nodes'))) + ) + if 'relations' in data and not isinstance(data.get('relations'), list): + raise ValidationError( + "relations must be a list", + "relations", + str(type(data.get('relations'))) + ) + + # Check for unexpected keys + allowed_keys = ('version', 'node_types', 'relation_types', 'nodes', 'relations', 'node_by_type', + 'relations_by_type', 'relations_by_from', 'relations_by_to') + for key in data.keys(): + if key not in allowed_keys: + warnings.warn(f"Unexpected key in data: {key}", UserWarning, stacklevel=2) + + # Validate nodes reference existing types + node_type_names = set() + for node_type in data.get('node_types', []): + if isinstance(node_type, NodeType): + node_type_names.add(node_type.name) + elif isinstance(node_type, dict) and 'name' in node_type: + node_type_names.add(node_type['name']) + + for check_node in data.get('nodes', []): + if isinstance(check_node, Node): + type_name = check_node.type_name + elif isinstance(check_node, dict): + type_name = check_node.get('type_name') + else: + raise ValidationError( + "nodes must contain Node objects or dictionaries", + "nodes", + str(type(check_node)) + ) + if type_name not in node_type_names: + raise NotFoundError( + "Node type", + type_name, + ) diff --git a/tests/benchmark.py b/tests/benchmark.py index bbf7ece..577b1e9 100644 --- a/tests/benchmark.py +++ b/tests/benchmark.py @@ -2,7 +2,7 @@ Advanced benchmark suite for the Graphite embedded graph database. Usage: - python benchmark.py [--size N] [--runs M] [--output json|plain] + python benchmark.py [--size N] [--runs M] [--output json|plain] This script measures the performance of core Graphite operations under synthetic, configurable workloads. It reports timing statistics and, where @@ -109,14 +109,14 @@ def create_benchmark_engine( # Chain: Type0 -> Type1 -> Type2 ... parent = f"NodeType{i-1}" fields = [ - (f"int_field_{j}", "int") for j in range(3) - ] + [ - (f"str_field_{j}", "string") for j in range(2) - ] + [ - ("float_field", "float"), - ("date_field", "date"), - ("bool_field", "bool"), - ] + (f"int_field_{j}", "int") for j in range(3) + ] + [ + (f"str_field_{j}", "string") for j in range(2) + ] + [ + ("float_field", "float"), + ("date_field", "date"), + ("bool_field", "bool"), + ] engine.define_node( f"node NodeType{i}" + diff --git a/tests/conftest.py b/tests/conftest.py index 1a14db8..af64f08 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -26,47 +26,47 @@ def populated_engine(): # Define node types engine.define_node( """ - node Person - name: string - age: int - email: string - """ + node Person + name: string + age: int + email: string + """ ) engine.define_node( """ - node Company - name: string - founded: date - employees: int - """ + node Company + name: string + founded: date + employees: int + """ ) engine.define_node( """ - node Project - title: string - budget: float - active: bool - """ + node Project + title: string + budget: float + active: bool + """ ) # Define relation types engine.define_relation( """ - relation WORKS_AT - Person -> Company - position: string - since: date - """ + relation WORKS_AT + Person -> Company + position: string + since: date + """ ) engine.define_relation( """ - relation MANAGES - Person -> Project - role: string - """ + relation MANAGES + Person -> Project + role: string + """ ) # Create nodes @@ -101,26 +101,26 @@ def engine_with_inheritance(): engine.define_node( """ - node Entity - id: string - created: date - """ + node Entity + id: string + created: date + """ ) engine.define_node( """ - node User from Entity - username: string - password: string - active: bool - """ + node User from Entity + username: string + password: string + active: bool + """ ) engine.define_node( """ - node Admin from User - permissions: string - """ + node Admin from User + permissions: string + """ ) engine.create_node("Entity", "ent1", "base_entity", "2023-01-01") diff --git a/tests/test_integration_basic.py b/tests/test_integration_basic.py index 9ac55dc..47ebaeb 100644 --- a/tests/test_integration_basic.py +++ b/tests/test_integration_basic.py @@ -13,28 +13,28 @@ def test_complete_workflow(self, clean_engine): # 1. Define schema engine.define_node( """ - node Person - name: string - age: int - email: string - """ + node Person + name: string + age: int + email: string + """ ) engine.define_node( """ - node Company - name: string - founded: date - """ + node Company + name: string + founded: date + """ ) engine.define_relation( """ - relation WORKS_AT - Person -> Company - position: string - since: date - """ + relation WORKS_AT + Person -> Company + position: string + since: date + """ ) # 2. Create data @@ -62,9 +62,9 @@ def test_complete_workflow(self, clean_engine): # 4. Use query builder young_employees = (engine.query.Person - .where("age < 30") - .outgoing("WORKS_AT") - .get()) + .where("age < 30") + .outgoing("WORKS_AT") + .get()) assert len(young_employees) == 1 assert next(iter(young_employees)).id == "c1" @@ -102,13 +102,11 @@ def test_bidirectional_relations(self, clean_engine): engine.define_node("node Person\nname: string") - engine.define_relation( - """ - relation FRIENDS_WITH both - Person - Person - since: date - """ - ) + engine.define_relation(""" + relation FRIENDS_WITH both + Person - Person + since: date + """) engine.create_node("Person", "p1", "Alice") engine.create_node("Person", "p2", "Bob") @@ -144,13 +142,13 @@ def test_complex_query_chaining(self, populated_engine): # Find all people who work at companies founded before 2021 # and manage projects with budget over 50000 result = (engine.query.Person - .outgoing("WORKS_AT") - .where("founded < 2021-01-01") - .incoming("WORKS_AT") - .outgoing("MANAGES") - .where("budget > 50000") - .incoming("MANAGES") - .order_by("age")) + .outgoing("WORKS_AT") + .where("founded < 2021-01-01") + .incoming("WORKS_AT") + .outgoing("MANAGES") + .where("budget > 50000") + .incoming("MANAGES") + .order_by("age")) assert len(result) >= 1 assert result[0]["name"] == "Alice" @@ -168,14 +166,14 @@ def test_edge_cases(self, clean_engine): # Node with all data types engine.define_node( - """ - node AllTypes - str_field: string - int_field: int - float_field: float - date_field: date - bool_field: bool - """ + """ + node AllTypes + str_field: string + int_field: int + float_field: float + date_field: date + bool_field: bool + """ ) engine.create_node( diff --git a/tests/test_integration_dsl.py b/tests/test_integration_dsl.py index 893af4d..bf8a5e0 100644 --- a/tests/test_integration_dsl.py +++ b/tests/test_integration_dsl.py @@ -11,58 +11,58 @@ def test_load_complete_dsl(self, clean_engine): engine = clean_engine dsl = """ - # Node type definitions - node Person - name: string - age: int - email: string - - node Company - name: string - founded: date - employees: int - - node Project - title: string - budget: float - active: bool - - # Relation type definitions - relation WORKS_AT - Person -> Company - position: string - since: date - - relation MANAGES - Person -> Project - role: string - - relation FRIENDS_WITH both - Person - Person - since: date - - # Data instances - Person, person1, "Alice", 30, "alice@email.com" - Person, person2, "Bob", 25, "bob@email.com" - Person, person3, "Charlie", 35, "charlie@email.com" - - Company, company1, "TechCorp", "2020-01-01", 500 - Company, company2, "StartupInc", "2022-03-15", 50 - - Project, project1, "Alpha", 100000.50, true - Project, project2, "Beta", 75000.00, false - - # Relation instances - person1 -[WORKS_AT, "Engineer", "2021-03-15"]-> company1 - person2 -[WORKS_AT, "Manager", "2020-06-01"]-> company1 - person3 -[WORKS_AT, "CEO", "2022-01-01"]-> company2 - - person1 -[MANAGES, "Lead"]-> project1 - person2 -[MANAGES, "Coordinator"]-> project2 - - person2 -[FRIENDS_WITH, "2020-08-10"]- person3 - person1 -[FRIENDS_WITH, "2019-05-20"]- person2 - """ + # Node type definitions + node Person + name: string + age: int + email: string + + node Company + name: string + founded: date + employees: int + + node Project + title: string + budget: float + active: bool + + # Relation type definitions + relation WORKS_AT + Person -> Company + position: string + since: date + + relation MANAGES + Person -> Project + role: string + + relation FRIENDS_WITH both + Person - Person + since: date + + # Data instances + Person, person1, "Alice", 30, "alice@email.com" + Person, person2, "Bob", 25, "bob@email.com" + Person, person3, "Charlie", 35, "charlie@email.com" + + Company, company1, "TechCorp", "2020-01-01", 500 + Company, company2, "StartupInc", "2022-03-15", 50 + + Project, project1, "Alpha", 100000.50, true + Project, project2, "Beta", 75000.00, false + + # Relation instances + person1 -[WORKS_AT, "Engineer", "2021-03-15"]-> company1 + person2 -[WORKS_AT, "Manager", "2020-06-01"]-> company1 + person3 -[WORKS_AT, "CEO", "2022-01-01"]-> company2 + + person1 -[MANAGES, "Lead"]-> project1 + person2 -[MANAGES, "Coordinator"]-> project2 + + person2 -[FRIENDS_WITH, "2020-08-10"]- person3 + person1 -[FRIENDS_WITH, "2019-05-20"]- person2 + """ engine.parse(dsl) @@ -104,21 +104,21 @@ def test_load_dsl_with_inheritance(self, clean_engine): engine = clean_engine dsl = """ - node Entity - id: string - created: date + node Entity + id: string + created: date - node User from Entity - username: string - password: string + node User from Entity + username: string + password: string - node Admin from User - permissions: string + node Admin from User + permissions: string - Entity, ent1, "entity_1", "2023-01-01" - User, user1, "user_1", "2023-02-01", "john", "pass123" - Admin, admin1, "admin_1", "2023-03-01", "admin", "admin123", "all" - """ + Entity, ent1, "entity_1", "2023-01-01" + User, user1, "user_1", "2023-02-01", "john", "pass123" + Admin, admin1, "admin_1", "2023-03-01", "admin", "admin123", "all" + """ engine.parse(dsl) @@ -143,26 +143,26 @@ def test_load_dsl_with_comments_and_whitespace(self, clean_engine): engine = clean_engine dsl = """ - # This is a comment + # This is a comment - node Person - # person's name - name: string - # person's age - age: int + node Person + # person's name + name: string + # person's age + age: int - # Another comment - node Company - name: string - founded: date + # Another comment + node Company + name: string + founded: date - Person, p1, "Alice", 30 - Person, p2, "Bob", 25 + Person, p1, "Alice", 30 + Person, p2, "Bob", 25 - Company, c1, "TechCorp", "2020-01-01" - """ + Company, c1, "TechCorp", "2020-01-01" + """ engine.parse(dsl) @@ -175,29 +175,29 @@ def test_load_dsl_multiline_definitions(self, clean_engine): engine = clean_engine dsl = """node Person - name: string - age: int - email: string - - node Company - name: string - founded: date - employees: int - industry: string - location: string - - relation WORKS_AT - Person -> Company - position: string - department: string - since: date - salary: float - - Person, p1, "Alice", 30, "alice@email.com" - Company, c1, "TechCorp", "2020-01-01", 500, "Technology", "SF" - - p1 -[WORKS_AT, "Engineer", "Engineering", "2021-03-15", 85000.50]-> c1 - """ + name: string + age: int + email: string + + node Company + name: string + founded: date + employees: int + industry: string + location: string + + relation WORKS_AT + Person -> Company + position: string + department: string + since: date + salary: float + + Person, p1, "Alice", 30, "alice@email.com" + Company, c1, "TechCorp", "2020-01-01", 500, "Technology", "SF" + + p1 -[WORKS_AT, "Engineer", "Engineering", "2021-03-15", 85000.50]-> c1 + """ engine.parse(dsl) @@ -222,13 +222,13 @@ def test_parse_method_sugar(self, clean_engine): engine = clean_engine dsl = """ - node Person - name: string - age: int + node Person + name: string + age: int - Person, p1, "Alice", 30 - Person, p2, "Bob", 25 - """ + Person, p1, "Alice", 30 + Person, p2, "Bob", 25 + """ engine.parse(dsl) # Should be same as load_dsl diff --git a/tests/test_integration_persistence.py b/tests/test_integration_persistence.py index a0c9a07..1213e5b 100644 --- a/tests/test_integration_persistence.py +++ b/tests/test_integration_persistence.py @@ -139,29 +139,29 @@ def test_round_trip_complex_data(self, clean_engine, temp_json_file): # Create complex schema engine.define_node( """ - node User - name: string - metadata: string - scores: string - """ + node User + name: string + metadata: string + scores: string + """ ) engine.define_node( """ - node Group - name: string - settings: string - """ + node Group + name: string + settings: string + """ ) engine.define_relation( """ - relation MEMBER_OF - User -> Group - role: string - joined: date - permissions: string - """ + relation MEMBER_OF + User -> Group + role: string + joined: date + permissions: string + """ ) # Create data with special characters diff --git a/tests/test_unit_algorithms.py b/tests/test_unit_algorithms.py new file mode 100644 index 0000000..c391967 --- /dev/null +++ b/tests/test_unit_algorithms.py @@ -0,0 +1,945 @@ +""" +Unit tests for graph algorithms in algorithms.py +""" +from datetime import date + +import pytest + +from src.graphite import Direction +from src.graphite.exceptions import NotFoundError + +class TestBFS: + """Test BFS algorithm implementation""" + + def test_bfs_simple_path(self, populated_engine): + """Test BFS finding a simple path""" + engine = populated_engine + + # BFS from person1 to company1 + result = engine.bfs( + start="person1", + end="company1", + direction=Direction.OUTGOING, + relation_type="WORKS_AT" + ) + + assert len(result) == 1 + node_id, depth, path = result[0] + assert node_id == "company1" + assert depth == 1 + assert len(path) == 1 + assert path[0][0].type_name == "WORKS_AT" + assert path[0][1].id == "company1" + + def test_bfs_multiple_paths(self, clean_engine): + """Test BFS finding multiple paths""" + engine = clean_engine + + engine.define_node("node Person\nname: string") + engine.define_relation( + """ + relation KNOWS + Person -> Person + """ + ) + + engine.create_node("Person", "p1", "Alice") + engine.create_node("Person", "p2", "Bob") + engine.create_node("Person", "p3", "Charlie") + engine.create_node("Person", "p4", "David") + + engine.create_relation("p1", "p3", "KNOWS") + engine.create_relation("p1", "p2", "KNOWS") + engine.create_relation("p2", "p3", "KNOWS") + engine.create_relation("p2", "p4", "KNOWS") + engine.create_relation("p4", "p3", "KNOWS") + + # BFS from p1 to p3 + result = engine.bfs( + start="p1", + end="p3", + direction=Direction.OUTGOING, + stop_at_first=False + ) + + # Should find 3 paths: p1 -> p3, p1 -> p2 -> p3, p1 -> p2 -> p4 -> p3 + assert len(result) == 3 + + def test_bfs_no_path(self, populated_engine): + """Test BFS when no path exists""" + engine = populated_engine + + # Create isolated node + engine.define_node("node Isolated\nname: string") + engine.create_node("Isolated", "iso1", "Isolated") + + result = engine.bfs( + start="person1", + end="iso1", + direction=Direction.OUTGOING, + max_depth=5 + ) + + assert len(result) == 0 + + def test_bfs_max_depth(self, clean_engine): + """Test BFS with max depth limit""" + engine = clean_engine + + engine.define_node("node Person\nname: string") + engine.define_relation( + """ + relation KNOWS + Person -> Person + """ + ) + + engine.create_node("Person", "p1", "Alice") + engine.create_node("Person", "p2", "Bob") + engine.create_node("Person", "p3", "Charlie") + + engine.create_relation("p1", "p2", "KNOWS") + engine.create_relation("p2", "p3", "KNOWS") + + result = engine.bfs( + start="p1", + end="p3", + max_depth=1, + ) + + assert len(result) == 0 + + result = engine.bfs( + start="p1", + end="p3", + max_depth=2, + ) + + assert len(result) == 1 + + def test_bfs_include_start(self, populated_engine): + """Test BFS with include_start=True""" + engine = populated_engine + + result = engine.bfs( + start="person1", + end=None, + direction=Direction.OUTGOING, + include_start=True, + max_depth=0 + ) + + assert len(result) == 1 + assert result[0][0] == "person1" + assert result[0][1] == 0 + assert result[0][2] == [] + + def test_bfs_with_callable_end(self, populated_engine): + """Test BFS with callable end condition""" + engine = populated_engine + + # Find any node with name "TechCorp" + def is_techcorp(path): + return path and path[-1][1].get("name") == "TechCorp" + + result = engine.bfs( + start="person1", + end=is_techcorp, + direction=Direction.OUTGOING, + stop_at_first=True + ) + + assert len(result) == 1 + assert result[0][0] == "company1" + assert result[0][1] == 1 + + def test_bfs_incoming_direction(self, populated_engine): + """Test BFS with incoming direction""" + engine = populated_engine + + # Find people who work at company1 (incoming to company1) + result = engine.bfs( + start="company1", + end=None, + direction=Direction.INCOMING, + relation_type="WORKS_AT", + max_depth=1 + ) + + assert len(result) == 2 + # Both person1 and person2 work at company1 + node_ids = {r[0] for r in result} + assert node_ids == {"person1", "person2"} + + def test_bfs_bidirectional_direction(self, clean_engine): + """Test BFS with both directions""" + engine = clean_engine + + engine.define_node("node Person\nname: string") + engine.define_relation( + """ + relation KNOWS + Person -> Person + """ + ) + + engine.create_node("Person", "p1", "Alice") + engine.create_node("Person", "p2", "Bob") + engine.create_node("Person", "p3", "Charlie") + + engine.create_relation("p1", "p2", "KNOWS") + engine.create_relation("p3", "p1", "KNOWS") + + # BFS from p1 in both directions should find both p2 and p3 + result = engine.bfs( + start="p1", + end=None, + direction=Direction.BOTH, + max_depth=1 + ) + + node_ids = {r[0] for r in result} + assert node_ids == {"p2", "p3"} + + def test_bfs_direction_switch(self, clean_engine): + """Test BFS with direction switch""" + engine = clean_engine + + engine.define_node("node Person\nname: string") + engine.define_relation( + """ + relation KNOWS + Person -> Person + """ + ) + + engine.create_node("Person", "p1", "Alice") + engine.create_node("Person", "p2", "Bob") + engine.create_node("Person", "p3", "Charlie") + + engine.create_relation("p1", "p2", "KNOWS") + engine.create_relation("p3", "p2", "KNOWS") + + # BFS with direction switch should find: + # p1 -------> p3 + # p1 -> p2 <- p3 + result = engine.bfs( + start="p1", + end="p3", + direction=Direction.BOTH, + allow_direction_switch=True + ) + + assert len(result) == 1 + assert result[0][0] == "p3" + + def test_bfs_visited_set(self, populated_engine): + """Test BFS with visited set""" + engine = populated_engine + + # Ignore company1 + result = engine.bfs( + start="person1", + end=None, + direction=Direction.OUTGOING, + relation_type="WORKS_AT", + visited={"company1"}, + max_depth=1 + ) + + # person1 should not find company1 as it's in visited + assert len(result) == 0 + + def test_bfs_relation_type_filter(self, populated_engine): + """Test BFS with relation type filter""" + engine = populated_engine + + # Only traverse WORKS_AT relations + result = engine.bfs( + start="person1", + end=None, + direction=Direction.OUTGOING, + relation_type="WORKS_AT", + max_depth=1 + ) + + assert len(result) == 1 + assert result[0][0] == "company1" + + # Only traverse MANAGES relations + result = engine.bfs( + start="person1", + end=None, + direction=Direction.OUTGOING, + relation_type="MANAGES", + max_depth=1 + ) + + assert len(result) == 1 + assert result[0][0] == "project1" + + def test_bfs_max_results(self, populated_engine): + """Test BFS with max_results limit""" + engine = populated_engine + + # Add more people to get more results + engine.create_node("Person", "person3", "Charlie", 40, "charlie@email.com") + engine.create_relation("person3", "company1", "WORKS_AT", "Intern", date(2022, 1, 1)) + + result = engine.bfs( + start="company1", + end=None, + direction=Direction.INCOMING, + relation_type="WORKS_AT", + max_depth=1, + max_results=1 + ) + + assert len(result) == 1 + + def test_bfs_stop_at_first_no_direction_switch(self, clean_engine): + """Test BFS with stop at first in Direction.BOTH without direction switch""" + engine = clean_engine + + engine.define_node("node Person\nname: string") + engine.define_relation(""" + relation KNOWS + Person -> Person + """) + + engine.create_node("Person", "p1", "Alice") + engine.create_node("Person", "p2", "Bob") + + engine.create_relation("p1", "p2", "KNOWS") + # Second relation to add in reverse direction to produce two results in BFS + engine.create_relation("p2", "p1", "KNOWS") + + result = engine.bfs( + start="p1", + end="p2", + direction=Direction.BOTH, + stop_at_first=True, + allow_direction_switch=False + ) + + assert len(result) == 1 + + +class TestShortestPath: + """Test shortest_path algorithm""" + + def test_shortest_path_direct(self, populated_engine): + """Test shortest path between directly connected nodes""" + engine = populated_engine + + result = engine.shortest_path( + from_node="person1", + to_end="company1", + direction=Direction.OUTGOING, + relation_type="WORKS_AT" + ) + + assert result is not None + node_id, depth, path = result + assert node_id == "company1" + assert depth == 1 + assert len(path) == 1 + + def test_shortest_path_indirect(self, clean_engine): + """Test shortest path between indirectly connected nodes""" + engine = clean_engine + + engine.define_node("node Person\nname: string") + engine.define_node("node Company\nname: string") + engine.define_relation( + """ + relation WORKS_AT + Person -> Company + """ + ) + engine.define_relation( + """ + relation MANAGED_BY + Person -> Person + """ + ) + + engine.create_node("Person", "p1", "Alice") + engine.create_node("Person", "p2", "Bob") + engine.create_node("Person", "p3", "Charlie") + engine.create_node("Company", "c1", "TechCorp") + + engine.create_relation("p1", "p2", "MANAGED_BY") + engine.create_relation("p2", "c1", "WORKS_AT") + + result = engine.shortest_path( + from_node="p1", + to_end="c1", + direction=Direction.OUTGOING + ) + + assert result is not None + node_id, depth, path = result + assert node_id == "c1" + assert depth == 2 + assert len(path) == 2 + assert path[0][0].type_name == "MANAGED_BY" + assert path[1][0].type_name == "WORKS_AT" + + def test_shortest_path_no_path(self, populated_engine): + """Test shortest path when no path exists""" + engine = populated_engine + + # Create isolated node + engine.define_node("node Isolated\nname: string") + engine.create_node("Isolated", "iso1", "Isolated") + + result = engine.shortest_path( + from_node="person1", + to_end="iso1", + direction=Direction.OUTGOING, + max_depth=5 + ) + + assert result is None + + def test_shortest_path_with_callable_end(self, populated_engine): + """Test shortest path with callable end condition""" + engine = populated_engine + + def is_techcorp(_path): + return _path and _path[-1][1].get("name") == "TechCorp" + + result = engine.shortest_path( + from_node="person1", + to_end=is_techcorp, + direction=Direction.OUTGOING + ) + + assert result is not None + node_id, depth, _ = result + assert node_id == "company1" + assert depth == 1 + + def test_shortest_path_weighted_not_implemented(self, populated_engine): + """Test weighted the shortest path raises NotImplementedError""" + engine = populated_engine + + with pytest.raises(NotImplementedError): + engine.shortest_path( + from_node="person1", + to_end="company1", + weight="some_weight" + ) + + +class TestAllShortestPaths: + """Test all_shortest_paths algorithm""" + + def test_all_shortest_paths_single(self, populated_engine): + """Test all shortest paths when there is only one""" + engine = populated_engine + + result = engine.all_shortest_paths( + from_node="person1", + to_end="company1", + direction=Direction.OUTGOING, + relation_type="WORKS_AT" + ) + + assert len(result) == 1 + assert result[0][0] == "company1" + assert result[0][1] == 1 + + def test_all_shortest_paths_multiple(self, clean_engine): + """Test all shortest paths when there are multiple equal-length paths""" + engine = clean_engine + + engine.define_node("node Person\nname: string") + engine.define_node("node Company\nname: string") + engine.define_relation( + """ + relation WORKS_AT + Person -> Company + """ + ) + engine.define_relation( + """ + relation CONNECTS + Person -> Person + """ + ) + + engine.create_node("Person", "p1", "Alice") + engine.create_node("Person", "p2", "Bob") + engine.create_node("Person", "p3", "Charlie") + engine.create_node("Company", "c1", "TechCorp") + + # Two paths from p1 to c1: p1->p2->c1 and p1->p3->c1 + engine.create_relation("p1", "p2", "CONNECTS") + engine.create_relation("p2", "c1", "WORKS_AT") + engine.create_relation("p1", "p3", "CONNECTS") + engine.create_relation("p3", "c1", "WORKS_AT") + + result = engine.all_shortest_paths( + from_node="p1", + to_end="c1", + direction=Direction.OUTGOING + ) + + assert len(result) == 2 + assert result[0][1] == 2 + assert result[1][1] == 2 + + def test_all_shortest_paths_no_path(self, populated_engine): + """Test all shortest paths when no path exists""" + engine = populated_engine + + engine.define_node("node Isolated\nname: string") + engine.create_node("Isolated", "iso1", "Isolated") + + result = engine.all_shortest_paths( + from_node="person1", + to_end="iso1", + direction=Direction.OUTGOING, + max_depth=5 + ) + + assert len(result) == 0 + + def test_all_shortest_paths_weighted_not_implemented(self, populated_engine): + """Test weighted all_shortest_paths raises NotImplementedError""" + engine = populated_engine + + with pytest.raises(NotImplementedError): + engine.all_shortest_paths( + from_node="person1", + to_end="company1", + weight="some_weight" + ) + + +class TestConnectedComponents: + """Test connected_components algorithm""" + + def test_connected_components_all_nodes(self, populated_engine): + """Test connected components on all nodes""" + engine = populated_engine + + # All nodes should be in one component (person1, person2, company1, project1) + components = engine.connected_components() + + # Should have at least one component + assert len(components) >= 1 + # The main component should have at least 4 nodes + main_component = max(components, key=len) + assert len(main_component) >= 4 + + def test_connected_components_specific_nodes(self, clean_engine): + """Test connected components on specific nodes""" + engine = clean_engine + + engine.define_node("node Person\nname: string") + engine.define_relation( + """ + relation KNOWS + Person -> Person + """ + ) + + engine.create_node("Person", "p1", "Alice") + engine.create_node("Person", "p2", "Bob") + engine.create_node("Person", "p3", "Charlie") + engine.create_node("Person", "p4", "David") + + # p1 connected to p2, p3 connected to p4 (separate components) + engine.create_relation("p1", "p2", "KNOWS") + engine.create_relation("p3", "p4", "KNOWS") + + components = engine.connected_components( + nodes={"p1", "p3"}, + return_all_nodes=True + ) + + assert len(components) == 2 + # Find the component containing p1 + p1_component = next(c for c in components if "p1" in c) + assert p1_component == {"p1", "p2"} + # Find the component containing p3 + p3_component = next(c for c in components if "p3" in c) + assert p3_component == {"p3", "p4"} + + def test_connected_components_single_node(self, clean_engine): + """Test connected components on single node""" + engine = clean_engine + + engine.define_node("node Person\nname: string") + engine.define_relation( + """ + relation KNOWS + Person -> Person + """ + ) + + engine.create_node("Person", "p1", "Alice") + engine.create_node("Person", "p2", "Bob") + engine.create_node("Person", "p3", "Charlie") + engine.create_node("Person", "p4", "David") + + # p1 connected to p2, p3 connected to p4 (separate components) + engine.create_relation("p1", "p2", "KNOWS") + engine.create_relation("p3", "p4", "KNOWS") + + components = engine.connected_components( + nodes="p1", + return_all_nodes=True + ) + + assert len(components) == 1 + # Find the component containing p1 + assert components[0] == {"p1", "p2"} + + def test_connected_components_invalid_node(self, clean_engine): + """Test connected components on an invalid node""" + engine = clean_engine + + engine.define_node("node Person\nname: string") + engine.define_relation( + """ + relation KNOWS + Person -> Person + """ + ) + + engine.create_node("Person", "p1", "Alice") + engine.create_node("Person", "p2", "Bob") + engine.create_node("Person", "p3", "Charlie") + engine.create_node("Person", "p4", "David") + + # p1 connected to p2, p3 connected to p4 (separate components) + engine.create_relation("p1", "p2", "KNOWS") + engine.create_relation("p3", "p4", "KNOWS") + + with pytest.raises(NotFoundError): + engine.connected_components( + nodes={"p5"}, + return_all_nodes=True + ) + + def test_connected_components_isolated_nodes(self, populated_engine): + """Test connected components with isolated nodes""" + engine = populated_engine + + # Add isolated nodes + engine.define_node("node Isolated\nname: string") + engine.create_node("Isolated", "iso1", "Isolated") + engine.create_node("Isolated", "iso2", "Isolated") + + components = engine.connected_components( + nodes={"person1", "iso1", "iso2"}, + return_all_nodes=True + ) + + # Should have 3 components (or more, depending on existing connections) + # The isolated nodes should each be in their own component + iso_components = [c for c in components if len(c) == 1 and "iso" in next(iter(c))] + assert len(iso_components) == 2 + + def test_connected_components_ignore_nodes(self, populated_engine): + """Test connected components with ignore_nodes""" + engine = populated_engine + + components = engine.connected_components( + nodes=None, + return_all_nodes=True, + ignore_nodes={"person1"} + ) + + # person1 should not appear in any component + for component in components: + assert "person1" not in component + + def test_connected_components_return_all_nodes_false(self, clean_engine): + """Test connected components with return_all_nodes=False""" + engine = clean_engine + + engine.define_node("node Person\nname: string") + engine.define_relation( + """ + relation KNOWS + Person -> Person + """ + ) + + engine.create_node("Person", "p1", "Alice") + engine.create_node("Person", "p2", "Bob") + engine.create_node("Person", "p3", "Charlie") + + engine.create_relation("p1", "p2", "KNOWS") + # p3 is isolated + + components = engine.connected_components( + nodes={"p1", "p3"}, + return_all_nodes=False + ) + + # Should only return the specified nodes, not the connected ones + assert len(components) == 2 + # p1's component should only include p1 (not p2) + p1_component = next(c for c in components if "p1" in c) + assert p1_component == {"p1"} + p3_component = next(c for c in components if "p3" in c) + assert p3_component == {"p3"} + + +class TestNeighborhood: + """Test neighborhood algorithm""" + + def test_neighborhood_depth_1(self, populated_engine): + """Test neighborhood with depth 1""" + engine = populated_engine + + nodes, _ = engine.neighborhood( + start="person1", + max_distance=1, + direction=Direction.OUTGOING + ) + + # Should include person1 and all direct neighbors + node_ids = {n.id for n, _ in nodes} + assert "person1" in node_ids + assert "company1" in node_ids + assert "project1" in node_ids + + def test_neighborhood_depth_2(self, clean_engine): + """Test neighborhood with depth 2""" + engine = clean_engine + + engine.define_node("node Person\nname: string") + engine.define_node("node Company\nname: string") + engine.define_relation( + """ + relation WORKS_AT + Person -> Company + """ + ) + engine.define_relation( + """ + relation MANAGES + Person -> Person + """ + ) + + engine.create_node("Person", "p1", "Alice") + engine.create_node("Person", "p2", "Bob") + engine.create_node("Company", "c1", "TechCorp") + + engine.create_relation("p1", "p2", "MANAGES") + engine.create_relation("p2", "c1", "WORKS_AT") + + nodes, _ = engine.neighborhood( + start="p1", + max_distance=2, + direction=Direction.OUTGOING + ) + + node_ids = {n.id for n, _ in nodes} + assert "p1" in node_ids + assert "p2" in node_ids + assert "c1" in node_ids + + # Check distances + p1_distance = next(d for n, d in nodes if n.id == "p1") + assert p1_distance == 0 + p2_distance = next(d for n, d in nodes if n.id == "p2") + assert p2_distance == 1 + c1_distance = next(d for n, d in nodes if n.id == "c1") + assert c1_distance == 2 + + def test_neighborhood_with_filter(self, populated_engine): + """Test neighborhood with filter method""" + engine = populated_engine + + # Only include paths that end with company nodes + def filter_company(path): + return path and path[-1][1].type_name == "Company" + + nodes, _ = engine.neighborhood( + start="person1", + max_distance=2, + filter_method=filter_company, + direction=Direction.OUTGOING + ) + + node_ids = {n.id for n, _ in nodes} + # Should include person1 and company1, but not project1 + assert "person1" in node_ids + assert "company1" in node_ids + assert "project1" not in node_ids + + def test_neighborhood_max_results(self, populated_engine): + """Test neighborhood with max_results""" + engine = populated_engine + + # Create more nodes to exceed max_results + engine.create_node("Person", "person3", "Charlie", 40, "charlie@email.com") + engine.create_relation("person3", "company1", "WORKS_AT", "Intern", date(2022, 1, 1)) + + nodes, _ = engine.neighborhood( + start="company1", + max_distance=1, + direction=Direction.INCOMING, + max_results=1 + ) + + # Only 1 result (plus start node) + assert len(nodes) <= 2 # start + 1 result + + def test_neighborhood_incoming_direction(self, populated_engine): + """Test neighborhood with incoming direction""" + engine = populated_engine + + nodes, _ = engine.neighborhood( + start="company1", + max_distance=1, + direction=Direction.INCOMING, + relation_type="WORKS_AT" + ) + + node_ids = {n.id for n, _ in nodes} + assert "company1" in node_ids + assert "person1" in node_ids + assert "person2" in node_ids + + def test_neighborhood_ignore_nodes(self, populated_engine): + """Test neighborhood with ignore_nodes""" + engine = populated_engine + + nodes, _ = engine.neighborhood( + start="person1", + max_distance=1, + direction=Direction.OUTGOING, + ignore_nodes={"company1"} + ) + + node_ids = {n.id for n, _ in nodes} + # company1 should be ignored + assert "company1" not in node_ids + + def test_neighborhood_duplicates(self, clean_engine): + """Test neighborhood with duplicates""" + engine = clean_engine + + engine.define_node("node Person\nname: string") + engine.define_relation( + """ + relation KNOWS + Person -> Person + """ + ) + + engine.create_node("Person", "p1", "Alice") + engine.create_node("Person", "p2", "Bob") + engine.create_node("Person", "p3", "Charlie") + engine.create_node("Person", "p4", "David") + + engine.create_relation("p1", "p2", "KNOWS") + engine.create_relation("p2", "p1", "KNOWS") + engine.create_relation("p3", "p1", "KNOWS") + engine.create_relation("p2", "p3", "KNOWS") + + # Should find: + # p1 in 1 path + # p2 in 2 path + # p3 in 2 path + nodes, relations = engine.neighborhood( + start="p1", + direction=Direction.BOTH # Catch reverses + ) + + assert len(nodes) == 3 # p1, p2, p3 + assert len(relations) == 4 # All relations + + +class TestAlgorithmEdgeCases: + """Test edge cases for algorithms""" + + def test_bfs_with_nonexistent_start(self, populated_engine): + """Test BFS with non-existent start node""" + engine = populated_engine + + with pytest.raises(NotFoundError): + engine.bfs( + start="nonexistent", + end=None + ) + + def test_bfs_with_nonexistent_end(self, populated_engine): + """Test BFS with non-existent end node""" + engine = populated_engine + + result = engine.bfs( + start="person1", + end="nonexistent" + ) + + assert len(result) == 0 + + def test_bfs_cycle_detection(self, clean_engine): + """Test BFS handles cycles correctly""" + engine = clean_engine + + engine.define_node("node Person\nname: string") + engine.define_relation( + """ + relation KNOWS + Person -> Person + """ + ) + + engine.create_node("Person", "p1", "Alice") + engine.create_node("Person", "p2", "Bob") + engine.create_node("Person", "p3", "Charlie") + + # Create a cycle: p1 -> p2 -> p3 -> p1 + engine.create_relation("p1", "p2", "KNOWS") + engine.create_relation("p2", "p3", "KNOWS") + engine.create_relation("p3", "p1", "KNOWS") + + # BFS should not get stuck in infinite loop + result = engine.bfs( + start="p1", + end=None, + direction=Direction.OUTGOING, + max_depth=5 + ) + + # Should find all nodes + node_ids = {r[0] for r in result} + assert node_ids == {"p2", "p3"} + + def test_neighborhood_empty_graph(self, clean_engine): + """Test neighborhood on empty graph""" + engine = clean_engine + + engine.define_node("node Person\nname: string") + engine.create_node("Person", "p1", "Alice") + + nodes, relations = engine.neighborhood( + start="p1", + max_distance=1 + ) + + assert len(nodes) == 1 + assert nodes == {(engine.get_node("p1"), 0)} + assert len(relations) == 0 + + def test_connected_components_empty(self, clean_engine): + """Test connected components on empty graph""" + engine = clean_engine + + components = engine.connected_components( + nodes=None + ) + + assert len(components) == 0 diff --git a/tests/test_unit_engine.py b/tests/test_unit_engine.py index 5e2f74e..9bc510f 100644 --- a/tests/test_unit_engine.py +++ b/tests/test_unit_engine.py @@ -16,10 +16,10 @@ def test_define_node_simple(self, clean_engine): engine.define_node( """ - node Person - name: string - age: int - """ + node Person + name: string + age: int + """ ) assert "Person" in engine.node_types @@ -36,19 +36,19 @@ def test_define_node_with_inheritance(self, clean_engine): # Define parent first engine.define_node( """ - node Entity - id: string - created: date - """ + node Entity + id: string + created: date + """ ) # Define child engine.define_node( """ - node User from Entity - username: string - password: string - """ + node User from Entity + username: string + password: string + """ ) assert "User" in engine.node_types @@ -71,9 +71,9 @@ def test_define_node_inheritance_parent_not_found(self, clean_engine): with pytest.raises(NotFoundError) as exc_info: engine.define_node( """ - node User from NonExistent - username: string - """ + node User from NonExistent + username: string + """ ) assert "Parent node type" in str(exc_info.value) @@ -89,11 +89,11 @@ def test_define_relation_simple(self, clean_engine): engine.define_relation( """ - relation WORKS_AT - Person -> Company - position: string - since: date - """ + relation WORKS_AT + Person -> Company + position: string + since: date + """ ) assert "WORKS_AT" in engine.relation_types @@ -114,9 +114,9 @@ def test_define_relation_with_reverse(self, clean_engine): engine.define_relation( """ - relation WORKS_AT reverse EMPLOYS - Person -> Company - """ + relation WORKS_AT reverse EMPLOYS + Person -> Company + """ ) assert "WORKS_AT" in engine.relation_types @@ -140,10 +140,10 @@ def test_define_relation_bidirectional(self, clean_engine): engine.define_relation( """ - relation FRIENDS_WITH both - Person - Person - since: date - """ + relation FRIENDS_WITH both + Person - Person + since: date + """ ) rel_type = engine.relation_types["FRIENDS_WITH"] @@ -156,9 +156,9 @@ def test_define_relation_node_type_not_found(self, clean_engine): with pytest.raises(NotFoundError) as exc_info: engine.define_relation( """ - relation WORKS_AT - Person -> Company - """ + relation WORKS_AT + Person -> Company + """ ) assert "Node type" in str(exc_info.value) @@ -172,11 +172,11 @@ def test_create_node_simple(self, clean_engine): engine.define_node( """ - node Person - name: string - age: int - email: string - """ + node Person + name: string + age: int + email: string + """ ) node = engine.create_node("Person", "person1", "Alice", 30, "alice@email.com") @@ -197,10 +197,10 @@ def test_create_node_with_date(self, clean_engine): engine.define_node( """ - node Event - title: string - event_date: date - """ + node Event + title: string + event_date: date + """ ) node = engine.create_node("Event", "event1", "Conference", "2023-12-01") @@ -216,10 +216,10 @@ def test_create_node_invalid_date(self, clean_engine): engine.define_node( """ - node Event - title: string - event_date: date - """ + node Event + title: string + event_date: date + """ ) with pytest.raises(FieldError): @@ -231,11 +231,11 @@ def test_create_node_wrong_property_count(self, clean_engine): engine.define_node( """ - node Person - name: string - age: int - email: string - """ + node Person + name: string + age: int + email: string + """ ) with pytest.raises(InvalidPropertiesError) as exc_info: @@ -262,11 +262,11 @@ def test_create_relation_simple(self, clean_engine): engine.define_node("node Company\nname: string") engine.define_relation( """ - relation WORKS_AT - Person -> Company - position: string - since: date - """ + relation WORKS_AT + Person -> Company + position: string + since: date + """ ) engine.create_node("Person", "person1", "Alice") @@ -300,10 +300,10 @@ def test_create_relation_bidirectional(self, clean_engine): engine.define_node("node Person\nname: string") engine.define_relation( """ - relation FRIENDS_WITH both - Person - Person - since: date - """ + relation FRIENDS_WITH both + Person - Person + since: date + """ ) engine.create_node("Person", "person1", "Alice") diff --git a/tests/test_unit_parser.py b/tests/test_unit_parser.py index 9bec54b..760c1be 100644 --- a/tests/test_unit_parser.py +++ b/tests/test_unit_parser.py @@ -18,11 +18,11 @@ def setup_method(self): def test_parse_node_definition_simple(self): """Test parsing simple node definition""" definition = """ - node Person - name: string - age: int - email: string - """ + node Person + name: string + age: int + email: string + """ node_name, fields, parent = self.parser.parse_node_definition(definition) @@ -40,10 +40,10 @@ def test_parse_node_definition_simple(self): def test_parse_node_definition_with_inheritance(self): """Test parsing node definition with inheritance""" definition = """ - node User from Entity - username: string - password: string - """ + node User from Entity + username: string + password: string + """ node_name, fields, parent = self.parser.parse_node_definition(definition) @@ -69,11 +69,11 @@ def test_parse_node_definition_empty_fields(self): def test_parse_relation_definition_simple(self): """Test parsing simple relation definition""" definition = """ - relation WORKS_AT - Person -> Company - position: string - since: date - """ + relation WORKS_AT + Person -> Company + position: string + since: date + """ (rel_name, from_type, to_type, fields, reverse_name, is_bidirectional) = self.parser.parse_relation_definition(definition) @@ -93,10 +93,10 @@ def test_parse_relation_definition_simple(self): def test_parse_relation_definition_with_reverse(self): """Test parsing relation definition with reverse name""" definition = """ - relation WORKS_AT reverse EMPLOYS - Person -> Company - position: string - """ + relation WORKS_AT reverse EMPLOYS + Person -> Company + position: string + """ (rel_name, from_type, to_type, _, reverse_name, is_bidirectional) = self.parser.parse_relation_definition(definition) @@ -110,10 +110,10 @@ def test_parse_relation_definition_with_reverse(self): def test_parse_relation_definition_bidirectional(self): """Test parsing bidirectional relation definition""" definition = """ - relation FRIENDS_WITH both - Person - Person - since: date - """ + relation FRIENDS_WITH both + Person - Person + since: date + """ (rel_name, from_type, to_type, _, reverse_name, is_bidirectional) = self.parser.parse_relation_definition(definition) diff --git a/tests/test_unit_query.py b/tests/test_unit_query.py index 6bcbddc..5ecc4d7 100644 --- a/tests/test_unit_query.py +++ b/tests/test_unit_query.py @@ -3,6 +3,7 @@ import pytest +from src.graphite import Direction from src.graphite.exceptions import ConditionError class TestQueryBuilder: @@ -26,7 +27,7 @@ def test_query_builder_invalid_node_type(self, populated_engine): engine = populated_engine with pytest.raises(AttributeError): - engine.query.NonExistent # pylint: disable=pointless-statement + engine.query.NonExistent.get() def test_query_builder_all_nodes(self, populated_engine): """Test all magic getter returns all nodes.""" @@ -146,7 +147,7 @@ def test_traverse_outgoing(self, populated_engine): result = populated_engine.query.Person.where('name = "Alice"') # Traverse WORKS_AT relation - works_at_result = result.traverse("WORKS_AT", "outgoing") + works_at_result = result.traverse("WORKS_AT", Direction.OUTGOING) assert len(works_at_result.nodes) == 1 assert next(iter(works_at_result.nodes)).type_name == "Company" @@ -293,10 +294,10 @@ def test_order_by_none_values(self, clean_engine): engine.define_node( """ - node Item - name: string - priority: int - """ + node Item + name: string + priority: int + """ ) engine.create_node("Item", "item1", "A", 2) @@ -384,20 +385,20 @@ def test_chained_queries(self, populated_engine): """Test chaining multiple query operations""" # Complex query: Get young employees who work at companies result = (populated_engine.query.Person - .where("age < 30") - .outgoing("WORKS_AT") - .order_by("founded")) + .where("age < 30") + .outgoing("WORKS_AT") + .order_by("founded")) assert len(result) == 1 assert result[0].type_name == "Company" # Even more complex complex_result = (populated_engine.query.Person - .where('name = "Alice"') - .outgoing("WORKS_AT") - .incoming("WORKS_AT") - .where("age > 20") - .limit(2, "age", True)) + .where('name = "Alice"') + .outgoing("WORKS_AT") + .incoming("WORKS_AT") + .where("age > 20") + .limit(2, "age", True)) # This should get all people who work at the same company as Alice assert len(complex_result.nodes) == 2