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

Meris/refactor playable playcontext #305

Conversation

merisbahti
Copy link
Contributor

Description

Basically change the two traits PlayContextId and PlayableId, from this:

pub trait PlayableId: Id {}
pub trait PlayContextId: Id {}

impl PlayContextId for ArtistId {}
impl PlayContextId for AlbumId {}
impl PlayContextId for PlaylistId {}
impl PlayContextId for ShowId {}
impl PlayableId for TrackId {}
impl PlayableId for EpisodeId {}

To use enums, like this:

pub enum PlayContext {
    Artist(ArtistId),
    Album(AlbumId),
    Playlist(PlaylistId),
    Show(ShowId),
}
pub enum Playable {
    Track(TrackId),
    Episode(EpisodeId),
}

Motivation and Context

So that we don't have to use dynamic dispatch, and don't have to see code like this:

let sliced = track_ids
    .into_iter()
    .map(|track_id| TrackId::from_id(&track_id).unwrap())
    .collect::<Vec<_>>();
let playable = sliced
    .iter()
    .map(|x| x as &dyn PlayableId)
    .collect::<Vec<_>>();

Dependencies

None

Type of change

Please delete options that are not relevant.

  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • This change requires a documentation update

How has this been tested?

Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce.

Please also list any relevant details for your test configuration

  • Test A: xxx
  • Test B: xxx

Is this change properly documented?

Please make sure you've properly documented the changes you're making.

Don't forget to add an entry to the CHANGELOG if necessary (new features, breaking changes, relevant internal improvements).

@merisbahti
Copy link
Contributor Author

There's a lot of more doc changes required for this, but I wanted to make sure that the code compiles before changing it.

WDYT?

@marioortizmanero
Copy link
Collaborator

That is another very nice approach to IDs. It would certainly simplify some parts of their implementation, and we could go back to having IDs with lifetimes. But would you really say that using enums is easier than dynamic dispatch? As far as I know, the code you included would be basically the same:

let sliced = track_ids
    .into_iter()
    .map(|track_id| TrackId::from_id(&track_id).unwrap())
    .collect::<Vec<_>>();
let playable = sliced
    .iter()
    .map(Playable::Track)
    .collect::<Vec<_>>();

The main benefit would be that even after constructing Playable::Track, we could match against the enum and obtain the original value back. Any other effects this may have before we consider its implementation?

@merisbahti
Copy link
Contributor Author

That is another very nice approach to IDs. It would certainly simplify some parts of their implementation, and we could go back to having IDs with lifetimes. But would you really say that using enums is easier than dynamic dispatch? As far as I know, the code you included would be basically the same:

let sliced = track_ids
    .into_iter()
    .map(|track_id| TrackId::from_id(&track_id).unwrap())
    .collect::<Vec<_>>();
let playable = sliced
    .iter()
    .map(Playable::Track)
    .collect::<Vec<_>>();

The main benefit would be that even after constructing Playable::Track, we could match against the enum and obtain the original value back. Any other effects this may have before we consider its implementation?

Thanks for the reply!

Just to be clear: I'm really a noob at rust, I don't know what I'm doing. But what I noticed is that I could (according to me) do it in 1 iteration like this:

 let playable = track_ids
     .into_iter()
     .map(|track_id| Playable::Track(TrackId::from_id(&track_id).unwrap()))
     .collect::<Vec<_>>();

But I couldn't figure out how to do this with the &dyn-case (borrowing of temporary value or something).

I was able to finish my little recreational project anyways, so it's fine if we keep it the way it is!

@marioortizmanero
Copy link
Collaborator

You're right. It's not possible to do that in one call in the case of dyn because you'd be borrowing data owned by the map.

@ramsayleung, what do you think we go back to the original Id implementation? The Id<T>(str, PhantomData<T>) one. Its implementation was way simpler (no macros nor object safety), and matching against the ID group enum would make it possible to retrieve the original data type. I'm just trying to think of downsides to using this approach, because I can't believe I didn't think of this when designing Id haha.

@ramsayleung
Copy link
Owner

It feels like a century past after @marioortizmanero refactored the Id implementation. Let me take time to recall the story.

The origination of this refactoring is that we have a problem with lists of Ids don't accept multiple of them #203:

    let uris = &[
        EpisodeId::from_uri("spotify:episode:7zC6yTmY67f32WOShs0qbb").unwrap(),
        EpisodeId::from_uri("spotify:episode:3hCrwz19QlXY0myYh0XgmL").unwrap(),
        TrackId::from_uri("spotify:track:0hAMkY2kwdXPPDfQ1e3BmJ").unwrap()
    ];

Then Mario created 4 proposals to fix this problem:

I'm just trying to think of downsides to using this approach, because I can't believe I didn't think of this when designing Id haha.

I personally suggest to @merisbahti to take a look at these proposals, and what are the advantages and disadvantages of the current solution compared with those proposals? Can this approach solve the original problem #203 without introducing some new issues?

@merisbahti
Copy link
Contributor Author

Damn, looks like you've put a lot of thought into this! Fun!

I'll take a look, though I'm not fast to reply hehe.

@marioortizmanero
Copy link
Collaborator

marioortizmanero commented Mar 22, 2022

To be fair it's going to be a lot of reading, I did multiple iterations as I was trying to understand the original problem. The issue I'm seeing with the enum is that you lose the ability to call methods in the Id. For instance, having a dyn Id still makes it possible to call id.uri(). But if we were to use the enums we'd have to implement the dispatch ourselves:

