1use std::collections::{BTreeMap, HashMap};
16use std::time::Duration;
17
18use anyhow::anyhow;
19use base64::Engine;
20use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
21use firestore::{FirestoreDb, FirestoreDbOptions};
22use futures::StreamExt;
23use gcloud_sdk::google::firestore::admin::v1::GetDatabaseRequest;
24use gcloud_sdk::google::firestore::admin::v1::database::DatabaseEdition;
25use gcloud_sdk::google::firestore::admin::v1::firestore_admin_client::FirestoreAdminClient;
26use gcloud_sdk::google::firestore::v1::value::ValueType;
27use gcloud_sdk::google::firestore::v1::{
28 ArrayValue, BatchWriteRequest, Document, MapValue, Value, Write, write,
29};
30use prost::Message as _;
31use risingwave_common::array::{Op, RowRef, StreamChunk};
32use risingwave_common::catalog::Schema;
33use risingwave_common::row::Row as _;
34use risingwave_common::types::{DataType, ScalarRefImpl, ToText};
35use risingwave_common::util::iter_util::ZipEqDebug;
36use serde::Deserialize;
37use serde_json::Value as JsonValue;
38use serde_with::{DisplayFromStr, serde_as};
39use thiserror_ext::AsReport;
40use with_options::WithOptions;
41
42use super::log_store::DeliveryFutureManagerAddFuture;
43use super::writer::{
44 AsyncTruncateLogSinkerOf, AsyncTruncateSinkWriter, AsyncTruncateSinkWriterExt,
45};
46use super::{Result, Sink, SinkError, SinkParam, SinkWriterParam};
47use crate::enforce_secret::EnforceSecret;
48
49pub const FIRESTORE_SINK: &str = "firestore";
50
51const DEFAULT_MAX_BATCH_SIZE: usize = 500; const MAX_DOCUMENT_SIZE: usize = 1_048_576;
53const MAX_BATCH_WRITE_BYTES: usize = 9 * 1024 * 1024;
55const DATABASE_METADATA_TIMEOUT: Duration = Duration::from_secs(10);
56
57#[derive(Deserialize, Debug, Clone, Copy, Default, PartialEq, Eq)]
58#[serde(rename_all = "lowercase")]
59pub enum FirestoreDecimalMode {
60 #[default]
61 Decimal128,
62 Double,
63}
64
65impl crate::with_options::WithOptions for FirestoreDecimalMode {}
66
67#[serde_as]
68#[derive(Deserialize, Debug, Clone, WithOptions)]
69pub struct FirestoreConfig {
70 #[serde(rename = "firestore.project_id")]
72 pub project_id: String,
73
74 #[serde(rename = "firestore.collection")]
76 pub collection: String,
77
78 #[serde(rename = "firestore.database_id")]
80 pub database_id: Option<String>,
81
82 #[serde(rename = "firestore.credentials")]
85 pub credentials: Option<String>,
86
87 #[serde(rename = "firestore.document_id_column")]
91 pub document_id_column: Option<String>,
92
93 #[serde(rename = "firestore.skip_invalid_rows", default)]
96 #[serde_as(as = "DisplayFromStr")]
97 pub skip_invalid_rows: bool,
98
99 #[serde(rename = "firestore.decimal_mode", default)]
102 pub decimal_mode: FirestoreDecimalMode,
103
104 #[serde(
106 rename = "firestore.max_batch_size",
107 default = "default_firestore_max_batch_size"
108 )]
109 #[serde_as(as = "DisplayFromStr")]
110 pub max_batch_size: usize,
111
112 #[serde(
115 rename = "firestore.max_future_send_nums",
116 default = "default_firestore_max_future_send_nums"
117 )]
118 #[serde_as(as = "DisplayFromStr")]
119 pub max_future_send_nums: usize,
120}
121
122fn default_firestore_max_batch_size() -> usize {
123 500
124}
125
126fn default_firestore_max_future_send_nums() -> usize {
127 256
128}
129
130impl EnforceSecret for FirestoreConfig {
131 const ENFORCE_SECRET_PROPERTIES: phf::Set<&'static str> = phf::phf_set! {
132 "firestore.credentials",
133 };
134}
135
136impl FirestoreConfig {
137 pub async fn build_client(&self) -> Result<FirestoreDb> {
138 self.build_client_with_emulator(std::env::var("FIRESTORE_EMULATOR_HOST").ok())
139 .await
140 }
141
142 async fn build_client_with_emulator(
143 &self,
144 emulator_host: Option<String>,
145 ) -> Result<FirestoreDb> {
146 let mut options = if let Some(ref database_id) = self.database_id {
147 FirestoreDbOptions::new(self.project_id.clone()).with_database_id(database_id.clone())
148 } else {
149 FirestoreDbOptions::new(self.project_id.clone())
150 };
151
152 if let Some(ref host) = emulator_host {
153 options.firebase_api_url = Some(format!("http://{host}"));
154 }
155 let (scopes, token_source) = self.token_source(emulator_host.is_some());
156 FirestoreDb::with_options_token_source(options, scopes, token_source)
157 .await
158 .map_err(|e| {
159 SinkError::Firestore(anyhow!(e).context("failed to create Firestore client"))
160 })
161 }
162
163 fn token_source(&self, emulator: bool) -> (Vec<String>, gcloud_sdk::TokenSourceType) {
164 use gcloud_sdk::{ExternalJwtFunctionSource, GCP_DEFAULT_SCOPES, Token, TokenSourceType};
165 if emulator {
166 let source = ExternalJwtFunctionSource::new(|| async {
169 Ok(Token::new(
170 "Bearer".to_owned(),
171 "owner".to_owned().into(),
172 chrono::Utc::now() + chrono::Duration::hours(1),
173 ))
174 });
175 (vec![], TokenSourceType::ExternalSource(Box::new(source)))
176 } else if let Some(ref cred) = self.credentials {
177 (
178 GCP_DEFAULT_SCOPES.clone(),
179 TokenSourceType::Json(cred.clone()),
180 )
181 } else {
182 (GCP_DEFAULT_SCOPES.clone(), TokenSourceType::Default)
183 }
184 }
185
186 async fn validate_decimal_mode(&self, schema: &Schema, client: &FirestoreDb) -> Result<()> {
187 if self.decimal_mode == FirestoreDecimalMode::Double
188 || !schema
189 .fields()
190 .iter()
191 .any(|field| contains_decimal(&field.data_type))
192 {
193 return Ok(());
194 }
195 let edition =
196 tokio::time::timeout(DATABASE_METADATA_TIMEOUT, self.database_edition(client))
197 .await
198 .map_err(|_| {
199 SinkError::Firestore(anyhow!("Firestore database metadata request timed out"))
200 })??;
201 match edition.and_then(|edition| DatabaseEdition::try_from(edition).ok()) {
202 Some(DatabaseEdition::Standard) => Err(SinkError::Config(anyhow!(
203 "firestore.decimal_mode='decimal128' requires Firestore Enterprise edition; \
204 this database is Standard. Set firestore.decimal_mode='double' to opt into \
205 floating-point conversion (which may lose precision), or cast DECIMAL to VARCHAR"
206 ))),
207 Some(DatabaseEdition::Enterprise) => Ok(()),
208 _ => {
209 tracing::warn!(database = %client.get_database_path(), "Firestore database edition is unknown; keeping firestore.decimal_mode='decimal128', without falling back to double");
210 Ok(())
211 }
212 }
213 }
214
215 async fn database_edition(&self, client: &FirestoreDb) -> Result<Option<i32>> {
216 let options = client.get_options();
217 let (scopes, token_source) = self.token_source(options.firebase_api_url.is_some());
218 let admin = gcloud_sdk::GoogleApi::from_function_with_token_source(
219 FirestoreAdminClient::new,
220 options
221 .firebase_api_url
222 .as_deref()
223 .unwrap_or("https://firestore.googleapis.com"),
224 Some(client.get_database_path().to_owned()),
225 scopes,
226 token_source,
227 )
228 .await
229 .map_err(|error| {
230 SinkError::Firestore(
231 anyhow!(error).context("failed to create Firestore metadata client"),
232 )
233 })?;
234 let mut request = tonic::Request::new(GetDatabaseRequest {
235 name: client.get_database_path().to_owned(),
236 });
237 request.set_timeout(DATABASE_METADATA_TIMEOUT);
238 match admin.get().get_database(request).await {
239 Ok(response) => Ok(Some(response.into_inner().database_edition)),
240 Err(error)
241 if matches!(
242 error.code(),
243 tonic::Code::PermissionDenied | tonic::Code::Unimplemented
244 ) =>
245 {
246 tracing::warn!(database = %client.get_database_path(), code = ?error.code(), "Cannot read Firestore database edition; metadata requires datastore.databases.getMetadata and may be unsupported by the emulator");
247 Ok(None)
248 }
249 Err(error) => Err(SinkError::Firestore(
250 anyhow!(error).context("failed to read Firestore database edition"),
251 )),
252 }
253 }
254
255 fn validate_schema(&self, schema: &Schema, pk_indices: &[usize]) -> Result<Option<usize>> {
256 parse_collection_path(&self.collection, schema)?;
257 for (name, value) in [
258 ("project_id", Some(&self.project_id)),
259 ("database_id", self.database_id.as_ref()),
260 ] {
261 if value.is_some_and(|value| value.is_empty() || value.contains('/')) {
262 return Err(SinkError::Config(anyhow!(
263 "firestore.{} must be a nonempty resource ID without '/'",
264 name
265 )));
266 }
267 }
268 if !(1..=DEFAULT_MAX_BATCH_SIZE).contains(&self.max_batch_size) {
269 return Err(SinkError::Config(anyhow!(
270 "firestore.max_batch_size must be between 1 and {}",
271 DEFAULT_MAX_BATCH_SIZE
272 )));
273 }
274 if self.max_future_send_nums == 0 {
275 return Err(SinkError::Config(anyhow!(
276 "firestore.max_future_send_nums must be greater than 0"
277 )));
278 }
279 for field in schema.fields() {
280 validate_column_type(&field.data_type, &field.name)?;
281 }
282 if let Some(name) = &self.document_id_column {
283 return schema
284 .fields()
285 .iter()
286 .position(|f| &f.name == name)
287 .map(Some)
288 .ok_or_else(|| {
289 SinkError::Config(anyhow!("document_id_column '{}' not found in schema", name))
290 });
291 }
292 if pk_indices.is_empty() {
293 return Err(SinkError::Config(anyhow!(
294 "Either document_id_column or primary key must be specified"
295 )));
296 }
297 Ok(None)
298 }
299
300 fn from_btreemap(values: BTreeMap<String, String>) -> Result<Self> {
301 serde_json::from_value::<FirestoreConfig>(serde_json::to_value(values).unwrap())
302 .map_err(|e| SinkError::Config(anyhow!(e)))
303 }
304}
305
306#[derive(Clone, Debug)]
307pub struct FirestoreSink {
308 pub config: FirestoreConfig,
309 schema: Schema,
310 pk_indices: Vec<usize>,
311}
312
313impl EnforceSecret for FirestoreSink {
314 fn enforce_secret<'a>(
315 prop_iter: impl Iterator<Item = &'a str>,
316 ) -> crate::error::ConnectorResult<()> {
317 for prop in prop_iter {
318 FirestoreConfig::enforce_one(prop)?;
319 }
320 Ok(())
321 }
322}
323
324impl Sink for FirestoreSink {
325 type LogSinker = AsyncTruncateLogSinkerOf<FirestoreSinkWriter>;
326
327 const SINK_NAME: &'static str = FIRESTORE_SINK;
328
329 async fn validate(&self) -> Result<()> {
330 risingwave_common::license::Feature::FirestoreSink
331 .check_available()
332 .map_err(|e| anyhow!(e))?;
333 self.config
334 .validate_schema(&self.schema, &self.pk_indices)?;
335 let client = self.config.build_client().await?;
336 self.config
337 .validate_decimal_mode(&self.schema, &client)
338 .await?;
339
340 Ok(())
341 }
342
343 async fn new_log_sinker(&self, _writer_param: SinkWriterParam) -> Result<Self::LogSinker> {
344 Ok(FirestoreSinkWriter::new(
345 self.config.clone(),
346 self.schema.clone(),
347 self.pk_indices.clone(),
348 )
349 .await?
350 .into_log_sinker(1))
352 }
353}
354
355impl TryFrom<SinkParam> for FirestoreSink {
356 type Error = SinkError;
357
358 fn try_from(param: SinkParam) -> std::result::Result<Self, Self::Error> {
359 let schema = param.schema();
360 let pk_indices = param.downstream_pk_or_empty();
361 let config = FirestoreConfig::from_btreemap(param.properties)?;
362
363 Ok(Self {
364 config,
365 schema,
366 pk_indices,
367 })
368 }
369}
370
371pub struct FirestoreSinkWriter {
372 client: FirestoreDb,
373 config: FirestoreConfig,
374 schema: Schema,
375 pk_indices: Vec<usize>,
376 doc_id_col_idx: Option<usize>,
377 collection_path: Vec<CollectionPathSegment>,
378}
379
380impl FirestoreSinkWriter {
381 pub async fn new(
382 config: FirestoreConfig,
383 schema: Schema,
384 pk_indices: Vec<usize>,
385 ) -> Result<Self> {
386 let doc_id_col_idx = config.validate_schema(&schema, &pk_indices)?;
387 let collection_path = parse_collection_path(&config.collection, &schema)?;
388 let client = config.build_client().await?;
389 config.validate_decimal_mode(&schema, &client).await?;
390
391 Ok(Self {
392 client,
393 config,
394 schema,
395 pk_indices,
396 doc_id_col_idx,
397 collection_path,
398 })
399 }
400
401 fn get_document_id(&self, row: RowRef<'_>) -> Result<String> {
402 if let Some(idx) = self.doc_id_col_idx {
403 let datum = row.datum_at(idx);
405 match datum {
406 Some(scalar) => {
407 let doc_id = scalar.to_text();
408 validate_firestore_document_id(&doc_id)?;
409 Ok(doc_id)
410 }
411 None => Err(SinkError::Firestore(anyhow!(
412 "document_id_column value is null"
413 ))),
414 }
415 } else {
416 let pk_values: Vec<JsonValue> = self
419 .pk_indices
420 .iter()
421 .map(|&idx| {
422 let field = &self.schema.fields()[idx];
423 row.datum_at(idx)
424 .map(|s| JsonValue::String(s.to_text_with_type(&field.data_type)))
425 .unwrap_or(JsonValue::Null)
426 })
427 .collect();
428 encode_pk_document_id(pk_values)
429 }
430 }
431
432 fn format_row(&self, row: RowRef<'_>) -> Result<HashMap<String, Value>> {
433 let mut map = HashMap::new();
434 for (datum, field) in row.iter().zip_eq_debug(self.schema.fields()) {
435 let value = format_datum(datum, &field.data_type, self.config.decimal_mode)?;
436 map.insert(field.name.clone(), value);
437 }
438 Ok(map)
439 }
440
441 fn get_document_path(&self, row: RowRef<'_>) -> Result<String> {
442 let mut segments = Vec::with_capacity(self.collection_path.len() + 1);
443 for segment in &self.collection_path {
444 segments.push(match segment {
445 CollectionPathSegment::Literal(value) => value.clone(),
446 CollectionPathSegment::Column(index) => {
447 let value = row
448 .datum_at(*index)
449 .ok_or_else(|| {
450 SinkError::Firestore(anyhow!("Firestore parent document ID is null"))
451 })?
452 .to_text();
453 validate_firestore_document_id(&value)?;
454 value
455 }
456 });
457 }
458 segments.push(self.get_document_id(row)?);
459 let path = segments.join("/");
460 if self.client.get_documents_path().len() + 1 + path.len() > 6 * 1024 {
461 return Err(SinkError::Firestore(anyhow!(
462 "Firestore document name exceeds 6 KiB"
463 )));
464 }
465 Ok(path)
466 }
467
468 fn write_chunk_inner(&self, chunk: StreamChunk) -> Result<Vec<FirestoreOperation>> {
469 let mut operations = BTreeMap::new();
470
471 for (op, row) in chunk.rows() {
472 let operation: Result<FirestoreOperation> = (|| {
473 let path = self.get_document_path(row)?;
474 Ok(match op {
475 Op::Insert | Op::UpdateInsert => {
476 let data = self.format_row(row)?;
477 validate_document_size(&path, &data)?;
478 FirestoreOperation::Upsert { path, data }
479 }
480 Op::Delete | Op::UpdateDelete => FirestoreOperation::Delete { path },
481 })
482 })();
483 match operation {
484 Ok(operation) => {
485 operations.insert(operation.document_path().to_owned(), operation);
486 }
487 Err(error) if self.config.skip_invalid_rows => {
488 tracing::warn!(error = %error.as_report(), "skipping invalid Firestore row");
489 }
490 Err(error) => return Err(error),
491 }
492 }
493
494 Ok(operations.into_values().collect())
495 }
496
497 async fn write_operations(&self, operations: Vec<FirestoreOperation>) -> Result<()> {
498 let writes = operations
499 .into_iter()
500 .map(|op| {
501 let name = format!(
502 "{}/{}",
503 self.client.get_documents_path(),
504 op.document_path()
505 );
506 let operation = match op {
507 FirestoreOperation::Upsert { data, .. } => write::Operation::Update(Document {
508 name,
509 fields: data,
510 ..Default::default()
511 }),
512 FirestoreOperation::Delete { .. } => write::Operation::Delete(name),
513 };
514 Write {
515 operation: Some(operation),
516 ..Default::default()
517 }
518 })
519 .collect::<Vec<_>>();
520 let batches = split_write_batches(writes, self.config.max_batch_size);
521 let requests = batches.into_iter().map(|writes| self.write_batch(writes));
522
523 futures::stream::iter(requests)
526 .buffer_unordered(self.config.max_future_send_nums)
527 .fold(Ok(()), |result, next| async move { result.and(next) })
528 .await
529 }
530
531 async fn write_batch(&self, writes: Vec<Write>) -> Result<()> {
532 let request = BatchWriteRequest {
533 database: self.client.get_database_path().clone(),
534 writes,
535 ..Default::default()
536 };
537 let response = self
538 .client
539 .client()
540 .get()
541 .batch_write(request.clone())
542 .await;
543 match response {
544 Ok(response) => {
545 let paths = request
546 .writes
547 .iter()
548 .map(write_document_path)
549 .collect::<Vec<_>>();
550 check_batch_statuses(
551 &response.into_inner().status,
552 &paths,
553 self.config.skip_invalid_rows,
554 )
555 }
556 Err(error)
557 if self.config.skip_invalid_rows && is_invalid_row_code(error.code() as i32) =>
558 {
559 for write in request.writes {
562 let path = write_document_path(&write);
563 let response = self
564 .client
565 .client()
566 .get()
567 .batch_write(BatchWriteRequest {
568 database: request.database.clone(),
569 writes: vec![write],
570 ..Default::default()
571 })
572 .await;
573 match response {
574 Ok(response) => {
575 check_batch_statuses(&response.into_inner().status, &[path], true)?
576 }
577 Err(error) if is_invalid_row_code(error.code() as i32) => {
578 tracing::warn!(document = %path, error = %error.as_report(), "skipping rejected Firestore row");
579 }
580 Err(error) => return Err(SinkError::Firestore(anyhow!(error))),
581 }
582 }
583 Ok(())
584 }
585 Err(error) => Err(SinkError::Firestore(
586 anyhow!(error).context("failed to execute Firestore batch write"),
587 )),
588 }
589 }
590}
591
592#[derive(Clone)]
593enum CollectionPathSegment {
594 Literal(String),
595 Column(usize),
596}
597
598fn parse_collection_path(path: &str, schema: &Schema) -> Result<Vec<CollectionPathSegment>> {
599 let segments = path.split('/').collect::<Vec<_>>();
600 if segments.len() % 2 != 1 || segments.len() > 201 {
601 return Err(SinkError::Config(anyhow!(
602 "firestore.collection must be a collection path with at most 100 subcollections"
603 )));
604 }
605 segments
606 .into_iter()
607 .enumerate()
608 .map(|(position, segment)| {
609 if let Some(column) = segment.strip_prefix('{').and_then(|s| s.strip_suffix('}')) {
610 if position % 2 == 0 {
611 return Err(SinkError::Config(anyhow!(
612 "collection placeholders must be parent document segments"
613 )));
614 }
615 let index = schema
616 .fields()
617 .iter()
618 .position(|f| f.name == column)
619 .ok_or_else(|| {
620 SinkError::Config(anyhow!(
621 "collection path column '{}' not found in schema",
622 column
623 ))
624 })?;
625 Ok(CollectionPathSegment::Column(index))
626 } else {
627 if segment.contains(['{', '}']) {
628 return Err(SinkError::Config(anyhow!(
629 "collection placeholders must occupy a complete path segment"
630 )));
631 }
632 validate_firestore_document_id(segment)
633 .map_err(|error| SinkError::Config(anyhow!(error)))?;
634 Ok(CollectionPathSegment::Literal(segment.to_owned()))
635 }
636 })
637 .collect()
638}
639
640fn is_invalid_row_code(code: i32) -> bool {
641 matches!(code, 3 | 11) }
643
644fn write_document_path(write: &Write) -> String {
645 match write
646 .operation
647 .as_ref()
648 .expect("Firestore write has an operation")
649 {
650 write::Operation::Update(document) => document.name.clone(),
651 write::Operation::Delete(path) => path.clone(),
652 write::Operation::Transform(_) => unreachable!("sink only emits upserts and deletes"),
653 }
654}
655
656fn split_write_batches(writes: Vec<Write>, max_count: usize) -> Vec<Vec<Write>> {
657 let mut batches = Vec::new();
658 let mut batch = Vec::new();
659 let mut bytes = 0;
660 for write in writes {
661 let size = write.encoded_len() + 5;
662 if !batch.is_empty() && (batch.len() >= max_count || bytes + size > MAX_BATCH_WRITE_BYTES) {
663 batches.push(std::mem::take(&mut batch));
664 bytes = 0;
665 }
666 bytes += size;
667 batch.push(write);
668 }
669 if !batch.is_empty() {
670 batches.push(batch);
671 }
672 batches
673}
674
675fn firestore_fields_size(fields: &HashMap<String, Value>) -> usize {
678 fields
679 .iter()
680 .map(|(name, value)| name.len() + 1 + firestore_value_size(value))
681 .sum()
682}
683
684fn firestore_value_size(value: &Value) -> usize {
685 match value.value_type.as_ref().expect("encoded Firestore value") {
686 ValueType::NullValue(_) | ValueType::BooleanValue(_) => 1,
687 ValueType::IntegerValue(_) | ValueType::DoubleValue(_) | ValueType::TimestampValue(_) => 8,
688 ValueType::StringValue(value) => value.len() + 1,
689 ValueType::BytesValue(value) => value.len(),
690 ValueType::ArrayValue(value) => value.values.iter().map(firestore_value_size).sum(),
691 ValueType::MapValue(value) => firestore_fields_size(&value.fields) + 32,
692 ValueType::GeoPointValue(_) => 16,
693 ValueType::ReferenceValue(value) => value.len() + 16,
694 ValueType::FieldReferenceValue(_)
695 | ValueType::FunctionValue(_)
696 | ValueType::PipelineValue(_) => {
697 unreachable!("query-only Firestore values are never emitted by the row encoder")
698 }
699 }
700}
701
702fn validate_document_size(path: &str, fields: &HashMap<String, Value>) -> Result<()> {
703 let name_size = path.split('/').map(|part| part.len() + 1).sum::<usize>() + 16;
704 if name_size + firestore_fields_size(fields) + 32 > MAX_DOCUMENT_SIZE {
705 return Err(SinkError::Firestore(anyhow!(
706 "Firestore document exceeds 1 MiB"
707 )));
708 }
709 Ok(())
710}
711
712fn check_batch_statuses(
713 statuses: &[gcloud_sdk::google::rpc::Status],
714 document_ids: &[String],
715 skip_invalid_rows: bool,
716) -> Result<()> {
717 if statuses.len() != document_ids.len() {
718 return Err(SinkError::Firestore(anyhow!(
719 "Firestore batch write returned {} statuses for {} writes",
720 statuses.len(),
721 document_ids.len()
722 )));
723 }
724 for (status, document_id) in statuses.iter().zip_eq_debug(document_ids) {
725 if status.code != 0 {
726 if skip_invalid_rows && is_invalid_row_code(status.code) {
727 tracing::warn!(document = %document_id, code = status.code, message = %status.message, "skipping rejected Firestore row");
728 continue;
729 }
730 return Err(SinkError::Firestore(anyhow!(
731 "Firestore batch write failed for document '{}': code={}, message={}",
732 document_id,
733 status.code,
734 status.message
735 )));
736 }
737 }
738 Ok(())
739}
740
741#[derive(Clone)]
742enum FirestoreOperation {
743 Upsert {
744 path: String,
745 data: HashMap<String, Value>,
746 },
747 Delete {
748 path: String,
749 },
750}
751
752impl FirestoreOperation {
753 fn document_path(&self) -> &str {
754 match self {
755 Self::Upsert { path, .. } | Self::Delete { path } => path,
756 }
757 }
758}
759
760fn format_datum(
763 datum: Option<ScalarRefImpl<'_>>,
764 data_type: &DataType,
765 decimal_mode: FirestoreDecimalMode,
766) -> Result<Value> {
767 format_datum_at_depth(datum, data_type, decimal_mode, 0)
768}
769
770fn validate_column_type(data_type: &DataType, column_path: &str) -> Result<()> {
772 match data_type {
773 DataType::Map(_) | DataType::Vector(_) | DataType::Variant => {
774 Err(SinkError::Config(anyhow!(
775 "Firestore sink column '{}' has unsupported type {}",
776 column_path,
777 data_type
778 )))
779 }
780 DataType::List(inner) if matches!(inner.elem(), DataType::List(_)) => {
781 Err(SinkError::Config(anyhow!(
782 "Firestore sink column '{}' has unsupported type {}: arrays cannot directly contain arrays",
783 column_path,
784 data_type
785 )))
786 }
787 DataType::List(inner) => validate_column_type(inner.elem(), &format!("{column_path}[]")),
788 DataType::Struct(st) => st.iter().try_for_each(|(name, data_type)| {
789 validate_column_type(data_type, &format!("{column_path}.{name}"))
790 }),
791 _ => Ok(()),
792 }
793}
794
795fn contains_decimal(data_type: &DataType) -> bool {
796 match data_type {
797 DataType::Decimal => true,
798 DataType::List(list) => contains_decimal(list.elem()),
799 DataType::Struct(struct_type) => struct_type.types().any(contains_decimal),
800 _ => false,
801 }
802}
803
804fn check_container_depth(depth: usize) -> Result<()> {
805 if depth >= 20 {
806 return Err(SinkError::Firestore(anyhow!(
807 "Firestore maps and arrays must not exceed 20 levels"
808 )));
809 }
810 Ok(())
811}
812
813fn array_value(values: Vec<Value>) -> Result<ValueType> {
814 if values
815 .iter()
816 .any(|v| matches!(v.value_type, Some(ValueType::ArrayValue(_))))
817 {
818 return Err(SinkError::Firestore(anyhow!(
819 "Firestore arrays cannot directly contain arrays"
820 )));
821 }
822 Ok(ValueType::ArrayValue(ArrayValue { values }))
823}
824
825fn format_datum_at_depth(
826 datum: Option<ScalarRefImpl<'_>>,
827 data_type: &DataType,
828 decimal_mode: FirestoreDecimalMode,
829 depth: usize,
830) -> Result<Value> {
831 let Some(scalar) = datum else {
832 return Ok(Value {
833 value_type: Some(ValueType::NullValue(0)),
834 });
835 };
836 let value_type = match data_type {
837 DataType::Boolean => ValueType::BooleanValue(scalar.into_bool()),
838 DataType::Int16 => ValueType::IntegerValue(scalar.into_int16().into()),
839 DataType::Int32 => ValueType::IntegerValue(scalar.into_int32().into()),
840 DataType::Int64 => ValueType::IntegerValue(scalar.into_int64()),
841 DataType::Float32 => ValueType::DoubleValue(scalar.into_float32().0.into()),
842 DataType::Float64 => ValueType::DoubleValue(scalar.into_float64().0),
843 DataType::Decimal => match decimal_mode {
844 FirestoreDecimalMode::Decimal128 => ValueType::MapValue(MapValue {
849 fields: HashMap::from([(
850 "__decimal128__".to_owned(),
851 Value {
852 value_type: Some(ValueType::StringValue(scalar.into_decimal().to_string())),
853 },
854 )]),
855 }),
856 FirestoreDecimalMode::Double => ValueType::DoubleValue(
857 f64::try_from(scalar.into_decimal())
858 .map_err(|error| SinkError::Firestore(anyhow!(error)))?,
859 ),
860 },
861 DataType::Timestamp => {
862 let time = scalar.into_timestamp().0.and_utc();
865 firestore_timestamp(time.timestamp(), time.timestamp_subsec_nanos())?
866 }
867 DataType::Timestamptz => {
868 let time = scalar.into_timestamptz();
869 firestore_timestamp(time.timestamp(), time.timestamp_subsec_nanos())?
870 }
871 DataType::Int256
872 | DataType::Serial
873 | DataType::Interval
874 | DataType::Date
875 | DataType::Time => ValueType::StringValue(scalar.to_text_with_type(data_type)),
876 DataType::Varchar => ValueType::StringValue(scalar.into_utf8().to_owned()),
877 DataType::Bytea => ValueType::StringValue(STANDARD.encode(scalar.into_bytea())),
878 DataType::Jsonb => return format_jsonb(scalar.into_jsonb().into(), depth),
879 DataType::List(inner) => {
880 check_container_depth(depth)?;
881 let values = scalar
882 .into_list()
883 .iter()
884 .map(|item| format_datum_at_depth(item, inner.elem(), decimal_mode, depth + 1))
885 .collect::<Result<Vec<_>>>()?;
886 array_value(values)?
887 }
888 DataType::Struct(st) => {
889 check_container_depth(depth)?;
890 let mut fields = HashMap::new();
891 for (datum, (name, dt)) in scalar
892 .into_struct()
893 .iter_fields_ref()
894 .zip_eq_debug(st.iter())
895 {
896 fields.insert(
897 name.to_owned(),
898 format_datum_at_depth(datum, dt, decimal_mode, depth + 1)?,
899 );
900 }
901 ValueType::MapValue(MapValue { fields })
902 }
903 DataType::Map(_) | DataType::Vector(_) | DataType::Variant => {
904 return Err(SinkError::Firestore(anyhow!(
905 "{} is not supported by Firestore sink",
906 data_type
907 )));
908 }
909 };
910 Ok(Value {
911 value_type: Some(value_type),
912 })
913}
914
915fn firestore_timestamp(seconds: i64, nanos: u32) -> Result<ValueType> {
916 if !(-62_135_596_800..=253_402_300_799).contains(&seconds) || nanos >= 1_000_000_000 {
917 return Err(SinkError::Firestore(anyhow!(
918 "Firestore timestamp must be between years 0001 and 9999"
919 )));
920 }
921 Ok(ValueType::TimestampValue(prost_types::Timestamp {
922 seconds,
923 nanos: (nanos / 1000 * 1000) as i32,
924 }))
925}
926
927fn format_jsonb(value: jsonbb::ValueRef<'_>, depth: usize) -> Result<Value> {
928 let value_type =
929 match value {
930 jsonbb::ValueRef::Null => ValueType::NullValue(0),
931 jsonbb::ValueRef::Bool(v) => ValueType::BooleanValue(v),
932 jsonbb::ValueRef::String(v) => ValueType::StringValue(v.as_str().to_owned()),
933 jsonbb::ValueRef::Number(v) => {
934 if let Some(v) = v.as_i64() {
935 ValueType::IntegerValue(v)
936 } else if v.as_u64().is_some() {
937 return Err(SinkError::Firestore(anyhow!(
938 "JSONB integer exceeds Firestore signed 64-bit range"
939 )));
940 } else {
941 ValueType::DoubleValue(v.as_f64().ok_or_else(|| {
942 SinkError::Firestore(anyhow!("JSONB number out of range"))
943 })?)
944 }
945 }
946 jsonbb::ValueRef::Array(v) => {
947 check_container_depth(depth)?;
948 array_value(
949 v.iter()
950 .map(|v| format_jsonb(v, depth + 1))
951 .collect::<Result<_>>()?,
952 )?
953 }
954 jsonbb::ValueRef::Object(v) => {
955 check_container_depth(depth)?;
956 let fields = v
957 .iter()
958 .map(|(k, v)| Ok((k.to_owned(), format_jsonb(v, depth + 1)?)))
959 .collect::<Result<_>>()?;
960 ValueType::MapValue(MapValue { fields })
961 }
962 };
963 Ok(Value {
964 value_type: Some(value_type),
965 })
966}
967
968fn encode_pk_document_id(pk_values: Vec<JsonValue>) -> Result<String> {
969 let doc_id =
970 URL_SAFE_NO_PAD.encode(serde_json::to_vec(&pk_values).map_err(|e| {
971 SinkError::Firestore(anyhow!(e).context("failed to encode primary key"))
972 })?);
973 validate_firestore_document_id(&doc_id)?;
974 Ok(doc_id)
975}
976
977fn validate_firestore_document_id(doc_id: &str) -> Result<()> {
978 if doc_id.len() > 1500 || (doc_id.starts_with("__") && doc_id.ends_with("__")) {
979 return Err(SinkError::Firestore(anyhow!(
980 "Firestore document ID exceeds 1500 bytes or uses a reserved name"
981 )));
982 }
983 if doc_id.is_empty() {
984 return Err(SinkError::Firestore(anyhow!(
985 "Firestore document ID must not be empty"
986 )));
987 }
988 if doc_id.contains('/') {
989 return Err(SinkError::Firestore(anyhow!(
990 "Firestore document ID must not contain '/'"
991 )));
992 }
993 if doc_id == "." || doc_id == ".." {
994 return Err(SinkError::Firestore(anyhow!(
995 "Firestore document ID must not be '.' or '..'"
996 )));
997 }
998 Ok(())
999}
1000
1001impl AsyncTruncateSinkWriter for FirestoreSinkWriter {
1002 async fn write_chunk<'a>(
1003 &'a mut self,
1004 chunk: StreamChunk,
1005 _add_future: DeliveryFutureManagerAddFuture<'a, Self::DeliveryFuture>,
1006 ) -> Result<()> {
1007 let operations = self.write_chunk_inner(chunk)?;
1008 self.write_operations(operations).await?;
1009 Ok(())
1010 }
1011}
1012
1013#[cfg(test)]
1014mod tests;