fix(cli): format NOT EXISTS without a nil dereference by jay23606 · Pull Request #591 · ajitpratap0/GoSQLX · GitHub
Skip to content

fix(cli): format NOT EXISTS without a nil dereference - #591

Merged
ajitpratap0 merged 1 commit into
ajitpratap0:mainfrom
jay23606:fix/format-not-exists
Oct 13, 2026
Merged

ajitpratap0 merged 1 commit into
ajitpratap0:mainfrom
jay23606:fix/format-not-exists

Conversation

@jay23606

Copy link
Copy Markdown
Contributor

Fixes #587

Summary

  • `gosqlx format` panicked on any query with `NOT EXISTS (subquery)`. The parser represents it as a `BinaryExpression` with operator `NOT`, the `EXISTS` on the left, and a nil right operand. The formatter passed that nil on, and the fallback called `TokenLiteral` on it.
  • The formatter now writes `NOT` and then the left operand when the right operand is nil. `EXISTS` is already handled.
  • Other binary expressions are unchanged.

Test

  • `TestSQLFormatter_NotExistsDoesNotPanic` formats `... WHERE NOT EXISTS (SELECT ...)` and checks that the output keeps `NOT EXISTS (`. It fails on `main` with the same nil-pointer panic and passes with this change.
  • `go test ./cmd/gosqlx/...` passes. The reported command now prints the query unchanged.

🤖 Generated with Claude Code

@jay23606
jay23606 requested a review from ajitpratap0 as a code owner October 12, 2026 21:44
@vercel

vercel Bot commented Oct 12, 2026

Copy link
Copy Markdown

@github-actions github-actions Bot added the cli label Oct 12, 2026
@codecov-commenter

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@ajitpratap0

Copy link
Copy Markdown
Owner

Pre-merge review: #591 fix(cli): format NOT EXISTS without a nil dereference

Thanks @jay23606. This is a small, correct fix. I ran it against a set of inputs built to break it and against the whole testdata/ corpus, comparing origin/main with this branch.

Verdict: MERGE. Nothing blocks. The suggestions below are cheap and optional. The larger problems I found all exist on main already, so they should get their own issues rather than grow this PR.

What the PR claims, and whether it holds

Claim Result
The parser builds NOT EXISTS (…) as BinaryExpression{Operator:"NOT", Left: *ExistsExpression, Right: nil} Confirmed. pkg/sql/parser/expressions_literal.go is the only place that builds a NOT BinaryExpression, and it always uses this shape. Plain NOT x becomes a UnaryExpression, so the new guard cannot catch any other expression.
EXISTS is already handled Confirmed. case *ast.ExistsExpression exists at sql_formatter.go:850. Point 2 in #587 ("no ExistsExpression case") was wrong.
Other binary expressions are unchanged Confirmed. Corpus output is byte-identical to main except for the two files that used to panic.

Adversarial cases (CLI formatSQL, compact and readable, then re-parse and re-format to check idempotence)

On main, 26 of these runs panicked. On this branch, none panic.

Input shape PR output (compact) Re-parses Idempotent
WHERE NOT EXISTS (correlated) … WHERE NOT EXISTS (SELECT 1 FROM u WHERE u.id = t.id) yes yes
SELECT NOT EXISTS (…) AS ne (select list) unchanged yes yes
CASE WHEN NOT EXISTS (…) THEN … unchanged yes yes
HAVING NOT EXISTS (…) unchanged yes yes
NOT NOT EXISTS (…) / NOT (NOT EXISTS (…)) unchanged yes yes
a = 1 AND NOT EXISTS (…) OR b = 2 unchanged yes yes
Nested NOT EXISTS inside a NOT EXISTS subquery unchanged yes yes
NOT EXISTS (WITH c AS (…) SELECT …) unchanged yes yes
UPDATE … WHERE NOT EXISTS, DELETE … WHERE NOT EXISTS, JOIN … ON … AND NOT EXISTS unchanged yes yes
NOT EXISTS (SELECT … UNION SELECT …) … WHERE NOT EXISTS () no n/a. Pre-existing bug; see Deferred item 2
testdata/mysql/19_not_exists.sql, testdata/corpus/tpch/q21_suppliers_kept_waiting.sql panicked on main; now formatted correctly (q21 keeps both the EXISTS and the NOT EXISTS) yes yes