match id {
    PlayableId::Track(id) => id.uri(),
    PlayableId::Episode(id) => id.uri(),
    // ...
}

This part could be implemented with a crate such as: https://docs.rs/enum_dispatch/latest/enum_dispatch/. I'm a bit hesitant to adding new libraries, but thanks to it we may be able to make IDs more:

  • Performant: we'd be able to wrap a str instead of a String, as before
  • Usable: see the example in this PR
  • Simple: the current implementation is more hacky than I'd like it to be, and relies on macros. To be fair, enum_dispatch is also a macro, but it's cleaner, in my opinion.

We'd have to consider if the change is worth it. Please let me know if there's anything I missed.

@marioortizmanero
Copy link
Collaborator

marioortizmanero commented May 25, 2022

I'm now free from finals and ready to work on this for a bit. I am modifying @merisbahti's PR to clean up the Id trait. The new approach uses a str instead of a String for performance, since it's now possible. The docs have to be updated as well. This means that the interface is more similar to what @kstep proposed back in #161.

@marioortizmanero
Copy link
Collaborator

marioortizmanero commented May 25, 2022

Alright, first iteration done. Please take a look when you have some time, @ramsayleung, and let me know what you think. Now that we don't need object safety, we can go back to the unsized Id that wraps str, and its owned variant, BufId. In this case, BufId is simply Box<Id>, aka Box<str>, since we don't really need to modify the inner string anyway.

The main issue I found is that it's illegal to have unsized enums, so for the PlayableId group we have to make it enum PlayableId<'a> { Track(&'a TrackId), ... } (sized) instead of enum PlayableId { Track(TrackId), ... } (unsized). This means that we can't just have Box<PlayableId> for the owned version, and we have to define another one with enum PlayableIdBuf { Track(TrackIdBuf), ... } instead. To avoid lots of boilerplate I have created a macro, but that is quite ugly to look at as well, since it also takes care of delegating the functions of their variants. I still have to include Borrow and ToOwned in the macro, I think, and some derives.

I know it might be a bit hard to understand, especially because of the heavy usage of macros, which I would like to reduce. I have to update the docs as well, so don't look at them too much. If you have any questions don't hesitate to ask them, and please point out any possible simplifications you may find.

@marioortizmanero
Copy link
Collaborator

marioortizmanero commented May 25, 2022

Also pinging @eladyn to see if this would still fix their issue in #218.

@eladyn
Copy link
Contributor

eladyn commented May 26, 2022

Thanks for pinging me and working on this.

The current version would definitely simplify the code from #218 a lot.

The parsing itself still needs to be done by hand, but all the ugly dyns and Boxes are now gone! That is, how it'd look like.
b.method("OpenUri", ("uri",), (), move |_, _, (uri,): (String,)| {
    enum Uri<'a> {
        Playable(PlayableId<'a>),
        Context(PlayContextId<'a>),
    }

    impl Uri<'_> {
        fn from_id<'a>(id_type: Type, id: &'a str) -> Result<Uri<'a>, IdError> {
            use Uri::*;
            let uri = match id_type {
                Type::Track => Playable(PlayableId::Track(TrackId::from_id(id)?)),
                Type::Episode => Playable(PlayableId::Episode(EpisodeId::from_id(id)?)),
                Type::Artist => Context(PlayContextId::Artist(ArtistId::from_id(id)?)),
                Type::Album => Context(PlayContextId::Album(AlbumId::from_id(id)?)),
                Type::Playlist => Context(PlayContextId::Playlist(PlaylistId::from_id(id)?)),
                Type::Show => Context(PlayContextId::Show(ShowId::from_id(id)?)),
                Type::User | Type::Collection => return Err(IdError::InvalidType),
            };
            Ok(uri)
        }
    }

    let mut chars = uri
        .strip_prefix("spotify")
        .ok_or_else(|| MethodErr::invalid_arg(&uri))?
        .chars();

    let sep = match chars.next() {
        Some(ch) if ch == '/' || ch == ':' => ch,
        _ => return Err(MethodErr::invalid_arg(&uri)),
    };
    let rest = chars.as_str();

    let (id_type, id) = rest
        .rsplit_once(sep)
        .and_then(|(id_type, id)| Some((id_type.parse::<Type>().ok()?, id)))
        .ok_or_else(|| MethodErr::invalid_arg(&uri))?;

    let uri = Uri::from_id(id_type, id).map_err(|_| MethodErr::invalid_arg(&uri))?;

    let device_name = utf8_percent_encode(&mv_device_name, NON_ALPHANUMERIC).to_string();
    let device_id = sp_client.device().ok().and_then(|devices| {
        devices.into_iter().find_map(|d| {
            if d.is_active && d.name == device_name {
                d.id
            } else {
                None
            }
        })
    });

    match uri {
        Uri::Playable(id) => {
            let _ = sp_client.start_uris_playback(
                Some(id),
                device_id.as_deref(),
                Some(Offset::for_position(0)),
                None,
            );
        }
        Uri::Context(id) => {
            let _ = sp_client.start_context_playback(
                id,
                device_id.as_deref(),
                Some(Offset::for_position(0)),
                None,
            );
        }
    }
    Ok(())
});

Regarding the different owned (e.g. ArtistIdBuf) and unowned (ArtistId) variants of the Id types: Wouldn't it be possible to define the types with the help of Cows?

pub struct ArtistId<'a>(Cow<'a, str>);
pub struct TrackId<'a>(Cow<'a, str>);
pub struct PlaylistId<'a>(Cow<'a, str>);
pub struct AlbumId<'a>(Cow<'a, str>);
pub struct EpisodeId<'a>(Cow<'a, str>);
pub struct UserId<'a>(Cow<'a, str>);

