diff --git a/CHANGELOG.md b/CHANGELOG.md index ea19e942..048ad818 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # jsonld ChangeLog +## 1.6.2 - 2019-05-xx + +### Fixed +- Allow overriding of protected terms when redefining to the same + definition, modulo the `protected` flag itself. +- Fix type-scoped context application: + - Ensure values and subject references are expanded and compacted + using type-scoped contexts, if available. + - Ensure `@type` values are evaluated against the previous context, + not the type-scoped context. + ## 1.6.1 - 2019-05-13 ### Fixed diff --git a/lib/compact.js b/lib/compact.js index ce990f2f..9d52a751 100644 --- a/lib/compact.js +++ b/lib/compact.js @@ -143,8 +143,8 @@ api.compact = ({ const rval = {}; - // revert type scoped terms - activeCtx = activeCtx.revertTypeScopedTerms(); + // revert type scoped context + activeCtx = activeCtx.revertTypeScopedContext(); if(options.link && '@id' in element) { // store linked element @@ -161,12 +161,15 @@ api.compact = ({ if(types.length > 1) { types = Array.from(types).sort(); } + // find all type-scoped contexts based on current context, prior to + // updating it + const typeContext = activeCtx; for(const type of types) { const compactedType = api.compactIri( - {activeCtx, iri: type, relativeTo: {vocab: true}}); + {activeCtx: typeContext, iri: type, relativeTo: {vocab: true}}); // Use any type-scoped context defined on this value - const ctx = _getContextValue(activeCtx, compactedType, '@context'); + const ctx = _getContextValue(typeContext, compactedType, '@context'); if(!_isUndefined(ctx)) { activeCtx = _processContext({ activeCtx, @@ -184,13 +187,16 @@ api.compact = ({ // compact @id and @type(s) if(expandedProperty === '@id' || expandedProperty === '@type') { + // if using a type-scoped context, resolve type values against previous + // context + const isType = expandedProperty === '@type'; + const valueContext = isType ? + (activeCtx.previousContext || activeCtx) : activeCtx; let compactedValue = _asArray(expandedValue).map( expandedIri => api.compactIri({ - activeCtx, + activeCtx: valueContext, iri: expandedIri, - relativeTo: { - vocab: expandedProperty === '@type' - } + relativeTo: {vocab: isType} })); if(compactedValue.length === 1) { compactedValue = compactedValue[0]; diff --git a/lib/context.js b/lib/context.js index 1dca2e13..cfcb1c01 100644 --- a/lib/context.js +++ b/lib/context.js @@ -66,6 +66,23 @@ api.process = ({ return activeCtx; } + // track the previous context + const previousContext = activeCtx.previousContext || activeCtx; + + // if context is property scoped and there's a previous context, amend it, + // not the current one + if(isPropertyTermScopedContext && activeCtx.previousContext) { + // TODO: consider optimizing to a shallow copy + activeCtx = activeCtx.clone(); + activeCtx.previousContext = api.process({ + activeCtx: activeCtx.previousContext, + localCtx: ctxs, + options, + isPropertyTermScopedContext + }); + return activeCtx; + } + // process each context in order, update active context // on each iteration to ensure proper caching let rval = activeCtx; @@ -75,15 +92,6 @@ api.process = ({ // update active context to one computed from last iteration activeCtx = rval; - // get context from cache if available - if(api.cache) { - const cached = api.cache.get(activeCtx, ctx); - if(cached) { - rval = activeCtx = cached; - continue; - } - } - // reset to initial context if(ctx === null) { // We can't nullify if there are protected terms and we're @@ -102,7 +110,7 @@ api.process = ({ console.warn('WARNING: invalid context nullification'); const oldActiveCtx = activeCtx; // copy all protected term definitions to fresh initial context - rval = activeCtx = api.getInitialContext(options); + rval = activeCtx = api.getInitialContext(options).clone(); for(const [term, _protected] of Object.entries(oldActiveCtx.protected)) { if(_protected) { @@ -124,10 +132,23 @@ api.process = ({ 'jsonld.SyntaxError', {code: 'invalid protected mode', context: localCtx, protectedMode}); } - rval = activeCtx = api.getInitialContext(options); + rval = activeCtx = api.getInitialContext(options).clone(); + // if context is type-scoped, ensure previous context has been set + if(isTypeScopedContext) { + rval.previousContext = previousContext.clone(); + } continue; } + // get context from cache if available + if(api.cache) { + const cached = api.cache.get(activeCtx, ctx); + if(cached) { + rval = activeCtx = cached; + continue; + } + } + // dereference @context key if present if(_isObject(ctx) && '@context' in ctx) { ctx = ctx['@context']; @@ -140,6 +161,9 @@ api.process = ({ 'jsonld.SyntaxError', {code: 'invalid local context', context: ctx}); } + // TODO: there is likely a `preivousContext` cloning optimization that + // could be applied here (no need to copy it under certain conditions) + // clone context before updating it rval = rval.clone(); @@ -239,7 +263,12 @@ api.process = ({ for(const key in ctx) { api.createTermDefinition( rval, ctx, key, defined, options, - isPropertyTermScopedContext, isTypeScopedContext); + isPropertyTermScopedContext); + } + + // if context is type-scoped, ensure previous context has been set + if(isTypeScopedContext && !rval.previousContext) { + rval.previousContext = previousContext.clone(); } // cache result @@ -248,6 +277,11 @@ api.process = ({ } } + // if context is type-scoped, ensure previous context has been set + if(isTypeScopedContext && !rval.previousContext) { + rval.previousContext = previousContext.clone(); + } + return rval; }; @@ -265,13 +299,10 @@ api.process = ({ * signal a warning. * @param isPropertyTermScopedContext `true` if `localCtx` is a scoped context * from a property term. - * @param isTypeScopedContext `true` if `localCtx` is a scoped context - * from a type. */ api.createTermDefinition = ( activeCtx, localCtx, term, defined, options, - isPropertyTermScopedContext = false, - isTypeScopedContext = false) => { + isPropertyTermScopedContext = false) => { if(defined.has(term)) { // term already defined if(defined.get(term)) { @@ -301,33 +332,11 @@ api.createTermDefinition = ( {code: 'invalid term definition', context: localCtx}); } - // FIXME if(1.1) ... ? - if(activeCtx.protected.hasOwnProperty(term) && - !isPropertyTermScopedContext) { - const protectedMode = (options && options.protectedMode) || 'error'; - if(protectedMode === 'error') { - throw new JsonLdError( - 'Invalid JSON-LD syntax; tried to redefine a protected term.', - 'jsonld.SyntaxError', - {code: 'protected term redefinition', context: localCtx, term}); - } else if(protectedMode === 'warn') { - // FIXME: remove logging and use a handler - console.warn('WARNING: protected term redefinition', {term}); - return; - } - throw new JsonLdError( - 'Invalid protectedMode.', - 'jsonld.SyntaxError', - {code: 'invalid protected mode', context: localCtx, term, - protectedMode}); - } + // keep reference to previous mapping for potential `@protected` check + const previousMapping = activeCtx.mappings.get(term); // remove old mapping - let previousMapping = null; if(activeCtx.mappings.has(term)) { - if(isTypeScopedContext) { - previousMapping = activeCtx.mappings.get(term); - } activeCtx.mappings.delete(term); } @@ -362,11 +371,6 @@ api.createTermDefinition = ( // create new mapping const mapping = {}; activeCtx.mappings.set(term, mapping); - if(isTypeScopedContext) { - activeCtx.hasTypeScopedTerms = true; - mapping.isTypeScopedTerm = true; - mapping.previousMapping = previousMapping; - } mapping.reverse = false; // make sure term definition only has expected keywords @@ -652,6 +656,33 @@ api.createTermDefinition = ( 'Invalid JSON-LD syntax; @context and @preserve cannot be aliased.', 'jsonld.SyntaxError', {code: 'invalid keyword alias', context: localCtx}); } + + // FIXME if(1.1) ... ? + if(previousMapping && previousMapping.protected && + !isPropertyTermScopedContext) { + // force new term to continue to be protected and see if the mappings would + // be equal + activeCtx.protected[term] = true; + mapping.protected = true; + if(!_deepCompare(previousMapping, mapping)) { + const protectedMode = (options && options.protectedMode) || 'error'; + if(protectedMode === 'error') { + throw new JsonLdError( + 'Invalid JSON-LD syntax; tried to redefine a protected term.', + 'jsonld.SyntaxError', + {code: 'protected term redefinition', context: localCtx, term}); + } else if(protectedMode === 'warn') { + // FIXME: remove logging and use a handler + console.warn('WARNING: protected term redefinition', {term}); + return; + } + throw new JsonLdError( + 'Invalid protectedMode.', + 'jsonld.SyntaxError', + {code: 'invalid protected mode', context: localCtx, term, + protectedMode}); + } + } }; /** @@ -781,7 +812,7 @@ api.getInitialContext = options => { inverse: null, getInverse: _createInverseContext, clone: _cloneActiveContext, - revertTypeScopedTerms: _revertTypeScopedTerms, + revertTypeScopedContext: _revertTypeScopedContext, protected: {} }; // TODO: consider using LRU cache instead @@ -957,7 +988,10 @@ api.getInitialContext = options => { child.inverse = null; child.getInverse = this.getInverse; child.protected = util.clone(this.protected); - child.revertTypeScopedTerms = this.revertTypeScopedTerms; + if(this.previousContext) { + child.previousContext = this.previousContext.clone(); + } + child.revertTypeScopedContext = this.revertTypeScopedContext; if('@language' in this) { child['@language'] = this['@language']; } @@ -968,35 +1002,14 @@ api.getInitialContext = options => { } /** - * Reverts any type-scoped terms in this active context to their previous - * mappings. + * Reverts any type-scoped context in this active context to the previous + * context. */ - function _revertTypeScopedTerms() { - // optimization: no type-scoped terms to remove, reuse active context - if(!this.hasTypeScopedTerms) { + function _revertTypeScopedContext() { + if(!this.previousContext) { return this; } - // create clone without type scoped terms - const child = this.clone(); - const entries = child.mappings.entries(); - for(const [term, mapping] of entries) { - if(mapping.isTypeScopedTerm) { - if(mapping.previousMapping) { - child.mappings.set(term, mapping.previousMapping); - if(mapping.previousMapping.protected) { - child.protected[term] = true; - } else { - delete child.protected[term]; - } - } else { - child.mappings.delete(term); - if(child.protected[term]) { - delete child.protected[term]; - } - } - } - } - return child; + return this.previousContext.clone(); } }; @@ -1303,3 +1316,48 @@ function _findContextUrls(input, urls, replace, base) { } } } + +function _deepCompare(x1, x2) { + // compare `null` or primitive types directly + if((!(x1 && typeof x1 === 'object')) || + (!(x2 && typeof x2 === 'object'))) { + return x1 === x2; + } + // x1 and x2 are objects (also potentially arrays) + const x1Array = Array.isArray(x1); + if(x1Array !== Array.isArray(x2)) { + return false; + } + if(x1Array) { + if(x1.length !== x2.length) { + return false; + } + for(let i = 0; i < x1.length; ++i) { + if(!_deepCompare(x1[i], x2[i])) { + return false; + } + } + return true; + } + // x1 and x2 are non-array objects + const k1s = Object.keys(x1); + const k2s = Object.keys(x2); + if(k1s.length !== k2s.length) { + return false; + } + for(const k1 in x1) { + let v1 = x1[k1]; + let v2 = x2[k1]; + // special case: `@container` can be in any order + if(k1 === '@container') { + if(Array.isArray(v1) && Array.isArray(v2)) { + v1 = v1.slice().sort(); + v2 = v2.slice().sort(); + } + } + if(!_deepCompare(v1, v2)) { + return false; + } + } + return true; +} diff --git a/lib/expand.js b/lib/expand.js index 4619a0d3..74e466f0 100644 --- a/lib/expand.js +++ b/lib/expand.js @@ -52,6 +52,9 @@ module.exports = api; * @param insideList true if the element is a list, false if not. * @param insideIndex true if the element is inside an index container, * false if not. + * @param typeScopedContext an optional type-scoped active context for + * expanding values of nodes that were expressed according to + * a type-scoped context. * @param expansionMap(info) a function that can be used to custom map * unmappable values (or to throw an error when they are detected); * if this function returns `undefined` then the default behavior @@ -66,6 +69,7 @@ api.expand = ({ options = {}, insideList = false, insideIndex = false, + typeScopedContext = null, expansionMap = () => undefined }) => { // nothing to expand @@ -115,7 +119,8 @@ api.expand = ({ element: element[i], options, expansionMap, - insideIndex + insideIndex, + typeScopedContext }); if(insideList && (_isArray(e) || _isList(e))) { // lists of lists are illegal @@ -152,9 +157,40 @@ api.expand = ({ // recursively expand object: - if(!insideIndex) { - // revert type scoped terms - activeCtx = activeCtx.revertTypeScopedTerms(); + // first, expand the active property + const expandedActiveProperty = _expandIri( + activeCtx, activeProperty, {vocab: true}, options); + + // second, determine if any type-scoped context should be reverted; it + // should only be reverted when the following are all true: + // 1. `element` is not a value or subject reference + // 2. `insideIndex` is false + typeScopedContext = typeScopedContext || + (activeCtx.previousContext ? activeCtx : null); + let keys = Object.keys(element).sort(); + let mustRevert = !insideIndex; + if(mustRevert && typeScopedContext && keys.length <= 2 && + !keys.includes('@context')) { + for(const key of keys) { + const expandedProperty = _expandIri( + typeScopedContext, key, {vocab: true}, options); + if(expandedProperty === '@value') { + // value found, ensure type-scoped context is used to expand it + mustRevert = false; + activeCtx = typeScopedContext; + break; + } + if(expandedProperty === '@id' && keys.length === 1) { + // subject reference found, do not revert + mustRevert = false; + break; + } + } + } + + if(mustRevert) { + // revert type scoped context + activeCtx = activeCtx.revertTypeScopedContext(); } // if element has a context, process it @@ -163,8 +199,7 @@ api.expand = ({ {activeCtx, localCtx: element['@context'], options}); } - // look for scoped context on @type - let keys = Object.keys(element).sort(); + // look for scoped contexts on `@type` for(const key of keys) { const expandedProperty = _expandIri(activeCtx, key, {vocab: true}, options); if(expandedProperty === '@type') { @@ -175,7 +210,8 @@ api.expand = ({ Array.isArray(value) ? (value.length > 1 ? value.slice().sort() : value) : [value]; for(const type of types) { - const ctx = _getContextValue(activeCtx, type, '@context'); + const ctx = _getContextValue( + activeCtx.previousContext || activeCtx, type, '@context'); if(!_isUndefined(ctx)) { activeCtx = _processContext({ activeCtx, @@ -188,10 +224,6 @@ api.expand = ({ } } - // expand the active property - const expandedActiveProperty = _expandIri( - activeCtx, activeProperty, {vocab: true}, options); - // process each key and value in element, ignoring @nest content let rval = {}; _expandObject({ @@ -202,6 +234,7 @@ api.expand = ({ expandedParent: rval, options, insideList, + typeScopedContext, expansionMap}); // get property count on expanded output @@ -449,7 +482,8 @@ function _expandObject({ expandedParent, '@type', _asArray(value).map(v => _isString(v) ? - _expandIri(activeCtx, v, {base: true, vocab: true}, options) : v), + _expandIri(activeCtx.previousContext || activeCtx, v, + {base: true, vocab: true}, options) : v), {propertyIsArray: options.isFrame}); continue; } @@ -613,8 +647,8 @@ function _expandObject({ } else if(container.includes('@type') && _isObject(value)) { // handle type container (skip if value is not an object) expandedValue = _expandIndexMap({ - // since container is `@type`, revert type scoped terms when expanding - activeCtx: termCtx.revertTypeScopedTerms(), + // since container is `@type`, revert type scoped context when expanding + activeCtx: termCtx.revertTypeScopedContext(), options, activeProperty: key, value,