From 6a3064532d36fa69a967facfbf176526cc5f65df Mon Sep 17 00:00:00 2001 From: Niclas Kjall-Ohlsson Date: Fri, 6 Feb 2026 22:41:54 +0100 Subject: [PATCH 1/2] Graph Schema --- src/graph/database.ts | 30 +++++++++++++++++++ src/parsing/functions/function_factory.ts | 1 + src/parsing/functions/schema.ts | 36 +++++++++++++++++++++++ 3 files changed, 67 insertions(+) create mode 100644 src/parsing/functions/schema.ts diff --git a/src/graph/database.ts b/src/graph/database.ts index 16a36c5..a8eb41a 100644 --- a/src/graph/database.ts +++ b/src/graph/database.ts @@ -39,6 +39,36 @@ class Database { public getRelationship(relationship: Relationship): PhysicalRelationship | null { return Database.relationships.get(relationship.type!) || null; } + public async schema(): Promise[]> { + const result: Record[] = []; + + for (const [label, physical] of Database.nodes) { + const records = await physical.data(); + const entry: Record = { kind: "node", label }; + if (records.length > 0) { + const { id, ...sample } = records[0]; + if (Object.keys(sample).length > 0) { + entry.sample = sample; + } + } + result.push(entry); + } + + for (const [type, physical] of Database.relationships) { + const records = await physical.data(); + const entry: Record = { kind: "relationship", type }; + if (records.length > 0) { + const { left_id, right_id, ...sample } = records[0]; + if (Object.keys(sample).length > 0) { + entry.sample = sample; + } + } + result.push(entry); + } + + return result; + } + public async getData(element: Node | Relationship): Promise { if (element instanceof Node) { const node = this.getNode(element); diff --git a/src/parsing/functions/function_factory.ts b/src/parsing/functions/function_factory.ts index 1768a72..94fd953 100644 --- a/src/parsing/functions/function_factory.ts +++ b/src/parsing/functions/function_factory.ts @@ -18,6 +18,7 @@ import "./rand"; import "./range"; import "./replace"; import "./round"; +import "./schema"; import "./size"; import "./split"; import "./stringify"; diff --git a/src/parsing/functions/schema.ts b/src/parsing/functions/schema.ts new file mode 100644 index 0000000..5762247 --- /dev/null +++ b/src/parsing/functions/schema.ts @@ -0,0 +1,36 @@ +import Database from "../../graph/database"; +import AsyncFunction from "./async_function"; +import { FunctionDef } from "./function_metadata"; + +/** + * Built-in function that returns the graph schema of the database. + * + * Lists all nodes and relationships with their labels/types and a sample + * of their data (excluding id from nodes, left_id and right_id from relationships). + * + * @example + * ``` + * LOAD FROM schema() AS s RETURN s + * ``` + */ +@FunctionDef({ + description: + "Returns the graph schema listing all nodes and relationships with a sample of their data.", + category: "async", + parameters: [], + output: { + description: "Schema entry with kind, label/type, and optional sample data", + type: "object", + }, + examples: ["LOAD FROM schema() AS s RETURN s"], +}) +class Schema extends AsyncFunction { + public async *generate(): AsyncGenerator { + const entries = await Database.getInstance().schema(); + for (const entry of entries) { + yield entry; + } + } +} + +export default Schema; From 3f639d8e116955348dd53d17bf3159fe356cf12a Mon Sep 17 00:00:00 2001 From: Niclas Kjall-Ohlsson Date: Fri, 6 Feb 2026 22:53:53 +0100 Subject: [PATCH 2/2] Graph Schema --- flowquery-py/src/graph/database.py | 26 +++++++++- .../src/parsing/functions/__init__.py | 2 + flowquery-py/src/parsing/functions/schema.py | 36 ++++++++++++++ flowquery-py/tests/compute/test_runner.py | 47 ++++++++++++++++++- tests/compute/runner.test.ts | 40 ++++++++++++++++ 5 files changed, 149 insertions(+), 2 deletions(-) create mode 100644 flowquery-py/src/parsing/functions/schema.py diff --git a/flowquery-py/src/graph/database.py b/flowquery-py/src/graph/database.py index 815115a..527275c 100644 --- a/flowquery-py/src/graph/database.py +++ b/flowquery-py/src/graph/database.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Dict, Optional, Union +from typing import Any, Dict, Optional, Union from ..parsing.ast_node import ASTNode from .node import Node @@ -54,6 +54,30 @@ def get_relationship(self, relationship: 'Relationship') -> Optional['PhysicalRe """Gets a relationship from the database.""" return Database._relationships.get(relationship.type) if relationship.type else None + async def schema(self) -> list[dict[str, Any]]: + """Returns the graph schema with node/relationship labels and sample data.""" + result: list[dict[str, Any]] = [] + + for label, physical_node in Database._nodes.items(): + records = await physical_node.data() + entry: dict[str, Any] = {"kind": "node", "label": label} + if records: + sample = {k: v for k, v in records[0].items() if k != "id"} + if sample: + entry["sample"] = sample + result.append(entry) + + for rel_type, physical_rel in Database._relationships.items(): + records = await physical_rel.data() + entry_rel: dict[str, Any] = {"kind": "relationship", "type": rel_type} + if records: + sample = {k: v for k, v in records[0].items() if k not in ("left_id", "right_id")} + if sample: + entry_rel["sample"] = sample + result.append(entry_rel) + + return result + async def get_data(self, element: Union['Node', 'Relationship']) -> Union['NodeData', 'RelationshipData']: """Gets data for a node or relationship.""" if isinstance(element, Node): diff --git a/flowquery-py/src/parsing/functions/__init__.py b/flowquery-py/src/parsing/functions/__init__.py index 2764fd3..8f87f4e 100644 --- a/flowquery-py/src/parsing/functions/__init__.py +++ b/flowquery-py/src/parsing/functions/__init__.py @@ -27,6 +27,7 @@ from .reducer_element import ReducerElement from .replace import Replace from .round_ import Round +from .schema import Schema from .size import Size from .split import Split from .stringify import Stringify @@ -71,5 +72,6 @@ "ToJson", "Type", "Functions", + "Schema", "PredicateSum", ] diff --git a/flowquery-py/src/parsing/functions/schema.py b/flowquery-py/src/parsing/functions/schema.py new file mode 100644 index 0000000..6bde093 --- /dev/null +++ b/flowquery-py/src/parsing/functions/schema.py @@ -0,0 +1,36 @@ +"""Schema introspection function.""" + +from typing import Any, AsyncGenerator + +from .async_function import AsyncFunction +from .function_metadata import FunctionDef + + +@FunctionDef({ + "description": ( + "Returns the graph schema listing all nodes and relationships " + "with a sample of their data." + ), + "category": "async", + "parameters": [], + "output": { + "description": "Schema entry with kind, label/type, and optional sample data", + "type": "object", + }, + "examples": [ + "CALL schema() YIELD kind, label, type, sample RETURN kind, label, type, sample", + ], +}) +class Schema(AsyncFunction): + """Returns the graph schema of the database. + + Lists all nodes and relationships with their labels/types and a sample + of their data (excluding id from nodes, left_id and right_id from relationships). + """ + + async def generate(self) -> AsyncGenerator[Any, None]: + # Import at runtime to avoid circular dependency + from ...graph.database import Database + entries = await Database.get_instance().schema() + for entry in entries: + yield entry diff --git a/flowquery-py/tests/compute/test_runner.py b/flowquery-py/tests/compute/test_runner.py index 1ef1ba5..f053e6d 100644 --- a/flowquery-py/tests/compute/test_runner.py +++ b/flowquery-py/tests/compute/test_runner.py @@ -1539,4 +1539,49 @@ async def test_match_with_constraints(self): await match.run() results = match.results assert len(results) == 1 - assert results[0]["name"] == "Employee 1" \ No newline at end of file + assert results[0]["name"] == "Employee 1" + + @pytest.mark.asyncio + async def test_schema_returns_nodes_and_relationships_with_sample_data(self): + """Test schema() returns nodes and relationships with sample data.""" + await Runner( + """ + CREATE VIRTUAL (:Animal) AS { + UNWIND [ + {id: 1, species: 'Cat', legs: 4}, + {id: 2, species: 'Dog', legs: 4} + ] AS record + RETURN record.id AS id, record.species AS species, record.legs AS legs + } + """ + ).run() + await Runner( + """ + CREATE VIRTUAL (:Animal)-[:CHASES]-(:Animal) AS { + UNWIND [ + {left_id: 2, right_id: 1, speed: 'fast'} + ] AS record + RETURN record.left_id AS left_id, record.right_id AS right_id, record.speed AS speed + } + """ + ).run() + + runner = Runner( + "CALL schema() YIELD kind, label, type, sample RETURN kind, label, type, sample" + ) + await runner.run() + results = runner.results + + animal = next((r for r in results if r.get("kind") == "node" and r.get("label") == "Animal"), None) + assert animal is not None + assert animal["sample"] is not None + assert "id" not in animal["sample"] + assert "species" in animal["sample"] + assert "legs" in animal["sample"] + + chases = next((r for r in results if r.get("kind") == "relationship" and r.get("type") == "CHASES"), None) + assert chases is not None + assert chases["sample"] is not None + assert "left_id" not in chases["sample"] + assert "right_id" not in chases["sample"] + assert "speed" in chases["sample"] \ No newline at end of file diff --git a/tests/compute/runner.test.ts b/tests/compute/runner.test.ts index bfc0abf..e213373 100644 --- a/tests/compute/runner.test.ts +++ b/tests/compute/runner.test.ts @@ -1460,3 +1460,43 @@ test("Test match with leftward double graph pattern", async () => { expect(results[0]).toEqual({ name1: "Person 1", name2: "Person 2", name3: "Person 3" }); expect(results[1]).toEqual({ name1: "Person 2", name2: "Person 3", name3: "Person 4" }); }); + +test("Test schema() returns nodes and relationships with sample data", async () => { + await new Runner(` + CREATE VIRTUAL (:Animal) AS { + UNWIND [ + {id: 1, species: 'Cat', legs: 4}, + {id: 2, species: 'Dog', legs: 4} + ] AS record + RETURN record.id AS id, record.species AS species, record.legs AS legs + } + `).run(); + await new Runner(` + CREATE VIRTUAL (:Animal)-[:CHASES]-(:Animal) AS { + UNWIND [ + {left_id: 2, right_id: 1, speed: 'fast'} + ] AS record + RETURN record.left_id AS left_id, record.right_id AS right_id, record.speed AS speed + } + `).run(); + + const runner = new Runner( + "CALL schema() YIELD kind, label, type, sample RETURN kind, label, type, sample" + ); + await runner.run(); + const results = runner.results; + + const animal = results.find((r: any) => r.kind === "node" && r.label === "Animal"); + expect(animal).toBeDefined(); + expect(animal.sample).toBeDefined(); + expect(animal.sample).not.toHaveProperty("id"); + expect(animal.sample).toHaveProperty("species"); + expect(animal.sample).toHaveProperty("legs"); + + const chases = results.find((r: any) => r.kind === "relationship" && r.type === "CHASES"); + expect(chases).toBeDefined(); + expect(chases.sample).toBeDefined(); + expect(chases.sample).not.toHaveProperty("left_id"); + expect(chases.sample).not.toHaveProperty("right_id"); + expect(chases.sample).toHaveProperty("speed"); +});