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
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ edition = "2024"
thiserror = "2"
nom = "8"
nom_locate = "5"
unicase = "2"

[dependencies.serde]
version = "1"
Expand Down
106 changes: 104 additions & 2 deletions src/analysis.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
use std::{
collections::{BTreeMap, HashMap, btree_map::Entry},
borrow::Cow,
collections::{BTreeMap, HashMap, HashSet, btree_map::Entry},
mem,
};

use serde::Serialize;
use unicase::Ascii;

use crate::{
Attrs, Expr, Query, Raw, Source, SourceKind, Type, Value, error::AnalysisError, token::Operator,
Expand Down Expand Up @@ -50,6 +52,55 @@ pub struct AnalysisOptions {
pub default_scope: Scope,
/// Type information for event records being queried.
pub event_type_info: Type,
/// Custom types that are not defined in the EventQL reference.
///
/// This set allows users to register custom type names that can be used
/// in type conversion expressions (e.g., `field AS CustomType`). Custom
/// type names are case-insensitive.
///
/// # Examples
///
/// ```
/// use eventql_parser::prelude::AnalysisOptions;
///
/// let options = AnalysisOptions::default()
/// .add_custom_type("Foobar");
/// ```
pub custom_types: HashSet<Ascii<String>>,
}

impl AnalysisOptions {
/// Adds a custom type name to the analysis options.
///
/// Custom types allow you to use type conversion syntax with types that are
/// not part of the standard EventQL type system. The type name is stored
/// case-insensitively.
///
/// # Arguments
///
/// * `value` - The custom type name to register
///
/// # Returns
///
/// Returns `self` to allow for method chaining.
///
/// # Examples
///
/// ```
/// use eventql_parser::prelude::AnalysisOptions;
///
/// let options = AnalysisOptions::default()
/// .add_custom_type("Timestamp")
/// .add_custom_type("UUID");
/// ```
pub fn add_custom_type<'a>(mut self, value: impl Into<Cow<'a, str>>) -> Self {
match value.into() {
Cow::Borrowed(t) => self.custom_types.insert(Ascii::new(t.to_owned())),
Cow::Owned(t) => self.custom_types.insert(Ascii::new(t)),
};

self
}
}

impl Default for AnalysisOptions {
Expand Down Expand Up @@ -347,6 +398,7 @@ impl Default for AnalysisOptions {
("tracestate".to_owned(), Type::String),
("signature".to_owned(), Type::String),
])),
custom_types: HashSet::default(),
}
}
}
Expand Down Expand Up @@ -743,6 +795,25 @@ impl<'a> Analysis<'a> {
expect.check(attrs, Type::Bool)
}

Operator::As => {
if let Value::Id(name) = &binary.rhs.value {
if let Some(tpe) = name_to_type(self.options, name) {
// NOTE - we could check if it's safe to convert the left branch to that type
return Ok(tpe);
} else {
return Err(AnalysisError::UnsupportedCustomType(
attrs.pos.line,
attrs.pos.col,
name.clone(),
));
}
}

unreachable!(
"we already made sure during parsing that we can only have an ID symbol at this point"
)
}

Operator::Not => unreachable!(),
},

Expand Down Expand Up @@ -997,6 +1068,15 @@ impl<'a> Analysis<'a> {
.unwrap_or_default(),
Value::Binary(binary) => match binary.operator {
Operator::Add | Operator::Sub | Operator::Mul | Operator::Div => Type::Number,
Operator::As => {
if let Value::Id(n) = &binary.rhs.as_ref().value
&& let Some(tpe) = name_to_type(self.options, n.as_str())
{
tpe
} else {
Type::Unspecified
}
}
Operator::Eq
| Operator::Neq
| Operator::Lt
Expand All @@ -1023,9 +1103,31 @@ impl<'a> Analysis<'a> {
| Operator::Or
| Operator::Xor
| Operator::Not
| Operator::Contains => unreachable!(),
| Operator::Contains
| Operator::As => unreachable!(),
},
Value::Group(expr) => self.project_type(&expr.value),
}
}
}

