Skip to content

fix(querystring): preserve empty string values - #3838

Open
sylvesterkaczmarek wants to merge 5 commits into
openai:mainfrom
sylvesterkaczmarek:fix-querystring-empty-values
Open

sylvesterkaczmarek wants to merge 5 commits into
openai:mainfrom
sylvesterkaczmarek:fix-querystring-empty-values

Conversation

@sylvesterkaczmarek

Copy link
Copy Markdown
Contributor

Summary

  • preserve explicit empty-string query values instead of dropping them
  • keep None query values omitted
  • add scalar, nested, and repeated-array regression coverage

Fixes #3837

Tests

Regression coverage added in tests/test_qs_empty_values.py. The connected development host was unavailable for a local suite run.

@sylvesterkaczmarek
sylvesterkaczmarek requested a review from a team as a code owner September 10, 2026 02:18
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 10, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-10T02:21:40.087103Z 6b4e277 PR opened
🔒 Security Review ✅ Completed 2026-09-10T02:22:49.355195Z 6b4e277 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@sylvesterkaczmarek
sylvesterkaczmarek force-pushed the fix-querystring-empty-values branch from 6b4e277 to 11607aa Compare October 1, 2026 08:30
@sylvesterkaczmarek

Copy link
Copy Markdown
Contributor Author

Following up after the Group 6 maintenance pass. This branch is current with main, mergeable, and has no failing checks or unresolved review threads. Could a maintainer review it when convenient?

@hossiendehghan989

Copy link
Copy Markdown

The scalar fix is correct, but I found one remaining branch-specific inconsistency that the current tests do not cover.

_stringify_item() handles array_format="comma" separately at src/openai/_qs.py:87-93, and that branch is unchanged by this PR. As a result, an array containing only None is still serialized as an explicit empty query parameter:

from openai._qs import Querystring

qs = Querystring()

assert qs.stringify({"filter": None}, array_format="comma") == ""
assert qs.stringify({"filter": [None]}, array_format="comma") == "filter="  # currently fails the invariant

This matters because the PR establishes the semantic distinction None -> omitted versus "" -> filter=. The same distinction should hold when the value is nested in an array. The current implementation already filters None while joining comma arrays, but it does not remove the key when every element was None; that makes [None] behave differently from None and can send a server-side empty filter instead of omitting the parameter.

A regression test should cover both sides of the distinction:

assert stringify({"filter": [None]}, array_format="comma") == ""
assert stringify({"filter": [""]}, array_format="comma") == "filter="
assert stringify({"filter": [None, "active"]}, array_format="comma") == "filter=active"

The implementation can preserve explicit empty strings while omitting an all-None array by checking whether the input contains any non-None item before returning the comma-joined pair. I reproduced the current behavior locally against src/openai/_qs.py; this is independent of the issue’s scalar case and should be a small, targeted addition to the otherwise solid fix.

@sylvesterkaczmarek

Copy link
Copy Markdown
Contributor Author

Thanks, reproduced. Fixed in GitHub-Verified commit 2eff92f: comma-format arrays now drop None values and omit the parameter entirely when nothing remains, while explicit empty strings still serialize as filter=. Added regressions for [], [None], [""], and [None, "active"]. Focused test_qs suite passes 11/11; Ruff check/format and git diff --check are clean. A maintainer re-review of the refreshed head would be appreciated.

@hossiendehghan989

Copy link
Copy Markdown

Checked the refreshed head against both sides of the distinction: value is None now returns early, the comma branch filters None items and returns nothing when the filtered list is empty, and an empty string still serialises as an explicit empty value. The issue's scalar case and the nested-array case I raised are both covered.

One residual test gap I would close before merge, because the invariant now lives in the shared helper rather than in the comma branch alone: all four array formats should agree on the nothing-left-after-filtering outcome.

@pytest.mark.parametrize("array_format", ["comma", "repeat", "indices", "brackets"])
def test_empty_array_omits_the_parameter(array_format):
assert serialise({"filter": []}, array_format=array_format) == ""
assert serialise({"filter": [None]}, array_format=array_format) == ""

repeat, indices and brackets already produce the empty string for both inputs today, since each one recurses per element back into _stringify_item, but only the comma cases are asserted. A parameterised test would tie that to the shared implementation, so a later change to one branch cannot leave the four formats disagreeing.

The non-empty cases stay deliberately format-specific — an empty string becomes filter= under comma but filter[]= or filter[0]= under the others — so a one-line comment in the test saying that only the empty outcome is shared would stop the next contributor from trying to unify them. Since the PR notes that no local suite run was possible, CI is the only verification of the refreshed head, and the focused test_qs additions look sufficient on their own.

@sylvesterkaczmarek

Copy link
Copy Markdown
Contributor Author

Added the cross-format regression in d42e99f. comma, repeat, indices and brackets now all explicitly assert that [] and [None] omit the parameter, with a note that non-empty serialization remains intentionally format-specific. Focused test_qs passes 15/15; Ruff check/format and git diff --check are clean. Commit is GitHub Verified.

