diff --git a/demonstrator/federated-automated-rhea-13.rq b/demonstrator/federated-automated-rhea-13.rq
new file mode 100644
index 0000000..2e93705
--- /dev/null
+++ b/demonstrator/federated-automated-rhea-13.rq
@@ -0,0 +1,21 @@
+# Datasources: https://sparql.rhea-db.org/sparql https://sparql.uniprot.org/sparql/
+PREFIX rh:
+PREFIX taxon:
+PREFIX up:
+# Query 13
+# Select all Rhea reactions used to annotate Escherichia coli (taxid=83333) in UniProtKB/Swiss-Prot
+# return the number of UniProtKB entries
+SELECT ?uniprot ?mnemo ?rhea ?accession ?equation
+WHERE {
+ {
+ VALUES (?taxid) { (taxon:83333) }
+ GRAPH {
+ ?uniprot up:reviewed true .
+ ?uniprot up:mnemonic ?mnemo .
+ ?uniprot up:organism ?taxid .
+ ?uniprot up:annotation/up:catalyticActivity/up:catalyzedReaction ?rhea .
+ }
+ }
+ ?rhea rh:accession ?accession .
+ ?rhea rh:equation ?equation .
+}
\ No newline at end of file
diff --git a/demonstrator/link-traversal-solidbench-1.rq b/demonstrator/link-traversal-solidbench-1.rq
new file mode 100644
index 0000000..1f26d48
--- /dev/null
+++ b/demonstrator/link-traversal-solidbench-1.rq
@@ -0,0 +1,11 @@
+# Datasources: https://solidbench.linkeddatafragments.org/pods/00000000000000000933/profile/card
+# QueryMode: solid-link-traversal
+PREFIX rdf:
+PREFIX snvoc:
+SELECT ?messageId ?messageCreationDate ?messageContent WHERE {
+ ?message snvoc:hasCreator ;
+ rdf:type snvoc:Post;
+ snvoc:content ?messageContent;
+ snvoc:creationDate ?messageCreationDate;
+ snvoc:id ?messageId.
+}
diff --git a/demonstrator/link-traversal-solidbench-2.rq b/demonstrator/link-traversal-solidbench-2.rq
new file mode 100644
index 0000000..8b07098
--- /dev/null
+++ b/demonstrator/link-traversal-solidbench-2.rq
@@ -0,0 +1,10 @@
+# Datasources: https://solidbench.linkeddatafragments.org/pods/00000015393162789111/posts
+# QueryMode: solid-link-traversal
+PREFIX snvoc:
+SELECT ?personId ?firstName ?lastName WHERE {
+ snvoc:id ?messageId;
+ snvoc:hasCreator ?creator.
+ ?creator snvoc:id ?personId;
+ snvoc:firstName ?firstName;
+ snvoc:lastName ?lastName.
+}
diff --git a/demonstrator/solid-link-traversal-foaf-knows.rq b/demonstrator/solid-link-traversal-foaf-knows.rq
deleted file mode 100644
index 0140d66..0000000
--- a/demonstrator/solid-link-traversal-foaf-knows.rq
+++ /dev/null
@@ -1,10 +0,0 @@
-# Datasources: https://triple.ilabt.imec.be/test/profile/card
-# QueryMode: solid-link-traversal
-PREFIX foaf:
-
-SELECT ?person ?name
-WHERE {
- foaf:knows ?person .
- ?person foaf:name ?name .
-}
-LIMIT 10
diff --git a/demonstrator/solid-link-traversal-see-also.rq b/demonstrator/solid-link-traversal-see-also.rq
deleted file mode 100644
index 5970bea..0000000
--- a/demonstrator/solid-link-traversal-see-also.rq
+++ /dev/null
@@ -1,9 +0,0 @@
-# Datasources:https://triple.ilabt.imec.be/test/profile/card
-# QueryMode: solid-link-traversal
-PREFIX rdfs:
-
-SELECT ?linkedDocument
-WHERE {
- rdfs:seeAlso ?linkedDocument .
-}
-LIMIT 10
diff --git a/demonstrator/solid-no-traversal-basic-select.rq b/demonstrator/solid-no-traversal-basic-select.rq
index 8bec9f4..a7c53b1 100644
--- a/demonstrator/solid-no-traversal-basic-select.rq
+++ b/demonstrator/solid-no-traversal-basic-select.rq
@@ -1,4 +1,4 @@
-# Datasources: https://triple.ilabt.imec.be/test/bio-usecase/nbn-chist-era-annex-1-chemicals-custom-predicate.ttl
+# Datasources: https://triple.ilabt.imec.be/test/
# QueryMode: solid-no-traversal
SELECT ?s ?p ?o
WHERE {
diff --git a/demonstrator/triple-solid-2.rq b/demonstrator/triple-solid-2.rq
index 0b7eff8..e65f646 100644
--- a/demonstrator/triple-solid-2.rq
+++ b/demonstrator/triple-solid-2.rq
@@ -1,4 +1,4 @@
-# Datasources: http://localhost:3000/test/random/nbn-chist-era-annex-1-chemicals-custom-predicate.ttl
+# Datasources: https://triple.ilabt.imec.be/test/bio-usecase/nbn-chist-era-annex-1-chemicals-custom-predicate.ttl
# QueryMode: solid-no-traversal
PREFIX thd:
SELECT DISTINCT ?CAS WHERE {
diff --git a/src/components/DataQuery.vue b/src/components/DataQuery.vue
index 7693675..a701352 100644
--- a/src/components/DataQuery.vue
+++ b/src/components/DataQuery.vue
@@ -2245,6 +2245,23 @@ export default {
isLikelySolidOrRdfSource(source: string) {
return !this.isLikelySparqlEndpointSource(source);
},
+ /**
+ * Infers query mode from canonical example filename prefixes so the sample
+ * picker remains stable even when files omit explicit # QueryMode metadata.
+ */
+ inferModeFromExampleId(exampleId: string): QueryExecutionMode | null {
+ const normalizedId = exampleId.toLowerCase();
+ if (
+ normalizedId.startsWith("link-traversal-") ||
+ normalizedId.startsWith("link-taversal-")
+ ) {
+ return "solid-link-traversal";
+ }
+ if (normalizedId.startsWith("solid-no-traversal-")) {
+ return "solid-no-traversal";
+ }
+ return null;
+ },
/**
* Determines the target query engine mode for an example query. Authors can
* explicitly pin a mode via `# QueryMode: ` in the .rq file.
@@ -2253,6 +2270,7 @@ export default {
_queryText: string,
sources: string[],
declaredMode?: string,
+ exampleId?: string,
): QueryExecutionMode {
if (
declaredMode &&
@@ -2261,6 +2279,13 @@ export default {
return declaredMode as QueryExecutionMode;
}
+ if (exampleId) {
+ const modeFromName = this.inferModeFromExampleId(exampleId);
+ if (modeFromName) {
+ return modeFromName;
+ }
+ }
+
const endpointSourceCount = sources.filter((source) =>
this.isLikelySparqlEndpointSource(source),
).length;
@@ -2283,6 +2308,7 @@ export default {
queryText: string,
sources: string[],
mode: QueryExecutionMode,
+ exampleId?: string,
): ExampleQueryCategory {
if (mode === "solid-link-traversal") {
return "Solid query (link traversal)";
@@ -2291,13 +2317,32 @@ export default {
return "Solid query (no traversal)";
}
+ const normalizedId = (exampleId || "").toLowerCase();
+ if (normalizedId.startsWith("federated-")) {
+ return "Federated query";
+ }
+
const hasServiceClause = /service\s*<[^>]+>/i.test(queryText);
const endpointSourceCount = sources.filter((source) =>
this.isLikelySparqlEndpointSource(source),
).length;
+ const endpointHosts = new Set(
+ sources
+ .filter((source) => this.isLikelySparqlEndpointSource(source))
+ .map((source) => this.normalizeSourceUrlForValidation(source))
+ .map((source) => {
+ try {
+ return new URL(source).host;
+ } catch {
+ return "";
+ }
+ })
+ .filter((host) => host.length > 0),
+ );
if (
hasServiceClause ||
+ endpointHosts.size > 1 ||
endpointSourceCount > 1 ||
(endpointSourceCount === 1 && sources.length > 1)
) {
@@ -2576,8 +2621,14 @@ export default {
query,
sources,
declaredMode,
+ rawName,
+ );
+ const category = this.categorizeExampleQuery(
+ query,
+ sources,
+ mode,
+ rawName,
);
- const category = this.categorizeExampleQuery(query, sources, mode);
queries.push({
id: rawName,
name,
@@ -2604,8 +2655,10 @@ export default {
);
if (!example) return;
- // Clone example sources so user edits never mutate the static sample list.
- this.currentQuery.sources = [...example.sources];
+ // Link-traversal examples intentionally start with no explicit datasource.
+ // Comunica can derive traversal seeds from IRIs that appear in the query.
+ this.currentQuery.sources =
+ example.mode === "solid-link-traversal" ? [] : [...example.sources];
this.currentQuery.query = example.query || "";
this.queryMode = example.mode;
this.syncYasqeFromExternalQuery(this.currentQuery.query, {
diff --git a/src/components/Guides/DataQueryGuide.vue b/src/components/Guides/DataQueryGuide.vue
index 5c9e6ce..95f0a71 100644
--- a/src/components/Guides/DataQueryGuide.vue
+++ b/src/components/Guides/DataQueryGuide.vue
@@ -52,12 +52,12 @@
Targets multiple SPARQL endpoints in one execution (commonly via SERVICE clauses).
- Solid query
- Targets RDF resources from a Solid pod or local server.
+ Solid query (no traversal)
+ Targets explicit Solid RDF resources/containers without discovering linked documents.
- Mixed source federated query
- Targets both Solid pod sources and SPARQL endpoint sources.
+ Solid query (link traversal)
+ Starts from Solid seed URLs and discovers additional RDF documents by following links.
@@ -70,6 +70,22 @@
baseline-test
Quick concept-check query on the Rhea endpoint that returns a small set of predicate IRIs.
+
+ federated-automated-rhea-13
+ Federates UniProt and Rhea to list E. coli-reviewed proteins and associated reactions.
+
+
+ federated-uniprot-rhea-human-reactions
+ Fetches human proteins in UniProt and joins to reaction labels from Rhea.
+
+
+ federated-wikidata-uniprot-human-proteins
+ Uses Wikidata + UniProt federation to enrich proteins with mnemonics.
+
+
+ federated-wikidata-cities
+ Single-endpoint Wikidata city query with label service for readable output.
+
triple-wikidata-1
Aggregates toxicity-related compound data in Wikidata and ranks by average LD50.
@@ -95,8 +111,20 @@
Retrieves OMA ortholog protein links and organism names for a selected UniProt protein.
- triple-combined-service
- Full mixed-source federated workflow that chains Solid/local CAS data, IDSM similarity search, and Rhea reactions.
+ solid-no-traversal-basic-select
+ Reads triples directly from a selected Solid container without traversal.
+
+
+ solid-no-traversal-literal-preview
+ Extracts literal values from a specific Solid RDF document.
+
+
+ link-traversal-solidbench-1
+ SolidBench link-traversal query that starts at a profile seed and discovers related posts.
+
+
+ link-traversal-solidbench-2
+ SolidBench link-traversal query that resolves creator details from a message seed URL.
diff --git a/src/services/query/queryPod.ts b/src/services/query/queryPod.ts
index f55a9bd..9912691 100644
--- a/src/services/query/queryPod.ts
+++ b/src/services/query/queryPod.ts
@@ -1,5 +1,6 @@
import { QueryEngine as SparqlEngineCache } from "query-sparql-remote-cache";
import { QueryEngine as SolidQueryEngine } from "@comunica/query-sparql-solid";
+import { QueryEngine as SolidLinkTraversalQueryEngine } from "@comunica/query-sparql-link-traversal-solid";
import { KeyRemoteCache } from "actor-query-process-remote-cache";
import { createCoiFetch } from "./z3-headers";
import { Bindings } from "@comunica/types";
@@ -24,7 +25,7 @@ import {
setStringNoLocale,
setDatetime,
} from "@inrupt/solid-client";
-import { fetch } from "@inrupt/solid-client-authn-browser";
+import { fetch, getDefaultSession } from "@inrupt/solid-client-authn-browser";
import {
stopQuery,
cleanSourcesUrlsForCache,
@@ -215,6 +216,11 @@ export function validateQuerySourcesForMode(
mixedSources: ComunicaSources[]
): void {
if (mixedSources.length === 0) {
+ if (mode === "solid-link-traversal") {
+ // Link-traversal execution may derive seed URLs directly from IRIs in the
+ // query text, so explicit sources are optional for this mode.
+ return;
+ }
throw new Error(
"Select at least one datasource URL before running the query."
);
@@ -342,117 +348,231 @@ export async function executeQueryWithPodConnected(
return output;
}
-/**
- * Executes a SPARQL query over a list of provided Solid Pod URLs.
- *
- * @param inputQuery The string representation of a SPARQL query to be executed.
- * @param mixedSources a ComunicaSources[] that provides the Solid Pod or SPARQL Endpoint sources for executing the specified query.
- * @param cachePath a string designating the user's query cache url
- * @returns either:
- * a) a CacheOutput object (containing cache provenance + query results)
- * b) a string indicating no cache was used
- * c) null if there was an error
- */
-async function sparqlQueryWithCache(
- inputQuery: string,
- mixedSources: ComunicaSources[],
- cachePath: string,
- queryMode: QueryExecutionMode = "endpoint"
-): Promise {
- // Current remote-cache engine is endpoint-focused; non-endpoint modes bypass
- // cache-hit probing and fall back to direct execution in the caller.
- if (queryMode !== "endpoint") {
- return "no-cache";
- }
+interface QueryBindingsEngine {
+ queryBindings: (
+ inputQuery: string,
+ context: Record
+ ) => Promise;
+}
- const cacheLocation = { url: cachePath + "queries.ttl" };
- const mySparqlEngine = new SparqlEngineCache();
+interface QueryStreamToJsonOptions {
+ includeProvenance?: boolean;
+}
- try {
- const fetchForCache = createCoiFetch(fetch, {
- coepCredentialless: false,
- passthroughOpaque: true,
- noCors: false,
- });
+interface BindingsStreamWithProperties extends AsyncIterable {
+ getProperty?: (name: string, callback: (value: unknown) => void) => void;
+}
- // Query executor using Comunica
- const bindingsStream = await mySparqlEngine.queryBindings(inputQuery, {
- lenient: true,
- fetch: fetchForCache,
- [KeyRemoteCache.location.name]: cacheLocation,
- sources: mixedSources,
- failOnCacheMiss: true,
- });
+function normalizeTermType(termType: string): string {
+ switch (termType) {
+ case "Literal":
+ return "literal";
+ case "NamedNode":
+ return "uri";
+ case "BlankNode":
+ return "bnode";
+ default:
+ return termType.toLowerCase();
+ }
+}
- // extract provenance information
- let provenance: ProvenanceData | null = null;
- // Extract provenance information to display in the UI
+/**
+ * Parses a Comunica bindings stream into the JSON structure consumed by YASR/UI.
+ * Provenance extraction is optional and used only for endpoint cache-hit reads.
+ */
+async function streamBindingsToOutput(
+ bindingsStream: BindingsStreamWithProperties,
+ options: QueryStreamToJsonOptions = {}
+): Promise {
+ let provenanceOutput: ProvenanceData | null = null;
+ if (options.includeProvenance && typeof bindingsStream.getProperty === "function") {
bindingsStream.getProperty("provenance", (val) => {
- if (val && typeof val === "object" && "algorithm" in val && "id" in val) {
- provenance = {
- algorithm: val.algorithm,
+ if (
+ val &&
+ typeof val === "object" &&
+ "algorithm" in val &&
+ "id" in val &&
+ val.id &&
+ typeof val.id === "object" &&
+ "termType" in val.id &&
+ "value" in val.id
+ ) {
+ provenanceOutput = {
+ algorithm: String(val.algorithm),
id: {
- termType: val.id.termType,
- value: val.id.value,
+ termType: String(val.id.termType),
+ value: String(val.id.value),
},
};
}
});
+ }
- // Displays the results of the query
- const bindingsArray: any[] = [];
- let firstBinding: Bindings | undefined = undefined;
+ const bindingsArray: any[] = [];
+ let firstBinding: Bindings | undefined = undefined;
- // Process each binding from the stream.
- for await (const binding of bindingsStream) {
- // Capture the variable names from the first binding.
- if (!firstBinding) {
- firstBinding = binding;
- }
- const bindingObj: Record = {};
- binding.forEach((term, variable) => {
- let termType: string;
- switch (term.termType) {
- case "Literal":
- termType = "literal";
- break;
- case "NamedNode":
- termType = "uri";
- break;
- case "BlankNode":
- termType = "bnode";
- break;
- default:
- termType = term.termType.toLowerCase();
- }
- bindingObj[variable.value] = { type: termType, value: term.value };
- });
- bindingsArray.push(bindingObj);
+ for await (const binding of bindingsStream) {
+ if (!firstBinding) {
+ firstBinding = binding;
}
+ const bindingObj: Record = {};
+ binding.forEach((term, variable) => {
+ bindingObj[variable.value] = {
+ type: normalizeTermType(term.termType),
+ value: term.value,
+ };
+ });
+ bindingsArray.push(bindingObj);
+ }
- // If there were no results, use an empty array of variables.
- const vars = firstBinding
- ? Array.from(firstBinding.keys()).map((variable) => variable.value)
- : [];
+ const vars = firstBinding
+ ? Array.from(firstBinding.keys()).map((variable) => variable.value)
+ : [];
- // results as an object
- const resultsOutput: QueryResultJson = {
+ return {
+ provenanceOutput,
+ resultsOutput: {
head: { vars },
results: { bindings: bindingsArray },
- };
+ },
+ };
+}
- const returnVal: CacheOutput = {
- provenanceOutput: provenance,
- resultsOutput: resultsOutput,
- };
- return returnVal;
+/**
+ * Creates an authenticated fetch wrapper for endpoint/cache-related Comunica
+ * contexts where response header normalization is needed.
+ */
+function createAuthenticatedQueryFetch(options: { noCors: boolean }): FetchLike {
+ return createCoiFetch(fetch, {
+ coepCredentialless: false,
+ passthroughOpaque: true,
+ noCors: options.noCors,
+ });
+}
+
+function createSolidQueryContext(mixedSources: ComunicaSources[]): Record {
+ const session = getDefaultSession();
+ const hasAuthenticatedSession = Boolean(session.info.isLoggedIn);
+ const authenticatedFetch: FetchLike = hasAuthenticatedSession
+ ? session.fetch.bind(session)
+ : fetch;
+
+ // Per Comunica Solid docs, provide the authn session in context so the Solid
+ // HTTP actor can attach credentials for protected pod resources.
+ const solidContext: Record = {
+ lenient: true,
+ fetch: authenticatedFetch,
+ // Solid engines are most stable when sources are explicit IRI strings.
+ sources: mixedSources.map((source) => source.value),
+ };
+
+ if (hasAuthenticatedSession) {
+ solidContext["@comunica/actor-http-inrupt-solid-client-authn:session"] = session;
+ }
+
+ return solidContext;
+}
+
+function createEndpointCacheContext(
+ mixedSources: ComunicaSources[],
+ cachePath: string
+): Record {
+ const authenticatedFetch = createAuthenticatedQueryFetch({ noCors: false });
+ return {
+ lenient: true,
+ fetch: authenticatedFetch,
+ [KeyRemoteCache.location.name]: { url: `${cachePath}queries.ttl` },
+ sources: mixedSources,
+ failOnCacheMiss: true,
+ };
+}
+
+async function executeWithEngine(
+ engine: QueryBindingsEngine,
+ inputQuery: string,
+ context: Record,
+ options: QueryStreamToJsonOptions = {}
+): Promise {
+ const bindingsStream = await engine.queryBindings(inputQuery, context);
+ return streamBindingsToOutput(bindingsStream, options);
+}
+
+async function executeEndpointCacheLookup(
+ inputQuery: string,
+ mixedSources: ComunicaSources[],
+ cachePath: string
+): Promise {
+ const endpointEngine = new SparqlEngineCache();
+ try {
+ return await executeWithEngine(
+ endpointEngine,
+ inputQuery,
+ createEndpointCacheContext(mixedSources, cachePath),
+ { includeProvenance: true }
+ );
+ } catch {
+ return "no-cache";
+ }
+}
+
+async function executeSolidNoTraversalQuery(
+ inputQuery: string,
+ mixedSources: ComunicaSources[]
+): Promise {
+ try {
+ const solidEngine = new SolidQueryEngine();
+ return await executeWithEngine(
+ solidEngine,
+ inputQuery,
+ createSolidQueryContext(mixedSources)
+ );
+ } catch (err) {
+ return err instanceof Error ? err : new Error(String(err));
+ }
+}
+
+async function executeSolidLinkTraversalQuery(
+ inputQuery: string,
+ mixedSources: ComunicaSources[]
+): Promise {
+ try {
+ return await executeWithEngine(
+ new SolidLinkTraversalQueryEngine(),
+ inputQuery,
+ createSolidQueryContext(mixedSources)
+ );
} catch (err) {
+ return err instanceof Error ? err : new Error(String(err));
+ }
+}
+
+/**
+ * Executes a SPARQL query with a pod cache conntected.
+ *
+ * @param inputQuery The string representation of a SPARQL query to be executed.
+ * @param mixedSources a ComunicaSources[] that provides the Solid Pod or SPARQL Endpoint sources for executing the specified query.
+ * @param cachePath a string designating the user's query cache url
+ * @returns either:
+ * a) a CacheOutput object (containing cache provenance + query results)
+ * b) a string indicating no cache was used
+ * c) null if there was an error
+ */
+async function sparqlQueryWithCache(
+ inputQuery: string,
+ mixedSources: ComunicaSources[],
+ cachePath: string,
+ queryMode: QueryExecutionMode = "endpoint"
+): Promise {
+ // Current remote-cache engine is endpoint-focused; non-endpoint modes bypass
+ // cache-hit probing and fall back to direct execution in the caller.
+ if (queryMode !== "endpoint") {
return "no-cache";
}
+ return executeEndpointCacheLookup(inputQuery, mixedSources, cachePath);
}
/**
- * Executes a SPARQL query over a list of provided Solid Pod URLs.
+ * Executes a SPARQL query in a woker thread.
*
* @param inputQuery The string representation of a SPARQL query to be executed.
* @param mixedSources a ComunicaSources[] that provides the Solid Pod or SPARQL Endpoint sources for executing the specified query.
@@ -463,85 +583,17 @@ export async function executeQueryInMainThread(
mixedSources: ComunicaSources[],
queryMode: QueryExecutionMode = "solid-no-traversal"
): Promise {
- let mySparqlEngine: { queryBindings: Function };
if (queryMode === "solid-link-traversal") {
- try {
- const traversalModuleName =
- "@comunica/" + "query-sparql-link-traversal-solid";
- const traversalModule = await import(traversalModuleName);
- mySparqlEngine = new traversalModule.QueryEngine();
- } catch {
- return new Error(
- "Link-traversal mode requires @comunica/query-sparql-link-traversal-solid. Install it and try again."
- );
- }
- } else {
- mySparqlEngine = new SolidQueryEngine();
+ return executeSolidLinkTraversalQuery(inputQuery, mixedSources);
}
- const fetchForCache = createCoiFetch(fetch, {
- coepCredentialless: false,
- passthroughOpaque: true,
- noCors: true,
- });
- try {
- // Query executor using Comunica
- const bindingsStream = await mySparqlEngine.queryBindings(inputQuery, {
- lenient: true,
- sources: mixedSources,
- fetch: fetchForCache,
- });
-
- // Displays the results of the query
- const bindingsArray: any[] = [];
- let firstBinding: Bindings | undefined = undefined;
-
- // Process each binding from the stream.
- for await (const binding of bindingsStream) {
- // Capture the variable names from the first binding.
- if (!firstBinding) {
- firstBinding = binding;
- }
- const bindingObj: Record = {};
- binding.forEach((term, variable) => {
- let termType: string;
- switch (term.termType) {
- case "Literal":
- termType = "literal";
- break;
- case "NamedNode":
- termType = "uri";
- break;
- case "BlankNode":
- termType = "bnode";
- break;
- default:
- termType = term.termType.toLowerCase();
- }
- bindingObj[variable.value] = { type: termType, value: term.value };
- });
- bindingsArray.push(bindingObj);
- }
-
- // If there were no results, use an empty array of variables.
- const vars = firstBinding
- ? Array.from(firstBinding.keys()).map((variable) => variable.value)
- : [];
-
- // results as an object
- const resultsOutput: QueryResultJson = {
- head: { vars },
- results: { bindings: bindingsArray },
- };
-
- const returnVal: CacheOutput = {
- provenanceOutput: null,
- resultsOutput: resultsOutput,
- };
- return returnVal;
- } catch (err) {
- return err;
+ if (queryMode === "solid-no-traversal") {
+ return executeSolidNoTraversalQuery(inputQuery, mixedSources);
}
+
+ return new Error(
+ `Main-thread query execution is only supported for Solid modes. Received "${queryMode}".`
+ );
}
/**
diff --git a/src/services/solid/login.ts b/src/services/solid/login.ts
index d7740a1..75a2cd6 100644
--- a/src/services/solid/login.ts
+++ b/src/services/solid/login.ts
@@ -13,6 +13,19 @@ Crucially, stores credentials in session and fetch objects.
*/
export const session: Session = getDefaultSession()
+/**
+ * Builds a redirect URL that is valid for Solid OIDC login callbacks.
+ * Hash fragments and reserved OIDC params are removed from the callback URL,
+ * while the full original page URL is preserved separately in sessionStorage.
+ */
+function buildSafeLoginRedirectUrl(currentHref: string): string {
+ const currentUrl = new URL(currentHref, window.location.origin);
+ currentUrl.hash = "";
+ currentUrl.searchParams.delete("code");
+ currentUrl.searchParams.delete("state");
+ return currentUrl.toString();
+}
+
/**
* Begins the User login process via the login() method from @inrupt/solid-client by following a Pod Provider URL link.
*
@@ -25,10 +38,11 @@ export async function startLogin(purl: string): Promise {
if (!session.info.isLoggedIn) {
try {
sessionStorage.setItem("postLoginRedirect", window.location.href);
+ const safeRedirectUrl = buildSafeLoginRedirectUrl(window.location.href);
await session.login({
oidcIssuer: purl,
- redirectUrl: window.location.href,
+ redirectUrl: safeRedirectUrl,
clientName: "Solid Cockpit"
});
} catch (error) {
@@ -118,4 +132,3 @@ export async function handleRedirectAfterPageLoad(): Promise {
console.error("Error during session restoration:", error);
}
}
-
diff --git a/tests/components/DataQueryExamples.test.ts b/tests/components/DataQueryExamples.test.ts
index 8e28dd6..9cafaeb 100644
--- a/tests/components/DataQueryExamples.test.ts
+++ b/tests/components/DataQueryExamples.test.ts
@@ -51,6 +51,96 @@ describe("DataQuery sample query editing flow", () => {
expect(editor.focus).toHaveBeenCalledTimes(1);
});
+ it("infers solid link-traversal mode from link-traversal filename prefixes", () => {
+ const vm = {
+ queryModes: [
+ { id: "endpoint" },
+ { id: "solid-no-traversal" },
+ { id: "solid-link-traversal" },
+ ],
+ inferModeFromExampleId:
+ componentOptions.methods.inferModeFromExampleId,
+ isLikelySparqlEndpointSource: () => false,
+ isLikelySolidOrRdfSource: () => true,
+ };
+
+ const standardPrefix = componentOptions.methods.determineExampleQueryMode.call(
+ vm,
+ "SELECT * WHERE { ?s ?p ?o }",
+ [],
+ undefined,
+ "link-traversal-solidbench-1",
+ );
+ const legacyTypoPrefix =
+ componentOptions.methods.determineExampleQueryMode.call(
+ vm,
+ "SELECT * WHERE { ?s ?p ?o }",
+ [],
+ undefined,
+ "link-taversal-demo",
+ );
+
+ expect(standardPrefix).toBe("solid-link-traversal");
+ expect(legacyTypoPrefix).toBe("solid-link-traversal");
+ });
+
+ it("categorizes federated-prefixed examples as Federated query", () => {
+ const vm = {
+ isLikelySparqlEndpointSource: () => true,
+ normalizeSourceUrlForValidation: (value: string) => value,
+ };
+
+ const category = componentOptions.methods.categorizeExampleQuery.call(
+ vm,
+ "SELECT * WHERE { ?s ?p ?o }",
+ ["https://query.wikidata.org/sparql"],
+ "endpoint",
+ "federated-example",
+ );
+
+ expect(category).toBe("Federated query");
+ });
+
+ it("loads link-traversal examples with empty datasources", () => {
+ const editor = {
+ setValue: vi.fn(),
+ setCursor: vi.fn(),
+ focus: vi.fn(),
+ };
+
+ const linkTraversalExample = {
+ id: "link-traversal-solidbench-1",
+ name: "Link Traversal Example",
+ mode: "solid-link-traversal",
+ category: "Solid query (link traversal)",
+ description: "desc",
+ query: "SELECT * WHERE { ?s ?p ?o } LIMIT 10",
+ // Even if the example metadata provides sources, UI load should blank them.
+ sources: [""],
+ };
+
+ const vm = {
+ yasqe: editor,
+ exampleQueries: [linkTraversalExample],
+ availableExampleQueries: [linkTraversalExample],
+ queryMode: "endpoint",
+ currentQuery: {
+ query: "",
+ sources: [] as string[],
+ },
+ syncYasqeFromExternalQuery: (query: string) => {
+ editor.setValue(query);
+ editor.setCursor({ line: 0, ch: 0 });
+ editor.focus();
+ },
+ };
+
+ componentOptions.methods.onSelectExample.call(vm, linkTraversalExample.id);
+
+ expect(vm.queryMode).toBe("solid-link-traversal");
+ expect(vm.currentQuery.sources).toEqual([]);
+ });
+
it("keeps one-way sync by never writing query watcher changes back to YASQE", () => {
const editor = {
setValue: vi.fn(),
diff --git a/tests/unit/login.test.ts b/tests/unit/login.test.ts
index b6b6c9d..9d9a9ed 100644
--- a/tests/unit/login.test.ts
+++ b/tests/unit/login.test.ts
@@ -77,6 +77,27 @@ test("startLogin stores redirect and calls session.login when logged out", async
);
});
+test("startLogin strips hash and reserved OIDC params from redirectUrl", async () => {
+ (globalThis as any).window.location.href =
+ "https://example.org/dataQuery?foo=bar&code=abc123&state=xyz#query=SELECT%20*%20WHERE%20%7B%20?s%20?p%20?o%20%7D";
+
+ let capturedRedirectUrl = "";
+ session.login = (async (options: any) => {
+ capturedRedirectUrl = options.redirectUrl;
+ }) as any;
+
+ const status = await startLogin("https://issuer.example");
+ assert.equal(status, "");
+ assert.equal(
+ capturedRedirectUrl,
+ "https://example.org/dataQuery?foo=bar"
+ );
+ assert.equal(
+ (globalThis as any).sessionStorage.getItem("postLoginRedirect"),
+ "https://example.org/dataQuery?foo=bar&code=abc123&state=xyz#query=SELECT%20*%20WHERE%20%7B%20?s%20?p%20?o%20%7D"
+ );
+});
+
test("startLogin is a no-op when already logged in", async () => {
session.info.isLoggedIn = true;
let callCount = 0;
diff --git a/tests/unit/queryModeValidation.test.ts b/tests/unit/queryModeValidation.test.ts
index e0afcfe..039d960 100644
--- a/tests/unit/queryModeValidation.test.ts
+++ b/tests/unit/queryModeValidation.test.ts
@@ -50,3 +50,9 @@ test("solid traversal mode accepts Solid-like document/container targets", () =>
validateQuerySourcesForMode("solid-link-traversal", sources)
);
});
+
+test("solid traversal mode allows empty source lists", () => {
+ assert.doesNotThrow(() =>
+ validateQuerySourcesForMode("solid-link-traversal", [])
+ );
+});