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
6 changes: 3 additions & 3 deletions docs/source/user-guide/graph-and-links.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,19 @@ The Graph of Links feature is experimental. The APIs may change.

# Explore the Entity/Link Graph with GraphQL

Tiled can optionally serve a graph of entities and links alongside a
Tiled can optionally serve a graph of entities connected by links, alongside a
catalog-backed tree, queryable through a GraphQL API. See
{doc}`../explanations/graphs` for background on what this feature is and why
it exists.

This guide walks through starting a demo server with the graph enabled and
This guide walks you through the process of starting a demo server with the graph enabled and
exploring it interactively in the browser.

## Enable the graph feature

The graph is available automatically whenever a server is serving a
catalog-backed tree (see {doc}`example-server-config`)---there is no separate
configuration flag. A ready-to-run demo lives in
configuration flag. A ready-to-run demo can be found in
`example_configs/graphs/` in the Tiled source repository:

```
Expand Down
10 changes: 0 additions & 10 deletions example_configs/graphs/serve_with_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,8 @@
import argparse

import uvicorn
from sqlalchemy import Column, Integer, Table

from tiled.config import parse_configs
from tiled.graph.store import _metadata
from tiled.server.app import build_app_from_config


Expand All @@ -19,14 +17,6 @@ def main() -> None:
parser.add_argument("catalog", nargs="?", default=None)
args = parser.parse_args()

# Ensure graph metadata can resolve entities.node_id foreign key.
Table(
"nodes",
_metadata,
Column("id", Integer, primary_key=True),
extend_existing=True,
)

config = parse_configs(args.config)
app = build_app_from_config(config)

Expand Down
2 changes: 1 addition & 1 deletion share/tiled/templates/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ <h1 class="title">Explore the Graph API</h1>
<h2 class="subtitle">
Use the interactive <em>GraphQL</em> playground to query the links graph.
</h2>
<a href="{{ root_url }}/graphql" target="_blank" rel="noreferrer">
<a href="{{ root_url }}/api/graphql" target="_blank" rel="noreferrer">

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On second thought...since we're marking this new feature as experimental, do we even want to link to it from the tiled landing page?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can leave it out for now -- easy to add later. @danielballan, what do you think?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure. I'm good either wya.

<button class="button is-large is-responsive is-link">
Try it
</button>
Expand Down
22 changes: 6 additions & 16 deletions tests/test_graph_access_control.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import pytest
from sqlalchemy import Column, Integer, Table
from starlette.testclient import TestClient

from tiled.catalog import in_memory
from tiled.catalog.core import initialize_database
from tiled.config import Database
from tiled.graph.schema import schema
from tiled.graph.store import GraphSQLAlchemyStore, _metadata
from tiled.graph.store import GraphSQLAlchemyStore
from tiled.queries import AccessBlobFilter
from tiled.server.app import build_app
from tiled.server.authentication import (
Expand Down Expand Up @@ -109,14 +109,12 @@ async def filters(

@pytest.fixture
async def store():
Table(
"nodes",
_metadata,
Column("id", Integer, primary_key=True),
extend_existing=True,
)
database_settings = DatabaseSettings(uri="sqlite:///:memory:")
s = await GraphSQLAlchemyStore.from_database_settings(database_settings)
# The store no longer creates its own tables; provision the catalog schema
# (which now includes the graph tables and the `nodes` table that
# entities.node_id references) on the shared in-memory database.
await initialize_database(s._engine)
yield s
# Tear down the shared pool entry (rather than just `s.close()`, which is
# a no-op here) so each test gets an isolated in-memory database instead
Expand Down Expand Up @@ -600,14 +598,6 @@ async def test_graphql_expands_and_compacts_curies(store, policy):
def test_graphql_http_route_access_control_integration(tmp_path, policy):
"""Validate HTTP GraphQL route wiring with auth dependencies and policy checks."""

# Ensure graph metadata can resolve the entities.node_id foreign key.
Table(
"nodes",
_metadata,
Column("id", Integer, primary_key=True),
extend_existing=True,
)

catalog = in_memory(writable_storage=str(tmp_path / "storage"))
app = build_app(
catalog,
Expand Down
5 changes: 5 additions & 0 deletions tiled/catalog/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@

async def initialize_database(engine: AsyncEngine):
# The definitions in .orm alter Base.metadata.
# The graph (splash-links) tables also live in the catalog database and
# attach to Base.metadata, so importing them here ensures create_all
# provisions them on fresh databases (existing databases get them via the
# Alembic migration c31f6a1d7e20).
from ..graph import orm as graph_orm # noqa: F401
from . import orm # noqa: F401

async with engine.connect() as connection:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Add graph entities and links tables
"""Add graph entities, links, and namespaces tables

Revision ID: c31f6a1d7e20
Revises: 9bc9b57294b9
Expand Down Expand Up @@ -71,8 +71,18 @@ def upgrade():
unique=False,
)

op.create_table(
"namespaces",
sa.Column("prefix", sa.String(), nullable=False),
sa.Column("uri", sa.String(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("prefix"),
)


def downgrade():
op.drop_table("namespaces")

op.drop_index("links_triple_idx", table_name="links")
op.drop_index("links_predicate_object_idx", table_name="links")
op.drop_index("links_subject_predicate_idx", table_name="links")
Expand Down
11 changes: 0 additions & 11 deletions tiled/graph/core.py

This file was deleted.

5 changes: 2 additions & 3 deletions tiled/graph/curie.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
"""
CURIE (Compact URI) expansion/compaction against the namespace registry.

Shared between the GraphQL schema (which expands terms written through
mutations and compacts terms read back out) and the JSON-LD REST import/
export routes, so both interfaces resolve prefixes the same way.
Used by the GraphQL schema to expand terms written through mutations and
compact terms read back out, so prefixes resolve consistently.
"""

from __future__ import annotations
Expand Down
79 changes: 79 additions & 0 deletions tiled/graph/orm.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
"""
SQLAlchemy Core table definitions for the graph (splash-links) feature.

These tables live in the catalog database alongside the catalog's own tables
(``entities.node_id`` is a foreign key into the catalog ``nodes`` table). They
are attached to the catalog's ``Base.metadata`` so that the two supported ways
of provisioning a catalog database both include them:

* a fresh database created by ``tiled.catalog.core.initialize_database``
(which runs ``Base.metadata.create_all``), and
* an existing database upgraded through the Alembic migration
``c31f6a1d7e20``.

The store (``tiled.graph.store``) uses these ``Table`` objects to read and
write rows; it does not create them itself. This mirrors how ``metadata_fts5``
is declared as a Core table on ``Base.metadata`` in ``tiled.catalog.orm``.
"""

from __future__ import annotations

from sqlalchemy import JSON, Column, DateTime, ForeignKey, Index, Integer, String, Table

from ..catalog.base import Base

metadata = Base.metadata

entities = Table(
"entities",
metadata,
Column("id", String, primary_key=True),
Column(
"node_id",
Integer,
ForeignKey("nodes.id", ondelete="SET NULL"),
nullable=True,
),
Column("entity_type", String, nullable=False),
Column("name", String, nullable=False),
Column("uri", String, nullable=True),
Column("properties", JSON, nullable=False),
Column("access_blob", JSON, nullable=False),
Column("created_at", DateTime(timezone=True), nullable=False),
Index("entities_node_id_idx", "node_id"),
Index("entities_type_created_idx", "entity_type", "created_at"),
Index("entities_uri_idx", "uri"),
)

links = Table(
"links",
metadata,
Column("id", String, primary_key=True),
Column(
"subject_id",
String,
ForeignKey("entities.id", ondelete="CASCADE"),
nullable=False,
),
Column("predicate", String, nullable=False),
Column(
"object_id",
String,
ForeignKey("entities.id", ondelete="CASCADE"),
nullable=False,
),
Column("properties", JSON, nullable=False),
Column("access_blob", JSON, nullable=False),
Column("created_at", DateTime(timezone=True), nullable=False),
Index("links_subject_predicate_idx", "subject_id", "predicate"),
Index("links_predicate_object_idx", "predicate", "object_id"),
Index("links_triple_idx", "subject_id", "predicate", "object_id"),
)

namespaces = Table(
"namespaces",
metadata,
Column("prefix", String, primary_key=True),
Column("uri", String, nullable=False),
Column("created_at", DateTime(timezone=True), nullable=False),
)
66 changes: 65 additions & 1 deletion tiled/graph/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from __future__ import annotations

import logging
import re
from typing import Callable

from fastapi import APIRouter, Depends, Request
Expand All @@ -31,6 +32,69 @@

logger = logging.getLogger(__name__)

# The query pre-loaded into the GraphiQL editor. It orients newcomers to the
# entity/link graph rather than showing GraphiQL's generic welcome text. Keep
# this free of backticks and `${...}` so it stays a valid JavaScript template
# literal when injected into the IDE HTML below.
DEFAULT_GRAPHIQL_QUERY = """# Tiled — Entity/Link Graph explorer
#
# This endpoint serves the graph of entities (nodes) and the links (edges)
# between them, alongside the catalog tree.
#
# Access is controlled, so most queries need an API key. Open the "Headers"
# tab below and add your key:
#
# { "Authorization": "Apikey YOUR_API_KEY" }
#
# Without it, queries do not error — they just return empty results.
#
# Run a query with Ctrl-Enter (or the play button). Browse the schema in the
# "Docs" and "Explorer" panels on the left.

query ExploreGraph {
entities(limit: 10) {
id
name
entityType
uri
outgoingLinks(limit: 5) {
predicate
object {
id
name
}
}
}
namespaces {
prefix
uri
}
}
"""

# Matches Strawberry's bundled `const EXAMPLE_QUERY = ` ... ` ;` assignment.
_EXAMPLE_QUERY_RE = re.compile(r"const EXAMPLE_QUERY = `.*?`;", re.DOTALL)


class _TiledGraphQLRouter(GraphQLRouter):
"""GraphQLRouter that preloads a Tiled-specific default query.

Strawberry bundles a static GraphiQL page whose editor opens with a
generic welcome message. We reuse that page but swap the default query for
one tailored to the entity/link graph. If Strawberry ever changes the
template and the marker is not found, the original HTML is served
unchanged.
"""

@property
def graphql_ide_html(self) -> str:
html = super().graphql_ide_html
return _EXAMPLE_QUERY_RE.sub(
lambda _: f"const EXAMPLE_QUERY = `{DEFAULT_GRAPHIQL_QUERY}`;",
html,
count=1,
)


def create_router(get_database_settings: Callable[[], DatabaseSettings]) -> APIRouter:
store: list[GraphSQLAlchemyStore] = [] # mutable cell — populated on startup
Expand Down Expand Up @@ -59,7 +123,7 @@ async def get_context(
"access_policy": getattr(request.app.state, "access_policy", None),
}

graphql_router = GraphQLRouter(
graphql_router = _TiledGraphQLRouter(
schema,
context_getter=get_context,
graphql_ide="graphiql",
Expand Down
19 changes: 15 additions & 4 deletions tiled/graph/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
- Entity — a named node with a type and arbitrary JSON properties
- Link — a directed, predicate-labeled edge between two entities
- Namespace — a CURIE prefix -> URI mapping used to expand/compact terms
(property keys and link predicates) for JSON-LD import/export.
(property keys and link predicates).

Query highlights:
- entity / entities — fetch nodes
Expand All @@ -20,8 +20,7 @@

Property keys and link predicates are expanded against the namespace
registry when written and compacted back to CURIEs when read, so a
prefix registered through `upsertNamespace` (or through JSON-LD import)
is resolved consistently regardless of which interface wrote the data.
prefix registered through `upsertNamespace` is resolved consistently.
"""

from __future__ import annotations
Expand All @@ -31,6 +30,7 @@

import strawberry
from graphql import GraphQLError
from strawberry.extensions import QueryDepthLimiter
from strawberry.scalars import JSON as StrawberryJSON
from strawberry.types import Info
from strawberry.types.unset import UNSET, UnsetType
Expand All @@ -44,6 +44,13 @@

logger = logging.getLogger(__name__)

# Maximum nesting depth allowed in a single GraphQL query. The entity/link
# graph is recursively traversable (Entity.outgoingLinks -> Link.object ->
# Entity.outgoingLinks -> ...), so an unbounded query could force arbitrarily
# deep and expensive resolution. Introspection queries are exempt (the limiter
# ignores them by default), so the GraphiQL "Docs" panel is unaffected.
MAX_QUERY_DEPTH = 10

# ---------------------------------------------------------------------------
# JSON scalar — pass arbitrary dicts / lists / primitives through GraphQL
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -575,4 +582,8 @@ async def delete_namespace(self, info: Info, prefix: str) -> bool:
# Schema
# ---------------------------------------------------------------------------

schema = strawberry.Schema(query=Query, mutation=Mutation)
schema = strawberry.Schema(
query=Query,
mutation=Mutation,
extensions=[lambda: QueryDepthLimiter(max_depth=MAX_QUERY_DEPTH)],
)
Loading