1use crate::error::{Error, Result};
19use crate::schema_cache::{Relationship, SchemaCache, Table};
20use std::collections::HashMap;
21
22#[derive(Clone, Debug)]
24pub struct EmbedPlan {
25 pub local_column: String,
27 pub foreign_column: String,
29 pub foreign_column_type: String,
31 pub foreign_schema: String,
33 pub foreign_table: String,
35 pub is_list: bool,
37}
38
39impl EmbedPlan {
40 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 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 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 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 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 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 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 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
274fn 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
289pub 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
301pub 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 _ => 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
342pub 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
375pub 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 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 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 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 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}