php-json-ld/jsonld.php
2013-02-17 13:38:55 -05:00

5367 lines
167 KiB
PHP

<?php
/**
* PHP implementation of the JSON-LD API.
* Version: 0.0.3
*
* @author Dave Longley
*
* BSD 3-Clause License
* Copyright (c) 2011-2013 Digital Bazaar, Inc.
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
*
* Neither the name of the Digital Bazaar, Inc. nor the names of its
* contributors may be used to endorse or promote products derived from
* this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
* IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
* TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
* PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
* HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
* TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
* PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
* LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
* NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
/**
* Performs JSON-LD compaction.
*
* @param mixed $input the JSON-LD object to compact.
* @param mixed $ctx the context to compact with.
* @param assoc [$options] options to use:
* [base] the base IRI to use.
* [strict] use strict mode (default: true).
* [optimize] true to optimize the compaction (default: false).
* [graph] true to always output a top-level graph (default: false).
* [urlClient(url)] the URL client to use.
*
* @return mixed the compacted JSON-LD output.
*/
function jsonld_compact($input, $ctx, $options=array()) {
$p = new JsonLdProcessor();
return $p->compact($input, $ctx, $options);
}
/**
* 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.
* [urlClient(url)] the URL client to use.
*
* @return array the expanded JSON-LD output.
*/
function jsonld_expand($input, $options=array()) {
$p = new JsonLdProcessor();
return $p->expand($input, $options);
}
/**
* Performs JSON-LD flattening.
*
* @param mixed $input the JSON-LD to flatten.
* @param mixed $ctx the context to use to compact the flattened output, or
* null.
* @param [options] the options to use:
* [base] the base IRI to use.
* [urlClient(url)] the URL client to use.
*
* @return mixed the flattened JSON-LD output.
*/
function jsonld_flatten($input, $ctx, $options=array()) {
$p = new JsonLdProcessor();
return $p->flatten($input, $ctx, $options);
}
/**
* Performs JSON-LD framing.
*
* @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).
* [urlClient(url)] the URL client to use.
*
* @return stdClass the framed JSON-LD output.
*/
function jsonld_frame($input, $frame, $options=array()) {
$p = new JsonLdProcessor();
return $p->frame($input, $frame, $options);
}
/**
* Performs RDF normalization on the given JSON-LD input.
*
* @param mixed $input the JSON-LD object to normalize.
* @param assoc [$options] the options to use:
* [base] the base IRI to use.
* [format] the format if output is a string:
* 'application/nquads' for N-Quads (default).
* [urlClient(url)] the URL client to use.
*
* @return array the normalized output.
*/
function jsonld_normalize($input, $options=array()) {
$p = new JsonLdProcessor();
return $p->normalize($input, $options);
}
/**
* Converts RDF statements into JSON-LD.
*
* @param mixed $statements a serialized string of RDF statements in a format
* specified by the format option or an array of the RDF statements
* to convert.
* @param assoc [$options] the options to use:
* [format] the format if input not an array:
* 'application/nquads' for N-Quads (default).
* [useRdfType] true to use rdf:type, false to use @type
* (default: false).
* [useNativeTypes] true to convert XSD types into native types
* (boolean, integer, double), false not to (default: true).
*
* @return array the JSON-LD output.
*/
function jsonld_from_rdf($input, $options=array()) {
$p = new JsonLdProcessor();
return $p->fromRDF($input, $options);
}
/**
* 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:
* [base] the base IRI to use.
* [format] the format to use to output a string:
* 'application/nquads' for N-Quads (default).
* [urlClient(url)] the URL client to use.
*
* @return array all RDF statements in the JSON-LD object.
*/
function jsonld_to_rdf($input, $options=array()) {
$p = new JsonLdProcessor();
return $p->toRDF($input, $options);
}
/**
* Relabels all blank nodes in the given JSON-LD input.
*
* @param mixed input the JSON-LD input.
*/
function jsonld_relabel_blank_nodes($input) {
$p = new JsonLdProcessor();
return $p->_labelBlankNodes(new UniqueNamer('_:b'), $input);
}
/** JSON-LD shared in-memory cache. */
global $jsonld_cache;
$jsonld_cache = new stdClass();
/** The default active context cache. */
$jsonld_cache->activeCtx = new ActiveContextCache();
/** The default JSON-LD URL client. */
global $jsonld_default_url_client;
$jsonld_default_url_client = null;
/**
* Sets the default JSON-LD URL client.
*
* @param callable url_client(url) the URL client to use.
*/
function jsonld_set_url_client($url_client) {
global $jsonld_default_url_client;
$jsonld_default_url_client = $url_client;
}
/**
* Retrieves JSON-LD at the given URL.
*
* @param string $url the URL to to retrieve.
*
* @return the JSON-LD.
*/
function jsonld_get_url($url) {
global $jsonld_default_url_client;
if($jsonld_default_url_client !== null) {
return call_user_func($jsonld_default_url_client, $url);
}
// default JSON-LD GET implementation
return jsonld_default_get_url($url);
}
/**
* The default implementation to retrieve JSON-LD at the given URL.
*
* @param string $url the URL to to retrieve.
*
* @return the JSON-LD.
*/
function jsonld_default_get_url($url) {
// default JSON-LD GET implementation
$opts = array('http' =>
array(
'method' => "GET",
'header' =>
"Accept: application/ld+json\r\n" .
"User-Agent: PaySwarm PHP Client/1.0\r\n"));
$stream = stream_context_create($opts);
$result = @file_get_contents($url, false, $stream);
if($result === false) {
throw new Exception("Could not GET url: '$url'");
}
return $result;
}
/** Registered global RDF Statement parsers hashed by content-type. */
global $jsonld_rdf_parsers;
$jsonld_rdf_parsers = new stdClass();
/**
* Registers a global RDF Statement parser by content-type, for use with
* jsonld_from_rdf. Global parsers will be used by JsonLdProcessors that do
* not register their own parsers.
*
* @param string $content_type the content-type for the parser.
* @param callable $parser(input) the parser function (takes a string as
* a parameter and returns an array of RDF statements).
*/
function jsonld_register_rdf_parser($content_type, $parser) {
global $jsonld_rdf_parsers;
$jsonld_rdf_parsers->{$content_type} = $parser;
}
/**
* Unregisters a global RDF Statement parser by content-type.
*
* @param string $content_type the content-type for the parser.
*/
function jsonld_unregister_rdf_parser($content_type) {
global $jsonld_rdf_parsers;
if(property_exists($jsonld_rdf_parsers, $content_type)) {
unset($jsonld_rdf_parsers->{$content_type});
}
}
/**
* Parses a URL into its component parts.
*
* @param string $url the URL to parse.
*
* @return assoc the parsed URL.
*/
function jsonld_parse_url($url) {
$rval = parse_url($url);
if(isset($rval['host'])) {
if(!isset($rval['path']) || $rval['path'] === '') {
$rval['path'] = '/';
}
}
else {
$rval['host'] = '';
if(!isset($rval['path'])) {
$rval['path'] = '';
}
}
if(!isset($rval['scheme'])) {
$rval['scheme'] = '';
}
if(isset($rval['user']) || isset($rval['pass'])) {
$rval['auth'] = '';
if(isset($rval['user'])) {
$rval['auth'] = $rval['user'];
}
if(isset($rval['pass'])) {
$rval['auth'] .= ":{$rval['pass']}";
}
}
return $rval;
}
/**
* Prepends a base IRI to the given relative IRI.
*
* @param mixed $base a string or the parsed base IRI.
* @param string $iri the relative IRI.
*
* @return string the absolute IRI.
*/
function jsonld_prepend_base($base, $iri) {
// already an absolute IRI
if(strpos($iri, ':') !== false) {
return $iri;
}
if(is_string($base)) {
$base = jsonld_parse_url($base);
}
$authority = $base['host'];
if(isset($base['port'])) {
$authority .= ":{$base['port']}";
}
$rel = jsonld_parse_url($iri);
// per RFC3986 normalize slashes and dots in path
// IRI contains authority
if(strpos($iri, '//') === 0) {
$path = substr($iri, 2);
$authority = substr($path, 0, strrpos($path, '/'));
$path = substr($path, strlen($authority));
}
// IRI represents an absolute path
else if(strpos($rel['path'], '/') === 0) {
$path = $rel['path'];
}
else {
$path = $base['path'];
// prepend last directory for base
if($rel['path'] !== '') {
$idx = strrpos($path, '/');
$idx = ($idx === false) ? 0 : $idx + 1;
$path = substr($path, 0, $idx) . $rel['path'];
}
}
$segments = explode('/', $path);
// remove '.' and '' (do not remove trailing empty path)
$idx = -1;
$end = count($segments) - 1;
$filter = function($e) use (&$idx, $end) {
$idx += 1;
return $e !== '.' && ($e !== '' || $idx === $end);
};
$segments = array_values(array_filter($segments, $filter));
// remove as many '..' as possible
for($i = 0; $i < count($segments);) {
$segment = $segments[$i];
if($segment === '..') {
// too many reverse dots
if($i === 0) {
$last = $segments[count($segments) - 1];
if($last !== '..') {
$segments = array($last);
}
else {
$segments = array();
}
break;
}
// remove '..' and previous segment
array_splice($segments, $i - 1, 2);
$segments = array_values($segments);
$i -= 1;
}
else {
$i += 1;
}
}
$path = '/' . implode('/', $segments);
// add query and hash
if(isset($rel['query'])) {
$path .= "?{$rel['query']}";
}
if(isset($rel['fragment'])) {
$path .= "#{$rel['fragment']}";
}
$absolute_iri = "{$base['scheme']}://";
if(isset($base['auth'])) {
$absolute_iri .= "{$base['auth']}@";
}
$absolute_iri .= "$authority$path";
return $absolute_iri;
}
/**
* Removes a base IRI from the given absolute IRI.
*
* @param mixed $base the base IRI.
* @param string $iri the absolute IRI.
*
* @return string the relative IRI if relative to base, otherwise the absolute
* IRI.
*/
function jsonld_remove_base($base, $iri) {
if(is_string($base)) {
$base = jsonld_parse_url($base);
}
// establish base root
$root = "{$base['scheme']}://";
if(isset($base['auth'])) {
$root .= "{$base['auth']}@";
}
$authority = $base['host'];
if(isset($base['port'])) {
$authority .= ":{$base['port']}";
}
$root .= $authority;
// IRI not relative to base
if(strpos($iri, $root) !== 0) {
return $iri;
}
// remove path segments that match
$base_segments = explode('/', $base['path']);
$iri_segments = explode('/', substr($iri, strlen($root)));
while(count($base_segments) > 0 && count($iri_segments) > 0) {
if($base_segments[0] !== $iri_segments[0]) {
break;
}
array_shift($base_segments);
array_shift($iri_segments);
}
// use '../' for each non-matching base segment
$rval = '';
if(count($base_segments) > 0) {
// do not count the last segment if it isn't a path (doesn't end in '/')
if(substr($base['path'], -1) !== '/') {
array_pop($base_segments);
}
foreach($base_segments as $segment) {
$rval .= '../';
}
}
// prepend remaining segments
$rval .= implode('/', $iri_segments);
return $rval;
}
/**
* A JSON-LD processor.
*/
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';
const XSD_STRING = 'http://www.w3.org/2001/XMLSchema#string';
/** 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';
/** Restraints */
const MAX_CONTEXT_URLS = 10;
/** Processor-specific RDF Statement parsers. */
protected $rdfParsers = null;
/**
* Constructs a JSON-LD processor.
*/
public function __construct() {}
/**
* 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.
* [base] the base IRI to use.
* [skipExpansion] true to assume the input is expanded and skip
* expansion, false not to, defaults to false.
* [activeCtx] true to also return the active context used.
* [urlClient(url)] the URL client to use.
*
* @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['renameBlankNodes']) or $options['renameBlankNodes'] =
true;
isset($options['strict']) or $options['strict'] = true;
isset($options['optimize']) or $options['optimize'] = false;
isset($options['graph']) or $options['graph'] = false;
isset($options['skipExpansion']) or $options['skipExpansion'] = false;
isset($options['activeCtx']) or $options['activeCtx'] = false;
isset($options['urlClient']) or $options['urlClient'] = 'jsonld_get_url';
if($options['skipExpansion'] === true) {
$expanded = $input;
}
else {
// expand input
try {
$expanded = $this->expand($input, $options);
}
catch(JsonLdException $e) {
throw new JsonLdException(
'Could not expand input before compaction.',
'jsonld.CompactError', null, $e);
}
}
// process context
$active_ctx = $this->_getInitialContext($options);
try {
$active_ctx = $this->processContext($active_ctx, $ctx, $options);
}
catch(JsonLdException $e) {
throw new JsonLdException(
'Could not process context before compaction.',
'jsonld.CompactError', null, $e);
}
// do compaction
$compacted = $this->_compact($active_ctx, null, $expanded, $options);
if(!$options['graph'] && is_array($compacted)) {
// simplify to a single item
if(count($compacted) === 1) {
$compacted = $compacted[0];
}
// simplify to an empty object
else if(count($compacted) === 0) {
$compacted = new stdClass();
}
}
// always use array if graph option is on
else if($options['graph']) {
$compacted = self::arrayify($compacted);
}
// follow @context key
if(is_object($ctx) && property_exists($ctx, '@context')) {
$ctx = $ctx->{'@context'};
}
// build output context
$ctx = self::copy($ctx);
$ctx = self::arrayify($ctx);
// remove empty contexts
$tmp = $ctx;
$ctx = array();
foreach($tmp as $v) {
if(!is_object($v) || count(array_keys((array)$v)) > 0) {
$ctx[] = $v;
}
}
// 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($has_context) {
$compacted->{'@context'} = $ctx;
}
$compacted->{$kwgraph} = $graph;
}
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;
}
}
}
if($options['activeCtx']) {
return array('compacted' => $compacted, 'activeCtx' => $active_ctx);
}
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.
* [renameBlankNodes] true to rename blank nodes, false not to,
* defaults to true.
* [keepFreeFloatingNodes] true to keep free-floating nodes,
* false not to, defaults to false.
* [urlClient(url)] the URL client to use.
*
* @return array the expanded JSON-LD output.
*/
public function expand($input, $options) {
// set default options
isset($options['base']) or $options['base'] = '';
isset($options['renameBlankNodes']) or $options['renameBlankNodes'] =
true;
isset($options['keepFreeFloatingNodes']) or
$options['keepFreeFloatingNodes'] = false;
isset($options['urlClient']) or $options['urlClient'] = 'jsonld_get_url';
// retrieve all @context URLs in the input
$input = self::copy($input);
try {
$this->_retrieveContextUrls(
$input, new stdClass(), $options['urlClient'], $options['base']);
}
catch(Exception $e) {
throw new JsonLdException(
'Could not perform JSON-LD expansion.',
'jsonld.ExpandError', null, $e);
}
// do expansion
$active_ctx = $this->_getInitialContext($options);
$expanded = $this->_expand($active_ctx, null, $input, $options, false);
// optimize away @graph with no other properties
if(is_object($expanded) && property_exists($expanded, '@graph') &&
count(array_keys((array)$expanded)) === 1) {
$expanded = $expanded->{'@graph'};
}
else if($expanded === null) {
$expanded = array();
}
// normalize to an array
return self::arrayify($expanded);
}
/**
* Performs JSON-LD flattening.
*
* @param mixed $input the JSON-LD to flatten.
* @param ctx the context to use to compact the flattened output, or null.
* @param assoc $options the options to use:
* [base] the base IRI to use.
* [urlClient(url)] the URL client to use.
*
* @return array the flattened output.
*/
public function flatten($input, $ctx, $options) {
// set default options
isset($options['base']) or $options['base'] = '';
isset($options['urlClient']) or $options['urlClient'] = 'jsonld_get_url';
try {
// expand input
$expanded = $this->expand($input, $options);
}
catch(Exception $e) {
throw new JsonLdException(
'Could not expand input before flattening.',
'jsonld.FlattenError', null, $e);
}
// do flattening
$flattened = $this->_flatten($expanded);
if($ctx === null) {
return $flattened;
}
// compact result (force @graph option to true, skip expansion)
$options['graph'] = true;
$options['skipExpansion'] = true;
try {
$compacted = $this->compact($flattened, $ctx, $options);
}
catch(Exception $e) {
throw new JsonLdException(
'Could not compact flattened output.',
'jsonld.FlattenError', null, $e);
}
return $compacted;
}
/**
* Performs JSON-LD framing.
*
* @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).
* [urlClient(url)] the URL client 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;
isset($options['urlClient']) or $options['urlClient'] = 'jsonld_get_url';
// preserve frame context
$ctx = (property_exists($frame, '@context') ?
$frame->{'@context'} : new stdClass());
try {
// expand input
$expanded = $this->expand($input, $options);
}
catch(Exception $e) {
throw new JsonLdException(
'Could not expand input before framing.',
'jsonld.FrameError', null, $e);
}
try {
// expand frame
$opts = self::copy($options);
$opts['keepFreeFloatingNodes'] = true;
$expanded_frame = $this->expand($frame, $opts);
}
catch(Exception $e) {
throw new JsonLdException(
'Could not expand frame before framing.',
'jsonld.FrameError', null, $e);
}
// do framing
$framed = $this->_frame($expanded, $expanded_frame, $options);
try {
// compact result (force @graph option to true)
$options['graph'] = true;
$options['skipExpansion'] = true;
$options['activeCtx'] = true;
$result = $this->compact($framed, $ctx, $options);
}
catch(Exception $e) {
throw new JsonLdException(
'Could not compact framed output.',
'jsonld.FrameError', null, $e);
}
$compacted = $result['compacted'];
$active_ctx = $result['activeCtx'];
// get graph alias
$graph = $this->_compactIri($active_ctx, '@graph');
// remove @preserve from results
$compacted->{$graph} = $this->_removePreserve(
$active_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.
* [urlClient(url)] the URL client to use.
*
* @return array the JSON-LD normalized output.
*/
public function normalize($input, $options) {
// set default options
isset($options['base']) or $options['base'] = '';
isset($options['urlClient']) or $options['urlClient'] = 'jsonld_get_url';
try {
// expand input then do normalization
$expanded = $this->expand($input, $options);
}
catch(Exception $e) {
throw new JsonLdException(
'Could not expand input before normalization.',
'jsonld.NormalizeError', null, $e);
}
// do normalization
return $this->_normalize($expanded, $options);
}
/**
* Converts RDF statements into JSON-LD.
*
* @param mixed $statements a serialized string of RDF statements in a format
* specified by the format option or an array of the RDF statements
* to convert.
* @param assoc $options the options to use:
* [format] the format if input is a string:
* 'application/nquads' for N-Quads (default).
* [useRdfType] true to use rdf:type, false to use @type
* (default: false).
* [useNativeTypes] true to convert XSD types into native types
* (boolean, integer, double), false not to (default: true).
*
* @return array the JSON-LD output.
*/
public function fromRDF($statements, $options) {
global $jsonld_rdf_parsers;
// set default options
isset($options['format']) or $options['format'] = 'application/nquads';
isset($options['useRdfType']) or $options['useRdfType'] = false;
isset($options['useNativeTypes']) or $options['useNativeTypes'] = true;
if(!is_array($statements)) {
// supported formats (processor-specific and global)
if(($this->rdfParsers !== null &&
!property_exists($this->rdfParsers, $options['format'])) ||
$this->rdfParsers === null &&
!property_exists($jsonld_rdf_parsers, $options['format'])) {
throw new JsonLdException(
'Unknown input format.',
'jsonld.UnknownFormat', array('format' => $options['format']));
}
if($this->rdfParsers !== null) {
$callable = $this->rdfParsers->{$options['format']};
}
else {
$callable = $jsonld_rdf_parsers->{$options['format']};
}
$statements = call_user_func($callable, $statements);
}
// convert from RDF
return $this->_fromRDF($statements, $options);
}
/**
* 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:
* [base] the base IRI to use.
* [format] the format to use to output a string:
* 'application/nquads' for N-Quads (default).
* [urlClient(url)] the URL client to use.
*
* @return array all RDF statements in the JSON-LD object.
*/
public function toRDF($input, $options) {
// set default options
isset($options['base']) or $options['base'] = '';
isset($options['urlClient']) or $options['urlClient'] = 'jsonld_get_url';
try {
// expand input
$expanded = $this->expand($input, $options);
}
catch(JsonLdException $e) {
throw new JsonLdException(
'Could not expand input before conversion to RDF.',
'jsonld.RdfError', $e);
}
// get RDF statements
$namer = new UniqueNamer('_:b');
$statements = array();
$this->_toRDF($expanded, $namer, null, null, null, $statements);
// convert to output format
if(isset($options['format'])) {
// supported formats
if($options['format'] === 'application/nquads') {
$nquads = array();
foreach($statements as $statement) {
$nquads[] = $this->toNQuad($statement);
}
sort($nquads);
$statements = implode($nquads);
}
else {
throw new JsonLdException(
'Unknown output format.',
'jsonld.UnknownFormat', array('format' => $options['format']));
}
}
// output RDF statements
return $statements;
}
/**
* 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:
* [renameBlankNodes] true to rename blank nodes, false not to,
* defaults to true.
* [urlClient(url)] the URL client to use.
*
* @return stdClass the new active context.
*/
public function processContext($active_ctx, $local_ctx, $options) {
// set default options
isset($options['base']) or $options['base'] = '';
isset($options['renameBlankNodes']) or $options['renameBlankNodes'] =
true;
isset($options['urlClient']) or $options['urlClient'] = 'jsonld_get_url';
// return initial context early for null context
if($local_ctx === null) {
return $this->_getInitialContext($options);
}
// retrieve URLs in local_ctx
$ctx = self::copy($local_ctx);
if(is_object($ctx) && !property_exists($ctx, '@context')) {
$ctx = (object)array('@context' => $ctx);
}
try {
$this->_retrieveContextUrls(
$ctx, new stdClass(), $options['urlClient'], $options['base']);
}
catch(Exception $e) {
throw new JsonLdException(
'Could not process JSON-LD context.',
'jsonld.ContextError', null, $e);
}
// 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};
$is_list = self::_isList($val);
if(is_array($val) || $is_list) {
if($is_list) {
$val = $val->{'@list'};
}
foreach($val as $v) {
if(self::compareValues($value, $v)) {
$rval = true;
break;
}
}
}
// avoid matching the set of values with an array value parameter
else if(!is_array($value)) {
$rval = self::compareValues($value, $val);
}
}
return $rval;
}
/**
* Adds a value to a subject. 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 assoc [$options] the options to use:
* [propertyIsArray] true if the property is always an array, false
* if not (default: false).
* [allowDuplicate] true to allow duplicates, false not to (uses a
* simple shallow comparison of subject ID or value)
* (default: true).
*/
public static function addValue(
$subject, $property, $value, $options=array()) {
isset($options['allowDuplicate']) or $options['allowDuplicate'] = true;
isset($options['propertyIsArray']) or $options['propertyIsArray'] = false;
if(is_array($value)) {
if(count($value) === 0 && $options['propertyIsArray'] &&
!property_exists($subject, $property)) {
$subject->{$property} = array();
}
foreach($value as $v) {
self::addValue($subject, $property, $v, $options);
}
}
else if(property_exists($subject, $property)) {
// check if subject already has value if duplicates not allowed
$has_value = (!$options['allowDuplicate'] &&
self::hasValue($subject, $property, $value));
// make property an array if value not present or always an array
if(!is_array($subject->{$property}) &&
(!$has_value || $options['propertyIsArray'])) {
$subject->{$property} = array($subject->{$property});
}
// add new value
if(!$has_value) {
$subject->{$property}[] = $value;
}
}
else {
// add new value as set or single value
$subject->{$property} = ($options['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 = (property_exists($subject, $property) ?
$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 assoc [$options] the options to use:
* [propertyIsArray] true if the property is always an array,
* false if not (default: false).
*/
public static function removeValue(
$subject, $property, $value, $options=array()) {
isset($options['propertyIsArray']) or $options['propertyIsArray'] = false;
// filter out value
$filter = function($e) use ($value) {
return !self::compareValues($e, $value);
};
$values = self::getValues($subject, $property);
$values = array_values(array_filter($values, $filter));
if(count($values) === 0) {
self::removeProperty($subject, $property);
}
else if(count($values) === 1 && !$options['property_is_array']) {
$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, @language,
* and @index, OR
* 3. They are both @lists with the same @list and @index, OR
* 4. They both have @ids they are the same.
*
* @param mixed $v1 the first value.
* @param mixed $v2 the second value.
*
* @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)) {
return (
self::_compareKeyValues($v1, $v2, '@value') &&
self::_compareKeyValues($v1, $v2, '@type') &&
self::_compareKeyValues($v1, $v2, '@language') &&
self::_compareKeyValues($v1, $v2, '@index'));
}
// 3. equal @lists
if(self::_isList($v1) && self::_isList($v2)) {
if(!self::_compareKeyValues($v1, $v2, '@index')) {
return false;
}
$list1 = $v1->{'@list'};
$list2 = $v2->{'@list'};
$count_list1 = count($list1);
$count_list2 = count($list2);
if($count_list1 !== $count_list2) {
return false;
}
for($i = 0; $i < $count_list1; ++$i) {
if(!self::compareValues($list1[$i], $list2[$i])) {
return false;
}
}
return true;
}
// 4. equal @ids
if(is_object($v1) && property_exists($v1, '@id') &&
is_object($v2) && property_exists($v2, '@id')) {
return $v1->{'@id'} === $v2->{'@id'};
}
return false;
}
/**
* 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;
}
// get default language
if($type === '@language' && property_exists($ctx, $type)) {
$rval = $ctx->{$type};
}
// get specific entry information
if(property_exists($ctx->mappings, $key)) {
$entry = $ctx->mappings->{$key};
if($entry === null) {
return null;
}
// return whole entry
if($type === null) {
$rval = $entry;
}
// return entry value for type
else if(property_exists($entry, $type)) {
$rval = $entry->{$type};
}
}
return $rval;
}
/**
* Parses statements in the form of N-Quads.
*
* @param string $input the N-Quads input to parse.
*
* @return array the resulting RDF statements.
*/
public static function parseNQuads($input) {
// define partial regexes
$iri = '(?:<([^:]+:[^>]*)>)';
$bnode = '(_:(?:[A-Za-z][A-Za-z0-9]*))';
$plain = '"([^"\\\\]*(?:\\\\.[^"\\\\]*)*)"';
$datatype = "(?:\\^\\^$iri)";
$language = '(?:@([a-z]+(?:-[a-z0-9]+)*))';
$literal = "(?:$plain(?:$datatype|$language)?)";
$ws = '[ \\t]';
$eoln = '/(?:\r\n)|(?:\n)|(?:\r)/';
$empty = "/^$ws*$/";
// define quad part regexes
$subject = "(?:$iri|$bnode)$ws+";
$property = "$iri$ws+";
$object = "(?:$iri|$bnode|$literal)$ws*";
$graph = "(?:\\.|(?:(?:$iri|$bnode)$ws*\\.))";
// full quad regex
$quad = "/^$ws*$subject$property$object$graph$ws*$/";
// build RDF statements
$statements = array();
// split N-Quad input into lines
$lines = preg_split($eoln, $input);
$line_number = 0;
foreach($lines as $line) {
$line_number += 1;
// skip empty lines
if(preg_match($empty, $line)) {
continue;
}
// parse quad
if(!preg_match($quad, $line, $match)) {
throw new JsonLdException(
'Error while parsing N-Quads; invalid quad.',
'jsonld.ParseError', array('line' => $line_number));
}
// create RDF statement
$s = (object)array(
'subject' => new stdClass(),
'property' => new stdClass(),
'object' => new stdClass());
// get subject
if($match[1] !== '') {
$s->subject->nominalValue = $match[1];
$s->subject->interfaceName = 'IRI';
}
else {
$s->subject->nominalValue = $match[2];
$s->subject->interfaceName = 'BlankNode';
}
// get property
$s->property->nominalValue = $match[3];
$s->property->interfaceName = 'IRI';
// get object
if($match[4] !== '') {
$s->object->nominalValue = $match[4];
$s->object->interfaceName = 'IRI';
}
else if($match[5] !== '') {
$s->object->nominalValue = $match[5];
$s->object->interfaceName = 'BlankNode';
}
else {
$unescaped = str_replace(
array('\"', '\t', '\n', '\r', '\\\\'),
array('"', "\t", "\n", "\r", '\\'),
$match[6]);
$s->object->nominalValue = $unescaped;
$s->object->interfaceName = 'LiteralNode';
if(isset($match[7]) && $match[7] !== '') {
$s->object->datatype = (object)array(
'nominalValue' => $match[7], 'interfaceName' => 'IRI');
}
else if(isset($match[8]) && $match[8] !== '') {
$s->object->language = $match[8];
}
}
// get graph
if(isset($match[9]) && $match[9] !== '') {
$s->name = (object)array(
'nominalValue' => $match[9], 'interfaceName' => 'IRI');
}
else if(isset($match[10]) && $match[10] !== '') {
$s->name = (object)array(
'nominalValue' => $match[10], 'interfaceName' => 'BlankNode');
}
// add statement
self::_appendUniqueRdfStatement($statements, $s);
}
return $statements;
}
/**
* Converts an RDF statement to an N-Quad string (a single quad).
*
* @param stdClass $statement the RDF statement to convert.
* @param string $bnode the bnode the statement is mapped to (optional, for
* use during normalization only).
*
* @return the N-Quad string.
*/
public static function toNQuad($statement, $bnode=null) {
$s = $statement->subject;
$p = $statement->property;
$o = $statement->object;
$g = property_exists($statement, 'name') ? $statement->name : null;
$quad = '';
// subject is an IRI or bnode
if($s->interfaceName === 'IRI') {
$quad .= "<{$s->nominalValue}>";
}
// normalization mode
else if($bnode !== null) {
$quad .= ($s->nominalValue === $bnode) ? '_:a' : '_:z';
}
// normal mode
else {
$quad .= $s->nominalValue;
}
// property is always an IRI
$quad .= " <{$p->nominalValue}> ";
// object is IRI, bnode, or literal
if($o->interfaceName === 'IRI') {
$quad .= "<{$o->nominalValue}>";
}
else if($o->interfaceName === 'BlankNode') {
// normalization mode
if($bnode !== null) {
$quad .= ($o->nominalValue === $bnode) ? '_:a' : '_:z';
}
// normal mode
else {
$quad .= $o->nominalValue;
}
}
else {
$escaped = str_replace(
array('\\', "\t", "\n", "\r", '"'),
array('\\\\', '\t', '\n', '\r', '\"'),
$o->nominalValue);
$quad .= '"' . $escaped . '"';
if(property_exists($o, 'datatype') &&
$o->datatype->nominalValue !== self::XSD_STRING) {
$quad .= "^^<{$o->datatype->nominalValue}>";
}
else if(property_exists($o, 'language')) {
$quad .= '@' . $o->language;
}
}
// graph
if($g !== null) {
if($g->interfaceName === 'IRI') {
$quad .= " <{$g->nominalValue}>";
}
else if($bnode) {
$quad .= ' _:g';
}
else {
$quad .= " {$g->nominalValue}";
}
}
$quad .= " .\n";
return $quad;
}
/**
* Registers a processor-specific RDF Statement parser by content-type.
* Global parsers will no longer be used by this processor.
*
* @param string $content_type the content-type for the parser.
* @param callable $parser(input) the parser function (takes a string as
* a parameter and returns an array of RDF statements).
*/
public function registerRDFParser($content_type, $parser) {
if($this->rdfParsers === null) {
$this->rdfParsers = new stdClass();
}
$this->rdfParsers->{$content_type} = $parser;
}
/**
* Unregisters a process-specific RDF Statement parser by content-type. If
* there are no remaining processor-specific parsers, then the global
* parsers will be re-enabled.
*
* @param string $content_type the content-type for the parser.
*/
public function unregisterRDFParser($content_type) {
if($this->rdfParsers !== null &&
property_exists($this->rdfParsers, $content_type)) {
unset($this->rdfParsers->{$content_type});
if(count(get_object_vars($content_type)) === 0) {
$this->rdfParsers = null;
}
}
}
/**
* If $value is an array, returns $value, otherwise returns an array
* containing $value as the only element.
*
* @param mixed $value the value.
*
* @return array an array.
*/
public static function arrayify($value) {
return is_array($value) ? $value : array($value);
}
/**
* 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;
}
}
/**
* Recursively compacts an element using the given active context. All values
* must be in expanded form before this method is called.
*
* @param stdClass $active_ctx the active context to use.
* @param mixed $active_property the compacted property with the element
* to compact, null for none.
* @param mixed $element the element to compact.
* @param assoc $options the compaction options.
*
* @return mixed the compacted value.
*/
protected function _compact(
$active_ctx, $active_property, $element, $options) {
// recursively compact array
if(is_array($element)) {
$rval = array();
foreach($element as $e) {
// compact, dropping any null values
$compacted = $this->_compact(
$active_ctx, $active_property, $e, $options);
// drop null values
if($compacted !== null) {
$rval[] = $compacted;
}
}
if(count($rval) === 1) {
// use single element if no container is specified
$container = self::getContextValue(
$active_ctx, $active_property, '@container');
if($container === null) {
$rval = $rval[0];
}
}
return $rval;
}
// recursively compact object
if(is_object($element)) {
// do value compaction on @values and subject references
if(self::_isValue($element) || self::_isSubjectReference($element)) {
return $this->_compactValue($active_ctx, $active_property, $element);
}
// shallow copy element and arrays so keys and values can be removed
// during property generator compaction
$shallow = new stdClass();
foreach($element as $expanded_property => $expanded_value) {
if(is_array($element->{$expanded_property})) {
$shallow->{$expanded_property} = $element->{$expanded_property};
}
else {
$shallow->{$expanded_property} = $element->{$expanded_property};
}
}
$element = $shallow;
// process element keys in order
$keys = array_keys((array)$element);
sort($keys);
$rval = new stdClass();
foreach($keys as $expanded_property) {
// skip key if removed during property generator duplicate handling
if(!property_exists($element, $expanded_property)) {
continue;
}
$expanded_value = $element->{$expanded_property};
// compact @id and @type(s)
if($expanded_property === '@id' || $expanded_property === '@type') {
// compact single @id
if(is_string($expanded_value)) {
$compacted_value = $this->_compactIri(
$active_ctx, $expanded_value, null,
array('vocab' => ($expanded_property === '@type')));
}
// expanded value must be a @type array
else {
$compacted_value = array();
foreach($expanded_value as $ev) {
$compacted_value[] = $this->_compactIri(
$active_ctx, $ev, null, array('vocab' => true));
}
}
// use keyword alias and add value
$alias = $this->_compactIri($active_ctx, $expanded_property);
$is_array = (is_array($compacted_value) &&
count($expanded_value) === 0);
self::addValue(
$rval, $alias, $compacted_value,
array('propertyIsArray' => $is_array));
continue;
}
// handle @index property
if($expanded_property === '@index') {
// drop @index if inside an @index container
$container = self::getContextValue(
$active_ctx, $active_property, '@container');
if($container === '@index') {
continue;
}
// use keyword alias and add value
$alias = $this->_compactIri($active_ctx, $expanded_property);
self::addValue($rval, $alias, $expanded_value);
continue;
}
// Note: expanded value must be an array due to expansion algorithm.
// preserve empty arrays
if(count($expanded_value) === 0) {
$item_active_property = $this->_compactIri(
$active_ctx, $expanded_property, $expanded_value,
array('vocab' => true), $element);
self::addValue(
$rval, $item_active_property, array(),
array('propertyIsArray' => true));
}
// recusively process array values
foreach($expanded_value as $expanded_item) {
// compact property and get container type
$item_active_property = $this->_compactIri(
$active_ctx, $expanded_property, $expanded_item,
array('vocab' => true), $element);
$container = self::getContextValue(
$active_ctx, $item_active_property, '@container');
// remove any duplicates that were (presumably) generated by a
// property generator
if(property_exists($active_ctx->mappings, $item_active_property)) {
$mapping = $active_ctx->mappings->{$item_active_property};
if($mapping && $mapping->propertyGenerator) {
$this->_findPropertyGeneratorDuplicates(
$active_ctx, $element, $expanded_property, $expanded_item,
$item_active_property, true);
}
}
// get @list value if appropriate
$is_list = self::_isList($expanded_item);
$list = null;
if($is_list) {
$list = $expanded_item->{'@list'};
}
// recursively compact expanded item
$compacted_item = $this->_compact(
$active_ctx, $item_active_property,
$is_list ? $list : $expanded_item, $options);
// handle @list
if($is_list) {
// ensure @list value is an array
$compacted_item = self::arrayify($compacted_item);
if($container !== '@list') {
// wrap using @list alias
$compacted_item = (object)array(
$this->_compactIri($active_ctx, '@list') => $compacted_item);
// include @index from expanded @list, if any
if(property_exists($expanded_item, '@index')) {
$compacted_item->{$this->_compactIri($active_ctx, '@index')} =
$expanded_item->{'@index'};
}
}
// can't use @list container for more than 1 list
else if(property_exists($rval, $item_active_property)) {
throw new JsonLdException(
'JSON-LD compact error; property has a "@list" @container ' .
'rule but there is more than a single @list that matches ' .
'the compacted term in the document. Compaction might mix ' .
'unwanted items into the list.',
'jsonld.SyntaxError');
}
}
// handle language and index maps
if($container === '@language' || $container === '@index') {
// get or create the map object
if(property_exists($rval, $item_active_property)) {
$map_object = $rval->{$item_active_property};
}
else {
$rval->{$item_active_property} = $map_object = new stdClass();
}
// if container is a language map, simplify compacted value to
// a simple string
if($container === '@language' && self::_isValue($compacted_item)) {
$compacted_item = $compacted_item->{'@value'};
}
// add compact value to map object using key from expanded value
// based on the container type
self::addValue(
$map_object, $expanded_item->{$container}, $compacted_item);
}
else {
// use an array if: @container is @set or @list , value is an empty
// array, or key is @graph
$is_array = ($container === '@set' || $container === '@list' ||
(is_array($compacted_item) && count($compacted_item) === 0) ||
$expanded_property === '@list' ||
$expanded_property === '@graph');
// add compact value
self::addValue(
$rval, $item_active_property, $compacted_item,
array('propertyIsArray' => $is_array));
}
}
}
return $rval;
}
// only primitives remain which are already compact
return $element;
}
/**
* Recursively expands an element using the given context. Any context in
* the element will be removed. All context URLs must have been retrieved
* before calling this method.
*
* @param stdClass $active_ctx the active context to use.
* @param mixed $active_property the property for the element, null for none.
* @param mixed $element the element to expand.
* @param assoc $options the expansion options.
* @param bool $inside_list true if the property is a list, false if not.
*
* @return mixed the expanded value.
*/
protected function _expand(
$active_ctx, $active_property, $element, $options, $inside_list) {
// nothing to expand
if($element === null) {
return $element;
}
// recursively expand array
if(is_array($element)) {
$rval = array();
foreach($element as $e) {
// expand element
$e = $this->_expand(
$active_ctx, $active_property, $e, $options, $inside_list);
if($inside_list && (is_array($e) || self::_isList($e))) {
// lists of lists are illegal
throw new JsonLdException(
'Invalid JSON-LD syntax; lists of lists are not permitted.',
'jsonld.SyntaxError');
}
// drop null values
else if($e !== null) {
if(is_array($e)) {
$rval = array_merge($rval, $e);
}
else {
$rval[] = $e;
}
}
}
return $rval;
}
// recursively expand object
if(is_object($element)) {
// if element has a context, process it
if(property_exists($element, '@context')) {
$active_ctx = $this->_processContext($active_ctx, $element->{'@context'}, $options);
}
// expand the active property
$expanded_active_property = $this->_expandIri(
$active_ctx, $active_property, array('vocab' => true));
$rval = new stdClass();
$keys = array_keys((array)$element);
sort($keys);
foreach($keys as $key) {
$value = $element->{$key};
if($key === '@context') {
continue;
}
// get term definition for key
if(property_exists($active_ctx->mappings, $key)) {
$mapping = $active_ctx->mappings->{$key};
}
else {
$mapping = null;
}
// expand key using property generator
if($mapping && $mapping->propertyGenerator) {
$expanded_property = $mapping->{'@id'};
}
// expand key to IRI
else {
$expanded_property = $this->_expandIri(
$active_ctx, $key, array('vocab' => true));
}
// drop non-absolute IRI keys that aren't keywords
if($expanded_property === null ||
!(is_array($expanded_property) ||
self::_isAbsoluteIri($expanded_property) ||
self::_isKeyword($expanded_property))) {
continue;
}
// syntax error if @id is not a string
if($expanded_property === '@id' && !is_string($value)) {
throw new JsonLdException(
'Invalid JSON-LD syntax; "@id" value must a string.',
'jsonld.SyntaxError', array('value' => $value));
}
// validate @type value
if($expanded_property === '@type') {
$this->_validateTypeValue($value);
}
// @graph must be an array or an object
if($expanded_property === '@graph' &&
!(is_object($value) || is_array($value))) {
throw new JsonLdException(
'Invalid JSON-LD syntax; "@value" value must not be an ' .
'object or an array.',
'jsonld.SyntaxError', array('value' => $value));
}
// @value must not be an object or an array
if($expanded_property === '@value' &&
(is_object($value) || is_array($value))) {
throw new JsonLdException(
'Invalid JSON-LD syntax; "@value" value must not be an ' .
'object or an array.',
'jsonld.SyntaxError', array('value' => $value));
}
// @language must be a string
if($expanded_property === '@language' && !is_string($value)) {
throw new JsonLdException(
'Invalid JSON-LD syntax; "@language" value must not be a string.',
'jsonld.SyntaxError', array('value' => $value));
// ensure language value is lowercase
$value = strtolower($value);
}
// preserve @index
if($expanded_property === '@index') {
if(!is_string($value)) {
throw new JsonLdException(
'Invalid JSON-LD syntax; "@index" value must be a string.',
'jsonld.SyntaxError', array('value' => $value));
}
}
$container = self::getContextValue($active_ctx, $key, '@container');
// handle language map container (skip if value is not an object)
if($container === '@language' && is_object($value)) {
$expanded_value = $this->_expandLanguageMap($value);
}
// handle index container (skip if value is not an object)
else if($container === '@index' && is_object($value)) {
$expand_index_map = function($active_property) use (
$active_ctx, $options, $value) {
$rval = array();
$keys = array_keys((array)$value);
sort($keys);
foreach($keys as $key) {
$val = $value->{$key};
$val = self::arrayify($val);
$val = $this->_expand(
$active_ctx, $active_property, $val, $options, false);
foreach($val as $item) {
if(!property_exists($item, '@index')) {
$item->{'@index'} = $key;
}
$rval[] = $item;
}
}
return $rval;
};
$expanded_value = $expand_index_map($key);
}
else {
// recurse into @list or @set keeping the active property
$is_list = ($expanded_property === '@list');
if($is_list || $expanded_property === '@set') {
$next_active_property = $active_property;
if($is_list && $expanded_active_property === '@graph') {
$next_active_property = null;
}
$expanded_value = $this->_expand(
$active_ctx, $next_active_property, $value, $options, $is_list);
if($is_list && self::_isList($expanded_value)) {
throw new JsonLdException(
'Invalid JSON-LD syntax; lists of lists are not permitted.',
'jsonld.SyntaxError');
}
}
else {
// recursively expand value with key as new active property
$expanded_value = $this->_expand(
$active_ctx, $key, $value, $options, false);
}
}
// drop null values if property is not @value
if($expanded_value === null && $expanded_property !== '@value') {
continue;
}
// convert expanded value to @list if container specifies it
if($expanded_property !== '@list' && !self::_isList($expanded_value) &&
$container === '@list') {
// ensure expanded value is an array
$expanded_value = (object)array(
'@list' => self::arrayify($expanded_value));
}
// add copy of value for each property from property generator
if(is_array($expanded_property)) {
$expanded_value = $this->_labelBlankNodes(
$active_ctx->namer, $expanded_value);
foreach($expanded_property as $iri) {
self::addValue(
$rval, $iri, self::copy($expanded_value),
array('propertyIsArray' => true));
}
}
// add value for property
else {
// use an array except for certain keywords
$use_array = (!in_array(
$expanded_property, array(
'@index', '@id', '@type', '@value', '@language')));
self::addValue(
$rval, $expanded_property, $expanded_value,
array('propertyIsArray' => $use_array));
}
}
// get property count on expanded output
$keys = array_keys((array)$rval);
$count = count($keys);
// @value must only have @language or @type
if(property_exists($rval, '@value')) {
// @value must only have @language or @type
if(property_exists($rval, '@type') &&
property_exists($rval, '@language')) {
throw new JsonLdException(
'Invalid JSON-LD syntax; an element containing "@value" may not ' .
'contain both "@type" and "@language".',
'jsonld.SyntaxError', array('element' => $rval));
}
$valid_count = $count - 1;
if(property_exists($rval, '@type')) {
$valid_count -= 1;
}
if(property_exists($rval, '@index')) {
$valid_count -= 1;
}
if(property_exists($rval, '@language')) {
$valid_count -= 1;
}
if($valid_count !== 0) {
throw new JsonLdException(
'Invalid JSON-LD syntax; an element containing "@value" may only ' .
'have an "@index" property and at most one other property ' .
'which can be "@type" or "@language".',
'jsonld.SyntaxError', array('element' => $rval));
}
// drop null @values
if($rval->{'@value'} === null) {
$rval = null;
}
// drop @language if @value isn't a string
else if(property_exists($rval, '@language') &&
!is_string($rval->{'@value'})) {
unset($rval->{'@language'});
}
}
// convert @type to an array
else if(property_exists($rval, '@type') && !is_array($rval->{'@type'})) {
$rval->{'@type'} = array($rval->{'@type'});
}
// handle @set and @list
else if(property_exists($rval, '@set') ||
property_exists($rval, '@list')) {
if($count > 1 && ($count !== 2 && property_exists($rval, '@index'))) {
throw new JsonLdException(
'Invalid JSON-LD syntax; if an element has the property "@set" ' .
'or "@list", then it can have at most one other property that is ' .
'"@index".',
'jsonld.SyntaxError', array('element' => $rval));
}
// optimize away @set
if(property_exists($rval, '@set')) {
$rval = $rval->{'@set'};
$keys = array_keys((array)$rval);
$count = count($keys);
}
}
// drop objects with only @language
else if($count === 1 && property_exists($rval, '@language')) {
$rval = null;
}
// drop certain top-level objects that do not occur in lists
if(is_object($rval) &&
!$options['keepFreeFloatingNodes'] && !$inside_list &&
($active_property === null || $expanded_active_property === '@graph')) {
// drop empty object or top-level @value
if($count === 0 || property_exists($rval, '@value')) {
$rval = null;
}
else {
// drop subjects that generate no triples
$has_triples = false;
$ignore = array('@graph', '@type');
foreach($keys as $key) {
if(!self::_isKeyword($key) || in_array($key, $ignore)) {
$has_triples = true;
break;
}
}
if(!$has_triples) {
$rval = null;
}
}
}
return $rval;
}
// drop top-level scalars that are not in lists
if(!$inside_list &&
($active_property === null ||
$this->_expandIri($active_ctx, $active_property,
array('vocab' => true)) === '@graph')) {
return null;
}
// expand element according to value expansion rules
return $this->_expandValue($active_ctx, $active_property, $element);
}
/**
* Performs JSON-LD flattening.
*
* @param array $input the expanded JSON-LD to flatten.
*
* @return array the flattened output.
*/
protected function _flatten($input) {
// produce a map of all subjects and name each bnode
$namer = new UniqueNamer('_:b');
$graphs = (object)array('@default' => new stdClass());
$this->_createNodeMap($input, $graphs, '@default', $namer);
// add all non-default graphs to default graph
$default_graph = $graphs->{'@default'};
$graph_names = array_keys((array)$graphs);
foreach($graph_names as $graph_name) {
if($graph_name === '@default') {
continue;
}
$node_map = $graphs->{$graph_name};
if(!property_exists($default_graph, $graph_name)) {
$default_graph->{$graph_name} = (object)array(
'@id' => $graph_name, '@graph' => array());
}
$subject = $default_graph->{$graph_name};
if(!property_exists($subject, '@graph')) {
$subject->{'@graph'} = array();
}
$ids = array_keys((array)$node_map);
sort($ids);
foreach($ids as $id) {
$subject->{'@graph'}[] = $node_map->{$id};
}
}
// produce flattened output
$flattened = array();
$keys = array_keys((array)$default_graph);
sort($keys);
foreach($keys as $key) {
$flattened[] = $default_graph->{$key};
}
return $flattened;
}
/**
* Performs JSON-LD framing.
*
* @param array $input the expanded JSON-LD to frame.
* @param array $frame the expanded JSON-LD frame to use.
* @param assoc $options the framing options.
*
* @return array the framed output.
*/
protected function _frame($input, $frame, $options) {
// create framing state
$state = (object)array(
'options' => $options,
'graphs' => (object)array(
'@default' => new stdClass(),
'@merged' => new stdClass()));
// produce a map of all graphs and name each bnode
// FIXME: currently uses subjects from @merged graph only
$namer = new UniqueNamer('_:b');
$this->_createNodeMap($input, $state->graphs, '@merged', $namer);
$state->subjects = $state->graphs->{'@merged'};
// frame the subjects
$framed = new ArrayObject();
$keys = array_keys((array)$state->subjects);
sort($keys);
$this->_matchFrame($state, $keys, $frame, $framed, null);
return (array)$framed;
}
/**
* Performs JSON-LD normalization.
*
* @param array $input the expanded JSON-LD object to normalize.
* @param assoc $options the normalization options.
*
* @return mixed the normalized output.
*/
protected function _normalize($input, $options) {
// map bnodes to RDF statements
$statements = array();
$bnodes = new stdClass();
$namer = new UniqueNamer('_:b');
$this->_toRDF($input, $namer, null, null, null, $statements);
foreach($statements as $statement) {
foreach(array('subject', 'object', 'name') as $node) {
if(property_exists($statement, $node) &&
$statement->{$node}->interfaceName === 'BlankNode') {
$id = $statement->{$node}->nominalValue;
if(property_exists($bnodes, $id)) {
$bnodes->{$id}->statements[] = $statement;
}
else {
$bnodes->{$id} = (object)array('statements' => array($statement));
}
}
}
}
// create canonical namer
$namer = new UniqueNamer('_:c14n');
// continue to hash bnode statements while bnodes are assigned names
$unnamed = null;
$nextUnnamed = array_keys((array)$bnodes);
$duplicates = null;
do {
$unnamed = $nextUnnamed;
$nextUnnamed = array();
$duplicates = new stdClass();
$unique = new stdClass();
foreach($unnamed as $bnode) {
// hash statements for each unnamed bnode
$hash = $this->_hashStatements($bnode, $bnodes, $namer);
// store hash as unique or a duplicate
if(property_exists($duplicates, $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 = array_keys((array)$unique);
sort($hashes);
foreach($hashes as $hash) {
$namer->getName($unique->{$hash});
}
}
while(count($unnamed) > count($nextUnnamed));
// enumerate duplicate hash groups in sorted order
$hashes = array_keys((array)$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('_:b');
$path_namer->getName($bnode);
$results[] = $this->_hashPaths($bnode, $bnodes, $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 normalized array
$normalized = array();
// update bnode names in each statement and serialize
foreach($statements as $statement) {
foreach(array('subject', 'object', 'name') as $node) {
if(property_exists($statement, $node) &&
$statement->{$node}->interfaceName === 'BlankNode' &&
strpos($statement->{$node}->nominalValue, '_:c14n') !== 0) {
$statement->{$node}->nominalValue = $namer->getName(
$statement->{$node}->nominalValue);
}
}
$normalized[] = $this->toNQuad($statement);
}
// sort normalized output
sort($normalized);
// handle output format
if(isset($options['format'])) {
if($options['format'] === 'application/nquads') {
return implode($normalized);
}
else {
throw new JsonLdException(
'Unknown output format.',
'jsonld.UnknownFormat', array('format' => $options['format']));
}
}
// return parsed RDF statements
return $this->parseNQuads(implode($normalized));
}
/**
* Converts RDF statements into JSON-LD.
*
* @param array $statements the RDF statements.
* @param assoc $options the RDF conversion options.
*
* @return array the JSON-LD output.
*/
protected function _fromRDF($statements, $options) {
// prepare graph map (maps graph name => subjects, lists)
$default_graph = (object)array(
'subjects' => new stdClass(), 'listMap' => new stdClass());
$graphs = new stdClass();
foreach($statements as $statement) {
// get subject, property, object, and graph name (default to '')
$s = $statement->subject->nominalValue;
$p = $statement->property->nominalValue;
$o = $statement->object;
$name = (property_exists($statement, 'name') ?
$statement->name->nominalValue : '');
// use default graph
if($name === '') {
$graph = $default_graph;
}
// create a named graph entry as needed
else if(!property_exists($graphs, $name)) {
$graph = $graphs->{$name} = (object)array(
'subjects' => new stdClass(), 'listMap' => new stdClass());
}
else {
$graph = $graphs->{$name};
}
// handle element in @list
if($p === self::RDF_FIRST) {
// create list entry as needed
$list_map = $graph->listMap;
if(!property_exists($list_map, $s)) {
$entry = $list_map->{$s} = new stdClass();
}
else {
$entry = $list_map->{$s};
}
// set object value
$entry->first = $this->_rdfToObject($o, $options['useNativeTypes']);
continue;
}
// handle other element in @list
if($p === self::RDF_REST) {
// set next in list
if($o->interfaceName === 'BlankNode') {
// create list entry as needed
$list_map = $graph->listMap;
if(!property_exists($list_map, $s)) {
$entry = $list_map->{$s} = new stdClass();
}
else {
$entry = $list_map->{$s};
}
$entry->rest = $o->nominalValue;
}
continue;
}
// if graph is not the default graph
if($name !== '' && !property_exists($default_graph->subjects, $name)) {
$default_graph->subjects->{$name} = (object)array('@id' => $name);
}
// add subject to graph as needed
$subjects = $graph->subjects;
if(!property_exists($subjects, $s)) {
$value = $subjects->{$s} = (object)array('@id' => $s);
}
// use existing subject value
else {
$value = $subjects->{$s};
}
// convert to @type unless options indicate to treat rdf:type as property
if($p === self::RDF_TYPE && !$options['useRdfType']) {
// add value of object as @type
self::addValue(
$value, '@type', $o->nominalValue, array('propertyIsArray' => true));
}
else {
// add property to value as needed
$object = $this->_rdfToObject($o, $options['useNativeTypes']);
self::addValue($value, $p, $object, array('propertyIsArray' => true));
// a bnode might be the beginning of a list, so add it to the list map
if($o->interfaceName === 'BlankNode') {
$id = $object->{'@id'};
$list_map = $graph->listMap;
if(!property_exists($list_map, $id)) {
$entry = $list_map->{$id} = new stdClass();
}
else {
$entry = $list_map->{$id};
}
$entry->head = $object;
}
}
}
// build @lists
$all_graphs = array_values((array)$graphs);
$all_graphs[] = $default_graph;
foreach($all_graphs as $graph) {
// find list head
$list_map = $graph->listMap;
foreach($list_map as $subject => $entry) {
// head found, build lists
if(property_exists($entry, 'head') &&
property_exists($entry, 'first')) {
// replace bnode @id with @list
$value = $entry->head;
unset($value->{'@id'});
$list = array($entry->first);
while(property_exists($entry, 'rest')) {
$rest = $entry->rest;
$entry = $list_map->{$rest};
if(!property_exists($entry, 'first')) {
throw new JsonLdException(
'Invalid RDF list entry.',
'jsonld.RdfError', array('bnode' => $rest));
}
$list[] = $entry->first;
}
$value->{'@list'} = $list;
}
}
}
// build default graph in subject @id order
$output = array();
$subjects = $default_graph->subjects;
$ids = array_keys((array)$subjects);
sort($ids);
foreach($ids as $i => $id) {
// add subject to default graph
$subject = $subjects->{$id};
$output[] = $subject;
// output named graph in subject @id order
if(property_exists($graphs, $id)) {
$graph = array();
$_subjects = $graphs->{$id}->subjects;
$_ids = array_keys((array)$_subjects);
sort($_ids);
foreach($_ids as $_i => $_id) {
$graph[] = $_subjects->{$_id};
}
$subject->{'@graph'} = $graph;
}
}
return $output;
}
/**
* Outputs the RDF statements found in the given JSON-LD element.
*
* @param mixed element the JSON-LD element.
* @param UniqueNamer namer the UniqueNamer for assigning bnode names.
* @param mixed subject the active subject.
* @param mixed property the active property.
* @param mixed graph the graph name.
* @param &array statements the array to add statements to.
*/
protected function _toRDF(
$element, $namer, $subject, $property, $graph, &$statements) {
// recurse into arrays
if(is_array($element)) {
// recurse into arrays
foreach($element as $e) {
$this->_toRDF($e, $namer, $subject, $property, $graph, $statements);
}
return;
}
// element must be an rdf:type IRI (@values covered above)
if(is_string($element)) {
// emit IRI
$statement = (object)array(
'subject' => self::copy($subject),
'property' => self::copy($property),
'object' => (object)array(
'nominalValue' => $element,
'interfaceName' => 'IRI'));
if($graph !== null) {
$statement->name = $graph;
}
self::_appendUniqueRdfStatement($statements, $statement);
return;
}
// convert @list
if(self::_isList($element)) {
$list = $this->_makeLinkedList($element);
$this->_toRDF($list, $namer, $subject, $property, $graph, $statements);
return;
}
// convert @value to object
if(self::_isValue($element)) {
$value = $element->{'@value'};
$datatype = (property_exists($element, '@type') ?
$element->{'@type'} : null);
if(is_bool($value) || is_double($value) || is_integer($value)) {
// convert to XSD datatypes as appropriate
if(is_bool($value)) {
$value = ($value ? 'true' : 'false');
$datatype or $datatype = self::XSD_BOOLEAN;
}
else if(is_double($value)) {
// canonical double representation
$value = preg_replace('/(\d)0*E\+?/', '$1E',
sprintf('%1.15E', $value));
$datatype or $datatype = self::XSD_DOUBLE;
}
else {
$value = strval($value);
$datatype or $datatype = self::XSD_INTEGER;
}
}
// default to xsd:string datatype
$datatype or $datatype = self::XSD_STRING;
$object = (object)array(
'nominalValue' => $value,
'interfaceName' => 'LiteralNode',
'datatype' => (object)array(
'nominalValue' => $datatype,
'interfaceName' => 'IRI'));
if(property_exists($element, '@language') &&
$datatype === self::XSD_STRING) {
$object->language = $element->{'@language'};
}
// emit literal
$statement = (object)array(
'subject' => self::copy($subject),
'property' => self::copy($property),
'object' => $object);
if($graph !== null) {
$statement->name = $graph;
}
self::_appendUniqueRdfStatement($statements, $statement);
return;
}
// Note: element must be a subject
// get subject @id (generate one if it is a bnode)
$id = property_exists($element, '@id') ? $element->{'@id'} : null;
$is_bnode = self::_isBlankNode($element);
if($is_bnode) {
$id = $namer->getName($id);
}
// create object
$object = (object)array(
'nominalValue' => $id,
'interfaceName' => $is_bnode ? 'BlankNode' : 'IRI');
// emit statement if subject isn't null
if($subject !== null) {
$statement = (object)array(
'subject' => self::copy($subject),
'property' => self::copy($property),
'object' => self::copy($object));
if($graph !== null) {
$statement->name = $graph;
}
self::_appendUniqueRdfStatement($statements, $statement);
}
// set new active subject to object
$subject = $object;
// recurse over subject properties in order
$props = array_keys((array)$element);
sort($props);
foreach($props as $prop) {
$p = $prop;
// convert @type to rdf:type
if($prop === '@type') {
$p = self::RDF_TYPE;
}
// recurse into @graph
if($prop === '@graph') {
$this->_toRDF(
$element->{$prop}, $namer, null, null, $subject, $statements);
continue;
}
// skip keywords
if(self::_isKeyword($p)) {
continue;
}
// create new active property
$property = (object)array(
'nominalValue' => $p,
'interfaceName' => 'IRI');
// recurse into value
$this->_toRDF(
$element->{$prop}, $namer, $subject, $property, $graph, $statements);
}
}
/**
* 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 assoc $options the context processing options.
*
* @return stdClass the new active context.
*/
protected function _processContext($active_ctx, $local_ctx, $options) {
global $jsonld_cache;
$rval = null;
// get context from cache if available
if(property_exists($jsonld_cache, 'activeCtx')) {
$rval = $jsonld_cache->activeCtx->get($active_ctx, $local_ctx);
if($rval) {
$rval->namer = $active_ctx->namer;
return $rval;
}
}
// initialize the resulting context
$rval = self::_cloneActiveContext($active_ctx);
// normalize local context to an array
if(is_object($local_ctx) && property_exists($local_ctx, '@context') &&
is_array($local_ctx->{'@context'})) {
$local_ctx = $local_ctx->{'@context'};
}
$ctxs = self::arrayify($local_ctx);
// process each context in order
foreach($ctxs as $ctx) {
// reset to initial context
if($ctx === null) {
$rval = $this->_getInitialContext($options);
$rval->namer = $active_ctx->namer;
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 retrieved 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();
// handle @vocab
if(property_exists($ctx, '@vocab')) {
$value = $ctx->{'@vocab'};
if($value === null) {
unset($rval->{'@vocab'});
}
else if(!is_string($value)) {
throw new JsonLdException(
'Invalid JSON-LD syntax; the value of "@vocab" in a ' .
'@context must be a string or null.',
'jsonld.SyntaxError', array('context' => $ctx));
}
else if(!self::_isAbsoluteIri($value)) {
throw new JsonLdException(
'Invalid JSON-LD syntax; the value of "@vocab" in a ' .
'@context must be an absolute IRI.',
'jsonld.SyntaxError', array('context' => $ctx));
}
else {
$rval->{'@vocab'} = $value;
}
$defined->{'@vocab'} = true;
}
// handle @language
if(property_exists($ctx, '@language')) {
$value = $ctx->{'@language'};
if($value === null) {
unset($rval->{'@language'});
}
else if(!is_string($value)) {
throw new JsonLdException(
'Invalid JSON-LD syntax; the value of "@language" in a ' .
'@context must be a string or null.',
'jsonld.SyntaxError', array('context' => $ctx));
}
else {
$rval->{'@language'} = strtolower($value);
}
$defined->{'@language'} = true;
}
// process all other keys
foreach($ctx as $k => $v) {
$this->_createTermDefinition($rval, $ctx, $k, $defined);
}
}
// cache result
if(property_exists($jsonld_cache, 'activeCtx')) {
$jsonld_cache->activeCtx->set($active_ctx, $local_ctx, $rval);
}
return $rval;
}
/**
* Expands a language map.
*
* @param stdClass $language_map the language map to expand.
*
* @return array the expanded language map.
*/
protected function _expandLanguageMap($language_map) {
$rval = array();
$keys = array_keys((array)$language_map);
sort($keys);
foreach($keys as $key) {
$values = $language_map->{$key};
$values = self::arrayify($values);
foreach($values as $item) {
if(!is_string($item)) {
throw new JsonLdException(
'Invalid JSON-LD syntax; language map values must be strings.',
'jsonld.SyntaxError', array('languageMap', $language_map));
}
$rval[] = (object)array(
'@value' => $item,
'@language' => strtolower($key));
}
}
return $rval;
}
/**
* Labels the blank nodes in the given value using the given UniqueNamer.
*
* @param UniqueNamer $namer the UniqueNamer to use.
* @param mixed $element the element with blank nodes to rename.
*
* @return mixed the element.
*/
public function _labelBlankNodes($namer, $element) {
if(is_array($element)) {
$length = count($element);
for($i = 0; $i < $length; ++$i) {
$element[$i] = $this->_labelBlankNodes($namer, $element[$i]);
}
}
else if(self::_isList($element)) {
$element->{'@list'} = $this->_labelBlankNodes(
$namer, $element->{'@list'});
}
else if(is_object($element)) {
// rename blank node
if(self::_isBlankNode($element)) {
$name = null;
if(property_exists($element, '@id')) {
$name = $element->{'@id'};
}
$element->{'@id'} = $namer->getName($name);
}
// recursively apply to all keys
$keys = array_keys((array)$element);
sort($keys);
foreach($keys as $key) {
if($key !== '@id') {
$element->{$key} = $this->_labelBlankNodes($namer, $element->{$key});
}
}
}
return $element;
}
/**
* Expands the given value by using the coercion and keyword rules in the
* given context.
*
* @param stdClass $active_ctx the active context to use.
* @param string $active_property the property the value is associated with.
* @param mixed $value the value to expand.
*
* @return mixed the expanded value.
*/
protected function _expandValue($active_ctx, $active_property, $value) {
// nothing to expand
if($value === null) {
return null;
}
// special-case expand @id and @type (skips '@id' expansion)
$expanded_property = $this->_expandIri(
$active_ctx, $active_property, array('vocab' => true));
if($expanded_property === '@id') {
return $this->_expandIri($active_ctx, $value, array('base' => true));
}
else if($expanded_property === '@type') {
return $this->_expandIri(
$active_ctx, $value, array('vocab' => true, 'base' => true));
}
// get type definition from context
$type = self::getContextValue($active_ctx, $active_property, '@type');
// do @id expansion (automatic for @graph)
if($type === '@id' || ($expanded_property === '@graph' &&
is_string($value))) {
return (object)array('@id' => $this->_expandIri(
$active_ctx, $value, array('base' => true)));
}
// do @id expansion w/vocab
if($type === '@vocab') {
return (object)array('@id' => $this->_expandIri(
$active_ctx, $value, array('vocab' => true, 'base' => true)));
}
// do not expand keyword values
if(self::_isKeyword($expanded_property)) {
return $value;
}
$rval = new stdClass();
// other type
if($type !== null) {
// rename blank node if requested
if($active_ctx->namer !== null && strpos($type, '_:') === 0) {
$type = $active_ctx->namer->getName($type);
}
$rval->{'@type'} = $type;
}
// check for language tagging for strings
else if(is_string($value)) {
$language = self::getContextValue(
$active_ctx, $active_property, '@language');
if($language !== null) {
$rval->{'@language'} = $language;
}
}
$rval->{'@value'} = $value;
return $rval;
}
/**
* Converts an RDF statement object to a JSON-LD object.
*
* @param stdClass $o the RDF statement object to convert.
* @param bool $use_native_types true to output native types, false not to.
*
* @return stdClass the JSON-LD object.
*/
protected function _rdfToObject($o, $use_native_types) {
// convert empty list
if($o->interfaceName === 'IRI' && $o->nominalValue === self::RDF_NIL) {
return (object)array('@list' => array());
}
// convert IRI/BlankNode object to JSON-LD
if($o->interfaceName === 'IRI' || $o->interfaceName === 'BlankNode') {
return (object)array('@id' => $o->nominalValue);
}
// convert literal object to JSON-LD
$rval = (object)array('@value' => $o->nominalValue);
// add datatype
if(property_exists($o, 'datatype')) {
$type = $o->datatype->nominalValue;
// use native types for certain xsd types
if($use_native_types) {
if($type === self::XSD_BOOLEAN) {
if($rval->{'@value'} === 'true') {
$rval->{'@value'} = true;
}
else if($rval->{'@value'} === 'false') {
$rval->{'@value'} = false;
}
}
else if(is_numeric($rval->{'@value'})) {
if($type === self::XSD_INTEGER) {
$i = intval($rval->{'@value'});
if(strval($i) === $rval->{'@value'}) {
$rval->{'@value'} = $i;
}
}
else if($type === self::XSD_DOUBLE) {
$rval->{'@value'} = doubleval($rval->{'@value'});
}
}
// do not add native type
if(!in_array($type, array(
self::XSD_BOOLEAN, self::XSD_INTEGER, self::XSD_DOUBLE,
self::XSD_STRING))) {
$rval->{'@type'} = $type;
}
}
else {
$rval->{'@type'} = $type;
}
}
// add language
if(property_exists($o, 'language')) {
$rval->{'@language'} = $o->language;
}
return $rval;
}
/**
* 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 in reverse
$list = $value->{'@list'};
$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;
}
/**
* Recursively flattens the subjects in the given JSON-LD expanded input
* into a node map.
*
* @param mixed $input the JSON-LD expanded input.
* @param stdClass $graphs a map of graph name to subject map.
* @[ara, string $graph the name of the current graph.
* @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 _createNodeMap(
$input, $graphs, $graph, $namer, $name=null, $list=null) {
// recurse through array
if(is_array($input)) {
foreach($input as $e) {
$this->_createNodeMap($e, $graphs, $graph, $namer, null, $list);
}
return;
}
// add non-object to list
if(!is_object($input)) {
if($list !== null) {
$list[] = $input;
}
return;
}
// add entries for @type
if(property_exists($input, '@type')) {
$types = $input->{'@type'};
$types = self::arrayify($types);
foreach($types as $type) {
$id = (strpos($type, '_:') === 0) ? $namer->getName($type) : $type;
if(!property_exists($graphs->{$graph}, $id)) {
$graphs->{$graph}->{$id} = (object)array('@id' => $id);
}
}
}
// add add values to list
if(self::_isValue($input)) {
if($list !== null) {
$list[] = $input;
}
return;
}
// Note: At this point, input must be a subject.
// get name for subject
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($graphs, $graph)) {
$graphs->{$graph} = new stdClass();
}
$subjects = $graphs->{$graph};
if(!property_exists($subjects, $name)) {
$subjects->{$name} = new stdClass();
}
$subject = $subjects->{$name};
$subject->{'@id'} = $name;
$properties = array_keys((array)$input);
sort($properties);
foreach($properties as $property) {
// skip @id
if($property === '@id') {
continue;
}
// recurse into graph
if($property === '@graph') {
// add graph subjects map entry
if(!property_exists($graphs, $name)) {
$graphs->{$name} = new stdClass();
}
$g = ($graph === '@merged') ? $graph : $name;
$this->_createNodeMap(
$input->{$property}, $graphs, $g, $namer, null, null);
continue;
}
// copy non-@type keywords
if($property !== '@type' && self::_isKeyword($property)) {
$subject->{$property} = $input->{$property};
continue;
}
// iterate over objects (ensure property is added for empty arrays)
$objects = $input->{$property};
if(count($objects) === 0) {
self::addValue(
$subject, $property, array(), array('propertyIsArray' => true));
continue;
}
foreach($objects as $o) {
// handle embedded subject or subject reference
if(self::_isSubject($o) || self::_isSubjectReference($o)) {
// rename blank node @id
$id = property_exists($o, '@id') ? $o->{'@id'} : null;
if(self::_isBlankNode($o)) {
$id = $namer->getName($id);
}
// add reference and recurse
self::addValue(
$subject, $property, (object)array('@id' => $id),
array('propertyIsArray' => true, 'allowDuplicate' => false));
$this->_createNodeMap($o, $graphs, $graph, $namer, $id, null);
}
// handle $list
else if(self::_isList($o)) {
$_list = new ArrayObject();
$this->_createNodeMap(
$o->{'@list'}, $graphs, $graph, $namer, $name, $_list);
$o = (object)array('@list' => (array)$_list);
self::addValue(
$subject, $property, $o,
array('propertyIsArray' => true, 'allowDuplicate' => false));
}
// handle @value
else {
$this->_createNodeMap($o, $graphs, $graph, $namer, $name, null);
self::addValue(
$subject, $property, $o,
array('propertyIsArray' => true, 'allowDuplicate' => false));
}
}
}
}
/**
* 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 _matchFrame(
$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;
$embed_on = $this->_getFrameFlag($frame, $options, 'embed');
$explicit_on = $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($embed_on && 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
$embed_on = 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)) {
$embed_on = true;
break;
}
}
}
// existing embed's parent is an object
else if(self::hasValue(
$existing->parent, $existing->property, $output)) {
$embed_on = true;
}
// existing embed has already been added, so allow an overwrite
if($embed_on) {
$this->_removeEmbed($state, $id);
}
}
// not embedding, add output without any other properties
if(!$embed_on) {
$this->_addFrameOutput($state, $parent, $property, $output);
}
else {
// add embed meta info
$state->embeds->{$id} = $embed;
// iterate over subject properties
$props = array_keys((array)$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(!$explicit_on) {
$this->_embedValues($state, $subject, $prop, $output);
}
continue;
}
// add objects
$objects = $subject->{$prop};
foreach($objects as $o) {
// recurse into list
if(self::_isList($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->_matchFrame(
$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->_matchFrame(
$state, array($o->{'@id'}), $frame->{$prop}, $output, $prop);
}
// include other values automatically
else {
$this->_addFrameOutput($state, $output, $prop, self::copy($o));
}
}
}
// handle defaults
$props = array_keys((array)$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];
$omit_default_on = $this->_getFrameFlag(
$next, $options, 'omitDefault');
if(!$omit_default_on && !property_exists($output, $prop)) {
$preserve = '@null';
if(property_exists($next, '@default')) {
$preserve = self::copy($next->{'@default'});
}
$preserve = self::arrayify($preserve);
$output->{$prop} = array((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 $k => $v) {
// only not a duck if @id or non-keyword isn't in subject
if(($k === '@id' || !self::_isKeyword($k)) &&
!property_exists($subject, $k)) {
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::_isList($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
$use_array = is_array($embed->parent->{$property});
self::removeValue($embed->parent, $property, $subject,
array('propertyIsArray' => $use_array));
self::addValue($embed->parent, $property, $subject,
array('propertyIsArray' => $use_array));
}
// recursively remove dependent dangling embeds
$removeDependents = function($id) {
// get embed keys as a separate array to enable deleting keys in map
$ids = array_keys((array)$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) && !($parent instanceof ArrayObject)) {
self::addValue(
$parent, $property, $output, array('propertyIsArray' => 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::_isList($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;
}
/**
* Hashes all of the statements about a blank node.
*
* @param string $id the ID of the bnode to hash statements for.
* @param stdClass $bnodes the mapping of bnodes to statements.
* @param UniqueNamer $namer the canonical bnode namer.
*
* @return string the new hash.
*/
protected function _hashStatements($id, $bnodes, $namer) {
// return cached hash
if(property_exists($bnodes->{$id}, 'hash')) {
return $bnodes->{$id}->hash;
}
// serialize all of bnode's statements
$statements = $bnodes->{$id}->statements;
$nquads = array();
foreach($statements as $statement) {
$nquads[] = $this->toNQuad($statement, $id);
}
// sort serialized quads
sort($nquads);
// cache and return hashed quads
$hash = $bnodes->{$id}->hash = sha1(implode($nquads));
return $hash;
}
/**
* Produces a hash for the paths of adjacent bnodes for a bnode,
* incorporating all information about its subgraph of bnodes. This
* method will recursively pick adjacent bnode permutations that produce the
* lexicographically-least 'path' serializations.
*
* @param string $id the ID of the bnode to hash paths for.
* @param stdClass $bnodes the map of bnode statements.
* @param UniqueNamer $namer the canonical bnode namer.
* @param UniqueNamer $path_namer the namer used to assign names to adjacent
* bnodes.
*
* @return stdClass the hash and path namer used.
*/
protected function _hashPaths($id, $bnodes, $namer, $path_namer) {
// create SHA-1 digest
$md = hash_init('sha1');
// group adjacent bnodes by hash, keep properties and references separate
$groups = new stdClass();
$statements = $bnodes->{$id}->statements;
foreach($statements as $statement) {
// get adjacent bnode
$bnode = $this->_getAdjacentBlankNodeName($statement->subject, $id);
if($bnode !== null) {
$direction = 'p';
}
else {
$bnode = $this->_getAdjacentBlankNodeName($statement->object, $id);
if($bnode !== null) {
$direction = 'r';
}
}
if($bnode !== null) {
// get bnode name (try canonical, path, then hash)
if($namer->isNamed($bnode)) {
$name = $namer->getName($bnode);
}
else if($path_namer->isNamed($bnode)) {
$name = $path_namer->getName($bnode);
}
else {
$name = $this->_hashStatements($bnode, $bnodes, $namer);
}
// hash direction, property, and bnode name/hash
$group_md = hash_init('sha1');
hash_update($group_md, $direction);
hash_update($group_md, $statement->property->nominalValue);
hash_update($group_md, $name);
$group_hash = hash_final($group_md);
// add bnode to hash group
if(property_exists($groups, $group_hash)) {
$groups->{$group_hash}[] = $bnode;
}
else {
$groups->{$group_hash} = array($bnode);
}
}
}
// iterate over groups in sorted hash order
$group_hashes = array_keys((array)$groups);
sort($group_hashes);
foreach($group_hashes as $group_hash) {
// digest group hash
hash_update($md, $group_hash);
// choose a path and namer from the permutations
$chosen_path = null;
$chosen_namer = null;
$permutator = new Permutator($groups->{$group_hash});
while($permutator->hasNext()) {
$permutation = $permutator->next();
$path_namer_copy = clone $path_namer;
// build adjacent path
$path = '';
$skipped = false;
$recurse = array();
foreach($permutation as $bnode) {
// use canonical name if available
if($namer->isNamed($bnode)) {
$path .= $namer->getName($bnode);
}
else {
// recurse if bnode isn't named in the path yet
if(!$path_namer_copy->isNamed($bnode)) {
$recurse[] = $bnode;
}
$path .= $path_namer_copy->getName($bnode);
}
// skip permutation if path is already >= chosen path
if($chosen_path !== null && strlen($path) >= strlen($chosen_path) &&
$path > $chosen_path) {
$skipped = true;
break;
}
}
// recurse
if(!$skipped) {
foreach($recurse as $bnode) {
$result = $this->_hashPaths(
$bnode, $bnodes, $namer, $path_namer_copy);
$path .= $path_namer_copy->getName($bnode);
$path .= "<{$result->hash}>";
$path_namer_copy = $result->pathNamer;
// skip permutation if path is already >= chosen path
if($chosen_path !== null &&
strlen($path) >= strlen($chosen_path) && $path > $chosen_path) {
$skipped = true;
break;
}
}
}
if(!$skipped && ($chosen_path === null || $path < $chosen_path)) {
$chosen_path = $path;
$chosen_namer = $path_namer_copy;
}
}
// digest chosen path and update namer
hash_update($md, $chosen_path);
$path_namer = $chosen_namer;
}
// return SHA-1 hash and path namer
return (object)array(
'hash' => hash_final($md), 'pathNamer' => $path_namer);
}
/**
* A helper function that gets the blank node name from an RDF statement
* node (subject or object). If the node is not a blank node or its
* nominal value does not match the given blank node ID, it will be
* returned.
*
* @param stdClass $node the RDF statement node.
* @param string $id the ID of the blank node to look next to.
*
* @return mixed the adjacent blank node name or null if none was found.
*/
protected function _getAdjacentBlankNodeName($node, $id) {
if($node->interfaceName === 'BlankNode' && $node->nominalValue !== $id) {
return $node->nominalValue;
}
return null;
}
/**
* Compares two strings first based on length and then lexicographically.
*
* @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) {
$len_a = strlen($a);
$len_b = strlen($b);
if($len_a < $len_b) {
return -1;
}
else if($len_b < $len_a) {
return 1;
}
else if($a === $b) {
return 0;
}
return ($a < $b) ? -1 : 1;
}
/**
* Picks the preferred compaction term from the given inverse context entry.
*
* @param active_ctx the active context.
* @param iri the IRI to pick the term for.
* @param value the value to pick the term for.
* @param parent the parent of the value (required for property generators).
* @param containers the preferred containers.
* @param type_or_language either '@type' or '@language'.
* @param type_or_language_value the preferred value for '@type' or
* '@language'.
*
* @return mixed the preferred term.
*/
protected function _selectTerm(
$active_ctx, $iri, $value, $parent, $containers,
$type_or_language, $type_or_language_value) {
$containers[] = '@none';
if($type_or_language_value === null) {
$type_or_language_value = '@null';
}
// options for the value of @type or @language,
// determine for @id based on whether or not value compacts to a term
if($type_or_language_value === '@id' && self::_isSubjectReference($value)) {
// try to compact value to a term
$term = $this->_compactIri(
$active_ctx, $value->{'@id'}, null, array('vocab' => true));
if(property_exists($active_ctx->mappings, $term) &&
$active_ctx->mappings->{$term} &&
$active_ctx->mappings->{$term}->{'@id'} === $value->{'@id'}) {
// prefer @vocab
$options = array('@vocab', '@id', '@none');
}
else {
// prefer @id
$options = array('@id', '@vocab', '@none');
}
}
else {
$options = array($type_or_language_value, '@none');
}
$term = null;
$container_map = $active_ctx->inverse->{$iri};
foreach($containers as $container) {
if($term !== null) {
break;
}
// if container not available in the map, continue
if(!property_exists($container_map, $container)) {
continue;
}
$type_or_language_value_map =
$container_map->{$container}->{$type_or_language};
foreach($options as $option) {
if($term !== null) {
break;
}
// if type/language option not available in the map, continue
if(!property_exists($type_or_language_value_map, $option)) {
continue;
}
$term_info = $type_or_language_value_map->{$option};
// see if a property generator matches
if(is_object($parent)) {
foreach($term_info->propertyGenerators as $property_generator) {
$match = $this->_findPropertyGeneratorDuplicates(
$active_ctx, $parent, $iri, $value, $property_generator, false);
if($match) {
$term = $property_generator;
break;
}
}
}
// no matching property generator, use a simple term instead
if($term === null) {
$term = $term_info->term;
}
}
}
return $term;
}
/**
* Compacts an IRI or keyword into a term or prefix if it can be. If the
* IRI has an associated value it may be passed.
*
* @param stdClass $active_ctx the active context to use.
* @param string $iri the IRI to compact.
* @param mixed $value the value to check or null.
* @param assoc $relative_to options for how to compact IRIs:
* vocab: true to split after @vocab, false not to.
* @param mixed $parent the parent element for the value.
*
* @return string the compacted term, prefix, keyword alias, or original IRI.
*/
protected function _compactIri(
$active_ctx, $iri, $value=null, $relative_to=array(), $parent=null) {
// can't compact null
if($iri === null) {
return $iri;
}
// term is a keyword
if(self::_isKeyword($iri)) {
// return alias if available
$aliases = $active_ctx->keywords->{$iri};
return (count($aliases) > 0) ? $aliases[0] : $iri;
}
// use inverse context to pick a term if iri is relative to vocab
if(isset($relative_to['vocab']) && $relative_to['vocab'] &&
property_exists($this->_getInverseContext($active_ctx), $iri)) {
$default_language = '@none';
if(property_exists($active_ctx, '@language')) {
$default_language = $active_ctx->{'@language'};
}
// prefer @index if available in value
$containers = array();
if(is_object($value) && property_exists($value, '@index')) {
$containers[] = '@index';
}
// defaults for term selection based on type/language
$type_or_language = '@language';
$type_or_language_value = '@null';
// choose the most specific term that works for all elements in @list
if(self::_isList($value)) {
// only select @list containers if @index is NOT in value
if(!property_exists($value, '@index')) {
$containers[] = '@list';
}
$list = $value->{'@list'};
$common_language = (count($list) === 0) ? $default_language : null;
$common_type = null;
foreach($list as $item) {
$item_language = '@none';
$item_type = '@none';
if(self::_isValue($item)) {
if(property_exists($item, '@language')) {
$item_language = $item->{'@language'};
}
else if(property_exists($item, '@type')) {
$item_type = $item->{'@type'};
}
// plain literal
else {
$item_language = '@null';
}
}
else {
$item_type = '@id';
}
if($common_language === null) {
$common_language = $item_language;
}
else if($item_language !== $common_language &&
self::_isValue($item)) {
$common_language = '@none';
}
if($common_type === null) {
$common_type = $item_type;
}
else if($item_type !== $common_type) {
$common_type = '@none';
}
// there are different languages and types in the list, so choose
// the most generic term, no need to keep iterating the list
if($common_language === '@none' && $common_type === '@none') {
break;
}
}
if($common_language === null) {
$common_language = '@none';
}
if($common_type === null) {
$common_type = '@none';
}
if($common_type !== '@none') {
$type_or_language = '@type';
$type_or_language_value = $common_type;
}
else {
$type_or_language_value = $common_language;
}
}
else {
if(self::_isValue($value)) {
if(property_exists($value, '@language')) {
$containers[] = '@language';
$type_or_language_value = $value->{'@language'};
}
else if(property_exists($value, '@type')) {
$type_or_language = '@type';
$type_or_language_value = $value->{'@type'};
}
}
else {
$type_or_language = '@type';
$type_or_language_value = '@id';
}
$containers[] = '@set';
}
// do term selection
$term = $this->_selectTerm(
$active_ctx, $iri, $value, $parent,
$containers, $type_or_language, $type_or_language_value);
if($term !== null) {
return $term;
}
}
// no term match, check for possible CURIEs
$choice = null;
foreach($active_ctx->mappings as $term => $definition) {
// skip terms with colons, they can't be prefixes
if(strpos($term, ':') !== false) {
continue;
}
// skip entries with @ids that are not partial matches
if($definition === null || $definition->propertyGenerator ||
$definition->{'@id'} === $iri ||
strpos($iri, $definition->{'@id'}) !== 0) {
continue;
}
// a CURIE is usable if:
// 1. it has no mapping, OR
// 2. value is null, which means we're not compacting an @value, AND
// the mapping matches the IRI)
$curie = $term . ':' . substr($iri, strlen($definition->{'@id'}));
$is_usable_curie = (!property_exists($active_ctx->mappings, $curie) ||
($value === null && $active_ctx->mappings->{$curie} &&
$active_ctx->mappings->{$curie}->{'@id'} === $iri));
// select curie if it is shorter or the same length but lexicographically
// less than the current choice
if($is_usable_curie && ($choice === null ||
self::_compareShortestLeast($curie, $choice) < 0)) {
$choice = $curie;
}
}
// return chosen curie
if($choice !== null) {
return $choice;
}
// no matching terms or curies, use @vocab if available
if(isset($relative_to['vocab']) && $relative_to['vocab']) {
if(property_exists($active_ctx, '@vocab')) {
// determine if vocab is a prefix of the iri
$vocab = $active_ctx->{'@vocab'};
if(strpos($iri, $vocab) === 0 && $iri !== $vocab) {
// use suffix as relative iri if it is not a term in the active context
$suffix = substr($iri, strlen($vocab));
if(!property_exists($active_ctx->mappings, $suffix)) {
return $suffix;
}
}
}
}
// compact IRI relative to base
else {
return jsonld_remove_base($active_ctx->{'@base'}, $iri);
}
// return IRI as is
return $iri;
}
/**
* Performs value compaction on an object with '@value' or '@id' as the only
* property.
*
* @param stdClass $active_ctx the active context.
* @param string $active_property the active property that points to the
* value.
* @param mixed $value the value to compact.
*
* @return mixed the compaction result.
*/
protected function _compactValue($active_ctx, $active_property, $value) {
// value is a @value
if(self::_isValue($value)) {
// get context rules
$type = self::getContextValue($active_ctx, $active_property, '@type');
$language = self::getContextValue(
$active_ctx, $active_property, '@language');
$container = self::getContextValue(
$active_ctx, $active_property, '@container');
// whether or not the value has an @index that must be preserved
$preserve_index = (property_exists($value, '@index') &&
$container !== '@index');
// if there's no @index to preserve ...
if(!$preserve_index) {
// matching @type or @language specified in context, compact value
if(self::_hasKeyValue($value, '@type', $type) ||
self::_hasKeyValue($value, '@language', $language)) {
return $value->{'@value'};
}
}
// return just the value of @value if all are true:
// 1. @value is the only key or @index isn't being preserved
// 2. there is no default language or @value is not a string or
// the key has a mapping with a null @language
$key_count = count(array_keys((array)$value));
$is_value_only_key = ($key_count === 1 ||
($key_count === 2 && property_exists($value, '@index') &&
!$preserve_index));
$has_default_language = property_exists($active_ctx, '@language');
$is_value_string = is_string($value->{'@value'});
$has_null_mapping = (
property_exists($active_ctx->mappings, $active_property) &&
$active_ctx->mappings->{$active_property} !== null &&
self::_hasKeyValue(
$active_ctx->mappings->{$active_property}, '@language', null));
if($is_value_only_key &&
(!$has_default_language || !$is_value_string || $has_null_mapping)) {
return $value->{'@value'};
}
$rval = new stdClass();
// preserve @index
if($preserve_index) {
$rval->{$this->_compactIri($active_ctx, '@index')} = $value->{'@index'};
}
// compact @type IRI
if(property_exists($value, '@type')) {
$rval->{$this->_compactIri($active_ctx, '@type')} = $this->_compactIri(
$active_ctx, $value->{'@type'}, null, array('vocab' => true));
}
// alias @language
else if(property_exists($value, '@language')) {
$rval->{$this->_compactIri($active_ctx, '@language')} =
$value->{'@language'};
}
// alias @value
$rval->{$this->_compactIri($active_ctx, '@value')} = $value->{'@value'};
return $rval;
}
// value is a subject reference
$expanded_property = $this->_expandIri(
$active_ctx, $active_property, array('vocab' => true));
$type = self::getContextValue($active_ctx, $active_property, '@type');
$compacted = $this->_compactIri(
$active_ctx, $value->{'@id'}, null,
array('vocab' => ($type === '@vocab')));
// compact to scalar
if($type === '@id' || $type === '@vocab' ||
$expanded_property === '@graph') {
return $compacted;
}
$rval = (object)array(
$this->_compactIri($active_ctx, '@id') => $compacted);
return $rval;
}
/**
* Finds and, if specified, removes any duplicate values that were
* presumably generated by a property generator in the given element.
*
* @param stdClass $active_ctx the active context.
* @param stdClass $element the element to remove duplicates from.
* @param string $expanded_property the property to map to a property
* generator.
* @param mixed $value the value to compare against when duplicate checking.
* @param string $active_property the property generator term.
* @param bool $remove true to remove the duplicates found, false not to.
*
* @return bool true if duplicates were found for every IRI.
*/
protected function _findPropertyGeneratorDuplicates(
$active_ctx, $element, $expanded_property, $value, $active_property,
$remove) {
$rval = true;
// get property generator IRIs
$iris = $active_ctx->mappings->{$active_property}->{'@id'};
// for each IRI that isn't 'expandedProperty', remove a single duplicate
// from element, if found
foreach($iris as $iri) {
if($rval === false) {
break;
}
if($iri === $expanded_property) {
continue;
}
$rval = false;
if(!property_exists($element, $iri)) {
break;
}
$length = count($element->{$iri});
// handle empty array case
if(is_array($value) && count($value) === 0) {
$rval = true;
if($remove) {
unset($element->{$iri});
}
continue;
}
// handle other cases
for($pi = 0; $pi < $length; ++$pi) {
if(self::compareValues($element->{$iri}[$pi], $value)) {
// duplicate found
$rval = true;
if($remove) {
// remove it in place
array_splice($element->{$iri}, $pi, 1);
if(count($element->{$iri}) === 0) {
unset($element->{$iri});
}
}
break;
}
}
}
return $rval;
}
/**
* Creates a term definition during context processing.
*
* @param stdClass $active_ctx the current active context.
* @param stdClass $local_ctx the local context being processed.
* @param string $term the key in the local context to define the mapping for.
* @param stdClass $defined a map of defining/defined keys to detect cycles
* and prevent double definitions.
*/
protected function _createTermDefinition(
$active_ctx, $local_ctx, $term, $defined) {
if(property_exists($defined, $term)) {
// term already defined
if($defined->{$term}) {
return;
}
// cycle detected
throw new JsonLdException(
'Cyclical context definition detected.',
'jsonld.CyclicalContext',
(object)array('context' => $local_ctx, 'term' => $term));
}
// now defining term
$defined->{$term} = false;
if(self::_isKeyword($term)) {
throw new JsonLdException(
'Invalid JSON-LD syntax; keywords cannot be overridden.',
'jsonld.SyntaxError', array('context' => $local_ctx));
}
if(property_exists($active_ctx->mappings, $term) &&
$active_ctx->mappings->{$term} !== null) {
// if term is a keyword alias, remove it
$kw = $active_ctx->mappings->{$term}->{'@id'};
if(self::_isKeyword($kw)) {
array_splice($active_ctx->keywords->{$kw},
array_search($term, $active_ctx->keywords->{$kw}), 1);
}
}
// get context term value
$value = $local_ctx->{$term};
// clear context entry
if($value === null || (is_object($value) &&
self::_hasKeyValue($value, '@id', null))) {
$active_ctx->mappings->{$term} = null;
$defined->{$term} = true;
return;
}
if(is_string($value)) {
// expand value to a full IRI
$id = $this->_expandIri(
$active_ctx, $value, array('vocab' => true, 'base' => true),
$local_ctx, $defined);
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 term as a keyword alias and resort
if(!in_array($term, $active_ctx->keywords->{$value})) {
$active_ctx->keywords->{$value}[] = $term;
usort($active_ctx->keywords->{$value},
array($this, '_compareShortestLeast'));
}
}
// define/redefine term to expanded IRI/keyword
$active_ctx->mappings->{$term} = (object)array(
'@id' => $id, 'propertyGenerator' => false);
$defined->{$term} = 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' => $local_ctx));
}
// create new mapping
$mapping = new stdClass();
$mapping->propertyGenerator = false;
if(property_exists($value, '@id')) {
$id = $value->{'@id'};
// handle property generator
if(is_array($id)) {
if($active_ctx->namer === null) {
throw new JsonLdException(
'Incompatible JSON-LD options; a property generator was found ' .
'in the @context, but blank node renaming has been disabled; ' .
'it must be enabled to use property generators.',
'jsonld.OptionsError', array('context' => $local_ctx));
}
$property_generator = array();
$ids = $id;
foreach($ids as $id) {
// expand @id
if(is_string($id)) {
$id = $this->_expandIri(
$active_ctx, $id, array('vocab' => true, 'base' => true),
$local_ctx, $defined);
}
if(!is_string($id) || self::_isKeyword($id)) {
throw new JsonLdException(
'Invalid JSON-LD syntax; property generators must consist of ' .
'an @id array containing only strings and no string can be ' .
'"@type".',
'jsonld.SyntaxError', array('context' => $local_ctx));
}
$property_generator[] = $id;
}
// add sorted property generator as @id in mapping
sort($property_generator);
$mapping->{'@id'} = $property_generator;
$mapping->propertyGenerator = true;
}
else if(!is_string($id)) {
throw new JsonLdException(
'Invalid JSON-LD syntax; @context @id value must be an array ' .
'of strings or a string.',
'jsonld.SyntaxError', array('context' => $local_ctx));
}
else {
// add @id to mapping
$mapping->{'@id'} = $this->_expandIri(
$active_ctx, $id, array('vocab' => true, 'base' => true),
$local_ctx, $defined);
}
}
else {
// see if the term has a prefix
$colon = strpos($term, ':');
if($colon !== false) {
$prefix = substr($term, 0, $colon);
if(property_exists($local_ctx, $prefix)) {
// define parent prefix
$this->_createTermDefinition(
$active_ctx, $local_ctx, $prefix, $defined);
}
// set @id based on prefix parent
if(property_exists($active_ctx->mappings, $prefix) &&
$active_ctx->mappings->{$prefix}) {
$suffix = substr($term, $colon + 1);
$mapping->{'@id'} = $active_ctx->mappings->{$prefix}->{'@id'} .
$suffix;
}
// term is an absolute IRI
else {
$mapping->{'@id'} = $term;
}
}
else {
// non-IRIs *must* define @ids if @vocab is not available
if(!property_exists($active_ctx, '@vocab')) {
throw new JsonLdException(
'Invalid JSON-LD syntax; @context terms must define an @id.',
'jsonld.SyntaxError',
array('context' => $local_ctx, 'term' => $term));
}
// prepend vocab to term
$mapping->{'@id'} = $active_ctx->{'@vocab'} . $term;
}
}
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' => $local_ctx));
}
if($type !== '@id') {
// expand @type to full IRI
$type = $this->_expandIri(
$active_ctx, $type, array('vocab' => true, 'base' => true),
$local_ctx, $defined);
}
// add @type to mapping
$mapping->{'@type'} = $type;
}
if(property_exists($value, '@container')) {
$container = $value->{'@container'};
if($container !== '@list' && $container !== '@set' &&
$container !== '@index' && $container !== '@language') {
throw new JsonLdException(
'Invalid JSON-LD syntax; @context @container value must be ' .
'one of the following: @list, @set, @index, or @language.',
'jsonld.SyntaxError', array('context' => $local_ctx));
}
// add @container to mapping
$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' => $local_ctx));
}
// add @language to mapping
if($language !== null) {
$language = strtolower($language);
}
$mapping->{'@language'} = $language;
}
// define term mapping
$active_ctx->mappings->{$term} = $mapping;
$defined->{$term} = true;
}
/**
* Expands a string to a full IRI. The string may be a term, a prefix, a
* relative IRI, or an absolute IRI. The associated absolute IRI will be
* returned.
*
* @param stdClass $active_ctx the current active context.
* @param string $value the string to expand.
* @param assoc $relative_to options for how to resolve relative IRIs:
* base: true to resolve against the base IRI, false not to.
* vocab: true to concatenate after @vocab, false not to.
* @param stdClass $local_ctx the local context being processed (only given
* if called during document processing).
* @param defined a map for tracking cycles in context definitions (only given
* if called during document processing).
*
* @return mixed the expanded value.
*/
function _expandIri(
$active_ctx, $value, $relative_to=array(), $local_ctx=null, $defined=null) {
// nothing to expand
if($value === null) {
return null;
}
// define term dependency if not defined
if($local_ctx !== null && property_exists($local_ctx, $value) &&
!self::_hasKeyValue($defined, $value, true)) {
$this->_createTermDefinition($active_ctx, $local_ctx, $value, $defined);
}
$rval = null;
if(isset($relative_to['vocab']) && $relative_to['vocab']) {
if(property_exists($active_ctx->mappings, $value)) {
// term dependency cannot be a property generator
$mapping = $active_ctx->mappings->{$value};
if($local_ctx !== null && $mapping && $mapping->propertyGenerator) {
throw new JsonLdException(
'Invalid JSON-LD syntax; a term definition cannot have a property ' .
'generator as a dependency.',
'jsonld.SyntaxError',
array('context' => $local_ctx, 'value' => $value));
}
// value is explicitly ignored with a null mapping
if($mapping === null) {
return null;
}
// value is a term
if(!$mapping->propertyGenerator) {
$rval = $mapping->{'@id'};
}
}
}
if($rval === null) {
// split value into prefix:suffix
$colon = strpos($value, ':');
if($colon !== false) {
$prefix = substr($value, 0, $colon);
$suffix = substr($value, $colon + 1);
// do not expand blank nodes (prefix of '_') or already-absolute
// IRIs (suffix of '//')
if($prefix !== '_' && strpos($suffix, '//') !== 0) {
// prefix dependency not defined, define it
if($local_ctx !== null && property_exists($local_ctx, $prefix)) {
$this->_createTermDefinition(
$active_ctx, $local_ctx, $prefix, $defined);
}
// use mapping if prefix is defined and not a property generator
if(property_exists($active_ctx->mappings, $prefix)) {
$mapping = $active_ctx->mappings->{$prefix};
if($mapping && !$mapping->propertyGenerator) {
$rval = $active_ctx->mappings->{$prefix}->{'@id'} . $suffix;
}
}
}
}
}
if($rval === null) {
$rval = $value;
}
// keywords need no expanding (aliasing already handled by now)
if(self::_isKeyword($rval)) {
return $rval;
}
if(self::_isAbsoluteIri($rval)) {
// rename blank node if requested
if(!$local_ctx && strpos($rval, '_:') === 0 &&
$active_ctx->namer !== null) {
$rval = $active_ctx->namer->getName($rval);
}
}
// prepend vocab
else if(isset($relative_to['vocab']) && $relative_to['vocab'] &&
property_exists($active_ctx, '@vocab')) {
$rval = $active_ctx->{'@vocab'} . $rval;
}
// prepend base
else if(isset($relative_to['base']) && $relative_to['base']) {
$rval = jsonld_prepend_base($active_ctx->{'@base'}, $rval);
}
if($local_ctx) {
// value must now be an absolute IRI
if(!self::_isAbsoluteIri($rval)) {
throw new JsonLdException(
'Invalid JSON-LD syntax; a @context value does not expand to ' .
'an absolute IRI.',
'jsonld.SyntaxError',
array('context' => $local_ctx, 'value' => $value));
}
}
return $rval;
}
/**
* Finds all @context URLs in the given JSON-LD input.
*
* @param mixed $input the JSON-LD input.
* @param stdClass $urls a map of URLs (url => false/@contexts).
* @param bool $replace true to replace the URLs in the given input with
* the @contexts from the urls map, false not to.
* @param string $base the base URL to resolve relative URLs with.
*/
protected function _findContextUrls($input, $urls, $replace, $base) {
if(is_array($input)) {
foreach($input as $e) {
$this->_findContextUrls($e, $urls, $replace, $base);
}
}
else if(is_object($input)) {
foreach($input as $k => &$v) {
if($k !== '@context') {
$this->_findContextUrls($v, $urls, $replace, $base);
continue;
}
// array @context
if(is_array($v)) {
$length = count($v);
for($i = 0; $i < $length; ++$i) {
if(is_string($v[$i])) {
$url = jsonld_prepend_base($base, $v[$i]);
// replace w/@context if requested
if($replace) {
$ctx = $urls->{$url};
if(is_array($ctx)) {
// add flattened context
array_splice($v, $i, 1, $ctx);
$i += count($ctx);
$length += count($ctx);
}
else {
$v[$i] = $ctx;
}
}
// @context URL found
else if(!property_exists($urls, $url)) {
$urls->{$url} = false;
}
}
}
}
// string @context
else if(is_string($v)) {
$v = jsonld_prepend_base($base, $v);
// replace w/@context if requested
if($replace) {
$input->{$k} = $urls->{$v};
}
// @context URL found
else if(!property_exists($urls, $v)) {
$urls->{$v} = false;
}
}
}
}
}
/**
* Retrieves external @context URLs using the given URL client. 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 input with possible contexts.
* @param stdClass $cycles an object for tracking context cycles.
* @param callable $url_client(url) the URL client.
* @param base $base the base URL to resolve relative URLs against.
*
* @return mixed the result.
*/
protected function _retrieveContextUrls(
&$input, $cycles, $url_client, $base='') {
if(count(get_object_vars($cycles)) > self::MAX_CONTEXT_URLS) {
throw new JsonLdException(
'Maximum number of @context URLs exceeded.',
'jsonld.ContextUrlError', array('max' => self::MAX_CONTEXT_URLS));
}
// for tracking the URLs to retrieve
$urls = new stdClass();
// find all URLs in the given input
$this->_findContextUrls($input, $urls, false, $base);
// queue all unretrieved URLs
$queue = array();
foreach($urls as $url => $ctx) {
if($ctx === false) {
$queue[] = $url;
}
}
// retrieve URLs in queue
foreach($queue as $url) {
// check for context URL cycle
if(property_exists($cycles, $url)) {
throw new JsonLdException(
'Cyclical @context URLs detected.',
'jsonld.ContextUrlError', array('url' => $url));
}
$_cycles = self::copy($cycles);
$_cycles->{$url} = true;
// retrieve URL
$ctx = $url_client($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(
'Derefencing a URL did not result in a valid JSON-LD object.',
'jsonld.InvalidUrl', array('url' => $url));
}
// use empty context if no @context key is present
if(!property_exists($ctx, '@context')) {
$ctx = (object)array('@context' => new stdClass());
}
// recurse
$this->_retrieveContextUrls($ctx, $_cycles, $url_client, $url);
$urls->{$url} = $ctx->{'@context'};
}
// replace all URLS in the input
$this->_findContextUrls($input, $urls, true, $base);
}
/**
* Gets the initial context.
*
* @param assoc $options the options to use.
* base the document base IRI.
*
* @return stdClass the initial context.
*/
protected function _getInitialContext($options) {
$namer = null;
if(isset($options['renameBlankNodes']) && $options['renameBlankNodes']) {
$namer = new UniqueNamer('_:b');
}
return (object)array(
'@base' => jsonld_parse_url($options['base']),
'mappings' => new stdClass(),
'keywords' => (object)array(
'@context' => array(),
'@container' => array(),
'@default' => array(),
'@embed' => array(),
'@explicit' => array(),
'@graph' => array(),
'@id' => array(),
'@index' => array(),
'@language' => array(),
'@list' => array(),
'@omitDefault' => array(),
'@preserve' => array(),
'@set' => array(),
'@type' => array(),
'@value' => array(),
'@vocab' => array()),
'namer' => $namer,
'inverse' => null);
}
/**
* Generates an inverse context for use in the compaction algorithm, if
* not already generated for the given active context.
*
* @param stdClass $active_ctx the active context to use.
*
* @return stdClass the inverse context.
*/
protected function _getInverseContext($active_ctx) {
$inverse = $active_ctx->inverse = new stdClass();
// handle default language
$default_language = '@none';
if(property_exists($active_ctx, '@language')) {
$default_language = $active_ctx->{'@language'};
}
// create term selections for each mapping in the context, ordered by
// shortest and then lexicographically least
$mappings = $active_ctx->mappings;
$terms = array_keys((array)$mappings);
usort($terms, array($this, '_compareShortestLeast'));
foreach($terms as $term) {
$mapping = $mappings->{$term};
if($mapping === null) {
continue;
}
// add term selection where it applies
if(property_exists($mapping, '@container')) {
$container = $mapping->{'@container'};
}
else {
$container = '@none';
}
// iterate over every IRI in the mapping
$iris = $mapping->{'@id'};
$iris = self::arrayify($iris);
foreach($iris as $iri) {
// initialize container map
if(!property_exists($inverse, $iri)) {
$inverse->{$iri} = new stdClass();
}
$container_map = $inverse->{$iri};
// add new entry
if(!property_exists($container_map, $container)) {
$container_map->{$container} = (object)array(
'@language' => new stdClass(),
'@type' => new stdClass());
$container_map->{$container}->{'@language'}->{$default_language} =
(object)array('term' => null, 'propertyGenerators' => array());
}
$entry = $container_map->{$container};
// consider updating @language entry if @type is not specified
if(!property_exists($mapping, '@type')) {
// if a @language is specified, update its specific entry
if(property_exists($mapping, '@language')) {
$language = $mapping->{'@language'};
if($language === null) {
$language = '@null';
}
$this->_addPreferredTerm(
$mapping, $term, $entry->{'@language'}, $language);
}
// add an entry for the default language and for no @language
else {
$this->_addPreferredTerm(
$mapping, $term, $entry->{'@language'}, $default_language);
$this->_addPreferredTerm(
$mapping, $term, $entry->{'@language'}, '@none');
}
}
// consider updating @type entry if @language is not specified
if(!property_exists($mapping, '@language')) {
if(property_exists($mapping, '@type')) {
$type = $mapping->{'@type'};
}
else {
$type = '@none';
}
$this->_addPreferredTerm($mapping, $term, $entry->{'@type'}, $type);
}
}
}
return $inverse;
}
/**
* Adds or updates the term or property generator for the given entry.
*
* @param stdClass $mapping the term mapping.
* @param string $term the term to add.
* @param stdClass $entry the inverse context type_or_language entry to
* add to.
* @param string $type_or_language_value the key in the entry to add to.
*/
function _addPreferredTerm($mapping, $term, $entry, $type_or_language_value) {
if(!property_exists($entry, $type_or_language_value)) {
$entry->{$type_or_language_value} = (object)array(
'term' => null, 'propertyGenerators' => array());
}
$e = $entry->{$type_or_language_value};
if($mapping->propertyGenerator) {
$e->propertyGenerators[] = $term;
}
else if($e->term === null) {
$e->term = $term;
}
}
/**
* Clones an active context, creating a child active context.
*
* @return stdClass a clone (child) of the active context.
*/
protected function _cloneActiveContext($active_ctx) {
$child = new stdClass();
$child->{'@base'} = $active_ctx->{'@base'};
$child->keywords = self::copy($active_ctx->keywords);
$child->mappings = self::copy($active_ctx->mappings);
$child->namer = $active_ctx->namer;
$child->inverse = null;
return $child;
}
/**
* Returns a copy of this active context that can be shared between
* different processing algorithms. This method only copies the parts
* of the active context that can't be shared.
*
* @param stdClass $active_ctx the active context to use.
*
* @return stdClass a shareable copy of the active context.
*/
public function _shareActiveContext($active_ctx) {
$rval = new stdClass();
$rval->{'@base'} = $active_ctx->{'@base'};
$rval->keywords = $active_ctx->keywords;
$rval->mappings = $active_ctx->mappings;
if($active_ctx->namer !== null) {
$rval->namer = new UniqueNamer('_:b');
}
$rval->inverse = $active_ctx->inverse;
return $rval;
}
/**
* Compares two RDF statements for equality.
*
* @param stdClass $s1 the first statement.
* @param stdClass $s2 the second statement.
*
* @return true if the statements are the same, false if not.
*/
protected static function _compareRdfStatements($s1, $s2) {
if(is_string($s1) || is_string($s2)) {
return $s1 === $s2;
}
$attrs = array('subject', 'property', 'object');
foreach($attrs as $attr) {
if($s1->{$attr}->interfaceName !== $s2->{$attr}->interfaceName ||
$s1->{$attr}->nominalValue !== $s2->{$attr}->nominalValue) {
return false;
}
}
if(property_exists($s1->object, 'language') !==
property_exists($s1->object, 'language')) {
return false;
}
if(property_exists($s1->object, 'language')) {
if($s1->object->language !== $s2->object->language) {
return false;
}
}
if(property_exists($s1->object, 'datatype') !==
property_exists($s1->object, 'datatype')) {
return false;
}
if(property_exists($s1->object, 'datatype')) {
if($s1->object->datatype->interfaceName !==
$s2->object->datatype->interfaceName ||
$s1->object->datatype->nominalValue !==
$s2->object->datatype->nominalValue) {
return false;
}
}
if(property_exists($s1, 'name') !== property_exists($s1, 'name')) {
return false;
}
if(property_exists($s1, 'name')) {
if($s1->name !== $s2->name) {
return false;
}
}
return true;
}
/**
* Appends an RDF statement to the given array of statements if it is unique.
*
* @param array $statements the array to add to.
* @param stdClass $statement the statement to add.
*/
protected static function _appendUniqueRdfStatement(
&$statements, $statement) {
foreach($statements as $s) {
if(self::_compareRdfStatements($s, $statement)) {
return;
}
}
$statements[] = $statement;
}
/**
* Returns whether or not the given value is a keyword.
*
* @param string $v the value to check.
*
* @return bool true if the value is a keyword, false if not.
*/
protected static function _isKeyword($v) {
if(!is_string($v)) {
return false;
}
switch($v) {
case '@context':
case '@container':
case '@default':
case '@embed':
case '@explicit':
case '@graph':
case '@id':
case '@index':
case '@language':
case '@list':
case '@omitDefault':
case '@preserve':
case '@set':
case '@type':
case '@value':
case '@vocab':
return true;
}
return false;
}
/**
* Returns true if the given value is an empty Object.
*
* @param mixed $v the value to check.
*
* @return bool true if the value is an empty Object, false if not.
*/
protected static function _isEmptyObject($v) {
return is_object($v) && count(get_object_vars($v)) === 0;
}
/**
* Throws an exception if the given value is not a valid @type value.
*
* @param mixed $v the value to check.
*/
protected static function _validateTypeValue($v) {
// must be a string or empty object
if(is_string($v) || self::_isEmptyObject($v)) {
return;
}
// must be an array
$is_valid = false;
if(is_array($v)) {
// must contain only strings
$is_valid = true;
foreach($v as $e) {
if(!(is_string($e))) {
$is_valid = false;
break;
}
}
}
if(!$is_valid) {
throw new JsonLdException(
'Invalid JSON-LD syntax; "@type" value must a string, an array ' .
'of strings, or an empty object.',
'jsonld.SyntaxError', array('value' => $v));
}
}
/**
* Returns true if the given value is a subject with properties.
*
* @param mixed $v the value to check.
*
* @return bool true if the value is a subject with properties, false if not.
*/
protected static function _isSubject($v) {
// 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.
$rval = false;
if(is_object($v) &&
!property_exists($v, '@value') &&
!property_exists($v, '@set') &&
!property_exists($v, '@list')) {
$count = count(get_object_vars($v));
$rval = ($count > 1 || !property_exists($v, '@id'));
}
return $rval;
}
/**
* Returns true if the given value is a subject reference.
*
* @param mixed $v the value to check.
*
* @return bool true if the value is a subject reference, false if not.
*/
protected static function _isSubjectReference($v) {
// 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($v) && count(get_object_vars($v)) === 1 &&
property_exists($v, '@id'));
}
/**
* Returns true if the given value is a @value.
*
* @param mixed $v the value to check.
*
* @return bool true if the value is a @value, false if not.
*/
protected static function _isValue($v) {
// 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($v) && property_exists($v, '@value');
}
/**
* Returns true if the given value is a @list.
*
* @param mixed $v the value to check.
*
* @return bool true if the value is a @list, false if not.
*/
protected static function _isList($v) {
// 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($v) && property_exists($v, '@list');
}
/**
* Returns true if the given value is a blank node.
*
* @param mixed $v the value to check.
*
* @return bool true if the value is a blank node, false if not.
*/
protected static function _isBlankNode($v) {
// 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.
$rval = false;
if(is_object($v)) {
if(property_exists($v, '@id')) {
$rval = (strpos($v->{'@id'}, '_:') === 0);
}
else {
$rval = (count(get_object_vars($v)) === 0 ||
!(property_exists($v, '@value') ||
property_exists($v, '@set') ||
property_exists($v, '@list')));
}
}
return $rval;
}
/**
* Returns true if the given value is an absolute IRI, false if not.
*
* @param string $v the value to check.
*
* @return bool true if the value is an absolute IRI, false if not.
*/
protected static function _isAbsoluteIri($v) {
return strpos($v, ':') !== false;
}
/**
* Returns true if the given target has the given key and its
* value equals is the given value.
*
* @param stdClass $target the target object.
* @param string key the key to check.
* @param mixed $value the value to check.
*
* @return bool true if the target has the given key and its value matches.
*/
protected static function _hasKeyValue($target, $key, $value) {
return (property_exists($target, $key) && $target->{$key} === $value);
}
/**
* Returns true if both of the given objects have the same value for the
* given key or if neither of the objects contain the given key.
*
* @param stdClass $o1 the first object.
* @param stdClass $o2 the second object.
* @param string key the key to check.
*
* @return bool true if both objects have the same value for the key or
* neither has the key.
*/
protected static function _compareKeyValues($o1, $o2, $key) {
if(property_exists($o1, $key)) {
return property_exists($o2, $key) && $o1->{$key} === $o2->{$key};
}
return !property_exists($o2, $key);
}
}
// register the N-Quads RDF parser
jsonld_register_rdf_parser(
'application/nquads', 'JsonLdProcessor::parseNQuads');
/**
* 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;
}
$rval .= $this->getTraceAsString() . "\n";
return $rval;
}
};
/**
* A UniqueNamer issues unique names, keeping track of any previously issued
* names.
*/
class UniqueNamer {
/**
* Constructs a new UniqueNamer.
*
* @param prefix the prefix to use ('<prefix><counter>').
*/
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->{$old_name};
}
// get next name
$name = $this->prefix . $this->counter;
$this->counter += 1;
// save mapping
if($old_name !== null) {
$this->existing->{$old_name} = $name;
$this->order[] = $old_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 old name 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 {
/**
* Constructs a new Permutator.
*
* @param array $list the array of elements to iterate over.
*/
public function __construct($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;
}
}
/**
* An ActiveContextCache caches active contexts so they can be reused without
* the overhead of recomputing them.
*/
class ActiveContextCache {
/**
* Constructs a new ActiveContextCache.
*
* @param int size the maximum size of the cache, defaults to 100.
*/
public function __construct($size=100) {
$this->order = array();
$this->cache = new stdClass();
$this->size = $size;
}
/**
* Gets an active context from the cache based on the current active
* context and the new local context.
*
* @param stdClass $active_ctx the current active context.
* @param stdClass $local_ctx the new local context.
*
* @return mixed a shared copy of the cached active context or null.
*/
public function get($active_ctx, $local_ctx) {
$key1 = serialize($active_ctx);
$key2 = serialize($local_ctx);
if(property_exists($this->cache, $key1)) {
$level1 = $this->cache->{$key1};
if(property_exists($level1, $key2)) {
// get shareable copy of cached active context
return JsonLdProcessor::_shareActiveContext($level1->{$key2});
}
}
return null;
}
/**
* Sets an active context in the cache based on the previous active
* context and the just-processed local context.
*
* @param stdClass $active_ctx the previous active context.
* @param stdClass $local_ctx the just-processed local context.
* @param stdClass $result the resulting active context.
*/
public function set($active_ctx, $local_ctx, $result) {
if(count($this->order) === $this->size) {
$entry = array_shift($this->order);
unset($this->cache->{$entry->activeCtx}->{$entry->localCtx});
}
$key1 = serialize($active_ctx);
$key2 = serialize($local_ctx);
$this->order[] = (object)array(
'activeCtx' => $key1, 'localCtx' => $key2);
if(!property_exists($this->cache, $key1)) {
$this->cache->{$key1} = new stdClass();
}
$this->cache->{$key1}->{$key2} = $result;
}
}
/* end of file, omit ?> */