faucet_common_delta/
connection.rs1use std::collections::HashMap;
6
7use faucet_core::FaucetError;
8use schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10
11use crate::credentials::DeltaCredentials;
12
13#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
17pub struct DeltaConnection {
18 pub table_uri: String,
22
23 #[serde(default)]
25 pub credentials: DeltaCredentials,
26
27 #[serde(default)]
32 pub storage_options: HashMap<String, String>,
33}
34
35impl DeltaConnection {
36 pub fn merged_storage_options(&self) -> HashMap<String, String> {
39 let mut opts = self.storage_options.clone();
40 self.credentials.apply(&mut opts);
41 opts
42 }
43
44 pub fn redacted_uri(&self) -> String {
47 faucet_core::util::redact_uri_credentials(&self.table_uri)
48 }
49
50 pub fn register_handlers(&self) {
57 register_all_handlers();
58 }
59
60 fn table_url(&self) -> Result<url::Url, FaucetError> {
63 deltalake::ensure_table_uri(&self.table_uri).map_err(|e| {
64 FaucetError::Config(format!(
65 "delta: invalid table_uri '{}': {e}",
66 self.redacted_uri()
67 ))
68 })
69 }
70
71 pub fn location_string(&self) -> Result<String, FaucetError> {
74 Ok(self.table_url()?.to_string())
75 }
76
77 pub async fn open(&self) -> Result<deltalake::DeltaTable, FaucetError> {
79 self.register_handlers();
80 let url = self.table_url()?;
81 deltalake::open_table_with_storage_options(url, self.merged_storage_options())
82 .await
83 .map_err(|e| self.map_open_error(e))
84 }
85
86 pub async fn open_optional(&self) -> Result<Option<deltalake::DeltaTable>, FaucetError> {
90 self.register_handlers();
91 let url = self.table_url()?;
92 match deltalake::open_table_with_storage_options(url, self.merged_storage_options()).await {
93 Ok(t) => Ok(Some(t)),
94 Err(e) if is_missing_table(&e) => Ok(None),
95 Err(e) => Err(self.map_open_error(e)),
96 }
97 }
98
99 pub async fn open_at_version(
101 &self,
102 version: u64,
103 ) -> Result<deltalake::DeltaTable, FaucetError> {
104 let mut table = self.open().await?;
105 table
106 .load_version(version)
107 .await
108 .map_err(|e| self.map_open_error(e))?;
109 Ok(table)
110 }
111
112 pub async fn open_at_timestamp(
114 &self,
115 timestamp: &str,
116 ) -> Result<deltalake::DeltaTable, FaucetError> {
117 let ts = chrono::DateTime::parse_from_rfc3339(timestamp)
118 .map_err(|e| {
119 FaucetError::Config(format!(
120 "delta: invalid `timestamp` '{timestamp}' (expected RFC 3339): {e}"
121 ))
122 })?
123 .with_timezone(&chrono::Utc);
124 let mut table = self.open().await?;
125 table
126 .load_with_datetime(ts)
127 .await
128 .map_err(|e| self.map_open_error(e))?;
129 Ok(table)
130 }
131
132 fn map_open_error(&self, e: deltalake::DeltaTableError) -> FaucetError {
133 FaucetError::Source(format!(
134 "delta: failed to open table '{}': {e}",
135 self.redacted_uri()
136 ))
137 }
138}
139
140fn is_missing_table(e: &deltalake::DeltaTableError) -> bool {
143 if matches!(e, deltalake::DeltaTableError::NotATable(_)) {
144 return true;
145 }
146 let msg = e.to_string().to_lowercase();
150 msg.contains("not found") || msg.contains("no such file") || msg.contains("does not exist")
151}
152
153fn register_all_handlers() {
158 #[cfg(feature = "s3")]
159 {
160 use std::sync::Once;
161 static S3: Once = Once::new();
162 S3.call_once(|| deltalake::aws::register_handlers(None));
163 }
164 #[cfg(feature = "azure")]
165 {
166 use std::sync::Once;
167 static AZURE: Once = Once::new();
168 AZURE.call_once(|| deltalake::azure::register_handlers(None));
169 }
170 #[cfg(feature = "gcs")]
171 {
172 use std::sync::Once;
173 static GCS: Once = Once::new();
174 GCS.call_once(|| deltalake::gcp::register_handlers(None));
175 }
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181 use serde_json::json;
182
183 fn conn(uri: &str) -> DeltaConnection {
184 DeltaConnection {
185 table_uri: uri.into(),
186 credentials: DeltaCredentials::default(),
187 storage_options: HashMap::new(),
188 }
189 }
190
191 #[test]
192 fn flatten_shape_round_trips() {
193 let v = json!({
194 "table_uri": "s3://bucket/tbl",
195 "credentials": { "type": "aws", "config": { "region": "us-east-1" } },
196 "storage_options": { "AWS_ALLOW_HTTP": "true" }
197 });
198 let c: DeltaConnection = serde_json::from_value(v).unwrap();
199 assert_eq!(c.table_uri, "s3://bucket/tbl");
200 let opts = c.merged_storage_options();
201 assert_eq!(opts["AWS_REGION"], "us-east-1");
202 assert_eq!(opts["AWS_ALLOW_HTTP"], "true");
203 }
204
205 #[test]
206 fn explicit_storage_option_overrides_credential() {
207 let v = json!({
208 "table_uri": "s3://bucket/tbl",
209 "credentials": { "type": "aws", "config": { "region": "us-east-1" } },
210 "storage_options": { "AWS_REGION": "eu-west-1" }
211 });
212 let c: DeltaConnection = serde_json::from_value(v).unwrap();
213 assert_eq!(c.merged_storage_options()["AWS_REGION"], "eu-west-1");
214 }
215
216 #[test]
217 fn defaults_omit_credentials_and_options() {
218 let c: DeltaConnection =
219 serde_json::from_value(json!({ "table_uri": "file:///tmp/t" })).unwrap();
220 assert_eq!(c.credentials, DeltaCredentials::Default);
221 assert!(c.merged_storage_options().is_empty());
222 }
223
224 #[test]
225 fn redacted_uri_passes_plain_uri_through() {
226 assert_eq!(conn("s3://bucket/tbl").redacted_uri(), "s3://bucket/tbl");
227 }
228
229 #[test]
230 fn register_handlers_is_idempotent() {
231 let c = conn("file:///tmp/t");
234 c.register_handlers();
235 c.register_handlers();
236 }
237}