From 89dd522d45d56d20eb0346c3a758b5b65ae78797 Mon Sep 17 00:00:00 2001 From: Dave Longley Date: Thu, 7 Feb 2013 17:19:19 -0500 Subject: [PATCH] Initial update to port over changes from jsonld.js. --- jsonld-tests.php | 15 +- jsonld.php | 3428 +++++++++++++++++++++++++++++----------------- 2 files changed, 2204 insertions(+), 1239 deletions(-) diff --git a/jsonld-tests.php b/jsonld-tests.php index 939e09e..687e659 100644 --- a/jsonld-tests.php +++ b/jsonld-tests.php @@ -4,7 +4,7 @@ * * @author Dave Longley * - * Copyright (c) 2011-2012 Digital Bazaar, Inc. All rights reserved. + * Copyright (c) 2011-2013 Digital Bazaar, Inc. All rights reserved. */ require_once('jsonld.php'); @@ -261,7 +261,12 @@ class TestRunner { 'base' => 'http://json-ld.org/test-suite/tests/' . $test->input); try { - if(in_array('jld:NormalizeTest', $type)) { + if(in_array('jld:ApiErrorTest', $type)) { + echo "Skipping test \"{$test->name}\" of type: " . + json_encode($type) . $eol; + continue; + } + else if(in_array('jld:NormalizeTest', $type)) { $this->test($test->name); $input = read_test_json($test->input, $filepath); $test->expect = read_test_nquads($test->expect, $filepath); @@ -281,6 +286,12 @@ class TestRunner { $test->expect = read_test_json($test->expect, $filepath); $result = jsonld_compact($input, $test->context, $options); } + else if(in_array('jld:FlattenTest', $type)) { + $this->test($test->name); + $input = read_test_json($test->input, $filepath); + $test->expect = read_test_json($test->expect, $filepath); + $result = jsonld_flatten($input, null, $options); + } else if(in_array('jld:FrameTest', $type)) { $this->test($test->name); $input = read_test_json($test->input, $filepath); diff --git a/jsonld.php b/jsonld.php index 32197fb..24e814e 100644 --- a/jsonld.php +++ b/jsonld.php @@ -70,6 +70,23 @@ function jsonld_expand($input, $options=array()) { return $p->expand($input, $options); } +/** + * Performs JSON-LD flattening. + * + * @param mixed $input the JSON-LD to flatten. + * @param mixed $ctx the context to use to compact the flattened output, or + * null. + * @param [options] the options to use: + * [base] the base IRI to use. + * [resolver(url, callback(err, jsonCtx))] the URL resolver to use. + * + * @return mixed the flattened JSON-LD output. + */ +function jsonld_flatten($input, $ctx, $options=array()) { + $p = new JsonLdProcessor(); + return $p->flatten($input, $ctx, $options); +} + /** * Performs JSON-LD framing. * @@ -145,6 +162,14 @@ function jsonld_to_rdf($input, $options=array()) { return $p->toRDF($input, $options); } +/** JSON-LD shared in-memory cache. */ +global $jsonld_cache; +$jsonld_cache = new stdClass(); + +/** The default active context cache. */ +// FIXME: turn on +//$jsonld_cache->activeCtx = new ActiveContextCache(); + /** The default JSON-LD URL resolver. */ global $jsonld_default_url_resolver; $jsonld_default_url_resolver = null; @@ -186,11 +211,11 @@ function jsonld_resolve_url($url) { function jsonld_default_resolve_url($url) { // default JSON-LD GET implementation $opts = array('http' => - array( - 'method' => "GET", - 'header' => - "Accept: application/ld+json\r\n" . - "User-Agent: PaySwarm PHP Client/1.0\r\n")); + array( + 'method' => "GET", + 'header' => + "Accept: application/ld+json\r\n" . + "User-Agent: PaySwarm PHP Client/1.0\r\n")); $stream = stream_context_create($opts); $result = @file_get_contents($url, false, $stream); if($result === false) { @@ -229,68 +254,111 @@ function jsonld_unregister_rdf_parser($content_type) { } } -/** - * Converts a relative path into an absolute URL. - * - * @param string $path the relative path. - * @param string $url the absolute URL base. - * - * @return string the absolute URL for the given relative path. - */ -function jsonld_to_absolute_url($path, $url) { - // validate path (it may already be an absolute URL) - if(filter_var($path, FILTER_VALIDATE_URL) === false) { - $url = parse_url($url); - $rval = "{$url['scheme']}://"; - - if(isset($url['user']) || isset($url['pass'])) { - $rval .= "{$url['user']}:{$url['pass']}@"; - } - - $rval .= $url['host']; - if(isset($url['port'])) { - $rval .= ":{$url['port']}"; - } - - $rval .= jsonld_prepend_base($url['path'], $path); - if(strrpos($rval, '?') === false && isset($url['query'])) { - $rval .= "?{$url['query']}"; - } - - if(strrpos($rval, '#') === false && isset($url['fragment'])) { - $rval .= "#{$url['fragment']}"; - } +/** + * Parses a URL into its component parts. + * + * @param string $url the URL to parse. + * + * @return assoc the parsed URL. + */ +function jsonld_parse_url($url) { + $rval = parse_url($url); + if(isset($rval['host'])) { + if(!isset($rval['path']) || $rval['path'] === '') { + $rval['path'] = '/'; + } } - else { - $rval = $path; + else if(!isset($rval['path'])) { + $rval['path'] = ''; } - - // validate result - if(filter_var($rval, FILTER_VALIDATE_URL) === false) { - throw new JsonLdException( - 'Malformed URL.', 'jsonld.InvalidUrl', array('url' => $rval)); - } - - return $rval; } /** - * Prepend a relative IRI to a base IRI. + * Prepends a base IRI to the given relative IRI. * - * @param string $base the base IRI. + * @param mixed $base a string or the parsed base IRI. * @param string $iri the relative IRI. + * + * @return string the absolute IRI. */ -function jsonld_prepend_base($base, $iri) { - if($iri === '' || strpos($iri, '#') === 0) { - return "$base$iri"; +function jsonld_prepend_base($base, $iri) { + if(is_string($base)) { + $base = jsonld_parse_url($base); } + $authority = $base['host']; + $rel = jsonld_parse_url($iri); + print_r($rel); - // prepend last directory for base - $idx = strrpos($base, '/'); - if($idx === false) { - return $iri; - } - return substr($base, 0, $idx + 1) . $iri; + // per RFC3986 normalize slashes and dots in path + + // IRI contains authority + if(strpos($iri, '//') === 0) { + $path = substr($iri, 2); + $authority = substr($path, 0, strrpos($path, '/')); + $path = substr($path, strlen($authority)); + } + // IRI represents an absolue path + else if(strpos($rel['path'], '/') === 0) { + $path = $rel['path']; + } + else { + $path = $base['path']; + + // prepend last directory for base + if($rel['path'] !== '') { + $idx = strrpos($path, '/'); + $idx = ($idx === false) ? 0 : $idx + 1; + $path = substr($path, 0, $idx) + $rel['path']; + } + } + + $segments = explode($path, '/'); + + // remove '.' and '' (do not remove trailing empty path) + $idx = 0; + $end = count($segments) - 1; + $filter = function($e) use ($idx, $end) { + $idx += 1; + return $e !== '.' && ($e !== '' || $idx === $end); + }; + $segments = array_filter($segments, $filter); + + // remove as many '..' as possible + for($i = 0; $i < count($segments);) { + $segment = $segments[$i]; + if($segment === '..') { + // too many reverse dots + if($i === 0) { + $last = $segments[count($segments) - 1]; + if($last !== '..') { + $segments = array($last); + } + else { + $segments = array(); + } + break; + } + + // remove '..' and previous segment + array_splice($segments, $i - 1, 2); + $i -= 1; + } + else { + $i += 1; + } + } + + $path = '/' . implode(',', $segments); + + // add query and hash + if(isset($rel['query'])) { + $path .= '?' . $rel['query']; + } + if(isset($rel['hash'])) { + $path .= '#' . $rel['hash']; + } + + return $base['scheme'] . "://$authority$path"; } /** @@ -326,7 +394,11 @@ class JsonLdProcessor { * @param mixed $input the JSON-LD object to compact. * @param mixed $ctx the context to compact with. * @param assoc $options the compaction options. + * [base] the base IRI to use. + * [skipExpansion] true to assume the input is expanded and skip + * expansion, false not to, defaults to false. * [activeCtx] true to also return the active context used. + * [resolver(url)] the URL resolver to use. * * @return mixed the compacted JSON-LD output. */ @@ -338,24 +410,32 @@ class JsonLdProcessor { // set default options isset($options['base']) or $options['base'] = ''; + isset($options['renameBlankNodes']) or $options['renameBlankNodes'] = + true; isset($options['strict']) or $options['strict'] = true; isset($options['optimize']) or $options['optimize'] = false; isset($options['graph']) or $options['graph'] = false; + isset($options['skipExpansion']) or $options['skipExpansion'] = false; isset($options['activeCtx']) or $options['activeCtx'] = false; isset($options['resolver']) or $options['resolver'] = 'jsonld_resolve_url'; - // expand input - try { - $expanded = $this->expand($input, $options); + if($options['skipExpansion'] === true) { + $expanded = $input; } - catch(JsonLdException $e) { - throw new JsonLdException( - 'Could not expand input before compaction.', - 'jsonld.CompactError', null, $e); + else { + // expand input + try { + $expanded = $this->expand($input, $options); + } + catch(JsonLdException $e) { + throw new JsonLdException( + 'Could not expand input before compaction.', + 'jsonld.CompactError', null, $e); + } } // process context - $active_ctx = $this->_getInitialContext(); + $active_ctx = $this->_getInitialContext($options); try { $active_ctx = $this->processContext($active_ctx, $ctx, $options); } @@ -368,13 +448,15 @@ class JsonLdProcessor { // do compaction $compacted = $this->_compact($active_ctx, null, $expanded, $options); - // always use an array if graph options is on - if($options['graph'] === true) { - $compacted = self::arrayify($compacted); - } - // else if compacted is an array with 1 entry, remove array - else if(is_array($compacted) && count($compacted) === 1) { - $compacted = $compacted[0]; + // if compacted is an array with 1 entry, remove array unless + // graph option is set + if($options['graph'] !== true && + is_array($compacted) && count($compacted) === 1) { + $compacted = $compacted[0]; + } + // always use array if graph option is on + else if($options['graph'] === true) { + $compacted = self::arrayify($compacted); } // follow @context key @@ -426,13 +508,10 @@ class JsonLdProcessor { } if($options['activeCtx']) { - return array( - 'compacted' => $compacted, - 'activeCtx' => $active_ctx); - } - else { - return $compacted; + return array('compacted' => $compacted, 'activeCtx' => $active_ctx); } + + return $compacted; } /** @@ -441,6 +520,10 @@ class JsonLdProcessor { * @param mixed $input the JSON-LD object to expand. * @param assoc $options the options to use: * [base] the base IRI to use. + * [renameBlankNodes] true to rename blank nodes, false not to, + * defaults to true. + * [keepFreeFloatingNodes] true to keep free-floating nodes, + * false not to, defaults to false. * [resolver(url)] the URL resolver to use. * * @return array the expanded JSON-LD output. @@ -448,6 +531,10 @@ class JsonLdProcessor { public function expand($input, $options) { // set default options isset($options['base']) or $options['base'] = ''; + isset($options['renameBlankNodes']) or $options['renameBlankNodes'] = + true; + isset($options['keepFreeFloatingNodes']) or + $options['keepFreeFloatingNodes'] = false; isset($options['resolver']) or $options['resolver'] = 'jsonld_resolve_url'; // resolve all @context URLs in the input @@ -463,18 +550,69 @@ class JsonLdProcessor { } // do expansion - $ctx = $this->_getInitialContext(); - $expanded = $this->_expand($ctx, null, $input, $options, false); + $active_ctx = $this->_getInitialContext($options); + $expanded = $this->_expand($active_ctx, null, $input, $options, false); // optimize away @graph with no other properties if(is_object($expanded) && property_exists($expanded, '@graph') && - count(get_object_vars($expanded)) === 1) { + count(array_keys((array)$expanded)) === 1) { $expanded = $expanded->{'@graph'}; } + else if($expanded === null) { + $expanded = array(); + } // normalize to an array return self::arrayify($expanded); } + /** + * Performs JSON-LD flattening. + * + * @param mixed $input the JSON-LD to flatten. + * @param ctx the context to use to compact the flattened output, or null. + * @param assoc $options the options to use: + * [base] the base IRI to use. + * [resolver(url)] the URL resolver to use. + * + * @return array the flattened output. + */ + public function flatten($input, $ctx, $options) { + // set default options + isset($options['base']) or $options['base'] = ''; + isset($options['resolver']) or $options['resolver'] = 'jsonld_resolve_url'; + + try { + // expand input + $expanded = $this->expand($input, $options); + } + catch(Exception $e) { + throw new JsonLdException( + 'Could not expand input before flattening.', + 'jsonld.FlattenError', null, $e); + } + + // do flattening + $flattened = $this->_flatten($expanded); + + if($ctx === null) { + return $flattened; + } + + // compact result (force @graph option to true, skip expansion) + $options['graph'] = true; + $options['skipExpansion'] = true; + try { + $compacted = $this->compact($flattened, $ctx, $options); + } + catch(Exception $e) { + throw new JsonLdException( + 'Could not compact flattened output.', + 'jsonld.FlattenError', null, $e); + } + + return $compacted; + } + /** * Performs JSON-LD framing. * @@ -505,7 +643,7 @@ class JsonLdProcessor { try { // expand input - $_input = $this->expand($input, $options); + $expanded = $this->expand($input, $options); } catch(Exception $e) { throw new JsonLdException( @@ -515,7 +653,9 @@ class JsonLdProcessor { try { // expand frame - $_frame = $this->expand($frame, $options); + $opts = self::copy($options); + $opts['keepFreeFloatingNodes'] = true; + $expanded_frame = $this->expand($frame, $opts); } catch(Exception $e) { throw new JsonLdException( @@ -524,11 +664,12 @@ class JsonLdProcessor { } // do framing - $framed = $this->_frame($_input, $_frame, $options); + $framed = $this->_frame($expanded, $expanded_frame, $options); try { // compact result (force @graph option to true) $options['graph'] = true; + $options['skipExpansion'] = true; $options['activeCtx'] = true; $result = $this->compact($framed, $ctx, $options); } @@ -539,12 +680,13 @@ class JsonLdProcessor { } $compacted = $result['compacted']; - $ctx = $result['activeCtx']; + $active_ctx = $result['activeCtx']; // get graph alias - $graph = $this->_compactIri($ctx, '@graph'); + $graph = $this->_compactIri($active_ctx, '@graph'); // remove @preserve from results - $compacted->{$graph} = $this->_removePreserve($ctx, $compacted->{$graph}); + $compacted->{$graph} = $this->_removePreserve( + $active_ctx, $compacted->{$graph}); return $compacted; } @@ -685,20 +827,24 @@ class JsonLdProcessor { * @param stdClass $active_ctx the current active context. * @param mixed $local_ctx the local context to process. * @param assoc $options the options to use: + * [renameBlankNodes] true to rename blank nodes, false not to, + * defaults to true. * [resolver(url)] the URL resolver to use. * * @return stdClass the new active context. */ public function processContext($active_ctx, $local_ctx, $options) { - // return initial context early for null context - if($local_ctx === null) { - return $this->_getInitialContext(); - } - // set default options isset($options['base']) or $options['base'] = ''; + isset($options['renameBlankNodes']) or $options['renameBlankNodes'] = + true; isset($options['resolver']) or $options['resolver'] = 'jsonld_resolve_url'; + // return initial context early for null context + if($local_ctx === null) { + return $this->_getInitialContext($options); + } + // resolve URLs in local_ctx $ctx = self::copy($local_ctx); if(is_object($ctx) && !property_exists($ctx, '@context')) { @@ -884,8 +1030,10 @@ class JsonLdProcessor { * considered equal if: * * 1. They are both primitives of the same type and value. - * 2. They are both @values with the same @value, @type, and @language, OR - * 3. They both have @ids they are the same. + * 2. They are both @values with the same @value, @type, @language, + * and @index, OR + * 3. They are both @lists with the same @list and @index, OR + * 4. They both have @ids they are the same. * * @param mixed $v1 the first value. * @param mixed $v2 the second value. @@ -899,17 +1047,33 @@ class JsonLdProcessor { } // 2. equal @values - if(self::_isValue($v1) && self::_isValue($v2) && - $v1->{'@value'} === $v2->{'@value'} && - property_exists($v1, '@type') === property_exists($v2, '@type') && - property_exists($v1, '@language') === property_exists($v2, '@language') && - (!property_exists($v1, '@type') || $v1->{'@type'} === $v2->{'@type'}) && - (!property_exists($v1, '@language') || - $v2->{'@language'} === $v2->{'@language'})) { + if(self::_isValue($v1) || self::_isValue($v2)) { + return ( + self::_compareKeyValues($v1, $v2, '@value') && + self::_compareKeyValues($v1, $v2, '@type') && + self::_compareKeyValues($v1, $v2, '@language') && + self::_compareKeyValues($v1, $v2, '@index')); + } + + // 3. equal @lists + if(self::_isList($v1) && self::_isList($v2)) { + if(!self::_compareKeyValues($v1, $v2, '@index')) { + return false; + } + $list1 = $v1->{'@list'}; + $list2 = $v2->{'@list'}; + if(count($list1) !== count($list2)) { + return false; + } + for($i = 0; $i < count($list1); ++$i) { + if(!self::compareValues($list1[$i], $list2[$i])) { + return false; + } + } return true; } - // 3. equal @ids + // 4. equal @ids if(is_object($v1) && property_exists($v1, '@id') && is_object($v2) && property_exists($v2, '@id')) { return $v1->{'@id'} === $v2->{'@id'}; @@ -945,6 +1109,9 @@ class JsonLdProcessor { // get specific entry information if(property_exists($ctx->mappings, $key)) { $entry = $ctx->mappings->{$key}; + if($entry === null) { + return null; + } // return whole entry if($type === null) { @@ -1064,7 +1231,7 @@ class JsonLdProcessor { } // add statement - JsonLdProcessor::_appendUniqueRdfStatement($statements, $s); + self::_appendUniqueRdfStatement($statements, $s); } return $statements; @@ -1124,7 +1291,7 @@ class JsonLdProcessor { $o->nominalValue); $quad .= '"' . $escaped . '"'; if(property_exists($o, 'datatype') && - $o->datatype->nominalValue !== JsonLdProcessor::XSD_STRING) { + $o->datatype->nominalValue !== self::XSD_STRING) { $quad .= "^^<{$o->datatype->nominalValue}>"; } else if(property_exists($o, 'language')) { @@ -1213,29 +1380,33 @@ class JsonLdProcessor { * Recursively compacts an element using the given active context. All values * must be in expanded form before this method is called. * - * @param stdClass $ctx the active context to use. - * @param mixed $property the property that points to the element, null for - * none. + * @param stdClass $active_ctx the active context to use. + * @param mixed $active_property the compacted property with the element + * to compact, null for none. * @param mixed $element the element to compact. - * @param array $options the compaction options. + * @param assoc $options the compaction options. * * @return mixed the compacted value. */ - protected function _compact($ctx, $property, $element, $options) { + protected function _compact( + $active_ctx, $active_property, $element, $options) { // recursively compact array if(is_array($element)) { $rval = array(); foreach($element as $e) { - $e = $this->_compact($ctx, $property, $e, $options); + // compact, dropping any null values + $compacted = $this->_compact( + $active_ctx, $active_property, $e, $options); // drop null values - if($e !== null) { - $rval[] = $e; + if($compacted !== null) { + $rval[] = $compacted; } } if(count($rval) === 1) { // use single element if no container is specified - $container = self::getContextValue($ctx, $property, '@container'); - if($container !== '@list' && $container !== '@set') { + $container = self::getContextValue( + $active_ctx, $active_property, '@container'); + if($container === null) { $rval = $rval[0]; } } @@ -1244,143 +1415,184 @@ class JsonLdProcessor { // recursively compact object if(is_object($element)) { - // element is a @value - if(self::_isValue($element)) { - // if @value is the only key - if(count(get_object_vars($element)) === 1) { - // if there is no default language or @value is not a string, - // return value of @value - if(!property_exists($ctx, '@language') || - !is_string($element->{'@value'})) { - return $element->{'@value'}; - } - // return full element, alias @value - $rval = new stdClass(); - $rval->{$this->_compactIri($ctx, '@value')} = $element->{'@value'}; - return $rval; - } + // do value compaction on @values and subject references + if(self::_isValue($element) || self::_isSubjectReference($element)) { + return $this->_compactValue($active_ctx, $active_property, $element); + } - // get type and language context rules - $type = self::getContextValue($ctx, $property, '@type'); - $language = self::getContextValue($ctx, $property, '@language'); - - // matching @type specified in context, compact element - if($type !== null && - property_exists($element, '@type') && $element->{'@type'} === $type) { - return $element->{'@value'}; - } - // matching @language specified in context, compact element - else if($language !== null && - property_exists($element, '@language') && - $element->{'@language'} === $language) { - return $element->{'@value'}; + // shallow copy element and arrays so keys and values can be removed + // during property generator compaction + $shallow = new stdClass(); + foreach($element as $expanded_property => $expanded_value) { + if(is_array($element->{$expanded_property})) { + $shallow->{$expanded_property} = $element->{$expanded_property}; } else { - $rval = new stdClass(); - // compact @type IRI - if(property_exists($element, '@type')) { - $rval->{$this->_compactIri($ctx, '@type')} = - $this->_compactIri($ctx, $element->{'@type'}); - } - // alias @language - else if(property_exists($element, '@language')) { - $rval->{$this->_compactIri($ctx, '@language')} = - $element->{'@language'}; - } - $rval->{$this->_compactIri($ctx, '@value')} = $element->{'@value'}; - return $rval; + $shallow->{$expanded_property} = $element->{$expanded_property}; } } + $element = $shallow; - // compact subject references - if(self::_isSubjectReference($element)) { - $type = self::getContextValue($ctx, $property, '@type'); - if($type === '@id' || $property === '@graph') { - return $this->_compactIri($ctx, $element->{'@id'}); - } - } - - // recursively process element keys + // process element keys in order + $keys = array_keys((array)$element); + sort($keys); $rval = new stdClass(); - foreach($element as $key => $value) { - // compact @id and @type(s) - if($key === '@id' || $key === '@type') { - // compact single @id - if(is_string($value)) { - $value = $this->_compactIri($ctx, $value); - } - // value must be a @type array - else { - $types = array(); - foreach($value as $v) { - $types[] = $this->_compactIri($ctx, $v); - } - $value = $types; - } - - // compact property and add value - $prop = $this->_compactIri($ctx, $key); - $isArray = (is_array($value) && count($value) === 0); - self::addValue( - $rval, $prop, $value, array('propertyIsArray' => $isArray)); + foreach($keys as $expanded_property) { + // skip key if removed during property generator duplicate handling + if(!property_exists($element, $expanded_property)) { continue; } - // Note: value must be an array due to expansion algorithm. + $expanded_value = $element->{$expanded_property}; + + // compact @id and @type(s) + if($expanded_property === '@id' || $expanded_property === '@type') { + // compact single @id + if(is_string($expanded_value)) { + $compacted_value = $this->_compactIri( + $active_ctx, $expanded_value, null, array( + 'base' => true, 'vocab' => ($expanded_property === '@type'))); + } + // expanded value must be a @type array + else { + $compacted_value = array(); + foreach($expanded_value as $ev) { + $compacted_value[] = $this->_compactIri( + $active_ctx, $ev, null, array('base' => true, 'vocab' => true)); + } + } + + // use keyword alias and add value + $alias = $this->_compactIri($active_ctx, $expanded_property); + $is_array = (is_array($compacted_value) && + count($expanded_value) === 0); + self::addValue( + $rval, $alias, $compacted_value, + array('propertyIsArray' => $is_array)); + continue; + } + + // handle @index property + if($expanded_property === '@index') { + // drop @index if inside an @index container + $container = self::getContextValue( + $active_ctx, $active_property, '@container'); + if($container === '@index') { + continue; + } + + // use keyword alias and add value + $alias = $this->_compactIri($active_ctx, $expanded_property); + self::addValue($rval, $alias, $expanded_value); + continue; + } + + // Note: expanded value must be an array due to expansion algorithm. // preserve empty arrays - if(count($value) === 0) { - $prop = $this->_compactIri($ctx, $key); + if(count($expanded_value) === 0) { + $active_property = $this->_compactIri( + $active_ctx, $expanded_property, null, array('vocab' => true), + $element); self::addValue( - $rval, $prop, array(), array('propertyIsArray' => true)); + $rval, $active_property, array(), array('propertyIsArray' => true)); } // recusively process array values - foreach($value as $v) { - $is_list = self::_isList($v); + foreach($expanded_value as $expanded_item) { + // compact property and get container type + $active_property = $this->_compactIri( + $active_ctx, $expanded_property, $expanded_item, + array('vocab' => true), $element); + $container = self::getContextValue( + $active_ctx, $active_property, '@container'); - // compact property - $prop = $this->_compactIri($ctx, $key, $v); - - // remove @list for recursion (will be re-added if necessary) - if($is_list) { - $v = $v->{'@list'}; + // remove any duplicates that were (presumably) generated by a + // property generator + if(property_exists($active_ctx->mappings, $active_property)) { + $mapping = $active_ctx->mappings->{$active_property}; + if($mapping && $mapping->propertyGenerator) { + $this->_findAndRemovePropertyGeneratorDuplicates( + $active_ctx, $element, $expanded_property, $expanded_item, + $active_property); + } } - // recursively compact value - $v = $this->_compact($ctx, $prop, $v, $options); + // get @list value if appropriate + $is_list = self::_isList($expanded_item); + $list = null; + if($is_list) { + $list = $expanded_item->{'@list'}; + } - // get container type for property - $container = self::getContextValue($ctx, $prop, '@container'); + // recursively compact expanded item + $compacted_item = $this->_compact( + $active_ctx, $active_property, $is_list ? $list : $expanded_item, + $options); // handle @list - if($is_list && $container !== '@list') { - // handle messy @list compaction - if(property_exists($rval, $prop) && $options['strict']) { + if($is_list) { + // ensure $list value is an array + $compacted_item = self::arrayify($compacted_item); + + if($container !== '@list') { + // wrap using @list alias + $compacted_item = (object)array( + $this->_compactIri($active_ctx, '@list'), $compacted_item); + + // include @index from expanded @list, if any + if(property_exists($expanded_item, '@index')) { + $compacted_item->{$this->_compactIri($active_ctx, '@index')} = + $expanded_item->{'@index'}; + } + } + // can't use @list container for more than 1 list + else if(property_exists($rval, $active_property)) { throw new JsonLdException( - '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 ' + + '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'); } - // reintroduce @list keyword - $kwlist = $this->_compactIri($ctx, '@list'); - $val = new stdClass(); - $val->{$kwlist} = $v; - $v = $val; } - // if @container is @set or @list or value is an empty array, use - // an array when adding value - $is_array = ($container === '@set' || $container === '@list' || - (is_array($v) && count($v) === 0)); + // handle language and index maps + if($container === '@language' || $container === '@index') { + // get or create the map object + if(property_exists($rval, $active_property)) { + $map_object = $rval->{$active_property}; + } + else { + $rval->{$active_property} = $map_object = new stdClass(); + } - // add compact value - self::addValue( - $rval, $prop, $v, array('propertyIsArray' => $is_array)); + // if container is a language map, simplify compacted value to + // a simple string + if($container === '@language' && self::_isValue($compacted_item)) { + $compacted_item = $compacted_item->{'@value'}; + } + + // add compact value to map object using key from expanded value + // based on the container type + self::addValue( + $map_object, $expanded_item->{$container}, $compacted_item); + } + else { + // use an array if: @container is @set or @list , value is an empty + // array, or key is @graph + $is_array = ($container === '@set' || $container === '@list' || + (is_array($compacted_item) && count($compacted_item) === 0) || + $expanded_property === '@graph'); + + // add compact value + self::addValue( + $rval, $active_property, $compacted_item, + array('propertyIsArray' => $is_array)); + } } } + return $rval; } @@ -1393,23 +1605,29 @@ class JsonLdProcessor { * the element will be removed. All context URLs must have been resolved * before calling this method. * - * @param stdClass $ctx the context to use. - * @param mixed $property the property for the element, null for none. + * @param stdClass $active_ctx the active context to use. + * @param mixed $active_property the property for the element, null for none. * @param mixed $element the element to expand. - * @param array $options the expansion options. - * @param bool $propertyIsList true if the property is a list, false if not. + * @param assoc $options the expansion options. + * @param bool $inside_list true if the property is a list, false if not. * * @return mixed the expanded value. */ protected function _expand( - $ctx, $property, $element, $options, $propertyIsList) { + $active_ctx, $active_property, $element, $options, $inside_list) { + // nothing to expand + if($element === null) { + return $element; + } + // recursively expand array if(is_array($element)) { $rval = array(); foreach($element as $e) { // expand element - $e = $this->_expand($ctx, $property, $e, $options, $propertyIsList); - if(is_array($e) && $propertyIsList) { + $e = $this->_expand( + $active_ctx, $active_property, $e, $options, $inside_list); + if($inside_list && (is_array($e) || self::_isList($e))) { // lists of lists are illegal throw new JsonLdException( 'Invalid JSON-LD syntax; lists of lists are not permitted.', @@ -1417,179 +1635,342 @@ class JsonLdProcessor { } // drop null values else if($e !== null) { - $rval[] = $e; + if(is_array($e)) { + $rval = array_merge($rval, $e); + } + else { + $rval[] = $e; + } } } return $rval; } - // expand non-object element according to value expansion rules - if(!is_object($element)) { - return $this->_expandValue($ctx, $property, $element, $options['base']); - } - - // Note: element must be an object, recursively expand it - - // if element has a context, process it - if(property_exists($element, '@context')) { - $ctx = $this->_processContext($ctx, $element->{'@context'}, $options); - unset($element->{'@context'}); - } - - $rval = new stdClass(); - foreach($element as $key => $value) { - // expand property - $prop = $this->_expandTerm($ctx, $key); - - // drop non-absolute IRI keys that aren't keywords - if(!self::_isAbsoluteIri($prop) && !self::_isKeyword($prop, $ctx)) { - continue; + // recursively expand object + if(is_object($element)) { + // if element has a context, process it + if(property_exists($element, '@context')) { + $active_ctx = $this->_processContext($active_ctx, $element->{'@context'}, $options); + unset($element->{'@context'}); } - // if value is null and property is not @value, continue - $value = $element->{$key}; - if($value === null && $prop !== '@value') { - continue; - } + // expand the active property + $expanded_active_property = $this->_expandIri( + $active_ctx, $active_property); - // syntax error if @id is not a string - if($prop === '@id' && !is_string($value)) { - throw new JsonLdException( - 'Invalid JSON-LD syntax; "@id" value must a string.', - 'jsonld.SyntaxError', array('value' => $value)); - } + $rval = new stdClass(); + $keys = array_keys((array)$element); + sort($keys); + foreach($keys as $key) { + $value = $element->{$key}; - // validate @type value - if($prop === '@type') { - $this->_validateTypeValue($value); - } + // expand key using property generator + if(property_exists($active_ctx->mappings, $key)) { + $mapping = $active_ctx->mappings->{$key}; + } + else { + $mapping = null; + } - // @graph must be an array or an object - if($prop === '@graph' && !(is_object($value) || is_array($value))) { - throw new JsonLdException( - 'Invalid JSON-LD syntax; "@value" value must not be an ' + - 'object or an array.', - 'jsonld.SyntaxError', array('value' => $value)); - } + if($mapping && $mapping->propertyGenerator) { + $expanded_property = $mapping->{'@id'}; + } + // expand key to IRI + else { + $expanded_property = $this->_expandIri( + $active_ctx, $key, array('vocab' => true)); + } - // @value must not be an object or an array - if($prop === '@value' && (is_object($value) || is_array($value))) { - throw new JsonLdException( - 'Invalid JSON-LD syntax; "@value" value must not be an ' + - 'object or an array.', - 'jsonld.SyntaxError', array('value' => $value)); - } + // drop non-absolute IRI keys that aren't keywords + if($expanded_property === null || + !(is_array($expanded_property) || + self::_isAbsoluteIri($expanded_property) || + self::_isKeyword($expanded_property))) { + continue; + } - // @language must be a string - if($prop === '@language' && !is_string($value)) { - throw new JsonLdException( - 'Invalid JSON-LD syntax; "@language" value must not be a string.', - 'jsonld.SyntaxError', array('value' => $value)); - } - - // recurse into @list or @set keeping the active property - $is_list = ($prop === '@list'); - if($is_list || $prop === '@set') { - $value = $this->_expand($ctx, $property, $value, $options, $is_list); - if($is_list && self::_isList($value)) { + // syntax error if @id is not a string + if($expanded_property === '@id' && !is_string($value)) { throw new JsonLdException( - 'Invalid JSON-LD syntax; lists of lists are not permitted.', - 'jsonld.SyntaxError'); + 'Invalid JSON-LD syntax; "@id" value must a string.', + 'jsonld.SyntaxError', array('value' => $value)); } - } - else { - // update active property and recursively expand value - $property = $key; - $value = $this->_expand($ctx, $property, $value, $options, false); - } - // drop null values if property is not @value (dropped below) - if($value !== null || $prop === '@value') { - // convert value to @list if container specifies it - if($prop !== '@list' && !self::_isList($value)) { - $container = self::getContextValue($ctx, $property, '@container'); - if($container === '@list') { - // ensure value is an array - $value = (object)array('@list' => self::arrayify($value)); + // validate @type value + if($expanded_property === '@type') { + $this->_validateTypeValue($value); + } + + // @graph must be an array or an object + if($expanded_property === '@graph' && + !(is_object($value) || is_array($value))) { + throw new JsonLdException( + 'Invalid JSON-LD syntax; "@value" value must not be an ' . + 'object or an array.', + 'jsonld.SyntaxError', array('value' => $value)); + } + + // @value must not be an object or an array + if($expanded_property === '@value' && + (is_object($value) || is_array($value))) { + throw new JsonLdException( + 'Invalid JSON-LD syntax; "@value" value must not be an ' . + 'object or an array.', + 'jsonld.SyntaxError', array('value' => $value)); + } + + // @language must be a string + if($expanded_property === '@language' && !is_string($value)) { + throw new JsonLdException( + 'Invalid JSON-LD syntax; "@language" value must not be a string.', + 'jsonld.SyntaxError', array('value' => $value)); + // ensure language value is lowercase + $value = strtolower($value); + } + + // preserve @index + if($expanded_property === '@index') { + if(!is_string($value)) { + throw new JsonLdException( + 'Invalid JSON-LD syntax; "@index" value must be a string.', + 'jsonld.SyntaxError', array('value' => $value)); } } - // optimize away @id for @type - if($prop === '@type') { - if(self::_isSubjectReference($value)) { - $value = $value->{'@id'}; - } - else if(is_array($value)) { - $val = array(); - foreach($value as $v) { - if(self::_isSubjectReference($v)) { - $val[] = $v->{'@id'}; - } - else { - $val[] = $v; + $container = self::getContextValue($active_ctx, $key, '@container'); + + // handle language map container (skip if value is not an object) + if($container === '@language' && is_object($value)) { + $expanded_value = $this->_expandLanguageMap($value); + } + // handle index container (skip if value is not an object) + else if($container === '@index' && is_object($value)) { + $expand_index_map = function($active_property) use ( + $active_ctx, $active_property, $options, $value) { + $rval = array(); + $keys = array_keys((array)$value); + sort($keys); + foreach($keys as $key) { + $val = $value->{$key}; + $val = self::arrayify($val); + $val = $this->_expand( + $active_ctx, $active_property, $val, $options, false); + foreach($val as $item) { + if(!property_exists($item, '@index')) { + $item->{'@index'} = $key; + } + $rval[] = $item; } } - $value = $val; + return $rval; + }; + $expanded_value = $expand_index_map($key); + } + else { + // recurse into @list or @set keeping the active property + $is_list = ($expanded_property === '@list'); + if($is_list || $expanded_property === '@set') { + $next_active_property = $active_property; + if($is_list && $expanded_active_property === '@graph') { + $next_active_property = null; + } + $expanded_value = $this->_expand( + $active_ctx, $active_property, $value, $options, $is_list); + if($is_list && self::_isList($expanded_value)) { + throw new JsonLdException( + 'Invalid JSON-LD syntax; lists of lists are not permitted.', + 'jsonld.SyntaxError'); + } + } + else { + // recursively expand value with key as new active property + $expanded_value = $this->_expand( + $active_ctx, $key, $value, $options, false); } } - // add value, use an array if not @id, @type, @value, or @language - $use_array = !($prop === '@id' || $prop === '@type' || - $prop === '@value' || $prop === '@language'); - self::addValue( - $rval, $prop, $value, array('propertyIsArray' => $use_array)); + // drop null values if property is not @value + if($expanded_value === null && $expanded_property !== '@value') { + continue; + } + + // convert expanded value to @list if container specifies it + if($expanded_property !== '@list' && !self::_isList($expanded_value) && + $container === '@list') { + // ensure expanded value is an array + $expanded_value = (object)array( + '@list' => self::arrayify($expanded_value)); + } + + // add copy of value for each property from property generator + if(is_array($expanded_property)) { + $this->_labelBlankNodes($active_ctx->namer, $expanded_value); + foreach($expanded_property as $iri) { + self::addValue( + $rval, $iri, self::copy($expanded_value), + array('propertyIsArray' => true)); + } + } + // add value for property + else { + // use an array except for certain keywords + $use_array = (!in_array( + $expanded_property, array( + '@index', '@id', '@type', '@value', '@language'))); + self::addValue( + $rval, $expanded_property, $expanded_value, + array('propertyIsArray' => $use_array)); + } } + + // get property count on expanded output + $keys = array_keys((array)$rval); + $count = count($keys); + + // @value must only have @language or @type + if(property_exists($rval, '@value')) { + // @value must only have @language or @type + if(property_exists($rval, '@type') && + property_exists($rval, '@language')) { + throw new JsonLdException( + 'Invalid JSON-LD syntax; an element containing "@value" may not ' . + 'contain both "@type" and "@language".', + 'jsonld.SyntaxError', array('element' => $rval)); + } + $valid_count = $count - 1; + if(property_exists($rval, '@type')) { + $valid_count -= 1; + } + if(property_exists($rval, '@index')) { + $valid_count -= 1; + } + if(property_exists($rval, '@language')) { + $valid_count -= 1; + } + if($valid_count !== 0) { + throw new JsonLdException( + 'Invalid JSON-LD syntax; an element containing "@value" may only ' . + 'have an "@index" property and at most one other property ' . + 'which can be "@type" or "@language".', + 'jsonld.SyntaxError', array('element' => $rval)); + } + // drop null @values + if($rval->{'@value'} === null) { + $rval = null; + } + // drop @language if @value isn't a string + else if(property_exists($rval, '@language') && + !is_string($rval->{'@value'})) { + unset($rval->{'@language'}); + } + } + // convert @type to an array + else if(property_exists($rval, '@type') && !is_array($rval->{'@type'})) { + $rval->{'@type'} = array($rval->{'@type'}); + } + // handle @set and @list + else if(property_exists($rval, '@set') || + property_exists($rval, '@list')) { + if($count > 1 && ($count !== 2 && property_exists($rval, '@index'))) { + throw new JsonLdException( + 'Invalid JSON-LD syntax; if an element has the property "@set" ' . + 'or "@list", then it can have at most one other property that is ' . + '"@index".', + 'jsonld.SyntaxError', array('element' => $rval)); + } + // optimize away @set + if(property_exists($rval, '@set')) { + $rval = $rval->{'@set'}; + $keys = array_keys((array)$rval); + $count = count($keys); + } + } + // drop objects with only @language + else if($count === 1 && property_exists($rval, '@language')) { + $rval = null; + } + + // drop certain top-level objects that do not occur in lists + if(is_object($rval) && + !$options['keepFreeFloatingNodes'] && !$inside_list && + ($active_property === null || $expanded_active_property === '@graph')) { + // drop empty object or top-level @value + if($count === 0 || property_exists($rval, '@value')) { + $rval = null; + } + else { + // drop subjects that generate no triples + $has_triples = false; + $ignore = array('@graph', '@type', '@list'); + foreach($keys as $key) { + if(!self::_isKeyword($key) || in_array($key, $ignore)) { + $has_triples = true; + break; + } + } + if(!$has_triples) { + $rval = null; + } + } + } + + return $rval; } - // get property count on expanded output - $count = count(get_object_vars($rval)); + // drop top-level scalars that are not in lists + if(!$inside_list && + ($active_property === null || + $this->_expandIri($active_ctx, $active_property) === '@graph')) { + return null; + } - // @value must only have @language or @type - if(property_exists($rval, '@value')) { - if(($count === 2 && !property_exists($rval, '@type') && - !property_exists($rval, '@language')) || - $count > 2) { - throw new JsonLdException( - 'Invalid JSON-LD syntax; an element containing "@value" must have ' + - 'at most one other property which can be "@type" or "@language".', - 'jsonld.SyntaxError', array('element' => $rval)); - } - // value @type must be a string - if(property_exists($rval, '@type') && !is_string($rval->{'@type'})) { - throw new JsonLdException( - 'Invalid JSON-LD syntax; the "@type" value of an element ' + - 'containing "@value" must be a string.', - 'jsonld.SyntaxError', array('element' => $rval)); - } - // drop null @values - else if($rval->{'@value'} === null) { - $rval = null; - } - } - // convert @type to an array - else if(property_exists($rval, '@type') && !is_array($rval->{'@type'})) { - $rval->{'@type'} = array($rval->{'@type'}); - } - // handle @set and @list - else if(property_exists($rval, '@set') || - property_exists($rval, '@list')) { - if($count !== 1) { - throw new JsonLdException( - 'Invalid JSON-LD syntax; if an element has the property "@set" ' + - 'or "@list", then it must be its only property.', - 'jsonld.SyntaxError', array('element' => $rval)); - } - // optimize away @set - if(property_exists($rval, '@set')) { - $rval = $rval->{'@set'}; - } - } - // drop objects with only @language - else if(property_exists($rval, '@language') && $count === 1) { - $rval = null; - } + // expand element according to value expansion rules + return $this->_expandValue($active_ctx, $active_property, $element); + } - return $rval; + /** + * Performs JSON-LD flattening. + * + * @param array $input the expanded JSON-LD to flatten. + * + * @return array the flattened output. + */ + protected function _flatten($input) { + // produce a map of all subjects and name each bnode + $namer = new UniqueNamer('_:t'); + $graphs = (object)array('@default' => new stdClass()); + $this->_createNodeMap($input, $graphs, '@default', $namer); + + // add all non-default graphs to default graph + $default_graph = $graphs->{'@default'}; + foreach($graphs as $graph_name => $node_map) { + if($graph_name === '@default') { + continue; + } + if(!property_exists($default_graph, $graph_name)) { + $default_graph->{$graph_name} = (object)array( + '@id' => $graph_name, '@graph' => array()); + } + $subject = $default_graph->{$graph_name}; + if(!property_exists($subject, '@graph')) { + $subject->{'@graph'} = array(); + } + $nodeMap = $graphs->{$graph_name}; + $ids = array_keys((array)$node_map); + sort($ids); + foreach($ids as $id) { + $subject->{'@graph'}[] = $node_map->{$id}; + } + } + + // produce flattened output + $flattened = array(); + $keys = array_keys((array)$default_graph); + sort($keys); + foreach($keys as $key) { + $flattened[] = $default_graph->{$key}; + } + return $flattened; } /** @@ -1597,7 +1978,7 @@ class JsonLdProcessor { * * @param array $input the expanded JSON-LD to frame. * @param array $frame the expanded JSON-LD frame to use. - * @param array $options the framing options. + * @param assoc $options the framing options. * * @return array the framed output. */ @@ -1630,7 +2011,7 @@ class JsonLdProcessor { * @param array $input the expanded JSON-LD object to normalize. * @param assoc $options the normalization options. * - * @return the normalized output. + * @return mixed the normalized output. */ protected function _normalize($input, $options) { // map bnodes to RDF statements @@ -1933,150 +2314,147 @@ class JsonLdProcessor { */ protected function _toRDF( $element, $namer, $subject, $property, $graph, &$statements) { - if(is_object($element)) { - // convert @value to object - if(self::_isValue($element)) { - $value = $element->{'@value'}; - $datatype = (property_exists($element, '@type') ? - $element->{'@type'} : null); - if(is_bool($value) || is_double($value) || is_integer($value)) { - // convert to XSD datatype - if(is_bool($value)) { - $value = ($value ? 'true' : 'false'); - $datatype or $datatype = self::XSD_BOOLEAN; - } - else if(is_double($value)) { - // canonical double representation - $value = preg_replace('/(\d)0*E\+?/', '$1E', - sprintf('%1.15E', $value)); - $datatype or $datatype = self::XSD_DOUBLE; - } - else { - $value = strval($value); - $datatype or $datatype = self::XSD_INTEGER; - } - } - - // default to xsd:string datatype - $datatype or $datatype = self::XSD_STRING; - - $object = (object)array( - 'nominalValue' => $value, - 'interfaceName' => 'LiteralNode', - 'datatype' => (object)array( - 'nominalValue' => $datatype, - 'interfaceName' => 'IRI')); - if(property_exists($element, '@language') && - $datatype === self::XSD_STRING) { - $object->language = $element->{'@language'}; - } - - // emit literal - $statement = (object)array( - 'subject' => self::copy($subject), - 'property' => self::copy($property), - 'object' => $object); - if($graph !== null) { - $statement->name = $graph; - } - JsonLdProcessor::_appendUniqueRdfStatement($statements, $statement); - return; - } - - // convert @list - if(self::_isList($element)) { - $list = $this->_makeLinkedList($element); - $this->_toRDF($list, $namer, $subject, $property, $graph, $statements); - return; - } - - // Note: element must be a subject - - // get subject @id (generate one if it is a bnode) - $id = property_exists($element, '@id') ? $element->{'@id'} : null; - $is_bnode = self::_isBlankNode($element); - if($is_bnode) { - $id = $namer->getName($id); - } - - // create object - $object = (object)array( - 'nominalValue' => $id, - 'interfaceName' => $is_bnode ? 'BlankNode' : 'IRI'); - - // emit statement if subject isn't null - if($subject !== null) { - $statement = (object)array( - 'subject' => self::copy($subject), - 'property' => self::copy($property), - 'object' => self::copy($object)); - if($graph !== null) { - $statement->name = $graph; - } - JsonLdProcessor::_appendUniqueRdfStatement($statements, $statement); - } - - // set new active subject to object - $subject = $object; - - // recurse over subject properties in order - $props = array_keys((array)$element); - sort($props); - foreach($props as $prop) { - $p = $prop; - - // convert @type to rdf:type - if($prop === '@type') { - $p = self::RDF_TYPE; - } - - // recurse into @graph - if($prop === '@graph') { - $this->_toRDF( - $element->{$prop}, $namer, null, null, $subject, $statements); - continue; - } - - // skip keywords - if(self::_isKeyword($p)) { - continue; - } - - // create new active property - $property = (object)array( - 'nominalValue' => $p, - 'interfaceName' => 'IRI'); - - // recurse into value - $this->_toRDF( - $element->{$prop}, $namer, $subject, $property, $graph, $statements); - } - - return; + // recurse into arrays + if(is_array($element)) { + // recurse into arrays + foreach($element as $e) { + $this->_toRDF($e, $namer, $subject, $property, $graph, $statements); + } + return; + } + + // element must be an rdf:type IRI (@values covered above) + if(is_string($element)) { + // emit IRI + $statement = (object)array( + 'subject' => self::copy($subject), + 'property' => self::copy($property), + 'object' => (object)array( + 'nominalValue' => $element, + 'interfaceName' => 'IRI')); + if($graph !== null) { + $statement->name = $graph; + } + self::_appendUniqueRdfStatement($statements, $statement); + return; } - if(is_array($element)) { - // recurse into arrays - foreach($element as $e) { - $this->_toRDF($e, $namer, $subject, $property, $graph, $statements); - } - return; + // convert @list + if(self::_isList($element)) { + $list = $this->_makeLinkedList($element); + $this->_toRDF($list, $namer, $subject, $property, $graph, $statements); + return; } - // element must be an rdf:type IRI (@values covered above) - if(is_string($element)) { - // emit IRI + // convert @value to object + if(self::_isValue($element)) { + $value = $element->{'@value'}; + $datatype = (property_exists($element, '@type') ? + $element->{'@type'} : null); + if(is_bool($value) || is_double($value) || is_integer($value)) { + // convert to XSD datatypes as appropriate + if(is_bool($value)) { + $value = ($value ? 'true' : 'false'); + $datatype or $datatype = self::XSD_BOOLEAN; + } + else if(is_double($value)) { + // canonical double representation + $value = preg_replace('/(\d)0*E\+?/', '$1E', + sprintf('%1.15E', $value)); + $datatype or $datatype = self::XSD_DOUBLE; + } + else { + $value = strval($value); + $datatype or $datatype = self::XSD_INTEGER; + } + } + + // default to xsd:string datatype + $datatype or $datatype = self::XSD_STRING; + + $object = (object)array( + 'nominalValue' => $value, + 'interfaceName' => 'LiteralNode', + 'datatype' => (object)array( + 'nominalValue' => $datatype, + 'interfaceName' => 'IRI')); + if(property_exists($element, '@language') && + $datatype === self::XSD_STRING) { + $object->language = $element->{'@language'}; + } + + // emit literal + $statement = (object)array( + 'subject' => self::copy($subject), + 'property' => self::copy($property), + 'object' => $object); + if($graph !== null) { + $statement->name = $graph; + } + self::_appendUniqueRdfStatement($statements, $statement); + return; + } + + // Note: element must be a subject + + // get subject @id (generate one if it is a bnode) + $id = property_exists($element, '@id') ? $element->{'@id'} : null; + $is_bnode = self::_isBlankNode($element); + if($is_bnode) { + $id = $namer->getName($id); + } + + // create object + $object = (object)array( + 'nominalValue' => $id, + 'interfaceName' => $is_bnode ? 'BlankNode' : 'IRI'); + + // emit statement if subject isn't null + if($subject !== null) { $statement = (object)array( 'subject' => self::copy($subject), 'property' => self::copy($property), - 'object' => (object)array( - 'nominalValue' => $element, - 'interfaceName' => 'IRI')); + 'object' => self::copy($object)); if($graph !== null) { $statement->name = $graph; } - JsonLdProcessor::_appendUniqueRdfStatement($statements, $statement); - return; + self::_appendUniqueRdfStatement($statements, $statement); + } + + // set new active subject to object + $subject = $object; + + // recurse over subject properties in order + $props = array_keys((array)$element); + sort($props); + foreach($props as $prop) { + $p = $prop; + + // convert @type to rdf:type + if($prop === '@type') { + $p = self::RDF_TYPE; + } + + // recurse into @graph + if($prop === '@graph') { + $this->_toRDF( + $element->{$prop}, $namer, null, null, $subject, $statements); + continue; + } + + // skip keywords + if(self::_isKeyword($p)) { + continue; + } + + // create new active property + $property = (object)array( + 'nominalValue' => $p, + 'interfaceName' => 'IRI'); + + // recurse into value + $this->_toRDF( + $element->{$prop}, $namer, $subject, $property, $graph, $statements); } } @@ -2085,13 +2463,25 @@ class JsonLdProcessor { * * @param stdClass $active_ctx the current active context. * @param mixed $local_ctx the local context to process. - * @param array $options the context processing options. + * @param assoc $options the context processing options. * * @return stdClass the new active context. */ protected function _processContext($active_ctx, $local_ctx, $options) { + global $jsonld_cache; + + $rval = null; + + // get context from cache if available + if(property_exists($jsonld_cache, 'activeCtx')) { + $rval = $jsonld_cache->activeCtx->get($active_ctx, $local_ctx); + if($rval) { + return $rval; + } + } + // initialize the resulting context - $rval = self::copy($active_ctx); + $rval = self::_cloneActiveContext($active_ctx); // normalize local context to an array if(is_object($local_ctx) && property_exists($local_ctx, '@context') && @@ -2104,7 +2494,8 @@ class JsonLdProcessor { foreach($ctxs as $ctx) { // reset to initial context if($ctx === null) { - $rval = $this->_getInitialContext(); + $rval = $this->_getInitialContext($options); + $rval->namer = $active_ctx->namer; continue; } @@ -2122,64 +2513,198 @@ class JsonLdProcessor { // define context mappings for keys in local context $defined = new stdClass(); - foreach($ctx as $k => $v) { - $this->_defineContextMapping( - $rval, $ctx, $k, $options['base'], $defined); + + // handle @vocab + if(property_exists($ctx, '@vocab')) { + $value = $ctx->{'@vocab'}; + if($value === null) { + unset($rval->{'@vocab'}); + } + else if(!is_string($value)) { + throw new JsonLdException( + 'Invalid JSON-LD syntax; the value of "@vocab" in a ' . + '@context must be a string or null.', + 'jsonld.SyntaxError', array('context' => $ctx)); + } + else if(!self::_isAbsoluteIri($value)) { + throw new JsonLdException( + 'Invalid JSON-LD syntax; the value of "@vocab" in a ' . + '@context must be an absolute IRI.', + 'jsonld.SyntaxError', array('context' => $ctx)); + } + else { + $rval->{'@vocab'} = $value; + } + $defined->{'@vocab'} = true; } + + // handle @language + if(property_exists($ctx, '@language')) { + $value = $ctx->{'@language'}; + if($value === null) { + unset($rval->{'@language'}); + } + else if(!is_string($value)) { + throw new JsonLdException( + 'Invalid JSON-LD syntax; the value of "@language" in a ' . + '@context must be a string or null.', + 'jsonld.SyntaxError', array('context' => $ctx)); + } + else { + $rval->{'@language'} = strtolower($value); + } + $defined->{'@language'} = true; + } + + // process all other keys + foreach($ctx as $k => $v) { + $this->_createTermDefinition($rval, $ctx, $k, $defined); + } + } + + // cache result + if(property_exists($jsonld_cache, 'activeCtx')) { + $jsonld_cache->activeCtx->set($active_ctx, $local_ctx, $rval); } return $rval; } + /** + * Expands a language map. + * + * @param stdClass $language_map the language map to expand. + * + * @return array the expanded language map. + */ + protected function _expandLanguageMap($language_map) { + $rval = array(); + $keys = array_keys((array)$language_map); + sort($keys); + foreach($keys as $key) { + $values = $language_map->{$key}; + $values = self::arrayify($values); + foreach($values as $item) { + if(!is_string($item)) { + throw new JsonLdException( + 'Invalid JSON-LD syntax; language map values must be strings.', + 'jsonld.SyntaxError', array('languageMap', $language_map)); + } + $rval[] = (object)array( + '@value' => $item, + '@language' => strtolower($key)); + } + } + return $rval; + } + + /** + * Labels the blank nodes in the given value using the given UniqueNamer. + * + * @param UniqueNamer $namer the UniqueNamer to use. + * @param mixed $element the element with blank nodes to rename. + * @param bool $is_id true if the given element is an @id (or @type). + * + * @return mixed the element. + */ + protected function _labelBlankNodes($namer, $element, $is_id=false) { + if(is_array($element)) { + for($i = 0; $i < count($element); ++$i) { + $element[$i] = $this->_labelBlankNodes($namer, $element[$i], $is_id); + } + } + else if(self::_isList($element)) { + $element->{'@list'} = $this->_labelBlankNodes( + $namer, $element->{'@list'}, $is_id); + } + else if(is_object($element)) { + // rename blank node + if(self::_isBlankNode($element)) { + $name = null; + if(property_exists($element, '@id')) { + $name = $element->{'@id'}; + } + $element->{'@id'} = $namer->getName($name); + } + + // recursively apply to all keys + $keys = array_keys((array)$element); + sort($keys); + foreach($keys as $key) { + if($key !== '@id') { + $element->{$key} = $this->_labelBlankNodes( + $namer, $element->{$key}, $key === '@type'); + } + } + } + // rename blank node identifier + else if(is_string($element) && $is_id && strpos($element, '_:') === 0) { + $element = $namer->getName($element); + } + + return $element; + } + /** * Expands the given value by using the coercion and keyword rules in the * given context. * - * @param stdClass $ctx the active context to use. - * @param string $property the property the value is associated with. + * @param stdClass $active_ctx the active context to use. + * @param string $active_property the property the value is associated with. * @param mixed $value the value to expand. - * @param string $base the base IRI to use. * * @return mixed the expanded value. */ - protected function _expandValue($ctx, $property, $value, $base) { + protected function _expandValue($active_ctx, $active_property, $value) { // nothing to expand if($value === null) { return null; } - // default to simple string return value - $rval = $value; - // special-case expand @id and @type (skips '@id' expansion) - $prop = $this->_expandTerm($ctx, $property); - if($prop === '@id' || $prop === '@type') { - $rval = $this->_expandTerm($ctx, $value, $base); + $expanded_property = $this->_expandIri( + $active_ctx, $active_property, array('vocab' => true)); + if($expanded_property === '@id') { + return $this->_expandIri($active_ctx, $value, array('base' => true)); + } + else if($expanded_property === '@type') { + return $this->_expandIri( + $active_ctx, $value, array('vocab' => true, 'base' => true)); } - else { - // get type definition from context - $type = self::getContextValue($ctx, $property, '@type'); - // do @id expansion (automatic for @graph) - if($type === '@id' || $prop === '@graph') { - $rval = (object)array('@id' => $this->_expandTerm($ctx, $value, $base)); + // get type definition from context + $type = self::getContextValue($active_ctx, $active_property, '@type'); + + // do @id expansion (automatic for @graph) + if($type === '@id' || $expanded_property === '@graph') { + return (object)array('@id' => $this->_expandIri( + $active_ctx, $value, array('base' => true))); + } + + // do not expand keyword values + if(self::_isKeyword($expanded_property)) { + return $value; + } + + $rval = new stdClass(); + + // other type + if($type !== null) { + // rename blank node if requested + if($active_ctx->namer !== null && strpos($type, '_:') === 0) { + $type = $active_ctx->namer->getName($type); } - else if(!self::_isKeyword($prop)) { - $rval = (object)array('@value' => $value); - - // other type - if($type !== null) { - $rval->{'@type'} = $type; - } - // check for language tagging - else { - $language = self::getContextValue($ctx, $property, '@language'); - if($language !== null) { - $rval->{'@language'} = $language; - } - } + $rval->{'@type'} = $type; + } + // check for language tagging for strings + else if(is_string($value)) { + $language = self::getContextValue( + $active_ctx, $active_property, '@language'); + if($language !== null) { + $rval->{'@language'} = $language; } } + $rval->{'@value'} = $value; return $rval; } @@ -2230,8 +2755,10 @@ class JsonLdProcessor { $rval->{'@value'} = doubleval($rval->{'@value'}); } } - // do not add xsd:string type - if($type !== self::XSD_STRING) { + // do not add native type + if(!in_array($type, array( + self::XSD_BOOLEAN, self::XSD_INTEGER, self::XSD_DOUBLE, + self::XSD_STRING))) { $rval->{'@type'} = $type; } } @@ -2257,10 +2784,8 @@ class JsonLdProcessor { * @return stdClass the head of the linked list of blank nodes. */ protected function _makeLinkedList($value) { - // convert @list array into embedded blank node linked list + // convert @list array into embedded blank node linked list in reverse $list = $value->{'@list'}; - - // build linked list in reverse $len = count($list); $tail = (object)array('@id' => self::RDF_NIL); for($i = $len - 1; $i >= 0; --$i) { @@ -2268,196 +2793,12 @@ class JsonLdProcessor { self::RDF_FIRST => array($list[$i]), self::RDF_REST => array($tail)); } - return $tail; } /** - * Hashes all of the statements about a blank node. - * - * @param string $id the ID of the bnode to hash statements for. - * @param stdClass $bnodes the mapping of bnodes to statements. - * @param UniqueNamer $namer the canonical bnode namer. - * - * @return string the new hash. - */ - protected function _hashStatements($id, $bnodes, $namer) { - // return cached hash - if(property_exists($bnodes->{$id}, 'hash')) { - return $bnodes->{$id}->hash; - } - - // serialize all of bnode's statements - $statements = $bnodes->{$id}->statements; - $nquads = array(); - foreach($statements as $statement) { - $nquads[] = $this->toNQuad($statement, $id); - } - - // sort serialized quads - sort($nquads); - - // cache and return hashed quads - $hash = $bnodes->{$id}->hash = sha1(implode($nquads)); - return $hash; - } - - /** - * Produces a hash for the paths of adjacent bnodes for a bnode, - * incorporating all information about its subgraph of bnodes. This - * method will recursively pick adjacent bnode permutations that produce the - * lexicographically-least 'path' serializations. - * - * @param string $id the ID of the bnode to hash paths for. - * @param stdClass $bnodes the map of bnode statements. - * @param UniqueNamer $namer the canonical bnode namer. - * @param UniqueNamer $path_namer the namer used to assign names to adjacent - * bnodes. - * - * @return stdClass the hash and path namer used. - */ - protected function _hashPaths($id, $bnodes, $namer, $path_namer) { - // create SHA-1 digest - $md = hash_init('sha1'); - - // group adjacent bnodes by hash, keep properties and references separate - $groups = new stdClass(); - $statements = $bnodes->{$id}->statements; - foreach($statements as $statement) { - // get adjacent bnode - $bnode = $this->_getAdjacentBlankNodeName($statement->subject, $id); - if($bnode !== null) { - $direction = 'p'; - } - else { - $bnode = $this->_getAdjacentBlankNodeName($statement->object, $id); - if($bnode !== null) { - $direction = 'r'; - } - } - if($bnode !== null) { - // get bnode name (try canonical, path, then hash) - if($namer->isNamed($bnode)) { - $name = $namer->getName($bnode); - } - else if($path_namer->isNamed($bnode)) { - $name = $path_namer->getName($bnode); - } - else { - $name = $this->_hashStatements($bnode, $bnodes, $namer); - } - - // hash direction, property, and bnode name/hash - $group_md = hash_init('sha1'); - hash_update($group_md, $direction); - hash_update($group_md, $statement->property->nominalValue); - hash_update($group_md, $name); - $group_hash = hash_final($group_md); - - // add bnode to hash group - if(property_exists($groups, $group_hash)) { - $groups->{$group_hash}[] = $bnode; - } - else { - $groups->{$group_hash} = array($bnode); - } - } - } - - // iterate over groups in sorted hash order - $group_hashes = array_keys((array)$groups); - sort($group_hashes); - foreach($group_hashes as $group_hash) { - // digest group hash - hash_update($md, $group_hash); - - // choose a path and namer from the permutations - $chosen_path = null; - $chosen_namer = null; - $permutator = new Permutator($groups->{$group_hash}); - while($permutator->hasNext()) { - $permutation = $permutator->next(); - $path_namer_copy = clone $path_namer; - - // build adjacent path - $path = ''; - $skipped = false; - $recurse = array(); - foreach($permutation as $bnode) { - // use canonical name if available - if($namer->isNamed($bnode)) { - $path .= $namer->getName($bnode); - } - else { - // recurse if bnode isn't named in the path yet - if(!$path_namer_copy->isNamed($bnode)) { - $recurse[] = $bnode; - } - $path .= $path_namer_copy->getName($bnode); - } - - // skip permutation if path is already >= chosen path - if($chosen_path !== null && strlen($path) >= strlen($chosen_path) && - $path > $chosen_path) { - $skipped = true; - break; - } - } - - // recurse - if(!$skipped) { - foreach($recurse as $bnode) { - $result = $this->_hashPaths( - $bnode, $bnodes, $namer, $path_namer_copy); - $path .= $path_namer_copy->getName($bnode); - $path .= "<{$result->hash}>"; - $path_namer_copy = $result->pathNamer; - - // skip permutation if path is already >= chosen path - if($chosen_path !== null && - strlen($path) >= strlen($chosen_path) && $path > $chosen_path) { - $skipped = true; - break; - } - } - } - - if(!$skipped && ($chosen_path === null || $path < $chosen_path)) { - $chosen_path = $path; - $chosen_namer = $path_namer_copy; - } - } - - // digest chosen path and update namer - hash_update($md, $chosen_path); - $path_namer = $chosen_namer; - } - - // return SHA-1 hash and path namer - return (object)array( - 'hash' => hash_final($md), 'pathNamer' => $path_namer); - } - - /** - * A helper function that gets the blank node name from an RDF statement - * node (subject or object). If the node is not a blank node or its - * nominal value does not match the given blank node ID, it will be - * returned. - * - * @param stdClass $node the RDF statement node. - * @param string $id the ID of the blank node to look next to. - * - * @return mixed the adjacent blank node name or null if none was found. - */ - protected function _getAdjacentBlankNodeName($node, $id) { - if($node->interfaceName === 'BlankNode' && $node->nominalValue !== $id) { - return $node->nominalValue; - } - return null; - } - - /** - * Recursively flattens the subjects in the given JSON-LD expanded input. + * Recursively flattens the subjects in the given JSON-LD expanded input + * into a node map. * * @param mixed $input the JSON-LD expanded input. * @param stdClass $graphs a map of graph name to subject map. @@ -2466,16 +2807,17 @@ class JsonLdProcessor { * @param mixed $name the name assigned to the current input if it is a bnode. * @param mixed $list the list to append to, null for none. */ - protected function _flatten($input, $graphs, $graph, $namer, $name, $list) { + protected function _createNodeMap( + $input, $graphs, $graph, $namer, $name=null, $list=null) { // recurse through array if(is_array($input)) { foreach($input as $e) { - $this->_flatten($e, $graphs, $graph, $namer, null, $list); + $this->_createNodeMap($e, $graphs, $graph, $namer, null, $list); } return; } - // add non-object or value + // add non-object to list if(!is_object($input) || self::_isValue($input)) { if($list !== null) { $list[] = $input; @@ -2483,6 +2825,26 @@ class JsonLdProcessor { return; } + // add entries for @type + if(property_exists($input, '@type')) { + $types = $input->{'@type'}; + $types = self::arrayify($types); + foreach($types as $type) { + $id = (strpos($type, '_:') === 0) ? $namer->getName($type) : $type; + if(!property_exists($graphs->{$graph}, $id)) { + $graphs->{$graph}->{$id} = (object)array('@id' => $id); + } + } + } + + // add add values to list + if(self::_isValue($input)) { + if($list !== null) { + $list[] = $input; + } + return; + } + // Note: At this point, input must be a subject. // get name for subject @@ -2510,30 +2872,39 @@ class JsonLdProcessor { } $subject = $subjects->{$name}; $subject->{'@id'} = $name; - foreach($input as $prop => $objects) { + $properties = array_keys((array)$input); + sort($properties); + foreach($properties as $property) { // skip @id - if($prop === '@id') { + if($property === '@id') { continue; } // recurse into graph - if($prop === '@graph') { + if($property === '@graph') { // add graph subjects map entry if(!property_exists($graphs, $name)) { $graphs->{$name} = new stdClass(); } $g = ($graph === '@merged') ? $graph : $name; - $this->_flatten($objects, $graphs, $g, $namer, null, null); + $this->_createNodeMap( + $input->{$property}, $graphs, $g, $namer, null, null); continue; } // copy non-@type keywords - if($prop !== '@type' && self::_isKeyword($prop)) { - $subject->{$prop} = $objects; + if($property !== '@type' && self::_isKeyword($property)) { + $subject->{$property} = $input->{$property}; continue; } // iterate over objects + $objects = $input->{$property}; + if(count($objects) === 0) { + self::addValue( + $subject, $property, array(), array('propertyIsArray' => true)); + continue; + } foreach($objects as $o) { // handle embedded subject or subject reference if(self::_isSubject($o) || self::_isSubjectReference($o)) { @@ -2545,25 +2916,24 @@ class JsonLdProcessor { // add reference and recurse self::addValue( - $subject, $prop, (object)array('@id' => $id), + $subject, $property, (object)array('@id' => $id), array('propertyIsArray' => true)); - $this->_flatten($o, $graphs, $graph, $namer, $id, null); + $this->_createNodeMap($o, $graphs, $graph, $namer, $id, null); } + // handle $list + else if(self::_isList($o)) { + $_list = new ArrayObject(); + $this->_createNodeMap( + $o->{'@list'}, $graphs, $graph, $namer, $name, $_list); + $o = (object)array('@list' => (array)$_list); + self::addValue( + $subject, $property, $o, array('propertyIsArray' => true)); + } + // handle @value else { - // recurse into list - if(self::_isList($o)) { - $_list = new ArrayObject(); - $this->_flatten( - $o->{'@list'}, $graphs, $graph, $namer, $name, $_list); - $o = (object)array('@list' => (array)$_list); - } - // special-handle @type blank nodes - else if($prop === '@type' && strpos($o, '_:') === 0) { - $o = $namer->getName($o); - } - - // add non-subject - self::addValue($subject, $prop, $o, array('propertyIsArray' => true)); + $this->_createNodeMap($o, $graphs, $graph, $namer, $name, null); + self::addValue( + $subject, $property, $o, array('propertyIsArray' => true)); } } } @@ -2991,6 +3361,190 @@ class JsonLdProcessor { return $input; } + /** + * Hashes all of the statements about a blank node. + * + * @param string $id the ID of the bnode to hash statements for. + * @param stdClass $bnodes the mapping of bnodes to statements. + * @param UniqueNamer $namer the canonical bnode namer. + * + * @return string the new hash. + */ + protected function _hashStatements($id, $bnodes, $namer) { + // return cached hash + if(property_exists($bnodes->{$id}, 'hash')) { + return $bnodes->{$id}->hash; + } + + // serialize all of bnode's statements + $statements = $bnodes->{$id}->statements; + $nquads = array(); + foreach($statements as $statement) { + $nquads[] = $this->toNQuad($statement, $id); + } + + // sort serialized quads + sort($nquads); + + // cache and return hashed quads + $hash = $bnodes->{$id}->hash = sha1(implode($nquads)); + return $hash; + } + + /** + * Produces a hash for the paths of adjacent bnodes for a bnode, + * incorporating all information about its subgraph of bnodes. This + * method will recursively pick adjacent bnode permutations that produce the + * lexicographically-least 'path' serializations. + * + * @param string $id the ID of the bnode to hash paths for. + * @param stdClass $bnodes the map of bnode statements. + * @param UniqueNamer $namer the canonical bnode namer. + * @param UniqueNamer $path_namer the namer used to assign names to adjacent + * bnodes. + * + * @return stdClass the hash and path namer used. + */ + protected function _hashPaths($id, $bnodes, $namer, $path_namer) { + // create SHA-1 digest + $md = hash_init('sha1'); + + // group adjacent bnodes by hash, keep properties and references separate + $groups = new stdClass(); + $statements = $bnodes->{$id}->statements; + foreach($statements as $statement) { + // get adjacent bnode + $bnode = $this->_getAdjacentBlankNodeName($statement->subject, $id); + if($bnode !== null) { + $direction = 'p'; + } + else { + $bnode = $this->_getAdjacentBlankNodeName($statement->object, $id); + if($bnode !== null) { + $direction = 'r'; + } + } + if($bnode !== null) { + // get bnode name (try canonical, path, then hash) + if($namer->isNamed($bnode)) { + $name = $namer->getName($bnode); + } + else if($path_namer->isNamed($bnode)) { + $name = $path_namer->getName($bnode); + } + else { + $name = $this->_hashStatements($bnode, $bnodes, $namer); + } + + // hash direction, property, and bnode name/hash + $group_md = hash_init('sha1'); + hash_update($group_md, $direction); + hash_update($group_md, $statement->property->nominalValue); + hash_update($group_md, $name); + $group_hash = hash_final($group_md); + + // add bnode to hash group + if(property_exists($groups, $group_hash)) { + $groups->{$group_hash}[] = $bnode; + } + else { + $groups->{$group_hash} = array($bnode); + } + } + } + + // iterate over groups in sorted hash order + $group_hashes = array_keys((array)$groups); + sort($group_hashes); + foreach($group_hashes as $group_hash) { + // digest group hash + hash_update($md, $group_hash); + + // choose a path and namer from the permutations + $chosen_path = null; + $chosen_namer = null; + $permutator = new Permutator($groups->{$group_hash}); + while($permutator->hasNext()) { + $permutation = $permutator->next(); + $path_namer_copy = clone $path_namer; + + // build adjacent path + $path = ''; + $skipped = false; + $recurse = array(); + foreach($permutation as $bnode) { + // use canonical name if available + if($namer->isNamed($bnode)) { + $path .= $namer->getName($bnode); + } + else { + // recurse if bnode isn't named in the path yet + if(!$path_namer_copy->isNamed($bnode)) { + $recurse[] = $bnode; + } + $path .= $path_namer_copy->getName($bnode); + } + + // skip permutation if path is already >= chosen path + if($chosen_path !== null && strlen($path) >= strlen($chosen_path) && + $path > $chosen_path) { + $skipped = true; + break; + } + } + + // recurse + if(!$skipped) { + foreach($recurse as $bnode) { + $result = $this->_hashPaths( + $bnode, $bnodes, $namer, $path_namer_copy); + $path .= $path_namer_copy->getName($bnode); + $path .= "<{$result->hash}>"; + $path_namer_copy = $result->pathNamer; + + // skip permutation if path is already >= chosen path + if($chosen_path !== null && + strlen($path) >= strlen($chosen_path) && $path > $chosen_path) { + $skipped = true; + break; + } + } + } + + if(!$skipped && ($chosen_path === null || $path < $chosen_path)) { + $chosen_path = $path; + $chosen_namer = $path_namer_copy; + } + } + + // digest chosen path and update namer + hash_update($md, $chosen_path); + $path_namer = $chosen_namer; + } + + // return SHA-1 hash and path namer + return (object)array( + 'hash' => hash_final($md), 'pathNamer' => $path_namer); + } + + /** + * A helper function that gets the blank node name from an RDF statement + * node (subject or object). If the node is not a blank node or its + * nominal value does not match the given blank node ID, it will be + * returned. + * + * @param stdClass $node the RDF statement node. + * @param string $id the ID of the blank node to look next to. + * + * @return mixed the adjacent blank node name or null if none was found. + */ + protected function _getAdjacentBlankNodeName($node, $id) { + if($node->interfaceName === 'BlankNode' && $node->nominalValue !== $id) { + return $node->nominalValue; + } + return null; + } + /** * Compares two strings first based on length and then lexicographically. * @@ -3000,107 +3554,112 @@ class JsonLdProcessor { * @return integer -1 if a < b, 1 if a > b, 0 if a == b. */ protected function _compareShortestLeast($a, $b) { - if(strlen($a) < strlen($b)) { + $len_a = strlen($a); + $len_b = strlen($b); + if($len_a < $len_b) { return -1; } - else if(strlen($b) < strlen($a)) { + else if($len_b < $len_a) { return 1; } - return ($a < $b) ? -1 : (($a > $b) ? 1 : 0); + else if($a === $b) { + return 0; + } + return ($a < $b) ? -1 : 1; } /** - * Ranks a term that is possible choice for compacting an IRI associated with - * the given value. + * Picks the preferred compaction term from the given inverse context entry. * - * @param stdClass $ctx the active context. - * @param string $term the term to rank. - * @param mixed $value the associated value. + * @param active_ctx the active context. + * @param iri the IRI to pick the term for. + * @param value the value to pick the term for. + * @param parent the parent of the value (required for property generators). + * @param containers the preferred containers. + * @param type_or_language either '@type' or '@language'. + * @param type_or_language_value the preferred value for '@type' or + * '@language'. * - * @return integer the term rank. + * @return mixed the preferred term. */ - protected function _rankTerm($ctx, $term, $value) { - // no term restrictions for a null value - if($value === null) { - return 3; + protected function _selectTerm( + $active_ctx, $iri, $value, $parent, $containers, + $type_or_language, $type_or_language_value) { + $containers[] = '@none'; + if($type_or_language_value === null) { + $type_or_language_value = '@null'; } - - // get context entry for term - $entry = $ctx->mappings->{$term}; - $has_type = property_exists($entry, '@type'); - $has_language = property_exists($entry, '@language'); - $has_default_language = property_exists($ctx, '@language'); - - // @list rank is the sum of its values' ranks - if(self::_isList($value)) { - $list = $value->{'@list'}; - if(count($list) === 0) { - return ($entry->{'@container'} === '@list') ? 1 : 0; + // options for the value of @type or @language + $options = array($type_or_language_value, '@none'); + $term = null; + $container_map = $active_ctx->inverse->{$iri}; + foreach($containers as $container) { + if($term !== null) { + break; } - // sum term ranks for each list value - $sum = 0; - foreach($list as $v) { - $sum += $this->_rankTerm($ctx, $term, $v); + + // if container not available in the map, continue + if(!property_exists($container_map, $container)) { + continue; } - return $sum; - } - // Note: Value must be an object that is a @value or subject/reference. - - if(self::_isValue($value)) { - // value has a @type - if(property_exists($value, '@type')) { - // @types match - if($has_type && $value->{'@type'} === $entry->{'@type'}) { - return 3; + $type_or_language_value_map = + $container_map->{$container}->{$type_or_language}; + foreach($options as $option) { + if($term !== null) { + break; } - return (!$has_type && !$has_language) ? 1 : 0; - } - // rank non-string value - if(!is_string($value->{'@value'})) { - return (!$has_type && !$has_language) ? 2 : 1; - } + // if type/language option not available in the map, continue + if(!property_exists($type_or_language_value_map, $option)) { + continue; + } - // value has no @type or @language - if(!property_exists($value, '@language')) { - // entry @language is specifically null or no @type, @language, or - // default - if(($has_language && $entry->{'@language'} === null) || - (!$has_type && !$has_language && !$has_default_language)) { - return 3; - } - return 0; - } + $term_info = $type_or_language_value_map->{$option}; - // @languages match or entry has no @type or @language but default - // @language matches - if(($has_language && $value->{'@language'} === $entry->{'@language'}) || - (!$has_type && !$has_language && $has_default_language && - $value->{'@language'} === $ctx->{'@language'})) { - return 3; + // see if a property generator matches + if(is_object($parent)) { + foreach($term_info->propertyGenerators as $property_generator) { + $iris = $active_ctx->mappings->{$property_generator}->{'@id'}; + $match = true; + foreach($iris as $iri_) { + if(!property_exists($parent, $iri_)) { + $match = false; + break; + } + } + if($match) { + $term = $property_generator; + break; + } + } + } + + // no matching property generator, use a simple term instead + if($term === null) { + $term = $term_info->term; + } } - return (!$has_type && !$has_language) ? 1 : 0; } - - // value must be a subject/reference - if($has_type && $entry->{'@type'} === '@id') { - return 3; - } - return (!$has_type && !$has_language) ? 1 : 0; + return $term; } /** * Compacts an IRI or keyword into a term or prefix if it can be. If the * IRI has an associated value it may be passed. * - * @param stdClass $ctx the active context to use. + * @param stdClass $active_ctx the active context to use. * @param string $iri the IRI to compact. * @param mixed $value the value to check or null. + * @param assoc $relative_to options for how to compact IRIs: + * base: true to compact against the base IRI, false not to. + * vocab: true to split after @vocab, false not to. + * @param mixed $parent the parent element for the value. * * @return string the compacted term, prefix, keyword alias, or original IRI. */ - protected function _compactIri($ctx, $iri, $value=null) { + protected function _compactIri( + $active_ctx, $iri, $value=null, $relative_to=array(), $parent=null) { // can't compact null if($iri === null) { return $iri; @@ -3109,219 +3668,355 @@ class JsonLdProcessor { // term is a keyword if(self::_isKeyword($iri)) { // return alias if available - $aliases = $ctx->keywords->{$iri}; - if(count($aliases) > 0) { - return $aliases[0]; + $aliases = $active_ctx->keywords->{$iri}; + return (count($aliases) > 0) ? $aliases[0] : $iri; + } + + // use inverse context to pick a term + $inverse_ctx = $this->_getInverseContext($active_ctx); + + $default_language = '@none'; + if(property_exists($active_ctx, '@language')) { + $default_language = $active_ctx->{'@language'}; + } + + if(property_exists($inverse_ctx, $iri)) { + // prefer @index if available in value + $containers = array(); + if(is_object($value) && property_exists($value, '@index')) { + $containers[] = '@index'; + } + + // defaults for term selection based on type/language + $type_or_language = '@language'; + $type_or_language_value = '@null'; + + // choose the most specific term that works for all elements in @list + if(self::_isList($value)) { + // only select @list containers if @index is NOT in value + if(!property_exists($value, '@index')) { + $containers[] = '@list'; + } + $list = $value->{'@list'}; + $common_language = (count($list) === 0) ? $default_language : null; + $common_type = null; + foreach($list as $item) { + $item_language = '@none'; + $item_type = '@none'; + if(self::_isValue($item)) { + if(property_exists($item, '@language')) { + $item_language = $item->{'@language'}; + } + else if(property_exists($item, '@type')) { + $item_type = $item->{'@type'}; + } + // plain literal + else { + $item_language = '@null'; + } + } + if($common_language === null) { + $common_language = $item_language; + } + else if($item_language !== $common_language && + self::_isValue($item)) { + $common_language = '@none'; + } + if($common_type === null) { + $common_type = $item_type; + } + else if($item_type !== $common_type) { + $common_type = '@none'; + } + // there are different languages and types in the list, so choose + // the most generic term, no need to keep iterating the list + if($common_language === '@none' && $common_type === '@none') { + break; + } + } + $common_language = $common_language || '@none'; + $common_type = $common_type || '@none'; + if($common_type !== '@none') { + $type_or_language = '@type'; + $type_or_language_value = $common_type; + } + else { + $type_or_language_value = $common_language; + } } else { - // no alias, keep original keyword - return $iri; - } - } - - // find all possible term matches - $terms = array(); - $highest = 0; - $list_container = false; - $is_list = self::_isList($value); - foreach($ctx->mappings as $term => $entry) { - $has_container = property_exists($entry, '@container'); - - // skip terms with non-matching iris - if($entry->{'@id'} !== $iri) { - continue; - } - // skip @set containers for @lists - if($is_list && $has_container && $entry->{'@container'} === '@set') { - continue; - } - // skip @list containers for non-@lists - if(!$is_list && $has_container && $entry->{'@container'} === '@list' && - $value !== null) { - continue; - } - // for @lists, if list_container is set, skip non-list containers - if($is_list && $list_container && (!$has_container || - $entry->{'@container'} !== '@list')) { - continue; - } - - // rank term - $rank = $this->_rankTerm($ctx, $term, $value); - if($rank > 0) { - // add 1 to rank if container is a @set - if($has_container && $entry->{'@container'} === '@set') { - $rank += 1; - } - - // for @lists, give preference to @list containers - if($is_list && !$list_container && $has_container && - $entry->{'@container'} === '@list') { - $list_container = true; - $terms = array(); - $highest = $rank; - $terms[] = $term; - } - // only push match if rank meets current threshold - else if($rank >= $highest) { - if($rank > $highest) { - $terms = array(); - $highest = $rank; + if(self::_isValue($value)) { + if(property_exists($value, '@language')) { + $containers[] = '@language'; + $type_or_language_value = $value->{'@language'}; + } + else if(property_exists($value, '@type')) { + $type_or_language = '@type'; + $type_or_language_value = $value->{'@type'}; } - $terms[] = $term; } + else { + $type_or_language = '@type'; + $type_or_language_value = '@id'; + } + $containers[] = '@set'; + } + + // do term selection + $term = $this->_selectTerm( + $active_ctx, $iri, $value, $parent, + $containers, $type_or_language, $type_or_language_value); + if($term !== null) { + return $term; } } - // no matching terms, use @vocab if available - if(count($terms) === 0 && property_exists($ctx, '@vocab') && - $ctx->{'@vocab'} !== null) { - // determine if vocab is a prefix of the iri - $vocab = $ctx->{'@vocab'}; - if(strpos($iri, $vocab) === 0) { - // use suffix as relative iri if it is not a term in the active context - $suffix = substr($iri, strlen($vocab)); - if(!property_exists($ctx->mappings, $suffix)) { + // no term match, check for possible CURIEs + $choice = null; + foreach($active_ctx->mappings as $term => $definition) { + // skip terms with colons, they can't be prefixes + if(strpos($term, ':') !== false) { + continue; + } + // skip entries with @ids that are not partial matches + if($definition === null || $definition->propertyGenerator || + $definition->{'@id'} === $iri || + strpos($iri, $definition->{'@id'}) !== 0) { + continue; + } + + // a CURIE is usable if: + // 1. it has no mapping, OR + // 2. value is null, which means we're not compacting an @value, AND + // the mapping matches the IRI) + $curie = $term . ':' . substr($iri, strlen($definition->{'@id'})); + $is_usable_curie = (!property_exists($active_ctx->mappings, $curie) || + ($value === null && $active_ctx->mappings->{$curie} && + $active_ctx->mappings->{$curie}->{'@id'} === $iri)); + + // select curie if it is shorter or the same length but lexicographically + // less than the current choice + if($is_usable_curie && ($choice === null || + self::_compareShortestLeast($curie, $choice) < 0)) { + $choice = $curie; + } + } + + // return chosen curie + if($choice !== null) { + return $choice; + } + + // no matching terms or curies, use @vocab if available + if(isset($relative_to['vocab']) && $relative_to['vocab'] && + property_exists($active_ctx, '@vocab')) { + // determine if vocab is a prefix of the iri + $vocab = $active_ctx->{'@vocab'}; + if(strpos($iri, $vocab) === 0 && $iri !== $vocab) { + // use suffix as relative iri if it is not a term in the active context + $suffix = substr($iri, strlen($vocab)); + if(!property_exists($active_ctx->mappings, $suffix)) { return $suffix; } } } - // no term matches, add possible CURIEs - if(count($terms) === 0) { - foreach($ctx->mappings as $term => $entry) { - // skip terms with colons, they can't be prefixes - if(strpos($term, ':') !== false) { - continue; - } - // skip entries with @ids that are not partial matches - if($entry->{'@id'} === $iri || strpos($iri, $entry->{'@id'}) !== 0) { - continue; - } - - // add CURIE as term if it has no mapping - $curie = $term . ':' . substr($iri, strlen($entry->{'@id'})); - if(!property_exists($ctx->mappings, $curie)) { - $terms[] = $curie; - } - } - } - - // no matching terms - if(count($terms) === 0) { - // use iri - return $iri; - } - - // return shortest and lexicographically-least term - usort($terms, array($this, '_compareShortestLeast')); - return $terms[0]; + // no compaction choices, return IRI as is + return $iri; } /** - * Defines a context mapping during context processing. + * Performs value compaction on an object with '@value' or '@id' as the only + * property. + * + * @param stdClass $active_ctx the active context. + * @param string $active_property the active property that points to the + * value. + * @param mixed $value the value to compact. + * + * @return mixed the compaction result. + */ + protected function _compactValue($active_ctx, $active_property, $value) { + // value is a @value + if(self::_isValue($value)) { + // get context rules + $type = self::getContextValue($active_ctx, $active_property, '@type'); + $language = self::getContextValue( + $active_ctx, $active_property, '@language'); + $container = self::getContextValue( + $active_ctx, $active_property, '@container'); + + // whether or not the value has an @index that must be preserved + $preserve_index = (property_exists($value, '@index') && + $container !== '@index'); + + // if there's no @index to preserve ... + if(!$preserve_index) { + // matching @type or @language specified in context, compact value + if(self::_hasKeyValue($value, '@type', $type) || + self::_hasKeyValue($value, '@language', $language)) { + return $value->{'@value'}; + } + } + + // return just the value of @value if all are true: + // 1. @value is the only key or @index isn't being preserved + // 2. there is no default language or @value is not a string or + // the key has a mapping with a null @language + $key_count = count(array_keys((array)$value)); + $is_value_only_key = ($key_count === 1 || + ($key_count === 2 && property_exists($value, '@index') && + !$preserve_index)); + $has_default_language = property_exists($active_ctx, '@language'); + $is_value_string = is_string($value->{'@value'}); + $has_null_mapping = ( + property_exists($active_ctx->mappings, $active_property) && + $active_ctx->mappings->{$active_property} !== null && + self::_hasKeyValue( + $active_ctx->mappings->{$active_property}, '@language', null)); + if($is_value_only_key && + (!$has_default_language || !$is_value_string || $has_null_mapping)) { + return $value->{'@value'}; + } + + $rval = new stdClass(); + + // preserve @index + if($preserve_index) { + $rval->{$this->_compactIri($active_ctx, '@index')} = $value->{'@index'}; + } + + // compact @type IRI + if(property_exists($value, '@type')) { + $rval->{$this->_compactIri($active_ctx, '@type')} = $this->_compactIri( + $active_ctx, $value->{'@type'}, null, + array('base' => true, 'vocab' => true)); + } + // alias @language + else if(property_exists($value, '@language')) { + $rval->{$this->_compactIri($active_ctx, '@language')} = + $value->{'@language'}; + } + + // alias @value + $rval->{$this->_compactIri($active_ctx, '@value')} = $value->{'@value'}; + + return $rval; + } + + // value is a subject reference + $expanded_property = $this->_expandIri($active_ctx, $active_property); + $type = self::getContextValue($active_ctx, $active_property, '@type'); + $term = $this->_compactIri( + $active_ctx, $value->{'@id'}, null, array('base' => true)); + + // compact to scalar + if($type === '@id' || $expanded_property === '@graph') { + return $term; + } + + $rval = (object)array( + $this->_compactIri($active_ctx, '@id') => $term); + return $rval; + } + + /** + * Finds and removes any duplicate values that were presumably generated by + * a property generator in the given element. + * + * @param stdClass $active_ctx the active context. + * @param stdClass $element the element to remove duplicates from. + * @param string $expanded_property the property to map to a property + * generator. + * @param mixed $value the value to compare against when duplicate checking. + * @param string $active_property the property generator term. + */ + protected function _findAndRemovePropertyGeneratorDuplicates( + $active_ctx, $element, $expanded_property, $value, $active_property) { + // get property generator IRIs + $iris = $active_ctx->mappings->{$active_property}->{'@id'}; + + // for each IRI that isn't 'expandedProperty', remove a single duplicate + // from element, if found + foreach($iris as $iri) { + if($iri === $expanded_property) { + continue; + } + for($pi = 0; $pi < count($element->{$iri}); ++$pi) { + if(self::compareValues($element->{$iri}[$pi], $value)) { + // duplicate found, remove it in place + array_splice($element->{$iri}, $pi, 1); + if(count($element->{$iri}) === 0) { + unset($element->{$iri}); + } + break; + } + } + } + } + + /** + * Creates a term definition during context processing. * * @param stdClass $active_ctx the current active context. - * @param stdClass $ctx the local context being processed. - * @param string $key the key in the local context to define the mapping for. - * @param string $base the base IRI. + * @param stdClass $local_ctx the local context being processed. + * @param string $term the key in the local context to define the mapping for. * @param stdClass $defined a map of defining/defined keys to detect cycles * and prevent double definitions. */ - protected function _defineContextMapping( - $active_ctx, $ctx, $key, $base, $defined) { - if(property_exists($defined, $key)) { - // key already defined - if($defined->{$key}) { + protected function _createTermDefinition( + $active_ctx, $local_ctx, $term, $defined) { + if(property_exists($defined, $term)) { + // term already defined + if($defined->{$term}) { return; } // cycle detected throw new JsonLdException( 'Cyclical context definition detected.', 'jsonld.CyclicalContext', - (object)array('context' => $ctx, 'key' => $key)); + (object)array('context' => $local_ctx, 'term' => $term)); } - // now defining key - $defined->{$key} = false; + // now defining term + $defined->{$term} = false; - // if key has a prefix, define it first - $colon = strpos($key, ':'); + // if term has a prefix, define it first + $colon = strpos($term, ':'); $prefix = null; if($colon !== false) { - $prefix = substr($key, 0, $colon); - if(property_exists($ctx, $prefix)) { + $prefix = substr($term, 0, $colon); + if(property_exists($local_ctx, $prefix)) { // define parent prefix - $this->_defineContextMapping( - $active_ctx, $ctx, $prefix, $base, $defined); + $this->_createTermDefinition( + $active_ctx, $local_ctx, $prefix, $defined); } } - // get context key value - $value = $ctx->{$key}; - - if(self::_isKeyword($key)) { - // support vocab - if($key === '@vocab') { - if($value !== null && !is_string($value)) { - throw new JsonLdException( - 'Invalid JSON-LD syntax; the value of "@vocab" in a ' + - '@context must be a string or null.', - 'jsonld.SyntaxError', array('context' => $ctx)); - } - if(!self::_isAbsoluteIri($value)) { - throw new JsonLdException( - 'Invalid JSON-LD syntax; the value of "@vocab" in a ' + - '@context must be an absolute IRI.', - 'jsonld.SyntaxError', array('context' => $ctx)); - } - if($value === null) { - unset($active_ctx->{'@vocab'}); - } - else { - $active_ctx->{'@vocab'} = $value; - } - $defined->{$key} = true; - return; - } - - // only @language is permitted - if($key !== '@language') { - throw new JsonLdException( - 'Invalid JSON-LD syntax; keywords cannot be overridden.', - 'jsonld.SyntaxError', array('context' => $ctx)); - } - - if($value !== null && !is_string($value)) { - throw new JsonLdException( - 'Invalid JSON-LD syntax; the value of "@language" in a ' + - '@context must be a string or null.', - 'jsonld.SyntaxError', array('context' => $ctx)); - } - - if($value === null) { - unset($active_ctx->{'@language'}); - } - else { - $active_ctx->{'@language'} = $value; - } - $defined->{$key} = true; - return; + if(self::_isKeyword($term)) { + throw new JsonLdException( + 'Invalid JSON-LD syntax; keywords cannot be overridden.', + 'jsonld.SyntaxError', array('context' => $local_ctx)); } + if(property_exists($active_ctx->mappings, $term) && + $active_ctx->mappings->{$term} !== null) { + // if term is a keyword alias, remove it + $kw = $active_ctx->mappings->{$term}->{'@id'}; + if(self::_isKeyword($kw)) { + array_splice($active_ctx->keywords->{$kw}, + in_array($term, $active_ctx->keywords->{$kw}), 1); + } + } + + // get context term value + $value = $local_ctx->{$term}; + // clear context entry - if($value === null or (is_object($value) && - property_exists($value, '@id') && $value->{'@id'} === null)) { - if(property_exists($active_ctx->mappings, $key)) { - // if key is a keyword alias, remove it - $kw = $active_ctx->mappings->{$key}->{'@id'}; - if(self::_isKeyword($kw)) { - array_splice($active_ctx->keywords->{$kw}, - in_array($key, $active_ctx->keywords->{$kw}), 1); - } - unset($active_ctx->mappings->{$key}); - } - $defined->{$key} = true; + if($value === null || (is_object($value) && + self::_hasKeyValue($value, '@id', null))) { + $active_ctx->mappings->{$term} = null; + $defined->{$term} = true; return; } @@ -3334,69 +4029,113 @@ class JsonLdProcessor { 'jsonld.SyntaxError'); } - // uniquely add key as a keyword alias and resort - if(in_array($key, $active_ctx->keywords->{$value}) === false) { - $active_ctx->keywords->{$value}[] = $key; + // uniquely add term as a keyword alias and resort + if(!in_array($term, $active_ctx->keywords->{$value})) { + $active_ctx->keywords->{$value}[] = $term; usort($active_ctx->keywords->{$value}, array($this, '_compareShortestLeast')); } } else { // expand value to a full IRI - $value = $this->_expandContextIri( - $active_ctx, $ctx, $value, $base, $defined); + $value = $this->_expandIri( + $active_ctx, $value, array('base' => true), $local_ctx, $defined); } - // define/redefine key to expanded IRI/keyword - $active_ctx->mappings->{$key} = (object)array('@id' => $value); - $defined->{$key} = true; + // define/redefine term to expanded IRI/keyword + $active_ctx->mappings->{$term} = (object)array( + '@id' => $value, 'propertyGenerator' => false); + $defined->{$term} = true; return; } if(!is_object($value)) { throw new JsonLdException( - 'Invalid JSON-LD syntax; @context property values must be ' + + 'Invalid JSON-LD syntax; @context property values must be ' . 'strings or objects.', - 'jsonld.SyntaxError', array('context' => $ctx)); + 'jsonld.SyntaxError', array('context' => $local_ctx)); } // create new mapping $mapping = new stdClass(); + $mapping->propertyGenerator = false; + + // merge onto parent mapping if one exists for a prefix + if($prefix !== null && + property_exists($active_ctx->mappings, $prefix) && + $active_ctx->mappings->{$prefix} !== null) { + $mapping = self::copy($active_ctx->mappings->{$prefix}); + } if(property_exists($value, '@id')) { $id = $value->{'@id'}; - if(!is_string($id)) { + // handle property generator + if(is_array($id)) { + if($active_ctx->namer === null) { + throw new JsonLdException( + 'Incompatible JSON-LD options; a property generator was found ' . + 'in the @context, but blank node renaming has been disabled; ' . + 'it must be enabled to use property generators.', + 'jsonld.OptionsError', array('context' => $local_ctx)); + } + + $property_generator = array(); + $ids = $id; + foreach($ids as $id) { + if(!is_string($id)) { + throw new JsonLdException( + 'Invalid JSON-LD syntax; property generators must consist of ' . + 'an @id array containing only strings.', + 'jsonld.SyntaxError', array('context' => $local_ctx)); + } + // expand @id if it is not @type + if($id !== '@type') { + $id = $this->_expandIri( + $active_ctx, $id, array('base' => true), $local_ctx, $defined); + } + $property_generator[] = $id; + } + // add sorted property generator as @id in mapping + sort($property_generator); + $mapping->{'@id'} = $property_generator; + $mapping->propertyGenerator = true; + } + else if(!is_string($id)) { throw new JsonLdException( - 'Invalid JSON-LD syntax; @context @id values must be strings.', - 'jsonld.SyntaxError', array('context' => $ctx)); + 'Invalid JSON-LD syntax; @context @id value must be an array ' . + 'of strings or a string.', + 'jsonld.SyntaxError', array('context' => $local_ctx)); } - - // expand @id if it is not @type - if($id !== '@type') { - // expand @id to full IRI - $id = $this->_expandContextIri( - $active_ctx, $ctx, $id, $base, $defined); + else { + // add @id to mapping, expanding it if it is not @type + if($id !== '@type') { + // expand @id to full IRI + $id = $this->_expandIri( + $active_ctx, $id, array('base' => true), $local_ctx, $defined); + } + $mapping->{'@id'} = $id; } - - // add @id to mapping - $mapping->{'@id'} = $id; } else { - // non-IRIs *must* define @ids if($prefix === null) { - throw new JsonLdException( - 'Invalid JSON-LD syntax; @context terms must define an @id.', - 'jsonld.SyntaxError', array('context' => $ctx, 'key' => $key)); + // non-IRIs *must* define @ids if @vocab is not available + if(!property_exists($active_ctx, '@vocab')) { + throw new JsonLdException( + 'Invalid JSON-LD syntax; @context terms must define an @id.', + 'jsonld.SyntaxError', + array('context' => $local_ctx, 'term' => $term)); + } + // prepend vocab to term + $mapping->{'@id'} = $active_ctx->{'@vocab'} . $term; } - // set @id based on prefix parent - if(property_exists($active_ctx->mappings, $prefix)) { - $suffix = substr($key, $colon + 1); + else if(property_exists($active_ctx->mappings, $prefix)) { + $suffix = substr($term, $colon + 1); $mapping->{'@id'} = $active_ctx->mappings->{$prefix}->{'@id'} . $suffix; } - // key is an absolute IRI + // term is an absolute IRI else { - $mapping->{'@id'} = $key; + $mapping->{'@id'} = $term; } } @@ -3405,13 +4144,14 @@ class JsonLdProcessor { if(!is_string($type)) { throw new JsonLdException( 'Invalid JSON-LD syntax; @context @type values must be strings.', - 'jsonld.SyntaxError', array('context' => $ctx)); + 'jsonld.SyntaxError', array('context' => $local_ctx)); } if($type !== '@id') { // expand @type to full IRI - $type = $this->_expandContextIri( - $active_ctx, $ctx, $type, '', $defined); + $type = $this->_expandIri( + $active_ctx, $type, array('vocab' => true, 'base' => true), + $local_ctx, $defined); } // add @type to mapping @@ -3420,11 +4160,12 @@ class JsonLdProcessor { if(property_exists($value, '@container')) { $container = $value->{'@container'}; - if($container !== '@list' && $container !== '@set') { + if($container !== '@list' && $container !== '@set' && + $container !== '@index' && $container !== '@language') { throw new JsonLdException( - 'Invalid JSON-LD syntax; @context @container value must be ' + - '"@list" or "@set".', - 'jsonld.SyntaxError', array('context' => $ctx)); + 'Invalid JSON-LD syntax; @context @container value must be ' . + 'one of the following: @list, @set, @index, or @language.', + 'jsonld.SyntaxError', array('context' => $local_ctx)); } // add @container to mapping @@ -3436,172 +4177,149 @@ class JsonLdProcessor { $language = $value->{'@language'}; if($language !== null && !is_string($language)) { throw new JsonLdException( - 'Invalid JSON-LD syntax; @context @language value must be ' + + 'Invalid JSON-LD syntax; @context @language value must be ' . 'a string or null.', - 'jsonld.SyntaxError', array('context' => $ctx)); + 'jsonld.SyntaxError', array('context' => $local_ctx)); } // add @language to mapping + if($language !== null) { + $language = strtolower($language); + } $mapping->{'@language'} = $language; } - // merge onto parent mapping if one exists for a prefix - if($prefix !== null && property_exists($active_ctx->mappings, $prefix)) { - $child = $mapping; - $mapping = self::copy($active_ctx->mappings->{$prefix}); - foreach($child as $k => $v) { - $mapping->{$k} = $v; - } - } - - // define key mapping - $active_ctx->mappings->{$key} = $mapping; - $defined->{$key} = true; + // define term mapping + $active_ctx->mappings->{$term} = $mapping; + $defined->{$term} = true; } /** - * Expands a string value to a full IRI during context processing. It can - * be assumed that the value is not a keyword. + * Expands a string to a full IRI. The string may be a term, a prefix, a + * relative IRI, or an absolute IRI. The associated absolute IRI will be + * returned. * * @param stdClass $active_ctx the current active context. - * @param stdClass $ctx the local context being processed. - * @param string $value the string value to expand. - * @param string $base the base IRI. - * @param stdClass $defined a map for tracking cycles in context definitions. + * @param string $value the string to expand. + * @param assoc $relative_to options for how to resolve relative IRIs: + * base: true to resolve against the base IRI, false not to. + * vocab: true to concatenate after @vocab, false not to. + * @param stdClass $local_ctx the local context being processed (only given + * if called during document processing). + * @param defined a map for tracking cycles in context definitions (only given + * if called during document processing). * * @return mixed the expanded value. */ - protected function _expandContextIri( - $active_ctx, $ctx, $value, $base, $defined) { - // dependency not defined, define it - if(property_exists($ctx, $value) && - (!property_exists($defined, $value) || !$defined->{$value})) { - $this->_defineContextMapping($active_ctx, $ctx, $value, $base, $defined); - } - - // recurse if value is a term - if(property_exists($active_ctx->mappings, $value)) { - $id = $active_ctx->mappings->{$value}->{'@id'}; - // value is already an absolute IRI - if($value === $id) { - return $value; - } - return $this->_expandContextIri($active_ctx, $ctx, $id, $base, $defined); - } - - // split value into prefix:suffix - if(strpos($value, ':') !== false) { - list($prefix, $suffix) = explode(':', $value, 2); - - // a prefix of '_' indicates a blank node - if($prefix === '_') { - return $value; - } - - // a suffix of '//' indicates value is an absolute IRI - if(strpos($suffix, '//') === 0) { - return $value; - } - - // dependency not defined, define it - if(property_exists($ctx, $prefix) && - (!property_exists($defined, $prefix) || !$defined->{$prefix})) { - $this->_defineContextMapping( - $active_ctx, $ctx, $prefix, $base, $defined); - } - - // recurse if prefix is defined - if(property_exists($active_ctx->mappings, $prefix)) { - $id = $active_ctx->mappings->{$prefix}->{'@id'}; - return $this->_expandContextIri( - $active_ctx, $ctx, $id, $base, $defined) . $suffix; - } - - // consider value an absolute IRI - return $value; - } - - // prepend vocab - if(property_exists($ctx, '@vocab') && $ctx->{'@vocab'} !== null) { - $value = $this->_prependBase($ctx->{'@vocab'}, $value); - } - // prepend base - else { - $value = $this->_prependBase($base, $value); - } - - // value must now be an absolute IRI - if(!self::_isAbsoluteIri($value)) { - throw new JsonLdException( - 'Invalid JSON-LD syntax; a @context value does not expand to ' + - 'an absolute IRI.', - 'jsonld.SyntaxError', array('context' => $ctx, 'value' => $value)); - } - - return $value; - } - - /** - * Expands a term into an absolute IRI. The term may be a regular term, a - * prefix, a relative IRI, or an absolute IRI. In any case, the associated - * absolute IRI will be returned. - * - * @param stdClass $ctx the active context to use. - * @param string $term the term to expand. - * @param string $base the base IRI to use if a relative IRI is detected. - * - * @return string the expanded term as an absolute IRI. - */ - protected function _expandTerm($ctx, $term, $base='') { + function _expandIri( + $active_ctx, $value, $relative_to=array(), $local_ctx=null, $defined=null) { // nothing to expand - if($term === null) { + if($value === null) { return null; } - // the term has a mapping, so it is a plain term - if(property_exists($ctx->mappings, $term)) { - $id = $ctx->mappings->{$term}->{'@id'}; - // term is already an absolute IRI - if($term === $id) { - return $term; - } - return $this->_expandTerm($ctx, $id, $base); + // define term dependency if not defined + if($local_ctx !== null && property_exists($local_ctx, $value) && + !self::_hasKeyValue($defined, $value, true)) { + $this->_createTermDefinition($active_ctx, $local_ctx, $value, $defined); } - // split term into prefix:suffix - if(strpos($term, ':') !== false) { - list($prefix, $suffix) = explode(':', $term, 2); - - // a prefix of '_' indicates a blank node - if($prefix === '_') { - return $term; - } - - // a suffix of '//' indicates value is an absolute IRI - if(strpos($suffix, '//') === 0) { - return $term; - } - - // the term's prefix has a mapping, so it is a CURIE - if(property_exists($ctx->mappings, $prefix)) { - return $this->_expandTerm( - $ctx, $ctx->mappings->{$prefix}->{'@id'}, $base) . $suffix; - } - - // consider term an absolute IRI - return $term; + if(property_exists($active_ctx->mappings, $value)) { + $mapping = $active_ctx->mappings->{$value}; } - // use vocab - if(property_exists($ctx, '@vocab') && $ctx->{'@vocab'} !== null) { - $term = $this->_prependBase($ctx->{'@vocab'}, $term); + if(isset($mapping)) { + // value is explicitly ignored with a null mapping + if($mapping === null) { + return null; + } } - // prepend base to term else { - $term = $this->_prependBase($base, $term); + $mapping = null; } - return $term; + // term dependency cannot be a property generator + if($local_ctx !== null && $mapping && + $mapping->propertyGenerator) { + throw new JsonLdException( + 'Invalid JSON-LD syntax; a term definition cannot have a property ' . + 'generator as a dependency.', + 'jsonld.SyntaxError', + array('context' => $local_ctx, 'value' => $value)); + } + + $is_absolute = false; + $rval = $value; + + // value is a term + if($mapping && !$mapping->propertyGenerator) { + $is_absolute = true; + $rval = $mapping->{'@id'}; + } + + // keywords need no expanding (aliasing already handled by now) + if(self::_isKeyword($rval)) { + return $rval; + } + + if(!$is_absolute) { + // split value into prefix:suffix + $colon = strpos($rval, ':'); + if($colon !== false) { + $is_absolute = true; + $prefix = substr($rval, 0, $colon); + $suffix = substr($rval, $colon + 1); + + // do not expand blank nodes (prefix of '_') or already-absolute + // IRIs (suffix of '//') + if($prefix !== '_' && strpos($suffix, '//') !== 0) { + // prefix dependency not defined, define it + if($local_ctx !== null && property_exists($local_ctx, $prefix) && + !self::_hasKeyValue($defined, $prefix, true)) { + $this->_createTermDefinition( + $active_ctx, $local_ctx, $prefix, $defined); + } + + // use mapping if prefix is defined and not a property generator + if(property_exists($active_ctx->mappings, $prefix)) { + $mapping = $active_ctx->mappings->{$prefix}; + if($mapping && !$mapping->propertyGenerator) { + $rval = $active_ctx->mappings->{$prefix}->{'@id'} . $suffix; + } + } + } + } + } + + if($is_absolute) { + // rename blank node if requested + if(!$local_ctx && strpos($rval, '_:') === 0 && + $active_ctx->namer !== null) { + $rval = $active_ctx->namer->getName($rval); + } + } + // prepend vocab + else if(isset($relative_to['vocab']) && $relative_to['vocab'] && + property_exists($active_ctx, '@vocab')) { + $rval = $active_ctx->{'@vocab'} . $rval; + } + // prepend base + else if(isset($relative_to['base']) && $relative_to['base']) { + $rval = jsonld_prepend_base($active_ctx->{'@base'}, $rval); + } + + if($local_ctx) { + // value must now be an absolute IRI + if(!self::_isAbsoluteIri($rval)) { + throw new JsonLdException( + 'Invalid JSON-LD syntax; a @context value does not expand to ' . + 'an absolute IRI.', + 'jsonld.SyntaxError', + array('context' => $local_ctx, 'value' => $value)); + } + } + + return $rval; } /** @@ -3631,7 +4349,7 @@ class JsonLdProcessor { $length = count($v); for($i = 0; $i < $length; ++$i) { if(is_string($v[$i])) { - $url = jsonld_to_absolute_url($v[$i], $base); + $url = jsonld_prepend_base($base, $v[$i]); // replace w/@context if requested if($replace) { $ctx = $urls->{$url}; @@ -3654,7 +4372,7 @@ class JsonLdProcessor { } // string @context else if(is_string($v)) { - $v = jsonld_to_absolute_url($v, $base); + $v = jsonld_prepend_base($base, $v); // replace w/@context if requested if($replace) { $input->{$k} = $urls->{$v}; @@ -3691,7 +4409,7 @@ class JsonLdProcessor { // for tracking the URLs to resolve $urls = new stdClass(); - // find all URLs in the given input + // find all URLs in the given input $this->_findContextUrls($input, $urls, false, $base); // queue all unresolved URLs @@ -3767,25 +4485,21 @@ class JsonLdProcessor { $this->_findContextUrls($input, $urls, true, $base); } - /** - * Prepends a base IRI to the given relative IRI. - * - * @param string $base the base IRI. - * @param string $iri the relative IRI. - * - * @return string the absolute IRI. - */ - protected function _prependBase($base, $iri) { - return jsonld_prepend_base($base, $iri); - } - /** * Gets the initial context. * + * @param assoc $options the options to use. + * base the document base IRI. + * * @return stdClass the initial context. */ - protected function _getInitialContext() { + protected function _getInitialContext($options) { + $namer = null; + if(isset($options['renameBlankNodes']) && $options['renameBlankNodes']) { + $namer = new UniqueNamer('_:t'); + } return (object)array( + '@base' => jsonld_parse_url($options['base']), 'mappings' => new stdClass(), 'keywords' => (object)array( '@context' => array(), @@ -3795,6 +4509,7 @@ class JsonLdProcessor { '@explicit' => array(), '@graph' => array(), '@id' => array(), + '@index' => array(), '@language' => array(), '@list' => array(), '@omitDefault' => array(), @@ -3802,8 +4517,161 @@ class JsonLdProcessor { '@set' => array(), '@type' => array(), '@value' => array(), - '@vocab' => array() - )); + '@vocab' => array()), + 'namer' => $namer, + 'inverse' => null); + } + + /** + * Generates an inverse context for use in the compaction algorithm, if + * not already generated for the given active context. + * + * @param stdClass $active_ctx the active context to use. + * + * @return stdClass the inverse context. + */ + protected function _getInverseContext($active_ctx) { + $inverse = $active_ctx->inverse = new stdClass(); + + // handle default language + $default_language = '@none'; + if(property_exists($active_ctx, '@language')) { + $default_language = $active_ctx->{'@language'}; + } + + // create term selections for each mapping in the context, ordered by + // shortest and then lexicographically least + $mappings = $active_ctx->mappings; + $terms = array_keys((array)$mappings); + usort($terms, array($this, '_compareShortestLeast')); + foreach($terms as $term) { + $mapping = $mappings->{$term}; + if($mapping === null) { + continue; + } + + // iterate over every IRI in the mapping + $iris = $mapping->{'@id'}; + $iris = self::arrayify($iris); + foreach($iris as $iri) { + // initialize container map + if(!property_exists($inverse, $iri)) { + $inverse->{$iri} = new stdClass(); + } + $container_map = $inverse->{$iri}; + + // add term selection where it applies + if(property_exists($mapping, '@container')) { + $container = $mapping->{'@container'}; + } + else { + $container = '@none'; + } + + // add new entry + if(!property_exists($container_map, $container)) { + $container_map->{$container} = (object)array( + '@language' => new stdClass(), + '@type' => new stdClass()); + $container_map->{$container}->{'@language'}->{$default_language} = + (object)array('term' => null, 'propertyGenerators' => array()); + } + $entry = $container_map->{$container}; + + // consider updating @language entry if @type is not specified + if(!property_exists($mapping, '@type')) { + // if a @language is specified, update its specific entry + if(property_exists($mapping, '@language')) { + $language = $mapping->{'@language'}; + if($language === null) { + $language = '@null'; + } + $this->_addPreferredTerm( + $mapping, $term, $entry->{'@language'}, $language); + } + // add an entry for the default language and for no @language + else { + $this->_addPreferredTerm( + $mapping, $term, $entry->{'@language'}, $default_language); + $this->_addPreferredTerm( + $mapping, $term, $entry->{'@language'}, '@none'); + } + } + + // consider updating @type entry if @language is not specified + if(!property_exists($mapping, '@language')) { + if(property_exists($mapping, '@type')) { + $type = $mapping->{'@type'}; + } + else { + $type = '@none'; + } + $this->_addPreferredTerm($mapping, $term, $entry->{'@type'}, $type); + } + } + } + + return $inverse; + } + + /** + * Adds or updates the term or property generator for the given entry. + * + * @param stdClass $mapping the term mapping. + * @param string $term the term to add. + * @param stdClass $entry the inverse context type_or_language entry to + * add to. + * @param string $type_or_language_value the key in the entry to add to. + */ + function _addPreferredTerm($mapping, $term, $entry, $type_or_language_value) { + if(!property_exists($entry, $type_or_language_value)) { + $entry->{$type_or_language_value} = (object)array( + 'term' => null, 'propertyGenerators' => array()); + } + + $e = $entry->{$type_or_language_value}; + if($mapping->propertyGenerator) { + $e->propertyGenerators[] = $term; + } + else if($e->term === null) { + $e->term = $term; + } + } + + /** + * Clones an active context, creating a child active context. + * + * @return stdClass a clone (child) of the active context. + */ + protected function _cloneActiveContext($active_ctx) { + $child = new stdClass(); + $child->{'@base'} = $active_ctx->{'@base'}; + $child->keywords = self::copy($active_ctx->keywords); + $child->mappings = self::copy($active_ctx->mappings); + $child->namer = $active_ctx->namer; + $child->inverse = null; + return $child; + } + + /** + * Returns a copy of this active context that can be shared between + * different processing algorithms. This method only copies the parts + * of the active context that can't be shared. + * + * @param stdClass $active_ctx the active context to use. + * + * @return stdClass a shareable copy of the active context. + */ + public function _shareActiveContext($active_ctx) { + $rval = new stdClass(); + $rval->{'@base'} = $active_ctx->{'@base'}; + $rval->keywords = $active_ctx->keywords; + $rval->mappings = $active_ctx->mappings; + if($active_ctx->namer !== null) { + $rval->namer = clone $active_ctx->namer; + } + $rval->inverse = $active_ctx->inverse; + return $rval; } /** @@ -3867,7 +4735,7 @@ class JsonLdProcessor { protected static function _appendUniqueRdfStatement( &$statements, $statement) { foreach($statements as $s) { - if(JsonLdProcessor::_compareRdfStatements($s, $statement)) { + if(self::_compareRdfStatements($s, $statement)) { return; } } @@ -3875,43 +4743,34 @@ class JsonLdProcessor { } /** - * Returns whether or not the given value is a keyword (or a keyword alias). + * Returns whether or not the given value is a keyword. * * @param string $v the value to check. - * @param stdClass [$ctx] the active context to check against. * * @return bool true if the value is a keyword, false if not. */ - protected static function _isKeyword($v, $ctx=null) { - if($ctx !== null) { - if(property_exists($ctx->keywords, $v)) { - return true; - } - foreach($ctx->keywords as $kw => $aliases) { - if(in_array($v, $aliases) !== false) { - return true; - } - } + protected static function _isKeyword($v) { + if(!is_string($v)) { + return false; } - else { - switch($v) { - case '@context': - case '@container': - case '@default': - case '@embed': - case '@explicit': - case '@graph': - case '@id': - case '@language': - case '@list': - case '@omitDefault': - case '@preserve': - case '@set': - case '@type': - case '@value': - case '@vocab': - return true; - } + switch($v) { + case '@context': + case '@container': + case '@default': + case '@embed': + case '@explicit': + case '@graph': + case '@id': + case '@index': + case '@language': + case '@list': + case '@omitDefault': + case '@preserve': + case '@set': + case '@type': + case '@value': + case '@vocab': + return true; } return false; } @@ -3933,18 +4792,18 @@ class JsonLdProcessor { * @param mixed $v the value to check. */ protected static function _validateTypeValue($v) { - // must be a string, subject reference, or empty object - if(is_string($v) || self::_isSubjectReference($v) || - self::_isEmptyObject($v)) { + // must be a string or empty object + if(is_string($v) || self::_isEmptyObject($v)) { return; } // must be an array $is_valid = false; if(is_array($v)) { + // must contain only strings $is_valid = true; foreach($v as $e) { - if(!(is_string($e) || self::_isSubjectReference($e))) { + if(!(is_string($e))) { $is_valid = false; break; } @@ -3953,7 +4812,7 @@ class JsonLdProcessor { if(!$is_valid) { throw new JsonLdException( - 'Invalid JSON-LD syntax; "@type" value must a string, an array ' + + 'Invalid JSON-LD syntax; "@type" value must a string, an array ' . 'of strings, or an empty object.', 'jsonld.SyntaxError', array('value' => $v)); } @@ -4062,6 +4921,39 @@ class JsonLdProcessor { protected static function _isAbsoluteIri($v) { return strpos($v, ':') !== false; } + + /** + * Returns true if the given target has the given key and its + * value equals is the given value. + * + * @param stdClass $target the target object. + * @param string key the key to check. + * @param mixed $value the value to check. + * + * @return bool true if the target has the given key and its value matches. + */ + // FIXME: use this function throughout processor, check for "property_exists" + protected static function _hasKeyValue($target, $key, $value) { + return (property_exists($target, $key) && $target->{$key} === $value); + } + + /** + * Returns true if both of the given objects have the same value for the + * given key or if neither of the objects contain the given key. + * + * @param stdClass $o1 the first object. + * @param stdClass $o2 the second object. + * @param string key the key to check. + * + * @return bool true if both objects have the same value for the key or + * neither has the key. + */ + protected static function _compareKeyValues($o1, $o2, $key) { + if(property_exists($o1, $key)) { + return property_exists($o2, $key) && $o1->{$key} === $o2->{$key}; + } + return !property_exists($o2, $key); + } } // register the N-Quads RDF parser @@ -4240,4 +5132,66 @@ class Permutator { } } +/** + * An ActiveContextCache caches active contexts so they can be reused without + * the overhead of recomputing them. + */ +class ActiveContextCache { + /** + * Constructs a new ActiveContextCache. + * + * @param int size the maximum size of the cache, defaults to 100. + */ + public function __construct($size=100) { + $this->order = array(); + $this->cache = new stdClass(); + $this->size = $size; + } + + /** + * Gets an active context from the cache based on the current active + * context and the new local context. + * + * @param stdClass $active_ctx the current active context. + * @param stdClass $local_ctx the new local context. + * + * @return mixed a shared copy of the cached active context or null. + */ + public function get($active_ctx, $local_ctx) { + $key1 = serialize($active_ctx); + $key2 = serialize($local_ctx); + if(property_exists($this->cache, $key1)) { + $level1 = $this->cache->{$key1}; + if(property_exists($level1, $key2)) { + // get shareable copy of cached active context + return JsonLdProcessor::_shareActiveContext($level1->{$key2}); + } + } + return null; + } + + /** + * Sets an active context in the cache based on the previous active + * context and the just-processed local context. + * + * @param stdClass $active_ctx the previous active context. + * @param stdClass $local_ctx the just-processed local context. + * @param stdClass $result the resulting active context. + */ + public function set($active_ctx, $local_ctx, $result) { + if(count($this->order) === $this->size) { + $entry = array_shift($this->order); + unset($this->cache->{$entry->activeCtx}->{$entry->localCtx}); + } + $key1 = serialize($active_ctx); + $key2 = serialize($local_ctx); + $this->order[] = (object)array( + 'activeCtx' => $key1, 'localCtx' => $key2); + if(!property_exists($this->cache)) { + $this->cache->{$key1} = new stdClass(); + } + $this->cache->{$key1}->{$key2} = $result; + } +} + /* end of file, omit ?> */