Skip to main content

fraiseql_core/runtime/
projection.rs

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