Code Coverage
 
Classes and Traits
Functions and Methods
Lines
Total
0.00% covered (danger)
0.00%
0 / 1
72.22% covered (warning)
72.22%
13 / 18
CRAP
95.83% covered (success)
95.83%
161 / 168
JsonLdRdfWriter
0.00% covered (danger)
0.00%
0 / 1
72.22% covered (warning)
72.22%
13 / 18
64
95.83% covered (success)
95.83%
161 / 168
 __construct
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
12 / 12
 encode
100.00% covered (success)
100.00%
1 / 1
2
100.00% covered (success)
100.00%
5 / 5
 compactify
0.00% covered (danger)
0.00%
0 / 1
7.04
90.91% covered (success)
90.91%
10 / 11
 toIRI
0.00% covered (danger)
0.00%
0 / 1
2.03
80.00% covered (warning)
80.00%
4 / 5
 getCurrentTerm
0.00% covered (danger)
0.00%
0 / 1
7.39
80.00% covered (warning)
80.00%
12 / 15
 beginJson
100.00% covered (success)
100.00%
1 / 1
2
100.00% covered (success)
100.00%
6 / 6
 finishJson
100.00% covered (success)
100.00%
1 / 1
5
100.00% covered (success)
100.00%
13 / 13
 finishDocument
100.00% covered (success)
100.00%
1 / 1
5
100.00% covered (success)
100.00%
14 / 14
 writeSubject
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
3 / 3
 finishSubject
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
3 / 3
 writePredicate
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
1 / 1
 writeResource
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
6 / 6
 writeText
100.00% covered (success)
100.00%
1 / 1
2
100.00% covered (success)
100.00%
6 / 6
 writeValue
100.00% covered (success)
100.00%
1 / 1
8
100.00% covered (success)
100.00%
24 / 24
 addTypedValue
0.00% covered (danger)
0.00%
0 / 1
10
95.00% covered (success)
95.00%
19 / 20
 finishPredicate
0.00% covered (danger)
0.00%
0 / 1
7.01
94.44% covered (success)
94.44%
17 / 18
 newSubWriter
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
5 / 5
 getMimeType
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
1 / 1
<?php
namespace Wikimedia\Purtle;
use LogicException;
/**
 * RdfWriter implementation for generating JSON-LD output.
 *
 * @license GPL-2.0-or-later
 * @author C. Scott Ananian
 */
class JsonLdRdfWriter extends RdfWriterBase {
    /**
     * The JSON-LD "@context", which maps terms to IRIs. This is shared with all sub-writers, and a
     * single context is emitted when the writer is finalized.
     *
     * @see https://www.w3.org/TR/json-ld/#the-context
     *
     * @var string[]
     */
    protected $context = [];
    /**
     * A set of predicates which rely on the default typing rules for
     * JSON-LD; that is, values for the predicate have been emitted which
     * would be broken if an explicit "@type" was added to the context
     * for the predicate.
     *
     * @var bool[]
     */
    protected $defaulted = [];
    /**
     * The JSON-LD "@graph", which lists all the nodes described by this JSON-LD object.
     * We apply an optimization eliminating the "@graph" entry if it consists
     * of a single node; in that case we will set $this->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.
     */
    private 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.
     */
    private 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.
     *
     * @return string
     */
    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 = floatval( $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( static 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';
    }
}