Skip to content

Commit 7826ad6

Browse files
authored
Support ANSI_QUOTES in MySQL/MariaDB DDL (#4452)
Parse out status vars from query event and pass the flag to the tidb parser. Add an integration test that can work with binlog more freely as we can't really observe query parsing from e2e (besides the 5.7-pos configuration that doesn't have binlog metadata to fall back on, but we need a maria test as well). As a bonus, the integration test runs in 1.2 seconds. Closes DBI-821
1 parent 29306ca commit 7826ad6

12 files changed

Lines changed: 347 additions & 150 deletions

File tree

.claude/skills/tilt/SKILL.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,9 @@ All e2e tests also depend on core PeerDB services: `flow-api`, `flow-worker`, `c
8686
|----------|---------|----------------------|
8787
| `connector_postgres` | `connectors/postgres` | postgres |
8888
| `connector_mongo` | `connectors/mongo` | mongodb |
89-
| `connector_mysql` | `connectors/mysql` | mysql-gtid |
89+
| `connector_mysql-gtid` | `connectors/mysql` | mysql-gtid |
90+
| `connector_mysql-pos` | `connectors/mysql` | mysql-pos |
91+
| `connector_mariadb` | `connectors/mysql` | mariadb |
9092
| `connector_clickhouse` | `connectors/clickhouse` | clickhouse |
9193

9294
Connector tests only depend on `catalog` + their specific DB.

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ AWS_ACCESS_KEY_ID=_peerdb_minioadmin
4646
AWS_SECRET_ACCESS_KEY=_peerdb_minioadmin
4747
AWS_ENDPOINT_URL_S3=http://host.docker.internal:9001
4848
AWS_EC2_METADATA_DISABLED="true"
49+
FLOW_TESTS_RDS_IAM_AUTH_SKIP=true
4950

5051
MINIO_ROOT_USER=_peerdb_minioadmin
5152
MINIO_ROOT_PASSWORD=_peerdb_minioadmin

Tiltfile

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -295,10 +295,10 @@ def e2e_test(name, test_run, extra_deps=[], vars_overrides={}):
295295
allow_parallel=True,
296296
)
297297

298-
def connector_test(connector, extra_deps=[], vars_overrides={}):
298+
def connector_test(connector, extra_deps=[], vars_overrides={}, name=''):
299299
overrides_str = ' '.join(['%s=%s' % (var, value) for var, value in vars_overrides.items()])
300300
local_resource(
301-
'connector_' + connector,
301+
'connector_' + (name or connector),
302302
cmd='cd flow && %s go test -count=1 -v ./connectors/%s/...' % (overrides_str, connector),
303303
labels=['Test'],
304304
auto_init=False,
@@ -369,6 +369,11 @@ e2e_test('api-mongodb', 'TestApiMongo', ['provision-mongodb'])
369369
# Connectors tests
370370

371371
connector_test('postgres', ['provision-postgres'])
372+
373+
connector_test('mysql', ['provision-mysql-gtid'], vars_overrides=mysql_gtid_vars, name='mysql-gtid')
374+
connector_test('mysql', ['provision-mysql-pos'], vars_overrides=mysql_pos_vars, name='mysql-pos')
375+
connector_test('mysql', ['provision-mariadb'], vars_overrides=mariadb_vars, name='mariadb')
376+
372377
connector_test('mongo', ['provision-mongodb'])
373-
connector_test('mysql', ['provision-mysql-gtid'])
378+
374379
connector_test('clickhouse', ['provision-clickhouse'])

ancillary-docker-compose.yml

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -185,14 +185,15 @@ services:
185185
- 9902:9902
186186
- 9904:9904
187187
- 9903:9903
188-
- 42001:42001
189-
- 42002:42002
190-
- 42003:42003
191-
- 42004:42004
192-
- 42005:42005
193-
- 49001:49001
194-
- 49002:49002
195-
- 49003:49003
188+
- 10001:10001
189+
- 12001:12001
190+
- 12002:12002
191+
- 12003:12003
192+
- 12004:12004
193+
- 12005:12005
194+
- 14001:14001
195+
- 14002:14002
196+
- 14003:14003
196197
extra_hosts:
197198
- "host.docker.internal:host-gateway"
198199
healthcheck:

flow/connectors/mysql/cdc.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"github.com/go-mysql-org/go-mysql/replication"
2020
"github.com/pingcap/tidb/pkg/parser"
2121
"github.com/pingcap/tidb/pkg/parser/ast"
22+
tidbmysql "github.com/pingcap/tidb/pkg/parser/mysql"
2223
_ "github.com/pingcap/tidb/pkg/types/parser_driver"
2324
"go.opentelemetry.io/otel/attribute"
2425
"go.opentelemetry.io/otel/metric"
@@ -44,6 +45,48 @@ const (
4445
binlogStalenessMultiplier = 3
4546
)
4647

48+
const (
49+
queryStatusVarFlags2 = 0
50+
queryStatusVarSQLMode = 1
51+
)
52+
53+
// sqlModeFromStatusVars extracts the sql_mode bitmask from a QueryEvent's status vars.
54+
// Both MySQL and MariaDB guarantee status vars are written in increasing order,
55+
// so we only need to parse 0 and 1 to get to 1
56+
// https://dev.mysql.com/doc/dev/mysql-server/latest/classmysql_1_1binlog_1_1event_1_1Query__event.html#Query_event_binary_format
57+
// https://github.com/MariaDB/server/blob/c3ec2dc368a8c7165cdbea58208eb828e76ebc57/sql/log_event_server.cc#L1083-L1087
58+
func sqlModeFromStatusVars(statusVars []byte) (uint64, bool) {
59+
for pos := 0; pos < len(statusVars); {
60+
code := statusVars[pos]
61+
pos++
62+
63+
switch code {
64+
case queryStatusVarFlags2:
65+
pos += 4
66+
case queryStatusVarSQLMode:
67+
if pos+8 > len(statusVars) {
68+
return 0, false
69+
}
70+
return binary.LittleEndian.Uint64(statusVars[pos : pos+8]), true
71+
default:
72+
return 0, false
73+
}
74+
75+
if pos > len(statusVars) {
76+
return 0, false
77+
}
78+
}
79+
return 0, false
80+
}
81+
82+
func setParserSQLModeFromStatusVars(mysqlParser *parser.Parser, statusVars []byte) {
83+
var sqlMode tidbmysql.SQLMode
84+
if mode, ok := sqlModeFromStatusVars(statusVars); ok && mode&uint64(tidbmysql.ModeANSIQuotes) != 0 {
85+
sqlMode = tidbmysql.ModeANSIQuotes
86+
}
87+
mysqlParser.SetSQLMode(sqlMode)
88+
}
89+
4790
func (c *MySqlConnector) binlogStalenessThreshold() time.Duration {
4891
return binlogStalenessMultiplier * c.binlogHeartbeatPeriod
4992
}
@@ -533,6 +576,7 @@ func (c *MySqlConnector) PullRecords(
533576
if mysqlParser == nil {
534577
mysqlParser = parser.New()
535578
}
579+
setParserSQLModeFromStatusVars(mysqlParser, ev.StatusVars)
536580
stmts, warns, err := parseSQL(mysqlParser, ev.Query)
537581
if err != nil {
538582
c.logger.Warn("failed to parse QueryEvent", slog.String("query", string(ev.Query)), slog.Any("error", err))

flow/connectors/mysql/cdc_test.go

Lines changed: 98 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -3,53 +3,119 @@ package connmysql
33
import (
44
"context"
55
"fmt"
6+
"slices"
7+
"strings"
68
"testing"
9+
"time"
710

11+
"github.com/go-mysql-org/go-mysql/replication"
12+
"github.com/google/uuid"
813
"github.com/pingcap/tidb/pkg/parser"
914
"github.com/pingcap/tidb/pkg/parser/ast"
15+
tidbmysql "github.com/pingcap/tidb/pkg/parser/mysql"
1016
"github.com/stretchr/testify/require"
1117

1218
"github.com/PeerDB-io/peerdb/flow/generated/protos"
1319
"github.com/PeerDB-io/peerdb/flow/internal"
1420
"github.com/PeerDB-io/peerdb/flow/shared"
1521
)
1622

17-
func newTestConnector(t *testing.T, ctx context.Context) *MySqlConnector {
23+
func startBinlogStream(t *testing.T, ctx context.Context, c *MySqlConnector) *replication.BinlogStreamer {
1824
t.Helper()
19-
flavor := protos.MySqlFlavor_MYSQL_MYSQL
20-
if internal.MySQLTestVersionIsMaria() {
21-
flavor = protos.MySqlFlavor_MYSQL_MARIA
25+
26+
var syncer *replication.BinlogSyncer
27+
var stream *replication.BinlogStreamer
28+
if internal.MySQLTestVersionIsMySQLPos() {
29+
pos, err := c.GetMasterPos(ctx)
30+
require.NoError(t, err)
31+
syncer, stream, _, _, err = c.startCdcStreamingFilePos(ctx, pos, nil)
32+
require.NoError(t, err)
33+
} else {
34+
gset, err := c.GetMasterGTIDSet(ctx)
35+
require.NoError(t, err)
36+
syncer, stream, _, _, err = c.startCdcStreamingGtid(ctx, gset, nil)
37+
require.NoError(t, err)
2238
}
23-
connector, err := NewMySqlConnector(ctx, &protos.MySqlConfig{
24-
Host: internal.MySQLTestHost(),
25-
Port: internal.MySQLTestPort(),
26-
User: "root",
27-
Password: internal.MySQLTestRootPasswordWithFallback("cipass"),
28-
Database: "mysql",
29-
DisableTls: true,
30-
Flavor: flavor,
31-
})
32-
require.NoError(t, err)
33-
t.Cleanup(func() {
34-
if err := connector.Close(); err != nil {
35-
t.Logf("connector close failed: %v", err)
36-
}
37-
})
38-
return connector
39+
t.Cleanup(syncer.Close)
40+
return stream
3941
}
4042

41-
func createTestDB(t *testing.T, ctx context.Context, c *MySqlConnector, dbName string) {
43+
func waitForQueryEventContaining(
44+
t *testing.T, ctx context.Context, stream *replication.BinlogStreamer, marker string,
45+
) ([]byte, string) {
4246
t.Helper()
43-
quoted := "`" + dbName + "`"
44-
_, err := c.Execute(ctx, "DROP DATABASE IF EXISTS "+quoted)
47+
48+
streamCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
49+
defer cancel()
50+
for {
51+
event, err := stream.GetEvent(streamCtx)
52+
require.NoError(t, err, "did not observe QueryEvent containing %q in the binlog", marker)
53+
if qe, ok := event.Event.(*replication.QueryEvent); ok && strings.Contains(string(qe.Query), marker) {
54+
// status vars alias into the event buffer, which go-mysql may reuse
55+
return slices.Clone(qe.StatusVars), string(qe.Query)
56+
}
57+
}
58+
}
59+
60+
func TestANSIQuotesDDLParsedFromBinlog(t *testing.T) {
61+
t.Parallel()
62+
ctx := t.Context()
63+
connector := newTestConnector(t, ctx)
64+
65+
dbName := testDBName("ansi_quotes_binlog")
66+
createTestDB(t, ctx, connector, dbName)
67+
_, err := connector.Execute(ctx, "CREATE TABLE `"+dbName+"`.`t` (id INT PRIMARY KEY)")
4568
require.NoError(t, err)
46-
_, err = c.Execute(ctx, "CREATE DATABASE "+quoted)
69+
70+
stream := startBinlogStream(t, ctx, connector)
71+
72+
// ANSI_QUOTES makes "..." an identifier quote, so the ALTER below only parses
73+
// when the mode is passed to the parser as well
74+
guid := uuid.NewString()
75+
_, err = connector.Execute(ctx, `SET SESSION sql_mode='ANSI_QUOTES'`)
4776
require.NoError(t, err)
48-
t.Cleanup(func() {
49-
if _, err := c.Execute(context.Background(), "DROP DATABASE IF EXISTS "+quoted); err != nil {
50-
t.Logf("drop test db %s failed: %v", dbName, err)
51-
}
52-
})
77+
78+
ddl := fmt.Sprintf(`/* %s */ ALTER TABLE "%s"."t" ADD COLUMN "c1" INT`, guid, dbName)
79+
_, err = connector.Execute(ctx, ddl)
80+
require.NoError(t, err)
81+
82+
statusVars, query := waitForQueryEventContaining(t, ctx, stream, guid)
83+
84+
// Validate extraction
85+
mode, ok := sqlModeFromStatusVars(statusVars)
86+
require.True(t, ok)
87+
require.NotZero(t, mode&uint64(tidbmysql.ModeANSIQuotes), "ANSI_QUOTES missing from binlog status vars")
88+
89+
// Validate parsing
90+
mysqlParser := parser.New()
91+
setParserSQLModeFromStatusVars(mysqlParser, statusVars)
92+
stmts, _, err := mysqlParser.ParseSQL(query)
93+
require.NoError(t, err)
94+
require.Len(t, stmts, 1)
95+
require.IsType(t, &ast.AlterTableStmt{}, stmts[0])
96+
97+
// Validate parsing fails without the mode
98+
_, _, err = parser.New().ParseSQL(query)
99+
require.Error(t, err)
100+
101+
// Status vars with codes > 1 are written after sql_mode (code 1). Every
102+
// QueryEvent already carries catalog (2) and charset (4); force a few more —
103+
// auto_increment (3), time_zone (5), lc_time_names (7) — to confirm a fuller
104+
// status-var block still doesn't interfere with extracting sql_mode.
105+
_, err = connector.Execute(ctx,
106+
`SET SESSION auto_increment_increment=7, auto_increment_offset=3, time_zone='+05:00', lc_time_names='fr_FR'`)
107+
require.NoError(t, err)
108+
109+
guid = uuid.NewString()
110+
ddl = fmt.Sprintf(`/* %s */ ALTER TABLE "%s"."t" ADD COLUMN "c2" INT`, guid, dbName)
111+
_, err = connector.Execute(ctx, ddl)
112+
require.NoError(t, err)
113+
114+
statusVars, _ = waitForQueryEventContaining(t, ctx, stream, guid)
115+
116+
mode, ok = sqlModeFromStatusVars(statusVars)
117+
require.True(t, ok)
118+
require.NotZero(t, mode&uint64(tidbmysql.ModeANSIQuotes), "ANSI_QUOTES missing from binlog status vars with extra status vars present")
53119
}
54120

55121
func TestParseSQLParsesTrailingNull(t *testing.T) {
@@ -101,8 +167,8 @@ func TestGetTableSchemaCaseSensitiveIdentifiers(t *testing.T) {
101167
require.NoError(t, err)
102168
require.Equal(t, int64(0), lctn)
103169

104-
lowerDB := "cs"
105-
upperDB := "CS_IDS_TEST"
170+
lowerDB := testDBName("cs")
171+
upperDB := testDBName("CS_IDS_TEST")
106172
createTestDB(t, ctx, connector, lowerDB)
107173
createTestDB(t, ctx, connector, upperDB)
108174

@@ -151,7 +217,7 @@ func TestGetTableSchemaPrimaryKeyVariants(t *testing.T) {
151217
ctx := t.Context()
152218
connector := newTestConnector(t, ctx)
153219

154-
dbName := "pk_variants_test"
220+
dbName := testDBName("pk_variants")
155221
createTestDB(t, ctx, connector, dbName)
156222

157223
exec := func(sql string) {
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package connmysql
2+
3+
import (
4+
"context"
5+
"strings"
6+
"testing"
7+
8+
"github.com/stretchr/testify/require"
9+
10+
"github.com/PeerDB-io/peerdb/flow/internal"
11+
"github.com/PeerDB-io/peerdb/flow/pkg/common"
12+
)
13+
14+
func newTestConnector(t *testing.T, ctx context.Context) *MySqlConnector {
15+
t.Helper()
16+
flavor, mechanism := internal.MySQLTestFlavorAndMechanism(t)
17+
config := internal.GetMySQLConfigFromEnv(flavor, mechanism)
18+
connector, err := NewMySqlConnector(ctx, config)
19+
require.NoError(t, err)
20+
t.Cleanup(func() {
21+
if err := connector.Close(); err != nil {
22+
t.Logf("connector close failed: %v", err)
23+
}
24+
})
25+
require.NoError(t, ConfigureReplication(t, connector, config))
26+
return connector
27+
}
28+
29+
func createTestDB(t *testing.T, ctx context.Context, c *MySqlConnector, dbName string) {
30+
t.Helper()
31+
quoted := "`" + dbName + "`"
32+
_, err := c.Execute(ctx, "DROP DATABASE IF EXISTS "+quoted)
33+
require.NoError(t, err)
34+
_, err = c.Execute(ctx, "CREATE DATABASE "+quoted)
35+
require.NoError(t, err)
36+
t.Cleanup(func() {
37+
if _, err := c.Execute(context.Background(), "DROP DATABASE IF EXISTS "+quoted); err != nil {
38+
t.Logf("drop test db %s failed: %v", dbName, err)
39+
}
40+
})
41+
}
42+
43+
func testDBName(mnemonic string) string {
44+
return mnemonic + "_" + strings.ToLower(common.RandomString(8))
45+
}

flow/connectors/mysql/rds_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
)
1111

1212
func TestAwsRDSIAMAuthConnectForMYSQL(t *testing.T) {
13+
internal.SkipRDSIAMAuthIfRequested(t)
1314
internal.SetupRDSIAMAuthAWSCredentials(t)
1415
conn := internal.RDSIAMAuthMySQLTestConnectionInfo(t)
1516
mysqlConnector, err := NewMySqlConnector(t.Context(),
@@ -27,6 +28,7 @@ func TestAwsRDSIAMAuthConnectForMYSQL(t *testing.T) {
2728
}
2829

2930
func TestAwsRDSIAMAuthConnectForMYSQLViaProxy(t *testing.T) {
31+
internal.SkipRDSIAMAuthIfRequested(t)
3032
internal.SetupRDSIAMAuthAWSCredentials(t)
3133
conn := internal.RDSIAMAuthMySQLTestConnectionInfo(t)
3234
mysqlConnector, err := NewMySqlConnector(t.Context(),

0 commit comments

Comments
 (0)