Skip to main content

heliosdb_proxy/graphql/
mod.rs

1//! GraphQL-to-SQL Gateway
2//!
3//! Feature 12 of the HeliosProxy roadmap.
4//!
5//! This module provides a GraphQL gateway that automatically generates efficient SQL
6//! queries from GraphQL requests. It includes:
7//!
8//! - Automatic schema introspection from database tables
9//! - Efficient SQL generation with JOIN optimization
10//! - N+1 query prevention via DataLoader pattern
11//! - Query complexity analysis and limits
12//! - Branch-aware and time-travel queries (HeliosDB-Lite integration)
13//!
14//! # Architecture
15//!
16//! ```text
17//! GraphQL Query → Parse → Validate → Plan → Generate SQL → Execute → Shape Response
18//! ```
19//!
20//! # Example
21//!
22//! ```rust,ignore
23//! use heliosdb::proxy::graphql::{GraphQLEngine, GraphQLConfig};
24//!
25//! let config = GraphQLConfig::builder()
26//!     .endpoint("/graphql")
27//!     .playground(true)
28//!     .max_depth(10)
29//!     .build();
30//!
31//! let engine = GraphQLEngine::new(config, db_pool).await?;
32//!
33//! let response = engine.execute(GraphQLRequest {
34//!     query: "query { users { id name } }".to_string(),
35//!     variables: None,
36//!     operation_name: None,
37//! }).await?;
38//! ```
39
40pub mod config;
41pub mod dataloader;
42pub mod engine;
43pub mod introspector;
44pub mod metrics;
45pub mod resolver;
46pub mod sql_generator;
47pub mod validation;
48
49pub use config::{GraphQLConfig, GraphQLConfigBuilder, RelationshipConfig, TableConfig};
50pub use dataloader::{BatchResult, DataLoader, DataLoaderConfig};
51pub use engine::{GraphQLEngine, GraphQLError, GraphQLRequest, GraphQLResponse};
52pub use introspector::{GraphQLField, GraphQLSchema, GraphQLType, SchemaIntrospector};
53pub use metrics::{GraphQLMetrics, OperationMetrics, QueryStats};
54pub use resolver::{FieldResolver, ResolverContext, ResolverResult};
55pub use sql_generator::{Filter, QueryPlan, Selection, SqlGenerator, SqlQuery};
56pub use validation::{ComplexityResult, QueryValidator, ValidationError};
57
58use std::collections::HashMap;
59
60/// GraphQL operation type
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
62pub enum OperationType {
63    /// Query (read-only)
64    Query,
65    /// Mutation (write)
66    Mutation,
67    /// Subscription (real-time)
68    Subscription,
69}
70
71impl std::fmt::Display for OperationType {
72    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73        match self {
74            OperationType::Query => write!(f, "query"),
75            OperationType::Mutation => write!(f, "mutation"),
76            OperationType::Subscription => write!(f, "subscription"),
77        }
78    }
79}
80
81/// Relationship type between tables
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
83pub enum RelationType {
84    /// One-to-one relationship
85    OneToOne,
86    /// One-to-many relationship
87    OneToMany,
88    /// Many-to-one relationship
89    ManyToOne,
90    /// Many-to-many relationship
91    ManyToMany,
92}
93
94impl RelationType {
95    /// Parse from string
96    #[allow(clippy::should_implement_trait)]
97    pub fn from_str(s: &str) -> Option<Self> {
98        match s.to_lowercase().as_str() {
99            "one_to_one" | "onetoone" | "1:1" => Some(RelationType::OneToOne),
100            "one_to_many" | "onetomany" | "1:n" => Some(RelationType::OneToMany),
101            "many_to_one" | "manytoone" | "n:1" => Some(RelationType::ManyToOne),
102            "many_to_many" | "manytomany" | "n:n" => Some(RelationType::ManyToMany),
103            _ => None,
104        }
105    }
106
107    /// Returns true if this relationship returns multiple records
108    pub fn is_list(&self) -> bool {
109        matches!(self, RelationType::OneToMany | RelationType::ManyToMany)
110    }
111}
112
113/// GraphQL scalar types mapped from SQL
114#[derive(Debug, Clone, PartialEq, Eq, Hash)]
115pub enum GraphQLScalar {
116    /// GraphQL ID type
117    ID,
118    /// GraphQL String type
119    String,
120    /// GraphQL Int type
121    Int,
122    /// GraphQL Float type
123    Float,
124    /// GraphQL Boolean type
125    Boolean,
126    /// GraphQL DateTime type (custom scalar)
127    DateTime,
128    /// GraphQL Date type (custom scalar)
129    Date,
130    /// GraphQL Time type (custom scalar)
131    Time,
132    /// GraphQL JSON type (custom scalar)
133    JSON,
134    /// GraphQL Decimal type (custom scalar)
135    Decimal,
136    /// GraphQL BigInt type (custom scalar)
137    BigInt,
138    /// Custom scalar type
139    Custom(String),
140}
141
142impl GraphQLScalar {
143    /// Convert SQL type to GraphQL scalar
144    pub fn from_sql_type(sql_type: &str) -> Self {
145        let lower = sql_type.to_lowercase();
146
147        if lower.contains("serial") || lower == "uuid" {
148            GraphQLScalar::ID
149        } else if lower.contains("int") || lower == "smallint" {
150            if lower.contains("big") {
151                GraphQLScalar::BigInt
152            } else {
153                GraphQLScalar::Int
154            }
155        } else if lower.contains("float") || lower.contains("double") || lower == "real" {
156            GraphQLScalar::Float
157        } else if lower.contains("numeric") || lower.contains("decimal") {
158            GraphQLScalar::Decimal
159        } else if lower == "boolean" || lower == "bool" {
160            GraphQLScalar::Boolean
161        } else if lower.contains("timestamp") || lower == "datetime" {
162            GraphQLScalar::DateTime
163        } else if lower == "date" {
164            GraphQLScalar::Date
165        } else if lower == "time" {
166            GraphQLScalar::Time
167        } else if lower == "json" || lower == "jsonb" {
168            GraphQLScalar::JSON
169        } else {
170            GraphQLScalar::String
171        }
172    }
173
174    /// Get the GraphQL SDL representation
175    pub fn to_sdl(&self) -> &str {
176        match self {
177            GraphQLScalar::ID => "ID",
178            GraphQLScalar::String => "String",
179            GraphQLScalar::Int => "Int",
180            GraphQLScalar::Float => "Float",
181            GraphQLScalar::Boolean => "Boolean",
182            GraphQLScalar::DateTime => "DateTime",
183            GraphQLScalar::Date => "Date",
184            GraphQLScalar::Time => "Time",
185            GraphQLScalar::JSON => "JSON",
186            GraphQLScalar::Decimal => "Decimal",
187            GraphQLScalar::BigInt => "BigInt",
188            GraphQLScalar::Custom(name) => name,
189        }
190    }
191}
192
193/// Consistency level for GraphQL queries (HeliosDB integration)
194#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
195pub enum ConsistencyLevel {
196    /// Strong consistency - read from primary
197    Strong,
198    /// Eventual consistency - can read from replicas
199    #[default]
200    Eventual,
201    /// Bounded staleness - read within time window
202    Bounded,
203}
204
205/// Distance metric for vector searches
206#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
207pub enum DistanceMetric {
208    /// Cosine similarity
209    #[default]
210    Cosine,
211    /// Euclidean distance
212    Euclidean,
213    /// Dot product
214    DotProduct,
215}
216
217/// Branch context for HeliosDB branch-aware queries
218#[derive(Debug, Clone)]
219pub struct BranchContext {
220    /// Branch name
221    pub name: String,
222    /// As-of timestamp (for time-travel)
223    pub as_of: Option<std::time::SystemTime>,
224}
225
226impl Default for BranchContext {
227    fn default() -> Self {
228        Self {
229            name: "main".to_string(),
230            as_of: None,
231        }
232    }
233}
234
235/// GraphQL execution context
236#[derive(Debug, Clone, Default)]
237pub struct ExecutionContext {
238    /// User identity (if authenticated)
239    pub user_id: Option<String>,
240    /// Roles for authorization
241    pub roles: Vec<String>,
242    /// Branch context
243    pub branch: BranchContext,
244    /// Consistency level
245    pub consistency: ConsistencyLevel,
246    /// Request headers
247    pub headers: HashMap<String, String>,
248    /// Custom metadata
249    pub metadata: HashMap<String, String>,
250}
251
252impl ExecutionContext {
253    /// Create a new execution context
254    pub fn new() -> Self {
255        Self::default()
256    }
257
258    /// Set the user ID
259    pub fn with_user(mut self, user_id: impl Into<String>) -> Self {
260        self.user_id = Some(user_id.into());
261        self
262    }
263
264    /// Add a role
265    pub fn with_role(mut self, role: impl Into<String>) -> Self {
266        self.roles.push(role.into());
267        self
268    }
269
270    /// Set the branch
271    pub fn with_branch(mut self, branch: impl Into<String>) -> Self {
272        self.branch.name = branch.into();
273        self
274    }
275
276    /// Set the as-of timestamp for time-travel
277    pub fn with_as_of(mut self, timestamp: std::time::SystemTime) -> Self {
278        self.branch.as_of = Some(timestamp);
279        self
280    }
281
282    /// Set the consistency level
283    pub fn with_consistency(mut self, level: ConsistencyLevel) -> Self {
284        self.consistency = level;
285        self
286    }
287
288    /// Check if the user has a specific role
289    pub fn has_role(&self, role: &str) -> bool {
290        self.roles.iter().any(|r| r == role)
291    }
292
293    /// Check if the context is authenticated
294    pub fn is_authenticated(&self) -> bool {
295        self.user_id.is_some()
296    }
297}
298
299/// GraphQL error codes
300#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
301pub enum ErrorCode {
302    /// Parse error
303    ParseError,
304    /// Validation error
305    ValidationError,
306    /// Authorization error
307    Unauthorized,
308    /// Forbidden
309    Forbidden,
310    /// Not found
311    NotFound,
312    /// Internal server error
313    InternalError,
314    /// Query too complex
315    QueryTooComplex,
316    /// Rate limited
317    RateLimited,
318    /// Timeout
319    Timeout,
320}
321
322impl ErrorCode {
323    /// Get the HTTP status code equivalent
324    pub fn http_status(&self) -> u16 {
325        match self {
326            ErrorCode::ParseError | ErrorCode::ValidationError => 400,
327            ErrorCode::Unauthorized => 401,
328            ErrorCode::Forbidden => 403,
329            ErrorCode::NotFound => 404,
330            ErrorCode::QueryTooComplex | ErrorCode::RateLimited => 429,
331            ErrorCode::Timeout => 408,
332            ErrorCode::InternalError => 500,
333        }
334    }
335}
336
337/// Convert string to PascalCase
338pub fn to_pascal_case(s: &str) -> String {
339    s.split('_')
340        .filter(|part| !part.is_empty())
341        .map(|part| {
342            let mut chars = part.chars();
343            match chars.next() {
344                None => String::new(),
345                Some(first) => first.to_uppercase().chain(chars).collect(),
346            }
347        })
348        .collect()
349}
350
351/// Convert string to camelCase
352pub fn to_camel_case(s: &str) -> String {
353    let pascal = to_pascal_case(s);
354    let mut chars = pascal.chars();
355    match chars.next() {
356        None => String::new(),
357        Some(first) => first.to_lowercase().chain(chars).collect(),
358    }
359}
360
361/// Convert string to snake_case
362pub fn to_snake_case(s: &str) -> String {
363    let mut result = String::with_capacity(s.len() + 4);
364    let mut prev_was_upper = false;
365
366    for (i, c) in s.chars().enumerate() {
367        if c.is_uppercase() {
368            if i > 0 && !prev_was_upper {
369                result.push('_');
370            }
371            result.push(c.to_lowercase().next().unwrap());
372            prev_was_upper = true;
373        } else {
374            result.push(c);
375            prev_was_upper = false;
376        }
377    }
378
379    result
380}
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385
386    #[test]
387    fn test_relation_type_from_str() {
388        assert_eq!(
389            RelationType::from_str("one_to_one"),
390            Some(RelationType::OneToOne)
391        );
392        assert_eq!(RelationType::from_str("1:n"), Some(RelationType::OneToMany));
393        assert_eq!(RelationType::from_str("n:1"), Some(RelationType::ManyToOne));
394        assert_eq!(
395            RelationType::from_str("many_to_many"),
396            Some(RelationType::ManyToMany)
397        );
398        assert_eq!(RelationType::from_str("invalid"), None);
399    }
400
401    #[test]
402    fn test_relation_type_is_list() {
403        assert!(!RelationType::OneToOne.is_list());
404        assert!(!RelationType::ManyToOne.is_list());
405        assert!(RelationType::OneToMany.is_list());
406        assert!(RelationType::ManyToMany.is_list());
407    }
408
409    #[test]
410    fn test_graphql_scalar_from_sql_type() {
411        assert_eq!(GraphQLScalar::from_sql_type("serial"), GraphQLScalar::ID);
412        assert_eq!(GraphQLScalar::from_sql_type("UUID"), GraphQLScalar::ID);
413        assert_eq!(GraphQLScalar::from_sql_type("INTEGER"), GraphQLScalar::Int);
414        assert_eq!(
415            GraphQLScalar::from_sql_type("BIGINT"),
416            GraphQLScalar::BigInt
417        );
418        assert_eq!(GraphQLScalar::from_sql_type("FLOAT"), GraphQLScalar::Float);
419        assert_eq!(
420            GraphQLScalar::from_sql_type("BOOLEAN"),
421            GraphQLScalar::Boolean
422        );
423        assert_eq!(
424            GraphQLScalar::from_sql_type("TIMESTAMP"),
425            GraphQLScalar::DateTime
426        );
427        assert_eq!(GraphQLScalar::from_sql_type("JSONB"), GraphQLScalar::JSON);
428        assert_eq!(
429            GraphQLScalar::from_sql_type("VARCHAR"),
430            GraphQLScalar::String
431        );
432    }
433
434    #[test]
435    fn test_to_pascal_case() {
436        assert_eq!(to_pascal_case("user_name"), "UserName");
437        assert_eq!(to_pascal_case("users"), "Users");
438        assert_eq!(to_pascal_case("post_comments"), "PostComments");
439    }
440
441    #[test]
442    fn test_to_camel_case() {
443        assert_eq!(to_camel_case("user_name"), "userName");
444        assert_eq!(to_camel_case("Users"), "users");
445        assert_eq!(to_camel_case("post_comments"), "postComments");
446    }
447
448    #[test]
449    fn test_to_snake_case() {
450        assert_eq!(to_snake_case("UserName"), "user_name");
451        assert_eq!(to_snake_case("postComments"), "post_comments");
452        assert_eq!(to_snake_case("ID"), "id");
453    }
454
455    #[test]
456    fn test_execution_context() {
457        let ctx = ExecutionContext::new()
458            .with_user("user123")
459            .with_role("admin")
460            .with_role("reader")
461            .with_branch("development")
462            .with_consistency(ConsistencyLevel::Strong);
463
464        assert_eq!(ctx.user_id, Some("user123".to_string()));
465        assert!(ctx.is_authenticated());
466        assert!(ctx.has_role("admin"));
467        assert!(ctx.has_role("reader"));
468        assert!(!ctx.has_role("writer"));
469        assert_eq!(ctx.branch.name, "development");
470        assert_eq!(ctx.consistency, ConsistencyLevel::Strong);
471    }
472
473    #[test]
474    fn test_error_code_http_status() {
475        assert_eq!(ErrorCode::ParseError.http_status(), 400);
476        assert_eq!(ErrorCode::Unauthorized.http_status(), 401);
477        assert_eq!(ErrorCode::Forbidden.http_status(), 403);
478        assert_eq!(ErrorCode::NotFound.http_status(), 404);
479        assert_eq!(ErrorCode::InternalError.http_status(), 500);
480    }
481}