use crate::config::SnowflakeStageConfig;
use arrow::array::RecordBatch;
use faucet_core::FaucetError;
use faucet_core::util::quote_ident;
use object_store::path::Path as ObjPath;
use object_store::{ObjectStore, ObjectStoreExt};
use parquet::arrow::ArrowWriter;
use parquet::basic::{Compression, ZstdLevel};
use parquet::file::properties::WriterProperties;
use std::sync::Arc;
use url::Url;
pub struct BulkStore {
pub store: Arc<dyn ObjectStore>,
pub base: ObjPath,
}
pub fn resolve_store(cfg: &SnowflakeStageConfig) -> Result<BulkStore, FaucetError> {
let url = Url::parse(&cfg.url).map_err(|e| {
FaucetError::Config(format!(
"snowflake bulk_load.url '{}' is not a valid URL: {e}",
cfg.url
))
})?;
let (store, base) = object_store::parse_url_opts(&url, &cfg.storage_options).map_err(|e| {
FaucetError::Sink(format!(
"snowflake bulk_load: cannot open object store for '{}': {e}",
cfg.url
))
})?;
Ok(BulkStore {
store: Arc::from(store),
base,
})
}
pub async fn upload(bs: &BulkStore, file: &str, bytes: Vec<u8>) -> Result<(), FaucetError> {
let mut full = bs.base.to_string();
if !full.is_empty() {
full.push('/');
}
full.push_str(file);
let path = ObjPath::from(full);
bs.store
.put(&path, object_store::PutPayload::from(bytes))
.await
.map_err(|e| FaucetError::Sink(format!("snowflake stage upload failed for {file}: {e}")))?;
Ok(())
}
pub fn encode_parquet(batch: &RecordBatch) -> Result<Vec<u8>, FaucetError> {
let props = WriterProperties::builder()
.set_compression(Compression::ZSTD(ZstdLevel::default()))
.build();
let mut buf: Vec<u8> = Vec::new();
{
let mut writer = ArrowWriter::try_new(&mut buf, batch.schema(), Some(props))
.map_err(|e| FaucetError::Sink(format!("parquet writer init failed: {e}")))?;
writer
.write(batch)
.map_err(|e| FaucetError::Sink(format!("parquet write failed: {e}")))?;
writer
.close()
.map_err(|e| FaucetError::Sink(format!("parquet finalize failed: {e}")))?;
}
Ok(buf)
}
pub fn build_copy_into(
database: &str,
schema: &str,
table: &str,
stage: &SnowflakeStageConfig,
file: &str,
) -> String {
let target = format!(
"{}.{}.{}",
quote_ident(database),
quote_ident(schema),
quote_ident(table)
);
let stage_name = stage.stage.trim_start_matches('@');
let mut sql = format!(
"COPY INTO {target} FROM @{stage_name}/{file} \
FILE_FORMAT = (TYPE = PARQUET) \
MATCH_BY_COLUMN_NAME = {}",
stage.match_by_column_name
);
if stage.purge {
sql.push_str(" PURGE = TRUE");
}
sql
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn stage() -> SnowflakeStageConfig {
SnowflakeStageConfig {
stage: "MY_STAGE".into(),
url: "s3://bucket/prefix/".into(),
storage_options: HashMap::new(),
match_by_column_name: "CASE_INSENSITIVE".into(),
purge: false,
}
}
#[test]
fn copy_into_quotes_target_and_references_stage_file() {
let sql = build_copy_into("db", "sc", "events", &stage(), "faucet-abc.parquet");
assert!(sql.contains(r#""db"."sc"."events""#), "{sql}");
assert!(sql.contains("@MY_STAGE/faucet-abc.parquet"), "{sql}");
assert!(sql.contains("TYPE = PARQUET"), "{sql}");
assert!(
sql.contains("MATCH_BY_COLUMN_NAME = CASE_INSENSITIVE"),
"{sql}"
);
assert!(!sql.contains("PURGE"), "{sql}");
}
#[test]
fn copy_into_adds_purge_when_requested() {
let mut s = stage();
s.purge = true;
let sql = build_copy_into("db", "sc", "t", &s, "f.parquet");
assert!(sql.contains("PURGE = TRUE"), "{sql}");
}
#[test]
fn copy_into_strips_leading_at_from_stage() {
let mut s = stage();
s.stage = "@FQ.SC.STG".into();
let sql = build_copy_into("db", "sc", "t", &s, "f.parquet");
assert!(sql.contains("@FQ.SC.STG/f.parquet"), "{sql}");
assert!(!sql.contains("@@"), "{sql}");
}
#[test]
fn copy_into_escapes_injection_in_table_name() {
let sql = build_copy_into("db", "sc", r#"ev"il"#, &stage(), "f.parquet");
assert!(sql.contains(r#""ev""il""#), "{sql}");
}
#[test]
fn resolve_store_rejects_a_bad_url() {
let mut s = stage();
s.url = "http:// not a url".into();
assert!(matches!(resolve_store(&s), Err(FaucetError::Config(_))));
}
#[test]
fn resolve_store_opens_a_local_file_backend() {
let dir = tempfile::tempdir().unwrap();
let mut s = stage();
s.url = format!("file://{}/", dir.path().to_str().unwrap());
assert!(resolve_store(&s).is_ok());
}
#[test]
fn encode_parquet_roundtrips_a_batch() {
use arrow::array::Int64Array;
use arrow::datatypes::{DataType, Field, Schema};
let schema = Arc::new(Schema::new(vec![Field::new("n", DataType::Int64, false)]));
let batch =
RecordBatch::try_new(schema, vec![Arc::new(Int64Array::from(vec![1, 2, 3]))]).unwrap();
let bytes = encode_parquet(&batch).unwrap();
assert_eq!(&bytes[..4], b"PAR1");
assert_eq!(&bytes[bytes.len() - 4..], b"PAR1");
}
}