1use anyhow::{anyhow, Context, Result};
18use async_trait::async_trait;
19use bytes::Bytes;
20use drasi_core::models::{
21 Element, ElementMetadata, ElementPropertyMap, ElementReference, SourceChange,
22};
23use drasi_postgres_common::{
24 oid_from_information_schema, parse_bytea_text, PostgresValue, BOOL, BYTEA, CHAR, DATE, FLOAT4,
25 FLOAT8, INT2, INT4, INT8, JSON, JSONB, NAME, NUMERIC, TEXT, TIME, TIMESTAMP, TIMESTAMPTZ, UUID,
26 VARCHAR,
27};
28use log::{debug, error, info, warn};
29use std::collections::HashMap;
30use std::sync::Arc;
31use tokio_postgres::{Client, NoTls, Row, Transaction};
32
33use drasi_lib::bootstrap::{
34 BootstrapContext, BootstrapProvider, BootstrapRequest, BootstrapResult,
35};
36use drasi_lib::channels::SourceChangeEvent;
37
38pub use crate::config::{PostgresBootstrapConfig, SslMode, TableKeyConfig};
39
40fn parse_lsn(lsn_str: &str) -> Result<u64> {
41 let parts: Vec<&str> = lsn_str.split('/').collect();
42 if parts.len() != 2 {
43 return Err(anyhow!("Invalid LSN format: {lsn_str}"));
44 }
45
46 let high = u64::from_str_radix(parts[0], 16)
47 .with_context(|| format!("Invalid high bits in LSN: {lsn_str}"))?;
48 let low = u64::from_str_radix(parts[1], 16)
49 .with_context(|| format!("Invalid low bits in LSN: {lsn_str}"))?;
50
51 Ok((high << 32) | low)
52}
53
54pub fn snapshot_position_bytes(snapshot_lsn: u64) -> Bytes {
63 let mut buf = Vec::with_capacity(16);
64 buf.extend_from_slice(&snapshot_lsn.to_be_bytes());
65 buf.extend_from_slice(&u64::MAX.to_be_bytes());
66 Bytes::from(buf)
67}
68
69pub struct PostgresBootstrapProvider {
74 config: PostgresConfig,
75}
76
77impl PostgresBootstrapProvider {
78 pub fn new(postgres_config: PostgresBootstrapConfig) -> Self {
80 Self {
81 config: PostgresConfig::from_bootstrap_config(postgres_config),
82 }
83 }
84
85 pub fn builder() -> PostgresBootstrapProviderBuilder {
87 PostgresBootstrapProviderBuilder::new()
88 }
89}
90
91pub struct PostgresBootstrapProviderBuilder {
108 host: String,
109 port: u16,
110 database: String,
111 user: String,
112 password: String,
113 tables: Vec<String>,
114 slot_name: String,
115 publication_name: String,
116 ssl_mode: SslMode,
117 table_keys: Vec<TableKeyConfig>,
118}
119
120impl PostgresBootstrapProviderBuilder {
121 pub fn new() -> Self {
123 Self {
124 host: "localhost".to_string(), port: 5432,
126 database: String::new(),
127 user: String::new(),
128 password: String::new(),
129 tables: Vec::new(),
130 slot_name: "drasi_slot".to_string(),
131 publication_name: "drasi_pub".to_string(),
132 ssl_mode: SslMode::Disable,
133 table_keys: Vec::new(),
134 }
135 }
136
137 pub fn with_host(mut self, host: impl Into<String>) -> Self {
139 self.host = host.into();
140 self
141 }
142
143 pub fn with_port(mut self, port: u16) -> Self {
145 self.port = port;
146 self
147 }
148
149 pub fn with_database(mut self, database: impl Into<String>) -> Self {
151 self.database = database.into();
152 self
153 }
154
155 pub fn with_user(mut self, user: impl Into<String>) -> Self {
157 self.user = user.into();
158 self
159 }
160
161 pub fn with_password(mut self, password: impl Into<String>) -> Self {
163 self.password = password.into();
164 self
165 }
166
167 pub fn with_tables(mut self, tables: Vec<String>) -> Self {
169 self.tables = tables;
170 self
171 }
172
173 pub fn with_table(mut self, table: impl Into<String>) -> Self {
175 self.tables.push(table.into());
176 self
177 }
178
179 pub fn with_slot_name(mut self, slot_name: impl Into<String>) -> Self {
181 self.slot_name = slot_name.into();
182 self
183 }
184
185 pub fn with_publication_name(mut self, publication_name: impl Into<String>) -> Self {
187 self.publication_name = publication_name.into();
188 self
189 }
190
191 pub fn with_ssl_mode(mut self, ssl_mode: SslMode) -> Self {
193 self.ssl_mode = ssl_mode;
194 self
195 }
196
197 pub fn with_table_keys(mut self, table_keys: Vec<TableKeyConfig>) -> Self {
199 self.table_keys = table_keys;
200 self
201 }
202
203 pub fn with_table_key(mut self, table: impl Into<String>, key_columns: Vec<String>) -> Self {
205 self.table_keys.push(TableKeyConfig {
206 table: table.into(),
207 key_columns,
208 });
209 self
210 }
211
212 pub fn build(self) -> PostgresBootstrapProvider {
214 let config = PostgresBootstrapConfig {
215 host: self.host,
216 port: self.port,
217 database: self.database,
218 user: self.user,
219 password: self.password,
220 tables: self.tables,
221 slot_name: self.slot_name,
222 publication_name: self.publication_name,
223 ssl_mode: self.ssl_mode,
224 table_keys: self.table_keys,
225 };
226 PostgresBootstrapProvider::new(config)
227 }
228}
229
230impl Default for PostgresBootstrapProviderBuilder {
231 fn default() -> Self {
232 Self::new()
233 }
234}
235
236#[async_trait]
237impl BootstrapProvider for PostgresBootstrapProvider {
238 async fn bootstrap(
239 &self,
240 request: BootstrapRequest,
241 context: &BootstrapContext,
242 event_tx: drasi_lib::channels::BootstrapEventSender,
243 _settings: Option<&drasi_lib::config::SourceSubscriptionSettings>,
244 ) -> Result<BootstrapResult> {
245 info!(
246 "Starting PostgreSQL bootstrap for query '{}' with {} node labels and {} relation labels",
247 request.query_id,
248 request.node_labels.len(),
249 request.relation_labels.len()
250 );
251
252 let mut handler =
254 PostgresBootstrapHandler::new(self.config.clone(), context.source_id.clone());
255
256 let query_id = request.query_id.clone();
258
259 let (count, source_position) = handler.execute(request, context, event_tx).await?;
261
262 info!("Completed PostgreSQL bootstrap for query {query_id}: sent {count} records");
263
264 Ok(BootstrapResult {
265 event_count: count,
266 source_position: Some(source_position),
267 })
268 }
269}
270
271#[derive(Debug, Clone)]
273struct PostgresConfig {
274 pub host: String,
275 pub port: u16,
276 pub database: String,
277 pub user: String,
278 pub password: String,
279 #[allow(dead_code)]
280 pub tables: Vec<String>,
281 #[allow(dead_code)]
282 pub slot_name: String,
283 #[allow(dead_code)]
284 pub publication_name: String,
285 #[allow(dead_code)]
286 pub ssl_mode: SslMode,
287 pub table_keys: Vec<TableKeyConfig>,
288}
289
290impl PostgresConfig {
291 fn from_bootstrap_config(postgres_config: PostgresBootstrapConfig) -> Self {
292 PostgresConfig {
293 host: postgres_config.host.clone(),
294 port: postgres_config.port,
295 database: postgres_config.database.clone(),
296 user: postgres_config.user.clone(),
297 password: postgres_config.password.clone(),
298 tables: postgres_config.tables.clone(),
299 slot_name: postgres_config.slot_name.clone(),
300 publication_name: postgres_config.publication_name.clone(),
301 ssl_mode: postgres_config.ssl_mode,
302 table_keys: postgres_config.table_keys.clone(),
303 }
304 }
305}
306
307struct PostgresBootstrapHandler {
309 config: PostgresConfig,
310 source_id: String,
311 table_primary_keys: HashMap<String, Vec<String>>,
313}
314
315impl PostgresBootstrapHandler {
316 fn new(config: PostgresConfig, source_id: String) -> Self {
317 Self {
318 config,
319 source_id,
320 table_primary_keys: HashMap::new(),
321 }
322 }
323
324 async fn execute(
326 &mut self,
327 request: BootstrapRequest,
328 context: &BootstrapContext,
329 event_tx: drasi_lib::channels::BootstrapEventSender,
330 ) -> Result<(usize, Bytes)> {
331 info!(
332 "Bootstrap: Connecting to PostgreSQL at {}:{}",
333 self.config.host, self.config.port
334 );
335
336 let mut client = self.connect().await?;
338
339 self.query_primary_keys(&client).await?;
341
342 info!("Bootstrap: Connected, creating snapshot transaction...");
343 let (transaction, snapshot_lsn) = self.create_snapshot(&mut client).await?;
345 let source_position = snapshot_position_bytes(snapshot_lsn);
346
347 info!("Bootstrap snapshot created at LSN: {snapshot_lsn:x}");
348
349 let tables = self.resolve_tables(&request, &transaction).await?;
351 info!(
352 "Resolved {} labels to {} tables",
353 request.node_labels.len() + request.relation_labels.len(),
354 tables.len()
355 );
356
357 let mut total_count = 0;
359 for table in &tables {
360 let count = self
361 .bootstrap_table(&transaction, table, context, &event_tx)
362 .await?;
363 info!("Bootstrapped {count} rows from table '{table}'");
364 total_count += count;
365 }
366
367 transaction.commit().await?;
369
370 info!("Bootstrap completed: {total_count} total elements sent");
371 Ok((total_count, source_position))
372 }
373
374 async fn connect(&self) -> Result<Client> {
376 let connection_string = format!(
377 "host={} port={} user={} password={} dbname={}",
378 self.config.host,
379 self.config.port,
380 self.config.user,
381 self.config.password,
382 self.config.database
383 );
384
385 let (client, connection) = tokio_postgres::connect(&connection_string, NoTls).await?;
386
387 tokio::spawn(async move {
389 if let Err(e) = connection.await {
390 error!("PostgreSQL connection error: {e}");
391 }
392 });
393
394 Ok(client)
395 }
396
397 async fn create_snapshot<'a>(&self, client: &'a mut Client) -> Result<(Transaction<'a>, u64)> {
399 let transaction = client
401 .build_transaction()
402 .isolation_level(tokio_postgres::IsolationLevel::RepeatableRead)
403 .start()
404 .await?;
405
406 let row = transaction
408 .query_one("SELECT pg_current_wal_lsn()::text", &[])
409 .await?;
410 let lsn: String = row.get(0);
411 let lsn = parse_lsn(&lsn).context("Failed to parse PostgreSQL snapshot LSN")?;
412
413 Ok((transaction, lsn))
414 }
415
416 async fn resolve_tables(
420 &self,
421 request: &BootstrapRequest,
422 transaction: &Transaction<'_>,
423 ) -> Result<Vec<String>> {
424 let mut tables = Vec::new();
425
426 let all_labels: Vec<String> = request
428 .node_labels
429 .iter()
430 .chain(request.relation_labels.iter())
431 .cloned()
432 .collect();
433
434 for label in all_labels {
435 if self.table_exists(transaction, &label).await? {
436 tables.push(label);
437 } else {
438 warn!("Table '{label}' does not exist, skipping");
439 }
440 }
441
442 Ok(tables)
443 }
444
445 async fn table_exists(&self, transaction: &Transaction<'_>, table_name: &str) -> Result<bool> {
447 let row = transaction
448 .query_one(
449 "SELECT EXISTS (
450 SELECT 1 FROM information_schema.tables
451 WHERE table_schema = 'public'
452 AND table_name = $1
453 )",
454 &[&table_name],
455 )
456 .await?;
457
458 Ok(row.get(0))
459 }
460
461 async fn bootstrap_table(
463 &self,
464 transaction: &Transaction<'_>,
465 table: &str,
466 context: &BootstrapContext,
467 event_tx: &drasi_lib::channels::BootstrapEventSender,
468 ) -> Result<usize> {
469 debug!("Starting bootstrap of table '{table}'");
470
471 let columns = self.get_table_columns(transaction, table).await?;
473
474 let query = format!("SELECT * FROM \"{}\"", table.replace('"', "\"\""));
476 let rows = transaction.query(&query, &[]).await?;
477
478 let mut count = 0;
479 let mut batch = Vec::new();
480 let batch_size = 1000;
481
482 for row in rows {
483 let source_change = self.row_to_source_change(&row, table, &columns).await?;
484
485 batch.push(SourceChangeEvent {
486 source_id: self.source_id.clone(),
487 change: source_change,
488 timestamp: chrono::Utc::now(),
489 sequence: None,
490 });
491
492 if batch.len() >= batch_size {
493 self.send_batch(&mut batch, context, event_tx).await?;
494 count += batch_size;
495 }
496 }
497
498 if !batch.is_empty() {
500 count += batch.len();
501 self.send_batch(&mut batch, context, event_tx).await?;
502 }
503
504 Ok(count)
505 }
506
507 async fn get_table_columns(
509 &self,
510 transaction: &Transaction<'_>,
511 table_name: &str,
512 ) -> Result<Vec<ColumnInfo>> {
513 let catalog_result = transaction
517 .query(
518 "SELECT a.attname AS column_name,
519 CASE
520 WHEN t.typtype = 'd' THEN t.typbasetype
521 ELSE a.atttypid
522 END::int4 AS type_oid
523 FROM pg_attribute a
524 JOIN pg_class c ON a.attrelid = c.oid
525 JOIN pg_namespace n ON c.relnamespace = n.oid
526 JOIN pg_type t ON a.atttypid = t.oid
527 WHERE n.nspname = 'public'
528 AND c.relname = $1
529 AND a.attnum > 0
530 AND NOT a.attisdropped
531 ORDER BY a.attnum",
532 &[&table_name],
533 )
534 .await;
535
536 if let Ok(rows) = catalog_result {
537 if !rows.is_empty() {
538 let mut columns = Vec::new();
539 for row in rows {
540 columns.push(ColumnInfo {
541 name: row.get(0),
542 type_oid: row.get::<_, i32>(1),
543 });
544 }
545 return Ok(columns);
546 }
547 }
548
549 let rows = transaction
550 .query(
551 "SELECT column_name, data_type, udt_name
552 FROM information_schema.columns
553 WHERE table_schema = 'public' AND table_name = $1
554 ORDER BY ordinal_position",
555 &[&table_name],
556 )
557 .await?;
558
559 let mut columns = Vec::new();
560 for row in rows {
561 let name: String = row.get(0);
562 let data_type: String = row.get(1);
563 let udt_name: String = row.get(2);
564 let type_oid = oid_from_information_schema(&data_type, Some(&udt_name)) as i32;
565 columns.push(ColumnInfo { name, type_oid });
566 }
567
568 Ok(columns)
569 }
570
571 async fn query_primary_keys(&mut self, client: &Client) -> Result<()> {
573 info!("Querying primary key information from PostgreSQL system catalogs");
574
575 let query = r#"
576 SELECT
577 n.nspname as schema_name,
578 c.relname as table_name,
579 a.attname as column_name
580 FROM pg_constraint con
581 JOIN pg_class c ON con.conrelid = c.oid
582 JOIN pg_namespace n ON c.relnamespace = n.oid
583 JOIN pg_attribute a ON a.attrelid = c.oid
584 WHERE con.contype = 'p' -- Primary key constraint
585 AND a.attnum = ANY(con.conkey)
586 AND n.nspname NOT IN ('pg_catalog', 'information_schema')
587 ORDER BY n.nspname, c.relname, array_position(con.conkey, a.attnum)
588 "#;
589
590 let rows = client.query(query, &[]).await?;
591
592 let mut primary_keys: HashMap<String, Vec<String>> = HashMap::new();
593
594 for row in rows {
595 let schema: &str = row.get(0);
596 let table: &str = row.get(1);
597 let column: &str = row.get(2);
598
599 let table_key = if schema == "public" {
601 table.to_string()
602 } else {
603 format!("{schema}.{table}")
604 };
605
606 primary_keys
607 .entry(table_key.clone())
608 .or_default()
609 .push(column.to_string());
610
611 debug!("Found primary key column '{column}' for table '{table_key}'");
612 }
613
614 for table_key_config in &self.config.table_keys {
616 let table_name = &table_key_config.table;
617 let key_columns = &table_key_config.key_columns;
618
619 if !key_columns.is_empty() {
620 info!(
621 "Using user-configured key columns for table '{table_name}': {key_columns:?}"
622 );
623 primary_keys.insert(table_name.clone(), key_columns.clone());
624 }
625 }
626
627 self.table_primary_keys = primary_keys.clone();
629
630 info!("Found primary keys for {} tables", primary_keys.len());
631 for (table, keys) in &primary_keys {
632 info!("Table '{table}' primary key columns: {keys:?}");
633 }
634
635 Ok(())
636 }
637
638 async fn row_to_source_change(
640 &self,
641 row: &Row,
642 table: &str,
643 columns: &[ColumnInfo],
644 ) -> Result<SourceChange> {
645 let mut properties = ElementPropertyMap::new();
646
647 let pk_columns = self.table_primary_keys.get(table);
649
650 let mut pk_values = Vec::new();
652
653 for (idx, column) in columns.iter().enumerate() {
654 let is_pk = pk_columns
656 .map(|pks| pks.contains(&column.name))
657 .unwrap_or(false);
658
659 let pg_value = row_column_to_postgres_value(row, idx, column.type_oid as u32);
661 let element_value = pg_value.to_element_value();
662
663 if is_pk {
665 if let Some(value_str) = pg_value.to_key_string() {
666 pk_values.push(value_str);
667 }
668 }
669
670 properties.insert(&column.name, element_value);
671 }
672
673 let elem_id = if !pk_values.is_empty() {
676 format!("{}:{}", table, pk_values.join("_"))
678 } else if pk_columns.is_none() || pk_columns.map(|pks| pks.is_empty()).unwrap_or(true) {
679 warn!(
681 "No primary key found for table '{table}'. Consider adding 'table_keys' configuration."
682 );
683 format!("{}:{}", table, uuid::Uuid::new_v4())
685 } else {
686 format!("{}:{}", table, uuid::Uuid::new_v4())
688 };
689
690 let metadata = ElementMetadata {
691 reference: ElementReference::new(&self.source_id, &elem_id),
692 labels: Arc::from(vec![Arc::from(table)]),
693 effective_from: chrono::Utc::now().timestamp_millis() as u64,
694 };
695
696 let element = Element::Node {
697 metadata,
698 properties,
699 };
700
701 Ok(SourceChange::Insert { element })
702 }
703
704 async fn send_batch(
706 &self,
707 batch: &mut Vec<SourceChangeEvent>,
708 context: &BootstrapContext,
709 event_tx: &drasi_lib::channels::BootstrapEventSender,
710 ) -> Result<()> {
711 for event in batch.drain(..) {
712 let sequence = context.next_sequence();
714
715 let bootstrap_event = drasi_lib::channels::BootstrapEvent {
716 source_id: event.source_id,
717 change: event.change,
718 timestamp: event.timestamp,
719 sequence,
720 };
721 event_tx.send(bootstrap_event).await.map_err(|e| {
722 anyhow!("Failed to send bootstrap event to channel (channel may be closed): {e}")
723 })?;
724 }
725 Ok(())
726 }
727}
728
729#[derive(Debug)]
730struct ColumnInfo {
731 name: String,
732 type_oid: i32,
733}
734
735fn row_column_to_postgres_value(row: &Row, idx: usize, type_oid: u32) -> PostgresValue {
737 macro_rules! opt {
739 ($t:ty, $map:expr) => {
740 match row.try_get::<_, Option<$t>>(idx) {
741 Ok(Some(v)) => $map(v),
742 Ok(None) => PostgresValue::Null,
743 Err(e) => {
744 warn!(
745 "Failed to read column idx={idx} oid={type_oid} as {}: {e}",
746 stringify!($t)
747 );
748 match row.try_get::<_, Option<String>>(idx) {
750 Ok(Some(s)) => PostgresValue::Text(s),
751 Ok(None) => PostgresValue::Null,
752 Err(_) => PostgresValue::Null,
753 }
754 }
755 }
756 };
757 }
758
759 match type_oid {
760 BOOL => opt!(bool, PostgresValue::Bool),
761 INT2 => opt!(i16, PostgresValue::Int2),
762 INT4 => opt!(i32, PostgresValue::Int4),
763 INT8 => opt!(i64, PostgresValue::Int8),
764 FLOAT4 => opt!(f32, PostgresValue::Float4),
765 FLOAT8 => opt!(f64, PostgresValue::Float8),
766 NUMERIC => opt!(rust_decimal::Decimal, PostgresValue::Numeric),
767 TEXT | NAME => opt!(String, PostgresValue::Text),
768 VARCHAR => opt!(String, PostgresValue::Varchar),
769 CHAR => match row.try_get::<_, Option<String>>(idx) {
770 Ok(Some(s)) => PostgresValue::Char(s.trim_end().to_string()),
771 Ok(None) => PostgresValue::Null,
772 Err(e) => {
773 warn!("Failed to read char column idx={idx}: {e}");
774 PostgresValue::Null
775 }
776 },
777 UUID => opt!(uuid::Uuid, PostgresValue::Uuid),
778 TIMESTAMP => opt!(chrono::NaiveDateTime, PostgresValue::Timestamp),
779 TIMESTAMPTZ => opt!(chrono::DateTime<chrono::Utc>, PostgresValue::TimestampTz),
780 DATE => opt!(chrono::NaiveDate, PostgresValue::Date),
781 TIME => opt!(chrono::NaiveTime, PostgresValue::Time),
782 JSON => opt!(serde_json::Value, PostgresValue::Json),
783 JSONB => opt!(serde_json::Value, PostgresValue::Jsonb),
784 BYTEA => match row.try_get::<_, Option<Vec<u8>>>(idx) {
785 Ok(Some(bytes)) => PostgresValue::Bytea(bytes),
786 Ok(None) => PostgresValue::Null,
787 Err(e) => {
788 warn!("bytea try_get failed idx={idx}: {e}; trying text");
790 match row.try_get::<_, Option<String>>(idx) {
791 Ok(Some(s)) => match parse_bytea_text(&s) {
792 Ok(b) => PostgresValue::Bytea(b),
793 Err(_) => PostgresValue::Text(s),
794 },
795 Ok(None) => PostgresValue::Null,
796 Err(_) => PostgresValue::Null,
797 }
798 }
799 },
800 1007 => match row.try_get::<_, Option<Vec<Option<i32>>>>(idx) {
803 Ok(Some(v)) => PostgresValue::Array(
804 v.into_iter()
805 .map(|o| o.map(PostgresValue::Int4).unwrap_or(PostgresValue::Null))
806 .collect(),
807 ),
808 Ok(None) => PostgresValue::Null,
809 Err(_) => try_array_as_text(row, idx, type_oid),
810 },
811 1009 => match row.try_get::<_, Option<Vec<Option<String>>>>(idx) {
812 Ok(Some(v)) => PostgresValue::Array(
813 v.into_iter()
814 .map(|o| o.map(PostgresValue::Text).unwrap_or(PostgresValue::Null))
815 .collect(),
816 ),
817 Ok(None) => PostgresValue::Null,
818 Err(_) => try_array_as_text(row, idx, type_oid),
819 },
820 1016 => match row.try_get::<_, Option<Vec<Option<i64>>>>(idx) {
821 Ok(Some(v)) => PostgresValue::Array(
822 v.into_iter()
823 .map(|o| o.map(PostgresValue::Int8).unwrap_or(PostgresValue::Null))
824 .collect(),
825 ),
826 Ok(None) => PostgresValue::Null,
827 Err(_) => try_array_as_text(row, idx, type_oid),
828 },
829 1005 => match row.try_get::<_, Option<Vec<Option<i16>>>>(idx) {
830 Ok(Some(v)) => PostgresValue::Array(
831 v.into_iter()
832 .map(|o| o.map(PostgresValue::Int2).unwrap_or(PostgresValue::Null))
833 .collect(),
834 ),
835 Ok(None) => PostgresValue::Null,
836 Err(_) => try_array_as_text(row, idx, type_oid),
837 },
838 1000 => match row.try_get::<_, Option<Vec<Option<bool>>>>(idx) {
839 Ok(Some(v)) => PostgresValue::Array(
840 v.into_iter()
841 .map(|o| o.map(PostgresValue::Bool).unwrap_or(PostgresValue::Null))
842 .collect(),
843 ),
844 Ok(None) => PostgresValue::Null,
845 Err(_) => try_array_as_text(row, idx, type_oid),
846 },
847 _ => {
848 match row.try_get::<_, Option<String>>(idx) {
850 Ok(Some(s)) => {
851 if s.starts_with('{') {
853 match drasi_postgres_common::decode_text_to_postgres_value(&s, type_oid) {
854 Ok(v) => v,
855 Err(_) => PostgresValue::Text(s),
856 }
857 } else {
858 PostgresValue::Text(s)
859 }
860 }
861 Ok(None) => PostgresValue::Null,
862 Err(e) => {
863 warn!(
864 "Unsupported PG type oid={type_oid} at idx={idx}: {e}; emitting Null"
865 );
866 PostgresValue::Null
867 }
868 }
869 }
870 }
871}
872
873fn try_array_as_text(row: &Row, idx: usize, array_oid: u32) -> PostgresValue {
874 match row.try_get::<_, Option<String>>(idx) {
875 Ok(Some(s)) => match drasi_postgres_common::decode_text_to_postgres_value(&s, array_oid) {
876 Ok(v) => v,
877 Err(_) => PostgresValue::Text(s),
878 },
879 Ok(None) => PostgresValue::Null,
880 Err(_) => PostgresValue::Null,
881 }
882}
883
884#[cfg(test)]
885mod tests {
886 use drasi_core::models::validate_effective_from;
887
888 #[test]
894 fn effective_from_uses_milliseconds() {
895 let effective_from = chrono::Utc::now().timestamp_millis() as u64;
896 assert!(
897 validate_effective_from(effective_from).is_ok(),
898 "Postgres bootstrapper effective_from ({effective_from}) should be in millisecond range"
899 );
900 }
901
902 #[test]
904 fn effective_from_rejects_nanoseconds_pattern() {
905 let bad_effective_from = chrono::Utc::now().timestamp_nanos_opt().unwrap() as u64;
907 assert!(
908 validate_effective_from(bad_effective_from).is_err(),
909 "Nanosecond timestamp ({bad_effective_from}) should be rejected"
910 );
911 }
912
913 #[test]
918 fn snapshot_position_bytes_is_16_byte_max_padded() {
919 let lsn = 0x0000_0000_0152_00b0u64;
920 let pos = super::snapshot_position_bytes(lsn);
921 assert_eq!(pos.len(), 16, "snapshot boundary must be 16 bytes");
922
923 let mut expected = Vec::with_capacity(16);
924 expected.extend_from_slice(&lsn.to_be_bytes());
925 expected.extend_from_slice(&u64::MAX.to_be_bytes());
926 assert_eq!(pos.as_ref(), expected.as_slice());
927 }
928}