You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

5593 lines
174 KiB

<?php
/**
* PHP implementation of the JSON-LD API.
* Version: 0.1.0
*
* @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.
* [graph] true to always output a top-level graph (default: false).
* [documentLoader(url)] the document loader.
*
* @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.
* [documentLoader(url)] the document loader.
*
* @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.
* [documentLoader(url)] the document loader.
*
* @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).
* [documentLoader(url)] the document loader.
*
* @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 dataset normalization on the given JSON-LD input. The output
* is an RDF dataset unless the 'format' option is used.
*
* @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.
* [documentLoader(url)] the document loader.
*
* @return mixed the normalized output.
*/
function jsonld_normalize($input, $options=array()) {
$p = new JsonLdProcessor();
return $p->normalize($input, $options);
}
/**
* Converts an RDF dataset to JSON-LD.
*
* @param mixed $input a serialized string of RDF in a format specified
* by the format option or an RDF dataset 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: false).
*
* @return array the JSON-LD output.
*/
function jsonld_from_rdf($input, $options=array()) {
$p = new JsonLdProcessor();
return $p->fromRDF($input, $options);
}
/**
* Outputs the RDF dataset 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.
* [produceGeneralizedRdf] true to output generalized RDF, false
* to produce only standard RDF (default: false).
* [documentLoader(url)] the document loader.
*
* @return mixed the resulting RDF dataset (or a serialization of it).
*/
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();
/** Stores the default JSON-LD document loader. */
global $jsonld_default_load_document;
$jsonld_default_load_document = null;
/**
* Sets the default JSON-LD document loader.
*
* @param callable load_document(url) the document loader.
*/
function jsonld_set_document_loader($load_document) {
global $jsonld_default_load_document;
$jsonld_default_load_document = $load_document;
}
/**
* Retrieves JSON-LD at the given URL.
*
* @param string $url the URL to retrieve.
*
* @return the JSON-LD.
*/
function jsonld_get_url($url) {
global $jsonld_default_load_document;
if($jsonld_default_load_document !== null) {
$document_loader = $jsonld_default_load_document;
}
else {
$document_loader = $jsonld_default_document_loader;
}
$remote_doc = $document_loader($url);
if($remote_doc) {
return $remote_doc->document;
}
return null;
}
/**
* The default implementation to retrieve JSON-LD at the given URL.
*
* @param string $url the URL to to retrieve.
*
* @return stdClass the RemoteDocument object.
*/
function jsonld_default_document_loader($url) {
$redirects = array();
$opts = array(
'http' => array(
'method' => 'GET',
'header' =>
"Accept: application/ld+json\r\n" .
"User-Agent: JSON-LD PHP Client/1.0\r\n"),
'https' => array(
'verify_peer' => true,
'method' => 'GET',
'header' =>
"Accept: application/ld+json\r\n" .
"User-Agent: JSON-LD PHP Client/1.0\r\n"));
$stream = stream_context_create($opts);
stream_context_set_params($stream, array('notification' =>
function($notification_code, $severity, $message) use (&$redirects) {
switch($notification_code) {
case STREAM_NOTIFY_REDIRECTED:
$redirects[] = $message;
break;
};
}));
$result = @file_get_contents($url, false, $stream);
if($result === false) {
throw new Exception("Could not GET url: '$url'");
}
$redirs = count($redirects);
if($redirs > 0) {
$url = $redirects[$redirs - 1];
}
// return RemoteDocument
return (object)array(
'contextUrl' => null,
'document' => $result,
'documentUrl' => $url);
}
/**
* The default implementation to retrieve JSON-LD at the given secure URL.
*
* @param string $url the secure URL to to retrieve.
*
* @return stdClass the RemoteDocument object.
*/
function jsonld_default_secure_document_loader($url) {
if(strpos($url, 'https') !== 0) {
throw new Exception("Could not GET url: '$url'; 'https' is required.");
}
$redirects = array();
// default JSON-LD https GET implementation
$opts = array(
'https' => array(
'verify_peer' => true,
'method' => "GET",
'header' =>
"Accept: application/ld+json\r\n" .
"User-Agent: JSON-LD PHP Client/1.0\r\n"));
$stream = stream_context_create($opts);
stream_context_set_params($stream, array('notification' =>
function($notification_code, $severity, $message) use (&$redirects) {
switch($notification_code) {
case STREAM_NOTIFY_REDIRECTED:
$redirects[] = $message;
break;
};
}));
$result = @file_get_contents($url, false, $stream);
if($result === false) {
throw new Exception("Could not GET url: '$url'");
}
foreach($redirects as $redirect) {
if(strpos($redirect, 'https') !== 0) {
throw new Exception(
"Could not GET redirected url: '$redirect'; 'https' is required.");
}
$url = $redirect;
}
// return RemoteDocument
return (object)array(
'contextUrl' => null,
'document' => $result,
'documentUrl' => $url);
}
/** Registered global RDF dataset parsers hashed by content-type. */
global $jsonld_rdf_parsers;
$jsonld_rdf_parsers = new stdClass();
/**
* Registers a global RDF dataset 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 RDF dataset).
*/
function jsonld_register_rdf_parser($content_type, $parser) {
global $jsonld_rdf_parsers;
$jsonld_rdf_parsers->{$content_type} = $parser;
}
/**
* Unregisters a global RDF dataset 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) {
if($url === null) {
$url = '';
}
$rval = parse_url($url);
// malformed url
if($rval === false) {
$rval = array();
}
$rval['href'] = $url;
if(!isset($rval['scheme'])) {
$rval['scheme'] = '';
$rval['protocol'] = '';
}
else {
$rval['protocol'] = $rval['scheme'] . ':';
}
if(!isset($rval['host'])) {
$rval['host'] = '';
}
if(!isset($rval['path'])) {
$rval['path'] = '';
}
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']}";
}
}
// parse authority for unparsed relative network-path reference
if(strpos($rval['href'], ':') === false &&
strpos($rval['href'], '//') === 0 && $rval['host'] === '') {
// must parse authority from pathname
$rval['path'] = substr($rval['path'], 2);
$idx = strpos($rval['path'], '/');
if($idx === false) {
$rval['authority'] = $rval['path'];
$rval['path'] = '';
}
else {
$rval['authority'] = substr($rval['path'], 0, $idx);
$rval['path'] = substr($rval['path'], $idx);
}
}
else {
$rval['authority'] = $rval['host'];
if(isset($rval['port'])) {
$rval['authority'] .= ":{$rval['port']}";
}
if(isset($rval['auth'])) {
$rval['authority'] = "{$rval['auth']}@{$rval['authority']}";
}
}
$rval['normalizedPath'] = jsonld_remove_dot_segments(
$rval['path'], $rval['authority'] !== '');
return $rval;
}
/**
* Removes dot segments from a URL path.
*
* @param string $path the path to remove dot segments from.
* @param bool $has_authority true if the URL has an authority, false if not.
*/
function jsonld_remove_dot_segments($path, $has_authority) {
$rval = '';
if(strpos($path, '/') === 0) {
$rval = '/';
}
// RFC 3986 5.2.4 (reworked)
$input = explode('/', $path);
$output = array();
while(count($input) > 0) {
if($input[0] === '.' || ($input[0] === '' && count($input) > 1)) {
array_shift($input);
continue;
}
if($input[0] === '..') {
array_shift($input);
if($has_authority ||
(count($output) > 0 && $output[count($output) - 1] !== '..')) {
array_pop($output);
}
// leading relative URL '..'
else {
$output[] = '..';
}
continue;
}
$output[] = array_shift($input);
}
return $rval . implode('/', $output);
}
/**
* 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;
}
// parse base if it is a string
if(is_string($base)) {
$base = jsonld_parse_url($base);
}
// parse given IRI
$rel = jsonld_parse_url($iri);
// start hierarchical part
$hierPart = $base['protocol'];
if($rel['authority']) {
$hierPart .= "//{$rel['authority']}";
}
else if($base['href'] !== '') {
$hierPart .= "//{$base['authority']}";
}
// per RFC3986 normalize
// IRI represents an absolute path
if(strpos($rel['path'], '/') === 0) {
$path = $rel['path'];
}
else {
$path = $base['path'];
// append relative path to the end of the last directory from base
if($rel['path'] !== '') {
$idx = strrpos($path, '/');
$idx = ($idx === false) ? 0 : $idx + 1;
$path = substr($path, 0, $idx);
if(strlen($path) > 0 && substr($path, -1) !== '/') {
$path .= '/';
}
$path .= $rel['path'];
}
}
// remove slashes and dots in path
$path = jsonld_remove_dot_segments($path, $hierPart !== '');
// add query and hash
if(isset($rel['query'])) {
$path .= "?{$rel['query']}";
}
if(isset($rel['fragment'])) {
$path .= "#{$rel['fragment']}";
}
$rval = $hierPart . $path;
if($rval === '') {
$rval = './';
}
return $rval;
}
/**
* 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 = '';
if($base['href'] !== '') {
$root .= "{$base['protocol']}//{$base['authority']}";
}
// support network-path reference with empty base
else if(strpos($iri, '//') === false) {
$root .= '//';
}
// IRI not relative to base
if($root === '' || strpos($iri, $root) !== 0) {
return $iri;
}
// remove root from IRI
$rel = jsonld_parse_url(substr($iri, strlen($root)));
// remove path segments that match
$base_segments = explode('/', $base['normalizedPath']);
$iri_segments = explode('/', $rel['normalizedPath']);
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) {
// don't count the last segment if it isn't a path (doesn't end in '/')
// don't count empty first segment, it means base began with '/'
if(substr($base['normalizedPath'], -1) !== '/' ||
$base_segments[0] === '') {
array_pop($base_segments);
}
foreach($base_segments as $segment) {
$rval .= '../';
}
}
// prepend remaining segments
$rval .= implode('/', $iri_segments);
// add query and hash
if(isset($rel['query'])) {
$rval .= "?{$rel['query']}";
}
if(isset($rel['fragment'])) {
$rval .= "#{$rel['fragment']}";
}
if($rval === '') {
$rval = './';
}
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_LIST = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#List';
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';
const RDF_LANGSTRING =
'http://www.w3.org/1999/02/22-rdf-syntax-ns#langString';
/** Restraints */
const MAX_CONTEXT_URLS = 10;
/** Processor-specific RDF dataset 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.
* [compactArrays] true to compact arrays to single values when
* appropriate, false not to (default: true).
* [graph] true to always output a top-level graph (default: false).
* [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.
* [documentLoader(url)] the document loader.
*
* @return mixed the compacted JSON-LD output.
*/
public function compact($input, $ctx, $options) {
if($ctx === null) {
throw new JsonLdException(
'The compaction context must not be null.',
'jsonld.CompactError');
}
// nothing to compact
if($input === null) {
return null;
}
self::setdefaults($options, array(
'base' => is_string($input) ? $input : '',
'compactArrays' => true,
'graph' => false,
'skipExpansion' => false,
'activeCtx' => false,
'documentLoader' => 'jsonld_default_document_loader'));
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['compactArrays'] &&
!$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 and/or @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) && $has_context) {
// 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.
* [expandContext] a context to expand with.
* [keepFreeFloatingNodes] true to keep free-floating nodes,
* false not to, defaults to false.
* [documentLoader(url)] the document loader.
*
* @return array the expanded JSON-LD output.
*/
public function expand($input, $options) {
self::setdefaults($options, array(
'base' => is_string($input) ? $input : '',
'keepFreeFloatingNodes' => false,
'documentLoader' => 'jsonld_default_document_loader'));
// if input is a string, attempt to dereference remote document
if(is_string($input)) {
$remote_doc = $options['documentLoader']($input);
}
else {
$remote_doc = (object)array(
'contextUrl' => null,
'documentUrl' => null,
'document' => $input);
}
// build meta-object and retrieve all @context urls
$input = (object)array(
'document' => self::copy($input),
'remoteContext' => (object)array(
'@context' => $remote_doc->contextUrl));
if(isset($options['expandContext'])) {
$expand_context = self::copy($options['expandContext']);
if(is_object($expand_context) &&
property_exists($expand_context, '@context')) {
$input->expandContext = $expand_context;
}
else {
$input->expandContext = (object)array('@context' => $expand_context);
}
}
// retrieve all @context URLs in the input
try {
$this->_retrieveContextUrls(
$input, new stdClass(), $options['documentLoader'], $options['base']);
}
catch(Exception $e) {
throw new JsonLdException(
'Could not perform JSON-LD expansion.',
'jsonld.ExpandError', null, $e);
}
$active_ctx = $this->_getInitialContext($options);
$document = $input->document;
$remote_context = $input->remoteContext->{'@context'};
// process optional expandContext
if(property_exists($input, 'expandContext')) {
$active_ctx = self::_processContext(
$active_ctx, $input->expandContext, $options);
}
// process remote context from HTTP Link Header
if($remote_context) {
$active_ctx = self::_processContext(
$active_ctx, $remote_context, $options);
}
// do expansion
$expanded = $this->_expand($active_ctx, null, $document, $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.
* [expandContext] a context to expand with.
* [documentLoader(url)] the document loader.
*
* @return array the flattened output.
*/
public function flatten($input, $ctx, $options) {
self::setdefaults($options, array(
'base' => is_string($input) ? $input : '',
'documentLoader' => 'jsonld_default_document_loader'));
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.
* [expandContext] a context to expand with.
* [embed] default @embed flag (default: true).
* [explicit] default @explicit flag (default: false).
* [omitDefault] default @omitDefault flag (default: false).
* [documentLoader(url)] the document loader.
*
* @return stdClass the framed JSON-LD output.
*/
public function frame($input, $frame, $options) {
self::setdefaults($options, array(
'base' => is_string($input) ? $input : '',
'compactArrays' => true,
'embed' => true,
'explicit' => false,
'omitDefault' => false,
'documentLoader' => 'jsonld_default_document_loader'));
// if frame is a string, attempt to dereference remote document
if(is_string($frame)) {
$remote_frame = $options['documentLoader']($frame);
}
else {
$remote_frame = (object)array(
'contextUrl' => null,
'documentUrl' => null,
'document' => $frame);
}
// preserve frame context
$frame = $remote_frame->document;
if($frame !== null) {
$ctx = (property_exists($frame, '@context') ?
$frame->{'@context'} : new stdClass());
if($remote_frame->contextUrl !== null) {
if($ctx !== null) {
$ctx = $remote_frame->contextUrl;
}
else {
$ctx = self::arrayify($ctx);
$ctx[] = $remote_frame->contextUrl;
}
$frame->{'@context'} = $ctx;
}
}
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}, $options);
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.
* [expandContext] a context to expand with.
* [format] the format if output is a string:
* 'application/nquads' for N-Quads.
* [documentLoader(url)] the document loader.
*
* @return mixed the normalized output.
*/
public function normalize($input, $options) {
self::setdefaults($options, array(
'base' => is_string($input) ? $input : '',
'documentLoader' => 'jsonld_default_document_loader'));
try {
// convert to RDF dataset then do normalization
$opts = self::copy($options);
if(isset($opts['format'])) {
unset($opts['format']);
}
$opts['produceGeneralizedRdf'] = false;
$dataset = $this->toRDF($input, $opts);
}
catch(Exception $e) {
throw new JsonLdException(
'Could not convert input to RDF dataset before normalization.',
'jsonld.NormalizeError', null, $e);
}
// do normalization
return $this->_normalize($dataset, $options);
}
/**
* Converts an RDF dataset to JSON-LD.
*
* @param mixed $dataset a serialized string of RDF in a format specified
* by the format option or an RDF dataset 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: false).
*
* @return array the JSON-LD output.
*/
public function fromRDF($dataset, $options) {
global $jsonld_rdf_parsers;
self::setdefaults($options, array(
'useRdfType' => false,
'useNativeTypes' => false));
if(!isset($options['format']) && is_string($dataset)) {
// set default format to nquads
$options['format'] = 'application/nquads';
}
// handle special format
if(isset($options['format']) && $options['format']) {
// 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']};
}
$dataset = $callable($dataset);
}
// convert from RDF
return $this->_fromRDF($dataset, $options);
}
/**
* Outputs the RDF dataset 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.
* [expandContext] a context to expand with.
* [format] the format to use to output a string:
* 'application/nquads' for N-Quads.
* [produceGeneralizedRdf] true to output generalized RDF, false
* to produce only standard RDF (default: false).
* [documentLoader(url)] the document loader.
*
* @return mixed the resulting RDF dataset (or a serialization of it).
*/
public function toRDF($input, $options) {
self::setdefaults($options, array(
'base' => is_string($input) ? $input : '',
'produceGeneralizedRdf' => false,
'documentLoader' => 'jsonld_default_document_loader'));
try {
// expand input
$expanded = $this->expand($input, $options);
}
catch(JsonLdException $e) {
throw new JsonLdException(
'Could not expand input before serialization to RDF.',
'jsonld.RdfError', $e);
}
// create node map for default graph (and any named graphs)
$namer = new UniqueNamer('_:b');
$node_map = (object)array('@default' => new stdClass());
$this->_createNodeMap($expanded, $node_map, '@default', $namer);
// output RDF dataset
$dataset = new stdClass();
$graph_names = array_keys((array)$node_map);
sort($graph_names);
foreach($graph_names as $graph_name) {
$graph = $node_map->{$graph_name};
$dataset->{$graph_name} = $this->_graphToRDF($graph, $namer, $options);
}
$rval = $dataset;
// convert to output format
if(isset($options['format']) && $options['format']) {
// supported formats
if($options['format'] === 'application/nquads') {
$rval = self::toNQuads($dataset);
}
else {
throw new JsonLdException(
'Unknown output format.',
'jsonld.UnknownFormat', array('format' => $options['format']));
}
}
return $rval;
}
/**
* 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:
* [documentLoader(url)] the document loader.
*
* @return stdClass the new active context.
*/
public function processContext($active_ctx, $local_ctx, $options) {
self::setdefaults($options, array(
'base' => '',
'documentLoader' => 'jsonld_default_document_loader'));
// return initial context early for null context
if($local_ctx === null) {
return $this->_getInitialContext($options);
}
// retrieve URLs in local_ctx
$local_ctx = self::copy($local_ctx);
if(is_string($local_ctx) or (
is_object($local_ctx) && !property_exists($local_ctx, '@context'))) {
$local_ctx = (object)array('@context' => $local_ctx);
}
try {
$this->_retrieveContextUrls(
$local_ctx, new stdClass(),
$options['documentLoader'], $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, $local_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()) {
self::setdefaults($options, array(
'allowDuplicate' => true,
'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()) {
self::setdefaults($options, array(
'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['propertyIsArray']) {
$subject->{$property} = $values[0];
}
else {
$subject->{$property} = $values;
}
}
/**
* Compares two JSON-LD values for equality. Two JSON-LD values will be
* considered equal if:
*
* 1. They are both primitives of the same type and value.
* 2. They are both @values with the same @value, @type, @language,
* and @index, OR
* 3. They both have @ids that 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 @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 RDF in the form of N-Quads.
*
* @param string $input the N-Quads input to parse.
*
* @return stdClass an RDF dataset.
*/
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_name = "(?:\\.|(?:(?:$iri|$bnode)$ws*\\.))";
// full quad regex
$quad = "/^$ws*$subject$property$object$graph_name$ws*$/";
// build RDF dataset
$dataset = new stdClass();
// 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 triple
$triple = (object)array(
'subject' => new stdClass(),
'predicate' => new stdClass(),
'object' => new stdClass());
// get subject
if($match[1] !== '') {
$triple->subject->type = 'IRI';
$triple->subject->value = $match[1];
}
else {
$triple->subject->type = 'blank node';
$triple->subject->value = $match[2];
}
// get predicate
$triple->predicate->type = 'IRI';
$triple->predicate->value = $match[3];
// get object
if($match[4] !== '') {
$triple->object->type = 'IRI';
$triple->object->value = $match[4];
}
else if($match[5] !== '') {
$triple->object->type = 'blank node';
$triple->object->value = $match[5];
}
else {
$triple->object->type = 'literal';
$unescaped = str_replace(
array('\"', '\t', '\n', '\r', '\\\\'),
array('"', "\t", "\n", "\r", '\\'),
$match[6]);
if(isset($match[7]) && $match[7] !== '') {
$triple->object->datatype = $match[7];
}
else if(isset($match[8]) && $match[8] !== '') {
$triple->object->datatype = self::RDF_LANGSTRING;
$triple->object->language = $match[8];
}
else {
$triple->object->datatype = self::XSD_STRING;
}
$triple->object->value = $unescaped;
}
// get graph name ('@default' is used for the default graph)
$name = '@default';
if(isset($match[9]) && $match[9] !== '') {
$name = $match[9];
}
else if(isset($match[10]) && $match[10] !== '') {
$name = $match[10];
}
// initialize graph in dataset
if(!property_exists($dataset, $name)) {
$dataset->{$name} = array($triple);
}
// add triple if unique to its graph
else {
$unique = true;
$triples = &$dataset->{$name};
foreach($triples as $t) {
if(self::_compareRDFTriples($t, $triple)) {
$unique = false;
break;
}
}
if($unique) {
$triples[] = $triple;
}
}
}
return $dataset;
}
/**
* Converts an RDF dataset to N-Quads.
*
* @param stdClass $dataset the RDF dataset to convert.
*
* @return string the N-Quads string.
*/
public static function toNQuads($dataset) {
$quads = array();
foreach($dataset as $graph_name => $triples) {
foreach($triples as $triple) {
if($graph_name === '@default') {
$graph_name = null;
}
$quads[] = self::toNQuad($triple, $graph_name);
}
}
sort($quads);
return implode($quads);
}
/**
* Converts an RDF triple and graph name to an N-Quad string (a single quad).
*
* @param stdClass $triple the RDF triple to convert.
* @param mixed $graph_name the name of the graph containing the triple, null
* for the default graph.
* @param string $bnode the bnode the quad is mapped to (optional, for
* use during normalization only).
*
* @return string the N-Quad string.
*/
public static function toNQuad($triple, $graph_name, $bnode=null) {
$s = $triple->subject;
$p = $triple->predicate;
$o = $triple->object;
$g = $graph_name;
$quad = '';
// subject is an IRI
if($s->type === 'IRI') {
$quad .= "<{$s->value}>";
}
// bnode normalization mode
else if($bnode !== null) {
$quad .= ($s->value === $bnode) ? '_:a' : '_:z';
}
// bnode normal mode
else {
$quad .= $s->value;
}
$quad .= ' ';
// predicate is an IRI
if($p->type === 'IRI') {
$quad .= "<{$p->value}>";
}
// FIXME: TBD what to do with bnode predicates during normalization
// bnode normalization mode
else if($bnode !== null) {
$quad .= '_:p';
}
// bnode normal mode
else {
$quad .= $p->value;
}
$quad .= ' ';
// object is IRI, bnode, or literal
if($o->type === 'IRI') {
$quad .= "<{$o->value}>";
}
else if($o->type === 'blank node') {
// normalization mode
if($bnode !== null) {
$quad .= ($o->value === $bnode) ? '_:a' : '_:z';
}
// normal mode
else {
$quad .= $o->value;
}
}
else {
$escaped = str_replace(
array('\\', "\t", "\n", "\r", '"'),
array('\\\\', '\t', '\n', '\r', '\"'),
$o->value);
$quad .= '"' . $escaped . '"';
if($o->datatype === self::RDF_LANGSTRING) {
if($o->language) {
$quad .= "@{$o->language}";
}
}
else if($o->datatype !== self::XSD_STRING) {
$quad .= "^^<{$o->datatype}>";
}
}
// graph
if($g !== null) {
if(strpos($g, '_:') !== 0) {
$quad .= " <$g>";
}
else if($bnode) {
$quad .= ' _:g';
}
else {
$quad .= " $g";
}
}
$quad .= " .\n";
return $quad;
}
/**
* Registers a processor-specific RDF dataset 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 RDF dataset).
*/
public function registerRDFParser($content_type, $parser) {
if($this->rdfParsers === null) {
$this->rdfParsers = new stdClass();
}
$this->rdfParsers->{$content_type} = $parser;
}
/**
* Unregisters a process-specific RDF dataset 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;
}
}
/**
* Sets the value of a key for the given array if that property
* has not already been set.
*
* @param &assoc $arr the object to update.
* @param string $key the key to update.
* @param mixed $value the value to set.
*/
public static function setdefault(&$arr, $key, $value) {
isset($arr[$key]) or $arr[$key] = $value;
}
/**
* Sets default values for keys in the given array.
*
* @param &assoc $arr the object to update.
* @param assoc $defaults the default keys and values.
*/
public static function setdefaults(&$arr, $defaults) {
foreach($defaults as $key => $value) {
self::setdefault($arr, $key, $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);
if($compacted !== null) {
$rval[] = $compacted;
}
}
if($options['compactArrays'] && 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);
}
// FIXME: avoid misuse of active property as an expanded property?
$inside_reverse = ($active_property === '@reverse');
// process element keys in order
$keys = array_keys((array)$element);
sort($keys);
$rval = new stdClass();
foreach($keys as $expanded_property) {
$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 @reverse
if($expanded_property === '@reverse') {
// recursively compact expanded value
$compacted_value = $this->_compact(
$active_ctx, '@reverse', $expanded_value, $options);
// handle double-reversed properties
foreach($compacted_value as $compacted_property => $value) {
if(property_exists($active_ctx->mappings, $compacted_property) &&
$active_ctx->mappings->{$compacted_property} &&
$active_ctx->mappings->{$compacted_property}->reverse) {
$container = self::getContextValue(
$active_ctx, $compacted_property, '@container');
$use_array = ($container === '@set' ||
!$options['compactArrays']);
self::addValue(
$rval, $compacted_property, $value,
array('propertyIsArray' => $use_array));
unset($compacted_value->{$compacted_property});
}
}
if(count(array_keys((array)$compacted_value)) > 0) {
// use keyword alias and add value
$alias = $this->_compactIri($active_ctx, $expanded_property);
self::addValue($rval, $alias, $compacted_value);
}
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), $inside_reverse);
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), $inside_reverse);
$container = self::getContextValue(
$active_ctx, $item_active_property, '@container');
// 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: compactArrays flag is false,
// @container is @set or @list, value is an empty
// array, or key is @graph
$is_array = (!$options['compactArrays'] ||
$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 to IRI
$expanded_property = $this->_expandIri(
$active_ctx, $key, array('vocab' => true));
// drop non-absolute IRI keys that aren't keywords
if($expanded_property === null ||
!(self::_isAbsoluteIri($expanded_property) ||
self::_isKeyword($expanded_property))) {
continue;
}
if(self::_isKeyword($expanded_property) &&
$expanded_active_property === '@reverse') {
throw new JsonLdException(
'Invalid JSON-LD syntax; a keyword cannot be used as a @reverse ' .
'property.',
'jsonld.SyntaxError', array('value' => $value));
}
// 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);
}
// @index must be a string
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));
}
}
// @reverse must be an object
if($expanded_property === '@reverse') {
if(!is_object($value)) {
throw new JsonLdException(
'Invalid JSON-LD syntax; "@reverse" value must be an object.',
'jsonld.SyntaxError', array('value' => $value));
}
$expanded_value = $this->_expand(
$active_ctx, '@reverse', $value, $options, $inside_list);
// properties double-reversed
if(property_exists($expanded_value, '@reverse')) {
foreach($expanded_value->{'@reverse'} as $rproperty => $rvalue) {
self::addValue(
$rval, $rproperty, $rvalue, array('propertyIsArray' => true));
}
}
// FIXME: can this be merged with code below to simplify?
// merge in all reversed properties
if(property_exists($rval, '@reverse')) {
$reverse_map = $rval->{'@reverse'};
}
else {
$reverse_map = null;
}
foreach($expanded_value as $property => $items) {
if($property === '@reverse') {
continue;
}
if($reverse_map === null) {
$reverse_map = $rval->{'@reverse'} = new stdClass();
}
self::addValue(
$reverse_map, $property, array(),
array('propertyIsArray' => true));
foreach($items as $item) {
if(self::_isValue($item) || self::_isList($item)) {
throw new JsonLdException(
'Invalid JSON-LD syntax; "@reverse" value must not be a ' +
'@value or an @list.',
'jsonld.SyntaxError',
array('value' => $expanded_value));
}
self::addValue(
$reverse_map, $property, $item,
array('propertyIsArray' => true));
}
}
continue;
}
$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));
}
// FIXME: can this be merged with code above to simplify?
// merge in reverse properties
if(property_exists($active_ctx->mappings, $key) &&
$active_ctx->mappings->{$key} &&
$active_ctx->mappings->{$key}->reverse) {
$reverse_map = $rval->{'@reverse'} = new stdClass();
$expanded_value = self::arrayify($expanded_value);
foreach($expanded_value as $item) {
if(self::_isValue($item) || self::_isList($item)) {
throw new JsonLdException(
'Invalid JSON-LD syntax; "@reverse" value must not be a ' +
'@value or an @list.',
'jsonld.SyntaxError', array('value' => $expanded_value));
}
self::addValue(
$reverse_map, $expanded_property, $item,
array('propertyIsArray' => true));
}
continue;
}
// add value for property
// 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) {
$node = $node_map->{$id};
// only add full subjects
if(!self::_isSubjectReference($node)) {
$subject->{'@graph'}[] = $node;
}
}
}
// produce flattened output
$flattened = array();
$keys = array_keys((array)$default_graph);
sort($keys);
foreach($keys as $key) {
$node = $default_graph->{$key};
// only add full subjects to top-level
if(!self::_isSubjectReference($node)) {
$flattened[] = $node;
}
}
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 normalization on the given RDF dataset.
*
* @param stdClass $dataset the RDF dataset to normalize.
* @param assoc $options the normalization options.
*
* @return mixed the normalized output.
*/
protected function _normalize($dataset, $options) {
// create quads and map bnodes to their associated quads
$quads = array();
$bnodes = new stdClass();
foreach($dataset as $graph_name => $triples) {
if($graph_name === '@default') {
$graph_name = null;
}
foreach($triples as $triple) {
$quad = $triple;
if($graph_name !== null) {
if(strpos($graph_name, '_:') === 0) {
$quad->name = (object)array(
'type' => 'blank node', 'value' => $graph_name);
}
else {
$quad->name = (object)array(
'type' => 'IRI', 'value' => $graph_name);
}
}
$quads[] = $quad;
foreach(array('subject', 'object', 'name') as $attr) {
if(property_exists($quad, $attr) &&
$quad->{$attr}->type === 'blank node') {
$id = $quad->{$attr}->value;
if(property_exists($bnodes, $id)) {
$bnodes->{$id}->quads[] = $quad;
}
else {
$bnodes->{$id} = (object)array('quads' => array($quad));
}
}
}
}
}
// mapping complete, start canonical naming
$namer = new UniqueNamer('_:c14n');
// continue to hash bnode quads 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 quads for each unnamed bnode
$hash = $this->_hashQuads($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