From 50f540cffb93b573d502c31e9f93dfaeccb1b20a Mon Sep 17 00:00:00 2001 From: cpegeric Date: Mon, 13 Jul 2026 16:05:19 +0100 Subject: [PATCH 1/3] fix(parser): quote MATCH...AGAINST pattern on deparse (#24823) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CREATE TABLE AS SELECT re-serializes the SELECT (build_ddl.go RawSQL, deparsed with WithSingleQuoteString). FullTextMatchExpr.Format wrote the AGAINST pattern with raw WriteString, dropping the surrounding quotes, so a query like CREATE TABLE ctas_t AS SELECT id, MATCH(body) AGAINST('防水' IN BOOLEAN MODE) sc FROM ft WHERE MATCH(body) AGAINST('防水' IN BOOLEAN MODE); re-parsed as AGAINST(防水 ...) -> syntax error 1105. The pattern is stored unquoted (search_pattern: STRING strips the quotes), so Format must re-quote it. Emit it via WriteValue(P_char, FormatString(pattern)) exactly like NumVal string literals: quoted+escaped in the CTAS/restore context, unchanged (raw) in the default human-readable context — so existing MATCH roundtrip tests are unaffected. Regression: the #24823 query + an embedded-single-quote case added to the WithSingleQuoteString roundtrip suite (TestSQLStringFmt). Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/sql/parsers/dialect/mysql/mysql_sql_test.go | 11 +++++++++++ pkg/sql/parsers/tree/expr.go | 6 +++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/pkg/sql/parsers/dialect/mysql/mysql_sql_test.go b/pkg/sql/parsers/dialect/mysql/mysql_sql_test.go index 12f488f798cc8..5ac6aeda20c2e 100644 --- a/pkg/sql/parsers/dialect/mysql/mysql_sql_test.go +++ b/pkg/sql/parsers/dialect/mysql/mysql_sql_test.go @@ -3790,6 +3790,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..b519a4f0b7f1e 100644 --- a/pkg/sql/parsers/tree/expr.go +++ b/pkg/sql/parsers/tree/expr.go @@ -1927,7 +1927,11 @@ func (node *FullTextMatchExpr) Format(ctx *FmtCtx) { } ctx.WriteString(") ") ctx.WriteString("AGAINST (") - ctx.WriteString(node.Pattern) + // Pattern is stored unquoted (search_pattern: STRING strips the quotes). Re-emit it + // as a properly-quoted/escaped string literal, matching NumVal, so a deparsed + // statement (e.g. CREATE TABLE AS SELECT, which re-serializes the query) round-trips + // and re-parses. Raw WriteString here dropped the quotes -> syntax error (#24823). + ctx.WriteValue(P_char, FormatString(node.Pattern)) if node.Mode != FULLTEXT_DEFAULT { ctx.WriteString(" ") From 7910d45ce72ae47f9314d904c16f5f9312cf5181 Mon Sep 17 00:00:00 2001 From: cpegeric Date: Mon, 13 Jul 2026 16:12:01 +0100 Subject: [PATCH 2/3] test(bvt): CREATE TABLE AS SELECT with MATCH...AGAINST (#24823) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit End-to-end regression for the deparse fix: builds a gojieba fulltext index and runs CREATE TABLE AS SELECT with MATCH(body) AGAINST('防水' IN BOOLEAN MODE) in both the projection (scored) and WHERE, plus WHERE-only variants. Before the fix this errored with syntax 1105 (dropped pattern quotes on CTAS re-serialization); now it creates the table with the correct matching rows. mo-tester run mode: 14/14. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../cases/fulltext/fulltext_ctas.result | 28 +++++++++++++++ .../cases/fulltext/fulltext_ctas.sql | 34 +++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 test/distributed/cases/fulltext/fulltext_ctas.result create mode 100644 test/distributed/cases/fulltext/fulltext_ctas.sql 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; From e414129124b0e301e59220715e5f5d1599d2f462 Mon Sep 17 00:00:00 2001 From: cpegeric Date: Tue, 14 Jul 2026 09:47:52 +0100 Subject: [PATCH 3/3] fix(parser): quote FULLTEXT MATCH...AGAINST pattern on the default deparse path (#24823) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FullTextMatchExpr.Format emitted the AGAINST pattern via ctx.WriteValue(P_char, ...), which only quotes when the FmtCtx opts in (quoteString/singleQuoteString). The default tree.String() path does not opt in, so the pattern was written bare (AGAINST (防水 ...)) — invalid SQL. Any re-serialization through the default path (CREATE TABLE AS SELECT, view expansion, etc.) produced un-reparseable SQL; the CTAS case only worked because it happened to enable string quoting. The pattern is ALWAYS a string literal (unlike the polymorphic NumVal), so emit it single-quoted and escaped unconditionally. Now the node round-trips on every formatter path. Regression coverage on the DEFAULT path: correct the six validSQL expectations in TestValid (which run through tree.String) from bare to quoted, and add TestFullTextMatchDeparseRoundTrip (default-path deparse -> re-parse -> idempotent), covering CJK and an embedded single quote. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../parsers/dialect/mysql/mysql_sql_test.go | 41 ++++++++++++++++--- pkg/sql/parsers/tree/expr.go | 17 +++++--- 2 files changed, 47 insertions(+), 11 deletions(-) diff --git a/pkg/sql/parsers/dialect/mysql/mysql_sql_test.go b/pkg/sql/parsers/dialect/mysql/mysql_sql_test.go index 5ac6aeda20c2e..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) diff --git a/pkg/sql/parsers/tree/expr.go b/pkg/sql/parsers/tree/expr.go index b519a4f0b7f1e..b34ac3f8821fc 100644 --- a/pkg/sql/parsers/tree/expr.go +++ b/pkg/sql/parsers/tree/expr.go @@ -1927,11 +1927,18 @@ func (node *FullTextMatchExpr) Format(ctx *FmtCtx) { } ctx.WriteString(") ") ctx.WriteString("AGAINST (") - // Pattern is stored unquoted (search_pattern: STRING strips the quotes). Re-emit it - // as a properly-quoted/escaped string literal, matching NumVal, so a deparsed - // statement (e.g. CREATE TABLE AS SELECT, which re-serializes the query) round-trips - // and re-parses. Raw WriteString here dropped the quotes -> syntax error (#24823). - ctx.WriteValue(P_char, FormatString(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(" ")