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};
9
10/// Field mapping for projection with alias support.
11#[derive(Debug, Clone)]
12pub struct FieldMapping {
13    /// JSONB key name (source).
14    pub source:          String,
15    /// Output key name (alias if different from source).
16    pub output:          String,
17    /// For nested object fields, the typename to add.
18    /// This enables `__typename` to be added recursively to nested objects.
19    pub nested_typename: Option<String>,
20    /// Nested field mappings (for related objects).
21    pub nested_fields:   Option<Vec<FieldMapping>>,
22}
23
24impl FieldMapping {
25    /// Create a simple field mapping (no alias).
26    #[must_use]
27    pub fn simple(name: impl Into<String>) -> Self {
28        let name = name.into();
29        Self {
30            source:          name.clone(),
31            output:          name,
32            nested_typename: None,
33            nested_fields:   None,
34        }
35    }
36
37    /// Create a field mapping with an alias.
38    #[must_use]
39    pub fn aliased(source: impl Into<String>, alias: impl Into<String>) -> Self {
40        Self {
41            source:          source.into(),
42            output:          alias.into(),
43            nested_typename: None,
44            nested_fields:   None,
45        }
46    }
47
48    /// Create a field mapping for a nested object with its own typename.
49    ///
50    /// # Example
51    ///
52    /// ```rust
53    /// # use fraiseql_core::runtime::FieldMapping;
54    /// // For a Post with nested author (User type)
55    /// let mapping = FieldMapping::nested_object("author", "User", vec![
56    ///     FieldMapping::simple("id"),
57    ///     FieldMapping::simple("name"),
58    /// ]);
59    /// assert_eq!(mapping.source, "author");
60    /// ```
61    #[must_use]
62    pub fn nested_object(
63        name: impl Into<String>,
64        typename: impl Into<String>,
65        fields: Vec<FieldMapping>,
66    ) -> Self {
67        let name = name.into();
68        Self {
69            source:          name.clone(),
70            output:          name,
71            nested_typename: Some(typename.into()),
72            nested_fields:   Some(fields),
73        }
74    }
75
76    /// Create an aliased nested object field.
77    #[must_use]
78    pub fn nested_object_aliased(
79        source: impl Into<String>,
80        alias: impl Into<String>,
81        typename: impl Into<String>,
82        fields: Vec<FieldMapping>,
83    ) -> Self {
84        Self {
85            source:          source.into(),
86            output:          alias.into(),
87            nested_typename: Some(typename.into()),
88            nested_fields:   Some(fields),
89        }
90    }
91
92    /// Set the typename for a nested object field.
93    #[must_use]
94    pub fn with_nested_typename(mut self, typename: impl Into<String>) -> Self {
95        self.nested_typename = Some(typename.into());
96        self
97    }
98
99    /// Set nested field mappings.
100    #[must_use]
101    pub fn with_nested_fields(mut self, fields: Vec<FieldMapping>) -> Self {
102        self.nested_fields = Some(fields);
103        self
104    }
105}
106
107/// Projection mapper - maps JSONB fields to GraphQL selection set.
108#[derive(Debug, Clone)]
109pub struct ProjectionMapper {
110    /// Fields to project (with optional aliases).
111    pub fields:   Vec<FieldMapping>,
112    /// Optional `__typename` value to add to each object.
113    pub typename: Option<String>,
114}
115
116impl ProjectionMapper {
117    /// Create new projection mapper from field names (no aliases).
118    #[must_use]
119    pub fn new(fields: Vec<String>) -> Self {
120        Self {
121            fields:   fields.into_iter().map(FieldMapping::simple).collect(),
122            typename: None,
123        }
124    }
125
126    /// Create new projection mapper with field mappings (supports aliases).
127    #[must_use]
128    pub const fn with_mappings(fields: Vec<FieldMapping>) -> Self {
129        Self {
130            fields,
131            typename: None,
132        }
133    }
134
135    /// Set `__typename` to include in projected objects.
136    #[must_use]
137    pub fn with_typename(mut self, typename: impl Into<String>) -> Self {
138        self.typename = Some(typename.into());
139        self
140    }
141
142    /// Project fields from JSONB value.
143    ///
144    /// # Arguments
145    ///
146    /// * `jsonb` - JSONB value from database
147    ///
148    /// # Returns
149    ///
150    /// Projected JSON value with only requested fields (and aliases applied)
151    ///
152    /// # Errors
153    ///
154    /// Returns error if projection fails.
155    pub fn project(&self, jsonb: &JsonbValue) -> Result<JsonValue> {
156        // Extract the inner serde_json::Value
157        let value = jsonb.as_value();
158
159        match value {
160            JsonValue::Object(map) => self.project_json_object(map),
161            JsonValue::Array(arr) => self.project_json_array(arr),
162            v => Ok(v.clone()),
163        }
164    }
165
166    /// Project object fields from JSON object.
167    fn project_json_object(&self, map: &serde_json::Map<String, JsonValue>) -> Result<JsonValue> {
168        let mut result = Map::new();
169
170        // Add __typename first if configured (GraphQL convention)
171        if let Some(ref typename) = self.typename {
172            result.insert("__typename".to_string(), JsonValue::String(typename.clone()));
173        }
174
175        // Project fields with alias support
176        for field in &self.fields {
177            if let Some(value) = map.get(&field.source) {
178                // Handle nested objects with their own typename
179                let projected_value = self.project_nested_value(value, field)?;
180                result.insert(field.output.clone(), projected_value);
181            }
182        }
183
184        Ok(JsonValue::Object(result))
185    }
186
187    /// Project a nested value, adding typename if configured.
188    #[allow(clippy::self_only_used_in_recursion)] // Reason: &self required for method dispatch; recursive structure is intentional
189    fn project_nested_value(&self, value: &JsonValue, field: &FieldMapping) -> Result<JsonValue> {
190        match value {
191            JsonValue::Object(obj) => {
192                // If this field has nested typename, add it
193                if let Some(ref typename) = field.nested_typename {
194                    let mut result = Map::new();
195                    result.insert("__typename".to_string(), JsonValue::String(typename.clone()));
196
197                    // If we have nested field mappings, use them; otherwise copy all fields
198                    if let Some(ref nested_fields) = field.nested_fields {
199                        for nested_field in nested_fields {
200                            if let Some(nested_value) = obj.get(&nested_field.source) {
201                                let projected =
202                                    self.project_nested_value(nested_value, nested_field)?;
203                                result.insert(nested_field.output.clone(), projected);
204                            }
205                        }
206                    } else {
207                        // No specific field mappings - copy all fields from source
208                        for (k, v) in obj {
209                            result.insert(k.clone(), v.clone());
210                        }
211                    }
212                    Ok(JsonValue::Object(result))
213                } else {
214                    // No typename for this nested object - return as-is
215                    Ok(value.clone())
216                }
217            },
218            JsonValue::Array(arr) => {
219                // For arrays of objects, add typename to each element
220                if field.nested_typename.is_some() {
221                    let projected: Result<Vec<JsonValue>> =
222                        arr.iter().map(|item| self.project_nested_value(item, field)).collect();
223                    Ok(JsonValue::Array(projected?))
224                } else {
225                    Ok(value.clone())
226                }
227            },
228            _ => {
229                // If the value is a JSON string that encodes an object or array
230                // (which happens when the database uses ->>'field' text extraction
231                // instead of ->'field' JSONB extraction), attempt to re-parse it.
232                // Scalar strings (e.g. "hello") won't parse as Object/Array and
233                // are returned unchanged, so this is safe for all field types.
234                if let JsonValue::String(ref s) = *value {
235                    if let Ok(parsed @ (JsonValue::Object(_) | JsonValue::Array(_))) =
236                        serde_json::from_str::<JsonValue>(s)
237                    {
238                        return self.project_nested_value(&parsed, field);
239                    }
240                }
241                Ok(value.clone())
242            },
243        }
244    }
245
246    /// Project array elements from JSON array.
247    fn project_json_array(&self, arr: &[JsonValue]) -> Result<JsonValue> {
248        let projected: Vec<JsonValue> = arr
249            .iter()
250            .filter_map(|item| {
251                if let JsonValue::Object(obj) = item {
252                    self.project_json_object(obj).ok()
253                } else {
254                    Some(item.clone())
255                }
256            })
257            .collect();
258
259        Ok(JsonValue::Array(projected))
260    }
261}
262
263/// Result projector - high-level result transformation.
264pub struct ResultProjector {
265    mapper: ProjectionMapper,
266}
267
268impl ResultProjector {
269    /// Create new result projector from field names (no aliases).
270    #[must_use]
271    pub fn new(fields: Vec<String>) -> Self {
272        Self {
273            mapper: ProjectionMapper::new(fields),
274        }
275    }
276
277    /// Create new result projector with field mappings (supports aliases).
278    #[must_use]
279    pub const fn with_mappings(fields: Vec<FieldMapping>) -> Self {
280        Self {
281            mapper: ProjectionMapper::with_mappings(fields),
282        }
283    }
284
285    /// Set `__typename` to include in all projected objects.
286    ///
287    /// Per GraphQL spec ยง2.7, `__typename` returns the name of the object type.
288    /// This should be called when the client requests `__typename` in the selection set.
289    #[must_use]
290    pub fn with_typename(mut self, typename: impl Into<String>) -> Self {
291        self.mapper = self.mapper.with_typename(typename);
292        self
293    }
294
295    /// Project database results to GraphQL response.
296    ///
297    /// # Arguments
298    ///
299    /// * `results` - Database results as JSONB values
300    /// * `is_list` - Whether the query returns a list
301    ///
302    /// # Returns
303    ///
304    /// GraphQL-compatible JSON response
305    ///
306    /// # Errors
307    ///
308    /// Returns error if projection fails.
309    pub fn project_results(&self, results: &[JsonbValue], is_list: bool) -> Result<JsonValue> {
310        if is_list {
311            // Project array of results
312            let projected: Result<Vec<JsonValue>> =
313                results.iter().map(|r| self.mapper.project(r)).collect();
314
315            Ok(JsonValue::Array(projected?))
316        } else {
317            // Project single result
318            if let Some(first) = results.first() {
319                self.mapper.project(first)
320            } else {
321                Ok(JsonValue::Null)
322            }
323        }
324    }
325
326    /// Wrap result in GraphQL data envelope.
327    ///
328    /// # Arguments
329    ///
330    /// * `result` - Projected result
331    /// * `query_name` - Query operation name
332    ///
333    /// # Returns
334    ///
335    /// GraphQL response with `{ "data": { "queryName": result } }` structure
336    #[must_use]
337    pub fn wrap_in_data_envelope(result: JsonValue, query_name: &str) -> JsonValue {
338        let mut data = Map::new();
339        data.insert(query_name.to_string(), result);
340
341        let mut response = Map::new();
342        response.insert("data".to_string(), JsonValue::Object(data));
343
344        JsonValue::Object(response)
345    }
346
347    /// Add __typename field to SQL-projected data.
348    ///
349    /// For data that has already been projected at the SQL level, we only need to add
350    /// the `__typename` field in Rust. This is much faster than projecting all fields
351    /// since the SQL already filtered to only requested fields.
352    ///
353    /// # Arguments
354    ///
355    /// * `projected_data` - JSONB data already projected by SQL
356    /// * `typename` - GraphQL type name to add
357    ///
358    /// # Returns
359    ///
360    /// New JSONB value with `__typename` field added
361    ///
362    /// # Example
363    ///
364    /// ```rust
365    /// # use fraiseql_core::runtime::ResultProjector;
366    /// # use fraiseql_core::db::types::JsonbValue;
367    /// # use serde_json::json;
368    /// let projector = ResultProjector::new(vec!["id".to_string(), "name".to_string()]);
369    /// // Database already returned only: { "id": "123", "name": "Alice" }
370    /// let result = projector.add_typename_only(
371    ///     &JsonbValue::new(json!({ "id": "123", "name": "Alice" })),
372    ///     "User"
373    /// ).unwrap();
374    ///
375    /// // Result: { "id": "123", "name": "Alice", "__typename": "User" }
376    /// assert_eq!(result["__typename"], "User");
377    /// ```
378    ///
379    /// # Errors
380    ///
381    /// Returns [`FraiseQLError::Validation`] if the projected data contains a
382    /// list element that is not a JSON object, making `__typename` injection impossible.
383    pub fn add_typename_only(
384        &self,
385        projected_data: &JsonbValue,
386        typename: &str,
387    ) -> Result<JsonValue> {
388        let value = projected_data.as_value();
389
390        match value {
391            JsonValue::Object(map) => {
392                let mut result = map.clone();
393                result.insert("__typename".to_string(), JsonValue::String(typename.to_string()));
394                Ok(JsonValue::Object(result))
395            },
396            JsonValue::Array(arr) => {
397                let updated: Result<Vec<JsonValue>> = arr
398                    .iter()
399                    .map(|item| {
400                        if let JsonValue::Object(obj) = item {
401                            let mut result = obj.clone();
402                            result.insert(
403                                "__typename".to_string(),
404                                JsonValue::String(typename.to_string()),
405                            );
406                            Ok(JsonValue::Object(result))
407                        } else {
408                            Ok(item.clone())
409                        }
410                    })
411                    .collect();
412                Ok(JsonValue::Array(updated?))
413            },
414            v => Ok(v.clone()),
415        }
416    }
417
418    /// Wrap error in GraphQL error envelope.
419    ///
420    /// # Arguments
421    ///
422    /// * `error` - Error to wrap
423    ///
424    /// # Returns
425    ///
426    /// GraphQL error response with `{ "errors": [...] }` structure
427    #[must_use]
428    pub fn wrap_error(error: &FraiseQLError) -> JsonValue {
429        let mut error_obj = Map::new();
430        error_obj.insert("message".to_string(), JsonValue::String(error.to_string()));
431
432        let mut response = Map::new();
433        response.insert("errors".to_string(), JsonValue::Array(vec![JsonValue::Object(error_obj)]));
434
435        JsonValue::Object(response)
436    }
437}
438
439#[cfg(test)]
440mod tests {
441    #![allow(clippy::unwrap_used)] // Reason: test code, panics are acceptable
442
443    use serde_json::json;
444
445    use super::*;
446
447    #[test]
448    fn test_projection_mapper_new() {
449        let mapper = ProjectionMapper::new(vec!["id".to_string(), "name".to_string()]);
450        assert_eq!(mapper.fields.len(), 2);
451    }
452
453    #[test]
454    fn test_project_object() {
455        let mapper = ProjectionMapper::new(vec!["id".to_string(), "name".to_string()]);
456
457        let data = json!({
458            "id": "123",
459            "name": "Alice",
460            "email": "alice@example.com"
461        });
462
463        let jsonb = JsonbValue::new(data);
464        let result = mapper.project(&jsonb).unwrap();
465
466        assert_eq!(result, json!({ "id": "123", "name": "Alice" }));
467    }
468
469    #[test]
470    fn test_project_array() {
471        let mapper = ProjectionMapper::new(vec!["id".to_string()]);
472
473        let data = json!([
474            { "id": "1", "name": "Alice" },
475            { "id": "2", "name": "Bob" }
476        ]);
477
478        let jsonb = JsonbValue::new(data);
479        let result = mapper.project(&jsonb).unwrap();
480
481        assert_eq!(result, json!([{ "id": "1" }, { "id": "2" }]));
482    }
483
484    #[test]
485    fn test_result_projector_list() {
486        let projector = ResultProjector::new(vec!["id".to_string()]);
487
488        let data = json!({ "id": "1", "name": "Alice" });
489        let results = vec![JsonbValue::new(data)];
490        let result = projector.project_results(&results, true).unwrap();
491
492        assert_eq!(result, json!([{ "id": "1" }]));
493    }
494
495    #[test]
496    fn test_result_projector_single() {
497        let projector = ResultProjector::new(vec!["id".to_string()]);
498
499        let data = json!({ "id": "1", "name": "Alice" });
500        let results = vec![JsonbValue::new(data)];
501        let result = projector.project_results(&results, false).unwrap();
502
503        assert_eq!(result, json!({ "id": "1" }));
504    }
505
506    #[test]
507    fn test_wrap_in_data_envelope() {
508        let result = json!([{ "id": "1" }]);
509        let wrapped = ResultProjector::wrap_in_data_envelope(result, "users");
510
511        assert_eq!(wrapped, json!({ "data": { "users": [{ "id": "1" }] } }));
512    }
513
514    #[test]
515    fn test_wrap_error() {
516        let error = FraiseQLError::Validation {
517            message: "Invalid query".to_string(),
518            path:    None,
519        };
520
521        let wrapped = ResultProjector::wrap_error(&error);
522
523        assert!(wrapped.get("errors").is_some());
524        assert_eq!(wrapped.get("data"), None);
525    }
526
527    #[test]
528    fn test_add_typename_only_object() {
529        let projector = ResultProjector::new(vec!["id".to_string()]);
530
531        let data = json!({ "id": "123", "name": "Alice" });
532        let jsonb = JsonbValue::new(data);
533        let result = projector.add_typename_only(&jsonb, "User").unwrap();
534
535        assert_eq!(result, json!({ "id": "123", "name": "Alice", "__typename": "User" }));
536    }
537
538    #[test]
539    fn test_add_typename_only_array() {
540        let projector = ResultProjector::new(vec!["id".to_string()]);
541
542        let data = json!([
543            { "id": "1", "name": "Alice" },
544            { "id": "2", "name": "Bob" }
545        ]);
546        let jsonb = JsonbValue::new(data);
547        let result = projector.add_typename_only(&jsonb, "User").unwrap();
548
549        assert_eq!(
550            result,
551            json!([
552                { "id": "1", "name": "Alice", "__typename": "User" },
553                { "id": "2", "name": "Bob", "__typename": "User" }
554            ])
555        );
556    }
557
558    #[test]
559    fn test_add_typename_only_primitive() {
560        let projector = ResultProjector::new(vec![]);
561
562        let jsonb = JsonbValue::new(json!("string_value"));
563        let result = projector.add_typename_only(&jsonb, "String").unwrap();
564
565        // Primitive values are returned unchanged (cannot add __typename to string)
566        assert_eq!(result, json!("string_value"));
567    }
568
569    // ========================================================================
570    // Alias tests
571    // ========================================================================
572
573    #[test]
574    fn test_field_mapping_simple() {
575        let mapping = FieldMapping::simple("name");
576        assert_eq!(mapping.source, "name");
577        assert_eq!(mapping.output, "name");
578    }
579
580    #[test]
581    fn test_field_mapping_aliased() {
582        let mapping = FieldMapping::aliased("author", "writer");
583        assert_eq!(mapping.source, "author");
584        assert_eq!(mapping.output, "writer");
585    }
586
587    #[test]
588    fn test_project_with_alias() {
589        let mapper = ProjectionMapper::with_mappings(vec![
590            FieldMapping::simple("id"),
591            FieldMapping::aliased("author", "writer"),
592        ]);
593
594        let data = json!({
595            "id": "123",
596            "author": { "name": "Alice" },
597            "title": "Hello World"
598        });
599
600        let jsonb = JsonbValue::new(data);
601        let result = mapper.project(&jsonb).unwrap();
602
603        // "author" should be output as "writer"
604        assert_eq!(
605            result,
606            json!({
607                "id": "123",
608                "writer": { "name": "Alice" }
609            })
610        );
611    }
612
613    #[test]
614    fn test_project_with_typename() {
615        let mapper =
616            ProjectionMapper::new(vec!["id".to_string(), "name".to_string()]).with_typename("User");
617
618        let data = json!({
619            "id": "123",
620            "name": "Alice",
621            "email": "alice@example.com"
622        });
623
624        let jsonb = JsonbValue::new(data);
625        let result = mapper.project(&jsonb).unwrap();
626
627        assert_eq!(
628            result,
629            json!({
630                "__typename": "User",
631                "id": "123",
632                "name": "Alice"
633            })
634        );
635    }
636
637    #[test]
638    fn test_project_with_alias_and_typename() {
639        let mapper = ProjectionMapper::with_mappings(vec![
640            FieldMapping::simple("id"),
641            FieldMapping::aliased("author", "writer"),
642        ])
643        .with_typename("Post");
644
645        let data = json!({
646            "id": "post-1",
647            "author": { "name": "Alice" },
648            "title": "Hello"
649        });
650
651        let jsonb = JsonbValue::new(data);
652        let result = mapper.project(&jsonb).unwrap();
653
654        assert_eq!(
655            result,
656            json!({
657                "__typename": "Post",
658                "id": "post-1",
659                "writer": { "name": "Alice" }
660            })
661        );
662    }
663
664    #[test]
665    fn test_result_projector_with_typename() {
666        let projector =
667            ResultProjector::new(vec!["id".to_string(), "name".to_string()]).with_typename("User");
668
669        let data = json!({ "id": "1", "name": "Alice", "email": "alice@example.com" });
670        let results = vec![JsonbValue::new(data)];
671        let result = projector.project_results(&results, false).unwrap();
672
673        assert_eq!(
674            result,
675            json!({
676                "__typename": "User",
677                "id": "1",
678                "name": "Alice"
679            })
680        );
681    }
682
683    #[test]
684    fn test_result_projector_list_with_typename() {
685        let projector = ResultProjector::new(vec!["id".to_string()]).with_typename("User");
686
687        let results = vec![
688            JsonbValue::new(json!({ "id": "1", "name": "Alice" })),
689            JsonbValue::new(json!({ "id": "2", "name": "Bob" })),
690        ];
691        let result = projector.project_results(&results, true).unwrap();
692
693        assert_eq!(
694            result,
695            json!([
696                { "__typename": "User", "id": "1" },
697                { "__typename": "User", "id": "2" }
698            ])
699        );
700    }
701
702    #[test]
703    fn test_result_projector_with_mappings() {
704        let projector = ResultProjector::with_mappings(vec![
705            FieldMapping::simple("id"),
706            FieldMapping::aliased("full_name", "name"),
707        ]);
708
709        let data = json!({ "id": "1", "full_name": "Alice Smith", "email": "alice@example.com" });
710        let results = vec![JsonbValue::new(data)];
711        let result = projector.project_results(&results, false).unwrap();
712
713        // "full_name" should be output as "name"
714        assert_eq!(
715            result,
716            json!({
717                "id": "1",
718                "name": "Alice Smith"
719            })
720        );
721    }
722
723    // ========================================================================
724    // Nested typename tests
725    // ========================================================================
726
727    #[test]
728    fn test_nested_object_typename() {
729        let mapper = ProjectionMapper::with_mappings(vec![
730            FieldMapping::simple("id"),
731            FieldMapping::simple("title"),
732            FieldMapping::nested_object(
733                "author",
734                "User",
735                vec![FieldMapping::simple("id"), FieldMapping::simple("name")],
736            ),
737        ])
738        .with_typename("Post");
739
740        let data = json!({
741            "id": "post-1",
742            "title": "Hello World",
743            "author": {
744                "id": "user-1",
745                "name": "Alice",
746                "email": "alice@example.com"
747            }
748        });
749
750        let jsonb = JsonbValue::new(data);
751        let result = mapper.project(&jsonb).unwrap();
752
753        assert_eq!(
754            result,
755            json!({
756                "__typename": "Post",
757                "id": "post-1",
758                "title": "Hello World",
759                "author": {
760                    "__typename": "User",
761                    "id": "user-1",
762                    "name": "Alice"
763                }
764            })
765        );
766    }
767
768    #[test]
769    fn test_nested_array_typename() {
770        let mapper = ProjectionMapper::with_mappings(vec![
771            FieldMapping::simple("id"),
772            FieldMapping::simple("name"),
773            FieldMapping::nested_object(
774                "posts",
775                "Post",
776                vec![FieldMapping::simple("id"), FieldMapping::simple("title")],
777            ),
778        ])
779        .with_typename("User");
780
781        let data = json!({
782            "id": "user-1",
783            "name": "Alice",
784            "posts": [
785                { "id": "post-1", "title": "First Post", "views": 100 },
786                { "id": "post-2", "title": "Second Post", "views": 200 }
787            ]
788        });
789
790        let jsonb = JsonbValue::new(data);
791        let result = mapper.project(&jsonb).unwrap();
792
793        assert_eq!(
794            result,
795            json!({
796                "__typename": "User",
797                "id": "user-1",
798                "name": "Alice",
799                "posts": [
800                    { "__typename": "Post", "id": "post-1", "title": "First Post" },
801                    { "__typename": "Post", "id": "post-2", "title": "Second Post" }
802                ]
803            })
804        );
805    }
806
807    #[test]
808    fn test_deeply_nested_typename() {
809        // Post -> author (User) -> company (Company)
810        let mapper = ProjectionMapper::with_mappings(vec![
811            FieldMapping::simple("id"),
812            FieldMapping::nested_object(
813                "author",
814                "User",
815                vec![
816                    FieldMapping::simple("name"),
817                    FieldMapping::nested_object(
818                        "company",
819                        "Company",
820                        vec![FieldMapping::simple("name")],
821                    ),
822                ],
823            ),
824        ])
825        .with_typename("Post");
826
827        let data = json!({
828            "id": "post-1",
829            "author": {
830                "name": "Alice",
831                "company": {
832                    "name": "Acme Corp",
833                    "revenue": 1_000_000
834                }
835            }
836        });
837
838        let jsonb = JsonbValue::new(data);
839        let result = mapper.project(&jsonb).unwrap();
840
841        assert_eq!(
842            result,
843            json!({
844                "__typename": "Post",
845                "id": "post-1",
846                "author": {
847                    "__typename": "User",
848                    "name": "Alice",
849                    "company": {
850                        "__typename": "Company",
851                        "name": "Acme Corp"
852                    }
853                }
854            })
855        );
856    }
857
858    #[test]
859    fn test_nested_object_with_alias_and_typename() {
860        let mapper = ProjectionMapper::with_mappings(vec![
861            FieldMapping::simple("id"),
862            FieldMapping::nested_object_aliased(
863                "author",
864                "writer",
865                "User",
866                vec![FieldMapping::simple("id"), FieldMapping::simple("name")],
867            ),
868        ])
869        .with_typename("Post");
870
871        let data = json!({
872            "id": "post-1",
873            "author": {
874                "id": "user-1",
875                "name": "Alice"
876            }
877        });
878
879        let jsonb = JsonbValue::new(data);
880        let result = mapper.project(&jsonb).unwrap();
881
882        // "author" should be output as "writer" with typename
883        assert_eq!(
884            result,
885            json!({
886                "__typename": "Post",
887                "id": "post-1",
888                "writer": {
889                    "__typename": "User",
890                    "id": "user-1",
891                    "name": "Alice"
892                }
893            })
894        );
895    }
896
897    // ========================================================================
898    // Issue #27: Nested objects returned as JSON strings
899    // ========================================================================
900
901    #[test]
902    fn test_nested_object_as_json_string_is_re_parsed() {
903        // Reproduces Issue #27: when the database extracts a nested JSONB field
904        // using ->>'field' (text operator), it arrives as a JSON string rather
905        // than a proper Object. The projector must re-parse it.
906        let mapper = ProjectionMapper::with_mappings(vec![
907            FieldMapping::simple("id"),
908            FieldMapping::nested_object(
909                "author",
910                "User",
911                vec![FieldMapping::simple("id"), FieldMapping::simple("name")],
912            ),
913        ])
914        .with_typename("Post");
915
916        // "author" is a raw JSON string, not a parsed object
917        let data = json!({
918            "id": "post-1",
919            "author": "{\"id\":\"user-2\",\"name\":\"Bob\"}"
920        });
921
922        let jsonb = JsonbValue::new(data);
923        let result = mapper.project(&jsonb).unwrap();
924
925        // author must be an object, not a string
926        let author = result.get("author").expect("author field missing");
927        assert!(author.is_object(), "author should be a JSON object, got: {:?}", author);
928        assert_eq!(author.get("id"), Some(&json!("user-2")));
929        assert_eq!(author.get("name"), Some(&json!("Bob")));
930    }
931
932    #[test]
933    fn test_nested_without_specific_fields() {
934        // When nested_fields is None, all source fields are copied
935        let mapper = ProjectionMapper::with_mappings(vec![
936            FieldMapping::simple("id"),
937            FieldMapping::simple("author").with_nested_typename("User"),
938        ])
939        .with_typename("Post");
940
941        let data = json!({
942            "id": "post-1",
943            "author": {
944                "id": "user-1",
945                "name": "Alice",
946                "email": "alice@example.com"
947            }
948        });
949
950        let jsonb = JsonbValue::new(data);
951        let result = mapper.project(&jsonb).unwrap();
952
953        // All author fields should be copied, plus __typename
954        assert_eq!(
955            result,
956            json!({
957                "__typename": "Post",
958                "id": "post-1",
959                "author": {
960                    "__typename": "User",
961                    "id": "user-1",
962                    "name": "Alice",
963                    "email": "alice@example.com"
964                }
965            })
966        );
967    }
968}