Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 13 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,18 +123,19 @@ Query databases via ADBC
Usage: databow [OPTIONS]

Options:
--profile <profile> Connection profile name or path
--driver <driver> Driver name (inferred from --uri scheme or profile if not specified)
--uri <uri> Database uniform resource identifier
--username <username> Database user username
--password <password> Database user password
--option <option> Driver-specific database option
--mode <mode> Table display style [default: utf8-compact]
--query <query> Execute query and exit
--file <file> Read and execute file and exit
--output <file> Write result to file
-h, --help Print help
-V, --version Print version
--profile <profile> Connection profile name or path
--driver <driver> Driver name (inferred from --uri scheme or profile if not specified)
--uri <uri> Database uniform resource identifier
--username <username> Database user username
--password <password> Database user password
--option <key=value> Driver-specific database option
--statement-option <key=value> Driver-specific statement option
--mode <mode> Table display style [default: utf8-compact]
--query <query> Execute query and exit
--file <file> Read and execute file and exit
--output <file> Write result to file
-h, --help Print help
-V, --version Print version
```

## License
Expand Down
8 changes: 8 additions & 0 deletions docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,14 @@ Driver-specific database option
databow --driver duckdb --option path=file.db
```

### --statement-option

Driver-specific statement option, applied to every statement executed

```sh
databow --driver postgresql --uri postgresql://crate@localhost:5432/crate --statement-option adbc.postgresql.use_copy=false
```

### --mode

