diff --git a/README.md b/README.md index 95321b5..edd0618 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,23 @@ # Purtle **Purtle** is a fast, lightweight RDF generator. It provides a "fluent" interface for -generating RDF output in Turtle, XML/RDF or N-Triples. The fluent interface allows the +generating RDF output in Turtle, JSON-LD, XML/RDF or N-Triples. The fluent interface allows the resulting PHP code to be structured just like Turtle notation for RDF, hence the name: "Purtle" is a contraction of "PHP Turtle". -The three concrete classes implementing the common `RdfWriter` interface are: +The concrete classes implementing the common `RdfWriter` interface are: * `TurtleRdfWriter` outputs Turtle * `XmlRdfWriter` outputs XML/RDF * `NTriplesRdfWriter` outputs N-Triples +* `JsonLdRdfWriter` outputs JSON-LD The PHP code would look something like this: $writer = new TurtleRdfWriter(); $writer->prefix( 'acme', 'http://acme.test/terms/' ); $writer->about( 'http://quux.test/Something' ) ->a( 'acme', 'Thing' ) ->say( 'acme', 'name' )->text( 'Thingy' )->text( 'Dingsda', 'de' ) ->say( 'acme', 'owner' )->is( 'http://quux.test/' ); diff --git a/composer.json b/composer.json index 5bf8b1b..d714be3 100644 --- a/composer.json +++ b/composer.json @@ -1,64 +1,68 @@ { "name": "wikimedia/purtle", "type": "library", "description": "Fast streaming RDF serializer", "keywords": [ "RDF", "Serializer", - "Turtle" + "Turtle", + "JSON-LD" ], "homepage": "https://mediawiki.org/wiki/Purtle", "license": "GPL-2.0+", "authors": [ { "name": "Daniel Kinzler" }, { "name": "Stanislav Malyshev" }, { "name": "Thiemo Mättig" + }, + { + "name": "C. Scott Ananian" } ], "support": { "irc": "irc://irc.freenode.net/wikimedia-dev" }, "require": { "php": ">=5.5.9" }, "require-dev": { "mediawiki/mediawiki-codesniffer": "0.12.0", "ockcyp/covers-validator": "~0.4.0", "phpunit/phpunit": "4.8.24", "phpmd/phpmd": "~2.3" }, "autoload": { "psr-4": { "Wikimedia\\Purtle\\": "src/", "Wikimedia\\Purtle\\Tests\\": "tests/phpunit/" } }, "extra": { "branch-alias": { "dev-master": "1.0.x-dev" } }, "scripts": { "test": [ "composer validate --no-interaction", "phpunit", "covers-validator" ], "cs": [ "phpcs -p -s", "phpmd src/ text phpmd.xml" ], "ci": [ "@cs", "@test" ], "fix": [ "phpcbf" ] } } diff --git a/src/JsonLdRdfWriter.php b/src/JsonLdRdfWriter.php new file mode 100644 index 0000000..2366a98 --- /dev/null +++ b/src/JsonLdRdfWriter.php @@ -0,0 +1,368 @@ +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 ); + // 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; + } + $this->context[ $base ] = $this->prefixes[ $base ]; + } + 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; + } + + /** + * 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" ); + $this->write( "}" ); + } + + 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 ) { + $this->values[] = [ + "@id" => $this->compactify( $base, $local ) + ]; + } + + /** + * @param string $text + * @param string|null $language + */ + protected function writeText( $text, $language = null ) { + if ( + $language === null || + !$this->isValidLanguageCode( $language ) + ) { + $this->values[] = $text; + } else { + $this->values[] = [ + "@language" => $language, + "@value" => $text + ]; + } + } + + /** + * @param string $literal + * @param string|null $typeBase + * @param string|null $typeLocal + */ + public function writeValue( $literal, $typeBase, $typeLocal = null ) { + if ( $typeBase === null && $typeLocal === null ) { + $this->values[] = $literal; + } elseif ( $typeLocal === null ) { + throw new InvalidArgumentException( "Got IRI: $typeBase" ); + } else { + $typeIRI = $this->toIRI( $typeBase, $typeLocal ); + if ( $typeIRI === 'http://www.w3.org/2001/XMLSchema#string' ) { + $this->values[] = strval( $literal ); + return; + } + if ( $typeIRI === 'http://www.w3.org/2001/XMLSchema#integer' ) { + $this->values[] = intval( $literal ); + return; + } + if ( $typeIRI === 'http://www.w3.org/2001/XMLSchema#boolean' ) { + $this->values[] = ( $literal === 'true' ); + return; + } + if ( $typeIRI === '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. + if ( strpos( json_encode( $v ), '.' ) !== false ) { + $this->values[] = $v; + return; + } + } + $this->values[] = [ + "@type" => $this->compactify( $typeBase, $typeLocal ), + "@value" => strval( $literal ) + ]; + } + } + + protected function finishPredicate() { + list( $base, $local ) = $this->currentPredicate; + $predIRI = $this->toIRI( $base, $local ); + + if ( $predIRI === self::RDF_TYPE_IRI ) { + // TODO: the context can optionally specify other predicates + // have type "@id" or "@vocab", which would trigger this + // same coercion. See https://www.w3.org/TR/json-ld/#iris + $name = "@type"; + $this->values = array_map( function ( array $val ) { + return $val[ "@id" ]; + }, $this->values ); + } else { + $name = $this->compactify( $base, $local ); + } + if ( isset( $this->predicates[$name] ) ) { + $was = $this->predicates[$name]; + if ( !is_array( $was ) ) { + $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; + // 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'; + } + +} diff --git a/src/RdfWriterBase.php b/src/RdfWriterBase.php index af7084d..149536f 100644 --- a/src/RdfWriterBase.php +++ b/src/RdfWriterBase.php @@ -1,648 +1,648 @@ role = $role; $this->labeler = $labeler ?: new BNodeLabeler(); $this->registerShorthand( 'a', 'rdf', 'type' ); $this->prefix( 'rdf', 'http://www.w3.org/1999/02/22-rdf-syntax-ns#' ); $this->prefix( 'xsd', 'http://www.w3.org/2001/XMLSchema#' ); } /** * @param string $role * @param BNodeLabeler $labeler * * @return RdfWriterBase */ abstract protected function newSubWriter( $role, BNodeLabeler $labeler ); /** * Registers a shorthand that can be used instead of a qname, * like 'a' can be used instead of 'rdf:type'. * * @param string $shorthand * @param string $prefix * @param string $local */ protected function registerShorthand( $shorthand, $prefix, $local ) { $this->shorthands[$shorthand] = [ $prefix, $local ]; } /** * Registers a prefix * * @param string $prefix * @param string $iri The base IRI * * @throws LogicException */ public function prefix( $prefix, $iri ) { if ( $this->prefixesLocked ) { throw new LogicException( 'Prefixes can not be added after start()' ); } $this->prefixes[$prefix] = $iri; } /** * Determines whether $shorthand can be used as a shorthand. * * @param string $shorthand * * @return bool */ protected function isShorthand( $shorthand ) { return isset( $this->shorthands[$shorthand] ); } /** * Determines whether $shorthand can legally be used as a prefix. * * @param string $prefix * * @return bool */ protected function isPrefix( $prefix ) { return isset( $this->prefixes[$prefix] ); } /** * Returns the prefix map. * * @return string[] An associative array mapping prefixes to base IRIs. */ public function getPrefixes() { return $this->prefixes; } /** * @param string|null $languageCode * * @return bool */ protected function isValidLanguageCode( $languageCode ) { // preg_match is somewhat (12%) slower than strspn but more readable return $languageCode !== null && preg_match( '/^[\da-z-]{2,}$/i', $languageCode ); } /** * @return RdfWriter */ final public function sub() { $writer = $this->newSubWriter( self::SUBDOCUMENT_ROLE, $this->labeler ); $writer->state = self::STATE_DOCUMENT; // share registered prefixes $writer->prefixes =& $this->prefixes; $this->subs[] = $writer; return $writer; } /** * Returns the writers role. The role determines the behavior of the writer with respect * to which states and transitions are possible: a BNODE_ROLE writer would for instance * not accept a call to about(), since it can only process triples about a single subject * (the blank node it represents). * * @return string A string corresponding to one of the the XXX_ROLE constants. */ final public function getRole() { return $this->role; } /** * Appends string to the output buffer. * @param string $w */ final protected function write( $w ) { $this->buffer[] = $w; } /** * If $base is a shorthand, $base and $local are updated to hold whatever qname * the shorthand was associated with. * * Otherwise, $base and $local remain unchanged. * * @param string &$base * @param string|null &$local */ protected function expandShorthand( &$base, &$local ) { if ( $local === null && isset( $this->shorthands[$base] ) ) { list( $base, $local ) = $this->shorthands[$base]; } } /** * If $base is a registered prefix, $base will be replaced by the base IRI associated with * that prefix, with $local appended. $local will be set to null. * * Otherwise, $base and $local remain unchanged. * * @param string &$base * @param string|null &$local * * @throws LogicException */ protected function expandQName( &$base, &$local ) { if ( $local !== null && $base !== '_' ) { if ( isset( $this->prefixes[$base] ) ) { $base = $this->prefixes[$base] . $local; // XXX: can we avoid this concat? $local = null; } else { throw new LogicException( 'Unknown prefix: ' . $base ); } } } /** * @see RdfWriter::blank() * * @param string|null $label node label, will be generated if not given. * * @return string */ final public function blank( $label = null ) { return $this->labeler->getLabel( $label ); } /** * @see RdfWriter::start() */ final public function start() { $this->state( self::STATE_DOCUMENT ); $this->prefixesLocked = true; } /** * @see RdfWriter::finish() */ final public function finish() { // close all unclosed states $this->state( self::STATE_DOCUMENT ); // ...then insert output of sub-writers into the buffer, // so it gets placed before the footer... $this->drainSubs(); // and then finalize $this->state( self::STATE_FINISH ); // Detaches all subs. $this->subs = []; } /** * @see RdfWriter::drain() * * @return string RDF */ final public function drain() { // we can drain after finish, but finish state is sticky if ( $this->state !== self::STATE_FINISH ) { $this->state( self::STATE_DOCUMENT ); } $this->drainSubs(); $this->flattenBuffer(); $rdf = join( '', $this->buffer ); $this->buffer = []; return $rdf; } /** * Calls drain() an any RdfWriter instances in $this->buffer, and replaces them * in $this->buffer with the string returned by the drain() call. Any closures * present in the $this->buffer will be called, and replaced by their return value. */ private function flattenBuffer() { foreach ( $this->buffer as &$b ) { if ( $b instanceof Closure ) { $b = $b(); } if ( $b instanceof RdfWriter ) { $b = $b->drain(); } } } /** * Drains all subwriters, and appends their output to this writer's buffer. * Subwriters remain usable. */ private function drainSubs() { foreach ( $this->subs as $sub ) { $rdf = $sub->drain(); $this->write( $rdf ); } } /** * @see RdfWriter::about() * * @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 RdfWriter $this */ final public function about( $base, $local = null ) { $this->expandSubject( $base, $local ); if ( $this->state === self::STATE_OBJECT && $base === $this->currentSubject[0] && $local === $this->currentSubject[1] ) { return $this; // redundant about() call } $this->state( self::STATE_SUBJECT ); $this->currentSubject[0] = $base; $this->currentSubject[1] = $local; $this->currentPredicate[0] = null; $this->currentPredicate[1] = null; $this->writeSubject( $base, $local ); return $this; } /** * @see RdfWriter::a() * Shorthand for say( 'a' )->is( $type ). * * @param string $typeBase The data type's QName prefix if $typeLocal is given, * or an IRI or shorthand if $typeLocal is null. * @param string|null $typeLocal The data type's QName suffix, * or null if $typeBase is an IRI or shorthand. * * @return RdfWriter $this */ final public function a( $typeBase, $typeLocal = null ) { return $this->say( 'a' )->is( $typeBase, $typeLocal ); } /** * @see RdfWriter::say() * * @param string $base A QName prefix. * @param string|null $local A QName suffix. * * @return RdfWriter $this */ final public function say( $base, $local = null ) { $this->expandPredicate( $base, $local ); if ( $this->state === self::STATE_OBJECT && $base === $this->currentPredicate[0] && $local === $this->currentPredicate[1] ) { return $this; // redundant about() call } $this->state( self::STATE_PREDICATE ); $this->currentPredicate[0] = $base; $this->currentPredicate[1] = $local; $this->writePredicate( $base, $local ); return $this; } /** * @see RdfWriter::is() * * @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 RdfWriter $this */ final public function is( $base, $local = null ) { $this->state( self::STATE_OBJECT ); $this->expandResource( $base, $local ); $this->writeResource( $base, $local ); return $this; } /** * @see RdfWriter::text() * * @param string $text the text to be placed in the output * @param string|null $language the language the text is in * * @return $this */ final public function text( $text, $language = null ) { $this->state( self::STATE_OBJECT ); $this->writeText( $text, $language ); return $this; } /** * @see RdfWriter::value() * * @param string $value the value encoded as a string * @param string|null $typeBase The data type's QName prefix if $typeLocal is given, * or an IRI or shorthand if $typeLocal is null. * @param string|null $typeLocal The data type's QName suffix, * or null if $typeBase is an IRI or shorthand. * * @return $this */ final public function value( $value, $typeBase = null, $typeLocal = null ) { $this->state( self::STATE_OBJECT ); if ( $typeBase === null && !is_string( $value ) ) { $vtype = gettype( $value ); switch ( $vtype ) { case 'integer': $typeBase = 'xsd'; $typeLocal = 'integer'; $value = "$value"; break; case 'double': $typeBase = 'xsd'; $typeLocal = 'double'; $value = "$value"; break; case 'boolean': $typeBase = 'xsd'; $typeLocal = 'boolean'; $value = $value ? 'true' : 'false'; break; } } $this->expandType( $typeBase, $typeLocal ); $this->writeValue( $value, $typeBase, $typeLocal ); return $this; } /** * State transition table * First state is "from", second is "to" * @var array */ protected $transitionTable = [ self::STATE_START => [ self::STATE_DOCUMENT => true, ], self::STATE_DOCUMENT => [ self::STATE_DOCUMENT => true, self::STATE_SUBJECT => true, self::STATE_FINISH => true, ], self::STATE_SUBJECT => [ self::STATE_PREDICATE => true, ], self::STATE_PREDICATE => [ self::STATE_OBJECT => true, ], self::STATE_OBJECT => [ self::STATE_DOCUMENT => true, self::STATE_SUBJECT => true, self::STATE_PREDICATE => true, self::STATE_OBJECT => true, ], ]; /** * Perform a state transition. Writer states roughly correspond to states in a naive * regular parser for the respective syntax. State transitions may generate output, * particularly of structural elements which correspond to terminals in a respective * parser. * * @param int $newState one of the self::STATE_... constants * * @throws LogicException */ final protected function state( $newState ) { if ( !isset( $this->transitionTable[$this->state][$newState] ) ) { throw new LogicException( 'Bad transition: ' . $this->state . ' -> ' . $newState ); } $action = $this->transitionTable[$this->state][$newState]; if ( $action !== true ) { if ( is_string( $action ) ) { $this->write( $action ); } else { $action(); } } $this->state = $newState; } /** * Must be implemented to generate output that starts a statement (or set of statements) * about a subject. Depending on the requirements of the output format, the implementation * may be empty. * * @note: $base and $local are given as passed to about() and processed by expandSubject(). * * @param string $base * @param string|null $local */ abstract protected function writeSubject( $base, $local = null ); /** * Must be implemented to generate output that represents the association of a predicate * with a subject that was previously defined by a call to writeSubject(). * * @note: $base and $local are given as passed to say() and processed by expandPredicate(). * * @param string $base * @param string|null $local */ abstract protected function writePredicate( $base, $local = null ); /** * Must be implemented to generate output that represents a resource used as the object * of a statement. * * @note: $base and $local are given as passed to is() and processed by expandObject(). * * @param string $base * @param string|null $local */ abstract protected function writeResource( $base, $local = null ); /** * Must be implemented to generate output that represents a text used as the object * of a statement. * * @param string $text the text to be placed in the output * @param string|null $language the language the text is in */ abstract protected function writeText( $text, $language ); /** * Must be implemented to generate output that represents a (typed) literal used as the object * of a statement. * * @note: $typeBase and $typeLocal are given as passed to value() and processed by expandType(). * * @param string $value the value encoded as a string * @param string|null $typeBase * @param string|null $typeLocal */ abstract protected function writeValue( $value, $typeBase, $typeLocal = null ); /** * Perform any expansion (shorthand to qname, qname to IRI) desired * for subject identifiers. * * @param string &$base * @param string|null &$local */ protected function expandSubject( &$base, &$local ) { } /** * Perform any expansion (shorthand to qname, qname to IRI) desired * for predicate identifiers. * * @param string &$base * @param string|null &$local */ protected function expandPredicate( &$base, &$local ) { } /** * Perform any expansion (shorthand to qname, qname to IRI) desired * for resource identifiers. * * @param string &$base * @param string|null &$local */ protected function expandResource( &$base, &$local ) { } /** * Perform any expansion (shorthand to qname, qname to IRI) desired * for type identifiers. * * @param string|null &$base * @param string|null &$local */ protected function expandType( &$base, &$local ) { } } diff --git a/src/RdfWriterFactory.php b/src/RdfWriterFactory.php index 153429c..852ced2 100644 --- a/src/RdfWriterFactory.php +++ b/src/RdfWriterFactory.php @@ -1,157 +1,172 @@ . + . + . + "0.1.0" . + "2017-09-19T22:53:13-04:00"^^ . + . diff --git a/tests/data/DumpHeader.rdf b/tests/data/DumpHeader.rdf new file mode 100644 index 0000000..3114306 --- /dev/null +++ b/tests/data/DumpHeader.rdf @@ -0,0 +1,11 @@ + + + + + + + 0.1.0 + 2017-09-19T22:53:13-04:00 + + + diff --git a/tests/data/DumpHeader.ttl b/tests/data/DumpHeader.ttl new file mode 100644 index 0000000..4c7ba15 --- /dev/null +++ b/tests/data/DumpHeader.ttl @@ -0,0 +1,13 @@ +@prefix rdf: . +@prefix xsd: . +@prefix wikibase: . +@prefix schema: . +@prefix owl: . +@prefix cc: . + +wikibase:Dump a schema:Dataset, + owl:Ontology ; + cc:license ; + schema:softwareVersion "0.1.0" ; + schema:dateModified "2017-09-19T22:53:13-04:00"^^xsd:dateTime ; + owl:imports . diff --git a/tests/data/EricMiller.jsonld b/tests/data/EricMiller.jsonld new file mode 100644 index 0000000..7e8337f --- /dev/null +++ b/tests/data/EricMiller.jsonld @@ -0,0 +1,12 @@ +{ + "@id": "http://www.w3.org/People/EM/contact#me", + "@type": "contact:Person", + "contact:fullName": "Eric Miller", + "contact:mailbox": { + "@id": "mailto:em@w3.org" + }, + "contact:personalTitle": "Dr.", + "@context": { + "contact": "http://www.w3.org/2000/10/swap/pim/contact#" + } +} diff --git a/tests/data/LabeledBlankNode.jsonld b/tests/data/LabeledBlankNode.jsonld new file mode 100644 index 0000000..28ae7d8 --- /dev/null +++ b/tests/data/LabeledBlankNode.jsonld @@ -0,0 +1,21 @@ +{ + "@graph": [ + { + "@id": "exstaff:85740", + "exterms:address": { + "@id": "_:johnaddress" + } + }, + { + "@id": "_:johnaddress", + "exterms:street": "1501 Grant Avenue", + "exterms:city": "Bedfort", + "exterms:state": "Massachusetts", + "exterms:postalCode": "01730" + } + ], + "@context": { + "exstaff": "http://www.example.org/staffid/", + "exterms": "http://www.example.org/terms/" + } +} diff --git a/tests/data/NumberedBlankNode.jsonld b/tests/data/NumberedBlankNode.jsonld new file mode 100644 index 0000000..78b26f2 --- /dev/null +++ b/tests/data/NumberedBlankNode.jsonld @@ -0,0 +1,28 @@ +{ + "@graph": [ + { + "@id": "exstaff:Sue", + "exterms:publication": { + "@id": "_:genid1" + } + }, + { + "@id": "_:genid1", + "exterms:title": "Antology of Time" + }, + { + "@id": "exstaff:Jack", + "exterms:publication": { + "@id": "_:genid2" + } + }, + { + "@id": "_:genid2", + "exterms:title": "Anthony of Time" + } + ], + "@context": { + "exstaff": "http://www.example.org/staffid/", + "exterms": "http://www.example.org/terms/" + } +} diff --git a/tests/data/Numbers.jsonld b/tests/data/Numbers.jsonld new file mode 100644 index 0000000..96f0dd4 --- /dev/null +++ b/tests/data/Numbers.jsonld @@ -0,0 +1,18 @@ +{ + "@graph": [ + { + "@id": "acme:Bongos", + "acme:stock": [ + 5, + 7 + ] + }, + { + "@id": "acme:Tablas", + "acme:stock": 6 + } + ], + "@context": { + "acme": "http://acme.test/" + } +} diff --git a/tests/data/Predicates.jsonld b/tests/data/Predicates.jsonld new file mode 100644 index 0000000..99cce10 --- /dev/null +++ b/tests/data/Predicates.jsonld @@ -0,0 +1,19 @@ +{ + "@graph": [ + { + "@id": "http://foobar.test/Bananas", + "@type": "http://foobar.test/Fruit", + "http://acme.test/name": [ + "Banana", + { + "@language": "de", + "@value": "Banane" + } + ] + }, + { + "@id": "http://foobar.test/Apples", + "http://acme.test/name": "Apple" + } + ] +} diff --git a/tests/data/Resources.jsonld b/tests/data/Resources.jsonld new file mode 100644 index 0000000..7f7316c --- /dev/null +++ b/tests/data/Resources.jsonld @@ -0,0 +1,14 @@ +{ + "@id": "acme:Bongos", + "acme:sounds": [ + { + "@id": "acme:Bing" + }, + { + "@id": "http://foobar.test/sound/Bang" + } + ], + "@context": { + "acme": "http://acme.test/" + } +} diff --git a/tests/data/TextWithSpecialChars.jsonld b/tests/data/TextWithSpecialChars.jsonld new file mode 100644 index 0000000..ecf3461 --- /dev/null +++ b/tests/data/TextWithSpecialChars.jsonld @@ -0,0 +1,19 @@ +{ + "@graph": [ + { + "@id": "exterms:Duck", + "exterms:says": "Duck says: \"Quack!\"" + }, + { + "@id": "exterms:Cow", + "exterms:says": "Cow says:\n\r 'Moo! \\Moo!'" + }, + { + "@id": "exterms:Bear", + "exterms:says": "Bear says: \u041f\u0440\u0435\u0432\u0435\u0434!" + } + ], + "@context": { + "exterms": "http://www.example.org/terms/" + } +} diff --git a/tests/data/Texts.jsonld b/tests/data/Texts.jsonld new file mode 100644 index 0000000..d499b5e --- /dev/null +++ b/tests/data/Texts.jsonld @@ -0,0 +1,21 @@ +{ + "@id": "acme:Bongos", + "acme:sounds": [ + { + "@language": "de", + "@value": "Bom" + }, + { + "@language": "en", + "@value": "Bam" + }, + { + "@language": "es-419", + "@value": "Como estas" + }, + "What?" + ], + "@context": { + "acme": "http://acme.test/" + } +} diff --git a/tests/data/Triples.jsonld b/tests/data/Triples.jsonld new file mode 100644 index 0000000..b1b5a87 --- /dev/null +++ b/tests/data/Triples.jsonld @@ -0,0 +1,20 @@ +{ + "@graph": [ + { + "@id": "http://foobar.test/Bananas", + "@type": "http://foobar.test/Fruit" + }, + { + "@id": "acme:Nuts", + "acme:weight": { + "@type": "xsd:decimal", + "@value": "5.5" + }, + "acme:color": "brown" + } + ], + "@context": { + "acme": "http://acme.test/", + "xsd": "http://www.w3.org/2001/XMLSchema#" + } +} diff --git a/tests/data/Values.jsonld b/tests/data/Values.jsonld new file mode 100644 index 0000000..94b2b05 --- /dev/null +++ b/tests/data/Values.jsonld @@ -0,0 +1,49 @@ +{ + "@id": "http://foobar.test/Bananas", + "acme:multi": [ + "A", + "B", + "C" + ], + "acme:type": [ + { + "@type": "acme:thing", + "@value": "foo" + }, + -5, + { + "@type": "xsd:decimal", + "@value": "-5" + }, + { + "@type": "xsd:double", + "@value": "-5" + }, + true, + false + ], + "acme:autotype": [ + -5, + 3.14, + true, + false + ], + "acme:no-autotype": [ + { + "@type": "xsd:decimal", + "@value": "-5" + }, + "3.14", + "1", + "" + ], + "acme:shorthand": "foo", + "acme:typed-shorthand": { + "@type": "acme:thing", + "@value": "foo" + }, + "@context": { + "acme": "http://acme.test/", + "xsd": "http://www.w3.org/2001/XMLSchema#" + } +} diff --git a/tests/phpunit/JsonLdRdfWriterTest.php b/tests/phpunit/JsonLdRdfWriterTest.php new file mode 100644 index 0000000..4120c73 --- /dev/null +++ b/tests/phpunit/JsonLdRdfWriterTest.php @@ -0,0 +1,47 @@ +assertEquals( $writer->encode( "foo{bar}bat", 0 ), '"foo{bar}bat"' ); + $this->assertEquals( $writer->encode( [], 0 ), "" ); + $this->assertEquals( $writer->encode( [ + "@id" => "foo" + ], 0 ), " \"@id\": \"foo\"" ); + $this->assertEquals( + $writer->encode( [ 1, 2, 3 ], 0 ), + " 1,\n 2,\n 3" + ); + } + +} diff --git a/tests/phpunit/NTriplesRdfWriterTest.php b/tests/phpunit/NTriplesRdfWriterTest.php index 0c7a504..69b97e9 100644 --- a/tests/phpunit/NTriplesRdfWriterTest.php +++ b/tests/phpunit/NTriplesRdfWriterTest.php @@ -1,35 +1,42 @@ getSupportedFormats(); $this->assertInternalType( 'array', $formats ); $this->assertNotEmpty( $formats ); } public function testGetWriter() { $factory = new RdfWriterFactory(); foreach ( $factory->getSupportedFormats() as $format ) { $writer = $factory->getWriter( $format ); $this->assertInstanceOf( RdfWriter::class, $writer ); } } public function testGivenInvalidFormat_getWriterThrowsException() { $factory = new RdfWriterFactory(); $this->setExpectedException( InvalidArgumentException::class ); $factory->getWriter( 'invalid' ); } public function testGetFormatName() { $factory = new RdfWriterFactory(); foreach ( $factory->getSupportedFormats() as $format ) { $actual = $factory->getFormatName( $format ); // the canonical name should just stay $this->assertEquals( $format, $actual ); } } public function testGivenInvalidFormat_getFormatNameReturnsFalse() { $factory = new RdfWriterFactory(); $this->assertFalse( $factory->getFormatName( 'invalid' ) ); } public function provideFormats() { return [ // N3 (currently falls through to turtle) [ 'N3', 'n3', 'n3', 'text/n3' ], [ 'text/n3', 'n3', 'n3', 'text/n3' ], [ 'text/rdf+n3', 'n3', 'n3', 'text/n3' ], [ 'ttl', 'turtle', 'ttl', 'text/turtle' ], [ 'turtle', 'turtle', 'ttl', 'text/turtle' ], [ 'text/turtle', 'turtle', 'ttl', 'text/turtle' ], [ 'application/x-turtle', 'turtle', 'ttl', 'text/turtle' ], [ 'nt', 'ntriples', 'nt', 'application/n-triples' ], [ 'ntriples', 'ntriples', 'nt', 'application/n-triples' ], [ 'n-triples', 'ntriples', 'nt', 'application/n-triples' ], [ 'text/plain', 'ntriples', 'nt', 'application/n-triples' ], [ 'text/n-triples', 'ntriples', 'nt', 'application/n-triples' ], [ 'application/ntriples', 'ntriples', 'nt', 'application/n-triples' ], [ 'application/n-triples', 'ntriples', 'nt', 'application/n-triples' ], [ 'xml', 'rdfxml', 'rdf', 'application/rdf+xml' ], [ 'rdf', 'rdfxml', 'rdf', 'application/rdf+xml' ], [ 'rdfxml', 'rdfxml', 'rdf', 'application/rdf+xml' ], [ 'application/rdf+xml', 'rdfxml', 'rdf', 'application/rdf+xml' ], [ 'application/xml', 'rdfxml', 'rdf', 'application/rdf+xml' ], [ 'text/xml', 'rdfxml', 'rdf', 'application/rdf+xml' ], + + [ 'json', 'jsonld', 'jsonld', 'application/ld+json' ], + [ 'jsonld', 'jsonld', 'jsonld', 'application/ld+json' ], + [ 'application/ld+json', 'jsonld', 'jsonld', 'application/ld+json' ], + [ 'application/json', 'jsonld', 'jsonld', 'application/ld+json' ], + [ 'application/json', 'jsonld', 'jsonld', 'application/json' ], ]; } /** * @dataProvider provideFormats */ public function testFormats( $name, $canonicalName, $expectedFileExtension, $expectedMimeType ) { $factory = new RdfWriterFactory(); $this->assertEquals( $canonicalName, $factory->getFormatName( $name ) ); $this->assertEquals( $expectedFileExtension, $factory->getFileExtension( $canonicalName ) ); $this->assertContains( $expectedMimeType, $factory->getMimeTypes( $canonicalName ) ); $writer = $factory->getWriter( $canonicalName ); $this->assertInstanceOf( RdfWriter::class, $writer ); } public function testGetMimeTypes() { $factory = new RdfWriterFactory(); foreach ( $factory->getSupportedFormats() as $format ) { $mimeTypes = $factory->getMimeTypes( $format ); $this->assertInternalType( 'array', $mimeTypes ); $this->assertNotEmpty( $mimeTypes ); } } public function testGivenInvalidFormat_getMimeTypesThrowsException() { $factory = new RdfWriterFactory(); $this->setExpectedException( InvalidArgumentException::class ); $factory->getMimeTypes( 'invalid' ); } public function testGetFileExtensions() { $factory = new RdfWriterFactory(); foreach ( $factory->getSupportedFormats() as $format ) { $extension = $factory->getFileExtension( $format ); $this->assertInternalType( 'string', $extension ); } } public function testGivenInvalidFormat_getFileExtensionsThrowsException() { $factory = new RdfWriterFactory(); $this->setExpectedException( InvalidArgumentException::class ); $factory->getFileExtension( 'invalid' ); } } diff --git a/tests/phpunit/RdfWriterTestBase.php b/tests/phpunit/RdfWriterTestBase.php index 051179c..54f1877 100644 --- a/tests/phpunit/RdfWriterTestBase.php +++ b/tests/phpunit/RdfWriterTestBase.php @@ -1,382 +1,418 @@ newWriter()->getMimeType(); $this->assertInternalType( 'string', $mimeType ); - $this->assertRegExp( '/^\w+\/[\w-]+(\+xml)?(; charset=UTF-8)?$/', $mimeType ); + $this->assertRegExp( '/^\w+\/[\w-]+(\+(xml|json))?(; charset=UTF-8)?$/', $mimeType ); } public function testTriples() { $writer = $this->newWriter(); $writer->prefix( 'acme', 'http://acme.test/' ); $writer->start(); $writer->about( 'http://foobar.test/Bananas' ) ->say( 'a' )->is( 'http://foobar.test/Fruit' ); // shorthand name "a" $writer->about( 'acme', 'Nuts' ) ->say( 'acme', 'weight' )->value( '5.5', 'xsd', 'decimal' ); // redundant about( 'acme', 'Nuts' ) $writer->about( 'acme', 'Nuts' ) ->say( 'acme', 'color' )->value( 'brown' ); $writer->finish(); $rdf = $writer->drain(); $this->assertOutputLines( 'Triples', $rdf ); } public function testPredicates() { $writer = $this->newWriter(); $writer->prefix( '', 'http://acme.test/' ); // empty prefix $writer->start(); $writer->about( 'http://foobar.test/Bananas' ) ->a( 'http://foobar.test/Fruit' ) // shorthand function a() ->say( '', 'name' ) // empty prefix ->text( 'Banana' ) ->say( '', 'name' ) // redundant say( '', 'name' ) ->text( 'Banane', 'de' ); $writer->about( 'http://foobar.test/Apples' ) ->say( '', 'name' ) // subsequent call to say( '', 'name' ) for a different subject ->text( 'Apple' ); $writer->finish(); $rdf = $writer->drain(); $this->assertOutputLines( 'Predicates', $rdf ); } public function testPredicates_drain() { $writer = $this->newWriter(); $writer->prefix( '', 'http://acme.test/' ); // empty prefix $writer->start(); $writer->about( 'http://foobar.test/Bananas' ) ->a( 'http://foobar.test/Fruit' ) // shorthand function a() ->say( '', 'name' ) // empty prefix ->text( 'Banana' ) ->say( '', 'name' ) // redundant say( '', 'name' ) ->text( 'Banane', 'de' ); $rdf1 = $writer->drain(); $this->assertNotEmpty( $rdf1 ); $writer->about( 'http://foobar.test/Apples' ) ->say( '', 'name' ) // subsequent call to say( '', 'name' ) for a different subject ->text( 'Apple' ); $writer->finish(); $rdf2 = $writer->drain(); $this->assertNotEmpty( $rdf2 ); $this->assertOutputLines( 'Predicates', $rdf1 . $rdf2 ); } public function testPredicates_sub() { $writer = $this->newWriter(); $writer->prefix( '', 'http://acme.test/' ); // empty prefix $writer->start(); $sub = $writer->sub(); // output of the sub writer will appear after the output of the main writer. $sub->about( 'http://foobar.test/Apples' ) ->say( '', 'name' ) // subsequent call to say( '', 'name' ) for a different subject ->text( 'Apple' ); $writer->about( 'http://foobar.test/Bananas' ) ->a( 'http://foobar.test/Fruit' ) // shorthand function a() ->say( '', 'name' ) // empty prefix ->text( 'Banana' ) ->say( '', 'name' ) // redundant say( '', 'name' ) ->text( 'Banane', 'de' ); $writer->finish(); $rdf = $writer->drain(); $this->assertOutputLines( 'Predicates', $rdf ); } public function testPredicates_sub_drain() { $writer = $this->newWriter(); $writer->prefix( '', 'http://acme.test/' ); // empty prefix $writer->start(); $sub = $writer->sub(); $writer->about( 'http://foobar.test/Bananas' ) ->a( 'http://foobar.test/Fruit' ) // shorthand function a() ->say( '', 'name' ) // empty prefix ->text( 'Banana' ) ->say( '', 'name' ) // redundant say( '', 'name' ) ->text( 'Banane', 'de' ); $rdf1 = $writer->drain(); $this->assertNotEmpty( $rdf1 ); // sub-writer should still be usable after drain() $sub->about( 'http://foobar.test/Apples' ) ->say( '', 'name' ) // subsequent call to say( '', 'name' ) for a different subject ->text( 'Apple' ); $writer->finish(); $rdf2 = $writer->drain(); $this->assertNotEmpty( $rdf2 ); $this->assertOutputLines( 'Predicates', $rdf1 . $rdf2 ); } public function testValues() { $writer = $this->newWriter(); $writer->prefix( 'acme', 'http://acme.test/' ); $writer->start(); $writer->about( 'http://foobar.test/Bananas' ) ->say( 'acme', 'multi' ) ->value( 'A' ) ->value( 'B' ) ->value( 'C' ) ->say( 'acme', 'type' ) ->value( 'foo', 'acme', 'thing' ) ->value( '-5', 'xsd', 'integer' ) ->value( '-5', 'xsd', 'decimal' ) ->value( '-5', 'xsd', 'double' ) ->value( 'true', 'xsd', 'boolean' ) ->value( 'false', 'xsd', 'boolean' ) ->say( 'acme', 'autotype' ) ->value( -5 ) ->value( 3.14 ) ->value( true ) ->value( false ) ->say( 'acme', 'no-autotype' ) ->value( -5, 'xsd', 'decimal' ) ->value( 3.14, 'xsd', 'string' ) ->value( true, 'xsd', 'string' ) ->value( false, 'xsd', 'string' ) ->say( 'acme', 'shorthand' )->value( 'foo' ) ->say( 'acme', 'typed-shorthand' )->value( 'foo', 'acme', 'thing' ); $writer->finish(); $rdf = $writer->drain(); $this->assertOutputLines( 'Values', $rdf ); } public function testResources() { $writer = $this->newWriter(); $writer->prefix( 'acme', 'http://acme.test/' ); $writer->start(); $writer->about( 'acme', 'Bongos' ) ->say( 'acme', 'sounds' ) ->is( 'acme', 'Bing' ) ->is( 'http://foobar.test/sound/Bang' ); $writer->finish(); $rdf = $writer->drain(); $this->assertOutputLines( 'Resources', $rdf ); } public function testTexts() { $writer = $this->newWriter(); $writer->prefix( 'acme', 'http://acme.test/' ); $writer->start(); $writer->about( 'acme', 'Bongos' ) ->say( 'acme', 'sounds' ) ->text( 'Bom', 'de' ) ->text( 'Bam', 'en' ) ->text( 'Como estas', 'es-419' ) ->text( 'What?', 'bad tag' ); $writer->finish(); $rdf = $writer->drain(); $this->assertOutputLines( 'Texts', $rdf ); } public function testNumbers() { $writer = $this->newWriter(); $writer->prefix( 'acme', 'http://acme.test/' ); $writer->start(); $writer->about( 'acme', 'Bongos' ) ->say( 'acme', 'stock' )->value( 5, 'xsd', 'integer' ) ->value( 7 ) ->about( 'acme', 'Tablas' ) ->say( 'acme', 'stock' )->value( 6 ); $writer->finish(); $rdf = $writer->drain(); $this->assertOutputLines( 'Numbers', $rdf ); } public function testEricMiller() { // example taken from http://www.w3.org/2007/02/turtle/primer/ $writer = $this->newWriter(); $writer->prefix( 'contact', 'http://www.w3.org/2000/10/swap/pim/contact#' ); $writer->start(); $writer->about( 'http://www.w3.org/People/EM/contact#me' ) ->say( 'rdf', 'type' )->is( 'contact', 'Person' ) ->say( 'contact', 'fullName' )->text( 'Eric Miller' ) ->say( 'contact', 'mailbox' )->is( 'mailto:em@w3.org' ) ->say( 'contact', 'personalTitle' )->text( 'Dr.' ); $writer->finish(); $rdf = $writer->drain(); $this->assertOutputLines( 'EricMiller', $rdf ); } public function testLabeledBlankNode() { // example taken from http://www.w3.org/2007/02/turtle/primer/ $writer = $this->newWriter(); $writer->prefix( 'exterms', 'http://www.example.org/terms/' ); $writer->prefix( 'exstaff', 'http://www.example.org/staffid/' ); $writer->start(); $writer->about( 'exstaff', '85740' ) ->say( 'exterms', 'address' )->is( '_', $label = $writer->blank( 'johnaddress' ) ) ->about( '_', $label ) ->say( 'exterms', 'street' )->text( '1501 Grant Avenue' ) ->say( 'exterms', 'city' )->text( 'Bedfort' ) ->say( 'exterms', 'state' )->text( 'Massachusetts' ) ->say( 'exterms', 'postalCode' )->text( '01730' ); $writer->finish(); $rdf = $writer->drain(); $this->assertOutputLines( 'LabeledBlankNode', $rdf ); } public function testNumberedBlankNodes() { // example taken from http://www.w3.org/2007/02/turtle/primer/ $writer = $this->newWriter(); $writer->prefix( 'exterms', 'http://www.example.org/terms/' ); $writer->prefix( 'exstaff', 'http://www.example.org/staffid/' ); $writer->prefix( 'ex', 'http://example.org/packages/vocab#' ); $writer->start(); $writer->about( 'exstaff', 'Sue' ) ->say( 'exterms', 'publication' )->is( '_', $label1 = $writer->blank() ); $writer->about( '_', $label1 ) ->say( 'exterms', 'title' )->text( 'Antology of Time' ); $writer->about( 'exstaff', 'Jack' ) ->say( 'exterms', 'publication' )->is( '_', $label2 = $writer->blank() ); $writer->about( '_', $label2 ) ->say( 'exterms', 'title' )->text( 'Anthony of Time' ); $writer->finish(); $rdf = $writer->drain(); $this->assertOutputLines( 'NumberedBlankNode', $rdf ); } public function testQuotesAndSpecials() { $writer = $this->newWriter(); $writer->prefix( 'exterms', 'http://www.example.org/terms/' ); $writer->start(); $writer->about( 'exterms', 'Duck' )->say( 'exterms', 'says' ) ->text( 'Duck says: "Quack!"' ); $writer->about( 'exterms', 'Cow' )->say( 'exterms', 'says' ) ->text( "Cow says:\n\r 'Moo! \\Moo!'" ); $writer->about( 'exterms', 'Bear' )->say( 'exterms', 'says' ) ->text( 'Bear says: Превед!' ); $writer->finish(); $rdf = $writer->drain(); $this->assertOutputLines( 'TextWithSpecialChars', $rdf ); } + public function testDumpHeader() { + $writer = $this->newWriter(); + $writer->prefix( 'wikibase', 'http://wikiba.se/ontology-beta#' ); + $writer->prefix( 'schema', 'http://schema.org/' ); + $writer->prefix( 'owl', 'http://www.w3.org/2002/07/owl#' ); + $writer->prefix( 'cc', 'http://creativecommons.org/ns#' ); + $writer->start(); + $writer->about( 'wikibase', 'Dump' ) + ->a( 'schema', "Dataset" ) + ->a( 'owl', 'Ontology' ) + ->say( 'cc', 'license' )->is( 'http://creativecommons.org/publicdomain/zero/1.0/' ) + ->say( 'schema', 'softwareVersion' )->value( '0.1.0' ) + ->say( 'schema', 'dateModified' )->value( '2017-09-19T22:53:13-04:00', 'xsd', 'dateTime' ) + ->say( 'owl', 'imports' )->is( 'http://wikiba.se/ontology-1.0.owl' ); + $writer->finish(); + + $rdf = $writer->drain(); + $this->assertOutputLines( 'DumpHeader', $rdf ); + } + /** * @param string $datasetName * @param string[]|string $actual */ private function assertOutputLines( $datasetName, $actual ) { $path = __DIR__ . '/../data/' . $datasetName . '.' . $this->getFileSuffix(); $this->assertNTriplesEquals( file_get_contents( $path ), $actual, "Result mismatches data in $path" ); } /** * @param string[]|string $nTriples * * @return string[] Sorted alphabetically. */ protected function normalizeNTriples( $nTriples ) { if ( is_string( $nTriples ) ) { // Trim and ignore newlines at the end of the file only. $nTriples = explode( "\n", rtrim( $nTriples, "\n" ) ); } - sort( $nTriples ); + if ( $this->sortLines() ) { + sort( $nTriples ); + } return $nTriples; } /** * @param string[]|string $expected * @param string[]|string $actual * @param string $message */ protected function assertNTriplesEquals( $expected, $actual, $message = '' ) { $expected = $this->normalizeNTriples( $expected ); $actual = $this->normalizeNTriples( $actual ); - // Comparing $expected and $actual directly would show triples that are present in both but - // shifted in position. That makes the output hard to read. Calculating the $missing and - // $extra sets helps. - $extra = array_diff( $actual, $expected ); - $missing = array_diff( $expected, $actual ); - - // Cute: $missing and $extra can be equal only if they are empty. Comparing them here - // directly looks a bit odd in code, but produces meaningful output, especially if the input - // was sorted. - $this->assertEquals( $missing, $extra, $message ); + if ( $this->sortLines() ) { + // Comparing $expected and $actual directly would show triples that are present in both but + // shifted in position. That makes the output hard to read. Calculating the $missing and + // $extra sets helps. + $extra = array_diff( $actual, $expected ); + $missing = array_diff( $expected, $actual ); + + // Cute: $missing and $extra can be equal only if they are empty. Comparing them here + // directly looks a bit odd in code, but produces meaningful output, especially if the input + // was sorted. + $this->assertEquals( $missing, $extra, $message ); + } else { + $this->assertEquals( $expected, $actual, $message ); + } } // FIXME: test non-ascii literals! // FIXME: test uerl-encoding // FIXME: test IRIs! } diff --git a/tests/phpunit/TurtleRdfWriterTest.php b/tests/phpunit/TurtleRdfWriterTest.php index 0e1c3dd..05bc872 100644 --- a/tests/phpunit/TurtleRdfWriterTest.php +++ b/tests/phpunit/TurtleRdfWriterTest.php @@ -1,43 +1,50 @@ assertTrue( $writer->getTrustIRIs(), 'initialy enabled' ); $writer->setTrustIRIs( false ); $this->assertFalse( $writer->getTrustIRIs(), 'disabled' ); } }