Skip to content

perf: remove unnecessary cloning and iteration #73

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 18 commits into from
Nov 7, 2023
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 100 additions & 65 deletions lib/ContextParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,21 @@ import {IJsonLdContext, IJsonLdContextNormalizedRaw, IPrefixValue, JsonLdContext
import {JsonLdContextNormalized, defaultExpandOptions, IExpandOptions} from "./JsonLdContextNormalized";
import {Util} from "./Util";

// tslint:disable-next-line:no-var-requires
const canonicalizeJson = require('canonicalize');
const deepEqual = (object1: any, object2: any): boolean => {
const objKeys1 = Object.keys(object1);
const objKeys2 = Object.keys(object2);

if (objKeys1.length !== objKeys2.length) return false;
return objKeys1.every((key) => {
const value1 = object1[key];
const value2 = object2[key];
return (value1 === value2) || (isObject(value1) && isObject(value2) && deepEqual(value1, value2));
});
};

const isObject = (object: any) => {
return object != null && typeof object === "object";
};

/**
* Parses JSON-LD contexts.
Expand Down Expand Up @@ -93,13 +106,14 @@ export class ContextParser {
*/
public idifyReverseTerms(context: IJsonLdContextNormalizedRaw): IJsonLdContextNormalizedRaw {
for (const key of Object.keys(context)) {
const value: IPrefixValue = context[key];
let value = context[key];
if (value && typeof value === 'object') {
if (value['@reverse'] && !value['@id']) {
if (typeof value['@reverse'] !== 'string' || Util.isValidKeyword(value['@reverse'])) {
throw new ErrorCoded(`Invalid @reverse value, must be absolute IRI or blank node: '${value['@reverse']}'`,
ERROR_CODES.INVALID_IRI_MAPPING);
}
value = context[key] = {...value, '@id': value['@reverse']};
value['@id'] = <string> value['@reverse'];
if (Util.isPotentialKeyword(value['@reverse'])) {
delete value['@reverse'];
Expand All @@ -119,9 +133,14 @@ export class ContextParser {
* @param {boolean} expandContentTypeToBase If @type inside the context may be expanded
* via @base if @vocab is set to null.
*/
public expandPrefixedTerms(context: JsonLdContextNormalized, expandContentTypeToBase: boolean) {
public expandPrefixedTerms(
context: JsonLdContextNormalized,
expandContentTypeToBase: boolean,
/* istanbul ignore next */
keys = Object.keys(context.getContextRaw()
)) {
const contextRaw = context.getContextRaw();
for (const key of Object.keys(contextRaw)) {
for (const key of keys) {
// Only expand allowed keys
if (Util.EXPAND_KEYS_BLACKLIST.indexOf(key) < 0 && !Util.isReservedInternalKeyword(key)) {
// Error if we try to alias a keyword to something else.
Expand Down Expand Up @@ -162,27 +181,30 @@ Tried mapping ${key} to ${JSON.stringify(keyValue)}`, ERROR_CODES.INVALID_KEYWOR
if ('@id' in value) {
// Use @id value for expansion
if (id !== undefined && id !== null && typeof id === 'string') {
contextRaw[key]['@id'] = context.expandTerm(id, true);
contextRaw[key] = { ...contextRaw[key], '@id': context.expandTerm(id, true) };
changed = changed || id !== contextRaw[key]['@id'];
}
} else if (!Util.isPotentialKeyword(key) && canAddIdEntry) {
// Add an explicit @id value based on the expanded key value
const newId = context.expandTerm(key, true);
if (newId !== key) {
// Don't set @id if expansion failed
contextRaw[key]['@id'] = newId;
contextRaw[key] = { ...contextRaw[key], '@id': newId };
changed = true;
}
}
if (type && typeof type === 'string' && type !== '@vocab'
&& (!value['@container'] || !(<any> value['@container'])['@type'])
&& canAddIdEntry) {
// First check @vocab, then fallback to @base
contextRaw[key]['@type'] = context.expandTerm(type, true);
if (expandContentTypeToBase && type === contextRaw[key]['@type']) {
contextRaw[key]['@type'] = context.expandTerm(type, false);
let expandedType = context.expandTerm(type, true);
if (expandContentTypeToBase && type === expandedType) {
expandedType = context.expandTerm(type, false);
}
if (expandedType !== type) {
changed = true;
contextRaw[key] = { ...contextRaw[key], '@type': expandedType };
}
changed = changed || type !== contextRaw[key]['@type'];
}
}
if (!changed) {
Expand All @@ -209,7 +231,10 @@ Tried mapping ${key} to ${JSON.stringify(keyValue)}`, ERROR_CODES.INVALID_KEYWOR
const value = context[key];
if (value && typeof value === 'object') {
if (typeof value['@language'] === 'string') {
value['@language'] = value['@language'].toLowerCase();
const lowercase = value['@language'].toLowerCase();
if (lowercase !== value['@language']) {
context[key] = {...value, '@language': lowercase};
}
}
}
}
Expand All @@ -226,16 +251,17 @@ Tried mapping ${key} to ${JSON.stringify(keyValue)}`, ERROR_CODES.INVALID_KEYWOR
const value = context[key];
if (value && typeof value === 'object') {
if (typeof value['@container'] === 'string') {
value['@container'] = { [value['@container']]: true };
context[key] = { ...value, '@container': { [value['@container']]: true } };
} else if (Array.isArray(value['@container'])) {
const newValue: {[key: string]: boolean} = {};
for (const containerValue of value['@container']) {
newValue[containerValue] = true;
}
value['@container'] = newValue;
context[key] = { ...value, '@container': newValue };
}
}
}
return context;
}

/**
Expand All @@ -256,7 +282,7 @@ Tried mapping ${key} to ${JSON.stringify(keyValue)}`, ERROR_CODES.INVALID_KEYWOR
if (value && typeof value === 'object') {
if (!('@protected' in context[key])) {
// Mark terms with object values as protected if they don't have an @protected: false annotation
context[key]['@protected'] = true;
context[key] = {...context[key], '@protected': true};
}
} else {
// Convert string-based term values to object-based values with @protected: true
Expand All @@ -265,7 +291,7 @@ Tried mapping ${key} to ${JSON.stringify(keyValue)}`, ERROR_CODES.INVALID_KEYWOR
'@protected': true,
};
if (Util.isSimpleTermDefinitionPrefix(value, expandOptions)) {
context[key]['@prefix'] = true
context[key] = {...context[key], '@prefix': true};
}
}
}
Expand All @@ -283,26 +309,25 @@ Tried mapping ${key} to ${JSON.stringify(keyValue)}`, ERROR_CODES.INVALID_KEYWOR
*/
public validateKeywordRedefinitions(contextBefore: IJsonLdContextNormalizedRaw,
contextAfter: IJsonLdContextNormalizedRaw,
expandOptions: IExpandOptions) {
for (const key of Object.keys(contextAfter)) {
expandOptions: IExpandOptions,
/* istanbul ignore next */
keys = Object.keys(contextAfter)) {
for (const key of keys) {
if (Util.isTermProtected(contextBefore, key)) {
// The entry in the context before will always be in object-mode
// If the new entry is in string-mode, convert it to object-mode
// before checking if it is identical.
if (typeof contextAfter[key] === 'string') {
contextAfter[key] = { '@id': contextAfter[key] };
}

// Convert term values to strings for each comparison
const valueBefore = canonicalizeJson(contextBefore[key]);
contextAfter[key] = { '@id': contextAfter[key], '@protected': true };
} else {
// We modify this deliberately,
// as we need it for the value comparison (they must be identical modulo '@protected')),
// and for the fact that this new value will override the first one.
contextAfter[key]['@protected'] = true;
const valueAfter = canonicalizeJson(contextAfter[key]);
contextAfter[key] = {...contextAfter[key], '@protected': true};
}

// Error if they are not identical
if (valueBefore !== valueAfter) {
if (!deepEqual(contextBefore[key], contextAfter[key])) {
throw new ErrorCoded(`Attempted to override the protected keyword ${key} from ${
JSON.stringify(Util.getContextValueId(contextBefore[key]))} to ${
JSON.stringify(Util.getContextValueId(contextAfter[key]))}`,
Expand Down Expand Up @@ -594,9 +619,14 @@ must be one of ${Util.CONTAINERS.join(', ')}`, ERROR_CODES.INVALID_CONTAINER_MAP
* @param {IParseOptions} options Parsing options.
* @return {IJsonLdContextNormalizedRaw} The mutated input context.
*/
public async parseInnerContexts(context: IJsonLdContextNormalizedRaw, options: IParseOptions)
public async parseInnerContexts(
context: IJsonLdContextNormalizedRaw,
options: IParseOptions,
/* istanbul ignore next */
keys = Object.keys(context)
)
: Promise<IJsonLdContextNormalizedRaw> {
for (const key of Object.keys(context)) {
for (const key of keys) {
const value = context[key];
if (value && typeof value === 'object') {
if ('@context' in value && value['@context'] !== null && !options.ignoreScopedContexts) {
Expand All @@ -607,19 +637,17 @@ must be one of ${Util.CONTAINERS.join(', ')}`, ERROR_CODES.INVALID_CONTAINER_MAP
// https://w3c.github.io/json-ld-api/#h-note-10
if (this.validateContext) {
try {
const parentContext = {...context};
parentContext[key] = {...parentContext[key]};
const parentContext = {...context, [key]: {...context[key]}};
delete parentContext[key]['@context'];
await this.parse(value['@context'],
{ ...options, external: false, parentContext, ignoreProtection: true, ignoreRemoteScopedContexts: true, ignoreScopedContexts: true });
} catch (e) {
throw new ErrorCoded(e.message, ERROR_CODES.INVALID_SCOPED_CONTEXT);
}
}

value['@context'] = (await this.parse(value['@context'],
{ ...options, external: false, minimalProcessing: true, ignoreRemoteScopedContexts: true, parentContext: context }))
.getContextRaw();
context[key] = {...value, '@context': (await this.parse(value['@context'],
{ ...options, external: false, minimalProcessing: true, ignoreRemoteScopedContexts: true, parentContext: context }))
.getContextRaw()}
}
}
}
Expand All @@ -633,17 +661,16 @@ must be one of ${Util.CONTAINERS.join(', ')}`, ERROR_CODES.INVALID_CONTAINER_MAP
* @return {Promise<JsonLdContextNormalized>} A promise resolving to the context.
*/
public async parse(context: JsonLdContext,
options: IParseOptions = {}): Promise<JsonLdContextNormalized> {
options: IParseOptions = {}, ioptions: { skipValidation?: boolean } = {}): Promise<JsonLdContextNormalized> {
const {
baseIRI,
parentContext: parentContextInitial,
parentContext,
external,
processingMode = ContextParser.DEFAULT_PROCESSING_MODE,
normalizeLanguageTags,
ignoreProtection,
minimalProcessing,
} = options;
let parentContext = parentContextInitial;
const remoteContexts = options.remoteContexts || {};

// Avoid remote context overflows
Expand Down Expand Up @@ -705,6 +732,8 @@ must be one of ${Util.CONTAINERS.join(', ')}`, ERROR_CODES.INVALID_CONTAINER_MAP
external: !!contextIris[i] || options.external,
parentContext: accContext.getContextRaw(),
remoteContexts: contextIris[i] ? { ...remoteContexts, [contextIris[i]]: true } : remoteContexts,
}, {
skipValidation: i < contexts.length - 1,
})),
Promise.resolve(new JsonLdContextNormalized(parentContext || {})));

Expand All @@ -718,10 +747,7 @@ must be one of ${Util.CONTAINERS.join(', ')}`, ERROR_CODES.INVALID_CONTAINER_MAP
}

// Make a deep clone of the given context, to avoid modifying it.
context = <IJsonLdContextNormalizedRaw> JSON.parse(JSON.stringify(context)); // No better way in JS at the moment.
if (parentContext && !minimalProcessing) {
parentContext = <IJsonLdContextNormalizedRaw> JSON.parse(JSON.stringify(parentContext));
}
context = <IJsonLdContextNormalizedRaw> {...context};

// According to the JSON-LD spec, @base must be ignored from external contexts.
if (external) {
Expand All @@ -733,7 +759,7 @@ must be one of ${Util.CONTAINERS.join(', ')}`, ERROR_CODES.INVALID_CONTAINER_MAP

// Hashify container entries
// Do this before protected term validation as that influences term format
this.containersToHash(context);
context = this.containersToHash(context);

// Don't perform any other modifications if only minimal processing is needed.
if (minimalProcessing) {
Expand All @@ -760,46 +786,56 @@ must be one of ${Util.CONTAINERS.join(', ')}`, ERROR_CODES.INVALID_CONTAINER_MAP
}

this.applyScopedProtected(importContext, { processingMode }, defaultExpandOptions);
let newContext: IJsonLdContextNormalizedRaw = { ...importContext, ...context };

const newContext: IJsonLdContextNormalizedRaw = Object.assign(importContext, context);

// Handle terms (before protection checks)
this.idifyReverseTerms(newContext);
this.normalize(newContext, { processingMode, normalizeLanguageTags });
this.applyScopedProtected(newContext, { processingMode }, defaultExpandOptions);

const keys = Object.keys(newContext);

const overlappingKeys: string[] = [];
if (typeof parentContext === 'object') {
// Merge different parts of the final context in order
this.applyScopedProtected(newContext, { processingMode }, defaultExpandOptions);
newContext = { ...parentContext, ...newContext };
for (const key in parentContext) {
if (key in newContext) {
overlappingKeys.push(key);
} else {
newContext[key] = parentContext[key];
}
}
}

const newContextWrapped = new JsonLdContextNormalized(newContext);

// Parse inner contexts with minimal processing
await this.parseInnerContexts(newContext, options);
await this.parseInnerContexts(newContext, options, keys);

const newContextWrapped = new JsonLdContextNormalized(newContext);

// In JSON-LD 1.1, @vocab can be relative to @vocab in the parent context, or a compact IRI.
if ((newContext && newContext['@version'] || ContextParser.DEFAULT_PROCESSING_MODE) >= 1.1
&& ((context['@vocab'] && typeof context['@vocab'] === 'string') || context['@vocab'] === '')) {
if (parentContext && '@vocab' in parentContext && context['@vocab'].indexOf(':') < 0) {
newContext['@vocab'] = parentContext['@vocab'] + context['@vocab'];
} else {
if (Util.isCompactIri(context['@vocab']) || context['@vocab'] in newContextWrapped.getContextRaw()) {
} else if (Util.isCompactIri(context['@vocab']) || context['@vocab'] in newContext) {
// @vocab is a compact IRI or refers exactly to a prefix
newContext['@vocab'] = newContextWrapped.expandTerm(context['@vocab'], true);
}
newContext['@vocab'] = newContextWrapped.expandTerm(context['@vocab'], true);

}
}

// Handle terms (before protection checks)
this.idifyReverseTerms(newContext);
// FIXME: Add keys as a 3rd argument here for performance
this.expandPrefixedTerms(newContextWrapped, this.expandContentTypeToBase);

// In JSON-LD 1.1, check if we are not redefining any protected keywords
if (!ignoreProtection && parentContext && processingMode >= 1.1) {
this.validateKeywordRedefinitions(parentContext, newContext, defaultExpandOptions);
this.validateKeywordRedefinitions(parentContext, newContext, defaultExpandOptions, overlappingKeys);
}

this.normalize(newContext, { processingMode, normalizeLanguageTags });
this.applyScopedProtected(newContext, { processingMode }, defaultExpandOptions);
if (this.validateContext) {
if (this.validateContext && !ioptions.skipValidation) {
this.validate(newContext, { processingMode });
}

return newContextWrapped;
} else {
throw new ErrorCoded(`Tried parsing a context that is not a string, array or object, but got ${context}`,
Expand All @@ -816,7 +852,7 @@ must be one of ${Util.CONTAINERS.join(', ')}`, ERROR_CODES.INVALID_CONTAINER_MAP
// First try to retrieve the context from cache
const cached = this.documentCache[url];
if (cached) {
return typeof cached === 'string' ? cached : Array.isArray(cached) ? cached.slice() : {... cached};
return cached;
}

// If not in cache, load it
Expand Down Expand Up @@ -863,8 +899,8 @@ must be one of ${Util.CONTAINERS.join(', ')}`, ERROR_CODES.INVALID_CONTAINER_MAP
* @param importContextIri The full URI of an @import value.
*/
public async loadImportContext(importContextIri: string): Promise<IJsonLdContextNormalizedRaw> {
// Load the context
const importContext = await this.load(importContextIri);
// Load the context - and do a deep clone since we are about to mutate it
let importContext = await this.load(importContextIri);

// Require the context to be a non-array object
if (typeof importContext !== 'object' || Array.isArray(importContext)) {
Expand All @@ -877,11 +913,11 @@ must be one of ${Util.CONTAINERS.join(', ')}`, ERROR_CODES.INVALID_CONTAINER_MAP
throw new ErrorCoded('An imported context can not import another context: ' + importContextIri,
ERROR_CODES.INVALID_CONTEXT_ENTRY);
}
importContext = {...importContext};

// Containers have to be converted into hash values the same way as for the importing context
// Otherwise context validation will fail for container values
this.containersToHash(importContext);
return importContext;
return this.containersToHash(importContext);
}

}
Expand Down Expand Up @@ -972,4 +1008,3 @@ export interface IParseOptions {
*/
ignoreScopedContexts?: boolean;
}

1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,6 @@
"dependencies": {
"@types/http-link-header": "^1.0.1",
"@types/node": "^18.0.0",
"canonicalize": "^1.0.1",
"cross-fetch": "^3.0.6",
"http-link-header": "^1.0.2",
"relative-to-absolute-iri": "^1.0.5"
Expand Down
Loading