diff --git a/CHANGELOG.md b/CHANGELOG.md index 5510435d..8ee02831 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ 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). +- 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/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..bc108efa 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} }`); } } @@ -1161,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); } } @@ -1202,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/data/sparql/sparqlDataProviderSettings.ts b/src/data/sparql/sparqlDataProviderSettings.ts index 3b03e935..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, }, @@ -722,23 +725,23 @@ 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 + } } } `, 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/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/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 */}
{ ); }); + 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( {}, @@ -228,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(