From 81d0b5314d2c6e015b39b3767d2b19fcafd87a64 Mon Sep 17 00:00:00 2001 From: Ostrzyciel Date: Tue, 18 Aug 2026 16:29:04 +0200 Subject: [PATCH 1/2] Use snapshots of Jelly-JVM Will be needed to add SPARQL support. We will move back to stable releases before the next cli release. --- build.sbt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build.sbt b/build.sbt index 329bbc2..8dddcdc 100644 --- a/build.sbt +++ b/build.sbt @@ -8,10 +8,10 @@ ThisBuild / scalaVersion := scalaV Global / lintUnusedKeysOnLoad := false resolvers += - "Sonatype OSS Snapshots" at "https://s01.oss.sonatype.org/content/repositories/snapshots" + "Sonatype OSS Snapshots" at "https://central.sonatype.com/repository/maven-snapshots" lazy val jenaV = "6.2.0" -lazy val jellyV = "3.7.3" +lazy val jellyV = "3.7.3+33-28c9f700-SNAPSHOT" lazy val graalvmV = "25.2.4" addCommandAlias("fixAll", "scalafixAll; scalafmtAll") From 98d7933f55775d8dbcc4727e4f5907a153562dac Mon Sep 17 00:00:00 2001 From: Ostrzyciel Date: Tue, 18 Aug 2026 20:29:00 +0200 Subject: [PATCH 2/2] Add sparql to-jelly/from-jelly commands This wasn't too bad to integrate. --- .github/workflows/aot-test.yml | 13 ++ README.md | 20 ++ build.sbt | 2 + .../jelly/cli/graal/GraalSubstitutes.java | 19 ++ .../scala/eu/neverblink/jelly/cli/App.scala | 7 + .../cli/command/sparql/SparqlFromJelly.scala | 50 +++++ .../command/sparql/SparqlSerDesCommand.scala | 74 +++++++ .../cli/command/sparql/SparqlToJelly.scala | 50 +++++ .../command/sparql/util/SparqlFormat.scala | 90 ++++++++ .../cli/command/sparql/SparqlSerDesSpec.scala | 206 ++++++++++++++++++ 10 files changed, 531 insertions(+) create mode 100644 src/main/scala/eu/neverblink/jelly/cli/command/sparql/SparqlFromJelly.scala create mode 100644 src/main/scala/eu/neverblink/jelly/cli/command/sparql/SparqlSerDesCommand.scala create mode 100644 src/main/scala/eu/neverblink/jelly/cli/command/sparql/SparqlToJelly.scala create mode 100644 src/main/scala/eu/neverblink/jelly/cli/command/sparql/util/SparqlFormat.scala create mode 100644 src/test/scala/eu/neverblink/jelly/cli/command/sparql/SparqlSerDesSpec.scala diff --git a/.github/workflows/aot-test.yml b/.github/workflows/aot-test.yml index 370ece7..c3d6385 100644 --- a/.github/workflows/aot-test.yml +++ b/.github/workflows/aot-test.yml @@ -81,6 +81,19 @@ jobs: ./jelly-cli \ rdf validate out.jelly --compare-to-rdf-file in.nt || exit 1 + # Test SPARQL result set conversions + echo '{"head":{"vars":["a"]},"results":{"bindings":[{"a":{"type":"uri","value":"http://e.org/x"}}]}}' > in.srj + ./jelly-cli \ + sparql to-jelly in.srj > out.jellys && \ + [ -s out.jellys ] || exit 1 + ./jelly-cli \ + sparql from-jelly --out-format=csv out.jellys | grep 'http://e.org/x' || exit 1 + # ASK results take a different code path than bindings + echo '{"head":{},"boolean":true}' | \ + ./jelly-cli sparql to-jelly --in-format=json > ask.jellys && \ + [ -s ask.jellys ] || exit 1 + ./jelly-cli sparql from-jelly ask.jellys | grep 'true' || exit 1 + - name: Upload binary uses: actions/upload-artifact@v4 with: diff --git a/README.md b/README.md index 6dbcabe..89c19c2 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,24 @@ jelly-cli rdf validate input.jelly You can also check whether the Jelly file has been encoded using specific stream options or is equivalent to another RDF file, with the use of additional options to this command. +### Convert SPARQL results to and from Jelly-SPARQL + +Jelly-SPARQL is a columnar format for SPARQL query results. To convert results to it, run: + +```shell +jelly-cli sparql to-jelly results.srj > results.jellys +``` + +And to convert back: + +```shell +jelly-cli sparql from-jelly results.jellys --out-format=csv > results.csv +``` + +Both commands handle SELECT results (bindings) and ASK results (a boolean). All standard result formats (JSON, XML, CSV and TSV) are supported, plus a plain text table (`text`) for output only. + +Jelly-SPARQL is an experimental draft and the format may still change. + ### General tips Use the `--help` option to learn more about all the available settings: @@ -114,6 +132,8 @@ jelly-cli rdf from-jelly --help jelly-cli rdf transcode --help jelly-cli rdf inspect --help jelly-cli rdf validate --help +jelly-cli sparql to-jelly --help +jelly-cli sparql from-jelly --help ``` And use the `--debug` option to get more information about any exceptions you encounter. diff --git a/build.sbt b/build.sbt index 8dddcdc..c7cd969 100644 --- a/build.sbt +++ b/build.sbt @@ -67,6 +67,8 @@ lazy val root = (project in file(".")) "org.apache.jena" % "jena-arq" % jenaV, // Jelly-JVM 3.7.x pins Jena 5.6.x as a dependency, we must exclude it, because we use Jena 6.x. ("eu.neverblink.jelly" % "jelly-jena" % jellyV).excludeAll(ExclusionRule("org.apache.jena")), + ("eu.neverblink.jelly" % "jelly-jena-sparql" % jellyV) + .excludeAll(ExclusionRule("org.apache.jena")), "eu.neverblink.jelly" % "jelly-core-protos-google" % jellyV, "com.github.alexarchambault" %% "case-app" % "2.1.0", "org.scalatest" %% "scalatest" % "3.2.20" % "test,test-serial", diff --git a/src/main/java/eu/neverblink/jelly/cli/graal/GraalSubstitutes.java b/src/main/java/eu/neverblink/jelly/cli/graal/GraalSubstitutes.java index f4eaa7e..319b6b7 100644 --- a/src/main/java/eu/neverblink/jelly/cli/graal/GraalSubstitutes.java +++ b/src/main/java/eu/neverblink/jelly/cli/graal/GraalSubstitutes.java @@ -7,6 +7,7 @@ import com.google.protobuf.TextFormat; import com.oracle.svm.core.annotate.*; +import java.io.File; import java.net.URI; import java.nio.charset.Charset; import java.util.UUID; @@ -83,6 +84,24 @@ public static String createFreshId() { } } +/** + * Jena's data bags spill to a temporary file once they outgrow their in-memory threshold, and name + * that file with a secure random UUID. The SPARQL results JSON reader buffers rows in a data bag + * when it has to read past the bindings to find the header, which drags secure random number + * generation back into the binary. + *

