graph to null in * #finishJson() to ensure that the deferred callback in #finishDocument() * doesn't later emit "@graph". * * @see https://www.w3.org/TR/json-ld/#named-graphs * * @var array[]|null */ private $graph = []; /** * A collection of predicates about a specific subject. The * subject is identified by the "@id" key in this array; the other * keys identify JSON-LD properties. * * @see https://www.w3.org/TR/json-ld/#dfn-edge * * @var array */ private $predicates = []; /** * A sequence of zero or more IRIs, nodes, or values, which are the * destination targets of the current predicates. * * @see https://www.w3.org/TR/json-ld/#dfn-list * * @var array */ private $values = []; /** * True iff we have written the opening of the "@graph" field. * * @var bool */ private $wroteGraph = false; /** * JSON-LD objects describing a single node can omit the "@graph" field; * this variable remains false only so long as we can guarantee that * only a single node has been described. * * @var bool */ private $disableGraphOpt = false; /** * The IRI for the RDF `type` property. */ const RDF_TYPE_IRI = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type'; /** * The type internally used for "default type", which is a string or * otherwise default-coerced type. */ const DEFAULT_TYPE = '@purtle@default@'; /** * @param string $role * @param BNodeLabeler|null $labeler */ public function __construct( $role = parent::DOCUMENT_ROLE, BNodeLabeler $labeler = null ) { parent::__construct( $role, $labeler ); // The following named methods are protected, not private, so we // can invoke them directly w/o function wrappers. $this->transitionTable[self::STATE_START][self::STATE_DOCUMENT] = [ $this, 'beginJson' ]; $this->transitionTable[self::STATE_DOCUMENT][self::STATE_FINISH] = [ $this, 'finishJson' ]; $this->transitionTable[self::STATE_OBJECT][self::STATE_PREDICATE] = [ $this, 'finishPredicate' ]; $this->transitionTable[self::STATE_OBJECT][self::STATE_SUBJECT] = [ $this, 'finishSubject' ]; $this->transitionTable[self::STATE_OBJECT][self::STATE_DOCUMENT] = [ $this, 'finishDocument' ]; } /** * Emit $val as JSON, with $indent extra indentations on each line. * @param array $val * @param int $indent * @return string the JSON string for $val */ public function encode( $val, $indent ) { $str = json_encode( $val, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ); // Strip outermost open/close braces/brackets $str = preg_replace( '/^[[{]\n?|\n?[}\]]$/', '', $str ); if ( $indent > 0 ) { // add extra indentation $str = preg_replace( '/^/m', str_repeat( ' ', $indent ), $str ); } return $str; } /** * Return a "compact IRI" corresponding to the given base/local pair. * This adds entries to the "@context" key when needed to allow use * of a given prefix. * @see https://www.w3.org/TR/json-ld/#dfn-compact-iri * * @param string $base A QName prefix if $local is given, or an IRI if $local is null. * @param string|null $local A QName suffix, or null if $base is an IRI. * * @return string A compact IRI. */ private function compactify( $base, $local = null ) { $this->expandShorthand( $base, $local ); if ( $local === null ) { return $base; } else { if ( $base !== '_' && isset( $this->prefixes[ $base ] ) ) { if ( $base === '' ) { // Empty prefix not supported; use full IRI return $this->prefixes[ $base ] . $local; } if ( !isset( $this->context[ $base ] ) ) { $this->context[ $base ] = $this->prefixes[ $base ]; } if ( $this->context[ $base ] !== $this->prefixes[ $base ] ) { // Context name conflict; use full IRI return $this->prefixes[ $base ] . $local; } } return $base . ':' . $local; } } /** * Return an absolute IRI from the given base/local pair. * @see https://www.w3.org/TR/json-ld/#dfn-absolute-iri * * @param string $base A QName prefix if $local is given, or an IRI if $local is null. * @param string|null $local A QName suffix, or null if $base is an IRI. * * @return string|null An absolute IRI, or null if it cannot be constructed. */ private function toIRI( $base, $local ) { $this->expandShorthand( $base, $local ); $this->expandQName( $base, $local ); if ( $local !== null ) { throw new LogicException( 'Unknown prefix: ' . $base ); } return $base; } /** * Return a appropriate term for the current predicate value. */ private function getCurrentTerm() { list( $base, $local ) = $this->currentPredicate; $predIRI = $this->toIRI( $base, $local ); if ( $predIRI === self::RDF_TYPE_IRI ) { return $predIRI; } $this->expandShorthand( $base, $local ); if ( $local === null ) { return $base; } elseif ( $base !== '_' && !isset( $this->prefixes[ $local ] ) ) { // Prefixes get priority over field names in @context $pred = $this->compactify( $base, $local ); if ( !isset( $this->context[ $local ] ) ) { $this->context[ $local ] = [ '@id' => $pred ]; } if ( $this->context[ $local ][ '@id' ] === $pred ) { return $local; } return $pred; } return $this->compactify( $base, $local ); } /** * Write document header. */ protected function beginJson() { if ( $this->role === self::DOCUMENT_ROLE ) { $this->write( "{\n" ); $this->write( function () { // If this buffer is drained early, disable @graph optimization $this->disableGraphOpt = true; return ''; } ); } } /** * Write document footer. */ protected function finishJson() { // If we haven't drained yet, and @graph has only 1 element, then we // can optimize our output and hoist the single node to top level. if ( $this->role === self::DOCUMENT_ROLE ) { if ( ( !$this->disableGraphOpt ) && count( $this->graph ) === 1 ) { $this->write( $this->encode( $this->graph[0], 0 ) ); $this->graph = null; // We're done with @graph. } else { $this->disableGraphOpt = true; $this->write( "\n ]" ); } } if ( count( $this->context ) ) { // Write @context field. $this->write( ",\n" ); $this->write( $this->encode( [ '@context' => $this->context ], 0 ) ); } $this->write( "\n}" ); } protected function finishDocument() { $this->finishSubject(); $this->write( function () { // if this is drained before finishJson(), then disable // the graph optimization and dump what we've got so far. $str = ''; if ( $this->graph !== null && count( $this->graph ) > 0 ) { $this->disableGraphOpt = true; if ( $this->role === self::DOCUMENT_ROLE && !$this->wroteGraph ) { $str .= " \"@graph\": [\n"; $this->wroteGraph = true; } else { $str .= ",\n"; } $str .= $this->encode( $this->graph, 1 ); $this->graph = []; return $str; } // Delay; maybe we'll be able to optimize this later. return $str; } ); } /** * @param string $base * @param string|null $local */ protected function writeSubject( $base, $local = null ) { $this->predicates = [ '@id' => $this->compactify( $base, $local ) ]; } protected function finishSubject() { $this->finishPredicate(); $this->graph[] = $this->predicates; } /** * @param string $base * @param string|null $local */ protected function writePredicate( $base, $local = null ) { // no op } /** * @param string $base * @param string|null $local */ protected function writeResource( $base, $local = null ) { $pred = $this->getCurrentTerm(); $value = $this->compactify( $base, $local ); $this->addTypedValue( '@id', $value, [ '@id' => $value ], ( $pred === self::RDF_TYPE_IRI ) ); } /** * @param string $text * @param string|null $language */ protected function writeText( $text, $language = null ) { if ( !$this->isValidLanguageCode( $language ) ) { $this->addTypedValue( self::DEFAULT_TYPE, $text ); } else { $expanded = [ '@language' => $language, '@value' => $text ]; $this->addTypedValue( self::DEFAULT_TYPE, $expanded, $expanded ); } } /** * @param string $literal * @param string|null $typeBase * @param string|null $typeLocal */ public function writeValue( $literal, $typeBase, $typeLocal = null ) { if ( $typeBase === null && $typeLocal === null ) { $this->addTypedValue( self::DEFAULT_TYPE, $literal ); return; } switch ( $this->toIRI( $typeBase, $typeLocal ) ) { case 'http://www.w3.org/2001/XMLSchema#string': $this->addTypedValue( self::DEFAULT_TYPE, strval( $literal ) ); return; case 'http://www.w3.org/2001/XMLSchema#integer': $this->addTypedValue( self::DEFAULT_TYPE, intval( $literal ) ); return; case 'http://www.w3.org/2001/XMLSchema#boolean': $this->addTypedValue( self::DEFAULT_TYPE, ( $literal === 'true' ) ); return; case 'http://www.w3.org/2001/XMLSchema#double': $v = doubleval( $literal ); // Only "numbers with fractions" are xsd:double. We need // to verify that the JSON string will contain a decimal // point, otherwise the value would be interpreted as an // xsd:integer. // TODO: consider instead using JSON_PRESERVE_ZERO_FRACTION // in $this->encode() once our required PHP >= 5.6.6. // OTOH, the spec language is ambiguous about whether "5." // would be considered an integer or a double. if ( strpos( json_encode( $v ), '.' ) !== false ) { $this->addTypedValue( self::DEFAULT_TYPE, $v ); return; } } $type = $this->compactify( $typeBase, $typeLocal ); $literal = strval( $literal ); $this->addTypedValue( $type, $literal, [ '@type' => $type, '@value' => $literal ] ); } /** * Add a typed value for the given predicate. If possible, adds a * default type to the context to avoid having to repeat type information * in each value for this predicate. If there is already a default * type which conflicts with this one, or if $forceExpand is true, * then use the "expanded" value which will explicitly override any * default type. * * @param string $type The compactified JSON-LD @type for this value, or * self::DEFAULT_TYPE to indicate the default JSON-LD type coercion rules * should be used. * @param string|int|float|bool $simpleVal The "simple" representation * for this value, used if the type can be hoisted into the context. * @param array|null $expandedVal The "expanded" representation for this * value, used if the context @type conflicts with this value; or null * to use "@value" for the expanded representation. * @param bool $forceExpand If true, don't try to add this type to the * context. Defaults to false. */ protected function addTypedValue( $type, $simpleVal, $expandedVal=null, $forceExpand=false ) { if ( !$forceExpand ) { $pred = $this->getCurrentTerm(); if ( $type === self::DEFAULT_TYPE ) { if ( !isset( $this->context[ $pred ][ '@type' ] ) ) { $this->defaulted[ $pred ] = true; } if ( isset( $this->defaulted[ $pred ] ) ) { $this->values[] = $simpleVal; return; } } elseif ( !isset( $this->defaulted[ $pred ] ) ) { if ( !isset( $this->context[ $pred ] ) ) { $this->context[ $pred ] = []; } if ( !isset( $this->context[ $pred ][ '@type' ] ) ) { $this->context[ $pred ][ '@type' ] = $type; } if ( $this->context[ $pred ][ '@type' ] === $type ) { $this->values[] = $simpleVal; return; } } } if ( $expandedVal === null ) { $this->values[] = [ '@value' => $simpleVal ]; } else { $this->values[] = $expandedVal; } } protected function finishPredicate() { $name = $this->getCurrentTerm(); if ( $name === self::RDF_TYPE_IRI ) { $name = '@type'; $this->values = array_map( function ( array $val ) { return $val[ '@id' ]; }, $this->values ); } if ( isset( $this->predicates[$name] ) ) { $was = $this->predicates[$name]; // Wrap $was into a numeric indexed array if it isn't already. // Note that $was could have non-numeric indices, eg // [ "@id" => "foo" ], in which was it still needs to be wrapped. if ( !( is_array( $was ) && isset( $was[0] ) ) ) { $was = [ $was ]; } $this->values = array_merge( $was, $this->values ); } $cnt = count( $this->values ); if ( $cnt === 0 ) { throw new LogicException( 'finishPredicate can\'t be called without at least one value' ); } elseif ( $cnt === 1 ) { $this->predicates[$name] = $this->values[0]; } else { $this->predicates[$name] = $this->values; } $this->values = []; } /** * @param string $role * @param BNodeLabeler $labeler * * @return RdfWriterBase */ protected function newSubWriter( $role, BNodeLabeler $labeler ) { $writer = new self( $role, $labeler ); // Have subwriter share context with this parent. $writer->context = &$this->context; $writer->defaulted = &$this->defaulted; // We can't use the @graph optimization. $this->disableGraphOpt = true; return $writer; } /** * @return string a MIME type */ public function getMimeType() { return 'application/ld+json; charset=UTF-8'; } }