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( {},