1use std::collections::HashMap;
10use std::pin::Pin;
11use std::sync::Mutex;
12
13use async_trait::async_trait;
14use faucet_common_redshift::data_api::{self, DataColumn, Effect};
15use faucet_common_redshift::{DataApiClient, RedshiftPool};
16use faucet_core::replication::cursor::{CursorBookmark, CursorTracker};
17use faucet_core::util::{quote_ident, substitute_context_bind_params};
18use faucet_core::{FaucetError, Source, Stream, StreamPage};
19use serde_json::Value;
20
21use crate::config::{BOOKMARK_TOKEN, RedshiftReplication, RedshiftSourceConfig};
22use crate::convert::{bind_params, row_to_json};
23
24pub struct RedshiftSource {
27 config: RedshiftSourceConfig,
28 backend: Backend,
29 start_bookmark: Mutex<Option<CursorBookmark>>,
31 calls: faucet_core::CallSlot,
34}
35
36enum Backend {
38 Wire(RedshiftPool),
40 DataApi(Box<DataApiClient>),
42}
43
44enum Conn<'a> {
47 Wire(sqlx::PgPool),
48 DataApi(&'a DataApiClient),
49}
50
51enum Cursor<'a> {
53 Wire(futures::stream::BoxStream<'a, Result<sqlx::postgres::PgRow, sqlx::Error>>),
54 DataApi {
55 api: &'a DataApiClient,
56 id: String,
57 token: Option<String>,
58 columns: Vec<DataColumn>,
59 done: bool,
60 },
61}
62
63struct IncrementalCtx {
65 column: String,
66 start: CursorBookmark,
67}
68
69impl RedshiftSource {
70 pub fn new(config: RedshiftSourceConfig) -> Result<Self, FaucetError> {
74 config.validate()?;
75 let backend = match DataApiClient::from_connection(&config.connection, FaucetError::Source)?
76 {
77 Some(api) => Backend::DataApi(Box::new(api)),
78 None => Backend::Wire(faucet_common_redshift::connect_lazy(
79 &config.connection,
80 config.max_connections,
81 )?),
82 };
83 Ok(Self {
84 config,
85 backend,
86 start_bookmark: Mutex::new(None),
87 calls: faucet_core::CallSlot::new(faucet_core::CallContext::new().without_timeout()),
88 })
89 }
90
91 fn incremental_ctx(&self) -> Option<IncrementalCtx> {
94 match &self.config.replication {
95 RedshiftReplication::Full => None,
96 RedshiftReplication::Incremental {
97 column,
98 initial_value,
99 } => {
100 let start = self
101 .start_bookmark
102 .lock()
103 .expect("start_bookmark mutex poisoned")
104 .clone()
105 .unwrap_or_else(|| CursorBookmark::initial(initial_value.clone()));
106 Some(IncrementalCtx {
107 column: column.clone(),
108 start,
109 })
110 }
111 }
112 }
113
114 fn resolve_query(
123 &self,
124 context: &HashMap<String, Value>,
125 incr: Option<&IncrementalCtx>,
126 cast: &str,
127 ) -> (String, Vec<Value>) {
128 let mut binds = self.config.params.clone();
129 let mut sql = self.config.query.clone();
130
131 if !context.is_empty() {
132 let (rewritten, ctx_values) =
133 substitute_context_bind_params(&sql, context, binds.len() + 1, |i| format!("${i}"));
134 sql = rewritten;
135 binds.extend(ctx_values);
136 }
137
138 let Some(ctx) = incr else {
139 return (sql, binds);
140 };
141 let cast = bookmark_cast(cast, &ctx.start.value);
142 if sql.contains(BOOKMARK_TOKEN) {
143 let marker = format!("CAST(${} AS {cast})", binds.len() + 1);
144 sql = sql.replace(BOOKMARK_TOKEN, &marker);
145 binds.push(ctx.start.value.clone());
146 }
147 binds.push(ctx.start.value.clone());
148 let c = quote_ident(&ctx.column);
149 let inner = sql.trim_end_matches(|ch: char| ch.is_whitespace() || ch == ';');
150 (
151 format!(
152 "SELECT * FROM ({inner}\n) AS q WHERE q.{c} >= CAST(${n} AS {cast}) ORDER BY q.{c}",
153 n = binds.len()
154 ),
155 binds,
156 )
157 }
158
159 async fn cursor_cast(
161 &self,
162 context: &HashMap<String, Value>,
163 column: &str,
164 ) -> Result<String, FaucetError> {
165 let described = self.describe(context).await.map_err(|e| match e {
166 FaucetError::Source(m) => FaucetError::Source(format!(
167 "redshift: could not describe the incremental query: {m}"
168 )),
169 other => other,
170 })?;
171 let name = described
172 .iter()
173 .find(|(c, _, _)| c == column)
174 .map(|(_, t, _)| t.clone())
175 .ok_or_else(|| {
176 FaucetError::Config(format!(
177 "redshift: incremental replication `column` {column:?} is not a column of \
178 the query's output"
179 ))
180 })?;
181 cast_type(&name)
182 }
183
184 async fn describe(
188 &self,
189 context: &HashMap<String, Value>,
190 ) -> Result<Vec<(String, String, Option<bool>)>, FaucetError> {
191 use sqlx::Executor;
192 let (probe, binds) = self.resolve_query(context, None, "");
193 let probe = probe.replace(BOOKMARK_TOKEN, "NULL");
194 let calls = self.calls.get();
195 match &self.backend {
196 Backend::Wire(pool) => {
197 let pool = pool.get(&calls).await?;
198 let described = faucet_common_redshift::calls::once(&calls, "describe", async {
199 (&pool)
200 .describe(&probe)
201 .await
202 .map_err(|e| FaucetError::Source(e.to_string()))
203 })
204 .await?;
205 Ok(described
206 .columns()
207 .iter()
208 .enumerate()
209 .map(|(i, c)| {
210 (
211 sqlx::Column::name(c).to_string(),
212 sqlx::TypeInfo::name(sqlx::Column::type_info(c)).to_string(),
213 described.nullable(i),
214 )
215 })
216 .collect())
217 }
218 Backend::DataApi(api) => {
219 let inner = probe.trim_end_matches(|ch: char| ch.is_whitespace() || ch == ';');
220 let sql = format!("SELECT * FROM ({inner}\n) AS q LIMIT 0");
221 let (columns, _) = api
222 .query(&calls, &data_api::named_parameters(&sql, &binds))
223 .await?;
224 Ok(columns
225 .into_iter()
226 .map(|c| (c.name, c.type_name, c.nullable))
227 .collect())
228 }
229 }
230 }
231
232 async fn open<'a>(
234 calls: &faucet_core::io::CallContext,
235 conn: &'a Conn<'a>,
236 sql: &'a str,
237 binds: &'a [Value],
238 ) -> Result<Cursor<'a>, FaucetError> {
239 use futures::StreamExt;
240 match conn {
241 Conn::DataApi(api) => {
242 let done = api
243 .execute(calls, Effect::Read, &data_api::named_parameters(sql, binds))
244 .await?;
245 Ok(Cursor::DataApi {
246 api,
247 id: done.id,
248 token: None,
249 columns: Vec::new(),
250 done: !done.has_result_set,
251 })
252 }
253 Conn::Wire(pool) => {
254 let query = bind_params(sqlx::query(sql), binds)?;
255 calls.record("query");
256 Ok(Cursor::Wire(query.fetch(pool).boxed()))
257 }
258 }
259 }
260
261 async fn next_chunk(
264 calls: &faucet_core::io::CallContext,
265 cursor: &mut Cursor<'_>,
266 max: usize,
267 ) -> Result<(Vec<Value>, bool), FaucetError> {
268 match cursor {
269 Cursor::Wire(rows) => {
270 let reads = calls.clone().with_roundtrip_metering(false);
271 let fetched =
272 faucet_common_redshift::calls::next_rows(&reads, "fetch", rows, max, |e| {
273 FaucetError::Source(format!("redshift query failed: {e}"))
274 })
275 .await?;
276 let exhausted = fetched.len() < max;
277 let rows = fetched.iter().map(row_to_json).collect::<Result<_, _>>()?;
278 Ok((rows, exhausted))
279 }
280 Cursor::DataApi {
281 api,
282 id,
283 token,
284 columns,
285 done,
286 } => {
287 if *done {
288 return Ok((Vec::new(), true));
289 }
290 let page = api.result_page(calls, id, token.as_deref()).await?;
291 if columns.is_empty() {
292 *columns = page.columns;
293 }
294 let rows = page
295 .rows
296 .iter()
297 .map(|f| data_api::row_to_json(columns, f).map(Value::Object))
298 .collect::<Result<_, _>>()?;
299 *token = page.next_token;
300 *done = token.is_none();
301 Ok((rows, *done))
302 }
303 }
304 }
305}
306
307fn bookmark_cast(cast: &str, bookmark: &Value) -> String {
314 let base = cast.trim().to_ascii_uppercase();
315 if !matches!(base.as_str(), "NUMERIC" | "DECIMAL") {
316 return cast.to_string();
317 }
318 let text = match bookmark {
319 Value::String(s) => s.trim().to_string(),
320 Value::Number(n) => n.to_string(),
321 _ => String::new(),
322 };
323 let mantissa = text.split(['e', 'E']).next().unwrap_or("");
324 let scale = mantissa
325 .split_once('.')
326 .map_or(0, |(_, frac)| {
327 frac.chars().filter(char::is_ascii_digit).count()
328 })
329 .min(37);
330 format!("NUMERIC(38,{scale})")
331}
332
333fn cast_type(name: &str) -> Result<String, FaucetError> {
335 let ok = !name.is_empty()
336 && name
337 .chars()
338 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | ' ' | '"' | '[' | ']'));
339 if ok {
340 Ok(name.to_string())
341 } else {
342 Err(FaucetError::Config(format!(
343 "redshift: the incremental cursor column has type {name:?}, which cannot be \
344 compared to a bookmark; cast it in the query"
345 )))
346 }
347}
348
349fn default_state_key(config: &RedshiftSourceConfig) -> String {
351 let material = format!(
353 "{}\u{1f}{}\u{1f}{}\u{1f}{}",
354 config.connection.endpoint_label(),
355 config.connection.port,
356 config.connection.database,
357 config.query
358 );
359 format!(
360 "redshift:{:016x}",
361 faucet_core::shard::shard_hash(&material)
362 )
363}
364
365fn declared_type(type_name: &str) -> faucet_core::LogicalType {
367 use faucet_core::LogicalType as L;
368 match type_name.to_ascii_uppercase().as_str() {
369 "TIMETZ" | "INTERVAL" | "OID" => L::String,
370 _ => L::from_sql_type(type_name, None, None).unwrap_or(L::String),
371 }
372}
373
374#[async_trait]
375impl Source for RedshiftSource {
376 fn connector_name(&self) -> &'static str {
377 "redshift"
378 }
379
380 fn capabilities(&self) -> faucet_core::Capabilities {
381 faucet_core::Capabilities::of_source(self)
382 .to_builder()
383 .declared_columns(true)
384 .build()
385 }
386
387 async fn declared_columns(
390 &self,
391 context: &HashMap<String, Value>,
392 ) -> Result<Option<faucet_core::DeclaredColumns>, FaucetError> {
393 let Ok(described) = self.describe(context).await else {
394 return Ok(None);
395 };
396 Ok(Some(faucet_core::DeclaredColumns::new(
397 described.into_iter().map(|(name, ty, nullable)| {
398 faucet_core::DeclaredColumn::new(name, declared_type(&ty), nullable != Some(false))
399 }),
400 )))
401 }
402
403 fn set_call_context(&self, ctx: faucet_core::io::CallContext) {
404 self.calls.install(ctx);
405 }
406
407 fn set_roundtrip_recorder(
408 &self,
409 recorder: std::sync::Arc<faucet_core::observability::RoundtripRecorder>,
410 ) {
411 self.calls.install_recorder(recorder);
412 }
413
414 fn config_schema(&self) -> Value {
415 serde_json::to_value(faucet_core::schema_for!(RedshiftSourceConfig))
416 .expect("schema serialization")
417 }
418
419 fn dataset_uri(&self) -> String {
420 let conn = &self.config.connection;
421 if conn.uses_data_api() {
422 return format!(
423 "redshift://{}/{}?query={}",
424 conn.endpoint_label(),
425 conn.database,
426 self.config.query
427 );
428 }
429 format!(
430 "redshift://{}:{}/{}?query={}",
431 self.config.connection.host,
432 self.config.connection.port,
433 self.config.connection.database,
434 self.config.query
435 )
436 }
437
438 fn state_key(&self) -> Option<String> {
439 match &self.config.replication {
440 RedshiftReplication::Full => None,
441 RedshiftReplication::Incremental { .. } => Some(
442 self.config
443 .state_key
444 .clone()
445 .unwrap_or_else(|| default_state_key(&self.config)),
446 ),
447 }
448 }
449
450 async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
451 let start = CursorBookmark::from_state(bookmark)?;
452 *self
453 .start_bookmark
454 .lock()
455 .expect("start_bookmark mutex poisoned") = Some(start);
456 Ok(())
457 }
458
459 async fn fetch_with_context(
460 &self,
461 context: &HashMap<String, Value>,
462 ) -> Result<Vec<Value>, FaucetError> {
463 Ok(self.fetch_with_context_incremental(context).await?.0)
464 }
465
466 async fn fetch_with_context_incremental(
467 &self,
468 context: &HashMap<String, Value>,
469 ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
470 use futures::StreamExt;
471 let mut records = Vec::new();
472 let mut bookmark = None;
473 let mut s = self.stream_pages(context, self.config.batch_size);
474 while let Some(page) = s.next().await {
475 let page = page?;
476 records.extend(page.records);
477 bookmark = page.bookmark.or(bookmark);
478 }
479 Ok((records, bookmark))
480 }
481
482 fn stream_pages<'a>(
491 &'a self,
492 context: &'a HashMap<String, Value>,
493 _batch_size: usize,
494 ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
495 let batch_size = self.config.batch_size;
496
497 Box::pin(async_stream::try_stream! {
498 let incr = self.incremental_ctx();
499 let cast = match &incr {
500 Some(ic) => self.cursor_cast(context, &ic.column).await?,
501 None => String::new(),
502 };
503 let (query_str, binds) = self.resolve_query(context, incr.as_ref(), &cast);
504 let mut tracker = incr
505 .as_ref()
506 .map(|ic| CursorTracker::new(ic.column.clone(), ic.start.clone()));
507 let calls = self.calls.get();
508 let conn = match &self.backend {
509 Backend::Wire(p) => Conn::Wire(p.get(&calls).await?),
510 Backend::DataApi(api) => Conn::DataApi(api),
511 };
512 let mut cursor = Self::open(&calls, &conn, &query_str, &binds).await?;
513 let chunk = if batch_size == 0 { usize::MAX } else { batch_size };
514 let cap = if batch_size == 0 { 1024 } else { batch_size };
515 let mut buffer: Vec<Value> = Vec::with_capacity(cap);
516 let mut total = 0usize;
517
518 loop {
519 let (fetched, exhausted) = Self::next_chunk(&calls, &mut cursor, chunk).await?;
520 for obj in fetched {
521 if tracker.as_mut().is_none_or(|t| t.admit(&obj)) {
522 buffer.push(obj);
523 }
524 if buffer.len() >= chunk {
525 let page = std::mem::replace(&mut buffer, Vec::with_capacity(cap));
526 total += page.len();
527 let bookmark = tracker.as_ref().map(|t| t.bookmark().to_value());
528 yield StreamPage { records: page, bookmark };
529 }
530 }
531 if exhausted {
532 break;
533 }
534 }
535
536 if !buffer.is_empty() || tracker.is_some() {
537 total += buffer.len();
538 let bookmark = tracker.as_ref().map(|t| t.bookmark().to_value());
539 yield StreamPage { records: buffer, bookmark };
540 }
541 if let Some(missing) = tracker.as_ref().map(CursorTracker::missing).filter(|n| *n > 0) {
542 tracing::warn!(
543 missing,
544 "Redshift incremental read: rows without a cursor value were emitted \
545 without moving the bookmark"
546 );
547 }
548
549 tracing::info!(
550 rows = total,
551 batch_size,
552 query = %self.config.query,
553 "Redshift source stream complete",
554 );
555 })
556 }
557
558 async fn check(
561 &self,
562 ctx: &faucet_core::check::CheckContext,
563 ) -> Result<faucet_core::check::CheckReport, FaucetError> {
564 use faucet_core::check::{CheckReport, Probe};
565
566 let started = std::time::Instant::now();
567 let calls = self.calls.get();
568 let ping = async {
569 match &self.backend {
570 Backend::Wire(pool) => {
571 let pool = pool.get(&calls).await?;
572 faucet_common_redshift::calls::once(&calls, "query", async {
573 sqlx::query("SELECT 1")
574 .execute(&pool)
575 .await
576 .map(|_| ())
577 .map_err(|e| FaucetError::Source(e.to_string()))
578 })
579 .await
580 }
581 Backend::DataApi(api) => api
582 .query(&calls, &data_api::Statement::new("SELECT 1"))
583 .await
584 .map(|_| ()),
585 }
586 };
587 let probe = match tokio::time::timeout(ctx.timeout, ping).await {
588 Ok(Ok(_)) => Probe::pass("auth", started.elapsed()),
589 Ok(Err(e)) => Probe::fail_hint(
590 "auth",
591 started.elapsed(),
592 e.to_string(),
593 "check host/port/database/user/credentials and that the cluster is reachable",
594 ),
595 Err(_) => Probe::fail_hint(
596 "auth",
597 started.elapsed(),
598 "timed out",
599 "check host/port/database/user/credentials and that the cluster is reachable",
600 ),
601 };
602 Ok(CheckReport::single(probe))
603 }
604}
605
606#[cfg(test)]
607mod tests {
608 use super::*;
609 use crate::config::RedshiftReplication;
610 use faucet_common_redshift::RedshiftConnection;
611 use serde_json::json;
612
613 fn base_config() -> RedshiftSourceConfig {
614 RedshiftSourceConfig {
615 connection: RedshiftConnection::new("host", "db", "user", "pw"),
616 query: "SELECT * FROM t".into(),
617 params: Vec::new(),
618 max_connections: 10,
619 batch_size: 1000,
620 replication: RedshiftReplication::Full,
621 state_key: None,
622 }
623 }
624
625 fn source(c: RedshiftSourceConfig) -> RedshiftSource {
626 RedshiftSource::new(c).unwrap()
627 }
628
629 #[test]
630 fn new_rejects_invalid_batch_size() {
631 let mut c = base_config();
632 c.batch_size = faucet_core::MAX_BATCH_SIZE + 1;
633 match RedshiftSource::new(c) {
634 Err(FaucetError::Config(m)) => assert!(m.contains("batch_size"), "got: {m}"),
635 _ => panic!("expected a batch_size Config error"),
636 }
637 }
638
639 fn with_credentials(credentials: Value) -> RedshiftSourceConfig {
640 let mut c = base_config();
641 c.connection.credentials = serde_json::from_value(credentials).unwrap();
642 c
643 }
644
645 #[tokio::test]
646 async fn new_builds_iam_and_data_api_sources_and_validates_them() {
647 let iam = with_credentials(json!({"type": "iam", "config": {"cluster_identifier": "c"}}));
648 assert!(
649 matches!(RedshiftSource::new(iam).unwrap().backend, Backend::Wire(ref p) if p.is_iam())
650 );
651 let api = with_credentials(
652 json!({"type": "redshift_data_api", "config": {"workgroup_name": "wg"}}),
653 );
654 let s = RedshiftSource::new(api).unwrap();
655 assert!(matches!(s.backend, Backend::DataApi(_)));
656 assert_eq!(
657 s.dataset_uri(),
658 "redshift://workgroup:wg/db?query=SELECT * FROM t"
659 );
660 for bad in [
661 json!({"type": "iam", "config": {}}),
662 json!({"type": "redshift_data_api", "config": {"cluster_identifier": "c"}}),
663 ] {
664 assert!(matches!(
665 RedshiftSource::new(with_credentials(bad)),
666 Err(FaucetError::Config(_))
667 ));
668 }
669 }
670
671 #[tokio::test]
672 async fn connector_name_is_redshift() {
673 assert_eq!(source(base_config()).connector_name(), "redshift");
674 }
675
676 #[tokio::test]
677 async fn dataset_uri_has_host_db_and_query() {
678 let s = source(base_config());
679 assert_eq!(
680 s.dataset_uri(),
681 "redshift://host:5439/db?query=SELECT * FROM t"
682 );
683 }
684
685 #[tokio::test]
686 async fn config_schema_reports_required_fields() {
687 let s = source(base_config());
688 let schema = s.config_schema();
689 assert!(schema["properties"]["query"].is_object());
690 let required = schema["required"].as_array().expect("required array");
691 assert!(required.iter().any(|v| v == "query"));
692 }
693
694 #[tokio::test]
695 async fn full_mode_has_no_state_key() {
696 assert!(source(base_config()).state_key().is_none());
697 }
698
699 #[tokio::test]
700 async fn incremental_state_key_derived_and_stable() {
701 let mut c = base_config();
702 c.replication = RedshiftReplication::Incremental {
703 column: "ts".into(),
704 initial_value: json!("2026-01-01"),
705 };
706 let k1 = source(c.clone()).state_key().unwrap();
707 let k2 = source(c).state_key().unwrap();
708 assert_eq!(k1, k2);
709 assert!(k1.starts_with("redshift:"));
710 let c = base_config();
711 let material = format!(
712 "{}\u{1f}{}\u{1f}{}\u{1f}{}",
713 c.connection.host, c.connection.port, c.connection.database, c.query
714 );
715 assert_eq!(
716 k1,
717 format!(
718 "redshift:{:016x}",
719 faucet_core::shard::shard_hash(&material)
720 )
721 );
722 }
723
724 #[tokio::test]
725 async fn explicit_state_key_wins_and_bookmark_overrides_initial() {
726 let mut c = base_config();
727 c.state_key = Some("my-key".into());
728 c.replication = RedshiftReplication::Incremental {
729 column: "ts".into(),
730 initial_value: json!("2026-01-01"),
731 };
732 let s = source(c);
733 assert_eq!(s.state_key().as_deref(), Some("my-key"));
734 s.apply_start_bookmark(json!("2026-06-01")).await.unwrap();
735 assert_eq!(
736 s.incremental_ctx().unwrap().start.value,
737 json!("2026-06-01")
738 );
739 assert!(
740 s.apply_start_bookmark(json!({"value": null}))
741 .await
742 .is_err()
743 );
744 }
745
746 #[tokio::test]
747 async fn resolve_query_no_context_no_incremental_is_verbatim() {
748 let mut c = base_config();
749 c.params = vec![json!(7)];
750 let s = source(c);
751 let (sql, binds) = s.resolve_query(&HashMap::new(), None, "");
752 assert_eq!(sql, "SELECT * FROM t");
753 assert_eq!(binds, vec![json!(7)]);
754 }
755
756 #[tokio::test]
757 async fn resolve_query_substitutes_context_positionally() {
758 let mut c = base_config();
759 c.query = "SELECT * FROM t WHERE id = {parent.id}".into();
760 let s = source(c);
761 let mut ctx = HashMap::new();
762 ctx.insert("parent.id".to_string(), json!(42));
763 let (sql, binds) = s.resolve_query(&ctx, None, "");
764 assert_eq!(sql, "SELECT * FROM t WHERE id = $1");
765 assert_eq!(binds, vec![json!(42)]);
766 }
767
768 #[tokio::test]
769 async fn resolve_query_pushes_down_bookmark_after_params_and_context() {
770 let mut c = base_config();
771 c.params = vec![json!(1)];
772 c.query = "SELECT * FROM t WHERE tenant = {p.t} AND ts >= ${bookmark};".into();
773 c.replication = RedshiftReplication::Incremental {
774 column: "ts".into(),
775 initial_value: json!("2026-01-01"),
776 };
777 let s = source(c);
778 let incr = s.incremental_ctx();
779 let mut ctx = HashMap::new();
780 ctx.insert("p.t".to_string(), json!("acme"));
781 let (sql, binds) = s.resolve_query(&ctx, incr.as_ref(), "TIMESTAMP");
782 assert_eq!(
784 sql,
785 "SELECT * FROM (SELECT * FROM t WHERE tenant = $2 AND ts >= CAST($3 AS TIMESTAMP)\n) \
786 AS q WHERE q.\"ts\" >= CAST($4 AS TIMESTAMP) ORDER BY q.\"ts\""
787 );
788 assert_eq!(
789 binds,
790 vec![
791 json!(1),
792 json!("acme"),
793 json!("2026-01-01"),
794 json!("2026-01-01")
795 ]
796 );
797 }
798
799 #[test]
800 fn a_decimal_bookmark_is_cast_at_its_own_scale() {
801 assert_eq!(bookmark_cast("NUMERIC", &json!("123.5")), "NUMERIC(38,1)");
802 assert_eq!(bookmark_cast("numeric", &json!("0.001")), "NUMERIC(38,3)");
803 assert_eq!(bookmark_cast("DECIMAL", &json!(7)), "NUMERIC(38,0)");
804 assert_eq!(bookmark_cast("NUMERIC", &json!(1.25)), "NUMERIC(38,2)");
805 assert_eq!(bookmark_cast("TIMESTAMP", &json!("123.5")), "TIMESTAMP");
806 }
807
808 #[tokio::test]
809 async fn a_decimal_cursor_never_binds_through_a_bare_numeric() {
810 let mut c = base_config();
811 c.replication = RedshiftReplication::Incremental {
812 column: "amount".into(),
813 initial_value: json!("123.500"),
814 };
815 let s = source(c);
816 let incr = s.incremental_ctx();
817 let (sql, _) = s.resolve_query(&HashMap::new(), incr.as_ref(), "NUMERIC");
818 assert!(sql.contains("CAST($1 AS NUMERIC(38,3))"), "{sql}");
819 assert!(!sql.contains("AS NUMERIC)"), "{sql}");
820 }
821
822 #[test]
823 fn cast_type_accepts_type_names_only() {
824 assert_eq!(cast_type("TIMESTAMPTZ").unwrap(), "TIMESTAMPTZ");
825 assert_eq!(cast_type("character varying").unwrap(), "character varying");
826 assert!(cast_type("INT4); DROP TABLE t; --").is_err());
827 assert!(cast_type("").is_err());
828 }
829
830 #[test]
831 fn declared_types_follow_the_rendering() {
832 use faucet_core::LogicalType as L;
833 assert_eq!(
834 declared_type("NUMERIC"),
835 L::Decimal {
836 precision: None,
837 scale: None
838 }
839 );
840 assert_eq!(declared_type("TIMESTAMPTZ"), L::Timestamp { tz: true });
841 assert_eq!(declared_type("TIMESTAMP"), L::Timestamp { tz: false });
842 assert_eq!(declared_type("INT4"), L::Int64);
843 assert_eq!(declared_type("TIMETZ"), L::String);
844 assert_eq!(declared_type("INTERVAL"), L::String);
845 assert_eq!(declared_type("SUPER"), L::String);
846 }
847}