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
472 changes: 360 additions & 112 deletions src/analysis.rs

Large diffs are not rendered by default.

22 changes: 20 additions & 2 deletions src/ast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,11 @@ pub enum Type {
/// Subject pattern type
Subject,
/// Function type
App { args: Vec<Type>, result: Box<Type> },
App {
args: Vec<Type>,
result: Box<Type>,
aggregate: bool,
},
/// Date type (e.g., `2026-01-03`)
///
/// Used when a field is explicitly converted to a date using the `AS DATE` syntax.
Expand Down Expand Up @@ -169,18 +173,21 @@ impl Type {
Self::App {
args: mut a_args,
result: mut a_res,
aggregate: a_agg,
},
Self::App {
args: b_args,
result: b_res,
aggregate: b_agg,
},
) if a_args.len() == b_args.len() => {
) if a_args.len() == b_args.len() && a_agg == b_agg => {
if a_args.is_empty() {
let tmp = mem::take(a_res.as_mut());
*a_res = tmp.check(attrs, *b_res)?;
return Ok(Self::App {
args: a_args,
result: a_res,
aggregate: a_agg,
});
}

Expand All @@ -195,6 +202,7 @@ impl Type {
Ok(Self::App {
args: a_args,
result: a_res,
aggregate: a_agg,
})
}

Expand Down Expand Up @@ -532,6 +540,16 @@ impl Query<Raw> {
/// analysis on the query, converting it from a raw (untyped) query to a
/// typed query.
///
/// The analysis validates:
/// - Variable declarations and scoping
/// - Type compatibility in expressions and operations
/// - Valid field accesses on record types
/// - Correct function argument types and counts
/// - Aggregate function usage restrictions (only in PROJECT INTO)
/// - No mixing of aggregate functions with source-bound fields
/// - Aggregate function arguments are source-bound fields
/// - Non-empty record literals in projections
///
/// # Arguments
///
/// * `options` - Configuration containing type information and default scope
Expand Down
113 changes: 112 additions & 1 deletion src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -184,14 +184,125 @@ pub enum AnalysisError {
/// Fields: `(line, column)`
///
/// This occurs when a record literal is required, such as in the
/// SELECT projection clause.
/// PROJECT INTO 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),

/// A function was called with the wrong number of arguments.
///
/// Fields: `(line, column, function_name, expected_count, actual_count)`
///
/// This occurs when calling a function with a different number of arguments
/// than what the function signature requires.
#[error("{0}:{1}: function '{2}' requires {3} parameters but got {4}")]
FunWrongArgumentCount(u32, u32, String, usize, usize),

/// An aggregate function was used outside of a PROJECT INTO clause.
///
/// Fields: `(line, column, function_name)`
///
/// This occurs when an aggregate function (e.g., SUM, COUNT, AVG) is used
/// in a context where aggregation is not allowed, such as in WHERE, GROUP BY,
/// or ORDER BY clauses. Aggregate functions can only be used in the PROJECT INTO
/// clause to compute aggregated values over groups of events.
///
/// # Example
///
/// Invalid usage:
/// ```eql
/// FROM e IN events
/// WHERE COUNT() > 5 // Error: aggregate function in WHERE clause
/// PROJECT INTO e
/// ```
///
/// Valid usage:
/// ```eql
/// FROM e IN events
/// PROJECT INTO { total: COUNT() }
/// ```
#[error("{0}:{1}: aggregate function '{2}' can only be used in a PROJECT INTO clause")]
WrongAggFunUsage(u32, u32, String),

/// An aggregate function was used together with source-bound fields.
///
/// Fields: `(line, column)`
///
/// This occurs when attempting to mix aggregate functions with fields that are
/// bound to source events within the same projection field. Aggregate functions
/// operate on groups of events, while source-bound fields refer to individual
/// event properties. These cannot be mixed in a single field expression.
///
/// # Example
///
/// Invalid usage:
/// ```eql
/// FROM e IN events
/// // Error: mixing aggregate (SUM) with source field (e.id)
/// PROJECT INTO { count: SUM(e.data.price), id: e.id }
/// ```
///
/// Valid usage:
/// ```eql
/// FROM e IN events
/// PROJECT INTO { sum: SUM(e.data.price), label: "total" }
/// ```
#[error("{0}:{1}: aggregate functions cannot be used with source-bound fields")]
UnallowedAggFuncUsageWithSrcField(u32, u32),

/// An empty record literal was used in a context where it is not allowed.
///
/// Fields: `(line, column)`
///
/// This occurs when using an empty record `{}` as a projection, which would
/// result in a query that produces no output fields. Projections must contain
/// at least one field.
///
/// # Example
///
/// Invalid usage:
/// ```eql
/// FROM e IN events
/// PROJECT INTO {} // Error: empty record
/// ```
///
/// Valid usage:
/// ```eql
/// FROM e IN events
/// PROJECT INTO { id: e.id }
/// ```
#[error("{0}:{1}: unexpected empty record")]
EmptyRecord(u32, u32),

/// An aggregate function was called with an argument that is not a source-bound field.
///
/// Fields: `(line, column)`
///
/// This occurs when an aggregate function (e.g., SUM, COUNT, AVG) is called with
/// an argument that is not derived from source event properties. Aggregate functions
/// must operate on fields that come from the source events being queried, not on
/// constants, literals, or results from other functions.
///
/// # Example
///
/// Invalid usage:
/// ```eql
/// FROM e IN events
/// // Error: RAND() is constant value
/// PROJECT INTO { sum: SUM(RAND()) }
/// ```
///
/// Valid usage:
/// ```eql
/// FROM e IN events
/// PROJECT INTO { sum: SUM(e.data.price) }
/// ```
#[error("{0}:{1}: aggregate functions arguments must be source-bound fields")]
ExpectSourceBoundProperty(u32, u32),
}

impl From<LexerError> for Error {
Expand Down
27 changes: 27 additions & 0 deletions src/tests/analysis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,30 @@ fn test_analyze_valid_type_conversion_weird_case() {
.unwrap();
insta::assert_yaml_snapshot!(query.run_static_analysis(&Default::default()));
}

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

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

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

#[test]
fn test_analyze_agg_must_use_source_bound() {
let query = parse_query(include_str!("./resources/agg_must_use_source_bound.eql")).unwrap();
insta::assert_yaml_snapshot!(query.run_static_analysis(&Default::default()));
}
2 changes: 2 additions & 0 deletions src/tests/resources/agg_must_use_source_bound.eql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
FROM e IN events
PROJECT INTO { sum: SUM(RAND()) }
2 changes: 2 additions & 0 deletions src/tests/resources/aggregate_with_sourced_bases_props.eql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
FROM e IN events
PROJECT INTO { count: SUM(e.data.price), id: e.id }
3 changes: 3 additions & 0 deletions src/tests/resources/reject_agg_in_predicate.eql
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
FROM e IN events
WHERE 3 > COUNT()
PROJECT INTO e
6 changes: 6 additions & 0 deletions src/tests/resources/valid_agg_usage.eql
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
FROM e IN events
PROJECT INTO {
sum: SUM(e.data.count),
label: "everything summed",
randomvalue: RAND()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
source: src/tests/analysis.rs
expression: "query.run_static_analysis(&Default::default())"
---
Err:
Analysis:
ExpectSourceBoundProperty:
- 2
- 25
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
source: src/tests/analysis.rs
expression: "query.run_static_analysis(&Default::default())"
---
Err:
Analysis:
UnallowedAggFuncUsageWithSrcField:
- 2
- 46
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:
WrongAggFunUsage:
- 2
- 11
- COUNT
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
---
source: src/tests/analysis.rs
expression: "query.run_static_analysis(&Default::default())"
---
Ok:
attrs:
pos:
line: 1
col: 1
sources:
- binding:
name: e
pos:
line: 1
col: 6
kind:
Name: events
predicate: ~
group_by: ~
order_by: ~
limit: ~
projection:
attrs:
pos:
line: 2
col: 14
value:
Record:
- name: sum
value:
attrs:
pos:
line: 3
col: 7
value:
App:
func: SUM
args:
- attrs:
pos:
line: 3
col: 11
value:
Access:
target:
attrs:
pos:
line: 3
col: 11
value:
Access:
target:
attrs:
pos:
line: 3
col: 11
value:
Id: e
field: data
field: count
- name: label
value:
attrs:
pos:
line: 4
col: 9
value:
String: everything summed
- name: randomvalue
value:
attrs:
pos:
line: 5
col: 15
value:
App:
func: RAND
args: []
distinct: false
meta:
project:
Record:
label: String
randomvalue: Number
sum: Number