Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 111 additions & 15 deletions src/analysis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -281,62 +281,62 @@ 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,
},
),
(
"MONTH".to_owned(),
Type::App {
args: vec![Type::String].into(),
args: vec![Type::Date].into(),
result: Box::new(Type::Number),
aggregate: false,
},
),
(
"DAY".to_owned(),
Type::App {
args: vec![Type::String].into(),
args: vec![Type::Date].into(),
result: Box::new(Type::Number),
aggregate: false,
},
),
(
"HOUR".to_owned(),
Type::App {
args: vec![Type::String].into(),
args: vec![Type::Time].into(),
result: Box::new(Type::Number),
aggregate: false,
},
),
(
"MINUTE".to_owned(),
Type::App {
args: vec![Type::String].into(),
args: vec![Type::Time].into(),
result: Box::new(Type::Number),
aggregate: false,
},
),
(
"SECOND".to_owned(),
Type::App {
args: vec![Type::String].into(),
args: vec![Type::Time].into(),
result: Box::new(Type::Number),
aggregate: false,
},
),
(
"WEEKDAY".to_owned(),
Type::App {
args: vec![Type::String].into(),
args: vec![Type::Date].into(),
result: Box::new(Type::Number),
aggregate: false,
},
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -500,26 +500,65 @@ 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<Scope>,
/// 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(),
scope: Scope::default(),
}
}

/// 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;
Expand All @@ -537,7 +576,35 @@ impl<'a> Analysis<'a> {
}
}

fn analyze_query(&mut self, query: Query<Raw>) -> AnalysisResult<Query<Typed>> {
/// 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<Raw>) -> AnalysisResult<Query<Typed>> {
self.enter_scope();

let mut sources = Vec::with_capacity(query.sources.len());
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions src/ast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand Down
37 changes: 34 additions & 3 deletions src/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,27 @@ use crate::{Binding, GroupBy, Raw};
/// This is a convenience alias for `Result<T, ParserError>`.
pub type ParseResult<A> = Result<A, ParserError>;

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 }
}

Expand Down Expand Up @@ -182,7 +196,24 @@ impl<'a> Parser<'a> {
))
}

fn parse_expr(&mut self) -> ParseResult<Expr> {
/// 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<Expr> {
let token = self.peek();

match token.sym {
Expand Down
71 changes: 70 additions & 1 deletion src/tests/analysis.rs
Original file line number Diff line number Diff line change
@@ -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() {
Expand Down Expand Up @@ -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
));
}
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ Ok:
source: String
specversion: String
subject: Subject
time: String
time: DateTime
traceparent: String
tracestate: String
type: String
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
source: src/tests/analysis.rs
expression: "analysis.analyze_expr(&AnalysisContext::default(), &expr, Type::Date)"
---
Ok: Date
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
source: src/tests/analysis.rs
expression: "analysis.analyze_expr(&AnalysisContext::default(), &expr, Type::Time)"
---
Ok: Time
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
source: src/tests/analysis.rs
expression: "analysis.analyze_expr(&AnalysisContext::default(), &expr, Type::Number)"
---
Ok: Number
Loading