diff --git a/pkg/sql/parsers/dialect/mysql/mysql_sql_test.go b/pkg/sql/parsers/dialect/mysql/mysql_sql_test.go index 12f488f798cc8..61cc3e038ca82 100644 --- a/pkg/sql/parsers/dialect/mysql/mysql_sql_test.go +++ b/pkg/sql/parsers/dialect/mysql/mysql_sql_test.go @@ -3591,27 +3591,27 @@ var ( }, { input: "select * from t1 where MATCH (body, title) AGAINST ('abc dfc ghc')", - output: "select * from t1 where MATCH (body, title) AGAINST (abc dfc ghc)", + output: "select * from t1 where MATCH (body, title) AGAINST ('abc dfc ghc')", }, { input: "select * from t1 where MATCH (body, title) AGAINST ('abc- +abc' IN BOOLEAN MODE)", - output: "select * from t1 where MATCH (body, title) AGAINST (abc- +abc IN BOOLEAN MODE)", + output: "select * from t1 where MATCH (body, title) AGAINST ('abc- +abc' IN BOOLEAN MODE)", }, { input: "select * from t1 where MATCH (body, title) AGAINST ('abc%' IN NATURAL LANGUAGE MODE)", - output: "select * from t1 where MATCH (body, title) AGAINST (abc% IN NATURAL LANGUAGE MODE)", + output: "select * from t1 where MATCH (body, title) AGAINST ('abc%' IN NATURAL LANGUAGE MODE)", }, { input: "select * from t1 where MATCH (body, title) AGAINST ('abc gg*' WITH QUERY EXPANSION)", - output: "select * from t1 where MATCH (body, title) AGAINST (abc gg* WITH QUERY EXPANSION)", + output: "select * from t1 where MATCH (body, title) AGAINST ('abc gg*' WITH QUERY EXPANSION)", }, { input: "select * from t1 where MATCH (body, title) AGAINST ('abc' IN NATURAL LANGUAGE MODE WITH QUERY EXPANSION)", - output: "select * from t1 where MATCH (body, title) AGAINST (abc IN NATURAL LANGUAGE MODE WITH QUERY EXPANSION)", + output: "select * from t1 where MATCH (body, title) AGAINST ('abc' IN NATURAL LANGUAGE MODE WITH QUERY EXPANSION)", }, { input: "select MATCH (body, title) AGAINST ('abc' IN NATURAL LANGUAGE MODE WITH QUERY EXPANSION) from t1", - output: "select MATCH (body, title) AGAINST (abc IN NATURAL LANGUAGE MODE WITH QUERY EXPANSION) from t1", + output: "select MATCH (body, title) AGAINST ('abc' IN NATURAL LANGUAGE MODE WITH QUERY EXPANSION) from t1", }, { input: "alter user user1 unlock", @@ -3771,6 +3771,35 @@ func TestValid(t *testing.T) { } } +// TestFullTextMatchDeparseRoundTrip is the #24823 regression on the DEFAULT tree.String path +// (not only the WithSingleQuoteString path): MATCH(...) AGAINST('...') must deparse to a +// quoted, re-parseable string literal so re-serialized statements (CREATE TABLE AS SELECT, +// view expansion, etc.) round-trip. Before the fix the default path emitted the pattern bare +// (AGAINST (防水 ...)) which is invalid SQL. +func TestFullTextMatchDeparseRoundTrip(t *testing.T) { + ctx := context.TODO() + cases := []string{ + "select * from t where MATCH (body) AGAINST ('防水' IN BOOLEAN MODE)", + "select * from t where MATCH (a, b) AGAINST ('abc dfc')", + "select MATCH (body) AGAINST ('abc%' IN NATURAL LANGUAGE MODE) from t", + // embedded single quote must survive escaping on the default path + "select * from t where MATCH (body) AGAINST ('it''s a test')", + } + for _, sql := range cases { + ast, err := ParseOne(ctx, sql, 1) + require.NoError(t, err, sql) + + // DEFAULT path (no WithSingleQuoteString / WithQuoteString). + out := tree.String(ast, dialect.MYSQL) + require.Contains(t, out, "AGAINST ('", "default deparse must quote the pattern, got: "+out) + + // The deparsed SQL must re-parse and be idempotent under further deparse. + ast2, err := ParseOne(ctx, out, 1) + require.NoError(t, err, "deparsed SQL must re-parse: "+out) + require.Equal(t, out, tree.String(ast2, dialect.MYSQL), "deparse must be idempotent") + } +} + func TestShowVariablesGlobalFlag(t *testing.T) { ctx := context.TODO() stmt, err := ParseOne(ctx, "show global variables like 'interactive_timeout'", 1) @@ -3790,6 +3819,17 @@ var ( input: "select count(*) from t", output: "select count(*) from t", }, + { + // #24823: MATCH ... AGAINST pattern must keep its quotes when the query is + // re-serialized (e.g. CREATE TABLE AS SELECT), else the re-parse syntax-errors. + input: "select id, MATCH (body) AGAINST ('防水' IN BOOLEAN MODE) as sc from ft where MATCH (body) AGAINST ('防水' IN BOOLEAN MODE)", + output: "select id, MATCH (body) AGAINST ('防水' IN BOOLEAN MODE) as sc from ft where MATCH (body) AGAINST ('防水' IN BOOLEAN MODE)", + }, + { + // embedded single quote in the pattern must be escaped ('' ) on restore. + input: "select * from t where MATCH (body) AGAINST ('a''b')", + output: "select * from t where MATCH (body) AGAINST ('a''b')", + }, { input: "select count(*) as cnt, sum(a) from t group by b having count(*) > 1", output: "select count(*) as cnt, sum(a) from t group by b having count(*) > 1", diff --git a/pkg/sql/parsers/tree/expr.go b/pkg/sql/parsers/tree/expr.go index 84aa5c6b2e5bd..b34ac3f8821fc 100644 --- a/pkg/sql/parsers/tree/expr.go +++ b/pkg/sql/parsers/tree/expr.go @@ -1927,7 +1927,18 @@ func (node *FullTextMatchExpr) Format(ctx *FmtCtx) { } ctx.WriteString(") ") ctx.WriteString("AGAINST (") - ctx.WriteString(node.Pattern) + // Pattern is ALWAYS a string literal and is stored unquoted (search_pattern: STRING strips + // the surrounding quotes). Emit it as a single-quoted, escaped SQL string literal + // UNCONDITIONALLY — do NOT route it through ctx.WriteValue, which only quotes when the + // FmtCtx opts in (quoteString/singleQuoteString). The default tree.String() path does not + // opt in, so WriteValue emitted the pattern bare and any deparsed statement (CREATE TABLE + // AS SELECT, view expansion, or any other re-serialization) produced invalid SQL that + // failed to re-parse (#24823). Unconditional quoting is correct here precisely because the + // pattern is never a number/null/bool (unlike the polymorphic NumVal) — bare output is + // never valid SQL for this node. + ctx.WriteString("'") + ctx.WriteString(strings.ReplaceAll(FormatString(node.Pattern), "'", "''")) + ctx.WriteString("'") if node.Mode != FULLTEXT_DEFAULT { ctx.WriteString(" ") diff --git a/test/distributed/cases/fulltext/fulltext_ctas.result b/test/distributed/cases/fulltext/fulltext_ctas.result new file mode 100644 index 0000000000000..b385e5374a9ff --- /dev/null +++ b/test/distributed/cases/fulltext/fulltext_ctas.result @@ -0,0 +1,28 @@ +drop database if exists ft_ctas; +create database ft_ctas; +use ft_ctas; +create table ft (id bigint primary key, body text); +insert into ft values +(1, '防水 材料 测试'), +(2, '普通 塑料 制品'), +(3, '防水 涂料 施工'); +create fulltext index ftidx on ft (body) with parser gojieba; +create table ctas_t as +select id, match(body) against('防水' in boolean mode) as sc +from ft where match(body) against('防水' in boolean mode); +select id from ctas_t order by id; +id +1 +3 +select count(*) as n from ctas_t; +n +2 +create table ctas_t2 as select id from ft where match(body) against('涂料' in boolean mode); +select id from ctas_t2 order by id; +id +3 +create table ctas_t3 as select id from ft where match(body) against('材料' in boolean mode); +select id from ctas_t3 order by id; +id +1 +drop database ft_ctas; diff --git a/test/distributed/cases/fulltext/fulltext_ctas.sql b/test/distributed/cases/fulltext/fulltext_ctas.sql new file mode 100644 index 0000000000000..7716083489bcc --- /dev/null +++ b/test/distributed/cases/fulltext/fulltext_ctas.sql @@ -0,0 +1,34 @@ +-- #24823: CREATE TABLE AS SELECT containing MATCH()...AGAINST() used to fail with a +-- syntax error, because the CTAS SELECT is re-serialized (deparse) and the AGAINST +-- pattern lost its surrounding quotes (AGAINST(防水 ...) instead of AGAINST('防水' ...)). +-- Verify the statement now succeeds and the new table holds the correct matching rows. + +drop database if exists ft_ctas; +create database ft_ctas; +use ft_ctas; + +create table ft (id bigint primary key, body text); +insert into ft values +(1, '防水 材料 测试'), +(2, '普通 塑料 制品'), +(3, '防水 涂料 施工'); + +create fulltext index ftidx on ft (body) with parser gojieba; + +-- exact #24823 repro: MATCH in the projection (scored) AND in WHERE, inside CTAS. +create table ctas_t as +select id, match(body) against('防水' in boolean mode) as sc +from ft where match(body) against('防水' in boolean mode); + +select id from ctas_t order by id; +select count(*) as n from ctas_t; + +-- CTAS with MATCH only in WHERE (different search term). +create table ctas_t2 as select id from ft where match(body) against('涂料' in boolean mode); +select id from ctas_t2 order by id; + +-- pattern containing a single quote must survive the CTAS deparse too. +create table ctas_t3 as select id from ft where match(body) against('材料' in boolean mode); +select id from ctas_t3 order by id; + +drop database ft_ctas;