use faucet_common_spanner::SpannerConnection;
use faucet_core::DEFAULT_BATCH_SIZE;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct SpannerSinkConfig {
#[serde(flatten)]
pub connection: SpannerConnection,
pub table_name: String,
#[serde(default = "default_batch_size")]
pub batch_size: usize,
#[serde(default = "default_ddl_timeout_secs")]
pub ddl_timeout_secs: u64,
#[serde(flatten)]
pub write: faucet_core::WriteSpec,
}
fn default_batch_size() -> usize {
DEFAULT_BATCH_SIZE
}
fn default_ddl_timeout_secs() -> u64 {
300
}
impl SpannerSinkConfig {
pub fn new(
project_id: impl Into<String>,
instance: impl Into<String>,
database: impl Into<String>,
table_name: impl Into<String>,
) -> Self {
Self {
connection: SpannerConnection {
project_id: project_id.into(),
instance: instance.into(),
database: database.into(),
auth: Default::default(),
max_sessions: 100,
emulator_host: None,
},
table_name: table_name.into(),
batch_size: DEFAULT_BATCH_SIZE,
ddl_timeout_secs: default_ddl_timeout_secs(),
write: faucet_core::WriteSpec::default(),
}
}
pub fn with_batch_size(mut self, batch_size: usize) -> Self {
self.batch_size = batch_size;
self
}
pub fn with_emulator_host(mut self, host: impl Into<String>) -> Self {
self.connection.emulator_host = Some(host.into());
self
}
pub fn with_ddl_timeout_secs(mut self, secs: u64) -> Self {
self.ddl_timeout_secs = secs;
self
}
pub fn validate(&self) -> Result<(), faucet_core::FaucetError> {
self.connection.validate()?;
faucet_core::validate_batch_size(self.batch_size)?;
if self.table_name.trim().is_empty() {
return Err(faucet_core::FaucetError::Config(
"spanner sink: `table_name` must not be empty".into(),
));
}
self.write.validate()?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use faucet_core::{WriteMode, WriteSpec};
use serde_json::json;
fn base() -> SpannerSinkConfig {
SpannerSinkConfig::new("p", "i", "d", "events")
}
#[test]
fn default_config() {
let config = base();
assert_eq!(config.table_name, "events");
assert_eq!(config.batch_size, DEFAULT_BATCH_SIZE);
assert_eq!(config.ddl_timeout_secs, 300);
assert_eq!(config.connection.max_sessions, 100);
assert!(matches!(config.write.write_mode, WriteMode::Append));
assert!(config.validate().is_ok());
}
#[test]
fn builder_methods() {
let config = base()
.with_batch_size(50)
.with_emulator_host("localhost:9010")
.with_ddl_timeout_secs(10);
assert_eq!(config.batch_size, 50);
assert_eq!(
config.connection.emulator_host.as_deref(),
Some("localhost:9010")
);
assert_eq!(config.ddl_timeout_secs, 10);
}
#[test]
fn deserializes_flattened_connection_and_write_spec() {
let config: SpannerSinkConfig = serde_json::from_value(json!({
"project_id": "p",
"instance": "i",
"database": "d",
"table_name": "t",
"write_mode": "upsert",
"key": ["id"]
}))
.unwrap();
assert_eq!(config.connection.project_id, "p");
assert!(matches!(config.write.write_mode, WriteMode::Upsert));
assert_eq!(config.write.key, vec!["id".to_string()]);
assert_eq!(config.batch_size, DEFAULT_BATCH_SIZE);
assert!(config.validate().is_ok());
}
#[test]
fn validate_rejects_empty_table_name() {
let mut config = base();
config.table_name = " ".into();
assert!(config.validate().is_err());
}
#[test]
fn validate_rejects_upsert_without_key() {
let mut config = base();
config.write = WriteSpec {
write_mode: WriteMode::Upsert,
key: vec![],
delete_marker: None,
};
assert!(config.validate().is_err());
}
#[test]
fn validate_rejects_oversized_batch() {
let config = base().with_batch_size(faucet_core::MAX_BATCH_SIZE + 1);
assert!(config.validate().is_err());
}
#[test]
fn batch_size_zero_is_accepted_as_sentinel() {
let config = base().with_batch_size(0);
assert!(config.validate().is_ok());
}
#[test]
fn debug_does_not_leak_inline_key() {
let mut config = base();
config.connection.auth = faucet_common_spanner::SpannerCredentials::ServiceAccountKey {
json: "TOPSECRET".into(),
};
let rendered = format!("{config:?}");
assert!(!rendered.contains("TOPSECRET"));
}
}