Those types would then contain both the owned and the unowned variant. As such, some boilerplate code could probably be removed.

I played a bit with that idea, and it seems like it could work out. You can see a very raw (and not compiling) prototype here. Some things that I noticed:

  • There are many occasions, where the public API surface would then change from accepting e.g. a &TrackId to TrackId<'_>, which would be breaking.
  • structs like FullArtist would need to be changed to using a ArtistId<'static> instead of ArtistIdBuf, in order to avoid introducing the lifetime in the type itself. That seems quite practical, however, since it allows &'static str to be used as well as String for the ids.
  • It might make the API surface a bit awkward, since the user needs to know that, instead of cloning, they may as well call .as_borrowed() (in the above prototype) to obtain another instance of the id.

See also this article, which covers a similar use case.

Looking forward to hearing your thoughts on this!

@marioortizmanero
Copy link
Collaborator

marioortizmanero commented May 26, 2022

That does look a bit better. What I really don't like is that you have to implement the parsing part yourself. I guess we could have an AnyId that works for all kinds of Ids, and then add a from_id and from_uri for the Id groups as well. That way, you could just do AnyId::from_uri(uri) and then match against its possible values to build one of your Uri instances.

About the Cow approach, I did think about it, but I wasn't sure what to think. What I really liked about having a borrowed and an owned version is that you don't need lifetimes in the owned one at all, but you're right that we could just use 'static. It's not a problem to break the API right now, as long as we're improving the interface. If it weren't for the borrowed/owned issue we have with the group Ids, I would keep the current approach, but I think that having Cow is definitely a better idea. I will give it a try on this branch!

@eladyn
Copy link
Contributor

eladyn commented May 26, 2022

That does look a bit better. What I really don't like is that you have to implement the parsing part yourself. I guess we could have an AnyId that works for all kinds of Ids, and then add a from_id and from_uri for the Id groups as well. That way, you could just do AnyId::from_uri(uri) and then match against its possible values to build one of your Uri instances.

Yeah, that sounds reasonable. Something that might make sense as well, are methods on that AnyId type like the following:

fn as_playable_id(&self) -> Option<PlayableId>;
fn to_playable_id(self) -> Option<PlayableIdBuf>;
fn as_play_context_id(&self) -> Option<PlayContextId>;
fn to_play_context_id(self) -> Option<PlayContextIdBuf>;

But those might also be a bit specific and easy enough to implement that it seems acceptable to leave matching AnyId to the library user.

An alternative, which is a bit hacky and inefficient however, would be, to offer the from_id / from_uri / from_id_or_uri methods for PlayableId and PlayContextId. Then, in my case, I could just try parsing the supplied URI into both types and use the one that works.

@marioortizmanero
Copy link
Collaborator

Yeah, that sounds reasonable. Something that might make sense as well, are methods on that AnyId type like the following:

Definitely, we can add those for usability.

Using Cow makes many things simpler:

  • No hack needed to implement Clone nor Default for the Id types (three less transmutes!)
  • Id groups don't need a hack either to have borrowed and owned versions
  • Macros are way simpler

I would definitely leave it like this, check out the last commit. I still have to fix a couple things but it's late already so I've left it as TODOs and I'll try to fix them when I can (any help appreciated). Thanks a lot for your feedback :)

@marioortizmanero marioortizmanero force-pushed the meris/refactor-playable-playcontext branch from a08da06 to a46a922 Compare May 27, 2022 00:43
Copy link
Contributor

@eladyn eladyn left a comment

Choose a reason for hiding this comment

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

I added some thoughts on various TODOs and other things. I also pushed a version that successfully compiles and runs the tests here, if you're interested.

src/lib.rs Outdated Show resolved Hide resolved
tests/test_with_oauth.rs Outdated Show resolved Hide resolved
rspotify-model/src/idtypes.rs Show resolved Hide resolved
tests/test_with_oauth.rs Outdated Show resolved Hide resolved
rspotify-model/src/lib.rs Outdated Show resolved Hide resolved
src/clients/base.rs Outdated Show resolved Hide resolved
src/clients/base.rs Outdated Show resolved Hide resolved
src/clients/base.rs Outdated Show resolved Hide resolved
src/clients/base.rs Outdated Show resolved Hide resolved
rspotify-model/src/idtypes.rs Outdated Show resolved Hide resolved
@marioortizmanero
Copy link
Collaborator

That is splendid, thanks a lot for the help with your branch. Sorry for the crappy code, it was late in the night :P

I'll merge it here and do one last review before we're done.

@marioortizmanero
Copy link
Collaborator

