From 82f745a4ff8585c3084887bf588780d49dfe51da Mon Sep 17 00:00:00 2001 From: YoEight Date: Sat, 10 Jan 2026 11:08:57 -0500 Subject: [PATCH 1/2] feat: support optional parameters --- src/analysis.rs | 146 ++++++++++++------ src/ast.rs | 102 +++++++++++- src/error.rs | 16 +- src/tests/analysis.rs | 6 + src/tests/resources/optional_param_func.eql | 2 + ...analysis__analyze_optional_param_func.snap | 80 ++++++++++ 6 files changed, 294 insertions(+), 58 deletions(-) create mode 100644 src/tests/resources/optional_param_func.eql create mode 100644 src/tests/snapshots/eventql_parser__tests__analysis__analyze_optional_param_func.snap diff --git a/src/analysis.rs b/src/analysis.rs index de30fbb..4d9b566 100644 --- a/src/analysis.rs +++ b/src/analysis.rs @@ -8,8 +8,8 @@ use serde::Serialize; use unicase::Ascii; use crate::{ - Attrs, Expr, Field, Query, Raw, Source, SourceKind, Type, Value, error::AnalysisError, - token::Operator, + Attrs, Binary, Expr, Field, FunArgs, Query, Raw, Source, SourceKind, Type, Value, + error::AnalysisError, token::Operator, }; /// Represents the state of a query that has been statically analyzed. @@ -112,7 +112,7 @@ impl Default for AnalysisOptions { ( "ABS".to_owned(), Type::App { - args: vec![Type::Number], + args: vec![Type::Number].into(), result: Box::new(Type::Number), aggregate: false, }, @@ -120,7 +120,7 @@ impl Default for AnalysisOptions { ( "CEIL".to_owned(), Type::App { - args: vec![Type::Number], + args: vec![Type::Number].into(), result: Box::new(Type::Number), aggregate: false, }, @@ -128,7 +128,7 @@ impl Default for AnalysisOptions { ( "FLOOR".to_owned(), Type::App { - args: vec![Type::Number], + args: vec![Type::Number].into(), result: Box::new(Type::Number), aggregate: false, }, @@ -136,7 +136,7 @@ impl Default for AnalysisOptions { ( "ROUND".to_owned(), Type::App { - args: vec![Type::Number], + args: vec![Type::Number].into(), result: Box::new(Type::Number), aggregate: false, }, @@ -144,7 +144,7 @@ impl Default for AnalysisOptions { ( "COS".to_owned(), Type::App { - args: vec![Type::Number], + args: vec![Type::Number].into(), result: Box::new(Type::Number), aggregate: false, }, @@ -152,7 +152,7 @@ impl Default for AnalysisOptions { ( "EXP".to_owned(), Type::App { - args: vec![Type::Number], + args: vec![Type::Number].into(), result: Box::new(Type::Number), aggregate: false, }, @@ -160,7 +160,7 @@ impl Default for AnalysisOptions { ( "POW".to_owned(), Type::App { - args: vec![Type::Number, Type::Number], + args: vec![Type::Number, Type::Number].into(), result: Box::new(Type::Number), aggregate: false, }, @@ -168,7 +168,7 @@ impl Default for AnalysisOptions { ( "SQRT".to_owned(), Type::App { - args: vec![Type::Number], + args: vec![Type::Number].into(), result: Box::new(Type::Number), aggregate: false, }, @@ -176,7 +176,7 @@ impl Default for AnalysisOptions { ( "RAND".to_owned(), Type::App { - args: vec![], + args: vec![].into(), result: Box::new(Type::Number), aggregate: false, }, @@ -184,7 +184,7 @@ impl Default for AnalysisOptions { ( "PI".to_owned(), Type::App { - args: vec![Type::Number], + args: vec![Type::Number].into(), result: Box::new(Type::Number), aggregate: false, }, @@ -192,7 +192,7 @@ impl Default for AnalysisOptions { ( "LOWER".to_owned(), Type::App { - args: vec![Type::String], + args: vec![Type::String].into(), result: Box::new(Type::String), aggregate: false, }, @@ -200,7 +200,7 @@ impl Default for AnalysisOptions { ( "UPPER".to_owned(), Type::App { - args: vec![Type::String], + args: vec![Type::String].into(), result: Box::new(Type::String), aggregate: false, }, @@ -208,7 +208,7 @@ impl Default for AnalysisOptions { ( "TRIM".to_owned(), Type::App { - args: vec![Type::String], + args: vec![Type::String].into(), result: Box::new(Type::String), aggregate: false, }, @@ -216,7 +216,7 @@ impl Default for AnalysisOptions { ( "LTRIM".to_owned(), Type::App { - args: vec![Type::String], + args: vec![Type::String].into(), result: Box::new(Type::String), aggregate: false, }, @@ -224,7 +224,7 @@ impl Default for AnalysisOptions { ( "RTRIM".to_owned(), Type::App { - args: vec![Type::String], + args: vec![Type::String].into(), result: Box::new(Type::String), aggregate: false, }, @@ -232,7 +232,7 @@ impl Default for AnalysisOptions { ( "LEN".to_owned(), Type::App { - args: vec![Type::String], + args: vec![Type::String].into(), result: Box::new(Type::Number), aggregate: false, }, @@ -240,7 +240,7 @@ impl Default for AnalysisOptions { ( "INSTR".to_owned(), Type::App { - args: vec![Type::String], + args: vec![Type::String].into(), result: Box::new(Type::Number), aggregate: false, }, @@ -248,7 +248,7 @@ impl Default for AnalysisOptions { ( "SUBSTRING".to_owned(), Type::App { - args: vec![Type::String, Type::Number, Type::Number], + args: vec![Type::String, Type::Number, Type::Number].into(), result: Box::new(Type::String), aggregate: false, }, @@ -256,7 +256,7 @@ impl Default for AnalysisOptions { ( "REPLACE".to_owned(), Type::App { - args: vec![Type::String, Type::String, Type::String], + args: vec![Type::String, Type::String, Type::String].into(), result: Box::new(Type::String), aggregate: false, }, @@ -264,7 +264,7 @@ impl Default for AnalysisOptions { ( "STARTSWITH".to_owned(), Type::App { - args: vec![Type::String, Type::String], + args: vec![Type::String, Type::String].into(), result: Box::new(Type::Bool), aggregate: false, }, @@ -272,7 +272,7 @@ impl Default for AnalysisOptions { ( "ENDSWITH".to_owned(), Type::App { - args: vec![Type::String, Type::String], + args: vec![Type::String, Type::String].into(), result: Box::new(Type::Bool), aggregate: false, }, @@ -280,7 +280,7 @@ impl Default for AnalysisOptions { ( "NOW".to_owned(), Type::App { - args: vec![], + args: vec![].into(), result: Box::new(Type::String), aggregate: false, }, @@ -288,7 +288,7 @@ impl Default for AnalysisOptions { ( "YEAR".to_owned(), Type::App { - args: vec![Type::String], + args: vec![Type::String].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], + args: vec![Type::String].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], + args: vec![Type::String].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], + args: vec![Type::String].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], + args: vec![Type::String].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], + args: vec![Type::String].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], + args: vec![Type::String].into(), result: Box::new(Type::Number), aggregate: false, }, @@ -344,7 +344,7 @@ impl Default for AnalysisOptions { ( "IF".to_owned(), Type::App { - args: vec![Type::Bool, Type::Unspecified, Type::Unspecified], + args: vec![Type::Bool, Type::Unspecified, Type::Unspecified].into(), result: Box::new(Type::Unspecified), aggregate: false, }, @@ -352,7 +352,10 @@ impl Default for AnalysisOptions { ( "COUNT".to_owned(), Type::App { - args: vec![], + args: FunArgs { + values: vec![Type::Bool], + needed: 0, + }, result: Box::new(Type::Number), aggregate: true, }, @@ -360,7 +363,7 @@ impl Default for AnalysisOptions { ( "SUM".to_owned(), Type::App { - args: vec![Type::Number], + args: vec![Type::Number].into(), result: Box::new(Type::Number), aggregate: true, }, @@ -368,7 +371,7 @@ impl Default for AnalysisOptions { ( "AVG".to_owned(), Type::App { - args: vec![Type::Number], + args: vec![Type::Number].into(), result: Box::new(Type::Number), aggregate: true, }, @@ -376,7 +379,7 @@ impl Default for AnalysisOptions { ( "MIN".to_owned(), Type::App { - args: vec![Type::Number], + args: vec![Type::Number].into(), result: Box::new(Type::Number), aggregate: true, }, @@ -384,7 +387,7 @@ impl Default for AnalysisOptions { ( "MAX".to_owned(), Type::App { - args: vec![Type::Number], + args: vec![Type::Number].into(), result: Box::new(Type::Number), aggregate: true, }, @@ -392,7 +395,7 @@ impl Default for AnalysisOptions { ( "MEDIAN".to_owned(), Type::App { - args: vec![Type::Number], + args: vec![Type::Number].into(), result: Box::new(Type::Number), aggregate: true, }, @@ -400,7 +403,7 @@ impl Default for AnalysisOptions { ( "STDDEV".to_owned(), Type::App { - args: vec![Type::Number], + args: vec![Type::Number].into(), result: Box::new(Type::Number), aggregate: true, }, @@ -408,7 +411,7 @@ impl Default for AnalysisOptions { ( "VARIANCE".to_owned(), Type::App { - args: vec![Type::Number], + args: vec![Type::Number].into(), result: Box::new(Type::Number), aggregate: true, }, @@ -416,7 +419,7 @@ impl Default for AnalysisOptions { ( "UNIQUE".to_owned(), Type::App { - args: vec![Type::Unspecified], + args: vec![Type::Unspecified].into(), result: Box::new(Type::Unspecified), aggregate: true, }, @@ -774,6 +777,9 @@ impl<'a> Analysis<'a> { match &expr.value { Value::Id(id) if !self.options.default_scope.entries.contains_key(id) => Ok(()), Value::Access(access) => self.ensure_agg_param_is_source_bound(&access.target), + Value::Binary(binary) => self.ensure_agg_binary_op_is_source_bound(&expr.attrs, binary), + Value::Unary(unary) => self.ensure_agg_param_is_source_bound(&unary.expr), + _ => Err(AnalysisError::ExpectSourceBoundProperty( expr.attrs.pos.line, expr.attrs.pos.col, @@ -781,6 +787,58 @@ impl<'a> Analysis<'a> { } } + fn ensure_agg_binary_op_is_source_bound( + &mut self, + attrs: &Attrs, + binary: &Binary, + ) -> AnalysisResult<()> { + if !self.ensure_agg_binary_op_branch_is_source_bound(&binary.lhs) + && !self.ensure_agg_binary_op_branch_is_source_bound(&binary.rhs) + { + return Err(AnalysisError::ExpectSourceBoundProperty( + attrs.pos.line, + attrs.pos.col, + )); + } + + Ok(()) + } + + fn ensure_agg_binary_op_branch_is_source_bound(&mut self, expr: &Expr) -> bool { + match &expr.value { + Value::Id(id) => !self.options.default_scope.entries.contains_key(id), + Value::Array(exprs) => { + if exprs.is_empty() { + return false; + } + + exprs + .iter() + .all(|expr| self.ensure_agg_binary_op_branch_is_source_bound(expr)) + } + Value::Record(fields) => { + if fields.is_empty() { + return false; + } + + fields + .iter() + .all(|field| self.ensure_agg_binary_op_branch_is_source_bound(&field.value)) + } + Value::Access(access) => { + self.ensure_agg_binary_op_branch_is_source_bound(&access.target) + } + + Value::Binary(binary) => self + .ensure_agg_binary_op_is_source_bound(&expr.attrs, binary) + .is_ok(), + Value::Unary(unary) => self.ensure_agg_binary_op_branch_is_source_bound(&unary.expr), + Value::Group(expr) => self.ensure_agg_binary_op_branch_is_source_bound(expr), + + Value::Number(_) | Value::String(_) | Value::Bool(_) | Value::App(_) => false, + } + } + fn invalidate_agg_func_usage(&mut self, expr: &Expr) -> AnalysisResult<()> { match &expr.value { Value::Number(_) @@ -942,13 +1000,11 @@ impl<'a> Analysis<'a> { aggregate, } = tpe { - if args.len() != app.args.len() { + if !args.match_arg_count(app.args.len()) { return Err(AnalysisError::FunWrongArgumentCount( expr.attrs.pos.line, expr.attrs.pos.col, app.func.clone(), - args.len(), - app.args.len(), )); } @@ -960,7 +1016,7 @@ impl<'a> Analysis<'a> { )); } - for (arg, tpe) in app.args.iter().zip(args.iter().cloned()) { + for (arg, tpe) in app.args.iter().zip(args.values.iter().cloned()) { self.analyze_expr(ctx, arg, tpe)?; } diff --git a/src/ast.rs b/src/ast.rs index f6f7dcc..859def5 100644 --- a/src/ast.rs +++ b/src/ast.rs @@ -11,7 +11,11 @@ //! - [`Value`] - The various kinds of expression values (literals, operators, etc.) //! - [`Source`] - Data sources in FROM clauses //! -use std::{collections::BTreeMap, mem}; +use std::{ + collections::BTreeMap, + fmt::{self, Display}, + mem, +}; use crate::{ analysis::{AnalysisOptions, Typed, static_analysis}, @@ -51,10 +55,39 @@ impl From> for Pos { } } +#[derive(Debug, Serialize, Clone)] +pub struct FunArgs { + pub values: Vec, + pub needed: usize, +} + +impl FunArgs { + pub fn required(args: Vec) -> Self { + Self { + needed: args.len(), + values: args, + } + } + + pub fn is_empty(&self) -> bool { + self.values.is_empty() + } + + pub fn match_arg_count(&self, cnt: usize) -> bool { + cnt >= self.needed && cnt <= self.values.len() + } +} + +impl From> for FunArgs { + fn from(value: Vec) -> Self { + Self::required(value) + } +} + /// Type information for expressions. /// /// This enum represents the type of an expression in the E -#[derive(Clone, PartialEq, Eq, Debug, Default, Serialize)] +#[derive(Clone, Debug, Default, Serialize)] pub enum Type { /// Type has not been determined yet #[default] @@ -73,7 +106,7 @@ pub enum Type { Subject, /// Function type App { - args: Vec, + args: FunArgs, result: Box, aggregate: bool, }, @@ -106,6 +139,65 @@ pub enum Type { Custom(String), } +impl Display for Type { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Type::Unspecified => write!(f, "any"), + Type::Number => write!(f, "number"), + Type::String => write!(f, "string"), + Type::Bool => write!(f, "boolean"), + Type::Array(tpe) => write!(f, "[]{tpe}"), + Type::Record(map) => { + write!(f, "{{ ")?; + + for (idx, (name, value)) in map.iter().enumerate() { + if idx != 0 { + write!(f, ", ")?; + } + + write!(f, "{name}: {value}")?; + } + + write!(f, " }}") + } + Type::Subject => write!(f, "subject"), + Type::App { + args, + result, + aggregate, + } => { + write!(f, "(")?; + + for (idx, arg) in args.values.iter().enumerate() { + if idx != 0 { + write!(f, ", ")?; + } + + write!(f, "{arg}")?; + + if idx + 1 > args.needed { + write!(f, "?")?; + } + } + + write!(f, ")")?; + + if *aggregate { + write!(f, " => ")?; + } else { + write!(f, " -> ")?; + } + + write!(f, "{result}") + } + Type::Date => write!(f, "date"), + Type::Time => write!(f, "time"), + Type::DateTime => write!(f, "datetime"), + Type::Custom(n) => write!(f, "{}", n.to_lowercase()), + } + } +} + impl Type { pub fn as_record_or_panic_mut(&mut self) -> &mut BTreeMap { if let Self::Record(r) = self { @@ -180,7 +272,7 @@ impl Type { result: b_res, aggregate: b_agg, }, - ) if a_args.len() == b_args.len() && a_agg == b_agg => { + ) if a_args.values.len() == b_args.values.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)?; @@ -191,7 +283,7 @@ impl Type { }); } - for (a, b) in a_args.iter_mut().zip(b_args.into_iter()) { + for (a, b) in a_args.values.iter_mut().zip(b_args.values.into_iter()) { let tmp = mem::take(a); *a = tmp.check(attrs, b)?; } diff --git a/src/error.rs b/src/error.rs index 34d2f11..dcb2f29 100644 --- a/src/error.rs +++ b/src/error.rs @@ -132,7 +132,7 @@ pub enum AnalysisError { /// /// This occurs when an expression has a different type than what is /// required by its context (e.g., using a string where a number is expected). - #[error("{0}:{1}: type mismatch: expected {2:?} but got {3:?} ")] + #[error("{0}:{1}: type mismatch: expected {2} but got {3} ")] TypeMismatch(u32, u32, Type, Type), /// A record field was accessed but doesn't exist in the record type. @@ -141,7 +141,7 @@ pub enum AnalysisError { /// /// This occurs when trying to access a field that is not defined in the /// record's type definition. - #[error("{0}:{1}: record field '{2:?}' is undeclared ")] + #[error("{0}:{1}: record field '{2}' is undeclared ")] FieldUndeclared(u32, u32, String), /// A function was called but is not declared in the scope. @@ -150,7 +150,7 @@ pub enum AnalysisError { /// /// This occurs when calling a function that is not defined in the default /// scope or any accessible scope. - #[error("{0}:{1}: function '{2:?}' is undeclared ")] + #[error("{0}:{1}: function '{2}' is undeclared ")] FuncUndeclared(u32, u32, String), /// Expected a record type but found a different type. @@ -159,7 +159,7 @@ pub enum AnalysisError { /// /// This occurs when a record type is required (e.g., for field access) /// but a different type was found. - #[error("{0}:{1}: expected record but got {2:?}")] + #[error("{0}:{1}: expected record but got {2}")] ExpectRecord(u32, u32, Type), /// Expected an array type but found a different type. @@ -167,7 +167,7 @@ pub enum AnalysisError { /// Fields: `(line, column, actual_type)` /// /// This occurs when an array type is required but a different type was found. - #[error("{0}:{1}: expected an array but got {2:?}")] + #[error("{0}:{1}: expected an array but got {2}")] ExpectArray(u32, u32, Type), /// Expected a field literal but found a different expression. @@ -195,12 +195,12 @@ pub enum AnalysisError { /// A function was called with the wrong number of arguments. /// - /// Fields: `(line, column, function_name, expected_count, actual_count)` + /// Fields: `(line, column, function_name)` /// /// 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), + #[error("{0}:{1}: incorrect number of arguments supplied to function '{2}'")] + FunWrongArgumentCount(u32, u32, String), /// An aggregate function was used outside of a PROJECT INTO clause. /// diff --git a/src/tests/analysis.rs b/src/tests/analysis.rs index 34be555..33406b8 100644 --- a/src/tests/analysis.rs +++ b/src/tests/analysis.rs @@ -100,3 +100,9 @@ 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())); } + +#[test] +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())); +} diff --git a/src/tests/resources/optional_param_func.eql b/src/tests/resources/optional_param_func.eql new file mode 100644 index 0000000..5f1673f --- /dev/null +++ b/src/tests/resources/optional_param_func.eql @@ -0,0 +1,2 @@ +FROM e IN events +PROJECT INTO { voters: COUNT(e.data.age > 30 ) } diff --git a/src/tests/snapshots/eventql_parser__tests__analysis__analyze_optional_param_func.snap b/src/tests/snapshots/eventql_parser__tests__analysis__analyze_optional_param_func.snap new file mode 100644 index 0000000..81cedfb --- /dev/null +++ b/src/tests/snapshots/eventql_parser__tests__analysis__analyze_optional_param_func.snap @@ -0,0 +1,80 @@ +--- +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: voters + value: + attrs: + pos: + line: 2 + col: 24 + value: + App: + func: COUNT + args: + - attrs: + pos: + line: 2 + col: 30 + value: + Binary: + lhs: + attrs: + pos: + line: 2 + col: 30 + value: + Access: + target: + attrs: + pos: + line: 2 + col: 30 + value: + Access: + target: + attrs: + pos: + line: 2 + col: 30 + value: + Id: e + field: data + field: age + operator: Gt + rhs: + attrs: + pos: + line: 2 + col: 43 + value: + Number: 30 + distinct: false + meta: + project: + Record: + voters: Number From 16edf96bcec3897fe58c09a83777f834cd90f017 Mon Sep 17 00:00:00 2001 From: YoEight Date: Sat, 10 Jan 2026 12:25:53 -0500 Subject: [PATCH 2/2] add documentation --- src/ast.rs | 153 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 151 insertions(+), 2 deletions(-) diff --git a/src/ast.rs b/src/ast.rs index 859def5..b30198e 100644 --- a/src/ast.rs +++ b/src/ast.rs @@ -55,13 +55,51 @@ impl From> for Pos { } } +/// Represents function argument types with optional parameter support. +/// +/// This type allows defining functions that have both required and optional parameters. +/// The `needed` field specifies how many arguments are required, while `values` contains +/// all possible argument types (both required and optional). +/// +/// # Examples +/// +/// ``` +/// use eventql_parser::prelude::{FunArgs, Type}; +/// +/// // Function with all required parameters: (number, string) +/// let required = FunArgs::required(vec![Type::Number, Type::String]); +/// assert_eq!(required.needed, 2); +/// assert_eq!(required.values.len(), 2); +/// +/// // Function with optional parameters: (boolean, number?) +/// let optional = FunArgs { +/// values: vec![Type::Bool, Type::Number], +/// needed: 1, // Only first parameter is required +/// }; +/// assert!(optional.match_arg_count(1)); // Can call with just boolean +/// assert!(optional.match_arg_count(2)); // Can call with both +/// assert!(!optional.match_arg_count(3)); // Cannot call with 3 args +/// ``` #[derive(Debug, Serialize, Clone)] pub struct FunArgs { + /// All argument types, including both required and optional parameters pub values: Vec, + /// Number of required arguments (must be <= values.len()) pub needed: usize, } impl FunArgs { + /// Creates a new `FunArgs` where all parameters are required. + /// + /// # Examples + /// + /// ``` + /// use eventql_parser::prelude::{FunArgs, Type}; + /// + /// let args = FunArgs::required(vec![Type::Number, Type::String]); + /// assert_eq!(args.needed, 2); + /// assert_eq!(args.values.len(), 2); + /// ``` pub fn required(args: Vec) -> Self { Self { needed: args.len(), @@ -69,10 +107,45 @@ impl FunArgs { } } + /// Returns `true` if there are no argument types defined. + /// + /// # Examples + /// + /// ``` + /// use eventql_parser::prelude::{FunArgs, Type}; + /// + /// let empty = FunArgs::required(vec![]); + /// assert!(empty.is_empty()); + /// + /// let not_empty = FunArgs::required(vec![Type::Number]); + /// assert!(!not_empty.is_empty()); + /// ``` pub fn is_empty(&self) -> bool { self.values.is_empty() } + /// Checks if a given argument count is valid for this function signature. + /// + /// Returns `true` if the count is between `needed` (inclusive) and + /// `values.len()` (inclusive), meaning all required arguments are + /// provided and no extra arguments beyond the optional ones are given. + /// + /// # Examples + /// + /// ``` + /// use eventql_parser::prelude::{FunArgs, Type}; + /// + /// let args = FunArgs { + /// values: vec![Type::Bool, Type::Number, Type::String], + /// needed: 1, // Only first parameter is required + /// }; + /// + /// assert!(!args.match_arg_count(0)); // Missing required argument + /// assert!(args.match_arg_count(1)); // Required argument provided + /// assert!(args.match_arg_count(2)); // Required + one optional + /// assert!(args.match_arg_count(3)); // All arguments provided + /// assert!(!args.match_arg_count(4)); // Too many arguments + /// ``` pub fn match_arg_count(&self, cnt: usize) -> bool { cnt >= self.needed && cnt <= self.values.len() } @@ -104,10 +177,39 @@ pub enum Type { Record(BTreeMap), /// Subject pattern type Subject, - /// Function type + /// Function type with support for optional parameters. + /// + /// The `args` field uses [`FunArgs`] to support both required and optional parameters. + /// Optional parameters are indicated when `args.needed < args.values.len()`. + /// + /// # Examples + /// + /// ``` + /// use eventql_parser::prelude::{Type, FunArgs}; + /// + /// // Function with all required parameters: (number, string) -> boolean + /// let all_required = Type::App { + /// args: vec![Type::Number, Type::String].into(), + /// result: Box::new(Type::Bool), + /// aggregate: false, + /// }; + /// + /// // Aggregate function with optional parameter: (boolean?) => number + /// let with_optional = Type::App { + /// args: FunArgs { + /// values: vec![Type::Bool], + /// needed: 0, // All parameters are optional + /// }, + /// result: Box::new(Type::Number), + /// aggregate: true, + /// }; + /// ``` App { + /// Function argument types, supporting optional parameters args: FunArgs, + /// Return type of the function result: Box, + /// Whether this is an aggregate function (operates on grouped data) aggregate: bool, }, /// Date type (e.g., `2026-01-03`) @@ -130,7 +232,7 @@ pub enum Type { /// # Examples /// /// ``` - /// use eventql_parser::prelude::{parse_query, AnalysisOptions}; + /// use eventql_parser::{parse_query, prelude::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"); @@ -139,6 +241,53 @@ pub enum Type { Custom(String), } +/// Provides human-readable string formatting for types. +/// +/// Function types display optional parameters with a `?` suffix. For example, +/// a function with signature `(boolean, number?) -> string` accepts 1 or 2 arguments. +/// Aggregate functions use `=>` instead of `->` in their signature. +/// +/// # Examples +/// +/// ``` +/// use eventql_parser::prelude::{Type, FunArgs}; +/// +/// // Basic types +/// assert_eq!(Type::Number.to_string(), "number"); +/// assert_eq!(Type::String.to_string(), "string"); +/// assert_eq!(Type::Bool.to_string(), "boolean"); +/// +/// // Array type +/// let arr = Type::Array(Box::new(Type::Number)); +/// assert_eq!(arr.to_string(), "[]number"); +/// +/// // Function with all required parameters +/// let func = Type::App { +/// args: vec![Type::Number, Type::String].into(), +/// result: Box::new(Type::Bool), +/// aggregate: false, +/// }; +/// assert_eq!(func.to_string(), "(number, string) -> boolean"); +/// +/// // Function with optional parameters +/// let func_optional = Type::App { +/// args: FunArgs { +/// values: vec![Type::Bool, Type::Number], +/// needed: 1, +/// }, +/// result: Box::new(Type::String), +/// aggregate: false, +/// }; +/// assert_eq!(func_optional.to_string(), "(boolean, number?) -> string"); +/// +/// // Aggregate function +/// let agg = Type::App { +/// args: vec![Type::Number].into(), +/// result: Box::new(Type::Number), +/// aggregate: true, +/// }; +/// assert_eq!(agg.to_string(), "(number) => number"); +/// ``` impl Display for Type { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self {