2974 lines
111 KiB
PHP
2974 lines
111 KiB
PHP
|
<?php
|
||
|
|
||
|
/*
|
||
|
* (c) Markus Lanthaler <mail@markus-lanthaler.com>
|
||
|
*
|
||
|
* For the full copyright and license information, please view the LICENSE
|
||
|
* file that was distributed with this source code.
|
||
|
*/
|
||
|
|
||
|
namespace ML\JsonLD;
|
||
|
|
||
|
use stdClass as JsonObject;
|
||
|
use ML\JsonLD\Exception\JsonLdException;
|
||
|
use ML\JsonLD\Exception\InvalidQuadException;
|
||
|
use ML\IRI\IRI;
|
||
|
|
||
|
/**
|
||
|
* Processor processes JSON-LD documents as specified by the JSON-LD
|
||
|
* specification.
|
||
|
*
|
||
|
* @author Markus Lanthaler <mail@markus-lanthaler.com>
|
||
|
*/
|
||
|
class Processor
|
||
|
{
|
||
|
/** Timeout for retrieving remote documents in seconds */
|
||
|
const REMOTE_TIMEOUT = 10;
|
||
|
|
||
|
/** Maximum number of recursion that are allowed to resolve an IRI */
|
||
|
const CONTEXT_MAX_IRI_RECURSIONS = 10;
|
||
|
|
||
|
/**
|
||
|
* @var array A list of all defined keywords
|
||
|
*/
|
||
|
private static $keywords = array('@context', '@id', '@value', '@language', '@type',
|
||
|
'@container', '@list', '@set', '@graph', '@reverse',
|
||
|
'@base', '@vocab', '@index', '@null');
|
||
|
// TODO Introduce @null supported just for framing
|
||
|
|
||
|
/**
|
||
|
* @var array Framing options keywords
|
||
|
*/
|
||
|
private static $framingKeywords = array('@explicit', '@default', '@embed',
|
||
|
//'@omitDefault', // TODO Is this really needed?
|
||
|
'@embedChildren'); // TODO How should this be called?
|
||
|
// TODO Add @preserve, @null?? Update spec keyword list
|
||
|
|
||
|
/**
|
||
|
* @var IRI The base IRI
|
||
|
*/
|
||
|
private $baseIri = null;
|
||
|
|
||
|
/**
|
||
|
* Compact arrays with just one element to a scalar
|
||
|
*
|
||
|
* If set to true, arrays holding just one element are compacted to
|
||
|
* scalars, otherwise the arrays are kept as arrays.
|
||
|
*
|
||
|
* @var bool
|
||
|
*/
|
||
|
private $compactArrays;
|
||
|
|
||
|
/**
|
||
|
* Optimize compacted output
|
||
|
*
|
||
|
* If set to true, the processor is free to optimize the result to produce
|
||
|
* an even compacter representation than the algorithm described by the
|
||
|
* official JSON-LD specification.
|
||
|
*
|
||
|
* @var bool
|
||
|
*/
|
||
|
private $optimize;
|
||
|
|
||
|
/**
|
||
|
* Use native types when converting from RDF
|
||
|
*
|
||
|
* If set to true, the processor will try to convert datatyped literals
|
||
|
* to native types instead of using the expanded object form when
|
||
|
* converting from RDF. xsd:boolean values will be converted to booleans
|
||
|
* whereas xsd:integer and xsd:double values will be converted to numbers.
|
||
|
*
|
||
|
* @var bool
|
||
|
*/
|
||
|
private $useNativeTypes;
|
||
|
|
||
|
/**
|
||
|
* Use rdf:type instead of \@type when converting from RDF
|
||
|
*
|
||
|
* If set to true, the JSON-LD processor will use the expanded rdf:type
|
||
|
* IRI as the property instead of \@type when converting from RDF.
|
||
|
*
|
||
|
* @var bool
|
||
|
*/
|
||
|
private $useRdfType;
|
||
|
|
||
|
/**
|
||
|
* Produce generalized RDF
|
||
|
*
|
||
|
* Unless set to true, triples/quads with a blank node predicate are
|
||
|
* dropped when converting to RDF.
|
||
|
*
|
||
|
* @var bool
|
||
|
*/
|
||
|
private $generalizedRdf;
|
||
|
|
||
|
/**
|
||
|
* @var array Blank node map
|
||
|
*/
|
||
|
private $blankNodeMap = array();
|
||
|
|
||
|
/**
|
||
|
* @var integer Blank node counter
|
||
|
*/
|
||
|
private $blankNodeCounter = 0;
|
||
|
|
||
|
/**
|
||
|
* @var DocumentFactoryInterface The factory to create new documents
|
||
|
*/
|
||
|
private $documentFactory = null;
|
||
|
|
||
|
/**
|
||
|
* @var DocumentLoaderInterface The document loader
|
||
|
*/
|
||
|
private $documentLoader = null;
|
||
|
|
||
|
/**
|
||
|
* Constructor
|
||
|
*
|
||
|
* The options parameter must be passed and all off the following properties
|
||
|
* have to be set:
|
||
|
*
|
||
|
* <dl>
|
||
|
* <dl>base</dl>
|
||
|
* <dt>The base IRI.</dt>
|
||
|
*
|
||
|
* <dl>compactArrays</dl>
|
||
|
* <dt>If set to true, arrays holding just one element are compacted
|
||
|
* to scalars, otherwise the arrays are kept as arrays.</dt>
|
||
|
*
|
||
|
* <dl>optimize</dl>
|
||
|
* <dt>If set to true, the processor is free to optimize the result to
|
||
|
* produce an even compacter representation than the algorithm
|
||
|
* described by the official JSON-LD specification.</dt>
|
||
|
*
|
||
|
* <dl>useNativeTypes</dl>
|
||
|
* <dt>If set to true, the processor will try to convert datatyped
|
||
|
* literals to native types instead of using the expanded object form
|
||
|
* when converting from RDF. <em>xsd:boolean</em> values will be
|
||
|
* converted to booleans whereas <em>xsd:integer</em> and
|
||
|
* <em>xsd:double</em> values will be converted to numbers.</dt>
|
||
|
*
|
||
|
* <dl>useRdfType</dl>
|
||
|
* <dt>If set to true, the JSON-LD processor will use the expanded
|
||
|
* <em>rdf:type</em> IRI as the property instead of <em>@type</em>
|
||
|
* when converting from RDF.</dt>
|
||
|
* </dl>
|
||
|
*
|
||
|
* @param JsonObject $options Options to configure the various algorithms.
|
||
|
*/
|
||
|
public function __construct($options)
|
||
|
{
|
||
|
$this->baseIri = new IRI($options->base);
|
||
|
$this->compactArrays = (bool) $options->compactArrays;
|
||
|
$this->optimize = (bool) $options->optimize;
|
||
|
$this->useNativeTypes = (bool) $options->useNativeTypes;
|
||
|
$this->useRdfType = (bool) $options->useRdfType;
|
||
|
$this->generalizedRdf = (bool) $options->produceGeneralizedRdf;
|
||
|
$this->documentFactory = $options->documentFactory;
|
||
|
$this->documentLoader = $options->documentLoader;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Parses a JSON-LD document to a PHP value
|
||
|
*
|
||
|
* @param string $document A JSON-LD document.
|
||
|
*
|
||
|
* @return mixed A PHP value.
|
||
|
*
|
||
|
* @throws JsonLdException If the JSON-LD document is not valid.
|
||
|
*/
|
||
|
public static function parse($document)
|
||
|
{
|
||
|
if (function_exists('mb_detect_encoding') &&
|
||
|
(false === mb_detect_encoding($document, 'UTF-8', true))) {
|
||
|
throw new JsonLdException(
|
||
|
JsonLdException::LOADING_DOCUMENT_FAILED,
|
||
|
'The JSON-LD document does not appear to be valid UTF-8.'
|
||
|
);
|
||
|
}
|
||
|
|
||
|
$data = json_decode($document, false, 512);
|
||
|
|
||
|
switch (json_last_error()) {
|
||
|
case JSON_ERROR_NONE:
|
||
|
break; // no error
|
||
|
case JSON_ERROR_DEPTH:
|
||
|
throw new JsonLdException(
|
||
|
JsonLdException::LOADING_DOCUMENT_FAILED,
|
||
|
'The maximum stack depth has been exceeded.'
|
||
|
);
|
||
|
case JSON_ERROR_STATE_MISMATCH:
|
||
|
throw new JsonLdException(
|
||
|
JsonLdException::LOADING_DOCUMENT_FAILED,
|
||
|
'Invalid or malformed JSON.'
|
||
|
);
|
||
|
case JSON_ERROR_CTRL_CHAR:
|
||
|
throw new JsonLdException(
|
||
|
JsonLdException::LOADING_DOCUMENT_FAILED,
|
||
|
'Control character error (possibly incorrectly encoded).'
|
||
|
);
|
||
|
case JSON_ERROR_SYNTAX:
|
||
|
throw new JsonLdException(
|
||
|
JsonLdException::LOADING_DOCUMENT_FAILED,
|
||
|
'Syntax error, malformed JSON.'
|
||
|
);
|
||
|
case JSON_ERROR_UTF8:
|
||
|
throw new JsonLdException(
|
||
|
JsonLdException::LOADING_DOCUMENT_FAILED,
|
||
|
'Malformed UTF-8 characters (possibly incorrectly encoded).'
|
||
|
);
|
||
|
default:
|
||
|
throw new JsonLdException(
|
||
|
JsonLdException::LOADING_DOCUMENT_FAILED,
|
||
|
'Unknown error while parsing JSON.'
|
||
|
);
|
||
|
}
|
||
|
|
||
|
return (empty($data)) ? null : $data;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Parses a JSON-LD document and returns it as a Document
|
||
|
*
|
||
|
* @param array|JsonObject $input The JSON-LD document to process.
|
||
|
*
|
||
|
* @return Document The parsed JSON-LD document.
|
||
|
*
|
||
|
* @throws JsonLdException If the JSON-LD input document is invalid.
|
||
|
*/
|
||
|
public function getDocument($input)
|
||
|
{
|
||
|
$nodeMap = new JsonObject();
|
||
|
$nodeMap->{'-' . JsonLD::DEFAULT_GRAPH} = new JsonObject();
|
||
|
$this->generateNodeMap($nodeMap, $input);
|
||
|
|
||
|
// We need to keep track of blank nodes as they are renamed when
|
||
|
// inserted into the Document
|
||
|
$nodes = array();
|
||
|
|
||
|
if (null === $this->documentFactory) {
|
||
|
$this->documentFactory = new DefaultDocumentFactory();
|
||
|
}
|
||
|
|
||
|
$document = $this->documentFactory->createDocument($this->baseIri);
|
||
|
|
||
|
foreach ($nodeMap as $graphName => &$nodes) {
|
||
|
$graphName = substr($graphName, 1);
|
||
|
if (JsonLD::DEFAULT_GRAPH === $graphName) {
|
||
|
$graph = $document->getGraph();
|
||
|
} else {
|
||
|
$graph = $document->createGraph($graphName);
|
||
|
}
|
||
|
|
||
|
foreach ($nodes as $id => &$item) {
|
||
|
$node = $graph->createNode($item->{'@id'}, true);
|
||
|
unset($item->{'@id'});
|
||
|
|
||
|
// Process node type as it needs to be handled differently than
|
||
|
// other properties
|
||
|
// TODO Could this be avoided by enforcing rdf:type instead of @type?
|
||
|
if (property_exists($item, '@type')) {
|
||
|
foreach ($item->{'@type'} as $type) {
|
||
|
$node->addType($graph->createNode($type), true);
|
||
|
}
|
||
|
unset($item->{'@type'});
|
||
|
}
|
||
|
|
||
|
foreach ($item as $property => $values) {
|
||
|
foreach ($values as $value) {
|
||
|
if (property_exists($value, '@value')) {
|
||
|
$node->addPropertyValue($property, Value::fromJsonLd($value));
|
||
|
} elseif (property_exists($value, '@id')) {
|
||
|
$node->addPropertyValue(
|
||
|
$property,
|
||
|
$graph->createNode($value->{'@id'}, true)
|
||
|
);
|
||
|
} else {
|
||
|
// TODO Handle lists
|
||
|
throw new \Exception('Lists are not supported by getDocument() yet');
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
|
||
|
unset($nodeMap);
|
||
|
|
||
|
return $document;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Expands a JSON-LD document
|
||
|
*
|
||
|
* @param mixed $element A JSON-LD element to be expanded.
|
||
|
* @param array $activectx The active context.
|
||
|
* @param null|string $activeprty The active property.
|
||
|
* @param boolean $frame True if a frame is being expanded, otherwise false.
|
||
|
*
|
||
|
* @return mixed The expanded document.
|
||
|
*
|
||
|
* @throws JsonLdException
|
||
|
*/
|
||
|
public function expand(&$element, $activectx = array(), $activeprty = null, $frame = false)
|
||
|
{
|
||
|
if (is_scalar($element)) {
|
||
|
|
||
|
if ((null === $activeprty) || ('@graph' === $activeprty)) {
|
||
|
$element = null;
|
||
|
} else {
|
||
|
$element = $this->expandValue($element, $activectx, $activeprty);
|
||
|
}
|
||
|
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
if (null === $element) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
if (is_array($element)) {
|
||
|
$result = array();
|
||
|
foreach ($element as &$item) {
|
||
|
$this->expand($item, $activectx, $activeprty, $frame);
|
||
|
|
||
|
// Check for lists of lists
|
||
|
if (('@list' === $this->getPropertyDefinition($activectx, $activeprty, '@container')) ||
|
||
|
('@list' === $activeprty)) {
|
||
|
if (is_array($item) || (is_object($item) && property_exists($item, '@list'))) {
|
||
|
throw new JsonLdException(
|
||
|
JsonLdException::LIST_OF_LISTS,
|
||
|
"List of lists detected in property \"$activeprty\".",
|
||
|
$element
|
||
|
);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if (is_array($item)) {
|
||
|
$result = array_merge($result, $item);
|
||
|
} elseif (null !== $item) {
|
||
|
$result[] = $item;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
$element = $result;
|
||
|
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
// Otherwise it's an object. Process its local context if available
|
||
|
if (property_exists($element, '@context')) {
|
||
|
$this->processContext($element->{'@context'}, $activectx);
|
||
|
unset($element->{'@context'});
|
||
|
}
|
||
|
|
||
|
$properties = get_object_vars($element);
|
||
|
ksort($properties);
|
||
|
|
||
|
$element = new JsonObject();
|
||
|
|
||
|
foreach ($properties as $property => $value) {
|
||
|
$expProperty = $this->expandIri($property, $activectx, false, true);
|
||
|
|
||
|
// Make sure to keep framing keywords if a frame is being expanded
|
||
|
if ($frame && in_array($expProperty, self::$framingKeywords)) {
|
||
|
// and that the default value is expanded
|
||
|
if ('@default' === $expProperty) {
|
||
|
$this->expand($value, $activectx, $activeprty, $frame);
|
||
|
}
|
||
|
|
||
|
self::setProperty($element, $expProperty, $value, JsonLdException::COLLIDING_KEYWORDS);
|
||
|
continue;
|
||
|
}
|
||
|
|
||
|
if (in_array($expProperty, self::$keywords)) {
|
||
|
if ('@reverse' === $activeprty) {
|
||
|
throw new JsonLdException(
|
||
|
JsonLdException::INVALID_REVERSE_PROPERTY_MAP,
|
||
|
'No keywords or keyword aliases are allowed in @reverse-maps, found ' . $expProperty
|
||
|
);
|
||
|
}
|
||
|
$this->expandKeywordValue($element, $activeprty, $expProperty, $value, $activectx, $frame);
|
||
|
|
||
|
continue;
|
||
|
} elseif (false === strpos($expProperty, ':')) {
|
||
|
// the expanded property is neither a keyword nor an IRI
|
||
|
continue;
|
||
|
}
|
||
|
|
||
|
$propertyContainer = $this->getPropertyDefinition($activectx, $property, '@container');
|
||
|
|
||
|
if (is_object($value) && in_array($propertyContainer, array('@language', '@index'))) {
|
||
|
$result = array();
|
||
|
|
||
|
$value = (array) $value; // makes it easier to order the key-value pairs
|
||
|
ksort($value);
|
||
|
|
||
|
if ('@language' === $propertyContainer) {
|
||
|
foreach ($value as $key => $val) {
|
||
|
// TODO Make sure key is a valid language tag
|
||
|
|
||
|
if (false === is_array($val)) {
|
||
|
$val = array($val);
|
||
|
}
|
||
|
|
||
|
foreach ($val as $item) {
|
||
|
if (false === is_string($item)) {
|
||
|
throw new JsonLdException(
|
||
|
JsonLdException::INVALID_LANGUAGE_MAP_VALUE,
|
||
|
"Detected invalid value in $property->$key: it must be a string as it " .
|
||
|
"is part of a language map.",
|
||
|
$item
|
||
|
);
|
||
|
}
|
||
|
|
||
|
$result[] = (object) array(
|
||
|
'@value' => $item,
|
||
|
'@language' => strtolower($key)
|
||
|
);
|
||
|
}
|
||
|
}
|
||
|
} else {
|
||
|
// @container: @index
|
||
|
foreach ($value as $key => $val) {
|
||
|
if (false === is_array($val)) {
|
||
|
$val = array($val);
|
||
|
}
|
||
|
|
||
|
$this->expand($val, $activectx, $property, $frame);
|
||
|
|
||
|
foreach ($val as $item) {
|
||
|
if (false === property_exists($item, '@index')) {
|
||
|
$item->{'@index'} = $key;
|
||
|
}
|
||
|
|
||
|
$result[] = $item;
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
$value = $result;
|
||
|
} else {
|
||
|
$this->expand($value, $activectx, $property, $frame);
|
||
|
}
|
||
|
|
||
|
// Remove properties with null values
|
||
|
if (null === $value) {
|
||
|
continue;
|
||
|
}
|
||
|
|
||
|
// If property has an @list container and value is not yet an
|
||
|
// expanded @list-object, transform it to one
|
||
|
if (('@list' === $propertyContainer) &&
|
||
|
((false === is_object($value) || (false === property_exists($value, '@list'))))) {
|
||
|
if (false === is_array($value)) {
|
||
|
$value = array($value);
|
||
|
}
|
||
|
|
||
|
$obj = new JsonObject();
|
||
|
$obj->{'@list'} = $value;
|
||
|
$value = $obj;
|
||
|
}
|
||
|
|
||
|
$target = $element;
|
||
|
if ($this->getPropertyDefinition($activectx, $property, '@reverse')) {
|
||
|
if (false === property_exists($target, '@reverse')) {
|
||
|
$target->{'@reverse'} = new JsonObject();
|
||
|
}
|
||
|
$target = $target->{'@reverse'};
|
||
|
|
||
|
if (false === is_array($value)) {
|
||
|
$value = array($value);
|
||
|
}
|
||
|
|
||
|
foreach ($value as $val) {
|
||
|
if (property_exists($val, '@value') || property_exists($val, '@list')) {
|
||
|
throw new JsonLdException(
|
||
|
JsonLdException::INVALID_REVERSE_PROPERTY_VALUE,
|
||
|
'Detected invalid value in @reverse-map (only nodes are allowed',
|
||
|
$val
|
||
|
);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
self::mergeIntoProperty($target, $expProperty, $value, true);
|
||
|
}
|
||
|
|
||
|
// All properties have been processed. Make sure the result is valid
|
||
|
// and optimize it where possible
|
||
|
$numProps = count(get_object_vars($element));
|
||
|
|
||
|
// Remove free-floating nodes
|
||
|
if ((false === $frame) && ((null === $activeprty) || ('@graph' === $activeprty)) &&
|
||
|
(((0 === $numProps) || property_exists($element, '@value') || property_exists($element, '@list') ||
|
||
|
((1 === $numProps) && property_exists($element, '@id'))))) {
|
||
|
|
||
|
$element = null;
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
// Indexes are allowed everywhere
|
||
|
if (property_exists($element, '@index')) {
|
||
|
$numProps--;
|
||
|
}
|
||
|
|
||
|
if (property_exists($element, '@value')) {
|
||
|
$numProps--; // @value
|
||
|
if (property_exists($element, '@language')) {
|
||
|
if (false === $frame) {
|
||
|
if (false === is_string($element->{'@language'})) {
|
||
|
throw new JsonLdException(
|
||
|
JsonLdException::INVALID_LANGUAGE_TAGGED_STRING,
|
||
|
'Invalid value for @language detected (must be a string).',
|
||
|
$element
|
||
|
);
|
||
|
}
|
||
|
|
||
|
if (false === is_string($element->{'@value'})) {
|
||
|
throw new JsonLdException(
|
||
|
JsonLdException::INVALID_LANGUAGE_TAGGED_VALUE,
|
||
|
'Only strings can be language tagged.',
|
||
|
$element
|
||
|
);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
$numProps--;
|
||
|
} elseif (property_exists($element, '@type')) {
|
||
|
if ((false === $frame) && ((false === is_string($element->{'@type'})) ||
|
||
|
(false === strpos($element->{'@type'}, ':')) ||
|
||
|
('_:' === substr($element->{'@type'}, 0, 2)))) {
|
||
|
throw new JsonLdException(
|
||
|
JsonLdException::INVALID_TYPED_VALUE,
|
||
|
'Invalid value for @type detected (must be an IRI).',
|
||
|
$element
|
||
|
);
|
||
|
}
|
||
|
|
||
|
$numProps--;
|
||
|
}
|
||
|
|
||
|
if ($numProps > 0) {
|
||
|
throw new JsonLdException(
|
||
|
JsonLdException::INVALID_VALUE_OBJECT,
|
||
|
'Detected an invalid @value object.',
|
||
|
$element
|
||
|
);
|
||
|
} elseif (null === $element->{'@value'}) {
|
||
|
// object has just an @value property that is null, can be replaced with that value
|
||
|
$element = $element->{'@value'};
|
||
|
}
|
||
|
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
// Not an @value object, make sure @type is an array
|
||
|
if (property_exists($element, '@type') && (false === is_array($element->{'@type'}))) {
|
||
|
$element->{'@type'} = array($element->{'@type'});
|
||
|
}
|
||
|
if (($numProps > 1) && ((property_exists($element, '@list') || property_exists($element, '@set')))) {
|
||
|
throw new JsonLdException(
|
||
|
JsonLdException::INVALID_SET_OR_LIST_OBJECT,
|
||
|
'An object with a @list or @set property can\'t contain other properties.',
|
||
|
$element
|
||
|
);
|
||
|
} elseif (property_exists($element, '@set')) {
|
||
|
// @set objects can be optimized away as they are just syntactic sugar
|
||
|
$element = $element->{'@set'};
|
||
|
} elseif (($numProps === 1) && (false === $frame) && property_exists($element, '@language')) {
|
||
|
// if there's just @language and nothing else and we are not expanding a frame, drop whole object
|
||
|
$element = null;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Expands the value of a keyword
|
||
|
*
|
||
|
* @param JsonObject $element The object this property-value pair is part of.
|
||
|
* @param string $activeprty The active property.
|
||
|
* @param string $keyword The keyword whose value is being expanded.
|
||
|
* @param mixed $value The value to expand.
|
||
|
* @param array $activectx The active context.
|
||
|
* @param boolean $frame True if a frame is being expanded, otherwise false.
|
||
|
*
|
||
|
* @throws JsonLdException
|
||
|
*/
|
||
|
private function expandKeywordValue(&$element, $activeprty, $keyword, $value, $activectx, $frame)
|
||
|
{
|
||
|
// Ignore all null values except for @value as in that case it is
|
||
|
// needed to determine what @type means
|
||
|
if ((null === $value) && ('@value' !== $keyword)) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
if ('@id' === $keyword) {
|
||
|
if (false === is_string($value)) {
|
||
|
throw new JsonLdException(
|
||
|
JsonLdException::INVALID_ID_VALUE,
|
||
|
'Invalid value for @id detected (must be a string).',
|
||
|
$element
|
||
|
);
|
||
|
}
|
||
|
|
||
|
$value = $this->expandIri($value, $activectx, true);
|
||
|
self::setProperty($element, $keyword, $value, JsonLdException::COLLIDING_KEYWORDS);
|
||
|
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
if ('@type' === $keyword) {
|
||
|
if (is_string($value)) {
|
||
|
$value = $this->expandIri($value, $activectx, true, true);
|
||
|
self::setProperty($element, $keyword, $value, JsonLdException::COLLIDING_KEYWORDS);
|
||
|
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
if (false === is_array($value)) {
|
||
|
$value = array($value);
|
||
|
}
|
||
|
|
||
|
$result = array();
|
||
|
|
||
|
foreach ($value as $item) {
|
||
|
if (is_string($item)) {
|
||
|
$result[] = $this->expandIri($item, $activectx, true, true);
|
||
|
} else {
|
||
|
if (false === $frame) {
|
||
|
throw new JsonLdException(
|
||
|
JsonLdException::INVALID_TYPE_VALUE,
|
||
|
"Invalid value for $keyword detected.",
|
||
|
$value
|
||
|
);
|
||
|
}
|
||
|
|
||
|
self::mergeIntoProperty($element, $keyword, $item);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Don't keep empty arrays
|
||
|
if (count($result) >= 1) {
|
||
|
self::mergeIntoProperty($element, $keyword, $result, true);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if (('@value' === $keyword)) {
|
||
|
if (false === $frame) {
|
||
|
if ((null !== $value) && (false === is_scalar($value))) {
|
||
|
// we need to preserve @value: null to distinguish values form nodes
|
||
|
throw new JsonLdException(
|
||
|
JsonLdException::INVALID_VALUE_OBJECT_VALUE,
|
||
|
"Invalid value for @value detected (must be a scalar).",
|
||
|
$value
|
||
|
);
|
||
|
}
|
||
|
} elseif (false === is_array($value)) {
|
||
|
$value = array($value);
|
||
|
}
|
||
|
|
||
|
self::setProperty($element, $keyword, $value, JsonLdException::COLLIDING_KEYWORDS);
|
||
|
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
if (('@language' === $keyword) || ('@index' === $keyword)) {
|
||
|
if (false === $frame) {
|
||
|
if (false === is_string($value)) {
|
||
|
throw ('@language' === $keyword)
|
||
|
? new JsonLdException(
|
||
|
JsonLdException::INVALID_LANGUAGE_TAGGED_STRING,
|
||
|
'@language must be a string',
|
||
|
$value
|
||
|
)
|
||
|
: new JsonLdException(
|
||
|
JsonLdException::INVALID_INDEX_VALUE,
|
||
|
'@index must be a string',
|
||
|
$value
|
||
|
);
|
||
|
}
|
||
|
} elseif (false === is_array($value)) {
|
||
|
$value = array($value);
|
||
|
}
|
||
|
|
||
|
self::setProperty($element, $keyword, $value, JsonLdException::COLLIDING_KEYWORDS);
|
||
|
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
// TODO Optimize the following code, there's a lot of repetition, only the $activeprty param is changing
|
||
|
if ('@list' === $keyword) {
|
||
|
if ((null === $activeprty) || ('@graph' === $activeprty)) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
$this->expand($value, $activectx, $activeprty, $frame);
|
||
|
|
||
|
if (false === is_array($value)) {
|
||
|
$value = array($value);
|
||
|
}
|
||
|
|
||
|
foreach ($value as $val) {
|
||
|
if (is_object($val) && property_exists($val, '@list')) {
|
||
|
throw new JsonLdException(JsonLdException::LIST_OF_LISTS, 'List of lists detected.', $element);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
self::mergeIntoProperty($element, $keyword, $value, true);
|
||
|
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
if ('@set' === $keyword) {
|
||
|
$this->expand($value, $activectx, $activeprty, $frame);
|
||
|
self::mergeIntoProperty($element, $keyword, $value, true);
|
||
|
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
if ('@reverse' === $keyword) {
|
||
|
if (false === is_object($value)) {
|
||
|
throw new JsonLdException(
|
||
|
JsonLdException::INVALID_REVERSE_VALUE,
|
||
|
'Detected invalid value for @reverse (must be an object).',
|
||
|
$value
|
||
|
);
|
||
|
}
|
||
|
|
||
|
$this->expand($value, $activectx, $keyword, $frame);
|
||
|
|
||
|
// Do not create @reverse-containers inside @reverse containers
|
||
|
if (property_exists($value, $keyword)) {
|
||
|
foreach (get_object_vars($value->{$keyword}) as $prop => $val) {
|
||
|
self::mergeIntoProperty($element, $prop, $val, true);
|
||
|
}
|
||
|
|
||
|
unset($value->{$keyword});
|
||
|
}
|
||
|
|
||
|
$value = get_object_vars($value);
|
||
|
|
||
|
if ((count($value) > 0) && (false === property_exists($element, $keyword))) {
|
||
|
$element->{$keyword} = new JsonObject();
|
||
|
}
|
||
|
|
||
|
foreach ($value as $prop => $val) {
|
||
|
foreach ($val as $v) {
|
||
|
if (property_exists($v, '@value') || property_exists($v, '@list')) {
|
||
|
throw new JsonLdException(
|
||
|
JsonLdException::INVALID_REVERSE_PROPERTY_VALUE,
|
||
|
'Detected invalid value in @reverse-map (only nodes are allowed',
|
||
|
$v
|
||
|
);
|
||
|
}
|
||
|
self::mergeIntoProperty($element->{$keyword}, $prop, $v, true);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
if ('@graph' === $keyword) {
|
||
|
$this->expand($value, $activectx, $keyword, $frame);
|
||
|
self::mergeIntoProperty($element, $keyword, $value, true);
|
||
|
|
||
|
return;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Expands a scalar value
|
||
|
*
|
||
|
* @param mixed $value The value to expand.
|
||
|
* @param array $activectx The active context.
|
||
|
* @param string $activeprty The active property.
|
||
|
*
|
||
|
* @return JsonObject The expanded value.
|
||
|
*/
|
||
|
private function expandValue($value, $activectx, $activeprty)
|
||
|
{
|
||
|
$def = $this->getPropertyDefinition($activectx, $activeprty);
|
||
|
|
||
|
$result = new JsonObject();
|
||
|
|
||
|
if ('@id' === $def['@type']) {
|
||
|
$result->{'@id'} = $this->expandIri($value, $activectx, true);
|
||
|
} elseif ('@vocab' === $def['@type']) {
|
||
|
$result->{'@id'} = $this->expandIri($value, $activectx, true, true);
|
||
|
} else {
|
||
|
$result->{'@value'} = $value;
|
||
|
|
||
|
if (isset($def['@type'])) {
|
||
|
$result->{'@type'} = $def['@type'];
|
||
|
} elseif (isset($def['@language']) && is_string($result->{'@value'})) {
|
||
|
$result->{'@language'} = $def['@language'];
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return $result;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Expands a JSON-LD IRI value (term, compact IRI, IRI) to an absolute
|
||
|
* IRI and relabels blank nodes
|
||
|
*
|
||
|
* @param mixed $value The value to be expanded to an absolute IRI.
|
||
|
* @param array $activectx The active context.
|
||
|
* @param bool $relativeIri Specifies whether $value should be treated as
|
||
|
* relative IRI against the base IRI or not.
|
||
|
* @param bool $vocabRelative Specifies whether $value is relative to @vocab
|
||
|
* if set or not.
|
||
|
* @param null|JsonObject $localctx If the IRI is being expanded as part of context
|
||
|
* processing, the current local context has to be
|
||
|
* passed as well.
|
||
|
* @param array $path A path of already processed terms to detect
|
||
|
* circular dependencies
|
||
|
*
|
||
|
* @return string The expanded IRI.
|
||
|
*/
|
||
|
private function expandIri(
|
||
|
$value,
|
||
|
$activectx,
|
||
|
$relativeIri = false,
|
||
|
$vocabRelative = false,
|
||
|
$localctx = null,
|
||
|
$path = array()
|
||
|
) {
|
||
|
if ((null === $value) || in_array($value, self::$keywords)) {
|
||
|
return $value;
|
||
|
}
|
||
|
|
||
|
if ($localctx) {
|
||
|
if (in_array($value, $path)) {
|
||
|
throw new JsonLdException(
|
||
|
JsonLdException::CYCLIC_IRI_MAPPING,
|
||
|
'Cycle in context definition detected: ' . join(' -> ', $path) . ' -> ' . $path[0],
|
||
|
$localctx
|
||
|
);
|
||
|
} else {
|
||
|
$path[] = $value;
|
||
|
|
||
|
if (count($path) >= self::CONTEXT_MAX_IRI_RECURSIONS) {
|
||
|
throw new JsonLdException(
|
||
|
JsonLdException::UNSPECIFIED,
|
||
|
'Too many recursions in term definition: ' . join(' -> ', $path) . ' -> ' . $path[0],
|
||
|
$localctx
|
||
|
);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if (isset($localctx->{$value})) {
|
||
|
$nested = null;
|
||
|
|
||
|
if (is_string($localctx->{$value})) {
|
||
|
$nested = $localctx->{$value};
|
||
|
} elseif (isset($localctx->{$value}->{'@id'})) {
|
||
|
$nested = $localctx->{$value}->{'@id'};
|
||
|
}
|
||
|
|
||
|
if ($nested && (end($path) !== $nested)) {
|
||
|
return $this->expandIri($nested, $activectx, false, true, $localctx, $path);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Terms apply only for vocab-relative IRIs
|
||
|
if ((true === $vocabRelative) && array_key_exists($value, $activectx)) {
|
||
|
return $activectx[$value]['@id'];
|
||
|
}
|
||
|
|
||
|
if (false !== strpos($value, ':')) {
|
||
|
list($prefix, $suffix) = explode(':', $value, 2);
|
||
|
|
||
|
if (('_' === $prefix) || ('//' === substr($suffix, 0, 2))) {
|
||
|
// Safety measure to prevent reassigned of, e.g., http://
|
||
|
// the "_" prefix is reserved for blank nodes and can't be expanded
|
||
|
return $value;
|
||
|
}
|
||
|
|
||
|
if ($localctx) {
|
||
|
$prefix = $this->expandIri($prefix, $activectx, false, true, $localctx, $path);
|
||
|
|
||
|
// If prefix contains a colon, we have successfully expanded it
|
||
|
if (false !== strpos($prefix, ':')) {
|
||
|
return $prefix . $suffix;
|
||
|
}
|
||
|
} elseif (array_key_exists($prefix, $activectx)) {
|
||
|
// compact IRI
|
||
|
return $activectx[$prefix]['@id'] . $suffix;
|
||
|
}
|
||
|
} else {
|
||
|
if ($vocabRelative && array_key_exists('@vocab', $activectx)) {
|
||
|
return $activectx['@vocab'] . $value;
|
||
|
} elseif (($relativeIri) && (null !== $activectx['@base'])) {
|
||
|
return (string) $activectx['@base']->resolve($value);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// can't expand it, return as is
|
||
|
return $value;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Compacts a JSON-LD document
|
||
|
*
|
||
|
* Attention: This method must be called with an expanded element,
|
||
|
* otherwise it might not work.
|
||
|
*
|
||
|
* @param mixed $element A JSON-LD element to be compacted.
|
||
|
* @param array $activectx The active context.
|
||
|
* @param array $inversectx The inverse context.
|
||
|
* @param null|string $activeprty The active property.
|
||
|
*
|
||
|
* @return mixed The compacted JSON-LD document.
|
||
|
*/
|
||
|
public function compact(&$element, $activectx = array(), $inversectx = array(), $activeprty = null)
|
||
|
{
|
||
|
if (is_array($element)) {
|
||
|
$result = array();
|
||
|
foreach ($element as &$item) {
|
||
|
$this->compact($item, $activectx, $inversectx, $activeprty);
|
||
|
if (null !== $item) {
|
||
|
$result[] = $item;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if ($this->compactArrays && (1 === count($result))) {
|
||
|
$element = $result[0];
|
||
|
} else {
|
||
|
$element = $result;
|
||
|
}
|
||
|
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
if (false === is_object($element)) {
|
||
|
// element is already in compact form, nothing else to do
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
if (property_exists($element, '@value') || property_exists($element, '@id')) {
|
||
|
$def = $this->getPropertyDefinition($activectx, $activeprty);
|
||
|
$element = $this->compactValue($element, $def, $activectx, $inversectx);
|
||
|
|
||
|
if (false === is_object($element)) {
|
||
|
return;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Otherwise, compact all properties
|
||
|
$properties = get_object_vars($element);
|
||
|
ksort($properties);
|
||
|
|
||
|
$inReverse = ('@reverse' === $activeprty);
|
||
|
$element = new JsonObject();
|
||
|
|
||
|
foreach ($properties as $property => $value) {
|
||
|
if (in_array($property, self::$keywords)) {
|
||
|
if ('@id' === $property) {
|
||
|
$value = $this->compactIri($value, $activectx, $inversectx);
|
||
|
} elseif ('@type' === $property) {
|
||
|
if (is_string($value)) {
|
||
|
$value = $this->compactIri($value, $activectx, $inversectx, null, true);
|
||
|
} else {
|
||
|
foreach ($value as &$iri) {
|
||
|
$iri = $this->compactIri($iri, $activectx, $inversectx, null, true);
|
||
|
}
|
||
|
|
||
|
if ($this->compactArrays && (1 === count($value))) {
|
||
|
$value = $value[0];
|
||
|
}
|
||
|
}
|
||
|
} elseif (('@graph' === $property) || ('@list' === $property)) {
|
||
|
$this->compact($value, $activectx, $inversectx, $property);
|
||
|
|
||
|
if (false === is_array($value)) {
|
||
|
$value = array($value);
|
||
|
}
|
||
|
} elseif ('@reverse' === $property) {
|
||
|
$this->compact($value, $activectx, $inversectx, $property);
|
||
|
|
||
|
// Move reverse properties out of the map into element
|
||
|
foreach (get_object_vars($value) as $prop => $val) {
|
||
|
if ($this->getPropertyDefinition($activectx, $prop, '@reverse')) {
|
||
|
$alwaysArray = ('@set' === $this->getPropertyDefinition($activectx, $prop, '@container'));
|
||
|
self::mergeIntoProperty($element, $prop, $val, $alwaysArray);
|
||
|
unset($value->{$prop});
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if (0 === count(get_object_vars($value))) {
|
||
|
continue; // no properties left in the @reverse-map
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Get the keyword alias from the inverse context if available
|
||
|
$activeprty = (isset($inversectx[$property]['term']))
|
||
|
? $inversectx[$property]['term']
|
||
|
: $property;
|
||
|
|
||
|
self::setProperty($element, $activeprty, $value, JsonLdException::COLLIDING_KEYWORDS);
|
||
|
|
||
|
// ... continue with next property
|
||
|
continue;
|
||
|
}
|
||
|
|
||
|
// handle @null-objects as used in framing
|
||
|
if (is_object($value) && property_exists($value, '@null')) {
|
||
|
$activeprty = $this->compactIri($property, $activectx, $inversectx, null, true, $inReverse);
|
||
|
|
||
|
if (false === property_exists($element, $activeprty)) {
|
||
|
$element->{$activeprty} = null;
|
||
|
}
|
||
|
|
||
|
continue;
|
||
|
}
|
||
|
|
||
|
// Make sure that empty arrays are preserved
|
||
|
if (0 === count($value)) {
|
||
|
$activeprty = $this->compactIri($property, $activectx, $inversectx, null, true, $inReverse);
|
||
|
|
||
|
self::mergeIntoProperty($element, $activeprty, $value);
|
||
|
|
||
|
// ... continue with next property
|
||
|
continue;
|
||
|
}
|
||
|
|
||
|
// Compact every item in value separately as they could map to different terms
|
||
|
foreach ($value as $item) {
|
||
|
$activeprty = $this->compactIri($property, $activectx, $inversectx, $item, true, $inReverse);
|
||
|
$def = $this->getPropertyDefinition($activectx, $activeprty);
|
||
|
|
||
|
if (in_array($def['@container'], array('@language', '@index'))) {
|
||
|
if (false === property_exists($element, $activeprty)) {
|
||
|
$element->{$activeprty} = new JsonObject();
|
||
|
}
|
||
|
|
||
|
$def[$def['@container']] = $item->{$def['@container']};
|
||
|
$item = $this->compactValue($item, $def, $activectx, $inversectx);
|
||
|
|
||
|
$this->compact($item, $activectx, $inversectx, $activeprty);
|
||
|
|
||
|
self::mergeIntoProperty($element->{$activeprty}, $def[$def['@container']], $item);
|
||
|
|
||
|
continue;
|
||
|
}
|
||
|
|
||
|
if (is_object($item)) {
|
||
|
if (property_exists($item, '@list')) {
|
||
|
$this->compact($item->{'@list'}, $activectx, $inversectx, $activeprty);
|
||
|
|
||
|
if (false === is_array($item->{'@list'})) {
|
||
|
$item->{'@list'} = array($item->{'@list'});
|
||
|
}
|
||
|
|
||
|
if ('@list' === $def['@container']) {
|
||
|
// a term can just hold one list if it has a @list container
|
||
|
// (we don't support lists of lists)
|
||
|
self::setProperty(
|
||
|
$element,
|
||
|
$activeprty,
|
||
|
$item->{'@list'},
|
||
|
JsonLdException::COMPACTION_TO_LIST_OF_LISTS
|
||
|
);
|
||
|
|
||
|
continue; // ... continue with next value
|
||
|
} else {
|
||
|
$result = new JsonObject();
|
||
|
|
||
|
$alias = $this->compactIri('@list', $activectx, $inversectx, null, true);
|
||
|
$result->{$alias} = $item->{'@list'};
|
||
|
|
||
|
if (isset($item->{'@index'})) {
|
||
|
$alias = $this->compactIri('@index', $activectx, $inversectx, null, true);
|
||
|
$result->{$alias} = $item->{'@index'};
|
||
|
}
|
||
|
|
||
|
$item = $result;
|
||
|
}
|
||
|
} else {
|
||
|
$this->compact($item, $activectx, $inversectx, $activeprty);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Merge value back into resulting object making sure that value is always
|
||
|
// an array if a container is set or compactArrays is set to false
|
||
|
$asArray = ((false === $this->compactArrays) || (false === $def['compactArrays']));
|
||
|
|
||
|
self::mergeIntoProperty($element, $activeprty, $item, $asArray);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Compacts a value
|
||
|
*
|
||
|
* The passed property definition must be an associative array
|
||
|
* containing the following data:
|
||
|
*
|
||
|
* <code>
|
||
|
* @type => type IRI or null
|
||
|
* @language => language code or null
|
||
|
* @index => index string or null
|
||
|
* @container => the container: @set, @list, @language, or @index
|
||
|
* </code>
|
||
|
*
|
||
|
* @param mixed $value The value to compact (arrays are not allowed!).
|
||
|
* @param array $definition The active property's definition.
|
||
|
* @param array $activectx The active context.
|
||
|
* @param array $inversectx The inverse context.
|
||
|
*
|
||
|
* @return mixed The compacted value.
|
||
|
*/
|
||
|
private function compactValue($value, $definition, $activectx, $inversectx)
|
||
|
{
|
||
|
if ('@index' === $definition['@container']) {
|
||
|
unset($value->{'@index'});
|
||
|
}
|
||
|
|
||
|
$numProperties = count(get_object_vars($value));
|
||
|
|
||
|
// @id object
|
||
|
if (property_exists($value, '@id')) {
|
||
|
if (1 === $numProperties) {
|
||
|
if ('@id' === $definition['@type']) {
|
||
|
return $this->compactIri($value->{'@id'}, $activectx, $inversectx);
|
||
|
}
|
||
|
|
||
|
if ('@vocab' === $definition['@type']) {
|
||
|
return $this->compactIri($value->{'@id'}, $activectx, $inversectx, null, true);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return $value;
|
||
|
}
|
||
|
|
||
|
// @value object
|
||
|
$criterion = (isset($value->{'@type'})) ? '@type' : null;
|
||
|
$criterion = (isset($value->{'@language'})) ? '@language' : $criterion;
|
||
|
|
||
|
if (null !== $criterion) {
|
||
|
if ((2 === $numProperties) && ($value->{$criterion} === $definition[$criterion])) {
|
||
|
return $value->{'@value'};
|
||
|
}
|
||
|
|
||
|
return $value;
|
||
|
}
|
||
|
|
||
|
// the object has neither a @type nor a @language property
|
||
|
// check the active property's definition
|
||
|
if (is_string($value->{'@value'}) && (null !== $definition['@language'])) {
|
||
|
// if the property is language tagged or there's a default language,
|
||
|
// we can't compact the value if it is a string
|
||
|
return $value;
|
||
|
}
|
||
|
|
||
|
// we can compact the value
|
||
|
return (1 === $numProperties) ? $value->{'@value'} : $value;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Compacts an absolute IRI (or aliases a keyword)
|
||
|
*
|
||
|
* If the IRI couldn't be compacted, the IRI is returned as is.
|
||
|
*
|
||
|
* @param mixed $iri The IRI to be compacted.
|
||
|
* @param array $activectx The active context.
|
||
|
* @param array $inversectx The inverse context.
|
||
|
* @param mixed $value The value of the property to compact.
|
||
|
* @param bool $vocabRelative If `true` is passed, this method tries
|
||
|
* to convert the IRI to an IRI relative to
|
||
|
* `@vocab`; otherwise, that fall back
|
||
|
* mechanism is disabled.
|
||
|
* @param bool $reverse Is the IRI used within a @reverse container?
|
||
|
*
|
||
|
* @return string Returns the compacted IRI on success; otherwise the
|
||
|
* IRI is returned as is.
|
||
|
*/
|
||
|
private function compactIri($iri, $activectx, $inversectx, $value = null, $vocabRelative = false, $reverse = false)
|
||
|
{
|
||
|
if ((true === $vocabRelative) && array_key_exists($iri, $inversectx)) {
|
||
|
if (null !== $value) {
|
||
|
$valueProfile = $this->getValueProfile($value, $inversectx);
|
||
|
|
||
|
$container = ('@list' === $valueProfile['@container'])
|
||
|
? array('@list', '@null')
|
||
|
: array($valueProfile['@container'], '@set', '@null');
|
||
|
|
||
|
if (null === $valueProfile['typeLang']) {
|
||
|
$typeOrLang = array('@null');
|
||
|
$typeOrLangValue = array('@null');
|
||
|
} else {
|
||
|
$typeOrLang = array($valueProfile['typeLang'], '@null');
|
||
|
$typeOrLangValue = array();
|
||
|
|
||
|
if (true === $reverse) {
|
||
|
$typeOrLangValue[] = '@reverse';
|
||
|
}
|
||
|
|
||
|
if (('@type' === $valueProfile['typeLang']) && ('@id' === $valueProfile['typeLangValue'])) {
|
||
|
array_push($typeOrLangValue, '@id', '@vocab', '@null');
|
||
|
} elseif (('@type' === $valueProfile['typeLang']) &&
|
||
|
('@vocab' === $valueProfile['typeLangValue'])) {
|
||
|
array_push($typeOrLangValue, '@vocab', '@id', '@null');
|
||
|
} else {
|
||
|
$typeOrLangValue = array($valueProfile['typeLangValue'], '@null');
|
||
|
}
|
||
|
}
|
||
|
|
||
|
$result = $this->queryInverseContext($inversectx[$iri], $container, $typeOrLang, $typeOrLangValue);
|
||
|
|
||
|
if (null !== $result) {
|
||
|
return $result;
|
||
|
}
|
||
|
} elseif (isset($inversectx[$iri]['term'])) {
|
||
|
return $inversectx[$iri]['term'];
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Compact using @vocab
|
||
|
if ($vocabRelative && isset($activectx['@vocab']) && (0 === strpos($iri, $activectx['@vocab'])) &&
|
||
|
(false !== ($vocabIri = substr($iri, strlen($activectx['@vocab'])))) &&
|
||
|
(false === isset($activectx[$vocabIri]))) {
|
||
|
return $vocabIri;
|
||
|
}
|
||
|
|
||
|
// Try to compact to a compact IRI
|
||
|
foreach ($inversectx as $termIri => $def) {
|
||
|
$termIriLen = strlen($termIri);
|
||
|
|
||
|
if (isset($def['term']) && (0 === strncmp($iri, $termIri, $termIriLen))) {
|
||
|
$compactIri = substr($iri, $termIriLen);
|
||
|
|
||
|
if (false !== $compactIri && '' !== $compactIri) {
|
||
|
$compactIri = $def['term'] . ':' . $compactIri;
|
||
|
|
||
|
if (false === isset($activectx[$compactIri]) ||
|
||
|
((false === $vocabRelative) && ($iri === $activectx[$compactIri]['@id']))) {
|
||
|
return $compactIri;
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Last resort, convert to a relative IRI
|
||
|
if ((false === $vocabRelative) && (null !== $activectx['@base'])) {
|
||
|
return (string) $activectx['@base']->baseFor($iri);
|
||
|
}
|
||
|
|
||
|
// IRI couldn't be compacted, return as is
|
||
|
return $iri;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Verifies whether two JSON-LD subtrees are equal not
|
||
|
*
|
||
|
* Please note that two unlabeled blank nodes will never be equal by
|
||
|
* definition.
|
||
|
*
|
||
|
* @param mixed $a The first subtree.
|
||
|
* @param mixed $b The second subree.
|
||
|
*
|
||
|
* @return bool Returns true if the two subtrees are equal; otherwise
|
||
|
* false.
|
||
|
*/
|
||
|
private static function subtreeEquals($a, $b)
|
||
|
{
|
||
|
if (gettype($a) !== gettype($b)) {
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
if (is_scalar($a)) {
|
||
|
return ($a === $b);
|
||
|
}
|
||
|
|
||
|
if (is_array($a)) {
|
||
|
$len = count($a);
|
||
|
|
||
|
if ($len !== count($b)) {
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
// TODO Ignore order for sets?
|
||
|
for ($i = 0; $i < $len; $i++) {
|
||
|
if (false === self::subtreeEquals($a[$i], $b[$i])) {
|
||
|
return false;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return true;
|
||
|
}
|
||
|
|
||
|
if (!property_exists($a, '@id') &&
|
||
|
!property_exists($a, '@value') &&
|
||
|
!property_exists($a, '@list')) {
|
||
|
// Blank nodes can never match as they can't be identified
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
$properties = array_keys(get_object_vars($a));
|
||
|
|
||
|
if (count($properties) !== count(get_object_vars($b))) {
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
foreach ($properties as $property) {
|
||
|
if ((false === property_exists($b, $property)) ||
|
||
|
(false === self::subtreeEquals($a->{$property}, $b->{$property}))) {
|
||
|
return false;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return true;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Calculates a value profile
|
||
|
*
|
||
|
* A value profile represent the schema of the value ignoring the
|
||
|
* concrete value. It is an associative array containing the following
|
||
|
* keys-value pairs:
|
||
|
*
|
||
|
* * `@container`: the container, defaults to `@set`
|
||
|
* * `typeLang`: is set to `@type` for typed values or `@language` for
|
||
|
* (language-tagged) strings; for all other values it is set to
|
||
|
* `null`
|
||
|
* * `typeLangValue`: set to the type of a typed value or the language
|
||
|
* of a language-tagged string (`@null` for all other strings); for
|
||
|
* all other values it is set to `null`
|
||
|
*
|
||
|
* @param JsonObject $value The value.
|
||
|
* @param array $inversectx The inverse context.
|
||
|
*
|
||
|
* @return array The value profile.
|
||
|
*/
|
||
|
private function getValueProfile(JsonObject $value, $inversectx)
|
||
|
{
|
||
|
$valueProfile = array(
|
||
|
'@container' => '@set',
|
||
|
'typeLang' => '@type',
|
||
|
'typeLangValue' => '@id'
|
||
|
);
|
||
|
|
||
|
if (property_exists($value, '@index')) {
|
||
|
$valueProfile['@container'] = '@index';
|
||
|
}
|
||
|
|
||
|
if (property_exists($value, '@id')) {
|
||
|
if (isset($inversectx[$value->{'@id'}]['term'])) {
|
||
|
$valueProfile['typeLangValue'] = '@vocab';
|
||
|
} else {
|
||
|
$valueProfile['typeLangValue'] = '@id';
|
||
|
}
|
||
|
|
||
|
return $valueProfile;
|
||
|
}
|
||
|
|
||
|
if (property_exists($value, '@value')) {
|
||
|
if (property_exists($value, '@type')) {
|
||
|
$valueProfile['typeLang'] = '@type';
|
||
|
$valueProfile['typeLangValue'] = $value->{'@type'};
|
||
|
} elseif (property_exists($value, '@language')) {
|
||
|
$valueProfile['typeLang'] = '@language';
|
||
|
$valueProfile['typeLangValue'] = $value->{'@language'};
|
||
|
|
||
|
if (false === property_exists($value, '@index')) {
|
||
|
$valueProfile['@container'] = '@language';
|
||
|
}
|
||
|
} else {
|
||
|
$valueProfile['typeLang'] = '@language';
|
||
|
$valueProfile['typeLangValue'] = '@null';
|
||
|
}
|
||
|
|
||
|
return $valueProfile;
|
||
|
}
|
||
|
|
||
|
if (property_exists($value, '@list')) {
|
||
|
$len = count($value->{'@list'});
|
||
|
|
||
|
if ($len > 0) {
|
||
|
$valueProfile = $this->getValueProfile($value->{'@list'}[0], $inversectx);
|
||
|
}
|
||
|
|
||
|
if (false === property_exists($value, '@index')) {
|
||
|
$valueProfile['@container'] = '@list';
|
||
|
}
|
||
|
|
||
|
|
||
|
for ($i = $len - 1; $i > 0; $i--) {
|
||
|
$profile = $this->getValueProfile($value->{'@list'}[$i], $inversectx);
|
||
|
|
||
|
if (($valueProfile['typeLang'] !== $profile['typeLang']) ||
|
||
|
($valueProfile['typeLangValue'] !== $profile['typeLangValue'])) {
|
||
|
$valueProfile['typeLang'] = null;
|
||
|
$valueProfile['typeLangValue'] = null;
|
||
|
|
||
|
return $valueProfile;
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return $valueProfile;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Queries the inverse context to find the term for a given query
|
||
|
* path (= value profile)
|
||
|
*
|
||
|
* @param array $inversectx The inverse context (or a subtree thereof)
|
||
|
* @param string[] $containers
|
||
|
* @param string[] $typeOrLangs
|
||
|
* @param string[] $typeOrLangValues
|
||
|
*
|
||
|
* @return null|string The best matching term or null if none was found.
|
||
|
*/
|
||
|
private function queryInverseContext($inversectx, $containers, $typeOrLangs, $typeOrLangValues)
|
||
|
{
|
||
|
foreach ($containers as $container) {
|
||
|
foreach ($typeOrLangs as $typeOrLang) {
|
||
|
foreach ($typeOrLangValues as $typeOrLangValue) {
|
||
|
if (isset($inversectx[$container][$typeOrLang][$typeOrLangValue])) {
|
||
|
return $inversectx[$container][$typeOrLang][$typeOrLangValue];
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return null;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Returns a property's definition
|
||
|
*
|
||
|
* The result will be in the form
|
||
|
*
|
||
|
* <code>
|
||
|
* array('@type' => type or null,
|
||
|
* '@language' => language or null,
|
||
|
* '@container' => container or null,
|
||
|
* 'isKeyword' => true or false)
|
||
|
* </code>
|
||
|
*
|
||
|
* If `$only` is set, only the value of that key of the array
|
||
|
* above will be returned.
|
||
|
*
|
||
|
* @param array $activectx The active context.
|
||
|
* @param string $property The property.
|
||
|
* @param null|string $only If set, only this element of the
|
||
|
* definition will be returned.
|
||
|
*
|
||
|
* @return array|string|null Returns either the property's definition or
|
||
|
* null if not found.
|
||
|
*/
|
||
|
private function getPropertyDefinition($activectx, $property, $only = null)
|
||
|
{
|
||
|
$result = array(
|
||
|
'@reverse' => false,
|
||
|
'@type' => null,
|
||
|
'@language' => (isset($activectx['@language']))
|
||
|
? $activectx['@language']
|
||
|
: null,
|
||
|
'@index' => null,
|
||
|
'@container' => null,
|
||
|
'isKeyword' => false,
|
||
|
'compactArrays' => true
|
||
|
);
|
||
|
|
||
|
if (in_array($property, self::$keywords)) {
|
||
|
$result['@type'] = (('@id' === $property) || ('@type' === $property))
|
||
|
? '@id'
|
||
|
: null;
|
||
|
$result['@language'] = null;
|
||
|
$result['isKeyword'] = true;
|
||
|
$result['compactArrays'] = (bool) (('@list' !== $property) && ('@graph' !== $property));
|
||
|
} else {
|
||
|
$def = (isset($activectx[$property])) ? $activectx[$property] : null;
|
||
|
|
||
|
if (null !== $def) {
|
||
|
$result['@id'] = $def['@id'];
|
||
|
$result['@reverse'] = $def['@reverse'];
|
||
|
|
||
|
if (isset($def['@type'])) {
|
||
|
$result['@type'] = $def['@type'];
|
||
|
$result['@language'] = null;
|
||
|
} elseif (array_key_exists('@language', $def)) { // could be null
|
||
|
$result['@language'] = $def['@language'];
|
||
|
}
|
||
|
|
||
|
if (isset($def['@container'])) {
|
||
|
$result['@container'] = $def['@container'];
|
||
|
|
||
|
if (('@list' === $def['@container']) || ('@set' === $def['@container'])) {
|
||
|
$result['compactArrays'] = false;
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if ($only) {
|
||
|
return (isset($result[$only])) ? $result[$only] : null;
|
||
|
}
|
||
|
|
||
|
return $result;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Processes a local context to update the active context
|
||
|
*
|
||
|
* @param mixed $loclctx The local context.
|
||
|
* @param array $activectx The active context.
|
||
|
* @param array $remotectxs The already included remote contexts.
|
||
|
*
|
||
|
* @throws JsonLdException
|
||
|
*/
|
||
|
public function processContext($loclctx, &$activectx, $remotectxs = array())
|
||
|
{
|
||
|
if (is_object($loclctx)) {
|
||
|
$loclctx = clone $loclctx;
|
||
|
}
|
||
|
|
||
|
if (false === is_array($loclctx)) {
|
||
|
$loclctx = array($loclctx);
|
||
|
}
|
||
|
|
||
|
foreach ($loclctx as $context) {
|
||
|
if (null === $context) {
|
||
|
$activectx = array('@base' => $this->baseIri);
|
||
|
} elseif (is_object($context)) {
|
||
|
// make sure we don't modify the passed context
|
||
|
$context = clone $context;
|
||
|
|
||
|
if (property_exists($context, '@base')) {
|
||
|
if (count($remotectxs) > 0) {
|
||
|
// do nothing, @base is ignored in a remote context
|
||
|
} elseif (null === $context->{'@base'}) {
|
||
|
$activectx['@base'] = null;
|
||
|
} elseif (false === is_string($context->{'@base'})) {
|
||
|
throw new JsonLdException(
|
||
|
JsonLdException::INVALID_BASE_IRI,
|
||
|
'The value of @base must be an IRI or null.',
|
||
|
$context
|
||
|
);
|
||
|
} else {
|
||
|
$base = new IRI($context->{'@base'});
|
||
|
if (false === $base->isAbsolute()) {
|
||
|
if (null === $activectx['@base']) {
|
||
|
throw new JsonLdException(
|
||
|
JsonLdException::INVALID_BASE_IRI,
|
||
|
'The relative base IRI cannot be resolved to an absolute IRI.',
|
||
|
$context
|
||
|
);
|
||
|
}
|
||
|
|
||
|
$activectx['@base'] = $activectx['@base']->resolve($base);
|
||
|
} else {
|
||
|
$activectx['@base'] = $base;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
unset($context->{'@base'});
|
||
|
}
|
||
|
|
||
|
if (property_exists($context, '@vocab')) {
|
||
|
if (null === $context->{'@vocab'}) {
|
||
|
unset($activectx['@vocab']);
|
||
|
} elseif ((false === is_string($context->{'@vocab'})) ||
|
||
|
(false === strpos($context->{'@vocab'}, ':'))) {
|
||
|
throw new JsonLdException(
|
||
|
JsonLdException::INVALID_VOCAB_MAPPING,
|
||
|
'The value of @vocab must be an absolute IRI or null.invalid vocab mapping, ',
|
||
|
$context
|
||
|
);
|
||
|
} else {
|
||
|
$activectx['@vocab'] = $context->{'@vocab'};
|
||
|
}
|
||
|
|
||
|
unset($context->{'@vocab'});
|
||
|
}
|
||
|
|
||
|
if (property_exists($context, '@language')) {
|
||
|
if ((null !== $context->{'@language'}) && (false === is_string($context->{'@language'}))) {
|
||
|
throw new JsonLdException(
|
||
|
JsonLdException::INVALID_DEFAULT_LANGUAGE,
|
||
|
'The value of @language must be a string.',
|
||
|
$context
|
||
|
);
|
||
|
}
|
||
|
|
||
|
$activectx['@language'] = $context->{'@language'};
|
||
|
unset($context->{'@language'});
|
||
|
}
|
||
|
|
||
|
foreach ($context as $key => $value) {
|
||
|
unset($context->{$key});
|
||
|
unset($activectx[$key]);
|
||
|
|
||
|
if (in_array($key, self::$keywords)) {
|
||
|
throw new JsonLdException(JsonLdException::KEYWORD_REDEFINITION, null, $key);
|
||
|
}
|
||
|
|
||
|
if ((null === $value) || is_string($value)) {
|
||
|
$value = (object) array('@id' => $value);
|
||
|
} elseif (is_object($value)) {
|
||
|
$value = clone $value; // make sure we don't modify context entries
|
||
|
} else {
|
||
|
throw new JsonLdException(JsonLdException::INVALID_TERM_DEFINITION);
|
||
|
}
|
||
|
|
||
|
if (property_exists($value, '@reverse')) {
|
||
|
if (property_exists($value, '@id')) {
|
||
|
throw new JsonLdException(
|
||
|
JsonLdException::INVALID_REVERSE_PROPERTY,
|
||
|
"Invalid term definition using both @reverse and @id detected",
|
||
|
$value
|
||
|
);
|
||
|
}
|
||
|
|
||
|
if (property_exists($value, '@container') &&
|
||
|
('@index' !== $value->{'@container'}) &&
|
||
|
('@set' !== $value->{'@container'})) {
|
||
|
throw new JsonLdException(
|
||
|
JsonLdException::INVALID_REVERSE_PROPERTY,
|
||
|
"Terms using the @reverse feature support only @set- and @index-containers.",
|
||
|
$value
|
||
|
);
|
||
|
}
|
||
|
|
||
|
$value->{'@id'} = $value->{'@reverse'};
|
||
|
$value->{'@reverse'} = true;
|
||
|
} else {
|
||
|
$value->{'@reverse'} = false;
|
||
|
}
|
||
|
|
||
|
if (property_exists($value, '@id')) {
|
||
|
if ((null !== $value->{'@id'}) && (false === is_string($value->{'@id'}))) {
|
||
|
throw new JsonLdException(JsonLdException::INVALID_IRI_MAPPING, null, $value->{'@id'});
|
||
|
}
|
||
|
|
||
|
$path = array();
|
||
|
if ($key !== $value->{'@id'}) {
|
||
|
$path[] = $key;
|
||
|
}
|
||
|
|
||
|
$expanded = $this->expandIri($value->{'@id'}, $activectx, false, true, $context, $path);
|
||
|
|
||
|
if ($value->{'@reverse'} && (false === strpos($expanded, ':'))) {
|
||
|
throw new JsonLdException(
|
||
|
JsonLdException::INVALID_IRI_MAPPING,
|
||
|
"Reverse properties must expand to absolute IRIs, \"$key\" expands to \"$expanded\"."
|
||
|
);
|
||
|
} elseif ('@context' === $expanded) {
|
||
|
throw new JsonLdException(
|
||
|
JsonLdException::INVALID_KEYWORD_ALIAS,
|
||
|
'Aliases for @context are not supported',
|
||
|
$value
|
||
|
);
|
||
|
}
|
||
|
} else {
|
||
|
$expanded = $this->expandIri($key, $activectx, false, true, $context);
|
||
|
}
|
||
|
|
||
|
|
||
|
if ((null === $expanded) || in_array($expanded, self::$keywords)) {
|
||
|
// if it's an aliased keyword or the IRI is null, we ignore all other properties
|
||
|
// TODO Should we throw an exception if there are other properties?
|
||
|
$activectx[$key] = array('@id' => $expanded, '@reverse' => false);
|
||
|
|
||
|
continue;
|
||
|
} elseif (false === strpos($expanded, ':')) {
|
||
|
throw new JsonLdException(
|
||
|
JsonLdException::INVALID_IRI_MAPPING,
|
||
|
"Failed to expand \"$key\" to an absolute IRI.",
|
||
|
$loclctx
|
||
|
);
|
||
|
}
|
||
|
|
||
|
$activectx[$key] = array('@id' => $expanded, '@reverse' => $value->{'@reverse'});
|
||
|
|
||
|
if (isset($value->{'@type'})) {
|
||
|
if (false === is_string($value->{'@type'})) {
|
||
|
throw new JsonLdException(JsonLdException::INVALID_TYPE_MAPPING);
|
||
|
}
|
||
|
|
||
|
$expanded = $this->expandIri($value->{'@type'}, $activectx, false, true, $context);
|
||
|
|
||
|
if (('@id' !== $expanded) && ('@vocab' !== $expanded) &&
|
||
|
((false === strpos($expanded, ':') || (0 === strpos($expanded, '_:'))))) {
|
||
|
throw new JsonLdException(
|
||
|
JsonLdException::INVALID_TYPE_MAPPING,
|
||
|
"Failed to expand $expanded to an absolute IRI.",
|
||
|
$loclctx
|
||
|
);
|
||
|
}
|
||
|
|
||
|
$activectx[$key]['@type'] = $expanded;
|
||
|
} elseif (property_exists($value, '@language')) {
|
||
|
if ((false === is_string($value->{'@language'})) && (null !== $value->{'@language'})) {
|
||
|
throw new JsonLdException(
|
||
|
JsonLdException::INVALID_LANGUAGE_MAPPING,
|
||
|
'The value of @language must be a string or null.',
|
||
|
$value
|
||
|
);
|
||
|
}
|
||
|
|
||
|
// Note the else. Language tagging applies just to term without type coercion
|
||
|
$activectx[$key]['@language'] = $value->{'@language'};
|
||
|
}
|
||
|
|
||
|
if (isset($value->{'@container'})) {
|
||
|
if (in_array($value->{'@container'}, array('@list', '@set', '@language', '@index'))) {
|
||
|
$activectx[$key]['@container'] = $value->{'@container'};
|
||
|
} else {
|
||
|
throw new JsonLdException(
|
||
|
JsonLdException::INVALID_CONTAINER_MAPPING,
|
||
|
'A container mapping of ' . $value->{'@container'} . ' is not supported.'
|
||
|
);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
} elseif (is_string($context)) {
|
||
|
$remoteContext = new IRI($context);
|
||
|
if ($remoteContext->isAbsolute()) {
|
||
|
$remoteContext = (string) $remoteContext;
|
||
|
} elseif (null === $activectx['@base']) {
|
||
|
throw new JsonLdException(
|
||
|
JsonLdException::INVALID_BASE_IRI,
|
||
|
'Can not resolve the relative URL of the remote context as no base has been set: ' . $remoteContext
|
||
|
);
|
||
|
} else {
|
||
|
$remoteContext = (string) $activectx['@base']->resolve($context);
|
||
|
}
|
||
|
if (in_array($remoteContext, $remotectxs)) {
|
||
|
throw new JsonLdException(
|
||
|
JsonLdException::RECURSIVE_CONTEXT_INCLUSION,
|
||
|
'Recursive inclusion of remote context: ' . join(' -> ', $remotectxs) . ' -> ' . $remoteContext
|
||
|
);
|
||
|
}
|
||
|
$remotectxs[] = $remoteContext;
|
||
|
|
||
|
try {
|
||
|
$remoteContext = $this->loadDocument($remoteContext);
|
||
|
} catch (JsonLdException $e) {
|
||
|
throw new JsonLdException(
|
||
|
JsonLdException::LOADING_REMOTE_CONTEXT_FAILED,
|
||
|
"Loading $remoteContext failed",
|
||
|
null,
|
||
|
null,
|
||
|
$e
|
||
|
);
|
||
|
}
|
||
|
|
||
|
if (is_object($remoteContext) && property_exists($remoteContext, '@context')) {
|
||
|
// TODO Use the context's IRI as base IRI when processing remote contexts (ISSUE-24)
|
||
|
$this->processContext($remoteContext->{'@context'}, $activectx, $remotectxs);
|
||
|
} else {
|
||
|
throw new JsonLdException(
|
||
|
JsonLdException::INVALID_REMOTE_CONTEXT,
|
||
|
'Remote context "' . $context . '" is invalid.',
|
||
|
$remoteContext
|
||
|
);
|
||
|
}
|
||
|
} else {
|
||
|
throw new JsonLdException(JsonLdException::INVALID_LOCAL_CONTEXT);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Load a JSON-LD document
|
||
|
*
|
||
|
* The document can be supplied directly as string, by passing a file
|
||
|
* path, or by passing a URL.
|
||
|
*
|
||
|
* @param null|string|array|JsonObject $input The JSON-LD document or a path
|
||
|
* or URL pointing to one.
|
||
|
*
|
||
|
* @return mixed The loaded JSON-LD document
|
||
|
*
|
||
|
* @throws JsonLdException
|
||
|
*/
|
||
|
private function loadDocument($input)
|
||
|
{
|
||
|
if (false === is_string($input)) {
|
||
|
// Return as is - it has already been parsed
|
||
|
return $input;
|
||
|
}
|
||
|
|
||
|
$document = $this->documentLoader->loadDocument($input);
|
||
|
|
||
|
return $document->document;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Creates an inverse context to simplify IRI compaction
|
||
|
*
|
||
|
* The inverse context is a multidimensional array that has the
|
||
|
* following shape:
|
||
|
*
|
||
|
* <code>
|
||
|
* [container|@null|term]
|
||
|
* [@type|@language][typeIRI|languageCode]
|
||
|
* [@null][@null]
|
||
|
* [term|propGen]
|
||
|
* [ array of terms ]
|
||
|
* </code>
|
||
|
*
|
||
|
* @param array $activectx The active context.
|
||
|
*
|
||
|
* @return array The inverse context.
|
||
|
*/
|
||
|
public function createInverseContext($activectx)
|
||
|
{
|
||
|
$inverseContext = array();
|
||
|
|
||
|
$defaultLanguage = isset($activectx['@language']) ? $activectx['@language'] : '@null';
|
||
|
$propertyGenerators = isset($activectx['@propertyGenerators']) ? $activectx['@propertyGenerators'] : array();
|
||
|
|
||
|
unset($activectx['@base']);
|
||
|
unset($activectx['@vocab']);
|
||
|
unset($activectx['@language']);
|
||
|
unset($activectx['@propertyGenerators']);
|
||
|
|
||
|
$activectx = array_merge($activectx, $propertyGenerators);
|
||
|
unset($propertyGenerators);
|
||
|
|
||
|
uksort($activectx, array($this, 'sortTerms'));
|
||
|
|
||
|
// Put every IRI of each term into the inverse context
|
||
|
foreach ($activectx as $term => $def) {
|
||
|
if (null === $def['@id']) {
|
||
|
// this is necessary since some terms can be decoupled from @vocab
|
||
|
continue;
|
||
|
}
|
||
|
|
||
|
$container = (isset($def['@container'])) ? $def['@container'] : '@null';
|
||
|
$iri = $def['@id'];
|
||
|
|
||
|
if (false === isset($inverseContext[$iri]['term']) && (false === $def['@reverse'])) {
|
||
|
$inverseContext[$iri]['term'] = $term;
|
||
|
}
|
||
|
|
||
|
$typeOrLang = '@null';
|
||
|
$typeLangValue = '@null';
|
||
|
|
||
|
if (true === $def['@reverse']) {
|
||
|
$typeOrLang = '@type';
|
||
|
$typeLangValue = '@reverse';
|
||
|
} elseif (isset($def['@type'])) {
|
||
|
$typeOrLang = '@type';
|
||
|
$typeLangValue = $def['@type'];
|
||
|
} elseif (array_key_exists('@language', $def)) { // can be null
|
||
|
$typeOrLang = '@language';
|
||
|
$typeLangValue = (null === $def['@language']) ? '@null' : $def['@language'];
|
||
|
} else {
|
||
|
// Every untyped term is implicitly set to the default language
|
||
|
if (false === isset($inverseContext[$iri][$container]['@language'][$defaultLanguage])) {
|
||
|
$inverseContext[$iri][$container]['@language'][$defaultLanguage] = $term;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if (false === isset($inverseContext[$iri][$container][$typeOrLang][$typeLangValue])) {
|
||
|
$inverseContext[$iri][$container][$typeOrLang][$typeLangValue] = $term;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Sort the whole inverse context in reverse order, the longest IRI comes first
|
||
|
uksort($inverseContext, array($this, 'sortTerms'));
|
||
|
$inverseContext = array_reverse($inverseContext);
|
||
|
|
||
|
return $inverseContext;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Creates a node map of an expanded JSON-LD document
|
||
|
*
|
||
|
* All keys in the node map are prefixed with "-" to support empty strings.
|
||
|
*
|
||
|
* @param JsonObject $nodeMap The object holding the node map.
|
||
|
* @param JsonObject|JsonObject[] $element An expanded JSON-LD element to
|
||
|
* be put into the node map
|
||
|
* @param string $activegraph The graph currently being processed.
|
||
|
* @param null|string $activeid The node currently being processed.
|
||
|
* @param null|string $activeprty The property currently being processed.
|
||
|
* @param null|JsonObject $list The list object if a list is being
|
||
|
* processed.
|
||
|
*/
|
||
|
private function generateNodeMap(
|
||
|
&$nodeMap,
|
||
|
$element,
|
||
|
$activegraph = JsonLD::DEFAULT_GRAPH,
|
||
|
$activeid = null,
|
||
|
$activeprty = null,
|
||
|
&$list = null
|
||
|
) {
|
||
|
if (is_array($element)) {
|
||
|
foreach ($element as $item) {
|
||
|
$this->generateNodeMap($nodeMap, $item, $activegraph, $activeid, $activeprty, $list);
|
||
|
}
|
||
|
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
// Relabel blank nodes in @type and add a node to the current graph
|
||
|
if (property_exists($element, '@type')) {
|
||
|
$types = null;
|
||
|
|
||
|
if (is_array($element->{'@type'})) {
|
||
|
$types = &$element->{'@type'};
|
||
|
} else {
|
||
|
$types = array(&$element->{'@type'});
|
||
|
}
|
||
|
|
||
|
foreach ($types as &$type) {
|
||
|
if (0 === strncmp($type, '_:', 2)) {
|
||
|
$type = $this->getBlankNodeId($type);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if (property_exists($element, '@value')) {
|
||
|
// Handle value objects
|
||
|
if (null === $list) {
|
||
|
$this->mergeIntoProperty(
|
||
|
$nodeMap->{'-' . $activegraph}->{'-' . $activeid},
|
||
|
$activeprty,
|
||
|
$element,
|
||
|
true,
|
||
|
true
|
||
|
);
|
||
|
} else {
|
||
|
$this->mergeIntoProperty($list, '@list', $element, true, false);
|
||
|
}
|
||
|
} elseif (property_exists($element, '@list')) {
|
||
|
// lists
|
||
|
$result = new JsonObject();
|
||
|
$result->{'@list'} = array();
|
||
|
|
||
|
$this->generateNodeMap($nodeMap, $element->{'@list'}, $activegraph, $activeid, $activeprty, $result);
|
||
|
$this->mergeIntoProperty(
|
||
|
$nodeMap->{'-' . $activegraph}->{'-' . $activeid},
|
||
|
$activeprty,
|
||
|
$result,
|
||
|
true,
|
||
|
false
|
||
|
);
|
||
|
} else {
|
||
|
// and node objects
|
||
|
if (false === property_exists($element, '@id')) {
|
||
|
$id = $this->getBlankNodeId();
|
||
|
} elseif (0 === strncmp($element->{'@id'}, '_:', 2)) {
|
||
|
$id = $this->getBlankNodeId($element->{'@id'});
|
||
|
} else {
|
||
|
$id = $element->{'@id'};
|
||
|
}
|
||
|
unset($element->{'@id'});
|
||
|
|
||
|
// Create node in node map if it doesn't exist yet
|
||
|
if (false === property_exists($nodeMap->{'-' . $activegraph}, '-' . $id)) {
|
||
|
$node = new JsonObject();
|
||
|
$node->{'@id'} = $id;
|
||
|
$nodeMap->{'-' . $activegraph}->{'-' . $id} = $node;
|
||
|
} else {
|
||
|
$node = $nodeMap->{'-' . $activegraph}->{'-' . $id};
|
||
|
}
|
||
|
|
||
|
// Add reference to active property
|
||
|
if (is_object($activeid)) {
|
||
|
$this->mergeIntoProperty($node, $activeprty, $activeid, true, true);
|
||
|
} elseif (null !== $activeprty) {
|
||
|
$reference = new JsonObject();
|
||
|
$reference->{'@id'} = $id;
|
||
|
|
||
|
if (null === $list) {
|
||
|
$this->mergeIntoProperty(
|
||
|
$nodeMap->{'-' . $activegraph}->{'-' . $activeid},
|
||
|
$activeprty,
|
||
|
$reference,
|
||
|
true,
|
||
|
true
|
||
|
);
|
||
|
} else {
|
||
|
$this->mergeIntoProperty($list, '@list', $reference, true, false);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if (property_exists($element, '@type')) {
|
||
|
$this->mergeIntoProperty($node, '@type', $element->{'@type'}, true, true);
|
||
|
unset($element->{'@type'});
|
||
|
}
|
||
|
|
||
|
if (property_exists($element, '@index')) {
|
||
|
$this->setProperty(
|
||
|
$node,
|
||
|
'@index',
|
||
|
$element->{'@index'},
|
||
|
JsonLdException::CONFLICTING_INDEXES
|
||
|
);
|
||
|
unset($element->{'@index'});
|
||
|
}
|
||
|
|
||
|
if (property_exists($element, '@reverse')) {
|
||
|
$reference = array('@id' => $id);
|
||
|
|
||
|
// First, add the reverse property to all nodes pointing to this node and then
|
||
|
// add them to the node mape
|
||
|
foreach (get_object_vars($element->{'@reverse'}) as $property => $value) {
|
||
|
foreach ($value as $val) {
|
||
|
$this->generateNodeMap($nodeMap, $val, $activegraph, (object) $reference, $property);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
unset($element->{'@reverse'});
|
||
|
}
|
||
|
|
||
|
// This node also represent a named graph, process it
|
||
|
if (property_exists($element, '@graph')) {
|
||
|
if (JsonLD::MERGED_GRAPH !== $activegraph) {
|
||
|
if (false === property_exists($nodeMap, '-' . $id)) {
|
||
|
$nodeMap->{'-' . $id} = new JsonObject();
|
||
|
}
|
||
|
|
||
|
$this->generateNodeMap($nodeMap, $element->{'@graph'}, $id);
|
||
|
} else {
|
||
|
$this->generateNodeMap($nodeMap, $element->{'@graph'}, JsonLD::MERGED_GRAPH);
|
||
|
}
|
||
|
|
||
|
unset($element->{'@graph'});
|
||
|
}
|
||
|
|
||
|
// Process all other properties in order
|
||
|
$properties = get_object_vars($element);
|
||
|
ksort($properties);
|
||
|
|
||
|
foreach ($properties as $property => $value) {
|
||
|
if (0 === strncmp($property, '_:', 2)) {
|
||
|
$property = $this->getBlankNodeId($property);
|
||
|
}
|
||
|
|
||
|
if (false === property_exists($node, $property)) {
|
||
|
$node->{$property} = array();
|
||
|
}
|
||
|
|
||
|
$this->generateNodeMap($nodeMap, $value, $activegraph, $id, $property);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Generate a new blank node identifier
|
||
|
*
|
||
|
* If an identifier is passed, a new blank node identifier is generated
|
||
|
* for it and stored for subsequent use. Calling the method with the same
|
||
|
* identifier (except null) will thus always return the same blank node
|
||
|
* identifier.
|
||
|
*
|
||
|
* @param null|string $id If available, existing blank node identifier.
|
||
|
*
|
||
|
* @return string Returns a blank node identifier.
|
||
|
*/
|
||
|
private function getBlankNodeId($id = null)
|
||
|
{
|
||
|
if ((null !== $id) && isset($this->blankNodeMap[$id])) {
|
||
|
return $this->blankNodeMap[$id];
|
||
|
}
|
||
|
|
||
|
$bnode = '_:b' . $this->blankNodeCounter++;
|
||
|
$this->blankNodeMap[$id] = $bnode;
|
||
|
|
||
|
return $bnode;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Flattens a JSON-LD document
|
||
|
*
|
||
|
* @param mixed $element A JSON-LD element to be flattened.
|
||
|
*
|
||
|
* @return array An array representing the flattened element.
|
||
|
*/
|
||
|
public function flatten($element)
|
||
|
{
|
||
|
$nodeMap = new JsonObject();
|
||
|
$nodeMap->{'-' . JsonLD::DEFAULT_GRAPH} = new JsonObject();
|
||
|
|
||
|
$this->generateNodeMap($nodeMap, $element);
|
||
|
|
||
|
$defaultGraph = $nodeMap->{'-' . JsonLD::DEFAULT_GRAPH};
|
||
|
unset($nodeMap->{'-' . JsonLD::DEFAULT_GRAPH});
|
||
|
|
||
|
// Store named graphs in the @graph property of the node representing
|
||
|
// the graph in the default graph
|
||
|
foreach ($nodeMap as $graphName => $graph) {
|
||
|
if (!isset($defaultGraph->{$graphName})) {
|
||
|
$defaultGraph->{$graphName} = new JsonObject();
|
||
|
$defaultGraph->{$graphName}->{'@id'} = substr($graphName, 1);
|
||
|
}
|
||
|
|
||
|
$graph = (array) $graph;
|
||
|
ksort($graph);
|
||
|
$defaultGraph->{$graphName}->{'@graph'} = array_values(
|
||
|
array_filter($graph, array($this, 'hasNodeProperties'))
|
||
|
);
|
||
|
}
|
||
|
|
||
|
$defaultGraph = (array) $defaultGraph;
|
||
|
ksort($defaultGraph);
|
||
|
|
||
|
return array_values(
|
||
|
array_filter($defaultGraph, array($this, 'hasNodeProperties'))
|
||
|
);
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Converts an expanded JSON-LD document to RDF quads
|
||
|
*
|
||
|
* The result is an array of Quads.
|
||
|
*
|
||
|
* @param array $document The expanded JSON-LD document to be transformed into quads.
|
||
|
*
|
||
|
* @return Quad[] The extracted quads.
|
||
|
*/
|
||
|
public function toRdf(array $document)
|
||
|
{
|
||
|
$nodeMap = new JsonObject();
|
||
|
$nodeMap->{'-' . JsonLD::DEFAULT_GRAPH} = new JsonObject();
|
||
|
|
||
|
$this->generateNodeMap($nodeMap, $document);
|
||
|
|
||
|
$result = array();
|
||
|
|
||
|
foreach ($nodeMap as $graphName => $graph) {
|
||
|
$graphName = substr($graphName, 1);
|
||
|
if (JsonLD::DEFAULT_GRAPH === $graphName) {
|
||
|
$activegraph = null;
|
||
|
} else {
|
||
|
$activegraph = new IRI($graphName);
|
||
|
|
||
|
if (false === $activegraph->isAbsolute()) {
|
||
|
continue;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
foreach ($graph as $subject => $node) {
|
||
|
$activesubj = new IRI(substr($subject, 1));
|
||
|
|
||
|
if (false === $activesubj->isAbsolute()) {
|
||
|
continue;
|
||
|
}
|
||
|
|
||
|
foreach ($node as $property => $values) {
|
||
|
if ('@id' === $property) {
|
||
|
continue;
|
||
|
} elseif ('@type' === $property) {
|
||
|
$activeprty = new IRI(RdfConstants::RDF_TYPE);
|
||
|
foreach ($values as $value) {
|
||
|
$result[] = new Quad($activesubj, $activeprty, new IRI($value), $activegraph);
|
||
|
}
|
||
|
|
||
|
continue;
|
||
|
} elseif ('@' === $property[0]) {
|
||
|
continue;
|
||
|
}
|
||
|
|
||
|
// Exclude triples/quads with a blank node predicate if generalized RDF isn't enabled
|
||
|
if ((0 === strncmp($property, '_:', 2)) && (false === $this->generalizedRdf)) {
|
||
|
continue;
|
||
|
}
|
||
|
|
||
|
$activeprty = new IRI($property);
|
||
|
if (false === $activeprty->isAbsolute()) {
|
||
|
continue;
|
||
|
}
|
||
|
|
||
|
foreach ($values as $value) {
|
||
|
if (property_exists($value, '@list')) {
|
||
|
$quads = array();
|
||
|
$head = $this->listToRdf($value->{'@list'}, $quads, $activegraph);
|
||
|
|
||
|
$result[] = new Quad($activesubj, $activeprty, $head, $activegraph);
|
||
|
foreach ($quads as $quad) {
|
||
|
$result[] = $quad;
|
||
|
}
|
||
|
} else {
|
||
|
$object = $this->elementToRdf($value);
|
||
|
|
||
|
if (null === $object) {
|
||
|
continue;
|
||
|
}
|
||
|
|
||
|
$result[] = new Quad($activesubj, $activeprty, $object, $activegraph);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return $result;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Converts a JSON-LD element to a RDF Quad object
|
||
|
*
|
||
|
* @param JsonObject $element The element to be converted.
|
||
|
*
|
||
|
* @return IRI|TypedValue|LanguageTagged|null The converted element to be used as Quad object.
|
||
|
*/
|
||
|
private function elementToRdf(JsonObject $element)
|
||
|
{
|
||
|
if (property_exists($element, '@value')) {
|
||
|
return Value::fromJsonLd($element);
|
||
|
}
|
||
|
|
||
|
$iri = new IRI($element->{'@id'});
|
||
|
|
||
|
return $iri->isAbsolute() ? $iri : null;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Converts a JSON-LD list to a linked RDF list (quads)
|
||
|
*
|
||
|
* @param array $entries The list entries
|
||
|
* @param array $quads The array to be used to hold the linked list
|
||
|
* @param null|IRI $graph The graph to be used in the constructed Quads
|
||
|
*
|
||
|
* @return IRI Returns the IRI of the head of the list
|
||
|
*/
|
||
|
private function listToRdf(array $entries, array &$quads, IRI $graph = null)
|
||
|
{
|
||
|
if (0 === count($entries)) {
|
||
|
return new IRI(RdfConstants::RDF_NIL);
|
||
|
}
|
||
|
|
||
|
$head = new IRI($this->getBlankNodeId());
|
||
|
$quads[] = new Quad($head, new IRI(RdfConstants::RDF_FIRST), $this->elementToRdf($entries[0]), $graph);
|
||
|
|
||
|
$bnode = $head;
|
||
|
for ($i = 1, $len = count($entries); $i < $len; $i++) {
|
||
|
$next = new IRI($this->getBlankNodeId());
|
||
|
|
||
|
$quads[] = new Quad($bnode, new IRI(RdfConstants::RDF_REST), $next, $graph);
|
||
|
|
||
|
$object = $this->elementToRdf($entries[$i]);
|
||
|
if (null !== $object) {
|
||
|
$quads[] = new Quad($next, new IRI(RdfConstants::RDF_FIRST), $object, $graph);
|
||
|
}
|
||
|
|
||
|
$bnode = $next;
|
||
|
}
|
||
|
|
||
|
$quads[] = new Quad($bnode, new IRI(RdfConstants::RDF_REST), new IRI(RdfConstants::RDF_NIL), $graph);
|
||
|
|
||
|
return $head;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Converts an array of RDF quads to a JSON-LD document
|
||
|
*
|
||
|
* The resulting JSON-LD document will be in expanded form.
|
||
|
*
|
||
|
* @param Quad[] $quads The quads to convert
|
||
|
*
|
||
|
* @return array The JSON-LD document.
|
||
|
*
|
||
|
* @throws InvalidQuadException If the quad is invalid.
|
||
|
*/
|
||
|
public function fromRdf(array $quads)
|
||
|
{
|
||
|
$graphs = new JsonObject();
|
||
|
$graphs->{JsonLD::DEFAULT_GRAPH} = new JsonObject();
|
||
|
$usages = new JsonObject();
|
||
|
|
||
|
foreach ($quads as $quad) {
|
||
|
$graphName = JsonLD::DEFAULT_GRAPH;
|
||
|
|
||
|
if ($quad->getGraph()) {
|
||
|
$graphName = (string) $quad->getGraph();
|
||
|
|
||
|
// Add a reference to this graph to the default graph if it
|
||
|
// doesn't exist yet
|
||
|
if (false === isset($graphs->{JsonLD::DEFAULT_GRAPH}->{$graphName})) {
|
||
|
$graphs->{JsonLD::DEFAULT_GRAPH}->{$graphName} =
|
||
|
self::objectToJsonLd($quad->getGraph());
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if (false === isset($graphs->{$graphName})) {
|
||
|
$graphs->{$graphName} = new JsonObject();
|
||
|
}
|
||
|
$graph = $graphs->{$graphName};
|
||
|
|
||
|
// Subjects and properties are always IRIs (blank nodes are IRIs
|
||
|
// as well): convert them to a string representation
|
||
|
$subject = (string) $quad->getSubject();
|
||
|
$property = (string) $quad->getProperty();
|
||
|
$object = $quad->getObject();
|
||
|
|
||
|
// All nodes are stored in the node map
|
||
|
if (false === isset($graph->{$subject})) {
|
||
|
$graph->{$subject} = self::objectToJsonLd($quad->getSubject());
|
||
|
}
|
||
|
$node = $graph->{$subject};
|
||
|
|
||
|
// ... as are all objects that are IRIs or blank nodes
|
||
|
if (($object instanceof IRI) && (false === isset($graph->{(string) $object}))) {
|
||
|
$graph->{(string) $object} = self::objectToJsonLd($object);
|
||
|
}
|
||
|
|
||
|
if (($property === RdfConstants::RDF_TYPE) && (false === $this->useRdfType) &&
|
||
|
($object instanceof IRI)) {
|
||
|
self::mergeIntoProperty($node, '@type', (string) $object, true, true);
|
||
|
} else {
|
||
|
$value = self::objectToJsonLd($object, $this->useNativeTypes);
|
||
|
|
||
|
self::mergeIntoProperty($node, $property, $value, true, true);
|
||
|
|
||
|
// If the object is an IRI or blank node it might be the
|
||
|
// beginning of a list. Store a reference to its usage so
|
||
|
// that we can replace it with a list object later
|
||
|
if ($object instanceof IRI) {
|
||
|
$objectStr = (string) $object;
|
||
|
|
||
|
// Usages of rdf:nil are stored per graph, while...
|
||
|
if (RdfConstants::RDF_NIL == $objectStr) {
|
||
|
$graph->{$objectStr}->usages[] = array(
|
||
|
'node' => $node,
|
||
|
'prop' => $property,
|
||
|
'value' => $value);
|
||
|
// references to other nodes are stored globally (blank nodes could be shared across graphs)
|
||
|
} else {
|
||
|
if (!isset($usages->{$objectStr})) {
|
||
|
$usages->{$objectStr} = array();
|
||
|
}
|
||
|
|
||
|
// Make sure that the same triple isn't counted multiple times
|
||
|
// TODO Making $usages->{$objectStr} a set would make this code simpler
|
||
|
$graphSubjectProperty = $graphName . '|' . $subject . '|' . $property;
|
||
|
if (false === isset($usages->{$objectStr}[$graphSubjectProperty])) {
|
||
|
$usages->{$objectStr}[$graphSubjectProperty] = array(
|
||
|
'graph' => $graphName,
|
||
|
'node' => $node,
|
||
|
'prop' => $property,
|
||
|
'value' => $value);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Transform linked lists to @list objects
|
||
|
$this->createListObjects($graphs, $usages);
|
||
|
|
||
|
// Generate the resulting document starting with the default graph
|
||
|
$document = array();
|
||
|
|
||
|
$nodes = get_object_vars($graphs->{JsonLD::DEFAULT_GRAPH});
|
||
|
ksort($nodes);
|
||
|
|
||
|
foreach ($nodes as $id => $node) {
|
||
|
// is it a named graph?
|
||
|
if (isset($graphs->{$id})) {
|
||
|
$node->{'@graph'} = array();
|
||
|
|
||
|
$graphNodes = get_object_vars($graphs->{$id});
|
||
|
ksort($graphNodes);
|
||
|
|
||
|
foreach ($graphNodes as $graphNodeId => $graphNode) {
|
||
|
// Only add the node when it has properties other than @id
|
||
|
if (count(get_object_vars($graphNode)) > 1) {
|
||
|
$node->{'@graph'}[] = $graphNode;
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if (count(get_object_vars($node)) > 1) {
|
||
|
$document[] = $node;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return $document;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Reconstruct @list arrays from linked list structures
|
||
|
*
|
||
|
* @param JsonObject $graphs The graph map
|
||
|
* @param JsonObject $usages The global node usage map
|
||
|
*/
|
||
|
private function createListObjects($graphs, $usages)
|
||
|
{
|
||
|
foreach ($graphs as $graph) {
|
||
|
if (false === isset($graph->{RdfConstants::RDF_NIL})) {
|
||
|
continue;
|
||
|
}
|
||
|
|
||
|
$nil = $graph->{RdfConstants::RDF_NIL};
|
||
|
|
||
|
foreach ($nil->usages as $usage) {
|
||
|
$u = $usage;
|
||
|
|
||
|
$node = $u['node'];
|
||
|
$prop = $u['prop'];
|
||
|
$head = $u['value'];
|
||
|
|
||
|
$list = array();
|
||
|
$listNodes = array();
|
||
|
|
||
|
while ((RdfConstants::RDF_REST === $prop) &&
|
||
|
(1 === count($usages->{$node->{'@id'}})) &&
|
||
|
property_exists($node, RdfConstants::RDF_FIRST) &&
|
||
|
property_exists($node, RdfConstants::RDF_REST) &&
|
||
|
(1 === count($node->{RdfConstants::RDF_FIRST})) &&
|
||
|
(1 === count($node->{RdfConstants::RDF_REST})) &&
|
||
|
((3 === count(get_object_vars($node))) || // only @id, rdf:first & rdf:next
|
||
|
((4 === count(get_object_vars($node))) && // or an additional rdf:type = rdf:List
|
||
|
property_exists($node, '@type') &&
|
||
|
($node->{'@type'} === array(RdfConstants::RDF_LIST)))
|
||
|
)
|
||
|
) {
|
||
|
$list[] = reset($node->{RdfConstants::RDF_FIRST});
|
||
|
$listNodes[] = $node->{'@id'};
|
||
|
|
||
|
|
||
|
$u = reset($usages->{$node->{'@id'}});
|
||
|
$node = $u['node'];
|
||
|
$prop = $u['prop'];
|
||
|
$head = $u['value'];
|
||
|
|
||
|
if (0 !== strncmp($node->{'@id'}, '_:', 2)) {
|
||
|
break;
|
||
|
}
|
||
|
};
|
||
|
|
||
|
// The list is nested in another list
|
||
|
if (RdfConstants::RDF_FIRST === $prop) {
|
||
|
// If it is empty, we can't do anything but keep the rdf:nil node
|
||
|
if (RdfConstants::RDF_NIL === $head->{'@id'}) {
|
||
|
continue;
|
||
|
}
|
||
|
|
||
|
// ... otherwise we keep the head and convert the rest to @list
|
||
|
$head = $graph->{$head->{'@id'}};
|
||
|
$head = reset($head->{RdfConstants::RDF_REST});
|
||
|
|
||
|
array_pop($list);
|
||
|
array_pop($listNodes);
|
||
|
}
|
||
|
|
||
|
unset($head->{'@id'});
|
||
|
$head->{'@list'} = array_reverse($list);
|
||
|
|
||
|
foreach ($listNodes as $node) {
|
||
|
unset($graph->{$node});
|
||
|
}
|
||
|
}
|
||
|
|
||
|
unset($nil->usages);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Frames a JSON-LD document according a supplied frame
|
||
|
*
|
||
|
* @param array|JsonObject $element A JSON-LD element to be framed.
|
||
|
* @param mixed $frame The frame.
|
||
|
*
|
||
|
* @return array $result The framed element in expanded form.
|
||
|
*
|
||
|
* @throws JsonLdException
|
||
|
*/
|
||
|
public function frame($element, $frame)
|
||
|
{
|
||
|
if ((false === is_array($frame)) || (1 !== count($frame)) || (false === is_object($frame[0]))) {
|
||
|
throw new JsonLdException(
|
||
|
JsonLdException::UNSPECIFIED,
|
||
|
'The frame is invalid. It must be a single object.',
|
||
|
$frame
|
||
|
);
|
||
|
}
|
||
|
|
||
|
$frame = $frame[0];
|
||
|
|
||
|
$options = new JsonObject();
|
||
|
$options->{'@embed'} = true;
|
||
|
$options->{'@embedChildren'} = true; // TODO Change this as soon as the tests haven been updated
|
||
|
|
||
|
foreach (self::$framingKeywords as $keyword) {
|
||
|
if (property_exists($frame, $keyword)) {
|
||
|
$options->{$keyword} = $frame->{$keyword};
|
||
|
unset($frame->{$keyword});
|
||
|
} elseif (false === property_exists($options, $keyword)) {
|
||
|
$options->{$keyword} = false;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
$procOptions = new JsonObject();
|
||
|
$procOptions->base = (string) $this->baseIri; // TODO Check which base IRI to use
|
||
|
$procOptions->compactArrays = $this->compactArrays;
|
||
|
$procOptions->optimize = $this->optimize;
|
||
|
$procOptions->useNativeTypes = $this->useNativeTypes;
|
||
|
$procOptions->useRdfType = $this->useRdfType;
|
||
|
$procOptions->produceGeneralizedRdf = $this->generalizedRdf;
|
||
|
$procOptions->documentFactory = $this->documentFactory;
|
||
|
$procOptions->documentLoader = $this->documentLoader;
|
||
|
|
||
|
$processor = new Processor($procOptions);
|
||
|
|
||
|
$graph = JsonLD::MERGED_GRAPH;
|
||
|
if (property_exists($frame, '@graph')) {
|
||
|
$graph = JsonLD::DEFAULT_GRAPH;
|
||
|
}
|
||
|
|
||
|
$nodeMap = new JsonObject();
|
||
|
$nodeMap->{'-' . $graph} = new JsonObject();
|
||
|
$processor->generateNodeMap($nodeMap, $element, $graph);
|
||
|
|
||
|
// Sort the node map to ensure a deterministic output
|
||
|
// TODO Move this to a separate function as basically the same is done in flatten()?
|
||
|
$nodeMap = (array) $nodeMap;
|
||
|
foreach ($nodeMap as &$nodes) {
|
||
|
$nodes = (array) $nodes;
|
||
|
ksort($nodes);
|
||
|
$nodes = (object) $nodes;
|
||
|
}
|
||
|
$nodeMap = (object) $nodeMap;
|
||
|
|
||
|
unset($processor);
|
||
|
|
||
|
$result = array();
|
||
|
|
||
|
foreach ($nodeMap->{'-' . $graph} as $node) {
|
||
|
$this->nodeMatchesFrame($node, $frame, $options, $nodeMap, $graph, $result);
|
||
|
}
|
||
|
|
||
|
return $result;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Checks whether a node matches a frame or not.
|
||
|
*
|
||
|
* @param JsonObject $node The node.
|
||
|
* @param null|JsonObject $frame The frame.
|
||
|
* @param JsonObject $options The current framing options.
|
||
|
* @param JsonObject $nodeMap The node map.
|
||
|
* @param string $graph The currently used graph.
|
||
|
* @param array $parent The parent to which matching results should be added.
|
||
|
* @param array $path The path of already processed nodes.
|
||
|
*
|
||
|
* @return bool Returns true if the node matches the frame, otherwise false.
|
||
|
*/
|
||
|
private function nodeMatchesFrame($node, $frame, $options, $nodeMap, $graph, &$parent, $path = array())
|
||
|
{
|
||
|
// TODO How should lists be handled? Is the @list required in the frame (current behavior) or not?
|
||
|
// https://github.com/json-ld/json-ld.org/issues/110
|
||
|
// TODO Add support for '@omitDefault'?
|
||
|
$filter = null;
|
||
|
if (null !== $frame) {
|
||
|
$filter = get_object_vars($frame);
|
||
|
}
|
||
|
|
||
|
$result = new JsonObject();
|
||
|
|
||
|
// Make sure that @id is always in the result if the node matches the filter
|
||
|
if (property_exists($node, '@id')) {
|
||
|
$result->{'@id'} = $node->{'@id'};
|
||
|
|
||
|
if ((null === $filter) && in_array($node->{'@id'}, $path)) {
|
||
|
$parent[] = $result;
|
||
|
|
||
|
return true;
|
||
|
}
|
||
|
|
||
|
$path[] = $node->{'@id'};
|
||
|
}
|
||
|
|
||
|
// If no filter is specified, simply return the passed node - {} is a wildcard
|
||
|
if ((null === $filter) || (0 === count($filter))) {
|
||
|
// TODO What effect should @explicit have with a wildcard match?
|
||
|
if (is_object($node)) {
|
||
|
if ((true === $options->{'@embed'}) || (false === property_exists($node, '@id'))) {
|
||
|
$this->addMissingNodeProperties($node, $options, $nodeMap, $graph, $result, $path);
|
||
|
}
|
||
|
|
||
|
$parent[] = $result;
|
||
|
} else {
|
||
|
$parent[] = $node;
|
||
|
}
|
||
|
|
||
|
return true;
|
||
|
}
|
||
|
|
||
|
foreach ($filter as $property => $validValues) {
|
||
|
if (is_array($validValues) && (0 === count($validValues))) {
|
||
|
if (property_exists($node, $property) ||
|
||
|
(('@graph' === $property) && isset($result->{'@id'}) &&
|
||
|
property_exists($nodeMap, $result->{'@id'}))) {
|
||
|
return false; // [] says that the property must not exist but it does
|
||
|
}
|
||
|
|
||
|
continue;
|
||
|
}
|
||
|
|
||
|
// If the property does not exist or is empty
|
||
|
if ((false === property_exists($node, $property)) || (is_array($node->{$property}) && 0 === count($node->{$property}))) {
|
||
|
// first check if it's @graph and whether the referenced graph exists
|
||
|
if ('@graph' === $property) {
|
||
|
if (isset($result->{'@id'}) && property_exists($nodeMap, $result->{'@id'})) {
|
||
|
$result->{'@graph'} = array();
|
||
|
$match = false;
|
||
|
|
||
|
foreach ($nodeMap->{'-' . $result->{'@id'}} as $item) {
|
||
|
foreach ($validValues as $validValue) {
|
||
|
$match |= $this->nodeMatchesFrame(
|
||
|
$item,
|
||
|
$validValue,
|
||
|
$options,
|
||
|
$nodeMap,
|
||
|
$result->{'@id'},
|
||
|
$result->{'@graph'}
|
||
|
);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if (false === $match) {
|
||
|
return false;
|
||
|
} else {
|
||
|
continue; // with next property
|
||
|
}
|
||
|
} else {
|
||
|
// the referenced graph doesn't exist
|
||
|
return false;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// otherwise, look if we have a default value for it
|
||
|
if (false === is_array($validValues)) {
|
||
|
$validValues = array($validValues);
|
||
|
}
|
||
|
|
||
|
$defaultFound = false;
|
||
|
foreach ($validValues as $validValue) {
|
||
|
if (is_object($validValue) && property_exists($validValue, '@default')) {
|
||
|
if (null === $validValue->{'@default'}) {
|
||
|
$result->{$property} = new JsonObject();
|
||
|
$result->{$property}->{'@null'} = true;
|
||
|
} else {
|
||
|
$result->{$property} = (is_array($validValue->{'@default'}))
|
||
|
? $validValue->{'@default'}
|
||
|
: array($validValue->{'@default'});
|
||
|
}
|
||
|
$defaultFound = true;
|
||
|
break;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if (true === $defaultFound) {
|
||
|
continue;
|
||
|
}
|
||
|
|
||
|
return false; // required property does not exist and no default value was found
|
||
|
}
|
||
|
|
||
|
// Check whether the values of the property match the filter
|
||
|
$match = false;
|
||
|
$result->{$property} = array();
|
||
|
|
||
|
if (false === is_array($validValues)) {
|
||
|
if ($node->{$property} === $validValues) {
|
||
|
$result->{$property} = $node->{$property};
|
||
|
continue;
|
||
|
} else {
|
||
|
return false;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
foreach ($validValues as $validValue) {
|
||
|
if (is_object($validValue)) {
|
||
|
// Extract framing options from subframe ($validValue is a subframe)
|
||
|
$validValue = clone $validValue;
|
||
|
$newOptions = clone $options;
|
||
|
unset($newOptions->{'@default'});
|
||
|
|
||
|
foreach (self::$framingKeywords as $keyword) {
|
||
|
if (property_exists($validValue, $keyword)) {
|
||
|
$newOptions->{$keyword} = $validValue->{$keyword};
|
||
|
unset($validValue->{$keyword});
|
||
|
}
|
||
|
}
|
||
|
|
||
|
$nodeValues = $node->{$property};
|
||
|
if (false === is_array($nodeValues)) {
|
||
|
$nodeValues = array($nodeValues);
|
||
|
}
|
||
|
|
||
|
foreach ($nodeValues as $value) {
|
||
|
if (is_object($value) && property_exists($value, '@id')) {
|
||
|
$match |= $this->nodeMatchesFrame(
|
||
|
$nodeMap->{'-' . $graph}->{'-' . $value->{'@id'}},
|
||
|
$validValue,
|
||
|
$newOptions,
|
||
|
$nodeMap,
|
||
|
$graph,
|
||
|
$result->{$property},
|
||
|
$path
|
||
|
);
|
||
|
} else {
|
||
|
$match |= $this->nodeMatchesFrame(
|
||
|
$value,
|
||
|
$validValue,
|
||
|
$newOptions,
|
||
|
$nodeMap,
|
||
|
$graph,
|
||
|
$result->{$property},
|
||
|
$path
|
||
|
);
|
||
|
}
|
||
|
}
|
||
|
} elseif (is_array($validValue)) {
|
||
|
throw new JsonLdException(
|
||
|
JsonLdException::UNSPECIFIED,
|
||
|
"Invalid frame detected. Property \"$property\" must not be an array of arrays.",
|
||
|
$frame
|
||
|
);
|
||
|
} else {
|
||
|
// This will just catch non-expanded IRIs for @id and @type
|
||
|
$nodeValues = $node->{$property};
|
||
|
if (false === is_array($nodeValues)) {
|
||
|
$nodeValues = array($nodeValues);
|
||
|
}
|
||
|
|
||
|
if (in_array($validValue, $nodeValues)) {
|
||
|
$match = true;
|
||
|
$result->{$property} = $node->{$property};
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if (false === $match) {
|
||
|
return false;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Discard subtree if this object should not be embedded
|
||
|
if ((false === $options->{'@embed'}) && property_exists($node, '@id')) {
|
||
|
$result = new JsonObject();
|
||
|
$result->{'@id'} = $node->{'@id'};
|
||
|
$parent[] = $result;
|
||
|
|
||
|
return true;
|
||
|
}
|
||
|
|
||
|
// all properties matched the filter, add the properties of the
|
||
|
// node which haven't been added yet
|
||
|
if (false === $options->{'@explicit'}) {
|
||
|
$this->addMissingNodeProperties($node, $options, $nodeMap, $graph, $result, $path);
|
||
|
}
|
||
|
|
||
|
$parent[] = $result;
|
||
|
|
||
|
return true;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Adds all properties from node to result if they haven't been added yet
|
||
|
*
|
||
|
* @param JsonObject $node The node whose properties should processed.
|
||
|
* @param JsonObject $options The current framing options.
|
||
|
* @param JsonObject $nodeMap The node map.
|
||
|
* @param string $graph The currently used graph.
|
||
|
* @param JsonObject $result The object to which the properties should be added.
|
||
|
* @param array $path The path of already processed nodes.
|
||
|
*/
|
||
|
private function addMissingNodeProperties($node, $options, $nodeMap, $graph, &$result, $path)
|
||
|
{
|
||
|
foreach ($node as $property => $value) {
|
||
|
if (property_exists($result, $property)) {
|
||
|
continue; // property has already been added
|
||
|
}
|
||
|
|
||
|
if (true === $options->{'@embedChildren'}) {
|
||
|
if (false === is_array($value)) {
|
||
|
$result->{$property} = unserialize(serialize($value)); // create a deep-copy
|
||
|
continue;
|
||
|
}
|
||
|
|
||
|
$result->{$property} = array();
|
||
|
foreach ($value as $item) {
|
||
|
if (is_object($item)) {
|
||
|
if (property_exists($item, '@id')) {
|
||
|
$item = $nodeMap->{'-' . $graph}->{'-' . $item->{'@id'}};
|
||
|
}
|
||
|
|
||
|
$this->nodeMatchesFrame($item, null, $options, $nodeMap, $graph, $result->{$property}, $path);
|
||
|
} else {
|
||
|
$result->{$property}[] = $item;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
} else {
|
||
|
// TODO Perform deep object copy??
|
||
|
$result->{$property} = unserialize(serialize($value)); // create a deep-copy
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Adds a property to an object if it doesn't exist yet
|
||
|
*
|
||
|
* If the property already exists, an exception is thrown as otherwise
|
||
|
* the existing value would be lost.
|
||
|
*
|
||
|
* @param JsonObject $object The object.
|
||
|
* @param string $property The name of the property.
|
||
|
* @param mixed $value The value of the property.
|
||
|
*
|
||
|
* @throws JsonLdException If the property exists already JSON-LD.
|
||
|
*/
|
||
|
private static function setProperty(&$object, $property, $value, $errorCode = null)
|
||
|
{
|
||
|
if (property_exists($object, $property) &&
|
||
|
(false === self::subtreeEquals($object->{$property}, $value))) {
|
||
|
|
||
|
if ($errorCode) {
|
||
|
throw new JsonLdException(
|
||
|
$errorCode,
|
||
|
"Object already contains a property \"$property\".",
|
||
|
$object
|
||
|
);
|
||
|
}
|
||
|
|
||
|
throw new JsonLdException(
|
||
|
JsonLdException::UNSPECIFIED,
|
||
|
"Object already contains a property \"$property\".",
|
||
|
$object
|
||
|
);
|
||
|
}
|
||
|
|
||
|
$object->{$property} = $value;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Merges a value into a property of an object
|
||
|
*
|
||
|
* @param JsonObject $object The object.
|
||
|
* @param string $property The name of the property to which the value
|
||
|
* should be merged into.
|
||
|
* @param mixed $value The value to merge into the property.
|
||
|
* @param bool $alwaysArray If set to true, the resulting property will
|
||
|
* always be an array.
|
||
|
* @param bool $unique If set to true, the value is only added if
|
||
|
* it doesn't exist yet.
|
||
|
*/
|
||
|
private static function mergeIntoProperty(&$object, $property, $value, $alwaysArray = false, $unique = false)
|
||
|
{
|
||
|
// No need to add a null value
|
||
|
if (null === $value) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
if (is_array($value)) {
|
||
|
// Make sure empty arrays are created since we preserve them in expansion
|
||
|
if ((0 === count($value)) && (false === property_exists($object, $property))) {
|
||
|
$object->{$property} = array();
|
||
|
}
|
||
|
|
||
|
foreach ($value as $val) {
|
||
|
static::mergeIntoProperty($object, $property, $val, $alwaysArray, $unique);
|
||
|
}
|
||
|
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
if (property_exists($object, $property)) {
|
||
|
if (false === is_array($object->{$property})) {
|
||
|
$object->{$property} = array($object->{$property});
|
||
|
}
|
||
|
|
||
|
if ($unique) {
|
||
|
foreach ($object->{$property} as $item) {
|
||
|
if (self::subtreeEquals($item, $value)) {
|
||
|
return;
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
$object->{$property}[] = $value;
|
||
|
} else {
|
||
|
$object->{$property} = ($alwaysArray) ? array($value) : $value;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Compares two values by their length and then lexicographically
|
||
|
*
|
||
|
* If two strings have different lengths, the shorter one will be
|
||
|
* considered less than the other. If they have the same length, they
|
||
|
* are compared lexicographically.
|
||
|
*
|
||
|
* @param mixed $a Value A.
|
||
|
* @param mixed $b Value B.
|
||
|
*
|
||
|
* @return int If value A is shorter than value B, -1 will be returned; if it's
|
||
|
* longer 1 will be returned. If both values have the same length
|
||
|
* and value A is considered lexicographically less, -1 will be
|
||
|
* returned, if they are equal 0 will be returned, otherwise 1
|
||
|
* will be returned.
|
||
|
*/
|
||
|
private static function sortTerms($a, $b)
|
||
|
{
|
||
|
$lenA = strlen($a);
|
||
|
$lenB = strlen($b);
|
||
|
|
||
|
if ($lenA < $lenB) {
|
||
|
return -1;
|
||
|
} elseif ($lenA === $lenB) {
|
||
|
return strcmp($a, $b);
|
||
|
} else {
|
||
|
return 1;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Converts an object to a JSON-LD representation
|
||
|
*
|
||
|
* Only {@link IRI IRIs}, {@link LanguageTaggedString language-tagged strings},
|
||
|
* and {@link TypedValue typed values} are converted by this method. All
|
||
|
* other objects are returned as-is.
|
||
|
*
|
||
|
* @param JsonObject $object The object to convert.
|
||
|
* @param boolean $useNativeTypes If set to true, native types are used
|
||
|
* for xsd:integer, xsd:double, and
|
||
|
* xsd:boolean, otherwise typed strings
|
||
|
* will be used instead.
|
||
|
*
|
||
|
* @return mixed The JSON-LD representation of the object.
|
||
|
*/
|
||
|
private static function objectToJsonLd($object, $useNativeTypes = true)
|
||
|
{
|
||
|
if ($object instanceof IRI) {
|
||
|
$result = new JsonObject();
|
||
|
$result->{'@id'} = (string) $object;
|
||
|
|
||
|
return $result;
|
||
|
} elseif ($object instanceof Value) {
|
||
|
return $object->toJsonLd($useNativeTypes);
|
||
|
}
|
||
|
|
||
|
return $object;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Checks whether a node has properties and not just an @id
|
||
|
*
|
||
|
* This is used to filter nodes consisting just of an @id-member when
|
||
|
* flattening and converting from RDF.
|
||
|
*
|
||
|
* @param JsonObject $node The node
|
||
|
*
|
||
|
* @return boolean True if the node has properties (other than @id),
|
||
|
* false otherwise.
|
||
|
*/
|
||
|
private function hasNodeProperties($node)
|
||
|
{
|
||
|
return (count(get_object_vars($node)) > 1);
|
||
|
}
|
||
|
}
|