The only thing that's bothering me is the double reference in cases like:

    fn playlist_items<'a>(
        &'a self,
        playlist_id: &'a PlaylistId<'_>,
        fields: Option<&'a str>,
        market: Option<&'a Market>,
    ) -> Paginator<'_, ClientResult<PlaylistItem>> {
        paginate(
            move |limit, offset| {
                self.playlist_items_manual(
                    playlist_id.as_borrowed(),
                    fields,
                    market,
                    Some(limit),

But I think there's nothing we can do. All the new appearances of as_borrowed are a bit ugly as well. It's definitely better than the Clone anyway. Do you think we could rename it to as_ref just to have it shorter? Maybe just borrow? Or would that be confusing?

Also, do you think you could spin up a quick benchmark for both approaches to ID joining before we use the more complex one? You could use the one I did in the past as a reference: #161 (comment)

@eladyn
Copy link
Contributor

eladyn commented May 31, 2022

The only thing that's bothering me is the double reference in cases like: … But I think there's nothing we can do.

Well, it's only a double reference, if the underlying variant is the Cow::Borrowed, but I see, what you mean. In theory, it seems like it should be possible with some lifetime magic, but I have no specific proposal on this one yet.

All the new appearances of as_borrowed are a bit ugly as well. It's definitely better than the Clone anyway. Do you think we could rename it to as_ref just to have it shorter? Maybe just borrow? Or would that be confusing?

Yeah, sure, name it however you want. I was initially going to ask you about the method name, but that somehow got lost somewhere in the process. I would personally opt for as_ref() because borrow() sounds like something more expensive, but have no strong preference.

Also, do you think you could spin up a quick benchmark for both approaches to ID joining before we use the more complex one? You could use the one I did in the past as a reference: #161 (comment)

Yeah, I'll happily experiment with that, probably tomorrow, however.

@eladyn
Copy link
Contributor

eladyn commented Jun 1, 2022

I've finally got around to benchmarking the different approaches. As you will see, I also threw a third implementation in there, which makes use of the unstable iter_intersperse feature, which allows replacing the last .collect::<Vec<_>>().join(",") with just one .collect::<String>().

The code can be found below.
#![feature(test, iter_intersperse)]

extern crate test;

fn main() {}

trait Id<'a> {
    fn id(&self) -> &str;
}

impl Id<'_> for &str {
    fn id(&self) -> &str {
        self
    }
}

#[inline]
pub(in crate) fn join_ids<'a, T: Id<'a> + 'a>(ids: impl IntoIterator<Item = T>) -> String {
    let mut ids = ids.into_iter();
    let mut joined = if let Some(first) = ids.next() { String::from(first.id()) } else { return String::new() };
    for id in ids {
        joined.push_str(",");
        joined.push_str(id.id());
    }
    joined
}

#[inline]
pub(in crate) fn join_ids_collect<'a, T: Id<'a> + 'a>(ids: impl IntoIterator<Item = T>) -> String {
    let ids = ids.into_iter().collect::<Vec<_>>();
    ids.iter().map(Id::id).collect::<Vec<_>>().join(",")
}

#[inline]
pub(in crate) fn join_ids_to_string<'a, T: Id<'a> + 'a>(ids: impl IntoIterator<Item = T>) -> String {
    ids.into_iter().map(|id| id.id().to_string()).collect::<Vec<_>>().join(",")
}

#[inline]
pub(in crate) fn join_ids_intersperse<'a, T: Id<'a> + 'a>(ids: impl IntoIterator<Item = T>) -> String {
    let ids = ids.into_iter().collect::<Vec<_>>();
    ids.iter().map(Id::id).intersperse(",").collect()
}

#[cfg(test)]
mod tests {
    use test::Bencher;

    const MAX: u32 = 100_000;

    fn initial_vec() -> Vec<&'static str> {
        let mut v = Vec::new();
        for i in 1..=MAX {
            if i % 2 == 0 {
                v.push("even number here");
            } else {
                v.push("odd number over here");
            }
        }
        v
    }

    #[bench]
    fn bench_custom_join(b: &mut Bencher) {
        let data = initial_vec();

        b.iter(|| {
            super::join_ids(data.iter().copied());
        });
    }

    #[bench]
    fn bench_join_intersperse(b: &mut Bencher) {
        let data = initial_vec();

        b.iter(|| {
            super::join_ids_intersperse(data.iter().copied());
        });
    }

    #[bench]
    fn bench_join_double_collect(b: &mut Bencher) {
        let data = initial_vec();

        b.iter(|| {
            super::join_ids_collect(data.iter().copied());
        });
    }

    #[bench]
    fn bench_join_to_string(b: &mut Bencher) {
        let data = initial_vec();

        b.iter(|| {
            super::join_ids_to_string(data.iter().copied());
        });
    }
}

The results are … interesting, and I'll probably leave a proper interpretation and conclusion up to you:

  • with MAX = 50:
    running 4 tests
    test tests::bench_custom_join         ... bench:         429 ns/iter (+/- 19)
    test tests::bench_join_double_collect ... bench:         239 ns/iter (+/- 8)
    test tests::bench_join_intersperse    ... bench:         542 ns/iter (+/- 13)
    test tests::bench_join_to_string      ... bench:       1,697 ns/iter (+/- 36)
    
    test result: ok. 0 passed; 0 failed; 0 ignored; 4 measured; 0 filtered out; finished in 0.67s
    
  • with MAX = 100_000:
    running 4 tests
    test tests::bench_custom_join         ... bench:     377,552 ns/iter (+/- 53,887)
    test tests::bench_join_double_collect ... bench:     557,764 ns/iter (+/- 124,244)
    test tests::bench_join_intersperse    ... bench:   1,556,949 ns/iter (+/- 225,427)
    test tests::bench_join_to_string      ... bench:   6,106,705 ns/iter (+/- 288,209)
    
    test result: ok. 0 passed; 0 failed; 0 ignored; 4 measured; 0 filtered out; finished in 14.97s
    
  • with MAX = 1_000_000:
    running 4 tests
    test tests::bench_custom_join         ... bench:   4,100,927 ns/iter (+/- 748,199)
    test tests::bench_join_double_collect ... bench:  14,232,532 ns/iter (+/- 743,242)
    test tests::bench_join_intersperse    ... bench:  14,837,128 ns/iter (+/- 638,982)
    test tests::bench_join_to_string      ... bench:  58,130,821 ns/iter (+/- 1,719,741)
    
    test result: ok. 0 passed; 0 failed; 0 ignored; 4 measured; 0 filtered out; finished in 30.10s
    

