Skip to main content

postrust_core/query/
builder.rs

1//! Query builder implementation.
2
3use crate::error::Result;
4use crate::plan::{
5    CallParams, CallPlan, CoercibleFilter, CoercibleLogicTree, CoercibleOrderTerm,
6    CoercibleSelectField, MutatePlan, ReadPlan, ReadPlanTree,
7};
8use postrust_sql::{
9    escape_ident, from_qi, DeleteBuilder, InsertBuilder, OrderExpr, SelectBuilder, SqlFragment,
10    SqlParam, UpdateBuilder,
11};
12
13/// Query builder for converting plans to SQL.
14pub struct QueryBuilder;
15
16impl QueryBuilder {
17    /// Build a SELECT query from a read plan tree.
18    pub fn build_read(tree: &ReadPlanTree) -> Result<SqlFragment> {
19        Self::build_read_plan(&tree.root)
20    }
21
22    /// Build a SELECT query from a read plan.
23    fn build_read_plan(plan: &ReadPlan) -> Result<SqlFragment> {
24        let mut builder = SelectBuilder::new();
25
26        // FROM clause
27        let qi = &plan.from;
28        if let Some(alias) = &plan.from_alias {
29            builder = builder.from_table_as(
30                &postrust_sql::identifier::QualifiedIdentifier::new(&qi.schema, &qi.name),
31                alias,
32            );
33        } else {
34            builder = builder.from_table(&postrust_sql::identifier::QualifiedIdentifier::new(
35                &qi.schema, &qi.name,
36            ));
37        }
38
39        // SELECT columns
40        for field in &plan.select {
41            let col_frag = Self::build_select_field(field)?;
42            builder = builder.column_raw(col_frag);
43        }
44
45        // WHERE clauses
46        for clause in &plan.where_clauses {
47            let expr = Self::build_logic_tree(clause)?;
48            builder = builder.where_raw(expr);
49        }
50
51        // ORDER BY
52        for term in &plan.order {
53            let order = Self::build_order_term(term);
54            builder = builder.order_by(order);
55        }
56
57        // LIMIT/OFFSET
58        if let Some(limit) = plan.range.limit {
59            builder = builder.limit(limit);
60        }
61        if plan.range.offset > 0 {
62            builder = builder.offset(plan.range.offset);
63        }
64
65        Ok(builder.build())
66    }
67
68    /// Build a SELECT field.
69    fn build_select_field(field: &CoercibleSelectField) -> Result<SqlFragment> {
70        let mut frag = SqlFragment::new();
71
72        // Aggregate function
73        if let Some(agg) = &field.aggregate {
74            frag.push(agg.to_sql());
75            frag.push("(");
76        }
77
78        // Column name with JSON path
79        frag.push(&escape_ident(&field.field.name));
80
81        // Close aggregate
82        if field.aggregate.is_some() {
83            frag.push(")");
84        }
85
86        // Cast
87        if let Some(cast) = &field.cast {
88            frag.push("::");
89            frag.push(cast);
90        }
91
92        // Alias
93        if let Some(alias) = &field.alias {
94            frag.push(" AS ");
95            frag.push(&escape_ident(alias));
96        }
97
98        Ok(frag)
99    }
100
101    /// Build a logic tree.
102    fn build_logic_tree(tree: &CoercibleLogicTree) -> Result<SqlFragment> {
103        match tree {
104            CoercibleLogicTree::Expr {
105                negated,
106                op,
107                children,
108            } => {
109                let sep = match op {
110                    crate::api_request::LogicOperator::And => " AND ",
111                    crate::api_request::LogicOperator::Or => " OR ",
112                };
113
114                let child_frags: Result<Vec<_>> =
115                    children.iter().map(Self::build_logic_tree).collect();
116
117                let mut combined = SqlFragment::join(sep, child_frags?).parens();
118
119                if *negated {
120                    let mut neg = SqlFragment::raw("NOT ");
121                    neg.append(combined);
122                    combined = neg;
123                }
124
125                Ok(combined)
126            }
127            CoercibleLogicTree::Stmt(filter) => Self::build_filter(filter),
128            CoercibleLogicTree::NullEmbed {
129                negated,
130                field_name,
131            } => {
132                let mut frag = SqlFragment::new();
133                frag.push(&escape_ident(field_name));
134                if *negated {
135                    frag.push(" IS NOT NULL");
136                } else {
137                    frag.push(" IS NULL");
138                }
139                Ok(frag)
140            }
141        }
142    }
143
144    /// Build a filter expression.
145    fn build_filter(filter: &CoercibleFilter) -> Result<SqlFragment> {
146        let mut frag = SqlFragment::new();
147
148        // Negation wraps the whole comparison. Placing `NOT` between the column
149        // and the operator only parses for a few operators -- `col NOT LIKE $1`
150        // is valid but `col NOT = $1` is a syntax error -- so the comparison is
151        // parenthesised instead, which is correct for every operator.
152        if filter.op_expr.negated {
153            frag.push("NOT (");
154        }
155
156        // Column name
157        frag.push(&escape_ident(&filter.field.name));
158
159        // Filter values are always bound as text, so a comparison against a
160        // non-text column needs an explicit cast on the placeholder -- without
161        // it PostgreSQL rejects the query with `operator does not exist:
162        // integer = text`. A JSON path already yields text, so it is left as-is.
163        let cast = if filter.field.json_path.is_empty() {
164            castable_type(&filter.field.ir_type)
165        } else {
166            None
167        };
168        let push_value = |frag: &mut SqlFragment, value: String| match cast {
169            Some(pg_type) => {
170                frag.push_typed_param(value, pg_type);
171            }
172            None => {
173                frag.push_param(value);
174            }
175        };
176
177        // Operation
178        match &filter.op_expr.operation {
179            crate::api_request::Operation::Simple { op, value } => {
180                frag.push(" ");
181                frag.push(op.to_sql());
182                frag.push(" ");
183                push_value(&mut frag, value.clone());
184            }
185            crate::api_request::Operation::Quant {
186                op,
187                quantifier,
188                value,
189            } => {
190                frag.push(" ");
191                frag.push(op.to_sql());
192                frag.push(" ");
193                if let Some(q) = quantifier {
194                    match q {
195                        crate::api_request::OpQuantifier::Any => frag.push("ANY("),
196                        crate::api_request::OpQuantifier::All => frag.push("ALL("),
197                    };
198                    // A quantified comparison takes an array of the column's
199                    // type. Array-typed columns are already handled by the
200                    // element cast, so they are left alone.
201                    match cast.filter(|t| !t.starts_with('_')) {
202                        Some(pg_type) => {
203                            frag.push_typed_param(value.clone(), &format!("{}[]", pg_type));
204                        }
205                        None => {
206                            frag.push_param(value.clone());
207                        }
208                    }
209                    frag.push(")");
210                } else {
211                    push_value(&mut frag, value.clone());
212                }
213            }
214            crate::api_request::Operation::In(values) => {
215                frag.push(" IN (");
216                for (i, v) in values.iter().enumerate() {
217                    if i > 0 {
218                        frag.push(", ");
219                    }
220                    push_value(&mut frag, v.clone());
221                }
222                frag.push(")");
223            }
224            crate::api_request::Operation::Is(is_val) => {
225                frag.push(" IS ");
226                frag.push(is_val.to_sql());
227            }
228            crate::api_request::Operation::IsDistinctFrom(value) => {
229                frag.push(" IS DISTINCT FROM ");
230                push_value(&mut frag, value.clone());
231            }
232            crate::api_request::Operation::Fts {
233                op,
234                language,
235                value,
236            } => {
237                frag.push(" @@ ");
238                frag.push(op.to_function());
239                frag.push("(");
240                if let Some(lang) = language {
241                    frag.push_param(lang.clone());
242                    frag.push(", ");
243                }
244                frag.push_param(value.clone());
245                frag.push(")");
246            }
247        }
248
249        if filter.op_expr.negated {
250            frag.push(")");
251        }
252
253        Ok(frag)
254    }
255
256    /// Build an ORDER BY term.
257    fn build_order_term(term: &CoercibleOrderTerm) -> OrderExpr {
258        let mut order = OrderExpr::new(&term.field.name);
259
260        if let Some(dir) = &term.direction {
261            order = match dir {
262                crate::api_request::OrderDirection::Asc => order.asc(),
263                crate::api_request::OrderDirection::Desc => order.desc(),
264            };
265        }
266
267        if let Some(nulls) = &term.nulls {
268            order = match nulls {
269                crate::api_request::OrderNulls::First => order.nulls_first(),
270                crate::api_request::OrderNulls::Last => order.nulls_last(),
271            };
272        }
273
274        order
275    }
276
277    /// Build a mutation query.
278    pub fn build_mutate(plan: &MutatePlan) -> Result<SqlFragment> {
279        match plan {
280            MutatePlan::Insert {
281                target,
282                columns,
283                body,
284                on_conflict,
285                returning,
286                ..
287            } => {
288                let qi = postrust_sql::identifier::QualifiedIdentifier::new(
289                    &target.schema,
290                    &target.name,
291                );
292
293                let mut builder = InsertBuilder::new().into_table(&qi);
294
295                // Column names
296                let col_names: Vec<String> = columns.iter().map(|c| c.name.clone()).collect();
297                builder = builder.columns(col_names);
298
299                // For bulk insert, we'd use json_populate_recordset
300                // For now, simplified single-row insert
301                if let Some(body_bytes) = body {
302                    // This would be expanded with proper JSON handling
303                    let body_str = String::from_utf8_lossy(body_bytes);
304                    let mut frag = SqlFragment::new();
305                    frag.push("SELECT * FROM json_populate_recordset(NULL::");
306                    frag.push(&from_qi(&qi));
307                    frag.push(", ");
308                    frag.push_param(body_str.to_string());
309                    frag.push("::json)");
310                    return Ok(frag);
311                }
312
313                // ON CONFLICT
314                if let Some((resolution, conflict_cols)) = on_conflict {
315                    match resolution {
316                        crate::api_request::PreferResolution::IgnoreDuplicates => {
317                            builder = builder.on_conflict_do_nothing();
318                        }
319                        crate::api_request::PreferResolution::MergeDuplicates => {
320                            let set_cols: Vec<(String, SqlFragment)> = columns
321                                .iter()
322                                .map(|c| {
323                                    let mut frag = SqlFragment::new();
324                                    frag.push("EXCLUDED.");
325                                    frag.push(&escape_ident(&c.name));
326                                    (c.name.clone(), frag)
327                                })
328                                .collect();
329                            builder =
330                                builder.on_conflict_do_update(conflict_cols.clone(), set_cols);
331                        }
332                    }
333                }
334
335                // RETURNING
336                for col in returning {
337                    builder = builder.returning(col);
338                }
339
340                Ok(builder.build())
341            }
342
343            MutatePlan::Update {
344                target,
345                columns,
346                body,
347                where_clauses,
348                returning,
349                ..
350            } => {
351                let qi = postrust_sql::identifier::QualifiedIdentifier::new(
352                    &target.schema,
353                    &target.name,
354                );
355
356                let builder = UpdateBuilder::new().table(&qi);
357
358                // SET columns from body
359                if let Some(body_bytes) = body {
360                    let body_str = String::from_utf8_lossy(body_bytes);
361                    // Simplified: would properly parse JSON and set columns
362                    let mut frag = SqlFragment::new();
363                    frag.push("UPDATE ");
364                    frag.push(&from_qi(&qi));
365                    frag.push(" SET ");
366
367                    for (i, col) in columns.iter().enumerate() {
368                        if i > 0 {
369                            frag.push(", ");
370                        }
371                        frag.push(&escape_ident(&col.name));
372                        frag.push(" = (");
373                        frag.push_param(body_str.to_string());
374                        frag.push("::json->>");
375                        frag.push_param(col.name.clone());
376                        frag.push(")::");
377                        frag.push(&col.ir_type);
378                    }
379
380                    // WHERE
381                    if !where_clauses.is_empty() {
382                        frag.push(" WHERE ");
383                        for (i, clause) in where_clauses.iter().enumerate() {
384                            if i > 0 {
385                                frag.push(" AND ");
386                            }
387                            frag.append(Self::build_logic_tree(clause)?);
388                        }
389                    }
390
391                    // RETURNING
392                    if !returning.is_empty() {
393                        frag.push(" RETURNING ");
394                        for (i, col) in returning.iter().enumerate() {
395                            if i > 0 {
396                                frag.push(", ");
397                            }
398                            frag.push(&escape_ident(col));
399                        }
400                    }
401
402                    return Ok(frag);
403                }
404
405                Ok(builder.build())
406            }
407
408            MutatePlan::Delete {
409                target,
410                where_clauses,
411                returning,
412            } => {
413                let qi = postrust_sql::identifier::QualifiedIdentifier::new(
414                    &target.schema,
415                    &target.name,
416                );
417
418                let mut builder = DeleteBuilder::new().from_table(&qi);
419
420                // WHERE
421                for clause in where_clauses {
422                    let expr = Self::build_logic_tree(clause)?;
423                    builder = builder.where_raw(expr);
424                }
425
426                // RETURNING
427                for col in returning {
428                    builder = builder.returning(col);
429                }
430
431                Ok(builder.build())
432            }
433        }
434    }
435
436    /// Build an RPC call query.
437    pub fn build_call(plan: &CallPlan) -> Result<SqlFragment> {
438        let qi = postrust_sql::identifier::QualifiedIdentifier::new(
439            &plan.function.schema,
440            &plan.function.name,
441        );
442
443        let mut frag = SqlFragment::new();
444        frag.push("SELECT * FROM ");
445        frag.push(&from_qi(&qi));
446        frag.push("(");
447
448        match &plan.params {
449            CallParams::Named(params) => {
450                for (i, (name, value)) in params.iter().enumerate() {
451                    if i > 0 {
452                        frag.push(", ");
453                    }
454                    frag.push(&escape_ident(name));
455                    frag.push(" => ");
456                    frag.push_param(SqlParam::Text(value.clone()));
457                }
458            }
459            CallParams::Positional(values) => {
460                for (i, value) in values.iter().enumerate() {
461                    if i > 0 {
462                        frag.push(", ");
463                    }
464                    frag.push_param(SqlParam::Text(value.clone()));
465                }
466            }
467            CallParams::SingleObject(body) => {
468                let body_str = String::from_utf8_lossy(body);
469                frag.push_param(SqlParam::Text(body_str.to_string()));
470            }
471            CallParams::None => {}
472        }
473
474        frag.push(")");
475
476        Ok(frag)
477    }
478}
479
480/// Return the type to cast a bound filter value to, if it is safe to do so.
481///
482/// The type name is interpolated into SQL, so only bare type names are
483/// accepted: anything else (an empty type, a parameterised type such as
484/// `character varying(255)`, or the `ARRAY`/`USER-DEFINED` placeholders that
485/// `information_schema` reports) yields `None` and the value is bound
486/// uncast, preserving the previous behaviour.
487fn castable_type(pg_type: &str) -> Option<&str> {
488    if pg_type.is_empty() {
489        return None;
490    }
491
492    let is_bare_name = pg_type
493        .chars()
494        .all(|c| c.is_ascii_alphanumeric() || c == '_');
495
496    if is_bare_name {
497        Some(pg_type)
498    } else {
499        None
500    }
501}
502
503#[cfg(test)]
504mod tests {
505    use super::*;
506
507    #[test]
508    fn castable_type_accepts_bare_type_names() {
509        assert_eq!(castable_type("int4"), Some("int4"));
510        assert_eq!(castable_type("timestamptz"), Some("timestamptz"));
511        assert_eq!(castable_type("_text"), Some("_text"));
512    }
513
514    #[test]
515    fn castable_type_rejects_unsafe_names() {
516        assert_eq!(castable_type(""), None);
517        assert_eq!(castable_type("character varying"), None);
518        assert_eq!(castable_type("USER-DEFINED"), None);
519        assert_eq!(castable_type("int4; DROP TABLE users"), None);
520    }
521}