Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
a5a6400
planner: rewrite FTS predicates to LIKE if no FTS index
terry1purcell Jan 18, 2026
4ded0d5
build errors
terry1purcell Jan 18, 2026
5051049
build errors2
terry1purcell Jan 18, 2026
a11e436
testcase1
terry1purcell Jan 18, 2026
3379ed5
testcase2
terry1purcell Jan 18, 2026
ba0f3d7
review1
terry1purcell Jan 18, 2026
82416d8
review2
terry1purcell Jan 18, 2026
f7b1fa5
review3
terry1purcell Jan 18, 2026
c63c6bb
review4
terry1purcell Jan 18, 2026
0c74944
review5
terry1purcell Jan 18, 2026
dbdbce1
review6
terry1purcell Jan 18, 2026
dc10c79
review7
terry1purcell Jan 18, 2026
48373d1
review8
terry1purcell Jan 19, 2026
2121e81
review9
terry1purcell Jan 19, 2026
0b4ba84
review10
terry1purcell Jan 19, 2026
69b1497
Merge branch 'pingcap:master' into fts
terry1purcell Mar 1, 2026
04b00aa
Merge branch 'master' into fts
terry1purcell Apr 7, 2026
ab11dda
refactor
terry1purcell Apr 7, 2026
c9e0409
Merge branch 'master' into fts
terry1purcell Apr 25, 2026
8ba9a8a
expression: revert fts_match_word arity/impl changes not needed for L…
terry1purcell Apr 26, 2026
19e9dc3
planner, expression: fix four review findings in MATCH...AGAINST LIKE…
terry1purcell Apr 26, 2026
569f3aa
rebase after months of change
terry1purcell Apr 26, 2026
19aba2c
planner: handle optional+excluded boolean FTS terms in LIKE fallback
terry1purcell Apr 26, 2026
eb1d412
planner: fix four correctness issues in MATCH...AGAINST LIKE fallback
terry1purcell Apr 26, 2026
4200060
planner/util: update null-reject builtin registry snapshot for match_…
terry1purcell Apr 26, 2026
a566282
expression: regenerate builtin thread-safety files for builtinFtsMysq…
terry1purcell Apr 27, 2026
afcf285
tests: add match_against to SHOW BUILTINS expected output
terry1purcell Apr 27, 2026
d5cfdcb
planner, expression: fix review findings in MATCH...AGAINST LIKE fall…
terry1purcell Apr 27, 2026
4080f42
planner, expression: address review findings in MATCH...AGAINST LIKE …
terry1purcell Apr 27, 2026
c22f54c
planner: restrict MATCH...AGAINST LIKE rewrite to predicate contexts
terry1purcell Apr 27, 2026
b7733c7
planner: use ILIKE for case-insensitive MATCH...AGAINST LIKE fallback
terry1purcell Apr 27, 2026
e603043
planner: fix gofmt comment formatting in fulltext_to_like.go
terry1purcell Apr 27, 2026
1ddbcca
planner: add fts-native alternative round for TiFlash FTS cost compet…
terry1purcell Apr 27, 2026
dc2cccb
review updates
terry1purcell May 3, 2026
98e97fd
Merge branch 'pingcap:master' into fts
terry1purcell May 11, 2026
32b7c04
planner, expression: address review feedback on MATCH...AGAINST LIKE …
terry1purcell May 11, 2026
bac401f
bazel update
terry1purcell May 11, 2026
4aa6f93
*: stop tracking Claude Code runtime state
terry1purcell May 11, 2026
49db4da
planner: fix NULL search handling in MATCH...AGAINST LIKE fallback
terry1purcell May 11, 2026
3647abe
expression: gate FTSMysqlMatchAgainst Flash pushdown on default modifier
terry1purcell May 11, 2026
8803614
cardinality: route constant FTS substitutes to the constants bucket
terry1purcell May 11, 2026
b0b04c4
expression: emit Constant(NULL) for AGAINST(NULL) in selectivity subs…
terry1purcell May 12, 2026
aba6e18
planner: move column-type check above NULL fast-path in LIKE fallback
terry1purcell May 12, 2026
57f98c4
cardinality: refresh stale Constant(0) comment after Constant(NULL) c…
terry1purcell May 12, 2026
a18872c
expression: add defensive bounds check before indexing FTS validator …
terry1purcell May 12, 2026
ed3e7a3
planner: restore FTS cost competition between native and ILIKE plans
terry1purcell May 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions pkg/planner/core/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ go_library(
"expression_rewriter.go",
"find_best_task.go",
"flat_plan.go",
"fulltext_to_like.go",
"hint_utils.go",
"index_join_path.go",
"indexmerge_path.go",
Expand Down Expand Up @@ -209,6 +210,7 @@ go_test(
"exhaust_physical_plans_test.go",
"expression_test.go",
"find_best_task_test.go",
"fulltext_to_like_test.go",
"hint_test.go",
"indexmerge_intersection_test.go",
"indexmerge_path_test.go",
Expand Down
70 changes: 70 additions & 0 deletions pkg/planner/core/expression_rewriter.go
Original file line number Diff line number Diff line change
Expand Up @@ -1692,6 +1692,8 @@ func (er *expressionRewriter) Leave(originInNode ast.Node) (retNode ast.Node, ok
}
er.ctxStack[len(er.ctxStack)-1].SetCoercibility(expression.CoercibilityExplicit)
er.ctxStack[len(er.ctxStack)-1].SetCharsetAndCollation(arg.GetType(er.sctx.GetEvalCtx()).GetCharset(), arg.GetType(er.sctx.GetEvalCtx()).GetCollate())
case *ast.MatchAgainst:
er.matchAgainstToExpression(v)
default:
er.err = errors.Errorf("UnknownType: %T", v)
return retNode, false
Expand Down Expand Up @@ -2217,6 +2219,74 @@ func (er *expressionRewriter) patternLikeOrIlikeToExpression(v *ast.PatternLikeO
er.ctxStackAppend(function, types.EmptyName)
}

func (er *expressionRewriter) matchAgainstToExpression(v *ast.MatchAgainst) {
// Check the session variable to determine behavior
var fallbackMode string
if er.planCtx != nil && er.planCtx.builder != nil && er.planCtx.builder.ctx != nil {
fallbackMode = er.planCtx.builder.ctx.GetSessionVars().FulltextSearchFallback
} else {
fallbackMode = "like" // default
Copy link

Copilot AI Jan 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The nil check chain at lines 2225-2226 could potentially panic if any of the intermediate values are nil. While the fallback to 'like' at line 2228 provides a default, the chained dereferencing (er.planCtx.builder.ctx.GetSessionVars()) could panic if 'builder' or 'ctx' is nil, before reaching the else block. Consider checking each level individually or restructuring the condition to be safer.

Suggested change
var fallbackMode string
if er.planCtx != nil && er.planCtx.builder != nil && er.planCtx.builder.ctx != nil {
fallbackMode = er.planCtx.builder.ctx.GetSessionVars().FulltextSearchFallback
} else {
fallbackMode = "like" // default
fallbackMode := "like" // default
if er.planCtx != nil && er.planCtx.builder != nil && er.planCtx.builder.ctx != nil {
if sv := er.planCtx.builder.ctx.GetSessionVars(); sv != nil {
fallbackMode = sv.FulltextSearchFallback
}

Copilot uses AI. Check for mistakes.
}

if fallbackMode == "error" {
er.err = expression.ErrNotSupportedYet.GenWithStackByArgs("MATCH...AGAINST without fulltext index")
return
}

// The Against expression has been visited and should be on the ctxStack
// Pop it from the stack
l := len(er.ctxStack)
if l < 1 {
er.err = errors.Errorf("MATCH...AGAINST: expected Against expression on stack")
return
}

againstExpr := er.ctxStack[l-1]
er.ctxStackPop(1)

// Check if it's a constant string
constExpr, ok := againstExpr.(*expression.Constant)
if !ok {
er.err = expression.ErrNotSupportedYet.GenWithStackByArgs("MATCH...AGAINST with non-constant search string")
return
}

searchText, err := constExpr.Eval(er.sctx.GetEvalCtx(), chunk.Row{})
if err != nil {
er.err = err
return
}

if searchText.Kind() != types.KindString {
er.err = expression.ErrNotSupportedYet.GenWithStackByArgs("MATCH...AGAINST with non-string search expression")
return
}

// Resolve column expressions
var columns []expression.Expression
for _, colName := range v.ColumnNames {
idx, err := expression.FindFieldName(er.names, colName)
if err != nil {
er.err = err
return
}
if idx < 0 {
er.err = errors.Errorf("Unknown column '%s' in MATCH...AGAINST", colName.Name.O)
return
}
columns = append(columns, er.schema.Columns[idx])
}

// Convert to LIKE predicates
result, err := er.convertMatchAgainstToLike(columns, searchText.GetString(), v.Modifier)
if err != nil {
er.err = err
return
}

er.ctxStackAppend(result, types.EmptyName)
}
Comment thread
terry1purcell marked this conversation as resolved.

func (er *expressionRewriter) regexpToScalarFunc(v *ast.PatternRegexpExpr) {
l := len(er.ctxStack)
er.err = expression.CheckArgsNotMultiColumnRow(er.ctxStack[l-2:]...)
Expand Down
295 changes: 295 additions & 0 deletions pkg/planner/core/fulltext_to_like.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,295 @@
// Copyright 2025 PingCAP, Inc.
Comment thread
terry1purcell marked this conversation as resolved.
Outdated
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description must include a valid issue number. Replace "close #xxx" with an actual issue number (e.g., "close #12345") to properly link this PR to its corresponding issue.

Copilot generated this review using guidance from repository custom instructions.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package core

import (
"strings"

"github.com/pingcap/tidb/pkg/expression"
"github.com/pingcap/tidb/pkg/parser/ast"
"github.com/pingcap/tidb/pkg/parser/mysql"
"github.com/pingcap/tidb/pkg/types"
)

// searchTerm represents a single term in a Boolean fulltext search query
type searchTerm struct {
word string
isRequired bool // Has '+' prefix
isExcluded bool // Has '-' prefix
isPrefixMatch bool // Has '*' suffix
isPhrase bool // Wrapped in quotes
}

// parseBooleanSearchString parses a Boolean mode search string into individual terms
func parseBooleanSearchString(text string) []searchTerm {
var terms []searchTerm
var current strings.Builder
inQuote := false
i := 0

for i < len(text) {
ch := text[i]

switch ch {
case '"':
if inQuote {
// End of phrase
phrase := current.String()
if phrase != "" {
terms = append(terms, searchTerm{
word: phrase,
isPhrase: true,
})
}
current.Reset()
inQuote = false
} else {
// Start of phrase
inQuote = true
}
i++
case ' ', '\t', '\n', '\r':
if inQuote {
current.WriteByte(ch)
} else if current.Len() > 0 {
// End of word
word := current.String()
terms = append(terms, parseSearchTerm(word))
current.Reset()
}
i++
default:
current.WriteByte(ch)
i++
}
}

// Handle remaining content
if current.Len() > 0 {
if inQuote {
// Unclosed quote, treat as phrase
terms = append(terms, searchTerm{
word: current.String(),
isPhrase: true,
})
} else {
word := current.String()
terms = append(terms, parseSearchTerm(word))
}
}

return terms
}

// parseSearchTerm parses a single search term (not in quotes) and extracts operators
func parseSearchTerm(word string) searchTerm {
if word == "" {
return searchTerm{}
}

term := searchTerm{word: word}

// Check for leading operators
if word[0] == '+' {
term.isRequired = true
word = word[1:]
} else if word[0] == '-' {
term.isExcluded = true
word = word[1:]
}

// Check for trailing wildcard
if len(word) > 0 && word[len(word)-1] == '*' {
term.isPrefixMatch = true
word = word[:len(word)-1]
}

term.word = word
return term
}

// convertMatchAgainstToLike converts a MATCH...AGAINST expression to LIKE predicates
func (er *expressionRewriter) convertMatchAgainstToLike(
columns []expression.Expression,
searchText string,
modifier ast.FulltextSearchModifier,
) (expression.Expression, error) {
if len(columns) == 0 {
return nil, expression.ErrNotSupportedYet.GenWithStackByArgs("MATCH...AGAINST with no columns")
}

if searchText == "" {
// Empty search string matches nothing
return &expression.Constant{
Value: types.NewIntDatum(0),
RetType: types.NewFieldType(mysql.TypeTiny),
}, nil
}

var columnPredicates []expression.Expression

if modifier.IsBooleanMode() {
// Parse Boolean mode search string
terms := parseBooleanSearchString(searchText)
if len(terms) == 0 {
return &expression.Constant{
Value: types.NewIntDatum(0),
RetType: types.NewFieldType(mysql.TypeTiny),
}, nil
}

// Group terms by type
var required, excluded, optional []searchTerm
for _, term := range terms {
if term.word == "" {
continue
}
if term.isRequired {
required = append(required, term)
} else if term.isExcluded {
excluded = append(excluded, term)
} else {
optional = append(optional, term)
}
}

// Build predicates for each column
for _, column := range columns {
var predicates []expression.Expression

// AND all required terms
for _, term := range required {
pred, err := er.buildLikePredicate(column, term.word, false, term.isPrefixMatch, term.isPhrase)
if err != nil {
return nil, err
}
predicates = append(predicates, pred)
}

// AND NOT all excluded terms
for _, term := range excluded {
pred, err := er.buildLikePredicate(column, term.word, true, term.isPrefixMatch, term.isPhrase)
if err != nil {
return nil, err
}
predicates = append(predicates, pred)
}

// OR all optional terms (if any)
if len(optional) > 0 {
var optionalPreds []expression.Expression
for _, term := range optional {
pred, err := er.buildLikePredicate(column, term.word, false, term.isPrefixMatch, term.isPhrase)
if err != nil {
return nil, err
}
optionalPreds = append(optionalPreds, pred)
}
if len(optionalPreds) > 0 {
predicates = append(predicates, expression.ComposeDNFCondition(er.sctx, optionalPreds...))
}
}

// If we have any predicates for this column, combine them with AND
if len(predicates) > 0 {
columnPredicates = append(columnPredicates, expression.ComposeCNFCondition(er.sctx, predicates...))
}
}
} else {
// Natural Language Mode: split into words and OR them together
words := strings.Fields(searchText)
if len(words) == 0 {
return &expression.Constant{
Value: types.NewIntDatum(0),
RetType: types.NewFieldType(mysql.TypeTiny),
}, nil
}

for _, column := range columns {
var wordPredicates []expression.Expression
for _, word := range words {
pred, err := er.buildLikePredicate(column, word, false, false, false)
if err != nil {
return nil, err
}
wordPredicates = append(wordPredicates, pred)
}
if len(wordPredicates) > 0 {
columnPredicates = append(columnPredicates, expression.ComposeDNFCondition(er.sctx, wordPredicates...))
}
}
}

// OR across all columns
if len(columnPredicates) == 0 {
return &expression.Constant{
Value: types.NewIntDatum(0),
RetType: types.NewFieldType(mysql.TypeTiny),
}, nil
}

return expression.ComposeDNFCondition(er.sctx, columnPredicates...), nil
}

// buildLikePredicate builds a single LIKE predicate for a column and search term
func (er *expressionRewriter) buildLikePredicate(
column expression.Expression,
term string,
isNegated bool,
isPrefixMatch bool,
Comment thread
terry1purcell marked this conversation as resolved.
Outdated
isPhrase bool,
) (expression.Expression, error) {
// Build the pattern
var pattern string
if isPhrase {
// Exact phrase: %term%
pattern = "%" + term + "%"
} else if isPrefixMatch {
// Prefix match: term%
pattern = term + "%"
} else {
// General match: %term%
pattern = "%" + term + "%"
}
Comment thread
terry1purcell marked this conversation as resolved.
Outdated

// Create constant for pattern
patternConst := &expression.Constant{
Value: types.NewStringDatum(pattern),
RetType: types.NewFieldType(mysql.TypeVarchar),
}

// Create escape constant (backslash = 92)
escapeConst := &expression.Constant{
Value: types.NewIntDatum(92),
RetType: types.NewFieldType(mysql.TypeTiny),
}

// Build LIKE function
likeFunc, err := er.newFunction(ast.Like, types.NewFieldType(mysql.TypeTiny), column, patternConst, escapeConst)
if err != nil {
return nil, err
}

// Apply NOT if needed
if isNegated {
notFunc, err := er.newFunction(ast.UnaryNot, types.NewFieldType(mysql.TypeTiny), likeFunc)
if err != nil {
return nil, err
}
return notFunc, nil
}

return likeFunc, nil
}
Loading
Loading