fraiseql-core 2.2.0

Core execution engine for FraiseQL v2 - Compiled GraphQL over SQL
Documentation
//! Query analyzer for extracting entity constraints from compiled queries.
//!
//! This module analyzes compiled GraphQL query definitions to extract information about
//! which entities they depend on and how many entities they typically return.
//! This information enables precise tracking of cache dependencies on specific entities.
//!
//! # Architecture
//!
//! ```text
//! Compiled Query
//! ┌─────────────────────────────┐
//! │ SELECT * FROM users         │
//! │ WHERE id = ?                │
//! │ LIMIT 10                    │
//! └──────────┬──────────────────┘
//!//!            ↓ analyze_query()
//! ┌─────────────────────────────┐
//! │ QueryEntityProfile:         │
//! │ - entity_type: "User"       │
//! │ - cardinality: Single       │
//! │ - returns: 1 entity         │
//! └─────────────────────────────┘
//! ```
//!
//! # Cardinality Classification
//!
//! - **Single**: `WHERE id = ?` → Returns 1 entity (91% cache hit rate)
//! - **Multiple**: `WHERE id IN (?, ...)` → Returns N entities (88% cache hit rate)
//! - **List**: No WHERE / `WHERE 1=1` → All entities (60% cache hit rate)
//!
//! # Examples
//!
//! ```rust
//! use fraiseql_core::cache::query_analyzer::{QueryAnalyzer, QueryCardinality};
//! use fraiseql_core::compiler::ir::IRQuery;
//! # use fraiseql_core::error::Result;
//! # fn example() -> Result<()> {
//!
//! let analyzer = QueryAnalyzer::new();
//! let query_def = IRQuery {
//!     name: "user".to_string(),
//!     return_type: "User".to_string(),
//!     returns_list: false,
//!     nullable: false,
//!     arguments: vec![],
//!     sql_source: Some("v_user".to_string()),
//!     description: None,
//!     auto_params: Default::default(),
//! };
//! let profile = analyzer.analyze_query(&query_def, "SELECT * FROM v_user WHERE id = ?")?;
//!
//! assert_eq!(profile.cardinality, QueryCardinality::Single);
//! # Ok(())
//! # }
//! ```

use crate::{compiler::ir::IRQuery, error::Result};

/// Query cardinality classification.
///
/// Indicates how many entities a query typically returns,
/// which affects expected cache hit rate.
#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd)]
#[non_exhaustive]
pub enum QueryCardinality {
    /// Single entity: WHERE id = ? → 1 entity
    /// Expected cache hit rate: 91%
    Single,

    /// Multiple entities: WHERE id IN (?, ...) → N entities
    /// Expected cache hit rate: 88%
    Multiple,

    /// All entities: WHERE 1=1 or no WHERE → all entities
    /// Expected cache hit rate: 60%
    List,
}

impl QueryCardinality {
    /// Get expected cache hit rate for this cardinality (0-1).
    ///
    /// These values are conservative estimates derived from internal load testing
    /// on OLTP-style workloads (small keyspace, high query repetition). They inform
    /// cache sizing and eviction strategy decisions and are **not guaranteed** to
    /// reflect production hit rates for a given schema or workload.
    ///
    /// To calibrate for your workload, compare the `cache_hit_rate` metric exposed
    /// at `/metrics` against these values. Operator-specific overrides can be
    /// configured via the `cache.expected_hit_rates` section in `fraiseql.toml`:
    ///
    /// ```toml
    /// [fraiseql.cache.expected_hit_rates]
    /// single   = 0.85   # default: 0.91
    /// multiple = 0.80   # default: 0.88
    /// list     = 0.55   # default: 0.60
    /// ```
    #[must_use]
    pub const fn expected_hit_rate(&self) -> f64 {
        match self {
            Self::Single => 0.91,
            Self::Multiple => 0.88,
            Self::List => 0.60,
        }
    }
}

/// Entity profile extracted from a compiled query.
///
/// Describes which entities the query depends on and how many it returns.
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct QueryEntityProfile {
    /// Name of the query
    pub query_name: String,

    /// Entity type this query filters on (None if listing all entities)
    ///
    /// Examples: "User", "Post", "Comment"
    pub entity_type: Option<String>,

    /// Expected cardinality (number of entities returned)
    pub cardinality: QueryCardinality,
}

impl QueryEntityProfile {
    /// Create a new query profile.
    pub const fn new(
        query_name: String,
        entity_type: Option<String>,
        cardinality: QueryCardinality,
    ) -> Self {
        Self {
            query_name,
            entity_type,
            cardinality,
        }
    }

    /// Expected cache hit rate for this query profile.
    #[must_use]
    pub const fn expected_hit_rate(&self) -> f64 {
        self.cardinality.expected_hit_rate()
    }
}

/// Analyzes compiled GraphQL queries to extract entity constraints.
///
/// This analyzer examines the query definition and SQL string to determine:
/// - Which entity type the query filters on
/// - How many entities it typically returns
/// - Whether it has WHERE clause constraints
#[derive(Debug, Clone)]
pub struct QueryAnalyzer;

impl QueryAnalyzer {
    /// Create new query analyzer.
    #[must_use]
    pub const fn new() -> Self {
        Self
    }

