Files
AI/참고/neo4j-graphrag-python-main/tests/unit/experimental/components/test_schema.py

2204 lines
71 KiB
Python
Raw Normal View History

2026-05-12 19:40:31 +09:00
# Copyright (c) "Neo4j"
# Neo4j Sweden AB [https://neo4j.com]
# #
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# #
# https://www.apache.org/licenses/LICENSE-2.0
# #
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import json
from typing import Tuple, Any
from unittest.mock import AsyncMock, patch, Mock
import pytest
from pydantic import ValidationError
from neo4j_graphrag.exceptions import SchemaValidationError, SchemaExtractionError
from neo4j_graphrag.experimental.components.schema import (
SchemaBuilder,
NodeType,
PropertyType,
RelationshipType,
ConstraintType,
GraphConstraintType,
SchemaFromTextExtractor,
GraphSchema,
SchemaFromExistingGraphExtractor,
Pattern,
)
import os
import tempfile
import yaml
from neo4j_graphrag.generation import PromptTemplate
from neo4j_graphrag.llm.types import LLMResponse
from neo4j_graphrag.utils.file_handler import FileFormat
# Most tests below read ``PropertyType.required`` to assert migration; silence only that
# deprecation. Explicit warning behavior is covered in ``test_property_type_deprecation.py``.
pytestmark = pytest.mark.filterwarnings(
"ignore:Use GraphSchema.constraints with type EXISTENCE:DeprecationWarning"
)
def test_node_type_initialization_from_string() -> None:
node_type = NodeType.model_validate("Label")
assert isinstance(node_type, NodeType)
assert node_type.label == "Label"
assert len(node_type.properties) == 1
assert node_type.properties[0].name == "name"
assert node_type.properties[0].type == "STRING"
def test_node_type_additional_properties_default() -> None:
# default behavior:
node_type = NodeType.model_validate({"label": "Label"})
assert node_type.additional_properties is True
# Empty properties list now violates min_length=1 constraint
with pytest.raises(ValidationError):
NodeType.model_validate({"label": "Label", "properties": []})
node_type = NodeType.model_validate(
{"label": "Label", "properties": [{"name": "name", "type": "STRING"}]}
)
assert node_type.additional_properties is False
# manually changing the default value
# impossible cases: no properties and no additional
with pytest.raises(ValidationError):
NodeType.model_validate({"label": "Label", "additional_properties": False})
with pytest.raises(ValidationError):
NodeType.model_validate(
{"label": "Label", "properties": [], "additional_properties": False}
)
# working case: properties and additional allowed
node_type = NodeType.model_validate(
{
"label": "Label",
"properties": [{"name": "name", "type": "STRING"}],
"additional_properties": True,
}
)
assert node_type.additional_properties is True
@pytest.mark.parametrize(
"label",
["__Person", "Person__", "__Person__", "__KNOWS", "KNOWS__", "__KNOWS__"],
)
def test_node_type_rejects_dunder_label(label: str) -> None:
with pytest.raises(ValidationError, match="reserved '__'"):
NodeType.model_validate(
{"label": label, "properties": [{"name": "name", "type": "STRING"}]}
)
@pytest.mark.parametrize(
"label",
["__KNOWS", "KNOWS__", "__KNOWS__"],
)
def test_relationship_type_rejects_dunder_label(label: str) -> None:
with pytest.raises(ValidationError, match="reserved '__'"):
RelationshipType.model_validate({"label": label})
def test_property_type_initalization() -> None:
prop = PropertyType(name="email", type="STRING")
assert prop.name == "email"
assert prop.type == "STRING"
assert prop.required is False
def test_property_type_with_required_true() -> None:
prop = PropertyType(name="id", type="INTEGER", required=True)
assert prop.required is True
def test_property_type_is_frozen() -> None:
prop = PropertyType(name="email", type="STRING", required=False)
with pytest.raises(ValidationError):
prop.name = "other"
with pytest.raises(ValidationError):
prop.required = True
def test_relationship_type_initialization_from_string() -> None:
relationship_type = RelationshipType.model_validate("REL")
assert isinstance(relationship_type, RelationshipType)
assert relationship_type.label == "REL"
assert relationship_type.properties == []
def test_relationship_type_additional_properties_default() -> None:
relationship_type = RelationshipType.model_validate({"label": "REL"})
assert relationship_type.additional_properties is True
relationship_type = RelationshipType.model_validate(
{"label": "REL", "properties": []}
)
assert relationship_type.additional_properties is True
relationship_type = RelationshipType.model_validate(
{"label": "REL", "properties": [{"name": "name", "type": "STRING"}]}
)
assert relationship_type.additional_properties is False
# manually changing the default value
# auto-correction: no properties and additional_properties=False -> auto-corrected to True
relationship_type = RelationshipType.model_validate(
{"label": "REL", "additional_properties": False}
)
assert relationship_type.additional_properties is True # auto-corrected
relationship_type = RelationshipType.model_validate(
{"label": "REL", "properties": [], "additional_properties": False}
)
assert relationship_type.additional_properties is True # auto-corrected
# working case: properties and additional allowed
relationship_type = RelationshipType.model_validate(
{
"label": "REL",
"properties": [{"name": "name", "type": "STRING"}],
"additional_properties": True,
}
)
assert relationship_type.additional_properties is True
def test_constraint_type_initialization() -> None:
constraint = ConstraintType(
type=GraphConstraintType.UNIQUENESS,
node_type="Person",
property_names=("name",),
)
assert constraint.type == "UNIQUENESS"
assert constraint.node_type == "Person"
assert constraint.property_names == ("name",)
def test_constraint_type_is_frozen() -> None:
constraint = ConstraintType(
type=GraphConstraintType.UNIQUENESS,
node_type="Person",
property_names=("name",),
)
with pytest.raises(ValidationError):
constraint.type = GraphConstraintType.UNIQUENESS
with pytest.raises(ValidationError):
constraint.node_type = "Organization"
with pytest.raises(ValidationError):
constraint.property_names = ("id",)
def test_constraint_type_rejects_empty_property_names() -> None:
"""``property_names`` has ``min_length=1``; empty tuple/list fails validation."""
with pytest.raises(ValidationError) as exc_info:
ConstraintType(
type=GraphConstraintType.UNIQUENESS,
node_type="Person",
property_names=(),
)
assert "too_short" in str(exc_info.value).lower()
with pytest.raises(ValidationError):
ConstraintType.model_validate(
{
"type": "UNIQUENESS",
"node_type": "Person",
"property_names": [],
}
)
def test_schema_additional_node_types_default() -> None:
schema_dict: dict[str, Any] = {
"node_types": [],
}
schema = GraphSchema.model_validate(schema_dict)
assert schema.additional_node_types is True
schema_dict = {
"node_types": ["Person"],
}
schema = GraphSchema.model_validate(schema_dict)
assert schema.additional_node_types is False
def test_schema_additional_relationship_types_default() -> None:
schema_dict: dict[str, Any] = {
"node_types": [],
}
schema = GraphSchema.model_validate(schema_dict)
assert schema.additional_relationship_types is True
schema_dict = {
"node_types": [],
"relationship_types": ["REL"],
}
schema = GraphSchema.model_validate(schema_dict)
assert schema.additional_relationship_types is False
def test_schema_additional_patterns_default() -> None:
schema_dict: dict[str, Any] = {
"node_types": [],
}
schema = GraphSchema.model_validate(schema_dict)
assert schema.additional_patterns is True
schema_dict = {
"node_types": ["Person"],
"relationship_types": ["REL"],
"patterns": [("Person", "REL", "Person")],
}
schema = GraphSchema.model_validate(schema_dict)
assert schema.additional_patterns is False
def test_schema_additional_parameter_validation() -> None:
"""Additional relationship types not allowed, but additional patterns allowed
=> raise Exception
"""
schema_dict = {
"node_types": [
{
"label": "Person",
"properties": [
{
"name": "name",
"type": "STRING",
},
{"name": "height", "type": "INTEGER"},
],
}
],
"relationship_types": [
{
"label": "KNOWS",
}
],
"patterns": [
("Person", "KNOWS", "Person"),
],
"additional_relationship_types": True,
"additional_patterns": False,
}
with pytest.raises(
ValidationError,
match="`additional_relationship_types` must be set to False when using `additional_patterns=False`",
):
GraphSchema.model_validate(schema_dict)
def test_schema_constraint_validation_property_not_in_node_type() -> None:
schema_dict: dict[str, Any] = {
"node_types": [
{
"label": "Person",
"properties": [{"name": "name", "type": "STRING"}],
}
],
"constraints": [
{"type": "UNIQUENESS", "node_type": "Person", "property_name": "email"}
],
}
with pytest.raises(SchemaValidationError) as exc_info:
GraphSchema.model_validate(schema_dict)
assert "Constraint references undefined property" in str(exc_info.value)
assert "on node type 'Person'" in str(exc_info.value)
def test_schema_constraint_with_additional_properties_with_allows_unknown_property() -> (
None
):
# if additional_properties is True, we can define constraints that are not in the node_type
schema_dict: dict[str, Any] = {
"node_types": [
{
"label": "Person",
"properties": [{"name": "name", "type": "STRING"}],
"additional_properties": True,
}
],
"constraints": [
{"type": "UNIQUENESS", "node_type": "Person", "property_name": "email"}
],
}
# Should raise - email is not allowed because the property is not defined in the node
with pytest.raises(SchemaValidationError) as exc_info:
GraphSchema.model_validate(schema_dict)
assert "Constraint references undefined property 'email'" in str(exc_info.value)
def test_schema_with_valid_constraints() -> None:
schema_dict: dict[str, Any] = {
"node_types": [
{"label": "Person", "properties": [{"name": "name", "type": "STRING"}]}
],
"constraints": [
{"type": "UNIQUENESS", "node_type": "Person", "property_name": "name"}
],
}
schema = GraphSchema.model_validate(schema_dict)
assert len(schema.constraints) == 1
assert schema.constraints[0].type == "UNIQUENESS"
assert schema.constraints[0].node_type == "Person"
assert schema.constraints[0].property_names == ("name",)
def test_schema_constraint_validation_invalid_node_type() -> None:
schema_dict: dict[str, Any] = {
"node_types": [
{"label": "Person", "properties": [{"name": "name", "type": "STRING"}]}
],
"constraints": [
{
"type": "UNIQUENESS",
"node_type": "NonExistentNode",
"property_name": "id",
}
],
}
with pytest.raises(SchemaValidationError) as exc_info:
GraphSchema.model_validate(schema_dict)
assert "Constraint references undefined node type: NonExistentNode" in str(
exc_info.value
)
def test_schema_constraint_validation_missing_property_name() -> None:
schema_dict: dict[str, Any] = {
"node_types": [
{"label": "Person", "properties": [{"name": "name", "type": "STRING"}]}
],
"constraints": [
{"type": "UNIQUENESS", "node_type": "Person", "property_name": ""}
],
}
with pytest.raises((SchemaValidationError, ValidationError)):
GraphSchema.model_validate(schema_dict)
def test_schema_key_constraint_node_valid() -> None:
schema_dict: dict[str, Any] = {
"node_types": [
{"label": "Person", "properties": [{"name": "email", "type": "STRING"}]}
],
"constraints": [
{
"type": "KEY",
"node_type": "Person",
"property_name": "email",
"relationship_type": None,
}
],
}
schema = GraphSchema.model_validate(schema_dict)
assert schema.key_property_names_for_node("Person") == {"email"}
assert schema.mandatory_property_names_for_node("Person") == {"email"}
assert schema.uniqueness_property_names_for_node("Person") == set()
def test_schema_key_constraint_relationship_valid() -> None:
schema_dict: dict[str, Any] = {
"node_types": [
{"label": "Person", "properties": [{"name": "name", "type": "STRING"}]}
],
"relationship_types": [
{
"label": "KNOWS",
"properties": [{"name": "since", "type": "INTEGER"}],
}
],
"constraints": [
{
"type": "KEY",
"node_type": "",
"property_name": "since",
"relationship_type": "KNOWS",
}
],
}
schema = GraphSchema.model_validate(schema_dict)
assert schema.key_property_names_for_relationship("KNOWS") == {"since"}
assert schema.mandatory_property_names_for_relationship("KNOWS") == {"since"}
def test_schema_uniqueness_and_key_same_property_rejected() -> None:
schema_dict: dict[str, Any] = {
"node_types": [
{"label": "Person", "properties": [{"name": "id", "type": "STRING"}]}
],
"constraints": [
{
"type": "UNIQUENESS",
"node_type": "Person",
"property_name": "id",
"relationship_type": None,
},
{
"type": "KEY",
"node_type": "Person",
"property_name": "id",
"relationship_type": None,
},
],
}
with pytest.raises(SchemaValidationError, match="UNIQUENESS and KEY"):
GraphSchema.model_validate(schema_dict)
# --- Composite (multi-property) constraint tests ---
def test_composite_key_constraint_node_valid() -> None:
schema_dict: dict[str, Any] = {
"node_types": [
{
"label": "Actor",
"properties": [
{"name": "firstname", "type": "STRING"},
{"name": "surname", "type": "STRING"},
],
}
],
"constraints": [
{
"type": "KEY",
"node_type": "Actor",
"property_names": ["firstname", "surname"],
"relationship_type": None,
}
],
}
schema = GraphSchema.model_validate(schema_dict)
assert len(schema.constraints) == 1
assert schema.constraints[0].property_names == ("firstname", "surname")
assert schema.key_property_names_for_node("Actor") == {"firstname", "surname"}
assert schema.mandatory_property_names_for_node("Actor") == {
"firstname",
"surname",
}
def test_composite_uniqueness_constraint_valid() -> None:
schema_dict: dict[str, Any] = {
"node_types": [
{
"label": "Book",
"properties": [
{"name": "title", "type": "STRING"},
{"name": "year", "type": "INTEGER"},
],
}
],
"constraints": [
{
"type": "UNIQUENESS",
"node_type": "Book",
"property_names": ["title", "year"],
}
],
}
schema = GraphSchema.model_validate(schema_dict)
assert len(schema.constraints) == 1
assert schema.constraints[0].property_names == ("title", "year")
assert schema.uniqueness_property_names_for_node("Book") == {"title", "year"}
def test_existence_rejects_composite() -> None:
schema_dict: dict[str, Any] = {
"node_types": [
{
"label": "Person",
"properties": [
{"name": "name", "type": "STRING"},
{"name": "email", "type": "STRING"},
],
}
],
"constraints": [
{
"type": "EXISTENCE",
"node_type": "Person",
"property_names": ["name", "email"],
}
],
}
with pytest.raises(ValueError, match="EXISTENCE constraint must have exactly one"):
GraphSchema.model_validate(schema_dict)
def test_backward_compat_property_name_migrates_to_property_names() -> None:
schema_dict: dict[str, Any] = {
"node_types": [
{"label": "Person", "properties": [{"name": "name", "type": "STRING"}]}
],
"constraints": [
{
"type": "UNIQUENESS",
"node_type": "Person",
"property_name": "name",
}
],
}
schema = GraphSchema.model_validate(schema_dict)
assert schema.constraints[0].property_names == ("name",)
with pytest.warns(DeprecationWarning, match="property_names"):
assert schema.constraints[0].property_name == "name"
def test_composite_constraint_validates_all_properties_exist() -> None:
schema_dict: dict[str, Any] = {
"node_types": [
{
"label": "Actor",
"properties": [
{"name": "firstname", "type": "STRING"},
],
}
],
"constraints": [
{
"type": "KEY",
"node_type": "Actor",
"property_names": ["firstname", "nonexistent"],
"relationship_type": None,
}
],
}
with pytest.raises(SchemaValidationError, match="nonexistent"):
GraphSchema.model_validate(schema_dict)
def test_composite_uniqueness_and_key_same_properties_rejected() -> None:
schema_dict: dict[str, Any] = {
"node_types": [
{
"label": "Actor",
"properties": [
{"name": "firstname", "type": "STRING"},
{"name": "surname", "type": "STRING"},
],
}
],
"constraints": [
{
"type": "UNIQUENESS",
"node_type": "Actor",
"property_names": ["firstname", "surname"],
},
{
"type": "KEY",
"node_type": "Actor",
"property_names": ["firstname", "surname"],
"relationship_type": None,
},
],
}
with pytest.raises(SchemaValidationError, match="UNIQUENESS and KEY"):
GraphSchema.model_validate(schema_dict)
def test_composite_uniqueness_and_key_different_properties_allowed() -> None:
schema_dict: dict[str, Any] = {
"node_types": [
{
"label": "Actor",
"properties": [
{"name": "firstname", "type": "STRING"},
{"name": "surname", "type": "STRING"},
{"name": "email", "type": "STRING"},
],
}
],
"constraints": [
{
"type": "UNIQUENESS",
"node_type": "Actor",
"property_names": ["email"],
},
{
"type": "KEY",
"node_type": "Actor",
"property_names": ["firstname", "surname"],
"relationship_type": None,
},
],
}
schema = GraphSchema.model_validate(schema_dict)
assert len(schema.constraints) == 2
assert schema.key_property_names_for_node("Actor") == {"firstname", "surname"}
assert schema.uniqueness_property_names_for_node("Actor") == {"email"}
def test_extract_graph_constraints_from_metadata_composite_key() -> None:
from neo4j_graphrag.experimental.components.schema import (
SchemaFromExistingGraphExtractor,
)
structured_schema: dict[str, Any] = {
"metadata": {
"constraint": [
{
"type": "NODE_KEY",
"properties": ["firstname", "surname"],
"labelsOrTypes": ["Actor"],
}
]
}
}
result = SchemaFromExistingGraphExtractor._extract_graph_constraints_from_metadata(
structured_schema
)
assert len(result) == 1
assert result[0]["type"] == "KEY"
assert result[0]["node_type"] == "Actor"
assert result[0]["property_names"] == ("firstname", "surname")
assert result[0]["property_name"] == "firstname"
@pytest.fixture
def valid_node_types() -> tuple[NodeType, ...]:
# required=False so tuples match GraphSchema after validation (legacy required=True
# is migrated to EXISTENCE and cleared).
return (
NodeType(
label="PERSON",
description="An individual human being.",
properties=[
PropertyType(name="birth date", type="ZONED_DATETIME"),
PropertyType(name="name", type="STRING", required=False),
],
additional_properties=False,
),
NodeType(
label="ORGANIZATION",
description="A structured group of people with a common purpose.",
properties=[PropertyType(name="name", type="STRING")],
additional_properties=True,
),
NodeType(
label="AGE",
description="Age of a person in years.",
properties=[PropertyType(name="value", type="INTEGER")],
additional_properties=True,
),
)
@pytest.fixture
def valid_relationship_types() -> tuple[RelationshipType, ...]:
return (
RelationshipType(
label="EMPLOYED_BY",
description="Indicates employment relationship.",
properties=[
PropertyType(name="start_time", type="LOCAL_DATETIME", required=False),
PropertyType(name="end_time", type="LOCAL_DATETIME"),
],
additional_properties=False,
),
RelationshipType(
label="ORGANIZED_BY",
description="Indicates organization responsible for an event.",
),
RelationshipType(
label="ATTENDED_BY", description="Indicates attendance at an event."
),
)
@pytest.fixture
def valid_patterns() -> tuple[Pattern, ...]:
return (
Pattern(source="PERSON", relationship="EMPLOYED_BY", target="ORGANIZATION"),
Pattern(source="ORGANIZATION", relationship="ATTENDED_BY", target="PERSON"),
)
@pytest.fixture
def patterns_with_invalid_entity() -> tuple[Pattern, ...]:
return (
Pattern(source="PERSON", relationship="EMPLOYED_BY", target="ORGANIZATION"),
Pattern(
source="NON_EXISTENT_ENTITY", relationship="ATTENDED_BY", target="PERSON"
),
)
@pytest.fixture
def valid_constraints() -> tuple[ConstraintType, ...]:
return (
ConstraintType(
type=GraphConstraintType.UNIQUENESS,
node_type="PERSON",
property_names=("name",),
),
)
@pytest.fixture
def patterns_with_invalid_relation() -> tuple[Pattern, ...]:
return (
Pattern(
source="PERSON", relationship="NON_EXISTENT_RELATION", target="ORGANIZATION"
),
)
@pytest.fixture
def schema_builder() -> SchemaBuilder:
return SchemaBuilder()
@pytest.fixture
def graph_schema(
schema_builder: SchemaBuilder,
valid_node_types: Tuple[NodeType, ...],
valid_relationship_types: Tuple[RelationshipType, ...],
valid_patterns: tuple[Pattern, ...],
) -> GraphSchema:
return schema_builder.create_schema_model(
list(valid_node_types), list(valid_relationship_types), list(valid_patterns)
)
def test_create_schema_model_valid_data(
schema_builder: SchemaBuilder,
valid_node_types: Tuple[NodeType, ...],
valid_relationship_types: Tuple[RelationshipType, ...],
valid_patterns: tuple[Pattern, ...],
) -> None:
schema = schema_builder.create_schema_model(
list(valid_node_types), list(valid_relationship_types), list(valid_patterns)
)
assert schema.node_types == valid_node_types
assert schema.relationship_types == valid_relationship_types
assert schema.patterns == valid_patterns
assert schema.additional_node_types is False
assert schema.additional_relationship_types is False
assert schema.additional_patterns is False
def test_create_schema_model_with_constraints(
schema_builder: SchemaBuilder,
valid_node_types: Tuple[NodeType, ...],
valid_constraints: Tuple[ConstraintType, ...],
) -> None:
schema = schema_builder.create_schema_model(
list(valid_node_types),
constraints=list(valid_constraints),
)
assert schema.node_types == valid_node_types
assert schema.constraints == valid_constraints
assert len(schema.constraints) == 1
assert schema.constraints[0].type == "UNIQUENESS"
assert schema.constraints[0].node_type == "PERSON"
assert schema.constraints[0].property_names == ("name",)
@pytest.mark.asyncio
async def test_run_method(
schema_builder: SchemaBuilder,
valid_node_types: Tuple[NodeType, ...],
valid_relationship_types: Tuple[RelationshipType, ...],
valid_patterns: tuple[Pattern, ...],
) -> None:
with patch.object(
schema_builder,
"create_schema_model",
return_value=GraphSchema(
node_types=valid_node_types,
relationship_types=valid_relationship_types,
patterns=valid_patterns,
),
):
schema = await schema_builder.run(
list(valid_node_types), list(valid_relationship_types), list(valid_patterns)
)
assert schema.node_types == valid_node_types
assert schema.relationship_types == valid_relationship_types
assert schema.patterns == valid_patterns
assert schema.additional_node_types is False
assert schema.additional_relationship_types is False
assert schema.additional_patterns is False
@pytest.mark.asyncio
async def test_run_method_with_constraints(
schema_builder: SchemaBuilder,
valid_node_types: Tuple[NodeType, ...],
valid_constraints: Tuple[ConstraintType, ...],
) -> None:
schema = await schema_builder.run(
list(valid_node_types),
constraints=list(valid_constraints),
)
assert schema.node_types == valid_node_types
assert schema.constraints == valid_constraints
assert len(schema.constraints) == 1
assert schema.constraints[0].type == "UNIQUENESS"
assert schema.constraints[0].node_type == "PERSON"
assert schema.constraints[0].property_names == ("name",)
def test_create_schema_model_invalid_entity(
schema_builder: SchemaBuilder,
valid_node_types: Tuple[NodeType, ...],
valid_relationship_types: Tuple[RelationshipType, ...],
patterns_with_invalid_entity: tuple[Pattern, ...],
) -> None:
with pytest.raises(SchemaValidationError) as exc_info:
schema_builder.create_schema_model(
list(valid_node_types),
list(valid_relationship_types),
list(patterns_with_invalid_entity),
)
assert "Node type 'NON_EXISTENT_ENTITY' is not defined" in str(
exc_info.value
), "Should fail due to non-existent entity"
def test_create_schema_model_invalid_relation(
schema_builder: SchemaBuilder,
valid_node_types: Tuple[NodeType, ...],
valid_relationship_types: Tuple[RelationshipType, ...],
patterns_with_invalid_relation: tuple[Pattern, ...],
) -> None:
with pytest.raises(SchemaValidationError) as exc_info:
schema_builder.create_schema_model(
list(valid_node_types),
list(valid_relationship_types),
list(patterns_with_invalid_relation),
)
assert "Relationship type 'NON_EXISTENT_RELATION' is not defined" in str(
exc_info.value
), "Should fail due to non-existent relation"
def test_create_schema_model_no_potential_schema(
schema_builder: SchemaBuilder,
valid_node_types: Tuple[NodeType, ...],
valid_relationship_types: Tuple[RelationshipType, ...],
) -> None:
schema_instance = schema_builder.create_schema_model(
list(valid_node_types), list(valid_relationship_types)
)
assert schema_instance.node_types == valid_node_types
assert schema_instance.relationship_types == valid_relationship_types
assert schema_instance.patterns == ()
def test_create_schema_model_no_relations_or_potential_schema(
schema_builder: SchemaBuilder,
valid_node_types: Tuple[NodeType, ...],
) -> None:
schema_instance = schema_builder.create_schema_model(list(valid_node_types))
assert len(schema_instance.node_types) == 3
person = schema_instance.node_type_from_label("PERSON")
assert person is not None
assert person.description == "An individual human being."
assert len(person.properties) == 2
assert person.additional_properties is False
org = schema_instance.node_type_from_label("ORGANIZATION")
assert org is not None
assert org.description == "A structured group of people with a common purpose."
assert org.additional_properties is True
age = schema_instance.node_type_from_label("AGE")
assert age is not None
assert age.description == "Age of a person in years."
assert age.additional_properties is True
def test_create_schema_model_missing_relations(
schema_builder: SchemaBuilder,
valid_node_types: Tuple[NodeType, ...],
valid_patterns: tuple[Pattern, ...],
) -> None:
with pytest.raises(SchemaValidationError) as exc_info:
schema_builder.create_schema_model(
node_types=valid_node_types, patterns=valid_patterns
)
assert "Relationship types must also be provided when using patterns." in str(
exc_info.value
), "Should fail due to missing relations"
@pytest.fixture
def mock_llm() -> AsyncMock:
mock = AsyncMock()
mock.ainvoke = AsyncMock()
return mock
@pytest.fixture
def valid_schema_json() -> str:
return """
{
"node_types": [
{
"label": "Person",
"properties": [
{"name": "name", "type": "STRING"}
]
},
{
"label": "Organization",
"properties": [
{"name": "name", "type": "STRING"}
]
}
],
"relationship_types": [
{
"label": "WORKS_FOR",
"properties": [
{"name": "since", "type": "DATE"}
]
}
],
"patterns": [
["Person", "WORKS_FOR", "Organization"]
]
}
"""
@pytest.fixture
def schema_json_with_valid_constraints() -> str:
return """
{
"node_types": [
{
"label": "Person",
"properties": [
{"name": "name", "type": "STRING"},
{"name": "email", "type": "STRING"}
]
},
{
"label": "Organization",
"properties": [
{"name": "name", "type": "STRING"}
]
}
],
"relationship_types": [
{
"label": "WORKS_FOR",
"properties": [
{"name": "since", "type": "DATE"}
]
}
],
"patterns": [
["Person", "WORKS_FOR", "Organization"]
],
"constraints": [
{"type": "UNIQUENESS", "node_type": "Person", "property_name": "name"}
]
}
"""
@pytest.fixture
def schema_json_with_invalid_constraints() -> str:
return """
{
"node_types": [
{
"label": "Person",
"properties": [
{"name": "name", "type": "STRING"}
]
},
{
"label": "Organization",
"properties": [
{"name": "name", "type": "STRING"}
]
}
],
"relationship_types": [
{
"label": "WORKS_FOR",
"properties": [
{"name": "since", "type": "DATE"}
]
}
],
"patterns": [
["Person", "WORKS_FOR", "Organization"]
],
"constraints": [
{"type": "UNIQUENESS", "node_type": "Person", "property_name": "name"},
{"type": "UNIQUENESS", "node_type": "Person", "property_name": "email"},
{"type": "UNIQUENESS", "node_type": "NonExistentNode", "property_name": "id"},
{"type": "UNIQUENESS", "node_type": "Person", "property_name": ""}
]
}
"""
@pytest.fixture
def schema_json_with_null_constraints() -> str:
return """
{
"node_types": [
{
"label": "Person",
"properties": [
{"name": "name", "type": "STRING"}
]
},
{
"label": "Organization",
"properties": [
{"name": "name", "type": "STRING"}
]
}
],
"relationship_types": [
{
"label": "WORKS_FOR",
"properties": [
{"name": "since", "type": "DATE"}
]
}
],
"patterns": [
["Person", "WORKS_FOR", "Organization"]
],
"constraints": null
}
"""
@pytest.fixture
def schema_json_with_required_properties() -> str:
return """
{
"node_types": [
{
"label": "Person",
"properties": [
{"name": "name", "type": "STRING", "required": true},
{"name": "email", "type": "STRING", "required": false},
{"name": "phone", "type": "STRING"}
]
}
],
"relationship_types": [
{"label": "KNOWS"}
],
"patterns": [
["Person", "KNOWS", "Person"]
]
}
"""
@pytest.fixture
def schema_json_with_string_required_values() -> str:
return """
{
"node_types": [
{
"label": "Person",
"properties": [
{"name": "name", "type": "STRING", "required": "true"},
{"name": "email", "type": "STRING", "required": "yes"},
{"name": "phone", "type": "STRING", "required": "false"},
{"name": "address", "type": "STRING", "required": "no"}
]
}
],
"relationship_types": [
{"label": "KNOWS"}
],
"patterns": [
["Person", "KNOWS", "Person"]
]
}
"""
@pytest.fixture
def invalid_schema_json() -> str:
return """
{
"node_types": [
{
"label": "Person",
},
],
invalid json content
}
"""
@pytest.fixture
def schema_from_text(mock_llm: AsyncMock) -> SchemaFromTextExtractor:
return SchemaFromTextExtractor(llm=mock_llm)
@pytest.mark.asyncio
async def test_schema_from_text_run_valid_response(
schema_from_text: SchemaFromTextExtractor,
mock_llm: AsyncMock,
valid_schema_json: str,
) -> None:
# configure the mock LLM to return a valid schema JSON
mock_llm.ainvoke.return_value = LLMResponse(content=valid_schema_json)
# run the schema extraction
schema_config = await schema_from_text.run(text="Sample text for extraction")
# verify the LLM was called with a prompt
mock_llm.ainvoke.assert_called_once()
prompt_arg = mock_llm.ainvoke.call_args[0][0]
assert isinstance(prompt_arg, str)
assert "Sample text for extraction" in prompt_arg
# verify the schema was correctly extracted
assert len(schema_config.node_types) == 2
assert schema_config.node_type_from_label("Person") is not None
assert schema_config.node_type_from_label("Organization") is not None
assert schema_config.relationship_types is not None
assert schema_config.relationship_type_from_label("WORKS_FOR") is not None
assert schema_config.patterns is not None
assert len(schema_config.patterns) == 1
assert schema_config.patterns[0] == ("Person", "WORKS_FOR", "Organization")
@pytest.mark.asyncio
async def test_schema_from_text_run_invalid_json(
schema_from_text: SchemaFromTextExtractor,
mock_llm: AsyncMock,
invalid_schema_json: str,
) -> None:
# configure the mock LLM to return invalid JSON
mock_llm.ainvoke.return_value = LLMResponse(content=invalid_schema_json)
# verify that running with invalid JSON raises a ValueError
with pytest.raises(SchemaExtractionError) as exc_info:
await schema_from_text.run(text="Sample text for extraction")
assert "not valid JSON" in str(exc_info.value)
@pytest.mark.asyncio
async def test_schema_from_text_custom_template(
mock_llm: AsyncMock, valid_schema_json: str
) -> None:
# create a custom template
custom_prompt = "This is a custom prompt with text: {text}"
custom_template = PromptTemplate(template=custom_prompt, expected_inputs=["text"])
# create SchemaFromTextExtractor with the custom template
schema_from_text = SchemaFromTextExtractor(
llm=mock_llm, prompt_template=custom_template
)
# configure mock LLM to return valid JSON and capture the prompt that was sent to it
mock_llm.ainvoke.return_value = LLMResponse(content=valid_schema_json)
# run the schema extraction
await schema_from_text.run(text="Sample text")
# verify the custom prompt was passed to the LLM
prompt_sent_to_llm = mock_llm.ainvoke.call_args[0][0]
assert "This is a custom prompt with text" in prompt_sent_to_llm
@pytest.mark.asyncio
async def test_schema_from_text_llm_params(
mock_llm: AsyncMock, valid_schema_json: str
) -> None:
# configure custom LLM parameters
llm_params = {"temperature": 0.1, "max_tokens": 500}
# create SchemaFromTextExtractor with custom LLM parameters
schema_from_text = SchemaFromTextExtractor(llm=mock_llm, llm_params=llm_params)
# configure the mock LLM to return a valid schema JSON
mock_llm.ainvoke.return_value = LLMResponse(content=valid_schema_json)
# run the schema extraction
await schema_from_text.run(text="Sample text")
# verify the LLM was called with the custom parameters
mock_llm.ainvoke.assert_called_once()
call_kwargs = mock_llm.ainvoke.call_args[1]
assert call_kwargs["temperature"] == 0.1
assert call_kwargs["max_tokens"] == 500
@pytest.mark.asyncio
async def test_schema_config_save_json(graph_schema: GraphSchema) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
# create file path
json_path = os.path.join(temp_dir, "schema.json")
# store the schema config
graph_schema.save(json_path)
# verify the file exists and has content
assert os.path.exists(json_path)
assert os.path.getsize(json_path) > 0
# verify the content is valid JSON and contains expected data
with open(json_path, "r") as f:
data = json.load(f)
assert "node_types" in data
assert len(data["node_types"]) == 3
@pytest.mark.asyncio
async def test_schema_config_save_yaml(graph_schema: GraphSchema) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
# Create file path
yaml_path = os.path.join(temp_dir, "schema.yaml")
# Store the schema config
graph_schema.save(yaml_path)
# Verify the file exists and has content
assert os.path.exists(yaml_path)
assert os.path.getsize(yaml_path) > 0
# Verify the content is valid YAML and contains expected data
with open(yaml_path, "r") as f:
data = yaml.safe_load(f)
assert "node_types" in data
assert len(data["node_types"]) == 3
@pytest.mark.asyncio
async def test_schema_config_from_file(graph_schema: GraphSchema) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
# create file paths with different extensions
json_path = os.path.join(temp_dir, "schema.json")
yaml_path = os.path.join(temp_dir, "schema.yaml")
yml_path = os.path.join(temp_dir, "schema.yml")
# store the schema config in the different formats
graph_schema.save(json_path)
graph_schema.save(yaml_path)
graph_schema.save(yml_path)
# load using from_file which should detect the format based on extension
json_schema = GraphSchema.from_file(json_path)
yaml_schema = GraphSchema.from_file(yaml_path)
yml_schema = GraphSchema.from_file(yml_path)
# simple verification that the objects were loaded correctly
assert isinstance(json_schema, GraphSchema)
assert isinstance(yaml_schema, GraphSchema)
assert isinstance(yml_schema, GraphSchema)
# verify basic structure is intact
assert "node_types" in json_schema.model_dump()
assert "node_types" in yaml_schema.model_dump()
assert "node_types" in yml_schema.model_dump()
# verify an unsupported extension raises the correct error
txt_path = os.path.join(temp_dir, "schema.txt")
graph_schema.save(
txt_path, format=FileFormat.JSON
) # Store as JSON but with .txt extension
with pytest.raises(ValueError, match="Unsupported file format: None"):
GraphSchema.from_file(txt_path)
@pytest.fixture
def valid_schema_json_array() -> str:
return """
[
{
"node_types": [
{
"label": "Person",
"properties": [
{"name": "name", "type": "STRING"}
]
},
{
"label": "Organization",
"properties": [
{"name": "name", "type": "STRING"}
]
}
],
"relationship_types": [
{
"label": "WORKS_FOR",
"properties": [
{"name": "since", "type": "DATE"}
]
}
],
"patterns": [
["Person", "WORKS_FOR", "Organization"]
]
}
]
"""
@pytest.mark.asyncio
async def test_schema_from_text_run_valid_json_array(
schema_from_text: SchemaFromTextExtractor,
mock_llm: AsyncMock,
valid_schema_json_array: str,
) -> None:
# configure the mock LLM to return a valid JSON array
mock_llm.ainvoke.return_value = LLMResponse(content=valid_schema_json_array)
# run the schema extraction
schema = await schema_from_text.run(text="Sample text for extraction")
# verify the schema was correctly extracted from the array
assert len(schema.node_types) == 2
assert schema.node_type_from_label("Person") is not None
assert schema.node_type_from_label("Organization") is not None
assert schema.relationship_types is not None
assert schema.relationship_type_from_label("WORKS_FOR") is not None
assert schema.patterns is not None
assert len(schema.patterns) == 1
assert schema.patterns[0] == ("Person", "WORKS_FOR", "Organization")
@pytest.fixture
def schema_json_with_invalid_node_patterns() -> str:
return """
{
"node_types": [
{
"label": "Person",
"properties": [
{"name": "name", "type": "STRING"}
]
},
{
"label": "Organization",
"properties": [
{"name": "name", "type": "STRING"}
]
}
],
"relationship_types": [
{
"label": "WORKS_FOR",
"properties": [
{"name": "since", "type": "DATE"}
]
}
],
"patterns": [
["Person", "WORKS_FOR", "Organization"],
["Person", "WORKS_FOR", "UndefinedNode"],
["UndefinedNode", "WORKS_FOR", "Organization"]
]
}
"""
@pytest.fixture
def schema_json_with_invalid_relationship_patterns() -> str:
return """
{
"node_types": [
{
"label": "Person",
"properties": [
{"name": "name", "type": "STRING"}
]
},
{
"label": "Organization",
"properties": [
{"name": "name", "type": "STRING"}
]
}
],
"relationship_types": [
{
"label": "WORKS_FOR",
"properties": [
{"name": "since", "type": "DATE"}
]
}
],
"patterns": [
["Person", "WORKS_FOR", "Organization"],
["Person", "UNDEFINED_RELATION", "Organization"],
["Organization", "ANOTHER_UNDEFINED_RELATION", "Person"]
]
}
"""
@pytest.fixture
def schema_json_with_nodes_without_labels() -> str:
return """
{
"node_types": [
{
"label": "Person",
"properties": [
{"name": "name", "type": "STRING"}
]
},
{
"properties": [
{"name": "name", "type": "STRING"}
]
},
{
"label": "",
"properties": [
{"name": "name", "type": "STRING"}
]
},
"Organization",
"",
"Company",
"Invalid description with spaces",
"{\\"invalid\\": \\"json object\\"}"
],
"relationship_types": [
{
"label": "WORKS_FOR",
"properties": [
{"name": "since", "type": "DATE"}
]
}
],
"patterns": [
["Person", "WORKS_FOR", "Organization"]
]
}
"""
@pytest.fixture
def schema_json_with_relationships_without_labels() -> str:
return """
{
"node_types": [
{
"label": "Person",
"properties": [
{"name": "name", "type": "STRING"}
]
},
{
"label": "Organization",
"properties": [
{"name": "name", "type": "STRING"}
]
}
],
"relationship_types": [
{
"label": "WORKS_FOR",
"properties": [
{"name": "since", "type": "DATE"}
]
},
{
"properties": [
{"name": "since", "type": "DATE"}
]
},
{
"label": "",
"properties": [
{"name": "since", "type": "DATE"}
]
},
"MANAGES",
"",
"SUPERVISES",
"invalid relationship description",
"{\\"invalid\\": \\"json\\"}"
],
"patterns": [
["Person", "WORKS_FOR", "Organization"],
["Person", "MANAGES", "Organization"]
]
}
"""
@pytest.fixture
def schema_json_with_nonexistent_property_constraint() -> str:
return """
{
"node_types": [
{
"label": "Person",
"properties": [
{"name": "name", "type": "STRING"}
]
}
],
"relationship_types": [],
"patterns": [],
"constraints": [
{"type": "UNIQUENESS", "node_type": "Person", "property_name": "name"},
{"type": "UNIQUENESS", "node_type": "Person", "property_name": "nonexistent_property"}
]
}
"""
@pytest.mark.asyncio
async def test_schema_from_text_filters_invalid_node_patterns(
schema_from_text: SchemaFromTextExtractor,
mock_llm: AsyncMock,
schema_json_with_invalid_node_patterns: str,
) -> None:
# configure the mock LLM to return schema with invalid node patterns
mock_llm.ainvoke.return_value = LLMResponse(
content=schema_json_with_invalid_node_patterns
)
# run the schema extraction
schema = await schema_from_text.run(text="Sample text for extraction")
# verify that invalid node patterns were filtered out (2 out of 3 patterns should be removed)
assert schema.patterns is not None
assert len(schema.patterns) == 1
assert schema.patterns[0] == ("Person", "WORKS_FOR", "Organization")
@pytest.mark.asyncio
async def test_schema_from_text_filters_invalid_relationship_patterns(
schema_from_text: SchemaFromTextExtractor,
mock_llm: AsyncMock,
schema_json_with_invalid_relationship_patterns: str,
) -> None:
# configure the mock LLM to return schema with invalid relationship patterns
mock_llm.ainvoke.return_value = LLMResponse(
content=schema_json_with_invalid_relationship_patterns
)
# run the schema extraction
schema = await schema_from_text.run(text="Sample text for extraction")
# verify that invalid relationship patterns were filtered out (2 out of 3 patterns should be removed)
assert schema.patterns is not None
assert len(schema.patterns) == 1
assert schema.patterns[0] == ("Person", "WORKS_FOR", "Organization")
@pytest.mark.asyncio
async def test_schema_from_text_filters_nodes_without_labels(
schema_from_text: SchemaFromTextExtractor,
mock_llm: AsyncMock,
schema_json_with_nodes_without_labels: str,
) -> None:
# configure the mock LLM to return schema with nodes without labels
mock_llm.ainvoke.return_value = LLMResponse(
content=schema_json_with_nodes_without_labels
)
# run the schema extraction
schema = await schema_from_text.run(text="Sample text for extraction")
# verify that nodes without labels were filtered out (5 out of 8 nodes should be removed)
assert len(schema.node_types) == 3
assert schema.node_type_from_label("Person") is not None
assert schema.node_type_from_label("Organization") is not None
assert schema.node_type_from_label("Company") is not None
# verify that the pattern is still valid with the remaining nodes
assert schema.patterns is not None
assert len(schema.patterns) == 1
assert schema.patterns[0] == ("Person", "WORKS_FOR", "Organization")
@pytest.mark.asyncio
async def test_schema_from_text_filters_relationships_without_labels(
schema_from_text: SchemaFromTextExtractor,
mock_llm: AsyncMock,
schema_json_with_relationships_without_labels: str,
) -> None:
# configure the mock LLM to return schema with relationships without labels
mock_llm.ainvoke.return_value = LLMResponse(
content=schema_json_with_relationships_without_labels
)
# run the schema extraction
schema = await schema_from_text.run(text="Sample text for extraction")
# verify that relationships without labels were filtered out (5 out of 8 relationships should be removed)
assert schema.relationship_types is not None
assert len(schema.relationship_types) == 3
assert schema.relationship_type_from_label("WORKS_FOR") is not None
assert schema.relationship_type_from_label("MANAGES") is not None
assert schema.relationship_type_from_label("SUPERVISES") is not None
# verify that the patterns are still valid with the remaining relationships
assert schema.patterns is not None
assert len(schema.patterns) == 2
assert ("Person", "WORKS_FOR", "Organization") in schema.patterns
assert ("Person", "MANAGES", "Organization") in schema.patterns
@pytest.mark.asyncio
async def test_schema_from_text_with_valid_constraints(
schema_from_text: SchemaFromTextExtractor,
mock_llm: AsyncMock,
schema_json_with_valid_constraints: str,
) -> None:
# configure the mock LLM to return schema with valid constraints
mock_llm.ainvoke.return_value = LLMResponse(
content=schema_json_with_valid_constraints
)
# run the schema extraction
schema = await schema_from_text.run(text="Sample text for extraction")
assert len(schema.constraints) == 1
assert schema.constraints[0].type == "UNIQUENESS"
assert schema.constraints[0].node_type == "Person"
assert schema.constraints[0].property_names == ("name",)
@pytest.mark.asyncio
async def test_schema_from_text_filters_invalid_constraints(
schema_from_text: SchemaFromTextExtractor,
mock_llm: AsyncMock,
schema_json_with_invalid_constraints: str,
) -> None:
# configure the mock LLM to return schema with invalid constraints
mock_llm.ainvoke.return_value = LLMResponse(
content=schema_json_with_invalid_constraints
)
# run the schema extraction
schema = await schema_from_text.run(text="Sample text for extraction")
# verify that invalid constraints were filtered out:
# constraints with NonExistentNode should be removed
# constraint with empty property_name should be removed
# only the valid constraint should remain
assert len(schema.constraints) == 1
assert schema.constraints[0].node_type == "Person"
assert schema.constraints[0].property_names == ("name",)
@pytest.mark.asyncio
async def test_schema_from_text_filters_constraint_with_nonexistent_property(
schema_from_text: SchemaFromTextExtractor,
mock_llm: AsyncMock,
schema_json_with_nonexistent_property_constraint: str,
) -> None:
# configure the mock LLM to return schema with constraint on nonexistent property
mock_llm.ainvoke.return_value = LLMResponse(
content=schema_json_with_nonexistent_property_constraint
)
# run the schema extraction
schema = await schema_from_text.run(text="Sample text for extraction")
# verify that only the valid constraint (with "name" property) remains
# the constraint with "nonexistent_property" should be filtered out
assert len(schema.constraints) == 1
assert schema.constraints[0].property_names == ("name",)
@pytest.mark.asyncio
async def test_schema_from_text_handles_null_constraints(
schema_from_text: SchemaFromTextExtractor,
mock_llm: AsyncMock,
schema_json_with_null_constraints: str,
) -> None:
# configure the mock LLM to return schema with null constraints
mock_llm.ainvoke.return_value = LLMResponse(
content=schema_json_with_null_constraints
)
# run the schema extraction - should not crash
schema = await schema_from_text.run(text="Sample text for extraction")
# verify schema was created with empty constraints
assert len(schema.constraints) == 0
@pytest.mark.asyncio
async def test_schema_from_text_handles_missing_constraints(
schema_from_text: SchemaFromTextExtractor,
mock_llm: AsyncMock,
valid_schema_json: str,
) -> None:
# configure the mock LLM to return schema without constraints field
mock_llm.ainvoke.return_value = LLMResponse(content=valid_schema_json)
# run the schema extraction - should not crash
schema = await schema_from_text.run(text="Sample text for extraction")
# verify schema was created with empty constraints
assert len(schema.constraints) == 0
def test_clean_json_content_markdown_with_json_language(
schema_from_text: SchemaFromTextExtractor,
) -> None:
content = """```json
{"node_types": [{"label": "Person"}]}
```"""
cleaned = schema_from_text._clean_json_content(content)
assert cleaned == '{"node_types": [{"label": "Person"}]}'
def test_clean_json_content_markdown_without_language(
schema_from_text: SchemaFromTextExtractor,
) -> None:
content = """```
{"node_types": [{"label": "Person"}]}
```"""
cleaned = schema_from_text._clean_json_content(content)
assert cleaned == '{"node_types": [{"label": "Person"}]}'
def test_clean_json_content_plain_json(
schema_from_text: SchemaFromTextExtractor,
) -> None:
content = '{"node_types": [{"label": "Person"}]}'
cleaned = schema_from_text._clean_json_content(content)
assert cleaned == '{"node_types": [{"label": "Person"}]}'
@pytest.mark.asyncio
async def test_schema_from_text_with_required_properties(
schema_from_text: SchemaFromTextExtractor,
mock_llm: AsyncMock,
schema_json_with_required_properties: str,
) -> None:
mock_llm.ainvoke.return_value = LLMResponse(
content=schema_json_with_required_properties
)
schema = await schema_from_text.run(text="Sample text for test")
person = schema.node_type_from_label("Person")
assert person is not None
# Legacy required:true migrates to EXISTENCE; flags are cleared on PropertyType
assert schema.existence_property_names_for_node("Person") == {"name"}
name_prop = next((p for p in person.properties if p.name == "name"), None)
email_prop = next((p for p in person.properties if p.name == "email"), None)
phone_prop = next((p for p in person.properties if p.name == "phone"), None)
assert name_prop is not None and name_prop.required is False
assert email_prop is not None and email_prop.required is False
assert phone_prop is not None and phone_prop.required is False
@pytest.mark.asyncio
async def test_schema_from_text_string_required_coerced_without_existence_migration(
schema_from_text: SchemaFromTextExtractor,
mock_llm: AsyncMock,
schema_json_with_string_required_values: str,
) -> None:
"""LLMs may emit string truthiness for ``required``; Pydantic coerces it on ``PropertyType``.
Migration of legacy ``required`` to ``EXISTENCE`` constraints only runs when the raw
property dict has ``required is True`` (JSON boolean). String values such as ``\"true\"``
are not migrated; add ``ConstraintType`` rows with type ``EXISTENCE`` on ``GraphSchema``
if you need existence semantics for that case.
"""
mock_llm.ainvoke.return_value = LLMResponse(
content=schema_json_with_string_required_values
)
schema = await schema_from_text.run(text="Sample text for test")
person = schema.node_type_from_label("Person")
assert person is not None
assert schema.existence_property_names_for_node("Person") == set()
name_prop = next((p for p in person.properties if p.name == "name"), None)
email_prop = next((p for p in person.properties if p.name == "email"), None)
assert name_prop is not None
assert email_prop is not None
assert name_prop.model_dump().get("required") is True
assert email_prop.model_dump().get("required") is True
phone_prop = next((p for p in person.properties if p.name == "phone"), None)
address_prop = next((p for p in person.properties if p.name == "address"), None)
assert phone_prop is not None
assert address_prop is not None
assert phone_prop.model_dump().get("required") is False
assert address_prop.model_dump().get("required") is False
@pytest.mark.asyncio
async def test_schema_from_text_handles_missing_required_field(
schema_from_text: SchemaFromTextExtractor,
mock_llm: AsyncMock,
valid_schema_json: str,
) -> None:
mock_llm.ainvoke.return_value = LLMResponse(content=valid_schema_json)
schema = await schema_from_text.run(text="Sample text")
person = schema.node_type_from_label("Person")
assert person is not None
# All properties should have required=False (default)
for prop in person.properties:
assert prop.required is False
@pytest.mark.asyncio
async def test_schema_from_text_uniqueness_does_not_force_required_property(
schema_from_text: SchemaFromTextExtractor,
mock_llm: AsyncMock,
) -> None:
schema_json = """
{
"node_types": [
{
"label": "Person",
"properties": [
{"name": "name", "type": "STRING", "required": false},
{"name": "email", "type": "STRING", "required": false}
]
}
],
"relationship_types": [],
"patterns": [],
"constraints": [
{"type": "UNIQUENESS", "node_type": "Person", "property_name": "name"}
]
}
"""
mock_llm.ainvoke.return_value = LLMResponse(content=schema_json)
schema = await schema_from_text.run(text="Sample text")
person = schema.node_type_from_label("Person")
assert person is not None
name_prop = next((p for p in person.properties if p.name == "name"), None)
email_prop = next((p for p in person.properties if p.name == "email"), None)
assert name_prop is not None and name_prop.required is False
assert email_prop is not None and email_prop.required is False
assert schema.existence_property_names_for_node("Person") == set()
assert schema.constraints[0].type == "UNIQUENESS"
@pytest.mark.asyncio
@patch("neo4j_graphrag.experimental.components.schema.get_structured_schema")
async def test_schema_from_existing_graph(mock_get_structured_schema: Mock) -> None:
mock_get_structured_schema.return_value = {
"node_props": {
"Person": [
{"property": "id", "type": "INTEGER"},
{"property": "name", "type": "STRING"},
]
},
"rel_props": {"KNOWS": [{"property": "fromDate", "type": "DATE"}]},
"relationships": [
{"start": "Person", "type": "KNOWS", "end": "Person"},
{"start": "Person", "type": "LIVES_IN", "end": "City"},
],
"metadata": {
"constraint": [
{
"id": 7,
"name": "person_id",
"type": "NODE_PROPERTY_EXISTENCE",
"entityType": "NODE",
"labelsOrTypes": ["Person"],
"properties": ["id"],
"ownedIndex": "person_id",
"propertyType": None,
},
],
"index": [
{
"label": "Person",
"properties": ["name"],
"size": 2,
"type": "RANGE",
"valuesSelectivity": 1.0,
"distinctValues": 2.0,
},
],
},
}
driver = Mock()
schema_builder = SchemaFromExistingGraphExtractor(
driver=driver,
)
schema = await schema_builder.run()
assert isinstance(schema, GraphSchema)
assert len(schema.node_types) == 2
person_node_type = schema.node_type_from_label("Person")
assert person_node_type is not None
id_person_property = [p for p in person_node_type.properties if p.name == "id"][0]
assert id_person_property.required is False
assert schema.existence_property_names_for_node("Person") == {"id"}
assert any(
c.type == "EXISTENCE"
and c.node_type == "Person"
and c.property_names == ("id",)
for c in schema.constraints
)
assert person_node_type.additional_properties is False
city_node_type = schema.node_type_from_label("City")
assert city_node_type is not None
assert city_node_type.additional_properties is True
assert len(schema.relationship_types) == 2
knows_rel = schema.relationship_type_from_label("KNOWS")
assert knows_rel is not None
assert knows_rel.additional_properties is False
lives_rel = schema.relationship_type_from_label("LIVES_IN")
assert lives_rel is not None
assert lives_rel.additional_properties is True
assert schema.additional_node_types is False
assert schema.additional_relationship_types is False
assert schema.additional_patterns is False
assert schema.patterns == (
Pattern(source="Person", relationship="KNOWS", target="Person"),
Pattern(source="Person", relationship="LIVES_IN", target="City"),
)
@pytest.mark.asyncio
@patch("neo4j_graphrag.experimental.components.schema.get_structured_schema")
async def test_schema_from_existing_graph_additional_params(
mock_get_structured_schema: Mock,
) -> None:
mock_get_structured_schema.return_value = {
"node_props": {
"Person": [
{"property": "id", "type": "INTEGER"},
{"property": "name", "type": "STRING"},
]
},
"rel_props": {"KNOWS": [{"property": "fromDate", "type": "DATE"}]},
"relationships": [
{"start": "Person", "type": "KNOWS", "end": "Person"},
{"start": "Person", "type": "LIVES_IN", "end": "City"},
],
}
driver = Mock()
schema_builder = SchemaFromExistingGraphExtractor(
driver=driver,
additional_node_types=True,
additional_relationship_types=True,
additional_patterns=True,
additional_properties=True,
)
schema = await schema_builder.run()
person_node_type = schema.node_type_from_label("Person")
assert person_node_type is not None
assert person_node_type.additional_properties is True
city_node_type = schema.node_type_from_label("City")
assert city_node_type is not None
assert city_node_type.additional_properties is True
knows_rel = schema.relationship_type_from_label("KNOWS")
assert knows_rel is not None
assert knows_rel.additional_properties is True
lives_rel = schema.relationship_type_from_label("LIVES_IN")
assert lives_rel is not None
assert lives_rel.additional_properties is True
assert schema.additional_node_types is True
assert schema.additional_relationship_types is True
assert schema.additional_patterns is True
def test_extract_graph_constraints_from_metadata_node_key_maps_to_key() -> None:
"""Neo4j ``NODE_KEY`` metadata must become ``GraphConstraintType.KEY``, not EXISTENCE."""
structured_schema: dict[str, Any] = {
"metadata": {
"constraint": [
{
"type": "NODE_KEY",
"labelsOrTypes": ["Person"],
"properties": ["email"],
}
]
}
}
out = SchemaFromExistingGraphExtractor._extract_graph_constraints_from_metadata(
structured_schema
)
assert out == [
{
"type": GraphConstraintType.KEY.value,
"node_type": "Person",
"property_name": "email",
"property_names": ("email",),
"relationship_type": None,
}
]
def test_extract_graph_constraints_from_metadata_relationship_key_maps_to_key() -> None:
"""Neo4j ``RELATIONSHIP_KEY`` metadata maps to relationship-scoped ``KEY`` constraints."""
structured_schema: dict[str, Any] = {
"metadata": {
"constraint": [
{
"type": "RELATIONSHIP_KEY",
"labelsOrTypes": ["WORKS_FOR"],
"properties": ["since"],
}
]
}
}
out = SchemaFromExistingGraphExtractor._extract_graph_constraints_from_metadata(
structured_schema
)
assert out == [
{
"type": GraphConstraintType.KEY.value,
"node_type": "",
"property_name": "since",
"property_names": ("since",),
"relationship_type": "WORKS_FOR",
}
]
def test_graph_schema_extraction_output_json_schema_lean_root() -> None:
"""Structured-output schema must not include pipeline-only GraphSchema flags."""
from neo4j_graphrag.experimental.components.graph_schema_extraction import (
GraphSchemaExtractionOutput,
)
raw = GraphSchemaExtractionOutput.model_json_schema()
dumped = json.dumps(raw)
assert "additional_node_types" not in dumped
assert "additional_relationship_types" not in dumped
assert "additional_patterns" not in dumped
def test_graph_schema_extraction_constraint_schema_avoids_null_type_for_vertex() -> (
None
):
"""Vertex AI maps JSON Schema to protobuf and rejects ``type: \"null\"`` (e.g. ``Optional``)."""
from neo4j_graphrag.experimental.components.graph_schema_extraction import (
GraphSchemaExtractionOutput,
)
raw = GraphSchemaExtractionOutput.model_json_schema()
ect = (raw.get("$defs") or {}).get("ExtractedConstraintType")
assert ect is not None
rel_schema = (ect.get("properties") or {}).get("relationship_type", {})
assert "anyOf" not in rel_schema
assert '"type": "null"' not in json.dumps(rel_schema)
def test_graph_schema_from_extraction_output() -> None:
from neo4j_graphrag.experimental.components.graph_schema_extraction import (
ExtractedConstraintType,
ExtractedNodeType,
ExtractedPropertyType,
GraphSchemaExtractionOutput,
)
dto = GraphSchemaExtractionOutput(
node_types=[
ExtractedNodeType(
label="Person",
properties=[
ExtractedPropertyType(name="name", type="STRING"),
],
)
],
relationship_types=[],
patterns=[],
constraints=[
ExtractedConstraintType(
type="UNIQUENESS",
node_type="Person",
property_names=["name"],
),
ExtractedConstraintType(
type="EXISTENCE",
node_type="Person",
property_names=["name"],
relationship_type="",
),
],
)
gs = GraphSchema.from_extraction_output(dto)
assert gs.node_types[0].label == "Person"
assert gs.node_types[0].properties[0].required is False
assert gs.existence_property_names_for_node("Person") == {"name"}
assert {c.property_names for c in gs.constraints} == {("name",)}
assert gs.additional_node_types is False
def test_validate_extraction_dict_to_graph_schema() -> None:
from neo4j_graphrag.experimental.components.schema import (
validate_extraction_dict_to_graph_schema,
)
d = {
"node_types": [
{
"label": "Person",
"properties": [{"name": "name", "type": "STRING", "required": False}],
}
],
"relationship_types": [],
"patterns": [],
"constraints": [],
}
gs = validate_extraction_dict_to_graph_schema(d)
assert len(gs.node_types) == 1
assert gs.node_types[0].label == "Person"