diff --git a/CHANGELOG.md b/CHANGELOG.md index dd1ec40e..4ffead86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # jsonld ChangeLog +### Added +- Support list of lists. + ## 1.6.2 - 2019-05-21 ### Fixed diff --git a/lib/compact.js b/lib/compact.js index 9603bd12..7f1e1051 100644 --- a/lib/compact.js +++ b/lib/compact.js @@ -138,6 +138,22 @@ api.compact = ({ return rval; } + // if expanded property is @list and we're contained within a list + // container, recursively compact this item to an array + if(_isList(element)) { + const container = _getContextValue( + activeCtx, activeProperty, '@container') || []; + if(container.includes('@list')) { + return api.compact({ + activeCtx, + activeProperty, + element: element['@list'], + options, + compactionMap + }); + } + } + // FIXME: avoid misuse of active property as an expanded property? const insideReverse = (activeProperty === '@reverse'); @@ -399,14 +415,12 @@ api.compact = ({ relativeTo: {vocab: true} })] = expandedItem['@index']; } - } else if(nestResult.hasOwnProperty(itemActiveProperty)) { - // can't use @list container for more than 1 list - throw new JsonLdError( - 'JSON-LD compact error; property has a "@list" @container ' + - 'rule but there is more than a single @list that matches ' + - 'the compacted term in the document. Compaction might mix ' + - 'unwanted items into the list.', - 'jsonld.SyntaxError', {code: 'compaction to list of lists'}); + } else { + _addValue(nestResult, itemActiveProperty, compactedItem, { + valueIsArray: true, + allowDuplicate: true + }); + continue; } } diff --git a/lib/expand.js b/lib/expand.js index 74e466f0..994debaf 100644 --- a/lib/expand.js +++ b/lib/expand.js @@ -122,11 +122,8 @@ api.expand = ({ insideIndex, typeScopedContext }); - if(insideList && (_isArray(e) || _isList(e))) { - // lists of lists are illegal - throw new JsonLdError( - 'Invalid JSON-LD syntax; lists of lists are not permitted.', - 'jsonld.SyntaxError', {code: 'list of lists'}); + if(insideList && _isArray(e)) { + e = {'@list': e}; } if(e === null) { @@ -672,11 +669,6 @@ function _expandObject({ insideList: isList, expansionMap }); - if(isList && _isList(expandedValue)) { - throw new JsonLdError( - 'Invalid JSON-LD syntax; lists of lists are not permitted.', - 'jsonld.SyntaxError', {code: 'list of lists'}); - } } else { // recursively expand value with key as new active property expandedValue = api.expand({ diff --git a/lib/fromRdf.js b/lib/fromRdf.js index dca067ec..5ce1ed2e 100644 --- a/lib/fromRdf.js +++ b/lib/fromRdf.js @@ -224,24 +224,6 @@ api.fromRDF = async ( } } - // the list is nested in another list - if(property === RDF_FIRST) { - // empty list - if(node['@id'] === RDF_NIL) { - // can't convert rdf:nil to a @list object because it would - // result in a list of lists which isn't supported - continue; - } - - // preserve list head - if(RDF_REST in graphObject[head['@id']]) { - head = graphObject[head['@id']][RDF_REST][0]; - } - - list.pop(); - listNodes.pop(); - } - // transform list into @list object delete head['@id']; head['@list'] = list.reverse(); diff --git a/lib/nodeMap.js b/lib/nodeMap.js index e71c28dc..b9e7da2b 100644 --- a/lib/nodeMap.js +++ b/lib/nodeMap.js @@ -47,8 +47,8 @@ api.createMergedNodeMap = (input, options) => { api.createNodeMap = (input, graphs, graph, issuer, name, list) => { // recurse through array if(types.isArray(input)) { - for(let i = 0; i < input.length; ++i) { - api.createNodeMap(input[i], graphs, graph, issuer, undefined, list); + for(const node of input) { + api.createNodeMap(node, graphs, graph, issuer, undefined, list); } return; } @@ -74,6 +74,11 @@ api.createNodeMap = (input, graphs, graph, issuer, name, list) => { list.push(input); } return; + } else if(list && graphTypes.isList(input)) { + const _list = []; + api.createNodeMap(input['@list'], graphs, graph, issuer, name, _list); + list.push({'@list': _list}); + return; } // Note: At this point, input must be a subject. @@ -81,8 +86,7 @@ api.createNodeMap = (input, graphs, graph, issuer, name, list) => { // spec requires @type to be named first, so assign names early if('@type' in input) { const types = input['@type']; - for(let i = 0; i < types.length; ++i) { - const type = types[i]; + for(const type of types) { if(type.indexOf('_:') === 0) { issuer.getId(type); } @@ -105,9 +109,7 @@ api.createNodeMap = (input, graphs, graph, issuer, name, list) => { const subject = subjects[name] = subjects[name] || {}; subject['@id'] = name; const properties = Object.keys(input).sort(); - for(let pi = 0; pi < properties.length; ++pi) { - let property = properties[pi]; - + for(let property of properties) { // skip @id if(property === '@id') { continue; @@ -119,8 +121,7 @@ api.createNodeMap = (input, graphs, graph, issuer, name, list) => { const reverseMap = input['@reverse']; for(const reverseProperty in reverseMap) { const items = reverseMap[reverseProperty]; - for(let ii = 0; ii < items.length; ++ii) { - const item = items[ii]; + for(const item of items) { let itemName = item['@id']; if(graphTypes.isBlankNode(item)) { itemName = issuer.getId(itemName); @@ -171,9 +172,7 @@ api.createNodeMap = (input, graphs, graph, issuer, name, list) => { util.addValue(subject, property, [], {propertyIsArray: true}); continue; } - for(let oi = 0; oi < objects.length; ++oi) { - let o = objects[oi]; - + for(let o of objects) { if(property === '@type') { // rename @type blank nodes o = (o.indexOf('_:') === 0) ? issuer.getId(o) : o; @@ -190,6 +189,10 @@ api.createNodeMap = (input, graphs, graph, issuer, name, list) => { subject, property, {'@id': id}, {propertyIsArray: true, allowDuplicate: false}); api.createNodeMap(o, graphs, graph, issuer, id); + } else if(graphTypes.isValue(o)) { + util.addValue( + subject, property, o, + {propertyIsArray: true, allowDuplicate: false}); } else if(graphTypes.isList(o)) { // handle @list const _list = []; @@ -249,8 +252,7 @@ api.mergeNodeMaps = graphs => { // add all non-default graphs to default graph const defaultGraph = graphs['@default']; const graphNames = Object.keys(graphs).sort(); - for(let i = 0; i < graphNames.length; ++i) { - const graphName = graphNames[i]; + for(const graphName of graphNames) { if(graphName === '@default') { continue; } @@ -265,9 +267,8 @@ api.mergeNodeMaps = graphs => { subject['@graph'] = []; } const graph = subject['@graph']; - const ids = Object.keys(nodeMap).sort(); - for(let ii = 0; ii < ids.length; ++ii) { - const node = nodeMap[ids[ii]]; + for(const id of Object.keys(nodeMap).sort()) { + const node = nodeMap[id]; // only add full subjects if(!graphTypes.isSubjectReference(node)) { graph.push(node); diff --git a/lib/toRdf.js b/lib/toRdf.js index f062ee28..634cc01d 100644 --- a/lib/toRdf.js +++ b/lib/toRdf.js @@ -85,8 +85,7 @@ api.toRDF = (input, options) => { */ function _graphToRDF(dataset, graph, graphTerm, issuer, options) { const ids = Object.keys(graph).sort(); - for(let i = 0; i < ids.length; ++i) { - const id = ids[i]; + for(const id of ids) { const node = graph[id]; const properties = Object.keys(node).sort(); for(let property of properties) { @@ -126,22 +125,16 @@ function _graphToRDF(dataset, graph, graphTerm, issuer, options) { continue; } - // convert @list to triples - if(graphTypes.isList(item)) { - _listToRDF( - item['@list'], issuer, subject, predicate, dataset, graphTerm); - } else { - // convert value or node object to triple - const object = _objectToRDF(item); - // skip null objects (they are relative IRIs) - if(object) { - dataset.push({ - subject, - predicate, - object, - graph: graphTerm - }); - } + // convert list, value or node object to triple + const object = _objectToRDF(item, issuer, dataset, graphTerm); + // skip null objects (they are relative IRIs) + if(object) { + dataset.push({ + subject, + predicate, + object, + graph: graphTerm + }); } } } @@ -154,59 +147,71 @@ function _graphToRDF(dataset, graph, graphTerm, issuer, options) { * * @param list the @list value. * @param issuer a IdentifierIssuer for assigning blank node names. - * @param subject the subject for the head of the list. - * @param predicate the predicate for the head of the list. * @param dataset the array of quads to append to. * @param graphTerm the graph term for each quad. + * + * @return the head of the list. */ -function _listToRDF(list, issuer, subject, predicate, dataset, graphTerm) { +function _listToRDF(list, issuer, dataset, graphTerm) { const first = {termType: 'NamedNode', value: RDF_FIRST}; const rest = {termType: 'NamedNode', value: RDF_REST}; const nil = {termType: 'NamedNode', value: RDF_NIL}; + const last = list.pop(); + // Result is the head of the list + const result = last ? {termType: 'BlankNode', value: issuer.getId()} : nil; + let subject = result; + for(const item of list) { - const blankNode = {termType: 'BlankNode', value: issuer.getId()}; + const object = _objectToRDF(item, issuer, dataset, graphTerm); + const next = {termType: 'BlankNode', value: issuer.getId()}; dataset.push({ subject, - predicate, - object: blankNode, + predicate: first, + object, graph: graphTerm }); + dataset.push({ + subject, + predicate: rest, + object: next, + graph: graphTerm + }); + subject = next; + } - subject = blankNode; - predicate = first; - const object = _objectToRDF(item); - - // skip null objects (they are relative IRIs) - if(object) { - dataset.push({ - subject, - predicate, - object, - graph: graphTerm - }); - } - - predicate = rest; + // Tail of list + if(last) { + const object = _objectToRDF(last, issuer, dataset, graphTerm); + dataset.push({ + subject, + predicate: first, + object, + graph: graphTerm + }); + dataset.push({ + subject, + predicate: rest, + object: nil, + graph: graphTerm + }); } - dataset.push({ - subject, - predicate, - object: nil, - graph: graphTerm - }); + return result; } /** - * Converts a JSON-LD value object to an RDF literal or a JSON-LD string or - * node object to an RDF resource. + * Converts a JSON-LD value object to an RDF literal or a JSON-LD string, + * node object to an RDF resource, or adds a list. * * @param item the JSON-LD value or node object. + * @param issuer a IdentifierIssuer for assigning blank node names. + * @param dataset the dataset to append RDF quads to. + * @param graphTerm the graph term for each quad. * * @return the RDF literal or RDF resource. */ -function _objectToRDF(item) { +function _objectToRDF(item, issuer, dataset, graphTerm) { const object = {}; // convert value object to RDF @@ -241,6 +246,10 @@ function _objectToRDF(item) { object.value = value; object.datatype.value = datatype || XSD_STRING; } + } else if(graphTypes.isList(item)) { + const _list = _listToRDF(item['@list'], issuer, dataset, graphTerm); + object.termType = _list.termType; + object.value = _list.value; } else { // convert string/node object to RDF const id = types.isObject(item) ? item['@id'] : item; diff --git a/lib/util.js b/lib/util.js index a41e6e7b..2b9b482a 100644 --- a/lib/util.js +++ b/lib/util.js @@ -249,6 +249,8 @@ api.hasValue = (subject, property, value) => { * @param [options] the options to use: * [propertyIsArray] true if the property is always an array, false * if not (default: false). + * [valueIsArray] true if the value to be added should be preserved as + * an array (lists) (default: false). * [allowDuplicate] true to allow duplicates, false not to (uses a * simple shallow comparison of subject ID or value) (default: true). */ @@ -257,11 +259,16 @@ api.addValue = (subject, property, value, options) => { if(!('propertyIsArray' in options)) { options.propertyIsArray = false; } + if(!('valueIsArray' in options)) { + options.valueIsArray = false; + } if(!('allowDuplicate' in options)) { options.allowDuplicate = true; } - if(types.isArray(value)) { + if(options.valueIsArray) { + subject[property] = value; + } else if(types.isArray(value)) { if(value.length === 0 && options.propertyIsArray && !subject.hasOwnProperty(property)) { subject[property] = []; diff --git a/tests/test-common.js b/tests/test-common.js index fc7c3946..8913ec40 100644 --- a/tests/test-common.js +++ b/tests/test-common.js @@ -37,12 +37,6 @@ const TEST_TYPES = { specVersion: ['json-ld-1.0'], // FIXME idRegex: [ - // list of lists - /compact-manifest.jsonld#tli01$/, - /compact-manifest.jsonld#tli02$/, - /compact-manifest.jsonld#tli03$/, - /compact-manifest.jsonld#tli04$/, - /compact-manifest.jsonld#tli05$/, // terms /compact-manifest.jsonld#tp001$/, // rel iri @@ -108,19 +102,11 @@ const TEST_TYPES = { }, 'jld:ExpandTest': { skip: { + // skip tests where behavior changed for a 1.1 processor + // see JSON-LD 1.0 Errata + specVersion: ['json-ld-1.0'], // FIXME idRegex: [ - // list of lists - /expand-manifest.jsonld#tli01$/, - /expand-manifest.jsonld#tli02$/, - /expand-manifest.jsonld#tli03$/, - /expand-manifest.jsonld#tli04$/, - /expand-manifest.jsonld#tli05$/, - /expand-manifest.jsonld#tli06$/, - /expand-manifest.jsonld#tli07$/, - /expand-manifest.jsonld#tli08$/, - /expand-manifest.jsonld#tli09$/, - /expand-manifest.jsonld#tli10$/, // mode /expand-manifest.jsonld#tp001$/, /expand-manifest.jsonld#tp002$/, @@ -250,12 +236,11 @@ const TEST_TYPES = { }, 'jld:FlattenTest': { skip: { + // skip tests where behavior changed for a 1.1 processor + // see JSON-LD 1.0 Errata + specVersion: ['json-ld-1.0'], // FIXME idRegex: [ - // list of lists - /flatten-manifest.jsonld#tli01$/, - /flatten-manifest.jsonld#tli02$/, - /flatten-manifest.jsonld#tli03$/, // html /html-manifest.jsonld#tf001$/, /html-manifest.jsonld#tf002$/, @@ -280,6 +265,9 @@ const TEST_TYPES = { }, 'jld:FrameTest': { skip: { + // skip tests where behavior changed for a 1.1 processor + // see JSON-LD 1.0 Errata + specVersion: ['json-ld-1.0'], // FIXME idRegex: [ // ex @@ -331,12 +319,11 @@ const TEST_TYPES = { }, 'jld:FromRDFTest': { skip: { + // skip tests where behavior changed for a 1.1 processor + // see JSON-LD 1.0 Errata + specVersion: ['json-ld-1.0'], // FIXME idRegex: [ - // list of lists - /fromRdf-manifest.jsonld#tli01$/, - /fromRdf-manifest.jsonld#tli02$/, - /fromRdf-manifest.jsonld#tli03$/, // JSON literals /fromRdf-manifest.jsonld#tjs01$/, /fromRdf-manifest.jsonld#tjs02$/, @@ -364,11 +351,11 @@ const TEST_TYPES = { }, 'jld:ToRDFTest': { skip: { + // skip tests where behavior changed for a 1.1 processor + // see JSON-LD 1.0 Errata + specVersion: ['json-ld-1.0'], // FIXME idRegex: [ - // list of lists - /toRdf-manifest.jsonld#tli01$/, - /toRdf-manifest.jsonld#tli02$/, // blank node properties /toRdf-manifest.jsonld#t0118$/, // well formed diff --git a/tests/webidl/JsonLdProcessor.idl b/tests/webidl/JsonLdProcessor.idl index 31cac3a8..5f0906f5 100644 --- a/tests/webidl/JsonLdProcessor.idl +++ b/tests/webidl/JsonLdProcessor.idl @@ -37,7 +37,6 @@ dictionary JsonLdError { enum JsonLdErrorCode { "colliding keywords", - "compaction to list of lists", "conflicting indexes", "cyclic IRI mapping", "invalid @id value", @@ -70,7 +69,6 @@ enum JsonLdErrorCode { "invalid value object value", "invalid vocab mapping", "keyword redefinition", - "list of lists", "loading document failed", "loading remote context failed", "multiple context link headers",