Skip to main content

nodedb_sql/planner/
returning.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Resolution of a DML `RETURNING` item list against the write's target.
4//!
5//! The clause is a projection over the target collection, so it converts
6//! through the same path a top-level SELECT list does. A bare column and a
7//! star pass through. Every other item resolves to
8//! [`Projection::CpComputed`]: the Data Plane returns the base columns the
9//! expression reads and the Control Plane evaluates it once per returned
10//! row. A sequence accessor is allowed here exactly as in a top-level SELECT
11//! list.
12
13use sqlparser::ast;
14
15use nodedb_types::DatabaseId;
16
17use crate::error::{Result, SqlError};
18use crate::parser::statement::parse_sql;
19use crate::planner::cp_projection::to_cp_computed;
20use crate::planner::select::helpers::convert_projection;
21use crate::resolver::columns::{ResolvedTable, TableScope};
22use crate::types::{Projection, SqlCatalog};
23
24/// Resolve a RETURNING item list against the DML target collection.
25///
26/// `items_sql` is the text after the `RETURNING` keyword. Every non-column
27/// item resolves to `Projection::CpComputed`: the Data Plane returns the base
28/// columns the expression reads and the Control Plane evaluates it per
29/// returned row.
30pub fn resolve_returning_items(
31    items_sql: &str,
32    target: &str,
33    catalog: &dyn SqlCatalog,
34) -> Result<Vec<Projection>> {
35    let items = parse_items(items_sql)?;
36    let info = catalog
37        .get_collection(DatabaseId::DEFAULT, target)?
38        .ok_or_else(|| SqlError::UnknownTable {
39            name: target.to_string(),
40        })?;
41    let scope = TableScope::single(ResolvedTable {
42        name: target.to_string(),
43        alias: None,
44        info,
45    })?
46    .as_statement_output()
47    .allowing_cp_functions();
48    let projection = convert_projection(&items, &scope)?;
49    Ok(projection.into_iter().map(to_cp_computed).collect())
50}
51
52/// The SELECT items of `SELECT <items_sql>`.
53///
54/// The wrapped statement must be exactly one SELECT with nothing but a
55/// projection list: a FROM, WHERE, GROUP BY, ORDER BY, LIMIT, or WITH clause
56/// after the keyword is refused, never silently ignored.
57fn parse_items(items_sql: &str) -> Result<Vec<ast::SelectItem>> {
58    let refuse = || SqlError::Parse {
59        detail: format!("invalid RETURNING list: '{}'", items_sql.trim()),
60    };
61    let mut statements = parse_sql(&format!("SELECT {items_sql}"))?;
62    if statements.len() != 1 {
63        return Err(refuse());
64    }
65    let Some(ast::Statement::Query(query)) = statements.pop() else {
66        return Err(refuse());
67    };
68    if query.with.is_some()
69        || query.order_by.is_some()
70        || query.limit_clause.is_some()
71        || query.fetch.is_some()
72    {
73        return Err(refuse());
74    }
75    let ast::SetExpr::Select(select) = *query.body else {
76        return Err(refuse());
77    };
78    let grouped = match &select.group_by {
79        ast::GroupByExpr::All(_) => true,
80        ast::GroupByExpr::Expressions(exprs, _) => !exprs.is_empty(),
81    };
82    if !select.from.is_empty() || select.selection.is_some() || grouped || select.having.is_some() {
83        return Err(refuse());
84    }
85    Ok(select.projection)
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91    use crate::catalog::SqlCatalogError;
92    use crate::types::{CollectionInfo, ColumnInfo, EngineType, SqlDataType, SqlExpr};
93
94    /// One strict `items` collection: `id TEXT`, `score BIGINT`.
95    struct ItemsCatalog;
96
97    impl SqlCatalog for ItemsCatalog {
98        fn get_collection(
99            &self,
100            _database_id: DatabaseId,
101            name: &str,
102        ) -> std::result::Result<Option<CollectionInfo>, SqlCatalogError> {
103            if name != "items" {
104                return Ok(None);
105            }
106            let col = |n: &str, t: SqlDataType| ColumnInfo {
107                name: n.to_string(),
108                data_type: t,
109                nullable: true,
110                is_primary_key: false,
111                default: None,
112                raw_type: None,
113                int_width: None,
114                float_width: None,
115            };
116            Ok(Some(CollectionInfo {
117                name: "items".to_string(),
118                engine: EngineType::DocumentStrict,
119                columns: vec![
120                    col("id", SqlDataType::String),
121                    col("score", SqlDataType::Int64),
122                ],
123                primary_key: None,
124                has_auto_tier: false,
125                indexes: Vec::new(),
126                bitemporal: false,
127                primary: nodedb_types::PrimaryEngine::Document,
128                vector_primary: None,
129                partition_strategy: nodedb_types::PartitionStrategy::CollectionHomed,
130                open_schema: CollectionInfo::open_schema_for(EngineType::DocumentStrict),
131            }))
132        }
133    }
134
135    fn resolve(items: &str) -> Result<Vec<Projection>> {
136        resolve_returning_items(items, "items", &ItemsCatalog)
137    }
138
139    #[test]
140    fn a_bare_column_stays_a_column() {
141        let projection = resolve("id, score").unwrap();
142        assert_eq!(projection.len(), 2);
143        assert!(matches!(&projection[0], Projection::Column(c) if c == "id"));
144        assert!(matches!(&projection[1], Projection::Column(c) if c == "score"));
145    }
146
147    #[test]
148    fn an_aliased_column_stays_computed_over_the_column() {
149        let projection = resolve("id AS a").unwrap();
150        assert_eq!(projection.len(), 1);
151        match &projection[0] {
152            Projection::Computed { expr, alias } => {
153                assert_eq!(alias, "a");
154                assert!(matches!(expr, SqlExpr::Column { name, .. } if name == "id"));
155            }
156            other => panic!("expected Computed, got {other:?}"),
157        }
158    }
159
160    #[test]
161    fn an_arithmetic_item_is_cp_computed() {
162        let projection = resolve("score * 2 AS d").unwrap();
163        assert_eq!(projection.len(), 1);
164        match &projection[0] {
165            Projection::CpComputed { expr, alias } => {
166                assert_eq!(alias, "d");
167                assert!(matches!(expr, SqlExpr::BinaryOp { .. }));
168            }
169            other => panic!("expected CpComputed, got {other:?}"),
170        }
171    }
172
173    #[test]
174    fn an_unaliased_expression_is_named_by_its_text() {
175        let projection = resolve("score + 1").unwrap();
176        match &projection[0] {
177            Projection::CpComputed { alias, .. } => assert_eq!(alias, "score + 1"),
178            other => panic!("expected CpComputed, got {other:?}"),
179        }
180    }
181
182    #[test]
183    fn a_sequence_accessor_is_cp_computed() {
184        let projection = resolve("id, nextval('s') AS n").unwrap();
185        assert_eq!(projection.len(), 2);
186        match &projection[1] {
187            Projection::CpComputed { expr, alias } => {
188                assert_eq!(alias, "n");
189                assert!(matches!(expr, SqlExpr::Function { name, .. } if name == "nextval"));
190            }
191            other => panic!("expected CpComputed, got {other:?}"),
192        }
193    }
194
195    #[test]
196    fn a_star_passes_through() {
197        let projection = resolve("*").unwrap();
198        assert_eq!(projection.len(), 1);
199        assert!(matches!(projection[0], Projection::Star));
200    }
201
202    #[test]
203    fn an_unknown_column_is_a_resolve_error() {
204        let err = resolve("ghost").unwrap_err();
205        assert!(
206            matches!(err, SqlError::UnknownColumn { ref column, .. } if column == "ghost"),
207            "expected UnknownColumn, got {err:?}"
208        );
209    }
210
211    #[test]
212    fn an_unknown_target_is_an_unknown_table() {
213        let err = resolve_returning_items("id", "ghost", &ItemsCatalog).unwrap_err();
214        assert!(matches!(err, SqlError::UnknownTable { ref name } if name == "ghost"));
215    }
216
217    #[test]
218    fn a_trailing_clause_is_refused() {
219        assert!(resolve("id FROM other").is_err());
220        assert!(resolve("id WHERE score > 1").is_err());
221        assert!(resolve("id ORDER BY id").is_err());
222        assert!(resolve("id; SELECT 1").is_err());
223        assert!(resolve("").is_err());
224    }
225}