6062 lines
178 KiB
PHP
6062 lines
178 KiB
PHP
<?php
|
|
|
|
/**
|
|
* PHP implementation of the JSON-LD API.
|
|
* Version: 1.1.0
|
|
*
|
|
* @author Dave Longley
|
|
* @author Michael Vogel
|
|
*
|
|
* 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 array $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 = [])
|
|
{
|
|
$p = new JsonLdProcessor();
|
|
return $p->compact($input, $ctx, $options);
|
|
}
|
|
|
|
/**
|
|
* Performs JSON-LD expansion.
|
|
*
|
|
* @param mixed $input the JSON-LD object to expand.
|
|
* @param array $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 = [])
|
|
{
|
|
$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 = [])
|
|
{
|
|
$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 array $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 = [])
|
|
{
|
|
$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 array $options the options to use:
|
|
* [base] the base IRI to use.
|
|
* [expandContext] a context to expand with.
|
|
* [documentLoader(url)] the document loader.
|
|
*
|
|
* @return stdClass 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 array $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 = [])
|
|
{
|
|
$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 array $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 = [])
|
|
{
|
|
$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 array $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 = [])
|
|
{
|
|
$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 string|false 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 array the parsed result.
|
|
*/
|
|
function jsonld_parse_link_header($header)
|
|
{
|
|
$rval = [];
|
|
// 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) ['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] = [$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 string 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 document URL or a local path
|
|
*
|
|
* @return stdClass the RemoteDocument object.
|
|
*/
|
|
function jsonld_default_document_loader($url)
|
|
{
|
|
$doc = (object) [
|
|
'contextUrl' => null, 'document' => null, 'documentUrl' => $url];
|
|
$redirects = [];
|
|
|
|
$opts = [
|
|
'http' => [
|
|
'method' => 'GET',
|
|
'header' =>
|
|
"Accept: application/ld+json\r\n"],
|
|
/* Note: Use jsonld_default_secure_document_loader for security. */
|
|
'ssl' => [
|
|
'verify_peer' => false,
|
|
'allow_self_signed' => true]
|
|
];
|
|
|
|
$context = stream_context_create($opts);
|
|
$content_type = null;
|
|
stream_context_set_params($context, ['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 = [];
|
|
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', ['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) [
|
|
'contextUrl' => null, 'document' => null, 'documentUrl' => $url];
|
|
$redirects = [];
|
|
|
|
// default JSON-LD https GET implementation
|
|
$opts = [
|
|
'http' => [
|
|
'method' => 'GET',
|
|
'header' =>
|
|
"Accept: application/ld+json\r\n"],
|
|
'ssl' => [
|
|
'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, ['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 = [];
|
|
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', ['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 array the parsed URL.
|
|
*/
|
|
function jsonld_parse_url($url)
|
|
{
|
|
if ($url === null) {
|
|
$url = '';
|
|
}
|
|
|
|
$keys = [
|
|
'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 = [];
|
|
$flags = [];
|
|
$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 = [];
|
|
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 = ['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 array $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, [
|
|
'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 = [];
|
|
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 ['compacted' => $compacted, 'activeCtx' => $active_ctx];
|
|
}
|
|
|
|
return $compacted;
|
|
}
|
|
|
|
/**
|
|
* Performs JSON-LD expansion.
|
|
*
|
|
* @param mixed $input the JSON-LD object to expand.
|
|
* @param array $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, [
|
|
'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) [
|
|
'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', ['remoteDoc' => $remote_doc], $e);
|
|
}
|
|
|
|
// set default base
|
|
self::setdefault($options, 'base', $remote_doc->documentUrl ?: '');
|
|
|
|
// build meta-object and retrieve all @context urls
|
|
$input = (object) [
|
|
'document' => self::copy($remote_doc->document),
|
|
'remoteContext' => (object) [
|
|
'@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) ['@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 = [];
|
|
}
|
|
// normalize to an array
|
|
return self::arrayify($expanded);
|
|
}
|
|
|
|
/**
|
|
* 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 array $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, [
|
|
'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 array $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, [
|
|
'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) [
|
|
'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', ['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 array $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, [
|
|
'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 array $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, [
|
|
'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, ['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 array $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, [
|
|
'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) ['@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, ['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 array $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, [
|
|
'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) ['@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 array $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 = [])
|
|
{
|
|
self::setdefaults($options, [
|
|
'allowDuplicate' => true,
|
|
'propertyIsArray' => false]);
|
|
|
|
if (is_array($value)) {
|
|
if (count($value) === 0 && $options['propertyIsArray'] &&
|
|
!property_exists($subject, $property)) {
|
|
$subject->{$property} = [];
|
|
}
|
|
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} = [$subject->{$property}];
|
|
}
|
|
|
|
// add new value
|
|
if (!$has_value) {
|
|
$subject->{$property}[] = $value;
|
|
}
|
|
} else {
|
|
// add new value as set or single value
|
|
$subject->{$property} = ($options['propertyIsArray'] ?
|
|
[$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} : []);
|
|
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 array $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 = [])
|
|
{
|
|
self::setdefaults($options, [
|
|
'propertyIsArray' => false]);
|
|
|
|
// filter out value
|
|
$filter = function($e) use ($value) {
|
|
return !self::compareValues($e, $value);
|
|
};
|
|
$values = self::getValues($subject, $property);
|
|
$values = array_values(array_filter($values, $filter));
|
|
|
|
if (count($values) === 0) {
|
|
self::removeProperty($subject, $property);
|
|
} else if (count($values) === 1 && !$options['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, ['line' => $line_number]);
|
|
}
|
|
|
|
// create RDF triple
|
|
$triple = (object) [
|
|
'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(
|
|
['\"', '\t', '\n', '\r', '\\\\'], ['"', "\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} = [$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 = [];
|
|
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(
|
|
['\\', "\t", "\n", "\r", '"'], ['\\\\', '\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 : [$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 &array $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 &array $arr the object to update.
|
|
* @param array $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 array $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 = [];
|
|
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'}] = [];
|
|
}
|
|
$options['link'][$element->{'@id'}][] = [
|
|
'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'}] = [];
|
|
}
|
|
$options['link'][$element->{'@id'}][] = [
|
|
'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, ['vocab' => ($expanded_property === '@type')]);
|
|
} else {
|
|
// expanded value must be a @type array
|
|
$compacted_value = [];
|
|
foreach ($expanded_value as $ev) {
|
|
$compacted_value[] = $this->_compactIri(
|
|
$active_ctx, $ev, null, ['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, ['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, ['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, ['vocab' => true], $inside_reverse);
|
|
self::addValue(
|
|
$rval, $item_active_property, [], ['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, ['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) [
|
|
$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, ['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 array $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 = [];
|
|
$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, ['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, ['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, ['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', ['value' => $value]);
|
|
}
|
|
if (property_exists($rval, $expanded_property)) {
|
|
throw new JsonLdException(
|
|
'Invalid JSON-LD syntax; colliding keywords detected.', 'jsonld.SyntaxError', 'colliding keywords', ['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', ['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', ['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', ['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', ['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', ['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', ['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', ['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, ['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, [], ['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', ['value' => $expanded_value]);
|
|
}
|
|
self::addValue(
|
|
$reverse_map, $property, $item, ['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 = [];
|
|
$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) [
|
|
'@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', ['value' => $expanded_value]);
|
|
}
|
|
self::addValue(
|
|
$reverse_map, $expanded_property, $item, ['propertyIsArray' => true]);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// add value for property
|
|
// use an array except for certain keywords
|
|
$use_array = (!in_array(
|
|
$expanded_property, [
|
|
'@index', '@id', '@type', '@value', '@language']));
|
|
self::addValue(
|
|
$rval, $expanded_property, $expanded_value, ['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', ['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', ['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', ['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', ['element' => $rval]);
|
|
}
|
|
} else if (property_exists($rval, '@type') && !is_array($rval->{'@type'})) {
|
|
// convert @type to an array
|
|
$rval->{'@type'} = [$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', ['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) ['@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) [
|
|
'@id' => $graph_name, '@graph' => []];
|
|
}
|
|
$subject = $default_graph->{$graph_name};
|
|
if (!property_exists($subject, '@graph')) {
|
|
$subject->{'@graph'} = [];
|
|
}
|
|
$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 = [];
|
|
$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 array $options the framing options.
|
|
*
|
|
* @return array the framed output.
|
|
*/
|
|
protected function _frame($input, $frame, $options)
|
|
{
|
|
// create framing state
|
|
$state = (object) [
|
|
'options' => $options,
|
|
'graphs' => (object) [
|
|
'@default' => new stdClass(),
|
|
'@merged' => new stdClass()],
|
|
'subjectStack' => [],
|
|
'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 array $options the normalization options.
|
|
*
|
|
* @return mixed the normalized output.
|
|
*/
|
|
protected function _normalize($dataset, $options)
|
|
{
|
|
// create quads and map bnodes to their associated quads
|
|
$quads = [];
|
|
$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) [
|
|
'type' => 'blank node', 'value' => $graph_name];
|
|
} else {
|
|
$quad->name = (object) [
|
|
'type' => 'IRI', 'value' => $graph_name];
|
|
}
|
|
}
|
|
$quads[] = $quad;
|
|
|
|
foreach (['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) ['quads' => [$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 = [];
|
|
$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} = [$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 = [];
|
|
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 = [];
|
|
|
|
/* 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 (['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, ['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 array $options the RDF serialization options.
|
|
*
|
|
* @return array the JSON-LD output.
|
|
*/
|
|
protected function _fromRDF($dataset, $options)
|
|
{
|
|
$default_graph = new stdClass();
|
|
$graph_map = (object) ['@default' => $default_graph];
|
|
$referenced_once = (object) [];
|
|
|
|
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) ['@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) ['@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) ['@id' => $o->value];
|
|
}
|
|
|
|
if ($p === self::RDF_TYPE && !$options['useRdfType'] && $object_is_id) {
|
|
self::addValue(
|
|
$node, '@type', $o->value, ['propertyIsArray' => true]);
|
|
continue;
|
|
}
|
|
|
|
$value = self::_RDFToObject($o, $options['useNativeTypes']);
|
|
self::addValue($node, $p, $value, ['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 = [];
|
|
}
|
|
$object->usages[] = (object) [
|
|
'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) [
|
|
'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 = [];
|
|
$list_nodes = [];
|
|
|
|
// 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 = [];
|
|
$subjects = array_keys((array) $default_graph);
|
|
sort($subjects);
|
|
foreach ($subjects as $subject) {
|
|
$node = $default_graph->{$subject};
|
|
if (property_exists($graph_map, $subject)) {
|
|
$node->{'@graph'} = [];
|
|
$graph_object = $graph_map->{$subject};
|
|
$subjects_ = array_keys((array) $graph_object);
|
|
sort($subjects_);
|
|
foreach ($subjects_ as $subject_) {
|
|
$node_ = $graph_object->{$subject_};
|
|
// only add full subjects to top-level
|
|
if (!self::_isSubjectReference($node_)) {
|
|
$node->{'@graph'}[] = $node_;
|
|
}
|
|
}
|
|
}
|
|
// only add full subjects to top-level
|
|
if (!self::_isSubjectReference($node)) {
|
|
$result[] = $node;
|
|
}
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* Processes a local context and returns a new active context.
|
|
*
|
|
* @param stdClass $active_ctx the current active context.
|
|
* @param mixed $local_ctx the local context to process.
|
|
* @param array $options the context processing options.
|
|
*
|
|
* @return stdClass the new active context.
|
|
*/
|
|
protected function _processContext($active_ctx, $local_ctx, $options)
|
|
{
|
|
global $jsonld_cache;
|
|
|
|
// normalize local context to an array
|
|
if (is_object($local_ctx) && property_exists($local_ctx, '@context') &&
|
|
is_array($local_ctx->{'@context'})) {
|
|
$local_ctx = $local_ctx->{'@context'};
|
|
}
|
|
$ctxs = self::arrayify($local_ctx);
|
|
|
|
// no contexts in array, clone existing context
|
|
if (count($ctxs) === 0) {
|
|
return self::_cloneActiveContext($active_ctx);
|
|
}
|
|
|
|
// process each context in order, update active context
|
|
// on each iteration to ensure proper caching
|
|
$rval = $active_ctx;
|
|
foreach ($ctxs as $ctx) {
|
|
// reset to initial context
|
|
if ($ctx === null) {
|
|
$rval = $active_ctx = $this->_getInitialContext($options);
|
|
continue;
|
|
}
|
|
|
|
// dereference @context key if present
|
|
if (is_object($ctx) && property_exists($ctx, '@context')) {
|
|
$ctx = $ctx->{'@context'};
|
|
}
|
|
|
|
// context must be an object by now, all URLs retrieved before this call
|
|
if (!is_object($ctx)) {
|
|
throw new JsonLdException(
|
|
'Invalid JSON-LD syntax; @context must be an object.', 'jsonld.SyntaxError', 'invalid local context', ['context' => $ctx]);
|
|
}
|
|
|
|
// get context from cache if available
|
|
if (property_exists($jsonld_cache, 'activeCtx')) {
|
|
$cached = $jsonld_cache->activeCtx->get($active_ctx, $ctx);
|
|
if ($cached) {
|
|
$rval = $active_ctx = $cached;
|
|
$must_clone = true;
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// update active context and clone new one before updating
|
|
$active_ctx = $rval;
|
|
$rval = self::_cloneActiveContext($rval);
|
|
|
|
// define context mappings for keys in local context
|
|
$defined = new stdClass();
|
|
|
|
// handle @base
|
|
if (property_exists($ctx, '@base')) {
|
|
$base = $ctx->{'@base'};
|
|
if ($base === null) {
|
|
$base = null;
|
|
} else if (!is_string($base)) {
|
|
throw new JsonLdException(
|
|
'Invalid JSON-LD syntax; the value of "@base" in a ' .
|
|
'@context must be a string or null.', 'jsonld.SyntaxError', 'invalid base IRI', ['context' => $ctx]);
|
|
} else if ($base !== '' && !self::_isAbsoluteIri($base)) {
|
|
throw new JsonLdException(
|
|
'Invalid JSON-LD syntax; the value of "@base" in a ' .
|
|
'@context must be an absolute IRI or the empty string.', 'jsonld.SyntaxError', 'invalid base IRI', ['context' => $ctx]);
|
|
}
|
|
if ($base !== null) {
|
|
$base = jsonld_parse_url($base);
|
|
}
|
|
$rval->{'@base'} = $base;
|
|
$defined->{'@base'} = true;
|
|
}
|
|
|
|
// handle @vocab
|
|
if (property_exists($ctx, '@vocab')) {
|
|
$value = $ctx->{'@vocab'};
|
|
if ($value === null) {
|
|
unset($rval->{'@vocab'});
|
|
} else if (!is_string($value)) {
|
|
throw new JsonLdException(
|
|
'Invalid JSON-LD syntax; the value of "@vocab" in a ' .
|
|
'@context must be a string or null.', 'jsonld.SyntaxError', 'invalid vocab mapping', ['context' => $ctx]);
|
|
} else if (!self::_isAbsoluteIri($value)) {
|
|
throw new JsonLdException(
|
|
'Invalid JSON-LD syntax; the value of "@vocab" in a ' .
|
|
'@context must be an absolute IRI.', 'jsonld.SyntaxError', 'invalid vocab mapping', ['context' => $ctx]);
|
|
} else {
|
|
$rval->{'@vocab'} = $value;
|
|
}
|
|
$defined->{'@vocab'} = true;
|
|
}
|
|
|
|
// handle @language
|
|
if (property_exists($ctx, '@language')) {
|
|
$value = $ctx->{'@language'};
|
|
if ($value === null) {
|
|
unset($rval->{'@language'});
|
|
} else if (!is_string($value)) {
|
|
throw new JsonLdException(
|
|
'Invalid JSON-LD syntax; the value of "@language" in a ' .
|
|
'@context must be a string or null.', 'jsonld.SyntaxError', 'invalid default language', ['context' => $ctx]);
|
|
} else {
|
|
$rval->{'@language'} = strtolower($value);
|
|
}
|
|
$defined->{'@language'} = true;
|
|
}
|
|
|
|
// process all other keys
|
|
foreach ($ctx as $k => $v) {
|
|
$this->_createTermDefinition($rval, $ctx, $k, $defined);
|
|
}
|
|
|
|
// cache result
|
|
if (property_exists($jsonld_cache, 'activeCtx')) {
|
|
$jsonld_cache->activeCtx->set($active_ctx, $ctx, $rval);
|
|
}
|
|
}
|
|
|
|
return $rval;
|
|
}
|
|
|
|
/**
|
|
* Expands a language map.
|
|
*
|
|
* @param stdClass $language_map the language map to expand.
|
|
*
|
|
* @return array the expanded language map.
|
|
*/
|
|
protected function _expandLanguageMap($language_map)
|
|
{
|
|
$rval = [];
|
|
$keys = array_keys((array) $language_map);
|
|
sort($keys);
|
|
foreach ($keys as $key) {
|
|
$values = $language_map->{$key};
|
|
$values = self::arrayify($values);
|
|
foreach ($values as $item) {
|
|
if ($item === null) {
|
|
continue;
|
|
}
|
|
if (!is_string($item)) {
|
|
throw new JsonLdException(
|
|
'Invalid JSON-LD syntax; language map values must be strings.', 'jsonld.SyntaxError', 'invalid language map value', ['languageMap', $language_map]);
|
|
}
|
|
$rval[] = (object) [
|
|
'@value' => $item,
|
|
'@language' => strtolower($key)];
|
|
}
|
|
}
|
|
return $rval;
|
|
}
|
|
|
|
/**
|
|
* Labels the blank nodes in the given value using the given UniqueNamer.
|
|
*
|
|
* @param UniqueNamer $namer the UniqueNamer to use.
|
|
* @param mixed $element the element with blank nodes to rename.
|
|
*
|
|
* @return mixed the element.
|
|
*/
|
|
public function _labelBlankNodes($namer, $element)
|
|
{
|
|
if (is_array($element)) {
|
|
$length = count($element);
|
|
for ($i = 0; $i < $length; ++$i) {
|
|
$element[$i] = $this->_labelBlankNodes($namer, $element[$i]);
|
|
}
|
|
} else if (self::_isList($element)) {
|
|
$element->{'@list'} = $this->_labelBlankNodes(
|
|
$namer, $element->{'@list'});
|
|
} else if (is_object($element)) {
|
|
// rename blank node
|
|
if (self::_isBlankNode($element)) {
|
|
$name = null;
|
|
if (property_exists($element, '@id')) {
|
|
$name = $element->{'@id'};
|
|
}
|
|
$element->{'@id'} = $namer->getName($name);
|
|
}
|
|
|
|
// recursively apply to all keys
|
|
$keys = array_keys((array) $element);
|
|
sort($keys);
|
|
foreach ($keys as $key) {
|
|
if ($key !== '@id') {
|
|
$element->{$key} = $this->_labelBlankNodes($namer, $element->{$key});
|
|
}
|
|
}
|
|
}
|
|
|
|
return $element;
|
|
}
|
|
|
|
/**
|
|
* Expands the given value by using the coercion and keyword rules in the
|
|
* given context.
|
|
*
|
|
* @param stdClass $active_ctx the active context to use.
|
|
* @param string $active_property the property the value is associated with.
|
|
* @param mixed $value the value to expand.
|
|
*
|
|
* @return mixed the expanded value.
|
|
*/
|
|
protected function _expandValue($active_ctx, $active_property, $value)
|
|
{
|
|
// nothing to expand
|
|
if ($value === null) {
|
|
return null;
|
|
}
|
|
|
|
// special-case expand @id and @type (skips '@id' expansion)
|
|
$expanded_property = $this->_expandIri(
|
|
$active_ctx, $active_property, ['vocab' => true]);
|
|
if ($expanded_property === '@id') {
|
|
return $this->_expandIri($active_ctx, $value, ['base' => true]);
|
|
} else if ($expanded_property === '@type') {
|
|
return $this->_expandIri(
|
|
$active_ctx, $value, ['vocab' => true, 'base' => true]);
|
|
}
|
|
|
|
// get type definition from context
|
|
$type = self::getContextValue($active_ctx, $active_property, '@type');
|
|
|
|
// do @id expansion (automatic for @graph)
|
|
if ($type === '@id' || ($expanded_property === '@graph' &&
|
|
is_string($value))) {
|
|
return (object) ['@id' => $this->_expandIri(
|
|
$active_ctx, $value, ['base' => true])];
|
|
}
|
|
// do @id expansion w/vocab
|
|
if ($type === '@vocab') {
|
|
return (object) ['@id' => $this->_expandIri(
|
|
$active_ctx, $value, ['vocab' => true, 'base' => true])];
|
|
}
|
|
|
|
// do not expand keyword values
|
|
if (self::_isKeyword($expanded_property)) {
|
|
return $value;
|
|
}
|
|
|
|
$rval = new stdClass();
|
|
|
|
// other type
|
|
if ($type !== null) {
|
|
$rval->{'@type'} = $type;
|
|
} else if (is_string($value)) {
|
|
// check for language tagging for strings
|
|
$language = self::getContextValue(
|
|
$active_ctx, $active_property, '@language');
|
|
if ($language !== null) {
|
|
$rval->{'@language'} = $language;
|
|
}
|
|
}
|
|
$rval->{'@value'} = $value;
|
|
|
|
return $rval;
|
|
}
|
|
|
|
/**
|
|
* Creates an array of RDF triples for the given graph.
|
|
*
|
|
* @param stdClass $graph the graph to create RDF triples for.
|
|
* @param UniqueNamer $namer for assigning bnode names.
|
|
* @param array $options the RDF serialization options.
|
|
*
|
|
* @return array the array of RDF triples for the given graph.
|
|
*/
|
|
protected function _graphToRDF($graph, $namer, $options)
|
|
{
|
|
$rval = [];
|
|
|
|
$ids = array_keys((array) $graph);
|
|
sort($ids);
|
|
foreach ($ids as $id) {
|
|
$node = $graph->{$id};
|
|
if ($id === '"') {
|
|
$id = '';
|
|
}
|
|
$properties = array_keys((array) $node);
|
|
sort($properties);
|
|
foreach ($properties as $property) {
|
|
$items = $node->{$property};
|
|
if ($property === '@type') {
|
|
$property = self::RDF_TYPE;
|
|
} else if (self::_isKeyword($property)) {
|
|
continue;
|
|
}
|
|
|
|
foreach ($items as $item) {
|
|
// skip relative IRI subjects and predicates
|
|
if (!(self::_isAbsoluteIri($id) && self::_isAbsoluteIri($property))) {
|
|
continue;
|
|
}
|
|
|
|
// RDF subject
|
|
$subject = new stdClass();
|
|
$subject->type = (strpos($id, '_:') === 0) ? 'blank node' : 'IRI';
|
|
$subject->value = $id;
|
|
|
|
// RDF predicate
|
|
$predicate = new stdClass();
|
|
$predicate->type = (strpos($property, '_:') === 0 ?
|
|
'blank node' : 'IRI');
|
|
$predicate->value = $property;
|
|
|
|
// skip bnode predicates unless producing generalized RDF
|
|
if ($predicate->type === 'blank node' &&
|
|
!$options['produceGeneralizedRdf']) {
|
|
continue;
|
|
}
|
|
|
|
if (self::_isList($item)) {
|
|
// convert @list to triples
|
|
$this->_listToRDF(
|
|
$item->{'@list'}, $namer, $subject, $predicate, $rval);
|
|
} else {
|
|
// convert value or node object to triple
|
|
$object = $this->_objectToRDF($item);
|
|
// skip null objects (they are relative IRIs)
|
|
if ($object) {
|
|
$rval[] = (object) [
|
|
'subject' => $subject,
|
|
'predicate' => $predicate,
|
|
'object' => $object];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return $rval;
|
|
}
|
|
|
|
/**
|
|
* Converts a @list value into linked list of blank node RDF triples
|
|
* (an RDF collection).
|
|
*
|
|
* @param array $list the @list value.
|
|
* @param UniqueNamer $namer for assigning blank node names.
|
|
* @param stdClass $subject the subject for the head of the list.
|
|
* @param stdClass $predicate the predicate for the head of the list.
|
|
* @param &array $triples the array of triples to append to.
|
|
*/
|
|
protected function _listToRDF(
|
|
$list, $namer, $subject, $predicate, &$triples)
|
|
{
|
|
$first = (object) ['type' => 'IRI', 'value' => self::RDF_FIRST];
|
|
$rest = (object) ['type' => 'IRI', 'value' => self::RDF_REST];
|
|
$nil = (object) ['type' => 'IRI', 'value' => self::RDF_NIL];
|
|
|
|
foreach ($list as $item) {
|
|
$blank_node = (object) [
|
|
'type' => 'blank node', 'value' => $namer->getName()];
|
|
$triples[] = (object) [
|
|
'subject' => $subject,
|
|
'predicate' => $predicate,
|
|
'object' => $blank_node];
|
|
|
|
$subject = $blank_node;
|
|
$predicate = $first;
|
|
$object = $this->_objectToRDF($item);
|
|
// skip null objects (they are relative IRIs)
|
|
if ($object) {
|
|
$triples[] = (object) [
|
|
'subject' => $subject,
|
|
'predicate' => $predicate,
|
|
'object' => $object];
|
|
}
|
|
|
|
$predicate = $rest;
|
|
}
|
|
|
|
$triples[] = (object) [
|
|
'subject' => $subject, 'predicate' => $predicate, 'object' => $nil];
|
|
}
|
|
|
|
/**
|
|
* Converts a JSON-LD value object to an RDF literal or a JSON-LD string or
|
|
* node object to an RDF resource.
|
|
*
|
|
* @param mixed $item the JSON-LD value or node object.
|
|
*
|
|
* @return stdClass the RDF literal or RDF resource.
|
|
*/
|
|
protected function _objectToRDF($item)
|
|
{
|
|
$object = new stdClass();
|
|
|
|
if (self::_isValue($item)) {
|
|
$object->type = 'literal';
|
|
$value = $item->{'@value'};
|
|
$datatype = property_exists($item, '@type') ? $item->{'@type'} : null;
|
|
|
|
// convert to XSD datatypes as appropriate
|
|
if (is_bool($value)) {
|
|
$object->value = ($value ? 'true' : 'false');
|
|
$object->datatype = $datatype ? $datatype : self::XSD_BOOLEAN;
|
|
} else if (is_double($value) || $datatype == self::XSD_DOUBLE) {
|
|
// canonical double representation
|
|
$object->value = preg_replace(
|
|
'/(\d)0*E\+?/', '$1E', sprintf('%1.15E', $value));
|
|
$object->datatype = $datatype ? $datatype : self::XSD_DOUBLE;
|
|
} else if (is_integer($value)) {
|
|
$object->value = strval($value);
|
|
$object->datatype = $datatype ? $datatype : self::XSD_INTEGER;
|
|
} else if (property_exists($item, '@language')) {
|
|
$object->value = $value;
|
|
$object->datatype = $datatype ? $datatype : self::RDF_LANGSTRING;
|
|
$object->language = $item->{'@language'};
|
|
} else {
|
|
$object->value = $value;
|
|
$object->datatype = $datatype ? $datatype : self::XSD_STRING;
|
|
}
|
|
} else {
|
|
// convert string/node object to RDF
|
|
$id = is_object($item) ? $item->{'@id'} : $item;
|
|
$object->type = (strpos($id, '_:') === 0) ? 'blank node' : 'IRI';
|
|
$object->value = $id;
|
|
}
|
|
|
|
// skip relative IRIs
|
|
if ($object->type === 'IRI' && !self::_isAbsoluteIri($object->value)) {
|
|
return null;
|
|
}
|
|
|
|
return $object;
|
|
}
|
|
|
|
/**
|
|
* Converts an RDF triple object to a JSON-LD object.
|
|
*
|
|
* @param stdClass $o the RDF triple object to convert.
|
|
* @param bool $use_native_types true to output native types, false not to.
|
|
*
|
|
* @return stdClass the JSON-LD object.
|
|
*/
|
|
protected function _RDFToObject($o, $use_native_types)
|
|
{
|
|
// convert IRI/blank node object to JSON-LD
|
|
if ($o->type === 'IRI' || $o->type === 'blank node') {
|
|
return (object) ['@id' => $o->value];
|
|
}
|
|
|
|
// convert literal object to JSON-LD
|
|
$rval = (object) ['@value' => $o->value];
|
|
|
|
if (property_exists($o, 'language')) {
|
|
// add language
|
|
$rval->{'@language'} = $o->language;
|
|
} else {
|
|
// add datatype
|
|
$type = $o->datatype;
|
|
// use native types for certain xsd types
|
|
if ($use_native_types) {
|
|
if ($type === self::XSD_BOOLEAN) {
|
|
if ($rval->{'@value'} === 'true') {
|
|
$rval->{'@value'} = true;
|
|
} else if ($rval->{'@value'} === 'false') {
|
|
$rval->{'@value'} = false;
|
|
}
|
|
} else if (is_numeric($rval->{'@value'})) {
|
|
if ($type === self::XSD_INTEGER) {
|
|
$i = intval($rval->{'@value'});
|
|
if (strval($i) === $rval->{'@value'}) {
|
|
$rval->{'@value'} = $i;
|
|
}
|
|
} else if ($type === self::XSD_DOUBLE) {
|
|
$rval->{'@value'} = doubleval($rval->{'@value'});
|
|
}
|
|
}
|
|
// do not add native type
|
|
if (!in_array($type, [
|
|
self::XSD_BOOLEAN, self::XSD_INTEGER, self::XSD_DOUBLE,
|
|
self::XSD_STRING])) {
|
|
$rval->{'@type'} = $type;
|
|
}
|
|
} else if ($type !== self::XSD_STRING) {
|
|
$rval->{'@type'} = $type;
|
|
}
|
|
}
|
|
|
|
return $rval;
|
|
}
|
|
|
|
/**
|
|
* Recursively flattens the subjects in the given JSON-LD expanded input
|
|
* into a node map.
|
|
*
|
|
* @param mixed $input the JSON-LD expanded input.
|
|
* @param stdClass $graphs a map of graph name to subject map.
|
|
* @param string $graph the name of the current graph.
|
|
* @param UniqueNamer $namer the blank node namer.
|
|
* @param mixed $name the name assigned to the current input if it is a bnode.
|
|
* @param mixed $list the list to append to, null for none.
|
|
*/
|
|
protected function _createNodeMap(
|
|
$input, $graphs, $graph, $namer, $name = null, $list = null)
|
|
{
|
|
// recurse through array
|
|
if (is_array($input)) {
|
|
foreach ($input as $e) {
|
|
$this->_createNodeMap($e, $graphs, $graph, $namer, null, $list);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// add non-object to list
|
|
if (!is_object($input)) {
|
|
if ($list !== null) {
|
|
$list[] = $input;
|
|
}
|
|
return;
|
|
}
|
|
|
|
// add values to list
|
|
if (self::_isValue($input)) {
|
|
if (property_exists($input, '@type')) {
|
|
$type = $input->{'@type'};
|
|
// rename @type blank node
|
|
if (strpos($type, '_:') === 0) {
|
|
$type = $input->{'@type'} = $namer->getName($type);
|
|
}
|
|
}
|
|
if ($list !== null) {
|
|
$list[] = $input;
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Note: At this point, input must be a subject.
|
|
// spec requires @type to be named first, so assign names early
|
|
if (property_exists($input, '@type')) {
|
|
foreach ($input->{'@type'} as $type) {
|
|
if (strpos($type, '_:') === 0) {
|
|
$namer->getName($type);
|
|
}
|
|
}
|
|
}
|
|
|
|
// get name for subject
|
|
if ($name === null) {
|
|
if (property_exists($input, '@id')) {
|
|
$name = $input->{'@id'};
|
|
}
|
|
if (self::_isBlankNode($input)) {
|
|
$name = $namer->getName($name);
|
|
}
|
|
}
|
|
|
|
// add subject reference to list
|
|
if ($list !== null) {
|
|
$list[] = (object) ['@id' => $name];
|
|
}
|
|
|
|
// create new subject or merge into existing one
|
|
if (!property_exists($graphs, $graph)) {
|
|
$graphs->{$graph} = new stdClass();
|
|
}
|
|
$subjects = $graphs->{$graph};
|
|
if (!property_exists($subjects, $name)) {
|
|
if ($name === '') {
|
|
$subjects->{'"'} = new stdClass();
|
|
} else {
|
|
$subjects->{$name} = new stdClass();
|
|
}
|
|
}
|
|
if ($name === '') {
|
|
$subject = $subjects->{'"'};
|
|
} else {
|
|
$subject = $subjects->{$name};
|
|
}
|
|
$subject->{'@id'} = $name;
|
|
$properties = array_keys((array) $input);
|
|
sort($properties);
|
|
foreach ($properties as $property) {
|
|
// skip @id
|
|
if ($property === '@id') {
|
|
continue;
|
|
}
|
|
|
|
// handle reverse properties
|
|
if ($property === '@reverse') {
|
|
$referenced_node = (object) ['@id' => $name];
|
|
$reverse_map = $input->{'@reverse'};
|
|
foreach ($reverse_map as $reverse_property => $items) {
|
|
foreach ($items as $item) {
|
|
$item_name = null;
|
|
if (property_exists($item, '@id')) {
|
|
$item_name = $item->{'@id'};
|
|
}
|
|
if (self::_isBlankNode($item)) {
|
|
$item_name = $namer->getName($item_name);
|
|
}
|
|
$this->_createNodeMap($item, $graphs, $graph, $namer, $item_name);
|
|
if ($item_name === '') {
|
|
$item_name = '"';
|
|
}
|
|
self::addValue(
|
|
$subjects->{$item_name}, $reverse_property, $referenced_node, ['propertyIsArray' => true, 'allowDuplicate' => false]);
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// recurse into graph
|
|
if ($property === '@graph') {
|
|
// add graph subjects map entry
|
|
if (!property_exists($graphs, $name)) {
|
|
// FIXME: temporary hack to avoid empty property bug
|
|
if (!$name) {
|
|
$name = '"';
|
|
}
|
|
$graphs->{$name} = new stdClass();
|
|
}
|
|
$g = ($graph === '@merged') ? $graph : $name;
|
|
$this->_createNodeMap(
|
|
$input->{$property}, $graphs, $g, $namer, null, null);
|
|
continue;
|
|
}
|
|
|
|
// copy non-@type keywords
|
|
if ($property !== '@type' && self::_isKeyword($property)) {
|
|
if ($property === '@index' && property_exists($subject, '@index') &&
|
|
($input->{'@index'} !== $subject->{'@index'} ||
|
|
$input->{'@index'}->{'@id'} !== $subject->{'@index'}->{'@id'})) {
|
|
throw new JsonLdException(
|
|
'Invalid JSON-LD syntax; conflicting @index property detected.', 'jsonld.SyntaxError', 'conflicting indexes', ['subject' => $subject]);
|
|
}
|
|
$subject->{$property} = $input->{$property};
|
|
continue;
|
|
}
|
|
|
|
// iterate over objects
|
|
$objects = $input->{$property};
|
|
|
|
// if property is a bnode, assign it a new id
|
|
if (strpos($property, '_:') === 0) {
|
|
$property = $namer->getName($property);
|
|
}
|
|
|
|
// ensure property is added for empty arrays
|
|
if (count($objects) === 0) {
|
|
self::addValue(
|
|
$subject, $property, [], ['propertyIsArray' => true]);
|
|
continue;
|
|
}
|
|
foreach ($objects as $o) {
|
|
if ($property === '@type') {
|
|
// rename @type blank nodes
|
|
$o = (strpos($o, '_:') === 0) ? $namer->getName($o) : $o;
|
|
}
|
|
|
|
// handle embedded subject or subject reference
|
|
if (self::_isSubject($o) || self::_isSubjectReference($o)) {
|
|
// rename blank node @id
|
|
$id = property_exists($o, '@id') ? $o->{'@id'} : null;
|
|
if (self::_isBlankNode($o)) {
|
|
$id = $namer->getName($id);
|
|
}
|
|
|
|
// add reference and recurse
|
|
self::addValue(
|
|
$subject, $property, (object) ['@id' => $id], ['propertyIsArray' => true, 'allowDuplicate' => false]);
|
|
$this->_createNodeMap($o, $graphs, $graph, $namer, $id, null);
|
|
} else if (self::_isList($o)) {
|
|
// handle @list
|
|
$_list = new ArrayObject();
|
|
$this->_createNodeMap(
|
|
$o->{'@list'}, $graphs, $graph, $namer, $name, $_list);
|
|
$o = (object) ['@list' => (array) $_list];
|
|
self::addValue(
|
|
$subject, $property, $o, ['propertyIsArray' => true, 'allowDuplicate' => false]);
|
|
} else {
|
|
// handle @value
|
|
$this->_createNodeMap($o, $graphs, $graph, $namer, $name, null);
|
|
self::addValue(
|
|
$subject, $property, $o, ['propertyIsArray' => true, 'allowDuplicate' => false]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Frames subjects according to the given frame.
|
|
*
|
|
* @param stdClass $state the current framing state.
|
|
* @param array $subjects the subjects to filter.
|
|
* @param array $frame the frame.
|
|
* @param mixed $parent the parent subject or top-level array.
|
|
* @param mixed $property the parent property, initialized to null.
|
|
*/
|
|
protected function _matchFrame(
|
|
$state, $subjects, $frame, $parent, $property)
|
|
{
|
|
// validate the frame
|
|
$this->_validateFrame($frame);
|
|
$frame = $frame[0];
|
|
|
|
// get flags for current frame
|
|
$options = $state->options;
|
|
$flags = [
|
|
'embed' => $this->_getFrameFlag($frame, $options, 'embed'),
|
|
'explicit' => $this->_getFrameFlag($frame, $options, 'explicit'),
|
|
'requireAll' => $this->_getFrameFlag($frame, $options, 'requireAll')];
|
|
|
|
// filter out subjects that match the frame
|
|
$matches = $this->_filterSubjects($state, $subjects, $frame, $flags);
|
|
|
|
// add matches to output
|
|
foreach ($matches as $id => $subject) {
|
|
if ($flags['embed'] === '@link' && property_exists($state->link, $id)) {
|
|
// TODO: may want to also match an existing linked subject against
|
|
// the current frame ... so different frames could produce different
|
|
// subjects that are only shared in-memory when the frames are the same
|
|
// add existing linked subject
|
|
$this->_addFrameOutput($parent, $property, $state->link->{$id});
|
|
continue;
|
|
}
|
|
|
|
/* Note: In order to treat each top-level match as a compartmentalized
|
|
result, clear the unique embedded subjects map when the property is null,
|
|
which only occurs at the top-level. */
|
|
if ($property === null) {
|
|
$state->uniqueEmbeds = new stdClass();
|
|
}
|
|
|
|
// start output for subject
|
|
$output = new stdClass();
|
|
$output->{'@id'} = $id;
|
|
$state->link->{$id} = $output;
|
|
|
|
// if embed is @never or if a circular reference would be created by an
|
|
// embed, the subject cannot be embedded, just add the reference;
|
|
// note that a circular reference won't occur when the embed flag is
|
|
// `@link` as the above check will short-circuit before reaching this point
|
|
if ($flags['embed'] === '@never' ||
|
|
$this->_createsCircularReference($subject, $state->subjectStack)) {
|
|
$this->_addFrameOutput($parent, $property, $output);
|
|
continue;
|
|
}
|
|
|
|
// if only the last match should be embedded
|
|
if ($flags['embed'] === '@last') {
|
|
// remove any existing embed
|
|
if (property_exists($state->uniqueEmbeds, $id)) {
|
|
$this->_removeEmbed($state, $id);
|
|
}
|
|
$state->uniqueEmbeds->{$id} = [
|
|
'parent' => $parent, 'property' => $property];
|
|
}
|
|
|
|
// push matching subject onto stack to enable circular embed checks
|
|
$state->subjectStack[] = $subject;
|
|
|
|
// iterate over subject properties
|
|
$props = array_keys((array) $subject);
|
|
sort($props);
|
|
foreach ($props as $prop) {
|
|
// copy keywords to output
|
|
if (self::_isKeyword($prop)) {
|
|
$output->{$prop} = self::copy($subject->{$prop});
|
|
continue;
|
|
}
|
|
|
|
// explicit is on and property isn't in the frame, skip processing
|
|
if ($flags['explicit'] && !property_exists($frame, $prop)) {
|
|
continue;
|
|
}
|
|
|
|
// add objects
|
|
$objects = $subject->{$prop};
|
|
foreach ($objects as $o) {
|
|
// recurse into list
|
|
if (self::_isList($o)) {
|
|
// add empty list
|
|
$list = (object) ['@list' => []];
|
|
$this->_addFrameOutput($output, $prop, $list);
|
|
|
|
// add list objects
|
|
$src = $o->{'@list'};
|
|
foreach ($src as $o) {
|
|
if (self::_isSubjectReference($o)) {
|
|
// recurse into subject reference
|
|
$subframe = (property_exists($frame, $prop) ?
|
|
$frame->{$prop}[0]->{'@list'} :
|
|
$this->_createImplicitFrame($flags));
|
|
$this->_matchFrame(
|
|
$state, [$o->{'@id'}], $subframe, $list, '@list');
|
|
} else {
|
|
// include other values automatically
|
|
$this->_addFrameOutput($list, '@list', self::copy($o));
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (self::_isSubjectReference($o)) {
|
|
// recurse into subject reference
|
|
$subframe = (property_exists($frame, $prop) ?
|
|
$frame->{$prop} : $this->_createImplicitFrame($flags));
|
|
$this->_matchFrame(
|
|
$state, [$o->{'@id'}], $subframe, $output, $prop);
|
|
} else {
|
|
// include other values automatically
|
|
$this->_addFrameOutput($output, $prop, self::copy($o));
|
|
}
|
|
}
|
|
}
|
|
|
|
// handle defaults
|
|
$props = array_keys((array) $frame);
|
|
sort($props);
|
|
foreach ($props as $prop) {
|
|
// skip keywords
|
|
if (self::_isKeyword($prop)) {
|
|
continue;
|
|
}
|
|
|
|
// if omit default is off, then include default values for properties
|
|
// that appear in the next frame but are not in the matching subject
|
|
$next = $frame->{$prop}[0];
|
|
$omit_default_on = $this->_getFrameFlag(
|
|
$next, $options, 'omitDefault');
|
|
if (!$omit_default_on && !property_exists($output, $prop)) {
|
|
$preserve = '@null';
|
|
if (property_exists($next, '@default')) {
|
|
$preserve = self::copy($next->{'@default'});
|
|
}
|
|
$preserve = self::arrayify($preserve);
|
|
$output->{$prop} = [(object) ['@preserve' => $preserve]];
|
|
}
|
|
}
|
|
|
|
// add output to parent
|
|
$this->_addFrameOutput($parent, $property, $output);
|
|
|
|
// pop matching subject from circular ref-checking stack
|
|
array_pop($state->subjectStack);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Creates an implicit frame when recursing through subject matches. If
|
|
* a frame doesn't have an explicit frame for a particular property, then
|
|
* a wildcard child frame will be created that uses the same flags that the
|
|
* parent frame used.
|
|
*
|
|
* @param array $flags the current framing flags.
|
|
*
|
|
* @return array the implicit frame.
|
|
*/
|
|
function _createImplicitFrame($flags)
|
|
{
|
|
$frame = new stdClass();
|
|
foreach ($flags as $key => $value) {
|
|
$frame->{'@' . $key} = [$flags[$key]];
|
|
}
|
|
return [$frame];
|
|
}
|
|
|
|
/**
|
|
* Checks the current subject stack to see if embedding the given subject
|
|
* would cause a circular reference.
|
|
*
|
|
* @param stdClass $subject_to_embed the subject to embed.
|
|
* @param array $subject_stack the current stack of subjects.
|
|
*
|
|
* @return bool true if a circular reference would be created, false if not.
|
|
*/
|
|
function _createsCircularReference($subject_to_embed, $subject_stack)
|
|
{
|
|
for ($i = count($subject_stack) - 1; $i >= 0; --$i) {
|
|
if ($subject_stack[$i]->{'@id'} === $subject_to_embed->{'@id'}) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Gets the frame flag value for the given flag name.
|
|
*
|
|
* @param stdClass $frame the frame.
|
|
* @param stdClass $options the framing options.
|
|
* @param string $name the flag name.
|
|
*
|
|
* @return mixed $the flag value.
|
|
*/
|
|
protected function _getFrameFlag($frame, $options, $name)
|
|
{
|
|
$flag = "@$name";
|
|
$rval = (property_exists($frame, $flag) ?
|
|
$frame->{$flag}[0] : $options[$name]);
|
|
if ($name === 'embed') {
|
|
// default is "@last"
|
|
// backwards-compatibility support for "embed" maps:
|
|
// true => "@last"
|
|
// false => "@never"
|
|
if ($rval === true) {
|
|
$rval = '@last';
|
|
} else if ($rval === false) {
|
|
$rval = '@never';
|
|
} else if ($rval !== '@always' && $rval !== '@never' &&
|
|
$rval !== '@link') {
|
|
$rval = '@last';
|
|
}
|
|
}
|
|
return $rval;
|
|
}
|
|
|
|
/**
|
|
* Validates a JSON-LD frame, throwing an exception if the frame is invalid.
|
|
*
|
|
* @param array $frame the frame to validate.
|
|
*/
|
|
protected function _validateFrame($frame)
|
|
{
|
|
if (!is_array($frame) || count($frame) !== 1 || !is_object($frame[0])) {
|
|
throw new JsonLdException(
|
|
'Invalid JSON-LD syntax; a JSON-LD frame must be a single object.', 'jsonld.SyntaxError', null, ['frame' => $frame]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns a map of all of the subjects that match a parsed frame.
|
|
*
|
|
* @param stdClass $state the current framing state.
|
|
* @param array $subjects the set of subjects to filter.
|
|
* @param stdClass $frame the parsed frame.
|
|
* @param array $flags the frame flags.
|
|
*
|
|
* @return stdClass all of the matched subjects.
|
|
*/
|
|
protected function _filterSubjects($state, $subjects, $frame, $flags)
|
|
{
|
|
$rval = new stdClass();
|
|
sort($subjects);
|
|
foreach ($subjects as $id) {
|
|
$subject = $state->subjects->{$id};
|
|
if ($this->_filterSubject($subject, $frame, $flags)) {
|
|
$rval->{$id} = $subject;
|
|
}
|
|
}
|
|
return $rval;
|
|
}
|
|
|
|
/**
|
|
* Returns true if the given subject matches the given frame.
|
|
*
|
|
* @param stdClass $subject the subject to check.
|
|
* @param stdClass $frame the frame to check.
|
|
* @param array $flags the frame flags.
|
|
*
|
|
* @return bool true if the subject matches, false if not.
|
|
*/
|
|
protected function _filterSubject($subject, $frame, $flags)
|
|
{
|
|
// check @type (object value means 'any' type, fall through to ducktyping)
|
|
if (property_exists($frame, '@type') &&
|
|
!(count($frame->{'@type'}) === 1 && is_object($frame->{'@type'}[0]))) {
|
|
$types = $frame->{'@type'};
|
|
foreach ($types as $type) {
|
|
// any matching @type is a match
|
|
if (self::hasValue($subject, '@type', $type)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// check ducktype
|
|
$wildcard = true;
|
|
$matches_some = false;
|
|
foreach ($frame as $k => $v) {
|
|
if (self::_isKeyword($k)) {
|
|
// skip non-@id and non-@type
|
|
if ($k !== '@id' && $k !== '@type') {
|
|
continue;
|
|
}
|
|
$wildcard = false;
|
|
|
|
// check @id for a specific @id value
|
|
if ($k === '@id' && is_string($v)) {
|
|
if (!property_exists($subject, $k) || $subject->{$k} !== $v) {
|
|
return false;
|
|
}
|
|
$matches_some = true;
|
|
continue;
|
|
}
|
|
}
|
|
|
|
$wildcard = false;
|
|
|
|
if (property_exists($subject, $k)) {
|
|
// $v === [] means do not match if property is present
|
|
if (is_array($v) && count($v) === 0) {
|
|
return false;
|
|
}
|
|
$matches_some = true;
|
|
continue;
|
|
}
|
|
|
|
// all properties must match to be a duck unless a @default is specified
|
|
$has_default = (is_array($v) && count($v) === 1 && is_object($v[0]) &&
|
|
property_exists($v[0], '@default'));
|
|
if ($flags['requireAll'] && !$has_default) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// return true if wildcard or subject matches some properties
|
|
return $wildcard || $matches_some;
|
|
}
|
|
|
|
/**
|
|
* Removes an existing embed.
|
|
*
|
|
* @param stdClass $state the current framing state.
|
|
* @param string $id the @id of the embed to remove.
|
|
*/
|
|
protected function _removeEmbed($state, $id)
|
|
{
|
|
// get existing embed
|
|
$embeds = $state->uniqueEmbeds;
|
|
$embed = $embeds->{$id};
|
|
$property = $embed['property'];
|
|
|
|
// create reference to replace embed
|
|
$subject = (object) ['@id' => $id];
|
|
|
|
// remove existing embed
|
|
if (is_array($embed->parent)) {
|
|
// replace subject with reference
|
|
foreach ($embed->parent as $i => $parent) {
|
|
if (self::compareValues($parent, $subject)) {
|
|
$embed->parent[$i] = $subject;
|
|
break;
|
|
}
|
|
}
|
|
} else {
|
|
// replace subject with reference
|
|
$use_array = is_array($embed->parent->{$property});
|
|
self::removeValue($embed->parent, $property, $subject, ['propertyIsArray' => $use_array]);
|
|
self::addValue($embed->parent, $property, $subject, ['propertyIsArray' => $use_array]);
|
|
}
|
|
|
|
// recursively remove dependent dangling embeds
|
|
$removeDependents = function($id) {
|
|
// get embed keys as a separate array to enable deleting keys in map
|
|
$ids = array_keys((array) $embeds);
|
|
foreach ($ids as $next) {
|
|
if (property_exists($embeds, $next) &&
|
|
is_object($embeds->{$next}->parent) &&
|
|
$embeds->{$next}->parent->{'@id'} === $id) {
|
|
unset($embeds->{$next});
|
|
$removeDependents($next);
|
|
}
|
|
}
|
|
};
|
|
$removeDependents($id);
|
|
}
|
|
|
|
/**
|
|
* Adds framing output to the given parent.
|
|
*
|
|
* @param mixed $parent the parent to add to.
|
|
* @param string $property the parent property.
|
|
* @param mixed $output the output to add.
|
|
*/
|
|
protected function _addFrameOutput($parent, $property, $output)
|
|
{
|
|
if (is_object($parent) && !($parent instanceof ArrayObject)) {
|
|
self::addValue(
|
|
$parent, $property, $output, ['propertyIsArray' => true]);
|
|
} else {
|
|
$parent[] = $output;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Removes the @preserve keywords as the last step of the framing algorithm.
|
|
*
|
|
* @param stdClass $ctx the active context used to compact the input.
|
|
* @param mixed $input the framed, compacted output.
|
|
* @param array $options the compaction options used.
|
|
*
|
|
* @return mixed the resulting output.
|
|
*/
|
|
protected function _removePreserve($ctx, $input, $options)
|
|
{
|
|
// recurse through arrays
|
|
if (is_array($input)) {
|
|
$output = [];
|
|
foreach ($input as $e) {
|
|
$result = $this->_removePreserve($ctx, $e, $options);
|
|
// drop nulls from arrays
|
|
if ($result !== null) {
|
|
$output[] = $result;
|
|
}
|
|
}
|
|
$input = $output;
|
|
} else if (is_object($input)) {
|
|
// remove @preserve
|
|
if (property_exists($input, '@preserve')) {
|
|
if ($input->{'@preserve'} === '@null') {
|
|
return null;
|
|
}
|
|
return $input->{'@preserve'};
|
|
}
|
|
|
|
// skip @values
|
|
if (self::_isValue($input)) {
|
|
return $input;
|
|
}
|
|
|
|
// recurse through @lists
|
|
if (self::_isList($input)) {
|
|
$input->{'@list'} = $this->_removePreserve(
|
|
$ctx, $input->{'@list'}, $options);
|
|
return $input;
|
|
}
|
|
|
|
// handle in-memory linked nodes
|
|
$id_alias = $this->_compactIri($ctx, '@id');
|
|
if (property_exists($input, $id_alias)) {
|
|
$id = $input->{$id_alias};
|
|
if (isset($options['link'][$id])) {
|
|
$idx = array_search($input, $options['link'][$id]);
|
|
if ($idx === false) {
|
|
// prevent circular visitation
|
|
$options['link'][$id][] = $input;
|
|
} else {
|
|
// already visited
|
|
return $options['link'][$id][$idx];
|
|
}
|
|
} else {
|
|
// prevent circular visitation
|
|
$options['link'][$id] = [$input];
|
|
}
|
|
}
|
|
|
|
// recurse through properties
|
|
foreach ($input as $prop => $v) {
|
|
$result = $this->_removePreserve($ctx, $v, $options);
|
|
$container = self::getContextValue($ctx, $prop, '@container');
|
|
if ($options['compactArrays'] &&
|
|
is_array($result) && count($result) === 1 &&
|
|
$container !== '@set' && $container !== '@list') {
|
|
$result = $result[0];
|
|
}
|
|
$input->{$prop} = $result;
|
|
}
|
|
}
|
|
return $input;
|
|
}
|
|
|
|
/**
|
|
* Compares two RDF triples for equality.
|
|
*
|
|
* @param stdClass $t1 the first triple.
|
|
* @param stdClass $t2 the second triple.
|
|
*
|
|
* @return bool if the triples are the same, false if not.
|
|
*/
|
|
protected static function _compareRDFTriples($t1, $t2)
|
|
{
|
|
foreach (['subject', 'predicate', 'object'] as $attr) {
|
|
if ($t1->{$attr}->type !== $t2->{$attr}->type ||
|
|
$t1->{$attr}->value !== $t2->{$attr}->value) {
|
|
return false;
|
|
}
|
|
}
|
|
if (property_exists($t1->object, 'language') !== property_exists($t1->object, 'language')) {
|
|
return false;
|
|
}
|
|
if (property_exists($t1->object, 'language') &&
|
|
$t1->object->language !== $t2->object->language) {
|
|
return false;
|
|
}
|
|
if (property_exists($t1->object, 'datatype') &&
|
|
$t1->object->datatype !== $t2->object->datatype) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Hashes all of the quads about a blank node.
|
|
*
|
|
* @param string $id the ID of the bnode to hash quads for.
|
|
* @param stdClass $bnodes the mapping of bnodes to quads.
|
|
* @param UniqueNamer $namer the canonical bnode namer.
|
|
*
|
|
* @return string the new hash.
|
|
*/
|
|
protected function _hashQuads($id, $bnodes, $namer)
|
|
{
|
|
// return cached hash
|
|
if (property_exists($bnodes->{$id}, 'hash')) {
|
|
return $bnodes->{$id}->hash;
|
|
}
|
|
|
|
// serialize all of bnode's quads
|
|
$quads = $bnodes->{$id}->quads;
|
|
$nquads = [];
|
|
foreach ($quads as $quad) {
|
|
$nquads[] = $this->toNQuad($quad, property_exists($quad, 'name') ?
|
|
$quad->name->value : null, $id);
|
|
}
|
|
|
|
// sort serialized quads
|
|
sort($nquads);
|
|
|
|
// cache and return hashed quads
|
|
$hash = $bnodes->{$id}->hash = hash('sha256', implode($nquads));
|
|
return $hash;
|
|
}
|
|
|
|
/**
|
|
* Produces a hash for the paths of adjacent bnodes for a bnode,
|
|
* incorporating all information about its subgraph of bnodes. This
|
|
* method will recursively pick adjacent bnode permutations that produce the
|
|
* lexicographically-least 'path' serializations.
|
|
*
|
|
* @param string $id the ID of the bnode to hash paths for.
|
|
* @param stdClass $bnodes the map of bnode quads.
|
|
* @param UniqueNamer $namer the canonical bnode namer.
|
|
* @param UniqueNamer $path_namer the namer used to assign names to adjacent
|
|
* bnodes.
|
|
*
|
|
* @return stdClass the hash and path namer used.
|
|
*/
|
|
protected function _hashPaths($id, $bnodes, $namer, $path_namer)
|
|
{
|
|
// create SHA-1 digest
|
|
$md = hash_init('sha256');
|
|
|
|
// group adjacent bnodes by hash, keep properties and references separate
|
|
$groups = new stdClass();
|
|
$quads = $bnodes->{$id}->quads;
|
|
foreach ($quads as $quad) {
|
|
// get adjacent bnode
|
|
$bnode = $this->_getAdjacentBlankNodeName($quad->subject, $id);
|
|
if ($bnode !== null) {
|
|
// normal property
|
|
$direction = 'p';
|
|
} else {
|
|
$bnode = $this->_getAdjacentBlankNodeName($quad->object, $id);
|
|
if ($bnode !== null) {
|
|
// reverse property
|
|
$direction = 'r';
|
|
}
|
|
}
|
|
if ($bnode !== null) {
|
|
// get bnode name (try canonical, path, then hash)
|
|
if ($namer->isNamed($bnode)) {
|
|
$name = $namer->getName($bnode);
|
|
} else if ($path_namer->isNamed($bnode)) {
|
|
$name = $path_namer->getName($bnode);
|
|
} else {
|
|
$name = $this->_hashQuads($bnode, $bnodes, $namer);
|
|
}
|
|
|
|
// hash direction, property, and bnode name/hash
|
|
$group_md = hash_init('sha256');
|
|
hash_update($group_md, $direction);
|
|
hash_update($group_md, $quad->predicate->value);
|
|
hash_update($group_md, $name);
|
|
$group_hash = hash_final($group_md);
|
|
|
|
// add bnode to hash group
|
|
if (property_exists($groups, $group_hash)) {
|
|
$groups->{$group_hash}[] = $bnode;
|
|
} else {
|
|
$groups->{$group_hash} = [$bnode];
|
|
}
|
|
}
|
|
}
|
|
|
|
// iterate over groups in sorted hash order
|
|
$group_hashes = array_keys((array) $groups);
|
|
sort($group_hashes);
|
|
foreach ($group_hashes as $group_hash) {
|
|
// digest group hash
|
|
hash_update($md, $group_hash);
|
|
|
|
// choose a path and namer from the permutations
|
|
$chosen_path = null;
|
|
$chosen_namer = null;
|
|
$permutator = new Permutator($groups->{$group_hash});
|
|
while ($permutator->hasNext()) {
|
|
$permutation = $permutator->next();
|
|
$path_namer_copy = clone $path_namer;
|
|
|
|
// build adjacent path
|
|
$path = '';
|
|
$skipped = false;
|
|
$recurse = [];
|
|
foreach ($permutation as $bnode) {
|
|
// use canonical name if available
|
|
if ($namer->isNamed($bnode)) {
|
|
$path .= $namer->getName($bnode);
|
|
} else {
|
|
// recurse if bnode isn't named in the path yet
|
|
if (!$path_namer_copy->isNamed($bnode)) {
|
|
$recurse[] = $bnode;
|
|
}
|
|
$path .= $path_namer_copy->getName($bnode);
|
|
}
|
|
|
|
// skip permutation if path is already >= chosen path
|
|
if ($chosen_path !== null && strlen($path) >= strlen($chosen_path) &&
|
|
$path > $chosen_path) {
|
|
$skipped = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// recurse
|
|
if (!$skipped) {
|
|
foreach ($recurse as $bnode) {
|
|
$result = $this->_hashPaths(
|
|
$bnode, $bnodes, $namer, $path_namer_copy);
|
|
$path .= $path_namer_copy->getName($bnode);
|
|
$path .= "<{$result->hash}>";
|
|
$path_namer_copy = $result->pathNamer;
|
|
|
|
// skip permutation if path is already >= chosen path
|
|
if ($chosen_path !== null &&
|
|
strlen($path) >= strlen($chosen_path) && $path > $chosen_path) {
|
|
$skipped = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!$skipped && ($chosen_path === null || $path < $chosen_path)) {
|
|
$chosen_path = $path;
|
|
$chosen_namer = $path_namer_copy;
|
|
}
|
|
}
|
|
|
|
// digest chosen path and update namer
|
|
hash_update($md, $chosen_path);
|
|
$path_namer = $chosen_namer;
|
|
}
|
|
|
|
// return SHA-1 hash and path namer
|
|
return (object) [
|
|
'hash' => hash_final($md), 'pathNamer' => $path_namer];
|
|
}
|
|
|
|
/**
|
|
* A helper function that gets the blank node name from an RDF quad
|
|
* node (subject or object). If the node is not a blank node or its
|
|
* value does not match the given blank node ID, it will be returned.
|
|
*
|
|
* @param stdClass $node the RDF quad node.
|
|
* @param string $id the ID of the blank node to look next to.
|
|
*
|
|
* @return mixed the adjacent blank node name or null if none was found.
|
|
*/
|
|
protected function _getAdjacentBlankNodeName($node, $id)
|
|
{
|
|
if ($node->type === 'blank node' && $node->value !== $id) {
|
|
return $node->value;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Compares two strings first based on length and then lexicographically.
|
|
*
|
|
* @param string $a the first string.
|
|
* @param string $b the second string.
|
|
*
|
|
* @return integer -1 if a < b, 1 if a > b, 0 if a == b.
|
|
*/
|
|
protected function _compareShortestLeast($a, $b)
|
|
{
|
|
$len_a = strlen($a);
|
|
$len_b = strlen($b);
|
|
if ($len_a < $len_b) {
|
|
return -1;
|
|
}
|
|
if ($len_b < $len_a) {
|
|
return 1;
|
|
}
|
|
if ($a === $b) {
|
|
return 0;
|
|
}
|
|
return ($a < $b) ? -1 : 1;
|
|
}
|
|
|
|
/**
|
|
* Picks the preferred compaction term from the given inverse context entry.
|
|
*
|
|
* @param mixed $active_ctx the active context.
|
|
* @param mixed $iri the IRI to pick the term for.
|
|
* @param mixed $value the value to pick the term for.
|
|
* @param mixed $containers the preferred containers.
|
|
* @param string $type_or_language either '@type' or '@language'.
|
|
* @param string|null $type_or_language_value the preferred value for '@type' or
|
|
* '@language'.
|
|
*
|
|
* @return mixed the preferred term.
|
|
*/
|
|
protected function _selectTerm(
|
|
$active_ctx, $iri, $value, $containers, $type_or_language, $type_or_language_value)
|
|
{
|
|
if ($type_or_language_value === null) {
|
|
$type_or_language_value = '@null';
|
|
}
|
|
|
|
// options for the value of @type or @language
|
|
$prefs = [];
|
|
|
|
// determine prefs for @id based on whether or not value compacts to a term
|
|
if (($type_or_language_value === '@id' ||
|
|
$type_or_language_value === '@reverse') &&
|
|
self::_isSubjectReference($value)) {
|
|
// prefer @reverse first
|
|
if ($type_or_language_value === '@reverse') {
|
|
$prefs[] = '@reverse';
|
|
}
|
|
// try to compact value to a term
|
|
$term = $this->_compactIri(
|
|
$active_ctx, $value->{'@id'}, null, ['vocab' => true]);
|
|
if (property_exists($active_ctx->mappings, $term) &&
|
|
$active_ctx->mappings->{$term} &&
|
|
$active_ctx->mappings->{$term}->{'@id'} === $value->{'@id'}) {
|
|
// prefer @vocab
|
|
array_push($prefs, '@vocab', '@id');
|
|
} else {
|
|
// prefer @id
|
|
array_push($prefs, '@id', '@vocab');
|
|
}
|
|
} else {
|
|
$prefs[] = $type_or_language_value;
|
|
}
|
|
$prefs[] = '@none';
|
|
|
|
$container_map = $active_ctx->inverse->{$iri};
|
|
foreach ($containers as $container) {
|
|
// if container not available in the map, continue
|
|
if (!property_exists($container_map, $container)) {
|
|
continue;
|
|
}
|
|
|
|
$type_or_language_value_map = $container_map->{$container}->{$type_or_language};
|
|
foreach ($prefs as $pref) {
|
|
// if type/language option not available in the map, continue
|
|
if (!property_exists($type_or_language_value_map, $pref)) {
|
|
continue;
|
|
}
|
|
|
|
// select term
|
|
return $type_or_language_value_map->{$pref};
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Compacts an IRI or keyword into a term or prefix if it can be. If the
|
|
* IRI has an associated value it may be passed.
|
|
*
|
|
* @param stdClass $active_ctx the active context to use.
|
|
* @param string $iri the IRI to compact.
|
|
* @param mixed $value the value to check or null.
|
|
* @param array $relative_to options for how to compact IRIs:
|
|
* vocab: true to split after @vocab, false not to.
|
|
* @param bool $reverse true if a reverse property is being compacted, false
|
|
* if not.
|
|
*
|
|
* @return string the compacted term, prefix, keyword alias, or original IRI.
|
|
*/
|
|
protected function _compactIri(
|
|
$active_ctx, $iri, $value = null, $relative_to = [], $reverse = false)
|
|
{
|
|
// can't compact null
|
|
if ($iri === null) {
|
|
return $iri;
|
|
}
|
|
|
|
$inverse_ctx = $this->_getInverseContext($active_ctx);
|
|
|
|
if (self::_isKeyword($iri)) {
|
|
// a keyword can only be compacted to simple alias
|
|
if (property_exists($inverse_ctx, $iri)) {
|
|
return $inverse_ctx->$iri->{'@none'}->{'@type'}->{'@none'};
|
|
}
|
|
return $iri;
|
|
}
|
|
|
|
if (!isset($relative_to['vocab'])) {
|
|
$relative_to['vocab'] = false;
|
|
}
|
|
|
|
// use inverse context to pick a term if iri is relative to vocab
|
|
if ($relative_to['vocab'] && property_exists($inverse_ctx, $iri)) {
|
|
$default_language = '@none';
|
|
if (property_exists($active_ctx, '@language')) {
|
|
$default_language = $active_ctx->{'@language'};
|
|
}
|
|
|
|
// prefer @index if available in value
|
|
$containers = [];
|
|
if (is_object($value) && property_exists($value, '@index')) {
|
|
$containers[] = '@index';
|
|
}
|
|
|
|
// defaults for term selection based on type/language
|
|
$type_or_language = '@language';
|
|
$type_or_language_value = '@null';
|
|
|
|
if ($reverse) {
|
|
$type_or_language = '@type';
|
|
$type_or_language_value = '@reverse';
|
|
$containers[] = '@set';
|
|
} else if (self::_isList($value)) {
|
|
// choose the most specific term that works for all elements in @list
|
|
// only select @list containers if @index is NOT in value
|
|
if (!property_exists($value, '@index')) {
|
|
$containers[] = '@list';
|
|
}
|
|
$list = $value->{'@list'};
|
|
$common_language = (count($list) === 0) ? $default_language : null;
|
|
$common_type = null;
|
|
foreach ($list as $item) {
|
|
$item_language = '@none';
|
|
$item_type = '@none';
|
|
if (self::_isValue($item)) {
|
|
if (property_exists($item, '@language')) {
|
|
$item_language = $item->{'@language'};
|
|
} else if (property_exists($item, '@type')) {
|
|
$item_type = $item->{'@type'};
|
|
} else {
|
|
// plain literal
|
|
$item_language = '@null';
|
|
}
|
|
} else {
|
|
$item_type = '@id';
|
|
}
|
|
if ($common_language === null) {
|
|
$common_language = $item_language;
|
|
} else if ($item_language !== $common_language &&
|
|
self::_isValue($item)) {
|
|
$common_language = '@none';
|
|
}
|
|
if ($common_type === null) {
|
|
$common_type = $item_type;
|
|
} else if ($item_type !== $common_type) {
|
|
$common_type = '@none';
|
|
}
|
|
// there are different languages and types in the list, so choose
|
|
// the most generic term, no need to keep iterating the list
|
|
if ($common_language === '@none' && $common_type === '@none') {
|
|
break;
|
|
}
|
|
}
|
|
if ($common_language === null) {
|
|
$common_language = '@none';
|
|
}
|
|
if ($common_type === null) {
|
|
$common_type = '@none';
|
|
}
|
|
if ($common_type !== '@none') {
|
|
$type_or_language = '@type';
|
|
$type_or_language_value = $common_type;
|
|
} else {
|
|
$type_or_language_value = $common_language;
|
|
}
|
|
} else {
|
|
if (self::_isValue($value)) {
|
|
if (property_exists($value, '@language') &&
|
|
!property_exists($value, '@index')) {
|
|
$containers[] = '@language';
|
|
$type_or_language_value = $value->{'@language'};
|
|
} else if (property_exists($value, '@type')) {
|
|
$type_or_language = '@type';
|
|
$type_or_language_value = $value->{'@type'};
|
|
}
|
|
} else {
|
|
$type_or_language = '@type';
|
|
$type_or_language_value = '@id';
|
|
}
|
|
$containers[] = '@set';
|
|
}
|
|
|
|
// do term selection
|
|
$containers[] = '@none';
|
|
$term = $this->_selectTerm(
|
|
$active_ctx, $iri, $value, $containers, $type_or_language, $type_or_language_value);
|
|
if ($term !== null) {
|
|
return $term;
|
|
}
|
|
}
|
|
|
|
// no term match, use @vocab if available
|
|
if ($relative_to['vocab']) {
|
|
if (property_exists($active_ctx, '@vocab')) {
|
|
// determine if vocab is a prefix of the iri
|
|
$vocab = $active_ctx->{'@vocab'};
|
|
if (strpos($iri, $vocab) === 0 && $iri !== $vocab) {
|
|
// use suffix as relative iri if it is not a term in the active
|
|
// context
|
|
$suffix = substr($iri, strlen($vocab));
|
|
if (!property_exists($active_ctx->mappings, $suffix)) {
|
|
return $suffix;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// no term or @vocab match, check for possible CURIEs
|
|
$choice = null;
|
|
$idx = 0;
|
|
$partial_matches = [];
|
|
$iri_map = $active_ctx->fast_curie_map;
|
|
// check for partial matches of against `iri`, which means look until
|
|
// iri.length - 1, not full length
|
|
$max_partial_length = strlen($iri) - 1;
|
|
for (; $idx < $max_partial_length && isset($iri_map[$iri[$idx]]); ++$idx) {
|
|
$iri_map = $iri_map[$iri[$idx]];
|
|
if (isset($iri_map[''])) {
|
|
$entry = $iri_map[''][0];
|
|
$entry->iri_length = $idx + 1;
|
|
$partial_matches[] = $entry;
|
|
}
|
|
}
|
|
// check partial matches in reverse order to prefer longest ones first
|
|
$partial_matches = array_reverse($partial_matches);
|
|
foreach ($partial_matches as $entry) {
|
|
$terms = $entry->terms;
|
|
foreach ($terms as $term) {
|
|
// a CURIE is usable if:
|
|
// 1. it has no mapping, OR
|
|
// 2. value is null, which means we're not compacting an @value, AND
|
|
// the mapping matches the IRI
|
|
$curie = $term . ':' . substr($iri, $entry->iri_length);
|
|
$is_usable_curie = (!property_exists($active_ctx->mappings, $curie) ||
|
|
($value === null &&
|
|
$active_ctx->mappings->{$curie}->{'@id'} === $iri));
|
|
|
|
// select curie if it is shorter or the same length but
|
|
// lexicographically less than the current choice
|
|
if ($is_usable_curie && ($choice === null ||
|
|
self::_compareShortestLeast($curie, $choice) < 0)) {
|
|
$choice = $curie;
|
|
}
|
|
}
|
|
}
|
|
|
|
// return chosen curie
|
|
if ($choice !== null) {
|
|
return $choice;
|
|
}
|
|
|
|
// compact IRI relative to base
|
|
if (!$relative_to['vocab']) {
|
|
return jsonld_remove_base($active_ctx->{'@base'}, $iri);
|
|
}
|
|
|
|
// return IRI as is
|
|
return $iri;
|
|
}
|
|
|
|
/**
|
|
* Performs value compaction on an object with '@value' or '@id' as the only
|
|
* property.
|
|
*
|
|
* @param stdClass $active_ctx the active context.
|
|
* @param string $active_property the active property that points to the
|
|
* value.
|
|
* @param mixed $value the value to compact.
|
|
*
|
|
* @return mixed the compaction result.
|
|
*/
|
|
protected function _compactValue($active_ctx, $active_property, $value)
|
|
{
|
|
// value is a @value
|
|
if (self::_isValue($value)) {
|
|
// get context rules
|
|
$type = self::getContextValue($active_ctx, $active_property, '@type');
|
|
$language = self::getContextValue(
|
|
$active_ctx, $active_property, '@language');
|
|
$container = self::getContextValue(
|
|
$active_ctx, $active_property, '@container');
|
|
|
|
// whether or not the value has an @index that must be preserved
|
|
$preserve_index = (property_exists($value, '@index') &&
|
|
$container !== '@index');
|
|
|
|
// if there's no @index to preserve
|
|
if (!$preserve_index) {
|
|
// matching @type or @language specified in context, compact value
|
|
if (self::_hasKeyValue($value, '@type', $type) ||
|
|
self::_hasKeyValue($value, '@language', $language)) {
|
|
return $value->{'@value'};
|
|
}
|
|
}
|
|
|
|
// return just the value of @value if all are true:
|
|
// 1. @value is the only key or @index isn't being preserved
|
|
// 2. there is no default language or @value is not a string or
|
|
// the key has a mapping with a null @language
|
|
$key_count = count(array_keys((array) $value));
|
|
$is_value_only_key = ($key_count === 1 ||
|
|
($key_count === 2 && property_exists($value, '@index') &&
|
|
!$preserve_index));
|
|
$has_default_language = property_exists($active_ctx, '@language');
|
|
$is_value_string = is_string($value->{'@value'});
|
|
$has_null_mapping = (
|
|
property_exists($active_ctx->mappings, $active_property) &&
|
|
$active_ctx->mappings->{$active_property} !== null &&
|
|
self::_hasKeyValue(
|
|
$active_ctx->mappings->{$active_property}, '@language', null));
|
|
if ($is_value_only_key &&
|
|
(!$has_default_language || !$is_value_string || $has_null_mapping)) {
|
|
return $value->{'@value'};
|
|
}
|
|
|
|
$rval = new stdClass();
|
|
|
|
// preserve @index
|
|
if ($preserve_index) {
|
|
$rval->{$this->_compactIri($active_ctx, '@index')} = $value->{'@index'};
|
|
}
|
|
|
|
// compact @type IRI
|
|
if (property_exists($value, '@type')) {
|
|
$rval->{$this->_compactIri($active_ctx, '@type')} = $this->_compactIri(
|
|
$active_ctx, $value->{'@type'}, null, ['vocab' => true]);
|
|
} else if (property_exists($value, '@language')) {
|
|
// alias @language
|
|
$rval->{$this->_compactIri($active_ctx, '@language')} = $value->{'@language'};
|
|
}
|
|
|
|
// alias @value
|
|
$rval->{$this->_compactIri($active_ctx, '@value')} = $value->{'@value'};
|
|
|
|
return $rval;
|
|
}
|
|
|
|
// value is a subject reference
|
|
$expanded_property = $this->_expandIri(
|
|
$active_ctx, $active_property, ['vocab' => true]);
|
|
$type = self::getContextValue($active_ctx, $active_property, '@type');
|
|
$compacted = $this->_compactIri(
|
|
$active_ctx, $value->{'@id'}, null, ['vocab' => ($type === '@vocab')]);
|
|
|
|
// compact to scalar
|
|
if ($type === '@id' || $type === '@vocab' ||
|
|
$expanded_property === '@graph') {
|
|
return $compacted;
|
|
}
|
|
|
|
$rval = (object) [
|
|
$this->_compactIri($active_ctx, '@id') => $compacted];
|
|
return $rval;
|
|
}
|
|
|
|
/**
|
|
* Creates a term definition during context processing.
|
|
*
|
|
* @param stdClass $active_ctx the current active context.
|
|
* @param stdClass $local_ctx the local context being processed.
|
|
* @param string $term the key in the local context to define the mapping for.
|
|
* @param stdClass $defined a map of defining/defined keys to detect cycles
|
|
* and prevent double definitions.
|
|
*/
|
|
protected function _createTermDefinition(
|
|
$active_ctx, $local_ctx, $term, $defined)
|
|
{
|
|
if (property_exists($defined, $term)) {
|
|
// term already defined
|
|
if ($defined->{$term}) {
|
|
return;
|
|
}
|
|
// cycle detected
|
|
throw new JsonLdException(
|
|
'Cyclical context definition detected.', 'jsonld.CyclicalContext', 'cyclic IRI mapping', ['context' => $local_ctx, 'term' => $term]);
|
|
}
|
|
|
|
// now defining term
|
|
$defined->{$term} = false;
|
|
|
|
if (self::_isKeyword($term)) {
|
|
throw new JsonLdException(
|
|
'Invalid JSON-LD syntax; keywords cannot be overridden.', 'jsonld.SyntaxError', 'keyword redefinition', ['context' => $local_ctx, 'term' => $term]);
|
|
}
|
|
|
|
// remove old mapping
|
|
if (property_exists($active_ctx->mappings, $term)) {
|
|
unset($active_ctx->mappings->{$term});
|
|
}
|
|
|
|
// get context term value
|
|
$value = $local_ctx->{$term};
|
|
|
|
// clear context entry
|
|
if ($value === null || (is_object($value) &&
|
|
self::_hasKeyValue($value, '@id', null))) {
|
|
$active_ctx->mappings->{$term} = null;
|
|
$defined->{$term} = true;
|
|
return;
|
|
}
|
|
|
|
// convert short-hand value to object w/@id
|
|
if (is_string($value)) {
|
|
$value = (object) ['@id' => $value];
|
|
}
|
|
|
|
if (!is_object($value)) {
|
|
throw new JsonLdException(
|
|
'Invalid JSON-LD syntax; @context property values must be ' .
|
|
'strings or objects.', 'jsonld.SyntaxError', 'invalid term definition', ['context' => $local_ctx]);
|
|
}
|
|
|
|
// create new mapping
|
|
$mapping = $active_ctx->mappings->{$term} = new stdClass();
|
|
$mapping->reverse = false;
|
|
|
|
if (property_exists($value, '@reverse')) {
|
|
if (property_exists($value, '@id')) {
|
|
throw new JsonLdException(
|
|
'Invalid JSON-LD syntax; a @reverse term definition must not ' .
|
|
'contain @id.', 'jsonld.SyntaxError', 'invalid reverse property', ['context' => $local_ctx]);
|
|
}
|
|
$reverse = $value->{'@reverse'};
|
|
if (!is_string($reverse)) {
|
|
throw new JsonLdException(
|
|
'Invalid JSON-LD syntax; a @context @reverse value must be a string.', 'jsonld.SyntaxError', 'invalid IRI mapping', ['context' => $local_ctx]);
|
|
}
|
|
|
|
// expand and add @id mapping
|
|
$id = $this->_expandIri(
|
|
$active_ctx, $reverse, ['vocab' => true, 'base' => false], $local_ctx, $defined);
|
|
if (!self::_isAbsoluteIri($id)) {
|
|
throw new JsonLdException(
|
|
'Invalid JSON-LD syntax; @context @reverse value must be ' .
|
|
'an absolute IRI or a blank node identifier.', 'jsonld.SyntaxError', 'invalid IRI mapping', ['context' => $local_ctx]);
|
|
}
|
|
$mapping->{'@id'} = $id;
|
|
$mapping->reverse = true;
|
|
} else if (property_exists($value, '@id')) {
|
|
$id = $value->{'@id'};
|
|
if (!is_string($id)) {
|
|
throw new JsonLdException(
|
|
'Invalid JSON-LD syntax; @context @id value must be a string.', 'jsonld.SyntaxError', 'invalid IRI mapping', ['context' => $local_ctx]);
|
|
}
|
|
if ($id !== $term) {
|
|
// add @id to mapping
|
|
$id = $this->_expandIri(
|
|
$active_ctx, $id, ['vocab' => true, 'base' => false], $local_ctx, $defined);
|
|
if (!self::_isAbsoluteIri($id) && !self::_isKeyword($id)) {
|
|
throw new JsonLdException(
|
|
'Invalid JSON-LD syntax; @context @id value must be an ' .
|
|
'absolute IRI, a blank node identifier, or a keyword.', 'jsonld.SyntaxError', 'invalid IRI mapping', ['context' => $local_ctx]);
|
|
}
|
|
$mapping->{'@id'} = $id;
|
|
}
|
|
}
|
|
|
|
// always compute whether term has a colon as an optimization for
|
|
// _compactIri
|
|
$colon = strpos($term, ':');
|
|
$mapping->_term_has_colon = ($colon !== false);
|
|
|
|
if (!property_exists($mapping, '@id')) {
|
|
// see if the term has a prefix
|
|
if ($mapping->_term_has_colon) {
|
|
$prefix = substr($term, 0, $colon);
|
|
if (property_exists($local_ctx, $prefix)) {
|
|
// define parent prefix
|
|
$this->_createTermDefinition(
|
|
$active_ctx, $local_ctx, $prefix, $defined);
|
|
}
|
|
|
|
if (property_exists($active_ctx->mappings, $prefix) &&
|
|
$active_ctx->mappings->{$prefix}) {
|
|
// set @id based on prefix parent
|
|
$suffix = substr($term, $colon + 1);
|
|
$mapping->{'@id'} = $active_ctx->mappings->{$prefix}->{'@id'} .
|
|
$suffix;
|
|
} else {
|
|
// term is an absolute IRI
|
|
$mapping->{'@id'} = $term;
|
|
}
|
|
} else {
|
|
// non-IRIs *must* define @ids if @vocab is not available
|
|
if (!property_exists($active_ctx, '@vocab')) {
|
|
throw new JsonLdException(
|
|
'Invalid JSON-LD syntax; @context terms must define an @id.', 'jsonld.SyntaxError', 'invalid IRI mapping', ['context' => $local_ctx, 'term' => $term]);
|
|
}
|
|
// prepend vocab to term
|
|
$mapping->{'@id'} = $active_ctx->{'@vocab'} . $term;
|
|
}
|
|
}
|
|
|
|
// optimization to store length of @id once for _compactIri
|
|
$mapping->_id_length = strlen($mapping->{'@id'});
|
|
|
|
// IRI mapping now defined
|
|
$defined->{$term} = true;
|
|
|
|
if (property_exists($value, '@type')) {
|
|
$type = $value->{'@type'};
|
|
if (!is_string($type)) {
|
|
throw new JsonLdException(
|
|
'Invalid JSON-LD syntax; @context @type values must be strings.', 'jsonld.SyntaxError', 'invalid type mapping', ['context' => $local_ctx]);
|
|
}
|
|
|
|
if ($type !== '@id' && $type !== '@vocab') {
|
|
// expand @type to full IRI
|
|
$type = $this->_expandIri(
|
|
$active_ctx, $type, ['vocab' => true], $local_ctx, $defined);
|
|
if (!self::_isAbsoluteIri($type)) {
|
|
throw new JsonLdException(
|
|
'Invalid JSON-LD syntax; an @context @type value must ' .
|
|
'be an absolute IRI.', 'jsonld.SyntaxError', 'invalid type mapping', ['context' => $local_ctx]);
|
|
}
|
|
if (strpos($type, '_:') === 0) {
|
|
throw new JsonLdException(
|
|
'Invalid JSON-LD syntax; an @context @type values must ' .
|
|
'be an IRI, not a blank node identifier.', 'jsonld.SyntaxError', 'invalid type mapping', ['context' => $local_ctx]);
|
|
}
|
|
}
|
|
|
|
// add @type to mapping
|
|
$mapping->{'@type'} = $type;
|
|
}
|
|
|
|
if (property_exists($value, '@container')) {
|
|
$container = $value->{'@container'};
|
|
if ($container !== '@list' && $container !== '@set' &&
|
|
$container !== '@index' && $container !== '@language') {
|
|
throw new JsonLdException(
|
|
'Invalid JSON-LD syntax; @context @container value must be ' .
|
|
'one of the following: @list, @set, @index, or @language.', 'jsonld.SyntaxError', 'invalid container mapping', ['context' => $local_ctx]);
|
|
}
|
|
if ($mapping->reverse && $container !== '@index' &&
|
|
$container !== '@set' && $container !== null) {
|
|
throw new JsonLdException(
|
|
'Invalid JSON-LD syntax; @context @container value for a @reverse ' .
|
|
'type definition must be @index or @set.', 'jsonld.SyntaxError', 'invalid reverse property', ['context' => $local_ctx]);
|
|
}
|
|
|
|
// add @container to mapping
|
|
$mapping->{'@container'} = $container;
|
|
}
|
|
|
|
if (property_exists($value, '@language') &&
|
|
!property_exists($value, '@type')) {
|
|
$language = $value->{'@language'};
|
|
if ($language !== null && !is_string($language)) {
|
|
throw new JsonLdException(
|
|
'Invalid JSON-LD syntax; @context @language value must be ' .
|
|
'a string or null.', 'jsonld.SyntaxError', 'invalid language mapping', ['context' => $local_ctx]);
|
|
}
|
|
|
|
// add @language to mapping
|
|
if ($language !== null) {
|
|
$language = strtolower($language);
|
|
}
|
|
$mapping->{'@language'} = $language;
|
|
}
|
|
|
|
// disallow aliasing @context and @preserve
|
|
$id = $mapping->{'@id'};
|
|
if ($id === '@context' || $id === '@preserve') {
|
|
throw new JsonLdException(
|
|
'Invalid JSON-LD syntax; @context and @preserve cannot be aliased.', 'jsonld.SyntaxError', 'invalid keyword alias', ['context' => $local_ctx]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Expands a string to a full IRI. The string may be a term, a prefix, a
|
|
* relative IRI, or an absolute IRI. The associated absolute IRI will be
|
|
* returned.
|
|
*
|
|
* @param stdClass $active_ctx the current active context.
|
|
* @param string $value the string to expand.
|
|
* @param array $relative_to options for how to resolve relative IRIs:
|
|
* base: true to resolve against the base IRI, false not to.
|
|
* vocab: true to concatenate after @vocab, false not to.
|
|
* @param stdClass $local_ctx the local context being processed (only given
|
|
* if called during document processing).
|
|
* @param array|stdClass|null $defined a map for tracking cycles in context definitions (only given
|
|
* if called during document processing).
|
|
*
|
|
* @return mixed the expanded value.
|
|
*/
|
|
function _expandIri(
|
|
$active_ctx, $value, $relative_to = [], $local_ctx = null, $defined = null)
|
|
{
|
|
// already expanded
|
|
if ($value === null || self::_isKeyword($value)) {
|
|
return $value;
|
|
}
|
|
|
|
// define term dependency if not defined
|
|
if ($local_ctx !== null && property_exists($local_ctx, $value) &&
|
|
!self::_hasKeyValue($defined, $value, true)) {
|
|
$this->_createTermDefinition($active_ctx, $local_ctx, $value, $defined);
|
|
}
|
|
|
|
if (isset($relative_to['vocab']) && $relative_to['vocab']) {
|
|
if (property_exists($active_ctx->mappings, $value)) {
|
|
$mapping = $active_ctx->mappings->{$value};
|
|
|
|
// value is explicitly ignored with a null mapping
|
|
if ($mapping === null) {
|
|
return null;
|
|
}
|
|
|
|
// value is a term
|
|
return $mapping->{'@id'};
|
|
}
|
|
}
|
|
|
|
// split value into prefix:suffix
|
|
$colon = strpos($value, ':');
|
|
if ($colon !== false) {
|
|
$prefix = substr($value, 0, $colon);
|
|
$suffix = substr($value, $colon + 1);
|
|
|
|
// do not expand blank nodes (prefix of '_') or already-absolute
|
|
// IRIs (suffix of '//')
|
|
if ($prefix === '_' || strpos($suffix, '//') === 0) {
|
|
return $value;
|
|
}
|
|
|
|
// prefix dependency not defined, define it
|
|
if ($local_ctx !== null && property_exists($local_ctx, $prefix)) {
|
|
$this->_createTermDefinition(
|
|
$active_ctx, $local_ctx, $prefix, $defined);
|
|
}
|
|
|
|
// use mapping if prefix is defined
|
|
if (property_exists($active_ctx->mappings, $prefix)) {
|
|
$mapping = $active_ctx->mappings->{$prefix};
|
|
if ($mapping) {
|
|
return $mapping->{'@id'} . $suffix;
|
|
}
|
|
}
|
|
|
|
// already absolute IRI
|
|
return $value;
|
|
}
|
|
|
|
// prepend vocab
|
|
if (isset($relative_to['vocab']) && $relative_to['vocab'] &&
|
|
property_exists($active_ctx, '@vocab')) {
|
|
return $active_ctx->{'@vocab'} . $value;
|
|
}
|
|
|
|
// prepend base
|
|
$rval = $value;
|
|
if (isset($relative_to['base']) && $relative_to['base']) {
|
|
$rval = jsonld_prepend_base($active_ctx->{'@base'}, $rval);
|
|
}
|
|
|
|
return $rval;
|
|
}
|
|
|
|
/**
|
|
* Finds all @context URLs in the given JSON-LD input.
|
|
*
|
|
* @param mixed $input the JSON-LD input.
|
|
* @param stdClass $urls a map of URLs (url => false/@contexts).
|
|
* @param bool $replace true to replace the URLs in the given input with
|
|
* the @contexts from the urls map, false not to.
|
|
* @param string $base the base URL to resolve relative URLs with.
|
|
*/
|
|
protected function _findContextUrls($input, $urls, $replace, $base)
|
|
{
|
|
if (is_array($input)) {
|
|
foreach ($input as $e) {
|
|
$this->_findContextUrls($e, $urls, $replace, $base);
|
|
}
|
|
} else if (is_object($input)) {
|
|
foreach ($input as $k => &$v) {
|
|
if ($k !== '@context') {
|
|
$this->_findContextUrls($v, $urls, $replace, $base);
|
|
continue;
|
|
}
|
|
|
|
// array @context
|
|
if (is_array($v)) {
|
|
$length = count($v);
|
|
for ($i = 0; $i < $length; ++$i) {
|
|
if (is_string($v[$i])) {
|
|
$url = jsonld_prepend_base($base, $v[$i]);
|
|
// replace w/@context if requested
|
|
if ($replace) {
|
|
$ctx = $urls->{$url};
|
|
if (is_array($ctx)) {
|
|
// add flattened context
|
|
array_splice($v, $i, 1, $ctx);
|
|
$i += count($ctx) - 1;
|
|
$length = count($v);
|
|
} else {
|
|
$v[$i] = $ctx;
|
|
}
|
|
} else if (!property_exists($urls, $url)) {
|
|
// @context URL found
|
|
$urls->{$url} = false;
|
|
}
|
|
}
|
|
}
|
|
} else if (is_string($v)) {
|
|
// string @context
|
|
$v = jsonld_prepend_base($base, $v);
|
|
// replace w/@context if requested
|
|
if ($replace) {
|
|
$input->{$k} = $urls->{$v};
|
|
} else if (!property_exists($urls, $v)) {
|
|
// @context URL found
|
|
$urls->{$v} = false;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Retrieves external @context URLs using the given document loader. Each
|
|
* instance of @context in the input that refers to a URL will be replaced
|
|
* with the JSON @context found at that URL.
|
|
*
|
|
* @param mixed $input the JSON-LD input with possible contexts.
|
|
* @param stdClass $cycles an object for tracking context cycles.
|
|
* @param callable $load_document(url) the document loader.
|
|
* @param string $base the base URL to resolve relative URLs against.
|
|
*/
|
|
protected function _retrieveContextUrls(
|
|
&$input, $cycles, $load_document, $base = '')
|
|
{
|
|
if (count(get_object_vars($cycles)) > self::MAX_CONTEXT_URLS) {
|
|
throw new JsonLdException(
|
|
'Maximum number of @context URLs exceeded.', 'jsonld.ContextUrlError', 'loading remote context failed', ['max' => self::MAX_CONTEXT_URLS]);
|
|
}
|
|
|
|
// for tracking the URLs to retrieve
|
|
$urls = new stdClass();
|
|
|
|
// regex for validating URLs
|
|
$regex = '/(http|https):\/\/(\w+:{0,1}\w*@)?(\S+)(:[0-9]+)?(\/|\/([\w#!:.?+=&%@!\-\/]))?/';
|
|
|
|
// find all URLs in the given input
|
|
$this->_findContextUrls($input, $urls, false, $base);
|
|
|
|
// queue all unretrieved URLs
|
|
$queue = [];
|
|
foreach ($urls as $url => $ctx) {
|
|
if ($ctx === false) {
|
|
// validate URL
|
|
if (!preg_match($regex, $url)) {
|
|
throw new JsonLdException(
|
|
'Malformed or unsupported URL.', 'jsonld.InvalidUrl', 'loading remote context failed', ['url' => $url]);
|
|
}
|
|
$queue[] = $url;
|
|
}
|
|
}
|
|
|
|
// retrieve URLs in queue
|
|
foreach ($queue as $url) {
|
|
// check for context URL cycle
|
|
if (property_exists($cycles, $url)) {
|
|
throw new JsonLdException(
|
|
'Cyclical @context URLs detected.', 'jsonld.ContextUrlError', 'recursive context inclusion', ['url' => $url]);
|
|
}
|
|
$_cycles = self::copy($cycles);
|
|
$_cycles->{$url} = true;
|
|
|
|
// retrieve URL
|
|
$remote_doc = call_user_func($load_document, $url);
|
|
$ctx = $remote_doc->document;
|
|
|
|
// parse string context as JSON
|
|
if (is_string($ctx)) {
|
|
try {
|
|
$ctx = self::_parse_json($ctx);
|
|
} catch (Exception $e) {
|
|
throw new JsonLdException(
|
|
'Could not parse JSON from URL.', 'jsonld.ParseError', 'loading remote context failed', ['url' => $url], $e);
|
|
}
|
|
}
|
|
|
|
// ensure ctx is an object
|
|
if (!is_object($ctx)) {
|
|
throw new JsonLdException(
|
|
'Derefencing a URL did not result in a valid JSON-LD object.', 'jsonld.InvalidUrl', 'invalid remote context', ['url' => $url]);
|
|
}
|
|
|
|
// use empty context if no @context key is present
|
|
if (!property_exists($ctx, '@context')) {
|
|
$ctx = (object) ['@context' => new stdClass()];
|
|
} else {
|
|
$ctx = (object) ['@context' => $ctx->{'@context'}];
|
|
}
|
|
|
|
// append context URL to context if given
|
|
if ($remote_doc->contextUrl !== null) {
|
|
$ctx->{'@context'} = self::arrayify($ctx->{'@context'});
|
|
$ctx->{'@context'}[] = $remote_doc->contextUrl;
|
|
}
|
|
|
|
// recurse
|
|
$this->_retrieveContextUrls($ctx, $_cycles, $load_document, $url);
|
|
$urls->{$url} = $ctx->{'@context'};
|
|
}
|
|
|
|
// replace all URLS in the input
|
|
$this->_findContextUrls($input, $urls, true, $base);
|
|
}
|
|
|
|
/**
|
|
* Gets the initial context.
|
|
*
|
|
* @param array $options the options to use.
|
|
* base the document base IRI.
|
|
*
|
|
* @return stdClass the initial context.
|
|
*/
|
|
protected function _getInitialContext($options)
|
|
{
|
|
return (object) [
|
|
'@base' => jsonld_parse_url($options['base']),
|
|
'mappings' => new stdClass(),
|
|
'inverse' => null];
|
|
}
|
|
|
|
/**
|
|
* Generates an inverse context for use in the compaction algorithm, if
|
|
* not already generated for the given active context.
|
|
*
|
|
* @param stdClass $active_ctx the active context to use.
|
|
*
|
|
* @return stdClass the inverse context.
|
|
*/
|
|
protected function _getInverseContext($active_ctx)
|
|
{
|
|
// inverse context already generated
|
|
if ($active_ctx->inverse) {
|
|
return $active_ctx->inverse;
|
|
}
|
|
|
|
$inverse = $active_ctx->inverse = new stdClass();
|
|
|
|
// variables for building fast CURIE map
|
|
$fast_curie_map = $active_ctx->fast_curie_map = new ArrayObject();
|
|
$iris_to_terms = [];
|
|
|
|
// handle default language
|
|
$default_language = '@none';
|
|
if (property_exists($active_ctx, '@language')) {
|
|
$default_language = $active_ctx->{'@language'};
|
|
}
|
|
|
|
// create term selections for each mapping in the context, ordered by
|
|
// shortest and then lexicographically least
|
|
$mappings = $active_ctx->mappings;
|
|
$terms = array_keys((array) $mappings);
|
|
usort($terms, [$this, '_compareShortestLeast']);
|
|
foreach ($terms as $term) {
|
|
$mapping = $mappings->{$term};
|
|
if ($mapping === null) {
|
|
continue;
|
|
}
|
|
|
|
// add term selection where it applies
|
|
if (property_exists($mapping, '@container')) {
|
|
$container = $mapping->{'@container'};
|
|
} else {
|
|
$container = '@none';
|
|
}
|
|
|
|
// iterate over every IRI in the mapping
|
|
$iris = $mapping->{'@id'};
|
|
$iris = self::arrayify($iris);
|
|
foreach ($iris as $iri) {
|
|
$is_keyword = self::_isKeyword($iri);
|
|
|
|
// initialize container map
|
|
if (!property_exists($inverse, $iri)) {
|
|
$inverse->{$iri} = new stdClass();
|
|
if (!$is_keyword && !$mapping->_term_has_colon) {
|
|
// init IRI to term map and fast CURIE map
|
|
$iris_to_terms[$iri] = new ArrayObject();
|
|
$iris_to_terms[$iri][] = $term;
|
|
$fast_curie_entry = (object) [
|
|
'iri' => $iri, 'terms' => $iris_to_terms[$iri]];
|
|
if (!array_key_exists($iri[0], (array) $fast_curie_map)) {
|
|
$fast_curie_map[$iri[0]] = new ArrayObject();
|
|
}
|
|
$fast_curie_map[$iri[0]][] = $fast_curie_entry;
|
|
}
|
|
} else if (!$is_keyword && !$mapping->_term_has_colon) {
|
|
// add IRI to term match
|
|
$iris_to_terms[$iri][] = $term;
|
|
}
|
|
$container_map = $inverse->{$iri};
|
|
|
|
// add new entry
|
|
if (!property_exists($container_map, $container)) {
|
|
$container_map->{$container} = (object) [
|
|
'@language' => new stdClass(),
|
|
'@type' => new stdClass()];
|
|
}
|
|
$entry = $container_map->{$container};
|
|
|
|
if ($mapping->reverse) {
|
|
// term is preferred for values using @reverse
|
|
$this->_addPreferredTerm(
|
|
$mapping, $term, $entry->{'@type'}, '@reverse');
|
|
} else if (property_exists($mapping, '@type')) {
|
|
// term is preferred for values using specific type
|
|
$this->_addPreferredTerm(
|
|
$mapping, $term, $entry->{'@type'}, $mapping->{'@type'});
|
|
} else if (property_exists($mapping, '@language')) {
|
|
// term is preferred for values using specific language
|
|
$language = $mapping->{'@language'};
|
|
if ($language === null) {
|
|
$language = '@null';
|
|
}
|
|
$this->_addPreferredTerm(
|
|
$mapping, $term, $entry->{'@language'}, $language);
|
|
} else {
|
|
// term is preferred for values w/default language or no type and
|
|
// no language
|
|
// add an entry for the default language
|
|
$this->_addPreferredTerm(
|
|
$mapping, $term, $entry->{'@language'}, $default_language);
|
|
|
|
// add entries for no type and no language
|
|
$this->_addPreferredTerm(
|
|
$mapping, $term, $entry->{'@type'}, '@none');
|
|
$this->_addPreferredTerm(
|
|
$mapping, $term, $entry->{'@language'}, '@none');
|
|
}
|
|
}
|
|
}
|
|
|
|
// build fast CURIE map
|
|
foreach ($fast_curie_map as $key => $value) {
|
|
$this->_buildIriMap($fast_curie_map, $key, 1);
|
|
}
|
|
|
|
return $inverse;
|
|
}
|
|
|
|
/**
|
|
* Runs a recursive algorithm to build a lookup map for quickly finding
|
|
* potential CURIEs.
|
|
*
|
|
* @param ArrayObject $iri_map the map to build.
|
|
* @param string $key the current key in the map to work on.
|
|
* @param int $idx the index into the IRI to compare.
|
|
*/
|
|
function _buildIriMap($iri_map, $key, $idx)
|
|
{
|
|
$entries = $iri_map[$key];
|
|
$next = $iri_map[$key] = new ArrayObject();
|
|
|
|
foreach ($entries as $entry) {
|
|
$iri = $entry->iri;
|
|
if ($idx >= strlen($iri)) {
|
|
$letter = '';
|
|
} else {
|
|
$letter = $iri[$idx];
|
|
}
|
|
if (!isset($next[$letter])) {
|
|
$next[$letter] = new ArrayObject();
|
|
}
|
|
$next[$letter][] = $entry;
|
|
}
|
|
|
|
foreach ($next as $key => $value) {
|
|
if ($key === '') {
|
|
continue;
|
|
}
|
|
$this->_buildIriMap($next, $key, $idx + 1);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Adds the term for the given entry if not already added.
|
|
*
|
|
* @param stdClass $mapping the term mapping.
|
|
* @param string $term the term to add.
|
|
* @param stdClass $entry the inverse context type_or_language entry to
|
|
* add to.
|
|
* @param string $type_or_language_value the key in the entry to add to.
|
|
*/
|
|
function _addPreferredTerm($mapping, $term, $entry, $type_or_language_value)
|
|
{
|
|
if (!property_exists($entry, $type_or_language_value)) {
|
|
$entry->{$type_or_language_value} = $term;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clones an active context, creating a child active context.
|
|
*
|
|
* @return stdClass a clone (child) of the active context.
|
|
*/
|
|
protected function _cloneActiveContext($active_ctx)
|
|
{
|
|
$child = new stdClass();
|
|
$child->{'@base'} = $active_ctx->{'@base'};
|
|
$child->mappings = self::copy($active_ctx->mappings);
|
|
$child->inverse = null;
|
|
if (property_exists($active_ctx, '@language')) {
|
|
$child->{'@language'} = $active_ctx->{'@language'};
|
|
}
|
|
if (property_exists($active_ctx, '@vocab')) {
|
|
$child->{'@vocab'} = $active_ctx->{'@vocab'};
|
|
}
|
|
return $child;
|
|
}
|
|
|
|
/**
|
|
* Returns whether or not the given value is a keyword.
|
|
*
|
|
* @param string $v the value to check.
|
|
*
|
|
* @return bool true if the value is a keyword, false if not.
|
|
*/
|
|
protected static function _isKeyword($v)
|
|
{
|
|
if (!is_string($v)) {
|
|
return false;
|
|
}
|
|
switch ($v) {
|
|
case '@base':
|
|
case '@context':
|
|
case '@container':
|
|
case '@default':
|
|
case '@embed':
|
|
case '@explicit':
|
|
case '@graph':
|
|
case '@id':
|
|
case '@index':
|
|
case '@language':
|
|
case '@list':
|
|
case '@omitDefault':
|
|
case '@preserve':
|
|
case '@requireAll':
|
|
case '@reverse':
|
|
case '@set':
|
|
case '@type':
|
|
case '@value':
|
|
case '@vocab':
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Returns true if the given value is an empty Object.
|
|
*
|
|
* @param mixed $v the value to check.
|
|
*
|
|
* @return bool true if the value is an empty Object, false if not.
|
|
*/
|
|
protected static function _isEmptyObject($v)
|
|
{
|
|
return is_object($v) && count(get_object_vars($v)) === 0;
|
|
}
|
|
|
|
/**
|
|
* Throws an exception if the given value is not a valid @type value.
|
|
*
|
|
* @param mixed $v the value to check.
|
|
*/
|
|
protected static function _validateTypeValue($v)
|
|
{
|
|
// must be a string or empty object
|
|
if (is_string($v) || self::_isEmptyObject($v)) {
|
|
return;
|
|
}
|
|
|
|
// must be an array
|
|
$is_valid = false;
|
|
if (is_array($v)) {
|
|
// must contain only strings
|
|
$is_valid = true;
|
|
foreach ($v as $e) {
|
|
if (!(is_string($e))) {
|
|
$is_valid = false;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!$is_valid) {
|
|
throw new JsonLdException(
|
|
'Invalid JSON-LD syntax; "@type" value must a string, an array ' .
|
|
'of strings, or an empty object.', 'jsonld.SyntaxError', 'invalid type value', ['value' => $v]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns true if the given value is a subject with properties.
|
|
*
|
|
* @param mixed $v the value to check.
|
|
*
|
|
* @return bool true if the value is a subject with properties, false if not.
|
|
*/
|
|
protected static function _isSubject($v)
|
|
{
|
|
// Note: A value is a subject if all of these hold true:
|
|
// 1. It is an Object.
|
|
// 2. It is not a @value, @set, or @list.
|
|
// 3. It has more than 1 key OR any existing key is not @id.
|
|
$rval = false;
|
|
if (is_object($v) &&
|
|
!property_exists($v, '@value') &&
|
|
!property_exists($v, '@set') &&
|
|
!property_exists($v, '@list')) {
|
|
$count = count(get_object_vars($v));
|
|
$rval = ($count > 1 || !property_exists($v, '@id'));
|
|
}
|
|
return $rval;
|
|
}
|
|
|
|
/**
|
|
* Returns true if the given value is a subject reference.
|
|
*
|
|
* @param mixed $v the value to check.
|
|
*
|
|
* @return bool true if the value is a subject reference, false if not.
|
|
*/
|
|
protected static function _isSubjectReference($v)
|
|
{
|
|
// Note: A value is a subject reference if all of these hold true:
|
|
// 1. It is an Object.
|
|
// 2. It has a single key: @id.
|
|
return (is_object($v) && count(get_object_vars($v)) === 1 &&
|
|
property_exists($v, '@id'));
|
|
}
|
|
|
|
/**
|
|
* Returns true if the given value is a @value.
|
|
*
|
|
* @param mixed $v the value to check.
|
|
*
|
|
* @return bool true if the value is a @value, false if not.
|
|
*/
|
|
protected static function _isValue($v)
|
|
{
|
|
// Note: A value is a @value if all of these hold true:
|
|
// 1. It is an Object.
|
|
// 2. It has the @value property.
|
|
return is_object($v) && property_exists($v, '@value');
|
|
}
|
|
|
|
/**
|
|
* Returns true if the given value is a @list.
|
|
*
|
|
* @param mixed $v the value to check.
|
|
*
|
|
* @return bool true if the value is a @list, false if not.
|
|
*/
|
|
protected static function _isList($v)
|
|
{
|
|
// Note: A value is a @list if all of these hold true:
|
|
// 1. It is an Object.
|
|
// 2. It has the @list property.
|
|
return is_object($v) && property_exists($v, '@list');
|
|
}
|
|
|
|
/**
|
|
* Returns true if the given value is a blank node.
|
|
*
|
|
* @param mixed $v the value to check.
|
|
*
|
|
* @return bool true if the value is a blank node, false if not.
|
|
*/
|
|
protected static function _isBlankNode($v)
|
|
{
|
|
// Note: A value is a blank node if all of these hold true:
|
|
// 1. It is an Object.
|
|
// 2. If it has an @id key its value begins with '_:'.
|
|
// 3. It has no keys OR is not a @value, @set, or @list.
|
|
$rval = false;
|
|
if (is_object($v)) {
|
|
if (property_exists($v, '@id')) {
|
|
$rval = (strpos($v->{'@id'}, '_:') === 0);
|
|
} else {
|
|
$rval = (count(get_object_vars($v)) === 0 ||
|
|
!(property_exists($v, '@value') ||
|
|
property_exists($v, '@set') ||
|
|
property_exists($v, '@list')));
|
|
}
|
|
}
|
|
return $rval;
|
|
}
|
|
|
|
/**
|
|
* Returns true if the given value is an absolute IRI, false if not.
|
|
*
|
|
* @param string $v the value to check.
|
|
*
|
|
* @return bool true if the value is an absolute IRI, false if not.
|
|
*/
|
|
protected static function _isAbsoluteIri($v)
|
|
{
|
|
return strpos($v, ':') !== false;
|
|
}
|
|
|
|
/**
|
|
* Returns true if the given target has the given key and its
|
|
* value equals is the given value.
|
|
*
|
|
* @param stdClass $target the target object.
|
|
* @param string $key the key to check.
|
|
* @param mixed $value the value to check.
|
|
*
|
|
* @return bool true if the target has the given key and its value matches.
|
|
*/
|
|
protected static function _hasKeyValue($target, $key, $value)
|
|
{
|
|
return (property_exists($target, $key) && $target->{$key} === $value);
|
|
}
|
|
|
|
/**
|
|
* Returns true if both of the given objects have the same value for the
|
|
* given key or if neither of the objects contain the given key.
|
|
*
|
|
* @param stdClass $o1 the first object.
|
|
* @param stdClass $o2 the second object.
|
|
* @param string $key the key to check.
|
|
*
|
|
* @return bool true if both objects have the same value for the key or
|
|
* neither has the key.
|
|
*/
|
|
protected static function _compareKeyValues($o1, $o2, $key)
|
|
{
|
|
if (property_exists($o1, $key)) {
|
|
return property_exists($o2, $key) && $o1->{$key} === $o2->{$key};
|
|
}
|
|
return !property_exists($o2, $key);
|
|
}
|
|
|
|
/**
|
|
* Parses JSON and sets an appropriate exception message on error.
|
|
*
|
|
* @param string $json the JSON to parse.
|
|
*
|
|
* @return mixed the parsed JSON object or array.
|
|
*/
|
|
protected static function _parse_json($json)
|
|
{
|
|
$rval = json_decode($json);
|
|
$error = json_last_error();
|
|
if ($error === JSON_ERROR_NONE && $rval === null) {
|
|
$error = JSON_ERROR_SYNTAX;
|
|
}
|
|
switch ($error) {
|
|
case JSON_ERROR_NONE:
|
|
break;
|
|
case JSON_ERROR_DEPTH:
|
|
throw new JsonLdException(
|
|
'Could not parse JSON; the maximum stack depth has been exceeded.', 'jsonld.ParseError');
|
|
case JSON_ERROR_STATE_MISMATCH:
|
|
throw new JsonLdException(
|
|
'Could not parse JSON; invalid or malformed JSON.', 'jsonld.ParseError');
|
|
case JSON_ERROR_CTRL_CHAR:
|
|
case JSON_ERROR_SYNTAX:
|
|
throw new JsonLdException(
|
|
'Could not parse JSON; syntax error, malformed JSON.', 'jsonld.ParseError');
|
|
case JSON_ERROR_UTF8:
|
|
throw new JsonLdException(
|
|
'Could not parse JSON from URL; malformed UTF-8 characters.', 'jsonld.ParseError');
|
|
default:
|
|
throw new JsonLdException(
|
|
'Could not parse JSON from URL; unknown error.', 'jsonld.ParseError');
|
|
}
|
|
return $rval;
|
|
}
|
|
|
|
}
|
|
|
|
// register the N-Quads RDF parser
|
|
jsonld_register_rdf_parser(
|
|
'application/nquads', ['JsonLdProcessor', 'parseNQuads']);
|
|
|
|
/**
|
|
* A JSON-LD Exception.
|
|
*/
|
|
class JsonLdException extends Exception
|
|
{
|
|
/** @var mixed */
|
|
protected $type;
|
|
/** @var mixed|null */
|
|
protected $details;
|
|
/** @var mixed|null */
|
|
protected $cause;
|
|
|
|
public function __construct(
|
|
$msg, $type, $code = 'error', $details = null, $previous = null)
|
|
{
|
|
$this->type = $type;
|
|
$this->code = $code;
|
|
$this->details = $details;
|
|
$this->cause = $previous;
|
|
parent::__construct($msg, 0, $previous);
|
|
}
|
|
|
|
public function __toString()
|
|
{
|
|
$rval = __CLASS__ . ": [{$this->type}]: {$this->message}\n";
|
|
if ($this->code) {
|
|
$rval .= 'Code: ' . $this->code . "\n";
|
|
}
|
|
if ($this->details) {
|
|
$rval .= 'Details: ' . print_r($this->details, true) . "\n";
|
|
}
|
|
if ($this->cause) {
|
|
$rval .= 'Cause: ' . $this->cause;
|
|
}
|
|
$rval .= $this->getTraceAsString() . "\n";
|
|
return $rval;
|
|
}
|
|
|
|
}
|
|
|
|
;
|
|
|
|
/**
|
|
* A UniqueNamer issues unique names, keeping track of any previously issued
|
|
* names.
|
|
*/
|
|
class UniqueNamer
|
|
{
|
|
/** @var string */
|
|
private $prefix;
|
|
/** @var int */
|
|
private $counter;
|
|
/** @var stdClass */
|
|
private $existing;
|
|
/** @var array */
|
|
public $order;
|
|
|
|
/**
|
|
* Constructs a new UniqueNamer.
|
|
*
|
|
* @param string $prefix the prefix to use ('<prefix><counter>').
|
|
*/
|
|
public function __construct($prefix)
|
|
{
|
|
$this->prefix = $prefix;
|
|
$this->counter = 0;
|
|
$this->existing = new stdClass();
|
|
$this->order = [];
|
|
}
|
|
|
|
/**
|
|
* Clones this UniqueNamer.
|
|
*/
|
|
public function __clone()
|
|
{
|
|
$this->existing = clone $this->existing;
|
|
}
|
|
|
|
/**
|
|
* Gets the new name for the given old name, where if no old name is given
|
|
* a new name will be generated.
|
|
*
|
|
* @param mixed|null $old_name the old name to get the new name for.
|
|
*
|
|
* @return string the new name.
|
|
*/
|
|
public function getName($old_name = null)
|
|
{
|
|
// return existing old name
|
|
if ($old_name && property_exists($this->existing, $old_name)) {
|
|
return $this->existing->{$old_name};
|
|
}
|
|
|
|
// get next name
|
|
$name = $this->prefix . $this->counter;
|
|
$this->counter += 1;
|
|
|
|
// save mapping
|
|
if ($old_name !== null) {
|
|
$this->existing->{$old_name} = $name;
|
|
$this->order[] = $old_name;
|
|
}
|
|
|
|
return $name;
|
|
}
|
|
|
|
/**
|
|
* Returns true if the given old name has already been assigned a new name.
|
|
*
|
|
* @param string $old_name the old name to check.
|
|
*
|
|
* @return true if the old name has been assigned a new name, false if not.
|
|
*/
|
|
public function isNamed($old_name)
|
|
{
|
|
return property_exists($this->existing, $old_name);
|
|
}
|
|
|
|
}
|
|
|
|
/**
|
|
* A Permutator iterates over all possible permutations of the given array
|
|
* of elements.
|
|
*/
|
|
class Permutator
|
|
{
|
|
/** @var array */
|
|
protected $list;
|
|
/** @var bool */
|
|
protected $done;
|
|
/** @var stdClass */
|
|
protected $left;
|
|
|
|
/**
|
|
* Constructs a new Permutator.
|
|
*
|
|
* @param array $list the array of elements to iterate over.
|
|
*/
|
|
public function __construct($list)
|
|
{
|
|
// original array
|
|
$this->list = $list;
|
|
sort($this->list);
|
|
// indicates whether there are more permutations
|
|
$this->done = false;
|
|
// directional info for permutation algorithm
|
|
$this->left = new stdClass();
|
|
foreach ($list as $v) {
|
|
$this->left->{$v} = true;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns true if there is another permutation.
|
|
*
|
|
* @return bool true if there is another permutation, false if not.
|
|
*/
|
|
public function hasNext()
|
|
{
|
|
return !$this->done;
|
|
}
|
|
|
|
/**
|
|
* Gets the next permutation. Call hasNext() to ensure there is another one
|
|
* first.
|
|
*
|
|
* @return array the next permutation.
|
|
*/
|
|
public function next()
|
|
{
|
|
// copy current permutation
|
|
$rval = $this->list;
|
|
|
|
/* Calculate the next permutation using the Steinhaus-Johnson-Trotter
|
|
permutation algorithm. */
|
|
|
|
// get largest mobile element k
|
|
// (mobile: element is greater than the one it is looking at)
|
|
$k = null;
|
|
$pos = 0;
|
|
$length = count($this->list);
|
|
for ($i = 0; $i < $length; ++$i) {
|
|
$element = $this->list[$i];
|
|
$left = $this->left->{$element};
|
|
if (($k === null || $element > $k) &&
|
|
(($left && $i > 0 && $element > $this->list[$i - 1]) ||
|
|
(!$left && $i < ($length - 1) && $element > $this->list[$i + 1]))) {
|
|
$k = $element;
|
|
$pos = $i;
|
|
}
|
|
}
|
|
|
|
// no more permutations
|
|
if ($k === null) {
|
|
$this->done = true;
|
|
} else {
|
|
// swap k and the element it is looking at
|
|
$swap = $this->left->{$k} ? $pos - 1 : $pos + 1;
|
|
$this->list[$pos] = $this->list[$swap];
|
|
$this->list[$swap] = $k;
|
|
|
|
// reverse the direction of all elements larger than k
|
|
for ($i = 0; $i < $length; ++$i) {
|
|
if ($this->list[$i] > $k) {
|
|
$this->left->{$this->list[$i]} = !$this->left->{$this->list[$i]};
|
|
}
|
|
}
|
|
}
|
|
|
|
return $rval;
|
|
}
|
|
|
|
}
|
|
|
|
/**
|
|
* An ActiveContextCache caches active contexts so they can be reused without
|
|
* the overhead of recomputing them.
|
|
*/
|
|
class ActiveContextCache
|
|
{
|
|
/** @var array */
|
|
protected $order;
|
|
/** @var stdClass */
|
|
protected $cache;
|
|
/** @var int */
|
|
protected $size;
|
|
|
|
/**
|
|
* Constructs a new ActiveContextCache.
|
|
*
|
|
* @param int $size the maximum size of the cache, defaults to 100.
|
|
*/
|
|
public function __construct($size = 100)
|
|
{
|
|
$this->order = [];
|
|
$this->cache = new stdClass();
|
|
$this->size = $size;
|
|
}
|
|
|
|
/**
|
|
* Gets an active context from the cache based on the current active
|
|
* context and the new local context.
|
|
*
|
|
* @param stdClass $active_ctx the current active context.
|
|
* @param stdClass $local_ctx the new local context.
|
|
*
|
|
* @return mixed a shared copy of the cached active context or null.
|
|
*/
|
|
public function get($active_ctx, $local_ctx)
|
|
{
|
|
$key1 = serialize($active_ctx);
|
|
$key2 = serialize($local_ctx);
|
|
if (property_exists($this->cache, $key1)) {
|
|
$level1 = $this->cache->{$key1};
|
|
if (property_exists($level1, $key2)) {
|
|
return $level1->{$key2};
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Sets an active context in the cache based on the previous active
|
|
* context and the just-processed local context.
|
|
*
|
|
* @param stdClass $active_ctx the previous active context.
|
|
* @param stdClass $local_ctx the just-processed local context.
|
|
* @param stdClass $result the resulting active context.
|
|
*/
|
|
public function set($active_ctx, $local_ctx, $result)
|
|
{
|
|
if (count($this->order) === $this->size) {
|
|
$entry = array_shift($this->order);
|
|
unset($this->cache->{$entry->activeCtx}->{$entry->localCtx});
|
|
}
|
|
|
|
$key1 = serialize($active_ctx);
|
|
$key2 = serialize($local_ctx);
|
|
$this->order[] = (object) [
|
|
'activeCtx' => $key1, 'localCtx' => $key2];
|
|
if (!property_exists($this->cache, $key1)) {
|
|
$this->cache->{$key1} = new stdClass();
|
|
}
|
|
|
|
$this->cache->{$key1}->{$key2} = JsonLdProcessor::copy($result);
|
|
}
|
|
}
|
|
|
|
/* end of file, omit ?> */
|