Skip to main content

postrust_graphql/resolver/
query.rs

1//! Query resolvers for GraphQL table queries.
2//!
3//! Converts GraphQL query arguments into ReadPlan structures that can be executed.
4
5use crate::input::filter::{
6    combine_with_and, filters_to_logic_tree, BooleanFilterInput, FloatFilterInput, IntFilterInput,
7    StringFilterInput, UuidFilterInput,
8};
9use crate::input::order::{OrderByField, PaginationInput};
10use postrust_core::api_request::{Filter, LogicTree, Range};
11use postrust_core::plan::{CoercibleLogicTree, CoercibleOrderTerm, CoercibleSelectField, ReadPlan};
12use postrust_core::schema_cache::Table;
13use serde::{Deserialize, Serialize};
14use std::collections::HashMap;
15
16/// Arguments for a GraphQL table query.
17#[derive(Debug, Clone, Default)]
18pub struct QueryArgs {
19    /// Fields to select (column names)
20    pub select: Vec<String>,
21    /// Filter conditions
22    pub filter: Option<TableFilter>,
23    /// Order by specifications
24    pub order_by: Vec<OrderByField>,
25    /// Limit number of results
26    pub limit: Option<i64>,
27    /// Offset into results
28    pub offset: Option<i64>,
29    /// Nested relation queries
30    pub relations: HashMap<String, QueryArgs>,
31}
32
33impl QueryArgs {
34    /// Create empty query args.
35    pub fn new() -> Self {
36        Self::default()
37    }
38
39    /// Set the select fields.
40    pub fn with_select(mut self, fields: Vec<String>) -> Self {
41        self.select = fields;
42        self
43    }
44
45    /// Set the filter.
46    pub fn with_filter(mut self, filter: TableFilter) -> Self {
47        self.filter = Some(filter);
48        self
49    }
50
51    /// Set order by.
52    pub fn with_order_by(mut self, order_by: Vec<OrderByField>) -> Self {
53        self.order_by = order_by;
54        self
55    }
56
57    /// Set pagination.
58    pub fn with_pagination(mut self, pagination: PaginationInput) -> Self {
59        self.limit = pagination.limit;
60        self.offset = pagination.offset;
61        self
62    }
63
64    /// Set limit.
65    pub fn with_limit(mut self, limit: i64) -> Self {
66        self.limit = Some(limit);
67        self
68    }
69
70    /// Set offset.
71    pub fn with_offset(mut self, offset: i64) -> Self {
72        self.offset = Some(offset);
73        self
74    }
75
76    /// Add a nested relation query.
77    pub fn with_relation(mut self, name: String, args: QueryArgs) -> Self {
78        self.relations.insert(name, args);
79        self
80    }
81
82    /// Get the pagination range.
83    pub fn to_range(&self) -> Range {
84        Range {
85            offset: self.offset.unwrap_or(0),
86            limit: self.limit,
87            // There is no `Range` header for a GraphQL argument to compete
88            // with, so whether the offset was written or defaulted decides
89            // nothing here.
90            offset_explicit: self.offset.is_some(),
91        }
92    }
93
94    /// Check if any select fields are specified.
95    pub fn has_select(&self) -> bool {
96        !self.select.is_empty()
97    }
98
99    /// Check if any filters are specified.
100    pub fn has_filter(&self) -> bool {
101        self.filter.is_some()
102    }
103
104    /// Check if any ordering is specified.
105    pub fn has_order_by(&self) -> bool {
106        !self.order_by.is_empty()
107    }
108
109    /// Check if pagination is specified.
110    pub fn has_pagination(&self) -> bool {
111        self.limit.is_some() || self.offset.is_some()
112    }
113}
114
115/// A dynamic filter for any table field.
116#[derive(Debug, Clone, Default, Serialize, Deserialize)]
117pub struct TableFilter {
118    /// Field-specific filters
119    #[serde(flatten)]
120    pub fields: HashMap<String, FieldFilter>,
121    /// AND combined filters
122    #[serde(rename = "_and")]
123    pub and: Option<Vec<TableFilter>>,
124    /// OR combined filters
125    #[serde(rename = "_or")]
126    pub or: Option<Vec<TableFilter>>,
127    /// Negated filter
128    #[serde(rename = "_not")]
129    pub not: Option<Box<TableFilter>>,
130}
131
132impl TableFilter {
133    /// Create an empty filter.
134    pub fn new() -> Self {
135        Self::default()
136    }
137
138    /// Add a field filter.
139    pub fn with_field(mut self, name: impl Into<String>, filter: FieldFilter) -> Self {
140        self.fields.insert(name.into(), filter);
141        self
142    }
143
144    /// Add AND filters.
145    pub fn with_and(mut self, filters: Vec<TableFilter>) -> Self {
146        self.and = Some(filters);
147        self
148    }
149
150    /// Add OR filters.
151    pub fn with_or(mut self, filters: Vec<TableFilter>) -> Self {
152        self.or = Some(filters);
153        self
154    }
155
156    /// Add NOT filter.
157    pub fn with_not(mut self, filter: TableFilter) -> Self {
158        self.not = Some(Box::new(filter));
159        self
160    }
161
162    /// Check if filter is empty.
163    pub fn is_empty(&self) -> bool {
164        self.fields.is_empty() && self.and.is_none() && self.or.is_none() && self.not.is_none()
165    }
166
167    /// Convert to a LogicTree.
168    pub fn to_logic_tree(&self) -> Option<LogicTree> {
169        let mut trees = Vec::new();
170
171        // Add field filters
172        for (field_name, field_filter) in &self.fields {
173            let filters = field_filter.to_filters(field_name);
174            if let Some(tree) = filters_to_logic_tree(filters) {
175                trees.push(tree);
176            }
177        }
178
179        // Add AND filters
180        if let Some(and_filters) = &self.and {
181            let and_trees: Vec<LogicTree> = and_filters
182                .iter()
183                .filter_map(|f| f.to_logic_tree())
184                .collect();
185            if let Some(tree) = combine_with_and(and_trees) {
186                trees.push(tree);
187            }
188        }
189
190        // Add OR filters
191        if let Some(or_filters) = &self.or {
192            let or_trees: Vec<LogicTree> = or_filters
193                .iter()
194                .filter_map(|f| f.to_logic_tree())
195                .collect();
196            if !or_trees.is_empty() {
197                trees.push(LogicTree::or(or_trees));
198            }
199        }
200
201        // Add NOT filter
202        if let Some(not_filter) = &self.not {
203            if let Some(tree) = not_filter.to_logic_tree() {
204                let negated = match tree {
205                    LogicTree::Expr { op, children, .. } => LogicTree::Expr {
206                        negated: true,
207                        op,
208                        children,
209                    },
210                    LogicTree::Stmt(filter) => {
211                        let negated_expr = postrust_core::api_request::OpExpr {
212                            negated: !filter.op_expr.negated,
213                            operation: filter.op_expr.operation,
214                        };
215                        LogicTree::Stmt(Filter::new(filter.field, negated_expr))
216                    }
217                };
218                trees.push(negated);
219            }
220        }
221
222        combine_with_and(trees)
223    }
224}
225
226/// A filter for a single field that can handle different types.
227#[derive(Debug, Clone, Serialize, Deserialize)]
228#[serde(untagged)]
229pub enum FieldFilter {
230    /// String filter operations
231    String(StringFilterInput),
232    /// Integer filter operations
233    Int(IntFilterInput),
234    /// Float filter operations
235    Float(FloatFilterInput),
236    /// Boolean filter operations
237    Boolean(BooleanFilterInput),
238    /// UUID filter operations
239    Uuid(UuidFilterInput),
240}
241
242impl FieldFilter {
243    /// Create a string filter.
244    pub fn string(filter: StringFilterInput) -> Self {
245        Self::String(filter)
246    }
247
248    /// Create an int filter.
249    pub fn int(filter: IntFilterInput) -> Self {
250        Self::Int(filter)
251    }
252
253    /// Create a float filter.
254    pub fn float(filter: FloatFilterInput) -> Self {
255        Self::Float(filter)
256    }
257
258    /// Create a boolean filter.
259    pub fn boolean(filter: BooleanFilterInput) -> Self {
260        Self::Boolean(filter)
261    }
262
263    /// Create a UUID filter.
264    pub fn uuid(filter: UuidFilterInput) -> Self {
265        Self::Uuid(filter)
266    }
267
268    /// Convert to a list of Filters.
269    pub fn to_filters(&self, field_name: &str) -> Vec<Filter> {
270        match self {
271            Self::String(f) => f.to_filters(field_name),
272            Self::Int(f) => f.to_filters(field_name),
273            Self::Float(f) => f.to_filters(field_name),
274            Self::Boolean(f) => f.to_filters(field_name),
275            Self::Uuid(f) => f.to_filters(field_name),
276        }
277    }
278}
279
280/// Build select fields from column names.
281pub fn build_select_fields(columns: &[String], table: &Table) -> Vec<CoercibleSelectField> {
282    if columns.is_empty() {
283        // Default: select all columns
284        return table
285            .columns
286            .iter()
287            .map(|(name, col)| CoercibleSelectField::simple(name, &col.data_type))
288            .collect();
289    }
290
291    columns
292        .iter()
293        .filter_map(|name| {
294            table
295                .columns
296                .get(name)
297                .map(|col| CoercibleSelectField::simple(name, &col.data_type))
298        })
299        .collect()
300}
301
302/// Build order terms from OrderByFields.
303pub fn build_order_terms(order_by: &[OrderByField], table: &Table) -> Vec<CoercibleOrderTerm> {
304    order_by
305        .iter()
306        .filter_map(|ob| {
307            table.columns.get(&ob.field).map(|col| {
308                let order_term = ob.to_order_term();
309                CoercibleOrderTerm::from_order_term(&order_term, &col.data_type)
310            })
311        })
312        .collect()
313}
314
315/// Build where clauses from a TableFilter.
316pub fn build_where_clauses(filter: &Option<TableFilter>, table: &Table) -> Vec<CoercibleLogicTree> {
317    let Some(filter) = filter else {
318        return vec![];
319    };
320
321    let type_resolver = |name: &str| -> String {
322        table
323            .get_column(name)
324            .map(|c| c.data_type.clone())
325            .unwrap_or_else(|| "text".to_string())
326    };
327
328    filter
329        .to_logic_tree()
330        .map(|tree| vec![CoercibleLogicTree::from_logic_tree(&tree, type_resolver)])
331        .unwrap_or_default()
332}
333
334/// Build a ReadPlan from GraphQL query arguments.
335pub fn build_read_plan(args: &QueryArgs, table: &Table) -> ReadPlan {
336    let select = build_select_fields(&args.select, table);
337    let order = build_order_terms(&args.order_by, table);
338    let where_clauses = build_where_clauses(&args.filter, table);
339
340    ReadPlan {
341        select,
342        from: table.qualified_identifier(),
343        from_alias: None,
344        where_clauses,
345        order,
346        range: args.to_range(),
347        rel_name: table.name.clone(),
348        rel_to_parent: None,
349        rel_join_conds: vec![],
350        rel_join_type: None,
351        rel_select: vec![],
352        depth: 0,
353    }
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359    use crate::input::filter::IntFilterInput;
360    use indexmap::IndexMap;
361    use postrust_core::schema_cache::Column;
362    use pretty_assertions::assert_eq;
363
364    fn create_test_table() -> Table {
365        let mut columns = IndexMap::new();
366        columns.insert(
367            "id".into(),
368            Column {
369                name: "id".into(),
370                description: None,
371                nullable: false,
372                data_type: "integer".into(),
373                nominal_type: "int4".into(),
374                max_len: None,
375                default: Some("nextval('users_id_seq')".into()),
376                enum_values: vec![],
377                is_pk: true,
378                position: 1,
379                always_generated: false,
380                domain_type: None,
381            },
382        );
383        columns.insert(
384            "name".into(),
385            Column {
386                name: "name".into(),
387                description: None,
388                nullable: false,
389                data_type: "text".into(),
390                nominal_type: "text".into(),
391                max_len: None,
392                default: None,
393                enum_values: vec![],
394                is_pk: false,
395                position: 2,
396                always_generated: false,
397                domain_type: None,
398            },
399        );
400        columns.insert(
401            "email".into(),
402            Column {
403                name: "email".into(),
404                description: None,
405                nullable: true,
406                data_type: "text".into(),
407                nominal_type: "text".into(),
408                max_len: None,
409                default: None,
410                enum_values: vec![],
411                is_pk: false,
412                position: 3,
413                always_generated: false,
414                domain_type: None,
415            },
416        );
417        columns.insert(
418            "age".into(),
419            Column {
420                name: "age".into(),
421                description: None,
422                nullable: true,
423                data_type: "integer".into(),
424                nominal_type: "int4".into(),
425                max_len: None,
426                default: None,
427                enum_values: vec![],
428                is_pk: false,
429                position: 4,
430                always_generated: false,
431                domain_type: None,
432            },
433        );
434
435        Table {
436            schema: "public".into(),
437            name: "users".into(),
438            description: None,
439            is_view: false,
440            insertable: true,
441            updatable: true,
442            deletable: true,
443            pk_cols: vec!["id".into()],
444            unique_constraints: Vec::new(),
445            columns,
446            computed_columns: Default::default(),
447            is_partitioned: false,
448        }
449    }
450
451    // ============================================================================
452    // QueryArgs Tests
453    // ============================================================================
454
455    #[test]
456    fn test_query_args_default() {
457        let args = QueryArgs::new();
458        assert!(!args.has_select());
459        assert!(!args.has_filter());
460        assert!(!args.has_order_by());
461        assert!(!args.has_pagination());
462    }
463
464    #[test]
465    fn test_query_args_with_select() {
466        let args = QueryArgs::new().with_select(vec!["id".to_string(), "name".to_string()]);
467        assert!(args.has_select());
468        assert_eq!(args.select.len(), 2);
469    }
470
471    #[test]
472    fn test_query_args_with_filter() {
473        let filter = TableFilter::new().with_field(
474            "name",
475            FieldFilter::string(StringFilterInput {
476                eq: Some("test".to_string()),
477                ..Default::default()
478            }),
479        );
480        let args = QueryArgs::new().with_filter(filter);
481        assert!(args.has_filter());
482    }
483
484    #[test]
485    fn test_query_args_with_order_by() {
486        let args = QueryArgs::new().with_order_by(vec![OrderByField::desc("created_at")]);
487        assert!(args.has_order_by());
488        assert_eq!(args.order_by.len(), 1);
489    }
490
491    #[test]
492    fn test_query_args_with_pagination() {
493        let args = QueryArgs::new().with_limit(10).with_offset(20);
494        assert!(args.has_pagination());
495        assert_eq!(args.limit, Some(10));
496        assert_eq!(args.offset, Some(20));
497    }
498
499    #[test]
500    fn test_query_args_to_range() {
501        let args = QueryArgs::new().with_limit(10).with_offset(5);
502        let range = args.to_range();
503        assert_eq!(range.offset, 5);
504        assert_eq!(range.limit, Some(10));
505    }
506
507    #[test]
508    fn test_query_args_with_relation() {
509        let child_args = QueryArgs::new().with_limit(5);
510        let args = QueryArgs::new().with_relation("orders".to_string(), child_args);
511        assert!(args.relations.contains_key("orders"));
512    }
513
514    // ============================================================================
515    // TableFilter Tests
516    // ============================================================================
517
518    #[test]
519    fn test_table_filter_empty() {
520        let filter = TableFilter::new();
521        assert!(filter.is_empty());
522        assert!(filter.to_logic_tree().is_none());
523    }
524
525    #[test]
526    fn test_table_filter_single_field() {
527        let filter = TableFilter::new().with_field(
528            "name",
529            FieldFilter::string(StringFilterInput {
530                eq: Some("Alice".to_string()),
531                ..Default::default()
532            }),
533        );
534        assert!(!filter.is_empty());
535        assert!(filter.to_logic_tree().is_some());
536    }
537
538    #[test]
539    fn test_table_filter_multiple_fields() {
540        let filter = TableFilter::new()
541            .with_field(
542                "name",
543                FieldFilter::string(StringFilterInput {
544                    eq: Some("Alice".to_string()),
545                    ..Default::default()
546                }),
547            )
548            .with_field(
549                "age",
550                FieldFilter::int(IntFilterInput {
551                    gt: Some(18),
552                    ..Default::default()
553                }),
554            );
555
556        let tree = filter.to_logic_tree().unwrap();
557        match tree {
558            LogicTree::Expr { children, .. } => {
559                assert_eq!(children.len(), 2);
560            }
561            _ => panic!("Expected Expr for multiple fields"),
562        }
563    }
564
565    #[test]
566    fn test_table_filter_with_and() {
567        let filter1 = TableFilter::new().with_field(
568            "a",
569            FieldFilter::int(IntFilterInput {
570                eq: Some(1),
571                ..Default::default()
572            }),
573        );
574        let filter2 = TableFilter::new().with_field(
575            "b",
576            FieldFilter::int(IntFilterInput {
577                eq: Some(2),
578                ..Default::default()
579            }),
580        );
581
582        let combined = TableFilter::new().with_and(vec![filter1, filter2]);
583        let tree = combined.to_logic_tree().unwrap();
584        assert!(matches!(tree, LogicTree::Expr { .. }));
585    }
586
587    #[test]
588    fn test_table_filter_with_or() {
589        let filter1 = TableFilter::new().with_field(
590            "status",
591            FieldFilter::string(StringFilterInput {
592                eq: Some("active".to_string()),
593                ..Default::default()
594            }),
595        );
596        let filter2 = TableFilter::new().with_field(
597            "status",
598            FieldFilter::string(StringFilterInput {
599                eq: Some("pending".to_string()),
600                ..Default::default()
601            }),
602        );
603
604        let combined = TableFilter::new().with_or(vec![filter1, filter2]);
605        let tree = combined.to_logic_tree().unwrap();
606
607        match tree {
608            LogicTree::Expr {
609                op: postrust_core::api_request::LogicOperator::Or,
610                ..
611            } => {}
612            _ => panic!("Expected OR expression"),
613        }
614    }
615
616    #[test]
617    fn test_table_filter_with_not() {
618        let inner = TableFilter::new().with_field(
619            "deleted",
620            FieldFilter::boolean(BooleanFilterInput {
621                eq: Some(true),
622                ..Default::default()
623            }),
624        );
625
626        let filter = TableFilter::new().with_not(inner);
627        let tree = filter.to_logic_tree().unwrap();
628
629        match tree {
630            LogicTree::Expr { negated: true, .. } | LogicTree::Stmt(_) => {}
631            _ => panic!("Expected negated expression"),
632        }
633    }
634
635    // ============================================================================
636    // FieldFilter Tests
637    // ============================================================================
638
639    #[test]
640    fn test_field_filter_string() {
641        let filter = FieldFilter::string(StringFilterInput {
642            eq: Some("test".to_string()),
643            ..Default::default()
644        });
645        let filters = filter.to_filters("name");
646        assert_eq!(filters.len(), 1);
647    }
648
649    #[test]
650    fn test_field_filter_int() {
651        let filter = FieldFilter::int(IntFilterInput {
652            gte: Some(18),
653            lte: Some(65),
654            ..Default::default()
655        });
656        let filters = filter.to_filters("age");
657        assert_eq!(filters.len(), 2); // gte and lte
658    }
659
660    #[test]
661    fn test_field_filter_boolean() {
662        let filter = FieldFilter::boolean(BooleanFilterInput {
663            eq: Some(true),
664            ..Default::default()
665        });
666        let filters = filter.to_filters("active");
667        assert_eq!(filters.len(), 1);
668    }
669
670    // ============================================================================
671    // Build Functions Tests
672    // ============================================================================
673
674    #[test]
675    fn test_build_select_fields_empty() {
676        let table = create_test_table();
677        let fields = build_select_fields(&[], &table);
678        assert_eq!(fields.len(), 4); // All columns
679    }
680
681    #[test]
682    fn test_build_select_fields_specific() {
683        let table = create_test_table();
684        let fields = build_select_fields(&["id".to_string(), "name".to_string()], &table);
685        assert_eq!(fields.len(), 2);
686    }
687
688    #[test]
689    fn test_build_select_fields_invalid_column() {
690        let table = create_test_table();
691        let fields = build_select_fields(&["nonexistent".to_string()], &table);
692        assert_eq!(fields.len(), 0); // Invalid columns are skipped
693    }
694
695    #[test]
696    fn test_build_order_terms() {
697        let table = create_test_table();
698        let order_by = vec![OrderByField::desc("name"), OrderByField::asc("id")];
699        let terms = build_order_terms(&order_by, &table);
700        assert_eq!(terms.len(), 2);
701    }
702
703    #[test]
704    fn test_build_order_terms_invalid_column() {
705        let table = create_test_table();
706        let order_by = vec![OrderByField::desc("nonexistent")];
707        let terms = build_order_terms(&order_by, &table);
708        assert_eq!(terms.len(), 0); // Invalid columns are skipped
709    }
710
711    #[test]
712    fn test_build_where_clauses_none() {
713        let table = create_test_table();
714        let clauses = build_where_clauses(&None, &table);
715        assert!(clauses.is_empty());
716    }
717
718    #[test]
719    fn test_build_where_clauses_with_filter() {
720        let table = create_test_table();
721        let filter = TableFilter::new().with_field(
722            "name",
723            FieldFilter::string(StringFilterInput {
724                eq: Some("test".to_string()),
725                ..Default::default()
726            }),
727        );
728        let clauses = build_where_clauses(&Some(filter), &table);
729        assert_eq!(clauses.len(), 1);
730    }
731
732    // ============================================================================
733    // ReadPlan Building Tests
734    // ============================================================================
735
736    #[test]
737    fn test_build_read_plan_basic() {
738        let table = create_test_table();
739        let args = QueryArgs::new();
740        let plan = build_read_plan(&args, &table);
741
742        assert_eq!(plan.from.name, "users");
743        assert_eq!(plan.select.len(), 4); // All columns
744        assert!(plan.where_clauses.is_empty());
745        assert!(plan.order.is_empty());
746    }
747
748    #[test]
749    fn test_build_read_plan_with_select() {
750        let table = create_test_table();
751        let args = QueryArgs::new().with_select(vec!["id".to_string(), "name".to_string()]);
752        let plan = build_read_plan(&args, &table);
753
754        assert_eq!(plan.select.len(), 2);
755    }
756
757    #[test]
758    fn test_build_read_plan_with_filter() {
759        let table = create_test_table();
760        let filter = TableFilter::new().with_field(
761            "age",
762            FieldFilter::int(IntFilterInput {
763                gte: Some(18),
764                ..Default::default()
765            }),
766        );
767        let args = QueryArgs::new().with_filter(filter);
768        let plan = build_read_plan(&args, &table);
769
770        assert!(!plan.where_clauses.is_empty());
771    }
772
773    #[test]
774    fn test_build_read_plan_with_order() {
775        let table = create_test_table();
776        let args = QueryArgs::new().with_order_by(vec![OrderByField::desc("name")]);
777        let plan = build_read_plan(&args, &table);
778
779        assert_eq!(plan.order.len(), 1);
780    }
781
782    #[test]
783    fn test_build_read_plan_with_pagination() {
784        let table = create_test_table();
785        let args = QueryArgs::new().with_limit(10).with_offset(20);
786        let plan = build_read_plan(&args, &table);
787
788        assert_eq!(plan.range.limit, Some(10));
789        assert_eq!(plan.range.offset, 20);
790    }
791
792    #[test]
793    fn test_build_read_plan_full() {
794        let table = create_test_table();
795        let filter = TableFilter::new()
796            .with_field(
797                "name",
798                FieldFilter::string(StringFilterInput {
799                    like: Some("%John%".to_string()),
800                    ..Default::default()
801                }),
802            )
803            .with_field(
804                "age",
805                FieldFilter::int(IntFilterInput {
806                    gte: Some(21),
807                    ..Default::default()
808                }),
809            );
810
811        let args = QueryArgs::new()
812            .with_select(vec![
813                "id".to_string(),
814                "name".to_string(),
815                "email".to_string(),
816            ])
817            .with_filter(filter)
818            .with_order_by(vec![OrderByField::asc("name")])
819            .with_limit(50)
820            .with_offset(0);
821
822        let plan = build_read_plan(&args, &table);
823
824        assert_eq!(plan.select.len(), 3);
825        assert!(!plan.where_clauses.is_empty());
826        assert_eq!(plan.order.len(), 1);
827        assert_eq!(plan.range.limit, Some(50));
828        assert_eq!(plan.range.offset, 0);
829    }
830}