@@ -781,20 +781,28 @@ impl ScalarQueryParser for LabelListQueryParser {
781781 }
782782}
783783
784- /// A parser for indices that handle string contains queries
784+ /// A parser for indices that handle string `contains` queries, and -- when
785+ /// `supports_regex` is set -- `regexp_like` / `regexp_match` queries.
785786#[ derive( Debug , Clone ) ]
786787pub struct TextQueryParser {
787788 index_name : String ,
788789 index_type : String ,
789790 needs_recheck : bool ,
791+ supports_regex : bool ,
790792}
791793
792794impl TextQueryParser {
793- pub fn new ( index_name : String , index_type : String , needs_recheck : bool ) -> Self {
795+ pub fn new (
796+ index_name : String ,
797+ index_type : String ,
798+ needs_recheck : bool ,
799+ supports_regex : bool ,
800+ ) -> Self {
794801 Self {
795802 index_name,
796803 index_type,
797804 needs_recheck,
805+ supports_regex,
798806 }
799807 }
800808}
@@ -837,31 +845,156 @@ impl ScalarQueryParser for TextQueryParser {
837845 func : & ScalarUDF ,
838846 args : & [ Expr ] ,
839847 ) -> Option < IndexedExpression > {
840- if args. len ( ) != 2 {
848+ // The first argument is the indexed column; the second is the substring
849+ // / pattern. `contains` takes exactly two arguments; the regex functions
850+ // optionally take a third flags argument.
851+ if args. len ( ) < 2 {
841852 return None ;
842853 }
843- let scalar = maybe_scalar ( & args[ 1 ] , data_type) ?;
844- match scalar {
845- ScalarValue :: Utf8 ( Some ( scalar_str) ) | ScalarValue :: LargeUtf8 ( Some ( scalar_str) ) => {
846- if func. name ( ) == "contains" {
847- let query = TextQuery :: StringContains ( scalar_str) ;
848- Some ( IndexedExpression :: index_query_with_recheck (
849- column. to_string ( ) ,
850- self . index_name . clone ( ) ,
851- self . index_type . clone ( ) ,
852- Arc :: new ( query) ,
853- self . needs_recheck ,
854- ) )
855- } else {
854+ // A non-string pattern cannot be handled.
855+ let ( ScalarValue :: Utf8 ( Some ( pattern) ) | ScalarValue :: LargeUtf8 ( Some ( pattern) ) ) =
856+ maybe_scalar ( & args[ 1 ] , data_type) ?
857+ else {
858+ return None ;
859+ } ;
860+
861+ let query = match func. name ( ) {
862+ "contains" if args. len ( ) == 2 => TextQuery :: StringContains ( pattern) ,
863+ "regexp_like" | "regexp_match" if self . supports_regex => {
864+ let pattern = match args. get ( 2 ) {
865+ Some ( flags_expr) => apply_regex_flags ( & pattern, flags_expr) ?,
866+ None => pattern,
867+ } ;
868+ // If the pattern yields no usable trigram (e.g. `a.b`), leave it
869+ // to a full scan instead of routing it to the index, which could
870+ // only answer with an unsupported "recheck everything" result.
871+ if !crate :: scalar:: ngram:: regex_can_use_index ( & pattern) {
872+ return None ;
873+ }
874+ TextQuery :: Regex ( pattern)
875+ }
876+ _ => return None ,
877+ } ;
878+
879+ Some ( IndexedExpression :: index_query_with_recheck (
880+ column. to_string ( ) ,
881+ self . index_name . clone ( ) ,
882+ self . index_type . clone ( ) ,
883+ Arc :: new ( query) ,
884+ self . needs_recheck ,
885+ ) )
886+ }
887+
888+ fn visit_like (
889+ & self ,
890+ column : & str ,
891+ like : & Like ,
892+ pattern : & ScalarValue ,
893+ ) -> Option < IndexedExpression > {
894+ // Infix LIKE is accelerated only by the ngram index (via its regex
895+ // machinery). A plain-literal `regexp_like(col, 'foo')` is rewritten to
896+ // `col LIKE '%foo%'` before it reaches the index, so this is the path
897+ // that accelerates those. ILIKE is skipped because its case folding does
898+ // not match the index's normalization.
899+ if !self . supports_regex || like. case_insensitive {
900+ return None ;
901+ }
902+ let pattern_str = match pattern {
903+ ScalarValue :: Utf8 ( Some ( s) ) | ScalarValue :: LargeUtf8 ( Some ( s) ) => s. as_str ( ) ,
904+ _ => return None ,
905+ } ;
906+ // Translate the LIKE pattern into a loose regex used only for candidate
907+ // generation; the original LIKE stays as the recheck filter, so the
908+ // regex only needs to be a sound superset.
909+ let regex = like_to_regex ( pattern_str, like. escape_char ) ?;
910+ if !crate :: scalar:: ngram:: regex_can_use_index ( & regex) {
911+ return None ;
912+ }
913+ Some ( IndexedExpression {
914+ scalar_query : Some ( ScalarIndexExpr :: Query ( ScalarIndexSearch {
915+ column : column. to_string ( ) ,
916+ index_name : self . index_name . clone ( ) ,
917+ index_type : self . index_type . clone ( ) ,
918+ query : Arc :: new ( TextQuery :: Regex ( regex) ) ,
919+ needs_recheck : self . needs_recheck ,
920+ fragment_bitmap : None ,
921+ } ) ) ,
922+ refine_expr : Some ( Expr :: Like ( like. clone ( ) ) ) ,
923+ } )
924+ }
925+ }
926+
927+ /// Translate a LIKE pattern into a regular expression used purely for ngram
928+ /// candidate generation: `%` becomes `.*`, `_` becomes `.`, and literal
929+ /// characters are regex-escaped. Returns `None` when no literal run is long
930+ /// enough to yield a trigram (the index could not help, so a full scan is left
931+ /// to handle it).
932+ fn like_to_regex ( pattern : & str , escape : Option < char > ) -> Option < String > {
933+ let mut regex = String :: new ( ) ;
934+ let mut run = 0usize ;
935+ let mut longest_run = 0usize ;
936+ let mut chars = pattern. chars ( ) ;
937+ while let Some ( c) = chars. next ( ) {
938+ let literal = if Some ( c) == escape {
939+ // The next character is escaped, i.e. a literal.
940+ chars. next ( )
941+ } else {
942+ match c {
943+ '%' => {
944+ regex. push_str ( ".*" ) ;
945+ run = 0 ;
856946 None
857947 }
948+ '_' => {
949+ regex. push ( '.' ) ;
950+ run = 0 ;
951+ None
952+ }
953+ other => Some ( other) ,
858954 }
859- _ => {
860- // If the scalar is not a string, we cannot handle it
861- None
955+ } ;
956+ if let Some ( lit) = literal {
957+ if regex_syntax:: is_meta_character ( lit) {
958+ regex. push ( '\\' ) ;
959+ }
960+ regex. push ( lit) ;
961+ // Only runs of alphanumeric characters can produce a trigram.
962+ if lit. is_alphanumeric ( ) {
963+ run += 1 ;
964+ longest_run = longest_run. max ( run) ;
965+ } else {
966+ run = 0 ;
862967 }
863968 }
864969 }
970+ ( longest_run >= 3 ) . then_some ( regex)
971+ }
972+
973+ /// Fold the supported `regexp_like` / `regexp_match` flags into an inline prefix
974+ /// on the pattern (e.g. flags `"i"` -> `"(?i)pattern"`). Returns `None` for a
975+ /// non-literal flags argument or an unrecognized flag, so the caller leaves the
976+ /// predicate to a full recheck rather than risk changing its semantics.
977+ fn apply_regex_flags ( pattern : & str , flags_expr : & Expr ) -> Option < String > {
978+ let ( Expr :: Literal ( ScalarValue :: Utf8 ( Some ( flags) ) , _)
979+ | Expr :: Literal ( ScalarValue :: LargeUtf8 ( Some ( flags) ) , _) ) = flags_expr
980+ else {
981+ return None ;
982+ } ;
983+ let mut inline = String :: new ( ) ;
984+ for flag in flags. chars ( ) {
985+ // Only flags expressible as an inline `(?...)` group in the regex crate
986+ // (which the recheck uses) are safe to fold.
987+ if [ 'i' , 's' , 'm' , 'x' ] . contains ( & flag) {
988+ inline. push ( flag) ;
989+ } else {
990+ return None ;
991+ }
992+ }
993+ if inline. is_empty ( ) {
994+ Some ( pattern. to_string ( ) )
995+ } else {
996+ Some ( format ! ( "(?{inline}){pattern}" ) )
997+ }
865998}
866999
8671000/// A parser for indices that handle queries with the contains_tokens function
@@ -1813,7 +1946,18 @@ fn visit_node(
18131946 Expr :: IsFalse ( expr) => Ok ( visit_is_bool ( expr. as_ref ( ) , index_info, false ) ) ,
18141947 Expr :: IsTrue ( expr) => Ok ( visit_is_bool ( expr. as_ref ( ) , index_info, true ) ) ,
18151948 Expr :: IsNull ( expr) => Ok ( visit_is_null ( expr. as_ref ( ) , index_info, false ) ) ,
1816- Expr :: IsNotNull ( expr) => Ok ( visit_is_null ( expr. as_ref ( ) , index_info, true ) ) ,
1949+ Expr :: IsNotNull ( expr) => {
1950+ // `regexp_match(col, pat)` returns a list and is coerced to
1951+ // `IsNotNull(regexp_match(...))` before it reaches here. Unwrap that
1952+ // so the regex acceleration applies; everything else is a genuine
1953+ // IS NOT NULL check.
1954+ if let Expr :: ScalarFunction ( scalar_fn) = expr. as_ref ( )
1955+ && scalar_fn. func . name ( ) == "regexp_match"
1956+ {
1957+ return Ok ( visit_scalar_fn ( scalar_fn, index_info) ) ;
1958+ }
1959+ Ok ( visit_is_null ( expr. as_ref ( ) , index_info, true ) )
1960+ }
18171961 Expr :: Not ( expr) => visit_not ( expr. as_ref ( ) , index_info, depth) ,
18181962 Expr :: BinaryExpr ( binary_expr) => visit_binary_expr ( binary_expr, index_info, depth) ,
18191963 Expr :: ScalarFunction ( scalar_fn) => Ok ( visit_scalar_fn ( scalar_fn, index_info) ) ,
@@ -2690,6 +2834,59 @@ mod tests {
26902834 assert ! ( matches!( negated. upper, NullableRowAddrMask :: BlockList ( _) ) ) ;
26912835 }
26922836
2837+ #[ test]
2838+ fn test_like_to_regex ( ) {
2839+ // `%` -> `.*`, `_` -> `.`, with a literal run of at least three chars.
2840+ assert_eq ! ( like_to_regex( "%foo%" , None ) . as_deref( ) , Some ( ".*foo.*" ) ) ;
2841+ assert_eq ! ( like_to_regex( "foo%bar" , None ) . as_deref( ) , Some ( "foo.*bar" ) ) ;
2842+ assert_eq ! ( like_to_regex( "foo_bar" , None ) . as_deref( ) , Some ( "foo.bar" ) ) ;
2843+ assert_eq ! ( like_to_regex( "foobar" , None ) . as_deref( ) , Some ( "foobar" ) ) ;
2844+
2845+ // Regex metacharacters in the literal portion are escaped.
2846+ assert_eq ! (
2847+ like_to_regex( "%a.bcd%" , None ) . as_deref( ) ,
2848+ Some ( ".*a\\ .bcd.*" )
2849+ ) ;
2850+
2851+ // No literal run of three alphanumeric characters -> no index help.
2852+ assert_eq ! ( like_to_regex( "%ab%" , None ) , None ) ;
2853+ assert_eq ! ( like_to_regex( "%a%b%c%" , None ) , None ) ;
2854+ assert_eq ! ( like_to_regex( "%" , None ) , None ) ;
2855+
2856+ // The escape character makes the following character a literal.
2857+ assert_eq ! (
2858+ like_to_regex( r"%foo\%bar%" , Some ( '\\' ) ) . as_deref( ) ,
2859+ Some ( ".*foo%bar.*" )
2860+ ) ;
2861+ }
2862+
2863+ #[ test]
2864+ fn test_apply_regex_flags ( ) {
2865+ fn flags ( s : & str ) -> Expr {
2866+ Expr :: Literal ( ScalarValue :: Utf8 ( Some ( s. to_string ( ) ) ) , None )
2867+ }
2868+
2869+ // Empty flags leave the pattern untouched (no inline group emitted).
2870+ assert_eq ! ( apply_regex_flags( "foo" , & flags( "" ) ) . as_deref( ) , Some ( "foo" ) ) ;
2871+ // Supported flags are folded into an inline `(?...)` prefix.
2872+ assert_eq ! (
2873+ apply_regex_flags( "foo" , & flags( "i" ) ) . as_deref( ) ,
2874+ Some ( "(?i)foo" )
2875+ ) ;
2876+ assert_eq ! (
2877+ apply_regex_flags( "foo" , & flags( "is" ) ) . as_deref( ) ,
2878+ Some ( "(?is)foo" )
2879+ ) ;
2880+ // An unrecognized flag bails out so the caller leaves the predicate to a
2881+ // full recheck rather than risk changing its semantics.
2882+ assert_eq ! ( apply_regex_flags( "foo" , & flags( "g" ) ) , None ) ;
2883+ // A non-string (hence non-literal-flags) argument cannot be folded.
2884+ assert_eq ! (
2885+ apply_regex_flags( "foo" , & Expr :: Literal ( ScalarValue :: Int32 ( Some ( 1 ) ) , None ) ) ,
2886+ None
2887+ ) ;
2888+ }
2889+
26932890 #[ test]
26942891 fn test_extract_like_leading_prefix ( ) {
26952892 // Simple prefix patterns (no recheck needed)
0 commit comments