fraiseql-core 2.3.2

Core execution engine for FraiseQL v2 - Compiled GraphQL over SQL
Documentation
//! Query classification — determines operation type for routing.

use std::collections::HashMap;

use super::super::{Executor, QueryType};
use crate::{
    db::traits::DatabaseAdapter,
    error::{FraiseQLError, Result},
    graphql::parse_query,
};

impl<A: DatabaseAdapter> Executor<A> {
    /// Classify a GraphQL query into its operation type for routing.
    ///
    /// This is the first phase of query execution. It determines which handler
    /// to invoke based on the query structure and conventions:
    ///
    /// - **Introspection** (`__schema`, `__type`) → Uses pre-built responses (zero-cost)
    /// - **Federation** (`_service`, `_entities`) → Fed-specific logic
    /// - **Relay node** (`node(id: "...")`) → Global ID lookup
    /// - **Mutations** (`mutation { ... }`) → Write operations
    /// - **Aggregates** (root field ends with `_aggregate`) → Analytics queries
    /// - **Windows** (root field ends with `_window`) → Time-series queries
    /// - **Regular** (default) → Standard field selections
    ///
    /// # Errors
    ///
    /// Returns `FraiseQLError::Parse` if the query string is malformed GraphQL.
    ///
    /// # Example
    ///
    /// ```text
    /// // Illustrative — classify_query() is internal.
    /// // Use executor.execute(query, None).await? for the public API.
    ///
    /// // Regular query → Regular
    /// // Mutation       → Mutation  → execute_mutation_query()
    /// // __schema       → Introspection
    /// // _entities      → Federation
    /// ```
    pub(in crate::runtime::executor) fn classify_query(&self, query: &str) -> Result<QueryType> {
        self.classify_query_with_parse(query).map(|(qt, _)| qt)
    }

    /// Classify a query and simultaneously return the parsed AST for `Regular`
    /// queries, avoiding a redundant parse in the multi-root pipeline path.
    ///
    /// Returns `(QueryType, Some(ParsedQuery))` for `Regular` queries and
    /// `(QueryType, None)` for all other types (introspection, federation, etc.).
    ///
    /// # Errors
    ///
    /// Returns [`FraiseQLError::Parse`] if the query string is malformed GraphQL.
    pub(in crate::runtime::executor) fn classify_query_with_parse(
        &self,
        query: &str,
    ) -> Result<(QueryType, Option<crate::graphql::ParsedQuery>)> {
        // Parse the query once; the AST is the canonical source of truth.
        // Substring scans on the raw string produce false-positives on aliases,
        // comments, and string argument values (e.g. `{ search(q: "_service") }`
        // would be mis-routed as a federation query by a text scan).
        let parsed = parse_query(query).map_err(|e| FraiseQLError::Parse {
            message:  e.to_string(),
            location: "query".to_string(),
        })?;

        let root_field = &parsed.root_field;

        // Introspection (highest priority): `__schema` or `__type`.
        // These are meta-fields defined by the GraphQL spec — always a root query.
        if root_field == "__schema" {
            return Ok((QueryType::IntrospectionSchema, None));
        }
        if root_field == "__type" {
            let type_name = extract_root_string_arg(&parsed, "name");
            return Ok((QueryType::IntrospectionType(type_name.unwrap_or_default()), None));
        }

        // Federation (Apollo Federation v1/v2 entry-points).
        if root_field == "_service" || root_field == "_entities" {
            return Ok((QueryType::Federation(root_field.clone()), None));
        }

        // Relay global node lookup: root field is exactly "node" on a query.
        // Extract selections from inline fragments (... on TypeName { fields })
        // so the execution layer can project only requested fields.
        if parsed.operation_type == "query" && root_field == "node" {
            let selections = parsed
                .selections
                .first()
                .map(|node_sel| {
                    // Flatten inline fragments: `node { ... on Booking { id startDate } }`
                    // Inline fragments are represented as FieldSelection with name "...on TypeName"
                    let mut fields = Vec::new();
                    for sel in &node_sel.nested_fields {
                        if sel.name.starts_with("...") {
                            // Inline fragment — lift its nested_fields up
                            fields.extend(sel.nested_fields.clone());
                        } else {
                            fields.push(sel.clone());
                        }
                    }
                    fields
                })
                .unwrap_or_default();
            return Ok((QueryType::NodeQuery { selections }, None));
        }

        // Mutations are routed by operation type.
        if parsed.operation_type == "mutation" {
            let type_selections: HashMap<String, Vec<String>> = parsed
                .selections
                .first()
                .map(|s| {
                    let mut map: HashMap<String, Vec<String>> = HashMap::new();
                    for f in &s.nested_fields {
                        if let Some(type_name) = f.name.strip_prefix("...on ") {
                            // Inline fragment: collect fields under the type name
                            let fields: Vec<String> = f
                                .nested_fields
                                .iter()
                                .map(|nf| nf.response_key().to_string())
                                .collect();
                            map.entry(type_name.to_string()).or_default().extend(fields);
                        } else {
                            // Common field (outside any inline fragment)
                            map.entry(String::new())
                                .or_default()
                                .push(f.response_key().to_string());
                        }
                    }
                    map
                })
                .unwrap_or_default();
            return Ok((
                QueryType::Mutation {
                    name: root_field.clone(),
                    type_selections,
                },
                None,
            ));
        }

        // Aggregate queries (root field ends with `_aggregate`).
        if root_field.ends_with("_aggregate") {
            return Ok((QueryType::Aggregate(root_field.clone()), None));
        }

        // Window queries (root field ends with `_window`).
        if root_field.ends_with("_window") {
            return Ok((QueryType::Window(root_field.clone()), None));
        }

        // Regular query — return the already-parsed AST to avoid re-parsing in
        // the multi-root pipeline path.
        Ok((QueryType::Regular, Some(parsed)))
    }
}

/// Extract the value of a named string argument from the first (root) field of
/// a parsed query.
///
/// For `{ __type(name: "User") { ... } }`, calling `extract_root_string_arg(parsed, "name")`
/// returns `Some("User".to_string())`.
///
/// Returns `None` if the argument is absent or is not a JSON string literal.
fn extract_root_string_arg(parsed: &crate::graphql::ParsedQuery, arg_name: &str) -> Option<String> {
    let root_field = parsed.selections.first()?;
    let arg = root_field.arguments.iter().find(|a| a.name == arg_name)?;

    // value_json is serialized as `"TypeName"` (with surrounding quotes) for
    // string values.  Strip the outer quotes to get the raw string.
    let json = &arg.value_json;
    if json.starts_with('"') && json.ends_with('"') && json.len() >= 2 {
        Some(json[1..json.len() - 1].replace("\\\"", "\""))
    } else {
        None
    }
}