Skip to content

Commit c926585

Browse files
authored
Merge branch 'main' into willtai/use-exceptions-for-error-handling
2 parents e7902e8 + a25ca4b commit c926585

File tree

6 files changed

+234
-84
lines changed

6 files changed

+234
-84
lines changed

.github/workflows/pr-e2e-tests.yaml

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,4 +71,11 @@ jobs:
7171
run: |
7272
count=0; until curl -s --fail localhost:8080/v1/.well-known/ready; do ((count++)); [ $count -ge 10 ] && echo "Reached maximum retry limit" && exit 1; sleep 15; done
7373
- name: Run tests
74-
run: poetry run pytest ./tests/e2e
74+
shell: bash
75+
run: |
76+
if [[ "${{ matrix.neo4j-edition }}" == "community" ]]; then
77+
poetry run pytest -m 'not enterprise_only' ./tests/e2e
78+
else
79+
poetry run pytest ./tests/e2e
80+
fi
81+

src/neo4j_genai/schema.py

Lines changed: 67 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
# See the License for the specific language governing permissions and
1414
# limitations under the License.
1515
from typing import Any, Optional
16+
from neo4j.exceptions import ClientError
1617

1718
import neo4j
1819

@@ -49,6 +50,12 @@
4950
RETURN {start: label, type: property, end: toString(other_node)} AS output
5051
"""
5152

53+
INDEX_QUERY = """
54+
CALL apoc.schema.nodes() YIELD label, properties, type, size, valuesSelectivity
55+
WHERE type = "RANGE" RETURN *,
56+
size * valuesSelectivity as distinctValues
57+
"""
58+
5259

5360
def query_database(
5461
driver: neo4j.Driver, query: str, params: Optional[dict] = None
@@ -82,6 +89,51 @@ def get_schema(
8289
Returns:
8390
str: the graph schema information in a serialized format.
8491
"""
92+
structured_schema = get_structured_schema(driver)
93+
94+
def _format_props(props):
95+
return ", ".join([f"{prop['property']}: {prop['type']}" for prop in props])
96+
97+
# Format node properties
98+
formatted_node_props = [
99+
f"{label} {{{_format_props(props)}}}"
100+
for label, props in structured_schema["node_props"].items()
101+
]
102+
103+
# Format relationship properties
104+
formatted_rel_props = [
105+
f"{rel_type} {{{_format_props(props)}}}"
106+
for rel_type, props in structured_schema["rel_props"].items()
107+
]
108+
109+
# Format relationships
110+
formatted_rels = [
111+
f"(:{element['start']})-[:{element['type']}]->(:{element['end']})"
112+
for element in structured_schema["relationships"]
113+
]
114+
115+
return "\n".join(
116+
[
117+
"Node properties:",
118+
"\n".join(formatted_node_props),
119+
"Relationship properties:",
120+
"\n".join(formatted_rel_props),
121+
"The relationships:",
122+
"\n".join(formatted_rels),
123+
]
124+
)
125+
126+
127+
def get_structured_schema(driver: neo4j.Driver) -> dict[str, Any]:
128+
"""
129+
Returns the structured schema of the graph.
130+
131+
Args:
132+
driver (neo4j.Driver): Neo4j Python driver instance.
133+
134+
Returns:
135+
dict[str, Any]: the graph schema information in a structured format.
136+
"""
85137
node_properties = [
86138
data["output"]
87139
for data in query_database(
@@ -97,6 +149,7 @@ def get_schema(
97149
driver, REL_PROPERTIES_QUERY, params={"EXCLUDED_LABELS": EXCLUDED_RELS}
98150
)
99151
]
152+
100153
relationships = [
101154
data["output"]
102155
for data in query_database(
@@ -106,35 +159,17 @@ def get_schema(
106159
)
107160
]
108161

109-
# Format node properties
110-
formatted_node_props = []
111-
for element in node_properties:
112-
props_str = ", ".join(
113-
[f"{prop['property']}: {prop['type']}" for prop in element["properties"]]
114-
)
115-
formatted_node_props.append(f"{element['labels']} {{{props_str}}}")
116-
117-
# Format relationship properties
118-
formatted_rel_props = []
119-
for element in rel_properties:
120-
props_str = ", ".join(
121-
[f"{prop['property']}: {prop['type']}" for prop in element["properties"]]
122-
)
123-
formatted_rel_props.append(f"{element['type']} {{{props_str}}}")
124-
125-
# Format relationships
126-
formatted_rels = [
127-
f"(:{element['start']})-[:{element['type']}]->(:{element['end']})"
128-
for element in relationships
129-
]
130-
131-
return "\n".join(
132-
[
133-
"Node properties:",
134-
"\n".join(formatted_node_props),
135-
"Relationship properties:",
136-
"\n".join(formatted_rel_props),
137-
"The relationships:",
138-
"\n".join(formatted_rels),
139-
]
140-
)
162+
# Get constraints and indexes
163+
try:
164+
constraint = query_database(driver, "SHOW CONSTRAINTS")
165+
index = query_database(driver, INDEX_QUERY)
166+
except ClientError:
167+
constraint = []
168+
index = []
169+
170+
return {
171+
"node_props": {el["labels"]: el["properties"] for el in node_properties},
172+
"rel_props": {el["type"]: el["properties"] for el in rel_properties},
173+
"relationships": relationships,
174+
"metadata": {"constraint": constraint, "index": index},
175+
}

tests/e2e/pytest.ini

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
[pytest]
2+
markers = enterprise_only: marks tests as neo4j enterprise edition only

tests/e2e/test_schema_e2e.py

Lines changed: 47 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,12 @@
1515

1616
import pytest
1717
from neo4j_genai.schema import (
18-
BASE_ENTITY_LABEL,
19-
EXCLUDED_LABELS,
20-
EXCLUDED_RELS,
18+
query_database,
19+
get_structured_schema,
2120
NODE_PROPERTIES_QUERY,
21+
BASE_ENTITY_LABEL,
2222
REL_PROPERTIES_QUERY,
2323
REL_QUERY,
24-
query_database,
2524
)
2625

2726

@@ -78,41 +77,56 @@ def test_cypher_returns_correct_relationships(driver):
7877
)
7978

8079

81-
@pytest.mark.usefixtures("setup_neo4j_for_schema_query_with_excluded_labels")
82-
def test_filtering_labels_node_properties(driver):
83-
node_properties = [
84-
data["output"]
85-
for data in query_database(
86-
driver,
87-
NODE_PROPERTIES_QUERY,
88-
params={"EXCLUDED_LABELS": EXCLUDED_LABELS},
89-
)
80+
@pytest.mark.usefixtures("setup_neo4j_for_schema_query")
81+
def test_get_structured_schema_returns_correct_node_properties(driver):
82+
result = get_structured_schema(driver)
83+
assert result["node_props"]["LabelA"] == [
84+
{"property": "property_a", "type": "STRING"}
9085
]
9186

92-
assert node_properties == []
87+
88+
@pytest.mark.usefixtures("setup_neo4j_for_schema_query")
89+
def test_get_structured_schema_returns_correct_relationship_properties(driver):
90+
result = get_structured_schema(driver)
91+
assert result["rel_props"]["REL_TYPE"] == [
92+
{"property": "rel_prop", "type": "STRING"}
93+
]
9394

9495

95-
@pytest.mark.usefixtures("setup_neo4j_for_schema_query_with_excluded_labels")
96-
def test_filtering_labels_relationship_properties(driver):
97-
relationship_properties = [
98-
data["output"]
99-
for data in query_database(
100-
driver, REL_PROPERTIES_QUERY, params={"EXCLUDED_LABELS": EXCLUDED_RELS}
101-
)
96+
@pytest.mark.usefixtures("setup_neo4j_for_schema_query")
97+
def test_get_structured_schema_returns_correct_relationships(driver):
98+
result = get_structured_schema(driver)
99+
assert sorted(result["relationships"], key=lambda x: x["end"]) == [
100+
{"end": "LabelB", "start": "LabelA", "type": "REL_TYPE"},
101+
{"end": "LabelC", "start": "LabelA", "type": "REL_TYPE"},
102102
]
103103

104-
assert relationship_properties == []
105104

105+
@pytest.mark.enterprise_only
106+
@pytest.mark.usefixtures("setup_neo4j_for_schema_query")
107+
def test_get_structured_schema_returns_correct_constraints(driver):
108+
query_database(driver, "DROP CONSTRAINT test_constraint IF EXISTS")
109+
query_database(
110+
driver,
111+
"CREATE CONSTRAINT test_constraint IF NOT EXISTS FOR (n:LabelA) REQUIRE n.property_a IS NOT NULL",
112+
)
106113

107-
@pytest.mark.usefixtures("setup_neo4j_for_schema_query_with_excluded_labels")
108-
def test_filtering_labels_relationships(driver):
109-
relationships = [
110-
data["output"]
111-
for data in query_database(
112-
driver,
113-
REL_QUERY,
114-
params={"EXCLUDED_LABELS": EXCLUDED_LABELS + [BASE_ENTITY_LABEL]},
115-
)
116-
]
114+
result = get_structured_schema(driver)
115+
assert result["metadata"]["constraint"][0].get("name") == "test_constraint"
116+
assert result["metadata"]["constraint"][0].get("type") == "NODE_PROPERTY_EXISTENCE"
117+
assert result["metadata"]["constraint"][0].get("entityType") == "NODE"
118+
assert result["metadata"]["constraint"][0].get("labelsOrTypes") == ["LabelA"]
119+
assert result["metadata"]["constraint"][0].get("properties") == ["property_a"]
120+
121+
122+
@pytest.mark.usefixtures("setup_neo4j_for_schema_query")
123+
def test_get_structured_schema_returns_correct_indexes(driver):
124+
query_database(driver, "DROP INDEX node_range_index IF EXISTS")
125+
query_database(
126+
driver, "CREATE INDEX node_range_index FOR (n:LabelA) ON (n.property_a)"
127+
)
117128

118-
assert relationships == []
129+
result = get_structured_schema(driver)
130+
assert result["metadata"]["index"][0].get("label") == "LabelA"
131+
assert result["metadata"]["index"][0].get("properties") == ["property_a"]
132+
assert result["metadata"]["index"][0].get("type") == "RANGE"

tests/e2e/test_schema_filters_e2e.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# Copyright (c) "Neo4j"
2+
# Neo4j Sweden AB [https://neo4j.com]
3+
# #
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
# #
8+
# https://www.apache.org/licenses/LICENSE-2.0
9+
# #
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
16+
import pytest
17+
from neo4j_genai.schema import (
18+
query_database,
19+
NODE_PROPERTIES_QUERY,
20+
BASE_ENTITY_LABEL,
21+
EXCLUDED_LABELS,
22+
EXCLUDED_RELS,
23+
REL_PROPERTIES_QUERY,
24+
REL_QUERY,
25+
)
26+
27+
28+
@pytest.mark.usefixtures("setup_neo4j_for_schema_query_with_excluded_labels")
29+
def test_filtering_labels_node_properties(driver):
30+
node_properties = [
31+
data["output"]
32+
for data in query_database(
33+
driver,
34+
NODE_PROPERTIES_QUERY,
35+
params={"EXCLUDED_LABELS": EXCLUDED_LABELS},
36+
)
37+
]
38+
39+
assert node_properties == []
40+
41+
42+
@pytest.mark.usefixtures("setup_neo4j_for_schema_query_with_excluded_labels")
43+
def test_filtering_labels_relationship_properties(driver):
44+
relationship_properties = [
45+
data["output"]
46+
for data in query_database(
47+
driver, REL_PROPERTIES_QUERY, params={"EXCLUDED_LABELS": EXCLUDED_RELS}
48+
)
49+
]
50+
51+
assert relationship_properties == []
52+
53+
54+
@pytest.mark.usefixtures("setup_neo4j_for_schema_query_with_excluded_labels")
55+
def test_filtering_labels_relationships(driver):
56+
relationships = [
57+
data["output"]
58+
for data in query_database(
59+
driver,
60+
REL_QUERY,
61+
params={"EXCLUDED_LABELS": EXCLUDED_LABELS + [BASE_ENTITY_LABEL]},
62+
)
63+
]
64+
65+
assert relationships == []

0 commit comments

Comments
 (0)