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}