Skip to main content

quarb_postgres/
lib.rs

1//! PostgreSQL adapter for the Quarb query engine.
2//!
3//! A thin catalog driver over the shared relational model
4//! (`quarb-relational`): it connects with `tokio-postgres` (on an
5//! internal current-thread runtime, so the public surface stays
6//! synchronous), introspects the `public` schema through the
7//! system catalogs (tables, columns, primary keys, foreign
8//! keys), streams every table, and hands the result to
9//! [`RelationalModel`].
10//!
11//! Type mapping: booleans, the integer family, and the float
12//! family arrive natively; text arrives as itself; every other
13//! type (numeric, dates, timestamps, uuid, json, arrays, …) is
14//! selected with a `::text` cast, where Quarb's numeric reading
15//! and comparisons take over — the same posture as the CSV
16//! adapter. `NULL` is null.
17//!
18//! The arbor mapping and the foreign-key reference machinery (`~>`
19//! chains, `->`/`<-` crosslinks, `<~` reverse resolution, the
20//! table-naming hint) are documented on the shared model. The
21//! database is materialized eagerly at connect and opened
22//! read-only in spirit (the adapter only ever issues SELECTs).
23
24use quarb::{AstAdapter, NodeId, Value};
25use quarb_relational::{RelationalModel, RowSpec, TableSpec};
26use tokio_postgres::types::Type;
27use tokio_postgres::{Client, NoTls, Row};
28
29/// An error connecting to or loading a database.
30#[derive(Debug, thiserror::Error)]
31pub enum PostgresError {
32    #[error("postgres: {0}")]
33    Postgres(#[from] tokio_postgres::Error),
34    #[error("postgres runtime: {0}")]
35    Runtime(#[from] std::io::Error),
36}
37
38/// A PostgreSQL database, materialized as an arbor.
39pub struct PostgresAdapter {
40    model: RelationalModel,
41}
42
43/// Whether `data_type` (as `information_schema` spells it) arrives
44/// natively; anything else is selected with a `::text` cast.
45fn native_type(data_type: &str) -> bool {
46    matches!(
47        data_type,
48        "boolean"
49            | "smallint"
50            | "integer"
51            | "bigint"
52            | "real"
53            | "double precision"
54            | "text"
55            | "character varying"
56            | "character"
57    )
58}
59
60fn cell(row: &Row, i: usize) -> Result<Value, tokio_postgres::Error> {
61    let ty = row.columns()[i].type_();
62    let value = match *ty {
63        Type::BOOL => row
64            .get::<_, Option<bool>>(i)
65            .map_or(Value::Null, Value::Bool),
66        Type::INT2 => row
67            .get::<_, Option<i16>>(i)
68            .map_or(Value::Null, |n| Value::Int(n as i64)),
69        Type::INT4 => row
70            .get::<_, Option<i32>>(i)
71            .map_or(Value::Null, |n| Value::Int(n as i64)),
72        Type::INT8 => row.get::<_, Option<i64>>(i).map_or(Value::Null, Value::Int),
73        Type::FLOAT4 => row
74            .get::<_, Option<f32>>(i)
75            .map_or(Value::Null, |f| Value::Float(f as f64)),
76        Type::FLOAT8 => row
77            .get::<_, Option<f64>>(i)
78            .map_or(Value::Null, Value::Float),
79        // Non-native types are `::text`-cast on the adapter's own fetch
80        // path, so this arm sees a real `text` column there. A full
81        // pushdown (`raw_query`) can hand us a raw numeric/date/
82        // timestamp/uuid/json column with no cast, though: decoding
83        // that as `String` would panic, so use `try_get` and surface
84        // the type error to the caller (qua then falls back to the scan
85        // path) rather than aborting the process.
86        _ => row
87            .try_get::<_, Option<String>>(i)?
88            .map_or(Value::Null, Value::Str),
89    };
90    Ok(value)
91}
92
93impl PostgresAdapter {
94    /// Connect and materialize the `public` schema. `config` is a
95    /// `tokio-postgres` connection string — URL form
96    /// (`postgres://user@host:port/db`) or keyword form
97    /// (`host=/run/postgresql user=me dbname=db`).
98    pub fn connect(config: &str) -> Result<Self, PostgresError> {
99        Self::connect_impl(config, None)
100    }
101
102    /// [`connect`], with one table's fetch filtered by a WHERE
103    /// clause (partial pushdown; the engine re-applies the
104    /// predicates).
105    pub fn connect_filtered(
106        config: &str,
107        table: &str,
108        where_sql: &str,
109    ) -> Result<Self, PostgresError> {
110        Self::connect_impl(config, Some((table.to_string(), where_sql.to_string())))
111    }
112
113    fn connect_impl(config: &str, filter: Option<(String, String)>) -> Result<Self, PostgresError> {
114        let rt = tokio::runtime::Builder::new_current_thread()
115            .enable_all()
116            .build()?;
117        // Introspect the catalog now; keep the runtime, client, and
118        // connection driver alive inside the fetcher so each table's
119        // rows can stream on first touch.
120        let (client, specs, types) = rt.block_on(async {
121            let (client, connection) = tokio_postgres::connect(config, NoTls).await?;
122            tokio::spawn(connection);
123            let (specs, types) = Self::introspect(&client).await?;
124            Ok::<_, tokio_postgres::Error>((client, specs, types))
125        })?;
126        let model = RelationalModel::lazy(
127            specs,
128            Box::new(move |t, spec| {
129                let w = filter
130                    .as_ref()
131                    .filter(|(tn, _)| *tn == spec.name)
132                    .map(|(_, w)| w.as_str());
133                rt.block_on(Self::fetch_rows(&client, spec, &types[t], w))
134                    .map_err(|e| e.to_string())
135            }),
136        );
137        Ok(PostgresAdapter { model })
138    }
139
140    /// The catalog: every public table's spec, plus its columns'
141    /// catalog types (which decide the `::text` casts at fetch).
142    async fn introspect(
143        client: &Client,
144    ) -> Result<(Vec<TableSpec>, Vec<Vec<String>>), tokio_postgres::Error> {
145        let names: Vec<String> = client
146            .query(
147                "SELECT table_name FROM information_schema.tables \
148                 WHERE table_schema = 'public' AND table_type = 'BASE TABLE' \
149                 ORDER BY table_name",
150                &[],
151            )
152            .await?
153            .iter()
154            .map(|r| r.get(0))
155            .collect();
156
157        let mut specs = Vec::new();
158        let mut all_types = Vec::new();
159        for name in names {
160            // Columns, in ordinal order, with their catalog types.
161            let cols = client
162                .query(
163                    "SELECT column_name, data_type \
164                     FROM information_schema.columns \
165                     WHERE table_schema = 'public' AND table_name = $1 \
166                     ORDER BY ordinal_position",
167                    &[&name],
168                )
169                .await?;
170            let columns: Vec<String> = cols.iter().map(|r| r.get(0)).collect();
171            let types: Vec<String> = cols.iter().map(|r| r.get(1)).collect();
172
173            // The primary key (single-column keys name the rows).
174            let pk_rows = client
175                .query(
176                    "SELECT kcu.column_name \
177                     FROM information_schema.table_constraints tc \
178                     JOIN information_schema.key_column_usage kcu \
179                       ON tc.constraint_name = kcu.constraint_name \
180                      AND tc.table_schema = kcu.table_schema \
181                      AND tc.table_name = kcu.table_name \
182                     WHERE tc.table_schema = 'public' AND tc.table_name = $1 \
183                       AND tc.constraint_type = 'PRIMARY KEY' \
184                     ORDER BY kcu.ordinal_position",
185                    &[&name],
186                )
187                .await?;
188            let pk = (pk_rows.len() == 1).then(|| {
189                let col: String = pk_rows[0].get(0);
190                columns.iter().position(|c| *c == col)
191            });
192            let pk = pk.flatten();
193
194            // Declared foreign keys. Read straight from `pg_catalog`:
195            // `conkey`/`confkey` are parallel column-number arrays, so
196            // unnesting them together (`WITH ORDINALITY` keeps them in
197            // lockstep) pairs each referencing column with its own
198            // referenced column — a composite key stays paired instead
199            // of cross-producting. Keying on `conrelid` (the owning
200            // table) also avoids the information_schema hazard that
201            // constraint names collide across tables.
202            let fk_rows = client
203                .query(
204                    "SELECT a.attname, cf.relname, af.attname \
205                     FROM pg_catalog.pg_constraint con \
206                     JOIN pg_catalog.pg_class c ON c.oid = con.conrelid \
207                     JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace \
208                     JOIN pg_catalog.pg_class cf ON cf.oid = con.confrelid \
209                     JOIN LATERAL unnest(con.conkey, con.confkey) \
210                          WITH ORDINALITY AS k(conkey, confkey, ord) ON true \
211                     JOIN pg_catalog.pg_attribute a \
212                       ON a.attrelid = con.conrelid AND a.attnum = k.conkey \
213                     JOIN pg_catalog.pg_attribute af \
214                       ON af.attrelid = con.confrelid AND af.attnum = k.confkey \
215                     WHERE con.contype = 'f' AND n.nspname = 'public' \
216                       AND c.relname = $1 \
217                     ORDER BY con.oid, k.ord",
218                    &[&name],
219                )
220                .await?;
221            let mut fks = Vec::new();
222            for r in &fk_rows {
223                let from: String = r.get(0);
224                let target: String = r.get(1);
225                let to: String = r.get(2);
226                if let Some(idx) = columns.iter().position(|c| *c == from) {
227                    fks.push((idx, target, to));
228                }
229            }
230
231            specs.push(TableSpec {
232                name,
233                columns,
234                pk,
235                fks,
236            });
237            all_types.push(types);
238        }
239        Ok((specs, all_types))
240    }
241
242    /// Stream one table's rows: native types bare, the rest cast to
243    /// text; row order follows the key when one names rows.
244    async fn fetch_rows(
245        client: &Client,
246        spec: &TableSpec,
247        types: &[String],
248        where_sql: Option<&str>,
249    ) -> Result<Vec<RowSpec>, tokio_postgres::Error> {
250        let select: Vec<String> = spec
251            .columns
252            .iter()
253            .zip(types)
254            .map(|(c, t)| {
255                if native_type(t) {
256                    format!("\"{c}\"")
257                } else {
258                    format!("\"{c}\"::text")
259                }
260            })
261            .collect();
262        // Qualify with the table name so a non-native pk (selected as
263        // `"pk"::text`, whose output column is also named `pk`) sorts by
264        // the raw column, not the text-cast alias — lexicographic vs.
265        // numeric — keeping this in step with `raw_query`'s qualified
266        // `ORDER BY "t"."pk"`.
267        let order = match spec.pk {
268            Some(i) => format!(" ORDER BY \"{}\".\"{}\"", spec.name, spec.columns[i]),
269            None => String::new(),
270        };
271        let filter = match where_sql {
272            Some(w) => format!(" WHERE {w}"),
273            None => String::new(),
274        };
275        let rows = client
276            .query(
277                &format!(
278                    "SELECT {} FROM \"{}\"{filter}{order}",
279                    select.join(", "),
280                    spec.name
281                ),
282                &[],
283            )
284            .await?;
285        rows.iter()
286            .enumerate()
287            .map(|(i, r)| {
288                let values = (0..spec.columns.len())
289                    .map(|c| cell(r, c))
290                    .collect::<Result<Vec<_>, _>>()?;
291                Ok(RowSpec {
292                    rowid: i as i64 + 1,
293                    values,
294                })
295            })
296            .collect()
297    }
298
299    /// A human-readable locator: `/table/key` for rows.
300    pub fn locator(&self, node: NodeId) -> String {
301        self.model.locator(node)
302    }
303}
304
305/// Execute pushed-down SQL directly: the column names and rows,
306/// ordered by `order_table`'s key when one is given (the pushdown
307/// contract: row order must match the adapter's document order).
308pub fn raw_query(
309    config: &str,
310    sql: &str,
311    order_table: Option<&str>,
312) -> Result<(Vec<String>, Vec<Vec<Value>>), PostgresError> {
313    let rt = tokio::runtime::Builder::new_current_thread()
314        .enable_all()
315        .build()?;
316    rt.block_on(async {
317        let (client, connection) = tokio_postgres::connect(config, NoTls).await?;
318        tokio::spawn(connection);
319        let sql = match order_table {
320            Some(t) => {
321                let (specs, _) = PostgresAdapter::introspect(&client).await?;
322                let key = specs
323                    .into_iter()
324                    .find(|s| s.name == t)
325                    .and_then(|s| s.pk.map(|i| s.columns[i].clone()));
326                match key {
327                    Some(k) => format!("{sql} ORDER BY \"{t}\".\"{k}\""),
328                    None => sql.to_string(),
329                }
330            }
331            None => sql.to_string(),
332        };
333        let rows = client.query(&sql, &[]).await?;
334        let cols: Vec<String> = rows
335            .first()
336            .map(|r| r.columns().iter().map(|c| c.name().to_string()).collect())
337            .unwrap_or_default();
338        let out = rows
339            .iter()
340            .map(|r| {
341                (0..r.columns().len())
342                    .map(|i| cell(r, i))
343                    .collect::<Result<Vec<_>, _>>()
344            })
345            .collect::<Result<Vec<_>, _>>()?;
346        Ok((cols, out))
347    })
348}
349
350impl AstAdapter for PostgresAdapter {
351    fn root(&self) -> NodeId {
352        self.model.root()
353    }
354    fn children(&self, node: NodeId) -> Vec<NodeId> {
355        self.model.children(node)
356    }
357    fn name(&self, node: NodeId) -> Option<String> {
358        self.model.name(node)
359    }
360    fn parent(&self, node: NodeId) -> Option<NodeId> {
361        self.model.parent(node)
362    }
363    fn property(&self, node: NodeId, name: &str) -> Option<Value> {
364        self.model.property(node, name)
365    }
366    fn metadata(&self, node: NodeId, key: &str) -> Option<Value> {
367        self.model.metadata(node, key)
368    }
369    fn resolve(&self, node: NodeId, property: &str, hint: Option<&str>) -> Option<NodeId> {
370        self.model.resolve(node, property, hint)
371    }
372    fn links(&self, node: NodeId) -> Vec<(String, NodeId)> {
373        self.model.links(node)
374    }
375    fn backlinks(&self, node: NodeId) -> Vec<(String, NodeId)> {
376        self.model.backlinks(node)
377    }
378}