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