From 336ed1b961eb474bfa7d69013ade71d131a2601e Mon Sep 17 00:00:00 2001 From: mkh-user Date: Wed, 24 Jun 2026 23:41:04 +0330 Subject: [PATCH] A complete overhaul of engine focused on checks --- .github/workflows/checks.yml | 2 +- docs/references/engine.md | 6 +- pyproject.toml | 1 + src/graphite/__init__.py | 24 +- src/graphite/algorithms.py | 44 ++- src/graphite/dsl_parser.py | 374 +++++++++++++++++++ src/graphite/engine.py | 352 +++++++++--------- src/graphite/exceptions.py | 26 +- src/graphite/instances.py | 8 +- src/graphite/migration.py | 24 +- src/graphite/parser.py | 454 ----------------------- src/graphite/query.py | 94 ++--- src/graphite/serialization.py | 192 +++++----- src/graphite/types.py | 2 +- src/graphite/utils.py | 7 +- tests/benchmark.py | 145 ++++---- tests/benchmark_result.md | 2 + tests/conftest.py | 174 +++++---- tests/test_integration_basic.py | 72 ++-- tests/test_integration_dsl.py | 4 +- tests/test_integration_persistence.py | 46 +-- tests/test_unit_algorithms.py | 249 +++++-------- tests/test_unit_engine.py | 493 ++++++++++++++++++++----- tests/test_unit_instances.py | 120 +++++- tests/test_unit_migration.py | 39 +- tests/test_unit_parser.py | 255 +++++++++++-- tests/test_unit_query.py | 513 ++++++++++++++++---------- tests/test_unit_serialization.py | 357 ++++++++++++++---- tests/test_unit_types.py | 8 +- tests/test_utils.py | 24 +- 30 files changed, 2437 insertions(+), 1674 deletions(-) create mode 100644 src/graphite/dsl_parser.py delete mode 100644 src/graphite/parser.py diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 86b26e2..b811054 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -62,7 +62,7 @@ jobs: - name: Test with pytest working-directory: tests - run: pytest || [ $? -eq 5 ] + run: pytest --cov=src.graphite --cov-report=term-missing:skip-covered --cov-branch --cov-fail-under=100 types: runs-on: ubuntu-latest name: Type Check diff --git a/docs/references/engine.md b/docs/references/engine.md index 452ac58..bc32599 100644 --- a/docs/references/engine.md +++ b/docs/references/engine.md @@ -115,16 +115,16 @@ when `rel_type` is not `None`, filters relations to given type Undefine a node type `NotFoundError` for invalid type -Removes all instances with `remove_node()` +Removes all instances with `remove_nodes()` Removes all relation types (and instances) referenced this node type with `undefine_relation()` (and internal `remove_relation()`s) -Removes all node types (and instances) defined from this node type (with `from` keyword) with `undefine_node()` (and internal `remove_node()`s) +Removes all node types (and instances) defined from this node type (with `from` keyword) with `undefine_node()` (and internal `remove_nodes()`s) ## `undefine_relation(relation_type: str) -> None` Undefine a relation type `NotFoundError` fro invalid type -Removes all instances with `remove_relation()` +Removes all instances with `remove_relations()` Removes reverse name created with `reverse` keyword if exists ## `remove_node(node: Union[Node, str]) -> None` diff --git a/pyproject.toml b/pyproject.toml index b3cac2c..917f5fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ dev = [ "pytest~=9.0.2", "pylint~=4.0.4", "ty~=0.0.46", + "pytest-cov~=7.1.0", ] benchmark = [ diff --git a/src/graphite/__init__.py b/src/graphite/__init__.py index e07dcc7..075b295 100644 --- a/src/graphite/__init__.py +++ b/src/graphite/__init__.py @@ -9,21 +9,23 @@ from .engine import GraphiteEngine from .instances import Node, Relation from .migration import Migration -from .parser import GraphiteParser -from .query import QueryBuilder, QueryResult, Direction +from .query import Direction, QueryBuilder, QueryResult from .serialization import GraphiteJSONEncoder from .types import DataType, Field, NodeType, RelationType -from .utils import SecurityWarning, engine, node, relation +from .utils import SecurityWarning, node, relation simplefilter('always', SecurityWarning) __all__ = [ - 'GraphiteEngine', - 'Node', 'Relation', - 'Migration', - 'GraphiteParser', - 'QueryBuilder', 'QueryResult', 'Direction', - 'GraphiteJSONEncoder', - 'DataType', 'Field', 'NodeType', 'RelationType', - 'SecurityWarning', 'engine', 'node', 'relation', + 'GraphiteEngine', + 'Node', 'Relation', + 'Migration', + 'QueryBuilder', 'QueryResult', 'Direction', + 'GraphiteJSONEncoder', + 'DataType', 'Field', 'NodeType', 'RelationType', + 'SecurityWarning', 'engine', 'node', 'relation', ] + +def engine() -> GraphiteEngine: + """Create graphite engine instance""" + return GraphiteEngine() diff --git a/src/graphite/algorithms.py b/src/graphite/algorithms.py index fea9da2..837e3b1 100644 --- a/src/graphite/algorithms.py +++ b/src/graphite/algorithms.py @@ -2,7 +2,7 @@ Implemented standard graph algorithms for Graphite graph database. """ from collections import deque -from typing import Callable, cast, TYPE_CHECKING +from typing import Callable, TYPE_CHECKING, cast from .instances import Node, Relation from .query import Direction @@ -10,8 +10,11 @@ if TYPE_CHECKING: from .engine import GraphiteEngine -# pylint: disable=too-many-locals, too-many-positional-arguments, too-many-arguments -# pylint: disable=too-many-branches +# pylint: disable=too-many-branches, too-many-locals, too-many-positional-arguments +# pylint: disable=too-many-arguments +# Reason: This BFS is a feature-rich and flexible implementation. It is mostly used as a +# low-level function to provide the necessary flexibility for other application-specific +# functions, so its complexity is decided. def bfs( engine: 'GraphiteEngine', start: Node | str, @@ -20,7 +23,7 @@ def bfs( direction: Direction = Direction.OUTGOING, relation_type: str | None = None, max_depth: int | None = None, - include_start:bool = False, + include_start: bool = False, allow_direction_switch: bool = False, visited: set[str] | None = None, max_results: int | None = None @@ -67,7 +70,7 @@ def bfs( 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()))) + 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) @@ -80,7 +83,7 @@ def bfs( result.append((current, depth, path)) if stop_at_first: break - continue # Exact match on node ID, this branch hasn't anything else to find + 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: @@ -103,7 +106,11 @@ def bfs( return result[:max_results] if max_results is not None else result -def shortest_path( # pylint: disable=too-many-positional-arguments, too-many-arguments +# pylint: disable=too-many-positional-arguments, too-many-arguments +# Reason: This function provides complete flexibility of BFS usage (no weight mode) to reduce +# hacking need in finding the shortest path. Providing another wrapper based on most-used +# configuration is planned. +def shortest_path( engine: 'GraphiteEngine', from_node: Node | str, to_end: Node | str | Callable[[list[tuple[Relation, Node]]], bool] | None = None, @@ -148,7 +155,9 @@ def shortest_path( # pylint: disable=too-many-positional-arguments, too-many-arg 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 +# pylint: disable=too-many-positional-arguments, too-many-arguments +# Reason: As described in shortest_path(). +def all_shortest_paths( engine: 'GraphiteEngine', from_node: Node | str, to_end: Node | str | Callable[[list[tuple[Relation, Node]]], bool] | None = None, @@ -196,7 +205,9 @@ def all_shortest_paths( # pylint: disable=too-many-positional-arguments, too-man 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 +# pylint: disable=too-many-locals, too-many-positional-arguments, too-many-arguments +# Reason: As described in shortest_path(). +def connected_components( engine: 'GraphiteEngine', nodes: Node | str | set[Node | str] | None = None, return_all_nodes: bool = False, @@ -222,21 +233,20 @@ def connected_components( # pylint: disable=too-many-positional-arguments, too-m if nodes is None: _nodes = set(engine.nodes.keys()) elif not isinstance(nodes, set): - _nodes = {nodes if isinstance(nodes, str) else nodes.id} + _nodes = { nodes.id if isinstance(nodes, Node) else nodes } else: - _nodes = {node.id if isinstance(node, Node) else node for node in nodes} + _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() + visited: set[str] = 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( + step_result = { r[0] for r in bfs( engine, str(node), direction=direction, @@ -244,7 +254,7 @@ def connected_components( # pylint: disable=too-many-positional-arguments, too-m include_start=True, allow_direction_switch=allow_direction_switch, visited=ignore_nodes - )} + ) } if not return_all_nodes: step_result &= _nodes @@ -268,7 +278,9 @@ def connected_components( # pylint: disable=too-many-positional-arguments, too-m return merged_result -def neighborhood( # pylint: disable=too-many-positional-arguments, too-many-arguments +# pylint: disable=too-many-locals, too-many-positional-arguments, too-many-arguments +# Reason: As described in shortest_path(). +def neighborhood( engine: 'GraphiteEngine', start: Node | str, max_distance: int | None = None, diff --git a/src/graphite/dsl_parser.py b/src/graphite/dsl_parser.py new file mode 100644 index 0000000..cd36afd --- /dev/null +++ b/src/graphite/dsl_parser.py @@ -0,0 +1,374 @@ +""" +Parser for Graphite DSL +""" +import re +import warnings +from datetime import date, datetime +from typing import Any, TYPE_CHECKING + +from .exceptions import ( + FieldError, GraphiteError, ParseError, + RelationTypeDefineError, +) +from .types import DataType, DataTypePython, Field + +if TYPE_CHECKING: + from .engine import GraphiteEngine + +def parse_field_value(value: Any, field: Field) -> Any: + """ + Parse a raw value for a field (node or relation) and return it. + + **Note:** Value will be validated with field information, use ``parse_value()`` + to ignore validation. + + :param value: Value to parse + :param field: Field to convert and validate + + :return: Parsed and validated value + """ + if isinstance(value, str) and field.dtype == DataType.STRING: + return value + value = parse_value(value) + return validate_field_value(value, field) + +def validate_field_value(value: Any, field: Field) -> None | str | int | float | bool | date: + """ + Converts given value to field's data type. + + :param value: Parsed value to validate + :param field: Field to validate + + :return: Converted and validated value + + :raise FieldError: Field value cannot be converted + """ + # pylint: disable=unidiomatic-typecheck + # Reason: Python type of each data type is predefined, so using an type instead of instance + # is an enhancement. + if value is None or type(value) == DataTypePython[field.dtype.name].value: + return value + try: + if field.dtype == DataType.BOOL and isinstance(value, str): + return value.lower() == 'true' + if field.dtype == DataType.DATE: + if isinstance(value, datetime): + return value.date() + return datetime.strptime(value, "%Y-%m-%d").date() + # pylint: disable=unnecessary-dunder-call + # Reason: __call__ is necessary for type checking pass. + return DataTypePython[field.dtype.name].value.__call__(value) + except Exception as e: + raise FieldError( + field, + value + ) from e + +def parse_value(value: Any) -> Any: + """ + Parses a raw value (usually ``str``) into correct type (by guessing type). + + :param value: Value to parse + + :return: Parsed value + """ + if isinstance(value, str): + value = value.strip() + if ((value.startswith('"') and value.endswith('"')) + or (value.startswith("'") and value.endswith("'"))): + return value[1:-1] + if value.replace('-', '').isdigit() and value.count("-") == 2: # Date-like + try: + return datetime.strptime(value, "%Y-%m-%d").date() + except ValueError: + pass + if value.isdigit() or (value.startswith('-') and value[1:].isdigit()): + return int(value) + if value.replace('.', '').isdigit() and value.count('.') == 1: + return float(value) + if value.lower() in ('true', 'false'): + return value.lower() == 'true' + return value + +def parse_node_definition(definition: str) -> tuple[str, list[tuple[str, str]], str | None]: + """ + Parse node type definition, for example:\n + '''\n + node Person\n + name: string\n + age: int\n + '''\n + + :param definition: Node type definition string in Graphite DSL + + :return: node type name, fields, parent type name + """ + lines = definition.strip().split('\n') + first_line = lines[0].strip() + + if not first_line.startswith('node '): + raise ParseError( + "Invalid node definition, expected 'node ...' pattern" + ) + + # Parse inheritance + if ' from ' in first_line: + parts = first_line.split(' from ') + + if len(parts) != 2: + raise ParseError( + "Invalid node definition, expected 'node from ' pattern", + None, + first_line.find(' from ') + ) + + node_name = parts[0].replace('node ', '', 1).strip() + parent = parts[1].strip() + else: + node_name = first_line.replace('node ', '', 1).strip() + parent = None + + fields = _fetch_fields(lines[1:]) + + return node_name, fields, parent + +def parse_relation_definition( + definition: str +) -> tuple[str, str, str, list[tuple[str, str]], str | None, bool]: + """ + Parse relation definition + + :param definition: Relation definition string in Graphite DSL + + :return: relation type name, source type name, target type name, fields, optional reverse + type name, is bidirectional or not + + :except ParseError: empty or invalid definition + """ + lines = definition.strip().split('\n') + + if len(lines) < 2: + raise ParseError("Invalid relation definition, expected at least two lines.") + + first_line = lines[0].strip() + + if not first_line.startswith('relation '): + raise ParseError( + "Invalid relation definition, expected 'relation ...' pattern" + ) + + # Check for 'both' keyword + is_bidirectional = ' both' in first_line + if is_bidirectional: + first_line = first_line.replace(' both', '') + + # Parse reverse + reverse_name = None + if ' reverse ' in first_line: + parts = first_line.split(' reverse ') + + if len(parts) != 2: + raise ParseError( + "Invalid relation definition, expected 'relation reverse ' pattern", + None, + first_line.find(' reverse ') + 1 + ) + + relation_name = parts[0].replace('relation ', '', 1).strip() + reverse_name = parts[1].strip() + else: + relation_name = first_line.replace('relation ', '', 1).strip() + + if is_bidirectional and reverse_name: + raise RelationTypeDefineError(relation_name) + + # Parse participants + participants_line = lines[1].strip() + try: + if '->' in participants_line: + parts = participants_line.split('->') + elif '-' in participants_line: + participants_line = participants_line.replace('--', '-') + parts = participants_line.split('-') + else: + raise GraphiteError + if len(parts) != 2: + raise GraphiteError + from_type = parts[0].strip() + to_type = parts[1].strip() + except GraphiteError as e: + raise ParseError( + "Invalid relation type format, expected ' -[-,>] ' pattern" + ) from e + + fields = _fetch_fields(lines[2:]) + + return relation_name, from_type, to_type, fields, reverse_name, is_bidirectional + +def parse_node_instance(line: str) -> tuple[str, str, list[Any]]: + """ + Parse node instance: 'User, user_1, "Joe Doe", 32, "joe4030"' + + :param line: node instance string in Graphite DSL + + :return: node type name, node id, parsed field values + """ + parts = parse_value_list(line, "node instance") + + node_type = parts[0].strip() + node_id = parts[1].strip() + values = list(map(parse_value, parts[2:])) + + return node_type, node_id, values + +def parse_relation_instance(line: str) -> tuple[str, str, str, list[Any]]: + """ + Parse relation instance: 'user_1 -[OWNER, 2000-10-04]-> notebook' + + :param line: relation instance string in Graphite DSL + + :return: source node id, target node id, relation type name, field values + """ + # Extract relation type and attributes + pattern = r'^(\w+)\s*(-\[([^\]]+)\]\s*[->-]\s*|\s*[->-]\s*\[([^\]]+)\]\s*->\s*)(\w+)$' + match = re.search(pattern, line) + if not match: + raise ParseError(f"Invalid relation format: {line}") + + from_node = match.group(1) + to_node = match.group(5) + + # Get relation type and attributes + rel_part = match.group(3) or match.group(4) + rel_parts = parse_value_list(rel_part, "relation instance") + rel_type = rel_parts[0] + attributes = list(map(parse_value, rel_parts[1:]) if len(rel_parts) > 1 else []) + + return from_node, to_node, rel_type, attributes + +# pylint: disable=too-many-locals +# Reason: As main responsible of DSL parsing complexity of this function is reasonable and +# relavily low. +def parse(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 found + """ + lines = data.strip().split('\n') + + i = 0 + try: + while i < len(lines): + line = lines[i].strip() + if not line or line.startswith('#'): + i += 1 + continue + + if line.startswith('node ') or line.startswith('relation '): + # Collect multiline node definition + type_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 + type_def.append(lines[i]) + i += 1 + if line.startswith('node '): + type_name, fields, parent_name = parse_node_definition('\n'.join(type_def)) + engine.define_node( + type_name, + *fields, + parent=parent_name + ) + else: + (type_name, source_type, target_type, fields, reverse_name, + is_bidirectional) = parse_relation_definition('\n'.join(type_def)) + engine.define_relation( + type_name, + source_type, + target_type, + *fields, + reverse_name=reverse_name, + is_bidirectional=is_bidirectional + ) + + elif '-[' in line and (']->' in line or ']-' in line): + # Relation instance + i += 1 + from_id, to_id, rel_type, values = parse_relation_instance(line) + engine.create_relation(from_id, to_id, rel_type, *values, parse_fields=True) + + else: + # Node instance + i += 1 + node_type, node_id, values = parse_node_instance(line) + engine.create_node(node_type, node_id, *values, parse_fields=True) + except ParseError as e: + e.line = i + if e.column is None: + e.column = 0 + raise e + +def parse_value_list(line: str, definition: str) -> list[str]: + """Parse a sequence of comma-separated values, robust to both double and single quotes""" + result = [] + current = '' + quote_char = None + for char in line: + if char in ('"', "'"): + if quote_char is None: + quote_char = char + elif quote_char == char: + quote_char = None + current += char + elif char == ',' and quote_char is None: + result.append(current.strip()) + current = '' + else: + current += char + if not current: + raise ParseError(f"Additional comma in {definition} values") + result.append(current.strip()) + return result + +def _fetch_fields(lines: list[str]) -> list[tuple[str, str]]: + fields = [] + for line_number, field_line in enumerate(lines): + field_line = field_line.strip() + if not field_line: + warnings.warn( + f"Line {line_number + 2} in relation definition is empty, skipped.", + SyntaxWarning, + stacklevel=3 + ) + continue + name_type = field_line.split(':') + + if len(name_type) != 2: + raise ParseError( + "Invalid field definition, expected ': ' pattern" + ) + + name = name_type[0].strip() + dtype = name_type[1].strip() + + if not name or not dtype: + raise ParseError( + "Invalid field definition, expected ': ' pattern" + ) + + fields.append((name, dtype)) + return fields diff --git a/src/graphite/engine.py b/src/graphite/engine.py index 10ae0b4..94d4f8d 100644 --- a/src/graphite/engine.py +++ b/src/graphite/engine.py @@ -9,68 +9,100 @@ from typing_extensions import deprecated -from . import algorithms as _algorithms +from . import algorithms as _algorithms, dsl_parser as _parser from .exceptions import ( FileSizeError, InvalidJSONError, InvalidPropertiesError, InvalidRelationError, - NotFoundError, SafeLoadExtensionError, TooNestedJSONError, + NotFoundError, ParseError, RelationTypeDefineError, SafeLoadExtensionError, TooNestedJSONError, ) from .instances import Node, Relation -from .parser import GraphiteParser from .query import Direction, QueryBuilder from .serialization import ( - GraphiteJSONEncoder, SAVE_FILE_VERSION, _validate_loaded_data, - graphite_object_hook, + GraphiteJSONEncoder, SAVE_FILE_VERSION, _load_from_dict, _validate_loaded_data, + graphite_object_hook ) -from .types import Field, NodeType, RelationType +from .types import DataType, Field, NodeType, RelationType -# pylint: disable=too-many-instance-attributes, too-many-public-methods +# pylint: disable=too-many-public-methods, too-many-instance-attributes +# Reason: GraphiteEngine is main entry point of Graphite, so count of its members is reasonable. class GraphiteEngine: """Main graph database engine""" def __init__(self): - self.node_types: dict[str, NodeType] = {} - self.relation_types: dict[str, RelationType] = {} - self.nodes: dict[str, Node] = {} - self.relations: dict[int, Relation] = {} + self.node_types: dict[str, NodeType] = { } + self.relation_types: dict[str, RelationType] = { } + self.nodes: dict[str, Node] = { } + self.relations: dict[int, Relation] = { } self.node_by_type: dict[str, set[str]] = defaultdict(set) self.relations_by_type: dict[str, set[int]] = defaultdict(set) self.relations_by_from: dict[str, set[int]] = defaultdict(set) self.relations_by_to: dict[str, set[int]] = defaultdict(set) - self.parser: GraphiteParser = GraphiteParser() self.query: QueryBuilder = QueryBuilder(self) # =============== SCHEMA DEFINITION =============== - def define_node(self, definition: str) -> None: + def define_node( + self, + node_type: str, + *fields: tuple[str, str], + parent: str | None = None + ) -> None: """ - Define a node type from DSL + Define a node type from DSL / direct creation - :param definition: Node definition string in Graphite DSL + :param node_type: Node definition string in Graphite DSL or type name + :param fields: Fields of node type: (name, type) + :param parent: Parent node type name :return: None :except GraphiteError: if node definition is not valid :except NotFoundError: if parent node definition (from ...) is not found """ - node_name, fields, parent_name = self.parser.parse_node_definition(definition) + node_type = node_type.strip() + if node_type.startswith('node '): + node_type, _fields, parent = _parser.parse_node_definition(node_type) + fields = tuple(_fields) - parent = None - if parent_name: - if parent_name not in self.node_types: + if parent is not None: + if parent not in self.node_types: raise NotFoundError( "Parent node type", - parent_name, + parent, ) - parent = self.node_types[parent_name] + parent: NodeType = self.node_types[parent] - node_type = NodeType(node_name, fields, parent) - self.node_types[node_name] = node_type - - def define_relation(self, definition: str) -> None: + final_fields: list[Field] = [] + for name, data_type in fields: + try: + final_fields.append(Field(name, DataType(data_type))) + except ValueError as e: + raise NotFoundError( + "Data type", + data_type + ) from e + node_type_obj = NodeType(node_type, final_fields, parent) + self.node_types[node_type] = node_type_obj + + # pylint: disable=too-many-positional-arguments, too-many-arguments, keyword-arg-before-vararg + # Reason: Arguments are just relation type fields, and order of them is based on usage. + def define_relation( + self, + relation_type: str, + source_type: str | None = None, + target_type: str | None = None, + *fields: tuple[str, str], + reverse_name: str | None = None, + is_bidirectional: bool = False + ) -> None: """ - Define a relation type from DSL + Define a relation type from DSL / direct creation - :param definition: Relation definition string in Graphite DSL + :param relation_type: Relation definition string in Graphite DSL / relation type name + :param source_type: Source node type name + :param target_type: Target node type name + :param fields: Fields of relation type: (name, type) + :param reverse_name: Reverse relation name + :param is_bidirectional: Is bidirectional relation :return: None @@ -78,32 +110,51 @@ def define_relation(self, definition: str) -> None: :except RelationTypeDefineError: if relation type have both 'reverse ...' and 'both' flags :except NotFoundError: if source or target node types are not found """ - (rel_name, from_type, to_type, fields, - reverse_name, is_bidirectional) = self.parser.parse_relation_definition(definition) + relation_type = relation_type.strip() + if relation_type.startswith('relation '): + (relation_type, source_type, target_type, _fields, + reverse_name, is_bidirectional) = _parser.parse_relation_definition(relation_type) + fields = tuple(_fields) + if source_type is None or target_type is None: + raise ParseError( + "Source and target node type is required for relation types" + ) + if reverse_name is not None and is_bidirectional: + raise RelationTypeDefineError(relation_type) # Validate node types exist - if from_type not in self.node_types: + if source_type not in self.node_types: raise NotFoundError( "Node type", - from_type, + source_type, ) - if to_type not in self.node_types: + if target_type not in self.node_types: raise NotFoundError( "Node type", - to_type, + target_type, ) - rel_type = RelationType( - rel_name, from_type, to_type, - fields, reverse_name, is_bidirectional + final_fields: list[Field] = [] + for name, data_type in fields: + try: + final_fields.append(Field(name, DataType(data_type))) + except ValueError as e: + raise NotFoundError( + "Data type", + data_type + ) from e + + relation_type_obj = RelationType( + relation_type, source_type, target_type, + final_fields, reverse_name, is_bidirectional ) - self.relation_types[rel_name] = rel_type + self.relation_types[relation_type] = relation_type_obj # Register reverse relation if specified if reverse_name: reverse_rel = RelationType( - reverse_name, to_type, from_type, - fields, rel_name, is_bidirectional + reverse_name, target_type, source_type, + final_fields, relation_type, is_bidirectional ) self.relation_types[reverse_name] = reverse_rel @@ -142,13 +193,13 @@ def create_node( ) # Create values dictionary - node_values = {} + node_values = { } if parse_fields: for current_field, value in zip(all_fields, values): - node_values[current_field.name] = self.parser.parse_field_value(value, current_field) + node_values[current_field.name] = _parser.parse_field_value(value, current_field) else: for current_field, value in zip(all_fields, values): - node_values[current_field.name] = self.parser.validate_field_value( + node_values[current_field.name] = _parser.validate_field_value( value, current_field ) @@ -197,8 +248,8 @@ def create_relation( ) if not ( - self.is_node_from_type(from_id, rel_type_obj.from_type) and - self.is_node_from_type(to_id, rel_type_obj.to_type) + self.is_node_from_type(from_id, rel_type_obj.from_type) and + self.is_node_from_type(to_id, rel_type_obj.to_type) ): raise InvalidRelationError( rel_type_obj, @@ -213,13 +264,13 @@ def create_relation( ) # Create values dictionary - rel_values = {} + rel_values = { } if parse_fields: for current_field, value in zip(rel_type_obj.fields, values): - rel_values[current_field.name] = self.parser.parse_field_value(value, current_field) + rel_values[current_field.name] = _parser.parse_field_value(value, current_field) else: for current_field, value in zip(rel_type_obj.fields, values): - rel_values[current_field.name] = self.parser.validate_field_value( + rel_values[current_field.name] = _parser.validate_field_value( value, current_field ) @@ -305,7 +356,7 @@ def get_nodes_of_type(self, node_type: str, with_subtypes: bool = True) -> set[N :except NotFoundError: if `node_type` not defined """ - return {self.nodes[n] for n in self._get_nodes_of_type_ids(node_type, with_subtypes)} + return { self.nodes[n] for n in self._get_nodes_of_type_ids(node_type, with_subtypes) } def _get_nodes_of_type_ids(self, node_type: str, with_subtypes: bool = True) -> set[str]: if node_type not in self.node_types: @@ -316,8 +367,10 @@ def _get_nodes_of_type_ids(self, node_type: str, with_subtypes: bool = True) -> nodes = self.node_by_type[node_type] if with_subtypes: - nodes.update(*[self._get_nodes_of_type_ids(ntype, True) - for ntype in self._get_subtypes(node_type)]) + nodes.update( + *[self._get_nodes_of_type_ids(ntype, True) + for ntype in self._get_subtypes(node_type)] + ) return nodes def _get_subtypes(self, node_type: str) -> set[str]: @@ -355,8 +408,8 @@ def get_relations_from(self, node_id: str, rel_type: str | None = None) -> set[R result_ids = self.relations_by_from[node_id] if rel_type: - result_ids = {r for r in result_ids if r in self.relations_by_type[rel_type]} - return {self.relations[rel_id] for rel_id in result_ids} + result_ids = { r for r in result_ids if r in self.relations_by_type[rel_type] } + return { self.relations[rel_id] for rel_id in result_ids } def get_relations_to(self, node_id: str, rel_type: str | None = None) -> set[Relation]: """ @@ -382,8 +435,8 @@ def get_relations_to(self, node_id: str, rel_type: str | None = None) -> set[Rel result_ids = self.relations_by_to[node_id] if rel_type: - result_ids = {r for r in result_ids if r in self.relations_by_type[rel_type]} - return {self.relations[rel_id] for rel_id in result_ids} + result_ids = { r for r in result_ids if r in self.relations_by_type[rel_type] } + return { self.relations[rel_id] for rel_id in result_ids } def undefine_node(self, node_type: str) -> None: """ @@ -400,10 +453,10 @@ def undefine_node(self, node_type: str) -> None: "Node type", node_type ) - for ntype in self.node_types.values(): + for ntype in list(self.node_types.values()): if ntype.parent and ntype.parent.name == node_type: self.undefine_node(ntype.name) - for rtype in self.relation_types.values(): + for rtype in list(self.relation_types.values()): if node_type in (rtype.from_type, rtype.to_type): self.undefine_relation(rtype.name) self.remove_nodes(self.node_by_type[node_type]) @@ -429,7 +482,7 @@ def undefine_relation(self, relation_type: str, _is_reverse: bool = False) -> No reverse_name = self.relation_types[relation_type].reverse_name if not _is_reverse and reverse_name: self.undefine_relation(reverse_name, True) - self.remove_relations({self.relations[r] for r in self.relations_by_type[relation_type]}) + self.remove_relations({ self.relations[r] for r in self.relations_by_type[relation_type] }) del self.relation_types[relation_type] self.relations_by_type.pop(relation_type) @@ -451,11 +504,11 @@ def remove_nodes( :except NotFoundError: if any node is not found """ if isinstance(nodes, (str, Node)): - _nodes = {nodes if isinstance(nodes, str) else nodes.id} + _nodes = { nodes if isinstance(nodes, str) else nodes.id } else: - _nodes = {n if isinstance(n, str) else n.id for n in nodes} + _nodes = { n if isinstance(n, str) else n.id for n in nodes } - missing = {nid for nid in _nodes if nid not in self.nodes} + missing = { nid for nid in _nodes if nid not in self.nodes } if missing: raise NotFoundError("Node", f"{next(iter(missing))} (and {len(missing) - 1} others)") @@ -473,7 +526,7 @@ def _collect_relations(self, nodes: set[str]) -> set[Relation]: for node in nodes: relations.update(self.relations_by_from[node]) relations.update(self.relations_by_to[node]) - return {self.relations[r] for r in relations} + return { self.relations[r] for r in relations } def remove_relations(self, relations: Relation | set[Relation] | list[Relation]) -> None: """ @@ -488,7 +541,7 @@ def remove_relations(self, relations: Relation | set[Relation] | list[Relation]) if isinstance(relations, list): relations = cast(set[Relation], set(relations)) elif isinstance(relations, Relation): - relations = {relations} + relations = { relations } for rel in relations: if id(rel) not in self.relations: @@ -497,22 +550,32 @@ def remove_relations(self, relations: Relation | set[Relation] | list[Relation]) for rel in relations: rel_id = id(rel) del self.relations[rel_id] - self.relations_by_type[rel.type_name].discard(rel) - self.relations_by_from[rel.from_node].discard(rel) - self.relations_by_to[rel.to_node].discard(rel) + self.relations_by_type[rel.type_name].discard(rel_id) + self.relations_by_from[rel.from_node].discard(rel_id) + self.relations_by_to[rel.to_node].discard(rel_id) @deprecated("Use remove_nodes() instead") def remove_node(self, node: Node | str | list[Node | str]) -> None: """ - Remove given nodes and all their relations + Deprecated: Use remove_nodes() instead """ + warnings.warn( + "engine.remove_node() is deprecated, use remove_nodes() instead.", + DeprecationWarning, + stacklevel=2 + ) return self.remove_nodes(node) @deprecated("Use remove_relations() instead") def remove_relation(self, relation: Relation | set[Relation] | list[Relation]) -> None: """ - Removes given relations + Deprecated: Use remove_relations() instead """ + warnings.warn( + "engine.remove_relation() is deprecated, use remove_relations() instead.", + DeprecationWarning, + stacklevel=2 + ) return self.remove_relations(relation) # ============= BULK LOADING / DSL ============= @@ -529,7 +592,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 """ - self.parser.parse(self, data) + _parser.parse(self, data) @deprecated("Use parse() instead") def load_dsl(self, dsl: str) -> None: @@ -544,6 +607,11 @@ def load_dsl(self, dsl: str) -> None: :except NotFoundError: using any undefined object (node type, relation type, node, relation) :except ValueError: if a used data type not fount """ + warnings.warn( + "engine.load_dsl() is deprecated, use parse() instead.", + DeprecationWarning, + stacklevel=2 + ) self.parse(dsl) # =============== PERSISTENCE =============== @@ -556,7 +624,6 @@ def save(self, file_path: str) -> None: """ data = self._build_save_payload() with open(file_path, 'w', encoding='utf-8') as f: - # noinspection PyTypeChecker json.dump(data, f, cls=GraphiteJSONEncoder, indent=2, ensure_ascii=False) def load_safe( @@ -607,7 +674,7 @@ def load_safe( # Load normally self._load_from_dict(data) - # 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) @@ -619,92 +686,11 @@ def _load_from_dict(self, data: dict[str, Any]) -> None: # Clear existing data self.clear() - node_types_data = data.get('node_types', []) - relation_types_data = data.get('relation_types', []) - nodes_data = data.get('nodes', []) - relations_data = data.get('relations', []) - - # Restore node types - for nt_dict in node_types_data: - if isinstance(nt_dict, NodeType): - nt = nt_dict - else: - # Convert from dict if needed - fields: list[Field] = list(map( - lambda fld: Field(fld.name, fld.dtype), - nt_dict.get("fields", []) - )) - nt = NodeType( - name=nt_dict['name'], - fields=fields, - parent=None # Will be restored later - ) - self.node_types[nt.name] = nt - - # Restore parent references for node types - for nt in node_types_data: - if isinstance(nt, dict): - parent_name = nt.get('parent') - name = nt.get('name') - else: - parent_name = nt.parent.name if nt.parent else None - name = nt.name - if isinstance(parent_name, dict): - parent_name = parent_name["name"] - if parent_name and parent_name in self.node_types and name in self.node_types: - self.node_types[name].parent = self.node_types[parent_name] - - # Restore relation types - for rt_dict in relation_types_data: - if isinstance(rt_dict, RelationType): - rt = rt_dict - else: - rt = RelationType( - name=rt_dict['name'], - from_type=rt_dict['from_type'], - to_type=rt_dict['to_type'], - fields=rt_dict.get('fields', []), - reverse_name=rt_dict.get('reverse_name'), - is_bidirectional=rt_dict.get('is_bidirectional', False) - ) - self.relation_types[rt.name] = rt - - # Restore nodes - for node_data in nodes_data: - if isinstance(node_data, Node): - loading_node = node_data - else: - loading_node = Node( - type_name=node_data['type_name'], - id=node_data['id'], - values=node_data['values'], - type_ref=None - ) - - # Restore type reference - if loading_node.type_name in self.node_types: - loading_node.type_ref = self.node_types[loading_node.type_name] - - self.nodes[loading_node.id] = loading_node - - # Restore relations - for rel_data in relations_data: - if isinstance(rel_data, Relation): - rel = rel_data - else: - rel = Relation( - type_name=rel_data['type_name'], - from_node=rel_data['from_node'], - to_node=rel_data['to_node'], - values=rel_data['values'], - type_ref=None - ) - - # Restore type reference - if rel.type_name in self.relation_types: - rel.type_ref = self.relation_types[rel.type_name] - - self.relations[id(rel)] = rel + node_types, relation_types, nodes, relations = _load_from_dict(data) + self.node_types = node_types.copy() + self.relation_types = relation_types.copy() + self.nodes = nodes.copy() + self.relations = relations.copy() # Rebuild all indexes self._rebuild_all_indexes() @@ -716,11 +702,11 @@ def _build_save_payload(self) -> dict[str, Any]: :return: Engine snapshot as JSON dictionary """ return { - "version" : SAVE_FILE_VERSION, - "node_types" : list(self.node_types.values()), - "relation_types" : list(self.relation_types.values()), - "nodes" : list(self.nodes.values()), - "relations" : sorted( + "version": SAVE_FILE_VERSION, + "node_types": list(self.node_types.values()), + "relation_types": list(self.relation_types.values()), + "nodes": list(self.nodes.values()), + "relations": sorted( self.relations.values(), key=lambda r: ( r.type_name, @@ -759,17 +745,17 @@ def load(self, filename: str, safe_mode: bool = True) -> None: :return: None """ - if safe_mode: - self.load_safe(filename) + # Legacy unsafe loading (for backward compatibility) + if not safe_mode: + warnings.warn( + "Unsafe loading mode will be deprecated in next versions. Use safe_mode=True for security. " + "You can use 'graphite.Migration.convert_pickle_to_json()' to update your database.", + PendingDeprecationWarning + ) + self._load_unsafe(filename) return - # Legacy unsafe loading (for backward compatibility) - warnings.warn( - "Unsafe loading mode will be deprecated in next versions. Use safe_mode=True for security. " - "You can use 'graphite.Migration.convert_pickle_to_json()' to update your database.", - PendingDeprecationWarning - ) - self._load_unsafe(filename) + self.load_safe(filename) def _load_unsafe(self, filename: str) -> None: """ @@ -808,15 +794,17 @@ def stats(self) -> dict[str, Any]: and relations """ return { - 'node_types' : len(self.node_types), + 'node_types': len(self.node_types), 'relation_types': len(self.relation_types), - 'nodes' : len(self.nodes), - 'relations' : len(self.relations), + 'nodes': len(self.nodes), + 'relations': len(self.relations), } # ================== ALGORITHMS ================= - def bfs( # pylint: disable=too-many-arguments, too-many-positional-arguments + # pylint: disable=too-many-positional-arguments, too-many-arguments + # Reason: See main function. + def bfs( self, start: Node | str, end: Node | str | Callable[[list[tuple[Relation, Node]]], bool] | None = None, @@ -856,7 +844,9 @@ def bfs( # pylint: disable=too-many-arguments, too-many-positional-arguments allow_direction_switch, visited, max_results ) - def shortest_path( # pylint: disable=too-many-positional-arguments, too-many-arguments + # pylint: disable=too-many-positional-arguments, too-many-arguments + # Reason: See main function. + def shortest_path( self, from_node: Node | str, to_end: Node | str | Callable[[list[tuple[Relation, Node]]], bool] | None = None, @@ -889,7 +879,9 @@ def shortest_path( # pylint: disable=too-many-positional-arguments, too-many-arg ignore_nodes, weight ) - def all_shortest_paths( # pylint: disable=too-many-positional-arguments, too-many-arguments + # pylint: disable=too-many-positional-arguments, too-many-arguments + # Reason: See main function. + def all_shortest_paths( self, from_node: Node | str, to_end: Node | str | Callable[[list[tuple[Relation, Node]]], bool] | None = None, @@ -922,7 +914,9 @@ def all_shortest_paths( # pylint: disable=too-many-positional-arguments, too-man ignore_nodes, weight ) - def connected_components( # pylint: disable=too-many-positional-arguments, too-many-arguments + # pylint: disable=too-many-positional-arguments, too-many-arguments + # Reason: See main function. + def connected_components( self, nodes: Node | str | set[Node | str] | None = None, return_all_nodes: bool = False, @@ -949,7 +943,9 @@ def connected_components( # pylint: disable=too-many-positional-arguments, too-m ignore_nodes ) - def neighborhood( # pylint: disable=too-many-positional-arguments, too-many-arguments + # pylint: disable=too-many-positional-arguments, too-many-arguments + # Reason: See main function. + def neighborhood( self, start: Node | str, max_distance: int | None = None, diff --git a/src/graphite/exceptions.py b/src/graphite/exceptions.py index 82b26ca..790c142 100644 --- a/src/graphite/exceptions.py +++ b/src/graphite/exceptions.py @@ -18,6 +18,7 @@ class ParseError(GraphiteError): :param line: line number :param column: column number """ + def __init__(self, message: str, line: int | None = None, column: int | None = None): self.line = line self.column = column @@ -29,6 +30,7 @@ class RelationTypeDefineError(GraphiteError): :param relation_type: relation type """ + def __init__(self, relation_type: str): super().__init__( f"{relation_type} can't be bidirectional and reverse named relation at the same time." @@ -42,6 +44,7 @@ class InvalidRelationError(GraphiteError): :param source_id: source node id :param target_id: target node id """ + def __init__(self, relation_type: RelationType, source_id: str, target_id: str): super().__init__( f"{relation_type.name} relation between {source_id} and {target_id} is invalid. " + @@ -56,6 +59,7 @@ class ValidationError(GraphiteError): :param field: field name :param value: given value """ + def __init__(self, message: str, field: str, value: Any = None): self.field = field self.value = value @@ -73,6 +77,7 @@ class NotFoundError(GraphiteError): :param resource_type: type of resource :param resource_id: ID used to find resource """ + def __init__(self, resource_type: str, resource_id: str): self.resource_type = resource_type self.resource_id = resource_id @@ -85,6 +90,7 @@ class InvalidPropertiesError(GraphiteError): :param valid_properties: valid properties for node / relation :param got_count: count of gotten properties """ + def __init__(self, valid_properties: list[Field], got_count: int): self.valid_properties = valid_properties self.got_count = got_count @@ -99,6 +105,7 @@ class FieldError(GraphiteError): :param field: field name :param value: given value """ + def __init__(self, field: Field, value: Any): self.field = field self.value = value @@ -106,20 +113,6 @@ def __init__(self, field: Field, value: Any): f"Field {field.name} must be '{str(field.dtype)}', got {type(value)} ({value})" ) -class DateParseError(GraphiteError): - """ - Date parsing error - - :param date_str: date string - :param expected_format: expected format (default: %Y-%m-%d) - """ - def __init__(self, date_str: str, expected_format: str = "%Y-%m-%d"): - self.date_str = date_str - self.expected_format = expected_format - super().__init__( - f"Failed to parse date '{date_str}'. Expected format: {expected_format}" - ) - class FileSizeError(GraphiteError): """ File size error @@ -127,6 +120,7 @@ class FileSizeError(GraphiteError): :param file_size: size of given file (MB) :param max_size: max valid size (MB) """ + def __init__(self, file_size: float, max_size: int | float): self.file_size = file_size self.max_size = max_size @@ -136,6 +130,7 @@ class SafeLoadExtensionError(GraphiteError): """ Safe-load extension error """ + def __init__(self): super().__init__("Only '.json' files are allowed for safe loading") @@ -143,6 +138,7 @@ class InvalidJSONError(GraphiteError): """ Invalid JSON error """ + def __init__(self): super().__init__("Invalid JSON") @@ -150,6 +146,7 @@ class TooNestedJSONError(GraphiteError): """ Too Nested JSON error """ + def __init__(self): super().__init__("JSON structure is too nested") @@ -157,6 +154,7 @@ class ConditionError(QueryError): """ Condition error """ + def __init__(self, condition: str): self.condition = condition super().__init__(f"Invalid condition string: {condition}") diff --git a/src/graphite/instances.py b/src/graphite/instances.py index ec58cf4..4920ac5 100644 --- a/src/graphite/instances.py +++ b/src/graphite/instances.py @@ -52,10 +52,10 @@ def __repr__(self) -> str: def __hash__(self) -> int: return hash(self.id) - def __eq__(self, other: object) -> bool: - if not isinstance(other, Node): - return NotImplemented - return self.id == other.id + def __eq__(self, other: Any) -> bool: + if isinstance(other, Node): + return self.id == other.id + return NotImplemented @dataclass class Relation: diff --git a/src/graphite/migration.py b/src/graphite/migration.py index 3df249c..1100480 100644 --- a/src/graphite/migration.py +++ b/src/graphite/migration.py @@ -72,20 +72,14 @@ def detect_pickle_and_convert_to_json( :return: None """ for pickle_file in glob.glob(os.path.join(directory, pattern)): - if pickle_file.endswith('.json'): - continue - json_file = pickle_file.rsplit('.', 1)[0] + '.json' - try: - # Quick check if it's a pickle file - with open(pickle_file, 'rb') as f: - # Try to read pickle header - header = f.read(4) - if header == b'\x80\x04' or header.startswith(b'\x80'): # Pickle protocol 4 - Migration.convert_pickle_to_json( - pickle_file, json_file, delete_originals - ) - except Exception as e: # pylint: disable=broad-exception-caught - # Not a pickle file or can't read - print(f"File '{pickle_file}' skipped: {e}") + with open(pickle_file, 'rb') as f: + # Try to read pickle header + header = f.read(4) + if header == b'\x80\x04' or header.startswith(b'\x80'): # Pickle protocol 4 + Migration.convert_pickle_to_json( + pickle_file, json_file, delete_originals + ) + else: + print(f"File '{pickle_file}' is not picle protocol 4, skipped.") diff --git a/src/graphite/parser.py b/src/graphite/parser.py deleted file mode 100644 index 0cafa30..0000000 --- a/src/graphite/parser.py +++ /dev/null @@ -1,454 +0,0 @@ -""" -Parser for Graphite DSL -""" -import re -import warnings -from datetime import datetime -from typing import Any, TYPE_CHECKING - -from .exceptions import ( - DateParseError, FieldError, GraphiteError, NotFoundError, ParseError, - RelationTypeDefineError, -) -from .types import DataType, DataTypePython, Field - -if TYPE_CHECKING: - from .engine import GraphiteEngine - -class GraphiteParser: - """Parser for Graphite DSL""" - - def parse_field_value(self, value: Any, field: Field) -> Any: - """ - Parse a raw value for a field (node or relation) and return it. - - **Note:** Value will be validated with field information, use ``parse_value()`` - to ignore validation. - - :param value: Value to parse - :param field: Field to convert and validate - - :return: Parsed and validated value - """ - if isinstance(value, str) and field.dtype == DataType.STRING: - return value - value = self.parse_value(value) - return self.validate_field_value(value, field) - - # pylint: disable=broad-exception-caught, too-many-branches, too-many-return-statements - @staticmethod - def validate_field_value(value: Any, field: Field) -> Any: - """ - Converts given value to field's data type. - - :param value: Parsed value to validate - :param field: Field to validate - - :return: Converted and validated value - - :raise FieldError: Field value cannot be converted - """ - if value is None: - return None - if isinstance(value, DataTypePython[field.dtype.name].value): - return value - try: - if field.dtype == DataType.STRING: - return str(value) - if field.dtype == DataType.INT: - return int(value) - if field.dtype == DataType.DATE: - if isinstance(value, datetime): - return value.date() - if isinstance(value, str): - return datetime.strptime(value, "%Y-%m-%d").date() - if field.dtype == DataType.FLOAT: - return float(value) - if field.dtype == DataType.BOOL: - if isinstance(value, str): - return value.lower() == "true" - return bool(value) - if field.dtype not in DataType: - raise NotFoundError( - "Data type", - str(field.dtype) - ) - except NotFoundError as e: - raise e - except Exception as e: - raise FieldError( - field, - value - ) from e - return value - - # pylint: disable=too-many-return-statements - @staticmethod - def parse_value(value: Any) -> Any: - """ - Parses a raw value (usually ``str``) into correct type (by guessing type). - - :param value: Value to parse - - :return: Parsed value - - :raise DateParseError: Date parsing failed - """ - if not isinstance(value, str): - return value - value = value.strip() - if ((value.startswith('"') and value.endswith('"')) - or (value.startswith("'") and value.endswith("'"))): - return value[1:-1] - if value.replace('-', '').isdigit() and value.count("-") == 2: # Date-like - try: - return datetime.strptime(value, '%Y-%m-%d').date() - except ValueError as e: - raise DateParseError(value) from e - if value.isdigit() or (value.startswith('-') and value[1:].isdigit()): - return int(value) - if value.replace('.', '').isdigit() and value.count('.') == 1: - return float(value) - if value.lower() in ('true', 'false'): - return value.lower() == 'true' - return value - - @staticmethod - def parse_node_definition(definition: str) -> tuple[str, list[Field], str | None]: - """ - Parse node type definition, for example:\n - '''\n - node Person\n - name: string\n - age: int\n - '''\n - - :param definition: Node type definition string in Graphite DSL - - :return: node type name, fields, parent type name - """ - lines = definition.strip().split('\n') - - if len(lines) == 0: - raise ParseError("Empty node definition, expected at least one line.", 1, 0) - - first_line = lines[0].strip() - - if not first_line.startswith('node '): - raise ParseError( - "Invalid node definition, expected 'node ...' pattern", - 1, - 0 - ) - - # Parse inheritance - if ' from ' in first_line: - parts = first_line.split(' from ') - - if len(parts) != 2: - raise ParseError( - "Invalid node definition, expected 'node from ' pattern", - 1, - first_line.find(' from ') - ) - - node_name = parts[0].replace('node ', '', 1).strip() - parent = parts[1].strip() - else: - node_name = first_line.replace('node ', '', 1).strip() - parent = None - - fields = [] - for line_number, field_line in enumerate(lines[1:]): - field_line = field_line.strip() - - if not field_line: - warnings.warn( - f"Line {line_number + 1} in node definition is empty, skipped.", - SyntaxWarning - ) - continue - - field_pair = field_line.split(':') - - if len(field_pair) != 2: - raise ParseError( - "Invalid field definition for node type, expected ': ' pattern", - line_number + 1, - 0 - ) - - name = field_pair[0].strip() - dtype_str = field_pair[1].strip() - - if not name or not dtype_str: - raise ParseError( - "Invalid field definition for node type, expected ': ' pattern", - line_number + 1, - 0 - ) - - try: - dtype = DataType(dtype_str) - except ValueError as e: - raise ParseError( - f"Invalid field data type: {dtype_str}", - line_number + 1, - field_line.find(":") + 1 - ) from e - - fields.append(Field(name, dtype)) - - return node_name, fields, parent - - # pylint: disable=too-many-locals, too-many-statements - @staticmethod - def parse_relation_definition( - definition: str - ) -> tuple[str, str, str, list[Field], str | None, bool]: - """ - Parse relation definition - - :param definition: Relation definition string in Graphite DSL - - :return: relation type name, source type name, target type name, fields, optional reverse - type name, is bidirectional or not - - :except ParseError: empty or invalid definition - """ - lines = definition.strip().split('\n') - - if len(lines) < 2: - raise ParseError("Empty relation definition, expected at least two line.", 1, 0) - - first_line = lines[0].strip() - - if not first_line.startswith('relation '): - raise ParseError( - "Invalid relation definition, expected 'relation ...' pattern", - 1, - 0 - ) - - # Check for 'both' keyword - is_bidirectional = ' both' in first_line - if is_bidirectional: - first_line = first_line.replace(' both', '') - - # Parse reverse - reverse_name = None - if ' reverse ' in first_line: - parts = first_line.split(' reverse ') - - if len(parts) != 2: - raise ParseError( - "Invalid relation definition, expected 'relation reverse ' pattern", - 1, - first_line.find(' reverse ') + 1 - ) - - relation_name = parts[0].replace('relation ', '', 1).strip() - reverse_name = parts[1].strip() - else: - relation_name = first_line.replace('relation ', '', 1).strip() - - if is_bidirectional and reverse_name: - raise RelationTypeDefineError(relation_name) - - # Parse participants - participants_line = lines[1].strip() - try: - if '->' in participants_line: - parts = participants_line.split('->') - from_type = parts[0].strip() - to_type = parts[1].strip() - elif '-' in participants_line: - participants_line = participants_line.replace('--', '-') - parts = participants_line.split('-') - if len(parts) != 2: - raise GraphiteError - from_type = parts[0].strip() - to_type = parts[-1].strip() - else: - raise GraphiteError - except GraphiteError as e: - raise ParseError( - "Invalid relation type format, expected ' -[-,>] ' pattern", - 2, - 0 - ) from e - - # Parse fields - fields = [] - for line_number, field_line in enumerate(lines[2:]): - field_line = field_line.strip() - if not field_line: - warnings.warn( - f"Line {line_number + 2} in relation definition is empty, skipped.", - SyntaxWarning - ) - continue - name_type = field_line.split(':') - - if len(name_type) != 2: - raise ParseError( - "Invalid field definition for relation type, expected ': ' pattern", - line_number + 2, - 0 - ) - - name = name_type[0].strip() - dtype_str = name_type[1].strip() - - if not name or not dtype_str: - raise ParseError( - "Invalid field definition for relation type, expected ': ' pattern", - line_number + 2, - 0 - ) - - try: - dtype = DataType(dtype_str) - except ValueError as e: - raise ParseError( - f"Invalid field data type: {dtype_str}", - line_number + 2, - field_line.find(":") + 1 - ) from e - - fields.append(Field(name, dtype)) - - return relation_name, from_type, to_type, fields, reverse_name, is_bidirectional - - @staticmethod - def parse_node_instance(line: str) -> tuple[str, str, list[Any]]: - """ - Parse node instance: 'User, user_1, "Joe Doe", 32, "joe4030"' - - :param line: node instance string in Graphite DSL - - :return: node type name, node id, parsed field values - """ - # Handle quoted strings - parts = [] - current = '' - in_quotes = False - for char in line: - if char == '"': - in_quotes = not in_quotes - current += char - elif char == ',' and not in_quotes: - parts.append(current.strip()) - current = '' - else: - current += char - if current: - parts.append(current.strip()) - - node_type = parts[0].strip() - node_id = parts[1].strip() - values = list(map(GraphiteParser.parse_value, parts[2:])) - - return node_type, node_id, values - - @staticmethod - def parse_relation_instance( - line: str - ) -> tuple[str, str, str, list[Any], str]: - """ - Parse relation instance: 'user_1 -[OWNER, 2000-10-04]-> notebook' - - :param line: relation instance string in Graphite DSL - - :return: source node id, target node id, relation type name, field values, direction - """ - # Extract relation type and attributes - pattern = r'(\w+)\s*(-\[([^\]]+)\]\s*[->-]\s*|\s*[->-]\s*\[([^\]]+)\]\s*->\s*)(\w+)' - match = re.search(pattern, line) - if not match: - raise ParseError(f"Invalid relation format: {line}") - - from_node = match.group(1) - to_node = match.group(5) - - # Get relation type and attributes - rel_part = match.group(3) or match.group(4) - rel_parts = [p.strip() for p in rel_part.split(',')] - rel_type = rel_parts[0] - attributes = list(map(GraphiteParser.parse_value, rel_parts[1:]) if len(rel_parts) > 1 else []) - - # Parse direction - if '->' in line: - direction = 'forward' - elif '-[' in line and ']-' in line: - direction = 'bidirectional' - else: - 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 7ad2786..b8a574b 100644 --- a/src/graphite/query.py +++ b/src/graphite/query.py @@ -1,6 +1,7 @@ """ Query engine and object for Graphite """ +import warnings from collections import defaultdict from collections.abc import Callable from enum import Enum @@ -9,7 +10,7 @@ from typing_extensions import deprecated -from .parser import GraphiteParser +from .dsl_parser import parse_value from .exceptions import ConditionError, NotFoundError from .instances import Node, Relation from .types import RelationType @@ -24,6 +25,8 @@ class Direction(Enum): BOTH = "both" # pylint: disable=too-many-public-methods +# Reason: This functions help users to reduce hacks, direct access, lines of code, +# error robustness. Providing a more advanced and optimized query system can fix this issue. class QueryResult: """ Represents a query result that can be chained @@ -106,8 +109,8 @@ def validate(self) -> 'QueryResult': """ return QueryResult( self.engine, - {node for node in self.nodes if node.id in self.engine.nodes}, - {relation for relation in self.edges if id(relation) in self.engine.relations}, + { node for node in self.nodes if node.id in self.engine.nodes }, + { relation for relation in self.edges if id(relation) in self.engine.relations }, ) def where(self, condition: str | Callable[[Node], bool]) -> 'QueryResult': @@ -137,7 +140,6 @@ def where(self, condition: str | Callable[[Node], bool]) -> 'QueryResult': return QueryResult(self.engine, filtered_nodes, self.edges) - # pylint: disable=too-many-branches @staticmethod def _evaluate_condition(target_nodes: set[Node], condition: str) -> set[Node]: """ @@ -150,28 +152,27 @@ def _evaluate_condition(target_nodes: set[Node], condition: str) -> set[Node]: :except ConditionError: if fail on executing condition """ - # Simple condition parser - ops = ['>=', '<=', '!=', '==', '>', '<', '='] - - for op in ops: + operators = { + '>=': lambda a, b: a >= b, + '<=': lambda a, b: a <= b, + '!=': lambda a, b: a != b, + '==': lambda a, b: a == b, + '=': lambda a, b: a == b, + '>': lambda a, b: a > b, + '<': lambda a, b: a < b, + } + + for op in sorted(operators.keys(), key=len, reverse=True): if op in condition: - left, right = condition.split(op) + left, right = condition.split(op, 1) left = left.strip() - right = GraphiteParser.parse_value(right.strip()) + right = parse_value(right.strip()) try: - if op in ('=', '=='): - return {n for n in target_nodes if n.get(left) is not None and n.get(left) == right} - if op == '!=': - return {n for n in target_nodes if n.get(left) is not None and n.get(left) != right} - if op == '>': - return {n for n in target_nodes if n.get(left) is not None and n.get(left) > right} - if op == '<': - return {n for n in target_nodes if n.get(left) is not None and n.get(left) < right} - if op == '>=': - return {n for n in target_nodes if n.get(left) is not None and n.get(left) >= right} - if op == '<=': - return {n for n in target_nodes if n.get(left) is not None and n.get(left) <= right} + return { + n for n in target_nodes + if n.get(left) is not None and operators[op](n.get(left), right) + } except TypeError as e: raise ConditionError(condition) from e @@ -191,9 +192,9 @@ def with_type(self, node_type: str, include_parent_types: bool = True) -> 'Query """ if include_parent_types: type_nodes = self.engine.get_nodes_of_type(node_type, True) - n = {node for node in self.nodes if node in type_nodes} + n = { node for node in self.nodes if node in type_nodes } else: - n = {node for node in self.nodes if node.type_name == node_type} + n = { node for node in self.nodes if node.type_name == node_type } return QueryResult(self.engine, n, self.edges) def with_fields(self, *fields: str) -> 'QueryResult': @@ -206,7 +207,7 @@ def with_fields(self, *fields: str) -> 'QueryResult': """ return QueryResult( self.engine, - {node for node in self.nodes if all(field in node.values for field in fields)}, + { node for node in self.nodes if all(field in node.values for field in fields) }, self.edges ) @@ -259,9 +260,7 @@ def traverse( else edge.from_node ) - target_node = self.engine.get_node(target_id) - if target_node: - result_nodes.add(target_node) + result_nodes.add(self.engine.nodes[target_id]) return QueryResult(self.engine, result_nodes, result_edges) @@ -314,11 +313,13 @@ def limit( """ return QueryResult( self.engine, - set(sorted( - self.nodes, - key=lambda node: node.get(order_by_field) if order_by_field else node.id, - reverse=descending - )[:n]), + set( + sorted( + self.nodes, + key=lambda node: node.get(order_by_field) if order_by_field else node.id, + reverse=descending + )[:n] + ), self.edges ) @@ -349,11 +350,13 @@ def paginate( end = start + per_page return QueryResult( self.engine, - set(sorted( - self.nodes, - key=lambda node: node.get(order_by_field) if order_by_field else node.id, - reverse=descending - )[start:end]), + set( + sorted( + self.nodes, + key=lambda node: node.get(order_by_field) if order_by_field else node.id, + reverse=descending + )[start:end] + ), self.edges, ) @@ -398,6 +401,10 @@ def distinct(self) -> 'QueryResult': :return: a new query with distinct nodes and original relations """ + warnings.warn( + "distinct() is deprecated and unnecessary", + DeprecationWarning + ) return self def order_by(self, by_field: str, descending: bool = False) -> list[Node]: @@ -413,6 +420,7 @@ def order_by(self, by_field: str, descending: bool = False) -> list[Node]: def get_key(from_node: Node) -> tuple[bool, Any]: val = from_node.get(by_field) return val is None, val + return sorted(self.nodes, key=get_key, reverse=descending) def sum(self, field: str) -> float: @@ -462,7 +470,7 @@ def min(self, field: str) -> float: :raise TypeError: If there isn't any numeric value in given field """ - nodes = {n for n in self.nodes if isinstance(n.get(field), (float, int))} + nodes = { n for n in self.nodes if isinstance(n.get(field), (float, int)) } if not nodes: raise TypeError(f"There is no node with numeric value for field {field}!") return reduce( @@ -480,7 +488,7 @@ def max(self, field: str) -> float: :raise TypeError: If there isn't any numeric value in given field """ - nodes = {n for n in self.nodes if isinstance(n.get(field), (float, int))} + nodes = { n for n in self.nodes if isinstance(n.get(field), (float, int)) } if not nodes: raise TypeError(f"There is no node with numeric value for field {field}!") return reduce( @@ -502,7 +510,7 @@ def get(self) -> set[Node]: :return: set of nodes """ - return self.nodes + return self.nodes.copy() def group_by(self, field: str) -> dict[Any, set[Node]]: """ @@ -524,7 +532,7 @@ def relations(self) -> set[Relation]: :return: set of relations """ - return self.edges + return self.edges.copy() def first( self, @@ -551,7 +559,7 @@ def ids(self) -> set[str]: :return: list of node IDs """ - return {n.id for n in self.nodes} + return { n.id for n in self.nodes } class QueryBuilder: """Builder for creating queries""" diff --git a/src/graphite/serialization.py b/src/graphite/serialization.py index 0ee1b4e..5214407 100644 --- a/src/graphite/serialization.py +++ b/src/graphite/serialization.py @@ -3,11 +3,8 @@ """ 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 typing import Any from .exceptions import NotFoundError, ValidationError from .instances import Node, Relation @@ -20,74 +17,60 @@ def _serialize_node(node: Node) -> dict[str, Any]: return { GRAPHITE_TYPE_FIELD: "Node", - "type_name" : node.type_name, - "id" : node.id, - "values" : node.values, + "type_name": node.type_name, + "id": node.id, + "values": node.values, } def _serialize_relation(relation: Relation) -> dict[str, Any]: return { GRAPHITE_TYPE_FIELD: "Relation", - "type_name" : relation.type_name, - "from_node" : relation.from_node, - "to_node" : relation.to_node, - "values" : relation.values, + "type_name": relation.type_name, + "from_node": relation.from_node, + "to_node": relation.to_node, + "values": relation.values, } def _serialize_node_type(node_type: NodeType) -> dict[str, Any]: return { GRAPHITE_TYPE_FIELD: "NodeType", - "name" : node_type.name, - "parent" : node_type.parent.name if node_type.parent else None, - "fields" : node_type.fields, + "name": node_type.name, + "parent": node_type.parent.name if node_type.parent else None, + "fields": node_type.fields, } def _serialize_relation_type(relation_type: RelationType) -> dict[str, Any]: return { GRAPHITE_TYPE_FIELD: "RelationType", - "name" : relation_type.name, - "from_type" : relation_type.from_type, - "to_type" : relation_type.to_type, - "fields" : relation_type.fields, - "reverse_name" : relation_type.reverse_name, - "is_bidirectional" : relation_type.is_bidirectional, + "name": relation_type.name, + "from_type": relation_type.from_type, + "to_type": relation_type.to_type, + "fields": relation_type.fields, + "reverse_name": relation_type.reverse_name, + "is_bidirectional": relation_type.is_bidirectional, } class GraphiteJSONEncoder(json.JSONEncoder): """Custom JSON encoder for Graphite data structures""" # pylint: disable=too-many-return-statements + # Reason: Returns reduce complexity of branching, and branches are necessary to handle all + # supported data types. def default(self, o: Any) -> Any: # Handle date/datetime objects if isinstance(o, (date, datetime)): return { - GRAPHITE_TYPE_FIELD: "datetime", - "value" : o.isoformat(), - "is_date" : isinstance(o, date) + GRAPHITE_TYPE_FIELD: "date", + "value": o.isoformat() } - # Handle DataType enum specifically (must come before Enum) + # Handle DataType enum if isinstance(o, DataType): return { GRAPHITE_TYPE_FIELD: "datatype", - "value" : o.value + "value": o.value } - # Handle Enum objects - if isinstance(o, Enum): - return { - GRAPHITE_TYPE_FIELD: "enum", - "enum_class" : type(o).__name__, - "value" : o.value - } - - # Handle defaultdict - if isinstance(o, defaultdict): - result = dict(o) - result[GRAPHITE_TYPE_FIELD] = "defaultdict" - result[DEFAULT_FACTORY_FIELD] = o.default_factory.__name__ if o.default_factory else None - return result - # Handle Node and Relation instances if isinstance(o, Node): return _serialize_node(o) @@ -104,23 +87,17 @@ def default(self, o: Any) -> Any: if isinstance(o, Field): return { GRAPHITE_TYPE_FIELD: "Field", - "name" : o.name, - "dtype" : o.dtype, + "name": o.name, + "dtype": o.dtype, } - # Handle dataclasses - if is_dataclass(o) and not isinstance(o, type): - # Convert to dict and add type info - result = asdict(o) - result[GRAPHITE_TYPE_FIELD] = type(o).__name__ - return result - if isinstance(o, (dict, list)): return o return super().default(o) -# pylint: disable=too-many-return-statements, too-many-branches +# pylint: disable=too-many-return-statements +# Reason: As described in GraphiteJSONEncode.default(). def graphite_object_hook(dct: dict[str, Any]) -> Any: """Decode Graphite-specific objects from JSON.""" if GRAPHITE_TYPE_FIELD not in dct: @@ -128,33 +105,12 @@ def graphite_object_hook(dct: dict[str, Any]) -> Any: graphite_type = dct.pop(GRAPHITE_TYPE_FIELD) - if graphite_type == "datetime": - value = dct["value"] - if dct.get("is_date"): - return date.fromisoformat(value) - return datetime.fromisoformat(value) - - if graphite_type == "enum": - enum_class = dct["enum_class"] - value = dct["value"] - if enum_class == "DataType": - return DataType(value) - return dct + if graphite_type in ("datetime", "date"): + return datetime.strptime(dct["value"], "%Y-%m-%d").date() if graphite_type == "datatype": return DataType(dct["value"]) - if graphite_type == "defaultdict": - factory_name = dct.pop(DEFAULT_FACTORY_FIELD, None) - factory: Callable[[], Any] | None = None - if factory_name == "list": - factory = list - elif factory_name == "dict": - factory = dict - result: dict[str, factory] = defaultdict(factory) - result.update(dct) - return result - if graphite_type == "Node": return Node( type_name=dct["type_name"], @@ -173,11 +129,11 @@ def graphite_object_hook(dct: dict[str, Any]) -> Any: ) if graphite_type == "NodeType": - return { - "name": dct["name"], - "fields": dct.get("fields", []), - "parent": dct.get("parent") - } + return NodeType( + dct["name"], + dct.get("fields", []), + dct.get("parent") + ) if graphite_type == "RelationType": return RelationType( @@ -195,7 +151,7 @@ def graphite_object_hook(dct: dict[str, Any]) -> Any: dtype=DataType(dct["dtype"]) ) - return dct + raise TypeError(f"Unknown graphite type: {graphite_type}") def _validate_loaded_data(data: dict[str, Any]) -> None: """ @@ -207,14 +163,7 @@ def _validate_loaded_data(data: dict[str, Any]) -> 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') + required_keys = ('version', 'node_types', 'relation_types', 'nodes', 'relations') for key in required_keys: if key not in data: raise ValidationError( @@ -248,7 +197,7 @@ def _validate_loaded_data(data: dict[str, Any]) -> None: "nodes", str(type(data.get('nodes'))) ) - if 'relations' in data and not isinstance(data.get('relations'), list): + if not isinstance(data.get('relations'), list): raise ValidationError( "relations must be a list", "relations", @@ -257,7 +206,7 @@ def _validate_loaded_data(data: dict[str, Any]) -> None: # 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') + '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) @@ -265,24 +214,61 @@ def _validate_loaded_data(data: dict[str, Any]) -> None: # 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']) + 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)) - ) + type_name = check_node.type_name if type_name not in node_type_names: raise NotFoundError( "Node type", type_name, ) + +def _load_from_dict( + data: dict[str, Any] +) -> tuple[dict[str, NodeType], dict[str, RelationType], dict[str, Node], dict[int, Relation]]: + """ + Internal method to load from dictionary (used by both load and load_safe) + + :param data: Dictionary of loaded data + + :return: None + """ + node_types_data = data.get('node_types', []) + relation_types_data = data.get('relation_types', []) + nodes_data = data.get('nodes', []) + relations_data = data.get('relations', []) + + node_types: dict[str, NodeType] = { } + relation_types: dict[str, RelationType] = { } + nodes: dict[str, Node] = { } + relations: dict[int, Relation] = { } + + # Restore node types + for nt in node_types_data: + node_types[nt.name] = nt + + # Restore parent references for node types + for nt in node_types_data: + parent_name = nt.parent + name = nt.name + if parent_name and parent_name in node_types and name in node_types: + node_types[name].parent = node_types[parent_name] + + # Restore relation types + for rt in relation_types_data: + relation_types[rt.name] = rt + + # Restore nodes + for node in nodes_data: + # Restore type reference + node.type_ref = node_types[node.type_name] + nodes[node.id] = node + + # Restore relations + for rel in relations_data: + # Restore type reference + rel.type_ref = relation_types[rel.type_name] + relations[id(rel)] = rel + + return node_types, relation_types, nodes, relations diff --git a/src/graphite/types.py b/src/graphite/types.py index 1430444..ec168fc 100644 --- a/src/graphite/types.py +++ b/src/graphite/types.py @@ -51,7 +51,7 @@ def get_all_fields(self) -> list[Field]: """Get all fields including inherited ones""" if self.all_fields_cache is not None: return self.all_fields_cache - fields = self.fields.copy() + fields = self.fields.copy() if self.fields is not None else [] if self.parent: fields = self.parent.get_all_fields() + fields self.all_fields_cache = fields diff --git a/src/graphite/utils.py b/src/graphite/utils.py index 61ac622..74302cb 100644 --- a/src/graphite/utils.py +++ b/src/graphite/utils.py @@ -27,12 +27,7 @@ def relation(name: str, from_type: str, to_type: str, **kwargs: Any) -> str: direction = "->" if not kwargs.get('both') else "-" lines.append(f"{from_type} {direction} {to_type}") - for field_name, field_type in kwargs.get('fields', {}).items(): + for field_name, field_type in kwargs.get('fields', { }).items(): lines.append(f"{field_name}: {field_type}") return "\n".join(lines) - -def engine() -> 'GraphiteEngine': - """Create graphite engine instance""" - from .engine import GraphiteEngine # pylint: disable=import-outside-toplevel - return GraphiteEngine() diff --git a/tests/benchmark.py b/tests/benchmark.py index 577b1e9..80cbb43 100644 --- a/tests/benchmark.py +++ b/tests/benchmark.py @@ -16,8 +16,7 @@ import sys import time from datetime import date -from typing import Any -from typing import Annotated +from typing import Annotated, Any try: from pympler import asizeof @@ -33,6 +32,7 @@ # Add parent directory to path (assumes benchmark is inside the package or next to it) sys.path.insert(0, os.path.abspath('..')) from src.graphite import DataType, Field, NodeType, GraphiteEngine + print("Source of Graphite found, using latest dev version") except ImportError: print("Using installed Graphite version...") @@ -56,7 +56,7 @@ def timed_call(func, *args, _iterations: int, _setup=None, **kwargs) -> dict[str elapsed = time.perf_counter() - start times.append(elapsed) if not times: - return {"mean": 0.0, "min": 0.0, "max": 0.0, "stdev": 0.0, "iterations": 0} + return { "mean": 0.0, "min": 0.0, "max": 0.0, "stdev": 0.0, "iterations": 0 } return { "mean": statistics.mean(times), "min": min(times), @@ -88,12 +88,12 @@ def n(num: float) -> str: # Data generation for benchmarks # --------------------------------------------------------------------------- # pylint: disable=too-many-locals +# Reason: Building a customized engine with low-level API needs current complexity. def create_benchmark_engine( num_node_types: int, num_relation_types: int, num_nodes: int, - num_relations: int, - inheritance_depth: int, + num_relations: int ) -> GraphiteEngine: """ Build an engine with a synthetic schema and populate it with nodes and relations. @@ -104,23 +104,18 @@ def create_benchmark_engine( # Define node types with a simple inheritance chain t = tqdm(total=4, desc="Creating benchmark engine", leave=False) for i in range(num_node_types): - parent = None - if inheritance_depth > 1 and i > 0: - # 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}" + - (f" from {parent}" if parent else "") + "\n" + "\n".join([f"{field[0]}: {field[1]}" for field in fields]) ) @@ -129,7 +124,7 @@ def create_benchmark_engine( # Define relation types for i in range(num_relation_types): from_type = f"NodeType{i % num_node_types}" - to_type = f"NodeType{(i+1) % num_node_types}" + to_type = f"NodeType{(i + 1) % num_node_types}" fields = [ ("weight", "float"), ("label", "string"), @@ -160,9 +155,11 @@ def create_benchmark_engine( values: dict[str, Any] = { f"int_field_{j}": int_vals[j] for j in range(3) } - values.update({ - f"str_field_{j}": str_vals[j] for j in range(2) - }) + values.update( + { + f"str_field_{j}": str_vals[j] for j in range(2) + } + ) values["float_field"] = float_val values["date_field"] = date_val values["bool_field"] = bool_val @@ -177,7 +174,7 @@ def create_benchmark_engine( rel_type_obj = engine.relation_types[rel_type_name] from_id = next(iter(engine.node_by_type[rel_type_obj.from_type])) to_id = next(iter(engine.node_by_type[rel_type_obj.to_type])) - values = {"weight": float(r % 100) / 100.0, "label": f"edge_{r}"} + values = { "weight": float(r % 100) / 100.0, "label": f"edge_{r}" } engine.create_relation(from_id, to_id, rel_type_name, *values.values()) t.update() t.close() @@ -187,18 +184,20 @@ def create_benchmark_engine( # --------------------------------------------------------------------------- # Benchmark class # --------------------------------------------------------------------------- -class GraphiteBenchmarks: # pylint: disable=too-many-instance-attributes +# pylint: disable=too-many-instance-attributes +# Reason: Additional attributes provide pre-calculated values based on configuration. +class GraphiteBenchmarks: """Collection of micro-benchmarks for Graphite.""" # pylint: disable=too-many-arguments, too-many-positional-arguments + # Reason: This is actually 5 (correct) def __init__( self, size: int, runs: int, node_types: int, relation_types: int, - relations_ratio: float, - inheritance_depth: int, + relations_ratio: float ): self.size = size self.hsize = n(size) @@ -207,8 +206,7 @@ def __init__( self.relation_types = relation_types self.relations_ratio = relations_ratio self.relations_count = int(size * relations_ratio) - self.inheritance_depth = inheritance_depth - self.results: dict[str, Any] = {} + self.results: dict[str, Any] = { } def _run_benchmark(self, name: str, func, *args, _setup=None, **kwargs): """Execute a timed call and store the result.""" @@ -222,8 +220,7 @@ def create_default_engine(self): self.node_types, self.relation_types, self.size, - self.relations_count, - self.inheritance_depth, + self.relations_count ) def benchmark_all(self): @@ -250,7 +247,7 @@ def define_node_types(): for i in range(n_types): parent = None if i % 5 == 0 and i > 0: - parent = f"node_type_{i-1}" + parent = f"node_type_{i - 1}" fields = "int_field: int\nstr_field: string\nfloat_field: float" if parent: definition = f"node node_type_{i} from {parent}\n{fields}" @@ -266,10 +263,13 @@ def define_relation_types(): eng = GraphiteEngine() # Predefine a few node types for i in range(min(n_types, 10)): - eng.node_types[f"node_type_{i}"] = NodeType(f"node_type_{i}", [Field("x", DataType.INT)]) + eng.node_types[f"node_type_{i}"] = NodeType( + f"node_type_{i}", + [Field("x", DataType.INT)] + ) for i in range(max(1, n_types // 2)): from_t = f"node_type_{i % 10}" - to_t = f"node_type_{(i+1) % 10}" + to_t = f"node_type_{(i + 1) % 10}" definition = f"relation Rel_{i}\n{from_t} -> {to_t}\nweight: float" eng.define_relation(definition) @@ -285,7 +285,7 @@ def benchmark_node_creation(self): """Create nodes in an already-defined engine.""" # Build a tiny engine with schema to reuse engine = create_benchmark_engine( - self.node_types, self.relation_types, 0, 0, 1 + self.node_types, self.relation_types, 0, 0 ) t = tqdm(total=1, desc="Benchmark: Node Creation", leave=False) @@ -293,9 +293,11 @@ def benchmark_node_creation(self): def create_many_nodes(): # Create nodes of a specific type for i in trange(self.size, desc="Creating nodes", leave=False): - engine.create_node("NodeType0", f"tmp_node_{i}", - i, i*2+1, i*3, f"str_{i}", f"data_{i}", - float(i)/10.0, date(2023, 1, 1), True) + engine.create_node( + "NodeType0", f"tmp_node_{i}", + i, i * 2 + 1, i * 3, f"str_{i}", f"data_{i}", + float(i) / 10.0, date(2023, 1, 1), True + ) def setup_clean(): # Remove previously created nodes (but keep schema) @@ -306,14 +308,18 @@ def setup_clean(): engine.relations.clear() gc.collect() - self._run_benchmark(f"node_creation(n: {n(self.size)})", create_many_nodes, _setup=setup_clean) + self._run_benchmark( + f"node_creation(n: {n(self.size)})", + create_many_nodes, + _setup=setup_clean + ) t.close() # ---------- Relation creation ---------- def benchmark_relation_creation(self): """Benchmark creating relation instances""" engine = create_benchmark_engine( - self.node_types, self.relation_types, self.size, 0, 1 + self.node_types, self.relation_types, self.size, 0 ) t = tqdm(total=2, desc="Benchmark: Relation Creation", leave=False) @@ -325,13 +331,17 @@ def create_relations(): # Just select valid node types src_n = next(iter(engine.node_by_type[rel_type_obj.from_type])) tgt_n = next(iter(engine.node_by_type[rel_type_obj.to_type])) - engine.create_relation(src_n, tgt_n, rel_type, float(i)/100.0, f"edge_{i}") + engine.create_relation(src_n, tgt_n, rel_type, float(i) / 100.0, f"edge_{i}") def setup_clean(): engine.remove_relations(set(engine.relations.values())) gc.collect() - self._run_benchmark(f"relation_creation(r: {n(self.size)})", create_relations, _setup=setup_clean) + self._run_benchmark( + f"relation_creation(r: {n(self.size)})", + create_relations, + _setup=setup_clean + ) t.close() # ---------- Queries ---------- @@ -340,11 +350,10 @@ def benchmark_queries(self): t = tqdm(total=22, desc="Benchmark: Queries", leave=False) engine = self.create_default_engine() - query = engine.query - all_nodes_result = query.all() + all_nodes_result = engine.query.all() limited = all_nodes_result.limit(100) remove_result = all_nodes_result.limit(100) - other = query.all().where("int_field_0 < 200") + other = engine.query.all().where("int_field_0 < 200") other_operation_size = n(limited.union(other).count()) def query_where_string(): @@ -371,10 +380,10 @@ def query_both(): "NodeType0", True ), ( f"query_get_relations_from(n: {n(self.size)})", engine.get_relations_from, - f"node_{self.size//2}" + f"node_{self.size // 2}" ), ( f"query_get_relations_to(n: {n(self.size)})", engine.get_relations_to, - f"node_{self.size//2}" + f"node_{self.size // 2}" ), ( f"query_where_string(n: {n(self.size)})", query_where_string ), ( @@ -511,11 +520,11 @@ def benchmark_dsl_parsing(self): # instances for i in range(1, size // 2, 2): dsl_lines.append(f"NodeA, node_{i}, {i}, \"example\"") - dsl_lines.append(f"NodeB, node_{i+1}, {i}.5") + dsl_lines.append(f"NodeB, node_{i + 1}, {i}.5") for i in range(1, size // 2, 2): src_n = f"node_{i}" - tgt_n = f"node_{i+1}" - dsl_lines.append(f"{src_n} -[REL_A, {i/100.0}]-> {tgt_n}") + tgt_n = f"node_{i + 1}" + dsl_lines.append(f"{src_n} -[REL_A, {i / 100.0}]-> {tgt_n}") dsl_text = "\n".join(dsl_lines) @@ -535,7 +544,7 @@ def benchmark_memory(self): engine = self.create_default_engine() engine_size = asizeof.asizeof(engine) if engine: - engine = None + engine.clear() self.results[f"memory_overhead(n: {n(size)}, r: {n(size // 2)})"] = { "size_bytes": engine_size, "size_human": human_bytes(engine_size), @@ -558,7 +567,9 @@ def generate_report(bench: GraphiteBenchmarks, dump_json: bool = False) -> str: lines.append(" " * 45 + "GRAPHITE BENCHMARK REPORT") lines.append(" " * 45 + f"Size factor: {n(bench.size)}, Runs: {bench.runs}") lines.append("=" * width) - lines.append("| Metric " + " " * 49 + "| Avg | Min | Max | StDev |") + lines.append( + "| Metric " + " " * 49 + "| Avg | Min | Max | StDev |" + ) lines.append("|" + "-" * 57 + "|" + ("-" * 13 + "|") * 4) memory_info = None @@ -567,8 +578,8 @@ def generate_report(bench: GraphiteBenchmarks, dump_json: bool = False) -> str: mean_s = stats["mean"] mean_ms = mean_s * 1000 lines.append( - f"| {name:<55} | {mean_ms:8.3f} ms | {stats['min']*1000:8.3f} ms | " - f"{stats['max']*1000:8.3f} ms | {stats['stdev']*1000:8.3f} ms |" + f"| {name:<55} | {mean_ms:8.3f} ms | {stats['min'] * 1000:8.3f} ms | " + f"{stats['max'] * 1000:8.3f} ms | {stats['stdev'] * 1000:8.3f} ms |" ) elif isinstance(stats, dict) and "size_bytes" in stats: memory_info = ( @@ -591,14 +602,16 @@ def generate_report(bench: GraphiteBenchmarks, dump_json: bool = False) -> str: # --------------------------------------------------------------------------- # CLI entry point # --------------------------------------------------------------------------- -# pylint: disable=too-many-arguments, too-many-positional-arguments, unused-argument + +# pylint: disable=unused-argument, too-many-locals, too-many-arguments +# pylint: disable=too-many-positional-arguments +# Reason: As CLI entry point, complexity of this function is natural. def main( size: Annotated[int, typer.Option(help="Database size (nodes)")] = 100000, runs: Annotated[int, typer.Option(help="Runs to benchmark")] = 10, - node_types: Annotated[int, typer.Option(help="Number of node types")] = 5, - relation_types: Annotated[int, typer.Option(help="Number of relation types")] = 3, + node_types: Annotated[int, typer.Option(help="Amount node types")] = 5, + relation_types: Annotated[int, typer.Option(help="Amount relation types")] = 3, relations_ratio: Annotated[float, typer.Option(help="Relation ratio")] = 0.5, - inheritance_depth: Annotated[int, typer.Option(help="Inheritance depth on node types")] = 1, dump_json: Annotated[ bool, typer.Option("--json", help="Output to JSON instead of table") @@ -607,31 +620,31 @@ def main( on_schema: Annotated[ bool, typer.Option(help="Benchmark node type and relation type definitions") - ] = None, # ty: ignore[invalid-parameter-default] + ] = None, # ty: ignore[invalid-parameter-default] on_node_creation: Annotated[ bool, typer.Option(help="Benchmark node creation") - ] = None, # ty: ignore[invalid-parameter-default] + ] = None, # ty: ignore[invalid-parameter-default] on_relation_creation: Annotated[ bool, typer.Option(help="Benchmark relation creation") - ] = None, # ty: ignore[invalid-parameter-default] + ] = None, # ty: ignore[invalid-parameter-default] on_queries: Annotated[ bool, typer.Option(help="Benchmark queries") - ] = None, # ty: ignore[invalid-parameter-default] + ] = None, # ty: ignore[invalid-parameter-default] on_serialization: Annotated[ bool, typer.Option(help="Benchmark save and load") - ] = None, # ty: ignore[invalid-parameter-default] + ] = None, # ty: ignore[invalid-parameter-default] on_dsl_parse: Annotated[ bool, typer.Option(help="Benchmark a complete DSL parsing") - ] = None, # ty: ignore[invalid-parameter-default] + ] = None, # ty: ignore[invalid-parameter-default] on_memory: Annotated[ bool, typer.Option(help="Benchmark memory usage") - ] = None, # ty: ignore[invalid-parameter-default] + ] = None, # ty: ignore[invalid-parameter-default] ): """ Advanced benchmark suite for Graphite embedded graph database @@ -642,7 +655,6 @@ def main( node_types, relation_types, relations_ratio, - inheritance_depth, ) benchmarks = { @@ -658,7 +670,8 @@ def main( benchmarks_to_run = { name: func for name, func in benchmarks.items() - if (run_all and (locals()[name] is None or locals()[name])) or (not run_all and locals()[name]) + if + (run_all and (locals()[name] is None or locals()[name])) or (not run_all and locals()[name]) } n_benchmarks = len(benchmarks_to_run) diff --git a/tests/benchmark_result.md b/tests/benchmark_result.md index c726697..122f3a7 100644 --- a/tests/benchmark_result.md +++ b/tests/benchmark_result.md @@ -1,6 +1,7 @@ # Graphite Benchmark Report ## System Configuration + - Python: 3.12 - OS: Windows 11 24H2 - CPU: Intel(R) Core(TM) i5-10210U CPU @ 1.60GHz @@ -8,6 +9,7 @@ - Storage: SSD ## Test Parameters + - Size factor: 100,000 nodes, 50,000 relations - Runs: 10 - Warm cache: Yes diff --git a/tests/conftest.py b/tests/conftest.py index af64f08..80b8043 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,86 +4,89 @@ import os import sys import tempfile -from datetime import date +import types from pytest import fixture -sys.path.insert(0, os.path.abspath('..')) -from src.graphite import GraphiteEngine # pylint: disable=wrong-import-position +try: + sys.path.insert(0, os.path.abspath('..')) + from src.graphite import GraphiteEngine, Node +except ImportError: + from graphite import GraphiteEngine, Node @fixture -def clean_engine(): +def clean_engine() -> GraphiteEngine: """Create a fresh GraphiteEngine instance""" engine = GraphiteEngine() - yield engine - # Cleanup if needed + return engine @fixture -def populated_engine(): - """Create engine with sample data""" +def simple_engine() -> GraphiteEngine: + """Create a simple GraphiteEngine instance with schema""" engine = GraphiteEngine() - # Define node types - engine.define_node( - """ - node Person - name: string - age: int - email: string - """ - ) - - engine.define_node( + engine.parse( """ - node Company - name: string - founded: date - employees: int - """ + node Person + name: string + + relation KNOWS + Person -> Person + """ ) - engine.define_node( - """ - node Project - title: string - budget: float - active: bool - """ - ) + return engine - # Define relation types - engine.define_relation( - """ - relation WORKS_AT - Person -> Company - position: string - since: date - """ - ) +@fixture +def populated_engine() -> GraphiteEngine: + """Create engine with sample data""" + engine = GraphiteEngine() - engine.define_relation( + engine.parse( """ - relation MANAGES - Person -> Project - role: string - """ + # Define node types + node Person + name: string + age: int + email: string + + node Company + name: string + founded: date + employees: int + + node Project + title: string + budget: float + active: bool + + # Define relation types + relation WORKS_AT + Person -> Company + position: string + since: date + + relation MANAGES + Person -> Project + role: string + + # Create nodes + Person, person1, Alice, 30, alice@email.com + Person, person2, Bob, 25, bob@email.com + Company, company1, TechCorp, 2020-1-1, 500 + Project, project1, Alpha, 100000.50, true + + # Create relations + person1 -[WORKS_AT, Engineer, 2021-03-15]-> company1 + person2 -[WORKS_AT, Manager, 2020-06-01]-> company1 + person1 -[MANAGES, Lead]-> project1 + """ ) - # Create nodes - engine.create_node("Person", "person1", "Alice", 30, "alice@email.com") - engine.create_node("Person", "person2", "Bob", 25, "bob@email.com") - engine.create_node("Company", "company1", "TechCorp", date(2020, 1, 1), 500) - engine.create_node("Project", "project1", "Alpha", 100000.50, True) - - # Create relations - engine.create_relation("person1", "company1", "WORKS_AT", "Engineer", date(2021, 3, 15)) - engine.create_relation("person2", "company1", "WORKS_AT", "Manager", date(2020, 6, 1)) - engine.create_relation("person1", "project1", "MANAGES", "Lead") - return engine @fixture -def temp_json_file(): +def temp_json_file() -> types.GeneratorType: """Create a temporary JSON file for testing""" with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f: temp_path = f.name @@ -99,34 +102,41 @@ def engine_with_inheritance(): """Create engine with inheritance hierarchy""" engine = GraphiteEngine() - engine.define_node( + engine.parse( """ - node Entity - id: string - created: date - """ + node Entity + id: string + created: date + + node User from Entity + username: string + password: string + active: bool + + node Admin from User + permissions: string + """ ) - engine.define_node( - """ - node User from Entity - username: string - password: string - active: bool - """ + engine.create_node("Entity", "ent1", "base_entity", "2023-01-01") + engine.create_node( + "User", "user1", "user_entity", "2023-02-01", + "john", "pass123", True ) - - engine.define_node( - """ - node Admin from User - permissions: string - """ + engine.create_node( + "Admin", "admin1", "admin_entity", "2023-03-01", + "admin", "admin123", True, "all" ) - engine.create_node("Entity", "ent1", "base_entity", "2023-01-01") - engine.create_node("User", "user1", "user_entity", "2023-02-01", - "john", "pass123", True) - engine.create_node("Admin", "admin1", "admin_entity", "2023-03-01", - "admin", "admin123", True, "all") - return engine + +@fixture +def alice_node(): + """Returns a node with Person type, person1 ID, name = Alice, age = 30, without type + reference.""" + return Node( + type_name="Person", + id="person1", + values={ "name": "Alice", "age": 30 }, + type_ref=None + ) diff --git a/tests/test_integration_basic.py b/tests/test_integration_basic.py index 47ebaeb..41330db 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,11 +102,7 @@ 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\nPerson - Person\nsince: date") engine.create_node("Person", "p1", "Alice") engine.create_node("Person", "p2", "Bob") @@ -142,13 +138,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" @@ -162,18 +158,18 @@ def test_edge_cases(self, clean_engine): engine.create_node("EmptyType", "empty1") assert engine.get_node("empty1") is not None - assert engine.get_node("empty1").values == {} + assert engine.get_node("empty1").values == { } # 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 bf8a5e0..7ebd457 100644 --- a/tests/test_integration_dsl.py +++ b/tests/test_integration_dsl.py @@ -31,6 +31,7 @@ def test_load_complete_dsl(self, clean_engine): relation WORKS_AT Person -> Company position: string + # Must support in-block comments since: date relation MANAGES @@ -174,7 +175,8 @@ def test_load_dsl_multiline_definitions(self, clean_engine): """Test loading DSL with multiline definitions""" engine = clean_engine - dsl = """node Person + dsl = """ + node Person name: string age: int email: string diff --git a/tests/test_integration_persistence.py b/tests/test_integration_persistence.py index 1213e5b..622a751 100644 --- a/tests/test_integration_persistence.py +++ b/tests/test_integration_persistence.py @@ -3,13 +3,16 @@ """ import os import warnings +from unittest.mock import mock_open, patch import pytest from src.graphite import GraphiteEngine -from src.graphite.exceptions import FileSizeError, InvalidJSONError, SafeLoadExtensionError +from src.graphite.exceptions import ( + FileSizeError, InvalidJSONError, SafeLoadExtensionError, + TooNestedJSONError, +) -# noinspection PyDeprecation class TestPersistenceIntegration: """Test persistence integration scenarios""" @@ -137,31 +140,18 @@ def test_round_trip_complex_data(self, clean_engine, temp_json_file): engine = clean_engine # Create complex schema - engine.define_node( - """ - node User - name: string - metadata: string - scores: string - """ - ) + engine.define_node("node User\nname: string\nmetadata: string\nscores: string") - engine.define_node( - """ - node Group - name: string - settings: string - """ - ) + engine.define_node("node Group\nname: string\nsettings: 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 @@ -231,3 +221,13 @@ def test_multiple_save_load_cycles(self, clean_engine, temp_json_file): assert len(engine3.nodes) == 2 assert engine3.get_node("t1")["value"] == "Cycle1" assert engine3.get_node("t2")["value"] == "Cycle2" + + def test_too_nested_json_handling(self, clean_engine, temp_json_file): + """Test TooNestedJSON handling""" + engine = clean_engine + mock_file = mock_open(read_data='{"a": {"b": {"c": "d"}}}') + + with patch('builtins.open', mock_file): + with patch('json.load', side_effect=RecursionError("mock recursion error")): + with pytest.raises(TooNestedJSONError): + engine.load(temp_json_file) diff --git a/tests/test_unit_algorithms.py b/tests/test_unit_algorithms.py index c391967..9ddf751 100644 --- a/tests/test_unit_algorithms.py +++ b/tests/test_unit_algorithms.py @@ -31,17 +31,9 @@ def test_bfs_simple_path(self, populated_engine): assert path[0][0].type_name == "WORKS_AT" assert path[0][1].id == "company1" - def test_bfs_multiple_paths(self, clean_engine): + def test_bfs_multiple_paths(self, simple_engine): """Test BFS finding multiple paths""" - engine = clean_engine - - engine.define_node("node Person\nname: string") - engine.define_relation( - """ - relation KNOWS - Person -> Person - """ - ) + engine = simple_engine engine.create_node("Person", "p1", "Alice") engine.create_node("Person", "p2", "Bob") @@ -82,17 +74,9 @@ def test_bfs_no_path(self, populated_engine): assert len(result) == 0 - def test_bfs_max_depth(self, clean_engine): + def test_bfs_max_depth(self, simple_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 = simple_engine engine.create_node("Person", "p1", "Alice") engine.create_node("Person", "p2", "Bob") @@ -139,12 +123,12 @@ def test_bfs_with_callable_end(self, populated_engine): engine = populated_engine # Find any node with name "TechCorp" - def is_techcorp(path): + def is_tech_corp(path): return path and path[-1][1].get("name") == "TechCorp" result = engine.bfs( start="person1", - end=is_techcorp, + end=is_tech_corp, direction=Direction.OUTGOING, stop_at_first=True ) @@ -168,20 +152,12 @@ def test_bfs_incoming_direction(self, populated_engine): assert len(result) == 2 # Both person1 and person2 work at company1 - node_ids = {r[0] for r in result} - assert node_ids == {"person1", "person2"} + node_ids = { r[0] for r in result } + assert node_ids == { "person1", "person2" } - def test_bfs_bidirectional_direction(self, clean_engine): + def test_bfs_bidirectional_direction(self, simple_engine): """Test BFS with both directions""" - engine = clean_engine - - engine.define_node("node Person\nname: string") - engine.define_relation( - """ - relation KNOWS - Person -> Person - """ - ) + engine = simple_engine engine.create_node("Person", "p1", "Alice") engine.create_node("Person", "p2", "Bob") @@ -198,20 +174,12 @@ def test_bfs_bidirectional_direction(self, clean_engine): max_depth=1 ) - node_ids = {r[0] for r in result} - assert node_ids == {"p2", "p3"} + node_ids = { r[0] for r in result } + assert node_ids == { "p2", "p3" } - def test_bfs_direction_switch(self, clean_engine): + def test_bfs_direction_switch(self, simple_engine): """Test BFS with direction switch""" - engine = clean_engine - - engine.define_node("node Person\nname: string") - engine.define_relation( - """ - relation KNOWS - Person -> Person - """ - ) + engine = simple_engine engine.create_node("Person", "p1", "Alice") engine.create_node("Person", "p2", "Bob") @@ -243,7 +211,7 @@ def test_bfs_visited_set(self, populated_engine): end=None, direction=Direction.OUTGOING, relation_type="WORKS_AT", - visited={"company1"}, + visited={ "company1" }, max_depth=1 ) @@ -297,15 +265,9 @@ def test_bfs_max_results(self, populated_engine): assert len(result) == 1 - def test_bfs_stop_at_first_no_direction_switch(self, clean_engine): + def test_bfs_stop_at_first_no_direction_switch(self, simple_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 = simple_engine engine.create_node("Person", "p1", "Alice") engine.create_node("Person", "p2", "Bob") @@ -351,18 +313,8 @@ def test_shortest_path_indirect(self, 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.define_relation("relation WORKS_AT\nPerson -> Company") + engine.define_relation("relation MANAGED_BY\nPerson -> Person") engine.create_node("Person", "p1", "Alice") engine.create_node("Person", "p2", "Bob") @@ -407,12 +359,12 @@ def test_shortest_path_with_callable_end(self, populated_engine): """Test shortest path with callable end condition""" engine = populated_engine - def is_techcorp(_path): + def is_tech_corp(_path): return _path and _path[-1][1].get("name") == "TechCorp" result = engine.shortest_path( from_node="person1", - to_end=is_techcorp, + to_end=is_tech_corp, direction=Direction.OUTGOING ) @@ -529,23 +481,14 @@ def test_connected_components_all_nodes(self, 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 + # Should have one component + assert len(components) == 1 + # The main component should have 4 nodes + assert len(components[0]) == 4 - def test_connected_components_specific_nodes(self, clean_engine): + def test_connected_components_specific_nodes(self, simple_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 = simple_engine engine.create_node("Person", "p1", "Alice") engine.create_node("Person", "p2", "Bob") @@ -557,29 +500,21 @@ def test_connected_components_specific_nodes(self, clean_engine): engine.create_relation("p3", "p4", "KNOWS") components = engine.connected_components( - nodes={"p1", "p3"}, + 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"} + 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"} + assert p3_component == { "p3", "p4" } - def test_connected_components_single_node(self, clean_engine): + def test_connected_components_single_node(self, simple_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 = simple_engine engine.create_node("Person", "p1", "Alice") engine.create_node("Person", "p2", "Bob") @@ -597,19 +532,11 @@ def test_connected_components_single_node(self, clean_engine): assert len(components) == 1 # Find the component containing p1 - assert components[0] == {"p1", "p2"} + assert components[0] == { "p1", "p2" } - def test_connected_components_invalid_node(self, clean_engine): + def test_connected_components_invalid_node(self, simple_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 = simple_engine engine.create_node("Person", "p1", "Alice") engine.create_node("Person", "p2", "Bob") @@ -622,7 +549,7 @@ def test_connected_components_invalid_node(self, clean_engine): with pytest.raises(NotFoundError): engine.connected_components( - nodes={"p5"}, + nodes={ "p5" }, return_all_nodes=True ) @@ -636,7 +563,7 @@ def test_connected_components_isolated_nodes(self, populated_engine): engine.create_node("Isolated", "iso2", "Isolated") components = engine.connected_components( - nodes={"person1", "iso1", "iso2"}, + nodes={ "person1", "iso1", "iso2" }, return_all_nodes=True ) @@ -652,24 +579,16 @@ def test_connected_components_ignore_nodes(self, populated_engine): components = engine.connected_components( nodes=None, return_all_nodes=True, - ignore_nodes={"person1"} + 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): + def test_connected_components_return_all_nodes_false(self, simple_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 = simple_engine engine.create_node("Person", "p1", "Alice") engine.create_node("Person", "p2", "Bob") @@ -679,7 +598,7 @@ def test_connected_components_return_all_nodes_false(self, clean_engine): # p3 is isolated components = engine.connected_components( - nodes={"p1", "p3"}, + nodes={ "p1", "p3" }, return_all_nodes=False ) @@ -687,9 +606,38 @@ def test_connected_components_return_all_nodes_false(self, clean_engine): 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"} + assert p1_component == { "p1" } p3_component = next(c for c in components if "p3" in c) - assert p3_component == {"p3"} + assert p3_component == { "p3" } + + def test_connected_components_visited_coverage(self, simple_engine): + """Test connected components visited feature""" + engine = simple_engine + + engine.parse( + """ + Person, p1, Person1 + Person, p2, Person2 + Person, p3, Person3 + Person, p4, Person4 + """ + ) + + engine.parse( + """ + p1 -[KNOWS]-> p2 + p1 -[KNOWS]-> p3 + p1 -[KNOWS]-> p4 + """ + ) + + result = engine.connected_components( + None, + True, + ) + + assert len(result) == 1 + class TestNeighborhood: @@ -706,7 +654,7 @@ def test_neighborhood_depth_1(self, populated_engine): ) # Should include person1 and all direct neighbors - node_ids = {n.id for n, _ in nodes} + node_ids = { n.id for n, _ in nodes } assert "person1" in node_ids assert "company1" in node_ids assert "project1" in node_ids @@ -743,7 +691,7 @@ def test_neighborhood_depth_2(self, clean_engine): direction=Direction.OUTGOING ) - node_ids = {n.id for n, _ in nodes} + node_ids = { n.id for n, _ in nodes } assert "p1" in node_ids assert "p2" in node_ids assert "c1" in node_ids @@ -771,7 +719,7 @@ def filter_company(path): direction=Direction.OUTGOING ) - node_ids = {n.id for n, _ in nodes} + 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 @@ -806,7 +754,7 @@ def test_neighborhood_incoming_direction(self, populated_engine): relation_type="WORKS_AT" ) - node_ids = {n.id for n, _ in nodes} + node_ids = { n.id for n, _ in nodes } assert "company1" in node_ids assert "person1" in node_ids assert "person2" in node_ids @@ -819,24 +767,16 @@ def test_neighborhood_ignore_nodes(self, populated_engine): start="person1", max_distance=1, direction=Direction.OUTGOING, - ignore_nodes={"company1"} + ignore_nodes={ "company1" } ) - node_ids = {n.id for n, _ in nodes} + 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): + def test_neighborhood_duplicates(self, simple_engine): """Test neighborhood with duplicates""" - engine = clean_engine - - engine.define_node("node Person\nname: string") - engine.define_relation( - """ - relation KNOWS - Person -> Person - """ - ) + engine = simple_engine engine.create_node("Person", "p1", "Alice") engine.create_node("Person", "p2", "Bob") @@ -854,11 +794,11 @@ def test_neighborhood_duplicates(self, clean_engine): # p3 in 2 path nodes, relations = engine.neighborhood( start="p1", - direction=Direction.BOTH # Catch reverses + direction=Direction.BOTH # Catch reverses ) - assert len(nodes) == 3 # p1, p2, p3 - assert len(relations) == 4 # All relations + assert len(nodes) == 3 # p1, p2, p3 + assert len(relations) == 4 # All relations class TestAlgorithmEdgeCases: @@ -885,17 +825,9 @@ def test_bfs_with_nonexistent_end(self, populated_engine): assert len(result) == 0 - def test_bfs_cycle_detection(self, clean_engine): + def test_bfs_cycle_detection(self, simple_engine): """Test BFS handles cycles correctly""" - engine = clean_engine - - engine.define_node("node Person\nname: string") - engine.define_relation( - """ - relation KNOWS - Person -> Person - """ - ) + engine = simple_engine engine.create_node("Person", "p1", "Alice") engine.create_node("Person", "p2", "Bob") @@ -915,14 +847,13 @@ def test_bfs_cycle_detection(self, clean_engine): ) # Should find all nodes - node_ids = {r[0] for r in result} - assert node_ids == {"p2", "p3"} + node_ids = { r[0] for r in result } + assert node_ids == { "p2", "p3" } - def test_neighborhood_empty_graph(self, clean_engine): + def test_neighborhood_empty_graph(self, simple_engine): """Test neighborhood on empty graph""" - engine = clean_engine + engine = simple_engine - engine.define_node("node Person\nname: string") engine.create_node("Person", "p1", "Alice") nodes, relations = engine.neighborhood( @@ -931,7 +862,7 @@ def test_neighborhood_empty_graph(self, clean_engine): ) assert len(nodes) == 1 - assert nodes == {(engine.get_node("p1"), 0)} + assert nodes == { (engine.get_node("p1"), 0) } assert len(relations) == 0 def test_connected_components_empty(self, clean_engine): diff --git a/tests/test_unit_engine.py b/tests/test_unit_engine.py index 9bc510f..26dbaf4 100644 --- a/tests/test_unit_engine.py +++ b/tests/test_unit_engine.py @@ -5,7 +5,11 @@ import pytest -from src.graphite.exceptions import FieldError, InvalidPropertiesError, NotFoundError +from src.graphite import DataType, Node, Relation +from src.graphite.exceptions import ( + FieldError, InvalidPropertiesError, InvalidRelationError, + NotFoundError, ParseError, RelationTypeDefineError +) class TestGraphiteEngineSchema: """Test GraphiteEngine schema definition methods""" @@ -14,42 +18,26 @@ def test_define_node_simple(self, clean_engine): """Test defining a simple node type""" engine = clean_engine - engine.define_node( - """ - node Person - name: string - age: int - """ - ) + engine.define_node("node Person\nname: string\nage: int") assert "Person" in engine.node_types node_type = engine.node_types["Person"] assert node_type.name == "Person" assert len(node_type.fields) == 2 assert node_type.fields[0].name == "name" + assert node_type.fields[0].dtype == DataType.STRING assert node_type.fields[1].name == "age" + assert node_type.fields[1].dtype == DataType.INT def test_define_node_with_inheritance(self, clean_engine): """Test defining node type with inheritance""" engine = clean_engine # Define parent first - engine.define_node( - """ - node Entity - id: string - created: date - """ - ) + engine.define_node("node Entity\nid: string\ncreated: date") # Define child - engine.define_node( - """ - node User from Entity - username: string - password: string - """ - ) + engine.define_node("node User from Entity\nusername: string\npassword: string") assert "User" in engine.node_types user_type = engine.node_types["User"] @@ -69,16 +57,36 @@ def test_define_node_inheritance_parent_not_found(self, clean_engine): engine = clean_engine with pytest.raises(NotFoundError) as exc_info: - engine.define_node( - """ - node User from NonExistent - username: string - """ - ) + engine.define_node("node User from NonExistent\nusername: string") assert "Parent node type" in str(exc_info.value) assert "NonExistent" in str(exc_info.value) + def test_define_node_no_dsl(self, clean_engine): + """Test defining node type without DSL""" + engine = clean_engine + + engine.define_node("Person", ("name", "string")) + + assert "Person" in engine.node_types + + def test_define_node_no_dsl_no_field(self, clean_engine): + """Test defining node type without DSL and any field""" + engine = clean_engine + + engine.define_node("Any") + + assert "Any" in engine.node_types + + def test_define_node_invalid_field_type(self, clean_engine): + """Test defining node type with invalid data type""" + engine = clean_engine + + with pytest.raises(NotFoundError) as exc_info: + engine.define_node("node Person\nname: String") + + assert "Data type" in str(exc_info.value) + def test_define_relation_simple(self, clean_engine): """Test defining a simple relation type""" engine = clean_engine @@ -88,12 +96,7 @@ def test_define_relation_simple(self, clean_engine): engine.define_node("node Company") engine.define_relation( - """ - relation WORKS_AT - Person -> Company - position: string - since: date - """ + "relation WORKS_AT\nPerson -> Company\nposition: string\nsince: date" ) assert "WORKS_AT" in engine.relation_types @@ -112,12 +115,7 @@ def test_define_relation_with_reverse(self, clean_engine): engine.define_node("node Person") engine.define_node("node Company") - engine.define_relation( - """ - relation WORKS_AT reverse EMPLOYS - Person -> Company - """ - ) + engine.define_relation("relation WORKS_AT reverse EMPLOYS\nPerson -> Company") assert "WORKS_AT" in engine.relation_types assert "EMPLOYS" in engine.relation_types @@ -138,31 +136,227 @@ def test_define_relation_bidirectional(self, clean_engine): engine.define_node("node Person") - engine.define_relation( - """ - relation FRIENDS_WITH both - Person - Person - since: date - """ - ) + engine.define_relation("relation FRIENDS_WITH both\nPerson - Person\nsince: date") rel_type = engine.relation_types["FRIENDS_WITH"] assert rel_type.is_bidirectional is True - def test_define_relation_node_type_not_found(self, clean_engine): + def test_define_relation_node_type_not_found(self, simple_engine): """Test defining relation with non-existent node types""" - engine = clean_engine + engine = simple_engine with pytest.raises(NotFoundError) as exc_info: + engine.define_relation("relation WORKS_AT\nInvalid -> Company") + + assert "Node type" in str(exc_info.value) + + with pytest.raises(NotFoundError) as exc_info: + engine.define_relation("relation WORKS_AT\nPerson -> Invalid") + + assert "Node type" in str(exc_info.value) + + def test_define_relation_no_dsl(self, clean_engine): + """Test defining relation type without DSL""" + engine = clean_engine + + engine.define_node("Person", ("name", "string")) + engine.define_relation("KNOWS", "Person", "Person", ("name", "string")) + + assert "KNOWS" in engine.relation_types + + def test_define_relation_no_dsl_validation(self, clean_engine): + """Test relation type validation without DSL""" + engine = clean_engine + + with pytest.raises(ParseError) as exc_info: + engine.define_relation("KNOWS", None, "Person") + + assert "Source and target node type is required" in str(exc_info.value) + + with pytest.raises(ParseError) as exc_info: + engine.define_relation("KNOWS", "Person", None) + + assert "Source and target node type is required" in str(exc_info.value) + + with pytest.raises(RelationTypeDefineError): engine.define_relation( - """ - relation WORKS_AT - Person -> Company - """ + "KNOWS", + "Person", + "Person", + reverse_name="OTHER_KNOWS", + is_bidirectional=True ) + + def test_define_relation_invalid_field_type(self, clean_engine): + """Test defining relation type with invalid data type""" + engine = clean_engine + + engine.define_node("Person") + + with pytest.raises(NotFoundError) as exc_info: + engine.define_relation("relation KNOWS\nPerson -> Person\nsince: Date") + + assert "Data type" in str(exc_info.value) + + def test_undefine_node(self, populated_engine): + """Test undefine_node""" + engine = populated_engine + + engine.undefine_node("Person") + + assert "Person" not in engine.node_types + assert "WORKS_AT" not in engine.relation_types + assert "MANAGES" not in engine.relation_types + assert "person1" not in engine.nodes + assert "person2" not in engine.nodes + assert engine.relations == { } + + def test_undefine_node_invalid_type(self, clean_engine): + """Test undefine_node with invalid type""" + engine = clean_engine + + with pytest.raises(NotFoundError) as exc_info: + engine.undefine_node("Person") + assert "Node type" in str(exc_info.value) + def test_undefine_node_inheritance(self, engine_with_inheritance): + """Test undefine_node with inheritance""" + engine = engine_with_inheritance + + engine.undefine_node("User") + + assert "Admin" not in engine.node_types + assert "User" not in engine.node_types + assert "Entity" in engine.node_types + + def test_undefine_node_isolated(self, populated_engine): + """Test undefine_node on isolated type""" + engine = populated_engine + + engine.define_node("node Isolated") + engine.create_node("Isolated", "isolated_node") + + engine.undefine_node("Isolated") + + assert "Isolated" not in engine.node_types + assert "isolated_node" not in engine.nodes + + def test_undefine_relation(self, simple_engine): + """Test undefine_relation""" + engine = simple_engine + + engine.undefine_relation("KNOWS") + + assert engine.relation_types == { } + + def test_undefine_relation_with_reverse(self, simple_engine): + """Test undefine_relation with reverse name""" + engine = simple_engine + + engine.define_node("node Company") + engine.define_relation("relation WORKS_AT reverse EMPLOYS\nPerson -> Company") + + assert "WORKS_AT" in engine.relation_types + assert "EMPLOYS" in engine.relation_types + + engine.undefine_relation("WORKS_AT") + + assert "WORKS_AT" not in engine.relation_types + assert "EMPLOYS" not in engine.relation_types + + def test_undefine_relation_invalid(self, clean_engine): + """Test undefine_relation with invalid type""" + engine = clean_engine + + with pytest.raises(NotFoundError) as exc_info: + engine.undefine_relation("KNOWS") + + assert "Relation type" in str(exc_info.value) + + +class TestGraphiteEngineDataUtils: + """Test GraphiteEngine data utilities""" + + def test_is_node_from_type(self, simple_engine): + """Test is_node_from_type""" + engine = simple_engine + + engine.create_node("Person", "person", "Alice") + + assert engine.is_node_from_type("person", "Person") is True + + def test_is_node_from_type_inheritance(self, simple_engine): + """Test is_node_from_type with inheritance""" + engine = simple_engine + + engine.define_node("node User from Person\nusername: string") + engine.define_node("node Admin from User") + engine.create_node("Admin", "admin", "Bob", "manager_bob") + + assert engine.is_node_from_type("admin", "User") is True + assert engine.is_node_from_type("admin", "Person") is True + + def test_is_node_from_type_invalid_node(self, simple_engine): + """Test is_node_from_type with invalid node""" + engine = simple_engine + + with pytest.raises(NotFoundError) as exc_info: + engine.is_node_from_type("person1", "Person") + + assert "Node" in str(exc_info.value) + + def test_is_node_from_type_invalid_type(self, simple_engine): + """Test is_node_from_type with invalid node type""" + engine = simple_engine + + engine.create_node("Person", "person1", "Alice") + + with pytest.raises(NotFoundError) as exc_info: + engine.is_node_from_type("person1", "Admin") + + assert "Node type" in str(exc_info.value) + + def test_get_nodes_of_type(self, engine_with_inheritance): + """Test get_nodes_of_type""" + engine = engine_with_inheritance + + assert len(engine.get_nodes_of_type("Entity")) == 3 + assert len(engine.get_nodes_of_type("User")) == 2 + assert len(engine.get_nodes_of_type("Admin")) == 1 + + def test_get_nodes_of_type_no_subtype(self, engine_with_inheritance): + """Test get_nodes_of_type with no subtype""" + engine = engine_with_inheritance + + assert len(engine.get_nodes_of_type("Entity", False)) == 1 + assert len(engine.get_nodes_of_type("User", False)) == 1 + assert len(engine.get_nodes_of_type("Admin", False)) == 1 + + def test_get_nodes_of_type_invalid_type(self, clean_engine): + """Test get_nodes_of_type with invalid type""" + engine = clean_engine + + with pytest.raises(NotFoundError) as exc_info: + engine.get_nodes_of_type("Person") + + assert "Node type" in str(exc_info.value) + + def test_get_relations_from_to_invalid_type(self, populated_engine): + """Test get_relations_from/to with invalid relation type""" + engine = populated_engine + + with pytest.raises(NotFoundError) as exc_info_1: + engine.get_relations_to("person1", "INVALID") + + with pytest.raises(NotFoundError) as exc_info_2: + engine.get_relations_from("person1", "INVALID") + + assert "Relation type" in str(exc_info_1.value) + assert "Relation type" in str(exc_info_2.value) + + class TestGraphiteEngineDataManipulation: """Test GraphiteEngine data manipulation methods""" @@ -170,22 +364,15 @@ def test_create_node_simple(self, clean_engine): """Test creating a node""" engine = clean_engine - engine.define_node( - """ - node Person - name: string - age: int - email: string - """ - ) + engine.define_node("node Person\nname: string\nage: int\nemail: string") node = engine.create_node("Person", "person1", "Alice", 30, "alice@email.com") assert node.id == "person1" assert node.type_name == "Person" assert node.values == { - "name" : "Alice", - "age" : 30, + "name": "Alice", + "age": 30, "email": "alice@email.com" } assert "person1" in engine.nodes @@ -195,13 +382,7 @@ def test_create_node_with_date(self, clean_engine): """Test creating a node with date field""" engine = clean_engine - engine.define_node( - """ - node Event - title: string - event_date: date - """ - ) + engine.define_node("node Event\ntitle: string\nevent_date: date") node = engine.create_node("Event", "event1", "Conference", "2023-12-01") @@ -214,13 +395,7 @@ def test_create_node_invalid_date(self, clean_engine): """Test creating node with invalid date string""" engine = clean_engine - engine.define_node( - """ - node Event - title: string - event_date: date - """ - ) + engine.define_node("node Event\ntitle: string\nevent_date: date") with pytest.raises(FieldError): engine.create_node("Event", "event1", "Conference", "invalid-date") @@ -229,14 +404,7 @@ def test_create_node_wrong_property_count(self, clean_engine): """Test creating node with wrong number of properties""" engine = clean_engine - engine.define_node( - """ - node Person - name: string - age: int - email: string - """ - ) + engine.define_node("node Person\nname: string\nage: int\nemail: string") with pytest.raises(InvalidPropertiesError) as exc_info: engine.create_node("Person", "person1", "Alice", 30) @@ -261,12 +429,7 @@ def test_create_relation_simple(self, clean_engine): engine.define_node("node Person\nname: string") engine.define_node("node Company\nname: string") engine.define_relation( - """ - relation WORKS_AT - Person -> Company - position: string - since: date - """ + """relation WORKS_AT\nPerson -> Company\nposition: string\nsince: date""" ) engine.create_node("Person", "person1", "Alice") @@ -283,7 +446,7 @@ def test_create_relation_simple(self, clean_engine): assert relation.to_node == "company1" assert relation.values == { "position": "Engineer", - "since" : date(2021, 1, 1) + "since": date(2021, 1, 1) } # Check indexes @@ -298,13 +461,7 @@ def test_create_relation_bidirectional(self, clean_engine): engine = 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\nPerson - Person\nsince: date") engine.create_node("Person", "person1", "Alice") engine.create_node("Person", "person2", "Bob") @@ -331,6 +488,7 @@ def test_create_relation_node_not_found(self, clean_engine): engine.define_relation("relation WORKS_AT\nPerson -> Company") engine.create_node("Person", "person1", "Alice") + engine.create_node("Company", "company1") with pytest.raises(NotFoundError) as exc_info: engine.create_relation("person1", "non_existent", "WORKS_AT") @@ -338,6 +496,12 @@ def test_create_relation_node_not_found(self, clean_engine): assert "Node" in str(exc_info.value) assert "non_existent" in str(exc_info.value) + with pytest.raises(NotFoundError) as exc_info: + engine.create_relation("non_existent", "company1", "WORKS_AT") + + assert "Node" in str(exc_info.value) + assert "non_existent" in str(exc_info.value) + def test_create_relation_type_not_found(self, clean_engine): """Test creating relation of non-existent type""" engine = clean_engine @@ -353,6 +517,133 @@ def test_create_relation_type_not_found(self, clean_engine): assert "Relation type" in str(exc_info.value) + def test_create_relation_wrong_property_count(self, simple_engine): + """Test creating relation of wrong number of properties""" + engine = simple_engine + + engine.create_node("Person", "person1", "Alice") + engine.create_node("Person", "person2", "Bob") + + with pytest.raises(InvalidPropertiesError) as exc_info: + engine.create_relation("person1", "person2", "KNOWS", "additional value") + + assert "Expected 0 properties" in str(exc_info.value) + + def test_create_relation_wrong_node_type(self, simple_engine): + """Test creating relation of wrong node type""" + engine = simple_engine + + engine.define_node("node Company\nname: string") + + engine.create_node("Person", "person1", "Alice") + engine.create_node("Person", "person2", "Bob") + engine.create_node("Company", "company1", "TechCorp") + + with pytest.raises(InvalidRelationError) as exc_info: + engine.create_relation("person1", "company1", "KNOWS", "Engineer") + + assert "is invalid" in str(exc_info.value) + + def test_create_relation_with_node_type_inheritance(self, simple_engine): + """Test creating relation when node type contains subtype""" + engine = simple_engine + + engine.define_node("node Admin from Person\nusername: string") + + engine.create_node("Person", "person", "Alice") + engine.create_node("Admin", "admin", "Bob", "manager_bob") + + engine.create_relation("person", "admin", "KNOWS") + + def test_remove_nodes_single_node(self, populated_engine): + """Test remove_nodes with a single node object / ID""" + engine = populated_engine + + node = engine.get_node("person1") + + engine.remove_nodes(node) + engine.remove_nodes("person2") + + assert "person1" not in engine.nodes + assert "person2" not in engine.nodes + + def test_remove_nodes_invalid(self, clean_engine): + """Test remove_nodes with invalid nodes""" + engine = clean_engine + + with pytest.raises(NotFoundError) as exc_info: + engine.remove_nodes({ "person1", Node("Example", "id", { }) }) + + assert "Node" in str(exc_info.value) + + def test_remove_relations_list(self, populated_engine): + """Test remove_relations with a list of relations""" + engine = populated_engine + + engine.remove_relations(list(engine.relations.values())) + + assert engine.relations == { } + + def test_remove_relations_single(self, populated_engine): + """Test remove_relations with a single relation""" + engine = populated_engine + + engine.remove_relations(list(engine.relations.values())[0]) + + def test_remove_relation_invalid(self, clean_engine): + """Test remove_relations with invalid relations""" + engine = clean_engine + + relation = Relation("Relation", "node1", "node2", { }) + + with pytest.raises(NotFoundError) as exc_info: + engine.remove_relations(relation) + + assert "Relation" in str(exc_info.value) + + @pytest.mark.filterwarnings("ignore::DeprecationWarning") + def test_deprecated_remove_node(self, populated_engine): + """Test deprecated remove_node""" + engine = populated_engine + + engine.remove_node("person1") + + assert "person1" not in engine.nodes + + @pytest.mark.filterwarnings("ignore::DeprecationWarning") + def test_deprecated_remove_relation(self, simple_engine): + """Test deprecated remove_relation""" + engine = simple_engine + + engine.create_node("Person", "person1", "Alice") + engine.create_node("Person", "person2", "Bob") + relation = engine.create_relation("person1", "person2", "KNOWS") + + assert id(relation) in engine.relations + + engine.remove_relation(relation) + + assert id(relation) not in engine.relations + + @pytest.mark.filterwarnings("ignore::DeprecationWarning") + def test_deprecated_load_dsl(self, clean_engine): + """Test deprecated load_dsl() function""" + engine = clean_engine + + engine.load_dsl( + """ + node Person + name: string + + relation KNOWS + Person -> Person + """ + ) + + assert "Person" in engine.node_types + assert "KNOWS" in engine.relation_types + + class TestGraphiteEngineQueryMethods: """Test GraphiteEngine query methods""" @@ -380,8 +671,8 @@ def test_get_nodes_of_type(self, populated_engine): assert len(company_nodes) == 1 # Check node IDs - person_ids = {node.id for node in person_nodes} - assert person_ids == {"person1", "person2"} + person_ids = { node.id for node in person_nodes } + assert person_ids == { "person1", "person2" } def test_get_relations_from(self, populated_engine): """Test getting relations from a node""" diff --git a/tests/test_unit_instances.py b/tests/test_unit_instances.py index d41385b..06a485c 100644 --- a/tests/test_unit_instances.py +++ b/tests/test_unit_instances.py @@ -1,23 +1,23 @@ """ Unit tests for Node and Relation instances """ +from datetime import date + +import pytest + from src.graphite import DataType, Field, Node, NodeType, Relation, RelationType +from src.graphite.exceptions import NotFoundError class TestNode: """Test Node class""" - def test_node_creation(self): + def test_node_creation(self, alice_node): """Test creating a node""" - node = Node( - type_name="Person", - id="person1", - values={"name": "Alice", "age": 30}, - type_ref=None - ) + node = alice_node assert node.type_name == "Person" assert node.id == "person1" - assert node.values == {"name": "Alice", "age": 30} + assert node.values == { "name": "Alice", "age": 30 } assert node.type_ref is None def test_node_get_field(self): @@ -25,7 +25,7 @@ def test_node_get_field(self): node = Node( type_name="Person", id="person1", - values={"name": "Alice", "age": 30} + values={ "name": "Alice", "age": 30 } ) assert node.get("name") == "Alice" @@ -37,7 +37,7 @@ def test_node_repr(self): node = Node( type_name="Person", id="person1", - values={} + values={ } ) assert repr(node) == "Node(Person:person1)" @@ -54,7 +54,7 @@ def test_node_with_type_ref(self): node = Node( type_name="Person", id="person1", - values={"name": "Alice", "age": 30}, + values={ "name": "Alice", "age": 30 }, type_ref=node_type ) @@ -62,6 +62,47 @@ def test_node_with_type_ref(self): assert node.type_ref == node_type assert node.type_ref.name == "Person" + def test_node_set(self, simple_engine): + """Test node.set()""" + engine = simple_engine + + node = engine.create_node("Person", "person1", "Alice") + + assert "person1" in engine.nodes + assert node["name"] == "Alice" + + node.set("name", "Alicia") + + assert node["name"] == "Alicia" + + def test_node_set_invalid(self, simple_engine): + """Test node.set() with invalid field""" + engine = simple_engine + + node = engine.create_node("Person", "person1", "Alice") + + with pytest.raises(NotFoundError) as exc_info: + node.set("age", 13) + + assert "Field" in str(exc_info.value) + + def test_node_equality(self): + """Test node equality""" + node1 = Node("Person", "person1", { "name": "Alice" }) + node2 = Node("Person", "person1", { "name": "Bob" }) + node3 = Node("Person", "person2", { "name": "Alice" }) + + assert node1 == node2 + assert node1 != node3 + assert node2 != node3 + + def test_node_equality_invalid(self): + """Test node equality with invalid object""" + node = Node("Person", "person", { "name": "Alice" }) + + assert node != 12 + + class TestRelation: """Test Relation class""" @@ -71,14 +112,14 @@ def test_relation_creation(self): type_name="WORKS_AT", from_node="person1", to_node="company1", - values={"since": "2021-01-01", "position": "Engineer"}, + values={ "since": "2021-01-01", "position": "Engineer" }, type_ref=None ) assert relation.type_name == "WORKS_AT" assert relation.from_node == "person1" assert relation.to_node == "company1" - assert relation.values == {"since": "2021-01-01", "position": "Engineer"} + assert relation.values == { "since": "2021-01-01", "position": "Engineer" } assert relation.type_ref is None def test_relation_get_field(self): @@ -87,7 +128,7 @@ def test_relation_get_field(self): type_name="WORKS_AT", from_node="person1", to_node="company1", - values={"since": "2021-01-01", "position": "Engineer"} + values={ "since": "2021-01-01", "position": "Engineer" } ) assert relation.get("since") == "2021-01-01" @@ -100,7 +141,7 @@ def test_relation_repr(self): type_name="WORKS_AT", from_node="person1", to_node="company1", - values={} + values={ } ) assert repr(relation) == "Relation(WORKS_AT:person1->company1)" @@ -121,10 +162,57 @@ def test_relation_with_type_ref(self): type_name="WORKS_AT", from_node="person1", to_node="company1", - values={"since": "2021-01-01", "position": "Engineer"}, + values={ "since": "2021-01-01", "position": "Engineer" }, type_ref=rel_type ) assert relation.type_ref is not None assert relation.type_ref == rel_type assert relation.type_ref.name == "WORKS_AT" + + def test_relation_set(self, clean_engine): + """Test relation.set()""" + engine = clean_engine + + engine.define_node("node Person\nname: string") + engine.define_relation("relation KNOWS\nPerson -> Person\nsince: date") + + engine.create_node("Person", "person1", "Alice") + engine.create_node("Person", "person2", "Bob") + relation = engine.create_relation("person1", "person2", "KNOWS", "2021-01-01") + + assert id(relation) in engine.relations + assert relation["since"] == date(2021, 1, 1) + + relation.set("since", date(2050, 1, 1)) + + assert relation["since"] == date(2050, 1, 1) + + def test_relation_set_invalid(self, clean_engine): + """Test relation.set() with invalid field""" + engine = clean_engine + + engine.define_node("node Person\nname: string") + engine.define_relation("relation KNOWS\nPerson -> Person\nsince: date") + + engine.create_node("Person", "person1", "Alice") + engine.create_node("Person", "person2", "Bob") + relation = engine.create_relation("person1", "person2", "KNOWS", "2021-01-01") + + with pytest.raises(NotFoundError) as exc_info: + relation.set("age", 13) + + assert "Field" in str(exc_info.value) + + def test_relation_equality(self): + """Test relation equality""" + relation1 = Relation("KNOWS", "person1", "person2", { }) + relation2 = Relation("KNOWS", "person1", "person2", { }) + + assert relation1 != relation2 + + def test_relation_equality_invalid(self): + """Test relation equality with invalid object""" + relation_test = Relation("KNOWS", "person1", "person2", { }) + + assert relation_test != 12 diff --git a/tests/test_unit_migration.py b/tests/test_unit_migration.py index d2c973f..aa3a100 100644 --- a/tests/test_unit_migration.py +++ b/tests/test_unit_migration.py @@ -11,7 +11,6 @@ @pytest.mark.filterwarnings("ignore:'convert_pickle_to_json':PendingDeprecationWarning") @pytest.mark.filterwarnings("ignore:Loading from pickle file") -# noinspection PyTypeChecker class TestMigration: """Test Migration utility class""" @@ -24,14 +23,14 @@ def test_convert_pickle_to_json_success(self, populated_engine, temp_json_file): # Save engine data in pickle format (simulating old format) data = { - 'node_types' : engine.node_types, - 'relation_types' : engine.relation_types, - 'nodes' : engine.nodes, - 'relations' : engine.relations, - 'node_by_type' : dict(engine.node_by_type), + 'node_types': engine.node_types, + 'relation_types': engine.relation_types, + 'nodes': engine.nodes, + 'relations': engine.relations, + 'node_by_type': dict(engine.node_by_type), 'relations_by_type': dict(engine.relations_by_type), 'relations_by_from': dict(engine.relations_by_from), - 'relations_by_to' : dict(engine.relations_by_to) + 'relations_by_to': dict(engine.relations_by_to) } with open(pickle_file, 'wb') as f: @@ -47,7 +46,6 @@ def test_convert_pickle_to_json_success(self, populated_engine, temp_json_file): # Verify JSON file can be loaded converted_engine = GraphiteEngine() - # noinspection PyDeprecation converted_engine.load(json_file, safe_mode=True) # Compare statistics @@ -64,14 +62,14 @@ def test_convert_pickle_to_json_delete_original(self, populated_engine, temp_jso # Create pickle file data = { - 'node_types' : engine.node_types, - 'relation_types' : engine.relation_types, - 'nodes' : engine.nodes, - 'relations' : engine.relations, - 'node_by_type' : dict(engine.node_by_type), + 'node_types': engine.node_types, + 'relation_types': engine.relation_types, + 'nodes': engine.nodes, + 'relations': engine.relations, + 'node_by_type': dict(engine.node_by_type), 'relations_by_type': dict(engine.relation_types), 'relations_by_from': dict(engine.relations_by_from), - 'relations_by_to' : dict(engine.relations_by_to) + 'relations_by_to': dict(engine.relations_by_to) } with open(pickle_file, 'wb') as f: @@ -117,14 +115,14 @@ def test_detect_pickle_and_convert(self, populated_engine): engine = populated_engine data = { - 'node_types' : engine.node_types, - 'relation_types' : engine.relation_types, - 'nodes' : engine.nodes, - 'relations' : engine.relations, - 'node_by_type' : dict(engine.node_by_type), + 'node_types': engine.node_types, + 'relation_types': engine.relation_types, + 'nodes': engine.nodes, + 'relations': engine.relations, + 'node_by_type': dict(engine.node_by_type), 'relations_by_type': dict(engine.relations_by_type), 'relations_by_from': dict(engine.relations_by_from), - 'relations_by_to' : dict(engine.relations_by_to) + 'relations_by_to': dict(engine.relations_by_to) } with open(pickle_file, 'wb') as f: @@ -142,6 +140,7 @@ def test_detect_pickle_and_convert(self, populated_engine): # Run detection and conversion Migration.detect_pickle_and_convert_to_json(temp_dir, pattern="*.db") + Migration.detect_pickle_and_convert_to_json(temp_dir, pattern="*.txt") # Check that JSON version was created expected_json = os.path.join(temp_dir, "test.json") diff --git a/tests/test_unit_parser.py b/tests/test_unit_parser.py index 760c1be..9004fb9 100644 --- a/tests/test_unit_parser.py +++ b/tests/test_unit_parser.py @@ -1,19 +1,52 @@ """ Unit tests for GraphiteParser """ -from datetime import datetime +from datetime import date, datetime import pytest -from src.graphite import DataType, GraphiteParser -from src.graphite.exceptions import ParseError +from src.graphite import DataType, Field +from src.graphite.dsl_parser import ( + parse, parse_node_definition, parse_node_instance, parse_relation_definition, + parse_relation_instance, parse_value, validate_field_value +) +from src.graphite.exceptions import ParseError, RelationTypeDefineError -class TestGraphiteParser: # pylint: disable=attribute-defined-outside-init - """Test GraphiteParser class""" +class TestParseSchema: + """Test parsing schema""" - def setup_method(self): - """Setup parser for each test""" - self.parser = GraphiteParser() + def test_parser_error_propagation(self, clean_engine): + """Test parser errors propagation""" + with pytest.raises(ParseError) as exc_info: + parse( + clean_engine, """ + node Person + + relation KNOWS + Person -> Person + + Person, person1 + Person, person2 + + person1 -[KNOWS]-> person2 + person1 -[KNOWS,]-> person3 + """ + ) + + assert "Additional comma in relation instance values" in str(exc_info.value) + assert exc_info.value.line == 10 + + with pytest.raises(ParseError) as exc_info: + parse( + clean_engine, """ + node Entity + + node Person from Human from Entity + """ + ) + + assert "Invalid node definition" in str(exc_info.value) + assert exc_info.value.line == 3 def test_parse_node_definition_simple(self): """Test parsing simple node definition""" @@ -24,18 +57,18 @@ def test_parse_node_definition_simple(self): email: string """ - node_name, fields, parent = self.parser.parse_node_definition(definition) + node_name, fields, parent = parse_node_definition(definition) assert node_name == "Person" assert parent is None assert len(fields) == 3 - assert fields[0].name == "name" - assert fields[0].dtype == DataType.STRING - assert fields[1].name == "age" - assert fields[1].dtype == DataType.INT - assert fields[2].name == "email" - assert fields[2].dtype == DataType.STRING + assert fields[0][0] == "name" + assert fields[0][1] == "string" + assert fields[1][0] == "age" + assert fields[1][1] == "int" + assert fields[2][0] == "email" + assert fields[2][1] == "string" def test_parse_node_definition_with_inheritance(self): """Test parsing node definition with inheritance""" @@ -45,27 +78,52 @@ def test_parse_node_definition_with_inheritance(self): password: string """ - node_name, fields, parent = self.parser.parse_node_definition(definition) + node_name, fields, parent = parse_node_definition(definition) assert node_name == "User" assert parent == "Entity" assert len(fields) == 2 - assert fields[0].name == "username" - assert fields[0].dtype == DataType.STRING - assert fields[1].name == "password" - assert fields[1].dtype == DataType.STRING + assert fields[0][0] == "username" + assert fields[0][1] == "string" + assert fields[1][0] == "password" + assert fields[1][1] == "string" def test_parse_node_definition_empty_fields(self): """Test parsing node definition without fields""" definition = "node Tag" - node_name, fields, parent = self.parser.parse_node_definition(definition) + node_name, fields, parent = parse_node_definition(definition) assert node_name == "Tag" assert parent is None assert len(fields) == 0 + def test_parse_node_definition_no_node_prefix(self): + """Test parsing node definition without 'node ' prefix""" + with pytest.raises(ParseError) as exc_info: + parse_node_definition("Person\nname: string") + + assert "Invalid node definition" in str(exc_info.value) + + def test_parse_node_definition_wrong_from_usage(self): + """Test parsing node definition with wrong usage of ' from '""" + with pytest.raises(ParseError) as exc_info: + parse_node_definition("node Person from Human from Entity\nname: string") + + assert "Invalid node definition" in str(exc_info.value) + + def test_parse_node_definition_empty_line(self): + """Test parsing node definition with empty line""" + with pytest.warns(SyntaxWarning): + type_name, fields, parent = parse_node_definition( + "node Person from Human\n\nname: string" + ) + + assert type_name == "Person" + assert fields == [("name", "string")] + assert parent == "Human" + def test_parse_relation_definition_simple(self): """Test parsing simple relation definition""" definition = """ @@ -76,7 +134,7 @@ def test_parse_relation_definition_simple(self): """ (rel_name, from_type, to_type, - fields, reverse_name, is_bidirectional) = self.parser.parse_relation_definition(definition) + fields, reverse_name, is_bidirectional) = parse_relation_definition(definition) assert rel_name == "WORKS_AT" assert from_type == "Person" @@ -85,10 +143,10 @@ def test_parse_relation_definition_simple(self): assert is_bidirectional is False assert len(fields) == 2 - assert fields[0].name == "position" - assert fields[0].dtype == DataType.STRING - assert fields[1].name == "since" - assert fields[1].dtype == DataType.DATE + assert fields[0][0] == "position" + assert fields[0][1] == "string" + assert fields[1][0] == "since" + assert fields[1][1] == "date" def test_parse_relation_definition_with_reverse(self): """Test parsing relation definition with reverse name""" @@ -99,7 +157,7 @@ def test_parse_relation_definition_with_reverse(self): """ (rel_name, from_type, to_type, - _, reverse_name, is_bidirectional) = self.parser.parse_relation_definition(definition) + _, reverse_name, is_bidirectional) = parse_relation_definition(definition) assert rel_name == "WORKS_AT" assert from_type == "Person" @@ -116,7 +174,7 @@ def test_parse_relation_definition_bidirectional(self): """ (rel_name, from_type, to_type, - _, reverse_name, is_bidirectional) = self.parser.parse_relation_definition(definition) + _, reverse_name, is_bidirectional) = parse_relation_definition(definition) assert rel_name == "FRIENDS_WITH" assert from_type == "Person" @@ -124,11 +182,76 @@ def test_parse_relation_definition_bidirectional(self): assert reverse_name is None assert is_bidirectional is True + def test_parse_relation_definition_single_line(self): + """Test parsing single line relation definition""" + with pytest.raises(ParseError) as exc_info: + parse_relation_definition("relation KNOWS") + + assert "expected at least two lines" in str(exc_info.value) + + def test_parse_relation_definition_no_relation_prefix(self): + """Test parsing relation definition with no 'relation ' prefix""" + with pytest.raises(ParseError) as exc_info: + parse_relation_definition("KNOWS\nPerson -> Person") + + assert "Invalid relation definition" in str(exc_info.value) + + def test_parse_relation_definition_wrong_reverse_usage(self): + """Test parsing relation definition with wrong usage of ' reverse '""" + with pytest.raises(ParseError) as exc_info: + parse_relation_definition("relation KNOWS reverse REL reverse OTHER\nPerson -> Person") + + assert "Invalid relation definition" in str(exc_info.value) + + def test_parse_relation_definition_bidirectional_with_reverse(self): + """Test parsing bidirectional relation definition with reverse name""" + with pytest.raises(RelationTypeDefineError): + parse_relation_definition("relation KNOWS both reverse REL\nPerson -> Person") + + def test_parse_relation_definition_invalid_pattern(self): + """Test parsing relation definition with invalid pattern""" + error_message = "Invalid relation type format" + + with pytest.raises(ParseError) as exc_info: + parse_relation_definition("relation KNOWS\nPerson > Person") + + assert error_message in str(exc_info.value) + + with pytest.raises(ParseError) as exc_info: + parse_relation_definition("relation KNOWS\nPerson -> Person -> Person") + + assert error_message in str(exc_info.value) + + with pytest.raises(ParseError) as exc_info: + parse_relation_definition("relation KNOWS\nPerson - - Person") + + assert error_message in str(exc_info.value) + + def test_parse_relation_definition_empty_line(self): + """Test parsing relation definition with empty line""" + with pytest.warns(SyntaxWarning, match="in relation definition is empty"): + parse_relation_definition("relation KNOWS\nPerson -> Person\n\nsince: date") + + def test_parse_fields_invalid_colons(self): + """Test parsing fields with invalid colons""" + with pytest.raises(ParseError) as exc_info: + parse_relation_definition("relation KNOWS\nPerson -> Person\nsince::") + + assert "Invalid field definition" in str(exc_info.value) + + with pytest.raises(ParseError) as exc_info: + parse_relation_definition("relation KNOWS\nPerson -> Person\nsince:") + + assert "Invalid field definition" in str(exc_info.value) + +class TestParseData: + """Test parsing data""" + def test_parse_node_instance_strings(self): """Test parsing node instance with strings""" line = 'User, user1, "Alice", 30, "alice@email.com"' - node_type, node_id, values = self.parser.parse_node_instance(line) + node_type, node_id, values = parse_node_instance(line) assert node_type == "User" assert node_id == "user1" @@ -138,7 +261,7 @@ def test_parse_node_instance_numbers(self): """Test parsing node instance with numbers""" line = "Product, prod1, 100, 19.99, true" - node_type, node_id, values = self.parser.parse_node_instance(line) + node_type, node_id, values = parse_node_instance(line) assert node_type == "Product" assert node_id == "prod1" @@ -148,7 +271,7 @@ def test_parse_node_instance_date(self): """Test parsing node instance with date""" line = 'Event, event1, "Conference", "2023-12-01"' - node_type, node_id, values = self.parser.parse_node_instance(line) + node_type, node_id, values = parse_node_instance(line) assert node_type == "Event" assert node_id == "event1" @@ -158,29 +281,45 @@ def test_parse_node_instance_negative_number(self): """Test parsing node instance with negative number""" line = "Temp, temp1, -10, 25.5" - node_type, node_id, values = self.parser.parse_node_instance(line) + node_type, node_id, values = parse_node_instance(line) assert node_type == "Temp" assert node_id == "temp1" assert values == [-10, 25.5] + def test_parse_node_instance_inner_quotes(self): + """Test parsing node instance with inner quotes""" + node_type, node_id, values = parse_node_instance( + "Person, person, \"'some test'\", '\"some text\"'" + ) + + assert node_type == "Person" + assert node_id == "person" + assert values == ["'some test'", '"some text"'] + + def test_parse_node_instance_invalid_comma(self): + """Test parsing node instance with invalid comma""" + with pytest.raises(ParseError) as exc_info: + parse_node_instance("Person, person, Alice,") + + assert "Additional comma in node instance values" in str(exc_info.value) + def test_parse_relation_instance_forward(self): """Test parsing forward relation instance""" line = "person1 -[WORKS_AT, Engineer, 2021-01-01]-> company1" - from_id, to_id, rel_type, values, direction = self.parser.parse_relation_instance(line) + from_id, to_id, rel_type, values = parse_relation_instance(line) assert from_id == "person1" assert to_id == "company1" assert rel_type == "WORKS_AT" assert values == ["Engineer", datetime.strptime("2021-01-01", "%Y-%m-%d").date()] - assert direction == "forward" def test_parse_relation_instance_no_attributes(self): """Test parsing relation instance without attributes""" line = "person1 -[LIKES]-> post1" - from_id, to_id, rel_type, values, _ = self.parser.parse_relation_instance(line) + from_id, to_id, rel_type, values = parse_relation_instance(line) assert from_id == "person1" assert to_id == "post1" @@ -191,17 +330,57 @@ def test_parse_relation_instance_bidirectional(self): """Test parsing bidirectional relation instance""" line = "person1 -[FRIENDS_WITH, 2020-05-15]- person2" - from_id, to_id, rel_type, values, direction = self.parser.parse_relation_instance(line) + from_id, to_id, rel_type, values = parse_relation_instance(line) assert from_id == "person1" assert to_id == "person2" assert rel_type == "FRIENDS_WITH" assert values == [datetime.strptime("2020-05-15", "%Y-%m-%d").date()] - assert direction == "bidirectional" def test_parse_invalid_relation_format(self): """Test parsing invalid relation format""" line = "invalid format without brackets" with pytest.raises(ParseError): - self.parser.parse_relation_instance(line) + parse_relation_instance(line) + + def test_parse_relation_instance_inner_quotes(self): + """Test parsing relation instance with inner quotes""" + source, target, rel_type, field_values = parse_relation_instance( + """ + person1 -[KNOWS, "'some test'", '"some text"']-> person2 + """.strip() + ) + + assert source == "person1" + assert target == "person2" + assert rel_type == "KNOWS" + assert field_values == ["'some test'", '"some text"'] + + def test_parse_relation_instance_invalid_comma(self): + """Test parsing relation instance with invalid comma""" + with pytest.raises(ParseError) as exc_info: + parse_relation_instance("person1 -[KNOWS,]-> person2") + + assert "Additional comma in relation instance values" in str(exc_info.value) + + +class TestParseFields: + """Test parsing fields for graphite""" + + def test_validate_auto_convert(self): + """Test automatic conversion in validation""" + assert validate_field_value(12, Field("", DataType.STRING)) == "12" + assert validate_field_value(12.9, Field("", DataType.INT)) == 12 + assert validate_field_value("2020-1-2", Field("", DataType.DATE)) == date(2020, 1, 2) + assert validate_field_value(12, Field("", DataType.FLOAT)) == 12.0 + assert validate_field_value("True", Field("", DataType.BOOL)) is True + assert validate_field_value(0, Field("", DataType.BOOL)) is False + assert validate_field_value( + datetime(2020, 1, 2, 10, 20, 54), + Field("", DataType.DATE) + ) == date(2020, 1, 2) + + def test_parse_invalid_date_like(self): + """Test parsing invalid date-like format""" + assert parse_value("100-2-1") == "100-2-1" diff --git a/tests/test_unit_query.py b/tests/test_unit_query.py index 5ecc4d7..bc2ce8c 100644 --- a/tests/test_unit_query.py +++ b/tests/test_unit_query.py @@ -4,7 +4,7 @@ import pytest from src.graphite import Direction -from src.graphite.exceptions import ConditionError +from src.graphite.exceptions import ConditionError, NotFoundError class TestQueryBuilder: """Test QueryBuilder class""" @@ -19,8 +19,8 @@ def test_query_builder_getattr(self, populated_engine): assert len(person_query.nodes) == 2 # Check we got correct nodes - node_ids = {node.id for node in person_query.nodes} - assert node_ids == {"person1", "person2"} + node_ids = { node.id for node in person_query.nodes } + assert node_ids == { "person1", "person2" } def test_query_builder_invalid_node_type(self, populated_engine): """Test accessing non-existent node type via QueryBuilder""" @@ -34,7 +34,7 @@ def test_query_builder_all_nodes(self, populated_engine): all_nodes_result = populated_engine.query.all() assert all_nodes_result.count() == 4 -class TestQueryResult: # pylint: disable=too-many-public-methods +class TestQueryResultBasic: """Test QueryResult class""" def test_query_result_creation(self, populated_engine): @@ -48,171 +48,53 @@ def test_query_result_creation(self, populated_engine): assert result.engine == engine assert result.edges == set() - def test_where_lambda(self, populated_engine): - """Test where with lambda condition""" - result = populated_engine.query.Person - - # Filter with lambda - filtered = result.where(lambda n: n["age"] > 25) - - assert len(filtered.nodes) == 1 - assert next(iter(filtered.get()))["name"] == "Alice" - - def test_where_string_condition(self, populated_engine): - """Test where with string condition""" - result = populated_engine.query.Person - - # Filter with string condition - filtered = result.where("age > 25") - - assert len(filtered.nodes) == 1 - assert next(iter(filtered.get()))["name"] == "Alice" - - def test_where_string_equality(self, populated_engine): - """Test where with equality condition""" - result = populated_engine.query.Person - - # Filter with equality - filtered = result.where('name = "Alice"') - - assert len(filtered.nodes) == 1 - assert next(iter(filtered.get()))["name"] == "Alice" - - # Alternative equality syntax - filtered2 = result.where('name == "Alice"') - assert len(filtered2.nodes) == 1 - - def test_where_string_inequality(self, populated_engine): - """Test where with inequality condition""" - result = populated_engine.query.Person - - filtered = result.where('name != "Alice"') - - assert len(filtered.nodes) == 1 - assert next(iter(filtered.get()))["name"] == "Bob" - - def test_where_comparison_operators(self, populated_engine): - """Test where with various comparison operators""" - result = populated_engine.query.Person + def test_with_type(self, engine_with_inheritance): + """Test with_type query""" + engine = engine_with_inheritance - # Greater than or equal - filtered_ge = result.where("age >= 30") - assert len(filtered_ge.nodes) == 1 + entity = engine.query.Entity + just_entity = entity.with_type("Entity", False).get() - # Less than - filtered_lt = result.where("age < 30") - assert len(filtered_lt.nodes) == 1 + assert len(entity.get()) != 1 + assert len(just_entity) == 1 + assert all(node.type_name == "Entity" for node in just_entity) - # Less than or equal - filtered_le = result.where("age <= 25") - assert len(filtered_le.nodes) == 1 + def test_with_type_with_parent_types(self, engine_with_inheritance): + """Test with_type query with parent types""" + engine = engine_with_inheritance - def test_where_invalid_condition(self, populated_engine): - """Test where with invalid condition string""" - result = populated_engine.query.Person - - with pytest.raises(ConditionError): - result.where("invalid condition format") + just_entity = engine.query.Entity.with_type("Entity", True).get() - def test_where_nonexistent_field(self, populated_engine): - """Test where condition with non-existent field""" - result = populated_engine.query.Person - - # Should return empty result, not raise error - filtered = result.where("nonexistent = 10") - assert len(filtered.nodes) == 0 + assert len(just_entity) == 3 + assert all(node.type_name in ("Entity", "User", "Admin") for node in just_entity) - def test_where_callable_error_is_wrapped(self, populated_engine): - """Test callable errors are wrapped as ConditionError.""" - result = populated_engine.query.Person - with pytest.raises(ConditionError): - result.where(lambda n: n["unknown"] > 1) - - def test_where_boolean_and_date_conditions(self, populated_engine): - """Test bool and date comparisons in string conditions.""" - active_projects = populated_engine.query.Project.where("active = true") - assert active_projects.count() == 1 - - founded_company = populated_engine.query.Company.where('founded = 2020-01-01') - assert founded_company.count() == 1 - assert founded_company.first()["founded"] == date(2020, 1, 1) - - def test_where_date_parse_error(self, populated_engine): - """Test invalid date string raises DateParseError.""" - result = populated_engine.query.Company.where('founded = 01/01/2020') - assert len(result.nodes) == 0 - - def test_traverse_outgoing(self, populated_engine): - """Test traversing outgoing relations""" - result = populated_engine.query.Person.where('name = "Alice"') - - # Traverse WORKS_AT relation - 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" - assert next(iter(works_at_result.nodes))["name"] == "TechCorp" - - # Check edges were captured - assert len(works_at_result.edges) == 1 - assert next(iter(works_at_result.edges)).type_name == "WORKS_AT" - - def test_outgoing_method(self, populated_engine): - """Test outgoing shortcut method""" - result = populated_engine.query.Person.where('name = "Alice"') - - works_at_result = result.outgoing("WORKS_AT") - - assert len(works_at_result.nodes) == 1 - assert next(iter(works_at_result.nodes)).type_name == "Company" - - def test_incoming_method(self, populated_engine): - """Test incoming shortcut method""" - # Get company and see who works there - result = populated_engine.query.Company - - employees_result = result.incoming("WORKS_AT") - - assert len(employees_result.nodes) == 2 - assert all(node.type_name == "Person" for node in employees_result.nodes) - - def test_both_method(self, populated_engine): - """Test both shortcut method""" - # For bidirectional relations, both would return all connected nodes - # Since we don't have bidirectional in sample, test with incoming+outgoing - result = populated_engine.query.Person.where('name = "Alice"') - - all_relations = result.both("WORKS_AT") - # Alice has outgoing WORKS_AT, no incoming WORKS_AT - assert len(all_relations.nodes) == 1 - - def test_both_method_mixed_incoming_and_outgoing(self, clean_engine): - """Test both traversal when a node has incoming and outgoing of same relation type""" + def test_with_fields(self, clean_engine): + """Test with_fields query""" engine = clean_engine - engine.define_node( - """ - node Person - name: string - """ - ) - engine.define_relation( - """ - relation KNOWS - Person -> Person - """ - ) + engine.define_node("node Manager\ndatabase_row: string") + engine.define_node("node Developer\ndatabase_row: string") + engine.define_node("node Designer\ndatabase_row: string") + engine.define_node("node Row\nname: string\nage: int") - engine.create_node("Person", "person1", "Alice") - engine.create_node("Person", "person2", "Bob") - engine.create_node("Person", "person3", "Charlie") + for i in range(3): + engine.create_node("Manager", f"manager{i}", f"row{i}") + engine.create_node("Developer", f"developer{i}", f"row{i}") + engine.create_node("Designer", f"designer{i}", f"row{i}") + engine.create_node("Row", f"row{i}", "Alice", 30 + i) - engine.create_relation("person1", "person2", "KNOWS") - engine.create_relation("person3", "person1", "KNOWS") + result = engine.query.all().with_fields("database_row").get() - related_nodes = engine.query.Person.where('name = "Alice"').both("KNOWS") + assert len(result) == 9 + assert all(node.type_name != "Row" for node in result) + + @pytest.mark.filterwarnings("ignore::DeprecationWarning") + def test_deprecated_distinct(self, populated_engine): + """Test deprecated distinct query""" + q = populated_engine.query.all().outgoing() + distinct = q.distinct() - assert set(related_nodes.ids()) == {"person2", "person3"} + assert distinct is q def test_limit(self, populated_engine): """Test limiting results""" @@ -292,13 +174,7 @@ def test_order_by_none_values(self, clean_engine): """Test ordering with None values""" engine = clean_engine - engine.define_node( - """ - node Item - name: string - priority: int - """ - ) + engine.define_node("node Item\nname: string\npriority: int") engine.create_node("Item", "item1", "A", 2) engine.create_node("Item", "item2", "B", None) @@ -311,14 +187,6 @@ def test_order_by_none_values(self, clean_engine): assert result[1]["name"] == "A" assert result[2]["name"] == "B" - def test_count(self, populated_engine): - """Test counting results""" - result = populated_engine.query.Person - - count = result.count() - - assert count == 2 - def test_union_exclude_intersect(self, populated_engine): """Test set-like query operations over nodes and relations.""" alice = populated_engine.query.Person.where('name = "Alice"') @@ -369,37 +237,298 @@ def test_ids(self, populated_engine): assert "person1" in ids assert "person2" in ids - def test_sum_avg_min_max_group_by(self, populated_engine): - """Test aggregate helpers and grouping.""" - people = populated_engine.query.Person - assert people.sum("age") == 55 - assert people.avg("age") == 27.5 - assert people.min("age") == 25 - assert people.max("age") == 30 - - grouped = people.group_by("age") - assert set(grouped.keys()) == {25, 30} - assert next(iter(grouped[30]))["name"] == "Alice" - 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 assert all(node.type_name == "Person" for node in complex_result.nodes) + + def test_set_val(self, populated_engine): + """Test set_val() query""" + result = populated_engine.query.Person.set_val(email="[masked]") + + assert all(node["email"] == "[masked]" for node in result.get()) + + def test_set_val_atomic(self, populated_engine): + """Test set_val query atomicity""" + q = populated_engine.query.all() + with pytest.raises(NotFoundError) as exc_info: + q.set_val(email="[masked]") + + assert "Field" in str(exc_info.value) + assert all(node["email"] != "[masked]" for node in q.get()) + +class TestQueryResultTraverse: + """Test traverse-included cases""" + + def test_traverse_outgoing(self, populated_engine): + """Test traversing outgoing relations""" + result = populated_engine.query.Person.where('name = "Alice"') + + # Traverse WORKS_AT relation + 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" + assert next(iter(works_at_result.nodes))["name"] == "TechCorp" + + # Check edges were captured + assert len(works_at_result.edges) == 1 + assert next(iter(works_at_result.edges)).type_name == "WORKS_AT" + + def test_traverse_invalid_type(self, populated_engine): + """Test traversing with invalid relation type""" + q = populated_engine.query.Person.where('name = Alice') + with pytest.raises(NotFoundError) as exc_info: + q.traverse("INVALID") + + assert "Relation type" in str(exc_info.value) + + def test_traverse_invalid_direction(self, populated_engine): + """Test traversing with invalid relation direction""" + q = populated_engine.query.Person.where('name = Alice') + with pytest.raises(NotImplementedError) as exc_info: + q.traverse("WORKS_AT", "invalid") + + assert "invalid" in str(exc_info.value) + + def test_outgoing_method(self, populated_engine): + """Test outgoing shortcut method""" + result = populated_engine.query.Person.where('name = "Alice"') + + works_at_result = result.outgoing("WORKS_AT") + + assert len(works_at_result.nodes) == 1 + assert next(iter(works_at_result.nodes)).type_name == "Company" + + def test_incoming_method(self, populated_engine): + """Test incoming shortcut method""" + # Get company and see who works there + result = populated_engine.query.Company + + employees_result = result.incoming("WORKS_AT") + + assert len(employees_result.nodes) == 2 + assert all(node.type_name == "Person" for node in employees_result.nodes) + + def test_both_method(self, populated_engine): + """Test both shortcut method""" + # For bidirectional relations `both()` would return all connected nodes. + # Since we don't have bidirectional in sample, test with incoming() + outgoing(). + result = populated_engine.query.Person.where('name = "Alice"') + + all_relations = result.both("WORKS_AT") + # Alice has outgoing WORKS_AT, no incoming WORKS_AT + assert len(all_relations.nodes) == 1 + + def test_both_method_mixed_incoming_and_outgoing(self, simple_engine): + """Test both traversal when a node has incoming and outgoing of same relation type""" + engine = simple_engine + + engine.create_node("Person", "person1", "Alice") + engine.create_node("Person", "person2", "Bob") + engine.create_node("Person", "person3", "Charlie") + + engine.create_relation("person1", "person2", "KNOWS") + engine.create_relation("person3", "person1", "KNOWS") + + related_nodes = engine.query.Person.where('name = "Alice"').both("KNOWS") + + assert set(related_nodes.ids()) == { "person2", "person3" } + +class TestQueryResultAggression: + """Test aggression queries""" + + def test_avg(self, clean_engine): + """Test avg query""" + engine = clean_engine + + engine.define_node("node Person\nage: int") + engine.parse( + """ + Person, p1, 30 + Person, p2, 28 + Person, p3, 30 + Person, p4, 24 + Person, p5, 21 + Person, p6, 22 + Person, p7, 27 + """ + ) + + result = engine.query.Person.avg("age") + + assert result == 26 + + def test_avg_non_numeric(self, simple_engine): + """Test avg query with non-numeric input""" + engine = simple_engine + + engine.parse( + """ + Person, p1, Alice + Person, p2, Bob + Person, p3, Cody + """ + ) + + with pytest.raises(TypeError) as exc_info: + engine.query.Person.avg("name") + + assert "numeric value" in str(exc_info.value) + + def test_min_max_non_numeric(self, simple_engine): + """Test min() and max() queries with non-numeric input""" + engine = simple_engine + + engine.parse( + """ + Person, p1, Alice + Person, p2, Bob + Person, p3, Cody + """ + ) + + with pytest.raises(TypeError) as exc_info: + engine.query.Person.min("name") + + assert "numeric value" in str(exc_info.value) + + with pytest.raises(TypeError) as exc_info: + engine.query.Person.max("name") + + assert "numeric value" in str(exc_info.value) + + def test_count(self, populated_engine): + """Test counting results""" + result = populated_engine.query.Person + + count = result.count() + + assert count == 2 + + def test_sum_avg_min_max_group_by(self, populated_engine): + """Test aggregate helpers and grouping.""" + people = populated_engine.query.Person + assert people.sum("age") == 55 + assert people.avg("age") == 27.5 + assert people.min("age") == 25 + assert people.max("age") == 30 + + grouped = people.group_by("age") + assert set(grouped.keys()) == { 25, 30 } + assert next(iter(grouped[30]))["name"] == "Alice" + +class TestQueryResultWhere: + """Test where() query""" + + def test_where_lambda(self, populated_engine): + """Test where with lambda condition""" + result = populated_engine.query.Person + + # Filter with lambda + filtered = result.where(lambda n: n["age"] > 25) + + assert len(filtered.nodes) == 1 + assert next(iter(filtered.get()))["name"] == "Alice" + + def test_where_string_condition(self, populated_engine): + """Test where with string condition""" + result = populated_engine.query.Person + + # Filter with string condition + filtered = result.where("age > 25") + + assert len(filtered.nodes) == 1 + assert next(iter(filtered.get()))["name"] == "Alice" + + def test_where_string_equality(self, populated_engine): + """Test where with equality condition""" + result = populated_engine.query.Person + + # Filter with equality + filtered = result.where('name = "Alice"') + + assert len(filtered.nodes) == 1 + assert next(iter(filtered.get()))["name"] == "Alice" + + # Alternative equality syntax + filtered2 = result.where('name == "Alice"') + assert len(filtered2.nodes) == 1 + + def test_where_string_inequality(self, populated_engine): + """Test where with inequality condition""" + result = populated_engine.query.Person + + filtered = result.where('name != "Alice"') + + assert len(filtered.nodes) == 1 + assert next(iter(filtered.get()))["name"] == "Bob" + + def test_where_comparison_operators(self, populated_engine): + """Test where with various comparison operators""" + result = populated_engine.query.Person + + # Greater than or equal + filtered_ge = result.where("age >= 30") + assert len(filtered_ge.nodes) == 1 + + # Less than + filtered_lt = result.where("age < 30") + assert len(filtered_lt.nodes) == 1 + + # Less than or equal + filtered_le = result.where("age <= 25") + assert len(filtered_le.nodes) == 1 + + def test_where_invalid_condition(self, populated_engine): + """Test where with invalid condition string""" + result = populated_engine.query.Person + + with pytest.raises(ConditionError): + result.where("invalid condition format") + + def test_where_nonexistent_field(self, populated_engine): + """Test where condition with non-existent field""" + result = populated_engine.query.Person + + # Should return empty result, not raise error + filtered = result.where("nonexistent = 10") + assert len(filtered.nodes) == 0 + + def test_where_callable_error_is_wrapped(self, populated_engine): + """Test callable errors are wrapped as ConditionError.""" + result = populated_engine.query.Person + with pytest.raises(ConditionError): + result.where(lambda n: n["unknown"] > 1) + + def test_where_boolean_and_date_conditions(self, populated_engine): + """Test bool and date comparisons in string conditions.""" + active_projects = populated_engine.query.Project.where("active = true") + assert active_projects.count() == 1 + + founded_company = populated_engine.query.Company.where('founded = 2020-01-01') + assert founded_company.count() == 1 + assert founded_company.first()["founded"] == date(2020, 1, 1) + + def test_where_string_error(self, populated_engine): + """Test invalid condition string raises ConditionError""" + with pytest.raises(ConditionError): + populated_engine.query.Person.where("age > date") diff --git a/tests/test_unit_serialization.py b/tests/test_unit_serialization.py index 55bfb48..3bdf68f 100644 --- a/tests/test_unit_serialization.py +++ b/tests/test_unit_serialization.py @@ -2,30 +2,36 @@ Unit tests for serialization """ import json -from collections import defaultdict from datetime import date from enum import Enum +import pytest + from src.graphite import ( - DataType, Field, GraphiteJSONEncoder, Node, NodeType, Relation, RelationType, + DataType, Field, GraphiteJSONEncoder, NodeType, Relation, RelationType, ) +from src.graphite.exceptions import InvalidJSONError, NotFoundError, ValidationError +from src.graphite.serialization import GRAPHITE_TYPE_FIELD, graphite_object_hook -class TestGraphiteJSONEncoder: - """Test GraphiteJSONEncoder class""" +class TestEncodeDecode: + """Test GraphiteJSONEncoder class and graphite_object_hook""" - def test_encode_date(self): - """Test encoding date objects""" + def test_date(self): + """Test date objects""" test_date = date(2023, 12, 1) result = json.dumps(test_date, cls=GraphiteJSONEncoder) data = json.loads(result) - assert data["__graphite_type__"] == "datetime" + assert data["__graphite_type__"] == "date" assert data["value"] == "2023-12-01" - assert data["is_date"] is True - def test_encode_datatype_enum(self): - """Test encoding DataType enum""" + value = graphite_object_hook(data) + + assert value == test_date + + def test_datatype_enum(self): + """Test DataType enum""" encoder = GraphiteJSONEncoder() result = encoder.default(DataType.STRING) @@ -33,8 +39,12 @@ def test_encode_datatype_enum(self): assert result["__graphite_type__"] == "datatype" assert result["value"] == "string" - def test_encode_field(self): - """Test encoding Field objects""" + value = graphite_object_hook(result) + + assert value == DataType.STRING + + def test_field(self): + """Test Field objects""" field = Field("name", DataType.STRING) encoder = GraphiteJSONEncoder() @@ -44,8 +54,12 @@ def test_encode_field(self): assert result["name"] == "name" assert result["dtype"] == DataType.STRING - def test_encode_node_type(self): - """Test encoding NodeType objects""" + value = graphite_object_hook(result) + + assert value == field + + def test_node_type(self): + """Test NodeType objects""" node_type = NodeType( name="Person", fields=[Field("name", DataType.STRING)], @@ -60,8 +74,12 @@ def test_encode_node_type(self): assert len(result["fields"]) == 1 assert "parent" in result - def test_encode_node_type_with_parent(self): - """Test encoding NodeType with parent reference""" + value = graphite_object_hook(result) + + assert value == node_type + + def test_node_type_with_parent(self): + """Test NodeType with parent reference""" parent = NodeType("Entity", []) child = NodeType("Person", [], parent) @@ -72,8 +90,13 @@ def test_encode_node_type_with_parent(self): assert result["name"] == "Person" assert result["parent"] == "Entity" # Should store name, not object - def test_encode_relation_type(self): - """Test encoding RelationType objects""" + value = graphite_object_hook(result) + value.parent = parent + + assert value == child + + def test_relation_type(self): + """Test RelationType objects""" rel_type = RelationType( name="WORKS_AT", from_type="Person", @@ -93,15 +116,13 @@ def test_encode_relation_type(self): assert result["reverse_name"] == "EMPLOYS" assert result["is_bidirectional"] is False - def test_encode_node(self): - """Test encoding Node objects""" - # pylint: disable=duplicate-code - node = Node( - type_name="Person", - id="person1", - values={"name": "Alice", "age": 30}, - type_ref=None - ) + value = graphite_object_hook(result) + + assert value == rel_type + + def test_node(self, alice_node): + """Test Node objects""" + node = alice_node encoder = GraphiteJSONEncoder() result = encoder.default(node) @@ -109,15 +130,19 @@ def test_encode_node(self): assert result["__graphite_type__"] == "Node" assert result["type_name"] == "Person" assert result["id"] == "person1" - assert result["values"] == {"name": "Alice", "age": 30} + assert result["values"] == { "name": "Alice", "age": 30 } + + value = graphite_object_hook(result) + + assert value == node - def test_encode_relation(self): - """Test encoding Relation objects""" + def test_relation(self): + """Test Relation objects""" relation = Relation( type_name="WORKS_AT", from_node="person1", to_node="company1", - values={"since": date(2021, 1, 1)}, + values={ "since": date(2021, 1, 1) }, type_ref=None ) @@ -128,38 +153,21 @@ def test_encode_relation(self): assert result["type_name"] == "WORKS_AT" assert result["from_node"] == "person1" assert result["to_node"] == "company1" - # Date in values should be encoded as datetime + # Date in values should be encoded as date assert isinstance(result["values"]["since"], date) - def test_encode_defaultdict(self): - """Test encoding defaultdict objects""" - ddict = defaultdict(list) - ddict["key1"].append("value1") - ddict["key2"].append("value2") + value = graphite_object_hook(result) - encoder = GraphiteJSONEncoder() - result = encoder.default(ddict) - - assert result["__graphite_type__"] == "defaultdict" - assert result["__default_factory"] == "list" - assert result["key1"] == ["value1"] - assert result["key2"] == ["value2"] + # Object must be changed, but content not + assert value != relation + assert relation.values == value.values + assert relation.type_name == "WORKS_AT" + assert relation.from_node == "person1" + assert relation.to_node == "company1" - def test_encode_defaultdict_dict(self): - """Test encoding defaultdict with dict factory""" - ddict: dict[str, dict] = defaultdict(dict) - ddict["key1"]["subkey"] = "value" - - encoder = GraphiteJSONEncoder() - result = encoder.default(ddict) - - assert result["__graphite_type__"] == "defaultdict" - assert result["__default_factory"] == "dict" - assert result["key1"]["subkey"] == "value" - - def test_encode_regular_dict(self): - """Test encoding regular dictionaries""" - regular_dict = {"key": "value", "number": 42} + def test_regular_dict(self): + """Test regular dictionaries""" + regular_dict = { "key": "value", "number": 42 } encoder = GraphiteJSONEncoder() result = encoder.default(regular_dict) @@ -167,8 +175,12 @@ def test_encode_regular_dict(self): # Should fall back to parent class assert result == regular_dict - def test_encode_list(self): - """Test encoding regular lists""" + value = graphite_object_hook(result) + + assert value == regular_dict + + def test_list(self): + """Test regular lists""" regular_list = [1, 2, 3, "test"] encoder = GraphiteJSONEncoder() @@ -177,16 +189,227 @@ def test_encode_list(self): # Should fall back to parent class assert result == regular_list - def test_encode_other_enum(self): - """Test encoding other enum types (not DataType)""" + value = graphite_object_hook(result) + + assert value == regular_list + + def test_encode_invalid(self): + """Test encoding invalid types""" + + + class TestClass: + """Test class""" + + def example(self): + """An example function""" + print(self) + + def another_example(self): + """Another example function""" + print(self, "test") + + class TestEnum(Enum): """Enum for test""" VALUE1 = "value1" VALUE2 = "value2" + encoder = GraphiteJSONEncoder() - result = encoder.default(TestEnum.VALUE1) - assert result["__graphite_type__"] == "enum" - assert result["enum_class"] == "TestEnum" - assert result["value"] == "value1" + with pytest.raises(TypeError): + encoder.default(TestClass()) + + with pytest.raises(TypeError): + encoder.default(TestEnum.VALUE1) + + def test_decode_invalid(self): + """Test decoding invalid types""" + data = { + GRAPHITE_TYPE_FIELD: "Invalid", + "key": "value", + } + + with pytest.raises(TypeError): + graphite_object_hook(data) + + +class TestValidation: + """Test validation logic""" + + def test_no_schema_validate(self, populated_engine, clean_engine, temp_json_file): + """Test load_safe() without schema validation""" + populated_engine.save(temp_json_file) + + clean_engine.load_safe(temp_json_file, validate_schema=False) + + def test_schema_validate(self, populated_engine, clean_engine, temp_json_file): + """Test load_safe() with schema validation""" + populated_engine.save(temp_json_file) + + clean_engine.load_safe(temp_json_file, validate_schema=True) + + def test_invalid_data_content(self, temp_json_file, clean_engine): + """Test invalid data type for content""" + with open(temp_json_file, "w", encoding="utf-8") as f: + f.write('') + + with pytest.raises(InvalidJSONError): + clean_engine.load_safe(temp_json_file) + + def test_missing_keys(self, temp_json_file, clean_engine): + """Test missing keys handling in load""" + with open(temp_json_file, "w", encoding="utf-8") as f: + f.write('{}') + + with pytest.raises(ValidationError) as exc_info: + clean_engine.load_safe(temp_json_file) + + assert "Missing required key" in str(exc_info.value) + + def test_version_mismatch(self, temp_json_file, clean_engine): + """Test version mismatch in load""" + with open(temp_json_file, "w", encoding="utf-8") as f: + f.write( + """ + { + "version": "2.0", + "node_types": [], + "relation_types": [], + "nodes": [], + "relations": [] + } + """ + ) + + with pytest.raises(ValidationError) as exc_info: + clean_engine.load_safe(temp_json_file) + + assert "Save file version" in str(exc_info.value) + + def test_keys_types(self, temp_json_file, clean_engine): + """Test invalid types for keys""" + with open(temp_json_file, "w", encoding="utf-8") as f: + f.write( + """ + { + "version": "1.0", + "node_types": {}, + "relation_types": [], + "nodes": [], + "relations": [] + } + """ + ) + + with pytest.raises(ValidationError) as exc_info: + clean_engine.load_safe(temp_json_file) + + assert "node_types must be a list" in str(exc_info.value) + + with open(temp_json_file, "w", encoding="utf-8") as f: + f.write( + """ + { + "version": "1.0", + "node_types": [], + "relation_types": {}, + "nodes": [], + "relations": [] + } + """ + ) + + with pytest.raises(ValidationError) as exc_info: + clean_engine.load_safe(temp_json_file) + + assert "relation_types must be a list" in str(exc_info.value) + + with open(temp_json_file, "w", encoding="utf-8") as f: + f.write( + """ + { + "version": "1.0", + "node_types": [], + "relation_types": [], + "nodes": {}, + "relations": [] + } + """ + ) + + with pytest.raises(ValidationError) as exc_info: + clean_engine.load_safe(temp_json_file) + + assert "nodes must be a list" in str(exc_info.value) + + with open(temp_json_file, "w", encoding="utf-8") as f: + f.write( + """ + { + "version": "1.0", + "node_types": [], + "relation_types": [], + "nodes": [], + "relations": {} + } + """ + ) + + with pytest.raises(ValidationError) as exc_info: + clean_engine.load_safe(temp_json_file) + + assert "relations must be a list" in str(exc_info.value) + + def test_invalid_keys(self, temp_json_file, clean_engine): + """Test invalid keys handling in load""" + with open(temp_json_file, "w", encoding="utf-8") as f: + f.write( + """ + { + "version": "1.0", + "node_types": [], + "relation_types": [], + "nodes": [], + "relations": [], + "invalid": [] + } + """ + ) + + with pytest.warns(UserWarning, match="Unexpected key in data"): + clean_engine.load_safe(temp_json_file) + + def test_missing_node_type(self, temp_json_file, clean_engine): + """Test missing node types detection""" + with open(temp_json_file, "w", encoding="utf-8") as f: + f.write( + """ + { + "version": "1.0", + "node_types": [ + { + "__graphite_type__": "NodeType", + "name": "P", + "parent": null, + "fields": null + } + ], + "relation_types": [], + "nodes": [ + { + "__graphite_type__": "Node", + "type_name": "Person", + "id": "person", + "values": {} + } + ], + "relations": [] + } + """ + ) + + with pytest.raises(NotFoundError) as exc_info: + clean_engine.load_safe(temp_json_file) + + assert "Node type" in str(exc_info.value) diff --git a/tests/test_unit_types.py b/tests/test_unit_types.py index bdda617..7d201c7 100644 --- a/tests/test_unit_types.py +++ b/tests/test_unit_types.py @@ -5,8 +5,8 @@ from src.graphite import DataType, Field, NodeType, RelationType -class TestDataType: - """Test DataType enum""" +class TestField: + """Test Field and DataType enum""" def test_data_type_values(self): """Test DataType enum values""" @@ -29,10 +29,6 @@ def test_invalid_data_type(self): with pytest.raises(ValueError): DataType("invalid_type") -# pylint: disable=too-few-public-methods -class TestField: - """Test Field class""" - def test_field_creation(self): """Test creating a field""" field = Field("name", DataType.STRING) diff --git a/tests/test_utils.py b/tests/test_utils.py index 5290951..84b1c79 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,8 +1,9 @@ """ Tests for utility functions """ +from src.graphite import engine from src.graphite.engine import GraphiteEngine -from src.graphite.utils import SecurityWarning, engine, node, relation +from src.graphite.utils import SecurityWarning, node, relation class TestUtils: """Test utility functions""" @@ -11,10 +12,7 @@ def test_node_helper(self): """Test node helper function""" result = node("Person", name="string", age="int", email="string") - expected = """node Person -name: string -age: int -email: string""" + expected = "node Person\nname: string\nage: int\nemail: string" assert result.strip() == expected @@ -22,8 +20,7 @@ def test_relation_helper_simple(self): """Test relation helper function simple case""" result = relation("WORKS_AT", "Person", "Company") - expected = """relation WORKS_AT -Person -> Company""" + expected = "relation WORKS_AT\nPerson -> Company" assert result.strip() == expected @@ -31,13 +28,10 @@ def test_relation_helper_with_fields(self): """Test relation helper with fields""" result = relation( "WORKS_AT", "Person", "Company", - fields={"position": "string", "since": "date"} + fields={ "position": "string", "since": "date" } ) - expected = """relation WORKS_AT -Person -> Company -position: string -since: date""" + expected = "relation WORKS_AT\nPerson -> Company\nposition: string\nsince: date" assert result.strip() == expected @@ -45,8 +39,7 @@ def test_relation_helper_bidirectional(self): """Test relation helper with bidirectional""" result = relation("FRIENDS_WITH", "Person", "Person", both=True) - expected = """relation FRIENDS_WITH both -Person - Person""" + expected = "relation FRIENDS_WITH both\nPerson - Person" assert result.strip() == expected @@ -54,8 +47,7 @@ def test_relation_helper_with_reverse(self): """Test relation helper with reverse""" result = relation("WORKS_AT", "Person", "Company", reverse="EMPLOYS") - expected = """relation WORKS_AT reverse EMPLOYS -Person -> Company""" + expected = "relation WORKS_AT reverse EMPLOYS\nPerson -> Company" assert result.strip() == expected