From 9a17b1e62b9cf27acce676e285c89958a1cd6ff4 Mon Sep 17 00:00:00 2001 From: Ivo Velitchkov Date: Wed, 26 Aug 2026 17:47:23 +0200 Subject: [PATCH 1/3] Fix SPARQL queries failing on Virtuoso endpoints - Use FILTER(!isBlank(...)) instead of FILTER(isIri(...)) on incoming-link patterns, where a literal subject is not possible; isIri() there makes Virtuoso choose a catastrophic query plan scanning the whole graph. - Rewrite the OwlRdfsSettings link type statistics query as a UNION with an outer sum() instead of joining two aggregate sub-queries, which the Virtuoso cost estimator rejects; drop the LIMIT that applied to a single-row aggregate result and so never limited anything. - Treat unbound link counts as 0: some endpoints return the aggregate variable unbound when it counts an empty solution group. - Add a connectedLinkStats() unit test covering the statistics query. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 4 ++++ src/data/sparql/responseHandler.ts | 7 ++++++- src/data/sparql/sparqlDataProvider.ts | 21 +++++++++++++------ src/data/sparql/sparqlDataProviderSettings.ts | 12 +++++------ src/data/sparql/sparqlModels.ts | 6 ++++-- test/data/sparql/sparqlProviderBasic.test.ts | 17 ++++++++++++++- 6 files changed, 51 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5510435d..b326aaef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to the Reactodia will be documented in this document. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] +#### 🐛 Fixed +- Fix `SparqlDataProvider` queries for connected elements hanging on some endpoints (e.g. Virtuoso chooses a catastrophic query plan): filter out blank nodes on the incoming-link patterns with `FILTER(!isBlank(...))` instead of `FILTER(isIri(...))`, which is equivalent in the subject position where a literal is not possible. +- Fix link type statistics query in `OwlRdfsSettings`/`OwlStatsSettings` being rejected by the Virtuoso cost estimator ("The estimated execution time ... exceeds the limit"): count incoming and outgoing links via `UNION` with an outer `sum()` instead of joining two aggregate sub-queries; counts stay exact, and the previous `LIMIT 101` is dropped as it applied to a single-row aggregate result, i.e. never. +- Fix error on a link statistics response with unbound counts, which some endpoints return when aggregating over an empty solution group: treat a missing count as 0 (with `COALESCE` in the default query as well). ## [0.35.2] - 2026-08-08 #### 🐛 Fixed diff --git a/src/data/sparql/responseHandler.ts b/src/data/sparql/responseHandler.ts index 6fb7fcc2..477ba3cc 100644 --- a/src/data/sparql/responseHandler.ts +++ b/src/data/sparql/responseHandler.ts @@ -616,7 +616,12 @@ export function appendProperty( values.push(propValue); } -function parseCount(countLiteral: Rdf.Literal): number { +function parseCount(countLiteral: Rdf.Literal | undefined): number { + // Count binding may be missing e.g. when aggregating over an empty + // solution group (some endpoints return an unbound value instead of 0) + if (!countLiteral) { + return 0; + } const numericCount = +countLiteral.value; return Number.isFinite(numericCount) ? numericCount : 0; } diff --git a/src/data/sparql/sparqlDataProvider.ts b/src/data/sparql/sparqlDataProvider.ts index fe4e258a..28a6d8bd 100644 --- a/src/data/sparql/sparqlDataProvider.ts +++ b/src/data/sparql/sparqlDataProvider.ts @@ -665,9 +665,12 @@ export class SparqlDataProvider implements DataProvider { const navigateElementFilterOut = this.acceptBlankNodes ? 'FILTER (IsIri(?outObject) || IsBlank(?outObject))' : 'FILTER IsIri(?outObject)'; + // ?inObject is in the subject position where only blank nodes are possible + // besides IRIs, and isIri() there causes some endpoints (e.g. Virtuoso) + // to choose a catastrophic query plan scanning the whole graph const navigateElementFilterIn = this.acceptBlankNodes - ? 'FILTER (IsIri(?inObject) || IsBlank(?inObject))' - : 'FILTER IsIri(?inObject)'; + ? '' + : 'FILTER(!isBlank(?inObject))'; const foundLinkStats: DataProviderLinkCount[] = []; await Promise.all(connectedLinkTypes.map(async ({linkType, hasInLink, hasOutLink}) => { @@ -887,16 +890,22 @@ export class SparqlDataProvider implements DataProvider { const linkPattern = refLinkType || '?link'; const bindType = refLinkType ? `BIND(${refLinkType} as ?link)` : ''; - // FILTER(IsIri()) is used to prevent blank nodes appearing in results - const blankFilter = this.acceptBlankNodes + // Filters prevent blank nodes and literals appearing in results; + // in the subject position only blank nodes are possible, and isIri() + // there is avoided because it causes some endpoints (e.g. Virtuoso) + // to choose a catastrophic query plan scanning the whole graph + const outFilter = this.acceptBlankNodes ? 'FILTER(isIri(?inst) || isBlank(?inst))' : 'FILTER(isIri(?inst))'; + const inFilter = this.acceptBlankNodes + ? '' + : 'FILTER(!isBlank(?inst))'; if (!direction || direction === 'out') { - unionParts.push(`{ ${refElementIRI} ${linkPattern} ?inst BIND("out" as ?direction) ${bindType} ${blankFilter} }`); + unionParts.push(`{ ${refElementIRI} ${linkPattern} ?inst BIND("out" as ?direction) ${bindType} ${outFilter} }`); } if (!direction || direction === 'in') { - unionParts.push(`{ ?inst ${linkPattern} ${refElementIRI} BIND("in" as ?direction) ${bindType} ${blankFilter} }`); + unionParts.push(`{ ?inst ${linkPattern} ${refElementIRI} BIND("in" as ?direction) ${bindType} ${inFilter} }`); } } diff --git a/src/data/sparql/sparqlDataProviderSettings.ts b/src/data/sparql/sparqlDataProviderSettings.ts index 3b03e935..40b152de 100644 --- a/src/data/sparql/sparqlDataProviderSettings.ts +++ b/src/data/sparql/sparqlDataProviderSettings.ts @@ -722,18 +722,18 @@ const OwlRdfsSettingsOverride: Partial = { } `, linkTypesStatisticsQuery: ` - SELECT ?link ?outCount ?inCount + SELECT (\${linkId} as ?link) (COALESCE(sum(?__out), 0) as ?outCount) (COALESCE(sum(?__in), 0) as ?inCount) WHERE { { - SELECT (\${linkId} as ?link) (count(?outObject) as ?outCount) WHERE { + SELECT (1 as ?__out) (0 as ?__in) WHERE { \${linkConfigurationOut} \${navigateElementFilterOut} - } LIMIT 101 - } { - SELECT (\${linkId} as ?link) (count(?inObject) as ?inCount) WHERE { + } + } UNION { + SELECT (0 as ?__out) (1 as ?__in) WHERE { \${linkConfigurationIn} \${navigateElementFilterIn} - } LIMIT 101 + } } } `, diff --git a/src/data/sparql/sparqlModels.ts b/src/data/sparql/sparqlModels.ts index 4686633e..249a5ea5 100644 --- a/src/data/sparql/sparqlModels.ts +++ b/src/data/sparql/sparqlModels.ts @@ -116,8 +116,10 @@ export interface LinkBinding { export interface LinkCountBinding { link: Rdf.NamedNode | Rdf.BlankNode; - inCount: Rdf.Literal; - outCount: Rdf.Literal; + /** May be unbound e.g. when an endpoint aggregates over an empty solution group. */ + inCount?: Rdf.Literal; + /** May be unbound e.g. when an endpoint aggregates over an empty solution group. */ + outCount?: Rdf.Literal; } export interface ConnectedLinkTypeBinding { diff --git a/test/data/sparql/sparqlProviderBasic.test.ts b/test/data/sparql/sparqlProviderBasic.test.ts index 28180827..66656dfc 100644 --- a/test/data/sparql/sparqlProviderBasic.test.ts +++ b/test/data/sparql/sparqlProviderBasic.test.ts @@ -4,7 +4,7 @@ import type { ElementIri, ElementModel, ElementTypeIri, ElementTypeModel, LinkTypeIri, LinkTypeModel, LinkModel, PropertyTypeIri, PropertyTypeModel, } from '../../../src/data/model'; -import type { DataProviderLookupItem } from '../../../src/data/dataProvider'; +import type { DataProviderLinkCount, DataProviderLookupItem } from '../../../src/data/dataProvider'; import { type MemoryDataset } from '../../../src/data/rdf/memoryDataset'; import * as Rdf from '../../../src/data/rdf/rdfModel'; import { rdf, owl } from '../../../src/data/rdf/vocabulary'; @@ -93,6 +93,21 @@ describe('SparqlDataProvider', () => { ); }); + it('provides connectedLinkStats() with exact counts', async () => { + const provider = await makeSparqlDataProvider( + {}, + {...OwlStatsSettings, filterOnlyLanguages: ['en']}, + ); + const stats = await provider.connectedLinkStats({ + elementId: org.Organization, + }); + expect(stats.find(s => s.id === rdfs.subClassOf)).toEqual({ + id: rdfs.subClassOf, + inCount: 3, + outCount: 1, + } satisfies DataProviderLinkCount); + }); + it('provides propertyTypes()', async () => { const provider = await makeSparqlDataProvider( {}, From 97408cb9b89801eaae02c43a36c1c2b0542cf36a Mon Sep 17 00:00:00 2001 From: Ivo Velitchkov Date: Fri, 11 Sep 2026 16:32:17 +0200 Subject: [PATCH 2/3] Fix text search in OwlRdfs and OwlStats settings filterTypePattern ended without a terminating dot, so a type filter followed by the text search pattern produced two adjacent triple patterns and a syntax error. Virtuoso also rejects ORDER BY on a variable bound to a literal constant, so ?score is now bound through an expression. Adds a test for a lookup by type and text. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 2 ++ src/data/sparql/sparqlDataProviderSettings.ts | 11 +++++++---- test/data/sparql/sparqlProviderBasic.test.ts | 12 ++++++++++++ 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b326aaef..6c6ea020 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - Fix `SparqlDataProvider` queries for connected elements hanging on some endpoints (e.g. Virtuoso chooses a catastrophic query plan): filter out blank nodes on the incoming-link patterns with `FILTER(!isBlank(...))` instead of `FILTER(isIri(...))`, which is equivalent in the subject position where a literal is not possible. - Fix link type statistics query in `OwlRdfsSettings`/`OwlStatsSettings` being rejected by the Virtuoso cost estimator ("The estimated execution time ... exceeds the limit"): count incoming and outgoing links via `UNION` with an outer `sum()` instead of joining two aggregate sub-queries; counts stay exact, and the previous `LIMIT 101` is dropped as it applied to a single-row aggregate result, i.e. never. - Fix error on a link statistics response with unbound counts, which some endpoints return when aggregating over an empty solution group: treat a missing count as 0 (with `COALESCE` in the default query as well). +- Fix SPARQL syntax error in `SparqlDataProvider.lookup()` when a text search is combined with a type filter in `OwlRdfsSettings`/`OwlStatsSettings`/`DBPediaSettings` (and the `WikidataSettings` pattern): the `filterTypePattern` triples were not terminated with `.` before the text search pattern. +- Fix text search in `OwlRdfsSettings`/`OwlStatsSettings` being rejected by Virtuoso ("SQ200: index of column in order by out of range"): `?score` is bound through an expression instead of a literal constant, which Virtuoso does not accept in `ORDER BY`. ## [0.35.2] - 2026-08-08 #### 🐛 Fixed diff --git a/src/data/sparql/sparqlDataProviderSettings.ts b/src/data/sparql/sparqlDataProviderSettings.ts index 40b152de..c4be0a66 100644 --- a/src/data/sparql/sparqlDataProviderSettings.ts +++ b/src/data/sparql/sparqlDataProviderSettings.ts @@ -616,7 +616,7 @@ const WikidataSettingsOverride: Partial = { } `, filterRefElementLinkPattern: '?claim ?link .', - filterTypePattern: '?inst wdt:P31 ?instType. ?instType wdt:P279* ?class', + filterTypePattern: '?inst wdt:P31 ?instType. ?instType wdt:P279* ?class.', filterAdditionalRestriction: `FILTER ISIRI(?inst) BIND(STR(?inst) as ?strInst) FILTER exists {?inst ?someprop ?someobj} @@ -664,10 +664,13 @@ const OwlRdfsSettingsOverride: Partial = { dataLabelProperty: 'rdfs:label', fullTextSearch: { prefix: '', + // ?score is a constant as regex() gives no relevance measure, but it is + // bound through an expression on ?search1 because some endpoints + // (e.g. Virtuoso) reject ORDER BY on a variable bound to a literal constant queryPattern: `?inst \${dataLabelProperty} ?search1 FILTER regex(COALESCE(str(?search1)), "\${text}", "i") - BIND(0 as ?score) + BIND(if(bound(?search1), 0, 1) as ?score) `, extractLabel: true, }, @@ -738,7 +741,7 @@ const OwlRdfsSettingsOverride: Partial = { } `, filterRefElementLinkPattern: '', - filterTypePattern: '?inst a ?instType. ?instType rdfs:subClassOf* ?class', + filterTypePattern: '?inst a ?instType. ?instType rdfs:subClassOf* ?class.', filterElementInfoPattern: ` OPTIONAL {?inst rdf:type ?foundClass} BIND (coalesce(?foundClass, owl:Thing) as ?class) @@ -820,7 +823,7 @@ const DBPediaOverride: Partial = { } `, - filterTypePattern: '?inst a ?instType. ?instType rdfs:subClassOf* ?class', + filterTypePattern: '?inst a ?instType. ?instType rdfs:subClassOf* ?class.', filterElementInfoPattern: ` OPTIONAL {?inst rdf:type ?foundClass. FILTER (!contains(str(?foundClass), 'http://dbpedia.org/class/yago'))} BIND (coalesce(?foundClass, owl:Thing) as ?class) diff --git a/test/data/sparql/sparqlProviderBasic.test.ts b/test/data/sparql/sparqlProviderBasic.test.ts index 66656dfc..bb8cada9 100644 --- a/test/data/sparql/sparqlProviderBasic.test.ts +++ b/test/data/sparql/sparqlProviderBasic.test.ts @@ -243,6 +243,18 @@ describe('SparqlDataProvider', () => { ] satisfies DataProviderLookupItem[] ); }); + + it('provides lookup() by type and text', async () => { + const provider = await makeSparqlDataProvider( + {}, + {...OwlStatsSettings, filterOnlyLanguages: ['en']}, + ); + const items = await provider.lookup({ + elementTypeId: owl.DatatypeProperty, + text: 'ident', + }); + expect(items.map(item => item.element.id)).toEqual([org.identifier]); + }); }); function readPropertyValues( From af595af1bae62e49fc7137bd36c88f36366a9650 Mon Sep 17 00:00:00 2001 From: Ivo Velitchkov Date: Fri, 11 Sep 2026 16:32:17 +0200 Subject: [PATCH 3/3] Show the endpoint's error text for a failed entity search SparqlDataProvider errors now carry the HTTP status and the start of the response body. The instances panel renders the message under the progress bar. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 1 + src/data/sparql/sparqlDataProvider.ts | 28 +++++++++++++++++++++------ src/widgets/instancesSearch.tsx | 12 ++++++++++++ styles/widgets/_instancesSearch.scss | 10 ++++++++++ 4 files changed, 45 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c6ea020..8ee02831 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - Fix error on a link statistics response with unbound counts, which some endpoints return when aggregating over an empty solution group: treat a missing count as 0 (with `COALESCE` in the default query as well). - Fix SPARQL syntax error in `SparqlDataProvider.lookup()` when a text search is combined with a type filter in `OwlRdfsSettings`/`OwlStatsSettings`/`DBPediaSettings` (and the `WikidataSettings` pattern): the `filterTypePattern` triples were not terminated with `.` before the text search pattern. - Fix text search in `OwlRdfsSettings`/`OwlStatsSettings` being rejected by Virtuoso ("SQ200: index of column in order by out of range"): `?score` is bound through an expression instead of a literal constant, which Virtuoso does not accept in `ORDER BY`. +- Report the HTTP status and the endpoint's error text in `SparqlDataProvider` query errors, and show the message under the progress bar in the entity search panel instead of only in the browser console. ## [0.35.2] - 2026-08-08 #### 🐛 Fixed diff --git a/src/data/sparql/sparqlDataProvider.ts b/src/data/sparql/sparqlDataProvider.ts index 28a6d8bd..bc108efa 100644 --- a/src/data/sparql/sparqlDataProvider.ts +++ b/src/data/sparql/sparqlDataProvider.ts @@ -1170,9 +1170,7 @@ async function executeSparqlQuery( const sparqlResponse = await response.json() as SparqlResponse; return mapSparqlResponseIntoRdfJs(sparqlResponse, factory); } else { - const error = new Error(response.statusText); - (error as { response?: Response }).response = response; - throw error; + throw await makeResponseError(response); } } @@ -1211,12 +1209,30 @@ async function executeSparqlConstruct( const parser = new N3.Parser(); return parser.parse(turtleText); } else { - const error = new Error(response.statusText); - (error as { response?: Response }).response = response; - throw error; + throw await makeResponseError(response); } } +/** + * Makes an error for a non-OK response with the HTTP status and the beginning + * of the response body in the message, as an endpoint usually explains + * a rejected query there (e.g. a syntax or a query cost estimation error). + */ +async function makeResponseError(response: Response): Promise { + let details = ''; + try { + const body = (await response.text()).trim(); + const maxLength = 500; + details = body.length > maxLength ? body.substring(0, maxLength) + '…' : body; + } catch (e) { + /* ignore */ + } + const status = `HTTP ${response.status} ${response.statusText}`.trim(); + const error = new Error(details ? `${status}: ${details}` : status); + (error as { response?: Response }).response = response; + return error; +} + function appendQueryParams(endpoint: string, queryParams: { [key: string]: string } = {}) { const initialSeparator = endpoint.indexOf('?') < 0 ? '?' : '&'; const additionalParams = initialSeparator + Object.keys(queryParams) diff --git a/src/widgets/instancesSearch.tsx b/src/widgets/instancesSearch.tsx index f880d6d9..9fcefe58 100644 --- a/src/widgets/instancesSearch.tsx +++ b/src/widgets/instancesSearch.tsx @@ -344,6 +344,11 @@ class InstancesSearchInner extends React.Component + {this.state.error ? ( +
+ {formatError(this.state.error)} +
+ ) : null} {/* specify resultId as key to reset scroll position when loaded new search results */}