@comake/skl-js-engine
Version:
Standard Knowledge Language Javascript Engine
417 lines (394 loc) • 14.8 kB
text/typescript
/* eslint-disable multiline-comment-style */
/* eslint-disable capitalized-comments */
/* eslint-disable indent */
/* eslint-disable @typescript-eslint/naming-convention */
import DataFactory from '@rdfjs/data-model';
import type { Literal, Quad, Quad_Object, Quad_Subject, Variable } from '@rdfjs/types';
import type { ContextDefinition, GraphObject, NodeObject, ValueObject } from 'jsonld';
import * as jsonld from 'jsonld';
import type { Frame } from 'jsonld/jsonld-spec';
import { FindOperator } from '../storage/FindOperator';
import type { FindOptionsRelations, FindOptionsWhere } from '../storage/FindOptionsTypes';
import type { InverseRelationOperatorValue } from '../storage/operator/InverseRelation';
import type { JSONArray, JSONObject, OrArray } from './Types';
import { ensureArray } from './Util';
import { RDF, XSD } from './Vocabularies';
const BLANK_NODE_PREFIX = '_:';
export function toJSValueFromDataType(value: string, dataType: string): number | boolean | string {
switch (dataType) {
case XSD.int:
case XSD.positiveInteger:
case XSD.negativeInteger:
case XSD.integer: {
return Number.parseInt(value, 10);
}
case XSD.boolean: {
if (value === 'true') {
return true;
}
if (value === 'false') {
return false;
}
return value;
}
case XSD.double:
case XSD.decimal:
case XSD.float: {
return Number.parseFloat(value);
}
case RDF.JSON:
return JSON.parse(value);
default: {
return value;
}
}
}
function toJsonLdObject(object: Quad_Object): NodeObject | ValueObject {
if (object.termType === 'Literal') {
if (object.language && object.language.length > 0) {
return {
'@value': object.value,
'@language': object.language
};
}
return {
'@value': toJSValueFromDataType(object.value, object.datatype.value),
'@type': object.datatype.value === RDF.JSON ? '@json' : object.datatype.value
};
}
if (object.termType === 'BlankNode') {
return { '@id': `_:${object.value}` };
}
return { '@id': object.value };
}
function toJsonLdSubject(object: Quad_Subject): string {
if (object.termType === 'BlankNode') {
return `_:${object.value}`;
}
return object.value;
}
function relationsToFrame(relations: FindOptionsRelations): NodeObject {
return Object.entries(relations).reduce((obj: NodeObject, [ field, value ]): NodeObject => {
const fieldFrame: Frame = {
'@explicit': false
};
let contextAddition: ContextDefinition | undefined;
if (typeof value === 'object' && value.type === 'operator') {
const { resolvedName, relations: subRelations } = value.value as InverseRelationOperatorValue;
contextAddition = { [resolvedName]: { '@reverse': field }};
if (subRelations) {
fieldFrame[resolvedName] = relationsToFrame(subRelations);
} else {
fieldFrame[resolvedName] = {
"@embed": "@always"
};
}
} else if (typeof value === 'boolean') {
fieldFrame[field] = {
"@embed": "@always"
};
} else {
fieldFrame[field] = relationsToFrame(value as FindOptionsRelations);
}
if (contextAddition) {
return {
...obj,
'@context': {
...(obj['@context'] as ContextDefinition),
...contextAddition
},
...fieldFrame
};
}
return {
...obj,
...fieldFrame
};
}, {});
}
function whereToFrame(where: FindOptionsWhere): NodeObject {
if (where.id && typeof where.id === 'string') {
return { '@id': where.id };
}
if (where.id && FindOperator.isFindOperator(where.id)) {
const operator = where.id as FindOperator<any, any>;
if (operator.operator === 'in') {
return { '@id': operator.value };
}
if (operator.operator === 'equal') {
return { '@id': operator.value };
}
}
if (where.type && typeof where.type === 'string') {
return { '@type': where.type };
}
if (where.type && typeof where.type === 'object' && 'value' in where.type && typeof where.type.value === 'string') {
return { '@type': where.type.value };
}
// Handle arbitrary property constraints
/* const frame: NodeObject = {};
Object.entries(where).forEach(([ key, value ]) => {
if (key !== 'id' && key !== 'type') {
if (typeof value === 'string') {
frame[key] = { '@id': value };
} else if (FindOperator.isFindOperator(value)) {
const operator = value as FindOperator<any, any>;
if (operator.operator === 'in') {
frame[key] = { '@id': operator.value };
}
}
}
}); */
return {};
}
function triplesToNodes(triples: Quad[]): {
nodesById: Record<string, NodeObject>;
nodeIdOrder: string[];
} {
const nodeIdOrder: string[] = [];
const nodesById = triples.reduce((obj: Record<string, NodeObject>, triple): Record<string, NodeObject> => {
const subject = toJsonLdSubject(triple.subject);
const isTypePredicate = triple.predicate.value === RDF.type;
const predicate = isTypePredicate ? '@type' : triple.predicate.value;
const object = isTypePredicate ? triple.object.value : toJsonLdObject(triple.object);
if (obj[subject]) {
if (obj[subject][predicate]) {
if (Array.isArray(obj[subject][predicate])) {
(obj[subject][predicate]! as any[]).push(object);
} else {
obj[subject][predicate] = [ obj[subject][predicate]!, object ] as any;
}
} else {
obj[subject][predicate] = object;
}
} else {
obj[subject] = {
'@id': subject,
[predicate]: object
};
if (!subject.startsWith(BLANK_NODE_PREFIX)) {
nodeIdOrder.push(subject);
}
}
return obj;
}, {});
return { nodesById, nodeIdOrder };
}
async function frameWithRelationsOrNonBlankNodes(
nodesById: Record<string, NodeObject>,
frame?: Frame,
relations?: FindOptionsRelations,
where?: FindOptionsWhere,
queriedType?: string,
orderedNodeIds?: string[],
rdfTypes?: string[]
): Promise<NodeObject> {
if (!frame) {
const relationsFrame = relations ? relationsToFrame(relations) : {};
const whereFrame = where ? whereToFrame(where) : {};
frame = { ...relationsFrame, ...whereFrame };
const nodesValue = Object.values(nodesById);
/* // eslint-disable-next-line no-console
console.log('nodesValue', JSON.stringify(nodesValue, null, 2));
// eslint-disable-next-line no-console
console.log('whereFrame', JSON.stringify(whereFrame, null, 2));
// eslint-disable-next-line no-console
console.log('relationsFrame', JSON.stringify(relationsFrame, null, 2)); */
/* Add all the @types in the nodes to the frame */
const typeSet = new Set<string>();
const hasTypeConstraint = whereFrame['@type'];
const hasIriTypeConstraint = where?.id;
if (Object.keys(frame).length === 0) {
// Only add all types when there's no where constraint (no filtering)
nodesValue.forEach((node: NodeObject): void => {
const isBlankNode = node['@id']?.startsWith(BLANK_NODE_PREFIX);
if (isBlankNode) {
return;
}
if (node['@type'] && frame && typeof frame === 'object') {
ensureArray(node['@type']).forEach((type: string): void => {
typeSet.add(type);
});
}
});
frame['@type'] ??= [];
frame['@type'] = [ ...typeSet ];
} else if (hasTypeConstraint) {
// For type-based queries, include the constraint type and types from entities that matched the WHERE clause
const constraintType = hasTypeConstraint as string;
// Always include the queried/constraint type
typeSet.add(constraintType);
if (rdfTypes && Array.isArray(rdfTypes)) {
rdfTypes.forEach((rdfType: string): void => {
typeSet.add(rdfType);
});
}
// If we have orderedNodeIds (entities that matched the WHERE clause), only include types from those
// This prevents over-matching related entities while still supporting subclass matching
if (orderedNodeIds && orderedNodeIds.length > 0) {
const orderedNodeIdSet = new Set(orderedNodeIds);
nodesValue.forEach((node: NodeObject): void => {
const nodeId = node['@id'];
if (!nodeId || node['@id']?.startsWith(BLANK_NODE_PREFIX)) {
return;
}
// Only include types from entities that matched the WHERE clause (in orderedNodeIds)
if (orderedNodeIdSet.has(nodeId) && node['@type'] && frame && typeof frame === 'object') {
const nodeTypes = ensureArray(node['@type']);
nodeTypes.forEach((type: string | undefined): void => {
if (type) {
typeSet.add(type);
}
});
}
});
} else {
// Fallback: if no orderedNodeIds, only add types from entities with the constraint type
nodesValue.forEach((node: NodeObject): void => {
const isBlankNode = node['@id']?.startsWith(BLANK_NODE_PREFIX);
if (isBlankNode) {
return;
}
if (node['@type'] && frame && typeof frame === 'object') {
const nodeTypes = ensureArray(node['@type']);
if (nodeTypes.includes(constraintType)) {
nodeTypes.forEach((type: string): void => {
typeSet.add(type);
});
}
}
});
}
frame['@type'] ??= [];
frame['@type'] = [ ...typeSet ];
// When we have orderedNodeIds, restrict root-level results to only those IDs
// This prevents related entities with the same type from appearing at the root level
// Only apply this constraint if we have non-blank node IDs, since blank nodes
// will be filtered out anyway and we need them during framing
if (orderedNodeIds && orderedNodeIds.length > 0) {
const nonBlankNodeIds = orderedNodeIds.filter((id: string): boolean => !id.startsWith('_:'));
if (nonBlankNodeIds.length > 0) {
frame['@id'] = nonBlankNodeIds as any;
}
}
}
else if (hasIriTypeConstraint && !frame['@id']) {
frame['@id'] = Object.keys(nodesById).map((nodeId: string): string | undefined => {
if (!nodeId.startsWith(BLANK_NODE_PREFIX)) {
return nodeId;
}
return undefined;
}).filter(Boolean) as any;
}
if (Object.keys(frame).length > 0) {
const results = await jsonld.frame({ '@graph': nodesValue }, frame);
if (Array.isArray(results['@graph'])) {
results['@graph'] = results['@graph'].filter(
(node: NodeObject): boolean => !node['@id']?.startsWith(BLANK_NODE_PREFIX)
);
}
if (typeof frame === 'object' && '@context' in frame && Object.keys(frame['@context']!).length > 0) {
let resultsList;
if (Array.isArray(results)) {
resultsList = results;
} else if ('@graph' in results) {
resultsList = ensureArray(results['@graph']);
} else {
const { '@context': unusedContext, ...entityResult } = results;
resultsList = [ entityResult ];
}
/* return {
'@graph': resultsList.filter((result): boolean =>
Object.keys((frame as NodeObject)['@context']!).some((relationField): boolean => relationField in result))
}; */
return resultsList as NodeObject;
}
return results;
}
const nonBlankNodes = Object.keys(nodesById).filter(
(nodeId: string): boolean => !nodeId.startsWith(BLANK_NODE_PREFIX)
);
return await jsonld.frame({ '@graph': Object.values(nodesById) }, { '@id': nonBlankNodes as any });
}
return await jsonld.frame({ '@graph': Object.values(nodesById) }, frame);
}
function sortNodesByOrder(nodes: NodeObject[], nodeIdOrder: string[]): NodeObject[] {
return nodes.sort(
(aNode: NodeObject, bNode: NodeObject): number =>
nodeIdOrder.indexOf(aNode['@id']!) - nodeIdOrder.indexOf(bNode['@id']!)
);
}
function sortGraphOfNodeObject(graphObject: GraphObject, nodeIdOrder: string[]): GraphObject {
return {
...graphObject,
'@graph': sortNodesByOrder(graphObject['@graph'] as NodeObject[], nodeIdOrder)
};
}
export async function triplesToJsonld(
triples: Quad[],
skipFraming?: boolean,
relations?: FindOptionsRelations,
where?: FindOptionsWhere,
orderedNodeIds?: string[],
rdfTypes?: string[]
): Promise<OrArray<NodeObject>> {
if (triples.length === 0) {
return [];
}
const { nodeIdOrder, nodesById } = triplesToNodes(triples);
if (skipFraming) {
return Object.values(nodesById);
}
// Extract the queried type from where clause for subclass handling
const queriedType = where?.type && typeof where.type === 'string' ? where.type : undefined;
const framed = await frameWithRelationsOrNonBlankNodes(
nodesById, undefined, relations, where, queriedType, orderedNodeIds, rdfTypes
);
if ('@graph' in framed) {
return sortNodesByOrder(framed['@graph'] as NodeObject[], orderedNodeIds ?? nodeIdOrder);
}
return framed;
}
export async function triplesToJsonldWithFrame(triples: Quad[], frame?: Frame): Promise<GraphObject> {
const { nodeIdOrder, nodesById } = triplesToNodes(triples);
const framed = await frameWithRelationsOrNonBlankNodes(nodesById, frame);
if ('@graph' in framed) {
return sortGraphOfNodeObject(framed as GraphObject, nodeIdOrder);
}
const { '@context': context, ...framedWithoutContext } = framed;
const graphObject: GraphObject = {
'@graph': [ framedWithoutContext as NodeObject ]
};
if (context) {
graphObject['@context'] = context;
}
return graphObject;
}
export function valueToLiteral(
value: string | boolean | number | Date | JSONObject | JSONArray,
datatype?: string
): Literal | Variable {
if (datatype) {
if (datatype === '@json' || datatype === RDF.JSON) {
return DataFactory.literal(JSON.stringify(value), RDF.JSON);
}
return DataFactory.literal((value as string | boolean | number).toString(), datatype);
}
if (typeof value === 'number') {
if (Number.isInteger(value)) {
return DataFactory.literal(value.toString(), XSD.integer);
}
return DataFactory.literal(value.toString(), XSD.decimal);
}
if (typeof value === 'boolean') {
return DataFactory.literal(value.toString(), XSD.boolean);
}
if (typeof value === 'string' && value.startsWith('?') && value.length > 1) {
return DataFactory.variable(value.slice(1));
}
if (value instanceof Date) {
return DataFactory.literal(value.toISOString(), XSD.dateTime);
}
// eslint-disable-next-line @typescript-eslint/no-base-to-string
return DataFactory.literal(value.toString());
}