fn name_to_type(opts: &AnalysisOptions, name: &str) -> Option<Type> {
if name.eq_ignore_ascii_case("string") {
Some(Type::String)
} else if name.eq_ignore_ascii_case("int") || name.eq_ignore_ascii_case("float64") {
Some(Type::Number)
} else if name.eq_ignore_ascii_case("boolean") {
Some(Type::Bool)
} else if name.eq_ignore_ascii_case("date") {
Some(Type::Date)
} else if name.eq_ignore_ascii_case("time") {
Some(Type::Time)
} else if name.eq_ignore_ascii_case("datetime") {
Some(Type::DateTime)
} else if opts.custom_types.contains(&Ascii::new(name.to_owned())) {
// ^ Sad we have to allocate here for no reason
Some(Type::Custom(name.to_owned()))
} else {
None
}
}
33 changes: 33 additions & 0 deletions src/ast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,33 @@ pub enum Type {
Subject,
/// Function type
App { args: Vec<Type>, result: Box<Type> },
/// Date type (e.g., `2026-01-03`)
///
/// Used when a field is explicitly converted to a date using the `AS DATE` syntax.
Date,
/// Time type (e.g., `13:45:39`)
///
/// Used when a field is explicitly converted to a time using the `AS TIME` syntax.
Time,
/// DateTime type (e.g., `2026-01-01T13:45:39Z`)
///
/// Used when a field is explicitly converted to a datetime using the `AS DATETIME` syntax.
DateTime,
/// Custom type not defined in the EventQL reference
///
/// Used when a field is converted to a custom type registered in [`AnalysisOptions::custom_types`].
/// The string contains the custom type name as it appears in the query.
///
/// # Examples
///
/// ```
/// use eventql_parser::prelude::{parse_query, AnalysisOptions};
///
/// let query = parse_query("FROM e IN events PROJECT INTO { ts: e.data.timestamp as CustomTimestamp }").unwrap();
/// let options = AnalysisOptions::default().add_custom_type("CustomTimestamp");
/// let typed_query = query.run_static_analysis(&options).unwrap();
/// ```
Custom(String),
}

