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::link_network::{Link, LinkId, LinkMetadata, LinkNetwork, LinkType};
10use crate::query_plan::{
11    QueryAggregate, QueryAggregateFunction, QueryComparisonOperator, QueryFilter, QueryOperation,
12    QueryOrder, QueryPlan, QuerySortDirection, QuerySourceEvidence, QueryValue,
13};
14use crate::source::{ByteRange, Point, SourceSpan};
15
16use parser::{ByteSpan, Field, ValueKind, ValueNode};
17pub use registry::{
18    GraphQlAdapterError, GraphQlArgumentRole, GraphQlOperationType, GraphQlRootMapping,
19    GraphQlSchemaRegistry,
20};
21
22/// Query plan and its provenance-connected links network.
23#[derive(Clone, Debug, PartialEq)]
24pub struct LoweredQueryPlan {
25    plan: QueryPlan,
26    network: LinkNetwork,
27    root_link: LinkId,
28}
29
30impl LoweredQueryPlan {
31    /// Canonical executable plan.
32    #[must_use]
33    pub const fn plan(&self) -> &QueryPlan {
34        &self.plan
35    }
36
37    /// Original GraphQL CST plus attached semantic plan links.
38    #[must_use]
39    pub const fn network(&self) -> &LinkNetwork {
40        &self.network
41    }
42
43    /// Root semantic link for the canonical plan.
44    #[must_use]
45    pub const fn root_link(&self) -> LinkId {
46        self.root_link
47    }
48
49    /// Splits the result into its public components.
50    #[must_use]
51    pub fn into_parts(self) -> (QueryPlan, LinkNetwork, LinkId) {
52        (self.plan, self.network, self.root_link)
53    }
54}
55
56/// Validates and lowers exactly one GraphQL operation/root field.
57pub fn lower_graphql(
58    source: &str,
59    registry: &GraphQlSchemaRegistry,
60) -> Result<LoweredQueryPlan, GraphQlAdapterError> {
61    let mut network = LinkNetwork::parse(source, "GraphQL", ParseConfiguration::default());
62    if !network.verify_full_match(None).is_clean() {
63        return Err(GraphQlAdapterError::new(
64            "GraphQL CST validation failed; semantic lowering was not attempted",
65        ));
66    }
67    let document = parser::parse(source)?;
68    let [root] = document.root_fields.as_slice() else {
69        return Err(GraphQlAdapterError::new(
70            "exactly one GraphQL root field is required",
71        ));
72    };
73    if root.alias.is_some() {
74        return Err(GraphQlAdapterError::new(
75            "GraphQL root aliases are not represented by the query plan",
76        ));
77    }
78    let mapping = registry.root(document.operation, &root.name)?;
79    let mut plan = QueryPlan::new(mapping.operation, &mapping.resource);
80    plan.source_evidence
81        .push(evidence(source, "root", root.span));
82    lower_arguments(source, root, mapping, &mut plan)?;
83    lower_selection(source, root, mapping, &mut plan)?;
84    validate_plan(&plan)?;
85    let root_link = attach_plan_links(&mut network, &plan);
86    Ok(LoweredQueryPlan {
87        plan,
88        network,
89        root_link,
90    })
91}
92
93fn lower_arguments(
94    source: &str,
95    root: &Field,
96    mapping: &GraphQlRootMapping,
97    plan: &mut QueryPlan,
98) -> Result<(), GraphQlAdapterError> {
99    for argument in &root.arguments {
100        let role = mapping.arguments.get(&argument.name).ok_or_else(|| {
101            GraphQlAdapterError::new(format!("unmapped GraphQL argument {:?}", argument.name))
102        })?;
103        match role {
104            GraphQlArgumentRole::Filter => {
105                plan.filter = Some(lower_filter(&argument.value, mapping)?);
106            }
107            GraphQlArgumentRole::Order => {
108                plan.order = lower_order(&argument.value, mapping)?;
109            }
110            GraphQlArgumentRole::Limit => {
111                plan.limit = Some(non_negative_integer(&argument.value, "limit")?);
112            }
113            GraphQlArgumentRole::Offset => {
114                plan.offset = Some(non_negative_integer(&argument.value, "offset")?);
115            }
116            GraphQlArgumentRole::Group => {
117                plan.group_by = lower_group(&argument.value, mapping)?;
118            }
119            GraphQlArgumentRole::MutationInput => {
120                plan.mutation = lower_mutation(&argument.value, mapping)?;
121            }
122        }
123        plan.source_evidence.push(evidence(
124            source,
125            format!("argument:{}", role.as_str()),
126            argument.value.span,
127        ));
128    }
129    Ok(())
130}
131
132fn lower_selection(
133    source: &str,
134    root: &Field,
135    mapping: &GraphQlRootMapping,
136    plan: &mut QueryPlan,
137) -> Result<(), GraphQlAdapterError> {
138    if root.selection.is_empty() {
139        return Err(GraphQlAdapterError::new(
140            "a mapped GraphQL root must have a projection selection",
141        ));
142    }
143    for field in &root.selection {
144        if !field.selection.is_empty() {
145            return Err(GraphQlAdapterError::new(format!(
146                "nested projection field {:?} has no explicit mapping",
147                field.name
148            )));
149        }
150        if let Some(function) = mapping.aggregates.get(&field.name) {
151            let aggregate_field = aggregate_field(field, mapping)?;
152            if *function != QueryAggregateFunction::Count && aggregate_field.is_none() {
153                return Err(GraphQlAdapterError::new(format!(
154                    "aggregate {:?} requires a mapped field argument",
155                    field.name
156                )));
157            }
158            plan.aggregates.push(QueryAggregate {
159                function: *function,
160                field: aggregate_field,
161                alias: field.alias.clone(),
162            });
163            plan.source_evidence.push(evidence(
164                source,
165                format!("aggregate:{}", function.as_str()),
166                field.span,
167            ));
168            continue;
169        }
170        if !field.arguments.is_empty() {
171            return Err(GraphQlAdapterError::new(format!(
172                "non-aggregate projection {:?} cannot have arguments",
173                field.name
174            )));
175        }
176        if field.alias.is_some() {
177            return Err(GraphQlAdapterError::new(format!(
178                "projection alias on {:?} is not represented by the query plan",
179                field.name
180            )));
181        }
182        let canonical = mapping.mapped_field(&field.name)?;
183        if !plan.projection.contains(&canonical) {
184            plan.projection.push(canonical.clone());
185        }
186        plan.source_evidence.push(evidence(
187            source,
188            format!("projection:{canonical}"),
189            field.span,
190        ));
191    }
192    Ok(())
193}
194
195fn aggregate_field(
196    field: &Field,
197    mapping: &GraphQlRootMapping,
198) -> Result<Option<String>, GraphQlAdapterError> {
199    match field.arguments.as_slice() {
200        [] => Ok(None),
201        [argument] if argument.name == "field" => {
202            Ok(Some(mapping.mapped_symbol(symbol(&argument.value)?)?))
203        }
204        _ => Err(GraphQlAdapterError::new(format!(
205            "aggregate {:?} accepts only an optional field argument",
206            field.name
207        ))),
208    }
209}
210
211fn lower_filter(
212    value: &ValueNode,
213    mapping: &GraphQlRootMapping,
214) -> Result<QueryFilter, GraphQlAdapterError> {
215    let ValueKind::Object(fields) = &value.value else {
216        return Err(GraphQlAdapterError::new("filter arguments must be objects"));
217    };
218    let mut clauses = Vec::new();
219    for (name, value) in fields {
220        match name.as_str() {
221            "and" => clauses.push(QueryFilter::And(filter_list(value, mapping)?)),
222            "or" => clauses.push(QueryFilter::Or(filter_list(value, mapping)?)),
223            "not" => clauses.push(QueryFilter::Not(Box::new(lower_filter(value, mapping)?))),
224            _ => clauses.extend(field_filter(name, value, mapping)?),
225        }
226    }
227    collapse_and(clauses, "empty filter objects are unsupported")
228}
229
230fn filter_list(
231    value: &ValueNode,
232    mapping: &GraphQlRootMapping,
233) -> Result<Vec<QueryFilter>, GraphQlAdapterError> {
234    let ValueKind::List(values) = &value.value else {
235        return Err(GraphQlAdapterError::new(
236            "GraphQL and/or filter values must be lists",
237        ));
238    };
239    if values.is_empty() {
240        return Err(GraphQlAdapterError::new(
241            "GraphQL and/or filter lists must not be empty",
242        ));
243    }
244    values
245        .iter()
246        .map(|value| lower_filter(value, mapping))
247        .collect()
248}
249
250fn field_filter(
251    source_field: &str,
252    value: &ValueNode,
253    mapping: &GraphQlRootMapping,
254) -> Result<Vec<QueryFilter>, GraphQlAdapterError> {
255    let field = mapping.mapped_field(source_field)?;
256    let ValueKind::Object(operators) = &value.value else {
257        return Ok(vec![QueryFilter::Compare {
258            field,
259            operator: QueryComparisonOperator::Equal,
260            value: query_value(value)?,
261        }]);
262    };
263    if operators.is_empty() {
264        return Err(GraphQlAdapterError::new(
265            "empty comparison objects are unsupported",
266        ));
267    }
268    operators
269        .iter()
270        .map(|(name, value)| {
271            let operator = QueryComparisonOperator::from_graphql_key(name).ok_or_else(|| {
272                GraphQlAdapterError::new(format!("unsupported filter operator {name:?}"))
273            })?;
274            if operator == QueryComparisonOperator::IsNull
275                && !matches!(value.value, ValueKind::Boolean(_))
276            {
277                return Err(GraphQlAdapterError::new("isNull requires a boolean value"));
278            }
279            if matches!(
280                operator,
281                QueryComparisonOperator::In | QueryComparisonOperator::NotIn
282            ) && !matches!(value.value, ValueKind::List(_))
283            {
284                return Err(GraphQlAdapterError::new("in/notIn requires a list value"));
285            }
286            Ok(QueryFilter::Compare {
287                field: field.clone(),
288                operator,
289                value: query_value(value)?,
290            })
291        })
292        .collect()
293}
294
295fn collapse_and(
296    mut clauses: Vec<QueryFilter>,
297    empty_message: &str,
298) -> Result<QueryFilter, GraphQlAdapterError> {
299    match clauses.len() {
300        0 => Err(GraphQlAdapterError::new(empty_message)),
301        1 => Ok(clauses.remove(0)),
302        _ => Ok(QueryFilter::And(clauses)),
303    }
304}
305
306fn lower_order(
307    value: &ValueNode,
308    mapping: &GraphQlRootMapping,
309) -> Result<Vec<QueryOrder>, GraphQlAdapterError> {
310    let entries = match &value.value {
311        ValueKind::List(entries) => entries.as_slice(),
312        ValueKind::Object(_) => std::slice::from_ref(value),
313        _ => {
314            return Err(GraphQlAdapterError::new(
315                "order arguments must be objects or lists of objects",
316            ));
317        }
318    };
319    if entries.is_empty() {
320        return Err(GraphQlAdapterError::new("order lists must not be empty"));
321    }
322    entries
323        .iter()
324        .map(|entry| lower_order_entry(entry, mapping))
325        .collect()
326}
327
328fn lower_order_entry(
329    value: &ValueNode,
330    mapping: &GraphQlRootMapping,
331) -> Result<QueryOrder, GraphQlAdapterError> {
332    let ValueKind::Object(fields) = &value.value else {
333        return Err(GraphQlAdapterError::new(
334            "each order entry must be an object",
335        ));
336    };
337    let field_value = object_field(fields, "field")?;
338    let direction_value = object_field(fields, "direction")?;
339    if fields.len() != 2 {
340        return Err(GraphQlAdapterError::new(
341            "order entries support only field and direction",
342        ));
343    }
344    Ok(QueryOrder {
345        field: mapping.mapped_symbol(symbol(field_value)?)?,
346        direction: direction(direction_value)?,
347    })
348}
349
350fn lower_group(
351    value: &ValueNode,
352    mapping: &GraphQlRootMapping,
353) -> Result<Vec<String>, GraphQlAdapterError> {
354    let values = match &value.value {
355        ValueKind::List(values) => values.as_slice(),
356        ValueKind::Enum(_) | ValueKind::String(_) => std::slice::from_ref(value),
357        _ => return Err(GraphQlAdapterError::new("group arguments must name fields")),
358    };
359    if values.is_empty() {
360        return Err(GraphQlAdapterError::new("group lists must not be empty"));
361    }
362    values
363        .iter()
364        .map(|value| mapping.mapped_symbol(symbol(value)?))
365        .collect()
366}
367
368fn lower_mutation(
369    value: &ValueNode,
370    mapping: &GraphQlRootMapping,
371) -> Result<BTreeMap<String, QueryValue>, GraphQlAdapterError> {
372    let ValueKind::Object(fields) = &value.value else {
373        return Err(GraphQlAdapterError::new(
374            "mutation input arguments must be objects",
375        ));
376    };
377    if fields.is_empty() {
378        return Err(GraphQlAdapterError::new(
379            "mutation inputs must not be empty",
380        ));
381    }
382    fields
383        .iter()
384        .map(|(field, value)| Ok((mapping.mapped_field(field)?, query_value(value)?)))
385        .collect()
386}
387
388fn query_value(value: &ValueNode) -> Result<QueryValue, GraphQlAdapterError> {
389    match &value.value {
390        ValueKind::Null => Ok(QueryValue::Null),
391        ValueKind::Boolean(value) => Ok(QueryValue::Boolean(*value)),
392        ValueKind::Integer(value) => safe_integer(*value).map(QueryValue::Integer),
393        ValueKind::Float(value) => Ok(QueryValue::Float(*value)),
394        ValueKind::String(value) | ValueKind::Enum(value) => Ok(QueryValue::String(value.clone())),
395        ValueKind::List(values) => values
396            .iter()
397            .map(query_value)
398            .collect::<Result<Vec<_>, _>>()
399            .map(QueryValue::List),
400        ValueKind::Object(_) => Err(GraphQlAdapterError::new(
401            "nested GraphQL input objects require explicit field mappings",
402        )),
403    }
404}
405
406fn non_negative_integer(value: &ValueNode, role: &str) -> Result<u64, GraphQlAdapterError> {
407    let ValueKind::Integer(value) = value.value else {
408        return Err(GraphQlAdapterError::new(format!(
409            "GraphQL {role} must be a non-negative integer"
410        )));
411    };
412    safe_integer(value).and_then(|value| {
413        u64::try_from(value).map_err(|_| {
414            GraphQlAdapterError::new(format!("GraphQL {role} must be a non-negative integer"))
415        })
416    })
417}
418
419fn safe_integer(value: i64) -> Result<i64, GraphQlAdapterError> {
420    const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991;
421    if (-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&value) {
422        Ok(value)
423    } else {
424        Err(GraphQlAdapterError::new(
425            "GraphQL integer is outside the cross-language safe range",
426        ))
427    }
428}
429
430fn symbol(value: &ValueNode) -> Result<&str, GraphQlAdapterError> {
431    match &value.value {
432        ValueKind::Enum(value) | ValueKind::String(value) => Ok(value),
433        _ => Err(GraphQlAdapterError::new(
434            "canonical field references must be GraphQL enums or strings",
435        )),
436    }
437}
438
439fn direction(value: &ValueNode) -> Result<QuerySortDirection, GraphQlAdapterError> {
440    match symbol(value)?.to_ascii_lowercase().as_str() {
441        "asc" => Ok(QuerySortDirection::Ascending),
442        "desc" => Ok(QuerySortDirection::Descending),
443        other => Err(GraphQlAdapterError::new(format!(
444            "unsupported sort direction {other:?}"
445        ))),
446    }
447}
448
449fn object_field<'a>(
450    fields: &'a [(String, ValueNode)],
451    name: &str,
452) -> Result<&'a ValueNode, GraphQlAdapterError> {
453    fields
454        .iter()
455        .find_map(|(field, value)| (field == name).then_some(value))
456        .ok_or_else(|| GraphQlAdapterError::new(format!("missing object field {name:?}")))
457}
458
459fn validate_plan(plan: &QueryPlan) -> Result<(), GraphQlAdapterError> {
460    if plan.projection.is_empty() && plan.aggregates.is_empty() {
461        return Err(GraphQlAdapterError::new(
462            "a query plan requires a projection or aggregate",
463        ));
464    }
465    match plan.operation {
466        QueryOperation::Insert | QueryOperation::Update if plan.mutation.is_empty() => Err(
467            GraphQlAdapterError::new("insert/update mutations require a mapped mutation input"),
468        ),
469        QueryOperation::Select | QueryOperation::Delete if !plan.mutation.is_empty() => {
470            Err(GraphQlAdapterError::new(
471                "select/delete operations cannot contain mutation assignments",
472            ))
473        }
474        _ => Ok(()),
475    }
476}
477
478fn attach_plan_links(network: &mut LinkNetwork, plan: &QueryPlan) -> LinkId {
479    let cst_links = plan
480        .source_evidence
481        .iter()
482        .map(|evidence| closest_cst(network, evidence.span()))
483        .collect::<Vec<_>>();
484    let plan_concept = network.insert_point("executable-query-plan");
485    let mut references = vec![plan_concept];
486    for (evidence, cst) in plan.source_evidence.iter().zip(cst_links) {
487        let concept = network.insert_point(evidence.role());
488        let mut child_references = vec![concept];
489        if let Some(cst) = cst {
490            child_references.push(cst);
491        }
492        let child = network.insert_dynamic_link(
493            &child_references,
494            LinkMetadata::new()
495                .with_link_type(LinkType::Semantic)
496                .with_named(true)
497                .with_term(evidence.role())
498                .with_language("GraphQL")
499                .with_span(evidence.span()),
500        );
501        references.push(child);
502    }
503    if let Some(cst) = plan
504        .source_evidence
505        .first()
506        .and_then(|evidence| closest_cst(network, evidence.span()))
507    {
508        references.push(cst);
509    }
510    let root_span = plan
511        .source_evidence
512        .first()
513        .map(QuerySourceEvidence::span)
514        .expect("lowered plans always retain root evidence");
515    network.insert_dynamic_link(
516        &references,
517        LinkMetadata::new()
518            .with_link_type(LinkType::Semantic)
519            .with_named(true)
520            .with_term("executable-query-plan")
521            .with_language("GraphQL")
522            .with_span(root_span),
523    )
524}
525
526fn closest_cst(network: &LinkNetwork, span: SourceSpan) -> Option<LinkId> {
527    let target = span.byte_range();
528    network
529        .links()
530        .filter(|link| link.metadata().link_type() == Some(LinkType::Syntax))
531        .filter_map(|link| Some((link, link.metadata().span()?.byte_range())))
532        .filter(|(_, candidate)| {
533            candidate.start() <= target.start() && candidate.end() >= target.end()
534        })
535        .min_by_key(|(_, candidate)| candidate.end() - candidate.start())
536        .map(|(link, _)| Link::id(link))
537}
538
539fn evidence(source: &str, role: impl Into<String>, span: ByteSpan) -> QuerySourceEvidence {
540    QuerySourceEvidence::new(role, source_span(source, span))
541}
542
543fn source_span(source: &str, span: ByteSpan) -> SourceSpan {
544    SourceSpan::new(
545        ByteRange::new(span.start, span.end),
546        point_at(source, span.start),
547        point_at(source, span.end),
548    )
549}
550
551fn point_at(source: &str, offset: usize) -> Point {
552    let prefix = &source[..offset];
553    let row = prefix.bytes().filter(|byte| *byte == b'\n').count();
554    let column = prefix
555        .rsplit_once('\n')
556        .map_or(prefix.len(), |(_, line)| line.len());
557    Point::new(row, column)
558}