wishthis/vendor/ml/json-ld/Processor.php

2974 lines
111 KiB
PHP
Raw Normal View History

2022-01-21 08:23:52 +00:00
<?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);
}
}