Corpus totals for testdata/**/*.sql (170 files, compact and readable, 340 runs):

  • main: 238 ok, 4 panics, 24 re-parse failures, 74 parse errors on input.
  • PR: 242 ok, 0 panics, 24 re-parse failures (the same ones), 74 parse errors on input.

No regressions.

Should fix before merge (optional, cheap)

  1. cmd/gosqlx/cmd/sql_formatter.go:851: the existing comment // Handle EXISTS (subquery) - NOT EXISTS is handled via UnaryExpression is wrong, and this PR is the right place to fix it. Suggested text: // Handle EXISTS (subquery). NOT EXISTS arrives as BinaryExpression{Operator:"NOT", Right:nil}; see the BinaryExpression case.

  2. Test coverage (sql_formatter_test.go, TestSQLFormatter_NotExistsDoesNotPanic): the test covers one shape, readable mode only, and checks a substring. Two changes would make it much stronger:

    • Make it a table test over a few shapes: WHERE NOT EXISTS, SELECT NOT EXISTS (…) AS x, CASE WHEN NOT EXISTS, HAVING NOT EXISTS, and nested.
    • For each shape, run both Compact: true and Compact: false, then assert that the output re-parses and that formatting it again gives the same output.

    Also, the test never calls p.Release() and never releases the parsed result AST; only the separate astObj is released. The newer pattern in TestSQLFormatter_ParenthesizedExpression (defer p.Release() plus defer ast.ReleaseAST(tree)) is cleaner. Calling (&Formatter{Opts: CLIFormatterOptions{…}}).formatSQL(sql) would go further and test the real CLI path.

  3. Optional hardening, item 3 of the fix proposed in [BUG] gosqlx format panics (nil pointer) on NOT EXISTS; CLI formatter lacks ExistsExpression case #587: add if expr == nil { return nil } at the top of formatExpression (sql_formatter.go:619). With it, an unhandled nil sub-expression can no longer crash the CLI through expr.TokenLiteral() in the default: branch (:894). I found no other nil shape that the parser produces today, so this is defensive only.