impl Type {
Expand Down Expand Up @@ -103,6 +130,12 @@ impl Type {
(Self::Number, Self::Number) => Ok(Self::Number),
(Self::String, Self::String) => Ok(Self::String),
(Self::Bool, Self::Bool) => Ok(Self::Bool),
(Self::Date, Self::Date) => Ok(Self::Date),
(Self::Time, Self::Time) => Ok(Self::Time),
(Self::DateTime, Self::DateTime) => Ok(Self::DateTime),
(Self::Custom(a), Self::Custom(b)) if a.eq_ignore_ascii_case(b.as_str()) => {
Ok(Self::Custom(a))
}
(Self::Array(mut a), Self::Array(b)) => {
*a = a.as_ref().clone().check(attrs, *b)?;
Ok(Self::Array(a))
Expand Down
14 changes: 14 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,15 @@ pub enum ParserError {
#[error("{0}:{1}: unexpected token {2}")]
UnexpectedToken(u32, u32, String),

/// Expected a type name but found something else.
///
/// Fields: `(line, column, found_token)`
///
/// This occurs when defining a type conversion operation but the left side is
/// not a type.
#[error("{0}:{1}: expected a type")]
ExpectedType(u32, u32),

/// The input ended unexpectedly while parsing.
///
/// This occurs when the parser expects more tokens but encounters
Expand Down Expand Up @@ -178,4 +187,9 @@ pub enum AnalysisError {
/// SELECT projection clause.
#[error("{0}:{1}: expected a record")]
ExpectRecordLiteral(u32, u32),

/// When a custom type (meaning a type not supported by EventQL by default) is used but
/// not registered in the `AnalysisOptions` custom type set.
#[error("{0}:{1}: unsupported custom type '{2}'")]
UnsupportedCustomType(u32, u32, String),
}
2 changes: 2 additions & 0 deletions src/lexer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,8 @@ fn ident(input: Text) -> IResult<Text, Token> {
Sym::Operator(Operator::Not)
} else if value.fragment().eq_ignore_ascii_case("contains") {
Sym::Operator(Operator::Contains)
} else if value.fragment().eq_ignore_ascii_case("as") {
Sym::Operator(Operator::As)
} else {
Sym::Id(value.fragment())
};
Expand Down
8 changes: 8 additions & 0 deletions src/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,13 @@ impl<'a> Parser<'a> {
self.shift();
let rhs = self.parse_binary(rhs_bind)?;

if matches!(operator, Operator::As) && !matches!(rhs.value, Value::Id(_)) {
return Err(ParserError::ExpectedType(
rhs.attrs.pos.line,
rhs.attrs.pos.col,
));
}

lhs = Expr {
attrs: lhs.attrs,
value: Value::Binary(Binary {
Expand Down Expand Up @@ -477,6 +484,7 @@ fn binding_pow(op: Operator) -> (u64, u64) {
Operator::Add | Operator::Sub => (20, 21),
Operator::Mul | Operator::Div => (30, 31),
Operator::Contains => (40, 39),
Operator::As => (50, 49),
Operator::Eq
| Operator::Neq
| Operator::Gt
Expand Down
31 changes: 30 additions & 1 deletion src/tests/analysis.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use crate::parse_query;
use crate::{parse_query, prelude::AnalysisOptions};

#[test]
fn test_infer_wrong_where_clause_1() {
Expand Down Expand Up @@ -44,3 +44,32 @@ fn test_analyze_invalid_type_contains() {
let query = parse_query(include_str!("./resources/invalid_type_contains.eql")).unwrap();
insta::assert_yaml_snapshot!(query.run_static_analysis(&Default::default()));
}

#[test]
fn test_analyze_valid_type_conversion() {
let query = parse_query(include_str!("./resources/valid_type_conversion.eql")).unwrap();
insta::assert_yaml_snapshot!(query.run_static_analysis(&Default::default()));
}

#[test]
fn test_analyze_invalid_type_conversion_custom_type() {
let query = parse_query(include_str!("./resources/type_conversion_custom_type.eql")).unwrap();
insta::assert_yaml_snapshot!(query.run_static_analysis(&Default::default()));
}

#[test]
fn test_analyze_valid_type_conversion_custom_type() {
let query = parse_query(include_str!("./resources/type_conversion_custom_type.eql")).unwrap();
insta::assert_yaml_snapshot!(
query.run_static_analysis(&AnalysisOptions::default().add_custom_type("Foobar"))
);
}

#[test]
fn test_analyze_valid_type_conversion_weird_case() {
let query = parse_query(include_str!(
"./resources/valid_type_conversion-weird-case.eql"
))
.unwrap();
insta::assert_yaml_snapshot!(query.run_static_analysis(&Default::default()));
}
12 changes: 12 additions & 0 deletions src/tests/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,15 @@ fn test_parser_valid_contains() {
let tokens = tokenize(include_str!("./resources/valid_contains.eql")).unwrap();
insta::assert_yaml_snapshot!(parse(tokens.as_slice()).unwrap());
}

#[test]
fn test_parser_valid_type_conversion() {
let tokens = tokenize(include_str!("./resources/valid_type_conversion.eql")).unwrap();
insta::assert_yaml_snapshot!(parse(tokens.as_slice()).unwrap());
}

#[test]
fn test_parser_invalid_type_conversion_expr() {
let tokens = tokenize(include_str!("./resources/invalid_type_conversion_expr.eql")).unwrap();
insta::assert_yaml_snapshot!(parse(tokens.as_slice()));
}
3 changes: 3 additions & 0 deletions src/tests/resources/invalid_type_conversion_expr.eql
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
FROM e IN events
FROM f IN subjects
PROJECT INTO { date: e.data.date as f.type }
2 changes: 2 additions & 0 deletions src/tests/resources/type_conversion_custom_type.eql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
FROM e IN events
PROJECT INTO { date: e.data.date as Foobar }
2 changes: 2 additions & 0 deletions src/tests/resources/valid_type_conversion-weird-case.eql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
FROM e IN events
PROJECT INTO { date: e.data.date as DaTeTiMe }
2 changes: 2 additions & 0 deletions src/tests/resources/valid_type_conversion.eql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
FROM e IN events
PROJECT INTO { date: e.data.date as DATETIME }
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
source: src/tests/analysis.rs
expression: "query.run_static_analysis(&Default::default())"
---
Err:
Analysis:
UnsupportedCustomType:
- 2
- 22
- Foobar
Loading