Skip to content

Fix EXPLAIN on MySQL 9 and MariaDB, add live-database integration tests - #18

Merged
KARTIKrocks merged 2 commits into
mainfrom
fix/explain-mysql9-mariadb
Jul 9, 2026
Merged

Fix EXPLAIN on MySQL 9 and MariaDB, add live-database integration tests#18
KARTIKrocks merged 2 commits into
mainfrom
fix/explain-mysql9-mariadb

Conversation

@KARTIKrocks

@KARTIKrocks KARTIKrocks commented Jul 9, 2026

Copy link
Copy Markdown
Owner

The explain package failed on every query against MySQL 9 and MariaDB. Three independent defects, all in analyzeMySQL, all invisible to unit tests because they concern what a real server actually returns:

  • MySQL 9 defaults @@explain_format to TREE, so a plain EXPLAIN returns one free-text column instead of the twelve-column plan the code scanned. Request EXPLAIN FORMAT=TRADITIONAL, which MySQL 5.7/8/9 and MariaDB all accept, so no server version detection is needed.
  • MariaDB emits ten plan columns where MySQL emits twelve (no partitions, no filtered), breaking the positional Scan. Address columns by name.
  • MySQL and MariaDB reject every statement inside a READ ONLY transaction with error 1792, including an EXPLAIN that only plans it, so WithAllowDML never worked there. Use a regular transaction for DML; safety still rests on validate(), on plain EXPLAIN never executing the statement, and on the unconditional rollback. Postgres allows it and keeps its read-only tx.

Also drops a false positive: a UNION RESULT row names a temporary table (<union1,2>) with type=ALL and no key, and was reported as an unindexed full table scan.

validate() now returns the statement kind so the MySQL path can choose its transaction mode, and the per-row rules move into mysqlRowIssues to keep analyzeMySQL under the gocyclo limit.

test/integration is a new unpublished module that runs explain against live PostgreSQL 18.3, MySQL 9.7.1 and MariaDB 12.3.2 via docker compose, behind an integration build tag so go test ./... stays Docker-free. Verified these are real guards: with the fix reverted, every MySQL and MariaDB case fails and every Postgres case passes.

Separately, the satellite modules were compiling against the published core rather than the working tree, because make release-prep had stripped their replace directives and nothing restored them. A breaking change to analyzer/ or middleware/ would have passed CI green. A committed go.work now handles local resolution for all ten modules; no go.mod carries a replace, and release-prep is removed in favour of the manual steps in CONTRIBUTING. Consumers are unaffected (GOWORK=off reproduces their build).

Summary

What does this PR change, and why?

Closes #

Type of change

  • Bug fix
  • New detection rule
  • New integration / parser
  • Feature / enhancement
  • Docs only
  • Refactor / chore

Checklist

  • make ci passes (fmt-check, vet, lint, test-race) across all modules
  • Added/updated tests (and, where practical, a failure-mode check)
  • Updated docs as needed (README.md, AGENTS.md, .sqlguard.example.yml)
  • Added an entry under ## [Unreleased] in CHANGELOG.md
  • No new third-party deps in analyzer / middleware / reporter
  • Findings stay redaction-safe (no raw literals leak into a Result)

Notes for reviewers

Anything reviewers should focus on — tricky areas, trade-offs, follow-ups.

Summary by CodeRabbit

  • New Features

    • Added integration testing support for PostgreSQL, MySQL, and MariaDB.
    • Added safer EXPLAIN handling with optional DML analysis support and clearer plan output behavior.
  • Bug Fixes

    • Improved database plan analysis accuracy across MySQL/MariaDB differences.
    • Fixed false positives for UNION-related temporary tables and better handled read-only transaction behavior.
  • Documentation

    • Updated contributor, workflow, and release notes to reflect the new multi-module and integration test setup.
The explain package failed on every query against MySQL 9 and MariaDB.
Three independent defects, all in analyzeMySQL, all invisible to unit
tests because they concern what a real server actually returns:

- MySQL 9 defaults @@explain_format to TREE, so a plain EXPLAIN returns
  one free-text column instead of the twelve-column plan the code scanned.
  Request EXPLAIN FORMAT=TRADITIONAL, which MySQL 5.7/8/9 and MariaDB all
  accept, so no server version detection is needed.
- MariaDB emits ten plan columns where MySQL emits twelve (no partitions,
  no filtered), breaking the positional Scan. Address columns by name.
- MySQL and MariaDB reject every statement inside a READ ONLY transaction
  with error 1792, including an EXPLAIN that only plans it, so WithAllowDML
  never worked there. Use a regular transaction for DML; safety still rests
  on validate(), on plain EXPLAIN never executing the statement, and on the
  unconditional rollback. Postgres allows it and keeps its read-only tx.

