Skip to main content

sources_postgres/document/
mod.rs

1//! [`PgDocumentBuilder`] — the read half of the Postgres source.
2//!
3//! Resolves which documents a changed row affects and assembles them from the
4//! schema. The work is split across this module:
5//!
6//! - [`fields`] — pure traversal of the index's field tree.
7//! - [`resolve`] — reverse resolution: changed row → affected document keys.
8//! - [`query`] — SQL generation (the server-side document query, reverse
9//!   queries, parameter binding).
10//! - [`value`] — decoding Postgres results into the value tree.
11//!
12//! ## Assembly happens in Postgres
13//!
14//! [`build`](PgDocumentBuilder::build) issues **one** query per document: the
15//! whole nested document is assembled server-side with `json_build_object` /
16//! `json_agg` and correlated subqueries (see [`query`]). Nested relations don't
17//! trigger extra round-trips, so there is no N+1. Existence and soft-delete
18//! fold into the query's `WHERE`, so a missing or deleted row simply returns no
19//! row → a tombstone.
20//!
21//! ## Coverage
22//!
23//! - Resolution: root table; direct foreign-key relations (`has_one`/
24//!   `has_many`); parent-side-key relations (`belongs_to`, resolved against the
25//!   parent table — so a change to, or deletion of, the *target* row re-emits
26//!   every document pointing at it); many-to-many (`through`) relations on
27//!   either the far or junction table; and tables reachable through multiple
28//!   hops of nesting, chained back to the root.
29//! - Assembly: column fields (transforms, defaults); belongs_to / has_one /
30//!   has_many / many_to_many joins (filters, ordering, limit); joins nested
31//!   inside joins; aggregates, including over a junction; boolean and timestamp
32//!   soft-delete with optional `when` filters.
33//!
34//! Relation targets are matched on each table's real primary key, looked up
35//! from the Postgres catalog and cached (see [`PgDocumentBuilder::table_primary_key`]).
36//! The index's own root key comes from its declared `primary_key`.
37//!
38//! ## Remaining limits
39//!
40//! A child-row *delete* on a related table can't be reverse-resolved from a
41//! key-only change (the row is already gone); this follows from the thin-event
42//! CDC design. Multi-hop reverse resolution issues one query per hop.
43
44mod 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
62/// Cache of each `(schema, table, column)`'s catalog metadata.
63type ColTypeCache = HashMap<(String, String, String), ColumnMeta>;
64
65/// Most keys per batched `build_many` query. Bounds the SQL length and the
66/// prepared-statement cache churn from the `IN (…)` list growing with key
67/// count; larger id sets are split across several round-trips.
68const BUILD_CHUNK: usize = 512;
69
70/// What the Postgres catalog says about a column: its cast-ready SQL type and
71/// whether it admits null. Fetched once per column and cached — both the
72/// document query (which needs the type to cast operands) and mapping resolution
73/// (which needs the type and nullability) read from the same lookup.
74#[derive(Debug, Clone)]
75struct ColumnMeta {
76    sql_type: String,
77    nullable: bool,
78}
79
80/// Builds index documents from a Postgres database, driven by a [`SourceSpec`] —
81/// the enabled indexes and their schemas, translated from the top-level config
82/// by the composition root. Cheap to clone — the pool, spec, and primary-key
83/// cache are shared.
84#[derive(Debug, Clone)]
85pub struct PgDocumentBuilder {
86    pool: PgPool,
87    spec: Arc<SourceSpec>,
88    /// Cache of each `(schema, table)`'s single-column primary key.
89    pk_cache: Arc<Mutex<HashMap<(String, String), ColumnName>>>,
90    /// Cache of each `(schema, table, column)`'s SQL type, used to cast filter
91    /// operands to the column's real type rather than comparing as text.
92    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    /// Build a real document for one arbitrary row of an index's root table —
116    /// exactly what the sink would write, for previewing a schema against live
117    /// data. Picks any row with a non-null primary key, then runs it through the
118    /// normal [`build`](DocumentBuilder::build) path. Returns `Ok(None)` when the
119    /// index has no single-column primary key or its root table is empty.
120    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    /// The single-column primary key of a table, from the Postgres catalog
159    /// (cached). Relations match against this, so a composite or missing
160    /// primary key is an error.
161    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    /// Resolve every relation table's primary key up front (cached), so the
209    /// document query can correlate and join through them.
210    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    /// The SQL type of a column, as a cast-ready name from the Postgres catalog
228    /// (e.g. `numeric`, `integer`, `timestamp with time zone`). A thin view over
229    /// [`column_meta`](Self::column_meta) for callers that only need the type to
230    /// cast a query operand.
231    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    /// The Postgres catalog's view of a column — its cast-ready SQL type and
241    /// whether it admits null — cached. An unknown column is an error: a field or
242    /// filter naming a column that does not exist is a misconfiguration.
243    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        // `format_type` yields a canonical, re-parseable type name, so it can be
260        // dropped straight into a `$n::<type>` cast. `attnotnull` is the column's
261        // NOT NULL constraint — the nullability mapping resolution needs, read
262        // from the same catalog row as the type.
263        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    /// Resolve the SQL type of every column a value filter compares against,
296    /// keyed by `(table, column)`, so the document query can cast each operand
297    /// to its column's type. Covers relation filters at any depth and the
298    /// root-table columns named by a soft-delete `when`.
299    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        // Soft-delete `when` filters and root filters run against the root table.
307        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    /// Ensure `types` carries the catalog SQL type of each root-table key column,
332    /// so the keyed lookup can cast every `$n` to it. The keys come back from
333    /// Postgres as values (a `uuid` as a string, say); without the cast the
334    /// re-bound `$n` is `text` and `uuid = text` has no operator.
335    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/// The Postgres source's view of its own catalog. The index mapping is derived
356/// from the self-describing schema in [`schema_core`]; this is the one
357/// store-specific piece used for *validation* — how Postgres types and
358/// constrains a column — so a declared schema can be checked against the live
359/// database.
360#[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 &params {
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        // No row means the root is absent or soft-deleted (both folded into the
456        // query's WHERE) → the document should not exist.
457        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            // The batched query keys the root with `IN (…)` on a single column,
484            // so it needs both a declared single-column primary key and ids that
485            // carry exactly that one key column. Pair each id with its lone key
486            // value; if any id is composite (or the index has no `primary_key`),
487            // fall back to per-document assembly for this group — correct, just
488            // not batched.
489            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 &params {
516                    statement = query::bind_param(statement, param)?;
517                }
518                let rows = statement.fetch_all(&self.pool).await.map_err(query_err)?;
519
520                // Map each returned root key to its assembled body. `doc_key` is
521                // the first column, decoded through the same path live-change
522                // keys take, so it matches the ids' key values exactly.
523                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                // Every requested id yields an outcome: a body present in the
532                // result is an upsert; an absent key means the root is gone or
533                // soft-deleted (both fold into the query's WHERE) → a tombstone.
534                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        // A document is keyed by its root row, so the root table alone seeds the
551        // whole index — `build` assembles the joins and aggregates per root row.
552        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        // The schema is self-describing, so the mapping is projected from it
566        // without touching the database.
567        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
575/// Primary-key column names of a table, in index order. `$1` binds the
576/// qualified `schema.table` (cast to `regclass`).
577pub(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
582/// Fetch the raw primary-key column-name strings for the table `qualified`
583/// names (e.g. `"public"."users"` or `public.users`). Callers apply their own
584/// policy for an invalid name, a missing key, or a composite key.
585pub(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}