Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature> emptyValue options for parse & stringify #226

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from

Conversation

timhwang21
Copy link

Addresses #223.

Behavior:

  • When parsing and encountering empty values, replaces them with
    opt.emptyValue (default to '')
  • When stringifying and encountering value === opt.emptyValue, outputs a key
    without a value

Copy link
Author

@timhwang21 timhwang21 left a comment

Choose a reason for hiding this comment

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

Parsing works properly, but I have a question regarding the best way to handle a certain stringification behavior. Thanks!

lib/stringify.js Outdated
@@ -27,13 +29,15 @@ var defaults = {
},
skipNulls: false,
strictNullHandling: false
// emptyValue: some flag
Copy link
Author

Choose a reason for hiding this comment

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

I'm not sure what the best way to handle this case is, as there needs to way to recognize "user provided no option" so the default behavior of outputting foo= as opposed to foo can be used.

I've seen things like using the global Infinity, storing the option in an object and using hasOwnProperty, etc. among others but not sure what you'd prefer. Thanks!

Copy link
Owner

Choose a reason for hiding this comment

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

Hmm, I don't think I'm understanding the question here. Can you restate? A test case (that currently fails) would be most helpful.

Copy link
Author

Choose a reason for hiding this comment

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

Yeah -- let's say we don't provide a default. This means when stringifying { foo: undefined } we get 'foo' whereas before we get '' (line 77). If we do provide a default, stringifying { foo: [whatever our chosen default is]} gives 'foo' instead of whatever it gave before my change. So without some more involved code, stringifying whatever value we choose as the default would change from whatever it was before to 'foo'.

Copy link
Owner

Choose a reason for hiding this comment

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

I'd like this not to be a breaking change, so qs.stringify({ foo: undefined }) needs to return the same value in master as it does in this PR.

Copy link
Owner

Choose a reason for hiding this comment

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

cc @timhwang21, have you had any time to work on this PR?

Copy link
Author

Choose a reason for hiding this comment

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

In all honesty, no, this entirely fell off my radar. Will catch up on the past few years of discussion 😅

lib/parse.js Outdated Show resolved Hide resolved
lib/stringify.js Outdated Show resolved Hide resolved
@ljharb
Copy link
Owner

ljharb commented Nov 23, 2018

@timhwang21 is this still something you're interested in?

@timhwang21
Copy link
Author

@ljharb sure! Sorry this fell off my radar as we ended up solving the problem we were facing on our end.