Also drops a false positive: a UNION RESULT row names a temporary table
(<union1,2>) with type=ALL and no key, and was reported as an unindexed
full table scan.

validate() now returns the statement kind so the MySQL path can choose its
transaction mode, and the per-row rules move into mysqlRowIssues to keep
analyzeMySQL under the gocyclo limit.

test/integration is a new unpublished module that runs explain against live
PostgreSQL 18.3, MySQL 9.7.1 and MariaDB 12.3.2 via docker compose, behind
an `integration` build tag so `go test ./...` stays Docker-free. Verified
these are real guards: with the fix reverted, every MySQL and MariaDB case
fails and every Postgres case passes.

Separately, the satellite modules were compiling against the *published*
core rather than the working tree, because `make release-prep` had stripped
their replace directives and nothing restored them. A breaking change to
analyzer/ or middleware/ would have passed CI green. A committed go.work now
handles local resolution for all ten modules; no go.mod carries a replace,
and release-prep is removed in favour of the manual steps in CONTRIBUTING.
Consumers are unaffected (GOWORK=off reproduces their build).
@KARTIKrocks

Copy link
Copy Markdown
Owner Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Full review finished.


Your plan includes PR reviews subject to rate limits. More reviews will be available in 59 minutes.

@coderabbitai

coderabbitai Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

This PR reworks MySQL/MariaDB EXPLAIN handling in explain/explain.go (statement-kind validation, DML-aware read-only transactions, column-name-based row parsing, TRADITIONAL format), adds a new test/integration module with Docker Compose services and live Postgres/MySQL/MariaDB tests, wires go.work-based satellite builds into CI/Makefile/gitignore, and updates docs, changelog, config, and minor dependency versions.

Changes

EXPLAIN fixes, workspace wiring, integration test suite, and docs

