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, utils::to_snake_case};
12
13/// SQL sort type for ORDER BY cast generation.
14///
15/// Determines whether the SQL generator wraps the extracted JSONB text in a
16/// type cast (e.g., `(data->>'amount')::numeric`) to ensure correct sort order.
17/// Without a cast, all JSONB extractions are `text` and sort lexicographically,
18/// which is wrong for numeric and date/time fields (`"9" > "10"`).
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
20#[non_exhaustive]
21pub enum OrderByFieldType {
22    /// No cast — text/string sort (correct for strings, UUIDs, enum values).
23    #[default]
24    Text,
25    /// Cast to integer type (`::bigint` / `CAST(... AS BIGINT)`).
26    Integer,
27    /// Cast to floating-point/numeric type (`::numeric` / `CAST(... AS DECIMAL(38,12))`).
28    Numeric,
29    /// Cast to boolean (`::boolean` / `CAST(... AS UNSIGNED)`).
30    Boolean,
31    /// Cast to timestamp (`::timestamptz` / `CAST(... AS DATETIME)`).
32    /// Also used for ISO-8601 date-time strings which sort correctly as text,
33    /// but the cast ensures the database optimizer can use typed comparisons.
34    DateTime,
35    /// Cast to date (`::date` / `CAST(... AS DATE)`).
36    Date,
37    /// Cast to time (`::time` / `CAST(... AS TIME)`).
38    Time,
39}
40
41/// ORDER BY clause with optional type and native column information.
42///
43/// The SQL generator uses `field_type` to emit the correct type cast for
44/// JSONB-extracted values, and `native_column` to bypass JSONB extraction
45/// entirely when the view exposes a dedicated typed column.
46///
47/// # Sort correctness by source
48///
49/// | Source | Text fields | Numeric fields | Date fields |
50/// |--------|------------|----------------|-------------|
51/// | JSONB (no cast) | Correct | **Wrong** (lexicographic) | Correct (ISO-8601) |
52/// | JSONB (with cast) | Correct | Correct | Correct |
53/// | Native column | Correct | Correct | Correct + indexable |
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55pub struct OrderByClause {
56    /// Field to order by (GraphQL camelCase name).
57    pub field:         String,
58    /// Sort direction.
59    pub direction:     OrderDirection,
60    /// Field type for SQL cast generation. `Text` (default) means no cast.
61    #[serde(default)]
62    pub field_type:    OrderByFieldType,
63    /// Native column name if the view exposes this field as a typed column.
64    /// When set, ORDER BY uses this column directly instead of JSONB extraction,
65    /// enabling index support and correct typing without casts.
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub native_column: Option<String>,
68}
69
70/// Sort direction
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
72#[non_exhaustive]
73pub enum OrderDirection {
74    /// Ascending (A-Z, 0-9)
75    Asc,
76    /// Descending (Z-A, 9-0)
77    Desc,
78}
79
80impl OrderDirection {
81    /// Return the SQL keyword for this direction.
82    #[must_use]
83    pub const fn as_sql(self) -> &'static str {
84        match self {
85            Self::Asc => "ASC",
86            Self::Desc => "DESC",
87        }
88    }
89}
90
91impl OrderByClause {
92    /// Create a new `OrderByClause` with default field type (text) and no native column.
93    #[must_use]
94    pub fn new(field: String, direction: OrderDirection) -> Self {
95        Self {
96            field,
97            direction,
98            field_type: OrderByFieldType::default(),
99            native_column: None,
100        }
101    }
102
103    /// Convert the GraphQL camelCase field name to the JSONB snake_case storage key.
104    ///
105    /// # Examples
106    ///
107    /// ```
108    /// use fraiseql_db::OrderByClause;
109    /// use fraiseql_db::OrderDirection;
110    ///
111    /// let clause = OrderByClause::new("createdAt".to_string(), OrderDirection::Asc);
112    /// assert_eq!(clause.storage_key(), "created_at");
113    /// ```
114    #[must_use]
115    pub fn storage_key(&self) -> String {
116        to_snake_case(&self.field)
117    }
118
119    /// Validate that a field name matches the GraphQL identifier pattern `[_A-Za-z][_0-9A-Za-z]*`.
120    ///
121    /// This is a security boundary: field names are interpolated into SQL `data->>'field'`
122    /// expressions. Any character outside the GraphQL identifier set must be rejected before
123    /// the `OrderByClause` is constructed.
124    ///
125    /// # Errors
126    ///
127    /// Returns `FraiseQLError::Validation` if the field contains invalid characters.
128    pub fn validate_field_name(field: &str) -> Result<()> {
129        let mut chars = field.chars();
130        let first_ok = chars.next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_');
131        let rest_ok = chars.all(|c| c.is_ascii_alphanumeric() || c == '_');
132        if first_ok && rest_ok {
133            Ok(())
134        } else {
135            Err(FraiseQLError::Validation {
136                message: format!(
137                    "orderBy field name '{field}' contains invalid characters; \
138                     only [_A-Za-z][_0-9A-Za-z]* is allowed"
139                ),
140                path:    None,
141            })
142        }
143    }
144
145    /// Parse `orderBy` from a GraphQL variables JSON value.
146    ///
147    /// Accepts two formats:
148    /// - Object: `{ "name": "DESC", "created_at": "ASC" }`
149    /// - Array:  `[{ "field": "name", "direction": "DESC" }]`
150    ///
151    /// Direction strings are case-insensitive.
152    ///
153    /// # Errors
154    ///
155    /// Returns `FraiseQLError::Validation` for invalid structure or direction values.
156    pub fn from_graphql_json(value: &serde_json::Value) -> Result<Vec<Self>> {
157        if let Some(obj) = value.as_object() {
158            // Object format: { "name": "DESC", "created_at": "ASC" }
159            obj.iter()
160                .map(|(field, dir_val)| {
161                    let dir_str = dir_val.as_str().ok_or_else(|| FraiseQLError::Validation {
162                        message: format!("orderBy direction for '{field}' must be a string"),
163                        path:    None,
164                    })?;
165                    let direction = match dir_str.to_ascii_uppercase().as_str() {
166                        "ASC" => OrderDirection::Asc,
167                        "DESC" => OrderDirection::Desc,
168                        _ => {
169                            return Err(FraiseQLError::Validation {
170                                message: format!(
171                                    "orderBy direction '{dir_str}' must be ASC or DESC"
172                                ),
173                                path:    None,
174                            });
175                        },
176                    };
177                    Self::validate_field_name(field)?;
178                    Ok(Self::new(field.clone(), direction))
179                })
180                .collect()
181        } else if let Some(arr) = value.as_array() {
182            // Array format: [{ "field": "name", "direction": "DESC" }]
183            arr.iter()
184                .map(|item| {
185                    let obj = item.as_object().ok_or_else(|| FraiseQLError::Validation {
186                        message: "orderBy array items must be objects".to_string(),
187                        path:    None,
188                    })?;
189                    let field = obj
190                        .get("field")
191                        .and_then(|v| v.as_str())
192                        .ok_or_else(|| FraiseQLError::Validation {
193                            message: "orderBy item missing 'field' string".to_string(),
194                            path:    None,
195                        })?
196                        .to_string();
197                    let dir_str = obj.get("direction").and_then(|v| v.as_str()).unwrap_or("ASC");
198                    let direction = match dir_str.to_ascii_uppercase().as_str() {
199                        "ASC" => OrderDirection::Asc,
200                        "DESC" => OrderDirection::Desc,
201                        _ => {
202                            return Err(FraiseQLError::Validation {
203                                message: format!(
204                                    "orderBy direction '{dir_str}' must be ASC or DESC"
205                                ),
206                                path:    None,
207                            });
208                        },
209                    };
210                    Self::validate_field_name(&field)?;
211                    Ok(Self::new(field, direction))
212                })
213                .collect()
214        } else {
215            Err(FraiseQLError::Validation {
216                message: "orderBy must be an object or array".to_string(),
217                path:    None,
218            })
219        }
220    }
221}
222
223/// SQL projection hint for database-specific field projection optimization.
224///
225/// When a type has a large JSONB payload, the compiler can generate
226/// SQL that projects only the requested fields, reducing network payload
227/// and JSON deserialization overhead.
228#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
229pub struct SqlProjectionHint {
230    /// Database type — typed to prevent silent typos (e.g. `"postgresq"`) that
231    /// would cause adapters to silently ignore the hint.
232    pub database: DatabaseType,
233
234    /// The projection SQL template.
235    /// Example for PostgreSQL:
236    /// `jsonb_build_object('id', data->>'id', 'email', data->>'email')`
237    pub projection_template: String,
238
239    /// Estimated reduction in payload size (percentage 0-100).
240    pub estimated_reduction_percent: u32,
241}
242
243#[cfg(test)]
244mod tests {
245    #![allow(clippy::unwrap_used)] // Reason: test code, panics are acceptable
246
247    use super::*;
248
249    // ── storage_key ───────────────────────────────────────────────────────
250
251    #[test]
252    fn test_storage_key_camel_to_snake() {
253        let clause = OrderByClause::new("createdAt".into(), OrderDirection::Asc);
254        assert_eq!(clause.storage_key(), "created_at");
255    }
256
257    #[test]
258    fn test_storage_key_multi_word() {
259        let clause = OrderByClause::new("firstName".into(), OrderDirection::Desc);
260        assert_eq!(clause.storage_key(), "first_name");
261    }
262
263    #[test]
264    fn test_storage_key_already_snake() {
265        let clause = OrderByClause::new("id".into(), OrderDirection::Asc);
266        assert_eq!(clause.storage_key(), "id");
267    }
268
269    #[test]
270    fn test_storage_key_long_camel() {
271        let clause = OrderByClause::new("updatedAtTimestamp".into(), OrderDirection::Asc);
272        assert_eq!(clause.storage_key(), "updated_at_timestamp");
273    }
274
275    // ── OrderDirection::as_sql ────────────────────────────────────────────
276
277    #[test]
278    fn test_order_direction_as_sql() {
279        assert_eq!(OrderDirection::Asc.as_sql(), "ASC");
280        assert_eq!(OrderDirection::Desc.as_sql(), "DESC");
281    }
282
283    // ── validate_field_name ───────────────────────────────────────────────
284
285    #[test]
286    fn test_validate_field_name_accepts_valid() {
287        assert!(OrderByClause::validate_field_name("id").is_ok());
288        assert!(OrderByClause::validate_field_name("createdAt").is_ok());
289        assert!(OrderByClause::validate_field_name("_private").is_ok());
290        assert!(OrderByClause::validate_field_name("field123").is_ok());
291    }
292
293    #[test]
294    fn test_validate_field_name_rejects_injection() {
295        assert!(OrderByClause::validate_field_name("'; DROP TABLE users; --").is_err());
296        assert!(OrderByClause::validate_field_name("field name").is_err());
297        assert!(OrderByClause::validate_field_name("123start").is_err());
298        assert!(OrderByClause::validate_field_name("").is_err());
299    }
300}