Skip to main content

meta_language/
graphql_adapter.rs

1//! Registry-driven lowering from validated GraphQL operations to query plans.
2
3mod parser;
4mod registry;
5
6use std::collections::BTreeMap;
7
8use crate::configuration::ParseConfiguration;
9use crate::line_index::LineIndex;
10use crate::link_network::LinkNetwork;
11use crate::query_plan::{
12    LoweredQueryPlan, QueryAggregate, QueryAggregateFunction, QueryComparisonOperator, QueryFilter,
13    QueryOperation, QueryOrder, QueryPlan, QuerySortDirection, QuerySourceEvidence, QueryValue,
14    attach_plan_links,
15};
16use crate::source::{ByteRange, SourceSpan};
17
18use parser::{ByteSpan, Field, ValueKind, ValueNode};
19pub use registry::{
20    GraphQlAdapterError, GraphQlArgumentRole, GraphQlOperationType, GraphQlRootMapping,
21    GraphQlSchemaRegistry,
22};
23
24/// Validates and lowers exactly one GraphQL operation/root field.
25pub fn lower_graphql(
26    source: &str,
27    registry: &GraphQlSchemaRegistry,
28) -> Result<LoweredQueryPlan, GraphQlAdapterError> {
29    let mut network = LinkNetwork::parse(source, "GraphQL", ParseConfiguration::default());
30    if !network.verify_full_match(None).is_clean() {
31        return Err(GraphQlAdapterError::new(
32            "GraphQL CST validation failed; semantic lowering was not attempted",
33        ));
34    }
35    let document = parser::parse(source)?;
36    let [root] = document.root_fields.as_slice() else {
37        return Err(GraphQlAdapterError::new(
38            "exactly one GraphQL root field is required",
39        ));
40    };
41    if root.alias.is_some() {
42        return Err(GraphQlAdapterError::new(
43            "GraphQL root aliases are not represented by the query plan",
44        ));
45    }
46    let mapping = registry.root(document.operation, &root.name)?;
47    let mut plan = QueryPlan::new(mapping.operation, &mapping.resource);
48    let lines = LineIndex::new(source);
49    plan.source_evidence
50        .push(evidence(&lines, "root", root.span));
51    lower_arguments(&lines, root, mapping, &mut plan)?;
52    lower_selection(&lines, root, mapping, &mut plan)?;
53    validate_plan(&plan)?;
54    let root_link = attach_plan_links(&mut network, &plan, "GraphQL");
55    Ok(LoweredQueryPlan::new(plan, network, root_link))
56}
57
58fn lower_arguments(
59    lines: &LineIndex,
60    root: &Field,
61    mapping: &GraphQlRootMapping,
62    plan: &mut QueryPlan,
63) -> Result<(), GraphQlAdapterError> {
64    for argument in &root.arguments {
65        let role = mapping.arguments.get(&argument.name).ok_or_else(|| {
66            GraphQlAdapterError::new(format!("unmapped GraphQL argument {:?}", argument.name))
67        })?;
68        match role {
69            GraphQlArgumentRole::Filter => {
70                plan.filter = Some(lower_filter(&argument.value, mapping)?);
71            }
72            GraphQlArgumentRole::Order => {
73                plan.order = lower_order(&argument.value, mapping)?;
74            }
75            GraphQlArgumentRole::Limit => {
76                plan.limit = Some(non_negative_integer(&argument.value, "limit")?);
77            }
78            GraphQlArgumentRole::Offset => {
79                plan.offset = Some(non_negative_integer(&argument.value, "offset")?);
80            }
81            GraphQlArgumentRole::Group => {
82                plan.group_by = lower_group(&argument.value, mapping)?;
83            }
84            GraphQlArgumentRole::MutationInput => {
85                plan.mutation = lower_mutation(&argument.value, mapping)?;
86            }
87        }
88        plan.source_evidence.push(evidence(
89            lines,
90            format!("argument:{}", role.as_str()),
91            argument.value.span,
92        ));
93    }
94    Ok(())
95}
96
97fn lower_selection(
98    lines: &LineIndex,
99    root: &Field,
100    mapping: &GraphQlRootMapping,
101    plan: &mut QueryPlan,
102) -> Result<(), GraphQlAdapterError> {
103    if root.selection.is_empty() {
104        return Err(GraphQlAdapterError::new(
105            "a mapped GraphQL root must have a projection selection",
106        ));
107    }
108    for field in &root.selection {
109        if !field.selection.is_empty() {
110            return Err(GraphQlAdapterError::new(format!(
111                "nested projection field {:?} has no explicit mapping",
112                field.name
113            )));
114        }
115        if let Some(function) = mapping.aggregates.get(&field.name) {
116            let aggregate_field = aggregate_field(field, mapping)?;
117            if *function != QueryAggregateFunction::Count && aggregate_field.is_none() {
118                return Err(GraphQlAdapterError::new(format!(
119                    "aggregate {:?} requires a mapped field argument",
120                    field.name
121                )));
122            }
123            plan.aggregates.push(QueryAggregate {
124                function: *function,
125                field: aggregate_field,
126                alias: field.alias.clone(),
127            });
128            plan.source_evidence.push(evidence(
129                lines,
130                format!("aggregate:{}", function.as_str()),
131                field.span,
132            ));
133            continue;
134        }
135        if !field.arguments.is_empty() {
136            return Err(GraphQlAdapterError::new(format!(
137                "non-aggregate projection {:?} cannot have arguments",
138                field.name
139            )));
140        }
141        if field.alias.is_some() {
142            return Err(GraphQlAdapterError::new(format!(
143                "projection alias on {:?} is not represented by the query plan",
144                field.name
145            )));
146        }
147        let canonical = mapping.mapped_field(&field.name)?;
148        if !plan.projection.contains(&canonical) {
149            plan.projection.push(canonical.clone());
150        }
151        plan.source_evidence.push(evidence(
152            lines,
153            format!("projection:{canonical}"),
154            field.span,
155        ));
156    }
157    Ok(())
158}
159
160fn aggregate_field(
161    field: &Field,
162    mapping: &GraphQlRootMapping,
163) -> Result<Option<String>, GraphQlAdapterError> {
164    match field.arguments.as_slice() {
165        [] => Ok(None),
166        [argument] if argument.name == "field" => {
167            Ok(Some(mapping.mapped_symbol(symbol(&argument.value)?)?))
168        }
169        _ => Err(GraphQlAdapterError::new(format!(
170            "aggregate {:?} accepts only an optional field argument",
171            field.name
172        ))),
173    }
174}
175
176fn lower_filter(
177    value: &ValueNode,
178    mapping: &GraphQlRootMapping,
179) -> Result<QueryFilter, GraphQlAdapterError> {
180    let ValueKind::Object(fields) = &value.value else {
181        return Err(GraphQlAdapterError::new("filter arguments must be objects"));
182    };
183    let mut clauses = Vec::new();
184    for (name, value) in fields {
185        match name.as_str() {
186            "and" => clauses.push(QueryFilter::And(filter_list(value, mapping)?)),
187            "or" => clauses.push(QueryFilter::Or(filter_list(value, mapping)?)),
188            "not" => clauses.push(QueryFilter::Not(Box::new(lower_filter(value, mapping)?))),
189            _ => clauses.extend(field_filter(name, value, mapping)?),
190        }
191    }
192    collapse_and(clauses, "empty filter objects are unsupported")
193}
194
195fn filter_list(
196    value: &ValueNode,
197    mapping: &GraphQlRootMapping,
198) -> Result<Vec<QueryFilter>, GraphQlAdapterError> {
199    let ValueKind::List(values) = &value.value else {
200        return Err(GraphQlAdapterError::new(
201            "GraphQL and/or filter values must be lists",
202        ));
203    };
204    if values.is_empty() {
205        return Err(GraphQlAdapterError::new(
206            "GraphQL and/or filter lists must not be empty",
207        ));
208    }
209    values
210        .iter()
211        .map(|value| lower_filter(value, mapping))
212        .collect()
213}
214
215fn field_filter(
216    source_field: &str,
217    value: &ValueNode,
218    mapping: &GraphQlRootMapping,
219) -> Result<Vec<QueryFilter>, GraphQlAdapterError> {
220    let field = mapping.mapped_field(source_field)?;
221    let ValueKind::Object(operators) = &value.value else {
222        return Ok(vec![QueryFilter::Compare {
223            field,
224            operator: QueryComparisonOperator::Equal,
225            value: query_value(value)?,
226        }]);
227    };
228    if operators.is_empty() {
229        return Err(GraphQlAdapterError::new(
230            "empty comparison objects are unsupported",
231        ));
232    }
233    operators
234        .iter()
235        .map(|(name, value)| {
236            let operator = QueryComparisonOperator::from_graphql_key(name).ok_or_else(|| {
237                GraphQlAdapterError::new(format!("unsupported filter operator {name:?}"))
238            })?;
239            if operator == QueryComparisonOperator::IsNull
240                && !matches!(value.value, ValueKind::Boolean(_))
241            {
242                return Err(GraphQlAdapterError::new("isNull requires a boolean value"));
243            }
244            if matches!(
245                operator,
246                QueryComparisonOperator::In | QueryComparisonOperator::NotIn
247            ) && !matches!(value.value, ValueKind::List(_))
248            {
249                return Err(GraphQlAdapterError::new("in/notIn requires a list value"));
250            }
251            Ok(QueryFilter::Compare {
252                field: field.clone(),
253                operator,
254                value: query_value(value)?,
255            })
256        })
257        .collect()
258}
259
260fn collapse_and(
261    mut clauses: Vec<QueryFilter>,
262    empty_message: &str,
263) -> Result<QueryFilter, GraphQlAdapterError> {
264    match clauses.len() {
265        0 => Err(GraphQlAdapterError::new(empty_message)),
266        1 => Ok(clauses.remove(0)),
267        _ => Ok(QueryFilter::And(clauses)),
268    }
269}
270
271fn lower_order(
272    value: &ValueNode,
273    mapping: &GraphQlRootMapping,
274) -> Result<Vec<QueryOrder>, GraphQlAdapterError> {
275    let entries = match &value.value {
276        ValueKind::List(entries) => entries.as_slice(),
277        ValueKind::Object(_) => std::slice::from_ref(value),
278        _ => {
279            return Err(GraphQlAdapterError::new(
280                "order arguments must be objects or lists of objects",
281            ));
282        }
283    };
284    if entries.is_empty() {
285        return Err(GraphQlAdapterError::new("order lists must not be empty"));
286    }
287    entries
288        .iter()
289        .map(|entry| lower_order_entry(entry, mapping))
290        .collect()
291}
292
293fn lower_order_entry(
294    value: &ValueNode,
295    mapping: &GraphQlRootMapping,
296) -> Result<QueryOrder, GraphQlAdapterError> {
297    let ValueKind::Object(fields) = &value.value else {
298        return Err(GraphQlAdapterError::new(
299            "each order entry must be an object",
300        ));
301    };
302    let field_value = object_field(fields, "field")?;
303    let direction_value = object_field(fields, "direction")?;
304    if fields.len() != 2 {
305        return Err(GraphQlAdapterError::new(
306            "order entries support only field and direction",
307        ));
308    }
309    Ok(QueryOrder {
310        field: mapping.mapped_symbol(symbol(field_value)?)?,
311        direction: direction(direction_value)?,
312    })
313}
314
315fn lower_group(
316    value: &ValueNode,
317    mapping: &GraphQlRootMapping,
318) -> Result<Vec<String>, GraphQlAdapterError> {
319    let values = match &value.value {
320        ValueKind::List(values) => values.as_slice(),
321        ValueKind::Enum(_) | ValueKind::String(_) => std::slice::from_ref(value),
322        _ => return Err(GraphQlAdapterError::new("group arguments must name fields")),
323    };
324    if values.is_empty() {
325        return Err(GraphQlAdapterError::new("group lists must not be empty"));
326    }
327    values
328        .iter()
329        .map(|value| mapping.mapped_symbol(symbol(value)?))
330        .collect()
331}
332
333fn lower_mutation(
334    value: &ValueNode,
335    mapping: &GraphQlRootMapping,
336) -> Result<BTreeMap<String, QueryValue>, GraphQlAdapterError> {
337    let ValueKind::Object(fields) = &value.value else {
338        return Err(GraphQlAdapterError::new(
339            "mutation input arguments must be objects",
340        ));
341    };
342    if fields.is_empty() {
343        return Err(GraphQlAdapterError::new(
344            "mutation inputs must not be empty",
345        ));
346    }
347    fields
348        .iter()
349        .map(|(field, value)| Ok((mapping.mapped_field(field)?, query_value(value)?)))
350        .collect()
351}
352
353fn query_value(value: &ValueNode) -> Result<QueryValue, GraphQlAdapterError> {
354    match &value.value {
355        ValueKind::Null => Ok(QueryValue::Null),
356        ValueKind::Boolean(value) => Ok(QueryValue::Boolean(*value)),
357        ValueKind::Integer(value) => safe_integer(*value).map(QueryValue::Integer),
358        ValueKind::Float(value) => Ok(QueryValue::Float(*value)),
359        ValueKind::String(value) | ValueKind::Enum(value) => Ok(QueryValue::String(value.clone())),
360        ValueKind::List(values) => values
361            .iter()
362            .map(query_value)
363            .collect::<Result<Vec<_>, _>>()
364            .map(QueryValue::List),
365        ValueKind::Object(_) => Err(GraphQlAdapterError::new(
366            "nested GraphQL input objects require explicit field mappings",
367        )),
368    }
369}
370
371fn non_negative_integer(value: &ValueNode, role: &str) -> Result<u64, GraphQlAdapterError> {
372    let ValueKind::Integer(value) = value.value else {
373        return Err(GraphQlAdapterError::new(format!(
374            "GraphQL {role} must be a non-negative integer"
375        )));
376    };
377    safe_integer(value).and_then(|value| {
378        u64::try_from(value).map_err(|_| {
379            GraphQlAdapterError::new(format!("GraphQL {role} must be a non-negative integer"))
380        })
381    })
382}
383
384fn safe_integer(value: i64) -> Result<i64, GraphQlAdapterError> {
385    const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991;
386    if (-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&value) {
387        Ok(value)
388    } else {
389        Err(GraphQlAdapterError::new(
390            "GraphQL integer is outside the cross-language safe range",
391        ))
392    }
393}
394
395fn symbol(value: &ValueNode) -> Result<&str, GraphQlAdapterError> {
396    match &value.value {
397        ValueKind::Enum(value) | ValueKind::String(value) => Ok(value),
398        _ => Err(GraphQlAdapterError::new(
399            "canonical field references must be GraphQL enums or strings",
400        )),
401    }
402}
403
404fn direction(value: &ValueNode) -> Result<QuerySortDirection, GraphQlAdapterError> {
405    match symbol(value)?.to_ascii_lowercase().as_str() {
406        "asc" => Ok(QuerySortDirection::Ascending),
407        "desc" => Ok(QuerySortDirection::Descending),
408        other => Err(GraphQlAdapterError::new(format!(
409            "unsupported sort direction {other:?}"
410        ))),
411    }
412}
413
414fn object_field<'a>(
415    fields: &'a [(String, ValueNode)],
416    name: &str,
417) -> Result<&'a ValueNode, GraphQlAdapterError> {
418    fields
419        .iter()
420        .find_map(|(field, value)| (field == name).then_some(value))
421        .ok_or_else(|| GraphQlAdapterError::new(format!("missing object field {name:?}")))
422}
423
424fn validate_plan(plan: &QueryPlan) -> Result<(), GraphQlAdapterError> {
425    if plan.projection.is_empty() && plan.aggregates.is_empty() {
426        return Err(GraphQlAdapterError::new(
427            "a query plan requires a projection or aggregate",
428        ));
429    }
430    match plan.operation {
431        QueryOperation::Insert | QueryOperation::Update if plan.mutation.is_empty() => Err(
432            GraphQlAdapterError::new("insert/update mutations require a mapped mutation input"),
433        ),
434        QueryOperation::Select | QueryOperation::Delete if !plan.mutation.is_empty() => {
435            Err(GraphQlAdapterError::new(
436                "select/delete operations cannot contain mutation assignments",
437            ))
438        }
439        _ => Ok(()),
440    }
441}
442
443fn evidence(lines: &LineIndex, role: impl Into<String>, span: ByteSpan) -> QuerySourceEvidence {
444    QuerySourceEvidence::new(role, source_span(lines, span))
445}
446
447fn source_span(lines: &LineIndex, span: ByteSpan) -> SourceSpan {
448    SourceSpan::new(
449        ByteRange::new(span.start, span.end),
450        lines.byte_point(span.start),
451        lines.byte_point(span.end),
452    )
453}