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 graphql::FieldSelection,
9 schema::{CompiledSchema, FieldDefinition},
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 source key to try when the primary `source` is not found.
21 /// Used for mutation error metadata where the key may be either `camelCase`
22 /// or `snake_case` depending on the backend.
23 pub source_fallback: Option<String>,
24 /// For nested object fields, the typename to add.
25 /// This enables `__typename` to be added recursively to nested objects.
26 pub nested_typename: Option<String>,
27 /// Nested field mappings (for related objects).
28 pub nested_fields: Option<Vec<FieldMapping>>,
29}
30
31impl FieldMapping {
32 /// Create a simple field mapping (no alias).
33 #[must_use]
34 pub fn simple(name: impl Into<String>) -> Self {
35 let name = name.into();
36 Self {
37 source: name.clone(),
38 output: name,
39 source_fallback: None,
40 nested_typename: None,
41 nested_fields: None,
42 }
43 }
44
45 /// Create a field mapping with an alias.
46 #[must_use]
47 pub fn aliased(source: impl Into<String>, alias: impl Into<String>) -> Self {
48 Self {
49 source: source.into(),
50 output: alias.into(),
51 source_fallback: None,
52 nested_typename: None,
53 nested_fields: None,
54 }
55 }
56
57 /// Create a field mapping for a nested object with its own typename.
58 ///
59 /// # Example
60 ///
61 /// ```rust
62 /// # use fraiseql_core::runtime::FieldMapping;
63 /// // For a Post with nested author (User type)
64 /// let mapping = FieldMapping::nested_object("author", "User", vec![
65 /// FieldMapping::simple("id"),
66 /// FieldMapping::simple("name"),
67 /// ]);
68 /// assert_eq!(mapping.source, "author");
69 /// ```
70 #[must_use]
71 pub fn nested_object(
72 name: impl Into<String>,
73 typename: impl Into<String>,
74 fields: Vec<FieldMapping>,
75 ) -> Self {
76 let name = name.into();
77 Self {
78 source: name.clone(),
79 output: name,
80 source_fallback: None,
81 nested_typename: Some(typename.into()),
82 nested_fields: Some(fields),
83 }
84 }
85
86 /// Create an aliased nested object field.
87 #[must_use]
88 pub fn nested_object_aliased(
89 source: impl Into<String>,
90 alias: impl Into<String>,
91 typename: impl Into<String>,
92 fields: Vec<FieldMapping>,
93 ) -> Self {
94 Self {
95 source: source.into(),
96 output: alias.into(),
97 source_fallback: None,
98 nested_typename: Some(typename.into()),
99 nested_fields: Some(fields),
100 }
101 }
102
103 /// Set the typename for a nested object field.
104 #[must_use]
105 pub fn with_nested_typename(mut self, typename: impl Into<String>) -> Self {
106 self.nested_typename = Some(typename.into());
107 self
108 }
109
110 /// Set nested field mappings.
111 #[must_use]
112 pub fn with_nested_fields(mut self, fields: Vec<FieldMapping>) -> Self {
113 self.nested_fields = Some(fields);
114 self
115 }
116}
117
118/// Projection mapper - maps JSONB fields to GraphQL selection set.
119#[derive(Debug, Clone)]
120pub struct ProjectionMapper {
121 /// Fields to project (with optional aliases).
122 pub fields: Vec<FieldMapping>,
123 /// Optional `__typename` value to add to each object.
124 pub typename: Option<String>,
125 /// When `true`, `__typename` is injected unconditionally regardless of selection set.
126 /// Used by federation `_entities` resolver where the gateway always expects `__typename`.
127 pub federation_mode: bool,
128}
129
130impl ProjectionMapper {
131 /// Create new projection mapper from field names (no aliases).
132 #[must_use]
133 pub fn new(fields: Vec<String>) -> Self {
134 Self {
135 fields: fields.into_iter().map(FieldMapping::simple).collect(),
136 typename: None,
137 federation_mode: false,
138 }
139 }
140
141 /// Create new projection mapper with field mappings (supports aliases).
142 #[must_use]
143 pub const fn with_mappings(fields: Vec<FieldMapping>) -> Self {
144 Self {
145 fields,
146 typename: None,
147 federation_mode: false,
148 }
149 }
150
151 /// Set `__typename` to include in projected objects.
152 #[must_use]
153 pub fn with_typename(mut self, typename: impl Into<String>) -> Self {
154 self.typename = Some(typename.into());
155 self
156 }
157
158 /// Enable federation mode: `__typename` is always injected regardless of selection set.
159 #[must_use]
160 pub const fn with_federation_mode(mut self, enabled: bool) -> Self {
161 self.federation_mode = enabled;
162 self
163 }
164
165 /// Project fields from JSONB value.
166 ///
167 /// # Arguments
168 ///
169 /// * `jsonb` - JSONB value from database
170 ///
171 /// # Returns
172 ///
173 /// Projected JSON value with only requested fields (and aliases applied)
174 ///
175 /// # Errors
176 ///
177 /// Returns error if projection fails.
178 pub fn project(&self, jsonb: &JsonbValue) -> Result<JsonValue> {
179 // Extract the inner serde_json::Value
180 let value = jsonb.as_value();
181
182 match value {
183 JsonValue::Object(map) => self.project_json_object(map),
184 JsonValue::Array(arr) => self.project_json_array(arr),
185 v => Ok(v.clone()),
186 }
187 }
188
189 /// Project object fields from JSON object.
190 ///
191 /// Maps source keys to output keys according to the configured `FieldMapping`s,
192 /// injects `__typename` when configured, and recursively projects nested objects
193 /// and arrays.
194 ///
195 /// # Errors
196 ///
197 /// Returns error if nested value projection fails.
198 pub fn project_json_object(
199 &self,
200 map: &serde_json::Map<String, JsonValue>,
201 ) -> Result<JsonValue> {
202 let mut result = Map::new();
203
204 // Add __typename first if configured (GraphQL convention)
205 if let Some(ref typename) = self.typename {
206 result.insert("__typename".to_string(), JsonValue::String(typename.clone()));
207 }
208
209 // Project fields with alias support and optional fallback key
210 for field in &self.fields {
211 let value = map
212 .get(&field.source)
213 .or_else(|| field.source_fallback.as_ref().and_then(|fb| map.get(fb)));
214 if let Some(value) = value {
215 let projected_value = self.project_nested_value(value, field)?;
216 result.insert(field.output.clone(), projected_value);
217 }
218 }
219
220 Ok(JsonValue::Object(result))
221 }
222
223 /// Project a nested value, adding typename if configured.
224 #[allow(clippy::self_only_used_in_recursion)] // Reason: &self required for method dispatch; recursive structure is intentional
225 fn project_nested_value(&self, value: &JsonValue, field: &FieldMapping) -> Result<JsonValue> {
226 match value {
227 JsonValue::Object(obj) => {
228 // If this field has nested typename, add it
229 if let Some(ref typename) = field.nested_typename {
230 let mut result = Map::new();
231 result.insert("__typename".to_string(), JsonValue::String(typename.clone()));
232
233 // If we have nested field mappings, use them; otherwise copy all fields
234 if let Some(ref nested_fields) = field.nested_fields {
235 for nested_field in nested_fields {
236 if let Some(nested_value) = obj.get(&nested_field.source) {
237 let projected =
238 self.project_nested_value(nested_value, nested_field)?;
239 result.insert(nested_field.output.clone(), projected);
240 }
241 }
242 } else {
243 // No specific field mappings - copy all fields from source
244 for (k, v) in obj {
245 result.insert(k.clone(), v.clone());
246 }
247 }
248 Ok(JsonValue::Object(result))
249 } else {
250 // No typename for this nested object - return as-is
251 Ok(value.clone())
252 }
253 },
254 JsonValue::Array(arr) => {
255 // For arrays of objects, add typename to each element
256 if field.nested_typename.is_some() {
257 let projected: Result<Vec<JsonValue>> =
258 arr.iter().map(|item| self.project_nested_value(item, field)).collect();
259 Ok(JsonValue::Array(projected?))
260 } else {
261 Ok(value.clone())
262 }
263 },
264 _ => {
265 // If the value is a JSON string that encodes an object or array
266 // (which happens when the database uses ->>'field' text extraction
267 // instead of ->'field' JSONB extraction), attempt to re-parse it.
268 // Scalar strings (e.g. "hello") won't parse as Object/Array and
269 // are returned unchanged, so this is safe for all field types.
270 if let JsonValue::String(ref s) = *value {
271 if let Ok(parsed @ (JsonValue::Object(_) | JsonValue::Array(_))) =
272 serde_json::from_str::<JsonValue>(s)
273 {
274 return self.project_nested_value(&parsed, field);
275 }
276 }
277 Ok(value.clone())
278 },
279 }
280 }
281
282 /// Project array elements from JSON array.
283 fn project_json_array(&self, arr: &[JsonValue]) -> Result<JsonValue> {
284 let projected: Vec<JsonValue> = arr
285 .iter()
286 .filter_map(|item| {
287 if let JsonValue::Object(obj) = item {
288 self.project_json_object(obj).ok()
289 } else {
290 Some(item.clone())
291 }
292 })
293 .collect();
294
295 Ok(JsonValue::Array(projected))
296 }
297}
298
299/// Result projector - high-level result transformation.
300pub struct ResultProjector {
301 mapper: ProjectionMapper,
302}
303
304impl ResultProjector {
305 /// Create new result projector from field names (no aliases).
306 #[must_use]
307 pub fn new(fields: Vec<String>) -> Self {
308 Self {
309 mapper: ProjectionMapper::new(fields),
310 }
311 }
312
313 /// Create new result projector with field mappings (supports aliases).
314 #[must_use]
315 pub const fn with_mappings(fields: Vec<FieldMapping>) -> Self {
316 Self {
317 mapper: ProjectionMapper::with_mappings(fields),
318 }
319 }
320
321 /// Set `__typename` to include in all projected objects.
322 ///
323 /// Per GraphQL spec §2.7, `__typename` returns the name of the object type.
324 /// This should be called when the client requests `__typename` in the selection set.
325 #[must_use]
326 pub fn with_typename(mut self, typename: impl Into<String>) -> Self {
327 self.mapper = self.mapper.with_typename(typename);
328 self
329 }
330
331 /// Configure typename injection from the query selection set.
332 ///
333 /// Inspects the root selection's nested fields for `__typename`. If found,
334 /// enables typename injection via [`with_typename`](Self::with_typename).
335 #[must_use]
336 pub fn configure_typename_from_selections(
337 self,
338 selections: &[FieldSelection],
339 entity_type: &str,
340 ) -> Self {
341 let wants_typename = selections
342 .first()
343 .is_some_and(|root| root.nested_fields.iter().any(|f| f.name == "__typename"));
344 if wants_typename {
345 self.with_typename(entity_type)
346 } else {
347 self
348 }
349 }
350
351 /// Enable federation mode: `__typename` is always injected regardless of selection set.
352 ///
353 /// Used by the `_entities` federation resolver where the gateway always expects
354 /// `__typename` in entity results.
355 #[must_use]
356 pub fn with_federation_mode(mut self, enabled: bool) -> Self {
357 self.mapper = self.mapper.with_federation_mode(enabled);
358 self
359 }
360
361 /// Project database results to GraphQL response.
362 ///
363 /// # Arguments
364 ///
365 /// * `results` - Database results as JSONB values (borrowed; not mutated).
366 /// * `is_list` - Whether the query returns a list.
367 ///
368 /// # Returns
369 ///
370 /// A freshly-allocated GraphQL-compatible JSON response. The projector
371 /// **never aliases the input slice**: each field of every `JsonbValue` is
372 /// cloned out into a new `serde_json::Value` tree (see F029 — ownership
373 /// contract is on `JsonbValue` itself).
374 ///
375 /// # Errors
376 ///
377 /// Returns error if projection fails.
378 pub fn project_results(&self, results: &[JsonbValue], is_list: bool) -> Result<JsonValue> {
379 if is_list {
380 // Project array of results
381 let projected: Result<Vec<JsonValue>> =
382 results.iter().map(|r| self.mapper.project(r)).collect();
383
384 Ok(JsonValue::Array(projected?))
385 } else {
386 // Project single result
387 if let Some(first) = results.first() {
388 self.mapper.project(first)
389 } else {
390 Ok(JsonValue::Null)
391 }
392 }
393 }
394
395 /// Wrap result in GraphQL data envelope.
396 ///
397 /// # Arguments
398 ///
399 /// * `result` - Projected result
400 /// * `query_name` - Query operation name
401 ///
402 /// # Returns
403 ///
404 /// GraphQL response with `{ "data": { "queryName": result } }` structure
405 #[must_use]
406 pub fn wrap_in_data_envelope(result: JsonValue, query_name: &str) -> JsonValue {
407 let mut data = Map::new();
408 data.insert(query_name.to_string(), result);
409
410 let mut response = Map::new();
411 response.insert("data".to_string(), JsonValue::Object(data));
412
413 JsonValue::Object(response)
414 }
415
416 /// Add __typename field to SQL-projected data.
417 ///
418 /// For data that has already been projected at the SQL level, we only need to add
419 /// the `__typename` field in Rust. This is much faster than projecting all fields
420 /// since the SQL already filtered to only requested fields.
421 ///
422 /// # Arguments
423 ///
424 /// * `projected_data` - JSONB data already projected by SQL
425 /// * `typename` - GraphQL type name to add
426 ///
427 /// # Returns
428 ///
429 /// New JSONB value with `__typename` field added
430 ///
431 /// # Example
432 ///
433 /// ```rust
434 /// # use fraiseql_core::runtime::ResultProjector;
435 /// # use fraiseql_core::db::types::JsonbValue;
436 /// # use serde_json::json;
437 /// let projector = ResultProjector::new(vec!["id".to_string(), "name".to_string()]);
438 /// // Database already returned only: { "id": "123", "name": "Alice" }
439 /// let result = projector.add_typename_only(
440 /// &JsonbValue::new(json!({ "id": "123", "name": "Alice" })),
441 /// "User"
442 /// ).unwrap();
443 ///
444 /// // Result: { "id": "123", "name": "Alice", "__typename": "User" }
445 /// assert_eq!(result["__typename"], "User");
446 /// ```
447 ///
448 /// # Errors
449 ///
450 /// Returns [`FraiseQLError::Validation`] if the projected data contains a
451 /// list element that is not a JSON object, making `__typename` injection impossible.
452 pub fn add_typename_only(
453 &self,
454 projected_data: &JsonbValue,
455 typename: &str,
456 ) -> Result<JsonValue> {
457 let value = projected_data.as_value();
458
459 match value {
460 JsonValue::Object(map) => {
461 let mut result = map.clone();
462 result.insert("__typename".to_string(), JsonValue::String(typename.to_string()));
463 Ok(JsonValue::Object(result))
464 },
465 JsonValue::Array(arr) => {
466 let updated: Result<Vec<JsonValue>> = arr
467 .iter()
468 .map(|item| {
469 if let JsonValue::Object(obj) = item {
470 let mut result = obj.clone();
471 result.insert(
472 "__typename".to_string(),
473 JsonValue::String(typename.to_string()),
474 );
475 Ok(JsonValue::Object(result))
476 } else {
477 Ok(item.clone())
478 }
479 })
480 .collect();
481 Ok(JsonValue::Array(updated?))
482 },
483 v => Ok(v.clone()),
484 }
485 }
486
487 /// Wrap error in GraphQL error envelope.
488 ///
489 /// # Arguments
490 ///
491 /// * `error` - Error to wrap
492 ///
493 /// # Returns
494 ///
495 /// GraphQL error response with `{ "errors": [...] }` structure
496 #[must_use]
497 pub fn wrap_error(error: &FraiseQLError) -> JsonValue {
498 let mut error_obj = Map::new();
499 error_obj.insert("message".to_string(), JsonValue::String(error.to_string()));
500
501 let mut response = Map::new();
502 response.insert("errors".to_string(), JsonValue::Array(vec![JsonValue::Object(error_obj)]));
503
504 JsonValue::Object(response)
505 }
506}
507
508/// Maximum nesting depth for entity projection.
509///
510/// Mirrors the SQL projection generator's depth cap so the Rust (mutation) and
511/// SQL (query) paths stop recursing at the same level and fall back to returning
512/// the stored sub-blob identically.
513const MAX_ENTITY_PROJECTION_DEPTH: usize = 4;
514
515/// Project an entity-shaped JSONB value into a GraphQL response object, mirroring
516/// the query path's SQL projection exactly.
517///
518/// This is the **single, canonical** entity projector, shared by the mutation
519/// success arm (projecting the returned entity) and the error arm (projecting the
520/// error metadata). It is behaviourally equivalent to the SQL query projection
521/// (`projection_generator::render_field`):
522///
523/// - **output key** = the selection's response key (the camelCase GraphQL surface name, honouring
524/// aliases),
525/// - **source key** = [`to_snake_case`](crate::utils::casing::to_snake_case) of the field name (the
526/// stored JSONB key), with a `camelCase` fallback for legacy metadata that used the surface
527/// casing,
528/// - **single object fields** with a sub-selection are recursed (depth-capped at
529/// `MAX_ENTITY_PROJECTION_DEPTH`),
530/// - **list fields, scalar fields, sub-selection-less object fields and over-depth fields** pass
531/// through their stored value (matching the SQL side's full-sub-blob fallback),
532/// - **`__typename`** is emitted only where the client selected it.
533///
534/// `type_name` is the concrete GraphQL object type of `entity` (resolved by the
535/// caller). `selections` is the result selection set for this level, with inline
536/// `... on T` fragments preserved; an **empty** slice means "no field filtering"
537/// and returns the stored entity unchanged.
538#[must_use]
539pub fn project_entity(
540 entity: &JsonValue,
541 type_name: &str,
542 selections: &[FieldSelection],
543 schema: &CompiledSchema,
544) -> JsonValue {
545 if selections.is_empty() {
546 // No selection set (e.g. the REST/typed path) — no field filtering.
547 return entity.clone();
548 }
549 project_entity_at(entity, type_name, selections, schema, 0)
550}
551
552fn project_entity_at(
553 entity: &JsonValue,
554 type_name: &str,
555 selections: &[FieldSelection],
556 schema: &CompiledSchema,
557 depth: usize,
558) -> JsonValue {
559 let JsonValue::Object(obj) = entity else {
560 // Not an object (e.g. null) — nothing to project.
561 return entity.clone();
562 };
563 let type_def = schema.find_type(type_name);
564 let mut out = Map::new();
565
566 for sel in effective_selections(selections, type_name, schema) {
567 if sel.name == "__typename" {
568 out.insert(sel.response_key().to_string(), JsonValue::String(type_name.to_string()));
569 continue;
570 }
571 let field_def =
572 type_def.and_then(|td| td.fields.iter().find(|f| f.name.as_str() == sel.name));
573 let Some(value) = lookup_source(obj, &sel.name) else {
574 // Absent stored key → omit (matches query/SQL behaviour: absent stays absent).
575 continue;
576 };
577 let projected = project_field_value(value, field_def, &sel.nested_fields, schema, depth);
578 out.insert(sel.response_key().to_string(), projected);
579 }
580
581 JsonValue::Object(out)
582}
583
584/// Project a single field value, recursing into nested object selections.
585fn project_field_value(
586 value: &JsonValue,
587 field_def: Option<&FieldDefinition>,
588 nested: &[FieldSelection],
589 schema: &CompiledSchema,
590 depth: usize,
591) -> JsonValue {
592 // Recurse only into single (non-list) object fields that carry a sub-selection,
593 // within the depth cap — exactly as the SQL projector decides. Everything else
594 // (scalars, lists, sub-selection-less objects, over-depth) returns the stored
595 // value verbatim, matching the SQL full-sub-blob fallback.
596 if depth < MAX_ENTITY_PROJECTION_DEPTH && !nested.is_empty() {
597 if let Some(fd) = field_def {
598 if !fd.field_type.is_scalar() && !fd.field_type.is_list() {
599 if let Some(child_type) = fd.field_type.type_name() {
600 match value {
601 JsonValue::Object(_) => {
602 return project_entity_at(value, child_type, nested, schema, depth + 1);
603 },
604 // The DB sometimes returns a nested object as a JSON string
605 // (when extracted via `->>`). Re-parse and project it.
606 JsonValue::String(s) => {
607 if let Ok(parsed @ JsonValue::Object(_)) =
608 serde_json::from_str::<JsonValue>(s)
609 {
610 return project_entity_at(
611 &parsed,
612 child_type,
613 nested,
614 schema,
615 depth + 1,
616 );
617 }
618 },
619 _ => {},
620 }
621 }
622 }
623 }
624 }
625 value.clone()
626}
627
628/// Look up a field's stored value: canonical `snake_case` key first, then a
629/// `camelCase` fallback for legacy metadata that used the GraphQL surface casing.
630fn lookup_source<'a>(obj: &'a Map<String, JsonValue>, field_name: &str) -> Option<&'a JsonValue> {
631 let snake = to_snake_case(field_name);
632 if let Some(v) = obj.get(&snake) {
633 return Some(v);
634 }
635 let camel = to_camel_case(field_name);
636 if camel != snake {
637 obj.get(&camel)
638 } else {
639 None
640 }
641}
642
643/// Flatten a selection set for a concrete object type: direct fields, plus the
644/// contents of any inline fragment `... on T` (or on an interface `T` implements).
645fn effective_selections<'a>(
646 selections: &'a [FieldSelection],
647 type_name: &str,
648 schema: &CompiledSchema,
649) -> Vec<&'a FieldSelection> {
650 let mut out = Vec::new();
651 for sel in selections {
652 if let Some(frag_type) = sel.name.strip_prefix("...on ") {
653 let frag_type = frag_type.trim();
654 let applies = frag_type == type_name
655 || schema
656 .find_type(type_name)
657 .is_some_and(|td| td.implements.iter().any(|i| i == frag_type));
658 if applies {
659 out.extend(effective_selections(&sel.nested_fields, type_name, schema));
660 }
661 } else {
662 out.push(sel);
663 }
664 }
665 out
666}