Skip to main content

fraiseql_core/runtime/
aggregate_parser.rs

1//! Aggregate Query Parser
2//!
3//! Parses GraphQL aggregate queries into `AggregationRequest` for execution.
4//!
5//! # GraphQL Query Format
6//!
7//! ```graphql
8//! query {
9//!   sales_aggregate(
10//!     where: { customer_id: { _eq: "uuid-123" } }
11//!     groupBy: { category: true, occurred_at_day: true }
12//!     having: { revenue_sum_gt: 1000 }
13//!     orderBy: { revenue_sum: DESC }
14//!     limit: 10
15//!   ) {
16//!     category
17//!     occurred_at_day
18//!     count
19//!     revenue_sum
20//!     revenue_avg
21//!   }
22//! }
23//! ```
24//!
25//! # Parsed Result
26//!
27//! ```text
28//! // Illustrative output structure only — not executable code.
29//! AggregationRequest {
30//!     table_name: "tf_sales",
31//!     where_clause: Some(...),
32//!     group_by: vec![
33//!         GroupBySelection::Dimension { path: "category", alias: "category" },
34//!         GroupBySelection::TemporalBucket { column: "occurred_at", bucket: Day, alias: "occurred_at_day" },
35//!     ],
36//!     aggregates: vec![
37//!         AggregateSelection::Count { alias: "count" },
38//!         AggregateSelection::MeasureAggregate { measure: "revenue", function: Sum, alias: "revenue_sum" },
39//!         AggregateSelection::MeasureAggregate { measure: "revenue", function: Avg, alias: "revenue_avg" },
40//!     ],
41//!     having: vec![...],
42//!     order_by: vec![...],
43//!     limit: Some(10),
44//!     offset: None,
45//! }
46//! ```
47
48use serde_json::Value;
49
50use crate::{
51    compiler::{
52        aggregate_types::{AggregateFunction, HavingOperator, TemporalBucket},
53        aggregation::{
54            AggregateSelection, AggregationRequest, GroupBySelection, HavingCondition,
55            OrderByClause, OrderDirection,
56        },
57        fact_table::FactTableMetadata,
58    },
59    db::where_clause::{WhereClause, WhereOperator},
60    error::{FraiseQLError, Result},
61};
62
63/// Aggregate query parser
64pub struct AggregateQueryParser;
65
66impl AggregateQueryParser {
67    /// Parse a simplified aggregate query into `AggregationRequest`.
68    ///
69    /// For we'll accept a JSON structure that represents the query:
70    /// ```json
71    /// {
72    ///   "table": "tf_sales",
73    ///   "groupBy": {
74    ///     "category": true,
75    ///     "occurred_at_day": true
76    ///   },
77    ///   "aggregates": [
78    ///     {"count": {}},
79    ///     {"revenue_sum": {}}
80    ///   ],
81    ///   "having": {
82    ///     "revenue_sum_gt": 1000
83    ///   },
84    ///   "orderBy": {
85    ///     "revenue_sum": "DESC"
86    ///   },
87    ///   "limit": 10
88    /// }
89    /// ```
90    ///
91    /// # Errors
92    ///
93    /// Returns error if the query structure is invalid or references non-existent measures.
94    ///
95    /// The `native_columns` map (column name → PostgreSQL cast string) controls whether
96    /// WHERE filters and GROUP BY fields are emitted as direct column references
97    /// (`WhereClause::NativeField`, `GroupBySelection::NativeDimension`) rather than
98    /// JSONB extraction variants.  Pass an empty map to get the old behaviour.
99    pub fn parse(
100        query_json: &Value,
101        metadata: &FactTableMetadata,
102        native_columns: &std::collections::HashMap<String, String>,
103    ) -> Result<AggregationRequest> {
104        // Extract table name
105        let table_name = query_json
106            .get("table")
107            .and_then(|v| v.as_str())
108            .ok_or_else(|| FraiseQLError::Validation {
109                message: "Missing 'table' field in aggregate query".to_string(),
110                path:    None,
111            })?
112            .to_string();
113
114        // Parse WHERE clause (if present)
115        let where_clause = if let Some(where_obj) = query_json.get("where") {
116            Some(Self::parse_where_clause(where_obj, native_columns)?)
117        } else {
118            None
119        };
120
121        // Parse GROUP BY selections
122        let group_by = if let Some(group_by_obj) = query_json.get("groupBy") {
123            Self::parse_group_by(group_by_obj, metadata, native_columns)?
124        } else {
125            vec![]
126        };
127
128        // Parse aggregate selections from requested fields
129        let aggregates = if let Some(agg_array) = query_json.get("aggregates") {
130            Self::parse_aggregates(agg_array, metadata)?
131        } else {
132            vec![]
133        };
134
135        // Parse HAVING conditions
136        let having = if let Some(having_obj) = query_json.get("having") {
137            Self::parse_having(having_obj, &aggregates, metadata)?
138        } else {
139            vec![]
140        };
141
142        // Parse ORDER BY clauses
143        let order_by = if let Some(order_obj) = query_json.get("orderBy") {
144            Self::parse_order_by(order_obj)?
145        } else {
146            vec![]
147        };
148
149        // Parse LIMIT
150        let limit = query_json
151            .get("limit")
152            .and_then(|v| v.as_u64())
153            .map(|n| u32::try_from(n).unwrap_or(u32::MAX));
154
155        // Parse OFFSET
156        let offset = query_json
157            .get("offset")
158            .and_then(|v| v.as_u64())
159            .map(|n| u32::try_from(n).unwrap_or(u32::MAX));
160
161        Ok(AggregationRequest {
162            table_name,
163            where_clause,
164            group_by,
165            aggregates,
166            having,
167            order_by,
168            limit,
169            offset,
170        })
171    }
172
173    /// Parse WHERE clause from JSON.
174    ///
175    /// For aggregate queries, WHERE works on denormalized filter columns only.
176    /// Expected format: `{ "field_operator": value }`
177    /// Example: `{ "customer_id_eq": "123", "occurred_at_gte": "2024-01-01" }`
178    ///
179    /// When a field name matches an entry in `native_columns`, the clause is emitted
180    /// as [`WhereClause::NativeField`] (direct column reference) rather than
181    /// [`WhereClause::Field`] (JSONB extraction).
182    fn parse_where_clause(
183        where_obj: &Value,
184        native_columns: &std::collections::HashMap<String, String>,
185    ) -> Result<WhereClause> {
186        let Some(obj) = where_obj.as_object() else {
187            return Ok(WhereClause::And(vec![]));
188        };
189
190        let mut conditions = Vec::new();
191
192        for (key, value) in obj {
193            // Parse field_operator format (e.g., "customer_id_eq" -> field="customer_id",
194            // operator="eq")
195            if let Some((field, operator_str)) = Self::parse_where_field_and_operator(key)? {
196                let operator = WhereOperator::from_str(operator_str)?;
197
198                let clause = if let Some(pg_cast) = native_columns.get(field) {
199                    WhereClause::NativeField {
200                        column: field.to_string(),
201                        pg_cast: pg_cast.clone(),
202                        operator,
203                        value: value.clone(),
204                    }
205                } else {
206                    WhereClause::Field {
207                        path: vec![field.to_string()],
208                        operator,
209                        value: value.clone(),
210                    }
211                };
212                conditions.push(clause);
213            }
214        }
215
216        Ok(WhereClause::And(conditions))
217    }
218
219    /// Parse WHERE field and operator from key (e.g., "`customer_id_eq`" -> ("`customer_id`",
220    /// "eq"))
221    fn parse_where_field_and_operator(key: &str) -> Result<Option<(&str, &str)>> {
222        // Find last underscore to split field from operator
223        if let Some(last_underscore) = key.rfind('_') {
224            let field = &key[..last_underscore];
225            let operator = &key[last_underscore + 1..];
226
227            // Validate operator is known
228            match WhereOperator::from_str(operator) {
229                Ok(_) => Ok(Some((field, operator))),
230                Err(_) => {
231                    // Not a valid operator suffix, treat entire key as field (might be used
232                    // elsewhere)
233                    Ok(None)
234                },
235            }
236        } else {
237            // No underscore, not a WHERE condition
238            Ok(None)
239        }
240    }
241
242    /// Parse GROUP BY selections.
243    ///
244    /// Supports two formats:
245    /// 1. Boolean true: {"category": true} -> regular dimension
246    /// 2. Boolean true with suffix: {"`occurred_at_day"`: true} -> temporal bucket
247    /// 3. String bucket name: {"`occurred_at"`: "day"} -> temporal bucket
248    ///
249    /// When a field name matches an entry in `native_columns`, the selection is emitted
250    /// as [`GroupBySelection::NativeDimension`] rather than [`GroupBySelection::Dimension`].
251    fn parse_group_by(
252        group_by_obj: &Value,
253        metadata: &FactTableMetadata,
254        native_columns: &std::collections::HashMap<String, String>,
255    ) -> Result<Vec<GroupBySelection>> {
256        let mut selections = Vec::new();
257
258        if let Some(obj) = group_by_obj.as_object() {
259            for (key, value) in obj {
260                if value.as_bool() == Some(true) {
261                    // Format 1 & 2: Boolean true (with or without suffix)
262                    // Priority 1: Try calendar dimension first (highest performance)
263                    if let Some(calendar_sel) = Self::try_parse_calendar_bucket(key, metadata)? {
264                        selections.push(calendar_sel);
265                    } else if let Some(bucket_sel) = Self::parse_temporal_bucket(key, metadata)? {
266                        // Priority 2: Fall back to DATE_TRUNC if no calendar dimension
267                        selections.push(bucket_sel);
268                    } else if let Some(mapped_col) =
269                        metadata.native_dimension_mapping.get(key.as_str())
270                    {
271                        // Priority 3: Deep JSONB path mapped to a native column
272                        selections.push(GroupBySelection::NativeDimension {
273                            column:  mapped_col.clone(),
274                            pg_cast: String::new(),
275                        });
276                    } else if let Some(pg_cast) = native_columns.get(key.as_str()) {
277                        // Priority 4: Native SQL column (filter-derived) — direct reference
278                        selections.push(GroupBySelection::NativeDimension {
279                            column:  key.clone(),
280                            pg_cast: pg_cast.clone(),
281                        });
282                    } else {
283                        // Priority 5: Regular JSONB dimension
284                        selections.push(GroupBySelection::Dimension {
285                            path:  key.clone(),
286                            alias: key.clone(),
287                        });
288                    }
289                } else if let Some(bucket_str) = value.as_str() {
290                    // Format 3: String bucket name {"occurred_at": "day"}
291                    let bucket = TemporalBucket::from_str(bucket_str)?;
292
293                    // Priority 1: Try calendar dimension first
294                    if let Some(calendar_sel) =
295                        Self::try_find_calendar_bucket(key, bucket, metadata)
296                    {
297                        selections.push(calendar_sel);
298                    } else {
299                        // Priority 2: Fall back to DATE_TRUNC
300                        // Verify this column exists in denormalized_filters
301                        let column_exists =
302                            metadata.denormalized_filters.iter().any(|f| f.name == *key);
303
304                        if !column_exists {
305                            return Err(FraiseQLError::Validation {
306                                message: format!(
307                                    "Temporal bucketing column '{}' not found in denormalized filters",
308                                    key
309                                ),
310                                path:    None,
311                            });
312                        }
313
314                        selections.push(GroupBySelection::TemporalBucket {
315                            column: key.clone(),
316                            bucket,
317                            alias: key.clone(),
318                        });
319                    }
320                }
321            }
322        }
323
324        Ok(selections)
325    }
326
327    /// Parse temporal bucket if the key matches pattern
328    fn parse_temporal_bucket(
329        key: &str,
330        metadata: &FactTableMetadata,
331    ) -> Result<Option<GroupBySelection>> {
332        // Check for temporal bucket patterns: column_day, column_week, etc.
333        for filter_col in &metadata.denormalized_filters {
334            for bucket in &[
335                ("_second", TemporalBucket::Second),
336                ("_minute", TemporalBucket::Minute),
337                ("_hour", TemporalBucket::Hour),
338                ("_day", TemporalBucket::Day),
339                ("_week", TemporalBucket::Week),
340                ("_month", TemporalBucket::Month),
341                ("_quarter", TemporalBucket::Quarter),
342                ("_year", TemporalBucket::Year),
343            ] {
344                let expected_key = format!("{}{}", filter_col.name, bucket.0);
345                if key == expected_key {
346                    return Ok(Some(GroupBySelection::TemporalBucket {
347                        column: filter_col.name.clone(),
348                        bucket: bucket.1,
349                        alias:  key.to_string(),
350                    }));
351                }
352            }
353        }
354
355        Ok(None)
356    }
357
358    /// Try to parse calendar dimension from key pattern (e.g., "`occurred_at_day`")
359    ///
360    /// Checks if the key matches a calendar dimension pattern and returns
361    /// a `CalendarDimension` selection if available, otherwise None.
362    fn try_parse_calendar_bucket(
363        key: &str,
364        metadata: &FactTableMetadata,
365    ) -> Result<Option<GroupBySelection>> {
366        for calendar_dim in &metadata.calendar_dimensions {
367            // Check all temporal bucket suffixes
368            for (suffix, bucket_type) in &[
369                ("_second", TemporalBucket::Second),
370                ("_minute", TemporalBucket::Minute),
371                ("_hour", TemporalBucket::Hour),
372                ("_day", TemporalBucket::Day),
373                ("_week", TemporalBucket::Week),
374                ("_month", TemporalBucket::Month),
375                ("_quarter", TemporalBucket::Quarter),
376                ("_year", TemporalBucket::Year),
377            ] {
378                let expected_key = format!("{}{}", calendar_dim.source_column, suffix);
379                if key == expected_key {
380                    // Find matching calendar bucket
381                    if let Some((gran, bucket)) =
382                        Self::find_calendar_bucket(calendar_dim, *bucket_type)
383                    {
384                        return Ok(Some(GroupBySelection::CalendarDimension {
385                            source_column:   calendar_dim.source_column.clone(),
386                            calendar_column: gran.column_name.clone(),
387                            json_key:        bucket.json_key.clone(),
388                            bucket:          bucket.bucket_type,
389                            alias:           key.to_string(),
390                        }));
391                    }
392                }
393            }
394        }
395        Ok(None)
396    }
397
398    /// Try to find calendar bucket for explicit temporal request
399    ///
400    /// Used when user provides explicit bucket like {"`occurred_at"`: "day"}
401    fn try_find_calendar_bucket(
402        column: &str,
403        bucket: TemporalBucket,
404        metadata: &FactTableMetadata,
405    ) -> Option<GroupBySelection> {
406        for calendar_dim in &metadata.calendar_dimensions {
407            if calendar_dim.source_column == column {
408                if let Some((gran, cal_bucket)) = Self::find_calendar_bucket(calendar_dim, bucket) {
409                    return Some(GroupBySelection::CalendarDimension {
410                        source_column:   calendar_dim.source_column.clone(),
411                        calendar_column: gran.column_name.clone(),
412                        json_key:        cal_bucket.json_key.clone(),
413                        bucket:          cal_bucket.bucket_type,
414                        alias:           column.to_string(),
415                    });
416                }
417            }
418        }
419        None
420    }
421
422    /// Find calendar bucket in available granularities
423    ///
424    /// Searches through calendar dimension granularities to find a matching bucket type.
425    /// Returns the granularity and bucket if found.
426    fn find_calendar_bucket(
427        calendar_dim: &crate::compiler::fact_table::CalendarDimension,
428        bucket: TemporalBucket,
429    ) -> Option<(
430        &crate::compiler::fact_table::CalendarGranularity,
431        &crate::compiler::fact_table::CalendarBucket,
432    )> {
433        for granularity in &calendar_dim.granularities {
434            for cal_bucket in &granularity.buckets {
435                if cal_bucket.bucket_type == bucket {
436                    return Some((granularity, cal_bucket));
437                }
438            }
439        }
440        None
441    }
442
443    /// Parse aggregate selections
444    fn parse_aggregates(
445        agg_array: &Value,
446        metadata: &FactTableMetadata,
447    ) -> Result<Vec<AggregateSelection>> {
448        let mut aggregates = Vec::new();
449
450        if let Some(arr) = agg_array.as_array() {
451            for item in arr {
452                if let Some(obj) = item.as_object() {
453                    // Each object should have one key (the aggregate name)
454                    for (agg_name, _value) in obj {
455                        aggregates.push(Self::parse_aggregate_selection(agg_name, metadata)?);
456                    }
457                }
458            }
459        }
460
461        Ok(aggregates)
462    }
463
464    /// Parse a single aggregate selection
465    fn parse_aggregate_selection(
466        agg_name: &str,
467        metadata: &FactTableMetadata,
468    ) -> Result<AggregateSelection> {
469        // Handle COUNT
470        if agg_name == "count" {
471            return Ok(AggregateSelection::Count {
472                alias: "count".to_string(),
473            });
474        }
475
476        // Handle COUNT_DISTINCT: supports both "count_distinct" (defaults to first dimension)
477        // and "field_count_distinct" pattern (e.g., "product_id_count_distinct")
478        if agg_name == "count_distinct" {
479            // Default to first dimension path, or "id" if none available
480            let default_field = Self::extract_dimension_paths(metadata)
481                .first()
482                .cloned()
483                .unwrap_or_else(|| "id".to_string());
484            return Ok(AggregateSelection::CountDistinct {
485                field: default_field,
486                alias: "count_distinct".to_string(),
487            });
488        }
489
490        // Handle field_count_distinct pattern (e.g., "customer_id_count_distinct")
491        if let Some(stripped) = agg_name.strip_suffix("_count_distinct") {
492            // Check if the stripped part matches a dimension path
493            let dimension_paths = Self::extract_dimension_paths(metadata);
494            if dimension_paths.iter().any(|p| p == stripped) {
495                return Ok(AggregateSelection::CountDistinct {
496                    field: stripped.to_string(),
497                    alias: agg_name.to_string(),
498                });
499            }
500            // Also allow count distinct on measures
501            if metadata.measures.iter().any(|m| m.name == stripped) {
502                return Ok(AggregateSelection::CountDistinct {
503                    field: stripped.to_string(),
504                    alias: agg_name.to_string(),
505                });
506            }
507            // If no match found, return error with helpful message
508            return Err(FraiseQLError::Validation {
509                message: format!(
510                    "COUNT DISTINCT field '{}' not found in dimensions or measures. Available: {:?}",
511                    stripped, dimension_paths
512                ),
513                path:    None,
514            });
515        }
516
517        // Handle boolean aggregates (BOOL_AND, BOOL_OR)
518        // e.g., "is_active_bool_and", "has_discount_bool_or"
519        for dimension_path in Self::extract_dimension_paths(metadata) {
520            if let Some(stripped) = agg_name.strip_suffix("_bool_and") {
521                if stripped == dimension_path {
522                    return Ok(AggregateSelection::BoolAggregate {
523                        field:    dimension_path,
524                        function: crate::compiler::aggregate_types::BoolAggregateFunction::And,
525                        alias:    agg_name.to_string(),
526                    });
527                }
528            }
529            if let Some(stripped) = agg_name.strip_suffix("_bool_or") {
530                if stripped == dimension_path {
531                    return Ok(AggregateSelection::BoolAggregate {
532                        field:    dimension_path,
533                        function: crate::compiler::aggregate_types::BoolAggregateFunction::Or,
534                        alias:    agg_name.to_string(),
535                    });
536                }
537            }
538        }
539
540        // Handle measure aggregates: revenue_sum, revenue_avg, etc.
541        for measure in &metadata.measures {
542            for func in &[
543                ("_sum", AggregateFunction::Sum),
544                ("_avg", AggregateFunction::Avg),
545                ("_min", AggregateFunction::Min),
546                ("_max", AggregateFunction::Max),
547                ("_stddev", AggregateFunction::Stddev),
548                ("_variance", AggregateFunction::Variance),
549                // Advanced aggregates
550                ("_array_agg", AggregateFunction::ArrayAgg),
551                ("_json_agg", AggregateFunction::JsonAgg),
552                ("_jsonb_agg", AggregateFunction::JsonbAgg),
553                ("_string_agg", AggregateFunction::StringAgg),
554            ] {
555                let expected_name = format!("{}{}", measure.name, func.0);
556                if agg_name == expected_name {
557                    return Ok(AggregateSelection::MeasureAggregate {
558                        measure:  measure.name.clone(),
559                        function: func.1,
560                        alias:    agg_name.to_string(),
561                    });
562                }
563            }
564        }
565
566        // Handle native measure aggregates: measures.volume_sum, measures.cost_avg, etc.
567        // These are JSONB measure paths mapped to flat SQL columns via native_measures.
568        for jsonb_path in metadata.native_measures.keys() {
569            for func in &[
570                ("_sum", AggregateFunction::Sum),
571                ("_avg", AggregateFunction::Avg),
572                ("_min", AggregateFunction::Min),
573                ("_max", AggregateFunction::Max),
574                ("_stddev", AggregateFunction::Stddev),
575                ("_variance", AggregateFunction::Variance),
576                ("_array_agg", AggregateFunction::ArrayAgg),
577                ("_json_agg", AggregateFunction::JsonAgg),
578                ("_jsonb_agg", AggregateFunction::JsonbAgg),
579                ("_string_agg", AggregateFunction::StringAgg),
580            ] {
581                let expected_name = format!("{}{}", jsonb_path, func.0);
582                if agg_name == expected_name {
583                    // Store the JSONB path as the measure name; the planner resolves
584                    // it to the native column via FactTableMetadata.native_measures.
585                    return Ok(AggregateSelection::MeasureAggregate {
586                        measure:  jsonb_path.clone(),
587                        function: func.1,
588                        alias:    agg_name.to_string(),
589                    });
590                }
591            }
592        }
593
594        // Check for dimension-level advanced aggregates
595        // e.g., "product_id_array_agg", "product_name_string_agg"
596        for dimension_path in Self::extract_dimension_paths(metadata) {
597            for func in &[
598                ("_array_agg", AggregateFunction::ArrayAgg),
599                ("_json_agg", AggregateFunction::JsonAgg),
600                ("_jsonb_agg", AggregateFunction::JsonbAgg),
601                ("_string_agg", AggregateFunction::StringAgg),
602            ] {
603                let expected_name = format!("{}{}", dimension_path, func.0);
604                if agg_name == expected_name {
605                    // For dimension aggregates, store the path as the "measure"
606                    return Ok(AggregateSelection::MeasureAggregate {
607                        measure:  dimension_path,
608                        function: func.1,
609                        alias:    agg_name.to_string(),
610                    });
611                }
612            }
613        }
614
615        Err(FraiseQLError::Validation {
616            message: format!("Unknown aggregate selection: {agg_name}"),
617            path:    None,
618        })
619    }
620
621    /// Extract dimension paths from metadata for advanced aggregate parsing
622    fn extract_dimension_paths(metadata: &FactTableMetadata) -> Vec<String> {
623        let mut paths = Vec::new();
624
625        // Add dimension paths from JSONB column
626        for dim_path in &metadata.dimensions.paths {
627            paths.push(dim_path.name.clone());
628        }
629
630        // Add denormalized filter columns (these can also be aggregated)
631        for filter in &metadata.denormalized_filters {
632            paths.push(filter.name.clone());
633        }
634
635        paths
636    }
637
638    /// Parse HAVING conditions
639    fn parse_having(
640        having_obj: &Value,
641        aggregates: &[AggregateSelection],
642        _metadata: &FactTableMetadata,
643    ) -> Result<Vec<HavingCondition>> {
644        let mut conditions = Vec::new();
645
646        if let Some(obj) = having_obj.as_object() {
647            for (key, value) in obj {
648                // Parse condition: revenue_sum_gt: 1000
649                if let Some((agg_name, operator)) = Self::parse_having_key(key) {
650                    // Find the aggregate
651                    let aggregate = aggregates
652                        .iter()
653                        .find(|a| a.alias() == agg_name)
654                        .ok_or_else(|| FraiseQLError::Validation {
655                            message: format!(
656                                "HAVING condition references non-selected aggregate: {agg_name}"
657                            ),
658                            path:    None,
659                        })?
660                        .clone();
661
662                    conditions.push(HavingCondition {
663                        aggregate,
664                        operator,
665                        value: value.clone(),
666                    });
667                }
668            }
669        }
670
671        Ok(conditions)
672    }
673
674    /// Parse HAVING key to extract aggregate name and operator
675    fn parse_having_key(key: &str) -> Option<(&str, HavingOperator)> {
676        for (suffix, op) in &[
677            ("_gt", HavingOperator::Gt),
678            ("_gte", HavingOperator::Gte),
679            ("_lt", HavingOperator::Lt),
680            ("_lte", HavingOperator::Lte),
681            ("_eq", HavingOperator::Eq),
682            ("_neq", HavingOperator::Neq),
683        ] {
684            if let Some(agg_name) = key.strip_suffix(suffix) {
685                return Some((agg_name, *op));
686            }
687        }
688        None
689    }
690
691    /// Parse ORDER BY clauses
692    fn parse_order_by(order_obj: &Value) -> Result<Vec<OrderByClause>> {
693        let mut clauses = Vec::new();
694
695        if let Some(obj) = order_obj.as_object() {
696            for (field, value) in obj {
697                #[allow(clippy::match_same_arms)]
698                // Reason: explicit ASC arm documents accepted values; wildcard handles malformed
699                // input
700                let direction = match value.as_str() {
701                    Some("ASC" | "asc") => OrderDirection::Asc,
702                    Some("DESC" | "desc") => OrderDirection::Desc,
703                    _ => OrderDirection::Asc, // Default to ASC
704                };
705
706                clauses.push(OrderByClause::new(field.clone(), direction));
707            }
708        }
709
710        Ok(clauses)
711    }
712}