Skip to content

Commit 8b62b76

Browse files
authored
planner: recheck LIKE/ILIKE with a non-default ESCAPE on memtable scans (#69670)
ref #69653
1 parent bd9159e commit 8b62b76

7 files changed

Lines changed: 159 additions & 21 deletions

File tree

pkg/executor/memtable_reader_test.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -895,6 +895,30 @@ func TestTiDBClusterLog(t *testing.T) {
895895
},
896896
expected: [][]string{},
897897
},
898+
{
899+
// ILIKE is pushed down as a case-insensitive prefilter, so an uppercase
900+
// pattern still matches a lowercase message through the remote search.
901+
// This also covers the retriever guard: a query whose only message
902+
// filter is ILIKE must still produce a pattern, otherwise the scan is
903+
// rejected with "denied to scan full logs".
904+
conditions: []string{
905+
"time>='2019/08/26 06:22:17.011'",
906+
"time<='2019/08/26 06:22:17.011'",
907+
"message ilike '%FOO%'",
908+
},
909+
expected: [][]string{
910+
{"2019/08/26 06:22:17.011", "pd", "CRITICAL", "[test log message pd 5, foo]"},
911+
},
912+
},
913+
{
914+
// The prefilter plus the retained scalar recheck must not over-match.
915+
conditions: []string{
916+
"time>='2019/08/26 06:18:13.011'",
917+
"time<='2019/08/26 06:28:19.011'",
918+
"message ilike '%NoSuchMessage%'",
919+
},
920+
expected: [][]string{},
921+
},
898922
{
899923
conditions: []string{
900924
"time>='2019/08/26 06:18:13.011'",

pkg/planner/core/memtable_predicate_extractor.go

Lines changed: 71 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -383,22 +383,25 @@ func (helper *extractHelper) extractLikePatternCol(
383383
continue
384384
}
385385

386-
var canBuildPattern bool
386+
var canBuildPattern, isPrefilter bool
387387
var pattern string
388388
// We use '|' to combine DNF regular expression: .*a.*|.*b.*
389389
// e.g:
390390
// SELECT * FROM t WHERE c LIKE '%a%' OR c LIKE '%b%'
391391
if fn.FuncName.L == ast.LogicOr && !toLower {
392-
canBuildPattern, pattern = helper.extractOrLikePattern(ctx, fn, extractColName, extractCols, needLike2Regexp)
392+
canBuildPattern, pattern, isPrefilter = helper.extractOrLikePattern(ctx, fn, extractColName, extractCols, needLike2Regexp, toLower)
393393
} else {
394-
canBuildPattern, pattern = helper.extractLikePattern(ctx, fn, extractColName, extractCols, needLike2Regexp)
394+
canBuildPattern, pattern, isPrefilter = helper.extractLikePattern(ctx, fn, extractColName, extractCols, needLike2Regexp, toLower)
395395
}
396396
if canBuildPattern && toLower {
397397
pattern = strings.ToLower(pattern)
398398
}
399399
if canBuildPattern {
400400
patterns = append(patterns, pattern)
401-
} else {
401+
}
402+
// A prefilter narrows the scan but is not exactly equivalent to the
403+
// predicate, so it is pushed down *and* kept for a scalar recheck.
404+
if !canBuildPattern || isPrefilter {
402405
remained = append(remained, expr)
403406
}
404407
}
@@ -411,40 +414,51 @@ func (helper extractHelper) extractOrLikePattern(
411414
extractColName string,
412415
extractCols map[int64]*types.FieldName,
413416
needLike2Regexp bool,
417+
toLower bool,
414418
) (
415419
ok bool,
416420
pattern string,
421+
isPrefilter bool,
417422
) {
418423
predicates := expression.SplitDNFItems(orFunc)
419424
if len(predicates) == 0 {
420-
return false, ""
425+
return false, "", false
421426
}
422427

423428
patternBuilder := make([]string, 0, len(predicates))
424429
for _, predicate := range predicates {
425430
fn, ok := predicate.(*expression.ScalarFunction)
426431
if !ok {
427-
return false, ""
432+
return false, "", false
428433
}
429434

430-
ok, partPattern := helper.extractLikePattern(ctx, fn, extractColName, extractCols, needLike2Regexp)
435+
ok, partPattern, partIsPrefilter := helper.extractLikePattern(ctx, fn, extractColName, extractCols, needLike2Regexp, toLower)
431436
if !ok {
432-
return false, ""
437+
return false, "", false
433438
}
439+
// One inexact branch makes the whole disjunction inexact.
440+
isPrefilter = isPrefilter || partIsPrefilter
434441
patternBuilder = append(patternBuilder, partPattern)
435442
}
436-
return true, strings.Join(patternBuilder, "|")
443+
return true, strings.Join(patternBuilder, "|"), isPrefilter
437444
}
438445

446+
// extractLikePattern builds the pushed-down pattern for a single predicate.
447+
// toLower reports whether the caller folds case on both the pattern and the
448+
// scanned value, which decides how a case-insensitive ILIKE is represented.
449+
// isPrefilter reports that the pattern only narrows the scan and is not exactly
450+
// equivalent to the predicate, so the caller must keep a scalar recheck.
439451
func (helper extractHelper) extractLikePattern(
440452
ctx base.PlanContext,
441453
fn *expression.ScalarFunction,
442454
extractColName string,
443455
extractCols map[int64]*types.FieldName,
444456
needLike2Regexp bool,
457+
toLower bool,
445458
) (
446459
ok bool,
447460
pattern string,
461+
isPrefilter bool,
448462
) {
449463
var colName string
450464
var datums []types.Datum
@@ -453,21 +467,62 @@ func (helper extractHelper) extractLikePattern(
453467
colName, datums, _ = helper.extractColBinaryOpConsExpr(ctx, extractCols, fn)
454468
}
455469
if colName != extractColName {
456-
return false, ""
470+
return false, "", false
457471
}
458472
switch fn.FuncName.L {
459473
case ast.EQ:
460-
return true, "^" + regexp.QuoteMeta(datums[0].GetString()) + "$"
474+
return true, "^" + regexp.QuoteMeta(datums[0].GetString()) + "$", false
461475
case ast.Like, ast.Ilike:
462-
if needLike2Regexp {
463-
return true, stringutil.CompileLike2Regexp(datums[0].GetString())
476+
// The pushed-down pattern must honour the LIKE ESCAPE so it does not
477+
// diverge from the original predicate (issue #69653). The escape has to
478+
// be resolvable at plan time: for a constant escape (including the
479+
// default '\' and the empty escape of NO_BACKSLASH_ESCAPES) we compile
480+
// the regexp with it; a non-constant/deferred escape can't be resolved
481+
// here, so we skip extraction and let the scalar predicate be rechecked.
482+
escape, ok := likeEscapeConst(fn)
483+
if !ok {
484+
return false, "", false
485+
}
486+
if !needLike2Regexp {
487+
return true, datums[0].GetString(), false
464488
}
465-
return true, datums[0].GetString()
489+
pattern = stringutil.CompileLike2Regexp(datums[0].GetString(), escape)
490+
// ILIKE matches case-insensitively while the pattern above is
491+
// case-sensitive. Callers that fold case (toLower) lower both the pattern
492+
// and the scanned value, so it stays equivalent there. Callers that do
493+
// not -- the cluster log path sends the pattern verbatim in
494+
// SearchLogRequest.Patterns -- get a case-insensitive group instead, so
495+
// an "ERROR" line still matches `message ILIKE '%error%'`. The scoped
496+
// (?i:...) form keeps the flag from leaking across a '|' when several
497+
// patterns are combined into one disjunction. Case folding there is the
498+
// regexp engine's rather than ILIKE's, so it is only a prefilter and the
499+
// predicate is rechecked.
500+
if fn.FuncName.L == ast.Ilike && !toLower {
501+
return true, "(?i:" + pattern + ")", true
502+
}
503+
return true, pattern, false
466504
case ast.Regexp, ast.RegexpLike:
467-
return true, datums[0].GetString()
505+
return true, datums[0].GetString(), false
468506
default:
469-
return false, ""
507+
return false, "", false
508+
}
509+
}
510+
511+
// likeEscapeConst returns the ESCAPE byte of a LIKE/ILIKE ScalarFunction when
512+
// it is a plan-time constant (ok=true). The escape must be constant so the
513+
// pushed-down pattern can be compiled to match the predicate exactly (issue
514+
// #69653); a non-constant, deferred, or parameterized escape returns ok=false,
515+
// telling the caller to skip pushdown and keep a scalar recheck.
516+
func likeEscapeConst(fn *expression.ScalarFunction) (byte, bool) {
517+
args := fn.GetArgs()
518+
if len(args) < 3 {
519+
return 0, false
520+
}
521+
escape, ok := args[2].(*expression.Constant)
522+
if !ok || escape.DeferredExpr != nil || escape.ParamMarker != nil {
523+
return 0, false
470524
}
525+
return byte(escape.Value.GetInt64()), true
471526
}
472527

473528
func (extractHelper) findColumn(schema *expression.Schema, names []*types.FieldName, colName string) map[int64]*types.FieldName {

pkg/planner/core/operator/logicalop/logicalop_test/logical_mem_table_predicate_extractor_test.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -540,6 +540,41 @@ func TestClusterLogTableExtractor(t *testing.T) {
540540
instances: set.NewStringSet(),
541541
level: set.NewStringSet("debug", "error"),
542542
},
543+
{
544+
// ILIKE is case-insensitive while the pattern is matched
545+
// case-sensitively by the remote log search, so it is pushed down as a
546+
// case-insensitive group -- an "ERROR" line still matches '%error%'.
547+
// The predicate stays for a scalar recheck (see the executor test in
548+
// TestClusterLogTableIlike), and a pattern is still produced so the
549+
// retriever does not reject the scan as a full-log scan.
550+
sql: "select * from information_schema.cluster_log where message ilike '%error%'",
551+
nodeTypes: set.NewStringSet(),
552+
instances: set.NewStringSet(),
553+
patterns: []string{"(?i:^.*error.*$)"},
554+
},
555+
{
556+
// Same for an ILIKE carrying a non-default ESCAPE ('#%' is a literal '%').
557+
sql: "select * from information_schema.cluster_log where message ilike '%error#%%' escape '#'",
558+
nodeTypes: set.NewStringSet(),
559+
instances: set.NewStringSet(),
560+
patterns: []string{"(?i:^.*error%.*$)"},
561+
},
562+
{
563+
// In a DNF the scoped (?i:...) keeps the case-insensitive branch from
564+
// leaking case folding onto the case-sensitive one across the '|'.
565+
sql: "select * from information_schema.cluster_log where (message ilike '%pd%' or message like '%tikv%')",
566+
nodeTypes: set.NewStringSet(),
567+
instances: set.NewStringSet(),
568+
patterns: []string{"(?i:^.*pd.*$)|^.*tikv.*$"},
569+
},
570+
{
571+
// A case-sensitive LIKE still pushes down, honouring a custom ESCAPE
572+
// so the pattern matches the predicate ('#%' is a literal '%').
573+
sql: "select * from information_schema.cluster_log where message like '%a#%b%' escape '#'",
574+
nodeTypes: set.NewStringSet(),
575+
instances: set.NewStringSet(),
576+
patterns: []string{"^.*a%b.*$"},
577+
},
543578
}
544579
for _, ca := range cases {
545580
logicalMemTable, ok := getLogicalMemTable(t, dom, se, parser, ca.sql)

pkg/planner/core/tests/extractor/BUILD.bazel

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ go_test(
88
"memtable_infoschema_extractor_test.go",
99
],
1010
flaky = True,
11-
shard_count = 4,
11+
shard_count = 5,
1212
deps = [
1313
"//pkg/infoschema",
1414
"//pkg/planner/core",

pkg/planner/core/tests/extractor/memtable_infoschema_extractor_test.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,3 +510,26 @@ func TestMemtableInfoschemaExtractorPart4(t *testing.T) {
510510
}
511511
testMemtableInfoschemaExtractor(t, tcs)
512512
}
513+
514+
func TestInfoSchemaTableNameLikeEscape(t *testing.T) {
515+
store := testkit.CreateMockStore(t)
516+
tk := testkit.NewTestKit(t, store)
517+
tk.MustExec("create database like_escape")
518+
tk.MustExec("use like_escape")
519+
tk.MustExec("create table `abc_def` (a int)")
520+
tk.MustExec("create table `abc#x` (a int)")
521+
522+
// With ESCAPE '#', "#_" is a literal underscore, so only `abc_def` matches
523+
// (not `abc#x`). The extractor pushes the pattern down compiled with the '#'
524+
// escape, so the memtable scan matches exactly the LIKE ... ESCAPE predicate
525+
// instead of diverging under the default '\' escape. See issue #69653.
526+
tk.MustQuery("select table_name, table_name like '%#_%' escape '#' as self_true " +
527+
"from information_schema.tables " +
528+
"where table_schema = 'like_escape' and table_name like '%#_%' escape '#'").
529+
Check(testkit.Rows("abc_def 1"))
530+
531+
// A default-escape LIKE is unaffected and still matches both tables.
532+
tk.MustQuery("select table_name from information_schema.tables " +
533+
"where table_schema = 'like_escape' and table_name like 'abc%'").
534+
Sort().Check(testkit.Rows("abc#x", "abc_def"))
535+
}

pkg/util/stringutil/string_util.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -257,9 +257,10 @@ func matchRune(a, b rune) bool {
257257
*/
258258
}
259259

260-
// CompileLike2Regexp convert a like `lhs` to a regular expression
261-
func CompileLike2Regexp(str string) string {
262-
patChars, patTypes := CompilePattern(str, '\\')
260+
// CompileLike2Regexp convert a like `lhs` to a regular expression, compiling
261+
// the pattern with the given LIKE escape byte (pass '\\' for the SQL default).
262+
func CompileLike2Regexp(str string, escape byte) string {
263+
patChars, patTypes := CompilePattern(str, escape)
263264
var result strings.Builder
264265
result.Grow(len(patChars)*2 + 2)
265266
result.WriteByte('^')

pkg/util/stringutil/string_util_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ func TestCompileLike2Regexp(t *testing.T) {
133133
{`%_%_aA`, "^...*aA$"},
134134
}
135135
for _, v := range tbl {
136-
result := CompileLike2Regexp(v.pattern)
136+
result := CompileLike2Regexp(v.pattern, '\\')
137137
require.Equalf(t, v.regexp, result, "source %v", v)
138138
}
139139
}

0 commit comments

Comments
 (0)