From fd7c67fd60304ecc7f2c85f3c1d3589697d02bde Mon Sep 17 00:00:00 2001 From: raphaellyzzz Date: Fri, 6 Nov 2026 21:58:27 -0300 Subject: [PATCH] feat(neo4j): implement and validate initial Cypher queries Closes #6 --- .../cypher/images_with_left_of_pattern.cypher | 7 ++ .../cypher/images_with_near_pattern.cypher | 9 +++ .../cypher/images_with_two_classes.cypher | 6 ++ .../graph/cypher/objects_by_class.cypher | 5 ++ .../graph/cypher/objects_in_image.cypher | 5 ++ .../graph/cypher/objects_near.cypher | 5 ++ .../cypher/relationships_in_image.cypher | 6 ++ backend/src/visual_graph/graph/queries.py | 22 +++++ backend/tests/integration/test_queries.py | 81 +++++++++++++++++++ 9 files changed, 146 insertions(+) create mode 100644 backend/src/visual_graph/graph/cypher/images_with_left_of_pattern.cypher create mode 100644 backend/src/visual_graph/graph/cypher/images_with_near_pattern.cypher create mode 100644 backend/src/visual_graph/graph/cypher/images_with_two_classes.cypher create mode 100644 backend/src/visual_graph/graph/cypher/objects_by_class.cypher create mode 100644 backend/src/visual_graph/graph/cypher/objects_in_image.cypher create mode 100644 backend/src/visual_graph/graph/cypher/objects_near.cypher create mode 100644 backend/src/visual_graph/graph/cypher/relationships_in_image.cypher create mode 100644 backend/src/visual_graph/graph/queries.py create mode 100644 backend/tests/integration/test_queries.py diff --git a/backend/src/visual_graph/graph/cypher/images_with_left_of_pattern.cypher b/backend/src/visual_graph/graph/cypher/images_with_left_of_pattern.cypher new file mode 100644 index 0000000..2dadb65 --- /dev/null +++ b/backend/src/visual_graph/graph/cypher/images_with_left_of_pattern.cypher @@ -0,0 +1,7 @@ +// Retrieve images where an object of class $class_a is LEFT_OF an object of class $class_b. +// Params: $class_a, $class_b +MATCH (img:Image)-[:CONTAINS]->(a:ObjectInstance)-[:IS_A]->(:Class {name: $class_a}) +MATCH (img)-[:CONTAINS]->(b:ObjectInstance)-[:IS_A]->(:Class {name: $class_b}) +MATCH (a)-[:LEFT_OF]->(b) +RETURN DISTINCT img.id AS image_id +ORDER BY image_id diff --git a/backend/src/visual_graph/graph/cypher/images_with_near_pattern.cypher b/backend/src/visual_graph/graph/cypher/images_with_near_pattern.cypher new file mode 100644 index 0000000..2442b24 --- /dev/null +++ b/backend/src/visual_graph/graph/cypher/images_with_near_pattern.cypher @@ -0,0 +1,9 @@ +// Retrieve images where an object of class $class_a is NEAR an object of class $class_b. +// Params: $class_a, $class_b +// Why a graph: this requires a relationship between two specific instances of the same +// image. Plain co-occurrence of the classes (images_with_two_classes) cannot express it. +MATCH (img:Image)-[:CONTAINS]->(a:ObjectInstance)-[:IS_A]->(:Class {name: $class_a}) +MATCH (img)-[:CONTAINS]->(b:ObjectInstance)-[:IS_A]->(:Class {name: $class_b}) +MATCH (a)-[:NEAR]-(b) +RETURN DISTINCT img.id AS image_id +ORDER BY image_id diff --git a/backend/src/visual_graph/graph/cypher/images_with_two_classes.cypher b/backend/src/visual_graph/graph/cypher/images_with_two_classes.cypher new file mode 100644 index 0000000..5bcfad0 --- /dev/null +++ b/backend/src/visual_graph/graph/cypher/images_with_two_classes.cypher @@ -0,0 +1,6 @@ +// Retrieve images containing two specified classes. +// Params: $class_a, $class_b +MATCH (img:Image)-[:CONTAINS]->(:ObjectInstance)-[:IS_A]->(:Class {name: $class_a}) +MATCH (img)-[:CONTAINS]->(:ObjectInstance)-[:IS_A]->(:Class {name: $class_b}) +RETURN DISTINCT img.id AS image_id +ORDER BY image_id diff --git a/backend/src/visual_graph/graph/cypher/objects_by_class.cypher b/backend/src/visual_graph/graph/cypher/objects_by_class.cypher new file mode 100644 index 0000000..54606c3 --- /dev/null +++ b/backend/src/visual_graph/graph/cypher/objects_by_class.cypher @@ -0,0 +1,5 @@ +// Retrieve objects by class (across all images). +// Params: $class_name +MATCH (img:Image)-[:CONTAINS]->(o:ObjectInstance)-[:IS_A]->(:Class {name: $class_name}) +RETURN img.id AS image_id, o.id AS object_id +ORDER BY image_id, object_id diff --git a/backend/src/visual_graph/graph/cypher/objects_in_image.cypher b/backend/src/visual_graph/graph/cypher/objects_in_image.cypher new file mode 100644 index 0000000..507bac9 --- /dev/null +++ b/backend/src/visual_graph/graph/cypher/objects_in_image.cypher @@ -0,0 +1,5 @@ +// Retrieve all objects contained in an image. +// Params: $image_id +MATCH (:Image {id: $image_id})-[:CONTAINS]->(o:ObjectInstance)-[:IS_A]->(c:Class) +RETURN o.id AS object_id, c.name AS class +ORDER BY object_id diff --git a/backend/src/visual_graph/graph/cypher/objects_near.cypher b/backend/src/visual_graph/graph/cypher/objects_near.cypher new file mode 100644 index 0000000..ff37f66 --- /dev/null +++ b/backend/src/visual_graph/graph/cypher/objects_near.cypher @@ -0,0 +1,5 @@ +// Retrieve objects near another object (NEAR is symmetric: match without direction). +// Params: $object_id +MATCH (:ObjectInstance {id: $object_id})-[:NEAR]-(other:ObjectInstance)-[:IS_A]->(c:Class) +RETURN DISTINCT other.id AS object_id, c.name AS class +ORDER BY object_id diff --git a/backend/src/visual_graph/graph/cypher/relationships_in_image.cypher b/backend/src/visual_graph/graph/cypher/relationships_in_image.cypher new file mode 100644 index 0000000..ae61e69 --- /dev/null +++ b/backend/src/visual_graph/graph/cypher/relationships_in_image.cypher @@ -0,0 +1,6 @@ +// Retrieve spatial relationships between objects of an image. +// Params: $image_id +MATCH (:Image {id: $image_id})-[:CONTAINS]->(a:ObjectInstance) +MATCH (a)-[r:LEFT_OF|RIGHT_OF|ABOVE|BELOW|NEAR|OVERLAPS]->(b:ObjectInstance) +RETURN a.id AS source, type(r) AS relationship, b.id AS target +ORDER BY source, relationship, target diff --git a/backend/src/visual_graph/graph/queries.py b/backend/src/visual_graph/graph/queries.py new file mode 100644 index 0000000..155d182 --- /dev/null +++ b/backend/src/visual_graph/graph/queries.py @@ -0,0 +1,22 @@ +"""Reusable Cypher queries (``cypher/*.cypher``) for the backend.""" + +from importlib import resources +from typing import Any + +from neo4j import Driver + + +def load_query(name: str) -> str: + return ( + resources.files("visual_graph.graph") + .joinpath(f"cypher/{name}.cypher") + .read_text(encoding="utf-8") + ) + + +def run_query( + driver: Driver, name: str, database: str | None = None, **params: Any +) -> list[dict[str, Any]]: + """Run the named query file and return rows as dictionaries.""" + with driver.session(database=database) as session: + return [record.data() for record in session.run(load_query(name), **params)] diff --git a/backend/tests/integration/test_queries.py b/backend/tests/integration/test_queries.py new file mode 100644 index 0000000..113f8eb --- /dev/null +++ b/backend/tests/integration/test_queries.py @@ -0,0 +1,81 @@ +from collections.abc import Iterator + +import pytest +from neo4j import Driver + +from visual_graph.graph.connection import create_driver +from visual_graph.graph.queries import run_query +from visual_graph.graph.seed import SAMPLE_IMAGE_ID, seed_sample_scene + +pytestmark = pytest.mark.integration + + +@pytest.fixture(scope="module") +def driver() -> Iterator[Driver]: + drv = create_driver() + with drv.session() as session: + session.run("MATCH (n) DETACH DELETE n").consume() + seed_sample_scene(drv) + yield drv + with drv.session() as session: + session.run("MATCH (n) DETACH DELETE n").consume() + drv.close() + + +def test_objects_in_image(driver: Driver) -> None: + rows = run_query(driver, "objects_in_image", image_id=SAMPLE_IMAGE_ID) + assert rows == [ + {"object_id": "sample-bicycle-1", "class": "bicycle"}, + {"object_id": "sample-chair-1", "class": "chair"}, + {"object_id": "sample-chair-2", "class": "chair"}, + {"object_id": "sample-dog-1", "class": "dog"}, + {"object_id": "sample-person-1", "class": "person"}, + ] + + +def test_objects_by_class_returns_every_instance(driver: Driver) -> None: + rows = run_query(driver, "objects_by_class", class_name="chair") + assert [r["object_id"] for r in rows] == ["sample-chair-1", "sample-chair-2"] + assert run_query(driver, "objects_by_class", class_name="cat") == [] + + +def test_relationships_in_image(driver: Driver) -> None: + rows = run_query(driver, "relationships_in_image", image_id=SAMPLE_IMAGE_ID) + triples = {(r["source"], r["relationship"], r["target"]) for r in rows} + assert ("sample-person-1", "NEAR", "sample-dog-1") in triples + assert ("sample-person-1", "LEFT_OF", "sample-bicycle-1") in triples + assert ("sample-dog-1", "BELOW", "sample-person-1") in triples + assert len(rows) == 13 # matches the seed exactly + + +def test_objects_near_is_symmetric(driver: Driver) -> None: + from_person = run_query(driver, "objects_near", object_id="sample-person-1") + from_dog = run_query(driver, "objects_near", object_id="sample-dog-1") + assert from_person == [{"object_id": "sample-dog-1", "class": "dog"}] + assert from_dog == [{"object_id": "sample-person-1", "class": "person"}] + + +def test_images_with_two_classes(driver: Driver) -> None: + rows = run_query(driver, "images_with_two_classes", class_a="person", class_b="dog") + assert rows == [{"image_id": SAMPLE_IMAGE_ID}] + assert run_query(driver, "images_with_two_classes", class_a="person", class_b="cat") == [] + + +def test_spatial_pattern_queries(driver: Driver) -> None: + near = run_query(driver, "images_with_near_pattern", class_a="person", class_b="dog") + assert near == [{"image_id": SAMPLE_IMAGE_ID}] + left = run_query(driver, "images_with_left_of_pattern", class_a="person", class_b="bicycle") + assert left == [{"image_id": SAMPLE_IMAGE_ID}] + reverse = run_query(driver, "images_with_left_of_pattern", class_a="bicycle", class_b="person") + assert reverse == [] + + +def test_graph_relationships_distinguish_what_class_cooccurrence_cannot(driver: Driver) -> None: + """A person and a chair appear in the same image, but they are not near each other. + + Class co-occurrence matches; the NEAR pattern correctly does not. + """ + both = run_query(driver, "images_with_two_classes", class_a="person", class_b="chair") + near = run_query(driver, "images_with_near_pattern", class_a="person", class_b="chair") + assert both == [{"image_id": SAMPLE_IMAGE_ID}] + assert near == []