UNPKG

@comake/skl-js-engine

Version:

Standard Knowledge Language Javascript Engine

417 lines (394 loc) 14.8 kB
/* 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()); }