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}