1mod fields;
45mod query;
46mod resolve;
47pub(crate) mod value;
48
49use std::collections::{HashMap, HashSet};
50use std::sync::{Arc, Mutex, PoisonError};
51
52use async_trait::async_trait;
53use schema_core::{
54 ColumnName, DatabaseSchema, Filter, IndexMapping, IndexName, IndexSchema, SoftDelete, TableName,
55};
56use sources_core::document::{Document, DocumentBuilder, DocumentId, IndexScope};
57use sources_core::{Catalog, ColumnInfo, Result, RowKey, SnapshotTable, SourceError, SourceSpec};
58use sqlx::{PgPool, Row};
59
60use fields::find_paths;
61
62type ColTypeCache = HashMap<(String, String, String), ColumnMeta>;
64
65const BUILD_CHUNK: usize = 512;
69
70#[derive(Debug, Clone)]
75struct ColumnMeta {
76 sql_type: String,
77 nullable: bool,
78}
79
80#[derive(Debug, Clone)]
85pub struct PgDocumentBuilder {
86 pool: PgPool,
87 spec: Arc<SourceSpec>,
88 pk_cache: Arc<Mutex<HashMap<(String, String), ColumnName>>>,
90 col_type_cache: Arc<Mutex<ColTypeCache>>,
93}
94
95impl PgDocumentBuilder {
96 pub fn new(pool: PgPool, spec: Arc<SourceSpec>) -> Self {
97 Self {
98 pool,
99 spec,
100 pk_cache: Arc::new(Mutex::new(HashMap::new())),
101 col_type_cache: Arc::new(Mutex::new(HashMap::new())),
102 }
103 }
104
105 #[tracing::instrument(name = "pg.connect", skip_all, err)]
106 pub async fn connect(connection_url: &str, spec: Arc<SourceSpec>) -> Result<Self> {
107 let pool = sqlx::postgres::PgPoolOptions::new()
108 .connect(connection_url)
109 .await
110 .map_err(|e| SourceError::Connection(e.to_string()))?;
111 tracing::info!(indexes = spec.indexes().count(), "connected to Postgres");
112 Ok(Self::new(pool, spec))
113 }
114
115 pub async fn sample_document(
121 &self,
122 index: &IndexName,
123 ) -> Result<Option<schema_core::GenericValue>> {
124 let schema = self
125 .spec
126 .schema(index)
127 .ok_or_else(|| SourceError::Query(format!("unknown index `{index}`")))?;
128 let Some(pk_column) = schema.primary_key.clone() else {
129 return Ok(None);
130 };
131 let sql = format!(
132 "SELECT \"{pk}\" FROM \"{db_schema}\".\"{table}\" WHERE \"{pk}\" IS NOT NULL LIMIT 1",
133 pk = pk_column,
134 db_schema = schema.db_schema,
135 table = schema.table,
136 );
137 let row = sqlx::query(sqlx::AssertSqlSafe(sql))
138 .fetch_optional(&self.pool)
139 .await
140 .map_err(query_err)?;
141 let Some(row) = row else {
142 return Ok(None);
143 };
144 let key_value = value::first_column_to_generic(&row);
145 if matches!(key_value, schema_core::GenericValue::Null) {
146 return Ok(None);
147 }
148 let id = DocumentId {
149 index: index.clone(),
150 key: RowKey(vec![(pk_column, key_value)]),
151 };
152 match self.build(&id).await? {
153 Document::Upsert { body, .. } => Ok(Some(body)),
154 Document::Delete { .. } => Ok(None),
155 }
156 }
157
158 pub(super) async fn table_primary_key(
162 &self,
163 schema: &DatabaseSchema,
164 table: &TableName,
165 ) -> Result<ColumnName> {
166 let cache_key = (schema.to_string(), table.to_string());
167 {
168 let cache = self.pk_cache.lock().unwrap_or_else(PoisonError::into_inner);
169 if let Some(column) = cache.get(&cache_key) {
170 return Ok(column.clone());
171 }
172 }
173 let column = match self.fetch_primary_key(schema, table).await?.as_slice() {
174 [single] => single.clone(),
175 [] => {
176 return Err(SourceError::Query(format!(
177 "table `{schema}.{table}` has no primary key"
178 )));
179 }
180 _ => {
181 return Err(SourceError::Unsupported(format!(
182 "table `{schema}.{table}` has a composite primary key; relations require a single-column key"
183 )));
184 }
185 };
186 self.pk_cache
187 .lock()
188 .unwrap_or_else(PoisonError::into_inner)
189 .insert(cache_key, column.clone());
190 Ok(column)
191 }
192
193 async fn fetch_primary_key(
194 &self,
195 schema: &DatabaseSchema,
196 table: &TableName,
197 ) -> Result<Vec<ColumnName>> {
198 let names = primary_key_column_names(&self.pool, format!("{schema}.{table}")).await?;
199 names
200 .into_iter()
201 .map(|name| {
202 ColumnName::try_new(name)
203 .map_err(|e| SourceError::Query(format!("invalid primary key column: {e}")))
204 })
205 .collect()
206 }
207
208 async fn relation_pks(
211 &self,
212 schema: &schema_core::IndexSchema,
213 ) -> Result<HashMap<String, ColumnName>> {
214 let mut tables = Vec::new();
215 fields::collect_relation_tables(&schema.fields, &mut tables);
216 let unique: HashSet<&TableName> = tables.iter().collect();
217 let mut pks = HashMap::new();
218 for table in unique {
219 pks.insert(
220 table.to_string(),
221 self.table_primary_key(&schema.db_schema, table).await?,
222 );
223 }
224 Ok(pks)
225 }
226
227 pub(super) async fn column_type(
232 &self,
233 schema: &DatabaseSchema,
234 table: &TableName,
235 column: &ColumnName,
236 ) -> Result<String> {
237 Ok(self.column_meta(schema, table, column).await?.sql_type)
238 }
239
240 async fn column_meta(
244 &self,
245 schema: &DatabaseSchema,
246 table: &TableName,
247 column: &ColumnName,
248 ) -> Result<ColumnMeta> {
249 let cache_key = (schema.to_string(), table.to_string(), column.to_string());
250 {
251 let cache = self
252 .col_type_cache
253 .lock()
254 .unwrap_or_else(PoisonError::into_inner);
255 if let Some(meta) = cache.get(&cache_key) {
256 return Ok(meta.clone());
257 }
258 }
259 let sql = "SELECT format_type(a.atttypid, a.atttypmod) AS sql_type, a.attnotnull AS not_null \
264 FROM pg_attribute a \
265 WHERE a.attrelid = $1::regclass AND a.attname = $2 \
266 AND a.attnum > 0 AND NOT a.attisdropped";
267 let row = sqlx::query(sql)
268 .bind(format!("{schema}.{table}"))
269 .bind(column.as_ref().to_owned())
270 .fetch_optional(&self.pool)
271 .await
272 .map_err(query_err)?;
273 let meta = match row {
274 Some(row) => {
275 let sql_type: String = row.try_get("sql_type").map_err(query_err)?;
276 let not_null: bool = row.try_get("not_null").map_err(query_err)?;
277 ColumnMeta {
278 sql_type,
279 nullable: !not_null,
280 }
281 }
282 None => {
283 return Err(SourceError::UnknownColumn(format!(
284 "{schema}.{table}.{column}"
285 )));
286 }
287 };
288 self.col_type_cache
289 .lock()
290 .unwrap_or_else(PoisonError::into_inner)
291 .insert(cache_key, meta.clone());
292 Ok(meta)
293 }
294
295 async fn filter_column_types(
300 &self,
301 schema: &IndexSchema,
302 ) -> Result<HashMap<(String, String), String>> {
303 let mut columns = Vec::new();
304 fields::collect_filter_columns(&schema.fields, &mut columns);
305
306 let when = match &schema.soft_delete {
308 Some(SoftDelete::Column(c)) => c.when.as_deref(),
309 Some(SoftDelete::Field(f)) => f.when.as_deref(),
310 None => None,
311 };
312 let root_filters = schema.filters.as_deref().unwrap_or_default();
313 for filter in when.unwrap_or_default().iter().chain(root_filters) {
314 if let Filter::ValueOp(value_op) = filter {
315 columns.push((&schema.table, &value_op.column));
316 }
317 }
318
319 let mut types = HashMap::new();
320 for (table, column) in columns {
321 let key = (table.to_string(), column.to_string());
322 if types.contains_key(&key) {
323 continue;
324 }
325 let sql_type = self.column_type(&schema.db_schema, table, column).await?;
326 types.insert(key, sql_type);
327 }
328 Ok(types)
329 }
330
331 async fn add_key_column_types(
336 &self,
337 schema: &IndexSchema,
338 columns: &[&ColumnName],
339 types: &mut HashMap<(String, String), String>,
340 ) -> Result<()> {
341 for column in columns {
342 let key = (schema.table.to_string(), column.to_string());
343 if types.contains_key(&key) {
344 continue;
345 }
346 let sql_type = self
347 .column_type(&schema.db_schema, &schema.table, column)
348 .await?;
349 types.insert(key, sql_type);
350 }
351 Ok(())
352 }
353}
354
355#[async_trait]
361impl Catalog for PgDocumentBuilder {
362 async fn column(
363 &self,
364 schema: &DatabaseSchema,
365 table: &TableName,
366 column: &ColumnName,
367 ) -> Result<ColumnInfo> {
368 let meta = self.column_meta(schema, table, column).await?;
369 Ok(ColumnInfo {
370 sql_type: meta.sql_type,
371 nullable: meta.nullable,
372 })
373 }
374}
375
376#[async_trait]
377impl DocumentBuilder for PgDocumentBuilder {
378 #[tracing::instrument(
379 name = "pg.resolve",
380 level = "debug",
381 skip_all,
382 fields(table = table.as_ref()),
383 err,
384 )]
385 async fn resolve(&self, table: &TableName, key: &RowKey) -> Result<Vec<DocumentId>> {
386 let mut ids = Vec::new();
387 for (name, schema) in self.spec.indexes() {
388 if schema.table == *table {
389 ids.push(DocumentId {
390 index: name.clone(),
391 key: key.clone(),
392 });
393 continue;
394 }
395
396 let mut paths = Vec::new();
397 let mut prefix = Vec::new();
398 find_paths(&schema.fields, table, &mut prefix, &mut paths);
399 if paths.is_empty() {
400 continue;
401 }
402 let Some(pk_column) = schema.primary_key.clone() else {
403 tracing::warn!(
404 index = %name, table = %table,
405 "cannot reverse-resolve: index has no primary_key",
406 );
407 continue;
408 };
409
410 let mut seen = HashSet::new();
411 for path in &paths {
412 for root in self.resolve_path(schema, table, key, path).await? {
413 if seen.insert(root.clone()) {
414 ids.push(DocumentId {
415 index: name.clone(),
416 key: RowKey(vec![(pk_column.clone(), root)]),
417 });
418 }
419 }
420 }
421 }
422 tracing::trace!(documents = ids.len(), "resolved affected documents");
423 Ok(ids)
424 }
425
426 #[tracing::instrument(
427 name = "pg.build",
428 level = "debug",
429 skip_all,
430 fields(index = id.index.as_ref()),
431 err,
432 )]
433 async fn build(&self, id: &DocumentId) -> Result<Document> {
434 let schema = self
435 .spec
436 .schema(&id.index)
437 .ok_or_else(|| SourceError::Query(format!("unknown index `{}`", id.index)))?;
438
439 let pks = self.relation_pks(schema).await?;
440 let mut col_types = self.filter_column_types(schema).await?;
441 let key_columns: Vec<&ColumnName> = id.key.0.iter().map(|(column, _)| column).collect();
442 self.add_key_column_types(schema, &key_columns, &mut col_types)
443 .await?;
444 let (sql, params) = query::document_query(schema, &id.key.0, &pks, &col_types)?;
445
446 let mut statement = sqlx::query(sql);
447 for param in ¶ms {
448 statement = query::bind_param(statement, param)?;
449 }
450 let row = statement
451 .fetch_optional(&self.pool)
452 .await
453 .map_err(query_err)?;
454
455 match row {
458 None => Ok(Document::Delete { id: id.clone() }),
459 Some(row) => {
460 let document: serde_json::Value = row.try_get("document").map_err(query_err)?;
461 Ok(Document::Upsert {
462 id: id.clone(),
463 body: value::coerce_document(document, &schema.fields),
464 })
465 }
466 }
467 }
468
469 #[tracing::instrument(name = "pg.build_many", level = "debug", skip_all, fields(ids = ids.len()))]
470 async fn build_many(&self, ids: &[DocumentId]) -> Result<Vec<Document>> {
471 let mut by_index: HashMap<&IndexName, Vec<&DocumentId>> = HashMap::new();
472 for id in ids {
473 by_index.entry(&id.index).or_default().push(id);
474 }
475
476 let mut out = Vec::with_capacity(ids.len());
477 for (index_name, group) in by_index {
478 let schema = self
479 .spec
480 .schema(index_name)
481 .ok_or_else(|| SourceError::Query(format!("unknown index `{index_name}`")))?;
482
483 let keyed: Option<Vec<(&schema_core::GenericValue, &DocumentId)>> = group
490 .iter()
491 .map(|id| match id.key.0.as_slice() {
492 [(_, value)] => Some((value, *id)),
493 _ => None,
494 })
495 .collect();
496 let (Some(pk_column), Some(keyed)) = (schema.primary_key.clone(), keyed) else {
497 for id in group {
498 out.push(self.build(id).await?);
499 }
500 continue;
501 };
502
503 let pks = self.relation_pks(schema).await?;
504 let mut col_types = self.filter_column_types(schema).await?;
505 self.add_key_column_types(schema, &[&pk_column], &mut col_types)
506 .await?;
507
508 for chunk in keyed.chunks(BUILD_CHUNK) {
509 let keys: Vec<schema_core::GenericValue> =
510 chunk.iter().map(|(value, _)| (*value).clone()).collect();
511 let (sql, params) =
512 query::documents_query(schema, &pk_column, &keys, &pks, &col_types)?;
513
514 let mut statement = sqlx::query(sql);
515 for param in ¶ms {
516 statement = query::bind_param(statement, param)?;
517 }
518 let rows = statement.fetch_all(&self.pool).await.map_err(query_err)?;
519
520 let mut bodies: HashMap<schema_core::GenericValue, schema_core::GenericValue> =
524 HashMap::with_capacity(rows.len());
525 for row in &rows {
526 let key = value::first_column_to_generic(row);
527 let document: serde_json::Value = row.try_get("document").map_err(query_err)?;
528 bodies.insert(key, value::coerce_document(document, &schema.fields));
529 }
530
531 for (value, id) in chunk {
535 let document = match bodies.remove(*value) {
536 Some(body) => Document::Upsert {
537 id: (*id).clone(),
538 body,
539 },
540 None => Document::Delete { id: (*id).clone() },
541 };
542 out.push(document);
543 }
544 }
545 }
546 Ok(out)
547 }
548
549 fn backfill_scopes(&self) -> Vec<IndexScope> {
550 self.spec
553 .indexes()
554 .map(|(name, schema)| IndexScope {
555 index: name.clone(),
556 root: SnapshotTable {
557 db_schema: schema.db_schema.clone(),
558 table: schema.table.clone(),
559 },
560 })
561 .collect()
562 }
563
564 async fn index_mappings(&self) -> Result<Vec<IndexMapping>> {
565 Ok(self.spec.index_mappings())
568 }
569}
570
571pub(super) fn query_err(error: sqlx::Error) -> SourceError {
572 SourceError::Query(error.to_string())
573}
574
575pub(crate) const PRIMARY_KEY_SQL: &str = "SELECT a.attname AS name \
578 FROM pg_index i \
579 JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) \
580 WHERE i.indrelid = $1::regclass AND i.indisprimary";
581
582pub(crate) async fn primary_key_column_names(
586 pool: &PgPool,
587 qualified: String,
588) -> Result<Vec<String>> {
589 let rows = sqlx::query(PRIMARY_KEY_SQL)
590 .bind(qualified)
591 .fetch_all(pool)
592 .await
593 .map_err(query_err)?;
594 let mut names = Vec::with_capacity(rows.len());
595 for row in &rows {
596 names.push(row.try_get::<String, _>("name").map_err(query_err)?);
597 }
598 Ok(names)
599}