Deferred: real bugs, pre-existing on main, out of scope here (I recommend filing issues)

  1. ast.SQL() and pkg/formatter render NOT EXISTS as SQL that does not parse. [BUG] gosqlx format panics (nil pointer) on NOT EXISTS; CLI formatter lacks ExistsExpression case #587 says the library renderers are fine. They are not.

    • Input: SELECT a FROM t WHERE NOT EXISTS (SELECT 1 FROM u WHERE u.id = t.id).
    • tree.SQL() returns SELECT a FROM t WHERE NOT (EXISTS (SELECT 1 FROM u WHERE u.id = t.id) NOT ). formatter.FormatString gives the same text with ;.
    • Re-parsing either output fails with E2002 … expected ), got NOT.
    • Cause: in pkg/sql/ast/sql.go, BinaryExpression.writeSQL takes the generic b.Not path, NOT ( + Left + + op + + Right + ), which writes op = "NOT" and an empty Right. It needs a branch for Operator == "NOT" && Right == nil that writes NOT followed by Left.
    • This bug was already there before the perf(ast): make SQL() serialization allocation-light (drop fmt/ToUpper, fix builderPool, writer-based rendering) (#558) #575 rewrite. It affects every NOT EXISTS going through SQL(), pkg/formatter, and anything else that round-trips through them. I recommend a new high-priority issue.
  2. The CLI formatter drops set-operation subqueries. EXISTS (…), IN (…) and the scalar (…) subquery only format *ast.SelectStatement. A *ast.SetOperation body (UNION/EXCEPT/INTERSECT) is silently dropped:

    • WHERE EXISTS (SELECT 1 FROM u UNION ALL SELECT 2 FROM v) becomes WHERE EXISTS ().
    • a IN (SELECT … UNION SELECT …) becomes a IN ().

    Neither output parses. pkg/formatter handles both correctly. I recommend a new issue.

  3. Many expression types are missing from the CLI formatExpression switch. None of them panic. They fall back to TokenLiteral() and corrupt the SQL without any error. These account for the 24 corpus re-parse failures:

    • CastExpression: CAST(a AS INT) and a::int both become CAST.
    • ExtractExpression: becomes EXTRACT.
    • IntervalExpression: becomes INTERVAL.
    • AnyExpression / AllExpression: a = ANY (subq) becomes ANY, and the left operand is lost too.
    • TupleExpression: (a, b) IN ((1, 2), (3, 4)) becomes TUPLE IN (TUPLE, TUPLE).
    • ArrayConstructorExpression: becomes ARRAY.
    • ArraySubscriptExpression / ArraySliceExpression: become [] and [:].
    • RollupExpression / CubeExpression / GroupingSetsExpression: the argument list is dropped.
    • NamedArgument: f(x => 1) becomes f(x). pkg/formatter has the same bug.

    pkg/formatter renders all of these correctly except NamedArgument. I recommend one umbrella issue. The durable fix is to make gosqlx format delegate to pkg/formatter instead of keeping a second renderer. Divergence between the two renderers is behind [BUG] GoSQLX does not support parentheses #519, [BUG] ClickHouse parametric aggregates drop params group on render (quantile(0.5)(x) → quantile(x)) #531, [BUG] insert with CTE #537 and [BUG] gosqlx format panics (nil pointer) on NOT EXISTS; CLI formatter lacks ExistsExpression case #587.

Overlap with in-flight PRs #583 and #584

Conventions

Security and supply chain (fork PR)

No concerns. Safe to run CI: yes.

  • Only two files change: cmd/gosqlx/cmd/sql_formatter.go (+6) and cmd/gosqlx/cmd/sql_formatter_test.go (+30).
  • Nothing touches .github/, scripts, the Taskfile, go.mod or go.sum, build tags or init().
  • No network, file or exec calls. The test does no I/O beyond in-memory parsing.
  • No non-ASCII or hidden characters in the diff. One commit, authored by the contributor's GitHub noreply address.
  • On input handling, the change removes a crash an attacker could trigger. A CLI user or CI job formatting untrusted SQL could abort the process with any NOT EXISTS. The new code adds one bounded recursion into Left, which is no deeper than existing cases and already falls under the parser's recursion limits.

Evidence

  • go test -race -count=1 ./cmd/gosqlx/...: all ok.
  • go vet ./cmd/gosqlx/...: clean. gofmt -l cmd/gosqlx/cmd: clean. golangci-lint run ./cmd/gosqlx/cmd/...: 0 issues.
  • go test -run TestSQLFormatter_NotExistsDoesNotPanic: PASS on the PR. The issue's reproduction panics on main with a nil-pointer dereference at sql_formatter.go:889.
  • Differential corpus run (CLI formatSQL, both modes, re-parse and idempotence), main vs PR: the only differences are mysql/19_not_exists.sql and corpus/tpch/q21_suppliers_kept_waiting.sql, which go from PANIC to ok.
  • Library check on main: tree.SQL() returns SELECT a FROM t WHERE NOT (EXISTS (SELECT 1 FROM u WHERE u.id = t.id) NOT ), and re-parsing it fails with E2002 expected ), got NOT.

Independent pre-merge review (adversarial correctness + security/supply-chain), run because the automated claude-review workflow skips fork PRs. Generated with Claude Code.

@vercel

vercel Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
gosqlx-website Ready Ready Preview Oct 13, 2026 11:12am UTC

The parser represents NOT EXISTS (subquery) as a NOT with the EXISTS on the
left and no right operand. The formatter passed that nil right operand on,
and the fallback called TokenLiteral on it, so gosqlx format panicked on any
query containing NOT EXISTS.

Format the NOT followed by its left operand in that case, and add a test.

Fixes #587

This branch was successfully deployed

1 active deployment
Preview — 396eaf00 Deployed Oct 13, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] gosqlx format panics (nil pointer) on NOT EXISTS; CLI formatter lacks ExistsExpression case

3 participants