Skip to content

Commit dfb6fe8

Browse files
authored
Add sparql to-jelly/from-jelly commands (#310)
* Use snapshots of Jelly-JVM Will be needed to add SPARQL support. We will move back to stable releases before the next cli release. * Add sparql to-jelly/from-jelly commands This wasn't too bad to integrate.
1 parent e7a3c79 commit dfb6fe8

10 files changed

Lines changed: 533 additions & 2 deletions

File tree

.github/workflows/aot-test.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,19 @@ jobs:
8181
./jelly-cli \
8282
rdf validate out.jelly --compare-to-rdf-file in.nt || exit 1
8383
84+
# Test SPARQL result set conversions
85+
echo '{"head":{"vars":["a"]},"results":{"bindings":[{"a":{"type":"uri","value":"http://e.org/x"}}]}}' > in.srj
86+
./jelly-cli \
87+
sparql to-jelly in.srj > out.jellys && \
88+
[ -s out.jellys ] || exit 1
89+
./jelly-cli \
90+
sparql from-jelly --out-format=csv out.jellys | grep 'http://e.org/x' || exit 1
91+
# ASK results take a different code path than bindings
92+
echo '{"head":{},"boolean":true}' | \
93+
./jelly-cli sparql to-jelly --in-format=json > ask.jellys && \
94+
[ -s ask.jellys ] || exit 1
95+
./jelly-cli sparql from-jelly ask.jellys | grep 'true' || exit 1
96+
8497
- name: Upload binary
8598
uses: actions/upload-artifact@v4
8699
with:

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,24 @@ jelly-cli rdf validate input.jelly
104104

105105
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.
106106

107+
### Convert SPARQL results to and from Jelly-SPARQL
108+
109+
Jelly-SPARQL is a columnar format for SPARQL query results. To convert results to it, run:
110+
111+
```shell
112+
jelly-cli sparql to-jelly results.srj > results.jellys
113+
```
114+
115+
And to convert back:
116+
117+
```shell
118+
jelly-cli sparql from-jelly results.jellys --out-format=csv > results.csv
119+
```
120+
121+
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.
122+
123+
Jelly-SPARQL is an experimental draft and the format may still change.
124+
107125
### General tips
108126

109127
Use the `--help` option to learn more about all the available settings:
@@ -114,6 +132,8 @@ jelly-cli rdf from-jelly --help
114132
jelly-cli rdf transcode --help
115133
jelly-cli rdf inspect --help
116134
jelly-cli rdf validate --help
135+
jelly-cli sparql to-jelly --help
136+
jelly-cli sparql from-jelly --help
117137
```
118138

119139
And use the `--debug` option to get more information about any exceptions you encounter.

build.sbt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,10 @@ ThisBuild / scalaVersion := scalaV
88
Global / lintUnusedKeysOnLoad := false
99

1010
resolvers +=
11-
"Sonatype OSS Snapshots" at "https://s01.oss.sonatype.org/content/repositories/snapshots"
11+
"Sonatype OSS Snapshots" at "https://central.sonatype.com/repository/maven-snapshots"
1212

1313
lazy val jenaV = "6.2.0"
14-
lazy val jellyV = "3.7.3"
14+
lazy val jellyV = "3.7.3+33-28c9f700-SNAPSHOT"
1515
lazy val graalvmV = "25.2.4"
1616

1717
addCommandAlias("fixAll", "scalafixAll; scalafmtAll")
@@ -67,6 +67,8 @@ lazy val root = (project in file("."))
6767
"org.apache.jena" % "jena-arq" % jenaV,
6868
// Jelly-JVM 3.7.x pins Jena 5.6.x as a dependency, we must exclude it, because we use Jena 6.x.
6969
("eu.neverblink.jelly" % "jelly-jena" % jellyV).excludeAll(ExclusionRule("org.apache.jena")),
70+
("eu.neverblink.jelly" % "jelly-jena-sparql" % jellyV)
71+
.excludeAll(ExclusionRule("org.apache.jena")),
7072
"eu.neverblink.jelly" % "jelly-core-protos-google" % jellyV,
7173
"com.github.alexarchambault" %% "case-app" % "2.1.0",
7274
"org.scalatest" %% "scalatest" % "3.2.20" % "test,test-serial",

src/main/java/eu/neverblink/jelly/cli/graal/GraalSubstitutes.java

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import com.google.protobuf.TextFormat;
88
import com.oracle.svm.core.annotate.*;
99

10+
import java.io.File;
1011
import java.net.URI;
1112
import java.nio.charset.Charset;
1213
import java.util.UUID;
@@ -83,6 +84,24 @@ public static String createFreshId() {
8384
}
8485
}
8586

87+
/**
88+
* Jena's data bags spill to a temporary file once they outgrow their in-memory threshold, and name
89+
* that file with a secure random UUID. The SPARQL results JSON reader buffers rows in a data bag
90+
* when it has to read past the bindings to find the header, which drags secure random number
91+
* generation back into the binary.
92+
* <p>
93+
* The file name only has to be unique, so a pseudo-random UUID does the job here.
94+
*/
95+
@TargetClass(className = "org.apache.jena.atlas.data.AbstractDataBag")
96+
final class AbstractDataBagSubstitute {
97+
@Substitute
98+
protected File getNewTemporaryFile() {
99+
ThreadLocalRandom r = ThreadLocalRandom.current();
100+
File sysTempDir = new File(System.getProperty("java.io.tmpdir"));
101+
return new File(sysTempDir, "DataBag-" + new UUID(r.nextLong(), r.nextLong()) + ".tmp");
102+
}
103+
}
104+
86105
/**
87106
* Disable UTF-32LE support in JSON parsers, which we don't need.
88107
* This allows us to avoid including all charsets in the native image, which saves quite a bit of space.

src/main/scala/eu/neverblink/jelly/cli/App.scala

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ package eu.neverblink.jelly.cli
33
import caseapp.*
44
import eu.neverblink.jelly.cli.command.*
55
import eu.neverblink.jelly.cli.command.rdf.*
6+
import eu.neverblink.jelly.cli.command.sparql.*
67
import eu.neverblink.jelly.cli.util.jena.riot.CliRiot
8+
import eu.neverblink.jelly.convert.jena.sparql.JellySparqlLanguage
79
import org.apache.jena.sys.JenaSystem
810

911
/** Main entrypoint.
@@ -14,6 +16,9 @@ object App extends CommandsEntryPoint:
1416
JenaSystem.init()
1517
// Initialize the CLI Riot parsers
1618
CliRiot.initialize()
19+
// JenaSystem.init() already does this via the subsystem lifecycle, but that relies on service
20+
// discovery, which we'd rather not depend on in native-image builds. The call is idempotent.
21+
JellySparqlLanguage.register()
1722

1823
override def enableCompletionsCommand: Boolean = true
1924

@@ -28,4 +33,6 @@ object App extends CommandsEntryPoint:
2833
RdfTranscode,
2934
RdfInspect,
3035
RdfValidate,
36+
SparqlFromJelly,
37+
SparqlToJelly,
3138
)
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package eu.neverblink.jelly.cli.command.sparql
2+
3+
import caseapp.*
4+
import eu.neverblink.jelly.cli.*
5+
import eu.neverblink.jelly.cli.command.sparql.util.SparqlFormat
6+
7+
object SparqlFromJellyPrint:
8+
val validFormats: List[SparqlFormat] = SparqlFormat.writeable
9+
val defaultFormat: SparqlFormat = SparqlFormat.Json
10+
lazy val helpMsg: String = SparqlFormat.helpMsg(validFormats, defaultFormat)
11+
12+
@HelpMessage(
13+
"Translates a Jelly-SPARQL stream to a different SPARQL result set format. \n" +
14+
"If no input file is specified, the input is read from stdin.\n" +
15+
"If no output file is specified, the output is written to stdout.\n" +
16+
"Both SELECT results (bindings) and ASK results (a boolean) are supported.\n" +
17+
"If an error is detected, the program will exit with a non-zero code.\n" +
18+
"Otherwise, the program will exit with code 0.",
19+
)
20+
@ArgsName("<file-to-convert>")
21+
case class SparqlFromJellyOptions(
22+
@Recurse
23+
common: JellyCommandOptions = JellyCommandOptions(),
24+
@HelpMessage(
25+
"Output file to write the SPARQL results to. If not specified, the output is written to stdout.",
26+
)
27+
@ExtraName("to") outputFile: Option[String] = None,
28+
@HelpMessage(
29+
"Format the Jelly-SPARQL stream should be translated to. " +
30+
"If not explicitly specified, but output file supplied, the format is inferred from the file name. " +
31+
SparqlFromJellyPrint.helpMsg,
32+
)
33+
@ExtraName("out-format") outputFormat: Option[String] = None,
34+
) extends HasJellyCommandOptions
35+
36+
object SparqlFromJelly extends SparqlSerDesCommand[SparqlFromJellyOptions]:
37+
38+
override def names: List[List[String]] = List(
39+
List("sparql", "from-jelly"),
40+
)
41+
42+
override val validFormats: List[SparqlFormat] = SparqlFromJellyPrint.validFormats
43+
44+
override val defaultFormat: SparqlFormat = SparqlFromJellyPrint.defaultFormat
45+
46+
override def doRun(options: SparqlFromJellyOptions, remainingArgs: RemainingArgs): Unit =
47+
val inputFile = remainingArgs.remaining.headOption
48+
val outputFormat = resolveFormat(options.outputFormat, options.outputFile)
49+
val (inputStream, outputStream) = getIoStreamsFromOptions(inputFile, options.outputFile)
50+
convert(SparqlFormat.JellySparql, outputFormat, inputStream, outputStream)
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package eu.neverblink.jelly.cli.command.sparql
2+
3+
import caseapp.*
4+
import com.google.protobuf.InvalidProtocolBufferException
5+
import eu.neverblink.jelly.cli.*
6+
import eu.neverblink.jelly.cli.command.sparql.util.SparqlFormat
7+
import eu.neverblink.jelly.core.{RdfProtoDeserializationError, RdfProtoSerializationError}
8+
import org.apache.jena.riot.{RIOT, RiotException}
9+
import org.apache.jena.riot.resultset.{ResultSetReaderRegistry, ResultSetWriterRegistry}
10+
11+
import java.io.{InputStream, OutputStream}
12+
13+
/** Common logic for the two SPARQL result set conversion commands.
14+
*/
15+
abstract class SparqlSerDesCommand[T <: HasJellyCommandOptions: {Parser, Help}]
16+
extends JellyCommand[T]:
17+
18+
override final def group = "sparql"
19+
20+
/** Formats the user can pick from for the non-Jelly side of the conversion. */
21+
val validFormats: List[SparqlFormat]
22+
23+
/** Format assumed when the user gives neither an explicit format nor a recognizable file name. */
24+
val defaultFormat: SparqlFormat
25+
26+
/** Picks the non-Jelly format.
27+
*
28+
* @throws InvalidFormatSpecified
29+
* if the user asked for a format this command cannot handle
30+
*/
31+
final def resolveFormat(format: Option[String], fileName: Option[String]): SparqlFormat =
32+
format match
33+
case Some(name) =>
34+
SparqlFormat.find(name).filter(validFormats.contains).getOrElse {
35+
throw InvalidFormatSpecified(name, SparqlFormat.validFormatsString(validFormats))
36+
}
37+
case None =>
38+
fileName
39+
.flatMap(SparqlFormat.inferFormat)
40+
.filter(validFormats.contains)
41+
.getOrElse(defaultFormat)
42+
43+
/** Reads a result set in one format and writes it back out in another.
44+
*
45+
* Both SELECT results (bindings) and ASK results (a single boolean) are handled.
46+
*/
47+
final def convert(
48+
from: SparqlFormat,
49+
to: SparqlFormat,
50+
inputStream: InputStream,
51+
outputStream: OutputStream,
52+
): Unit =
53+
try {
54+
val context = RIOT.getContext.copy()
55+
val reader = ResultSetReaderRegistry.getFactory(from.jenaLang).create(from.jenaLang)
56+
val writer = ResultSetWriterRegistry.getFactory(to.jenaLang).create(to.jenaLang)
57+
val result = reader.readAny(inputStream, context)
58+
if result.isBoolean then
59+
writer.write(outputStream, result.getBooleanResult.booleanValue, context)
60+
else writer.write(outputStream, result.getResultSet, context)
61+
outputStream.flush()
62+
} catch
63+
// The Jelly RowSet reader wraps I/O errors (including protobuf ones) in a RiotException,
64+
// so unwrap it to report a malformed Jelly file the same way the rdf commands do.
65+
case e: RiotException =>
66+
e.getCause match
67+
case cause: InvalidProtocolBufferException => throw InvalidJellyFile(cause)
68+
case _ => throw JenaRiotException(e)
69+
case e: InvalidProtocolBufferException =>
70+
throw InvalidJellyFile(e)
71+
case e: RdfProtoDeserializationError =>
72+
throw JellyDeserializationError(e.getMessage)
73+
case e: RdfProtoSerializationError =>
74+
throw JellySerializationError(e.getMessage)
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package eu.neverblink.jelly.cli.command.sparql
2+
3+
import caseapp.*
4+
import eu.neverblink.jelly.cli.*
5+
import eu.neverblink.jelly.cli.command.sparql.util.SparqlFormat
6+
7+
object SparqlToJellyPrint:
8+
val validFormats: List[SparqlFormat] = SparqlFormat.readable
9+
val defaultFormat: SparqlFormat = SparqlFormat.Json
10+
lazy val helpMsg: String = SparqlFormat.helpMsg(validFormats, defaultFormat)
11+
12+
@HelpMessage(
13+
"Translates SPARQL query results to a Jelly-SPARQL stream. \n" +
14+
"If no input file is specified, the input is read from stdin.\n" +
15+
"If no output file is specified, the output is written to stdout.\n" +
16+
"Both SELECT results (bindings) and ASK results (a boolean) are supported.\n" +
17+
"If an error is detected, the program will exit with a non-zero code.\n" +
18+
"Otherwise, the program will exit with code 0.",
19+
)
20+
@ArgsName("<file-to-convert>")
21+
case class SparqlToJellyOptions(
22+
@Recurse
23+
common: JellyCommandOptions = JellyCommandOptions(),
24+
@HelpMessage(
25+
"Output file to write the Jelly-SPARQL to. If not specified, the output is written to stdout.",
26+
)
27+
@ExtraName("to") outputFile: Option[String] = None,
28+
@HelpMessage(
29+
"Format of the SPARQL results that should be translated to Jelly. " +
30+
"If not explicitly specified, but input file supplied, the format is inferred from the file name. " +
31+
SparqlToJellyPrint.helpMsg,
32+
)
33+
@ExtraName("in-format") inputFormat: Option[String] = None,
34+
) extends HasJellyCommandOptions
35+
36+
object SparqlToJelly extends SparqlSerDesCommand[SparqlToJellyOptions]:
37+
38+
override def names: List[List[String]] = List(
39+
List("sparql", "to-jelly"),
40+
)
41+
42+
override val validFormats: List[SparqlFormat] = SparqlToJellyPrint.validFormats
43+
44+
override val defaultFormat: SparqlFormat = SparqlToJellyPrint.defaultFormat
45+
46+
override def doRun(options: SparqlToJellyOptions, remainingArgs: RemainingArgs): Unit =
47+
val inputFile = remainingArgs.remaining.headOption
48+
val inputFormat = resolveFormat(options.inputFormat, inputFile)
49+
val (inputStream, outputStream) = getIoStreamsFromOptions(inputFile, options.outputFile)
50+
convert(inputFormat, SparqlFormat.JellySparql, inputStream, outputStream)

0 commit comments

Comments
 (0)