Skip to main content

fraiseql_core/runtime/
projection.rs

1//! Result projection - transforms JSONB database results to GraphQL responses.
2
3use std::collections::HashSet;
4
5use serde_json::{Map, Value as JsonValue};
6
7use crate::{
8    db::types::JsonbValue,
9    error::{FraiseQLError, Result},
10    graphql::FieldSelection,
11    schema::{CompiledSchema, FieldDefinition},
12};
13
14/// Field mapping for projection with alias support.
15#[derive(Debug, Clone)]
16pub struct FieldMapping {
17    /// JSONB key name (source).
18    pub source:          String,
19    /// Output key name (alias if different from source).
20    pub output:          String,
21    /// Fallback source key to try when the primary `source` is not found.
22    /// Used for mutation error metadata where the key may be either `camelCase`
23    /// or `snake_case` depending on the backend.
24    pub source_fallback: Option<String>,
25    /// For nested object fields, the typename to add.
26    /// This enables `__typename` to be added recursively to nested objects.
27    pub nested_typename: Option<String>,
28    /// Nested field mappings (for related objects).
29    pub nested_fields:   Option<Vec<FieldMapping>>,
30}
31
32impl FieldMapping {
33    /// Create a simple field mapping (no alias).
34    #[must_use]
35    pub fn simple(name: impl Into<String>) -> Self {
36        let name = name.into();
37        Self {
38            source:          name.clone(),
39            output:          name,
40            source_fallback: None,
41            nested_typename: None,
42            nested_fields:   None,
43        }
44    }
45
46    /// Create a field mapping with an alias.
47    #[must_use]
48    pub fn aliased(source: impl Into<String>, alias: impl Into<String>) -> Self {
49        Self {
50            source:          source.into(),
51            output:          alias.into(),
52            source_fallback: None,
53            nested_typename: None,
54            nested_fields:   None,
55        }
56    }
57
58    /// Create a field mapping for a nested object with its own typename.
59    ///
60    /// # Example
61    ///
62    /// ```rust
63    /// # use fraiseql_core::runtime::FieldMapping;
64    /// // For a Post with nested author (User type)
65    /// let mapping = FieldMapping::nested_object("author", "User", vec![
66    ///     FieldMapping::simple("id"),
67    ///     FieldMapping::simple("name"),
68    /// ]);
69    /// assert_eq!(mapping.source, "author");
70    /// ```
71    #[must_use]
72    pub fn nested_object(
73        name: impl Into<String>,
74        typename: impl Into<String>,
75        fields: Vec<FieldMapping>,
76    ) -> Self {
77        let name = name.into();
78        Self {
79            source:          name.clone(),
80            output:          name,
81            source_fallback: None,
82            nested_typename: Some(typename.into()),
83            nested_fields:   Some(fields),
84        }
85    }
86
87    /// Create an aliased nested object field.
88    #[must_use]
89    pub fn nested_object_aliased(
90        source: impl Into<String>,
91        alias: impl Into<String>,
92        typename: impl Into<String>,
93        fields: Vec<FieldMapping>,
94    ) -> Self {
95        Self {
96            source:          source.into(),
97            output:          alias.into(),
98            source_fallback: None,
99            nested_typename: Some(typename.into()),
100            nested_fields:   Some(fields),
101        }
102    }
103
104    /// Set the typename for a nested object field.
105    #[must_use]
106    pub fn with_nested_typename(mut self, typename: impl Into<String>) -> Self {
107        self.nested_typename = Some(typename.into());
108        self
109    }
110
111    /// Set nested field mappings.
112    #[must_use]
113    pub fn with_nested_fields(mut self, fields: Vec<FieldMapping>) -> Self {
114        self.nested_fields = Some(fields);
115        self
116    }
117}
118
119/// Projection mapper - maps JSONB fields to GraphQL selection set.
120#[derive(Debug, Clone)]
121pub struct ProjectionMapper {
122    /// Fields to project (with optional aliases).
123    pub fields:          Vec<FieldMapping>,
124    /// Optional `__typename` value to add to each object.
125    pub typename:        Option<String>,
126    /// When `true`, `__typename` is injected unconditionally regardless of selection set.
127    /// Used by federation `_entities` resolver where the gateway always expects `__typename`.
128    pub federation_mode: bool,
129}
130
131impl ProjectionMapper {
132    /// Create new projection mapper from field names (no aliases).
133    #[must_use]
134    pub fn new(fields: Vec<String>) -> Self {
135        Self {
136            fields:          fields.into_iter().map(FieldMapping::simple).collect(),
137            typename:        None,
138            federation_mode: false,
139        }
140    }
141
142    /// Create new projection mapper with field mappings (supports aliases).
143    #[must_use]
144    pub const fn with_mappings(fields: Vec<FieldMapping>) -> Self {
145        Self {
146            fields,
147            typename: None,
148            federation_mode: false,
149        }
150    }
151
152    /// Set `__typename` to include in projected objects.
153    #[must_use]
154    pub fn with_typename(mut self, typename: impl Into<String>) -> Self {
155        self.typename = Some(typename.into());
156        self
157    }
158
159    /// Enable federation mode: `__typename` is always injected regardless of selection set.
160    #[must_use]
161    pub const fn with_federation_mode(mut self, enabled: bool) -> Self {
162        self.federation_mode = enabled;
163        self
164    }
165
166    /// Project fields from JSONB value.
167    ///
168    /// # Arguments
169    ///
170    /// * `jsonb` - JSONB value from database
171    ///
172    /// # Returns
173    ///
174    /// Projected JSON value with only requested fields (and aliases applied)
175    ///
176    /// # Errors
177    ///
178    /// Returns error if projection fails.
179    pub fn project(&self, jsonb: &JsonbValue) -> Result<JsonValue> {
180        // Extract the inner serde_json::Value
181        let value = jsonb.as_value();
182
183        match value {
184            JsonValue::Object(map) => self.project_json_object(map),
185            JsonValue::Array(arr) => self.project_json_array(arr),
186            v => Ok(v.clone()),
187        }
188    }
189
190    /// Project object fields from JSON object.
191    ///
192    /// Maps source keys to output keys according to the configured `FieldMapping`s,
193    /// injects `__typename` when configured, and recursively projects nested objects
194    /// and arrays.
195    ///
196    /// # Errors
197    ///
198    /// Returns error if nested value projection fails.
199    pub fn project_json_object(
200        &self,
201        map: &serde_json::Map<String, JsonValue>,
202    ) -> Result<JsonValue> {
203        let mut result = Map::new();
204
205        // Add __typename first if configured (GraphQL convention)
206        if let Some(ref typename) = self.typename {
207            result.insert("__typename".to_string(), JsonValue::String(typename.clone()));
208        }
209
210        // Project fields with alias support and optional fallback key
211        for field in &self.fields {
212            let value = map
213                .get(&field.source)
214                .or_else(|| field.source_fallback.as_ref().and_then(|fb| map.get(fb)));
215            if let Some(value) = value {
216                let projected_value = self.project_nested_value(value, field)?;
217                result.insert(field.output.clone(), projected_value);
218            }
219        }
220
221        Ok(JsonValue::Object(result))
222    }
223
224    /// Project a nested value, adding typename if configured.
225    #[allow(clippy::self_only_used_in_recursion)] // Reason: &self required for method dispatch; recursive structure is intentional
226    fn project_nested_value(&self, value: &JsonValue, field: &FieldMapping) -> Result<JsonValue> {
227        match value {
228            JsonValue::Object(obj) => {
229                // If this field has nested typename, add it
230                if let Some(ref typename) = field.nested_typename {
231                    let mut result = Map::new();
232                    result.insert("__typename".to_string(), JsonValue::String(typename.clone()));
233
234                    // If we have nested field mappings, use them; otherwise copy all fields
235                    if let Some(ref nested_fields) = field.nested_fields {
236                        for nested_field in nested_fields {
237                            if let Some(nested_value) = obj.get(&nested_field.source) {
238                                let projected =
239                                    self.project_nested_value(nested_value, nested_field)?;
240                                result.insert(nested_field.output.clone(), projected);
241                            }
242                        }
243                    } else {
244                        // No specific field mappings - copy all fields from source
245                        for (k, v) in obj {
246                            result.insert(k.clone(), v.clone());
247                        }
248                    }
249                    Ok(JsonValue::Object(result))
250                } else {
251                    // No typename for this nested object - return as-is
252                    Ok(value.clone())
253                }
254            },
255            JsonValue::Array(arr) => {
256                // For arrays of objects, add typename to each element
257                if field.nested_typename.is_some() {
258                    let projected: Result<Vec<JsonValue>> =
259                        arr.iter().map(|item| self.project_nested_value(item, field)).collect();
260                    Ok(JsonValue::Array(projected?))
261                } else {
262                    Ok(value.clone())
263                }
264            },
265            _ => {
266                // If the value is a JSON string that encodes an object or array
267                // (which happens when the database uses ->>'field' text extraction
268                // instead of ->'field' JSONB extraction), attempt to re-parse it.
269                // Scalar strings (e.g. "hello") won't parse as Object/Array and
270                // are returned unchanged, so this is safe for all field types.
271                if let JsonValue::String(ref s) = *value {
272                    if let Ok(parsed @ (JsonValue::Object(_) | JsonValue::Array(_))) =
273                        serde_json::from_str::<JsonValue>(s)
274                    {
275                        return self.project_nested_value(&parsed, field);
276                    }
277                }
278                Ok(value.clone())
279            },
280        }
281    }
282
283    /// Project array elements from JSON array.
284    fn project_json_array(&self, arr: &[JsonValue]) -> Result<JsonValue> {
285        let projected: Vec<JsonValue> = arr
286            .iter()
287            .filter_map(|item| {
288                if let JsonValue::Object(obj) = item {
289                    self.project_json_object(obj).ok()
290                } else {
291                    Some(item.clone())
292                }
293            })
294            .collect();
295
296        Ok(JsonValue::Array(projected))
297    }
298}
299
300/// Result projector - high-level result transformation.
301pub struct ResultProjector {
302    mapper: ProjectionMapper,
303}
304
305impl ResultProjector {
306    /// Create new result projector from field names (no aliases).
307    #[must_use]
308    pub fn new(fields: Vec<String>) -> Self {
309        Self {
310            mapper: ProjectionMapper::new(fields),
311        }
312    }
313
314    /// Create new result projector with field mappings (supports aliases).
315    #[must_use]
316    pub const fn with_mappings(fields: Vec<FieldMapping>) -> Self {
317        Self {
318            mapper: ProjectionMapper::with_mappings(fields),
319        }
320    }
321
322    /// Set `__typename` to include in all projected objects.
323    ///
324    /// Per GraphQL spec §2.7, `__typename` returns the name of the object type.
325    /// This should be called when the client requests `__typename` in the selection set.
326    #[must_use]
327    pub fn with_typename(mut self, typename: impl Into<String>) -> Self {
328        self.mapper = self.mapper.with_typename(typename);
329        self
330    }
331
332    /// Configure typename injection from the query selection set.
333    ///
334    /// Inspects the root selection's nested fields for `__typename`. If found,
335    /// enables typename injection via [`with_typename`](Self::with_typename).
336    #[must_use]
337    pub fn configure_typename_from_selections(
338        self,
339        selections: &[FieldSelection],
340        entity_type: &str,
341    ) -> Self {
342        let wants_typename = selections
343            .first()
344            .is_some_and(|root| root.nested_fields.iter().any(|f| f.name == "__typename"));
345        if wants_typename {
346            self.with_typename(entity_type)
347        } else {
348            self
349        }
350    }
351
352    /// Enable federation mode: `__typename` is always injected regardless of selection set.
353    ///
354    /// Used by the `_entities` federation resolver where the gateway always expects
355    /// `__typename` in entity results.
356    #[must_use]
357    pub fn with_federation_mode(mut self, enabled: bool) -> Self {
358        self.mapper = self.mapper.with_federation_mode(enabled);
359        self
360    }
361
362    /// Project database results to GraphQL response.
363    ///
364    /// # Arguments
365    ///
366    /// * `results` - Database results as JSONB values (borrowed; not mutated).
367    /// * `is_list` - Whether the query returns a list.
368    ///
369    /// # Returns
370    ///
371    /// A freshly-allocated GraphQL-compatible JSON response. The projector
372    /// **never aliases the input slice**: each field of every `JsonbValue` is
373    /// cloned out into a new `serde_json::Value` tree (see F029 — ownership
374    /// contract is on `JsonbValue` itself).
375    ///
376    /// # Errors
377    ///
378    /// Returns error if projection fails.
379    pub fn project_results(&self, results: &[JsonbValue], is_list: bool) -> Result<JsonValue> {
380        if is_list {
381            // Project array of results
382            let projected: Result<Vec<JsonValue>> =
383                results.iter().map(|r| self.mapper.project(r)).collect();
384
385            Ok(JsonValue::Array(projected?))
386        } else {
387            // Project single result
388            if let Some(first) = results.first() {
389                self.mapper.project(first)
390            } else {
391                Ok(JsonValue::Null)
392            }
393        }
394    }
395
396    /// Wrap result in GraphQL data envelope.
397    ///
398    /// # Arguments
399    ///
400    /// * `result` - Projected result
401    /// * `query_name` - Query operation name
402    ///
403    /// # Returns
404    ///
405    /// GraphQL response with `{ "data": { "queryName": result } }` structure
406    #[must_use]
407    pub fn wrap_in_data_envelope(result: JsonValue, query_name: &str) -> JsonValue {
408        let mut data = Map::new();
409        data.insert(query_name.to_string(), result);
410
411        let mut response = Map::new();
412        response.insert("data".to_string(), JsonValue::Object(data));
413
414        JsonValue::Object(response)
415    }
416
417    /// Add __typename field to SQL-projected data.
418    ///
419    /// For data that has already been projected at the SQL level, we only need to add
420    /// the `__typename` field in Rust. This is much faster than projecting all fields
421    /// since the SQL already filtered to only requested fields.
422    ///
423    /// # Arguments
424    ///
425    /// * `projected_data` - JSONB data already projected by SQL
426    /// * `typename` - GraphQL type name to add
427    ///
428    /// # Returns
429    ///
430    /// New JSONB value with `__typename` field added
431    ///
432    /// # Example
433    ///
434    /// ```rust
435    /// # use fraiseql_core::runtime::ResultProjector;
436    /// # use fraiseql_core::db::types::JsonbValue;
437    /// # use serde_json::json;
438    /// let projector = ResultProjector::new(vec!["id".to_string(), "name".to_string()]);
439    /// // Database already returned only: { "id": "123", "name": "Alice" }
440    /// let result = projector.add_typename_only(
441    ///     &JsonbValue::new(json!({ "id": "123", "name": "Alice" })),
442    ///     "User"
443    /// ).unwrap();
444    ///
445    /// // Result: { "id": "123", "name": "Alice", "__typename": "User" }
446    /// assert_eq!(result["__typename"], "User");
447    /// ```
448    ///
449    /// # Errors
450    ///
451    /// Returns [`FraiseQLError::Validation`] if the projected data contains a
452    /// list element that is not a JSON object, making `__typename` injection impossible.
453    pub fn add_typename_only(
454        &self,
455        projected_data: &JsonbValue,
456        typename: &str,
457    ) -> Result<JsonValue> {
458        let value = projected_data.as_value();
459
460        match value {
461            JsonValue::Object(map) => {
462                let mut result = map.clone();
463                result.insert("__typename".to_string(), JsonValue::String(typename.to_string()));
464                Ok(JsonValue::Object(result))
465            },
466            JsonValue::Array(arr) => {
467                let updated: Result<Vec<JsonValue>> = arr
468                    .iter()
469                    .map(|item| {
470                        if let JsonValue::Object(obj) = item {
471                            let mut result = obj.clone();
472                            result.insert(
473                                "__typename".to_string(),
474                                JsonValue::String(typename.to_string()),
475                            );
476                            Ok(JsonValue::Object(result))
477                        } else {
478                            Ok(item.clone())
479                        }
480                    })
481                    .collect();
482                Ok(JsonValue::Array(updated?))
483            },
484            v => Ok(v.clone()),
485        }
486    }
487
488    /// Wrap error in GraphQL error envelope.
489    ///
490    /// # Arguments
491    ///
492    /// * `error` - Error to wrap
493    ///
494    /// # Returns
495    ///
496    /// GraphQL error response with `{ "errors": [...] }` structure
497    #[must_use]
498    pub fn wrap_error(error: &FraiseQLError) -> JsonValue {
499        let mut error_obj = Map::new();
500        error_obj.insert("message".to_string(), JsonValue::String(error.to_string()));
501
502        let mut response = Map::new();
503        response.insert("errors".to_string(), JsonValue::Array(vec![JsonValue::Object(error_obj)]));
504
505        JsonValue::Object(response)
506    }
507}
508
509/// Build `FieldMapping`s from a type definition's fields, mapping `camelCase`
510/// source keys (as stored in mutation metadata JSONB) to `snake_case` output keys
511/// (as defined in the GraphQL schema).
512///
513/// Recursively builds nested mappings for `Object` and `List(Object)` fields by
514/// looking up types in the compiled schema. This enables the same `ProjectionMapper`
515/// pipeline used for query results to handle mutation error metadata.
516///
517/// # Arguments
518///
519/// * `fields` — the type's field definitions
520/// * `schema` — compiled schema for resolving nested object types
521/// * `requested` — optional selection filter; when `Some`, only listed fields are included
522/// * `visited` — cycle guard to prevent infinite recursion on self-referencing types
523#[must_use]
524#[allow(clippy::implicit_hasher)] // Reason: internal API; no need for hasher generality
525pub fn build_field_mappings_from_type(
526    fields: &[FieldDefinition],
527    schema: &CompiledSchema,
528    requested: Option<&[String]>,
529    visited: &mut HashSet<String>,
530) -> Vec<FieldMapping> {
531    fields
532        .iter()
533        .filter(|f| requested.is_none_or(|r| r.iter().any(|name| name == f.name.as_str())))
534        .map(|field| {
535            let source = to_camel_case(field.name.as_str());
536            let output = field.name.to_string();
537
538            // Fallback: try snake_case key when camelCase is not found.
539            // Mutation metadata may use either convention depending on the backend.
540            let source_fallback = if source != output {
541                Some(output.clone())
542            } else {
543                None
544            };
545
546            // Resolve the innermost type (unwrap List wrapper if present)
547            let inner = field.field_type.inner_type().unwrap_or(&field.field_type);
548
549            if let Some(type_name) = inner.type_name() {
550                // Object/Enum/Interface reference — try to resolve in schema
551                if let Some(td) = schema.find_type(type_name) {
552                    if visited.insert(type_name.to_string()) {
553                        let nested =
554                            build_field_mappings_from_type(&td.fields, schema, None, visited);
555                        visited.remove(type_name);
556                        return FieldMapping {
557                            source,
558                            output,
559                            source_fallback,
560                            nested_typename: Some(type_name.to_string()),
561                            nested_fields: Some(nested),
562                        };
563                    }
564                    // Cycle detected — return without recursion
565                }
566            }
567
568            FieldMapping {
569                source,
570                output,
571                source_fallback,
572                nested_typename: None,
573                nested_fields: None,
574            }
575        })
576        .collect()
577}
578
579/// Convert a `snake_case` field name to `camelCase` for metadata key lookup.
580///
581/// Examples: `"last_activity_date"` → `"lastActivityDate"`,
582///            `"cascade_count"` → `"cascadeCount"`.
583fn to_camel_case(snake: &str) -> String {
584    let mut result = String::with_capacity(snake.len());
585    let mut capitalise_next = false;
586
587    for ch in snake.chars() {
588        if ch == '_' {
589            capitalise_next = true;
590        } else if capitalise_next {
591            result.push(ch.to_ascii_uppercase());
592            capitalise_next = false;
593        } else {
594            result.push(ch);
595        }
596    }
597
598    result
599}