+ * The file name only has to be unique, so a pseudo-random UUID does the job here. + */ +@TargetClass(className = "org.apache.jena.atlas.data.AbstractDataBag") +final class AbstractDataBagSubstitute { + @Substitute + protected File getNewTemporaryFile() { + ThreadLocalRandom r = ThreadLocalRandom.current(); + File sysTempDir = new File(System.getProperty("java.io.tmpdir")); + return new File(sysTempDir, "DataBag-" + new UUID(r.nextLong(), r.nextLong()) + ".tmp"); + } +} + /** * Disable UTF-32LE support in JSON parsers, which we don't need. * This allows us to avoid including all charsets in the native image, which saves quite a bit of space. diff --git a/src/main/scala/eu/neverblink/jelly/cli/App.scala b/src/main/scala/eu/neverblink/jelly/cli/App.scala index ba236d4..1f7377f 100644 --- a/src/main/scala/eu/neverblink/jelly/cli/App.scala +++ b/src/main/scala/eu/neverblink/jelly/cli/App.scala @@ -3,7 +3,9 @@ package eu.neverblink.jelly.cli import caseapp.* import eu.neverblink.jelly.cli.command.* import eu.neverblink.jelly.cli.command.rdf.* +import eu.neverblink.jelly.cli.command.sparql.* import eu.neverblink.jelly.cli.util.jena.riot.CliRiot +import eu.neverblink.jelly.convert.jena.sparql.JellySparqlLanguage import org.apache.jena.sys.JenaSystem /** Main entrypoint. @@ -14,6 +16,9 @@ object App extends CommandsEntryPoint: JenaSystem.init() // Initialize the CLI Riot parsers CliRiot.initialize() + // JenaSystem.init() already does this via the subsystem lifecycle, but that relies on service + // discovery, which we'd rather not depend on in native-image builds. The call is idempotent. + JellySparqlLanguage.register() override def enableCompletionsCommand: Boolean = true @@ -28,4 +33,6 @@ object App extends CommandsEntryPoint: RdfTranscode, RdfInspect, RdfValidate, + SparqlFromJelly, + SparqlToJelly, ) diff --git a/src/main/scala/eu/neverblink/jelly/cli/command/sparql/SparqlFromJelly.scala b/src/main/scala/eu/neverblink/jelly/cli/command/sparql/SparqlFromJelly.scala new file mode 100644 index 0000000..a8ecdac --- /dev/null +++ b/src/main/scala/eu/neverblink/jelly/cli/command/sparql/SparqlFromJelly.scala @@ -0,0 +1,50 @@ +package eu.neverblink.jelly.cli.command.sparql + +import caseapp.* +import eu.neverblink.jelly.cli.* +import eu.neverblink.jelly.cli.command.sparql.util.SparqlFormat + +object SparqlFromJellyPrint: + val validFormats: List[SparqlFormat] = SparqlFormat.writeable + val defaultFormat: SparqlFormat = SparqlFormat.Json + lazy val helpMsg: String = SparqlFormat.helpMsg(validFormats, defaultFormat) + +@HelpMessage( + "Translates a Jelly-SPARQL stream to a different SPARQL result set format. \n" + + "If no input file is specified, the input is read from stdin.\n" + + "If no output file is specified, the output is written to stdout.\n" + + "Both SELECT results (bindings) and ASK results (a boolean) are supported.\n" + + "If an error is detected, the program will exit with a non-zero code.\n" + + "Otherwise, the program will exit with code 0.", +) +@ArgsName("") +case class SparqlFromJellyOptions( + @Recurse + common: JellyCommandOptions = JellyCommandOptions(), + @HelpMessage( + "Output file to write the SPARQL results to. If not specified, the output is written to stdout.", + ) + @ExtraName("to") outputFile: Option[String] = None, + @HelpMessage( + "Format the Jelly-SPARQL stream should be translated to. " + + "If not explicitly specified, but output file supplied, the format is inferred from the file name. " + + SparqlFromJellyPrint.helpMsg, + ) + @ExtraName("out-format") outputFormat: Option[String] = None, +) extends HasJellyCommandOptions + +object SparqlFromJelly extends SparqlSerDesCommand[SparqlFromJellyOptions]: + + override def names: List[List[String]] = List( + List("sparql", "from-jelly"), + ) + + override val validFormats: List[SparqlFormat] = SparqlFromJellyPrint.validFormats + + override val defaultFormat: SparqlFormat = SparqlFromJellyPrint.defaultFormat + + override def doRun(options: SparqlFromJellyOptions, remainingArgs: RemainingArgs): Unit = + val inputFile = remainingArgs.remaining.headOption + val outputFormat = resolveFormat(options.outputFormat, options.outputFile) + val (inputStream, outputStream) = getIoStreamsFromOptions(inputFile, options.outputFile) + convert(SparqlFormat.JellySparql, outputFormat, inputStream, outputStream) diff --git a/src/main/scala/eu/neverblink/jelly/cli/command/sparql/SparqlSerDesCommand.scala b/src/main/scala/eu/neverblink/jelly/cli/command/sparql/SparqlSerDesCommand.scala new file mode 100644 index 0000000..49f9fd5 --- /dev/null +++ b/src/main/scala/eu/neverblink/jelly/cli/command/sparql/SparqlSerDesCommand.scala @@ -0,0 +1,74 @@ +package eu.neverblink.jelly.cli.command.sparql + +import caseapp.* +import com.google.protobuf.InvalidProtocolBufferException +import eu.neverblink.jelly.cli.* +import eu.neverblink.jelly.cli.command.sparql.util.SparqlFormat +import eu.neverblink.jelly.core.{RdfProtoDeserializationError, RdfProtoSerializationError} +import org.apache.jena.riot.{RIOT, RiotException} +import org.apache.jena.riot.resultset.{ResultSetReaderRegistry, ResultSetWriterRegistry} + +import java.io.{InputStream, OutputStream} + +/** Common logic for the two SPARQL result set conversion commands. + */ +abstract class SparqlSerDesCommand[T <: HasJellyCommandOptions: {Parser, Help}] + extends JellyCommand[T]: + + override final def group = "sparql" + + /** Formats the user can pick from for the non-Jelly side of the conversion. */ + val validFormats: List[SparqlFormat] + + /** Format assumed when the user gives neither an explicit format nor a recognizable file name. */ + val defaultFormat: SparqlFormat + + /** Picks the non-Jelly format. + * + * @throws InvalidFormatSpecified + * if the user asked for a format this command cannot handle + */ + final def resolveFormat(format: Option[String], fileName: Option[String]): SparqlFormat = + format match + case Some(name) => + SparqlFormat.find(name).filter(validFormats.contains).getOrElse { + throw InvalidFormatSpecified(name, SparqlFormat.validFormatsString(validFormats)) + } + case None => + fileName + .flatMap(SparqlFormat.inferFormat) + .filter(validFormats.contains) + .getOrElse(defaultFormat) + + /** Reads a result set in one format and writes it back out in another. + * + * Both SELECT results (bindings) and ASK results (a single boolean) are handled. + */ + final def convert( + from: SparqlFormat, + to: SparqlFormat, + inputStream: InputStream, + outputStream: OutputStream, + ): Unit = + try { + val context = RIOT.getContext.copy() + val reader = ResultSetReaderRegistry.getFactory(from.jenaLang).create(from.jenaLang) + val writer = ResultSetWriterRegistry.getFactory(to.jenaLang).create(to.jenaLang) + val result = reader.readAny(inputStream, context) + if result.isBoolean then + writer.write(outputStream, result.getBooleanResult.booleanValue, context) + else writer.write(outputStream, result.getResultSet, context) + outputStream.flush() + } catch + // The Jelly RowSet reader wraps I/O errors (including protobuf ones) in a RiotException, + // so unwrap it to report a malformed Jelly file the same way the rdf commands do. + case e: RiotException => + e.getCause match + case cause: InvalidProtocolBufferException => throw InvalidJellyFile(cause) + case _ => throw JenaRiotException(e) + case e: InvalidProtocolBufferException => + throw InvalidJellyFile(e) + case e: RdfProtoDeserializationError => + throw JellyDeserializationError(e.getMessage) + case e: RdfProtoSerializationError => + throw JellySerializationError(e.getMessage) diff --git a/src/main/scala/eu/neverblink/jelly/cli/command/sparql/SparqlToJelly.scala b/src/main/scala/eu/neverblink/jelly/cli/command/sparql/SparqlToJelly.scala new file mode 100644 index 0000000..1cb692b --- /dev/null +++ b/src/main/scala/eu/neverblink/jelly/cli/command/sparql/SparqlToJelly.scala @@ -0,0 +1,50 @@ +package eu.neverblink.jelly.cli.command.sparql + +import caseapp.* +import eu.neverblink.jelly.cli.* +import eu.neverblink.jelly.cli.command.sparql.util.SparqlFormat + +object SparqlToJellyPrint: + val validFormats: List[SparqlFormat] = SparqlFormat.readable + val defaultFormat: SparqlFormat = SparqlFormat.Json + lazy val helpMsg: String = SparqlFormat.helpMsg(validFormats, defaultFormat) + +@HelpMessage( + "Translates SPARQL query results to a Jelly-SPARQL stream. \n" + + "If no input file is specified, the input is read from stdin.\n" + + "If no output file is specified, the output is written to stdout.\n" + + "Both SELECT results (bindings) and ASK results (a boolean) are supported.\n" + + "If an error is detected, the program will exit with a non-zero code.\n" + + "Otherwise, the program will exit with code 0.", +) +@ArgsName("") +case class SparqlToJellyOptions( + @Recurse + common: JellyCommandOptions = JellyCommandOptions(), + @HelpMessage( + "Output file to write the Jelly-SPARQL to. If not specified, the output is written to stdout.", + ) + @ExtraName("to") outputFile: Option[String] = None, + @HelpMessage( + "Format of the SPARQL results that should be translated to Jelly. " + + "If not explicitly specified, but input file supplied, the format is inferred from the file name. " + + SparqlToJellyPrint.helpMsg, + ) + @ExtraName("in-format") inputFormat: Option[String] = None, +) extends HasJellyCommandOptions + +object SparqlToJelly extends SparqlSerDesCommand[SparqlToJellyOptions]: + + override def names: List[List[String]] = List( + List("sparql", "to-jelly"), + ) + + override val validFormats: List[SparqlFormat] = SparqlToJellyPrint.validFormats + + override val defaultFormat: SparqlFormat = SparqlToJellyPrint.defaultFormat + + override def doRun(options: SparqlToJellyOptions, remainingArgs: RemainingArgs): Unit = + val inputFile = remainingArgs.remaining.headOption + val inputFormat = resolveFormat(options.inputFormat, inputFile) + val (inputStream, outputStream) = getIoStreamsFromOptions(inputFile, options.outputFile) + convert(inputFormat, SparqlFormat.JellySparql, inputStream, outputStream) diff --git a/src/main/scala/eu/neverblink/jelly/cli/command/sparql/util/SparqlFormat.scala b/src/main/scala/eu/neverblink/jelly/cli/command/sparql/util/SparqlFormat.scala new file mode 100644 index 0000000..b491081 --- /dev/null +++ b/src/main/scala/eu/neverblink/jelly/cli/command/sparql/util/SparqlFormat.scala @@ -0,0 +1,90 @@ +package eu.neverblink.jelly.cli.command.sparql.util + +import eu.neverblink.jelly.convert.jena.sparql.JellySparqlLanguage +import org.apache.jena.riot.{Lang, RDFLanguages} +import org.apache.jena.riot.resultset.ResultSetLang + +/** A SPARQL result set format that the sparql commands can convert to or from. + * + * This is the SPARQL results counterpart of + * [[eu.neverblink.jelly.cli.command.rdf.util.RdfFormat]]. + */ +sealed trait SparqlFormat: + val fullName: String + val cliOptions: List[String] + val jenaLang: Lang + override final def toString: String = fullName + +object SparqlFormat: + + /** Formats we can read a result set from. */ + sealed trait Readable extends SparqlFormat + + /** Formats we can write a result set to. */ + sealed trait Writeable extends SparqlFormat + + case object Json extends SparqlFormat.Readable, SparqlFormat.Writeable: + override val fullName: String = "SPARQL results JSON" + override val cliOptions: List[String] = List("json", "srj") + override val jenaLang: Lang = ResultSetLang.RS_JSON + + case object Xml extends SparqlFormat.Readable, SparqlFormat.Writeable: + override val fullName: String = "SPARQL results XML" + override val cliOptions: List[String] = List("xml", "srx") + override val jenaLang: Lang = ResultSetLang.RS_XML + + case object Csv extends SparqlFormat.Readable, SparqlFormat.Writeable: + override val fullName: String = "CSV" + override val cliOptions: List[String] = List("csv") + override val jenaLang: Lang = ResultSetLang.RS_CSV + + case object Tsv extends SparqlFormat.Readable, SparqlFormat.Writeable: + override val fullName: String = "TSV" + override val cliOptions: List[String] = List("tsv") + override val jenaLang: Lang = ResultSetLang.RS_TSV + + /** Jena's pretty-printed table. Jena registers no reader for it, so it's output-only. */ + case object Text extends SparqlFormat.Writeable: + override val fullName: String = "Text table" + override val cliOptions: List[String] = List("text") + override val jenaLang: Lang = ResultSetLang.RS_Text + + /** We never convert Jelly to Jelly, so this is neither Readable nor Writeable – it is only here + * so that the other side of the conversion has a name. + */ + case object JellySparql extends SparqlFormat: + override val fullName: String = "Jelly-SPARQL" + override val cliOptions: List[String] = List("jelly-sparql") + override val jenaLang: Lang = JellySparqlLanguage.JELLY_SPARQL + + private val sparqlFormats: List[SparqlFormat] = List(Json, Xml, Csv, Tsv, Text, JellySparql) + + def all: List[SparqlFormat] = sparqlFormats + + lazy val readable: List[SparqlFormat.Readable] = + sparqlFormats.collect { case f: SparqlFormat.Readable => f } + + lazy val writeable: List[SparqlFormat.Writeable] = + sparqlFormats.collect { case f: SparqlFormat.Writeable => f } + + /** Returns a string representation of the option for the user. + */ + def optionString(option: SparqlFormat): String = + f"${option.fullName}: ${option.cliOptions.mkString(", ")}" + + def validFormatsString(formats: List[SparqlFormat]): String = + formats.map(optionString).mkString("; ") + + def helpMsg(formats: List[SparqlFormat], default: SparqlFormat): String = + f"Possible values: ${validFormatsString(formats)}. Default: ${default.fullName}" + + /** Finds the appropriate SparqlFormat based on supplied option string. + */ + def find(cliOption: String): Option[SparqlFormat] = + sparqlFormats.find(_.cliOptions.contains(cliOption)) + + /** Infers the format based on the file name. + */ + def inferFormat(fileName: String): Option[SparqlFormat] = + val guessType = RDFLanguages.guessContentType(fileName) + sparqlFormats.collectFirst { case f if f.jenaLang.getContentType == guessType => f } diff --git a/src/test/scala/eu/neverblink/jelly/cli/command/sparql/SparqlSerDesSpec.scala b/src/test/scala/eu/neverblink/jelly/cli/command/sparql/SparqlSerDesSpec.scala new file mode 100644 index 0000000..5cfb2b6 --- /dev/null +++ b/src/test/scala/eu/neverblink/jelly/cli/command/sparql/SparqlSerDesSpec.scala @@ -0,0 +1,206 @@ +package eu.neverblink.jelly.cli.command.sparql + +import eu.neverblink.jelly.cli.* +import eu.neverblink.jelly.cli.command.helpers.TestFixtureHelper +import eu.neverblink.jelly.cli.command.sparql.util.SparqlFormat +import eu.neverblink.jelly.convert.jena.sparql.JellySparqlLanguage +import org.apache.jena.query.{ResultSet, ResultSetFactory} +import org.apache.jena.riot.{Lang, ResultSetMgr} +import org.apache.jena.riot.resultset.ResultSetLang +import org.apache.jena.sparql.resultset.ResultsCompare +import org.scalatest.matchers.should.Matchers +import org.scalatest.wordspec.AnyWordSpec + +import java.io.ByteArrayInputStream +import java.nio.charset.StandardCharsets.UTF_8 +import java.nio.file.{Files, Path} +import java.util.UUID.randomUUID +import scala.jdk.CollectionConverters.* + +object SparqlSerDesSpec: + /** A SELECT result set testing every term type Jelly-SPARQL has to support, plus unbound cells. + */ + val selectJson: String = + """{ "head": { "vars": [ "s", "label", "num", "bn" ] }, + | "results": { "bindings": [ + | { "s": {"type":"uri","value":"http://example.org/a"}, + | "label": {"type":"literal","value":"hello"}, + | "num": {"type":"literal","value":"42", + | "datatype":"http://www.w3.org/2001/XMLSchema#integer"}, + | "bn": {"type":"bnode","value":"b0"} }, + | { "s": {"type":"uri","value":"http://example.org/b"}, + | "label": {"type":"literal","value":"cześć","xml:lang":"pl"} }, + | { "s": {"type":"uri","value":"http://example.org/c"}, + | "num": {"type":"literal","value":"-1", + | "datatype":"http://www.w3.org/2001/XMLSchema#integer"} } + | ] } }""".stripMargin + + def askJson(value: Boolean): String = f"""{ "head": {}, "boolean": $value }""" + + def parse(bytes: Array[Byte], lang: Lang): ResultSet = + ResultSetMgr.read(ByteArrayInputStream(bytes), lang) + + def parse(s: String, lang: Lang): ResultSet = parse(s.getBytes(UTF_8), lang) + +class SparqlSerDesSpec extends AnyWordSpec with TestFixtureHelper with Matchers: + import SparqlSerDesSpec.* + + // Not used by these tests – the SPARQL fixtures are written out by hand. + protected val testCardinality: Int = 0 + + SparqlToJelly.testMode(true) + SparqlFromJelly.testMode(true) + + private val tmpDir: Path = Files.createTempDirectory("jelly-cli-sparql") + + private def withFile[T](content: String, extension: String)(testCode: String => T): T = + val file = Files.createTempFile(tmpDir, randomUUID.toString, extension) + Files.write(file, content.getBytes(UTF_8)) + try testCode(file.toString) + finally Files.deleteIfExists(file) + + private def withEmptyFile[T](extension: String)(testCode: String => T): T = + val file = Files.createTempFile(tmpDir, randomUUID.toString, extension) + try testCode(file.toString) + finally Files.deleteIfExists(file) + + /** Runs `sparql to-jelly` over the given results and returns the Jelly-SPARQL bytes. */ + private def toJelly(content: String, extension: String, args: List[String] = Nil): Array[Byte] = + withFile(content, extension) { f => + SparqlToJelly.runTestCommand(List("sparql", "to-jelly", f) ++ args) + SparqlToJelly.getOutBytes + } + + "sparql to-jelly command" should { + "convert a SELECT result set, preserving every binding" in { + val jelly = toJelly(selectJson, ".srj") + val roundTripped = + ResultSetFactory.makeRewindable(parse(jelly, JellySparqlLanguage.JELLY_SPARQL)) + // Check the shape explicitly – comparing two empty result sets would also "succeed" + roundTripped.getResultVars.asScala.toList should contain theSameElementsInOrderAs + List("s", "label", "num", "bn") + roundTripped.size shouldBe 3 + roundTripped.reset() + ResultsCompare.equalsByTermAndOrder( + roundTripped, + parse(selectJson, ResultSetLang.RS_JSON), + ) shouldBe true + } + + "convert an ASK result" in { + for value <- Seq(true, false) do + val jelly = toJelly(askJson(value), ".srj") + ResultSetMgr.readBoolean( + ByteArrayInputStream(jelly), + JellySparqlLanguage.JELLY_SPARQL, + ) shouldBe value + } + + "infer the input format from the file name" in { + // .srx is only recognizable from the extension – no --in-format is passed + val xml = + ResultSetMgr.asString(parse(selectJson, ResultSetLang.RS_JSON), ResultSetLang.RS_XML) + val jelly = toJelly(xml, ".srx") + ResultsCompare.equalsByTermAndOrder( + parse(jelly, JellySparqlLanguage.JELLY_SPARQL), + parse(selectJson, ResultSetLang.RS_JSON), + ) shouldBe true + } + + "respect an explicit --in-format over the file name" in { + val xml = + ResultSetMgr.asString(parse(selectJson, ResultSetLang.RS_JSON), ResultSetLang.RS_XML) + // File claims to be JSON, but we tell the command it's really XML + val jelly = toJelly(xml, ".srj", List("--in-format", "xml")) + ResultsCompare.equalsByTermAndOrder( + parse(jelly, JellySparqlLanguage.JELLY_SPARQL), + parse(selectJson, ResultSetLang.RS_JSON), + ) shouldBe true + } + + "read from stdin" in { + SparqlToJelly.setStdIn(ByteArrayInputStream(selectJson.getBytes(UTF_8))) + SparqlToJelly.runTestCommand(List("sparql", "to-jelly")) + ResultsCompare.equalsByTermAndOrder( + parse(SparqlToJelly.getOutBytes, JellySparqlLanguage.JELLY_SPARQL), + parse(selectJson, ResultSetLang.RS_JSON), + ) shouldBe true + } + + "reject a format it cannot read" in { + // The text table is output-only, so it must not be accepted as an input format + val e = intercept[ExitException] { + toJelly(selectJson, ".srj", List("--in-format", SparqlFormat.Text.cliOptions.head)) + } + e.getCause shouldBe a[InvalidFormatSpecified] + } + } + + "sparql from-jelly command" should { + "convert a SELECT result set back to the machine-readable formats" in { + val jelly = toJelly(selectJson, ".srj") + for format <- Seq(SparqlFormat.Json, SparqlFormat.Xml) do + SparqlFromJelly.setStdIn(ByteArrayInputStream(jelly)) + SparqlFromJelly.runTestCommand( + List("sparql", "from-jelly", "--out-format", format.cliOptions.head), + ) + ResultsCompare.equalsByTermAndOrder( + parse(SparqlFromJelly.getOutBytes, format.jenaLang), + parse(selectJson, ResultSetLang.RS_JSON), + ) shouldBe true + } + + "write the text table and CSV, which are not machine-readable round trips" in { + val jelly = toJelly(selectJson, ".srj") + for format <- Seq(SparqlFormat.Text, SparqlFormat.Csv, SparqlFormat.Tsv) do + SparqlFromJelly.setStdIn(ByteArrayInputStream(jelly)) + val (out, _) = SparqlFromJelly.runTestCommand( + List("sparql", "from-jelly", "--out-format", format.cliOptions.head), + ) + // All three are row-oriented text, so every variable and every subject should show up + for expected <- Seq("s", "label", "num", "bn", "http://example.org/a", "42", "cześć") do + out should include(expected) + } + + "convert an ASK result" in { + for value <- Seq(true, false) do + val jelly = toJelly(askJson(value), ".srj") + SparqlFromJelly.setStdIn(ByteArrayInputStream(jelly)) + val (out, _) = SparqlFromJelly.runTestCommand(List("sparql", "from-jelly")) + ResultSetMgr.readBoolean( + ByteArrayInputStream(out.getBytes(UTF_8)), + ResultSetLang.RS_JSON, + ) shouldBe value + } + + "infer the output format from the file name" in { + val jelly = toJelly(selectJson, ".srj") + withEmptyFile(".srx") { target => + SparqlFromJelly.setStdIn(ByteArrayInputStream(jelly)) + SparqlFromJelly.runTestCommand(List("sparql", "from-jelly", "--to", target)) + ResultsCompare.equalsByTermAndOrder( + parse(Files.readAllBytes(Path.of(target)), ResultSetLang.RS_XML), + parse(selectJson, ResultSetLang.RS_JSON), + ) shouldBe true + } + } + + "reject a format it cannot write" in { + val e = intercept[ExitException] { + SparqlFromJelly.setStdIn(ByteArrayInputStream(Array())) + SparqlFromJelly.runTestCommand( + List("sparql", "from-jelly", "--out-format", "jelly-sparql"), + ) + } + e.getCause shouldBe a[InvalidFormatSpecified] + } + + "report a malformed Jelly file" in { + withFile("this is definitely not Jelly", ".jellys") { f => + val e = intercept[ExitException] { + SparqlFromJelly.runTestCommand(List("sparql", "from-jelly", f)) + } + e.getCause shouldBe a[InvalidJellyFile] + } + } + }