@hossiendehghan989

Copy link
Copy Markdown

Great, this closes the remaining cross-format gap while preserving the intentional differences for non-empty values. The 15/15 focused test result, clean Ruff checks, and verified commit provide a solid verification record.

The parameterized empty-array coverage now protects the shared invariant across all four formats, so I have no further changes to request from this review.

@jnohclee-rgb

Copy link
Copy Markdown

Independent offline validation of this existing fix at head d42e99f921b1fb654c64a0fa8df3ff6d9f517e84, compared with main 919b6236382f3f9f76d8453efb9f9ad03cff1126: 13 shapes tested; main passed six and failed seven, while this head passed all 13.

The failing baseline shapes were scalar empty string, nested brackets/dots, repeated/bracketed/indexed arrays containing an empty string, and a tuple containing an empty string. Controls retained None omission, false, numeric zero, whitespace, Unicode/escaped query text, and comma-array serialization. parse_qsl(..., keep_blank_values=True) is the inspection oracle; this does not claim SDK parse() preserves blanks or establish a live-service query contract.

Python 3.14.7, fictional query values, network denied, no client/API calls. Exact source imports share the existing isolated dependency environment; this is not a full-suite or historical dependency-matrix approval. I have not created a competing PR. Diagnostic below:

"""Offline Querystring matrix; no clients or transport."""
import json
from urllib.parse import parse_qsl
import openai._qs as qs
cases=[('empty',{'filter':''},{},[('filter','')]),('none',{'filter':None},{},[]),('false',{'filter':False},{},[('filter','false')]),('zero',{'filter':0},{},[('filter','0')]),('space',{'filter':' '},{},[('filter',' ')]),('escaping',{'filter':'&=雪'},{},[('filter','&=雪')]),('nested_brackets',{'filter':{'name':''}},{},[('filter[name]','')]),('nested_dots',{'filter':{'name':''}},{'nested_format':'dots'},[('filter.name','')]),('repeat',{'filter':['','x',None]},{},[('filter',''),('filter','x')]),('brackets',{'filter':['','x',None]},{'array_format':'brackets'},[('filter[]',''),('filter[]','x')]),('indices',{'filter':['','x',None]},{'array_format':'indices'},[('filter[0]',''),('filter[1]','x')]),('comma',{'filter':['','x',None]},{'array_format':'comma'},[('filter',',x')]),('tuple',{'filter':('','x')},{},[('filter',''),('filter','x')])]
rows=[]
for name,value,options,expected in cases:
 encoded=qs.stringify(value,**options);actual=parse_qsl(encoded,keep_blank_values=True)
 rows.append({'case':name,'pass':actual==expected,'encoded':encoded,'actual':actual,'expected':expected})
print(json.dumps({'source_module':qs.__file__,'cases':rows,'passed':sum(r['pass'] for r in rows),'failed':sum(not r['pass'] for r in rows),'scope':'Querystring helper; parse_qsl with keep_blank_values=True is inspection oracle, not SDK parse() round-trip contract.'}))

AI disclosure: Codex prepared and executed these automated diagnostic checks; all inputs and backend outcomes are fictional.

@hossiendehghan989

Copy link
Copy Markdown

Thanks for the careful follow-up. I re-checked the array_format="comma" branch against the current implementation, and your observation is correct.

The scalar distinction established by this PR should also hold inside comma-separated arrays:

stringify({"filter": None}, array_format="comma")          == ""
stringify({"filter": [None]}, array_format="comma")        == ""
stringify({"filter": [""]}, array_format="comma")          == "filter="
stringify({"filter": [None, "active"]}, array_format="comma") == "filter=active"

At the moment, the comma branch filters None values while joining, but still emits the key when all elements are None, which produces filter= and violates the intended None-means-omitted semantics. Please add the targeted regression coverage and adjust that branch so an all-None array is omitted while an explicit empty string remains representable. It would also be good to make the expected behavior for an empty list ([]) explicit in the tests, so the change does not accidentally alter an existing convention.

Once this is addressed, the fix looks focused and consistent with the semantics established by the PR.

@jnohclee-rgb

Copy link
Copy Markdown

AI-assisted follow-up to the latest comment: I re-ran its four examples plus [] against the current head d42e99f921b1fb654c64a0fa8df3ff6d9f517e84. All five already produce the requested outputs: scalar None, [None] and [] omit the parameter; [""] gives filter=; [None, "active"] gives filter=active.

The current comma branch filters None into a list and returns no items when that list is empty, before joining. Its file is byte-identical to the pinned head in this check. The all-None concern therefore appears to describe an earlier revision. This is a local query-helper check with network denied, not a server-contract or full-suite approval. No additional code change was needed for those examples.

This branch has not been deployed

No deployments
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.

Querystring drops explicit empty-string scalar values

3 participants