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
367    /// * `is_list` - Whether the query returns a list
368    ///
369    /// # Returns
370    ///
371    /// GraphQL-compatible JSON response
372    ///
373    /// # Errors
374    ///
375    /// Returns error if projection fails.
376    pub fn project_results(&self, results: &[JsonbValue], is_list: bool) -> Result<JsonValue> {
377        if is_list {
378            // Project array of results
379            let projected: Result<Vec<JsonValue>> =
380                results.iter().map(|r| self.mapper.project(r)).collect();
381
382            Ok(JsonValue::Array(projected?))
383        } else {
384            // Project single result
385            if let Some(first) = results.first() {
386                self.mapper.project(first)
387            } else {
388                Ok(JsonValue::Null)
389            }
390        }
391    }
392
393    /// Wrap result in GraphQL data envelope.
394    ///
395    /// # Arguments
396    ///
397    /// * `result` - Projected result
398    /// * `query_name` - Query operation name
399    ///
400    /// # Returns
401    ///
402    /// GraphQL response with `{ "data": { "queryName": result } }` structure
403    #[must_use]
404    pub fn wrap_in_data_envelope(result: JsonValue, query_name: &str) -> JsonValue {
405        let mut data = Map::new();
406        data.insert(query_name.to_string(), result);
407
408        let mut response = Map::new();
409        response.insert("data".to_string(), JsonValue::Object(data));
410
411        JsonValue::Object(response)
412    }
413
414    /// Add __typename field to SQL-projected data.
415    ///
416    /// For data that has already been projected at the SQL level, we only need to add
417    /// the `__typename` field in Rust. This is much faster than projecting all fields
418    /// since the SQL already filtered to only requested fields.
419    ///
420    /// # Arguments
421    ///
422    /// * `projected_data` - JSONB data already projected by SQL
423    /// * `typename` - GraphQL type name to add
424    ///
425    /// # Returns
426    ///
427    /// New JSONB value with `__typename` field added
428    ///
429    /// # Example
430    ///
431    /// ```rust
432    /// # use fraiseql_core::runtime::ResultProjector;
433    /// # use fraiseql_core::db::types::JsonbValue;
434    /// # use serde_json::json;
435    /// let projector = ResultProjector::new(vec!["id".to_string(), "name".to_string()]);
436    /// // Database already returned only: { "id": "123", "name": "Alice" }
437    /// let result = projector.add_typename_only(
438    ///     &JsonbValue::new(json!({ "id": "123", "name": "Alice" })),
439    ///     "User"
440    /// ).unwrap();
441    ///
442    /// // Result: { "id": "123", "name": "Alice", "__typename": "User" }
443    /// assert_eq!(result["__typename"], "User");
444    /// ```
445    ///
446    /// # Errors
447    ///
448    /// Returns [`FraiseQLError::Validation`] if the projected data contains a
449    /// list element that is not a JSON object, making `__typename` injection impossible.
450    pub fn add_typename_only(
451        &self,
452        projected_data: &JsonbValue,
453        typename: &str,
454    ) -> Result<JsonValue> {
455        let value = projected_data.as_value();
456
457        match value {
458            JsonValue::Object(map) => {
459                let mut result = map.clone();
460                result.insert("__typename".to_string(), JsonValue::String(typename.to_string()));
461                Ok(JsonValue::Object(result))
462            },
463            JsonValue::Array(arr) => {
464                let updated: Result<Vec<JsonValue>> = arr
465                    .iter()
466                    .map(|item| {
467                        if let JsonValue::Object(obj) = item {
468                            let mut result = obj.clone();
469                            result.insert(
470                                "__typename".to_string(),
471                                JsonValue::String(typename.to_string()),
472                            );
473                            Ok(JsonValue::Object(result))
474                        } else {
475                            Ok(item.clone())
476                        }
477                    })
478                    .collect();
479                Ok(JsonValue::Array(updated?))
480            },
481            v => Ok(v.clone()),
482        }
483    }
484
485    /// Wrap error in GraphQL error envelope.
486    ///
487    /// # Arguments
488    ///
489    /// * `error` - Error to wrap
490    ///
491    /// # Returns
492    ///
493    /// GraphQL error response with `{ "errors": [...] }` structure
494    #[must_use]
495    pub fn wrap_error(error: &FraiseQLError) -> JsonValue {
496        let mut error_obj = Map::new();
497        error_obj.insert("message".to_string(), JsonValue::String(error.to_string()));
498
499        let mut response = Map::new();
500        response.insert("errors".to_string(), JsonValue::Array(vec![JsonValue::Object(error_obj)]));
501
502        JsonValue::Object(response)
503    }
504}
505
506/// Build `FieldMapping`s from a type definition's fields, mapping `camelCase`
507/// source keys (as stored in mutation metadata JSONB) to `snake_case` output keys
508/// (as defined in the GraphQL schema).
509///
510/// Recursively builds nested mappings for `Object` and `List(Object)` fields by
511/// looking up types in the compiled schema. This enables the same `ProjectionMapper`
512/// pipeline used for query results to handle mutation error metadata.
513///
514/// # Arguments
515///
516/// * `fields` — the type's field definitions
517/// * `schema` — compiled schema for resolving nested object types
518/// * `requested` — optional selection filter; when `Some`, only listed fields are included
519/// * `visited` — cycle guard to prevent infinite recursion on self-referencing types
520#[must_use]
521#[allow(clippy::implicit_hasher)] // Reason: internal API; no need for hasher generality
522pub fn build_field_mappings_from_type(
523    fields: &[FieldDefinition],
524    schema: &CompiledSchema,
525    requested: Option<&[String]>,
526    visited: &mut HashSet<String>,
527) -> Vec<FieldMapping> {
528    fields
529        .iter()
530        .filter(|f| requested.is_none_or(|r| r.iter().any(|name| name == f.name.as_str())))
531        .map(|field| {
532            let source = to_camel_case(field.name.as_str());
533            let output = field.name.to_string();
534
535            // Fallback: try snake_case key when camelCase is not found.
536            // Mutation metadata may use either convention depending on the backend.
537            let source_fallback = if source != output {
538                Some(output.clone())
539            } else {
540                None
541            };
542
543            // Resolve the innermost type (unwrap List wrapper if present)
544            let inner = field.field_type.inner_type().unwrap_or(&field.field_type);
545
546            if let Some(type_name) = inner.type_name() {
547                // Object/Enum/Interface reference — try to resolve in schema
548                if let Some(td) = schema.find_type(type_name) {
549                    if visited.insert(type_name.to_string()) {
550                        let nested =
551                            build_field_mappings_from_type(&td.fields, schema, None, visited);
552                        visited.remove(type_name);
553                        return FieldMapping {
554                            source,
555                            output,
556                            source_fallback,
557                            nested_typename: Some(type_name.to_string()),
558                            nested_fields: Some(nested),
559                        };
560                    }
561                    // Cycle detected — return without recursion
562                }
563            }
564
565            FieldMapping {
566                source,
567                output,
568                source_fallback,
569                nested_typename: None,
570                nested_fields: None,
571            }
572        })
573        .collect()
574}
575
576/// Convert a `snake_case` field name to `camelCase` for metadata key lookup.
577///
578/// Examples: `"last_activity_date"` → `"lastActivityDate"`,
579///            `"cascade_count"` → `"cascadeCount"`.
580fn to_camel_case(snake: &str) -> String {
581    let mut result = String::with_capacity(snake.len());
582    let mut capitalise_next = false;
583
584    for ch in snake.chars() {
585        if ch == '_' {
586            capitalise_next = true;
587        } else if capitalise_next {
588            result.push(ch.to_ascii_uppercase());
589            capitalise_next = false;
590        } else {
591            result.push(ch);
592        }
593    }
594
595    result
596}
597
598#[cfg(test)]
599mod tests {
600    #![allow(clippy::unwrap_used)] // Reason: test code, panics are acceptable
601
602    use serde_json::json;
603
604    use super::*;
605
606    #[test]
607    fn test_projection_mapper_new() {
608        let mapper = ProjectionMapper::new(vec!["id".to_string(), "name".to_string()]);
609        assert_eq!(mapper.fields.len(), 2);
610    }
611
612    #[test]
613    fn test_project_object() {
614        let mapper = ProjectionMapper::new(vec!["id".to_string(), "name".to_string()]);
615
616        let data = json!({
617            "id": "123",
618            "name": "Alice",
619            "email": "alice@example.com"
620        });
621
622        let jsonb = JsonbValue::new(data);
623        let result = mapper.project(&jsonb).unwrap();
624
625        assert_eq!(result, json!({ "id": "123", "name": "Alice" }));
626    }
627
628    #[test]
629    fn test_project_array() {
630        let mapper = ProjectionMapper::new(vec!["id".to_string()]);
631
632        let data = json!([
633            { "id": "1", "name": "Alice" },
634            { "id": "2", "name": "Bob" }
635        ]);
636
637        let jsonb = JsonbValue::new(data);
638        let result = mapper.project(&jsonb).unwrap();
639
640        assert_eq!(result, json!([{ "id": "1" }, { "id": "2" }]));
641    }
642
643    #[test]
644    fn test_result_projector_list() {
645        let projector = ResultProjector::new(vec!["id".to_string()]);
646
647        let data = json!({ "id": "1", "name": "Alice" });
648        let results = vec![JsonbValue::new(data)];
649        let result = projector.project_results(&results, true).unwrap();
650
651        assert_eq!(result, json!([{ "id": "1" }]));
652    }
653
654    #[test]
655    fn test_result_projector_single() {
656        let projector = ResultProjector::new(vec!["id".to_string()]);
657
658        let data = json!({ "id": "1", "name": "Alice" });
659        let results = vec![JsonbValue::new(data)];
660        let result = projector.project_results(&results, false).unwrap();
661
662        assert_eq!(result, json!({ "id": "1" }));
663    }
664
665    #[test]
666    fn test_wrap_in_data_envelope() {
667        let result = json!([{ "id": "1" }]);
668        let wrapped = ResultProjector::wrap_in_data_envelope(result, "users");
669
670        assert_eq!(wrapped, json!({ "data": { "users": [{ "id": "1" }] } }));
671    }
672
673    #[test]
674    fn test_wrap_error() {
675        let error = FraiseQLError::Validation {
676            message: "Invalid query".to_string(),
677            path:    None,
678        };
679
680        let wrapped = ResultProjector::wrap_error(&error);
681
682        assert!(wrapped.get("errors").is_some());
683        assert_eq!(wrapped.get("data"), None);
684    }
685
686    #[test]
687    fn test_add_typename_only_object() {
688        let projector = ResultProjector::new(vec!["id".to_string()]);
689
690        let data = json!({ "id": "123", "name": "Alice" });
691        let jsonb = JsonbValue::new(data);
692        let result = projector.add_typename_only(&jsonb, "User").unwrap();
693
694        assert_eq!(result, json!({ "id": "123", "name": "Alice", "__typename": "User" }));
695    }
696
697    #[test]
698    fn test_add_typename_only_array() {
699        let projector = ResultProjector::new(vec!["id".to_string()]);
700
701        let data = json!([
702            { "id": "1", "name": "Alice" },
703            { "id": "2", "name": "Bob" }
704        ]);
705        let jsonb = JsonbValue::new(data);
706        let result = projector.add_typename_only(&jsonb, "User").unwrap();
707
708        assert_eq!(
709            result,
710            json!([
711                { "id": "1", "name": "Alice", "__typename": "User" },
712                { "id": "2", "name": "Bob", "__typename": "User" }
713            ])
714        );
715    }
716
717    #[test]
718    fn test_add_typename_only_primitive() {
719        let projector = ResultProjector::new(vec![]);
720
721        let jsonb = JsonbValue::new(json!("string_value"));
722        let result = projector.add_typename_only(&jsonb, "String").unwrap();
723
724        // Primitive values are returned unchanged (cannot add __typename to string)
725        assert_eq!(result, json!("string_value"));
726    }
727
728    // ========================================================================
729    // Alias tests
730    // ========================================================================
731
732    #[test]
733    fn test_field_mapping_simple() {
734        let mapping = FieldMapping::simple("name");
735        assert_eq!(mapping.source, "name");
736        assert_eq!(mapping.output, "name");
737    }
738
739    #[test]
740    fn test_field_mapping_aliased() {
741        let mapping = FieldMapping::aliased("author", "writer");
742        assert_eq!(mapping.source, "author");
743        assert_eq!(mapping.output, "writer");
744    }
745
746    #[test]
747    fn test_project_with_alias() {
748        let mapper = ProjectionMapper::with_mappings(vec![
749            FieldMapping::simple("id"),
750            FieldMapping::aliased("author", "writer"),
751        ]);
752
753        let data = json!({
754            "id": "123",
755            "author": { "name": "Alice" },
756            "title": "Hello World"
757        });
758
759        let jsonb = JsonbValue::new(data);
760        let result = mapper.project(&jsonb).unwrap();
761
762        // "author" should be output as "writer"
763        assert_eq!(
764            result,
765            json!({
766                "id": "123",
767                "writer": { "name": "Alice" }
768            })
769        );
770    }
771
772    #[test]
773    fn test_project_with_typename() {
774        let mapper =
775            ProjectionMapper::new(vec!["id".to_string(), "name".to_string()]).with_typename("User");
776
777        let data = json!({
778            "id": "123",
779            "name": "Alice",
780            "email": "alice@example.com"
781        });
782
783        let jsonb = JsonbValue::new(data);
784        let result = mapper.project(&jsonb).unwrap();
785
786        assert_eq!(
787            result,
788            json!({
789                "__typename": "User",
790                "id": "123",
791                "name": "Alice"
792            })
793        );
794    }
795
796    #[test]
797    fn test_project_with_alias_and_typename() {
798        let mapper = ProjectionMapper::with_mappings(vec![
799            FieldMapping::simple("id"),
800            FieldMapping::aliased("author", "writer"),
801        ])
802        .with_typename("Post");
803
804        let data = json!({
805            "id": "post-1",
806            "author": { "name": "Alice" },
807            "title": "Hello"
808        });
809
810        let jsonb = JsonbValue::new(data);
811        let result = mapper.project(&jsonb).unwrap();
812
813        assert_eq!(
814            result,
815            json!({
816                "__typename": "Post",
817                "id": "post-1",
818                "writer": { "name": "Alice" }
819            })
820        );
821    }
822
823    #[test]
824    fn test_result_projector_with_typename() {
825        let projector =
826            ResultProjector::new(vec!["id".to_string(), "name".to_string()]).with_typename("User");
827
828        let data = json!({ "id": "1", "name": "Alice", "email": "alice@example.com" });
829        let results = vec![JsonbValue::new(data)];
830        let result = projector.project_results(&results, false).unwrap();
831
832        assert_eq!(
833            result,
834            json!({
835                "__typename": "User",
836                "id": "1",
837                "name": "Alice"
838            })
839        );
840    }
841
842    #[test]
843    fn test_result_projector_list_with_typename() {
844        let projector = ResultProjector::new(vec!["id".to_string()]).with_typename("User");
845
846        let results = vec![
847            JsonbValue::new(json!({ "id": "1", "name": "Alice" })),
848            JsonbValue::new(json!({ "id": "2", "name": "Bob" })),
849        ];
850        let result = projector.project_results(&results, true).unwrap();
851
852        assert_eq!(
853            result,
854            json!([
855                { "__typename": "User", "id": "1" },
856                { "__typename": "User", "id": "2" }
857            ])
858        );
859    }
860
861    #[test]
862    fn test_result_projector_with_mappings() {
863        let projector = ResultProjector::with_mappings(vec![
864            FieldMapping::simple("id"),
865            FieldMapping::aliased("full_name", "name"),
866        ]);
867
868        let data = json!({ "id": "1", "full_name": "Alice Smith", "email": "alice@example.com" });
869        let results = vec![JsonbValue::new(data)];
870        let result = projector.project_results(&results, false).unwrap();
871
872        // "full_name" should be output as "name"
873        assert_eq!(
874            result,
875            json!({
876                "id": "1",
877                "name": "Alice Smith"
878            })
879        );
880    }
881
882    // ========================================================================
883    // Nested typename tests
884    // ========================================================================
885
886    #[test]
887    fn test_nested_object_typename() {
888        let mapper = ProjectionMapper::with_mappings(vec![
889            FieldMapping::simple("id"),
890            FieldMapping::simple("title"),
891            FieldMapping::nested_object(
892                "author",
893                "User",
894                vec![FieldMapping::simple("id"), FieldMapping::simple("name")],
895            ),
896        ])
897        .with_typename("Post");
898
899        let data = json!({
900            "id": "post-1",
901            "title": "Hello World",
902            "author": {
903                "id": "user-1",
904                "name": "Alice",
905                "email": "alice@example.com"
906            }
907        });
908
909        let jsonb = JsonbValue::new(data);
910        let result = mapper.project(&jsonb).unwrap();
911
912        assert_eq!(
913            result,
914            json!({
915                "__typename": "Post",
916                "id": "post-1",
917                "title": "Hello World",
918                "author": {
919                    "__typename": "User",
920                    "id": "user-1",
921                    "name": "Alice"
922                }
923            })
924        );
925    }
926
927    #[test]
928    fn test_nested_array_typename() {
929        let mapper = ProjectionMapper::with_mappings(vec![
930            FieldMapping::simple("id"),
931            FieldMapping::simple("name"),
932            FieldMapping::nested_object(
933                "posts",
934                "Post",
935                vec![FieldMapping::simple("id"), FieldMapping::simple("title")],
936            ),
937        ])
938        .with_typename("User");
939
940        let data = json!({
941            "id": "user-1",
942            "name": "Alice",
943            "posts": [
944                { "id": "post-1", "title": "First Post", "views": 100 },
945                { "id": "post-2", "title": "Second Post", "views": 200 }
946            ]
947        });
948
949        let jsonb = JsonbValue::new(data);
950        let result = mapper.project(&jsonb).unwrap();
951
952        assert_eq!(
953            result,
954            json!({
955                "__typename": "User",
956                "id": "user-1",
957                "name": "Alice",
958                "posts": [
959                    { "__typename": "Post", "id": "post-1", "title": "First Post" },
960                    { "__typename": "Post", "id": "post-2", "title": "Second Post" }
961                ]
962            })
963        );
964    }
965
966    #[test]
967    fn test_deeply_nested_typename() {
968        // Post -> author (User) -> company (Company)
969        let mapper = ProjectionMapper::with_mappings(vec![
970            FieldMapping::simple("id"),
971            FieldMapping::nested_object(
972                "author",
973                "User",
974                vec![
975                    FieldMapping::simple("name"),
976                    FieldMapping::nested_object(
977                        "company",
978                        "Company",
979                        vec![FieldMapping::simple("name")],
980                    ),
981                ],
982            ),
983        ])
984        .with_typename("Post");
985
986        let data = json!({
987            "id": "post-1",
988            "author": {
989                "name": "Alice",
990                "company": {
991                    "name": "Acme Corp",
992                    "revenue": 1_000_000
993                }
994            }
995        });
996
997        let jsonb = JsonbValue::new(data);
998        let result = mapper.project(&jsonb).unwrap();
999
1000        assert_eq!(
1001            result,
1002            json!({
1003                "__typename": "Post",
1004                "id": "post-1",
1005                "author": {
1006                    "__typename": "User",
1007                    "name": "Alice",
1008                    "company": {
1009                        "__typename": "Company",
1010                        "name": "Acme Corp"
1011                    }
1012                }
1013            })
1014        );
1015    }
1016
1017    #[test]
1018    fn test_nested_object_with_alias_and_typename() {
1019        let mapper = ProjectionMapper::with_mappings(vec![
1020            FieldMapping::simple("id"),
1021            FieldMapping::nested_object_aliased(
1022                "author",
1023                "writer",
1024                "User",
1025                vec![FieldMapping::simple("id"), FieldMapping::simple("name")],
1026            ),
1027        ])
1028        .with_typename("Post");
1029
1030        let data = json!({
1031            "id": "post-1",
1032            "author": {
1033                "id": "user-1",
1034                "name": "Alice"
1035            }
1036        });
1037
1038        let jsonb = JsonbValue::new(data);
1039        let result = mapper.project(&jsonb).unwrap();
1040
1041        // "author" should be output as "writer" with typename
1042        assert_eq!(
1043            result,
1044            json!({
1045                "__typename": "Post",
1046                "id": "post-1",
1047                "writer": {
1048                    "__typename": "User",
1049                    "id": "user-1",
1050                    "name": "Alice"
1051                }
1052            })
1053        );
1054    }
1055
1056    // ========================================================================
1057    // Issue #27: Nested objects returned as JSON strings
1058    // ========================================================================
1059
1060    #[test]
1061    fn test_nested_object_as_json_string_is_re_parsed() {
1062        // Reproduces Issue #27: when the database extracts a nested JSONB field
1063        // using ->>'field' (text operator), it arrives as a JSON string rather
1064        // than a proper Object. The projector must re-parse it.
1065        let mapper = ProjectionMapper::with_mappings(vec![
1066            FieldMapping::simple("id"),
1067            FieldMapping::nested_object(
1068                "author",
1069                "User",
1070                vec![FieldMapping::simple("id"), FieldMapping::simple("name")],
1071            ),
1072        ])
1073        .with_typename("Post");
1074
1075        // "author" is a raw JSON string, not a parsed object
1076        let data = json!({
1077            "id": "post-1",
1078            "author": "{\"id\":\"user-2\",\"name\":\"Bob\"}"
1079        });
1080
1081        let jsonb = JsonbValue::new(data);
1082        let result = mapper.project(&jsonb).unwrap();
1083
1084        // author must be an object, not a string
1085        let author = result.get("author").expect("author field missing");
1086        assert!(author.is_object(), "author should be a JSON object, got: {:?}", author);
1087        assert_eq!(author.get("id"), Some(&json!("user-2")));
1088        assert_eq!(author.get("name"), Some(&json!("Bob")));
1089    }
1090
1091    // ========================================================================
1092    // configure_typename_from_selections tests
1093    // ========================================================================
1094
1095    fn make_selections_with_typename() -> Vec<FieldSelection> {
1096        vec![FieldSelection {
1097            name:          "users".to_string(),
1098            alias:         None,
1099            arguments:     vec![],
1100            nested_fields: vec![
1101                FieldSelection {
1102                    name:          "id".to_string(),
1103                    alias:         None,
1104                    arguments:     vec![],
1105                    nested_fields: vec![],
1106                    directives:    vec![],
1107                },
1108                FieldSelection {
1109                    name:          "__typename".to_string(),
1110                    alias:         None,
1111                    arguments:     vec![],
1112                    nested_fields: vec![],
1113                    directives:    vec![],
1114                },
1115            ],
1116            directives:    vec![],
1117        }]
1118    }
1119
1120    fn make_selections_without_typename() -> Vec<FieldSelection> {
1121        vec![FieldSelection {
1122            name:          "users".to_string(),
1123            alias:         None,
1124            arguments:     vec![],
1125            nested_fields: vec![FieldSelection {
1126                name:          "id".to_string(),
1127                alias:         None,
1128                arguments:     vec![],
1129                nested_fields: vec![],
1130                directives:    vec![],
1131            }],
1132            directives:    vec![],
1133        }]
1134    }
1135
1136    #[test]
1137    fn test_configure_typename_from_selections_present() {
1138        let projector = ResultProjector::new(vec!["id".to_string()])
1139            .configure_typename_from_selections(&make_selections_with_typename(), "User");
1140
1141        let data = json!({ "id": "1", "name": "Alice" });
1142        let results = vec![JsonbValue::new(data)];
1143        let result = projector.project_results(&results, false).unwrap();
1144
1145        assert_eq!(result, json!({ "__typename": "User", "id": "1" }));
1146    }
1147
1148    #[test]
1149    fn test_configure_typename_from_selections_absent() {
1150        let projector = ResultProjector::new(vec!["id".to_string()])
1151            .configure_typename_from_selections(&make_selections_without_typename(), "User");
1152
1153        let data = json!({ "id": "1", "name": "Alice" });
1154        let results = vec![JsonbValue::new(data)];
1155        let result = projector.project_results(&results, false).unwrap();
1156
1157        // No __typename because selection set didn't request it
1158        assert_eq!(result, json!({ "id": "1" }));
1159    }
1160
1161    #[test]
1162    fn test_configure_typename_from_selections_list() {
1163        let projector = ResultProjector::new(vec!["id".to_string()])
1164            .configure_typename_from_selections(&make_selections_with_typename(), "User");
1165
1166        let results = vec![
1167            JsonbValue::new(json!({ "id": "1" })),
1168            JsonbValue::new(json!({ "id": "2" })),
1169        ];
1170        let result = projector.project_results(&results, true).unwrap();
1171
1172        assert_eq!(
1173            result,
1174            json!([
1175                { "__typename": "User", "id": "1" },
1176                { "__typename": "User", "id": "2" }
1177            ])
1178        );
1179    }
1180
1181    #[test]
1182    fn test_configure_typename_empty_selections() {
1183        // Empty selections → no typename
1184        let projector = ResultProjector::new(vec!["id".to_string()])
1185            .configure_typename_from_selections(&[], "User");
1186
1187        let data = json!({ "id": "1" });
1188        let results = vec![JsonbValue::new(data)];
1189        let result = projector.project_results(&results, false).unwrap();
1190
1191        assert_eq!(result, json!({ "id": "1" }));
1192    }
1193
1194    // ========================================================================
1195    // Federation mode tests
1196    // ========================================================================
1197
1198    #[test]
1199    fn test_federation_mode_injects_typename() {
1200        let projector = ResultProjector::new(vec!["id".to_string()])
1201            .with_typename("User")
1202            .with_federation_mode(true);
1203
1204        let data = json!({ "id": "1", "name": "Alice" });
1205        let results = vec![JsonbValue::new(data)];
1206        let result = projector.project_results(&results, false).unwrap();
1207
1208        assert_eq!(result, json!({ "__typename": "User", "id": "1" }));
1209    }
1210
1211    #[test]
1212    fn test_federation_mode_flag_propagates() {
1213        let mapper = ProjectionMapper::new(vec!["id".to_string()]).with_federation_mode(true);
1214        assert!(mapper.federation_mode);
1215
1216        let mapper2 = ProjectionMapper::new(vec!["id".to_string()]).with_federation_mode(false);
1217        assert!(!mapper2.federation_mode);
1218    }
1219
1220    // ========================================================================
1221
1222    #[test]
1223    fn test_nested_without_specific_fields() {
1224        // When nested_fields is None, all source fields are copied
1225        let mapper = ProjectionMapper::with_mappings(vec![
1226            FieldMapping::simple("id"),
1227            FieldMapping::simple("author").with_nested_typename("User"),
1228        ])
1229        .with_typename("Post");
1230
1231        let data = json!({
1232            "id": "post-1",
1233            "author": {
1234                "id": "user-1",
1235                "name": "Alice",
1236                "email": "alice@example.com"
1237            }
1238        });
1239
1240        let jsonb = JsonbValue::new(data);
1241        let result = mapper.project(&jsonb).unwrap();
1242
1243        // All author fields should be copied, plus __typename
1244        assert_eq!(
1245            result,
1246            json!({
1247                "__typename": "Post",
1248                "id": "post-1",
1249                "author": {
1250                    "__typename": "User",
1251                    "id": "user-1",
1252                    "name": "Alice",
1253                    "email": "alice@example.com"
1254                }
1255            })
1256        );
1257    }
1258}