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

Improve type-safety for the total routes. #19

Merged
merged 8 commits into from
Nov 9, 2024
Merged

Conversation

arturaz
Copy link
Contributor

@arturaz arturaz commented Jun 20, 2024

This should be source compatible but not binary compatible due to Route -> RouteImpl change.

This should be source compatible but not binary compatible due to `Route` -> `RouteImpl` change.
@arturaz arturaz requested a review from raquo as a code owner June 20, 2024 10:43
@raquo
Copy link
Owner

raquo commented Jun 20, 2024

Thanks! TBH initially I didn't realize that we would need to distinguish between partial and total routes to support the desired functionality, but it's obvious now.

I think your goal can be achieved in a simpler way: make Route into an abstract class, and have Route.Partial and Route.Total classes extend from it. The Route abstract class should have pretty much the entire implementation in it, with the Total and Partial subclasses containing only the methods that are specific to those use cases (probably none for Partial, and that one new method you want for Total).

I understand that this would be less flexible than using thin traits and thick impls, but I don't want to optimize for flexibility, I want to optimize for simplicity, and part of that is representing things the way we think about the domain problem. I don't think of routes as containing or deriving other routes (def backing), and I don't think of routes as something abstract that can have multiple implementations. All routes share the same implementation, total routes just have some extra methods. In this PR, it's hard to see that Route has shared implementation. It just... happens to be shared because one impl is calling into another, but the sharing is not a natural part of its structure the way a shared superclass is.

@arturaz
Copy link
Contributor Author

arturaz commented Jun 24, 2024

Restructured as requested.

Comment on lines 457 to 464
): Partial[Page, FragmentPatternArgs[PathArgs, QueryArgs, FragmentArgs]] = {
withQueryAndFragmentRouteBuilder(pattern)(b => new Partial(
matchEncodePF = matchEncode,
decodePF = decode,
createRelativeUrl = { args =>
val patternArgs = PathQueryFragmentMatching(path = args.path, query = args.query, fragment = args.fragment)
"/" + pattern.createPart(patternArgs)
},
matchRelativeUrl = relativeUrl => pattern.matchRawUrl(relativeUrl).toOption,
createRelativeUrl = b.createRelativeUrl,
matchRelativeUrl = b.matchRelativeUrl,
basePath = basePath
)
))
Copy link
Owner

Choose a reason for hiding this comment

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

So, previously, withQueryAndFragment would call into withQueryAndFragmentPF, but now that's not possible because we have specialized Partial and Total classes for the two cases.

In this PR, to keep sharing the code between those implementations, you've introduced the RouteBuilder abstraction. After looking at it, I don't think it is carrying its weight. With only two usages of withQueryAndFragmentRouteBuilder, we are actually spending more lines of code (incl. the definition of withQueryAndFragmentRouteBuilder) compared to just not sharing the implementations of withQueryAndFragment and withQueryAndFragmentPF. For me, both shorter code, and fewer abstractions / indirections is better, even if it's a bit repetitive.

The same goes for other uses of RouteBuilder. IIUC, they all exist only for code sharing, but since they produce more lines of code, and more abstracted / indirect code, I think the RouteBuilder abstraction should be eliminated. Implementations should just spell out everything with no regard for code sharing, like the old def withQueryAndFragmentPF, def onlyQueryAndFragmentPF, etc. do.

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 originally did what you said, but then because all of the things are private inside Route, you have to have private[waypoint] def toTotal: Route.Total[Page, Args] in there and then you have to explicitly provide type arguments in the Total constructors because Scala couldn't infer them from type signature anymore, which felt just as repetive.

While the builder currently doesn't do much IMHO it's fundamentally sensible, especially if more shared code is added in the future. The other approach is just copy-pasting code, which is easy but allows for errors when modifying code. I suppose you can argue that tests should catch it?

Copy link
Owner

@raquo raquo Jun 26, 2024

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 you mean with def toTotal – would that be a method on Partial? Why would we need it?

What I meant, for example, is that def onlyQuery could be just something like:

  new Total(
      encode = encode,
      decode = decode,
      createRelativeUrl = args => "/" + pattern.createUrlString(path = (), params = args),
      matchRelativeUrl = relativeUrl => pattern.matchRawUrl(relativeUrl).toOption.map(_.params),
      basePath = basePath
  ))

and def onlyQueryPF could have a very similar but non-shared implementation with new Partial(...). It would repeat the createRelativeUrl / matchRelativeUrl code, but those are just two usages of two lines, I'm fine with that to reduce abstraction and make the code shorter.

