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}