Skip to content

Commit ce22325

Browse files
authored
feat: move datetime properties to proper datetime type (#12)
1 parent f19dd27 commit ce22325

9 files changed

+242
-20
lines changed

src/analysis.rs

Lines changed: 111 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -281,62 +281,62 @@ impl Default for AnalysisOptions {
281281
"NOW".to_owned(),
282282
Type::App {
283283
args: vec![].into(),
284-
result: Box::new(Type::String),
284+
result: Box::new(Type::DateTime),
285285
aggregate: false,
286286
},
287287
),
288288
(
289289
"YEAR".to_owned(),
290290
Type::App {
291-
args: vec![Type::String].into(),
291+
args: vec![Type::Date].into(),
292292
result: Box::new(Type::Number),
293293
aggregate: false,
294294
},
295295
),
296296
(
297297
"MONTH".to_owned(),
298298
Type::App {
299-
args: vec![Type::String].into(),
299+
args: vec![Type::Date].into(),
300300
result: Box::new(Type::Number),
301301
aggregate: false,
302302
},
303303
),
304304
(
305305
"DAY".to_owned(),
306306
Type::App {
307-
args: vec![Type::String].into(),
307+
args: vec![Type::Date].into(),
308308
result: Box::new(Type::Number),
309309
aggregate: false,
310310
},
311311
),
312312
(
313313
"HOUR".to_owned(),
314314
Type::App {
315-
args: vec![Type::String].into(),
315+
args: vec![Type::Time].into(),
316316
result: Box::new(Type::Number),
317317
aggregate: false,
318318
},
319319
),
320320
(
321321
"MINUTE".to_owned(),
322322
Type::App {
323-
args: vec![Type::String].into(),
323+
args: vec![Type::Time].into(),
324324
result: Box::new(Type::Number),
325325
aggregate: false,
326326
},
327327
),
328328
(
329329
"SECOND".to_owned(),
330330
Type::App {
331-
args: vec![Type::String].into(),
331+
args: vec![Type::Time].into(),
332332
result: Box::new(Type::Number),
333333
aggregate: false,
334334
},
335335
),
336336
(
337337
"WEEKDAY".to_owned(),
338338
Type::App {
339-
args: vec![Type::String].into(),
339+
args: vec![Type::Date].into(),
340340
result: Box::new(Type::Number),
341341
aggregate: false,
342342
},
@@ -429,7 +429,7 @@ impl Default for AnalysisOptions {
429429
event_type_info: Type::Record(BTreeMap::from([
430430
("specversion".to_owned(), Type::String),
431431
("id".to_owned(), Type::String),
432-
("time".to_owned(), Type::String),
432+
("time".to_owned(), Type::DateTime),
433433
("source".to_owned(), Type::String),
434434
("subject".to_owned(), Type::Subject),
435435
("type".to_owned(), Type::String),
@@ -500,26 +500,65 @@ struct CheckContext {
500500
use_source_based: bool,
501501
}
502502

503+
/// Context for controlling analysis behavior.
504+
///
505+
/// This struct allows you to configure how expressions are analyzed,
506+
/// such as whether aggregate functions are allowed in the current context.
503507
#[derive(Default)]
504-
struct AnalysisContext {
505-
allow_agg_func: bool,
508+
pub struct AnalysisContext {
509+
/// Controls whether aggregate functions (like COUNT, SUM, AVG) are allowed
510+
/// in the current analysis context.
511+
///
512+
/// Set to `true` to allow aggregate functions, `false` to reject them.
513+
/// Defaults to `false`.
514+
pub allow_agg_func: bool,
506515
}
507516

508-
struct Analysis<'a> {
517+
/// A type checker and static analyzer for EventQL expressions.
518+
///
519+
/// This struct maintains the analysis state including scopes and type information.
520+
/// It can be used to perform type checking on individual expressions or entire queries.
521+
pub struct Analysis<'a> {
522+
/// The analysis options containing type information for functions and event types.
509523
options: &'a AnalysisOptions,
524+
/// Stack of previous scopes for nested scope handling.
510525
prev_scopes: Vec<Scope>,
526+
/// The current scope containing variable bindings and their types.
511527
scope: Scope,
512528
}
513529

514530
impl<'a> Analysis<'a> {
515-
fn new(options: &'a AnalysisOptions) -> Self {
531+
/// Creates a new analysis instance with the given options.
532+
pub fn new(options: &'a AnalysisOptions) -> Self {
516533
Self {
517534
options,
518535
prev_scopes: Default::default(),
519536
scope: Scope::default(),
520537
}
521538
}
522539

540+
/// Returns a reference to the current scope.
541+
///
542+
/// The scope contains variable bindings and their types for the current
543+
/// analysis context. Note that this only includes local variable bindings
544+
/// and does not include global definitions such as built-in functions
545+
/// (e.g., `COUNT`, `NOW`) or event type information, which are stored
546+
/// in the `AnalysisOptions`.
547+
pub fn scope(&self) -> &Scope {
548+
&self.scope
549+
}
550+
551+
/// Returns a mutable reference to the current scope.
552+
///
553+
/// This allows you to modify the scope by adding or removing variable bindings.
554+
/// This is useful when you need to set up custom type environments before
555+
/// analyzing expressions. Note that this only provides access to local variable
556+
/// bindings; global definitions like built-in functions are managed through
557+
/// `AnalysisOptions` and cannot be modified via the scope.
558+
pub fn scope_mut(&mut self) -> &mut Scope {
559+
&mut self.scope
560+
}
561+
523562
fn enter_scope(&mut self) {
524563
if self.scope.is_empty() {
525564
return;
@@ -537,7 +576,35 @@ impl<'a> Analysis<'a> {
537576
}
538577
}
539578

540-
fn analyze_query(&mut self, query: Query<Raw>) -> AnalysisResult<Query<Typed>> {
579+
/// Performs static analysis on a parsed query.
580+
///
581+
/// This method analyzes an entire EventQL query, performing type checking on all
582+
/// clauses including sources, predicates, group by, order by, and projections.
583+
/// It returns a typed version of the query with type information attached.
584+
///
585+
/// # Arguments
586+
///
587+
/// * `query` - A parsed query in its raw (untyped) form
588+
///
589+
/// # Returns
590+
///
591+
/// Returns a typed query with all type information resolved, or an error if
592+
/// type checking fails for any part of the query.
593+
///
594+
/// # Example
595+
///
596+
/// ```rust
597+
/// use eventql_parser::{parse_query, prelude::{Analysis, AnalysisOptions}};
598+
///
599+
/// let query = parse_query("FROM e IN events WHERE [1,2,3] CONTAINS e.data.price PROJECT INTO e").unwrap();
600+
///
601+
/// let options = AnalysisOptions::default();
602+
/// let mut analysis = Analysis::new(&options);
603+
///
604+
/// let typed_query = analysis.analyze_query(query);
605+
/// assert!(typed_query.is_ok());
606+
/// ```
607+
pub fn analyze_query(&mut self, query: Query<Raw>) -> AnalysisResult<Query<Typed>> {
541608
self.enter_scope();
542609

543610
let mut sources = Vec::with_capacity(query.sources.len());
@@ -892,7 +959,36 @@ impl<'a> Analysis<'a> {
892959
}
893960
}
894961

895-
fn analyze_expr(
962+
/// Analyzes an expression and checks it against an expected type.
963+
///
964+
/// This method performs type checking on an expression, verifying that all operations
965+
/// are type-safe and that the expression's type is compatible with the expected type.
966+
///
967+
/// # Arguments
968+
///
969+
/// * `ctx` - The analysis context controlling analysis behavior
970+
/// * `expr` - The expression to analyze
971+
/// * `expect` - The expected type of the expression
972+
///
973+
/// # Returns
974+
///
975+
/// Returns the actual type of the expression after checking compatibility with the expected type,
976+
/// or an error if type checking fails.
977+
///
978+
/// # Example
979+
///
980+
/// ```rust
981+
/// use eventql_parser::prelude::{tokenize, Parser, Analysis, AnalysisContext, AnalysisOptions, Type};
982+
///
983+
/// let tokens = tokenize("1 + 2").unwrap();
984+
/// let expr = Parser::new(tokens.as_slice()).parse_expr().unwrap();
985+
/// let options = AnalysisOptions::default();
986+
/// let mut analysis = Analysis::new(&options);
987+
///
988+
/// let result = analysis.analyze_expr(&AnalysisContext::default(), &expr, Type::Number);
989+
/// assert!(result.is_ok());
990+
/// ```
991+
pub fn analyze_expr(
896992
&mut self,
897993
ctx: &AnalysisContext,
898994
expr: &Expr,

src/ast.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,12 @@ impl Type {
378378
(Self::Date, Self::Date) => Ok(Self::Date),
379379
(Self::Time, Self::Time) => Ok(Self::Time),
380380
(Self::DateTime, Self::DateTime) => Ok(Self::DateTime),
381+
382+
// `DateTime` can be implicitly cast to `Date` or `Time`
383+
(Self::DateTime, Self::Date) => Ok(Self::Date),
384+
(Self::Date, Self::DateTime) => Ok(Self::Date),
385+
(Self::DateTime, Self::Time) => Ok(Self::Time),
386+
(Self::Time, Self::DateTime) => Ok(Self::Time),
381387
(Self::Custom(a), Self::Custom(b)) if a.eq_ignore_ascii_case(b.as_str()) => {
382388
Ok(Self::Custom(a))
383389
}

src/parser.rs

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,27 @@ use crate::{Binding, GroupBy, Raw};
2121
/// This is a convenience alias for `Result<T, ParserError>`.
2222
pub type ParseResult<A> = Result<A, ParserError>;
2323

24-
struct Parser<'a> {
24+
/// A parser for EventQL expressions and queries.
25+
///
26+
/// The parser takes a stream of tokens and builds an abstract syntax tree (AST)
27+
/// representing the structure of the EventQL query or expression.
28+
pub struct Parser<'a> {
2529
input: &'a [Token<'a>],
2630
offset: usize,
2731
}
2832

2933
impl<'a> Parser<'a> {
30-
fn new(input: &'a [Token<'a>]) -> Self {
34+
/// Creates a new parser from a slice of tokens.
35+
///
36+
/// # Example
37+
///
38+
/// ```rust
39+
/// use eventql_parser::prelude::{tokenize, Parser};
40+
///
41+
/// let tokens = tokenize("1 + 2").unwrap();
42+
/// let parser = Parser::new(tokens.as_slice());
43+
/// ```
44+
pub fn new(input: &'a [Token<'a>]) -> Self {
3145
Self { input, offset: 0 }
3246
}
3347

@@ -182,7 +196,24 @@ impl<'a> Parser<'a> {
182196
))
183197
}
184198

185-
fn parse_expr(&mut self) -> ParseResult<Expr> {
199+
/// Parses a single expression from the token stream.
200+
///
201+
/// This method can be used to parse individual expressions rather than complete queries.
202+
/// It's useful for testing or for parsing expression fragments.
203+
///
204+
/// # Returns
205+
///
206+
/// Returns the parsed expression, or a parse error if the tokens don't form a valid expression.
207+
///
208+
/// # Example
209+
///
210+
/// ```rust
211+
/// use eventql_parser::prelude::{tokenize, Parser};
212+
///
213+
/// let tokens = tokenize("NOW()").unwrap();
214+
/// let expr = Parser::new(tokens.as_slice()).parse_expr().unwrap();
215+
/// ```
216+
pub fn parse_expr(&mut self) -> ParseResult<Expr> {
186217
let token = self.peek();
187218

188219
match token.sym {

src/tests/analysis.rs

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
use crate::{parse_query, prelude::AnalysisOptions};
1+
use crate::{
2+
Type,
3+
lexer::tokenize,
4+
parse_query,
5+
parser::Parser,
6+
prelude::{Analysis, AnalysisContext, AnalysisOptions},
7+
};
28

39
#[test]
410
fn test_infer_wrong_where_clause_1() {
@@ -106,3 +112,66 @@ fn test_analyze_optional_param_func() {
106112
let query = parse_query(include_str!("./resources/optional_param_func.eql")).unwrap();
107113
insta::assert_yaml_snapshot!(query.run_static_analysis(&Default::default()));
108114
}
115+
116+
#[test]
117+
fn test_typecheck_datetime_contravariance_1() {
118+
let tokens = tokenize("e.time").unwrap();
119+
let expr = Parser::new(tokens.as_slice()).parse_expr().unwrap();
120+
let options = &AnalysisOptions::default();
121+
let mut analysis = Analysis::new(&options);
122+
123+
analysis
124+
.scope_mut()
125+
.entries
126+
.insert("e".to_string(), options.event_type_info.clone());
127+
128+
// `e.time` is a `Type::DateTime` but it will typecheck if a `Type::Date` is expected
129+
insta::assert_yaml_snapshot!(analysis.analyze_expr(
130+
&AnalysisContext::default(),
131+
&expr,
132+
Type::Date
133+
));
134+
}
135+
136+
#[test]
137+
fn test_typecheck_datetime_contravariance_2() {
138+
let tokens = tokenize("NOW()").unwrap();
139+
let expr = Parser::new(tokens.as_slice()).parse_expr().unwrap();
140+
let options = &AnalysisOptions::default();
141+
let mut analysis = Analysis::new(&options);
142+
143+
// `NOW()` is a `Type::DateTime` but it will typecheck if a `Type::Time` is expected
144+
insta::assert_yaml_snapshot!(analysis.analyze_expr(
145+
&AnalysisContext::default(),
146+
&expr,
147+
Type::Time
148+
));
149+
}
150+
151+
#[test]
152+
fn test_typecheck_datetime_contravariance_3() {
153+
let tokens = tokenize("YEAR(NOW())").unwrap();
154+
let expr = Parser::new(tokens.as_slice()).parse_expr().unwrap();
155+
let options = &AnalysisOptions::default();
156+
let mut analysis = Analysis::new(&options);
157+
158+
insta::assert_yaml_snapshot!(analysis.analyze_expr(
159+
&AnalysisContext::default(),
160+
&expr,
161+
Type::Number
162+
));
163+
}
164+
165+
#[test]
166+
fn test_typecheck_datetime_contravariance_4() {
167+
let tokens = tokenize("HOUR(NOW())").unwrap();
168+
let expr = Parser::new(tokens.as_slice()).parse_expr().unwrap();
169+
let options = &AnalysisOptions::default();
170+
let mut analysis = Analysis::new(&options);
171+
172+
insta::assert_yaml_snapshot!(analysis.analyze_expr(
173+
&AnalysisContext::default(),
174+
&expr,
175+
Type::Number
176+
));
177+
}

src/tests/snapshots/eventql_parser__tests__analysis__analyze_valid_contains.snap

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ Ok:
9696
source: String
9797
specversion: String
9898
subject: Subject
99-
time: String
99+
time: DateTime
100100
traceparent: String
101101
tracestate: String
102102
type: String
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
source: src/tests/analysis.rs
3+
expression: "analysis.analyze_expr(&AnalysisContext::default(), &expr, Type::Date)"
4+
---
5+
Ok: Date
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
source: src/tests/analysis.rs
3+
expression: "analysis.analyze_expr(&AnalysisContext::default(), &expr, Type::Time)"
4+
---
5+
Ok: Time
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
source: src/tests/analysis.rs
3+
expression: "analysis.analyze_expr(&AnalysisContext::default(), &expr, Type::Number)"
4+
---
5+
Ok: Number

0 commit comments

Comments
 (0)