From f2d6a36b029b6776ea79df15a2f63de62a1edfbe Mon Sep 17 00:00:00 2001 From: YoEight Date: Sat, 10 Jan 2026 15:25:48 -0500 Subject: [PATCH 1/4] feat: move datetime properties to proper datetime type --- src/analysis.rs | 32 ++++----- src/ast.rs | 6 ++ src/parser.rs | 6 +- src/tests/analysis.rs | 71 ++++++++++++++++++- ...sts__analysis__analyze_valid_contains.snap | 2 +- ...__typecheck_datetime_contravariance_1.snap | 5 ++ ...__typecheck_datetime_contravariance_2.snap | 5 ++ ...__typecheck_datetime_contravariance_3.snap | 5 ++ ...__typecheck_datetime_contravariance_4.snap | 5 ++ 9 files changed, 116 insertions(+), 21 deletions(-) create mode 100644 src/tests/snapshots/eventql_parser__tests__analysis__typecheck_datetime_contravariance_1.snap create mode 100644 src/tests/snapshots/eventql_parser__tests__analysis__typecheck_datetime_contravariance_2.snap create mode 100644 src/tests/snapshots/eventql_parser__tests__analysis__typecheck_datetime_contravariance_3.snap create mode 100644 src/tests/snapshots/eventql_parser__tests__analysis__typecheck_datetime_contravariance_4.snap diff --git a/src/analysis.rs b/src/analysis.rs index 4d9b566..393e627 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), @@ -501,18 +501,18 @@ struct CheckContext { } #[derive(Default)] -struct AnalysisContext { +pub struct AnalysisContext { allow_agg_func: bool, } -struct Analysis<'a> { - options: &'a AnalysisOptions, - prev_scopes: Vec, - scope: Scope, +pub struct Analysis<'a> { + pub options: &'a AnalysisOptions, + pub prev_scopes: Vec, + pub scope: Scope, } impl<'a> Analysis<'a> { - fn new(options: &'a AnalysisOptions) -> Self { + pub fn new(options: &'a AnalysisOptions) -> Self { Self { options, prev_scopes: Default::default(), @@ -892,7 +892,7 @@ impl<'a> Analysis<'a> { } } - fn analyze_expr( + 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..f703e17 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -21,13 +21,13 @@ use crate::{Binding, GroupBy, Raw}; /// This is a convenience alias for `Result`. pub type ParseResult = Result; -struct Parser<'a> { +pub struct Parser<'a> { input: &'a [Token<'a>], offset: usize, } impl<'a> Parser<'a> { - fn new(input: &'a [Token<'a>]) -> Self { + pub fn new(input: &'a [Token<'a>]) -> Self { Self { input, offset: 0 } } @@ -182,7 +182,7 @@ impl<'a> Parser<'a> { )) } - fn parse_expr(&mut self) -> ParseResult { + 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..afbeae3 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 + .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 From a6b9524aa9010a0d8b48dad46474fff81d747681 Mon Sep 17 00:00:00 2001 From: YoEight Date: Sat, 10 Jan 2026 18:53:32 -0500 Subject: [PATCH 2/4] step --- src/analysis.rs | 64 +++++++++++++++++++++++++++++++++++++++---- src/parser.rs | 31 +++++++++++++++++++++ src/tests/analysis.rs | 2 +- 3 files changed, 91 insertions(+), 6 deletions(-) diff --git a/src/analysis.rs b/src/analysis.rs index 393e627..3802d1c 100644 --- a/src/analysis.rs +++ b/src/analysis.rs @@ -500,18 +500,35 @@ 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)] pub struct AnalysisContext { - allow_agg_func: bool, + /// 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, } +/// 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> { - pub options: &'a AnalysisOptions, - pub prev_scopes: Vec, - pub scope: Scope, + /// 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> { + /// Creates a new analysis instance with the given options. pub fn new(options: &'a AnalysisOptions) -> Self { Self { options, @@ -520,6 +537,14 @@ impl<'a> Analysis<'a> { } } + pub fn scope(&self) -> &Scope { + &self.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 +562,7 @@ impl<'a> Analysis<'a> { } } - fn analyze_query(&mut self, query: Query) -> AnalysisResult> { + pub fn analyze_query(&mut self, query: Query) -> AnalysisResult> { self.enter_scope(); let mut sources = Vec::with_capacity(query.sources.len()); @@ -892,6 +917,35 @@ impl<'a> Analysis<'a> { } } + /// 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, diff --git a/src/parser.rs b/src/parser.rs index f703e17..a59c283 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -21,12 +21,26 @@ use crate::{Binding, GroupBy, Raw}; /// This is a convenience alias for `Result`. pub type ParseResult = Result; +/// 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> { + /// 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,6 +196,23 @@ impl<'a> Parser<'a> { )) } + /// 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(); diff --git a/src/tests/analysis.rs b/src/tests/analysis.rs index afbeae3..abb82b6 100644 --- a/src/tests/analysis.rs +++ b/src/tests/analysis.rs @@ -121,7 +121,7 @@ fn test_typecheck_datetime_contravariance_1() { let mut analysis = Analysis::new(&options); analysis - .scope + .scope_mut() .entries .insert("e".to_string(), options.event_type_info.clone()); From 798868fee618c05a9403b432bad57eb81a70711b Mon Sep 17 00:00:00 2001 From: YoEight Date: Sat, 10 Jan 2026 19:02:37 -0500 Subject: [PATCH 3/4] documentation is done too --- src/analysis.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/analysis.rs b/src/analysis.rs index 3802d1c..9b1b8a2 100644 --- a/src/analysis.rs +++ b/src/analysis.rs @@ -537,10 +537,24 @@ 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 } From 7c12a5a27c3d8fdc7d1812b2be33f0646671b4c9 Mon Sep 17 00:00:00 2001 From: YoEight Date: Sat, 10 Jan 2026 19:06:06 -0500 Subject: [PATCH 4/4] forgot about that function --- src/analysis.rs | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/analysis.rs b/src/analysis.rs index 9b1b8a2..5748c05 100644 --- a/src/analysis.rs +++ b/src/analysis.rs @@ -576,6 +576,34 @@ impl<'a> Analysis<'a> { } } + /// 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();