hinge 0.1.0

SQL-native ELT engine — dependency graph resolved automatically from FROM/JOIN clauses, parallel execution, single binary
Documentation
use percent_encoding::percent_decode_str;
use snowflake_api::SnowflakeApi;
use url::Url;

// ── Error ─────────────────────────────────────────────────────────────────────

#[derive(Debug, thiserror::Error)]
pub enum SnowflakeConnectionError {
    #[error("invalid Snowflake URL: {0}")]
    Parse(#[from] url::ParseError),
    #[error("invalid Snowflake URL: {0}")]
    Config(String),
    #[error("Snowflake client error: {0}")]
    Client(String),
}

// ── Executor ──────────────────────────────────────────────────────────────────

/// Executes SQL assets against a Snowflake data warehouse.
///
/// See [`SnowflakeExecutor::from_url`] for connection URL format details.
/// No network call is made until the first [`Executor::run`] call.
///
/// [`Executor::run`]: crate::Executor::run
pub struct SnowflakeExecutor {
    api: SnowflakeApi,
}

impl SnowflakeExecutor {
    /// Build an executor from a connection URL.
    ///
    /// Format:
    /// ```text
    /// snowflake://user:password@account-identifier/database/schema?warehouse=WH&role=ROLE
    /// ```
    ///
    /// - `account-identifier` — Snowflake account in `org-account` or legacy
    ///   `locator.region.provider` form (no `.snowflakecomputing.com` suffix).
    /// - `database` and `schema` path segments are optional.
    /// - `warehouse` and `role` query parameters are optional.
    /// - Special characters in `user` or `password` must be percent-encoded
    ///   (e.g. `@` → `%40`).
    ///
    /// No network call is made here; authentication is deferred to the first
    /// [`Executor::run`] call.
    pub fn from_url(raw: &str) -> Result<Self, SnowflakeConnectionError> {
        let parsed = Url::parse(raw)?;

        let account = parsed
            .host_str()
            .ok_or_else(|| SnowflakeConnectionError::Config(
                "missing account identifier (host part of URL)".into(),
            ))?
            .to_owned();

        let username = {
            let u = parsed.username();
            if u.is_empty() {
                return Err(SnowflakeConnectionError::Config("missing username".into()));
            }
            decode(u)
        };

        let password = decode(
            parsed
                .password()
                .ok_or_else(|| SnowflakeConnectionError::Config("missing password".into()))?,
        );

        let segments: Vec<String> = parsed
            .path_segments()
            .into_iter()
            .flatten()
            .filter(|s| !s.is_empty())
            .map(decode)
            .collect();

        let database = segments.first().map(String::as_str);
        let schema   = segments.get(1).map(String::as_str);

        let mut warehouse = None;
        let mut role      = None;
        for (k, v) in parsed.query_pairs() {
            match k.as_ref() {
                "warehouse" => warehouse = Some(v.into_owned()),
                "role"      => role      = Some(v.into_owned()),
                _           => {}
            }
        }

        SnowflakeApi::with_password_auth(
            &account,
            warehouse.as_deref(),
            database,
            schema,
            &username,
            role.as_deref(),
            &password,
        )
        .map(|api| Self { api })
        .map_err(|e| SnowflakeConnectionError::Client(e.to_string()))
    }

    pub(super) fn api(&self) -> &SnowflakeApi {
        &self.api
    }
}

fn decode(s: &str) -> String {
    percent_decode_str(s).decode_utf8_lossy().into_owned()
}