Skip to main content

postrust_core/
embed.rs

1//! Relationship embedding: fetching related rows for a set of parent rows.
2//!
3//! Both the REST and GraphQL surfaces embed related resources, and both do it
4//! the same way: take the parent rows already fetched, collect the values of
5//! the join column, and issue **one** query for all of them rather than one
6//! query per parent row.
7//!
8//! ```text
9//! SELECT row_to_json(t) FROM (
10//!     SELECT * FROM "public"."posts" WHERE "user_id" = ANY($1::int4[])
11//! ) t
12//! ```
13//!
14//! The children are then grouped by that column and attached to the parent
15//! rows, so a request embedding two relationships across a page of 25 parents
16//! costs three queries, not fifty-one.
17
18use crate::error::{Error, Result};
19use crate::schema_cache::{Relationship, SchemaCache, Table};
20use std::collections::HashMap;
21
22/// Everything needed to fetch one relationship's rows for a set of parents.
23#[derive(Clone, Debug)]
24pub struct EmbedPlan {
25    /// Column on the parent row whose value identifies the parent.
26    pub local_column: String,
27    /// Column on the related table that points back at the parent.
28    pub foreign_column: String,
29    /// PostgreSQL type of the foreign column, used to cast the bound array.
30    pub foreign_column_type: String,
31    /// Schema of the related table.
32    pub foreign_schema: String,
33    /// Name of the related table.
34    pub foreign_table: String,
35    /// Whether the relationship yields many rows per parent.
36    pub is_list: bool,
37}
38
39impl EmbedPlan {
40    /// Resolve a relationship into an embed plan.
41    ///
42    /// Returns an error for relationships this cannot express yet, rather than
43    /// silently omitting the embedded data.
44    pub fn resolve(relationship: &Relationship, schema_cache: &SchemaCache) -> Result<Self> {
45        let foreign_table_qi = relationship.foreign_table().clone();
46
47        let columns = match relationship {
48            Relationship::ForeignKey { cardinality, .. } => cardinality.columns(),
49            Relationship::Computed { .. } => {
50                return Err(Error::EmbeddingError(
51                    "embedding a computed relationship is not supported yet".into(),
52                ))
53            }
54        };
55
56        if columns.len() != 1 {
57            return Err(Error::EmbeddingError(format!(
58                "embedding \"{}\" is not supported yet: it joins on {} columns and \
59                 only single-column joins are implemented",
60                foreign_table_qi.name,
61                columns.len()
62            )));
63        }
64
65        let (local_column, foreign_column) = columns[0].clone();
66
67        let foreign_table: &Table = schema_cache.get_table(&foreign_table_qi).ok_or_else(|| {
68            Error::EmbeddingError(format!(
69                "cannot embed \"{}\": it is not in an exposed schema",
70                foreign_table_qi
71            ))
72        })?;
73
74        let foreign_column_type = foreign_table
75            .get_column(&foreign_column)
76            .map(|c| c.nominal_type.clone())
77            .ok_or_else(|| {
78                Error::EmbeddingError(format!(
79                    "cannot embed \"{}\": join column \"{}\" not found",
80                    foreign_table_qi, foreign_column
81                ))
82            })?;
83
84        Ok(Self {
85            local_column,
86            foreign_column,
87            foreign_column_type,
88            foreign_schema: foreign_table_qi.schema.clone(),
89            foreign_table: foreign_table_qi.name.clone(),
90            is_list: !relationship.is_to_one(),
91        })
92    }
93
94    /// SQL that fetches every related row for the given parent key values.
95    ///
96    /// The keys are bound as a single text array and cast to the foreign
97    /// column's type, so the column itself is never wrapped in a cast and an
98    /// index on it remains usable. `limit` bounds the rows per query, not per
99    /// parent.
100    ///
101    /// `columns` is the set of columns the client asked for. An empty set means
102    /// every column. Projecting here rather than discarding columns after the
103    /// fact matters: an unprojected column is read from the heap, serialised to
104    /// JSON by PostgreSQL, sent over the socket and parsed, before being thrown
105    /// away. The join column is always included even when it was not requested,
106    /// because grouping needs it; the caller strips it afterwards.
107    pub fn children_sql(&self, limit: Option<i64>, columns: &[String]) -> Result<String> {
108        let type_name = castable_type_name(&self.foreign_column_type).ok_or_else(|| {
109            Error::EmbeddingError(format!(
110                "cannot embed \"{}\": join column type \"{}\" is not a plain type name",
111                self.foreign_table, self.foreign_column_type
112            ))
113        })?;
114
115        let projection = self.projection(columns);
116
117        let mut inner = format!(
118            "SELECT {} FROM {}.{} WHERE {} = ANY($1::{}[])",
119            projection,
120            postrust_sql::escape_ident(&self.foreign_schema),
121            postrust_sql::escape_ident(&self.foreign_table),
122            postrust_sql::escape_ident(&self.foreign_column),
123            type_name
124        );
125
126        if let Some(limit) = limit {
127            inner.push_str(&format!(" LIMIT {}", limit));
128        }
129
130        Ok(format!("SELECT row_to_json(t) FROM ({}) t", inner))
131    }
132
133    /// SQL that fetches related rows already grouped by their join key.
134    ///
135    /// One row comes back per distinct key: the key itself and a JSON array of
136    /// that key's children. Grouping in PostgreSQL rather than in this process
137    /// removes a per-child-row JSON parse, the per-row hash insert, and the
138    /// clone of each group onto its parent.
139    ///
140    /// The key is returned as JSON rather than cast to text, so it is rendered
141    /// by the same code that renders the parents' keys. Casting to text in SQL
142    /// would agree for integers and uuids and disagree for a NUMERIC join
143    /// column, where PostgreSQL and serde_json format differently.
144    ///
145    /// `limit` still bounds the rows scanned, not the rows per parent, so it is
146    /// applied to the inner select exactly as the ungrouped form does.
147    pub fn children_grouped_sql(&self, limit: Option<i64>, columns: &[String]) -> Result<String> {
148        let type_name = castable_type_name(&self.foreign_column_type).ok_or_else(|| {
149            Error::EmbeddingError(format!(
150                "cannot embed \"{}\": join column type \"{}\" is not a plain type name",
151                self.foreign_table, self.foreign_column_type
152            ))
153        })?;
154
155        let key = postrust_sql::escape_ident(&self.foreign_column);
156
157        let mut inner = format!(
158            "SELECT {} FROM {}.{} WHERE {} = ANY($1::{}[])",
159            self.projection(columns),
160            postrust_sql::escape_ident(&self.foreign_schema),
161            postrust_sql::escape_ident(&self.foreign_table),
162            key,
163            type_name
164        );
165
166        if let Some(limit) = limit {
167            inner.push_str(&format!(" LIMIT {}", limit));
168        }
169
170        Ok(format!(
171            "SELECT to_jsonb(c.{key}) AS k, json_agg(row_to_json(c)) AS v \
172             FROM ({inner}) c GROUP BY c.{key}",
173            key = key,
174            inner = inner
175        ))
176    }
177
178    /// A correlated subselect that yields this relationship as one JSON column.
179    ///
180    /// This is the single-query form of embedding: instead of fetching parents,
181    /// collecting their keys and issuing a second query, the relationship is
182    /// attached to the parent query as an expression, so PostgreSQL builds the
183    /// array while it already has the parent row.
184    ///
185    /// `inner_select` is the child's SELECT list, which the caller assembles --
186    /// its columns, plus any deeper relationship expressions built by calling
187    /// this again. Only the caller knows the shape of its own selection tree, so
188    /// the recursion lives there and the SQL assembly lives here.
189    ///
190    /// Parent columns are deliberately left alone: they stay ordinary typed
191    /// columns and are converted to JSON by the same code as an unembedded
192    /// request, so embedding does not change how a NUMERIC or a timestamp is
193    /// rendered. Only the relationship column arrives as JSON, which is what
194    /// the separate child query already returned.
195    pub fn embed_expression(
196        &self,
197        parent_alias: &str,
198        child_alias: &str,
199        inner_select: &str,
200        limit: Option<i64>,
201    ) -> Result<String> {
202        // The child table is aliased rather than referred to by name. A
203        // self-referential relationship would otherwise make the correlation
204        // ambiguous, since the parent and the child are the same table.
205        let mut inner = format!(
206            "SELECT {} FROM {}.{} AS {} WHERE {}.{} = {}.{}",
207            inner_select,
208            postrust_sql::escape_ident(&self.foreign_schema),
209            postrust_sql::escape_ident(&self.foreign_table),
210            postrust_sql::escape_ident(child_alias),
211            postrust_sql::escape_ident(child_alias),
212            postrust_sql::escape_ident(&self.foreign_column),
213            postrust_sql::escape_ident(parent_alias),
214            postrust_sql::escape_ident(&self.local_column),
215        );
216
217        // A to-one relationship takes the first row; a to-many takes them all.
218        // The limit bounds rows per parent here, which is what a client asking
219        // for a page of children means, and is stricter than the row cap the
220        // two-query form could apply.
221        if let Some(limit) = limit {
222            inner.push_str(&format!(" LIMIT {}", limit));
223        } else if !self.is_list {
224            inner.push_str(" LIMIT 1");
225        }
226
227        let alias = postrust_sql::escape_ident(&format!("{}_j", child_alias));
228
229        Ok(if self.is_list {
230            // An empty array rather than null, so the shape does not depend on
231            // whether the parent happens to have children.
232            format!(
233                "COALESCE((SELECT json_agg(row_to_json({alias})) FROM ({inner}) {alias}), '[]'::json)",
234                alias = alias,
235                inner = inner
236            )
237        } else {
238            format!(
239                "(SELECT row_to_json({alias}) FROM ({inner}) {alias})",
240                alias = alias,
241                inner = inner
242            )
243        })
244    }
245
246    /// The child projection list: the requested columns plus the join column.
247    ///
248    /// Column names come from the client, so each is escaped rather than
249    /// interpolated bare. Anything that is not a plain column reference -- a
250    /// nested relation, say -- is not a column and is skipped.
251    fn projection(&self, columns: &[String]) -> String {
252        if columns.is_empty() {
253            return "*".to_string();
254        }
255
256        let mut wanted: Vec<&str> = Vec::with_capacity(columns.len() + 1);
257        for column in columns {
258            if !wanted.contains(&column.as_str()) {
259                wanted.push(column);
260            }
261        }
262        if !wanted.contains(&self.foreign_column.as_str()) {
263            wanted.push(&self.foreign_column);
264        }
265
266        wanted
267            .into_iter()
268            .map(postrust_sql::escape_ident)
269            .collect::<Vec<_>>()
270            .join(", ")
271    }
272}
273
274/// Reject anything that is not a bare type name, since it is interpolated.
275fn castable_type_name(pg_type: &str) -> Option<&str> {
276    if pg_type.is_empty() {
277        return None;
278    }
279    if pg_type
280        .chars()
281        .all(|c| c.is_ascii_alphanumeric() || c == '_')
282    {
283        Some(pg_type)
284    } else {
285        None
286    }
287}
288
289/// Render a JSON value as the text form used to match join keys.
290///
291/// Keys are compared as text and cast by PostgreSQL, so a numeric key must not
292/// arrive quoted the way `to_string` would render a JSON string.
293pub fn key_to_text(value: &serde_json::Value) -> Option<String> {
294    match value {
295        serde_json::Value::Null => None,
296        serde_json::Value::String(s) => Some(s.clone()),
297        other => Some(other.to_string()),
298    }
299}
300
301/// Group related rows by the value of their join column.
302/// Build the grouping from rows PostgreSQL has already grouped.
303///
304/// Each row is the join key as JSON and a JSON array of that key's children.
305/// The key goes through `key_to_text`, the same rendering the parent side uses,
306/// so the two agree for every column type.
307pub fn group_from_aggregated(
308    rows: Vec<(serde_json::Value, serde_json::Value)>,
309) -> HashMap<String, Vec<serde_json::Value>> {
310    let mut grouped: HashMap<String, Vec<serde_json::Value>> = HashMap::with_capacity(rows.len());
311
312    for (key, children) in rows {
313        let key = key_to_text(&key).unwrap_or_default();
314        let children = match children {
315            serde_json::Value::Array(items) => items,
316            // json_agg only ever yields an array or null.
317            _ => Vec::new(),
318        };
319        grouped.entry(key).or_default().extend(children);
320    }
321
322    grouped
323}
324
325pub fn group_by_key(
326    children: Vec<serde_json::Value>,
327    foreign_column: &str,
328) -> HashMap<String, Vec<serde_json::Value>> {
329    let mut grouped: HashMap<String, Vec<serde_json::Value>> = HashMap::new();
330
331    for child in children {
332        let key = child
333            .get(foreign_column)
334            .and_then(key_to_text)
335            .unwrap_or_default();
336        grouped.entry(key).or_default().push(child);
337    }
338
339    grouped
340}
341
342/// Attach grouped children onto a parent row under `field_name`.
343///
344/// A to-one relationship yields the first match or `null`; a to-many yields an
345/// array, empty when there are no matches, so the shape of the response does
346/// not depend on whether data happens to exist.
347pub fn attach_to_parent(
348    parent: &mut serde_json::Value,
349    field_name: &str,
350    plan: &EmbedPlan,
351    grouped: &HashMap<String, Vec<serde_json::Value>>,
352) {
353    let key = parent.get(&plan.local_column).and_then(key_to_text);
354
355    let matches = key
356        .as_ref()
357        .and_then(|k| grouped.get(k))
358        .cloned()
359        .unwrap_or_default();
360
361    let value = if plan.is_list {
362        serde_json::Value::Array(matches)
363    } else {
364        matches
365            .into_iter()
366            .next()
367            .unwrap_or(serde_json::Value::Null)
368    };
369
370    if let Some(object) = parent.as_object_mut() {
371        object.insert(field_name.to_string(), value);
372    }
373}
374
375/// Collect the distinct, non-null join keys of a set of parent rows.
376pub fn parent_keys(parents: &[serde_json::Value], local_column: &str) -> Vec<String> {
377    let mut seen = std::collections::HashSet::new();
378    let mut keys = Vec::new();
379
380    for parent in parents {
381        if let Some(key) = parent.get(local_column).and_then(key_to_text) {
382            if seen.insert(key.clone()) {
383                keys.push(key);
384            }
385        }
386    }
387
388    keys
389}
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394
395    fn plan(is_list: bool) -> EmbedPlan {
396        EmbedPlan {
397            local_column: "id".into(),
398            foreign_column: "user_id".into(),
399            foreign_column_type: "int4".into(),
400            foreign_schema: "public".into(),
401            foreign_table: "posts".into(),
402            is_list,
403        }
404    }
405
406    #[test]
407    fn children_sql_binds_keys_as_a_cast_array() {
408        let sql = plan(true).children_sql(None, &[]).unwrap();
409        assert_eq!(
410            sql,
411            "SELECT row_to_json(t) FROM (SELECT * FROM \"public\".\"posts\" \
412             WHERE \"user_id\" = ANY($1::int4[])) t"
413        );
414    }
415
416    #[test]
417    fn embed_expression_aggregates_a_to_many_relation() {
418        let sql = plan(true)
419            .embed_expression("p", "posts", r#""id", "title""#, None)
420            .unwrap();
421
422        assert!(sql.starts_with("COALESCE((SELECT json_agg("), "{}", sql);
423        // Correlated on the parent, so there is no second query and no bound
424        // array of keys.
425        assert!(sql.contains(r#""posts"."user_id" = "p"."id""#), "{}", sql);
426        assert!(
427            sql.contains(r#"AS "posts""#),
428            "the child table is aliased: {}",
429            sql
430        );
431        assert!(!sql.contains("ANY("), "{}", sql);
432        // An absent relation is an empty array, not null.
433        assert!(sql.contains("'[]'::json"), "{}", sql);
434    }
435
436    #[test]
437    fn embed_expression_takes_one_row_for_a_to_one_relation() {
438        let sql = plan(false)
439            .embed_expression("p", "author", r#""id""#, None)
440            .unwrap();
441
442        assert!(sql.contains("row_to_json"), "{}", sql);
443        assert!(!sql.contains("json_agg"), "{}", sql);
444        assert!(
445            sql.contains("LIMIT 1"),
446            "a to-one relation yields one row: {}",
447            sql
448        );
449    }
450
451    #[test]
452    fn embed_expression_limits_rows_per_parent() {
453        let sql = plan(true)
454            .embed_expression("p", "posts", r#""id""#, Some(25))
455            .unwrap();
456        assert!(sql.contains("LIMIT 25"), "{}", sql);
457    }
458
459    #[test]
460    fn children_sql_projects_only_the_requested_columns() {
461        let sql = plan(true)
462            .children_sql(None, &["title".to_string(), "body".to_string()])
463            .unwrap();
464
465        assert!(
466            sql.contains(r#"SELECT "title", "body", "user_id" FROM"#),
467            "{}",
468            sql
469        );
470        assert!(
471            !sql.contains("SELECT *"),
472            "an unrequested column should not be read at all: {}",
473            sql
474        );
475    }
476
477    #[test]
478    fn children_sql_always_includes_the_join_column() {
479        // The grouping keys off the join column, so it has to come back even
480        // when the client did not ask for it.
481        let sql = plan(true)
482            .children_sql(None, &["title".to_string()])
483            .unwrap();
484        assert!(sql.contains(r#""user_id""#), "{}", sql);
485    }
486
487    #[test]
488    fn children_sql_does_not_repeat_the_join_column() {
489        let sql = plan(true)
490            .children_sql(None, &["user_id".to_string(), "title".to_string()])
491            .unwrap();
492        assert_eq!(
493            sql.matches(r#""user_id""#).count(),
494            2,
495            "expected the column once in the projection and once in the WHERE: {}",
496            sql
497        );
498    }
499
500    #[test]
501    fn children_sql_escapes_column_names() {
502        // Column names reach here from the client.
503        let sql = plan(true)
504            .children_sql(None, &[r#"ev"il"#.to_string()])
505            .unwrap();
506        assert!(sql.contains(r#""ev""il""#), "{}", sql);
507    }
508
509    #[test]
510    fn children_sql_falls_back_to_every_column() {
511        let sql = plan(true).children_sql(None, &[]).unwrap();
512        assert!(sql.contains("SELECT * FROM"), "{}", sql);
513    }
514
515    #[test]
516    fn children_sql_applies_a_limit() {
517        let sql = plan(true).children_sql(Some(25), &[]).unwrap();
518        assert!(sql.contains("LIMIT 25"), "{}", sql);
519    }
520
521    #[test]
522    fn children_sql_rejects_a_non_plain_type_name() {
523        let mut p = plan(true);
524        p.foreign_column_type = "int4; DROP TABLE users".into();
525        assert!(p.children_sql(None, &[]).is_err());
526    }
527
528    #[test]
529    fn keys_are_rendered_without_json_quoting() {
530        assert_eq!(key_to_text(&serde_json::json!(7)), Some("7".to_string()));
531        assert_eq!(
532            key_to_text(&serde_json::json!("abc")),
533            Some("abc".to_string())
534        );
535        assert_eq!(key_to_text(&serde_json::Value::Null), None);
536    }
537
538    #[test]
539    fn parent_keys_are_distinct_and_skip_nulls() {
540        let parents = vec![
541            serde_json::json!({"id": 1}),
542            serde_json::json!({"id": 2}),
543            serde_json::json!({"id": 1}),
544            serde_json::json!({"id": null}),
545        ];
546        assert_eq!(parent_keys(&parents, "id"), vec!["1", "2"]);
547    }
548
549    #[test]
550    fn to_many_attaches_an_array_and_empty_when_absent() {
551        let grouped = group_by_key(vec![serde_json::json!({"id": 10, "user_id": 1})], "user_id");
552
553        let mut matched = serde_json::json!({"id": 1});
554        attach_to_parent(&mut matched, "posts", &plan(true), &grouped);
555        assert_eq!(matched["posts"].as_array().map(|a| a.len()), Some(1));
556
557        let mut unmatched = serde_json::json!({"id": 2});
558        attach_to_parent(&mut unmatched, "posts", &plan(true), &grouped);
559        assert_eq!(
560            unmatched["posts"],
561            serde_json::json!([]),
562            "a to-many with no matches must still be an array"
563        );
564    }
565
566    #[test]
567    fn to_one_attaches_an_object_or_null() {
568        let grouped = group_by_key(vec![serde_json::json!({"id": 10, "user_id": 1})], "user_id");
569
570        let mut matched = serde_json::json!({"id": 1});
571        attach_to_parent(&mut matched, "author", &plan(false), &grouped);
572        assert_eq!(matched["author"]["id"], serde_json::json!(10));
573
574        let mut unmatched = serde_json::json!({"id": 2});
575        attach_to_parent(&mut unmatched, "author", &plan(false), &grouped);
576        assert_eq!(unmatched["author"], serde_json::Value::Null);
577    }
578}