@reactodia/workspace
Version:
Reactodia Workspace -- library for visual interaction with graphs in a form of a diagram.
1,277 lines (1,143 loc) • 46 kB
text/typescript
import * as N3 from 'n3';
import { multimapArrayAdd } from '../../coreUtils/collections';
import * as Rdf from '../rdf/rdfModel';
import { rdfs, schema } from '../rdf/vocabulary';
import {
ElementTypeModel, ElementTypeGraph, LinkTypeModel, ElementModel, LinkModel, PropertyTypeModel,
ElementIri, ElementTypeIri, LinkTypeIri, PropertyTypeIri,
} from '../model';
import {
DataProvider, DataProviderLinkCount, DataProviderLookupParams, DataProviderLookupItem,
} from '../dataProvider';
import { chunkArray, chunkUndirectedCrossProduct } from './requestChunking';
import {
MutableClassModel,
MutableLinkType,
MutablePropertyModel,
appendProperty,
collectClassInfo,
collectElementTypes,
collectLinkTypes,
collectPropertyInfo,
enrichElementsWithImages,
getClassTree,
getElementsInfo,
getLinkTypes,
getLinksInfo,
getConnectedLinkTypes,
getFilteredData,
getLinkStatistics,
triplesToElementBinding,
isDirectLink,
isDirectProperty,
} from './responseHandler';
import {
ClassBinding, ElementBinding, ElementTypeBinding, LinkBinding, PropertyBinding, FilterBinding,
LinkCountBinding, LinkTypeBinding, ConnectedLinkTypeBinding, ElementImageBinding, SparqlResponse,
mapSparqlResponseIntoRdfJs,
} from './sparqlModels';
import {
SparqlDataProviderSettings, OwlStatsSettings, LinkConfiguration, PropertyConfiguration,
} from './sparqlDataProviderSettings';
/**
* Options for {@link SparqlDataProvider}.
*
* @see {@link SparqlDataProvider}
*/
export interface SparqlDataProviderOptions {
/**
* [RDF/JS-compatible term factory](https://rdf.js.org/data-model-spec/#datafactory-interface)
* to create RDF terms.
*/
factory?: Rdf.DataFactory;
/**
* SPARQL endpoint URL to send queries to.
*/
endpointUrl: string;
/**
* Query method for SPARQL queries to use:
* - `GET` - more compatible, may have issues with large request URLs;
* - `POST` - less compatible, better on large data sets.
*
* @default "GET"
*/
queryMethod?: 'GET' | 'POST';
/**
* Custom function to send SPARQL requests.
*
* By default, a global [fetch()](https://developer.mozilla.org/en-US/docs/Web/API/fetch)
* is used with the following options:
* ```js
* {
* ...params,
* credentials: 'same-origin',
* mode: 'cors',
* cache: 'default',
* }
* ```
*/
queryFunction?: SparqlQueryFunction;
/**
* Chunk size to split each SPARQL request with multiple input IRIs into.
*
* To disable batch splitting pass `null`.
*
* **Default** is `{maxSize: 2500, unit: 'totalLength'}` when `queryMethod` is `GET`,
* otherwise `null`.
*/
chunk?: SparqlProviderChunkOptions | null;
/**
* Element property type IRIs to use to get image URLs for elements.
*
* If needed, image URL extraction can be customized via
* {@link SparqlDataProviderOptions.prepareImages prepareImages}.
*/
imagePropertyUris?: ReadonlyArray<string>;
/**
* Allows to extract/fetch image URLs externally instead of using
* {@link SparqlDataProviderOptions.imagePropertyUris imagePropertyUris} option.
*/
prepareImages?: (
elementInfo: Iterable<ElementModel>,
signal: AbortSignal | undefined
) => Promise<Map<ElementIri, string>>;
/**
* Property IRI to store prepared image URL for an entity.
*
* @default "http://schema.org/thumbnailUrl"
*/
prepareImagePredicate?: PropertyTypeIri;
/**
* Allows to extract/fetch labels separately from SPARQL query as an alternative or
* in addition to `label` output binding.
*/
prepareLabels?: (
resources: Set<string>,
signal: AbortSignal | undefined
) => Promise<Map<string, Rdf.Literal[]>>;
/**
* Property IRI to store prepared labels for an entity.
*
* @default "http://www.w3.org/2000/01/rdf-schema#label"
*/
prepareLabelPredicate?: PropertyTypeIri;
}
/**
* Custom function to send SPARQL HTTP requests.
*/
export type SparqlQueryFunction = (params: {
url: string;
body?: string;
headers: { [header: string]: string };
method: string;
signal?: AbortSignal;
}) => Promise<Response>;
/**
* Options for request chunking in {@link SparqlDataProvider}.
*
* @see {@link SparqlDataProviderOptions.chunk}
*/
export interface SparqlProviderChunkOptions {
/**
* Maximum allowed chunk size.
*
* This value must be a non-negative integer.
*
* Note that the provider may form the chunk which is larger than
* specified if it cannot be split into smaller sub-chunks.
*/
readonly maxSize: number;
/**
* Unit of measure for {@link SparqlProviderChunkOptions.maxSize maxSize}:
* - `itemCount` - count of IRIs in the chunk;
* - `totalLength` - total length of IRIs with separators in the chunk.
*/
readonly unit: 'itemCount' | 'totalLength';
}
/**
* Provides graph data by requesting it from a SPARQL endpoint.
*
* @category Data
*/
export class SparqlDataProvider implements DataProvider {
readonly factory: Rdf.DataFactory;
private readonly options: SparqlDataProviderOptions;
private readonly settings: SparqlDataProviderSettings;
private readonly queryFunction: SparqlQueryFunction;
private readonly acceptBlankNodes = false;
private readonly chunkMeasure: ChunkMeasure;
private linkByPredicate = new Map<string, LinkConfiguration[]>();
private linkById = new Map<LinkTypeIri, LinkConfiguration>();
private openWorldLinks: boolean;
private propertyByPredicate = new Map<string, PropertyConfiguration[]>();
private openWorldProperties: boolean;
private readonly labelPredicate: PropertyTypeIri;
private readonly imagePredicate: PropertyTypeIri;
constructor(
options: SparqlDataProviderOptions,
settings: SparqlDataProviderSettings = OwlStatsSettings,
) {
const {
factory = Rdf.DefaultDataFactory,
queryFunction = queryInternal,
} = options;
this.factory = factory;
this.options = options;
this.settings = settings;
this.queryFunction = queryFunction;
const {
chunk,
queryMethod = 'GET',
prepareLabelPredicate,
prepareImagePredicate,
} = this.options;
if (chunk) {
if (!Number.isSafeInteger(chunk.maxSize)) {
throw new Error(
'SparqlDataProviderOptions.chunk.maxSize should be a non-negative number'
);
}
}
if (chunk) {
this.chunkMeasure = {
maxSize: chunk.maxSize,
measure: makeChunkMeasure(chunk.unit),
};
} else if (chunk === undefined && queryMethod === 'GET') {
this.chunkMeasure = {
maxSize: 2500,
measure: makeChunkMeasure('totalLength'),
};
} else {
this.chunkMeasure = {
maxSize: Infinity,
measure: makeChunkMeasure('itemCount'),
};
}
for (const link of settings.linkConfigurations) {
this.linkById.set(link.id, link);
const predicate = isDirectLink(link) ? link.path : link.id;
multimapArrayAdd(this.linkByPredicate, predicate, link);
}
this.openWorldLinks = settings.linkConfigurations.length === 0 ||
Boolean(settings.openWorldLinks);
for (const property of settings.propertyConfigurations) {
const predicate = isDirectProperty(property) ? property.path : property.id;
multimapArrayAdd(this.propertyByPredicate, predicate, property);
}
this.openWorldProperties = settings.propertyConfigurations.length === 0 ||
Boolean(settings.openWorldProperties);
this.labelPredicate = prepareLabelPredicate ?? rdfs.label;
this.imagePredicate = prepareImagePredicate ?? schema.thumbnailUrl;
}
private async queryChunked<T extends string>(
items: readonly T[],
callback: (batch: readonly T[]) => Promise<void>
): Promise<void> {
const {maxSize, measure} = this.chunkMeasure;
const tasks: Promise<void>[] = [];
for (const chunk of chunkArray(items, measure, maxSize)) {
tasks.push(callback(chunk));
}
await Promise.all(tasks);
}
async knownElementTypes(params: {
signal?: AbortSignal;
}): Promise<ElementTypeGraph> {
const {signal} = params;
const {defaultPrefix, schemaLabelProperty, filterOnlyLanguages, classTreeQuery} = this.settings;
if (!classTreeQuery) {
return {elementTypes: [], subtypeOf: []};
}
const query = defaultPrefix + resolveTemplate(classTreeQuery, {
schemaLabelProperty,
labelLanguageFilter: formatLanguageFilter('?label', filterOnlyLanguages),
});
const result = await this.executeSparqlSelect<ClassBinding>(query, {signal});
const classTree = getClassTree(result);
if (this.options.prepareLabels) {
await attachLabels(classTree.elementTypes, this.options.prepareLabels, signal);
}
return classTree;
}
async propertyTypes(params: {
propertyIds: ReadonlyArray<PropertyTypeIri>;
signal?: AbortSignal;
}): Promise<Map<PropertyTypeIri, PropertyTypeModel>> {
const {propertyIds, signal} = params;
const {defaultPrefix, schemaLabelProperty, filterOnlyLanguages, propertyInfoQuery} = this.settings;
const properties = new Map<PropertyTypeIri, MutablePropertyModel>();
if (propertyInfoQuery) {
await this.queryChunked(propertyIds, async batch => {
const ids = batch.map(escapeIri).map(id => ` ( ${id} )`).join(' ');
const query = defaultPrefix + resolveTemplate(propertyInfoQuery, {
ids,
schemaLabelProperty,
labelLanguageFilter: formatLanguageFilter('?label', filterOnlyLanguages),
});
const response = await this.executeSparqlSelect<PropertyBinding>(query, {signal});
collectPropertyInfo(response, properties);
});
} else {
for (const id of propertyIds) {
properties.set(id, {id, label: []});
}
}
if (this.options.prepareLabels) {
await attachLabels(properties.values(), this.options.prepareLabels, signal);
}
return properties;
}
async elementTypes(params: {
classIds: ReadonlyArray<ElementTypeIri>;
signal?: AbortSignal;
}): Promise<Map<ElementTypeIri, ElementTypeModel>> {
const {classIds, signal} = params;
const {defaultPrefix, schemaLabelProperty, filterOnlyLanguages, classInfoQuery} = this.settings;
const classes = new Map<ElementTypeIri, MutableClassModel>();
if (classInfoQuery) {
await this.queryChunked(classIds, async batch => {
const ids = batch.map(escapeIri).map(id => ` ( ${id} )`).join(' ');
const query = defaultPrefix + resolveTemplate(classInfoQuery, {
ids,
schemaLabelProperty,
labelLanguageFilter: formatLanguageFilter('?label', filterOnlyLanguages),
});
const response = await this.executeSparqlSelect<ClassBinding>(query, {signal});
collectClassInfo(response, classes);
});
} else {
for (const classId of classIds) {
classes.set(classId, {id: classId, label: []});
}
}
if (this.options.prepareLabels) {
await attachLabels(classes.values(), this.options.prepareLabels, signal);
}
return classes;
}
async linkTypes(params: {
linkTypeIds: ReadonlyArray<LinkTypeIri>;
signal?: AbortSignal;
}): Promise<Map<LinkTypeIri, LinkTypeModel>> {
const {linkTypeIds, signal} = params;
const {defaultPrefix, schemaLabelProperty, filterOnlyLanguages, linkTypesInfoQuery} = this.settings;
const linkTypes = new Map<LinkTypeIri, MutableLinkType>();
if (linkTypesInfoQuery) {
await this.queryChunked(linkTypeIds, async batch => {
const ids = batch.map(escapeIri).map(id => ` ( ${id} )`).join(' ');
const query = defaultPrefix + resolveTemplate(linkTypesInfoQuery, {
ids,
schemaLabelProperty,
labelLanguageFilter: formatLanguageFilter('?label', filterOnlyLanguages),
});
const response = await this.executeSparqlSelect<LinkTypeBinding>(query, {signal});
collectLinkTypes(response, linkTypes);
});
} else {
for (const typeId of linkTypeIds) {
linkTypes.set(typeId, {id: typeId, label: []});
}
}
if (this.options.prepareLabels) {
await attachLabels(linkTypes.values(), this.options.prepareLabels, signal);
}
return linkTypes;
}
async knownLinkTypes(params: {
signal?: AbortSignal;
}): Promise<LinkTypeModel[]> {
const {signal} = params;
const {
defaultPrefix, schemaLabelProperty, filterOnlyLanguages, linkTypesQuery, linkTypesPattern,
} = this.settings;
if (!linkTypesQuery) {
return [];
}
const query = defaultPrefix + resolveTemplate(linkTypesQuery, {
linkTypesPattern,
schemaLabelProperty,
labelLanguageFilter: formatLanguageFilter('?label', filterOnlyLanguages),
});
const result = await this.executeSparqlSelect<LinkTypeBinding>(query, {signal});
const linkTypes = getLinkTypes(result);
if (this.options.prepareLabels) {
await attachLabels(linkTypes.values(), this.options.prepareLabels, signal);
}
return Array.from(linkTypes.values());
}
async elements(params: {
elementIds: ReadonlyArray<ElementIri>;
signal?: AbortSignal;
}): Promise<Map<ElementIri, ElementModel>> {
const {elementIds, signal} = params;
const triples: Rdf.Quad[] = [];
if (elementIds.length > 0) {
await this.queryChunked(elementIds, async batch => {
const ids = batch.map(escapeIri).map(id => ` (${id})`).join(' ');
const {defaultPrefix, dataLabelProperty, filterOnlyLanguages, elementInfoQuery} = this.settings;
const query = defaultPrefix + resolveTemplate(elementInfoQuery, {
ids,
dataLabelProperty,
labelLanguageFilter: formatLanguageFilter('?label', filterOnlyLanguages),
valueLanguageFilter: formatLanguageFilter('?propValue', filterOnlyLanguages),
propertyConfigurations: this.formatPropertyInfo(),
});
const response = await this.executeSparqlConstruct(query);
for (const triple of response) {
triples.push(triple);
}
});
}
const types = this.queryManyElementTypes(
this.settings.propertyConfigurations.length > 0 ? elementIds : [],
signal
);
const bindings = triplesToElementBinding(triples);
const elementModels = getElementsInfo(
bindings,
await types,
this.propertyByPredicate,
this.labelPredicate,
this.openWorldProperties
);
if (this.options.prepareLabels) {
await attachProperties(
elementModels.values(),
this.options.prepareLabels,
this.labelPredicate,
signal
);
}
if (this.options.prepareImages) {
await prepareElementImages(
elementModels,
this.options.prepareImages,
this.imagePredicate,
this.factory,
signal
);
} else if (this.options.imagePropertyUris && this.options.imagePropertyUris.length) {
await this.attachImages(elementModels, this.options.imagePropertyUris, signal);
}
return elementModels;
}
private async attachImages(
elements: Map<ElementIri, ElementModel>,
imagePropertyIris: ReadonlyArray<string>,
signal: AbortSignal | undefined
): Promise<void> {
const imageProperties = imagePropertyIris.map(escapeIri).map(id => ` ( ${id} )`).join(' ');
await this.queryChunked(Array.from(elements.keys()), async batch => {
const ids = batch.map(id => ` ( ${escapeIri(id)} )`).join(' ');
const query = this.settings.defaultPrefix + `
SELECT ?inst ?linkType ?image
WHERE {
VALUES (?inst) {${ids}}
VALUES (?linkType) {${imageProperties}}
${this.settings.imageQueryPattern}
}
`;
try {
const bindings = await this.executeSparqlSelect<ElementImageBinding>(query, {signal});
enrichElementsWithImages(bindings, elements, this.imagePredicate);
} catch (err) {
console.warn('Failed to load entity image URLs', err);
}
});
}
async links(params: {
primary: ReadonlyArray<ElementIri>;
secondary: ReadonlyArray<ElementIri>;
linkTypeIds?: ReadonlyArray<LinkTypeIri>;
signal?: AbortSignal;
}): Promise<LinkModel[]> {
const {primary, secondary, linkTypeIds, signal} = params;
const {filterOnlyLanguages, linksInfoQuery} = this.settings;
const propLanguageFilter = formatLanguageFilter('?propValue', filterOnlyLanguages);
const linkConfigurations = this.formatLinkLinks();
let bindings: Promise<ReadonlyArray<LinkBinding>>;
if (primary.length > 0 && secondary.length > 0) {
if (linksInfoQuery.includes('${ids}')) {
bindings = this.queryUndirectedLinks(
primary,
secondary,
{propLanguageFilter, linkConfigurations},
signal,
);
} else {
bindings = this.queryDirectedLinks(
primary,
secondary,
{propLanguageFilter, linkConfigurations},
signal,
);
}
} else {
bindings = Promise.resolve<LinkBinding[]>([]);
}
let elementsForTypes: Set<ElementIri> | undefined;
if (this.linkByPredicate.size > 0) {
// Optimization for common case without link configurations
elementsForTypes = new Set(primary);
for (const iri of secondary) {
elementsForTypes.add(iri);
}
}
const types = this.queryManyElementTypes(
elementsForTypes ? Array.from(elementsForTypes) : [],
signal
);
let linksInfo = getLinksInfo(
await bindings,
await types,
this.linkByPredicate,
this.openWorldLinks
);
if (linkTypeIds) {
const allowedLinkTypes = new Set(linkTypeIds);
linksInfo = linksInfo.filter(link => allowedLinkTypes.has(link.linkTypeId));
}
return linksInfo;
}
private async queryUndirectedLinks(
primary: ReadonlyArray<ElementIri>,
secondary: ReadonlyArray<ElementIri>,
queryVariables: {
propLanguageFilter: string;
linkConfigurations: string;
},
signal: AbortSignal | undefined
): Promise<LinkBinding[]> {
const {propLanguageFilter, linkConfigurations} = queryVariables;
const {defaultPrefix, linksInfoQuery} = this.settings;
const allElementIris = new Set<ElementIri>(primary);
for (const iri of secondary) {
allElementIris.add(iri);
}
const query = defaultPrefix + resolveTemplate(linksInfoQuery, {
ids: formatValueClauseContent(allElementIris),
propLanguageFilter,
linkConfigurations,
});
const response = await this.executeSparqlSelect<LinkBinding>(query, {signal});
return response.results.bindings;
}
private async queryDirectedLinks(
primary: ReadonlyArray<ElementIri>,
secondary: ReadonlyArray<ElementIri>,
queryVariables: {
propLanguageFilter: string;
linkConfigurations: string;
},
signal: AbortSignal | undefined
): Promise<LinkBinding[]> {
const {propLanguageFilter, linkConfigurations} = queryVariables;
const {defaultPrefix, linksInfoQuery} = this.settings;
const {maxSize, measure} = this.chunkMeasure;
const bindings: LinkBinding[] = [];
const tasks = Array.from(
chunkUndirectedCrossProduct(
primary,
secondary,
measure,
maxSize
),
async chunk => {
const query = defaultPrefix + resolveTemplate(linksInfoQuery, {
sourceIris: formatValueClauseContent(chunk.sources),
targetIris: formatValueClauseContent(chunk.targets),
propLanguageFilter,
linkConfigurations,
});
const result = await this.executeSparqlSelect<LinkBinding>(query, {signal});
for (const binding of result.results.bindings) {
bindings.push(binding);
}
}
);
await Promise.all(tasks);
return bindings;
}
async connectedLinkStats(params: {
elementId: ElementIri;
inexactCount?: boolean;
signal?: AbortSignal;
}): Promise<DataProviderLinkCount[]> {
const {elementId, inexactCount, signal} = params;
const {defaultPrefix, linkTypesOfQuery, linkTypesStatisticsQuery, filterTypePattern} = this.settings;
const bindDirection = /\?direction\b/.test(linkTypesOfQuery);
const elementIri = escapeIri(elementId);
const forAll = this.formatLinkUnion(
elementId, undefined, undefined, '?outObject', '?inObject', bindDirection
);
if (forAll.usePredicatePart) {
forAll.unionParts.push(
`{ ${elementIri} ?link ?outObject ${bindDirection ? 'BIND("out" AS ?direction)' : ''} }`
);
forAll.unionParts.push(
`{ ?inObject ?link ${elementIri} ${bindDirection ? 'BIND("in" AS ?direction)' : ''} }`
);
}
const query = defaultPrefix + resolveTemplate(linkTypesOfQuery, {
elementIri,
linkConfigurations: forAll.unionParts.join('\nUNION\n'),
});
const linkTypeBindings = await this.executeSparqlSelect<ConnectedLinkTypeBinding>(query, {signal});
const hasConnectedDirection = linkTypeBindings.head.vars.includes('direction');
const connectedLinkTypes = getConnectedLinkTypes(
linkTypeBindings,
this.linkByPredicate,
this.openWorldLinks
);
const navigateElementFilterOut = this.acceptBlankNodes
? 'FILTER (IsIri(?outObject) || IsBlank(?outObject))'
: 'FILTER IsIri(?outObject)';
const navigateElementFilterIn = this.acceptBlankNodes
? 'FILTER (IsIri(?inObject) || IsBlank(?inObject))'
: 'FILTER IsIri(?inObject)';
const foundLinkStats: DataProviderLinkCount[] = [];
await Promise.all(connectedLinkTypes.map(async ({linkType, hasInLink, hasOutLink}) => {
const linkConfig = this.linkById.get(linkType);
let linkConfigurationOut: string;
let linkConfigurationIn: string;
if (!linkConfig || isDirectLink(linkConfig)) {
const predicate = escapeIri(
linkConfig && isDirectLink(linkConfig) ? linkConfig.path : linkType
);
linkConfigurationOut = `${elementIri} ${predicate} ?outObject`;
linkConfigurationIn = `?inObject ${predicate} ${elementIri}`;
} else {
linkConfigurationOut = this.formatLinkPath(linkConfig.path, elementIri, '?outObject');
linkConfigurationIn = this.formatLinkPath(linkConfig.path, '?inObject', elementIri);
}
if (linkConfig && linkConfig.domain && linkConfig.domain.length > 0) {
const commaSeparatedDomains = linkConfig.domain.map(escapeIri).join(', ');
const restrictionOut = filterTypePattern.replace(/[?$]inst\b/g, elementIri);
const restrictionIn = filterTypePattern.replace(/[?$]inst\b/g, '?inObject');
linkConfigurationOut += ` { ${restrictionOut} FILTER(?class IN (${commaSeparatedDomains})) }`;
linkConfigurationIn += ` { ${restrictionIn} FILTER(?class IN (${commaSeparatedDomains})) }`;
}
if (!linkTypesStatisticsQuery || (inexactCount && hasConnectedDirection)) {
foundLinkStats.push({
id: linkType,
inCount: hasInLink ? 1 : 0,
outCount: hasOutLink ? 1 : 0,
inexact: true,
});
} else {
const statsQuery = defaultPrefix + resolveTemplate(linkTypesStatisticsQuery, {
linkId: escapeIri(linkType),
elementIri,
linkConfigurationOut,
linkConfigurationIn,
navigateElementFilterOut,
navigateElementFilterIn,
});
const bindings = await this.executeSparqlSelect<LinkCountBinding>(statsQuery, {signal});
const linkStats = getLinkStatistics(bindings);
if (linkStats) {
foundLinkStats.push(linkStats);
}
}
}));
return foundLinkStats;
}
async lookup(baseParams: DataProviderLookupParams): Promise<DataProviderLookupItem[]> {
const {signal} = baseParams;
const params: DataProviderLookupParams = {
...baseParams,
limit: baseParams.limit === undefined ? 100 : baseParams.limit,
};
// query types to match link configuration domains
const types = this.querySingleElementTypes(
params.refElementId && this.settings.linkConfigurations.length > 0
? params.refElementId : undefined,
signal
);
const filterQuery = this.createFilterQuery(params);
const bindings = await this.executeSparqlSelect<ElementBinding & FilterBinding>(filterQuery, {signal});
const linkedElements = getFilteredData(
bindings,
await types,
this.linkByPredicate,
this.labelPredicate,
this.openWorldLinks
);
if (this.options.prepareLabels) {
const models = linkedElements.map(linked => linked.element);
await attachProperties(
models,
this.options.prepareLabels,
this.labelPredicate,
signal
);
}
return linkedElements;
}
private createFilterQuery(params: DataProviderLookupParams): string {
if (!params.refElementId && params.refElementLinkId) {
throw new Error('Cannot execute refElementLink filter without refElement');
}
let outerProjection = '?inst ?class ?label';
let innerProjection = '?inst';
let refQueryPart = '';
let refQueryTypes = '';
if (params.refElementId) {
outerProjection += ' ?link ?direction';
innerProjection += ' ?link ?direction';
refQueryPart = this.createRefQueryPart({
elementId: params.refElementId,
linkId: params.refElementLinkId,
direction: params.linkDirection,
});
if (this.settings.linkConfigurations.length > 0) {
outerProjection += ' ?classAll';
refQueryTypes = this.settings.filterTypePattern.replace(/[?$]class\b/g, '?classAll');
}
}
let elementTypePart = '';
if (params.elementTypeId) {
const elementTypeIri = escapeIri(params.elementTypeId);
elementTypePart = this.settings.filterTypePattern.replace(/[?$]class\b/g, elementTypeIri);
}
const {
defaultPrefix, dataLabelProperty, filterOnlyLanguages, filterElementInfoPattern, fullTextSearch,
} = this.settings;
const elementInfoPart = resolveTemplate(filterElementInfoPattern, {
dataLabelProperty,
labelLanguageFilter: formatLanguageFilter('?label', filterOnlyLanguages),
});
let textSearchPart = '';
if (params.text) {
innerProjection += ' ?score';
if (this.settings.fullTextSearch.extractLabel) {
textSearchPart += sparqlExtractLabel('?inst', '?extractedLabel');
}
textSearchPart = resolveTemplate(fullTextSearch.queryPattern, {text: params.text, dataLabelProperty});
}
let limitPart = '';
if (typeof params.limit === 'number') {
limitPart = `LIMIT ${params.limit}`;
}
return `${defaultPrefix}
${fullTextSearch.prefix}
SELECT ${outerProjection}
WHERE {
{
SELECT DISTINCT ${innerProjection} WHERE {
${elementTypePart}
${refQueryPart}
${textSearchPart}
${this.settings.filterAdditionalRestriction}
}
${textSearchPart ? 'ORDER BY DESC(?score)' : ''}
${limitPart}
}
${refQueryTypes}
${elementInfoPart}
} ${textSearchPart ? 'ORDER BY DESC(?score)' : ''}
`;
}
/**
* Executes arbitrary SPARQL SELECT query and returns the result tuples.
*/
executeSparqlSelect<Binding>(
query: string,
options?: { signal?: AbortSignal }
): Promise<SparqlResponse<Binding>> {
const method = this.options.queryMethod ?? 'GET';
const {signal} = options ?? {};
return executeSparqlQuery<Binding>(
this.options.endpointUrl,
query,
method,
this.queryFunction,
this.factory,
signal
);
}
/**
* Executes arbitrary SPARQL CONSTRUCT query and returns the result RDF graph.
*/
executeSparqlConstruct(
query: string,
options?: { signal?: AbortSignal }
): Promise<Rdf.Quad[]> {
const method = this.options.queryMethod ?? 'GET';
const {signal} = options ?? {};
return executeSparqlConstruct(
this.options.endpointUrl,
query,
method,
this.queryFunction,
signal,
);
}
protected createRefQueryPart(params: { elementId: ElementIri; linkId?: LinkTypeIri; direction?: 'in' | 'out' }) {
const {elementId, linkId, direction} = params;
const {unionParts, usePredicatePart} = this.formatLinkUnion(
elementId, linkId, direction, '?inst', '?inst', true
);
if (usePredicatePart) {
const refElementIRI = escapeIri(params.elementId);
let refLinkType: string | undefined;
if (linkId) {
const link = this.linkById.get(linkId);
refLinkType = link && isDirectLink(link) ? escapeIri(link.path) : escapeIri(linkId);
}
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
? 'FILTER(isIri(?inst) || isBlank(?inst))'
: 'FILTER(isIri(?inst))';
if (!direction || direction === 'out') {
unionParts.push(`{ ${refElementIRI} ${linkPattern} ?inst BIND("out" as ?direction) ${bindType} ${blankFilter} }`);
}
if (!direction || direction === 'in') {
unionParts.push(`{ ?inst ${linkPattern} ${refElementIRI} BIND("in" as ?direction) ${bindType} ${blankFilter} }`);
}
}
let resultPattern = unionParts.length === 0 ? 'FILTER(false)' : unionParts.join('\nUNION\n');
const useAllLinksPattern = !linkId && this.settings.filterRefElementLinkPattern.length > 0;
if (useAllLinksPattern) {
resultPattern += `\n${this.settings.filterRefElementLinkPattern}`;
}
return resultPattern;
}
private formatLinkUnion(
refElementIri: ElementIri,
linkIri: LinkTypeIri | undefined,
direction: 'in' | 'out' | undefined,
outElementVar: string,
inElementVar: string,
bindDirection: boolean
) {
const {linkConfigurations} = this.settings;
const fixedIri = escapeIri(refElementIri);
const unionParts: string[] = [];
let hasDirectLink = false;
for (const link of linkConfigurations) {
if (linkIri && link.id !== linkIri) { continue; }
if (isDirectLink(link)) {
hasDirectLink = true;
} else {
const linkType = escapeIri(link.id);
if (!direction || direction === 'out') {
const path = this.formatLinkPath(link.path, fixedIri, outElementVar);
const boundedDirection = bindDirection ? 'BIND("out" as ?direction) ' : '';
unionParts.push(
`{ ${path} BIND(${linkType} as ?link) ${boundedDirection}}`
);
}
if (!direction || direction === 'in') {
const path = this.formatLinkPath(link.path, inElementVar, fixedIri);
const boundedDirection = bindDirection ? 'BIND("in" as ?direction) ' : '';
unionParts.push(
`{ ${path} BIND(${linkType} as ?link) ${boundedDirection}}`
);
}
}
}
const usePredicatePart = this.openWorldLinks || hasDirectLink;
return {unionParts, usePredicatePart};
}
private formatLinkLinks(): string {
const unionParts: string[] = [];
let hasDirectLink = false;
for (const link of this.settings.linkConfigurations) {
if (isDirectLink(link)) {
hasDirectLink = true;
} else {
const linkType = escapeIri(link.id);
unionParts.push(
`${this.formatLinkPath(link.path, '?source', '?target')} BIND(${linkType} as ?type)`
);
}
}
const usePredicatePart = this.openWorldLinks || hasDirectLink;
if (usePredicatePart) {
unionParts.push('?source ?type ?target');
}
return (
unionParts.length === 0 ? '' :
unionParts.length === 1 ? unionParts[0] :
'{ ' + unionParts.join(' }\nUNION\n{ ') + ' }'
);
}
private formatLinkPath(path: string, source: string, target: string): string {
return path.replace(/[?$]source\b/g, source).replace(/[?$]target\b/g, target);
}
private formatPropertyInfo(): string {
const unionParts: string[] = [];
let hasDirectProperty = false;
for (const property of this.settings.propertyConfigurations) {
if (isDirectProperty(property)) {
hasDirectProperty = true;
} else {
const propType = escapeIri(property.id);
const formatted = this.formatPropertyPath(property.path, '?inst', '?propValue');
unionParts.push(
`{ ${formatted} BIND(${propType} as ?propType) }`
);
}
}
const usePredicatePart = this.openWorldProperties || hasDirectProperty;
if (usePredicatePart) {
unionParts.push('{ ?inst ?propType ?propValue }');
}
return unionParts.join('\nUNION\n');
}
private formatPropertyPath(path: string, subject: string, value: string): string {
return path.replace(/[?$]inst\b/g, subject).replace(/[?$]value\b/g, value);
}
private async querySingleElementTypes(
element: ElementIri | undefined,
signal: AbortSignal | undefined
): Promise<Set<ElementTypeIri> | undefined> {
if (!element) {
return undefined;
}
const types = await this.queryManyElementTypes([element], signal);
return types.get(element);
}
private async queryManyElementTypes(
elements: ReadonlyArray<ElementIri>,
signal: AbortSignal | undefined
): Promise<Map<ElementIri, Set<ElementTypeIri>>> {
if (elements.length === 0) {
return new Map();
}
const {filterTypePattern} = this.settings;
const elementTypes = new Map<ElementIri, Set<ElementTypeIri>>();
await this.queryChunked(elements, async batch => {
const ids = batch.map(iri => `(${escapeIri(iri)})`).join(' ');
const queryTemplate = 'SELECT ?inst ?class { VALUES(?inst) { ${ids} } ${filterTypePattern} }';
const query = resolveTemplate(queryTemplate, {ids, filterTypePattern});
const response = await this.executeSparqlSelect<ElementTypeBinding>(query, {signal});
collectElementTypes(response, elementTypes);
});
return elementTypes;
}
}
interface ChunkMeasure {
readonly measure: (item: string) => number;
readonly maxSize: number;
}
function makeChunkMeasure(unit: SparqlProviderChunkOptions['unit']): ChunkMeasure['measure'] {
switch (unit) {
case 'totalLength': {
// IRI is formatted as (IRI) and joined with a space,
// which gives 5 additional characters per IRI
return item => escapeIri(item).length + 3;
}
default: {
return () => 1;
}
}
}
interface LabeledItem {
id: string;
label: ReadonlyArray<Rdf.Literal>;
}
async function attachLabels(
items: Iterable<LabeledItem>,
fetchLabels: NonNullable<SparqlDataProviderOptions['prepareLabels']>,
signal: AbortSignal | undefined
): Promise<void> {
const resources = new Set<string>(Array.from(items, item => item.id));
const labels = await fetchLabels(resources, signal);
for (const item of items) {
const itemLabels = labels.get(item.id);
if (itemLabels) {
item.label = itemLabels;
}
}
}
type MutableProperties = Record<PropertyTypeIri, Array<Rdf.NamedNode | Rdf.Literal>>;
async function attachProperties(
items: Iterable<ElementModel>,
fetchProperties: NonNullable<SparqlDataProviderOptions['prepareLabels']>,
propertyIri: PropertyTypeIri,
signal: AbortSignal | undefined
) {
const resources = new Set<string>(Array.from(items, item => item.id));
const properties = await fetchProperties(resources, signal);
for (const item of items) {
const itemValues = properties.get(item.id);
if (itemValues) {
for (const value of itemValues) {
appendProperty(item.properties as MutableProperties, propertyIri, value);
}
}
}
}
function prepareElementImages(
elements: Map<ElementIri, ElementModel>,
fetchImages: NonNullable<SparqlDataProviderOptions['prepareImages']>,
imagePropertyIri: PropertyTypeIri,
factory: Rdf.DataFactory,
signal: AbortSignal | undefined
): Promise<void> {
return fetchImages(elements.values(), signal).then(images => {
for (const [iri, image] of images) {
const entity = elements.get(iri);
if (entity) {
appendProperty(
entity.properties as MutableProperties,
imagePropertyIri,
factory.namedNode(image)
);
}
}
});
}
function resolveTemplate(template: string, values: { [key: string]: string | undefined }) {
let result = template;
for (const replaceKey in values) {
if (!Object.prototype.hasOwnProperty.call(values, replaceKey)) { continue; }
const replaceValue = values[replaceKey] || '';
result = result.replace(new RegExp('\\${' + replaceKey + '}', 'g'), replaceValue);
}
return result;
}
async function executeSparqlQuery<Binding>(
endpoint: string,
query: string,
method: 'GET' | 'POST',
queryFunction: SparqlQueryFunction,
factory: Rdf.DataFactory,
signal: AbortSignal | undefined
): Promise<SparqlResponse<Binding>> {
let internalQuery: Promise<Response>;
if (method === 'GET') {
internalQuery = queryFunction({
url: appendQueryParams(endpoint, {query}),
headers: {
'Accept': 'application/sparql-results+json',
},
method: 'GET',
signal,
});
} else {
internalQuery = queryFunction({
url: endpoint,
body: query,
headers: {
'Accept': 'application/sparql-results+json',
'Content-Type': 'application/sparql-query; charset=UTF-8',
},
method: 'POST',
signal,
});
}
const response = await internalQuery;
if (response.ok) {
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;
}
}
async function executeSparqlConstruct(
endpoint: string,
query: string,
method: 'GET' | 'POST',
queryFunction: SparqlQueryFunction,
signal: AbortSignal | undefined
): Promise<Rdf.Quad[]> {
let internalQuery: Promise<Response>;
if (method === 'GET') {
internalQuery = queryFunction({
url: appendQueryParams(endpoint, {query}),
headers: {
'Accept': 'text/turtle',
},
method: 'GET',
signal,
});
} else {
internalQuery = queryFunction({
url: endpoint,
body: query,
headers: {
'Accept': 'text/turtle',
'Content-Type': 'application/sparql-query; charset=UTF-8',
},
method: 'POST',
signal,
});
}
const response = await internalQuery;
if (response.ok) {
const turtleText = await response.text();
const parser = new N3.Parser();
return parser.parse(turtleText);
} else {
const error = new Error(response.statusText);
(error as { response?: Response }).response = response;
throw error;
}
}
function appendQueryParams(endpoint: string, queryParams: { [key: string]: string } = {}) {
const initialSeparator = endpoint.indexOf('?') < 0 ? '?' : '&';
const additionalParams = initialSeparator + Object.keys(queryParams)
.map(key => `${key}=${encodeURIComponent(queryParams[key])}`)
.join('&');
return endpoint + additionalParams;
}
function queryInternal(params: {
url: string;
body?: string;
headers: { [header: string]: string };
method: string;
signal?: AbortSignal;
}) {
return fetch(params.url, {
method: params.method,
body: params.body,
credentials: 'same-origin',
mode: 'cors',
cache: 'default',
headers: params.headers,
signal: params.signal,
});
}
function formatLanguageFilter(
variable: string,
languages: ReadonlyArray<string> | undefined
): string {
if (!languages) {
return '';
}
const parts = [`FILTER(!isLiteral(${variable}) || lang(${variable}) = ""`];
for (const language of languages) {
parts.push(` || lang(${variable}) = "${language}"`);
}
parts.push(')');
return parts.join('');
}
function sparqlExtractLabel(subject: string, label: string): string {
return `
BIND ( str( ${subject} ) as ?uriStr)
BIND ( strafter(?uriStr, "#") as ?label3)
BIND ( strafter(strafter(?uriStr, "//"), "/") as ?label6)
BIND ( strafter(?label6, "/") as ?label5)
BIND ( strafter(?label5, "/") as ?label4)
BIND (if (?label3 != "", ?label3,
if (?label4 != "", ?label4,
if (?label5 != "", ?label5, ?label6))) as ${label})
`;
}
function formatValueClauseContent(iris: Iterable<string>): string {
return Array.from(iris, iri => `(${escapeIri(iri)})`).join(' ');
}
function escapeIri(iri: string) {
if (typeof iri !== 'string') {
throw new Error(`Cannot escape IRI of type "${typeof iri}"`);
}
return `<${iri}>`;
}