Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.98% covered (success)
92.98%
106 / 114
72.41% covered (warning)
72.41%
21 / 29
CRAP
0.00% covered (danger)
0.00%
0 / 1
RdfWriterBase
92.98% covered (success)
92.98%
106 / 114
72.41% covered (warning)
72.41%
21 / 29
59.16
0.00% covered (danger)
0.00%
0 / 1
 __construct
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 newSubWriter
n/a
0 / 0
n/a
0 / 0
0
 registerShorthand
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 prefix
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 isShorthand
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isPrefix
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getPrefixes
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isValidLanguageCode
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 sub
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 getRole
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 write
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 expandShorthand
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 expandQName
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
4.13
 blank
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 start
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 finish
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 drain
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 flattenBuffer
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
4.13
 drainSubs
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 about
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 a
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 say
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 is
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 text
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 value
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
7
 state
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
4.03
 writeSubject
n/a
0 / 0
n/a
0 / 0
0
 writePredicate
n/a
0 / 0
n/a
0 / 0
0
 writeResource
n/a
0 / 0
n/a
0 / 0
0
 writeText
n/a
0 / 0
n/a
0 / 0
0
 writeValue
n/a
0 / 0
n/a
0 / 0
0
 expandSubject
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 expandPredicate
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 expandResource
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 expandType
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace Wikimedia\Purtle;
4
5use Closure;
6use InvalidArgumentException;
7use LogicException;
8
9/**
10 * Base class for RdfWriter implementations.
11 *
12 * Subclasses have to implement at least the writeXXX() methods to generate the desired output
13 * for the respective RDF constructs. Subclasses may override the startXXX() and finishXXX()
14 * methods to generate structural output, and override expandXXX() to transform identifiers.
15 *
16 * @license GPL-2.0-or-later
17 * @author Daniel Kinzler
18 */
19abstract class RdfWriterBase implements RdfWriter {
20
21    /**
22     * @var array An array of strings, RdfWriters, or closures.
23     */
24    private $buffer = [];
25
26    /**
27     * @var RdfWriter[] sub-writers.
28     */
29    private $subs = [];
30
31    protected const STATE_START = 0;
32    protected const STATE_DOCUMENT = 5;
33    protected const STATE_SUBJECT = 10;
34    protected const STATE_PREDICATE = 11;
35    protected const STATE_OBJECT = 12;
36    protected const STATE_FINISH = 666;
37
38    /**
39     * @var int the current state
40     */
41    private $state = self::STATE_START;
42
43    /**
44     * Shorthands that can be used in place of IRIs, e.g. ("a" to mean rdf:type).
45     *
46     * @var string[][] a map of shorthand names to [ $base, $local ] pairs.
47     * @todo Handle "a" as a special case directly. Use for custom "variables" like %currentValue
48     *  instead.
49     */
50    private $shorthands = [];
51
52    /**
53     * @var string[] a map of prefixes to base IRIs
54     */
55    protected $prefixes = [];
56
57    /**
58     * @var array pair to store the current subject.
59     * Holds the $base and $local parameters passed to about().
60     */
61    protected $currentSubject = [ null, null ];
62
63    /**
64     * @var array pair to store the current predicate.
65     * Holds the $base and $local parameters passed to say().
66     */
67    protected $currentPredicate = [ null, null ];
68
69    /**
70     * @var BNodeLabeler
71     */
72    private $labeler;
73
74    /**
75     * Role ID for writers that will generate a full RDF document.
76     */
77    public const DOCUMENT_ROLE = 'document';
78    public const SUBDOCUMENT_ROLE = 'sub';
79
80    /**
81     * @var string The writer's role, see the XXX_ROLE constants.
82     */
83    protected $role;
84
85    /**
86     * Are prefixed locked against modification?
87     * @var bool
88     */
89    private $prefixesLocked = false;
90
91    /**
92     * @param string $role The writer's role, use the XXX_ROLE constants.
93     * @param BNodeLabeler|null $labeler
94     *
95     * @throws InvalidArgumentException
96     */
97    public function __construct( $role, ?BNodeLabeler $labeler = null ) {
98        if ( !is_string( $role ) ) {
99            throw new InvalidArgumentException( '$role must be a string' );
100        }
101
102        $this->role = $role;
103        $this->labeler = $labeler ?: new BNodeLabeler();
104
105        $this->registerShorthand( 'a', 'rdf', 'type' );
106
107        $this->prefix( 'rdf', 'http://www.w3.org/1999/02/22-rdf-syntax-ns#' );
108        $this->prefix( 'xsd', 'http://www.w3.org/2001/XMLSchema#' );
109    }
110
111    /**
112     * @param string $role
113     * @param BNodeLabeler $labeler
114     *
115     * @return RdfWriterBase
116     */
117    abstract protected function newSubWriter( $role, BNodeLabeler $labeler );
118
119    /**
120     * Registers a shorthand that can be used instead of a qname,
121     * like 'a' can be used instead of 'rdf:type'.
122     *
123     * @param string $shorthand
124     * @param string $prefix
125     * @param string $local
126     */
127    protected function registerShorthand( $shorthand, $prefix, $local ) {
128        $this->shorthands[$shorthand] = [ $prefix, $local ];
129    }
130
131    /**
132     * Registers a prefix
133     *
134     * @param string $prefix
135     * @param string $iri The base IRI
136     *
137     * @throws LogicException
138     */
139    public function prefix( $prefix, $iri ) {
140        if ( $this->prefixesLocked ) {
141            throw new LogicException( 'Prefixes can not be added after start()' );
142        }
143
144        $this->prefixes[$prefix] = $iri;
145    }
146
147    /**
148     * Determines whether $shorthand can be used as a shorthand.
149     *
150     * @param string $shorthand
151     *
152     * @return bool
153     */
154    protected function isShorthand( $shorthand ) {
155        return isset( $this->shorthands[$shorthand] );
156    }
157
158    /**
159     * Determines whether $shorthand can legally be used as a prefix.
160     *
161     * @param string $prefix
162     *
163     * @return bool
164     */
165    protected function isPrefix( $prefix ) {
166        return isset( $this->prefixes[$prefix] );
167    }
168
169    /**
170     * Returns the prefix map.
171     *
172     * @return string[] An associative array mapping prefixes to base IRIs.
173     */
174    public function getPrefixes() {
175        return $this->prefixes;
176    }
177
178    /**
179     * @param string|null $languageCode
180     *
181     * @return bool
182     */
183    protected function isValidLanguageCode( $languageCode ) {
184        // preg_match is somewhat (12%) slower than strspn but more readable
185        return $languageCode !== null && preg_match( '/^[\da-z-]{2,}$/i', $languageCode );
186    }
187
188    /**
189     * @return RdfWriter
190     */
191    final public function sub() {
192        $writer = $this->newSubWriter( self::SUBDOCUMENT_ROLE, $this->labeler );
193        $writer->state = self::STATE_DOCUMENT;
194
195        // share registered prefixes
196        $writer->prefixes =& $this->prefixes;
197
198        $this->subs[] = $writer;
199        return $writer;
200    }
201
202    /**
203     * @return string A string corresponding to one of the the XXX_ROLE constants.
204     */
205    final public function getRole() {
206        return $this->role;
207    }
208
209    /**
210     * Appends string to the output buffer.
211     * @param string $w
212     */
213    final protected function write( $w ) {
214        $this->buffer[] = $w;
215    }
216
217    /**
218     * If $base is a shorthand, $base and $local are updated to hold whatever qname
219     * the shorthand was associated with.
220     *
221     * Otherwise, $base and $local remain unchanged.
222     *
223     * @param string &$base
224     * @param string|null &$local
225     */
226    protected function expandShorthand( &$base, &$local ) {
227        if ( $local === null && isset( $this->shorthands[$base] ) ) {
228            [ $base, $local ] = $this->shorthands[$base];
229        }
230    }
231
232    /**
233     * If $base is a registered prefix, $base will be replaced by the base IRI associated with
234     * that prefix, with $local appended. $local will be set to null.
235     *
236     * Otherwise, $base and $local remain unchanged.
237     *
238     * @param string &$base
239     * @param string|null &$local
240     *
241     * @throws LogicException
242     */
243    protected function expandQName( &$base, &$local ) {
244        if ( $local !== null && $base !== '_' ) {
245            if ( isset( $this->prefixes[$base] ) ) {
246                $base = $this->prefixes[$base] . $local; // XXX: can we avoid this concat?
247                $local = null;
248            } else {
249                throw new LogicException( 'Unknown prefix: ' . $base );
250            }
251        }
252    }
253
254    /**
255     * @see RdfWriter::blank()
256     *
257     * @param string|null $label node label; will be generated if not given.
258     *
259     * @return string
260     */
261    final public function blank( $label = null ) {
262        return $this->labeler->getLabel( $label );
263    }
264
265    /**
266     * @see RdfWriter::start()
267     */
268    final public function start() {
269        $this->state( self::STATE_DOCUMENT );
270        $this->prefixesLocked = true;
271    }
272
273    /**
274     * @see RdfWriter::finish()
275     */
276    final public function finish() {
277        // close all unclosed states
278        $this->state( self::STATE_DOCUMENT );
279
280        // ...then insert output of sub-writers into the buffer,
281        // so it gets placed before the footer...
282        $this->drainSubs();
283
284        // and then finalize
285        $this->state( self::STATE_FINISH );
286
287        // Detaches all subs.
288        $this->subs = [];
289    }
290
291    /**
292     * @see RdfWriter::drain()
293     *
294     * @return string RDF
295     */
296    final public function drain() {
297        // we can drain after finish, but finish state is sticky
298        if ( $this->state !== self::STATE_FINISH ) {
299            $this->state( self::STATE_DOCUMENT );
300        }
301
302        $this->drainSubs();
303        $this->flattenBuffer();
304
305        $rdf = implode( '', $this->buffer );
306        $this->buffer = [];
307
308        return $rdf;
309    }
310
311    /**
312     * Calls drain() an any RdfWriter instances in $this->buffer, and replaces them
313     * in $this->buffer with the string returned by the drain() call. Any closures
314     * present in the $this->buffer will be called, and replaced by their return value.
315     */
316    private function flattenBuffer() {
317        foreach ( $this->buffer as &$b ) {
318            if ( $b instanceof Closure ) {
319                $b = $b();
320            }
321            if ( $b instanceof RdfWriter ) {
322                $b = $b->drain();
323            }
324        }
325    }
326
327    /**
328     * Drains all subwriters, and appends their output to this writer's buffer.
329     * Subwriters remain usable.
330     */
331    private function drainSubs() {
332        foreach ( $this->subs as $sub ) {
333            $rdf = $sub->drain();
334            $this->write( $rdf );
335        }
336    }
337
338    /**
339     * @see RdfWriter::about()
340     *
341     * @param string $base A QName prefix if $local is given, or an IRI if $local is null.
342     * @param string|null $local A QName suffix, or null if $base is an IRI.
343     *
344     * @return RdfWriter $this
345     */
346    final public function about( $base, $local = null ) {
347        $this->expandSubject( $base, $local );
348
349        if ( $this->state === self::STATE_OBJECT
350            && $base === $this->currentSubject[0]
351            && $local === $this->currentSubject[1]
352        ) {
353            return $this; // redundant about() call
354        }
355
356        $this->state( self::STATE_SUBJECT );
357
358        $this->currentSubject[0] = $base;
359        $this->currentSubject[1] = $local;
360        $this->currentPredicate[0] = null;
361        $this->currentPredicate[1] = null;
362
363        $this->writeSubject( $base, $local );
364        return $this;
365    }
366
367    /**
368     * @see RdfWriter::a()
369     * Shorthand for say( 'a' )->is( $type ).
370     *
371     * @param string $typeBase The data type's QName prefix if $typeLocal is given,
372     *        or an IRI or shorthand if $typeLocal is null.
373     * @param string|null $typeLocal The data type's  QName suffix,
374     *        or null if $typeBase is an IRI or shorthand.
375     *
376     * @return RdfWriter $this
377     */
378    final public function a( $typeBase, $typeLocal = null ) {
379        return $this->say( 'a' )->is( $typeBase, $typeLocal );
380    }
381
382    /**
383     * @see RdfWriter::say()
384     *
385     * @param string $base A QName prefix.
386     * @param string|null $local A QName suffix.
387     *
388     * @return RdfWriter $this
389     */
390    final public function say( $base, $local = null ) {
391        $this->expandPredicate( $base, $local );
392
393        if ( $this->state === self::STATE_OBJECT
394            && $base === $this->currentPredicate[0]
395            && $local === $this->currentPredicate[1]
396        ) {
397            return $this; // redundant about() call
398        }
399
400        $this->state( self::STATE_PREDICATE );
401
402        $this->currentPredicate[0] = $base;
403        $this->currentPredicate[1] = $local;
404
405        $this->writePredicate( $base, $local );
406        return $this;
407    }
408
409    /**
410     * @see RdfWriter::is()
411     *
412     * @param string $base A QName prefix if $local is given, or an IRI if $local is null.
413     * @param string|null $local A QName suffix, or null if $base is an IRI.
414     *
415     * @return RdfWriter $this
416     */
417    final public function is( $base, $local = null ) {
418        $this->state( self::STATE_OBJECT );
419
420        $this->expandResource( $base, $local );
421        $this->writeResource( $base, $local );
422        return $this;
423    }
424
425    /**
426     * @see RdfWriter::text()
427     *
428     * @param string $text the text to be placed in the output
429     * @param string|null $language the language the text is in
430     *
431     * @return $this
432     */
433    final public function text( $text, $language = null ) {
434        $this->state( self::STATE_OBJECT );
435
436        $this->writeText( $text, $language );
437        return $this;
438    }
439
440    /**
441     * @see RdfWriter::value()
442     *
443     * @param string $value the value encoded as a string
444     * @param string|null $typeBase The data type's QName prefix if $typeLocal is given,
445     *        or an IRI or shorthand if $typeLocal is null.
446     * @param string|null $typeLocal The data type's  QName suffix,
447     *        or null if $typeBase is an IRI or shorthand.
448     *
449     * @return $this
450     */
451    final public function value( $value, $typeBase = null, $typeLocal = null ) {
452        $this->state( self::STATE_OBJECT );
453
454        if ( $typeBase === null && !is_string( $value ) ) {
455            $vtype = gettype( $value );
456            switch ( $vtype ) {
457                case 'integer':
458                    $typeBase = 'xsd';
459                    $typeLocal = 'integer';
460                    $value = "$value";
461                    break;
462
463                case 'double':
464                    $typeBase = 'xsd';
465                    $typeLocal = 'double';
466                    $value = "$value";
467                    break;
468
469                case 'boolean':
470                    $typeBase = 'xsd';
471                    $typeLocal = 'boolean';
472                    $value = $value ? 'true' : 'false';
473                    break;
474            }
475        }
476
477        $this->expandType( $typeBase, $typeLocal );
478
479        $this->writeValue( $value, $typeBase, $typeLocal );
480        return $this;
481    }
482
483    /**
484     * State transition table
485     * First state is "from", second is "to"
486     * @var array
487     */
488    protected $transitionTable = [
489            self::STATE_START => [
490                    self::STATE_DOCUMENT => true,
491            ],
492            self::STATE_DOCUMENT => [
493                    self::STATE_DOCUMENT => true,
494                    self::STATE_SUBJECT => true,
495                    self::STATE_FINISH => true,
496            ],
497            self::STATE_SUBJECT => [
498                    self::STATE_PREDICATE => true,
499            ],
500            self::STATE_PREDICATE => [
501                    self::STATE_OBJECT => true,
502            ],
503            self::STATE_OBJECT => [
504                    self::STATE_DOCUMENT => true,
505                    self::STATE_SUBJECT => true,
506                    self::STATE_PREDICATE => true,
507                    self::STATE_OBJECT => true,
508            ],
509    ];
510
511    /**
512     * Perform a state transition. Writer states roughly correspond to states in a naive
513     * regular parser for the respective syntax. State transitions may generate output,
514     * particularly of structural elements which correspond to terminals in a respective
515     * parser.
516     *
517     * @param int $newState one of the self::STATE_... constants
518     *
519     * @throws LogicException
520     */
521    final protected function state( $newState ) {
522        if ( !isset( $this->transitionTable[$this->state][$newState] ) ) {
523            throw new LogicException( 'Bad transition: ' . $this->state . ' -> ' . $newState );
524        }
525
526        $action = $this->transitionTable[$this->state][$newState];
527        if ( $action !== true ) {
528            if ( is_string( $action ) ) {
529                $this->write( $action );
530            } else {
531                $action();
532            }
533        }
534
535        $this->state = $newState;
536    }
537
538    /**
539     * Must be implemented to generate output that starts a statement (or set of statements)
540     * about a subject. Depending on the requirements of the output format, the implementation
541     * may be empty.
542     *
543     * @note $base and $local are given as passed to about() and processed by expandSubject().
544     *
545     * @param string $base
546     * @param string|null $local
547     */
548    abstract protected function writeSubject( $base, $local = null );
549
550    /**
551     * Must be implemented to generate output that represents the association of a predicate
552     * with a subject that was previously defined by a call to writeSubject().
553     *
554     * @note $base and $local are given as passed to say() and processed by expandPredicate().
555     *
556     * @param string $base
557     * @param string|null $local
558     */
559    abstract protected function writePredicate( $base, $local = null );
560
561    /**
562     * Must be implemented to generate output that represents a resource used as the object
563     * of a statement.
564     *
565     * @note $base and $local are given as passed to is() and processed by expandObject().
566     *
567     * @param string $base
568     * @param string|null $local
569     */
570    abstract protected function writeResource( $base, $local = null );
571
572    /**
573     * Must be implemented to generate output that represents a text used as the object
574     * of a statement.
575     *
576     * @param string $text the text to be placed in the output
577     * @param string|null $language the language the text is in
578     */
579    abstract protected function writeText( $text, $language );
580
581    /**
582     * Must be implemented to generate output that represents a (typed) literal used as the object
583     * of a statement.
584     *
585     * @note $typeBase and $typeLocal are given as passed to value() and processed by expandType().
586     *
587     * @param string $value the value encoded as a string
588     * @param string|null $typeBase
589     * @param string|null $typeLocal
590     */
591    abstract protected function writeValue( $value, $typeBase, $typeLocal = null );
592
593    /**
594     * Perform any expansion (shorthand to qname, qname to IRI) desired
595     * for subject identifiers.
596     *
597     * @param string &$base
598     * @param string|null &$local
599     */
600    protected function expandSubject( &$base, &$local ) {
601    }
602
603    /**
604     * Perform any expansion (shorthand to qname, qname to IRI) desired
605     * for predicate identifiers.
606     *
607     * @param string &$base
608     * @param string|null &$local
609     */
610    protected function expandPredicate( &$base, &$local ) {
611    }
612
613    /**
614     * Perform any expansion (shorthand to qname, qname to IRI) desired
615     * for resource identifiers.
616     *
617     * @param string &$base
618     * @param string|null &$local
619     */
620    protected function expandResource( &$base, &$local ) {
621    }
622
623    /**
624     * Perform any expansion (shorthand to qname, qname to IRI) desired
625     * for type identifiers.
626     *
627     * @param string|null &$base
628     * @param string|null &$local
629     */
630    protected function expandType( &$base, &$local ) {
631    }
632
633}