Skip to main content

faucet_sink_bigquery/
sink.rs

1//! BigQuery streaming insert sink.
2
3use crate::config::BigQuerySinkConfig;
4use crate::idempotent;
5use crate::merge;
6use async_trait::async_trait;
7use faucet_common_bigquery::build_client;
8use faucet_core::FaucetError;
9use faucet_core::idempotency::COMMIT_TOKEN_TOKEN_COL;
10use gcp_bigquery_client::Client;
11use gcp_bigquery_client::error::BQError;
12use gcp_bigquery_client::model::get_query_results_parameters::GetQueryResultsParameters;
13use gcp_bigquery_client::model::job::Job;
14use gcp_bigquery_client::model::query_parameter::QueryParameter;
15use gcp_bigquery_client::model::query_parameter_type::QueryParameterType;
16use gcp_bigquery_client::model::query_parameter_value::QueryParameterValue;
17use gcp_bigquery_client::model::query_request::QueryRequest;
18use gcp_bigquery_client::model::query_response::{QueryResponse, ResultSet};
19use gcp_bigquery_client::model::table_data_insert_all_request::TableDataInsertAllRequest;
20use gcp_bigquery_client::model::table_data_insert_all_response::TableDataInsertAllResponse;
21use serde_json::Value;
22use std::time::Duration;
23use tokio::sync::RwLock;
24
25/// Max wall-clock spent polling an idempotent-write / token-read job to
26/// completion before giving up. Exactly-once pages are small, so this is a
27/// generous safety cap, not a steady-state wait.
28const IDEMPOTENT_JOB_TIMEOUT: Duration = Duration::from_secs(120);
29
30/// Server-side long-poll window per `getQueryResults` completion check —
31/// BigQuery holds the connection open up to this long, so we don't busy-wait.
32const JOB_POLL_LONG_POLL_MS: i32 = 10_000;
33
34/// `true` when a `tables.get` error is a 404 (table does not exist) — used by
35/// `current_schema` to report a not-yet-created target as `Ok(None)` rather
36/// than a hard error.
37fn is_table_not_found(err: &BQError) -> bool {
38    matches!(err, BQError::ResponseError { error } if error.error.code == 404)
39}
40
41/// Rows a completed DML job reported as affected
42/// (`statistics.query.numDmlAffectedRows`, which BigQuery sends as a string).
43///
44/// Reported as `0` when the field is absent or unparseable: the delete has
45/// already been verified to have committed by then, so this is a metric, never
46/// a correctness signal — an unreadable count must not fail a successful
47/// cleanup.
48fn dml_affected_rows(job: &Job) -> u64 {
49    job.statistics
50        .as_ref()
51        .and_then(|s| s.query.as_ref())
52        .and_then(|q| q.num_dml_affected_rows.as_deref())
53        .and_then(|n| n.parse::<u64>().ok())
54        .unwrap_or(0)
55}
56
57/// Serialize the cleanup scope — a map of destination column → value — into the
58/// single JSON object bound as `@scope`.
59fn scope_to_payload(scope: &std::collections::BTreeMap<String, Value>) -> String {
60    let obj: serde_json::Map<String, Value> =
61        scope.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
62    Value::Object(obj).to_string()
63}
64
65/// Serialize planned delete key tuples into a JSON array of `{key_col: value}`
66/// objects for the `@deletes` parameter consumed by the semi-join `DELETE`.
67fn deletes_to_payload(deletes: &[faucet_core::KeyTuple]) -> String {
68    let arr: Vec<Value> = deletes
69        .iter()
70        .map(|kt| {
71            let mut obj = serde_json::Map::new();
72            for (k, v) in &kt.0 {
73                obj.insert(k.clone(), v.clone());
74            }
75            Value::Object(obj)
76        })
77        .collect();
78    Value::Array(arr).to_string()
79}
80
81/// A sink that writes JSON records to a BigQuery table using the streaming
82/// insert API (`tabledata.insertAll`).
83pub struct BigQuerySink {
84    config: BigQuerySinkConfig,
85    client: Client,
86    /// Target table schema, fetched lazily on the first exactly-once / upsert
87    /// call and reused for every page in the run. `None` until first read, and
88    /// reset to `None` by [`evolve_schema`](faucet_core::Sink::evolve_schema) so
89    /// the next page diffs against the evolved table. Unused on the plain
90    /// streaming path.
91    schema_cache: RwLock<Option<Vec<idempotent::FieldSpec>>>,
92    /// Lazily-built GCS client for the Arrow columnar load-job staging upload
93    /// (#380). Built once from `config.bulk_load.gcs_auth` on the first
94    /// columnar write and reused for every staged file.
95    #[cfg(feature = "arrow")]
96    gcs_store: tokio::sync::OnceCell<google_cloud_storage::client::Storage>,
97}
98
99impl BigQuerySink {
100    /// Create a new BigQuery sink from the given configuration.
101    ///
102    /// This initialises the BigQuery client and authenticates with GCP.
103    /// Returns a [`FaucetError::Auth`] if authentication fails.
104    pub async fn new(config: BigQuerySinkConfig) -> Result<Self, FaucetError> {
105        faucet_core::validate_batch_size(config.batch_size)?;
106        config.write.validate()?;
107        let client = build_client(&config.auth).await?;
108        Ok(Self {
109            config,
110            client,
111            schema_cache: RwLock::new(None),
112            #[cfg(feature = "arrow")]
113            gcs_store: tokio::sync::OnceCell::new(),
114        })
115    }
116
117    /// Construct a sink from a pre-built BigQuery client.
118    ///
119    /// This is a low-level escape hatch for callers that build their own
120    /// [`gcp_bigquery_client::Client`] — for example to target the
121    /// [`bigquery-emulator`](https://github.com/goccy/bigquery-emulator) via
122    /// [`ClientBuilder::with_v2_base_url`](gcp_bigquery_client::client_builder::ClientBuilder::with_v2_base_url),
123    /// or to drive a wiremock-backed test fixture. Production code should
124    /// prefer [`BigQuerySink::new`], which handles credential loading.
125    #[doc(hidden)]
126    pub fn from_parts(config: BigQuerySinkConfig, client: Client) -> Self {
127        Self {
128            config,
129            client,
130            schema_cache: RwLock::new(None),
131            #[cfg(feature = "arrow")]
132            gcs_store: tokio::sync::OnceCell::new(),
133        }
134    }
135
136    /// Issue a single `tabledata.insertAll` call and return the raw response.
137    ///
138    /// Returns `Err` only on transport-level or HTTP-level failures. Per-row
139    /// `insertErrors` in the response body are surfaced to the caller as-is;
140    /// it is the caller's responsibility to inspect them.
141    ///
142    /// `skip_invalid_rows` maps to BigQuery's `skipInvalidRows` flag. When
143    /// `false` (the all-or-nothing [`write_batch`](Self::write_batch) path) a
144    /// single invalid row makes BigQuery commit *nothing* and return per-row
145    /// errors. When `true` (the [`write_batch_partial`] DLQ path) BigQuery
146    /// commits every valid row and reports `insertErrors` only for the rejected
147    /// ones — which is what makes the per-row `Ok`/`Err` mapping in
148    /// `write_batch_partial` truthful (without it, the "good" siblings are
149    /// reported `Ok` but were never actually committed → silent data loss).
150    ///
151    /// [`write_batch_partial`]: faucet_core::Sink::write_batch_partial
152    async fn insert_chunk_raw(
153        &self,
154        rows: &[Value],
155        skip_invalid_rows: bool,
156    ) -> Result<TableDataInsertAllResponse, FaucetError> {
157        let mut insert_request = TableDataInsertAllRequest::new();
158        if skip_invalid_rows {
159            insert_request.skip_invalid_rows();
160        }
161        for row in rows {
162            // When `insert_id_field` is configured, send that field's value as
163            // the streaming `insertId` so BigQuery can de-duplicate retries
164            // (#78/#31). A row lacking the field is inserted without one.
165            let insert_id = self.config.insert_id_field.as_ref().and_then(|field| {
166                row.get(field).map(|v| match v {
167                    Value::String(s) => s.clone(),
168                    other => other.to_string(),
169                })
170            });
171            insert_request.add_row(insert_id, row).map_err(|e| {
172                FaucetError::Sink(format!("failed to serialize row for BigQuery: {e}"))
173            })?;
174        }
175        self.client
176            .tabledata()
177            .insert_all(
178                &self.config.project_id,
179                &self.config.dataset_id,
180                &self.config.table_id,
181                insert_request,
182            )
183            .await
184            .map_err(|e| FaucetError::Sink(format!("BigQuery insertAll failed: {e}")))
185    }
186
187    /// Insert a single chunk of rows in one `tabledata.insertAll` call,
188    /// collapsing any per-row errors into a single [`FaucetError::Sink`].
189    ///
190    /// Used by [`write_batch`](Self::write_batch). Callers that need per-row
191    /// error granularity should use
192    /// [`write_batch_partial`](faucet_core::Sink::write_batch_partial) instead,
193    /// which calls [`insert_chunk_raw`](Self::insert_chunk_raw) directly.
194    async fn insert_batch(&self, rows: &[Value]) -> Result<usize, FaucetError> {
195        if rows.is_empty() {
196            return Ok(0);
197        }
198
199        // All-or-nothing path: `skipInvalidRows=false` so BigQuery commits the
200        // whole chunk or nothing. Any `insertErrors` below becomes an outer
201        // `Err`, so the pipeline aborts before the bookmark advances — no
202        // partial commit to resume past.
203        let response = self.insert_chunk_raw(rows, false).await?;
204
205        // Check for per-row errors.
206        if let Some(errors) = response.insert_errors
207            && !errors.is_empty()
208        {
209            let count = errors.len();
210            let first = &errors[0];
211            return Err(FaucetError::Sink(format!(
212                "BigQuery insertAll: {count} row(s) failed; first error on row {:?}: {:?}",
213                first.index,
214                first
215                    .errors
216                    .as_ref()
217                    .and_then(|errs| errs.first())
218                    .map(|e| &e.message),
219            )));
220        }
221
222        Ok(rows.len())
223    }
224
225    // -----------------------------------------------------------------------
226    // Exactly-once helpers
227    // -----------------------------------------------------------------------
228
229    /// Build a NAMED STRING query parameter.
230    fn string_param(name: &str, value: &str) -> QueryParameter {
231        QueryParameter {
232            name: Some(name.to_string()),
233            parameter_type: Some(QueryParameterType {
234                r#type: "STRING".to_string(),
235                array_type: None,
236                struct_types: None,
237            }),
238            parameter_value: Some(QueryParameterValue {
239                value: Some(value.to_string()),
240                array_values: None,
241                struct_values: None,
242            }),
243        }
244    }
245
246    /// Fetch the target table's schema fields directly via `tables.get`, with no
247    /// caching. Returns the raw [`idempotent::FieldSpec`]s (possibly empty for a
248    /// schemaless table); a missing table surfaces as the client's `BQError`.
249    async fn fetch_schema_fields(&self) -> Result<Vec<idempotent::FieldSpec>, BQError> {
250        let table = self
251            .client
252            .table()
253            .get(
254                &self.config.project_id,
255                &self.config.dataset_id,
256                &self.config.table_id,
257                Some(vec!["schema"]),
258            )
259            .await?;
260        // Table.schema is TableSchema (not Option); TableSchema.fields is Option<Vec<...>>.
261        Ok(table
262            .schema
263            .fields
264            .as_ref()
265            .map(|fs| {
266                fs.iter()
267                    .map(idempotent::FieldSpec::from_table_field)
268                    .collect()
269            })
270            .unwrap_or_default())
271    }
272
273    /// Fetch (once) and cache the target table's schema as
274    /// [`idempotent::FieldSpec`]s, returning an owned clone. Used by the
275    /// exactly-once / upsert write paths, which require a table with a defined
276    /// schema — a missing table or empty schema is a hard error here.
277    ///
278    /// The cache is reset by [`evolve_schema`](faucet_core::Sink::evolve_schema)
279    /// so a later page re-fetches the evolved schema.
280    async fn target_schema(&self) -> Result<Vec<idempotent::FieldSpec>, FaucetError> {
281        if let Some(fields) = self.schema_cache.read().await.as_ref() {
282            return Ok(fields.clone());
283        }
284        // Miss: fetch under the write lock so concurrent callers don't each
285        // issue a redundant tables.get. Re-check after acquiring in case a
286        // racing writer already filled it.
287        let mut guard = self.schema_cache.write().await;
288        if let Some(fields) = guard.as_ref() {
289            return Ok(fields.clone());
290        }
291        let fields = self
292            .fetch_schema_fields()
293            .await
294            .map_err(|e| FaucetError::Sink(format!("BigQuery tables.get (schema) failed: {e}")))?;
295        if fields.is_empty() {
296            return Err(FaucetError::Sink(format!(
297                "BigQuery target table {}.{}.{} has no schema fields; exactly-once \
298                 delivery requires a table with a defined schema",
299                self.config.project_id, self.config.dataset_id, self.config.table_id
300            )));
301        }
302        *guard = Some(fields.clone());
303        Ok(fields)
304    }
305
306    /// Backtick-quoted fully-qualified `` `project.dataset.table` `` reference.
307    fn table_ref(&self) -> String {
308        idempotent::table_ref(
309            &self.config.project_id,
310            &self.config.dataset_id,
311            &self.config.table_id,
312        )
313    }
314
315    /// Temp table id used while an overwrite run is in flight.
316    fn overwrite_temp_id(&self) -> String {
317        format!("{}__faucet_ovw", self.config.table_id)
318    }
319
320    /// Backtick-quoted reference to the overwrite staging table.
321    fn overwrite_temp_ref(&self) -> String {
322        idempotent::table_ref(
323            &self.config.project_id,
324            &self.config.dataset_id,
325            &self.overwrite_temp_id(),
326        )
327    }
328
329    /// Load one page into the overwrite staging table via the typed, buffer-free
330    /// `INSERT … SELECT FROM UNNEST(JSON_QUERY_ARRAY(@payload))` query path (the
331    /// same generator the exactly-once write uses). Streaming `insertAll` is
332    /// avoided deliberately: its rows sit in a streaming buffer that the commit
333    /// swap's `SELECT` might not see yet.
334    async fn insert_overwrite_page(&self, records: &[Value]) -> Result<usize, FaucetError> {
335        let columns = self.target_schema().await?;
336        let payload = serde_json::to_string(records).map_err(|e| {
337            FaucetError::Sink(format!("BigQuery overwrite: serialize page payload: {e}"))
338        })?;
339        let sql = idempotent::build_insert_select(
340            &columns,
341            &self.config.project_id,
342            &self.config.dataset_id,
343            &self.overwrite_temp_id(),
344        );
345        let mut req = QueryRequest::new(sql);
346        req.use_legacy_sql = false;
347        req.parameter_mode = Some("NAMED".to_string());
348        req.query_parameters = Some(vec![Self::string_param("payload", &payload)]);
349        let resp = self
350            .client
351            .job()
352            .query(&self.config.project_id, req)
353            .await
354            .map_err(|e| {
355                FaucetError::Sink(format!("BigQuery overwrite page insert failed: {e}"))
356            })?;
357        self.await_query_complete(resp).await?;
358        Ok(records.len())
359    }
360
361    /// Run one schema-evolution DDL statement through the same `jobs.query` +
362    /// authoritative job-status-verify path the data writes use, mapping any
363    /// failure to [`FaucetError::Sink`].
364    async fn run_ddl(&self, sql: String) -> Result<(), FaucetError> {
365        let mut req = QueryRequest::new(sql);
366        req.use_legacy_sql = false;
367        let resp = self
368            .client
369            .job()
370            .query(&self.config.project_id, req)
371            .await
372            .map_err(|e| FaucetError::Sink(format!("BigQuery schema-evolution DDL failed: {e}")))?;
373        self.await_query_complete(resp).await
374    }
375
376    /// Create the commit-token watermark table if it does not exist.
377    async fn ensure_commit_table(&self) -> Result<(), FaucetError> {
378        let sql =
379            idempotent::build_create_commit_table(&self.config.project_id, &self.config.dataset_id);
380        let mut req = QueryRequest::new(sql);
381        req.use_legacy_sql = false;
382        let resp = self
383            .client
384            .job()
385            .query(&self.config.project_id, req)
386            .await
387            .map_err(|e| FaucetError::Sink(format!("BigQuery commit-table create failed: {e}")))?;
388        self.await_query_complete(resp).await
389    }
390
391    /// [`await_query_job`](Self::await_query_job), discarding the job body —
392    /// the shape every caller that only needs "did it commit?" wants.
393    async fn await_query_complete(&self, initial: QueryResponse) -> Result<(), FaucetError> {
394        self.await_query_job(initial).await.map(|_| ())
395    }
396
397    /// Wait for a query/script job to finish, then authoritatively verify it
398    /// succeeded. Returns the terminal `Job` only once it reached a terminal
399    /// state with no `errorResult` — callers that need DML statistics (the
400    /// scoped-cleanup delete count) read them off the returned body.
401    ///
402    /// Why `get_job` rather than the response `errors` field: the client maps
403    /// only non-2xx HTTP to `Err`, so a job that fails at *runtime* (a CAST
404    /// failure, a NULL into a REQUIRED column, …) comes back as `Ok` with the
405    /// failure recorded in the job body. `Job.status.error_result` is the
406    /// authoritative terminal-failure signal; the `errors` array can also carry
407    /// non-fatal warnings, so it must not be treated as failure on its own.
408    async fn await_query_job(&self, initial: QueryResponse) -> Result<Job, FaucetError> {
409        let (job_id, location) = Self::job_reference(&initial)?;
410
411        // Phase 1 — wait for completion via server-side long-poll (not a busy wait).
412        if !initial.job_complete.unwrap_or(false) {
413            let started = std::time::Instant::now();
414            loop {
415                let params = GetQueryResultsParameters {
416                    location: location.clone(),
417                    timeout_ms: Some(JOB_POLL_LONG_POLL_MS),
418                    max_results: Some(0),
419                    ..Default::default()
420                };
421                let resp = self
422                    .client
423                    .job()
424                    .get_query_results(&self.config.project_id, &job_id, params)
425                    .await
426                    .map_err(|e| {
427                        FaucetError::Sink(format!("BigQuery jobs.getQueryResults failed: {e}"))
428                    })?;
429                if resp.job_complete.unwrap_or(false) {
430                    break;
431                }
432                if started.elapsed() >= IDEMPOTENT_JOB_TIMEOUT {
433                    return Err(FaucetError::Sink(format!(
434                        "BigQuery job '{job_id}' did not complete within {}s",
435                        IDEMPOTENT_JOB_TIMEOUT.as_secs()
436                    )));
437                }
438                // The server long-poll normally blocks until completion, but if
439                // it returns early, back off so a still-running job can't turn
440                // this into a tight request-hammering loop.
441                tokio::time::sleep(Duration::from_millis(250)).await;
442            }
443        }
444
445        // Phase 2 — authoritative success check via the job's errorResult.
446        //
447        // We require an explicit terminal `DONE` state with no `errorResult`.
448        // A missing `status`, a non-`DONE` state, or a present `errorResult` all
449        // mean we cannot confirm the transaction durably committed — fail safe
450        // (returning `Ok` here would advance the bookmark over data that may
451        // never have landed, the silent-data-loss failure mode).
452        let job = self
453            .client
454            .job()
455            .get_job(&self.config.project_id, &job_id, location.as_deref())
456            .await
457            .map_err(|e| FaucetError::Sink(format!("BigQuery jobs.get failed: {e}")))?;
458        // Read the two fields we judge on, then drop the borrow so the job body
459        // itself can be handed back to the caller.
460        let (state, error_result) = {
461            let status = job.status.as_ref().ok_or_else(|| {
462                FaucetError::Sink(format!(
463                    "BigQuery job '{job_id}' returned no status; cannot confirm durable commit"
464                ))
465            })?;
466            (
467                status.state.clone(),
468                status.error_result.as_ref().map(|e| e.to_string()),
469            )
470        };
471        if let Some(err) = error_result {
472            return Err(FaucetError::Sink(format!(
473                "BigQuery query job '{job_id}' failed: {err}"
474            )));
475        }
476        match state.as_deref() {
477            Some("DONE") => Ok(job),
478            other => Err(FaucetError::Sink(format!(
479                "BigQuery job '{job_id}' is in state {other:?}, not DONE; cannot confirm durable commit"
480            ))),
481        }
482    }
483
484    /// Extract `(job_id, location)` from a query response's job reference.
485    fn job_reference(qr: &QueryResponse) -> Result<(String, Option<String>), FaucetError> {
486        let r = qr.job_reference.as_ref().ok_or_else(|| {
487            FaucetError::Sink("BigQuery query response missing jobReference".to_string())
488        })?;
489        let job_id = r
490            .job_id
491            .clone()
492            .ok_or_else(|| FaucetError::Sink("BigQuery jobReference missing jobId".to_string()))?;
493        Ok((job_id, r.location.clone()))
494    }
495
496    /// Run a planned upsert/delete page as one BigQuery multi-statement
497    /// transaction. When `token` is `Some((scope, tok))` the watermark `MERGE`
498    /// is appended inside the same transaction (exactly-once + upsert).
499    ///
500    /// The caller must have already validated `plan.failed` is empty (and, for
501    /// the exactly-once path, ensured the commit table exists). Returns the
502    /// number of rows applied (upserts + deletes).
503    async fn run_upsert_script(
504        &self,
505        plan: &faucet_core::WritePlan,
506        token: Option<(&str, &str)>,
507    ) -> Result<usize, FaucetError> {
508        let columns = self.target_schema().await?;
509        merge::validate_keys_present(&columns, &self.config.write.key)?;
510
511        let has_upserts = !plan.upserts.is_empty();
512        let has_deletes = !plan.deletes.is_empty();
513        if !has_upserts && !has_deletes {
514            // Nothing planned; for the exactly-once path the bookmark still
515            // needs its token, so emit the watermark MERGE alone.
516            if token.is_none() {
517                return Ok(0);
518            }
519        }
520
521        let key = &self.config.write.key;
522        let (project, dataset, table) = (
523            &self.config.project_id,
524            &self.config.dataset_id,
525            &self.config.table_id,
526        );
527        let sql = match token {
528            Some(_) => merge::build_upsert_idempotent_sql(
529                &columns,
530                key,
531                has_upserts,
532                has_deletes,
533                project,
534                dataset,
535                table,
536            ),
537            None => merge::build_upsert_transaction_sql(
538                &columns,
539                key,
540                has_upserts,
541                has_deletes,
542                project,
543                dataset,
544                table,
545            ),
546        };
547
548        let mut params = Vec::new();
549        if has_upserts {
550            let payload = serde_json::to_string(&plan.upserts).map_err(|e| {
551                FaucetError::Sink(format!("bigquery upsert: serialize payload: {e}"))
552            })?;
553            params.push(Self::string_param("payload", &payload));
554        }
555        if has_deletes {
556            let deletes = deletes_to_payload(&plan.deletes);
557            params.push(Self::string_param("deletes", &deletes));
558        }
559        if let Some((scope, tok)) = token {
560            params.push(Self::string_param("scope", scope));
561            params.push(Self::string_param("token", tok));
562        }
563
564        let mut req = QueryRequest::new(sql);
565        req.use_legacy_sql = false;
566        req.parameter_mode = Some("NAMED".to_string());
567        // MERGE-by-key is idempotent, so a retried request is harmless; for the
568        // exactly-once path a deterministic request_id additionally suppresses
569        // duplicate jobs from a retried HTTP request within BigQuery's window.
570        if let Some((scope, tok)) = token {
571            req.request_id = Some(idempotent::build_request_id(scope, tok));
572        }
573        req.query_parameters = Some(params);
574
575        let resp = self
576            .client
577            .job()
578            .query(&self.config.project_id, req)
579            .await
580            .map_err(|e| FaucetError::Sink(format!("bigquery upsert write failed: {e}")))?;
581        self.await_query_complete(resp).await?;
582
583        Ok(plan.upserts.len() + plan.deletes.len())
584    }
585
586    /// Delete rows in `scope` whose key was not written by this run (#478).
587    ///
588    /// One `DELETE` statement, which BigQuery applies atomically — so the
589    /// cleanup is all-or-nothing, as it must be: a partial delete would remove
590    /// rows the run actually wrote. Both the scope predicate and the written-key
591    /// set travel as a single bound JSON STRING parameter each (`@scope` /
592    /// `@keys`), the same shape the typed `INSERT … SELECT FROM
593    /// UNNEST(JSON_QUERY_ARRAY(@payload))` write path uses; one parameter per
594    /// key would exceed BigQuery's parameter limits well before the scope did.
595    ///
596    /// An empty `seen` set is meaningful, not a no-op — it means the source
597    /// reported the scope as empty, so every row in it is stale and must go.
598    /// That is the case this feature exists for, and `NOT EXISTS` over an empty
599    /// `UNNEST` expresses it directly.
600    async fn cleanup_scope_impl(
601        &self,
602        scope: &std::collections::BTreeMap<String, Value>,
603        seen: &faucet_core::SeenKeys,
604    ) -> Result<u64, FaucetError> {
605        let key = &self.config.write.key;
606        if key.is_empty() {
607            return Err(FaucetError::Sink(
608                "bigquery cleanup requires a non-empty `key`".to_string(),
609            ));
610        }
611        let columns = self.target_schema().await?;
612        let scope_cols: Vec<String> = scope.keys().cloned().collect();
613        merge::validate_cleanup_columns(&columns, &scope_cols, key)?;
614
615        let keys_payload = deletes_to_payload(seen.keys());
616        merge::check_cleanup_payload_size(keys_payload.len(), seen.len())?;
617
618        let sql = merge::build_cleanup_delete(
619            &columns,
620            &scope_cols,
621            key,
622            &self.config.project_id,
623            &self.config.dataset_id,
624            &self.config.table_id,
625        );
626        let mut req = QueryRequest::new(sql);
627        req.use_legacy_sql = false;
628        req.parameter_mode = Some("NAMED".to_string());
629        req.query_parameters = Some(vec![
630            Self::string_param("scope", &scope_to_payload(scope)),
631            Self::string_param("keys", &keys_payload),
632        ]);
633
634        let resp = self
635            .client
636            .job()
637            .query(&self.config.project_id, req)
638            .await
639            .map_err(|e| FaucetError::Sink(format!("bigquery cleanup delete failed: {e}")))?;
640        let job = self.await_query_job(resp).await?;
641        let deleted = dml_affected_rows(&job);
642
643        tracing::info!(
644            table = %format!(
645                "{}.{}.{}",
646                self.config.project_id, self.config.dataset_id, self.config.table_id
647            ),
648            deleted,
649            written_keys = seen.len(),
650            "BigQuery scoped cleanup complete"
651        );
652        Ok(deleted)
653    }
654}
655
656#[async_trait]
657impl faucet_core::Sink for BigQuerySink {
658    fn connector_name(&self) -> &'static str {
659        "bigquery"
660    }
661
662    /// BigQuery bulk-loads via a GCS-staged Parquet load job under `bulk_load`
663    /// (#528). Advertise the `staging` capability.
664    fn supports_staged_load(&self) -> bool {
665        true
666    }
667
668    fn config_schema(&self) -> serde_json::Value {
669        serde_json::to_value(faucet_core::schema_for!(BigQuerySinkConfig))
670            .expect("schema serialization")
671    }
672
673    fn dataset_uri(&self) -> String {
674        format!(
675            "bigquery://{}.{}.{}",
676            self.config.project_id, self.config.dataset_id, self.config.table_id
677        )
678    }
679
680    /// Preflight check (`faucet doctor`).
681    ///
682    /// Runs a single read-only `tables.get` against the configured
683    /// `project_id.dataset_id.table_id` using the already-authenticated
684    /// client built in [`BigQuerySink::new`]. This mints/uses the access
685    /// token and confirms the credentials can read the target table's
686    /// metadata — without inserting any rows. Auth, missing-dataset,
687    /// missing-table, and permission errors all surface as a `Fail` probe
688    /// with a remediation hint. The access token is never included in the
689    /// reason or hint.
690    async fn check(
691        &self,
692        ctx: &faucet_core::check::CheckContext,
693    ) -> Result<faucet_core::check::CheckReport, FaucetError> {
694        use faucet_core::check::{CheckReport, Probe};
695
696        let started = std::time::Instant::now();
697        let fqn = format!(
698            "{}.{}.{}",
699            self.config.project_id, self.config.dataset_id, self.config.table_id
700        );
701
702        let result = tokio::time::timeout(
703            ctx.timeout,
704            self.client.table().get(
705                &self.config.project_id,
706                &self.config.dataset_id,
707                &self.config.table_id,
708                Some(vec!["tableReference"]),
709            ),
710        )
711        .await;
712
713        let probe = match result {
714            Ok(Ok(_table)) => Probe::pass("auth", started.elapsed()),
715            Ok(Err(e)) => Probe::fail_hint(
716                "auth",
717                started.elapsed(),
718                format!("BigQuery tables.get on {fqn} failed: {e}"),
719                "Verify the service account has roles/bigquery.dataViewer (or \
720                 read access) on the dataset and that the project, dataset, and \
721                 table IDs are correct.",
722            ),
723            Err(_elapsed) => Probe::fail_hint(
724                "auth",
725                started.elapsed(),
726                format!(
727                    "BigQuery tables.get on {fqn} timed out after {:?}",
728                    ctx.timeout
729                ),
730                "Check network reachability to bigquery.googleapis.com and that \
731                 credentials can be minted within the timeout.",
732            ),
733        };
734
735        Ok(CheckReport::single(probe))
736    }
737
738    /// Write records to BigQuery.
739    ///
740    /// When `config.batch_size > 0` and the input slice is larger than
741    /// `batch_size`, the slice is split into chunks of `batch_size` rows and
742    /// each chunk is sent as a separate `tabledata.insertAll` call. When
743    /// `config.batch_size == 0`, the entire slice is sent in a single
744    /// `insertAll` request — useful when upstream `StreamPage`s are already
745    /// sized for BigQuery's per-request limits.
746    async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
747        if records.is_empty() {
748            return Ok(0);
749        }
750
751        if matches!(
752            self.config.write.write_mode,
753            faucet_core::WriteMode::Upsert | faucet_core::WriteMode::Delete
754        ) {
755            let plan = faucet_core::plan_writes(records, &self.config.write);
756            if let Some((idx, msg)) = plan.failed.first() {
757                return Err(FaucetError::Sink(format!(
758                    "bigquery {}: row {idx}: {msg}",
759                    self.config.write.write_mode.as_str()
760                )));
761            }
762            return self.run_upsert_script(&plan, None).await;
763        }
764
765        // Overwrite: load the page into the staging table via the buffer-free
766        // query path (not streaming `insertAll`); the atomic swap runs in
767        // `commit_overwrite`.
768        if self.config.write.is_overwrite() {
769            return self.insert_overwrite_page(records).await;
770        }
771
772        let chunks: Vec<&[Value]> = if self.config.batch_size == 0 {
773            // Sentinel: pass the entire upstream page through in a single
774            // insertAll call. Subject to BigQuery's ~10MB request limit.
775            vec![records]
776        } else {
777            records.chunks(self.config.batch_size).collect()
778        };
779
780        let mut total = 0;
781        for chunk in chunks {
782            total += self.insert_batch(chunk).await?;
783        }
784
785        tracing::info!(
786            table = %format!(
787                "{}.{}.{}",
788                self.config.project_id, self.config.dataset_id, self.config.table_id
789            ),
790            rows = total,
791            "BigQuery write complete"
792        );
793        Ok(total)
794    }
795
796    /// Write records to BigQuery, returning a per-row outcome vector.
797    ///
798    /// Unlike [`write_batch`](faucet_core::Sink::write_batch), which collapses all
799    /// `insertErrors` into a single `FaucetError`, this method maps each row
800    /// to `Ok(())` if BigQuery accepted it or `Err(FaucetError::Sink(...))` if
801    /// BigQuery reported a per-row error for it. This allows the pipeline's DLQ
802    /// router to quarantine only the rows that BigQuery actually rejected while
803    /// keeping already-committed siblings out of the dead-letter queue.
804    ///
805    /// Transport-level or HTTP-level failures (e.g. network errors, 4xx/5xx
806    /// responses) are still returned as an outer `Err` because no rows can be
807    /// considered committed in that case.
808    ///
809    /// Chunking follows the same `batch_size` semantics as `write_batch`:
810    /// `batch_size == 0` sends the entire slice in one call; `batch_size > 0`
811    /// splits the slice into chunks and concatenates the per-row outcomes in
812    /// input order.
813    async fn write_batch_partial(
814        &self,
815        records: &[Value],
816    ) -> Result<Vec<faucet_core::RowOutcome>, FaucetError> {
817        use std::collections::HashMap;
818
819        if records.is_empty() {
820            return Ok(Vec::new());
821        }
822
823        if self.config.write.is_overwrite() {
824            // Overwrite is insert-shaped with no per-row key failures.
825            self.insert_overwrite_page(records).await?;
826            return Ok(records.iter().map(|_| Ok(())).collect());
827        }
828
829        if !matches!(self.config.write.write_mode, faucet_core::WriteMode::Append) {
830            let plan = faucet_core::plan_writes(records, &self.config.write);
831            self.run_upsert_script(&plan, None).await?;
832            let mut outcomes: Vec<faucet_core::RowOutcome> =
833                records.iter().map(|_| Ok(())).collect();
834            for (idx, msg) in &plan.failed {
835                outcomes[*idx] = Err(FaucetError::Sink(format!(
836                    "bigquery {}: {msg}",
837                    self.config.write.write_mode.as_str()
838                )));
839            }
840            return Ok(outcomes);
841        }
842
843        let chunks: Vec<&[Value]> = if self.config.batch_size == 0 {
844            vec![records]
845        } else {
846            records.chunks(self.config.batch_size).collect()
847        };
848
849        let mut outcomes: Vec<faucet_core::RowOutcome> = Vec::with_capacity(records.len());
850
851        for chunk in chunks {
852            // `skipInvalidRows=true`: BigQuery commits every valid row and
853            // returns `insertErrors` only for the rejected ones. This is what
854            // makes mapping the flagged indices to `Err` and the rest to
855            // `Ok(())` correct — the unflagged rows really were committed, so
856            // the DLQ router quarantines only the bad rows and the bookmark
857            // advances over genuinely-persisted data.
858            let response = self.insert_chunk_raw(chunk, true).await?;
859
860            // Build a set of failed row indices → first error message.
861            let failed: HashMap<usize, String> = response
862                .insert_errors
863                .unwrap_or_default()
864                .into_iter()
865                .filter_map(|e| {
866                    let idx = e.index? as usize;
867                    let msg = e
868                        .errors
869                        .as_ref()
870                        .and_then(|v| v.first())
871                        .map(|er| er.message.clone().unwrap_or_default())
872                        .unwrap_or_default();
873                    Some((idx, msg))
874                })
875                .collect();
876
877            for i in 0..chunk.len() {
878                match failed.get(&i) {
879                    Some(msg) => outcomes.push(Err(FaucetError::Sink(format!(
880                        "BigQuery row rejected: {msg}"
881                    )))),
882                    None => outcomes.push(Ok(())),
883                }
884            }
885        }
886
887        Ok(outcomes)
888    }
889
890    fn supported_write_modes(&self) -> &'static [faucet_core::WriteMode] {
891        &[
892            faucet_core::WriteMode::Append,
893            faucet_core::WriteMode::Upsert,
894            faucet_core::WriteMode::Delete,
895            faucet_core::WriteMode::Overwrite,
896        ]
897    }
898
899    fn is_overwrite(&self) -> bool {
900        self.config.write.is_overwrite()
901    }
902
903    /// Create the staging table as an empty structural clone of the target
904    /// (`CREATE OR REPLACE TABLE temp LIKE target`, which also copies
905    /// partitioning/clustering), dropping any leftover staging from a crashed
906    /// run. Bucket-free: no GCS staging is involved. The target must already
907    /// exist (LIKE requires it) — overwrite replaces its rows, not its schema.
908    async fn begin_overwrite(&self) -> Result<(), FaucetError> {
909        self.run_ddl(format!(
910            "CREATE OR REPLACE TABLE {} LIKE {}",
911            self.overwrite_temp_ref(),
912            self.table_ref()
913        ))
914        .await
915    }
916
917    /// Atomically replace the destination in one BigQuery multi-statement
918    /// transaction — `TRUNCATE TABLE target; INSERT INTO target SELECT * FROM
919    /// temp;` — so a failure rolls back and the prior rows survive.
920    /// `TRUNCATE`+`INSERT` (rather than `CREATE OR REPLACE … AS SELECT`)
921    /// preserves the target's own partitioning, clustering, and description. The
922    /// staging table is dropped afterwards. Staging was loaded via the query
923    /// path, so there is no streaming buffer to miss.
924    async fn commit_overwrite(&self) -> Result<(), FaucetError> {
925        let temp = self.overwrite_temp_ref();
926        let sql = match &self.config.scope {
927            Some(scope) => {
928                // Backtick-quote the scoped column (strip any backticks).
929                let col = format!("`{}`", scope.column().replace('`', ""));
930                idempotent::build_scoped_overwrite_commit_sql(
931                    &self.table_ref(),
932                    &temp,
933                    &scope.render_where_literal(&col),
934                )
935            }
936            None => idempotent::build_overwrite_commit_sql(&self.table_ref(), &temp),
937        };
938        self.run_ddl(sql).await?;
939        self.run_ddl(format!("DROP TABLE IF EXISTS {temp}")).await
940    }
941
942    /// Drop the staging table so a failed/cancelled overwrite leaves nothing
943    /// behind. Best-effort — the destination was never touched.
944    async fn abort_overwrite(&self) -> Result<(), FaucetError> {
945        self.run_ddl(format!(
946            "DROP TABLE IF EXISTS {}",
947            self.overwrite_temp_ref()
948        ))
949        .await
950    }
951
952    fn dedups_by_key(&self) -> bool {
953        self.config.write.dedups_by_key()
954    }
955
956    /// Scoped cleanup is always available: a BigQuery table's scope and key
957    /// predicates address real columns, and the exactly-once/upsert paths
958    /// already require the target table to carry a defined schema.
959    fn supports_cleanup(&self) -> bool {
960        true
961    }
962
963    async fn cleanup_scope(
964        &self,
965        scope: &std::collections::BTreeMap<String, Value>,
966        seen: &faucet_core::SeenKeys,
967    ) -> Result<u64, FaucetError> {
968        self.cleanup_scope_impl(scope, seen).await
969    }
970
971    fn supports_idempotent_writes(&self) -> bool {
972        true
973    }
974
975    /// Read the last durably-committed token for `scope` from the watermark
976    /// table, so the pipeline can skip already-committed pages on resume.
977    async fn last_committed_token(&self, scope: &str) -> Result<Option<String>, FaucetError> {
978        self.ensure_commit_table().await?;
979        let mut req = QueryRequest::new(idempotent::build_select_token(
980            &self.config.project_id,
981            &self.config.dataset_id,
982        ));
983        req.use_legacy_sql = false;
984        req.parameter_mode = Some("NAMED".to_string());
985        req.query_parameters = Some(vec![Self::string_param("scope", scope)]);
986
987        let resp = self
988            .client
989            .job()
990            .query(&self.config.project_id, req)
991            .await
992            .map_err(|e| FaucetError::Sink(format!("BigQuery token read failed: {e}")))?;
993
994        // The watermark is a single tiny row, so `jobs.query` returns it inline.
995        // If BigQuery did not complete the read synchronously, fail safe: a
996        // wrong `None` here would re-run an already-committed page and produce
997        // duplicates, defeating exactly-once.
998        if !resp.job_complete.unwrap_or(false) {
999            return Err(FaucetError::Sink(
1000                "BigQuery watermark read did not complete synchronously".to_string(),
1001            ));
1002        }
1003        // `ResultSet` only yields rows when the response carries a schema; a
1004        // completed `SELECT` always returns one. If it is somehow absent we
1005        // cannot tell "no committed token" from "row present but unreadable",
1006        // and a wrong `None` would replay committed pages — fail safe instead.
1007        if resp.schema.is_none() {
1008            return Err(FaucetError::Sink(
1009                "BigQuery watermark read returned no schema; cannot trust the token result"
1010                    .to_string(),
1011            ));
1012        }
1013
1014        let mut rs = ResultSet::new_from_query_response(resp);
1015        if rs.next_row() {
1016            rs.get_string_by_name(COMMIT_TOKEN_TOKEN_COL)
1017                .map_err(|e| FaucetError::Sink(format!("BigQuery token decode failed: {e}")))
1018        } else {
1019            Ok(None)
1020        }
1021    }
1022
1023    /// Atomically write `records` and record `token` for `scope` in one BigQuery
1024    /// multi-statement transaction: a typed `INSERT … SELECT FROM
1025    /// UNNEST(JSON_QUERY_ARRAY(@payload))` plus a watermark `MERGE`. Either both
1026    /// the rows and the token commit, or neither does — so a crash/resume skips
1027    /// the already-committed page (zero duplicates) and a failed page replays
1028    /// cleanly.
1029    ///
1030    /// The entire page is one atomic unit (no `batch_size` re-chunking — core
1031    /// issues exactly one token per page), so the page must serialize within
1032    /// BigQuery's ~10 MB `jobs.query` request limit.
1033    async fn write_batch_idempotent(
1034        &self,
1035        records: &[Value],
1036        scope: &str,
1037        token: &str,
1038    ) -> Result<usize, FaucetError> {
1039        self.ensure_commit_table().await?;
1040
1041        if !matches!(self.config.write.write_mode, faucet_core::WriteMode::Append) {
1042            let plan = faucet_core::plan_writes(records, &self.config.write);
1043            if let Some((idx, msg)) = plan.failed.first() {
1044                return Err(FaucetError::Sink(format!(
1045                    "bigquery {}: row {idx}: {msg}",
1046                    self.config.write.write_mode.as_str()
1047                )));
1048            }
1049            return self.run_upsert_script(&plan, Some((scope, token))).await;
1050        }
1051
1052        let columns = self.target_schema().await?;
1053
1054        let payload = serde_json::to_string(records).map_err(|e| {
1055            FaucetError::Sink(format!(
1056                "BigQuery exactly-once: serialize page payload: {e}"
1057            ))
1058        })?;
1059
1060        let sql = idempotent::build_transaction_sql(
1061            &columns,
1062            &self.config.project_id,
1063            &self.config.dataset_id,
1064            &self.config.table_id,
1065        );
1066        let mut req = QueryRequest::new(sql);
1067        req.use_legacy_sql = false;
1068        req.parameter_mode = Some("NAMED".to_string());
1069        req.request_id = Some(idempotent::build_request_id(scope, token));
1070        req.query_parameters = Some(vec![
1071            Self::string_param("payload", &payload),
1072            Self::string_param("scope", scope),
1073            Self::string_param("token", token),
1074        ]);
1075
1076        let resp = self
1077            .client
1078            .job()
1079            .query(&self.config.project_id, req)
1080            .await
1081            .map_err(|e| FaucetError::Sink(format!("BigQuery idempotent write failed: {e}")))?;
1082        self.await_query_complete(resp).await?;
1083
1084        tracing::info!(
1085            table = %format!(
1086                "{}.{}.{}",
1087                self.config.project_id, self.config.dataset_id, self.config.table_id
1088            ),
1089            rows = records.len(),
1090            token = %token,
1091            "BigQuery exactly-once page committed"
1092        );
1093        Ok(records.len())
1094    }
1095
1096    // -----------------------------------------------------------------------
1097    // Schema drift (issue #194)
1098    // -----------------------------------------------------------------------
1099
1100    fn supports_schema_evolution(&self) -> bool {
1101        true
1102    }
1103
1104    /// Read the live destination schema via a schema-only `tables.get`, mapped
1105    /// to an `infer_schema`-shaped object so the drift policy can diff a page
1106    /// against the real table.
1107    ///
1108    /// Returns `Ok(None)` when the target table does not exist yet (404) or
1109    /// carries no field definitions — both mean "no schema to diff against",
1110    /// so the drift pass treats every page column as new.
1111    async fn current_schema(&self) -> Result<Option<Value>, FaucetError> {
1112        match self.fetch_schema_fields().await {
1113            Ok(fields) if fields.is_empty() => Ok(None),
1114            Ok(fields) => Ok(Some(idempotent::fieldspecs_to_json_schema(&fields))),
1115            Err(e) if is_table_not_found(&e) => Ok(None),
1116            Err(e) => Err(FaucetError::Sink(format!(
1117                "BigQuery current_schema (tables.get) failed: {e}"
1118            ))),
1119        }
1120    }
1121
1122    /// Apply an additive schema evolution to the target table via `ALTER TABLE`
1123    /// DDL (issue #194):
1124    ///
1125    /// - additions → `ADD COLUMN IF NOT EXISTS <col> <type>`
1126    /// - widenings → `ALTER COLUMN <col> SET DATA TYPE <type>`
1127    /// - nullability relaxations → `ALTER COLUMN <col> DROP NOT NULL`
1128    ///
1129    /// Each statement runs as its own `jobs.query` job, verified to completion
1130    /// via the authoritative job-status check. Every statement is idempotent so
1131    /// concurrent runs converge. The cached schema is invalidated afterwards so
1132    /// the next page re-fetches the evolved table.
1133    async fn evolve_schema(
1134        &self,
1135        evolution: &faucet_core::SchemaEvolution,
1136    ) -> Result<(), FaucetError> {
1137        let table_ref = self.table_ref();
1138
1139        for c in &evolution.additions {
1140            let bq = idempotent::base_to_bq(
1141                faucet_core::json_schema_base_type(&c.to).unwrap_or(faucet_core::SqlBaseType::Text),
1142            );
1143            self.run_ddl(idempotent::build_add_column_ddl(&table_ref, &c.name, bq))
1144                .await?;
1145        }
1146        for c in &evolution.widenings {
1147            let bq = idempotent::base_to_bq(
1148                faucet_core::json_schema_base_type(&c.to).unwrap_or(faucet_core::SqlBaseType::Text),
1149            );
1150            self.run_ddl(idempotent::build_alter_type_ddl(&table_ref, &c.name, bq))
1151                .await?;
1152        }
1153        for col in &evolution.relax_nullability {
1154            self.run_ddl(idempotent::build_drop_not_null_ddl(&table_ref, col))
1155                .await?;
1156        }
1157
1158        // Invalidate the cached schema so the next exactly-once / upsert page
1159        // (and the next drift diff) reads the evolved table.
1160        *self.schema_cache.write().await = None;
1161        Ok(())
1162    }
1163
1164    /// Columnar load-job is available only when a `bulk_load` staging config is
1165    /// set **and** the write mode is `append` (#380). Load jobs are
1166    /// append/truncate only; upsert/delete stay on the `Value` MERGE path, so
1167    /// the pipeline never negotiates the columnar loop for them.
1168    #[cfg(feature = "arrow")]
1169    fn supports_columnar(&self) -> bool {
1170        self.config.bulk_load.is_some()
1171            && self.config.write.write_mode == faucet_core::WriteMode::Append
1172    }
1173
1174    /// Write one Arrow `RecordBatch` by encoding it to Parquet, staging it on
1175    /// GCS, and running a BigQuery `PARQUET` load job to completion. Append-only.
1176    /// The body lives in `load.rs` (pure cloud I/O — a GCS-SDK staging upload +
1177    /// live load job — that can't run in CI, so `codecov.yml` excludes that file
1178    /// exactly as it does the GCS connectors).
1179    #[cfg(feature = "arrow")]
1180    async fn write_batch_columnar(
1181        &self,
1182        batch: &arrow::array::RecordBatch,
1183    ) -> Result<usize, FaucetError> {
1184        crate::load::write_columnar(&self.client, &self.config, &self.gcs_store, batch).await
1185    }
1186}
1187
1188#[cfg(test)]
1189mod tests {
1190    use super::{Job, deletes_to_payload, dml_affected_rows, scope_to_payload};
1191    use faucet_core::KeyTuple;
1192    use serde_json::json;
1193
1194    // dataset_uri test is skipped: BigQuerySink::new() requires GCP credentials
1195    // (build_client fetches auth in new()), and from_parts() requires a
1196    // gcp_bigquery_client::Client which cannot be constructed without auth.
1197
1198    #[test]
1199    fn deletes_to_payload_preserves_number_type() {
1200        // The delete payload must keep an integer key as a JSON number (not the
1201        // string "2"), so the matching `CAST(JSON_VALUE(d, '$.id') AS INT64)`
1202        // semi-join compares like-for-like.
1203        let p = deletes_to_payload(&[KeyTuple(vec![("id".into(), json!(2))])]);
1204        let v: serde_json::Value = serde_json::from_str(&p).expect("valid JSON");
1205        assert_eq!(v, json!([{"id": 2}]));
1206        assert!(v[0]["id"].is_number(), "id must serialize as a number: {p}");
1207    }
1208
1209    #[test]
1210    fn deletes_to_payload_composite_key_roundtrips() {
1211        let p = deletes_to_payload(&[KeyTuple(vec![
1212            ("tenant".into(), json!("acme")),
1213            ("id".into(), json!(7)),
1214        ])]);
1215        let v: serde_json::Value = serde_json::from_str(&p).expect("valid JSON");
1216        assert_eq!(v, json!([{"tenant": "acme", "id": 7}]));
1217    }
1218
1219    // --- scoped cleanup (issue #478) ---
1220
1221    #[test]
1222    fn scope_to_payload_is_one_object_with_typed_values() {
1223        let scope = std::collections::BTreeMap::from([
1224            ("contact_id".to_string(), json!(42)),
1225            ("region".to_string(), json!("eu")),
1226        ]);
1227        let v: serde_json::Value = serde_json::from_str(&scope_to_payload(&scope)).expect("JSON");
1228        assert_eq!(v, json!({"contact_id": 42, "region": "eu"}));
1229        // An integer scope value must stay a JSON number so the matching
1230        // `CAST(JSON_VALUE(@scope, '$.contact_id') AS INT64)` compares like-for-like.
1231        assert!(v["contact_id"].is_number());
1232    }
1233
1234    #[test]
1235    fn seen_keys_serialize_through_the_same_payload_shape() {
1236        // An empty written-key set is meaningful (the source reported the scope
1237        // empty), and must serialize to `[]` so `NOT EXISTS` deletes the scope.
1238        assert_eq!(deletes_to_payload(&[]), "[]");
1239    }
1240
1241    #[test]
1242    fn dml_affected_rows_reads_the_job_statistics() {
1243        use gcp_bigquery_client::model::job_statistics::JobStatistics;
1244        use gcp_bigquery_client::model::job_statistics2::JobStatistics2;
1245
1246        let job = |n: Option<&str>| Job {
1247            statistics: Some(JobStatistics {
1248                query: Some(JobStatistics2 {
1249                    num_dml_affected_rows: n.map(str::to_string),
1250                    ..Default::default()
1251                }),
1252                ..Default::default()
1253            }),
1254            ..Default::default()
1255        };
1256        assert_eq!(dml_affected_rows(&job(Some("7"))), 7);
1257        assert_eq!(dml_affected_rows(&job(Some("0"))), 0);
1258        // Absent / unparseable stats report 0 rather than failing: by this point
1259        // the delete has already been verified to have committed.
1260        assert_eq!(dml_affected_rows(&job(None)), 0);
1261        assert_eq!(dml_affected_rows(&job(Some("not-a-number"))), 0);
1262        assert_eq!(dml_affected_rows(&Job::default()), 0);
1263    }
1264
1265    #[test]
1266    fn deletes_to_payload_multiple_rows() {
1267        let p = deletes_to_payload(&[
1268            KeyTuple(vec![("id".into(), json!(1))]),
1269            KeyTuple(vec![("id".into(), json!(2))]),
1270        ]);
1271        let v: serde_json::Value = serde_json::from_str(&p).expect("valid JSON");
1272        assert_eq!(v, json!([{"id": 1}, {"id": 2}]));
1273    }
1274}