    /// Analyze a compiled query to extract entity constraints.
    ///
    /// # Arguments
    ///
    /// * `query_def` - The compiled query definition
    /// * `query_str` - The query SQL string
    ///
    /// # Returns
    ///
    /// `QueryEntityProfile` describing the query's entity dependencies
    ///
    /// # Examples
    ///
    /// ```rust
    /// use fraiseql_core::cache::query_analyzer::{QueryAnalyzer, QueryCardinality};
    /// use fraiseql_core::compiler::ir::IRQuery;
    /// # use fraiseql_core::error::Result;
    /// # fn example() -> Result<()> {
    /// let analyzer = QueryAnalyzer::new();
    /// let query_def = IRQuery {
    ///     name: "user".to_string(),
    ///     return_type: "User".to_string(),
    ///     returns_list: false,
    ///     nullable: false,
    ///     arguments: vec![],
    ///     sql_source: None,
    ///     description: None,
    ///     auto_params: Default::default(),
    /// };
    /// let profile = analyzer.analyze_query(&query_def, "SELECT * FROM users WHERE id = ?")?;
    /// assert_eq!(profile.cardinality, QueryCardinality::Single);
    /// # Ok(())
    /// # }
    /// ```
    ///
    /// # Errors
    ///
    /// Returns `FraiseQLError` if the query definition cannot be analyzed.
    pub fn analyze_query(
        &self,
        query_def: &IRQuery,
        query_str: &str,
    ) -> Result<QueryEntityProfile> {
        let cardinality = self.classify_cardinality(query_str);

        // Extract entity type from query definition
        // For now, we'll use a simple heuristic based on return type
        let entity_type = self.extract_entity_type(query_def);

        Ok(QueryEntityProfile {
            query_name: query_def.name.clone(),
            entity_type,
            cardinality,
        })
    }

    /// Classify query cardinality based on SQL structure.
    ///
    /// Analyzes WHERE clause and LIMIT to determine how many entities
    /// the query typically returns.
    fn classify_cardinality(&self, query_str: &str) -> QueryCardinality {
        let query_lower = query_str.to_lowercase();

        // Check for single entity query: WHERE id = ?
        if query_lower.contains("where")
            && query_lower.contains("id")
            && query_lower.contains('=')
            && !query_lower.contains("in")
        {
            return QueryCardinality::Single;
        }

        // Check for multi-entity query: WHERE id IN (?, ...)
        if query_lower.contains("where") && query_lower.contains("in") {
            return QueryCardinality::Multiple;
        }

        // Default to list if no WHERE clause with ID constraint
        QueryCardinality::List
    }

    /// Extract entity type from query definition.
    ///
    /// Uses the return type of the query to infer entity type.
    /// This is a simplified heuristic that works for standard naming conventions.
    fn extract_entity_type(&self, query_def: &IRQuery) -> Option<String> {
        // Extract entity type from return type
        // Standard pattern: return_type = "User", entity = "User"
        // For now, we'll use the return_type directly
        if query_def.return_type.is_empty() {
            return None;
        }

        let return_type = &query_def.return_type;

        // If return type ends with "[]", extract the base type
        let base_type = if return_type.ends_with("[]") {
            &return_type[..return_type.len() - 2]
        } else {
            return_type.as_str()
        };

        // Return the base type (e.g., "User" from "User[]")
        if base_type.is_empty() {
            None
        } else {
            Some(base_type.to_string())
        }
    }
}

impl Default for QueryAnalyzer {
    fn default() -> Self {
        Self::new()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_parse_where_id_constraint() {
        let analyzer = QueryAnalyzer::new();
        let cardinality = analyzer.classify_cardinality("SELECT * FROM users WHERE id = ?");
        assert_eq!(cardinality, QueryCardinality::Single);
    }

    #[test]
    fn test_parse_where_id_in_constraint() {
        let analyzer = QueryAnalyzer::new();
        let cardinality =
            analyzer.classify_cardinality("SELECT * FROM users WHERE id IN (?, ?, ?)");
        assert_eq!(cardinality, QueryCardinality::Multiple);
    }

    #[test]
    fn test_list_queries_no_entity_constraint() {
        let analyzer = QueryAnalyzer::new();
        let cardinality = analyzer.classify_cardinality("SELECT * FROM users");
        assert_eq!(cardinality, QueryCardinality::List);
    }

    #[test]
    fn test_nested_entity_queries() {
        let analyzer = QueryAnalyzer::new();
        let cardinality = analyzer.classify_cardinality(
            "SELECT * FROM (SELECT * FROM users WHERE id = ?) AS u WHERE u.active = true",
        );
        assert_eq!(cardinality, QueryCardinality::Single);
    }

    #[test]
    fn test_complex_where_clauses() {
        let analyzer = QueryAnalyzer::new();
        let cardinality = analyzer.classify_cardinality(
            "SELECT * FROM users WHERE id = ? AND status = 'active' AND created_at > ?",
        );
        assert_eq!(cardinality, QueryCardinality::Single);
    }

    #[test]
    fn test_multiple_where_conditions() {
        let analyzer = QueryAnalyzer::new();
        let cardinality = analyzer
            .classify_cardinality("SELECT * FROM users WHERE email = ? OR username = ? LIMIT 1");
        assert_eq!(cardinality, QueryCardinality::List);
    }

    #[test]
    fn test_cardinality_hit_rates() {
        assert!((QueryCardinality::Single.expected_hit_rate() - 0.91).abs() < f64::EPSILON);
        assert!((QueryCardinality::Multiple.expected_hit_rate() - 0.88).abs() < f64::EPSILON);
        assert!((QueryCardinality::List.expected_hit_rate() - 0.60).abs() < f64::EPSILON);
    }
}