Skip to content

fix: analyze a query once per execution, not once per attempt (#67) - #85

Merged
KARTIKrocks merged 2 commits into
mainfrom
fix/errskip-double-analysis
Sep 25, 2026
Merged

KARTIKrocks merged 2 commits into
mainfrom
fix/errskip-double-analysis

Conversation

@KARTIKrocks

@KARTIKrocks KARTIKrocks commented Sep 25, 2026 •

Copy link
Copy Markdown
Owner

User description

Fixes #67.

The bug

wConn.QueryContext / ExecContext analyzed the query before handing it to
the base driver. The existing comment covered a base with no direct
Queryer/Execer at all, but not one that has the entry point and
declines an individual call with driver.ErrSkip. database/sql then falls
back to Prepare+Query, which re-enters through wStmt — and the same
execution is analyzed a second time.

go-sql-driver/mysql returns ErrSkip for every parameterized query unless
interpolateParams=true (off by default), so on MySQL essentially all
application traffic was double-analyzed. The duplicate static finding hid
behind the default one-minute dedup window; the N+1 counter is not deduped, so
every configured threshold was effectively halved — WithN1Detection(10, …)
fired at 5 real queries.

The fix

Call the base first, then analyzeExecuted, which returns without
Check/CheckLatency when the answer is errors.Is(err, driver.ErrSkip).
The prepare path is left as the single analysis point.

Two consequences, both deliberate:

  • Analysis now happens after execution on this path. Nothing consumes
    findings pre-execution.
  • The latency window is read before the rules run, so analysis time cannot
    push a query past the slow-query threshold (it previously sat outside the
    window because Check ran before the timer started).

Second commit: the other "did not run" answers

A review pass found ErrSkip is not the only one, and that the invariant the
first commit writes into AGENTS.md was not yet true:

  • driver.ErrBadConn. database/sql retries the query on another
    connection — twice from the pool, then once on a fresh one — and each attempt
    re-enters the wrapper. Reproduced: 3 base calls, 3 analyses for one
    db.Query. ErrBadConn's contract forbids returning it when the operation
    may have been performed, so a declined attempt ran nothing. Surfaces whenever
    pooled connections go stale — MySQL's wait_timeout, a restart, a failover.
  • Arguments rejected before the base is reached. wStmt's context methods
    analyzed before converting named parameters for a base that predates them, so
    a call failing with sqlguard: driver does not support named parameters
    still produced findings and bumped the N+1 counter.

So the statement paths use analyzeExecuted too, and conversion runs first.
Guard.Observe is now left to the out-of-tree integrations, which are only
ever told a query ran — database/sql has no ErrSkip fallback for
StmtQueryContext/StmtExecContext, but it does retry them on ErrBadConn.

Against the first commit the three added tests report got 3, a tripped N+1
threshold, and got 1 for a query that never ran.

Tests

middleware/driver_fallback_test.go gains fakeErrSkipDriver — a conn that
implements QueryerContext/ExecerContext and declines with ErrSkip,
mirroring mysql. Reverting the driver.go change:

--- FAIL: TestDriver_ErrSkipQueryAnalyzedOnce  a query the base declined with ErrSkip must be analyzed once, got 2
--- FAIL: TestDriver_ErrSkipExecAnalyzedOnce   an exec the base declined with ErrSkip must be analyzed once, got 2
--- FAIL: TestDriver_ErrSkipN1CountedOnce      one logical query must not trip N+1 (threshold 2); got 1 reports

fakeBadConnDriver covers the retry path and fakeQueryerDriver covers the other half of the acceptance criteria: a base
that does handle the direct path executes the query itself and is still
analyzed exactly once. The shared N+1 assertion now runs against all three
fake drivers.

Also

  • AGENTS.md: records the analyze-after-the-base invariant, so Guard.Observe
    is not restored here by a later simplification. The reviewer configs merged
    in chore: configure CodeAnt AI and sync the other reviewer configs #84 already name this rule analyze-once-per-execution and describe
    analyzeExecuted and the fakeBadConnDriver fixtures; this PR is what makes
    the code satisfy it.
  • CHANGELOG.md + website/docs/middleware.md (_Changed in 0.5._).

make fmt-check vet lint test test-race lint-docs all green, rebased on
current main.


CodeAnt-AI Description

Analyze each database execution exactly once

What Changed

  • Queries declined with ErrSkip are analyzed only on the prepare-and-execute fallback path, preventing duplicate findings and inflated N+1 counts
  • Retries after ErrBadConn no longer count failed connection attempts as executions
  • Calls rejected during named-argument conversion are not analyzed because they never reach the database
  • Latency reflects database execution time without including analysis work
  • Added coverage and documentation for direct execution, fallback, retry, and rejected-argument scenarios

Impact

✅ Accurate N+1 thresholds on MySQL parameterized queries
✅ No duplicate findings during connection retries
✅ No reports for queries rejected before execution

💡 Usage Guide

Checking Your Pull Request

Every time you make a pull request, our system automatically looks through it. We check for security issues, mistakes in how you're setting up your infrastructure, and common code problems. We do this to make sure your changes are solid and won't cause any trouble later.

Talking to CodeAnt AI

Got a question or need a hand with something in your pull request? You can easily get in touch with CodeAnt AI right here. Just type the following in a comment on your pull request, and replace "Your question here" with whatever you want to ask:

@codeant-ai ask: Your question here

This lets you have a chat with CodeAnt AI about your pull request, making it easier to understand and improve your code.

Example

@codeant-ai ask: Can you suggest a safer alternative to storing this secret?

Preserve Org Learnings with CodeAnt

You can record team preferences so CodeAnt AI applies them in future reviews. Reply directly to the specific CodeAnt AI suggestion (in the same thread) and replace "Your feedback here" with your input:

@codeant-ai: Your feedback here

This helps CodeAnt AI learn and adapt to your team's coding style and standards.

Example

@codeant-ai: Do not flag unused imports.

Retrigger review

Ask CodeAnt AI to review the PR again, by typing:

@codeant-ai: review

Check Your Repository Health

To analyze the health of your code repository, visit our dashboard at https://app.codeant.ai. This tool helps you identify potential issues and areas for improvement in your codebase, ensuring your repository maintains high standards of code health.

…67)

