diff --git a/CHANGELOG.md b/CHANGELOG.md index 5510435d..72241f92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ 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.connectedLinkStats()` error on a response with unbound counts (treat a missing count as 0). +- Fix query syntax error in `SparqlDataProvider.lookup()` when a text search is combined with a type filter due to a missing ending `.` in the built-in `SparqlDataProviderSettings.filterTypePattern`. +- Fix text search in `OwlRdfsSettings`/`OwlStatsSettings` being rejected by Virtuoso ("SQ200: index of column in order by out of range") due to `?score` being bound through an expression instead of a literal constant, which Virtuoso does not accept in `ORDER BY`. + +#### ⏱ Performance +- Optimize `SparqlDataProvider` queries to avoid catastrophic query plans on some endpoints (e.g. Virtuoso): + * Use `FILTER(!isBlank(...))` instead of `FILTER(isIri(...))` for the lookup; + * Change link type statistics query for `OwlRdfsSettings`/`OwlStatsSettings` to count incoming and outgoing links via `UNION` with an outer `sum()` instead of joining two aggregate sub-queries to avoid being rejected by the Virtuoso cost estimator ("The estimated execution time ... exceeds the limit"). + +#### 💅 Polish +- Display a query error details (message) when possible below error status bar in the `InstancesSearch`. ## [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..51e4fb93 100644 --- a/src/data/sparql/sparqlDataProvider.ts +++ b/src/data/sparql/sparqlDataProvider.ts @@ -665,9 +665,11 @@ export class SparqlDataProvider implements DataProvider { const navigateElementFilterOut = this.acceptBlankNodes ? 'FILTER (IsIri(?outObject) || IsBlank(?outObject))' : 'FILTER IsIri(?outObject)'; + // Specifically use isBlank() as opposed to isIri() for filtering to avoid + // a full scan on some endpoints (e.g. Virtuoso) 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 +889,20 @@ 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 + // Specifically use isBlank() as opposed to isIri() when possible for filtering + // to avoid a full scan on some endpoints (e.g. Virtuoso) + 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 +1167,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 SparqlResponseError.fromResponse(response); } } @@ -1202,9 +1206,47 @@ 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 SparqlResponseError.fromResponse(response); + } +} + +/** + * SPARQL response error originated from an endpoint. + */ +export class SparqlResponseError extends Error { + /** + * SPARQL endpoint response with an error result. + */ + readonly response: Response; + + constructor( + message: string, + response: Response, + options?: ErrorOptions + ) { + super(message, options); + this.response = 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). + */ + static async fromResponse(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(); + return new SparqlResponseError( + details ? `${status}: ${details}` : status, + response + ); } } 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..9cbdf759 100644 --- a/src/widgets/instancesSearch.tsx +++ b/src/widgets/instancesSearch.tsx @@ -322,6 +322,7 @@ class InstancesSearchInner extends React.Component + {queryError ? ( +
+ {queryError} +
+ ) : 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(