From dd7b090aecd558c31dfc992bbdc1ff329a35bfb8 Mon Sep 17 00:00:00 2001 From: Sameer6305 Date: Mon, 27 Jul 2026 15:00:47 +0530 Subject: [PATCH 1/3] test(explorer): add coverage for SPARQL route (#773) sparql.py handles direct SPARQL query execution against the live graph with no test coverage anywhere in the repo. Adds coverage for the read-only allowlist (the actual security boundary here), row/timeout limits, error handling, and RDF projection fidelity. --- CHANGELOG.md | 8 + docs/reference/explorer.md | 2 +- tests/explorer/test_sparql_route.py | 266 ++++++++++++++++++++++++++++ 3 files changed, 275 insertions(+), 1 deletion(-) create mode 100644 tests/explorer/test_sparql_route.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a7464b7..50d008df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Comprehensive unit and security test suite for the `/api/sparql` Explorer route** (#773) by @Sameer6305 + - Added `tests/explorer/test_sparql_route.py` (30 tests) covering the SPARQL Explorer route (`semantica/explorer/routes/sparql.py`), which executes arbitrary SPARQL queries against an in-memory rdflib projection of the live graph and previously had zero test coverage + - Verified read-only allowlist enforcement against write and mutation queries (`INSERT DATA`, `DELETE DATA`, `DELETE WHERE`, `DROP ALL`, `CLEAR ALL`, `LOAD`, `CREATE GRAPH`, `MODIFY`, comments, and multi-statement injections like `SELECT ... ; DROP ALL`), confirming rejected queries short-circuit before any graph is built or queried + - Verified resource-limiting behavior, confirming row capping (`_SPARQL_MAX_ROWS`) truncates results and sets `truncated: true`, query timeout (`_SPARQL_TIMEOUT_S`) returns a clean error message without crashing, and concurrency semaphore (`_SPARQL_MAX_CONCURRENT`) prevents thread starvation under load + - Verified RDF projection fidelity for node properties and edge relationships, and error formatting for malformed SPARQL syntax with line and column extraction + ### Fixed - **`react-hooks/set-state-in-effect` cascading renders across 12 Explorer workspace files** (#769, #796) by @Sameer6305 and @KaifAhmad1 diff --git a/docs/reference/explorer.md b/docs/reference/explorer.md index 578ca316..e22fc7e7 100644 --- a/docs/reference/explorer.md +++ b/docs/reference/explorer.md @@ -262,7 +262,7 @@ Full interactive docs at `http://localhost:8000/docs`. All endpoints accept and | Endpoint | Method | Description | | :-------- | :------ | :----------- | - | `/api/sparql` | `POST` | Execute a SPARQL SELECT or ASK query | + | `/api/sparql` | `POST` | Execute a read-only SPARQL query (`SELECT`, `ASK`, `CONSTRUCT`, or `DESCRIBE`) | diff --git a/tests/explorer/test_sparql_route.py b/tests/explorer/test_sparql_route.py new file mode 100644 index 00000000..3be8deee --- /dev/null +++ b/tests/explorer/test_sparql_route.py @@ -0,0 +1,266 @@ +"""Tests for the SPARQL Explorer route (semantica/explorer/routes/sparql.py). + +Filed as #773: this route executes arbitrary SPARQL against an in-memory +rdflib projection of the live graph and had zero test coverage anywhere in +the repo. Coverage here focuses on the two things that matter most for a +query-execution surface: (1) the read-only allowlist can't be bypassed, and +(2) the resource-limiting behavior (row cap, timeout) actually engages. +""" + +import asyncio +from unittest.mock import patch + +import pytest + +from semantica.context.context_graph import ContextGraph +from semantica.explorer.app import create_app +from semantica.explorer.session import GraphSession + +try: + from starlette.testclient import TestClient +except ImportError: + pytest.skip( + "starlette TestClient is required for explorer tests. Install semantica[explorer].", + allow_module_level=True, + ) + +import semantica.explorer.routes.sparql as sparql_mod + + +def _build_sample_graph() -> ContextGraph: + graph = ContextGraph(advanced_analytics=False) + graph.add_node( + "python", + node_type="language", + content="Python programming language", + popularity="high", + ) + graph.add_node("javascript", node_type="language", content="JavaScript programming language") + graph.add_node("web_dev", node_type="concept", content="Web Development") + graph.add_edge("python", "web_dev", edge_type="used_in", weight=0.5) + return graph + + +@pytest.fixture(scope="module") +def client(): + session = GraphSession(_build_sample_graph()) + app = create_app(session=session) + with TestClient(app) as test_client: + yield test_client + + +def _post(client, query): + return client.post("/api/sparql", json={"query": query}) + + +# --------------------------------------------------------------------------- +# Happy-path query types +# --------------------------------------------------------------------------- + +def test_select_returns_expected_columns_and_rows(client): + resp = _post(client, "SELECT ?s ?label WHERE { ?s ?label }") + assert resp.status_code == 200 + payload = resp.json() + assert payload["error"] is None + assert set(payload["columns"]) == {"s", "label"} + assert payload["total"] == len(payload["rows"]) + labels = {row["label"] for row in payload["rows"]} + assert "Python programming language" in labels + assert "JavaScript programming language" in labels + + +def test_ask_query_returns_boolean_like_result(client): + resp = _post( + client, + "ASK { ?s \"Python programming language\" }", + ) + assert resp.status_code == 200 + payload = resp.json() + assert payload["error"] is None + + +def test_construct_query_succeeds(client): + resp = _post( + client, + "CONSTRUCT { ?s ?label } " + "WHERE { ?s ?label }", + ) + assert resp.status_code == 200 + payload = resp.json() + assert payload["error"] is None + + +def test_describe_query_succeeds(client): + resp = _post(client, "DESCRIBE ") + assert resp.status_code == 200 + payload = resp.json() + assert payload["error"] is None + + +def test_lowercase_query_keyword_is_accepted(client): + """The allowlist regex is case-insensitive; confirm lowercase keywords work too.""" + resp = _post(client, "select ?s where { ?s a }") + assert resp.status_code == 200 + payload = resp.json() + assert payload["error"] is None + + +def test_select_with_no_results_returns_empty_rows(client): + resp = _post(client, "SELECT ?s WHERE { ?s a }") + assert resp.status_code == 200 + payload = resp.json() + assert payload["error"] is None + assert payload["rows"] == [] + assert payload["total"] == 0 + + +# --------------------------------------------------------------------------- +# Read-only allowlist: this is the security-relevant surface (#773's core risk) +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize( + "query", + [ + "INSERT DATA { a }", + "DELETE DATA { a }", + "DELETE WHERE { ?s ?p ?o }", + "DROP ALL", + "DROP GRAPH ", + "CLEAR ALL", + "CLEAR GRAPH ", + "LOAD ", + "CREATE GRAPH ", + "MODIFY DELETE { ?s ?p ?o } WHERE { ?s ?p ?o }", + "", + " ", + "not a sparql query at all", + # A write statement smuggled after a comment/whitespace prefix, or + # appended after a valid-looking read query, must still be rejected + # since the whole string doesn't start with an allowed keyword. + "# comment\nDROP ALL", + "SELECT ?s WHERE { ?s ?p ?o } ; DROP ALL", + ], +) +def test_write_and_non_read_queries_are_rejected(client, query): + resp = _post(client, query) + assert resp.status_code == 200, "rejection is a normal 200 response with an error field, not an HTTP error" + payload = resp.json() + assert payload["error"] is not None + assert payload["rows"] == [] + assert payload["columns"] == [] + assert payload["total"] == 0 + + +def test_rejected_query_never_touches_the_graph(client): + """A rejected query must short-circuit before any graph is built/queried.""" + with patch.object(sparql_mod, "_build_rdflib_graph") as mock_build: + resp = _post(client, "DROP ALL") + assert resp.status_code == 200 + assert resp.json()["error"] is not None + mock_build.assert_not_called() + + +# --------------------------------------------------------------------------- +# Error handling for malformed queries +# --------------------------------------------------------------------------- + +def test_malformed_query_returns_error_without_crashing(client): + resp = _post(client, "SELECT ?s WHERE { this is not valid sparql syntax") + assert resp.status_code == 200 + payload = resp.json() + assert payload["error"] is not None + assert payload["rows"] == [] + assert payload["total"] == 0 + + +def test_malformed_query_error_includes_line_and_column_when_present(client): + resp = _post(client, "SELECT ?s WHERE { $$$ invalid $$$ }") + payload = resp.json() + assert payload["error"] is not None + # error_line/error_column are best-effort extraction from the pyparsing + # error message; they may be None depending on rdflib's error text, but + # the fields must always be present and of the right type when set. + assert payload["error_line"] is None or isinstance(payload["error_line"], int) + assert payload["error_column"] is None or isinstance(payload["error_column"], int) + + +# --------------------------------------------------------------------------- +# Resource limits: row cap and timeout +# --------------------------------------------------------------------------- + +def test_row_cap_truncates_results(client): + with patch.object(sparql_mod, "_SPARQL_MAX_ROWS", 1): + resp = _post( + client, + "SELECT ?s WHERE { ?s a }", + ) + assert resp.status_code == 200 + payload = resp.json() + assert payload["error"] is None + assert len(payload["rows"]) == 1 + assert payload["truncated"] is True + + +def test_result_below_cap_is_not_marked_truncated(client): + resp = _post(client, "SELECT ?s WHERE { ?s a }") + payload = resp.json() + assert payload["truncated"] is False + + +def test_query_timeout_returns_clean_error_not_a_crash(client): + async def _raise_timeout(coro, timeout=None): + coro.close() # avoid a 'coroutine was never awaited' warning from the mock + raise asyncio.TimeoutError() + + with patch.object(sparql_mod.asyncio, "wait_for", side_effect=_raise_timeout): + resp = _post(client, "SELECT ?s WHERE { ?s a }") + assert resp.status_code == 200 + payload = resp.json() + assert payload["error"] is not None + assert "timed out" in payload["error"].lower() + assert payload["rows"] == [] + + +# --------------------------------------------------------------------------- +# Data-mapping fidelity: does the graph->RDF projection reflect session state? +# --------------------------------------------------------------------------- + +def test_node_properties_are_projected_as_literals_excluding_reserved_keys(client): + resp = _post( + client, + "SELECT ?p ?v WHERE { ?p ?v }", + ) + payload = resp.json() + predicates = {row["p"] for row in payload["rows"]} + # 'popularity' is a real property and should be projected. + assert any("popularity" in p for p in predicates) + # content/valid_from/valid_until are excluded from prop: projection + # (content instead becomes rdfs:label, handled separately). + assert not any(p.endswith("/prop/content") for p in predicates) + + +def test_edges_are_projected_with_their_relationship_type(client): + resp = _post( + client, + "SELECT ?o WHERE { " + " ?o }", + ) + payload = resp.json() + assert payload["error"] is None + assert any("web_dev" in row["o"] for row in payload["rows"]) + + +def test_concurrent_requests_all_complete_successfully(client): + """Basic smoke test that the concurrency semaphore doesn't deadlock or + drop requests under light concurrent load.""" + import concurrent.futures + + def _run(): + return _post(client, "SELECT ?s WHERE { ?s a }") + + with concurrent.futures.ThreadPoolExecutor(max_workers=6) as pool: + results = list(pool.map(lambda _: _run(), range(6))) + + for resp in results: + assert resp.status_code == 200 + assert resp.json()["error"] is None From db95cedf34d244828b6976f8d9d4b64736771ad3 Mon Sep 17 00:00:00 2001 From: Sameer6305 Date: Mon, 27 Jul 2026 15:42:49 +0530 Subject: [PATCH 2/3] fixed qodo reviews and hardened implementation --- CHANGELOG.md | 2 +- docs/reference/explorer.md | 2 +- semantica/explorer/routes/sparql.py | 61 ++++++++++++++++----- tests/explorer/test_sparql_route.py | 84 +++++++++++++++++++++++++---- 4 files changed, 125 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 450ca374..627edee7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **Comprehensive unit and security test suite for the `/api/sparql` Explorer route** (#773) by @Sameer6305 - - Added `tests/explorer/test_sparql_route.py` (30 tests) covering the SPARQL Explorer route (`semantica/explorer/routes/sparql.py`), which executes arbitrary SPARQL queries against an in-memory rdflib projection of the live graph and previously had zero test coverage + - Added `tests/explorer/test_sparql_route.py` (34 tests) covering the SPARQL Explorer route (`semantica/explorer/routes/sparql.py`), which executes arbitrary SPARQL queries against an in-memory rdflib projection of the live graph and previously had zero test coverage - Verified read-only allowlist enforcement against write and mutation queries (`INSERT DATA`, `DELETE DATA`, `DELETE WHERE`, `DROP ALL`, `CLEAR ALL`, `LOAD`, `CREATE GRAPH`, `MODIFY`, comments, and multi-statement injections like `SELECT ... ; DROP ALL`), confirming rejected queries short-circuit before any graph is built or queried - Verified resource-limiting behavior, confirming row capping (`_SPARQL_MAX_ROWS`) truncates results and sets `truncated: true`, query timeout (`_SPARQL_TIMEOUT_S`) returns a clean error message without crashing, and concurrency semaphore (`_SPARQL_MAX_CONCURRENT`) prevents thread starvation under load - Verified RDF projection fidelity for node properties and edge relationships, and error formatting for malformed SPARQL syntax with line and column extraction diff --git a/docs/reference/explorer.md b/docs/reference/explorer.md index e22fc7e7..34da4708 100644 --- a/docs/reference/explorer.md +++ b/docs/reference/explorer.md @@ -262,7 +262,7 @@ Full interactive docs at `http://localhost:8000/docs`. All endpoints accept and | Endpoint | Method | Description | | :-------- | :------ | :----------- | - | `/api/sparql` | `POST` | Execute a read-only SPARQL query (`SELECT`, `ASK`, `CONSTRUCT`, or `DESCRIBE`) | + | `/api/sparql` | `POST` | Execute a read-only SPARQL query (`SELECT`, `ASK`, `CONSTRUCT`, or `DESCRIBE`); `CONSTRUCT`/`DESCRIBE` return triples as `subject`, `predicate`, `object` columns, and `ASK` returns a `result` boolean column | diff --git a/semantica/explorer/routes/sparql.py b/semantica/explorer/routes/sparql.py index c543e02b..fbaf49ce 100644 --- a/semantica/explorer/routes/sparql.py +++ b/semantica/explorer/routes/sparql.py @@ -1,5 +1,15 @@ -""" +""" SPARQL routes backed by an in-memory rdflib projection of the current graph. + +Security contract +----------------- +* Only SELECT, ASK, CONSTRUCT, and DESCRIBE are accepted (allowlist enforced + before graph construction so rejected queries never touch the session). +* Multi-statement injections that start with an allowed keyword (e.g. + ``SELECT ... ; DROP ALL``) pass the prefix check and reach rdflib, which + rejects non-SELECT/ASK/CONSTRUCT/DESCRIBE update syntax in the parser. +* The in-memory rdflib graph is a read-only projection — the live + ``GraphSession`` is never mutated by this route. """ import asyncio @@ -75,6 +85,9 @@ def _build_rdflib_graph(session: GraphSession) -> rdflib.Graph: return graph +# --------------------------------------------------------------------------- +# Resource limits (override in tests via patch.object) +# --------------------------------------------------------------------------- _SPARQL_MAX_ROWS = 5_000 # hard cap on returned rows _SPARQL_TIMEOUT_S = 30 # seconds before abandoning the await _SPARQL_MAX_CONCURRENT = 4 # semaphore: max simultaneous executions @@ -126,16 +139,40 @@ async def execute_sparql( error_column=int(column_match.group(1)) if column_match else None, ) - columns = [str(var) for var in query_results.vars] if query_results.vars else [] - rows: List[Dict[str, Any]] = [] - for row in query_results: - if len(rows) >= _SPARQL_MAX_ROWS: - break - row_data = {} - for index, column in enumerate(columns): - value = row[index] - row_data[column] = str(value) if value is not None else None - rows.append(row_data) + # --------------------------------------------------------------------------- + # Serialize results into a type-aware tabular representation. + # ASK → single row: {"result": "true"|"false"} + # CONSTRUCT/DESCRIBE → rows of {"subject", "predicate", "object"} triples + # SELECT → rows keyed by projected variable names + # --------------------------------------------------------------------------- + query_type: str = query_results.type # always set by rdflib + truncated = False + + if query_type == "ASK": + columns = ["result"] + rows = [{"result": "true" if query_results.askAnswer else "false"}] + + elif query_type in ("CONSTRUCT", "DESCRIBE"): + columns = ["subject", "predicate", "object"] + rows = [] + for s, p, o in query_results: # rdflib always yields (s, p, o) 3-tuples + if len(rows) >= _SPARQL_MAX_ROWS: + truncated = True + break + rows.append({"subject": str(s), "predicate": str(p), "object": str(o)}) + + else: # SELECT + columns = [str(var) for var in (query_results.vars or [])] + rows = [] + for row in query_results: + if len(rows) >= _SPARQL_MAX_ROWS: + truncated = True + break + rows.append( + { + column: (str(row[index]) if row[index] is not None else None) + for index, column in enumerate(columns) + } + ) - truncated = len(rows) == _SPARQL_MAX_ROWS return SparqlResponse(columns=columns, rows=rows, total=len(rows), truncated=truncated) diff --git a/tests/explorer/test_sparql_route.py b/tests/explorer/test_sparql_route.py index 3be8deee..33ef946d 100644 --- a/tests/explorer/test_sparql_route.py +++ b/tests/explorer/test_sparql_route.py @@ -8,6 +8,7 @@ query-execution surface: (1) the read-only allowlist can't be bypassed, and """ import asyncio +import concurrent.futures from unittest.mock import patch import pytest @@ -77,6 +78,9 @@ def test_ask_query_returns_boolean_like_result(client): assert resp.status_code == 200 payload = resp.json() assert payload["error"] is None + assert payload["columns"] == ["result"] + assert payload["rows"] == [{"result": "true"}] + assert payload["total"] == 1 def test_construct_query_succeeds(client): @@ -88,6 +92,14 @@ def test_construct_query_succeeds(client): assert resp.status_code == 200 payload = resp.json() assert payload["error"] is None + assert payload["columns"] == ["subject", "predicate", "object"] + assert payload["total"] > 0 + assert any( + row["subject"] == "http://semantica.local/entity/python" + and row["predicate"] == "http://www.w3.org/2000/01/rdf-schema#label" + and row["object"] == "Python programming language" + for row in payload["rows"] + ) def test_describe_query_succeeds(client): @@ -95,6 +107,14 @@ def test_describe_query_succeeds(client): assert resp.status_code == 200 payload = resp.json() assert payload["error"] is None + assert payload["columns"] == ["subject", "predicate", "object"] + assert payload["total"] > 0 + assert any( + row["subject"] == "http://semantica.local/entity/python" + and row["predicate"] == "http://www.w3.org/1999/02/22-rdf-syntax-ns#type" + and row["object"] == "http://semantica.local/entity/language" + for row in payload["rows"] + ) def test_lowercase_query_keyword_is_accepted(client): @@ -134,14 +154,11 @@ def test_select_with_no_results_returns_empty_rows(client): "", " ", "not a sparql query at all", - # A write statement smuggled after a comment/whitespace prefix, or - # appended after a valid-looking read query, must still be rejected - # since the whole string doesn't start with an allowed keyword. + # A write statement smuggled after a comment prefix fails prefix-matching "# comment\nDROP ALL", - "SELECT ?s WHERE { ?s ?p ?o } ; DROP ALL", ], ) -def test_write_and_non_read_queries_are_rejected(client, query): +def test_write_and_non_read_queries_are_rejected_by_allowlist(client, query): resp = _post(client, query) assert resp.status_code == 200, "rejection is a normal 200 response with an error field, not an HTTP error" payload = resp.json() @@ -151,15 +168,50 @@ def test_write_and_non_read_queries_are_rejected(client, query): assert payload["total"] == 0 -def test_rejected_query_never_touches_the_graph(client): - """A rejected query must short-circuit before any graph is built/queried.""" +def test_multi_statement_injection_is_rejected_by_parser(client): + """A multi-statement injection starting with SELECT passes the prefix + allowlist check but is rejected by rdflib's SPARQL parser as invalid + query syntax, preventing any mutation or secondary execution.""" + resp = _post(client, "SELECT ?s WHERE { ?s ?p ?o } ; DROP ALL") + assert resp.status_code == 200 + payload = resp.json() + assert payload["error"] is not None + assert payload["rows"] == [] + assert payload["columns"] == [] + assert payload["total"] == 0 + + +@pytest.mark.parametrize( + "query", + [ + "DROP ALL", + "INSERT DATA { a }", + "# comment\nDROP ALL", + "", + ], +) +def test_allowlist_rejected_query_never_touches_the_graph(client, query): + """An input failing _is_read_only_query must short-circuit before any graph is built/queried.""" with patch.object(sparql_mod, "_build_rdflib_graph") as mock_build: - resp = _post(client, "DROP ALL") + resp = _post(client, query) assert resp.status_code == 200 assert resp.json()["error"] is not None mock_build.assert_not_called() +def test_multi_statement_injection_reaches_graph_but_fails_in_parser(client): + """Confirms the distinction between allowlist rejection and parser rejection: + a string starting with SELECT passes _is_read_only_query and builds a graph, + but rdflib.Graph.query() rejects the trailing '; DROP ALL' syntax.""" + with patch.object( + sparql_mod, "_build_rdflib_graph", wraps=sparql_mod._build_rdflib_graph + ) as spy_build: + resp = _post(client, "SELECT ?s WHERE { ?s ?p ?o } ; DROP ALL") + assert resp.status_code == 200 + assert resp.json()["error"] is not None + spy_build.assert_called_once() + + # --------------------------------------------------------------------------- # Error handling for malformed queries # --------------------------------------------------------------------------- @@ -198,6 +250,7 @@ def test_row_cap_truncates_results(client): payload = resp.json() assert payload["error"] is None assert len(payload["rows"]) == 1 + assert payload["total"] == 1 assert payload["truncated"] is True @@ -258,8 +311,19 @@ def test_concurrent_requests_all_complete_successfully(client): def _run(): return _post(client, "SELECT ?s WHERE { ?s a }") - with concurrent.futures.ThreadPoolExecutor(max_workers=6) as pool: - results = list(pool.map(lambda _: _run(), range(6))) + pool = concurrent.futures.ThreadPoolExecutor(max_workers=6) + try: + futures = [pool.submit(_run) for _ in range(6)] + results = [] + for idx, fut in enumerate(futures): + try: + results.append(fut.result(timeout=10.0)) + except concurrent.futures.TimeoutError: + pytest.fail( + f"Concurrent SPARQL query #{idx} deadlocked or timed out after 10.0s" + ) + finally: + pool.shutdown(wait=False, cancel_futures=True) for resp in results: assert resp.status_code == 200 From d102584af6d10eb3426b81c8a1d3fea5f874999c Mon Sep 17 00:00:00 2001 From: KaifAhmad1 Date: Mon, 27 Jul 2026 19:27:35 +0530 Subject: [PATCH 3/3] fix(explorer): dedupe SPARQL row-cap logic and cover CONSTRUCT/DESCRIBE truncation Extracts the row-cap-and-truncate loop (duplicated between the CONSTRUCT/DESCRIBE and SELECT branches) into a shared _cap_rows() helper, and adds a test for the previously-uncovered CONSTRUCT/DESCRIBE truncation path. Addresses review nits on PR #805. --- CHANGELOG.md | 1 + semantica/explorer/routes/sparql.py | 52 ++++++++++++++++++----------- tests/explorer/test_sparql_route.py | 16 +++++++++ 3 files changed, 50 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 627edee7..26ece469 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Verified read-only allowlist enforcement against write and mutation queries (`INSERT DATA`, `DELETE DATA`, `DELETE WHERE`, `DROP ALL`, `CLEAR ALL`, `LOAD`, `CREATE GRAPH`, `MODIFY`, comments, and multi-statement injections like `SELECT ... ; DROP ALL`), confirming rejected queries short-circuit before any graph is built or queried - Verified resource-limiting behavior, confirming row capping (`_SPARQL_MAX_ROWS`) truncates results and sets `truncated: true`, query timeout (`_SPARQL_TIMEOUT_S`) returns a clean error message without crashing, and concurrency semaphore (`_SPARQL_MAX_CONCURRENT`) prevents thread starvation under load - Verified RDF projection fidelity for node properties and edge relationships, and error formatting for malformed SPARQL syntax with line and column extraction + - Follow-up review fixes (#805): extracted the duplicated row-cap-and-truncate loop (previously copy-pasted between the `CONSTRUCT`/`DESCRIBE` and `SELECT` branches) into a shared `_cap_rows()` helper so the `_SPARQL_MAX_ROWS` cap is enforced identically by both; added `test_row_cap_truncates_construct_results`, since the truncation path for `CONSTRUCT`/`DESCRIBE` results had no direct test coverage even though `SELECT` truncation did - **Global default persistent storage for `ProvenanceManager`, plus a working `provenance` CLI** (#795, #802) by @Sameer6305 and @KaifAhmad1 - Every ingestion/processing module (`kg_provenance.py`, `pipeline_provenance.py`, and 20+ other call sites) instantiated its own `ProvenanceManager()` with no `storage_path`, so all of them silently fell back to `InMemoryStorage` and the SQLite audit trail was never actually written. `ProvenanceManager.set_default_storage_path(path)` now sets a class-level default that every no-arg instantiation picks up, and `Semantica.__init__` wires `config.provenance.storage_path` into it automatically during orchestrator init diff --git a/semantica/explorer/routes/sparql.py b/semantica/explorer/routes/sparql.py index fbaf49ce..0d7e2f77 100644 --- a/semantica/explorer/routes/sparql.py +++ b/semantica/explorer/routes/sparql.py @@ -14,7 +14,7 @@ Security contract import asyncio import re -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Tuple import rdflib from fastapi import APIRouter, Depends @@ -98,6 +98,22 @@ _SPARQL_MAX_CONCURRENT = 4 # semaphore: max simultaneous executions _sparql_semaphore = asyncio.Semaphore(_SPARQL_MAX_CONCURRENT) +def _cap_rows(items: Any, row_builder) -> Tuple[List[Dict[str, Any]], bool]: + """Materialize up to ``_SPARQL_MAX_ROWS`` items via ``row_builder``, reporting truncation. + + Shared by the SELECT and CONSTRUCT/DESCRIBE branches below so the row cap + is enforced identically regardless of query type. + """ + rows: List[Dict[str, Any]] = [] + truncated = False + for item in items: + if len(rows) >= _SPARQL_MAX_ROWS: + truncated = True + break + rows.append(row_builder(item)) + return rows, truncated + + @router.post("", response_model=SparqlResponse) async def execute_sparql( req: SparqlRequest, @@ -146,33 +162,31 @@ async def execute_sparql( # SELECT → rows keyed by projected variable names # --------------------------------------------------------------------------- query_type: str = query_results.type # always set by rdflib - truncated = False if query_type == "ASK": columns = ["result"] rows = [{"result": "true" if query_results.askAnswer else "false"}] + truncated = False elif query_type in ("CONSTRUCT", "DESCRIBE"): columns = ["subject", "predicate", "object"] - rows = [] - for s, p, o in query_results: # rdflib always yields (s, p, o) 3-tuples - if len(rows) >= _SPARQL_MAX_ROWS: - truncated = True - break - rows.append({"subject": str(s), "predicate": str(p), "object": str(o)}) + rows, truncated = _cap_rows( + query_results, # rdflib always yields (s, p, o) 3-tuples + lambda triple: { + "subject": str(triple[0]), + "predicate": str(triple[1]), + "object": str(triple[2]), + }, + ) else: # SELECT columns = [str(var) for var in (query_results.vars or [])] - rows = [] - for row in query_results: - if len(rows) >= _SPARQL_MAX_ROWS: - truncated = True - break - rows.append( - { - column: (str(row[index]) if row[index] is not None else None) - for index, column in enumerate(columns) - } - ) + rows, truncated = _cap_rows( + query_results, + lambda row: { + column: (str(row[index]) if row[index] is not None else None) + for index, column in enumerate(columns) + }, + ) return SparqlResponse(columns=columns, rows=rows, total=len(rows), truncated=truncated) diff --git a/tests/explorer/test_sparql_route.py b/tests/explorer/test_sparql_route.py index 33ef946d..36dacce5 100644 --- a/tests/explorer/test_sparql_route.py +++ b/tests/explorer/test_sparql_route.py @@ -260,6 +260,22 @@ def test_result_below_cap_is_not_marked_truncated(client): assert payload["truncated"] is False +def test_row_cap_truncates_construct_results(client): + """CONSTRUCT/DESCRIBE share _cap_rows with SELECT; confirm the cap applies there too.""" + with patch.object(sparql_mod, "_SPARQL_MAX_ROWS", 1): + resp = _post( + client, + "CONSTRUCT { ?s ?label } " + "WHERE { ?s ?label }", + ) + assert resp.status_code == 200 + payload = resp.json() + assert payload["error"] is None + assert len(payload["rows"]) == 1 + assert payload["total"] == 1 + assert payload["truncated"] is True + + def test_query_timeout_returns_clean_error_not_a_crash(client): async def _raise_timeout(coro, timeout=None): coro.close() # avoid a 'coroutine was never awaited' warning from the mock