use ariadne::{Color, Label, Report, ReportKind, Source};
use chumsky::input::{Stream, ValueInput};
use chumsky::prelude::*;
use logos::Logos;
use crate::query::QueryError;
use crate::query::ast::{
AggFunc, ArithOp, Attr, ClassSpec, CompareOp, Expr, FromSource, OrderBy, PathOperand,
Predicate, Query, RefRole, SelectItem, SortDir, UnaryOp, Value,
};
#[derive(Logos, Debug, Clone, PartialEq)]
#[logos(skip r"[ \t\r\n]+")]
pub enum Token {
#[token("(")]
LParen,
#[token(")")]
RParen,
#[token(",")]
Comma,
#[token("[")]
LBracket,
#[token("]")]
RBracket,
#[token(":")]
Colon,
#[token("=")]
Eq,
#[token("!=")]
Ne,
#[token("<=")]
Le,
#[token("<")]
Lt,
#[token(">=")]
Ge,
#[token(">")]
Gt,
#[token("*")]
Star,
#[token("+")]
Plus,
#[token("-")]
Minus,
#[token("/")]
Divide,
#[regex(r"@[A-Za-z_][A-Za-z0-9_.$]*", |lex| lex.slice()[1..].to_string())]
At(String),
#[regex(r#""[^"]*""#, |lex| { let s = lex.slice(); s[1..s.len()-1].to_string() })]
Str(String),
#[regex(
r"[0-9]+\.[0-9]*([eE][+-]?[0-9]+)?[fFdD]?|[0-9]+([eE][+-]?[0-9]+)[fFdD]?|[0-9]+[fFdD]",
|lex| {
let s = lex.slice();
let core = s.trim_end_matches(['f', 'F', 'd', 'D']);
core.parse::<f64>().ok()
},
priority = 4
)]
Float(f64),
#[regex(r"'[^'\\\n\r]'", |lex| {
let s = lex.slice();
s[1..s.len() - 1].chars().next().map(|c| c as i64)
})]
#[regex(r"0[xX][0-9a-fA-F]+[lL]?", |lex| {
let s = lex.slice().trim_end_matches(['l', 'L']);
u64::from_str_radix(&s[2..], 16).ok().map(|v| v as i64)
}, priority = 3)]
#[regex(r"0[0-7]+[lL]?", |lex| {
let s = lex.slice().trim_end_matches(['l', 'L']);
u64::from_str_radix(s, 8).ok().map(|v| v as i64)
}, priority = 3)]
#[regex(r"[0-9]+[lL]?", |lex| {
let s = lex.slice().trim_end_matches(['l', 'L']);
s.parse::<u64>().ok().map(|v| v as i64)
})]
Int(i64),
#[regex(r"[A-Za-z_][A-Za-z0-9_.$*]*(\[\])*", |lex| lex.slice().to_string())]
Ident(String),
}
fn token_display(t: &Token) -> String {
match t {
Token::LParen => "'('".into(),
Token::RParen => "')'".into(),
Token::Comma => "','".into(),
Token::LBracket => "'['".into(),
Token::RBracket => "']'".into(),
Token::Colon => "':'".into(),
Token::Eq => "'='".into(),
Token::Ne => "'!='".into(),
Token::Le => "'<='".into(),
Token::Lt => "'<'".into(),
Token::Ge => "'>='".into(),
Token::Gt => "'>'".into(),
Token::Star => "'*'".into(),
Token::Plus => "'+'".into(),
Token::Minus => "'-'".into(),
Token::Divide => "'/'".into(),
Token::At(s) => format!("'@{s}'"),
Token::Str(s) => format!("\"{s}\""),
Token::Float(f) => format!("{f}"),
Token::Int(i) => format!("{i}"),
Token::Ident(s) => format!("'{s}'"),
}
}
pub fn tokenize_spanned(src: &str) -> Result<Vec<(Token, SimpleSpan)>, String> {
let mut out = Vec::new();
let mut lex = Token::lexer(src);
while let Some(res) = lex.next() {
let span = lex.span();
match res {
Ok(tok) => out.push((tok, (span.start..span.end).into())),
Err(()) => {
let slice = &src[span.clone()];
if slice.starts_with('\'') {
let rest = &src[span.start + 1..];
if let Some(close) = rest.find('\'') {
let inner = &rest[..close];
if inner.chars().count() != 1 {
return Err(format!(
"OQL strings use double quotes, not single: write {:?} \
(single quotes are only a one-character literal, e.g. 'a')",
inner
));
}
}
return Err(format!(
"character literal must contain exactly one character: {slice:?} \
(single-quoted, no escapes)"
));
}
if slice.chars().next().is_some_and(|c| c.is_ascii_digit()) {
return Err(format!(
"numeric literal out of range for a 64-bit integer (unsigned range, \
0..=0xffffffffffffffff): {slice:?}"
));
}
return Err(format!(
"unexpected character(s) at offset {}: {:?}",
span.start,
&src[span.clone()]
));
}
}
}
Ok(out)
}
fn ident_ci<'a, I>(
kw: &'static str,
) -> impl Parser<'a, I, String, extra::Err<Rich<'a, Token>>> + Clone
where
I: ValueInput<'a, Token = Token, Span = SimpleSpan>,
{
select! { Token::Ident(s) if s.eq_ignore_ascii_case(kw) => s }.labelled(kw)
}
fn any_ident<'a, I>() -> impl Parser<'a, I, String, extra::Err<Rich<'a, Token>>> + Clone
where
I: ValueInput<'a, Token = Token, Span = SimpleSpan>,
{
select! { Token::Ident(s) => s }
}
fn dom_fn<'a, I>(
name: &'static str,
) -> impl Parser<'a, I, String, extra::Err<Rich<'a, Token>>> + Clone
where
I: ValueInput<'a, Token = Token, Span = SimpleSpan>,
{
ident_ci(name)
.ignore_then(just(Token::LParen))
.ignore_then(any_ident().or_not())
.then_ignore(just(Token::RParen))
.validate(move |arg, e, emitter| match arg {
Some(a) => a,
None => {
emitter.emit(Rich::custom(
e.span(),
format!("{name}(x) requires a single alias argument, e.g. {name}(s)"),
));
String::new()
}
})
}
#[allow(clippy::type_complexity)]
pub(crate) fn parser<'a, I>() -> impl Parser<'a, I, Query, extra::Err<Rich<'a, Token>>>
where
I: ValueInput<'a, Token = Token, Span = SimpleSpan>,
{
let at_attr = select! { Token::Ident(p) if p.ends_with('.') => p }
.or_not()
.then(select! { Token::At(name) => name })
.validate(|(prefix, name): (Option<String>, String), e, emitter| {
let base = match name.as_str() {
"objectId" => Attr::ObjectId,
"objectAddress" => Attr::ObjectAddress,
"usedHeapSize" | "shallowHeapSize" | "shallowSize" => Attr::UsedHeapSize,
"retainedHeapSize" | "retainedHeap" => Attr::RetainedHeapSize,
"displayName" => Attr::DisplayName,
"name" => Attr::DisplayName,
"length" => Attr::Length,
"inbounds" => Attr::Inbounds,
"outbounds" => Attr::Outbounds,
"valueArray" => Attr::ValueArray,
"referenceArray" => Attr::ReferenceArray,
"GCRoots" => Attr::GcRoots,
"GCRootInfo" | "info" => Attr::GcRootInfo,
other => {
let msg = match did_you_mean(
other,
ATTRIBUTES.iter().map(|a| a.trim_start_matches('@')),
) {
Some(s) => format!("unknown @attribute: @{other} — did you mean `@{s}`?"),
None => format!("unknown @attribute: @{other}"),
};
emitter.emit(Rich::custom(e.span(), msg));
Attr::ObjectId
}
};
match prefix {
Some(p) => {
let segs: Vec<String> = p
.trim_end_matches('.')
.split('.')
.map(str::to_string)
.collect();
if segs.len() >= 2 {
Attr::RefPath {
hops: segs,
tail: Box::new(base),
role: RefRole::ProjectionOnly,
}
} else {
base
}
}
None => base,
}
});
let attr = at_attr
.or(ident_ci("classof")
.ignore_then(just(Token::LParen))
.ignore_then(any_ident())
.then_ignore(just(Token::RParen))
.map(|_| Attr::ClassOf))
.or(dom_fn("dominators").map(Attr::Dominators))
.or(dom_fn("dominatorof").map(Attr::DominatorOf))
.or(dom_fn("toString").map(Attr::ToString))
.or(any_ident().map(Attr::Field))
.labelled("attribute");
let expr = recursive(|expr| {
let lit = select! {
Token::Int(n) => Value::Int(n),
Token::Float(f) => Value::Float(f),
Token::Str(s) => Value::Str(s),
Token::Ident(s) if s.eq_ignore_ascii_case("true") => Value::Bool(true),
Token::Ident(s) if s.eq_ignore_ascii_case("false") => Value::Bool(false),
Token::Ident(s) if s.eq_ignore_ascii_case("null") => Value::Null,
}
.map(Expr::Lit);
let tohex = ident_ci("toHex")
.ignore_then(just(Token::LParen))
.ignore_then(expr.clone())
.then_ignore(just(Token::RParen))
.map(|arg| Expr::Attr(Attr::ToHex(Box::new(arg))));
let coalesce_expr = ident_ci("COALESCE")
.ignore_then(just(Token::LParen))
.ignore_then(
expr.clone()
.separated_by(just(Token::Comma))
.collect::<Vec<_>>(),
)
.then_ignore(just(Token::RParen))
.validate(|args, e, emitter| {
if args.is_empty() {
emitter.emit(Rich::custom(
e.span(),
"COALESCE requires at least one argument",
));
}
Expr::Coalesce(args)
});
let nullif_expr = ident_ci("NULLIF")
.ignore_then(just(Token::LParen))
.ignore_then(expr.clone())
.then_ignore(just(Token::Comma))
.then(expr.clone())
.then_ignore(just(Token::RParen))
.map(|(lhs, rhs)| Expr::NullIf {
lhs: Box::new(lhs),
rhs: Box::new(rhs),
});
let method_call = select! {
Token::Ident(s) if s.contains('.') && !s.ends_with('.') => s
}
.then(
just(Token::LParen)
.ignore_then(
expr.clone()
.separated_by(just(Token::Comma))
.collect::<Vec<_>>(),
)
.then_ignore(just(Token::RParen)),
)
.map(|(dotted, args): (String, Vec<Expr>)| {
let (recv, meth) = dotted.rsplit_once('.').unwrap();
Expr::Method {
receiver: Box::new(Expr::Attr(Attr::Field(recv.to_string()))),
name: meth.to_string(),
args,
}
});
let agg_star = just(Token::Star).to(SelectItem::Star);
let agg_attr_arg = attr.clone().map(SelectItem::Attr);
let agg_arg = agg_star.or(agg_attr_arg);
let agg_expr_arm = select! {
Token::Ident(s) if agg_func(&s).is_some() => agg_func(&s).unwrap(),
}
.then_ignore(just(Token::LParen))
.then(agg_arg)
.then_ignore(just(Token::RParen))
.map(|(func, arg)| Expr::Aggregate {
func,
arg: Box::new(arg),
});
let primary = agg_expr_arm
.or(method_call)
.or(coalesce_expr)
.or(nullif_expr)
.or(tohex)
.or(lit)
.or(just(Token::LParen)
.ignore_then(expr.clone())
.then_ignore(just(Token::RParen)))
.or(
attr.clone()
.then(
just(Token::LBracket)
.ignore_then(
expr.clone().or_not().then(
just(Token::Colon)
.ignore_then(expr.clone().or_not())
.or_not(),
),
)
.then_ignore(just(Token::RBracket))
.or_not(),
)
.map(|(base, postfix)| {
let attr_val = match postfix {
None => base,
Some((Some(idx_expr), None)) => {
Attr::ArrayIndex {
base: Box::new(base),
index: Box::new(idx_expr),
}
}
Some((start, Some(end))) => {
Attr::ArraySlice {
base: Box::new(base),
start: start.map(Box::new),
end: end.map(Box::new),
}
}
Some((None, None)) => {
base
}
};
Expr::Attr(attr_val)
}),
);
let unary = just(Token::Minus)
.to(UnaryOp::Neg)
.or(just(Token::Plus).to(UnaryOp::Pos))
.or_not()
.then(primary)
.map(|(u, p)| match u {
None | Some(UnaryOp::Pos) => p,
Some(UnaryOp::Neg) => match p {
Expr::Lit(Value::Int(n)) => Expr::Lit(Value::Int(-n)),
Expr::Lit(Value::Float(f)) => Expr::Lit(Value::Float(-f)),
other => Expr::Unary {
op: UnaryOp::Neg,
arg: Box::new(other),
},
},
});
let mul = unary.clone().foldl(
just(Token::Star)
.to(ArithOp::Mul)
.or(just(Token::Divide).to(ArithOp::Div))
.then(unary)
.repeated(),
|lhs, (op, rhs)| Expr::Binary {
op,
lhs: Box::new(lhs),
rhs: Box::new(rhs),
},
);
mul.clone().foldl(
just(Token::Plus)
.to(ArithOp::Add)
.or(just(Token::Minus).to(ArithOp::Sub))
.then(mul)
.repeated(),
|lhs, (op, rhs)| Expr::Binary {
op,
lhs: Box::new(lhs),
rhs: Box::new(rhs),
},
)
});
let sym_op = select! {
Token::Eq => CompareOp::Eq,
Token::Ne => CompareOp::Ne,
Token::Lt => CompareOp::Lt,
Token::Le => CompareOp::Le,
Token::Gt => CompareOp::Gt,
Token::Ge => CompareOp::Ge,
};
let not_like = ident_ci("NOT")
.ignore_then(ident_ci("LIKE"))
.to(CompareOp::NotLike);
let like = ident_ci("LIKE").to(CompareOp::Like);
let op = sym_op.or(not_like).or(like).labelled("comparison operator");
let retained_set = ident_ci("AS")
.ignore_then(ident_ci("RETAINED"))
.ignore_then(
ident_ci("SET").to(true).or(any().or_not().validate(|_, e, emitter| {
emitter.emit(Rich::custom(
e.span(),
"expected SET after 'AS RETAINED' (usage: SELECT <expr> AS RETAINED SET FROM ...)",
));
true
})),
)
.or_not()
.map(|r| r.unwrap_or(false));
let base_query = recursive(|base_query| {
let from_subquery = just(Token::LParen)
.ignore_then(base_query.clone())
.then_ignore(just(Token::RParen))
.map(|inner: Query| FromSource::Subquery(Box::new(inner)));
let from_class = ident_ci("INSTANCEOF")
.or_not()
.map(|i| i.is_some())
.then(
any_ident()
.labelled("class name")
.map(|name| (name, false))
.or(select! { Token::Str(s) => (s, true) }.labelled("class regex")),
)
.validate(|(instanceof, (class_name, is_regex)), e, emitter| {
if instanceof && is_regex {
emitter.emit(Rich::custom(
e.span(),
"INSTANCEOF requires a bare class name, not a quoted regex \
(usage: FROM INSTANCEOF java.lang.Object, or drop INSTANCEOF \
and use FROM \"<regex>\" for regex matching)",
));
}
FromSource::Class(ClassSpec {
instanceof,
class_name,
is_regex,
})
});
let from_object = select! { Token::Int(n) => n }.map(|n| FromSource::Object(n as u64));
let instanceof_addr = ident_ci("INSTANCEOF")
.ignore_then(select! { Token::Int(_) => () })
.validate(|_, e, emitter| {
emitter.emit(Rich::custom(
e.span(),
"INSTANCEOF <address> is not supported; use INSTANCEOF <class> \
(e.g. FROM INSTANCEOF java.lang.Thread)",
));
FromSource::Object(0)
});
let from_object_expr = just(Token::LParen).rewind().validate(|_, e, emitter| {
emitter.emit(Rich::custom(
e.span(),
"arithmetic/boolean FROM-OBJECTS expressions are not yet supported; \
use FROM OBJECTS <address> (e.g. FROM OBJECTS 0x1295e2f8) or FROM <class>",
));
FromSource::Object(0)
});
let from_source = from_subquery
.or(from_object)
.or(instanceof_addr)
.or(from_object_expr)
.or(from_class);
let predicate = recursive(|pred| {
let paren = just(Token::LParen)
.ignore_then(pred.clone())
.then_ignore(just(Token::RParen));
let instanceof = attr
.clone()
.then_ignore(ident_ci("INSTANCEOF"))
.then(any_ident())
.map(|(_lhs, cname)| Predicate::InstanceOf(cname));
let in_subquery = attr
.clone()
.then_ignore(ident_ci("IN"))
.then_ignore(just(Token::LParen))
.then(base_query.clone())
.then_ignore(just(Token::RParen))
.map(|(lhs, inner): (Attr, Query)| Predicate::InSubquery {
lhs,
inner: Box::new(inner),
});
let in_list = {
let lit_val = select! {
Token::Int(n) => Value::Int(n),
Token::Float(f) => Value::Float(f),
Token::Str(s) => Value::Str(s),
};
let null_val = ident_ci("NULL").to(Value::Null);
let bool_val = ident_ci("TRUE")
.to(Value::Bool(true))
.or(ident_ci("FALSE").to(Value::Bool(false)));
let val = null_val.or(bool_val).or(lit_val);
let not_in = ident_ci("NOT").then_ignore(ident_ci("IN")).to(true);
let just_in = ident_ci("IN").to(false);
let in_kw = not_in.or(just_in);
expr.clone()
.then(in_kw)
.then_ignore(just(Token::LParen))
.then(
val.clone()
.then(
just(Token::Comma)
.ignore_then(val)
.repeated()
.collect::<Vec<_>>(),
)
.map(|(first, rest)| {
let mut vals = vec![first];
vals.extend(rest);
vals
}),
)
.then_ignore(just(Token::RParen))
.map(|((lhs, negated), vals): ((Expr, bool), Vec<Value>)| {
if negated {
let mut terms = vals.into_iter().map(|v| Predicate::Compare {
lhs: lhs.clone(),
op: CompareOp::Ne,
rhs: Expr::Lit(v),
});
let first = terms.next().unwrap();
terms.fold(first, |acc, t| Predicate::And(Box::new(acc), Box::new(t)))
} else {
let mut terms = vals.into_iter().map(|v| Predicate::Compare {
lhs: lhs.clone(),
op: CompareOp::Eq,
rhs: Expr::Lit(v),
});
let first = terms.next().unwrap();
terms.fold(first, |acc, t| Predicate::Or(Box::new(acc), Box::new(t)))
}
})
};
let compare = expr.clone().then(op).then(expr.clone()).validate(
|((lhs, op), rhs), e, emitter| {
if matches!(op, CompareOp::Like | CompareOp::NotLike)
&& !matches!(&rhs, Expr::Lit(Value::Str(_)))
{
emitter.emit(Rich::custom(
e.span(),
"LIKE right-hand side must be a string literal, \
e.g. LIKE \"java\\\\..*\"",
));
}
Predicate::Compare { lhs, op, rhs }
},
);
let between_kw = ident_ci("NOT")
.then_ignore(ident_ci("BETWEEN"))
.to(true)
.or(ident_ci("BETWEEN").to(false));
let between_pred = expr
.clone()
.then(between_kw)
.then(expr.clone())
.then_ignore(ident_ci("AND"))
.then(expr.clone())
.map(|(((subject, negated), lo), hi)| {
if negated {
Predicate::Or(
Box::new(Predicate::Compare {
lhs: subject.clone(),
op: CompareOp::Lt,
rhs: lo,
}),
Box::new(Predicate::Compare {
lhs: subject,
op: CompareOp::Gt,
rhs: hi,
}),
)
} else {
Predicate::And(
Box::new(Predicate::Compare {
lhs: subject.clone(),
op: CompareOp::Ge,
rhs: lo,
}),
Box::new(Predicate::Compare {
lhs: subject,
op: CompareOp::Le,
rhs: hi,
}),
)
}
});
let is_null = expr
.clone()
.then_ignore(ident_ci("IS"))
.then(ident_ci("NOT").or_not().map(|n| n.is_some()))
.then_ignore(ident_ci("NULL"))
.map(|(lhs, negated)| Predicate::Compare {
lhs,
op: if negated {
CompareOp::Ne
} else {
CompareOp::Eq
},
rhs: Expr::Lit(Value::Null),
});
let exists_pred = ident_ci("EXISTS")
.ignore_then(just(Token::LParen))
.ignore_then(base_query.clone())
.then_ignore(just(Token::RParen))
.map(|inner| Predicate::Exists {
inner: Box::new(inner),
negated: false,
});
let primary = paren
.or(instanceof)
.or(in_subquery)
.or(in_list)
.or(between_pred)
.or(is_null)
.or(exists_pred)
.or(compare);
let not = recursive(|not| {
let not_exists = ident_ci("NOT")
.ignore_then(ident_ci("EXISTS"))
.ignore_then(just(Token::LParen))
.ignore_then(base_query.clone())
.then_ignore(just(Token::RParen))
.map(|inner| Predicate::Exists {
inner: Box::new(inner),
negated: true,
});
not_exists
.or(ident_ci("NOT")
.ignore_then(not)
.map(|p| Predicate::Not(Box::new(p))))
.or(primary)
});
let and = not
.clone()
.foldl(ident_ci("AND").ignore_then(not).repeated(), |l, r| {
Predicate::And(Box::new(l), Box::new(r))
});
and.clone()
.foldl(ident_ci("OR").ignore_then(and).repeated(), |l, r| {
Predicate::Or(Box::new(l), Box::new(r))
})
.labelled("predicate expression")
});
let case_expr = ident_ci("CASE")
.ignore_then(
ident_ci("WHEN")
.ignore_then(predicate.clone())
.then_ignore(ident_ci("THEN"))
.then(expr.clone())
.repeated()
.at_least(1)
.collect::<Vec<_>>(),
)
.then(ident_ci("ELSE").ignore_then(expr.clone()).or_not())
.then_ignore(ident_ci("END"))
.map(|(branches, else_)| Expr::Case {
branches,
else_: else_.map(Box::new),
});
let select_item = recursive(|item| {
let agg = select! {
Token::Ident(s) if agg_func(&s).is_some() => agg_func(&s).unwrap(),
}
.then_ignore(just(Token::LParen))
.then(item.clone())
.then_ignore(just(Token::RParen))
.map(
|(func, (arg, _alias)): (AggFunc, (SelectItem, Option<String>))| {
(
SelectItem::Aggregate {
func,
arg: Box::new(arg),
},
None::<String>,
)
},
);
let percentile_item = ident_ci("PERCENTILE")
.ignore_then(just(Token::LParen))
.ignore_then(item.clone())
.then_ignore(just(Token::Comma))
.then(select! { Token::Int(n) => n })
.then_ignore(just(Token::RParen))
.validate(|((arg, _alias), p): ((SelectItem, Option<String>), i64), e, emitter| {
if !(1..=100).contains(&p) {
emitter.emit(Rich::custom(
e.span(),
format!(
"PERCENTILE(<arg>, p): p must be an integer between 1 and 100, got {p}"
),
));
}
let clamped = p.clamp(1, 100) as u8;
(
SelectItem::Aggregate {
func: AggFunc::Percentile(clamped),
arg: Box::new(arg),
},
None::<String>,
)
});
let path_operand = any_ident().map(|s: String| {
if s.contains('.') || s.contains('*') {
PathOperand::Class(s)
} else {
PathOperand::Alias(s)
}
});
let path_item = ident_ci("path")
.ignore_then(just(Token::LParen))
.ignore_then(path_operand.clone())
.then_ignore(just(Token::Comma))
.then(path_operand)
.then_ignore(just(Token::RParen))
.map(|(from, to)| (SelectItem::Path { from, to }, None::<String>));
let star = just(Token::Star).map(|_| (SelectItem::Star, None::<String>));
let tostring_item =
dom_fn("toString").map(|a| (SelectItem::ToString(a), None::<String>));
let case_item = case_expr
.clone()
.map(|e| (SelectItem::Expr(Box::new(e)), None::<String>));
let select_attr_item = select! { Token::Ident(p) if p.ends_with('.') => p }
.or_not()
.then(select! { Token::At(name) => name })
.then(
one_of([Token::Plus, Token::Minus, Token::Star, Token::Divide])
.rewind()
.or_not(),
)
.map(
|((prefix, name), next_arith): ((Option<String>, String), _)| {
if next_arith.is_some() {
return None; }
let (attr, canonical) = match name.as_str() {
"usedHeapSize" => (Attr::UsedHeapSize, "@usedHeapSize"),
"shallowHeapSize" => (Attr::UsedHeapSize, "@usedHeapSize"),
"shallowSize" => (Attr::UsedHeapSize, "@usedHeapSize"),
"retainedHeapSize" => (Attr::RetainedHeapSize, "@retainedHeapSize"),
"retainedHeap" => (Attr::RetainedHeapSize, "@retainedHeapSize"),
_ => return None,
};
let has_hops = prefix
.as_deref()
.map(|p| p.trim_end_matches('.').contains('.'))
.unwrap_or(false);
if has_hops {
return None; }
let written = format!("@{name}");
let auto_alias = if written != canonical {
Some(written)
} else {
None
};
Some((SelectItem::Attr(attr), auto_alias))
},
)
.filter_map(|x| x);
let expr_item = expr.clone().map(|e| {
let item = match e {
Expr::Attr(a) => SelectItem::Attr(a),
other => SelectItem::Expr(Box::new(other)),
};
(item, None::<String>)
});
let base_item = percentile_item
.or(agg)
.or(path_item)
.or(tostring_item)
.or(star)
.or(case_item)
.or(select_attr_item)
.or(expr_item);
let alias_name = ident_ci("AS").ignore_then(
select! { Token::Str(s) => s }.or(any_ident().and_is(ident_ci("RETAINED").not())),
);
base_item
.then(alias_name.or_not())
.map(|((item, pre_alias), explicit_alias)| {
(item, explicit_alias.map(Some).unwrap_or(pre_alias))
})
.labelled("expression")
});
let select_list = select_item
.separated_by(just(Token::Comma))
.at_least(1)
.collect::<Vec<_>>()
.map(|pairs: Vec<(SelectItem, Option<String>)>| {
let (items, aliases): (Vec<_>, Vec<_>) = pairs.into_iter().unzip();
(items, aliases)
});
ident_ci("SELECT")
.ignore_then(ident_ci("DISTINCT").or_not().map(|d| d.is_some()))
.then(retained_set.clone())
.then_ignore(ident_ci("OBJECTS").or_not())
.then(select_list)
.then(retained_set.clone())
.then_ignore(ident_ci("FROM"))
.then_ignore(ident_ci("OBJECTS").or_not())
.then(from_source)
.then(any_ident().and_is(reserved_ident().not()).or_not())
.then(ident_ci("WHERE").ignore_then(predicate.clone()).or_not())
.then(
ident_ci("GROUP")
.ignore_then(ident_ci("BY"))
.ignore_then(
case_expr
.clone()
.or(expr.clone())
.separated_by(just(Token::Comma))
.at_least(1)
.collect::<Vec<_>>(),
)
.or_not()
.map(|v| v.unwrap_or_default()),
)
.then(ident_ci("HAVING").ignore_then(predicate.clone()).or_not())
.then(
ident_ci("ORDER")
.ignore_then(ident_ci("BY"))
.ignore_then(attr.clone())
.then(
ident_ci("ASC")
.to(SortDir::Asc)
.or(ident_ci("DESC").to(SortDir::Desc))
.or_not()
.map(|d| d.unwrap_or(SortDir::Asc)),
)
.map(|(key, dir)| OrderBy { key, dir })
.or_not(),
)
.then(
ident_ci("LIMIT")
.ignore_then(
select! { Token::Int(n) if n >= 0 => n as u64 }.labelled("LIMIT count"),
)
.or_not(),
)
.then(
ident_ci("OFFSET")
.ignore_then(
select! { Token::Int(n) if n >= 0 => n as u64 }.labelled("OFFSET count"),
)
.or_not(),
)
.map(
|(
(
(
(
(
(
(
(
(
(
(distinct, leading_retained),
(select, select_aliases),
),
trailing_retained,
),
from,
),
alias,
),
where_,
),
group_by,
),
having,
),
order_by,
),
limit,
),
offset,
)| {
let mut q = Query {
distinct,
select,
select_aliases,
retained_set: leading_retained || trailing_retained,
from,
alias,
where_,
order_by,
limit,
offset,
union_branches: Vec::new(),
union_limit: None,
group_by,
having,
intersect_branches: Vec::new(),
except_branches: Vec::new(),
};
normalize_query_ref_paths(&mut q);
q
},
)
});
let paren_branch = just(Token::LParen)
.ignore_then(base_query.clone())
.then_ignore(just(Token::RParen))
.map(|q| (q, true)); let union_branch = paren_branch.or(base_query.clone().map(|q| (q, false)));
let trailing_limit = ident_ci("LIMIT")
.ignore_then(select! { Token::Int(n) if n >= 0 => n as u64 }.labelled("LIMIT count"))
.or_not();
let intersect_tail = ident_ci("INTERSECT")
.ignore_then(base_query.clone())
.repeated()
.collect::<Vec<Query>>();
let except_tail = ident_ci("EXCEPT")
.ignore_then(base_query.clone())
.repeated()
.collect::<Vec<Query>>();
base_query
.clone()
.then(
ident_ci("UNION")
.ignore_then(union_branch)
.repeated()
.collect::<Vec<_>>(),
)
.then(intersect_tail)
.then(except_tail)
.then(trailing_limit)
.then_ignore(end())
.validate(
|((((mut head, union_tail), intersects), excepts), trailing): (
(((Query, Vec<(Query, bool)>), Vec<Query>), Vec<Query>),
Option<u64>,
),
e,
emitter| {
let last_was_paren = union_tail.last().map(|(_, p)| *p).unwrap_or(false);
head.union_branches = union_tail.into_iter().map(|(q, _)| q).collect();
if !head.union_branches.is_empty() {
if let Some(n) = trailing {
head.union_limit = Some(n);
} else if !last_was_paren {
if let Some(last) = head.union_branches.last_mut() {
if let Some(n) = last.limit.take() {
head.union_limit = Some(n);
}
if last.order_by.is_some() {
head.order_by = last.order_by.take();
}
}
}
}
if !intersects.is_empty() && !excepts.is_empty() {
emitter.emit(Rich::custom(
e.span(),
"cannot mix INTERSECT and EXCEPT in one chain without subquery \
parentheses — wrap one side in a subquery: \
(SELECT ... INTERSECT ...) EXCEPT ...",
));
}
head.intersect_branches = intersects;
head.except_branches = excepts;
head
},
)
}
fn normalize_query_ref_paths(q: &mut Query) {
let alias = q.alias.clone();
for item in &mut q.select {
normalize_select_item(item, alias.as_deref());
}
if let Some(pred) = &mut q.where_ {
normalize_predicate(pred, alias.as_deref());
}
if let Some(ob) = &mut q.order_by {
normalize_attr(&mut ob.key, alias.as_deref());
}
for e in &mut q.group_by {
normalize_expr(e, alias.as_deref());
}
if let Some(pred) = &mut q.having {
normalize_predicate(pred, alias.as_deref());
}
}
fn normalize_select_item(item: &mut SelectItem, alias: Option<&str>) {
if let SelectItem::Attr(Attr::Field(name)) = &*item {
if let Some(al) = alias {
if name == al && !name.contains('.') {
*item = SelectItem::Star;
return;
}
}
}
match item {
SelectItem::Attr(a) => normalize_attr(a, alias),
SelectItem::Aggregate { arg, .. } => normalize_select_item(arg, alias),
SelectItem::Star => {}
SelectItem::Path { .. } => {}
SelectItem::ToString(_) => {}
SelectItem::Expr(e) => {
normalize_expr(e, alias);
if let Expr::Attr(a) = e.as_ref() {
*item = SelectItem::Attr(a.clone());
}
}
}
}
fn normalize_predicate(pred: &mut Predicate, alias: Option<&str>) {
match pred {
Predicate::And(a, b) | Predicate::Or(a, b) => {
normalize_predicate(a, alias);
normalize_predicate(b, alias);
}
Predicate::Not(a) => normalize_predicate(a, alias),
Predicate::Compare { lhs, rhs, .. } => {
normalize_expr(lhs, alias);
normalize_expr(rhs, alias);
}
Predicate::InSubquery { lhs, .. } => normalize_attr(lhs, alias),
Predicate::InstanceOf(_) => {}
Predicate::Exists { .. } => {}
}
}
fn normalize_expr(e: &mut Expr, alias: Option<&str>) {
match e {
Expr::Attr(a) => normalize_attr(a, alias),
Expr::Lit(_) => {}
Expr::Binary { lhs, rhs, .. } => {
normalize_expr(lhs, alias);
normalize_expr(rhs, alias);
}
Expr::Unary { arg, .. } => normalize_expr(arg, alias),
Expr::Method {
receiver,
name,
args,
} => {
let hop_field = match name.as_str() {
"getKey" => Some("key"),
"getValue" => Some("value"),
_ => None,
};
if let (Some(field), true, Expr::Attr(Attr::Field(recv))) =
(hop_field, args.is_empty(), receiver.as_ref())
{
let mut new_attr = Attr::RefPath {
hops: vec![recv.clone(), field.to_string()],
tail: Box::new(Attr::ObjectAddress),
role: RefRole::ProjectionOnly,
};
normalize_attr(&mut new_attr, alias);
*e = Expr::Attr(new_attr);
return;
}
normalize_expr(receiver, alias);
for a in args {
normalize_expr(a, alias);
}
}
Expr::Aggregate { .. } => {} Expr::Case { branches, else_ } => {
for (pred, then_expr) in branches {
normalize_predicate(pred, alias);
normalize_expr(then_expr, alias);
}
if let Some(e) = else_ {
normalize_expr(e, alias);
}
}
Expr::Coalesce(args) => {
for arg in args {
normalize_expr(arg, alias);
}
}
Expr::NullIf { lhs, rhs } => {
normalize_expr(lhs, alias);
normalize_expr(rhs, alias);
}
}
}
fn normalize_attr(a: &mut Attr, alias: Option<&str>) {
if let Attr::RefPath { hops, tail, role } = a {
if let Some(al) = alias {
if hops.first().map(String::as_str) == Some(al) {
hops.remove(0);
}
}
if hops.is_empty() {
*a = (**tail).clone();
} else {
let _ = role; normalize_attr(tail, alias);
}
return;
}
if let Attr::ArrayIndex { base, index } = a {
normalize_attr(base, alias);
normalize_expr(index, alias);
return;
}
if let Attr::ArraySlice { base, start, end } = a {
normalize_attr(base, alias);
if let Some(s) = start {
normalize_expr(s, alias);
}
if let Some(e) = end {
normalize_expr(e, alias);
}
return;
}
let Attr::Field(name) = a else { return };
if !name.contains('.') {
return;
}
let stripped: &str = match alias {
Some(al) => name
.strip_prefix(al)
.and_then(|rest| rest.strip_prefix('.'))
.unwrap_or(name),
None => name.as_str(),
};
let segs: Vec<&str> = stripped.split('.').collect();
if segs.len() < 2 {
*a = Attr::Field(stripped.to_string());
return;
}
let (tail, hops) = segs.split_last().unwrap();
*a = Attr::RefPath {
hops: hops.iter().map(|s| s.to_string()).collect(),
tail: Box::new(Attr::Field((*tail).to_string())),
role: RefRole::ProjectionOnly,
};
}
fn reserved_ident<'a, I>() -> impl Parser<'a, I, String, extra::Err<Rich<'a, Token>>> + Clone
where
I: ValueInput<'a, Token = Token, Span = SimpleSpan>,
{
select! { Token::Ident(s) if is_reserved(&s) => s }
}
pub const KEYWORDS: &[&str] = &["SELECT", "DISTINCT", "FROM"];
pub const RESERVED: &[&str] = &[
"WHERE",
"LIMIT",
"OFFSET",
"UNION",
"AND",
"OR",
"NOT",
"LIKE",
"INSTANCEOF",
"IN",
"ORDER",
"BY",
"ASC",
"DESC",
"OBJECTS",
"AS",
"RETAINED",
"SET",
"GROUP",
"HAVING",
"CASE",
"WHEN",
"THEN",
"ELSE",
"END",
"BETWEEN",
"COALESCE",
"NULLIF",
"EXISTS",
"IS",
"NULL",
"INTERSECT",
"EXCEPT",
];
pub const AGG_FUNCS: &[&str] = &["COUNT", "SUM", "MIN", "MAX", "AVG", "PERCENTILE", "MEDIAN"];
pub const FUNCS: &[&str] = &[
"classof",
"toString",
"toHex",
"path",
"dominators",
"dominatorof",
];
pub const METHODS: &[&str] = &[
"length",
"size",
"getKey",
"getValue",
"equals",
"contains",
"intValue",
"longValue",
"shortValue",
"byteValue",
"floatValue",
"doubleValue",
"booleanValue",
"charValue",
"toString",
"getName",
"getObjectAddress",
"getObjectId",
"getUsedHeapSize",
"getRetainedHeapSize",
"getClazz",
];
pub const ATTRIBUTES: &[&str] = &[
"@objectId",
"@objectAddress",
"@usedHeapSize",
"@retainedHeapSize",
"@displayName",
"@name",
"@length",
"@inbounds",
"@outbounds",
"@valueArray",
"@referenceArray",
"@GCRoots",
"@GCRootInfo",
"@info",
];
#[cfg(test)]
pub fn completion_words() -> Vec<&'static str> {
KEYWORDS
.iter()
.chain(RESERVED.iter())
.chain(AGG_FUNCS.iter())
.chain(ATTRIBUTES.iter())
.chain(FUNCS.iter())
.copied()
.collect()
}
fn agg_func(s: &str) -> Option<AggFunc> {
match () {
_ if s.eq_ignore_ascii_case("COUNT") => Some(AggFunc::Count),
_ if s.eq_ignore_ascii_case("SUM") => Some(AggFunc::Sum),
_ if s.eq_ignore_ascii_case("MIN") => Some(AggFunc::Min),
_ if s.eq_ignore_ascii_case("MAX") => Some(AggFunc::Max),
_ if s.eq_ignore_ascii_case("AVG") => Some(AggFunc::Avg),
_ if s.eq_ignore_ascii_case("MEDIAN") => Some(AggFunc::Median),
_ => None,
}
}
fn is_reserved(s: &str) -> bool {
RESERVED.iter().any(|k| s.eq_ignore_ascii_case(k))
}
fn line_col(src: &str, byte_offset: usize) -> (usize, usize) {
let capped = byte_offset.min(src.len());
let mut line = 1;
let mut col = 1;
for (i, ch) in src.char_indices() {
if i >= capped {
break;
}
if ch == '\n' {
line += 1;
col = 1;
} else {
col += 1;
}
}
(line, col)
}
fn lev(a: &str, b: &str) -> usize {
let a = a.as_bytes();
let b = b.as_bytes();
let mut prev: Vec<usize> = (0..=b.len()).collect();
let mut curr = vec![0usize; b.len() + 1];
for (i, &ca) in a.iter().enumerate() {
curr[0] = i + 1;
for (j, &cb) in b.iter().enumerate() {
let cost = if ca == cb { 0 } else { 1 };
curr[j + 1] = (prev[j + 1] + 1).min(curr[j] + 1).min(prev[j] + cost);
}
std::mem::swap(&mut prev, &mut curr);
}
prev[b.len()]
}
fn did_you_mean<'a>(token: &str, candidates: impl IntoIterator<Item = &'a str>) -> Option<String> {
let t = token.to_ascii_lowercase();
candidates
.into_iter()
.map(|c| (c, lev(&t, &c.to_ascii_lowercase())))
.filter(|(_, d)| *d <= 2)
.min_by_key(|(_, d)| *d)
.map(|(c, _)| c.to_string())
}
fn suggest_for_found(found: Option<&Token>) -> Option<String> {
match found {
Some(Token::Ident(s)) => did_you_mean(
s,
KEYWORDS
.iter()
.chain(RESERVED.iter())
.chain(AGG_FUNCS.iter())
.chain(FUNCS.iter())
.copied(),
)
.filter(|s| !s.eq_ignore_ascii_case(found_ident_or_empty(found))),
_ => None,
}
}
fn found_ident_or_empty(found: Option<&Token>) -> &str {
match found {
Some(Token::Ident(s)) => s,
_ => "",
}
}
fn unknown_call_hint(src: &str, lparen: usize) -> Option<String> {
let before = &src[..lparen];
let trimmed = before.trim_end();
let start = trimmed
.rfind(|c: char| !c.is_ascii_alphanumeric() && c != '_')
.map(|i| i + 1)
.unwrap_or(0);
let callee = &trimmed[start..];
if callee.is_empty() {
return None;
}
let is_known = |name: &str| {
AGG_FUNCS
.iter()
.chain(FUNCS.iter())
.chain(METHODS.iter())
.any(|k| k.eq_ignore_ascii_case(name))
};
if is_known(callee) {
return None;
}
did_you_mean(callee, AGG_FUNCS.iter().chain(FUNCS.iter()).copied())
}
fn blank_query_message(src: &str) -> Option<String> {
if src.trim().is_empty() {
Some("empty query — supply OQL, e.g. `SELECT * FROM java.lang.String`".to_string())
} else {
None
}
}
fn missing_from_hint(src: &str) -> Option<&'static str> {
let low = src.to_ascii_lowercase();
let has_kw = |kw: &str| {
low.split(|c: char| !c.is_ascii_alphanumeric() && c != '_')
.any(|w| w == kw)
};
if has_kw("select") && !has_kw("from") {
Some("a query needs a `FROM <class>` clause (e.g. `SELECT * FROM java.lang.String`)")
} else {
None
}
}
fn double_eq_hint(src: &str, eq_offset: usize) -> Option<&'static str> {
let before = src.get(..eq_offset)?;
if before.ends_with('=') {
Some("use `=` for equality (OQL has no `==`)")
} else {
None
}
}
fn order_by_aggregate_hint(src: &str, lparen_offset: usize) -> Option<&'static str> {
let before = src[..lparen_offset].trim_end();
let last_word = before.split_whitespace().next_back()?;
let is_agg = AGG_FUNCS.iter().any(|&f| f.eq_ignore_ascii_case(last_word));
if !is_agg {
return None;
}
let upper = src[..lparen_offset].to_ascii_uppercase();
if !upper.contains("ORDER") || !upper.contains("BY") {
return None;
}
Some(
"aggregate functions are not valid ORDER BY keys; \
use a column alias instead (e.g. `COUNT(*) AS n … ORDER BY n DESC`)",
)
}
fn missing_by_hint(src: &str) -> Option<&'static str> {
let words: Vec<String> = src
.split(|c: char| !c.is_ascii_alphanumeric() && c != '_')
.filter(|w| !w.is_empty())
.map(|w| w.to_ascii_lowercase())
.collect();
let it = words.iter().enumerate();
for (i, w) in it {
if w == "order" && words.get(i + 1).map(String::as_str) != Some("by") {
return Some(
"`ORDER` must be followed by `BY` (e.g. `ORDER BY @retainedHeapSize DESC`)",
);
}
}
None
}
fn compact_error(src: &str, e: &Rich<'_, Token>) -> String {
let span = *e.span();
let (line, col) = line_col(src, span.start);
match e.reason() {
chumsky::error::RichReason::Custom(msg) => format!("{msg} at {line}:{col}"),
_ => {
let found = e
.found()
.map(token_display)
.unwrap_or_else(|| "end of input".to_string());
if e.found().is_none() {
if let Some(hint) = missing_from_hint(src) {
return format!("unexpected {found} at {line}:{col} — {hint}");
}
}
if let Some(hint) = missing_by_hint(src) {
return format!("unexpected {found} at {line}:{col} — {hint}");
}
if matches!(e.found(), Some(Token::Eq)) {
if let Some(hint) = double_eq_hint(src, span.start) {
return format!("unexpected {found} at {line}:{col} — {hint}");
}
}
if matches!(e.found(), Some(Token::LParen)) {
if let Some(hint) = order_by_aggregate_hint(src, span.start) {
return format!("unexpected {found} at {line}:{col} — {hint}");
}
if let Some(callee) = unknown_call_hint(src, span.start) {
return format!(
"unexpected {found} at {line}:{col} — did you mean `{callee}`?"
);
}
}
let suggestion = suggest_for_found(e.found());
match suggestion {
Some(s) => format!("unexpected {found} at {line}:{col} — did you mean `{s}`?"),
None => format!("unexpected {found} at {line}:{col}"),
}
}
}
}
fn parse_internal(src: &str) -> Result<Query, String> {
if let Some(msg) = blank_query_message(src) {
return Err(msg);
}
let toks = tokenize_spanned(src)?;
let eoi: SimpleSpan = (src.len()..src.len()).into();
let stream = Stream::from_iter(toks).map(eoi, |(t, s): (Token, SimpleSpan)| (t, s));
parser().parse(stream).into_result().map_err(|errs| {
errs.iter()
.map(|e| compact_error(src, e))
.collect::<Vec<_>>()
.join("; ")
})
}
#[allow(dead_code)]
pub fn parse(src: &str) -> Result<Query, QueryError> {
parse_internal(src).map_err(QueryError)
}
pub fn parse_or_report(src: &str) -> Result<Query, String> {
if let Some(msg) = blank_query_message(src) {
return Err(msg);
}
let toks = match tokenize_spanned(src) {
Ok(t) => t,
Err(e) => return Err(format!("tokenize error: {e}")),
};
let eoi: SimpleSpan = (src.len()..src.len()).into();
let stream = Stream::from_iter(toks).map(eoi, |(t, s): (Token, SimpleSpan)| (t, s));
match parser().parse(stream).into_result() {
Ok(q) => Ok(q),
Err(errs) => {
let mut buf = Vec::new();
for e in &errs {
let span = *e.span();
let msg = match e.reason() {
chumsky::error::RichReason::Custom(m) => m.clone(),
_ => {
let found = e
.found()
.map(token_display)
.unwrap_or_else(|| "end of input".to_string());
if e.found().is_none() {
if let Some(hint) = missing_from_hint(src) {
format!("unexpected {found} — {hint}")
} else {
format!("unexpected {found}")
}
} else if let Some(hint) = missing_by_hint(src) {
format!("unexpected {found} — {hint}")
} else if matches!(e.found(), Some(Token::Eq))
&& double_eq_hint(src, span.start).is_some()
{
format!(
"unexpected {found} — {}",
double_eq_hint(src, span.start).unwrap()
)
} else if matches!(e.found(), Some(Token::LParen)) {
if let Some(hint) = order_by_aggregate_hint(src, span.start) {
format!("unexpected {found} — {hint}")
} else {
match unknown_call_hint(src, span.start) {
Some(callee) => {
format!("unexpected {found} — did you mean `{callee}`?")
}
None => format!("unexpected {found}"),
}
}
} else {
match suggest_for_found(e.found()) {
Some(s) => format!("unexpected {found} — did you mean `{s}`?"),
None => format!("unexpected {found}"),
}
}
}
};
let mut out = Vec::new();
Report::build(ReportKind::Error, ("query", span.into_range()))
.with_message(&msg)
.with_label(
Label::new(("query", span.into_range()))
.with_message(&msg)
.with_color(Color::Red),
)
.finish()
.write(("query", Source::from(src)), &mut out)
.ok();
buf.push(String::from_utf8_lossy(&out).into_owned());
}
Err(buf.join("\n"))
}
}
}
pub struct CompletionContext {
pub labels: Vec<String>,
pub tokens: Vec<Token>,
}
pub fn parse_for_complete(src: &str, cursor_pos: usize) -> CompletionContext {
let prefix = &src[..cursor_pos.min(src.len())];
let toks = match tokenize_spanned(prefix) {
Ok(t) => t,
Err(_) => {
return CompletionContext {
labels: vec![],
tokens: vec![],
};
}
};
let eoi_span: SimpleSpan = (cursor_pos..cursor_pos).into();
let stream = Stream::from_iter(toks).map(eoi_span, |(t, s)| (t, s));
let (_output, errors) = parser().parse(stream).into_output_errors();
let mut labels: Vec<String> = Vec::new();
let mut tokens: Vec<Token> = Vec::new();
for err in &errors {
if err.span().end + 1 < cursor_pos {
continue;
}
for pat in err.expected() {
match pat {
chumsky::error::RichPattern::Label(s) => {
let s = s.to_string();
if !labels.contains(&s) {
labels.push(s);
}
}
chumsky::error::RichPattern::Token(t) if !tokens.contains(&**t) => {
tokens.push((**t).clone());
}
_ => {}
}
}
}
CompletionContext { labels, tokens }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::query::ast::*;
fn toks(src: &str) -> Vec<Token> {
tokenize_spanned(src)
.unwrap_or_else(|e| panic!("tokenize failed for {src:?}: {e}"))
.into_iter()
.map(|(t, _)| t)
.collect()
}
fn id(s: &str) -> Token {
Token::Ident(s.into())
}
fn field(s: &str) -> Attr {
Attr::Field(s.into())
}
fn cmp(lhs: Attr, op: CompareOp, rhs: Value) -> Predicate {
Predicate::Compare {
lhs: Expr::Attr(lhs),
op,
rhs: Expr::Lit(rhs),
}
}
fn and(a: Predicate, b: Predicate) -> Predicate {
Predicate::And(Box::new(a), Box::new(b))
}
fn or(a: Predicate, b: Predicate) -> Predicate {
Predicate::Or(Box::new(a), Box::new(b))
}
fn not(a: Predicate) -> Predicate {
Predicate::Not(Box::new(a))
}
fn q(
distinct: bool,
select: Vec<SelectItem>,
instanceof: bool,
class_name: &str,
alias: Option<&str>,
where_: Option<Predicate>,
limit: Option<u64>,
) -> Query {
let n = select.len();
Query {
distinct,
select,
select_aliases: vec![None; n],
retained_set: false,
from: FromSource::Class(ClassSpec {
instanceof,
class_name: class_name.into(),
is_regex: false,
}),
alias: alias.map(|s| s.into()),
where_,
order_by: None,
limit,
offset: None,
union_branches: Vec::new(),
union_limit: None,
group_by: Vec::new(),
having: None,
intersect_branches: Vec::new(),
except_branches: Vec::new(),
}
}
fn star() -> Vec<SelectItem> {
vec![SelectItem::Star]
}
fn attr_sel(a: Attr) -> SelectItem {
SelectItem::Attr(a)
}
fn agg(func: AggFunc, arg: SelectItem) -> SelectItem {
SelectItem::Aggregate {
func,
arg: Box::new(arg),
}
}
#[test]
fn parse_from_objects_numeric_id() {
use super::FromSource;
assert_eq!(
super::parse("SELECT * FROM OBJECTS 1").unwrap().from,
FromSource::Object(1)
);
assert_eq!(
super::parse("SELECT * FROM OBJECTS 0x10").unwrap().from,
FromSource::Object(16)
);
assert_eq!(
super::parse("SELECT * FROM OBJECTS 0x0").unwrap().from,
FromSource::Object(0)
);
}
#[test]
fn reject_from_objects_expr_and_instanceof_addr() {
let e = super::parse("SELECT * FROM OBJECTS (1 + 2)").unwrap_err();
assert!(
e.to_string().contains("arithmetic/boolean FROM-OBJECTS"),
"got: {e}"
);
let e = super::parse("SELECT * FROM INSTANCEOF 0x1").unwrap_err();
assert!(
e.to_string().to_lowercase().contains("instanceof"),
"got: {e}"
);
}
#[test]
fn token_stream_cases() {
let cases: Vec<(&str, Vec<Token>)> = vec![
(
"SELECT * FROM java.lang.String s",
vec![
id("SELECT"),
Token::Star,
id("FROM"),
id("java.lang.String"),
id("s"),
],
),
(
"WHERE @usedHeapSize > 100 AND name = \"foo\"",
vec![
id("WHERE"),
Token::At("usedHeapSize".into()),
Token::Gt,
Token::Int(100),
id("AND"),
id("name"),
Token::Eq,
Token::Str("foo".into()),
],
),
(
"= != < <= > >=",
vec![
Token::Eq,
Token::Ne,
Token::Lt,
Token::Le,
Token::Gt,
Token::Ge,
],
),
("( , )", vec![Token::LParen, Token::Comma, Token::RParen]),
("-3.5", vec![Token::Minus, Token::Float(3.5)]),
("-42", vec![Token::Minus, Token::Int(42)]),
("1.5", vec![Token::Float(1.5)]),
("7.", vec![Token::Float(7.0)]),
("com.acme.*", vec![id("com.acme.*")]),
("com.a*b.C", vec![id("com.a*b.C")]),
("Outer$Inner", vec![id("Outer$Inner")]),
("_hidden", vec![id("_hidden")]),
("\"\"", vec![Token::Str("".into())]),
("\"a b c\"", vec![Token::Str("a b c".into())]),
("@a.b$c", vec![Token::At("a.b$c".into())]),
("* x*", vec![Token::Star, id("x*")]),
(
"SELECT\t*\nFROM\rC",
vec![id("SELECT"), Token::Star, id("FROM"), id("C")],
),
("char[]", vec![id("char[]")]),
("java.lang.String[]", vec![id("java.lang.String[]")]),
("int[][]", vec![id("int[][]")]),
];
for (src, expected) in cases {
assert_eq!(toks(src), expected, "token stream mismatch for {src:?}");
}
}
#[test]
fn lexes_arithmetic_operators() {
let toks: Vec<Token> = crate::query::parse::tokenize_spanned("@a + 2 - 3 * 4 / 5")
.unwrap()
.into_iter()
.map(|(t, _)| t)
.collect();
assert_eq!(
toks,
vec![
Token::At("a".into()),
Token::Plus,
Token::Int(2),
Token::Minus,
Token::Int(3),
Token::Star,
Token::Int(4),
Token::Divide,
Token::Int(5),
]
);
}
#[test]
fn minus_before_number_is_operator_not_negative_literal() {
let toks: Vec<Token> = crate::query::parse::tokenize_spanned("1-2")
.unwrap()
.into_iter()
.map(|(t, _)| t)
.collect();
assert_eq!(toks, vec![Token::Int(1), Token::Minus, Token::Int(2)]);
}
#[test]
fn ast_cases() {
let cases: Vec<(&str, Query)> = vec![
(
"SELECT * FROM java.lang.String s",
q(
false,
star(),
false,
"java.lang.String",
Some("s"),
None,
None,
),
),
(
"SELECT * FROM C",
q(false, star(), false, "C", None, None, None),
),
(
"SELECT DISTINCT name FROM C",
q(
false,
vec![attr_sel(field("name"))],
false,
"C",
None,
None,
None,
)
.tap_distinct(),
),
(
"SELECT * FROM INSTANCEOF java.util.List",
q(false, star(), true, "java.util.List", None, None, None),
),
(
"SELECT * FROM C LIMIT 5",
q(false, star(), false, "C", None, None, Some(5)),
),
(
"SELECT * FROM C LIMIT 0",
q(false, star(), false, "C", None, None, Some(0)),
),
(
"SELECT @objectId, name, @usedHeapSize FROM C",
q(
false,
vec![
attr_sel(Attr::ObjectId),
attr_sel(field("name")),
attr_sel(Attr::UsedHeapSize),
],
false,
"C",
None,
None,
None,
),
),
(
"SELECT @objectId, @objectAddress, @usedHeapSize, @displayName, @length FROM C",
q(
false,
vec![
attr_sel(Attr::ObjectId),
attr_sel(Attr::ObjectAddress),
attr_sel(Attr::UsedHeapSize),
attr_sel(Attr::DisplayName),
attr_sel(Attr::Length),
],
false,
"C",
None,
None,
None,
),
),
(
"SELECT classof(s) FROM java.lang.String s",
q(
false,
vec![attr_sel(Attr::ClassOf)],
false,
"java.lang.String",
Some("s"),
None,
None,
),
),
(
"SELECT COUNT(*) FROM C",
q(
false,
vec![agg(AggFunc::Count, SelectItem::Star)],
false,
"C",
None,
None,
None,
),
),
(
"SELECT SUM(@usedHeapSize) FROM C",
q(
false,
vec![agg(AggFunc::Sum, attr_sel(Attr::UsedHeapSize))],
false,
"C",
None,
None,
None,
),
),
(
"SELECT MIN(x), MAX(x), AVG(x) FROM C",
q(
false,
vec![
agg(AggFunc::Min, attr_sel(field("x"))),
agg(AggFunc::Max, attr_sel(field("x"))),
agg(AggFunc::Avg, attr_sel(field("x"))),
],
false,
"C",
None,
None,
None,
),
),
(
"SELECT * FROM C WHERE hash > 0",
q(
false,
star(),
false,
"C",
None,
Some(cmp(field("hash"), CompareOp::Gt, Value::Int(0))),
None,
),
),
(
"SELECT * FROM C WHERE name = \"main\"",
q(
false,
star(),
false,
"C",
None,
Some(cmp(field("name"), CompareOp::Eq, Value::Str("main".into()))),
None,
),
),
(
"SELECT * FROM C WHERE ratio <= 1.5",
q(
false,
star(),
false,
"C",
None,
Some(cmp(field("ratio"), CompareOp::Le, Value::Float(1.5))),
None,
),
),
(
"SELECT * FROM C WHERE flag = true",
q(
false,
star(),
false,
"C",
None,
Some(cmp(field("flag"), CompareOp::Eq, Value::Bool(true))),
None,
),
),
(
"SELECT * FROM C WHERE a = FALSE AND b != NULL",
q(
false,
star(),
false,
"C",
None,
Some(and(
cmp(field("a"), CompareOp::Eq, Value::Bool(false)),
cmp(field("b"), CompareOp::Ne, Value::Null),
)),
None,
),
),
(
"SELECT * FROM C WHERE NOT a = 1 OR b = 2 AND c = 3",
q(
false,
star(),
false,
"C",
None,
Some(or(
not(cmp(field("a"), CompareOp::Eq, Value::Int(1))),
and(
cmp(field("b"), CompareOp::Eq, Value::Int(2)),
cmp(field("c"), CompareOp::Eq, Value::Int(3)),
),
)),
None,
),
),
(
"SELECT * FROM C WHERE (a = 1 OR b = 2) AND c = 3",
q(
false,
star(),
false,
"C",
None,
Some(and(
or(
cmp(field("a"), CompareOp::Eq, Value::Int(1)),
cmp(field("b"), CompareOp::Eq, Value::Int(2)),
),
cmp(field("c"), CompareOp::Eq, Value::Int(3)),
)),
None,
),
),
(
"SELECT * FROM C WHERE s INSTANCEOF java.lang.String",
q(
false,
star(),
false,
"C",
None,
Some(Predicate::InstanceOf("java.lang.String".into())),
None,
),
),
(
"SELECT * FROM C WHERE NOT NOT a = 1",
q(
false,
star(),
false,
"C",
None,
Some(not(not(cmp(field("a"), CompareOp::Eq, Value::Int(1))))),
None,
),
),
(
"SELECT DISTINCT @objectId, name FROM INSTANCEOF java.lang.Thread t \
WHERE @usedHeapSize >= 100 AND name != \"main\" LIMIT 5",
q(
true,
vec![attr_sel(Attr::ObjectId), attr_sel(field("name"))],
true,
"java.lang.Thread",
Some("t"),
Some(and(
cmp(Attr::UsedHeapSize, CompareOp::Ge, Value::Int(100)),
cmp(field("name"), CompareOp::Ne, Value::Str("main".into())),
)),
Some(5),
),
),
(
"SELECT * FROM C WHERE delta = -7",
q(
false,
star(),
false,
"C",
None,
Some(cmp(field("delta"), CompareOp::Eq, Value::Int(-7))),
None,
),
),
(
"SELECT COUNT(name) FROM C",
q(
false,
vec![agg(AggFunc::Count, attr_sel(field("name")))],
false,
"C",
None,
None,
None,
),
),
];
for (src, expected) in cases {
let got = parse(src).unwrap_or_else(|e| panic!("parse failed for {src:?}: {}", e.0));
assert_eq!(got, expected, "AST mismatch for {src:?}");
}
}
#[test]
fn error_cases() {
let cases: Vec<(&str, &str)> = vec![
("", "empty query"), ("SELECT", "unexpected"), ("SELECT *", "unexpected"), ("SELECT * FROM", "unexpected"), ("SELECT * FROM C bogus extra", "unexpected"), ("SELECT @bogus FROM C", "bogus"), ("SELECT * FROM C WHERE hash >", "unexpected"), ("SELECT * FROM C WHERE hash", "unexpected"), ("SELECT * FROM C LIMIT abc", "unexpected"), ("SELECT * FROM C LIMIT -1", "unexpected"), ("SELECT COUNT * FROM C", "unexpected"), ("SELECT * FROM C WHERE (a = 1", "unexpected"), ("SELECT , FROM C", "unexpected"), ("SELECT * FROM C WHERE a = ", "unexpected"), ("SELECT * FROM C WHERE a == 1", "unexpected"), ];
for (src, needle) in cases {
let err = parse(src)
.err()
.unwrap_or_else(|| panic!("expected parse error for {src:?}"))
.0;
assert!(!err.is_empty(), "empty error for {src:?}");
assert!(
!err.contains('\n'),
"expected single-line error for {src:?}, got: {err}"
);
assert!(
err.contains(needle),
"error for {src:?} should contain {needle:?}, got: {err}"
);
if !src.trim().is_empty() {
assert!(
err.contains(':') || err.contains("offset"),
"error for {src:?} should carry a location, got: {err}"
);
}
}
}
#[test]
fn tokenizer_error_cases() {
for src in ["SELECT * FROM C WHERE a = #", "a & b", "SELECT ~ FROM C"] {
let err = tokenize_spanned(src)
.err()
.unwrap_or_else(|| panic!("expected tokenize error for {src:?}"));
assert!(err.contains("offset"), "got: {err}");
}
let err = tokenize_spanned("name = \"foo").unwrap_err();
assert!(err.contains("offset"), "got: {err}");
}
#[test]
fn single_quoted_string_hints_double_quotes() {
let err = tokenize_spanned("@displayName = 'foo'").unwrap_err();
assert!(
err.contains("double quotes") && err.contains("\"foo\""),
"expected a double-quote hint for 'foo', got: {err}"
);
assert!(
tokenize_spanned("x = 'a'").is_ok(),
"'a' is a valid char literal"
);
}
#[test]
fn union_two_branches_parses() {
let q =
parse("SELECT * FROM java.lang.String UNION SELECT * FROM java.lang.Integer").unwrap();
assert_eq!(q.union_branches.len(), 1);
assert_eq!(q.union_branches[0].from.class_name(), "java.lang.Integer");
assert!(
q.union_branches[0].union_branches.is_empty(),
"branches must be flat, not nested"
);
}
#[test]
fn union_three_branches_flat() {
let q = parse("SELECT * FROM A UNION SELECT * FROM B UNION SELECT * FROM C").unwrap();
assert_eq!(q.union_branches.len(), 2);
assert_eq!(q.union_branches[0].from.class_name(), "B");
assert_eq!(q.union_branches[1].from.class_name(), "C");
assert!(q.union_branches.iter().all(|b| b.union_branches.is_empty()));
}
#[test]
fn no_union_leaves_branches_empty() {
assert!(parse("SELECT * FROM C").unwrap().union_branches.is_empty());
}
#[test]
fn union_wide_limit_parenthesized_form() {
let q =
parse("SELECT * FROM java.lang.String UNION (SELECT * FROM java.lang.Object) LIMIT 5")
.unwrap();
assert_eq!(q.union_branches.len(), 1, "head + 1 branch");
assert_eq!(
q.union_limit,
Some(5),
"trailing LIMIT after `)` must be union-wide"
);
assert_eq!(q.limit, None, "head branch keeps no per-branch LIMIT");
assert_eq!(q.union_branches[0].limit, None, "branch keeps no LIMIT");
}
#[test]
fn union_wide_limit_bare_form_binds_union_wide() {
let q = parse("SELECT * FROM A UNION SELECT * FROM B LIMIT 5").unwrap();
assert_eq!(q.union_branches.len(), 1);
assert_eq!(
q.union_limit,
Some(5),
"bare-form trailing LIMIT is union-wide"
);
assert_eq!(q.limit, None, "head keeps no per-branch LIMIT");
assert_eq!(
q.union_branches[0].limit, None,
"last branch must NOT absorb the union-wide LIMIT"
);
}
#[test]
fn union_wide_limit_absent_is_none() {
let q = parse("SELECT * FROM A UNION SELECT * FROM B").unwrap();
assert_eq!(q.union_limit, None);
}
#[test]
fn single_query_union_limit_is_none() {
let q = parse("SELECT * FROM C LIMIT 5").unwrap();
assert_eq!(q.union_limit, None, "single query has no union_limit");
assert_eq!(q.limit, Some(5), "single-query LIMIT stays per-query");
}
#[test]
fn union_wide_limit_zero_parses() {
let q = parse("SELECT * FROM A UNION SELECT * FROM B LIMIT 0").unwrap();
assert_eq!(q.union_limit, Some(0));
}
#[test]
fn median_parses_as_single_arg_aggregate() {
let q = parse("SELECT MEDIAN(@usedHeapSize) FROM C").unwrap();
assert_eq!(
q.select,
vec![agg(AggFunc::Median, attr_sel(Attr::UsedHeapSize))]
);
}
#[test]
fn percentile_parses_with_integer_arg() {
let q = parse("SELECT PERCENTILE(@usedHeapSize, 95) FROM C").unwrap();
assert_eq!(
q.select,
vec![agg(AggFunc::Percentile(95), attr_sel(Attr::UsedHeapSize))]
);
}
#[test]
fn percentile_boundary_values_parse() {
assert_eq!(
parse("SELECT PERCENTILE(@usedHeapSize, 1) FROM C")
.unwrap()
.select,
vec![agg(AggFunc::Percentile(1), attr_sel(Attr::UsedHeapSize))]
);
assert_eq!(
parse("SELECT PERCENTILE(@usedHeapSize, 100) FROM C")
.unwrap()
.select,
vec![agg(AggFunc::Percentile(100), attr_sel(Attr::UsedHeapSize))]
);
}
#[test]
fn percentile_out_of_range_is_actionable_error() {
for bad in ["0", "101", "200"] {
let src = format!("SELECT PERCENTILE(@usedHeapSize, {bad}) FROM C");
let err = parse(&src).unwrap_err().0;
assert!(
err.contains("between 1 and 100"),
"p={bad} should give an actionable range error, got: {err}"
);
}
}
#[test]
fn percentile_missing_second_arg_is_error() {
assert!(parse("SELECT PERCENTILE(@usedHeapSize) FROM C").is_err());
}
#[test]
fn percentile_case_insensitive() {
let q = parse("SELECT percentile(@usedHeapSize, 50) FROM C").unwrap();
assert_eq!(
q.select,
vec![agg(AggFunc::Percentile(50), attr_sel(Attr::UsedHeapSize))]
);
}
#[test]
fn union_branch_inner_limit_preserved_with_union_wide_limit() {
let q = parse("SELECT * FROM A UNION (SELECT * FROM B LIMIT 3) LIMIT 5").unwrap();
assert_eq!(q.union_branches.len(), 1);
assert_eq!(
q.union_branches[0].limit,
Some(3),
"the branch's own LIMIT (inside parens) is preserved"
);
assert_eq!(q.union_limit, Some(5), "trailing LIMIT is union-wide");
}
#[test]
fn union_parenthesized_branch_parses() {
let q = parse("SELECT * FROM java.lang.String UNION (SELECT * FROM java.lang.Integer)")
.unwrap();
assert_eq!(q.union_branches.len(), 1);
let branch = &q.union_branches[0];
assert_eq!(branch.from.class_name(), "java.lang.Integer");
assert_eq!(branch.select, vec![SelectItem::Star]);
assert!(
branch.union_branches.is_empty(),
"branches must be flat, not nested"
);
}
#[test]
fn union_parenthesized_branch_equals_bare_branch() {
let bare =
parse("SELECT * FROM java.lang.String UNION SELECT * FROM java.lang.Integer").unwrap();
let paren = parse("SELECT * FROM java.lang.String UNION (SELECT * FROM java.lang.Integer)")
.unwrap();
assert_eq!(bare.union_branches, paren.union_branches);
}
#[test]
fn union_bare_branch_still_parses() {
let q = parse("SELECT * FROM A UNION SELECT * FROM B").unwrap();
assert_eq!(q.union_branches.len(), 1);
assert_eq!(q.union_branches[0].from.class_name(), "B");
}
#[test]
fn union_multiple_parenthesized_branches() {
let q = parse("SELECT * FROM A UNION (SELECT * FROM B) UNION (SELECT * FROM C)").unwrap();
assert_eq!(q.union_branches.len(), 2);
assert_eq!(q.union_branches[0].from.class_name(), "B");
assert_eq!(q.union_branches[1].from.class_name(), "C");
assert!(q.union_branches.iter().all(|b| b.union_branches.is_empty()));
}
#[test]
fn union_mixed_bare_and_parenthesized_branches() {
let q = parse("SELECT * FROM A UNION (SELECT * FROM B) UNION SELECT * FROM C").unwrap();
assert_eq!(q.union_branches.len(), 2);
assert_eq!(q.union_branches[0].from.class_name(), "B");
assert_eq!(q.union_branches[1].from.class_name(), "C");
}
#[test]
fn union_parenthesized_branch_with_where_and_limit() {
let q =
parse("SELECT * FROM A UNION (SELECT * FROM B b WHERE b.hash > 0 LIMIT 5)").unwrap();
assert_eq!(q.union_branches.len(), 1);
let branch = &q.union_branches[0];
assert_eq!(branch.from.class_name(), "B");
assert!(branch.where_.is_some(), "branch WHERE must be populated");
assert_eq!(branch.limit, Some(5), "branch LIMIT must be populated");
}
#[test]
fn union_unterminated_parenthesized_branch_errors() {
let err = parse("SELECT * FROM A UNION (SELECT * FROM B")
.unwrap_err()
.0;
assert!(
!err.is_empty(),
"expected non-empty error for unterminated UNION branch"
);
}
#[test]
fn from_subquery_parses() {
let q = parse("SELECT * FROM (SELECT * FROM java.lang.String) x").unwrap();
match &q.from {
FromSource::Subquery(inner) => {
assert_eq!(inner.from.class_name(), "java.lang.String");
assert!(inner.union_branches.is_empty());
}
other => panic!("expected subquery FROM, got {other:?}"),
}
assert_eq!(q.alias.as_deref(), Some("x"));
}
#[test]
fn from_class_still_parses_after_migration() {
let q = parse("SELECT * FROM java.lang.String s").unwrap();
assert_eq!(q.from.class_name(), "java.lang.String");
assert!(!q.from.instanceof());
assert!(q.from.as_subquery().is_none());
assert_eq!(q.alias.as_deref(), Some("s"));
}
#[test]
fn from_instanceof_class_sets_flag() {
let q = parse("SELECT * FROM INSTANCEOF java.util.List").unwrap();
assert!(
q.from.instanceof(),
"INSTANCEOF flag must survive migration"
);
assert_eq!(q.from.class_name(), "java.util.List");
}
#[test]
fn from_quoted_string_is_regex() {
let q = parse(r#"SELECT * FROM "java.lang.*""#).unwrap();
let spec = q.from.class_spec().expect("class source");
assert_eq!(
spec,
&ClassSpec {
instanceof: false,
class_name: "java.lang.*".into(),
is_regex: true,
}
);
}
#[test]
fn from_quoted_alternation_regex() {
let q = parse(r#"SELECT * FROM ".*Ab.*|java.lang.Runtime""#).unwrap();
let spec = q.from.class_spec().expect("class source");
assert!(spec.is_regex, "double-quoted FROM must set is_regex");
assert_eq!(spec.class_name, ".*Ab.*|java.lang.Runtime");
}
#[test]
fn from_bare_ident_is_not_regex() {
let q = parse("SELECT * FROM java.lang.String").unwrap();
let spec = q.from.class_spec().expect("class source");
assert!(!spec.is_regex, "bare-ident FROM must NOT be regex");
}
#[test]
fn from_bare_glob_is_not_regex() {
let q = parse("SELECT * FROM com.acme.*").unwrap();
let spec = q.from.class_spec().expect("class source");
assert!(!spec.is_regex, "bare-glob FROM must NOT be regex");
assert_eq!(spec.class_name, "com.acme.*");
}
#[test]
fn instanceof_with_quoted_regex_is_rejected() {
let err = parse(r#"SELECT * FROM INSTANCEOF "java.lang.*""#)
.expect_err("INSTANCEOF with a quoted regex must be rejected");
assert!(
err.0.contains("INSTANCEOF") && err.0.to_lowercase().contains("bare class name"),
"error must actionably explain INSTANCEOF needs a bare class name; got: {}",
err.0
);
}
#[test]
fn instanceof_with_bare_ident_still_valid() {
let q = parse("SELECT * FROM INSTANCEOF java.util.List").unwrap();
let spec = q.from.class_spec().expect("class source");
assert!(spec.instanceof);
assert!(!spec.is_regex);
}
#[test]
fn from_quoted_regex_with_alias() {
let q = parse(r#"SELECT * FROM "java\.lang\..*" s"#).unwrap();
let spec = q.from.class_spec().expect("class source");
assert!(spec.is_regex);
assert_eq!(spec.class_name, r"java\.lang\..*");
assert_eq!(q.alias.as_deref(), Some("s"));
}
#[test]
fn refpath_two_hops_parses() {
let q = parse("SELECT x.parent.name FROM Node x").unwrap();
match &q.select[0] {
SelectItem::Attr(Attr::RefPath { hops, tail, .. }) => {
assert_eq!(hops, &vec!["parent".to_string()]);
assert!(matches!(**tail, Attr::Field(ref f) if f == "name"));
}
other => panic!("expected RefPath, got {other:?}"),
}
}
#[test]
fn refpath_bare_single_segment_stays_field() {
let q = parse("SELECT x.name FROM Node x").unwrap();
match &q.select[0] {
SelectItem::Attr(Attr::Field(f)) => assert_eq!(f, "name"),
other => panic!("expected bare Field, got {other:?}"),
}
}
#[test]
fn refpath_without_alias_keeps_leading_segment() {
let q = parse("SELECT a.b.c FROM Node").unwrap();
match &q.select[0] {
SelectItem::Attr(Attr::RefPath { hops, tail, .. }) => {
assert_eq!(hops, &vec!["a".to_string(), "b".to_string()]);
assert!(matches!(**tail, Attr::Field(ref f) if f == "c"));
}
other => panic!("expected RefPath, got {other:?}"),
}
}
#[test]
fn refpath_in_where_parses() {
let q = parse("SELECT * FROM Node x WHERE x.parent.id = 7").unwrap();
match q.where_.as_ref().unwrap() {
Predicate::Compare {
lhs: Expr::Attr(Attr::RefPath { hops, tail, .. }),
..
} => {
assert_eq!(hops, &vec!["parent".to_string()]);
assert!(matches!(**tail, Attr::Field(ref f) if f == "id"));
}
other => panic!("expected RefPath compare, got {other:?}"),
}
}
#[test]
fn union_inside_subquery_is_rejected() {
let err = parse("SELECT * FROM (SELECT * FROM A UNION SELECT * FROM B) x")
.unwrap_err()
.to_string();
assert!(
err.contains("unexpected"),
"expected a located parse error, got: {err}"
);
assert!(
err.contains(':'),
"error should carry a line:col location, got: {err}"
);
}
#[test]
fn in_subquery_parses() {
let q = parse("SELECT * FROM java.lang.String s WHERE @objectAddress IN (SELECT * FROM java.lang.Integer)").unwrap();
match q.where_.as_ref().unwrap() {
Predicate::InSubquery { lhs, inner } => {
assert!(matches!(lhs, Attr::ObjectAddress));
assert_eq!(inner.from.class_name(), "java.lang.Integer");
assert!(inner.union_branches.is_empty());
}
other => panic!("expected InSubquery, got {other:?}"),
}
}
#[test]
fn in_subquery_combines_with_and() {
let q = parse(
"SELECT * FROM java.lang.String s WHERE hash > 0 AND @objectAddress IN (SELECT * FROM C)",
)
.unwrap();
match q.where_.as_ref().unwrap() {
Predicate::And(l, r) => {
assert!(matches!(**l, Predicate::Compare { .. }));
assert!(matches!(**r, Predicate::InSubquery { .. }));
}
other => panic!("expected AND(compare, InSubquery), got {other:?}"),
}
}
#[test]
fn in_subquery_on_object_id_parses() {
let q =
parse("SELECT * FROM C WHERE @objectId IN (SELECT @objectId FROM java.lang.Integer)")
.unwrap();
match q.where_.as_ref().unwrap() {
Predicate::InSubquery { lhs, inner } => {
assert!(matches!(lhs, Attr::ObjectId));
assert_eq!(inner.from.class_name(), "java.lang.Integer");
}
other => panic!("expected InSubquery, got {other:?}"),
}
}
#[test]
fn union_inside_in_subquery_is_rejected() {
let err = parse(
"SELECT * FROM C WHERE @objectAddress IN (SELECT * FROM A UNION SELECT * FROM B)",
)
.unwrap_err()
.to_string();
assert!(
err.contains("unexpected"),
"expected a located parse error, got: {err}"
);
assert!(
err.contains(':'),
"error should carry a line:col location, got: {err}"
);
}
#[test]
fn parse_dominators_attr() {
let q = parse("SELECT dominators(s) FROM java.lang.String s").unwrap();
assert_eq!(q.select.len(), 1);
match &q.select[0] {
SelectItem::Attr(Attr::Dominators(v)) => assert_eq!(v, "s"),
other => panic!("expected Attr::Dominators, got {other:?}"),
}
}
#[test]
fn parse_dominatorof_attr() {
let q = parse("SELECT dominatorof(s) FROM java.lang.String s").unwrap();
match &q.select[0] {
SelectItem::Attr(Attr::DominatorOf(v)) => assert_eq!(v, "s"),
other => panic!("expected Attr::DominatorOf, got {other:?}"),
}
}
#[test]
fn parse_dominators_requires_arg() {
let err = parse("SELECT dominators() FROM java.lang.String s").unwrap_err();
assert!(
err.to_string().contains("dominators(x) requires"),
"unexpected error: {err}"
);
}
#[test]
fn parse_dominatorof_requires_arg() {
let err = parse("SELECT dominatorof() FROM java.lang.String s").unwrap_err();
assert!(
err.to_string().contains("dominatorof(x) requires"),
"unexpected error: {err}"
);
}
#[test]
fn dominators_in_select_list_with_other_items() {
let q = parse("SELECT @objectId, dominators(s) FROM java.lang.String s").unwrap();
assert_eq!(q.select.len(), 2);
assert!(matches!(&q.select[1], SelectItem::Attr(Attr::Dominators(v)) if v == "s"));
}
#[test]
fn dominatorof_report_error_names_function() {
let rep = parse_or_report("SELECT dominatorof() FROM C").unwrap_err();
assert!(
rep.contains("dominatorof(x) requires"),
"report missing message: {rep}"
);
}
#[test]
fn parse_inbounds_attr() {
let q = parse("SELECT @inbounds FROM C").unwrap();
assert_eq!(q.select, vec![attr_sel(Attr::Inbounds)]);
}
#[test]
fn parse_outbounds_attr() {
let q = parse("SELECT @outbounds FROM C").unwrap();
assert_eq!(q.select, vec![attr_sel(Attr::Outbounds)]);
}
#[test]
fn inbounds_usable_in_where() {
let q = parse("SELECT @objectId FROM C WHERE @inbounds > 0").unwrap();
match q.where_.as_ref().unwrap() {
Predicate::Compare { lhs, .. } => {
assert_eq!(lhs.as_attr().expect("Expr::Attr"), &Attr::Inbounds)
}
other => panic!("expected compare on @inbounds, got {other:?}"),
}
}
#[test]
fn outbounds_usable_in_where() {
let q = parse("SELECT @objectId FROM C WHERE @outbounds != 0").unwrap();
match q.where_.as_ref().unwrap() {
Predicate::Compare { lhs, .. } => {
assert_eq!(lhs.as_attr().expect("Expr::Attr"), &Attr::Outbounds)
}
other => panic!("expected compare on @outbounds, got {other:?}"),
}
}
#[test]
fn parse_path_alias_and_class() {
let q = parse("SELECT path(s, java.lang.Thread) FROM C s").unwrap();
assert_eq!(
q.select,
vec![SelectItem::Path {
from: PathOperand::Alias("s".into()),
to: PathOperand::Class("java.lang.Thread".into()),
}]
);
}
#[test]
fn parse_path_both_aliases() {
let q = parse("SELECT path(a, b) FROM C").unwrap();
assert_eq!(
q.select,
vec![SelectItem::Path {
from: PathOperand::Alias("a".into()),
to: PathOperand::Alias("b".into()),
}]
);
}
#[test]
fn parse_path_both_classes() {
let q = parse("SELECT path(java.lang.String, java.lang.Integer) FROM C").unwrap();
assert_eq!(
q.select,
vec![SelectItem::Path {
from: PathOperand::Class("java.lang.String".into()),
to: PathOperand::Class("java.lang.Integer".into()),
}]
);
}
#[test]
fn parse_path_globbed_operand_is_class() {
let q = parse("SELECT path(s, com.acme.*) FROM C s").unwrap();
assert_eq!(
q.select,
vec![SelectItem::Path {
from: PathOperand::Alias("s".into()),
to: PathOperand::Class("com.acme.*".into()),
}]
);
}
#[test]
fn path_bare_field_without_parens_stays_field() {
let q = parse("SELECT path FROM C").unwrap();
assert_eq!(q.select, vec![attr_sel(field("path"))]);
}
#[test]
fn path_dotted_field_without_parens_stays_field() {
let q = parse("SELECT x.path FROM C x").unwrap();
assert_eq!(q.select, vec![attr_sel(field("path"))]);
}
#[test]
fn path_coexists_with_other_select_items() {
let q = parse("SELECT @objectId, path(s, C) FROM C s").unwrap();
assert_eq!(
q.select,
vec![
attr_sel(Attr::ObjectId),
SelectItem::Path {
from: PathOperand::Alias("s".into()),
to: PathOperand::Alias("C".into()),
},
]
);
}
#[test]
fn path_one_operand_is_error() {
let err = parse("SELECT path(s) FROM C s").unwrap_err().0;
assert!(!err.is_empty(), "expected non-empty error for path(s)");
assert!(
!err.contains('\n'),
"expected single-line error, got: {err}"
);
}
#[test]
fn inbounds_outbounds_in_attributes_const() {
assert!(
ATTRIBUTES.contains(&"@inbounds"),
"ATTRIBUTES must include @inbounds"
);
assert!(
ATTRIBUTES.contains(&"@outbounds"),
"ATTRIBUTES must include @outbounds"
);
}
#[test]
fn parse_as_retained_set() {
let q = parse("SELECT s AS RETAINED SET FROM java.lang.String s").unwrap();
assert!(q.retained_set);
assert_eq!(q.select.len(), 1);
}
#[test]
fn parse_no_retained_set_default_false() {
assert!(
!parse("SELECT s FROM java.lang.String s")
.unwrap()
.retained_set
);
}
#[test]
fn parse_as_retained_missing_set() {
let err = parse("SELECT s AS RETAINED FROM java.lang.String s").unwrap_err();
assert!(
err.to_string().contains("expected SET after 'AS RETAINED'"),
"unexpected: {err}"
);
}
#[test]
fn parse_as_retained_set_with_where_and_limit() {
let q = parse(
"SELECT s AS RETAINED SET FROM java.lang.String s WHERE @retainedHeapSize > 0 LIMIT 5",
)
.unwrap();
assert!(q.retained_set);
assert!(q.where_.is_some());
assert_eq!(q.limit, Some(5));
}
#[test]
fn parse_as_retained_case_insensitive() {
assert!(
parse("SELECT s as retained set FROM C s")
.unwrap()
.retained_set
);
}
#[test]
fn parses_retained_heap_size_attr() {
let q = parse("SELECT @retainedHeapSize FROM C").unwrap();
assert_eq!(q.select, vec![SelectItem::Attr(Attr::RetainedHeapSize)]);
}
#[allow(dead_code)]
fn retained_heap_alias_normalizes_to_retained_heap_size() {
let q = parse("SELECT @retainedHeap FROM C").unwrap();
assert_eq!(q.select, vec![SelectItem::Attr(Attr::RetainedHeapSize)]);
}
#[test]
fn retained_heap_size_usable_in_where() {
let q = parse("SELECT @objectId FROM C WHERE @retainedHeapSize > 1024").unwrap();
assert!(q.where_.is_some());
}
#[test]
fn parses_order_by_desc() {
let q = parse("SELECT @objectId FROM C ORDER BY @retainedHeapSize DESC").unwrap();
let ob = q.order_by.expect("ORDER BY parsed");
assert_eq!(ob.key, Attr::RetainedHeapSize);
assert_eq!(ob.dir, SortDir::Desc);
}
#[test]
fn order_by_defaults_to_asc() {
let q = parse("SELECT @objectId FROM C ORDER BY @usedHeapSize").unwrap();
assert_eq!(q.order_by.unwrap().dir, SortDir::Asc);
}
#[test]
fn order_by_before_limit() {
let q = parse("SELECT @objectId FROM C ORDER BY @retainedHeapSize DESC LIMIT 10").unwrap();
assert!(q.order_by.is_some());
assert_eq!(q.limit, Some(10));
assert_eq!(q.offset, None);
}
#[test]
fn limit_with_offset() {
let q = parse("SELECT @objectId FROM C LIMIT 5 OFFSET 10").unwrap();
assert_eq!(q.limit, Some(5));
assert_eq!(q.offset, Some(10));
}
#[test]
fn offset_zero_is_accepted() {
let q = parse("SELECT @objectId FROM C LIMIT 5 OFFSET 0").unwrap();
assert_eq!(q.limit, Some(5));
assert_eq!(q.offset, Some(0));
}
#[test]
fn limit_without_offset_has_none_offset() {
let q = parse("SELECT @objectId FROM C LIMIT 5").unwrap();
assert_eq!(q.limit, Some(5));
assert_eq!(q.offset, None);
}
#[test]
fn no_order_by_is_none() {
let q = parse("SELECT @objectId FROM C").unwrap();
assert!(q.order_by.is_none());
}
#[test]
fn span_tracks_byte_offsets() {
let lg = tokenize_spanned("@usedHeapSize").expect("logos tokenizes");
assert_eq!(lg.len(), 1);
let (tok, span) = &lg[0];
assert_eq!(*tok, Token::At("usedHeapSize".into()));
assert_eq!((span.start, span.end), (0, 13));
}
#[test]
fn line_col_basic() {
assert_eq!(line_col("abc", 0), (1, 1));
assert_eq!(line_col("abc", 2), (1, 3));
assert_eq!(line_col("ab\ncd", 3), (2, 1));
assert_eq!(line_col("ab\ncd", 4), (2, 2));
assert_eq!(line_col("abc", 99), (1, 4)); }
#[test]
fn report_contains_caret_marker() {
let rep = parse_or_report("SELCT * FROM C").unwrap_err();
assert!(
rep.contains("query:1:"),
"expected caret location, got:\n{rep}"
);
}
#[test]
fn report_suggests_nearest_keyword_on_typo() {
let rep = parse_or_report("SELCT * FROM C").unwrap_err();
assert!(
rep.contains("did you mean `SELECT`?"),
"expected a SELECT suggestion in the report, got:\n{rep}"
);
}
#[test]
fn report_ok_on_valid_query() {
assert!(parse_or_report("SELECT * FROM C").is_ok());
}
#[test]
fn report_order_by_aggregate_hint_in_caret_diagnostic() {
let rep = parse_or_report(
"SELECT classof(x) AS class, COUNT(*) AS n \
FROM INSTANCEOF java.lang.Object x \
GROUP BY classof(x) \
ORDER BY COUNT(*) DESC",
)
.unwrap_err();
assert!(
rep.contains("aggregate functions are not valid ORDER BY keys"),
"expected aggregate hint in caret diagnostic, got:\n{rep}"
);
}
#[test]
fn report_tokenizer_error_surfaced() {
let rep = parse_or_report("SELECT * FROM C WHERE a = #").unwrap_err();
assert!(rep.contains("tokenize error"), "got: {rep}");
}
#[test]
fn agg_funcs_const_matches_parser() {
for &f in AGG_FUNCS {
if f.eq_ignore_ascii_case("PERCENTILE") {
assert!(
agg_func(f).is_none(),
"PERCENTILE should not be a single-arg agg_func"
);
assert!(
parse(&format!("SELECT {f}(@usedHeapSize, 50) FROM C")).is_ok(),
"parser rejects two-arg aggregate {f:?}"
);
continue;
}
assert!(
agg_func(f).is_some(),
"agg_func rejects declared AGG_FUNC {f:?}"
);
assert!(
parse(&format!("SELECT {f}(*) FROM C")).is_ok(),
"parser rejects aggregate {f:?}"
);
}
}
#[test]
fn reserved_const_matches_parser() {
for &r in RESERVED {
assert!(
is_reserved(r),
"is_reserved rejects declared RESERVED {r:?}"
);
}
}
#[test]
fn attributes_const_all_parse() {
for &a in ATTRIBUTES {
assert!(
parse(&format!("SELECT {a} FROM C")).is_ok(),
"parser rejects declared attribute {a:?}"
);
}
}
#[test]
fn completion_words_covers_all_sources() {
let words = completion_words();
for set in [KEYWORDS, RESERVED, AGG_FUNCS, ATTRIBUTES, FUNCS] {
for &w in set {
assert!(words.contains(&w), "completion_words missing {w:?}");
}
}
let unique: std::collections::HashSet<_> = words.iter().collect();
assert_eq!(words.len(), unique.len(), "completion_words has duplicates");
}
#[test]
fn parse_prefixed_at_attr_single_select() {
let q = parse("SELECT s.@objectId FROM java.lang.String s").expect("should parse");
assert_eq!(q.select, vec![attr_sel(Attr::ObjectId)]);
}
#[test]
fn parse_multi_dot_prefixed_at_attr_builds_refpath() {
let q = parse("SELECT a.b.@objectId FROM java.lang.Object a").expect("should parse");
assert_eq!(
q.select,
vec![attr_sel(Attr::RefPath {
hops: vec!["b".to_string()],
tail: Box::new(Attr::ObjectId),
role: RefRole::ProjectionOnly,
})],
);
}
#[test]
fn parse_prefixed_at_attr_with_dotted_field() {
let q = parse("SELECT s.@objectId, s.hash FROM java.lang.String s").expect("should parse");
assert_eq!(
q.select,
vec![attr_sel(Attr::ObjectId), attr_sel(field("hash"))],
"prefix dropped from @attr; `s.hash` alias-stripped to Field(\"hash\")"
);
}
#[test]
fn parse_value_length_builds_refpath_with_length_tail() {
let q = parse("SELECT s.value.@length FROM java.lang.String s").expect("should parse");
assert_eq!(
q.select,
vec![attr_sel(Attr::RefPath {
hops: vec!["value".to_string()],
tail: Box::new(Attr::Length),
role: RefRole::ProjectionOnly,
})],
);
}
#[test]
fn parse_at_attr_tail_in_where_builds_refpath() {
let q = parse("SELECT s FROM java.lang.String s WHERE s.value.@length > 3")
.expect("should parse");
match q.where_.as_ref().expect("where") {
Predicate::Compare {
lhs: Expr::Attr(Attr::RefPath { hops, tail, .. }),
..
} => {
assert_eq!(hops, &vec!["value".to_string()]);
assert!(matches!(**tail, Attr::Length));
}
other => panic!("expected RefPath compare, got {other:?}"),
}
}
#[test]
fn parse_bare_at_attr_still_works() {
let q = parse("SELECT @objectId FROM java.lang.String").expect("should parse");
assert_eq!(q.select, vec![attr_sel(Attr::ObjectId)]);
}
#[test]
fn parse_prefixed_at_attr_mixed_columns() {
let q = parse(
"SELECT s.@objectAddress, s.@usedHeapSize, s.@retainedHeapSize FROM java.lang.Object s",
)
.expect("should parse");
assert_eq!(
q.select,
vec![
attr_sel(Attr::ObjectAddress),
attr_sel(Attr::UsedHeapSize),
attr_sel(Attr::RetainedHeapSize),
]
);
}
#[test]
fn parse_prefixed_at_attr_unknown_name_errors() {
let err = parse("SELECT s.@bogus FROM X s").expect_err("unknown @attr should error");
assert!(
err.0.contains("unknown @attribute"),
"actionable error expected, got: {}",
err.0
);
}
#[test]
fn unknown_at_attr_suggests_nearest() {
let err = parse("SELECT @objectAdress FROM X").expect_err("typo @attr should error");
assert!(
err.0.contains("did you mean `@objectAddress`?"),
"expected an @objectAddress suggestion, got: {}",
err.0
);
}
#[test]
fn missing_from_clause_is_hinted() {
let err = parse("SELECT @objectId").expect_err("missing FROM should error");
assert!(
err.0.contains("FROM"),
"expected a missing-FROM hint, got: {}",
err.0
);
}
#[test]
fn blank_query_gives_actionable_message() {
for src in ["", " ", "\n\t "] {
let err = parse(src).expect_err("blank query should error");
assert!(
err.0.contains("empty query") && err.0.contains("SELECT"),
"expected an actionable blank-query message, got: {}",
err.0
);
}
}
#[test]
fn order_without_by_is_hinted() {
let err = parse("SELECT * FROM java.lang.Thread ORDER @objectId")
.expect_err("ORDER without BY should error");
assert!(
err.0.contains("ORDER BY"),
"expected an ORDER BY hint, got: {}",
err.0
);
}
#[test]
fn missing_by_hint_helper_is_word_bounded() {
assert!(missing_by_hint("SELECT * FROM C ORDER x").is_some());
assert!(missing_by_hint("SELECT * FROM C ORDER BY x").is_none());
assert!(missing_by_hint("SELECT * FROM C").is_none());
assert!(missing_by_hint("SELECT reorder FROM C").is_none());
}
#[test]
fn group_by_is_now_supported() {
let q = super::parse(
"SELECT @displayName, COUNT(*) FROM java.lang.Thread GROUP BY @displayName",
)
.expect("GROUP BY should parse successfully");
assert_eq!(q.group_by.len(), 1);
}
#[test]
fn suggestion_never_echoes_the_found_token() {
assert_eq!(suggest_for_found(Some(&Token::Ident("BY".into()))), None);
assert_eq!(
suggest_for_found(Some(&Token::Ident("SELCT".into()))).as_deref(),
Some("SELECT")
);
}
#[test]
fn double_equals_is_hinted() {
let err = parse("SELECT * FROM java.lang.Thread WHERE @objectId == 1")
.expect_err("== should error");
assert!(
err.0.contains('=') && err.0.to_lowercase().contains("equality"),
"expected a `==` -> `=` hint, got: {}",
err.0
);
}
#[test]
fn double_eq_hint_helper_detects_adjacent_eq() {
let src = "a == b";
let second_eq = src.match_indices('=').nth(1).unwrap().0;
assert!(double_eq_hint(src, second_eq).is_some());
let src2 = "a = b";
let eq = src2.find('=').unwrap();
assert!(double_eq_hint(src2, eq).is_none());
}
#[test]
fn missing_from_hint_helper_is_word_bounded() {
assert!(missing_from_hint("SELECT @objectId").is_some());
assert!(missing_from_hint("SELECT * FROM C").is_none());
assert!(missing_from_hint("SELECT fromage FROM C").is_none());
assert!(missing_from_hint("SELECT selection").is_some());
assert!(missing_from_hint("DELETE X").is_none());
}
#[test]
fn unknown_function_call_suggests_nearest() {
let err = parse("SELECT SUMM(@usedHeapSize) FROM java.lang.Thread")
.expect_err("SUMM is not a function");
assert!(
err.0.contains("did you mean") && err.0.contains("SUM"),
"expected a SUM suggestion for SUMM(, got: {}",
err.0
);
}
#[test]
fn unknown_call_hint_helper_finds_the_callee() {
let src = "SELECT SUMM(x) FROM C";
let lparen = src.find('(').unwrap();
assert_eq!(unknown_call_hint(src, lparen).as_deref(), Some("SUM"));
let src2 = "SELECT COUNT(x) FROM C";
let lp2 = src2.find('(').unwrap();
assert!(unknown_call_hint(src2, lp2).is_none());
let src3 = "SELECT zzzzzz(x) FROM C";
let lp3 = src3.find('(').unwrap();
assert!(unknown_call_hint(src3, lp3).is_none());
}
#[test]
fn parse_prefixed_at_attr_in_where() {
let q =
parse("SELECT * FROM java.lang.String s WHERE s.@objectId = 0").expect("should parse");
assert_eq!(
q.where_,
Some(cmp(Attr::ObjectId, CompareOp::Eq, Value::Int(0)))
);
}
#[test]
fn parse_prefixed_at_attr_alias_name_not_hardcoded() {
let q = parse("SELECT obj.@objectId FROM java.lang.Object obj").expect("should parse");
assert_eq!(q.select, vec![attr_sel(Attr::ObjectId)]);
}
#[test]
fn parse_prefixed_at_attr_in_order_by() {
let q = parse("SELECT * FROM java.lang.String s ORDER BY s.@retainedHeapSize DESC")
.expect("should parse");
assert_eq!(
q.order_by.map(|o| (o.key, o.dir)),
Some((Attr::RetainedHeapSize, SortDir::Desc))
);
}
#[test]
fn parse_like_operator() {
let q = parse(r#"SELECT * FROM C WHERE name LIKE "m.*""#).unwrap();
assert_eq!(
q.where_.as_ref().unwrap(),
&cmp(field("name"), CompareOp::Like, Value::Str("m.*".into()))
);
}
#[test]
fn parse_like_case_insensitive_keyword() {
let q = parse(r#"SELECT * FROM C WHERE name like "m.*""#).unwrap();
assert_eq!(
q.where_.as_ref().unwrap(),
&cmp(field("name"), CompareOp::Like, Value::Str("m.*".into()))
);
}
#[test]
fn parse_not_like_operator() {
let q = parse(r#"SELECT * FROM C WHERE name NOT LIKE "m.*""#).unwrap();
assert_eq!(
q.where_.as_ref().unwrap(),
&cmp(field("name"), CompareOp::NotLike, Value::Str("m.*".into()))
);
}
#[test]
fn parse_not_like_case_insensitive_keyword() {
let q = parse(r#"SELECT * FROM C WHERE name not like "m.*""#).unwrap();
assert_eq!(
q.where_.as_ref().unwrap(),
&cmp(field("name"), CompareOp::NotLike, Value::Str("m.*".into()))
);
}
#[test]
fn parse_prefix_not_still_wraps_predicate() {
let q = parse(r#"SELECT * FROM C WHERE NOT name = "x""#).unwrap();
assert_eq!(
q.where_.as_ref().unwrap(),
¬(cmp(field("name"), CompareOp::Eq, Value::Str("x".into())))
);
}
#[test]
fn parse_prefix_not_wrapping_like() {
let q = parse(r#"SELECT * FROM C WHERE NOT name LIKE "m.*""#).unwrap();
assert_eq!(
q.where_.as_ref().unwrap(),
¬(cmp(
field("name"),
CompareOp::Like,
Value::Str("m.*".into())
))
);
}
#[test]
fn parse_like_combines_with_and() {
let q = parse(r#"SELECT * FROM C WHERE name LIKE "m.*" AND id = 1"#).unwrap();
assert_eq!(
q.where_.as_ref().unwrap(),
&and(
cmp(field("name"), CompareOp::Like, Value::Str("m.*".into())),
cmp(field("id"), CompareOp::Eq, Value::Int(1))
)
);
}
trait TapDistinct {
fn tap_distinct(self) -> Self;
}
impl TapDistinct for Query {
fn tap_distinct(mut self) -> Self {
self.distinct = true;
self
}
}
#[test]
fn parse_tostring_select_item() {
let q = parse("SELECT toString(s) FROM java.lang.String s").unwrap();
assert_eq!(q.select.len(), 1);
match &q.select[0] {
SelectItem::ToString(alias) => assert_eq!(alias, "s"),
other => panic!("expected SelectItem::ToString(\"s\"), got {other:?}"),
}
assert_eq!(q.alias.as_deref(), Some("s"));
}
#[test]
fn parse_tostring_in_where_like() {
let q =
parse(r#"SELECT * FROM java.lang.String s WHERE toString(s) LIKE "java.*""#).unwrap();
match q.where_.as_ref().unwrap() {
Predicate::Compare { lhs, op, rhs } => {
assert_eq!(
lhs.as_attr().expect("Compare lhs is Expr::Attr"),
&Attr::ToString("s".into()),
"WHERE LHS must be Attr::ToString(\"s\")"
);
assert_eq!(*op, CompareOp::Like, "operator must be Like");
assert_eq!(
rhs.as_lit().expect("Compare rhs is Expr::Lit"),
&Value::Str("java.*".into()),
"RHS must be Str(\"java.*\")"
);
}
other => panic!("expected Compare predicate, got {other:?}"),
}
}
#[test]
fn parse_bare_tostring_without_parens_is_field_not_function() {
let q = parse("SELECT toString FROM java.lang.String").unwrap();
assert_eq!(q.select.len(), 1);
match &q.select[0] {
SelectItem::Attr(Attr::Field(name)) => assert_eq!(name, "toString"),
other => panic!(
"bare `toString` without parens must be Attr::Field(\"toString\"), got {other:?}"
),
}
}
#[test]
fn parse_tostring_requires_alias_arg() {
let err = parse("SELECT toString() FROM java.lang.String s").unwrap_err();
assert!(
err.to_string().contains("toString(x) requires"),
"expected actionable error, got: {err}"
);
}
#[test]
fn parse_tostring_case_insensitive() {
for variant in &["TOSTRING(s)", "ToString(s)", "tostring(s)"] {
let src = format!("SELECT {variant} FROM java.lang.String s");
let q = parse(&src).unwrap_or_else(|e| panic!("parse failed for {src:?}: {e}"));
match &q.select[0] {
SelectItem::ToString(alias) => assert_eq!(alias, "s", "for {variant}"),
other => panic!("expected ToString for {variant}, got {other:?}"),
}
}
}
#[test]
fn from_objects_bare_class_parses() {
let with_objects = parse("SELECT * FROM OBJECTS java.lang.String").unwrap();
let without = parse("SELECT * FROM java.lang.String").unwrap();
assert_eq!(
with_objects.from, without.from,
"FROM OBJECTS <class> must produce identical FROM as FROM <class>"
);
}
#[test]
fn from_objects_class_name_and_is_regex() {
let q = parse("SELECT * FROM OBJECTS java.lang.String").unwrap();
let spec = q.from.class_spec().expect("expected class source");
assert_eq!(
spec.class_name, "java.lang.String",
"class_name must be java.lang.String after OBJECTS"
);
assert!(
!spec.is_regex,
"bare-ident after OBJECTS must not be is_regex"
);
assert!(!spec.instanceof, "OBJECTS alone must not set instanceof");
}
#[test]
fn from_objects_count_star_parses() {
let q = parse("SELECT COUNT(*) FROM OBJECTS java.lang.String").unwrap();
assert_eq!(
q.from.class_name(),
"java.lang.String",
"FROM OBJECTS class_name must be java.lang.String"
);
}
#[test]
fn from_objects_case_insensitive() {
for variant in &[
"SELECT * FROM OBJECTS java.lang.String",
"SELECT * FROM objects java.lang.String",
"SELECT * FROM Objects java.lang.String",
] {
let q = parse(variant).unwrap_or_else(|e| panic!("parse failed for {variant:?}: {e}"));
assert_eq!(
q.from.class_name(),
"java.lang.String",
"FROM {variant} must yield class java.lang.String"
);
}
}
#[test]
fn from_objects_subquery_parses() {
let q = parse("SELECT * FROM OBJECTS ( SELECT * FROM java.lang.String )").unwrap();
match &q.from {
FromSource::Subquery(inner) => {
assert_eq!(
inner.from.class_name(),
"java.lang.String",
"inner subquery must have class java.lang.String"
);
}
other => panic!("expected FromSource::Subquery after OBJECTS, got {other:?}"),
}
}
#[test]
fn from_objects_quoted_regex_sets_is_regex() {
let q = parse(r#"SELECT * FROM OBJECTS "java\.lang\..*""#).unwrap();
let spec = q.from.class_spec().expect("expected class source");
assert!(
spec.is_regex,
"quoted class after OBJECTS must set is_regex = true"
);
assert_eq!(spec.class_name, r"java\.lang\..*");
}
#[test]
fn from_objects_with_alias() {
let q = parse("SELECT s FROM OBJECTS java.lang.String s").unwrap();
assert_eq!(q.from.class_name(), "java.lang.String");
assert_eq!(q.alias.as_deref(), Some("s"));
}
#[test]
fn from_objects_instanceof_is_accepted_as_noop() {
let result = parse("SELECT * FROM OBJECTS INSTANCEOF java.lang.String");
match result {
Ok(q) => {
assert_eq!(
q.from.class_name(),
"java.lang.String",
"FROM OBJECTS INSTANCEOF: class must be java.lang.String"
);
assert!(
q.from.instanceof(),
"FROM OBJECTS INSTANCEOF: instanceof flag must be set"
);
}
Err(e) => {
panic!(
"FROM OBJECTS INSTANCEOF currently accepted as no-op, \
but got parse error: {e}\n\
If you intentionally added rejection, update this test to \
assert the actionable error message."
);
}
}
}
#[test]
fn objects_as_where_field_still_parses() {
let q = parse("SELECT * FROM C WHERE objects = 1").unwrap();
match q.where_.as_ref().unwrap() {
Predicate::Compare { lhs, .. } => {
assert_eq!(
lhs.as_attr().expect("Compare lhs is Expr::Attr"),
&Attr::Field("objects".into()),
"objects as a WHERE field must parse as Attr::Field(\"objects\")"
);
}
other => panic!("expected Compare predicate, got {other:?}"),
}
}
#[test]
fn objects_is_in_reserved() {
assert!(
RESERVED.iter().any(|&r| r.eq_ignore_ascii_case("OBJECTS")),
"OBJECTS must be in RESERVED (guards alias-position and completion drift)"
);
assert!(
is_reserved("OBJECTS"),
"is_reserved(\"OBJECTS\") must return true"
);
assert!(
is_reserved("objects"),
"is_reserved is case-insensitive; must return true for \"objects\""
);
}
#[test]
fn from_objects_glob_class_parses() {
let with_objects = parse("SELECT * FROM OBJECTS java.util.*").unwrap();
let without = parse("SELECT * FROM java.util.*").unwrap();
assert_eq!(
with_objects.from, without.from,
"FROM OBJECTS glob must produce identical FROM as FROM glob"
);
}
#[test]
fn alias_bare_ident_on_attr() {
let q = parse("SELECT s.@objectId AS foo FROM java.lang.String s").unwrap();
assert_eq!(q.select_aliases.len(), 1);
assert_eq!(q.select_aliases[0].as_deref(), Some("foo"));
assert_eq!(q.select, vec![SelectItem::Attr(Attr::ObjectId)]);
}
#[test]
fn alias_quoted_string() {
let q = parse(r#"SELECT @usedHeapSize AS "size" FROM java.lang.String"#).unwrap();
assert_eq!(q.select_aliases[0].as_deref(), Some("size"));
assert_eq!(q.select, vec![SelectItem::Attr(Attr::UsedHeapSize)]);
}
#[test]
fn alias_as_retained_set_regression() {
let q = parse("SELECT s AS RETAINED SET FROM java.lang.String s").unwrap();
assert!(
q.retained_set,
"retained_set must be true when AS RETAINED SET is used"
);
assert_eq!(
q.select,
vec![SelectItem::Star],
"select must be [Star] — bare alias s rewrites to Star"
);
assert_eq!(
q.select_aliases[0], None,
"item must carry no alias (RETAINED was not consumed as alias name)"
);
}
#[test]
fn alias_as_retained_set_case_insensitive_regression() {
let q = parse("SELECT s as retained set FROM java.lang.String s").unwrap();
assert!(q.retained_set);
assert_eq!(q.select_aliases[0], None);
}
#[test]
fn alias_on_aggregate() {
let q = parse("SELECT COUNT(*) AS n FROM java.lang.String").unwrap();
assert_eq!(q.select_aliases[0].as_deref(), Some("n"));
assert!(
matches!(
&q.select[0],
SelectItem::Aggregate {
func: AggFunc::Count,
..
}
),
"select must be COUNT(*)"
);
}
#[test]
fn no_alias_means_none() {
let q = parse("SELECT @objectId FROM java.lang.String").unwrap();
assert_eq!(q.select_aliases.len(), 1);
assert_eq!(q.select_aliases[0], None);
}
#[test]
fn alias_on_retained_heap_size() {
let q = parse("SELECT @retainedHeapSize AS r FROM java.lang.String").unwrap();
assert_eq!(q.select_aliases[0].as_deref(), Some("r"));
assert_eq!(q.select, vec![SelectItem::Attr(Attr::RetainedHeapSize)]);
}
#[test]
fn multiple_aliased_columns() {
let q =
parse("SELECT @objectId AS id, @usedHeapSize AS bytes FROM java.lang.String").unwrap();
assert_eq!(q.select_aliases.len(), 2);
assert_eq!(q.select_aliases[0].as_deref(), Some("id"));
assert_eq!(q.select_aliases[1].as_deref(), Some("bytes"));
}
#[test]
fn mixed_aliased_and_plain_columns() {
let q = parse("SELECT @objectId AS id, @usedHeapSize FROM java.lang.String").unwrap();
assert_eq!(q.select_aliases[0].as_deref(), Some("id"));
assert_eq!(q.select_aliases[1], None);
}
#[test]
fn alias_combined_with_order_by() {
let q = parse(
"SELECT @usedHeapSize AS bytes FROM java.lang.String ORDER BY @usedHeapSize DESC",
)
.unwrap();
assert_eq!(q.select_aliases[0].as_deref(), Some("bytes"));
assert!(q.order_by.is_some());
}
#[test]
fn alias_on_path_item() {
let q = parse("SELECT path(s, java.lang.Object) AS p FROM java.lang.String s").unwrap();
assert_eq!(q.select_aliases[0].as_deref(), Some("p"));
assert!(matches!(&q.select[0], SelectItem::Path { .. }));
}
#[test]
fn alias_quoted_reserved_word_name() {
let q = parse(r#"SELECT * AS "FROM" FROM java.lang.String"#).unwrap();
assert_eq!(q.select_aliases[0].as_deref(), Some("FROM"));
}
#[test]
fn alias_union_head_branch_preserved() {
let q =
parse("SELECT @objectId AS id FROM java.lang.String UNION SELECT @objectId FROM java.lang.Object")
.unwrap();
assert_eq!(q.select_aliases[0].as_deref(), Some("id"));
assert_eq!(q.union_branches[0].select_aliases[0], None);
}
#[test]
fn select_aliases_length_matches_select() {
let queries = [
"SELECT * FROM C",
"SELECT @objectId, @usedHeapSize FROM C",
"SELECT COUNT(*) AS n, SUM(@usedHeapSize) AS total FROM C",
];
for oql in &queries {
let q = parse(oql).unwrap();
assert_eq!(
q.select.len(),
q.select_aliases.len(),
"select_aliases length mismatch for: {oql}"
);
}
}
#[test]
fn select_objects_is_noop() {
let q = parse("SELECT OBJECTS s FROM java.lang.String s").unwrap();
assert_eq!(
q.select,
vec![SelectItem::Star],
"OBJECTS must be a no-op: select must be [Star] (bare alias rewrites to Star)"
);
assert!(!q.retained_set, "OBJECTS must not set retained_set");
assert!(!q.distinct, "OBJECTS must not set distinct");
}
#[test]
fn leading_as_retained_set() {
let q = parse("SELECT AS RETAINED SET s FROM java.lang.String s").unwrap();
assert!(
q.retained_set,
"leading AS RETAINED SET must set retained_set"
);
assert_eq!(
q.select,
vec![SelectItem::Star],
"select must be [Star] with leading retained (bare alias rewrites)"
);
}
#[test]
fn select_objects_same_as_select() {
let with_objects = parse("SELECT OBJECTS s FROM java.lang.String s").unwrap();
let without_objects = parse("SELECT s FROM java.lang.String s").unwrap();
assert_eq!(
with_objects.select, without_objects.select,
"OBJECTS must be a pure no-op: selects must be identical"
);
assert_eq!(
with_objects.retained_set, without_objects.retained_set,
"OBJECTS must not change retained_set"
);
assert_eq!(
with_objects.distinct, without_objects.distinct,
"OBJECTS must not change distinct"
);
}
#[test]
fn trailing_as_retained_set_regression() {
let q = parse("SELECT s AS RETAINED SET FROM java.lang.String s").unwrap();
assert!(
q.retained_set,
"trailing AS RETAINED SET must still set retained_set"
);
assert_eq!(
q.select,
vec![SelectItem::Star],
"select must be [Star] for trailing form (bare alias rewrites)"
);
}
#[test]
fn leading_as_retained_missing_set_errors() {
let err = parse("SELECT AS RETAINED s FROM java.lang.String s").unwrap_err();
assert!(
err.to_string().contains("expected SET after 'AS RETAINED'"),
"leading missing-SET error must be actionable, got: {err}"
);
}
#[test]
fn select_distinct_objects() {
let q = parse("SELECT DISTINCT OBJECTS s FROM java.lang.String s").unwrap();
assert!(q.distinct, "distinct must be true");
assert_eq!(
q.select,
vec![SelectItem::Star],
"select must be [Star] with DISTINCT OBJECTS (bare alias rewrites)"
);
assert!(!q.retained_set);
}
#[test]
fn leading_as_retained_set_objects() {
let q = parse("SELECT AS RETAINED SET OBJECTS s FROM java.lang.String s").unwrap();
assert!(
q.retained_set,
"retained_set must be true with leading AS RETAINED SET OBJECTS"
);
assert_eq!(q.select, vec![SelectItem::Star],);
}
#[test]
fn select_objects_case_insensitive() {
let q = parse("select objects s from java.lang.String s").unwrap();
assert_eq!(q.select, vec![SelectItem::Star],);
}
#[test]
fn leading_and_trailing_as_retained_set_both_true() {
let q = parse("SELECT AS RETAINED SET s AS RETAINED SET FROM java.lang.String s")
.expect("leading + trailing AS RETAINED SET must parse Ok");
assert!(
q.retained_set,
"when both leading and trailing present, retained_set must be true"
);
}
#[test]
fn select_objects_before_aggregate() {
let q = parse("SELECT OBJECTS COUNT(*) FROM java.lang.String")
.expect("OBJECTS before aggregate must parse Ok");
assert!(
matches!(
&q.select[0],
SelectItem::Aggregate {
func: AggFunc::Count,
..
}
),
"COUNT(*) after OBJECTS must still parse as aggregate"
);
}
#[test]
fn select_objects_star() {
let q = parse("SELECT OBJECTS * FROM java.lang.String").unwrap();
assert_eq!(q.select, vec![SelectItem::Star]);
}
#[test]
fn select_distinct_as_retained_set_leading() {
let q = parse("SELECT DISTINCT AS RETAINED SET s FROM java.lang.String s").unwrap();
assert!(q.distinct, "distinct must be true");
assert!(q.retained_set, "retained_set must be true");
assert_eq!(q.select, vec![SelectItem::Star],);
}
#[test]
fn select_as_retained_set_distinct_wrong_order_pinned() {
let r = parse("SELECT AS RETAINED SET DISTINCT s FROM java.lang.String s");
if let Ok(q) = r {
assert!(
!q.distinct,
"DISTINCT after AS RETAINED SET should NOT set distinct flag (wrong order)"
);
}
}
fn parse_one(s: &str) -> Query {
super::parse(s).unwrap_or_else(|e| panic!("parse failed for {s:?}: {}", e.0))
}
#[test]
fn arithmetic_precedence_mul_binds_tighter_than_add() {
let q = parse_one("SELECT @usedHeapSize + @length * 2 FROM C");
match &q.select[0] {
SelectItem::Expr(e) => match e.as_ref() {
Expr::Binary {
op: ArithOp::Add,
lhs,
rhs,
} => {
assert!(matches!(lhs.as_ref(), Expr::Attr(_)));
assert!(matches!(
rhs.as_ref(),
Expr::Binary {
op: ArithOp::Mul,
..
}
));
}
other => panic!("expected Add root, got {other:?}"),
},
other => panic!("expected Expr item, got {other:?}"),
}
}
#[test]
fn arithmetic_parens_override_precedence() {
let q = parse_one("SELECT (@usedHeapSize + @length) * 2 FROM C");
match &q.select[0] {
SelectItem::Expr(e) => assert!(matches!(
e.as_ref(),
Expr::Binary {
op: ArithOp::Mul,
..
}
)),
other => panic!("got {other:?}"),
}
}
#[test]
fn unary_minus_on_attr_parses() {
let q = parse_one("SELECT -@usedHeapSize FROM C");
match &q.select[0] {
SelectItem::Expr(e) => assert!(matches!(
e.as_ref(),
Expr::Unary {
op: UnaryOp::Neg,
..
}
)),
other => panic!("got {other:?}"),
}
}
#[test]
fn lone_star_stays_select_star_not_multiply() {
let q = parse_one("SELECT * FROM C");
assert_eq!(q.select, vec![SelectItem::Star]);
}
#[test]
fn bare_attr_folds_to_attr_item_not_expr() {
let q = parse_one("SELECT @usedHeapSize FROM C");
assert_eq!(q.select, vec![SelectItem::Attr(Attr::UsedHeapSize)]);
}
#[test]
fn where_arithmetic_both_sides() {
let q = parse_one("SELECT * FROM C WHERE @usedHeapSize / 8 > @length + 1");
match q.where_.unwrap() {
Predicate::Compare {
lhs,
op: CompareOp::Gt,
rhs,
} => {
assert!(matches!(
lhs,
Expr::Binary {
op: ArithOp::Div,
..
}
));
assert!(matches!(
rhs,
Expr::Binary {
op: ArithOp::Add,
..
}
));
}
other => panic!("got {other:?}"),
}
}
#[test]
fn where_plain_compare_folds_to_leaf_exprs() {
let q = parse_one("SELECT * FROM C WHERE @usedHeapSize > 100");
match q.where_.unwrap() {
Predicate::Compare {
lhs,
op: CompareOp::Gt,
rhs,
} => {
assert_eq!(lhs, Expr::Attr(Attr::UsedHeapSize));
assert_eq!(rhs, Expr::Lit(Value::Int(100)));
}
other => panic!("got {other:?}"),
}
}
#[test]
fn negative_literal_folds_via_unary() {
let q = parse_one("SELECT * FROM C WHERE delta = -5");
match q.where_.unwrap() {
Predicate::Compare { rhs, .. } => assert_eq!(rhs, Expr::Lit(Value::Int(-5))),
other => panic!("got {other:?}"),
}
}
#[test]
fn like_rhs_must_be_string_literal() {
let err = super::parse("SELECT * FROM C WHERE name LIKE @a + 1").unwrap_err();
let msg = format!("{err:?}");
assert!(
msg.to_lowercase().contains("like"),
"error should mention LIKE: {msg}"
);
}
#[test]
fn bare_alias_in_select_rewrites_to_star() {
let q = parse_one("SELECT s FROM java.lang.String s");
assert_eq!(
q.select,
vec![SelectItem::Star],
"bare alias s must rewrite to Star, got {:?}",
q.select
);
}
#[test]
fn bare_alias_in_count_arg_rewrites_to_star() {
let q = parse_one("SELECT COUNT(s) FROM java.lang.String s");
match &q.select[0] {
SelectItem::Aggregate {
func: AggFunc::Count,
arg,
} => {
assert_eq!(
arg.as_ref(),
&SelectItem::Star,
"COUNT(s) arg must rewrite to Star, got {:?}",
arg
);
}
other => panic!("expected COUNT aggregate, got {other:?}"),
}
}
#[test]
fn dotted_alias_field_is_not_rewritten_to_star() {
let q = parse_one("SELECT s.count FROM java.lang.String s");
assert_eq!(
q.select,
vec![SelectItem::Attr(Attr::Field("count".into()))],
"s.count must normalize to Field(\"count\"), got {:?}",
q.select
);
}
#[test]
fn bare_non_alias_field_stays_field() {
let q = parse_one("SELECT count FROM java.lang.String s");
assert_eq!(
q.select,
vec![SelectItem::Attr(Attr::Field("count".into()))],
"bare non-alias field must stay Field(\"count\"), got {:?}",
q.select
);
}
#[test]
fn alias_qualified_at_attr_still_works() {
let q = parse_one("SELECT s.@objectId FROM java.lang.String s");
assert_eq!(
q.select,
vec![SelectItem::Attr(Attr::ObjectId)],
"s.@objectId must remain Attr::ObjectId, got {:?}",
q.select
);
}
#[test]
fn bare_alias_mixed_columns_only_alias_rewrites() {
let q = parse_one("SELECT s, @usedHeapSize FROM java.lang.String s");
assert_eq!(q.select.len(), 2, "must have 2 columns");
assert_eq!(
q.select[0],
SelectItem::Star,
"first column (alias) must be Star"
);
assert_eq!(
q.select[1],
SelectItem::Attr(Attr::UsedHeapSize),
"second column must be @usedHeapSize"
);
}
#[test]
fn bare_alias_in_sum_arg_rewrites_to_star() {
let q = parse_one("SELECT SUM(s) FROM java.lang.String s");
match &q.select[0] {
SelectItem::Aggregate {
func: AggFunc::Sum,
arg,
} => {
assert_eq!(
arg.as_ref(),
&SelectItem::Star,
"SUM(s) arg must rewrite to Star, got {:?}",
arg
);
}
other => panic!("expected SUM aggregate, got {other:?}"),
}
}
#[test]
fn lex_numeric_literal_forms() {
use super::Token::*;
let toks = |s: &str| {
super::tokenize_spanned(s)
.unwrap()
.into_iter()
.map(|(t, _)| t)
.collect::<Vec<_>>()
};
assert_eq!(toks("100"), vec![Int(100)]);
assert_eq!(toks("100L"), vec![Int(100)]);
assert_eq!(toks("100l"), vec![Int(100)]);
assert_eq!(toks("0xFF"), vec![Int(255)]);
assert_eq!(toks("0Xff"), vec![Int(255)]);
assert_eq!(toks("0xFFL"), vec![Int(255)]);
assert_eq!(toks("0144"), vec![Int(100)]);
assert_eq!(toks("0144L"), vec![Int(100)]);
assert_eq!(toks("0"), vec![Int(0)]);
assert_eq!(toks("08"), vec![Int(8)]); assert_eq!(toks("'a'"), vec![Int(97)]);
assert_eq!(toks("1.5"), vec![Float(1.5)]);
assert_eq!(toks("7."), vec![Float(7.0)]);
assert_eq!(toks("1.5F"), vec![Float(1.5)]);
assert_eq!(toks("2.0D"), vec![Float(2.0)]);
assert_eq!(toks("5F"), vec![Float(5.0)]);
assert_eq!(toks("5D"), vec![Float(5.0)]);
assert_eq!(toks("1e5"), vec![Float(100000.0)]);
assert_eq!(toks("1.5e-3"), vec![Float(0.0015)]);
assert_eq!(toks("2E+2"), vec![Float(200.0)]);
}
#[test]
fn lex_numeric_literal_errors() {
assert!(super::tokenize_spanned("0xFFFFFFFFFFFFFFFFF").is_err()); assert!(super::tokenize_spanned("''").is_err()); assert!(super::tokenize_spanned("'ab'").is_err()); assert!(super::tokenize_spanned("0xZZ").is_ok()); }
#[test]
fn lex_high_bit_address_roundtrips() {
use super::Token::*;
let toks = |s: &str| {
super::tokenize_spanned(s)
.unwrap()
.into_iter()
.map(|(t, _)| t)
.collect::<Vec<_>>()
};
let t = toks("0xffff800012345678");
assert_eq!(t.len(), 1);
match t[0] {
Int(x) => assert_eq!(x as u64, 0xffff_8000_1234_5678u64),
ref other => panic!("expected Int, got {other:?}"),
}
let t = toks("0xffffffffffffffff");
assert_eq!(t.len(), 1);
match t[0] {
Int(x) => assert_eq!(x as u64, u64::MAX),
ref other => panic!("expected Int, got {other:?}"),
}
assert_eq!(
super::parse("SELECT * FROM OBJECTS 0xffff800012345678")
.unwrap()
.from,
super::FromSource::Object(0xffff_8000_1234_5678)
);
assert!(super::tokenize_spanned("999999999999999999999999999").is_err());
}
#[test]
fn numeric_literals_in_arithmetic() {
assert!(super::parse("SELECT 0xFF + 1 FROM java.lang.String").is_ok());
assert!(super::parse("SELECT 2 * 1.5D FROM java.lang.String").is_ok());
assert!(super::parse("SELECT -0144 FROM java.lang.String").is_ok());
}
#[test]
fn parse_at_name_attribute() {
assert!(super::parse("SELECT @name FROM java.lang.Thread").is_ok());
assert!(
super::parse(r#"SELECT * FROM java.lang.Thread WHERE @name = "java.lang.Thread""#)
.is_ok()
);
}
#[test]
fn parse_at_name_aliases_displayname() {
let by_name = super::parse("SELECT @name FROM java.lang.Thread").unwrap();
let by_display = super::parse("SELECT @displayName FROM java.lang.Thread").unwrap();
assert_eq!(by_name, by_display);
}
#[test]
fn parse_and_eval_tohex() {
assert!(super::parse("SELECT toHex(@objectAddress) FROM java.lang.Thread LIMIT 1").is_ok());
assert!(super::parse("SELECT toHex(255) FROM java.lang.Thread").is_ok());
}
#[test]
fn parse_method_postfix() {
assert!(super::parse("SELECT s.getName() FROM java.lang.Thread s").is_ok());
assert!(super::parse("SELECT i.intValue() FROM java.lang.Integer i").is_ok());
assert!(super::parse("SELECT a.get(0) FROM java.util.ArrayList a").is_ok());
assert!(super::parse("SELECT s.value.@length FROM java.lang.String s").is_ok());
assert!(super::parse("SELECT i.intValue() * 2 FROM java.lang.Integer i").is_ok());
}
#[test]
fn parse_method_postfix_ast_shape() {
let q = super::parse("SELECT s.getName() FROM java.lang.Thread s").unwrap();
match &q.select[0] {
SelectItem::Expr(e) => match e.as_ref() {
Expr::Method {
receiver,
name,
args,
} => {
assert!(
matches!(receiver.as_ref(), Expr::Attr(Attr::Field(f)) if f == "s"),
"expected Attr::Field(\"s\"), got {receiver:?}"
);
assert_eq!(name, "getName");
assert!(args.is_empty(), "expected no args, got {args:?}");
}
other => panic!("expected Expr::Method, got {other:?}"),
},
other => panic!("expected SelectItem::Expr, got {other:?}"),
}
let q = super::parse("SELECT a.get(0) FROM java.util.ArrayList a").unwrap();
match &q.select[0] {
SelectItem::Expr(e) => match e.as_ref() {
Expr::Method {
receiver,
name,
args,
} => {
assert!(
matches!(receiver.as_ref(), Expr::Attr(Attr::Field(f)) if f == "a"),
"expected Attr::Field(\"a\"), got {receiver:?}"
);
assert_eq!(name, "get");
assert_eq!(args.len(), 1, "expected 1 arg, got {args:?}");
assert!(
matches!(&args[0], Expr::Lit(Value::Int(0))),
"expected Int(0), got {:?}",
args[0]
);
}
other => panic!("expected Expr::Method, got {other:?}"),
},
other => panic!("expected SelectItem::Expr, got {other:?}"),
}
}
#[test]
fn parse_backing_array_attrs() {
assert!(super::parse("SELECT @valueArray FROM java.lang.String").is_ok());
assert!(super::parse("SELECT @referenceArray FROM java.util.ArrayList").is_ok());
}
#[test]
fn getkey_lowers_to_key_refpath_objectaddress_tail() {
let q = super::parse("SELECT e.getKey() FROM java.util.HashMap$Node e").unwrap();
match &q.select[0] {
SelectItem::Attr(Attr::RefPath { hops, tail, role }) => {
assert_eq!(hops, &vec!["key".to_string()], "expected single 'key' hop");
assert_eq!(
tail.as_ref(),
&Attr::ObjectAddress,
"getKey() tail must project the resolved object's address"
);
assert_eq!(*role, RefRole::ProjectionOnly);
}
other => panic!("expected SelectItem::Attr(RefPath), got {other:?}"),
}
}
#[test]
fn getvalue_lowers_to_value_refpath_objectaddress_tail() {
let q = super::parse("SELECT e.getValue() FROM java.util.HashMap$Node e").unwrap();
match &q.select[0] {
SelectItem::Attr(Attr::RefPath { hops, tail, .. }) => {
assert_eq!(hops, &vec!["value".to_string()]);
assert_eq!(tail.as_ref(), &Attr::ObjectAddress);
}
other => panic!("expected SelectItem::Attr(RefPath), got {other:?}"),
}
}
#[test]
fn non_refhop_methods_are_not_lowered() {
for oql in [
"SELECT s.getName() FROM java.lang.Thread s",
"SELECT i.intValue() FROM java.lang.Integer i",
"SELECT c.size() FROM java.util.ArrayList c",
] {
let q = super::parse(oql).unwrap();
assert!(
matches!(&q.select[0], SelectItem::Expr(e) if matches!(e.as_ref(), Expr::Method { .. })),
"method in `{oql}` was unexpectedly lowered away from Expr::Method: {:?}",
q.select[0]
);
}
}
#[test]
fn getkey_with_args_is_not_lowered() {
let q = super::parse("SELECT e.getKey(1) FROM java.util.HashMap$Node e").unwrap();
assert!(
matches!(&q.select[0], SelectItem::Expr(e) if matches!(e.as_ref(), Expr::Method { .. })),
"getKey(1) with an arg must not lower: {:?}",
q.select[0]
);
}
#[test]
fn parse_gcroot_attrs() {
assert!(parse("SELECT @GCRoots FROM java.lang.Thread").is_ok());
assert!(parse("SELECT @GCRootInfo FROM java.lang.Thread").is_ok());
assert!(parse("SELECT @info FROM java.lang.Thread").is_ok());
}
#[test]
fn typo_keyword_suggests_correction() {
let e = parse("SELCT x FROM C").unwrap_err();
assert!(
e.0.to_lowercase().contains("did you mean") && e.0.contains("SELECT"),
"expected a SELECT suggestion, got: {:?}",
e.0
);
}
#[test]
fn lev_basic_distances() {
assert_eq!(lev("SELECT", "SELECT"), 0);
assert_eq!(lev("SELCT", "SELECT"), 1);
assert_eq!(lev("FRM", "FROM"), 1);
assert_eq!(lev("abc", "xyz"), 3);
}
#[test]
fn did_you_mean_finds_near_keyword() {
assert_eq!(
did_you_mean("SELCT", KEYWORDS.iter().copied()),
Some("SELECT".to_string())
);
assert_eq!(
did_you_mean("FRM", KEYWORDS.iter().copied()),
Some("FROM".to_string())
);
}
#[test]
fn did_you_mean_ignores_far_tokens() {
assert_eq!(did_you_mean("java", KEYWORDS.iter().copied()), None);
}
#[test]
fn no_suggestion_when_token_is_valid_identifier_far_from_keywords() {
let e = parse("SELECT x FROM").unwrap_err();
assert!(
!e.0.contains("did you mean"),
"no spurious suggestion: {:?}",
e.0
);
}
#[test]
fn parse_group_by_simple() {
let q = super::parse(
"SELECT @displayName, COUNT(*) FROM java.lang.Thread GROUP BY @displayName",
)
.unwrap();
assert_eq!(q.group_by.len(), 1);
assert!(matches!(
&q.group_by[0],
crate::query::ast::Expr::Attr(crate::query::ast::Attr::DisplayName)
));
assert!(q.having.is_none());
}
#[test]
fn parse_group_by_multi_key() {
let q = super::parse(
"SELECT @displayName, @usedHeapSize, COUNT(*) FROM java.lang.Thread \
GROUP BY @displayName, @usedHeapSize",
)
.unwrap();
assert_eq!(q.group_by.len(), 2);
}
#[test]
fn parse_having() {
let q = super::parse(
"SELECT @displayName, COUNT(*) FROM java.lang.Thread \
GROUP BY @displayName HAVING COUNT(*) > 5",
)
.unwrap();
assert!(q.having.is_some());
}
#[test]
fn parse_having_without_group_by_is_parsed() {
let result = super::parse("SELECT COUNT(*) FROM java.lang.Thread HAVING COUNT(*) > 5");
match result {
Ok(q) => assert!(q.having.is_some()),
Err(e) => assert!(e.0.to_lowercase().contains("having"), "got: {}", e.0),
}
}
#[test]
fn group_by_after_where() {
let q = super::parse(
"SELECT @displayName, COUNT(*) FROM java.lang.Thread \
WHERE @usedHeapSize > 100 GROUP BY @displayName",
)
.unwrap();
assert_eq!(q.group_by.len(), 1);
assert!(q.where_.is_some());
}
#[test]
fn parse_case_when_else() {
let q = super::parse(
r#"SELECT CASE WHEN @usedHeapSize > 1000 THEN "large" ELSE "small" END FROM java.lang.String"#,
)
.unwrap();
assert_eq!(q.select.len(), 1);
let item = &q.select[0];
assert!(
matches!(item, crate::query::ast::SelectItem::Expr(e)
if matches!(e.as_ref(), crate::query::ast::Expr::Case { branches, else_, .. }
if branches.len() == 1 && else_.is_some())),
"expected Case expr, got: {item:?}"
);
}
#[test]
fn parse_case_when_no_else() {
let q = super::parse(
r#"SELECT CASE WHEN @usedHeapSize > 100 THEN "big" END FROM java.lang.String"#,
)
.unwrap();
let item = &q.select[0];
assert!(
matches!(item, crate::query::ast::SelectItem::Expr(e)
if matches!(e.as_ref(), crate::query::ast::Expr::Case { else_, .. } if else_.is_none())),
"expected Case with no else, got: {item:?}"
);
}
#[test]
fn parse_case_multi_when() {
let q = super::parse(
r#"SELECT CASE WHEN @usedHeapSize > 10000 THEN "xl" WHEN @usedHeapSize > 1000 THEN "lg" ELSE "sm" END FROM java.lang.String"#,
)
.unwrap();
let item = &q.select[0];
assert!(
matches!(item, crate::query::ast::SelectItem::Expr(e)
if matches!(e.as_ref(), crate::query::ast::Expr::Case { branches, .. } if branches.len() == 2)),
"expected 2 branches, got: {item:?}"
);
}
#[test]
fn parse_coalesce() {
let q = super::parse("SELECT COALESCE(@usedHeapSize, 0) FROM java.lang.String").unwrap();
assert!(
matches!(&q.select[0], crate::query::ast::SelectItem::Expr(e)
if matches!(e.as_ref(), crate::query::ast::Expr::Coalesce(args) if args.len() == 2)),
"got: {:?}",
q.select[0]
);
}
#[test]
fn parse_nullif() {
let q = super::parse("SELECT NULLIF(@usedHeapSize, 0) FROM java.lang.String").unwrap();
assert!(
matches!(&q.select[0], crate::query::ast::SelectItem::Expr(e)
if matches!(e.as_ref(), crate::query::ast::Expr::NullIf { .. })),
"got: {:?}",
q.select[0]
);
}
#[test]
fn parse_between() {
let q =
super::parse("SELECT * FROM java.lang.String WHERE @usedHeapSize BETWEEN 10 AND 100")
.unwrap();
assert!(q.where_.is_some());
assert!(
matches!(&q.where_, Some(crate::query::ast::Predicate::And(_, _))),
"BETWEEN should desugar to And, got: {:?}",
q.where_
);
}
#[test]
fn parse_not_between() {
let q = super::parse(
"SELECT * FROM java.lang.String WHERE @usedHeapSize NOT BETWEEN 10 AND 100",
)
.unwrap();
assert!(
matches!(&q.where_, Some(crate::query::ast::Predicate::Or(_, _))),
"NOT BETWEEN should desugar to Or(Lt, Gt), got: {:?}",
q.where_
);
}
#[test]
fn parse_not_between_desugars_to_lt_gt() {
use crate::query::ast::{CompareOp, Predicate};
let q = super::parse(
"SELECT * FROM java.lang.String s WHERE @usedHeapSize NOT BETWEEN 10 AND 100",
)
.unwrap();
if let Some(Predicate::Or(lhs, rhs)) = &q.where_ {
assert!(
matches!(
lhs.as_ref(),
Predicate::Compare {
op: CompareOp::Lt,
..
}
),
"left branch should be Lt, got: {lhs:?}"
);
assert!(
matches!(
rhs.as_ref(),
Predicate::Compare {
op: CompareOp::Gt,
..
}
),
"right branch should be Gt, got: {rhs:?}"
);
} else {
panic!("NOT BETWEEN should produce Or, got: {:?}", q.where_);
}
}
#[test]
fn coalesce_zero_args_errors() {
let err = super::parse("SELECT COALESCE() FROM java.lang.String")
.expect_err("zero-arg COALESCE must error");
assert!(
err.0.contains("COALESCE") || err.0.to_lowercase().contains("coalesce"),
"error must mention COALESCE, got: {}",
err.0
);
}
#[test]
fn parse_in_value_list() {
use crate::query::ast::{CompareOp, Predicate};
let q = super::parse(
r#"SELECT * FROM java.lang.String s WHERE toString(s) IN ("MONDAY", "TUESDAY")"#,
)
.unwrap();
assert!(
matches!(&q.where_, Some(Predicate::Or(_, _))),
"IN list should desugar to OR chain, got: {:?}",
q.where_
);
if let Some(Predicate::Or(left, right)) = &q.where_ {
assert!(matches!(
left.as_ref(),
Predicate::Compare {
op: CompareOp::Eq,
..
}
));
assert!(matches!(
right.as_ref(),
Predicate::Compare {
op: CompareOp::Eq,
..
}
));
}
}
#[test]
fn parse_not_in_value_list() {
use crate::query::ast::Predicate;
let q = super::parse(
r#"SELECT * FROM java.lang.String s WHERE toString(s) NOT IN ("MONDAY", "TUESDAY")"#,
)
.unwrap();
assert!(
matches!(&q.where_, Some(Predicate::And(_, _))),
"NOT IN list should desugar to AND chain, got: {:?}",
q.where_
);
}
#[test]
fn parse_exists() {
let q = super::parse(
"SELECT COUNT(*) FROM java.lang.String \
WHERE EXISTS (SELECT * FROM java.lang.Thread)",
)
.unwrap();
assert!(
matches!(
&q.where_,
Some(crate::query::ast::Predicate::Exists { negated: false, .. })
),
"got: {:?}",
q.where_
);
}
#[test]
fn parse_not_exists() {
let q = super::parse(
"SELECT COUNT(*) FROM java.lang.String \
WHERE NOT EXISTS (SELECT * FROM java.lang.Thread)",
)
.unwrap();
assert!(
matches!(
&q.where_,
Some(crate::query::ast::Predicate::Exists { negated: true, .. })
),
"got: {:?}",
q.where_
);
}
#[test]
fn parse_is_null_and_is_not_null() {
use crate::query::ast::{CompareOp, Expr, Predicate, Value};
let q = super::parse("SELECT * FROM java.lang.String s WHERE toString(s) IS NULL").unwrap();
match &q.where_ {
Some(Predicate::Compare {
op: CompareOp::Eq,
rhs,
..
}) => {
assert_eq!(rhs, &Expr::Lit(Value::Null), "IS NULL rhs must be null");
}
other => panic!("IS NULL should desugar to = null, got: {other:?}"),
}
let q2 =
super::parse("SELECT * FROM java.lang.String s WHERE toString(s) IS NOT NULL").unwrap();
match &q2.where_ {
Some(Predicate::Compare {
op: CompareOp::Ne,
rhs,
..
}) => {
assert_eq!(rhs, &Expr::Lit(Value::Null), "IS NOT NULL rhs must be null");
}
other => panic!("IS NOT NULL should desugar to != null, got: {other:?}"),
}
}
#[test]
fn parse_intersect() {
let q = super::parse(
"SELECT @displayName FROM java.lang.Thread \
INTERSECT \
SELECT @displayName FROM java.lang.String",
)
.unwrap();
assert_eq!(q.intersect_branches.len(), 1);
assert!(q.except_branches.is_empty());
assert!(q.union_branches.is_empty());
}
#[test]
fn parse_except() {
let q = super::parse(
"SELECT @displayName FROM java.lang.Thread \
EXCEPT \
SELECT @displayName FROM java.lang.String",
)
.unwrap();
assert_eq!(q.except_branches.len(), 1);
assert!(q.intersect_branches.is_empty());
assert!(q.union_branches.is_empty());
}
#[test]
fn intersect_and_except_mixed_errors() {
let err = super::parse(
"SELECT @displayName FROM java.lang.Thread \
INTERSECT SELECT @displayName FROM java.lang.String \
EXCEPT SELECT @displayName FROM java.lang.Object",
)
.expect_err("mixing INTERSECT and EXCEPT must error");
assert!(
err.0.to_lowercase().contains("intersect") || err.0.to_lowercase().contains("except"),
"error must mention INTERSECT or EXCEPT, got: {}",
err.0
);
}
#[test]
fn parse_array_index() {
let q = super::parse("SELECT s.value[0] FROM java.lang.String s").unwrap();
let item = &q.select[0];
assert!(
matches!(
item,
crate::query::ast::SelectItem::Attr(crate::query::ast::Attr::ArrayIndex { .. })
),
"expected ArrayIndex, got: {item:?}"
);
}
#[test]
fn parse_array_slice() {
let q = super::parse("SELECT s.value[1:3] FROM java.lang.String s").unwrap();
let item = &q.select[0];
assert!(
matches!(
item,
crate::query::ast::SelectItem::Attr(crate::query::ast::Attr::ArraySlice { .. })
),
"expected ArraySlice, got: {item:?}"
);
}
#[test]
fn parse_array_slice_open_start() {
let q = super::parse("SELECT s.value[:3] FROM java.lang.String s").unwrap();
let item = &q.select[0];
assert!(
matches!(
item,
crate::query::ast::SelectItem::Attr(crate::query::ast::Attr::ArraySlice {
start: None,
..
})
),
"expected open-start slice, got: {item:?}"
);
}
#[test]
fn parse_array_slice_open_end() {
let q = super::parse("SELECT s.value[2:] FROM java.lang.String s").unwrap();
let item = &q.select[0];
assert!(
matches!(
item,
crate::query::ast::SelectItem::Attr(crate::query::ast::Attr::ArraySlice {
end: None,
..
})
),
"expected open-end slice, got: {item:?}"
);
}
#[test]
fn parse_error_uses_source_chars_not_debug_names() {
let err = parse_or_report("[SELCT * FROM java.lang.String").unwrap_err();
assert!(err.contains('['), "expected '[' in error, got: {err}");
assert!(
!err.contains("LBracket"),
"expected no 'LBracket' in error, got: {err}"
);
}
}