From b8710671896efb077907823e9ff537b50fb925a9 Mon Sep 17 00:00:00 2001 From: Martin Yankovs Date: Tue, 28 Apr 2026 15:51:24 +0300 Subject: [PATCH 1/2] fix(storage/sql): skip empty bindings to GORM Where for nil-only filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- storage/sql.go | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/storage/sql.go b/storage/sql.go index c9b36ec..7722099 100644 --- a/storage/sql.go +++ b/storage/sql.go @@ -214,8 +214,7 @@ func (s *SQLAdapter) GetContext(ctx context.Context, dest any, filter map[string if len(filter) == 0 { return errors.New("filtering is required when getting a resource") } - query, bindings := s.buildQuery(filter) - result := s.dbWithCtx(ctx).Where(query, bindings).Find(dest) + result := s.applyFilter(s.dbWithCtx(ctx), filter).Find(dest) if result.RowsAffected == 0 { return ErrNotFound } @@ -230,8 +229,7 @@ func (s *SQLAdapter) UpdateContext(ctx context.Context, item any, filter map[str if len(filter) == 0 { return errors.New("filtering is required when updating a resource") } - query, bindings := s.buildQuery(filter) - result := s.dbWithCtx(ctx).Where(query, bindings).Save(item) + result := s.applyFilter(s.dbWithCtx(ctx), filter).Save(item) return result.Error } @@ -243,8 +241,7 @@ func (s *SQLAdapter) DeleteContext(ctx context.Context, item any, filter map[str if len(filter) == 0 { return errors.New("filtering is required when deleting a resource") } - query, bindings := s.buildQuery(filter) - result := s.dbWithCtx(ctx).Where(query, bindings).Delete(item) + result := s.applyFilter(s.dbWithCtx(ctx), filter).Delete(item) return result.Error } @@ -342,8 +339,7 @@ func (s *SQLAdapter) ListContext(ctx context.Context, dest any, sortKey string, } return s.executePaginatedQuery(ctx, dest, sortKey, sortDirection, limit, cursor, func(q *gorm.DB) *gorm.DB { if len(filter) > 0 { - query, bindings := s.buildQuery(filter) - return q.Where(query, bindings) + return s.applyFilter(q, filter) } return q }) @@ -402,8 +398,7 @@ func (s *SQLAdapter) CountContext(ctx context.Context, dest any, filter map[stri q := s.dbWithCtx(ctx).Model(dest) if len(filter) > 0 { - query, bindings := s.buildQuery(filter) - q = q.Where(query, bindings) + q = s.applyFilter(q, filter) } var total int64 @@ -414,6 +409,21 @@ func (s *SQLAdapter) CountContext(ctx context.Context, dest any, filter map[stri return total, nil } +// applyFilter builds a WHERE clause from the filter map and attaches it to +// the given gorm session. When the bindings map is empty (e.g. a filter like +// {"deleted_at": nil} which produces only an "IS NULL" clause) we must NOT +// forward an empty map[string]any{} as a parameter to GORM — pgx5 in +// PreferSimpleProtocol mode cannot encode it and fails with +// "unable to encode map[string]interface{}{} into text format for unknown +// type (OID 0): cannot find encode plan". Pass the query alone instead. +func (s *SQLAdapter) applyFilter(q *gorm.DB, filter map[string]any) *gorm.DB { + query, bindings := s.buildQuery(filter) + if len(bindings) == 0 { + return q.Where(query) + } + return q.Where(query, bindings) +} + func (s *SQLAdapter) Query(dest any, statement string, limit int, cursor string, params ...map[string]any) (string, error) { return s.QueryContext(context.Background(), dest, statement, limit, cursor, params...) } From 39f53b50f3b3121907fb338c7fcace259d021235 Mon Sep 17 00:00:00 2001 From: Martin Yankovs Date: Tue, 5 May 2026 01:11:25 +0300 Subject: [PATCH 2/2] test(storage/sql): regression test for nil-only filter via applyFilter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- storage/sql_test.go | 65 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/storage/sql_test.go b/storage/sql_test.go index d266e2e..ae92207 100644 --- a/storage/sql_test.go +++ b/storage/sql_test.go @@ -302,3 +302,68 @@ func TestMemoryAdapterDelegatesNonContextCountAndQuery(t *testing.T) { t.Fatalf("expected memory.Query to propagate not-implemented error") } } + +// nilFilterItem has a nullable column so we can build filters whose +// only entry has a nil value, exercising buildQuery's IS NULL branch. +type nilFilterItem struct { + Id string `json:"id" gorm:"primaryKey;column:id"` + DeletedAt *string `json:"deleted_at" gorm:"column:deleted_at"` +} + +func (nilFilterItem) TableName() string { return "nil_filter_items" } + +// TestSQLAdapterNilOnlyFilterDoesNotForwardEmptyBindings pins the fix +// for the pgx5 simple-protocol crash where buildQuery returned an empty +// map[string]any{} as bindings for nil-only filters and gorm forwarded +// it to pgx as a parameter ("unable to encode map[string]interface{}{} +// into text format for unknown type (OID 0): cannot find encode plan"). +// +// applyFilter must call q.Where(query) with NO variadic args when +// bindings are empty. Sqlite tolerates the broken shape, so this test +// exercises behavioral correctness (nil-only filters return the right +// rows) — if a future cleanup reintroduces empty-map forwarding, this +// keeps passing under sqlite but the matching pgx5 path will regress. +// The unit invariant we lock in here is "Count/Get/List with a nil-only +// filter return the expected rows without error". +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) + } +}