While the method involving .to_string() is obviously the most inefficient, the other results heavily depend on the size of the initial_vec that is iterated over. The function with intersperse is apparently not worth waiting for (at least in this case), as it was generally worse or at most as good as the other two.

Now, between the "custom" joining and the .collect()...collect().join() the situation is a bit more interesting. The latter is faster with a smaller initial_vec while the former takes over at higher sizes. I'm assuming that it is rather rare that join_ids is called with such large item counts, which would mean that we can light-heartedly use the double collect + join variant?

Interested in reading your thoughts on that one!

@marioortizmanero
Copy link
Collaborator

marioortizmanero commented Jun 2, 2022

Agreed, let's rename it to as_ref just for brevity.

Regarding joining the Ids, yeah, we have to remember that the size of the iterator won't actually be that large most times. Only after #298 you can actually get past the API limits, but even then I'm not sure if someone would ever pass 100.000 songs. I would personally do the double collect just because it's more maintainable. It seems more efficient for smaller sizes, but that's less importantly since it's quite approximate. Pretty cool that you tried interperse too btw, I didn't know about that feature.

@marioortizmanero
Copy link
Collaborator

marioortizmanero commented Jun 2, 2022

Oh, I just realized that clippy prints out this warning:

warning: calling `push_str()` using a single-character string literal
   --> src/lib.rs:302:9
    |
302 |         joined.push_str(",");
    |         ^^^^^^^^^^^^^^^^^^^^ help: consider using `push` with a character literal: `joined.push(',')`
    |
    = note: `#[warn(clippy::single_char_add_str)]` on by default
    = help: for further information visit https://rust-lang.github.io/rust-clippy/master/index.html#single_char_add_str

Maybe it would be more efficient with its suggestion? Would you mind re-running the benchmarks with that real quick before I change it? Not sure if it works like that for intersperse too.

@marioortizmanero
Copy link
Collaborator

marioortizmanero commented Jun 2, 2022

