diff --git a/lib/rdf/JsonLdFastPath.ts b/lib/rdf/JsonLdFastPath.ts new file mode 100644 index 0000000..0bc9d33 --- /dev/null +++ b/lib/rdf/JsonLdFastPath.ts @@ -0,0 +1,693 @@ +import type * as RDF from '@rdfjs/types'; + +// eslint-disable-next-line ts/no-require-imports +import canonicalizeJsonModule = require('canonicalize'); +import type { JsonLdContextNormalized } from 'jsonld-context-parser'; +import { ContextParser, Util as ContextUtil } from 'jsonld-context-parser'; +import { DataFactory } from 'rdf-data-factory'; +import { IRIS_RDF, IRIS_XSD, PREFIX_RDF } from './Iris'; +import { PrefetchedDocumentLoader } from './PrefetchedDocumentLoader'; +import type { RdfParserOptions } from './RdfParser'; + +const IRI_RDF_FIRST = PREFIX_RDF('first'); +const IRI_RDF_REST = PREFIX_RDF('rest'); +const IRI_RDF_NIL = PREFIX_RDF('nil'); + +// The canonicalize module is a callable CommonJS export, +// but its type declarations only describe an ES default export. +const canonicalizeJson: (input: unknown) => string | undefined = canonicalizeJsonModule; + +/** + * The term expansion options that {@link https://www.npmjs.com/package/jsonld-streaming-parser|jsonld-streaming-parser} + * applies in its default JSON-LD 1.1 processing mode. + * These MUST be mirrored here so that the fast path expands terms identically to the generic parser. + */ +const EXPAND_OPTIONS = { + allowPrefixForcing: true, + allowPrefixNonGenDelims: false, + allowVocabRelativeToBase: true, +}; + +/** + * Document-level JSON-LD keywords that the fast path does not implement. + * Any (potential) use of one of these in a document makes the document fall back to the generic parser. + * This is intentionally over-eager (e.g. a matching substring inside a string literal also triggers a fallback): + * a false positive merely costs performance, never correctness. + */ +// eslint-disable-next-line max-len +const UNSUPPORTED_DOC_PATTERN = /"@(?:graph|reverse|index|nest|included|direction|base|vocab|language|set|container|prefix|version|propagate|protected|import|annotation|none)"\s*:/u; + +/** + * Matches all occurrences of the `@context` keyword, used to detect (unsupported) non-root contexts. + */ +const CONTEXT_KEY_PATTERN = /"@context"\s*:/gu; + +/** + * Internal signal to abort fast-path conversion and fall back to the generic parser. + */ +class FastPathBailout extends Error { + public constructor() { + super('JSON-LD fast path bailout'); + } +} + +/** + * The pre-analyzed state of a normalized JSON-LD context, reused across all documents sharing that context. + */ +interface IFastPathContextEntry { + /** + * The normalized context. + */ + context: JsonLdContextNormalized; + /** + * The raw normalized context map (the result of {@link JsonLdContextNormalized.getContextRaw}). + */ + dict: Record; + /** + * Terms whose definitions carry JSON-LD features the fast path cannot honour + * (property-/type-scoped contexts, reverse properties, non-list containers, ...). + * Any document using one of these terms as a key or `@type` value falls back to the generic parser. + */ + unsafeTerms: Set; + /** + * Memoized vocab-mode term-to-IRI expansions (`expandTerm(term, true)`). + * `null` means the term expands to nothing (dropped); + * `false` means expansion errored (the generic parser reproduces the error). + */ + vocabIris: Map; + /** + * Memoized base-mode term-to-IRI expansions (`expandTerm(term, false)`). + */ + baseIris: Map; + /** + * Memoized `@type`-value expansions (vocab-mode with base-mode fallback). + */ + typeIris: Map; +} + +/** + * Normalized-context caches, keyed (weakly) on the prefetched-contexts record that a + * ComponentsManager threads through all of its parse invocations. + * Entries are promises to deduplicate concurrent normalizations of the same context. + */ +const contextCaches = + new WeakMap, Map>>(); + +/** + * A monotonic counter to give every converted document a distinct blank node label namespace. + */ +let documentCounter = 0; + +/** + * Check if the given parse options allow attempting the fast path at all. + * @param options RDF parser options. + */ +export function isJsonLdFastPathCandidate(options: RdfParserOptions): boolean { + if (options.disableJsonLdFastPath) { + return false; + } + if (!options.contexts) { + return false; + } + const contentType: string | undefined = ( options).contentType; + if (contentType) { + return contentType === 'application/ld+json' || contentType === 'application/json'; + } + return options.path.endsWith('.jsonld') || options.path.endsWith('.json'); +} + +/** + * Attempt to convert the given JSON-LD document text into quads via a specialized, + * synchronous fast path for the fixed-shape, known-context documents + * that componentsjs itself generates and consumes. + * + * Motivation: a Components.js-based application load parses hundreds of small JSON-LD documents + * that all share a handful of well-known contexts and a rigidly regular structure. + * The generic streaming pipeline pays per-value async handler dispatch and per-document parser + * construction for flexibility these documents never use. + * This fast path normalizes each distinct context ONCE (with the real, spec-compliant + * {@link ContextParser}), and then converts documents with a plain synchronous recursive walk. + * + * Correctness contract: this is NOT a general JSON-LD processor. + * This function inspects each document (and its context) and returns `undefined` + * whenever the document could use any feature outside the implemented subset, + * in which case the caller MUST fall back to the generic parser. + * Term-to-IRI expansion is delegated to (memoized) jsonld-context-parser `expandTerm` calls, + * so IRI semantics are identical to the generic parser by construction. + * + * The implemented subset: root-level `@context` (URLs resolvable from the prefetched contexts), + * `@id`, `@type`, `@value` (with `@type`, including `@json`), `@list` (and `@container: @list` terms), + * `@type: @id` term coercion, datatype coercion, compact IRIs, blank nodes, numbers, booleans and null. + * Documents (potentially) using anything else — `@graph`, `@reverse`, `@language`, `@index`, `@nest`, + * `@base`, `@vocab`, nested contexts, scoped contexts, relative IRIs, ... — fall back. + * @param text The full document text. + * @param options RDF parser options. + * @returns The document's quads, or `undefined` if the document is outside the supported + * subset and MUST be parsed with the generic parser instead. + */ +export async function tryParseJsonLdFastPath(text: string, options: RdfParserOptions): Promise { + // Reject documents that may use unsupported keywords, or that have non-root contexts. + if (UNSUPPORTED_DOC_PATTERN.test(text) || (text.match(CONTEXT_KEY_PATTERN) ?? []).length > 1) { + return; + } + + // Parse the JSON; syntax errors are reported by the generic parser for identical error behaviour. + let document: any; + try { + document = JSON.parse(text); + } catch { + return; + } + if (!document || typeof document !== 'object' || Array.isArray(document)) { + return; + } + + const entry = await getContextEntry(document['@context'], options); + if (!entry) { + return; + } + + try { + return new FastPathConverter(entry, documentCounter++).convertDocument(document); + } catch { + // A bailout: the document goes outside the supported subset somewhere, + // so it must be (re-)parsed with the generic parser. + + } +} + +/** + * Obtain (or build and cache) the analyzed context entry for the given root `@context` value. + * @param contextValue The document's root `@context` value. + * @param options RDF parser options. + * @returns The analyzed context entry, + * or `undefined` if the context is outside the supported subset. + */ +async function getContextEntry( + contextValue: any, + options: RdfParserOptions, +): Promise { + // Only string contexts (single or array) are supported, + // as the cache is keyed on their URLs. + const urls: string[] = typeof contextValue === 'string' ? [ contextValue ] : contextValue; + if (!Array.isArray(urls) || urls.length === 0 || urls.some(url => typeof url !== 'string')) { + return; + } + + let cache = contextCaches.get(options.contexts!); + if (!cache) { + cache = new Map(); + contextCaches.set(options.contexts!, cache); + } + const key = urls.join('\n'); + let entryPromise = cache.get(key); + if (!entryPromise) { + entryPromise = buildContextEntry(contextValue, options); + cache.set(key, entryPromise); + } + return entryPromise; +} + +/** + * Normalize and analyze a context. + * @param contextValue The document's root `@context` value. + * @param options RDF parser options. + */ +async function buildContextEntry( + contextValue: any, + options: RdfParserOptions, +): Promise { + let context: JsonLdContextNormalized; + try { + // Remote lookups are never allowed here; contexts requiring them fall back to + // the generic parser, which implements the configured remote lookup behaviour. + context = await new ContextParser({ + documentLoader: new PrefetchedDocumentLoader({ + contexts: options.contexts!, + remoteContextLookups: false, + }), + skipValidation: options.skipContextValidation, + }).parse(contextValue); + } catch { + return; + } + + const dict = context.getContextRaw(); + const unsafeTerms = new Set(); + for (const term of Object.keys(dict)) { + if (term.startsWith('@')) { + // Context-level keywords: only the processing-mode version marker is supported. + if (term !== '@version') { + return; + } + continue; + } + const definition = dict[term]; + if (definition === null || typeof definition === 'string') { + // Nullified terms are dropped silently by JSON-LD processors; keyword aliases change + // document structure. Both are out of subset: fall back when such a term is used. + if (definition === null || definition.startsWith('@')) { + unsafeTerms.add(term); + } + continue; + } + if ( + typeof definition !== 'object' || + // Scoped contexts, reverse properties, language/direction defaults, + // and index/nest maps are out of subset. + '@context' in definition || '@reverse' in definition || '@language' in definition || + '@direction' in definition || '@index' in definition || '@nest' in definition || + // Only @list (and the semantically transparent @set) containers are supported. + (definition['@container'] && + Object.keys(definition['@container']).some(container => container !== '@list' && container !== '@set')) || + // Type coercion: only @id, @json, and datatype IRIs are supported. + ('@type' in definition && (typeof definition['@type'] !== 'string' || + (definition['@type'].startsWith('@') && + definition['@type'] !== '@id' && definition['@type'] !== '@json'))) || + // Keyword aliasing through expanded term definitions is out of subset. + (typeof definition['@id'] === 'string' && definition['@id'].startsWith('@')) + ) { + unsafeTerms.add(term); + } + } + + return { + context, + dict, + unsafeTerms, + vocabIris: new Map(), + baseIris: new Map(), + typeIris: new Map(), + }; +} + +/** + * Converts a single guarded JSON-LD document to quads. See {@link tryParseJsonLdFastPath}. + */ +class FastPathConverter { + private readonly entry: IFastPathContextEntry; + private readonly dataFactory: DataFactory; + private readonly blankNodePrefix: string; + private readonly quads: RDF.Quad[] = []; + private blankNodeCounter = 0; + + public constructor(entry: IFastPathContextEntry, documentId: number) { + this.entry = entry; + this.dataFactory = new DataFactory(); + this.blankNodePrefix = `cjsfp-${documentId}-`; + } + + /** + * Convert the given document, throwing {@link FastPathBailout} if it is outside the supported subset. + * @param document A parsed JSON-LD document (a root node object). + */ + public convertDocument(document: any): RDF.Quad[] { + // The root context was already consumed by the context analysis. + delete document['@context']; + this.convertNode(document); + return this.quads; + } + + /** + * Emit all quads for the given node object, and return the term identifying it. + * @param node A node object. + */ + private convertNode(node: Record): RDF.NamedNode | RDF.BlankNode { + const id = node['@id']; + if (id !== undefined && typeof id !== 'string') { + throw new FastPathBailout(); + } + const subject = id === undefined ? this.blankNode() : this.termForId(id); + + for (const key of Object.keys(node)) { + if (key === '@id') { + continue; + } + if (key === '@type') { + this.emitTypes(subject, node[key]); + continue; + } + if (key.startsWith('@') || this.entry.unsafeTerms.has(key)) { + // Any other keyword (or keyword-like key), and any term whose definition is out of + // subset (e.g. a property-scoped context), aborts to the generic parser. + throw new FastPathBailout(); + } + const predicateIri = this.expandVocab(key); + if (!predicateIri || predicateIri.startsWith('@') || predicateIri.startsWith('_:') || + !ContextUtil.isValidIri(predicateIri)) { + // The generic parser drops or errors on these; it decides. + throw new FastPathBailout(); + } + const definition = this.entry.dict[key]; + this.emitProperty( + subject, + this.dataFactory.namedNode(predicateIri), + node[key], + typeof definition === 'object' ? definition : undefined, + ); + } + return subject; + } + + /** + * Emit `rdf:type` quads for the given `@type` value. + * @param subject The node's subject term. + * @param types The raw `@type` value. + */ + private emitTypes(subject: RDF.NamedNode | RDF.BlankNode, types: any): void { + for (const type of Array.isArray(types) ? types : [ types ]) { + if (typeof type !== 'string' || type.startsWith('_:') || this.entry.unsafeTerms.has(type)) { + // Blank node types and types that would activate a type-scoped context + // are handled by the generic parser. + throw new FastPathBailout(); + } + const iri = this.expandTypeIri(type); + if (iri === null || iri.startsWith('@')) { + // Mirrors the generic parser: types expanding to keywords are skipped silently. + continue; + } + if (!ContextUtil.isValidIri(iri)) { + throw new FastPathBailout(); + } + this.quads.push(this.dataFactory.quad( + subject, + this.dataFactory.namedNode(IRIS_RDF.type), + this.dataFactory.namedNode(iri), + )); + } + } + + /** + * Emit the quad(s) for a single property of a node. + * @param subject The node's subject term. + * @param predicate The property's predicate term. + * @param value The property's raw value. + * @param definition The property's term definition, if it is an expanded term definition. + */ + private emitProperty( + subject: RDF.NamedNode | RDF.BlankNode, + predicate: RDF.NamedNode, + value: any, + definition: Record | undefined, + ): void { + // JSON-typed terms capture their full raw value as a single JSON literal. + if (definition && definition['@type'] === '@json') { + this.quads.push(this.dataFactory.quad(subject, predicate, this.jsonLiteral(value))); + return; + } + if (value === null) { + // Null values are dropped. + return; + } + if (definition && definition['@container'] && definition['@container']['@list']) { + this.quads.push(this.dataFactory.quad(subject, predicate, this.listToTerm(value, definition))); + return; + } + if (Array.isArray(value)) { + for (const element of value) { + if (Array.isArray(element)) { + // Nested-array flattening is left to the generic parser. + throw new FastPathBailout(); + } + const term = this.valueToTerm(element, definition); + if (term) { + this.quads.push(this.dataFactory.quad(subject, predicate, term)); + } + } + return; + } + const term = this.valueToTerm(value, definition); + if (term) { + this.quads.push(this.dataFactory.quad(subject, predicate, term)); + } + } + + /** + * Convert a single (non-array) property value into a term, + * emitting quads for nested node objects and lists along the way. + * @param value A raw property value. + * @param definition The property's term definition, if it is an expanded term definition. + * @returns The value's term, or `undefined` if the value is dropped (JSON-LD null semantics). + */ + private valueToTerm( + value: any, + definition: Record | undefined, + ): RDF.NamedNode | RDF.BlankNode | RDF.Literal | undefined { + const coercion: string | undefined = definition ? definition['@type'] : undefined; + switch (typeof value) { + case 'string': + if (coercion === '@id') { + return this.termForId(value); + } + return coercion === undefined ? + this.dataFactory.literal(value) : + this.dataFactory.literal(value, this.dataFactory.namedNode(coercion)); + case 'number': + return this.numberToTerm(value, coercion); + case 'boolean': + return this.dataFactory.literal( + String(value), + this.dataFactory.namedNode(coercion !== undefined && coercion !== '@id' ? coercion : IRIS_XSD.boolean), + ); + default: + // Case 'object', the only other type JSON.parse can produce. + if (value === null) { + return; + } + // Note: value is never an array here; all callers handle (and pre-check) arrays. + if ('@value' in value) { + return this.valueObjectToTerm(value); + } + if ('@list' in value) { + if (Object.keys(value).length > 1) { + throw new FastPathBailout(); + } + return this.listToTerm(value['@list'], definition); + } + return this.convertNode(value); + } + } + + /** + * Convert a value object (`@value`) into a term. + * @param value A value object. + * @returns The value's term, or `undefined` if the value is dropped (JSON-LD null semantics). + */ + private valueObjectToTerm(value: Record): RDF.Literal | undefined { + const keys = Object.keys(value); + if (value['@type'] === '@json') { + // The generic (streaming) parser only reliably recognizes JSON values when the `@type` + // arrives before the `@value`, and only for scalars and flat scalar maps + // (deeper structures interact with its strict-mode key processing); + // mirror that sensitivity exactly and leave everything else to it. + if (keys.length === 2 && keys[0] === '@type' && keys[1] === '@value' && + FastPathConverter.isScalarOrFlatScalarMap(value['@value'])) { + return this.jsonLiteral(value['@value']); + } + throw new FastPathBailout(); + } + let typeIri: string | undefined; + for (const key of keys) { + if (key === '@value') { + continue; + } + if (key !== '@type') { + throw new FastPathBailout(); + } + const type = value[key]; + // The generic parser has intricate `@type`-specific behaviour here + // (keyword types): it implements those cases. + if (typeof type !== 'string' || type.startsWith('@')) { + throw new FastPathBailout(); + } + const iri = this.expandTypeIri(type); + if (!iri || !ContextUtil.isValidIri(iri)) { + throw new FastPathBailout(); + } + typeIri = iri; + } + + const rawValue = value['@value']; + switch (typeof rawValue) { + case 'string': + return typeIri === undefined ? + this.dataFactory.literal(rawValue) : + this.dataFactory.literal(rawValue, this.dataFactory.namedNode(typeIri)); + case 'number': + // The generic parser does not canonicalize explicitly-typed numeric values; + // it implements that case. + if (typeIri !== undefined) { + throw new FastPathBailout(); + } + return this.numberToTerm(rawValue, typeIri); + case 'boolean': + return this.dataFactory.literal( + String(rawValue), + this.dataFactory.namedNode(typeIri ?? IRIS_XSD.boolean), + ); + default: + // Null values are dropped; object/array values are invalid — in both cases, + // the generic parser implements the exact behaviour. + if (rawValue === null && typeIri === undefined) { + return; + } + throw new FastPathBailout(); + } + } + + /** + * Convert a JSON number into a literal, mirroring the generic parser's + * datatype selection and canonical lexical forms. + * @param value A number. + * @param coercion The term definition's `@type` value, if any. + */ + private numberToTerm(value: number, coercion: string | undefined): RDF.Literal { + if (value % 1 === 0 && value <= -1e21) { + // The generic pipeline emits a non-canonical xsd:integer lexical form for these; + // let it decide rather than replicating that behaviour. + throw new FastPathBailout(); + } + let datatype = value % 1 === 0 && value < 1e21 ? IRIS_XSD.integer : IRIS_XSD.double; + if (coercion !== undefined && coercion !== '@id') { + datatype = coercion; + } + const lexicalForm = value % 1 === 0 && datatype !== IRIS_XSD.double ? + String(value) : + value.toExponential(15).replace(/(\d)0*e\+?/u, '$1E'); + return this.dataFactory.literal(lexicalForm, this.dataFactory.namedNode(datatype)); + } + + /** + * Build an RDF list for the given raw `@list` value, and return its head term. + * @param value The raw list value. + * @param definition The property's term definition, if it is an expanded term definition. + */ + private listToTerm(value: any, definition: Record | undefined): RDF.NamedNode | RDF.BlankNode { + const elements = Array.isArray(value) ? value : [ value ]; + let head: RDF.NamedNode | RDF.BlankNode = this.dataFactory.namedNode(IRI_RDF_NIL); + for (let i = elements.length - 1; i >= 0; i--) { + if (Array.isArray(elements[i])) { + // Lists of lists are left to the generic parser. + throw new FastPathBailout(); + } + const term = this.valueToTerm(elements[i], definition); + if (!term) { + // Null list elements are dropped. + continue; + } + const cell = this.blankNode(); + this.quads.push(this.dataFactory.quad(cell, this.dataFactory.namedNode(IRI_RDF_FIRST), term)); + this.quads.push(this.dataFactory.quad(cell, this.dataFactory.namedNode(IRI_RDF_REST), head)); + head = cell; + } + return head; + } + + /** + * Check whether the given JSON value is a scalar, or an object whose values are all scalars. + * @param value Any JSON value. + */ + private static isScalarOrFlatScalarMap(value: any): boolean { + if (value === null || typeof value !== 'object') { + return true; + } + if (Array.isArray(value)) { + return false; + } + return Object.values(value).every(entry => entry === null || typeof entry !== 'object'); + } + + /** + * Create a canonical `rdf:JSON` literal for the given raw JSON value, + * using the same canonicalization as the generic parser. + * @param value Any JSON value. + */ + private jsonLiteral(value: any): RDF.Literal { + return this.dataFactory.literal(canonicalizeJson(value)!, this.dataFactory.namedNode(IRIS_RDF.JSON)); + } + + /** + * Convert an `@id` (or `@type: @id`-coerced) string into a term. + * @param value An identifier string. + */ + private termForId(value: string): RDF.NamedNode | RDF.BlankNode { + if (value.startsWith('_:')) { + return this.dataFactory.blankNode(value.slice(2)); + } + const iri = this.expandBase(value); + if (!iri || !ContextUtil.isValidIri(iri)) { + // Notably: relative IRIs, which the generic parser resolves against the base IRI. + throw new FastPathBailout(); + } + return this.dataFactory.namedNode(iri); + } + + /** + * Create a fresh blank node with a label that cannot collide with the labels of any other + * document (fast-pathed or not) parsed into the same object graph. + */ + private blankNode(): RDF.BlankNode { + return this.dataFactory.blankNode(`${this.blankNodePrefix}${this.blankNodeCounter++}`); + } + + /** + * Memoized vocab-mode term expansion, mirroring the generic parser's predicate expansion. + * @param term A term. + * @returns The expanded IRI, or `null` when expansion fails + * (in which case the generic parser implements the exact drop/error behaviour). + */ + private expandVocab(term: string): string | null { + let iri = this.entry.vocabIris.get(term); + if (iri === undefined) { + try { + iri = this.entry.context.expandTerm(term, true, EXPAND_OPTIONS); + } catch { + iri = false; + } + this.entry.vocabIris.set(term, iri); + } + if (iri === false) { + throw new FastPathBailout(); + } + return iri; + } + + /** + * Memoized base-mode term expansion, mirroring the generic parser's resource expansion. + * @param term A term. + * @returns The expanded IRI, or `null` when expansion fails. + */ + private expandBase(term: string): string | null { + let iri = this.entry.baseIris.get(term); + if (iri === undefined) { + try { + iri = this.entry.context.expandTerm(term, false, EXPAND_OPTIONS); + } catch { + iri = false; + } + this.entry.baseIris.set(term, iri); + } + if (iri === false) { + throw new FastPathBailout(); + } + return iri; + } + + /** + * Memoized `@type`-value expansion, mirroring the generic parser's + * vocab-mode-with-base-mode-fallback expansion of type IRIs. + * @param term A term. + * @returns The expanded IRI, or `null` when expansion fails. + */ + private expandTypeIri(term: string): string | null { + let iri = this.entry.typeIris.get(term); + if (iri === undefined) { + iri = this.expandVocab(term); + if (iri === term) { + iri = this.expandBase(term); + } + this.entry.typeIris.set(term, iri); + } + return iri; + } +} diff --git a/lib/rdf/RdfParser.ts b/lib/rdf/RdfParser.ts index 0ff9f92..cf7daa2 100644 --- a/lib/rdf/RdfParser.ts +++ b/lib/rdf/RdfParser.ts @@ -1,9 +1,10 @@ import { createReadStream, promises as fs } from 'node:fs'; -import type { Readable } from 'node:stream'; +import { PassThrough, Readable } from 'node:stream'; import type * as RDF from '@rdfjs/types'; import type { ParseOptions } from 'rdf-parse'; import { rdfParser } from 'rdf-parse'; import type { Logger } from 'winston'; +import { isJsonLdFastPathCandidate, tryParseJsonLdFastPath } from './JsonLdFastPath'; import { PrefetchedDocumentLoader } from './PrefetchedDocumentLoader'; import { RdfStreamIncluder } from './RdfStreamIncluder'; @@ -11,6 +12,12 @@ import { RdfStreamIncluder } from './RdfStreamIncluder'; * Parses a data stream to a triple stream. */ export class RdfParser { + /** + * The maximum number of bytes {@link RdfParser.parseViaFastPath} may buffer. + * Documents larger than this cap are streamed through the generic parser. + */ + public static fastPathBufferCap = 8 * 1_024 * 1_024; + /** * Parses the given stream into RDF quads. * @param textStream A text stream. @@ -57,11 +64,91 @@ export class RdfParser { }; // Execute parsing + const includedQuadStream = new RdfStreamIncluder(options); + if (isJsonLdFastPathCandidate(options)) { + // Buffer the document and attempt the specialized JSON-LD fast path, + // falling back to the generic parser for anything outside its subset. + this.parseViaFastPath(textStream, options, includedQuadStream); + } else { + this.parseGeneric(textStream, options, includedQuadStream); + } + return includedQuadStream; + } + + /** + * Parse the given stream with the generic parsing pipeline. + * @param textStream A text stream. + * @param options Parsing options. + * @param includedQuadStream The output stream. + */ + protected parseGeneric( + textStream: NodeJS.ReadableStream, + options: RdfParserOptions, + includedQuadStream: RdfStreamIncluder, + ): void { const quadStream = rdfParser.parse(textStream, options); - const includedQuadStream = quadStream.pipe(new RdfStreamIncluder(options)); + quadStream.pipe(includedQuadStream); quadStream.on('error', (error: Error) => includedQuadStream .emit('error', RdfParser.addPathToError(error, options.path))); - return includedQuadStream; + } + + /** + * Buffer the given JSON-LD document stream, and parse it via {@link JsonLdFastPath} when possible, + * or via the generic parsing pipeline otherwise. + * @param textStream A text stream. + * @param options Parsing options. + * @param includedQuadStream The output stream. + */ + protected parseViaFastPath( + textStream: NodeJS.ReadableStream, + options: RdfParserOptions, + includedQuadStream: RdfStreamIncluder, + ): void { + const chunks: (Buffer | string)[] = []; + let bufferedLength = 0; + let streamedThrough = false; + textStream.on('data', (chunk: Buffer | string) => { + if (streamedThrough) { + return; + } + chunks.push(chunk); + bufferedLength += chunk.length; + if (bufferedLength > RdfParser.fastPathBufferCap) { + // The document is too large to buffer; replay what was consumed and stream the rest + // directly into the generic parser. + streamedThrough = true; + const replayStream = new PassThrough(); + for (const bufferedChunk of chunks) { + replayStream.write(bufferedChunk); + } + textStream.pipe(replayStream); + this.parseGeneric(replayStream, options, includedQuadStream); + } + }); + textStream.on('error', (error: Error) => includedQuadStream + .emit('error', RdfParser.addPathToError(error, options.path))); + // eslint-disable-next-line ts/no-misused-promises + textStream.on('end', async() => { + if (streamedThrough) { + return; + } + try { + const text = Buffer + .concat(chunks.map(chunk => typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : chunk)) + .toString('utf8'); + const quads = await tryParseJsonLdFastPath(text, options); + if (quads) { + for (const quad of quads) { + includedQuadStream.write(quad); + } + includedQuadStream.end(); + } else { + this.parseGeneric(Readable.from([ text ]), options, includedQuadStream); + } + } catch (error: unknown) { + includedQuadStream.emit('error', RdfParser.addPathToError( error, options.path)); + } + }); } /** @@ -129,4 +216,9 @@ export type RdfParserOptions = ParseOptions & { * If allowed, only a warning is emitted. */ remoteContextLookups?: boolean; + /** + * If the specialized JSON-LD fast path must be disabled, + * so that all documents are parsed with the generic parsing pipeline. + */ + disableJsonLdFastPath?: boolean; }; diff --git a/package.json b/package.json index 00a464e..49222fa 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "@types/minimist": "^1.2.0", "@types/node": "^18.0.0", "@types/semver": "^7.3.4", + "canonicalize": "^1.0.8", "jsonld-context-parser": "^3.0.0", "minimist": "^1.2.0", "rdf-data-factory": "^2.0.2", diff --git a/test/unit/rdf/JsonLdFastPath-test.ts b/test/unit/rdf/JsonLdFastPath-test.ts new file mode 100644 index 0000000..b566779 --- /dev/null +++ b/test/unit/rdf/JsonLdFastPath-test.ts @@ -0,0 +1,507 @@ +import { PassThrough, Readable } from 'node:stream'; +import type * as RDF from '@rdfjs/types'; +import { JsonLdContextNormalized } from 'jsonld-context-parser'; +import { rdfParser } from 'rdf-parse'; +import * as JsonLdFastPath from '../../../lib/rdf/JsonLdFastPath'; +import type { RdfParserOptions } from '../../../lib/rdf/RdfParser'; +import { RdfParser } from '../../../lib/rdf/RdfParser'; +import 'jest-rdf'; + +const arrayifyStream = require('arrayify-stream'); +const quad = require('rdf-quad'); +const streamifyString = require('streamify-string'); + +const CTX_URL = 'http://example.org/context.jsonld'; +const VOC = 'http://example.org/voc/'; + +// A context exercising every term-definition shape the fast path classifies. +const RICH_CONTEXTS: Record = { + [CTX_URL]: { + '@context': { + ex: VOC, + name: `${VOC}name`, + typedString: { '@id': `${VOC}typedString`, '@type': `${VOC}dt` }, + ref: { '@id': `${VOC}ref`, '@type': '@id' }, + jsonVal: { '@id': `${VOC}jsonVal`, '@type': '@json' }, + myList: { '@id': `${VOC}myList`, '@container': '@list' }, + refList: { '@id': `${VOC}refList`, '@container': '@list', '@type': '@id' }, + setTerm: { '@id': `${VOC}setTerm`, '@container': '@set' }, + doubleVal: { '@id': `${VOC}doubleVal`, '@type': 'http://www.w3.org/2001/XMLSchema#double' }, + floatVal: { '@id': `${VOC}floatVal`, '@type': 'http://www.w3.org/2001/XMLSchema#float' }, + // Terms below carry features outside the fast path subset: documents using them fall back. + + Scoped: { '@id': `${VOC}Scoped`, '@context': { nick: `${VOC}nick` }}, + scopedProp: { '@id': `${VOC}scopedProp`, '@context': { nick: `${VOC}nick` }}, + rev: { '@reverse': `${VOC}rev` }, + idxMap: { '@id': `${VOC}idxMap`, '@container': '@index' }, + idxDef: { '@id': `${VOC}idxDef`, '@index': `${VOC}idx` }, + nestDef: { '@id': `${VOC}nestDef`, '@nest': '@nest' }, + langDef: { '@id': `${VOC}langDef`, '@language': 'en' }, + dirDef: { '@id': `${VOC}dirDef`, '@direction': 'ltr' }, + vocabRef: { '@id': `${VOC}vocabRef`, '@type': '@vocab' }, + arrayType: { '@id': `${VOC}arrayType`, '@type': null }, + aliasId: '@id', + aliasObj: { '@id': '@type' }, + nulled: null, + idNull: { '@id': null }, + }, + }, +}; + +function makeOptions( + disableJsonLdFastPath: boolean, + contexts: Record | undefined, + extra: Partial = {}, +): RdfParserOptions { + return { + path: 'file.jsonld', + contexts, + skipContextValidation: true, + remoteContextLookups: false, + disableJsonLdFastPath, + ...extra, + }; +} + +interface IParityResult { + tookFast: boolean; + quads?: RDF.Quad[]; + error?: Error; +} + +async function parseQuads( + docText: string, + disable: boolean, + contexts: Record | undefined, + extra: Partial = {}, +): Promise<{ quads?: RDF.Quad[]; error?: Error }> { + try { + return { + quads: await arrayifyStream(new RdfParser() + .parse(streamifyString(docText), makeOptions(disable, contexts, extra))), + }; + } catch (error: unknown) { + return { error: error }; + } +} + +/** + * Parse the document with the fast path enabled and disabled, + * expect identical outcomes (isomorphic quads, or equal errors), + * and report whether the fast path was actually taken. + */ +async function expectParity( + doc: any, + contexts: Record | undefined = RICH_CONTEXTS, + extra: Partial = {}, +): Promise { + const docText = typeof doc === 'string' ? doc : JSON.stringify(doc); + const generic = await parseQuads(docText, true, contexts, extra); + const spy = jest.spyOn(rdfParser, 'parse'); + const fast = await parseQuads(docText, false, contexts, extra); + const tookFast = spy.mock.calls.length === 0; + spy.mockRestore(); + if (generic.error) { + expect(fast.error?.message).toEqual(generic.error.message); + } else { + expect(fast.error).toBeUndefined(); + expect(fast.quads).toBeRdfIsomorphic(generic.quads!); + } + return { tookFast, quads: fast.quads, error: fast.error }; +} + +function ctxDoc(body: any): any { + return { '@context': CTX_URL, ...body }; +} + +describe('JsonLdFastPath', () => { + describe('emitting documents inside the supported subset', () => { + it.each(<[string, any][]> [ + [ 'plain string values', ctxDoc({ '@id': 'ex:s', name: 'Alice' }) ], + [ 'multiple string values', ctxDoc({ '@id': 'ex:s', name: [ 'Alice', 'Bob' ]}) ], + [ 'full-IRI and compact-IRI keys', ctxDoc({ '@id': 'ex:s', [`${VOC}p`]: 'a', 'ex:q': 'b' }) ], + [ 'a single type', ctxDoc({ '@id': 'ex:s', '@type': 'ex:T' }) ], + [ 'multiple types', ctxDoc({ '@id': 'ex:s', '@type': [ 'ex:T1', `${VOC}T2` ]}) ], + [ 'an empty type array', ctxDoc({ '@id': 'ex:s', '@type': []}) ], + [ 'a type expanding to a keyword', ctxDoc({ '@id': 'ex:s', '@type': '@id', name: 'x' }) ], + [ 'nested nodes with only a keyword-expanding type', ctxDoc({ '@id': 'ex:s', name: { '@type': '@json' }}) ], + [ 'a type with a null term definition id', ctxDoc({ '@id': 'ex:s', '@type': 'idNull', name: 'x' }) ], + [ '@id-coerced references', ctxDoc({ '@id': 'ex:s', ref: 'ex:o' }) ], + [ '@id-coerced blank node references', ctxDoc({ '@id': 'ex:s', ref: '_:b0' }) ], + [ 'a blank node subject', ctxDoc({ '@id': '_:root', name: 'x' }) ], + [ 'nested anonymous node objects', ctxDoc({ '@id': 'ex:s', ref: { name: 'inner' }}) ], + [ 'nested identified node objects', ctxDoc({ '@id': 'ex:s', ref: { '@id': 'ex:o', name: 'inner' }}) ], + [ 'empty object values', ctxDoc({ '@id': 'ex:s', ref: {}}) ], + [ 'node reference objects', ctxDoc({ '@id': 'ex:s', name: { '@id': 'ex:o' }}) ], + [ 'a root node without any quads', ctxDoc({ '@id': 'ex:s' }) ], + [ 'a root node without @id', ctxDoc({ name: 'x' }) ], + [ 'plain value objects', ctxDoc({ '@id': 'ex:s', name: { '@value': 'x' }}) ], + [ 'typed value objects', ctxDoc({ '@id': 'ex:s', name: { '@value': 'x', '@type': `${VOC}dt` }}) ], + [ 'compact-IRI-typed value objects', ctxDoc({ '@id': 'ex:s', name: { '@value': 'x', '@type': 'ex:dt' }}) ], + [ 'null value objects', ctxDoc({ '@id': 'ex:s', name: { '@value': null }, ref: 'ex:o' }) ], + [ + 'type-first JSON value objects with flat scalar maps', + ctxDoc({ '@id': 'ex:s', name: { '@type': '@json', '@value': { b: 1, a: true, c: 'x', d: null }}}), + ], + [ + 'type-first JSON value objects with scalars', + ctxDoc({ '@id': 'ex:s', name: { '@type': '@json', '@value': 1.5 }}), + ], + [ 'numeric value objects', ctxDoc({ '@id': 'ex:s', name: [{ '@value': 1 }, { '@value': 1.5 }]}) ], + [ 'boolean value objects', ctxDoc({ '@id': 'ex:s', name: [{ '@value': true }, { '@value': false, '@type': `${VOC}dt` }]}) ], + [ 'integers', ctxDoc({ '@id': 'ex:s', name: [ 0, 7, -42 ]}) ], + [ 'doubles', ctxDoc({ '@id': 'ex:s', name: [ 1.5, -0.25 ]}) ], + [ 'huge integers becoming doubles', ctxDoc({ '@id': 'ex:s', name: 1e22 }) ], + [ 'double-coerced integers', ctxDoc({ '@id': 'ex:s', doubleVal: 5 }) ], + [ 'float-coerced numbers', ctxDoc({ '@id': 'ex:s', floatVal: [ 5, 5.5 ]}) ], + [ 'datatype-coerced strings', ctxDoc({ '@id': 'ex:s', typedString: 'x' }) ], + [ 'datatype-coerced booleans', ctxDoc({ '@id': 'ex:s', typedString: true, floatVal: false }) ], + [ '@id-coerced numbers (coercion ignored)', ctxDoc({ '@id': 'ex:s', ref: 5 }) ], + [ 'booleans', ctxDoc({ '@id': 'ex:s', name: [ true, false ]}) ], + [ 'null values (dropped)', ctxDoc({ '@id': 'ex:s', name: null, ref: 'ex:o' }) ], + [ 'null in arrays (dropped)', ctxDoc({ '@id': 'ex:s', name: [ 'a', null ]}) ], + [ 'JSON-typed terms with objects', ctxDoc({ '@id': 'ex:s', jsonVal: { b: 1, a: 'x' }}) ], + [ 'JSON-typed terms with arrays', ctxDoc({ '@id': 'ex:s', jsonVal: [ 1, { a: true }]}) ], + [ 'JSON-typed terms with null', ctxDoc({ '@id': 'ex:s', jsonVal: null }) ], + [ 'JSON-typed terms with scalars', ctxDoc({ '@id': 'ex:s', jsonVal: 1.5 }) ], + [ 'list containers', ctxDoc({ '@id': 'ex:s', myList: [ 'a', 1, true ]}) ], + [ 'list containers with a single value', ctxDoc({ '@id': 'ex:s', myList: 'a' }) ], + [ 'empty list containers', ctxDoc({ '@id': 'ex:s', myList: []}) ], + [ 'list containers with nulls (dropped)', ctxDoc({ '@id': 'ex:s', myList: [ 'a', null ]}) ], + [ '@id-coerced list containers', ctxDoc({ '@id': 'ex:s', refList: [ 'ex:a', 'ex:b' ]}) ], + [ 'explicit @list objects', ctxDoc({ '@id': 'ex:s', name: { '@list': [ 'a', 'b' ]}}) ], + [ 'explicit empty @list objects', ctxDoc({ '@id': 'ex:s', name: { '@list': []}}) ], + [ 'lists of node objects', ctxDoc({ '@id': 'ex:s', myList: [{ '@id': 'ex:o', name: 'x' }]}) ], + [ 'set containers', ctxDoc({ '@id': 'ex:s', setTerm: [ 'a', 'b' ]}) ], + [ 'an array context with a single URL', { '@context': [ CTX_URL ], '@id': 'ex:s', name: 'x' }], + ])('should fast-path %s identically to the generic parser', async(label, doc) => { + const { tookFast } = await expectParity(doc); + expect(tookFast).toBe(true); + }); + + it('should emit canonical lexical forms for numbers', async() => { + const { tookFast, quads } = await expectParity(ctxDoc({ + '@id': `${VOC}s`, + doubleVal: 5, + floatVal: 5.5, + name: [ 7, 2.5, 1e22 ], + })); + expect(tookFast).toBe(true); + expect(quads).toBeRdfIsomorphic([ + quad(`${VOC}s`, `${VOC}doubleVal`, '"5.0E0"^^http://www.w3.org/2001/XMLSchema#double'), + quad(`${VOC}s`, `${VOC}floatVal`, '"5.5E0"^^http://www.w3.org/2001/XMLSchema#float'), + quad(`${VOC}s`, `${VOC}name`, '"7"^^http://www.w3.org/2001/XMLSchema#integer'), + quad(`${VOC}s`, `${VOC}name`, '"2.5E0"^^http://www.w3.org/2001/XMLSchema#double'), + quad(`${VOC}s`, `${VOC}name`, '"1.0E22"^^http://www.w3.org/2001/XMLSchema#double'), + ]); + }); + + it('should reuse cached context analyses across documents', async() => { + const parseContextSpy = jest.spyOn(JsonLdFastPath, 'tryParseJsonLdFastPath'); + expect((await expectParity(ctxDoc({ '@id': 'ex:s', name: 'a' }))).tookFast).toBe(true); + expect((await expectParity(ctxDoc({ '@id': 'ex:s', name: 'b' }))).tookFast).toBe(true); + expect(parseContextSpy).toHaveBeenCalledTimes(2); + parseContextSpy.mockRestore(); + }); + }); + + describe('falling back for documents outside the supported subset', () => { + it.each(<[string, any][]> [ + [ '@graph documents', ctxDoc({ '@graph': [{ '@id': 'ex:s', name: 'x' }]}) ], + [ '@language value objects', ctxDoc({ '@id': 'ex:s', name: { '@value': 'x', '@language': 'en' }}) ], + [ '@reverse usage', ctxDoc({ '@id': 'ex:s', '@reverse': { name: { '@id': 'ex:o' }}}) ], + [ '@index usage', ctxDoc({ '@id': 'ex:s', name: { '@value': 'x', '@index': 'i' }}) ], + [ '@set value objects', ctxDoc({ '@id': 'ex:s', name: { '@set': [ 'x' ]}}) ], + [ 'non-root contexts', ctxDoc({ '@id': 'ex:s', name: { '@context': {}, '@id': 'ex:o' }}) ], + [ 'inline object contexts', { '@context': { p: `${VOC}p` }, '@id': 'ex:s', p: 'x' }], + [ 'array contexts with inline objects', { '@context': [{ p: `${VOC}p` }], '@id': 'ex:s', p: 'x' }], + [ 'empty array contexts', { '@context': [], '@id': `${VOC}s`, [`${VOC}p`]: 'x' }], + [ 'documents without a context', { '@id': `${VOC}s`, [`${VOC}p`]: 'x' }], + [ 'root arrays', [{ '@context': CTX_URL, '@id': 'ex:s', name: 'x' }]], + [ 'non-object roots', '"just a string"' ], + [ 'null roots', 'null' ], + [ 'unsafe terms: type-scoped contexts as key', ctxDoc({ '@id': 'ex:s', Scoped: 'x' }) ], + [ 'unsafe terms: type-scoped contexts as type', ctxDoc({ '@id': 'ex:s', '@type': 'Scoped', name: 'x' }) ], + [ 'unsafe terms: property-scoped contexts', ctxDoc({ '@id': 'ex:s', scopedProp: { name: 'x' }}) ], + [ 'unsafe terms: reverse terms', ctxDoc({ '@id': 'ex:s', rev: { '@id': 'ex:o' }}) ], + [ 'unsafe terms: index containers', ctxDoc({ '@id': 'ex:s', idxMap: { i: 'x' }}) ], + [ 'unsafe terms: index definitions', ctxDoc({ '@id': 'ex:s', idxDef: 'x' }) ], + [ 'unsafe terms: nest definitions', ctxDoc({ '@id': 'ex:s', nestDef: 'x' }) ], + [ 'unsafe terms: language definitions', ctxDoc({ '@id': 'ex:s', langDef: 'x' }) ], + [ 'unsafe terms: direction definitions', ctxDoc({ '@id': 'ex:s', dirDef: 'x' }) ], + [ 'unsafe terms: @vocab coercion', ctxDoc({ '@id': 'ex:s', vocabRef: 'name' }) ], + [ 'unsafe terms: non-string type coercion', ctxDoc({ '@id': 'ex:s', arrayType: 'x' }) ], + [ 'unsafe terms: keyword aliases', ctxDoc({ aliasId: 'ex:s', name: 'x' }) ], + [ 'unsafe terms: object keyword aliases', ctxDoc({ '@id': 'ex:s', aliasObj: 'ex:T' }) ], + [ 'unsafe terms: nulled terms', ctxDoc({ '@id': 'ex:s', nulled: 'x' }) ], + [ 'unsafe terms: nulled types', ctxDoc({ '@id': 'ex:s', '@type': 'nulled' }) ], + [ 'terms dropped by a null definition id', ctxDoc({ '@id': 'ex:s', idNull: 'x', name: 'y' }) ], + [ 'unknown keywords', ctxDoc({ '@id': 'ex:s', '@fake': 'x', name: 'y' }) ], + [ 'blank node predicates', ctxDoc({ '@id': 'ex:s', '_:p': 'x', name: 'y' }) ], + [ 'non-string @id values', ctxDoc({ '@id': 42, name: 'x' }) ], + [ 'non-string @type values', ctxDoc({ '@id': 'ex:s', '@type': 42 }) ], + [ 'blank node @type values', ctxDoc({ '@id': 'ex:s', '@type': '_:T' }) ], + [ 'nested arrays', ctxDoc({ '@id': 'ex:s', name: [[ 'x' ]]}) ], + [ 'nested arrays in list containers', ctxDoc({ '@id': 'ex:s', myList: [[ 'x' ]]}) ], + [ 'nested arrays in explicit lists', ctxDoc({ '@id': 'ex:s', name: { '@list': [[ 'x' ]]}}) ], + [ '@list objects with other keys', ctxDoc({ '@id': 'ex:s', name: { '@list': [ 'x' ], 'ex:p': 'y' }}) ], + [ 'value objects with node keys', ctxDoc({ '@id': 'ex:s', name: { '@value': 'x', '@id': 'ex:o' }}) ], + [ 'value objects with non-string types', ctxDoc({ '@id': 'ex:s', name: { '@value': 'x', '@type': 42 }}) ], + [ 'value objects with keyword types', ctxDoc({ '@id': 'ex:s', name: { '@value': 'x', '@type': '@vocab' }}) ], + [ + 'value-first @json value objects', + ctxDoc({ '@id': 'ex:s', name: { '@value': { b: 1, a: [ true, null ]}, '@type': '@json' }}), + ], + [ + '@json value objects with array values', + ctxDoc({ '@id': 'ex:s', name: { '@type': '@json', '@value': [ 1, 2 ]}}), + ], + [ + '@json value objects with nested structures', + ctxDoc({ '@id': 'ex:s', name: { '@type': '@json', '@value': { out: { inn: 1 }}}}), + ], + [ + 'value objects with explicitly typed numbers', + ctxDoc({ '@id': 'ex:s', name: { '@value': 2, '@type': 'http://www.w3.org/2001/XMLSchema#double' }}), + ], + [ + 'value objects with unexpandable types', + ctxDoc({ '@id': 'ex:s', name: { '@value': 'x', '@type': 'unknownTerm' }}), + ], + [ 'value objects with object values', ctxDoc({ '@id': 'ex:s', name: { '@value': { a: 1 }}}) ], + [ 'value objects with array values', ctxDoc({ '@id': 'ex:s', name: { '@value': [ 1 ]}}) ], + [ 'typed null value objects', ctxDoc({ '@id': 'ex:s', name: { '@value': null, '@type': `${VOC}dt` }}) ], + [ 'huge negative integers', ctxDoc({ '@id': 'ex:s', name: -1e21 }) ], + [ 'relative @id values', ctxDoc({ '@id': 'relative', name: 'x' }) ], + [ 'relative @id-coerced references', ctxDoc({ '@id': 'ex:s', ref: 'relative' }) ], + [ 'unexpandable types', ctxDoc({ '@id': 'ex:s', '@type': 'unknownTerm' }) ], + [ 'unexpandable keys', ctxDoc({ '@id': 'ex:s', unknownTerm: 'x' }) ], + [ 'invalid JSON', '{"@context": "http://example.org/context.jsonld", "name": ' ], + ])('should fall back for %s with identical results', async(label, doc) => { + const { tookFast } = await expectParity(doc); + expect(tookFast).toBe(false); + }); + + it('should fall back for contexts declaring a context-level @vocab', async() => { + const contexts = { 'http://example.org/vocab.jsonld': { '@context': { '@vocab': VOC }}}; + const { tookFast } = await expectParity( + { '@context': 'http://example.org/vocab.jsonld', '@id': `${VOC}s`, p: 'x' }, + contexts, + ); + expect(tookFast).toBe(false); + }); + + it('should fast-path contexts declaring only @version', async() => { + const contexts = { 'http://example.org/versioned.jsonld': { '@context': { '@version': 1.1, p: `${VOC}p` }}}; + const { tookFast } = await expectParity( + { '@context': 'http://example.org/versioned.jsonld', '@id': `${VOC}s`, p: 'x' }, + contexts, + ); + expect(tookFast).toBe(true); + }); + + it('should fall back for unresolvable contexts', async() => { + const { tookFast } = await expectParity( + { '@context': 'http://example.org/unknown-context.jsonld', '@id': `${VOC}s`, p: 'x' }, + ); + expect(tookFast).toBe(false); + }); + + it('should fall back when vocab-mode term expansion errors', async() => { + const contexts = { 'http://example.org/expand.jsonld': { '@context': { p: `${VOC}p` }}}; + const original = JsonLdContextNormalized.prototype.expandTerm; + const spy = jest.spyOn(JsonLdContextNormalized.prototype, 'expandTerm') + .mockImplementation( function(this: any, ...args: any[]) { + if (args[0] === 'zzz') { + throw new Error(`expansion failure for ${args[0]}`); + } + return original.apply(this, args); + }); + try { + const { tookFast } = await expectParity( + { '@context': 'http://example.org/expand.jsonld', '@id': `${VOC}s`, zzz: 'x' }, + contexts, + ); + expect(tookFast).toBe(false); + } finally { + spy.mockRestore(); + } + }); + + it('should fall back when base-mode term expansion errors', async() => { + const contexts = { 'http://example.org/expand2.jsonld': { '@context': { p: `${VOC}p` }}}; + const original = JsonLdContextNormalized.prototype.expandTerm; + const spy = jest.spyOn(JsonLdContextNormalized.prototype, 'expandTerm') + .mockImplementation( function(this: any, ...args: any[]) { + if (args[0] === 'zzz2' && !args[1]) { + throw new Error(`expansion failure for ${args[0]}`); + } + return original.apply(this, args); + }); + try { + const { tookFast } = await expectParity( + { '@context': 'http://example.org/expand2.jsonld', '@id': 'zzz2', p: 'x' }, + contexts, + ); + expect(tookFast).toBe(false); + } finally { + spy.mockRestore(); + } + }); + + it('should fall back for unresolvable contexts consistently across documents', async() => { + // The (negative) analysis must also be cached and shared. + const contexts = { ...RICH_CONTEXTS }; + const doc = { '@context': 'http://example.org/unknown-context.jsonld', '@id': `${VOC}s`, p: 'x' }; + expect((await expectParity(doc, contexts)).tookFast).toBe(false); + expect((await expectParity(doc, contexts)).tookFast).toBe(false); + }); + }); + + describe('candidate detection', () => { + it('should not attempt the fast path without prefetched contexts', async() => { + const { tookFast } = await expectParity({ '@id': `${VOC}s`, [`${VOC}p`]: 'x' }, undefined); + expect(tookFast).toBe(false); + }); + + it('should not attempt the fast path when disabled explicitly', async() => { + const spy = jest.spyOn(rdfParser, 'parse'); + const quads = await arrayifyStream(new RdfParser().parse( + streamifyString(JSON.stringify(ctxDoc({ '@id': 'ex:s', name: 'x' }))), + makeOptions(true, RICH_CONTEXTS), + )); + expect(spy).toHaveBeenCalledTimes(1); + expect(quads).toHaveLength(1); + spy.mockRestore(); + }); + + it('should not attempt the fast path for non-JSON-LD paths', async() => { + const spy = jest.spyOn(rdfParser, 'parse'); + const quads = await arrayifyStream(new RdfParser().parse( + streamifyString(`<${VOC}s> <${VOC}p> <${VOC}o>.`), + makeOptions(false, RICH_CONTEXTS, { path: 'file.ttl' }), + )); + expect(spy).toHaveBeenCalledTimes(1); + expect(quads).toHaveLength(1); + spy.mockRestore(); + }); + + it('should not attempt the fast path for non-JSON-LD content types', async() => { + const spy = jest.spyOn(rdfParser, 'parse'); + const quads = await arrayifyStream(new RdfParser().parse( + streamifyString(`<${VOC}s> <${VOC}p> <${VOC}o>.`), + makeOptions(false, RICH_CONTEXTS, { contentType: 'text/turtle' }), + )); + expect(spy).toHaveBeenCalledTimes(1); + expect(quads).toHaveLength(1); + spy.mockRestore(); + }); + + it('should attempt the fast path for JSON-LD content types regardless of path', async() => { + const spy = jest.spyOn(rdfParser, 'parse'); + const quads = await arrayifyStream(new RdfParser().parse( + streamifyString(JSON.stringify(ctxDoc({ '@id': 'ex:s', name: 'x' }))), + makeOptions(false, RICH_CONTEXTS, { path: 'file.txt', contentType: 'application/ld+json' }), + )); + expect(spy).not.toHaveBeenCalled(); + expect(quads).toHaveLength(1); + spy.mockRestore(); + }); + }); + + describe('document buffering', () => { + it('should stream documents larger than the buffer cap through the generic parser', async() => { + const originalCap = RdfParser.fastPathBufferCap; + RdfParser.fastPathBufferCap = 10; + try { + const spy = jest.spyOn(rdfParser, 'parse'); + const stream = new PassThrough(); + const outputPromise = arrayifyStream(new RdfParser() + .parse(stream, makeOptions(false, RICH_CONTEXTS))); + const docText = JSON.stringify(ctxDoc({ '@id': 'ex:s', name: 'x' })); + stream.write(docText.slice(0, 20)); + stream.write(docText.slice(20)); + stream.end(); + const quads = await outputPromise; + expect(spy).toHaveBeenCalledTimes(1); + expect(quads).toBeRdfIsomorphic([ quad(`${VOC}s`, `${VOC}name`, '"x"') ]); + spy.mockRestore(); + } finally { + RdfParser.fastPathBufferCap = originalCap; + } + }); + + it('should support Buffer chunks', async() => { + const stream = new PassThrough(); + const outputPromise = arrayifyStream(new RdfParser() + .parse(stream, makeOptions(false, RICH_CONTEXTS))); + stream.write(Buffer.from(JSON.stringify(ctxDoc({ '@id': 'ex:s', name: 'x' })))); + stream.end(); + const quads = await outputPromise; + expect(quads).toBeRdfIsomorphic([ quad(`${VOC}s`, `${VOC}name`, '"x"') ]); + }); + + it('should support string chunks', async() => { + const docText = JSON.stringify(ctxDoc({ '@id': 'ex:s', name: 'x' })); + const stream = Readable.from([ docText.slice(0, 20), docText.slice(20) ]); + const quads = await arrayifyStream(new RdfParser() + .parse(stream, makeOptions(false, RICH_CONTEXTS))); + expect(quads).toBeRdfIsomorphic([ quad(`${VOC}s`, `${VOC}name`, '"x"') ]); + }); + + it('should wrap errors emitted while buffering', async() => { + const stream = new PassThrough(); + const outputPromise = arrayifyStream(new RdfParser() + .parse(stream, makeOptions(false, RICH_CONTEXTS))); + stream.emit('error', new Error('buffer failure')); + await expect(outputPromise).rejects + .toThrow('Error while parsing file "file.jsonld": buffer failure'); + }); + + it('should wrap errors thrown synchronously by the generic parser after fallback', async() => { + const spy = jest.spyOn(rdfParser, 'parse').mockImplementation(() => { + throw new Error('sync parser failure'); + }); + const outputPromise = arrayifyStream(new RdfParser().parse( + streamifyString('{"@graph": []}'), + makeOptions(false, RICH_CONTEXTS), + )); + await expect(outputPromise).rejects + .toThrow('Error while parsing file "file.jsonld": sync parser failure'); + spy.mockRestore(); + }); + }); + + describe('interaction with imports', () => { + it('should follow imports emitted by fast-pathed documents', async() => { + const importTarget = JSON.stringify(ctxDoc({ '@id': 'ex:other', name: 'imported' })); + globalThis.fetch = jest.fn(async() => ({ + body: streamifyString(importTarget), + ok: true, + headers: new Headers({ 'Content-Type': 'application/ld+json' }), + statusText: 'OK', + })); + const doc = ctxDoc({ + '@id': 'ex:s', + name: 'root', + 'http://www.w3.org/2000/01/rdf-schema#seeAlso': { '@id': 'http://example.org/imported.jsonld' }, + }); + const spy = jest.spyOn(rdfParser, 'parse'); + const quads = await arrayifyStream(new RdfParser().parse( + streamifyString(JSON.stringify(doc)), + makeOptions(false, RICH_CONTEXTS, { path: 'http://example.org/root.jsonld' }), + )); + expect(spy).not.toHaveBeenCalled(); + spy.mockRestore(); + expect(quads).toBeRdfIsomorphic([ + quad('http://example.org/voc/s', `${VOC}name`, '"root"'), + quad( + 'http://example.org/voc/s', + 'http://www.w3.org/2000/01/rdf-schema#seeAlso', + 'http://example.org/imported.jsonld', + ), + quad('http://example.org/voc/other', `${VOC}name`, '"imported"'), + ]); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index c00e171..0beb126 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2649,7 +2649,7 @@ caniuse-lite@^1.0.30001782: resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001784.tgz#bdf9733a0813ccfb5ab4d02f2127e62ee4c6b718" integrity sha512-WU346nBTklUV9YfUl60fqRbU5ZqyXlqvo1SgigE1OAXK5bFL8LL9q1K7aap3N739l4BvNqnkm3YrGHiY9sfUQw== -canonicalize@^1.0.1: +canonicalize@^1.0.1, canonicalize@^1.0.8: version "1.0.8" resolved "https://registry.yarnpkg.com/canonicalize/-/canonicalize-1.0.8.tgz#24d1f1a00ed202faafd9bf8e63352cd4450c6df1" integrity sha512-0CNTVCLZggSh7bc5VkX5WWPWO+cyZbNd07IHIsSXLia/eAq+r836hgk+8BKoEh7949Mda87VUOitx5OddVj64A==