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

URLSearchParams Implementation #54

Merged
merged 20 commits into from
Sep 12, 2023

Conversation

etiennemartin
Copy link
Contributor

This PR is a proposal for the implementation of the URLSerachParams class provided by NodeJS. You can find the documentation here: Docs.

This implementation is continuation of the work done to implement URL itself. It tries to implement as much of the NodeJS functionality as possible for goja.

The following is currently unsupported in the implementation:

  • ctor(iterable): Using function generators
  • sort(): Since the backing object is a url.URL which backs the data as a Map, we can't reliably sort the entries
  • [] operator: Not sure if we are able to override this with goja.

If the general approach is something we are happy with, I will look at making sorting and ordering something built in. This would require more work since we wouldn't be able to rely on the backing object being a url.URL struct. Right now we use the Encode() of url.URL method in several places.

url/url.go Outdated
})

// search Params
defineURLAccessorProp(r, p, "searchParams", func(u *url.URL) interface{} {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Most of the code in this file was simply moved from module.go. I've only added this new method as part of the URL implementation (Along with tests).

@etiennemartin
Copy link
Contributor Author

Hmmm. Looks like I missed something in the push. I'll update this PR tonight.

@etiennemartin
Copy link
Contributor Author

The latest changes shows that a random change can affect the ordering of the url parameters. This results in flaky tests. I'll have to find a way to get this working with something consistent. My idea right now is to wrap a struct and maintain a separate list of ordered parameters.

@dop251
Copy link
Owner

dop251 commented Jun 8, 2023

Hi. I've just realised that in node the url implementation is actually written in javascript: https://github.com/nodejs/node/blob/main/lib/internal/url.js

I think for maximum compatibility the implementation should be derived from there, converted to Go of course.

I had a quick look at the code, one thing that strikes me is that they set the search property of the associated URL every time the query parameters are updated... Not quite sure why not make it a lazy update since URL.search is an accessor property.

As for the iteration, I guess goja needs to expose something that would allow doing for ... of from Go.

Not quite sure what you mean by [] operator.

@etiennemartin
Copy link
Contributor Author

I think for maximum compatibility the implementation should be derived from there, converted to Go of course.

How important is the idea of maximum compatibility here? I'm just wondering since this would invalidate the implementation of URL and URLSearchParams. If we aim to be as close as possible it would suggest a re-write for the most part. My assumption here was to ensure that the implementation would behave the same, not exactly implemented the same. Just trying to align expectations moving forward.

I had a quick look at the code, one thing that strikes me is that they set the search property of the associated URL every time the query parameters are updated... Not quite sure why not make it a lazy update since URL.search is an accessor property.

That's interesting, I see what you're saying. I wonder if this is just an oversight, or lack of need to optimize? Not sure.

Not quite sure what you mean by [] operator.

When I was looking to write the implementation, I got tripped up on the use of iterators. I managed to get most examples in the docs to work using normal collections (Array, Map). For the most part it should be fine. It's just a bit odd since the docs reference ES6 and goja supports 5.1.

I guess overall the big question for me is how closely do we want the API's implementation to be similar to NodeJS's implementation. I was about to change the backing object to URL and URLSearchParams to adhere to a strict ordering of query params.

@dop251
Copy link
Owner

dop251 commented Jun 8, 2023

It should be as close as practically achievable. As it's not that much code and there is a reference implementation in javascript, it shouldn't be too difficult to implement it "as-is". See my comment here: #40 (comment), I think you should follow this route, but instead of using url.Values you should have a different implementation which is a ported version from the reference code.

@etiennemartin
Copy link
Contributor Author

See my comment here: #40 (comment), I think you should follow this route

I have an implementation in the works right now with something like this. I'll post an update to this PR once I've done the work.

@etiennemartin
Copy link
Contributor Author

I've pushed a pretty big change that changes the backing structure. This brings us closer to the native implementation and allows us to maintain the order of the query parameters. I just need to look into some encoding oddities in the URL class right now (See commented out tests).

@dop251
Copy link
Owner

dop251 commented Jun 12, 2023

What's the reason for using own structure instead of url.URL? I thought it worked quite well...

@etiennemartin
Copy link
Contributor Author

What's the reason for using own structure instead of url.URL? I thought it worked quite well...

I opted to move away from url.URL because of the url search parameters. The search parameters are stored as an unordered map. This makes it impossible to maintain the order of the search queries and makes writing non-flaky tests nearly impossible. So I opted in maintaining manually parsing them and maintaining an array. I was hoping the language would change this but seeing this:

golang/go#29985

I don't have much hope in this change making it in for a while.

That being said, there's an issue I'm running into now with goja. The search parameter's constructor is expected to take in an object into the constructor. I need to take another look, but the object properties seem to be non-deterministic in their order when they are passed from Javascript -> Go. This might make writing tests for this new class really hard.

@dop251
Copy link
Owner

dop251 commented Jun 13, 2023

Just don't use URL.Query(). I mean it would make sense if you abandoned net.URL completely, but you seem to be using the parser and then just copying everything into a custom struct which does not make a lot of sense in my opinion.

@etiennemartin
Copy link
Contributor Author

I'll make the change back. I think you're right. I'll change the nodeURL struct to be a url.URL along with an array of searchParams. The original idea was to move closer to the implementation that NodeJS had.

url/urlsearchparams.go Outdated Show resolved Hide resolved
url/urlsearchparams.go Outdated Show resolved Hide resolved
url/urlsearchparams.go Outdated Show resolved Hide resolved
url/urlsearchparams.go Outdated Show resolved Hide resolved
url/nodeurl.go Outdated Show resolved Hide resolved
@etiennemartin
Copy link
Contributor Author

I've updated the code and removed the use of Export() with each execution. This approach also makes use for the ForOf implementation you provided. Sorry for the delay I was away for a bit. Let me know what you think.

Copy link
Owner

@dop251 dop251 left a comment

Choose a reason for hiding this comment

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

Hi. Thanks for continuing working on this and apologies for the delay. I've added some comments, please have a look.

url/nodeurl.go Outdated Show resolved Hide resolved
url/nodeurl.go Outdated Show resolved Hide resolved
url/nodeurl.go Outdated Show resolved Hide resolved
str := ""
sep := ""
for _, v := range s {
str = fmt.Sprintf("%s%s%s", str, sep, v.Encode())
Copy link
Owner

Choose a reason for hiding this comment

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

Not the most efficient way. Even a simple string concatenation would be better I think. But best to use strings.Builder.

url/nodeurl.go Outdated Show resolved Hide resolved
name := call.Arguments[0].String()
if len(call.Arguments) > 1 {
value := call.Arguments[1].String()
arr := searchParams{}
Copy link
Owner

Choose a reason for hiding this comment

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

Perhaps a better approach would be finding an element and doing an inline copy, rather than always allocate and copy.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I've since simplified this approach, but the reason I used approach was to allow for deletion of multiple items in one pass. The Search Params might have have many entries with the name. Which would result in all of them being removed.

Copy link
Owner

Choose a reason for hiding this comment

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

It still doesn't justify the allocation and the full copy. Even if multiple elements are deleted it's still possible to do it with in-place replacement and with a single pass.

url/urlsearchparams.go Outdated Show resolved Hide resolved
url/urlsearchparams.go Outdated Show resolved Hide resolved
url/nodeurl.go Outdated Show resolved Hide resolved
url/nodeurl.go Outdated Show resolved Hide resolved
Copy link
Owner

@dop251 dop251 left a comment

Choose a reason for hiding this comment

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

It's getting there, just a few minor things that need to be addressed.

query := searchParams{}

o := v.ToObject(r)
ex := r.Try(func() {
Copy link
Owner

Choose a reason for hiding this comment

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

No need to use Try here as this function is only called by the Runtime. If something within ForOf throws an exception the resulting panic will be handled by the caller of this function.

func buildParamsFromMap(r *goja.Runtime, v goja.Value) *url.URL {
query := searchParams{}
o := v.ToObject(r)
ex := r.Try(func() {
Copy link
Owner

Choose a reason for hiding this comment

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

Same here

url/nodeurl.go Outdated
}

func (nu *nodeURL) getValues(name string) []string {
vals := []string{}
Copy link
Owner

Choose a reason for hiding this comment

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

This is generally frowned upon. Unless you absolutely have to have the returned value to be non-nil, it's better to just do var vals []string

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Interesting, the reason I had done this was to avoid a crash when we called len(vals) where the value is returned. I didn't know that len(x) could take nil as a value. Good catch!

case reflectTypeString:
var str string
r.ExportTo(v, &str)
u = buildParamsFromString(str)
Copy link
Owner

Choose a reason for hiding this comment

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

It's easier to do u = buildParamsFromString(v.String())

url/nodeurl.go Outdated
} else {
vals = append(vals, fmt.Sprintf("%s=%s", sp.name, sp.value))
}
return strings.Join(vals, "&")
Copy link
Owner

Choose a reason for hiding this comment

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

Why do you need the slice and the Join() here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

We don't need it at all, this was a relic from a previous implementation. I'll simplify this.

}

// Currently not supporting the following:
// - ctor(iterable): Using function generators
Copy link
Owner

Choose a reason for hiding this comment

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

Is this still relevant?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

With the current implementation this is still relevant. I don't have any code to handle the use of generator functions. It's treated as an Object that generates an empty object. I would need to find a way to call the generator function and process it's output. Any leads on how I could do that given a goja.Object that represents a generator function?

Copy link
Owner

Choose a reason for hiding this comment

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

The output of a generator function is an iterable for which you can use ForOf. BTW, an array is also an iterable, so you can generalise it for objects:

func buildParamsFromObject(r *goja.Runtime, v goja.Value) *url.URL {
    o := v.ToObject(r)
    if o.GetSymbol(goja.SymIterator) != nil {
        return buildParamsFromIterable(r, o)
    }
    ...
}

buildParamsFromIterable is pretty much the same as the current buildParamsFromArray.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Awesome! I totally missed the goja.GetSymbol() functionality in goja. This is great! I'll make this change and push it up. Thanks for the heads up.

name := call.Arguments[0].String()
if len(call.Arguments) > 1 {
value := call.Arguments[1].String()
arr := searchParams{}
Copy link
Owner

Choose a reason for hiding this comment

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

It still doesn't justify the allocation and the full copy. Even if multiple elements are deleted it's still possible to do it with in-place replacement and with a single pass.

@etiennemartin
Copy link
Contributor Author

etiennemartin commented Aug 31, 2023

I've updated the code base on your comment, the only one I couldn't answer is the one related to the generator functions. I'm not quite sure how I can run a generator function from Go in a clean manner. I'm looking for a way to detect if the parameter is a generator function or not. Is there support for this using goja? If so can you help by pointing me in the right direction?

I've tried the following to see if I can call the generator function and the assert call seems to fail:

callable, ok := goja.AssertFunction(v)
v. if ok {
  ret, err := callable(nil)
  
  // ...	
}

In the example above, v is a goja.Object. But when I dig into it, it's data property seems to be a goja.generatorObject which I'm not sure how to call.

}

// Currently not supporting the following:
// - ctor(iterable): Using function generators
Copy link
Owner

Choose a reason for hiding this comment

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

The output of a generator function is an iterable for which you can use ForOf. BTW, an array is also an iterable, so you can generalise it for objects:

func buildParamsFromObject(r *goja.Runtime, v goja.Value) *url.URL {
    o := v.ToObject(r)
    if o.GetSymbol(goja.SymIterator) != nil {
        return buildParamsFromIterable(r, o)
    }
    ...
}

buildParamsFromIterable is pretty much the same as the current buildParamsFromArray.

return u
}

func stringFromValue(r *goja.Runtime, v goja.Value) string {
Copy link
Owner

Choose a reason for hiding this comment

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

Why do you need this? The standard .String() should handle all these cases.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sounds good, I'll remove this.

@dop251 dop251 changed the base branch from master to urlsearchparams September 12, 2023 13:46
@dop251 dop251 merged commit 76fdc05 into dop251:urlsearchparams Sep 12, 2023
8 checks passed
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.

2 participants