Skip to content

fix(storage/sql): skip empty bindings to GORM Where for nil-only filters#173

Open
Lutherwaves wants to merge 2 commits into
mainfrom
fix/buildquery-empty-bindings-pgx-simple-protocol
Open

fix(storage/sql): skip empty bindings to GORM Where for nil-only filters#173
Lutherwaves wants to merge 2 commits into
mainfrom
fix/buildquery-empty-bindings-pgx-simple-protocol

Conversation

@Lutherwaves
Copy link
Copy Markdown
Contributor

@Lutherwaves Lutherwaves commented Apr 28, 2026

Problem

SQLAdapter.{Get,List,Update,Delete,Count} accept a map[string]any filter. When every value in the filter is nil, buildQuery produces a clause like col IS NULL plus an empty map[string]any{} of bindings. That empty map is forwarded to GORM as a parameter to .Where(query, bindings).

Under pgx5 with PreferSimpleProtocol: true (which is what OpenConnection configures for Postgres), the empty map cannot be encoded:

unable to encode map[string]interface{}{} into text format for unknown type (OID 0): cannot find encode plan

The call returns an error for shapes that should be valid:

adapter.Count(&Foo{}, map[string]any{"deleted_at": nil})    // count non-deleted rows
adapter.List(&dst, "id", map[string]any{"archived_at": nil}, 50, "")  // list non-archived
adapter.Get(&dst, map[string]any{"resolved_at": nil})       // first unresolved

Filters with at least one non-nil value (e.g. {"id": id, "deleted_at": nil}) work fine because the bindings map is non-empty.

Fix

Add SQLAdapter.applyFilter(q, filter) that calls buildQuery and forwards bindings to .Where only when the bindings map is non-empty. The five methods that fed buildQuery output into .Where now route through it.

No behavior change for filters with at least one non-nil value.

Test plan

  • go build ./...
  • go vet ./...
  • Existing tests pass
  • TestSQLAdapterNilOnlyFilterDoesNotForwardEmptyBindings covers Count, Get, List with nil-only filters. The in-memory sqlite adapter tolerates the broken empty-map shape, so the test asserts behavioral correctness rather than the wire-level crash; the comment block on the test documents what to verify against a real pgx5 simple-protocol harness.

@Lutherwaves Lutherwaves self-assigned this Apr 28, 2026
When a filter contains only nil values (e.g. {"deleted_at": nil}),
buildQuery returns the SQL clause "deleted_at IS NULL" plus an empty
map[string]any{} as bindings. Forwarding that empty map as a parameter
to gorm's .Where(query, bindings) is fine on most drivers, but under
pgx5 with PreferSimpleProtocol: true (which is enabled by the default
Postgres setup in OpenConnection) it fails with:

    unable to encode map[string]interface{}{} into text format for
    unknown type (OID 0): cannot find encode plan

This 500s every Count/Get/List/Update/Delete call where every filter
value happens to be nil — a common shape for "non-deleted rows" counts.

Fix by introducing applyFilter, which forwards bindings only when the
map is non-empty. All five SQLAdapter methods that previously passed
buildQuery's output to .Where now go through it.

No behavior change for filters with at least one non-nil value.
@Lutherwaves Lutherwaves force-pushed the fix/buildquery-empty-bindings-pgx-simple-protocol branch from d304a47 to 79b7488 Compare May 4, 2026 21:55
Pins the fix in the previous commit by exercising Count/Get/List with a
nil-only filter ({"deleted_at": nil}). Sqlite tolerates the broken empty-
map shape that pgx5 simple-protocol rejects, so the test asserts the
behavioral contract — nil-only filters return the expected rows — rather
than the wire-level crash. If a future cleanup reintroduces empty
map[string]any{} forwarding to gorm.Where, this test still passes under
sqlite but the matching pgx5 path will regress; the comment block on the
test documents that linkage so the next reader knows what to test against
a real Postgres harness.
@Lutherwaves Lutherwaves marked this pull request as ready for review May 4, 2026 22:12
@Lutherwaves Lutherwaves closed this May 4, 2026
@Lutherwaves Lutherwaves reopened this May 4, 2026
@Lutherwaves
Copy link
Copy Markdown
Contributor Author

@CodeRabbit review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 5, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 5, 2026

📝 Walkthrough

Walkthrough

The SQL adapter's filter application is refactored to use a new internal applyFilter helper that detects empty parameter bindings and conditionally invokes GORM's Where() method with only the SQL clause (no empty map). This aligns five query methods (GetContext, UpdateContext, DeleteContext, ListContext, CountContext) to consistent parameter-passing semantics.

Changes

Filter Parameter Handling Refinement

