Skip to content

Commit 28596b5

Browse files
authored
Remove deep cloning of contexts during parsing
This significantly improves parsing performance. I have also done a (small) benchmark of these changes. As can be seen the time taken to parse a context with these changes significantly decreases (in particular it is now 20x faster to parse a VC context). Before: Parse a context that has not been cached; and without caching in place x 78.17 ops/sec ±0.73% (78 runs sampled) Parse a context object that has not been cached x 2,280 ops/sec ±0.65% (91 runs sampled) in #72: Parse a context that has not been cached; and without caching in place x 123 ops/sec ±0.78% (84 runs sampled) Parse a context object that has not been cached x 5,417 ops/sec ±0.70% (87 runs sampled) After (in this PR): Parse a context that has not been cached; and without caching in place x 1,714 ops/sec ±0.78% (84 runs sampled) Parse a context object that has not been cached x 8,902 ops/sec ±0.70% (87 runs sampled)
1 parent e3519d2 commit 28596b5

File tree

5 files changed

+144
-78
lines changed

5 files changed

+144
-78
lines changed

lib/ContextParser.ts

Lines changed: 84 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,6 @@ import {IJsonLdContext, IJsonLdContextNormalizedRaw, IPrefixValue, JsonLdContext
77
import {JsonLdContextNormalized, defaultExpandOptions, IExpandOptions} from "./JsonLdContextNormalized";
88
import {Util} from "./Util";
99

10-
// tslint:disable-next-line:no-var-requires
11-
const canonicalizeJson = require('canonicalize');
12-
1310
/**
1411
* Parses JSON-LD contexts.
1512
*/
@@ -93,13 +90,14 @@ export class ContextParser {
9390
*/
9491
public idifyReverseTerms(context: IJsonLdContextNormalizedRaw): IJsonLdContextNormalizedRaw {
9592
for (const key of Object.keys(context)) {
96-
const value: IPrefixValue = context[key];
93+
let value = context[key];
9794
if (value && typeof value === 'object') {
9895
if (value['@reverse'] && !value['@id']) {
9996
if (typeof value['@reverse'] !== 'string' || Util.isValidKeyword(value['@reverse'])) {
10097
throw new ErrorCoded(`Invalid @reverse value, must be absolute IRI or blank node: '${value['@reverse']}'`,
10198
ERROR_CODES.INVALID_IRI_MAPPING);
10299
}
100+
value = context[key] = {...value, '@id': value['@reverse']};
103101
value['@id'] = <string> value['@reverse'];
104102
if (Util.isPotentialKeyword(value['@reverse'])) {
105103
delete value['@reverse'];
@@ -118,10 +116,12 @@ export class ContextParser {
118116
* @param {IJsonLdContextNormalizedRaw} context A context.
119117
* @param {boolean} expandContentTypeToBase If @type inside the context may be expanded
120118
* via @base if @vocab is set to null.
119+
* @param {string[]} keys Optional set of keys from the context to expand. If left undefined, all
120+
* keys in the context will be expanded.
121121
*/
122-
public expandPrefixedTerms(context: JsonLdContextNormalized, expandContentTypeToBase: boolean) {
122+
public expandPrefixedTerms(context: JsonLdContextNormalized, expandContentTypeToBase: boolean, keys?: string[]) {
123123
const contextRaw = context.getContextRaw();
124-
for (const key of Object.keys(contextRaw)) {
124+
for (const key of (keys || Object.keys(contextRaw))) {
125125
// Only expand allowed keys
126126
if (Util.EXPAND_KEYS_BLACKLIST.indexOf(key) < 0 && !Util.isReservedInternalKeyword(key)) {
127127
// Error if we try to alias a keyword to something else.
@@ -162,27 +162,30 @@ Tried mapping ${key} to ${JSON.stringify(keyValue)}`, ERROR_CODES.INVALID_KEYWOR
162162
if ('@id' in value) {
163163
// Use @id value for expansion
164164
if (id !== undefined && id !== null && typeof id === 'string') {
165-
contextRaw[key]['@id'] = context.expandTerm(id, true);
165+
contextRaw[key] = { ...contextRaw[key], '@id': context.expandTerm(id, true) };
166166
changed = changed || id !== contextRaw[key]['@id'];
167167
}
168168
} else if (!Util.isPotentialKeyword(key) && canAddIdEntry) {
169169
// Add an explicit @id value based on the expanded key value
170170
const newId = context.expandTerm(key, true);
171171
if (newId !== key) {
172172
// Don't set @id if expansion failed
173-
contextRaw[key]['@id'] = newId;
173+
contextRaw[key] = { ...contextRaw[key], '@id': newId };
174174
changed = true;
175175
}
176176
}
177177
if (type && typeof type === 'string' && type !== '@vocab'
178178
&& (!value['@container'] || !(<any> value['@container'])['@type'])
179179
&& canAddIdEntry) {
180180
// First check @vocab, then fallback to @base
181-
contextRaw[key]['@type'] = context.expandTerm(type, true);
182-
if (expandContentTypeToBase && type === contextRaw[key]['@type']) {
183-
contextRaw[key]['@type'] = context.expandTerm(type, false);
181+
let expandedType = context.expandTerm(type, true);
182+
if (expandContentTypeToBase && type === expandedType) {
183+
expandedType = context.expandTerm(type, false);
184+
}
185+
if (expandedType !== type) {
186+
changed = true;
187+
contextRaw[key] = { ...contextRaw[key], '@type': expandedType };
184188
}
185-
changed = changed || type !== contextRaw[key]['@type'];
186189
}
187190
}
188191
if (!changed) {
@@ -209,7 +212,10 @@ Tried mapping ${key} to ${JSON.stringify(keyValue)}`, ERROR_CODES.INVALID_KEYWOR
209212
const value = context[key];
210213
if (value && typeof value === 'object') {
211214
if (typeof value['@language'] === 'string') {
212-
value['@language'] = value['@language'].toLowerCase();
215+
const lowercase = value['@language'].toLowerCase();
216+
if (lowercase !== value['@language']) {
217+
context[key] = {...value, '@language': lowercase};
218+
}
213219
}
214220
}
215221
}
@@ -226,13 +232,13 @@ Tried mapping ${key} to ${JSON.stringify(keyValue)}`, ERROR_CODES.INVALID_KEYWOR
226232
const value = context[key];
227233
if (value && typeof value === 'object') {
228234
if (typeof value['@container'] === 'string') {
229-
value['@container'] = { [value['@container']]: true };
235+
context[key] = { ...value, '@container': { [value['@container']]: true } };
230236
} else if (Array.isArray(value['@container'])) {
231237
const newValue: {[key: string]: boolean} = {};
232238
for (const containerValue of value['@container']) {
233239
newValue[containerValue] = true;
234240
}
235-
value['@container'] = newValue;
241+
context[key] = { ...value, '@container': newValue };
236242
}
237243
}
238244
}
@@ -256,7 +262,7 @@ Tried mapping ${key} to ${JSON.stringify(keyValue)}`, ERROR_CODES.INVALID_KEYWOR
256262
if (value && typeof value === 'object') {
257263
if (!('@protected' in context[key])) {
258264
// Mark terms with object values as protected if they don't have an @protected: false annotation
259-
context[key]['@protected'] = true;
265+
context[key] = {...context[key], '@protected': true};
260266
}
261267
} else {
262268
// Convert string-based term values to object-based values with @protected: true
@@ -265,7 +271,7 @@ Tried mapping ${key} to ${JSON.stringify(keyValue)}`, ERROR_CODES.INVALID_KEYWOR
265271
'@protected': true,
266272
};
267273
if (Util.isSimpleTermDefinitionPrefix(value, expandOptions)) {
268-
context[key]['@prefix'] = true
274+
context[key] = {...context[key], '@prefix': true};
269275
}
270276
}
271277
}
@@ -280,29 +286,29 @@ Tried mapping ${key} to ${JSON.stringify(keyValue)}`, ERROR_CODES.INVALID_KEYWOR
280286
* @param {IJsonLdContextNormalizedRaw} contextBefore The context that may contain some protected terms.
281287
* @param {IJsonLdContextNormalizedRaw} contextAfter A new context that is being applied on the first one.
282288
* @param {IExpandOptions} expandOptions Options that are needed for any expansions during this validation.
289+
* @param {string[]} keys Optional set of keys from the context to validate. If left undefined, all
290+
* keys defined in contextAfter will be checked.
283291
*/
284292
public validateKeywordRedefinitions(contextBefore: IJsonLdContextNormalizedRaw,
285293
contextAfter: IJsonLdContextNormalizedRaw,
286-
expandOptions: IExpandOptions) {
287-
for (const key of Object.keys(contextAfter)) {
294+
expandOptions?: IExpandOptions,
295+
keys?: string[]) {
296+
for (const key of (keys ?? Object.keys(contextAfter) )) {
288297
if (Util.isTermProtected(contextBefore, key)) {
289298
// The entry in the context before will always be in object-mode
290299
// If the new entry is in string-mode, convert it to object-mode
291300
// before checking if it is identical.
292301
if (typeof contextAfter[key] === 'string') {
293-
contextAfter[key] = { '@id': contextAfter[key] };
294-
}
295-
296-
// Convert term values to strings for each comparison
297-
const valueBefore = canonicalizeJson(contextBefore[key]);
302+
contextAfter[key] = { '@id': contextAfter[key], '@protected': true };
303+
} else {
298304
// We modify this deliberately,
299305
// as we need it for the value comparison (they must be identical modulo '@protected')),
300306
// and for the fact that this new value will override the first one.
301-
contextAfter[key]['@protected'] = true;
302-
const valueAfter = canonicalizeJson(contextAfter[key]);
307+
contextAfter[key] = {...contextAfter[key], '@protected': true};
308+
}
303309

304310
// Error if they are not identical
305-
if (valueBefore !== valueAfter) {
311+
if (!Util.deepEqual(contextBefore[key], contextAfter[key])) {
306312
throw new ErrorCoded(`Attempted to override the protected keyword ${key} from ${
307313
JSON.stringify(Util.getContextValueId(contextBefore[key]))} to ${
308314
JSON.stringify(Util.getContextValueId(contextAfter[key]))}`,
@@ -593,10 +599,11 @@ must be one of ${Util.CONTAINERS.join(', ')}`, ERROR_CODES.INVALID_CONTAINER_MAP
593599
* @param {IJsonLdContextNormalizedRaw} context A context.
594600
* @param {IParseOptions} options Parsing options.
595601
* @return {IJsonLdContextNormalizedRaw} The mutated input context.
602+
* @param {string[]} keys Optional set of keys from the context to parseInnerContexts of. If left undefined, all
603+
* keys in the context will be iterated over.
596604
*/
597-
public async parseInnerContexts(context: IJsonLdContextNormalizedRaw, options: IParseOptions)
598-
: Promise<IJsonLdContextNormalizedRaw> {
599-
for (const key of Object.keys(context)) {
605+
public async parseInnerContexts(context: IJsonLdContextNormalizedRaw, options: IParseOptions, keys?: string[]): Promise<IJsonLdContextNormalizedRaw> {
606+
for (const key of (keys ?? Object.keys(context))) {
600607
const value = context[key];
601608
if (value && typeof value === 'object') {
602609
if ('@context' in value && value['@context'] !== null && !options.ignoreScopedContexts) {
@@ -607,19 +614,17 @@ must be one of ${Util.CONTAINERS.join(', ')}`, ERROR_CODES.INVALID_CONTAINER_MAP
607614
// https://w3c.github.io/json-ld-api/#h-note-10
608615
if (this.validateContext) {
609616
try {
610-
const parentContext = {...context};
611-
parentContext[key] = {...parentContext[key]};
617+
const parentContext = {...context, [key]: {...context[key]}};
612618
delete parentContext[key]['@context'];
613619
await this.parse(value['@context'],
614620
{ ...options, external: false, parentContext, ignoreProtection: true, ignoreRemoteScopedContexts: true, ignoreScopedContexts: true });
615621
} catch (e) {
616622
throw new ErrorCoded(e.message, ERROR_CODES.INVALID_SCOPED_CONTEXT);
617623
}
618624
}
619-
620-
value['@context'] = (await this.parse(value['@context'],
621-
{ ...options, external: false, minimalProcessing: true, ignoreRemoteScopedContexts: true, parentContext: context }))
622-
.getContextRaw();
625+
context[key] = {...value, '@context': (await this.parse(value['@context'],
626+
{ ...options, external: false, minimalProcessing: true, ignoreRemoteScopedContexts: true, parentContext: context }))
627+
.getContextRaw()}
623628
}
624629
}
625630
}
@@ -632,18 +637,21 @@ must be one of ${Util.CONTAINERS.join(', ')}`, ERROR_CODES.INVALID_CONTAINER_MAP
632637
* @param {IParseOptions} options Optional parsing options.
633638
* @return {Promise<JsonLdContextNormalized>} A promise resolving to the context.
634639
*/
640+
public async parse(context: JsonLdContext, options?: IParseOptions): Promise<JsonLdContextNormalized>
635641
public async parse(context: JsonLdContext,
636-
options: IParseOptions = {}): Promise<JsonLdContextNormalized> {
642+
options: IParseOptions = {},
643+
// These options are only for internal use on recursive calls and should not be used by
644+
// libraries consuming this function
645+
internalOptions: { skipValidation?: boolean } = {}): Promise<JsonLdContextNormalized> {
637646
const {
638647
baseIRI,
639-
parentContext: parentContextInitial,
648+
parentContext,
640649
external,
641650
processingMode = ContextParser.DEFAULT_PROCESSING_MODE,
642651
normalizeLanguageTags,
643652
ignoreProtection,
644653
minimalProcessing,
645654
} = options;
646-
let parentContext = parentContextInitial;
647655
const remoteContexts = options.remoteContexts || {};
648656

649657
// Avoid remote context overflows
@@ -705,7 +713,11 @@ must be one of ${Util.CONTAINERS.join(', ')}`, ERROR_CODES.INVALID_CONTAINER_MAP
705713
external: !!contextIris[i] || options.external,
706714
parentContext: accContext.getContextRaw(),
707715
remoteContexts: contextIris[i] ? { ...remoteContexts, [contextIris[i]]: true } : remoteContexts,
708-
})),
716+
},
717+
// @ts-expect-error: This third argument causes a type error because we have hidden it from consumers
718+
{
719+
skipValidation: i < contexts.length - 1,
720+
})),
709721
Promise.resolve(new JsonLdContextNormalized(parentContext || {})));
710722

711723
// Override the base IRI if provided.
@@ -718,10 +730,7 @@ must be one of ${Util.CONTAINERS.join(', ')}`, ERROR_CODES.INVALID_CONTAINER_MAP
718730
}
719731

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

726735
// According to the JSON-LD spec, @base must be ignored from external contexts.
727736
if (external) {
@@ -760,46 +769,55 @@ must be one of ${Util.CONTAINERS.join(', ')}`, ERROR_CODES.INVALID_CONTAINER_MAP
760769
}
761770

762771
this.applyScopedProtected(importContext, { processingMode }, defaultExpandOptions);
763-
let newContext: IJsonLdContextNormalizedRaw = { ...importContext, ...context };
772+
773+
const newContext: IJsonLdContextNormalizedRaw = Object.assign(importContext, context);
774+
775+
// Handle terms (before protection checks)
776+
this.idifyReverseTerms(newContext);
777+
this.normalize(newContext, { processingMode, normalizeLanguageTags });
778+
this.applyScopedProtected(newContext, { processingMode }, defaultExpandOptions);
779+
780+
const keys = Object.keys(newContext);
781+
782+
const overlappingKeys: string[] = [];
764783
if (typeof parentContext === 'object') {
765784
// Merge different parts of the final context in order
766-
this.applyScopedProtected(newContext, { processingMode }, defaultExpandOptions);
767-
newContext = { ...parentContext, ...newContext };
785+
for (const key in parentContext) {
786+
if (key in newContext) {
787+
overlappingKeys.push(key);
788+
} else {
789+
newContext[key] = parentContext[key];
790+
}
791+
}
768792
}
769793

770-
const newContextWrapped = new JsonLdContextNormalized(newContext);
771-
772794
// Parse inner contexts with minimal processing
773-
await this.parseInnerContexts(newContext, options);
795+
await this.parseInnerContexts(newContext, options, keys);
796+
797+
const newContextWrapped = new JsonLdContextNormalized(newContext);
774798

775799
// In JSON-LD 1.1, @vocab can be relative to @vocab in the parent context, or a compact IRI.
776800
if ((newContext && newContext['@version'] || ContextParser.DEFAULT_PROCESSING_MODE) >= 1.1
777801
&& ((context['@vocab'] && typeof context['@vocab'] === 'string') || context['@vocab'] === '')) {
778802
if (parentContext && '@vocab' in parentContext && context['@vocab'].indexOf(':') < 0) {
779803
newContext['@vocab'] = parentContext['@vocab'] + context['@vocab'];
780-
} else {
781-
if (Util.isCompactIri(context['@vocab']) || context['@vocab'] in newContextWrapped.getContextRaw()) {
804+
} else if (Util.isCompactIri(context['@vocab']) || context['@vocab'] in newContext) {
782805
// @vocab is a compact IRI or refers exactly to a prefix
783-
newContext['@vocab'] = newContextWrapped.expandTerm(context['@vocab'], true);
784-
}
806+
newContext['@vocab'] = newContextWrapped.expandTerm(context['@vocab'], true);
807+
785808
}
786809
}
787810

788-
// Handle terms (before protection checks)
789-
this.idifyReverseTerms(newContext);
790-
this.expandPrefixedTerms(newContextWrapped, this.expandContentTypeToBase);
811+
this.expandPrefixedTerms(newContextWrapped, this.expandContentTypeToBase, keys);
791812

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

797-
this.normalize(newContext, { processingMode, normalizeLanguageTags });
798-
this.applyScopedProtected(newContext, { processingMode }, defaultExpandOptions);
799-
if (this.validateContext) {
818+
if (this.validateContext && !internalOptions.skipValidation) {
800819
this.validate(newContext, { processingMode });
801820
}
802-
803821
return newContextWrapped;
804822
} else {
805823
throw new ErrorCoded(`Tried parsing a context that is not a string, array or object, but got ${context}`,
@@ -816,7 +834,7 @@ must be one of ${Util.CONTAINERS.join(', ')}`, ERROR_CODES.INVALID_CONTAINER_MAP
816834
// First try to retrieve the context from cache
817835
const cached = this.documentCache[url];
818836
if (cached) {
819-
return typeof cached === 'string' ? cached : Array.isArray(cached) ? cached.slice() : {... cached};
837+
return cached;
820838
}
821839

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

869887
// Require the context to be a non-array object
870888
if (typeof importContext !== 'object' || Array.isArray(importContext)) {
@@ -877,6 +895,7 @@ must be one of ${Util.CONTAINERS.join(', ')}`, ERROR_CODES.INVALID_CONTAINER_MAP
877895
throw new ErrorCoded('An imported context can not import another context: ' + importContextIri,
878896
ERROR_CODES.INVALID_CONTEXT_ENTRY);
879897
}
898+
importContext = {...importContext};
880899

881900
// Containers have to be converted into hash values the same way as for the importing context
882901
// Otherwise context validation will fail for container values
@@ -972,4 +991,3 @@ export interface IParseOptions {
972991
*/
973992
ignoreScopedContexts?: boolean;
974993
}
975-

lib/Util.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,4 +247,27 @@ export class Util {
247247
public static isReservedInternalKeyword(key: string) {
248248
return key.startsWith('@__');
249249
}
250+
251+
/**
252+
* Check if two objects are deepEqual to on another.
253+
* @param object1 The first object to test.
254+
* @param object2 The second object to test.
255+
*/
256+
public static deepEqual(object1: any, object2: any): boolean {
257+
const objKeys1 = Object.keys(object1);
258+
const objKeys2 = Object.keys(object2);
259+
260+
if (objKeys1.length !== objKeys2.length) return false;
261+
return objKeys1.every((key) => {
262+
const value1 = object1[key];
263+
const value2 = object2[key];
264+
return (value1 === value2) || (
265+
value1 !== null &&
266+
value2 !== null &&
267+
typeof value1 === "object" &&
268+
typeof value2 === "object" &&
269+
this.deepEqual(value1, value2)
270+
);
271+
});
272+
};
250273
}

package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,6 @@
8686
"dependencies": {
8787
"@types/http-link-header": "^1.0.1",
8888
"@types/node": "^18.0.0",
89-
"canonicalize": "^1.0.1",
9089
"cross-fetch": "^3.0.6",
9190
"http-link-header": "^1.0.2",
9291
"relative-to-absolute-iri": "^1.0.5"

0 commit comments

Comments
 (0)