Skip to content

Fuzz expression grouping against PostgreSQL and SQLite - #2839

Merged
LucaCappelletti94 merged 5 commits into
mainfrom
expr-shape-fuzz
Nov 9, 2026
Merged

LucaCappelletti94 merged 5 commits into
mainfrom
expr-shape-fuzz

Conversation

@LucaCappelletti94

Copy link
Copy Markdown
Contributor

Several recent PRs, mine and other contributors', showed that the expression tree is often built with wrong operator priorities. I hit the same problem writing a semantic expression evaluator, which kept disagreeing with the engine because of wrong ASTs.

The existing oracle targets only check that sqlparser accepts what PostgreSQL and SQLite accept. fuzz_postgres_expr and fuzz_sqlite_expr also check the tree: they print it fully parenthesized and fail when the engine reads that text differently from the original (pg_query parse trees for PostgreSQL, EXPLAIN bytecode for SQLite).

The inputs are operator chains built from a small hand-written Grammar of tokens per dialect, so nearly every input is valid SQL and the fuzzing goes into grouping. The flip side is that only those tokens, only SELECT <expr> FROM t and only four levels of nesting are covered. We will expand it over time, given this is already quite enough for now.

This already found many AST errors, so I will open a tracking issue for them, populated automatically with Opus, and plan the fixes once the fuzzer converges.

@LucaCappelletti94 LucaCappelletti94 added fuzzer A bug found by the fuzzer PostgreSQL SQLite labels Nov 7, 2026
@codecov-commenter

codecov-commenter commented Nov 7, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 83.16%. Comparing base (65972d7) to head (b066e27).
⚠️ Report is 2 commits behind head on main.

Additional details and impacted files
@@           Coverage Diff           @@
##             main    #2839   +/-   ##
=======================================
  Coverage   83.16%   83.16%           
=======================================
  Files          42       42           
  Lines       36420    36420           
  Branches    36420    36420           
=======================================
  Hits        30287    30287           
  Misses       2636     2636           
  Partials     3497     3497           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

tree
}

fuzz_target!(|case: Case| {

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.

I'd propose this check: without it, Display bugs are reported as grouping errors.

  let Ok(original) = pg_query::parse(&sql) else { return };
  let Ok(mut statements) = Parser::parse_sql(&PostgreSqlDialect {}, &sql) else { return };
  let [statement] = statements.as_mut_slice() else { return };

  let printed = statement.to_string();
  let Ok(baseline) = pg_query::parse(&printed) else {
      panic!("PostgreSQL rejects sqlparser's output\n  sql: {sql}\n  printed: {printed}");
  };
  if tree(&original) != tree(&baseline) {
      // The tokens changed: a Display bug, or sqlparser parsed a different construct (e.g. a keyword taken as an alias).
      panic!("sqlparser reads or prints the statement differently\n  sql: {sql}\n  printed: {printed}");
  }

  parenthesize(statement); // Only wraps in `Nested`, no rewrites.
  let grouped = statement.to_string();
  let regrouped = pg_query::parse(&grouped);
  if !regrouped.as_ref().is_ok_and(|r| tree(&baseline) == tree(r)) {
      // Same tokens, only the parentheses differ, so this is a real grouping bug.
      panic!("sqlparser groups the expression differently\n  sql: {sql}\n  grouped: {grouped}");
  }

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done as proposed, plus some additional tweaking. I must admit this thing feels like the Pozzo di San Patrizio of bugs

///
/// A custom operator prints as `OPERATOR(name)`, so its name reaches the engine even when the bare text would lex differently.
/// Booleans print as `TRUE` and `FALSE` like the generated text, since SQLite's constant reuse compares token text.
pub fn parenthesize(statement: &mut Statement) {

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.

It rewrites the statement tree, so let's cover it with tests.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Covered

static DB: Db = Db::open();
}

fn check(db: &Db, case: &Case) {

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.

Same as the PostgreSQL target: bytecode depends on how constants are spelled, so 0x1F printing as X'1F' shows up as a grouping difference. The two-step check would help here too: explain(original) vs explain(printed), then explain(printed) vs explain(grouped).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

0x1F printed as X'1F' now fails. TRUE and true compile to different bytecode, so every text has its booleans lowercased before EXPLAIN. The first step also found a real printing bug, 0 NOT NULL + 1 printed as 0 IS NOT NULL + 1.

I'll keep an eye out for others.

@s5dsn-eqee s5dsn-eqee 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.

LGTM!

@LucaCappelletti94

Copy link
Copy Markdown
Contributor Author

Thanks @s5dsn-eqee for the PR, I will be merging this now.

cc @alamb I might also do a separate PR shutting down the cluster fuzz lite job, when I proposed it I had not realized the state of the crate and I expected bugs to be an exception and not the rule. As it stands, it is functionally useless as it gets stuck in the sea of bugs in main and only burns CI time.

@LucaCappelletti94
LucaCappelletti94 added this pull request to the merge queue Nov 9, 2026
Merged via the queue into main with commit 8a176d9 Nov 9, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

fuzzer A bug found by the fuzzer PostgreSQL SQLite

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants