fraiseql_core/runtime/projection.rs
1//! Result projection - transforms JSONB database results to GraphQL responses.
2
3use std::collections::HashSet;
4
5use serde_json::{Map, Value as JsonValue};
6
7use crate::{
8 db::types::JsonbValue,
9 error::{FraiseQLError, Result},
10 graphql::FieldSelection,
11 schema::{CompiledSchema, FieldDefinition},
12};
13
14/// Field mapping for projection with alias support.
15#[derive(Debug, Clone)]
16pub struct FieldMapping {
17 /// JSONB key name (source).
18 pub source: String,
19 /// Output key name (alias if different from source).
20 pub output: String,
21 /// Fallback source key to try when the primary `source` is not found.
22 /// Used for mutation error metadata where the key may be either `camelCase`
23 /// or `snake_case` depending on the backend.
24 pub source_fallback: Option<String>,
25 /// For nested object fields, the typename to add.
26 /// This enables `__typename` to be added recursively to nested objects.
27 pub nested_typename: Option<String>,
28 /// Nested field mappings (for related objects).
29 pub nested_fields: Option<Vec<FieldMapping>>,
30}
31
32impl FieldMapping {
33 /// Create a simple field mapping (no alias).
34 #[must_use]
35 pub fn simple(name: impl Into<String>) -> Self {
36 let name = name.into();
37 Self {
38 source: name.clone(),
39 output: name,
40 source_fallback: None,
41 nested_typename: None,
42 nested_fields: None,
43 }
44 }
45
46 /// Create a field mapping with an alias.
47 #[must_use]
48 pub fn aliased(source: impl Into<String>, alias: impl Into<String>) -> Self {
49 Self {
50 source: source.into(),
51 output: alias.into(),
52 source_fallback: None,
53 nested_typename: None,
54 nested_fields: None,
55 }
56 }
57
58 /// Create a field mapping for a nested object with its own typename.
59 ///
60 /// # Example
61 ///
62 /// ```rust
63 /// # use fraiseql_core::runtime::FieldMapping;
64 /// // For a Post with nested author (User type)
65 /// let mapping = FieldMapping::nested_object("author", "User", vec![
66 /// FieldMapping::simple("id"),
67 /// FieldMapping::simple("name"),
68 /// ]);
69 /// assert_eq!(mapping.source, "author");
70 /// ```
71 #[must_use]
72 pub fn nested_object(
73 name: impl Into<String>,
74 typename: impl Into<String>,
75 fields: Vec<FieldMapping>,
76 ) -> Self {
77 let name = name.into();
78 Self {
79 source: name.clone(),
80 output: name,
81 source_fallback: None,
82 nested_typename: Some(typename.into()),
83 nested_fields: Some(fields),
84 }
85 }
86
87 /// Create an aliased nested object field.
88 #[must_use]
89 pub fn nested_object_aliased(
90 source: impl Into<String>,
91 alias: impl Into<String>,
92 typename: impl Into<String>,
93 fields: Vec<FieldMapping>,
94 ) -> Self {
95 Self {
96 source: source.into(),
97 output: alias.into(),
98 source_fallback: None,
99 nested_typename: Some(typename.into()),
100 nested_fields: Some(fields),
101 }
102 }
103
104 /// Set the typename for a nested object field.
105 #[must_use]
106 pub fn with_nested_typename(mut self, typename: impl Into<String>) -> Self {
107 self.nested_typename = Some(typename.into());
108 self
109 }
110
111 /// Set nested field mappings.
112 #[must_use]
113 pub fn with_nested_fields(mut self, fields: Vec<FieldMapping>) -> Self {
114 self.nested_fields = Some(fields);
115 self
116 }
117}
118
119/// Projection mapper - maps JSONB fields to GraphQL selection set.
120#[derive(Debug, Clone)]
121pub struct ProjectionMapper {
122 /// Fields to project (with optional aliases).
123 pub fields: Vec<FieldMapping>,
124 /// Optional `__typename` value to add to each object.
125 pub typename: Option<String>,
126 /// When `true`, `__typename` is injected unconditionally regardless of selection set.
127 /// Used by federation `_entities` resolver where the gateway always expects `__typename`.
128 pub federation_mode: bool,
129}
130
131impl ProjectionMapper {
132 /// Create new projection mapper from field names (no aliases).
133 #[must_use]
134 pub fn new(fields: Vec<String>) -> Self {
135 Self {
136 fields: fields.into_iter().map(FieldMapping::simple).collect(),
137 typename: None,
138 federation_mode: false,
139 }
140 }
141
142 /// Create new projection mapper with field mappings (supports aliases).
143 #[must_use]
144 pub const fn with_mappings(fields: Vec<FieldMapping>) -> Self {
145 Self {
146 fields,
147 typename: None,
148 federation_mode: false,
149 }
150 }
151
152 /// Set `__typename` to include in projected objects.
153 #[must_use]
154 pub fn with_typename(mut self, typename: impl Into<String>) -> Self {
155 self.typename = Some(typename.into());
156 self
157 }
158
159 /// Enable federation mode: `__typename` is always injected regardless of selection set.
160 #[must_use]
161 pub const fn with_federation_mode(mut self, enabled: bool) -> Self {
162 self.federation_mode = enabled;
163 self
164 }
165
166 /// Project fields from JSONB value.
167 ///
168 /// # Arguments
169 ///
170 /// * `jsonb` - JSONB value from database
171 ///
172 /// # Returns
173 ///
174 /// Projected JSON value with only requested fields (and aliases applied)
175 ///
176 /// # Errors
177 ///
178 /// Returns error if projection fails.
179 pub fn project(&self, jsonb: &JsonbValue) -> Result<JsonValue> {
180 // Extract the inner serde_json::Value
181 let value = jsonb.as_value();
182
183 match value {
184 JsonValue::Object(map) => self.project_json_object(map),
185 JsonValue::Array(arr) => self.project_json_array(arr),
186 v => Ok(v.clone()),
187 }
188 }
189
190 /// Project object fields from JSON object.
191 ///
192 /// Maps source keys to output keys according to the configured `FieldMapping`s,
193 /// injects `__typename` when configured, and recursively projects nested objects
194 /// and arrays.
195 ///
196 /// # Errors
197 ///
198 /// Returns error if nested value projection fails.
199 pub fn project_json_object(
200 &self,
201 map: &serde_json::Map<String, JsonValue>,
202 ) -> Result<JsonValue> {
203 let mut result = Map::new();
204
205 // Add __typename first if configured (GraphQL convention)
206 if let Some(ref typename) = self.typename {
207 result.insert("__typename".to_string(), JsonValue::String(typename.clone()));
208 }
209
210 // Project fields with alias support and optional fallback key
211 for field in &self.fields {
212 let value = map
213 .get(&field.source)
214 .or_else(|| field.source_fallback.as_ref().and_then(|fb| map.get(fb)));
215 if let Some(value) = value {
216 let projected_value = self.project_nested_value(value, field)?;
217 result.insert(field.output.clone(), projected_value);
218 }
219 }
220
221 Ok(JsonValue::Object(result))
222 }
223
224 /// Project a nested value, adding typename if configured.
225 #[allow(clippy::self_only_used_in_recursion)] // Reason: &self required for method dispatch; recursive structure is intentional
226 fn project_nested_value(&self, value: &JsonValue, field: &FieldMapping) -> Result<JsonValue> {
227 match value {
228 JsonValue::Object(obj) => {
229 // If this field has nested typename, add it
230 if let Some(ref typename) = field.nested_typename {
231 let mut result = Map::new();
232 result.insert("__typename".to_string(), JsonValue::String(typename.clone()));
233
234 // If we have nested field mappings, use them; otherwise copy all fields
235 if let Some(ref nested_fields) = field.nested_fields {
236 for nested_field in nested_fields {
237 if let Some(nested_value) = obj.get(&nested_field.source) {
238 let projected =
239 self.project_nested_value(nested_value, nested_field)?;
240 result.insert(nested_field.output.clone(), projected);
241 }
242 }
243 } else {
244 // No specific field mappings - copy all fields from source
245 for (k, v) in obj {
246 result.insert(k.clone(), v.clone());
247 }
248 }
249 Ok(JsonValue::Object(result))
250 } else {
251 // No typename for this nested object - return as-is
252 Ok(value.clone())
253 }
254 },
255 JsonValue::Array(arr) => {
256 // For arrays of objects, add typename to each element
257 if field.nested_typename.is_some() {
258 let projected: Result<Vec<JsonValue>> =
259 arr.iter().map(|item| self.project_nested_value(item, field)).collect();
260 Ok(JsonValue::Array(projected?))
261 } else {
262 Ok(value.clone())
263 }
264 },
265 _ => {
266 // If the value is a JSON string that encodes an object or array
267 // (which happens when the database uses ->>'field' text extraction
268 // instead of ->'field' JSONB extraction), attempt to re-parse it.
269 // Scalar strings (e.g. "hello") won't parse as Object/Array and
270 // are returned unchanged, so this is safe for all field types.
271 if let JsonValue::String(ref s) = *value {
272 if let Ok(parsed @ (JsonValue::Object(_) | JsonValue::Array(_))) =
273 serde_json::from_str::<JsonValue>(s)
274 {
275 return self.project_nested_value(&parsed, field);
276 }
277 }
278 Ok(value.clone())
279 },
280 }
281 }
282
283 /// Project array elements from JSON array.
284 fn project_json_array(&self, arr: &[JsonValue]) -> Result<JsonValue> {
285 let projected: Vec<JsonValue> = arr
286 .iter()
287 .filter_map(|item| {
288 if let JsonValue::Object(obj) = item {
289 self.project_json_object(obj).ok()
290 } else {
291 Some(item.clone())
292 }
293 })
294 .collect();
295
296 Ok(JsonValue::Array(projected))
297 }
298}
299
300/// Result projector - high-level result transformation.
301pub struct ResultProjector {
302 mapper: ProjectionMapper,
303}
304
305impl ResultProjector {
306 /// Create new result projector from field names (no aliases).
307 #[must_use]
308 pub fn new(fields: Vec<String>) -> Self {
309 Self {
310 mapper: ProjectionMapper::new(fields),
311 }
312 }
313
314 /// Create new result projector with field mappings (supports aliases).
315 #[must_use]
316 pub const fn with_mappings(fields: Vec<FieldMapping>) -> Self {
317 Self {
318 mapper: ProjectionMapper::with_mappings(fields),
319 }
320 }
321
322 /// Set `__typename` to include in all projected objects.
323 ///
324 /// Per GraphQL spec §2.7, `__typename` returns the name of the object type.
325 /// This should be called when the client requests `__typename` in the selection set.
326 #[must_use]
327 pub fn with_typename(mut self, typename: impl Into<String>) -> Self {
328 self.mapper = self.mapper.with_typename(typename);
329 self
330 }
331
332 /// Configure typename injection from the query selection set.
333 ///
334 /// Inspects the root selection's nested fields for `__typename`. If found,
335 /// enables typename injection via [`with_typename`](Self::with_typename).
336 #[must_use]
337 pub fn configure_typename_from_selections(
338 self,
339 selections: &[FieldSelection],
340 entity_type: &str,
341 ) -> Self {
342 let wants_typename = selections
343 .first()
344 .is_some_and(|root| root.nested_fields.iter().any(|f| f.name == "__typename"));
345 if wants_typename {
346 self.with_typename(entity_type)
347 } else {
348 self
349 }
350 }
351
352 /// Enable federation mode: `__typename` is always injected regardless of selection set.
353 ///
354 /// Used by the `_entities` federation resolver where the gateway always expects
355 /// `__typename` in entity results.
356 #[must_use]
357 pub fn with_federation_mode(mut self, enabled: bool) -> Self {
358 self.mapper = self.mapper.with_federation_mode(enabled);
359 self
360 }
361
362 /// Project database results to GraphQL response.
363 ///
364 /// # Arguments
365 ///
366 /// * `results` - Database results as JSONB values (borrowed; not mutated).
367 /// * `is_list` - Whether the query returns a list.
368 ///
369 /// # Returns
370 ///
371 /// A freshly-allocated GraphQL-compatible JSON response. The projector
372 /// **never aliases the input slice**: each field of every `JsonbValue` is
373 /// cloned out into a new `serde_json::Value` tree (see F029 — ownership
374 /// contract is on `JsonbValue` itself).
375 ///
376 /// # Errors
377 ///
378 /// Returns error if projection fails.
379 pub fn project_results(&self, results: &[JsonbValue], is_list: bool) -> Result<JsonValue> {
380 if is_list {
381 // Project array of results
382 let projected: Result<Vec<JsonValue>> =
383 results.iter().map(|r| self.mapper.project(r)).collect();
384
385 Ok(JsonValue::Array(projected?))
386 } else {
387 // Project single result
388 if let Some(first) = results.first() {
389 self.mapper.project(first)
390 } else {
391 Ok(JsonValue::Null)
392 }
393 }
394 }
395
396 /// Wrap result in GraphQL data envelope.
397 ///
398 /// # Arguments
399 ///
400 /// * `result` - Projected result
401 /// * `query_name` - Query operation name
402 ///
403 /// # Returns
404 ///
405 /// GraphQL response with `{ "data": { "queryName": result } }` structure
406 #[must_use]
407 pub fn wrap_in_data_envelope(result: JsonValue, query_name: &str) -> JsonValue {
408 let mut data = Map::new();
409 data.insert(query_name.to_string(), result);
410
411 let mut response = Map::new();
412 response.insert("data".to_string(), JsonValue::Object(data));
413
414 JsonValue::Object(response)
415 }
416
417 /// Add __typename field to SQL-projected data.
418 ///
419 /// For data that has already been projected at the SQL level, we only need to add
420 /// the `__typename` field in Rust. This is much faster than projecting all fields
421 /// since the SQL already filtered to only requested fields.
422 ///
423 /// # Arguments
424 ///
425 /// * `projected_data` - JSONB data already projected by SQL
426 /// * `typename` - GraphQL type name to add
427 ///
428 /// # Returns
429 ///
430 /// New JSONB value with `__typename` field added
431 ///
432 /// # Example
433 ///
434 /// ```rust
435 /// # use fraiseql_core::runtime::ResultProjector;
436 /// # use fraiseql_core::db::types::JsonbValue;
437 /// # use serde_json::json;
438 /// let projector = ResultProjector::new(vec!["id".to_string(), "name".to_string()]);
439 /// // Database already returned only: { "id": "123", "name": "Alice" }
440 /// let result = projector.add_typename_only(
441 /// &JsonbValue::new(json!({ "id": "123", "name": "Alice" })),
442 /// "User"
443 /// ).unwrap();
444 ///
445 /// // Result: { "id": "123", "name": "Alice", "__typename": "User" }
446 /// assert_eq!(result["__typename"], "User");
447 /// ```
448 ///
449 /// # Errors
450 ///
451 /// Returns [`FraiseQLError::Validation`] if the projected data contains a
452 /// list element that is not a JSON object, making `__typename` injection impossible.
453 pub fn add_typename_only(
454 &self,
455 projected_data: &JsonbValue,
456 typename: &str,
457 ) -> Result<JsonValue> {
458 let value = projected_data.as_value();
459
460 match value {
461 JsonValue::Object(map) => {
462 let mut result = map.clone();
463 result.insert("__typename".to_string(), JsonValue::String(typename.to_string()));
464 Ok(JsonValue::Object(result))
465 },
466 JsonValue::Array(arr) => {
467 let updated: Result<Vec<JsonValue>> = arr
468 .iter()
469 .map(|item| {
470 if let JsonValue::Object(obj) = item {
471 let mut result = obj.clone();
472 result.insert(
473 "__typename".to_string(),
474 JsonValue::String(typename.to_string()),
475 );
476 Ok(JsonValue::Object(result))
477 } else {
478 Ok(item.clone())
479 }
480 })
481 .collect();
482 Ok(JsonValue::Array(updated?))
483 },
484 v => Ok(v.clone()),
485 }
486 }
487
488 /// Wrap error in GraphQL error envelope.
489 ///
490 /// # Arguments
491 ///
492 /// * `error` - Error to wrap
493 ///
494 /// # Returns
495 ///
496 /// GraphQL error response with `{ "errors": [...] }` structure
497 #[must_use]
498 pub fn wrap_error(error: &FraiseQLError) -> JsonValue {
499 let mut error_obj = Map::new();
500 error_obj.insert("message".to_string(), JsonValue::String(error.to_string()));
501
502 let mut response = Map::new();
503 response.insert("errors".to_string(), JsonValue::Array(vec![JsonValue::Object(error_obj)]));
504
505 JsonValue::Object(response)
506 }
507}
508
509/// Build `FieldMapping`s from a type definition's fields, mapping `camelCase`
510/// source keys (as stored in mutation metadata JSONB) to `snake_case` output keys
511/// (as defined in the GraphQL schema).
512///
513/// Recursively builds nested mappings for `Object` and `List(Object)` fields by
514/// looking up types in the compiled schema. This enables the same `ProjectionMapper`
515/// pipeline used for query results to handle mutation error metadata.
516///
517/// # Arguments
518///
519/// * `fields` — the type's field definitions
520/// * `schema` — compiled schema for resolving nested object types
521/// * `requested` — optional selection filter; when `Some`, only listed fields are included
522/// * `visited` — cycle guard to prevent infinite recursion on self-referencing types
523#[must_use]
524#[allow(clippy::implicit_hasher)] // Reason: internal API; no need for hasher generality
525pub fn build_field_mappings_from_type(
526 fields: &[FieldDefinition],
527 schema: &CompiledSchema,
528 requested: Option<&[String]>,
529 visited: &mut HashSet<String>,
530) -> Vec<FieldMapping> {
531 fields
532 .iter()
533 .filter(|f| requested.is_none_or(|r| r.iter().any(|name| name == f.name.as_str())))
534 .map(|field| {
535 let source = to_camel_case(field.name.as_str());
536 let output = field.name.to_string();
537
538 // Fallback: try snake_case key when camelCase is not found.
539 // Mutation metadata may use either convention depending on the backend.
540 let source_fallback = if source != output {
541 Some(output.clone())
542 } else {
543 None
544 };
545
546 // Resolve the innermost type (unwrap List wrapper if present)
547 let inner = field.field_type.inner_type().unwrap_or(&field.field_type);
548
549 if let Some(type_name) = inner.type_name() {
550 // Object/Enum/Interface reference — try to resolve in schema
551 if let Some(td) = schema.find_type(type_name) {
552 if visited.insert(type_name.to_string()) {
553 let nested =
554 build_field_mappings_from_type(&td.fields, schema, None, visited);
555 visited.remove(type_name);
556 return FieldMapping {
557 source,
558 output,
559 source_fallback,
560 nested_typename: Some(type_name.to_string()),
561 nested_fields: Some(nested),
562 };
563 }
564 // Cycle detected — return without recursion
565 }
566 }
567
568 FieldMapping {
569 source,
570 output,
571 source_fallback,
572 nested_typename: None,
573 nested_fields: None,
574 }
575 })
576 .collect()
577}
578
579/// Convert a `snake_case` field name to `camelCase` for metadata key lookup.
580///
581/// Examples: `"last_activity_date"` → `"lastActivityDate"`,
582/// `"cascade_count"` → `"cascadeCount"`.
583fn to_camel_case(snake: &str) -> String {
584 let mut result = String::with_capacity(snake.len());
585 let mut capitalise_next = false;
586
587 for ch in snake.chars() {
588 if ch == '_' {
589 capitalise_next = true;
590 } else if capitalise_next {
591 result.push(ch.to_ascii_uppercase());
592 capitalise_next = false;
593 } else {
594 result.push(ch);
595 }
596 }
597
598 result
599}