-
Notifications
You must be signed in to change notification settings - Fork 13
[SQL-3301] Support Binary Comparisons in Match Language #172
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 14 commits
df66470
c158adc
43b0cb4
650e613
1f6eca8
89e8d35
26ada87
ab89af4
8f96aa6
4444c53
523953b
6038f46
393c326
0839edb
5161767
c6b2029
d79c43b
3c73cb4
0b87aca
f7df859
a11ecdb
c742a92
f33e698
f9e3ccf
9113ee2
26044cf
de9d714
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,12 +10,13 @@ | |
| /// require a bit of finesse to ensure that {$match: {$expr: false}} is not | ||
| /// treated as a COLLSCAN. | ||
| /// | ||
| /// Note that although comparison operators _can_ be rewritten to use match | ||
| /// language, this optimization does not perform such rewrites. LIKE and IS | ||
| /// ultimately translate to $regexMatch and $eq/$type in aggregation language. | ||
| /// Neither of those can utilize indexes when used in $match stages. Comparison | ||
| /// operators can utilize indexes even when they use expr language in $match. | ||
| /// Therefore, this optimization is only concerned with rewriting LIKE and IS. | ||
| /// Comparison operators (<, <=, <>, =, >, >=) are also rewritten to native | ||
| /// match language when exactly one side is a plain field reference and the | ||
| /// other side is a literal (see `rewrite_comparison`). Because MQL's native | ||
| /// comparison operators sort null/missing below every other BSON type, a | ||
| /// comparison over a nullable field is guarded with an explicit | ||
| /// `{field: {$gt: null}}` existence check to preserve SQL's three-valued | ||
| /// semantics; see `rewrite_comparison` for details. | ||
| /// | ||
| /// Also note, MatchSplitting should ensure we never have a conjunction at this | ||
| /// point, however we choose to make this optimization work independent of that | ||
|
|
@@ -61,10 +62,48 @@ struct MatchLanguageRewriterVisitor { | |
| } | ||
|
|
||
| impl MatchLanguageRewriterVisitor { | ||
| /// Returns `true` if every component of `field_path` can be addressed by | ||
| /// native match language (find syntax), and `false` otherwise. | ||
| /// | ||
| /// Native match language addresses a field by using its name — possibly | ||
| /// dotted — as a document key, e.g. `{"a.b": {"$eq": 4}}` targets the field | ||
| /// `b` nested under `a`. It has no | ||
| /// `$getField` escape hatch, so a field whose *name* literally contains a | ||
| /// `.`, starts with `$`, or is the empty string cannot be expressed: the | ||
| /// `.` is interpreted as a path separator, and a leading `$` is parsed as an | ||
| /// operator. | ||
| /// | ||
| /// For example, the delimited SQL identifier `` `$a.b` `` (a single field | ||
| /// literally named `$a.b`) would rewrite to the match stage: | ||
| /// | ||
| /// ```json | ||
| /// { "$match": { "$a.b": { "$eq": 4 } } } | ||
| /// ``` | ||
| /// | ||
| /// which MongoDB rejects at execution time: | ||
| /// | ||
| /// ```text | ||
| /// MongoServerError[BadValue]: unknown top level operator: $a.b. If you | ||
| /// have a field name that starts with a '$' symbol, consider using | ||
| /// $getField or $setField. | ||
| /// ``` | ||
| /// | ||
| /// When this returns `false`, the expression is left to be translated to | ||
| /// `$expr` language so the existing `$getField`-based translation handles | ||
| /// the field correctly. | ||
| fn is_match_addressable(field_path: &FieldPath) -> bool { | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Note: Consider moving this to the try_into for field patch to field access. Moving it down will make this cleaner and catch bugs elsewhere.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. After investigation, there wasn't an easy way to merge this into the try_into on its own because the usage of try_into doesn't perfect match the is_match_addressable usage across the codebase. I'm going to leave this here for now. |
||
| field_path | ||
| .fields | ||
| .iter() | ||
| .all(|f| !(f.contains('.') || f.starts_with('$') || f.is_empty())) | ||
| } | ||
|
|
||
| fn rewrite_is(is: IsExpr) -> Option<MatchQuery> { | ||
| match *is.expr { | ||
| Expression::FieldAccess(fa) => fa | ||
| .try_into() | ||
| .ok() | ||
| .filter(Self::is_match_addressable) | ||
| .map(|mp: FieldPath| { | ||
| if is.target_type == TypeOrMissing::Type(Type::Null) { | ||
| MatchQuery::Comparison(MatchLanguageComparison { | ||
|
|
@@ -80,15 +119,14 @@ impl MatchLanguageRewriterVisitor { | |
| cache: SchemaCache::new(), | ||
| }) | ||
| } | ||
| }) | ||
| .ok(), | ||
| }), | ||
| _ => None, | ||
| } | ||
| } | ||
|
|
||
| fn rewrite_like(like: LikeExpr) -> Option<MatchQuery> { | ||
| let match_path = match *like.expr { | ||
| Expression::FieldAccess(fa) => fa.try_into().ok(), | ||
| Expression::FieldAccess(fa) => fa.try_into().ok().filter(Self::is_match_addressable), | ||
| _ => return None, | ||
| }; | ||
|
|
||
|
|
@@ -146,7 +184,11 @@ impl MatchLanguageRewriterVisitor { | |
| // Anything more complex — a function call, a subquery, a literal — cannot be | ||
| // mapped to the FieldPath that MatchLanguageIn requires, so we bail early. | ||
| let field_path: FieldPath = match sf.args.first()? { | ||
| Expression::FieldAccess(fa) => fa.clone().try_into().ok()?, | ||
| Expression::FieldAccess(fa) => fa | ||
| .clone() | ||
| .try_into() | ||
| .ok() | ||
| .filter(Self::is_match_addressable)?, | ||
| _ => return None, | ||
| }; | ||
|
|
||
|
|
@@ -186,24 +228,141 @@ impl MatchLanguageRewriterVisitor { | |
| })) | ||
| } | ||
|
|
||
| /// Maps a SQL comparison [`ScalarFunction`] to its native match-language | ||
| /// counterpart. Returns `None` for any non-comparison function; callers | ||
| /// should only invoke this with one of the six comparison variants. | ||
| fn comparison_op(function: &ScalarFunction) -> Option<MatchLanguageComparisonOp> { | ||
| match function { | ||
| ScalarFunction::Lt => Some(MatchLanguageComparisonOp::Lt), | ||
| ScalarFunction::Lte => Some(MatchLanguageComparisonOp::Lte), | ||
| ScalarFunction::Neq => Some(MatchLanguageComparisonOp::Ne), | ||
| ScalarFunction::Eq => Some(MatchLanguageComparisonOp::Eq), | ||
| ScalarFunction::Gt => Some(MatchLanguageComparisonOp::Gt), | ||
| ScalarFunction::Gte => Some(MatchLanguageComparisonOp::Gte), | ||
| _ => None, | ||
| } | ||
| } | ||
|
|
||
| /// Commutes a [`MatchLanguageComparisonOp`] for the case where the literal | ||
| /// appeared on the left of the original SQL expression (e.g. `10 < x`). | ||
| /// Commuting swaps which side is "larger", so `Lt`/`Gt` and `Lte`/`Gte` | ||
| /// flip, while `Eq`/`Ne` are self-commutative. | ||
| fn commute(op: MatchLanguageComparisonOp) -> MatchLanguageComparisonOp { | ||
| use MatchLanguageComparisonOp::*; | ||
| match op { | ||
| Eq => Eq, | ||
| Ne => Ne, | ||
| Lt => Gt, | ||
| Lte => Gte, | ||
| Gt => Lt, | ||
| Gte => Lte, | ||
| } | ||
| } | ||
|
|
||
| /// Rewrites a binary comparison scalar function (`<`, `<=`, `<>`, `=`, `>`, | ||
| /// `>=`) to a native [`MatchQuery::Comparison`]. | ||
| /// | ||
| /// Succeeds only when the expression has the shape `<field> <op> <literal>` | ||
| /// or `<literal> <op> <field>`: exactly one side must be a plain field | ||
| /// access convertible to a [`FieldPath`], and the other a [`LiteralValue`]. | ||
| /// Any other shape (both sides literal, both sides field accesses, computed | ||
| /// expressions, subqueries) falls through to `None`, leaving the expression | ||
| /// in `$expr` (aggregation) language. When the literal appears on the left | ||
| /// (e.g. `10 < x`), the operator is commuted so the field ends up on the | ||
| /// "input" side of the comparison (`x > 10`). | ||
| /// | ||
| /// # Null / missing correctness | ||
| /// | ||
| /// MQL's native comparison operators sort `null` and missing fields below | ||
| /// every other BSON type, so e.g. `{field: {$lt: 10}}` would match documents | ||
| /// where `field` is `null` or missing — whereas SQL's three-valued logic | ||
| /// says `NULL < 10` is `UNKNOWN` and must be excluded. To preserve SQL | ||
| /// semantics, when the field is possibly null/missing | ||
| /// (`field_path.is_nullable`) we guard the comparison with an explicit | ||
| /// existence check, producing `{$and: [{field: {$gt: null}}, {field: {$op: literal}}]}`. | ||
| /// `{field: {$gt: null}}` is true iff `field` is present and not null, | ||
| /// exploiting the same BSON sort-order trick used elsewhere in this codebase | ||
| /// (see `mir::optimizer::match_null_filtering`). No optimizer downstream of | ||
| /// this rewriter can add this guard: once a `Filter`'s condition becomes a | ||
| /// `MatchQuery` there is no longer an `Expression`/`FieldAccess` tree to | ||
| /// inspect, and the translator and codegen are purely syntactic. | ||
| /// | ||
| /// # Returns | ||
| /// | ||
| /// - `Some(MatchQuery::Comparison { .. })` when the field is non-nullable. | ||
| /// - `Some(MatchQuery::Logical(And, [null_guard, comparison]))` when the | ||
| /// field is nullable. | ||
| /// - `None` when the expression is not a `<field> <op> <literal>` comparison. | ||
| fn rewrite_comparison(sf: &ScalarFunctionApplication) -> Option<MatchQuery> { | ||
| let op = Self::comparison_op(&sf.function)?; | ||
|
|
||
| let lhs = sf.args.first()?; | ||
| let rhs = sf.args.get(1)?; | ||
|
|
||
| // Exactly one side must be a field access, the other a literal. Normalize | ||
| // to (field_path, literal, needs_commute) regardless of which side the | ||
| // field appeared on. | ||
| let (field_path, literal, needs_commute): (FieldPath, LiteralValue, bool) = match (lhs, rhs) | ||
| { | ||
| (Expression::FieldAccess(fa), Expression::Literal(lit)) => { | ||
| (fa.clone().try_into().ok()?, lit.clone(), false) | ||
| } | ||
| (Expression::Literal(lit), Expression::FieldAccess(fa)) => { | ||
| (fa.clone().try_into().ok()?, lit.clone(), true) | ||
| } | ||
| _ => return None, | ||
| }; | ||
|
|
||
| // Native match language cannot address a field whose name contains a | ||
| // `.`, starts with `$`, or is empty; such fields must stay in $expr. | ||
| if !Self::is_match_addressable(&field_path) { | ||
| return None; | ||
| } | ||
|
|
||
| let op = if needs_commute { Self::commute(op) } else { op }; | ||
|
|
||
| let comparison = MatchQuery::Comparison(MatchLanguageComparison { | ||
| function: op, | ||
| input: Some(field_path.clone()), | ||
| arg: literal, | ||
| cache: SchemaCache::new(), | ||
| }); | ||
|
|
||
| if !field_path.is_nullable { | ||
| return Some(comparison); | ||
| } | ||
|
|
||
| Some(comparison) | ||
| } | ||
|
jpowell-mongo marked this conversation as resolved.
|
||
|
|
||
| // Only rewrite a condition that consists of Is, Like, or a logical operation | ||
| // that contains only other rewritable expressions. | ||
| fn rewrite_condition(condition: Expression) -> Option<MatchQuery> { | ||
| match condition { | ||
| Expression::Is(is) => Self::rewrite_is(is), | ||
| Expression::Like(like) => Self::rewrite_like(like), | ||
| Expression::FieldAccess(fa) => fa.try_into().ok().map(|fp: FieldPath| { | ||
| MatchQuery::Comparison(MatchLanguageComparison { | ||
| function: MatchLanguageComparisonOp::Eq, | ||
| input: Some(fp), | ||
| arg: LiteralValue::Boolean(true), | ||
| cache: SchemaCache::new(), | ||
| }) | ||
| }), | ||
| Expression::FieldAccess(fa) => fa | ||
| .try_into() | ||
| .ok() | ||
| .filter(Self::is_match_addressable) | ||
| .map(|fp: FieldPath| { | ||
| MatchQuery::Comparison(MatchLanguageComparison { | ||
| function: MatchLanguageComparisonOp::Eq, | ||
| input: Some(fp), | ||
| arg: LiteralValue::Boolean(true), | ||
| cache: SchemaCache::new(), | ||
| }) | ||
| }), | ||
| Expression::ScalarFunction(sf) => match sf.function { | ||
| ScalarFunction::And => Self::rewrite_logical(MatchLanguageLogicalOp::And, sf.args), | ||
| ScalarFunction::Or => Self::rewrite_logical(MatchLanguageLogicalOp::Or, sf.args), | ||
| ScalarFunction::In | ScalarFunction::NotIn => Self::rewrite_in(&sf), | ||
| ScalarFunction::Lt | ||
| | ScalarFunction::Lte | ||
| | ScalarFunction::Neq | ||
| | ScalarFunction::Eq | ||
| | ScalarFunction::Gt | ||
| | ScalarFunction::Gte => Self::rewrite_comparison(&sf), | ||
| // NOT is unary; only rewrite when it has exactly one operand, | ||
| // otherwise leave it in $expr language rather than emit a | ||
| // Logical{Not} we can't faithfully translate. | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Note:
Although this looks like net new code, this logic is a near copy of the
Stage::Filter(f) => match *f.sourcematch arm, but with small adjustments to work forMqlStage::MatchFilterinstead.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
non-blocking: ++ is there any chance we can macro this out or find a way to simplify? up to you