Skip to content
Merged
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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
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
66 changes: 54 additions & 12 deletions src/data/sparql/sparqlDataProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}) => {
Expand Down Expand Up @@ -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} }`);
}
}

Expand Down Expand Up @@ -1161,9 +1167,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 SparqlResponseError.fromResponse(response);
}
}

Expand Down Expand Up @@ -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<SparqlResponseError> {
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
);
}
}

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
13 changes: 13 additions & 0 deletions src/widgets/instancesSearch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,7 @@ class InstancesSearchInner extends React.Component<InstancesSearchInnerProps, St

const resultItems = this.state.items ?? [];
const actionsAreHidden = this.state.querying || this.state.selection.size === 0;
const queryError = this.state.error ? tryFormatQueryError(this.state.error) : undefined;

return <div
className={cx(
Expand All @@ -344,6 +345,11 @@ class InstancesSearchInner extends React.Component<InstancesSearchInnerProps, St
<ProgressBar state={progressState}
title={t.text('search_entities.query_progress.title')}
/>
{queryError ? (
<div className={`${CLASS_NAME}__error`}>
{queryError}
</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 +678,13 @@ function findEntityData(graph: DataGraphStructure, iri: ElementIri): ElementMode
return undefined;
}

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

export function createRequest(criteria: SearchCriteria): DataProviderLookupParams {
const {text, elementType, refElement, refElementLink, linkDirection} = criteria;
return {
Expand Down
5 changes: 4 additions & 1 deletion src/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,10 @@ export * from './data/rdf/rdfDataProvider';
*/
export * as Rdf from './data/rdf/rdfModel';
export * from './data/rdf/vocabulary';
export * from './data/sparql/sparqlDataProvider';
export {
SparqlDataProvider, type SparqlDataProviderOptions, type SparqlProviderChunkOptions,
type SparqlQueryFunction, SparqlResponseError,
} from './data/sparql/sparqlDataProvider';
export * from './data/sparql/sparqlDataProviderSettings';

export {
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