diff --git a/jsonld-tests.php b/jsonld-tests.php index 797fbe2..878ee4f 100644 --- a/jsonld-tests.php +++ b/jsonld-tests.php @@ -12,313 +12,275 @@ require_once('jsonld.php'); $isCli = defined('STDIN'); $eol = $isCli ? "\n" : '
'; -function error_handler($errno, $errstr, $errfile, $errline) -{ - global $eol; - echo "$eol$errstr$eol"; - array_walk( - debug_backtrace(), - create_function( - '$a,$b', - 'echo "{$a[\'function\']}()' . - '(".basename($a[\'file\']).":{$a[\'line\']}); ' . $eol . '";')); - throw new Exception(); - return false; +function error_handler($errno, $errstr, $errfile, $errline) { + global $eol; + echo "$eol$errstr$eol"; + array_walk( + debug_backtrace(), + create_function( + '$a,$b', + 'echo "{$a[\'function\']}()' . + '(".basename($a[\'file\']).":{$a[\'line\']}); ' . $eol . '";')); + throw new Exception(); + return false; } -if(!$isCli) -{ - set_error_handler('error_handler'); +if(!$isCli) { + set_error_handler('error_handler'); } -function _sortKeys($obj) -{ - $rval; - - if($obj === null) - { - $rval = null; - } - else if(is_array($obj)) - { - $rval = array(); - foreach($obj as $o) - { - $rval[] = _sortKeys($o); +function deep_compare($expect, $result) { + if(is_array($expect)) { + if(!is_array($result)) { + return false; + } + if(count($expect) !== count($result)) { + return false; + } + foreach($expect as $i => $v) { + if(!deep_compare($v, $result[$i])) { + return false; } - } - else if(is_object($obj)) - { - $rval = new stdClass(); - $keys = array_keys((array)$obj); - sort($keys); - foreach($keys as $key) - { - $rval->$key = _sortKeys($obj->$key); + } + return true; + } + + if(is_object($expect)) { + if(!is_object($result)) { + return false; + } + if(count(get_object_vars($expect)) !== count(get_object_vars($result))) { + return false; + } + foreach($expect as $k => $v) { + if(!deep_compare($v, $result->{$k})) { + return false; } - } - else - { - $rval = $obj; - } + } + } - return $rval; -} - -function _stringifySorted($obj, $indent) -{ - /* - $flags = JSON_UNESCAPED_SLASHES; - if($indent) - { - $flags |= JSON_PRETTY_PRINT; - }*/ - return str_replace('\\/', '/', json_encode(_sortKeys($obj)));//, $flags); + return $expect === $result; } /** * Reads test JSON files. * - * @param file the file to read. - * @param filepath the test filepath. + * @param string $file the file to read. + * @param string $filepath the test filepath. * - * @return the read JSON. + * @return string the read JSON. */ -function _readTestJson($file, $filepath) -{ - $rval; - global $eol; +function read_test_json($file, $filepath) { + global $eol; - try - { - $file = $filepath . '/' . $file; - $rval = json_decode(file_get_contents($file)); - } - catch(Exception $e) - { - echo "Exception while parsing file: '$file'$eol"; - throw $e; - } - - return $rval; + try { + $file = $filepath . '/' . $file; + return json_decode(file_get_contents($file)); + } + catch(Exception $e) { + echo "Exception while parsing file: '$file'$eol"; + throw $e; + } } -class TestRunner -{ - public function __construct() - { - // set up groups, add root group - $this->groups = array(); - $this->group(''); - } +class TestRunner { + public function __construct() { + // set up groups, add root group + $this->groups = array(); + $this->group(''); - public function group($name) - { - $group = new stdClass(); - $group->name = $name; - $group->tests = array(); - $group->count = 1; - $this->groups[] = $group; - } + $this->passed = 0; + $this->failed = 0; + $this->total = 0; + } - public function ungroup() - { - array_pop($this->groups); - } + public function group($name) { + $this->groups[] = (object)array( + 'name' => $name, + 'tests' => array(), + 'count' => 1); + } - public function test($name) - { - $this->groups[count($this->groups) - 1]->tests[] = $name; + public function ungroup() { + array_pop($this->groups); + } - $line = ''; - foreach($this->groups as $g) - { - $line .= ($line === '') ? $g->name : ('/' . $g->name); + public function test($name) { + $this->groups[count($this->groups) - 1]->tests[] = $name; + $this->total += 1; + + $line = ''; + foreach($this->groups as $g) { + $line .= ($line === '') ? $g->name : ('/' . $g->name); + } + + $g = $this->groups[count($this->groups) - 1]; + if($g->name !== '') { + $count = '' . $g->count; + $end = 4 - strlen($count); + for($i = 0; $i < $end; ++$i) { + $count = '0' . $count; } + $line .= ' ' . $count; + $g->count += 1; + } + $line .= '/' . array_pop($g->tests) . '... '; + echo $line; + } - $g = $this->groups[count($this->groups) - 1]; - if($g->name !== '') - { - $count = '' . $g->count; - $end = 4 - strlen($count); - for($i = 0; $i < $end; ++$i) - { - $count = '0' . $count; - } - $line .= ' ' . $count; - $g->count += 1; + public function check($test, $expect, $result) { + global $eol; + + if(strpos($test->{'@type'}, 'NormalizeTest') !== false) { + $pass = JsonLdProcessor::compareNormalized($expect, $result); + } + else { + $pass = deep_compare($expect, $result); + } + + if($pass) { + $this->passed += 1; + echo "PASS$eol"; + } + else { + $this->failed += 1; + echo "FAIL$eol"; + echo 'Expect: ' . print_r($expect, true) . $eol; + echo 'Result: ' . print_r($result, true) . $eol; + + /* + $flags = JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT; + echo 'JSON Expect: ' . + json_encode(json_decode(expect, $flags)) . $eol; + echo 'JSON Result: ' . + json_encode(json_decode(result, $flags)) . $eol; + */ + + // FIXME: remove me + throw new Exception('FAIL'); + } + } + + public function load($filepath) { + global $eol; + $manifests = array(); + + // get full path + $filepath = realpath($filepath); + echo "Reading manifest files from: '$filepath'$eol"; + + // read each test file from the directory + $files = array(); + $handle = opendir($filepath); + if($handle) { + while(($file = readdir($handle)) !== false) { + if($file !== '..' and $file !== '.') + { + $files[] = $filepath . '/' . $file; + } + } + closedir($handle); + } + else { + throw new Exception('Could not open directory.'); + } + + foreach($files as $file) { + $info = pathinfo($file); + // FIXME: hackish, manifests are now JSON-LD + if(strstr($info['basename'], 'manifest') !== false && + $info['extension'] == 'jsonld') { + echo "Reading manifest file: '$file'$eol"; + + try { + $manifest = json_decode(file_get_contents($file)); + } + catch(Exception $e) { + echo "Exception while parsing file: '$file'$eol"; + throw $e; + } + + $manifest->filepath = $filepath; + $manifests[] = $manifest; } - $line .= '/' . array_pop($g->tests) . '... '; - echo $line; - } + } - public function check($expect, $result, $indent=false) - { - global $eol; + echo count($manifests) . " manifest file(s) read.$eol"; + return $manifests; + } - // sort and use given indent level - $expect = _stringifySorted($expect, $indent); - $result = _stringifySorted($result, $indent); + public function run($manifests) { + /* Manifest format: { + name: , + sequence: [{ + 'name': , + '@type': ["test:TestCase", "jld:"], + 'input': , + 'context': , + 'frame': , + 'expect': , + }] + } + */ + global $eol; + foreach($manifests as $manifest) { + $this->group($manifest->name); + $filepath = $manifest->filepath; + foreach($manifest->sequence as $test) { + // read test input files + $type = $test->{'@type'}; + $options = array( + 'base' => 'http://json-ld.org/test-suite/tests/' . $test->input); + if(in_array('jld:NormalizeTest', $type)) { + $this->test($test->name); + $input = read_test_json($test->input, $filepath); + $test->expect = read_test_json($test->expect, $filepath); + $result = jsonld_normalize($input, $options); + } + else if(in_array('jld:ExpandTest', $type)) { + $this->test($test->name); + $input = read_test_json($test->input, $filepath); + $test->expect = read_test_json($test->expect, $filepath); + $result = jsonld_expand($input, $options); + } + else if(in_array('jld:CompactTest', $type)) { + $this->test($test->name); + $input = read_test_json($test->input, $filepath); + $test->context = read_test_json($test->context, $filepath); + $test->expect = read_test_json($test->expect, $filepath); + $result = jsonld_compact($input, $test->context, $options); + } + else if(in_array('jld:FrameTest', $type)) { + $this->test($test->name); + $input = read_test_json($test->input, $filepath); + $test->frame = read_test_json($test->frame, $filepath); + $test->expect = read_test_json($test->expect, $filepath); + $result = jsonld_frame($input, $test->frame, $options); + } + else { + echo "Skipping test \"{$test->name}\" of type: " . + json_encode($type) . $eol; + continue; + } - $fail = false; - if($expect === $result) - { - $line = 'PASS'; - } - else - { - $line = 'FAIL'; - $fail = true; + // check results + $this->check($test, $test->expect, $result); } + } + } +} - echo "$line$eol"; - if($fail) - { - echo 'Expect: ' . print_r($expect, true) . $eol; - echo 'Result: ' . print_r($result, true) . $eol; - - /* - $flags = JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT; - echo 'Legible Expect: ' . - json_encode(json_decode(expect, $flags)) . $eol; - echo 'Legible Result: ' . - json_encode(json_decode(result, $flags)) . $eol; - */ - - // FIXME: remove me - throw new Exception('FAIL'); - } - } - - public function load($filepath) - { - global $eol; - $manifests = array(); - - // get full path - $filepath = realpath($filepath); - echo "Reading manifest files from: '$filepath'$eol"; - - // read each test file from the directory - $files = array(); - $handle = opendir($filepath); - if($handle) - { - while(($file = readdir($handle)) !== false) - { - if($file !== '..' and $file !== '.') - { - $files[] = $filepath . '/' . $file; - } - } - closedir($handle); - } - else - { - throw new Exception('Could not open directory.'); - } - - foreach($files as $file) - { - $info = pathinfo($file); - // FIXME: hackish, manifests are now JSON-LD - if(strstr($info['basename'], 'manifest') !== false and - $info['extension'] == 'jsonld') - { - echo "Reading manifest file: '$file'$eol"; - - try - { - $manifest = json_decode(file_get_contents($file)); - } - catch(Exception $e) - { - echo "Exception while parsing file: '$file'$eol"; - throw $e; - } - - $manifest->filepath = $filepath; - $manifests[] = $manifest; - } - } - - echo count($manifests) . " manifest file(s) read.$eol"; - - return $manifests; - } - - public function run($manifests) - { - /* Manifest format: - { - name: , - sequence: [{ - 'name': , - '@type': ["test:TestCase", "jld:"], - 'input': , - 'context': , - 'frame': , - 'expect': , - }] - } - */ - global $eol; - - foreach($manifests as $manifest) - { - $this->group($manifest->name); - $filepath = $manifest->filepath; - - foreach($manifest->sequence as $test) - { - // read test input files - $indent = 2; - $type = $test->{'@type'}; - if(in_array('jld:NormalizeTest', $type)) - { - $indent = 0; - $input = _readTestJson($test->input, $filepath); - $test->expect = _readTestJson($test->expect, $filepath); - $result = jsonld_normalize($input); - } - else if(in_array('jld:ExpandTest', $type)) - { - $input = _readTestJson($test->input, $filepath); - $test->expect = _readTestJson($test->expect, $filepath); - $result = jsonld_expand($input); - } - else if(in_array('jld:CompactTest', $type)) - { - $input = _readTestJson($test->input, $filepath); - $test->context = _readTestJson($test->context, $filepath); - $test->expect = _readTestJson($test->expect, $filepath); - $result = jsonld_compact($test->context->{'@context'}, $input); - } - else if(in_array('jld:FrameTest', $type)) - { - $input = _readTestJson($test->input, $filepath); - $test->frame = _readTestJson($test->frame, $filepath); - $test->expect = _readTestJson($test->expect, $filepath); - $result = jsonld_frame($input, $test->frame); - } - else - { - echo 'Skipping test "' . $test->name . '" of type: ' . - json_encode($type) . $eol; - continue; - } - - // check results (only indent output on non-normalize tests) - $this->test($test->name); - $this->check($test->expect, $result, $indent); - } - } - } +// get command line options +$options = getopt('d:'); +if($options === false || !array_key_exists('d', $options)) { + $var = 'path to json-ld.org/test-suite/tests'; + echo "Usage: php jsonld-tests.php -d <$var>$eol"; + exit(0); } // load and run tests $tr = new TestRunner(); $tr->group('JSON-LD'); -$tr->run($tr->load('tests')); +$tr->run($tr->load($options['d'])); $tr->ungroup(); echo "All tests complete.$eol"; diff --git a/jsonld.php b/jsonld.php index d610e8e..e64abf2 100644 --- a/jsonld.php +++ b/jsonld.php @@ -1,2933 +1,3402 @@ normalize($input); +function jsonld_compact($input, $ctx, $options=array()) { + $p = new JsonLdProcessor(); + return $p->compact($input, $ctx, $options); } /** - * Removes the context from a JSON-LD object, expanding it to full-form. + * Performs JSON-LD expansion. * - * @param input the JSON-LD object to remove the context from. + * @param mixed $input the JSON-LD object to expand. + * @param assoc[$options] the options to use: + * [base] the base IRI to use. + * [resolver(url, callback(err, jsonCtx))] the URL resolver to use. * - * @return the context-neutral JSON-LD object. + * @return array the expanded JSON-LD output. */ -function jsonld_expand($input) -{ - $p = new JsonLdProcessor(); - return $p->expand(new stdClass(), null, $input); +function jsonld_expand($input, $options=array()) { + $p = new JsonLdProcessor(); + return $p->expand($input, $options); } /** - * Expands the given JSON-LD object and then compacts it using the - * given context. + * Performs JSON-LD framing. * - * @param ctx the new context to use. - * @param input the input JSON-LD object. + * @param mixed $input the JSON-LD object to frame. + * @param stdClass $frame the JSON-LD frame to use. + * @param assoc [$options] the framing options. + * [base] the base IRI to use. + * [embed] default @embed flag (default: true). + * [explicit] default @explicit flag (default: false). + * [omitDefault] default @omitDefault flag (default: false). + * [optimize] optimize when compacting (default: false). + * [resolver(url, callback(err, jsonCtx))] the URL resolver to use. * - * @return the output JSON-LD object. + * @return stdClass the framed JSON-LD output. */ -function jsonld_compact($ctx, $input) -{ - $rval = null; - - // TODO: should context simplification be optional? (ie: remove context - // entries that are not used in the output) - - if($input !== null) - { - // fully expand input - $input = jsonld_expand($input); - - // merge context if it is an array - if(is_array($ctx)) - { - $ctx = jsonld_merge_contexts(new stdClass, $ctx); - } - - // setup output context - $ctxOut = new stdClass(); - - // compact - $p = new JsonLdProcessor(); - $rval = $out = $p->compact(_clone($ctx), null, $input, $ctxOut); - - // add context if used - if(count(array_keys((array)$ctxOut)) > 0) - { - $rval = new stdClass(); - $rval->{'@context'} = $ctxOut; - if(is_array($out)) - { - $rval->{_getKeywords($ctxOut)->{'@id'}} = $out; - } - else - { - foreach($out as $k => $v) - { - $rval->{$k} = $v; - } - } - } - } - - return $rval; +function jsonld_frame($input, $frame, $options=array()) { + $p = new JsonLdProcessor(); + return $p->frame($input, $frame, $options); } /** - * Merges one context with another. + * Performs JSON-LD normalization. * - * @param ctx1 the context to overwrite/append to. - * @param ctx2 the new context to merge onto ctx1. + * @param mixed $input the JSON-LD object to normalize. + * @param assoc [$options] the options to use: + * [base] the base IRI to use. + * [resolver(url, callback(err, jsonCtx))] the URL resolver to use. * - * @return the merged context. + * @return array the normalized JSON-LD output. */ -function jsonld_merge_contexts($ctx1, $ctx2) -{ - // merge first context if it is an array - if(is_array($ctx1)) - { - $ctx1 = jsonld_merge_contexts($ctx1, $ctx2); - } - - // copy context to merged output - $merged = _clone($ctx1); - - if(is_array($ctx2)) - { - // merge array of contexts in order - foreach($ctx2 as $ctx) - { - $merged = jsonld_merge_contexts($merged, $ctx); - } - } - else - { - // if the new context contains any IRIs that are in the merged context, - // remove them from the merged context, they will be overwritten - foreach($ctx2 as $key => $value) - { - // ignore special keys starting with '@' - if(strpos($key, '@') !== 0) - { - foreach($merged as $mkey => $mvalue) - { - if($mvalue === $value) - { - // FIXME: update related coerce rules - unset($merged->$mkey); - break; - } - } - } - } - - // merge contexts - foreach($ctx2 as $key => $value) - { - $merged->$key = _clone($value); - } - } - - return $merged; +function jsonld_normalize($input, $options=array()) { + $p = new JsonLdProcessor(); + return $p->normalize($input, $options); } /** - * 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. + * Outputs the RDF statements found in the given JSON-LD object. * - * @param ctx the context to use. - * @param term the term to expand. + * @param mixed $input the JSON-LD object. + * @param assoc [$options] the options to use: + * [resolver(url, callback(err, jsonCtx))] the URL resolver to use. * - * @return the expanded term as an absolute IRI. + * @return array all RDF statements in the JSON-LD object. */ -function jsonld_expand_term($ctx, $term) -{ - return _expandTerm($ctx, $term); +function jsonld_to_rdf($input, $options=array()) { + $p = new JsonLdProcessor(); + return $p->toRdf($input, $options); } /** - * Compacts an IRI into a term or prefix if it can be. IRIs will not be - * compacted to relative IRIs if they match the given context's default - * vocabulary. + * Processes a local context, resolving any URLs as necessary, and returns a + * new active context. * - * @param ctx the context to use. - * @param iri the IRI to compact. + * @param active_ctx the current active context. + * @param local_ctx the local context to process. + * @param [options] the options to use: + * [resolver(url, callback(err, jsonCtx))] the URL resolver to use. * - * @return the compacted IRI as a term or prefix or the original IRI. + * @return the new active context. */ -function jsonld_compact_iri($ctx, $iri) -{ - return _compactIri($ctx, $iri, null); -} - -/** - * Frames JSON-LD input. - * - * @param input the JSON-LD input. - * @param frame the frame to use. - * @param options framing options to use. - * - * @return the framed output. - */ -function jsonld_frame($input, $frame, $options=null) -{ - $rval; - - // normalize input - $input = jsonld_normalize($input); - - // save frame context - $ctx = null; - if(is_object($frame) and property_exists($frame, '@context')) - { - $ctx = _clone($frame->{'@context'}); - - // remove context from frame - $frame = jsonld_expand($frame); - } - else if(is_array($frame)) - { - // save first context in the array - if(count($frame) > 0 and property_exists($frame[0], '@context')) - { - $ctx = _clone($frame[0]->{'@context'}); - } - - // expand all elements in the array - $tmp = array(); - foreach($frame as $f) - { - $tmp[] = jsonld_expand($f); - } - $frame = $tmp; - } - - // create framing options - // TODO: merge in options from function parameter - $options = new stdClass(); - $options->defaults = new stdClass(); - $options->defaults->embedOn = true; - $options->defaults->explicitOn = false; - $options->defaults->omitDefaultOn = false; - - // build map of all subjects - $subjects = new stdClass(); - foreach($input as $i) - { - $subjects->{$i->{'@id'}} = $i; - } - - // frame input - $rval = _frame( - $subjects, $input, $frame, new stdClass(), false, null, null, $options); - - // apply context - if($ctx !== null and $rval !== null) - { - // preserve top-level array by compacting individual entries - if(is_array($rval)) - { - $tmp = $rval; - $rval = array(); - foreach($tmp as $value) - { - $rval[] = jsonld_compact($ctx, $value); - } - } - else - { - $rval = jsonld_compact($ctx, $rval); - } - } - - return $rval; -} - -/** - * Resolves external @context URLs. Every @context URL in the given JSON-LD - * object is resolved using the given URL-resolver function. Once all of - * the @contexts have been resolved, the method will return. If an error - * is encountered, an exception will be thrown. - * - * @param input the JSON-LD input object (or array). - * @param resolver the resolver method that takes a URL and returns a JSON-LD - * serialized @context or throws an exception. - * - * @return the fully-resolved JSON-LD output (object or array). - */ -function jsonld_resolve($input, $resolver) -{ - // find all @context URLs - $urls = new ArrayObject(); - _findUrls($input, $urls, false); - - // resolve all URLs - foreach($urls as $url => $value) - { - $result = call_user_func($resolver, $url); - if(!is_string($result)) - { - // already deserialized - $urls[$url] = $result->{'@context'}; - } - else - { - // deserialize JSON - $tmp = json_decode($result); - if($tmp === null) - { - throw new Exception( - "Could not resolve @context URL ('$url'), " . - 'malformed JSON detected.'); - } - $urls[$url] = $tmp->{'@context'}; - } - } - - // replace @context URLs in input - _findUrls($input, $urls, true); - - return $input; -} - -/** - * Finds all of the @context URLs in the given input and replaces them - * if requested by their associated values in the given URL map. - * - * @param input the JSON-LD input object. - * @param urls the URLs ArrayObject. - * @param replace true to replace, false not to. - */ -function _findUrls($input, $urls, $replace) -{ - if(is_array($input)) - { - foreach($input as $v) - { - _findUrls($v); - } - } - else if(is_object($input)) - { - foreach($input as $key => $value) - { - if($key === '@context') - { - // @context is an array that might contain URLs - if(is_array($value)) - { - foreach($value as $idx => $v) - { - if(is_string($v)) - { - // replace w/resolved @context if appropriate - if($replace) - { - $input->{$key}[$idx] = $urls[$v]; - } - // unresolved @context found - else - { - $urls[$v] = new stdClass(); - } - } - } - } - else if(is_string($value)) - { - // replace w/resolved @context if appropriate - if($replace) - { - $input->$key = $urls[$value]; - } - // unresolved @context found - else - { - $urls[$value] = new stdClass(); - } - } - } - } - } -} - -/** - * Gets the keywords from a context. - * - * @param ctx the context. - * - * @return the keywords. - */ -function _getKeywords($ctx) -{ - // TODO: reduce calls to this function by caching keywords in processor - // state - - $rval = (object)array( - '@id' => '@id', - '@language' => '@language', - '@value' => '@value', - '@type' => '@type' - ); - - if($ctx) - { - // gather keyword aliases from context - $keywords = new stdClass(); - foreach($ctx as $key => $value) - { - if(is_string($value) and property_exists($rval, $value)) - { - $keywords->{$value} = $key; - } - } - - // overwrite keywords - foreach($keywords as $key => $value) - { - $rval->$key = $value; - } - } - - return $rval; -} - -/** - * Sets a subject's property to the given object value. If a value already - * exists, it will be appended to an array. - * - * @param s the subject. - * @param p the property. - * @param o the object. - */ -function _setProperty($s, $p, $o) -{ - if(property_exists($s, $p)) - { - if(is_array($s->$p)) - { - array_push($s->$p, $o); - } - else - { - $s->$p = array($s->$p, $o); - } - } - else - { - $s->$p = $o; - } -} - -/** - * Clones an object, array, or string/number. If cloning an object, the keys - * will be sorted. - * - * @param value the value to clone. - * - * @return the cloned value. - */ -function _clone($value) -{ - $rval; - - if(is_object($value)) - { - $rval = new stdClass(); - $keys = array_keys((array)$value); - sort($keys); - foreach($keys as $key) - { - $rval->$key = _clone($value->$key); - } - } - else if(is_array($value)) - { - $rval = array(); - foreach($value as $v) - { - $rval[] = _clone($v); - } - } - else - { - $rval = $value; - } - - return $rval; -} - -/** - * Gets the iri associated with a term. - * - * @param ctx the context. - * @param term the term. - * - * @return the iri or NULL. - */ -function _getTermIri($ctx, $term) -{ - $rval = null; - if(property_exists($ctx, $term)) - { - if(is_string($ctx->$term)) - { - $rval = $ctx->$term; - } - else if(is_object($ctx->$term) and property_exists($ctx->$term, '@id')) - { - $rval = $ctx->$term->{'@id'}; - } - } - return $rval; -} - -/** - * Compacts an IRI into a term or prefix if it can be. IRIs will not be - * compacted to relative IRIs if they match the given context's default - * vocabulary. - * - * @param ctx the context to use. - * @param iri the IRI to compact. - * @param usedCtx a context to update if a value was used from "ctx". - * - * @return the compacted IRI as a term or prefix or the original IRI. - */ -function _compactIri($ctx, $iri, $usedCtx) -{ - $rval = null; - - // check the context for a term that could shorten the IRI - // (give preference to terms over prefixes) - foreach($ctx as $key => $value) - { - // skip special context keys (start with '@') - if(strlen($key) > 0 and $key[0] !== '@') - { - // compact to a term - if($iri === _getTermIri($ctx, $key)) - { - $rval = $key; - if($usedCtx !== null) - { - $usedCtx->$key = _clone($ctx->$key); - } - break; - } - } - } - - // term not found, if term is keyword, use alias - if($rval === null) - { - $keywords = _getKeywords($ctx); - if(property_exists($keywords, $iri)) - { - $rval = $keywords->{$iri}; - if($rval !== $iri and $usedCtx !== null) - { - $usedCtx->$rval = $iri; - } - } - } - - // term not found, check the context for a prefix - if($rval === null) - { - foreach($ctx as $key => $value) - { - // skip special context keys (start with '@') - if(strlen($key) > 0 and $key[0] !== '@') - { - // see if IRI begins with the next IRI from the context - $ctxIri = _getTermIri($ctx, $key); - if($ctxIri !== null) - { - $idx = strpos($iri, $ctxIri); - - // compact to a prefix - if($idx === 0 and strlen($iri) > strlen($ctxIri)) - { - $rval = $key . ':' . substr($iri, strlen($ctxIri)); - if($usedCtx !== null) - { - $usedCtx->$key = _clone($ctx->$key); - } - break; - } - } - } - } - } - - // could not compact IRI - if($rval === null) - { - $rval = $iri; - } - - return $rval; -} - -/** - * 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 ctx the context to use. - * @param term the term to expand. - * @param usedCtx a context to update if a value was used from "ctx". - * - * @return the expanded term as an absolute IRI. - */ -function _expandTerm($ctx, $term, $usedCtx) -{ - $rval = $term; - - // get JSON-LD keywords - $keywords = _getKeywords($ctx); - - // 1. If the property has a colon, it is a prefix or an absolute IRI: - $idx = strpos($term, ':'); - if($idx !== false) - { - // get the potential prefix - $prefix = substr($term, 0, $idx); - - // expand term if prefix is in context, otherwise leave it be - if(property_exists($ctx, $prefix)) - { - // prefix found, expand property to absolute IRI - $iri = _getTermIri($ctx, $prefix); - $rval = $iri . substr($term, $idx + 1); - if($usedCtx !== null) - { - $usedCtx->$prefix = _clone($ctx->$prefix); - } - } - } - // 2. If the property is in the context, then it's a term. - else if(property_exists($ctx, $term)) - { - $rval = _getTermIri($ctx, $term); - if($usedCtx !== null) - { - $usedCtx->$term = _clone($ctx->$term); - } - } - // 3. The property is a keyword. - else - { - foreach($keywords as $key => $value) - { - if($term === $value) - { - $rval = $key; - break; - } - } - } - - return $rval; -} - -/** - * Gets whether or not a value is a reference to a subject (or a subject with - * no properties). - * - * @param value the value to check. - * - * @return true if the value is a reference to a subject, false if not. - */ -function _isReference($value) -{ - // Note: A value is a reference to a subject if all of these hold true: - // 1. It is an Object. - // 2. It is has an @id key. - // 3. It has only 1 key. - return ($value !== null and - is_object($value) and - property_exists($value, '@id') and - count(get_object_vars($value)) === 1); -}; - -/** - * Gets whether or not a value is a subject with properties. - * - * @param value the value to check. - * - * @return true if the value is a subject with properties, false if not. - */ -function _isSubject($value) -{ - $rval = false; - - // Note: A value is a subject if all of these hold true: - // 1. It is an Object. - // 2. It is not a literal (@value). - // 3. It has more than 1 key OR any existing key is not '@id'. - if($value !== null and is_object($value) and - !property_exists($value, '@value')) - { - $keyCount = count(get_object_vars($value)); - $rval = ($keyCount > 1 or !property_exists($value, '@id')); - } - - return $rval; -} - -function _isBlankNodeIri($v) -{ - return strpos($v, '_:') === 0; -} - -function _isNamedBlankNode($v) -{ - // look for "_:" at the beginning of the subject - return ( - is_object($v) and property_exists($v, '@id') and - _isBlankNodeIri($v->{'@id'})); -} - -function _isBlankNode($v) -{ - // look for a subject with no ID or a blank node ID - return ( - _isSubject($v) and - (!property_exists($v, '@id') or _isNamedBlankNode($v))); -} - -/** - * Compares two values. - * - * @param v1 the first value. - * @param v2 the second value. - * - * @return -1 if v1 < v2, 0 if v1 == v2, 1 if v1 > v2. - */ -function _compare($v1, $v2) -{ - $rval = 0; - - if(is_array($v1) and is_array($v2)) - { - $length = count($v1); - for($i = 0; $i < $length and $rval === 0; ++$i) - { - $rval = _compare($v1[$i], $v2[$i]); - } - } - else - { - $rval = ($v1 < $v2 ? -1 : ($v1 > $v2 ? 1 : 0)); - } - - return $rval; -} - -/** - * Compares two keys in an object. If the key exists in one object - * and not the other, the object with the key is less. If the key exists in - * both objects, then the one with the lesser value is less. - * - * @param o1 the first object. - * @param o2 the second object. - * @param key the key. - * - * @return -1 if o1 < o2, 0 if o1 == o2, 1 if o1 > o2. - */ -function _compareObjectKeys($o1, $o2, $key) -{ - $rval = 0; - if(property_exists($o1, $key)) - { - if(property_exists($o2, $key)) - { - $rval = _compare($o1->$key, $o2->$key); - } - else - { - $rval = -1; - } - } - else if(property_exists($o2, $key)) - { - $rval = 1; - } - return $rval; -} - -/** - * Compares two object values. - * - * @param o1 the first object. - * @param o2 the second object. - * - * @return -1 if o1 < o2, 0 if o1 == o2, 1 if o1 > o2. - */ -function _compareObjects($o1, $o2) -{ - $rval = 0; - - if(is_string($o1)) - { - if(!is_string($o2)) - { - $rval = -1; - } - else - { - $rval = _compare($o1, $o2); - } - } - else if(is_string($o2)) - { - $rval = 1; - } - else - { - $rval = _compareObjectKeys($o1, $o2, '@value'); - if($rval === 0) - { - if(property_exists($o1, '@value')) - { - $rval = _compareObjectKeys($o1, $o2, '@type'); - if($rval === 0) - { - $rval = _compareObjectKeys($o1, $o2, '@language'); - } - } - // both are '@id' objects - else - { - $rval = _compare($o1->{'@id'}, $o2->{'@id'}); - } - } - } - - return $rval; -} - -/** - * Filter function for bnodes. - * - * @param e the array element. - * - * @return true to return the element in the filter results, false not to. - */ -function _filterBlankNodes($e) -{ - return !_isNamedBlankNode($e); -} - -/** - * Compares the object values between two bnodes. - * - * @param a the first bnode. - * @param b the second bnode. - * - * @return -1 if a < b, 0 if a == b, 1 if a > b. - */ -function _compareBlankNodeObjects($a, $b) -{ - $rval = 0; - - /* - 3. For each property, compare sorted object values. - 3.1. The bnode with fewer objects is first. - 3.2. For each object value, compare only literals (@values) and non-bnodes. - 3.2.1. The bnode with fewer non-bnodes is first. - 3.2.2. The bnode with a string object is first. - 3.2.3. The bnode with the alphabetically-first string is first. - 3.2.4. The bnode with a @value is first. - 3.2.5. The bnode with the alphabetically-first @value is first. - 3.2.6. The bnode with the alphabetically-first @type is first. - 3.2.7. The bnode with a @language is first. - 3.2.8. The bnode with the alphabetically-first @language is first. - 3.2.9. The bnode with the alphabetically-first @id is first. - */ - - foreach($a as $p => $value) - { - // skip IDs (IRIs) - if($p !== '@id') - { - // step #3.1 - $lenA = is_array($a->$p) ? count($a->$p) : 1; - $lenB = is_array($b->$p) ? count($b->$p) : 1; - $rval = _compare($lenA, $lenB); - - // step #3.2.1 - if($rval === 0) - { - // normalize objects to an array - $objsA = $a->$p; - $objsB = $b->$p; - if(!is_array($objsA)) - { - $objsA = array($objsA); - $objsB = array($objsB); - } - - // compare non-bnodes (remove bnodes from comparison) - $objsA = array_filter($objsA, '_filterBlankNodes'); - $objsB = array_filter($objsB, '_filterBlankNodes'); - $objsALen = count($objsA); - $rval = _compare($objsALen, count($objsB)); - } - - // steps #3.2.2-3.2.9 - if($rval === 0) - { - usort($objsA, '_compareObjects'); - usort($objsB, '_compareObjects'); - for($i = 0; $i < $objsALen and $rval === 0; ++$i) - { - $rval = _compareObjects($objsA[$i], $objsB[$i]); - } - } - - if($rval !== 0) - { - break; - } - } - } - - return $rval; -} - -/** - * A blank node name generator that uses a prefix and counter. - */ -class NameGenerator -{ - public function __construct($prefix) - { - $this->count = -1; - $this->base = '_:' . $prefix; - } - - public function next() - { - $this->count += 1; - return $this->current(); - } - - public function current() - { - return $this->base . $this->count; - } - - public function inNamespace($iri) - { - return strpos($iri, $this->base) === 0; - } -} - -/** - * Populates a map of all named subjects from the given input and an array - * of all unnamed bnodes (includes embedded ones). - * - * @param input the input (must be expanded, no context). - * @param subjects the subjects map to populate. - * @param bnodes the bnodes array to populate. - */ -function _collectSubjects($input, $subjects, $bnodes) -{ - if($input === null) - { - // nothing to collect - } - else if(is_array($input)) - { - foreach($input as $value) - { - _collectSubjects($value, $subjects, $bnodes); - } - } - else if(is_object($input)) - { - if(property_exists($input, '@id')) - { - // graph literal/disjoint graph - if(is_array($input->{'@id'})) - { - _collectSubjects($input->{'@id'}, $subjects, $bnodes); - } - // named subject - else if(_isSubject($input)) - { - $subjects->{$input->{'@id'}} = $input; - } - } - // unnamed blank node - else if(_isBlankNode($input)) - { - $bnodes[] = $input; - } - - // recurse through subject properties - foreach($input as $value) - { - _collectSubjects($value, $subjects, $bnodes); - } - } -} - -/** - * Filters duplicate objects. - */ -class DuplicateFilter -{ - public function __construct($obj) - { - $this->obj = $obj; - } - - public function filter($e) - { - return (_compareObjects($e, $this->obj) === 0); - } -} - -/** - * Flattens the given value into a map of unique subjects. It is assumed that - * all blank nodes have been uniquely named before this call. Array values for - * properties will be sorted. - * - * @param parent the value's parent, NULL for none. - * @param parentProperty the property relating the value to the parent. - * @param value the value to flatten. - * @param subjects the map of subjects to write to. - */ -function _flatten($parent, $parentProperty, $value, $subjects) -{ - $flattened = null; - - if($value === null) - { - // drop null values - } - else if(is_array($value)) - { - // list of objects or a disjoint graph - foreach($value as $v) - { - _flatten($parent, $parentProperty, $v, $subjects); - } - } - else if(is_object($value)) - { - // already-expanded value or special-case reference-only @type - if(property_exists($value, '@value') or $parentProperty === '@type') - { - $flattened = _clone($value); - } - // graph literal/disjoint graph - else if(is_array($value->{'@id'})) - { - // cannot flatten embedded graph literals - if($parent !== null) - { - throw new Exception('Embedded graph literals cannot be flattened.'); - } - - // top-level graph literal - foreach($value->{'@id'} as $k => $v) - { - _flatten($parent, $parentProperty, $v, $subjects); - } - } - // regular subject - else - { - // create or fetch existing subject - if(property_exists($subjects, $value->{'@id'})) - { - // FIXME: '@id' might be a graph literal (as {}) - $subject = $subjects->{$value->{'@id'}}; - } - else - { - // FIXME: '@id' might be a graph literal (as {}) - $subject = new stdClass(); - $subject->{'@id'} = $value->{'@id'}; - $subjects->{$value->{'@id'}} = $subject; - } - $flattened = new stdClass(); - $flattened->{'@id'} = $subject->{'@id'}; - - // flatten embeds - foreach($value as $key => $v) - { - // drop null values, skip @id (it is already set above) - if($v !== null and $key !== '@id') - { - if(property_exists($subject, $key)) - { - if(!is_array($subject->$key)) - { - $subject->$key = new ArrayObject(array($subject->$key)); - } - else - { - $subject->$key = new ArrayObject($subject->$key); - } - } - else - { - $subject->$key = new ArrayObject(); - } - - _flatten($subject->$key, $key, $value->$key, $subjects); - $subject->$key = (array)$subject->$key; - if(count($subject->$key) === 1) - { - // convert subject[key] to object if it has only 1 - $arr = $subject->$key; - $subject->$key = $arr[0]; - } - } - } - } - } - // string value - else - { - $flattened = $value; - } - - // add flattened value to parent - if($flattened !== null and $parent !== null) - { - if($parent instanceof ArrayObject) - { - // do not add duplicate IRIs for the same property - $duplicate = count(array_filter( - (array)$parent, array( - new DuplicateFilter($flattened), 'filter'))) > 0; - if(!$duplicate) - { - $parent[] = $flattened; - } - } - else - { - $parent->$parentProperty = $flattened; - } - } -} - -/** - * A MappingBuilder is used to build a mapping of existing blank node names - * to a form for serialization. The serialization is used to compare blank - * nodes against one another to determine a sort order. - */ -class MappingBuilder -{ - /** - * Constructs a new MappingBuilder. - */ - public function __construct() - { - $this->count = 1; - $this->processed = new stdClass(); - $this->mapping = new stdClass(); - $this->adj = new stdClass(); - $this->keyStack = array(); - $entry = new stdClass(); - $entry->keys = array('s1'); - $entry->idx = 0; - $this->keyStack[] = $entry; - $this->done = new stdClass(); - $this->s = ''; - } - - /** - * Copies this MappingBuilder. - * - * @return the MappingBuilder copy. - */ - public function copy() - { - $rval = new MappingBuilder(); - $rval->count = $this->count; - $rval->processed = _clone($this->processed); - $rval->mapping = _clone($this->mapping); - $rval->adj = _clone($this->adj); - $rval->keyStack = _clone($this->keyStack); - $rval->done = _clone($this->done); - $rval->s = $this->s; - return $rval; - } - - /** - * Maps the next name to the given bnode IRI if the bnode IRI isn't already - * in the mapping. If the given bnode IRI is canonical, then it will be - * given a shortened form of the same name. - * - * @param iri the blank node IRI to map the next name to. - * - * @return the mapped name. - */ - public function mapNode($iri) - { - if(!property_exists($this->mapping, $iri)) - { - if(strpos($iri, '_:c14n') === 0) - { - $this->mapping->$iri = 'c' . substr($iri, 6); - } - else - { - $this->mapping->$iri = 's' . $this->count++; - } - } - return $this->mapping->$iri; - } -} - -/** - * Rotates the elements in an array one position. - * - * @param a the array. - */ -function _rotate(&$a) -{ - $e = array_shift($a); - array_push($a, $e); - return $e; -} - -/** - * Compares two serializations for the same blank node. If the two - * serializations aren't complete enough to determine if they are equal (or if - * they are actually equal), 0 is returned. - * - * @param s1 the first serialization. - * @param s2 the second serialization. - * - * @return -1 if s1 < s2, 0 if s1 == s2 (or indeterminate), 1 if s1 > v2. - */ -function _compareSerializations($s1, $s2) -{ - $rval = 0; - - $s1Len = strlen($s1); - $s2Len = strlen($s2); - if($s1Len == $s2Len) - { - $rval = strcmp($s1, $s2); - } - else - { - $rval = strncmp($s1, $s2, ($s1Len > $s2Len) ? $s2Len : $s1Len); - } - - return $rval; -} - -/** - * Compares two nodes based on their iris. - * - * @param a the first node. - * @param b the second node. - * - * @return -1 if iriA < iriB, 0 if iriA == iriB, 1 if iriA > iriB. - */ -function _compareIris($a, $b) -{ - return _compare($a->{'@id'}, $b->{'@id'}); -} - -/** - * Filters blank node edges. - * - * @param e the edge to check. - * - * @return true if the edge is a blank node edge. - */ -function _filterBlankNodeEdges($e) -{ - return _isBlankNodeIri($e->s); -} - -/** - * Sorts mapping keys based on their associated mapping values. - */ -class MappingKeySorter -{ - public function __construct($mapping) - { - $this->mapping = $mapping; - } - - public function compare($a, $b) - { - return _compare($this->mapping->$a, $this->mapping->$b); - } +function jsonld_process_context($active_ctx, $local_ctx, $options=array()) { + $p = new JsonLdProcessor(); + return $p->processContext($active_ctx, $local_ctx, $options); } /** * A JSON-LD processor. */ -class JsonLdProcessor -{ - /** - * Constructs a JSON-LD processor. - */ - public function __construct() - { - } +class JsonLdProcessor { + /** XSD constants */ + const XSD_BOOLEAN = 'http://www.w3.org/2001/XMLSchema#boolean'; + const XSD_DOUBLE = 'http://www.w3.org/2001/XMLSchema#double'; + const XSD_INTEGER = 'http://www.w3.org/2001/XMLSchema#integer'; - /** - * Recursively compacts a value. This method will compact IRIs to prefixes - * or terms and do reverse type coercion to compact a value. - * - * @param ctx the context to use. - * @param property the property that points to the value, NULL for none. - * @param value the value to compact. - * @param usedCtx a context to update if a value was used from "ctx". - * - * @return the compacted value. - */ - public function compact($ctx, $property, $value, $usedCtx) - { - $rval; + /** RDF constants */ + const RDF_FIRST = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#first'; + const RDF_REST = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#rest'; + const RDF_NIL = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#nil'; + const RDF_TYPE = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type'; - // get JSON-LD keywords - $keywords = _getKeywords($ctx); + /** + * Constructs a JSON-LD processor. + */ + public function __construct() {} - if($value === null) - { - // return null, but check coerce type to add to usedCtx - $rval = null; - $this->getCoerceType($ctx, $property, $usedCtx); + /** + * Performs JSON-LD compaction. + * + * @param mixed $input the JSON-LD object to compact. + * @param mixed $ctx the context to compact with. + * @param assoc $options the compaction options. + * [activeCtx] true to also return the active context used. + * + * @return mixed the compacted JSON-LD output. + */ + public function compact($input, $ctx, $options) { + // nothing to compact + if($input === null) { + return null; + } + + // set default options + isset($options['base']) or $options['base'] = ''; + isset($options['strict']) or $options['strict'] = true; + isset($options['optimize']) or $options['optimize'] = false; + isset($options['graph']) or $options['graph'] = false; + isset($options['activeCtx']) or $options['activeCtx'] = false; + // FIXME: implement jsonld_resolve_url + isset($options['resolver']) or $options['resolver'] = 'jsonld_resolve_url'; + + // 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(); + try { + $active_ctx = $this->processContext($active_ctx, $ctx, $options); + } + catch(JsonLdException $e) { + throw new JsonLdException( + 'Could not merge context before compaction.', + 'jsonld.CompactError', null, $e); + } + + // do compaction + $compacted = $this->_compact($active_ctx, null, $input, $options); + + // always use an array if graph options is on + if($options['graph'] === true) { + $output = self::arrayify(); + } + // else if compacted is an array with 1 entry, remove array + else if(is_array($output) && count($output) === 1) { + $output = $output[0]; + } + + // build output context + $ctx = self::copy($ctx); + $ctx = self::arrayify($ctx); + + // remove empty contexts + $tmp = $ctx; + $ctx = array(); + foreach($tmp as $i => $v) { + if(!is_object($v) || count(get_object_vars($v)) > 0) { + $ctx[] = $v; } - else if(is_array($value)) - { - // recursively add compacted values to array - $rval = array(); - foreach($value as $v) - { - $rval[] = $this->compact($ctx, $property, $v, $usedCtx); - } + } + + // remove array if only one context + $ctx_length = count($ctx); + $has_context = ($ctx_length > 0); + if($ctx_length === 1) { + $ctx = $ctx[0]; + } + + // add context + if($has_context || $options['graph']) { + if(is_array($compacted)) { + // use '@graph' keyword + $kwgraph =$this->_compactIri($active_ctx, '@graph'); + $graph = $compacted; + $compacted = new stdClass(); + if($hasContext) { + $compacted->{'@context'} = $ctx; + } + $compacted->{$kwgraph} = $graph; } - // graph literal/disjoint graph - else if( - is_object($value) and - property_exists($value, '@id') and - is_array($value->{'@id'})) - { - $rval = new stdClass(); - $rval->{$keywords->{'@id'}} = $this->compact( - $ctx, $property, $value->{'@id'}, $usedCtx); + else if(is_object($compacted)) { + // reorder keys so @context is first + $graph = $compacted; + $compacted = new stdClass(); + $compacted->{'@context'} = $ctx; + foreach($graph as $k => $v) { + $compacted->{$k} = $v; + } } - // recurse if value is a subject - else if(_isSubject($value)) - { - // recursively handle sub-properties that aren't a sub-context - $rval = new stdClass(); - foreach($value as $key => $v) - { - if($v !== '@context') - { - // set object to compacted property, only overwrite existing - // properties if the property actually compacted - $p = _compactIri($ctx, $key, $usedCtx); - if($p !== $key or !property_exists($rval, $p)) - { - $rval->$p = $this->compact($ctx, $key, $v, $usedCtx); - } - } - } + } + + if($options['activeCtx']) { + return array( + 'compacted' => $compacted, + 'activeCtx' => $active_ctx); + } + else { + return $compacted; + } + } + + /** + * Performs JSON-LD expansion. + * + * @param mixed $input the JSON-LD object to expand. + * @param assoc $options the options to use: + * [base] the base IRI to use. + * [resolver(url, callback(err, jsonCtx))] the URL resolver to use. + * + * @return array the expanded JSON-LD output. + */ + public function expand($input, $options) { + // set default options + isset($options['base']) or $options['base'] = ''; + // FIXME: implement jsonld_resolve_url + isset($options['resolver']) or $options['resolver'] = 'jsonld_resolve_url'; + + // resolve all @context URLs in the input + $input = self::copy($input); + $this->_resolveUrls($input, $options['resolver']); + + // do expansion + $ctx = $this->_getInitialContext(); + $expanded = $this->_expand($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) { + $expanded = $expanded->{'@graph'}; + } + // normalize to an array + return self::arrayify($expanded); + } + + /** + * Performs JSON-LD framing. + * + * @param mixed $input the JSON-LD object to frame. + * @param stdClass $frame the JSON-LD frame to use. + * @param $options the framing options. + * [base] the base IRI to use. + * [embed] default @embed flag (default: true). + * [explicit] default @explicit flag (default: false). + * [omitDefault] default @omitDefault flag (default: false). + * [optimize] optimize when compacting (default: false). + * [resolver(url, callback(err, jsonCtx))] the URL resolver to use. + * + * @return stdClass the framed JSON-LD output. + */ + public function frame($input, $frame, $options) { + // set default options + isset($options['base']) or $options['base'] = ''; + isset($options['embed']) or $options['embed'] = true; + isset($options['explicit']) or $options['explicit'] = false; + isset($options['omitDefault']) or $options['omitDefault'] = false; + isset($options['optimize']) or $options['optimize'] = false; + // FIXME: implement jsonld_resolve_url + isset($options['resolver']) or $options['resolver'] = 'jsonld_resolve_url'; + + // preserve frame context + $ctx = $frame->{'@context'} ?: new stdClass(); + + try { + // expand input + $_input = $this->expand($input, $options); + } + catch(JsonLdException $e) { + throw new JsonLdException( + 'Could not expand input before framing.', + 'jsonld.FrameError', $e); + } + + try { + // expand frame + $_frame = $this->expand($frame, $options); + } + catch(JsonLdException $e) { + throw new JsonLdException( + 'Could not expand frame before framing.', + 'jsonld.FrameError', $e); + } + + // do framing + $framed = $this->_frame($_input, $_frame, $options); + + try { + // compact result (force @graph option to true) + $options['graph'] = true; + $options['activeCtx'] = true; + $result = $this->compact($framed, $ctx, $options); + } + catch(JsonLdException $e) { + throw new JsonLdException( + 'Could not compact framed output.', + 'jsonld.FrameError', $e); + } + + $compacted = $result['compacted']; + $ctx = $result['activeCtx']; + + // get graph alias + $graph = $this->_compactIri($ctx, '@graph'); + // remove @preserve from results + $compacted->{$graph} = $this->_removePreserve($ctx, $compacted->{$graph}); + return $compacted; + } + + /** + * Performs JSON-LD normalization. + * + * @param mixed $input the JSON-LD object to normalize. + * @param assoc $options the options to use: + * [base] the base IRI to use. + * [resolver(url, callback(err, jsonCtx))] the URL resolver to use. + * + * @return array the JSON-LD normalized output. + */ + public function normalize($input, $options) { + // set default options + isset($options['base']) or $options['base'] = ''; + // FIXME: implement jsonld_resolve_url + isset($options['resolver']) or $options['resolver'] = 'jsonld_resolve_url'; + + try { + // expand input then do normalization + $expanded = $this->expand($input, $options); + } + catch(JsonLdException $e) { + throw new JsonLdException( + 'Could not expand input before normalization.', + 'jsonld.NormalizeError', $e); + } + + // do normalization + $this->_normalize($expanded); + } + + /** + * Outputs the RDF statements found in the given JSON-LD object. + * + * @param mixed $input the JSON-LD object. + * @param assoc $options the options to use: + * [resolver(url, callback(err, jsonCtx))] the URL resolver to use. + * + * @return array the RDF statements. + */ + public function toRDF($input, $options) { + // set default options + isset($options['base']) or $options['base'] = ''; + // FIXME: implement jsonld_resolve_url + isset($options['resolver']) or $options['resolver'] = 'jsonld_resolve_url'; + + // resolve all @context URLs in the input + $input = self::copy($input); + $this->_resolveUrls($input, $options['resolver']); + + // output RDF statements + return $this->_toRDF($input); + } + + /** + * Processes a local context, resolving any URLs as necessary, and returns a + * new active context in its callback. + * + * @param stdClass $active_ctx the current active context. + * @param mixed $local_ctx the local context to process. + * @param assoc $options the options to use: + * [resolver(url, callback(err, jsonCtx))] the URL resolver to use. + * + * @return stdClass the new active context. + */ + public function processContext($active_ctx, $local_ctx) { + // return initial context early for null context + if($local_ctx === null) { + return $this->_getInitialContext(); + } + + // set default options + isset($options['base']) or $options['base'] = ''; + // FIXME: implement jsonld_resolve_url + isset($options['resolver']) or $options['resolver'] = 'jsonld_resolve_url'; + + // resolve URLs in localCtx + $local_ctx = self::copy($local_ctx); + if(is_object($local_ctx) && !property_exists($local_ctx, '@context')) { + $local_ctx = (object)array('@context' => $local_ctx); + } + $ctx = $this->_resolveUrls($local_ctx, $options['resolver']); + + // process context + return $this->_processContext($active_ctx, $ctx, $options); + } + + /** + * Returns true if the given subject has the given property. + * + * @param stdClass $subject the subject to check. + * @param string $property the property to look for. + * + * @return bool true if the subject has the given property, false if not. + */ + public static function hasProperty($subject, $property) { + $rval = false; + if(property_exists($subject, $property)) { + $value = $subject->{$property}; + $rval = (!is_array($value) || count($value) > 0); + } + return $rval; + } + + /** + * Determines if the given value is a property of the given subject. + * + * @param stdClass $subject the subject to check. + * @param string $property the property to check. + * @param mixed $value the value to check. + * + * @return bool true if the value exists, false if not. + */ + public static function hasValue($subject, $property, $value) { + $rval = false; + if(self::hasProperty($subject, $property)) { + $val = $subject->{$property}; + $isList = self::_isListValue($val); + if(is_array($val) || $isList) { + if($isList) { + $val = $val->{'@list'}; + } + foreach($val as $v) { + if(self::compareValues($value, $v)) { + $rval = true; + break; + } + } } - else - { - // get coerce type - $coerce = $this->getCoerceType($ctx, $property, $usedCtx); + // avoid matching the set of values with an array value parameter + else if(!is_array($value)) { + $rval = self::compareValues($value, $val); + } + } + return $rval; + } - // get type from value, to ensure coercion is valid - $type = null; - if(is_object($value)) - { - // type coercion can only occur if language is not specified - if(!property_exists($value, '@language')) - { - // type must match coerce type if specified - if(property_exists($value, '@type')) - { - $type = $value->{'@type'}; - } - // type is ID (IRI) - else if(property_exists($value, '@id')) - { - $type = '@id'; - } - // can be coerced to any type - else - { - $type = $coerce; - } - } - } - // type can be coerced to anything - else if(is_string($value)) - { - $type = $coerce; - } + /** + * Adds a value to a subject. If the subject already has the value, it will + * not be added. If the value is an array, all values in the array will be + * added. + * + * Note: If the value is a subject that already exists as a property of the + * given subject, this method makes no attempt to deeply merge properties. + * Instead, the value will not be added. + * + * @param stdClass $subject the subject to add the value to. + * @param string $property the property that relates the value to the subject. + * @param mixed $value the value to add. + * @param bool [$propertyIsArray] true if the property is always an array, + * false if not (default: false). + */ + public static function addValue( + $subject, $property, $value, $propertyIsArray=false) { + if(is_array($value)) { + if(count($value) === 0 && $propertyIsArray && + !property_exists($subject, $property)) { + $subject->{$property} = array(); + } + foreach($value as $v) { + self::addValue($subject, $property, $v, $propertyIsArray); + } + } + else if(property_exists($subject, $property)) { + $hasValue = self::hasValue($subject, $property, $value); - // types that can be auto-coerced from a JSON-builtin - if($coerce === null and - ($type === JSONLD_XSD_BOOLEAN or $type === JSONLD_XSD_INTEGER or - $type === JSONLD_XSD_DOUBLE)) - { - $coerce = $type; - } - - // do reverse type-coercion - if($coerce !== null) - { - // type is only null if a language was specified, which is an error - // if type coercion is specified - if($type === null) - { - throw new Exception( - 'Cannot coerce type when a language is specified. ' . - 'The language information would be lost.'); - } - // if the value type does not match the coerce type, it is an error - else if($type !== $coerce) - { - throw new Exception( - 'Cannot coerce type because the type does not match.'); - } - // do reverse type-coercion - else - { - if(is_object($value)) - { - if(property_exists($value, '@id')) - { - $rval = $value->{'@id'}; - } - else if(property_exists($value, '@value')) - { - $rval = $value->{'@value'}; - } - } - else - { - $rval = $value; - } - - // do basic JSON types conversion - if($coerce === JSONLD_XSD_BOOLEAN) - { - $rval = ($rval === 'true' or $rval != 0); - } - else if($coerce === JSONLD_XSD_DOUBLE) - { - $rval = floatval($rval); - } - else if($coerce === JSONLD_XSD_INTEGER) - { - $rval = intval($rval); - } - } - } - // no type-coercion, just change keywords/copy value - else if(is_object($value)) - { - $rval = new stdClass(); - foreach($value as $key => $v) - { - $rval->{$keywords->$key} = $v; - } - } - else - { - $rval = _clone($value); - } - - // compact IRI - if($type === '@id') - { - if(is_object($rval)) - { - $rval->{$keywords->{'@id'}} = _compactIri( - $ctx, $rval->{$keywords->{'@id'}}, $usedCtx); - } - else - { - $rval = _compactIri($ctx, $rval, $usedCtx); - } - } + // make property an array if value not present or always an array + if(!is_array($subject->{$property}) && (!$hasValue || $propertyIsArray)) { + $subject->{$property} = array($subject->{$property}); } + // add new value + if(!$hasValue) { + $subject->{$property}[] = $value; + } + } + else { + // add new value as set or single value + $subject->{$property} = $propertyIsArray ? array($value) : $value; + } + } + + /** + * Gets all of the values for a subject's property as an array. + * + * @param stdClass $subject the subject. + * @param string $property the property. + * + * @return array all of the values for a subject's property as an array. + */ + public static function getValues($subject, $property) { + $rval = $subject->{$property} ?: array(); + return self::arrayify($rval); + } + + /** + * Removes a property from a subject. + * + * @param stdClass $subject the subject. + * @param string $property the property. + */ + public static function removeProperty($subject, $property) { + unset($subject->{$property}); + } + + /** + * Removes a value from a subject. + * + * @param stdClass $subject the subject. + * @param string $property the property that relates the value to the subject. + * @param mixed $value the value to remove. + * @param bool [$propertyIsArray] true if the property is always an array, + * false if not (default: false). + */ + public static function removeValue( + $subject, $property, $value, $propertyIsArray=false) { + + // filter out value + $filter = function($e) use ($value) { + return !self::compareValues($e, $value); + }; + $values = self::getValues($subject, $property); + $values = array_filter($values, $filter); + + if(count($values) === 0) { + self::removeProperty($subject, $property); + } + else if(count($values) === 1 && !$propertyIsArray) { + $subject->{$property} = $values[0]; + } + else { + $subject->{$property} = $values; + } + } + + /** + * Compares two JSON-LD values for equality. Two JSON-LD values will be + * 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. + * + * @param mixed $v1 the first value. + * @param mixed $v2 the second value. + * + * @return bool true if v1 and v2 are considered equal, false if not. + */ + public static function compareValues($v1, $v2) { + // 1. equal primitives + if($v1 === $v2) { + return true; + } + + // 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'})) { + return true; + } + + // 3. equal @ids + if(is_object($v1) && property_exists($v1, '@id') && + is_object($v2) && property_exists($v2, '@id')) { + return $v1->{'@id'} === $v2->{'@id'}; + } + + return false; + } + + /** + * Compares two JSON-LD normalized inputs for equality. + * + * @param array $n1 the first normalized input. + * @param array $n2 the second normalized input. + * + * @return bool true if the inputs are equivalent, false if not. + */ + public static function compareNormalized($n1, $n2) { + if(!is_array($n1) || !is_array($n2)) { + throw new JsonLdException( + 'Invalid JSON-LD syntax; normalized JSON-LD must be an array.', + 'jsonld.SyntaxError'); + } + + // different # of subjects + if(count($n1) !== count($n2)) { + return false; + } + + // assume subjects are in the same order because of normalization + foreach($n1 as $i => $s1) { + $s2 = $n2[$i]; + + // different @ids + if($s1->{'@id'} !== $s2->{'@id'}) { + return false; + } + + // subjects have different properties + if(count(get_object_vars($s1)) !== count(get_object_vars($s2))) { + return false; + } + + foreach($s1 as $p => $objects) { + // skip @id property + if($p === '@id') { + continue; + } + + // s2 is missing s1 property + if(!self::hasProperty($s2, $p)) { + return false; + } + + // subjects have different objects for the property + if(count($objects) !== count($s2->{$p})) { + return false; + } + + foreach($objects as $oi => $o) { + // s2 is missing s1 object + if(!self::hasValue($s2, $p, $o)) { + return false; + } + } + } + } + + return true; + } + + /** + * Gets the value for the given active context key and type, null if none is + * set. + * + * @param stdClass $ctx the active context. + * @param string $key the context key. + * @param string [$type] the type of value to get (eg: '@id', '@type'), if not + * specified gets the entire entry for a key, null if not found. + * + * @return mixed the value. + */ + public static function getContextValue($ctx, $key, $type) { + $rval = null; + + // return null for invalid key + if($key === null) { return $rval; - } + } - /** - * Recursively expands a value using the given context. Any context in - * the value will be removed. - * - * @param ctx the context. - * @param property the property that points to the value, NULL for none. - * @param value the value to expand. - * - * @return the expanded value. - */ - public function expand($ctx, $property, $value) - { - $rval; + // get default language + if($type === '@language' && property_exists($ctx, $type)) { + $rval = $ctx->{$type}; + } - // TODO: add data format error detection? + // get specific entry information + if(property_exists($ctx->mappings, $key)) { + $entry = $ctx->mappings->{$key}; - // value is null, nothing to expand - if($value === null) - { - $rval = null; + // return whole entry + if($type === null) { + $rval = $entry; } - // if no property is specified and the value is a string (this means the - // value is a property itself), expand to an IRI - else if($property === null and is_string($value)) - { - $rval = _expandTerm($ctx, $value, null); + // return entry value for type + else if(property_exists($entry, $type)) { + $rval = $entry->{$type}; } - else if(is_array($value)) - { - // recursively add expanded values to array - $rval = array(); - foreach($value as $v) - { - $rval[] = $this->expand($ctx, $property, $v); - } - } - else if(is_object($value)) - { - // if value has a context, use it - if(property_exists($value, '@context')) - { - $ctx = jsonld_merge_contexts($ctx, $value->{'@context'}); - } + } - // recursively handle sub-properties that aren't a sub-context - $rval = new stdClass(); - foreach($value as $key => $v) - { - // preserve frame keywords - if($key === '@embed' or $key === '@explicit' or - $key === '@default' or $key === '@omitDefault') - { - _setProperty($rval, $key, _clone($v)); - } - else if($key !== '@context') - { - // set object to expanded property - _setProperty( - $rval, _expandTerm($ctx, $key, null), - $this->expand($ctx, $key, $v)); - } - } - } - else - { - // do type coercion - $coerce = $this->getCoerceType($ctx, $property, null); + return $rval; + } - // get JSON-LD keywords - $keywords = _getKeywords($ctx); + /** + * If $value is an array, returns $value, otherwise returns an array + * containing $value as the only element. + * + * @param mixed $value the value. + * + * @return an array. + */ + public static function arrayify($value) { + if(is_array($value)) { + return $value; + } + return array($value); + } - // automatic coercion for basic JSON types - if($coerce === null and !is_string($value) and - (is_numeric($value) or is_bool($value))) - { - if(is_bool($value)) - { - $coerce = JSONLD_XSD_BOOLEAN; - } - else if(is_int($value)) - { - $coerce = JSONLD_XSD_INTEGER; - } - else - { - $coerce = JSONLD_XSD_DOUBLE; - } - } + /** + * Clones an object, array, or string/number. + * + * @param mixed $value the value to clone. + * + * @return mixed the cloned value. + */ + public static function copy($value) { + if(is_object($value) || is_array($value)) { + return unserialize(serialize($value)); + } + else { + return $value; + } + } - // special-case expand @id and @type (skips '@id' expansion) - if($property === '@id' or $property === $keywords->{'@id'} or - $property === '@type' or $property === $keywords->{'@type'}) - { - $rval = _expandTerm($ctx, $value, null); - } - // coerce to appropriate type - else if($coerce !== null) - { - $rval = new stdClass(); - - // expand ID (IRI) - if($coerce === '@id') - { - $rval->{'@id'} = _expandTerm($ctx, $value, null); - } - // other type - else - { - $rval->{'@type'} = $coerce; - if($coerce === JSONLD_XSD_DOUBLE) - { - // do special JSON-LD double format - $value = preg_replace( - '/(e(?:\+|-))([0-9])$/', '${1}0${2}', - sprintf('%1.6e', $value)); - } - else if($coerce === JSONLD_XSD_BOOLEAN) - { - $value = $value ? 'true' : 'false'; - } - $rval->{'@value'} = '' . $value; - } - } - // nothing to coerce - else - { - $rval = '' . $value; - } - } - - return $rval; - } - - /** - * Normalizes a JSON-LD object. - * - * @param input the JSON-LD object to normalize. - * - * @return the normalized JSON-LD object. - */ - public function normalize($input) - { + /** + * 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 mixed $element the element to compact. + * @param array $options the compaction options. + * + * @return mixed the compacted value. + */ + protected function _compact($ctx, $property, $element, $options) { + // recursively compact array + if(is_array($element)) { $rval = array(); - - // TODO: validate context - - if($input !== null) - { - // create name generator state - $this->ng = new stdClass(); - $this->ng->tmp = null; - $this->ng->c14n = null; - - // expand input - $expanded = $this->expand(new stdClass(), null, $input); - - // assign names to unnamed bnodes - $this->nameBlankNodes($expanded); - - // flatten - $subjects = new stdClass(); - _flatten(null, null, $expanded, $subjects); - - // append subjects with sorted properties to array - foreach($subjects as $key => $s) - { - $sorted = new stdClass(); - $keys = array_keys((array)$s); - sort($keys); - foreach($keys as $i => $k) - { - $sorted->$k = $s->$k; - } - $rval[] = $sorted; - } - - // canonicalize blank nodes - $this->canonicalizeBlankNodes($rval); - - // sort output - usort($rval, '_compareIris'); + foreach($element as $e) { + $e = $this->_compact($ctx, $property, $e, $options); + // drop null values + if($e !== null) { + $rval[] = $e; + } } - - return $rval; - } - - /** - * Gets the coerce type for the given property. - * - * @param ctx the context to use. - * @param property the property to get the coerced type for. - * @param usedCtx a context to update if a value was used from "ctx". - * - * @return the coerce type, null for none. - */ - public function getCoerceType($ctx, $property, $usedCtx) - { - $rval = null; - - // get expanded property - $p = _expandTerm($ctx, $property, null); - - // built-in type coercion JSON-LD-isms - if($p === '@id' or $p === '@type') - { - $rval = '@id'; - } - else - { - // look up compacted property in coercion map - $p = _compactIri($ctx, $p, null); - if(property_exists($ctx, $p) and is_object($ctx->$p) and - property_exists($ctx->$p, '@type')) - { - // property found, return expanded type - $type = $ctx->$p->{'@type'}; - $rval = _expandTerm($ctx, $type, $usedCtx); - if($usedCtx !== null) - { - $usedCtx->$p = _clone($ctx->$p); - } - } - } - - return $rval; - } - - /** - * Assigns unique names to blank nodes that are unnamed in the given input. - * - * @param input the input to assign names to. - */ - public function nameBlankNodes($input) - { - // create temporary blank node name generator - $ng = $this->ng->tmp = new NameGenerator('tmp'); - - // collect subjects and unnamed bnodes - $subjects = new stdClass(); - $bnodes = new ArrayObject(); - _collectSubjects($input, $subjects, $bnodes); - - // uniquely name all unnamed bnodes - foreach($bnodes as $i => $bnode) - { - if(!property_exists($bnode, '@id')) - { - // generate names until one is unique - while(property_exists($subjects, $ng->next())); - $bnode->{'@id'} = $ng->current(); - $subjects->{$ng->current()} = $bnode; - } - } - } - - /** - * Renames a blank node, changing its references, etc. The method assumes - * that the given name is unique. - * - * @param b the blank node to rename. - * @param id the new name to use. - */ - public function renameBlankNode($b, $id) - { - $old = $b->{'@id'}; - - // update bnode IRI - $b->{'@id'} = $id; - - // update subjects map - $subjects = $this->subjects; - $subjects->$id = $subjects->$old; - unset($subjects->$old); - - // update reference and property lists - $this->edges->refs->$id = $this->edges->refs->$old; - $this->edges->props->$id = $this->edges->props->$old; - unset($this->edges->refs->$old); - unset($this->edges->props->$old); - - // update references to this bnode - $refs = $this->edges->refs->$id->all; - foreach($refs as $i => $r) - { - $iri = $r->s; - if($iri === $old) - { - $iri = $id; - } - $ref = $subjects->$iri; - $props = $this->edges->props->$iri->all; - foreach($props as $prop) - { - if($prop->s === $old) - { - $prop->s = $id; - - // normalize property to array for single code-path - $p = $prop->p; - $tmp = is_object($ref->$p) ? array($ref->$p) : - (is_array($ref->$p) ? $ref->$p : array()); - $length = count($tmp); - for($n = 0; $n < $length; ++$n) - { - if(is_object($tmp[$n]) and - property_exists($tmp[$n], '@id') and - $tmp[$n]->{'@id'} === $old) - { - $tmp[$n]->{'@id'} = $id; - } - } - } - } - } - - // update references from this bnode - $props = $this->edges->props->$id->all; - foreach($props as $prop) - { - $iri = $prop->s; - $refs = $this->edges->refs->$iri->all; - foreach($refs as $r) - { - if($r->s === $old) - { - $r->s = $id; - } - } - } - } - - /** - * Canonically names blank nodes in the given input. - * - * @param input the flat input graph to assign names to. - */ - public function canonicalizeBlankNodes($input) - { - // create serialization state - $this->renamed = new stdClass(); - $this->mappings = new stdClass(); - $this->serializations = new stdClass(); - - // collect subjects and bnodes from flat input graph - $edges = $this->edges = new stdClass(); - $edges->refs = new stdClass(); - $edges->props = new stdClass(); - $subjects = $this->subjects = new stdClass(); - $bnodes = array(); - foreach($input as $v) - { - $iri = $v->{'@id'}; - $subjects->$iri = $v; - $edges->refs->$iri = new stdClass(); - $edges->refs->$iri->all = array(); - $edges->refs->$iri->bnodes = array(); - $edges->props->$iri = new stdClass(); - $edges->props->$iri->all = array(); - $edges->props->$iri->bnodes = array(); - if(_isBlankNodeIri($iri)) - { - $bnodes[] = $v; - } - } - - // collect edges in the graph - $this->collectEdges(); - - // create canonical blank node name generator - $c14n = $this->ng->c14n = new NameGenerator('c14n'); - $ngTmp = $this->ng->tmp; - - // rename all bnodes that happen to be in the c14n namespace - // and initialize serializations - foreach($bnodes as $i => $bnode) - { - $iri = $bnode->{'@id'}; - if($c14n->inNamespace($iri)) - { - // generate names until one is unique - while(property_exists($subjects, $ngTmp->next())); - $this->renameBlankNode($bnode, $ngTmp->current()); - $iri = $bnode->{'@id'}; - } - $this->serializations->$iri = new stdClass(); - $this->serializations->$iri->props = null; - $this->serializations->$iri->refs = null; - } - - // keep sorting and naming blank nodes until they are all named - $resort = true; - while(count($bnodes) > 0) - { - if($resort) - { - $resort = false; - usort($bnodes, array($this, 'deepCompareBlankNodes')); - } - - // name all bnodes according to the first bnode's relation mappings - // (if it has mappings then a resort will be necessary) - $bnode = array_shift($bnodes); - $iri = $bnode->{'@id'}; - $resort = ($this->serializations->$iri->{'props'} !== null); - $dirs = array('props', 'refs'); - foreach($dirs as $dir) - { - // if no serialization has been computed, name only the first node - if($this->serializations->$iri->$dir === null) - { - $mapping = new stdClass(); - $mapping->$iri = 's1'; - } - else - { - $mapping = $this->serializations->$iri->$dir->m; - } - - // sort keys by value to name them in order - $keys = array_keys((array)$mapping); - usort($keys, array(new MappingKeySorter($mapping), 'compare')); - - // name bnodes in mapping - $renamed = array(); - foreach($keys as $i => $iriK) - { - if(!$c14n->inNamespace($iri) and - property_exists($subjects, $iriK)) - { - $this->renameBlankNode($subjects->$iriK, $c14n->next()); - $renamed[] = $iriK; - } - } - - // only keep non-canonically named bnodes - $tmp = $bnodes; - $bnodes = array(); - foreach($tmp as $i => $b) - { - $iriB = $b->{'@id'}; - if(!$c14n->inNamespace($iriB)) - { - // mark serializations related to the named bnodes as dirty - foreach($renamed as $r) - { - if($this->markSerializationDirty($iriB, $r, $dir)) - { - // resort if a serialization was marked dirty - $resort = true; - } - } - $bnodes[] = $b; - } - } - } - } - - // sort property lists that now have canonically-named bnodes - foreach($edges->props as $key => $value) - { - $subject = $subjects->$key; - foreach($subject as $p => $v) - { - if(strpos($p, '@id') !== 0 and is_array($v)) - { - usort($v, '_compareObjects'); - $subject->$p = $v; - } - } - } - } - - /** - * Marks a relation serialization as dirty if necessary. - * - * @param iri the IRI of the bnode to check. - * @param changed the old IRI of the bnode that changed. - * @param dir the direction to check ('props' or 'refs'). - * - * @return true if the serialization was marked dirty, false if not. - */ - public function markSerializationDirty($iri, $changed, $dir) - { - $rval = false; - $s = $this->serializations->$iri; - if($s->$dir !== null and property_exists($s->$dir->m, $changed)) - { - $s->$dir = null; - $rval = true; + if(count($rval) === 1) { + // use single element if no container is specified + $container = self::getContextValue($ctx, $property, '@container'); + if($container !== '@list' && $container !== '@set') { + $rval = $rval[0]; + } } return $rval; - } + } - /** - * Serializes the properties of the given bnode for its relation - * serialization. - * - * @param b the blank node. - * - * @return the serialized properties. - */ - public function serializeProperties($b) - { - $rval = ''; + // recursively compact object + if(is_object($element)) { + // element is a @value + if(self::_isValue($element)) { + $type = self::getContextValue($ctx, $property, '@type'); + $language = self::getContextValue($ctx, $property, '@language'); - $first = true; - foreach($b as $p => $o) - { - if($p !== '@id') - { - if($first) - { - $first = false; - } - else - { - $rval .= '|'; - } + // matching @type specified in context, compact element + if($type !== null && + property_exists($element, '@type') && $element->{'@type'} === $type) { + $element = $element->{'@value'}; - // property - $rval .= '<' . $p . '>'; - - // object(s) - $objs = is_array($o) ? $o : array($o); - foreach($objs as $obj) - { - if(is_object($obj)) - { - // ID (IRI) - if(property_exists($obj, '@id')) - { - if(_isBlankNodeIri($obj->{'@id'})) - { - $rval .= '_:'; - } - else - { - $rval .= '<' . $obj->{'@id'} . '>'; - } - } - // literal - else - { - $rval .= '"' . $obj->{'@value'} . '"'; - - // type literal - if(property_exists($obj, '@type')) - { - $rval .= '^^<' . $obj->{'@type'} . '>'; - } - // language literal - else if(property_exists($obj, '@language')) - { - $rval .= '@' . $obj->{'@language'}; - } - } - } - // plain literal - else - { - $rval .= '"' . $obj . '"'; - } - } - } + // use native datatypes for certain xsd types + if($type === self::XSD_BOOLEAN) { + $element = !($element === 'false' || $element === '0'); + } + else if($type === self::XSD_INTEGER) { + $element = intval($element); + } + else if($type === self::XSD_DOUBLE) { + $element = doubleval($element); + } + } + // matching @language specified in context, compact element + else if($language !== null && + property_exists($element, '@language') && + $element->{'@language'} === $language) { + $element = $element->{'@value'}; + } + // compact @type IRI + else if(property_exists($element, '@type')) { + $element->{'@type'} = $this->_compactIri($ctx, $element->{'@type'}); + } + return $element; } + // compact subject references + if(self::_isSubjectReference($element)) { + $type = self::getContextValue($ctx, $property, '@type'); + if($type === '@id') { + $element = $this->_compactIri($ctx, $element->{'@id'}); + return $element; + } + } + + // recursively process element 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, $isArray); + continue; + } + + // Note: value must be an array due to expansion algorithm. + + // preserve empty arrays + if(count($value) === 0) { + $prop =$this->_compactIri($ctx, $key); + self::addValue($rval, $prop, array(), true); + } + + // recusively process array values + foreach($value as $v) { + $isList = self::_isListValue($v); + + // compact property + $prop = $this->_compactIri($ctx, $key, $v); + + // remove @list for recursion (will be re-added if necessary) + if($isList) { + $v = $v->{'@list'}; + } + + // recursively compact value + $v = $this->compact($ctx, $prop, $v, $options); + + // get container type for property + $container = self::getContextValue($ctx, $prop, '@container'); + + // handle @list + if($isList && $container !== '@list') { + // handle messy @list compaction + if(property_exists($rval, $prop) && $options['strict']) { + 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 ' + + '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 + $isArray = ($container === '@set' || $container === '@list' || + (is_array($v) && count($v) === 0)); + + // add compact value + self::addValue($rval, $prop, $v, $isArray); + } + } return $rval; - } + } - /** - * Recursively increments the relation serialization for a mapping. - * - * @param mb the mapping builder to update. - */ - public function serializeMapping($mb) - { - if(count($mb->keyStack) > 0) - { - // continue from top of key stack - $next = array_pop($mb->keyStack); - $len = count($next->keys); - for(; $next->idx < $len; ++$next->idx) - { - $k = $next->keys[$next->idx]; - if(!property_exists($mb->adj, $k)) - { - $mb->keyStack[] = $next; - break; - } + // only primitives remain which are already compact + return $element; + } - if(property_exists($mb->done, $k)) - { - // mark cycle - $mb->s .= '_' . $k; - } - else - { - // mark key as serialized - $mb->done->$k = true; - - // serialize top-level key and its details - $s = $k; - $adj = $mb->adj->$k; - $iri = $adj->i; - if(property_exists($this->subjects, $iri)) - { - $b = $this->subjects->$iri; - - // serialize properties - $s .= '[' . $this->serializeProperties($b) . ']'; - - // serialize references - $s .= '['; - $first = true; - $refs = $this->edges->refs->$iri->all; - foreach($refs as $r) - { - if($first) - { - $first = false; - } - else - { - $s .= '|'; - } - $s .= '<' . $r->p . '>'; - $s .= _isBlankNodeIri($r->s) ? - '_:' : ('<' . $refs->s . '>'); - } - $s .= ']'; - } - - // serialize adjacent node keys - $s .= implode($adj->k); - $mb->s .= $s; - $entry = new stdClass(); - $entry->keys = $adj->k; - $entry->idx = 0; - $mb->keyStack[] = $entry; - $this->serializeMapping($mb); - } - } - } - } - - /** - * Recursively serializes adjacent bnode combinations. - * - * @param s the serialization to update. - * @param iri the IRI of the bnode being serialized. - * @param siri the serialization name for the bnode IRI. - * @param mb the MappingBuilder to use. - * @param dir the edge direction to use ('props' or 'refs'). - * @param mapped all of the already-mapped adjacent bnodes. - * @param notMapped all of the not-yet mapped adjacent bnodes. - */ - public function serializeCombos( - $s, $iri, $siri, $mb, $dir, $mapped, $notMapped) - { - // handle recursion - if(count($notMapped) > 0) - { - // copy mapped nodes - $mapped = _clone($mapped); - - // map first bnode in list - $mapped->{$mb->mapNode($notMapped[0]->s)} = $notMapped[0]->s; - - // recurse into remaining possible combinations - $original = $mb->copy(); - $notMapped = array_slice($notMapped, 1); - $rotations = max(1, count($notMapped)); - for($r = 0; $r < $rotations; ++$r) - { - $m = ($r === 0) ? $mb : $original->copy(); - $this->serializeCombos( - $s, $iri, $siri, $m, $dir, $mapped, $notMapped); - - // rotate not-mapped for next combination - _rotate($notMapped); - } - } - // no more adjacent bnodes to map, update serialization - else - { - $keys = array_keys((array)$mapped); - sort($keys); - $entry = new stdClass(); - $entry->i = $iri; - $entry->k = $keys; - $entry->m = $mapped; - $mb->adj->$siri = $entry; - $this->serializeMapping($mb); - - // optimize away mappings that are already too large - if($s->$dir === null or - _compareSerializations($mb->s, $s->$dir->s) <= 0) - { - // recurse into adjacent values - foreach($keys as $i => $k) - { - $this->serializeBlankNode($s, $mapped->$k, $mb, $dir); - } - - // update least serialization if new one has been found - $this->serializeMapping($mb); - if($s->$dir === null or - (_compareSerializations($mb->s, $s->$dir->s) <= 0 and - strlen($mb->s) >= strlen($s->$dir->s))) - { - $s->$dir = new stdClass(); - $s->$dir->s = $mb->s; - $s->$dir->m = $mb->mapping; - } - } - } - } - - /** - * Computes the relation serialization for the given blank node IRI. - * - * @param s the serialization to update. - * @param iri the current bnode IRI to be mapped. - * @param mb the MappingBuilder to use. - * @param dir the edge direction to use ('props' or 'refs'). - */ - public function serializeBlankNode($s, $iri, $mb, $dir) - { - // only do mapping if iri not already processed - if(!property_exists($mb->processed, $iri)) - { - // iri now processed - $mb->processed->$iri = true; - $siri = $mb->mapNode($iri); - - // copy original mapping builder - $original = $mb->copy(); - - // split adjacent bnodes on mapped and not-mapped - $adjs = $this->edges->$dir->$iri->bnodes; - $mapped = new stdClass(); - $notMapped = array(); - foreach($adjs as $adj) - { - if(property_exists($mb->mapping, $adj->s)) - { - $mapped->{$mb->mapping->{$adj->s}} = $adj->s; - } - else - { - $notMapped[] = $adj; - } - } - - // TODO: ensure this optimization does not alter canonical order - - // if the current bnode already has a serialization, reuse it - /*$hint = property_exists($this->serializations, $iri) ? - $this->serializations->$iri->$dir : null; - if($hint !== null) - { - $hm = $hint->m; - usort(notMapped, - { - return _compare(hm[a.s], hm[b.s]); - }); - for($i in notMapped) - { - mapped[mb.mapNode(notMapped[i].s)] = notMapped[i].s; - } - notMapped = array(); - }*/ - - // loop over possible combinations - $combos = max(1, count($notMapped)); - for($i = 0; $i < $combos; ++$i) - { - $m = ($i === 0) ? $mb : $original->copy(); - $this->serializeCombos( - $s, $iri, $siri, $mb, $dir, $mapped, $notMapped); - } - } - } - - /** - * Compares two blank nodes for equivalence. - * - * @param a the first blank node. - * @param b the second blank node. - * - * @return -1 if a < b, 0 if a == b, 1 if a > b. - */ - public function deepCompareBlankNodes($a, $b) - { - $rval = 0; - - // compare IRIs - $iriA = $a->{'@id'}; - $iriB = $b->{'@id'}; - if($iriA === $iriB) - { - $rval = 0; - } - else - { - // do shallow compare first - $rval = $this->shallowCompareBlankNodes($a, $b); - - // deep comparison is necessary - if($rval === 0) - { - // compare property edges and then reference edges - $dirs = array('props', 'refs'); - for($i = 0; $rval === 0 and $i < 2; ++$i) - { - // recompute 'a' and 'b' serializations as necessary - $dir = $dirs[$i]; - $sA = $this->serializations->$iriA; - $sB = $this->serializations->$iriB; - if($sA->$dir === null) - { - $mb = new MappingBuilder(); - if($dir === 'refs') - { - // keep same mapping and count from 'props' serialization - $mb->mapping = _clone($sA->props->m); - $mb->count = count(array_keys((array)$mb->mapping)) + 1; - } - $this->serializeBlankNode($sA, $iriA, $mb, $dir); - } - if($sB->$dir === null) - { - $mb = new MappingBuilder(); - if($dir === 'refs') - { - // keep same mapping and count from 'props' serialization - $mb->mapping = _clone($sB->props->m); - $mb->count = count(array_keys((array)$mb->mapping)) + 1; - } - $this->serializeBlankNode($sB, $iriB, $mb, $dir); - } - - // compare serializations - $rval = _compare($sA->$dir->s, $sB->$dir->s); - } - } - } - - return $rval; - } - - /** - * Performs a shallow sort comparison on the given bnodes. - * - * @param a the first bnode. - * @param b the second bnode. - * - * @return -1 if a < b, 0 if a == b, 1 if a > b. - */ - public function shallowCompareBlankNodes($a, $b) - { - $rval = 0; - - /* ShallowSort Algorithm (when comparing two bnodes): - 1. Compare the number of properties. - 1.1. The bnode with fewer properties is first. - 2. Compare alphabetically sorted-properties. - 2.1. The bnode with the alphabetically-first property is first. - 3. For each property, compare object values. - 4. Compare the number of references. - 4.1. The bnode with fewer references is first. - 5. Compare sorted references. - 5.1. The bnode with the reference iri (vs. bnode) is first. - 5.2. The bnode with the alphabetically-first reference iri is first. - 5.3. The bnode with the alphabetically-first reference property is - first. - */ - $pA = array_keys((array)$a); - $pB = array_keys((array)$b); - - // step #1 - $rval = _compare(count($pA), count($pB)); - - // step #2 - if($rval === 0) - { - sort($pA); - sort($pB); - $rval = _compare($pA, $pB); - } - - // step #3 - if($rval === 0) - { - $rval = _compareBlankNodeObjects($a, $b); - } - - // step #4 - if($rval === 0) - { - $edgesA = $this->edges->refs->{$a->{'@id'}}->all; - $edgesB = $this->edges->refs->{$b->{'@id'}}->all; - $edgesALen = count($edgesA); - $rval = _compare($edgesALen, count($edgesB)); - } - - // step #5 - if($rval === 0) - { - for($i = 0; $i < $edgesALen and $rval === 0; ++$i) - { - $rval = $this->compareEdges($edgesA[$i], $edgesB[$i]); - } - } - - return $rval; - } - - /** - * Compares two edges. Edges with an IRI (vs. a bnode ID) come first, then - * alphabetically-first IRIs, then alphabetically-first properties. If a - * blank node has been canonically named, then blank nodes will be compared - * after properties (with a preference for canonically named over - * non-canonically named), otherwise they won't be. - * - * @param a the first edge. - * @param b the second edge. - * - * @return -1 if a < b, 0 if a == b, 1 if a > b. - */ - public function compareEdges($a, $b) - { - $rval = 0; - - $bnodeA = _isBlankNodeIri($a->s); - $bnodeB = _isBlankNodeIri($b->s); - $c14n = $this->ng->c14n; - - // if not both bnodes, one that is a bnode is greater - if($bnodeA != $bnodeB) - { - $rval = $bnodeA ? 1 : -1; - } - else - { - if(!$bnodeA) - { - $rval = _compare($a->s, $b->s); - } - if($rval === 0) - { - $rval = _compare($a->p, $b->p); - } - - // do bnode IRI comparison if canonical naming has begun - if($rval === 0 and $c14n !== null) - { - $c14nA = $c14n->inNamespace($a->s); - $c14nB = $c14n->inNamespace($b->s); - if($c14nA != $c14nB) - { - $rval = $c14nA ? 1 : -1; - } - else if($c14nA) - { - $rval = _compare($a->s, $b->s); - } - } - } - - return $rval; - } - - /** - * Populates the given reference map with all of the subject edges in the - * graph. The references will be categorized by the direction of the edges, - * where 'props' is for properties and 'refs' is for references to a subject - * as an object. The edge direction categories for each IRI will be sorted - * into groups 'all' and 'bnodes'. - */ - public function collectEdges() - { - $refs = $this->edges->refs; - $props = $this->edges->props; - - // collect all references and properties - foreach($this->subjects as $iri => $subject) - { - foreach($subject as $key => $object) - { - if($key !== '@id') - { - // normalize to array for single codepath - $tmp = !is_array($object) ? array($object) : $object; - foreach($tmp as $o) - { - if(is_object($o) and property_exists($o, '@id') and - property_exists($this->subjects, $o->{'@id'})) - { - $objIri = $o->{'@id'}; - - // map object to this subject - $e = new stdClass(); - $e->s = $iri; - $e->p = $key; - $refs->$objIri->all[] = $e; - - // map this subject to object - $e = new stdClass(); - $e->s = $objIri; - $e->p = $key; - $props->$iri->all[] = $e; - } - } - } - } - } - - // create sorted categories - foreach($refs as $iri => $ref) - { - usort($ref->all, array($this, 'compareEdges')); - $ref->bnodes = array_filter($ref->all, '_filterBlankNodeEdges'); - } - foreach($props as $iri => $prop) - { - usort($prop->all, array($this, 'compareEdges')); - $prop->bnodes = array_filter($prop->all, '_filterBlankNodeEdges'); - } - } -} - -/** - * Returns true if the given input is a subject and has one of the given types - * in the given frame. - * - * @param input the input. - * @param frame the frame with types to look for. - * - * @return true if the input has one of the given types. - */ -function _isType($input, $frame) -{ - $rval = false; - - // check if type(s) are specified in frame and input - $type = '@type'; - if(property_exists($frame, $type) and - is_object($input) and - property_exists($input, $type)) - { - $tmp = is_array($input->$type) ? $input->$type : array($input->$type); - $types = is_array($frame->$type) ? $frame->$type : array($frame->$type); - $length = count($types); - for($t = 0; $t < $length and !$rval; ++$t) - { - $type = $types[$t]; - foreach($tmp as $e) - { - if($e === $type) - { - $rval = true; - break; - } - } - } - } - - return $rval; -} - -/** - * Filters non-keywords. - * - * @param e the element to check. - * - * @return true if the element is a non-keyword. - */ -function _filterNonKeyWords($e) -{ - return strpos($e, '@') !== 0; -} - -/** - * Returns true if the given input matches the given frame via duck-typing. - * - * @param input the input. - * @param frame the frame to check against. - * - * @return true if the input matches the frame. - */ -function _isDuckType($input, $frame) -{ - $rval = false; - - // frame must not have a specific type - if(!property_exists($frame, '@type')) - { - // get frame properties that must exist on input - $props = array_filter(array_keys((array)$frame), '_filterNonKeywords'); - if(count($props) === 0) - { - // input always matches if there are no properties - $rval = true; - } - // input must be a subject with all the given properties - else if(is_object($input) and property_exists($input, '@id')) - { - $rval = true; - foreach($props as $prop) - { - if(!property_exists($input, $prop)) - { - $rval = false; - break; - } - } - } - } - - return $rval; -} - -/** - * Recursively removes dependent dangling embeds. - * - * @param iri the iri of the parent to remove embeds for. - * @param embeds the embeds map. - */ -function removeDependentEmbeds($iri, $embeds) -{ - $iris = get_object_vars($embeds); - foreach($iris as $i => $embed) - { - if($embed->parent !== null and - $embed->parent->{'@id'} === $iri) - { - unset($embeds->$i); - removeDependentEmbeds($i, $embeds); - } - } -} - -/** - * Subframes a value. - * - * @param subjects a map of subjects in the graph. - * @param value the value to subframe. - * @param frame the frame to use. - * @param embeds a map of previously embedded subjects, used to prevent cycles. - * @param autoembed true if auto-embed is on, false if not. - * @param parent the parent object. - * @param parentKey the parent object. - * @param options the framing options. - * - * @return the framed input. - */ -function _subframe( - $subjects, $value, $frame, $embeds, $autoembed, - $parent, $parentKey, $options) -{ - // get existing embed entry - $iri = $value->{'@id'}; - $embed = property_exists($embeds, $iri) ? $embeds->{$iri} : null; - - // determine if value should be embedded or referenced, - // embed is ON if: - // 1. The frame OR default option specifies @embed as ON, AND - // 2. There is no existing embed OR it is an autoembed, AND - // autoembed mode is off. - $embedOn = ( - ((property_exists($frame, '@embed') and $frame->{'@embed'}) or - (!property_exists($frame, '@embed') and $options->defaults->embedOn)) and - ($embed === null or ($embed->autoembed and !$autoembed))); - - if(!$embedOn) - { - // not embedding, so only use subject IRI as reference - $tmp = new stdClass(); - $tmp->{'@id'} = $value->{'@id'}; - $value = $tmp; - } - else - { - // create new embed entry - if($embed === null) - { - $embed = new stdClass(); - $embeds->{$iri} = $embed; - } - // replace the existing embed with a reference - else if($embed->parent !== null) - { - if(is_array($embed->parent->{$embed->key})) - { - // find and replace embed in array - $arrLen = count($embed->parent->{$embed->key}); - for($i = 0; $i < $arrLen; ++$i) - { - $obj = $embed->parent->{$embed->key}[$i]; - if(is_object($obj) and property_exists($obj, '@id') and - $obj->{'@id'} === $iri) - { - $tmp = new stdClass(); - $tmp->{'@id'} = $value->{'@id'}; - $embed->parent->{$embed->key}[$i] = $tmp; - break; - } - } - } - else - { - $tmp = new stdClass(); - $tmp->{'@id'} = $value->{'@id'}; - $embed->parent->{$embed->key} = $tmp; - } - - // recursively remove any dependent dangling embeds - removeDependentEmbeds($iri, $embeds); - } - - // update embed entry - $embed->autoembed = $autoembed; - $embed->parent = $parent; - $embed->key = $parentKey; - - // check explicit flag - $explicitOn = property_exists($frame, '@explicit') ? - $frame->{'@explicit'} : $options->defaults->explicitOn; - if($explicitOn) - { - // remove keys from the value that aren't in the frame - foreach($value as $key => $v) - { - // do not remove @id or any frame key - if($key !== '@id' and !property_exists($frame, $key)) - { - unset($value->$key); - } - } - } - - // iterate over keys in value - $vars = get_object_vars($value); - foreach($vars as $key => $v) - { - // skip keywords - if(strpos($key, '@') !== 0) - { - // get the subframe if available - if(property_exists($frame, $key)) - { - $f = $frame->{$key}; - $_autoembed = false; - } - // use a catch-all subframe to preserve data from graph - else - { - $f = is_array($v) ? array() : new stdClass(); - $_autoembed = true; - } - - // build input and do recursion - $input = is_array($v) ? $v : array($v); - $length = count($input); - for($n = 0; $n < $length; ++$n) - { - // replace reference to subject w/embedded subject - if(is_object($input[$n]) and - property_exists($input[$n], '@id') and - property_exists($subjects, $input[$n]->{'@id'})) - { - $input[$n] = $subjects->{$input[$n]->{'@id'}}; - } - } - $value->$key = _frame( - $subjects, $input, $f, $embeds, $_autoembed, - $value, $key, $options); - } - } - - // iterate over frame keys to add any missing values - foreach($frame as $key => $f) - { - // skip keywords and non-null keys in value - if(strpos($key, '@') !== 0 and - (!property_exists($value, $key) || $value->{$key} === null)) - { - // add empty array to value - if(is_array($f)) - { - // add empty array/null property to value - $value->$key = array(); - } - // add default value to value - else - { - // use first subframe if frame is an array - if(is_array($f)) - { - $f = (count($f) > 0) ? $f[0] : new stdClass(); - } - - // determine if omit default is on - $omitOn = property_exists($f, '@omitDefault') ? - $f->{'@omitDefault'} : - $options->defaults->omitDefaultOn; - if(!$omitOn) - { - if(property_exists($f, '@default')) - { - // use specified default value - $value->{$key} = $f->{'@default'}; - } - else - { - // build-in default value is: null - $value->{$key} = null; - } - } - } - } - } - } - - return $value; -} - -/** - * Recursively frames the given input according to the given frame. - * - * @param subjects a map of subjects in the graph. - * @param input the input to frame. - * @param frame the frame to use. - * @param embeds a map of previously embedded subjects, used to prevent cycles. - * @param autoembed true if auto-embed is on, false if not. - * @param parent the parent object (for subframing), null for none. - * @param parentKey the parent key (for subframing), null for none. - * @param options the framing options. - * - * @return the framed input. - */ -function _frame( - $subjects, $input, $frame, $embeds, $autoembed, - $parent, $parentKey, $options) -{ - $rval = null; - - // prepare output, set limit, get array of frames - $limit = -1; - if(is_array($frame)) - { + /** + * Recursively expands an element using the given context. Any context in + * 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 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. + * + * @return mixed the expanded value. + */ + protected function _expand( + $ctx, $property, $element, $options, $propertyIsList) { + // recursively expand array + if(is_array($element)) { $rval = array(); - $frames = $frame; - if(count($frames) == 0) - { - $frames[] = new stdClass(); + foreach($element as $e) { + // expand element + $e = $this->_expand($ctx, $property, $e, $options, $propertyIsList); + if(is_array($e) && $propertyIsList) { + // lists of lists are illegal + throw new JsonLdException( + 'Invalid JSON-LD syntax; lists of lists are not permitted.', + 'jsonld.SyntaxError'); + } + // drop null values + else if($e !== null) { + $rval[] = $e; + } } - } - else - { - $frames = array($frame); - $limit = 1; - } + return $rval; + } - // iterate over frames adding input matches to list - $frameLen = count($frames); - $values = array(); - for($i = 0; $i < $frameLen and $limit !== 0; ++$i) - { - // get next frame - $frame = $frames[$i]; - if(!is_object($frame)) - { - throw new Exception( - 'Invalid JSON-LD frame. Frame type is not a map or array.'); + // recursively expand object + if(is_object($element)) { + // if element has a context, process it + if(property_exists($element, '@context')) { + $ctx = $this->_processContext($ctx, $element->{'@context'}, $options); + unset($element->{'@context'}); } - // create array of values for each frame - $inLen = count($input); - $v = array(); - for($n = 0; $n < $inLen and $limit !== 0; ++$n) - { - // dereference input if it refers to a subject - $next = $input[$n]; - if(is_object($next) and property_exists($next, '@id') and - property_exists($subjects, $next->{'@id'})) - { - $next = $subjects->{$next->{'@id'}}; - } + $rval = new stdClass(); + foreach($element as $key => $value) { + // expand property + $prop = $this->_expandTerm($ctx, $key); - // add input to list if it matches frame specific type or duck-type - if(_isType($next, $frame) or _isDuckType($next, $frame)) - { - $v[] = $next; - --$limit; - } - } - $values[$i] = $v; - } + // drop non-absolute IRI keys that aren't keywords + if(!self::_isAbsoluteIri($prop) && !self::_isKeyword($prop, $ctx)) { + continue; + } - // for each matching value, add it to the output - $vaLen = count($values); - for($i1 = 0; $i1 < $vaLen; ++$i1) - { - foreach($values[$i1] as $value) - { - $frame = $frames[$i1]; + // if value is null and property is not @value, continue + $value = $element->{$key}; + if($value === null && $prop !== '@value') { + continue; + } - // if value is a subject, do subframing - if(_isSubject($value)) - { - $value = _subframe( - $subjects, $value, $frame, $embeds, $autoembed, - $parent, $parentKey, $options); - } + // 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)); + } - // add value to output - if($rval === null) - { - $rval = $value; - } - else - { - // determine if value is a reference to an embed - $isRef = (_isReference($value) and - property_exists($embeds, $value->{'@id'})); + // @type must be a string, array of strings, or an empty JSON object + if($prop === '@type' && + !(is_string($value) || self::_isArrayOfStrings($value) || + self::_isEmptyObject($value))) { + throw new JsonLdException( + 'Invalid JSON-LD syntax; "@type" value must a string, an array ' + + 'of strings, or an empty object.', + 'jsonld.SyntaxError', array('value' => $value)); + } - // push any value that isn't a parentless reference - if(!($parent === null and $isRef)) - { - $rval[] = $value; + // @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)); + } + + // @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)); + } + + // @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, @set, or @graph, keeping the active property + $isList = ($prop === '@list'); + if($isList || $prop === '@set' || $prop === '@graph') { + $value = $this->_expand($ctx, $property, $value, $options, $isList); + if($isList && self::_isListValue($value)) { + throw new JsonLdException( + 'Invalid JSON-LD syntax; lists of lists are not permitted.', + 'jsonld.SyntaxError'); + } + } + 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::_isListValue($value)) { + $container = self::getContextValue($ctx, $property, '@container'); + if($container === '@list') { + // ensure value is an array + $value = self::arrayify(); + $value = (object)array('@list' => $value); } - } - } - } + } - return $rval; + // add value, use an array if not @id, @type, @value, or @language + $useArray = !($prop === '@id' || $prop === '@type' || + $prop === '@value' || $prop === '@language'); + self::addValue($rval, $prop, $value, $useArray); + } + } + + // get property count on expanded output + $count = count(get_object_vars($rval)); + + // @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)); + } + // return only the value of @value if there is no @type or @language + else if($count === 1) { + $rval = $rval->{'@value'}; + } + // 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; + } + + return $rval; + } + + // expand element according to value expansion rules + return $this->_expandValue($ctx, $property, $element, $options['base']); + } + + /** + * Performs JSON-LD framing. + * + * @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. + * + * @return array the framed output. + */ + protected function _frame($input, $frame, $options) { + // create framing state + $state = (object)array( + 'options' => $options, + 'subjects' => new stdClass()); + + // produce a map of all subjects and name each bnode + $namer = new UniqueNamer('_:t'); + $this->_flatten($state->subjects, $input, $namer, null, null); + + // frame the subjects + $framed = new ArrayObject(); + $this->_match_frame($state, $state->subjects, $frame, $framed, null); + return (array)$framed; + } + + /** + * Performs JSON-LD normalization. + * + * @param input the expanded JSON-LD object to normalize. + * + * @return the normalized output. + */ + protected function _normalize($input) { + // get statements + $namer = new UniqueNamer('_:t'); + $bnodes = new stdClass(); + $subjects = new stdClass(); + $this->_getStatements($input, $namer, $bnodes, $subjects); + + // create canonical namer + $namer = new UniqueNamer('_:c14n'); + + // continue to hash bnode statements while bnodes are assigned names + $unnamed = null; + $nextUnnamed = get_object_vars($bnodes); + $duplicates = null; + do { + $unnamed = nextUnnamed; + $nextUnnamed = array(); + $duplicates = new stdClass(); + $unique = new stdClass(); + foreach($unnamed as $bnode) { + // hash statements for each unnamed bnode + $statements = $bnodes->{$bnode}; + $hash = $this->_hashStatements($statements, $namer); + + // store hash as unique or a duplicate + if(property_exists($duplicate, $hash)) { + $duplicates->{$hash}[] = $bnode; + $nextUnnamed[] = $bnode; + } + else if(property_exists($unique, $hash)) { + $duplicates->{$hash} = array($unique->{$hash}, $bnode); + $nextUnnamed[] = $unique->{$hash}; + $nextUnnamed[] = $bnode; + unset($unique->{$hash}); + } + else { + $unique->{$hash} = $bnode; + } + } + + // name unique bnodes in sorted hash order + $hashes = get_object_vars($unique); + sort($hashes); + foreach($hashes as $hash) { + $bnode = $unique->{$hash}; + $namer->getName($bnode); + } + } + while(count($unnamed) > count($nextUnnamed)); + + // enumerate duplicate hash groups in sorted order + $hashes = get_object_vars($duplicates); + sort($hashes); + foreach($hashes as $hash) { + // process group + $group = $duplicates->{$hash}; + $results = array(); + foreach($group as $bnode) { + // skip already-named bnodes + if($namer->isNamed($bnode)) { + continue; + } + + // hash bnode paths + $path_namer = new UniqueNamer('_:t'); + $path_namer->getName($bnode); + $results[] = $this->_hashPaths( + $bnodes, $bnodes->{$bnode}, $namer, $path_namer); + } + + // name bnodes in hash order + usort($results, function($a, $b) { + $a = $a->hash; + $b = $b->hash; + return ($a < $b) ? -1 : (($a > $b) ? 1 : 0); + }); + foreach($results as $result) { + // name all bnodes in path namer in key-entry order + foreach($result->pathNamer->order as $bnode) { + $namer->getName($bnode); + } + } + } + + // create JSON-LD array + $output = array(); + + // add all bnodes + foreach($bnodes as $id => $statements) { + // add all property statements to bnode + $name = $namer->getName($id); + $bnode = (object)array('@id' => $name); + foreach($statements as $statement) { + if($statement->s === '_:a') { + $z = $this->_getBlankNodeName($statement->o); + $o = $z ? (object)array('@id' => $namer.getName($z)) : $statement->o; + self::addValue($bnode, $statement->p, $o, true); + } + } + $output[] = $bnode; + } + + // add all non-bnodes + foreach($subjects as $id => $statements) { + // add all statements to subject + $subject = (object)array('@id' => $id); + foreach($statements as $statement) { + $z = $this->_getBlankNodeName($statement->o); + $o = $z ? (object)array('@id' => $namer.getName($z)) : $statement->o; + self::addValue($subject, $statement->p, $o, true); + } + $output[] = $subject; + } + + // sort normalized output by @id + usort($output, function($a, $b) { + $a = $a->{'@id'}; + $b = $b->{'@id'}; + return ($a < $b) ? -1 : (($a > $b) ? 1 : 0); + }); + + return $output; + } + + /** + * Outputs the RDF statements found in the given JSON-LD object. + * + * @param mixed $input the JSON-LD object. + * + * @return array the RDF statements. + */ + protected function _toRDF($input) { + // FIXME: implement + throw new JsonLdException('Not implemented', 'jsonld.NotImplemented'); + } + + /** + * Processes a local context and returns a new active context. + * + * @param stdClass $active_ctx the current active context. + * @param mixed $local_ctx the local context to process. + * @param array $options the context processing options. + * + * @return stdClass the new active context. + */ + protected function _processContext($active_ctx, $local_ctx, $options) { + // initialize the resulting context + $rval = self::copy($active_ctx); + + // normalize local context to an array + $ctxs = self::arrayify($local_ctx); + + // process each context in order + foreach($ctxs as $ctx) { + // reset to initial context + if($ctx === null) { + $rval = $this->_getInitialContext(); + continue; + } + + // dereference @context key if present + if(is_object($ctx) && property_exists($ctx, '@context')) { + $ctx = $ctx->{'@context'}; + } + + // context must be an object by now, all URLs resolved before this call + if(!is_object($ctx)) { + throw new JsonLdException( + 'Invalid JSON-LD syntax; @context must be an object.', + 'jsonld.SyntaxError', array('context' => $ctx)); + } + + // define context mappings for keys in local context + $defined = new stdClass(); + foreach($ctx as $k => $v) { + $this->_defineContextMapping( + $rval, $ctx, $k, $options['base'], $defined); + } + } + + return $rval; + } + + /** + * 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 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) { + // 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') { + $rval = $this->_expandTerm($ctx, $value, $base); + } + else if($prop === '@type') { + $rval = $this->_expandTerm($ctx, $value); + } + else { + // get type definition from context + $type = self::getContextValue($ctx, $property, '@type'); + + // do @id expansion + if($type === '@id') { + $rval = (object)array('@id' => $this->_expandTerm($ctx, $value, $base)); + } + // other type + else if($type !== null) { + $rval = (object)array('@value' => strval($value), '@type' => $type); + } + // check for language tagging + else { + $language = self::getContextValue($ctx, $property, '@language'); + if($language !== null) { + $rval = (object)array( + '@value' => strval(value), '@language' => $language); + } + } + } + + return $rval; + } + + /** + * Recursively gets all statements from the given expanded JSON-LD input. + * + * @param mixed $input the valid expanded JSON-LD input. + * @param UniqueNamer $namer the namer to use when encountering blank nodes. + * @param stdClass $bnodes the blank node statements map to populate. + * @param stdClass $subjects the subject statements map to populate. + * @param mixed [$name] the name (@id) assigned to the current input. + */ + protected function _getStatements( + $input, $namer, $bnodes, $subjects, $name=null) { + // recurse into arrays + if(is_array($input)) { + foreach($input as $e) { + $this->_getStatements($e, $namer, $bnodes, $subjects); + } + } + // safe to assume input is a subject/blank node + else { + $isBnode = self::_isBlankNode($input); + + // name blank node if appropriate, use passed name if given + if($name === null) { + if(property_exists($input, '@id')) { + $name = $input->{'@id'}; + } + if($isBnode) { + $name = $namer->getName($name); + } + } + + // use a subject of '_:a' for blank node statements + $s = $isBnode ? '_:a' : $name; + + // get statements for the blank node + $entries; + if($isBnode) { + if(!property_exists($bnodes, $name)) { + $entries = $bnodes->{$name} = new ArrayObject(); + } + else { + $entries = $bnodes->{$name}; + } + } + else if(!property_exists($subjects, $name)) { + $entries = $subjects->{$name} = new ArrayObject(); + } + else { + $entries = $subjects->{$name}; + } + + // add all statements in input + foreach($input as $p => $objects) { + // skip @id + if($p === '@id') { + continue; + } + + // convert @lists into embedded blank node linked lists + foreach($objects as $i => $o) { + if(self::_isListValue($o)) { + $objects[$i] = $this->_makeLinkedList($o); + } + } + + foreach($objects as $o) { + // convert boolean to @value + if(is_bool($o)) { + $o = (object)array( + '@value' => strval($o), '@type' => self::XSD_BOOLEAN); + } + // convert double to @value + else if(is_double($o)) { + // do special JSON-LD double format, printf('%1.16e') equivalent + $o = preg_replace('/(e(?:\+|-))([0-9])$/', '${1}0${2}', + sprintf('%1.16e', $o)); + $o = (object)array('@value' => $o, '@type' => self::XSD_DOUBLE); + } + // convert integer to @value + else if(is_numeric($o)) { + $o = (object)array( + '@value' => strval($o), '@type' => self::XSD_INTEGER); + } + + // object is a blank node + if(self::_isBlankNode($o)) { + // name object position blank node + $o_name = $namer->getName($o->{'@id'}); + + // add property statement + $this->_addStatement($entries, (object)array( + 's' => $s, 'p' => $p, 'o' => (object)array('@id' => $o_name))); + + // add reference statement + if(!property_exists($bnodes, $name)) { + $o_entries = $bnodes->{$o_name} = new ArrayObject(); + } + else { + $o_entries = $bnodes->{$o_name}; + } + $this->_addStatement( + $o_entries, (object)array( + 's' => $name, 'p' => $p, 'o' => (object)array('@id' => '_:a'))); + + // recurse into blank node + $this->_getStatements($o, $namer, $bnodes, $subjects, $o_name); + } + // object is a string, @value, subject reference + else if(is_string($o) || self::_isValue($o) || + self::_isSubjectReference($o)) { + // add property statement + $this->_addStatement($entries, (object)array( + 's' => $s, 'p' => $p, 'o' => $o)); + + // ensure a subject entry exists for subject reference + if(self::_isSubjectReference($o) && + !property_exists($subjects, $name)) { + $subjects->{$name} = new ArrayObject(); + } + } + // object must be an embedded subject + else { + // add property statement + $this->_addStatement($entries, (object)array( + 's' => $s, 'p' => $p, 'o' => (object)array( + '@id' => $o->{'@id'}))); + + // recurse into subject + $this->_getStatements($o, $namer, $bnodes, $subjects); + } + } + } + } + } + + /** + * Converts a @list value into an embedded linked list of blank nodes in + * expanded form. The resulting array can be used as an RDF-replacement for + * a property that used a @list. + * + * @param array $value the @list value. + * + * @return stdClass the head of the linked list of blank nodes. + */ + protected function _makeLinkedList($value) { + // convert @list array into embedded blank node linked list + $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) { + $tail = (object)array( + self::RDF_FIRST => array($list[$i]), + self::RDF_REST => array($tail)); + } + + return $tail; + } + + /** + * Adds a statement to an array of statements. If the statement already exists + * in the array, it will not be added. + * + * @param ArrayObject $statements the statements array. + * @param stdClass $statement the statement to add. + */ + protected function _addStatement($statements, $statement) { + foreach($statements as $s) { + if($s->s === $statement->s && $s->p === $statement->p && + self::compareValues($s->o, $statement->o)) { + return; + } + } + $statements[] = $statement; + } + + /** + * Hashes all of the statements about a blank node. + * + * @param ArrayObject $statements the statements about the bnode. + * @param UniqueNamer $namer the canonical bnode namer. + * + * @return string the new hash. + */ + protected function _hashStatements($statements, $namer) { + // serialize all statements + $triples = array(); + foreach($statements as $statement) { + // serialize triple + $triple = ''; + + // serialize subject + if($statement->s === '_:a') { + $triple .= '_:a'; + } + else if(strpos($statement->s, '_:') === 0) { + $id = $statement->s; + $id = $namer->isNamed($id) ? $namer->getName($id) : '_:z'; + $triple .= $id; + } + else { + $triple .= '<' . $statement->s . '>'; + } + + // serialize property + $p = ($statement->p === '@type') ? self::RDF_TYPE : $statement->p; + $triple .= ' <' . $p . '> '; + + // serialize object + if(self::_isBlankNode($statement->o)) { + if($statement->o->{'@id'} === '_:a') { + $triple .= '_:a'; + } + else { + $id = $statement->o->{'@id'}; + $id = $namer->isNamed($id) ? $namer->getName($id) : '_:z'; + $triple .= $id; + } + } + else if(is_string($statement->o)) { + $triple .= '"' . $statement->o . '"'; + } + else if(self::_isSubjectReference($statement->o)) { + $triple .= '<' . $statement->o->{'@id'} . '>'; + } + // must be a value + else { + $triple .= '"' . $statement->o->{'@value'} . '"'; + + if(property_exists($statement->o, '@type')) { + $triple .= '^^<' . $statement->o['@type'] . '>'; + } + else if(property_exists($statement->o, '@language')) { + $triple .= '@' . $statement->o{'@language'}; + } + } + + // add triple + $triples[] = $triple; + } + + // sort serialized triples + sort($triples); + + // return hashed triples + return sha1(implode($triples)); + } + + /** + * 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 stdClass $bnodes the map of bnode statements. + * @param ArrayObject $statements the statements for the bnode to produce + * the hash for. + * @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($bnodes, $statements, $namer, $path_namer) { + // create SHA-1 digest + $md = hash_init('sha1'); + + // group adjacent bnodes by hash, keep properties and references separate + $groups = new stdClass(); + foreach($statements as $statement) { + $bnode = null; + $direction = null; + if($statement->s !== '_:a' && strpos($statement->s, '_:') === 0) { + $bnode = $statement->s; + $direction = 'p'; + } + else { + $bnode = $this->_getBlankNodeName($statement->o); + $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 if(property_exists($cache, $bnode)) { + $name = $cache->{$bnode}; + } + else { + $name = $this->_hashStatements($bnodes->{$bnode}, $namer); + $cache->{$bnode} = $name; + } + + // hash direction, property, and bnode name/hash + $group_md = hash_init('sha1'); + hash_update($group_md, $direction); + hash_update($group_md, + ($statement->p === '@type') ? self::RDF_TYPE : $statement->p); + 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 = get_object_vars($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( + $bnodes, $bnodes->{$bnode}, $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 > $chosenPath) { + $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 a statement value + * (a subject or object). If the statement value is not a blank node or it + * has an @id of '_:a', then null will be returned. + * + * @param mixed $value the statement value. + * + * @return mixed the blank node name or null if none was found. + */ + protected function _getBlankNodeName($value) { + return ((self::_isBlankNode($value) && $value->{'@id'} !== '_:a') ? + $value->{'@id'} : null); + } + + /** + * Recursively flattens the subjects in the given JSON-LD expanded input. + * + * @param stdClass $subjects a map of subject @id to subject. + * @param mixed $input the JSON-LD expanded input. + * @param UniqueNamer $namer the blank node namer. + * @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($subjects, $input, $namer, $name, $list) { + // recurse through array + if(is_array($input)) { + foreach($input as $e) { + $this->_flatten($subjects, $e, $namer, null, $list); + } + } + // handle subject + else if(is_object($input)) { + // add value to list + if(self::_isValue($input) && $list) { + $list[] = $input; + return; + } + + // get name for subject + if($name === null) { + if(property_exists($input, '@id')) { + $name = $input->{'@id'}; + } + if(self::_isBlankNode($input)) { + $name = $namer->getName($name); + } + } + + // add subject reference to list + if($list !== null) { + $list[] = (object)array('@id' => $name); + } + + // create new subject or merge into existing one + if(property_exists($subjects, $name)) { + $subject = $subjects->{$name}; + } + else { + $subject = $subjects->{$name} = new stdClass(); + } + + $subject->{'@id'} = $name; + foreach($input as $prop => $objects) { + // skip @id + if($prop === '@id') { + continue; + } + + // copy keywords + if(self::_isKeyword($prop)) { + $subject->{$prop} = $objects; + continue; + } + + // iterate over objects + foreach($objects as $o) { + // handle embedded subject or subject reference + if(self::_isSubject($o) || self::_isSubjectReference($o)) { + // rename blank node @id + $id = property_exists($o, '@id') ? $o->{'@id'} : '_:'; + if(strpos($id, '_:') === 0) { + $id = $namer->getName($id); + } + + // add reference and recurse + self::addValue($subject, $prop, (object)array('@id' => id), true); + $this->_flatten($subjects, $o, $namer, $id, null); + } + else { + // recurse into list + if(self::_isListValue($o)) { + $l = new ArrayObject(); + $this->_flatten($subjects, $o->{'@list'}, $namer, $name, $l); + $o = (object)array('@list' => (array)$l); + } + + // add non-subject + self::addValue($subject, $prop, $o, true); + } + } + } + } + // add non-object to list + else if($list !== null) { + $list[] = $input; + } + } + + /** + * Frames subjects according to the given frame. + * + * @param stdClass $state the current framing state. + * @param array $subjects the subjects to filter. + * @param array $frame the frame. + * @param mixed $parent the parent subject or top-level array. + * @param mixed $property the parent property, initialized to null. + */ + protected function _match_frame( + $state, $subjects, $frame, $parent, $property) { + // validate the frame + $this->_validateFrame($state, $frame); + $frame = $frame[0]; + + // filter out subjects that match the frame + $matches = $this->_filterSubjects($state, $subjects, $frame); + + // get flags for current frame + $options = $state->options; + $embedOn = $this->_getFrameFlag($frame, $options, 'embed'); + $explicitOn = $this->_getFrameFlag($frame, $options, 'explicit'); + + // add matches to output + foreach($matches as $id => $subject) { + /* Note: In order to treat each top-level match as a compartmentalized + result, create an independent copy of the embedded subjects map when the + property is null, which only occurs at the top-level. */ + if($property === null) { + $state->embeds = new stdClass(); + } + + // start output + $output = new stdClass(); + $output->{'@id'} = $id; + + // prepare embed meta info + $embed = (object)array('parent' => $parent, 'property' => $property); + + // if embed is on and there is an existing embed + if($embedOn && property_exists($state->embeds, $id)) { + // only overwrite an existing embed if it has already been added to its + // parent -- otherwise its parent is somewhere up the tree from this + // embed and the embed would occur twice once the tree is added + $embedOn = false; + + // existing embed's parent is an array + $existing = $state->embeds->{$id}; + if(is_array($existing->parent)) { + foreach($existing->parent as $p) { + if(self::compareValues($output, $p)) { + $embedOn = true; + break; + } + } + } + // existing embed's parent is an object + else if(self::hasValue( + $existing->parent, $existing->property, $output)) { + $embedOn = true; + } + + // existing embed has already been added, so allow an overwrite + if($embedOn) { + $this->_removeEmbed($state, $id); + } + } + + // not embedding, add output without any other properties + if(!$embedOn) { + $this->_addFrameOutput($state, $parent, $property, $output); + } + else { + // add embed meta info + $state->embeds->{$id} = $embed; + + // iterate over subject properties + $props = get_object_vars($subject); + sort($props); + foreach($props as $prop) { + // copy keywords to output + if(self::_isKeyword($prop)) { + $output->{$prop} = self::copy($subject->{$prop}); + continue; + } + + // if property isn't in the frame + if(!property_exists($frame, $prop)) { + // if explicit is off, embed values + if(!$explicitOn) { + $this->_embedValues($state, $subject, $prop, $output); + } + continue; + } + + // add objects + $objects = $subject->{$prop}; + foreach($objects as $o) { + // recurse into list + if(self::_isListValue($o)) { + // add empty list + $list = (object)array('@list' => array()); + $this->_addFrameOutput($state, $output, $prop, $list); + + // add list objects + $src = $o->{'@list'}; + foreach($src as $o) { + // recurse into subject reference + if(self::_isSubjectReference($o)) { + $this->_match_frame( + $state, array($o->{'@id'}), $frame->{$prop}, + $list, '@list'); + } + // include other values automatically + else { + $this->_addFrameOutput( + $state, $list, '@list', self::copy($o)); + } + } + continue; + } + + // recurse into subject reference + if(self::_isSubjectReference($o)) { + $this->_match_frame( + $state, array($o->{'@id'}), $frame->{$prop}, $output, $prop); + } + // include other values automatically + else { + $this->_addFrameOutput($state, $output, $prop, self::copy($o)); + } + } + } + + // handle defaults + $props = get_object_vars($frame); + sort($props); + foreach($props as $prop) { + // skip keywords + if(self::_isKeyword($prop)) { + continue; + } + + // if omit default is off, then include default values for properties + // that appear in the next frame but are not in the matching subject + $next = $frame->{$prop}[0]; + $omitDefaultOn = $this->_getFrameFlag($next, $options, 'omitDefault'); + if(!$omitDefaultOn && !property_exists($output, $prop)) { + $preserve = '@null'; + if(property_exists($next, '@default')) { + $preserve = self::copy($next->{'@default'}); + } + $output->{$prop} = (object)array('@preserve' => $preserve); + } + } + + // add output to parent + $this->_addFrameOutput($state, $parent, $property, $output); + } + } + } + + /** + * Gets the frame flag value for the given flag name. + * + * @param stdClass $frame the frame. + * @param stdClass $options the framing options. + * @param string $name the flag name. + * + * @return mixed $the flag value. + */ + protected function _getFrameFlag($frame, $options, $name) { + $flag = "'@'$name"; + return (property_exists($frame, $flag) ? + $frame->{$flag}[0] : $options->{$name}); + } + + /** + * Validates a JSON-LD frame, throwing an exception if the frame is invalid. + * + * @param stdClass $state the current frame state. + * @param array $frame the frame to validate. + */ + protected function _validateFrame($state, $frame) { + if(!is_array($frame) || count($frame) !== 1 || !is_object($frame[0])) { + throw new JsonLdException( + 'Invalid JSON-LD syntax; a JSON-LD frame must be a single object.', + 'jsonld.SyntaxError', array('frame' => $frame)); + } + } + + /** + * Returns a map of all of the subjects that match a parsed frame. + * + * @param stdClass $state the current framing state. + * @param array $subjects the set of subjects to filter. + * @param stdClass $frame the parsed frame. + * + * @return stdClass all of the matched subjects. + */ + protected function _filterSubjects($state, $subjects, $frame) { + $rval = new stdClass(); + sort($subjects); + foreach($subjects as $id) { + $subject = $state->subjects->{$id}; + if($this->_filterSubject($subject, $frame)) { + $rval->{$id} = $subject; + } + } + return $rval; + } + + /** + * Returns true if the given subject matches the given frame. + * + * @param stdClass $subject the subject to check. + * @param stdClass $frame the frame to check. + * + * @return bool true if the subject matches, false if not. + */ + protected function _filterSubject($subject, $frame) { + // check @type (object value means 'any' type, fall through to ducktyping) + if(property_exists($frame, '@type') && + !(count($frame->{'@type'}) === 1 && is_object($frame->{'@type'}[0]))) { + $types = $frame->{'@type'}; + foreach($types as $type) { + // any matching @type is a match + if(self::hasValue($subject, '@type', $type)) { + return true; + } + } + return false; + } + + // check ducktype + foreach($frame as $key) { + // only not a duck if @id or non-keyword isn't in subject + if(($key === '@id' || !self::_isKeyword($key)) && + !property_exists($subject, $key)) { + return false; + } + } + return true; + } + + /** + * Embeds values for the given subject and property into the given output + * during the framing algorithm. + * + * @param stdClass $state the current framing state. + * @param stdClass $subject the subject. + * @param string $property the property. + * @param mixed $output the output. + */ + protected function _embedValues($state, $subject, $property, $output) { + // embed subject properties in output + $objects = $subject->{$property}; + foreach($objects as $o) { + // recurse into @list + if(self::_isListValue($o)) { + $list = (object)array('@list' => new ArrayObject()); + $this->_addFrameOutput($state, $output, $property, $list); + $this->_embedValues($state, $o, '@list', $list->{'@list'}); + $list->{'@list'} = (array)$list->{'@list'}; + return; + } + + // handle subject reference + if(self::_isSubjectReference($o)) { + $id = $o->{'@id'}; + + // embed full subject if isn't already embedded + if(!property_exists($state->embeds, $id)) { + // add embed + $embed = (object)array('parent' => $output, 'property' => $property); + $state->embeds->{$id} = $embed; + + // recurse into subject + $o = new stdClass(); + $s = $state->subjects->{$id}; + foreach($s as $prop => $v) { + // copy keywords + if(self::_isKeyword($prop)) { + $o->{$prop} = self::copy($v); + continue; + } + $this->_embedValues($state, $s, $prop, $o); + } + } + $this->_addFrameOutput($state, $output, $property, $o); + } + // copy non-subject value + else { + $this->_addFrameOutput($state, $output, $property, self::copy($o)); + } + } + } + + /** + * Removes an existing embed. + * + * @param stdClass $state the current framing state. + * @param string $id the @id of the embed to remove. + */ + protected function _removeEmbed($state, $id) { + // get existing embed + $embeds = $state->embeds; + $embed = $embeds->{$id}; + $property = $embed->property; + + // create reference to replace embed + $subject = (object)array('@id' => id); + + // remove existing embed + if(is_array($embed->parent)) { + // replace subject with reference + foreach($embed->parent as $i => $parent) { + if(self::compareValues($parent, $subject)) { + $embed->parent[$i] = $subject; + break; + } + } + } + else { + // replace subject with reference + $useArray = is_array($embed->parent->{$property}); + self::removeValue($embed->parent, $property, $subject, $useArray); + self::addValue($embed->parent, $property, $subject, $useArray); + } + + // recursively remove dependent dangling embeds + $removeDependents = function($id) { + // get embed keys as a separate array to enable deleting keys in map + $ids = get_object_vars($embeds); + foreach($ids as $next) { + if(property_exists($embeds, $next) && + is_object($embeds->{$next}->parent) && + $embeds->{$next}->parent->{'@id'} === $id) { + unset($embeds->{$next}); + $removeDependents($next); + } + } + }; + $removeDependents($id); + } + + /** + * Adds framing output to the given parent. + * + * @param stdClass $state the current framing state. + * @param mixed $parent the parent to add to. + * @param string $property the parent property. + * @param mixed $output the output to add. + */ + protected function _addFrameOutput($state, $parent, $property, $output) { + if(is_object($parent)) { + self::addValue($parent, $property, $output, true); + } + else { + $parent[] = $output; + } + } + + /** + * Removes the @preserve keywords as the last step of the framing algorithm. + * + * @param stdClass $ctx the active context used to compact the input. + * @param mixed $input the framed, compacted output. + * + * @return mixed the resulting output. + */ + protected function _removePreserve($ctx, $input) { + // recurse through arrays + if(is_array($input)) { + $output = array(); + foreach($input as $e) { + $result = $this->_removePreserve($ctx, $e); + // drop nulls from arrays + if($result !== null) { + $output[] = $result; + } + } + $input = $output; + } + else if(is_object($input)) { + // remove @preserve + if(property_exists($input, '@preserve')) { + if($input->{'@preserve'} === '@null') { + return null; + } + return $input->{'@preserve'}; + } + + // skip @values + if(self::_isValue($input)) { + return $input; + } + + // recurse through @lists + if(self::_isListValue($input)) { + $input->{'@list'} = $this->_removePreserve($ctx, $input->{'@list'}); + return $input; + } + + // recurse through properties + foreach($input as $prop => $v) { + $result = $this->_removePreserve($ctx, $v); + $container = self::getContextValue($ctx, $prop, '@container'); + if(is_array($result) && count($result) === 1 && + $container !== '@set' && $container !== '@list') { + $result = $result[0]; + } + $input->{$prop} = $result; + } + } + return $input; + } + + /** + * Compares two strings first based on length and then lexicographically. + * + * @param string $a the first string. + * @param string $b the second string. + * + * @return integer -1 if a < b, 1 if a > b, 0 if a == b. + */ + protected function _compareShortestLeast($a, $b) { + if(strlen($a) < strlen($b)) { + return -1; + } + else if(strlen($b) < strlen($a)) { + return 1; + } + return ($a < $b) ? -1 : (($a > $b) ? 1 : 0); + } + + /** + * Ranks a term that is possible choice for compacting an IRI associated with + * the given value. + * + * @param stdClass $ctx the active context. + * @param string $term the term to rank. + * @param mixed $value the associated value. + * + * @return integer the term rank. + */ + protected function _rankTerm($ctx, $term, $value) { + // no term restrictions for a null value + if($value === null) { + return 3; + } + + // get context entry for term + $entry = $ctx->mappings->{$term}; + $hasType = property_exists($entry, '@type'); + $hasLanguage = property_exists($entry, '@language'); + $hasDefaultLanguage = property_exists($ctx, '@language'); + + // @list rank is the sum of its values' ranks + if(self::_isListValue($value)) { + $list = $value->{'@list'}; + if(count($list) === 0) { + return ($entry->{'@container'} === '@list') ? 1 : 0; + } + // sum term ranks for each list value + $sum = 0; + foreach($list as $v) { + $sum += $this->_rankTerm($ctx, $term, $v); + } + return $sum; + } + + // rank boolean or number + if(is_bool($value) || is_numeric($value)) { + if(is_bool($value)) { + $type = self::XSD_BOOLEAN; + } + else if(is_double(value)) { + $type = self::XSD_DOUBLE; + } + else { + $type = self::XSD_INTEGER; + } + if($entry->{'@type'} === $type) { + return 3; + } + return (!$hasType && !$hasLanguage) ? 2 : 1; + } + + // rank string (this means the value has no @language) + if(is_string($value)) { + // entry @language is specifically null or no @type, @language, or default + if(($hasLanguage && $entry->{'@language'} === null) || + (!$hasType && !$hasLanguage && !$hasDefaultLanguage)) { + return 3; + } + return 0; + } + + // Note: Value must be an object that is a @value or subject/reference. + + // @value must have either @type or @language + if(self::_isValue($value)) { + if(property_exists($value, '@type')) { + // @types match + if($hasType && $value->{'@type'} === $entry->{'@type'}) { + return 3; + } + return (!$hasType && !$hasLanguage) ? 1 : 0; + } + + // @languages match or entry has no @type or @language but default + // @language matches + if(($hasLanguage && $value->{'@language'} === $entry->{'@language'}) || + (!$hasType && !$hasLanguage && + $value->{'@language'} === $ctx->{'@language'})) { + return 3; + } + return (!$hasType && !$hasLanguage) ? 1 : 0; + } + + // value must be a subject/reference + if($entry->{'@type'} === '@id') { + return 3; + } + return (!$hasType && !$hasLanguage) ? 1 : 0; + } + + /** + * 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 string $iri the IRI to compact. + * @param mixed $value the value to check or null. + * + * @return string the compacted term, prefix, keyword alias, or original IRI. + */ + protected function _compactIri($ctx, $iri, $value=null) { + // can't compact null + if($iri === null) { + return $iri; + } + + // compact rdf:type + if($iri === self::RDF_TYPE) { + return '@type'; + } + + // term is a keyword + if(self::_isKeyword($iri)) { + // return alias if available + $aliases = $ctx->keywords->{$iri}; + if(count($aliases) > 0) { + return $aliases[0]; + } + else { + // no alias, keep original keyword + return $iri; + } + } + + // find all possible term matches + $terms = array(); + $highest = 0; + $listContainer = false; + $isList = self::_isListValue($value); + foreach($ctx->mappings as $term) { + // skip terms with non-matching iris + $entry = $ctx->mappings->{$term}; + if($entry->{'@id'} !== $iri) { + continue; + } + // skip @set containers for @lists + if($isList && $entry->{'@container'} === '@set') { + continue; + } + // skip @list containers for non-@lists + if(!$isList && $entry->{'@container'} === '@list') { + continue; + } + // for @lists, if listContainer is set, skip non-list containers + if($isList && $listContainer && $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($entry->{'@container'} === '@set') { + $rank += 1; + } + + // for @lists, give preference to @list containers + if($isList && !$listContainer && $entry->{'@container'} === '@list') { + $listContainer = 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; + } + $terms[] = $term; + } + } + } + + // 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, use IRI + if(count($terms) === 0) { + return $iri; + } + + // return shortest and lexicographically-least term + usort($terms, array($this, '_compareShortestLeast')); + return $terms[0]; + } + + /** + * Defines a context mapping 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 $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}) { + return; + } + // cycle detected + throw new JsonLdException( + 'Cyclical context definition detected.', + 'jsonld.CyclicalContext', + (object)array('context' => $ctx, 'key' => $key)); + } + + // now defining key + $defined->{$key} = false; + + // if key has a prefix, define it first + $colon = strpos($key, ':'); + $prefix = null; + if($colon !== false) { + $prefix = substr($key, 0, $colon); + if(property_exists($ctx, $prefix)) { + // define parent prefix + $this->_defineContextMapping( + $active_ctx, $ctx, $prefix, $base, $defined); + } + } + + // get context key value + $value = $ctx->{$key}; + + if(self::_isKeyword($key)) { + // 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; + } + + // clear context entry + if($value === 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; + return; + } + + if(is_string($value)) { + if(self::_isKeyword($value)) { + // disallow aliasing @context and @preserve + if($value === '@context' || $value === '@preserve') { + throw new JsonLdException( + 'Invalid JSON-LD syntax; @context and @preserve cannot be aliased.', + 'jsonld.SyntaxError'); + } + + // uniquely add key as a keyword alias and resort + $aliases = $active_ctx->keywords->{$value}; + if(in_array($key, $active_ctx->keywords->{$value}) === false) { + $active_ctx->keywords->{$value}[] = $key; + usort($active_ctx->keywords->{$value}, + array($this, '_compareShortestLeast')); + } + } + else { + // expand value to a full IRI + $value = $this->_expandContextIri( + $active_ctx, $ctx, $value, $base, $defined); + } + + // define/redefine key to expanded IRI/keyword + $active_ctx->mappings->{$key} = (object)array('@id' => $value); + $defined->{$key} = true; + return; + } + + if(!is_object($value)) { + throw new JsonLdException( + 'Invalid JSON-LD syntax; @context property values must be ' + + 'strings or objects.', + 'jsonld.SyntaxError', array('context' => $ctx)); + } + + // create new mapping + $mapping = new stdClass(); + + if(property_exists($value, '@id')) { + $id = $value->{'@id'}; + if(!is_string($id)) { + throw new JsonLdException( + 'Invalid JSON-LD syntax; @context @id values must be strings.', + 'jsonld.SyntaxError', array('context' => $ctx)); + } + + // expand @id to full IRI + $id = $this->_expandContextIri($active_ctx, $ctx, $id, $base, $defined); + + // 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)); + } + + // set @id based on prefix parent + if(property_exists($active_ctx->mappings, $prefix)) { + $suffix = substr($key, $colon + 1); + $mapping->{'@id'} = $active_ctx->mappings->{$prefix}->{'@id'} . $suffix; + } + // key is an absolute IRI + else { + $mapping->{'@id'} = $key; + } + } + + if(property_exists($value, '@type')) { + $type = $value->{'@type'}; + if(!is_string($type)) { + throw new JsonLdException( + 'Invalid JSON-LD syntax; @context @type values must be strings.', + 'jsonld.SyntaxError', array('context' => $ctx)); + } + + if($type !== '@id') { + // expand @type to full IRI + $type = $this->_expandContextIri( + $active_ctx, $ctx, $type, '', $defined); + } + + // add @type to mapping + $mapping->{'@type'} = $type; + } + + if(property_exists($value, '@container')) { + $container = $value->{'@container'}; + if($container !== '@list' && $container !== '@set') { + throw new JsonLdException( + 'Invalid JSON-LD syntax; @context @container value must be ' + + '"@list" or "@set".', + 'jsonld.SyntaxError', array('context' => $ctx)); + } + + // add @container to mapping + $mapping->{'@container'} = $container; + } + + if(property_exists($value, '@language') && + !property_exists($value, '@type')) { + $language = $value->{'@language'}; + if($language !== null && !is_string($language)) { + throw new JsonLdException( + 'Invalid JSON-LD syntax; @context @language value must be ' + + 'a string or null.', + 'jsonld.SyntaxError', array('context' => $ctx)); + } + + // add @language to mapping + $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; + } + + /** + * Expands a string value to a full IRI during context processing. It can + * be assumed that the value is not a keyword. + * + * @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. + * + * @return mixed the expanded value. + */ + protected function _expandContextIri( + $active_ctx, $ctx, $value, $base, $defined) { + // dependency not defined, define it + if(property_exists($ctx, $value) && $defined->{$value} !== true) { + $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 _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) && $defined->{$prefix} !== true) { + $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 base + $value = "$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='') { + // nothing to expand + if($term === 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); + } + + // 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; + } + + // prepend base to term + return "$base$term"; + } + + /** + * Resolves external @context URLs using the given URL resolver. Each instance + * of @context in the input that refers to a URL will be replaced with the + * JSON @context found at that URL. + * + * @param mixed $input the JSON-LD object with possible contexts. + * @param callable $resolver(url, callback(err, jsonCtx)) the URL resolver. + * + * @return mixed the result. + */ + protected function _resolveUrls($input, $resolver) { + // keeps track of resolved URLs (prevents duplicate work) + $urls = new stdClass(); + + // finds URLs in @context properties and replaces them with their + // resolved @contexts if replace is true + $findUrls = function($input, $replace) use (&$findUrls, $urls) { + if(is_array($input)) { + $output = array(); + foreach($input as $v) { + $output[] = $findUrls($v, $replace); + } + return $output; + } + else if(is_object($input)) { + foreach($input as $k => $v) { + if($k !== '@context') { + $input->{$k} = $findUrls($v, $replace); + continue; + } + + // array @context + if(is_array($v)) { + foreach($v as $i => $url) { + if(is_string($url)) { + // replace w/resolved @context if requested + if($replace) { + $v[$i] = $urls->{$url}; + } + // unresolved @context found + else if(!property_exists($urls, $url)) { + $urls->{$url} = new stdClass(); + } + } + } + } + // string @context + else if(is_string($v)) { + // replace w/resolved @context if requested + if($replace) { + $input->{$key} = $urls->{$v}; + } + // unresolved @context found + else if(!property_exists($urls, $v)) { + $urls->{$v} = new stdClass(); + } + } + } + } + return $input; + }; + $input = $findUrls($input, false); + + // resolve all URLs + foreach($urls as $url => $v) { + // validate URL + if(filter_var($url, FILTER_VALIDATE_URL) === false) { + throw new JsonLdException( + 'Malformed URL.', 'jsonld.InvalidUrl', array('url' => $url)); + } + + // resolve URL + $ctx = $resolver($url); + + // parse string context as JSON + if(is_string($ctx)) { + $ctx = json_decode($ctx); + switch(json_last_error()) { + case JSON_ERROR_NONE: + break; + case JSON_ERROR_DEPTH: + throw new JsonLdException( + 'Could not parse JSON from URL; the maximum stack depth has ' . + 'been exceeded.', 'jsonld.ParseError', array('url' => $url)); + case JSON_ERROR_STATE_MISMATCH: + throw new JsonLdException( + 'Could not parse JSON from URL; invalid or malformed JSON.', + 'jsonld.ParseError', array('url' => $url)); + case JSON_ERROR_CTRL_CHAR: + case JSON_ERROR_SYNTAX: + throw new JsonLdException( + 'Could not parse JSON from URL; syntax error, malformed JSON.', + 'jsonld.ParseError', array('url' => $url)); + case JSON_ERROR_UTF8: + throw new JsonLdException( + 'Could not parse JSON from URL; malformed UTF-8 characters.', + 'jsonld.ParseError', array('url' => $url)); + default: + throw new JsonLdException( + 'Could not parse JSON from URL; unknown error.', + 'jsonld.ParseError', array('url' => $url)); + } + } + + // ensure ctx is an object + if(!is_object($ctx)) { + throw new JsonLdException( + 'URL does not resolve to a valid JSON-LD object.', + 'jsonld.InvalidUrl', array('url' => $url)); + } + + // FIXME: needs to recurse to resolve URLs in the result, and + // detect cycles, and limit recursion + if(property_exists($ctx, '@context')) { + $urls->{$url} = $ctx->{'@context'}; + } + } + + // do url replacement + return $findUrls($input, true); + } + + /** + * Gets the initial context. + * + * @return stdClass the initial context. + */ + protected function _getInitialContext() { + return (object)array( + 'mappings' => new stdClass(), + 'keywords' => (object)array( + '@context'=> array(), + '@container'=> array(), + '@default'=> array(), + '@embed'=> array(), + '@explicit'=> array(), + '@graph'=> array(), + '@id'=> array(), + '@language'=> array(), + '@list'=> array(), + '@omitDefault'=> array(), + '@preserve'=> array(), + '@set'=> array(), + '@type'=> array(), + '@value'=> array() + )); + } + + /** + * Returns whether or not the given value is a keyword (or a keyword alias). + * + * @param string $value 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($value, $ctx=null) { + if($ctx !== null) { + if(property_exists($ctx->keywords, $value)) { + return true; + } + foreach($ctx->keywords as $kw => $aliases) { + if(in_array($value, $aliases) !== false) { + return true; + } + } + } + else { + switch($value) { + 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': + return true; + } + } + return false; + } + + /** + * Returns true if the given input is an empty Object. + * + * @param mixed $input the input to check. + * + * @return bool true if the input is an empty Object, false if not. + */ + protected static function _isEmptyObject($input) { + return is_object($input) && count(get_object_vars($input)) === 0; + } + + /** + * Returns true if the given input is an Array of Strings. + * + * @param mixed $input the input to check. + * + * @return bool true if the input is an Array of Strings, false if not. + */ + protected static function _isArrayOfStrings($input) { + if(!is_array($input)) { + return false; + } + foreach($input as $v) { + if(!is_string($v)) { + return false; + } + } + return true; + } + + /** + * Returns true if the given value is a subject with properties. + * + * @param mixed $value the value to check. + * + * @return bool true if the value is a subject with properties, false if not. + */ + protected static function _isSubject($value) { + $rval = false; + + // Note: A value is a subject if all of these hold true: + // 1. It is an Object. + // 2. It is not a @value, @set, or @list. + // 3. It has more than 1 key OR any existing key is not @id. + if(is_object($value) && + !property_exists($value, '@value') && + !property_exists($value, '@set') && + !property_exists($value, '@list')) { + $count = count(get_object_vars($value)); + $rval = ($count > 1 || !property_exists($value, '@id')); + } + + return $rval; + } + + /** + * Returns true if the given value is a subject reference. + * + * @param mixed $value the value to check. + * + * @return bool true if the value is a subject reference, false if not. + */ + protected static function _isSubjectReference($value) { + // Note: A value is a subject reference if all of these hold true: + // 1. It is an Object. + // 2. It has a single key: @id. + return (is_object($value) && count(get_object_vars($value)) === 1 && + property_exists($value, '@id')); + } + + /** + * Returns true if the given value is a @value. + * + * @param mixed $value the value to check. + * + * @return bool true if the value is a @value, false if not. + */ + protected static function _isValue($value) { + // Note: A value is a @value if all of these hold true: + // 1. It is an Object. + // 2. It has the @value property. + return is_object($value) && property_exists($value, '@value'); + } + + /** + * Returns true if the given value is a @set. + * + * @param mixed $value the value to check. + * + * @return bool true if the value is a @set, false if not. + */ + protected static function _isSetValue($value) { + // Note: A value is a @set if all of these hold true: + // 1. It is an Object. + // 2. It has the @set property. + return is_object($value) && property_exists($value, '@set'); + } + + /** + * Returns true if the given value is a @list. + * + * @param mixed $value the value to check. + * + * @return bool true if the value is a @list, false if not. + */ + protected static function _isListValue($value) { + // Note: A value is a @list if all of these hold true: + // 1. It is an Object. + // 2. It has the @list property. + return is_object($value) && property_exists($value, '@list'); + } + + /** + * Returns true if the given value is a blank node. + * + * @param mixed $value the value to check. + * + * @return bool true if the value is a blank node, false if not. + */ + protected static function _isBlankNode($value) { + $rval = false; + // Note: A value is a blank node if all of these hold true: + // 1. It is an Object. + // 2. If it has an @id key its value begins with '_:'. + // 3. It has no keys OR is not a @value, @set, or @list. + if(is_object($value)) { + if(property_exists($value, '@id')) { + $rval = (strpos($value->{'@id'}, '_:') === 0); + } + else { + $rval = (count(get_object_vars($value)) === 0 || + !(property_exists($value, '@value') || + property_exists($value, '@set') || + property_exists($value, '@list'))); + } + } + return $rval; + } + + /** + * Returns true if the given value is an absolute IRI, false if not. + * + * @param string $value the value to check. + * + * @return bool true if the value is an absolute IRI, false if not. + */ + protected static function _isAbsoluteIri($value) { + return strpos($value, ':') !== false; + } } -?> +/** + * A JSON-LD Exception. + */ +class JsonLdException extends Exception { + protected $type; + protected $details; + protected $cause; + public function __construct($msg, $type, $details=null, $previous=null) { + $this->type = $type; + $this->details = $details; + $this->cause = $previous; + parent::__construct($msg, 0, $previous); + } + public function __toString() { + $rval = __CLASS__ . ": [{$this->type}]: {$this->message}\n"; + if($this->details) { + $rval .= 'details: ' . print_r($this->details, true) . "\n"; + } + if($this->cause) { + $rval .= 'cause: ' . $this->cause->toString(); + } + $rval .= $this->getTraceAsString() . "\n"; + return $rval; + } +}; + +/** + * A UniqueNamer issues unique names, keeping track of any previously issued + * names. + */ +class UniqueNamer { + protected $prefix; + protected $counter; + protected $existing; + protected $order; + + /** + * Constructs a new UniqueNamer. + * + * @param prefix the prefix to use (''). + */ + public function __construct($prefix) { + $this->prefix = $prefix; + $this->counter = 0; + $this->existing = new stdClass(); + $this->order = array(); + } + + /** + * Clones this UniqueNamer. + */ + public function __clone() { + $this->existing = clone $this->existing; + } + + /** + * Gets the new name for the given old name, where if no old name is given + * a new name will be generated. + * + * @param mixed [$old_name] the old name to get the new name for. + * + * @return string the new name. + */ + public function getName($old_name=null) { + // return existing old name + if($old_name && property_exists($this->existing, $old_name)) { + return $this->existing->{$oldName}; + } + + // get next name + $name = $this->prefix . $this->counter; + $this->counter += 1; + + // save mapping + if($old_name !== null) { + $this->existing->{$oldName} = $name; + } + + return $name; + } + + /** + * Returns true if the given old name has already been assigned a new name. + * + * @param string $old_name the old name to check. + * + * @return true if the oldName has been assigned a new name, false if not. + */ + public function isNamed($old_name) { + return property_exists($this->existing, $old_name); + } +} + +/** + * A Permutator iterates over all possible permutations of the given array + * of elements. + */ +class Permutator { + protected $list; + protected $done; + protected $left; + + /** + * Constructs a new Permutator. + * + * @param array $list the array of elements to iterate over. + */ + public function __constructor($list) { + // original array + $this->list = $list; + sort($this->list); + // indicates whether there are more permutations + $this->done = false; + // directional info for permutation algorithm + $this->left = new stdClass(); + foreach($list as $v) { + $this->left->{$v} = true; + } + } + + /** + * Returns true if there is another permutation. + * + * @return bool true if there is another permutation, false if not. + */ + public function hasNext() { + return !$this->done; + } + + /** + * Gets the next permutation. Call hasNext() to ensure there is another one + * first. + * + * @return array the next permutation. + */ + public function next() { + // copy current permutation + $rval = $this->list; + + /* Calculate the next permutation using the Steinhaus-Johnson-Trotter + permutation algorithm. */ + + // get largest mobile element k + // (mobile: element is greater than the one it is looking at) + $k = null; + $pos = 0; + $length = count($this->list); + for($i = 0; $i < $length; ++$i) { + $element = $this->list[$i]; + $left = $this->left->{$element}; + if(($k === null || $element > $k) && + (($left && $i > 0 && $element > $this->list[$i - 1]) || + (!$left && $i < ($length - 1) && $element > $this->list[$i + 1]))) { + $k = $element; + $pos = $i; + } + } + + // no more permutations + if($k === null) { + $this->done = true; + } + else { + // swap k and the element it is looking at + $swap = $this->left->{$k} ? $pos - 1 : $pos + 1; + $this->list[$pos] = $this->list[$swap]; + $this->list[$swap] = $k; + + // reverse the direction of all elements larger than k + for($i = 0; $i < $length; ++$i) { + if($this->list[$i] > $k) { + $this->left->{$this->list[$i]} = !$this->left->{$this->list[$i]}; + } + } + } + + return $rval; + } +} + +/* end of file, omit ?> */