laminar_connectors/lakehouse/delta_source_config/
mod.rs1#![allow(clippy::disallowed_types)] use std::collections::HashMap;
6use std::time::Duration;
7
8use crate::config::ConnectorConfig;
9use crate::error::ConnectorError;
10use crate::storage::{CloudConfigValidator, SecretMasker, StorageCredentialResolver};
11
12use super::delta_config::DeltaCatalogType;
13
14#[derive(Debug, Clone)]
18pub struct DeltaSourceConfig {
19 pub table_path: String,
21
22 pub read_mode: DeltaReadMode,
24
25 pub starting_version: DeltaStartingVersion,
27
28 pub poll_interval: Duration,
30
31 pub storage_options: HashMap<String, String>,
33
34 env_resolved_storage_keys: Vec<String>,
36
37 pub catalog_type: DeltaCatalogType,
39
40 pub catalog_database: Option<String>,
42
43 pub catalog_name: Option<String>,
45
46 pub catalog_schema: Option<String>,
48}
49
50impl Default for DeltaSourceConfig {
51 fn default() -> Self {
52 Self {
53 table_path: String::new(),
54 read_mode: DeltaReadMode::Changes,
55 starting_version: DeltaStartingVersion::Snapshot,
56 poll_interval: Duration::from_secs(1),
57 storage_options: HashMap::new(),
58 env_resolved_storage_keys: Vec::new(),
59 catalog_type: DeltaCatalogType::None,
60 catalog_database: None,
61 catalog_name: None,
62 catalog_schema: None,
63 }
64 }
65}
66
67impl DeltaSourceConfig {
68 #[must_use]
70 pub fn new(table_path: &str) -> Self {
71 Self {
72 table_path: table_path.to_string(),
73 ..Default::default()
74 }
75 }
76
77 pub fn from_config(config: &ConnectorConfig) -> Result<Self, ConnectorError> {
88 let mut cfg = Self {
89 table_path: config.require("table.path")?.to_string(),
90 ..Self::default()
91 };
92
93 if let Some(v) = config.get("read.mode") {
94 cfg.read_mode = match v.to_ascii_lowercase().as_str() {
95 "changes" => DeltaReadMode::Changes,
96 "snapshot" => DeltaReadMode::Snapshot,
97 _ => {
98 return Err(ConnectorError::ConfigurationError(format!(
99 "invalid read.mode: '{v}' (expected 'changes' or 'snapshot')"
100 )))
101 }
102 };
103 }
104 if let Some(v) = config.get("starting.version") {
105 if cfg.read_mode == DeltaReadMode::Snapshot {
106 return Err(ConnectorError::ConfigurationError(
107 "starting.version applies only to read.mode=changes; read.mode=snapshot \
108 reads the latest version"
109 .into(),
110 ));
111 }
112 cfg.starting_version = if v.eq_ignore_ascii_case("latest") {
113 DeltaStartingVersion::Latest
114 } else {
115 DeltaStartingVersion::Version(v.parse().map_err(|_| {
116 ConnectorError::ConfigurationError(format!(
117 "invalid starting.version: '{v}' (expected a non-negative version or \
118 'latest')"
119 ))
120 })?)
121 };
122 }
123 if let Some(v) = config.get("poll.interval.ms") {
124 let ms: u64 = v.parse().map_err(|_| {
125 ConnectorError::ConfigurationError(format!("invalid poll.interval.ms: '{v}'"))
126 })?;
127 cfg.poll_interval = Duration::from_millis(ms);
128 }
129 if config.get("cdf.enabled").is_some() {
130 return Err(ConnectorError::ConfigurationError(
131 "Delta source does not support cdf.enabled".into(),
132 ));
133 }
134 if config.get("partition.filter").is_some() {
135 return Err(ConnectorError::ConfigurationError(
136 "Delta source does not support partition.filter".into(),
137 ));
138 }
139 if config.get("schema.evolution.action").is_some() {
140 return Err(ConnectorError::ConfigurationError(
141 "Delta CDF source always fails on schema evolution; schema.evolution.action is unsupported"
142 .into(),
143 ));
144 }
145
146 cfg.catalog_type = DeltaCatalogType::from_config(config)?;
148 if let Some(v) = config.get("catalog.database") {
149 cfg.catalog_database = Some(v.to_string());
150 }
151 if let Some(v) = config.get("catalog.name") {
152 cfg.catalog_name = Some(v.to_string());
153 }
154 if let Some(v) = config.get("catalog.schema") {
155 cfg.catalog_schema = Some(v.to_string());
156 }
157 let explicit_storage = config.properties_with_prefix("storage.");
159 let resolved = StorageCredentialResolver::resolve(&cfg.table_path, &explicit_storage);
160 cfg.env_resolved_storage_keys = resolved.env_resolved_keys;
161 cfg.storage_options = resolved.options;
162
163 if let Some(v) = config.get("storage.s3_locking_provider") {
165 cfg.storage_options
166 .insert("AWS_S3_LOCKING_PROVIDER".to_string(), v.to_string());
167 }
168 if let Some(v) = config.get("storage.dynamodb_table_name") {
169 cfg.storage_options
170 .insert("DELTA_DYNAMO_TABLE_NAME".to_string(), v.to_string());
171 }
172
173 cfg.validate()?;
174 Ok(cfg)
175 }
176
177 #[cfg(feature = "delta-lake")]
179 pub(crate) fn stable_storage_options(&self) -> HashMap<String, String> {
180 let mut options = self.storage_options.clone();
181 for key in &self.env_resolved_storage_keys {
182 options.remove(key);
183 }
184 options
185 }
186
187 #[must_use]
189 pub fn display_storage_options(&self) -> String {
190 SecretMasker::display_map(&self.storage_options)
191 }
192
193 pub fn validate(&self) -> Result<(), ConnectorError> {
199 if self.table_path.is_empty() {
200 return Err(ConnectorError::missing_config("table.path"));
201 }
202 if self.read_mode == DeltaReadMode::Snapshot
203 && self.starting_version != DeltaStartingVersion::Snapshot
204 {
205 return Err(ConnectorError::ConfigurationError(
206 "starting.version applies only to read.mode=changes".into(),
207 ));
208 }
209 if self.poll_interval.is_zero() {
210 return Err(ConnectorError::ConfigurationError(
211 "poll.interval.ms must be > 0".into(),
212 ));
213 }
214
215 match &self.catalog_type {
217 DeltaCatalogType::None => {}
218 DeltaCatalogType::Glue => {
219 if self.catalog_database.is_none() {
220 return Err(ConnectorError::ConfigurationError(
221 "Glue catalog requires 'catalog.database' to be set".into(),
222 ));
223 }
224 }
225 DeltaCatalogType::Unity(unity) => {
226 unity.validate()?;
227 if self.catalog_name.is_none() || self.catalog_schema.is_none() {
228 return Err(ConnectorError::ConfigurationError(
229 "Unity catalog requires 'catalog.name' and 'catalog.schema' to be set"
230 .into(),
231 ));
232 }
233 }
234 }
235
236 let resolved = StorageCredentialResolver::resolve(&self.table_path, &self.storage_options);
238 let cloud_result = CloudConfigValidator::validate(&resolved);
239 if !cloud_result.is_valid() {
240 return Err(ConnectorError::ConfigurationError(
241 cloud_result.error_message(),
242 ));
243 }
244
245 Ok(())
246 }
247}
248
249#[derive(Debug, Clone, Copy, PartialEq, Eq)]
251pub enum DeltaReadMode {
252 Changes,
254 Snapshot,
256}
257
258#[derive(Debug, Clone, Copy, PartialEq, Eq)]
261pub enum DeltaStartingVersion {
262 Snapshot,
265 Latest,
268 Version(u64),
271}
272
273#[cfg(test)]
274mod tests;