@timhwang21 timhwang21 changed the title WIP: emptyValue options for parse & stringify Feature> emptyValue options for parse & stringify Nov 24, 2018
lib/stringify.js Outdated
if (obj === null) {
if (strictNullHandling) {
if (obj === null || obj === emptyValue) {
if (strictNullHandling || (obj === emptyValue && emptyValue !== null)) {
return encoder && !encodeValuesOnly ? encoder(prefix, defaults.encoder, charset) : prefix;
Copy link
Author

Choose a reason for hiding this comment

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

This code is sort of confusing, but I think this results in the most expected outcomes:

  1. No options: { a: null } becomes 'a='
  2. strictNullHandling: { a: null } becomes 'a'
  3. emptyValue: 'something': { a: 'something' } becomes 'a'
  4. emptyValue: null: { a: null } becomes 'a=' (corner case; for null we defer to strictNullHandling)
  5. emptyValue: null + strictNullHandling: { a: null } becomes 'a' (same as case 1; emptyValue: null is redundant here)

The relevant cases for this patch are 3, 4 and 5. Without the additional check on line 71, case 3 would give 'a='. However to me it seems if the user provides emptyValue they implicitly want "strict empty handling" to be true, as I don't think returning 'a=' for 3 is ever a useful outcome.

To avoid changing existing behavior, when emptyValue is null we require strictNullHandling on top of that.

An alternative would be to make emptyValue ALWAYS assume strictNullHandling is true, even for null. We'd have to change the default value from null to some flag that would never be used as an actual value, like Infinity or something.

Copy link
Owner

@ljharb ljharb Nov 25, 2018

Choose a reason for hiding this comment

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

Hmm, I'm not sure I agree - a= explicitly means "empty" to me, whereas a could be shorthand for a: true. I think I might expect case 3 to become a=, and case 6 (emptyValue: 'something' + strictNullHandling) makes { a: 'something' } become a?

Copy link
Author

Choose a reason for hiding this comment

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

I do agree that case 3 giving a= is more expected, but I think it's less useful. I'm imagining the following:

  1. User does qs.parse('foo&bar=1', { emptyValue: true }) and gets { foo: true, bar: 1 }
  2. User increments bar by 1 and re-serializes: qs.stringify({ foo: true, bar: 2 }, { emptyValue: true }). I'd expect to get foo&bar=2 which follows the format of the original input, not foo=&bar=2 here.

Regardless... I do agree that requiring strictNullHandling: true for this behavior for stringify does feel more logical. Returning a just strikes me as more in line with the use case this PR is meant to address.

Copy link
Owner

Choose a reason for hiding this comment

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

I guess the confusion is because we have an implicit empty value of null, and a separate “strict null handling” var. if it was “strict empty value handling”, then it’d be easy to make it make sense. I’m not sure what the best approach here is.

Copy link
Author

Choose a reason for hiding this comment

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

Hm. What if we move the check for emptyValue to a block under the check for null? Because line 75 sets obj = '' if !strictNullHandling, obj will never equal emptyValue's implicit default value of null. Then the concerns will be more segregated.

Copy link
Owner

Choose a reason for hiding this comment

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

I think it makes sense to forbid the strictNullHandling option entirely when emptyValue is not null.

However, then it may make sense to add a new option, strictEmptyHandling, that subsumes strictNullHandling in all cases?

Copy link
Author

Choose a reason for hiding this comment

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

+1 on the first part, that makes it more consistent with parse behavior.

I'm honestly still unconvinced on the second part, as I still don't think anyone would ever want to use stringify + emptyValue without also wanting "strict" behavior. It just seems like it would lead to user error since the desired outcome requires emptyValue to always be used in tandem with strictFooHandling...

Copy link
Owner

Choose a reason for hiding this comment

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

Both with and without the strict option, what’s the roundtrip behavior of a null?

Copy link
Author

Choose a reason for hiding this comment

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

stringify({ a: null })
// => 'a='
parse('a=')
// => { a: '' }

// strict - matches
stringify({ a: null }, { strictNullHandling: true })
// => 'a'
parse('a', { strictNullHandling: true })
// => { a: null }

I'd expect emptyValue: 'foobar' to basically be strictFoobarHandling: true:

stringify({ a: 'foobar' }, { emptyValue: 'foobar' })
// => 'a'
parse('a', { emptyValue: 'foobar' })
// => { a: 'foobar' }

lib/parse.js Outdated Show resolved Hide resolved
lib/stringify.js Outdated
if (obj === null) {
if (strictNullHandling) {
if (obj === null || obj === emptyValue) {
if (strictNullHandling || (obj === emptyValue && emptyValue !== null)) {
return encoder && !encodeValuesOnly ? encoder(prefix, defaults.encoder, charset) : prefix;
Copy link
Owner

@ljharb ljharb Nov 25, 2018

Choose a reason for hiding this comment

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

Hmm, I'm not sure I agree - a= explicitly means "empty" to me, whereas a could be shorthand for a: true. I think I might expect case 3 to become a=, and case 6 (emptyValue: 'something' + strictNullHandling) makes { a: 'something' } become a?

@issuefiler
Copy link

I suggest the following.

INPUT
OUTPUT

""
{}

"a"
{a: null}

"a="
{a: ""}

"a=null"
{a: "null"}

"a[0]" (arrayFormat: "indices")
{a: []}

"a[0]=" (arrayFormat: "indices")
{a: [""]}

"a[]" (arrayFormat: "brackets")
{a: []}

"a[]=" (arrayFormat: "brackets")
{a: [""]}

{a: undefined}
""

{a: null}
"a"

{a: ""}
"a="

{a: "null"}
"a=null"

I believe interpreting the presense of a key as its value being truthy is up to the users.
qs should, by default, just focus on conveying what keys are “defined” and what values, if any, they hold.
qs doesn’t know whether "a" is boolean or not.

If how others interpret the stringified is concerned, why don’t you add “Do not convey (do omit) the presense of nulled keys” and “Do not convey (do omit) the presense of empty arrays” options?

@jayaddison
Copy link

Just adding a note that I'm interested in this functionality too.

While I mostly agree with @issuefiler's desired behaviour (a is null, a= is an empty string, 'undefined' implies key-not-present, ...), I also agree with the suggestion that this should be an option via options.emptyValue.

Given that truthy/falsy checks can be tricky and non-obvious to reason about, it's worth being careful to reduce impact to existing codebases (especially since there are many of them) when they upgrade qs.

@timhwang21
Copy link
Author

@ljharb after thinking about this some more, I'm liking the idea of strictEmptyHandling (and skipEmpty as well) you suggested. I'm proposing the following API:

Existing options:

  • strictNullHandling -- Now no longer does anything by itself. When set to true, sets strictEmptyHandling to true and sets emptyValues to null.
  • skipNulls -- Now no longer does anything by itself. When set to true, sets skipEmpty to true and sets emptyValues to null.

New options:

  • emptyValues -- Defaults to null. Represents the value qs treats as "empty."
  • strictEmptyHandling
    • parse -- when set to true and given a value without a trailing =, parses as emptyValue instead of ''.
    • stringify -- when set to true and given a k-v pair with where value === emptyValue, returns a the key without an equals sign.
  • skipEmpty
    • parse -- not implemented
    • stringify -- when set to true and stringifying a k-v pair where value === emptyValue, returns the empty string.

Edge cases:

  • When stringifying, we cannot treat multiple values as empty. We can allow passing an array emptyValues to stringify; however, this breaks the parallel options setup between stringify and parse, because multiple empty values don't make sense for parse. The use case would be that when stringifying, we can do things like treat both null and '' as empty. Alternatively, we can stay with the API of the current PR and keep the concept of empty and strict null entirely separate; however, I feel this makes the API harder to understand.
  • strictNullHandling: true or skipNulls: true AND emptyValue != null. The conflict arises from strictNullHandling wanting to set emptyValue to null. We can either have this throw an error like my current PR does or have the user-specified emptyValue override null. I think the latter is reasonable but confusing.

Summary:

  • If not passing any value to emptyValue (meaning the default of null is used), strict{Null,Empty}Handling and skip{Nulls,Empty} are equivalent.
  • Existing code that doesn't specify the new options will see no change in behavior.
  • I think we should choose one of the two options I proposed above for handling the need to serialize BOTH null an '' as an empty value. This seems like a very reasonable and desirable use case. I personally favor options.emptyValue: any for parse and options.emptyValues: any[] for stringify.

Please let me know what you think. Thanks!

@ljharb
Copy link
Owner

ljharb commented Jan 13, 2021

@timhwang21 I like your new API suggestion. I like throwing an error when conceptually conflicting options are passed.

I'm not sure I understand the last bullet point in the "Summary", but if you're able to update the PR, maybe it will become clear to me :-)

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

Successfully merging this pull request may close these issues.

None yet

4 participants