Skip to main content

fraiseql_db/types/
sql_hints.rs

1//! Shared SQL types used across the compiler, schema, and database layers.
2//!
3//! These types are defined here (rather than in `compiler::aggregation` or
4//! `schema`) so that `db/` can import them without creating a dependency on
5//! the compilation or schema layers — a prerequisite for eventually extracting
6//! `db/` into its own crate (`fraiseql-db`).
7
8use fraiseql_error::{FraiseQLError, Result};
9use serde::{Deserialize, Serialize};
10
11use crate::types::db_types::DatabaseType;
12
13/// ORDER BY clause
14///
15/// # Numeric field sorting
16///
17/// When sorting on a JSONB field via relay pagination, the value is
18/// extracted as `text` using `data->>'field'`. This means **numeric
19/// JSON fields sort lexicographically** (`"9" > "10"`), which is
20/// incorrect for integer and float data.
21///
22/// Workaround: expose integer sort keys as a dedicated typed column
23/// in the database view. String and ISO-8601 date/time fields sort
24/// correctly without this workaround.
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26pub struct OrderByClause {
27    /// Field to order by (can be dimension, aggregate, or temporal bucket)
28    pub field:     String,
29    /// Sort direction
30    pub direction: OrderDirection,
31}
32
33/// Sort direction
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
35#[non_exhaustive]
36pub enum OrderDirection {
37    /// Ascending (A-Z, 0-9)
38    Asc,
39    /// Descending (Z-A, 9-0)
40    Desc,
41}
42
43impl OrderByClause {
44    /// Validate that a field name matches the GraphQL identifier pattern `[_A-Za-z][_0-9A-Za-z]*`.
45    ///
46    /// This is a security boundary: field names are interpolated into SQL `data->>'field'`
47    /// expressions. Any character outside the GraphQL identifier set must be rejected before
48    /// the `OrderByClause` is constructed.
49    ///
50    /// # Errors
51    ///
52    /// Returns `FraiseQLError::Validation` if the field contains invalid characters.
53    fn validate_field_name(field: &str) -> Result<()> {
54        let mut chars = field.chars();
55        let first_ok = chars.next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_');
56        let rest_ok = chars.all(|c| c.is_ascii_alphanumeric() || c == '_');
57        if first_ok && rest_ok {
58            Ok(())
59        } else {
60            Err(FraiseQLError::Validation {
61                message: format!(
62                    "orderBy field name '{field}' contains invalid characters; \
63                     only [_A-Za-z][_0-9A-Za-z]* is allowed"
64                ),
65                path:    None,
66            })
67        }
68    }
69
70    /// Parse `orderBy` from a GraphQL variables JSON value.
71    ///
72    /// Accepts two formats:
73    /// - Object: `{ "name": "DESC", "created_at": "ASC" }`
74    /// - Array:  `[{ "field": "name", "direction": "DESC" }]`
75    ///
76    /// Direction strings are case-insensitive.
77    ///
78    /// # Errors
79    ///
80    /// Returns `FraiseQLError::Validation` for invalid structure or direction values.
81    pub fn from_graphql_json(value: &serde_json::Value) -> Result<Vec<Self>> {
82        if let Some(obj) = value.as_object() {
83            // Object format: { "name": "DESC", "created_at": "ASC" }
84            obj.iter()
85                .map(|(field, dir_val)| {
86                    let dir_str = dir_val.as_str().ok_or_else(|| FraiseQLError::Validation {
87                        message: format!("orderBy direction for '{field}' must be a string"),
88                        path:    None,
89                    })?;
90                    let direction = match dir_str.to_ascii_uppercase().as_str() {
91                        "ASC" => OrderDirection::Asc,
92                        "DESC" => OrderDirection::Desc,
93                        _ => {
94                            return Err(FraiseQLError::Validation {
95                                message: format!(
96                                    "orderBy direction '{dir_str}' must be ASC or DESC"
97                                ),
98                                path:    None,
99                            });
100                        },
101                    };
102                    Self::validate_field_name(field)?;
103                    Ok(Self {
104                        field: field.clone(),
105                        direction,
106                    })
107                })
108                .collect()
109        } else if let Some(arr) = value.as_array() {
110            // Array format: [{ "field": "name", "direction": "DESC" }]
111            arr.iter()
112                .map(|item| {
113                    let obj = item.as_object().ok_or_else(|| FraiseQLError::Validation {
114                        message: "orderBy array items must be objects".to_string(),
115                        path:    None,
116                    })?;
117                    let field = obj
118                        .get("field")
119                        .and_then(|v| v.as_str())
120                        .ok_or_else(|| FraiseQLError::Validation {
121                            message: "orderBy item missing 'field' string".to_string(),
122                            path:    None,
123                        })?
124                        .to_string();
125                    let dir_str = obj.get("direction").and_then(|v| v.as_str()).unwrap_or("ASC");
126                    let direction = match dir_str.to_ascii_uppercase().as_str() {
127                        "ASC" => OrderDirection::Asc,
128                        "DESC" => OrderDirection::Desc,
129                        _ => {
130                            return Err(FraiseQLError::Validation {
131                                message: format!(
132                                    "orderBy direction '{dir_str}' must be ASC or DESC"
133                                ),
134                                path:    None,
135                            });
136                        },
137                    };
138                    Self::validate_field_name(&field)?;
139                    Ok(Self { field, direction })
140                })
141                .collect()
142        } else {
143            Err(FraiseQLError::Validation {
144                message: "orderBy must be an object or array".to_string(),
145                path:    None,
146            })
147        }
148    }
149}
150
151/// SQL projection hint for database-specific field projection optimization.
152///
153/// When a type has a large JSONB payload, the compiler can generate
154/// SQL that projects only the requested fields, reducing network payload
155/// and JSON deserialization overhead.
156#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
157pub struct SqlProjectionHint {
158    /// Database type — typed to prevent silent typos (e.g. `"postgresq"`) that
159    /// would cause adapters to silently ignore the hint.
160    pub database: DatabaseType,
161
162    /// The projection SQL template.
163    /// Example for PostgreSQL:
164    /// `jsonb_build_object('id', data->>'id', 'email', data->>'email')`
165    pub projection_template: String,
166
167    /// Estimated reduction in payload size (percentage 0-100).
168    pub estimated_reduction_percent: u32,
169}