I've modified the groups so that they're implemented automatically with the macro enum_dispatch:

  • Good: I would say the implementation is a bit simpler now, but it also requires that you know what enum_dispatch is.
  • Good: From is now also implemented for the groups.
  • Bad: It also means we need to move the static methods outside the trait (I don't think that matters, though).
  • Bad: It doesn't work for as_ref, so I've implemented these manually. We could report this as a bug and maybe fix it in the future.

Some more questions:

  • Do you agree that it's a bit better with enum_dispatch? Or should we go back to defining two macros of our own?
  • Does the new parse_uri help with your original problem? It was also useful to remove some code from the macro.
  • Any thoughts on the two remaining TODOs?

@eladyn
Copy link
Contributor

eladyn commented Jun 2, 2022

Maybe it would be more efficient with its suggestion? Would you mind re-running the benchmarks with that real quick before I change it? Not sure if it works like that for intersperse too.

Oh, that's interesting. Unfortunately, it seems like this'd be only a cosmetic change, as it doesn't change the results much, from what I can see. So I would agree with you going for the double collect.

Some more questions:

  • Do you agree that it's a bit better with enum_dispatch? Or should we go back to defining two macros of our own?

Yeah, that definitely looks much cleaner!

  • Does the new parse_uri help with your original problem? It was also useful to remove some code from the macro.

With that and the From<…> impls with enum_dispatch, my original function is now considerably more readable and – in comparison to the 0.8 version – type safe and complete, so that is really cool.

  • Any thoughts on the two remaining TODOs?

I will add them among my other few last comments in the review below.

The only “bigger” thing that came up when experimenting with the new API is the following:

As far as I can tell, there is currently no easy way to directly construct an owned variant of any Id type from a String. Instead, one has to do something like .from_id(...).into_static(). Would it be possible to change the signature of the .from_id(...) function to something that accepts a Cow<'_, str, for example? Or maybe add another method that does the same as .from_id(), but for Strings?

rspotify-model/src/idtypes.rs Outdated Show resolved Hide resolved
rspotify-model/src/idtypes.rs Show resolved Hide resolved
rspotify-model/src/idtypes.rs Outdated Show resolved Hide resolved
rspotify-model/src/idtypes.rs Outdated Show resolved Hide resolved
rspotify-model/src/idtypes.rs Outdated Show resolved Hide resolved
@marioortizmanero
Copy link
Collaborator

Ok, I've fixed everything, even some examples that we were missing. I've also added string interpolation where I could, as I'll merge #294 after this. I've opened #324 for the webapp fix.

As far as I can tell, there is currently no easy way to directly construct an owned variant of any Id type from a String. Instead, one has to do something like .from_id(...).into_static(). Would it be possible to change the signature of the .from_id(...) function to something that accepts a Cow<'_, str, for example? Or maybe add another method that does the same as .from_id(), but for Strings?

Yeah that's a good point. We could add something like from_id_owned? Or from_id_cow? We could also make from_id generic over the input, I'll try that.

rspotify-model/src/idtypes.rs Outdated Show resolved Hide resolved
///
/// The string passed to this method must be made out of valid
/// characters only; otherwise undefined behaviour may occur.
pub unsafe fn from_id_unchecked<S>(id: S) -> Self
Copy link
Owner

Choose a reason for hiding this comment

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

I am not sure if it's necessary to use unsafe statement?

Copy link
Collaborator

Choose a reason for hiding this comment

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

I mean, we aren't technically violating memory safety here, but we are violating the integrity of the ID type system. So I thought unsafe was a good enough indicator to be careful about its usage. I guess it's weird because we don't actually have unsafe code inside it, so we could use a crate like https://docs.rs/unsafe_fn/latest/unsafe_fn/. I would use this new lint, however: https://doc.rust-lang.org/stable/nightly-rustc/rustc_lint/builtin/static.UNSAFE_OP_IN_UNSAFE_FN.html

What do you think?

Copy link
Contributor

Choose a reason for hiding this comment

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

I think it should be unsafe as we are really violating type safety in the sense we violate guarantees of id types here. So it's up to programmer to uphold these guarantees, hence it's unsafe.

}
// These don't work with `enum_dispatch`, unfortunately.
impl<'a> PlayContextId<'a> {
pub fn as_ref(&'a self) -> PlayContextId<'a> {
Copy link
Owner

Choose a reason for hiding this comment

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

I am a little confused about how this as_ref function works?

Copy link
Collaborator

Choose a reason for hiding this comment

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

It makes it possible to reborrow the internal string so that no cloning is necessary. It just takes another immutable reference to the inner contents, with the same lifetime.

This is what I'm the most unhappy about in the PR. It is somewhat annoying to use but I think it's the only way.

@ramsayleung
Copy link
Owner

ramsayleung commented Jul 3, 2022

If I don't misunderstand the insight of this PR, the whole design of the PR would look like that:

image

The key problem is that some endpoints accept PlayableId and PlayContextId as parameter, and the uri(we need to send to Spotify API) of these Id depend on the type of Id:

    /// Examples: `spotify:album:6IcGNaXFRf5Y1jc7QsE9O2`,
    /// `spotify:track:4y4VO05kYgUTo2bzbox1an`.
    fn uri(&self) -> String {
        format!("spotify:{}:{}", self._type(), self.id())
    }

So we need dynamic dispatch mechanism, our previous implementation was built on Box and dyn, with the cost of overhead and usability. Now, the current implementation regains the ability of dynamic dispatch by leveraging the power of enum_dispatch. As @eladyn suggested, we use Cow<'a, str> to hold the data of id, instead of maintaining two versions of id data, the borrowed version and owned version.

This is my concern too, in order to know the Id type of given string, the library user have to parse the string manually.

What I really don't like is that you have to implement the parsing part yourself. I guess we could have an AnyId that works for all kinds of Ids, and then add a from_id and from_uri for the Id groups as well. That way, you could just do AnyId::from_uri(uri) and then match against its possible values to build one of your Uri instances.

The source code of the `plantuml` diagram:
@startuml
abstract Id{
 +{abstract}id(&self) -> &str
 +{abstract}_type(&self) -> Type
==
 +uri(&self) -> String
 +url(&self) -> String
 +parse_uri(uri: &str) -> Result<(Type, &str), IdError> 
}


note left of Id::uri
  The implemetation of uri is `spotify:{type}:{id}`
end note

abstract PlayContextId extends Id
abstract PlayableId extends Id

note right of AlbumId::_type
  The `Type` of AlbumId is `Album`
end note

PlayContextId <|-- AlbumId
class AlbumId{
[type = Album]
==
Cow<'a, str> id_data
==
 + id(&self) -> &str
 + _type(&self) -> Type
==
+id_is_valid(id: &str) -> bool
+from_id_unchecked(id: S) -> Self
+from_id(id: S) -> Result<Self, IdError>
+from_uri(uri: &'a str) -> Result<Self, IdError>
+from_id_or_uri(id_or_uri: &'a str) -> Result<Self, IdError>
+as_ref(&'a self) -> AlbumId<'a>
+into_static(self) -> AlbumId<'static>
+clone_static(&self) -> AlbumId<'static>
}

note bottom of TrackId
  Other Ids are mostly similar to AlbumId, so just dismiss their details
end note

PlayContextId <|-- TrackId
class TrackId{
[type = Track]
...
}

PlayContextId <|-- PlaylistId
class PlaylistId{
[type = Playlist]
...
}

PlayContextId <|-- ArtistId
class ArtistId{
[type = Artist]
...
}

PlayableId <|-- ShowId
class ShowId{
[type = Show]
...
}

PlayableId <|-- EpisodeId
class EpisodeId{
[type = Episode]
...
}

Id <|-- UserId
class UserId{
[type = User]
...
}


abstract OAuthClient{
+ start_context_playback(&self, context_uri: <b>PlayContextId</b><'_>) -> ClientResult<()>
+ start_uris_playback<'a>(&self, uris: impl IntoIterator<Item = <b>PlayableId</b><'a>>) -> ClientResult<()> 
+ playlist_add_items( &self, items: impl IntoIterator<Item = <b>PlayableId</b><'a>>) -> ClientResult<PlaylistResult>
+ playlist_replace_items<'a>(&self, items: impl IntoIterator<Item = <b>PlayableId</b><'a>>) -> ClientResult<()>
+ playlist_remove_all_occurrences_of_items( &self, track_ids: impl IntoIterator<Item = <b>PlayableId</b><'a>>) -> ClientResult<PlaylistResult> 
+ start_uris_playback<'a>( &self, uris: impl IntoIterator<Item = <b>PlayableId</b><'a>>) -> ClientResult<()>
+ add_item_to_queue( &self, item: <b>PlayableId</b><'_>,) -> ClientResult<()>

}

@enduml

@marioortizmanero
Copy link
Collaborator

Great job on the diagram! It's quite well made, but it doesn't feel right because we aren't really using inheritance anymore. As in, AlbumId doesn't inherit nor implement PlayContextId, it's just a variant in that enum, and both AlbumId and PlayContextId implement the trait Id. The latter delegates the implementation to the inner variants, but it's more composition than inheritance now. Anyhow, yeah, your comment sums this up.

This is my concern too, in order to know the Id type of given string, the library user have to parse the string manually.

I think we don't need the AnyId group anymore, unless @eladyn says otherwise, thanks to the addition of parse_uri.

@ramsayleung
Copy link
Owner

ramsayleung commented Jul 3, 2022

it doesn't feel right because we aren't really using inheritance anymore. As in, AlbumId doesn't inherit nor implement PlayContextId, it's just a variant in that enum, and both AlbumId and PlayContextId implement the trait Id

Yes, I know that, but it's a little hard to express enum dispatch in class diagram, because there is no such thing is OOP, all they have are inheritance and implementation. I update the class diagram to show the relationship between PlayContextId, PlayableId and Id. If you think the updated diagram is correct, probably we could add it into documentation after this PR merged.

image

I think we don't need the AnyId group anymore, unless @eladyn says otherwise, thanks to the addition of parse_uri.

Cool!

The source code of the `plantuml` diagram:
@startuml
abstract Id{
 +{abstract}id(&self) -> &str
 +{abstract}_type(&self) -> Type
==
 +uri(&self) -> String
 +url(&self) -> String
 +parse_uri(uri: &str) -> Result<(Type, &str), IdError> 
}


note left of Id::uri
  The implemetation of uri is `spotify:{type}:{id}`
end note

enum PlayContextId implements Id

enum PlayContextId{
AlbumId
TrackId
PlaylistId
ArtistId
}

enum PlayableId implements Id

enum PlayableId{
ShowId
EpisodeId
}

note bottom of PlayContextId
call dynamic-dispatched methods by `enum_dispatch` crate with almost no overhead.
end note

note right of AlbumId::_type
  The `Type` of AlbumId is `Album`
end note

Id <|-- AlbumId
class AlbumId{
[type = Album]
==
Cow<'a, str> id_data
==
 + id(&self) -> &str
 + _type(&self) -> Type
==
+id_is_valid(id: &str) -> bool
+from_id_unchecked(id: S) -> Self
+from_id(id: S) -> Result<Self, IdError>
+from_uri(uri: &'a str) -> Result<Self, IdError>
+from_id_or_uri(id_or_uri: &'a str) -> Result<Self, IdError>
+as_ref(&'a self) -> AlbumId<'a>
+into_static(self) -> AlbumId<'static>
+clone_static(&self) -> AlbumId<'static>
}

note bottom of TrackId
  Other Ids are mostly similar to AlbumId, so just dismiss their details
end note

Id <|-- TrackId
class TrackId{
[type = Track]
...
}

Id <|-- PlaylistId
class PlaylistId{
[type = Playlist]
...
}

Id <|-- ArtistId
class ArtistId{
[type = Artist]
...
}

Id <|-- ShowId
class ShowId{
[type = Show]
...
}

Id <|-- EpisodeId
class EpisodeId{
[type = Episode]
...
}

Id <|-- UserId
class UserId{
[type = User]
...
}


abstract OAuthClient{
+ start_context_playback(&self, context_uri: <b>PlayContextId</b><'_>) -> ClientResult<()>
+ start_uris_playback<'a>(&self, uris: impl IntoIterator<Item = <b>PlayableId</b><'a>>) -> ClientResult<()> 
+ playlist_add_items( &self, items: impl IntoIterator<Item = <b>PlayableId</b><'a>>) -> ClientResult<PlaylistResult>
+ playlist_replace_items<'a>(&self, items: impl IntoIterator<Item = <b>PlayableId</b><'a>>) -> ClientResult<()>
+ playlist_remove_all_occurrences_of_items( &self, track_ids: impl IntoIterator<Item = <b>PlayableId</b><'a>>) -> ClientResult<PlaylistResult> 
+ start_uris_playback<'a>( &self, uris: impl IntoIterator<Item = <b>PlayableId</b><'a>>) -> ClientResult<()>
+ add_item_to_queue( &self, item: <b>PlayableId</b><'_>,) -> ClientResult<()>

}

@enduml

@marioortizmanero
Copy link
Collaborator

Cool. Just one more thing before adding that image to the docs: it seems like the uri method in Id is duplicate. And parse_uri is a separate function, not sure if I would include it inside the trait. There's a typo: implemetation in one of the bubbles should be implementation.

I've just added one more test for the into_static and clone_static functions. And I've fixed the clippy warnings. Shall we merge this PR, then?

@ramsayleung
Copy link
Owner

ramsayleung commented Jul 4, 2022

it seems like the uri method in Id is duplicate

I think it's not duplicate, one of them is uri, and the other is url, but these two functions look similar.

And parse_uri is a separate function, not sure if I would include it inside the trait.

I think we could include it inside the Id trait, otherwise we need to leave it inside the macro definition, the macro code will unnecessarily blow up. Do you mean move parse_uri outside the Id trait, and leaving it as

There's a typo: implemetation in one of the bubbles should be implementation.

oooh, Good catch.

Shall we merge this PR, then?

Probably, we could add the list of endpoints we just break in the CHANGELOG, for example start_context_playback, start_uri_playback, etc.

Copy link
Contributor

@eladyn eladyn left a comment

Choose a reason for hiding this comment

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

I went over the code once again and found some minor last things. Looking forward to seeing this merged!

rspotify-model/src/idtypes.rs Outdated Show resolved Hide resolved
rspotify-model/src/idtypes.rs Outdated Show resolved Hide resolved
rspotify-model/src/idtypes.rs Show resolved Hide resolved
@marioortizmanero
Copy link
Collaborator

marioortizmanero commented Jul 9, 2022

I think we could include it inside the Id trait, otherwise we need to leave it inside the macro definition, the macro code will unnecessarily blow up. Do you mean move parse_uri outside the Id trait, and leaving it as

Sorry, not quite sure what you meant here, @ramsayleung. It's useful to have it as a separate function so that anyone can implement their own Id type.

Probably, we could add the list of endpoints we just break in the CHANGELOG, for example start_context_playback, start_uri_playback, etc.

I honestly don't think it's worth listing the broken methods. I was doing it but literally 90% are broken and it's just noise in the changelog, I would say. Shall we merge like this?

Thanks for the last review, @eladyn, I've just fixed your suggestions. I've also:

  • Ellided lifetimes where possible
  • Removed TODO comments (the join_ids one)
  • A couple other clippy lints I found. I decided to continue with these in Cleanup with more clippy lints #336.

The into_static and clone_static are unfortunate because it's a lot of boilerplate, but oh well.

@marioortizmanero marioortizmanero force-pushed the meris/refactor-playable-playcontext branch from a73383b to e182979 Compare July 9, 2022 18:14
@marioortizmanero marioortizmanero force-pushed the meris/refactor-playable-playcontext branch from e182979 to db0d7fa Compare July 9, 2022 18:39
@eladyn
Copy link
Contributor

eladyn commented Jul 9, 2022

Coming back to what you wrote in #305 (comment): I finally managed to get it (kind of) working the way I imagined it could work.

There might be a simpler way, but the way I solved this is accepting a generic context to be passed into a paginate_with_ctx function. Then, the closure is allowed to access those variables, as the lifetime bounds guarantee that no references outlive their referenced data.

All in all, it's a bit ugly, but none of that ugliness reaches “user code”, so it might be fine? You can find the code here. In order to not clutter this PR even more, do you think it would be a good idea to open a new one after this is merged? We could then discuss those changes separately.

@ramsayleung
Copy link
Owner

ramsayleung commented Jul 10, 2022

It's useful to have it as a separate function so that anyone can implement their own Id type.

It makes sense :)

I honestly don't think it's worth listing the broken methods. I was doing it but literally 90% are broken and it's just noise in the changelog, I would say. Shall we merge like this?

I get your point :)

rspotify-model/src/lib.rs Show resolved Hide resolved
src/clients/base.rs Show resolved Hide resolved
@@ -314,7 +314,7 @@ where
/// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-an-artists-albums)
fn artist_albums<'a>(
&'a self,
artist_id: &'a ArtistId,
artist_id: &'a ArtistId<'_>,
Copy link
Owner

Choose a reason for hiding this comment

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

artist_id: &'a ArtistId<'_>, should be artist_id: ArtistId<'_>,? Because we don't need the explicit life notation 'a.

Copy link
Contributor

@eladyn eladyn Jul 10, 2022

Choose a reason for hiding this comment

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

I'm going to reply here, but this applies to several other review comments too (every auto-paginated endpoint, not those with _manual):

This is something that we discussed earlier on this PR, see these comments and this one. Omitting the & reference works in the sync case, but not in the async one (because the closure passed to paginate would return a Future that contains a reference to the id that is owned by the closure).

As I wrote here, I found a potential solution to this problem, which can be found in eladyn@b2bfaff. That solution is a bit more complex however and might be better suited for a follow-up PR?

src/clients/base.rs Show resolved Hide resolved
src/clients/base.rs Show resolved Hide resolved
src/clients/base.rs Show resolved Hide resolved
src/clients/base.rs Show resolved Hide resolved
src/clients/base.rs Show resolved Hide resolved
@@ -7,6 +7,8 @@ pub use oauth::OAuthClient;

use crate::ClientResult;

use std::fmt::Write as _;
Copy link
Owner

Choose a reason for hiding this comment

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

I am a little confused, what does this statement mean?

Copy link
Collaborator

Choose a reason for hiding this comment

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

It just imports the trait without getting it into the namespace. We just want to be able to use the methods in the trait, but not really Write itself, as it may collide or get in the way.

ids.into_iter().map(Id::id).collect::<Vec<_>>().join(",")
pub(in crate) fn join_ids<'a, T: Id + 'a>(ids: impl IntoIterator<Item = T>) -> String {
let ids = ids.into_iter().collect::<Vec<_>>();
ids.iter().map(Id::id).collect::<Vec<_>>().join(",")
Copy link
Owner

Choose a reason for hiding this comment

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

Do we need to collect twice to join_ids, it will allocate resource twice?

Copy link
Collaborator

Choose a reason for hiding this comment

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

Please refer to this: #305 (comment)

@ramsayleung
Copy link
Owner

Great, I think we are ready to merge this PR?

@marioortizmanero
Copy link
Collaborator

Sure, merging this!

Thanks for the proposed fix, @eladyn, I'll open a new PR for that and we can discuss it before the new version.

@marioortizmanero marioortizmanero merged commit 9b2e70a into ramsayleung:master Jul 13, 2022
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.

5 participants