Table display style
Expand Down
41 changes: 29 additions & 12 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ pub enum ConnectionSource {

pub struct AppConfig {
pub connection: ConnectionSource,
pub statement_options: Vec<(String, String)>,
pub query_source: QuerySource,
pub table_mode: TableMode,
pub output_path: Option<PathBuf>,
Expand Down Expand Up @@ -65,8 +66,14 @@ pub fn parse_args() -> AppConfig {
.help("Database user password"),
Arg::new("option")
.long("option")
.value_name("key=value")
.help("Driver-specific database option")
.action(ArgAction::Append),
Arg::new("statement-option")
.long("statement-option")
.value_name("key=value")
.help("Driver-specific statement option")
.action(ArgAction::Append),
Arg::new("mode")
.long("mode")
.help("Table display style")
Expand Down Expand Up @@ -101,18 +108,8 @@ pub fn parse_args() -> AppConfig {
let username = matches.get_one::<String>("username").cloned();
let password = matches.get_one::<String>("password").cloned();

let mut options = Vec::new();
if let Some(option_values) = matches.get_many::<String>("option") {
for option in option_values {
match parse_option(option) {
Ok((key, value)) => options.push((key, value)),
Err(err) => {
eprintln!("{err}");
exit(1);
}
}
}
}
let options = collect_options(&matches, "option");
let statement_options = collect_options(&matches, "statement-option");

// Build ConnectionSource based on whether --profile or --driver was provided
let connection = if let Some(profile) = profile {
Expand Down Expand Up @@ -173,12 +170,29 @@ pub fn parse_args() -> AppConfig {

AppConfig {
connection,
statement_options,
query_source,
table_mode,
output_path,
}
}

/// Parse all `key=value` values supplied for the argument `id`, exiting with an
/// error message if any value is malformed.
fn collect_options(matches: &clap::ArgMatches, id: &str) -> Vec<(String, String)> {
let Some(values) = matches.get_many::<String>(id) else {
return Vec::new();
};
values
.map(|option| {
parse_option(option).unwrap_or_else(|err| {
eprintln!("{err}");
exit(1);
})
})
.collect()
}

fn parse_output_path(s: &str) -> Result<PathBuf, String> {
let path = PathBuf::from(s);
OutputFormat::from_path(&path)?;
Expand Down Expand Up @@ -357,6 +371,7 @@ mod tests {
password: Some("test_pass".to_string()),
options: vec![("key1".to_string(), "val1".to_string())],
},
statement_options: vec![],
query_source: QuerySource::Interactive,
table_mode: TableMode::default(),
output_path: None,
Expand Down Expand Up @@ -391,6 +406,7 @@ mod tests {
password: None,
options: vec![],
},
statement_options: vec![],
query_source: QuerySource::Interactive,
table_mode: TableMode::AsciiMarkdown,
output_path: None,
Expand All @@ -415,6 +431,7 @@ mod tests {
password: None,
options: vec![("extra".to_string(), "option".to_string())],
},
statement_options: vec![],
query_source: QuerySource::Query("SELECT 1".to_string()),
table_mode: TableMode::default(),
output_path: Some(PathBuf::from("output.json")),
Expand Down
48 changes: 46 additions & 2 deletions src/database.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
// SPDX-License-Identifier: Apache-2.0

use crate::cli::ConnectionSource;
use adbc_core::options::{AdbcVersion, InfoCode, OptionDatabase, OptionValue};
use adbc_core::{Connection, Database, Driver, LOAD_FLAG_DEFAULT, Statement};
use adbc_core::options::{AdbcVersion, InfoCode, OptionDatabase, OptionStatement, OptionValue};
use adbc_core::{Connection, Database, Driver, LOAD_FLAG_DEFAULT, Optionable, Statement};
use adbc_driver_manager::profile::{
ConnectionProfile, ConnectionProfileProvider, FilesystemProfileProvider, process_profile_value,
};
Expand Down Expand Up @@ -293,6 +293,22 @@ fn build_database_options(
db_options
}

/// Convert CLI `key=value` pairs into ADBC statement options. Standard ADBC
/// keys map to their dedicated variants; all other keys are driver-specific.
pub fn build_statement_options(
options: Vec<(String, String)>,
) -> Vec<(OptionStatement, OptionValue)> {
options
.into_iter()
.map(|(key, value)| {
(
OptionStatement::from(key.as_str()),
OptionValue::String(value),
)
})
.collect()
}

/// Merge two sets of database options. Options from `overrides` take precedence.
fn merge_options(
base: Vec<(OptionDatabase, OptionValue)>,
Expand All @@ -315,6 +331,7 @@ pub struct QueryResult {
pub fn execute_query(
connection: &mut impl adbc_core::Connection,
sql: &str,
statement_options: &[(OptionStatement, OptionValue)],
) -> Result<QueryResult, String> {
if sql.trim().is_empty() {
return Ok(QueryResult {
Expand All @@ -327,6 +344,12 @@ pub fn execute_query(
.new_statement()
.map_err(|e| format!("Failed to create statement: {e}"))?;

for (key, value) in statement_options {
statement
.set_option(key.clone(), value.clone())
.map_err(|e| format!("Failed to set statement option '{}': {e}", key.as_ref()))?;
}

statement
.set_sql_query(sql)
.map_err(|e| format!("Failed to set SQL query: {e}"))?;
Expand Down Expand Up @@ -477,6 +500,27 @@ mod tests {
assert!(matches!(&options[1].0, OptionDatabase::Other(k) if k == "another"));
}

#[test]
fn test_build_statement_options_empty() {
assert!(build_statement_options(vec![]).is_empty());
}

#[test]
fn test_build_statement_options_standard_and_custom() {
let options = build_statement_options(vec![
(
"adbc.ingest.mode".to_string(),
"adbc.ingest.mode.append".to_string(),
),
("custom_key".to_string(), "custom_value".to_string()),
]);
assert_eq!(options.len(), 2);
assert!(matches!(options[0].0, OptionStatement::IngestMode));
assert!(matches!(&options[0].1, OptionValue::String(s) if s == "adbc.ingest.mode.append"));
assert!(matches!(&options[1].0, OptionStatement::Other(k) if k == "custom_key"));
assert!(matches!(&options[1].1, OptionValue::String(s) if s == "custom_value"));
}

#[test]
fn test_merge_options_empty_base() {
let base = vec![];
Expand Down
18 changes: 13 additions & 5 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,24 @@ use std::process::exit;
fn main() {
let AppConfig {
connection,
statement_options,
query_source,
table_mode,
output_path,
} = parse_args();
let statement_options = database::build_statement_options(statement_options);

if matches!(query_source, QuerySource::Interactive) {
let connection = database::initialize_connection(connection).unwrap_or_else(|e| {
eprintln!("{e}");
exit(1);
});
repl::run_repl(connection, table_mode, table::TruncationOptions::default());
repl::run_repl(
connection,
table_mode,
table::TruncationOptions::default(),
&statement_options,
);
return;
}

Expand Down Expand Up @@ -62,10 +69,11 @@ fn main() {
eprintln!("{e}");
exit(1);
});
let result = database::execute_query(&mut connection, &sql).unwrap_or_else(|e| {
eprintln!("{e}");
exit(1);
});
let result =
database::execute_query(&mut connection, &sql, &statement_options).unwrap_or_else(|e| {
eprintln!("{e}");
exit(1);
});
if let Err(e) = output_results(&result, table_mode, output_path.as_deref()) {
eprintln!("{e}");
exit(1);
Expand Down
5 changes: 4 additions & 1 deletion src/repl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use crate::highlighter::SyntectHighlighter;
use crate::pager::PagerSettings;
use crate::table::{RenderOptions, TableMode, TruncationOptions};
use adbc_core::Connection;
use adbc_core::options::{OptionStatement, OptionValue};
use reedline::{
Prompt, PromptEditMode, PromptHistorySearch, Reedline, Signal, ValidationResult, Validator,
};
Expand Down Expand Up @@ -74,6 +75,7 @@ pub fn run_repl(
mut connection: impl Connection,
table_mode: TableMode,
truncation: TruncationOptions,
statement_options: &[(OptionStatement, OptionValue)],
) {
let vendor = database::get_vendor_info(&connection);
println!("{}", format_banner(env!("CARGO_PKG_VERSION"), &vendor));
Expand Down Expand Up @@ -113,7 +115,8 @@ pub fn run_repl(
// Free the previous result before running the next query so
// two full results are never held at once.
state.last_result = None;
let batches = match database::execute_query(&mut connection, sql) {
let batches = match database::execute_query(&mut connection, sql, statement_options)
{
Ok(result) => result.batches,
Err(err) => {
eprintln!("{err}");
Expand Down
45 changes: 45 additions & 0 deletions tests/integration_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,51 @@ fn test_no_driver_no_scheme_uri_errors() {
assert!(stderr.contains("--driver"));
}

#[test]
fn test_statement_option_rejected_by_driver() {
let output = Command::new("cargo")
.args([
"run",
"--",
"--driver",
"duckdb",
"--statement-option",
"bogus=1",
"--query",
"SELECT 1",
])
.output()
.expect("Failed to execute command");

assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("Failed to set statement option 'bogus'"),
"stderr: {stderr}"
);
}

#[test]
fn test_statement_option_invalid_format() {
let output = Command::new("cargo")
.args([
"run",
"--",
"--driver",
"duckdb",
"--statement-option",
"novalue",
"--query",
"SELECT 1",
])
.output()
.expect("Failed to execute command");

assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("Invalid option format"), "stderr: {stderr}");
}

#[test]
fn test_file_argument() {
// Create a temporary SQL file
Expand Down
Loading