Comment on lines 482 to 485
): Total[Page, Unit] = {
new Total[Page, Unit](
encode = _ => (),
decode = _ => staticPage,
Copy link
Owner

Choose a reason for hiding this comment

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

Hm. I'm not sure if it's safe to remove the == staticPage check. I have a feeling that this won't always work. For example:

trait Page { ... }
case object LoginPage extends Page

def initialPage: Page = LoginPage

val initialRoute = Route.static(initialPage, ...)

OTOH, initialRoute will capture ClassTag[Page] instead of ClassTag[LoginPage.type], and so it will match all instances of Page, not just LoginPage. This is unexpected for users, because users provide a specific initialPage for this route, so they expect the matching to happen against that page with ==, instead of by type.

I am not sure if Total is the right type for this route. Total assumes a total matching between one Page type and one args type, but here, both of those types are singletons, and the ClassTag mechanics don't seem to work well in this case.

TBH not sure what to do about this case yet. Maybe it could be a partial route instead, not sure.

@arturaz
Copy link
Contributor Author

arturaz commented Aug 13, 2024

I've made the requested changes and introduced Route.staticTotal. While it is possible to misuse it, you have to explicitly opt-in into it and I find it useful.

arturaz added a commit to arturaz/packages that referenced this pull request Aug 14, 2024
@@ -33,41 +33,41 @@ class BasePathSpec extends UnitSpec {

describe(s"basePath = `$basePath`") {

val homeRoute: Route[HomePage.type, Unit] = Route.static(
val homeRoute: Route.Total[HomePage.type, Unit] = Route.staticTotal(
Copy link
Owner

Choose a reason for hiding this comment

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

You've replaced static with staticTotal here, but that leaves static untested. Since static is the primary method, it should retain its tests, and staticTotal tests should be added either in this file or separately.

Comment on lines 491 to 500
* val route: Total[AppPage, Unit] = Route.staticTotal[AppPage](Foo, root / "foo" / endOfSegments)
* }}}
*
* This will think that it can route any `AppPage`, however it will not. Use [[static]] for those cases instead.
* */
def staticTotal[Page](
staticPage: Page,
pattern: PathSegment[Unit, DummyError],
basePath: String = ""
): Total[Page, Unit] = {
Copy link
Owner

Choose a reason for hiding this comment

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

So, manually specifying the AppPage in Route.staticTotal[AppPage](...) is required for the bug to manifest.

It's unfortunate, but that alone is not the entire problem. The following would also produce non-total Total routes:

case class LoginPage(spte: Option[String])

Route.staticTotal(LoginPage(step = Some("blah")), root / "login" / blah /endOfSegments)

Route.staticTotal(LoginPage(step = None), root / "login" / endOfSegments)

Scala would infer the total type to be LoginPage, but that would not be true for either of these routes, in fact there is no type that could make it a total type.

So, static pages can only be a Total route if they refer to singleton objects, AND the user did not put an incorrect type ascription. Which is the typical use case, but still, this feels rather fragile.

And yet, again, I don't see a better solution. Unless, perhaps Scala 3 macros can be used to check that the provided Page type is a singleton type? I dunno. I would be ok with that method being Scala 3 only.

As it stands with this PR, I think, most users will keep using the static method, but to add some extra warning, let's rename staticTotal to unsafeStaticTotal.

Also, can you please add a small test to show that staticTotal works as expected, and to see how exactly your a non-total version of it fails – if it's some inscrutable error, we should provide a nicer exception.

With the rename and the tests, I think we'll be able to merge it. Thanks for your work, and sorry for the delays, been very busy with another project.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

but to add some extra warning, let's rename staticTotal to unsafeStaticTotal.

OK, but I'll name it staticTotalUnsafe, just because Metals autocomplete doesn't search in the middle of the word, so unsafeStaticTotal wouldn't show up in autocomplete for .static, whilst .staticTotalUnsafe would.

Copy link
Owner

Choose a reason for hiding this comment

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

Sure ok

@arturaz
Copy link
Contributor Author

arturaz commented Aug 15, 2024

Turns out there is a way to require a type to be singleton!

https://www.scala-lang.org/api/2.13.6/scala/ValueOf.html

Thus no extra tests are needed, right?

@raquo
Copy link
Owner

raquo commented Aug 15, 2024

TIL, that's great, we should use that for the new method. Does it mean that the user won't be able to manually provide the incorrect AppPage type ascription anymore, eliminating the safety issue? If so, I guess we can even drop unsafe from the name. Still need some very basic test, including a doesNotCompile check on the incorrect usage pattern that you mentioned in the scaladoc, to prevent regressions.

@arturaz
Copy link
Contributor Author

arturaz commented Aug 16, 2024

Added compilation tests.

@arturaz
Copy link
Contributor Author

arturaz commented Aug 20, 2024

Just to clarify - is there anything left to do before merging this?

@raquo
Copy link
Owner

raquo commented Aug 20, 2024

No, everything looks good, thanks! I'll merge this and will do some other maintenance when I have time to work on OSS, probably next month.

@raquo raquo merged commit 1544628 into raquo:master Nov 9, 2024
1 check passed
@raquo
Copy link
Owner

raquo commented Nov 9, 2024

@arturaz Thank you for working on this, and for the patience! I've merged this now, and I'll include this in the next version.

I looked it over again after checking it out, and noticed that after all the revisions, getClass is not needed. I replaced it entirely with ClassTag, which we were using anyway.

I'm not 100% sure what type information Java's Class encodes, but I believe the change is 100% equivalent, because:

  1. in case of the singleton (staticTotal), the Page type can not contain generic params, so it's a simple straightforward case
  2. in all other cases, we used to get Class from ClassTag, so they must contain the same information.

If I'm missing something, and you intended to use Class for some of its capabilities that I'm not aware of, please let me know.

@arturaz
Copy link
Contributor Author

arturaz commented Nov 9, 2024 via email

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