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    backend::types::JsonbValue,
7    error::{FraiseQLError, Result},
8    graphql::FieldSelection,
9    schema::{CompiledSchema, FieldDefinition, FieldType},
10    utils::casing::{to_camel_case, to_snake_case},
11};
12
13/// Field mapping for projection with alias support.
14#[derive(Debug, Clone)]
15pub struct FieldMapping {
16    /// JSONB key name (source).
17    pub source:          String,
18    /// Output key name (alias if different from source).
19    pub output:          String,
20    /// Fallback stored key, tried when `source` is not present in the row.
21    ///
22    /// Populated by every constructor from `stored_key_candidates`. It is not
23    /// support for `camelCase` *storage* — a view exposes `snake_case` keys and
24    /// columns, and the SQL projection generator has no fallback at all. It is
25    /// there because this projector runs over **two different maps**: the raw
26    /// stored document, whose keys are `snake_case`, and the output of
27    /// `jsonb_build_object`, whose keys are the camelCase *response* names the
28    /// SQL projection already emitted. One mapping has to read both.
29    ///
30    /// Until #1271 this field had **no producer** anywhere in the workspace —
31    /// its documented job was described and never performed, and the primary
32    /// `source` was the declared name verbatim, so the raw-document case was
33    /// simply unreadable.
34    pub source_fallback: Option<String>,
35    /// For nested object fields, the typename to add.
36    /// This enables `__typename` to be added recursively to nested objects.
37    pub nested_typename: Option<String>,
38    /// Nested field mappings (for related objects).
39    pub nested_fields:   Option<Vec<FieldMapping>>,
40    /// The schema positively declares this field a **scalar** (#1192).
41    ///
42    /// Set by [`ProjectionMapper::with_declared_scalars`], which is the only
43    /// thing that knows. It gates the JSON-text re-parse below: a value the
44    /// database handed back as a string is re-read as an object or an array when
45    /// it parses as one, to recover a nested object the SQL side extracted with
46    /// `->>` — and the comment justifying that said scalar strings "won't parse
47    /// as Object/Array, so this is safe for all field types". That premise is
48    /// false for exactly the rows a text column full of serialized JSON exists
49    /// to carry: an audit payload, a webhook body, an imported document. A field
50    /// declared `String` was returned as the value its text encodes, so the
51    /// response violated the schema the server publishes, per row, depending on
52    /// whether that row's text happened to parse.
53    ///
54    /// Defaults to `false` — *not known to be scalar* — so a mapping built
55    /// without schema knowledge keeps the recovery behaviour it had.
56    pub declared_scalar: bool,
57}
58
59/// The stored JSONB key a declared field name resolves to, plus the legacy
60/// spelling to fall back on.
61///
62/// **The single definition of the rule**, shared by the two Rust projectors:
63/// [`FieldMapping`]'s constructors resolve it once per query, and
64/// [`lookup_source`] resolves it per lookup for the selection-driven
65/// [`project_entity`]. Before #1271 they were two implementations and only one
66/// of them was right — the mapper read the declared name verbatim, so a
67/// `camelCase` field over a `snake_case` view was silently absent from a 200.
68///
69/// `snake_case` first, because that is the key the SQL projection generator
70/// (`projection_generator::render_field`) and the `where` parser both derive;
71/// the `camelCase` spelling second, for stored rows built with the surface
72/// casing. A single-word name yields the same string twice, so it has no
73/// fallback.
74pub fn stored_key_candidates(field_name: &str) -> (String, Option<String>) {
75    let snake = to_snake_case(field_name);
76    let camel = to_camel_case(field_name);
77    let fallback = (camel != snake).then_some(camel);
78    (snake, fallback)
79}
80
81impl FieldMapping {
82    /// Create a simple field mapping (no alias).
83    ///
84    /// `name` is the **declared field name**; the stored key it reads is
85    /// derived by `stored_key_candidates`, not taken verbatim.
86    ///
87    /// ```rust
88    /// # use fraiseql_core::runtime::FieldMapping;
89    /// let mapping = FieldMapping::simple("fkUser");
90    /// assert_eq!(mapping.source, "fk_user");
91    /// assert_eq!(mapping.output, "fkUser");
92    /// ```
93    #[must_use]
94    pub fn simple(name: impl Into<String>) -> Self {
95        let name = name.into();
96        let (source, source_fallback) = stored_key_candidates(&name);
97        Self {
98            source,
99            output: name,
100            source_fallback,
101            nested_typename: None,
102            nested_fields: None,
103            declared_scalar: false,
104        }
105    }
106
107    /// Create a field mapping with an alias.
108    ///
109    /// `source` is the **declared field name** and `alias` the response key —
110    /// the same split the SQL projection generator makes, where an aliased
111    /// field `myName: fullName` reads `data->>'full_name'` and is emitted as
112    /// `myName` (#418). The stored key is derived from `source` by
113    /// `stored_key_candidates`.
114    #[must_use]
115    pub fn aliased(source: impl Into<String>, alias: impl Into<String>) -> Self {
116        let (source, source_fallback) = stored_key_candidates(&source.into());
117        Self {
118            source,
119            output: alias.into(),
120            source_fallback,
121            nested_typename: None,
122            nested_fields: None,
123            declared_scalar: false,
124        }
125    }
126
127    /// Create a field mapping for a nested object with its own typename.
128    ///
129    /// # Example
130    ///
131    /// ```rust
132    /// # use fraiseql_core::runtime::FieldMapping;
133    /// // For a Post with nested author (User type)
134    /// let mapping = FieldMapping::nested_object("author", "User", vec![
135    ///     FieldMapping::simple("id"),
136    ///     FieldMapping::simple("name"),
137    /// ]);
138    /// assert_eq!(mapping.source, "author");
139    /// ```
140    #[must_use]
141    pub fn nested_object(
142        name: impl Into<String>,
143        typename: impl Into<String>,
144        fields: Vec<FieldMapping>,
145    ) -> Self {
146        let name = name.into();
147        let (source, source_fallback) = stored_key_candidates(&name);
148        Self {
149            source,
150            output: name,
151            source_fallback,
152            nested_typename: Some(typename.into()),
153            nested_fields: Some(fields),
154            declared_scalar: false,
155        }
156    }
157
158    /// Create an aliased nested object field.
159    #[must_use]
160    pub fn nested_object_aliased(
161        source: impl Into<String>,
162        alias: impl Into<String>,
163        typename: impl Into<String>,
164        fields: Vec<FieldMapping>,
165    ) -> Self {
166        let (source, source_fallback) = stored_key_candidates(&source.into());
167        Self {
168            source,
169            output: alias.into(),
170            source_fallback,
171            nested_typename: Some(typename.into()),
172            nested_fields: Some(fields),
173            declared_scalar: false,
174        }
175    }
176
177    /// Set the typename for a nested object field.
178    #[must_use]
179    pub fn with_nested_typename(mut self, typename: impl Into<String>) -> Self {
180        self.nested_typename = Some(typename.into());
181        self
182    }
183
184    /// Set nested field mappings.
185    #[must_use]
186    pub fn with_nested_fields(mut self, fields: Vec<FieldMapping>) -> Self {
187        self.nested_fields = Some(fields);
188        self
189    }
190
191    /// Read this field's value out of a stored JSONB object.
192    ///
193    /// The primary key first, then [`source_fallback`](Self::source_fallback) —
194    /// both resolved once at construction, so this stays allocation-free on the
195    /// per-row path.
196    #[must_use]
197    pub fn lookup_in<'a>(
198        &self,
199        map: &'a serde_json::Map<String, JsonValue>,
200    ) -> Option<&'a JsonValue> {
201        map.get(&self.source)
202            .or_else(|| self.source_fallback.as_ref().and_then(|fb| map.get(fb)))
203    }
204}
205
206/// Projection mapper - maps JSONB fields to GraphQL selection set.
207#[derive(Debug, Clone)]
208pub struct ProjectionMapper {
209    /// Fields to project (with optional aliases).
210    pub fields:          Vec<FieldMapping>,
211    /// Optional `__typename` value to add to each object.
212    pub typename:        Option<String>,
213    /// When `true`, `__typename` is injected unconditionally regardless of selection set.
214    /// Used by federation `_entities` resolver where the gateway always expects `__typename`.
215    pub federation_mode: bool,
216}
217
218/// Does this declared type's value space **exclude** JSON objects and arrays?
219///
220/// The question [`FieldMapping::declared_scalar`] needs, and deliberately not
221/// [`FieldType::is_scalar`], which answers a different one. `is_scalar` is true
222/// of `Json` — whose value space is *all* JSON — and of the vector types, which
223/// serialize as arrays. Marking either would stop the text-recovery re-parse
224/// from firing where it is doing its job.
225///
226/// A **custom** scalar is also left unmarked. A project defines its own
227/// serialization, so whether one of its values can be a JSON object is a
228/// question this module cannot adjudicate — the same rule the argument-value
229/// validator follows: narrow to what is positively known, never widen. #1192
230/// raised the custom-scalar case explicitly and this is the answer to it.
231const fn excludes_json_composites(field_type: &FieldType) -> bool {
232    matches!(
233        field_type,
234        FieldType::String
235            | FieldType::Int
236            | FieldType::Float
237            | FieldType::Boolean
238            | FieldType::Id
239            | FieldType::DateTime
240            | FieldType::Date
241            | FieldType::Time
242            | FieldType::Uuid
243            | FieldType::Decimal
244    )
245}
246
247impl ProjectionMapper {
248    /// Create new projection mapper from field names (no aliases).
249    #[must_use]
250    pub fn new(fields: Vec<String>) -> Self {
251        Self {
252            fields:          fields.into_iter().map(FieldMapping::simple).collect(),
253            typename:        None,
254            federation_mode: false,
255        }
256    }
257
258    /// Create new projection mapper with field mappings (supports aliases).
259    #[must_use]
260    pub const fn with_mappings(fields: Vec<FieldMapping>) -> Self {
261        Self {
262            fields,
263            typename: None,
264            federation_mode: false,
265        }
266    }
267
268    /// Mark every mapping whose field `entity_type` declares as a **scalar**
269    /// (#1192), so the JSON-text recovery in [`FieldMapping::declared_scalar`]
270    /// does not fire on a `String` whose characters happen to be JSON.
271    ///
272    /// Lookup is by the mapping's `output` name — the GraphQL field name, which
273    /// is what a type definition carries — falling back to `source` for a
274    /// mapping built from a stored key. A field the schema does not describe
275    /// stays unmarked: this narrows the recovery to what is positively known,
276    /// and never widens it.
277    #[must_use]
278    pub fn with_declared_scalars(mut self, schema: &CompiledSchema, entity_type: &str) -> Self {
279        let Some(type_def) = schema.find_type(entity_type) else {
280            return self;
281        };
282        for mapping in &mut self.fields {
283            let declared = type_def
284                .fields
285                .iter()
286                .find(|f| f.name == mapping.output || f.name == mapping.source);
287            if let Some(fd) = declared {
288                mapping.declared_scalar = excludes_json_composites(&fd.field_type);
289            }
290        }
291        self
292    }
293
294    /// Set `__typename` to include in projected objects.
295    #[must_use]
296    pub fn with_typename(mut self, typename: impl Into<String>) -> Self {
297        self.typename = Some(typename.into());
298        self
299    }
300
301    /// Enable federation mode: `__typename` is always injected regardless of selection set.
302    #[must_use]
303    pub const fn with_federation_mode(mut self, enabled: bool) -> Self {
304        self.federation_mode = enabled;
305        self
306    }
307
308    /// Project fields from JSONB value.
309    ///
310    /// # Arguments
311    ///
312    /// * `jsonb` - JSONB value from database
313    ///
314    /// # Returns
315    ///
316    /// Projected JSON value with only requested fields (and aliases applied)
317    ///
318    /// # Errors
319    ///
320    /// Returns error if projection fails.
321    pub fn project(&self, jsonb: &JsonbValue) -> Result<JsonValue> {
322        // Extract the inner serde_json::Value
323        let value = jsonb.as_value();
324
325        match value {
326            JsonValue::Object(map) => self.project_json_object(map),
327            JsonValue::Array(arr) => self.project_json_array(arr),
328            v => Ok(v.clone()),
329        }
330    }
331
332    /// Project object fields from JSON object.
333    ///
334    /// Maps source keys to output keys according to the configured `FieldMapping`s,
335    /// injects `__typename` when configured, and recursively projects nested objects
336    /// and arrays.
337    ///
338    /// # Errors
339    ///
340    /// Returns error if nested value projection fails.
341    pub fn project_json_object(
342        &self,
343        map: &serde_json::Map<String, JsonValue>,
344    ) -> Result<JsonValue> {
345        let mut result = Map::new();
346
347        // Add __typename first if configured (GraphQL convention)
348        if let Some(ref typename) = self.typename {
349            result.insert("__typename".to_string(), JsonValue::String(typename.clone()));
350        }
351
352        // Project fields with alias support, resolving each stored key by the
353        // rule every consumer of a declared field name shares (#1271).
354        for field in &self.fields {
355            if let Some(value) = field.lookup_in(map) {
356                let projected_value = self.project_nested_value(value, field)?;
357                result.insert(field.output.clone(), projected_value);
358            }
359        }
360
361        Ok(JsonValue::Object(result))
362    }
363
364    /// Project a nested value, adding typename if configured.
365    #[allow(clippy::self_only_used_in_recursion)] // Reason: &self required for method dispatch; recursive structure is intentional
366    fn project_nested_value(&self, value: &JsonValue, field: &FieldMapping) -> Result<JsonValue> {
367        match value {
368            JsonValue::Object(obj) => {
369                // If this field has nested typename, add it
370                if let Some(ref typename) = field.nested_typename {
371                    let mut result = Map::new();
372                    result.insert("__typename".to_string(), JsonValue::String(typename.clone()));
373
374                    // If we have nested field mappings, use them; otherwise copy all fields
375                    if let Some(ref nested_fields) = field.nested_fields {
376                        for nested_field in nested_fields {
377                            // #1271: the nested branch reads its own sub-map and
378                            // had its own verbatim lookup, so it needs the rule
379                            // explicitly rather than inheriting it from above.
380                            if let Some(nested_value) = nested_field.lookup_in(obj) {
381                                let projected =
382                                    self.project_nested_value(nested_value, nested_field)?;
383                                result.insert(nested_field.output.clone(), projected);
384                            }
385                        }
386                    } else {
387                        // No specific field mappings - copy all fields from source
388                        for (k, v) in obj {
389                            result.insert(k.clone(), v.clone());
390                        }
391                    }
392                    Ok(JsonValue::Object(result))
393                } else {
394                    // No typename for this nested object - return as-is
395                    Ok(value.clone())
396                }
397            },
398            JsonValue::Array(arr) => {
399                // For arrays of objects, add typename to each element
400                if field.nested_typename.is_some() {
401                    let projected: Result<Vec<JsonValue>> =
402                        arr.iter().map(|item| self.project_nested_value(item, field)).collect();
403                    Ok(JsonValue::Array(projected?))
404                } else {
405                    Ok(value.clone())
406                }
407            },
408            _ => {
409                // If the value is a JSON string that encodes an object or array
410                // (which happens when the database uses ->>'field' text extraction
411                // instead of ->'field' JSONB extraction), attempt to re-parse it.
412                //
413                // #1192: unless the schema declares the field a scalar. This used
414                // to run for every field type, justified by "scalar strings won't
415                // parse as Object/Array" — true of `"hello"`, false of the
416                // serialized JSON that text columns routinely hold, and the
417                // response then carried an object where the published schema
418                // promised a string.
419                if field.declared_scalar {
420                    return Ok(value.clone());
421                }
422                if let JsonValue::String(ref s) = *value {
423                    if let Ok(parsed @ (JsonValue::Object(_) | JsonValue::Array(_))) =
424                        serde_json::from_str::<JsonValue>(s)
425                    {
426                        return self.project_nested_value(&parsed, field);
427                    }
428                }
429                Ok(value.clone())
430            },
431        }
432    }
433
434    /// Project array elements from JSON array.
435    ///
436    /// A row that fails projection propagates as an error rather than being
437    /// silently dropped from the result set (#736): `project_json_object` is
438    /// currently infallible, but if it ever gains an error path, an array with
439    /// a bad element must not shrink without a trace.
440    fn project_json_array(&self, arr: &[JsonValue]) -> Result<JsonValue> {
441        let projected: Result<Vec<JsonValue>> = arr
442            .iter()
443            .map(|item| {
444                if let JsonValue::Object(obj) = item {
445                    self.project_json_object(obj)
446                } else {
447                    Ok(item.clone())
448                }
449            })
450            .collect();
451
452        Ok(JsonValue::Array(projected?))
453    }
454}
455
456/// Result projector - high-level result transformation.
457pub struct ResultProjector {
458    mapper: ProjectionMapper,
459}
460
461impl ResultProjector {
462    /// Create new result projector from field names (no aliases).
463    #[must_use]
464    pub fn new(fields: Vec<String>) -> Self {
465        Self {
466            mapper: ProjectionMapper::new(fields),
467        }
468    }
469
470    /// Create new result projector with field mappings (supports aliases).
471    #[must_use]
472    pub const fn with_mappings(fields: Vec<FieldMapping>) -> Self {
473        Self {
474            mapper: ProjectionMapper::with_mappings(fields),
475        }
476    }
477
478    /// Mark the mappings the schema declares scalar (#1192).
479    ///
480    /// See [`ProjectionMapper::with_declared_scalars`].
481    #[must_use]
482    pub fn with_declared_scalars(mut self, schema: &CompiledSchema, entity_type: &str) -> Self {
483        self.mapper = self.mapper.with_declared_scalars(schema, entity_type);
484        self
485    }
486
487    /// Set `__typename` to include in all projected objects.
488    ///
489    /// Per GraphQL spec §2.7, `__typename` returns the name of the object type.
490    /// This should be called when the client requests `__typename` in the selection set.
491    #[must_use]
492    pub fn with_typename(mut self, typename: impl Into<String>) -> Self {
493        self.mapper = self.mapper.with_typename(typename);
494        self
495    }
496
497    /// Configure typename injection from the query selection set.
498    ///
499    /// Inspects the root selection's nested fields for `__typename`. If found,
500    /// enables typename injection via [`with_typename`](Self::with_typename).
501    #[must_use]
502    pub fn configure_typename_from_selections(
503        self,
504        selections: &[FieldSelection],
505        entity_type: &str,
506    ) -> Self {
507        let wants_typename = selections
508            .first()
509            .is_some_and(|root| root.nested_fields.iter().any(|f| f.name == "__typename"));
510        if wants_typename {
511            self.with_typename(entity_type)
512        } else {
513            self
514        }
515    }
516
517    /// Enable federation mode: `__typename` is always injected regardless of selection set.
518    ///
519    /// Used by the `_entities` federation resolver where the gateway always expects
520    /// `__typename` in entity results.
521    #[must_use]
522    pub fn with_federation_mode(mut self, enabled: bool) -> Self {
523        self.mapper = self.mapper.with_federation_mode(enabled);
524        self
525    }
526
527    /// Project database results to GraphQL response.
528    ///
529    /// # Arguments
530    ///
531    /// * `results` - Database results as JSONB values (borrowed; not mutated).
532    /// * `is_list` - Whether the query returns a list.
533    ///
534    /// # Returns
535    ///
536    /// A freshly-allocated GraphQL-compatible JSON response. The projector
537    /// **never aliases the input slice**: each field of every `JsonbValue` is
538    /// cloned out into a new `serde_json::Value` tree (see F029 — ownership
539    /// contract is on `JsonbValue` itself).
540    ///
541    /// # Errors
542    ///
543    /// Returns error if projection fails.
544    pub fn project_results(&self, results: &[JsonbValue], is_list: bool) -> Result<JsonValue> {
545        if is_list {
546            // Project array of results
547            let projected: Result<Vec<JsonValue>> =
548                results.iter().map(|r| self.mapper.project(r)).collect();
549
550            Ok(JsonValue::Array(projected?))
551        } else {
552            // Project single result
553            if let Some(first) = results.first() {
554                self.mapper.project(first)
555            } else {
556                Ok(JsonValue::Null)
557            }
558        }
559    }
560
561    /// Wrap result in GraphQL data envelope.
562    ///
563    /// # Arguments
564    ///
565    /// * `result` - Projected result
566    /// * `query_name` - Query operation name
567    ///
568    /// # Returns
569    ///
570    /// GraphQL response with `{ "data": { "queryName": result } }` structure
571    #[must_use]
572    pub fn wrap_in_data_envelope(result: JsonValue, query_name: &str) -> JsonValue {
573        let mut data = Map::new();
574        data.insert(query_name.to_string(), result);
575
576        let mut response = Map::new();
577        response.insert("data".to_string(), JsonValue::Object(data));
578
579        JsonValue::Object(response)
580    }
581
582    /// Add __typename field to SQL-projected data.
583    ///
584    /// For data that has already been projected at the SQL level, we only need to add
585    /// the `__typename` field in Rust. This is much faster than projecting all fields
586    /// since the SQL already filtered to only requested fields.
587    ///
588    /// # Arguments
589    ///
590    /// * `projected_data` - JSONB data already projected by SQL
591    /// * `typename` - GraphQL type name to add
592    ///
593    /// # Returns
594    ///
595    /// New JSONB value with `__typename` field added
596    ///
597    /// # Example
598    ///
599    /// ```rust
600    /// # use fraiseql_core::runtime::ResultProjector;
601    /// # use fraiseql_core::db::types::JsonbValue;
602    /// # use serde_json::json;
603    /// let projector = ResultProjector::new(vec!["id".to_string(), "name".to_string()]);
604    /// // Database already returned only: { "id": "123", "name": "Alice" }
605    /// let result = projector.add_typename_only(
606    ///     &JsonbValue::new(json!({ "id": "123", "name": "Alice" })),
607    ///     "User"
608    /// ).unwrap();
609    ///
610    /// // Result: { "id": "123", "name": "Alice", "__typename": "User" }
611    /// assert_eq!(result["__typename"], "User");
612    /// ```
613    ///
614    /// # Errors
615    ///
616    /// Returns [`FraiseQLError::Validation`] if the projected data contains a
617    /// list element that is not a JSON object, making `__typename` injection impossible.
618    pub fn add_typename_only(
619        &self,
620        projected_data: &JsonbValue,
621        typename: &str,
622    ) -> Result<JsonValue> {
623        let value = projected_data.as_value();
624
625        match value {
626            JsonValue::Object(map) => {
627                let mut result = map.clone();
628                result.insert("__typename".to_string(), JsonValue::String(typename.to_string()));
629                Ok(JsonValue::Object(result))
630            },
631            JsonValue::Array(arr) => {
632                let updated: Result<Vec<JsonValue>> = arr
633                    .iter()
634                    .map(|item| {
635                        if let JsonValue::Object(obj) = item {
636                            let mut result = obj.clone();
637                            result.insert(
638                                "__typename".to_string(),
639                                JsonValue::String(typename.to_string()),
640                            );
641                            Ok(JsonValue::Object(result))
642                        } else {
643                            Ok(item.clone())
644                        }
645                    })
646                    .collect();
647                Ok(JsonValue::Array(updated?))
648            },
649            v => Ok(v.clone()),
650        }
651    }
652
653    /// Wrap error in GraphQL error envelope.
654    ///
655    /// # Arguments
656    ///
657    /// * `error` - Error to wrap
658    ///
659    /// # Returns
660    ///
661    /// GraphQL error response with `{ "errors": [...] }` structure
662    #[must_use]
663    pub fn wrap_error(error: &FraiseQLError) -> JsonValue {
664        let mut error_obj = Map::new();
665        error_obj.insert("message".to_string(), JsonValue::String(error.to_string()));
666
667        let mut response = Map::new();
668        response.insert("errors".to_string(), JsonValue::Array(vec![JsonValue::Object(error_obj)]));
669
670        JsonValue::Object(response)
671    }
672}
673
674/// Project an entity-shaped JSONB value into a GraphQL response object, mirroring
675/// the query path's SQL projection exactly.
676///
677/// This is the **single, canonical** entity projector, shared by the mutation
678/// success arm (projecting the returned entity) and the error arm (projecting the
679/// error metadata). It is behaviourally equivalent to the SQL query projection
680/// (`projection_generator::render_field`):
681///
682/// - **output key** = the selection's response key (the camelCase GraphQL surface name, honouring
683///   aliases),
684/// - **source key** = [`to_snake_case`](crate::utils::casing::to_snake_case) of the field name (the
685///   stored JSONB key), with a `camelCase` fallback for legacy metadata that used the surface
686///   casing,
687/// - **object fields**, single or listed, are projected through their sub-selection at **any
688///   depth**. The recursion follows the selection, which `max_query_depth` bounds, so there is no
689///   depth at which a stored sub-object passes through: that fall-through, at a cap of 4, served
690///   every stored key of an object five levels down, gated or not. An object field with no
691///   sub-selection selects none of its fields and projects to `{}`,
692/// - **scalar fields** pass through their stored value,
693/// - **`__typename`** is emitted only where the client selected it.
694///
695/// `type_name` is the concrete GraphQL object type of `entity` (resolved by the
696/// caller). `selections` is the result selection set for this level, with inline
697/// `... on T` fragments preserved.
698///
699/// # An empty selection set projects **nothing**
700///
701/// It used to return the stored entity unchanged, and that one line is what made
702/// #1352 and #1357 invisible: an empty slice is the *permissive* shape, not a
703/// neutral one, so a transport that arrived without a selection set was not
704/// refused — it was granted every field of the row, policy-gated ones included,
705/// while `selection_set_selects_gated_field` reported that nothing gated was
706/// selected and the #423 field authorizer took zero calls.
707///
708/// Nothing legitimate reaches here empty any more: GraphQL § 5.3.3 refuses a
709/// composite field named without a selection set (#1357), every mutation's return
710/// type is composite (#1358), and the write entries take a non-empty
711/// [`WriteSelections`](crate::runtime::WriteSelections). An empty slice arriving
712/// here is therefore a defect upstream, and the safe answer to a defect is the
713/// empty object — which is also what the read path already answers for a selection
714/// set that `@skip` resolves to nothing, so the two agree.
715///
716/// There is deliberately **no `project_entity_unfiltered`** to reach for. Every
717/// caller in the tree either has a real selection set or guards emptiness before
718/// calling (federation returns early on `root.nested_fields.is_empty()`), so a
719/// named permissive helper would be a public function with no caller — and an
720/// unused facility is how the permissive branch stayed reachable in the first
721/// place. A caller that genuinely wants the whole entity can write `entity.clone()`
722/// and own that decision where it is made.
723#[must_use]
724pub fn project_entity(
725    entity: &JsonValue,
726    type_name: &str,
727    selections: &[FieldSelection],
728    schema: &CompiledSchema,
729) -> JsonValue {
730    if selections.is_empty() {
731        return JsonValue::Object(Map::new());
732    }
733    project_entity_at(entity, type_name, selections, schema)
734}
735
736fn project_entity_at(
737    entity: &JsonValue,
738    type_name: &str,
739    selections: &[FieldSelection],
740    schema: &CompiledSchema,
741) -> JsonValue {
742    let JsonValue::Object(obj) = entity else {
743        // Not an object (e.g. null) — nothing to project.
744        return entity.clone();
745    };
746    let type_def = schema.find_type(type_name);
747    let mut out = Map::new();
748
749    for sel in effective_selections(selections, type_name, schema) {
750        if sel.name == "__typename" {
751            out.insert(sel.response_key().to_string(), JsonValue::String(type_name.to_string()));
752            continue;
753        }
754        let field_def =
755            type_def.and_then(|td| td.fields.iter().find(|f| f.name.as_str() == sel.name));
756        let Some(value) = lookup_source(obj, &sel.name) else {
757            // Absent stored key → omit (matches query/SQL behaviour: absent stays absent).
758            continue;
759        };
760        let projected = project_field_value(value, field_def, &sel.nested_fields, schema);
761        out.insert(sel.response_key().to_string(), projected);
762    }
763
764    JsonValue::Object(out)
765}
766
767/// Project a single field value, recursing into nested object selections.
768fn project_field_value(
769    value: &JsonValue,
770    field_def: Option<&FieldDefinition>,
771    nested: &[FieldSelection],
772    schema: &CompiledSchema,
773) -> JsonValue {
774    // An object field is projected through its sub-selection at every depth, and an empty
775    // sub-selection projects nothing of it. Only a scalar returns its stored value.
776    if let Some(fd) = field_def {
777        if !fd.field_type.is_scalar() && !fd.field_type.is_list() {
778            if let Some(child_type) = fd.field_type.type_name() {
779                match value {
780                    JsonValue::Object(_) => {
781                        return project_entity_at(value, child_type, nested, schema);
782                    },
783                    // The DB sometimes returns a nested object as a JSON string
784                    // (when extracted via `->>`). Re-parse and project it.
785                    JsonValue::String(s) => {
786                        if let Ok(parsed @ JsonValue::Object(_)) =
787                            serde_json::from_str::<JsonValue>(s)
788                        {
789                            return project_entity_at(&parsed, child_type, nested, schema);
790                        }
791                    },
792                    _ => {},
793                }
794            }
795        } else if fd.field_type.is_list() {
796            // #489: a nested LIST-of-object field. The SQL/stored side returns the
797            // raw aggregated sub-blob (snake_case keys, unselected keys included);
798            // project every element at the element type — the same recasing +
799            // selection-set projection applied to single nested objects — so nested
800            // list output matches top-level and nested-object output.
801            if let Some(child_type) = fd.field_type.inner_type().and_then(FieldType::type_name) {
802                if let JsonValue::Array(arr) = value {
803                    return JsonValue::Array(
804                        arr.iter()
805                            .map(|el| project_list_element(el, child_type, nested, schema))
806                            .collect(),
807                    );
808                }
809                // The DB sometimes returns the whole array as a JSON string
810                // (`->>` text extraction). Re-parse and project each element.
811                if let JsonValue::String(s) = value {
812                    if let Ok(JsonValue::Array(arr)) = serde_json::from_str::<JsonValue>(s) {
813                        return JsonValue::Array(
814                            arr.iter()
815                                .map(|el| project_list_element(el, child_type, nested, schema))
816                                .collect(),
817                        );
818                    }
819                }
820            }
821        }
822    }
823    value.clone()
824}
825
826/// Project one element of a nested list-of-object field (#489): recase + project
827/// object elements at `child_type`, passing scalars and non-object strings through.
828fn project_list_element(
829    element: &JsonValue,
830    child_type: &str,
831    nested: &[FieldSelection],
832    schema: &CompiledSchema,
833) -> JsonValue {
834    match element {
835        JsonValue::Object(_) => project_entity_at(element, child_type, nested, schema),
836        // An element itself text-encoded as a JSON object.
837        JsonValue::String(s) => match serde_json::from_str::<JsonValue>(s) {
838            Ok(parsed @ JsonValue::Object(_)) => {
839                project_entity_at(&parsed, child_type, nested, schema)
840            },
841            _ => element.clone(),
842        },
843        _ => element.clone(),
844    }
845}
846
847/// #489 — recase + project nested LIST-of-object fields left raw by the SQL projection.
848///
849/// The query path projects top-level fields and nested single objects at the SQL level
850/// (`jsonb_build_object`), but list fields fall back to the raw stored sub-blob
851/// (`snake_case` keys, unselected keys included). This walks the already-projected
852/// `value` guided by the selection set and, for each list-of-object field at any depth,
853/// replaces its elements with the fully projected form via
854/// [`project_entity`] at the element type. Non-list fields are left untouched — the SQL
855/// side already projected them; single-object fields are only recursed into to reach
856/// any lists nested inside them.
857///
858/// `type_name` is the entity type of `value` (or of each element when `value` is a
859/// list-returning query's top-level array); `selections` is the entity-level selection
860/// set (the query field's `nested_fields`).
861pub fn project_nested_lists(
862    value: &mut JsonValue,
863    type_name: &str,
864    selections: &[FieldSelection],
865    schema: &CompiledSchema,
866) {
867    match value {
868        // A list-returning query: each element is an entity of `type_name` (the list
869        // itself is not a nesting level).
870        JsonValue::Array(arr) => {
871            for el in arr.iter_mut() {
872                project_nested_lists(el, type_name, selections, schema);
873            }
874        },
875        JsonValue::Object(obj) => {
876            let type_def = schema.find_type(type_name);
877            for sel in effective_selections(selections, type_name, schema) {
878                if sel.name == "__typename" {
879                    continue;
880                }
881                let Some(fd) =
882                    type_def.and_then(|td| td.fields.iter().find(|f| f.name.as_str() == sel.name))
883                else {
884                    continue;
885                };
886                if fd.field_type.is_scalar() {
887                    continue;
888                }
889                let key = sel.response_key();
890                if fd.field_type.is_list() {
891                    // The element type of a list-of-object field. `project_entity` fully
892                    // projects each raw element, including any lists nested inside it.
893                    if let Some(child_type) =
894                        fd.field_type.inner_type().and_then(FieldType::type_name)
895                    {
896                        if let Some(JsonValue::Array(arr)) = obj.get_mut(key) {
897                            for el in arr.iter_mut() {
898                                *el = project_entity(el, child_type, &sel.nested_fields, schema);
899                            }
900                        }
901                    }
902                } else if let Some(child_type) = fd.field_type.type_name() {
903                    // Single object already projected by the SQL side — recurse to reach
904                    // any lists nested inside it.
905                    if let Some(child) = obj.get_mut(key) {
906                        project_nested_lists(child, child_type, &sel.nested_fields, schema);
907                    }
908                }
909            }
910        },
911        _ => {},
912    }
913}
914
915/// Stamp `__typename` on the nested single objects whose selection asked for it.
916///
917/// `__typename` is `String!` (GraphQL spec § Type Name Introspection): it can
918/// never be null, and a requested field can never be absent. It is a meta-field,
919/// not a JSONB key, so it is stripped from the SQL projection at every depth —
920/// projecting it would emit `data->>'__typename'`, a literal NULL (the symptom
921/// #912 reports). Something on the Rust side has to put it back.
922///
923/// Two of the three levels already had an owner: the root object is stamped by
924/// [`ResultProjector::configure_typename_from_selections`], and list elements by
925/// [`project_entity`]. A *single* nested object had none, so a requested nested
926/// `__typename` was dropped from the response with no error at all.
927///
928/// The key is inserted at the position the client's selection set puts it, not
929/// appended: a response's fields follow the query's order (spec § Response
930/// Format).
931pub fn stamp_nested_typenames(
932    value: &mut JsonValue,
933    type_name: &str,
934    selections: &[FieldSelection],
935    schema: &CompiledSchema,
936) {
937    match value {
938        // A list-returning query: each element is an entity of `type_name` (the
939        // list itself is not a nesting level).
940        JsonValue::Array(arr) => {
941            for el in arr.iter_mut() {
942                stamp_nested_typenames(el, type_name, selections, schema);
943            }
944        },
945        JsonValue::Object(obj) => {
946            let type_def = schema.find_type(type_name);
947            for sel in effective_selections(selections, type_name, schema) {
948                // This level's own `__typename` belongs to whoever built this
949                // object; only children are this pass's business.
950                if sel.name == "__typename" {
951                    continue;
952                }
953                let Some(fd) =
954                    type_def.and_then(|td| td.fields.iter().find(|f| f.name.as_str() == sel.name))
955                else {
956                    continue;
957                };
958                // Lists are projected element-by-element through `project_entity`,
959                // which stamps them already.
960                if fd.field_type.is_scalar() || fd.field_type.is_list() {
961                    continue;
962                }
963                let Some(child_type) = fd.field_type.type_name() else {
964                    continue;
965                };
966                let key = sel.response_key().to_string();
967                let child_selections = &sel.nested_fields;
968                let Some(child) = obj.get_mut(&key) else {
969                    continue;
970                };
971                if let JsonValue::Object(child_obj) = child {
972                    reinsert_typename_in_order(child_obj, child_selections, child_type, schema);
973                }
974                stamp_nested_typenames(child, child_type, child_selections, schema);
975            }
976        },
977        _ => {},
978    }
979}
980
981/// Rebuild `obj` with its selected `__typename` keys in their requested
982/// positions, leaving every other key in its existing order.
983///
984/// `serde_json::Map` preserves insertion order (`preserve_order`), so a plain
985/// insert would append — putting `__typename` last however the client wrote it.
986fn reinsert_typename_in_order(
987    obj: &mut Map<String, JsonValue>,
988    selections: &[FieldSelection],
989    type_name: &str,
990    schema: &CompiledSchema,
991) {
992    // Nothing to insert → leave the object, and its existing order, untouched.
993    if !effective_selections(selections, type_name, schema)
994        .iter()
995        .any(|sel| sel.name == "__typename")
996    {
997        return;
998    }
999
1000    let mut rebuilt = Map::new();
1001    for sel in effective_selections(selections, type_name, schema) {
1002        let key = sel.response_key();
1003        if sel.name == "__typename" {
1004            rebuilt.insert(key.to_string(), JsonValue::String(type_name.to_string()));
1005        } else if let Some(v) = obj.get(key) {
1006            rebuilt.insert(key.to_string(), v.clone());
1007        }
1008    }
1009    // Anything the selection walker did not account for keeps its place at the
1010    // end rather than being dropped — this pass adds a key, it never removes one.
1011    for (k, v) in obj.iter() {
1012        if !rebuilt.contains_key(k) {
1013            rebuilt.insert(k.clone(), v.clone());
1014        }
1015    }
1016    *obj = rebuilt;
1017}
1018
1019/// Look up a field's stored value: canonical `snake_case` key first, then a
1020/// `camelCase` fallback for legacy metadata that used the GraphQL surface casing.
1021fn lookup_source<'a>(obj: &'a Map<String, JsonValue>, field_name: &str) -> Option<&'a JsonValue> {
1022    let (snake, camel) = stored_key_candidates(field_name);
1023    obj.get(&snake).or_else(|| camel.and_then(|c| obj.get(&c)))
1024}
1025
1026/// Flatten a selection set for a concrete object type: direct fields, plus the
1027/// contents of any inline fragment `... on T` (or on an interface `T` implements).
1028/// Resolve inline `... on T` fragments in `selections` against `type_name`,
1029/// returning the flat list of field selections that apply to it.
1030///
1031/// Shared with the mutation runner's cascade projection, which navigates the
1032/// payload/envelope selection sets (`... on <Name>Payload`, `... on <EntityType>`)
1033/// the same way the entity projector does.
1034pub fn effective_selections<'a>(
1035    selections: &'a [FieldSelection],
1036    type_name: &str,
1037    schema: &CompiledSchema,
1038) -> Vec<&'a FieldSelection> {
1039    let mut out = Vec::new();
1040    for sel in selections {
1041        if let Some(frag_type) = sel.name.strip_prefix("...on ") {
1042            let frag_type = frag_type.trim();
1043            let applies = frag_type == type_name
1044                || schema
1045                    .find_type(type_name)
1046                    .is_some_and(|td| td.implements.iter().any(|i| i == frag_type));
1047            if applies {
1048                out.extend(effective_selections(&sel.nested_fields, type_name, schema));
1049            }
1050        } else {
1051            out.push(sel);
1052        }
1053    }
1054    out
1055}