// ============================================================================
// Generic extension primitives (issue #67)
//
// Extension-agnostic building blocks for DuckDB extensions: INSTALL, LOAD,
// and ATTACH. Extension-specific convenience helpers (Quack, Iceberg, ...)
// live in their own packages (e.g. `f4ah6o/duckdb/quack`) and compose these
// primitives instead of growing the `Connection` surface.
// ============================================================================
///|
/// Escape `value` as a single-quoted SQL string literal.
fn sql_string_literal(value : String) -> String {
let sb = StringBuilder()
ignore(sb.write_char('\''))
for c in value {
if c == '\'' {
ignore(sb..write_char('\'').write_char('\''))
} else {
ignore(sb.write_char(c))
}
}
ignore(sb.write_char('\''))
sb.to_string()
}
///|
/// Whether `c` is valid in a bare SQL identifier (letters, digits, underscore).
fn is_sql_identifier_char(c : Char) -> Bool {
(c >= 'a' && c <= 'z') ||
(c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') ||
c == '_'
}
///|
/// Whether `c` may start a bare SQL identifier (letter or underscore —
/// leading digits are only legal inside a quoted identifier).
fn is_sql_identifier_start_char(c : Char) -> Bool {
(c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_'
}
///|
/// Validate `value` as a non-empty SQL identifier made of
/// `is_sql_identifier_char` characters, returning it verbatim on success.
/// Usable for double-quoted contexts (e.g. ATTACH aliases); bare
/// identifiers additionally need `bare_sql_identifier`.
fn sql_identifier(
value : String,
argument~ : String,
) -> Result[String, DuckDBError] {
if value.length() == 0 {
Err(DuckDBError::InvalidArgument(argument~, reason="must not be empty"))
} else {
for c in value {
if !is_sql_identifier_char(c) {
return Err(
DuckDBError::InvalidArgument(
argument~,
reason="may only contain letters, digits, and underscores",
),
)
}
}
Ok(value)
}
}
///|
/// Validate `value` as a bare SQL identifier rendered unquoted (extension
/// names, repositories): `sql_identifier` plus a letter/underscore first
/// character, since a leading digit is not valid bare.
fn bare_sql_identifier(
value : String,
argument~ : String,
) -> Result[String, DuckDBError] {
match sql_identifier(value, argument~) {
Err(err) => Err(err)
Ok(value) => {
let mut start_ok = false
for c in value {
start_ok = is_sql_identifier_start_char(c)
break
}
if start_ok {
Ok(value)
} else {
Err(
DuckDBError::InvalidArgument(
argument~,
reason="must start with a letter or underscore",
),
)
}
}
}
}
///|
/// Adapt a `QueryResult` callback into a `Unit` callback, discarding rows.
fn unit_done(
result : Result[QueryResult, DuckDBError],
on_done : (Result[Unit, DuckDBError]) -> Unit,
) -> Unit {
match result {
Ok(_) => on_done(Ok(()))
Err(err) => on_done(Err(err))
}
}
///|
/// SQL for `INSTALL name` / `FORCE INSTALL name [FROM repository]`.
fn install_extension_sql(
name : String,
repository : String,
force : Bool,
) -> Result[String, DuckDBError] {
match bare_sql_identifier(name, argument="name") {
Err(err) => Err(err)
Ok(name) =>
if repository == "" {
Ok((if force { "FORCE " } else { "" }) + "INSTALL " + name)
} else {
match bare_sql_identifier(repository, argument="repository") {
Err(err) => Err(err)
Ok(repository) =>
Ok(
(if force { "FORCE " } else { "" }) +
"INSTALL " +
name +
" FROM " +
repository,
)
}
}
}
}
///|
/// SQL for `ATTACH 'uri' [AS "alias"] [(options)]`.
fn attach_sql(
uri : String,
db_alias : String,
options : String,
) -> Result[String, DuckDBError] {
let sb = StringBuilder()
ignore(sb..write_view("ATTACH ").write_view(sql_string_literal(uri)))
if db_alias != "" {
match sql_identifier(db_alias, argument="alias") {
Ok(db_alias) =>
ignore(sb..write_view(" AS \"")..write_view(db_alias).write_char('"'))
Err(err) => return Err(err)
}
}
if options != "" {
ignore(sb..write_view(" (")..write_view(options).write_char(')'))
}
Ok(sb.to_string())
}
///|
/// Install a DuckDB extension by `name` (`INSTALL` / `FORCE INSTALL`).
///
/// `repository` optionally names the extension repository to install from
/// (for example `core_nightly`); `force` re-installs even when the extension
/// is already installed. Names are validated as plain identifiers — anything
/// else returns `InvalidArgument` without touching the database.
pub fn Connection::install_extension(
self : Connection,
name : String,
repository? : String = "",
force? : Bool = false,
on_done~ : (Result[Unit, DuckDBError]) -> Unit,
) -> Unit {
match install_extension_sql(name, repository, force) {
Ok(sql) =>
self.query(sql, on_done=fn(result) { unit_done(result, on_done) })
Err(err) => on_done(Err(err))
}
}
///|
/// SQL for `LOAD name`.
fn load_extension_sql(name : String) -> Result[String, DuckDBError] {
match bare_sql_identifier(name, argument="name") {
Ok(name) => Ok("LOAD " + name)
Err(err) => Err(err)
}
}
///|
/// Load an installed DuckDB extension into this connection (`LOAD name`).
pub fn Connection::load_extension(
self : Connection,
name : String,
on_done~ : (Result[Unit, DuckDBError]) -> Unit,
) -> Unit {
match load_extension_sql(name) {
Ok(sql) =>
self.query(sql, on_done=fn(result) { unit_done(result, on_done) })
Err(err) => on_done(Err(err))
}
}
///|
/// Attach a database file or endpoint to this connection (`ATTACH 'uri'`).
///
/// `db_alias` optionally names the attached catalog; it is validated as a
/// plain identifier and rendered double-quoted. `options` is rendered
/// verbatim inside the trailing parenthesized clause — the escape hatch for
/// extension-specific attach options (for example
/// `TYPE quack, TOKEN 'secret'`). Callers building `options` from user input
/// must validate and escape values themselves.
pub fn Connection::attach(
self : Connection,
uri : String,
db_alias? : String = "",
options? : String = "",
on_done~ : (Result[Unit, DuckDBError]) -> Unit,
) -> Unit {
match attach_sql(uri, db_alias, options) {
Ok(sql) =>
self.query(sql, on_done=fn(result) { unit_done(result, on_done) })
Err(err) => on_done(Err(err))
}
}