diff --git a/server/src/main.rs b/server/src/main.rs index dba27878..dcb72294 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -17,15 +17,14 @@ use std::borrow::Cow; use std::cell::RefCell; use std::cmp::{max, min}; use std::ffi::OsStr; -use std::fmt; use std::fs::File; use std::io::{self, stdin, stdout, BufRead, BufReader, BufWriter, Read, Write}; use std::path::{Path, PathBuf}; use std::rc::Rc; -use std::str; use std::str::FromStr; use std::thread::available_parallelism; use std::time::{Duration, Instant}; +use std::{fmt, fs, str}; use url::form_urlencoded; const MAX_SPARQL_BODY_SIZE: u64 = 1_048_576; @@ -161,6 +160,56 @@ enum Command { #[arg(long)] graph: Option, }, + /// Executes a SPARQL query against the store. + Query { + /// Directory in which the data stored by Oxigraph are persisted. + #[arg(short, long)] + location: PathBuf, + /// The SPARQL query to execute. + /// + /// If no query or query file are given, stdin is used. + #[arg(short, long, conflicts_with = "query_file")] + query: Option, + /// File in which the query is stored. + /// + /// If no query or query file are given, stdin is used. + #[arg(long, conflicts_with = "query")] + query_file: Option, + /// Base URI of the query. + #[arg(long)] + query_base: Option, + /// File in which the query results will be stored. + /// + /// If no file is given, stdout is used. + #[arg(short, long)] + results_file: Option, + /// The format of the results. + /// + /// Can be an extension like "nt" or a MIME type like "application/n-triples". + /// + /// By default the format is guessed from the results file extension. + #[arg(long, required_unless_present = "results_file")] + results_format: Option, + }, + /// Executes a SPARQL update against the store. + Update { + /// Directory in which the data stored by Oxigraph are persisted. + #[arg(short, long)] + location: PathBuf, + /// The SPARQL update to execute. + /// + /// If no query or query file are given, stdin is used. + #[arg(short, long, conflicts_with = "update_file")] + update: Option, + /// File in which the update is stored. + /// + /// If no update or update file are given, stdin is used. + #[arg(long, conflicts_with = "update")] + update_file: Option, + /// Base URI of the update. + #[arg(long)] + update_base: Option, + }, } pub fn main() -> anyhow::Result<()> { @@ -367,6 +416,153 @@ pub fn main() -> anyhow::Result<()> { dump(&store, stdout().lock(), format, graph) } } + Command::Query { + location, + query, + query_file, + query_base, + results_file, + results_format, + } => { + let query = if let Some(query) = query { + query + } else if let Some(query_file) = query_file { + fs::read_to_string(&query_file).with_context(|| { + format!("Not able to read query file {}", query_file.display()) + })? + } else { + // TODO: use io::read_to_string + let mut query = String::new(); + stdin().lock().read_to_string(&mut query)?; + query + }; + let query = Query::parse(&query, query_base.as_deref())?; + let store = Store::open_read_only(location)?; + match store.query(query)? { + QueryResults::Solutions(solutions) => { + let format = if let Some(name) = results_format { + if let Some(format) = QueryResultsFormat::from_extension(&name) { + format + } else if let Some(format) = QueryResultsFormat::from_media_type(&name) { + format + } else { + bail!("The file format '{name}' is unknown") + } + } else if let Some(results_file) = &results_file { + format_from_path(results_file, |ext| { + QueryResultsFormat::from_extension(ext) + .ok_or_else(|| anyhow!("The file extension '{ext}' is unknown")) + })? + } else { + bail!("The --results-format option must be set when writing to stdout") + }; + if let Some(results_file) = results_file { + let mut writer = QueryResultsSerializer::from_format(format) + .solutions_writer( + BufWriter::new(File::create(results_file)?), + solutions.variables().to_vec(), + )?; + for solution in solutions { + writer.write(&solution?)?; + } + writer.finish()?; + } else { + let stdout = stdout(); // Not needed in Rust 1.61 + let mut writer = QueryResultsSerializer::from_format(format) + .solutions_writer(stdout.lock(), solutions.variables().to_vec())?; + for solution in solutions { + writer.write(&solution?)?; + } + let _ = writer.finish()?; + } + } + QueryResults::Boolean(result) => { + let format = if let Some(name) = results_format { + if let Some(format) = QueryResultsFormat::from_extension(&name) { + format + } else if let Some(format) = QueryResultsFormat::from_media_type(&name) { + format + } else { + bail!("The file format '{name}' is unknown") + } + } else if let Some(results_file) = &results_file { + format_from_path(results_file, |ext| { + QueryResultsFormat::from_extension(ext) + .ok_or_else(|| anyhow!("The file extension '{ext}' is unknown")) + })? + } else { + bail!("The --results-format option must be set when writing to stdout") + }; + if let Some(results_file) = results_file { + QueryResultsSerializer::from_format(format).write_boolean_result( + BufWriter::new(File::create(results_file)?), + result, + )?; + } else { + let _ = QueryResultsSerializer::from_format(format) + .write_boolean_result(stdout().lock(), result)?; + } + } + QueryResults::Graph(triples) => { + let format = if let Some(name) = results_format { + if let Some(format) = GraphFormat::from_extension(&name) { + format + } else if let Some(format) = GraphFormat::from_media_type(&name) { + format + } else { + bail!("The file format '{name}' is unknown") + } + } else if let Some(results_file) = &results_file { + format_from_path(results_file, |ext| { + GraphFormat::from_extension(ext) + .ok_or_else(|| anyhow!("The file extension '{ext}' is unknown")) + })? + } else { + bail!("The --results-format option must be set when writing to stdout") + }; + if let Some(results_file) = results_file { + let mut writer = GraphSerializer::from_format(format) + .triple_writer(BufWriter::new(File::create(results_file)?))?; + for triple in triples { + writer.write(triple?.as_ref())?; + } + writer.finish()?; + } else { + let stdout = stdout(); // Not needed in Rust 1.61 + let mut writer = + GraphSerializer::from_format(format).triple_writer(stdout.lock())?; + for triple in triples { + writer.write(triple?.as_ref())?; + } + writer.finish()?; + } + } + } + Ok(()) + } + Command::Update { + location, + update, + update_file, + update_base, + } => { + let update = if let Some(update) = update { + update + } else if let Some(update_file) = update_file { + fs::read_to_string(&update_file).with_context(|| { + format!("Not able to read update file {}", update_file.display()) + })? + } else { + // TODO: use io::read_to_string + let mut update = String::new(); + stdin().lock().read_to_string(&mut update)?; + update + }; + let update = Update::parse(&update, update_base.as_deref())?; + let store = Store::open(location)?; + store.update(update)?; + Ok(()) + } } } @@ -424,18 +620,7 @@ enum GraphOrDatasetFormat { impl GraphOrDatasetFormat { fn from_path(path: &Path) -> anyhow::Result { - if let Some(ext) = path.extension().and_then(|ext| ext.to_str()) { - Self::from_extension(ext).map_err(|e| { - e.context(format!( - "Not able to guess the file format from file name extension '{ext}'" - )) - }) - } else { - bail!( - "The path {} has no extension to guess a file format from", - path.display() - ) - } + format_from_path(path, Self::from_extension) } fn from_extension(name: &str) -> anyhow::Result { @@ -467,6 +652,24 @@ impl GraphOrDatasetFormat { } } +fn format_from_path( + path: &Path, + from_extension: impl FnOnce(&str) -> anyhow::Result, +) -> anyhow::Result { + if let Some(ext) = path.extension().and_then(|ext| ext.to_str()) { + from_extension(ext).map_err(|e| { + e.context(format!( + "Not able to guess the file format from file name extension '{ext}'" + )) + }) + } else { + bail!( + "The path {} has no extension to guess a file format from", + path.display() + ) + } +} + impl FromStr for GraphOrDatasetFormat { type Err = Error; @@ -1388,6 +1591,33 @@ mod tests { )) } + fn initialized_cli_store(data: &'static str) -> Result { + let store_dir = TempDir::new()?; + cli_command()? + .arg("load") + .arg("--location") + .arg(store_dir.path()) + .arg("--format") + .arg("trig") + .write_stdin(data) + .assert() + .success(); + Ok(store_dir) + } + + fn assert_cli_state(store_dir: TempDir, data: &'static str) -> Result<()> { + cli_command()? + .arg("dump") + .arg("--location") + .arg(store_dir.path()) + .arg("--format") + .arg("nq") + .assert() + .stdout(data) + .success(); + Ok(()) + } + #[test] fn cli_help() -> Result<()> { cli_command()? @@ -1583,16 +1813,9 @@ mod tests { #[test] fn cli_backup() -> Result<()> { - let store_dir = TempDir::new()?; - cli_command()? - .arg("load") - .arg("--location") - .arg(store_dir.path()) - .arg("--format") - .arg("nq") - .write_stdin(" .") - .assert() - .success(); + let store_dir = initialized_cli_store( + " .", + )?; let backup_dir = TempDir::new()?; remove_dir_all(backup_dir.path())?; // The directory should not exist yet @@ -1605,18 +1828,133 @@ mod tests { .assert() .success(); + assert_cli_state( + store_dir, + " .\n", + ) + } + + #[test] + fn cli_ask_query_inline() -> Result<()> { + let store_dir = initialized_cli_store( + " .", + )?; cli_command()? - .arg("dump") + .arg("query") .arg("--location") - .arg(backup_dir.path()) - .arg("--format") - .arg("nq") + .arg(store_dir.path()) + .arg("--query") + .arg("ASK {

}") + .arg("--query-base") + .arg("http://example.com/") + .arg("--results-format") + .arg("csv") .assert() - .success() - .stdout(" .\n"); + .stdout("true") + .success(); + Ok(()) + } + + #[test] + fn cli_construct_query_stdin() -> Result<()> { + let store_dir = initialized_cli_store( + " .", + )?; + cli_command()? + .arg("query") + .arg("--location") + .arg(store_dir.path()) + .arg("--query-base") + .arg("http://example.com/") + .arg("--results-format") + .arg("nt") + .write_stdin("CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }") + .assert() + .stdout(" .\n") + .success(); Ok(()) } + #[test] + fn cli_select_query_file() -> Result<()> { + let store_dir = initialized_cli_store( + " .", + )?; + let input_file = NamedTempFile::new("input.rq")?; + input_file.write_str("SELECT ?s WHERE { ?s ?p ?o }")?; + let output_file = NamedTempFile::new("output.tsv")?; + cli_command()? + .arg("query") + .arg("--location") + .arg(store_dir.path()) + .arg("--query-file") + .arg(input_file.path()) + .arg("--results-file") + .arg(output_file.path()) + .assert() + .success(); + output_file.assert("?s\n\n"); + Ok(()) + } + + #[test] + fn cli_ask_update_inline() -> Result<()> { + let store_dir = TempDir::new()?; + cli_command()? + .arg("update") + .arg("--location") + .arg(store_dir.path()) + .arg("--update") + .arg("INSERT DATA {

}") + .arg("--update-base") + .arg("http://example.com/") + .assert() + .success(); + assert_cli_state( + store_dir, + " .\n", + ) + } + + #[test] + fn cli_construct_update_stdin() -> Result<()> { + let store_dir = TempDir::new()?; + cli_command()? + .arg("update") + .arg("--location") + .arg(store_dir.path()) + .arg("--update-base") + .arg("http://example.com/") + .write_stdin("INSERT DATA {

}") + .assert() + .success(); + assert_cli_state( + store_dir, + " .\n", + ) + } + + #[test] + fn cli_update_file() -> Result<()> { + let store_dir = TempDir::new()?; + let input_file = NamedTempFile::new("input.rq")?; + input_file.write_str( + "INSERT DATA { }", + )?; + cli_command()? + .arg("update") + .arg("--location") + .arg(store_dir.path()) + .arg("--update-file") + .arg(input_file.path()) + .assert() + .success(); + assert_cli_state( + store_dir, + " .\n", + ) + } + #[test] fn get_ui() -> Result<()> { ServerTest::new()?.test_status(