Support MATCH AGAINST (#708)

Support added for both MySQL and Generic dialects.
This commit is contained in:
Augusto Fotino 2022-11-30 14:16:50 -03:00 committed by GitHub
parent 886875f3bf
commit 09d53623bc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 155 additions and 0 deletions

View file

@ -449,6 +449,26 @@ pub enum Expr {
/// or as `__ TO SECOND(x)`.
fractional_seconds_precision: Option<u64>,
},
/// `MySQL` specific text search function [(1)].
///
/// Syntax:
/// ```text
/// MARCH (<col>, <col>, ...) AGAINST (<expr> [<search modifier>])
///
/// <col> = CompoundIdentifier
/// <expr> = String literal
/// ```
///
///
/// [(1)]: https://dev.mysql.com/doc/refman/8.0/en/fulltext-search.html#function_match
MatchAgainst {
/// `(<col>, <col>, ...)`.
columns: Vec<Ident>,
/// `<expr>`.
match_value: Value,
/// `<search modifier>`
opt_search_modifier: Option<SearchModifier>,
},
}
impl fmt::Display for Expr {
@ -818,6 +838,21 @@ impl fmt::Display for Expr {
}
Ok(())
}
Expr::MatchAgainst {
columns,
match_value: match_expr,
opt_search_modifier,
} => {
write!(f, "MATCH ({}) AGAINST ", display_comma_separated(columns),)?;
if let Some(search_modifier) = opt_search_modifier {
write!(f, "({match_expr} {search_modifier})")?;
} else {
write!(f, "({match_expr})")?;
}
Ok(())
}
}
}
}
@ -3659,6 +3694,43 @@ impl fmt::Display for SchemaName {
}
}
/// Fulltext search modifiers ([1]).
///
/// [1]: https://dev.mysql.com/doc/refman/8.0/en/fulltext-search.html#function_match
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub enum SearchModifier {
/// `IN NATURAL LANGUAGE MODE`.
InNaturalLanguageMode,
/// `IN NATURAL LANGUAGE MODE WITH QUERY EXPANSION`.
InNaturalLanguageModeWithQueryExpansion,
///`IN BOOLEAN MODE`.
InBooleanMode,
///`WITH QUERY EXPANSION`.
WithQueryExpansion,
}
impl fmt::Display for SearchModifier {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InNaturalLanguageMode => {
write!(f, "IN NATURAL LANGUAGE MODE")?;
}
Self::InNaturalLanguageModeWithQueryExpansion => {
write!(f, "IN NATURAL LANGUAGE MODE WITH QUERY EXPANSION")?;
}
Self::InBooleanMode => {
write!(f, "IN BOOLEAN MODE")?;
}
Self::WithQueryExpansion => {
write!(f, "WITH QUERY EXPANSION")?;
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;

View file

@ -71,6 +71,7 @@ define_keywords!(
ACTION,
ADD,
ADMIN,
AGAINST,
ALL,
ALLOCATE,
ALTER,
@ -229,6 +230,7 @@ define_keywords!(
EXECUTE,
EXISTS,
EXP,
EXPANSION,
EXPLAIN,
EXTENDED,
EXTERNAL,
@ -348,6 +350,7 @@ define_keywords!(
MINUTE,
MINVALUE,
MOD,
MODE,
MODIFIES,
MODULE,
MONTH,

View file

@ -505,6 +505,9 @@ impl<'a> Parser<'a> {
}
Keyword::ARRAY_AGG => self.parse_array_agg_expr(),
Keyword::NOT => self.parse_not(),
Keyword::MATCH if dialect_of!(self is MySqlDialect | GenericDialect) => {
self.parse_match_against()
}
// Here `w` is a word, check if it's a part of a multi-part
// identifier, a function call, or a simple identifier:
_ => match self.peek_token() {
@ -1209,6 +1212,57 @@ impl<'a> Parser<'a> {
}
}
/// Parses fulltext expressions [(1)]
///
/// # Errors
/// This method will raise an error if the column list is empty or with invalid identifiers,
/// the match expression is not a literal string, or if the search modifier is not valid.
///
/// [(1)]: Expr::MatchAgainst
pub fn parse_match_against(&mut self) -> Result<Expr, ParserError> {
let columns = self.parse_parenthesized_column_list(Mandatory)?;
self.expect_keyword(Keyword::AGAINST)?;
self.expect_token(&Token::LParen)?;
// MySQL is too permissive about the value, IMO we can't validate it perfectly on syntax level.
let match_value = self.parse_value()?;
let in_natural_language_mode_keywords = &[
Keyword::IN,
Keyword::NATURAL,
Keyword::LANGUAGE,
Keyword::MODE,
];
let with_query_expansion_keywords = &[Keyword::WITH, Keyword::QUERY, Keyword::EXPANSION];
let in_boolean_mode_keywords = &[Keyword::IN, Keyword::BOOLEAN, Keyword::MODE];
let opt_search_modifier = if self.parse_keywords(in_natural_language_mode_keywords) {
if self.parse_keywords(with_query_expansion_keywords) {
Some(SearchModifier::InNaturalLanguageModeWithQueryExpansion)
} else {
Some(SearchModifier::InNaturalLanguageMode)
}
} else if self.parse_keywords(in_boolean_mode_keywords) {
Some(SearchModifier::InBooleanMode)
} else if self.parse_keywords(with_query_expansion_keywords) {
Some(SearchModifier::WithQueryExpansion)
} else {
None
};
self.expect_token(&Token::RParen)?;
Ok(Expr::MatchAgainst {
columns,
match_value,
opt_search_modifier,
})
}
/// Parse an INTERVAL expression.
///
/// Some syntactically valid intervals:

View file

@ -1166,6 +1166,32 @@ fn parse_create_table_with_spatial_definition() {
.verified_stmt("CREATE TABLE tb (c1 INT, c2 INT, SPATIAL KEY potato (c1, c2))");
}
#[test]
fn parse_fulltext_expression() {
mysql_and_generic().verified_stmt("SELECT * FROM tb WHERE MATCH (c1) AGAINST ('string')");
mysql_and_generic().verified_stmt(
"SELECT * FROM tb WHERE MATCH (c1) AGAINST ('string' IN NATURAL LANGUAGE MODE)",
);
mysql_and_generic().verified_stmt("SELECT * FROM tb WHERE MATCH (c1) AGAINST ('string' IN NATURAL LANGUAGE MODE WITH QUERY EXPANSION)");
mysql_and_generic()
.verified_stmt("SELECT * FROM tb WHERE MATCH (c1) AGAINST ('string' IN BOOLEAN MODE)");
mysql_and_generic()
.verified_stmt("SELECT * FROM tb WHERE MATCH (c1) AGAINST ('string' WITH QUERY EXPANSION)");
mysql_and_generic()
.verified_stmt("SELECT * FROM tb WHERE MATCH (c1, c2, c3) AGAINST ('string')");
mysql_and_generic().verified_stmt("SELECT * FROM tb WHERE MATCH (c1) AGAINST (123)");
mysql_and_generic().verified_stmt("SELECT * FROM tb WHERE MATCH (c1) AGAINST (NULL)");
mysql_and_generic().verified_stmt("SELECT COUNT(IF(MATCH (title, body) AGAINST ('database' IN NATURAL LANGUAGE MODE), 1, NULL)) AS count FROM articles");
}
#[test]
#[should_panic = "Expected FULLTEXT or SPATIAL option without constraint name, found: cons"]
fn parse_create_table_with_fulltext_definition_should_not_accept_constraint_name() {