Skip to content

Commit 09d5362

Browse files
authored
Support MATCH AGAINST (apache#708)
Support added for both MySQL and Generic dialects.
1 parent 886875f commit 09d5362

File tree

4 files changed

+155
-0
lines changed

4 files changed

+155
-0
lines changed

src/ast/mod.rs

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,26 @@ pub enum Expr {
449449
/// or as `__ TO SECOND(x)`.
450450
fractional_seconds_precision: Option<u64>,
451451
},
452+
/// `MySQL` specific text search function [(1)].
453+
///
454+
/// Syntax:
455+
/// ```text
456+
/// MARCH (<col>, <col>, ...) AGAINST (<expr> [<search modifier>])
457+
///
458+
/// <col> = CompoundIdentifier
459+
/// <expr> = String literal
460+
/// ```
461+
///
462+
///
463+
/// [(1)]: https://dev.mysql.com/doc/refman/8.0/en/fulltext-search.html#function_match
464+
MatchAgainst {
465+
/// `(<col>, <col>, ...)`.
466+
columns: Vec<Ident>,
467+
/// `<expr>`.
468+
match_value: Value,
469+
/// `<search modifier>`
470+
opt_search_modifier: Option<SearchModifier>,
471+
},
452472
}
453473

454474
impl fmt::Display for Expr {
@@ -818,6 +838,21 @@ impl fmt::Display for Expr {
818838
}
819839
Ok(())
820840
}
841+
Expr::MatchAgainst {
842+
columns,
843+
match_value: match_expr,
844+
opt_search_modifier,
845+
} => {
846+
write!(f, "MATCH ({}) AGAINST ", display_comma_separated(columns),)?;
847+
848+
if let Some(search_modifier) = opt_search_modifier {
849+
write!(f, "({match_expr} {search_modifier})")?;
850+
} else {
851+
write!(f, "({match_expr})")?;
852+
}
853+
854+
Ok(())
855+
}
821856
}
822857
}
823858
}
@@ -3659,6 +3694,43 @@ impl fmt::Display for SchemaName {
36593694
}
36603695
}
36613696