wConn.QueryContext/ExecContext analyzed the query before handing it to the
base driver. A base with no direct Queryer/Execer was handled, but not one
that has the entry point and declines an individual call with
driver.ErrSkip: database/sql then falls back to Prepare+Query, which
re-enters through wStmt and analyzes the same execution a second time.

That is the common path, not an edge case — go-sql-driver/mysql returns
ErrSkip for every parameterized query unless interpolateParams=true — so on
MySQL essentially all traffic was analyzed twice. The duplicate static
finding hid behind the default dedup window, but the N+1 counter is not
deduped: every configured threshold was effectively halved.

The base is now called first and analyzeExecuted skips analysis when the
answer is ErrSkip, leaving the prepare path as the single analysis point.
Findings are produced after execution on this path, which nothing consumes,
and the latency window is read before the rules run so analysis time cannot
push a query past the slow-query threshold.

The fake ErrSkip driver in driver_fallback_test.go pins the behaviour
(2 findings and a tripped N+1 threshold before the fix), alongside a
direct-path driver proving bases that do execute are unaffected.
Review follow-up on the #67 fix: ErrSkip was not the only per-call answer
that means "this did not execute".

database/sql retries a query on driver.ErrBadConn — twice from the pool,
then once on a brand-new connection — and every attempt re-enters the
wrapper. One logical query was therefore analyzed up to three times, at the
conn and the stmt level alike, which is the same N+1 inflation #67 fixed and
shows up whenever pooled connections go stale (MySQL's wait_timeout, a
restart, a failover). ErrBadConn's contract forbids returning it when the
operation may have been performed, so a declined attempt ran nothing and
analyzeExecuted now skips it.

wStmt likewise analyzed before converting named parameters for a base that
predates them, so a call failing with "driver does not support named
parameters" — without reaching the database — still produced findings and
bumped the N+1 counter. Conversion now runs first, and the statement paths
use analyzeExecuted like the conn paths, so Guard.Observe is left to the
out-of-tree integrations, which are only told a query ran.

Three fake-driver tests pin it; against the previous commit they report 3
analyses for one retried query, a tripped N+1 threshold, and 1 analysis for
a query that never ran.
@codeant-ai

codeant-ai Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

🤖 CodeAnt AI — Review Status

Status Commit Started (UTC) Finished (UTC)
✅ Reviewed your PR e038c5d Sep 25, 2026 · 12:00 12:02

@codeant-ai

codeant-ai Bot commented Sep 25, 2026

Copy link
Copy Markdown

Thanks for using CodeAnt! 🎉

We're free for open-source projects. if you're enjoying it, help us grow by sharing.

Share on X ·
Reddit ·
LinkedIn

@coderabbitai

coderabbitai Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

Next included review available in 46 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Repository: KARTIKrocks/sqlguard/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 0a5cc06c-60c1-4094-8aa1-8a5ce38c2bb9

📥 Commits

Reviewing files that changed from the base of the PR and between 482be26 and e038c5d.

📒 Files selected for processing (5)
  • AGENTS.md
  • CHANGELOG.md
  • middleware/driver.go
  • middleware/driver_fallback_test.go
  • website/docs/middleware.md

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.

@codeant-ai codeant-ai Bot added the size:L This PR changes 100-499 lines, ignoring generated files label Sep 25, 2026
@codeant-ai

codeant-ai Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

🏁 CodeAnt Quality Gate Results

Commit: e038c5d7
Scan Time: 2026-09-25 12:01:02 UTC

✅ Overall Status: PASSED

Quality Gate Details

Quality Gate Status Details
Secrets ✅ PASSED 0 secrets found
SAST ✅ PASSED No security issues
SCA (Dependencies) ✅ PASSED Rating S: No vulnerabilities

View Full Results

@codeant-ai

codeant-ai Bot commented Sep 25, 2026

Copy link
Copy Markdown

CodeAnt Nitpicks

1 custom suggestion

1. Remove or rephrase the upstream driver option name, because documentation option names must be exported identifiers or registered rule names; describe the behavior without naming interpolateParams.

Custom_rule · website/docs/middleware.md:102

@KARTIKrocks
KARTIKrocks merged commit b30597a into main Sep 25, 2026
33 checks passed
@KARTIKrocks
KARTIKrocks deleted the fix/errskip-double-analysis branch September 25, 2026 12:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L This PR changes 100-499 lines, ignoring generated files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Every query is analyzed twice when the base driver returns driver.ErrSkip (doubles N+1 counts on MySQL)

1 participant