Support EXPLAIN INSERT

This commit is contained in:
Philip O'Toole
2026-01-06 23:16:04 -05:00
committed by GitHub
parent f607884334
commit 9589c3caa5
9 changed files with 137 additions and 13 deletions

View File

@@ -1,3 +1,8 @@
## v9.3.11 (January 6th 2026)
### Implementation changes and bug fixes
- [PR #2438](https://github.com/rqlite/rqlite/pull/2438): Correctly handle `EXPLAIN QUERY PLAN` for mutations. Fixes issue [#2433](https://github.com/rqlite/rqlite/issues/2433).
- [PR #2436](https://github.com/rqlite/rqlite/pull/2436): Add unit testing for EXPLAIN SELECT at DB level.
## v9.3.10 (January 5th 2026)
### Implementation changes and bug fixes
- [PR #2427](https://github.com/rqlite/rqlite/pull/2427): Remove any temporary WAL files if persisting a Snapshot fails or is not even invoked.

View File

@@ -440,6 +440,7 @@ type Statement struct {
Parameters []*Parameter `protobuf:"bytes,2,rep,name=parameters,proto3" json:"parameters,omitempty"`
ForceQuery bool `protobuf:"varint,3,opt,name=forceQuery,proto3" json:"forceQuery,omitempty"`
ForceStall bool `protobuf:"varint,4,opt,name=forceStall,proto3" json:"forceStall,omitempty"`
SqlExplain bool `protobuf:"varint,5,opt,name=sql_explain,json=sqlExplain,proto3" json:"sql_explain,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -502,6 +503,13 @@ func (x *Statement) GetForceStall() bool {
return false
}
func (x *Statement) GetSqlExplain() bool {
if x != nil {
return x.SqlExplain
}
return false
}
type Request struct {
state protoimpl.MessageState `protogen:"open.v1"`
Transaction bool `protobuf:"varint,1,opt,name=transaction,proto3" json:"transaction,omitempty"`
@@ -2094,7 +2102,7 @@ const file_command_proto_rawDesc = "" +
"\x01y\x18\x04 \x01(\fH\x00R\x01y\x12\x0e\n" +
"\x01s\x18\x05 \x01(\tH\x00R\x01s\x12\x12\n" +
"\x04name\x18\x06 \x01(\tR\x04nameB\a\n" +
"\x05value\"\x91\x01\n" +
"\x05value\"\xb2\x01\n" +
"\tStatement\x12\x10\n" +
"\x03sql\x18\x01 \x01(\tR\x03sql\x122\n" +
"\n" +
@@ -2105,7 +2113,9 @@ const file_command_proto_rawDesc = "" +
"forceQuery\x12\x1e\n" +
"\n" +
"forceStall\x18\x04 \x01(\bR\n" +
"forceStall\"\xa7\x01\n" +
"forceStall\x12\x1f\n" +
"\vsql_explain\x18\x05 \x01(\bR\n" +
"sqlExplain\"\xa7\x01\n" +
"\aRequest\x12 \n" +
"\vtransaction\x18\x01 \x01(\bR\vtransaction\x122\n" +
"\n" +

View File

@@ -20,6 +20,7 @@ message Statement {
repeated Parameter parameters = 2;
bool forceQuery = 3;
bool forceStall = 4;
bool sql_explain = 5;
}
message Request {

View File

@@ -51,13 +51,15 @@ func Process(stmts []*proto.Statement, rwrand, rwtime bool) (retErr error) {
lowered := strings.ToLower(stmts[i].Sql)
if (!rwtime || !ContainsTime(lowered)) &&
(!rwrand || !ContainsRandom(lowered)) &&
!ContainsReturning(lowered) {
!ContainsReturning(lowered) &&
!ContainsExplain(lowered) {
continue
}
parsed, err := rsql.NewParser(strings.NewReader(stmts[i].Sql)).ParseStatement()
if err != nil {
continue
}
_, stmts[i].SqlExplain = parsed.(*sql.ExplainStatement)
rewriter := NewRewriter()
rewriter.RewriteRand = rwrand
rewriter.RewriteTime = rwtime
@@ -110,6 +112,13 @@ func ContainsReturning(stmt string) bool {
return strings.Contains(stmt, "returning ")
}
// ContainsExplain returns true if the statement contains an EXPLAIN clause.
// The function performs a lower-case comparison so it is up to the caller to
// ensure the statement is lower-cased.
func ContainsExplain(stmt string) bool {
return strings.Contains(stmt, "explain ")
}
// Rewriter rewrites SQL statements.
type Rewriter struct {
RewriteRand bool

View File

@@ -1168,7 +1168,7 @@ func (db *DB) queryWithConn(ctx context.Context, req *command.Request, xTime boo
allRows = append(allRows, rows)
continue
}
if !readOnly {
if !readOnly && !stmt.SqlExplain {
stats.Add(numQueryErrors, 1)
rows = &command.QueryRows{
Error: "attempt to change database via query operation",

View File

@@ -1443,16 +1443,13 @@ func (s *Service) handleQuery(w http.ResponseWriter, r *http.Request, qp QueryPa
}
stats.Add(numQueryStmtsRx, int64(len(queries)))
// No point rewriting queries if they don't go through the Raft log, since they
// will never be replayed from the log anyway.
if qp.Level() == proto.ConsistencyLevel_STRONG {
if !qp.NoParse() {
fmt.Println("Processing queries:", queries)
if err := sql.Process(queries, qp.NoRewriteRandom(), !qp.NoRewriteTime()); err != nil {
http.Error(w, fmt.Sprintf("SQL rewrite: %s", err.Error()), http.StatusInternalServerError)
return
}
}
}
resp := NewResponse()
resp.Results.AssociativeJSON = qp.Associative()

View File

@@ -2153,13 +2153,17 @@ func (s *Store) Noop(id string) (raft.ApplyFuture, error) {
}
// RORWCount returns the number of read-only and read-write statements in the
// given ExecuteQueryRequest.
// given ExecuteQueryRequest. EXPLAIN statements are always considered read-only.
func (s *Store) RORWCount(eqr *proto.ExecuteQueryRequest) (nRW, nRO int) {
for _, stmt := range eqr.Request.Statements {
sql := stmt.Sql
if sql == "" {
continue
}
if stmt.SqlExplain {
nRO++
continue
}
ro, err := s.db.StmtReadOnly(sql)
if err == nil && ro {
nRO++

View File

@@ -759,6 +759,78 @@ func Test_SingleNodeExecuteQuery_Linearizable(t *testing.T) {
}
}
func Test_SingleNodeExecuteQuery_EXPLAIN(t *testing.T) {
s, ln := mustNewStore(t)
defer ln.Close()
if err := s.Open(); err != nil {
t.Fatalf("failed to open single-node store: %s", err.Error())
}
if err := s.Bootstrap(NewServer(s.ID(), s.Addr(), true)); err != nil {
t.Fatalf("failed to bootstrap single-node store: %s", err.Error())
}
defer s.Close(true)
_, err := s.WaitForLeader(10 * time.Second)
if err != nil {
t.Fatalf("Error waiting for leader: %s", err)
}
er := executeRequestFromString(`CREATE TABLE foo (id INTEGER NOT NULL PRIMARY KEY, name TEXT)`,
false, false)
_, _, err = s.Execute(er)
if err != nil {
t.Fatalf("failed to execute on single node: %s", err.Error())
}
// Simple read-only statement.
eqr := executeQueryRequestFromString("EXPLAIN QUERY PLAN SELECT * FROM foo",
proto.ConsistencyLevel_WEAK, false, false)
eqr.Request.Statements[0].SqlExplain = true
resp, _, _, err := s.Request(eqr)
if err != nil {
t.Fatalf("failed to perform EXPLAIN SELECT on single node: %s", err.Error())
}
if !strings.Contains(asJSON(resp), "SCAN foo") { // Simple check that it looks right
t.Fatalf("unexpected results for EXPLAIN QUERY PLAN\ngot: %s", asJSON(resp))
}
// SQLite C code considers this a read-write statement so check that
// the Store handles this by converting to query.
eqr = executeQueryRequestFromString(`EXPLAIN QUERY PLAN INSERT INTO foo(name) VALUES("fiona")`,
proto.ConsistencyLevel_WEAK, false, false)
eqr.Request.Statements[0].SqlExplain = true
resp, _, _, err = s.Request(eqr)
if err != nil {
t.Fatalf("failed to perform EXPLAIN INSERT on single node: %s", err.Error())
}
if !strings.Contains(asJSON(resp), "columns") { // Simple check that it looks right
t.Fatalf("unexpected results for EXPLAIN QUERY PLAN\ngot: %s", asJSON(resp))
}
// Check that EXPLAIN sent directory to query endpoint also works OK.
qr := queryRequestFromString("EXPLAIN QUERY PLAN SELECT * FROM foo", false, false)
qr.Level = proto.ConsistencyLevel_WEAK
qr.Request.Statements[0].SqlExplain = true
rows, _, _, err := s.Query(qr)
if err != nil {
t.Fatalf("failed to perform EXPLAIN SELECT on single node: %s", err.Error())
}
if !strings.Contains(asJSON(rows), "SCAN foo") { // Simple check that it looks right
t.Fatalf("unexpected results for EXPLAIN QUERY PLAN\ngot: %s", asJSON(rows))
}
qr = queryRequestFromString(`EXPLAIN QUERY PLAN INSERT INTO foo(name) VALUES("fiona")`, false, false)
qr.Level = proto.ConsistencyLevel_WEAK
qr.Request.Statements[0].SqlExplain = true
rows, _, _, err = s.Query(qr)
if err != nil {
t.Fatalf("failed to perform EXPLAIN SELECT on single node: %s", err.Error())
}
if !strings.Contains(asJSON(rows), "columns") { // Simple check that it looks right
t.Fatalf("unexpected results for EXPLAIN QUERY PLAN\ngot: %s", asJSON(rows))
}
}
func Test_SingleNodeExecuteQuery_RETURNING(t *testing.T) {
s, ln := mustNewStore(t)
defer ln.Close()

View File

@@ -942,6 +942,32 @@ func Test_SingleNode_RETURNING_KeywordAsIdent(t *testing.T) {
}
}
func Test_SingleNode_EXPLAIN(t *testing.T) {
node := mustNewLeaderNode("leader1")
defer node.Deprovision()
_, err := node.Execute(`CREATE TABLE foo (id integer not null primary key, name text)`)
if err != nil {
t.Fatalf(`CREATE TABLE failed: %s`, err.Error())
}
res, err := node.Query(`EXPLAIN QUERY PLAN INSERT INTO foo(name) VALUES("declan")`)
if err != nil {
t.Fatalf(`EXPLAIN failed: %s`, err.Error())
}
if !strings.Contains(res, "notused") {
t.Fatalf("EXPLAIN result does not appear valid: %s", res)
}
res, err = node.Query(`EXPLAIN QUERY PLAN SELECT * FROM foo`)
if err != nil {
t.Fatalf(`EXPLAIN failed: %s`, err.Error())
}
if !strings.Contains(res, "notused") {
t.Fatalf("EXPLAIN result does not appear valid: %s", res)
}
}
func Test_SingleNodeQueued(t *testing.T) {
node := mustNewLeaderNode("leader1")
defer node.Deprovision()