Permalink
Find file
38b07bb Apr 25, 2016
@dlongley @davidlehn
6039 lines (5492 sloc) 193 KB
<?php
/**
* PHP implementation of the JSON-LD API.
* Version: 0.4.8-dev
*
* @author Dave Longley
*
* BSD 3-Clause License
* Copyright (c) 2011-2014 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).
* [requireAll] default @requireAll flag (default: true).
* [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);
}
/**
* **Experimental**
*
* Links a JSON-LD document's nodes in memory.
*
* @param mixed $input the JSON-LD document to link.
* @param mixed $ctx the JSON-LD context to apply 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 the linked JSON-LD output.
*/
function jsonld_link($input, $ctx, $options) {
// API matches running frame with a wildcard frame and embed: '@link'
// get arguments
$frame = new stdClass();
if($ctx) {
$frame->{'@context'} = $ctx;
}
$frame->{'@embed'} = '@link';
return jsonld_frame($input, $frame, $options);
};
/**
* Performs RDF dataset normalization on the given input. The input is
* JSON-LD unless the 'inputFormat' option is used. 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.
* [intputFormat] the format if input is not JSON-LD:
* 'application/nquads' for N-Quads.
* [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);
}
/**
* JSON-encodes (with unescaped slashes) the given stdClass or array.
*
* @param mixed $input the native PHP stdClass or array which will be
* converted to JSON by json_encode().
* @param int $options the options to use.
* [JSON_PRETTY_PRINT] pretty print.
* @param int $depth the maximum depth to use.
*
* @return the encoded JSON data.
*/
function jsonld_encode($input, $options=0, $depth=512) {
// newer PHP has a flag to avoid escaped '/'
if(defined('JSON_UNESCAPED_SLASHES')) {
return json_encode($input, JSON_UNESCAPED_SLASHES | $options, $depth);
}
// use a simple string replacement of '\/' to '/'.
return str_replace('\\/', '/', json_encode($input, $options, $depth));
}
/**
* Decodes a serialized JSON-LD object.
*
* @param string $input the JSON-LD input.
*
* @return mixed the resolved JSON-LD object, null on error.
*/
function jsonld_decode($input) {
return json_decode($input);
}
/**
* Parses a link header. The results will be key'd by the value of "rel".
*
* Link: <http://json-ld.org/contexts/person.jsonld>; rel="http://www.w3.org/ns/json-ld#context"; type="application/ld+json"
*
* Parses as: {
* 'http://www.w3.org/ns/json-ld#context': {
* target: http://json-ld.org/contexts/person.jsonld,
* type: 'application/ld+json'
* }
* }
*
* If there is more than one "rel" with the same IRI, then entries in the
* resulting map for that "rel" will be arrays of objects, otherwise they will
* be single objects.
*
* @param string $header the link header to parse.
*
* @return assoc the parsed result.
*/
function jsonld_parse_link_header($header) {
$rval = array();
// split on unbracketed/unquoted commas
if(!preg_match_all(
'/(?:<[^>]*?>|"[^"]*?"|[^,])+/', $header, $entries, PREG_SET_ORDER)) {
return $rval;
}
$r_link_header = '/\s*<([^>]*?)>\s*(?:;\s*(.*))?/';
foreach($entries as $entry) {
if(!preg_match($r_link_header, $entry[0], $match)) {
continue;
}
$result = (object)array('target' => $match[1]);
$params = $match[2];
$r_params = '/(.*?)=(?:(?:"([^"]*?)")|([^"]*?))\s*(?:(?:;\s*)|$)/';
preg_match_all($r_params, $params, $matches, PREG_SET_ORDER);
foreach($matches as $match) {
$result->{$match[1]} = $match[2] ?: $match[3];
}
$rel = property_exists($result, 'rel') ? $result->rel : '';
if(!isset($rval[$rel])) {
$rval[$rel] = $result;
} else if(is_array($rval[$rel])) {
$rval[$rel][] = $result;
} else {
$rval[$rel] = array($rval[$rel], $result);
}
}
return $rval;
}
/**
* 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 = 'jsonld_default_document_loader';
/**
* 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 = call_user_func($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) {
$doc = (object)array(
'contextUrl' => null, 'document' => null, 'documentUrl' => $url);
$redirects = array();
$opts = array(
'http' => array(
'method' => 'GET',
'header' =>
"Accept: application/ld+json\r\n"),
/* Note: Use jsonld_default_secure_document_loader for security. */
'ssl' => array(
'verify_peer' => false,
'allow_self_signed' => true)
);
$context = stream_context_create($opts);
$content_type = null;
stream_context_set_params($context, array('notification' =>
function($notification_code, $severity, $message) use (
&$redirects, &$content_type) {
switch($notification_code) {
case STREAM_NOTIFY_REDIRECTED:
$redirects[] = $message;
break;
case STREAM_NOTIFY_MIME_TYPE_IS:
$content_type = $message;
break;
};
}));
$result = @file_get_contents($url, false, $context);
if($result === false) {
throw new JsonLdException(
'Could not retrieve a JSON-LD document from the URL: ' . $url,
'jsonld.LoadDocumentError', 'loading document failed');
}
$link_header = array();
foreach($http_response_header as $header) {
if(strpos($header, 'link') === 0) {
$value = explode(': ', $header);
if(count($value) > 1) {
$link_header[] = $value[1];
}
}
}
$link_header = jsonld_parse_link_header(join(',', $link_header));
if(isset($link_header['http://www.w3.org/ns/json-ld#context'])) {
$link_header = $link_header['http://www.w3.org/ns/json-ld#context'];
} else {
$link_header = null;
}
if($link_header && $content_type !== 'application/ld+json') {
// only 1 related link header permitted
if(is_array($link_header)) {
throw new JsonLdException(
'URL could not be dereferenced, it has more than one ' .
'associated HTTP Link Header.', 'jsonld.LoadDocumentError',
'multiple context link headers', array('url' => $url));
}
$doc->{'contextUrl'} = $link_header->target;
}
// update document url based on redirects
$redirs = count($redirects);
if($redirs > 0) {
$url = $redirects[$redirs - 1];
}
$doc->document = $result;
$doc->documentUrl = $url;
return $doc;
}
/**
* 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 JsonLdException(
"Could not GET url: '$url'; 'https' is required.",
'jsonld.LoadDocumentError', 'loading document failed');
}
$doc = (object)array(
'contextUrl' => null, 'document' => null, 'documentUrl' => $url);
$redirects = array();
// default JSON-LD https GET implementation
$opts = array(
'http' => array(
'method' => 'GET',
'header' =>
"Accept: application/ld+json\r\n"),
'ssl' => array(
'verify_peer' => true,
'allow_self_signed' => false,
'cafile' => '/etc/ssl/certs/ca-certificates.crt'));
$context = stream_context_create($opts);
$content_type = null;
stream_context_set_params($context, array('notification' =>
function($notification_code, $severity, $message) use (
&$redirects, &$content_type) {
switch($notification_code) {
case STREAM_NOTIFY_REDIRECTED:
$redirects[] = $message;
break;
case STREAM_NOTIFY_MIME_TYPE_IS:
$content_type = $message;
break;
};
}));
$result = @file_get_contents($url, false, $context);
if($result === false) {
throw new JsonLdException(
'Could not retrieve a JSON-LD document from the URL: ' + $url,
'jsonld.LoadDocumentError', 'loading document failed');
}
$link_header = array();
foreach($http_response_header as $header) {
if(strpos($header, 'link') === 0) {
$value = explode(': ', $header);
if(count($value) > 1) {
$link_header[] = $value[1];
}
}
}
$link_header = jsonld_parse_link_header(join(',', $link_header));
if(isset($link_header['http://www.w3.org/ns/json-ld#context'])) {
$link_header = $link_header['http://www.w3.org/ns/json-ld#context'];
} else {
$link_header = null;
}
if($link_header && $content_type !== 'application/ld+json') {
// only 1 related link header permitted
if(is_array($link_header)) {
throw new JsonLdException(
'URL could not be dereferenced, it has more than one ' .
'associated HTTP Link Header.', 'jsonld.LoadDocumentError',
'multiple context link headers', array('url' => $url));
}
$doc->{'contextUrl'} = $link_header->target;
}
// update document url based on redirects
foreach($redirects as $redirect) {
if(strpos($redirect, 'https') !== 0) {
throw new JsonLdException(
"Could not GET redirected url: '$redirect'; 'https' is required.",
'jsonld.LoadDocumentError', 'loading document failed');
}
$url = $redirect;
}
$doc->document = $result;
$doc->documentUrl = $url;
return $doc;
}
/** 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 = '';
}
$keys = array(
'href', 'protocol', 'scheme', '?authority', 'authority',
'?auth', 'auth', 'user', 'pass', 'host', '?port', 'port', 'path',
'?query', 'query', '?fragment', 'fragment');
$regex = "/^(([^:\/?#]+):)?(\/\/(((([^:@]*)(?::([^:@]*))?)?@)?([^:\/?#]*)(:(\d*))?))?([^?#]*)(\?([^#]*))?(#(.*))?/";
preg_match($regex, $url, $match);
$rval = array();
$flags = array();
$len = count($keys);
for($i = 0; $i < $len; ++$i) {
$key = $keys[$i];
if(strpos($key, '?') === 0) {
$flags[substr($key, 1)] = !empty($match[$i]);
} else if(!isset($match[$i])) {
$rval[$key] = null;
} else {
$rval[$key] = $match[$i];
}
}
if(!$flags['authority']) {
$rval['authority'] = null;
}
if(!$flags['auth']) {
$rval['auth'] = $rval['user'] = $rval['pass'] = null;
}
if(!$flags['port']) {
$rval['port'] = null;
}
if(!$flags['query']) {
$rval['query'] = null;
}
if(!$flags['fragment']) {
$rval['fragment'] = null;
}
$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);
} else {
// leading relative URL '..'
$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) {
// skip IRI processing
if($base === null) {
return $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);
// per RFC3986 5.2.2
$transform = array('protocol' => $base['protocol']);
if($rel['authority'] !== null) {
$transform['authority'] = $rel['authority'];
$transform['path'] = $rel['path'];
$transform['query'] = $rel['query'];
} else {
$transform['authority'] = $base['authority'];
if($rel['path'] === '') {
$transform['path'] = $base['path'];
if($rel['query'] !== null) {
$transform['query'] = $rel['query'];
} else {
$transform['query'] = $base['query'];
}
} else {
if(strpos($rel['path'], '/') === 0) {
// IRI represents an absolute path
$transform['path'] = $rel['path'];
} else {
// merge paths
$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'];
}
$transform['path'] = $path;
}
$transform['query'] = $rel['query'];
}
}
// remove slashes and dots in path
$transform['path'] = jsonld_remove_dot_segments(
$transform['path'], !!$transform['authority']);
// construct URL
$rval = $transform['protocol'];
if($transform['authority'] !== null) {
$rval .= '//' . $transform['authority'];
}
$rval .= $transform['path'];
if($transform['query'] !== null) {
$rval .= '?' . $transform['query'];
}
if($rel['fragment'] !== null) {
$rval .= '#' . $rel['fragment'];
}
// handle empty base
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) {
// skip IRI processing
if($base === null) {
return $iri;
}
if(is_string($base)) {
$base = jsonld_parse_url($base);
}
// establish base root
$root = '';
if($base['href'] !== '') {
$root .= "{$base['protocol']}//{$base['authority']}";
} else if(strpos($iri, '//') === false) {
// support network-path reference with empty base
$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 (do not remove last segment unless there
// is a hash or query)
$base_segments = explode('/', $base['normalizedPath']);
$iri_segments = explode('/', $rel['normalizedPath']);
$last = ($rel['query'] || $rel['fragment']) ? 0 : 1;
while(count($base_segments) > 0 && count($iri_segments) > $last) {
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 ends with '/' last path doesn't
// count and if it doesn't end with '/' it isn't a path)
array_pop($base_segments);
foreach($base_segments as $segment) {
$rval .= '../';
}
}
// prepend remaining segments
$rval .= implode('/', $iri_segments);
// add query and hash
if($rel['query'] !== null) {
$rval .= "?{$rel['query']}";
}
if($rel['fragment'] !== null) {
$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) {
global $jsonld_default_load_document;
if($ctx === null) {
throw new JsonLdException(
'The compaction context must not be null.',
'jsonld.CompactError', 'invalid local context');
}
// 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_load_document,
'link' => false));
if($options['link']) {
// force skip expansion when linking, "link" is not part of the
// public API, it should only be called from framing
$options['skipExpansion'] = true;
}
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, 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, null, $e);
}
// do compaction
$compacted = $this->_compact($active_ctx, null, $expanded, $options);
if($options['compactArrays'] &&
!$options['graph'] && is_array($compacted)) {
if(count($compacted) === 1) {
// simplify to a single item
$compacted = $compacted[0];
} else if(count($compacted) === 0) {
// simplify to an empty object
$compacted = new stdClass();
}
} else if($options['graph']) {
// always use array if graph option is on
$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) {
global $jsonld_default_load_document;
self::setdefaults($options, array(
'keepFreeFloatingNodes' => false,
'documentLoader' => $jsonld_default_load_document));
// if input is a string, attempt to dereference remote document
if(is_string($input)) {
$remote_doc = call_user_func($options['documentLoader'], $input);
} else {
$remote_doc = (object)array(
'contextUrl' => null,
'documentUrl' => null,
'document' => $input);
}
try {
if($remote_doc->document === null) {
throw new JsonLdException(
'No remote document found at the given URL.',
'jsonld.NullRemoteDocument');
}
if(is_string($remote_doc->document)) {
$remote_doc->document = self::_parse_json($remote_doc->document);
}
} catch(Exception $e) {
throw new JsonLdException(
'Could not retrieve a JSON-LD document from the URL.',
'jsonld.LoadDocumentError', 'loading document failed',
array('remoteDoc' => $remote_doc), $e);
}
// set default base
self::setdefault($options, 'base', $remote_doc->documentUrl ?: '');
// build meta-object and retrieve all @context urls
$input = (object)array(
'document' => self::copy($remote_doc->document),
'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, 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) {
global $jsonld_default_load_document;
self::setdefaults($options, array(
'base' => is_string($input) ? $input : '',
'documentLoader' => $jsonld_default_load_document));
try {
// expand input
$expanded = $this->expand($input, $options);
} catch(Exception $e) {
throw new JsonLdException(
'Could not expand input before flattening.',
'jsonld.FlattenError', null, 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, 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: '@last', '@always', '@never', '@link'
* (default: '@last').
* [explicit] default @explicit flag (default: false).
* [requireAll] default @requireAll flag (default: true).
* [omitDefault] default @omitDefault flag (default: false).
* [documentLoader(url)] the document loader.
*
* @return stdClass the framed JSON-LD output.
*/
public function frame($input, $frame, $options) {
global $jsonld_default_load_document;
self::setdefaults($options, array(
'base' => is_string($input) ? $input : '',
'compactArrays' => true,
'embed' => '@last',
'explicit' => false,
'requireAll' => true,
'omitDefault' => false,
'documentLoader' => $jsonld_default_load_document));
// if frame is a string, attempt to dereference remote document
if(is_string($frame)) {
$remote_frame = call_user_func($options['documentLoader'], $frame);
} else {
$remote_frame = (object)array(
'contextUrl' => null,
'documentUrl' => null,
'document' => $frame);
}
try {
if($remote_frame->document === null) {
throw new JsonLdException(
'No remote document found at the given URL.',
'jsonld.NullRemoteDocument');
}
if(is_string($remote_frame->document)) {
$remote_frame->document = self::_parse_json($remote_frame->document);
}
} catch(Exception $e) {
throw new JsonLdException(
'Could not retrieve a JSON-LD document from the URL.',
'jsonld.LoadDocumentError', 'loading document failed',
array('remoteDoc' => $remote_frame), $e);
}
// 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, null, $e);
}
try {
// expand frame
$opts = $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, null, $e);
}
// do framing
$framed = $this->_frame($expanded, $expanded_frame, $options);
try {
// compact result (force @graph option to true, skip expansion, check
// for linked embeds)
$options['graph'] = true;
$options['skipExpansion'] = true;
$options['link'] = new ArrayObject();
$options['activeCtx'] = true;
$result = $this->compact($framed, $ctx, $options);
} catch(Exception $e) {
throw new JsonLdException(
'Could not compact framed output.',
'jsonld.FrameError', null, null, $e);
}
$compacted = $result['compacted'];
$active_ctx = $result['activeCtx'];
// get graph alias
$graph = $this->_compactIri($active_ctx, '@graph');
// remove @preserve from results
$options['link'] = new ArrayObject();
$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.
* [inputFormat] the format if input is not JSON-LD:
* 'application/nquads' for N-Quads.
* [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) {
global $jsonld_default_load_document;
self::setdefaults($options, array(
'base' => is_string($input) ? $input : '',
'documentLoader' => $jsonld_default_load_document));
if(isset($options['inputFormat'])) {
if($options['inputFormat'] != 'application/nquads') {
throw new JsonLdException(
'Unknown normalization input format.', 'jsonld.NormalizeError');
}
$dataset = $this->parseNQuads($input);
} else {
try {
// convert to RDF dataset then do normalization
$opts = $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, 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', null, array('format' => $options['format']));
}
if($this->rdfParsers !== null) {
$callable = $this->rdfParsers->{$options['format']};
} else {
$callable = $jsonld_rdf_parsers->{$options['format']};
}
$dataset = call_user_func($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) {
global $jsonld_default_load_document;
self::setdefaults($options, array(
'base' => is_string($input) ? $input : '',
'produceGeneralizedRdf' => false,
'documentLoader' => $jsonld_default_load_document));
try {
// expand input
$expanded = $this->expand($input, $options);
} catch(JsonLdException $e) {
throw new JsonLdException(
'Could not expand input before serialization to RDF.',
'jsonld.RdfError', null, null, $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};
// skip relative IRIs
if($graph_name === '@default' || self::_isAbsoluteIri($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',
null, 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) {
global $jsonld_default_load_document;
self::setdefaults($options, array(
'base' => '',
'documentLoader' => $jsonld_default_load_document));
// 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, 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;
}
}
} else if(!is_array($value)) {
// avoid matching the set of values with an array value parameter
$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;
}
if($type === null) {
// return whole entry
$rval = $entry;
} else if(property_exists($entry, $type)) {
// return entry value for 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', null, 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);
} else {
// add triple if unique to its graph
$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}>";
} else if($bnode !== null) {
// bnode normalization mode
$quad .= ($s->value === $bnode) ? '_:a' : '_:z';
} else {
// bnode normal mode
$quad .= $s->value;
}
$quad .= ' ';
// predicate is an IRI
if($p->type === 'IRI') {
$quad .= "<{$p->value}>";
} else if($bnode !== null) {
// FIXME: TBD what to do with bnode predicates during normalization
// bnode normalization mode
$quad .= '_:p';
} else {
// bnode normal mode
$quad .= $p->value;
}
$quad .= ' ';
// object is IRI, bnode, or literal
if($o->type === 'IRI') {
$quad .= "<{$o->value}>";
} else if($o->type === 'blank node') {
if($bnode !== null) {
// normalization mode
$quad .= ($o->value === $bnode) ? '_:a' : '_:z';
} else {
// normal mode
$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));
}
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)) {
if($options['link'] && property_exists($element, '@id') &&
isset($options['link'][$element->{'@id'}])) {
// check for a linked element to reuse
$linked = $options['link'][$element->{'@id'}];
foreach($linked as $link) {
if($link['expanded'] === $element) {
return $link['compacted'];
}
}
}
// do value compaction on @values and subject references
if(self::_isValue($element) || self::_isSubjectReference($element)) {
$rval = $this->_compactValue($active_ctx, $active_property, $element);
if($options['link'] && self::_isSubjectReference($element)) {
// store linked element
if(!isset($options['link'][$element->{'@id'}])) {
$options['link'][$element->{'@id'}] = array();
}
$options['link'][$element->{'@id'}][] = array(
'expanded' => $element, 'compacted' => $rval);
}
return $rval;
}
// FIXME: avoid misuse of active property as an expanded property?
$inside_reverse = ($active_property === '@reverse');
$rval = new stdClass();
if($options['link'] && property_exists($element, '@id')) {
// store linked element
if(!isset($options['link'][$element->{'@id'}])) {
$options['link'][$element->{'@id'}] = array();
}
$options['link'][$element->{'@id'}][] = array(
'expanded' => $element, 'compacted' => $rval);
}
// process element keys in order
$keys = array_keys((array)$element);
sort($keys);
foreach($keys as $expanded_property) {
$expanded_value = $element->{$expanded_property};
// compact @id and @type(s)
if($expanded_property === '@id' || $expanded_property === '@type') {
if(is_string($expanded_value)) {
// compact single @id
$compacted_value = $this->_compactIri(
$active_ctx, $expanded_value, null,
array('vocab' => ($expanded_property === '@type')));
} else {
// expanded value must be a @type array
$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;
}
// skip array processing for keywords that aren't @graph or @list
if($expanded_property !== '@graph' && $expanded_property !== '@list' &&
self::_isKeyword($expanded_property)) {
// use keyword alias and add value as is
$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'};
}
} else if(property_exists($rval, $item_active_property)) {
// can't use @list container for more than 1 list
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',
'compaction to list of lists');
}
}
// 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();
$container = self::getContextValue(
$active_ctx, $active_property, '@container');
$inside_list = $inside_list || $container === '@list';
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', 'list of lists');
}
// drop null values
if($e !== null) {
if(is_array($e)) {
$rval = array_merge($rval, $e);
} else {
$rval[] = $e;
}
}
}
return $rval;
}
if(!is_object($element)) {
// drop free-floating 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);
}
// recursively expand object:
// 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;
}
// 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)) {
if($expanded_active_property === '@reverse') {
throw new JsonLdException(
'Invalid JSON-LD syntax; a keyword cannot be used as a @reverse ' .
'property.', 'jsonld.SyntaxError', 'invalid reverse property map',
array('value' => $value));
}
if(property_exists($rval, $expanded_property)) {
throw new JsonLdException(
'Invalid JSON-LD syntax; colliding keywords detected.',
'jsonld.SyntaxError', 'colliding keywords',
array('keyword' => $expanded_property));
}
}
// syntax error if @id is not a string
if($expanded_property === '@id' && !is_string($value)) {
if(!isset($options['isFrame']) || !$options['isFrame']) {
throw new JsonLdException(
'Invalid JSON-LD syntax; "@id" value must a string.',
'jsonld.SyntaxError', 'invalid @id value',
array('value' => $value));
}
if(!is_object($value)) {
throw new JsonLdException(
'Invalid JSON-LD syntax; "@id" value must a string or an object.',
'jsonld.SyntaxError', 'invalid @id value',
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; "@graph" value must not be an ' .
'object or an array.', 'jsonld.SyntaxError',
'invalid @graph value', 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',
'invalid value object value', array('value' => $value));
}
// @language must be a string
if($expanded_property === '@language') {
if($value === null) {
// drop null @language values, they expand as if they didn't exist
continue;
}
if(!is_string($value)) {
throw new JsonLdException(
'Invalid JSON-LD syntax; "@language" value must not be a string.',
'jsonld.SyntaxError', 'invalid language-tagged string',
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', 'invalid @index value',
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', 'invalid @reverse value',
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',
'invalid reverse property value',
array('value' => $expanded_value));
}
self::addValue(
$reverse_map, $property, $item,
array('propertyIsArray' => true));
}
}
continue;
}
$container = self::getContextValue($active_ctx, $key, '@container');
if($container === '@language' && is_object($value)) {
// handle language map container (skip if value is not an object)
$expanded_value = $this->_expandLanguageMap($value);
} else if($container === '@index' && is_object($value)) {
// handle index container (skip if value is not an object)
$expanded_value = array();
$value_keys = array_keys((array)$value);
sort($value_keys);
foreach($value_keys as $value_key) {
$val = $value->{$value_key};
$val = self::arrayify($val);
$val = $this->_expand($active_ctx, $key, $val, $options, false);
foreach($val as $item) {
if(!property_exists($item, '@index')) {
$item->{'@index'} = $value_key;
}
$expanded_value[] = $item;
}
}
} else {
// recurse into @list or @set
$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', 'list of lists');
}
} 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) {
if(property_exists($rval, '@reverse')) {
$reverse_map = $rval->{'@reverse'};
} else {
$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',
'invalid reverse property value',
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', 'invalid value object',
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', 'invalid value object',
array('element' => $rval));
}
// drop null @values
if($rval->{'@value'} === null) {
$rval = null;
} else if(property_exists($rval, '@language') &&
!is_string($rval->{'@value'})) {
// if @language is present, @value must be a string
throw new JsonLdException(
'Invalid JSON-LD syntax; only strings may be language-tagged.',
'jsonld.SyntaxError', 'invalid language-tagged value',
array('element' => $rval));
} else if(property_exists($rval, '@type') &&
(!self::_isAbsoluteIri($rval->{'@type'}) ||
strpos($rval->{'@type'}, '_:') === 0)) {
throw new JsonLdException(
'Invalid JSON-LD syntax; an element containing "@value" ' .
'and "@type" must have an absolute IRI for the value ' .
'of "@type".', 'jsonld.SyntaxError', 'invalid typed value',
array('element' => $rval));
}
} else if(property_exists($rval, '@type') && !is_array($rval->{'@type'})) {
// convert @type to an array
$rval->{'@type'} = array($rval->{'@type'});
} else if(property_exists($rval, '@set') ||
property_exists($rval, '@list')) {
// handle @set and @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', 'invalid set or list object',
array('element' => $rval));
}
// optimize away @set
if(property_exists($rval, '@set')) {
$rval = $rval->{'@set'};
$keys = array_keys((array)$rval);
$count = count($keys);
}
} else if($count === 1 && property_exists($rval, '@language')) {
// drop objects with only @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/@list, or object with only @id
if($count === 0 || property_exists($rval, '@value') ||
property_exists($rval, '@list') ||
($count === 1 && property_exists($rval, '@id'))) {
$rval = null;
}
}
return $rval;
}
/**
* Performs JSON-LD flattening.
*
* @param array $input the expanded JSON-LD to flatten.
*
* @return array the flattened output.
*/
protected function _flatten($input) {
// produce a map of all subjects and name each bnode
$namer = new UniqueNamer('_: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()),
'subjectStack' => array(),
'link' => 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
foreach($result->pathNamer->order as $bnode) {
$namer->getName($bnode);
}
}
}
// create normalized array
$normalized = array();
/* Note: At this point all bnodes in the set of RDF quads have been
assigned canonical names, which have been stored in the 'namer' object.
Here each quad is updated by assigning each of its bnodes its new name
via the 'namer' object. */
// update bnode names in each quad and serialize
foreach($quads as $quad) {
foreach(array('subject', 'object', 'name') as $attr) {
if(property_exists($quad, $attr) &&
$quad->{$attr}->type === 'blank node' &&
strpos($quad->{$attr}->value, '_:c14n') !== 0) {
$quad->{$attr}->value = $namer->getName($quad->{$attr}->value);
}
}
$normalized[] = $this->toNQuad($quad, property_exists($quad, 'name') ?
$quad->name->value : null);
}
// sort normalized output
sort($normalized);
// handle output format
if(isset($options['format']) && $options['format']) {
if($options['format'] === 'application/nquads') {
return implode($normalized);
}
throw new JsonLdException(
'Unknown output format.',
'jsonld.UnknownFormat', null, array('format' => $options['format']));
}
// return RDF dataset
return $this->parseNQuads(implode($normalized));
}
/**
* Converts an RDF dataset to JSON-LD.
*
* @param stdClass $dataset the RDF dataset.
* @param assoc $options the RDF serialization options.
*
* @return array the JSON-LD output.
*/
protected function _fromRDF($dataset, $options) {
$default_graph = new stdClass();
$graph_map = (object)array('@default' => $default_graph);
$referenced_once = (object)array();
foreach($dataset as $name => $graph) {
if(!property_exists($graph_map, $name)) {
$graph_map->{$name} = new stdClass();
}
if($name !== '@default' && !property_exists($default_graph, $name)) {
$default_graph->{$name} = (object)array('@id' => $name);
}
$node_map = $graph_map->{$name};
foreach($graph as $triple) {
// get subject, predicate, object
$s = $triple->subject->value;
$p = $triple->predicate->value;
$o = $triple->object;
if(!property_exists($node_map, $s)) {
$node_map->{$s} = (object)array('@id' => $s);
}
$node = $node_map->{$s};
$object_is_id = ($o->type === 'IRI' || $o->type === 'blank node');
if($object_is_id && !property_exists($node_map, $o->value)) {
$node_map->{$o->value} = (object)array('@id' => $o->value);
}
if($p === self::RDF_TYPE && !$options['useRdfType'] && $object_is_id) {
self::addValue(
$node, '@type', $o->value, array('propertyIsArray' => true));
continue;
}
$value = self::_RDFToObject($o, $options['useNativeTypes']);
self::addValue($node, $p, $value, array('propertyIsArray' => true));
// object may be an RDF list/partial list node but we can't know
// easily until all triples are read
if($object_is_id) {
if($o->value === self::RDF_NIL) {
$object = $node_map->{$o->value};
if(!property_exists($object, 'usages')) {
$object->usages = array();
}
$object->usages[] = (object)array(
'node' => $node,
'property' => $p,
'value' => $value);
} else if(property_exists($referenced_once, $o->value)) {
// object referenced more than once
$referenced_once->{$o->value} = false;
} else {
// track single reference
$referenced_once->{$o->value} = (object)array(
'node' => $node,
'property' => $p,
'value' => $value);
}
}
}
}
// convert linked lists to @list arrays
foreach($graph_map as $name => $graph_object) {
// no @lists to be converted, continue
if(!property_exists($graph_object, self::RDF_NIL)) {
continue;
}
// iterate backwards through each RDF list
$nil = $graph_object->{self::RDF_NIL};
foreach($nil->usages as $usage) {
$node = $usage->node;
$property = $usage->property;
$head = $usage->value;
$list = array();
$list_nodes = array();
// ensure node is a well-formed list node; it must:
// 1. Be referenced only once.
// 2. Have an array for rdf:first that has 1 item.
// 3. Have an array for rdf:rest that has 1 item.
// 4. Have no keys other than: @id, rdf:first, rdf:rest, and,
// optionally, @type where the value is rdf:List.
$node_key_count = count(array_keys((array)$node));
while($property === self::RDF_REST &&
property_exists($referenced_once, $node->{'@id'}) &&
is_object($referenced_once->{$node->{'@id'}}) &&
property_exists($node, self::RDF_FIRST) &&
property_exists($node, self::RDF_REST) &&
is_array($node->{self::RDF_FIRST}) &&
is_array($node->{self::RDF_REST}) &&
count($node->{self::RDF_FIRST}) === 1 &&
count($node->{self::RDF_REST}) === 1 &&
($node_key_count === 3 || ($node_key_count === 4 &&
property_exists($node, '@type') && is_array($node->{'@type'}) &&
count($node->{'@type'}) === 1 &&
$node->{'@type'}[0] === self::RDF_LIST))) {
$list[] = $node->{self::RDF_FIRST}[0];
$list_nodes[] = $node->{'@id'};
// get next node, moving backwards through list
$usage = $referenced_once->{$node->{'@id'}};
$node = $usage->node;
$property = $usage->property;
$head = $usage->value;
$node_key_count = count(array_keys((array)$node));
// if node is not a blank node, then list head found
if(strpos($node->{'@id'}, '_:') !== 0) {
break;
}
}
// list is nested in another list
if($property === self::RDF_FIRST) {
// empty list
if($node->{'@id'} === self::RDF_NIL) {
// can't convert rdf:nil to a @list object because it would
// result in a list of lists which isn't supported
continue;
}
// preserve list head
$head = $graph_object->{$head->{'@id'}}->{self::RDF_REST}[0];
array_pop($list);
array_pop($list_nodes);
}
// transform list into @list object
unset($head->{'@id'});
$head->{'@list'} = array_reverse($list);
foreach($list_nodes as $list_node) {
unset($graph_object->{$list_node});
}
}
unset($nil->usages);
}
$result = array();
$subjects = array_keys((array)$default_graph);
sort($subjects);
foreach($subjects as $subject) {
$node = $default_graph->{$subject};
if(property_exists($graph_map, $subject)) {
$node->{'@graph'} = array();
$graph_object = $graph_map->{$subject};
$subjects_ = array_keys((array)$graph_object);
sort($subjects_);
foreach($subjects_ as $subject_) {