Layer / File(s) Summary
EXPLAIN statement-kind validation and MySQL parsing rework
explain/explain.go, explain/explain_test.go
validate now returns statement kind gating DML behind WithAllowDML; analyzeMySQL accepts a dml flag controlling ReadOnly, switches to EXPLAIN FORMAT=TRADITIONAL, and row parsing/issue detection is rewritten to be column-name driven via mysqlRowIssues.
go.work-based satellite build wiring
.gitignore, .github/workflows/ci.yml, Makefile, integrations/gormguard/go.mod, integrations/pgxguard/go.mod, parsers/pgparser/go.mod
Commits go.work/go.work.sum, adds a CI integration job with DB service containers, replaces release-prep with db-up/db-down/test-integration/vet-integration Makefile targets, and bumps a few indirect dependency versions.
New integration test module
test/integration/go.mod, test/integration/docker-compose.yml, test/integration/integration_test.go, test/integration/mysql_test.go, test/integration/postgres_test.go
Adds a standalone Go module with Docker Compose DB services, shared test helpers, and MySQL/MariaDB/Postgres test suites verifying EXPLAIN rule detection, DML refusal/allowance, and format overrides against live databases.
Documentation, changelog, config updates
.coderabbit.yaml, README.md, AGENTS.md, CONTRIBUTING.md, CHANGELOG.md
Updates review-path instructions for integrations/**/explain/**, EXPLAIN safety/dialect docs, contributor guidance on go.work/integration testing/release process, and a new v0.1.1 changelog entry.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

  • KARTIKrocks/sqlguard#1: Both PRs modify .coderabbit.yaml's CodeRabbit review path_instructions, including EXPLAIN/integration handling.
  • KARTIKrocks/sqlguard#2: Both PRs touch the explain subsystem, with this PR further reworking PlanAnalyzer.validate and MySQL EXPLAIN execution/parsing logic.

Poem

A rabbit dug through EXPLAIN's plan,
Found MySQL's rows scanned column-by-column-name,
Read-only locks now bend for DML's request,
Docker whales spin up, put schemas to the test. 🐇🐳
go.work stitched tight, no replace in sight —
Ship it, v0.1.1, and sleep soundly tonight.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 96.30% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main MySQL/MariaDB EXPLAIN fix and the added live integration tests.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/explain-mysql9-mariadb

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 6

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@explain/explain_test.go`:
- Line 34: The test around p.validate(c.query) is discarding the returned
statement kind, so update the table-driven cases to assert kind alongside safe
and err; add wantKind expectations for existing statements and CTE-wrapped DML
where supported, using the validate function’s returned kind to catch fallback
classification regressions. Also include a case that exercises the failure path
for the new behavior where practical so the test proves both accepted and
rejected classification outcomes.

In `@explain/explain.go`:
- Line 247: The BeginTx call in explain.go currently flips transaction mode with
ReadOnly: !dml, which breaks the EXPLAIN safety invariant. Update the EXPLAIN
flow in the relevant transaction setup so it always uses a read-only transaction
in the EXPLAIN path, and keep the always-Rollback behavior intact in the
surrounding EXPLAIN logic. If DML EXPLAIN is not supported under a read-only
transaction, have the EXPLAIN handling in the same code path return an error
instead of relaxing the transaction to read-write.
- Around line 261-268: The tabular EXPLAIN path in explain.go should fail closed
when the expected columns are missing. After rows.Columns() in the EXPLAIN
parsing logic, validate that the required tabular fields such as table are
present in the index map before continuing; if FORMAT=TRADITIONAL returns an
incompatible shape, return an error instead of proceeding with an empty
col("table"). Update the column-validation logic near the rows.Columns()
handling and keep the existing EXPLAIN row parsing flow guarded by this check.

In `@test/integration/go.mod`:
- Around line 13-14: Upgrade the dependency entry for github.com/jackc/pgx/v5 in
the test/integration go.mod module to v5.9.2 or later to address the reported
vulnerability, and keep the github.com/go-sql-driver/mysql requirement as-is
unless you also choose to bump it to the available v1.10.0. Make sure the
version change is applied in the go.mod dependency list so any integration tests
importing pgx/v5 resolve the patched release.

In `@test/integration/integration_test.go`:
- Around line 65-72: The helper exec currently calls db.ExecContext with
context.Background(), so hanging setup statements can block until the test suite
timeout. Update exec to use testCtx(t) or a short per-statement context with a
deadline when executing each statement, so failures surface as context deadline
errors; keep the fix localized in exec and preserve the existing t.Helper and
t.Fatalf behavior.

In `@test/integration/mysql_test.go`:
- Around line 179-180: The cleanup in the MySQL integration test uses exec,
which calls t.Fatalf and can panic when invoked from t.Cleanup in Go 1.21+.
Update the restore path in the test around the SET GLOBAL explain_format setup
to avoid fataling inside the cleanup closure; instead, perform the restore with
inline error handling and log failures with t.Logf (or equivalent) so a failed
cleanup does not obscure the test outcome.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: ba412e75-59e1-45fb-8522-3ca3ffd35ff5

📥 Commits

Reviewing files that changed from the base of the PR and between f012b74 and c9bb54f.

⛔ Files ignored due to path filters (6)
  • go.work is excluded by !**/*.work
  • go.work.sum is excluded by !**/*.sum
  • integrations/gormguard/go.sum is excluded by !**/*.sum
  • integrations/pgxguard/go.sum is excluded by !**/*.sum
  • parsers/pgparser/go.sum is excluded by !**/*.sum
  • test/integration/go.sum is excluded by !**/*.sum
📒 Files selected for processing (18)
  • .coderabbit.yaml
  • .github/workflows/ci.yml
  • .gitignore
  • AGENTS.md
  • CHANGELOG.md
  • CONTRIBUTING.md
  • Makefile
  • README.md
  • explain/explain.go
  • explain/explain_test.go
  • integrations/gormguard/go.mod
  • integrations/pgxguard/go.mod
  • parsers/pgparser/go.mod
  • test/integration/docker-compose.yml
  • test/integration/go.mod
  • test/integration/integration_test.go
  • test/integration/mysql_test.go
  • test/integration/postgres_test.go
Comment thread explain/explain_test.go Outdated
Comment thread explain/explain.go
// (error 1792), including an EXPLAIN that only plans it. Fall back to a
// regular transaction for DML: plain EXPLAIN never executes the statement,
// and the rollback below is what guarantees nothing is committed.
tx, err := p.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: !dml})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ���️ Heavy lift

Keep EXPLAIN transactions read-only.

ReadOnly: !dml makes opt-in DML use a read-write transaction, weakening the documented EXPLAIN safety invariant. If MySQL/MariaDB require this exception, update the repository policy explicitly; otherwise keep the transaction read-only and handle unsupported DML EXPLAIN as an error.

As per path instructions, explain/** must “keep the read-only BeginTx + always-Rollback” and “EXPLAIN must never execute the statement.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@explain/explain.go` at line 247, The BeginTx call in explain.go currently
flips transaction mode with ReadOnly: !dml, which breaks the EXPLAIN safety
invariant. Update the EXPLAIN flow in the relevant transaction setup so it
always uses a read-only transaction in the EXPLAIN path, and keep the
always-Rollback behavior intact in the surrounding EXPLAIN logic. If DML EXPLAIN
is not supported under a read-only transaction, have the EXPLAIN handling in the
same code path return an error instead of relaxing the transaction to
read-write.

Source: Path instructions

Comment thread explain/explain.go
Comment thread test/integration/go.mod Outdated
Comment thread test/integration/integration_test.go
Comment on lines +179 to +180
exec(t, db, `SET GLOBAL explain_format = 'TREE'`)
t.Cleanup(func() { exec(t, db, `SET GLOBAL explain_format = '`+original+`'`) })

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

t.Fatalf inside t.Cleanup will panic.

exec calls t.Fatalf on error, but in Go 1.21+ calling Fatal/Fatalf from a Cleanup function panics with "test executed Fatal(...) method in Cleanup function". If the SET GLOBAL restore fails (e.g., container stopped), the panic obscures the real test result. Handle the error inline with t.Logf instead.

🐛 Proposed fix
 	exec(t, db, `SET GLOBAL explain_format = 'TREE'`)
-	t.Cleanup(func() { exec(t, db, `SET GLOBAL explain_format = '`+original+`'`) })
+	t.Cleanup(func() {
+		_, err := db.ExecContext(context.Background(), `SET GLOBAL explain_format = '`+original+`'`)
+		if err != nil {
+			t.Logf("warning: failed to restore @@explain_format to %q: %v", original, err)
+		}
+	})
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
exec(t, db, `SET GLOBAL explain_format = 'TREE'`)
t.Cleanup(func() { exec(t, db, `SET GLOBAL explain_format = '`+original+`'`) })
exec(t, db, `SET GLOBAL explain_format = 'TREE'`)
t.Cleanup(func() {
_, err := db.ExecContext(context.Background(), `SET GLOBAL explain_format = '`+original+`'`)
if err != nil {
t.Logf("warning: failed to restore @@explain_format to %q: %v", original, err)
}
})
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/integration/mysql_test.go` around lines 179 - 180, The cleanup in the
MySQL integration test uses exec, which calls t.Fatalf and can panic when
invoked from t.Cleanup in Go 1.21+. Update the restore path in the test around
the SET GLOBAL explain_format setup to avoid fataling inside the cleanup
closure; instead, perform the restore with inline error handling and log
failures with t.Logf (or equivalent) so a failed cleanup does not obscure the
test outcome.
Applies four of six review findings; the other two rest on premises that
do not hold against the current code.

Applied:

- explain: fail closed when the MySQL EXPLAIN plan lacks an expected column.
  Previously a plan of the wrong shape made every col() lookup return "" and
  Analyze reported zero issues. Verified by pointing the analyzer at a plain
  EXPLAIN on MySQL 9, which now errors with `unrecognized MySQL EXPLAIN plan
  (missing "table" column; got [EXPLAIN])` rather than silently passing.
- explain: assert the statement kind in TestValidate, including CTE-wrapped
  DML. The kind drives isDML, which picks the transaction mode, so a
  classification regression would quietly reintroduce MySQL error 1792.
- test/integration: give each statement in exec its own deadline, so a wedged
  server surfaces as a context error instead of hanging to the suite timeout.
  Uses a standalone context rather than testCtx so exec stays safe in cleanups.
- test/integration: pgx v5.7.6 -> v5.10.0, mysql v1.9.3 -> v1.10.0. Not a
  security fix — govulncheck reports no advisory against v5.7.6, called or
  otherwise. pgxguard already requires pgx v5.10.0, so the workspace resolved
  it there while go.mod said v5.7.6; the module now resolves identically with
  and without GOWORK.

Not applied:

- "BeginTx ReadOnly: !dml breaks the EXPLAIN safety invariant; error out
  instead." Doing so removes WithAllowDML on MySQL and MariaDB entirely: both
  reject every statement in a READ ONLY transaction with error 1792, including
  a planning-only EXPLAIN. That was the bug this branch fixes. The invariant
  the code actually guarantees — the statement is never executed — is upheld by
  validate(), by never using EXPLAIN ANALYZE, and by the unconditional
  rollback, and is covered by TestMySQL_ExplainDMLDoesNotMutate.
- "t.Fatalf in t.Cleanup can panic on Go 1.21+." It does not. Verified on
  Go 1.26: a Fatalf inside a cleanup fails the test normally and does not skip
  the other registered cleanups. Failing loudly is also right here, since a
  failed restore leaves @@explain_format mutated on a shared server.
@KARTIKrocks
KARTIKrocks merged commit bac4394 into main Jul 9, 2026
16 checks passed
@KARTIKrocks
KARTIKrocks deleted the fix/explain-mysql9-mariadb branch July 9, 2026 16:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

1 participant