3697+
/// Fulltext search modifiers ([1]).
3698+
///
3699+
/// [1]: https://dev.mysql.com/doc/refman/8.0/en/fulltext-search.html#function_match
3700+
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
3701+
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
3702+
pub enum SearchModifier {
3703+
/// `IN NATURAL LANGUAGE MODE`.
3704+
InNaturalLanguageMode,
3705+
/// `IN NATURAL LANGUAGE MODE WITH QUERY EXPANSION`.
3706+
InNaturalLanguageModeWithQueryExpansion,
3707+
///`IN BOOLEAN MODE`.
3708+
InBooleanMode,
3709+
///`WITH QUERY EXPANSION`.
3710+
WithQueryExpansion,
3711+
}
3712+
3713+
impl fmt::Display for SearchModifier {
3714+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3715+
match self {
3716+
Self::InNaturalLanguageMode => {
3717+
write!(f, "IN NATURAL LANGUAGE MODE")?;
3718+
}
3719+
Self::InNaturalLanguageModeWithQueryExpansion => {
3720+
write!(f, "IN NATURAL LANGUAGE MODE WITH QUERY EXPANSION")?;
3721+
}
3722+
Self::InBooleanMode => {
3723+
write!(f, "IN BOOLEAN MODE")?;
3724+
}
3725+
Self::WithQueryExpansion => {
3726+
write!(f, "WITH QUERY EXPANSION")?;
3727+
}
3728+
}
3729+
3730+
Ok(())
3731+
}
3732+
}
3733+
36623734
#[cfg(test)]
36633735
mod tests {
36643736
use super::*;

src/keywords.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ define_keywords!(
7171
ACTION,
7272
ADD,
7373
ADMIN,
74+
AGAINST,
7475
ALL,
7576
ALLOCATE,
7677
ALTER,
@@ -229,6 +230,7 @@ define_keywords!(
229230
EXECUTE,
230231
EXISTS,
231232
EXP,
233+
EXPANSION,
232234
EXPLAIN,
233235
EXTENDED,
234236
EXTERNAL,
@@ -348,6 +350,7 @@ define_keywords!(
348350
MINUTE,
349351
MINVALUE,
350352
MOD,
353+
MODE,
351354
MODIFIES,
352355
MODULE,
353356
MONTH,

src/parser.rs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -505,6 +505,9 @@ impl<'a> Parser<'a> {
505505
}
506506
Keyword::ARRAY_AGG => self.parse_array_agg_expr(),
507507
Keyword::NOT => self.parse_not(),
508+
Keyword::MATCH if dialect_of!(self is MySqlDialect | GenericDialect) => {
509+
self.parse_match_against()
510+
}
508511
// Here `w` is a word, check if it's a part of a multi-part
509512
// identifier, a function call, or a simple identifier:
510513
_ => match self.peek_token() {
@@ -1209,6 +1212,57 @@ impl<'a> Parser<'a> {
12091212
}
12101213
}
12111214

1215+
/// Parses fulltext expressions [(1)]
1216+
///
1217+
/// # Errors
1218+
/// This method will raise an error if the column list is empty or with invalid identifiers,
1219+
/// the match expression is not a literal string, or if the search modifier is not valid.
1220+
///
1221+
/// [(1)]: Expr::MatchAgainst
1222+
pub fn parse_match_against(&mut self) -> Result<Expr, ParserError> {
1223+
let columns = self.parse_parenthesized_column_list(Mandatory)?;
1224+
1225+
self.expect_keyword(Keyword::AGAINST)?;
1226+
1227+
self.expect_token(&Token::LParen)?;
1228+
1229+
// MySQL is too permissive about the value, IMO we can't validate it perfectly on syntax level.
1230+
let match_value = self.parse_value()?;
1231+
1232+
let in_natural_language_mode_keywords = &[
1233+
Keyword::IN,
1234+
Keyword::NATURAL,
1235+
Keyword::LANGUAGE,
1236+
Keyword::MODE,
1237+
];
1238+
1239+
let with_query_expansion_keywords = &[Keyword::WITH, Keyword::QUERY, Keyword::EXPANSION];
1240+
1241+
let in_boolean_mode_keywords = &[Keyword::IN, Keyword::BOOLEAN, Keyword::MODE];
1242+
1243+
let opt_search_modifier = if self.parse_keywords(in_natural_language_mode_keywords) {
1244+
if self.parse_keywords(with_query_expansion_keywords) {
1245+
Some(SearchModifier::InNaturalLanguageModeWithQueryExpansion)
1246+
} else {
1247+
Some(SearchModifier::InNaturalLanguageMode)
1248+
}
1249+
} else if self.parse_keywords(in_boolean_mode_keywords) {
1250+
Some(SearchModifier::InBooleanMode)
1251+
} else if self.parse_keywords(with_query_expansion_keywords) {
1252+
Some(SearchModifier::WithQueryExpansion)
1253+
} else {
1254+
None
1255+
};
1256+
1257+
self.expect_token(&Token::RParen)?;
1258+
1259+
Ok(Expr::MatchAgainst {
1260+
columns,
1261+
match_value,
1262+
opt_search_modifier,
1263+
})
1264+
}
1265+
12121266
/// Parse an INTERVAL expression.
12131267
///
12141268
/// Some syntactically valid intervals:

tests/sqlparser_mysql.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1166,6 +1166,32 @@ fn parse_create_table_with_spatial_definition() {
11661166
.verified_stmt("CREATE TABLE tb (c1 INT, c2 INT, SPATIAL KEY potato (c1, c2))");
11671167
}
11681168

1169+
#[test]
1170+
fn parse_fulltext_expression() {
1171+
mysql_and_generic().verified_stmt("SELECT * FROM tb WHERE MATCH (c1) AGAINST ('string')");
1172+
1173+
mysql_and_generic().verified_stmt(
1174+
"SELECT * FROM tb WHERE MATCH (c1) AGAINST ('string' IN NATURAL LANGUAGE MODE)",
1175+
);
1176+
1177+
mysql_and_generic().verified_stmt("SELECT * FROM tb WHERE MATCH (c1) AGAINST ('string' IN NATURAL LANGUAGE MODE WITH QUERY EXPANSION)");
1178+
1179+
mysql_and_generic()
1180+
.verified_stmt("SELECT * FROM tb WHERE MATCH (c1) AGAINST ('string' IN BOOLEAN MODE)");
1181+
1182+
mysql_and_generic()
1183+
.verified_stmt("SELECT * FROM tb WHERE MATCH (c1) AGAINST ('string' WITH QUERY EXPANSION)");
1184+
1185+
mysql_and_generic()
1186+
.verified_stmt("SELECT * FROM tb WHERE MATCH (c1, c2, c3) AGAINST ('string')");
1187+
1188+
mysql_and_generic().verified_stmt("SELECT * FROM tb WHERE MATCH (c1) AGAINST (123)");
1189+
1190+
mysql_and_generic().verified_stmt("SELECT * FROM tb WHERE MATCH (c1) AGAINST (NULL)");
1191+
1192+
mysql_and_generic().verified_stmt("SELECT COUNT(IF(MATCH (title, body) AGAINST ('database' IN NATURAL LANGUAGE MODE), 1, NULL)) AS count FROM articles");
1193+
}
1194+
11691195
#[test]
11701196
#[should_panic = "Expected FULLTEXT or SPATIAL option without constraint name, found: cons"]
11711197
fn parse_create_table_with_fulltext_definition_should_not_accept_constraint_name() {

0 commit comments

Comments
 (0)