Skip to main content

risingwave_connector/sink/snowflake_redshift/
snowflake.rs

1// Copyright 2025 RisingWave Labs
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use core::num::NonZeroU64;
16use std::collections::BTreeMap;
17use std::time::Duration;
18
19use anyhow::anyhow;
20use phf::{Set, phf_set};
21use risingwave_common::array::StreamChunk;
22use risingwave_common::catalog::Schema;
23use risingwave_common::types::DataType;
24use risingwave_pb::connector_service::{SinkMetadata, sink_metadata};
25use risingwave_pb::stream_plan::PbSinkSchemaChange;
26use serde::Deserialize;
27use serde_with::{DisplayFromStr, serde_as};
28use thiserror_ext::AsReport;
29use tokio::sync::mpsc::{UnboundedSender, unbounded_channel};
30use tokio::time::{MissedTickBehavior, interval};
31use tonic::async_trait;
32use with_options::WithOptions;
33
34use crate::connector_common::IcebergSinkCompactionUpdate;
35use crate::enforce_secret::EnforceSecret;
36use crate::sink::catalog::SinkId;
37use crate::sink::coordinate::CoordinatedLogSinker;
38use crate::sink::decouple_checkpoint_log_sink::default_commit_checkpoint_interval;
39use crate::sink::file_sink::s3::S3Common;
40use crate::sink::jdbc_jni_client::{self, JdbcJniClient};
41use crate::sink::snowflake_redshift::{
42    __OP, __ROW_ID, SnowflakeRedshiftSinkJdbcWriter, SnowflakeRedshiftSinkS3Writer,
43};
44use crate::sink::writer::SinkWriter;
45use crate::sink::{
46    Result, SINK_TYPE_APPEND_ONLY, SINK_TYPE_OPTION, SINK_TYPE_UPSERT,
47    SinglePhaseCommitCoordinator, Sink, SinkCommitCoordinator, SinkError, SinkParam,
48    SinkWriterParam,
49};
50
51pub const SNOWFLAKE_SINK_V2: &str = "snowflake_v2";
52
53const AUTH_METHOD_PASSWORD: &str = "password";
54const AUTH_METHOD_KEY_PAIR_FILE: &str = "key_pair_file";
55const AUTH_METHOD_KEY_PAIR_OBJECT: &str = "key_pair_object";
56const PROP_AUTH_METHOD: &str = "auth.method";
57
58pub fn build_full_table_name(database: &str, schema_name: &str, table_name: &str) -> String {
59    format!(r#""{}"."{}"."{}""#, database, schema_name, table_name)
60}
61
62#[serde_as]
63#[derive(Debug, Clone, Deserialize, WithOptions)]
64pub struct SnowflakeV2Config {
65    #[serde(rename = "type")]
66    pub r#type: String,
67
68    #[serde(rename = "intermediate.table.name")]
69    pub snowflake_cdc_table_name: Option<String>,
70
71    #[serde(rename = "table.name")]
72    pub snowflake_target_table_name: Option<String>,
73
74    #[serde(rename = "database")]
75    pub snowflake_database: Option<String>,
76
77    #[serde(rename = "schema")]
78    pub snowflake_schema: Option<String>,
79
80    #[serde(default = "default_target_interval_schedule")]
81    #[serde(rename = "write.target.interval.seconds")]
82    #[serde_as(as = "DisplayFromStr")]
83    pub writer_target_interval_seconds: u64,
84
85    #[serde(default = "default_intermediate_interval_schedule")]
86    #[serde(rename = "write.intermediate.interval.seconds")]
87    #[serde_as(as = "DisplayFromStr")]
88    pub write_intermediate_interval_seconds: u64,
89
90    #[serde(rename = "warehouse")]
91    pub snowflake_warehouse: Option<String>,
92
93    #[serde(default, rename = "task.serverless")]
94    #[serde_as(as = "DisplayFromStr")]
95    pub task_serverless: bool,
96
97    #[serde(rename = "task.target_completion_interval")]
98    pub task_target_completion_interval: Option<String>,
99
100    #[serde(rename = "jdbc.url")]
101    pub jdbc_url: Option<String>,
102
103    #[serde(rename = "username")]
104    pub username: Option<String>,
105
106    #[serde(rename = "password")]
107    pub password: Option<String>,
108
109    // Authentication method control (password | key_pair_file | key_pair_object)
110    #[serde(rename = "auth.method")]
111    pub auth_method: Option<String>,
112
113    // Key-pair authentication via connection Properties (Option 2: file-based)
114    #[serde(rename = "private_key_file")]
115    pub private_key_file: Option<String>,
116
117    #[serde(rename = "private_key_file_pwd")]
118    pub private_key_file_pwd: Option<String>,
119
120    // Key-pair authentication via connection Properties (Option 1: object-based, PEM content)
121    #[serde(rename = "private_key_pem")]
122    pub private_key_pem: Option<String>,
123
124    /// Commit every n(>0) checkpoints, default is 10.
125    #[serde(default = "default_commit_checkpoint_interval")]
126    #[serde_as(as = "DisplayFromStr")]
127    #[with_option(allow_alter_on_fly)]
128    pub commit_checkpoint_interval: u64,
129
130    /// Enable auto schema change for upsert sink.
131    /// If enabled, the sink will automatically alter the target table to add new columns.
132    #[serde(default)]
133    #[serde(rename = "auto.schema.change")]
134    #[serde_as(as = "DisplayFromStr")]
135    pub auto_schema_change: bool,
136
137    #[serde(default)]
138    #[serde(rename = "create_table_if_not_exists")]
139    #[serde_as(as = "DisplayFromStr")]
140    pub create_table_if_not_exists: bool,
141
142    #[serde(default = "default_with_s3")]
143    #[serde(rename = "with_s3")]
144    #[serde_as(as = "DisplayFromStr")]
145    pub with_s3: bool,
146
147    #[serde(flatten)]
148    pub s3_inner: Option<S3Common>,
149
150    #[serde(rename = "stage")]
151    pub stage: Option<String>,
152
153    #[serde(flatten)]
154    pub unknown_fields: std::collections::HashMap<String, String>,
155}
156
157crate::impl_sink_unknown_fields!(SnowflakeV2Config);
158
159fn default_target_interval_schedule() -> u64 {
160    3600 // Default to 1 hour
161}
162
163fn default_intermediate_interval_schedule() -> u64 {
164    1800 // Default to 0.5 hour
165}
166
167fn default_with_s3() -> bool {
168    true
169}
170
171impl SnowflakeV2Config {
172    /// Build JDBC Properties for the Snowflake JDBC connection (no URL parameters).
173    /// Returns (`jdbc_url`, `driver_properties`).
174    /// - `driver_properties` are transformed/used by the Java runner and passed to `DriverManager::getConnection(url, props)`
175    ///
176    /// Note: This method assumes the config has been validated by `from_btreemap`.
177    pub fn build_jdbc_connection_properties(&self) -> Result<(String, Vec<(String, String)>)> {
178        let jdbc_url = self
179            .jdbc_url
180            .clone()
181            .ok_or(SinkError::Config(anyhow!("jdbc.url is required")))?;
182        let username = self
183            .username
184            .clone()
185            .ok_or(SinkError::Config(anyhow!("username is required")))?;
186
187        let mut connection_properties: Vec<(String, String)> = vec![("user".to_owned(), username)];
188
189        // auth_method is guaranteed to be Some after validation in from_btreemap
190        match self.auth_method.as_deref().unwrap() {
191            AUTH_METHOD_PASSWORD => {
192                // password is guaranteed to exist by from_btreemap validation
193                connection_properties.push(("password".to_owned(), self.password.clone().unwrap()));
194            }
195            AUTH_METHOD_KEY_PAIR_FILE => {
196                // private_key_file is guaranteed to exist by from_btreemap validation
197                connection_properties.push((
198                    "private_key_file".to_owned(),
199                    self.private_key_file.clone().unwrap(),
200                ));
201                if let Some(pwd) = self.private_key_file_pwd.clone() {
202                    connection_properties.push(("private_key_file_pwd".to_owned(), pwd));
203                }
204            }
205            AUTH_METHOD_KEY_PAIR_OBJECT => {
206                connection_properties.push((
207                    PROP_AUTH_METHOD.to_owned(),
208                    AUTH_METHOD_KEY_PAIR_OBJECT.to_owned(),
209                ));
210                // private_key_pem is guaranteed to exist by from_btreemap validation
211                connection_properties.push((
212                    "private_key_pem".to_owned(),
213                    self.private_key_pem.clone().unwrap(),
214                ));
215                if let Some(pwd) = self.private_key_file_pwd.clone() {
216                    connection_properties.push(("private_key_file_pwd".to_owned(), pwd));
217                }
218            }
219            _ => {
220                // This should never happen since from_btreemap validates auth_method
221                unreachable!(
222                    "Invalid auth_method - should have been caught during config validation"
223                )
224            }
225        }
226
227        Ok((jdbc_url, connection_properties))
228    }
229
230    pub fn from_btreemap(properties: &BTreeMap<String, String>) -> Result<Self> {
231        let mut config =
232            serde_json::from_value::<SnowflakeV2Config>(serde_json::to_value(properties).unwrap())
233                .map_err(|e| SinkError::Config(anyhow!(e)))?;
234        if config.r#type != SINK_TYPE_APPEND_ONLY && config.r#type != SINK_TYPE_UPSERT {
235            return Err(SinkError::Config(anyhow!(
236                "`{}` must be {}, or {}",
237                SINK_TYPE_OPTION,
238                SINK_TYPE_APPEND_ONLY,
239                SINK_TYPE_UPSERT
240            )));
241        }
242        if config.r#type == SINK_TYPE_UPSERT && !config.with_s3 {
243            return Err(SinkError::Config(anyhow!(
244                "Snowflake upsert sinks require `with_s3 = true` so all CDC rows are loaded by the serialized COPY INTO task"
245            )));
246        }
247        let has_upsert_task_config = config.snowflake_cdc_table_name.is_some()
248            || properties.contains_key("write.target.interval.seconds")
249            || config.snowflake_warehouse.is_some()
250            || config.task_serverless
251            || config.task_target_completion_interval.is_some();
252        if config.r#type != SINK_TYPE_UPSERT && has_upsert_task_config {
253            return Err(SinkError::Config(anyhow!(
254                "`intermediate.table.name`, `write.target.interval.seconds`, `warehouse`, \
255                 `task.serverless`, and `task.target_completion_interval` require `{}` = {}",
256                SINK_TYPE_OPTION,
257                SINK_TYPE_UPSERT
258            )));
259        }
260        if config.task_target_completion_interval.is_some() && !config.task_serverless {
261            return Err(SinkError::Config(anyhow!(
262                "`task.target_completion_interval` requires `task.serverless` to be true"
263            )));
264        }
265        if config.task_serverless && config.snowflake_warehouse.is_some() {
266            return Err(SinkError::Config(anyhow!(
267                "`task.serverless` must not be combined with `warehouse`"
268            )));
269        }
270
271        // Normalize and validate authentication method
272        let has_password = config.password.is_some();
273        let has_file = config.private_key_file.is_some();
274        let has_pem = config.private_key_pem.as_deref().is_some();
275
276        let normalized_auth_method = match config
277            .auth_method
278            .as_deref()
279            .map(|s| s.trim().to_ascii_lowercase())
280        {
281            Some(method) if method == AUTH_METHOD_PASSWORD => {
282                if !has_password {
283                    return Err(SinkError::Config(anyhow!(
284                        "auth.method=password requires `password`"
285                    )));
286                }
287                if has_file || has_pem {
288                    return Err(SinkError::Config(anyhow!(
289                        "auth.method=password must not set `private_key_file`/`private_key_pem`"
290                    )));
291                }
292                AUTH_METHOD_PASSWORD.to_owned()
293            }
294            Some(method) if method == AUTH_METHOD_KEY_PAIR_FILE => {
295                if !has_file {
296                    return Err(SinkError::Config(anyhow!(
297                        "auth.method=key_pair_file requires `private_key_file`"
298                    )));
299                }
300                if has_password {
301                    return Err(SinkError::Config(anyhow!(
302                        "auth.method=key_pair_file must not set `password`"
303                    )));
304                }
305                if has_pem {
306                    return Err(SinkError::Config(anyhow!(
307                        "auth.method=key_pair_file must not set `private_key_pem`"
308                    )));
309                }
310                AUTH_METHOD_KEY_PAIR_FILE.to_owned()
311            }
312            Some(method) if method == AUTH_METHOD_KEY_PAIR_OBJECT => {
313                if !has_pem {
314                    return Err(SinkError::Config(anyhow!(
315                        "auth.method=key_pair_object requires `private_key_pem`"
316                    )));
317                }
318                if has_password {
319                    return Err(SinkError::Config(anyhow!(
320                        "auth.method=key_pair_object must not set `password`"
321                    )));
322                }
323                AUTH_METHOD_KEY_PAIR_OBJECT.to_owned()
324            }
325            Some(other) => {
326                return Err(SinkError::Config(anyhow!(
327                    "invalid auth.method: {} (allowed: password | key_pair_file | key_pair_object)",
328                    other
329                )));
330            }
331            None => {
332                // Infer auth method from supplied fields
333                match (has_password, has_file, has_pem) {
334                    (true, false, false) => AUTH_METHOD_PASSWORD.to_owned(),
335                    (false, true, false) => AUTH_METHOD_KEY_PAIR_FILE.to_owned(),
336                    (false, false, true) => AUTH_METHOD_KEY_PAIR_OBJECT.to_owned(),
337                    (true, true, _) | (true, _, true) | (false, true, true) => {
338                        return Err(SinkError::Config(anyhow!(
339                            "ambiguous auth: multiple auth options provided; remove one or set `auth.method`"
340                        )));
341                    }
342                    _ => {
343                        return Err(SinkError::Config(anyhow!(
344                            "no authentication configured: set either `password`, or `private_key_file`, or `private_key_pem` (or provide `auth.method`)"
345                        )));
346                    }
347                }
348            }
349        };
350        config.auth_method = Some(normalized_auth_method);
351        Ok(config)
352    }
353
354    pub fn build_snowflake_task_ctx_jdbc_client(
355        &self,
356        is_append_only: bool,
357        schema: &Schema,
358        pk_indices: &Vec<usize>,
359    ) -> Result<Option<(SnowflakeTaskContext, JdbcJniClient)>> {
360        if !self.auto_schema_change
361            && is_append_only
362            && !self.create_table_if_not_exists
363            && !self.with_s3
364        {
365            // append-only + no auto schema change is not need to create a client
366            return Ok(None);
367        }
368        let target_table_name = self
369            .snowflake_target_table_name
370            .clone()
371            .ok_or(SinkError::Config(anyhow!("table.name is required")))?;
372        let database = self
373            .snowflake_database
374            .clone()
375            .ok_or(SinkError::Config(anyhow!("database is required")))?;
376        let schema_name = self
377            .snowflake_schema
378            .clone()
379            .ok_or(SinkError::Config(anyhow!("schema is required")))?;
380        let mut snowflake_task_ctx = SnowflakeTaskContext {
381            target_table_name: target_table_name.clone(),
382            database,
383            schema_name,
384            schema: schema.clone(),
385            ..Default::default()
386        };
387
388        let (jdbc_url, connection_properties) = self.build_jdbc_connection_properties()?;
389        let client = JdbcJniClient::new_with_props(jdbc_url, connection_properties)?;
390
391        if self.with_s3 {
392            let stage = self
393                .stage
394                .clone()
395                .ok_or(SinkError::Config(anyhow!("stage is required")))?;
396            snowflake_task_ctx.stage = Some(stage);
397            if is_append_only {
398                snowflake_task_ctx.pipe_name = Some(format!("{}_pipe", target_table_name));
399            }
400        }
401        if !is_append_only {
402            let cdc_table_name = self
403                .snowflake_cdc_table_name
404                .clone()
405                .ok_or(SinkError::Config(anyhow!(
406                    "intermediate.table.name is required"
407                )))?;
408            snowflake_task_ctx.cdc_table_name = Some(cdc_table_name.clone());
409            snowflake_task_ctx.writer_target_interval_seconds = self.writer_target_interval_seconds;
410            snowflake_task_ctx.task_serverless = self.task_serverless;
411            snowflake_task_ctx.task_target_completion_interval =
412                self.task_target_completion_interval.clone();
413            if !self.task_serverless {
414                snowflake_task_ctx.warehouse = Some(
415                    self.snowflake_warehouse
416                        .clone()
417                        .ok_or(SinkError::Config(anyhow!("warehouse is required")))?,
418                );
419            }
420            let pk_column_names: Vec<_> = schema
421                .fields
422                .iter()
423                .enumerate()
424                .filter(|(index, _)| pk_indices.contains(index))
425                .map(|(_, field)| field.name.clone())
426                .collect();
427            if pk_column_names.is_empty() {
428                return Err(SinkError::Config(anyhow!(
429                    "Primary key columns not found. Please set the `primary_key` column in the sink properties, or ensure that the sink contains the primary key columns from the upstream."
430                )));
431            }
432            snowflake_task_ctx.pk_column_names = Some(pk_column_names);
433            snowflake_task_ctx.all_column_names = Some(
434                schema
435                    .fields
436                    .iter()
437                    .map(|field| field.name.clone())
438                    .collect(),
439            );
440            snowflake_task_ctx.task_name = Some(format!(
441                "rw_snowflake_sink_from_{cdc_table_name}_to_{target_table_name}"
442            ));
443        }
444        Ok(Some((snowflake_task_ctx, client)))
445    }
446}
447
448impl EnforceSecret for SnowflakeV2Config {
449    const ENFORCE_SECRET_PROPERTIES: Set<&'static str> = phf_set! {
450        "username",
451        "password",
452        "jdbc.url",
453        // Key-pair authentication secrets
454        "private_key_file_pwd",
455        "private_key_pem",
456    };
457}
458
459#[derive(Clone, Debug)]
460pub struct SnowflakeV2Sink {
461    config: SnowflakeV2Config,
462    schema: Schema,
463    pk_indices: Vec<usize>,
464    is_append_only: bool,
465    param: SinkParam,
466}
467
468impl EnforceSecret for SnowflakeV2Sink {
469    fn enforce_secret<'a>(
470        prop_iter: impl Iterator<Item = &'a str>,
471    ) -> crate::sink::ConnectorResult<()> {
472        for prop in prop_iter {
473            SnowflakeV2Config::enforce_one(prop)?;
474        }
475        Ok(())
476    }
477}
478
479impl TryFrom<SinkParam> for SnowflakeV2Sink {
480    type Error = SinkError;
481
482    fn try_from(param: SinkParam) -> std::result::Result<Self, Self::Error> {
483        let schema = param.schema();
484        let config = SnowflakeV2Config::from_btreemap(&param.properties)?;
485        let is_append_only = param.sink_type.is_append_only();
486        let pk_indices = param.downstream_pk_or_empty();
487        Ok(Self {
488            config,
489            schema,
490            pk_indices,
491            is_append_only,
492            param,
493        })
494    }
495}
496
497impl Sink for SnowflakeV2Sink {
498    type LogSinker = CoordinatedLogSinker<SnowflakeSinkWriter>;
499
500    const SINK_NAME: &'static str = SNOWFLAKE_SINK_V2;
501
502    crate::impl_validate_sink_unknown_fields!();
503
504    async fn validate(&self) -> Result<()> {
505        risingwave_common::license::Feature::SnowflakeSink
506            .check_available()
507            .map_err(|e| anyhow::anyhow!(e))?;
508        if let Some((snowflake_task_ctx, client)) =
509            self.config.build_snowflake_task_ctx_jdbc_client(
510                self.is_append_only,
511                &self.schema,
512                &self.pk_indices,
513            )?
514        {
515            let client = SnowflakeJniClient::new(client, snowflake_task_ctx);
516            client.execute_create_table().await?;
517            client.execute_create_pipe().await?;
518        }
519
520        Ok(())
521    }
522
523    fn support_schema_change() -> bool {
524        true
525    }
526
527    fn validate_alter_config(config: &BTreeMap<String, String>) -> Result<()> {
528        SnowflakeV2Config::from_btreemap(config)?;
529        Ok(())
530    }
531
532    async fn new_log_sinker(
533        &self,
534        writer_param: crate::sink::SinkWriterParam,
535    ) -> Result<Self::LogSinker> {
536        let writer = SnowflakeSinkWriter::new(
537            self.config.clone(),
538            self.is_append_only,
539            writer_param.clone(),
540            self.param.clone(),
541        )
542        .await?;
543
544        let commit_checkpoint_interval =
545            NonZeroU64::new(self.config.commit_checkpoint_interval).expect(
546                "commit_checkpoint_interval should be greater than 0, and it should be checked in config validation",
547            );
548
549        CoordinatedLogSinker::new(
550            &writer_param,
551            self.param.clone(),
552            writer,
553            commit_checkpoint_interval,
554        )
555        .await
556    }
557
558    fn is_coordinated_sink(&self) -> bool {
559        true
560    }
561
562    async fn new_coordinator(
563        &self,
564        _iceberg_compact_stat_sender: Option<UnboundedSender<IcebergSinkCompactionUpdate>>,
565    ) -> Result<SinkCommitCoordinator> {
566        let coordinator = SnowflakeSinkCommitter::new(
567            self.config.clone(),
568            &self.schema,
569            &self.pk_indices,
570            self.is_append_only,
571            self.param.sink_id,
572        )?;
573        Ok(SinkCommitCoordinator::SinglePhase(Box::new(coordinator)))
574    }
575}
576
577pub enum SnowflakeSinkWriter {
578    S3(SnowflakeRedshiftSinkS3Writer),
579    Jdbc(SnowflakeRedshiftSinkJdbcWriter),
580}
581
582impl SnowflakeSinkWriter {
583    pub async fn new(
584        config: SnowflakeV2Config,
585        is_append_only: bool,
586        writer_param: SinkWriterParam,
587        param: SinkParam,
588    ) -> Result<Self> {
589        let schema = param.schema();
590        let database = config.snowflake_database.ok_or_else(|| {
591            SinkError::Config(anyhow!("database is required for Snowflake JDBC sink"))
592        })?;
593        let schema_name = config.snowflake_schema.ok_or_else(|| {
594            SinkError::Config(anyhow!("schema is required for Snowflake JDBC sink"))
595        })?;
596        let table_name = config.snowflake_target_table_name.ok_or_else(|| {
597            SinkError::Config(anyhow!("table.name is required for Snowflake JDBC sink"))
598        })?;
599        if config.with_s3 {
600            let s3_writer = SnowflakeRedshiftSinkS3Writer::new(
601                config.s3_inner.ok_or_else(|| {
602                    SinkError::Config(anyhow!(
603                        "S3 configuration is required for Snowflake S3 sink"
604                    ))
605                })?,
606                schema,
607                is_append_only,
608                table_name,
609            )?;
610            Ok(Self::S3(s3_writer))
611        } else {
612            let jdbc_writer = SnowflakeRedshiftSinkJdbcWriter::new(
613                is_append_only,
614                writer_param,
615                param,
616                build_full_table_name(&database, &schema_name, &table_name),
617            )
618            .await?;
619            Ok(Self::Jdbc(jdbc_writer))
620        }
621    }
622}
623
624#[async_trait]
625impl SinkWriter for SnowflakeSinkWriter {
626    type CommitMetadata = Option<SinkMetadata>;
627
628    async fn begin_epoch(&mut self, epoch: u64) -> Result<()> {
629        match self {
630            Self::S3(writer) => writer.begin_epoch(epoch),
631            Self::Jdbc(writer) => writer.begin_epoch(epoch).await,
632        }
633    }
634
635    async fn write_batch(&mut self, chunk: StreamChunk) -> Result<()> {
636        match self {
637            Self::S3(writer) => writer.write_batch(chunk).await,
638            Self::Jdbc(writer) => writer.write_batch(chunk).await,
639        }
640    }
641
642    async fn barrier(&mut self, is_checkpoint: bool) -> Result<Option<SinkMetadata>> {
643        match self {
644            Self::S3(writer) => {
645                writer.barrier(is_checkpoint).await?;
646            }
647            Self::Jdbc(writer) => {
648                writer.barrier(is_checkpoint).await?;
649            }
650        }
651        Ok(Some(SinkMetadata {
652            metadata: Some(sink_metadata::Metadata::Serialized(
653                risingwave_pb::connector_service::sink_metadata::SerializedMetadata {
654                    metadata: vec![],
655                },
656            )),
657        }))
658    }
659
660    async fn abort(&mut self) -> Result<()> {
661        if let Self::Jdbc(writer) = self {
662            writer.abort().await
663        } else {
664            Ok(())
665        }
666    }
667}
668
669#[derive(Default, Clone)]
670pub struct SnowflakeTaskContext {
671    // required for task creation
672    pub target_table_name: String,
673    pub database: String,
674    pub schema_name: String,
675    pub schema: Schema,
676
677    // only upsert
678    pub task_name: Option<String>,
679    pub cdc_table_name: Option<String>,
680    pub writer_target_interval_seconds: u64,
681    pub warehouse: Option<String>,
682    pub task_serverless: bool,
683    pub task_target_completion_interval: Option<String>,
684    pub pk_column_names: Option<Vec<String>>,
685    pub all_column_names: Option<Vec<String>>,
686
687    // only s3 writer
688    pub stage: Option<String>,
689    pub pipe_name: Option<String>,
690}
691pub struct SnowflakeSinkCommitter {
692    client: Option<SnowflakeJniClient>,
693    _periodic_task_handle: Option<tokio::task::JoinHandle<()>>,
694    shutdown_sender: Option<tokio::sync::mpsc::UnboundedSender<()>>,
695}
696
697impl SnowflakeSinkCommitter {
698    pub fn new(
699        config: SnowflakeV2Config,
700        schema: &Schema,
701        pk_indices: &Vec<usize>,
702        is_append_only: bool,
703        sink_id: SinkId,
704    ) -> Result<Self> {
705        let (client, periodic_task_handle, shutdown_sender) =
706            if let Some((snowflake_task_ctx, client)) =
707                config.build_snowflake_task_ctx_jdbc_client(is_append_only, schema, pk_indices)?
708            {
709                let (periodic_task_handle, shutdown_sender) =
710                    if snowflake_task_ctx.pipe_name.is_some() {
711                        let (shutdown_sender, shutdown_receiver) = unbounded_channel();
712                        let snowflake_client =
713                            SnowflakeJniClient::new(client.clone(), snowflake_task_ctx.clone());
714                        let periodic_task_handle = tokio::spawn(async move {
715                            Self::run_periodic_query_task(
716                                snowflake_client,
717                                config.write_intermediate_interval_seconds,
718                                sink_id,
719                                shutdown_receiver,
720                            )
721                            .await;
722                        });
723                        (Some(periodic_task_handle), Some(shutdown_sender))
724                    } else {
725                        (None, None)
726                    };
727                (
728                    Some(SnowflakeJniClient::new(client, snowflake_task_ctx)),
729                    periodic_task_handle,
730                    shutdown_sender,
731                )
732            } else {
733                (None, None, None)
734            };
735
736        Ok(Self {
737            client,
738            _periodic_task_handle: periodic_task_handle,
739            shutdown_sender,
740        })
741    }
742
743    async fn run_periodic_query_task(
744        client: SnowflakeJniClient,
745        write_intermediate_interval_seconds: u64,
746        sink_id: SinkId,
747        mut shutdown_receiver: tokio::sync::mpsc::UnboundedReceiver<()>,
748    ) {
749        let mut copy_timer = interval(Duration::from_secs(write_intermediate_interval_seconds));
750        copy_timer.set_missed_tick_behavior(MissedTickBehavior::Skip);
751        loop {
752            tokio::select! {
753                _ = shutdown_receiver.recv() => break,
754                _ = copy_timer.tick() => {
755                    if let Err(e) = async {
756                        client.execute_flush_pipe().await?;
757                        Ok::<(),SinkError>(())
758                    }.await {
759                        tracing::error!("Failed to execute copy into task for sink id {}: {}", sink_id, e.as_report());
760                    }
761                }
762            }
763        }
764        tracing::info!("Periodic query task stopped for sink id {}", sink_id);
765    }
766}
767
768#[async_trait]
769impl SinglePhaseCommitCoordinator for SnowflakeSinkCommitter {
770    async fn init(&mut self) -> Result<()> {
771        if let Some(client) = &self.client {
772            // Todo: move this to validate
773            client.execute_drop_legacy_pipe().await?;
774            client.execute_create_pipe().await?;
775            client.execute_create_merge_into_task().await?;
776        }
777        Ok(())
778    }
779
780    async fn commit_data(&mut self, _epoch: u64, _metadata: Vec<SinkMetadata>) -> Result<()> {
781        Ok(())
782    }
783
784    async fn commit_schema_change(
785        &mut self,
786        _epoch: u64,
787        schema_change: PbSinkSchemaChange,
788    ) -> Result<()> {
789        use risingwave_pb::stream_plan::sink_schema_change::PbOp as SinkSchemaChangeOp;
790        let schema_change_op = schema_change
791            .op
792            .ok_or_else(|| SinkError::Coordinator(anyhow!("Invalid schema change operation")))?;
793        let SinkSchemaChangeOp::AddColumns(add_columns) = schema_change_op else {
794            return Err(SinkError::Coordinator(anyhow!(
795                "Only AddColumns schema change is supported for Snowflake sink"
796            )));
797        };
798        let client = self.client.as_mut().ok_or_else(|| {
799            SinkError::Config(anyhow!("Snowflake sink committer is not initialized."))
800        })?;
801        client
802            .execute_alter_add_columns(
803                &add_columns
804                    .fields
805                    .into_iter()
806                    .map(|f| {
807                        let dt = DataType::from(f.data_type.unwrap());
808                        Ok((f.name, convert_snowflake_data_type(&dt)?))
809                    })
810                    .collect::<Result<Vec<_>>>()?,
811            )
812            .await
813    }
814}
815
816impl Drop for SnowflakeSinkCommitter {
817    fn drop(&mut self) {
818        if let Some(client) = self.client.take() {
819            if let Some(sender) = self.shutdown_sender.take() {
820                let _ = sender.send(()); // Ignore the result, as the receiver may have been dropped.
821            }
822            tokio::spawn(async move {
823                client.execute_drop_task().await.ok();
824            });
825        }
826    }
827}
828
829pub struct SnowflakeJniClient {
830    jdbc_client: JdbcJniClient,
831    snowflake_task_context: SnowflakeTaskContext,
832}
833
834impl SnowflakeJniClient {
835    pub fn new(jdbc_client: JdbcJniClient, snowflake_task_context: SnowflakeTaskContext) -> Self {
836        Self {
837            jdbc_client,
838            snowflake_task_context,
839        }
840    }
841
842    pub async fn execute_alter_add_columns(
843        &mut self,
844        columns: &Vec<(String, String)>,
845    ) -> Result<()> {
846        self.execute_drop_task().await?;
847        if let Some(names) = self.snowflake_task_context.all_column_names.as_mut() {
848            names.extend(columns.iter().map(|(name, _)| name.clone()));
849        }
850        if let Some(cdc_table_name) = &self.snowflake_task_context.cdc_table_name {
851            let alter_add_column_cdc_table_sql = build_alter_add_column_sql(
852                cdc_table_name,
853                &self.snowflake_task_context.database,
854                &self.snowflake_task_context.schema_name,
855                columns,
856            );
857            self.jdbc_client
858                .execute_sql_sync(vec![alter_add_column_cdc_table_sql])
859                .await?;
860        }
861
862        let alter_add_column_target_table_sql = build_alter_add_column_sql(
863            &self.snowflake_task_context.target_table_name,
864            &self.snowflake_task_context.database,
865            &self.snowflake_task_context.schema_name,
866            columns,
867        );
868        self.jdbc_client
869            .execute_sql_sync(vec![alter_add_column_target_table_sql])
870            .await?;
871
872        self.execute_create_merge_into_task().await?;
873        Ok(())
874    }
875
876    pub async fn execute_create_merge_into_task(&self) -> Result<()> {
877        if self.snowflake_task_context.task_name.is_some() {
878            let create_task_sql = build_create_merge_into_task_sql(&self.snowflake_task_context);
879            let start_task_sql = build_start_task_sql(&self.snowflake_task_context);
880            self.jdbc_client
881                .execute_sql_sync(vec![create_task_sql])
882                .await?;
883            self.jdbc_client
884                .execute_sql_sync(vec![start_task_sql])
885                .await?;
886        }
887        Ok(())
888    }
889
890    pub async fn execute_drop_task(&self) -> Result<()> {
891        if self.snowflake_task_context.task_name.is_some() {
892            let sql = build_drop_task_sql(&self.snowflake_task_context);
893            if let Err(e) = self.jdbc_client.execute_sql_sync(vec![sql]).await {
894                tracing::error!(
895                    "Failed to drop Snowflake sink task {:?}: {:?}",
896                    self.snowflake_task_context.task_name,
897                    e.as_report()
898                );
899            } else {
900                tracing::info!(
901                    "Snowflake sink task {:?} dropped",
902                    self.snowflake_task_context.task_name
903                );
904            }
905        }
906        Ok(())
907    }
908
909    pub async fn execute_create_table(&self) -> Result<()> {
910        // create target table
911        let create_target_table_sql = build_create_table_sql(
912            &self.snowflake_task_context.target_table_name,
913            &self.snowflake_task_context.database,
914            &self.snowflake_task_context.schema_name,
915            &self.snowflake_task_context.schema,
916            false,
917        )?;
918        self.jdbc_client
919            .execute_sql_sync(vec![create_target_table_sql])
920            .await?;
921        if let Some(cdc_table_name) = &self.snowflake_task_context.cdc_table_name {
922            let create_cdc_table_sql = build_create_table_sql(
923                cdc_table_name,
924                &self.snowflake_task_context.database,
925                &self.snowflake_task_context.schema_name,
926                &self.snowflake_task_context.schema,
927                true,
928            )?;
929            self.jdbc_client
930                .execute_sql_sync(vec![create_cdc_table_sql])
931                .await?;
932        }
933        Ok(())
934    }
935
936    pub async fn execute_create_pipe(&self) -> Result<()> {
937        if let Some(pipe_name) = &self.snowflake_task_context.pipe_name {
938            let table_name =
939                if let Some(table_name) = self.snowflake_task_context.cdc_table_name.as_ref() {
940                    table_name
941                } else {
942                    &self.snowflake_task_context.target_table_name
943                };
944            let create_pipe_sql = build_create_pipe_sql(
945                table_name,
946                &self.snowflake_task_context.database,
947                &self.snowflake_task_context.schema_name,
948                self.snowflake_task_context.stage.as_ref().ok_or_else(|| {
949                    SinkError::Config(anyhow!("snowflake.stage is required for S3 writer"))
950                })?,
951                pipe_name,
952                &self.snowflake_task_context.target_table_name,
953            );
954            self.jdbc_client
955                .execute_sql_sync(vec![create_pipe_sql])
956                .await?;
957        }
958        Ok(())
959    }
960
961    pub async fn execute_drop_legacy_pipe(&self) -> Result<()> {
962        // Older upsert sinks created this pipe and refreshed it from a local timer. Remove it
963        // before starting the task so no asynchronous Snowpipe load can race with MERGE/DELETE.
964        if self.snowflake_task_context.task_name.is_some()
965            && self.snowflake_task_context.stage.is_some()
966        {
967            let pipe_name = format!("{}_pipe", self.snowflake_task_context.target_table_name);
968            let drop_pipe_sql = build_drop_pipe_sql(
969                &self.snowflake_task_context.database,
970                &self.snowflake_task_context.schema_name,
971                &pipe_name,
972            );
973            self.jdbc_client
974                .execute_sql_sync(vec![drop_pipe_sql])
975                .await?;
976        }
977        Ok(())
978    }
979
980    pub async fn execute_flush_pipe(&self) -> Result<()> {
981        if let Some(pipe_name) = &self.snowflake_task_context.pipe_name {
982            let flush_pipe_sql = build_flush_pipe_sql(
983                &self.snowflake_task_context.database,
984                &self.snowflake_task_context.schema_name,
985                pipe_name,
986            );
987            self.jdbc_client
988                .execute_sql_sync(vec![flush_pipe_sql])
989                .await?;
990        }
991        Ok(())
992    }
993}
994
995fn build_create_table_sql(
996    table_name: &str,
997    database: &str,
998    schema_name: &str,
999    schema: &Schema,
1000    need_op_and_row_id: bool,
1001) -> Result<String> {
1002    let full_table_name = build_full_table_name(database, schema_name, table_name);
1003    let mut columns: Vec<String> = schema
1004        .fields
1005        .iter()
1006        .map(|field| {
1007            let data_type = convert_snowflake_data_type(&field.data_type)?;
1008            Ok(format!(r#""{}" {}"#, field.name, data_type))
1009        })
1010        .collect::<Result<Vec<String>>>()?;
1011    if need_op_and_row_id {
1012        columns.push(format!(r#""{}" STRING"#, __ROW_ID));
1013        columns.push(format!(r#""{}" INT"#, __OP));
1014    }
1015    let columns_str = columns.join(", ");
1016    Ok(format!(
1017        "CREATE TABLE IF NOT EXISTS {} ({}) ENABLE_SCHEMA_EVOLUTION  = true",
1018        full_table_name, columns_str
1019    ))
1020}
1021
1022fn convert_snowflake_data_type(data_type: &DataType) -> Result<String> {
1023    let data_type = match data_type {
1024        DataType::Int16 => "SMALLINT".to_owned(),
1025        DataType::Int32 => "INTEGER".to_owned(),
1026        DataType::Int64 => "BIGINT".to_owned(),
1027        DataType::Float32 => "FLOAT4".to_owned(),
1028        DataType::Float64 => "FLOAT8".to_owned(),
1029        DataType::Boolean => "BOOLEAN".to_owned(),
1030        DataType::Varchar => "STRING".to_owned(),
1031        DataType::Date => "DATE".to_owned(),
1032        DataType::Timestamp => "TIMESTAMP".to_owned(),
1033        DataType::Timestamptz => "TIMESTAMP_TZ".to_owned(),
1034        DataType::Jsonb => "STRING".to_owned(),
1035        // RisingWave uses rust_decimal with MAX_PRECISION=28. Snowflake's DECIMAL without
1036        // explicit precision defaults to (38,0), which drops all fractional digits. We use
1037        // DECIMAL(38, 10) to preserve up to 10 fractional digits, matching the Iceberg sink
1038        // convention, though values with more than 10 fractional digits may still lose precision.
1039        DataType::Decimal => "DECIMAL(38, 10)".to_owned(),
1040        DataType::Bytea => "BINARY".to_owned(),
1041        DataType::Time => "TIME".to_owned(),
1042        _ => {
1043            return Err(SinkError::Config(anyhow!(
1044                "Dont support auto create table for datatype: {}",
1045                data_type
1046            )));
1047        }
1048    };
1049    Ok(data_type)
1050}
1051
1052fn build_create_pipe_sql(
1053    table_name: &str,
1054    database: &str,
1055    schema: &str,
1056    stage: &str,
1057    pipe_name: &str,
1058    target_table_name: &str,
1059) -> String {
1060    let pipe_name = format!(r#""{}"."{}"."{}""#, database, schema, pipe_name);
1061    let copy_into_sql = build_copy_into_sql(table_name, database, schema, stage, target_table_name);
1062    format!(
1063        "CREATE OR REPLACE PIPE {} AUTO_INGEST = FALSE AS {}",
1064        pipe_name, copy_into_sql
1065    )
1066}
1067
1068fn build_copy_into_sql(
1069    table_name: &str,
1070    database: &str,
1071    schema: &str,
1072    stage: &str,
1073    target_table_name: &str,
1074) -> String {
1075    // Trailing `/` is required to enforce exact directory matching.
1076    // Without it, a COPY for table "dim_project" would also match files under
1077    // "dim_project_contract/" since Snowflake uses prefix matching.
1078    let stage = format!(
1079        r#""{}"."{}"."{}"/{}/"#,
1080        database, schema, stage, target_table_name
1081    );
1082    let table_name = format!(r#""{}"."{}"."{}""#, database, schema, table_name);
1083    format!(
1084        "COPY INTO {} FROM @{} MATCH_BY_COLUMN_NAME = CASE_INSENSITIVE FILE_FORMAT = (TYPE = 'JSON');",
1085        table_name, stage
1086    )
1087}
1088
1089fn build_flush_pipe_sql(database: &str, schema: &str, pipe_name: &str) -> String {
1090    let pipe_name = format!(r#""{}"."{}"."{}""#, database, schema, pipe_name);
1091    format!("ALTER PIPE {} REFRESH;", pipe_name,)
1092}
1093
1094fn build_drop_pipe_sql(database: &str, schema: &str, pipe_name: &str) -> String {
1095    let pipe_name = format!(r#""{}"."{}"."{}""#, database, schema, pipe_name);
1096    format!("DROP PIPE IF EXISTS {}", pipe_name)
1097}
1098
1099fn build_alter_add_column_sql(
1100    table_name: &str,
1101    database: &str,
1102    schema: &str,
1103    columns: &Vec<(String, String)>,
1104) -> String {
1105    let full_table_name = build_full_table_name(database, schema, table_name);
1106    jdbc_jni_client::build_alter_add_column_sql(&full_table_name, columns, true)
1107}
1108
1109fn build_start_task_sql(snowflake_task_context: &SnowflakeTaskContext) -> String {
1110    let SnowflakeTaskContext {
1111        task_name,
1112        database,
1113        schema_name: schema,
1114        ..
1115    } = snowflake_task_context;
1116    let full_task_name = format!(
1117        r#""{}"."{}"."{}""#,
1118        database,
1119        schema,
1120        task_name.as_ref().unwrap()
1121    );
1122    format!("ALTER TASK {} RESUME", full_task_name)
1123}
1124
1125fn build_drop_task_sql(snowflake_task_context: &SnowflakeTaskContext) -> String {
1126    let SnowflakeTaskContext {
1127        task_name,
1128        database,
1129        schema_name: schema,
1130        ..
1131    } = snowflake_task_context;
1132    let full_task_name = format!(
1133        r#""{}"."{}"."{}""#,
1134        database,
1135        schema,
1136        task_name.as_ref().unwrap()
1137    );
1138    format!("DROP TASK IF EXISTS {}", full_task_name)
1139}
1140
1141fn build_create_merge_into_task_sql(snowflake_task_context: &SnowflakeTaskContext) -> String {
1142    let SnowflakeTaskContext {
1143        task_name,
1144        cdc_table_name,
1145        target_table_name,
1146        writer_target_interval_seconds,
1147        warehouse,
1148        task_serverless,
1149        task_target_completion_interval,
1150        pk_column_names,
1151        all_column_names,
1152        database,
1153        schema_name,
1154        stage,
1155        ..
1156    } = snowflake_task_context;
1157    let full_task_name = format!(
1158        r#""{}"."{}"."{}""#,
1159        database,
1160        schema_name,
1161        task_name.as_ref().unwrap()
1162    );
1163    let full_cdc_table_name = format!(
1164        r#""{}"."{}"."{}""#,
1165        database,
1166        schema_name,
1167        cdc_table_name.as_ref().unwrap()
1168    );
1169    let full_target_table_name = format!(
1170        r#""{}"."{}"."{}""#,
1171        database, schema_name, target_table_name
1172    );
1173    let copy_into_sql = build_copy_into_sql(
1174        cdc_table_name.as_ref().unwrap(),
1175        database,
1176        schema_name,
1177        stage
1178            .as_ref()
1179            .expect("stage is required for Snowflake upsert tasks"),
1180        target_table_name,
1181    );
1182
1183    let pk_names_str = pk_column_names
1184        .as_ref()
1185        .unwrap()
1186        .iter()
1187        .map(|name| format!(r#""{}""#, name))
1188        .collect::<Vec<String>>()
1189        .join(", ");
1190    let pk_names_eq_str = pk_column_names
1191        .as_ref()
1192        .unwrap()
1193        .iter()
1194        .map(|name| format!(r#"target."{}" = source."{}""#, name, name))
1195        .collect::<Vec<String>>()
1196        .join(" AND ");
1197    let all_column_names_set_str = all_column_names
1198        .as_ref()
1199        .unwrap()
1200        .iter()
1201        .map(|name| format!(r#"target."{}" = source."{}""#, name, name))
1202        .collect::<Vec<String>>()
1203        .join(", ");
1204    let all_column_names_str = all_column_names
1205        .as_ref()
1206        .unwrap()
1207        .iter()
1208        .map(|name| format!(r#""{}""#, name))
1209        .collect::<Vec<String>>()
1210        .join(", ");
1211    let all_column_names_insert_str = all_column_names
1212        .as_ref()
1213        .unwrap()
1214        .iter()
1215        .map(|name| format!(r#"source."{}""#, name))
1216        .collect::<Vec<String>>()
1217        .join(", ");
1218    let row_id_ordering = format!(
1219        r#"TRY_TO_NUMBER(SPLIT_PART("{row_id}", '_', 1)) DESC NULLS LAST, TRY_TO_NUMBER(SPLIT_PART("{row_id}", '_', 2)) DESC NULLS LAST, "{row_id}" DESC"#,
1220        row_id = __ROW_ID,
1221    );
1222
1223    let compute_clause = if *task_serverless {
1224        task_target_completion_interval
1225            .as_ref()
1226            .map(|interval| format!("TARGET_COMPLETION_INTERVAL = '{}'", interval))
1227    } else {
1228        Some(format!("WAREHOUSE = {}", warehouse.as_ref().unwrap()))
1229    };
1230
1231    // Snowflake Scripting autocommits each statement. This sequence is safe because upsert writers
1232    // can only stage files in S3, COPY INTO is the only CDC-table writer, and NO_OVERLAP prevents a
1233    // second task run from inserting rows between MERGE and DELETE.
1234    format!(
1235        r#"CREATE OR REPLACE TASK {task_name}
1236{compute_clause}
1237SCHEDULE = '{writer_target_interval_seconds} SECONDS'
1238OVERLAP_POLICY = NO_OVERLAP
1239AS
1240BEGIN
1241    {copy_into_sql}
1242
1243    MERGE INTO {target_table_name} AS target
1244    USING (
1245        SELECT *
1246        FROM (
1247            SELECT *, ROW_NUMBER() OVER (PARTITION BY {pk_names_str} ORDER BY {row_id_ordering}) AS dedupe_id
1248            FROM {cdc_table_name}
1249        ) AS subquery
1250        WHERE dedupe_id = 1
1251    ) AS source
1252    ON {pk_names_eq_str}
1253    WHEN MATCHED AND source."{snowflake_sink_op}" IN (2, 4) THEN DELETE
1254    WHEN MATCHED AND source."{snowflake_sink_op}" IN (1, 3) THEN UPDATE SET {all_column_names_set_str}
1255    WHEN NOT MATCHED AND source."{snowflake_sink_op}" IN (1, 3) THEN INSERT ({all_column_names_str}) VALUES ({all_column_names_insert_str});
1256
1257    DELETE FROM {cdc_table_name};
1258END;"#,
1259        task_name = full_task_name,
1260        compute_clause = compute_clause
1261            .map(|clause| format!("{clause}\n"))
1262            .unwrap_or_default(),
1263        writer_target_interval_seconds = writer_target_interval_seconds,
1264        copy_into_sql = copy_into_sql,
1265        cdc_table_name = full_cdc_table_name,
1266        target_table_name = full_target_table_name,
1267        pk_names_str = pk_names_str,
1268        row_id_ordering = row_id_ordering,
1269        pk_names_eq_str = pk_names_eq_str,
1270        all_column_names_set_str = all_column_names_set_str,
1271        all_column_names_str = all_column_names_str,
1272        all_column_names_insert_str = all_column_names_insert_str,
1273        snowflake_sink_op = __OP,
1274    )
1275}
1276
1277#[cfg(test)]
1278mod tests {
1279    use std::collections::BTreeMap;
1280
1281    use super::*;
1282    use crate::sink::jdbc_jni_client::normalize_sql;
1283
1284    fn base_properties() -> BTreeMap<String, String> {
1285        BTreeMap::from([
1286            ("type".to_owned(), "append-only".to_owned()),
1287            ("jdbc.url".to_owned(), "jdbc:snowflake://account".to_owned()),
1288            ("username".to_owned(), "RW_USER".to_owned()),
1289        ])
1290    }
1291
1292    #[test]
1293    fn test_build_jdbc_props_password() {
1294        let mut props = base_properties();
1295        props.insert("password".to_owned(), "secret".to_owned());
1296        let config = SnowflakeV2Config::from_btreemap(&props).unwrap();
1297        let (url, connection_properties) = config.build_jdbc_connection_properties().unwrap();
1298        assert_eq!(url, "jdbc:snowflake://account");
1299        let map: BTreeMap<_, _> = connection_properties.into_iter().collect();
1300        assert_eq!(map.get("user"), Some(&"RW_USER".to_owned()));
1301        assert_eq!(map.get("password"), Some(&"secret".to_owned()));
1302        assert!(!map.contains_key("authenticator"));
1303    }
1304
1305    #[test]
1306    fn test_build_jdbc_props_key_pair_file() {
1307        let mut props = base_properties();
1308        props.insert(
1309            "auth.method".to_owned(),
1310            AUTH_METHOD_KEY_PAIR_FILE.to_owned(),
1311        );
1312        props.insert("private_key_file".to_owned(), "/tmp/rsa_key.p8".to_owned());
1313        props.insert("private_key_file_pwd".to_owned(), "dummy".to_owned());
1314        let config = SnowflakeV2Config::from_btreemap(&props).unwrap();
1315        let (url, connection_properties) = config.build_jdbc_connection_properties().unwrap();
1316        assert_eq!(url, "jdbc:snowflake://account");
1317        let map: BTreeMap<_, _> = connection_properties.into_iter().collect();
1318        assert_eq!(map.get("user"), Some(&"RW_USER".to_owned()));
1319        assert_eq!(
1320            map.get("private_key_file"),
1321            Some(&"/tmp/rsa_key.p8".to_owned())
1322        );
1323        assert_eq!(map.get("private_key_file_pwd"), Some(&"dummy".to_owned()));
1324    }
1325
1326    #[test]
1327    fn test_build_jdbc_props_key_pair_object() {
1328        let mut props = base_properties();
1329        props.insert(
1330            "auth.method".to_owned(),
1331            AUTH_METHOD_KEY_PAIR_OBJECT.to_owned(),
1332        );
1333        props.insert(
1334            "private_key_pem".to_owned(),
1335            "-----BEGIN PRIVATE KEY-----
1336...
1337-----END PRIVATE KEY-----"
1338                .to_owned(),
1339        );
1340        let config = SnowflakeV2Config::from_btreemap(&props).unwrap();
1341        let (url, connection_properties) = config.build_jdbc_connection_properties().unwrap();
1342        assert_eq!(url, "jdbc:snowflake://account");
1343        let map: BTreeMap<_, _> = connection_properties.into_iter().collect();
1344        assert_eq!(
1345            map.get("private_key_pem"),
1346            Some(
1347                &"-----BEGIN PRIVATE KEY-----
1348...
1349-----END PRIVATE KEY-----"
1350                    .to_owned()
1351            )
1352        );
1353        assert!(!map.contains_key("private_key_file"));
1354    }
1355
1356    #[test]
1357    fn test_snowflake_task_target_completion_interval_requires_serverless() {
1358        let mut props = base_properties();
1359        props.insert("password".to_owned(), "secret".to_owned());
1360        props.insert("type".to_owned(), "upsert".to_owned());
1361        props.insert(
1362            "task.target_completion_interval".to_owned(),
1363            "5 MINUTES".to_owned(),
1364        );
1365
1366        let err = SnowflakeV2Config::from_btreemap(&props).unwrap_err();
1367        assert!(
1368            err.as_report().to_string().contains(
1369                "`task.target_completion_interval` requires `task.serverless` to be true"
1370            )
1371        );
1372    }
1373
1374    #[test]
1375    fn test_snowflake_serverless_task_rejects_warehouse() {
1376        let mut props = base_properties();
1377        props.insert("password".to_owned(), "secret".to_owned());
1378        props.insert("type".to_owned(), "upsert".to_owned());
1379        props.insert("task.serverless".to_owned(), "true".to_owned());
1380        props.insert("warehouse".to_owned(), "test_warehouse".to_owned());
1381
1382        let err = SnowflakeV2Config::from_btreemap(&props).unwrap_err();
1383        assert!(
1384            err.as_report()
1385                .to_string()
1386                .contains("`task.serverless` must not be combined with `warehouse`")
1387        );
1388    }
1389
1390    #[test]
1391    fn test_snowflake_append_only_rejects_upsert_task_options() {
1392        for (key, value) in [
1393            ("intermediate.table.name", "test_intermediate"),
1394            ("write.target.interval.seconds", "3600"),
1395            ("warehouse", "test_warehouse"),
1396            ("task.serverless", "true"),
1397            ("task.target_completion_interval", "5 MINUTES"),
1398        ] {
1399            let mut props = base_properties();
1400            props.insert("password".to_owned(), "secret".to_owned());
1401            props.insert(key.to_owned(), value.to_owned());
1402
1403            let err = SnowflakeV2Config::from_btreemap(&props).unwrap_err();
1404            assert!(
1405                err.as_report().to_string().contains(
1406                    "`intermediate.table.name`, `write.target.interval.seconds`, `warehouse`, \
1407                 `task.serverless`, and `task.target_completion_interval` require `type` = upsert"
1408                ),
1409                "option {key} should be rejected for append-only sink"
1410            );
1411        }
1412    }
1413
1414    #[test]
1415    fn test_snowflake_upsert_requires_s3() {
1416        let mut props = base_properties();
1417        props.insert("password".to_owned(), "secret".to_owned());
1418        props.insert("type".to_owned(), "upsert".to_owned());
1419        props.insert("with_s3".to_owned(), "false".to_owned());
1420
1421        let err = SnowflakeV2Config::from_btreemap(&props).unwrap_err();
1422        assert!(
1423            err.as_report()
1424                .to_string()
1425                .contains("Snowflake upsert sinks require `with_s3 = true`")
1426        );
1427    }
1428
1429    #[test]
1430    fn test_snowflake_sink_commit_coordinator() {
1431        let snowflake_task_context = SnowflakeTaskContext {
1432            task_name: Some("test_task".to_owned()),
1433            cdc_table_name: Some("test_cdc_table".to_owned()),
1434            target_table_name: "test_target_table".to_owned(),
1435            writer_target_interval_seconds: 3600,
1436            warehouse: Some("test_warehouse".to_owned()),
1437            task_serverless: false,
1438            task_target_completion_interval: None,
1439            pk_column_names: Some(vec!["v1".to_owned()]),
1440            all_column_names: Some(vec!["v1".to_owned(), "v2".to_owned()]),
1441            database: "test_db".to_owned(),
1442            schema_name: "test_schema".to_owned(),
1443            schema: Schema { fields: vec![] },
1444            stage: Some("RW_S3_STAGE".to_owned()),
1445            pipe_name: None,
1446        };
1447        let task_sql = build_create_merge_into_task_sql(&snowflake_task_context);
1448        let expected = r#"CREATE OR REPLACE TASK "test_db"."test_schema"."test_task"
1449WAREHOUSE = test_warehouse
1450SCHEDULE = '3600 SECONDS'
1451OVERLAP_POLICY = NO_OVERLAP
1452AS
1453BEGIN
1454    COPY INTO "test_db"."test_schema"."test_cdc_table" FROM @"test_db"."test_schema"."RW_S3_STAGE"/test_target_table/ MATCH_BY_COLUMN_NAME = CASE_INSENSITIVE FILE_FORMAT = (TYPE = 'JSON');
1455
1456    MERGE INTO "test_db"."test_schema"."test_target_table" AS target
1457    USING (
1458        SELECT *
1459        FROM (
1460            SELECT *, ROW_NUMBER() OVER (PARTITION BY "v1" ORDER BY TRY_TO_NUMBER(SPLIT_PART("__row_id", '_', 1)) DESC NULLS LAST, TRY_TO_NUMBER(SPLIT_PART("__row_id", '_', 2)) DESC NULLS LAST, "__row_id" DESC) AS dedupe_id
1461            FROM "test_db"."test_schema"."test_cdc_table"
1462        ) AS subquery
1463        WHERE dedupe_id = 1
1464    ) AS source
1465    ON target."v1" = source."v1"
1466    WHEN MATCHED AND source."__op" IN (2, 4) THEN DELETE
1467    WHEN MATCHED AND source."__op" IN (1, 3) THEN UPDATE SET target."v1" = source."v1", target."v2" = source."v2"
1468    WHEN NOT MATCHED AND source."__op" IN (1, 3) THEN INSERT ("v1", "v2") VALUES (source."v1", source."v2");
1469
1470    DELETE FROM "test_db"."test_schema"."test_cdc_table";
1471END;"#;
1472        assert_eq!(normalize_sql(&task_sql), normalize_sql(expected));
1473    }
1474
1475    #[test]
1476    fn test_snowflake_sink_commit_coordinator_multi_pk() {
1477        let snowflake_task_context = SnowflakeTaskContext {
1478            task_name: Some("test_task_multi_pk".to_owned()),
1479            cdc_table_name: Some("cdc_multi_pk".to_owned()),
1480            target_table_name: "target_multi_pk".to_owned(),
1481            writer_target_interval_seconds: 300,
1482            warehouse: Some("multi_pk_warehouse".to_owned()),
1483            task_serverless: false,
1484            task_target_completion_interval: None,
1485            pk_column_names: Some(vec!["id1".to_owned(), "id2".to_owned()]),
1486            all_column_names: Some(vec!["id1".to_owned(), "id2".to_owned(), "val".to_owned()]),
1487            database: "test_db".to_owned(),
1488            schema_name: "test_schema".to_owned(),
1489            schema: Schema { fields: vec![] },
1490            stage: Some("RW_S3_STAGE".to_owned()),
1491            pipe_name: None,
1492        };
1493        let task_sql = build_create_merge_into_task_sql(&snowflake_task_context);
1494        let expected = r#"CREATE OR REPLACE TASK "test_db"."test_schema"."test_task_multi_pk"
1495WAREHOUSE = multi_pk_warehouse
1496SCHEDULE = '300 SECONDS'
1497OVERLAP_POLICY = NO_OVERLAP
1498AS
1499BEGIN
1500    COPY INTO "test_db"."test_schema"."cdc_multi_pk" FROM @"test_db"."test_schema"."RW_S3_STAGE"/target_multi_pk/ MATCH_BY_COLUMN_NAME = CASE_INSENSITIVE FILE_FORMAT = (TYPE = 'JSON');
1501
1502    MERGE INTO "test_db"."test_schema"."target_multi_pk" AS target
1503    USING (
1504        SELECT *
1505        FROM (
1506            SELECT *, ROW_NUMBER() OVER (PARTITION BY "id1", "id2" ORDER BY TRY_TO_NUMBER(SPLIT_PART("__row_id", '_', 1)) DESC NULLS LAST, TRY_TO_NUMBER(SPLIT_PART("__row_id", '_', 2)) DESC NULLS LAST, "__row_id" DESC) AS dedupe_id
1507            FROM "test_db"."test_schema"."cdc_multi_pk"
1508        ) AS subquery
1509        WHERE dedupe_id = 1
1510    ) AS source
1511    ON target."id1" = source."id1" AND target."id2" = source."id2"
1512    WHEN MATCHED AND source."__op" IN (2, 4) THEN DELETE
1513    WHEN MATCHED AND source."__op" IN (1, 3) THEN UPDATE SET target."id1" = source."id1", target."id2" = source."id2", target."val" = source."val"
1514    WHEN NOT MATCHED AND source."__op" IN (1, 3) THEN INSERT ("id1", "id2", "val") VALUES (source."id1", source."id2", source."val");
1515
1516    DELETE FROM "test_db"."test_schema"."cdc_multi_pk";
1517END;"#;
1518        assert_eq!(normalize_sql(&task_sql), normalize_sql(expected));
1519    }
1520
1521    #[test]
1522    fn test_snowflake_sink_commit_coordinator_serverless_task() {
1523        let snowflake_task_context = SnowflakeTaskContext {
1524            task_name: Some("test_serverless_task".to_owned()),
1525            cdc_table_name: Some("serverless_cdc_table".to_owned()),
1526            target_table_name: "serverless_target_table".to_owned(),
1527            writer_target_interval_seconds: 120,
1528            warehouse: None,
1529            task_serverless: true,
1530            task_target_completion_interval: Some("5 MINUTES".to_owned()),
1531            pk_column_names: Some(vec!["id".to_owned()]),
1532            all_column_names: Some(vec!["id".to_owned(), "val".to_owned()]),
1533            database: "test_db".to_owned(),
1534            schema_name: "test_schema".to_owned(),
1535            schema: Schema { fields: vec![] },
1536            stage: Some("RW_S3_STAGE".to_owned()),
1537            pipe_name: None,
1538        };
1539        let task_sql = build_create_merge_into_task_sql(&snowflake_task_context);
1540        let expected = r#"CREATE OR REPLACE TASK "test_db"."test_schema"."test_serverless_task"
1541TARGET_COMPLETION_INTERVAL = '5 MINUTES'
1542SCHEDULE = '120 SECONDS'
1543OVERLAP_POLICY = NO_OVERLAP
1544AS
1545BEGIN
1546    COPY INTO "test_db"."test_schema"."serverless_cdc_table" FROM @"test_db"."test_schema"."RW_S3_STAGE"/serverless_target_table/ MATCH_BY_COLUMN_NAME = CASE_INSENSITIVE FILE_FORMAT = (TYPE = 'JSON');
1547
1548    MERGE INTO "test_db"."test_schema"."serverless_target_table" AS target
1549    USING (
1550        SELECT *
1551        FROM (
1552            SELECT *, ROW_NUMBER() OVER (PARTITION BY "id" ORDER BY TRY_TO_NUMBER(SPLIT_PART("__row_id", '_', 1)) DESC NULLS LAST, TRY_TO_NUMBER(SPLIT_PART("__row_id", '_', 2)) DESC NULLS LAST, "__row_id" DESC) AS dedupe_id
1553            FROM "test_db"."test_schema"."serverless_cdc_table"
1554        ) AS subquery
1555        WHERE dedupe_id = 1
1556    ) AS source
1557    ON target."id" = source."id"
1558    WHEN MATCHED AND source."__op" IN (2, 4) THEN DELETE
1559    WHEN MATCHED AND source."__op" IN (1, 3) THEN UPDATE SET target."id" = source."id", target."val" = source."val"
1560    WHEN NOT MATCHED AND source."__op" IN (1, 3) THEN INSERT ("id", "val") VALUES (source."id", source."val");
1561
1562    DELETE FROM "test_db"."test_schema"."serverless_cdc_table";
1563END;"#;
1564        assert_eq!(normalize_sql(&task_sql), normalize_sql(expected));
1565    }
1566
1567    #[test]
1568    fn test_build_create_pipe_sql_stage_has_trailing_slash() {
1569        let sql = build_create_pipe_sql(
1570            "reservations_intermediate",
1571            "test_db",
1572            "test_schema",
1573            "RW_S3_STAGE",
1574            "reservations_pipe",
1575            "reservations",
1576        );
1577        assert!(
1578            sql.contains(r#"FROM @"test_db"."test_schema"."RW_S3_STAGE"/reservations/ "#),
1579            "unexpected pipe sql: {sql}"
1580        );
1581    }
1582
1583    #[test]
1584    fn test_convert_snowflake_decimal_data_type() {
1585        assert_eq!(
1586            convert_snowflake_data_type(&DataType::Decimal).unwrap(),
1587            "DECIMAL(38, 10)"
1588        );
1589    }
1590}