diff --git a/src/analysis.rs b/src/analysis.rs index 4d9b566..5748c05 100644 --- a/src/analysis.rs +++ b/src/analysis.rs @@ -281,14 +281,14 @@ impl Default for AnalysisOptions { "NOW".to_owned(), Type::App { args: vec![].into(), - result: Box::new(Type::String), + result: Box::new(Type::DateTime), aggregate: false, }, ), ( "YEAR".to_owned(), Type::App { - args: vec![Type::String].into(), + args: vec![Type::Date].into(), result: Box::new(Type::Number), aggregate: false, }, @@ -296,7 +296,7 @@ impl Default for AnalysisOptions { ( "MONTH".to_owned(), Type::App { - args: vec![Type::String].into(), + args: vec![Type::Date].into(), result: Box::new(Type::Number), aggregate: false, }, @@ -304,7 +304,7 @@ impl Default for AnalysisOptions { ( "DAY".to_owned(), Type::App { - args: vec![Type::String].into(), + args: vec![Type::Date].into(), result: Box::new(Type::Number), aggregate: false, }, @@ -312,7 +312,7 @@ impl Default for AnalysisOptions { ( "HOUR".to_owned(), Type::App { - args: vec![Type::String].into(), + args: vec![Type::Time].into(), result: Box::new(Type::Number), aggregate: false, }, @@ -320,7 +320,7 @@ impl Default for AnalysisOptions { ( "MINUTE".to_owned(), Type::App { - args: vec![Type::String].into(), + args: vec![Type::Time].into(), result: Box::new(Type::Number), aggregate: false, }, @@ -328,7 +328,7 @@ impl Default for AnalysisOptions { ( "SECOND".to_owned(), Type::App { - args: vec![Type::String].into(), + args: vec![Type::Time].into(), result: Box::new(Type::Number), aggregate: false, }, @@ -336,7 +336,7 @@ impl Default for AnalysisOptions { ( "WEEKDAY".to_owned(), Type::App { - args: vec![Type::String].into(), + args: vec![Type::Date].into(), result: Box::new(Type::Number), aggregate: false, }, @@ -429,7 +429,7 @@ impl Default for AnalysisOptions { event_type_info: Type::Record(BTreeMap::from([ ("specversion".to_owned(), Type::String), ("id".to_owned(), Type::String), - ("time".to_owned(), Type::String), + ("time".to_owned(), Type::DateTime), ("source".to_owned(), Type::String), ("subject".to_owned(), Type::Subject), ("type".to_owned(), Type::String), @@ -500,19 +500,36 @@ struct CheckContext { use_source_based: bool, } +/// Context for controlling analysis behavior. +/// +/// This struct allows you to configure how expressions are analyzed, +/// such as whether aggregate functions are allowed in the current context. #[derive(Default)] -struct AnalysisContext { - allow_agg_func: bool, +pub struct AnalysisContext { + /// Controls whether aggregate functions (like COUNT, SUM, AVG) are allowed + /// in the current analysis context. + /// + /// Set to `true` to allow aggregate functions, `false` to reject them. + /// Defaults to `false`. + pub allow_agg_func: bool, } -struct Analysis<'a> { +/// A type checker and static analyzer for EventQL expressions. +/// +/// This struct maintains the analysis state including scopes and type information. +/// It can be used to perform type checking on individual expressions or entire queries. +pub struct Analysis<'a> { + /// The analysis options containing type information for functions and event types. options: &'a AnalysisOptions, + /// Stack of previous scopes for nested scope handling. prev_scopes: Vec, + /// The current scope containing variable bindings and their types. scope: Scope, } impl<'a> Analysis<'a> { - fn new(options: &'a AnalysisOptions) -> Self { + /// Creates a new analysis instance with the given options. + pub fn new(options: &'a AnalysisOptions) -> Self { Self { options, prev_scopes: Default::default(), @@ -520,6 +537,28 @@ impl<'a> Analysis<'a> { } } + /// Returns a reference to the current scope. + /// + /// The scope contains variable bindings and their types for the current + /// analysis context. Note that this only includes local variable bindings + /// and does not include global definitions such as built-in functions + /// (e.g., `COUNT`, `NOW`) or event type information, which are stored + /// in the `AnalysisOptions`. + pub fn scope(&self) -> &Scope { + &self.scope + } + + /// Returns a mutable reference to the current scope. + /// + /// This allows you to modify the scope by adding or removing variable bindings. + /// This is useful when you need to set up custom type environments before + /// analyzing expressions. Note that this only provides access to local variable + /// bindings; global definitions like built-in functions are managed through + /// `AnalysisOptions` and cannot be modified via the scope. + pub fn scope_mut(&mut self) -> &mut Scope { + &mut self.scope + } + fn enter_scope(&mut self) { if self.scope.is_empty() { return; @@ -537,7 +576,35 @@ impl<'a> Analysis<'a> { } } - fn analyze_query(&mut self, query: Query) -> AnalysisResult> { + /// Performs static analysis on a parsed query. + /// + /// This method analyzes an entire EventQL query, performing type checking on all + /// clauses including sources, predicates, group by, order by, and projections. + /// It returns a typed version of the query with type information attached. + /// + /// # Arguments + /// + /// * `query` - A parsed query in its raw (untyped) form + /// + /// # Returns + /// + /// Returns a typed query with all type information resolved, or an error if + /// type checking fails for any part of the query. + /// + /// # Example + /// + /// ```rust + /// use eventql_parser::{parse_query, prelude::{Analysis, AnalysisOptions}}; + /// + /// let query = parse_query("FROM e IN events WHERE [1,2,3] CONTAINS e.data.price PROJECT INTO e").unwrap(); + /// + /// let options = AnalysisOptions::default(); + /// let mut analysis = Analysis::new(&options); + /// + /// let typed_query = analysis.analyze_query(query); + /// assert!(typed_query.is_ok()); + /// ``` + pub fn analyze_query(&mut self, query: Query) -> AnalysisResult> { self.enter_scope(); let mut sources = Vec::with_capacity(query.sources.len()); @@ -892,7 +959,36 @@ impl<'a> Analysis<'a> { } } - fn analyze_expr( + /// Analyzes an expression and checks it against an expected type. + /// + /// This method performs type checking on an expression, verifying that all operations + /// are type-safe and that the expression's type is compatible with the expected type. + /// + /// # Arguments + /// + /// * `ctx` - The analysis context controlling analysis behavior + /// * `expr` - The expression to analyze + /// * `expect` - The expected type of the expression + /// + /// # Returns + /// + /// Returns the actual type of the expression after checking compatibility with the expected type, + /// or an error if type checking fails. + /// + /// # Example + /// + /// ```rust + /// use eventql_parser::prelude::{tokenize, Parser, Analysis, AnalysisContext, AnalysisOptions, Type}; + /// + /// let tokens = tokenize("1 + 2").unwrap(); + /// let expr = Parser::new(tokens.as_slice()).parse_expr().unwrap(); + /// let options = AnalysisOptions::default(); + /// let mut analysis = Analysis::new(&options); + /// + /// let result = analysis.analyze_expr(&AnalysisContext::default(), &expr, Type::Number); + /// assert!(result.is_ok()); + /// ``` + pub fn analyze_expr( &mut self, ctx: &AnalysisContext, expr: &Expr, diff --git a/src/ast.rs b/src/ast.rs index b30198e..30b291c 100644 --- a/src/ast.rs +++ b/src/ast.rs @@ -378,6 +378,12 @@ impl Type { (Self::Date, Self::Date) => Ok(Self::Date), (Self::Time, Self::Time) => Ok(Self::Time), (Self::DateTime, Self::DateTime) => Ok(Self::DateTime), + + // `DateTime` can be implicitly cast to `Date` or `Time` + (Self::DateTime, Self::Date) => Ok(Self::Date), + (Self::Date, Self::DateTime) => Ok(Self::Date), + (Self::DateTime, Self::Time) => Ok(Self::Time), + (Self::Time, Self::DateTime) => Ok(Self::Time), (Self::Custom(a), Self::Custom(b)) if a.eq_ignore_ascii_case(b.as_str()) => { Ok(Self::Custom(a)) } diff --git a/src/parser.rs b/src/parser.rs index e9df8f8..a59c283 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -21,13 +21,27 @@ use crate::{Binding, GroupBy, Raw}; /// This is a convenience alias for `Result`. pub type ParseResult = Result; -struct Parser<'a> { +/// A parser for EventQL expressions and queries. +/// +/// The parser takes a stream of tokens and builds an abstract syntax tree (AST) +/// representing the structure of the EventQL query or expression. +pub struct Parser<'a> { input: &'a [Token<'a>], offset: usize, } impl<'a> Parser<'a> { - fn new(input: &'a [Token<'a>]) -> Self { + /// Creates a new parser from a slice of tokens. + /// + /// # Example + /// + /// ```rust + /// use eventql_parser::prelude::{tokenize, Parser}; + /// + /// let tokens = tokenize("1 + 2").unwrap(); + /// let parser = Parser::new(tokens.as_slice()); + /// ``` + pub fn new(input: &'a [Token<'a>]) -> Self { Self { input, offset: 0 } } @@ -182,7 +196,24 @@ impl<'a> Parser<'a> { )) } - fn parse_expr(&mut self) -> ParseResult { + /// Parses a single expression from the token stream. + /// + /// This method can be used to parse individual expressions rather than complete queries. + /// It's useful for testing or for parsing expression fragments. + /// + /// # Returns + /// + /// Returns the parsed expression, or a parse error if the tokens don't form a valid expression. + /// + /// # Example + /// + /// ```rust + /// use eventql_parser::prelude::{tokenize, Parser}; + /// + /// let tokens = tokenize("NOW()").unwrap(); + /// let expr = Parser::new(tokens.as_slice()).parse_expr().unwrap(); + /// ``` + pub fn parse_expr(&mut self) -> ParseResult { let token = self.peek(); match token.sym { diff --git a/src/tests/analysis.rs b/src/tests/analysis.rs index 33406b8..abb82b6 100644 --- a/src/tests/analysis.rs +++ b/src/tests/analysis.rs @@ -1,4 +1,10 @@ -use crate::{parse_query, prelude::AnalysisOptions}; +use crate::{ + Type, + lexer::tokenize, + parse_query, + parser::Parser, + prelude::{Analysis, AnalysisContext, AnalysisOptions}, +}; #[test] fn test_infer_wrong_where_clause_1() { @@ -106,3 +112,66 @@ fn test_analyze_optional_param_func() { let query = parse_query(include_str!("./resources/optional_param_func.eql")).unwrap(); insta::assert_yaml_snapshot!(query.run_static_analysis(&Default::default())); } + +#[test] +fn test_typecheck_datetime_contravariance_1() { + let tokens = tokenize("e.time").unwrap(); + let expr = Parser::new(tokens.as_slice()).parse_expr().unwrap(); + let options = &AnalysisOptions::default(); + let mut analysis = Analysis::new(&options); + + analysis + .scope_mut() + .entries + .insert("e".to_string(), options.event_type_info.clone()); + + // `e.time` is a `Type::DateTime` but it will typecheck if a `Type::Date` is expected + insta::assert_yaml_snapshot!(analysis.analyze_expr( + &AnalysisContext::default(), + &expr, + Type::Date + )); +} + +#[test] +fn test_typecheck_datetime_contravariance_2() { + let tokens = tokenize("NOW()").unwrap(); + let expr = Parser::new(tokens.as_slice()).parse_expr().unwrap(); + let options = &AnalysisOptions::default(); + let mut analysis = Analysis::new(&options); + + // `NOW()` is a `Type::DateTime` but it will typecheck if a `Type::Time` is expected + insta::assert_yaml_snapshot!(analysis.analyze_expr( + &AnalysisContext::default(), + &expr, + Type::Time + )); +} + +#[test] +fn test_typecheck_datetime_contravariance_3() { + let tokens = tokenize("YEAR(NOW())").unwrap(); + let expr = Parser::new(tokens.as_slice()).parse_expr().unwrap(); + let options = &AnalysisOptions::default(); + let mut analysis = Analysis::new(&options); + + insta::assert_yaml_snapshot!(analysis.analyze_expr( + &AnalysisContext::default(), + &expr, + Type::Number + )); +} + +#[test] +fn test_typecheck_datetime_contravariance_4() { + let tokens = tokenize("HOUR(NOW())").unwrap(); + let expr = Parser::new(tokens.as_slice()).parse_expr().unwrap(); + let options = &AnalysisOptions::default(); + let mut analysis = Analysis::new(&options); + + insta::assert_yaml_snapshot!(analysis.analyze_expr( + &AnalysisContext::default(), + &expr, + Type::Number + )); +} diff --git a/src/tests/snapshots/eventql_parser__tests__analysis__analyze_valid_contains.snap b/src/tests/snapshots/eventql_parser__tests__analysis__analyze_valid_contains.snap index de3a7b2..1f3fb84 100644 --- a/src/tests/snapshots/eventql_parser__tests__analysis__analyze_valid_contains.snap +++ b/src/tests/snapshots/eventql_parser__tests__analysis__analyze_valid_contains.snap @@ -96,7 +96,7 @@ Ok: source: String specversion: String subject: Subject - time: String + time: DateTime traceparent: String tracestate: String type: String diff --git a/src/tests/snapshots/eventql_parser__tests__analysis__typecheck_datetime_contravariance_1.snap b/src/tests/snapshots/eventql_parser__tests__analysis__typecheck_datetime_contravariance_1.snap new file mode 100644 index 0000000..4f57546 --- /dev/null +++ b/src/tests/snapshots/eventql_parser__tests__analysis__typecheck_datetime_contravariance_1.snap @@ -0,0 +1,5 @@ +--- +source: src/tests/analysis.rs +expression: "analysis.analyze_expr(&AnalysisContext::default(), &expr, Type::Date)" +--- +Ok: Date diff --git a/src/tests/snapshots/eventql_parser__tests__analysis__typecheck_datetime_contravariance_2.snap b/src/tests/snapshots/eventql_parser__tests__analysis__typecheck_datetime_contravariance_2.snap new file mode 100644 index 0000000..cbe7ba6 --- /dev/null +++ b/src/tests/snapshots/eventql_parser__tests__analysis__typecheck_datetime_contravariance_2.snap @@ -0,0 +1,5 @@ +--- +source: src/tests/analysis.rs +expression: "analysis.analyze_expr(&AnalysisContext::default(), &expr, Type::Time)" +--- +Ok: Time diff --git a/src/tests/snapshots/eventql_parser__tests__analysis__typecheck_datetime_contravariance_3.snap b/src/tests/snapshots/eventql_parser__tests__analysis__typecheck_datetime_contravariance_3.snap new file mode 100644 index 0000000..78eb746 --- /dev/null +++ b/src/tests/snapshots/eventql_parser__tests__analysis__typecheck_datetime_contravariance_3.snap @@ -0,0 +1,5 @@ +--- +source: src/tests/analysis.rs +expression: "analysis.analyze_expr(&AnalysisContext::default(), &expr, Type::Number)" +--- +Ok: Number diff --git a/src/tests/snapshots/eventql_parser__tests__analysis__typecheck_datetime_contravariance_4.snap b/src/tests/snapshots/eventql_parser__tests__analysis__typecheck_datetime_contravariance_4.snap new file mode 100644 index 0000000..78eb746 --- /dev/null +++ b/src/tests/snapshots/eventql_parser__tests__analysis__typecheck_datetime_contravariance_4.snap @@ -0,0 +1,5 @@ +--- +source: src/tests/analysis.rs +expression: "analysis.analyze_expr(&AnalysisContext::default(), &expr, Type::Number)" +--- +Ok: Number