Skip to main content

meta_language/graphql_adapter/
registry.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::error::Error;
3use std::fmt;
4
5use serde_json::Value;
6
7use crate::query_plan::{QueryAggregateFunction, QueryOperation};
8
9/// GraphQL operation family used as a registry key.
10#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
11pub enum GraphQlOperationType {
12    /// Read operation (including GraphQL's anonymous shorthand form).
13    Query,
14    /// Mutation operation.
15    Mutation,
16}
17
18impl GraphQlOperationType {
19    /// Canonical registry label.
20    #[must_use]
21    pub const fn as_str(self) -> &'static str {
22        match self {
23            Self::Query => "query",
24            Self::Mutation => "mutation",
25        }
26    }
27
28    fn parse(value: &str) -> Option<Self> {
29        match value {
30            "query" => Some(Self::Query),
31            "mutation" => Some(Self::Mutation),
32            _ => None,
33        }
34    }
35}
36
37/// Semantic role assigned to a GraphQL root-field argument.
38#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
39pub enum GraphQlArgumentRole {
40    /// Boolean filter object.
41    Filter,
42    /// Ordering object or list.
43    Order,
44    /// Non-negative result limit.
45    Limit,
46    /// Non-negative result offset.
47    Offset,
48    /// Grouping field or list of fields.
49    Group,
50    /// Mutation assignment object.
51    MutationInput,
52}
53
54impl GraphQlArgumentRole {
55    /// Canonical registry label.
56    #[must_use]
57    pub const fn as_str(self) -> &'static str {
58        match self {
59            Self::Filter => "filter",
60            Self::Order => "order",
61            Self::Limit => "limit",
62            Self::Offset => "offset",
63            Self::Group => "group",
64            Self::MutationInput => "mutation-input",
65        }
66    }
67
68    fn parse(value: &str) -> Option<Self> {
69        match value {
70            "filter" => Some(Self::Filter),
71            "order" => Some(Self::Order),
72            "limit" => Some(Self::Limit),
73            "offset" => Some(Self::Offset),
74            "group" => Some(Self::Group),
75            "mutation-input" => Some(Self::MutationInput),
76            _ => None,
77        }
78    }
79}
80
81/// One explicit schema/root mapping registered with the adapter.
82#[derive(Clone, Debug, PartialEq, Eq)]
83pub struct GraphQlRootMapping {
84    pub(super) source_operation: GraphQlOperationType,
85    pub(super) source_field: String,
86    pub(super) operation: QueryOperation,
87    pub(super) resource: String,
88    pub(super) arguments: BTreeMap<String, GraphQlArgumentRole>,
89    pub(super) fields: BTreeMap<String, String>,
90    pub(super) aggregates: BTreeMap<String, QueryAggregateFunction>,
91}
92
93impl GraphQlRootMapping {
94    /// Creates a mapping. Field, argument and aggregate names remain
95    /// unsupported until explicitly added.
96    #[must_use]
97    pub fn new(
98        source_operation: GraphQlOperationType,
99        source_field: impl Into<String>,
100        operation: QueryOperation,
101        resource: impl Into<String>,
102    ) -> Self {
103        Self {
104            source_operation,
105            source_field: source_field.into(),
106            operation,
107            resource: resource.into(),
108            arguments: BTreeMap::new(),
109            fields: BTreeMap::new(),
110            aggregates: BTreeMap::new(),
111        }
112    }
113
114    /// Adds a source argument to semantic-role mapping.
115    #[must_use]
116    pub fn with_argument(
117        mut self,
118        source_name: impl Into<String>,
119        role: GraphQlArgumentRole,
120    ) -> Self {
121        self.arguments.insert(source_name.into(), role);
122        self
123    }
124
125    /// Adds a GraphQL field/enum to canonical domain-field mapping.
126    #[must_use]
127    pub fn with_field(
128        mut self,
129        source_name: impl Into<String>,
130        canonical_field: impl Into<String>,
131    ) -> Self {
132        self.fields
133            .insert(source_name.into(), canonical_field.into());
134        self
135    }
136
137    /// Adds a GraphQL selection field to canonical aggregate mapping.
138    #[must_use]
139    pub fn with_aggregate(
140        mut self,
141        source_name: impl Into<String>,
142        aggregate: QueryAggregateFunction,
143    ) -> Self {
144        self.aggregates.insert(source_name.into(), aggregate);
145        self
146    }
147
148    /// GraphQL operation family.
149    #[must_use]
150    pub const fn source_operation(&self) -> GraphQlOperationType {
151        self.source_operation
152    }
153
154    /// GraphQL root field.
155    #[must_use]
156    pub fn source_field(&self) -> &str {
157        &self.source_field
158    }
159
160    /// Canonical operation.
161    #[must_use]
162    pub const fn operation(&self) -> QueryOperation {
163        self.operation
164    }
165
166    /// Canonical resource.
167    #[must_use]
168    pub fn resource(&self) -> &str {
169        &self.resource
170    }
171
172    pub(super) fn mapped_field(&self, source: &str) -> Result<String, GraphQlAdapterError> {
173        self.fields
174            .get(source)
175            .cloned()
176            .ok_or_else(|| GraphQlAdapterError::new(format!("unmapped GraphQL field {source:?}")))
177    }
178
179    pub(super) fn mapped_symbol(&self, source: &str) -> Result<String, GraphQlAdapterError> {
180        let matches = self
181            .fields
182            .iter()
183            .filter(|(name, _)| name.eq_ignore_ascii_case(source))
184            .map(|(_, canonical)| canonical)
185            .collect::<Vec<_>>();
186        match matches.as_slice() {
187            [canonical] => Ok((*canonical).clone()),
188            [] => Err(GraphQlAdapterError::new(format!(
189                "unmapped GraphQL field symbol {source:?}"
190            ))),
191            _ => Err(GraphQlAdapterError::new(format!(
192                "ambiguous GraphQL field symbol {source:?}"
193            ))),
194        }
195    }
196}
197
198/// Explicit registry used by GraphQL lowering. Unknown and duplicate mappings
199/// fail closed rather than falling back to schema-name guesses.
200#[derive(Clone, Debug, Default, PartialEq, Eq)]
201pub struct GraphQlSchemaRegistry {
202    roots: BTreeMap<(GraphQlOperationType, String), GraphQlRootMapping>,
203}
204
205impl GraphQlSchemaRegistry {
206    /// Creates an empty registry.
207    #[must_use]
208    pub const fn new() -> Self {
209        Self {
210            roots: BTreeMap::new(),
211        }
212    }
213
214    /// Registers one root mapping.
215    pub fn register_root(
216        &mut self,
217        mapping: GraphQlRootMapping,
218    ) -> Result<(), GraphQlAdapterError> {
219        validate_mapping(&mapping)?;
220        let key = (mapping.source_operation, mapping.source_field.clone());
221        if self.roots.contains_key(&key) {
222            return Err(GraphQlAdapterError::new(format!(
223                "duplicate GraphQL {} root mapping {:?}",
224                mapping.source_operation.as_str(),
225                mapping.source_field
226            )));
227        }
228        self.roots.insert(key, mapping);
229        Ok(())
230    }
231
232    /// Loads the documented JSON registry shape used by shared parity fixtures.
233    pub fn from_json(value: &Value) -> Result<Self, GraphQlAdapterError> {
234        let roots = value
235            .get("roots")
236            .and_then(Value::as_array)
237            .ok_or_else(|| GraphQlAdapterError::new("registry.roots must be an array"))?;
238        let mut registry = Self::new();
239        for root in roots {
240            let source_operation = required_string(root, "sourceOperation")?;
241            let source_operation =
242                GraphQlOperationType::parse(source_operation).ok_or_else(|| {
243                    GraphQlAdapterError::new(format!(
244                        "unsupported GraphQL operation mapping {source_operation:?}"
245                    ))
246                })?;
247            let operation_label = required_string(root, "operation")?;
248            let operation = QueryOperation::parse(operation_label).ok_or_else(|| {
249                GraphQlAdapterError::new(format!(
250                    "unsupported canonical operation {operation_label:?}"
251                ))
252            })?;
253            let mut mapping = GraphQlRootMapping::new(
254                source_operation,
255                required_string(root, "sourceField")?,
256                operation,
257                required_string(root, "resource")?,
258            );
259            for (name, role) in optional_object(root, "arguments")? {
260                let role = role.as_str().ok_or_else(|| {
261                    GraphQlAdapterError::new("GraphQL argument roles must be strings")
262                })?;
263                let role = GraphQlArgumentRole::parse(role).ok_or_else(|| {
264                    GraphQlAdapterError::new(format!("unsupported argument role {role:?}"))
265                })?;
266                mapping = mapping.with_argument(name, role);
267            }
268            for (name, canonical) in optional_object(root, "fields")? {
269                let canonical = canonical.as_str().ok_or_else(|| {
270                    GraphQlAdapterError::new("canonical GraphQL fields must be strings")
271                })?;
272                mapping = mapping.with_field(name, canonical);
273            }
274            for (name, aggregate) in optional_object(root, "aggregates")? {
275                let aggregate = aggregate.as_str().ok_or_else(|| {
276                    GraphQlAdapterError::new("GraphQL aggregate mappings must be strings")
277                })?;
278                let aggregate = QueryAggregateFunction::parse(aggregate).ok_or_else(|| {
279                    GraphQlAdapterError::new(format!("unsupported aggregate {aggregate:?}"))
280                })?;
281                mapping = mapping.with_aggregate(name, aggregate);
282            }
283            registry.register_root(mapping)?;
284        }
285        Ok(registry)
286    }
287
288    pub(super) fn root(
289        &self,
290        operation: GraphQlOperationType,
291        source_field: &str,
292    ) -> Result<&GraphQlRootMapping, GraphQlAdapterError> {
293        self.roots
294            .get(&(operation, source_field.to_string()))
295            .ok_or_else(|| {
296                GraphQlAdapterError::new(format!(
297                    "unmapped GraphQL {} root field {source_field:?}",
298                    operation.as_str()
299                ))
300            })
301    }
302}
303
304/// Error returned for invalid registries, syntax, or semantic mappings.
305#[derive(Clone, Debug, PartialEq, Eq)]
306pub struct GraphQlAdapterError {
307    message: String,
308}
309
310impl GraphQlAdapterError {
311    pub(super) fn new(message: impl Into<String>) -> Self {
312        Self {
313            message: message.into(),
314        }
315    }
316}
317
318impl fmt::Display for GraphQlAdapterError {
319    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
320        formatter.write_str(&self.message)
321    }
322}
323
324impl Error for GraphQlAdapterError {}
325
326fn validate_mapping(mapping: &GraphQlRootMapping) -> Result<(), GraphQlAdapterError> {
327    if mapping.source_field.is_empty() || mapping.resource.is_empty() {
328        return Err(GraphQlAdapterError::new(
329            "GraphQL root and canonical resource names must not be empty",
330        ));
331    }
332    if mapping.arguments.keys().any(String::is_empty)
333        || mapping.fields.keys().any(String::is_empty)
334        || mapping.fields.values().any(String::is_empty)
335        || mapping.aggregates.keys().any(String::is_empty)
336    {
337        return Err(GraphQlAdapterError::new(
338            "GraphQL mapping names and canonical fields must not be empty",
339        ));
340    }
341    if mapping.source_operation == GraphQlOperationType::Query
342        && mapping.operation != QueryOperation::Select
343    {
344        return Err(GraphQlAdapterError::new(
345            "GraphQL query roots must map to the select operation",
346        ));
347    }
348    if mapping.source_operation == GraphQlOperationType::Mutation
349        && mapping.operation == QueryOperation::Select
350    {
351        return Err(GraphQlAdapterError::new(
352            "GraphQL mutation roots must map to insert, update, or delete",
353        ));
354    }
355    let unique_roles = mapping.arguments.values().copied().collect::<BTreeSet<_>>();
356    if unique_roles.len() != mapping.arguments.len() {
357        return Err(GraphQlAdapterError::new(
358            "a GraphQL root mapping cannot assign the same semantic role twice",
359        ));
360    }
361    let mut case_insensitive_fields = BTreeSet::new();
362    if mapping
363        .fields
364        .keys()
365        .any(|name| !case_insensitive_fields.insert(name.to_ascii_lowercase()))
366    {
367        return Err(GraphQlAdapterError::new(
368            "GraphQL field symbol mappings must not be case-ambiguous",
369        ));
370    }
371    if mapping
372        .aggregates
373        .keys()
374        .any(|name| mapping.fields.contains_key(name))
375    {
376        return Err(GraphQlAdapterError::new(
377            "a GraphQL selection cannot map to both a field and an aggregate",
378        ));
379    }
380    Ok(())
381}
382
383fn required_string<'a>(value: &'a Value, key: &str) -> Result<&'a str, GraphQlAdapterError> {
384    value
385        .get(key)
386        .and_then(Value::as_str)
387        .ok_or_else(|| GraphQlAdapterError::new(format!("registry root {key:?} must be a string")))
388}
389
390fn optional_object<'a>(
391    value: &'a Value,
392    key: &str,
393) -> Result<Vec<(&'a str, &'a Value)>, GraphQlAdapterError> {
394    let Some(value) = value.get(key) else {
395        return Ok(Vec::new());
396    };
397    let object = value.as_object().ok_or_else(|| {
398        GraphQlAdapterError::new(format!("registry root {key:?} must be an object"))
399    })?;
400    Ok(object
401        .iter()
402        .map(|(name, value)| (name.as_str(), value))
403        .collect())
404}