implement unary NOT

This commit is contained in:
aecsocket
2026-07-25 07:31:49 +01:00
parent efe6f9ba16
commit a34099e925
7 changed files with 217 additions and 47 deletions
+1
View File
@@ -115,6 +115,7 @@ serde_json = { workspace = true }
serde_with = { workspace = true }
sha1 = { workspace = true }
sha2 = { workspace = true }
smallvec = { workspace = true }
spdx = { workspace = true, features = ["text"] }
sqlx = { workspace = true, features = [
"chrono",
@@ -158,6 +158,9 @@ fn lower(filter: &FilterExpr, version: bool) -> Result<TypesenseFilter<'_>> {
validate_predicate(predicate)?;
Ok(TypesenseFilter::Predicate { predicate, version })
}
FilterExpr::Not(_) => {
Err(eyre!("search filter contains an unnormalized negation"))
}
}
}
@@ -199,6 +202,7 @@ fn filter_scope(filter: &FilterExpr) -> FilterScope {
FilterScope::Mixed
}
}
FilterExpr::Not(expression) => filter_scope(expression),
}
}
@@ -214,7 +218,7 @@ fn to_dnf(filter: &FilterExpr) -> Result<Vec<Vec<&FilterPredicate>>> {
FilterExpr::Predicate(predicate) => Ok(vec![vec![predicate]]),
FilterExpr::Or(expressions) => {
let mut clauses = Vec::new();
for expression in expressions {
for expression in expressions.iter() {
clauses.extend(to_dnf(expression)?);
if clauses.len() > MAX_DNF_CLAUSES {
return Err(eyre!(
@@ -226,7 +230,7 @@ fn to_dnf(filter: &FilterExpr) -> Result<Vec<Vec<&FilterPredicate>>> {
}
FilterExpr::And(expressions) => {
let mut clauses = vec![Vec::new()];
for expression in expressions {
for expression in expressions.iter() {
let right = to_dnf(expression)?;
if clauses.len().saturating_mul(right.len()) > MAX_DNF_CLAUSES {
return Err(eyre!(
@@ -246,6 +250,9 @@ fn to_dnf(filter: &FilterExpr) -> Result<Vec<Vec<&FilterPredicate>>> {
}
Ok(clauses)
}
FilterExpr::Not(_) => {
Err(eyre!("search filter contains an unnormalized negation"))
}
}
}
@@ -270,6 +277,10 @@ fn filter_complexity(filter: &FilterExpr) -> (usize, usize) {
},
)
}
FilterExpr::Not(expression) => {
let (nodes, depth) = filter_complexity(expression);
(nodes + 1, depth + 1)
}
}
}
@@ -437,6 +448,16 @@ mod tests {
);
}
#[test]
fn serializes_legacy_unary_not_filters() {
assert_eq!(
serialize(
r#"NOT"project_id"="8xOSkvVU" AND NOT"project_id"="DRol93FL""#
),
"project_id:!=`8xOSkvVU` && project_id:!=`DRol93FL`"
);
}
#[test]
fn cartesian_version_filter_uses_one_join() {
let filter = serialize(
+11 -6
View File
@@ -1,26 +1,31 @@
use smallvec::SmallVec;
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum FilterExpr {
And(Vec<Self>),
Or(Vec<Self>),
And(Box<SmallVec<[Self; 4]>>),
Or(Box<SmallVec<[Self; 4]>>),
Not(Box<Self>),
Predicate(FilterPredicate),
}
impl FilterExpr {
pub fn and(expressions: impl IntoIterator<Item = Self>) -> Option<Self> {
let mut expressions = expressions.into_iter().collect::<Vec<_>>();
let mut expressions =
expressions.into_iter().collect::<SmallVec<[_; 4]>>();
match expressions.len() {
0 => None,
1 => expressions.pop(),
_ => Some(Self::And(expressions)),
_ => Some(Self::And(Box::new(expressions))),
}
}
pub fn or(expressions: impl IntoIterator<Item = Self>) -> Option<Self> {
let mut expressions = expressions.into_iter().collect::<Vec<_>>();
let mut expressions =
expressions.into_iter().collect::<SmallVec<[_; 4]>>();
match expressions.len() {
0 => None,
1 => expressions.pop(),
_ => Some(Self::Or(expressions)),
_ => Some(Self::Or(Box::new(expressions))),
}
}
}
+103 -21
View File
@@ -1,5 +1,7 @@
use std::collections::{BTreeMap, BTreeSet};
use smallvec::SmallVec;
use super::{
FilterComparison, FilterCondition, FilterExpr, FilterField, FilterLiteral,
FilterPredicate,
@@ -7,12 +9,70 @@ use super::{
pub fn normalize(expression: FilterExpr) -> FilterExpr {
match expression {
FilterExpr::And(expressions) => normalize_and(expressions),
FilterExpr::Or(expressions) => normalize_or(expressions),
FilterExpr::And(expressions) => normalize_and(*expressions),
FilterExpr::Or(expressions) => normalize_or(*expressions),
FilterExpr::Not(expression) => normalize_not(*expression),
FilterExpr::Predicate(predicate) => normalize_predicate(predicate),
}
}
fn normalize_not(expression: FilterExpr) -> FilterExpr {
match expression {
FilterExpr::And(expressions) => normalize_or(
(*expressions)
.into_iter()
.map(|expression| FilterExpr::Not(Box::new(expression)))
.collect(),
),
FilterExpr::Or(expressions) => normalize_and(
(*expressions)
.into_iter()
.map(|expression| FilterExpr::Not(Box::new(expression)))
.collect(),
),
FilterExpr::Not(expression) => normalize(*expression),
FilterExpr::Predicate(mut predicate) => {
predicate.condition = match predicate.condition {
FilterCondition::Compare { comparison, value } => {
FilterCondition::Compare {
comparison: match comparison {
FilterComparison::Equal => {
FilterComparison::NotEqual
}
FilterComparison::NotEqual => {
FilterComparison::Equal
}
FilterComparison::GreaterThan => {
FilterComparison::LessThanOrEqual
}
FilterComparison::GreaterThanOrEqual => {
FilterComparison::LessThan
}
FilterComparison::LessThan => {
FilterComparison::GreaterThanOrEqual
}
FilterComparison::LessThanOrEqual => {
FilterComparison::GreaterThan
}
},
value,
}
}
FilterCondition::In { values, negated } => {
FilterCondition::In {
values,
negated: !negated,
}
}
FilterCondition::Exists { negated } => {
FilterCondition::Exists { negated: !negated }
}
};
normalize_predicate(predicate)
}
}
}
fn normalize_predicate(predicate: FilterPredicate) -> FilterExpr {
if predicate.field.as_str() == "minecraft_java_server.ping.data"
&& let FilterCondition::Exists { negated } = predicate.condition
@@ -29,30 +89,34 @@ fn normalize_predicate(predicate: FilterPredicate) -> FilterExpr {
FilterExpr::Predicate(predicate)
}
fn normalize_and(expressions: Vec<FilterExpr>) -> FilterExpr {
let mut normalized = expressions
.into_iter()
.map(normalize)
.flat_map(|expression| match expression {
FilterExpr::And(children) => children,
expression => vec![expression],
})
.collect::<Vec<_>>();
fn normalize_and(expressions: SmallVec<[FilterExpr; 4]>) -> FilterExpr {
let mut normalized = expressions.into_iter().map(normalize).fold(
SmallVec::<[_; 4]>::new(),
|mut flattened, expression| {
match expression {
FilterExpr::And(children) => flattened.extend(*children),
expression => flattened.push(expression),
}
flattened
},
);
normalized.sort();
normalized.dedup();
FilterExpr::and(normalized).expect("an AND expression is non-empty")
}
fn normalize_or(expressions: Vec<FilterExpr>) -> FilterExpr {
let mut normalized = expressions
.into_iter()
.map(normalize)
.flat_map(|expression| match expression {
FilterExpr::Or(children) => children,
expression => vec![expression],
})
.collect::<Vec<_>>();
fn normalize_or(expressions: SmallVec<[FilterExpr; 4]>) -> FilterExpr {
let mut normalized = expressions.into_iter().map(normalize).fold(
SmallVec::<[_; 4]>::new(),
|mut flattened, expression| {
match expression {
FilterExpr::Or(children) => flattened.extend(*children),
expression => flattened.push(expression),
}
flattened
},
);
normalized.sort();
normalized.dedup();
@@ -210,7 +274,7 @@ fn predicate_clause(
_ => None,
})
.collect(),
FilterExpr::Or(_) => None,
FilterExpr::Or(_) | FilterExpr::Not(_) => None,
}
}
@@ -323,4 +387,22 @@ mod tests {
assert_eq!(normalized, expected);
}
#[test]
fn normalizes_unary_not() {
let normalized = normalize(
parse_expression(
r#"NOT"project_id"="8xOSkvVU" AND NOT (downloads > 100 OR open_source = true)"#,
)
.unwrap(),
);
let expected = normalize(
parse_expression(
r#"project_id != "8xOSkvVU" AND downloads <= 100 AND open_source != true"#,
)
.unwrap(),
);
assert_eq!(normalized, expected);
}
}
+77 -18
View File
@@ -18,16 +18,25 @@ pub struct FilterParseError {
fn keyword(
keyword: &'static str,
) -> BoxedParser<'static, char, (), Simple<char>> {
keyword
.chars()
.fold(empty().ignored().boxed(), |parser, character| {
parser
.then_ignore(one_of([
character.to_ascii_lowercase(),
character.to_ascii_uppercase(),
]))
.boxed()
})
let keyword =
keyword
.chars()
.fold(empty().ignored().boxed(), |parser, character| {
parser
.then_ignore(one_of([
character.to_ascii_lowercase(),
character.to_ascii_uppercase(),
]))
.boxed()
});
let boundary = filter(|character: &char| {
!character.is_ascii_alphanumeric() && !"_.".contains(*character)
})
.rewind()
.ignored()
.or(end());
keyword.then_ignore(boundary).boxed()
}
fn quoted_literal(
@@ -63,8 +72,8 @@ fn literal_parser() -> BoxedParser<'static, char, FilterLiteral, Simple<char>> {
quoted.or(bare).padded().boxed()
}
fn parser() -> impl Parser<char, FilterExpr, Error = Simple<char>> {
let field = filter(|character: &char| {
fn field_name_parser() -> BoxedParser<'static, char, String, Simple<char>> {
filter(|character: &char| {
character.is_ascii_alphabetic() || "_.".contains(*character)
})
.then(
@@ -73,9 +82,19 @@ fn parser() -> impl Parser<char, FilterExpr, Error = Simple<char>> {
})
.repeated(),
)
.map(|(first, rest)| {
FilterField::new(std::iter::once(first).chain(rest).collect::<String>())
})
.map(|(first, rest)| std::iter::once(first).chain(rest).collect::<String>())
.boxed()
}
fn parser() -> impl Parser<char, FilterExpr, Error = Simple<char>> {
let field_name = field_name_parser();
let field = choice((
field_name.clone(),
field_name.clone().delimited_by(just('"'), just('"')),
field_name.clone().delimited_by(just('\''), just('\'')),
field_name.delimited_by(just('`'), just('`')),
))
.map(FilterField::new)
.padded();
let literal = literal_parser();
@@ -131,9 +150,16 @@ fn parser() -> impl Parser<char, FilterExpr, Error = Simple<char>> {
.clone()
.or(expression.delimited_by(just('(').padded(), just(')').padded()))
.padded();
let and = atom
let unary = keyword("NOT").padded().repeated().then(atom).map(
|(operators, expression)| {
operators.into_iter().fold(expression, |expression, ()| {
FilterExpr::Not(Box::new(expression))
})
},
);
let and = unary
.clone()
.then(keyword("AND").padded().ignore_then(atom).repeated())
.then(keyword("AND").padded().ignore_then(unary).repeated())
.map(|(first, rest)| {
FilterExpr::and(std::iter::once(first).chain(rest))
.expect("an expression always contains one operand")
@@ -167,7 +193,9 @@ pub fn parse_expression(input: &str) -> Result<FilterExpr, FilterParseError> {
#[cfg(test)]
mod tests {
use super::parse_expression;
use crate::search::filter::{FilterCondition, FilterExpr, FilterLiteral};
use crate::search::filter::{
FilterComparison, FilterCondition, FilterExpr, FilterLiteral,
};
#[test]
fn parses_boolean_precedence() {
@@ -204,4 +232,35 @@ mod tests {
} if value == "value with spaces"
));
}
#[test]
fn parses_unary_not_with_quoted_field() {
let expression =
parse_expression(r#"NOT"project_id"="8xOSkvVU""#).unwrap();
let FilterExpr::Not(expression) = expression else {
panic!("expected a NOT expression");
};
let FilterExpr::Predicate(predicate) = *expression else {
panic!("expected a predicate");
};
assert_eq!(predicate.field.as_str(), "project_id");
assert!(matches!(
predicate.condition,
FilterCondition::Compare {
comparison: FilterComparison::Equal,
value: FilterLiteral::String(value),
} if value == "8xOSkvVU"
));
}
#[test]
fn does_not_parse_not_prefix_in_field_name_as_operator() {
let expression = parse_expression("notification = true").unwrap();
let FilterExpr::Predicate(predicate) = expression else {
panic!("expected a predicate");
};
assert_eq!(predicate.field.as_str(), "notification");
}
}