Skip to main content

hinge/infrastructure/snowflake/
executor.rs

1use percent_encoding::percent_decode_str;
2use snowflake_api::SnowflakeApi;
3use url::Url;
4
5// ── Error ─────────────────────────────────────────────────────────────────────
6
7#[derive(Debug, thiserror::Error)]
8pub enum SnowflakeConnectionError {
9    #[error("invalid Snowflake URL: {0}")]
10    Parse(#[from] url::ParseError),
11    #[error("invalid Snowflake URL: {0}")]
12    Config(String),
13    #[error("Snowflake client error: {0}")]
14    Client(String),
15}
16
17// ── Executor ──────────────────────────────────────────────────────────────────
18
19/// Executes SQL assets against a Snowflake data warehouse.
20///
21/// See [`SnowflakeExecutor::from_url`] for connection URL format details.
22/// No network call is made until the first [`Executor::run`] call.
23///
24/// [`Executor::run`]: crate::Executor::run
25pub struct SnowflakeExecutor {
26    api: SnowflakeApi,
27}
28
29impl SnowflakeExecutor {
30    /// Build an executor from a connection URL.
31    ///
32    /// Format:
33    /// ```text
34    /// snowflake://user:password@account-identifier/database/schema?warehouse=WH&role=ROLE
35    /// ```
36    ///
37    /// - `account-identifier` — Snowflake account in `org-account` or legacy
38    ///   `locator.region.provider` form (no `.snowflakecomputing.com` suffix).
39    /// - `database` and `schema` path segments are optional.
40    /// - `warehouse` and `role` query parameters are optional.
41    /// - Special characters in `user` or `password` must be percent-encoded
42    ///   (e.g. `@` → `%40`).
43    ///
44    /// No network call is made here; authentication is deferred to the first
45    /// [`Executor::run`] call.
46    pub fn from_url(raw: &str) -> Result<Self, SnowflakeConnectionError> {
47        let parsed = Url::parse(raw)?;
48
49        let account = parsed
50            .host_str()
51            .ok_or_else(|| SnowflakeConnectionError::Config(
52                "missing account identifier (host part of URL)".into(),
53            ))?
54            .to_owned();
55
56        let username = {
57            let u = parsed.username();
58            if u.is_empty() {
59                return Err(SnowflakeConnectionError::Config("missing username".into()));
60            }
61            decode(u)
62        };
63
64        let password = decode(
65            parsed
66                .password()
67                .ok_or_else(|| SnowflakeConnectionError::Config("missing password".into()))?,
68        );
69
70        let segments: Vec<String> = parsed
71            .path_segments()
72            .into_iter()
73            .flatten()
74            .filter(|s| !s.is_empty())
75            .map(decode)
76            .collect();
77
78        let database = segments.first().map(String::as_str);
79        let schema   = segments.get(1).map(String::as_str);
80
81        let mut warehouse = None;
82        let mut role      = None;
83        for (k, v) in parsed.query_pairs() {
84            match k.as_ref() {
85                "warehouse" => warehouse = Some(v.into_owned()),
86                "role"      => role      = Some(v.into_owned()),
87                _           => {}
88            }
89        }
90
91        SnowflakeApi::with_password_auth(
92            &account,
93            warehouse.as_deref(),
94            database,
95            schema,
96            &username,
97            role.as_deref(),
98            &password,
99        )
100        .map(|api| Self { api })
101        .map_err(|e| SnowflakeConnectionError::Client(e.to_string()))
102    }
103
104    pub(super) fn api(&self) -> &SnowflakeApi {
105        &self.api
106    }
107}
108
109fn decode(s: &str) -> String {
110    percent_decode_str(s).decode_utf8_lossy().into_owned()
111}