Skip to content

fix(postgrest): make query builders immutable (#1208)#1523

Open
CAN-Aidoo wants to merge 1 commit into
supabase:mainfrom
CAN-Aidoo:can
Open

fix(postgrest): make query builders immutable (#1208)#1523
CAN-Aidoo wants to merge 1 commit into
supabase:mainfrom
CAN-Aidoo:can

Conversation

@CAN-Aidoo

Copy link
Copy Markdown

Summary

Fix #1208 by making every postgrest query builder chain method return a new instance instead of mutating shared RequestConfig state.

Before (bug):

query = sb.table("items").select("*").eq("account_id", "abc")
query.in_("id", ["1","2","3"]).execute()   # runs with 1,2,3   ✅
query.in_("id", ["4","5","6"]).execute()   # runs with 1..6    ❌

Same class of bug affects pagination via reused .range()/.limit()/.offset() — the request URL ends up with ?offset=0&offset=250&…&limit=251&limit=251… (see the DDoerner comment on the issue).

Approach

  • Add a module-level _clone_request(request) helper that returns a fresh RequestConfig with copies of the mutable pieces (Headers, QueryParams).
  • Add _clone(self) on BaseFilterRequestBuilder (inherited by BaseSelectRequestBuilderBaseRPCRequestBuilder) and on the four standalone Async*RequestBuilder classes.
  • Rewrite every mutating method to new = self._clone(); …mutate new…; return new.
  • For methods that return a different builder type (AsyncSelectRequestBuilder.{single,maybe_single,csv,text_search,explain}), clone the request first and hand it to the new builder — never mutate self.request.

Also fixes a latent bug in BaseFilterRequestBuilder.match() where the loop used self.eq(...) instead of chaining through the accumulator.

Coverage vs. prior PRs

#1385 (open): introduced the _clone() idea but only for not_, filter, or_, match — leaves ~20 mutating methods still broken (order/limit/offset/range/max_affected/select/single/csv/explain/text_search/retry, plus every RPC builder method). This PR extends the pattern to all of them.

#1521 (closed): used a class decorator (_make_immutable) that monkey-patched every public method. This PR uses explicit per-method _clone() calls instead — reviewable, doesn't break IDE navigation, preserves Self-typing for mypy.

Breaking change callout

@silentworks noted on 2026-03-30 that this fix is scheduled for v3 because it's a breaking change. Users relying on builder is builder.eq(...) identity, or on mutating a builder in place and re-executing, will see behavior change. Suggest labeling for v3 (or gating behind an opt-in flag if you'd prefer to ship it in v2).

I asked in this comment on #1208 whether you'd prefer this against main or a v3 branch — happy to retarget if needed.

Test plan

  • test_builder_immutability — reproduces the exact eq → in_ scenario from the issue.
  • test_pagination_immutability — reproduces the DDoerner .range() pagination scenario.
  • test_select_builder_base_is_untouched — parameterized over 15 chain methods (limit, offset, order, range, select, eq, in_, filter, or_, max_affected, match, not_, single, maybe_single, csv) asserting the base builder's params and headers are unchanged after each call.
  • test_max_affected_returns_new_instance — renamed from test_max_affected_returns_self, updated assertion to reflect the new immutable contract.
  • All 196 existing unit tests still pass (uv run --package postgrest pytest src/postgrest/tests/_async src/postgrest/tests/_sync --ignore=…integration.py --ignore=…test_client.py).
  • make postgrest.mypy clean.
  • uv run ruff check src/postgrest clean.

Closes #1208

Every chain method on the postgrest builders (BaseFilterRequestBuilder,
BaseSelectRequestBuilder, BaseRPCRequestBuilder, and the four
Async*RequestBuilder classes) mutated self.request.params/headers and
returned self. Sharing a base query across multiple executions leaked
conditions between calls — a builder reused for pagination or
multi-branch filtering accumulated params instead of producing
independent requests.

Introduce a module-level _clone_request() helper and a _clone() method
on each builder. Every mutating method now returns a fresh instance
with an independent RequestConfig. Methods that return a different
builder type (single, maybe_single, csv, text_search, explain) clone
the request before handing it off.

Also fixes a latent bug in BaseFilterRequestBuilder.match() where the
loop used self.eq instead of chaining through the accumulator.

Add test_builder_immutability and test_pagination_immutability
reproducing the exact scenarios from the issue, plus a parameterized
test_select_builder_base_is_untouched covering 15 chain methods.
Rename test_max_affected_returns_self to reflect the new contract.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@CAN-Aidoo CAN-Aidoo requested review from a team and o-santi as code owners July 2, 2026 11:35
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.

Query builder queries are not immutable

1 participant