Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion src/data/sparql/responseHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
49 changes: 37 additions & 12 deletions src/data/sparql/sparqlDataProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}) => {
Expand Down Expand Up @@ -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} }`);
}
}

Expand Down Expand Up @@ -1161,9 +1170,7 @@ async function executeSparqlQuery<Binding>(
const sparqlResponse = await response.json() as SparqlResponse<Binding>;
return mapSparqlResponseIntoRdfJs(sparqlResponse, factory);
} else {
const error = new Error(response.statusText);
(error as { response?: Response }).response = response;
throw error;
throw await makeResponseError(response);
}
}

Expand Down Expand Up @@ -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<Error> {
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)
Expand Down
23 changes: 13 additions & 10 deletions src/data/sparql/sparqlDataProviderSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -616,7 +616,7 @@ const WikidataSettingsOverride: Partial<SparqlDataProviderSettings> = {
}
`,
filterRefElementLinkPattern: '?claim <http://wikiba.se/ontology#directClaim> ?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}
Expand Down Expand Up @@ -664,10 +664,13 @@ const OwlRdfsSettingsOverride: Partial<SparqlDataProviderSettings> = {
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,
},
Expand Down Expand Up @@ -722,23 +725,23 @@ const OwlRdfsSettingsOverride: Partial<SparqlDataProviderSettings> = {
}
`,
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)
Expand Down Expand Up @@ -820,7 +823,7 @@ const DBPediaOverride: Partial<SparqlDataProviderSettings> = {
}
`,

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)
Expand Down
6 changes: 4 additions & 2 deletions src/data/sparql/sparqlModels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
12 changes: 12 additions & 0 deletions src/widgets/instancesSearch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,11 @@ class InstancesSearchInner extends React.Component<InstancesSearchInnerProps, St
<ProgressBar state={progressState}
title={t.text('search_entities.query_progress.title')}
/>
{this.state.error ? (
<div className={`${CLASS_NAME}__error`}>
{formatError(this.state.error)}
</div>
) : null}
{/* specify resultId as key to reset scroll position when loaded new search results */}
<div key={this.state.resultId}
className={`${CLASS_NAME}__rest reactodia-scrollable`}
Expand Down Expand Up @@ -672,6 +677,13 @@ function findEntityData(graph: DataGraphStructure, iri: ElementIri): ElementMode
return undefined;
}

function formatError(error: unknown): string {
if (error instanceof Error) {
return error.message || error.name;
}
return String(error);
}

export function createRequest(criteria: SearchCriteria): DataProviderLookupParams {
const {text, elementType, refElement, refElementLink, linkDirection} = criteria;
return {
Expand Down
10 changes: 10 additions & 0 deletions styles/widgets/_instancesSearch.scss
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,16 @@
}
}

&__error {
padding: 5px 10px;
color: theme.$color-danger;
font-size: smaller;
overflow-wrap: anywhere;
max-height: 8em;
overflow-y: auto;
user-select: text;
}

&__rest {
padding: 10px 10px 0 10px;
border-top: 1px solid theme.$border-color-base;
Expand Down
29 changes: 28 additions & 1 deletion test/data/sparql/sparqlProviderBasic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(
{},
Expand Down Expand Up @@ -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(
Expand Down