use core::fmt;
#[derive(Debug, Clone, PartialEq)]
enum Token {
Word(String),
Path(String),
String(String),
Number(f64),
Integer(i64),
Parameter(String),
Symbol(&'static str),
}
#[derive(Debug, Clone)]
struct Lexed {
token: Token,
offset: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("AQL parse error at offset {offset}: {reason}")]
pub struct AqlError {
pub offset: usize,
pub reason: String,
}
impl AqlError {
fn new(offset: usize, reason: impl Into<String>) -> Self {
Self {
offset,
reason: reason.into(),
}
}
}
#[allow(clippy::too_many_lines)]
fn lex(input: &str) -> Result<Vec<Lexed>, AqlError> {
let bytes = input.as_bytes();
let mut out = Vec::new();
let mut i = 0usize;
while i < bytes.len() {
let c = bytes[i];
if c.is_ascii_whitespace() {
i += 1;
continue;
}
let start = i;
match c {
b'\'' | b'"' => {
let quote = c;
i += 1;
let mut value = String::new();
loop {
if i >= bytes.len() {
return Err(AqlError::new(start, "unterminated string literal"));
}
if bytes[i] == b'\\' && i + 1 < bytes.len() {
value.push(bytes[i + 1] as char);
i += 2;
continue;
}
if bytes[i] == quote {
i += 1;
break;
}
value.push(bytes[i] as char);
i += 1;
}
out.push(Lexed {
token: Token::String(value),
offset: start,
});
}
b'$' => {
i += 1;
let from = i;
while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {
i += 1;
}
if i == from {
return Err(AqlError::new(start, "`$` with no parameter name"));
}
out.push(Lexed {
token: Token::Parameter(input[from..i].to_owned()),
offset: start,
});
}
b'0'..=b'9' => {
while i < bytes.len() && (bytes[i].is_ascii_digit() || bytes[i] == b'.') {
i += 1;
}
let text = &input[start..i];
let token = if text.contains('.') {
Token::Number(
text.parse()
.map_err(|_| AqlError::new(start, "malformed number"))?,
)
} else {
Token::Integer(
text.parse()
.map_err(|_| AqlError::new(start, "number does not fit in i64"))?,
)
};
out.push(Lexed {
token,
offset: start,
});
}
c if c.is_ascii_alphabetic() || c == b'_' => {
let mut depth = 0usize;
let mut seen_slash = false;
while i < bytes.len() {
let b = bytes[i];
if b == b'[' {
if depth == 0 && !seen_slash {
break;
}
depth += 1;
} else if b == b']' {
if depth == 0 {
break;
}
depth -= 1;
} else if depth == 0 {
if b == b'/' {
seen_slash = true;
} else if !(b.is_ascii_alphanumeric()
|| b == b'_'
|| b == b'-'
|| b == b'.')
{
break;
}
}
i += 1;
}
let text = &input[start..i];
let token = if text.contains('/') {
Token::Path(text.to_owned())
} else {
Token::Word(text.to_owned())
};
out.push(Lexed {
token,
offset: start,
});
}
_ => {
const SYMBOLS: [&str; 14] = [
">=", "<=", "!=", "(", ")", "{", "}", ",", "=", ">", "<", "[", "]", "*",
];
let Some(sym) = SYMBOLS.iter().find(|s| input[i..].starts_with(**s)) else {
return Err(AqlError::new(i, "unexpected character"));
};
i += sym.len();
out.push(Lexed {
token: Token::Symbol(sym),
offset: start,
});
}
}
}
Ok(out)
}
#[derive(Debug, Clone, PartialEq)]
pub enum Literal {
String(String),
Integer(i64),
Number(f64),
Boolean(bool),
}
impl fmt::Display for Literal {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::String(v) => write!(f, "'{v}'"),
Self::Integer(v) => write!(f, "{v}"),
Self::Number(v) => write!(f, "{v}"),
Self::Boolean(v) => write!(f, "{v}"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IdentifiedPath {
pub root: String,
pub path: Option<String>,
}
impl fmt::Display for IdentifiedPath {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.path {
Some(p) => write!(f, "{}/{p}", self.root),
None => f.write_str(&self.root),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum Operand {
Path(IdentifiedPath),
Literal(Literal),
Parameter(String),
Function {
name: String,
args: Vec<Operand>,
},
}
impl fmt::Display for Operand {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Path(p) => write!(f, "{p}"),
Self::Literal(l) => write!(f, "{l}"),
Self::Parameter(name) => write!(f, "${name}"),
Self::Function { name, args } => {
write!(f, "{name}(")?;
for (i, a) in args.iter().enumerate() {
if i > 0 {
write!(f, ", ")?;
}
write!(f, "{a}")?;
}
write!(f, ")")
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum CompareOp {
Equal,
NotEqual,
Greater,
GreaterOrEqual,
Less,
LessOrEqual,
Like,
Matches,
}
impl fmt::Display for CompareOp {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::Equal => "=",
Self::NotEqual => "!=",
Self::Greater => ">",
Self::GreaterOrEqual => ">=",
Self::Less => "<",
Self::LessOrEqual => "<=",
Self::Like => "LIKE",
Self::Matches => "MATCHES",
})
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum Expr {
Compare {
lhs: Operand,
op: CompareOp,
rhs: Operand,
},
MatchesSet {
lhs: Operand,
values: Vec<Operand>,
},
Exists(IdentifiedPath),
And(Box<Expr>, Box<Expr>),
Or(Box<Expr>, Box<Expr>),
Not(Box<Expr>),
}
impl fmt::Display for Expr {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Compare { lhs, op, rhs } => write!(f, "{lhs} {op} {rhs}"),
Self::MatchesSet { lhs, values } => {
write!(f, "{lhs} MATCHES {{")?;
for (i, v) in values.iter().enumerate() {
if i > 0 {
write!(f, ", ")?;
}
write!(f, "{v}")?;
}
write!(f, "}}")
}
Self::Exists(p) => write!(f, "EXISTS {p}"),
Self::And(a, b) => write!(f, "({a} AND {b})"),
Self::Or(a, b) => write!(f, "({a} OR {b})"),
Self::Not(a) => write!(f, "NOT ({a})"),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum Predicate {
Archetype(String),
Standard(Box<Expr>),
}
impl fmt::Display for Predicate {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Archetype(id) => write!(f, "[{id}]"),
Self::Standard(e) => write!(f, "[{e}]"),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ClassExpr {
pub rm_type: String,
pub alias: Option<String>,
pub predicate: Option<Predicate>,
}
impl fmt::Display for ClassExpr {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.rm_type)?;
if let Some(alias) = &self.alias {
write!(f, " {alias}")?;
}
if let Some(predicate) = &self.predicate {
write!(f, "{predicate}")?;
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum From {
Class(ClassExpr),
Contains {
left: Box<From>,
negated: bool,
right: Box<From>,
},
And(Box<From>, Box<From>),
Or(Box<From>, Box<From>),
}
impl From {
#[must_use]
pub fn classes(&self) -> Vec<&ClassExpr> {
let mut out = Vec::new();
self.collect_classes(&mut out);
out
}
fn collect_classes<'a>(&'a self, out: &mut Vec<&'a ClassExpr>) {
match self {
Self::Class(c) => out.push(c),
Self::Contains { left, right, .. } | Self::And(left, right) | Self::Or(left, right) => {
left.collect_classes(out);
right.collect_classes(out);
}
}
}
}
impl fmt::Display for From {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Class(c) => write!(f, "{c}"),
Self::Contains {
left,
negated,
right,
} => {
let not = if *negated { "NOT " } else { "" };
write!(f, "{left} {not}CONTAINS {right}")
}
Self::And(a, b) => write!(f, "({a} AND {b})"),
Self::Or(a, b) => write!(f, "({a} OR {b})"),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct SelectColumn {
pub expr: Operand,
pub alias: Option<String>,
}
impl fmt::Display for SelectColumn {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.expr)?;
if let Some(alias) = &self.alias {
write!(f, " AS {alias}")?;
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Direction {
Ascending,
Descending,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OrderBy {
pub path: IdentifiedPath,
pub direction: Direction,
}
impl fmt::Display for OrderBy {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.path)?;
if self.direction == Direction::Descending {
write!(f, " DESC")?;
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct AqlQuery {
pub distinct: bool,
pub top: Option<u64>,
pub columns: Vec<SelectColumn>,
pub from: From,
pub where_clause: Option<Expr>,
pub order_by: Vec<OrderBy>,
pub limit: Option<u64>,
pub offset: Option<u64>,
}
impl AqlQuery {
#[must_use]
pub fn aliases(&self) -> Vec<&str> {
self.from
.classes()
.into_iter()
.filter_map(|c| c.alias.as_deref())
.collect()
}
#[must_use]
pub fn archetype_ids(&self) -> Vec<&str> {
self.from
.classes()
.into_iter()
.filter_map(|c| match &c.predicate {
Some(Predicate::Archetype(id)) => Some(id.as_str()),
_ => None,
})
.collect()
}
#[must_use]
pub fn parameters(&self) -> Vec<&str> {
let mut out: Vec<&str> = Vec::new();
for c in &self.columns {
walk_operand_parameters(&c.expr, &mut out);
}
for class in self.from.classes() {
if let Some(Predicate::Standard(e)) = &class.predicate {
walk_expr_parameters(e, &mut out);
}
}
if let Some(w) = &self.where_clause {
walk_expr_parameters(w, &mut out);
}
out
}
pub fn check(&self) -> Result<(), AqlError> {
let aliases = self.aliases();
let mut unbound: Option<String> = None;
let mut check_path = |p: &IdentifiedPath| {
if unbound.is_none() && !aliases.contains(&p.root.as_str()) {
unbound = Some(p.root.clone());
}
};
for c in &self.columns {
if let Operand::Path(p) = &c.expr {
check_path(p);
}
}
for o in &self.order_by {
check_path(&o.path);
}
if let Some(w) = &self.where_clause {
for p in collect_paths(w) {
check_path(p);
}
}
match unbound {
None => Ok(()),
Some(alias) => Err(AqlError::new(
0,
format!("path is rooted at `{alias}`, which FROM does not bind"),
)),
}
}
}
fn walk_operand_parameters<'a>(op: &'a Operand, out: &mut Vec<&'a str>) {
match op {
Operand::Parameter(name) => {
if !out.contains(&name.as_str()) {
out.push(name);
}
}
Operand::Function { args, .. } => {
for a in args {
walk_operand_parameters(a, out);
}
}
Operand::Path(_) | Operand::Literal(_) => {}
}
}
fn walk_expr_parameters<'a>(expr: &'a Expr, out: &mut Vec<&'a str>) {
match expr {
Expr::Compare { lhs, rhs, .. } => {
walk_operand_parameters(lhs, out);
walk_operand_parameters(rhs, out);
}
Expr::MatchesSet { lhs, values } => {
walk_operand_parameters(lhs, out);
for v in values {
walk_operand_parameters(v, out);
}
}
Expr::And(a, b) | Expr::Or(a, b) => {
walk_expr_parameters(a, out);
walk_expr_parameters(b, out);
}
Expr::Not(a) => walk_expr_parameters(a, out),
Expr::Exists(_) => {}
}
}
fn walk_expr_paths<'a>(expr: &'a Expr, out: &mut Vec<&'a IdentifiedPath>) {
match expr {
Expr::Compare { lhs, rhs, .. } => {
if let Operand::Path(p) = lhs {
out.push(p);
}
if let Operand::Path(p) = rhs {
out.push(p);
}
}
Expr::MatchesSet { lhs, .. } => {
if let Operand::Path(p) = lhs {
out.push(p);
}
}
Expr::Exists(p) => out.push(p),
Expr::And(a, b) | Expr::Or(a, b) => {
walk_expr_paths(a, out);
walk_expr_paths(b, out);
}
Expr::Not(a) => walk_expr_paths(a, out),
}
}
fn collect_paths(expr: &Expr) -> Vec<&IdentifiedPath> {
let mut out = Vec::new();
walk_expr_paths(expr, &mut out);
out
}
impl fmt::Display for AqlQuery {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("SELECT ")?;
if self.distinct {
f.write_str("DISTINCT ")?;
}
if let Some(top) = self.top {
write!(f, "TOP {top} ")?;
}
for (i, c) in self.columns.iter().enumerate() {
if i > 0 {
f.write_str(", ")?;
}
write!(f, "{c}")?;
}
write!(f, " FROM {}", self.from)?;
if let Some(w) = &self.where_clause {
write!(f, " WHERE {w}")?;
}
if !self.order_by.is_empty() {
f.write_str(" ORDER BY ")?;
for (i, o) in self.order_by.iter().enumerate() {
if i > 0 {
f.write_str(", ")?;
}
write!(f, "{o}")?;
}
}
if let Some(limit) = self.limit {
write!(f, " LIMIT {limit}")?;
}
if let Some(offset) = self.offset {
write!(f, " OFFSET {offset}")?;
}
Ok(())
}
}
impl core::str::FromStr for AqlQuery {
type Err = AqlError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Parser::new(lex(s)?, s.len()).query()
}
}
struct Parser {
tokens: Vec<Lexed>,
pos: usize,
end: usize,
}
impl Parser {
fn new(tokens: Vec<Lexed>, end: usize) -> Self {
Self {
tokens,
pos: 0,
end,
}
}
fn offset(&self) -> usize {
self.tokens.get(self.pos).map_or(self.end, |t| t.offset)
}
fn peek(&self) -> Option<&Token> {
self.tokens.get(self.pos).map(|t| &t.token)
}
fn next_token(&mut self) -> Option<Token> {
let t = self.tokens.get(self.pos).map(|t| t.token.clone());
if t.is_some() {
self.pos += 1;
}
t
}
fn peek_keyword(&self, word: &str) -> bool {
matches!(self.peek(), Some(Token::Word(w)) if w.eq_ignore_ascii_case(word))
}
fn eat_keyword(&mut self, word: &str) -> bool {
if self.peek_keyword(word) {
self.pos += 1;
true
} else {
false
}
}
fn expect_keyword(&mut self, word: &str) -> Result<(), AqlError> {
if self.eat_keyword(word) {
Ok(())
} else {
Err(AqlError::new(self.offset(), format!("expected `{word}`")))
}
}
fn eat_symbol(&mut self, sym: &str) -> bool {
if matches!(self.peek(), Some(Token::Symbol(s)) if *s == sym) {
self.pos += 1;
true
} else {
false
}
}
fn expect_symbol(&mut self, sym: &str) -> Result<(), AqlError> {
if self.eat_symbol(sym) {
Ok(())
} else {
Err(AqlError::new(self.offset(), format!("expected `{sym}`")))
}
}
fn query(&mut self) -> Result<AqlQuery, AqlError> {
self.expect_keyword("SELECT")?;
let distinct = self.eat_keyword("DISTINCT");
let top = if self.eat_keyword("TOP") {
Some(self.integer()?)
} else {
None
};
if self.eat_symbol("*") {
return Err(AqlError::new(
self.offset(),
"`SELECT *` is not supported: AQL projections must name paths (Q12.9)",
));
}
let mut columns = vec![self.select_column()?];
while self.eat_symbol(",") {
columns.push(self.select_column()?);
}
self.expect_keyword("FROM")?;
let from = self.containment()?;
let where_clause = if self.eat_keyword("WHERE") {
Some(self.expr()?)
} else {
None
};
let mut order_by = Vec::new();
if self.eat_keyword("ORDER") {
self.expect_keyword("BY")?;
loop {
let path = self.identified_path()?;
let direction = if self.eat_keyword("DESC") {
Direction::Descending
} else {
let _ = self.eat_keyword("ASC");
Direction::Ascending
};
order_by.push(OrderBy { path, direction });
if !self.eat_symbol(",") {
break;
}
}
}
let limit = if self.eat_keyword("LIMIT") {
Some(self.integer()?)
} else {
None
};
let offset = if self.eat_keyword("OFFSET") {
Some(self.integer()?)
} else {
None
};
if self.pos < self.tokens.len() {
return Err(AqlError::new(self.offset(), "unexpected trailing input"));
}
Ok(AqlQuery {
distinct,
top,
columns,
from,
where_clause,
order_by,
limit,
offset,
})
}
fn integer(&mut self) -> Result<u64, AqlError> {
let offset = self.offset();
match self.next_token() {
Some(Token::Integer(v)) if v >= 0 => Ok(u64::try_from(v).unwrap_or(0)),
_ => Err(AqlError::new(offset, "expected a non-negative integer")),
}
}
fn select_column(&mut self) -> Result<SelectColumn, AqlError> {
let expr = self.operand()?;
let alias = if self.eat_keyword("AS") {
let offset = self.offset();
match self.next_token() {
Some(Token::Word(w)) => Some(w),
_ => return Err(AqlError::new(offset, "expected an alias after `AS`")),
}
} else {
None
};
Ok(SelectColumn { expr, alias })
}
fn operand(&mut self) -> Result<Operand, AqlError> {
let offset = self.offset();
match self.next_token() {
Some(Token::String(v)) => Ok(Operand::Literal(Literal::String(v))),
Some(Token::Integer(v)) => Ok(Operand::Literal(Literal::Integer(v))),
Some(Token::Number(v)) => Ok(Operand::Literal(Literal::Number(v))),
Some(Token::Parameter(name)) => Ok(Operand::Parameter(name)),
Some(Token::Path(text)) => Ok(Operand::Path(split_path(&text))),
Some(Token::Word(w)) => {
if w.eq_ignore_ascii_case("true") {
return Ok(Operand::Literal(Literal::Boolean(true)));
}
if w.eq_ignore_ascii_case("false") {
return Ok(Operand::Literal(Literal::Boolean(false)));
}
if self.eat_symbol("(") {
let mut args = Vec::new();
if !self.eat_symbol(")") {
loop {
args.push(self.operand()?);
if !self.eat_symbol(",") {
break;
}
}
self.expect_symbol(")")?;
}
return Ok(Operand::Function {
name: w.to_uppercase(),
args,
});
}
Ok(Operand::Path(IdentifiedPath {
root: w,
path: None,
}))
}
_ => Err(AqlError::new(offset, "expected a value or a path")),
}
}
fn identified_path(&mut self) -> Result<IdentifiedPath, AqlError> {
let offset = self.offset();
match self.next_token() {
Some(Token::Path(text)) => Ok(split_path(&text)),
Some(Token::Word(w)) => Ok(IdentifiedPath {
root: w,
path: None,
}),
_ => Err(AqlError::new(offset, "expected a path")),
}
}
fn containment(&mut self) -> Result<From, AqlError> {
let mut left = self.containment_primary()?;
loop {
if self.peek_keyword("CONTAINS") || self.peek_keyword("NOT") {
let negated = self.eat_keyword("NOT");
if negated && !self.peek_keyword("CONTAINS") {
return Err(AqlError::new(
self.offset(),
"expected `CONTAINS` after `NOT`",
));
}
self.expect_keyword("CONTAINS")?;
let right = self.containment()?;
left = From::Contains {
left: Box::new(left),
negated,
right: Box::new(right),
};
continue;
}
if self.eat_keyword("AND") {
let right = self.containment()?;
left = From::And(Box::new(left), Box::new(right));
continue;
}
if self.eat_keyword("OR") {
let right = self.containment()?;
left = From::Or(Box::new(left), Box::new(right));
continue;
}
break;
}
Ok(left)
}
fn containment_primary(&mut self) -> Result<From, AqlError> {
if self.eat_symbol("(") {
let inner = self.containment()?;
self.expect_symbol(")")?;
return Ok(inner);
}
let offset = self.offset();
let Some(Token::Word(rm_type)) = self.next_token() else {
return Err(AqlError::new(offset, "expected an RM class name"));
};
if rm_type.eq_ignore_ascii_case("VERSION") {
return Err(AqlError::new(
offset,
"the VERSION class extension is not supported (Q12.9)",
));
}
let alias = match self.peek() {
Some(Token::Word(w))
if ![
"CONTAINS", "NOT", "AND", "OR", "WHERE", "ORDER", "LIMIT", "OFFSET",
]
.iter()
.any(|k| w.eq_ignore_ascii_case(k)) =>
{
self.next_token();
match self.tokens[self.pos - 1].token.clone() {
Token::Word(w) => Some(w),
_ => None,
}
}
_ => None,
};
let predicate = if self.eat_symbol("[") {
let p = self.predicate()?;
self.expect_symbol("]")?;
Some(p)
} else {
None
};
Ok(From::Class(ClassExpr {
rm_type,
alias,
predicate,
}))
}
fn predicate(&mut self) -> Result<Predicate, AqlError> {
if let Some(Token::Word(w) | Token::Path(w)) = self.peek() {
let looks_archetype = w.contains('-') && w.contains('.');
let next_is_close = matches!(
self.tokens.get(self.pos + 1).map(|t| &t.token),
Some(Token::Symbol("]"))
);
if looks_archetype && next_is_close {
let id = w.clone();
self.pos += 1;
return Ok(Predicate::Archetype(id));
}
}
Ok(Predicate::Standard(Box::new(self.expr()?)))
}
fn expr(&mut self) -> Result<Expr, AqlError> {
let mut left = self.expr_and()?;
while self.eat_keyword("OR") {
let right = self.expr_and()?;
left = Expr::Or(Box::new(left), Box::new(right));
}
Ok(left)
}
fn expr_and(&mut self) -> Result<Expr, AqlError> {
let mut left = self.expr_unary()?;
while self.eat_keyword("AND") {
let right = self.expr_unary()?;
left = Expr::And(Box::new(left), Box::new(right));
}
Ok(left)
}
fn expr_unary(&mut self) -> Result<Expr, AqlError> {
if self.eat_keyword("NOT") {
return Ok(Expr::Not(Box::new(self.expr_unary()?)));
}
if self.eat_keyword("EXISTS") {
return Ok(Expr::Exists(self.identified_path()?));
}
if self.eat_symbol("(") {
let inner = self.expr()?;
self.expect_symbol(")")?;
return Ok(inner);
}
let lhs = self.operand()?;
if self.eat_keyword("MATCHES") {
let close = if self.eat_symbol("{") {
"}"
} else if self.eat_symbol("(") {
")"
} else {
return Err(AqlError::new(
self.offset(),
"expected `{` or `(` after MATCHES",
));
};
let mut values = Vec::new();
if !self.eat_symbol(close) {
loop {
values.push(self.operand()?);
if !self.eat_symbol(",") {
break;
}
}
self.expect_symbol(close)?;
}
return Ok(Expr::MatchesSet { lhs, values });
}
let offset = self.offset();
let op = if self.eat_keyword("LIKE") {
CompareOp::Like
} else {
match self.next_token() {
Some(Token::Symbol("=")) => CompareOp::Equal,
Some(Token::Symbol("!=")) => CompareOp::NotEqual,
Some(Token::Symbol(">")) => CompareOp::Greater,
Some(Token::Symbol(">=")) => CompareOp::GreaterOrEqual,
Some(Token::Symbol("<")) => CompareOp::Less,
Some(Token::Symbol("<=")) => CompareOp::LessOrEqual,
_ => return Err(AqlError::new(offset, "expected a comparison operator")),
}
};
let rhs = self.operand()?;
Ok(Expr::Compare { lhs, op, rhs })
}
}
fn split_path(text: &str) -> IdentifiedPath {
match text.split_once('/') {
Some((root, path)) => IdentifiedPath {
root: root.to_owned(),
path: Some(path.to_owned()),
},
None => IdentifiedPath {
root: text.to_owned(),
path: None,
},
}
}
#[cfg(test)]
mod tests {
use super::*;
const BLOOD_PRESSURE: &str = "
SELECT
o/data[at0001]/events[at0006]/data[at0003]/items[at0004]/value/magnitude AS systolic,
o/data[at0001]/events[at0006]/data[at0003]/items[at0005]/value/magnitude AS diastolic,
c/context/start_time AS taken
FROM EHR e[ehr_id/value=$ehrUid]
CONTAINS COMPOSITION c[openEHR-EHR-COMPOSITION.encounter.v1]
CONTAINS OBSERVATION o[openEHR-EHR-OBSERVATION.blood_pressure.v2]
WHERE o/data[at0001]/events[at0006]/data[at0003]/items[at0004]/value/magnitude >= 140
OR o/data[at0001]/events[at0006]/data[at0003]/items[at0005]/value/magnitude >= 90
ORDER BY c/context/start_time DESC
LIMIT 5
";
#[test]
fn the_canonical_blood_pressure_query_parses() {
let q: AqlQuery = BLOOD_PRESSURE.parse().unwrap();
assert_eq!(q.columns.len(), 3);
assert_eq!(q.aliases(), vec!["e", "c", "o"]);
assert_eq!(
q.archetype_ids(),
vec![
"openEHR-EHR-COMPOSITION.encounter.v1",
"openEHR-EHR-OBSERVATION.blood_pressure.v2"
]
);
assert_eq!(q.parameters(), vec!["ehrUid"]);
assert_eq!(q.limit, Some(5));
assert_eq!(q.order_by[0].direction, Direction::Descending);
q.check().unwrap();
}
#[test]
fn a_parsed_query_reparses_from_its_own_rendering() {
let q: AqlQuery = BLOOD_PRESSURE.parse().unwrap();
let rendered = q.to_string();
let again: AqlQuery = rendered.parse().unwrap_or_else(|e| {
panic!("re-parse failed: {e}\nrendered: {rendered}");
});
assert_eq!(again, q);
}
#[test]
fn an_undefined_alias_is_reported_although_the_query_is_well_formed() {
let q: AqlQuery = "SELECT o/value FROM COMPOSITION c CONTAINS OBSERVATION obs"
.parse()
.unwrap();
let err = q.check().unwrap_err();
assert!(err.reason.contains("`o`"), "{err}");
let good: AqlQuery = "SELECT obs/value FROM COMPOSITION c CONTAINS OBSERVATION obs"
.parse()
.unwrap();
assert!(good.check().is_ok());
}
#[test]
fn aggregates_and_distinct_parse() {
let q: AqlQuery = "
SELECT DISTINCT MAX(o/data/events/data/items/value/magnitude) AS peak, COUNT(c) AS n
FROM EHR CONTAINS COMPOSITION c CONTAINS OBSERVATION o
"
.parse()
.unwrap();
assert!(q.distinct);
assert!(matches!(q.columns[0].expr, Operand::Function { .. }));
if let Operand::Function { name, .. } = &q.columns[0].expr {
assert_eq!(name, "MAX");
}
}
#[test]
fn not_contains_parses_and_keeps_its_negation() {
let q: AqlQuery = "
SELECT e/ehr_id/value
FROM EHR e CONTAINS COMPOSITION c[openEHR-EHR-COMPOSITION.referral.v1]
NOT CONTAINS OBSERVATION o[openEHR-EHR-OBSERVATION.lab_test.v1]
"
.parse()
.unwrap();
let rendered = q.to_string();
assert!(rendered.contains("NOT CONTAINS"), "{rendered}");
}
#[test]
fn like_and_offset_parse() {
let q: AqlQuery = "
SELECT DISTINCT c/name/value AS n
FROM EHR e[ehr_id/value=$ehrUid] CONTAINS COMPOSITION c
WHERE c/context/start_time LIKE '2019-0?-*'
ORDER BY c/context/start_time
LIMIT 10 OFFSET 10
"
.parse()
.unwrap();
assert_eq!(q.offset, Some(10));
assert_eq!(q.order_by[0].direction, Direction::Ascending);
assert!(matches!(
q.where_clause,
Some(Expr::Compare {
op: CompareOp::Like,
..
})
));
}
#[test]
fn unsupported_constructs_are_refused_and_say_so() {
for (text, needle) in [
("SELECT * FROM COMPOSITION c", "SELECT *"),
("SELECT c/uid FROM VERSION v", "VERSION"),
] {
let err = text.parse::<AqlQuery>().unwrap_err();
assert!(err.reason.contains(needle), "{err}");
assert!(err.reason.contains("Q12.9"), "{err}");
}
}
#[test]
fn malformed_queries_report_an_offset() {
for text in [
"SELECT",
"SELECT c/uid",
"SELECT c/uid FROM",
"SELECT c/uid FROM COMPOSITION c WHERE",
"SELECT c/uid FROM COMPOSITION c LIMIT",
"SELECT c/uid FROM COMPOSITION c EXTRA",
"SELECT 'unterminated FROM COMPOSITION c",
] {
assert!(text.parse::<AqlQuery>().is_err(), "accepted {text:?}");
}
}
#[test]
fn parameters_are_collected_from_every_clause_and_deduplicated() {
let q: AqlQuery = "
SELECT c/uid/value
FROM EHR e[ehr_id/value=$ehrUid] CONTAINS COMPOSITION c
WHERE c/context/start_time > $since AND c/name/value = $name
"
.parse()
.unwrap();
assert_eq!(q.parameters(), vec!["ehrUid", "since", "name"]);
}
#[test]
fn keywords_are_case_insensitive() {
let lower: AqlQuery = "select c/uid from COMPOSITION c limit 1".parse().unwrap();
let upper: AqlQuery = "SELECT c/uid FROM COMPOSITION c LIMIT 1".parse().unwrap();
assert_eq!(lower, upper);
}
#[test]
fn a_string_literal_carries_its_escapes() {
let value = |text: &str| -> String {
let q: AqlQuery = format!("SELECT c/uid FROM COMPOSITION c WHERE c/name/value = {text}")
.parse()
.unwrap_or_else(|e| panic!("{text}: {e}"));
match q.where_clause {
Some(Expr::Compare {
rhs: Operand::Literal(Literal::String(v)),
..
}) => v,
other => panic!("{text} did not parse to a string literal: {other:?}"),
}
};
assert_eq!(value(r"'O\'Brien'"), "O'Brien");
assert_eq!(value(r"'a\\b'"), r"a\b");
assert_eq!(value(r#""it's here""#), "it's here");
assert_eq!(value(r#"'say \"hi\"'"#), r#"say "hi""#);
assert_eq!(value("''"), "");
assert!(
"SELECT c/uid FROM COMPOSITION c WHERE c/name/value = 'x\\"
.parse::<AqlQuery>()
.is_err(),
"a literal ending in a dangling backslash is unterminated"
);
}
#[test]
fn a_parameter_may_be_the_last_thing_in_a_query() {
let q: AqlQuery = "SELECT c/uid FROM COMPOSITION c WHERE c/uid/value = $uid"
.parse()
.unwrap();
assert_eq!(q.parameters(), vec!["uid"]);
assert!("SELECT c/uid FROM COMPOSITION c WHERE c/uid/value = $"
.parse::<AqlQuery>()
.is_err());
}
#[test]
fn a_negative_limit_or_offset_is_refused_rather_than_clamped() {
for text in [
"SELECT c/uid FROM COMPOSITION c LIMIT -5",
"SELECT c/uid FROM COMPOSITION c LIMIT 5 OFFSET -1",
] {
assert!(text.parse::<AqlQuery>().is_err(), "accepted {text}");
}
let q: AqlQuery = "SELECT c/uid FROM COMPOSITION c LIMIT 5 OFFSET 10"
.parse()
.unwrap();
assert_eq!((q.limit, q.offset), (Some(5), Some(10)));
}
#[test]
fn a_negative_numeric_literal_is_refused_rather_than_misread() {
for text in [
"SELECT c/uid FROM COMPOSITION c WHERE c/v > -1",
"SELECT c/uid FROM COMPOSITION c WHERE c/v > -2.5",
] {
let err = text.parse::<AqlQuery>().expect_err(text);
assert_eq!(err.offset, text.find('-').unwrap(), "{}", err.reason);
}
}
#[test]
fn a_parse_error_points_at_the_token_that_failed() {
let text = "SELECT c/uid FROM COMPOSITION c WHERE";
let err = text.parse::<AqlQuery>().expect_err(text);
assert_eq!(
err.offset,
text.len(),
"running off the end reports the end, not 0"
);
let text = "SELECT c/uid FROM COMPOSITION c LIMIT 'five'";
let err = text.parse::<AqlQuery>().expect_err(text);
assert_eq!(err.offset, text.find('\'').unwrap());
}
#[test]
fn a_comparison_may_use_a_float_a_boolean_or_a_negative_number() {
let q: AqlQuery = "
SELECT o/value/magnitude
FROM COMPOSITION c CONTAINS OBSERVATION o
WHERE o/value/magnitude > 37.5
AND o/value/units = 'Cel'
AND o/deleted = false
"
.parse()
.unwrap();
let rendered = q.to_string();
for wanted in ["37.5", "'Cel'", "false"] {
assert!(rendered.contains(wanted), "{wanted} lost from {rendered}");
}
assert_eq!(rendered.parse::<AqlQuery>().unwrap(), q);
}
#[test]
fn every_comparison_operator_and_matches_set_survives_a_round_trip() {
let q: AqlQuery = "
SELECT c/uid/value
FROM COMPOSITION c
WHERE c/a != 1 AND c/b < 2 AND c/c <= 3 AND c/d > 4 AND c/e >= 5
AND c/f LIKE 'x%'
AND NOT c/g = 6
AND c/category MATCHES {'433', '431', '451'}
"
.parse()
.unwrap();
let rendered = q.to_string();
for wanted in [
"c/a != 1", "c/b < 2", "c/c <= 3", "c/d > 4", "c/e >= 5",
"c/f LIKE 'x%'", "NOT ", "'433', '431', '451'",
] {
assert!(rendered.contains(wanted), "{wanted} lost from {rendered}");
}
assert_eq!(rendered.parse::<AqlQuery>().unwrap(), q);
}
#[test]
fn check_sees_an_unbound_alias_inside_every_shape_of_condition() {
for condition in [
"o/value = 1",
"NOT o/value = 1",
"c/uid = 1 AND o/value = 1",
"c/uid = 1 OR o/value = 1",
"o/value MATCHES {1, 2}",
"EXISTS o/value",
] {
let text = format!("SELECT c/uid FROM COMPOSITION c WHERE {condition}");
let q: AqlQuery = text.parse().unwrap_or_else(|e| panic!("{condition}: {e}"));
let err = q
.check()
.expect_err(&format!("`{condition}` is rooted at unbound `o`"));
assert!(err.reason.contains('o'), "{}", err.reason);
}
}
#[test]
fn a_rendering_separates_more_than_one_of_everything() {
let q: AqlQuery = "
SELECT max(o/value/magnitude, o/value/precision) AS peak
FROM COMPOSITION c CONTAINS OBSERVATION o
ORDER BY c/context/start_time DESC, c/uid/value ASC
"
.parse()
.unwrap();
let rendered = q.to_string();
assert!(
rendered.contains("MAX(o/value/magnitude, o/value/precision)"),
"arguments run together: {rendered}"
);
assert!(
rendered.contains("c/context/start_time DESC, c/uid/value"),
"sort keys run together: {rendered}"
);
assert_eq!(rendered.parse::<AqlQuery>().unwrap(), q);
}
#[test]
fn a_predicate_is_an_archetype_only_when_it_looks_like_one_and_stands_alone() {
let q: AqlQuery = "SELECT c/uid FROM COMPOSITION c[openEHR-EHR-COMPOSITION.encounter.v1]"
.parse()
.unwrap();
assert_eq!(q.archetype_ids(), vec!["openEHR-EHR-COMPOSITION.encounter.v1"]);
let q: AqlQuery = "SELECT c/uid FROM COMPOSITION c[name.value = 'x']"
.parse()
.unwrap();
assert!(
q.archetype_ids().is_empty(),
"a condition was read as an archetype id"
);
let q: AqlQuery =
"SELECT c/uid FROM COMPOSITION c[archetype_node_id = 'openEHR-EHR-COMPOSITION.encounter.v1']"
.parse()
.unwrap();
assert!(q.archetype_ids().is_empty());
}
#[test]
fn a_bare_alias_is_a_path_to_the_whole_object() {
let q: AqlQuery = "SELECT c/uid FROM COMPOSITION c ORDER BY c"
.parse()
.unwrap();
assert_eq!(q.order_by[0].path.root, "c");
assert!(q.order_by[0].path.path.is_none());
q.check().expect("`c` is bound by FROM");
let q: AqlQuery = "SELECT c/uid FROM COMPOSITION c WHERE EXISTS o"
.parse()
.unwrap();
assert!(q.check().is_err(), "`o` is not bound by FROM");
}
#[test]
fn only_a_dashed_and_dotted_word_standing_alone_is_an_archetype_id() {
let ids = |text: &str| -> Vec<String> {
text.parse::<AqlQuery>()
.unwrap_or_else(|e| panic!("{text}: {e}"))
.archetype_ids()
.into_iter()
.map(str::to_owned)
.collect()
};
assert_eq!(
ids("SELECT c/uid FROM COMPOSITION c[openEHR-EHR-COMPOSITION.encounter.v1]"),
vec!["openEHR-EHR-COMPOSITION.encounter.v1"]
);
for text in [
"SELECT c/uid FROM COMPOSITION c[at0001.1]", "SELECT c/uid FROM COMPOSITION c[some-word]", "SELECT c/uid FROM COMPOSITION c[at0001]", ] {
assert!(
text.parse::<AqlQuery>().is_err(),
"{text} was accepted, and its predicate read as an archetype id"
);
}
assert!(ids(
"SELECT c/uid FROM COMPOSITION c[archetype_node_id = 'openEHR-EHR-COMPOSITION.encounter.v1']"
)
.is_empty());
}
}