Layer / File(s) Summary
Core Filter Helper
storage/sql.go
New applyFilter helper detects when buildQuery produces empty bindings and calls q.Where(query) (single arg) versus q.Where(query, bindings) (two args) to avoid forwarding empty map[string]any{} to GORM.
Method Wiring
storage/sql.go
GetContext, UpdateContext, DeleteContext, ListContext (filter non-empty), and CountContext (filter non-empty) updated to route through applyFilter instead of inline buildQuery + Where(query, bindings).
Test Coverage
storage/sql_test.go
Adds nilFilterItem fixture with soft-delete field and TestSQLAdapterNilOnlyFilterDoesNotForwardEmptyBindings verifying that nil-only filters return correct rows without parameter-forwarding errors.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Title check ⚠️ Warning Title describes the core fix (skip empty bindings to GORM) for the specific problem (nil-only filters), directly matching the changeset. Reduce title to under 50 characters. Consider: 'fix(storage/sql): handle nil-only filters' (42 chars) or 'fix(sql): skip empty bindings for nil filters' (45 chars).
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description check ✅ Passed Description comprehensively explains the problem (pgx5 encoding error with empty bindings), solution (applyFilter helper), and test coverage directly aligned with the changeset.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ 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/buildquery-empty-bindings-pgx-simple-protocol

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

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 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 `@storage/sql_test.go`:
- Around line 328-369: Extend
TestSQLAdapterNilOnlyFilterDoesNotForwardEmptyBindings to also assert the Update
and Delete code paths: call sql.Update (targeting e.g. the non-key field or a
dummy field) with filter map[string]any{"deleted_at": nil} and verify only the
"live" row is affected (check the row was updated and the "gone" row remains
unchanged), and call sql.Delete with the same nil-only filter and verify only
the "live" row is removed (confirm Count/List/Get reflect that deletion while
the "gone" row remains). Use the existing nilFilterItem records ("live" and
"gone") and the same sql variable and filter map to locate the code paths for
sql.Update and sql.Delete.
🪄 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: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 1fee8c0f-faf4-48aa-9669-c66d24a2b0b3

📥 Commits

Reviewing files that changed from the base of the PR and between 0255b0b and 403465e.

📒 Files selected for processing (2)
  • storage/sql.go
  • storage/sql_test.go

Comment thread storage/sql_test.go
Comment on lines +328 to +369
func TestSQLAdapterNilOnlyFilterDoesNotForwardEmptyBindings(t *testing.T) {
m := storage.GetMemoryAdapterInstance()
if err := m.Execute(`CREATE TABLE IF NOT EXISTS nil_filter_items (id TEXT PRIMARY KEY, deleted_at TEXT)`); err != nil {
t.Fatalf("create table: %v", err)
}
if err := m.Execute(`DELETE FROM nil_filter_items`); err != nil {
t.Fatalf("truncate: %v", err)
}
sql := m.DB

if err := sql.Create(&nilFilterItem{Id: "live"}); err != nil {
t.Fatalf("Create live: %v", err)
}
deletedTs := "2026-01-01"
if err := sql.Create(&nilFilterItem{Id: "gone", DeletedAt: &deletedTs}); err != nil {
t.Fatalf("Create gone: %v", err)
}

total, err := sql.Count(&[]nilFilterItem{}, map[string]any{"deleted_at": nil})
if err != nil {
t.Fatalf("Count with nil-only filter: %v", err)
}
if total != 1 {
t.Fatalf("Count = %d; want 1 (only the non-deleted row)", total)
}

var got nilFilterItem
if err := sql.Get(&got, map[string]any{"deleted_at": nil}); err != nil {
t.Fatalf("Get with nil-only filter: %v", err)
}
if got.Id != "live" {
t.Fatalf("Get id = %q; want %q", got.Id, "live")
}

var listed []nilFilterItem
if _, err := sql.List(&listed, "id", map[string]any{"deleted_at": nil}, 10, ""); err != nil {
t.Fatalf("List with nil-only filter: %v", err)
}
if len(listed) != 1 || listed[0].Id != "live" {
t.Fatalf("List = %+v; want one row id=live", listed)
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Extend this regression test to cover Update and Delete paths changed in this PR.

Count/Get/List are covered, but Update/Delete were also switched to applyFilter. Adding one nil-only filter assertion for each will lock in coverage for all touched entrypoints.

🤖 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 `@storage/sql_test.go` around lines 328 - 369, Extend
TestSQLAdapterNilOnlyFilterDoesNotForwardEmptyBindings to also assert the Update
and Delete code paths: call sql.Update (targeting e.g. the non-key field or a
dummy field) with filter map[string]any{"deleted_at": nil} and verify only the
"live" row is affected (check the row was updated and the "gone" row remains
unchanged), and call sql.Delete with the same nil-only filter and verify only
the "live" row is removed (confirm Count/List/Get reflect that deletion while
the "gone" row remains). Use the existing nilFilterItem records ("live" and
"gone") and the same sql variable and filter map to locate the code paths for
sql.Update and sql.Delete.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant