Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,15 @@ uv sync
uv run pytest -m integration
```

### Database schema

Constraints and indexes are created idempotently (safe to run repeatedly):

```bash
cd backend
uv run python -m visual_graph.graph
```

## License

This project is licensed under the MIT License.
18 changes: 18 additions & 0 deletions backend/src/visual_graph/graph/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
"""``python -m visual_graph.graph`` applies the database schema."""

from visual_graph.graph.connection import create_driver, verify_connection
from visual_graph.graph.schema import apply_schema


def main() -> None:
driver = create_driver()
try:
verify_connection(driver)
apply_schema(driver)
finally:
driver.close()
print("Neo4j schema applied.")


if __name__ == "__main__":
main()
41 changes: 41 additions & 0 deletions backend/src/visual_graph/graph/schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"""Idempotent Neo4j schema: constraints and indexes.

Every statement uses ``IF NOT EXISTS`` so ``apply_schema`` can run any number of times.
"""

from neo4j import Driver

SCHEMA_STATEMENTS: tuple[str, ...] = (
"CREATE CONSTRAINT image_id_unique IF NOT EXISTS "
"FOR (n:Image) REQUIRE n.id IS UNIQUE",
"CREATE CONSTRAINT object_instance_id_unique IF NOT EXISTS "
"FOR (n:ObjectInstance) REQUIRE n.id IS UNIQUE",
# Class uniqueness strategy: `name` is the natural key; create classes with MERGE.
"CREATE CONSTRAINT class_name_unique IF NOT EXISTS "
"FOR (n:Class) REQUIRE n.name IS UNIQUE",
# Required properties would need Enterprise edition, so they are enforced in code.
"CREATE INDEX image_uri IF NOT EXISTS FOR (n:Image) ON (n.uri)",
"CREATE INDEX object_instance_confidence IF NOT EXISTS "
"FOR (n:ObjectInstance) ON (n.confidence)",
)

EXPECTED_CONSTRAINTS = frozenset(
{"image_id_unique", "object_instance_id_unique", "class_name_unique"}
)
EXPECTED_INDEXES = frozenset({"image_uri", "object_instance_confidence"})


def apply_schema(driver: Driver, database: str | None = None) -> None:
with driver.session(database=database) as session:
for statement in SCHEMA_STATEMENTS:
session.run(statement).consume()
# Wait until indexes are online before returning.
session.run("CALL db.awaitIndexes(60)").consume()


def existing_schema(driver: Driver, database: str | None = None) -> tuple[set[str], set[str]]:
"""Return ``(constraint names, index names)`` currently in the database."""
with driver.session(database=database) as session:
constraints = {r["name"] for r in session.run("SHOW CONSTRAINTS YIELD name")}
indexes = {r["name"] for r in session.run("SHOW INDEXES YIELD name")}
return constraints, indexes
62 changes: 62 additions & 0 deletions backend/tests/integration/test_schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
from collections.abc import Iterator

import pytest
from neo4j import Driver
from neo4j.exceptions import ConstraintError

from visual_graph.graph.connection import create_driver
from visual_graph.graph.schema import (
EXPECTED_CONSTRAINTS,
EXPECTED_INDEXES,
apply_schema,
existing_schema,
)

pytestmark = pytest.mark.integration


@pytest.fixture
def driver() -> Iterator[Driver]:
drv = create_driver()
with drv.session() as session:
session.run("MATCH (n) DETACH DELETE n").consume()
yield drv
with drv.session() as session:
session.run("MATCH (n) DETACH DELETE n").consume()
drv.close()


def test_schema_creates_constraints_and_indexes(driver: Driver) -> None:
apply_schema(driver)
constraints, indexes = existing_schema(driver)
assert EXPECTED_CONSTRAINTS <= constraints
assert EXPECTED_INDEXES <= indexes


def test_schema_is_idempotent(driver: Driver) -> None:
apply_schema(driver)
before = existing_schema(driver)
apply_schema(driver)
apply_schema(driver)
assert existing_schema(driver) == before


@pytest.mark.parametrize(
("label", "prop"),
[("Image", "id"), ("ObjectInstance", "id"), ("Class", "name")],
)
def test_duplicates_are_rejected(driver: Driver, label: str, prop: str) -> None:
apply_schema(driver)
with driver.session() as session:
session.run(f"CREATE (:{label} {{{prop}: 'dup'}})").consume()
with pytest.raises(ConstraintError):
session.run(f"CREATE (:{label} {{{prop}: 'dup'}})").consume()


def test_merge_does_not_duplicate_classes(driver: Driver) -> None:
apply_schema(driver)
with driver.session() as session:
for _ in range(3):
session.run("MERGE (:Class {name: 'person'})").consume()
count = session.run("MATCH (c:Class {name: 'person'}) RETURN count(c) AS n").single()
assert count is not None and count["n"] == 1
Loading